1use crate::codegen::resolve_field;
7use crate::config::E2eConfig;
8use crate::escape::{escape_python, sanitize_filename, sanitize_ident};
9use crate::field_access::FieldResolver;
10use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup};
11use alef_core::backend::GeneratedFile;
12use alef_core::config::AlefConfig;
13use alef_core::hash::{self, CommentStyle};
14use anyhow::Result;
15use heck::{ToShoutySnakeCase, ToSnakeCase};
16use std::collections::HashMap;
17use std::fmt::Write as FmtWrite;
18use std::path::PathBuf;
19
20pub struct PythonE2eCodegen;
22
23impl super::E2eCodegen for PythonE2eCodegen {
24 fn generate(
25 &self,
26 groups: &[FixtureGroup],
27 e2e_config: &E2eConfig,
28 _alef_config: &AlefConfig,
29 ) -> Result<Vec<GeneratedFile>> {
30 let mut files = Vec::new();
31 let output_base = PathBuf::from(e2e_config.effective_output()).join("python");
32
33 files.push(GeneratedFile {
35 path: output_base.join("conftest.py"),
36 content: render_conftest(e2e_config, groups),
37 generated_header: true,
38 });
39
40 files.push(GeneratedFile {
42 path: output_base.join("__init__.py"),
43 content: String::new(),
44 generated_header: false,
45 });
46
47 files.push(GeneratedFile {
49 path: output_base.join("tests").join("__init__.py"),
50 content: String::new(),
51 generated_header: false,
52 });
53
54 let python_pkg = e2e_config.resolve_package("python");
56 let pkg_name = python_pkg
57 .as_ref()
58 .and_then(|p| p.name.as_deref())
59 .unwrap_or("kreuzcrawl");
60 let pkg_path = python_pkg
61 .as_ref()
62 .and_then(|p| p.path.as_deref())
63 .unwrap_or("../../packages/python");
64 let pkg_version = python_pkg
65 .as_ref()
66 .and_then(|p| p.version.as_deref())
67 .unwrap_or("0.1.0");
68 files.push(GeneratedFile {
69 path: output_base.join("pyproject.toml"),
70 content: render_pyproject(pkg_name, pkg_path, pkg_version, e2e_config.dep_mode),
71 generated_header: true,
72 });
73
74 for group in groups {
76 let fixtures: Vec<&Fixture> = group.fixtures.iter().collect();
77
78 if fixtures.is_empty() {
79 continue;
80 }
81
82 if fixtures.iter().all(|f| is_skipped(f, "python")) {
87 continue;
88 }
89
90 let filename = format!("test_{}.py", sanitize_filename(&group.category));
91 let content = render_test_file(&group.category, &fixtures, e2e_config);
92
93 files.push(GeneratedFile {
94 path: output_base.join("tests").join(filename),
95 content,
96 generated_header: true,
97 });
98 }
99
100 Ok(files)
101 }
102
103 fn language_name(&self) -> &'static str {
104 "python"
105 }
106}
107
108fn render_pyproject(
113 pkg_name: &str,
114 _pkg_path: &str,
115 pkg_version: &str,
116 dep_mode: crate::config::DependencyMode,
117) -> String {
118 let dep_spec = match dep_mode {
119 crate::config::DependencyMode::Registry => {
120 format!(
121 "dependencies = [\"{pkg_name}{pkg_version}\", \"pytest>=7.4\", \"pytest-asyncio>=0.23\", \"pytest-timeout>=2.1\"]\n"
122 )
123 }
124 crate::config::DependencyMode::Local => {
125 format!(
126 "dependencies = [\"{pkg_name}\", \"pytest>=7.4\", \"pytest-asyncio>=0.23\", \"pytest-timeout>=2.1\"]\n\
127 \n\
128 [tool.uv.sources]\n\
129 {pkg_name} = {{ workspace = true }}\n"
130 )
131 }
132 };
133
134 format!(
135 r#"[build-system]
136build-backend = "setuptools.build_meta"
137requires = ["setuptools>=68", "wheel"]
138
139[project]
140name = "{pkg_name}-e2e-tests"
141version = "0.0.0"
142description = "End-to-end tests"
143requires-python = ">=3.10"
144{dep_spec}
145[tool.setuptools]
146packages = []
147
148[tool.pytest.ini_options]
149asyncio_mode = "auto"
150testpaths = ["tests"]
151python_files = "test_*.py"
152python_functions = "test_*"
153addopts = "-v --strict-markers --tb=short"
154timeout = 300
155
156[tool.ruff.lint]
157ignore = ["PLR2004"]
158
159[tool.ruff.lint.per-file-ignores]
160"tests/**" = ["S101", "S108", "PT011", "B017"]
161"#
162 )
163}
164
165fn resolve_function_name(e2e_config: &E2eConfig) -> String {
170 resolve_function_name_for_call(&e2e_config.call)
171}
172
173fn resolve_function_name_for_call(call_config: &crate::config::CallConfig) -> String {
174 call_config
175 .overrides
176 .get("python")
177 .and_then(|o| o.function.clone())
178 .unwrap_or_else(|| call_config.function.clone())
179}
180
181fn resolve_module(e2e_config: &E2eConfig) -> String {
182 e2e_config
183 .call
184 .overrides
185 .get("python")
186 .and_then(|o| o.module.clone())
187 .unwrap_or_else(|| e2e_config.call.module.replace('-', "_"))
188}
189
190fn resolve_options_type(e2e_config: &E2eConfig) -> Option<String> {
191 e2e_config
192 .call
193 .overrides
194 .get("python")
195 .and_then(|o| o.options_type.clone())
196}
197
198fn resolve_options_via(e2e_config: &E2eConfig) -> &str {
200 e2e_config
201 .call
202 .overrides
203 .get("python")
204 .and_then(|o| o.options_via.as_deref())
205 .unwrap_or("kwargs")
206}
207
208fn resolve_enum_fields(e2e_config: &E2eConfig) -> &HashMap<String, String> {
210 static EMPTY: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
211 e2e_config
212 .call
213 .overrides
214 .get("python")
215 .map(|o| &o.enum_fields)
216 .unwrap_or(&EMPTY)
217}
218
219fn resolve_handle_nested_types(e2e_config: &E2eConfig) -> &HashMap<String, String> {
222 static EMPTY: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
223 e2e_config
224 .call
225 .overrides
226 .get("python")
227 .map(|o| &o.handle_nested_types)
228 .unwrap_or(&EMPTY)
229}
230
231fn resolve_handle_dict_types(e2e_config: &E2eConfig) -> &std::collections::HashSet<String> {
234 static EMPTY: std::sync::LazyLock<std::collections::HashSet<String>> =
235 std::sync::LazyLock::new(std::collections::HashSet::new);
236 e2e_config
237 .call
238 .overrides
239 .get("python")
240 .map(|o| &o.handle_dict_types)
241 .unwrap_or(&EMPTY)
242}
243
244fn is_skipped(fixture: &Fixture, language: &str) -> bool {
245 fixture.skip.as_ref().is_some_and(|s| s.should_skip(language))
246}
247
248fn render_conftest(e2e_config: &E2eConfig, groups: &[FixtureGroup]) -> String {
253 let module = resolve_module(e2e_config);
254 let has_http_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| f.is_http_test());
255
256 let header = hash::header(CommentStyle::Hash);
257 if has_http_fixtures {
258 format!(
259 r#"{header}"""Pytest configuration for e2e tests."""
260import pytest
261
262# Ensure the package is importable.
263# The {module} package is expected to be installed in the current environment.
264
265
266@pytest.fixture
267def client(http_test_server): # noqa: ANN001, ANN201
268 """Return a test client bound to the per-test HTTP server."""
269 return http_test_server.client()
270"#
271 )
272 } else {
273 format!(
274 r#"{header}"""Pytest configuration for e2e tests."""
275# Ensure the package is importable.
276# The {module} package is expected to be installed in the current environment.
277"#
278 )
279 }
280}
281
282fn render_test_file(category: &str, fixtures: &[&Fixture], e2e_config: &E2eConfig) -> String {
283 let mut out = String::new();
284 out.push_str(&hash::header(CommentStyle::Hash));
285 let _ = writeln!(out, "\"\"\"E2e tests for category: {category}.\"\"\"");
286
287 let module = resolve_module(e2e_config);
288 let function_name = resolve_function_name(e2e_config);
289 let options_type = resolve_options_type(e2e_config);
290 let options_via = resolve_options_via(e2e_config);
291 let enum_fields = resolve_enum_fields(e2e_config);
292 let handle_nested_types = resolve_handle_nested_types(e2e_config);
293 let handle_dict_types = resolve_handle_dict_types(e2e_config);
294 let field_resolver = FieldResolver::new(
295 &e2e_config.fields,
296 &e2e_config.fields_optional,
297 &e2e_config.result_fields,
298 &e2e_config.fields_array,
299 );
300
301 let has_error_test = fixtures
302 .iter()
303 .any(|f| f.assertions.iter().any(|a| a.assertion_type == "error"));
304 let has_skipped = fixtures.iter().any(|f| is_skipped(f, "python"));
305 let has_http_tests = fixtures.iter().any(|f| f.is_http_test());
306
307 let is_async = fixtures.iter().any(|f| {
309 let cc = e2e_config.resolve_call(f.call.as_deref());
310 cc.r#async
311 }) || e2e_config.call.r#async;
312 let needs_pytest = has_error_test || has_skipped || is_async;
313
314 let needs_json_import = options_via == "json"
316 && fixtures.iter().any(|f| {
317 e2e_config
318 .call
319 .args
320 .iter()
321 .any(|arg| arg.arg_type == "json_object" && !resolve_field(&f.input, &arg.field).is_null())
322 });
323
324 let needs_os_import = e2e_config.call.args.iter().any(|arg| arg.arg_type == "mock_url");
326
327 let needs_re_import = has_http_tests
329 && fixtures.iter().any(|f| {
330 f.http
331 .as_ref()
332 .is_some_and(|h| h.expected_response.headers.values().any(|v| v == "<<uuid>>"))
333 });
334
335 let needs_options_type = options_via == "kwargs"
337 && options_type.is_some()
338 && fixtures.iter().any(|f| {
339 e2e_config
340 .call
341 .args
342 .iter()
343 .any(|arg| arg.arg_type == "json_object" && !resolve_field(&f.input, &arg.field).is_null())
344 });
345
346 let mut used_enum_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
348 if needs_options_type && !enum_fields.is_empty() {
349 for fixture in fixtures.iter() {
350 for arg in &e2e_config.call.args {
351 if arg.arg_type == "json_object" {
352 let value = resolve_field(&fixture.input, &arg.field);
353 if let Some(obj) = value.as_object() {
354 for key in obj.keys() {
355 if let Some(enum_type) = enum_fields.get(key) {
356 used_enum_types.insert(enum_type.clone());
357 }
358 }
359 }
360 }
361 }
362 }
363 }
364
365 let mut stdlib_imports: Vec<String> = Vec::new();
369 let mut thirdparty_bare: Vec<String> = Vec::new();
370 let mut thirdparty_from: Vec<String> = Vec::new();
371
372 if needs_json_import {
373 stdlib_imports.push("import json".to_string());
374 }
375
376 if needs_os_import {
377 stdlib_imports.push("import os".to_string());
378 }
379
380 if needs_re_import {
381 stdlib_imports.push("import re".to_string());
382 }
383
384 if needs_pytest {
385 thirdparty_bare.push("import pytest # noqa: F401".to_string());
390 }
391
392 let has_non_http_fixtures = fixtures.iter().any(|f| !f.is_http_test());
394 if has_non_http_fixtures {
395 let handle_constructors: Vec<String> = e2e_config
397 .call
398 .args
399 .iter()
400 .filter(|arg| arg.arg_type == "handle")
401 .map(|arg| format!("create_{}", arg.name.to_snake_case()))
402 .collect();
403
404 let mut import_names: Vec<String> = Vec::new();
408 for fixture in fixtures.iter() {
409 let cc = e2e_config.resolve_call(fixture.call.as_deref());
410 let fn_name = resolve_function_name_for_call(cc);
411 if !import_names.contains(&fn_name) {
412 import_names.push(fn_name);
413 }
414 }
415 if import_names.is_empty() {
418 import_names.push(function_name.clone());
419 }
420 for ctor in &handle_constructors {
421 if !import_names.contains(ctor) {
422 import_names.push(ctor.clone());
423 }
424 }
425
426 let needs_config_import = e2e_config.call.args.iter().any(|arg| {
428 arg.arg_type == "handle"
429 && fixtures.iter().any(|f| {
430 let val = resolve_field(&f.input, &arg.field);
431 !val.is_null() && val.as_object().is_some_and(|o| !o.is_empty())
432 })
433 });
434 if needs_config_import {
435 let config_class = options_type.as_deref().unwrap_or("CrawlConfig");
436 if !import_names.contains(&config_class.to_string()) {
437 import_names.push(config_class.to_string());
438 }
439 }
440
441 if !handle_nested_types.is_empty() {
443 let mut used_nested_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
444 for fixture in fixtures.iter() {
445 for arg in &e2e_config.call.args {
446 if arg.arg_type == "handle" {
447 let config_value = resolve_field(&fixture.input, &arg.field);
448 if let Some(obj) = config_value.as_object() {
449 for key in obj.keys() {
450 if let Some(type_name) = handle_nested_types.get(key) {
451 if obj[key].is_object() {
452 used_nested_types.insert(type_name.clone());
453 }
454 }
455 }
456 }
457 }
458 }
459 }
460 for type_name in used_nested_types {
461 if !import_names.contains(&type_name) {
462 import_names.push(type_name);
463 }
464 }
465 }
466
467 for fixture in fixtures.iter() {
469 for assertion in &fixture.assertions {
470 if assertion.assertion_type == "method_result" {
471 if let Some(method_name) = &assertion.method {
472 let import = python_method_helper_import(method_name);
473 if let Some(name) = import {
474 if !import_names.contains(&name) {
475 import_names.push(name);
476 }
477 }
478 }
479 }
480 }
481 }
482
483 if let (true, Some(opts_type)) = (needs_options_type, &options_type) {
484 import_names.push(opts_type.clone());
485 thirdparty_from.push(format!("from {module} import {}", import_names.join(", ")));
486 if !used_enum_types.is_empty() {
488 let enum_mod = e2e_config
489 .call
490 .overrides
491 .get("python")
492 .and_then(|o| o.enum_module.as_deref())
493 .unwrap_or(&module);
494 let enum_names: Vec<&String> = used_enum_types.iter().collect();
495 thirdparty_from.push(format!(
496 "from {enum_mod} import {}",
497 enum_names.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
498 ));
499 }
500 } else {
501 thirdparty_from.push(format!("from {module} import {}", import_names.join(", ")));
502 }
503 }
504
505 stdlib_imports.sort();
506 thirdparty_bare.sort();
507 thirdparty_from.sort();
508
509 if !stdlib_imports.is_empty() {
511 for imp in &stdlib_imports {
512 let _ = writeln!(out, "{imp}");
513 }
514 let _ = writeln!(out);
515 }
516 for imp in &thirdparty_bare {
518 let _ = writeln!(out, "{imp}");
519 }
520 for imp in &thirdparty_from {
521 let _ = writeln!(out, "{imp}");
522 }
523 let _ = writeln!(out);
525 let _ = writeln!(out);
526
527 for fixture in fixtures {
528 if fixture.is_http_test() {
529 render_http_test_function(&mut out, fixture);
530 } else {
531 render_test_function(
532 &mut out,
533 fixture,
534 e2e_config,
535 options_type.as_deref(),
536 options_via,
537 enum_fields,
538 handle_nested_types,
539 handle_dict_types,
540 &field_resolver,
541 );
542 }
543 let _ = writeln!(out);
544 }
545
546 out
547}
548
549fn render_http_test_function(out: &mut String, fixture: &Fixture) {
560 let Some(http) = &fixture.http else {
561 return;
562 };
563
564 let fn_name = sanitize_ident(&fixture.id);
565 let description = &fixture.description;
566 let desc_with_period = if description.ends_with('.') {
567 description.to_string()
568 } else {
569 format!("{description}.")
570 };
571
572 if is_skipped(fixture, "python") {
573 let reason = fixture
574 .skip
575 .as_ref()
576 .and_then(|s| s.reason.as_deref())
577 .unwrap_or("skipped for python");
578 let escaped = escape_python(reason);
579 let _ = writeln!(out, "@pytest.mark.skip(reason=\"{escaped}\")");
580 }
581
582 let _ = writeln!(out, "def test_{fn_name}(client) -> None:");
583 let _ = writeln!(out, " \"\"\"{desc_with_period}\"\"\"");
584
585 let method = http.request.method.to_lowercase();
587 let path = &http.request.path;
588
589 let mut call_kwargs: Vec<String> = Vec::new();
591
592 if let Some(body) = &http.request.body {
594 let py_body = json_to_python_literal(body);
595 call_kwargs.push(format!(" json={py_body},"));
596 }
597
598 if !http.request.headers.is_empty() {
600 let entries: Vec<String> = http
601 .request
602 .headers
603 .iter()
604 .map(|(k, v)| format!(" \"{}\": \"{}\",", escape_python(k), escape_python(v)))
605 .collect();
606 let headers_block = entries.join("\n");
607 call_kwargs.push(format!(" headers={{\n{headers_block}\n }},"));
608 }
609
610 if !http.request.query_params.is_empty() {
612 let entries: Vec<String> = http
613 .request
614 .query_params
615 .iter()
616 .map(|(k, v)| format!(" \"{}\": {},", escape_python(k), json_to_python_literal(v)))
617 .collect();
618 let params_block = entries.join("\n");
619 call_kwargs.push(format!(" params={{\n{params_block}\n }},"));
620 }
621
622 if !http.request.cookies.is_empty() {
624 let entries: Vec<String> = http
625 .request
626 .cookies
627 .iter()
628 .map(|(k, v)| format!(" \"{}\": \"{}\",", escape_python(k), escape_python(v)))
629 .collect();
630 let cookies_block = entries.join("\n");
631 call_kwargs.push(format!(" cookies={{\n{cookies_block}\n }},"));
632 }
633
634 if call_kwargs.is_empty() {
635 let _ = writeln!(out, " response = client.{method}(\"{path}\")");
636 } else {
637 let _ = writeln!(out, " response = client.{method}(");
638 let _ = writeln!(out, " \"{path}\",");
639 for kwarg in &call_kwargs {
640 let _ = writeln!(out, "{kwarg}");
641 }
642 let _ = writeln!(out, " )");
643 }
644
645 let status = http.expected_response.status_code;
647 let _ = writeln!(out, " assert response.status_code == {status} # noqa: S101");
648
649 if let Some(expected_body) = &http.expected_response.body {
651 let py_val = json_to_python_literal(expected_body);
652 let _ = writeln!(out, " data = response.json()");
653 let _ = writeln!(out, " assert data == {py_val} # noqa: S101");
654 } else if let Some(partial) = &http.expected_response.body_partial {
655 let _ = writeln!(out, " data = response.json()");
656 if let Some(obj) = partial.as_object() {
657 for (key, val) in obj {
658 let py_val = json_to_python_literal(val);
659 let escaped_key = escape_python(key);
660 let _ = writeln!(out, " assert data[\"{escaped_key}\"] == {py_val} # noqa: S101");
661 }
662 }
663 }
664
665 for (header_name, header_value) in &http.expected_response.headers {
667 let lower_name = header_name.to_lowercase();
668 let escaped_name = escape_python(&lower_name);
669 match header_value.as_str() {
670 "<<present>>" => {
671 let _ = writeln!(out, " assert \"{escaped_name}\" in response.headers # noqa: S101");
672 }
673 "<<absent>>" => {
674 let _ = writeln!(
675 out,
676 " assert response.headers.get(\"{escaped_name}\") is None # noqa: S101"
677 );
678 }
679 "<<uuid>>" => {
680 let _ = writeln!(
681 out,
682 " assert re.match(r'^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$', response.headers[\"{escaped_name}\"]) # noqa: S101"
683 );
684 }
685 exact => {
686 let escaped_val = escape_python(exact);
687 let _ = writeln!(
688 out,
689 " assert response.headers[\"{escaped_name}\"] == \"{escaped_val}\" # noqa: S101"
690 );
691 }
692 }
693 }
694
695 if let Some(validation_errors) = &http.expected_response.validation_errors {
697 if !validation_errors.is_empty() {
698 let _ = writeln!(out, " errors = response.json().get(\"detail\", [])");
699 for ve in validation_errors {
700 let loc_py: Vec<String> = ve.loc.iter().map(|s| format!("\"{}\"", escape_python(s))).collect();
701 let loc_str = loc_py.join(", ");
702 let escaped_msg = escape_python(&ve.msg);
703 let _ = writeln!(
704 out,
705 " assert any(e[\"loc\"] == [{loc_str}] and \"{escaped_msg}\" in e[\"msg\"] for e in errors) # noqa: S101"
706 );
707 }
708 }
709 }
710}
711
712#[allow(clippy::too_many_arguments)]
717fn render_test_function(
718 out: &mut String,
719 fixture: &Fixture,
720 e2e_config: &E2eConfig,
721 options_type: Option<&str>,
722 options_via: &str,
723 enum_fields: &HashMap<String, String>,
724 handle_nested_types: &HashMap<String, String>,
725 handle_dict_types: &std::collections::HashSet<String>,
726 field_resolver: &FieldResolver,
727) {
728 let fn_name = sanitize_ident(&fixture.id);
729 let description = &fixture.description;
730 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
731 let function_name = resolve_function_name_for_call(call_config);
732 let result_var = &call_config.result_var;
733
734 let desc_with_period = if description.ends_with('.') {
735 description.to_string()
736 } else {
737 format!("{description}.")
738 };
739
740 if is_skipped(fixture, "python") {
742 let reason = fixture
743 .skip
744 .as_ref()
745 .and_then(|s| s.reason.as_deref())
746 .unwrap_or("skipped for python");
747 let escaped = escape_python(reason);
748 let _ = writeln!(out, "@pytest.mark.skip(reason=\"{escaped}\")");
749 }
750
751 let is_async = call_config.r#async;
752 if is_async {
753 let _ = writeln!(out, "@pytest.mark.asyncio");
754 let _ = writeln!(out, "async def test_{fn_name}() -> None:");
755 } else {
756 let _ = writeln!(out, "def test_{fn_name}() -> None:");
757 }
758 let _ = writeln!(out, " \"\"\"{desc_with_period}\"\"\"");
759
760 let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
762
763 let mut arg_bindings = Vec::new();
765 let mut kwarg_exprs = Vec::new();
766 for arg in &call_config.args {
767 let var_name = &arg.name;
768
769 if arg.arg_type == "handle" {
770 let constructor_name = format!("create_{}", arg.name.to_snake_case());
773 let config_value = resolve_field(&fixture.input, &arg.field);
774 if config_value.is_null()
775 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
776 {
777 arg_bindings.push(format!(" {var_name} = {constructor_name}(None)"));
778 } else if let Some(obj) = config_value.as_object() {
779 let kwargs: Vec<String> = obj
783 .iter()
784 .map(|(k, v)| {
785 let snake_key = k.to_snake_case();
786 let py_val = if let Some(type_name) = handle_nested_types.get(k) {
787 if let Some(nested_obj) = v.as_object() {
789 if nested_obj.is_empty() {
790 format!("{type_name}()")
792 } else if handle_dict_types.contains(k) {
793 json_to_python_literal(v)
798 } else {
799 let nested_kwargs: Vec<String> = nested_obj
801 .iter()
802 .map(|(nk, nv)| {
803 let nested_snake_key = nk.to_snake_case();
804 format!("{nested_snake_key}={}", json_to_python_literal(nv))
805 })
806 .collect();
807 format!("{type_name}({})", nested_kwargs.join(", "))
808 }
809 } else {
810 json_to_python_literal(v)
812 }
813 } else if k == "request_timeout" {
814 if let Some(ms) = v.as_u64() {
820 format!("{}", ms / 1000)
821 } else {
822 json_to_python_literal(v)
823 }
824 } else {
825 json_to_python_literal(v)
826 };
827 format!("{snake_key}={py_val}")
828 })
829 .collect();
830 let config_class = options_type.unwrap_or("CrawlConfig");
832 let single_line = format!(" {var_name}_config = {config_class}({})", kwargs.join(", "));
833 if single_line.len() <= 120 {
834 arg_bindings.push(single_line);
835 } else {
836 let mut lines = format!(" {var_name}_config = {config_class}(\n");
838 for kw in &kwargs {
839 lines.push_str(&format!(" {kw},\n"));
840 }
841 lines.push_str(" )");
842 arg_bindings.push(lines);
843 }
844 arg_bindings.push(format!(" {var_name} = {constructor_name}({var_name}_config)"));
845 } else {
846 let literal = json_to_python_literal(config_value);
847 arg_bindings.push(format!(" {var_name} = {constructor_name}({literal})"));
848 }
849 kwarg_exprs.push(format!("{var_name}={var_name}"));
850 continue;
851 }
852
853 if arg.arg_type == "mock_url" {
854 let fixture_id = &fixture.id;
855 arg_bindings.push(format!(
856 " {var_name} = os.environ['MOCK_SERVER_URL'] + '/fixtures/{fixture_id}'"
857 ));
858 kwarg_exprs.push(format!("{var_name}={var_name}"));
859 continue;
860 }
861
862 let value = resolve_field(&fixture.input, &arg.field);
863
864 if value.is_null() && arg.optional {
865 continue;
866 }
867
868 if arg.arg_type == "json_object" && !value.is_null() {
870 match options_via {
871 "dict" => {
872 let literal = json_to_python_literal(value);
874 let noqa = if literal.contains("/tmp/") {
875 " # noqa: S108"
876 } else {
877 ""
878 };
879 arg_bindings.push(format!(" {var_name} = {literal}{noqa}"));
880 kwarg_exprs.push(format!("{var_name}={var_name}"));
881 continue;
882 }
883 "json" => {
884 let json_str = serde_json::to_string(value).unwrap_or_default();
886 let escaped = escape_python(&json_str);
887 arg_bindings.push(format!(" {var_name} = json.loads(\"{escaped}\")"));
888 kwarg_exprs.push(format!("{var_name}={var_name}"));
889 continue;
890 }
891 _ => {
892 if let (Some(opts_type), Some(obj)) = (options_type, value.as_object()) {
894 let kwargs: Vec<String> = obj
895 .iter()
896 .map(|(k, v)| {
897 let snake_key = k.to_snake_case();
898 let py_val = if let Some(enum_type) = enum_fields.get(k) {
899 if let Some(s) = v.as_str() {
901 let upper_val = s.to_shouty_snake_case();
902 format!("{enum_type}.{upper_val}")
903 } else {
904 json_to_python_literal(v)
905 }
906 } else {
907 json_to_python_literal(v)
908 };
909 format!("{snake_key}={py_val}")
910 })
911 .collect();
912 let constructor = format!("{opts_type}({})", kwargs.join(", "));
913 arg_bindings.push(format!(" {var_name} = {constructor}"));
914 kwarg_exprs.push(format!("{var_name}={var_name}"));
915 continue;
916 }
917 }
918 }
919 }
920
921 if value.is_null() && !arg.optional {
923 let default_val = match arg.arg_type.as_str() {
924 "string" => "\"\"".to_string(),
925 "int" | "integer" => "0".to_string(),
926 "float" | "number" => "0.0".to_string(),
927 "bool" | "boolean" => "False".to_string(),
928 _ => "None".to_string(),
929 };
930 arg_bindings.push(format!(" {var_name} = {default_val}"));
931 kwarg_exprs.push(format!("{var_name}={var_name}"));
932 continue;
933 }
934
935 let literal = json_to_python_literal(value);
936 let noqa = if literal.contains("/tmp/") {
937 " # noqa: S108"
938 } else {
939 ""
940 };
941 arg_bindings.push(format!(" {var_name} = {literal}{noqa}"));
942 kwarg_exprs.push(format!("{var_name}={var_name}"));
943 }
944
945 if let Some(visitor_spec) = &fixture.visitor {
947 let _ = writeln!(out, " class _TestVisitor:");
948 for (method_name, action) in &visitor_spec.callbacks {
949 emit_python_visitor_method(out, method_name, action);
950 }
951 kwarg_exprs.push("visitor=_TestVisitor()".to_string());
952 }
953
954 for binding in &arg_bindings {
955 let _ = writeln!(out, "{binding}");
956 }
957
958 let call_args = kwarg_exprs.join(", ");
959 let await_prefix = if is_async { "await " } else { "" };
960 let call_expr = format!("{await_prefix}{function_name}({call_args})");
961
962 if has_error_assertion {
963 let error_assertion = fixture.assertions.iter().find(|a| a.assertion_type == "error");
965 let has_message = error_assertion
966 .and_then(|a| a.value.as_ref())
967 .and_then(|v| v.as_str())
968 .is_some();
969
970 if has_message {
971 let _ = writeln!(out, " with pytest.raises(Exception) as exc_info: # noqa: B017");
972 let _ = writeln!(out, " {call_expr}");
973 if let Some(msg) = error_assertion.and_then(|a| a.value.as_ref()).and_then(|v| v.as_str()) {
974 let escaped = escape_python(msg);
975 let _ = writeln!(out, " assert \"{escaped}\" in str(exc_info.value) # noqa: S101");
976 }
977 } else {
978 let _ = writeln!(out, " with pytest.raises(Exception): # noqa: B017");
979 let _ = writeln!(out, " {call_expr}");
980 }
981
982 return;
985 }
986
987 let has_usable_assertion = fixture.assertions.iter().any(|a| {
989 if a.assertion_type == "not_error" || a.assertion_type == "error" {
990 return false;
991 }
992 match &a.field {
993 Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
994 _ => true,
995 }
996 });
997 let py_result_var = if has_usable_assertion {
998 result_var.to_string()
999 } else {
1000 "_".to_string()
1001 };
1002 let _ = writeln!(out, " {py_result_var} = {call_expr}");
1003
1004 let fields_enum = &e2e_config.fields_enum;
1005 for assertion in &fixture.assertions {
1006 if assertion.assertion_type == "not_error" {
1007 continue;
1009 }
1010 render_assertion(out, assertion, result_var, field_resolver, fields_enum);
1011 }
1012}
1013
1014fn json_to_python_literal(value: &serde_json::Value) -> String {
1019 match value {
1020 serde_json::Value::Null => "None".to_string(),
1021 serde_json::Value::Bool(true) => "True".to_string(),
1022 serde_json::Value::Bool(false) => "False".to_string(),
1023 serde_json::Value::Number(n) => n.to_string(),
1024 serde_json::Value::String(s) => python_string_literal(s),
1025 serde_json::Value::Array(arr) => {
1026 let items: Vec<String> = arr.iter().map(json_to_python_literal).collect();
1027 format!("[{}]", items.join(", "))
1028 }
1029 serde_json::Value::Object(map) => {
1030 let items: Vec<String> = map
1031 .iter()
1032 .map(|(k, v)| format!("\"{}\": {}", escape_python(k), json_to_python_literal(v)))
1033 .collect();
1034 format!("{{{}}}", items.join(", "))
1035 }
1036 }
1037}
1038
1039fn render_assertion(
1044 out: &mut String,
1045 assertion: &Assertion,
1046 result_var: &str,
1047 field_resolver: &FieldResolver,
1048 fields_enum: &std::collections::HashSet<String>,
1049) {
1050 if let Some(f) = &assertion.field {
1052 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1053 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
1054 return;
1055 }
1056 }
1057
1058 let field_access = match &assertion.field {
1059 Some(f) if !f.is_empty() => field_resolver.accessor(f, "python", result_var),
1060 _ => result_var.to_string(),
1061 };
1062
1063 let field_is_enum = assertion.field.as_deref().is_some_and(|f| {
1074 if fields_enum.contains(f) {
1075 return true;
1076 }
1077 let resolved = field_resolver.resolve(f);
1078 if fields_enum.contains(resolved) {
1079 return true;
1080 }
1081 field_resolver.accessor(f, "python", result_var).contains("[0]")
1086 });
1087
1088 let field_is_optional = match &assertion.field {
1091 Some(f) if !f.is_empty() => {
1092 let resolved = field_resolver.resolve(f);
1093 field_resolver.is_optional(resolved)
1094 }
1095 _ => false,
1096 };
1097
1098 match assertion.assertion_type.as_str() {
1099 "error" | "not_error" => {
1100 }
1102 "equals" => {
1103 if let Some(val) = &assertion.value {
1104 let expected = value_to_python_string(val);
1105 let op = if val.is_boolean() || val.is_null() { "is" } else { "==" };
1107 if val.is_string() {
1110 let _ = writeln!(out, " assert {field_access}.strip() {op} {expected} # noqa: S101");
1111 } else {
1112 let _ = writeln!(out, " assert {field_access} {op} {expected} # noqa: S101");
1113 }
1114 }
1115 }
1116 "contains" => {
1117 if let Some(val) = &assertion.value {
1118 let expected = value_to_python_string(val);
1119 let cmp_expr = if field_is_enum && val.is_string() {
1121 format!("str({field_access}).lower()")
1122 } else {
1123 field_access.clone()
1124 };
1125 if field_is_optional {
1126 let _ = writeln!(out, " assert {field_access} is not None # noqa: S101");
1127 let _ = writeln!(out, " assert {expected} in {cmp_expr} # noqa: S101");
1128 } else {
1129 let _ = writeln!(out, " assert {expected} in {cmp_expr} # noqa: S101");
1130 }
1131 }
1132 }
1133 "contains_all" => {
1134 if let Some(values) = &assertion.values {
1135 for val in values {
1136 let expected = value_to_python_string(val);
1137 let cmp_expr = if field_is_enum && val.is_string() {
1139 format!("str({field_access}).lower()")
1140 } else {
1141 field_access.clone()
1142 };
1143 if field_is_optional {
1144 let _ = writeln!(out, " assert {field_access} is not None # noqa: S101");
1145 let _ = writeln!(out, " assert {expected} in {cmp_expr} # noqa: S101");
1146 } else {
1147 let _ = writeln!(out, " assert {expected} in {cmp_expr} # noqa: S101");
1148 }
1149 }
1150 }
1151 }
1152 "not_contains" => {
1153 if let Some(val) = &assertion.value {
1154 let expected = value_to_python_string(val);
1155 let cmp_expr = if field_is_enum && val.is_string() {
1157 format!("str({field_access}).lower()")
1158 } else {
1159 field_access.clone()
1160 };
1161 if field_is_optional {
1162 let _ = writeln!(
1163 out,
1164 " assert {field_access} is None or {expected} not in {cmp_expr} # noqa: S101"
1165 );
1166 } else {
1167 let _ = writeln!(out, " assert {expected} not in {cmp_expr} # noqa: S101");
1168 }
1169 }
1170 }
1171 "not_empty" => {
1172 let _ = writeln!(out, " assert {field_access} # noqa: S101");
1173 }
1174 "is_empty" => {
1175 let _ = writeln!(out, " assert not {field_access} # noqa: S101");
1176 }
1177 "contains_any" => {
1178 if let Some(values) = &assertion.values {
1179 let items: Vec<String> = values.iter().map(value_to_python_string).collect();
1180 let list_str = items.join(", ");
1181 let cmp_expr = if field_is_enum {
1183 format!("str({field_access}).lower()")
1184 } else {
1185 field_access.clone()
1186 };
1187 if field_is_optional {
1188 let _ = writeln!(out, " assert {field_access} is not None # noqa: S101");
1189 let _ = writeln!(
1190 out,
1191 " assert any(v in {cmp_expr} for v in [{list_str}]) # noqa: S101"
1192 );
1193 } else {
1194 let _ = writeln!(
1195 out,
1196 " assert any(v in {cmp_expr} for v in [{list_str}]) # noqa: S101"
1197 );
1198 }
1199 }
1200 }
1201 "greater_than" => {
1202 if let Some(val) = &assertion.value {
1203 let expected = value_to_python_string(val);
1204 let _ = writeln!(out, " assert {field_access} > {expected} # noqa: S101");
1205 }
1206 }
1207 "less_than" => {
1208 if let Some(val) = &assertion.value {
1209 let expected = value_to_python_string(val);
1210 let _ = writeln!(out, " assert {field_access} < {expected} # noqa: S101");
1211 }
1212 }
1213 "greater_than_or_equal" | "min" => {
1214 if let Some(val) = &assertion.value {
1215 let expected = value_to_python_string(val);
1216 let _ = writeln!(out, " assert {field_access} >= {expected} # noqa: S101");
1217 }
1218 }
1219 "less_than_or_equal" | "max" => {
1220 if let Some(val) = &assertion.value {
1221 let expected = value_to_python_string(val);
1222 let _ = writeln!(out, " assert {field_access} <= {expected} # noqa: S101");
1223 }
1224 }
1225 "starts_with" => {
1226 if let Some(val) = &assertion.value {
1227 let expected = value_to_python_string(val);
1228 let _ = writeln!(out, " assert {field_access}.startswith({expected}) # noqa: S101");
1229 }
1230 }
1231 "ends_with" => {
1232 if let Some(val) = &assertion.value {
1233 let expected = value_to_python_string(val);
1234 let _ = writeln!(out, " assert {field_access}.endswith({expected}) # noqa: S101");
1235 }
1236 }
1237 "min_length" => {
1238 if let Some(val) = &assertion.value {
1239 if let Some(n) = val.as_u64() {
1240 let _ = writeln!(out, " assert len({field_access}) >= {n} # noqa: S101");
1241 }
1242 }
1243 }
1244 "max_length" => {
1245 if let Some(val) = &assertion.value {
1246 if let Some(n) = val.as_u64() {
1247 let _ = writeln!(out, " assert len({field_access}) <= {n} # noqa: S101");
1248 }
1249 }
1250 }
1251 "count_min" => {
1252 if let Some(val) = &assertion.value {
1253 if let Some(n) = val.as_u64() {
1254 let _ = writeln!(out, " assert len({field_access}) >= {n} # noqa: S101");
1255 }
1256 }
1257 }
1258 "count_equals" => {
1259 if let Some(val) = &assertion.value {
1260 if let Some(n) = val.as_u64() {
1261 let _ = writeln!(out, " assert len({field_access}) == {n} # noqa: S101");
1262 }
1263 }
1264 }
1265 "is_true" => {
1266 let _ = writeln!(out, " assert {field_access} is True # noqa: S101");
1267 }
1268 "is_false" => {
1269 let _ = writeln!(out, " assert not {field_access} # noqa: S101");
1270 }
1271 "method_result" => {
1272 if let Some(method_name) = &assertion.method {
1273 let call_expr = build_python_method_call(result_var, method_name, assertion.args.as_ref());
1274 let check = assertion.check.as_deref().unwrap_or("is_true");
1275 match check {
1276 "equals" => {
1277 if let Some(val) = &assertion.value {
1278 if val.is_boolean() {
1279 if val.as_bool() == Some(true) {
1280 let _ = writeln!(out, " assert {call_expr} is True # noqa: S101");
1281 } else {
1282 let _ = writeln!(out, " assert {call_expr} is False # noqa: S101");
1283 }
1284 } else {
1285 let expected = value_to_python_string(val);
1286 let _ = writeln!(out, " assert {call_expr} == {expected} # noqa: S101");
1287 }
1288 }
1289 }
1290 "is_true" => {
1291 let _ = writeln!(out, " assert {call_expr} # noqa: S101");
1292 }
1293 "is_false" => {
1294 let _ = writeln!(out, " assert not {call_expr} # noqa: S101");
1295 }
1296 "greater_than_or_equal" => {
1297 if let Some(val) = &assertion.value {
1298 let n = val.as_u64().unwrap_or(0);
1299 let _ = writeln!(out, " assert {call_expr} >= {n} # noqa: S101");
1300 }
1301 }
1302 "count_min" => {
1303 if let Some(val) = &assertion.value {
1304 let n = val.as_u64().unwrap_or(0);
1305 let _ = writeln!(out, " assert len({call_expr}) >= {n} # noqa: S101");
1306 }
1307 }
1308 "contains" => {
1309 if let Some(val) = &assertion.value {
1310 let expected = value_to_python_string(val);
1311 let _ = writeln!(out, " assert {expected} in {call_expr} # noqa: S101");
1312 }
1313 }
1314 "is_error" => {
1315 let _ = writeln!(out, " with pytest.raises(Exception): # noqa: B017");
1316 let _ = writeln!(out, " {call_expr}");
1317 }
1318 other_check => {
1319 panic!("unsupported method_result check type: {other_check}");
1320 }
1321 }
1322 } else {
1323 panic!("method_result assertion missing 'method' field");
1324 }
1325 }
1326 "matches_regex" => {
1327 if let Some(val) = &assertion.value {
1328 let expected = value_to_python_string(val);
1329 let _ = writeln!(out, " import re # noqa: PLC0415");
1330 let _ = writeln!(
1331 out,
1332 " assert re.search({expected}, {field_access}) is not None # noqa: S101"
1333 );
1334 }
1335 }
1336 other => {
1337 panic!("unsupported assertion type: {other}");
1338 }
1339 }
1340}
1341
1342fn build_python_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
1345 match method_name {
1346 "root_child_count" => format!("{result_var}.root_node().child_count()"),
1347 "root_node_type" => format!("{result_var}.root_node().kind()"),
1348 "named_children_count" => format!("{result_var}.root_node().named_child_count()"),
1349 "has_error_nodes" => format!("tree_has_error_nodes({result_var})"),
1350 "error_count" | "tree_error_count" => format!("tree_error_count({result_var})"),
1351 "tree_to_sexp" => format!("tree_to_sexp({result_var})"),
1352 "contains_node_type" => {
1353 let node_type = args
1354 .and_then(|a| a.get("node_type"))
1355 .and_then(|v| v.as_str())
1356 .unwrap_or("");
1357 format!("tree_contains_node_type({result_var}, \"{node_type}\")")
1358 }
1359 "find_nodes_by_type" => {
1360 let node_type = args
1361 .and_then(|a| a.get("node_type"))
1362 .and_then(|v| v.as_str())
1363 .unwrap_or("");
1364 format!("find_nodes_by_type({result_var}, \"{node_type}\")")
1365 }
1366 "run_query" => {
1367 let query_source = args
1368 .and_then(|a| a.get("query_source"))
1369 .and_then(|v| v.as_str())
1370 .unwrap_or("");
1371 let language = args
1372 .and_then(|a| a.get("language"))
1373 .and_then(|v| v.as_str())
1374 .unwrap_or("");
1375 format!("run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1376 }
1377 _ => {
1378 if let Some(args_val) = args {
1379 let arg_str = args_val
1380 .as_object()
1381 .map(|obj| {
1382 obj.iter()
1383 .map(|(k, v)| format!("{}={}", k, value_to_python_string(v)))
1384 .collect::<Vec<_>>()
1385 .join(", ")
1386 })
1387 .unwrap_or_default();
1388 format!("{result_var}.{method_name}({arg_str})")
1389 } else {
1390 format!("{result_var}.{method_name}()")
1391 }
1392 }
1393 }
1394}
1395
1396fn python_method_helper_import(method_name: &str) -> Option<String> {
1399 match method_name {
1400 "has_error_nodes" => Some("tree_has_error_nodes".to_string()),
1401 "error_count" | "tree_error_count" => Some("tree_error_count".to_string()),
1402 "tree_to_sexp" => Some("tree_to_sexp".to_string()),
1403 "contains_node_type" => Some("tree_contains_node_type".to_string()),
1404 "find_nodes_by_type" => Some("find_nodes_by_type".to_string()),
1405 "run_query" => Some("run_query".to_string()),
1406 _ => None,
1408 }
1409}
1410
1411fn value_to_python_string(value: &serde_json::Value) -> String {
1412 match value {
1413 serde_json::Value::String(s) => python_string_literal(s),
1414 serde_json::Value::Bool(true) => "True".to_string(),
1415 serde_json::Value::Bool(false) => "False".to_string(),
1416 serde_json::Value::Number(n) => n.to_string(),
1417 serde_json::Value::Null => "None".to_string(),
1418 other => python_string_literal(&other.to_string()),
1419 }
1420}
1421
1422fn python_string_literal(s: &str) -> String {
1425 if s.contains('"') && !s.contains('\'') {
1426 let escaped = s
1428 .replace('\\', "\\\\")
1429 .replace('\'', "\\'")
1430 .replace('\n', "\\n")
1431 .replace('\r', "\\r")
1432 .replace('\t', "\\t");
1433 format!("'{escaped}'")
1434 } else {
1435 format!("\"{}\"", escape_python(s))
1436 }
1437}
1438
1439fn emit_python_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1441 let params = match method_name {
1442 "visit_link" => "self, ctx, href, text, title",
1443 "visit_image" => "self, ctx, src, alt, title",
1444 "visit_heading" => "self, ctx, level, text, id",
1445 "visit_code_block" => "self, ctx, lang, code",
1446 "visit_code_inline"
1447 | "visit_strong"
1448 | "visit_emphasis"
1449 | "visit_strikethrough"
1450 | "visit_underline"
1451 | "visit_subscript"
1452 | "visit_superscript"
1453 | "visit_mark"
1454 | "visit_button"
1455 | "visit_summary"
1456 | "visit_figcaption"
1457 | "visit_definition_term"
1458 | "visit_definition_description" => "self, ctx, text",
1459 "visit_text" => "self, ctx, text",
1460 "visit_list_item" => "self, ctx, ordered, marker, text",
1461 "visit_blockquote" => "self, ctx, content, depth",
1462 "visit_table_row" => "self, ctx, cells, is_header",
1463 "visit_custom_element" => "self, ctx, tag_name, html",
1464 "visit_form" => "self, ctx, action_url, method",
1465 "visit_input" => "self, ctx, input_type, name, value",
1466 "visit_audio" | "visit_video" | "visit_iframe" => "self, ctx, src",
1467 "visit_details" => "self, ctx, is_open",
1468 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
1469 "self, ctx, output, *args"
1470 }
1471 "visit_list_start" => "self, ctx, ordered, *args",
1472 "visit_list_end" => "self, ctx, ordered, output, *args",
1473 _ => "self, ctx, *args",
1474 };
1475
1476 let _ = writeln!(
1477 out,
1478 " def {method_name}({params}): # noqa: A002, ANN001, ANN202, ARG002"
1479 );
1480 match action {
1481 CallbackAction::Skip => {
1482 let _ = writeln!(out, " return \"skip\"");
1483 }
1484 CallbackAction::Continue => {
1485 let _ = writeln!(out, " return \"continue\"");
1486 }
1487 CallbackAction::PreserveHtml => {
1488 let _ = writeln!(out, " return \"preserve_html\"");
1489 }
1490 CallbackAction::Custom { output } => {
1491 let escaped = escape_python(output);
1492 let _ = writeln!(out, " return {{\"custom\": \"{escaped}\"}}");
1493 }
1494 CallbackAction::CustomTemplate { template } => {
1495 let escaped_template = template
1500 .replace('\\', "\\\\")
1501 .replace('\'', "\\'")
1502 .replace('\n', "\\n")
1503 .replace('\r', "\\r")
1504 .replace('\t', "\\t");
1505 let _ = writeln!(out, " return {{\"custom\": f'{escaped_template}'}}");
1506 }
1507 }
1508}