use anyhow::Result;
use crate::core::config::ResolvedCrateConfig;
use crate::e2e::config::E2eConfig;
use crate::e2e::fixture::Fixture;
use super::helpers;
use super::test_file::render_test_file;
pub(super) fn render_snippet_body(
fixture: &Fixture,
e2e_config: &E2eConfig,
config: &ResolvedCrateConfig,
type_defs: &[crate::core::ir::TypeDef],
enums: &[crate::core::ir::EnumDef],
errors: &[crate::core::ir::ErrorDef],
) -> Result<String> {
let mut call_fixture = fixture.docs_call_fixture();
let expects_error = call_fixture
.assertions
.iter()
.any(|assertion| assertion.assertion_type == "error");
call_fixture.assertions.clear();
call_fixture.mock_response = None;
call_fixture.env = None;
let presentation = crate::e2e::codegen::presentation::resolve(&call_fixture, e2e_config, "python", type_defs);
let call = e2e_config.resolve_call_for_fixture(
call_fixture.call.as_deref(),
&call_fixture.id,
&call_fixture.resolved_category(),
&call_fixture.tags,
&call_fixture.input,
);
let force_bind_result = !expects_error && !call.returns_void;
let test_file = render_test_file(
&fixture.resolved_category(),
&[&call_fixture],
e2e_config,
config,
type_defs,
enums,
&[],
force_bind_result,
);
let (imports, body, is_async) = extract_python_snippet(&test_file)?;
let api_key_var = crate::e2e::fixture::FixtureEnv::api_key_var_or_default(fixture.env.as_ref());
let body = body
.into_iter()
.map(|line| {
line.replace(
"api_key=\"test-key\"",
&format!("api_key=os.environ[\"{api_key_var}\"]"),
)
})
.collect::<Vec<_>>();
let body = match fixture.docs_client().and_then(|client| client.base_url.as_deref()) {
Some(base_url) => {
let bare_call = format!("api_key=os.environ[\"{api_key_var}\"])");
let with_base_url = format!(
"api_key=os.environ[\"{api_key_var}\"], base_url=\"{}\")",
crate::e2e::escape::escape_python(base_url)
);
body.into_iter()
.map(|line| line.replace(&bare_call, &with_base_url))
.collect::<Vec<_>>()
}
None => body,
};
let error_type = config.error_type_name();
let mut imports = imports.into_iter().map(str::to_string).collect::<Vec<_>>();
if body.iter().any(|line| line.contains("os.environ")) && !imports.iter().any(|line| line == "import os") {
imports.push("import os".to_string());
}
let typed_branch = crate::e2e::codegen::snippet_error_branch::for_fixture("python", fixture, errors)
.filter(|branch| branch.host_type != error_type);
if expects_error {
let module = helpers::resolve_module(e2e_config);
let mut names = vec![error_type.clone()];
names.extend(typed_branch.as_ref().map(|branch| branch.host_type.clone()));
names.sort();
imports.push(format!("from {module} import {}", names.join(", ")));
}
Ok(crate::e2e::template_env::render(
"python/snippet_body.py.jinja",
minijinja::context! {
imports => imports, body => body, is_async => is_async, presentation => presentation,
expects_error => expects_error,
error_type => error_type,
typed_error_type => typed_branch.as_ref().map(|branch| branch.host_type.clone()),
result_var => call.effective_result_var(),
returns_void => call.returns_void,
},
))
}
fn extract_python_snippet(rendered: &str) -> Result<(Vec<&str>, Vec<&str>, bool)> {
let lines = rendered.lines().collect::<Vec<_>>();
let signature = lines
.iter()
.position(|line| line.starts_with("async def test_") || line.starts_with("def test_"))
.ok_or_else(|| anyhow::anyhow!("generated Python test did not contain a fixture function"))?;
let imports = lines[..signature]
.iter()
.copied()
.filter(|line| (line.starts_with("from ") || line.starts_with("import ")) && !line.contains("pytest"))
.collect();
let body = lines[signature + 1..]
.iter()
.copied()
.filter_map(|line| line.strip_prefix(" "))
.filter(|line| !line.trim_start().starts_with("\"\"\"") && !line.trim().is_empty())
.collect();
Ok((imports, body, lines[signature].starts_with("async def ")))
}
#[cfg(test)]
mod tests {
use crate::core::config::{NewAlefConfig, ResolvedCrateConfig};
use crate::core::ir::{ErrorDef, ErrorVariant};
use crate::e2e::codegen::E2eCodegen;
use crate::e2e::codegen::python::PythonE2eCodegen;
use crate::e2e::config::E2eConfig;
use crate::e2e::fixture::Fixture;
const CONFIG: &str = r#"
[workspace]
languages = ["python"]
[[crates]]
name = "example-core"
sources = ["src/lib.rs"]
[crates.e2e]
fixtures = "fixtures"
[crates.e2e.call]
function = "chat"
module = "example_api"
args = [{ name = "prompt", field = "prompt", type = "string" }]
"#;
fn config() -> (E2eConfig, ResolvedCrateConfig) {
let cfg: NewAlefConfig = toml::from_str(CONFIG).expect("config parses");
let e2e = cfg.crates[0].e2e.clone().expect("e2e config");
let resolved = cfg.resolve().expect("config resolves").remove(0);
(e2e, resolved)
}
fn error_fixture(declared: &str) -> Fixture {
serde_json::from_value(serde_json::json!({
"id": "auth_401",
"description": "401 Unauthorized",
"input": {"prompt": "Hello"},
"assertions": [
{"type": "error"},
{"type": "error", "value": declared},
]
}))
.expect("fixture must parse")
}
fn errors_with(variant: &str) -> Vec<ErrorDef> {
vec![ErrorDef {
name: "ApiError".to_string(),
rust_path: "lib::ApiError".to_string(),
original_rust_path: String::new(),
variants: vec![ErrorVariant {
name: variant.to_string(),
is_unit: true,
..ErrorVariant::default()
}],
doc: String::new(),
methods: vec![],
binding_excluded: false,
binding_exclusion_reason: None,
version: Default::default(),
}]
}
#[test]
fn a_fixture_naming_an_error_variant_catches_that_variants_exception_first() {
let (e2e, resolved) = config();
let rendered = PythonE2eCodegen
.render_snippet_body_with_functions(
&error_fixture("Authentication"),
&e2e,
&resolved,
&[],
&[],
&[],
&errors_with("Authentication"),
)
.expect("snippet renders");
let typed = rendered
.find("except AuthenticationError as error:")
.unwrap_or_else(|| panic!("no typed catch branch in:\n{rendered}"));
let generic = rendered
.find("except Error as error:")
.unwrap_or_else(|| panic!("no catch-all branch in:\n{rendered}"));
assert!(typed < generic, "typed branch must precede the catch-all:\n{rendered}");
assert!(
rendered.contains("from example_api import AuthenticationError, Error"),
"both exception classes must be imported:\n{rendered}"
);
}
#[test]
fn without_an_error_registry_the_snippet_keeps_its_single_generic_branch() {
let (e2e, resolved) = config();
let rendered = PythonE2eCodegen
.render_snippet_body(&error_fixture("Authentication"), &e2e, &resolved, &[], &[])
.expect("snippet renders");
assert!(rendered.contains("except Error as error:"), "{rendered}");
assert!(!rendered.contains("except AuthenticationError"), "{rendered}");
assert_eq!(rendered.matches("except ").count(), 1, "{rendered}");
}
#[test]
fn a_message_style_declared_value_keeps_the_generic_branch() {
let (e2e, resolved) = config();
let rendered = PythonE2eCodegen
.render_snippet_body_with_functions(
&error_fixture("prompt must not be empty"),
&e2e,
&resolved,
&[],
&[],
&[],
&errors_with("Authentication"),
)
.expect("snippet renders");
assert_eq!(rendered.matches("except ").count(), 1, "{rendered}");
assert!(rendered.contains("except Error as error:"), "{rendered}");
}
}