reachpy 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,201 @@
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 4
4
+
5
+ [[package]]
6
+ name = "byteorder"
7
+ version = "1.5.0"
8
+ source = "registry+https://github.com/rust-lang/crates.io-index"
9
+ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
10
+
11
+ [[package]]
12
+ name = "cdr"
13
+ version = "0.2.4"
14
+ source = "registry+https://github.com/rust-lang/crates.io-index"
15
+ checksum = "9617422bf43fde9280707a7e90f8f7494389c182f5c70b0f67592d0f06d41dfa"
16
+ dependencies = [
17
+ "byteorder",
18
+ "serde",
19
+ ]
20
+
21
+ [[package]]
22
+ name = "heck"
23
+ version = "0.5.0"
24
+ source = "registry+https://github.com/rust-lang/crates.io-index"
25
+ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
26
+
27
+ [[package]]
28
+ name = "libc"
29
+ version = "0.2.186"
30
+ source = "registry+https://github.com/rust-lang/crates.io-index"
31
+ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
32
+
33
+ [[package]]
34
+ name = "once_cell"
35
+ version = "1.21.4"
36
+ source = "registry+https://github.com/rust-lang/crates.io-index"
37
+ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
38
+
39
+ [[package]]
40
+ name = "portable-atomic"
41
+ version = "1.13.1"
42
+ source = "registry+https://github.com/rust-lang/crates.io-index"
43
+ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
44
+
45
+ [[package]]
46
+ name = "proc-macro2"
47
+ version = "1.0.106"
48
+ source = "registry+https://github.com/rust-lang/crates.io-index"
49
+ checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
50
+ dependencies = [
51
+ "unicode-ident",
52
+ ]
53
+
54
+ [[package]]
55
+ name = "pyo3"
56
+ version = "0.28.3"
57
+ source = "registry+https://github.com/rust-lang/crates.io-index"
58
+ checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12"
59
+ dependencies = [
60
+ "libc",
61
+ "once_cell",
62
+ "portable-atomic",
63
+ "pyo3-build-config",
64
+ "pyo3-ffi",
65
+ "pyo3-macros",
66
+ ]
67
+
68
+ [[package]]
69
+ name = "pyo3-build-config"
70
+ version = "0.28.3"
71
+ source = "registry+https://github.com/rust-lang/crates.io-index"
72
+ checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e"
73
+ dependencies = [
74
+ "target-lexicon",
75
+ ]
76
+
77
+ [[package]]
78
+ name = "pyo3-ffi"
79
+ version = "0.28.3"
80
+ source = "registry+https://github.com/rust-lang/crates.io-index"
81
+ checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e"
82
+ dependencies = [
83
+ "libc",
84
+ "pyo3-build-config",
85
+ ]
86
+
87
+ [[package]]
88
+ name = "pyo3-macros"
89
+ version = "0.28.3"
90
+ source = "registry+https://github.com/rust-lang/crates.io-index"
91
+ checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813"
92
+ dependencies = [
93
+ "proc-macro2",
94
+ "pyo3-macros-backend",
95
+ "quote",
96
+ "syn",
97
+ ]
98
+
99
+ [[package]]
100
+ name = "pyo3-macros-backend"
101
+ version = "0.28.3"
102
+ source = "registry+https://github.com/rust-lang/crates.io-index"
103
+ checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb"
104
+ dependencies = [
105
+ "heck",
106
+ "proc-macro2",
107
+ "pyo3-build-config",
108
+ "quote",
109
+ "syn",
110
+ ]
111
+
112
+ [[package]]
113
+ name = "quote"
114
+ version = "1.0.45"
115
+ source = "registry+https://github.com/rust-lang/crates.io-index"
116
+ checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
117
+ dependencies = [
118
+ "proc-macro2",
119
+ ]
120
+
121
+ [[package]]
122
+ name = "reachpy-messages"
123
+ version = "0.1.0"
124
+ dependencies = [
125
+ "byteorder",
126
+ "cdr",
127
+ "pyo3",
128
+ "thiserror",
129
+ ]
130
+
131
+ [[package]]
132
+ name = "serde"
133
+ version = "1.0.228"
134
+ source = "registry+https://github.com/rust-lang/crates.io-index"
135
+ checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
136
+ dependencies = [
137
+ "serde_core",
138
+ ]
139
+
140
+ [[package]]
141
+ name = "serde_core"
142
+ version = "1.0.228"
143
+ source = "registry+https://github.com/rust-lang/crates.io-index"
144
+ checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
145
+ dependencies = [
146
+ "serde_derive",
147
+ ]
148
+
149
+ [[package]]
150
+ name = "serde_derive"
151
+ version = "1.0.228"
152
+ source = "registry+https://github.com/rust-lang/crates.io-index"
153
+ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
154
+ dependencies = [
155
+ "proc-macro2",
156
+ "quote",
157
+ "syn",
158
+ ]
159
+
160
+ [[package]]
161
+ name = "syn"
162
+ version = "2.0.117"
163
+ source = "registry+https://github.com/rust-lang/crates.io-index"
164
+ checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
165
+ dependencies = [
166
+ "proc-macro2",
167
+ "quote",
168
+ "unicode-ident",
169
+ ]
170
+
171
+ [[package]]
172
+ name = "target-lexicon"
173
+ version = "0.13.5"
174
+ source = "registry+https://github.com/rust-lang/crates.io-index"
175
+ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
176
+
177
+ [[package]]
178
+ name = "thiserror"
179
+ version = "1.0.69"
180
+ source = "registry+https://github.com/rust-lang/crates.io-index"
181
+ checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
182
+ dependencies = [
183
+ "thiserror-impl",
184
+ ]
185
+
186
+ [[package]]
187
+ name = "thiserror-impl"
188
+ version = "1.0.69"
189
+ source = "registry+https://github.com/rust-lang/crates.io-index"
190
+ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
191
+ dependencies = [
192
+ "proc-macro2",
193
+ "quote",
194
+ "syn",
195
+ ]
196
+
197
+ [[package]]
198
+ name = "unicode-ident"
199
+ version = "1.0.24"
200
+ source = "registry+https://github.com/rust-lang/crates.io-index"
201
+ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
@@ -0,0 +1,3 @@
1
+ [workspace]
2
+ members = ["crates/reachpy-messages"]
3
+ resolver = "2"
reachpy-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,72 @@
1
+ Metadata-Version: 2.4
2
+ Name: reachpy
3
+ Version: 0.1.0
4
+ Classifier: Development Status :: 3 - Alpha
5
+ Classifier: Intended Audience :: Developers
6
+ Classifier: License :: OSI Approved :: MIT License
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.10
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Rust
12
+ Classifier: Topic :: Software Development :: Libraries
13
+ Summary: Python-native robotics framework built on ROS2
14
+ Author: ReachPy Contributors
15
+ License: MIT
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
18
+
19
+ # ReachPy 🚀
20
+
21
+ **ReachPy** is a modern Python framework built to dramatically improve the Developer Experience (DX) for robotics engineers using ROS 2.
22
+
23
+ Building robotics applications in ROS 2 shouldn't mean wrestling with boilerplate, complex build pipelines, or rigid message compilation. ReachPy aims to provide a more Pythonic, intuitive, and dynamic interface to the ROS 2 ecosystem, letting you focus on writing robot logic rather than fighting the middleware.
24
+
25
+ ## ✨ Why ReachPy?
26
+
27
+ While `rclpy` provides the standard bindings for ROS 2, the workflow often requires dropping down to C++ build tools (`colcon`, `CMake`) just to define custom data structures. ReachPy is being built to bridge this gap, offering dynamic runtime features powered by a high-performance Rust backend.
28
+
29
+ ## 📦 Core Components
30
+
31
+ ### Dynamic Messaging (`reachpy_messages`)
32
+ At the core of ReachPy is our custom message serialization engine, written in Rust (via PyO3).
33
+
34
+ Instead of writing `.msg` files, recompiling your workspace, and sourcing environments just to add a new field to a message, `reachpy_messages` allows you to define, serialize, and deserialize native ROS 2 Common Data Representation (CDR) bytes **on the fly**.
35
+
36
+ - **Dynamic Schema Registry:** Register complex nested types, arrays, and primitives entirely at runtime.
37
+ - **Native CDR Support:** Outputs perfectly aligned Little-Endian bytes that are 100% compatible with standard ROS 2 nodes.
38
+ - **Blazing Fast:** Offloads the heavy lifting of byte-packing to Rust, ensuring your Python nodes stay performant.
39
+
40
+ ## 🚀 Quick Start (Preview)
41
+
42
+ *Note: ReachPy is currently in active development.*
43
+
44
+ ```python
45
+ import reachpy
46
+ from reachpy.messages import MessageSchema, FieldSchema, FieldType
47
+
48
+ # 1. Define a ROS 2 compatible message entirely in Python
49
+ schema = MessageSchema(
50
+ name="RobotWaypoint",
51
+ fields=[
52
+ FieldSchema("x", FieldType.Float64),
53
+ FieldSchema("y", FieldType.Float64),
54
+ FieldSchema("is_active", FieldType.Bool)
55
+ ]
56
+ )
57
+
58
+ # 2. Register it dynamically (no CMake or colcon build required!)
59
+ reachpy.messages.register_schema("RobotWaypoint", schema)
60
+
61
+ # 3. Serialize directly to ROS 2 CDR bytes to send over the wire
62
+ cdr_payload = reachpy.messages.serialize("RobotWaypoint", [15.2, -7.8, True])
63
+ ```
64
+
65
+ ## 🛠️ Building from Source
66
+
67
+ ReachPy uses Rust for its performance-critical extensions. You will need Rust and Python 3.8+ installed.
68
+
69
+ ```bash
70
+ # Build and install the Rust extension locally
71
+ maturin develop --release
72
+ ```
@@ -0,0 +1,54 @@
1
+ # ReachPy 🚀
2
+
3
+ **ReachPy** is a modern Python framework built to dramatically improve the Developer Experience (DX) for robotics engineers using ROS 2.
4
+
5
+ Building robotics applications in ROS 2 shouldn't mean wrestling with boilerplate, complex build pipelines, or rigid message compilation. ReachPy aims to provide a more Pythonic, intuitive, and dynamic interface to the ROS 2 ecosystem, letting you focus on writing robot logic rather than fighting the middleware.
6
+
7
+ ## ✨ Why ReachPy?
8
+
9
+ While `rclpy` provides the standard bindings for ROS 2, the workflow often requires dropping down to C++ build tools (`colcon`, `CMake`) just to define custom data structures. ReachPy is being built to bridge this gap, offering dynamic runtime features powered by a high-performance Rust backend.
10
+
11
+ ## 📦 Core Components
12
+
13
+ ### Dynamic Messaging (`reachpy_messages`)
14
+ At the core of ReachPy is our custom message serialization engine, written in Rust (via PyO3).
15
+
16
+ Instead of writing `.msg` files, recompiling your workspace, and sourcing environments just to add a new field to a message, `reachpy_messages` allows you to define, serialize, and deserialize native ROS 2 Common Data Representation (CDR) bytes **on the fly**.
17
+
18
+ - **Dynamic Schema Registry:** Register complex nested types, arrays, and primitives entirely at runtime.
19
+ - **Native CDR Support:** Outputs perfectly aligned Little-Endian bytes that are 100% compatible with standard ROS 2 nodes.
20
+ - **Blazing Fast:** Offloads the heavy lifting of byte-packing to Rust, ensuring your Python nodes stay performant.
21
+
22
+ ## 🚀 Quick Start (Preview)
23
+
24
+ *Note: ReachPy is currently in active development.*
25
+
26
+ ```python
27
+ import reachpy
28
+ from reachpy.messages import MessageSchema, FieldSchema, FieldType
29
+
30
+ # 1. Define a ROS 2 compatible message entirely in Python
31
+ schema = MessageSchema(
32
+ name="RobotWaypoint",
33
+ fields=[
34
+ FieldSchema("x", FieldType.Float64),
35
+ FieldSchema("y", FieldType.Float64),
36
+ FieldSchema("is_active", FieldType.Bool)
37
+ ]
38
+ )
39
+
40
+ # 2. Register it dynamically (no CMake or colcon build required!)
41
+ reachpy.messages.register_schema("RobotWaypoint", schema)
42
+
43
+ # 3. Serialize directly to ROS 2 CDR bytes to send over the wire
44
+ cdr_payload = reachpy.messages.serialize("RobotWaypoint", [15.2, -7.8, True])
45
+ ```
46
+
47
+ ## 🛠️ Building from Source
48
+
49
+ ReachPy uses Rust for its performance-critical extensions. You will need Rust and Python 3.8+ installed.
50
+
51
+ ```bash
52
+ # Build and install the Rust extension locally
53
+ maturin develop --release
54
+ ```
@@ -0,0 +1,19 @@
1
+ [package]
2
+ name = "reachpy-messages"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ description = "ReachPy message system — Python-native ROS2 message definitions with CDR serialization"
6
+
7
+ [lib]
8
+ name = "reachpy_messages"
9
+ crate-type = ["cdylib", "rlib"]
10
+ path = "lib.rs"
11
+
12
+ [dependencies]
13
+ pyo3 = { version = "0.28", features = ["extension-module"] }
14
+ cdr = "0.2"
15
+ thiserror = "1.0"
16
+ byteorder = "1.5"
17
+
18
+ [dev-dependencies]
19
+ pyo3 = { version = "0.28", features = ["auto-initialize"] }
@@ -0,0 +1,741 @@
1
+ use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
2
+ use pyo3::exceptions::{PyKeyError, PyTypeError, PyValueError};
3
+ use pyo3::prelude::*;
4
+ use pyo3::types::{PyBytes, PyDict, PyList, PyTuple};
5
+ use pyo3::IntoPyObjectExt;
6
+ use std::collections::HashMap;
7
+ use std::io::Cursor;
8
+ use std::sync::{Mutex, OnceLock};
9
+ use thiserror::Error;
10
+
11
+ const CDR_LE_ENCAPSULATION: [u8; 4] = [0x00, 0x01, 0x00, 0x00];
12
+
13
+ #[derive(Clone)]
14
+ struct FieldSchema {
15
+ name: String,
16
+ field_type: FieldType,
17
+ }
18
+
19
+ #[derive(Clone)]
20
+ enum FieldType {
21
+ Bool,
22
+ Int8,
23
+ Int16,
24
+ Int32,
25
+ Int64,
26
+ UInt8,
27
+ UInt16,
28
+ UInt32,
29
+ UInt64,
30
+ Float32,
31
+ Float64,
32
+ RosString,
33
+ RosBytes,
34
+ Array(Box<FieldType>),
35
+ Struct(String),
36
+ Time,
37
+ Duration,
38
+ }
39
+
40
+ impl FieldType {
41
+ fn from_name(name: &str) -> Result<Self, MessageError> {
42
+ if let Some(inner) = name
43
+ .strip_prefix("array<")
44
+ .and_then(|value| value.strip_suffix('>'))
45
+ {
46
+ return Ok(Self::Array(Box::new(Self::from_name(inner)?)));
47
+ }
48
+ if let Some(struct_name) = name.strip_prefix("struct:") {
49
+ if struct_name.trim().is_empty() {
50
+ return Err(MessageError::SchemaDefinition(
51
+ "Struct type must include a schema name".into(),
52
+ ));
53
+ }
54
+ return Ok(Self::Struct(struct_name.trim().to_string()));
55
+ }
56
+ match name {
57
+ "bool" => Ok(Self::Bool),
58
+ "int8" => Ok(Self::Int8),
59
+ "int16" => Ok(Self::Int16),
60
+ "int32" => Ok(Self::Int32),
61
+ "int64" => Ok(Self::Int64),
62
+ "uint8" => Ok(Self::UInt8),
63
+ "uint16" => Ok(Self::UInt16),
64
+ "uint32" => Ok(Self::UInt32),
65
+ "uint64" => Ok(Self::UInt64),
66
+ "float32" => Ok(Self::Float32),
67
+ "float64" => Ok(Self::Float64),
68
+ "string" | "ros_string" => Ok(Self::RosString),
69
+ "bytes" | "ros_bytes" => Ok(Self::RosBytes),
70
+ "time" => Ok(Self::Time),
71
+ "duration" => Ok(Self::Duration),
72
+ other => Err(MessageError::SerializationError(format!(
73
+ "Unsupported field type '{other}'"
74
+ ))),
75
+ }
76
+ }
77
+
78
+ fn as_name(&self) -> String {
79
+ match self {
80
+ Self::Bool => "bool".to_string(),
81
+ Self::Int8 => "int8".to_string(),
82
+ Self::Int16 => "int16".to_string(),
83
+ Self::Int32 => "int32".to_string(),
84
+ Self::Int64 => "int64".to_string(),
85
+ Self::UInt8 => "uint8".to_string(),
86
+ Self::UInt16 => "uint16".to_string(),
87
+ Self::UInt32 => "uint32".to_string(),
88
+ Self::UInt64 => "uint64".to_string(),
89
+ Self::Float32 => "float32".to_string(),
90
+ Self::Float64 => "float64".to_string(),
91
+ Self::RosString => "string".to_string(),
92
+ Self::RosBytes => "bytes".to_string(),
93
+ Self::Array(inner) => format!("array<{}>", inner.as_name()),
94
+ Self::Struct(name) => format!("struct:{name}"),
95
+ Self::Time => "time".to_string(),
96
+ Self::Duration => "duration".to_string(),
97
+ }
98
+ }
99
+
100
+ }
101
+
102
+ #[derive(Clone)]
103
+ struct MessageSchema {
104
+ fields: Vec<FieldSchema>,
105
+ }
106
+
107
+ #[derive(Debug, Error)]
108
+ pub enum MessageError {
109
+ #[error("Unknown custom message schema: {0}")]
110
+ UnknownSchema(String),
111
+ #[error("Field type mismatch on '{field}': expected {expected}, got {got}")]
112
+ TypeMismatch {
113
+ field: String,
114
+ expected: String,
115
+ got: String,
116
+ },
117
+ #[error("Serialization failed: {0}")]
118
+ SerializationError(String),
119
+ #[error("Deserialization failed: {0}")]
120
+ DeserializationError(String),
121
+ #[error("Schema already registered: {0}")]
122
+ DuplicateSchema(String),
123
+ #[error("Schema definition error: {0}")]
124
+ SchemaDefinition(String),
125
+ }
126
+
127
+ impl From<MessageError> for PyErr {
128
+ fn from(value: MessageError) -> Self {
129
+ match value {
130
+ MessageError::UnknownSchema(name) => PyKeyError::new_err(name),
131
+ MessageError::TypeMismatch { .. }
132
+ | MessageError::SerializationError(_)
133
+ | MessageError::DeserializationError(_) => PyTypeError::new_err(value.to_string()),
134
+ MessageError::DuplicateSchema(_) | MessageError::SchemaDefinition(_) => {
135
+ PyValueError::new_err(value.to_string())
136
+ }
137
+ }
138
+ }
139
+ }
140
+
141
+ static SCHEMA_REGISTRY: OnceLock<Mutex<HashMap<String, MessageSchema>>> = OnceLock::new();
142
+
143
+ fn get_schema_registry() -> &'static Mutex<HashMap<String, MessageSchema>> {
144
+ SCHEMA_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
145
+ }
146
+
147
+ fn register_schema_internal(name: &str, schema: MessageSchema) -> Result<(), MessageError> {
148
+ let mut registry = get_schema_registry()
149
+ .lock()
150
+ .map_err(|_| MessageError::SerializationError("Schema registry lock poisoned".into()))?;
151
+ if registry.contains_key(name) {
152
+ return Err(MessageError::DuplicateSchema(name.to_string()));
153
+ }
154
+ registry.insert(name.to_string(), schema);
155
+ Ok(())
156
+ }
157
+
158
+ fn get_schema_internal(name: &str) -> Result<MessageSchema, MessageError> {
159
+ let registry = get_schema_registry()
160
+ .lock()
161
+ .map_err(|_| MessageError::DeserializationError("Schema registry lock poisoned".into()))?;
162
+ registry
163
+ .get(name)
164
+ .cloned()
165
+ .ok_or_else(|| MessageError::UnknownSchema(name.to_string()))
166
+ }
167
+
168
+ fn parse_schema(fields: &Bound<'_, PyList>) -> Result<MessageSchema, MessageError> {
169
+ let mut parsed_fields = Vec::with_capacity(fields.len());
170
+ for item in fields.iter() {
171
+ let pair = item.cast::<PyList>().ok();
172
+ let tuple = item.cast::<PyTuple>().ok();
173
+
174
+ let (name, field_type_name): (String, String) = if let Some(p) = pair {
175
+ if p.len() != 2 {
176
+ return Err(MessageError::SchemaDefinition(
177
+ "Each field entry must contain exactly 2 items".into(),
178
+ ));
179
+ }
180
+ (
181
+ p.get_item(0)
182
+ .map_err(|e| MessageError::SchemaDefinition(e.to_string()))?
183
+ .extract::<String>()
184
+ .map_err(|e| MessageError::SchemaDefinition(e.to_string()))?,
185
+ p.get_item(1)
186
+ .map_err(|e| MessageError::SchemaDefinition(e.to_string()))?
187
+ .extract::<String>()
188
+ .map_err(|e| MessageError::SchemaDefinition(e.to_string()))?,
189
+ )
190
+ } else if let Some(t) = tuple {
191
+ if t.len() != 2 {
192
+ return Err(MessageError::SchemaDefinition(
193
+ "Each field entry must contain exactly 2 items".into(),
194
+ ));
195
+ }
196
+ (
197
+ t.get_item(0)
198
+ .map_err(|e| MessageError::SchemaDefinition(e.to_string()))?
199
+ .extract::<String>()
200
+ .map_err(|e| MessageError::SchemaDefinition(e.to_string()))?,
201
+ t.get_item(1)
202
+ .map_err(|e| MessageError::SchemaDefinition(e.to_string()))?
203
+ .extract::<String>()
204
+ .map_err(|e| MessageError::SchemaDefinition(e.to_string()))?,
205
+ )
206
+ } else {
207
+ return Err(MessageError::SchemaDefinition(
208
+ "Fields must be provided as list[tuple[str, str]]".into(),
209
+ ));
210
+ };
211
+
212
+ let field_type = FieldType::from_name(&field_type_name)?;
213
+ parsed_fields.push(FieldSchema { name, field_type });
214
+ }
215
+
216
+ Ok(MessageSchema {
217
+ fields: parsed_fields,
218
+ })
219
+ }
220
+
221
+ fn type_name_for_value(value: &Bound<'_, PyAny>) -> &'static str {
222
+ if value.extract::<bool>().is_ok() {
223
+ "bool"
224
+ } else if value.extract::<i64>().is_ok() {
225
+ "int"
226
+ } else if value.extract::<u64>().is_ok() {
227
+ "uint"
228
+ } else if value.extract::<f64>().is_ok() {
229
+ "float"
230
+ } else if value.extract::<String>().is_ok() {
231
+ "string"
232
+ } else if value.cast::<PyBytes>().is_ok() {
233
+ "bytes"
234
+ } else if value.cast::<PyList>().is_ok() || value.extract::<Vec<Py<PyAny>>>().is_ok() {
235
+ "list"
236
+ } else if value.cast::<PyDict>().is_ok() {
237
+ "dict"
238
+ } else {
239
+ "unknown"
240
+ }
241
+ }
242
+
243
+ fn write_field_value(
244
+ buf: &mut Vec<u8>,
245
+ py: Python<'_>,
246
+ field: &FieldSchema,
247
+ value: &Bound<'_, PyAny>,
248
+ ) -> Result<(), MessageError> {
249
+ let mismatch = |expected: String| MessageError::TypeMismatch {
250
+ field: field.name.clone(),
251
+ expected,
252
+ got: type_name_for_value(value).to_string(),
253
+ };
254
+ write_by_type(buf, py, &field.field_type, value, &field.name, &mismatch)
255
+ }
256
+
257
+ fn write_by_type(
258
+ buf: &mut Vec<u8>,
259
+ py: Python<'_>,
260
+ field_type: &FieldType,
261
+ value: &Bound<'_, PyAny>,
262
+ field_name: &str,
263
+ mismatch: &dyn Fn(String) -> MessageError,
264
+ ) -> Result<(), MessageError> {
265
+ match field_type {
266
+ FieldType::Bool => {
267
+ align_write(buf, 1);
268
+ buf.write_u8(if value
269
+ .extract::<bool>()
270
+ .map_err(|_| mismatch("bool".to_string()))?
271
+ {
272
+ 1
273
+ } else {
274
+ 0
275
+ })
276
+ .map_err(|e| MessageError::SerializationError(e.to_string()))?
277
+ }
278
+ FieldType::Int8 => {
279
+ align_write(buf, 1);
280
+ buf.write_i8(
281
+ value
282
+ .extract::<i8>()
283
+ .map_err(|_| mismatch("int8".to_string()))?,
284
+ )
285
+ .map_err(|e| MessageError::SerializationError(e.to_string()))?
286
+ }
287
+ FieldType::Int16 => {
288
+ align_write(buf, 2);
289
+ buf.write_i16::<LittleEndian>(
290
+ value
291
+ .extract::<i16>()
292
+ .map_err(|_| mismatch("int16".to_string()))?,
293
+ )
294
+ .map_err(|e| MessageError::SerializationError(e.to_string()))?
295
+ }
296
+ FieldType::Int32 => {
297
+ align_write(buf, 4);
298
+ buf.write_i32::<LittleEndian>(
299
+ value
300
+ .extract::<i32>()
301
+ .map_err(|_| mismatch("int32".to_string()))?,
302
+ )
303
+ .map_err(|e| MessageError::SerializationError(e.to_string()))?
304
+ }
305
+ FieldType::Int64 => {
306
+ align_write(buf, 8);
307
+ buf.write_i64::<LittleEndian>(
308
+ value
309
+ .extract::<i64>()
310
+ .map_err(|_| mismatch("int64".to_string()))?,
311
+ )
312
+ .map_err(|e| MessageError::SerializationError(e.to_string()))?
313
+ }
314
+ FieldType::UInt8 => {
315
+ align_write(buf, 1);
316
+ buf.write_u8(
317
+ value
318
+ .extract::<u8>()
319
+ .map_err(|_| mismatch("uint8".to_string()))?,
320
+ )
321
+ .map_err(|e| MessageError::SerializationError(e.to_string()))?
322
+ }
323
+ FieldType::UInt16 => {
324
+ align_write(buf, 2);
325
+ buf.write_u16::<LittleEndian>(
326
+ value
327
+ .extract::<u16>()
328
+ .map_err(|_| mismatch("uint16".to_string()))?,
329
+ )
330
+ .map_err(|e| MessageError::SerializationError(e.to_string()))?
331
+ }
332
+ FieldType::UInt32 => {
333
+ align_write(buf, 4);
334
+ buf.write_u32::<LittleEndian>(
335
+ value
336
+ .extract::<u32>()
337
+ .map_err(|_| mismatch("uint32".to_string()))?,
338
+ )
339
+ .map_err(|e| MessageError::SerializationError(e.to_string()))?
340
+ }
341
+ FieldType::UInt64 => {
342
+ align_write(buf, 8);
343
+ buf.write_u64::<LittleEndian>(
344
+ value
345
+ .extract::<u64>()
346
+ .map_err(|_| mismatch("uint64".to_string()))?,
347
+ )
348
+ .map_err(|e| MessageError::SerializationError(e.to_string()))?
349
+ }
350
+ FieldType::Float32 => {
351
+ align_write(buf, 4);
352
+ buf.write_f32::<LittleEndian>(
353
+ value
354
+ .extract::<f32>()
355
+ .map_err(|_| mismatch("float32".to_string()))?,
356
+ )
357
+ .map_err(|e| MessageError::SerializationError(e.to_string()))?
358
+ }
359
+ FieldType::Float64 => {
360
+ align_write(buf, 8);
361
+ buf.write_f64::<LittleEndian>(
362
+ value
363
+ .extract::<f64>()
364
+ .map_err(|_| mismatch("float64".to_string()))?,
365
+ )
366
+ .map_err(|e| MessageError::SerializationError(e.to_string()))?
367
+ }
368
+ FieldType::RosString => {
369
+ align_write(buf, 4);
370
+ let data = value
371
+ .extract::<String>()
372
+ .map_err(|_| mismatch("string".to_string()))?;
373
+ let bytes = data.as_bytes();
374
+ let ros_len = bytes.len() as u32 + 1;
375
+ buf.write_u32::<LittleEndian>(ros_len)
376
+ .map_err(|e| MessageError::SerializationError(e.to_string()))?;
377
+ buf.extend_from_slice(bytes);
378
+ buf.push(0_u8);
379
+ }
380
+ FieldType::RosBytes => {
381
+ align_write(buf, 4);
382
+ let data = value
383
+ .cast::<PyBytes>()
384
+ .map_err(|_| mismatch("bytes".to_string()))?
385
+ .as_bytes();
386
+ buf.write_u32::<LittleEndian>(data.len() as u32)
387
+ .map_err(|e| MessageError::SerializationError(e.to_string()))?;
388
+ buf.extend_from_slice(data);
389
+ }
390
+ FieldType::Array(inner) => {
391
+ align_write(buf, 4);
392
+ let items = value
393
+ .cast::<PyList>()
394
+ .map_err(|_| mismatch(format!("array<{}>", inner.as_name())))?;
395
+ buf.write_u32::<LittleEndian>(items.len() as u32)
396
+ .map_err(|e| MessageError::SerializationError(e.to_string()))?;
397
+ for item in items.iter() {
398
+ let item_mismatch = |expected: String| MessageError::TypeMismatch {
399
+ field: field_name.to_string(),
400
+ expected,
401
+ got: type_name_for_value(&item).to_string(),
402
+ };
403
+ write_by_type(buf, py, inner, &item, field_name, &item_mismatch)?;
404
+ }
405
+ }
406
+ FieldType::Struct(schema_name) => {
407
+ let nested = value
408
+ .cast::<PyDict>()
409
+ .map_err(|_| mismatch(format!("struct:{schema_name}")))?;
410
+ let nested_schema = get_schema_internal(schema_name)?;
411
+ for nested_field in &nested_schema.fields {
412
+ let nested_value = nested
413
+ .get_item(&nested_field.name)
414
+ .map_err(|e| MessageError::SerializationError(e.to_string()))?
415
+ .ok_or_else(|| {
416
+ MessageError::SerializationError(format!(
417
+ "Missing nested field '{}.{}'",
418
+ field_name, nested_field.name
419
+ ))
420
+ })?;
421
+ write_by_type(
422
+ buf,
423
+ py,
424
+ &nested_field.field_type,
425
+ &nested_value,
426
+ &format!("{field_name}.{}", nested_field.name),
427
+ mismatch,
428
+ )?;
429
+ }
430
+ }
431
+ FieldType::Time | FieldType::Duration => {
432
+ let dict = value
433
+ .cast::<PyDict>()
434
+ .map_err(|_| mismatch(field_type.as_name()))?;
435
+ align_write(buf, 4);
436
+ let sec = dict
437
+ .get_item("sec")
438
+ .map_err(|e| MessageError::SerializationError(e.to_string()))?
439
+ .ok_or_else(|| MessageError::SerializationError(format!("Missing field '{field_name}.sec'")))?
440
+ .extract::<i32>()
441
+ .map_err(|_| mismatch(format!("{} with int32 sec", field_type.as_name())))?;
442
+ buf.write_i32::<LittleEndian>(sec)
443
+ .map_err(|e| MessageError::SerializationError(e.to_string()))?;
444
+ align_write(buf, 4);
445
+ let nanosec = dict
446
+ .get_item("nanosec")
447
+ .map_err(|e| MessageError::SerializationError(e.to_string()))?
448
+ .ok_or_else(|| MessageError::SerializationError(format!("Missing field '{field_name}.nanosec'")))?
449
+ .extract::<u32>()
450
+ .map_err(|_| mismatch(format!("{} with uint32 nanosec", field_type.as_name())))?;
451
+ buf.write_u32::<LittleEndian>(nanosec)
452
+ .map_err(|e| MessageError::SerializationError(e.to_string()))?;
453
+ }
454
+ }
455
+
456
+ Ok(())
457
+ }
458
+
459
+ fn read_field_value<'py>(
460
+ py: Python<'py>,
461
+ reader: &mut Cursor<&[u8]>,
462
+ field: &FieldSchema,
463
+ ) -> Result<Py<PyAny>, MessageError> {
464
+ read_by_type(py, reader, &field.field_type, &field.name)
465
+ }
466
+
467
+ fn read_by_type<'py>(
468
+ py: Python<'py>,
469
+ reader: &mut Cursor<&[u8]>,
470
+ field_type: &FieldType,
471
+ field_name: &str,
472
+ ) -> Result<Py<PyAny>, MessageError> {
473
+ let value = match field_type {
474
+ FieldType::Bool => {
475
+ align_read(reader, 1)?;
476
+ (reader
477
+ .read_u8()
478
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?
479
+ != 0)
480
+ .into_py_any(py)
481
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?
482
+ }
483
+ FieldType::Int8 => { align_read(reader, 1)?;
484
+ reader
485
+ .read_i8()
486
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?
487
+ .into_py_any(py)
488
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?
489
+ }
490
+ FieldType::Int16 => { align_read(reader, 2)?;
491
+ reader
492
+ .read_i16::<LittleEndian>()
493
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?
494
+ .into_py_any(py)
495
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?
496
+ }
497
+ FieldType::Int32 => { align_read(reader, 4)?;
498
+ reader
499
+ .read_i32::<LittleEndian>()
500
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?
501
+ .into_py_any(py)
502
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?
503
+ }
504
+ FieldType::Int64 => { align_read(reader, 8)?;
505
+ reader
506
+ .read_i64::<LittleEndian>()
507
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?
508
+ .into_py_any(py)
509
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?
510
+ }
511
+ FieldType::UInt8 => { align_read(reader, 1)?;
512
+ reader
513
+ .read_u8()
514
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?
515
+ .into_py_any(py)
516
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?
517
+ }
518
+ FieldType::UInt16 => { align_read(reader, 2)?;
519
+ reader
520
+ .read_u16::<LittleEndian>()
521
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?
522
+ .into_py_any(py)
523
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?
524
+ }
525
+ FieldType::UInt32 => { align_read(reader, 4)?;
526
+ reader
527
+ .read_u32::<LittleEndian>()
528
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?
529
+ .into_py_any(py)
530
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?
531
+ }
532
+ FieldType::UInt64 => { align_read(reader, 8)?;
533
+ reader
534
+ .read_u64::<LittleEndian>()
535
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?
536
+ .into_py_any(py)
537
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?
538
+ }
539
+ FieldType::Float32 => { align_read(reader, 4)?;
540
+ reader
541
+ .read_f32::<LittleEndian>()
542
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?
543
+ .into_py_any(py)
544
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?
545
+ }
546
+ FieldType::Float64 => { align_read(reader, 8)?;
547
+ reader
548
+ .read_f64::<LittleEndian>()
549
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?
550
+ .into_py_any(py)
551
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?
552
+ }
553
+ FieldType::RosString => {
554
+ align_read(reader, 4)?;
555
+ let len = reader
556
+ .read_u32::<LittleEndian>()
557
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?;
558
+ let mut buf = vec![0_u8; len as usize];
559
+ std::io::Read::read_exact(reader, &mut buf)
560
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?;
561
+ if buf.last().copied() != Some(0) {
562
+ return Err(MessageError::DeserializationError(format!(
563
+ "Invalid ROS string for '{}': missing NUL terminator",
564
+ field_name
565
+ )));
566
+ }
567
+ buf.pop();
568
+ String::from_utf8(buf)
569
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?
570
+ .into_py_any(py)
571
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?
572
+ }
573
+ FieldType::RosBytes => {
574
+ align_read(reader, 4)?;
575
+ let len = reader
576
+ .read_u32::<LittleEndian>()
577
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?;
578
+ let mut buf = vec![0_u8; len as usize];
579
+ std::io::Read::read_exact(reader, &mut buf)
580
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?;
581
+ PyBytes::new(py, &buf).into_any().unbind()
582
+ }
583
+ FieldType::Array(inner) => {
584
+ align_read(reader, 4)?;
585
+ let len = reader
586
+ .read_u32::<LittleEndian>()
587
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?
588
+ as usize;
589
+ let out = PyList::empty(py);
590
+ for _ in 0..len {
591
+ out.append(read_by_type(py, reader, inner, field_name)?)
592
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?;
593
+ }
594
+ out.into_any().unbind()
595
+ }
596
+ FieldType::Struct(schema_name) => {
597
+ let schema = get_schema_internal(schema_name)?;
598
+ let out = PyDict::new(py);
599
+ for nested_field in &schema.fields {
600
+ let nested_value = read_by_type(
601
+ py,
602
+ reader,
603
+ &nested_field.field_type,
604
+ &format!("{field_name}.{}", nested_field.name),
605
+ )?;
606
+ out.set_item(&nested_field.name, nested_value)
607
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?;
608
+ }
609
+ out.into_any().unbind()
610
+ }
611
+ FieldType::Time | FieldType::Duration => {
612
+ align_read(reader, 4)?;
613
+ let sec = reader
614
+ .read_i32::<LittleEndian>()
615
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?;
616
+ align_read(reader, 4)?;
617
+ let nanosec = reader
618
+ .read_u32::<LittleEndian>()
619
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?;
620
+ let out = PyDict::new(py);
621
+ out.set_item("sec", sec)
622
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?;
623
+ out.set_item("nanosec", nanosec)
624
+ .map_err(|e| MessageError::DeserializationError(e.to_string()))?;
625
+ out.into_any().unbind()
626
+ }
627
+ };
628
+
629
+ Ok(value)
630
+ }
631
+
632
+ fn align_write(buf: &mut Vec<u8>, align: usize) {
633
+ let rem = buf.len() % align;
634
+ if rem != 0 {
635
+ let pad = align - rem;
636
+ buf.resize(buf.len() + pad, 0_u8);
637
+ }
638
+ }
639
+
640
+ fn align_read(reader: &mut Cursor<&[u8]>, align: usize) -> Result<(), MessageError> {
641
+ let pos = reader.position() as usize;
642
+ let rem = pos % align;
643
+ if rem != 0 {
644
+ let pad = (align - rem) as u64;
645
+ let new_pos = reader.position() + pad;
646
+ if (new_pos as usize) > reader.get_ref().len() {
647
+ return Err(MessageError::DeserializationError(
648
+ "Input truncated while aligning CDR stream".into(),
649
+ ));
650
+ }
651
+ reader.set_position(new_pos);
652
+ }
653
+ Ok(())
654
+ }
655
+
656
+ #[pyfunction]
657
+ fn register_schema(name: &str, fields: &Bound<'_, PyList>) -> PyResult<()> {
658
+ let schema = parse_schema(fields)?;
659
+ register_schema_internal(name, schema)?;
660
+ Ok(())
661
+ }
662
+
663
+ #[pyfunction]
664
+ fn get_schema(py: Python<'_>, name: &str) -> PyResult<Py<PyList>> {
665
+ let schema = get_schema_internal(name)?;
666
+ let out = PyList::empty(py);
667
+ for field in schema.fields {
668
+ out.append((field.name, field.field_type.as_name()))?;
669
+ }
670
+ Ok(out.unbind())
671
+ }
672
+
673
+ #[pyfunction]
674
+ fn schema_exists(name: &str) -> bool {
675
+ if let Ok(registry) = get_schema_registry().lock() {
676
+ registry.contains_key(name)
677
+ } else {
678
+ false
679
+ }
680
+ }
681
+
682
+ #[pyfunction]
683
+ fn unregister_schema(name: &str) -> PyResult<()> {
684
+ let mut registry = get_schema_registry()
685
+ .lock()
686
+ .map_err(|_| PyValueError::new_err("Schema registry lock poisoned"))?;
687
+ registry
688
+ .remove(name)
689
+ .ok_or_else(|| MessageError::UnknownSchema(name.to_string()))?;
690
+ Ok(())
691
+ }
692
+
693
+ #[pyfunction]
694
+ fn serialize(py: Python<'_>, schema_name: &str, values: &Bound<'_, PyDict>) -> PyResult<Py<PyBytes>> {
695
+ let schema = get_schema_internal(schema_name)?;
696
+ let mut out = Vec::with_capacity(128);
697
+ out.extend_from_slice(&CDR_LE_ENCAPSULATION);
698
+ for field in &schema.fields {
699
+ let value = values
700
+ .get_item(&field.name)?
701
+ .ok_or_else(|| MessageError::SerializationError(format!("Missing field '{}'", field.name)))?;
702
+ write_field_value(&mut out, py, field, &value)?;
703
+ }
704
+ Ok(PyBytes::new(py, &out).unbind())
705
+ }
706
+
707
+ #[pyfunction]
708
+ fn deserialize(py: Python<'_>, schema_name: &str, data: &[u8]) -> PyResult<Py<PyDict>> {
709
+ if data.len() < 4 {
710
+ return Err(PyValueError::new_err("CDR payload too short (missing encapsulation header)"));
711
+ }
712
+ if data[0..2] != CDR_LE_ENCAPSULATION[0..2] {
713
+ return Err(PyValueError::new_err(format!(
714
+ "Unsupported CDR encapsulation {:02x?}. Only little-endian CDR is currently supported",
715
+ &data[0..4]
716
+ )));
717
+ }
718
+
719
+ let schema = get_schema_internal(schema_name)?;
720
+ let out = PyDict::new(py);
721
+ let mut cursor = Cursor::new(data);
722
+ cursor.set_position(4);
723
+
724
+ for field in &schema.fields {
725
+ let value = read_field_value(py, &mut cursor, field)?;
726
+ out.set_item(&field.name, value)?;
727
+ }
728
+
729
+ Ok(out.unbind())
730
+ }
731
+
732
+ #[pymodule]
733
+ fn _reachpy_messages(m: &Bound<'_, PyModule>) -> PyResult<()> {
734
+ m.add_function(wrap_pyfunction!(register_schema, m)?)?;
735
+ m.add_function(wrap_pyfunction!(get_schema, m)?)?;
736
+ m.add_function(wrap_pyfunction!(schema_exists, m)?)?;
737
+ m.add_function(wrap_pyfunction!(unregister_schema, m)?)?;
738
+ m.add_function(wrap_pyfunction!(serialize, m)?)?;
739
+ m.add_function(wrap_pyfunction!(deserialize, m)?)?;
740
+ Ok(())
741
+ }
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["maturin>=1.0,<2.0"]
3
+ build-backend = "maturin"
4
+
5
+ [project]
6
+ name = "reachpy"
7
+ version = "0.1.0"
8
+ description = "Python-native robotics framework built on ROS2"
9
+ requires-python = ">=3.10"
10
+ license = { text = "MIT" }
11
+ readme = "README.md"
12
+ authors = [
13
+ { name = "ReachPy Contributors" },
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Rust",
24
+ "Topic :: Software Development :: Libraries",
25
+ ]
26
+
27
+ [tool.maturin]
28
+ features = ["pyo3/extension-module"]
29
+ python-source = "python"
30
+ module-name = "reachpy._reachpy_messages"
@@ -0,0 +1,5 @@
1
+ """ReachPy public Python package surface."""
2
+
3
+ from . import _reachpy_messages as reachpy_messages
4
+
5
+ __all__ = ["reachpy_messages"]