Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2b38303
feat(hiroz-py): rclpy alignment — P1–P8 API improvements
YuanYuYuan May 29, 2026
381a9ae
fix(hiroz-py): surface P6 callback thread errors via last_error property
YuanYuYuan May 29, 2026
46007dd
test(hiroz-py): close P1/P3/P5/P7 coverage gaps in rclpy alignment tests
YuanYuYuan Jul 5, 2026
fe16a33
docs(hiroz-py): document P1-P8 rclpy alignment and add migration guide
YuanYuYuan Jul 5, 2026
18c01c9
fix(hiroz-py): raise TimeoutError from get_result instead of returnin…
YuanYuYuan Jul 5, 2026
cfde179
fix(hiroz-py): route action errors through map_call_error
YuanYuYuan Jul 28, 2026
2fe4621
fix(hiroz-py): fail loudly on bad service names and type hashes
YuanYuYuan Jul 28, 2026
b084999
fix(hiroz-py): make map_call_error accept zenoh errors, fix get_resul…
YuanYuYuan Jul 28, 2026
6e5b144
style(hiroz-py): rustfmt the get_result timeout arm
YuanYuYuan Jul 28, 2026
06a64f0
fix(hiroz-py): stop boxing anyhow errors before timeout classification
YuanYuYuan Jul 28, 2026
b1df460
feat(hiroz-py): anchor exceptions under RuntimeError and builtins.Tim…
YuanYuYuan Jul 28, 2026
2a83aa7
fix(hiroz-py): address review findings on validation, leaks and docs
YuanYuYuan Jul 28, 2026
771c309
feat(hiroz-py): wire P7 into codegen; make callback-server shutdown safe
YuanYuYuan Jul 28, 2026
866075a
fix(hiroz-py): hoist timeout validation out of the allow_threads clos…
YuanYuYuan Jul 28, 2026
af148a8
fix(hiroz-py): reject empty path components in service names
YuanYuYuan Jul 28, 2026
9f72a83
test(hiroz-py): correct the invalid-name cases to what hiroz actually…
YuanYuYuan Jul 28, 2026
e5a274a
fix(hiroz-py): wait_for_server polls the full action-server predicate
YuanYuYuan Jul 28, 2026
7c08392
chore(msgs): regenerate python msgspec stubs after rebase
YuanYuYuan Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 156 additions & 22 deletions crates/hiroz-codegen/src/python_msgspec_generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,21 @@
//! This module generates both Python msgspec structs and complete Rust PyO3 modules
//! from ROS message definitions, eliminating the need for manual registry code.

use crate::types::{ArrayType, FieldType, ResolvedMessage, ResolvedService};
use crate::types::{ArrayType, FieldType, ResolvedAction, ResolvedMessage, ResolvedService};
use anyhow::Result;
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::fs;
use std::path::Path;

