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