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),
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) -> String {
237 let module = resolve_module(e2e_config);
238 format!(
239 r#"# This file is auto-generated by alef. DO NOT EDIT.
240"""Pytest configuration for e2e tests."""
241# Ensure the package is importable.
242# The {module} package is expected to be installed in the current environment.
243"#
244 )
245}
246
247fn render_test_file(category: &str, fixtures: &[&Fixture], e2e_config: &E2eConfig) -> String {
248 let mut out = String::new();
249 let _ = writeln!(out, "# This file is auto-generated by alef. DO NOT EDIT.");
250 let _ = writeln!(out, "\"\"\"E2e tests for category: {category}.\"\"\"");
251
252 let module = resolve_module(e2e_config);
253 let function_name = resolve_function_name(e2e_config);
254 let options_type = resolve_options_type(e2e_config);
255 let options_via = resolve_options_via(e2e_config);
256 let enum_fields = resolve_enum_fields(e2e_config);
257 let handle_nested_types = resolve_handle_nested_types(e2e_config);
258 let handle_dict_types = resolve_handle_dict_types(e2e_config);
259 let field_resolver = FieldResolver::new(
260 &e2e_config.fields,
261 &e2e_config.fields_optional,
262 &e2e_config.result_fields,
263 &e2e_config.fields_array,
264 );
265
266 let has_error_test = fixtures
267 .iter()
268 .any(|f| f.assertions.iter().any(|a| a.assertion_type == "error"));
269 let has_skipped = fixtures.iter().any(|f| is_skipped(f, "python"));
270
271 let is_async = fixtures.iter().any(|f| {
273 let cc = e2e_config.resolve_call(f.call.as_deref());
274 cc.r#async
275 }) || e2e_config.call.r#async;
276 let needs_pytest = has_error_test || has_skipped || is_async;
277
278 let needs_json_import = options_via == "json"
280 && fixtures.iter().any(|f| {
281 e2e_config
282 .call
283 .args
284 .iter()
285 .any(|arg| arg.arg_type == "json_object" && !resolve_field(&f.input, &arg.field).is_null())
286 });
287
288 let needs_os_import = e2e_config.call.args.iter().any(|arg| arg.arg_type == "mock_url");
290
291 let needs_options_type = options_via == "kwargs"
293 && options_type.is_some()
294 && fixtures.iter().any(|f| {
295 e2e_config
296 .call
297 .args
298 .iter()
299 .any(|arg| arg.arg_type == "json_object" && !resolve_field(&f.input, &arg.field).is_null())
300 });
301
302 let mut used_enum_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
304 if needs_options_type && !enum_fields.is_empty() {
305 for fixture in fixtures.iter() {
306 for arg in &e2e_config.call.args {
307 if arg.arg_type == "json_object" {
308 let value = resolve_field(&fixture.input, &arg.field);
309 if let Some(obj) = value.as_object() {
310 for key in obj.keys() {
311 if let Some(enum_type) = enum_fields.get(key) {
312 used_enum_types.insert(enum_type.clone());
313 }
314 }
315 }
316 }
317 }
318 }
319 }
320
321 let mut stdlib_imports: Vec<String> = Vec::new();
325 let mut thirdparty_bare: Vec<String> = Vec::new();
326 let mut thirdparty_from: Vec<String> = Vec::new();
327
328 if needs_json_import {
329 stdlib_imports.push("import json".to_string());
330 }
331
332 if needs_os_import {
333 stdlib_imports.push("import os".to_string());
334 }
335
336 if needs_pytest {
337 thirdparty_bare.push("import pytest".to_string());
338 }
339
340 let handle_constructors: Vec<String> = e2e_config
342 .call
343 .args
344 .iter()
345 .filter(|arg| arg.arg_type == "handle")
346 .map(|arg| format!("create_{}", arg.name.to_snake_case()))
347 .collect();
348
349 let mut import_names: Vec<String> = vec![function_name.clone()];
351 for fixture in fixtures.iter() {
352 let cc = e2e_config.resolve_call(fixture.call.as_deref());
353 let fn_name = resolve_function_name_for_call(cc);
354 if !import_names.contains(&fn_name) {
355 import_names.push(fn_name);
356 }
357 }
358 for ctor in &handle_constructors {
359 if !import_names.contains(ctor) {
360 import_names.push(ctor.clone());
361 }
362 }
363
364 let needs_config_import = e2e_config.call.args.iter().any(|arg| {
366 arg.arg_type == "handle"
367 && fixtures.iter().any(|f| {
368 let val = resolve_field(&f.input, &arg.field);
369 !val.is_null() && val.as_object().is_some_and(|o| !o.is_empty())
370 })
371 });
372 if needs_config_import {
373 let config_class = options_type.as_deref().unwrap_or("CrawlConfig");
374 if !import_names.contains(&config_class.to_string()) {
375 import_names.push(config_class.to_string());
376 }
377 }
378
379 if !handle_nested_types.is_empty() {
381 let mut used_nested_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
382 for fixture in fixtures.iter() {
383 for arg in &e2e_config.call.args {
384 if arg.arg_type == "handle" {
385 let config_value = resolve_field(&fixture.input, &arg.field);
386 if let Some(obj) = config_value.as_object() {
387 for key in obj.keys() {
388 if let Some(type_name) = handle_nested_types.get(key) {
389 if obj[key].is_object() {
390 used_nested_types.insert(type_name.clone());
391 }
392 }
393 }
394 }
395 }
396 }
397 }
398 for type_name in used_nested_types {
399 if !import_names.contains(&type_name) {
400 import_names.push(type_name);
401 }
402 }
403 }
404
405 if let (true, Some(opts_type)) = (needs_options_type, &options_type) {
406 import_names.push(opts_type.clone());
407 thirdparty_from.push(format!("from {module} import {}", import_names.join(", ")));
408 if !used_enum_types.is_empty() {
410 let enum_mod = e2e_config
411 .call
412 .overrides
413 .get("python")
414 .and_then(|o| o.enum_module.as_deref())
415 .unwrap_or(&module);
416 let enum_names: Vec<&String> = used_enum_types.iter().collect();
417 thirdparty_from.push(format!(
418 "from {enum_mod} import {}",
419 enum_names.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
420 ));
421 }
422 } else {
423 thirdparty_from.push(format!("from {module} import {}", import_names.join(", ")));
424 }
425
426 stdlib_imports.sort();
427 thirdparty_bare.sort();
428 thirdparty_from.sort();
429
430 if !stdlib_imports.is_empty() {
432 for imp in &stdlib_imports {
433 let _ = writeln!(out, "{imp}");
434 }
435 let _ = writeln!(out);
436 }
437 for imp in &thirdparty_bare {
439 let _ = writeln!(out, "{imp}");
440 }
441 for imp in &thirdparty_from {
442 let _ = writeln!(out, "{imp}");
443 }
444 let _ = writeln!(out);
446 let _ = writeln!(out);
447
448 for fixture in fixtures {
449 render_test_function(
450 &mut out,
451 fixture,
452 e2e_config,
453 options_type.as_deref(),
454 options_via,
455 enum_fields,
456 handle_nested_types,
457 handle_dict_types,
458 &field_resolver,
459 );
460 let _ = writeln!(out);
461 }
462
463 out
464}
465
466#[allow(clippy::too_many_arguments)]
467fn render_test_function(
468 out: &mut String,
469 fixture: &Fixture,
470 e2e_config: &E2eConfig,
471 options_type: Option<&str>,
472 options_via: &str,
473 enum_fields: &HashMap<String, String>,
474 handle_nested_types: &HashMap<String, String>,
475 handle_dict_types: &std::collections::HashSet<String>,
476 field_resolver: &FieldResolver,
477) {
478 let fn_name = sanitize_ident(&fixture.id);
479 let description = &fixture.description;
480 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
481 let function_name = resolve_function_name_for_call(call_config);
482 let result_var = &call_config.result_var;
483
484 let desc_with_period = if description.ends_with('.') {
485 description.to_string()
486 } else {
487 format!("{description}.")
488 };
489
490 if is_skipped(fixture, "python") {
492 let reason = fixture
493 .skip
494 .as_ref()
495 .and_then(|s| s.reason.as_deref())
496 .unwrap_or("skipped for python");
497 let _ = writeln!(out, "@pytest.mark.skip(reason=\"{reason}\")");
498 }
499
500 let is_async = call_config.r#async;
501 if is_async {
502 let _ = writeln!(out, "@pytest.mark.asyncio");
503 let _ = writeln!(out, "async def test_{fn_name}() -> None:");
504 } else {
505 let _ = writeln!(out, "def test_{fn_name}() -> None:");
506 }
507 let _ = writeln!(out, " \"\"\"{desc_with_period}\"\"\"");
508
509 let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
511
512 let mut arg_bindings = Vec::new();
514 let mut kwarg_exprs = Vec::new();
515 for arg in &call_config.args {
516 let var_name = &arg.name;
517
518 if arg.arg_type == "handle" {
519 let constructor_name = format!("create_{}", arg.name.to_snake_case());
522 let config_value = resolve_field(&fixture.input, &arg.field);
523 if config_value.is_null()
524 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
525 {
526 arg_bindings.push(format!(" {var_name} = {constructor_name}(None)"));
527 } else if let Some(obj) = config_value.as_object() {
528 let kwargs: Vec<String> = obj
532 .iter()
533 .map(|(k, v)| {
534 let snake_key = k.to_snake_case();
535 let py_val = if let Some(type_name) = handle_nested_types.get(k) {
536 if let Some(nested_obj) = v.as_object() {
538 if nested_obj.is_empty() {
539 format!("{type_name}()")
541 } else if handle_dict_types.contains(k) {
542 json_to_python_literal(v)
547 } else {
548 let nested_kwargs: Vec<String> = nested_obj
550 .iter()
551 .map(|(nk, nv)| {
552 let nested_snake_key = nk.to_snake_case();
553 format!("{nested_snake_key}={}", json_to_python_literal(nv))
554 })
555 .collect();
556 format!("{type_name}({})", nested_kwargs.join(", "))
557 }
558 } else {
559 json_to_python_literal(v)
561 }
562 } else if k == "request_timeout" {
563 if let Some(ms) = v.as_u64() {
569 format!("{}", ms / 1000)
570 } else {
571 json_to_python_literal(v)
572 }
573 } else {
574 json_to_python_literal(v)
575 };
576 format!("{snake_key}={py_val}")
577 })
578 .collect();
579 let config_class = options_type.unwrap_or("CrawlConfig");
581 let single_line = format!(" {var_name}_config = {config_class}({})", kwargs.join(", "));
582 if single_line.len() <= 120 {
583 arg_bindings.push(single_line);
584 } else {
585 let mut lines = format!(" {var_name}_config = {config_class}(\n");
587 for kw in &kwargs {
588 lines.push_str(&format!(" {kw},\n"));
589 }
590 lines.push_str(" )");
591 arg_bindings.push(lines);
592 }
593 arg_bindings.push(format!(" {var_name} = {constructor_name}({var_name}_config)"));
594 } else {
595 let literal = json_to_python_literal(config_value);
596 arg_bindings.push(format!(" {var_name} = {constructor_name}({literal})"));
597 }
598 kwarg_exprs.push(format!("{var_name}={var_name}"));
599 continue;
600 }
601
602 if arg.arg_type == "mock_url" {
603 let fixture_id = &fixture.id;
604 arg_bindings.push(format!(
605 " {var_name} = os.environ['MOCK_SERVER_URL'] + '/fixtures/{fixture_id}'"
606 ));
607 kwarg_exprs.push(format!("{var_name}={var_name}"));
608 continue;
609 }
610
611 let value = resolve_field(&fixture.input, &arg.field);
612
613 if value.is_null() && arg.optional {
614 continue;
615 }
616
617 if arg.arg_type == "json_object" && !value.is_null() {
619 match options_via {
620 "dict" => {
621 let literal = json_to_python_literal(value);
623 arg_bindings.push(format!(" {var_name} = {literal}"));
624 kwarg_exprs.push(format!("{var_name}={var_name}"));
625 continue;
626 }
627 "json" => {
628 let json_str = serde_json::to_string(value).unwrap_or_default();
630 let escaped = escape_python(&json_str);
631 arg_bindings.push(format!(" {var_name} = json.loads(\"{escaped}\")"));
632 kwarg_exprs.push(format!("{var_name}={var_name}"));
633 continue;
634 }
635 _ => {
636 if let (Some(opts_type), Some(obj)) = (options_type, value.as_object()) {
638 let kwargs: Vec<String> = obj
639 .iter()
640 .map(|(k, v)| {
641 let snake_key = k.to_snake_case();
642 let py_val = if let Some(enum_type) = enum_fields.get(k) {
643 if let Some(s) = v.as_str() {
645 let upper_val = s.to_shouty_snake_case();
646 format!("{enum_type}.{upper_val}")
647 } else {
648 json_to_python_literal(v)
649 }
650 } else {
651 json_to_python_literal(v)
652 };
653 format!("{snake_key}={py_val}")
654 })
655 .collect();
656 let constructor = format!("{opts_type}({})", kwargs.join(", "));
657 arg_bindings.push(format!(" {var_name} = {constructor}"));
658 kwarg_exprs.push(format!("{var_name}={var_name}"));
659 continue;
660 }
661 }
662 }
663 }
664
665 if value.is_null() && !arg.optional {
667 let default_val = match arg.arg_type.as_str() {
668 "string" => "\"\"".to_string(),
669 "int" | "integer" => "0".to_string(),
670 "float" | "number" => "0.0".to_string(),
671 "bool" | "boolean" => "False".to_string(),
672 _ => "None".to_string(),
673 };
674 arg_bindings.push(format!(" {var_name} = {default_val}"));
675 kwarg_exprs.push(format!("{var_name}={var_name}"));
676 continue;
677 }
678
679 let literal = json_to_python_literal(value);
680 arg_bindings.push(format!(" {var_name} = {literal}"));
681 kwarg_exprs.push(format!("{var_name}={var_name}"));
682 }
683
684 if let Some(visitor_spec) = &fixture.visitor {
686 let _ = writeln!(out, " class _TestVisitor:");
687 for (method_name, action) in &visitor_spec.callbacks {
688 emit_python_visitor_method(out, method_name, action);
689 }
690 kwarg_exprs.push("visitor=_TestVisitor()".to_string());
691 }
692
693 for binding in &arg_bindings {
694 let _ = writeln!(out, "{binding}");
695 }
696
697 let call_args = kwarg_exprs.join(", ");
698 let await_prefix = if is_async { "await " } else { "" };
699 let call_expr = format!("{await_prefix}{function_name}({call_args})");
700
701 if has_error_assertion {
702 let error_assertion = fixture.assertions.iter().find(|a| a.assertion_type == "error");
704 let has_message = error_assertion
705 .and_then(|a| a.value.as_ref())
706 .and_then(|v| v.as_str())
707 .is_some();
708
709 if has_message {
710 let _ = writeln!(out, " with pytest.raises(Exception) as exc_info:");
711 let _ = writeln!(out, " {call_expr}");
712 if let Some(msg) = error_assertion.and_then(|a| a.value.as_ref()).and_then(|v| v.as_str()) {
713 let escaped = escape_python(msg);
714 let _ = writeln!(out, " assert \"{escaped}\" in str(exc_info.value) # noqa: S101");
715 }
716 } else {
717 let _ = writeln!(out, " with pytest.raises(Exception):");
718 let _ = writeln!(out, " {call_expr}");
719 }
720
721 return;
724 }
725
726 let has_usable_assertion = fixture.assertions.iter().any(|a| {
728 if a.assertion_type == "not_error" || a.assertion_type == "error" {
729 return false;
730 }
731 match &a.field {
732 Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
733 _ => true,
734 }
735 });
736 let py_result_var = if has_usable_assertion {
737 result_var.to_string()
738 } else {
739 "_".to_string()
740 };
741 let _ = writeln!(out, " {py_result_var} = {call_expr}");
742
743 let fields_enum = &e2e_config.fields_enum;
744 for assertion in &fixture.assertions {
745 if assertion.assertion_type == "not_error" {
746 continue;
748 }
749 render_assertion(out, assertion, result_var, field_resolver, fields_enum);
750 }
751}
752
753fn resolve_field<'a>(input: &'a serde_json::Value, field_path: &str) -> &'a serde_json::Value {
758 let path = field_path.strip_prefix("input.").unwrap_or(field_path);
761 let mut current = input;
762 for part in path.split('.') {
763 current = current.get(part).unwrap_or(&serde_json::Value::Null);
764 }
765 current
766}
767
768fn json_to_python_literal(value: &serde_json::Value) -> String {
769 match value {
770 serde_json::Value::Null => "None".to_string(),
771 serde_json::Value::Bool(true) => "True".to_string(),
772 serde_json::Value::Bool(false) => "False".to_string(),
773 serde_json::Value::Number(n) => n.to_string(),
774 serde_json::Value::String(s) => python_string_literal(s),
775 serde_json::Value::Array(arr) => {
776 let items: Vec<String> = arr.iter().map(json_to_python_literal).collect();
777 format!("[{}]", items.join(", "))
778 }
779 serde_json::Value::Object(map) => {
780 let items: Vec<String> = map
781 .iter()
782 .map(|(k, v)| format!("\"{}\": {}", escape_python(k), json_to_python_literal(v)))
783 .collect();
784 format!("{{{}}}", items.join(", "))
785 }
786 }
787}
788
789fn render_assertion(
794 out: &mut String,
795 assertion: &Assertion,
796 result_var: &str,
797 field_resolver: &FieldResolver,
798 fields_enum: &std::collections::HashSet<String>,
799) {
800 if let Some(f) = &assertion.field {
802 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
803 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
804 return;
805 }
806 }
807
808 let field_access = match &assertion.field {
809 Some(f) if !f.is_empty() => field_resolver.accessor(f, "python", result_var),
810 _ => result_var.to_string(),
811 };
812
813 let field_is_enum = assertion.field.as_deref().is_some_and(|f| {
824 if fields_enum.contains(f) {
825 return true;
826 }
827 let resolved = field_resolver.resolve(f);
828 if fields_enum.contains(resolved) {
829 return true;
830 }
831 field_resolver.accessor(f, "python", result_var).contains("[0]")
836 });
837
838 let field_is_optional = match &assertion.field {
841 Some(f) if !f.is_empty() => {
842 let resolved = field_resolver.resolve(f);
843 field_resolver.is_optional(resolved)
844 }
845 _ => false,
846 };
847
848 match assertion.assertion_type.as_str() {
849 "error" | "not_error" => {
850 }
852 "equals" => {
853 if let Some(val) = &assertion.value {
854 let expected = value_to_python_string(val);
855 let op = if val.is_boolean() || val.is_null() { "is" } else { "==" };
857 if val.is_string() {
860 let _ = writeln!(out, " assert {field_access}.strip() {op} {expected} # noqa: S101");
861 } else {
862 let _ = writeln!(out, " assert {field_access} {op} {expected} # noqa: S101");
863 }
864 }
865 }
866 "contains" => {
867 if let Some(val) = &assertion.value {
868 let expected = value_to_python_string(val);
869 let cmp_expr = if field_is_enum && val.is_string() {
871 format!("str({field_access}).lower()")
872 } else {
873 field_access.clone()
874 };
875 if field_is_optional {
876 let _ = writeln!(out, " assert {field_access} is not None # noqa: S101");
877 let _ = writeln!(out, " assert {expected} in {cmp_expr} # noqa: S101");
878 } else {
879 let _ = writeln!(out, " assert {expected} in {cmp_expr} # noqa: S101");
880 }
881 }
882 }
883 "contains_all" => {
884 if let Some(values) = &assertion.values {
885 for val in values {
886 let expected = value_to_python_string(val);
887 let cmp_expr = if field_is_enum && val.is_string() {
889 format!("str({field_access}).lower()")
890 } else {
891 field_access.clone()
892 };
893 if field_is_optional {
894 let _ = writeln!(out, " assert {field_access} is not None # noqa: S101");
895 let _ = writeln!(out, " assert {expected} in {cmp_expr} # noqa: S101");
896 } else {
897 let _ = writeln!(out, " assert {expected} in {cmp_expr} # noqa: S101");
898 }
899 }
900 }
901 }
902 "not_contains" => {
903 if let Some(val) = &assertion.value {
904 let expected = value_to_python_string(val);
905 let cmp_expr = if field_is_enum && val.is_string() {
907 format!("str({field_access}).lower()")
908 } else {
909 field_access.clone()
910 };
911 if field_is_optional {
912 let _ = writeln!(
913 out,
914 " assert {field_access} is None or {expected} not in {cmp_expr} # noqa: S101"
915 );
916 } else {
917 let _ = writeln!(out, " assert {expected} not in {cmp_expr} # noqa: S101");
918 }
919 }
920 }
921 "not_empty" => {
922 let _ = writeln!(out, " assert {field_access} # noqa: S101");
923 }
924 "is_empty" => {
925 let _ = writeln!(out, " assert not {field_access} # noqa: S101");
926 }
927 "contains_any" => {
928 if let Some(values) = &assertion.values {
929 let items: Vec<String> = values.iter().map(value_to_python_string).collect();
930 let list_str = items.join(", ");
931 let cmp_expr = if field_is_enum {
933 format!("str({field_access}).lower()")
934 } else {
935 field_access.clone()
936 };
937 if field_is_optional {
938 let _ = writeln!(out, " assert {field_access} is not None # noqa: S101");
939 let _ = writeln!(
940 out,
941 " assert any(v in {cmp_expr} for v in [{list_str}]) # noqa: S101"
942 );
943 } else {
944 let _ = writeln!(
945 out,
946 " assert any(v in {cmp_expr} for v in [{list_str}]) # noqa: S101"
947 );
948 }
949 }
950 }
951 "greater_than" => {
952 if let Some(val) = &assertion.value {
953 let expected = value_to_python_string(val);
954 let _ = writeln!(out, " assert {field_access} > {expected} # noqa: S101");
955 }
956 }
957 "less_than" => {
958 if let Some(val) = &assertion.value {
959 let expected = value_to_python_string(val);
960 let _ = writeln!(out, " assert {field_access} < {expected} # noqa: S101");
961 }
962 }
963 "greater_than_or_equal" | "min" => {
964 if let Some(val) = &assertion.value {
965 let expected = value_to_python_string(val);
966 let _ = writeln!(out, " assert {field_access} >= {expected} # noqa: S101");
967 }
968 }
969 "less_than_or_equal" | "max" => {
970 if let Some(val) = &assertion.value {
971 let expected = value_to_python_string(val);
972 let _ = writeln!(out, " assert {field_access} <= {expected} # noqa: S101");
973 }
974 }
975 "starts_with" => {
976 if let Some(val) = &assertion.value {
977 let expected = value_to_python_string(val);
978 let _ = writeln!(out, " assert {field_access}.startswith({expected}) # noqa: S101");
979 }
980 }
981 "ends_with" => {
982 if let Some(val) = &assertion.value {
983 let expected = value_to_python_string(val);
984 let _ = writeln!(out, " assert {field_access}.endswith({expected}) # noqa: S101");
985 }
986 }
987 "min_length" => {
988 if let Some(val) = &assertion.value {
989 if let Some(n) = val.as_u64() {
990 let _ = writeln!(out, " assert len({field_access}) >= {n} # noqa: S101");
991 }
992 }
993 }
994 "max_length" => {
995 if let Some(val) = &assertion.value {
996 if let Some(n) = val.as_u64() {
997 let _ = writeln!(out, " assert len({field_access}) <= {n} # noqa: S101");
998 }
999 }
1000 }
1001 "count_min" => {
1002 if let Some(val) = &assertion.value {
1003 if let Some(n) = val.as_u64() {
1004 let _ = writeln!(out, " assert len({field_access}) >= {n} # noqa: S101");
1005 }
1006 }
1007 }
1008 "count_equals" => {
1009 if let Some(val) = &assertion.value {
1010 if let Some(n) = val.as_u64() {
1011 let _ = writeln!(out, " assert len({field_access}) == {n} # noqa: S101");
1012 }
1013 }
1014 }
1015 "is_true" => {
1016 let _ = writeln!(out, " assert {field_access} is True # noqa: S101");
1017 }
1018 other => {
1019 let _ = writeln!(out, " # TODO: unsupported assertion type: {other}");
1020 }
1021 }
1022}
1023
1024fn value_to_python_string(value: &serde_json::Value) -> String {
1025 match value {
1026 serde_json::Value::String(s) => python_string_literal(s),
1027 serde_json::Value::Bool(true) => "True".to_string(),
1028 serde_json::Value::Bool(false) => "False".to_string(),
1029 serde_json::Value::Number(n) => n.to_string(),
1030 serde_json::Value::Null => "None".to_string(),
1031 other => python_string_literal(&other.to_string()),
1032 }
1033}
1034
1035fn python_string_literal(s: &str) -> String {
1038 if s.contains('"') && !s.contains('\'') {
1039 let escaped = s
1041 .replace('\\', "\\\\")
1042 .replace('\'', "\\'")
1043 .replace('\n', "\\n")
1044 .replace('\r', "\\r")
1045 .replace('\t', "\\t");
1046 format!("'{escaped}'")
1047 } else {
1048 format!("\"{}\"", escape_python(s))
1049 }
1050}
1051
1052fn emit_python_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1054 let params = match method_name {
1055 "visit_link" => "self, ctx, href, text, title",
1056 "visit_image" => "self, ctx, src, alt, title",
1057 "visit_heading" => "self, ctx, level, text, id",
1058 "visit_code_block" => "self, ctx, lang, code",
1059 "visit_code_inline"
1060 | "visit_strong"
1061 | "visit_emphasis"
1062 | "visit_strikethrough"
1063 | "visit_underline"
1064 | "visit_subscript"
1065 | "visit_superscript"
1066 | "visit_mark"
1067 | "visit_button"
1068 | "visit_summary"
1069 | "visit_figcaption"
1070 | "visit_definition_term"
1071 | "visit_definition_description" => "self, ctx, text",
1072 "visit_text" => "self, ctx, text",
1073 "visit_list_item" => "self, ctx, ordered, marker, text",
1074 "visit_blockquote" => "self, ctx, content, depth",
1075 "visit_table_row" => "self, ctx, cells, is_header",
1076 "visit_custom_element" => "self, ctx, tag_name, html",
1077 "visit_form" => "self, ctx, action_url, method",
1078 "visit_input" => "self, ctx, input_type, name, value",
1079 "visit_audio" | "visit_video" | "visit_iframe" => "self, ctx, src",
1080 "visit_details" => "self, ctx, is_open",
1081 _ => "self, ctx, *args",
1082 };
1083
1084 let _ = writeln!(
1085 out,
1086 " def {method_name}({params}): # noqa: A002, ANN001, ANN202, ARG002"
1087 );
1088 match action {
1089 CallbackAction::Skip => {
1090 let _ = writeln!(out, " return \"skip\"");
1091 }
1092 CallbackAction::Continue => {
1093 let _ = writeln!(out, " return \"continue\"");
1094 }
1095 CallbackAction::PreserveHtml => {
1096 let _ = writeln!(out, " return \"preserve_html\"");
1097 }
1098 CallbackAction::Custom { output } => {
1099 let escaped = escape_python(output);
1100 let _ = writeln!(out, " return {{\"custom\": \"{escaped}\"}}");
1101 }
1102 CallbackAction::CustomTemplate { template } => {
1103 let escaped_template = template.replace('\'', "\\'");
1107 let _ = writeln!(out, " return {{\"custom\": f'{escaped_template}'}}");
1108 }
1109 }
1110}