/// Generate both Python msgspec structs AND complete Rust PyO3 module
pub fn generate_python_bindings(
messages: &[ResolvedMessage],
services: &[ResolvedService],
actions: &[ResolvedAction],
python_output_dir: &Path,
rust_output_path: &Path,
) -> Result<()> {
Expand All @@ -30,6 +33,49 @@ pub fn generate_python_bindings(
msgs.sort_by(|a, b| a.parsed.name.cmp(&b.parsed.name));
}

// Group services by package so we can emit rclpy-style grouping classes (P4).
let mut service_groups: HashMap<String, Vec<&ResolvedService>> = HashMap::new();
for srv in services {
service_groups
.entry(srv.parsed.package.clone())
.or_default()
.push(srv);
Comment thread
YuanYuYuan marked this conversation as resolved.
}

// Group actions by package so we can emit rclpy-style grouping classes (P7).
let mut action_groups: HashMap<String, Vec<&ResolvedAction>> = HashMap::new();
for action in actions {
action_groups
.entry(action.parsed.package.clone())
.or_default()
.push(action);
}

// Group action Goal/Result/Feedback by package, tracking the action type
// hash the same way service Request/Response track the service hash.
let mut action_messages: BTreeMap<String, Vec<&ResolvedMessage>> = BTreeMap::new();
let mut action_hashes: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
for action in actions {
let action_hash = action.type_hash.to_rihs_string();
for part in [
Some(&action.goal),
action.result.as_ref(),
action.feedback.as_ref(),
]
.into_iter()
.flatten()
{
action_messages
.entry(part.parsed.package.clone())
.or_default()
.push(part);
action_hashes
.entry(part.parsed.package.clone())
.or_default()
.insert(part.parsed.name.clone(), action_hash.clone());
}
}

// Group service Request/Response by package, and track service type hashes
let mut service_messages: BTreeMap<String, Vec<&ResolvedMessage>> = BTreeMap::new();
let mut service_hashes: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
Expand Down Expand Up @@ -64,9 +110,20 @@ pub fn generate_python_bindings(
srv_msgs.sort_by(|a, b| a.parsed.name.cmp(&b.parsed.name));
}

// Generate Python msgspec structs (one file per package)
for (package_name, package_msgs) in &packages {
// Combine regular messages with service Request/Response for this package
// One file per package, covering packages that contribute only services or
// only actions as well as those with plain messages.
let all_packages: BTreeSet<String> = packages
.keys()
.chain(service_messages.keys())
.chain(action_messages.keys())
.cloned()
.collect();

for package_name in &all_packages {
let package_msgs = packages
.get(package_name)
.map(|v| v.as_slice())
.unwrap_or(&[]);
let srv_msgs = service_messages
.get(package_name)
.map(|v| v.as_slice())
Expand All @@ -75,32 +132,36 @@ pub fn generate_python_bindings(
.get(package_name)
.cloned()
.unwrap_or_default();
let srv_groups = service_groups
.get(package_name)
.map(|v| v.as_slice())
.unwrap_or(&[]);
let act_msgs = action_messages
.get(package_name)
.map(|v| v.as_slice())
.unwrap_or(&[]);
let act_hashes = action_hashes.get(package_name).cloned().unwrap_or_default();
let act_groups = action_groups
.get(package_name)
.map(|v| v.as_slice())
.unwrap_or(&[]);

let python_code = generate_python_package_with_services(
package_name,
package_msgs,
srv_msgs,
&svc_hashes,
srv_groups,
act_msgs,
&act_hashes,
act_groups,
)?;
let output_path = python_output_dir.join(format!("{}.py", package_name));
fs::write(output_path, python_code)?;
}

// Generate Python files for packages that only have service types
for (package_name, srv_msgs) in &service_messages {
if !packages.contains_key(package_name) {
let svc_hashes = service_hashes
.get(package_name)
.cloned()
.unwrap_or_default();
let python_code =
generate_python_package_with_services(package_name, &[], srv_msgs, &svc_hashes)?;
let output_path = python_output_dir.join(format!("{}.py", package_name));
fs::write(output_path, python_code)?;
}
}

// Generate __init__.py for Python package
let init_code = generate_python_init(&packages)?;
let init_code = generate_python_init(&all_packages)?;
fs::write(python_output_dir.join("__init__.py"), init_code)?;

// Generate COMPLETE Rust PyO3 module (replaces python_registry.rs entirely)
Expand All @@ -118,11 +179,16 @@ fn tokens_to_string(tokens: TokenStream) -> String {
}

/// Generate Python msgspec structs for a package (messages + service Request/Response)
#[allow(clippy::too_many_arguments)]
fn generate_python_package_with_services(
package_name: &str,
messages: &[&ResolvedMessage],
service_messages: &[&ResolvedMessage],
service_hashes: &BTreeMap<String, String>,
service_groups: &[&ResolvedService],
action_messages: &[&ResolvedMessage],
action_hashes: &BTreeMap<String, String>,
action_groups: &[&ResolvedAction],
) -> Result<String> {
let mut code = format!(
"\"\"\"Auto-generated ROS 2 message types for {}.\"\"\"\n\
Expand All @@ -142,9 +208,77 @@ fn generate_python_package_with_services(
code.push_str(&generate_msgspec_struct(msg, svc_hash.map(|s| s.as_str()))?);
}

// Generate action Goal/Result/Feedback structs with the action type hash
for msg in action_messages {
let act_hash = action_hashes.get(&msg.parsed.name);
code.push_str(&generate_msgspec_struct(msg, act_hash.map(|s| s.as_str()))?);
}

// Emit rclpy-style service grouping classes (P4). These reference the
// Request/Response structs above, so they must come after them.
for srv in service_groups {
code.push_str(&generate_service_grouping_class(srv));
}

// Emit rclpy-style action grouping classes (P7), after the structs they
// reference.
for action in action_groups {
code.push_str(&generate_action_grouping_class(action));
}

Ok(code)
}

/// Generate a service grouping class: `AddTwoInts.Request` / `.Response` (P4).
///
/// Lets `create_client`/`create_server` accept a single rclpy-style type
/// (`example_interfaces.AddTwoInts`) instead of the bare Request class.
fn generate_service_grouping_class(srv: &ResolvedService) -> String {
let srv_name = &srv.parsed.name;
let package = &srv.parsed.package;
let request_struct = &srv.request.parsed.name;
let response_struct = &srv.response.parsed.name;
format!(
"class {srv_name}:\n \
\"\"\"Service grouping type. Use {srv_name}.Request and {srv_name}.Response.\"\"\"\n \
__srvtype__: ClassVar[str] = '{package}/srv/{srv_name}'\n \
Request: ClassVar[type] = {request_struct}\n \
Response: ClassVar[type] = {response_struct}\n\n"
)
}

/// Generate an action grouping class: `Fibonacci.Goal` / `.Result` / `.Feedback` (P7).
///
/// Lets `create_action_client`/`create_action_server` accept a single
/// rclpy-style type instead of three separate structs. `Result` and `Feedback`
/// are optional in the `.action` format, so only emit the members that exist —
/// referencing an absent struct would produce a NameError on import.
fn generate_action_grouping_class(action: &ResolvedAction) -> String {
let action_name = &action.parsed.name;
let package = &action.parsed.package;
let mut code = format!(
"class {action_name}:\n \
\"\"\"Action grouping type. Use {action_name}.Goal, .Result and .Feedback.\"\"\"\n \
__actiontype__: ClassVar[str] = '{package}/action/{action_name}'\n \
Goal: ClassVar[type] = {}\n",
action.goal.parsed.name
);
if let Some(result) = &action.result {
code.push_str(&format!(
" Result: ClassVar[type] = {}\n",
result.parsed.name
));
}
if let Some(feedback) = &action.feedback {
code.push_str(&format!(
" Feedback: ClassVar[type] = {}\n",
feedback.parsed.name
));
}
code.push('\n');
code
}

fn rust_to_python_type(field_type: &FieldType, current_package: &str) -> Result<String> {
// Get the base field type (without array indicators)
let base_type = &field_type.base_type;
Expand Down Expand Up @@ -703,17 +837,17 @@ fn generate_serialize_to_zbuf(
}
}

fn generate_python_init(packages: &BTreeMap<String, Vec<&ResolvedMessage>>) -> Result<String> {
fn generate_python_init(packages: &BTreeSet<String>) -> Result<String> {
let mut code =
"\"\"\"Auto-generated ROS 2 message types package.\"\"\"\n\n# Import all message types\n"
.to_string();

for package_name in packages.keys() {
for package_name in packages {
code.push_str(&format!("from . import {}\n", package_name));
}

code.push_str("\n__all__ = [\n");
for package_name in packages.keys() {
for package_name in packages {
code.push_str(&format!(" \"{}\",\n", package_name));
}
code.push_str("]\n");
Expand Down
12 changes: 11 additions & 1 deletion crates/hiroz-msgs/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ fn main() -> Result<()> {
#[cfg(feature = "python_registry")]
{
// Use hiroz_codegen's discovery and resolver to get resolved messages
let (messages, services, _actions) =
let (messages, services, actions) =
hiroz_codegen::discovery::discover_all(&package_refs)?;

// Filter out problematic messages
Expand Down Expand Up @@ -120,10 +120,19 @@ fn main() -> Result<()> {
})
.collect();

let actions: Vec<_> = actions
.into_iter()
.filter(|act| {
let full_name = format!("{}/{}", act.package, act.name);
!full_name.starts_with("actionlib_msgs/")
})
.collect();

// Resolve dependencies using hiroz_codegen resolver
let mut resolver = hiroz_codegen::resolver::Resolver::new(is_humble);
let resolved_msgs = resolver.resolve_messages(messages)?;
let resolved_srvs = resolver.resolve_services(services)?;
let resolved_actions = resolver.resolve_actions(actions)?;

// Create Python output directory
let python_output_dir = PathBuf::from("python/hiroz_msgs_py/types");
Expand All @@ -133,6 +142,7 @@ fn main() -> Result<()> {
python_msgspec_generator::generate_python_bindings(
&resolved_msgs,
&resolved_srvs,
&resolved_actions,
&python_output_dir,
&out_dir.join("python_bindings.rs"),
)?;
Expand Down
2 changes: 2 additions & 0 deletions crates/hiroz-msgs/python/hiroz_msgs_py/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

# Import all message types
from . import action_msgs
from . import action_tutorials_interfaces
from . import builtin_interfaces
from . import example_interfaces
from . import geometry_msgs
Expand All @@ -16,6 +17,7 @@

__all__ = [
"action_msgs",
"action_tutorials_interfaces",
"builtin_interfaces",
"example_interfaces",
"geometry_msgs",
Expand Down
6 changes: 6 additions & 0 deletions crates/hiroz-msgs/python/hiroz_msgs_py/types/action_msgs.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,9 @@ class CancelGoalResponse(msgspec.Struct, frozen=True, kw_only=True):
__msgtype__: ClassVar[str] = 'action_msgs/msg/CancelGoalResponse'
__hash__: ClassVar[str] = 'RIHS01_c66d49f351ea4375bf3eef8569e74b7afc19305d9fa94c71b412262e411f2a8f'

class CancelGoal:
"""Service grouping type. Use CancelGoal.Request and CancelGoal.Response."""
__srvtype__: ClassVar[str] = 'action_msgs/srv/CancelGoal'
Request: ClassVar[type] = CancelGoalRequest
Response: ClassVar[type] = CancelGoalResponse

Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Auto-generated ROS 2 message types for action_tutorials_interfaces."""
import msgspec
from typing import ClassVar

class FibonacciGoal(msgspec.Struct, frozen=True, kw_only=True):
order: int = 0

__msgtype__: ClassVar[str] = 'action_tutorials_interfaces/msg/FibonacciGoal'
__hash__: ClassVar[str] = 'RIHS01_12b2d4be0186b9d26e02c9be2cbbc9438ab3ba78b66806b3f7f4111bb75199cb'

class FibonacciResult(msgspec.Struct, frozen=True, kw_only=True):
sequence: list[int] = msgspec.field(default_factory=list)

__msgtype__: ClassVar[str] = 'action_tutorials_interfaces/msg/FibonacciResult'
__hash__: ClassVar[str] = 'RIHS01_12b2d4be0186b9d26e02c9be2cbbc9438ab3ba78b66806b3f7f4111bb75199cb'

class FibonacciFeedback(msgspec.Struct, frozen=True, kw_only=True):
partial_sequence: list[int] = msgspec.field(default_factory=list)

__msgtype__: ClassVar[str] = 'action_tutorials_interfaces/msg/FibonacciFeedback'
__hash__: ClassVar[str] = 'RIHS01_12b2d4be0186b9d26e02c9be2cbbc9438ab3ba78b66806b3f7f4111bb75199cb'

class Fibonacci:
"""Action grouping type. Use Fibonacci.Goal, .Result and .Feedback."""
__actiontype__: ClassVar[str] = 'action_tutorials_interfaces/action/Fibonacci'
Goal: ClassVar[type] = FibonacciGoal
Result: ClassVar[type] = FibonacciResult
Feedback: ClassVar[type] = FibonacciFeedback

Loading
Loading