Skip to main content

nautilus_core/python/
mod.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#![expect(clippy::doc_markdown, reason = "Python docstrings")]
17
18//! Python bindings and interoperability built using [`PyO3`](https://pyo3.rs).
19
20#![allow(
21    deprecated,
22    reason = "pyo3-stub-gen currently relies on PyO3 initialization helpers marked as deprecated"
23)]
24#![expect(
25    clippy::missing_errors_doc,
26    reason = "errors documented on underlying Rust methods"
27)]
28//!
29//! This sub-module groups together the Rust code that is *only* required when compiling the
30//! `python` feature flag. It provides thin adapters so that NautilusTrader functionality can be
31//! consumed from the `nautilus_trader` Python package without sacrificing type-safety or
32//! performance.
33
34/// Implements read-only Python getters for cloneable configuration fields.
35#[macro_export]
36macro_rules! impl_pyo3_config_getters {
37    ($config:ty { $($field:ident: $field_type:ty),+ $(,)? }) => {
38        #[pyo3_stub_gen::derive::gen_stub_pymethods]
39        #[pyo3::pymethods]
40        #[allow(
41            clippy::clone_on_copy,
42            reason = "one macro handles Copy and owned configuration fields"
43        )]
44        impl $config {
45            $(
46                #[getter]
47                fn $field(&self) -> $field_type {
48                    self.$field.clone()
49                }
50            )+
51        }
52    };
53}
54
55pub mod casing;
56pub mod datetime;
57pub mod enums;
58pub mod params;
59pub mod parsing;
60pub mod serialization;
61/// String manipulation utilities for Python.
62pub mod string;
63pub mod uuid;
64pub mod version;
65
66use std::{convert::Infallible, fmt::Display};
67
68use pyo3::{
69    BoundObject, Py,
70    conversion::IntoPyObjectExt,
71    exceptions::{
72        PyException, PyKeyError, PyNotImplementedError, PyRuntimeError, PyTypeError, PyValueError,
73    },
74    prelude::*,
75    types::{PyString, PyWeakrefMethods, PyWeakrefReference},
76    wrap_pyfunction,
77};
78
79use crate::{
80    UUID4,
81    consts::{NAUTILUS_USER_AGENT, NAUTILUS_VERSION},
82    correctness::CorrectnessError,
83    datetime::{
84        MILLISECONDS_IN_SECOND, NANOSECONDS_IN_MICROSECOND, NANOSECONDS_IN_MILLISECOND,
85        NANOSECONDS_IN_SECOND,
86    },
87};
88
89/// Clones a Python object reference by attaching to the interpreter.
90///
91/// The result is a second strong reference to the same object, so this does not break a reference
92/// cycle. When a Rust object holds a `Py<T>` whose Python object reaches back into Rust, cloning
93/// adds another strong edge to that cycle rather than removing one.
94///
95/// Break such a back-reference with a Python weak reference (see [`upgrade_py_weakref`]) or an
96/// explicit terminal release point that drops the strong reference during disposal.
97#[must_use]
98pub fn clone_py_object(obj: &Py<PyAny>) -> Py<PyAny> {
99    Python::attach(|py| obj.clone_ref(py))
100}
101
102/// Upgrades the weak reference a Rust object keeps to its Python wrapper.
103///
104/// Returns `Ok(None)` when no wrapper was ever attached, which is the case for a purely Rust
105/// construction. `owner` names the Rust object in the error message.
106///
107/// # Errors
108///
109/// Returns an error if a wrapper was attached but has since been collected. Callers propagate
110/// this rather than skipping a required callback, because a live wrapper is an ownership
111/// invariant of the caller rather than an optional extra.
112pub fn upgrade_py_weakref(
113    py_self: Option<&Py<PyWeakrefReference>>,
114    owner: &dyn Display,
115) -> PyResult<Option<Py<PyAny>>> {
116    let Some(py_self) = py_self else {
117        return Ok(None);
118    };
119
120    Python::attach(|py| {
121        py_self
122            .bind(py)
123            .upgrade()
124            .map(|wrapper| Some(wrapper.unbind()))
125            .ok_or_else(|| {
126                to_pyruntime_err(format!("Python wrapper for {owner} has been collected"))
127            })
128    })
129}
130
131/// Calls a Python callback with a single argument, logging any errors.
132pub fn call_python(py: Python, callback: &Py<PyAny>, py_obj: Py<PyAny>) {
133    if let Err(e) = callback.call1(py, (py_obj,)) {
134        log::error!("Error calling Python: {e}");
135    }
136}
137
138/// Schedules a Python callback on the event loop thread via `call_soon_threadsafe`.
139///
140/// This must be used instead of [`call_python`] when invoking Python callbacks
141/// from Tokio worker threads, since Python callbacks that enter the kernel
142/// (e.g. via `MessageBus.send`) must run on the asyncio event loop thread.
143pub fn call_python_threadsafe(
144    py: Python,
145    call_soon: &Py<PyAny>,
146    callback: &Py<PyAny>,
147    py_obj: Py<PyAny>,
148) {
149    if let Err(e) = call_soon.call1(py, (callback, py_obj)) {
150        log::error!("Error scheduling Python callback on event loop: {e}");
151    }
152}
153
154/// Extends `IntoPyObjectExt` with an infallible conversion to `Py<PyAny>`.
155pub trait IntoPyObjectNautilusExt<'py>: IntoPyObjectExt<'py> {
156    /// Converts `self` into a [`Py<PyAny>`] when the underlying conversion is infallible.
157    #[inline]
158    fn into_py_any_unwrap(self, py: Python<'py>) -> Py<PyAny>
159    where
160        Self: IntoPyObject<'py, Error = Infallible>,
161    {
162        match self.into_pyobject(py) {
163            Ok(obj) => obj.into_any().unbind(),
164            Err(never) => match never {},
165        }
166    }
167}
168
169impl<'py, T> IntoPyObjectNautilusExt<'py> for T where T: IntoPyObjectExt<'py> {}
170
171/// Gets the type name for the given Python `obj`.
172///
173/// # Errors
174///
175/// Returns a error if accessing the type name fails.
176pub fn get_pytype_name<'py>(obj: &Bound<'py, PyAny>) -> PyResult<Bound<'py, PyString>> {
177    obj.get_type().name()
178}
179
180/// Converts any type that implements `Display` to a Python `ValueError`.
181pub fn to_pyvalue_err(e: impl Display) -> PyErr {
182    PyValueError::new_err(e.to_string())
183}
184
185/// Converts a correctness check failure to a Python `ValueError`.
186#[must_use]
187#[allow(
188    clippy::needless_pass_by_value,
189    reason = "Result::map_err passes owned errors to conversion functions"
190)]
191pub fn correctness_error_to_pyvalue_err(e: CorrectnessError) -> PyErr {
192    PyValueError::new_err(e.to_string())
193}
194
195/// Converts any type that implements `Display` to a Python `TypeError`.
196pub fn to_pytype_err(e: impl Display) -> PyErr {
197    PyTypeError::new_err(e.to_string())
198}
199
200/// Converts any type that implements `Display` to a Python `RuntimeError`.
201pub fn to_pyruntime_err(e: impl Display) -> PyErr {
202    PyRuntimeError::new_err(e.to_string())
203}
204
205/// Converts any type that implements `Display` to a Python `KeyError`.
206pub fn to_pykey_err(e: impl Display) -> PyErr {
207    PyKeyError::new_err(e.to_string())
208}
209
210/// Converts any type that implements `Display` to a Python `Exception`.
211pub fn to_pyexception(e: impl Display) -> PyErr {
212    PyException::new_err(e.to_string())
213}
214
215/// Converts any type that implements `Display` to a Python `NotImplementedError`.
216pub fn to_pynotimplemented_err(e: impl Display) -> PyErr {
217    PyNotImplementedError::new_err(e.to_string())
218}
219
220/// Exposed through `nautilus_trader.core`.
221///
222/// # Errors
223///
224/// Returns a `PyErr` if registering any module components fails.
225#[pymodule]
226#[rustfmt::skip]
227pub fn core(_: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
228    m.add(stringify!(NAUTILUS_VERSION), NAUTILUS_VERSION)?;
229    m.add(stringify!(NAUTILUS_USER_AGENT), NAUTILUS_USER_AGENT)?;
230    m.add(stringify!(MILLISECONDS_IN_SECOND), MILLISECONDS_IN_SECOND)?;
231    m.add(stringify!(NANOSECONDS_IN_SECOND), NANOSECONDS_IN_SECOND)?;
232    m.add(stringify!(NANOSECONDS_IN_MILLISECOND), NANOSECONDS_IN_MILLISECOND)?;
233    m.add(stringify!(NANOSECONDS_IN_MICROSECOND), NANOSECONDS_IN_MICROSECOND)?;
234    m.add_class::<UUID4>()?;
235    m.add_function(wrap_pyfunction!(casing::py_convert_to_snake_case, m)?)?;
236    m.add_function(wrap_pyfunction!(string::py_mask_api_key, m)?)?;
237    m.add_function(wrap_pyfunction!(datetime::py_secs_to_nanos, m)?)?;
238    m.add_function(wrap_pyfunction!(datetime::py_secs_to_millis, m)?)?;
239    m.add_function(wrap_pyfunction!(datetime::py_millis_to_nanos, m)?)?;
240    m.add_function(wrap_pyfunction!(datetime::py_micros_to_nanos, m)?)?;
241    m.add_function(wrap_pyfunction!(datetime::py_nanos_to_secs, m)?)?;
242    m.add_function(wrap_pyfunction!(datetime::py_nanos_to_millis, m)?)?;
243    m.add_function(wrap_pyfunction!(datetime::py_nanos_to_micros, m)?)?;
244    m.add_function(wrap_pyfunction!(datetime::py_unix_nanos_to_iso8601, m)?)?;
245    m.add_function(wrap_pyfunction!(datetime::py_last_weekday_nanos, m)?)?;
246    m.add_function(wrap_pyfunction!(datetime::py_is_within_last_24_hours, m)?)?;
247    Ok(())
248}
249
250#[cfg(test)]
251mod tests {
252    use std::sync::Once;
253
254    use pyo3::{Python, exceptions::PyValueError};
255    use rstest::rstest;
256
257    use super::*;
258
259    fn ensure_python_initialized() {
260        static INIT: Once = Once::new();
261        INIT.call_once(|| {
262            Python::initialize();
263        });
264    }
265
266    #[rstest]
267    fn test_correctness_error_to_pyvalue_err_preserves_display_text() {
268        ensure_python_initialized();
269
270        let error = CorrectnessError::EmptyString {
271            param: "value".to_string(),
272        };
273
274        Python::attach(|py| {
275            let py_err = correctness_error_to_pyvalue_err(error);
276
277            assert!(py_err.is_instance_of::<PyValueError>(py));
278            assert_eq!(
279                py_err.value(py).to_string(),
280                "invalid string for 'value', was empty"
281            );
282        });
283    }
284}