Skip to main content

monarch_hyperactor/
config.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! Configuration bridge for Monarch Hyperactor.
10//!
11//! This module defines Monarch-specific configuration keys and their
12//! Python bindings on top of the core `hyperactor::config::global`
13//! system. It wires those keys into the layered config and exposes
14//! Python-facing helpers such as `configure(...)`,
15//! `get_global_config()`, `get_runtime_config()`, and
16//! `clear_runtime_config()`, which together implement the "Runtime"
17//! configuration layer used by the Monarch Python API.
18
19use std::collections::HashMap;
20use std::fmt::Debug;
21use std::time::Duration;
22
23use hyperactor::channel::BindSpec;
24use hyperactor_config::AttrValue;
25use hyperactor_config::CONFIG;
26use hyperactor_config::ConfigAttr;
27use hyperactor_config::attrs::AttrKeyInfo;
28use hyperactor_config::attrs::Attrs;
29use hyperactor_config::attrs::ErasedKey;
30use hyperactor_config::attrs::declare_attrs;
31use hyperactor_config::global::Source;
32use pyo3::conversion::IntoPyObject;
33use pyo3::conversion::IntoPyObjectExt;
34use pyo3::exceptions::PyTypeError;
35use pyo3::exceptions::PyValueError;
36use pyo3::prelude::*;
37use typeuri::Named;
38
39use crate::channel::PyBindSpec;
40
41/// Python enum for Encoding.
42///
43/// Serialization format used for actor message payloads.
44#[pyclass(
45    module = "monarch._rust_bindings.monarch_hyperactor.config",
46    eq,
47    eq_int,
48    name = "Encoding"
49)]
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum PyEncoding {
52    Bincode,
53    Json,
54    Multipart,
55}
56
57impl From<wirevalue::Encoding> for PyEncoding {
58    fn from(e: wirevalue::Encoding) -> Self {
59        match e {
60            wirevalue::Encoding::Bincode => PyEncoding::Bincode,
61            wirevalue::Encoding::Json => PyEncoding::Json,
62            wirevalue::Encoding::Multipart => PyEncoding::Multipart,
63        }
64    }
65}
66
67impl From<PyEncoding> for wirevalue::Encoding {
68    fn from(e: PyEncoding) -> Self {
69        match e {
70            PyEncoding::Bincode => wirevalue::Encoding::Bincode,
71            PyEncoding::Json => wirevalue::Encoding::Json,
72            PyEncoding::Multipart => wirevalue::Encoding::Multipart,
73        }
74    }
75}
76
77/// Python wrapper for Range<u16>, using Python's slice type.
78///
79/// This type bridges between Python's `slice` and Rust's
80/// `std::ops::Range<u16>`.
81/// Accepts: `slice(8000, 9000)`
82///
83/// Empty ranges are allowed (e.g., `slice(8000, 8000)`).
84/// Backwards ranges are rejected (e.g., `slice(9000, 8000)`).
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct PyPortRange(pub std::ops::Range<u16>);
87
88impl From<PyPortRange> for std::ops::Range<u16> {
89    fn from(r: PyPortRange) -> Self {
90        r.0
91    }
92}
93
94impl From<std::ops::Range<u16>> for PyPortRange {
95    fn from(r: std::ops::Range<u16>) -> Self {
96        PyPortRange(r)
97    }
98}
99
100impl<'py> FromPyObject<'py> for PyPortRange {
101    fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult<Self> {
102        // Extract slice(start, stop, step)
103        let slice = ob.downcast::<pyo3::types::PySlice>().map_err(|_| {
104            PyTypeError::new_err("Port range must be a slice object: slice(start, stop)")
105        })?;
106
107        // Validate step is None or 1 (port ranges are continuous, no stepping)
108        let step = slice.getattr("step")?;
109        if !step.is_none() {
110            let step_val: isize = step.extract().map_err(|_| {
111                PyTypeError::new_err("slice.step must be None or 1 for port ranges")
112            })?;
113            if step_val != 1 {
114                return Err(PyValueError::new_err(format!(
115                    "Invalid slice step {}: port ranges require step=None or step=1",
116                    step_val
117                )));
118            }
119        }
120
121        // Extract and validate start
122        let start_obj = slice.getattr("start")?;
123        if start_obj.is_none() {
124            return Err(PyTypeError::new_err(
125                "slice.start must be set to an integer in range [0, 65535]",
126            ));
127        }
128        let start = start_obj.extract::<u16>().map_err(|_| {
129            PyTypeError::new_err("slice.start must be an integer in range [0, 65535]")
130        })?;
131
132        // Extract and validate stop
133        let stop_obj = slice.getattr("stop")?;
134        if stop_obj.is_none() {
135            return Err(PyTypeError::new_err(
136                "slice.stop must be set to an integer in range [0, 65535]",
137            ));
138        }
139        let stop = stop_obj.extract::<u16>().map_err(|_| {
140            PyTypeError::new_err("slice.stop must be an integer in range [0, 65535]")
141        })?;
142
143        // Allow empty ranges (start == stop), reject backwards ranges (start > stop)
144        if start > stop {
145            return Err(PyValueError::new_err(format!(
146                "Invalid port range slice({}, {}): start cannot be greater than stop",
147                start, stop
148            )));
149        }
150
151        Ok(PyPortRange(start..stop))
152    }
153}
154
155impl<'py> IntoPyObject<'py> for PyPortRange {
156    type Target = PyAny;
157    type Output = Bound<'py, Self::Target>;
158    type Error = PyErr;
159
160    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
161        Ok(pyo3::types::PySlice::new(py, self.0.start as isize, self.0.end as isize, 1).into_any())
162    }
163}
164
165/// Python wrapper for Duration, using humantime format strings.
166///
167/// This type bridges between Python strings (e.g., "30s", "5m") and
168/// Rust's `std::time::Duration`. It uses the `humantime` crate for
169/// parsing and formatting.
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub struct PyDuration(pub Duration);
172
173impl From<PyDuration> for Duration {
174    fn from(d: PyDuration) -> Self {
175        d.0
176    }
177}
178
179impl From<Duration> for PyDuration {
180    fn from(d: Duration) -> Self {
181        PyDuration(d)
182    }
183}
184
185impl<'py> FromPyObject<'py> for PyDuration {
186    fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult<Self> {
187        let s: String = ob.extract()?;
188        let duration = humantime::parse_duration(&s).map_err(|e| {
189            PyValueError::new_err(format!("Invalid duration format '{}': {}", s, e))
190        })?;
191        Ok(PyDuration(duration))
192    }
193}
194
195impl<'py> IntoPyObject<'py> for PyDuration {
196    type Target = PyAny;
197    type Output = Bound<'py, Self::Target>;
198    type Error = PyErr;
199
200    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
201        let formatted = humantime::format_duration(self.0).to_string();
202        formatted.into_bound_py_any(py)
203    }
204}
205
206// Declare monarch-specific configuration keys
207declare_attrs! {
208    /// Use queue-based message dispatch for Python actors instead of direct dispatch
209    @meta(CONFIG = ConfigAttr::new(
210        Some("MONARCH_ACTOR_QUEUE_DISPATCH".to_string()),
211        Some("actor_queue_dispatch".to_string()),
212    ))
213    pub attr ACTOR_QUEUE_DISPATCH: bool = true;
214
215    /// Worker thread count for Monarch's Python Tokio runtime bridge.
216    @meta(CONFIG = ConfigAttr::new(
217        Some("MONARCH_TOKIO_WORKER_THREADS".to_string()),
218        Some("tokio_worker_threads".to_string()),
219    ))
220    pub attr TOKIO_WORKER_THREADS: Option<hyperactor_config::NonZeroUsize> = None;
221}
222
223/// Python API for configuration management
224///
225/// Reload configuration from environment variables
226#[pyfunction()]
227pub fn reload_config_from_env() -> PyResult<()> {
228    // Reload the hyperactor global configuration from environment variables
229    hyperactor_config::global::init_from_env();
230    Ok(())
231}
232
233#[pyfunction()]
234pub fn reset_config_to_defaults() -> PyResult<()> {
235    // Set all config values to defaults, ignoring even environment variables.
236    hyperactor_config::global::reset_to_defaults();
237    Ok(())
238}
239
240/// Map from the kwarg name passed to `monarch.configure(...)` to the
241/// `Key<T>` associated with that kwarg. This contains all attribute
242/// keys whose `@meta(CONFIG = ConfigAttr { py_name: Some(...), .. })`
243/// specifies a kwarg name.
244static KEY_BY_NAME: std::sync::LazyLock<HashMap<&'static str, &'static dyn ErasedKey>> =
245    std::sync::LazyLock::new(|| {
246        inventory::iter::<AttrKeyInfo>()
247            .filter_map(|info| {
248                info.meta
249                    .get(CONFIG)
250                    .and_then(|cfg: &ConfigAttr| cfg.py_name.as_deref())
251                    .map(|py_name| (py_name, info.erased))
252            })
253            .collect()
254    });
255
256/// Map from typehash to an info struct that can be used to downcast
257/// an `ErasedKey` to a concrete `Key<T>` and use it to get/set values
258/// in the global configl
259static TYPEHASH_TO_INFO: std::sync::LazyLock<HashMap<u64, &'static PythonConfigTypeInfo>> =
260    std::sync::LazyLock::new(|| {
261        inventory::iter::<PythonConfigTypeInfo>()
262            .map(|info| ((info.typehash)(), info))
263            .collect()
264    });
265
266/// Fetch a config value from the layered global config and convert it
267/// to Python.
268///
269/// Looks up `key` in the full configuration
270/// (Defaults/File/Env/Runtime/ TestOverride), clones the `T`-typed
271/// value if present, converts it to `P`, then into a `Py<PyAny>`. If
272/// the key is unset in all layers, returns `Ok(None)`.
273fn get_global_config_py<'py, P, T>(
274    py: Python<'py>,
275    key: &'static dyn ErasedKey,
276) -> PyResult<Option<Py<PyAny>>>
277where
278    T: AttrValue + TryInto<P>,
279    P: IntoPyObjectExt<'py>,
280    PyErr: From<<T as TryInto<P>>::Error>,
281{
282    // The error case should never happen. If somehow it ever does
283    // we'll represent "our typing assumptions are wrong" by returning
284    // a PyTypeError rather than a panic.
285    let key = key.downcast_ref::<T>().ok_or_else(|| {
286        PyTypeError::new_err(format!(
287            "internal config type mismatch for key `{}`",
288            key.name(),
289        ))
290    })?;
291    let val: Option<P> = hyperactor_config::global::try_get_cloned(*key)
292        .map(|v| v.try_into())
293        .transpose()?;
294    val.map(|v| v.into_py_any(py)).transpose()
295}
296
297/// Fetch a config value from the **Runtime** layer only and convert
298/// it to Python.
299///
300/// This mirrors [`get_global_config_py`] but restricts the lookup to
301/// the `Source::Runtime` layer (ignoring
302/// TestOverride/Env/File/ClientOverride/defaults). If the key has a
303/// runtime override, it is cloned as `T`, converted to `P`, then to a
304/// `Py<PyAny>`; otherwise `Ok(None)` is returned.
305fn get_runtime_config_py<'py, P, T>(
306    py: Python<'py>,
307    key: &'static dyn ErasedKey,
308) -> PyResult<Option<Py<PyAny>>>
309where
310    T: AttrValue + TryInto<P>,
311    P: IntoPyObjectExt<'py>,
312    PyErr: From<<T as TryInto<P>>::Error>,
313{
314    let key = key.downcast_ref::<T>().expect("cannot fail");
315    let runtime = hyperactor_config::global::runtime_attrs();
316    let val: Option<P> = runtime
317        .get(*key)
318        .cloned()
319        .map(|v| v.try_into())
320        .transpose()?;
321    val.map(|v| v.into_py_any(py)).transpose()
322}
323
324/// Store a Python-provided config value into the **Runtime** layer.
325///
326/// This is the write-path for the "Python configuration layer": it
327/// takes a typed key/value and merges it into `Source::Runtime` via
328/// `create_or_merge`. No other layers
329/// (Env/File/TestOverride/ClientOverride/Defaults) are affected.
330fn set_runtime_config_py<T: AttrValue + Debug>(
331    key: &'static dyn ErasedKey,
332    value: T,
333) -> PyResult<()> {
334    // Again, can't fail unless there's a bug in the code in this file.
335    let key = key.downcast_ref().expect("cannot fail");
336    let mut attrs = Attrs::new();
337    attrs.set(*key, value);
338    hyperactor_config::global::create_or_merge(Source::Runtime, attrs);
339    Ok(())
340}
341
342fn py_value_repr(py: Python<'_>, val: &Py<PyAny>) -> String {
343    val.bind(py)
344        .repr()
345        .map(|repr| repr.to_string_lossy().into_owned())
346        .unwrap_or_else(|_| "<unprintable>".to_string())
347}
348
349/// Bridge a single Python kwarg into a typed Runtime config update.
350///
351/// This is the write-path behind `configure(**kwargs)`:
352/// - `configure(...)` calls this for each `(name, value)` pair,
353/// - we resolve `name` to an erased config key via `KEY_BY_NAME`,
354/// - we use the key's `typehash` to find the registered
355///   `PythonConfigTypeInfo`,
356/// - and finally call its `set_runtime_config` closure, which
357///   downcasts the value and forwards to `set_runtime_config_py` to
358///   write into the `Source::Runtime` layer.
359///
360/// Unknown keys or keys without a Python conversion registered result
361/// in a `ValueError` / `TypeError` back to Python.
362fn configure_kwarg(py: Python<'_>, name: &str, val: Py<PyAny>) -> PyResult<()> {
363    // Get the `ErasedKey` from the kwarg `name` passed to
364    // `monarch.configure(...)`.
365    let key = match KEY_BY_NAME.get(name) {
366        None => {
367            return Err(PyValueError::new_err(format!(
368                "invalid configuration key: `{}`",
369                name
370            )));
371        }
372        Some(key) => *key,
373    };
374
375    // Using the typehash from the erased key, get/call the function
376    // that can downcast the key and set the value on the global
377    // config.
378    match TYPEHASH_TO_INFO.get(&key.typehash()) {
379        None => Err(PyTypeError::new_err(format!(
380            "configuration key `{}` has type `{}`, but configuring with values of this type from Python is not supported.",
381            name,
382            key.typename()
383        ))),
384        Some(info) => (info.set_runtime_config)(py, key, val),
385    }
386}
387
388/// Per-type adapter for the Python config bridge.
389///
390/// Each `PythonConfigTypeInfo` provides type-specific get/set logic
391/// for a particular `Key<T>` via the type-erased `ErasedKey`
392/// interface.
393///
394/// Since we only have `&'static dyn ErasedKey` at runtime (we don't
395/// know `T`), we use **type erasure with recovery via function
396/// pointers**: the `declare_py_config_type!` macro bakes the concrete
397/// type `T` into each function pointer at compile time, allowing
398/// runtime dispatch to recover the type.
399///
400/// Fields:
401/// - `typehash`: Identifies the underlying `T` for runtime lookup
402/// - `set_global_config`: Knows how to extract `PyObject` as `T` and
403///   write to Runtime layer
404/// - `get_global_config`: Reads `T` from merged config (all layers)
405///   and converts to `PyObject`
406/// - `get_runtime_config`: Reads `T` from Runtime layer only and
407///   converts to `PyObject`
408///
409/// Instances are registered via `inventory` and collected into
410/// `TYPEHASH_TO_INFO`, enabling dynamic dispatch by typehash in
411/// `configure()` and `get_*_config()`.
412struct PythonConfigTypeInfo {
413    /// Identifies the underlying `T` (matches `T::typehash()`).
414    typehash: fn() -> u64,
415    /// Read this key from the merged layered config into a Py<PyAny>.
416    get_global_config:
417        fn(py: Python<'_>, key: &'static dyn ErasedKey) -> PyResult<Option<Py<PyAny>>>,
418    /// Write a Python value into the Runtime layer for this key.
419    set_runtime_config:
420        fn(py: Python<'_>, key: &'static dyn ErasedKey, val: Py<PyAny>) -> PyResult<()>,
421    /// Read this key from the Runtime layer into a Py<PyAny>.
422    get_runtime_config:
423        fn(py: Python<'_>, key: &'static dyn ErasedKey) -> PyResult<Option<Py<PyAny>>>,
424}
425
426// Collect all `PythonConfigTypeInfo` instances registered by
427// `declare_py_config_type!`. These are later gathered into
428// `TYPEHASH_TO_INFO` via `inventory::iter()`.
429inventory::collect!(PythonConfigTypeInfo);
430
431/// Macro to declare that keys of this type can be configured from
432/// python using `monarch.configure(...)`. For types like `String`
433/// that are convertible directly to/from PyObjects, you can just use
434/// `declare_py_config_type!(String)`. For types that must first be
435/// converted to/from a rust python wrapper (e.g., keys with type
436/// `BindSpec` must use `PyBindSpec` as an intermediate step), the
437/// usage is `declare_py_config_type!(PyBindSpec as BindSpec)`.
438///
439/// For `Option<T>` keys where `T` requires a Python wrapper, use
440/// `declare_py_config_type!(Option<PyWrapper> as Option<RustType>)`.
441/// Python passes `None` for `Rust None` and the wrapper's representation
442/// for `Some`. The non-Option `declare_py_config_type!(PyWrapper as
443/// RustType)` must also be registered.
444macro_rules! declare_py_config_type {
445    ($($ty:ty),+ $(,)?) => {
446        hyperactor::internal_macro_support::paste! {
447            $(
448                hyperactor::internal_macro_support::inventory::submit! {
449                    PythonConfigTypeInfo {
450                        typehash: $ty::typehash,
451                        set_runtime_config: |py, key, val| {
452                            let val: $ty = val.extract::<$ty>(py).map_err(|err| PyTypeError::new_err(format!(
453                                "invalid value `{}` for configuration key `{}` ({})",
454                                val, key.name(), err
455                            )))?;
456                            set_runtime_config_py(key, val)
457                        },
458                        get_global_config: |py, key| {
459                            get_global_config_py::<$ty, $ty>(py, key)
460                        },
461                        get_runtime_config: |py, key| {
462                            get_runtime_config_py::<$ty, $ty>(py, key)
463                        }
464                    }
465                }
466            )+
467        }
468    };
469    (Option<$py_ty:ty> as Option<$ty:ty>) => {
470        hyperactor::internal_macro_support::inventory::submit! {
471            PythonConfigTypeInfo {
472                typehash: <Option<$ty> as Named>::typehash,
473                set_runtime_config: |py, key, val| {
474                    let val_repr = py_value_repr(py, &val);
475                    let val: Option<$py_ty> = val.extract::<Option<$py_ty>>(py)
476                        .map_err(|err| PyTypeError::new_err(format!(
477                            "invalid value `{}` for configuration key `{}` ({})",
478                            val_repr, key.name(), err
479                        )))?;
480                    // Python extraction checks shape; TryInto checks Rust-side invariants.
481                    let val: Option<$ty> = val
482                        .map(TryInto::try_into)
483                        .transpose()
484                        .map_err(|err| PyValueError::new_err(format!(
485                            "invalid value `{}` for configuration key `{}` ({})",
486                            val_repr, key.name(), err
487                        )))?;
488                    set_runtime_config_py(key, val)
489                },
490                get_global_config: |py, key| {
491                    let key = key.downcast_ref::<Option<$ty>>().ok_or_else(|| {
492                        PyTypeError::new_err(format!(
493                            "internal config type mismatch for key `{}`",
494                            key.name(),
495                        ))
496                    })?;
497                    match hyperactor_config::global::try_get_cloned(key.clone()) {
498                        None => Ok(None),
499                        Some(opt_val) => opt_val.map(<$py_ty>::from).into_py_any(py).map(Some),
500                    }
501                },
502                get_runtime_config: |py, key| {
503                    let key = key.downcast_ref::<Option<$ty>>().ok_or_else(|| {
504                        PyTypeError::new_err(format!(
505                            "internal config type mismatch for key `{}`",
506                            key.name(),
507                        ))
508                    })?;
509                    let runtime = hyperactor_config::global::runtime_attrs();
510                    match runtime.get(key.clone()).cloned() {
511                        None => Ok(None),
512                        Some(opt_val) => opt_val.map(<$py_ty>::from).into_py_any(py).map(Some),
513                    }
514                }
515            }
516        }
517    };
518    ($py_ty:ty as $ty:ty) => {
519        hyperactor::internal_macro_support::paste! {
520            hyperactor::internal_macro_support::inventory::submit! {
521                PythonConfigTypeInfo {
522                    typehash: $ty::typehash,
523                    set_runtime_config: |py, key, val| {
524                        let val_repr = py_value_repr(py, &val);
525                        let val: $py_ty = val.extract::<$py_ty>(py).map_err(|err| PyTypeError::new_err(format!(
526                            "invalid value `{}` for configuration key `{}` ({})",
527                            val_repr, key.name(), err
528                        )))?;
529                        // Python extraction checks shape; TryInto checks Rust-side invariants.
530                        let val: $ty = val.try_into().map_err(|err| PyValueError::new_err(format!(
531                            "invalid value `{}` for configuration key `{}` ({})",
532                            val_repr, key.name(), err
533                        )))?;
534                        set_runtime_config_py(key, val)
535                    },
536                    get_global_config: |py, key| {
537                        get_global_config_py::<$py_ty, $ty>(py, key)
538                    },
539                    get_runtime_config: |py, key| {
540                        get_runtime_config_py::<$py_ty, $ty>(py, key)
541                    }
542                }
543            }
544        }
545    };
546}
547
548declare_py_config_type!(PyBindSpec as BindSpec);
549declare_py_config_type!(PyDuration as Duration);
550declare_py_config_type!(Option<PyDuration> as Option<Duration>);
551declare_py_config_type!(PyEncoding as wirevalue::Encoding);
552declare_py_config_type!(PyPortRange as std::ops::Range::<u16>);
553declare_py_config_type!(String as hyperactor_mesh::config::SocketAddrStr);
554declare_py_config_type!(usize as hyperactor_config::NonZeroUsize);
555declare_py_config_type!(Option<usize> as Option<hyperactor_config::NonZeroUsize>);
556declare_py_config_type!(
557    i8, i16, i32, i64, u8, u16, u32, u64, usize, f32, f64, bool, String
558);
559declare_py_config_type!(
560    Option::<i8>,
561    Option::<i16>,
562    Option::<i32>,
563    Option::<i64>,
564    Option::<u8>,
565    Option::<u16>,
566    Option::<u32>,
567    Option::<u64>,
568    Option::<usize>,
569    Option::<f32>,
570    Option::<f64>,
571    Option::<bool>,
572);
573
574/// Python entrypoint for `monarch_hyperactor.config.configure(...)`.
575///
576/// This takes the keyword arguments passed from Python, resolves each
577/// kwarg name to a typed config key via `KEY_BY_NAME` (populated from
578/// `@meta(CONFIG = ConfigAttr { py_name: Some(...), .. })`), and then
579/// uses `configure_kwarg` to downcast the value and write it into the
580/// **Runtime** configuration layer.
581///
582/// In other words, this is the write-path from `configure(**kwargs)`
583/// into `Source::Runtime`; other layers
584/// (Env/File/TestOverride/Defaults) are untouched.
585///
586/// The name `configure(...)` is historical – conceptually this is
587/// `set_runtime_config(...)` for the Python-owned Runtime layer, but
588/// we keep the shorter name for API stability.
589#[pyfunction]
590#[pyo3(signature = (**kwargs))]
591fn configure(py: Python<'_>, kwargs: Option<HashMap<String, Py<PyAny>>>) -> PyResult<()> {
592    kwargs
593        .map(|kwargs| {
594            kwargs.into_iter().try_for_each(|(key, val)| {
595                // Special handling for default_transport: convert ChannelTransport
596                // enum or string to PyBindSpec before processing
597                let val = if key == "default_transport" {
598                    PyBindSpec::new(val.bind(py))?
599                        .into_pyobject(py)?
600                        .into_any()
601                        .unbind()
602                } else {
603                    val
604                };
605                configure_kwarg(py, &key, val)
606            })
607        })
608        .transpose()?;
609    Ok(())
610}
611
612/// Return a snapshot of the current Hyperactor configuration for
613/// Python-exposed keys.
614///
615/// Iterates over all attribute keys whose `@meta(CONFIG = ConfigAttr
616/// { py_name: Some(...), .. })` declares a Python kwarg name, looks
617/// up each key in the **layered** global config
618/// (Defaults/File/Env/Runtime/TestOverride), and, if set, converts
619/// the value to a `PyObject`.
620///
621/// The result is a plain `HashMap` from kwarg name to value for all
622/// such keys that currently have a value in the global config; keys
623/// with no value in any layer are omitted.
624#[pyfunction]
625fn get_global_config(py: Python<'_>) -> PyResult<HashMap<String, Py<PyAny>>> {
626    KEY_BY_NAME
627        .iter()
628        .filter_map(|(name, key)| match TYPEHASH_TO_INFO.get(&key.typehash()) {
629            None => None,
630            Some(info) => match (info.get_global_config)(py, *key) {
631                Err(err) => Some(Err(err)),
632                Ok(val) => val.map(|val| Ok(((*name).into(), val))),
633            },
634        })
635        .collect()
636}
637
638/// Get only the Runtime layer configuration (Python-exposed keys).
639///
640/// The Runtime layer is effectively the "Python configuration layer",
641/// populated exclusively via `configure(**kwargs)` from Python. This
642/// function returns only the Python-exposed keys (those with
643/// `@meta(CONFIG = ConfigAttr { py_name: Some(...), .. })`) that are
644/// currently set in the Runtime layer.
645///
646/// This can be used to implement a `configured()` context manager to
647/// snapshot and restore the Runtime layer for composable, nested
648/// configuration overrides:
649///
650/// ```python
651/// prev = get_runtime_config()
652/// try:
653///     configure(**overrides)
654///     yield get_global_config()
655/// finally:
656///     clear_runtime_config()
657///     configure(**prev)
658/// ```
659///
660/// Unlike `get_global_config()`, which returns the merged view across
661/// all layers (File, Env, Runtime, TestOverride, defaults), this
662/// returns only what's explicitly set in the Runtime layer.
663#[pyfunction]
664fn get_runtime_config(py: Python<'_>) -> PyResult<HashMap<String, Py<PyAny>>> {
665    KEY_BY_NAME
666        .iter()
667        .filter_map(|(name, key)| match TYPEHASH_TO_INFO.get(&key.typehash()) {
668            None => None,
669            Some(info) => match (info.get_runtime_config)(py, *key) {
670                Err(err) => Some(Err(err)),
671                Ok(val) => val.map(|val| Ok(((*name).into(), val))),
672            },
673        })
674        .collect()
675}
676
677/// Clear runtime configuration overrides.
678///
679/// This removes all entries from the Runtime config layer for this
680/// process. The Runtime layer is exclusively populated via Python's
681/// `configure(**kwargs)`, so clearing it is SAFE — it will not
682/// destroy configuration from other sources (environment variables,
683/// config files, or built-in defaults).
684///
685/// This is primarily used by Python's `configured()` context manager
686/// to restore configuration state after applying temporary overrides.
687/// Other layers (Env, File, TestOverride, defaults) are unaffected.
688#[pyfunction]
689fn clear_runtime_config(_py: Python<'_>) -> PyResult<()> {
690    hyperactor_config::global::clear(Source::Runtime);
691    Ok(())
692}
693
694/// Register Python bindings for the config module
695pub fn register_python_bindings(module: &Bound<'_, PyModule>) -> PyResult<()> {
696    let reload = wrap_pyfunction!(reload_config_from_env, module)?;
697    reload.setattr(
698        "__module__",
699        "monarch._rust_bindings.monarch_hyperactor.config",
700    )?;
701    module.add_function(reload)?;
702
703    let reset = wrap_pyfunction!(reset_config_to_defaults, module)?;
704    reset.setattr(
705        "__module__",
706        "monarch._rust_bindings.monarch_hyperactor.config",
707    )?;
708    module.add_function(reset)?;
709
710    let configure = wrap_pyfunction!(configure, module)?;
711    configure.setattr(
712        "__module__",
713        "monarch._rust_bindings.monarch_hyperactor.config",
714    )?;
715    module.add_function(configure)?;
716
717    let get_global_config = wrap_pyfunction!(get_global_config, module)?;
718    get_global_config.setattr(
719        "__module__",
720        "monarch._rust_bindings.monarch_hyperactor.config",
721    )?;
722    module.add_function(get_global_config)?;
723
724    let get_runtime_config = wrap_pyfunction!(get_runtime_config, module)?;
725    get_runtime_config.setattr(
726        "__module__",
727        "monarch._rust_bindings.monarch_hyperactor.config",
728    )?;
729    module.add_function(get_runtime_config)?;
730
731    let clear_runtime_config = wrap_pyfunction!(clear_runtime_config, module)?;
732    clear_runtime_config.setattr(
733        "__module__",
734        "monarch._rust_bindings.monarch_hyperactor.config",
735    )?;
736    module.add_function(clear_runtime_config)?;
737
738    module.add_class::<PyEncoding>()?;
739
740    Ok(())
741}
742
743#[cfg(test)]
744mod tests {
745    use std::collections::HashMap;
746    use std::time::Duration;
747
748    use hyperactor_config::attrs::declare_attrs;
749    use pyo3::exceptions::PyValueError;
750    use pyo3::prelude::*;
751    use pyo3::types::PyString;
752    use pyo3::types::PyTuple;
753
754    use super::*;
755    use crate::runtime::GilSite;
756    use crate::runtime::monarch_with_gil_blocking;
757
758    declare_attrs! {
759        @meta(CONFIG = ConfigAttr::new(
760            None,
761            Some("test_nonzero_usize".to_string()),
762        ))
763        attr TEST_NONZERO_USIZE: hyperactor_config::NonZeroUsize =
764            hyperactor_config::NonZeroUsize::MIN;
765    }
766
767    #[test]
768    fn test_pyduration_parse_valid_formats() {
769        Python::initialize();
770        monarch_with_gil_blocking(GilSite::Test, |py| {
771            // Test various valid duration formats
772            let s = PyString::new(py, "30s");
773            let d: PyDuration = s.extract().unwrap();
774            assert_eq!(d.0, Duration::from_secs(30));
775
776            let s = PyString::new(py, "5m");
777            let d: PyDuration = s.extract().unwrap();
778            assert_eq!(d.0, Duration::from_mins(5));
779
780            let s = PyString::new(py, "1h");
781            let d: PyDuration = s.extract().unwrap();
782            assert_eq!(d.0, Duration::from_secs(3600));
783
784            let s = PyString::new(py, "500ms");
785            let d: PyDuration = s.extract().unwrap();
786            assert_eq!(d.0, Duration::from_millis(500));
787
788            let s = PyString::new(py, "1m 30s");
789            let d: PyDuration = s.extract().unwrap();
790            assert_eq!(d.0, Duration::from_secs(90));
791        });
792    }
793
794    #[test]
795    fn test_pyduration_parse_invalid_format() {
796        Python::initialize();
797        monarch_with_gil_blocking(GilSite::Test, |py| {
798            let s = PyString::new(py, "invalid");
799            let result: PyResult<PyDuration> = s.extract();
800            assert!(result.is_err());
801            let err_msg = format!("{}", result.unwrap_err());
802            assert!(err_msg.contains("Invalid duration format"));
803        });
804    }
805
806    #[test]
807    fn test_pyduration_roundtrip() {
808        Python::initialize();
809        monarch_with_gil_blocking(GilSite::Test, |py| {
810            let original = Duration::from_secs(42);
811            let py_duration = PyDuration(original);
812            let py_obj = py_duration.into_pyobject(py).unwrap();
813            let back: PyDuration = py_obj.extract().unwrap();
814            assert_eq!(back.0, original);
815        });
816    }
817
818    #[test]
819    fn test_pyencoding_enum_variants() {
820        Python::initialize();
821        monarch_with_gil_blocking(GilSite::Test, |py| {
822            // Test all enum variants roundtrip
823            for variant in [PyEncoding::Bincode, PyEncoding::Json, PyEncoding::Multipart] {
824                let py_obj = Bound::new(py, variant).unwrap().into_any();
825                let back: PyEncoding = py_obj.extract().unwrap();
826                assert_eq!(back, variant);
827            }
828        });
829    }
830
831    #[test]
832    fn test_pyencoding_rejects_strings() {
833        Python::initialize();
834        monarch_with_gil_blocking(GilSite::Test, |_py| {
835            // Strings ought not to work
836            let s = PyString::new(_py, "bincode");
837            let result: PyResult<PyEncoding> = s.extract();
838            assert!(result.is_err());
839        });
840    }
841
842    #[test]
843    fn test_pyencoding_conversions() {
844        Python::initialize();
845        monarch_with_gil_blocking(GilSite::Test, |_py| {
846            // Test Rust enum -> PyEncoding -> Rust enum
847            let rust_enc = wirevalue::Encoding::Bincode;
848            let py_enc: PyEncoding = rust_enc.into();
849            assert_eq!(py_enc, PyEncoding::Bincode);
850
851            let back: wirevalue::Encoding = py_enc.into();
852            assert_eq!(back, rust_enc);
853
854            // Test all variants
855            assert_eq!(
856                PyEncoding::from(wirevalue::Encoding::Json),
857                PyEncoding::Json
858            );
859            assert_eq!(
860                PyEncoding::from(wirevalue::Encoding::Multipart),
861                PyEncoding::Multipart
862            );
863        });
864    }
865
866    #[test]
867    fn test_nonzero_usize_config_rejects_zero_from_python() {
868        Python::initialize();
869        monarch_with_gil_blocking(GilSite::Test, |py| {
870            clear_runtime_config(py).expect("clear runtime config before test");
871
872            let kwargs = HashMap::from([(
873                "test_nonzero_usize".to_string(),
874                0usize.into_py_any(py).expect("convert integer to python"),
875            )]);
876            let err = configure(py, Some(kwargs)).expect_err("zero value should be rejected");
877
878            assert!(err.is_instance_of::<PyValueError>(py));
879            let err_msg = err.to_string();
880            assert!(
881                err_msg.contains(
882                    "invalid value `0` for configuration key `monarch_hyperactor::config::tests::test_nonzero_usize`"
883                ),
884                "unexpected error message: {}",
885                err_msg
886            );
887            assert!(
888                err_msg.contains("expected non-zero usize"),
889                "unexpected error message: {}",
890                err_msg
891            );
892
893            clear_runtime_config(py).expect("clear runtime config after test");
894        });
895    }
896
897    #[test]
898    fn test_pyencoding_roundtrip() {
899        Python::initialize();
900        monarch_with_gil_blocking(GilSite::Test, |py| {
901            let original = wirevalue::Encoding::Multipart;
902            let py_encoding: PyEncoding = original.into();
903            let py_obj = Bound::new(py, py_encoding).unwrap().into_any();
904            let back: PyEncoding = py_obj.extract().unwrap();
905            let rust_back: wirevalue::Encoding = back.into();
906            assert_eq!(rust_back, original);
907        });
908    }
909
910    #[test]
911    fn test_pyportrange_parse_slice_format() {
912        Python::initialize();
913        monarch_with_gil_blocking(GilSite::Test, |py| {
914            let slice = pyo3::types::PySlice::new(py, 8000, 9000, 1);
915            let r: PyPortRange = slice.extract().unwrap();
916            assert_eq!(r.0.start, 8000);
917            assert_eq!(r.0.end, 9000);
918        });
919    }
920
921    #[test]
922    fn test_pyportrange_reject_tuples_and_strings() {
923        Python::initialize();
924        monarch_with_gil_blocking(GilSite::Test, |py| {
925            // Tuples should not work
926            let tuple = PyTuple::new(py, [8000u16, 9000u16]).unwrap();
927            let result: PyResult<PyPortRange> = tuple.extract();
928            assert!(result.is_err());
929
930            // Strings should not work
931            let s = PyString::new(py, "8000..9000");
932            let result: PyResult<PyPortRange> = s.extract();
933            assert!(result.is_err());
934        });
935    }
936
937    #[test]
938    fn test_pyportrange_reject_backwards_range() {
939        Python::initialize();
940        monarch_with_gil_blocking(GilSite::Test, |py| {
941            // start > stop should be rejected
942            let slice = pyo3::types::PySlice::new(py, 9000, 8000, 1);
943            let result: PyResult<PyPortRange> = slice.extract();
944            assert!(result.is_err());
945            let err_msg = format!("{}", result.unwrap_err());
946            assert!(err_msg.contains("start cannot be greater than stop"));
947        });
948    }
949
950    #[test]
951    fn test_pyportrange_reject_invalid_step() {
952        Python::initialize();
953        monarch_with_gil_blocking(GilSite::Test, |py| {
954            // step != 1 and step != None should be rejected
955            let slice = pyo3::types::PySlice::new(py, 8000, 9000, 2);
956            let result: PyResult<PyPortRange> = slice.extract();
957            assert!(result.is_err());
958            let err_msg = format!("{}", result.unwrap_err());
959            assert!(err_msg.contains("port ranges require step=None or step=1"));
960        });
961    }
962
963    #[test]
964    fn test_pyportrange_reject_none_start() {
965        Python::initialize();
966        monarch_with_gil_blocking(GilSite::Test, |py| {
967            // slice(None, 9000) should be rejected
968            // Create via Python eval since PySlice::new doesn't support None
969            let slice = py.eval(c"slice(None, 9000)", None, None).unwrap();
970            let result: PyResult<PyPortRange> = slice.extract();
971            assert!(result.is_err());
972            let err_msg = format!("{}", result.unwrap_err());
973            assert!(err_msg.contains("slice.start must be set"));
974        });
975    }
976
977    #[test]
978    fn test_pyportrange_reject_none_stop() {
979        Python::initialize();
980        monarch_with_gil_blocking(GilSite::Test, |py| {
981            // slice(8000, None) should be rejected
982            // Create via Python eval since PySlice::new doesn't support None
983            let slice = py.eval(c"slice(8000, None)", None, None).unwrap();
984            let result: PyResult<PyPortRange> = slice.extract();
985            assert!(result.is_err());
986            let err_msg = format!("{}", result.unwrap_err());
987            assert!(err_msg.contains("slice.stop must be set"));
988        });
989    }
990
991    #[test]
992    fn test_pyportrange_allow_empty_range() {
993        Python::initialize();
994        monarch_with_gil_blocking(GilSite::Test, |py| {
995            // start == stop should be allowed (empty range)
996            let slice = pyo3::types::PySlice::new(py, 8000, 8000, 1);
997            let r: PyPortRange = slice.extract().unwrap();
998            assert_eq!(r.0.start, 8000);
999            assert_eq!(r.0.end, 8000);
1000            assert!(r.0.is_empty());
1001        });
1002    }
1003
1004    #[test]
1005    fn test_pyportrange_roundtrip() {
1006        Python::initialize();
1007        monarch_with_gil_blocking(GilSite::Test, |py| {
1008            let original = 8000..9000;
1009            let py_range = PyPortRange(original.clone());
1010            let py_obj = py_range.into_pyobject(py).unwrap();
1011
1012            // Should be a slice object
1013            assert!(py_obj.downcast::<pyo3::types::PySlice>().is_ok());
1014
1015            // Parse back
1016            let back: PyPortRange = py_obj.extract().unwrap();
1017            assert_eq!(back.0, original);
1018        });
1019    }
1020}