use crate::core::backend::GeneratedFile;
use crate::core::config::ResolvedCrateConfig;
use crate::core::ir::{
ApiSurface, EntrypointKind, HandlerContractDef, RegistrationDef, RegistrationVariantStyle, ServiceDef, TypeRef,
};
use heck::{ToShoutySnakeCase, ToSnakeCase, ToUpperCamelCase};
use minijinja::context;
use std::collections::BTreeSet;
use std::path::PathBuf;
fn python_type_annotation(ty: &TypeRef) -> String {
match ty {
TypeRef::String | TypeRef::Char => "str".to_owned(),
TypeRef::Primitive(p) => {
use crate::core::ir::PrimitiveType;
match p {
PrimitiveType::Bool => "bool".to_owned(),
PrimitiveType::F32 | PrimitiveType::F64 => "float".to_owned(),
_ => "int".to_owned(),
}
}
TypeRef::Bytes => "bytes".to_owned(),
TypeRef::Optional(inner) => format!("{} | None", python_type_annotation(inner)),
TypeRef::Vec(inner) => format!("list[{}]", python_type_annotation(inner)),
TypeRef::Map(k, v) => format!("dict[{}, {}]", python_type_annotation(k), python_type_annotation(v)),
TypeRef::Unit => "None".to_owned(),
TypeRef::Named(n) => n.clone(),
TypeRef::Json => "object".to_owned(),
TypeRef::Path => "str".to_owned(),
TypeRef::Duration => "float".to_owned(),
}
}
fn find_contract<'a>(api: &'a ApiSurface, trait_name: &str) -> Option<&'a HandlerContractDef> {
api.handler_contracts.iter().find(|c| c.trait_name == trait_name)
}
fn collect_named_types(ty: &TypeRef, out: &mut BTreeSet<String>) {
match ty {
TypeRef::Named(n) => {
out.insert(n.clone());
}
TypeRef::Optional(inner) | TypeRef::Vec(inner) => collect_named_types(inner, out),
TypeRef::Map(k, v) => {
collect_named_types(k, out);
collect_named_types(v, out);
}
_ => {}
}
}
fn collect_service_named_types(service: &ServiceDef, out: &mut BTreeSet<String>) {
for p in &service.constructor.params {
collect_named_types(&p.ty, out);
}
for m in &service.configurators {
for p in &m.params {
collect_named_types(&p.ty, out);
}
}
for r in &service.registrations {
for p in &r.metadata_params {
collect_named_types(&p.ty, out);
}
}
for e in &service.entrypoints {
for p in &e.params {
collect_named_types(&p.ty, out);
}
}
}
fn collect_variant_runtime_types(service: &ServiceDef, out: &mut BTreeSet<String>) {
for r in &service.registrations {
for variant in &r.variants {
if let Some(wc) = &variant.wrapper_call {
out.insert(wc.wrapper_type_name.clone());
for arg in &wc.args {
if let crate::core::ir::WrapperConstructorArg::Fixed { value_expr, .. } = arg {
let segments: Vec<&str> = value_expr.split("::").collect();
if segments.len() >= 2 {
out.insert(segments[segments.len() - 2].to_owned());
}
}
}
}
}
}
}
fn format_docstring(text: &str, indent: usize) -> String {
let trimmed = text.trim();
let pad = " ".repeat(indent);
if !trimmed.contains('\n') {
return format!("{pad}\"\"\"{trimmed}\"\"\"\n");
}
let mut lines = trimmed.lines();
let first = lines.next().unwrap_or("");
let mut out = format!("{pad}\"\"\"{first}\n");
for line in lines {
if line.trim().is_empty() {
out.push('\n');
} else {
out.push_str(&pad);
out.push_str(line);
out.push('\n');
}
}
out.push_str(&pad);
out.push_str("\"\"\"\n");
out
}
pub(super) fn gen_service_py(api: &ApiSurface, module_name: &str) -> String {
let mut out = String::new();
let mut named_types: BTreeSet<String> = BTreeSet::new();
let mut runtime_types: BTreeSet<String> = BTreeSet::new();
for service in &api.services {
collect_service_named_types(service, &mut named_types);
collect_variant_runtime_types(service, &mut runtime_types);
}
for n in &runtime_types {
named_types.remove(n);
}
let any_registrations = api.services.iter().any(|s| !s.registrations.is_empty());
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_py_header.py.jinja",
context! { module_name => module_name },
));
if !runtime_types.is_empty() {
let joined = runtime_types.iter().cloned().collect::<Vec<_>>().join(", ");
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_py_runtime_import.py.jinja",
context! { module_name => module_name, imports => joined },
));
}
if any_registrations || !named_types.is_empty() {
out.push('\n');
out.push_str("if TYPE_CHECKING:\n");
if any_registrations {
out.push_str(" from collections.abc import Callable\n");
if !named_types.is_empty() {
out.push('\n');
}
}
if !named_types.is_empty() {
let joined = named_types.iter().cloned().collect::<Vec<_>>().join(", ");
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_py_type_checking_import.py.jinja",
context! { module_name => module_name, imports => joined },
));
}
}
out.push_str("\n\n");
for service in &api.services {
gen_service_class(&mut out, service, api, module_name);
}
out
}
fn gen_service_class(out: &mut String, service: &ServiceDef, api: &ApiSurface, module_name: &str) {
let class_name = &service.name;
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_py_class_header.py.jinja",
context! { class_name => class_name },
));
if !service.doc.is_empty() {
out.push_str(&format_docstring(&service.doc, 4));
out.push('\n');
}
{
let ctor = &service.constructor;
let mut init_params = vec!["self".to_owned()];
let mut init_args = Vec::new();
for p in &ctor.params {
let annotation = python_type_annotation(&p.ty);
if p.optional {
init_params.push(format!("{}: {} | None = None", p.name, annotation));
} else {
init_params.push(format!("{}: {}", p.name, annotation));
}
init_args.push(p.name.clone());
}
let param_sig = init_params.join(", ");
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_py_init_header.py.jinja",
context! { param_sig => param_sig },
));
if !ctor.doc.is_empty() {
out.push_str(&format_docstring(&ctor.doc, 8));
}
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_py_registration_state.py.jinja",
context! {},
));
for arg in &init_args {
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_py_init_assignment.py.jinja",
context! { arg => arg },
));
}
out.push('\n');
}
for method in &service.configurators {
let mut params = vec!["self".to_owned()];
for p in &method.params {
let annotation = python_type_annotation(&p.ty);
if p.optional {
params.push(format!("{}: {} | None = None", p.name, annotation));
} else {
params.push(format!("{}: {}", p.name, annotation));
}
}
let param_sig = params.join(", ");
let method_name = &method.name;
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_py_configurator_header.py.jinja",
context! {
method_name => method_name,
param_sig => param_sig,
class_name => class_name,
},
));
if !method.doc.is_empty() {
out.push_str(&format_docstring(&method.doc, 8));
}
for p in &method.params {
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_py_configurator_assignment.py.jinja",
context! { name => p.name.as_str() },
));
}
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_py_return_self.py.jinja",
context! {},
));
}
for reg in &service.registrations {
gen_registration_method(out, reg, service, api, module_name);
}
for ep in &service.entrypoints {
let mut params = vec!["self".to_owned()];
for p in &ep.params {
let annotation = python_type_annotation(&p.ty);
if p.optional {
params.push(format!("{}: {} | None = None", p.name, annotation));
} else {
params.push(format!("{}: {}", p.name, annotation));
}
}
let param_sig = params.join(", ");
let ep_name = &ep.method;
match ep.kind {
EntrypointKind::Run => {
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_py_entrypoint_header.py.jinja",
context! { ep_name => ep_name, param_sig => param_sig, return_type => "None" },
));
if !ep.doc.is_empty() {
out.push_str(&format_docstring(&ep.doc, 8));
}
let native_fn = format!("{service_snake}_{ep_name}", service_snake = class_name.to_snake_case());
let args = ep
.params
.iter()
.map(|p| format!(", {}", p.name))
.collect::<Vec<_>>()
.join("");
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_py_entrypoint_call.py.jinja",
context! {
return_prefix => "",
module_name => module_name,
native_fn => native_fn,
args => args,
},
));
out.push('\n');
}
EntrypointKind::Finalize => {
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_py_entrypoint_header.py.jinja",
context! { ep_name => ep_name, param_sig => param_sig, return_type => "Any" },
));
if !ep.doc.is_empty() {
out.push_str(&format_docstring(&ep.doc, 8));
}
let native_fn = format!("{service_snake}_{ep_name}", service_snake = class_name.to_snake_case());
let args = ep
.params
.iter()
.map(|p| format!(", {}", p.name))
.collect::<Vec<_>>()
.join("");
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_py_entrypoint_call.py.jinja",
context! {
return_prefix => "return ",
module_name => module_name,
native_fn => native_fn,
args => args,
},
));
out.push('\n');
}
}
}
}
fn build_wrapper_constructor_expr(variant: &crate::core::ir::RegistrationVariant) -> Option<String> {
let wc = variant.wrapper_call.as_ref()?;
let mut call_args = Vec::new();
for arg in &wc.args {
match arg {
crate::core::ir::WrapperConstructorArg::Fixed {
param_name: _,
value_expr,
} => {
let segments: Vec<&str> = value_expr.split("::").collect();
if segments.len() >= 2 {
let class = segments[segments.len() - 2];
let variant_name = segments[segments.len() - 1].to_shouty_snake_case();
call_args.push(format!("{class}.{variant_name}"));
} else {
call_args.push(value_expr.clone());
}
}
crate::core::ir::WrapperConstructorArg::Free { param } => {
call_args.push(param.name.clone());
}
}
}
let call_expr = if wc.constructor_method.is_empty() || wc.constructor_method == "__init__" {
format!("{}({})", wc.wrapper_type_name, call_args.join(", "))
} else {
format!(
"{}.{}({})",
wc.wrapper_type_name,
wc.constructor_method,
call_args.join(", ")
)
};
Some(format!("{} = {}", wc.metadata_param, call_expr))
}
fn variant_meta_tuple(variant: &crate::core::ir::RegistrationVariant, base_reg: &RegistrationDef) -> (String, String) {
let wrapper_consumed: BTreeSet<&str> = if let Some(wc) = &variant.wrapper_call {
let mut s = BTreeSet::new();
s.insert(wc.metadata_param.as_str());
for arg in &wc.args {
match arg {
crate::core::ir::WrapperConstructorArg::Fixed { param_name, .. } => {
s.insert(param_name.as_str());
}
crate::core::ir::WrapperConstructorArg::Free { param } => {
s.insert(param.name.as_str());
}
}
}
s
} else {
BTreeSet::new()
};
let overridden: BTreeSet<&str> = variant.overrides.iter().map(|o| o.param_name.as_str()).collect();
let mut meta_items: Vec<String> = Vec::new();
if let Some(wc) = &variant.wrapper_call {
meta_items.push(wc.metadata_param.clone());
}
for p in &base_reg.metadata_params {
if wrapper_consumed.contains(p.name.as_str()) || overridden.contains(p.name.as_str()) {
continue;
}
meta_items.push(p.name.clone());
}
let base_method = base_reg.method.clone();
let meta_tuple = if meta_items.is_empty() {
"()".to_owned()
} else if meta_items.len() == 1 {
format!("({},)", meta_items[0])
} else {
format!("({})", meta_items.join(", "))
};
(base_method, meta_tuple)
}
fn emit_direct_method(
out: &mut String,
variant: &crate::core::ir::RegistrationVariant,
base_reg: &RegistrationDef,
class_name: &str,
free_params_sig: &[String],
meta_tuple: &str,
) {
let variant_name = &variant.name;
let base_method = &base_reg.method;
let params_sig = if free_params_sig.is_empty() {
"self, handler: Callable[..., Any]".to_owned()
} else {
format!("self, {}, handler: Callable[..., Any]", free_params_sig.join(", "))
};
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_py_direct_variant_header.py.jinja",
context! { variant_name => variant_name, params_sig => params_sig, class_name => class_name },
));
if let Some(doc) = &variant.doc {
out.push_str(&format_docstring(doc, 8));
} else {
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_py_direct_variant_doc.py.jinja",
context! { variant_name => variant_name },
));
}
if let Some(wrapper_expr) = build_wrapper_constructor_expr(variant) {
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_py_statement.py.jinja",
context! { statement => wrapper_expr },
));
}
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_py_append_registration.py.jinja",
context! { base_method => base_method, meta_tuple => meta_tuple, callback => "handler" },
));
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_py_return_self.py.jinja",
context! {},
));
}
fn emit_decorator_factory(
out: &mut String,
variant: &crate::core::ir::RegistrationVariant,
base_reg: &RegistrationDef,
free_params_sig: &[String],
meta_tuple: &str,
) {
let variant_name = &variant.name;
let base_method = &base_reg.method;
let decorator_name = format!("{variant_name}_decorator");
let params_sig_no_handler = if free_params_sig.is_empty() {
"self".to_owned()
} else {
format!("self, {}", free_params_sig.join(", "))
};
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_py_decorator_factory_header.py.jinja",
context! { decorator_name => decorator_name, params_sig => params_sig_no_handler },
));
if let Some(doc) = &variant.doc {
let decorator_doc = format!("Decorator form for {}", doc.trim_start());
out.push_str(&format_docstring(&decorator_doc, 8));
} else {
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_py_decorator_variant_doc.py.jinja",
context! { variant_name => variant_name },
));
}
if let Some(wrapper_expr) = build_wrapper_constructor_expr(variant) {
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_py_statement.py.jinja",
context! { statement => wrapper_expr },
));
}
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_py_decorator_body.py.jinja",
context! { base_method => base_method, meta_tuple => meta_tuple },
));
}
fn gen_registration_variant(
out: &mut String,
variant: &crate::core::ir::RegistrationVariant,
base_reg: &RegistrationDef,
_service: &ServiceDef,
class_name: &str,
) {
let mut free_params_sig = Vec::new();
for param in &variant.signature_params {
let annotation = python_type_annotation(¶m.ty);
if param.optional {
free_params_sig.push(format!("{}: {} | None = None", param.name, annotation));
} else {
free_params_sig.push(format!("{}: {}", param.name, annotation));
}
}
let (_base_method, meta_tuple) = variant_meta_tuple(variant, base_reg);
match variant.style {
RegistrationVariantStyle::VerbDecorator => {
emit_direct_method(out, variant, base_reg, class_name, &free_params_sig, &meta_tuple);
}
RegistrationVariantStyle::Builder => {
emit_decorator_factory(out, variant, base_reg, &free_params_sig, &meta_tuple);
}
RegistrationVariantStyle::Hybrid => {
emit_direct_method(out, variant, base_reg, class_name, &free_params_sig, &meta_tuple);
emit_decorator_factory(out, variant, base_reg, &free_params_sig, &meta_tuple);
}
}
}
fn gen_registration_method(
out: &mut String,
reg: &RegistrationDef,
service: &ServiceDef,
api: &ApiSurface,
_module_name: &str,
) {
let method_name = ®.method;
let class_name = &service.name;
let _contract = find_contract(api, ®.callback_contract);
let mut meta_params: Vec<String> = reg
.metadata_params
.iter()
.map(|p| {
let annotation = python_type_annotation(&p.ty);
if p.optional {
format!("{}: {} | None = None", p.name, annotation)
} else {
format!("{}: {}", p.name, annotation)
}
})
.collect();
meta_params.insert(0, "self".to_owned());
let meta_sig = meta_params.join(", ");
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_py_registration_method_header.py.jinja",
context! { method_name => method_name, meta_sig => meta_sig },
));
if !reg.doc.is_empty() {
out.push_str(&format_docstring(®.doc, 8));
}
let meta_names: Vec<&str> = reg.metadata_params.iter().map(|p| p.name.as_str()).collect();
let meta_tuple = if meta_names.is_empty() {
"()".to_owned()
} else if meta_names.len() == 1 {
format!("({},)", meta_names[0])
} else {
format!("({})", meta_names.join(", "))
};
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_py_decorator_body.py.jinja",
context! { base_method => method_name, meta_tuple => meta_tuple },
));
let direct_name = format!("register_{method_name}");
if direct_name != *method_name {
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_py_direct_registration.py.jinja",
context! {
direct_name => direct_name,
meta_sig => meta_sig,
callback_param => reg.callback_param.as_str(),
class_name => class_name,
method_name => method_name,
meta_tuple => meta_tuple,
},
));
}
for variant in ®.variants {
gen_registration_variant(out, variant, reg, service, class_name);
}
}
pub(super) fn gen_service_rs(api: &ApiSurface, config: &ResolvedCrateConfig) -> String {
let core_import = config.core_import_name();
let mut out = String::new();
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_rs_header.rs.jinja",
context! {},
));
let referenced_contracts: Vec<&HandlerContractDef> = {
let mut names: Vec<&str> = api
.services
.iter()
.flat_map(|s| s.registrations.iter())
.map(|r| r.callback_contract.as_str())
.collect();
names.sort_unstable();
names.dedup();
names
.iter()
.filter_map(|n| find_contract(api, n))
.filter(|c| {
c.trait_name != "WebSocketHandler" && c.trait_name != "SseEventProducer"
})
.collect()
};
for contract in &referenced_contracts {
gen_handler_bridge(&mut out, contract, &core_import);
}
for service in &api.services {
for ep in &service.entrypoints {
gen_run_pyfunction(&mut out, service, ep, api, &core_import);
}
}
out
}
fn gen_handler_bridge(out: &mut String, contract: &HandlerContractDef, core_import: &str) {
let trait_name = &contract.trait_name;
let bridge_name = format!("Py{}Bridge", trait_name.to_upper_camel_case());
let dispatch_name = &contract.dispatch.name;
let req_type = contract.wire_request_type.as_deref().unwrap_or("serde_json::Value");
let resp_type = contract.wire_response_type.as_deref().unwrap_or("serde_json::Value");
let req_type = if req_type.contains("::") {
req_type.split("::").last().unwrap_or(req_type)
} else {
req_type
};
let resp_type = if resp_type.contains("::") {
resp_type.split("::").last().unwrap_or(resp_type)
} else {
resp_type
};
let extra_param: String = contract
.dispatch_extra_params
.iter()
.map(|p| format!(", {p}"))
.collect();
let wire_name = contract.wire_param_name.as_deref().unwrap_or("request");
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_handler_bridge_struct.rs.jinja",
context! { trait_name => trait_name, bridge_name => bridge_name.as_str() },
));
let req_path = if req_type == "Value" {
"serde_json::Value".to_string()
} else {
format!("{core_import}::{req_type}")
};
let resp_path = if resp_type == "Value" {
"serde_json::Value".to_string()
} else {
format!("{core_import}::{resp_type}")
};
let box_err = "Box<dyn std::error::Error + Send + Sync>";
let wire_output = format!("Result<{resp_path}, {box_err}>");
let output_type = contract
.dispatch_return_type
.clone()
.unwrap_or_else(|| wire_output.clone());
let tail = match &contract.response_adapter {
Some(adapter) => format!("{adapter}(outcome)"),
None => "outcome".to_string(),
};
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_handler_bridge_impl.rs.jinja",
context! {
core_import => core_import,
trait_name => trait_name,
bridge_name => bridge_name,
dispatch_name => dispatch_name,
extra_param => extra_param,
wire_name => wire_name,
req_path => req_path,
output_type => output_type,
wire_output => wire_output,
box_err => box_err,
resp_path => resp_path,
tail => tail,
},
));
}
fn gen_run_pyfunction(
out: &mut String,
service: &ServiceDef,
ep: &crate::core::ir::EntrypointDef,
api: &ApiSurface,
core_import: &str,
) {
let service_snake = service.name.to_snake_case();
let fn_name = format!("{service_snake}_{}", ep.method);
let owner_path = &service.rust_path;
let ep_method = &ep.method;
let mut rust_params = vec![
"_py: Python<'_>".to_owned(),
"registrations: &Bound<'_, PyList>".to_owned(),
];
for p in &ep.params {
let rust_ty = typeref_to_rust_type(&p.ty, core_import);
rust_params.push(format!("{}: {}", p.name, rust_ty));
}
let param_sig = rust_params.join(", ");
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_pyfunction_header.rs.jinja",
context! {
owner_path => owner_path,
ep_method => ep_method,
fn_name => fn_name,
param_sig => param_sig,
},
));
let ctor_call = build_ctor_call(service, owner_path, core_import);
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_rs_owner_ctor.rs.jinja",
context! { ctor_call => ctor_call },
));
out.push('\n');
out.push_str(" for entry in registrations.iter() {\n");
out.push_str(" let tuple: &Bound<'_, PyTuple> = entry.downcast()?;\n");
out.push_str(" let method_name: String = tuple.get_item(0)?.extract()?;\n");
out.push_str(" let callable = tuple.get_item(2)?;\n\n");
out.push_str(" match method_name.as_str() {\n");
for reg in &service.registrations {
let reg_method = ®.method;
let contract_name = ®.callback_contract;
if let Some(contract) = find_contract(api, contract_name) {
let bridge_name = format!("Py{}Bridge", contract.trait_name.to_upper_camel_case());
let meta_count = reg.metadata_params.len();
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_registration_arm.rs.jinja",
context! {
reg_method => reg_method,
bridge_name => bridge_name,
core_import => core_import,
contract_name => contract_name,
},
));
if meta_count > 0 {
out.push_str(" let meta_item = tuple.get_item(1)?;\n");
out.push_str(" let meta: &Bound<'_, PyTuple> = meta_item.downcast()?;\n");
for (i, meta_param) in reg.metadata_params.iter().enumerate() {
let opaque_named = match &meta_param.ty {
TypeRef::Named(n) => api
.types
.iter()
.find(|t| &t.name == n && !t.is_trait && t.is_opaque)
.map(|_| n.clone()),
_ => None,
};
if let Some(name) = opaque_named {
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_registration_meta_opaque.rs.jinja",
context! {
param_name => meta_param.name.as_str(),
type_name => name,
core_import => core_import,
index => i,
},
));
} else {
let rust_ty = typeref_to_rust_type(&meta_param.ty, core_import);
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_registration_meta_value.rs.jinja",
context! {
param_name => meta_param.name.as_str(),
rust_type => rust_ty,
index => i,
},
));
}
}
let meta_args: Vec<String> = reg.metadata_params.iter().map(|p| p.name.clone()).collect();
let args = if meta_args.is_empty() {
String::new()
} else {
format!("{}, ", meta_args.join(", "))
};
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_registration_owner_call.rs.jinja",
context! {
reg_method => reg_method,
args => args,
},
));
} else {
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_registration_owner_call.rs.jinja",
context! {
reg_method => reg_method,
args => "",
},
));
}
if reg.error_type.is_some() {
out.push_str(
" .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;\n",
);
} else {
out.push_str(" ;\n");
}
out.push_str(" }\n");
}
}
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_unknown_registration_arm.rs.jinja",
context! {},
));
out.push_str(" }\n");
out.push_str(" }\n\n");
let ep_call = build_ep_call(ep, service, core_import);
out.push_str(&ep_call);
out.push_str(&crate::backends::pyo3::template_env::render(
"service_api_pyfunction_footer.rs.jinja",
context! {},
));
}
fn build_ctor_call(service: &ServiceDef, owner_path: &str, _core_import: &str) -> String {
if service.constructor.params.is_empty() {
format!("{owner_path}::{}()", service.constructor.name)
} else {
format!("{owner_path}::{}()", service.constructor.name)
}
}
fn build_ep_call(ep: &crate::core::ir::EntrypointDef, _service: &ServiceDef, _core_import: &str) -> String {
let ep_method = &ep.method;
let ep_args: Vec<String> = ep.params.iter().map(|p| p.name.clone()).collect();
let args_str = ep_args.join(", ");
let bind = if matches!(ep.return_type, TypeRef::Unit) {
""
} else {
"let _ = "
};
if ep.is_async {
format!(
" {bind}_py.detach(|| {{\n \
pyo3_async_runtimes::tokio::get_runtime().block_on(owner.{ep_method}({args_str}))\n \
}})\n \
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;\n"
)
} else if ep.error_type.is_some() {
format!(
" {bind}owner.{ep_method}({args_str})\n \
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;\n"
)
} else {
format!(" {bind}owner.{ep_method}({args_str});\n")
}
}
fn typeref_to_rust_type(ty: &TypeRef, core_import: &str) -> String {
match ty {
TypeRef::String | TypeRef::Char => "String".to_owned(),
TypeRef::Primitive(p) => {
use crate::core::ir::PrimitiveType;
match p {
PrimitiveType::Bool => "bool".to_owned(),
PrimitiveType::U8 => "u8".to_owned(),
PrimitiveType::U16 => "u16".to_owned(),
PrimitiveType::U32 => "u32".to_owned(),
PrimitiveType::U64 => "u64".to_owned(),
PrimitiveType::I8 => "i8".to_owned(),
PrimitiveType::I16 => "i16".to_owned(),
PrimitiveType::I32 => "i32".to_owned(),
PrimitiveType::I64 => "i64".to_owned(),
PrimitiveType::F32 => "f32".to_owned(),
PrimitiveType::F64 => "f64".to_owned(),
PrimitiveType::Usize => "usize".to_owned(),
PrimitiveType::Isize => "isize".to_owned(),
}
}
TypeRef::Bytes => "Vec<u8>".to_owned(),
TypeRef::Optional(inner) => format!("Option<{}>", typeref_to_rust_type(inner, core_import)),
TypeRef::Vec(inner) => format!("Vec<{}>", typeref_to_rust_type(inner, core_import)),
TypeRef::Map(k, v) => format!(
"std::collections::HashMap<{}, {}>",
typeref_to_rust_type(k, core_import),
typeref_to_rust_type(v, core_import)
),
TypeRef::Unit => "()".to_owned(),
TypeRef::Named(n) => format!("{core_import}::{n}"),
TypeRef::Json => "serde_json::Value".to_owned(),
TypeRef::Path => "std::path::PathBuf".to_owned(),
TypeRef::Duration => "std::time::Duration".to_owned(),
}
}
pub fn generate(api: &ApiSurface, config: &ResolvedCrateConfig) -> anyhow::Result<Vec<GeneratedFile>> {
if api.services.is_empty() {
return Ok(vec![]);
}
use crate::core::config::resolve_output_dir;
let output_dir = resolve_output_dir(config.output_paths.get("python"), &config.name, "crates/{name}-py/src/");
let module_name = config.python_module_name();
let service_rs = gen_service_rs(api, config);
let service_py = gen_service_py(api, &module_name);
let output_base = config
.python
.as_ref()
.and_then(|p| p.stubs.as_ref())
.map(|s| PathBuf::from(&s.output))
.unwrap_or_else(|| {
let package_name = config.name.replace('-', "_");
PathBuf::from(format!("packages/python/{}", package_name))
});
Ok(vec![
GeneratedFile {
path: PathBuf::from(&output_dir).join("service.rs"),
content: service_rs,
generated_header: true,
},
GeneratedFile {
path: output_base.join("service.py"),
content: service_py,
generated_header: true,
},
])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::ir::{
EntrypointDef, EntrypointKind, HandlerContractDef, MethodDef, ParamDef, PrimitiveType, RegistrationDef,
ServiceDef, TypeRef,
};
fn make_fixture_surface() -> ApiSurface {
let constructor = MethodDef {
name: "new".to_owned(),
params: vec![],
return_type: TypeRef::Unit,
is_async: false,
is_static: true,
error_type: None,
doc: "Create a new service owner.".to_owned(),
receiver: None,
sanitized: false,
trait_source: None,
returns_ref: false,
returns_cow: false,
return_newtype_wrapper: None,
has_default_impl: false,
binding_excluded: false,
binding_exclusion_reason: None,
};
let configurator = MethodDef {
name: "with_timeout".to_owned(),
params: vec![ParamDef {
name: "timeout_ms".to_owned(),
ty: TypeRef::Primitive(PrimitiveType::U64),
optional: false,
default: None,
..ParamDef::default()
}],
return_type: TypeRef::Named("TestService".to_owned()),
is_async: false,
is_static: false,
error_type: None,
doc: "Set request timeout.".to_owned(),
receiver: Some(crate::core::ir::ReceiverKind::RefMut),
sanitized: false,
trait_source: None,
returns_ref: false,
returns_cow: false,
return_newtype_wrapper: None,
has_default_impl: false,
binding_excluded: false,
binding_exclusion_reason: None,
};
let registration = RegistrationDef {
method: "add_handler".to_owned(),
callback_param: "handler".to_owned(),
callback_contract: "RequestHandler".to_owned(),
metadata_params: vec![
ParamDef {
name: "path".to_owned(),
ty: TypeRef::String,
optional: false,
default: None,
..ParamDef::default()
},
ParamDef {
name: "method".to_owned(),
ty: TypeRef::String,
optional: false,
default: None,
..ParamDef::default()
},
],
receiver: Some(crate::core::ir::ReceiverKind::RefMut),
return_type: TypeRef::Unit,
error_type: None,
doc: "Register a request handler for a path and method.".to_owned(),
variants: vec![],
};
let run_ep = EntrypointDef {
method: "run".to_owned(),
kind: EntrypointKind::Run,
is_async: true,
params: vec![ParamDef {
name: "addr".to_owned(),
ty: TypeRef::String,
optional: false,
default: None,
..ParamDef::default()
}],
return_type: TypeRef::Unit,
error_type: Some("ServiceError".to_owned()),
doc: "Run the service.".to_owned(),
};
let finalize_ep = EntrypointDef {
method: "into_router".to_owned(),
kind: EntrypointKind::Finalize,
is_async: false,
params: vec![],
return_type: TypeRef::Named("Router".to_owned()),
error_type: None,
doc: "Consume and convert into a router.".to_owned(),
};
let service = ServiceDef {
name: "TestService".to_owned(),
rust_path: "my_crate::TestService".to_owned(),
constructor,
configurators: vec![configurator],
registrations: vec![registration],
entrypoints: vec![run_ep, finalize_ep],
doc: "A test service owner.".to_owned(),
cfg: None,
};
let dispatch_method = MethodDef {
name: "handle".to_owned(),
params: vec![ParamDef {
name: "request".to_owned(),
ty: TypeRef::Named("RequestData".to_owned()),
optional: false,
default: None,
..ParamDef::default()
}],
return_type: TypeRef::Named("ResponseData".to_owned()),
is_async: true,
is_static: false,
error_type: Some("HandlerError".to_owned()),
doc: "Dispatch a request.".to_owned(),
receiver: Some(crate::core::ir::ReceiverKind::Ref),
sanitized: false,
trait_source: None,
returns_ref: false,
returns_cow: false,
return_newtype_wrapper: None,
has_default_impl: false,
binding_excluded: false,
binding_exclusion_reason: None,
};
let contract = HandlerContractDef {
trait_name: "RequestHandler".to_owned(),
rust_path: "my_crate::RequestHandler".to_owned(),
dispatch: dispatch_method,
optional_methods: vec![],
wire_request_type: Some("RequestData".to_owned()),
wire_response_type: Some("ResponseData".to_owned()),
dispatch_extra_params: vec![],
wire_param_name: None,
dispatch_return_type: None,
response_adapter: None,
doc: "Async trait for handling requests.".to_owned(),
};
ApiSurface {
crate_name: "my_crate".to_owned(),
version: "0.1.0".to_owned(),
services: vec![service],
handler_contracts: vec![contract],
..ApiSurface::default()
}
}
#[test]
fn python_output_contains_service_class() {
let surface = make_fixture_surface();
let output = gen_service_py(&surface, "_my_crate");
assert!(
output.contains("class TestService:"),
"expected `class TestService:` in output:\n{output}"
);
}
#[test]
fn python_output_contains_init_with_registrations() {
let surface = make_fixture_surface();
let output = gen_service_py(&surface, "_my_crate");
assert!(
output.contains("def __init__(self)"),
"expected `def __init__(self)` in output:\n{output}"
);
assert!(
output.contains("self._registrations"),
"expected `self._registrations` in output:\n{output}"
);
}
#[test]
fn python_output_contains_configurator() {
let surface = make_fixture_surface();
let output = gen_service_py(&surface, "_my_crate");
assert!(
output.contains("def with_timeout(self, timeout_ms: int)"),
"expected `with_timeout` configurator:\n{output}"
);
assert!(
output.contains("return self"),
"expected `return self` in configurator:\n{output}"
);
}
#[test]
fn python_output_contains_registration_decorator() {
let surface = make_fixture_surface();
let output = gen_service_py(&surface, "_my_crate");
assert!(
output.contains("def add_handler("),
"expected `add_handler` registration method:\n{output}"
);
assert!(
output.contains("def _decorator(fn"),
"expected inner `_decorator` closure:\n{output}"
);
assert!(
output.contains("self._registrations.append"),
"expected `_registrations.append` in decorator:\n{output}"
);
}
#[test]
fn python_output_contains_run_entrypoint() {
let surface = make_fixture_surface();
let output = gen_service_py(&surface, "_my_crate");
assert!(
output.contains("def run(self"),
"expected `def run(self` entrypoint:\n{output}"
);
assert!(
output.contains("_my_crate.test_service_run("),
"expected native call `_my_crate.test_service_run(` in run:\n{output}"
);
}
#[test]
fn python_output_contains_registration_variants() {
let mut surface = make_fixture_surface();
let variant = crate::core::ir::RegistrationVariant {
name: "get".to_owned(),
overrides: vec![],
wrapper_call: Some(crate::core::ir::WrapperConstructorCall {
metadata_param: "builder".to_owned(),
wrapper_type_path: "mylib::RouteBuilder".to_owned(),
wrapper_type_name: "RouteBuilder".to_owned(),
constructor_method: "new".to_owned(),
args: vec![
crate::core::ir::WrapperConstructorArg::Fixed {
param_name: "method".to_owned(),
value_expr: "mylib::Method::GET".to_owned(),
},
crate::core::ir::WrapperConstructorArg::Free {
param: ParamDef {
name: "path".to_owned(),
ty: TypeRef::String,
optional: false,
default: None,
..ParamDef::default()
},
},
],
}),
signature_params: vec![ParamDef {
name: "path".to_owned(),
ty: TypeRef::String,
optional: false,
default: None,
..ParamDef::default()
}],
doc: Some("Register a GET handler.".to_owned()),
style: crate::core::ir::RegistrationVariantStyle::Hybrid,
};
surface.services[0].registrations[0].variants.push(variant);
let output = gen_service_py(&surface, "_my_crate");
assert!(
output.contains("def get(self, path: str, handler: Callable[..., Any])"),
"expected `def get(self, path: str, handler)` method form:\n{output}"
);
assert!(
output.contains("def get_decorator(self, path: str)"),
"expected `def get_decorator(self, path: str)` decorator form:\n{output}"
);
assert!(
output.contains("builder = RouteBuilder.new(Method.GET, path)"),
"expected wrapper constructor call with Method.GET:\n{output}"
);
assert!(
output.contains("(\"add_handler\", (builder,), handler)"),
"expected metadata tuple to contain only the constructed wrapper:\n{output}"
);
}
#[test]
fn rust_output_contains_handler_bridge_struct() {
let surface = make_fixture_surface();
let config = make_test_config();
let output = gen_service_rs(&surface, &config);
assert!(
output.contains("pub struct PyRequestHandlerBridge"),
"expected `PyRequestHandlerBridge` struct:\n{output}"
);
}
#[test]
fn rust_output_contains_handler_bridge_impl() {
let surface = make_fixture_surface();
let config = make_test_config();
let output = gen_service_rs(&surface, &config);
assert!(
output.contains("impl my_crate::RequestHandler for PyRequestHandlerBridge"),
"expected trait impl:\n{output}"
);
assert!(
output.contains("fn handle(") && output.contains("Pin<Box<dyn Future<Output"),
"expected dispatch method returning a boxed future:\n{output}"
);
}
#[test]
fn rust_output_contains_pyfunction_run() {
let surface = make_fixture_surface();
let config = make_test_config();
let output = gen_service_rs(&surface, &config);
assert!(
output.contains("#[pyfunction]"),
"expected `#[pyfunction]` attribute:\n{output}"
);
assert!(
output.contains("pub fn test_service_run("),
"expected `test_service_run` function:\n{output}"
);
}
#[test]
fn rust_output_contains_registration_dispatch() {
let surface = make_fixture_surface();
let config = make_test_config();
let output = gen_service_rs(&surface, &config);
assert!(
output.contains("\"add_handler\""),
"expected `\"add_handler\"` match arm:\n{output}"
);
assert!(
output.contains("Arc<dyn my_crate::RequestHandler>"),
"expected Arc wrapping of handler:\n{output}"
);
}
#[test]
fn generate_returns_two_files_for_non_empty_services() {
let surface = make_fixture_surface();
let config = make_test_config();
let files = generate(&surface, &config).expect("generate should not fail");
assert_eq!(files.len(), 2, "expected 2 generated files, got {}", files.len());
let paths: Vec<&str> = files
.iter()
.map(|f| f.path.file_name().unwrap().to_str().unwrap())
.collect();
assert!(paths.contains(&"service.rs"), "expected service.rs in output");
assert!(paths.contains(&"service.py"), "expected service.py in output");
}
#[test]
fn generate_returns_empty_for_no_services() {
let surface = ApiSurface::default();
let config = make_test_config();
let files = generate(&surface, &config).expect("generate should not fail");
assert!(files.is_empty(), "expected no files for surface without services");
}
fn make_test_config() -> ResolvedCrateConfig {
use crate::core::config::resolved::ResolvedCrateConfig;
ResolvedCrateConfig {
name: "my-crate".to_owned(),
..ResolvedCrateConfig::default()
}
}
}