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!(
133                "\n[tool.uv]\nsources.{pkg_name} = {{ path = \"{pkg_path}\" }}\n",
134                pkg_path = pkg_path
135            ),
136        ),
137    };
138
139    format!(
140        r#"[build-system]
141build-backend = "setuptools.build_meta"
142requires = [ "setuptools>=68", "wheel" ]
143
144[project]
145name = "{pkg_name}-e2e-tests"
146version = "0.0.0"
147description = "End-to-end tests"
148requires-python = ">=3.10"
149classifiers = [
150  "Programming Language :: Python :: 3 :: Only",
151  "Programming Language :: Python :: 3.10",
152  "Programming Language :: Python :: 3.11",
153  "Programming Language :: Python :: 3.12",
154  "Programming Language :: Python :: 3.13",
155  "Programming Language :: Python :: 3.14",
156]
157{deps_line}
158
159[tool.setuptools]
160packages = [  ]
161{uv_sources_block}
162[tool.ruff]
163lint.ignore = [ "PLR2004" ]
164lint.per-file-ignores."tests/**" = [ "B017", "PT011", "S101", "S108" ]
165
166[tool.pytest]
167ini_options.asyncio_mode = "auto"
168ini_options.testpaths = [ "tests" ]
169ini_options.python_files = "test_*.py"
170ini_options.python_functions = "test_*"
171ini_options.addopts = "-v --strict-markers --tb=short"
172ini_options.timeout = 300
173"#
174    )
175}
176
177// ---------------------------------------------------------------------------
178// Config resolution helpers
179// ---------------------------------------------------------------------------
180
181fn resolve_function_name(e2e_config: &E2eConfig) -> String {
182    resolve_function_name_for_call(&e2e_config.call)
183}
184
185fn resolve_function_name_for_call(call_config: &crate::config::CallConfig) -> String {
186    call_config
187        .overrides
188        .get("python")
189        .and_then(|o| o.function.clone())
190        .unwrap_or_else(|| call_config.function.clone())
191}
192
193fn resolve_module(e2e_config: &E2eConfig) -> String {
194    e2e_config
195        .call
196        .overrides
197        .get("python")
198        .and_then(|o| o.module.clone())
199        .unwrap_or_else(|| e2e_config.call.module.replace('-', "_"))
200}
201
202fn resolve_options_type(e2e_config: &E2eConfig) -> Option<String> {
203    e2e_config
204        .call
205        .overrides
206        .get("python")
207        .and_then(|o| o.options_type.clone())
208}
209
210/// Resolve how json_object args are passed: "kwargs" (default), "dict", or "json".
211fn resolve_options_via(e2e_config: &E2eConfig) -> &str {
212    e2e_config
213        .call
214        .overrides
215        .get("python")
216        .and_then(|o| o.options_via.as_deref())
217        .unwrap_or("kwargs")
218}
219
220/// Resolve enum field mappings from the Python override config.
221fn resolve_enum_fields(e2e_config: &E2eConfig) -> &HashMap<String, String> {
222    static EMPTY: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
223    e2e_config
224        .call
225        .overrides
226        .get("python")
227        .map(|o| &o.enum_fields)
228        .unwrap_or(&EMPTY)
229}
230
231/// Resolve handle nested type mappings from the Python override config.
232/// Maps config field names to their Python constructor type names.
233fn resolve_handle_nested_types(e2e_config: &E2eConfig) -> &HashMap<String, String> {
234    static EMPTY: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
235    e2e_config
236        .call
237        .overrides
238        .get("python")
239        .map(|o| &o.handle_nested_types)
240        .unwrap_or(&EMPTY)
241}
242
243/// Resolve handle dict type set from the Python override config.
244/// Fields in this set use `TypeName({...})` instead of `TypeName(key=val, ...)`.
245fn resolve_handle_dict_types(e2e_config: &E2eConfig) -> &std::collections::HashSet<String> {
246    static EMPTY: std::sync::LazyLock<std::collections::HashSet<String>> =
247        std::sync::LazyLock::new(std::collections::HashSet::new);
248    e2e_config
249        .call
250        .overrides
251        .get("python")
252        .map(|o| &o.handle_dict_types)
253        .unwrap_or(&EMPTY)
254}
255
256fn is_skipped(fixture: &Fixture, language: &str) -> bool {
257    fixture.skip.as_ref().is_some_and(|s| s.should_skip(language))
258}
259
260// ---------------------------------------------------------------------------
261// Rendering
262// ---------------------------------------------------------------------------
263
264fn render_conftest(e2e_config: &E2eConfig, groups: &[FixtureGroup]) -> String {
265    let module = resolve_module(e2e_config);
266    let has_http_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| f.is_http_test());
267
268    let header = hash::header(CommentStyle::Hash);
269    if has_http_fixtures {
270        format!(
271            r#"{header}"""Pytest configuration for e2e tests."""
272from __future__ import annotations
273
274import os
275import subprocess
276import threading
277from pathlib import Path
278from typing import Generator
279
280import pytest
281
282# Ensure the package is importable.
283# The {module} package is expected to be installed in the current environment.
284
285_HERE = Path(__file__).parent
286_E2E_DIR = _HERE.parent
287_MOCK_SERVER_BIN = _E2E_DIR / "rust" / "target" / "release" / "mock-server"
288_FIXTURES_DIR = _E2E_DIR.parent / "fixtures"
289
290
291@pytest.fixture(scope="session", autouse=True)
292def mock_server() -> Generator[str, None, None]:
293    """Spawn the mock HTTP server binary and set MOCK_SERVER_URL."""
294    proc = subprocess.Popen(  # noqa: S603
295        [str(_MOCK_SERVER_BIN), str(_FIXTURES_DIR)],
296        stdout=subprocess.PIPE,
297        stderr=None,
298        stdin=subprocess.PIPE,
299    )
300    url = ""
301    assert proc.stdout is not None
302    for raw_line in proc.stdout:
303        line = raw_line.decode().strip()
304        if line.startswith("MOCK_SERVER_URL="):
305            url = line.split("=", 1)[1]
306            break
307    os.environ["MOCK_SERVER_URL"] = url
308    # Drain stdout in background so the server never blocks.
309    threading.Thread(target=proc.stdout.read, daemon=True).start()
310    yield url
311    if proc.stdin:
312        proc.stdin.close()
313    proc.terminate()
314    proc.wait()
315
316
317def _make_request(method: str, path: str, **kwargs: object) -> object:
318    """Make an HTTP request to the mock server."""
319    import urllib.request  # noqa: PLC0415
320
321    base_url = os.environ.get("MOCK_SERVER_URL", "http://localhost:8080")
322    url = f"{{base_url}}{{path}}"
323    data = kwargs.pop("json", None)
324    if data is not None:
325        import json  # noqa: PLC0415
326
327        body = json.dumps(data).encode()
328        headers = dict(kwargs.pop("headers", {{}}))
329        headers.setdefault("Content-Type", "application/json")
330        req = urllib.request.Request(url, data=body, headers=headers, method=method.upper())
331    else:
332        headers = dict(kwargs.pop("headers", {{}}))
333        req = urllib.request.Request(url, headers=headers, method=method.upper())
334    try:
335        with urllib.request.urlopen(req) as resp:  # noqa: S310
336            return resp
337    except urllib.error.HTTPError as exc:
338        return exc
339
340
341@pytest.fixture(scope="session")
342def app(mock_server: str) -> object:  # noqa: ARG001
343    """Return a simple HTTP helper bound to the mock server URL."""
344
345    class _App:
346        def request(self, path: str, **kwargs: object) -> object:
347            method = str(kwargs.pop("method", "GET"))
348            return _make_request(method, path, **kwargs)
349
350    return _App()
351"#
352        )
353    } else {
354        format!(
355            r#"{header}"""Pytest configuration for e2e tests."""
356# Ensure the package is importable.
357# The {module} package is expected to be installed in the current environment.
358"#
359        )
360    }
361}
362
363fn render_test_file(category: &str, fixtures: &[&Fixture], e2e_config: &E2eConfig) -> String {
364    let mut out = String::new();
365    out.push_str(&hash::header(CommentStyle::Hash));
366    let _ = writeln!(out, "\"\"\"E2e tests for category: {category}.\"\"\"");
367
368    let module = resolve_module(e2e_config);
369    let function_name = resolve_function_name(e2e_config);
370    let options_type = resolve_options_type(e2e_config);
371    let options_via = resolve_options_via(e2e_config);
372    let enum_fields = resolve_enum_fields(e2e_config);
373    let handle_nested_types = resolve_handle_nested_types(e2e_config);
374    let handle_dict_types = resolve_handle_dict_types(e2e_config);
375    let field_resolver = FieldResolver::new(
376        &e2e_config.fields,
377        &e2e_config.fields_optional,
378        &e2e_config.result_fields,
379        &e2e_config.fields_array,
380    );
381
382    let has_error_test = fixtures
383        .iter()
384        .any(|f| f.assertions.iter().any(|a| a.assertion_type == "error"));
385    let has_skipped = fixtures.iter().any(|f| is_skipped(f, "python"));
386    let has_http_tests = fixtures.iter().any(|f| f.is_http_test());
387
388    // Check if any fixture in this file uses an async call.
389    let is_async = fixtures.iter().any(|f| {
390        let cc = e2e_config.resolve_call(f.call.as_deref());
391        cc.r#async
392    }) || e2e_config.call.r#async;
393    let needs_pytest = has_error_test || has_skipped || is_async;
394
395    // "json" mode needs `import json`.
396    let needs_json_import = options_via == "json"
397        && fixtures.iter().any(|f| {
398            e2e_config
399                .call
400                .args
401                .iter()
402                .any(|arg| arg.arg_type == "json_object" && !resolve_field(&f.input, &arg.field).is_null())
403        });
404
405    // mock_url args need `import os`.
406    let needs_os_import = e2e_config.call.args.iter().any(|arg| arg.arg_type == "mock_url");
407
408    // HTTP tests handle `import re` inline (per-test), so no top-level re import is needed.
409    let needs_re_import = false;
410    let _ = has_http_tests; // used indirectly via inline imports in render_http_test_function
411
412    // Only import options_type when using "kwargs" mode.
413    let needs_options_type = options_via == "kwargs"
414        && options_type.is_some()
415        && fixtures.iter().any(|f| {
416            e2e_config
417                .call
418                .args
419                .iter()
420                .any(|arg| arg.arg_type == "json_object" && !resolve_field(&f.input, &arg.field).is_null())
421        });
422
423    // Collect enum types actually used across all fixtures in this file.
424    let mut used_enum_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
425    if needs_options_type && !enum_fields.is_empty() {
426        for fixture in fixtures.iter() {
427            for arg in &e2e_config.call.args {
428                if arg.arg_type == "json_object" {
429                    let value = resolve_field(&fixture.input, &arg.field);
430                    if let Some(obj) = value.as_object() {
431                        for key in obj.keys() {
432                            if let Some(enum_type) = enum_fields.get(key) {
433                                used_enum_types.insert(enum_type.clone());
434                            }
435                        }
436                    }
437                }
438            }
439        }
440    }
441
442    // Collect imports sorted per isort/ruff I001: stdlib group, then
443    // third-party group, separated by a blank line. Within each group
444    // `import X` lines come before `from X import Y` lines, both sorted.
445    let mut stdlib_imports: Vec<String> = Vec::new();
446    let mut thirdparty_bare: Vec<String> = Vec::new();
447    let mut thirdparty_from: Vec<String> = Vec::new();
448
449    if needs_json_import {
450        stdlib_imports.push("import json".to_string());
451    }
452
453    if needs_os_import {
454        stdlib_imports.push("import os".to_string());
455    }
456
457    if needs_re_import {
458        stdlib_imports.push("import re".to_string());
459    }
460
461    if needs_pytest {
462        // F401 (unused-import) suppression: pytest is needed at module level for
463        // its fixture decorators and `pytest.mark.*` annotations, but ruff cannot
464        // statically tell whether a generated test file references those — so we
465        // hint to ruff that the import is intentional.
466        thirdparty_bare.push("import pytest  # noqa: F401".to_string());
467    }
468
469    // For non-HTTP fixtures, build the normal function imports.
470    // Only count fixtures that are not skipped and have assertions (need to call the function).
471    let has_non_http_fixtures = fixtures
472        .iter()
473        .any(|f| !f.is_http_test() && !is_skipped(f, "python") && !f.assertions.is_empty());
474    if has_non_http_fixtures {
475        // Collect handle constructor function names that need to be imported.
476        let handle_constructors: Vec<String> = e2e_config
477            .call
478            .args
479            .iter()
480            .filter(|arg| arg.arg_type == "handle")
481            .map(|arg| format!("create_{}", arg.name.to_snake_case()))
482            .collect();
483
484        // Collect all unique function names actually used across all fixtures in this file.
485        // Do not seed with the default function_name — only include it when at least one
486        // fixture resolves to it, to avoid unused-import (F401) warnings from ruff.
487        let mut import_names: Vec<String> = Vec::new();
488        for fixture in fixtures.iter() {
489            let cc = e2e_config.resolve_call(fixture.call.as_deref());
490            let fn_name = resolve_function_name_for_call(cc);
491            if !import_names.contains(&fn_name) {
492                import_names.push(fn_name);
493            }
494        }
495        // Safety net: should not occur since the group is non-empty, but ensures
496        // import_names is never empty if all fixtures use the default call.
497        if import_names.is_empty() {
498            import_names.push(function_name.clone());
499        }
500        for ctor in &handle_constructors {
501            if !import_names.contains(ctor) {
502                import_names.push(ctor.clone());
503            }
504        }
505
506        // If any handle arg has config, import the config class (CrawlConfig or options_type).
507        let needs_config_import = e2e_config.call.args.iter().any(|arg| {
508            arg.arg_type == "handle"
509                && fixtures.iter().any(|f| {
510                    let val = resolve_field(&f.input, &arg.field);
511                    !val.is_null() && val.as_object().is_some_and(|o| !o.is_empty())
512                })
513        });
514        if needs_config_import {
515            let config_class = options_type.as_deref().unwrap_or("CrawlConfig");
516            if !import_names.contains(&config_class.to_string()) {
517                import_names.push(config_class.to_string());
518            }
519        }
520
521        // Import any nested handle config types actually used in this file.
522        if !handle_nested_types.is_empty() {
523            let mut used_nested_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
524            for fixture in fixtures.iter() {
525                for arg in &e2e_config.call.args {
526                    if arg.arg_type == "handle" {
527                        let config_value = resolve_field(&fixture.input, &arg.field);
528                        if let Some(obj) = config_value.as_object() {
529                            for key in obj.keys() {
530                                if let Some(type_name) = handle_nested_types.get(key) {
531                                    if obj[key].is_object() {
532                                        used_nested_types.insert(type_name.clone());
533                                    }
534                                }
535                            }
536                        }
537                    }
538                }
539            }
540            for type_name in used_nested_types {
541                if !import_names.contains(&type_name) {
542                    import_names.push(type_name);
543                }
544            }
545        }
546
547        // Collect method_result helper function imports.
548        for fixture in fixtures.iter() {
549            for assertion in &fixture.assertions {
550                if assertion.assertion_type == "method_result" {
551                    if let Some(method_name) = &assertion.method {
552                        let import = python_method_helper_import(method_name);
553                        if let Some(name) = import {
554                            if !import_names.contains(&name) {
555                                import_names.push(name);
556                            }
557                        }
558                    }
559                }
560            }
561        }
562
563        if let (true, Some(opts_type)) = (needs_options_type, &options_type) {
564            import_names.push(opts_type.clone());
565            thirdparty_from.push(format!("from {module} import {}", import_names.join(", ")));
566            // Import enum types from enum_module (if specified) or main module.
567            if !used_enum_types.is_empty() {
568                let enum_mod = e2e_config
569                    .call
570                    .overrides
571                    .get("python")
572                    .and_then(|o| o.enum_module.as_deref())
573                    .unwrap_or(&module);
574                let enum_names: Vec<&String> = used_enum_types.iter().collect();
575                thirdparty_from.push(format!(
576                    "from {enum_mod} import {}",
577                    enum_names.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
578                ));
579            }
580        } else {
581            thirdparty_from.push(format!("from {module} import {}", import_names.join(", ")));
582        }
583    }
584
585    stdlib_imports.sort();
586    thirdparty_bare.sort();
587    thirdparty_from.sort();
588
589    // Emit sorted import groups with blank lines between groups per PEP 8.
590    if !stdlib_imports.is_empty() {
591        for imp in &stdlib_imports {
592            let _ = writeln!(out, "{imp}");
593        }
594        let _ = writeln!(out);
595    }
596    // Third-party: bare imports then from-imports, no blank line between them.
597    for imp in &thirdparty_bare {
598        let _ = writeln!(out, "{imp}");
599    }
600    for imp in &thirdparty_from {
601        let _ = writeln!(out, "{imp}");
602    }
603    // Two blank lines after imports (PEP 8 / ruff I001).
604    let _ = writeln!(out);
605    let _ = writeln!(out);
606
607    for fixture in fixtures {
608        if fixture.is_http_test() {
609            render_http_test_function(&mut out, fixture);
610        } else if !is_skipped(fixture, "python") && fixture.assertions.is_empty() {
611            // Non-HTTP fixture with no assertions: generate a skipped placeholder.
612            let fn_name = sanitize_ident(&fixture.id);
613            let description = &fixture.description;
614            let desc_with_period = if description.ends_with('.') {
615                description.to_string()
616            } else {
617                format!("{description}.")
618            };
619            let _ = writeln!(
620                out,
621                "@pytest.mark.skip(reason=\"no assertions configured for this fixture in python e2e\")"
622            );
623            let _ = writeln!(out, "def test_{fn_name}() -> None:");
624            let _ = writeln!(out, "    \"\"\"{desc_with_period}\"\"\"");
625        } else {
626            render_test_function(
627                &mut out,
628                fixture,
629                e2e_config,
630                options_type.as_deref(),
631                options_via,
632                enum_fields,
633                handle_nested_types,
634                handle_dict_types,
635                &field_resolver,
636            );
637        }
638        let _ = writeln!(out);
639    }
640
641    out
642}
643
644// ---------------------------------------------------------------------------
645// HTTP server test rendering
646// ---------------------------------------------------------------------------
647
648/// Render a pytest test function for an HTTP server fixture.
649///
650/// The generated test:
651/// 1. Receives a `client` fixture from conftest.py (the test server client).
652/// 2. Sends the configured request.
653/// 3. Asserts status code, body (exact or partial), headers, and validation errors.
654fn render_http_test_function(out: &mut String, fixture: &Fixture) {
655    let Some(http) = &fixture.http else {
656        return;
657    };
658
659    let fn_name = sanitize_ident(&fixture.id);
660    let description = &fixture.description;
661    let desc_with_period = if description.ends_with('.') {
662        description.to_string()
663    } else {
664        format!("{description}.")
665    };
666
667    if is_skipped(fixture, "python") {
668        let reason = fixture
669            .skip
670            .as_ref()
671            .and_then(|s| s.reason.as_deref())
672            .unwrap_or("skipped for python");
673        let escaped = escape_python(reason);
674        let _ = writeln!(out, "@pytest.mark.skip(reason=\"{escaped}\")");
675    }
676
677    let _ = writeln!(out, "def test_{fn_name}(mock_server: str) -> None:");
678    let _ = writeln!(out, "    \"\"\"{desc_with_period}\"\"\"");
679    let _ = writeln!(out, "    import os  # noqa: PLC0415");
680    let _ = writeln!(out, "    import urllib.request  # noqa: PLC0415");
681    let _ = writeln!(out, "    base = os.environ.get(\"MOCK_SERVER_URL\", mock_server)");
682    let fixture_id = fixture.id.as_str();
683    let _ = writeln!(out, "    url = f\"{{base}}/fixtures/{fixture_id}\"");
684
685    // Build the request call using urllib.
686    let method = http.request.method.to_uppercase();
687
688    // Build headers dict.
689    let mut header_entries: Vec<String> = Vec::new();
690    for (k, v) in &http.request.headers {
691        header_entries.push(format!("        \"{}\": \"{}\",", escape_python(k), escape_python(v)));
692    }
693    let headers_py = if header_entries.is_empty() {
694        "{}".to_string()
695    } else {
696        format!("{{\n{}\n    }}", header_entries.join("\n"))
697    };
698
699    if let Some(body) = &http.request.body {
700        let py_body = json_to_python_literal(body);
701        let _ = writeln!(out, "    import json  # noqa: PLC0415");
702        let _ = writeln!(out, "    _headers = {headers_py}");
703        let _ = writeln!(out, "    _headers.setdefault(\"Content-Type\", \"application/json\")");
704        let _ = writeln!(out, "    _body = json.dumps({py_body}).encode()");
705        let _ = writeln!(
706            out,
707            "    _req = urllib.request.Request(url, data=_body, headers=_headers, method=\"{method}\")"
708        );
709    } else {
710        let _ = writeln!(out, "    _headers = {headers_py}");
711        let _ = writeln!(
712            out,
713            "    _req = urllib.request.Request(url, headers=_headers, method=\"{method}\")"
714        );
715    }
716    // Determine which response variables are actually needed.
717    let needs_body = http.expected_response.body.is_some()
718        || http.expected_response.body_partial.is_some()
719        || http
720            .expected_response
721            .validation_errors
722            .as_ref()
723            .is_some_and(|v| !v.is_empty());
724    let needs_headers = !http.expected_response.headers.is_empty();
725
726    let _ = writeln!(out, "    try:");
727    let _ = writeln!(out, "        response = urllib.request.urlopen(_req)  # noqa: S310");
728    let _ = writeln!(out, "        status_code = response.status");
729    if needs_body {
730        let _ = writeln!(out, "        resp_body = response.read()");
731    }
732    if needs_headers {
733        let _ = writeln!(out, "        resp_headers = dict(response.headers)");
734    }
735    let _ = writeln!(out, "    except urllib.error.HTTPError as _exc:");
736    let _ = writeln!(out, "        status_code = _exc.code");
737    if needs_body {
738        let _ = writeln!(out, "        resp_body = _exc.read()");
739    }
740    if needs_headers {
741        let _ = writeln!(out, "        resp_headers = dict(_exc.headers)");
742    }
743
744    // Status code assertion.
745    let status = http.expected_response.status_code;
746    let _ = writeln!(out, "    assert status_code == {status}  # noqa: S101");
747
748    // Body assertions.
749    if let Some(expected_body) = &http.expected_response.body {
750        let py_val = json_to_python_literal(expected_body);
751        let _ = writeln!(out, "    import json as _json  # noqa: PLC0415");
752        let _ = writeln!(out, "    data = _json.loads(resp_body)");
753        let _ = writeln!(out, "    assert data == {py_val}  # noqa: S101");
754    } else if let Some(partial) = &http.expected_response.body_partial {
755        let _ = writeln!(out, "    import json as _json  # noqa: PLC0415");
756        let _ = writeln!(out, "    data = _json.loads(resp_body)");
757        if let Some(obj) = partial.as_object() {
758            for (key, val) in obj {
759                let py_val = json_to_python_literal(val);
760                let escaped_key = escape_python(key);
761                let _ = writeln!(out, "    assert data[\"{escaped_key}\"] == {py_val}  # noqa: S101");
762            }
763        }
764    }
765
766    // Header assertions.
767    for (header_name, header_value) in &http.expected_response.headers {
768        let lower_name = header_name.to_lowercase();
769        let escaped_name = escape_python(&lower_name);
770        match header_value.as_str() {
771            "<<present>>" => {
772                let _ = writeln!(out, "    assert \"{escaped_name}\" in resp_headers  # noqa: S101");
773            }
774            "<<absent>>" => {
775                let _ = writeln!(
776                    out,
777                    "    assert resp_headers.get(\"{escaped_name}\") is None  # noqa: S101"
778                );
779            }
780            "<<uuid>>" => {
781                let _ = writeln!(out, "    import re  # noqa: PLC0415");
782                let _ = writeln!(
783                    out,
784                    "    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"
785                );
786            }
787            exact => {
788                let escaped_val = escape_python(exact);
789                let _ = writeln!(
790                    out,
791                    "    assert resp_headers[\"{escaped_name}\"] == \"{escaped_val}\"  # noqa: S101"
792                );
793            }
794        }
795    }
796
797    // Validation error assertions.
798    if let Some(validation_errors) = &http.expected_response.validation_errors {
799        if !validation_errors.is_empty() {
800            let _ = writeln!(out, "    import json as _json  # noqa: PLC0415");
801            let _ = writeln!(out, "    _data = _json.loads(resp_body)");
802            let _ = writeln!(out, "    errors = _data.get(\"detail\", [])");
803            for ve in validation_errors {
804                let loc_py: Vec<String> = ve.loc.iter().map(|s| format!("\"{}\"", escape_python(s))).collect();
805                let loc_str = loc_py.join(", ");
806                let escaped_msg = escape_python(&ve.msg);
807                let _ = writeln!(
808                    out,
809                    "    assert any(e[\"loc\"] == [{loc_str}] and \"{escaped_msg}\" in e[\"msg\"] for e in errors)  # noqa: S101"
810                );
811            }
812        }
813    }
814}
815
816// ---------------------------------------------------------------------------
817// Function-call test rendering
818// ---------------------------------------------------------------------------
819
820#[allow(clippy::too_many_arguments)]
821fn render_test_function(
822    out: &mut String,
823    fixture: &Fixture,
824    e2e_config: &E2eConfig,
825    options_type: Option<&str>,
826    options_via: &str,
827    enum_fields: &HashMap<String, String>,
828    handle_nested_types: &HashMap<String, String>,
829    handle_dict_types: &std::collections::HashSet<String>,
830    field_resolver: &FieldResolver,
831) {
832    let fn_name = sanitize_ident(&fixture.id);
833    let description = &fixture.description;
834    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
835    let function_name = resolve_function_name_for_call(call_config);
836    let result_var = &call_config.result_var;
837
838    let desc_with_period = if description.ends_with('.') {
839        description.to_string()
840    } else {
841        format!("{description}.")
842    };
843
844    // Emit pytest.mark.skip for fixtures that should be skipped for python.
845    if is_skipped(fixture, "python") {
846        let reason = fixture
847            .skip
848            .as_ref()
849            .and_then(|s| s.reason.as_deref())
850            .unwrap_or("skipped for python");
851        let escaped = escape_python(reason);
852        let _ = writeln!(out, "@pytest.mark.skip(reason=\"{escaped}\")");
853    }
854
855    let is_async = call_config.r#async;
856    if is_async {
857        let _ = writeln!(out, "@pytest.mark.asyncio");
858        let _ = writeln!(out, "async def test_{fn_name}() -> None:");
859    } else {
860        let _ = writeln!(out, "def test_{fn_name}() -> None:");
861    }
862    let _ = writeln!(out, "    \"\"\"{desc_with_period}\"\"\"");
863
864    // Check if any assertion is an error assertion.
865    let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
866
867    // Build argument expressions from config.
868    let mut arg_bindings = Vec::new();
869    let mut kwarg_exprs = Vec::new();
870    for arg in &call_config.args {
871        let var_name = &arg.name;
872
873        if arg.arg_type == "handle" {
874            // Generate a create_engine (or equivalent) call and pass the variable.
875            // If there's config data, construct a CrawlConfig with kwargs.
876            let constructor_name = format!("create_{}", arg.name.to_snake_case());
877            let config_value = resolve_field(&fixture.input, &arg.field);
878            if config_value.is_null()
879                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
880            {
881                arg_bindings.push(format!("    {var_name} = {constructor_name}(None)"));
882            } else if let Some(obj) = config_value.as_object() {
883                // Build kwargs for the config constructor (CrawlConfig(key=val, ...)).
884                // For fields with a nested type mapping, wrap the dict value in the
885                // appropriate typed constructor instead of passing a plain dict.
886                let kwargs: Vec<String> = obj
887                    .iter()
888                    .map(|(k, v)| {
889                        let snake_key = k.to_snake_case();
890                        let py_val = if let Some(type_name) = handle_nested_types.get(k) {
891                            // Wrap the nested dict in the typed constructor.
892                            if let Some(nested_obj) = v.as_object() {
893                                if nested_obj.is_empty() {
894                                    // Empty dict: use the default constructor.
895                                    format!("{type_name}()")
896                                } else if handle_dict_types.contains(k) {
897                                    // The outer Python config type (e.g. CrawlConfig) accepts a
898                                    // plain dict for this field (e.g. `auth: dict | None`).
899                                    // The binding-layer wrapper (e.g. api.py) creates the typed
900                                    // object internally, so we must NOT pre-wrap it here.
901                                    json_to_python_literal(v)
902                                } else {
903                                    // Type takes keyword arguments.
904                                    let nested_kwargs: Vec<String> = nested_obj
905                                        .iter()
906                                        .map(|(nk, nv)| {
907                                            let nested_snake_key = nk.to_snake_case();
908                                            format!("{nested_snake_key}={}", json_to_python_literal(nv))
909                                        })
910                                        .collect();
911                                    format!("{type_name}({})", nested_kwargs.join(", "))
912                                }
913                            } else {
914                                // Non-object value: use as-is.
915                                json_to_python_literal(v)
916                            }
917                        } else if k == "request_timeout" {
918                            // The Python binding converts request_timeout with Duration::from_secs
919                            // (seconds) while fixtures specify values in milliseconds. Divide by
920                            // 1000 to compensate: e.g., 1 ms → 0 s (immediate timeout),
921                            // 5000 ms → 5 s. This keeps test semantics consistent with the
922                            // fixture intent.
923                            if let Some(ms) = v.as_u64() {
924                                format!("{}", ms / 1000)
925                            } else {
926                                json_to_python_literal(v)
927                            }
928                        } else {
929                            json_to_python_literal(v)
930                        };
931                        format!("{snake_key}={py_val}")
932                    })
933                    .collect();
934                // Use the options_type if configured, otherwise "CrawlConfig".
935                let config_class = options_type.unwrap_or("CrawlConfig");
936                let single_line = format!("    {var_name}_config = {config_class}({})", kwargs.join(", "));
937                if single_line.len() <= 120 {
938                    arg_bindings.push(single_line);
939                } else {
940                    // Split into multi-line for readability and E501 compliance.
941                    let mut lines = format!("    {var_name}_config = {config_class}(\n");
942                    for kw in &kwargs {
943                        lines.push_str(&format!("        {kw},\n"));
944                    }
945                    lines.push_str("    )");
946                    arg_bindings.push(lines);
947                }
948                arg_bindings.push(format!("    {var_name} = {constructor_name}({var_name}_config)"));
949            } else {
950                let literal = json_to_python_literal(config_value);
951                arg_bindings.push(format!("    {var_name} = {constructor_name}({literal})"));
952            }
953            kwarg_exprs.push(format!("{var_name}={var_name}"));
954            continue;
955        }
956
957        if arg.arg_type == "mock_url" {
958            let fixture_id = &fixture.id;
959            arg_bindings.push(format!(
960                "    {var_name} = os.environ['MOCK_SERVER_URL'] + '/fixtures/{fixture_id}'"
961            ));
962            kwarg_exprs.push(format!("{var_name}={var_name}"));
963            continue;
964        }
965
966        let value = resolve_field(&fixture.input, &arg.field);
967
968        if value.is_null() && arg.optional {
969            continue;
970        }
971
972        // For json_object args, use the configured options_via strategy.
973        // A1 fix: when optional=true and value is non-null, pass T directly (not Optional[T]).
974        if arg.arg_type == "json_object" && !value.is_null() {
975            match options_via {
976                "dict" => {
977                    // Pass as a plain Python dict literal.
978                    let literal = json_to_python_literal(value);
979                    let noqa = if literal.contains("/tmp/") {
980                        "  # noqa: S108"
981                    } else {
982                        ""
983                    };
984                    arg_bindings.push(format!("    {var_name} = {literal}{noqa}"));
985                    kwarg_exprs.push(format!("{var_name}={var_name}"));
986                    continue;
987                }
988                "json" => {
989                    // Pass via json.loads() with the raw JSON string.
990                    let json_str = serde_json::to_string(value).unwrap_or_default();
991                    let escaped = escape_python(&json_str);
992                    arg_bindings.push(format!("    {var_name} = json.loads(\"{escaped}\")"));
993                    kwarg_exprs.push(format!("{var_name}={var_name}"));
994                    continue;
995                }
996                _ => {
997                    // "kwargs" (default): construct OptionsType(key=val, ...).
998                    if let (Some(opts_type), Some(obj)) = (options_type, value.as_object()) {
999                        let kwargs: Vec<String> = obj
1000                            .iter()
1001                            .map(|(k, v)| {
1002                                let snake_key = k.to_snake_case();
1003                                let py_val = if let Some(enum_type) = enum_fields.get(k) {
1004                                    // Map string value to enum constant.
1005                                    if let Some(s) = v.as_str() {
1006                                        let upper_val = s.to_shouty_snake_case();
1007                                        format!("{enum_type}.{upper_val}")
1008                                    } else {
1009                                        json_to_python_literal(v)
1010                                    }
1011                                } else {
1012                                    json_to_python_literal(v)
1013                                };
1014                                format!("{snake_key}={py_val}")
1015                            })
1016                            .collect();
1017                        let constructor = format!("{opts_type}({})", kwargs.join(", "));
1018                        arg_bindings.push(format!("    {var_name} = {constructor}"));
1019                        kwarg_exprs.push(format!("{var_name}={var_name}"));
1020                        continue;
1021                    }
1022                }
1023            }
1024        }
1025
1026        // When optional=true but fixture value is null, skip the argument entirely.
1027        // The function signature expects Optional[T] — Python's default keyword behavior handles None.
1028        if arg.optional && value.is_null() {
1029            continue;
1030        }
1031
1032        // For required args with no fixture value, use a language-appropriate default.
1033        if value.is_null() && !arg.optional {
1034            let default_val = match arg.arg_type.as_str() {
1035                "string" => "\"\"".to_string(),
1036                "int" | "integer" => "0".to_string(),
1037                "float" | "number" => "0.0".to_string(),
1038                "bool" | "boolean" => "False".to_string(),
1039                _ => "None".to_string(),
1040            };
1041            arg_bindings.push(format!("    {var_name} = {default_val}"));
1042            kwarg_exprs.push(format!("{var_name}={var_name}"));
1043            continue;
1044        }
1045
1046        let literal = json_to_python_literal(value);
1047        let noqa = if literal.contains("/tmp/") {
1048            "  # noqa: S108"
1049        } else {
1050            ""
1051        };
1052        arg_bindings.push(format!("    {var_name} = {literal}{noqa}"));
1053        kwarg_exprs.push(format!("{var_name}={var_name}"));
1054    }
1055
1056    // Generate visitor class if the fixture has a visitor spec.
1057    if let Some(visitor_spec) = &fixture.visitor {
1058        let _ = writeln!(out, "    class _TestVisitor:");
1059        for (method_name, action) in &visitor_spec.callbacks {
1060            emit_python_visitor_method(out, method_name, action);
1061        }
1062        kwarg_exprs.push("visitor=_TestVisitor()".to_string());
1063    }
1064
1065    for binding in &arg_bindings {
1066        let _ = writeln!(out, "{binding}");
1067    }
1068
1069    let call_args = kwarg_exprs.join(", ");
1070    let await_prefix = if is_async { "await " } else { "" };
1071    let call_expr = format!("{await_prefix}{function_name}({call_args})");
1072
1073    if has_error_assertion {
1074        // Find error assertion for optional message check.
1075        let error_assertion = fixture.assertions.iter().find(|a| a.assertion_type == "error");
1076        let has_message = error_assertion
1077            .and_then(|a| a.value.as_ref())
1078            .and_then(|v| v.as_str())
1079            .is_some();
1080
1081        if has_message {
1082            let _ = writeln!(out, "    with pytest.raises(Exception) as exc_info:  # noqa: B017");
1083            let _ = writeln!(out, "        {call_expr}");
1084            if let Some(msg) = error_assertion.and_then(|a| a.value.as_ref()).and_then(|v| v.as_str()) {
1085                let escaped = escape_python(msg);
1086                let _ = writeln!(out, "    assert \"{escaped}\" in str(exc_info.value)  # noqa: S101");
1087            }
1088        } else {
1089            let _ = writeln!(out, "    with pytest.raises(Exception):  # noqa: B017");
1090            let _ = writeln!(out, "        {call_expr}");
1091        }
1092
1093        // Skip non-error assertions: `result` is not defined outside the
1094        // `pytest.raises` block, so referencing it would trigger ruff F821.
1095        return;
1096    }
1097
1098    // Non-error path.
1099    // A2 fix: respect returns_result=false (non-Result returns don't need error handling).
1100    let has_usable_assertion = fixture.assertions.iter().any(|a| {
1101        if a.assertion_type == "not_error" || a.assertion_type == "error" {
1102            return false;
1103        }
1104        match &a.field {
1105            Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
1106            _ => true,
1107        }
1108    });
1109    let py_result_var = if has_usable_assertion {
1110        result_var.to_string()
1111    } else {
1112        "_".to_string()
1113    };
1114    let _ = writeln!(out, "    {py_result_var} = {call_expr}");
1115
1116    let fields_enum = &e2e_config.fields_enum;
1117    for assertion in &fixture.assertions {
1118        if assertion.assertion_type == "not_error" {
1119            // A2: When returns_result=false, the call doesn't return Result<T, E>,
1120            // so there's no error to check. Skip the assertion entirely.
1121            if !call_config.returns_result {
1122                continue;
1123            }
1124            // The call already raises on error in Python.
1125            continue;
1126        }
1127        render_assertion(out, assertion, result_var, field_resolver, fields_enum);
1128    }
1129}
1130
1131// ---------------------------------------------------------------------------
1132// Argument rendering
1133// ---------------------------------------------------------------------------
1134
1135fn json_to_python_literal(value: &serde_json::Value) -> String {
1136    match value {
1137        serde_json::Value::Null => "None".to_string(),
1138        serde_json::Value::Bool(true) => "True".to_string(),
1139        serde_json::Value::Bool(false) => "False".to_string(),
1140        serde_json::Value::Number(n) => n.to_string(),
1141        serde_json::Value::String(s) => python_string_literal(s),
1142        serde_json::Value::Array(arr) => {
1143            let items: Vec<String> = arr.iter().map(json_to_python_literal).collect();
1144            format!("[{}]", items.join(", "))
1145        }
1146        serde_json::Value::Object(map) => {
1147            let items: Vec<String> = map
1148                .iter()
1149                .map(|(k, v)| format!("\"{}\": {}", escape_python(k), json_to_python_literal(v)))
1150                .collect();
1151            format!("{{{}}}", items.join(", "))
1152        }
1153    }
1154}
1155
1156// ---------------------------------------------------------------------------
1157// Assertion rendering
1158// ---------------------------------------------------------------------------
1159
1160fn render_assertion(
1161    out: &mut String,
1162    assertion: &Assertion,
1163    result_var: &str,
1164    field_resolver: &FieldResolver,
1165    fields_enum: &std::collections::HashSet<String>,
1166) {
1167    // Skip assertions on fields that don't exist on the result type.
1168    if let Some(f) = &assertion.field {
1169        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1170            let _ = writeln!(out, "    # skipped: field '{f}' not available on result type");
1171            return;
1172        }
1173    }
1174
1175    let field_access = match &assertion.field {
1176        Some(f) if !f.is_empty() => field_resolver.accessor(f, "python", result_var),
1177        _ => result_var.to_string(),
1178    };
1179
1180    // Determine whether this field should be compared as an enum string.
1181    //
1182    // PyO3 integer-based enums (`#[pyclass(eq, eq_int)]`) are NOT iterable, so
1183    // `"value" in enum_field` raises TypeError.  Use `str(enum_field).lower()`
1184    // instead, which for a variant like `LinkType.Anchor` gives `"linktype.anchor"`,
1185    // making `"anchor" in str(LinkType.Anchor).lower()` evaluate to True.
1186    //
1187    // We apply this to fields explicitly listed in `fields_enum` (using both the
1188    // fixture field path and the resolved path) and to any field whose accessor
1189    // involves array-element indexing (`[0]`) which typically holds typed enums.
1190    let field_is_enum = assertion.field.as_deref().is_some_and(|f| {
1191        if fields_enum.contains(f) {
1192            return true;
1193        }
1194        let resolved = field_resolver.resolve(f);
1195        if fields_enum.contains(resolved) {
1196            return true;
1197        }
1198        // Also treat fields accessed via array indexing as potentially enum-typed
1199        // (e.g., `result.links[0].link_type`, `result.assets[0].asset_category`).
1200        // This is safe because `str(string_value).lower()` is idempotent for
1201        // plain string fields, and all fixture `contains` values are lowercase.
1202        field_resolver.accessor(f, "python", result_var).contains("[0]")
1203    });
1204
1205    // Check whether the field path (or any prefix of it) is optional so we can
1206    // guard `in` / `not in` expressions against None.
1207    let field_is_optional = match &assertion.field {
1208        Some(f) if !f.is_empty() => {
1209            let resolved = field_resolver.resolve(f);
1210            field_resolver.is_optional(resolved)
1211        }
1212        _ => false,
1213    };
1214
1215    match assertion.assertion_type.as_str() {
1216        "error" | "not_error" => {
1217            // Handled at call site.
1218        }
1219        "equals" => {
1220            if let Some(val) = &assertion.value {
1221                let expected = value_to_python_string(val);
1222                // Use `is` for boolean/None comparisons (ruff E712).
1223                let op = if val.is_boolean() || val.is_null() { "is" } else { "==" };
1224                // For string equality, strip trailing whitespace to handle trailing newlines
1225                // from the converter.
1226                if val.is_string() {
1227                    let _ = writeln!(out, "    assert {field_access}.strip() {op} {expected}  # noqa: S101");
1228                } else {
1229                    let _ = writeln!(out, "    assert {field_access} {op} {expected}  # noqa: S101");
1230                }
1231            }
1232        }
1233        "contains" => {
1234            if let Some(val) = &assertion.value {
1235                let expected = value_to_python_string(val);
1236                // For enum fields, convert to lowercase string for comparison.
1237                let cmp_expr = if field_is_enum && val.is_string() {
1238                    format!("str({field_access}).lower()")
1239                } else {
1240                    field_access.clone()
1241                };
1242                if field_is_optional {
1243                    let _ = writeln!(out, "    assert {field_access} is not None  # noqa: S101");
1244                    let _ = writeln!(out, "    assert {expected} in {cmp_expr}  # noqa: S101");
1245                } else {
1246                    let _ = writeln!(out, "    assert {expected} in {cmp_expr}  # noqa: S101");
1247                }
1248            }
1249        }
1250        "contains_all" => {
1251            if let Some(values) = &assertion.values {
1252                for val in values {
1253                    let expected = value_to_python_string(val);
1254                    // For enum fields, convert to lowercase string for comparison.
1255                    let cmp_expr = if field_is_enum && val.is_string() {
1256                        format!("str({field_access}).lower()")
1257                    } else {
1258                        field_access.clone()
1259                    };
1260                    if field_is_optional {
1261                        let _ = writeln!(out, "    assert {field_access} is not None  # noqa: S101");
1262                        let _ = writeln!(out, "    assert {expected} in {cmp_expr}  # noqa: S101");
1263                    } else {
1264                        let _ = writeln!(out, "    assert {expected} in {cmp_expr}  # noqa: S101");
1265                    }
1266                }
1267            }
1268        }
1269        "not_contains" => {
1270            if let Some(val) = &assertion.value {
1271                let expected = value_to_python_string(val);
1272                // For enum fields, convert to lowercase string for comparison.
1273                let cmp_expr = if field_is_enum && val.is_string() {
1274                    format!("str({field_access}).lower()")
1275                } else {
1276                    field_access.clone()
1277                };
1278                if field_is_optional {
1279                    let _ = writeln!(
1280                        out,
1281                        "    assert {field_access} is None or {expected} not in {cmp_expr}  # noqa: S101"
1282                    );
1283                } else {
1284                    let _ = writeln!(out, "    assert {expected} not in {cmp_expr}  # noqa: S101");
1285                }
1286            }
1287        }
1288        "not_empty" => {
1289            let _ = writeln!(out, "    assert {field_access}  # noqa: S101");
1290        }
1291        "is_empty" => {
1292            let _ = writeln!(out, "    assert not {field_access}  # noqa: S101");
1293        }
1294        "contains_any" => {
1295            if let Some(values) = &assertion.values {
1296                let items: Vec<String> = values.iter().map(value_to_python_string).collect();
1297                let list_str = items.join(", ");
1298                // For enum fields, convert to lowercase string for comparison.
1299                let cmp_expr = if field_is_enum {
1300                    format!("str({field_access}).lower()")
1301                } else {
1302                    field_access.clone()
1303                };
1304                if field_is_optional {
1305                    let _ = writeln!(out, "    assert {field_access} is not None  # noqa: S101");
1306                    let _ = writeln!(
1307                        out,
1308                        "    assert any(v in {cmp_expr} for v in [{list_str}])  # noqa: S101"
1309                    );
1310                } else {
1311                    let _ = writeln!(
1312                        out,
1313                        "    assert any(v in {cmp_expr} for v in [{list_str}])  # noqa: S101"
1314                    );
1315                }
1316            }
1317        }
1318        "greater_than" => {
1319            if let Some(val) = &assertion.value {
1320                let expected = value_to_python_string(val);
1321                let _ = writeln!(out, "    assert {field_access} > {expected}  # noqa: S101");
1322            }
1323        }
1324        "less_than" => {
1325            if let Some(val) = &assertion.value {
1326                let expected = value_to_python_string(val);
1327                let _ = writeln!(out, "    assert {field_access} < {expected}  # noqa: S101");
1328            }
1329        }
1330        "greater_than_or_equal" | "min" => {
1331            if let Some(val) = &assertion.value {
1332                let expected = value_to_python_string(val);
1333                let _ = writeln!(out, "    assert {field_access} >= {expected}  # noqa: S101");
1334            }
1335        }
1336        "less_than_or_equal" | "max" => {
1337            if let Some(val) = &assertion.value {
1338                let expected = value_to_python_string(val);
1339                let _ = writeln!(out, "    assert {field_access} <= {expected}  # noqa: S101");
1340            }
1341        }
1342        "starts_with" => {
1343            if let Some(val) = &assertion.value {
1344                let expected = value_to_python_string(val);
1345                let _ = writeln!(out, "    assert {field_access}.startswith({expected})  # noqa: S101");
1346            }
1347        }
1348        "ends_with" => {
1349            if let Some(val) = &assertion.value {
1350                let expected = value_to_python_string(val);
1351                let _ = writeln!(out, "    assert {field_access}.endswith({expected})  # noqa: S101");
1352            }
1353        }
1354        "min_length" => {
1355            if let Some(val) = &assertion.value {
1356                if let Some(n) = val.as_u64() {
1357                    let _ = writeln!(out, "    assert len({field_access}) >= {n}  # noqa: S101");
1358                }
1359            }
1360        }
1361        "max_length" => {
1362            if let Some(val) = &assertion.value {
1363                if let Some(n) = val.as_u64() {
1364                    let _ = writeln!(out, "    assert len({field_access}) <= {n}  # noqa: S101");
1365                }
1366            }
1367        }
1368        "count_min" => {
1369            if let Some(val) = &assertion.value {
1370                if let Some(n) = val.as_u64() {
1371                    let _ = writeln!(out, "    assert len({field_access}) >= {n}  # noqa: S101");
1372                }
1373            }
1374        }
1375        "count_equals" => {
1376            if let Some(val) = &assertion.value {
1377                if let Some(n) = val.as_u64() {
1378                    let _ = writeln!(out, "    assert len({field_access}) == {n}  # noqa: S101");
1379                }
1380            }
1381        }
1382        "is_true" => {
1383            let _ = writeln!(out, "    assert {field_access} is True  # noqa: S101");
1384        }
1385        "is_false" => {
1386            let _ = writeln!(out, "    assert not {field_access}  # noqa: S101");
1387        }
1388        "method_result" => {
1389            if let Some(method_name) = &assertion.method {
1390                let call_expr = build_python_method_call(result_var, method_name, assertion.args.as_ref());
1391                let check = assertion.check.as_deref().unwrap_or("is_true");
1392                match check {
1393                    "equals" => {
1394                        if let Some(val) = &assertion.value {
1395                            if val.is_boolean() {
1396                                if val.as_bool() == Some(true) {
1397                                    let _ = writeln!(out, "    assert {call_expr} is True  # noqa: S101");
1398                                } else {
1399                                    let _ = writeln!(out, "    assert {call_expr} is False  # noqa: S101");
1400                                }
1401                            } else {
1402                                let expected = value_to_python_string(val);
1403                                let _ = writeln!(out, "    assert {call_expr} == {expected}  # noqa: S101");
1404                            }
1405                        }
1406                    }
1407                    "is_true" => {
1408                        let _ = writeln!(out, "    assert {call_expr}  # noqa: S101");
1409                    }
1410                    "is_false" => {
1411                        let _ = writeln!(out, "    assert not {call_expr}  # noqa: S101");
1412                    }
1413                    "greater_than_or_equal" => {
1414                        if let Some(val) = &assertion.value {
1415                            let n = val.as_u64().unwrap_or(0);
1416                            let _ = writeln!(out, "    assert {call_expr} >= {n}  # noqa: S101");
1417                        }
1418                    }
1419                    "count_min" => {
1420                        if let Some(val) = &assertion.value {
1421                            let n = val.as_u64().unwrap_or(0);
1422                            let _ = writeln!(out, "    assert len({call_expr}) >= {n}  # noqa: S101");
1423                        }
1424                    }
1425                    "contains" => {
1426                        if let Some(val) = &assertion.value {
1427                            let expected = value_to_python_string(val);
1428                            let _ = writeln!(out, "    assert {expected} in {call_expr}  # noqa: S101");
1429                        }
1430                    }
1431                    "is_error" => {
1432                        let _ = writeln!(out, "    with pytest.raises(Exception):  # noqa: B017");
1433                        let _ = writeln!(out, "        {call_expr}");
1434                    }
1435                    other_check => {
1436                        panic!("unsupported method_result check type: {other_check}");
1437                    }
1438                }
1439            } else {
1440                panic!("method_result assertion missing 'method' field");
1441            }
1442        }
1443        "matches_regex" => {
1444            if let Some(val) = &assertion.value {
1445                let expected = value_to_python_string(val);
1446                let _ = writeln!(out, "    import re  # noqa: PLC0415");
1447                let _ = writeln!(
1448                    out,
1449                    "    assert re.search({expected}, {field_access}) is not None  # noqa: S101"
1450                );
1451            }
1452        }
1453        other => {
1454            panic!("unsupported assertion type: {other}");
1455        }
1456    }
1457}
1458
1459/// Build a Python call expression for a method_result assertion on a tree-sitter Tree.
1460/// Maps method names to the appropriate Python function calls.
1461fn build_python_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
1462    match method_name {
1463        "root_child_count" => format!("{result_var}.root_node().child_count()"),
1464        "root_node_type" => format!("{result_var}.root_node().kind()"),
1465        "named_children_count" => format!("{result_var}.root_node().named_child_count()"),
1466        "has_error_nodes" => format!("tree_has_error_nodes({result_var})"),
1467        "error_count" | "tree_error_count" => format!("tree_error_count({result_var})"),
1468        "tree_to_sexp" => format!("tree_to_sexp({result_var})"),
1469        "contains_node_type" => {
1470            let node_type = args
1471                .and_then(|a| a.get("node_type"))
1472                .and_then(|v| v.as_str())
1473                .unwrap_or("");
1474            format!("tree_contains_node_type({result_var}, \"{node_type}\")")
1475        }
1476        "find_nodes_by_type" => {
1477            let node_type = args
1478                .and_then(|a| a.get("node_type"))
1479                .and_then(|v| v.as_str())
1480                .unwrap_or("");
1481            format!("find_nodes_by_type({result_var}, \"{node_type}\")")
1482        }
1483        "run_query" => {
1484            let query_source = args
1485                .and_then(|a| a.get("query_source"))
1486                .and_then(|v| v.as_str())
1487                .unwrap_or("");
1488            let language = args
1489                .and_then(|a| a.get("language"))
1490                .and_then(|v| v.as_str())
1491                .unwrap_or("");
1492            format!("run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1493        }
1494        _ => {
1495            if let Some(args_val) = args {
1496                let arg_str = args_val
1497                    .as_object()
1498                    .map(|obj| {
1499                        obj.iter()
1500                            .map(|(k, v)| format!("{}={}", k, value_to_python_string(v)))
1501                            .collect::<Vec<_>>()
1502                            .join(", ")
1503                    })
1504                    .unwrap_or_default();
1505                format!("{result_var}.{method_name}({arg_str})")
1506            } else {
1507                format!("{result_var}.{method_name}()")
1508            }
1509        }
1510    }
1511}
1512
1513/// Returns the Python import name for a method_result method that uses a
1514/// module-level helper function (not a method on the result object).
1515fn python_method_helper_import(method_name: &str) -> Option<String> {
1516    match method_name {
1517        "has_error_nodes" => Some("tree_has_error_nodes".to_string()),
1518        "error_count" | "tree_error_count" => Some("tree_error_count".to_string()),
1519        "tree_to_sexp" => Some("tree_to_sexp".to_string()),
1520        "contains_node_type" => Some("tree_contains_node_type".to_string()),
1521        "find_nodes_by_type" => Some("find_nodes_by_type".to_string()),
1522        "run_query" => Some("run_query".to_string()),
1523        // Methods accessed via result_var (e.g. tree.root_node().child_count()) don't need imports.
1524        _ => None,
1525    }
1526}
1527
1528fn value_to_python_string(value: &serde_json::Value) -> String {
1529    match value {
1530        serde_json::Value::String(s) => python_string_literal(s),
1531        serde_json::Value::Bool(true) => "True".to_string(),
1532        serde_json::Value::Bool(false) => "False".to_string(),
1533        serde_json::Value::Number(n) => n.to_string(),
1534        serde_json::Value::Null => "None".to_string(),
1535        other => python_string_literal(&other.to_string()),
1536    }
1537}
1538
1539/// Produce a quoted Python string literal, choosing single or double quotes
1540/// to avoid unnecessary escaping (ruff Q003).
1541fn python_string_literal(s: &str) -> String {
1542    if s.contains('"') && !s.contains('\'') {
1543        // Use single quotes to avoid escaping double quotes.
1544        let escaped = s
1545            .replace('\\', "\\\\")
1546            .replace('\'', "\\'")
1547            .replace('\n', "\\n")
1548            .replace('\r', "\\r")
1549            .replace('\t', "\\t");
1550        format!("'{escaped}'")
1551    } else {
1552        format!("\"{}\"", escape_python(s))
1553    }
1554}
1555
1556/// Emit a Python visitor method for a callback action.
1557fn emit_python_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1558    let params = match method_name {
1559        "visit_link" => "self, ctx, href, text, title",
1560        "visit_image" => "self, ctx, src, alt, title",
1561        "visit_heading" => "self, ctx, level, text, id",
1562        "visit_code_block" => "self, ctx, lang, code",
1563        "visit_code_inline"
1564        | "visit_strong"
1565        | "visit_emphasis"
1566        | "visit_strikethrough"
1567        | "visit_underline"
1568        | "visit_subscript"
1569        | "visit_superscript"
1570        | "visit_mark"
1571        | "visit_button"
1572        | "visit_summary"
1573        | "visit_figcaption"
1574        | "visit_definition_term"
1575        | "visit_definition_description" => "self, ctx, text",
1576        "visit_text" => "self, ctx, text",
1577        "visit_list_item" => "self, ctx, ordered, marker, text",
1578        "visit_blockquote" => "self, ctx, content, depth",
1579        "visit_table_row" => "self, ctx, cells, is_header",
1580        "visit_custom_element" => "self, ctx, tag_name, html",
1581        "visit_form" => "self, ctx, action_url, method",
1582        "visit_input" => "self, ctx, input_type, name, value",
1583        "visit_audio" | "visit_video" | "visit_iframe" => "self, ctx, src",
1584        "visit_details" => "self, ctx, is_open",
1585        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
1586            "self, ctx, output, *args"
1587        }
1588        "visit_list_start" => "self, ctx, ordered, *args",
1589        "visit_list_end" => "self, ctx, ordered, output, *args",
1590        _ => "self, ctx, *args",
1591    };
1592
1593    let _ = writeln!(
1594        out,
1595        "        def {method_name}({params}):  # noqa: A002, ANN001, ANN202, ARG002"
1596    );
1597    match action {
1598        CallbackAction::Skip => {
1599            let _ = writeln!(out, "            return \"skip\"");
1600        }
1601        CallbackAction::Continue => {
1602            let _ = writeln!(out, "            return \"continue\"");
1603        }
1604        CallbackAction::PreserveHtml => {
1605            let _ = writeln!(out, "            return \"preserve_html\"");
1606        }
1607        CallbackAction::Custom { output } => {
1608            let escaped = escape_python(output);
1609            let _ = writeln!(out, "            return {{\"custom\": \"{escaped}\"}}");
1610        }
1611        CallbackAction::CustomTemplate { template } => {
1612            // Use single-quoted f-string so that double quotes inside the template
1613            // (e.g. `QUOTE: "{text}"`) are not misinterpreted as string delimiters.
1614            // Escape newlines/tabs/backslashes/single quotes so the template stays
1615            // on a single line in the generated source.
1616            let escaped_template = template
1617                .replace('\\', "\\\\")
1618                .replace('\'', "\\'")
1619                .replace('\n', "\\n")
1620                .replace('\r', "\\r")
1621                .replace('\t', "\\t");
1622            let _ = writeln!(out, "            return {{\"custom\": f'{escaped_template}'}}");
1623        }
1624    }
1625}