Skip to main content

alef_e2e/codegen/
python.rs

1//! Python e2e test code generator.
2//!
3//! Generates `e2e/python/conftest.py` and `tests/test_{category}.py` files from
4//! JSON fixtures, driven entirely by `E2eConfig` and `CallConfig`.
5
6use 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
20/// Python e2e test code generator.
21pub 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        // conftest.py
34        files.push(GeneratedFile {
35            path: output_base.join("conftest.py"),
36            content: render_conftest(e2e_config, groups),
37            generated_header: true,
38        });
39
40        // Root __init__.py (prevents ruff INP001).
41        files.push(GeneratedFile {
42            path: output_base.join("__init__.py"),
43            content: "\n".to_string(),
44            generated_header: false,
45        });
46
47        // tests/__init__.py
48        files.push(GeneratedFile {
49            path: output_base.join("tests").join("__init__.py"),
50            content: "\n".to_string(),
51            generated_header: false,
52        });
53
54        // pyproject.toml for standalone uv resolution
55        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        // Per-category test files.
75        for group in groups {
76            let fixtures: Vec<&Fixture> = group.fixtures.iter().collect();
77
78            if fixtures.is_empty() {
79                continue;
80            }
81
82            // Skip emitting the file entirely when every fixture is skipped for
83            // python — there's nothing to run, and emitting imports of
84            // not-bound APIs causes module-level ImportError that masks the
85            // skip marker.
86            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
108// ---------------------------------------------------------------------------
109// pyproject.toml
110// ---------------------------------------------------------------------------
111
112fn render_pyproject(
113    pkg_name: &str,
114    _pkg_path: &str,
115    pkg_version: &str,
116    dep_mode: crate::config::DependencyMode,
117) -> String {
118    // Generate in pyproject-fmt canonical form so the pre-commit hook is a no-op.
119    // pyproject-fmt sorts deps alphabetically, uses spaces inside brackets, dotted
120    // tool keys, and injects Python classifiers.
121    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
174// ---------------------------------------------------------------------------
175// Config resolution helpers
176// ---------------------------------------------------------------------------
177
178fn 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
207/// Resolve how json_object args are passed: "kwargs" (default), "dict", or "json".
208fn 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
217/// Resolve enum field mappings from the Python override config.
218fn 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
228/// Resolve handle nested type mappings from the Python override config.
229/// Maps config field names to their Python constructor type names.
230fn 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
240/// Resolve handle dict type set from the Python override config.
241/// Fields in this set use `TypeName({...})` instead of `TypeName(key=val, ...)`.
242fn 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
257// ---------------------------------------------------------------------------
258// Rendering
259// ---------------------------------------------------------------------------
260
261fn 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    // Check if any fixture in this file uses an async call.
385    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    // "json" mode needs `import json`.
392    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    // mock_url args need `import os`.
402    let needs_os_import = e2e_config.call.args.iter().any(|arg| arg.arg_type == "mock_url");
403
404    // HTTP tests handle `import re` inline (per-test), so no top-level re import is needed.
405    let needs_re_import = false;
406    let _ = has_http_tests; // used indirectly via inline imports in render_http_test_function
407
408    // Only import options_type when using "kwargs" mode.
409    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    // Collect enum types actually used across all fixtures in this file.
420    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    // Collect imports sorted per isort/ruff I001: stdlib group, then
439    // third-party group, separated by a blank line. Within each group
440    // `import X` lines come before `from X import Y` lines, both sorted.
441    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        // F401 (unused-import) suppression: pytest is needed at module level for
459        // its fixture decorators and `pytest.mark.*` annotations, but ruff cannot
460        // statically tell whether a generated test file references those — so we
461        // hint to ruff that the import is intentional.
462        thirdparty_bare.push("import pytest  # noqa: F401".to_string());
463    }
464
465    // For non-HTTP fixtures, build the normal function imports.
466    let has_non_http_fixtures = fixtures.iter().any(|f| !f.is_http_test());
467    if has_non_http_fixtures {
468        // Collect handle constructor function names that need to be imported.
469        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        // Collect all unique function names actually used across all fixtures in this file.
478        // Do not seed with the default function_name — only include it when at least one
479        // fixture resolves to it, to avoid unused-import (F401) warnings from ruff.
480        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        // Safety net: should not occur since the group is non-empty, but ensures
489        // import_names is never empty if all fixtures use the default call.
490        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        // If any handle arg has config, import the config class (CrawlConfig or options_type).
500        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        // Import any nested handle config types actually used in this file.
515        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        // Collect method_result helper function imports.
541        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            // Import enum types from enum_module (if specified) or main module.
560            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    // Emit sorted import groups with blank lines between groups per PEP 8.
583    if !stdlib_imports.is_empty() {
584        for imp in &stdlib_imports {
585            let _ = writeln!(out, "{imp}");
586        }
587        let _ = writeln!(out);
588    }
589    // Third-party: bare imports then from-imports, no blank line between them.
590    for imp in &thirdparty_bare {
591        let _ = writeln!(out, "{imp}");
592    }
593    for imp in &thirdparty_from {
594        let _ = writeln!(out, "{imp}");
595    }
596    // Two blank lines after imports (PEP 8 / ruff I001).
597    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
622// ---------------------------------------------------------------------------
623// HTTP server test rendering
624// ---------------------------------------------------------------------------
625
626/// Render a pytest test function for an HTTP server fixture.
627///
628/// The generated test:
629/// 1. Receives a `client` fixture from conftest.py (the test server client).
630/// 2. Sends the configured request.
631/// 3. Asserts status code, body (exact or partial), headers, and validation errors.
632fn 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    // Build the request call using urllib.
664    let method = http.request.method.to_uppercase();
665
666    // Build headers dict.
667    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    // Determine which response variables are actually needed.
695    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    // Status code assertion.
723    let status = http.expected_response.status_code;
724    let _ = writeln!(out, "    assert status_code == {status}  # noqa: S101");
725
726    // Body assertions.
727    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    // Header assertions.
745    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    // Validation error assertions.
776    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// ---------------------------------------------------------------------------
795// Function-call test rendering
796// ---------------------------------------------------------------------------
797
798#[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    // Emit pytest.mark.skip for fixtures that should be skipped for python.
823    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    // Check if any assertion is an error assertion.
843    let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
844
845    // Build argument expressions from config.
846    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            // Generate a create_engine (or equivalent) call and pass the variable.
853            // If there's config data, construct a CrawlConfig with kwargs.
854            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                // Build kwargs for the config constructor (CrawlConfig(key=val, ...)).
862                // For fields with a nested type mapping, wrap the dict value in the
863                // appropriate typed constructor instead of passing a plain dict.
864                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                            // Wrap the nested dict in the typed constructor.
870                            if let Some(nested_obj) = v.as_object() {
871                                if nested_obj.is_empty() {
872                                    // Empty dict: use the default constructor.
873                                    format!("{type_name}()")
874                                } else if handle_dict_types.contains(k) {
875                                    // The outer Python config type (e.g. CrawlConfig) accepts a
876                                    // plain dict for this field (e.g. `auth: dict | None`).
877                                    // The binding-layer wrapper (e.g. api.py) creates the typed
878                                    // object internally, so we must NOT pre-wrap it here.
879                                    json_to_python_literal(v)
880                                } else {
881                                    // Type takes keyword arguments.
882                                    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                                // Non-object value: use as-is.
893                                json_to_python_literal(v)
894                            }
895                        } else if k == "request_timeout" {
896                            // The Python binding converts request_timeout with Duration::from_secs
897                            // (seconds) while fixtures specify values in milliseconds. Divide by
898                            // 1000 to compensate: e.g., 1 ms → 0 s (immediate timeout),
899                            // 5000 ms → 5 s. This keeps test semantics consistent with the
900                            // fixture intent.
901                            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                // Use the options_type if configured, otherwise "CrawlConfig".
913                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                    // Split into multi-line for readability and E501 compliance.
919                    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        // For json_object args, use the configured options_via strategy.
951        // A1 fix: when optional=true and value is non-null, pass T directly (not Optional[T]).
952        if arg.arg_type == "json_object" && !value.is_null() {
953            match options_via {
954                "dict" => {
955                    // Pass as a plain Python dict literal.
956                    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                    // Pass via json.loads() with the raw JSON string.
968                    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                    // "kwargs" (default): construct OptionsType(key=val, ...).
976                    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                                    // Map string value to enum constant.
983                                    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        // When optional=true but fixture value is null, skip the argument entirely.
1005        // The function signature expects Optional[T] — Python's default keyword behavior handles None.
1006        if arg.optional && value.is_null() {
1007            continue;
1008        }
1009
1010        // For required args with no fixture value, use a language-appropriate default.
1011        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    // Generate visitor class if the fixture has a visitor spec.
1035    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        // Find error assertion for optional message check.
1053        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        // Skip non-error assertions: `result` is not defined outside the
1072        // `pytest.raises` block, so referencing it would trigger ruff F821.
1073        return;
1074    }
1075
1076    // Non-error path.
1077    // A2 fix: respect returns_result=false (non-Result returns don't need error handling).
1078    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            // A2: When returns_result=false, the call doesn't return Result<T, E>,
1098            // so there's no error to check. Skip the assertion entirely.
1099            if !call_config.returns_result {
1100                continue;
1101            }
1102            // The call already raises on error in Python.
1103            continue;
1104        }
1105        render_assertion(out, assertion, result_var, field_resolver, fields_enum);
1106    }
1107}
1108
1109// ---------------------------------------------------------------------------
1110// Argument rendering
1111// ---------------------------------------------------------------------------
1112
1113fn 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
1134// ---------------------------------------------------------------------------
1135// Assertion rendering
1136// ---------------------------------------------------------------------------
1137
1138fn 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    // Skip assertions on fields that don't exist on the result type.
1146    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    // Determine whether this field should be compared as an enum string.
1159    //
1160    // PyO3 integer-based enums (`#[pyclass(eq, eq_int)]`) are NOT iterable, so
1161    // `"value" in enum_field` raises TypeError.  Use `str(enum_field).lower()`
1162    // instead, which for a variant like `LinkType.Anchor` gives `"linktype.anchor"`,
1163    // making `"anchor" in str(LinkType.Anchor).lower()` evaluate to True.
1164    //
1165    // We apply this to fields explicitly listed in `fields_enum` (using both the
1166    // fixture field path and the resolved path) and to any field whose accessor
1167    // involves array-element indexing (`[0]`) which typically holds typed enums.
1168    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        // Also treat fields accessed via array indexing as potentially enum-typed
1177        // (e.g., `result.links[0].link_type`, `result.assets[0].asset_category`).
1178        // This is safe because `str(string_value).lower()` is idempotent for
1179        // plain string fields, and all fixture `contains` values are lowercase.
1180        field_resolver.accessor(f, "python", result_var).contains("[0]")
1181    });
1182
1183    // Check whether the field path (or any prefix of it) is optional so we can
1184    // guard `in` / `not in` expressions against None.
1185    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            // Handled at call site.
1196        }
1197        "equals" => {
1198            if let Some(val) = &assertion.value {
1199                let expected = value_to_python_string(val);
1200                // Use `is` for boolean/None comparisons (ruff E712).
1201                let op = if val.is_boolean() || val.is_null() { "is" } else { "==" };
1202                // For string equality, strip trailing whitespace to handle trailing newlines
1203                // from the converter.
1204                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                // For enum fields, convert to lowercase string for comparison.
1215                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                    // For enum fields, convert to lowercase string for comparison.
1233                    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                // For enum fields, convert to lowercase string for comparison.
1251                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                // For enum fields, convert to lowercase string for comparison.
1277                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
1437/// Build a Python call expression for a method_result assertion on a tree-sitter Tree.
1438/// Maps method names to the appropriate Python function calls.
1439fn 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
1491/// Returns the Python import name for a method_result method that uses a
1492/// module-level helper function (not a method on the result object).
1493fn 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        // Methods accessed via result_var (e.g. tree.root_node().child_count()) don't need imports.
1502        _ => 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
1517/// Produce a quoted Python string literal, choosing single or double quotes
1518/// to avoid unnecessary escaping (ruff Q003).
1519fn python_string_literal(s: &str) -> String {
1520    if s.contains('"') && !s.contains('\'') {
1521        // Use single quotes to avoid escaping double quotes.
1522        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
1534/// Emit a Python visitor method for a callback action.
1535fn 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            // Use single-quoted f-string so that double quotes inside the template
1591            // (e.g. `QUOTE: "{text}"`) are not misinterpreted as string delimiters.
1592            // Escape newlines/tabs/backslashes/single quotes so the template stays
1593            // on a single line in the generated source.
1594            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}