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
34pub mod casing;
35pub mod datetime;
36pub mod enums;
37pub mod params;
38pub mod parsing;
39pub mod serialization;
40/// String manipulation utilities for Python.
41pub mod string;
42pub mod uuid;
43pub mod version;
44
45use std::fmt::Display;
46
47use pyo3::{
48    Py,
49    conversion::IntoPyObjectExt,
50    exceptions::{
51        PyException, PyKeyError, PyNotImplementedError, PyRuntimeError, PyTypeError, PyValueError,
52    },
53    prelude::*,
54    types::PyString,
55    wrap_pyfunction,
56};
57use pyo3_stub_gen::derive::gen_stub_pyfunction;
58
59use crate::{
60    UUID4,
61    consts::{NAUTILUS_USER_AGENT, NAUTILUS_VERSION},
62    correctness::CorrectnessError,
63    datetime::{
64        MILLISECONDS_IN_SECOND, NANOSECONDS_IN_MICROSECOND, NANOSECONDS_IN_MILLISECOND,
65        NANOSECONDS_IN_SECOND,
66    },
67};
68
69/// Safely clones a Python object by acquiring the GIL and properly managing reference counts.
70///
71/// This function exists to break reference cycles between Rust and Python that can occur
72/// when using `Arc<Py<PyAny>>` in callback-holding structs. The original design wrapped
73/// Python callbacks in `Arc` for thread-safe sharing, but this created circular references:
74///
75/// 1. Rust `Arc` holds Python objects → increases Python reference count.
76/// 2. Python objects might reference Rust objects → creates cycles.
77/// 3. Neither side can be garbage collected → memory leak.
78///
79/// By using plain `Py<PyAny>` with GIL-based cloning instead of `Arc<Py<PyAny>>`, we:
80/// - Avoid circular references between Rust and Python memory management.
81/// - Ensure proper Python reference counting under the GIL.
82/// - Allow both Rust and Python garbage collectors to work correctly.
83#[must_use]
84pub fn clone_py_object(obj: &Py<PyAny>) -> Py<PyAny> {
85    Python::attach(|py| obj.clone_ref(py))
86}
87
88/// Calls a Python callback with a single argument, logging any errors.
89pub fn call_python(py: Python, callback: &Py<PyAny>, py_obj: Py<PyAny>) {
90    if let Err(e) = callback.call1(py, (py_obj,)) {
91        log::error!("Error calling Python: {e}");
92    }
93}
94
95/// Schedules a Python callback on the event loop thread via `call_soon_threadsafe`.
96///
97/// This must be used instead of [`call_python`] when invoking Python callbacks
98/// from Tokio worker threads, since Python callbacks that enter the kernel
99/// (e.g. via `MessageBus.send`) must run on the asyncio event loop thread.
100pub fn call_python_threadsafe(
101    py: Python,
102    call_soon: &Py<PyAny>,
103    callback: &Py<PyAny>,
104    py_obj: Py<PyAny>,
105) {
106    if let Err(e) = call_soon.call1(py, (callback, py_obj)) {
107        log::error!("Error scheduling Python callback on event loop: {e}");
108    }
109}
110
111/// Extend `IntoPyObjectExt` helper trait to unwrap `Py<PyAny>` after conversion.
112pub trait IntoPyObjectNautilusExt<'py>: IntoPyObjectExt<'py> {
113    /// Convert `self` into a [`Py<PyAny>`] while *panicking* if the conversion fails.
114    ///
115    /// This is a convenience wrapper around [`IntoPyObjectExt::into_py_any`] that avoids the
116    /// cumbersome `Result` handling when we are certain that the conversion cannot fail (for
117    /// instance when we are converting primitives or other types that already implement the
118    /// necessary PyO3 traits).
119    #[inline]
120    fn into_py_any_unwrap(self, py: Python<'py>) -> Py<PyAny> {
121        self.into_py_any(py)
122            .expect("Failed to convert type to Py<PyAny>")
123    }
124}
125
126impl<'py, T> IntoPyObjectNautilusExt<'py> for T where T: IntoPyObjectExt<'py> {}
127
128/// Gets the type name for the given Python `obj`.
129///
130/// # Errors
131///
132/// Returns a error if accessing the type name fails.
133pub fn get_pytype_name<'py>(obj: &Bound<'py, PyAny>) -> PyResult<Bound<'py, PyString>> {
134    obj.get_type().name()
135}
136
137/// Converts any type that implements `Display` to a Python `ValueError`.
138pub fn to_pyvalue_err(e: impl Display) -> PyErr {
139    PyValueError::new_err(e.to_string())
140}
141
142/// Converts a correctness check failure to a Python `ValueError`.
143#[must_use]
144#[allow(
145    clippy::needless_pass_by_value,
146    reason = "Result::map_err passes owned errors to conversion functions"
147)]
148pub fn correctness_error_to_pyvalue_err(e: CorrectnessError) -> PyErr {
149    PyValueError::new_err(e.to_string())
150}
151
152/// Converts any type that implements `Display` to a Python `TypeError`.
153pub fn to_pytype_err(e: impl Display) -> PyErr {
154    PyTypeError::new_err(e.to_string())
155}
156
157/// Converts any type that implements `Display` to a Python `RuntimeError`.
158pub fn to_pyruntime_err(e: impl Display) -> PyErr {
159    PyRuntimeError::new_err(e.to_string())
160}
161
162/// Converts any type that implements `Display` to a Python `KeyError`.
163pub fn to_pykey_err(e: impl Display) -> PyErr {
164    PyKeyError::new_err(e.to_string())
165}
166
167/// Converts any type that implements `Display` to a Python `Exception`.
168pub fn to_pyexception(e: impl Display) -> PyErr {
169    PyException::new_err(e.to_string())
170}
171
172/// Converts any type that implements `Display` to a Python `NotImplementedError`.
173pub fn to_pynotimplemented_err(e: impl Display) -> PyErr {
174    PyNotImplementedError::new_err(e.to_string())
175}
176
177/// Return a value indicating whether the `obj` is a `PyCapsule`.
178///
179/// Parameters
180/// ----------
181/// obj : Any
182///     The object to check.
183///
184/// # Returns
185///
186/// bool
187#[pyfunction(name = "is_pycapsule")]
188#[gen_stub_pyfunction(module = "nautilus_trader.core")]
189#[expect(
190    clippy::needless_pass_by_value,
191    reason = "Python FFI requires owned types"
192)]
193#[allow(unsafe_code)]
194fn py_is_pycapsule(obj: Py<PyAny>) -> bool {
195    // SAFETY: obj.as_ptr() returns a valid Python object pointer
196    unsafe {
197        // PyCapsule_CheckExact checks if the object is exactly a PyCapsule
198        pyo3::ffi::PyCapsule_CheckExact(obj.as_ptr()) != 0
199    }
200}
201
202/// Loaded as `nautilus_pyo3.core`.
203///
204/// # Errors
205///
206/// Returns a `PyErr` if registering any module components fails.
207#[pymodule]
208#[rustfmt::skip]
209pub fn core(_: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
210    m.add(stringify!(NAUTILUS_VERSION), NAUTILUS_VERSION)?;
211    m.add(stringify!(NAUTILUS_USER_AGENT), NAUTILUS_USER_AGENT)?;
212    m.add(stringify!(MILLISECONDS_IN_SECOND), MILLISECONDS_IN_SECOND)?;
213    m.add(stringify!(NANOSECONDS_IN_SECOND), NANOSECONDS_IN_SECOND)?;
214    m.add(stringify!(NANOSECONDS_IN_MILLISECOND), NANOSECONDS_IN_MILLISECOND)?;
215    m.add(stringify!(NANOSECONDS_IN_MICROSECOND), NANOSECONDS_IN_MICROSECOND)?;
216    m.add_class::<UUID4>()?;
217    m.add_function(wrap_pyfunction!(py_is_pycapsule, m)?)?;
218    m.add_function(wrap_pyfunction!(casing::py_convert_to_snake_case, m)?)?;
219    m.add_function(wrap_pyfunction!(string::py_mask_api_key, m)?)?;
220    m.add_function(wrap_pyfunction!(datetime::py_secs_to_nanos, m)?)?;
221    m.add_function(wrap_pyfunction!(datetime::py_secs_to_millis, m)?)?;
222    m.add_function(wrap_pyfunction!(datetime::py_millis_to_nanos, m)?)?;
223    m.add_function(wrap_pyfunction!(datetime::py_micros_to_nanos, m)?)?;
224    m.add_function(wrap_pyfunction!(datetime::py_nanos_to_secs, m)?)?;
225    m.add_function(wrap_pyfunction!(datetime::py_nanos_to_millis, m)?)?;
226    m.add_function(wrap_pyfunction!(datetime::py_nanos_to_micros, m)?)?;
227    m.add_function(wrap_pyfunction!(datetime::py_unix_nanos_to_iso8601, m)?)?;
228    m.add_function(wrap_pyfunction!(datetime::py_last_weekday_nanos, m)?)?;
229    m.add_function(wrap_pyfunction!(datetime::py_is_within_last_24_hours, m)?)?;
230    Ok(())
231}
232
233#[cfg(test)]
234mod tests {
235    use std::sync::Once;
236
237    use pyo3::{Python, exceptions::PyValueError};
238    use rstest::rstest;
239
240    use super::*;
241
242    fn ensure_python_initialized() {
243        static INIT: Once = Once::new();
244        INIT.call_once(|| {
245            Python::initialize();
246        });
247    }
248
249    #[rstest]
250    fn test_correctness_error_to_pyvalue_err_preserves_display_text() {
251        ensure_python_initialized();
252
253        let error = CorrectnessError::EmptyString {
254            param: "value".to_string(),
255        };
256
257        Python::attach(|py| {
258            let py_err = correctness_error_to_pyvalue_err(error);
259
260            assert!(py_err.is_instance_of::<PyValueError>(py));
261            assert_eq!(
262                py_err.value(py).to_string(),
263                "invalid string for 'value', was empty"
264            );
265        });
266    }
267}