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::{ToPascalCase, 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        // tests/__init__.py
39        files.push(GeneratedFile {
40            path: output_base.join("tests").join("__init__.py"),
41            content: String::new(),
42            generated_header: false,
43        });
44
45        // Per-category test files.
46        for group in groups {
47            let fixtures: Vec<&Fixture> = group.fixtures.iter().filter(|f| !is_skipped(f, "python")).collect();
48
49            if fixtures.is_empty() {
50                continue;
51            }
52
53            let filename = format!("test_{}.py", sanitize_filename(&group.category));
54            let content = render_test_file(&group.category, &fixtures, e2e_config);
55
56            files.push(GeneratedFile {
57                path: output_base.join("tests").join(filename),
58                content,
59                generated_header: true,
60            });
61        }
62
63        Ok(files)
64    }
65
66    fn language_name(&self) -> &'static str {
67        "python"
68    }
69}
70
71// ---------------------------------------------------------------------------
72// Config resolution helpers
73// ---------------------------------------------------------------------------
74
75fn resolve_function_name(e2e_config: &E2eConfig) -> String {
76    e2e_config
77        .call
78        .overrides
79        .get("python")
80        .and_then(|o| o.function.clone())
81        .unwrap_or_else(|| e2e_config.call.function.clone())
82}
83
84fn resolve_module(e2e_config: &E2eConfig) -> String {
85    e2e_config
86        .call
87        .overrides
88        .get("python")
89        .and_then(|o| o.module.clone())
90        .unwrap_or_else(|| e2e_config.call.module.replace('-', "_"))
91}
92
93fn resolve_options_type(e2e_config: &E2eConfig) -> Option<String> {
94    e2e_config
95        .call
96        .overrides
97        .get("python")
98        .and_then(|o| o.options_type.clone())
99}
100
101/// Resolve how json_object args are passed: "kwargs" (default), "dict", or "json".
102fn resolve_options_via(e2e_config: &E2eConfig) -> &str {
103    e2e_config
104        .call
105        .overrides
106        .get("python")
107        .and_then(|o| o.options_via.as_deref())
108        .unwrap_or("kwargs")
109}
110
111/// Resolve enum field mappings from the Python override config.
112fn resolve_enum_fields(e2e_config: &E2eConfig) -> &HashMap<String, String> {
113    static EMPTY: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
114    e2e_config
115        .call
116        .overrides
117        .get("python")
118        .map(|o| &o.enum_fields)
119        .unwrap_or(&EMPTY)
120}
121
122fn is_skipped(fixture: &Fixture, language: &str) -> bool {
123    fixture.skip.as_ref().is_some_and(|s| s.should_skip(language))
124}
125
126// ---------------------------------------------------------------------------
127// Rendering
128// ---------------------------------------------------------------------------
129
130fn render_conftest(e2e_config: &E2eConfig) -> String {
131    let module = resolve_module(e2e_config);
132    format!(
133        r#""""Pytest configuration for e2e tests."""
134# Ensure the package is importable.
135# The {module} package is expected to be installed in the current environment.
136"#
137    )
138}
139
140fn render_test_file(category: &str, fixtures: &[&Fixture], e2e_config: &E2eConfig) -> String {
141    let mut out = String::new();
142    let _ = writeln!(out, "\"\"\"E2e tests for category: {category}.");
143    let _ = writeln!(out, "\"\"\"");
144    let _ = writeln!(out, "# ruff: noqa: S101");
145
146    let module = resolve_module(e2e_config);
147    let function_name = resolve_function_name(e2e_config);
148    let options_type = resolve_options_type(e2e_config);
149    let options_via = resolve_options_via(e2e_config);
150    let enum_fields = resolve_enum_fields(e2e_config);
151    let field_resolver = FieldResolver::new(&e2e_config.fields, &e2e_config.fields_optional);
152
153    let has_error_test = fixtures
154        .iter()
155        .any(|f| f.assertions.iter().any(|a| a.assertion_type == "error"));
156
157    if has_error_test {
158        let _ = writeln!(out, "import pytest");
159    }
160
161    // "json" mode needs `import json`.
162    let needs_json_import = options_via == "json"
163        && fixtures.iter().any(|f| {
164            e2e_config
165                .call
166                .args
167                .iter()
168                .any(|arg| arg.arg_type == "json_object" && !resolve_field(&f.input, &arg.field).is_null())
169        });
170
171    if needs_json_import {
172        let _ = writeln!(out, "import json");
173    }
174
175    // Only import options_type when using "kwargs" mode.
176    let needs_options_type = options_via == "kwargs"
177        && options_type.is_some()
178        && fixtures.iter().any(|f| {
179            e2e_config
180                .call
181                .args
182                .iter()
183                .any(|arg| arg.arg_type == "json_object" && !resolve_field(&f.input, &arg.field).is_null())
184        });
185
186    // Collect enum types actually used across all fixtures in this file.
187    let mut used_enum_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
188    if needs_options_type && !enum_fields.is_empty() {
189        for fixture in fixtures.iter() {
190            for arg in &e2e_config.call.args {
191                if arg.arg_type == "json_object" {
192                    let value = resolve_field(&fixture.input, &arg.field);
193                    if let Some(obj) = value.as_object() {
194                        for key in obj.keys() {
195                            if let Some(enum_type) = enum_fields.get(key) {
196                                used_enum_types.insert(enum_type.clone());
197                            }
198                        }
199                    }
200                }
201            }
202        }
203    }
204
205    if let (true, Some(opts_type)) = (needs_options_type, &options_type) {
206        let _ = writeln!(out, "from {module} import {function_name}, {opts_type}");
207        // Import enum types from enum_module (if specified) or main module.
208        if !used_enum_types.is_empty() {
209            let enum_mod = e2e_config
210                .call
211                .overrides
212                .get("python")
213                .and_then(|o| o.enum_module.as_deref())
214                .unwrap_or(&module);
215            let enum_names: Vec<&String> = used_enum_types.iter().collect();
216            let _ = writeln!(
217                out,
218                "from {enum_mod} import {}",
219                enum_names.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
220            );
221        }
222    } else {
223        let _ = writeln!(out, "from {module} import {function_name}");
224    }
225    let _ = writeln!(out);
226
227    for fixture in fixtures {
228        render_test_function(
229            &mut out,
230            fixture,
231            e2e_config,
232            options_type.as_deref(),
233            options_via,
234            enum_fields,
235            &field_resolver,
236        );
237        let _ = writeln!(out);
238    }
239
240    out
241}
242
243fn render_test_function(
244    out: &mut String,
245    fixture: &Fixture,
246    e2e_config: &E2eConfig,
247    options_type: Option<&str>,
248    options_via: &str,
249    enum_fields: &HashMap<String, String>,
250    field_resolver: &FieldResolver,
251) {
252    let fn_name = sanitize_ident(&fixture.id);
253    let description = &fixture.description;
254    let function_name = resolve_function_name(e2e_config);
255    let result_var = &e2e_config.call.result_var;
256
257    let desc_with_period = if description.ends_with('.') {
258        description.to_string()
259    } else {
260        format!("{description}.")
261    };
262
263    let _ = writeln!(out, "def test_{fn_name}() -> None:");
264    let _ = writeln!(out, "    \"\"\"{desc_with_period}\"\"\"");
265
266    // Check if any assertion is an error assertion.
267    let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
268
269    // Build argument expressions from config.
270    let mut arg_bindings = Vec::new();
271    let mut kwarg_exprs = Vec::new();
272    for arg in &e2e_config.call.args {
273        let value = resolve_field(&fixture.input, &arg.field);
274        let var_name = &arg.name;
275
276        if value.is_null() && arg.optional {
277            continue;
278        }
279
280        // For json_object args, use the configured options_via strategy.
281        if arg.arg_type == "json_object" && !value.is_null() {
282            match options_via {
283                "dict" => {
284                    // Pass as a plain Python dict literal.
285                    let literal = json_to_python_literal(value);
286                    arg_bindings.push(format!("    {var_name} = {literal}"));
287                    kwarg_exprs.push(format!("{var_name}={var_name}"));
288                    continue;
289                }
290                "json" => {
291                    // Pass via json.loads() with the raw JSON string.
292                    let json_str = serde_json::to_string(value).unwrap_or_default();
293                    let escaped = escape_python(&json_str);
294                    arg_bindings.push(format!("    {var_name} = json.loads(\"{escaped}\")"));
295                    kwarg_exprs.push(format!("{var_name}={var_name}"));
296                    continue;
297                }
298                _ => {
299                    // "kwargs" (default): construct OptionsType(key=val, ...).
300                    if let (Some(opts_type), Some(obj)) = (options_type, value.as_object()) {
301                        let kwargs: Vec<String> = obj
302                            .iter()
303                            .map(|(k, v)| {
304                                let snake_key = k.to_snake_case();
305                                let py_val = if let Some(enum_type) = enum_fields.get(k) {
306                                    // Map string value to enum constant.
307                                    if let Some(s) = v.as_str() {
308                                        let pascal_val = s.to_pascal_case();
309                                        format!("{enum_type}.{pascal_val}")
310                                    } else {
311                                        json_to_python_literal(v)
312                                    }
313                                } else {
314                                    json_to_python_literal(v)
315                                };
316                                format!("{snake_key}={py_val}")
317                            })
318                            .collect();
319                        let constructor = format!("{opts_type}({})", kwargs.join(", "));
320                        arg_bindings.push(format!("    {var_name} = {constructor}"));
321                        kwarg_exprs.push(format!("{var_name}={var_name}"));
322                        continue;
323                    }
324                }
325            }
326        }
327
328        let literal = json_to_python_literal(value);
329        arg_bindings.push(format!("    {var_name} = {literal}"));
330        kwarg_exprs.push(format!("{var_name}={var_name}"));
331    }
332
333    for binding in &arg_bindings {
334        let _ = writeln!(out, "{binding}");
335    }
336
337    let call_args = kwarg_exprs.join(", ");
338    let call_expr = format!("{function_name}({call_args})");
339
340    if has_error_assertion {
341        // Find error assertion for optional message check.
342        let error_assertion = fixture.assertions.iter().find(|a| a.assertion_type == "error");
343        let has_message = error_assertion
344            .and_then(|a| a.value.as_ref())
345            .and_then(|v| v.as_str())
346            .is_some();
347
348        if has_message {
349            let _ = writeln!(out, "    with pytest.raises(Exception) as exc_info:");
350            let _ = writeln!(out, "        {call_expr}");
351            if let Some(msg) = error_assertion.and_then(|a| a.value.as_ref()).and_then(|v| v.as_str()) {
352                let escaped = escape_python(msg);
353                let _ = writeln!(out, "    assert \"{escaped}\" in str(exc_info.value)");
354            }
355        } else {
356            let _ = writeln!(out, "    with pytest.raises(Exception):");
357            let _ = writeln!(out, "        {call_expr}");
358        }
359
360        // Render any non-error assertions (unlikely but handle gracefully).
361        for assertion in &fixture.assertions {
362            if assertion.assertion_type != "error" {
363                render_assertion(out, assertion, result_var, field_resolver);
364            }
365        }
366        return;
367    }
368
369    // Non-error path.
370    let _ = writeln!(out, "    {result_var} = {call_expr}");
371
372    for assertion in &fixture.assertions {
373        if assertion.assertion_type == "not_error" {
374            // The call already raises on error in Python.
375            continue;
376        }
377        render_assertion(out, assertion, result_var, field_resolver);
378    }
379}
380
381// ---------------------------------------------------------------------------
382// Argument rendering
383// ---------------------------------------------------------------------------
384
385fn resolve_field<'a>(input: &'a serde_json::Value, field_path: &str) -> &'a serde_json::Value {
386    let mut current = input;
387    for part in field_path.split('.') {
388        current = current.get(part).unwrap_or(&serde_json::Value::Null);
389    }
390    current
391}
392
393fn json_to_python_literal(value: &serde_json::Value) -> String {
394    match value {
395        serde_json::Value::Null => "None".to_string(),
396        serde_json::Value::Bool(true) => "True".to_string(),
397        serde_json::Value::Bool(false) => "False".to_string(),
398        serde_json::Value::Number(n) => n.to_string(),
399        serde_json::Value::String(s) => format!("\"{}\"", escape_python(s)),
400        serde_json::Value::Array(arr) => {
401            let items: Vec<String> = arr.iter().map(json_to_python_literal).collect();
402            format!("[{}]", items.join(", "))
403        }
404        serde_json::Value::Object(map) => {
405            let items: Vec<String> = map
406                .iter()
407                .map(|(k, v)| format!("\"{}\": {}", escape_python(k), json_to_python_literal(v)))
408                .collect();
409            format!("{{{}}}", items.join(", "))
410        }
411    }
412}
413
414// ---------------------------------------------------------------------------
415// Assertion rendering
416// ---------------------------------------------------------------------------
417
418fn render_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
419    let field_access = match &assertion.field {
420        Some(f) if !f.is_empty() => field_resolver.accessor(f, "python", result_var),
421        _ => result_var.to_string(),
422    };
423
424    match assertion.assertion_type.as_str() {
425        "error" | "not_error" => {
426            // Handled at call site.
427        }
428        "equals" => {
429            if let Some(val) = &assertion.value {
430                let expected = value_to_python_string(val);
431                let _ = writeln!(out, "    assert {field_access}.strip() == {expected}");
432            }
433        }
434        "contains" => {
435            if let Some(val) = &assertion.value {
436                let expected = value_to_python_string(val);
437                let _ = writeln!(out, "    assert {expected} in {field_access}");
438            }
439        }
440        "contains_all" => {
441            if let Some(values) = &assertion.values {
442                for val in values {
443                    let expected = value_to_python_string(val);
444                    let _ = writeln!(out, "    assert {expected} in {field_access}");
445                }
446            }
447        }
448        "not_contains" => {
449            if let Some(val) = &assertion.value {
450                let expected = value_to_python_string(val);
451                let _ = writeln!(out, "    assert {expected} not in {field_access}");
452            }
453        }
454        "not_empty" => {
455            let _ = writeln!(out, "    assert {field_access}");
456        }
457        "is_empty" => {
458            let _ = writeln!(out, "    assert not {field_access}");
459        }
460        "starts_with" => {
461            if let Some(val) = &assertion.value {
462                let expected = value_to_python_string(val);
463                let _ = writeln!(out, "    assert {field_access}.startswith({expected})");
464            }
465        }
466        "ends_with" => {
467            if let Some(val) = &assertion.value {
468                let expected = value_to_python_string(val);
469                let _ = writeln!(out, "    assert {field_access}.endswith({expected})");
470            }
471        }
472        "min_length" => {
473            if let Some(val) = &assertion.value {
474                if let Some(n) = val.as_u64() {
475                    let _ = writeln!(out, "    assert len({field_access}) >= {n}");
476                }
477            }
478        }
479        "max_length" => {
480            if let Some(val) = &assertion.value {
481                if let Some(n) = val.as_u64() {
482                    let _ = writeln!(out, "    assert len({field_access}) <= {n}");
483                }
484            }
485        }
486        other => {
487            let _ = writeln!(out, "    # TODO: unsupported assertion type: {other}");
488        }
489    }
490}
491
492fn value_to_python_string(value: &serde_json::Value) -> String {
493    match value {
494        serde_json::Value::String(s) => format!("\"{}\"", escape_python(s)),
495        serde_json::Value::Bool(true) => "True".to_string(),
496        serde_json::Value::Bool(false) => "False".to_string(),
497        serde_json::Value::Number(n) => n.to_string(),
498        serde_json::Value::Null => "None".to_string(),
499        other => format!("\"{}\"", escape_python(&other.to_string())),
500    }
501}