1use crate::codegen::resolve_field;
7use crate::config::E2eConfig;
8use crate::escape::{escape_python, sanitize_filename, sanitize_ident};
9use crate::field_access::FieldResolver;
10use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup};
11use alef_core::backend::GeneratedFile;
12use alef_core::config::AlefConfig;
13use alef_core::hash::{self, CommentStyle};
14use anyhow::Result;
15use heck::{ToShoutySnakeCase, ToSnakeCase};
16use std::collections::HashMap;
17use std::fmt::Write as FmtWrite;
18use std::path::PathBuf;
19
20pub struct PythonE2eCodegen;
22
23impl super::E2eCodegen for PythonE2eCodegen {
24 fn generate(
25 &self,
26 groups: &[FixtureGroup],
27 e2e_config: &E2eConfig,
28 _alef_config: &AlefConfig,
29 ) -> Result<Vec<GeneratedFile>> {
30 let mut files = Vec::new();
31 let output_base = PathBuf::from(e2e_config.effective_output()).join("python");
32
33 files.push(GeneratedFile {
35 path: output_base.join("conftest.py"),
36 content: render_conftest(e2e_config, groups),
37 generated_header: true,
38 });
39
40 files.push(GeneratedFile {
42 path: output_base.join("__init__.py"),
43 content: String::new(),
44 generated_header: false,
45 });
46
47 files.push(GeneratedFile {
49 path: output_base.join("tests").join("__init__.py"),
50 content: String::new(),
51 generated_header: false,
52 });
53
54 let python_pkg = e2e_config.resolve_package("python");
56 let pkg_name = python_pkg
57 .as_ref()
58 .and_then(|p| p.name.as_deref())
59 .unwrap_or("kreuzcrawl");
60 let pkg_path = python_pkg
61 .as_ref()
62 .and_then(|p| p.path.as_deref())
63 .unwrap_or("../../packages/python");
64 let pkg_version = python_pkg
65 .as_ref()
66 .and_then(|p| p.version.as_deref())
67 .unwrap_or("0.1.0");
68 files.push(GeneratedFile {
69 path: output_base.join("pyproject.toml"),
70 content: render_pyproject(pkg_name, pkg_path, pkg_version, e2e_config.dep_mode),
71 generated_header: true,
72 });
73
74 for group in groups {
76 let fixtures: Vec<&Fixture> = group.fixtures.iter().collect();
77
78 if fixtures.is_empty() {
79 continue;
80 }
81
82 let filename = format!("test_{}.py", sanitize_filename(&group.category));
83 let content = render_test_file(&group.category, &fixtures, e2e_config);
84
85 files.push(GeneratedFile {
86 path: output_base.join("tests").join(filename),
87 content,
88 generated_header: true,
89 });
90 }
91
92 Ok(files)
93 }
94
95 fn language_name(&self) -> &'static str {
96 "python"
97 }
98}
99
100fn render_pyproject(
105 pkg_name: &str,
106 _pkg_path: &str,
107 pkg_version: &str,
108 dep_mode: crate::config::DependencyMode,
109) -> String {
110 let dep_spec = match dep_mode {
111 crate::config::DependencyMode::Registry => {
112 format!(
113 "dependencies = [\"{pkg_name}{pkg_version}\", \"pytest>=7.4\", \"pytest-asyncio>=0.23\", \"pytest-timeout>=2.1\"]\n"
114 )
115 }
116 crate::config::DependencyMode::Local => {
117 format!(
118 "dependencies = [\"{pkg_name}\", \"pytest>=7.4\", \"pytest-asyncio>=0.23\", \"pytest-timeout>=2.1\"]\n\
119 \n\
120 [tool.uv.sources]\n\
121 {pkg_name} = {{ workspace = true }}\n"
122 )
123 }
124 };
125
126 format!(
127 r#"[build-system]
128build-backend = "setuptools.build_meta"
129requires = ["setuptools>=68", "wheel"]
130
131[project]
132name = "{pkg_name}-e2e-tests"
133version = "0.0.0"
134description = "End-to-end tests"
135requires-python = ">=3.10"
136{dep_spec}
137[tool.setuptools]
138packages = []
139
140[tool.pytest.ini_options]
141asyncio_mode = "auto"
142testpaths = ["tests"]
143python_files = "test_*.py"
144python_functions = "test_*"
145addopts = "-v --strict-markers --tb=short"
146timeout = 300
147
148[tool.ruff.lint]
149ignore = ["PLR2004"]
150
151[tool.ruff.lint.per-file-ignores]
152"tests/**" = ["S101", "S108", "PT011", "B017"]
153"#
154 )
155}
156
157fn resolve_function_name(e2e_config: &E2eConfig) -> String {
162 resolve_function_name_for_call(&e2e_config.call)
163}
164
165fn resolve_function_name_for_call(call_config: &crate::config::CallConfig) -> String {
166 call_config
167 .overrides
168 .get("python")
169 .and_then(|o| o.function.clone())
170 .unwrap_or_else(|| call_config.function.clone())
171}
172
173fn resolve_module(e2e_config: &E2eConfig) -> String {
174 e2e_config
175 .call
176 .overrides
177 .get("python")
178 .and_then(|o| o.module.clone())
179 .unwrap_or_else(|| e2e_config.call.module.replace('-', "_"))
180}
181
182fn resolve_options_type(e2e_config: &E2eConfig) -> Option<String> {
183 e2e_config
184 .call
185 .overrides
186 .get("python")
187 .and_then(|o| o.options_type.clone())
188}
189
190fn resolve_options_via(e2e_config: &E2eConfig) -> &str {
192 e2e_config
193 .call
194 .overrides
195 .get("python")
196 .and_then(|o| o.options_via.as_deref())
197 .unwrap_or("kwargs")
198}
199
200fn resolve_enum_fields(e2e_config: &E2eConfig) -> &HashMap<String, String> {
202 static EMPTY: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
203 e2e_config
204 .call
205 .overrides
206 .get("python")
207 .map(|o| &o.enum_fields)
208 .unwrap_or(&EMPTY)
209}
210
211fn resolve_handle_nested_types(e2e_config: &E2eConfig) -> &HashMap<String, String> {
214 static EMPTY: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
215 e2e_config
216 .call
217 .overrides
218 .get("python")
219 .map(|o| &o.handle_nested_types)
220 .unwrap_or(&EMPTY)
221}
222
223fn resolve_handle_dict_types(e2e_config: &E2eConfig) -> &std::collections::HashSet<String> {
226 static EMPTY: std::sync::LazyLock<std::collections::HashSet<String>> =
227 std::sync::LazyLock::new(std::collections::HashSet::new);
228 e2e_config
229 .call
230 .overrides
231 .get("python")
232 .map(|o| &o.handle_dict_types)
233 .unwrap_or(&EMPTY)
234}
235
236fn is_skipped(fixture: &Fixture, language: &str) -> bool {
237 fixture.skip.as_ref().is_some_and(|s| s.should_skip(language))
238}
239
240fn render_conftest(e2e_config: &E2eConfig, groups: &[FixtureGroup]) -> String {
245 let module = resolve_module(e2e_config);
246 let has_http_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| f.is_http_test());
247
248 let header = hash::header(CommentStyle::Hash);
249 if has_http_fixtures {
250 format!(
251 r#"{header}"""Pytest configuration for e2e tests."""
252import pytest
253
254# Ensure the package is importable.
255# The {module} package is expected to be installed in the current environment.
256
257
258@pytest.fixture
259def client(http_test_server): # noqa: ANN001, ANN201
260 """Return a test client bound to the per-test HTTP server."""
261 return http_test_server.client()
262"#
263 )
264 } else {
265 format!(
266 r#"{header}"""Pytest configuration for e2e tests."""
267# Ensure the package is importable.
268# The {module} package is expected to be installed in the current environment.
269"#
270 )
271 }
272}
273
274fn render_test_file(category: &str, fixtures: &[&Fixture], e2e_config: &E2eConfig) -> String {
275 let mut out = String::new();
276 out.push_str(&hash::header(CommentStyle::Hash));
277 let _ = writeln!(out, "\"\"\"E2e tests for category: {category}.\"\"\"");
278
279 let module = resolve_module(e2e_config);
280 let function_name = resolve_function_name(e2e_config);
281 let options_type = resolve_options_type(e2e_config);
282 let options_via = resolve_options_via(e2e_config);
283 let enum_fields = resolve_enum_fields(e2e_config);
284 let handle_nested_types = resolve_handle_nested_types(e2e_config);
285 let handle_dict_types = resolve_handle_dict_types(e2e_config);
286 let field_resolver = FieldResolver::new(
287 &e2e_config.fields,
288 &e2e_config.fields_optional,
289 &e2e_config.result_fields,
290 &e2e_config.fields_array,
291 );
292
293 let has_error_test = fixtures
294 .iter()
295 .any(|f| f.assertions.iter().any(|a| a.assertion_type == "error"));
296 let has_skipped = fixtures.iter().any(|f| is_skipped(f, "python"));
297 let has_http_tests = fixtures.iter().any(|f| f.is_http_test());
298
299 let is_async = fixtures.iter().any(|f| {
301 let cc = e2e_config.resolve_call(f.call.as_deref());
302 cc.r#async
303 }) || e2e_config.call.r#async;
304 let needs_pytest = has_error_test || has_skipped || is_async;
305
306 let needs_json_import = options_via == "json"
308 && fixtures.iter().any(|f| {
309 e2e_config
310 .call
311 .args
312 .iter()
313 .any(|arg| arg.arg_type == "json_object" && !resolve_field(&f.input, &arg.field).is_null())
314 });
315
316 let needs_os_import = e2e_config.call.args.iter().any(|arg| arg.arg_type == "mock_url");
318
319 let needs_re_import = has_http_tests
321 && fixtures.iter().any(|f| {
322 f.http
323 .as_ref()
324 .is_some_and(|h| h.expected_response.headers.values().any(|v| v == "<<uuid>>"))
325 });
326
327 let needs_options_type = options_via == "kwargs"
329 && options_type.is_some()
330 && fixtures.iter().any(|f| {
331 e2e_config
332 .call
333 .args
334 .iter()
335 .any(|arg| arg.arg_type == "json_object" && !resolve_field(&f.input, &arg.field).is_null())
336 });
337
338 let mut used_enum_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
340 if needs_options_type && !enum_fields.is_empty() {
341 for fixture in fixtures.iter() {
342 for arg in &e2e_config.call.args {
343 if arg.arg_type == "json_object" {
344 let value = resolve_field(&fixture.input, &arg.field);
345 if let Some(obj) = value.as_object() {
346 for key in obj.keys() {
347 if let Some(enum_type) = enum_fields.get(key) {
348 used_enum_types.insert(enum_type.clone());
349 }
350 }
351 }
352 }
353 }
354 }
355 }
356
357 let mut stdlib_imports: Vec<String> = Vec::new();
361 let mut thirdparty_bare: Vec<String> = Vec::new();
362 let mut thirdparty_from: Vec<String> = Vec::new();
363
364 if needs_json_import {
365 stdlib_imports.push("import json".to_string());
366 }
367
368 if needs_os_import {
369 stdlib_imports.push("import os".to_string());
370 }
371
372 if needs_re_import {
373 stdlib_imports.push("import re".to_string());
374 }
375
376 if needs_pytest {
377 thirdparty_bare.push("import pytest".to_string());
378 }
379
380 let has_non_http_fixtures = fixtures.iter().any(|f| !f.is_http_test());
382 if has_non_http_fixtures {
383 let handle_constructors: Vec<String> = e2e_config
385 .call
386 .args
387 .iter()
388 .filter(|arg| arg.arg_type == "handle")
389 .map(|arg| format!("create_{}", arg.name.to_snake_case()))
390 .collect();
391
392 let mut import_names: Vec<String> = Vec::new();
396 for fixture in fixtures.iter() {
397 let cc = e2e_config.resolve_call(fixture.call.as_deref());
398 let fn_name = resolve_function_name_for_call(cc);
399 if !import_names.contains(&fn_name) {
400 import_names.push(fn_name);
401 }
402 }
403 if import_names.is_empty() {
406 import_names.push(function_name.clone());
407 }
408 for ctor in &handle_constructors {
409 if !import_names.contains(ctor) {
410 import_names.push(ctor.clone());
411 }
412 }
413
414 let needs_config_import = e2e_config.call.args.iter().any(|arg| {
416 arg.arg_type == "handle"
417 && fixtures.iter().any(|f| {
418 let val = resolve_field(&f.input, &arg.field);
419 !val.is_null() && val.as_object().is_some_and(|o| !o.is_empty())
420 })
421 });
422 if needs_config_import {
423 let config_class = options_type.as_deref().unwrap_or("CrawlConfig");
424 if !import_names.contains(&config_class.to_string()) {
425 import_names.push(config_class.to_string());
426 }
427 }
428
429 if !handle_nested_types.is_empty() {
431 let mut used_nested_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
432 for fixture in fixtures.iter() {
433 for arg in &e2e_config.call.args {
434 if arg.arg_type == "handle" {
435 let config_value = resolve_field(&fixture.input, &arg.field);
436 if let Some(obj) = config_value.as_object() {
437 for key in obj.keys() {
438 if let Some(type_name) = handle_nested_types.get(key) {
439 if obj[key].is_object() {
440 used_nested_types.insert(type_name.clone());
441 }
442 }
443 }
444 }
445 }
446 }
447 }
448 for type_name in used_nested_types {
449 if !import_names.contains(&type_name) {
450 import_names.push(type_name);
451 }
452 }
453 }
454
455 for fixture in fixtures.iter() {
457 for assertion in &fixture.assertions {
458 if assertion.assertion_type == "method_result" {
459 if let Some(method_name) = &assertion.method {
460 let import = python_method_helper_import(method_name);
461 if let Some(name) = import {
462 if !import_names.contains(&name) {
463 import_names.push(name);
464 }
465 }
466 }
467 }
468 }
469 }
470
471 if let (true, Some(opts_type)) = (needs_options_type, &options_type) {
472 import_names.push(opts_type.clone());
473 thirdparty_from.push(format!("from {module} import {}", import_names.join(", ")));
474 if !used_enum_types.is_empty() {
476 let enum_mod = e2e_config
477 .call
478 .overrides
479 .get("python")
480 .and_then(|o| o.enum_module.as_deref())
481 .unwrap_or(&module);
482 let enum_names: Vec<&String> = used_enum_types.iter().collect();
483 thirdparty_from.push(format!(
484 "from {enum_mod} import {}",
485 enum_names.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
486 ));
487 }
488 } else {
489 thirdparty_from.push(format!("from {module} import {}", import_names.join(", ")));
490 }
491 }
492
493 stdlib_imports.sort();
494 thirdparty_bare.sort();
495 thirdparty_from.sort();
496
497 if !stdlib_imports.is_empty() {
499 for imp in &stdlib_imports {
500 let _ = writeln!(out, "{imp}");
501 }
502 let _ = writeln!(out);
503 }
504 for imp in &thirdparty_bare {
506 let _ = writeln!(out, "{imp}");
507 }
508 for imp in &thirdparty_from {
509 let _ = writeln!(out, "{imp}");
510 }
511 let _ = writeln!(out);
513 let _ = writeln!(out);
514
515 for fixture in fixtures {
516 if fixture.is_http_test() {
517 render_http_test_function(&mut out, fixture);
518 } else {
519 render_test_function(
520 &mut out,
521 fixture,
522 e2e_config,
523 options_type.as_deref(),
524 options_via,
525 enum_fields,
526 handle_nested_types,
527 handle_dict_types,
528 &field_resolver,
529 );
530 }
531 let _ = writeln!(out);
532 }
533
534 out
535}
536
537fn render_http_test_function(out: &mut String, fixture: &Fixture) {
548 let Some(http) = &fixture.http else {
549 return;
550 };
551
552 let fn_name = sanitize_ident(&fixture.id);
553 let description = &fixture.description;
554 let desc_with_period = if description.ends_with('.') {
555 description.to_string()
556 } else {
557 format!("{description}.")
558 };
559
560 if is_skipped(fixture, "python") {
561 let reason = fixture
562 .skip
563 .as_ref()
564 .and_then(|s| s.reason.as_deref())
565 .unwrap_or("skipped for python");
566 let _ = writeln!(out, "@pytest.mark.skip(reason=\"{reason}\")");
567 }
568
569 let _ = writeln!(out, "def test_{fn_name}(client) -> None:");
570 let _ = writeln!(out, " \"\"\"{desc_with_period}\"\"\"");
571
572 let method = http.request.method.to_lowercase();
574 let path = &http.request.path;
575
576 let mut call_kwargs: Vec<String> = Vec::new();
578
579 if let Some(body) = &http.request.body {
581 let py_body = json_to_python_literal(body);
582 call_kwargs.push(format!(" json={py_body},"));
583 }
584
585 if !http.request.headers.is_empty() {
587 let entries: Vec<String> = http
588 .request
589 .headers
590 .iter()
591 .map(|(k, v)| format!(" \"{}\": \"{}\",", escape_python(k), escape_python(v)))
592 .collect();
593 let headers_block = entries.join("\n");
594 call_kwargs.push(format!(" headers={{\n{headers_block}\n }},"));
595 }
596
597 if !http.request.query_params.is_empty() {
599 let entries: Vec<String> = http
600 .request
601 .query_params
602 .iter()
603 .map(|(k, v)| format!(" \"{}\": {},", escape_python(k), json_to_python_literal(v)))
604 .collect();
605 let params_block = entries.join("\n");
606 call_kwargs.push(format!(" params={{\n{params_block}\n }},"));
607 }
608
609 if !http.request.cookies.is_empty() {
611 let entries: Vec<String> = http
612 .request
613 .cookies
614 .iter()
615 .map(|(k, v)| format!(" \"{}\": \"{}\",", escape_python(k), escape_python(v)))
616 .collect();
617 let cookies_block = entries.join("\n");
618 call_kwargs.push(format!(" cookies={{\n{cookies_block}\n }},"));
619 }
620
621 if call_kwargs.is_empty() {
622 let _ = writeln!(out, " response = client.{method}(\"{path}\")");
623 } else {
624 let _ = writeln!(out, " response = client.{method}(");
625 let _ = writeln!(out, " \"{path}\",");
626 for kwarg in &call_kwargs {
627 let _ = writeln!(out, "{kwarg}");
628 }
629 let _ = writeln!(out, " )");
630 }
631
632 let status = http.expected_response.status_code;
634 let _ = writeln!(out, " assert response.status_code == {status} # noqa: S101");
635
636 if let Some(expected_body) = &http.expected_response.body {
638 let py_val = json_to_python_literal(expected_body);
639 let _ = writeln!(out, " data = response.json()");
640 let _ = writeln!(out, " assert data == {py_val} # noqa: S101");
641 } else if let Some(partial) = &http.expected_response.body_partial {
642 let _ = writeln!(out, " data = response.json()");
643 if let Some(obj) = partial.as_object() {
644 for (key, val) in obj {
645 let py_val = json_to_python_literal(val);
646 let escaped_key = escape_python(key);
647 let _ = writeln!(out, " assert data[\"{escaped_key}\"] == {py_val} # noqa: S101");
648 }
649 }
650 }
651
652 for (header_name, header_value) in &http.expected_response.headers {
654 let lower_name = header_name.to_lowercase();
655 let escaped_name = escape_python(&lower_name);
656 match header_value.as_str() {
657 "<<present>>" => {
658 let _ = writeln!(out, " assert \"{escaped_name}\" in response.headers # noqa: S101");
659 }
660 "<<absent>>" => {
661 let _ = writeln!(
662 out,
663 " assert response.headers.get(\"{escaped_name}\") is None # noqa: S101"
664 );
665 }
666 "<<uuid>>" => {
667 let _ = writeln!(
668 out,
669 " assert re.match(r'^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$', response.headers[\"{escaped_name}\"]) # noqa: S101"
670 );
671 }
672 exact => {
673 let escaped_val = escape_python(exact);
674 let _ = writeln!(
675 out,
676 " assert response.headers[\"{escaped_name}\"] == \"{escaped_val}\" # noqa: S101"
677 );
678 }
679 }
680 }
681
682 if let Some(validation_errors) = &http.expected_response.validation_errors {
684 if !validation_errors.is_empty() {
685 let _ = writeln!(out, " errors = response.json().get(\"detail\", [])");
686 for ve in validation_errors {
687 let loc_py: Vec<String> = ve.loc.iter().map(|s| format!("\"{}\"", escape_python(s))).collect();
688 let loc_str = loc_py.join(", ");
689 let escaped_msg = escape_python(&ve.msg);
690 let _ = writeln!(
691 out,
692 " assert any(e[\"loc\"] == [{loc_str}] and \"{escaped_msg}\" in e[\"msg\"] for e in errors) # noqa: S101"
693 );
694 }
695 }
696 }
697}
698
699#[allow(clippy::too_many_arguments)]
704fn render_test_function(
705 out: &mut String,
706 fixture: &Fixture,
707 e2e_config: &E2eConfig,
708 options_type: Option<&str>,
709 options_via: &str,
710 enum_fields: &HashMap<String, String>,
711 handle_nested_types: &HashMap<String, String>,
712 handle_dict_types: &std::collections::HashSet<String>,
713 field_resolver: &FieldResolver,
714) {
715 let fn_name = sanitize_ident(&fixture.id);
716 let description = &fixture.description;
717 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
718 let function_name = resolve_function_name_for_call(call_config);
719 let result_var = &call_config.result_var;
720
721 let desc_with_period = if description.ends_with('.') {
722 description.to_string()
723 } else {
724 format!("{description}.")
725 };
726
727 if is_skipped(fixture, "python") {
729 let reason = fixture
730 .skip
731 .as_ref()
732 .and_then(|s| s.reason.as_deref())
733 .unwrap_or("skipped for python");
734 let _ = writeln!(out, "@pytest.mark.skip(reason=\"{reason}\")");
735 }
736
737 let is_async = call_config.r#async;
738 if is_async {
739 let _ = writeln!(out, "@pytest.mark.asyncio");
740 let _ = writeln!(out, "async def test_{fn_name}() -> None:");
741 } else {
742 let _ = writeln!(out, "def test_{fn_name}() -> None:");
743 }
744 let _ = writeln!(out, " \"\"\"{desc_with_period}\"\"\"");
745
746 let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
748
749 let mut arg_bindings = Vec::new();
751 let mut kwarg_exprs = Vec::new();
752 for arg in &call_config.args {
753 let var_name = &arg.name;
754
755 if arg.arg_type == "handle" {
756 let constructor_name = format!("create_{}", arg.name.to_snake_case());
759 let config_value = resolve_field(&fixture.input, &arg.field);
760 if config_value.is_null()
761 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
762 {
763 arg_bindings.push(format!(" {var_name} = {constructor_name}(None)"));
764 } else if let Some(obj) = config_value.as_object() {
765 let kwargs: Vec<String> = obj
769 .iter()
770 .map(|(k, v)| {
771 let snake_key = k.to_snake_case();
772 let py_val = if let Some(type_name) = handle_nested_types.get(k) {
773 if let Some(nested_obj) = v.as_object() {
775 if nested_obj.is_empty() {
776 format!("{type_name}()")
778 } else if handle_dict_types.contains(k) {
779 json_to_python_literal(v)
784 } else {
785 let nested_kwargs: Vec<String> = nested_obj
787 .iter()
788 .map(|(nk, nv)| {
789 let nested_snake_key = nk.to_snake_case();
790 format!("{nested_snake_key}={}", json_to_python_literal(nv))
791 })
792 .collect();
793 format!("{type_name}({})", nested_kwargs.join(", "))
794 }
795 } else {
796 json_to_python_literal(v)
798 }
799 } else if k == "request_timeout" {
800 if let Some(ms) = v.as_u64() {
806 format!("{}", ms / 1000)
807 } else {
808 json_to_python_literal(v)
809 }
810 } else {
811 json_to_python_literal(v)
812 };
813 format!("{snake_key}={py_val}")
814 })
815 .collect();
816 let config_class = options_type.unwrap_or("CrawlConfig");
818 let single_line = format!(" {var_name}_config = {config_class}({})", kwargs.join(", "));
819 if single_line.len() <= 120 {
820 arg_bindings.push(single_line);
821 } else {
822 let mut lines = format!(" {var_name}_config = {config_class}(\n");
824 for kw in &kwargs {
825 lines.push_str(&format!(" {kw},\n"));
826 }
827 lines.push_str(" )");
828 arg_bindings.push(lines);
829 }
830 arg_bindings.push(format!(" {var_name} = {constructor_name}({var_name}_config)"));
831 } else {
832 let literal = json_to_python_literal(config_value);
833 arg_bindings.push(format!(" {var_name} = {constructor_name}({literal})"));
834 }
835 kwarg_exprs.push(format!("{var_name}={var_name}"));
836 continue;
837 }
838
839 if arg.arg_type == "mock_url" {
840 let fixture_id = &fixture.id;
841 arg_bindings.push(format!(
842 " {var_name} = os.environ['MOCK_SERVER_URL'] + '/fixtures/{fixture_id}'"
843 ));
844 kwarg_exprs.push(format!("{var_name}={var_name}"));
845 continue;
846 }
847
848 let value = resolve_field(&fixture.input, &arg.field);
849
850 if value.is_null() && arg.optional {
851 continue;
852 }
853
854 if arg.arg_type == "json_object" && !value.is_null() {
856 match options_via {
857 "dict" => {
858 let literal = json_to_python_literal(value);
860 let noqa = if literal.contains("/tmp/") {
861 " # noqa: S108"
862 } else {
863 ""
864 };
865 arg_bindings.push(format!(" {var_name} = {literal}{noqa}"));
866 kwarg_exprs.push(format!("{var_name}={var_name}"));
867 continue;
868 }
869 "json" => {
870 let json_str = serde_json::to_string(value).unwrap_or_default();
872 let escaped = escape_python(&json_str);
873 arg_bindings.push(format!(" {var_name} = json.loads(\"{escaped}\")"));
874 kwarg_exprs.push(format!("{var_name}={var_name}"));
875 continue;
876 }
877 _ => {
878 if let (Some(opts_type), Some(obj)) = (options_type, value.as_object()) {
880 let kwargs: Vec<String> = obj
881 .iter()
882 .map(|(k, v)| {
883 let snake_key = k.to_snake_case();
884 let py_val = if let Some(enum_type) = enum_fields.get(k) {
885 if let Some(s) = v.as_str() {
887 let upper_val = s.to_shouty_snake_case();
888 format!("{enum_type}.{upper_val}")
889 } else {
890 json_to_python_literal(v)
891 }
892 } else {
893 json_to_python_literal(v)
894 };
895 format!("{snake_key}={py_val}")
896 })
897 .collect();
898 let constructor = format!("{opts_type}({})", kwargs.join(", "));
899 arg_bindings.push(format!(" {var_name} = {constructor}"));
900 kwarg_exprs.push(format!("{var_name}={var_name}"));
901 continue;
902 }
903 }
904 }
905 }
906
907 if value.is_null() && !arg.optional {
909 let default_val = match arg.arg_type.as_str() {
910 "string" => "\"\"".to_string(),
911 "int" | "integer" => "0".to_string(),
912 "float" | "number" => "0.0".to_string(),
913 "bool" | "boolean" => "False".to_string(),
914 _ => "None".to_string(),
915 };
916 arg_bindings.push(format!(" {var_name} = {default_val}"));
917 kwarg_exprs.push(format!("{var_name}={var_name}"));
918 continue;
919 }
920
921 let literal = json_to_python_literal(value);
922 let noqa = if literal.contains("/tmp/") {
923 " # noqa: S108"
924 } else {
925 ""
926 };
927 arg_bindings.push(format!(" {var_name} = {literal}{noqa}"));
928 kwarg_exprs.push(format!("{var_name}={var_name}"));
929 }
930
931 if let Some(visitor_spec) = &fixture.visitor {
933 let _ = writeln!(out, " class _TestVisitor:");
934 for (method_name, action) in &visitor_spec.callbacks {
935 emit_python_visitor_method(out, method_name, action);
936 }
937 kwarg_exprs.push("visitor=_TestVisitor()".to_string());
938 }
939
940 for binding in &arg_bindings {
941 let _ = writeln!(out, "{binding}");
942 }
943
944 let call_args = kwarg_exprs.join(", ");
945 let await_prefix = if is_async { "await " } else { "" };
946 let call_expr = format!("{await_prefix}{function_name}({call_args})");
947
948 if has_error_assertion {
949 let error_assertion = fixture.assertions.iter().find(|a| a.assertion_type == "error");
951 let has_message = error_assertion
952 .and_then(|a| a.value.as_ref())
953 .and_then(|v| v.as_str())
954 .is_some();
955
956 if has_message {
957 let _ = writeln!(out, " with pytest.raises(Exception) as exc_info: # noqa: B017");
958 let _ = writeln!(out, " {call_expr}");
959 if let Some(msg) = error_assertion.and_then(|a| a.value.as_ref()).and_then(|v| v.as_str()) {
960 let escaped = escape_python(msg);
961 let _ = writeln!(out, " assert \"{escaped}\" in str(exc_info.value) # noqa: S101");
962 }
963 } else {
964 let _ = writeln!(out, " with pytest.raises(Exception): # noqa: B017");
965 let _ = writeln!(out, " {call_expr}");
966 }
967
968 return;
971 }
972
973 let has_usable_assertion = fixture.assertions.iter().any(|a| {
975 if a.assertion_type == "not_error" || a.assertion_type == "error" {
976 return false;
977 }
978 match &a.field {
979 Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
980 _ => true,
981 }
982 });
983 let py_result_var = if has_usable_assertion {
984 result_var.to_string()
985 } else {
986 "_".to_string()
987 };
988 let _ = writeln!(out, " {py_result_var} = {call_expr}");
989
990 let fields_enum = &e2e_config.fields_enum;
991 for assertion in &fixture.assertions {
992 if assertion.assertion_type == "not_error" {
993 continue;
995 }
996 render_assertion(out, assertion, result_var, field_resolver, fields_enum);
997 }
998}
999
1000fn json_to_python_literal(value: &serde_json::Value) -> String {
1005 match value {
1006 serde_json::Value::Null => "None".to_string(),
1007 serde_json::Value::Bool(true) => "True".to_string(),
1008 serde_json::Value::Bool(false) => "False".to_string(),
1009 serde_json::Value::Number(n) => n.to_string(),
1010 serde_json::Value::String(s) => python_string_literal(s),
1011 serde_json::Value::Array(arr) => {
1012 let items: Vec<String> = arr.iter().map(json_to_python_literal).collect();
1013 format!("[{}]", items.join(", "))
1014 }
1015 serde_json::Value::Object(map) => {
1016 let items: Vec<String> = map
1017 .iter()
1018 .map(|(k, v)| format!("\"{}\": {}", escape_python(k), json_to_python_literal(v)))
1019 .collect();
1020 format!("{{{}}}", items.join(", "))
1021 }
1022 }
1023}
1024
1025fn render_assertion(
1030 out: &mut String,
1031 assertion: &Assertion,
1032 result_var: &str,
1033 field_resolver: &FieldResolver,
1034 fields_enum: &std::collections::HashSet<String>,
1035) {
1036 if let Some(f) = &assertion.field {
1038 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1039 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
1040 return;
1041 }
1042 }
1043
1044 let field_access = match &assertion.field {
1045 Some(f) if !f.is_empty() => field_resolver.accessor(f, "python", result_var),
1046 _ => result_var.to_string(),
1047 };
1048
1049 let field_is_enum = assertion.field.as_deref().is_some_and(|f| {
1060 if fields_enum.contains(f) {
1061 return true;
1062 }
1063 let resolved = field_resolver.resolve(f);
1064 if fields_enum.contains(resolved) {
1065 return true;
1066 }
1067 field_resolver.accessor(f, "python", result_var).contains("[0]")
1072 });
1073
1074 let field_is_optional = match &assertion.field {
1077 Some(f) if !f.is_empty() => {
1078 let resolved = field_resolver.resolve(f);
1079 field_resolver.is_optional(resolved)
1080 }
1081 _ => false,
1082 };
1083
1084 match assertion.assertion_type.as_str() {
1085 "error" | "not_error" => {
1086 }
1088 "equals" => {
1089 if let Some(val) = &assertion.value {
1090 let expected = value_to_python_string(val);
1091 let op = if val.is_boolean() || val.is_null() { "is" } else { "==" };
1093 if val.is_string() {
1096 let _ = writeln!(out, " assert {field_access}.strip() {op} {expected} # noqa: S101");
1097 } else {
1098 let _ = writeln!(out, " assert {field_access} {op} {expected} # noqa: S101");
1099 }
1100 }
1101 }
1102 "contains" => {
1103 if let Some(val) = &assertion.value {
1104 let expected = value_to_python_string(val);
1105 let cmp_expr = if field_is_enum && val.is_string() {
1107 format!("str({field_access}).lower()")
1108 } else {
1109 field_access.clone()
1110 };
1111 if field_is_optional {
1112 let _ = writeln!(out, " assert {field_access} is not None # noqa: S101");
1113 let _ = writeln!(out, " assert {expected} in {cmp_expr} # noqa: S101");
1114 } else {
1115 let _ = writeln!(out, " assert {expected} in {cmp_expr} # noqa: S101");
1116 }
1117 }
1118 }
1119 "contains_all" => {
1120 if let Some(values) = &assertion.values {
1121 for val in values {
1122 let expected = value_to_python_string(val);
1123 let cmp_expr = if field_is_enum && val.is_string() {
1125 format!("str({field_access}).lower()")
1126 } else {
1127 field_access.clone()
1128 };
1129 if field_is_optional {
1130 let _ = writeln!(out, " assert {field_access} is not None # noqa: S101");
1131 let _ = writeln!(out, " assert {expected} in {cmp_expr} # noqa: S101");
1132 } else {
1133 let _ = writeln!(out, " assert {expected} in {cmp_expr} # noqa: S101");
1134 }
1135 }
1136 }
1137 }
1138 "not_contains" => {
1139 if let Some(val) = &assertion.value {
1140 let expected = value_to_python_string(val);
1141 let cmp_expr = if field_is_enum && val.is_string() {
1143 format!("str({field_access}).lower()")
1144 } else {
1145 field_access.clone()
1146 };
1147 if field_is_optional {
1148 let _ = writeln!(
1149 out,
1150 " assert {field_access} is None or {expected} not in {cmp_expr} # noqa: S101"
1151 );
1152 } else {
1153 let _ = writeln!(out, " assert {expected} not in {cmp_expr} # noqa: S101");
1154 }
1155 }
1156 }
1157 "not_empty" => {
1158 let _ = writeln!(out, " assert {field_access} # noqa: S101");
1159 }
1160 "is_empty" => {
1161 let _ = writeln!(out, " assert not {field_access} # noqa: S101");
1162 }
1163 "contains_any" => {
1164 if let Some(values) = &assertion.values {
1165 let items: Vec<String> = values.iter().map(value_to_python_string).collect();
1166 let list_str = items.join(", ");
1167 let cmp_expr = if field_is_enum {
1169 format!("str({field_access}).lower()")
1170 } else {
1171 field_access.clone()
1172 };
1173 if field_is_optional {
1174 let _ = writeln!(out, " assert {field_access} is not None # noqa: S101");
1175 let _ = writeln!(
1176 out,
1177 " assert any(v in {cmp_expr} for v in [{list_str}]) # noqa: S101"
1178 );
1179 } else {
1180 let _ = writeln!(
1181 out,
1182 " assert any(v in {cmp_expr} for v in [{list_str}]) # noqa: S101"
1183 );
1184 }
1185 }
1186 }
1187 "greater_than" => {
1188 if let Some(val) = &assertion.value {
1189 let expected = value_to_python_string(val);
1190 let _ = writeln!(out, " assert {field_access} > {expected} # noqa: S101");
1191 }
1192 }
1193 "less_than" => {
1194 if let Some(val) = &assertion.value {
1195 let expected = value_to_python_string(val);
1196 let _ = writeln!(out, " assert {field_access} < {expected} # noqa: S101");
1197 }
1198 }
1199 "greater_than_or_equal" | "min" => {
1200 if let Some(val) = &assertion.value {
1201 let expected = value_to_python_string(val);
1202 let _ = writeln!(out, " assert {field_access} >= {expected} # noqa: S101");
1203 }
1204 }
1205 "less_than_or_equal" | "max" => {
1206 if let Some(val) = &assertion.value {
1207 let expected = value_to_python_string(val);
1208 let _ = writeln!(out, " assert {field_access} <= {expected} # noqa: S101");
1209 }
1210 }
1211 "starts_with" => {
1212 if let Some(val) = &assertion.value {
1213 let expected = value_to_python_string(val);
1214 let _ = writeln!(out, " assert {field_access}.startswith({expected}) # noqa: S101");
1215 }
1216 }
1217 "ends_with" => {
1218 if let Some(val) = &assertion.value {
1219 let expected = value_to_python_string(val);
1220 let _ = writeln!(out, " assert {field_access}.endswith({expected}) # noqa: S101");
1221 }
1222 }
1223 "min_length" => {
1224 if let Some(val) = &assertion.value {
1225 if let Some(n) = val.as_u64() {
1226 let _ = writeln!(out, " assert len({field_access}) >= {n} # noqa: S101");
1227 }
1228 }
1229 }
1230 "max_length" => {
1231 if let Some(val) = &assertion.value {
1232 if let Some(n) = val.as_u64() {
1233 let _ = writeln!(out, " assert len({field_access}) <= {n} # noqa: S101");
1234 }
1235 }
1236 }
1237 "count_min" => {
1238 if let Some(val) = &assertion.value {
1239 if let Some(n) = val.as_u64() {
1240 let _ = writeln!(out, " assert len({field_access}) >= {n} # noqa: S101");
1241 }
1242 }
1243 }
1244 "count_equals" => {
1245 if let Some(val) = &assertion.value {
1246 if let Some(n) = val.as_u64() {
1247 let _ = writeln!(out, " assert len({field_access}) == {n} # noqa: S101");
1248 }
1249 }
1250 }
1251 "is_true" => {
1252 let _ = writeln!(out, " assert {field_access} is True # noqa: S101");
1253 }
1254 "is_false" => {
1255 let _ = writeln!(out, " assert not {field_access} # noqa: S101");
1256 }
1257 "method_result" => {
1258 if let Some(method_name) = &assertion.method {
1259 let call_expr = build_python_method_call(result_var, method_name, assertion.args.as_ref());
1260 let check = assertion.check.as_deref().unwrap_or("is_true");
1261 match check {
1262 "equals" => {
1263 if let Some(val) = &assertion.value {
1264 if val.is_boolean() {
1265 if val.as_bool() == Some(true) {
1266 let _ = writeln!(out, " assert {call_expr} is True # noqa: S101");
1267 } else {
1268 let _ = writeln!(out, " assert {call_expr} is False # noqa: S101");
1269 }
1270 } else {
1271 let expected = value_to_python_string(val);
1272 let _ = writeln!(out, " assert {call_expr} == {expected} # noqa: S101");
1273 }
1274 }
1275 }
1276 "is_true" => {
1277 let _ = writeln!(out, " assert {call_expr} # noqa: S101");
1278 }
1279 "is_false" => {
1280 let _ = writeln!(out, " assert not {call_expr} # noqa: S101");
1281 }
1282 "greater_than_or_equal" => {
1283 if let Some(val) = &assertion.value {
1284 let n = val.as_u64().unwrap_or(0);
1285 let _ = writeln!(out, " assert {call_expr} >= {n} # noqa: S101");
1286 }
1287 }
1288 "count_min" => {
1289 if let Some(val) = &assertion.value {
1290 let n = val.as_u64().unwrap_or(0);
1291 let _ = writeln!(out, " assert len({call_expr}) >= {n} # noqa: S101");
1292 }
1293 }
1294 "contains" => {
1295 if let Some(val) = &assertion.value {
1296 let expected = value_to_python_string(val);
1297 let _ = writeln!(out, " assert {expected} in {call_expr} # noqa: S101");
1298 }
1299 }
1300 "is_error" => {
1301 let _ = writeln!(out, " with pytest.raises(Exception): # noqa: B017");
1302 let _ = writeln!(out, " {call_expr}");
1303 }
1304 other_check => {
1305 panic!("unsupported method_result check type: {other_check}");
1306 }
1307 }
1308 } else {
1309 panic!("method_result assertion missing 'method' field");
1310 }
1311 }
1312 "matches_regex" => {
1313 if let Some(val) = &assertion.value {
1314 let expected = value_to_python_string(val);
1315 let _ = writeln!(out, " import re # noqa: PLC0415");
1316 let _ = writeln!(
1317 out,
1318 " assert re.search({expected}, {field_access}) is not None # noqa: S101"
1319 );
1320 }
1321 }
1322 other => {
1323 panic!("unsupported assertion type: {other}");
1324 }
1325 }
1326}
1327
1328fn build_python_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
1331 match method_name {
1332 "root_child_count" => format!("{result_var}.root_node().child_count()"),
1333 "root_node_type" => format!("{result_var}.root_node().kind()"),
1334 "named_children_count" => format!("{result_var}.root_node().named_child_count()"),
1335 "has_error_nodes" => format!("tree_has_error_nodes({result_var})"),
1336 "error_count" | "tree_error_count" => format!("tree_error_count({result_var})"),
1337 "tree_to_sexp" => format!("tree_to_sexp({result_var})"),
1338 "contains_node_type" => {
1339 let node_type = args
1340 .and_then(|a| a.get("node_type"))
1341 .and_then(|v| v.as_str())
1342 .unwrap_or("");
1343 format!("tree_contains_node_type({result_var}, \"{node_type}\")")
1344 }
1345 "find_nodes_by_type" => {
1346 let node_type = args
1347 .and_then(|a| a.get("node_type"))
1348 .and_then(|v| v.as_str())
1349 .unwrap_or("");
1350 format!("find_nodes_by_type({result_var}, \"{node_type}\")")
1351 }
1352 "run_query" => {
1353 let query_source = args
1354 .and_then(|a| a.get("query_source"))
1355 .and_then(|v| v.as_str())
1356 .unwrap_or("");
1357 let language = args
1358 .and_then(|a| a.get("language"))
1359 .and_then(|v| v.as_str())
1360 .unwrap_or("");
1361 format!("run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1362 }
1363 _ => {
1364 if let Some(args_val) = args {
1365 let arg_str = args_val
1366 .as_object()
1367 .map(|obj| {
1368 obj.iter()
1369 .map(|(k, v)| format!("{}={}", k, value_to_python_string(v)))
1370 .collect::<Vec<_>>()
1371 .join(", ")
1372 })
1373 .unwrap_or_default();
1374 format!("{result_var}.{method_name}({arg_str})")
1375 } else {
1376 format!("{result_var}.{method_name}()")
1377 }
1378 }
1379 }
1380}
1381
1382fn python_method_helper_import(method_name: &str) -> Option<String> {
1385 match method_name {
1386 "has_error_nodes" => Some("tree_has_error_nodes".to_string()),
1387 "error_count" | "tree_error_count" => Some("tree_error_count".to_string()),
1388 "tree_to_sexp" => Some("tree_to_sexp".to_string()),
1389 "contains_node_type" => Some("tree_contains_node_type".to_string()),
1390 "find_nodes_by_type" => Some("find_nodes_by_type".to_string()),
1391 "run_query" => Some("run_query".to_string()),
1392 _ => None,
1394 }
1395}
1396
1397fn value_to_python_string(value: &serde_json::Value) -> String {
1398 match value {
1399 serde_json::Value::String(s) => python_string_literal(s),
1400 serde_json::Value::Bool(true) => "True".to_string(),
1401 serde_json::Value::Bool(false) => "False".to_string(),
1402 serde_json::Value::Number(n) => n.to_string(),
1403 serde_json::Value::Null => "None".to_string(),
1404 other => python_string_literal(&other.to_string()),
1405 }
1406}
1407
1408fn python_string_literal(s: &str) -> String {
1411 if s.contains('"') && !s.contains('\'') {
1412 let escaped = s
1414 .replace('\\', "\\\\")
1415 .replace('\'', "\\'")
1416 .replace('\n', "\\n")
1417 .replace('\r', "\\r")
1418 .replace('\t', "\\t");
1419 format!("'{escaped}'")
1420 } else {
1421 format!("\"{}\"", escape_python(s))
1422 }
1423}
1424
1425fn emit_python_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1427 let params = match method_name {
1428 "visit_link" => "self, ctx, href, text, title",
1429 "visit_image" => "self, ctx, src, alt, title",
1430 "visit_heading" => "self, ctx, level, text, id",
1431 "visit_code_block" => "self, ctx, lang, code",
1432 "visit_code_inline"
1433 | "visit_strong"
1434 | "visit_emphasis"
1435 | "visit_strikethrough"
1436 | "visit_underline"
1437 | "visit_subscript"
1438 | "visit_superscript"
1439 | "visit_mark"
1440 | "visit_button"
1441 | "visit_summary"
1442 | "visit_figcaption"
1443 | "visit_definition_term"
1444 | "visit_definition_description" => "self, ctx, text",
1445 "visit_text" => "self, ctx, text",
1446 "visit_list_item" => "self, ctx, ordered, marker, text",
1447 "visit_blockquote" => "self, ctx, content, depth",
1448 "visit_table_row" => "self, ctx, cells, is_header",
1449 "visit_custom_element" => "self, ctx, tag_name, html",
1450 "visit_form" => "self, ctx, action_url, method",
1451 "visit_input" => "self, ctx, input_type, name, value",
1452 "visit_audio" | "visit_video" | "visit_iframe" => "self, ctx, src",
1453 "visit_details" => "self, ctx, is_open",
1454 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
1455 "self, ctx, output, *args"
1456 }
1457 "visit_list_start" => "self, ctx, ordered, *args",
1458 "visit_list_end" => "self, ctx, ordered, output, *args",
1459 _ => "self, ctx, *args",
1460 };
1461
1462 let _ = writeln!(
1463 out,
1464 " def {method_name}({params}): # noqa: A002, ANN001, ANN202, ARG002"
1465 );
1466 match action {
1467 CallbackAction::Skip => {
1468 let _ = writeln!(out, " return \"skip\"");
1469 }
1470 CallbackAction::Continue => {
1471 let _ = writeln!(out, " return \"continue\"");
1472 }
1473 CallbackAction::PreserveHtml => {
1474 let _ = writeln!(out, " return \"preserve_html\"");
1475 }
1476 CallbackAction::Custom { output } => {
1477 let escaped = escape_python(output);
1478 let _ = writeln!(out, " return {{\"custom\": \"{escaped}\"}}");
1479 }
1480 CallbackAction::CustomTemplate { template } => {
1481 let escaped_template = template
1486 .replace('\\', "\\\\")
1487 .replace('\'', "\\'")
1488 .replace('\n', "\\n")
1489 .replace('\r', "\\r")
1490 .replace('\t', "\\t");
1491 let _ = writeln!(out, " return {{\"custom\": f'{escaped_template}'}}");
1492 }
1493 }
1494}