use std::collections::HashSet;
use std::fmt::Write as FmtWrite;
use std::fs;
use std::io;
use std::path::Path;
use brk_types::{Index, TreeNode};
use serde_json::Value;
use crate::{
ClientMetadata, Endpoint, FieldNamePosition, IndexSetPattern, PatternField, StructuralPattern,
TypeSchemas, extract_inner_type, get_fields_with_child_info, get_node_fields,
get_pattern_instance_base, is_enum_schema, to_pascal_case, to_snake_case,
};
pub fn generate_python_client(
metadata: &ClientMetadata,
endpoints: &[Endpoint],
schemas: &TypeSchemas,
output_dir: &Path,
) -> io::Result<()> {
let mut output = String::new();
writeln!(output, "# Auto-generated BRK Python client").unwrap();
writeln!(output, "# Do not edit manually\n").unwrap();
writeln!(output, "from __future__ import annotations").unwrap();
writeln!(
output,
"from typing import TypeVar, Generic, Any, Optional, List, Literal, TypedDict"
)
.unwrap();
writeln!(output, "import httpx\n").unwrap();
writeln!(output, "T = TypeVar('T')\n").unwrap();
generate_type_definitions(&mut output, schemas);
generate_base_client(&mut output);
generate_metric_node(&mut output);
generate_index_accessors(&mut output, &metadata.index_set_patterns);
generate_structural_patterns(&mut output, &metadata.structural_patterns, metadata);
generate_tree_classes(&mut output, &metadata.catalog, metadata);
generate_main_client(&mut output, endpoints);
fs::write(output_dir.join("client.py"), output)?;
Ok(())
}
fn generate_type_definitions(output: &mut String, schemas: &TypeSchemas) {
if schemas.is_empty() {
return;
}
writeln!(output, "# Type definitions\n").unwrap();
let sorted_names = topological_sort_schemas(schemas);
for name in sorted_names {
let Some(schema) = schemas.get(&name) else {
continue;
};
if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
writeln!(output, "class {}(TypedDict):", name).unwrap();
for (prop_name, prop_schema) in props {
let prop_type = schema_to_python_type_ctx(prop_schema, Some(&name));
let safe_name = escape_python_keyword(prop_name);
writeln!(output, " {}: {}", safe_name, prop_type).unwrap();
}
writeln!(output).unwrap();
} else if is_enum_schema(schema) {
let py_type = schema_to_python_type_ctx(schema, Some(&name));
writeln!(output, "{} = {}", name, py_type).unwrap();
} else {
let py_type = schema_to_python_type_ctx(schema, Some(&name));
writeln!(output, "{} = {}", name, py_type).unwrap();
}
}
writeln!(output).unwrap();
}
fn topological_sort_schemas(schemas: &TypeSchemas) -> Vec<String> {
use std::collections::{HashMap, HashSet};
let mut deps: HashMap<String, HashSet<String>> = HashMap::new();
for (name, schema) in schemas {
let mut type_deps = HashSet::new();
collect_schema_refs(schema, &mut type_deps);
type_deps.retain(|d| schemas.contains_key(d));
deps.insert(name.clone(), type_deps);
}
let mut in_degree: HashMap<String, usize> = HashMap::new();
for name in schemas.keys() {
in_degree.insert(name.clone(), 0);
}
for type_deps in deps.values() {
for dep in type_deps {
*in_degree.entry(dep.clone()).or_insert(0) += 1;
}
}
let mut queue: Vec<String> = in_degree
.iter()
.filter(|(_, count)| **count == 0)
.map(|(name, _)| name.clone())
.collect();
queue.sort();
let mut result = Vec::new();
while let Some(name) = queue.pop() {
result.push(name.clone());
if let Some(type_deps) = deps.get(&name) {
for dep in type_deps {
if let Some(count) = in_degree.get_mut(dep) {
*count = count.saturating_sub(1);
if *count == 0 {
queue.push(dep.clone());
queue.sort(); }
}
}
}
}
result.reverse();
let result_set: HashSet<_> = result.iter().cloned().collect();
let mut missing: Vec<_> = schemas
.keys()
.filter(|k| !result_set.contains(*k))
.cloned()
.collect();
missing.sort();
result.extend(missing);
result
}
fn collect_schema_refs(schema: &Value, refs: &mut std::collections::HashSet<String>) {
match schema {
Value::Object(map) => {
if let Some(ref_path) = map.get("$ref").and_then(|r| r.as_str())
&& let Some(type_name) = ref_path.rsplit('/').next()
{
refs.insert(type_name.to_string());
}
for value in map.values() {
collect_schema_refs(value, refs);
}
}
Value::Array(arr) => {
for item in arr {
collect_schema_refs(item, refs);
}
}
_ => {}
}
}
fn json_type_to_python(ty: &str, schema: &Value, current_type: Option<&str>) -> String {
match ty {
"integer" => "int".to_string(),
"number" => "float".to_string(),
"boolean" => "bool".to_string(),
"string" => "str".to_string(),
"null" => "None".to_string(),
"array" => {
let item_type = schema
.get("items")
.map(|s| schema_to_python_type_ctx(s, current_type))
.unwrap_or_else(|| "Any".to_string());
format!("List[{}]", item_type)
}
"object" => {
if let Some(add_props) = schema.get("additionalProperties") {
let value_type = schema_to_python_type_ctx(add_props, current_type);
return format!("dict[str, {}]", value_type);
}
"dict".to_string()
}
_ => "Any".to_string(),
}
}
fn schema_to_python_type_ctx(schema: &Value, current_type: Option<&str>) -> String {
if let Some(all_of) = schema.get("allOf").and_then(|v| v.as_array()) {
for item in all_of {
let resolved = schema_to_python_type_ctx(item, current_type);
if resolved != "Any" {
return resolved;
}
}
}
if let Some(ref_path) = schema.get("$ref").and_then(|r| r.as_str()) {
let type_name = ref_path.rsplit('/').next().unwrap_or("Any");
if current_type == Some(type_name) {
return format!("\"{}\"", type_name);
}
return type_name.to_string();
}
if let Some(enum_values) = schema.get("enum").and_then(|e| e.as_array()) {
let literals: Vec<String> = enum_values
.iter()
.filter_map(|v| v.as_str())
.map(|s| format!("\"{}\"", s))
.collect();
if !literals.is_empty() {
return format!("Literal[{}]", literals.join(", "));
}
}
if let Some(ty) = schema.get("type") {
if let Some(type_array) = ty.as_array() {
let types: Vec<String> = type_array
.iter()
.filter_map(|t| t.as_str())
.filter(|t| *t != "null") .map(|t| json_type_to_python(t, schema, current_type))
.collect();
let has_null = type_array.iter().any(|t| t.as_str() == Some("null"));
if types.len() == 1 {
let base_type = &types[0];
return if has_null {
format!("Optional[{}]", base_type)
} else {
base_type.clone()
};
} else if !types.is_empty() {
let union = types.join(" | ");
return if has_null {
format!("Optional[{}]", union)
} else {
union
};
}
}
if let Some(ty_str) = ty.as_str() {
return json_type_to_python(ty_str, schema, current_type);
}
}
if let Some(variants) = schema
.get("anyOf")
.or_else(|| schema.get("oneOf"))
.and_then(|v| v.as_array())
{
let types: Vec<String> = variants
.iter()
.map(|v| schema_to_python_type_ctx(v, current_type))
.collect();
let filtered: Vec<_> = types.iter().filter(|t| *t != "Any").collect();
if !filtered.is_empty() {
return filtered
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(" | ");
}
return types.join(" | ");
}
if let Some(format) = schema.get("format").and_then(|f| f.as_str()) {
return match format {
"int32" | "int64" => "int".to_string(),
"float" | "double" => "float".to_string(),
"date" | "date-time" => "str".to_string(),
_ => "Any".to_string(),
};
}
"Any".to_string()
}
fn escape_python_keyword(name: &str) -> String {
const PYTHON_KEYWORDS: &[&str] = &[
"False", "None", "True", "and", "as", "assert", "async", "await", "break", "class",
"continue", "def", "del", "elif", "else", "except", "finally", "for", "from", "global",
"if", "import", "in", "is", "lambda", "nonlocal", "not", "or", "pass", "raise", "return",
"try", "while", "with", "yield",
];
let name = if name
.chars()
.next()
.map(|c| c.is_ascii_digit())
.unwrap_or(false)
{
format!("_{}", name)
} else {
name.to_string()
};
if PYTHON_KEYWORDS.contains(&name.as_str()) {
format!("{}_", name)
} else {
name
}
}
fn generate_base_client(output: &mut String) {
writeln!(
output,
r#"class BrkError(Exception):
"""Custom error class for BRK client errors."""
def __init__(self, message: str, status: Optional[int] = None):
super().__init__(message)
self.status = status
class BrkClientBase:
"""Base HTTP client for making requests."""
def __init__(self, base_url: str, timeout: float = 30.0):
self.base_url = base_url
self.timeout = timeout
self._client = httpx.Client(timeout=timeout)
def get(self, path: str) -> Any:
"""Make a GET request."""
try:
response = self._client.get(f"{{self.base_url}}{{path}}")
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
raise BrkError(f"HTTP error: {{e.response.status_code}}", e.response.status_code)
except httpx.RequestError as e:
raise BrkError(str(e))
def close(self):
"""Close the HTTP client."""
self._client.close()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
"#
)
.unwrap();
}
fn generate_metric_node(output: &mut String) {
writeln!(
output,
r#"class MetricNode(Generic[T]):
"""A metric node that can fetch data for different indexes."""
def __init__(self, client: BrkClientBase, path: str):
self._client = client
self._path = path
def get(self) -> List[T]:
"""Fetch all data points for this metric."""
return self._client.get(self._path)
def get_range(self, from_date: str, to_date: str) -> List[T]:
"""Fetch data points within a date range."""
return self._client.get(f"{{self._path}}?from={{from_date}}&to={{to_date}}")
"#
)
.unwrap();
}
fn generate_index_accessors(output: &mut String, patterns: &[IndexSetPattern]) {
if patterns.is_empty() {
return;
}
writeln!(output, "# Index accessor classes\n").unwrap();
for pattern in patterns {
writeln!(output, "class {}(Generic[T]):", pattern.name).unwrap();
writeln!(
output,
" \"\"\"Index accessor for metrics with {} indexes.\"\"\"",
pattern.indexes.len()
)
.unwrap();
writeln!(output, " ").unwrap();
writeln!(
output,
" def __init__(self, client: BrkClientBase, base_path: str):"
)
.unwrap();
for index in &pattern.indexes {
let field_name = index_to_snake_case(index);
let path_segment = index.serialize_long();
writeln!(
output,
" self.{}: MetricNode[T] = MetricNode(client, f'{{base_path}}/{}')",
field_name, path_segment
)
.unwrap();
}
writeln!(output).unwrap();
}
}
fn index_to_snake_case(index: &Index) -> String {
format!("by_{}", to_snake_case(index.serialize_long()))
}
fn generate_structural_patterns(
output: &mut String,
patterns: &[StructuralPattern],
metadata: &ClientMetadata,
) {
if patterns.is_empty() {
return;
}
writeln!(output, "# Reusable structural pattern classes\n").unwrap();
for pattern in patterns {
let is_parameterizable = pattern.is_parameterizable();
if pattern.is_generic {
writeln!(output, "class {}(Generic[T]):", pattern.name).unwrap();
} else {
writeln!(output, "class {}:", pattern.name).unwrap();
}
writeln!(
output,
" \"\"\"Pattern struct for repeated tree structure.\"\"\""
)
.unwrap();
writeln!(output, " ").unwrap();
if is_parameterizable {
writeln!(
output,
" def __init__(self, client: BrkClientBase, acc: str):"
)
.unwrap();
writeln!(
output,
" \"\"\"Create pattern node with accumulated metric name.\"\"\""
)
.unwrap();
} else {
writeln!(
output,
" def __init__(self, client: BrkClientBase, base_path: str):"
)
.unwrap();
}
for field in &pattern.fields {
if is_parameterizable {
generate_parameterized_python_field(output, field, pattern, metadata);
} else {
generate_tree_path_python_field(output, field, metadata);
}
}
writeln!(output).unwrap();
}
}
fn generate_parameterized_python_field(
output: &mut String,
field: &PatternField,
pattern: &StructuralPattern,
metadata: &ClientMetadata,
) {
let field_name = to_snake_case(&field.name);
let py_type = field_to_python_type_generic(field, metadata, pattern.is_generic);
if metadata.is_pattern_type(&field.rust_type) {
let child_acc = if let Some(pos) = pattern.get_field_position(&field.name) {
match pos {
FieldNamePosition::Append(suffix) => format!("f'{{acc}}{}'", suffix),
FieldNamePosition::Prepend(prefix) => format!("f'{}{{acc}}'", prefix),
FieldNamePosition::Identity => "acc".to_string(),
FieldNamePosition::SetBase(base) => format!("'{}'", base),
}
} else {
format!("f'{{acc}}_{}'", field.name)
};
writeln!(
output,
" self.{}: {} = {}(client, {})",
field_name, py_type, field.rust_type, child_acc
)
.unwrap();
return;
}
let metric_expr = if let Some(pos) = pattern.get_field_position(&field.name) {
match pos {
FieldNamePosition::Append(suffix) => format!("f'/{{acc}}{}'", suffix),
FieldNamePosition::Prepend(prefix) => format!("f'/{}{{acc}}'", prefix),
FieldNamePosition::Identity => "f'/{acc}'".to_string(),
FieldNamePosition::SetBase(base) => format!("'/{}'", base),
}
} else {
format!("f'/{{acc}}_{}'", field.name)
};
if metadata.field_uses_accessor(field) {
let accessor = metadata.find_index_set_pattern(&field.indexes).unwrap();
writeln!(
output,
" self.{}: {} = {}(client, {})",
field_name, py_type, accessor.name, metric_expr
)
.unwrap();
} else {
writeln!(
output,
" self.{}: {} = MetricNode(client, {})",
field_name, py_type, metric_expr
)
.unwrap();
}
}
fn generate_tree_path_python_field(
output: &mut String,
field: &PatternField,
metadata: &ClientMetadata,
) {
let field_name = to_snake_case(&field.name);
let py_type = field_to_python_type(field, metadata);
if metadata.is_pattern_type(&field.rust_type) {
writeln!(
output,
" self.{}: {} = {}(client, f'{{base_path}}/{}')",
field_name, py_type, field.rust_type, field.name
)
.unwrap();
} else if metadata.field_uses_accessor(field) {
let accessor = metadata.find_index_set_pattern(&field.indexes).unwrap();
writeln!(
output,
" self.{}: {} = {}(client, f'{{base_path}}/{}')",
field_name, py_type, accessor.name, field.name
)
.unwrap();
} else {
writeln!(
output,
" self.{}: {} = MetricNode(client, f'{{base_path}}/{}')",
field_name, py_type, field.name
)
.unwrap();
}
}
fn field_to_python_type(field: &PatternField, metadata: &ClientMetadata) -> String {
field_to_python_type_generic(field, metadata, false)
}
fn field_to_python_type_generic(
field: &PatternField,
metadata: &ClientMetadata,
is_generic: bool,
) -> String {
field_to_python_type_with_generic_value(field, metadata, is_generic, None)
}
fn field_to_python_type_with_generic_value(
field: &PatternField,
metadata: &ClientMetadata,
is_generic: bool,
generic_value_type: Option<&str>,
) -> String {
let value_type = if is_generic && field.rust_type == "T" {
"T".to_string()
} else {
extract_inner_type(&field.rust_type)
};
if metadata.is_pattern_type(&field.rust_type) {
if metadata.is_pattern_generic(&field.rust_type)
&& let Some(vt) = generic_value_type
{
return format!("{}[{}]", field.rust_type, vt);
}
field.rust_type.clone()
} else if let Some(accessor) = metadata.find_index_set_pattern(&field.indexes) {
format!("{}[{}]", accessor.name, value_type)
} else {
format!("MetricNode[{}]", value_type)
}
}
fn generate_tree_classes(output: &mut String, catalog: &TreeNode, metadata: &ClientMetadata) {
writeln!(output, "# Catalog tree classes\n").unwrap();
let pattern_lookup = metadata.pattern_lookup();
let mut generated = HashSet::new();
generate_tree_class(
output,
"CatalogTree",
catalog,
&pattern_lookup,
metadata,
&mut generated,
);
}
fn generate_tree_class(
output: &mut String,
name: &str,
node: &TreeNode,
pattern_lookup: &std::collections::HashMap<Vec<PatternField>, String>,
metadata: &ClientMetadata,
generated: &mut HashSet<String>,
) {
let TreeNode::Branch(children) = node else {
return;
};
let fields_with_child_info = get_fields_with_child_info(children, name, pattern_lookup);
let fields: Vec<PatternField> = fields_with_child_info
.iter()
.map(|(f, _)| f.clone())
.collect();
if pattern_lookup.contains_key(&fields) && pattern_lookup.get(&fields) != Some(&name.to_string())
{
return;
}
if generated.contains(name) {
return;
}
generated.insert(name.to_string());
writeln!(output, "class {}:", name).unwrap();
writeln!(output, " \"\"\"Catalog tree node.\"\"\"").unwrap();
writeln!(output, " ").unwrap();
writeln!(
output,
" def __init__(self, client: BrkClientBase, base_path: str = ''):"
)
.unwrap();
for ((field, child_fields_opt), (child_name, child_node)) in
fields_with_child_info.iter().zip(children.iter())
{
let generic_value_type = child_fields_opt
.as_ref()
.and_then(|cf| metadata.get_generic_value_type(&field.rust_type, cf));
let py_type = field_to_python_type_with_generic_value(
field,
metadata,
false,
generic_value_type.as_deref(),
);
let field_name_py = to_snake_case(&field.name);
if metadata.is_pattern_type(&field.rust_type) {
let pattern = metadata.find_pattern(&field.rust_type);
let is_parameterizable = pattern.is_some_and(|p| p.is_parameterizable());
if is_parameterizable {
let metric_base = get_pattern_instance_base(child_node, child_name);
writeln!(
output,
" self.{}: {} = {}(client, '{}')",
field_name_py, py_type, field.rust_type, metric_base
)
.unwrap();
} else {
writeln!(
output,
" self.{}: {} = {}(client, f'{{base_path}}/{}')",
field_name_py, py_type, field.rust_type, field.name
)
.unwrap();
}
} else if metadata.field_uses_accessor(field) {
let metric_path = if let TreeNode::Leaf(leaf) = child_node {
format!("/{}", leaf.name())
} else {
format!("{{base_path}}/{}", field.name)
};
let accessor = metadata.find_index_set_pattern(&field.indexes).unwrap();
if metric_path.contains("{base_path}") {
writeln!(
output,
" self.{}: {} = {}(client, f'{}')",
field_name_py, py_type, accessor.name, metric_path
)
.unwrap();
} else {
writeln!(
output,
" self.{}: {} = {}(client, '{}')",
field_name_py, py_type, accessor.name, metric_path
)
.unwrap();
}
} else {
let metric_path = if let TreeNode::Leaf(leaf) = child_node {
format!("/{}", leaf.name())
} else {
format!("{{base_path}}/{}", field.name)
};
if metric_path.contains("{base_path}") {
writeln!(
output,
" self.{}: {} = MetricNode(client, f'{}')",
field_name_py, py_type, metric_path
)
.unwrap();
} else {
writeln!(
output,
" self.{}: {} = MetricNode(client, '{}')",
field_name_py, py_type, metric_path
)
.unwrap();
}
}
}
writeln!(output).unwrap();
for (child_name, child_node) in children {
if let TreeNode::Branch(grandchildren) = child_node {
let child_fields = get_node_fields(grandchildren, pattern_lookup);
if !pattern_lookup.contains_key(&child_fields) {
let child_class_name = format!("{}_{}", name, to_pascal_case(child_name));
generate_tree_class(
output,
&child_class_name,
child_node,
pattern_lookup,
metadata,
generated,
);
}
}
}
}
fn generate_main_client(output: &mut String, endpoints: &[Endpoint]) {
writeln!(output, "class BrkClient(BrkClientBase):").unwrap();
writeln!(
output,
" \"\"\"Main BRK client with catalog tree and API methods.\"\"\""
)
.unwrap();
writeln!(output, " ").unwrap();
writeln!(
output,
" def __init__(self, base_url: str = 'http://localhost:3000', timeout: float = 30.0):"
)
.unwrap();
writeln!(output, " super().__init__(base_url, timeout)").unwrap();
writeln!(output, " self.tree = CatalogTree(self)").unwrap();
writeln!(output).unwrap();
generate_api_methods(output, endpoints);
}
fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
for endpoint in endpoints {
if !endpoint.should_generate() {
continue;
}
let method_name = endpoint_to_method_name(endpoint);
let return_type = endpoint
.response_type
.as_deref()
.map(js_type_to_python)
.unwrap_or_else(|| "Any".to_string());
let params = build_method_params(endpoint);
writeln!(
output,
" def {}(self{}) -> {}:",
method_name, params, return_type
)
.unwrap();
if let Some(summary) = &endpoint.summary {
writeln!(output, " \"\"\"{}\"\"\"", summary).unwrap();
}
let path = build_path_template(&endpoint.path, &endpoint.path_params);
if endpoint.query_params.is_empty() {
if endpoint.path_params.is_empty() {
writeln!(output, " return self.get('{}')", path).unwrap();
} else {
writeln!(output, " return self.get(f'{}')", path).unwrap();
}
} else {
writeln!(output, " params = []").unwrap();
for param in &endpoint.query_params {
let safe_name = escape_python_keyword(¶m.name);
if param.required {
writeln!(
output,
" params.append(f'{}={{{}}}')",
param.name, safe_name
)
.unwrap();
} else {
writeln!(
output,
" if {} is not None: params.append(f'{}={{{}}}')",
safe_name, param.name, safe_name
)
.unwrap();
}
}
writeln!(output, " query = '&'.join(params)").unwrap();
writeln!(
output,
" return self.get(f'{}{{\"?\" + query if query else \"\"}}')",
path
)
.unwrap();
}
writeln!(output).unwrap();
}
}
fn endpoint_to_method_name(endpoint: &Endpoint) -> String {
to_snake_case(&endpoint.operation_name())
}
fn js_type_to_python(js_type: &str) -> String {
if let Some(inner) = js_type.strip_suffix("[]") {
format!("List[{}]", js_type_to_python(inner))
} else {
match js_type {
"number" => "int".to_string(),
"boolean" => "bool".to_string(),
"string" => "str".to_string(),
"null" => "None".to_string(),
"Object" | "object" => "dict".to_string(),
"*" => "Any".to_string(),
_ => js_type.to_string(),
}
}
}
fn build_method_params(endpoint: &Endpoint) -> String {
let mut params = Vec::new();
for param in &endpoint.path_params {
let safe_name = escape_python_keyword(¶m.name);
params.push(format!(", {}: str", safe_name));
}
for param in &endpoint.query_params {
let safe_name = escape_python_keyword(¶m.name);
if param.required {
params.push(format!(", {}: str", safe_name));
} else {
params.push(format!(", {}: Optional[str] = None", safe_name));
}
}
params.join("")
}
fn build_path_template(path: &str, path_params: &[super::Parameter]) -> String {
let mut result = path.to_string();
for param in path_params {
let placeholder = format!("{{{}}}", param.name);
let safe_name = escape_python_keyword(¶m.name);
let interpolation = format!("{{{}}}", safe_name);
result = result.replace(&placeholder, &interpolation);
}
result
}