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