1use crate::codegen::resolve_field;
7use crate::config::E2eConfig;
8use crate::escape::{escape_python, sanitize_filename, sanitize_ident};
9use crate::field_access::FieldResolver;
10use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup};
11use alef_core::backend::GeneratedFile;
12use alef_core::config::AlefConfig;
13use alef_core::hash::{self, CommentStyle};
14use anyhow::Result;
15use heck::{ToShoutySnakeCase, ToSnakeCase};
16use std::collections::HashMap;
17use std::fmt::Write as FmtWrite;
18use std::path::PathBuf;
19
20pub struct PythonE2eCodegen;
22
23impl super::E2eCodegen for PythonE2eCodegen {
24 fn generate(
25 &self,
26 groups: &[FixtureGroup],
27 e2e_config: &E2eConfig,
28 _alef_config: &AlefConfig,
29 ) -> Result<Vec<GeneratedFile>> {
30 let mut files = Vec::new();
31 let output_base = PathBuf::from(e2e_config.effective_output()).join("python");
32
33 files.push(GeneratedFile {
35 path: output_base.join("conftest.py"),
36 content: render_conftest(e2e_config, groups),
37 generated_header: true,
38 });
39
40 files.push(GeneratedFile {
42 path: output_base.join("__init__.py"),
43 content: "\n".to_string(),
44 generated_header: false,
45 });
46
47 files.push(GeneratedFile {
49 path: output_base.join("tests").join("__init__.py"),
50 content: "\n".to_string(),
51 generated_header: false,
52 });
53
54 let python_pkg = e2e_config.resolve_package("python");
56 let pkg_name = python_pkg
57 .as_ref()
58 .and_then(|p| p.name.as_deref())
59 .unwrap_or("kreuzcrawl");
60 let pkg_path = python_pkg
61 .as_ref()
62 .and_then(|p| p.path.as_deref())
63 .unwrap_or("../../packages/python");
64 let pkg_version = python_pkg
65 .as_ref()
66 .and_then(|p| p.version.as_deref())
67 .unwrap_or("0.1.0");
68 files.push(GeneratedFile {
69 path: output_base.join("pyproject.toml"),
70 content: render_pyproject(pkg_name, pkg_path, pkg_version, e2e_config.dep_mode),
71 generated_header: true,
72 });
73
74 for group in groups {
76 let fixtures: Vec<&Fixture> = group.fixtures.iter().collect();
77
78 if fixtures.is_empty() {
79 continue;
80 }
81
82 if fixtures.iter().all(|f| is_skipped(f, "python")) {
87 continue;
88 }
89
90 let filename = format!("test_{}.py", sanitize_filename(&group.category));
91 let content = render_test_file(&group.category, &fixtures, e2e_config);
92
93 files.push(GeneratedFile {
94 path: output_base.join("tests").join(filename),
95 content,
96 generated_header: true,
97 });
98 }
99
100 Ok(files)
101 }
102
103 fn language_name(&self) -> &'static str {
104 "python"
105 }
106}
107
108fn render_pyproject(
113 pkg_name: &str,
114 _pkg_path: &str,
115 pkg_version: &str,
116 dep_mode: crate::config::DependencyMode,
117) -> String {
118 let (deps_line, uv_sources_block) = match dep_mode {
122 crate::config::DependencyMode::Registry => (
123 format!(
124 "dependencies = [ \"pytest>=7.4\", \"pytest-asyncio>=0.23\", \"pytest-timeout>=2.1\", \"{pkg_name}{pkg_version}\" ]"
125 ),
126 String::new(),
127 ),
128 crate::config::DependencyMode::Local => (
129 format!(
130 "dependencies = [ \"pytest>=7.4\", \"pytest-asyncio>=0.23\", \"pytest-timeout>=2.1\", \"{pkg_name}\" ]"
131 ),
132 format!("\n[tool.uv]\nsources.{pkg_name} = {{ workspace = true }}\n"),
133 ),
134 };
135
136 format!(
137 r#"[build-system]
138build-backend = "setuptools.build_meta"
139requires = [ "setuptools>=68", "wheel" ]
140
141[project]
142name = "{pkg_name}-e2e-tests"
143version = "0.0.0"
144description = "End-to-end tests"
145requires-python = ">=3.10"
146classifiers = [
147 "Programming Language :: Python :: 3 :: Only",
148 "Programming Language :: Python :: 3.10",
149 "Programming Language :: Python :: 3.11",
150 "Programming Language :: Python :: 3.12",
151 "Programming Language :: Python :: 3.13",
152 "Programming Language :: Python :: 3.14",
153]
154{deps_line}
155
156[tool.setuptools]
157packages = [ ]
158{uv_sources_block}
159[tool.ruff]
160lint.ignore = [ "PLR2004" ]
161lint.per-file-ignores."tests/**" = [ "B017", "PT011", "S101", "S108" ]
162
163[tool.pytest]
164ini_options.asyncio_mode = "auto"
165ini_options.testpaths = [ "tests" ]
166ini_options.python_files = "test_*.py"
167ini_options.python_functions = "test_*"
168ini_options.addopts = "-v --strict-markers --tb=short"
169ini_options.timeout = 300
170"#
171 )
172}
173
174fn resolve_function_name(e2e_config: &E2eConfig) -> String {
179 resolve_function_name_for_call(&e2e_config.call)
180}
181
182fn resolve_function_name_for_call(call_config: &crate::config::CallConfig) -> String {
183 call_config
184 .overrides
185 .get("python")
186 .and_then(|o| o.function.clone())
187 .unwrap_or_else(|| call_config.function.clone())
188}
189
190fn resolve_module(e2e_config: &E2eConfig) -> String {
191 e2e_config
192 .call
193 .overrides
194 .get("python")
195 .and_then(|o| o.module.clone())
196 .unwrap_or_else(|| e2e_config.call.module.replace('-', "_"))
197}
198
199fn resolve_options_type(e2e_config: &E2eConfig) -> Option<String> {
200 e2e_config
201 .call
202 .overrides
203 .get("python")
204 .and_then(|o| o.options_type.clone())
205}
206
207fn resolve_options_via(e2e_config: &E2eConfig) -> &str {
209 e2e_config
210 .call
211 .overrides
212 .get("python")
213 .and_then(|o| o.options_via.as_deref())
214 .unwrap_or("kwargs")
215}
216
217fn resolve_enum_fields(e2e_config: &E2eConfig) -> &HashMap<String, String> {
219 static EMPTY: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
220 e2e_config
221 .call
222 .overrides
223 .get("python")
224 .map(|o| &o.enum_fields)
225 .unwrap_or(&EMPTY)
226}
227
228fn resolve_handle_nested_types(e2e_config: &E2eConfig) -> &HashMap<String, String> {
231 static EMPTY: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
232 e2e_config
233 .call
234 .overrides
235 .get("python")
236 .map(|o| &o.handle_nested_types)
237 .unwrap_or(&EMPTY)
238}
239
240fn resolve_handle_dict_types(e2e_config: &E2eConfig) -> &std::collections::HashSet<String> {
243 static EMPTY: std::sync::LazyLock<std::collections::HashSet<String>> =
244 std::sync::LazyLock::new(std::collections::HashSet::new);
245 e2e_config
246 .call
247 .overrides
248 .get("python")
249 .map(|o| &o.handle_dict_types)
250 .unwrap_or(&EMPTY)
251}
252
253fn is_skipped(fixture: &Fixture, language: &str) -> bool {
254 fixture.skip.as_ref().is_some_and(|s| s.should_skip(language))
255}
256
257fn render_conftest(e2e_config: &E2eConfig, groups: &[FixtureGroup]) -> String {
262 let module = resolve_module(e2e_config);
263 let has_http_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| f.is_http_test());
264
265 let header = hash::header(CommentStyle::Hash);
266 if has_http_fixtures {
267 format!(
268 r#"{header}"""Pytest configuration for e2e tests."""
269from __future__ import annotations
270
271import os
272import subprocess
273import threading
274from pathlib import Path
275from typing import Generator
276
277import pytest
278
279# Ensure the package is importable.
280# The {module} package is expected to be installed in the current environment.
281
282_HERE = Path(__file__).parent
283_MOCK_SERVER_BIN = _HERE / "rust" / "target" / "release" / "mock-server"
284_FIXTURES_DIR = _HERE.parent / "fixtures"
285
286
287@pytest.fixture(scope="session", autouse=True)
288def mock_server() -> Generator[str, None, None]:
289 """Spawn the mock HTTP server binary and set MOCK_SERVER_URL."""
290 proc = subprocess.Popen( # noqa: S603
291 [str(_MOCK_SERVER_BIN), str(_FIXTURES_DIR)],
292 stdout=subprocess.PIPE,
293 stderr=None,
294 stdin=subprocess.PIPE,
295 )
296 url = ""
297 assert proc.stdout is not None
298 for raw_line in proc.stdout:
299 line = raw_line.decode().strip()
300 if line.startswith("MOCK_SERVER_URL="):
301 url = line.split("=", 1)[1]
302 break
303 os.environ["MOCK_SERVER_URL"] = url
304 # Drain stdout in background so the server never blocks.
305 threading.Thread(target=proc.stdout.read, daemon=True).start()
306 yield url
307 if proc.stdin:
308 proc.stdin.close()
309 proc.terminate()
310 proc.wait()
311
312
313def _make_request(method: str, path: str, **kwargs: object) -> object:
314 """Make an HTTP request to the mock server."""
315 import urllib.request # noqa: PLC0415
316
317 base_url = os.environ.get("MOCK_SERVER_URL", "http://localhost:8080")
318 url = f"{{base_url}}{{path}}"
319 data = kwargs.pop("json", None)
320 if data is not None:
321 import json # noqa: PLC0415
322
323 body = json.dumps(data).encode()
324 headers = dict(kwargs.pop("headers", {{}}))
325 headers.setdefault("Content-Type", "application/json")
326 req = urllib.request.Request(url, data=body, headers=headers, method=method.upper())
327 else:
328 headers = dict(kwargs.pop("headers", {{}}))
329 req = urllib.request.Request(url, headers=headers, method=method.upper())
330 try:
331 with urllib.request.urlopen(req) as resp: # noqa: S310
332 return resp
333 except urllib.error.HTTPError as exc:
334 return exc
335
336
337@pytest.fixture(scope="session")
338def app(mock_server: str) -> object: # noqa: ARG001
339 """Return a simple HTTP helper bound to the mock server URL."""
340
341 class _App:
342 def request(self, path: str, **kwargs: object) -> object:
343 method = str(kwargs.pop("method", "GET"))
344 return _make_request(method, path, **kwargs)
345
346 return _App()
347"#
348 )
349 } else {
350 format!(
351 r#"{header}"""Pytest configuration for e2e tests."""
352# Ensure the package is importable.
353# The {module} package is expected to be installed in the current environment.
354"#
355 )
356 }
357}
358
359fn render_test_file(category: &str, fixtures: &[&Fixture], e2e_config: &E2eConfig) -> String {
360 let mut out = String::new();
361 out.push_str(&hash::header(CommentStyle::Hash));
362 let _ = writeln!(out, "\"\"\"E2e tests for category: {category}.\"\"\"");
363
364 let module = resolve_module(e2e_config);
365 let function_name = resolve_function_name(e2e_config);
366 let options_type = resolve_options_type(e2e_config);
367 let options_via = resolve_options_via(e2e_config);
368 let enum_fields = resolve_enum_fields(e2e_config);
369 let handle_nested_types = resolve_handle_nested_types(e2e_config);
370 let handle_dict_types = resolve_handle_dict_types(e2e_config);
371 let field_resolver = FieldResolver::new(
372 &e2e_config.fields,
373 &e2e_config.fields_optional,
374 &e2e_config.result_fields,
375 &e2e_config.fields_array,
376 );
377
378 let has_error_test = fixtures
379 .iter()
380 .any(|f| f.assertions.iter().any(|a| a.assertion_type == "error"));
381 let has_skipped = fixtures.iter().any(|f| is_skipped(f, "python"));
382 let has_http_tests = fixtures.iter().any(|f| f.is_http_test());
383
384 let is_async = fixtures.iter().any(|f| {
386 let cc = e2e_config.resolve_call(f.call.as_deref());
387 cc.r#async
388 }) || e2e_config.call.r#async;
389 let needs_pytest = has_error_test || has_skipped || is_async;
390
391 let needs_json_import = options_via == "json"
393 && fixtures.iter().any(|f| {
394 e2e_config
395 .call
396 .args
397 .iter()
398 .any(|arg| arg.arg_type == "json_object" && !resolve_field(&f.input, &arg.field).is_null())
399 });
400
401 let needs_os_import = e2e_config.call.args.iter().any(|arg| arg.arg_type == "mock_url");
403
404 let needs_re_import = false;
406 let _ = has_http_tests; let needs_options_type = options_via == "kwargs"
410 && options_type.is_some()
411 && fixtures.iter().any(|f| {
412 e2e_config
413 .call
414 .args
415 .iter()
416 .any(|arg| arg.arg_type == "json_object" && !resolve_field(&f.input, &arg.field).is_null())
417 });
418
419 let mut used_enum_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
421 if needs_options_type && !enum_fields.is_empty() {
422 for fixture in fixtures.iter() {
423 for arg in &e2e_config.call.args {
424 if arg.arg_type == "json_object" {
425 let value = resolve_field(&fixture.input, &arg.field);
426 if let Some(obj) = value.as_object() {
427 for key in obj.keys() {
428 if let Some(enum_type) = enum_fields.get(key) {
429 used_enum_types.insert(enum_type.clone());
430 }
431 }
432 }
433 }
434 }
435 }
436 }
437
438 let mut stdlib_imports: Vec<String> = Vec::new();
442 let mut thirdparty_bare: Vec<String> = Vec::new();
443 let mut thirdparty_from: Vec<String> = Vec::new();
444
445 if needs_json_import {
446 stdlib_imports.push("import json".to_string());
447 }
448
449 if needs_os_import {
450 stdlib_imports.push("import os".to_string());
451 }
452
453 if needs_re_import {
454 stdlib_imports.push("import re".to_string());
455 }
456
457 if needs_pytest {
458 thirdparty_bare.push("import pytest # noqa: F401".to_string());
463 }
464
465 let has_non_http_fixtures = fixtures.iter().any(|f| !f.is_http_test());
467 if has_non_http_fixtures {
468 let handle_constructors: Vec<String> = e2e_config
470 .call
471 .args
472 .iter()
473 .filter(|arg| arg.arg_type == "handle")
474 .map(|arg| format!("create_{}", arg.name.to_snake_case()))
475 .collect();
476
477 let mut import_names: Vec<String> = Vec::new();
481 for fixture in fixtures.iter() {
482 let cc = e2e_config.resolve_call(fixture.call.as_deref());
483 let fn_name = resolve_function_name_for_call(cc);
484 if !import_names.contains(&fn_name) {
485 import_names.push(fn_name);
486 }
487 }
488 if import_names.is_empty() {
491 import_names.push(function_name.clone());
492 }
493 for ctor in &handle_constructors {
494 if !import_names.contains(ctor) {
495 import_names.push(ctor.clone());
496 }
497 }
498
499 let needs_config_import = e2e_config.call.args.iter().any(|arg| {
501 arg.arg_type == "handle"
502 && fixtures.iter().any(|f| {
503 let val = resolve_field(&f.input, &arg.field);
504 !val.is_null() && val.as_object().is_some_and(|o| !o.is_empty())
505 })
506 });
507 if needs_config_import {
508 let config_class = options_type.as_deref().unwrap_or("CrawlConfig");
509 if !import_names.contains(&config_class.to_string()) {
510 import_names.push(config_class.to_string());
511 }
512 }
513
514 if !handle_nested_types.is_empty() {
516 let mut used_nested_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
517 for fixture in fixtures.iter() {
518 for arg in &e2e_config.call.args {
519 if arg.arg_type == "handle" {
520 let config_value = resolve_field(&fixture.input, &arg.field);
521 if let Some(obj) = config_value.as_object() {
522 for key in obj.keys() {
523 if let Some(type_name) = handle_nested_types.get(key) {
524 if obj[key].is_object() {
525 used_nested_types.insert(type_name.clone());
526 }
527 }
528 }
529 }
530 }
531 }
532 }
533 for type_name in used_nested_types {
534 if !import_names.contains(&type_name) {
535 import_names.push(type_name);
536 }
537 }
538 }
539
540 for fixture in fixtures.iter() {
542 for assertion in &fixture.assertions {
543 if assertion.assertion_type == "method_result" {
544 if let Some(method_name) = &assertion.method {
545 let import = python_method_helper_import(method_name);
546 if let Some(name) = import {
547 if !import_names.contains(&name) {
548 import_names.push(name);
549 }
550 }
551 }
552 }
553 }
554 }
555
556 if let (true, Some(opts_type)) = (needs_options_type, &options_type) {
557 import_names.push(opts_type.clone());
558 thirdparty_from.push(format!("from {module} import {}", import_names.join(", ")));
559 if !used_enum_types.is_empty() {
561 let enum_mod = e2e_config
562 .call
563 .overrides
564 .get("python")
565 .and_then(|o| o.enum_module.as_deref())
566 .unwrap_or(&module);
567 let enum_names: Vec<&String> = used_enum_types.iter().collect();
568 thirdparty_from.push(format!(
569 "from {enum_mod} import {}",
570 enum_names.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
571 ));
572 }
573 } else {
574 thirdparty_from.push(format!("from {module} import {}", import_names.join(", ")));
575 }
576 }
577
578 stdlib_imports.sort();
579 thirdparty_bare.sort();
580 thirdparty_from.sort();
581
582 if !stdlib_imports.is_empty() {
584 for imp in &stdlib_imports {
585 let _ = writeln!(out, "{imp}");
586 }
587 let _ = writeln!(out);
588 }
589 for imp in &thirdparty_bare {
591 let _ = writeln!(out, "{imp}");
592 }
593 for imp in &thirdparty_from {
594 let _ = writeln!(out, "{imp}");
595 }
596 let _ = writeln!(out);
598 let _ = writeln!(out);
599
600 for fixture in fixtures {
601 if fixture.is_http_test() {
602 render_http_test_function(&mut out, fixture);
603 } else {
604 render_test_function(
605 &mut out,
606 fixture,
607 e2e_config,
608 options_type.as_deref(),
609 options_via,
610 enum_fields,
611 handle_nested_types,
612 handle_dict_types,
613 &field_resolver,
614 );
615 }
616 let _ = writeln!(out);
617 }
618
619 out
620}
621
622fn render_http_test_function(out: &mut String, fixture: &Fixture) {
633 let Some(http) = &fixture.http else {
634 return;
635 };
636
637 let fn_name = sanitize_ident(&fixture.id);
638 let description = &fixture.description;
639 let desc_with_period = if description.ends_with('.') {
640 description.to_string()
641 } else {
642 format!("{description}.")
643 };
644
645 if is_skipped(fixture, "python") {
646 let reason = fixture
647 .skip
648 .as_ref()
649 .and_then(|s| s.reason.as_deref())
650 .unwrap_or("skipped for python");
651 let escaped = escape_python(reason);
652 let _ = writeln!(out, "@pytest.mark.skip(reason=\"{escaped}\")");
653 }
654
655 let _ = writeln!(out, "def test_{fn_name}(mock_server: str) -> None:");
656 let _ = writeln!(out, " \"\"\"{desc_with_period}\"\"\"");
657 let _ = writeln!(out, " import os # noqa: PLC0415");
658 let _ = writeln!(out, " import urllib.request # noqa: PLC0415");
659 let _ = writeln!(out, " base = os.environ.get(\"MOCK_SERVER_URL\", mock_server)");
660 let fixture_id = fixture.id.as_str();
661 let _ = writeln!(out, " url = f\"{{base}}/fixtures/{fixture_id}\"");
662
663 let method = http.request.method.to_uppercase();
665
666 let mut header_entries: Vec<String> = Vec::new();
668 for (k, v) in &http.request.headers {
669 header_entries.push(format!(" \"{}\": \"{}\",", escape_python(k), escape_python(v)));
670 }
671 let headers_py = if header_entries.is_empty() {
672 "{}".to_string()
673 } else {
674 format!("{{\n{}\n }}", header_entries.join("\n"))
675 };
676
677 if let Some(body) = &http.request.body {
678 let py_body = json_to_python_literal(body);
679 let _ = writeln!(out, " import json # noqa: PLC0415");
680 let _ = writeln!(out, " _headers = {headers_py}");
681 let _ = writeln!(out, " _headers.setdefault(\"Content-Type\", \"application/json\")");
682 let _ = writeln!(out, " _body = json.dumps({py_body}).encode()");
683 let _ = writeln!(
684 out,
685 " _req = urllib.request.Request(url, data=_body, headers=_headers, method=\"{method}\")"
686 );
687 } else {
688 let _ = writeln!(out, " _headers = {headers_py}");
689 let _ = writeln!(
690 out,
691 " _req = urllib.request.Request(url, headers=_headers, method=\"{method}\")"
692 );
693 }
694 let needs_body = http.expected_response.body.is_some()
696 || http.expected_response.body_partial.is_some()
697 || http
698 .expected_response
699 .validation_errors
700 .as_ref()
701 .is_some_and(|v| !v.is_empty());
702 let needs_headers = !http.expected_response.headers.is_empty();
703
704 let _ = writeln!(out, " try:");
705 let _ = writeln!(out, " response = urllib.request.urlopen(_req) # noqa: S310");
706 let _ = writeln!(out, " status_code = response.status");
707 if needs_body {
708 let _ = writeln!(out, " resp_body = response.read()");
709 }
710 if needs_headers {
711 let _ = writeln!(out, " resp_headers = dict(response.headers)");
712 }
713 let _ = writeln!(out, " except urllib.error.HTTPError as _exc:");
714 let _ = writeln!(out, " status_code = _exc.code");
715 if needs_body {
716 let _ = writeln!(out, " resp_body = _exc.read()");
717 }
718 if needs_headers {
719 let _ = writeln!(out, " resp_headers = dict(_exc.headers)");
720 }
721
722 let status = http.expected_response.status_code;
724 let _ = writeln!(out, " assert status_code == {status} # noqa: S101");
725
726 if let Some(expected_body) = &http.expected_response.body {
728 let py_val = json_to_python_literal(expected_body);
729 let _ = writeln!(out, " import json as _json # noqa: PLC0415");
730 let _ = writeln!(out, " data = _json.loads(resp_body)");
731 let _ = writeln!(out, " assert data == {py_val} # noqa: S101");
732 } else if let Some(partial) = &http.expected_response.body_partial {
733 let _ = writeln!(out, " import json as _json # noqa: PLC0415");
734 let _ = writeln!(out, " data = _json.loads(resp_body)");
735 if let Some(obj) = partial.as_object() {
736 for (key, val) in obj {
737 let py_val = json_to_python_literal(val);
738 let escaped_key = escape_python(key);
739 let _ = writeln!(out, " assert data[\"{escaped_key}\"] == {py_val} # noqa: S101");
740 }
741 }
742 }
743
744 for (header_name, header_value) in &http.expected_response.headers {
746 let lower_name = header_name.to_lowercase();
747 let escaped_name = escape_python(&lower_name);
748 match header_value.as_str() {
749 "<<present>>" => {
750 let _ = writeln!(out, " assert \"{escaped_name}\" in resp_headers # noqa: S101");
751 }
752 "<<absent>>" => {
753 let _ = writeln!(
754 out,
755 " assert resp_headers.get(\"{escaped_name}\") is None # noqa: S101"
756 );
757 }
758 "<<uuid>>" => {
759 let _ = writeln!(out, " import re # noqa: PLC0415");
760 let _ = writeln!(
761 out,
762 " assert re.match(r'^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$', resp_headers[\"{escaped_name}\"]) # noqa: S101"
763 );
764 }
765 exact => {
766 let escaped_val = escape_python(exact);
767 let _ = writeln!(
768 out,
769 " assert resp_headers[\"{escaped_name}\"] == \"{escaped_val}\" # noqa: S101"
770 );
771 }
772 }
773 }
774
775 if let Some(validation_errors) = &http.expected_response.validation_errors {
777 if !validation_errors.is_empty() {
778 let _ = writeln!(out, " import json as _json # noqa: PLC0415");
779 let _ = writeln!(out, " _data = _json.loads(resp_body)");
780 let _ = writeln!(out, " errors = _data.get(\"detail\", [])");
781 for ve in validation_errors {
782 let loc_py: Vec<String> = ve.loc.iter().map(|s| format!("\"{}\"", escape_python(s))).collect();
783 let loc_str = loc_py.join(", ");
784 let escaped_msg = escape_python(&ve.msg);
785 let _ = writeln!(
786 out,
787 " assert any(e[\"loc\"] == [{loc_str}] and \"{escaped_msg}\" in e[\"msg\"] for e in errors) # noqa: S101"
788 );
789 }
790 }
791 }
792}
793
794#[allow(clippy::too_many_arguments)]
799fn render_test_function(
800 out: &mut String,
801 fixture: &Fixture,
802 e2e_config: &E2eConfig,
803 options_type: Option<&str>,
804 options_via: &str,
805 enum_fields: &HashMap<String, String>,
806 handle_nested_types: &HashMap<String, String>,
807 handle_dict_types: &std::collections::HashSet<String>,
808 field_resolver: &FieldResolver,
809) {
810 let fn_name = sanitize_ident(&fixture.id);
811 let description = &fixture.description;
812 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
813 let function_name = resolve_function_name_for_call(call_config);
814 let result_var = &call_config.result_var;
815
816 let desc_with_period = if description.ends_with('.') {
817 description.to_string()
818 } else {
819 format!("{description}.")
820 };
821
822 if is_skipped(fixture, "python") {
824 let reason = fixture
825 .skip
826 .as_ref()
827 .and_then(|s| s.reason.as_deref())
828 .unwrap_or("skipped for python");
829 let escaped = escape_python(reason);
830 let _ = writeln!(out, "@pytest.mark.skip(reason=\"{escaped}\")");
831 }
832
833 let is_async = call_config.r#async;
834 if is_async {
835 let _ = writeln!(out, "@pytest.mark.asyncio");
836 let _ = writeln!(out, "async def test_{fn_name}() -> None:");
837 } else {
838 let _ = writeln!(out, "def test_{fn_name}() -> None:");
839 }
840 let _ = writeln!(out, " \"\"\"{desc_with_period}\"\"\"");
841
842 let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
844
845 let mut arg_bindings = Vec::new();
847 let mut kwarg_exprs = Vec::new();
848 for arg in &call_config.args {
849 let var_name = &arg.name;
850
851 if arg.arg_type == "handle" {
852 let constructor_name = format!("create_{}", arg.name.to_snake_case());
855 let config_value = resolve_field(&fixture.input, &arg.field);
856 if config_value.is_null()
857 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
858 {
859 arg_bindings.push(format!(" {var_name} = {constructor_name}(None)"));
860 } else if let Some(obj) = config_value.as_object() {
861 let kwargs: Vec<String> = obj
865 .iter()
866 .map(|(k, v)| {
867 let snake_key = k.to_snake_case();
868 let py_val = if let Some(type_name) = handle_nested_types.get(k) {
869 if let Some(nested_obj) = v.as_object() {
871 if nested_obj.is_empty() {
872 format!("{type_name}()")
874 } else if handle_dict_types.contains(k) {
875 json_to_python_literal(v)
880 } else {
881 let nested_kwargs: Vec<String> = nested_obj
883 .iter()
884 .map(|(nk, nv)| {
885 let nested_snake_key = nk.to_snake_case();
886 format!("{nested_snake_key}={}", json_to_python_literal(nv))
887 })
888 .collect();
889 format!("{type_name}({})", nested_kwargs.join(", "))
890 }
891 } else {
892 json_to_python_literal(v)
894 }
895 } else if k == "request_timeout" {
896 if let Some(ms) = v.as_u64() {
902 format!("{}", ms / 1000)
903 } else {
904 json_to_python_literal(v)
905 }
906 } else {
907 json_to_python_literal(v)
908 };
909 format!("{snake_key}={py_val}")
910 })
911 .collect();
912 let config_class = options_type.unwrap_or("CrawlConfig");
914 let single_line = format!(" {var_name}_config = {config_class}({})", kwargs.join(", "));
915 if single_line.len() <= 120 {
916 arg_bindings.push(single_line);
917 } else {
918 let mut lines = format!(" {var_name}_config = {config_class}(\n");
920 for kw in &kwargs {
921 lines.push_str(&format!(" {kw},\n"));
922 }
923 lines.push_str(" )");
924 arg_bindings.push(lines);
925 }
926 arg_bindings.push(format!(" {var_name} = {constructor_name}({var_name}_config)"));
927 } else {
928 let literal = json_to_python_literal(config_value);
929 arg_bindings.push(format!(" {var_name} = {constructor_name}({literal})"));
930 }
931 kwarg_exprs.push(format!("{var_name}={var_name}"));
932 continue;
933 }
934
935 if arg.arg_type == "mock_url" {
936 let fixture_id = &fixture.id;
937 arg_bindings.push(format!(
938 " {var_name} = os.environ['MOCK_SERVER_URL'] + '/fixtures/{fixture_id}'"
939 ));
940 kwarg_exprs.push(format!("{var_name}={var_name}"));
941 continue;
942 }
943
944 let value = resolve_field(&fixture.input, &arg.field);
945
946 if value.is_null() && arg.optional {
947 continue;
948 }
949
950 if arg.arg_type == "json_object" && !value.is_null() {
953 match options_via {
954 "dict" => {
955 let literal = json_to_python_literal(value);
957 let noqa = if literal.contains("/tmp/") {
958 " # noqa: S108"
959 } else {
960 ""
961 };
962 arg_bindings.push(format!(" {var_name} = {literal}{noqa}"));
963 kwarg_exprs.push(format!("{var_name}={var_name}"));
964 continue;
965 }
966 "json" => {
967 let json_str = serde_json::to_string(value).unwrap_or_default();
969 let escaped = escape_python(&json_str);
970 arg_bindings.push(format!(" {var_name} = json.loads(\"{escaped}\")"));
971 kwarg_exprs.push(format!("{var_name}={var_name}"));
972 continue;
973 }
974 _ => {
975 if let (Some(opts_type), Some(obj)) = (options_type, value.as_object()) {
977 let kwargs: Vec<String> = obj
978 .iter()
979 .map(|(k, v)| {
980 let snake_key = k.to_snake_case();
981 let py_val = if let Some(enum_type) = enum_fields.get(k) {
982 if let Some(s) = v.as_str() {
984 let upper_val = s.to_shouty_snake_case();
985 format!("{enum_type}.{upper_val}")
986 } else {
987 json_to_python_literal(v)
988 }
989 } else {
990 json_to_python_literal(v)
991 };
992 format!("{snake_key}={py_val}")
993 })
994 .collect();
995 let constructor = format!("{opts_type}({})", kwargs.join(", "));
996 arg_bindings.push(format!(" {var_name} = {constructor}"));
997 kwarg_exprs.push(format!("{var_name}={var_name}"));
998 continue;
999 }
1000 }
1001 }
1002 }
1003
1004 if arg.optional && value.is_null() {
1007 continue;
1008 }
1009
1010 if value.is_null() && !arg.optional {
1012 let default_val = match arg.arg_type.as_str() {
1013 "string" => "\"\"".to_string(),
1014 "int" | "integer" => "0".to_string(),
1015 "float" | "number" => "0.0".to_string(),
1016 "bool" | "boolean" => "False".to_string(),
1017 _ => "None".to_string(),
1018 };
1019 arg_bindings.push(format!(" {var_name} = {default_val}"));
1020 kwarg_exprs.push(format!("{var_name}={var_name}"));
1021 continue;
1022 }
1023
1024 let literal = json_to_python_literal(value);
1025 let noqa = if literal.contains("/tmp/") {
1026 " # noqa: S108"
1027 } else {
1028 ""
1029 };
1030 arg_bindings.push(format!(" {var_name} = {literal}{noqa}"));
1031 kwarg_exprs.push(format!("{var_name}={var_name}"));
1032 }
1033
1034 if let Some(visitor_spec) = &fixture.visitor {
1036 let _ = writeln!(out, " class _TestVisitor:");
1037 for (method_name, action) in &visitor_spec.callbacks {
1038 emit_python_visitor_method(out, method_name, action);
1039 }
1040 kwarg_exprs.push("visitor=_TestVisitor()".to_string());
1041 }
1042
1043 for binding in &arg_bindings {
1044 let _ = writeln!(out, "{binding}");
1045 }
1046
1047 let call_args = kwarg_exprs.join(", ");
1048 let await_prefix = if is_async { "await " } else { "" };
1049 let call_expr = format!("{await_prefix}{function_name}({call_args})");
1050
1051 if has_error_assertion {
1052 let error_assertion = fixture.assertions.iter().find(|a| a.assertion_type == "error");
1054 let has_message = error_assertion
1055 .and_then(|a| a.value.as_ref())
1056 .and_then(|v| v.as_str())
1057 .is_some();
1058
1059 if has_message {
1060 let _ = writeln!(out, " with pytest.raises(Exception) as exc_info: # noqa: B017");
1061 let _ = writeln!(out, " {call_expr}");
1062 if let Some(msg) = error_assertion.and_then(|a| a.value.as_ref()).and_then(|v| v.as_str()) {
1063 let escaped = escape_python(msg);
1064 let _ = writeln!(out, " assert \"{escaped}\" in str(exc_info.value) # noqa: S101");
1065 }
1066 } else {
1067 let _ = writeln!(out, " with pytest.raises(Exception): # noqa: B017");
1068 let _ = writeln!(out, " {call_expr}");
1069 }
1070
1071 return;
1074 }
1075
1076 let has_usable_assertion = fixture.assertions.iter().any(|a| {
1079 if a.assertion_type == "not_error" || a.assertion_type == "error" {
1080 return false;
1081 }
1082 match &a.field {
1083 Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
1084 _ => true,
1085 }
1086 });
1087 let py_result_var = if has_usable_assertion {
1088 result_var.to_string()
1089 } else {
1090 "_".to_string()
1091 };
1092 let _ = writeln!(out, " {py_result_var} = {call_expr}");
1093
1094 let fields_enum = &e2e_config.fields_enum;
1095 for assertion in &fixture.assertions {
1096 if assertion.assertion_type == "not_error" {
1097 if !call_config.returns_result {
1100 continue;
1101 }
1102 continue;
1104 }
1105 render_assertion(out, assertion, result_var, field_resolver, fields_enum);
1106 }
1107}
1108
1109fn json_to_python_literal(value: &serde_json::Value) -> String {
1114 match value {
1115 serde_json::Value::Null => "None".to_string(),
1116 serde_json::Value::Bool(true) => "True".to_string(),
1117 serde_json::Value::Bool(false) => "False".to_string(),
1118 serde_json::Value::Number(n) => n.to_string(),
1119 serde_json::Value::String(s) => python_string_literal(s),
1120 serde_json::Value::Array(arr) => {
1121 let items: Vec<String> = arr.iter().map(json_to_python_literal).collect();
1122 format!("[{}]", items.join(", "))
1123 }
1124 serde_json::Value::Object(map) => {
1125 let items: Vec<String> = map
1126 .iter()
1127 .map(|(k, v)| format!("\"{}\": {}", escape_python(k), json_to_python_literal(v)))
1128 .collect();
1129 format!("{{{}}}", items.join(", "))
1130 }
1131 }
1132}
1133
1134fn render_assertion(
1139 out: &mut String,
1140 assertion: &Assertion,
1141 result_var: &str,
1142 field_resolver: &FieldResolver,
1143 fields_enum: &std::collections::HashSet<String>,
1144) {
1145 if let Some(f) = &assertion.field {
1147 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1148 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
1149 return;
1150 }
1151 }
1152
1153 let field_access = match &assertion.field {
1154 Some(f) if !f.is_empty() => field_resolver.accessor(f, "python", result_var),
1155 _ => result_var.to_string(),
1156 };
1157
1158 let field_is_enum = assertion.field.as_deref().is_some_and(|f| {
1169 if fields_enum.contains(f) {
1170 return true;
1171 }
1172 let resolved = field_resolver.resolve(f);
1173 if fields_enum.contains(resolved) {
1174 return true;
1175 }
1176 field_resolver.accessor(f, "python", result_var).contains("[0]")
1181 });
1182
1183 let field_is_optional = match &assertion.field {
1186 Some(f) if !f.is_empty() => {
1187 let resolved = field_resolver.resolve(f);
1188 field_resolver.is_optional(resolved)
1189 }
1190 _ => false,
1191 };
1192
1193 match assertion.assertion_type.as_str() {
1194 "error" | "not_error" => {
1195 }
1197 "equals" => {
1198 if let Some(val) = &assertion.value {
1199 let expected = value_to_python_string(val);
1200 let op = if val.is_boolean() || val.is_null() { "is" } else { "==" };
1202 if val.is_string() {
1205 let _ = writeln!(out, " assert {field_access}.strip() {op} {expected} # noqa: S101");
1206 } else {
1207 let _ = writeln!(out, " assert {field_access} {op} {expected} # noqa: S101");
1208 }
1209 }
1210 }
1211 "contains" => {
1212 if let Some(val) = &assertion.value {
1213 let expected = value_to_python_string(val);
1214 let cmp_expr = if field_is_enum && val.is_string() {
1216 format!("str({field_access}).lower()")
1217 } else {
1218 field_access.clone()
1219 };
1220 if field_is_optional {
1221 let _ = writeln!(out, " assert {field_access} is not None # noqa: S101");
1222 let _ = writeln!(out, " assert {expected} in {cmp_expr} # noqa: S101");
1223 } else {
1224 let _ = writeln!(out, " assert {expected} in {cmp_expr} # noqa: S101");
1225 }
1226 }
1227 }
1228 "contains_all" => {
1229 if let Some(values) = &assertion.values {
1230 for val in values {
1231 let expected = value_to_python_string(val);
1232 let cmp_expr = if field_is_enum && val.is_string() {
1234 format!("str({field_access}).lower()")
1235 } else {
1236 field_access.clone()
1237 };
1238 if field_is_optional {
1239 let _ = writeln!(out, " assert {field_access} is not None # noqa: S101");
1240 let _ = writeln!(out, " assert {expected} in {cmp_expr} # noqa: S101");
1241 } else {
1242 let _ = writeln!(out, " assert {expected} in {cmp_expr} # noqa: S101");
1243 }
1244 }
1245 }
1246 }
1247 "not_contains" => {
1248 if let Some(val) = &assertion.value {
1249 let expected = value_to_python_string(val);
1250 let cmp_expr = if field_is_enum && val.is_string() {
1252 format!("str({field_access}).lower()")
1253 } else {
1254 field_access.clone()
1255 };
1256 if field_is_optional {
1257 let _ = writeln!(
1258 out,
1259 " assert {field_access} is None or {expected} not in {cmp_expr} # noqa: S101"
1260 );
1261 } else {
1262 let _ = writeln!(out, " assert {expected} not in {cmp_expr} # noqa: S101");
1263 }
1264 }
1265 }
1266 "not_empty" => {
1267 let _ = writeln!(out, " assert {field_access} # noqa: S101");
1268 }
1269 "is_empty" => {
1270 let _ = writeln!(out, " assert not {field_access} # noqa: S101");
1271 }
1272 "contains_any" => {
1273 if let Some(values) = &assertion.values {
1274 let items: Vec<String> = values.iter().map(value_to_python_string).collect();
1275 let list_str = items.join(", ");
1276 let cmp_expr = if field_is_enum {
1278 format!("str({field_access}).lower()")
1279 } else {
1280 field_access.clone()
1281 };
1282 if field_is_optional {
1283 let _ = writeln!(out, " assert {field_access} is not None # noqa: S101");
1284 let _ = writeln!(
1285 out,
1286 " assert any(v in {cmp_expr} for v in [{list_str}]) # noqa: S101"
1287 );
1288 } else {
1289 let _ = writeln!(
1290 out,
1291 " assert any(v in {cmp_expr} for v in [{list_str}]) # noqa: S101"
1292 );
1293 }
1294 }
1295 }
1296 "greater_than" => {
1297 if let Some(val) = &assertion.value {
1298 let expected = value_to_python_string(val);
1299 let _ = writeln!(out, " assert {field_access} > {expected} # noqa: S101");
1300 }
1301 }
1302 "less_than" => {
1303 if let Some(val) = &assertion.value {
1304 let expected = value_to_python_string(val);
1305 let _ = writeln!(out, " assert {field_access} < {expected} # noqa: S101");
1306 }
1307 }
1308 "greater_than_or_equal" | "min" => {
1309 if let Some(val) = &assertion.value {
1310 let expected = value_to_python_string(val);
1311 let _ = writeln!(out, " assert {field_access} >= {expected} # noqa: S101");
1312 }
1313 }
1314 "less_than_or_equal" | "max" => {
1315 if let Some(val) = &assertion.value {
1316 let expected = value_to_python_string(val);
1317 let _ = writeln!(out, " assert {field_access} <= {expected} # noqa: S101");
1318 }
1319 }
1320 "starts_with" => {
1321 if let Some(val) = &assertion.value {
1322 let expected = value_to_python_string(val);
1323 let _ = writeln!(out, " assert {field_access}.startswith({expected}) # noqa: S101");
1324 }
1325 }
1326 "ends_with" => {
1327 if let Some(val) = &assertion.value {
1328 let expected = value_to_python_string(val);
1329 let _ = writeln!(out, " assert {field_access}.endswith({expected}) # noqa: S101");
1330 }
1331 }
1332 "min_length" => {
1333 if let Some(val) = &assertion.value {
1334 if let Some(n) = val.as_u64() {
1335 let _ = writeln!(out, " assert len({field_access}) >= {n} # noqa: S101");
1336 }
1337 }
1338 }
1339 "max_length" => {
1340 if let Some(val) = &assertion.value {
1341 if let Some(n) = val.as_u64() {
1342 let _ = writeln!(out, " assert len({field_access}) <= {n} # noqa: S101");
1343 }
1344 }
1345 }
1346 "count_min" => {
1347 if let Some(val) = &assertion.value {
1348 if let Some(n) = val.as_u64() {
1349 let _ = writeln!(out, " assert len({field_access}) >= {n} # noqa: S101");
1350 }
1351 }
1352 }
1353 "count_equals" => {
1354 if let Some(val) = &assertion.value {
1355 if let Some(n) = val.as_u64() {
1356 let _ = writeln!(out, " assert len({field_access}) == {n} # noqa: S101");
1357 }
1358 }
1359 }
1360 "is_true" => {
1361 let _ = writeln!(out, " assert {field_access} is True # noqa: S101");
1362 }
1363 "is_false" => {
1364 let _ = writeln!(out, " assert not {field_access} # noqa: S101");
1365 }
1366 "method_result" => {
1367 if let Some(method_name) = &assertion.method {
1368 let call_expr = build_python_method_call(result_var, method_name, assertion.args.as_ref());
1369 let check = assertion.check.as_deref().unwrap_or("is_true");
1370 match check {
1371 "equals" => {
1372 if let Some(val) = &assertion.value {
1373 if val.is_boolean() {
1374 if val.as_bool() == Some(true) {
1375 let _ = writeln!(out, " assert {call_expr} is True # noqa: S101");
1376 } else {
1377 let _ = writeln!(out, " assert {call_expr} is False # noqa: S101");
1378 }
1379 } else {
1380 let expected = value_to_python_string(val);
1381 let _ = writeln!(out, " assert {call_expr} == {expected} # noqa: S101");
1382 }
1383 }
1384 }
1385 "is_true" => {
1386 let _ = writeln!(out, " assert {call_expr} # noqa: S101");
1387 }
1388 "is_false" => {
1389 let _ = writeln!(out, " assert not {call_expr} # noqa: S101");
1390 }
1391 "greater_than_or_equal" => {
1392 if let Some(val) = &assertion.value {
1393 let n = val.as_u64().unwrap_or(0);
1394 let _ = writeln!(out, " assert {call_expr} >= {n} # noqa: S101");
1395 }
1396 }
1397 "count_min" => {
1398 if let Some(val) = &assertion.value {
1399 let n = val.as_u64().unwrap_or(0);
1400 let _ = writeln!(out, " assert len({call_expr}) >= {n} # noqa: S101");
1401 }
1402 }
1403 "contains" => {
1404 if let Some(val) = &assertion.value {
1405 let expected = value_to_python_string(val);
1406 let _ = writeln!(out, " assert {expected} in {call_expr} # noqa: S101");
1407 }
1408 }
1409 "is_error" => {
1410 let _ = writeln!(out, " with pytest.raises(Exception): # noqa: B017");
1411 let _ = writeln!(out, " {call_expr}");
1412 }
1413 other_check => {
1414 panic!("unsupported method_result check type: {other_check}");
1415 }
1416 }
1417 } else {
1418 panic!("method_result assertion missing 'method' field");
1419 }
1420 }
1421 "matches_regex" => {
1422 if let Some(val) = &assertion.value {
1423 let expected = value_to_python_string(val);
1424 let _ = writeln!(out, " import re # noqa: PLC0415");
1425 let _ = writeln!(
1426 out,
1427 " assert re.search({expected}, {field_access}) is not None # noqa: S101"
1428 );
1429 }
1430 }
1431 other => {
1432 panic!("unsupported assertion type: {other}");
1433 }
1434 }
1435}
1436
1437fn build_python_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
1440 match method_name {
1441 "root_child_count" => format!("{result_var}.root_node().child_count()"),
1442 "root_node_type" => format!("{result_var}.root_node().kind()"),
1443 "named_children_count" => format!("{result_var}.root_node().named_child_count()"),
1444 "has_error_nodes" => format!("tree_has_error_nodes({result_var})"),
1445 "error_count" | "tree_error_count" => format!("tree_error_count({result_var})"),
1446 "tree_to_sexp" => format!("tree_to_sexp({result_var})"),
1447 "contains_node_type" => {
1448 let node_type = args
1449 .and_then(|a| a.get("node_type"))
1450 .and_then(|v| v.as_str())
1451 .unwrap_or("");
1452 format!("tree_contains_node_type({result_var}, \"{node_type}\")")
1453 }
1454 "find_nodes_by_type" => {
1455 let node_type = args
1456 .and_then(|a| a.get("node_type"))
1457 .and_then(|v| v.as_str())
1458 .unwrap_or("");
1459 format!("find_nodes_by_type({result_var}, \"{node_type}\")")
1460 }
1461 "run_query" => {
1462 let query_source = args
1463 .and_then(|a| a.get("query_source"))
1464 .and_then(|v| v.as_str())
1465 .unwrap_or("");
1466 let language = args
1467 .and_then(|a| a.get("language"))
1468 .and_then(|v| v.as_str())
1469 .unwrap_or("");
1470 format!("run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1471 }
1472 _ => {
1473 if let Some(args_val) = args {
1474 let arg_str = args_val
1475 .as_object()
1476 .map(|obj| {
1477 obj.iter()
1478 .map(|(k, v)| format!("{}={}", k, value_to_python_string(v)))
1479 .collect::<Vec<_>>()
1480 .join(", ")
1481 })
1482 .unwrap_or_default();
1483 format!("{result_var}.{method_name}({arg_str})")
1484 } else {
1485 format!("{result_var}.{method_name}()")
1486 }
1487 }
1488 }
1489}
1490
1491fn python_method_helper_import(method_name: &str) -> Option<String> {
1494 match method_name {
1495 "has_error_nodes" => Some("tree_has_error_nodes".to_string()),
1496 "error_count" | "tree_error_count" => Some("tree_error_count".to_string()),
1497 "tree_to_sexp" => Some("tree_to_sexp".to_string()),
1498 "contains_node_type" => Some("tree_contains_node_type".to_string()),
1499 "find_nodes_by_type" => Some("find_nodes_by_type".to_string()),
1500 "run_query" => Some("run_query".to_string()),
1501 _ => None,
1503 }
1504}
1505
1506fn value_to_python_string(value: &serde_json::Value) -> String {
1507 match value {
1508 serde_json::Value::String(s) => python_string_literal(s),
1509 serde_json::Value::Bool(true) => "True".to_string(),
1510 serde_json::Value::Bool(false) => "False".to_string(),
1511 serde_json::Value::Number(n) => n.to_string(),
1512 serde_json::Value::Null => "None".to_string(),
1513 other => python_string_literal(&other.to_string()),
1514 }
1515}
1516
1517fn python_string_literal(s: &str) -> String {
1520 if s.contains('"') && !s.contains('\'') {
1521 let escaped = s
1523 .replace('\\', "\\\\")
1524 .replace('\'', "\\'")
1525 .replace('\n', "\\n")
1526 .replace('\r', "\\r")
1527 .replace('\t', "\\t");
1528 format!("'{escaped}'")
1529 } else {
1530 format!("\"{}\"", escape_python(s))
1531 }
1532}
1533
1534fn emit_python_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1536 let params = match method_name {
1537 "visit_link" => "self, ctx, href, text, title",
1538 "visit_image" => "self, ctx, src, alt, title",
1539 "visit_heading" => "self, ctx, level, text, id",
1540 "visit_code_block" => "self, ctx, lang, code",
1541 "visit_code_inline"
1542 | "visit_strong"
1543 | "visit_emphasis"
1544 | "visit_strikethrough"
1545 | "visit_underline"
1546 | "visit_subscript"
1547 | "visit_superscript"
1548 | "visit_mark"
1549 | "visit_button"
1550 | "visit_summary"
1551 | "visit_figcaption"
1552 | "visit_definition_term"
1553 | "visit_definition_description" => "self, ctx, text",
1554 "visit_text" => "self, ctx, text",
1555 "visit_list_item" => "self, ctx, ordered, marker, text",
1556 "visit_blockquote" => "self, ctx, content, depth",
1557 "visit_table_row" => "self, ctx, cells, is_header",
1558 "visit_custom_element" => "self, ctx, tag_name, html",
1559 "visit_form" => "self, ctx, action_url, method",
1560 "visit_input" => "self, ctx, input_type, name, value",
1561 "visit_audio" | "visit_video" | "visit_iframe" => "self, ctx, src",
1562 "visit_details" => "self, ctx, is_open",
1563 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
1564 "self, ctx, output, *args"
1565 }
1566 "visit_list_start" => "self, ctx, ordered, *args",
1567 "visit_list_end" => "self, ctx, ordered, output, *args",
1568 _ => "self, ctx, *args",
1569 };
1570
1571 let _ = writeln!(
1572 out,
1573 " def {method_name}({params}): # noqa: A002, ANN001, ANN202, ARG002"
1574 );
1575 match action {
1576 CallbackAction::Skip => {
1577 let _ = writeln!(out, " return \"skip\"");
1578 }
1579 CallbackAction::Continue => {
1580 let _ = writeln!(out, " return \"continue\"");
1581 }
1582 CallbackAction::PreserveHtml => {
1583 let _ = writeln!(out, " return \"preserve_html\"");
1584 }
1585 CallbackAction::Custom { output } => {
1586 let escaped = escape_python(output);
1587 let _ = writeln!(out, " return {{\"custom\": \"{escaped}\"}}");
1588 }
1589 CallbackAction::CustomTemplate { template } => {
1590 let escaped_template = template
1595 .replace('\\', "\\\\")
1596 .replace('\'', "\\'")
1597 .replace('\n', "\\n")
1598 .replace('\r', "\\r")
1599 .replace('\t', "\\t");
1600 let _ = writeln!(out, " return {{\"custom\": f'{escaped_template}'}}");
1601 }
1602 }
1603}