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".to_string());
386 }
387
388 let has_non_http_fixtures = fixtures.iter().any(|f| !f.is_http_test());
390 if has_non_http_fixtures {
391 let handle_constructors: Vec<String> = e2e_config
393 .call
394 .args
395 .iter()
396 .filter(|arg| arg.arg_type == "handle")
397 .map(|arg| format!("create_{}", arg.name.to_snake_case()))
398 .collect();
399
400 let mut import_names: Vec<String> = Vec::new();
404 for fixture in fixtures.iter() {
405 let cc = e2e_config.resolve_call(fixture.call.as_deref());
406 let fn_name = resolve_function_name_for_call(cc);
407 if !import_names.contains(&fn_name) {
408 import_names.push(fn_name);
409 }
410 }
411 if import_names.is_empty() {
414 import_names.push(function_name.clone());
415 }
416 for ctor in &handle_constructors {
417 if !import_names.contains(ctor) {
418 import_names.push(ctor.clone());
419 }
420 }
421
422 let needs_config_import = e2e_config.call.args.iter().any(|arg| {
424 arg.arg_type == "handle"
425 && fixtures.iter().any(|f| {
426 let val = resolve_field(&f.input, &arg.field);
427 !val.is_null() && val.as_object().is_some_and(|o| !o.is_empty())
428 })
429 });
430 if needs_config_import {
431 let config_class = options_type.as_deref().unwrap_or("CrawlConfig");
432 if !import_names.contains(&config_class.to_string()) {
433 import_names.push(config_class.to_string());
434 }
435 }
436
437 if !handle_nested_types.is_empty() {
439 let mut used_nested_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
440 for fixture in fixtures.iter() {
441 for arg in &e2e_config.call.args {
442 if arg.arg_type == "handle" {
443 let config_value = resolve_field(&fixture.input, &arg.field);
444 if let Some(obj) = config_value.as_object() {
445 for key in obj.keys() {
446 if let Some(type_name) = handle_nested_types.get(key) {
447 if obj[key].is_object() {
448 used_nested_types.insert(type_name.clone());
449 }
450 }
451 }
452 }
453 }
454 }
455 }
456 for type_name in used_nested_types {
457 if !import_names.contains(&type_name) {
458 import_names.push(type_name);
459 }
460 }
461 }
462
463 for fixture in fixtures.iter() {
465 for assertion in &fixture.assertions {
466 if assertion.assertion_type == "method_result" {
467 if let Some(method_name) = &assertion.method {
468 let import = python_method_helper_import(method_name);
469 if let Some(name) = import {
470 if !import_names.contains(&name) {
471 import_names.push(name);
472 }
473 }
474 }
475 }
476 }
477 }
478
479 if let (true, Some(opts_type)) = (needs_options_type, &options_type) {
480 import_names.push(opts_type.clone());
481 thirdparty_from.push(format!("from {module} import {}", import_names.join(", ")));
482 if !used_enum_types.is_empty() {
484 let enum_mod = e2e_config
485 .call
486 .overrides
487 .get("python")
488 .and_then(|o| o.enum_module.as_deref())
489 .unwrap_or(&module);
490 let enum_names: Vec<&String> = used_enum_types.iter().collect();
491 thirdparty_from.push(format!(
492 "from {enum_mod} import {}",
493 enum_names.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
494 ));
495 }
496 } else {
497 thirdparty_from.push(format!("from {module} import {}", import_names.join(", ")));
498 }
499 }
500
501 stdlib_imports.sort();
502 thirdparty_bare.sort();
503 thirdparty_from.sort();
504
505 if !stdlib_imports.is_empty() {
507 for imp in &stdlib_imports {
508 let _ = writeln!(out, "{imp}");
509 }
510 let _ = writeln!(out);
511 }
512 for imp in &thirdparty_bare {
514 let _ = writeln!(out, "{imp}");
515 }
516 for imp in &thirdparty_from {
517 let _ = writeln!(out, "{imp}");
518 }
519 let _ = writeln!(out);
521 let _ = writeln!(out);
522
523 for fixture in fixtures {
524 if fixture.is_http_test() {
525 render_http_test_function(&mut out, fixture);
526 } else {
527 render_test_function(
528 &mut out,
529 fixture,
530 e2e_config,
531 options_type.as_deref(),
532 options_via,
533 enum_fields,
534 handle_nested_types,
535 handle_dict_types,
536 &field_resolver,
537 );
538 }
539 let _ = writeln!(out);
540 }
541
542 out
543}
544
545fn render_http_test_function(out: &mut String, fixture: &Fixture) {
556 let Some(http) = &fixture.http else {
557 return;
558 };
559
560 let fn_name = sanitize_ident(&fixture.id);
561 let description = &fixture.description;
562 let desc_with_period = if description.ends_with('.') {
563 description.to_string()
564 } else {
565 format!("{description}.")
566 };
567
568 if is_skipped(fixture, "python") {
569 let reason = fixture
570 .skip
571 .as_ref()
572 .and_then(|s| s.reason.as_deref())
573 .unwrap_or("skipped for python");
574 let escaped = escape_python(reason);
575 let _ = writeln!(out, "@pytest.mark.skip(reason=\"{escaped}\")");
576 }
577
578 let _ = writeln!(out, "def test_{fn_name}(client) -> None:");
579 let _ = writeln!(out, " \"\"\"{desc_with_period}\"\"\"");
580
581 let method = http.request.method.to_lowercase();
583 let path = &http.request.path;
584
585 let mut call_kwargs: Vec<String> = Vec::new();
587
588 if let Some(body) = &http.request.body {
590 let py_body = json_to_python_literal(body);
591 call_kwargs.push(format!(" json={py_body},"));
592 }
593
594 if !http.request.headers.is_empty() {
596 let entries: Vec<String> = http
597 .request
598 .headers
599 .iter()
600 .map(|(k, v)| format!(" \"{}\": \"{}\",", escape_python(k), escape_python(v)))
601 .collect();
602 let headers_block = entries.join("\n");
603 call_kwargs.push(format!(" headers={{\n{headers_block}\n }},"));
604 }
605
606 if !http.request.query_params.is_empty() {
608 let entries: Vec<String> = http
609 .request
610 .query_params
611 .iter()
612 .map(|(k, v)| format!(" \"{}\": {},", escape_python(k), json_to_python_literal(v)))
613 .collect();
614 let params_block = entries.join("\n");
615 call_kwargs.push(format!(" params={{\n{params_block}\n }},"));
616 }
617
618 if !http.request.cookies.is_empty() {
620 let entries: Vec<String> = http
621 .request
622 .cookies
623 .iter()
624 .map(|(k, v)| format!(" \"{}\": \"{}\",", escape_python(k), escape_python(v)))
625 .collect();
626 let cookies_block = entries.join("\n");
627 call_kwargs.push(format!(" cookies={{\n{cookies_block}\n }},"));
628 }
629
630 if call_kwargs.is_empty() {
631 let _ = writeln!(out, " response = client.{method}(\"{path}\")");
632 } else {
633 let _ = writeln!(out, " response = client.{method}(");
634 let _ = writeln!(out, " \"{path}\",");
635 for kwarg in &call_kwargs {
636 let _ = writeln!(out, "{kwarg}");
637 }
638 let _ = writeln!(out, " )");
639 }
640
641 let status = http.expected_response.status_code;
643 let _ = writeln!(out, " assert response.status_code == {status} # noqa: S101");
644
645 if let Some(expected_body) = &http.expected_response.body {
647 let py_val = json_to_python_literal(expected_body);
648 let _ = writeln!(out, " data = response.json()");
649 let _ = writeln!(out, " assert data == {py_val} # noqa: S101");
650 } else if let Some(partial) = &http.expected_response.body_partial {
651 let _ = writeln!(out, " data = response.json()");
652 if let Some(obj) = partial.as_object() {
653 for (key, val) in obj {
654 let py_val = json_to_python_literal(val);
655 let escaped_key = escape_python(key);
656 let _ = writeln!(out, " assert data[\"{escaped_key}\"] == {py_val} # noqa: S101");
657 }
658 }
659 }
660
661 for (header_name, header_value) in &http.expected_response.headers {
663 let lower_name = header_name.to_lowercase();
664 let escaped_name = escape_python(&lower_name);
665 match header_value.as_str() {
666 "<<present>>" => {
667 let _ = writeln!(out, " assert \"{escaped_name}\" in response.headers # noqa: S101");
668 }
669 "<<absent>>" => {
670 let _ = writeln!(
671 out,
672 " assert response.headers.get(\"{escaped_name}\") is None # noqa: S101"
673 );
674 }
675 "<<uuid>>" => {
676 let _ = writeln!(
677 out,
678 " 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"
679 );
680 }
681 exact => {
682 let escaped_val = escape_python(exact);
683 let _ = writeln!(
684 out,
685 " assert response.headers[\"{escaped_name}\"] == \"{escaped_val}\" # noqa: S101"
686 );
687 }
688 }
689 }
690
691 if let Some(validation_errors) = &http.expected_response.validation_errors {
693 if !validation_errors.is_empty() {
694 let _ = writeln!(out, " errors = response.json().get(\"detail\", [])");
695 for ve in validation_errors {
696 let loc_py: Vec<String> = ve.loc.iter().map(|s| format!("\"{}\"", escape_python(s))).collect();
697 let loc_str = loc_py.join(", ");
698 let escaped_msg = escape_python(&ve.msg);
699 let _ = writeln!(
700 out,
701 " assert any(e[\"loc\"] == [{loc_str}] and \"{escaped_msg}\" in e[\"msg\"] for e in errors) # noqa: S101"
702 );
703 }
704 }
705 }
706}
707
708#[allow(clippy::too_many_arguments)]
713fn render_test_function(
714 out: &mut String,
715 fixture: &Fixture,
716 e2e_config: &E2eConfig,
717 options_type: Option<&str>,
718 options_via: &str,
719 enum_fields: &HashMap<String, String>,
720 handle_nested_types: &HashMap<String, String>,
721 handle_dict_types: &std::collections::HashSet<String>,
722 field_resolver: &FieldResolver,
723) {
724 let fn_name = sanitize_ident(&fixture.id);
725 let description = &fixture.description;
726 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
727 let function_name = resolve_function_name_for_call(call_config);
728 let result_var = &call_config.result_var;
729
730 let desc_with_period = if description.ends_with('.') {
731 description.to_string()
732 } else {
733 format!("{description}.")
734 };
735
736 if is_skipped(fixture, "python") {
738 let reason = fixture
739 .skip
740 .as_ref()
741 .and_then(|s| s.reason.as_deref())
742 .unwrap_or("skipped for python");
743 let escaped = escape_python(reason);
744 let _ = writeln!(out, "@pytest.mark.skip(reason=\"{escaped}\")");
745 }
746
747 let is_async = call_config.r#async;
748 if is_async {
749 let _ = writeln!(out, "@pytest.mark.asyncio");
750 let _ = writeln!(out, "async def test_{fn_name}() -> None:");
751 } else {
752 let _ = writeln!(out, "def test_{fn_name}() -> None:");
753 }
754 let _ = writeln!(out, " \"\"\"{desc_with_period}\"\"\"");
755
756 let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
758
759 let mut arg_bindings = Vec::new();
761 let mut kwarg_exprs = Vec::new();
762 for arg in &call_config.args {
763 let var_name = &arg.name;
764
765 if arg.arg_type == "handle" {
766 let constructor_name = format!("create_{}", arg.name.to_snake_case());
769 let config_value = resolve_field(&fixture.input, &arg.field);
770 if config_value.is_null()
771 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
772 {
773 arg_bindings.push(format!(" {var_name} = {constructor_name}(None)"));
774 } else if let Some(obj) = config_value.as_object() {
775 let kwargs: Vec<String> = obj
779 .iter()
780 .map(|(k, v)| {
781 let snake_key = k.to_snake_case();
782 let py_val = if let Some(type_name) = handle_nested_types.get(k) {
783 if let Some(nested_obj) = v.as_object() {
785 if nested_obj.is_empty() {
786 format!("{type_name}()")
788 } else if handle_dict_types.contains(k) {
789 json_to_python_literal(v)
794 } else {
795 let nested_kwargs: Vec<String> = nested_obj
797 .iter()
798 .map(|(nk, nv)| {
799 let nested_snake_key = nk.to_snake_case();
800 format!("{nested_snake_key}={}", json_to_python_literal(nv))
801 })
802 .collect();
803 format!("{type_name}({})", nested_kwargs.join(", "))
804 }
805 } else {
806 json_to_python_literal(v)
808 }
809 } else if k == "request_timeout" {
810 if let Some(ms) = v.as_u64() {
816 format!("{}", ms / 1000)
817 } else {
818 json_to_python_literal(v)
819 }
820 } else {
821 json_to_python_literal(v)
822 };
823 format!("{snake_key}={py_val}")
824 })
825 .collect();
826 let config_class = options_type.unwrap_or("CrawlConfig");
828 let single_line = format!(" {var_name}_config = {config_class}({})", kwargs.join(", "));
829 if single_line.len() <= 120 {
830 arg_bindings.push(single_line);
831 } else {
832 let mut lines = format!(" {var_name}_config = {config_class}(\n");
834 for kw in &kwargs {
835 lines.push_str(&format!(" {kw},\n"));
836 }
837 lines.push_str(" )");
838 arg_bindings.push(lines);
839 }
840 arg_bindings.push(format!(" {var_name} = {constructor_name}({var_name}_config)"));
841 } else {
842 let literal = json_to_python_literal(config_value);
843 arg_bindings.push(format!(" {var_name} = {constructor_name}({literal})"));
844 }
845 kwarg_exprs.push(format!("{var_name}={var_name}"));
846 continue;
847 }
848
849 if arg.arg_type == "mock_url" {
850 let fixture_id = &fixture.id;
851 arg_bindings.push(format!(
852 " {var_name} = os.environ['MOCK_SERVER_URL'] + '/fixtures/{fixture_id}'"
853 ));
854 kwarg_exprs.push(format!("{var_name}={var_name}"));
855 continue;
856 }
857
858 let value = resolve_field(&fixture.input, &arg.field);
859
860 if value.is_null() && arg.optional {
861 continue;
862 }
863
864 if arg.arg_type == "json_object" && !value.is_null() {
866 match options_via {
867 "dict" => {
868 let literal = json_to_python_literal(value);
870 let noqa = if literal.contains("/tmp/") {
871 " # noqa: S108"
872 } else {
873 ""
874 };
875 arg_bindings.push(format!(" {var_name} = {literal}{noqa}"));
876 kwarg_exprs.push(format!("{var_name}={var_name}"));
877 continue;
878 }
879 "json" => {
880 let json_str = serde_json::to_string(value).unwrap_or_default();
882 let escaped = escape_python(&json_str);
883 arg_bindings.push(format!(" {var_name} = json.loads(\"{escaped}\")"));
884 kwarg_exprs.push(format!("{var_name}={var_name}"));
885 continue;
886 }
887 _ => {
888 if let (Some(opts_type), Some(obj)) = (options_type, value.as_object()) {
890 let kwargs: Vec<String> = obj
891 .iter()
892 .map(|(k, v)| {
893 let snake_key = k.to_snake_case();
894 let py_val = if let Some(enum_type) = enum_fields.get(k) {
895 if let Some(s) = v.as_str() {
897 let upper_val = s.to_shouty_snake_case();
898 format!("{enum_type}.{upper_val}")
899 } else {
900 json_to_python_literal(v)
901 }
902 } else {
903 json_to_python_literal(v)
904 };
905 format!("{snake_key}={py_val}")
906 })
907 .collect();
908 let constructor = format!("{opts_type}({})", kwargs.join(", "));
909 arg_bindings.push(format!(" {var_name} = {constructor}"));
910 kwarg_exprs.push(format!("{var_name}={var_name}"));
911 continue;
912 }
913 }
914 }
915 }
916
917 if value.is_null() && !arg.optional {
919 let default_val = match arg.arg_type.as_str() {
920 "string" => "\"\"".to_string(),
921 "int" | "integer" => "0".to_string(),
922 "float" | "number" => "0.0".to_string(),
923 "bool" | "boolean" => "False".to_string(),
924 _ => "None".to_string(),
925 };
926 arg_bindings.push(format!(" {var_name} = {default_val}"));
927 kwarg_exprs.push(format!("{var_name}={var_name}"));
928 continue;
929 }
930
931 let literal = json_to_python_literal(value);
932 let noqa = if literal.contains("/tmp/") {
933 " # noqa: S108"
934 } else {
935 ""
936 };
937 arg_bindings.push(format!(" {var_name} = {literal}{noqa}"));
938 kwarg_exprs.push(format!("{var_name}={var_name}"));
939 }
940
941 if let Some(visitor_spec) = &fixture.visitor {
943 let _ = writeln!(out, " class _TestVisitor:");
944 for (method_name, action) in &visitor_spec.callbacks {
945 emit_python_visitor_method(out, method_name, action);
946 }
947 kwarg_exprs.push("visitor=_TestVisitor()".to_string());
948 }
949
950 for binding in &arg_bindings {
951 let _ = writeln!(out, "{binding}");
952 }
953
954 let call_args = kwarg_exprs.join(", ");
955 let await_prefix = if is_async { "await " } else { "" };
956 let call_expr = format!("{await_prefix}{function_name}({call_args})");
957
958 if has_error_assertion {
959 let error_assertion = fixture.assertions.iter().find(|a| a.assertion_type == "error");
961 let has_message = error_assertion
962 .and_then(|a| a.value.as_ref())
963 .and_then(|v| v.as_str())
964 .is_some();
965
966 if has_message {
967 let _ = writeln!(out, " with pytest.raises(Exception) as exc_info: # noqa: B017");
968 let _ = writeln!(out, " {call_expr}");
969 if let Some(msg) = error_assertion.and_then(|a| a.value.as_ref()).and_then(|v| v.as_str()) {
970 let escaped = escape_python(msg);
971 let _ = writeln!(out, " assert \"{escaped}\" in str(exc_info.value) # noqa: S101");
972 }
973 } else {
974 let _ = writeln!(out, " with pytest.raises(Exception): # noqa: B017");
975 let _ = writeln!(out, " {call_expr}");
976 }
977
978 return;
981 }
982
983 let has_usable_assertion = fixture.assertions.iter().any(|a| {
985 if a.assertion_type == "not_error" || a.assertion_type == "error" {
986 return false;
987 }
988 match &a.field {
989 Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
990 _ => true,
991 }
992 });
993 let py_result_var = if has_usable_assertion {
994 result_var.to_string()
995 } else {
996 "_".to_string()
997 };
998 let _ = writeln!(out, " {py_result_var} = {call_expr}");
999
1000 let fields_enum = &e2e_config.fields_enum;
1001 for assertion in &fixture.assertions {
1002 if assertion.assertion_type == "not_error" {
1003 continue;
1005 }
1006 render_assertion(out, assertion, result_var, field_resolver, fields_enum);
1007 }
1008}
1009
1010fn json_to_python_literal(value: &serde_json::Value) -> String {
1015 match value {
1016 serde_json::Value::Null => "None".to_string(),
1017 serde_json::Value::Bool(true) => "True".to_string(),
1018 serde_json::Value::Bool(false) => "False".to_string(),
1019 serde_json::Value::Number(n) => n.to_string(),
1020 serde_json::Value::String(s) => python_string_literal(s),
1021 serde_json::Value::Array(arr) => {
1022 let items: Vec<String> = arr.iter().map(json_to_python_literal).collect();
1023 format!("[{}]", items.join(", "))
1024 }
1025 serde_json::Value::Object(map) => {
1026 let items: Vec<String> = map
1027 .iter()
1028 .map(|(k, v)| format!("\"{}\": {}", escape_python(k), json_to_python_literal(v)))
1029 .collect();
1030 format!("{{{}}}", items.join(", "))
1031 }
1032 }
1033}
1034
1035fn render_assertion(
1040 out: &mut String,
1041 assertion: &Assertion,
1042 result_var: &str,
1043 field_resolver: &FieldResolver,
1044 fields_enum: &std::collections::HashSet<String>,
1045) {
1046 if let Some(f) = &assertion.field {
1048 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1049 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
1050 return;
1051 }
1052 }
1053
1054 let field_access = match &assertion.field {
1055 Some(f) if !f.is_empty() => field_resolver.accessor(f, "python", result_var),
1056 _ => result_var.to_string(),
1057 };
1058
1059 let field_is_enum = assertion.field.as_deref().is_some_and(|f| {
1070 if fields_enum.contains(f) {
1071 return true;
1072 }
1073 let resolved = field_resolver.resolve(f);
1074 if fields_enum.contains(resolved) {
1075 return true;
1076 }
1077 field_resolver.accessor(f, "python", result_var).contains("[0]")
1082 });
1083
1084 let field_is_optional = match &assertion.field {
1087 Some(f) if !f.is_empty() => {
1088 let resolved = field_resolver.resolve(f);
1089 field_resolver.is_optional(resolved)
1090 }
1091 _ => false,
1092 };
1093
1094 match assertion.assertion_type.as_str() {
1095 "error" | "not_error" => {
1096 }
1098 "equals" => {
1099 if let Some(val) = &assertion.value {
1100 let expected = value_to_python_string(val);
1101 let op = if val.is_boolean() || val.is_null() { "is" } else { "==" };
1103 if val.is_string() {
1106 let _ = writeln!(out, " assert {field_access}.strip() {op} {expected} # noqa: S101");
1107 } else {
1108 let _ = writeln!(out, " assert {field_access} {op} {expected} # noqa: S101");
1109 }
1110 }
1111 }
1112 "contains" => {
1113 if let Some(val) = &assertion.value {
1114 let expected = value_to_python_string(val);
1115 let cmp_expr = if field_is_enum && val.is_string() {
1117 format!("str({field_access}).lower()")
1118 } else {
1119 field_access.clone()
1120 };
1121 if field_is_optional {
1122 let _ = writeln!(out, " assert {field_access} is not None # noqa: S101");
1123 let _ = writeln!(out, " assert {expected} in {cmp_expr} # noqa: S101");
1124 } else {
1125 let _ = writeln!(out, " assert {expected} in {cmp_expr} # noqa: S101");
1126 }
1127 }
1128 }
1129 "contains_all" => {
1130 if let Some(values) = &assertion.values {
1131 for val in values {
1132 let expected = value_to_python_string(val);
1133 let cmp_expr = if field_is_enum && val.is_string() {
1135 format!("str({field_access}).lower()")
1136 } else {
1137 field_access.clone()
1138 };
1139 if field_is_optional {
1140 let _ = writeln!(out, " assert {field_access} is not None # noqa: S101");
1141 let _ = writeln!(out, " assert {expected} in {cmp_expr} # noqa: S101");
1142 } else {
1143 let _ = writeln!(out, " assert {expected} in {cmp_expr} # noqa: S101");
1144 }
1145 }
1146 }
1147 }
1148 "not_contains" => {
1149 if let Some(val) = &assertion.value {
1150 let expected = value_to_python_string(val);
1151 let cmp_expr = if field_is_enum && val.is_string() {
1153 format!("str({field_access}).lower()")
1154 } else {
1155 field_access.clone()
1156 };
1157 if field_is_optional {
1158 let _ = writeln!(
1159 out,
1160 " assert {field_access} is None or {expected} not in {cmp_expr} # noqa: S101"
1161 );
1162 } else {
1163 let _ = writeln!(out, " assert {expected} not in {cmp_expr} # noqa: S101");
1164 }
1165 }
1166 }
1167 "not_empty" => {
1168 let _ = writeln!(out, " assert {field_access} # noqa: S101");
1169 }
1170 "is_empty" => {
1171 let _ = writeln!(out, " assert not {field_access} # noqa: S101");
1172 }
1173 "contains_any" => {
1174 if let Some(values) = &assertion.values {
1175 let items: Vec<String> = values.iter().map(value_to_python_string).collect();
1176 let list_str = items.join(", ");
1177 let cmp_expr = if field_is_enum {
1179 format!("str({field_access}).lower()")
1180 } else {
1181 field_access.clone()
1182 };
1183 if field_is_optional {
1184 let _ = writeln!(out, " assert {field_access} is not None # noqa: S101");
1185 let _ = writeln!(
1186 out,
1187 " assert any(v in {cmp_expr} for v in [{list_str}]) # noqa: S101"
1188 );
1189 } else {
1190 let _ = writeln!(
1191 out,
1192 " assert any(v in {cmp_expr} for v in [{list_str}]) # noqa: S101"
1193 );
1194 }
1195 }
1196 }
1197 "greater_than" => {
1198 if let Some(val) = &assertion.value {
1199 let expected = value_to_python_string(val);
1200 let _ = writeln!(out, " assert {field_access} > {expected} # noqa: S101");
1201 }
1202 }
1203 "less_than" => {
1204 if let Some(val) = &assertion.value {
1205 let expected = value_to_python_string(val);
1206 let _ = writeln!(out, " assert {field_access} < {expected} # noqa: S101");
1207 }
1208 }
1209 "greater_than_or_equal" | "min" => {
1210 if let Some(val) = &assertion.value {
1211 let expected = value_to_python_string(val);
1212 let _ = writeln!(out, " assert {field_access} >= {expected} # noqa: S101");
1213 }
1214 }
1215 "less_than_or_equal" | "max" => {
1216 if let Some(val) = &assertion.value {
1217 let expected = value_to_python_string(val);
1218 let _ = writeln!(out, " assert {field_access} <= {expected} # noqa: S101");
1219 }
1220 }
1221 "starts_with" => {
1222 if let Some(val) = &assertion.value {
1223 let expected = value_to_python_string(val);
1224 let _ = writeln!(out, " assert {field_access}.startswith({expected}) # noqa: S101");
1225 }
1226 }
1227 "ends_with" => {
1228 if let Some(val) = &assertion.value {
1229 let expected = value_to_python_string(val);
1230 let _ = writeln!(out, " assert {field_access}.endswith({expected}) # noqa: S101");
1231 }
1232 }
1233 "min_length" => {
1234 if let Some(val) = &assertion.value {
1235 if let Some(n) = val.as_u64() {
1236 let _ = writeln!(out, " assert len({field_access}) >= {n} # noqa: S101");
1237 }
1238 }
1239 }
1240 "max_length" => {
1241 if let Some(val) = &assertion.value {
1242 if let Some(n) = val.as_u64() {
1243 let _ = writeln!(out, " assert len({field_access}) <= {n} # noqa: S101");
1244 }
1245 }
1246 }
1247 "count_min" => {
1248 if let Some(val) = &assertion.value {
1249 if let Some(n) = val.as_u64() {
1250 let _ = writeln!(out, " assert len({field_access}) >= {n} # noqa: S101");
1251 }
1252 }
1253 }
1254 "count_equals" => {
1255 if let Some(val) = &assertion.value {
1256 if let Some(n) = val.as_u64() {
1257 let _ = writeln!(out, " assert len({field_access}) == {n} # noqa: S101");
1258 }
1259 }
1260 }
1261 "is_true" => {
1262 let _ = writeln!(out, " assert {field_access} is True # noqa: S101");
1263 }
1264 "is_false" => {
1265 let _ = writeln!(out, " assert not {field_access} # noqa: S101");
1266 }
1267 "method_result" => {
1268 if let Some(method_name) = &assertion.method {
1269 let call_expr = build_python_method_call(result_var, method_name, assertion.args.as_ref());
1270 let check = assertion.check.as_deref().unwrap_or("is_true");
1271 match check {
1272 "equals" => {
1273 if let Some(val) = &assertion.value {
1274 if val.is_boolean() {
1275 if val.as_bool() == Some(true) {
1276 let _ = writeln!(out, " assert {call_expr} is True # noqa: S101");
1277 } else {
1278 let _ = writeln!(out, " assert {call_expr} is False # noqa: S101");
1279 }
1280 } else {
1281 let expected = value_to_python_string(val);
1282 let _ = writeln!(out, " assert {call_expr} == {expected} # noqa: S101");
1283 }
1284 }
1285 }
1286 "is_true" => {
1287 let _ = writeln!(out, " assert {call_expr} # noqa: S101");
1288 }
1289 "is_false" => {
1290 let _ = writeln!(out, " assert not {call_expr} # noqa: S101");
1291 }
1292 "greater_than_or_equal" => {
1293 if let Some(val) = &assertion.value {
1294 let n = val.as_u64().unwrap_or(0);
1295 let _ = writeln!(out, " assert {call_expr} >= {n} # noqa: S101");
1296 }
1297 }
1298 "count_min" => {
1299 if let Some(val) = &assertion.value {
1300 let n = val.as_u64().unwrap_or(0);
1301 let _ = writeln!(out, " assert len({call_expr}) >= {n} # noqa: S101");
1302 }
1303 }
1304 "contains" => {
1305 if let Some(val) = &assertion.value {
1306 let expected = value_to_python_string(val);
1307 let _ = writeln!(out, " assert {expected} in {call_expr} # noqa: S101");
1308 }
1309 }
1310 "is_error" => {
1311 let _ = writeln!(out, " with pytest.raises(Exception): # noqa: B017");
1312 let _ = writeln!(out, " {call_expr}");
1313 }
1314 other_check => {
1315 panic!("unsupported method_result check type: {other_check}");
1316 }
1317 }
1318 } else {
1319 panic!("method_result assertion missing 'method' field");
1320 }
1321 }
1322 "matches_regex" => {
1323 if let Some(val) = &assertion.value {
1324 let expected = value_to_python_string(val);
1325 let _ = writeln!(out, " import re # noqa: PLC0415");
1326 let _ = writeln!(
1327 out,
1328 " assert re.search({expected}, {field_access}) is not None # noqa: S101"
1329 );
1330 }
1331 }
1332 other => {
1333 panic!("unsupported assertion type: {other}");
1334 }
1335 }
1336}
1337
1338fn build_python_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
1341 match method_name {
1342 "root_child_count" => format!("{result_var}.root_node().child_count()"),
1343 "root_node_type" => format!("{result_var}.root_node().kind()"),
1344 "named_children_count" => format!("{result_var}.root_node().named_child_count()"),
1345 "has_error_nodes" => format!("tree_has_error_nodes({result_var})"),
1346 "error_count" | "tree_error_count" => format!("tree_error_count({result_var})"),
1347 "tree_to_sexp" => format!("tree_to_sexp({result_var})"),
1348 "contains_node_type" => {
1349 let node_type = args
1350 .and_then(|a| a.get("node_type"))
1351 .and_then(|v| v.as_str())
1352 .unwrap_or("");
1353 format!("tree_contains_node_type({result_var}, \"{node_type}\")")
1354 }
1355 "find_nodes_by_type" => {
1356 let node_type = args
1357 .and_then(|a| a.get("node_type"))
1358 .and_then(|v| v.as_str())
1359 .unwrap_or("");
1360 format!("find_nodes_by_type({result_var}, \"{node_type}\")")
1361 }
1362 "run_query" => {
1363 let query_source = args
1364 .and_then(|a| a.get("query_source"))
1365 .and_then(|v| v.as_str())
1366 .unwrap_or("");
1367 let language = args
1368 .and_then(|a| a.get("language"))
1369 .and_then(|v| v.as_str())
1370 .unwrap_or("");
1371 format!("run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1372 }
1373 _ => {
1374 if let Some(args_val) = args {
1375 let arg_str = args_val
1376 .as_object()
1377 .map(|obj| {
1378 obj.iter()
1379 .map(|(k, v)| format!("{}={}", k, value_to_python_string(v)))
1380 .collect::<Vec<_>>()
1381 .join(", ")
1382 })
1383 .unwrap_or_default();
1384 format!("{result_var}.{method_name}({arg_str})")
1385 } else {
1386 format!("{result_var}.{method_name}()")
1387 }
1388 }
1389 }
1390}
1391
1392fn python_method_helper_import(method_name: &str) -> Option<String> {
1395 match method_name {
1396 "has_error_nodes" => Some("tree_has_error_nodes".to_string()),
1397 "error_count" | "tree_error_count" => Some("tree_error_count".to_string()),
1398 "tree_to_sexp" => Some("tree_to_sexp".to_string()),
1399 "contains_node_type" => Some("tree_contains_node_type".to_string()),
1400 "find_nodes_by_type" => Some("find_nodes_by_type".to_string()),
1401 "run_query" => Some("run_query".to_string()),
1402 _ => None,
1404 }
1405}
1406
1407fn value_to_python_string(value: &serde_json::Value) -> String {
1408 match value {
1409 serde_json::Value::String(s) => python_string_literal(s),
1410 serde_json::Value::Bool(true) => "True".to_string(),
1411 serde_json::Value::Bool(false) => "False".to_string(),
1412 serde_json::Value::Number(n) => n.to_string(),
1413 serde_json::Value::Null => "None".to_string(),
1414 other => python_string_literal(&other.to_string()),
1415 }
1416}
1417
1418fn python_string_literal(s: &str) -> String {
1421 if s.contains('"') && !s.contains('\'') {
1422 let escaped = s
1424 .replace('\\', "\\\\")
1425 .replace('\'', "\\'")
1426 .replace('\n', "\\n")
1427 .replace('\r', "\\r")
1428 .replace('\t', "\\t");
1429 format!("'{escaped}'")
1430 } else {
1431 format!("\"{}\"", escape_python(s))
1432 }
1433}
1434
1435fn emit_python_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1437 let params = match method_name {
1438 "visit_link" => "self, ctx, href, text, title",
1439 "visit_image" => "self, ctx, src, alt, title",
1440 "visit_heading" => "self, ctx, level, text, id",
1441 "visit_code_block" => "self, ctx, lang, code",
1442 "visit_code_inline"
1443 | "visit_strong"
1444 | "visit_emphasis"
1445 | "visit_strikethrough"
1446 | "visit_underline"
1447 | "visit_subscript"
1448 | "visit_superscript"
1449 | "visit_mark"
1450 | "visit_button"
1451 | "visit_summary"
1452 | "visit_figcaption"
1453 | "visit_definition_term"
1454 | "visit_definition_description" => "self, ctx, text",
1455 "visit_text" => "self, ctx, text",
1456 "visit_list_item" => "self, ctx, ordered, marker, text",
1457 "visit_blockquote" => "self, ctx, content, depth",
1458 "visit_table_row" => "self, ctx, cells, is_header",
1459 "visit_custom_element" => "self, ctx, tag_name, html",
1460 "visit_form" => "self, ctx, action_url, method",
1461 "visit_input" => "self, ctx, input_type, name, value",
1462 "visit_audio" | "visit_video" | "visit_iframe" => "self, ctx, src",
1463 "visit_details" => "self, ctx, is_open",
1464 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
1465 "self, ctx, output, *args"
1466 }
1467 "visit_list_start" => "self, ctx, ordered, *args",
1468 "visit_list_end" => "self, ctx, ordered, output, *args",
1469 _ => "self, ctx, *args",
1470 };
1471
1472 let _ = writeln!(
1473 out,
1474 " def {method_name}({params}): # noqa: A002, ANN001, ANN202, ARG002"
1475 );
1476 match action {
1477 CallbackAction::Skip => {
1478 let _ = writeln!(out, " return \"skip\"");
1479 }
1480 CallbackAction::Continue => {
1481 let _ = writeln!(out, " return \"continue\"");
1482 }
1483 CallbackAction::PreserveHtml => {
1484 let _ = writeln!(out, " return \"preserve_html\"");
1485 }
1486 CallbackAction::Custom { output } => {
1487 let escaped = escape_python(output);
1488 let _ = writeln!(out, " return {{\"custom\": \"{escaped}\"}}");
1489 }
1490 CallbackAction::CustomTemplate { template } => {
1491 let escaped_template = template
1496 .replace('\\', "\\\\")
1497 .replace('\'', "\\'")
1498 .replace('\n', "\\n")
1499 .replace('\r', "\\r")
1500 .replace('\t', "\\t");
1501 let _ = writeln!(out, " return {{\"custom\": f'{escaped_template}'}}");
1502 }
1503 }
1504}