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::{ToShoutySnakeCase, 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.effective_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("__init__.py"),
41 content: String::new(),
42 generated_header: false,
43 });
44
45 files.push(GeneratedFile {
47 path: output_base.join("tests").join("__init__.py"),
48 content: String::new(),
49 generated_header: false,
50 });
51
52 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 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
98fn 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
149fn 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
179fn 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
189fn 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
200fn 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
212fn 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
229fn 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 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 let needs_os_import = e2e_config.call.args.iter().any(|arg| arg.arg_type == "mock_url");
283
284 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 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 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 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 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 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 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 if !stdlib_imports.is_empty() {
417 for imp in &stdlib_imports {
418 let _ = writeln!(out, "{imp}");
419 }
420 let _ = writeln!(out);
421 }
422 for imp in &thirdparty_bare {
424 let _ = writeln!(out, "{imp}");
425 }
426 for imp in &thirdparty_from {
427 let _ = writeln!(out, "{imp}");
428 }
429 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 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 let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
495
496 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 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 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 if let Some(nested_obj) = v.as_object() {
522 if nested_obj.is_empty() {
523 format!("{type_name}()")
525 } else if handle_dict_types.contains(k) {
526 json_to_python_literal(v)
531 } else {
532 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 json_to_python_literal(v)
545 }
546 } else if k == "request_timeout" {
547 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 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 if arg.arg_type == "json_object" && !value.is_null() {
592 match options_via {
593 "dict" => {
594 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 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 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 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 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 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 return;
688 }
689
690 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 continue;
712 }
713 render_assertion(out, assertion, result_var, field_resolver, fields_enum);
714 }
715}
716
717fn 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
750fn 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 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 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 field_resolver.accessor(f, "python", result_var).contains("[0]")
797 });
798
799 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 }
813 "equals" => {
814 if let Some(val) = &assertion.value {
815 let expected = value_to_python_string(val);
816 let op = if val.is_boolean() || val.is_null() { "is" } else { "==" };
818 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 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 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 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 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
986fn python_string_literal(s: &str) -> String {
989 if s.contains('"') && !s.contains('\'') {
990 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}