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 a single asyncio runtime for all Python actors, rather than one per actor
209    @meta(CONFIG = ConfigAttr::new(
210        Some("HYPERACTOR_SHARED_ASYNCIO_RUNTIME".to_string()),
211        Some("shared_asyncio_runtime".to_string()),
212    ))
213    pub attr SHARED_ASYNCIO_RUNTIME: bool = false;
214
215    /// Use queue-based message dispatch for Python actors instead of direct dispatch
216    @meta(CONFIG = ConfigAttr::new(
217        Some("MONARCH_ACTOR_QUEUE_DISPATCH".to_string()),
218        Some("actor_queue_dispatch".to_string()),
219    ))
220    pub attr ACTOR_QUEUE_DISPATCH: bool = false;
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.clone())
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.clone())
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.clone(), value);
338    hyperactor_config::global::create_or_merge(Source::Runtime, attrs);
339    Ok(())
340}
341
342/// Bridge a single Python kwarg into a typed Runtime config update.
343///
344/// This is the write-path behind `configure(**kwargs)`:
345/// - `configure(...)` calls this for each `(name, value)` pair,
346/// - we resolve `name` to an erased config key via `KEY_BY_NAME`,
347/// - we use the key's `typehash` to find the registered
348///   `PythonConfigTypeInfo`,
349/// - and finally call its `set_runtime_config` closure, which
350///   downcasts the value and forwards to `set_runtime_config_py` to
351///   write into the `Source::Runtime` layer.
352///
353/// Unknown keys or keys without a Python conversion registered result
354/// in a `ValueError` / `TypeError` back to Python.
355fn configure_kwarg(py: Python<'_>, name: &str, val: Py<PyAny>) -> PyResult<()> {
356    // Get the `ErasedKey` from the kwarg `name` passed to
357    // `monarch.configure(...)`.
358    let key = match KEY_BY_NAME.get(name) {
359        None => {
360            return Err(PyValueError::new_err(format!(
361                "invalid configuration key: `{}`",
362                name
363            )));
364        }
365        Some(key) => *key,
366    };
367
368    // Using the typehash from the erased key, get/call the function
369    // that can downcast the key and set the value on the global
370    // config.
371    match TYPEHASH_TO_INFO.get(&key.typehash()) {
372        None => Err(PyTypeError::new_err(format!(
373            "configuration key `{}` has type `{}`, but configuring with values of this type from Python is not supported.",
374            name,
375            key.typename()
376        ))),
377        Some(info) => (info.set_runtime_config)(py, key, val),
378    }
379}
380
381/// Per-type adapter for the Python config bridge.
382///
383/// Each `PythonConfigTypeInfo` provides type-specific get/set logic
384/// for a particular `Key<T>` via the type-erased `ErasedKey`
385/// interface.
386///
387/// Since we only have `&'static dyn ErasedKey` at runtime (we don't
388/// know `T`), we use **type erasure with recovery via function
389/// pointers**: the `declare_py_config_type!` macro bakes the concrete
390/// type `T` into each function pointer at compile time, allowing
391/// runtime dispatch to recover the type.
392///
393/// Fields:
394/// - `typehash`: Identifies the underlying `T` for runtime lookup
395/// - `set_global_config`: Knows how to extract `PyObject` as `T` and
396///   write to Runtime layer
397/// - `get_global_config`: Reads `T` from merged config (all layers)
398///   and converts to `PyObject`
399/// - `get_runtime_config`: Reads `T` from Runtime layer only and
400///   converts to `PyObject`
401///
402/// Instances are registered via `inventory` and collected into
403/// `TYPEHASH_TO_INFO`, enabling dynamic dispatch by typehash in
404/// `configure()` and `get_*_config()`.
405struct PythonConfigTypeInfo {
406    /// Identifies the underlying `T` (matches `T::typehash()`).
407    typehash: fn() -> u64,
408    /// Read this key from the merged layered config into a Py<PyAny>.
409    get_global_config:
410        fn(py: Python<'_>, key: &'static dyn ErasedKey) -> PyResult<Option<Py<PyAny>>>,
411    /// Write a Python value into the Runtime layer for this key.
412    set_runtime_config:
413        fn(py: Python<'_>, key: &'static dyn ErasedKey, val: Py<PyAny>) -> PyResult<()>,
414    /// Read this key from the Runtime layer into a Py<PyAny>.
415    get_runtime_config:
416        fn(py: Python<'_>, key: &'static dyn ErasedKey) -> PyResult<Option<Py<PyAny>>>,
417}
418
419// Collect all `PythonConfigTypeInfo` instances registered by
420// `declare_py_config_type!`. These are later gathered into
421// `TYPEHASH_TO_INFO` via `inventory::iter()`.
422inventory::collect!(PythonConfigTypeInfo);
423
424/// Macro to declare that keys of this type can be configured from
425/// python using `monarch.configure(...)`. For types like `String`
426/// that are convertible directly to/from PyObjects, you can just use
427/// `declare_py_config_type!(String)`. For types that must first be
428/// converted to/from a rust python wrapper (e.g., keys with type
429/// `BindSpec` must use `PyBindSpec` as an intermediate step), the
430/// usage is `declare_py_config_type!(PyBindSpec as BindSpec)`.
431///
432/// For `Option<T>` keys where `T` requires a Python wrapper, use
433/// `declare_py_config_type!(Option<PyWrapper> as Option<RustType>)`.
434/// Python passes `None` for `Rust None` and the wrapper's representation
435/// for `Some`. The non-Option `declare_py_config_type!(PyWrapper as
436/// RustType)` must also be registered.
437macro_rules! declare_py_config_type {
438    ($($ty:ty),+ $(,)?) => {
439        hyperactor::internal_macro_support::paste! {
440            $(
441                hyperactor::internal_macro_support::inventory::submit! {
442                    PythonConfigTypeInfo {
443                        typehash: $ty::typehash,
444                        set_runtime_config: |py, key, val| {
445                            let val: $ty = val.extract::<$ty>(py).map_err(|err| PyTypeError::new_err(format!(
446                                "invalid value `{}` for configuration key `{}` ({})",
447                                val, key.name(), err
448                            )))?;
449                            set_runtime_config_py(key, val)
450                        },
451                        get_global_config: |py, key| {
452                            get_global_config_py::<$ty, $ty>(py, key)
453                        },
454                        get_runtime_config: |py, key| {
455                            get_runtime_config_py::<$ty, $ty>(py, key)
456                        }
457                    }
458                }
459            )+
460        }
461    };
462    (Option<$py_ty:ty> as Option<$ty:ty>) => {
463        hyperactor::internal_macro_support::inventory::submit! {
464            PythonConfigTypeInfo {
465                typehash: <Option<$ty> as Named>::typehash,
466                set_runtime_config: |py, key, val| {
467                    let val: Option<$ty> = val.extract::<Option<$py_ty>>(py)
468                        .map_err(|err| PyTypeError::new_err(format!(
469                            "invalid value `{}` for configuration key `{}` ({})",
470                            val, key.name(), err
471                        )))?
472                        .map(Into::into);
473                    set_runtime_config_py(key, val)
474                },
475                get_global_config: |py, key| {
476                    let key = key.downcast_ref::<Option<$ty>>().ok_or_else(|| {
477                        PyTypeError::new_err(format!(
478                            "internal config type mismatch for key `{}`",
479                            key.name(),
480                        ))
481                    })?;
482                    match hyperactor_config::global::try_get_cloned(key.clone()) {
483                        None => Ok(None),
484                        Some(opt_val) => opt_val.map(<$py_ty>::from).into_py_any(py).map(Some),
485                    }
486                },
487                get_runtime_config: |py, key| {
488                    let key = key.downcast_ref::<Option<$ty>>().ok_or_else(|| {
489                        PyTypeError::new_err(format!(
490                            "internal config type mismatch for key `{}`",
491                            key.name(),
492                        ))
493                    })?;
494                    let runtime = hyperactor_config::global::runtime_attrs();
495                    match runtime.get(key.clone()).cloned() {
496                        None => Ok(None),
497                        Some(opt_val) => opt_val.map(<$py_ty>::from).into_py_any(py).map(Some),
498                    }
499                }
500            }
501        }
502    };
503    ($py_ty:ty as $ty:ty) => {
504        hyperactor::internal_macro_support::paste! {
505            hyperactor::internal_macro_support::inventory::submit! {
506                PythonConfigTypeInfo {
507                    typehash: $ty::typehash,
508                    set_runtime_config: |py, key, val| {
509                        let val: $ty = val.extract::<$py_ty>(py).map_err(|err| PyTypeError::new_err(format!(
510                            "invalid value `{}` for configuration key `{}` ({})",
511                            val, key.name(), err
512                        )))?.into();
513                        set_runtime_config_py(key, val)
514                    },
515                    get_global_config: |py, key| {
516                        get_global_config_py::<$py_ty, $ty>(py, key)
517                    },
518                    get_runtime_config: |py, key| {
519                        get_runtime_config_py::<$py_ty, $ty>(py, key)
520                    }
521                }
522            }
523        }
524    };
525}
526
527declare_py_config_type!(PyBindSpec as BindSpec);
528declare_py_config_type!(PyDuration as Duration);
529declare_py_config_type!(Option<PyDuration> as Option<Duration>);
530declare_py_config_type!(PyEncoding as wirevalue::Encoding);
531declare_py_config_type!(PyPortRange as std::ops::Range::<u16>);
532declare_py_config_type!(String as hyperactor_mesh::config::SocketAddrStr);
533declare_py_config_type!(
534    i8, i16, i32, i64, u8, u16, u32, u64, usize, f32, f64, bool, String
535);
536declare_py_config_type!(
537    Option::<i8>,
538    Option::<i16>,
539    Option::<i32>,
540    Option::<i64>,
541    Option::<u8>,
542    Option::<u16>,
543    Option::<u32>,
544    Option::<u64>,
545    Option::<usize>,
546    Option::<f32>,
547    Option::<f64>,
548    Option::<bool>,
549);
550
551/// Python entrypoint for `monarch_hyperactor.config.configure(...)`.
552///
553/// This takes the keyword arguments passed from Python, resolves each
554/// kwarg name to a typed config key via `KEY_BY_NAME` (populated from
555/// `@meta(CONFIG = ConfigAttr { py_name: Some(...), .. })`), and then
556/// uses `configure_kwarg` to downcast the value and write it into the
557/// **Runtime** configuration layer.
558///
559/// In other words, this is the write-path from `configure(**kwargs)`
560/// into `Source::Runtime`; other layers
561/// (Env/File/TestOverride/Defaults) are untouched.
562///
563/// The name `configure(...)` is historical – conceptually this is
564/// `set_runtime_config(...)` for the Python-owned Runtime layer, but
565/// we keep the shorter name for API stability.
566#[pyfunction]
567#[pyo3(signature = (**kwargs))]
568fn configure(py: Python<'_>, kwargs: Option<HashMap<String, Py<PyAny>>>) -> PyResult<()> {
569    kwargs
570        .map(|kwargs| {
571            kwargs.into_iter().try_for_each(|(key, val)| {
572                // Special handling for default_transport: convert ChannelTransport
573                // enum or string to PyBindSpec before processing
574                let val = if key == "default_transport" {
575                    PyBindSpec::new(val.bind(py))?
576                        .into_pyobject(py)?
577                        .into_any()
578                        .unbind()
579                } else {
580                    val
581                };
582                configure_kwarg(py, &key, val)
583            })
584        })
585        .transpose()?;
586    Ok(())
587}
588
589/// Return a snapshot of the current Hyperactor configuration for
590/// Python-exposed keys.
591///
592/// Iterates over all attribute keys whose `@meta(CONFIG = ConfigAttr
593/// { py_name: Some(...), .. })` declares a Python kwarg name, looks
594/// up each key in the **layered** global config
595/// (Defaults/File/Env/Runtime/TestOverride), and, if set, converts
596/// the value to a `PyObject`.
597///
598/// The result is a plain `HashMap` from kwarg name to value for all
599/// such keys that currently have a value in the global config; keys
600/// with no value in any layer are omitted.
601#[pyfunction]
602fn get_global_config(py: Python<'_>) -> PyResult<HashMap<String, Py<PyAny>>> {
603    KEY_BY_NAME
604        .iter()
605        .filter_map(|(name, key)| match TYPEHASH_TO_INFO.get(&key.typehash()) {
606            None => None,
607            Some(info) => match (info.get_global_config)(py, *key) {
608                Err(err) => Some(Err(err)),
609                Ok(val) => val.map(|val| Ok(((*name).into(), val))),
610            },
611        })
612        .collect()
613}
614
615/// Get only the Runtime layer configuration (Python-exposed keys).
616///
617/// The Runtime layer is effectively the "Python configuration layer",
618/// populated exclusively via `configure(**kwargs)` from Python. This
619/// function returns only the Python-exposed keys (those with
620/// `@meta(CONFIG = ConfigAttr { py_name: Some(...), .. })`) that are
621/// currently set in the Runtime layer.
622///
623/// This can be used to implement a `configured()` context manager to
624/// snapshot and restore the Runtime layer for composable, nested
625/// configuration overrides:
626///
627/// ```python
628/// prev = get_runtime_config()
629/// try:
630///     configure(**overrides)
631///     yield get_global_config()
632/// finally:
633///     clear_runtime_config()
634///     configure(**prev)
635/// ```
636///
637/// Unlike `get_global_config()`, which returns the merged view across
638/// all layers (File, Env, Runtime, TestOverride, defaults), this
639/// returns only what's explicitly set in the Runtime layer.
640#[pyfunction]
641fn get_runtime_config(py: Python<'_>) -> PyResult<HashMap<String, Py<PyAny>>> {
642    KEY_BY_NAME
643        .iter()
644        .filter_map(|(name, key)| match TYPEHASH_TO_INFO.get(&key.typehash()) {
645            None => None,
646            Some(info) => match (info.get_runtime_config)(py, *key) {
647                Err(err) => Some(Err(err)),
648                Ok(val) => val.map(|val| Ok(((*name).into(), val))),
649            },
650        })
651        .collect()
652}
653
654/// Clear runtime configuration overrides.
655///
656/// This removes all entries from the Runtime config layer for this
657/// process. The Runtime layer is exclusively populated via Python's
658/// `configure(**kwargs)`, so clearing it is SAFE — it will not
659/// destroy configuration from other sources (environment variables,
660/// config files, or built-in defaults).
661///
662/// This is primarily used by Python's `configured()` context manager
663/// to restore configuration state after applying temporary overrides.
664/// Other layers (Env, File, TestOverride, defaults) are unaffected.
665#[pyfunction]
666fn clear_runtime_config(_py: Python<'_>) -> PyResult<()> {
667    hyperactor_config::global::clear(Source::Runtime);
668    Ok(())
669}
670
671/// Register Python bindings for the config module
672pub fn register_python_bindings(module: &Bound<'_, PyModule>) -> PyResult<()> {
673    let reload = wrap_pyfunction!(reload_config_from_env, module)?;
674    reload.setattr(
675        "__module__",
676        "monarch._rust_bindings.monarch_hyperactor.config",
677    )?;
678    module.add_function(reload)?;
679
680    let reset = wrap_pyfunction!(reset_config_to_defaults, module)?;
681    reset.setattr(
682        "__module__",
683        "monarch._rust_bindings.monarch_hyperactor.config",
684    )?;
685    module.add_function(reset)?;
686
687    let configure = wrap_pyfunction!(configure, module)?;
688    configure.setattr(
689        "__module__",
690        "monarch._rust_bindings.monarch_hyperactor.config",
691    )?;
692    module.add_function(configure)?;
693
694    let get_global_config = wrap_pyfunction!(get_global_config, module)?;
695    get_global_config.setattr(
696        "__module__",
697        "monarch._rust_bindings.monarch_hyperactor.config",
698    )?;
699    module.add_function(get_global_config)?;
700
701    let get_runtime_config = wrap_pyfunction!(get_runtime_config, module)?;
702    get_runtime_config.setattr(
703        "__module__",
704        "monarch._rust_bindings.monarch_hyperactor.config",
705    )?;
706    module.add_function(get_runtime_config)?;
707
708    let clear_runtime_config = wrap_pyfunction!(clear_runtime_config, module)?;
709    clear_runtime_config.setattr(
710        "__module__",
711        "monarch._rust_bindings.monarch_hyperactor.config",
712    )?;
713    module.add_function(clear_runtime_config)?;
714
715    module.add_class::<PyEncoding>()?;
716
717    Ok(())
718}
719
720#[cfg(test)]
721mod tests {
722    use std::time::Duration;
723
724    use pyo3::prelude::*;
725    use pyo3::types::PyString;
726    use pyo3::types::PyTuple;
727
728    use super::*;
729
730    #[test]
731    fn test_pyduration_parse_valid_formats() {
732        Python::initialize();
733        Python::attach(|py| {
734            // Test various valid duration formats
735            let s = PyString::new(py, "30s");
736            let d: PyDuration = s.extract().unwrap();
737            assert_eq!(d.0, Duration::from_secs(30));
738
739            let s = PyString::new(py, "5m");
740            let d: PyDuration = s.extract().unwrap();
741            assert_eq!(d.0, Duration::from_mins(5));
742
743            let s = PyString::new(py, "1h");
744            let d: PyDuration = s.extract().unwrap();
745            assert_eq!(d.0, Duration::from_secs(3600));
746
747            let s = PyString::new(py, "500ms");
748            let d: PyDuration = s.extract().unwrap();
749            assert_eq!(d.0, Duration::from_millis(500));
750
751            let s = PyString::new(py, "1m 30s");
752            let d: PyDuration = s.extract().unwrap();
753            assert_eq!(d.0, Duration::from_secs(90));
754        });
755    }
756
757    #[test]
758    fn test_pyduration_parse_invalid_format() {
759        Python::initialize();
760        Python::attach(|py| {
761            let s = PyString::new(py, "invalid");
762            let result: PyResult<PyDuration> = s.extract();
763            assert!(result.is_err());
764            let err_msg = format!("{}", result.unwrap_err());
765            assert!(err_msg.contains("Invalid duration format"));
766        });
767    }
768
769    #[test]
770    fn test_pyduration_roundtrip() {
771        Python::initialize();
772        Python::attach(|py| {
773            let original = Duration::from_secs(42);
774            let py_duration = PyDuration(original);
775            let py_obj = py_duration.into_pyobject(py).unwrap();
776            let back: PyDuration = py_obj.extract().unwrap();
777            assert_eq!(back.0, original);
778        });
779    }
780
781    #[test]
782    fn test_pyencoding_enum_variants() {
783        Python::initialize();
784        Python::attach(|py| {
785            // Test all enum variants roundtrip
786            for variant in [PyEncoding::Bincode, PyEncoding::Json, PyEncoding::Multipart] {
787                let py_obj = Bound::new(py, variant).unwrap().into_any();
788                let back: PyEncoding = py_obj.extract().unwrap();
789                assert_eq!(back, variant);
790            }
791        });
792    }
793
794    #[test]
795    fn test_pyencoding_rejects_strings() {
796        Python::initialize();
797        Python::attach(|_py| {
798            // Strings ought not to work
799            let s = PyString::new(_py, "bincode");
800            let result: PyResult<PyEncoding> = s.extract();
801            assert!(result.is_err());
802        });
803    }
804
805    #[test]
806    fn test_pyencoding_conversions() {
807        Python::initialize();
808        Python::attach(|_py| {
809            // Test Rust enum -> PyEncoding -> Rust enum
810            let rust_enc = wirevalue::Encoding::Bincode;
811            let py_enc: PyEncoding = rust_enc.into();
812            assert_eq!(py_enc, PyEncoding::Bincode);
813
814            let back: wirevalue::Encoding = py_enc.into();
815            assert_eq!(back, rust_enc);
816
817            // Test all variants
818            assert_eq!(
819                PyEncoding::from(wirevalue::Encoding::Json),
820                PyEncoding::Json
821            );
822            assert_eq!(
823                PyEncoding::from(wirevalue::Encoding::Multipart),
824                PyEncoding::Multipart
825            );
826        });
827    }
828
829    #[test]
830    fn test_pyencoding_roundtrip() {
831        Python::initialize();
832        Python::attach(|py| {
833            let original = wirevalue::Encoding::Multipart;
834            let py_encoding: PyEncoding = original.into();
835            let py_obj = Bound::new(py, py_encoding).unwrap().into_any();
836            let back: PyEncoding = py_obj.extract().unwrap();
837            let rust_back: wirevalue::Encoding = back.into();
838            assert_eq!(rust_back, original);
839        });
840    }
841
842    #[test]
843    fn test_pyportrange_parse_slice_format() {
844        Python::initialize();
845        Python::attach(|py| {
846            let slice = pyo3::types::PySlice::new(py, 8000, 9000, 1);
847            let r: PyPortRange = slice.extract().unwrap();
848            assert_eq!(r.0.start, 8000);
849            assert_eq!(r.0.end, 9000);
850        });
851    }
852
853    #[test]
854    fn test_pyportrange_reject_tuples_and_strings() {
855        Python::initialize();
856        Python::attach(|py| {
857            // Tuples should not work
858            let tuple = PyTuple::new(py, [8000u16, 9000u16]).unwrap();
859            let result: PyResult<PyPortRange> = tuple.extract();
860            assert!(result.is_err());
861
862            // Strings should not work
863            let s = PyString::new(py, "8000..9000");
864            let result: PyResult<PyPortRange> = s.extract();
865            assert!(result.is_err());
866        });
867    }
868
869    #[test]
870    fn test_pyportrange_reject_backwards_range() {
871        Python::initialize();
872        Python::attach(|py| {
873            // start > stop should be rejected
874            let slice = pyo3::types::PySlice::new(py, 9000, 8000, 1);
875            let result: PyResult<PyPortRange> = slice.extract();
876            assert!(result.is_err());
877            let err_msg = format!("{}", result.unwrap_err());
878            assert!(err_msg.contains("start cannot be greater than stop"));
879        });
880    }
881
882    #[test]
883    fn test_pyportrange_reject_invalid_step() {
884        Python::initialize();
885        Python::attach(|py| {
886            // step != 1 and step != None should be rejected
887            let slice = pyo3::types::PySlice::new(py, 8000, 9000, 2);
888            let result: PyResult<PyPortRange> = slice.extract();
889            assert!(result.is_err());
890            let err_msg = format!("{}", result.unwrap_err());
891            assert!(err_msg.contains("port ranges require step=None or step=1"));
892        });
893    }
894
895    #[test]
896    fn test_pyportrange_reject_none_start() {
897        Python::initialize();
898        Python::attach(|py| {
899            // slice(None, 9000) should be rejected
900            // Create via Python eval since PySlice::new doesn't support None
901            let slice = py.eval(c"slice(None, 9000)", None, None).unwrap();
902            let result: PyResult<PyPortRange> = slice.extract();
903            assert!(result.is_err());
904            let err_msg = format!("{}", result.unwrap_err());
905            assert!(err_msg.contains("slice.start must be set"));
906        });
907    }
908
909    #[test]
910    fn test_pyportrange_reject_none_stop() {
911        Python::initialize();
912        Python::attach(|py| {
913            // slice(8000, None) should be rejected
914            // Create via Python eval since PySlice::new doesn't support None
915            let slice = py.eval(c"slice(8000, None)", None, None).unwrap();
916            let result: PyResult<PyPortRange> = slice.extract();
917            assert!(result.is_err());
918            let err_msg = format!("{}", result.unwrap_err());
919            assert!(err_msg.contains("slice.stop must be set"));
920        });
921    }
922
923    #[test]
924    fn test_pyportrange_allow_empty_range() {
925        Python::initialize();
926        Python::attach(|py| {
927            // start == stop should be allowed (empty range)
928            let slice = pyo3::types::PySlice::new(py, 8000, 8000, 1);
929            let r: PyPortRange = slice.extract().unwrap();
930            assert_eq!(r.0.start, 8000);
931            assert_eq!(r.0.end, 8000);
932            assert!(r.0.is_empty());
933        });
934    }
935
936    #[test]
937    fn test_pyportrange_roundtrip() {
938        Python::initialize();
939        Python::attach(|py| {
940            let original = 8000..9000;
941            let py_range = PyPortRange(original.clone());
942            let py_obj = py_range.into_pyobject(py).unwrap();
943
944            // Should be a slice object
945            assert!(py_obj.downcast::<pyo3::types::PySlice>().is_ok());
946
947            // Parse back
948            let back: PyPortRange = py_obj.extract().unwrap();
949            assert_eq!(back.0, original);
950        });
951    }
952}