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