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<'py> FromPyObject<'py> for PyPortRange {
101 fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult<Self> {
102 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 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 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 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 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#[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
206declare_attrs! {
208 @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 @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#[pyfunction()]
227pub fn reload_config_from_env() -> PyResult<()> {
228 hyperactor_config::global::init_from_env();
230 Ok(())
231}
232
233#[pyfunction()]
234pub fn reset_config_to_defaults() -> PyResult<()> {
235 hyperactor_config::global::reset_to_defaults();
237 Ok(())
238}
239
240static 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
256static 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
266fn 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 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
297fn 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
324fn set_runtime_config_py<T: AttrValue + Debug>(
331 key: &'static dyn ErasedKey,
332 value: T,
333) -> PyResult<()> {
334 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
342fn configure_kwarg(py: Python<'_>, name: &str, val: Py<PyAny>) -> PyResult<()> {
356 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 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
381struct PythonConfigTypeInfo {
406 typehash: fn() -> u64,
408 get_global_config:
410 fn(py: Python<'_>, key: &'static dyn ErasedKey) -> PyResult<Option<Py<PyAny>>>,
411 set_runtime_config:
413 fn(py: Python<'_>, key: &'static dyn ErasedKey, val: Py<PyAny>) -> PyResult<()>,
414 get_runtime_config:
416 fn(py: Python<'_>, key: &'static dyn ErasedKey) -> PyResult<Option<Py<PyAny>>>,
417}
418
419inventory::collect!(PythonConfigTypeInfo);
423
424macro_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#[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 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#[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#[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#[pyfunction]
666fn clear_runtime_config(_py: Python<'_>) -> PyResult<()> {
667 hyperactor_config::global::clear(Source::Runtime);
668 Ok(())
669}
670
671pub 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 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 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 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 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 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 let tuple = PyTuple::new(py, [8000u16, 9000u16]).unwrap();
859 let result: PyResult<PyPortRange> = tuple.extract();
860 assert!(result.is_err());
861
862 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 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 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 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 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 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 assert!(py_obj.downcast::<pyo3::types::PySlice>().is_ok());
946
947 let back: PyPortRange = py_obj.extract().unwrap();
949 assert_eq!(back.0, original);
950 });
951 }
952}