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