1use std::collections::HashMap;
17
18use ahash::AHashMap;
19use log::LevelFilter;
20use nautilus_core::{UUID4, python::to_pyvalue_err};
21use nautilus_model::identifiers::TraderId;
22use pyo3::prelude::*;
23use ustr::Ustr;
24
25use crate::{
26 enums::{LogColor, LogLevel},
27 logging::{
28 self, headers,
29 logger::{self, LogGuard, LoggerConfig},
30 logging_clock_set_realtime_mode, logging_clock_set_static_mode,
31 logging_clock_set_static_time, logging_set_bypass, map_log_level_to_filter,
32 parse_level_filter_str,
33 writer::FileWriterConfig,
34 },
35 python::config_error_to_pyvalue_err,
36};
37
38#[pyo3_stub_gen::derive::gen_stub_pymethods]
39#[pymethods]
40impl LoggerConfig {
41 #[new]
43 #[pyo3(signature = (
44 stdout_level=None,
45 fileout_level=None,
46 component_levels=None,
47 is_colored=None,
48 print_config=None,
49 bypass_logging=None,
50 log_components_only=None,
51 file_config=None,
52 clear_log_file=None,
53 fileout_sync_on_flush=None,
54 buffered_stdout=None,
55 ))]
56 #[expect(
57 clippy::too_many_arguments,
58 reason = "PyO3 constructor mirrors LoggerConfig keyword arguments"
59 )]
60 fn py_new(
61 stdout_level: Option<LogLevel>,
62 fileout_level: Option<LogLevel>,
63 component_levels: Option<std::collections::HashMap<String, String>>,
64 is_colored: Option<bool>,
65 print_config: Option<bool>,
66 bypass_logging: Option<bool>,
67 log_components_only: Option<bool>,
68 file_config: Option<FileWriterConfig>,
69 clear_log_file: Option<bool>,
70 fileout_sync_on_flush: Option<bool>,
71 buffered_stdout: Option<bool>,
72 ) -> PyResult<Self> {
73 let component_levels = parse_component_levels(component_levels).map_err(to_pyvalue_err)?;
74 let mut config = Self::new(
75 stdout_level.map_or(LevelFilter::Info, map_log_level_to_filter),
76 fileout_level.map_or(LevelFilter::Off, map_log_level_to_filter),
77 component_levels,
78 AHashMap::new(),
79 log_components_only.unwrap_or(false),
80 is_colored.unwrap_or(true),
81 print_config.unwrap_or(false),
82 false,
83 bypass_logging.unwrap_or(false),
84 file_config,
85 clear_log_file.unwrap_or(false),
86 );
87 config.fileout_sync_on_flush = fileout_sync_on_flush.unwrap_or(true);
88 config.buffered_stdout = buffered_stdout.unwrap_or(false);
89 config.validate().map_err(config_error_to_pyvalue_err)?;
90 Ok(config)
91 }
92
93 #[getter]
94 #[pyo3(name = "stdout_level")]
95 fn py_stdout_level(&self) -> LogLevel {
96 level_filter_to_log_level(self.stdout_level)
97 }
98
99 #[getter]
100 #[pyo3(name = "fileout_level")]
101 fn py_fileout_level(&self) -> LogLevel {
102 level_filter_to_log_level(self.fileout_level)
103 }
104
105 #[getter]
106 #[pyo3(name = "component_levels")]
107 fn py_component_levels(&self) -> HashMap<String, String> {
108 self.component_level
109 .iter()
110 .map(|(component, level)| (component.to_string(), level.to_string()))
111 .collect()
112 }
113
114 #[getter]
115 #[pyo3(name = "is_colored")]
116 const fn py_is_colored(&self) -> bool {
117 self.is_colored
118 }
119
120 #[getter]
121 #[pyo3(name = "print_config")]
122 const fn py_print_config(&self) -> bool {
123 self.print_config
124 }
125
126 #[getter]
127 #[pyo3(name = "bypass_logging")]
128 const fn py_bypass_logging(&self) -> bool {
129 self.bypass_logging
130 }
131
132 #[getter]
133 #[pyo3(name = "log_components_only")]
134 const fn py_log_components_only(&self) -> bool {
135 self.log_components_only
136 }
137
138 #[getter]
139 #[pyo3(name = "file_config")]
140 fn py_file_config(&self) -> Option<FileWriterConfig> {
141 self.file_config.clone()
142 }
143
144 #[getter]
145 #[pyo3(name = "clear_log_file")]
146 const fn py_clear_log_file(&self) -> bool {
147 self.clear_log_file
148 }
149
150 #[getter]
151 #[pyo3(name = "fileout_sync_on_flush")]
152 const fn py_fileout_sync_on_flush(&self) -> bool {
153 self.fileout_sync_on_flush
154 }
155
156 #[getter]
157 #[pyo3(name = "buffered_stdout")]
158 const fn py_buffered_stdout(&self) -> bool {
159 self.buffered_stdout
160 }
161
162 #[staticmethod]
175 #[pyo3(name = "from_spec")]
176 pub fn py_from_spec(spec: &str) -> PyResult<Self> {
177 Self::from_spec(spec).map_err(to_pyvalue_err)
178 }
179}
180
181#[pyo3_stub_gen::derive::gen_stub_pymethods]
182#[pymethods]
183impl FileWriterConfig {
184 #[new]
186 #[pyo3(signature = (directory=None, file_name=None, file_format=None, file_rotate=None))]
187 #[must_use]
188 pub fn py_new(
189 directory: Option<String>,
190 file_name: Option<String>,
191 file_format: Option<String>,
192 file_rotate: Option<(u64, u32)>,
193 ) -> Self {
194 Self::new(directory, file_name, file_format, file_rotate)
195 }
196
197 #[getter]
198 #[pyo3(name = "directory")]
199 fn py_directory(&self) -> Option<&str> {
200 self.directory.as_deref()
201 }
202
203 #[getter]
204 #[pyo3(name = "file_name")]
205 fn py_file_name(&self) -> Option<&str> {
206 self.file_name.as_deref()
207 }
208
209 #[getter]
210 #[pyo3(name = "file_format")]
211 fn py_file_format(&self) -> Option<&str> {
212 self.file_format.as_deref()
213 }
214
215 #[getter]
216 #[pyo3(name = "file_rotate")]
217 fn py_file_rotate(&self) -> Option<(u64, u32)> {
218 self.file_rotate
219 .as_ref()
220 .map(|rotate| (rotate.max_file_size, rotate.max_backup_count))
221 }
222}
223
224#[pyfunction]
238#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
239#[pyo3(name = "init_logging")]
240#[expect(clippy::too_many_arguments)]
241#[pyo3(signature = (trader_id, instance_id, level_stdout, level_file=None, component_levels=None, directory=None, file_name=None, file_format=None, file_rotate=None, is_colored=None, is_bypassed=None, print_config=None, log_components_only=None, fileout_sync_on_flush=None, buffered_stdout=None))]
242pub fn py_init_logging(
243 trader_id: TraderId,
244 instance_id: UUID4,
245 level_stdout: LogLevel,
246 level_file: Option<LogLevel>,
247 component_levels: Option<std::collections::HashMap<String, String>>,
248 directory: Option<String>,
249 file_name: Option<String>,
250 file_format: Option<String>,
251 file_rotate: Option<(u64, u32)>,
252 is_colored: Option<bool>,
253 is_bypassed: Option<bool>,
254 print_config: Option<bool>,
255 log_components_only: Option<bool>,
256 fileout_sync_on_flush: Option<bool>,
257 buffered_stdout: Option<bool>,
258) -> PyResult<LogGuard> {
259 let level_file = level_file.map_or(LevelFilter::Off, map_log_level_to_filter);
260
261 let component_levels = parse_component_levels(component_levels).map_err(to_pyvalue_err)?;
262
263 let file_config = FileWriterConfig::new(directory, file_name, file_format, file_rotate);
264 file_config
265 .validate()
266 .map_err(config_error_to_pyvalue_err)?;
267
268 let mut config = LoggerConfig::new(
269 map_log_level_to_filter(level_stdout),
270 level_file,
271 component_levels,
272 AHashMap::new(), log_components_only.unwrap_or(false),
274 is_colored.unwrap_or(true),
275 print_config.unwrap_or(false),
276 false, is_bypassed.unwrap_or(false), None, false, );
281 config.fileout_sync_on_flush = fileout_sync_on_flush.unwrap_or(true);
282 config.buffered_stdout = buffered_stdout.unwrap_or(false);
283
284 let is_bypassed = config.bypass_logging;
285 let guard = logging::init_logging(trader_id, instance_id, config, file_config)
286 .map_err(to_pyvalue_err)?;
287
288 if is_bypassed {
290 logging_set_bypass();
291 }
292
293 Ok(guard)
294}
295
296#[pyfunction()]
297#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
298#[pyo3(name = "logger_flush")]
299pub fn py_logger_flush() {
300 log::logger().flush();
301}
302
303#[pyfunction()]
311#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
312#[pyo3(name = "logging_sync_to_disk")]
313pub fn py_logging_sync_to_disk() -> PyResult<bool> {
314 logging::logging_sync_to_disk()
315 .map(|()| true)
316 .map_err(to_pyvalue_err)
317}
318
319fn parse_component_levels(
320 original_map: Option<std::collections::HashMap<String, String>>,
321) -> anyhow::Result<AHashMap<Ustr, LevelFilter>> {
322 match original_map {
323 Some(map) => {
324 let mut new_map = AHashMap::new();
325
326 for (key, value) in map {
327 let ustr_key = Ustr::from(&key);
328 let level = parse_level_filter_str(&value)?;
329 new_map.insert(ustr_key, level);
330 }
331 Ok(new_map)
332 }
333 None => Ok(AHashMap::new()),
334 }
335}
336
337const fn level_filter_to_log_level(level: LevelFilter) -> LogLevel {
338 match level {
339 LevelFilter::Off => LogLevel::Off,
340 LevelFilter::Error => LogLevel::Error,
341 LevelFilter::Warn => LogLevel::Warning,
342 LevelFilter::Info => LogLevel::Info,
343 LevelFilter::Debug => LogLevel::Debug,
344 LevelFilter::Trace => LogLevel::Trace,
345 }
346}
347
348#[pyfunction]
350#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
351#[pyo3(name = "logger_log")]
352pub fn py_logger_log(level: LogLevel, color: LogColor, component: &str, message: &str) {
353 logger::log(level, color, Ustr::from(component), message);
354}
355
356#[pyfunction]
358#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
359#[pyo3(name = "log_header")]
360pub fn py_log_header(trader_id: TraderId, machine_id: &str, instance_id: UUID4, component: &str) {
361 headers::log_header(trader_id, machine_id, instance_id, Ustr::from(component));
362}
363
364#[pyfunction]
366#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
367#[pyo3(name = "log_sysinfo")]
368pub fn py_log_sysinfo(component: &str) {
369 headers::log_sysinfo(Ustr::from(component));
370}
371
372#[pyfunction]
374#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
375#[pyo3(name = "logging_clock_set_static_mode")]
376pub fn py_logging_clock_set_static_mode() {
377 logging_clock_set_static_mode();
378}
379
380#[pyfunction]
382#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
383#[pyo3(name = "logging_clock_set_realtime_mode")]
384pub fn py_logging_clock_set_realtime_mode() {
385 logging_clock_set_realtime_mode();
386}
387
388#[pyfunction]
390#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
391#[pyo3(name = "logging_clock_set_static_time")]
392pub fn py_logging_clock_set_static_time(time_ns: u64) {
393 logging_clock_set_static_time(time_ns);
394}
395
396#[cfg(feature = "tracing-bridge")]
398#[pyfunction]
399#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
400#[pyo3(name = "tracing_is_initialized")]
401#[must_use]
402pub fn py_tracing_is_initialized() -> bool {
403 crate::logging::bridge::tracing_is_initialized()
404}
405
406#[cfg(feature = "tracing-bridge")]
422#[pyfunction]
423#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.common")]
424#[pyo3(name = "init_tracing")]
425pub fn py_init_tracing() -> PyResult<()> {
426 crate::logging::bridge::init_tracing().map_err(to_pyvalue_err)
427}
428
429#[pyclass(
435 module = "nautilus_trader.common",
436 name = "Logger",
437 unsendable,
438 from_py_object
439)]
440#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.common")]
441#[derive(Debug, Clone)]
442pub struct PyLogger {
443 name: Ustr,
444}
445
446impl PyLogger {
447 pub fn new(name: &str) -> Self {
448 Self {
449 name: Ustr::from(name),
450 }
451 }
452
453 pub fn log_callback_error(&self, method: &str, result: PyResult<()>) {
455 if let Err(e) = result {
456 let exception = format_exception(&e);
457 self.log_message(
458 LogLevel::Error,
459 Some(LogColor::Red),
460 &format!("Python {method} failed:\n{exception}"),
461 );
462 }
463 }
464
465 fn log_message(&self, level: LogLevel, color: Option<LogColor>, message: &str) {
466 let color = color.unwrap_or(LogColor::Normal);
467 logger::log(level, color, self.name, message);
468 }
469}
470
471#[pymethods]
472#[pyo3_stub_gen::derive::gen_stub_pymethods]
473impl PyLogger {
474 #[new]
476 #[pyo3(signature = (name="Python"))]
477 fn py_new(name: &str) -> Self {
478 Self::new(name)
479 }
480
481 #[getter]
483 fn name(&self) -> &str {
484 &self.name
485 }
486
487 #[pyo3(name = "trace")]
489 #[pyo3(signature = (message, color=None))]
490 fn py_trace(&self, message: &str, color: Option<LogColor>) {
491 self.log_message(LogLevel::Trace, color, message);
492 }
493
494 #[pyo3(name = "debug")]
496 #[pyo3(signature = (message, color=None))]
497 fn py_debug(&self, message: &str, color: Option<LogColor>) {
498 self.log_message(LogLevel::Debug, color, message);
499 }
500
501 #[pyo3(name = "info")]
503 #[pyo3(signature = (message, color=None))]
504 fn py_info(&self, message: &str, color: Option<LogColor>) {
505 self.log_message(LogLevel::Info, color, message);
506 }
507
508 #[pyo3(name = "warning")]
510 #[pyo3(signature = (message, color=None))]
511 fn py_warning(&self, message: &str, color: Option<LogColor>) {
512 self.log_message(LogLevel::Warning, color, message);
513 }
514
515 #[pyo3(name = "error")]
517 #[pyo3(signature = (message, color=None))]
518 fn py_error(&self, message: &str, color: Option<LogColor>) {
519 self.log_message(LogLevel::Error, color, message);
520 }
521
522 #[pyo3(name = "exception")]
524 #[pyo3(signature = (message="", color=None))]
525 fn py_exception(&self, py: Python, message: &str, color: Option<LogColor>) {
526 let mut full_msg = message.to_owned();
527
528 if pyo3::PyErr::occurred(py) {
529 let err = PyErr::fetch(py);
530 let err_str = err.to_string();
531
532 if full_msg.is_empty() {
533 full_msg = err_str;
534 } else {
535 full_msg = format!("{full_msg}: {err_str}");
536 }
537 }
538
539 self.log_message(LogLevel::Error, color, &full_msg);
540 }
541
542 #[pyo3(name = "flush")]
544 fn py_flush(&self) {
545 log::logger().flush();
546 }
547
548 #[pyo3(name = "_log")]
550 #[pyo3(signature = (level, color=None, message=""))]
551 fn py_log(&self, level: LogLevel, color: Option<LogColor>, message: &str) {
552 self.log_message(level, color, message);
553 }
554}
555
556#[must_use]
560pub fn format_exception(e: &PyErr) -> String {
561 Python::attach(|py| {
562 py.import("traceback")
563 .and_then(|module| {
564 module.call_method1(
565 "format_exception",
566 (e.get_type(py), e.value(py), e.traceback(py)),
567 )
568 })
569 .and_then(|lines| lines.extract::<Vec<String>>())
570 .map_or_else(|_| e.to_string(), |lines| lines.concat())
571 })
572}
573
574#[cfg(test)]
575mod tests {
576 use nautilus_core::python::to_pyruntime_err;
577 use pyo3::ffi::c_str;
578 use rstest::rstest;
579
580 use super::*;
581
582 #[rstest]
584 fn test_init_logging_failure_leaves_no_bypass_for_retry() {
585 let temp_dir = tempfile::tempdir().unwrap();
586 let blocking_file = temp_dir.path().join("not_a_directory");
587 std::fs::write(&blocking_file, "").unwrap();
588
589 let init = |directory: &std::path::Path, is_bypassed: bool| {
590 py_init_logging(
591 TraderId::from("TRADER-001"),
592 UUID4::new(),
593 LogLevel::Off,
594 Some(LogLevel::Info),
595 None,
596 Some(directory.to_str().unwrap().to_string()),
597 None,
598 None,
599 None,
600 None,
601 Some(is_bypassed),
602 None,
603 None,
604 None,
605 None,
606 )
607 };
608
609 let failed = init(&blocking_file, true);
610 let guard = init(temp_dir.path(), false).unwrap();
611 log::info!(component = "BypassTest"; "logged after retry");
612 crate::logging::logging_sync_to_disk().unwrap();
613
614 assert!(failed.is_err());
615 let log_path = std::fs::read_dir(temp_dir.path())
616 .unwrap()
617 .map(|entry| entry.unwrap().path())
618 .find(|path| path.extension().is_some_and(|extension| extension == "log"))
619 .unwrap();
620 let contents = std::fs::read_to_string(log_path).unwrap();
621 assert!(
622 contents.contains("logged after retry"),
623 "log file: {contents:?}"
624 );
625 drop(guard);
626 }
627
628 #[rstest]
629 fn test_format_exception_traceback_and_cause() {
630 Python::initialize();
631 Python::attach(|py| {
632 let module = PyModule::from_code(
633 py,
634 c_str!(
635 r#"
636def callback():
637 try:
638 fail()
639 except ValueError as e:
640 raise RuntimeError("callback failure") from e
641
642def fail():
643 raise ValueError("original failure")
644"#
645 ),
646 c_str!("strategy_callback.py"),
647 c_str!("strategy_callback"),
648 )
649 .unwrap();
650 let e = module.call_method0("callback").unwrap_err();
651 let formatted = format_exception(&e);
652
653 assert!(formatted.contains("File \"strategy_callback.py\", line 9, in fail"));
654 assert!(formatted.contains("File \"strategy_callback.py\", line 6, in callback"));
655 assert!(formatted.contains("ValueError: original failure"));
656 assert!(formatted.contains("The above exception was the direct cause"));
657 assert!(formatted.ends_with("RuntimeError: callback failure\n"));
658 });
659 }
660
661 #[rstest]
662 fn test_format_exception_without_traceback() {
663 Python::initialize();
664 let e = to_pyruntime_err("callback failure");
665 assert_eq!(format_exception(&e), "RuntimeError: callback failure\n");
666 }
667
668 #[rstest]
669 fn test_format_exception_fallback() {
670 Python::initialize();
671 Python::attach(|py| {
672 let traceback = py.import("traceback").unwrap();
673 let original = traceback.getattr("format_exception").unwrap();
674 traceback.setattr("format_exception", py.None()).unwrap();
675 let e = to_pyruntime_err("callback failure");
676 let formatted = format_exception(&e);
677 traceback.setattr("format_exception", original).unwrap();
678
679 assert_eq!(formatted, "RuntimeError: callback failure");
680 });
681 }
682}