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.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 pkg_name = e2e_config
54 .packages
55 .get("python")
56 .and_then(|p| p.name.as_deref())
57 .unwrap_or("kreuzcrawl");
58 let pkg_path = e2e_config
59 .packages
60 .get("python")
61 .and_then(|p| p.path.as_deref())
62 .unwrap_or("../../packages/python");
63 files.push(GeneratedFile {
64 path: output_base.join("pyproject.toml"),
65 content: render_pyproject(pkg_name, pkg_path),
66 generated_header: true,
67 });
68
69 for group in groups {
71 let fixtures: Vec<&Fixture> = group.fixtures.iter().collect();
72
73 if fixtures.is_empty() {
74 continue;
75 }
76
77 let filename = format!("test_{}.py", sanitize_filename(&group.category));
78 let content = render_test_file(&group.category, &fixtures, e2e_config);
79
80 files.push(GeneratedFile {
81 path: output_base.join("tests").join(filename),
82 content,
83 generated_header: true,
84 });
85 }
86
87 Ok(files)
88 }
89
90 fn language_name(&self) -> &'static str {
91 "python"
92 }
93}
94
95fn render_pyproject(pkg_name: &str, pkg_path: &str) -> String {
100 format!(
101 r#"[build-system]
102build-backend = "setuptools.build_meta"
103requires = ["setuptools>=68", "wheel"]
104
105[project]
106name = "{pkg_name}-e2e-tests"
107version = "0.0.0"
108description = "End-to-end tests"
109requires-python = ">=3.10"
110dependencies = ["{pkg_name}", "pytest>=7.4", "pytest-asyncio>=0.23", "pytest-timeout>=2.1"]
111
112[tool.uv.sources]
113{pkg_name} = {{ path = "{pkg_path}", editable = true }}
114
115[tool.pytest.ini_options]
116asyncio_mode = "auto"
117testpaths = ["tests"]
118python_files = "test_*.py"
119python_functions = "test_*"
120addopts = "-v --strict-markers --tb=short"
121timeout = 300
122"#
123 )
124}
125
126fn resolve_function_name(e2e_config: &E2eConfig) -> String {
131 e2e_config
132 .call
133 .overrides
134 .get("python")
135 .and_then(|o| o.function.clone())
136 .unwrap_or_else(|| e2e_config.call.function.clone())
137}
138
139fn resolve_module(e2e_config: &E2eConfig) -> String {
140 e2e_config
141 .call
142 .overrides
143 .get("python")
144 .and_then(|o| o.module.clone())
145 .unwrap_or_else(|| e2e_config.call.module.replace('-', "_"))
146}
147
148fn resolve_options_type(e2e_config: &E2eConfig) -> Option<String> {
149 e2e_config
150 .call
151 .overrides
152 .get("python")
153 .and_then(|o| o.options_type.clone())
154}
155
156fn resolve_options_via(e2e_config: &E2eConfig) -> &str {
158 e2e_config
159 .call
160 .overrides
161 .get("python")
162 .and_then(|o| o.options_via.as_deref())
163 .unwrap_or("kwargs")
164}
165
166fn resolve_enum_fields(e2e_config: &E2eConfig) -> &HashMap<String, String> {
168 static EMPTY: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
169 e2e_config
170 .call
171 .overrides
172 .get("python")
173 .map(|o| &o.enum_fields)
174 .unwrap_or(&EMPTY)
175}
176
177fn resolve_handle_nested_types(e2e_config: &E2eConfig) -> &HashMap<String, String> {
180 static EMPTY: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
181 e2e_config
182 .call
183 .overrides
184 .get("python")
185 .map(|o| &o.handle_nested_types)
186 .unwrap_or(&EMPTY)
187}
188
189fn resolve_handle_dict_types(e2e_config: &E2eConfig) -> &std::collections::HashSet<String> {
192 static EMPTY: std::sync::LazyLock<std::collections::HashSet<String>> =
193 std::sync::LazyLock::new(std::collections::HashSet::new);
194 e2e_config
195 .call
196 .overrides
197 .get("python")
198 .map(|o| &o.handle_dict_types)
199 .unwrap_or(&EMPTY)
200}
201
202fn is_skipped(fixture: &Fixture, language: &str) -> bool {
203 fixture.skip.as_ref().is_some_and(|s| s.should_skip(language))
204}
205
206fn render_conftest(e2e_config: &E2eConfig) -> String {
211 let module = resolve_module(e2e_config);
212 format!(
213 r#"# This file is auto-generated by alef. DO NOT EDIT.
214"""Pytest configuration for e2e tests."""
215# Ensure the package is importable.
216# The {module} package is expected to be installed in the current environment.
217"#
218 )
219}
220
221fn render_test_file(category: &str, fixtures: &[&Fixture], e2e_config: &E2eConfig) -> String {
222 let mut out = String::new();
223 let _ = writeln!(out, "# This file is auto-generated by alef. DO NOT EDIT.");
224 let _ = writeln!(out, "\"\"\"E2e tests for category: {category}.\"\"\"");
225
226 let module = resolve_module(e2e_config);
227 let function_name = resolve_function_name(e2e_config);
228 let options_type = resolve_options_type(e2e_config);
229 let options_via = resolve_options_via(e2e_config);
230 let enum_fields = resolve_enum_fields(e2e_config);
231 let handle_nested_types = resolve_handle_nested_types(e2e_config);
232 let handle_dict_types = resolve_handle_dict_types(e2e_config);
233 let field_resolver = FieldResolver::new(
234 &e2e_config.fields,
235 &e2e_config.fields_optional,
236 &e2e_config.result_fields,
237 &e2e_config.fields_array,
238 );
239
240 let has_error_test = fixtures
241 .iter()
242 .any(|f| f.assertions.iter().any(|a| a.assertion_type == "error"));
243 let has_skipped = fixtures.iter().any(|f| is_skipped(f, "python"));
244
245 let is_async = e2e_config.call.r#async;
246 let needs_pytest = has_error_test || has_skipped || is_async;
247
248 let needs_json_import = options_via == "json"
250 && fixtures.iter().any(|f| {
251 e2e_config
252 .call
253 .args
254 .iter()
255 .any(|arg| arg.arg_type == "json_object" && !resolve_field(&f.input, &arg.field).is_null())
256 });
257
258 let needs_os_import = e2e_config.call.args.iter().any(|arg| arg.arg_type == "mock_url");
260
261 let needs_options_type = options_via == "kwargs"
263 && options_type.is_some()
264 && fixtures.iter().any(|f| {
265 e2e_config
266 .call
267 .args
268 .iter()
269 .any(|arg| arg.arg_type == "json_object" && !resolve_field(&f.input, &arg.field).is_null())
270 });
271
272 let mut used_enum_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
274 if needs_options_type && !enum_fields.is_empty() {
275 for fixture in fixtures.iter() {
276 for arg in &e2e_config.call.args {
277 if arg.arg_type == "json_object" {
278 let value = resolve_field(&fixture.input, &arg.field);
279 if let Some(obj) = value.as_object() {
280 for key in obj.keys() {
281 if let Some(enum_type) = enum_fields.get(key) {
282 used_enum_types.insert(enum_type.clone());
283 }
284 }
285 }
286 }
287 }
288 }
289 }
290
291 let mut stdlib_imports: Vec<String> = Vec::new();
295 let mut thirdparty_bare: Vec<String> = Vec::new();
296 let mut thirdparty_from: Vec<String> = Vec::new();
297
298 if needs_json_import {
299 stdlib_imports.push("import json".to_string());
300 }
301
302 if needs_os_import {
303 stdlib_imports.push("import os".to_string());
304 }
305
306 if needs_pytest {
307 thirdparty_bare.push("import pytest".to_string());
308 }
309
310 let handle_constructors: Vec<String> = e2e_config
312 .call
313 .args
314 .iter()
315 .filter(|arg| arg.arg_type == "handle")
316 .map(|arg| format!("create_{}", arg.name.to_snake_case()))
317 .collect();
318
319 let mut import_names: Vec<String> = vec![function_name.clone()];
320 for ctor in &handle_constructors {
321 if !import_names.contains(ctor) {
322 import_names.push(ctor.clone());
323 }
324 }
325
326 let needs_config_import = e2e_config.call.args.iter().any(|arg| {
328 arg.arg_type == "handle"
329 && fixtures.iter().any(|f| {
330 let val = resolve_field(&f.input, &arg.field);
331 !val.is_null() && val.as_object().is_some_and(|o| !o.is_empty())
332 })
333 });
334 if needs_config_import {
335 let config_class = options_type.as_deref().unwrap_or("CrawlConfig");
336 if !import_names.contains(&config_class.to_string()) {
337 import_names.push(config_class.to_string());
338 }
339 }
340
341 if !handle_nested_types.is_empty() {
343 let mut used_nested_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
344 for fixture in fixtures.iter() {
345 for arg in &e2e_config.call.args {
346 if arg.arg_type == "handle" {
347 let config_value = resolve_field(&fixture.input, &arg.field);
348 if let Some(obj) = config_value.as_object() {
349 for key in obj.keys() {
350 if let Some(type_name) = handle_nested_types.get(key) {
351 if obj[key].is_object() && !obj[key].as_object().unwrap().is_empty() {
352 used_nested_types.insert(type_name.clone());
353 }
354 }
355 }
356 }
357 }
358 }
359 }
360 for type_name in used_nested_types {
361 if !import_names.contains(&type_name) {
362 import_names.push(type_name);
363 }
364 }
365 }
366
367 if let (true, Some(opts_type)) = (needs_options_type, &options_type) {
368 import_names.push(opts_type.clone());
369 thirdparty_from.push(format!("from {module} import {}", import_names.join(", ")));
370 if !used_enum_types.is_empty() {
372 let enum_mod = e2e_config
373 .call
374 .overrides
375 .get("python")
376 .and_then(|o| o.enum_module.as_deref())
377 .unwrap_or(&module);
378 let enum_names: Vec<&String> = used_enum_types.iter().collect();
379 thirdparty_from.push(format!(
380 "from {enum_mod} import {}",
381 enum_names.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
382 ));
383 }
384 } else {
385 thirdparty_from.push(format!("from {module} import {}", import_names.join(", ")));
386 }
387
388 stdlib_imports.sort();
389 thirdparty_bare.sort();
390 thirdparty_from.sort();
391
392 if !stdlib_imports.is_empty() {
394 for imp in &stdlib_imports {
395 let _ = writeln!(out, "{imp}");
396 }
397 let _ = writeln!(out);
398 }
399 for imp in &thirdparty_bare {
401 let _ = writeln!(out, "{imp}");
402 }
403 for imp in &thirdparty_from {
404 let _ = writeln!(out, "{imp}");
405 }
406 let _ = writeln!(out);
408 let _ = writeln!(out);
409
410 for fixture in fixtures {
411 render_test_function(
412 &mut out,
413 fixture,
414 e2e_config,
415 options_type.as_deref(),
416 options_via,
417 enum_fields,
418 handle_nested_types,
419 handle_dict_types,
420 &field_resolver,
421 );
422 let _ = writeln!(out);
423 }
424
425 out
426}
427
428#[allow(clippy::too_many_arguments)]
429fn render_test_function(
430 out: &mut String,
431 fixture: &Fixture,
432 e2e_config: &E2eConfig,
433 options_type: Option<&str>,
434 options_via: &str,
435 enum_fields: &HashMap<String, String>,
436 handle_nested_types: &HashMap<String, String>,
437 handle_dict_types: &std::collections::HashSet<String>,
438 field_resolver: &FieldResolver,
439) {
440 let fn_name = sanitize_ident(&fixture.id);
441 let description = &fixture.description;
442 let function_name = resolve_function_name(e2e_config);
443 let result_var = &e2e_config.call.result_var;
444
445 let desc_with_period = if description.ends_with('.') {
446 description.to_string()
447 } else {
448 format!("{description}.")
449 };
450
451 if is_skipped(fixture, "python") {
453 let reason = fixture
454 .skip
455 .as_ref()
456 .and_then(|s| s.reason.as_deref())
457 .unwrap_or("skipped for python");
458 let _ = writeln!(out, "@pytest.mark.skip(reason=\"{reason}\")");
459 }
460
461 let is_async = e2e_config.call.r#async;
462 if is_async {
463 let _ = writeln!(out, "@pytest.mark.asyncio");
464 let _ = writeln!(out, "async def test_{fn_name}() -> None:");
465 } else {
466 let _ = writeln!(out, "def test_{fn_name}() -> None:");
467 }
468 let _ = writeln!(out, " \"\"\"{desc_with_period}\"\"\"");
469
470 let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
472
473 let mut arg_bindings = Vec::new();
475 let mut kwarg_exprs = Vec::new();
476 for arg in &e2e_config.call.args {
477 let var_name = &arg.name;
478
479 if arg.arg_type == "handle" {
480 let constructor_name = format!("create_{}", arg.name.to_snake_case());
483 let config_value = resolve_field(&fixture.input, &arg.field);
484 if config_value.is_null()
485 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
486 {
487 arg_bindings.push(format!(" {var_name} = {constructor_name}(None)"));
488 } else if let Some(obj) = config_value.as_object() {
489 let kwargs: Vec<String> = obj
493 .iter()
494 .map(|(k, v)| {
495 let snake_key = k.to_snake_case();
496 let py_val = if let Some(type_name) = handle_nested_types.get(k) {
497 if let Some(nested_obj) = v.as_object() {
499 if nested_obj.is_empty() {
500 format!("{type_name}()")
502 } else if handle_dict_types.contains(k) {
503 json_to_python_literal(v)
508 } else {
509 let nested_kwargs: Vec<String> = nested_obj
511 .iter()
512 .map(|(nk, nv)| {
513 let nested_snake_key = nk.to_snake_case();
514 format!("{nested_snake_key}={}", json_to_python_literal(nv))
515 })
516 .collect();
517 format!("{type_name}({})", nested_kwargs.join(", "))
518 }
519 } else {
520 json_to_python_literal(v)
522 }
523 } else if k == "request_timeout" {
524 if let Some(ms) = v.as_u64() {
530 format!("{}", ms / 1000)
531 } else {
532 json_to_python_literal(v)
533 }
534 } else {
535 json_to_python_literal(v)
536 };
537 format!("{snake_key}={py_val}")
538 })
539 .collect();
540 let config_class = options_type.unwrap_or("CrawlConfig");
542 arg_bindings.push(format!(" {var_name}_config = {config_class}({})", kwargs.join(", ")));
543 arg_bindings.push(format!(" {var_name} = {constructor_name}({var_name}_config)"));
544 } else {
545 let literal = json_to_python_literal(config_value);
546 arg_bindings.push(format!(" {var_name} = {constructor_name}({literal})"));
547 }
548 kwarg_exprs.push(format!("{var_name}={var_name}"));
549 continue;
550 }
551
552 if arg.arg_type == "mock_url" {
553 let fixture_id = &fixture.id;
554 arg_bindings.push(format!(
555 " {var_name} = os.environ['MOCK_SERVER_URL'] + '/fixtures/{fixture_id}'"
556 ));
557 kwarg_exprs.push(format!("{var_name}={var_name}"));
558 continue;
559 }
560
561 let value = resolve_field(&fixture.input, &arg.field);
562
563 if value.is_null() && arg.optional {
564 continue;
565 }
566
567 if arg.arg_type == "json_object" && !value.is_null() {
569 match options_via {
570 "dict" => {
571 let literal = json_to_python_literal(value);
573 arg_bindings.push(format!(" {var_name} = {literal}"));
574 kwarg_exprs.push(format!("{var_name}={var_name}"));
575 continue;
576 }
577 "json" => {
578 let json_str = serde_json::to_string(value).unwrap_or_default();
580 let escaped = escape_python(&json_str);
581 arg_bindings.push(format!(" {var_name} = json.loads(\"{escaped}\")"));
582 kwarg_exprs.push(format!("{var_name}={var_name}"));
583 continue;
584 }
585 _ => {
586 if let (Some(opts_type), Some(obj)) = (options_type, value.as_object()) {
588 let kwargs: Vec<String> = obj
589 .iter()
590 .map(|(k, v)| {
591 let snake_key = k.to_snake_case();
592 let py_val = if let Some(enum_type) = enum_fields.get(k) {
593 if let Some(s) = v.as_str() {
595 let upper_val = s.to_shouty_snake_case();
596 format!("{enum_type}.{upper_val}")
597 } else {
598 json_to_python_literal(v)
599 }
600 } else {
601 json_to_python_literal(v)
602 };
603 format!("{snake_key}={py_val}")
604 })
605 .collect();
606 let constructor = format!("{opts_type}({})", kwargs.join(", "));
607 arg_bindings.push(format!(" {var_name} = {constructor}"));
608 kwarg_exprs.push(format!("{var_name}={var_name}"));
609 continue;
610 }
611 }
612 }
613 }
614
615 if value.is_null() && !arg.optional {
617 let default_val = match arg.arg_type.as_str() {
618 "string" => "\"\"".to_string(),
619 "int" | "integer" => "0".to_string(),
620 "float" | "number" => "0.0".to_string(),
621 "bool" | "boolean" => "False".to_string(),
622 _ => "None".to_string(),
623 };
624 arg_bindings.push(format!(" {var_name} = {default_val}"));
625 kwarg_exprs.push(format!("{var_name}={var_name}"));
626 continue;
627 }
628
629 let literal = json_to_python_literal(value);
630 arg_bindings.push(format!(" {var_name} = {literal}"));
631 kwarg_exprs.push(format!("{var_name}={var_name}"));
632 }
633
634 for binding in &arg_bindings {
635 let _ = writeln!(out, "{binding}");
636 }
637
638 let call_args = kwarg_exprs.join(", ");
639 let await_prefix = if is_async { "await " } else { "" };
640 let call_expr = format!("{await_prefix}{function_name}({call_args})");
641
642 if has_error_assertion {
643 let error_assertion = fixture.assertions.iter().find(|a| a.assertion_type == "error");
645 let has_message = error_assertion
646 .and_then(|a| a.value.as_ref())
647 .and_then(|v| v.as_str())
648 .is_some();
649
650 if has_message {
651 let _ = writeln!(out, " with pytest.raises(Exception) as exc_info:");
652 let _ = writeln!(out, " {call_expr}");
653 if let Some(msg) = error_assertion.and_then(|a| a.value.as_ref()).and_then(|v| v.as_str()) {
654 let escaped = escape_python(msg);
655 let _ = writeln!(out, " assert \"{escaped}\" in str(exc_info.value) # noqa: S101");
656 }
657 } else {
658 let _ = writeln!(out, " with pytest.raises(Exception):");
659 let _ = writeln!(out, " {call_expr}");
660 }
661
662 return;
665 }
666
667 let has_usable_assertion = fixture.assertions.iter().any(|a| {
669 if a.assertion_type == "not_error" || a.assertion_type == "error" {
670 return false;
671 }
672 match &a.field {
673 Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
674 _ => true,
675 }
676 });
677 let py_result_var = if has_usable_assertion {
678 result_var.to_string()
679 } else {
680 "_".to_string()
681 };
682 let _ = writeln!(out, " {py_result_var} = {call_expr}");
683
684 let fields_enum = &e2e_config.fields_enum;
685 for assertion in &fixture.assertions {
686 if assertion.assertion_type == "not_error" {
687 continue;
689 }
690 render_assertion(out, assertion, result_var, field_resolver, fields_enum);
691 }
692}
693
694fn resolve_field<'a>(input: &'a serde_json::Value, field_path: &str) -> &'a serde_json::Value {
699 let mut current = input;
700 for part in field_path.split('.') {
701 current = current.get(part).unwrap_or(&serde_json::Value::Null);
702 }
703 current
704}
705
706fn json_to_python_literal(value: &serde_json::Value) -> String {
707 match value {
708 serde_json::Value::Null => "None".to_string(),
709 serde_json::Value::Bool(true) => "True".to_string(),
710 serde_json::Value::Bool(false) => "False".to_string(),
711 serde_json::Value::Number(n) => n.to_string(),
712 serde_json::Value::String(s) => python_string_literal(s),
713 serde_json::Value::Array(arr) => {
714 let items: Vec<String> = arr.iter().map(json_to_python_literal).collect();
715 format!("[{}]", items.join(", "))
716 }
717 serde_json::Value::Object(map) => {
718 let items: Vec<String> = map
719 .iter()
720 .map(|(k, v)| format!("\"{}\": {}", escape_python(k), json_to_python_literal(v)))
721 .collect();
722 format!("{{{}}}", items.join(", "))
723 }
724 }
725}
726
727fn render_assertion(
732 out: &mut String,
733 assertion: &Assertion,
734 result_var: &str,
735 field_resolver: &FieldResolver,
736 fields_enum: &std::collections::HashSet<String>,
737) {
738 if let Some(f) = &assertion.field {
740 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
741 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
742 return;
743 }
744 }
745
746 let field_access = match &assertion.field {
747 Some(f) if !f.is_empty() => field_resolver.accessor(f, "python", result_var),
748 _ => result_var.to_string(),
749 };
750
751 let field_is_enum = assertion.field.as_deref().is_some_and(|f| {
762 if fields_enum.contains(f) {
763 return true;
764 }
765 let resolved = field_resolver.resolve(f);
766 if fields_enum.contains(resolved) {
767 return true;
768 }
769 field_resolver.accessor(f, "python", result_var).contains("[0]")
774 });
775
776 let field_is_optional = match &assertion.field {
779 Some(f) if !f.is_empty() => {
780 let resolved = field_resolver.resolve(f);
781 field_resolver.is_optional(resolved)
782 }
783 _ => false,
784 };
785
786 match assertion.assertion_type.as_str() {
787 "error" | "not_error" => {
788 }
790 "equals" => {
791 if let Some(val) = &assertion.value {
792 let expected = value_to_python_string(val);
793 let op = if val.is_boolean() || val.is_null() { "is" } else { "==" };
795 if val.is_string() {
798 let _ = writeln!(out, " assert {field_access}.strip() {op} {expected} # noqa: S101");
799 } else {
800 let _ = writeln!(out, " assert {field_access} {op} {expected} # noqa: S101");
801 }
802 }
803 }
804 "contains" => {
805 if let Some(val) = &assertion.value {
806 let expected = value_to_python_string(val);
807 let cmp_expr = if field_is_enum && val.is_string() {
809 format!("str({field_access}).lower()")
810 } else {
811 field_access.clone()
812 };
813 if field_is_optional {
814 let _ = writeln!(
815 out,
816 " assert {field_access} is not None and {expected} in {cmp_expr} # noqa: S101"
817 );
818 } else {
819 let _ = writeln!(out, " assert {expected} in {cmp_expr} # noqa: S101");
820 }
821 }
822 }
823 "contains_all" => {
824 if let Some(values) = &assertion.values {
825 for val in values {
826 let expected = value_to_python_string(val);
827 let cmp_expr = if field_is_enum && val.is_string() {
829 format!("str({field_access}).lower()")
830 } else {
831 field_access.clone()
832 };
833 if field_is_optional {
834 let _ = writeln!(
835 out,
836 " assert {field_access} is not None and {expected} in {cmp_expr} # noqa: S101"
837 );
838 } else {
839 let _ = writeln!(out, " assert {expected} in {cmp_expr} # noqa: S101");
840 }
841 }
842 }
843 }
844 "not_contains" => {
845 if let Some(val) = &assertion.value {
846 let expected = value_to_python_string(val);
847 let cmp_expr = if field_is_enum && val.is_string() {
849 format!("str({field_access}).lower()")
850 } else {
851 field_access.clone()
852 };
853 if field_is_optional {
854 let _ = writeln!(
855 out,
856 " assert {field_access} is None or {expected} not in {cmp_expr} # noqa: S101"
857 );
858 } else {
859 let _ = writeln!(out, " assert {expected} not in {cmp_expr} # noqa: S101");
860 }
861 }
862 }
863 "not_empty" => {
864 let _ = writeln!(out, " assert {field_access} # noqa: S101");
865 }
866 "is_empty" => {
867 let _ = writeln!(out, " assert not {field_access} # noqa: S101");
868 }
869 "contains_any" => {
870 if let Some(values) = &assertion.values {
871 let items: Vec<String> = values.iter().map(value_to_python_string).collect();
872 let list_str = items.join(", ");
873 let cmp_expr = if field_is_enum {
875 format!("str({field_access}).lower()")
876 } else {
877 field_access.clone()
878 };
879 if field_is_optional {
880 let _ = writeln!(
881 out,
882 " assert {field_access} is not None and any(v in {cmp_expr} for v in [{list_str}]) # noqa: S101"
883 );
884 } else {
885 let _ = writeln!(
886 out,
887 " assert any(v in {cmp_expr} for v in [{list_str}]) # noqa: S101"
888 );
889 }
890 }
891 }
892 "greater_than" => {
893 if let Some(val) = &assertion.value {
894 let expected = value_to_python_string(val);
895 let _ = writeln!(out, " assert {field_access} > {expected} # noqa: S101");
896 }
897 }
898 "less_than" => {
899 if let Some(val) = &assertion.value {
900 let expected = value_to_python_string(val);
901 let _ = writeln!(out, " assert {field_access} < {expected} # noqa: S101");
902 }
903 }
904 "greater_than_or_equal" => {
905 if let Some(val) = &assertion.value {
906 let expected = value_to_python_string(val);
907 let _ = writeln!(out, " assert {field_access} >= {expected} # noqa: S101");
908 }
909 }
910 "less_than_or_equal" => {
911 if let Some(val) = &assertion.value {
912 let expected = value_to_python_string(val);
913 let _ = writeln!(out, " assert {field_access} <= {expected} # noqa: S101");
914 }
915 }
916 "starts_with" => {
917 if let Some(val) = &assertion.value {
918 let expected = value_to_python_string(val);
919 let _ = writeln!(out, " assert {field_access}.startswith({expected}) # noqa: S101");
920 }
921 }
922 "ends_with" => {
923 if let Some(val) = &assertion.value {
924 let expected = value_to_python_string(val);
925 let _ = writeln!(out, " assert {field_access}.endswith({expected}) # noqa: S101");
926 }
927 }
928 "min_length" => {
929 if let Some(val) = &assertion.value {
930 if let Some(n) = val.as_u64() {
931 let _ = writeln!(out, " assert len({field_access}) >= {n} # noqa: S101");
932 }
933 }
934 }
935 "max_length" => {
936 if let Some(val) = &assertion.value {
937 if let Some(n) = val.as_u64() {
938 let _ = writeln!(out, " assert len({field_access}) <= {n} # noqa: S101");
939 }
940 }
941 }
942 "count_min" => {
943 if let Some(val) = &assertion.value {
944 if let Some(n) = val.as_u64() {
945 let _ = writeln!(out, " assert len({field_access}) >= {n} # noqa: S101");
946 }
947 }
948 }
949 other => {
950 let _ = writeln!(out, " # TODO: unsupported assertion type: {other}");
951 }
952 }
953}
954
955fn value_to_python_string(value: &serde_json::Value) -> String {
956 match value {
957 serde_json::Value::String(s) => python_string_literal(s),
958 serde_json::Value::Bool(true) => "True".to_string(),
959 serde_json::Value::Bool(false) => "False".to_string(),
960 serde_json::Value::Number(n) => n.to_string(),
961 serde_json::Value::Null => "None".to_string(),
962 other => python_string_literal(&other.to_string()),
963 }
964}
965
966fn python_string_literal(s: &str) -> String {
969 if s.contains('"') && !s.contains('\'') {
970 let escaped = s
972 .replace('\\', "\\\\")
973 .replace('\'', "\\'")
974 .replace('\n', "\\n")
975 .replace('\r', "\\r")
976 .replace('\t', "\\t");
977 format!("'{escaped}'")
978 } else {
979 format!("\"{}\"", escape_python(s))
980 }
981}