Skip to main content

nautilus_common/python/
factory.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Registries which resolve Python objects into owned Rust factories.
17//!
18//! A Python caller cannot hand ownership of a `#[pyclass]` instance to Rust, so subsystems which
19//! let Python install a backing accept a factory object instead. Each subsystem keeps a
20//! [`FactoryRegistry`] of extractors keyed by Python class name, then resolves an owned
21//! `Box<dyn Factory>` from an arbitrary Python object at configuration time.
22
23use ahash::AHashMap;
24use nautilus_core::python::to_pynotimplemented_err;
25use parking_lot::Mutex;
26use pyo3::{Py, PyAny, PyResult, Python};
27
28/// Function type for extracting a Python object into a boxed factory.
29pub type FactoryExtractor<T> = fn(Python<'_>, Py<PyAny>) -> PyResult<Box<T>>;
30
31/// Registry of Python factory extractors keyed by Python class name.
32///
33/// The `label` names the factory kind in error messages, for example `"message bus factory"`.
34#[derive(Debug)]
35pub struct FactoryRegistry<T: ?Sized> {
36    label: &'static str,
37    extractors_by_type: Mutex<AHashMap<String, FactoryExtractor<T>>>,
38}
39
40impl<T: ?Sized> FactoryRegistry<T> {
41    /// Creates an empty registry which describes itself with `label` in error messages.
42    #[must_use]
43    pub fn new(label: &'static str) -> Self {
44        Self {
45            label,
46            extractors_by_type: Mutex::new(AHashMap::new()),
47        }
48    }
49
50    /// Registers an extractor for a Python factory type name.
51    ///
52    /// Registering the same extractor again succeeds without change, so a Python module
53    /// initializer can run more than once per process.
54    ///
55    /// # Errors
56    ///
57    /// Returns an error if a different extractor is already registered for the type name.
58    pub fn register(
59        &self,
60        type_name: String,
61        extractor: FactoryExtractor<T>,
62    ) -> anyhow::Result<()> {
63        let mut extractors = self.extractors_by_type.lock();
64
65        if let Some(registered) = extractors.get(&type_name) {
66            if std::ptr::fn_addr_eq(*registered, extractor) {
67                return Ok(());
68            }
69
70            anyhow::bail!(
71                "A different {label} extractor is already registered for '{type_name}'",
72                label = self.label
73            );
74        }
75
76        extractors.insert(type_name, extractor);
77        Ok(())
78    }
79
80    /// Extracts a Python object into a boxed factory.
81    ///
82    /// # Errors
83    ///
84    /// Returns an error if no extractor is registered for the Python type or extraction fails.
85    pub fn extract(&self, py: Python<'_>, factory: Py<PyAny>) -> PyResult<Box<T>> {
86        let type_name = factory
87            .getattr(py, "__class__")?
88            .getattr(py, "__name__")?
89            .extract::<String>(py)?;
90        let extractors = self.extractors_by_type.lock();
91
92        match extractors.get(&type_name) {
93            Some(extractor) => extractor(py, factory),
94            None => Err(to_pynotimplemented_err(format!(
95                "No {label} extractor registered for '{type_name}'",
96                label = self.label
97            ))),
98        }
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use std::fmt::Debug;
105
106    use pyo3::{exceptions::PyNotImplementedError, types::PyDict};
107    use rstest::rstest;
108
109    use super::*;
110
111    trait StubFactory: Debug + Send + Sync {
112        fn name(&self) -> &'static str;
113    }
114
115    #[derive(Debug)]
116    #[pyo3::pyclass(name = "StubFactoryOne")]
117    struct StubFactoryOne;
118
119    impl StubFactory for StubFactoryOne {
120        fn name(&self) -> &'static str {
121            "one"
122        }
123    }
124
125    #[expect(
126        clippy::unnecessary_wraps,
127        reason = "signature must match the FactoryExtractor fn pointer"
128    )]
129    fn extract_one(_py: Python<'_>, _factory: Py<PyAny>) -> PyResult<Box<dyn StubFactory>> {
130        Ok(Box::new(StubFactoryOne))
131    }
132
133    #[expect(
134        clippy::unnecessary_wraps,
135        reason = "signature must match the FactoryExtractor fn pointer"
136    )]
137    fn extract_conflicting(_py: Python<'_>, _factory: Py<PyAny>) -> PyResult<Box<dyn StubFactory>> {
138        Ok(Box::new(StubFactoryOne))
139    }
140
141    #[rstest]
142    fn test_extract_resolves_registered_python_class() {
143        Python::initialize();
144        let registry = FactoryRegistry::<dyn StubFactory>::new("stub factory");
145        registry
146            .register("StubFactoryOne".to_string(), extract_one)
147            .unwrap();
148
149        Python::attach(|py| {
150            let factory = Py::new(py, StubFactoryOne).unwrap().into_any();
151
152            let extracted = registry.extract(py, factory).unwrap();
153
154            assert_eq!(extracted.name(), "one");
155        });
156    }
157
158    #[rstest]
159    fn test_extract_rejects_unregistered_python_class() {
160        Python::initialize();
161        let registry = FactoryRegistry::<dyn StubFactory>::new("stub factory");
162
163        Python::attach(|py| {
164            let factory = PyDict::new(py).unbind().into_any();
165
166            let error = registry.extract(py, factory).unwrap_err();
167
168            assert!(error.is_instance_of::<PyNotImplementedError>(py));
169            assert_eq!(
170                error.to_string(),
171                "NotImplementedError: No stub factory extractor registered for 'dict'"
172            );
173        });
174    }
175
176    #[rstest]
177    fn test_register_is_idempotent_for_the_same_extractor() {
178        let registry = FactoryRegistry::<dyn StubFactory>::new("stub factory");
179
180        registry
181            .register("StubFactoryOne".to_string(), extract_one)
182            .unwrap();
183
184        assert!(
185            registry
186                .register("StubFactoryOne".to_string(), extract_one)
187                .is_ok()
188        );
189    }
190
191    #[rstest]
192    fn test_register_rejects_a_conflicting_extractor() {
193        let registry = FactoryRegistry::<dyn StubFactory>::new("stub factory");
194        registry
195            .register("StubFactoryOne".to_string(), extract_one)
196            .unwrap();
197
198        let error = registry
199            .register("StubFactoryOne".to_string(), extract_conflicting)
200            .unwrap_err();
201
202        assert_eq!(
203            error.to_string(),
204            "A different stub factory extractor is already registered for 'StubFactoryOne'"
205        );
206    }
207}