1use 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#[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#[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 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 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 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 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 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#[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
212declare_attrs! {
214 @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#[pyfunction()]
226pub fn reload_config_from_env() -> PyResult<()> {
227 hyperactor_config::global::init_from_env();
229 Ok(())
230}
231
232#[pyfunction()]
233pub fn reset_config_to_defaults() -> PyResult<()> {
234 hyperactor_config::global::reset_to_defaults();
236 Ok(())
237}
238
239static 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
255static 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
265fn 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 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
296fn 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
323fn set_runtime_config_py<T: AttrValue + Debug>(
330 key: &'static dyn ErasedKey,
331 value: T,
332) -> PyResult<()> {
333 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
348fn configure_kwarg(py: Python<'_>, name: &str, val: Py<PyAny>) -> PyResult<()> {
362 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 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
387struct PythonConfigTypeInfo {
412 typehash: fn() -> u64,
414 get_global_config:
416 fn(py: Python<'_>, key: &'static dyn ErasedKey) -> PyResult<Option<Py<PyAny>>>,
417 set_runtime_config:
419 fn(py: Python<'_>, key: &'static dyn ErasedKey, val: Py<PyAny>) -> PyResult<()>,
420 get_runtime_config:
422 fn(py: Python<'_>, key: &'static dyn ErasedKey) -> PyResult<Option<Py<PyAny>>>,
423}
424
425inventory::collect!(PythonConfigTypeInfo);
429
430macro_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 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 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#[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 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#[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#[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#[pyfunction]
688fn clear_runtime_config(_py: Python<'_>) -> PyResult<()> {
689 hyperactor_config::global::clear(Source::Runtime);
690 Ok(())
691}
692
693pub 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 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 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 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 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 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 let tuple = PyTuple::new(py, [8000u16, 9000u16]).unwrap();
926 let result: PyResult<PyPortRange> = tuple.extract();
927 assert!(result.is_err());
928
929 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 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 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 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 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 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 assert!(py_obj.cast::<pyo3::types::PySlice>().is_ok());
1013
1014 let back: PyPortRange = py_obj.extract().unwrap();
1016 assert_eq!(back.0, original);
1017 });
1018 }
1019}