1use 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
18pub 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 files.push(GeneratedFile {
33 path: output_base.join("conftest.py"),
34 content: render_conftest(e2e_config),
35 generated_header: true,
36 });
37
38 files.push(GeneratedFile {
40 path: output_base.join("tests").join("__init__.py"),
41 content: String::new(),
42 generated_header: false,
43 });
44
45 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
71fn 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
101fn 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
111fn 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
126fn 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 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 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 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 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 let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
268
269 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 if arg.arg_type == "json_object" && !value.is_null() {
282 match options_via {
283 "dict" => {
284 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 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 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 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 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 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 let _ = writeln!(out, " {result_var} = {call_expr}");
371
372 for assertion in &fixture.assertions {
373 if assertion.assertion_type == "not_error" {
374 continue;
376 }
377 render_assertion(out, assertion, result_var, field_resolver);
378 }
379}
380
381fn 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
414fn 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 }
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}