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