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