1use crate::config::E2eConfig;
7use crate::escape::{escape_python, sanitize_filename, sanitize_ident};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::AlefConfig;
12use alef_core::hash::{self, CommentStyle};
13use anyhow::Result;
14use heck::{ToShoutySnakeCase, ToSnakeCase};
15use std::collections::HashMap;
16use std::fmt::Write as FmtWrite;
17use std::path::PathBuf;
18
19pub struct PythonE2eCodegen;
21
22impl super::E2eCodegen for PythonE2eCodegen {
23 fn generate(
24 &self,
25 groups: &[FixtureGroup],
26 e2e_config: &E2eConfig,
27 _alef_config: &AlefConfig,
28 ) -> Result<Vec<GeneratedFile>> {
29 let mut files = Vec::new();
30 let output_base = PathBuf::from(e2e_config.effective_output()).join("python");
31
32 files.push(GeneratedFile {
34 path: output_base.join("conftest.py"),
35 content: render_conftest(e2e_config, groups),
36 generated_header: true,
37 });
38
39 files.push(GeneratedFile {
41 path: output_base.join("__init__.py"),
42 content: String::new(),
43 generated_header: false,
44 });
45
46 files.push(GeneratedFile {
48 path: output_base.join("tests").join("__init__.py"),
49 content: String::new(),
50 generated_header: false,
51 });
52
53 let python_pkg = e2e_config.resolve_package("python");
55 let pkg_name = python_pkg
56 .as_ref()
57 .and_then(|p| p.name.as_deref())
58 .unwrap_or("kreuzcrawl");
59 let pkg_path = python_pkg
60 .as_ref()
61 .and_then(|p| p.path.as_deref())
62 .unwrap_or("../../packages/python");
63 let pkg_version = python_pkg
64 .as_ref()
65 .and_then(|p| p.version.as_deref())
66 .unwrap_or("0.1.0");
67 files.push(GeneratedFile {
68 path: output_base.join("pyproject.toml"),
69 content: render_pyproject(pkg_name, pkg_path, pkg_version, e2e_config.dep_mode),
70 generated_header: true,
71 });
72
73 for group in groups {
75 let fixtures: Vec<&Fixture> = group.fixtures.iter().collect();
76
77 if fixtures.is_empty() {
78 continue;
79 }
80
81 let filename = format!("test_{}.py", sanitize_filename(&group.category));
82 let content = render_test_file(&group.category, &fixtures, e2e_config);
83
84 files.push(GeneratedFile {
85 path: output_base.join("tests").join(filename),
86 content,
87 generated_header: true,
88 });
89 }
90
91 Ok(files)
92 }
93
94 fn language_name(&self) -> &'static str {
95 "python"
96 }
97}
98
99fn render_pyproject(
104 pkg_name: &str,
105 _pkg_path: &str,
106 pkg_version: &str,
107 dep_mode: crate::config::DependencyMode,
108) -> String {
109 let dep_spec = match dep_mode {
110 crate::config::DependencyMode::Registry => {
111 format!(
112 "dependencies = [\"{pkg_name}{pkg_version}\", \"pytest>=7.4\", \"pytest-asyncio>=0.23\", \"pytest-timeout>=2.1\"]\n"
113 )
114 }
115 crate::config::DependencyMode::Local => {
116 format!(
117 "dependencies = [\"{pkg_name}\", \"pytest>=7.4\", \"pytest-asyncio>=0.23\", \"pytest-timeout>=2.1\"]\n\
118 \n\
119 [tool.uv.sources]\n\
120 {pkg_name} = {{ workspace = true }}\n"
121 )
122 }
123 };
124
125 format!(
126 r#"[build-system]
127build-backend = "setuptools.build_meta"
128requires = ["setuptools>=68", "wheel"]
129
130[project]
131name = "{pkg_name}-e2e-tests"
132version = "0.0.0"
133description = "End-to-end tests"
134requires-python = ">=3.10"
135{dep_spec}
136[tool.setuptools]
137packages = []
138
139[tool.pytest.ini_options]
140asyncio_mode = "auto"
141testpaths = ["tests"]
142python_files = "test_*.py"
143python_functions = "test_*"
144addopts = "-v --strict-markers --tb=short"
145timeout = 300
146
147[tool.ruff.lint]
148ignore = ["PLR2004"]
149
150[tool.ruff.lint.per-file-ignores]
151"tests/**" = ["S101", "S108", "PT011", "B017"]
152"#
153 )
154}
155
156fn resolve_function_name(e2e_config: &E2eConfig) -> String {
161 resolve_function_name_for_call(&e2e_config.call)
162}
163
164fn resolve_function_name_for_call(call_config: &crate::config::CallConfig) -> String {
165 call_config
166 .overrides
167 .get("python")
168 .and_then(|o| o.function.clone())
169 .unwrap_or_else(|| call_config.function.clone())
170}
171
172fn resolve_module(e2e_config: &E2eConfig) -> String {
173 e2e_config
174 .call
175 .overrides
176 .get("python")
177 .and_then(|o| o.module.clone())
178 .unwrap_or_else(|| e2e_config.call.module.replace('-', "_"))
179}
180
181fn resolve_options_type(e2e_config: &E2eConfig) -> Option<String> {
182 e2e_config
183 .call
184 .overrides
185 .get("python")
186 .and_then(|o| o.options_type.clone())
187}
188
189fn resolve_options_via(e2e_config: &E2eConfig) -> &str {
191 e2e_config
192 .call
193 .overrides
194 .get("python")
195 .and_then(|o| o.options_via.as_deref())
196 .unwrap_or("kwargs")
197}
198
199fn resolve_enum_fields(e2e_config: &E2eConfig) -> &HashMap<String, String> {
201 static EMPTY: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
202 e2e_config
203 .call
204 .overrides
205 .get("python")
206 .map(|o| &o.enum_fields)
207 .unwrap_or(&EMPTY)
208}
209
210fn resolve_handle_nested_types(e2e_config: &E2eConfig) -> &HashMap<String, String> {
213 static EMPTY: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
214 e2e_config
215 .call
216 .overrides
217 .get("python")
218 .map(|o| &o.handle_nested_types)
219 .unwrap_or(&EMPTY)
220}
221
222fn resolve_handle_dict_types(e2e_config: &E2eConfig) -> &std::collections::HashSet<String> {
225 static EMPTY: std::sync::LazyLock<std::collections::HashSet<String>> =
226 std::sync::LazyLock::new(std::collections::HashSet::new);
227 e2e_config
228 .call
229 .overrides
230 .get("python")
231 .map(|o| &o.handle_dict_types)
232 .unwrap_or(&EMPTY)
233}
234
235fn is_skipped(fixture: &Fixture, language: &str) -> bool {
236 fixture.skip.as_ref().is_some_and(|s| s.should_skip(language))
237}
238
239fn render_conftest(e2e_config: &E2eConfig, groups: &[FixtureGroup]) -> String {
244 let module = resolve_module(e2e_config);
245 let has_http_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| f.is_http_test());
246
247 let header = hash::header(CommentStyle::Hash);
248 if has_http_fixtures {
249 format!(
250 r#"{header}"""Pytest configuration for e2e tests."""
251import pytest
252
253# Ensure the package is importable.
254# The {module} package is expected to be installed in the current environment.
255
256
257@pytest.fixture
258def client(http_test_server): # noqa: ANN001, ANN201
259 """Return a test client bound to the per-test HTTP server."""
260 return http_test_server.client()
261"#
262 )
263 } else {
264 format!(
265 r#"{header}"""Pytest configuration for e2e tests."""
266# Ensure the package is importable.
267# The {module} package is expected to be installed in the current environment.
268"#
269 )
270 }
271}
272
273fn render_test_file(category: &str, fixtures: &[&Fixture], e2e_config: &E2eConfig) -> String {
274 let mut out = String::new();
275 out.push_str(&hash::header(CommentStyle::Hash));
276 let _ = writeln!(out, "\"\"\"E2e tests for category: {category}.\"\"\"");
277
278 let module = resolve_module(e2e_config);
279 let function_name = resolve_function_name(e2e_config);
280 let options_type = resolve_options_type(e2e_config);
281 let options_via = resolve_options_via(e2e_config);
282 let enum_fields = resolve_enum_fields(e2e_config);
283 let handle_nested_types = resolve_handle_nested_types(e2e_config);
284 let handle_dict_types = resolve_handle_dict_types(e2e_config);
285 let field_resolver = FieldResolver::new(
286 &e2e_config.fields,
287 &e2e_config.fields_optional,
288 &e2e_config.result_fields,
289 &e2e_config.fields_array,
290 );
291
292 let has_error_test = fixtures
293 .iter()
294 .any(|f| f.assertions.iter().any(|a| a.assertion_type == "error"));
295 let has_skipped = fixtures.iter().any(|f| is_skipped(f, "python"));
296 let has_http_tests = fixtures.iter().any(|f| f.is_http_test());
297
298 let is_async = fixtures.iter().any(|f| {
300 let cc = e2e_config.resolve_call(f.call.as_deref());
301 cc.r#async
302 }) || e2e_config.call.r#async;
303 let needs_pytest = has_error_test || has_skipped || is_async;
304
305 let needs_json_import = options_via == "json"
307 && fixtures.iter().any(|f| {
308 e2e_config
309 .call
310 .args
311 .iter()
312 .any(|arg| arg.arg_type == "json_object" && !resolve_field(&f.input, &arg.field).is_null())
313 });
314
315 let needs_os_import = e2e_config.call.args.iter().any(|arg| arg.arg_type == "mock_url");
317
318 let needs_re_import = has_http_tests
320 && fixtures.iter().any(|f| {
321 f.http
322 .as_ref()
323 .is_some_and(|h| h.expected_response.headers.values().any(|v| v == "<<uuid>>"))
324 });
325
326 let needs_options_type = options_via == "kwargs"
328 && options_type.is_some()
329 && fixtures.iter().any(|f| {
330 e2e_config
331 .call
332 .args
333 .iter()
334 .any(|arg| arg.arg_type == "json_object" && !resolve_field(&f.input, &arg.field).is_null())
335 });
336
337 let mut used_enum_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
339 if needs_options_type && !enum_fields.is_empty() {
340 for fixture in fixtures.iter() {
341 for arg in &e2e_config.call.args {
342 if arg.arg_type == "json_object" {
343 let value = resolve_field(&fixture.input, &arg.field);
344 if let Some(obj) = value.as_object() {
345 for key in obj.keys() {
346 if let Some(enum_type) = enum_fields.get(key) {
347 used_enum_types.insert(enum_type.clone());
348 }
349 }
350 }
351 }
352 }
353 }
354 }
355
356 let mut stdlib_imports: Vec<String> = Vec::new();
360 let mut thirdparty_bare: Vec<String> = Vec::new();
361 let mut thirdparty_from: Vec<String> = Vec::new();
362
363 if needs_json_import {
364 stdlib_imports.push("import json".to_string());
365 }
366
367 if needs_os_import {
368 stdlib_imports.push("import os".to_string());
369 }
370
371 if needs_re_import {
372 stdlib_imports.push("import re".to_string());
373 }
374
375 if needs_pytest {
376 thirdparty_bare.push("import pytest".to_string());
377 }
378
379 let has_non_http_fixtures = fixtures.iter().any(|f| !f.is_http_test());
381 if has_non_http_fixtures {
382 let handle_constructors: Vec<String> = e2e_config
384 .call
385 .args
386 .iter()
387 .filter(|arg| arg.arg_type == "handle")
388 .map(|arg| format!("create_{}", arg.name.to_snake_case()))
389 .collect();
390
391 let mut import_names: Vec<String> = Vec::new();
395 for fixture in fixtures.iter() {
396 let cc = e2e_config.resolve_call(fixture.call.as_deref());
397 let fn_name = resolve_function_name_for_call(cc);
398 if !import_names.contains(&fn_name) {
399 import_names.push(fn_name);
400 }
401 }
402 if import_names.is_empty() {
405 import_names.push(function_name.clone());
406 }
407 for ctor in &handle_constructors {
408 if !import_names.contains(ctor) {
409 import_names.push(ctor.clone());
410 }
411 }
412
413 let needs_config_import = e2e_config.call.args.iter().any(|arg| {
415 arg.arg_type == "handle"
416 && fixtures.iter().any(|f| {
417 let val = resolve_field(&f.input, &arg.field);
418 !val.is_null() && val.as_object().is_some_and(|o| !o.is_empty())
419 })
420 });
421 if needs_config_import {
422 let config_class = options_type.as_deref().unwrap_or("CrawlConfig");
423 if !import_names.contains(&config_class.to_string()) {
424 import_names.push(config_class.to_string());
425 }
426 }
427
428 if !handle_nested_types.is_empty() {
430 let mut used_nested_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
431 for fixture in fixtures.iter() {
432 for arg in &e2e_config.call.args {
433 if arg.arg_type == "handle" {
434 let config_value = resolve_field(&fixture.input, &arg.field);
435 if let Some(obj) = config_value.as_object() {
436 for key in obj.keys() {
437 if let Some(type_name) = handle_nested_types.get(key) {
438 if obj[key].is_object() {
439 used_nested_types.insert(type_name.clone());
440 }
441 }
442 }
443 }
444 }
445 }
446 }
447 for type_name in used_nested_types {
448 if !import_names.contains(&type_name) {
449 import_names.push(type_name);
450 }
451 }
452 }
453
454 for fixture in fixtures.iter() {
456 for assertion in &fixture.assertions {
457 if assertion.assertion_type == "method_result" {
458 if let Some(method_name) = &assertion.method {
459 let import = python_method_helper_import(method_name);
460 if let Some(name) = import {
461 if !import_names.contains(&name) {
462 import_names.push(name);
463 }
464 }
465 }
466 }
467 }
468 }
469
470 if let (true, Some(opts_type)) = (needs_options_type, &options_type) {
471 import_names.push(opts_type.clone());
472 thirdparty_from.push(format!("from {module} import {}", import_names.join(", ")));
473 if !used_enum_types.is_empty() {
475 let enum_mod = e2e_config
476 .call
477 .overrides
478 .get("python")
479 .and_then(|o| o.enum_module.as_deref())
480 .unwrap_or(&module);
481 let enum_names: Vec<&String> = used_enum_types.iter().collect();
482 thirdparty_from.push(format!(
483 "from {enum_mod} import {}",
484 enum_names.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
485 ));
486 }
487 } else {
488 thirdparty_from.push(format!("from {module} import {}", import_names.join(", ")));
489 }
490 }
491
492 stdlib_imports.sort();
493 thirdparty_bare.sort();
494 thirdparty_from.sort();
495
496 if !stdlib_imports.is_empty() {
498 for imp in &stdlib_imports {
499 let _ = writeln!(out, "{imp}");
500 }
501 let _ = writeln!(out);
502 }
503 for imp in &thirdparty_bare {
505 let _ = writeln!(out, "{imp}");
506 }
507 for imp in &thirdparty_from {
508 let _ = writeln!(out, "{imp}");
509 }
510 let _ = writeln!(out);
512 let _ = writeln!(out);
513
514 for fixture in fixtures {
515 if fixture.is_http_test() {
516 render_http_test_function(&mut out, fixture);
517 } else {
518 render_test_function(
519 &mut out,
520 fixture,
521 e2e_config,
522 options_type.as_deref(),
523 options_via,
524 enum_fields,
525 handle_nested_types,
526 handle_dict_types,
527 &field_resolver,
528 );
529 }
530 let _ = writeln!(out);
531 }
532
533 out
534}
535
536fn render_http_test_function(out: &mut String, fixture: &Fixture) {
547 let Some(http) = &fixture.http else {
548 return;
549 };
550
551 let fn_name = sanitize_ident(&fixture.id);
552 let description = &fixture.description;
553 let desc_with_period = if description.ends_with('.') {
554 description.to_string()
555 } else {
556 format!("{description}.")
557 };
558
559 if is_skipped(fixture, "python") {
560 let reason = fixture
561 .skip
562 .as_ref()
563 .and_then(|s| s.reason.as_deref())
564 .unwrap_or("skipped for python");
565 let _ = writeln!(out, "@pytest.mark.skip(reason=\"{reason}\")");
566 }
567
568 let _ = writeln!(out, "def test_{fn_name}(client) -> None:");
569 let _ = writeln!(out, " \"\"\"{desc_with_period}\"\"\"");
570
571 let method = http.request.method.to_lowercase();
573 let path = &http.request.path;
574
575 let mut call_kwargs: Vec<String> = Vec::new();
577
578 if let Some(body) = &http.request.body {
580 let py_body = json_to_python_literal(body);
581 call_kwargs.push(format!(" json={py_body},"));
582 }
583
584 if !http.request.headers.is_empty() {
586 let entries: Vec<String> = http
587 .request
588 .headers
589 .iter()
590 .map(|(k, v)| format!(" \"{}\": \"{}\",", escape_python(k), escape_python(v)))
591 .collect();
592 let headers_block = entries.join("\n");
593 call_kwargs.push(format!(" headers={{\n{headers_block}\n }},"));
594 }
595
596 if !http.request.query_params.is_empty() {
598 let entries: Vec<String> = http
599 .request
600 .query_params
601 .iter()
602 .map(|(k, v)| format!(" \"{}\": {},", escape_python(k), json_to_python_literal(v)))
603 .collect();
604 let params_block = entries.join("\n");
605 call_kwargs.push(format!(" params={{\n{params_block}\n }},"));
606 }
607
608 if !http.request.cookies.is_empty() {
610 let entries: Vec<String> = http
611 .request
612 .cookies
613 .iter()
614 .map(|(k, v)| format!(" \"{}\": \"{}\",", escape_python(k), escape_python(v)))
615 .collect();
616 let cookies_block = entries.join("\n");
617 call_kwargs.push(format!(" cookies={{\n{cookies_block}\n }},"));
618 }
619
620 if call_kwargs.is_empty() {
621 let _ = writeln!(out, " response = client.{method}(\"{path}\")");
622 } else {
623 let _ = writeln!(out, " response = client.{method}(");
624 let _ = writeln!(out, " \"{path}\",");
625 for kwarg in &call_kwargs {
626 let _ = writeln!(out, "{kwarg}");
627 }
628 let _ = writeln!(out, " )");
629 }
630
631 let status = http.expected_response.status_code;
633 let _ = writeln!(out, " assert response.status_code == {status} # noqa: S101");
634
635 if let Some(expected_body) = &http.expected_response.body {
637 let py_val = json_to_python_literal(expected_body);
638 let _ = writeln!(out, " data = response.json()");
639 let _ = writeln!(out, " assert data == {py_val} # noqa: S101");
640 } else if let Some(partial) = &http.expected_response.body_partial {
641 let _ = writeln!(out, " data = response.json()");
642 if let Some(obj) = partial.as_object() {
643 for (key, val) in obj {
644 let py_val = json_to_python_literal(val);
645 let escaped_key = escape_python(key);
646 let _ = writeln!(out, " assert data[\"{escaped_key}\"] == {py_val} # noqa: S101");
647 }
648 }
649 }
650
651 for (header_name, header_value) in &http.expected_response.headers {
653 let lower_name = header_name.to_lowercase();
654 let escaped_name = escape_python(&lower_name);
655 match header_value.as_str() {
656 "<<present>>" => {
657 let _ = writeln!(out, " assert \"{escaped_name}\" in response.headers # noqa: S101");
658 }
659 "<<absent>>" => {
660 let _ = writeln!(
661 out,
662 " assert response.headers.get(\"{escaped_name}\") is None # noqa: S101"
663 );
664 }
665 "<<uuid>>" => {
666 let _ = writeln!(
667 out,
668 " 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"
669 );
670 }
671 exact => {
672 let escaped_val = escape_python(exact);
673 let _ = writeln!(
674 out,
675 " assert response.headers[\"{escaped_name}\"] == \"{escaped_val}\" # noqa: S101"
676 );
677 }
678 }
679 }
680
681 if let Some(validation_errors) = &http.expected_response.validation_errors {
683 if !validation_errors.is_empty() {
684 let _ = writeln!(out, " errors = response.json().get(\"detail\", [])");
685 for ve in validation_errors {
686 let loc_py: Vec<String> = ve.loc.iter().map(|s| format!("\"{}\"", escape_python(s))).collect();
687 let loc_str = loc_py.join(", ");
688 let escaped_msg = escape_python(&ve.msg);
689 let _ = writeln!(
690 out,
691 " assert any(e[\"loc\"] == [{loc_str}] and \"{escaped_msg}\" in e[\"msg\"] for e in errors) # noqa: S101"
692 );
693 }
694 }
695 }
696}
697
698#[allow(clippy::too_many_arguments)]
703fn render_test_function(
704 out: &mut String,
705 fixture: &Fixture,
706 e2e_config: &E2eConfig,
707 options_type: Option<&str>,
708 options_via: &str,
709 enum_fields: &HashMap<String, String>,
710 handle_nested_types: &HashMap<String, String>,
711 handle_dict_types: &std::collections::HashSet<String>,
712 field_resolver: &FieldResolver,
713) {
714 let fn_name = sanitize_ident(&fixture.id);
715 let description = &fixture.description;
716 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
717 let function_name = resolve_function_name_for_call(call_config);
718 let result_var = &call_config.result_var;
719
720 let desc_with_period = if description.ends_with('.') {
721 description.to_string()
722 } else {
723 format!("{description}.")
724 };
725
726 if is_skipped(fixture, "python") {
728 let reason = fixture
729 .skip
730 .as_ref()
731 .and_then(|s| s.reason.as_deref())
732 .unwrap_or("skipped for python");
733 let _ = writeln!(out, "@pytest.mark.skip(reason=\"{reason}\")");
734 }
735
736 let is_async = call_config.r#async;
737 if is_async {
738 let _ = writeln!(out, "@pytest.mark.asyncio");
739 let _ = writeln!(out, "async def test_{fn_name}() -> None:");
740 } else {
741 let _ = writeln!(out, "def test_{fn_name}() -> None:");
742 }
743 let _ = writeln!(out, " \"\"\"{desc_with_period}\"\"\"");
744
745 let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
747
748 let mut arg_bindings = Vec::new();
750 let mut kwarg_exprs = Vec::new();
751 for arg in &call_config.args {
752 let var_name = &arg.name;
753
754 if arg.arg_type == "handle" {
755 let constructor_name = format!("create_{}", arg.name.to_snake_case());
758 let config_value = resolve_field(&fixture.input, &arg.field);
759 if config_value.is_null()
760 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
761 {
762 arg_bindings.push(format!(" {var_name} = {constructor_name}(None)"));
763 } else if let Some(obj) = config_value.as_object() {
764 let kwargs: Vec<String> = obj
768 .iter()
769 .map(|(k, v)| {
770 let snake_key = k.to_snake_case();
771 let py_val = if let Some(type_name) = handle_nested_types.get(k) {
772 if let Some(nested_obj) = v.as_object() {
774 if nested_obj.is_empty() {
775 format!("{type_name}()")
777 } else if handle_dict_types.contains(k) {
778 json_to_python_literal(v)
783 } else {
784 let nested_kwargs: Vec<String> = nested_obj
786 .iter()
787 .map(|(nk, nv)| {
788 let nested_snake_key = nk.to_snake_case();
789 format!("{nested_snake_key}={}", json_to_python_literal(nv))
790 })
791 .collect();
792 format!("{type_name}({})", nested_kwargs.join(", "))
793 }
794 } else {
795 json_to_python_literal(v)
797 }
798 } else if k == "request_timeout" {
799 if let Some(ms) = v.as_u64() {
805 format!("{}", ms / 1000)
806 } else {
807 json_to_python_literal(v)
808 }
809 } else {
810 json_to_python_literal(v)
811 };
812 format!("{snake_key}={py_val}")
813 })
814 .collect();
815 let config_class = options_type.unwrap_or("CrawlConfig");
817 let single_line = format!(" {var_name}_config = {config_class}({})", kwargs.join(", "));
818 if single_line.len() <= 120 {
819 arg_bindings.push(single_line);
820 } else {
821 let mut lines = format!(" {var_name}_config = {config_class}(\n");
823 for kw in &kwargs {
824 lines.push_str(&format!(" {kw},\n"));
825 }
826 lines.push_str(" )");
827 arg_bindings.push(lines);
828 }
829 arg_bindings.push(format!(" {var_name} = {constructor_name}({var_name}_config)"));
830 } else {
831 let literal = json_to_python_literal(config_value);
832 arg_bindings.push(format!(" {var_name} = {constructor_name}({literal})"));
833 }
834 kwarg_exprs.push(format!("{var_name}={var_name}"));
835 continue;
836 }
837
838 if arg.arg_type == "mock_url" {
839 let fixture_id = &fixture.id;
840 arg_bindings.push(format!(
841 " {var_name} = os.environ['MOCK_SERVER_URL'] + '/fixtures/{fixture_id}'"
842 ));
843 kwarg_exprs.push(format!("{var_name}={var_name}"));
844 continue;
845 }
846
847 let value = resolve_field(&fixture.input, &arg.field);
848
849 if value.is_null() && arg.optional {
850 continue;
851 }
852
853 if arg.arg_type == "json_object" && !value.is_null() {
855 match options_via {
856 "dict" => {
857 let literal = json_to_python_literal(value);
859 let noqa = if literal.contains("/tmp/") {
860 " # noqa: S108"
861 } else {
862 ""
863 };
864 arg_bindings.push(format!(" {var_name} = {literal}{noqa}"));
865 kwarg_exprs.push(format!("{var_name}={var_name}"));
866 continue;
867 }
868 "json" => {
869 let json_str = serde_json::to_string(value).unwrap_or_default();
871 let escaped = escape_python(&json_str);
872 arg_bindings.push(format!(" {var_name} = json.loads(\"{escaped}\")"));
873 kwarg_exprs.push(format!("{var_name}={var_name}"));
874 continue;
875 }
876 _ => {
877 if let (Some(opts_type), Some(obj)) = (options_type, value.as_object()) {
879 let kwargs: Vec<String> = obj
880 .iter()
881 .map(|(k, v)| {
882 let snake_key = k.to_snake_case();
883 let py_val = if let Some(enum_type) = enum_fields.get(k) {
884 if let Some(s) = v.as_str() {
886 let upper_val = s.to_shouty_snake_case();
887 format!("{enum_type}.{upper_val}")
888 } else {
889 json_to_python_literal(v)
890 }
891 } else {
892 json_to_python_literal(v)
893 };
894 format!("{snake_key}={py_val}")
895 })
896 .collect();
897 let constructor = format!("{opts_type}({})", kwargs.join(", "));
898 arg_bindings.push(format!(" {var_name} = {constructor}"));
899 kwarg_exprs.push(format!("{var_name}={var_name}"));
900 continue;
901 }
902 }
903 }
904 }
905
906 if value.is_null() && !arg.optional {
908 let default_val = match arg.arg_type.as_str() {
909 "string" => "\"\"".to_string(),
910 "int" | "integer" => "0".to_string(),
911 "float" | "number" => "0.0".to_string(),
912 "bool" | "boolean" => "False".to_string(),
913 _ => "None".to_string(),
914 };
915 arg_bindings.push(format!(" {var_name} = {default_val}"));
916 kwarg_exprs.push(format!("{var_name}={var_name}"));
917 continue;
918 }
919
920 let literal = json_to_python_literal(value);
921 let noqa = if literal.contains("/tmp/") {
922 " # noqa: S108"
923 } else {
924 ""
925 };
926 arg_bindings.push(format!(" {var_name} = {literal}{noqa}"));
927 kwarg_exprs.push(format!("{var_name}={var_name}"));
928 }
929
930 if let Some(visitor_spec) = &fixture.visitor {
932 let _ = writeln!(out, " class _TestVisitor:");
933 for (method_name, action) in &visitor_spec.callbacks {
934 emit_python_visitor_method(out, method_name, action);
935 }
936 kwarg_exprs.push("visitor=_TestVisitor()".to_string());
937 }
938
939 for binding in &arg_bindings {
940 let _ = writeln!(out, "{binding}");
941 }
942
943 let call_args = kwarg_exprs.join(", ");
944 let await_prefix = if is_async { "await " } else { "" };
945 let call_expr = format!("{await_prefix}{function_name}({call_args})");
946
947 if has_error_assertion {
948 let error_assertion = fixture.assertions.iter().find(|a| a.assertion_type == "error");
950 let has_message = error_assertion
951 .and_then(|a| a.value.as_ref())
952 .and_then(|v| v.as_str())
953 .is_some();
954
955 if has_message {
956 let _ = writeln!(out, " with pytest.raises(Exception) as exc_info: # noqa: B017");
957 let _ = writeln!(out, " {call_expr}");
958 if let Some(msg) = error_assertion.and_then(|a| a.value.as_ref()).and_then(|v| v.as_str()) {
959 let escaped = escape_python(msg);
960 let _ = writeln!(out, " assert \"{escaped}\" in str(exc_info.value) # noqa: S101");
961 }
962 } else {
963 let _ = writeln!(out, " with pytest.raises(Exception): # noqa: B017");
964 let _ = writeln!(out, " {call_expr}");
965 }
966
967 return;
970 }
971
972 let has_usable_assertion = fixture.assertions.iter().any(|a| {
974 if a.assertion_type == "not_error" || a.assertion_type == "error" {
975 return false;
976 }
977 match &a.field {
978 Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
979 _ => true,
980 }
981 });
982 let py_result_var = if has_usable_assertion {
983 result_var.to_string()
984 } else {
985 "_".to_string()
986 };
987 let _ = writeln!(out, " {py_result_var} = {call_expr}");
988
989 let fields_enum = &e2e_config.fields_enum;
990 for assertion in &fixture.assertions {
991 if assertion.assertion_type == "not_error" {
992 continue;
994 }
995 render_assertion(out, assertion, result_var, field_resolver, fields_enum);
996 }
997}
998
999fn resolve_field<'a>(input: &'a serde_json::Value, field_path: &str) -> &'a serde_json::Value {
1004 let path = field_path.strip_prefix("input.").unwrap_or(field_path);
1007 let mut current = input;
1008 for part in path.split('.') {
1009 current = current.get(part).unwrap_or(&serde_json::Value::Null);
1010 }
1011 current
1012}
1013
1014fn json_to_python_literal(value: &serde_json::Value) -> String {
1015 match value {
1016 serde_json::Value::Null => "None".to_string(),
1017 serde_json::Value::Bool(true) => "True".to_string(),
1018 serde_json::Value::Bool(false) => "False".to_string(),
1019 serde_json::Value::Number(n) => n.to_string(),
1020 serde_json::Value::String(s) => python_string_literal(s),
1021 serde_json::Value::Array(arr) => {
1022 let items: Vec<String> = arr.iter().map(json_to_python_literal).collect();
1023 format!("[{}]", items.join(", "))
1024 }
1025 serde_json::Value::Object(map) => {
1026 let items: Vec<String> = map
1027 .iter()
1028 .map(|(k, v)| format!("\"{}\": {}", escape_python(k), json_to_python_literal(v)))
1029 .collect();
1030 format!("{{{}}}", items.join(", "))
1031 }
1032 }
1033}
1034
1035fn render_assertion(
1040 out: &mut String,
1041 assertion: &Assertion,
1042 result_var: &str,
1043 field_resolver: &FieldResolver,
1044 fields_enum: &std::collections::HashSet<String>,
1045) {
1046 if let Some(f) = &assertion.field {
1048 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1049 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
1050 return;
1051 }
1052 }
1053
1054 let field_access = match &assertion.field {
1055 Some(f) if !f.is_empty() => field_resolver.accessor(f, "python", result_var),
1056 _ => result_var.to_string(),
1057 };
1058
1059 let field_is_enum = assertion.field.as_deref().is_some_and(|f| {
1070 if fields_enum.contains(f) {
1071 return true;
1072 }
1073 let resolved = field_resolver.resolve(f);
1074 if fields_enum.contains(resolved) {
1075 return true;
1076 }
1077 field_resolver.accessor(f, "python", result_var).contains("[0]")
1082 });
1083
1084 let field_is_optional = match &assertion.field {
1087 Some(f) if !f.is_empty() => {
1088 let resolved = field_resolver.resolve(f);
1089 field_resolver.is_optional(resolved)
1090 }
1091 _ => false,
1092 };
1093
1094 match assertion.assertion_type.as_str() {
1095 "error" | "not_error" => {
1096 }
1098 "equals" => {
1099 if let Some(val) = &assertion.value {
1100 let expected = value_to_python_string(val);
1101 let op = if val.is_boolean() || val.is_null() { "is" } else { "==" };
1103 if val.is_string() {
1106 let _ = writeln!(out, " assert {field_access}.strip() {op} {expected} # noqa: S101");
1107 } else {
1108 let _ = writeln!(out, " assert {field_access} {op} {expected} # noqa: S101");
1109 }
1110 }
1111 }
1112 "contains" => {
1113 if let Some(val) = &assertion.value {
1114 let expected = value_to_python_string(val);
1115 let cmp_expr = if field_is_enum && val.is_string() {
1117 format!("str({field_access}).lower()")
1118 } else {
1119 field_access.clone()
1120 };
1121 if field_is_optional {
1122 let _ = writeln!(out, " assert {field_access} is not None # noqa: S101");
1123 let _ = writeln!(out, " assert {expected} in {cmp_expr} # noqa: S101");
1124 } else {
1125 let _ = writeln!(out, " assert {expected} in {cmp_expr} # noqa: S101");
1126 }
1127 }
1128 }
1129 "contains_all" => {
1130 if let Some(values) = &assertion.values {
1131 for val in values {
1132 let expected = value_to_python_string(val);
1133 let cmp_expr = if field_is_enum && val.is_string() {
1135 format!("str({field_access}).lower()")
1136 } else {
1137 field_access.clone()
1138 };
1139 if field_is_optional {
1140 let _ = writeln!(out, " assert {field_access} is not None # noqa: S101");
1141 let _ = writeln!(out, " assert {expected} in {cmp_expr} # noqa: S101");
1142 } else {
1143 let _ = writeln!(out, " assert {expected} in {cmp_expr} # noqa: S101");
1144 }
1145 }
1146 }
1147 }
1148 "not_contains" => {
1149 if let Some(val) = &assertion.value {
1150 let expected = value_to_python_string(val);
1151 let cmp_expr = if field_is_enum && val.is_string() {
1153 format!("str({field_access}).lower()")
1154 } else {
1155 field_access.clone()
1156 };
1157 if field_is_optional {
1158 let _ = writeln!(
1159 out,
1160 " assert {field_access} is None or {expected} not in {cmp_expr} # noqa: S101"
1161 );
1162 } else {
1163 let _ = writeln!(out, " assert {expected} not in {cmp_expr} # noqa: S101");
1164 }
1165 }
1166 }
1167 "not_empty" => {
1168 let _ = writeln!(out, " assert {field_access} # noqa: S101");
1169 }
1170 "is_empty" => {
1171 let _ = writeln!(out, " assert not {field_access} # noqa: S101");
1172 }
1173 "contains_any" => {
1174 if let Some(values) = &assertion.values {
1175 let items: Vec<String> = values.iter().map(value_to_python_string).collect();
1176 let list_str = items.join(", ");
1177 let cmp_expr = if field_is_enum {
1179 format!("str({field_access}).lower()")
1180 } else {
1181 field_access.clone()
1182 };
1183 if field_is_optional {
1184 let _ = writeln!(out, " assert {field_access} is not None # noqa: S101");
1185 let _ = writeln!(
1186 out,
1187 " assert any(v in {cmp_expr} for v in [{list_str}]) # noqa: S101"
1188 );
1189 } else {
1190 let _ = writeln!(
1191 out,
1192 " assert any(v in {cmp_expr} for v in [{list_str}]) # noqa: S101"
1193 );
1194 }
1195 }
1196 }
1197 "greater_than" => {
1198 if let Some(val) = &assertion.value {
1199 let expected = value_to_python_string(val);
1200 let _ = writeln!(out, " assert {field_access} > {expected} # noqa: S101");
1201 }
1202 }
1203 "less_than" => {
1204 if let Some(val) = &assertion.value {
1205 let expected = value_to_python_string(val);
1206 let _ = writeln!(out, " assert {field_access} < {expected} # noqa: S101");
1207 }
1208 }
1209 "greater_than_or_equal" | "min" => {
1210 if let Some(val) = &assertion.value {
1211 let expected = value_to_python_string(val);
1212 let _ = writeln!(out, " assert {field_access} >= {expected} # noqa: S101");
1213 }
1214 }
1215 "less_than_or_equal" | "max" => {
1216 if let Some(val) = &assertion.value {
1217 let expected = value_to_python_string(val);
1218 let _ = writeln!(out, " assert {field_access} <= {expected} # noqa: S101");
1219 }
1220 }
1221 "starts_with" => {
1222 if let Some(val) = &assertion.value {
1223 let expected = value_to_python_string(val);
1224 let _ = writeln!(out, " assert {field_access}.startswith({expected}) # noqa: S101");
1225 }
1226 }
1227 "ends_with" => {
1228 if let Some(val) = &assertion.value {
1229 let expected = value_to_python_string(val);
1230 let _ = writeln!(out, " assert {field_access}.endswith({expected}) # noqa: S101");
1231 }
1232 }
1233 "min_length" => {
1234 if let Some(val) = &assertion.value {
1235 if let Some(n) = val.as_u64() {
1236 let _ = writeln!(out, " assert len({field_access}) >= {n} # noqa: S101");
1237 }
1238 }
1239 }
1240 "max_length" => {
1241 if let Some(val) = &assertion.value {
1242 if let Some(n) = val.as_u64() {
1243 let _ = writeln!(out, " assert len({field_access}) <= {n} # noqa: S101");
1244 }
1245 }
1246 }
1247 "count_min" => {
1248 if let Some(val) = &assertion.value {
1249 if let Some(n) = val.as_u64() {
1250 let _ = writeln!(out, " assert len({field_access}) >= {n} # noqa: S101");
1251 }
1252 }
1253 }
1254 "count_equals" => {
1255 if let Some(val) = &assertion.value {
1256 if let Some(n) = val.as_u64() {
1257 let _ = writeln!(out, " assert len({field_access}) == {n} # noqa: S101");
1258 }
1259 }
1260 }
1261 "is_true" => {
1262 let _ = writeln!(out, " assert {field_access} is True # noqa: S101");
1263 }
1264 "is_false" => {
1265 let _ = writeln!(out, " assert not {field_access} # noqa: S101");
1266 }
1267 "method_result" => {
1268 if let Some(method_name) = &assertion.method {
1269 let call_expr = build_python_method_call(result_var, method_name, assertion.args.as_ref());
1270 let check = assertion.check.as_deref().unwrap_or("is_true");
1271 match check {
1272 "equals" => {
1273 if let Some(val) = &assertion.value {
1274 if val.is_boolean() {
1275 if val.as_bool() == Some(true) {
1276 let _ = writeln!(out, " assert {call_expr} is True # noqa: S101");
1277 } else {
1278 let _ = writeln!(out, " assert {call_expr} is False # noqa: S101");
1279 }
1280 } else {
1281 let expected = value_to_python_string(val);
1282 let _ = writeln!(out, " assert {call_expr} == {expected} # noqa: S101");
1283 }
1284 }
1285 }
1286 "is_true" => {
1287 let _ = writeln!(out, " assert {call_expr} # noqa: S101");
1288 }
1289 "is_false" => {
1290 let _ = writeln!(out, " assert not {call_expr} # noqa: S101");
1291 }
1292 "greater_than_or_equal" => {
1293 if let Some(val) = &assertion.value {
1294 let n = val.as_u64().unwrap_or(0);
1295 let _ = writeln!(out, " assert {call_expr} >= {n} # noqa: S101");
1296 }
1297 }
1298 "count_min" => {
1299 if let Some(val) = &assertion.value {
1300 let n = val.as_u64().unwrap_or(0);
1301 let _ = writeln!(out, " assert len({call_expr}) >= {n} # noqa: S101");
1302 }
1303 }
1304 "contains" => {
1305 if let Some(val) = &assertion.value {
1306 let expected = value_to_python_string(val);
1307 let _ = writeln!(out, " assert {expected} in {call_expr} # noqa: S101");
1308 }
1309 }
1310 "is_error" => {
1311 let _ = writeln!(out, " with pytest.raises(Exception): # noqa: B017");
1312 let _ = writeln!(out, " {call_expr}");
1313 }
1314 other_check => {
1315 panic!("unsupported method_result check type: {other_check}");
1316 }
1317 }
1318 } else {
1319 panic!("method_result assertion missing 'method' field");
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 _ => "self, ctx, *args",
1455 };
1456
1457 let _ = writeln!(
1458 out,
1459 " def {method_name}({params}): # noqa: A002, ANN001, ANN202, ARG002"
1460 );
1461 match action {
1462 CallbackAction::Skip => {
1463 let _ = writeln!(out, " return \"skip\"");
1464 }
1465 CallbackAction::Continue => {
1466 let _ = writeln!(out, " return \"continue\"");
1467 }
1468 CallbackAction::PreserveHtml => {
1469 let _ = writeln!(out, " return \"preserve_html\"");
1470 }
1471 CallbackAction::Custom { output } => {
1472 let escaped = escape_python(output);
1473 let _ = writeln!(out, " return {{\"custom\": \"{escaped}\"}}");
1474 }
1475 CallbackAction::CustomTemplate { template } => {
1476 let escaped_template = template.replace('\'', "\\'");
1480 let _ = writeln!(out, " return {{\"custom\": f'{escaped_template}'}}");
1481 }
1482 }
1483}