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::config::E2eConfig;
7use crate::escape::{escape_python, sanitize_filename, sanitize_ident};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::AlefConfig;
12use anyhow::Result;
13use heck::{ToShoutySnakeCase, ToSnakeCase};
14use std::collections::HashMap;
15use std::fmt::Write as FmtWrite;
16use std::path::PathBuf;
17
18/// Python e2e test code generator.
19pub struct PythonE2eCodegen;
20
21impl super::E2eCodegen for PythonE2eCodegen {
22    fn generate(
23        &self,
24        groups: &[FixtureGroup],
25        e2e_config: &E2eConfig,
26        _alef_config: &AlefConfig,
27    ) -> Result<Vec<GeneratedFile>> {
28        let mut files = Vec::new();
29        let output_base = PathBuf::from(e2e_config.effective_output()).join("python");
30
31        // conftest.py
32        files.push(GeneratedFile {
33            path: output_base.join("conftest.py"),
34            content: render_conftest(e2e_config),
35            generated_header: true,
36        });
37
38        // Root __init__.py (prevents ruff INP001).
39        files.push(GeneratedFile {
40            path: output_base.join("__init__.py"),
41            content: String::new(),
42            generated_header: false,
43        });
44
45        // tests/__init__.py
46        files.push(GeneratedFile {
47            path: output_base.join("tests").join("__init__.py"),
48            content: String::new(),
49            generated_header: false,
50        });
51
52        // pyproject.toml for standalone uv resolution
53        let python_pkg = e2e_config.resolve_package("python");
54        let pkg_name = python_pkg
55            .as_ref()
56            .and_then(|p| p.name.as_deref())
57            .unwrap_or("kreuzcrawl");
58        let pkg_path = python_pkg
59            .as_ref()
60            .and_then(|p| p.path.as_deref())
61            .unwrap_or("../../packages/python");
62        let pkg_version = python_pkg
63            .as_ref()
64            .and_then(|p| p.version.as_deref())
65            .unwrap_or("0.1.0");
66        files.push(GeneratedFile {
67            path: output_base.join("pyproject.toml"),
68            content: render_pyproject(pkg_name, pkg_path, pkg_version, e2e_config.dep_mode),
69            generated_header: true,
70        });
71
72        // Per-category test files.
73        for group in groups {
74            let fixtures: Vec<&Fixture> = group.fixtures.iter().collect();
75
76            if fixtures.is_empty() {
77                continue;
78            }
79
80            let filename = format!("test_{}.py", sanitize_filename(&group.category));
81            let content = render_test_file(&group.category, &fixtures, e2e_config);
82
83            files.push(GeneratedFile {
84                path: output_base.join("tests").join(filename),
85                content,
86                generated_header: true,
87            });
88        }
89
90        Ok(files)
91    }
92
93    fn language_name(&self) -> &'static str {
94        "python"
95    }
96}
97
98// ---------------------------------------------------------------------------
99// pyproject.toml
100// ---------------------------------------------------------------------------
101
102fn render_pyproject(
103    pkg_name: &str,
104    _pkg_path: &str,
105    pkg_version: &str,
106    dep_mode: crate::config::DependencyMode,
107) -> String {
108    let dep_spec = match dep_mode {
109        crate::config::DependencyMode::Registry => {
110            format!(
111                "dependencies = [\"{pkg_name}{pkg_version}\", \"pytest>=7.4\", \"pytest-asyncio>=0.23\", \"pytest-timeout>=2.1\"]\n"
112            )
113        }
114        crate::config::DependencyMode::Local => {
115            format!(
116                "dependencies = [\"{pkg_name}\", \"pytest>=7.4\", \"pytest-asyncio>=0.23\", \"pytest-timeout>=2.1\"]\n\
117                 \n\
118                 [tool.uv.sources]\n\
119                 {pkg_name} = {{ workspace = true }}\n"
120            )
121        }
122    };
123
124    format!(
125        r#"[build-system]
126build-backend = "setuptools.build_meta"
127requires = ["setuptools>=68", "wheel"]
128
129[project]
130name = "{pkg_name}-e2e-tests"
131version = "0.0.0"
132description = "End-to-end tests"
133requires-python = ">=3.10"
134{dep_spec}
135[tool.setuptools]
136packages = []
137
138[tool.pytest.ini_options]
139asyncio_mode = "auto"
140testpaths = ["tests"]
141python_files = "test_*.py"
142python_functions = "test_*"
143addopts = "-v --strict-markers --tb=short"
144timeout = 300
145"#
146    )
147}
148
149// ---------------------------------------------------------------------------
150// Config resolution helpers
151// ---------------------------------------------------------------------------
152
153fn resolve_function_name(e2e_config: &E2eConfig) -> String {
154    resolve_function_name_for_call(&e2e_config.call)
155}
156
157fn resolve_function_name_for_call(call_config: &crate::config::CallConfig) -> String {
158    call_config
159        .overrides
160        .get("python")
161        .and_then(|o| o.function.clone())
162        .unwrap_or_else(|| call_config.function.clone())
163}
164
165fn resolve_module(e2e_config: &E2eConfig) -> String {
166    e2e_config
167        .call
168        .overrides
169        .get("python")
170        .and_then(|o| o.module.clone())
171        .unwrap_or_else(|| e2e_config.call.module.replace('-', "_"))
172}
173
174fn resolve_options_type(e2e_config: &E2eConfig) -> Option<String> {
175    e2e_config
176        .call
177        .overrides
178        .get("python")
179        .and_then(|o| o.options_type.clone())
180}
181
182/// Resolve how json_object args are passed: "kwargs" (default), "dict", or "json".
183fn resolve_options_via(e2e_config: &E2eConfig) -> &str {
184    e2e_config
185        .call
186        .overrides
187        .get("python")
188        .and_then(|o| o.options_via.as_deref())
189        .unwrap_or("kwargs")
190}
191
192/// Resolve enum field mappings from the Python override config.
193fn resolve_enum_fields(e2e_config: &E2eConfig) -> &HashMap<String, String> {
194    static EMPTY: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
195    e2e_config
196        .call
197        .overrides
198        .get("python")
199        .map(|o| &o.enum_fields)
200        .unwrap_or(&EMPTY)
201}
202
203/// Resolve handle nested type mappings from the Python override config.
204/// Maps config field names to their Python constructor type names.
205fn resolve_handle_nested_types(e2e_config: &E2eConfig) -> &HashMap<String, String> {
206    static EMPTY: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
207    e2e_config
208        .call
209        .overrides
210        .get("python")
211        .map(|o| &o.handle_nested_types)
212        .unwrap_or(&EMPTY)
213}
214
215/// Resolve handle dict type set from the Python override config.
216/// Fields in this set use `TypeName({...})` instead of `TypeName(key=val, ...)`.
217fn resolve_handle_dict_types(e2e_config: &E2eConfig) -> &std::collections::HashSet<String> {
218    static EMPTY: std::sync::LazyLock<std::collections::HashSet<String>> =
219        std::sync::LazyLock::new(std::collections::HashSet::new);
220    e2e_config
221        .call
222        .overrides
223        .get("python")
224        .map(|o| &o.handle_dict_types)
225        .unwrap_or(&EMPTY)
226}
227
228fn is_skipped(fixture: &Fixture, language: &str) -> bool {
229    fixture.skip.as_ref().is_some_and(|s| s.should_skip(language))
230}
231
232// ---------------------------------------------------------------------------
233// Rendering
234// ---------------------------------------------------------------------------
235
236fn render_conftest(e2e_config: &E2eConfig) -> String {
237    let module = resolve_module(e2e_config);
238    format!(
239        r#"# This file is auto-generated by alef. DO NOT EDIT.
240"""Pytest configuration for e2e tests."""
241# Ensure the package is importable.
242# The {module} package is expected to be installed in the current environment.
243"#
244    )
245}
246
247fn render_test_file(category: &str, fixtures: &[&Fixture], e2e_config: &E2eConfig) -> String {
248    let mut out = String::new();
249    let _ = writeln!(out, "# This file is auto-generated by alef. DO NOT EDIT.");
250    let _ = writeln!(out, "\"\"\"E2e tests for category: {category}.\"\"\"");
251
252    let module = resolve_module(e2e_config);
253    let function_name = resolve_function_name(e2e_config);
254    let options_type = resolve_options_type(e2e_config);
255    let options_via = resolve_options_via(e2e_config);
256    let enum_fields = resolve_enum_fields(e2e_config);
257    let handle_nested_types = resolve_handle_nested_types(e2e_config);
258    let handle_dict_types = resolve_handle_dict_types(e2e_config);
259    let field_resolver = FieldResolver::new(
260        &e2e_config.fields,
261        &e2e_config.fields_optional,
262        &e2e_config.result_fields,
263        &e2e_config.fields_array,
264    );
265
266    let has_error_test = fixtures
267        .iter()
268        .any(|f| f.assertions.iter().any(|a| a.assertion_type == "error"));
269    let has_skipped = fixtures.iter().any(|f| is_skipped(f, "python"));
270
271    // Check if any fixture in this file uses an async call.
272    let is_async = fixtures.iter().any(|f| {
273        let cc = e2e_config.resolve_call(f.call.as_deref());
274        cc.r#async
275    }) || e2e_config.call.r#async;
276    let needs_pytest = has_error_test || has_skipped || is_async;
277
278    // "json" mode needs `import json`.
279    let needs_json_import = options_via == "json"
280        && fixtures.iter().any(|f| {
281            e2e_config
282                .call
283                .args
284                .iter()
285                .any(|arg| arg.arg_type == "json_object" && !resolve_field(&f.input, &arg.field).is_null())
286        });
287
288    // mock_url args need `import os`.
289    let needs_os_import = e2e_config.call.args.iter().any(|arg| arg.arg_type == "mock_url");
290
291    // Only import options_type when using "kwargs" mode.
292    let needs_options_type = options_via == "kwargs"
293        && options_type.is_some()
294        && fixtures.iter().any(|f| {
295            e2e_config
296                .call
297                .args
298                .iter()
299                .any(|arg| arg.arg_type == "json_object" && !resolve_field(&f.input, &arg.field).is_null())
300        });
301
302    // Collect enum types actually used across all fixtures in this file.
303    let mut used_enum_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
304    if needs_options_type && !enum_fields.is_empty() {
305        for fixture in fixtures.iter() {
306            for arg in &e2e_config.call.args {
307                if arg.arg_type == "json_object" {
308                    let value = resolve_field(&fixture.input, &arg.field);
309                    if let Some(obj) = value.as_object() {
310                        for key in obj.keys() {
311                            if let Some(enum_type) = enum_fields.get(key) {
312                                used_enum_types.insert(enum_type.clone());
313                            }
314                        }
315                    }
316                }
317            }
318        }
319    }
320
321    // Collect imports sorted per isort/ruff I001: stdlib group, then
322    // third-party group, separated by a blank line. Within each group
323    // `import X` lines come before `from X import Y` lines, both sorted.
324    let mut stdlib_imports: Vec<String> = Vec::new();
325    let mut thirdparty_bare: Vec<String> = Vec::new();
326    let mut thirdparty_from: Vec<String> = Vec::new();
327
328    if needs_json_import {
329        stdlib_imports.push("import json".to_string());
330    }
331
332    if needs_os_import {
333        stdlib_imports.push("import os".to_string());
334    }
335
336    if needs_pytest {
337        thirdparty_bare.push("import pytest".to_string());
338    }
339
340    // Collect handle constructor function names that need to be imported.
341    let handle_constructors: Vec<String> = e2e_config
342        .call
343        .args
344        .iter()
345        .filter(|arg| arg.arg_type == "handle")
346        .map(|arg| format!("create_{}", arg.name.to_snake_case()))
347        .collect();
348
349    // Collect all unique function names needed across all fixtures in this file.
350    let mut import_names: Vec<String> = vec![function_name.clone()];
351    for fixture in fixtures.iter() {
352        let cc = e2e_config.resolve_call(fixture.call.as_deref());
353        let fn_name = resolve_function_name_for_call(cc);
354        if !import_names.contains(&fn_name) {
355            import_names.push(fn_name);
356        }
357    }
358    for ctor in &handle_constructors {
359        if !import_names.contains(ctor) {
360            import_names.push(ctor.clone());
361        }
362    }
363
364    // If any handle arg has config, import the config class (CrawlConfig or options_type).
365    let needs_config_import = e2e_config.call.args.iter().any(|arg| {
366        arg.arg_type == "handle"
367            && fixtures.iter().any(|f| {
368                let val = resolve_field(&f.input, &arg.field);
369                !val.is_null() && val.as_object().is_some_and(|o| !o.is_empty())
370            })
371    });
372    if needs_config_import {
373        let config_class = options_type.as_deref().unwrap_or("CrawlConfig");
374        if !import_names.contains(&config_class.to_string()) {
375            import_names.push(config_class.to_string());
376        }
377    }
378
379    // Import any nested handle config types actually used in this file.
380    if !handle_nested_types.is_empty() {
381        let mut used_nested_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
382        for fixture in fixtures.iter() {
383            for arg in &e2e_config.call.args {
384                if arg.arg_type == "handle" {
385                    let config_value = resolve_field(&fixture.input, &arg.field);
386                    if let Some(obj) = config_value.as_object() {
387                        for key in obj.keys() {
388                            if let Some(type_name) = handle_nested_types.get(key) {
389                                if obj[key].is_object() {
390                                    used_nested_types.insert(type_name.clone());
391                                }
392                            }
393                        }
394                    }
395                }
396            }
397        }
398        for type_name in used_nested_types {
399            if !import_names.contains(&type_name) {
400                import_names.push(type_name);
401            }
402        }
403    }
404
405    if let (true, Some(opts_type)) = (needs_options_type, &options_type) {
406        import_names.push(opts_type.clone());
407        thirdparty_from.push(format!("from {module} import {}", import_names.join(", ")));
408        // Import enum types from enum_module (if specified) or main module.
409        if !used_enum_types.is_empty() {
410            let enum_mod = e2e_config
411                .call
412                .overrides
413                .get("python")
414                .and_then(|o| o.enum_module.as_deref())
415                .unwrap_or(&module);
416            let enum_names: Vec<&String> = used_enum_types.iter().collect();
417            thirdparty_from.push(format!(
418                "from {enum_mod} import {}",
419                enum_names.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
420            ));
421        }
422    } else {
423        thirdparty_from.push(format!("from {module} import {}", import_names.join(", ")));
424    }
425
426    stdlib_imports.sort();
427    thirdparty_bare.sort();
428    thirdparty_from.sort();
429
430    // Emit sorted import groups with blank lines between groups per PEP 8.
431    if !stdlib_imports.is_empty() {
432        for imp in &stdlib_imports {
433            let _ = writeln!(out, "{imp}");
434        }
435        let _ = writeln!(out);
436    }
437    // Third-party: bare imports then from-imports, no blank line between them.
438    for imp in &thirdparty_bare {
439        let _ = writeln!(out, "{imp}");
440    }
441    for imp in &thirdparty_from {
442        let _ = writeln!(out, "{imp}");
443    }
444    // Two blank lines after imports (PEP 8 / ruff I001).
445    let _ = writeln!(out);
446    let _ = writeln!(out);
447
448    for fixture in fixtures {
449        render_test_function(
450            &mut out,
451            fixture,
452            e2e_config,
453            options_type.as_deref(),
454            options_via,
455            enum_fields,
456            handle_nested_types,
457            handle_dict_types,
458            &field_resolver,
459        );
460        let _ = writeln!(out);
461    }
462
463    out
464}
465
466#[allow(clippy::too_many_arguments)]
467fn render_test_function(
468    out: &mut String,
469    fixture: &Fixture,
470    e2e_config: &E2eConfig,
471    options_type: Option<&str>,
472    options_via: &str,
473    enum_fields: &HashMap<String, String>,
474    handle_nested_types: &HashMap<String, String>,
475    handle_dict_types: &std::collections::HashSet<String>,
476    field_resolver: &FieldResolver,
477) {
478    let fn_name = sanitize_ident(&fixture.id);
479    let description = &fixture.description;
480    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
481    let function_name = resolve_function_name_for_call(call_config);
482    let result_var = &call_config.result_var;
483
484    let desc_with_period = if description.ends_with('.') {
485        description.to_string()
486    } else {
487        format!("{description}.")
488    };
489
490    // Emit pytest.mark.skip for fixtures that should be skipped for python.
491    if is_skipped(fixture, "python") {
492        let reason = fixture
493            .skip
494            .as_ref()
495            .and_then(|s| s.reason.as_deref())
496            .unwrap_or("skipped for python");
497        let _ = writeln!(out, "@pytest.mark.skip(reason=\"{reason}\")");
498    }
499
500    let is_async = call_config.r#async;
501    if is_async {
502        let _ = writeln!(out, "@pytest.mark.asyncio");
503        let _ = writeln!(out, "async def test_{fn_name}() -> None:");
504    } else {
505        let _ = writeln!(out, "def test_{fn_name}() -> None:");
506    }
507    let _ = writeln!(out, "    \"\"\"{desc_with_period}\"\"\"");
508
509    // Check if any assertion is an error assertion.
510    let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
511
512    // Build argument expressions from config.
513    let mut arg_bindings = Vec::new();
514    let mut kwarg_exprs = Vec::new();
515    for arg in &call_config.args {
516        let var_name = &arg.name;
517
518        if arg.arg_type == "handle" {
519            // Generate a create_engine (or equivalent) call and pass the variable.
520            // If there's config data, construct a CrawlConfig with kwargs.
521            let constructor_name = format!("create_{}", arg.name.to_snake_case());
522            let config_value = resolve_field(&fixture.input, &arg.field);
523            if config_value.is_null()
524                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
525            {
526                arg_bindings.push(format!("    {var_name} = {constructor_name}(None)"));
527            } else if let Some(obj) = config_value.as_object() {
528                // Build kwargs for the config constructor (CrawlConfig(key=val, ...)).
529                // For fields with a nested type mapping, wrap the dict value in the
530                // appropriate typed constructor instead of passing a plain dict.
531                let kwargs: Vec<String> = obj
532                    .iter()
533                    .map(|(k, v)| {
534                        let snake_key = k.to_snake_case();
535                        let py_val = if let Some(type_name) = handle_nested_types.get(k) {
536                            // Wrap the nested dict in the typed constructor.
537                            if let Some(nested_obj) = v.as_object() {
538                                if nested_obj.is_empty() {
539                                    // Empty dict: use the default constructor.
540                                    format!("{type_name}()")
541                                } else if handle_dict_types.contains(k) {
542                                    // The outer Python config type (e.g. CrawlConfig) accepts a
543                                    // plain dict for this field (e.g. `auth: dict | None`).
544                                    // The binding-layer wrapper (e.g. api.py) creates the typed
545                                    // object internally, so we must NOT pre-wrap it here.
546                                    json_to_python_literal(v)
547                                } else {
548                                    // Type takes keyword arguments.
549                                    let nested_kwargs: Vec<String> = nested_obj
550                                        .iter()
551                                        .map(|(nk, nv)| {
552                                            let nested_snake_key = nk.to_snake_case();
553                                            format!("{nested_snake_key}={}", json_to_python_literal(nv))
554                                        })
555                                        .collect();
556                                    format!("{type_name}({})", nested_kwargs.join(", "))
557                                }
558                            } else {
559                                // Non-object value: use as-is.
560                                json_to_python_literal(v)
561                            }
562                        } else if k == "request_timeout" {
563                            // The Python binding converts request_timeout with Duration::from_secs
564                            // (seconds) while fixtures specify values in milliseconds. Divide by
565                            // 1000 to compensate: e.g., 1 ms → 0 s (immediate timeout),
566                            // 5000 ms → 5 s. This keeps test semantics consistent with the
567                            // fixture intent.
568                            if let Some(ms) = v.as_u64() {
569                                format!("{}", ms / 1000)
570                            } else {
571                                json_to_python_literal(v)
572                            }
573                        } else {
574                            json_to_python_literal(v)
575                        };
576                        format!("{snake_key}={py_val}")
577                    })
578                    .collect();
579                // Use the options_type if configured, otherwise "CrawlConfig".
580                let config_class = options_type.unwrap_or("CrawlConfig");
581                let single_line = format!("    {var_name}_config = {config_class}({})", kwargs.join(", "));
582                if single_line.len() <= 120 {
583                    arg_bindings.push(single_line);
584                } else {
585                    // Split into multi-line for readability and E501 compliance.
586                    let mut lines = format!("    {var_name}_config = {config_class}(\n");
587                    for kw in &kwargs {
588                        lines.push_str(&format!("        {kw},\n"));
589                    }
590                    lines.push_str("    )");
591                    arg_bindings.push(lines);
592                }
593                arg_bindings.push(format!("    {var_name} = {constructor_name}({var_name}_config)"));
594            } else {
595                let literal = json_to_python_literal(config_value);
596                arg_bindings.push(format!("    {var_name} = {constructor_name}({literal})"));
597            }
598            kwarg_exprs.push(format!("{var_name}={var_name}"));
599            continue;
600        }
601
602        if arg.arg_type == "mock_url" {
603            let fixture_id = &fixture.id;
604            arg_bindings.push(format!(
605                "    {var_name} = os.environ['MOCK_SERVER_URL'] + '/fixtures/{fixture_id}'"
606            ));
607            kwarg_exprs.push(format!("{var_name}={var_name}"));
608            continue;
609        }
610
611        let value = resolve_field(&fixture.input, &arg.field);
612
613        if value.is_null() && arg.optional {
614            continue;
615        }
616
617        // For json_object args, use the configured options_via strategy.
618        if arg.arg_type == "json_object" && !value.is_null() {
619            match options_via {
620                "dict" => {
621                    // Pass as a plain Python dict literal.
622                    let literal = json_to_python_literal(value);
623                    arg_bindings.push(format!("    {var_name} = {literal}"));
624                    kwarg_exprs.push(format!("{var_name}={var_name}"));
625                    continue;
626                }
627                "json" => {
628                    // Pass via json.loads() with the raw JSON string.
629                    let json_str = serde_json::to_string(value).unwrap_or_default();
630                    let escaped = escape_python(&json_str);
631                    arg_bindings.push(format!("    {var_name} = json.loads(\"{escaped}\")"));
632                    kwarg_exprs.push(format!("{var_name}={var_name}"));
633                    continue;
634                }
635                _ => {
636                    // "kwargs" (default): construct OptionsType(key=val, ...).
637                    if let (Some(opts_type), Some(obj)) = (options_type, value.as_object()) {
638                        let kwargs: Vec<String> = obj
639                            .iter()
640                            .map(|(k, v)| {
641                                let snake_key = k.to_snake_case();
642                                let py_val = if let Some(enum_type) = enum_fields.get(k) {
643                                    // Map string value to enum constant.
644                                    if let Some(s) = v.as_str() {
645                                        let upper_val = s.to_shouty_snake_case();
646                                        format!("{enum_type}.{upper_val}")
647                                    } else {
648                                        json_to_python_literal(v)
649                                    }
650                                } else {
651                                    json_to_python_literal(v)
652                                };
653                                format!("{snake_key}={py_val}")
654                            })
655                            .collect();
656                        let constructor = format!("{opts_type}({})", kwargs.join(", "));
657                        arg_bindings.push(format!("    {var_name} = {constructor}"));
658                        kwarg_exprs.push(format!("{var_name}={var_name}"));
659                        continue;
660                    }
661                }
662            }
663        }
664
665        // For required args with no fixture value, use a language-appropriate default.
666        if value.is_null() && !arg.optional {
667            let default_val = match arg.arg_type.as_str() {
668                "string" => "\"\"".to_string(),
669                "int" | "integer" => "0".to_string(),
670                "float" | "number" => "0.0".to_string(),
671                "bool" | "boolean" => "False".to_string(),
672                _ => "None".to_string(),
673            };
674            arg_bindings.push(format!("    {var_name} = {default_val}"));
675            kwarg_exprs.push(format!("{var_name}={var_name}"));
676            continue;
677        }
678
679        let literal = json_to_python_literal(value);
680        arg_bindings.push(format!("    {var_name} = {literal}"));
681        kwarg_exprs.push(format!("{var_name}={var_name}"));
682    }
683
684    // Generate visitor class if the fixture has a visitor spec.
685    if let Some(visitor_spec) = &fixture.visitor {
686        let _ = writeln!(out, "    class _TestVisitor:");
687        for (method_name, action) in &visitor_spec.callbacks {
688            emit_python_visitor_method(out, method_name, action);
689        }
690        kwarg_exprs.push("visitor=_TestVisitor()".to_string());
691    }
692
693    for binding in &arg_bindings {
694        let _ = writeln!(out, "{binding}");
695    }
696
697    let call_args = kwarg_exprs.join(", ");
698    let await_prefix = if is_async { "await " } else { "" };
699    let call_expr = format!("{await_prefix}{function_name}({call_args})");
700
701    if has_error_assertion {
702        // Find error assertion for optional message check.
703        let error_assertion = fixture.assertions.iter().find(|a| a.assertion_type == "error");
704        let has_message = error_assertion
705            .and_then(|a| a.value.as_ref())
706            .and_then(|v| v.as_str())
707            .is_some();
708
709        if has_message {
710            let _ = writeln!(out, "    with pytest.raises(Exception) as exc_info:");
711            let _ = writeln!(out, "        {call_expr}");
712            if let Some(msg) = error_assertion.and_then(|a| a.value.as_ref()).and_then(|v| v.as_str()) {
713                let escaped = escape_python(msg);
714                let _ = writeln!(out, "    assert \"{escaped}\" in str(exc_info.value)  # noqa: S101");
715            }
716        } else {
717            let _ = writeln!(out, "    with pytest.raises(Exception):");
718            let _ = writeln!(out, "        {call_expr}");
719        }
720
721        // Skip non-error assertions: `result` is not defined outside the
722        // `pytest.raises` block, so referencing it would trigger ruff F821.
723        return;
724    }
725
726    // Non-error path.
727    let has_usable_assertion = fixture.assertions.iter().any(|a| {
728        if a.assertion_type == "not_error" || a.assertion_type == "error" {
729            return false;
730        }
731        match &a.field {
732            Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
733            _ => true,
734        }
735    });
736    let py_result_var = if has_usable_assertion {
737        result_var.to_string()
738    } else {
739        "_".to_string()
740    };
741    let _ = writeln!(out, "    {py_result_var} = {call_expr}");
742
743    let fields_enum = &e2e_config.fields_enum;
744    for assertion in &fixture.assertions {
745        if assertion.assertion_type == "not_error" {
746            // The call already raises on error in Python.
747            continue;
748        }
749        render_assertion(out, assertion, result_var, field_resolver, fields_enum);
750    }
751}
752
753// ---------------------------------------------------------------------------
754// Argument rendering
755// ---------------------------------------------------------------------------
756
757fn resolve_field<'a>(input: &'a serde_json::Value, field_path: &str) -> &'a serde_json::Value {
758    // Field paths in call config are "input.path", "input.config", etc.
759    // Since we already receive `fixture.input`, strip the leading "input." prefix.
760    let path = field_path.strip_prefix("input.").unwrap_or(field_path);
761    let mut current = input;
762    for part in path.split('.') {
763        current = current.get(part).unwrap_or(&serde_json::Value::Null);
764    }
765    current
766}
767
768fn json_to_python_literal(value: &serde_json::Value) -> String {
769    match value {
770        serde_json::Value::Null => "None".to_string(),
771        serde_json::Value::Bool(true) => "True".to_string(),
772        serde_json::Value::Bool(false) => "False".to_string(),
773        serde_json::Value::Number(n) => n.to_string(),
774        serde_json::Value::String(s) => python_string_literal(s),
775        serde_json::Value::Array(arr) => {
776            let items: Vec<String> = arr.iter().map(json_to_python_literal).collect();
777            format!("[{}]", items.join(", "))
778        }
779        serde_json::Value::Object(map) => {
780            let items: Vec<String> = map
781                .iter()
782                .map(|(k, v)| format!("\"{}\": {}", escape_python(k), json_to_python_literal(v)))
783                .collect();
784            format!("{{{}}}", items.join(", "))
785        }
786    }
787}
788
789// ---------------------------------------------------------------------------
790// Assertion rendering
791// ---------------------------------------------------------------------------
792
793fn render_assertion(
794    out: &mut String,
795    assertion: &Assertion,
796    result_var: &str,
797    field_resolver: &FieldResolver,
798    fields_enum: &std::collections::HashSet<String>,
799) {
800    // Skip assertions on fields that don't exist on the result type.
801    if let Some(f) = &assertion.field {
802        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
803            let _ = writeln!(out, "    # skipped: field '{f}' not available on result type");
804            return;
805        }
806    }
807
808    let field_access = match &assertion.field {
809        Some(f) if !f.is_empty() => field_resolver.accessor(f, "python", result_var),
810        _ => result_var.to_string(),
811    };
812
813    // Determine whether this field should be compared as an enum string.
814    //
815    // PyO3 integer-based enums (`#[pyclass(eq, eq_int)]`) are NOT iterable, so
816    // `"value" in enum_field` raises TypeError.  Use `str(enum_field).lower()`
817    // instead, which for a variant like `LinkType.Anchor` gives `"linktype.anchor"`,
818    // making `"anchor" in str(LinkType.Anchor).lower()` evaluate to True.
819    //
820    // We apply this to fields explicitly listed in `fields_enum` (using both the
821    // fixture field path and the resolved path) and to any field whose accessor
822    // involves array-element indexing (`[0]`) which typically holds typed enums.
823    let field_is_enum = assertion.field.as_deref().is_some_and(|f| {
824        if fields_enum.contains(f) {
825            return true;
826        }
827        let resolved = field_resolver.resolve(f);
828        if fields_enum.contains(resolved) {
829            return true;
830        }
831        // Also treat fields accessed via array indexing as potentially enum-typed
832        // (e.g., `result.links[0].link_type`, `result.assets[0].asset_category`).
833        // This is safe because `str(string_value).lower()` is idempotent for
834        // plain string fields, and all fixture `contains` values are lowercase.
835        field_resolver.accessor(f, "python", result_var).contains("[0]")
836    });
837
838    // Check whether the field path (or any prefix of it) is optional so we can
839    // guard `in` / `not in` expressions against None.
840    let field_is_optional = match &assertion.field {
841        Some(f) if !f.is_empty() => {
842            let resolved = field_resolver.resolve(f);
843            field_resolver.is_optional(resolved)
844        }
845        _ => false,
846    };
847
848    match assertion.assertion_type.as_str() {
849        "error" | "not_error" => {
850            // Handled at call site.
851        }
852        "equals" => {
853            if let Some(val) = &assertion.value {
854                let expected = value_to_python_string(val);
855                // Use `is` for boolean/None comparisons (ruff E712).
856                let op = if val.is_boolean() || val.is_null() { "is" } else { "==" };
857                // For string equality, strip trailing whitespace to handle trailing newlines
858                // from the converter.
859                if val.is_string() {
860                    let _ = writeln!(out, "    assert {field_access}.strip() {op} {expected}  # noqa: S101");
861                } else {
862                    let _ = writeln!(out, "    assert {field_access} {op} {expected}  # noqa: S101");
863                }
864            }
865        }
866        "contains" => {
867            if let Some(val) = &assertion.value {
868                let expected = value_to_python_string(val);
869                // For enum fields, convert to lowercase string for comparison.
870                let cmp_expr = if field_is_enum && val.is_string() {
871                    format!("str({field_access}).lower()")
872                } else {
873                    field_access.clone()
874                };
875                if field_is_optional {
876                    let _ = writeln!(out, "    assert {field_access} is not None  # noqa: S101");
877                    let _ = writeln!(out, "    assert {expected} in {cmp_expr}  # noqa: S101");
878                } else {
879                    let _ = writeln!(out, "    assert {expected} in {cmp_expr}  # noqa: S101");
880                }
881            }
882        }
883        "contains_all" => {
884            if let Some(values) = &assertion.values {
885                for val in values {
886                    let expected = value_to_python_string(val);
887                    // For enum fields, convert to lowercase string for comparison.
888                    let cmp_expr = if field_is_enum && val.is_string() {
889                        format!("str({field_access}).lower()")
890                    } else {
891                        field_access.clone()
892                    };
893                    if field_is_optional {
894                        let _ = writeln!(out, "    assert {field_access} is not None  # noqa: S101");
895                        let _ = writeln!(out, "    assert {expected} in {cmp_expr}  # noqa: S101");
896                    } else {
897                        let _ = writeln!(out, "    assert {expected} in {cmp_expr}  # noqa: S101");
898                    }
899                }
900            }
901        }
902        "not_contains" => {
903            if let Some(val) = &assertion.value {
904                let expected = value_to_python_string(val);
905                // For enum fields, convert to lowercase string for comparison.
906                let cmp_expr = if field_is_enum && val.is_string() {
907                    format!("str({field_access}).lower()")
908                } else {
909                    field_access.clone()
910                };
911                if field_is_optional {
912                    let _ = writeln!(
913                        out,
914                        "    assert {field_access} is None or {expected} not in {cmp_expr}  # noqa: S101"
915                    );
916                } else {
917                    let _ = writeln!(out, "    assert {expected} not in {cmp_expr}  # noqa: S101");
918                }
919            }
920        }
921        "not_empty" => {
922            let _ = writeln!(out, "    assert {field_access}  # noqa: S101");
923        }
924        "is_empty" => {
925            let _ = writeln!(out, "    assert not {field_access}  # noqa: S101");
926        }
927        "contains_any" => {
928            if let Some(values) = &assertion.values {
929                let items: Vec<String> = values.iter().map(value_to_python_string).collect();
930                let list_str = items.join(", ");
931                // For enum fields, convert to lowercase string for comparison.
932                let cmp_expr = if field_is_enum {
933                    format!("str({field_access}).lower()")
934                } else {
935                    field_access.clone()
936                };
937                if field_is_optional {
938                    let _ = writeln!(out, "    assert {field_access} is not None  # noqa: S101");
939                    let _ = writeln!(
940                        out,
941                        "    assert any(v in {cmp_expr} for v in [{list_str}])  # noqa: S101"
942                    );
943                } else {
944                    let _ = writeln!(
945                        out,
946                        "    assert any(v in {cmp_expr} for v in [{list_str}])  # noqa: S101"
947                    );
948                }
949            }
950        }
951        "greater_than" => {
952            if let Some(val) = &assertion.value {
953                let expected = value_to_python_string(val);
954                let _ = writeln!(out, "    assert {field_access} > {expected}  # noqa: S101");
955            }
956        }
957        "less_than" => {
958            if let Some(val) = &assertion.value {
959                let expected = value_to_python_string(val);
960                let _ = writeln!(out, "    assert {field_access} < {expected}  # noqa: S101");
961            }
962        }
963        "greater_than_or_equal" | "min" => {
964            if let Some(val) = &assertion.value {
965                let expected = value_to_python_string(val);
966                let _ = writeln!(out, "    assert {field_access} >= {expected}  # noqa: S101");
967            }
968        }
969        "less_than_or_equal" | "max" => {
970            if let Some(val) = &assertion.value {
971                let expected = value_to_python_string(val);
972                let _ = writeln!(out, "    assert {field_access} <= {expected}  # noqa: S101");
973            }
974        }
975        "starts_with" => {
976            if let Some(val) = &assertion.value {
977                let expected = value_to_python_string(val);
978                let _ = writeln!(out, "    assert {field_access}.startswith({expected})  # noqa: S101");
979            }
980        }
981        "ends_with" => {
982            if let Some(val) = &assertion.value {
983                let expected = value_to_python_string(val);
984                let _ = writeln!(out, "    assert {field_access}.endswith({expected})  # noqa: S101");
985            }
986        }
987        "min_length" => {
988            if let Some(val) = &assertion.value {
989                if let Some(n) = val.as_u64() {
990                    let _ = writeln!(out, "    assert len({field_access}) >= {n}  # noqa: S101");
991                }
992            }
993        }
994        "max_length" => {
995            if let Some(val) = &assertion.value {
996                if let Some(n) = val.as_u64() {
997                    let _ = writeln!(out, "    assert len({field_access}) <= {n}  # noqa: S101");
998                }
999            }
1000        }
1001        "count_min" => {
1002            if let Some(val) = &assertion.value {
1003                if let Some(n) = val.as_u64() {
1004                    let _ = writeln!(out, "    assert len({field_access}) >= {n}  # noqa: S101");
1005                }
1006            }
1007        }
1008        "count_equals" => {
1009            if let Some(val) = &assertion.value {
1010                if let Some(n) = val.as_u64() {
1011                    let _ = writeln!(out, "    assert len({field_access}) == {n}  # noqa: S101");
1012                }
1013            }
1014        }
1015        "is_true" => {
1016            let _ = writeln!(out, "    assert {field_access} is True  # noqa: S101");
1017        }
1018        other => {
1019            let _ = writeln!(out, "    # TODO: unsupported assertion type: {other}");
1020        }
1021    }
1022}
1023
1024fn value_to_python_string(value: &serde_json::Value) -> String {
1025    match value {
1026        serde_json::Value::String(s) => python_string_literal(s),
1027        serde_json::Value::Bool(true) => "True".to_string(),
1028        serde_json::Value::Bool(false) => "False".to_string(),
1029        serde_json::Value::Number(n) => n.to_string(),
1030        serde_json::Value::Null => "None".to_string(),
1031        other => python_string_literal(&other.to_string()),
1032    }
1033}
1034
1035/// Produce a quoted Python string literal, choosing single or double quotes
1036/// to avoid unnecessary escaping (ruff Q003).
1037fn python_string_literal(s: &str) -> String {
1038    if s.contains('"') && !s.contains('\'') {
1039        // Use single quotes to avoid escaping double quotes.
1040        let escaped = s
1041            .replace('\\', "\\\\")
1042            .replace('\'', "\\'")
1043            .replace('\n', "\\n")
1044            .replace('\r', "\\r")
1045            .replace('\t', "\\t");
1046        format!("'{escaped}'")
1047    } else {
1048        format!("\"{}\"", escape_python(s))
1049    }
1050}
1051
1052/// Emit a Python visitor method for a callback action.
1053fn emit_python_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1054    let params = match method_name {
1055        "visit_link" => "self, ctx, href, text, title",
1056        "visit_image" => "self, ctx, src, alt, title",
1057        "visit_heading" => "self, ctx, level, text, id",
1058        "visit_code_block" => "self, ctx, lang, code",
1059        "visit_code_inline"
1060        | "visit_strong"
1061        | "visit_emphasis"
1062        | "visit_strikethrough"
1063        | "visit_underline"
1064        | "visit_subscript"
1065        | "visit_superscript"
1066        | "visit_mark"
1067        | "visit_button"
1068        | "visit_summary"
1069        | "visit_figcaption"
1070        | "visit_definition_term"
1071        | "visit_definition_description" => "self, ctx, text",
1072        "visit_text" => "self, ctx, text",
1073        "visit_list_item" => "self, ctx, ordered, marker, text",
1074        "visit_blockquote" => "self, ctx, content, depth",
1075        "visit_table_row" => "self, ctx, cells, is_header",
1076        "visit_custom_element" => "self, ctx, tag_name, html",
1077        "visit_form" => "self, ctx, action_url, method",
1078        "visit_input" => "self, ctx, input_type, name, value",
1079        "visit_audio" | "visit_video" | "visit_iframe" => "self, ctx, src",
1080        "visit_details" => "self, ctx, is_open",
1081        _ => "self, ctx, *args",
1082    };
1083
1084    let _ = writeln!(
1085        out,
1086        "        def {method_name}({params}):  # noqa: A002, ANN001, ANN202, ARG002"
1087    );
1088    match action {
1089        CallbackAction::Skip => {
1090            let _ = writeln!(out, "            return \"skip\"");
1091        }
1092        CallbackAction::Continue => {
1093            let _ = writeln!(out, "            return \"continue\"");
1094        }
1095        CallbackAction::PreserveHtml => {
1096            let _ = writeln!(out, "            return \"preserve_html\"");
1097        }
1098        CallbackAction::Custom { output } => {
1099            let escaped = escape_python(output);
1100            let _ = writeln!(out, "            return {{\"custom\": \"{escaped}\"}}");
1101        }
1102        CallbackAction::CustomTemplate { template } => {
1103            // Use single-quoted f-string so that double quotes inside the template
1104            // (e.g. `QUOTE: "{text}"`) are not misinterpreted as string delimiters.
1105            // Escape any single quotes in the template to avoid breaking the literal.
1106            let escaped_template = template.replace('\'', "\\'");
1107            let _ = writeln!(out, "            return {{\"custom\": f'{escaped_template}'}}");
1108        }
1109    }
1110}