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