Skip to main content

_eggress/
lib.rs

1use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering};
2use std::sync::{Arc, OnceLock};
3use std::time::Duration;
4
5static PY_CONNECTION_LIVE_COUNT: AtomicUsize = AtomicUsize::new(0);
6static PY_CONNECTION_TOTAL_CREATED: AtomicUsize = AtomicUsize::new(0);
7static PY_OUTBOUND_RUNTIME: OnceLock<Result<Arc<tokio::runtime::Runtime>, String>> =
8    OnceLock::new();
9
10fn outbound_runtime() -> Result<Arc<tokio::runtime::Runtime>, String> {
11    PY_OUTBOUND_RUNTIME
12        .get_or_init(|| {
13            tokio::runtime::Builder::new_multi_thread()
14                .enable_all()
15                .build()
16                .map(Arc::new)
17                .map_err(|e| format!("runtime setup failed: {e}"))
18        })
19        .as_ref()
20        .map(Arc::clone)
21        .map_err(Clone::clone)
22}
23
24use pyo3::exceptions::{PyException, PyValueError};
25use pyo3::prelude::*;
26use pyo3::types::{PyDict, PyList, PyModule, PyModuleMethods, PySequence, PyString};
27use tokio::io::{AsyncReadExt, AsyncWriteExt};
28
29#[pyclass]
30struct PyAppliedSystemProxy {
31    inner: Option<eggress_system_proxy::AppliedProxy>,
32}
33
34#[pymethods]
35impl PyAppliedSystemProxy {
36    fn restore(&mut self, py: Python<'_>) -> PyResult<()> {
37        if let Some(mut applied) = self.inner.take() {
38            py.detach(|| applied.restore())
39                .map_err(PyValueError::new_err)?;
40        }
41        Ok(())
42    }
43
44    fn __enter__(slf: Py<Self>) -> Py<Self> {
45        slf
46    }
47
48    fn __exit__(
49        &mut self,
50        py: Python<'_>,
51        _exc_type: &Bound<'_, PyAny>,
52        _exc_value: &Bound<'_, PyAny>,
53        _traceback: &Bound<'_, PyAny>,
54    ) -> PyResult<bool> {
55        self.restore(py)?;
56        Ok(false)
57    }
58}
59
60#[pyfunction]
61fn apply_system_proxy(py: Python<'_>, kind: &str, address: &str) -> PyResult<PyAppliedSystemProxy> {
62    let kind = match kind {
63        "http" => eggress_system_proxy::CompatibilityProxyKind::Http,
64        "socks5" => eggress_system_proxy::CompatibilityProxyKind::Socks5,
65        other => {
66            return Err(PyValueError::new_err(format!(
67                "unknown proxy kind: {other}"
68            )))
69        }
70    };
71    let address = address
72        .parse()
73        .map_err(|e| PyValueError::new_err(format!("invalid proxy address: {e}")))?;
74    let inner = py
75        .detach(|| eggress_system_proxy::apply_compatibility_proxy(kind, address))
76        .map_err(PyValueError::new_err)?;
77    Ok(PyAppliedSystemProxy { inner: Some(inner) })
78}
79
80pyo3::create_exception!(_eggress, EggressError, PyException);
81pyo3::create_exception!(_eggress, ConfigError, EggressError);
82pyo3::create_exception!(_eggress, StartupError, EggressError);
83pyo3::create_exception!(_eggress, ReloadError, EggressError);
84pyo3::create_exception!(_eggress, ShutdownError, EggressError);
85pyo3::create_exception!(_eggress, UnsupportedFeatureError, EggressError);
86pyo3::create_exception!(_eggress, InternalError, EggressError);
87pyo3::create_exception!(_eggress, ConnectionError, EggressError);
88pyo3::create_exception!(_eggress, ConnectionClosedError, EggressError);
89pyo3::create_exception!(_eggress, TimeoutError, EggressError);
90pyo3::create_exception!(_eggress, DnsError, EggressError);
91pyo3::create_exception!(_eggress, AuthError, EggressError);
92pyo3::create_exception!(_eggress, TlsError, EggressError);
93pyo3::create_exception!(_eggress, LoopMismatchError, EggressError);
94pyo3::create_exception!(_eggress, ConnectionCancelledError, EggressError);
95pyo3::create_exception!(_eggress, UseAfterCloseError, EggressError);
96pyo3::create_exception!(_eggress, UdpAssociationError, EggressError);
97pyo3::create_exception!(_eggress, UnsupportedCompositionError, EggressError);
98
99fn map_error(_py: Python<'_>, err: eggress_embed::EggressError) -> PyErr {
100    use eggress_embed::EggressError as E;
101    let msg = err.to_string();
102    match err {
103        E::Config(_) => ConfigError::new_err(msg),
104        E::Runtime(_) => InternalError::new_err(msg),
105        E::Startup(_) => StartupError::new_err(msg),
106        E::Reload(_) => ReloadError::new_err(msg),
107        E::Shutdown(_) => ShutdownError::new_err(msg),
108        E::UnsupportedFeature { .. } => UnsupportedFeatureError::new_err(msg),
109        E::Internal(_) => InternalError::new_err(msg),
110    }
111}
112
113#[pyclass]
114struct PyEggressConfig {
115    inner: eggress_embed::EggressConfig,
116}
117
118#[pymethods]
119impl PyEggressConfig {
120    #[staticmethod]
121    fn from_toml(py: Python<'_>, toml_str: &str) -> PyResult<Self> {
122        let config = py
123            .detach(|| eggress_embed::EggressConfig::from_toml_str(toml_str))
124            .map_err(|e| map_error(py, e))?;
125        Ok(Self { inner: config })
126    }
127
128    #[staticmethod]
129    fn from_file(py: Python<'_>, path: &str) -> PyResult<Self> {
130        let config = py
131            .detach(|| eggress_embed::EggressConfig::from_toml_file(path))
132            .map_err(|e| map_error(py, e))?;
133        Ok(Self { inner: config })
134    }
135
136    fn redacted_toml(&self, py: Python<'_>) -> PyResult<String> {
137        py.detach(|| self.inner.to_redacted_toml())
138            .map_err(|e| map_error(py, e))
139    }
140}
141
142#[pyclass]
143struct PyEggressService {
144    inner: Option<eggress_embed::EggressService>,
145}
146
147#[pymethods]
148impl PyEggressService {
149    #[new]
150    fn new(_py: Python<'_>, config: &PyEggressConfig) -> Self {
151        Self {
152            inner: Some(eggress_embed::EggressService::new(config.inner.clone())),
153        }
154    }
155
156    #[staticmethod]
157    fn from_toml(py: Python<'_>, toml_str: &str) -> PyResult<Self> {
158        let svc = py
159            .detach(|| eggress_embed::EggressService::from_toml_str(toml_str))
160            .map_err(|e| map_error(py, e))?;
161        Ok(Self { inner: Some(svc) })
162    }
163
164    #[staticmethod]
165    fn from_file(py: Python<'_>, path: &str) -> PyResult<Self> {
166        let svc = py
167            .detach(|| eggress_embed::EggressService::from_toml_file(path))
168            .map_err(|e| map_error(py, e))?;
169        Ok(Self { inner: Some(svc) })
170    }
171
172    fn start(&mut self, py: Python<'_>) -> PyResult<PyEggressHandle> {
173        let svc = self
174            .inner
175            .take()
176            .ok_or_else(|| EggressError::new_err("service already started"))?;
177        let handle = py
178            .detach(|| svc.start_blocking())
179            .map_err(|e| map_error(py, e))?;
180        Ok(PyEggressHandle {
181            inner: Some(handle),
182        })
183    }
184
185    /// Start with the compatibility-only runtime options parsed from pproxy
186    /// arguments. Native Eggress service startup never uses this path.
187    fn start_with_compatibility_options(
188        &mut self,
189        py: Python<'_>,
190        auth_timeout_seconds: u64,
191        system_proxy: bool,
192        debug: bool,
193        verbose_level: u8,
194    ) -> PyResult<PyEggressHandle> {
195        let svc = self
196            .inner
197            .take()
198            .ok_or_else(|| EggressError::new_err("service already started"))?;
199        let options = eggress_runtime::CompatibilityOptions {
200            auth_timeout: Some(Duration::from_secs(auth_timeout_seconds)),
201            system_proxy,
202            debug,
203            verbose_level,
204        };
205        let handle = py
206            .detach(|| svc.start_blocking_with_compatibility_options(options))
207            .map_err(|e| map_error(py, e))?;
208        Ok(PyEggressHandle {
209            inner: Some(handle),
210        })
211    }
212}
213
214#[pyclass]
215struct PyEggressHandle {
216    inner: Option<eggress_embed::EggressHandle>,
217}
218
219#[pymethods]
220impl PyEggressHandle {
221    fn bound_addresses(&self, py: Python<'_>) -> PyResult<Py<PyDict>> {
222        let handle = self
223            .inner
224            .as_ref()
225            .ok_or_else(|| EggressError::new_err("handle consumed"))?;
226        let addrs = py.detach(|| handle.bound_addresses());
227        let dict = PyDict::new(py);
228        for la in &addrs.listeners {
229            dict.set_item(&la.name, la.addr.to_string())?;
230        }
231        if let Some(admin) = addrs.admin {
232            dict.set_item("_admin", admin.to_string())?;
233        }
234        Ok(dict.into())
235    }
236
237    fn status(&self, py: Python<'_>) -> PyResult<Py<PyDict>> {
238        let handle = self
239            .inner
240            .as_ref()
241            .ok_or_else(|| EggressError::new_err("handle consumed"))?;
242        let st = py.detach(|| handle.status());
243        let dict = PyDict::new(py);
244        dict.set_item("generation", st.generation)?;
245        dict.set_item("readiness", st.readiness)?;
246        dict.set_item("active_connections", st.active_connections)?;
247        dict.set_item("uptime_secs", st.uptime_secs)?;
248        dict.set_item("listener_count", st.listener_count)?;
249        dict.set_item("udp_associations_active", st.udp_associations_active)?;
250        dict.set_item("upstream_count", st.upstream_count)?;
251        let py_listeners = PyList::empty(py);
252        for ls in &st.listeners {
253            let ldict = PyDict::new(py);
254            ldict.set_item("name", &ls.name)?;
255            ldict.set_item("bind", &ls.bind)?;
256            ldict.set_item("local_addr", ls.local_addr.to_string())?;
257            ldict.set_item("protocols", &ls.protocols)?;
258            ldict.set_item("udp_enabled", ls.udp_enabled)?;
259            py_listeners.append(ldict)?;
260        }
261        dict.set_item("listeners", py_listeners)?;
262        Ok(dict.into())
263    }
264
265    fn metrics_text(&self, py: Python<'_>) -> PyResult<String> {
266        let handle = self
267            .inner
268            .as_ref()
269            .ok_or_else(|| EggressError::new_err("handle consumed"))?;
270        py.detach(|| handle.metrics_text())
271            .map_err(|e| map_error(py, e))
272    }
273
274    fn reload_toml(&self, py: Python<'_>, toml_str: &str) -> PyResult<Py<PyDict>> {
275        let handle = self
276            .inner
277            .as_ref()
278            .ok_or_else(|| EggressError::new_err("handle consumed"))?;
279        let outcome = py
280            .detach(|| handle.reload_toml_str(toml_str))
281            .map_err(|e| map_error(py, e))?;
282        let dict = PyDict::new(py);
283        match outcome {
284            eggress_embed::ReloadOutcome::Applied {
285                generation,
286                upstreams,
287            } => {
288                dict.set_item("generation", generation)?;
289                dict.set_item("upstreams", upstreams)?;
290            }
291        }
292        Ok(dict.into())
293    }
294
295    fn shutdown(&mut self, py: Python<'_>) -> PyResult<()> {
296        if let Some(handle) = self.inner.take() {
297            py.detach(|| handle.shutdown_blocking())
298                .map_err(|e| map_error(py, e))?;
299        }
300        Ok(())
301    }
302
303    fn __enter__(slf: Py<Self>) -> Py<Self> {
304        slf
305    }
306
307    fn __exit__(
308        &mut self,
309        py: Python<'_>,
310        _exc_type: &Bound<'_, PyAny>,
311        _exc_value: &Bound<'_, PyAny>,
312        _traceback: &Bound<'_, PyAny>,
313    ) -> PyResult<bool> {
314        if let Some(handle) = self.inner.take() {
315            if let Err(e) = py.detach(|| handle.shutdown_blocking()) {
316                eprintln!("shutdown error in __exit__: {e}");
317            }
318        }
319        Ok(false)
320    }
321}
322
323const STATE_CREATED: u8 = 0;
324const STATE_CONNECTING: u8 = 1;
325const STATE_CONNECTED: u8 = 2;
326const STATE_CLOSING: u8 = 3;
327const STATE_CLOSED: u8 = 4;
328const STATE_FAILED: u8 = 5;
329
330#[pyclass]
331struct PyConnection {
332    state: Arc<AtomicU8>,
333    handle: Option<eggress_embed::EggressHandle>,
334    config_toml: String,
335    bound_addr: Option<String>,
336    remote_addr: Option<String>,
337    peername: Option<String>,
338    sockname: Option<String>,
339    error: Option<String>,
340}
341
342#[pymethods]
343impl PyConnection {
344    #[new]
345    #[pyo3(signature = (uris, /, *args))]
346    fn new(py: Python<'_>, uris: &Bound<'_, PySequence>, args: Vec<String>) -> PyResult<Self> {
347        let mut all_args: Vec<String> = Vec::new();
348        for i in 0..uris.len()? {
349            all_args.push(uris.get_item(i)?.extract::<String>()?);
350        }
351        all_args.extend(args);
352
353        if all_args.is_empty() {
354            return Err(ConnectionError::new_err(
355                "at least one URI argument is required",
356            ));
357        }
358
359        let parsed = eggress_pproxy_compat::PproxyArgs::parse(&all_args)
360            .map_err(|e| ConnectionError::new_err(format!("argument parse error: {e}")))?;
361
362        let output = py
363            .detach(|| eggress_pproxy_compat::translate_pproxy_args(&parsed))
364            .map_err(|e| ConnectionError::new_err(format!("translation failed: {e}")))?;
365
366        if output.has_unsupported() {
367            let features: Vec<_> = output.unsupported.iter().map(|u| u.feature).collect();
368            return Err(UnsupportedFeatureError::new_err(format!(
369                "unsupported features: {}",
370                features.join(", ")
371            )));
372        }
373
374        let config = eggress_embed::EggressConfig::from_toml_str(&output.toml)
375            .map_err(|e| ConnectionError::new_err(format!("config error: {e}")))?;
376        let service = eggress_embed::EggressService::new(config);
377        let handle = py
378            .detach(|| service.start_blocking())
379            .map_err(|e| ConnectionError::new_err(format!("startup failed: {e}")))?;
380
381        let addrs = handle.bound_addresses();
382        let bound = addrs.listeners.first().map(|l| l.addr.to_string());
383
384        PY_CONNECTION_TOTAL_CREATED.fetch_add(1, Ordering::Relaxed);
385        PY_CONNECTION_LIVE_COUNT.fetch_add(1, Ordering::Relaxed);
386
387        Ok(Self {
388            state: Arc::new(AtomicU8::new(STATE_CREATED)),
389            handle: Some(handle),
390            config_toml: output.toml,
391            bound_addr: bound,
392            remote_addr: None,
393            peername: None,
394            sockname: None,
395            error: None,
396        })
397    }
398
399    #[getter]
400    fn state(&self) -> &str {
401        match self.state.load(Ordering::Acquire) {
402            STATE_CREATED => "created",
403            STATE_CONNECTING => "connecting",
404            STATE_CONNECTED => "connected",
405            STATE_CLOSING => "closing",
406            STATE_CLOSED => "closed",
407            STATE_FAILED => "failed",
408            _ => "unknown",
409        }
410    }
411
412    #[getter]
413    fn closed(&self) -> bool {
414        matches!(
415            self.state.load(Ordering::Acquire),
416            STATE_CLOSED | STATE_FAILED
417        )
418    }
419
420    #[getter]
421    fn config(&self) -> &str {
422        &self.config_toml
423    }
424
425    #[getter]
426    fn peername(&self) -> Option<&str> {
427        self.peername.as_deref()
428    }
429
430    #[getter]
431    fn sockname(&self) -> Option<&str> {
432        self.sockname.as_deref().or(self.bound_addr.as_deref())
433    }
434
435    #[getter]
436    fn extra_info(&self, py: Python<'_>) -> PyResult<Py<PyDict>> {
437        let dict = PyDict::new(py);
438        dict.set_item("state", self.state())?;
439        if let Some(ref addr) = self.bound_addr {
440            dict.set_item("bound_addr", addr)?;
441        }
442        if let Some(ref addr) = self.remote_addr {
443            dict.set_item("remote_addr", addr)?;
444        }
445        if let Some(ref err) = self.error {
446            dict.set_item("error", err)?;
447        }
448        Ok(dict.into())
449    }
450
451    fn close(&mut self, py: Python<'_>) -> PyResult<()> {
452        if !begin_close(&self.state) {
453            return Ok(());
454        }
455        if let Some(handle) = self.handle.take() {
456            py.detach(|| handle.shutdown_blocking())
457                .map_err(|e| ConnectionError::new_err(format!("shutdown error: {e}")))?;
458        }
459        self.state.store(STATE_CLOSED, Ordering::Release);
460        PY_CONNECTION_LIVE_COUNT.fetch_sub(1, Ordering::Relaxed);
461        Ok(())
462    }
463
464    fn wait_closed(&mut self, py: Python<'_>) -> PyResult<()> {
465        let current = self.state.load(Ordering::Acquire);
466        if current == STATE_CLOSED || current == STATE_FAILED {
467            return Ok(());
468        }
469        self.close(py)?;
470        Ok(())
471    }
472
473    fn __enter__(slf: Py<Self>) -> Py<Self> {
474        slf
475    }
476
477    fn __exit__(
478        &mut self,
479        py: Python<'_>,
480        _exc_type: &Bound<'_, PyAny>,
481        _exc_value: &Bound<'_, PyAny>,
482        _traceback: &Bound<'_, PyAny>,
483    ) -> PyResult<bool> {
484        self.close(py)?;
485        Ok(false)
486    }
487
488    fn __del__(&mut self, py: Python<'_>) {
489        if !begin_close(&self.state) {
490            return;
491        }
492        eprintln!(
493            "Warning: Connection object was not properly closed. Calling close() in __del__."
494        );
495        if let Some(handle) = self.handle.take() {
496            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
497                py.detach(|| handle.shutdown_blocking())
498            }));
499            match result {
500                Ok(Ok(())) => {}
501                Ok(Err(error)) => eprintln!("shutdown error in __del__: {error}"),
502                Err(_) => eprintln!("shutdown panicked in __del__"),
503            }
504        }
505        self.state.store(STATE_CLOSED, Ordering::Release);
506        PY_CONNECTION_LIVE_COUNT.fetch_sub(1, Ordering::Relaxed);
507    }
508
509    fn __repr__(&self) -> String {
510        format!(
511            "Connection(state='{}', bound='{}')",
512            self.state(),
513            self.bound_addr.as_deref().unwrap_or("None")
514        )
515    }
516
517    #[staticmethod]
518    fn connection_stats(py: Python<'_>) -> PyResult<Py<PyDict>> {
519        let dict = PyDict::new(py);
520        dict.set_item("live", PY_CONNECTION_LIVE_COUNT.load(Ordering::Relaxed))?;
521        dict.set_item(
522            "total_created",
523            PY_CONNECTION_TOTAL_CREATED.load(Ordering::Relaxed),
524        )?;
525        Ok(dict.into())
526    }
527
528    #[staticmethod]
529    fn reset_connection_stats() {
530        PY_CONNECTION_LIVE_COUNT.store(0, Ordering::Relaxed);
531        PY_CONNECTION_TOTAL_CREATED.store(0, Ordering::Relaxed);
532    }
533}
534
535fn begin_close(state: &AtomicU8) -> bool {
536    loop {
537        let current = state.load(Ordering::Acquire);
538        if current == STATE_CLOSED || current == STATE_FAILED || current == STATE_CLOSING {
539            return false;
540        }
541        match state.compare_exchange(current, STATE_CLOSING, Ordering::AcqRel, Ordering::Acquire) {
542            Ok(_) => return true,
543            Err(_) => continue,
544        }
545    }
546}
547
548// --- pproxy URI inspection helpers ---
549
550#[pyclass(skip_from_py_object)]
551#[derive(Clone)]
552struct PyUriInfo {
553    scheme: String,
554    host: String,
555    port: u16,
556    tls: bool,
557    ssl: bool,
558    inbound: bool,
559    backward_num: u32,
560    has_auth: bool,
561    has_rule: bool,
562    is_reverse_listener: bool,
563    redacted_display: String,
564    error: Option<String>,
565}
566
567#[pymethods]
568impl PyUriInfo {
569    #[getter]
570    fn scheme(&self) -> &str {
571        &self.scheme
572    }
573    #[getter]
574    fn host(&self) -> &str {
575        &self.host
576    }
577    #[getter]
578    fn port(&self) -> u16 {
579        self.port
580    }
581    #[getter]
582    fn tls(&self) -> bool {
583        self.tls
584    }
585    #[getter]
586    fn ssl(&self) -> bool {
587        self.ssl
588    }
589    #[getter]
590    fn inbound(&self) -> bool {
591        self.inbound
592    }
593    #[getter]
594    fn backward_num(&self) -> u32 {
595        self.backward_num
596    }
597    #[getter]
598    fn has_auth(&self) -> bool {
599        self.has_auth
600    }
601    #[getter]
602    fn has_rule(&self) -> bool {
603        self.has_rule
604    }
605    #[getter]
606    fn is_reverse_listener(&self) -> bool {
607        self.is_reverse_listener
608    }
609    #[getter]
610    fn redacted_display(&self) -> &str {
611        &self.redacted_display
612    }
613    #[getter]
614    fn error(&self) -> Option<&str> {
615        self.error.as_deref()
616    }
617
618    fn __repr__(&self) -> String {
619        match &self.error {
620            Some(e) => format!("UriInfo(error='{}')", e),
621            None => format!(
622                "UriInfo(scheme='{}', host='{}', port={}, tls={})",
623                self.scheme, self.host, self.port, self.tls
624            ),
625        }
626    }
627}
628
629#[pyfunction]
630fn check_pproxy_uri(uri: &str) -> PyUriInfo {
631    match eggress_pproxy_compat::uri::parse_pproxy_uri(uri) {
632        Ok(parsed) => PyUriInfo {
633            scheme: parsed.scheme.clone(),
634            host: parsed.host.clone(),
635            port: parsed.port,
636            tls: parsed.tls,
637            ssl: parsed.ssl,
638            inbound: parsed.inbound,
639            backward_num: parsed.backward_num,
640            has_auth: parsed.username.is_some(),
641            has_rule: parsed.rule.is_some(),
642            is_reverse_listener: parsed.is_reverse_listener(),
643            redacted_display: parsed.redacted_display(),
644            error: None,
645        },
646        Err(e) => PyUriInfo {
647            scheme: String::new(),
648            host: String::new(),
649            port: 0,
650            tls: false,
651            ssl: false,
652            inbound: false,
653            backward_num: 0,
654            has_auth: false,
655            has_rule: false,
656            is_reverse_listener: false,
657            redacted_display: String::new(),
658            error: Some(e.to_string()),
659        },
660    }
661}
662
663#[pyfunction]
664fn redact_pproxy_uri(uri: &str) -> PyResult<String> {
665    let parsed = eggress_pproxy_compat::uri::parse_pproxy_uri(uri)
666        .map_err(|e| UnsupportedFeatureError::new_err(format!("invalid pproxy URI: {e}")))?;
667    Ok(parsed.redacted_display())
668}
669
670// --- diagnostics ---
671
672#[pyclass(skip_from_py_object)]
673#[derive(Clone)]
674struct PyDiagnostic {
675    code: String,
676    feature_id: Option<String>,
677    tier: Option<String>,
678    message: String,
679    suggestion: Option<String>,
680}
681
682#[pymethods]
683impl PyDiagnostic {
684    #[getter]
685    fn code(&self) -> &str {
686        &self.code
687    }
688    #[getter]
689    fn feature_id(&self) -> Option<&str> {
690        self.feature_id.as_deref()
691    }
692    #[getter]
693    fn tier(&self) -> Option<&str> {
694        self.tier.as_deref()
695    }
696    #[getter]
697    fn message(&self) -> &str {
698        &self.message
699    }
700    #[getter]
701    fn suggestion(&self) -> Option<&str> {
702        self.suggestion.as_deref()
703    }
704
705    fn __repr__(&self) -> String {
706        format!("[{}] {}", self.code, self.message)
707    }
708}
709
710#[pyfunction]
711fn diagnostics_for_uri(py: Python<'_>, uri: &str) -> PyResult<Vec<PyDiagnostic>> {
712    let parsed = eggress_pproxy_compat::uri::parse_pproxy_uri(uri)
713        .map_err(|e| UnsupportedFeatureError::new_err(format!("invalid pproxy URI: {e}")))?;
714
715    let mut diagnostics: Vec<PyDiagnostic> = Vec::new();
716
717    let output = py
718        .detach(|| {
719            eggress_pproxy_compat::translate_from_uris(
720                &eggress_pproxy_compat::PproxyArgs::default_args(),
721                &[parsed],
722                &[],
723            )
724        })
725        .map_err(|e| UnsupportedFeatureError::new_err(format!("translation failed: {e}")))?;
726
727    for warn in &output.warnings {
728        let sd = eggress_pproxy_compat::StructuredDiagnostic::from(warn);
729        diagnostics.push(PyDiagnostic {
730            code: sd.code.to_string(),
731            feature_id: sd.feature_id,
732            tier: sd.tier,
733            message: sd.message,
734            suggestion: sd.suggestion,
735        });
736    }
737    for u in &output.unsupported {
738        diagnostics.push(PyDiagnostic {
739            code: "unsupported_protocol".to_string(),
740            feature_id: Some(u.feature.to_string()),
741            tier: Some("unsupported".to_string()),
742            message: u.detail.clone(),
743            suggestion: None,
744        });
745    }
746
747    Ok(diagnostics)
748}
749
750#[pyfunction]
751fn supported_features() -> Vec<&'static str> {
752    vec![
753        "http",
754        "socks4",
755        "socks4a",
756        "socks5",
757        "shadowsocks",
758        "trojan",
759        "redir",
760        "unix",
761        "bind",
762        "listen",
763        "backward",
764        "rebind",
765        "direct",
766    ]
767}
768
769// --- config explanation helpers ---
770
771fn parse_toml_config(py: Python<'_>, toml_str: &str) -> PyResult<Py<PyDict>> {
772    let parsed: toml::Value = py
773        .detach(|| toml::from_str(toml_str))
774        .map_err(|e| ConfigError::new_err(format!("failed to parse TOML: {e}")))?;
775
776    let dict = PyDict::new(py);
777
778    // Listeners
779    let listeners_list = PyList::empty(py);
780    if let Some(listeners) = parsed.get("listeners").and_then(|v| v.as_array()) {
781        for l in listeners {
782            let ldict = PyDict::new(py);
783            if let Some(name) = l.get("name").and_then(|v| v.as_str()) {
784                ldict.set_item("name", name)?;
785            }
786            if let Some(bind) = l.get("bind").and_then(|v| v.as_str()) {
787                ldict.set_item("bind", bind)?;
788            }
789            if let Some(protocols) = l.get("protocols").and_then(|v| v.as_array()) {
790                let py_protos = PyList::empty(py);
791                for p in protocols {
792                    if let Some(s) = p.as_str() {
793                        py_protos.append(s)?;
794                    }
795                }
796                ldict.set_item("protocols", py_protos)?;
797            }
798            if l.get("udp").is_some() {
799                ldict.set_item("udp_enabled", true)?;
800            }
801            if l.get("tls").is_some() {
802                ldict.set_item("tls", true)?;
803            }
804            if l.get("transparent").is_some() {
805                ldict.set_item("transparent", true)?;
806            }
807            if let Some(unix) = l.get("unix") {
808                ldict.set_item("unix_socket", true)?;
809                if let Some(path) = unix.get("path").and_then(|v| v.as_str()) {
810                    ldict.set_item("unix_path", path)?;
811                }
812            }
813            listeners_list.append(ldict)?;
814        }
815    }
816    dict.set_item("listeners", listeners_list)?;
817
818    // Upstreams
819    let upstreams_list = PyList::empty(py);
820    if let Some(upstreams) = parsed.get("upstreams").and_then(|v| v.as_array()) {
821        for u in upstreams {
822            let udict = PyDict::new(py);
823            if let Some(id) = u.get("id").and_then(|v| v.as_str()) {
824                udict.set_item("id", id)?;
825            }
826            if let Some(uri) = u.get("uri").and_then(|v| v.as_str()) {
827                // Redact credentials in the URI
828                let redacted = redact_config_uri(uri);
829                udict.set_item("uri", redacted)?;
830            }
831            upstreams_list.append(udict)?;
832        }
833    }
834    dict.set_item("upstreams", upstreams_list)?;
835
836    // Upstream groups
837    let groups_list = PyList::empty(py);
838    if let Some(groups) = parsed.get("upstream_groups").and_then(|v| v.as_array()) {
839        for g in groups {
840            let gdict = PyDict::new(py);
841            if let Some(id) = g.get("id").and_then(|v| v.as_str()) {
842                gdict.set_item("id", id)?;
843            }
844            if let Some(scheduler) = g.get("scheduler").and_then(|v| v.as_str()) {
845                gdict.set_item("scheduler", scheduler)?;
846            }
847            if let Some(members) = g.get("members").and_then(|v| v.as_array()) {
848                let py_members = PyList::empty(py);
849                for m in members {
850                    if let Some(s) = m.as_str() {
851                        py_members.append(s)?;
852                    }
853                }
854                gdict.set_item("members", py_members)?;
855            }
856            groups_list.append(gdict)?;
857        }
858    }
859    dict.set_item("upstream_groups", groups_list)?;
860
861    // Rules
862    let rules_list = PyList::empty(py);
863    if let Some(rules) = parsed.get("rules").and_then(|v| v.as_array()) {
864        for r in rules {
865            let rdict = PyDict::new(py);
866            if let Some(id) = r.get("id").and_then(|v| v.as_str()) {
867                rdict.set_item("id", id)?;
868            }
869            if let Some(ug) = r.get("upstream_group").and_then(|v| v.as_str()) {
870                rdict.set_item("upstream_group", ug)?;
871            }
872            if r.get("direct").and_then(|v| v.as_bool()) == Some(true) {
873                rdict.set_item("action", "direct")?;
874            } else if let Some(reject) = r.get("reject").and_then(|v| v.as_str()) {
875                rdict.set_item("action", format!("reject({})", reject))?;
876            } else if r.get("upstream_group").is_some() {
877                rdict.set_item("action", "upstream")?;
878            }
879            if r.get("match").is_some() {
880                rdict.set_item("has_match", true)?;
881            } else if r.get("any").and_then(|v| v.as_bool()) == Some(true) {
882                rdict.set_item("match_all", true)?;
883            }
884            rules_list.append(rdict)?;
885        }
886    }
887    dict.set_item("rules", rules_list)?;
888
889    // Reverse servers
890    let reverse_servers_list = PyList::empty(py);
891    if let Some(servers) = parsed.get("reverse_servers").and_then(|v| v.as_array()) {
892        for s in servers {
893            let sdict = PyDict::new(py);
894            if let Some(id) = s.get("id").and_then(|v| v.as_str()) {
895                sdict.set_item("id", id)?;
896            }
897            if let Some(bind) = s.get("control_bind").and_then(|v| v.as_str()) {
898                sdict.set_item("control_bind", bind)?;
899            }
900            reverse_servers_list.append(sdict)?;
901        }
902    }
903    dict.set_item("reverse_servers", reverse_servers_list)?;
904
905    // Reverse clients
906    let reverse_clients_list = PyList::empty(py);
907    if let Some(clients) = parsed.get("reverse_clients").and_then(|v| v.as_array()) {
908        for c in clients {
909            let cdict = PyDict::new(py);
910            if let Some(id) = c.get("id").and_then(|v| v.as_str()) {
911                cdict.set_item("id", id)?;
912            }
913            if let Some(addr) = c.get("server_addr").and_then(|v| v.as_str()) {
914                cdict.set_item("server_addr", addr)?;
915            }
916            reverse_clients_list.append(cdict)?;
917        }
918    }
919    dict.set_item("reverse_clients", reverse_clients_list)?;
920
921    // Security notes
922    let security_list = PyList::empty(py);
923    // Check for plaintext credentials
924    if let Some(listeners) = parsed.get("listeners").and_then(|v| v.as_array()) {
925        for l in listeners {
926            if l.get("auth").is_some() {
927                security_list.append("listener has plaintext auth credentials in TOML")?;
928            }
929            if l.get("shadowsocks").is_some() {
930                security_list.append("listener has Shadowsocks credentials in TOML")?;
931            }
932        }
933    }
934    if let Some(servers) = parsed.get("reverse_servers").and_then(|v| v.as_array()) {
935        for s in servers {
936            if s.get("auth_password").is_some() {
937                security_list.append("reverse server has plaintext credentials in TOML")?;
938            }
939        }
940    }
941    if let Some(clients) = parsed.get("reverse_clients").and_then(|v| v.as_array()) {
942        for c in clients {
943            if c.get("auth_password").is_some() {
944                security_list.append("reverse client has plaintext credentials in TOML")?;
945            }
946        }
947    }
948    if let Some(listeners) = parsed.get("listeners").and_then(|v| v.as_array()) {
949        for l in listeners {
950            if l.get("transparent").is_some() {
951                security_list.append("transparent proxy listener requires elevated privileges")?;
952            }
953        }
954    }
955    dict.set_item("security_notes", security_list)?;
956
957    Ok(dict.into())
958}
959
960/// Redact credentials from a config URI for safe display.
961///
962/// The userinfo separator is the LAST unbracketed `@` after the scheme,
963/// not the first; a raw password containing `@` must not be split.
964fn redact_config_uri(uri: &str) -> String {
965    let Some(scheme_end) = uri.find("://") else {
966        return uri.to_string();
967    };
968    let after_scheme = &uri[scheme_end + 3..];
969    let mut last_at: Option<usize> = None;
970    let mut bracket_depth = 0u32;
971    for (i, c) in after_scheme.char_indices() {
972        match c {
973            '[' => bracket_depth += 1,
974            ']' => bracket_depth = bracket_depth.saturating_sub(1),
975            '@' if bracket_depth == 0 => last_at = Some(i),
976            _ => {}
977        }
978    }
979    if let Some(at_pos) = last_at {
980        return format!(
981            "{}****@{}",
982            &uri[..scheme_end + 3],
983            &after_scheme[at_pos + 1..]
984        );
985    }
986    uri.to_string()
987}
988
989#[pyfunction]
990fn explain_config_toml(py: Python<'_>, toml_str: &str) -> PyResult<Py<PyDict>> {
991    parse_toml_config(py, toml_str)
992}
993
994#[pyfunction]
995fn explain_pproxy_args(py: Python<'_>, args: &Bound<'_, PySequence>) -> PyResult<Py<PyDict>> {
996    let result = translate_pproxy_args(py, args)?;
997    let toml_str = result.output.toml.clone();
998    let warnings: Vec<(String, String)> = result
999        .output
1000        .warnings
1001        .iter()
1002        .map(|w| (w.category.to_string(), w.message.clone()))
1003        .collect();
1004    let unsupported: Vec<(String, String)> = result
1005        .output
1006        .unsupported
1007        .iter()
1008        .map(|u| (u.feature.to_string(), u.detail.clone()))
1009        .collect();
1010    let is_ok = !result.output.has_unsupported();
1011
1012    let dict = parse_toml_config(py, &toml_str)?;
1013
1014    let warnings_list = PyList::empty(py);
1015    for (cat, msg) in &warnings {
1016        let wdict = PyDict::new(py);
1017        wdict.set_item("category", cat.as_str())?;
1018        wdict.set_item("message", msg.as_str())?;
1019        warnings_list.append(wdict)?;
1020    }
1021    dict.bind(py).set_item("warnings", warnings_list)?;
1022
1023    let unsupported_list = PyList::empty(py);
1024    for (feat, detail) in &unsupported {
1025        let udict = PyDict::new(py);
1026        udict.set_item("feature", feat.as_str())?;
1027        udict.set_item("detail", detail.as_str())?;
1028        unsupported_list.append(udict)?;
1029    }
1030    dict.bind(py).set_item("unsupported", unsupported_list)?;
1031
1032    dict.bind(py).set_item("toml", &toml_str)?;
1033    dict.bind(py).set_item("ok", is_ok)?;
1034
1035    Ok(dict)
1036}
1037
1038#[pyfunction]
1039fn explain_pproxy_uri(py: Python<'_>, uri: &str) -> PyResult<Py<PyDict>> {
1040    let parsed = eggress_pproxy_compat::uri::parse_pproxy_uri(uri)
1041        .map_err(|e| UnsupportedFeatureError::new_err(format!("invalid pproxy URI: {e}")))?;
1042
1043    let output = py
1044        .detach(|| {
1045            eggress_pproxy_compat::translate_from_uris(
1046                &eggress_pproxy_compat::PproxyArgs::default_args(),
1047                &[parsed],
1048                &[],
1049            )
1050        })
1051        .map_err(|e| UnsupportedFeatureError::new_err(format!("translation failed: {e}")))?;
1052
1053    let dict = parse_toml_config(py, &output.toml)?;
1054
1055    let warnings_list = PyList::empty(py);
1056    for w in &output.warnings {
1057        let wdict = PyDict::new(py);
1058        wdict.set_item("category", w.category)?;
1059        wdict.set_item("message", &w.message)?;
1060        warnings_list.append(wdict)?;
1061    }
1062    dict.bind(py).set_item("warnings", warnings_list)?;
1063
1064    let unsupported_list = PyList::empty(py);
1065    for u in &output.unsupported {
1066        let udict = PyDict::new(py);
1067        udict.set_item("feature", u.feature)?;
1068        udict.set_item("detail", &u.detail)?;
1069        unsupported_list.append(udict)?;
1070    }
1071    dict.bind(py).set_item("unsupported", unsupported_list)?;
1072
1073    dict.bind(py).set_item("toml", &output.toml)?;
1074    dict.bind(py).set_item("ok", !output.has_unsupported())?;
1075
1076    Ok(dict)
1077}
1078
1079#[pyfunction]
1080fn route_explain(py: Python<'_>, toml_str: &str, target: &str) -> PyResult<Py<PyDict>> {
1081    use eggress_config::compile::compile_config;
1082    use eggress_config::model::ConfigFile;
1083    use eggress_core::{ClientIdentity, ProtocolId, TargetAddr};
1084    use eggress_routing::{RouteRequest, Router, TransportKind};
1085
1086    let target_addr: TargetAddr = target
1087        .parse()
1088        .map_err(|e: String| PyValueError::new_err(format!("invalid target: {e}")))?;
1089
1090    let explanation = py
1091        .detach(|| -> Result<_, String> {
1092            let config: ConfigFile =
1093                toml::from_str(toml_str).map_err(|e| format!("failed to parse TOML: {e}"))?;
1094
1095            let runtime_config =
1096                compile_config(&config).map_err(|e| format!("failed to compile config: {e}"))?;
1097
1098            let router =
1099                Router::with_groups(runtime_config.rules, runtime_config.default_action, vec![]);
1100
1101            let request = RouteRequest {
1102                target: &target_addr,
1103                source: None,
1104                listener: "",
1105                inbound_protocol: ProtocolId::Socks5,
1106                identity: &ClientIdentity::Anonymous,
1107                transport: TransportKind::Tcp,
1108            };
1109
1110            Ok(router.explain(&request, 0))
1111        })
1112        .map_err(ConfigError::new_err)?;
1113
1114    let dict = PyDict::new(py);
1115    dict.set_item("target", &explanation.target)?;
1116    dict.set_item("listener", &explanation.listener)?;
1117    dict.set_item("protocol", &explanation.protocol)?;
1118    dict.set_item("transport", &explanation.transport)?;
1119    dict.set_item("matched_rule", explanation.matched_rule)?;
1120    dict.set_item("action", &explanation.action)?;
1121    dict.set_item("upstream_group", explanation.upstream_group)?;
1122    dict.set_item("scheduler", explanation.scheduler)?;
1123    let eligible_list = PyList::empty(py);
1124    for u in &explanation.eligible_upstreams {
1125        let udict = PyDict::new(py);
1126        udict.set_item("id", &u.id)?;
1127        udict.set_item("health", &u.health)?;
1128        udict.set_item("eligible", u.eligible)?;
1129        udict.set_item("active", u.active)?;
1130        udict.set_item("in_flight", u.in_flight)?;
1131        eligible_list.append(udict)?;
1132    }
1133    dict.set_item("eligible_upstreams", eligible_list)?;
1134    dict.set_item("selected_upstream", explanation.selected_upstream)?;
1135    dict.set_item("chain", explanation.chain)?;
1136    dict.set_item("generation", explanation.generation)?;
1137
1138    Ok(dict.into())
1139}
1140
1141#[pyfunction]
1142fn test_upstream_connect(py: Python<'_>, uri: &str, timeout_secs: f64) -> PyResult<Py<PyDict>> {
1143    use std::net::ToSocketAddrs;
1144
1145    let dict = PyDict::new(py);
1146
1147    // Parse the URI to extract host:port
1148    let url =
1149        url::Url::parse(uri).map_err(|e| PyValueError::new_err(format!("invalid URI: {e}")))?;
1150
1151    let host = url
1152        .host_str()
1153        .ok_or_else(|| PyValueError::new_err("URI has no host"))?
1154        .to_string();
1155    let port = url.port().unwrap_or(match url.scheme() {
1156        "socks5" => 1080,
1157        "socks4" | "socks4a" => 1080,
1158        "http" | "https" => 80,
1159        "ss" => 8388,
1160        "trojan" => 443,
1161        _ => 0,
1162    });
1163
1164    dict.set_item("host", &host)?;
1165    dict.set_item("port", port)?;
1166    dict.set_item("scheme", url.scheme())?;
1167
1168    // Has auth?
1169    let has_auth = !url.username().is_empty() || url.password().is_some();
1170    dict.set_item("has_auth", has_auth)?;
1171
1172    // Redact for display
1173    let redacted = if has_auth {
1174        format!("{}://****@{}:{}", url.scheme(), host, port)
1175    } else {
1176        format!("{}://{}:{}", url.scheme(), host, port)
1177    };
1178    dict.set_item("redacted_uri", &redacted)?;
1179
1180    // Attempt TCP connect
1181    let addr_str = format!("{}:{}", host, port);
1182    let (connected, latency_us, last_error): (bool, Option<u64>, Option<String>) =
1183        py.detach(|| {
1184            let std_duration = std::time::Duration::from_secs_f64(timeout_secs);
1185            let socket_addrs = match addr_str.to_socket_addrs() {
1186                Ok(addrs) => addrs,
1187                Err(e) => {
1188                    return (false, None, Some(format!("DNS resolution failed: {e}")));
1189                }
1190            };
1191
1192            let mut last_error: Option<String> = None;
1193            for addr in socket_addrs {
1194                let start = std::time::Instant::now();
1195                match std::net::TcpStream::connect_timeout(&addr, std_duration) {
1196                    Ok(_stream) => {
1197                        return (true, Some(start.elapsed().as_micros() as u64), None);
1198                    }
1199                    Err(e) => {
1200                        last_error = Some(format!("connect to {addr} failed: {e}"));
1201                    }
1202                }
1203            }
1204            (false, None, last_error)
1205        });
1206
1207    dict.set_item("connected", connected)?;
1208    dict.set_item("latency_us", latency_us)?;
1209    dict.set_item("error", last_error)?;
1210
1211    Ok(dict.into())
1212}
1213
1214// --- pproxy compatibility translation helpers ---
1215
1216#[pyclass(skip_from_py_object)]
1217#[derive(Clone)]
1218struct PyTranslationWarning {
1219    inner: eggress_pproxy_compat::CompatWarning,
1220}
1221
1222#[pymethods]
1223impl PyTranslationWarning {
1224    #[getter]
1225    fn category(&self) -> &str {
1226        self.inner.category
1227    }
1228
1229    #[getter]
1230    fn message(&self) -> &str {
1231        &self.inner.message
1232    }
1233
1234    #[getter]
1235    fn tier(&self) -> &str {
1236        eggress_pproxy_compat::manifest_tier_for_category(self.inner.category).as_str()
1237    }
1238
1239    fn __repr__(&self) -> String {
1240        format!("[{}] {}", self.inner.category, self.inner.message)
1241    }
1242}
1243
1244#[pyclass(skip_from_py_object)]
1245#[derive(Clone)]
1246struct PyUnsupportedFeature {
1247    inner: eggress_pproxy_compat::UnsupportedFeature,
1248}
1249
1250#[pymethods]
1251impl PyUnsupportedFeature {
1252    #[getter]
1253    fn feature(&self) -> &str {
1254        self.inner.feature
1255    }
1256
1257    #[getter]
1258    fn message(&self) -> &str {
1259        &self.inner.detail
1260    }
1261
1262    #[getter]
1263    fn tier(&self) -> &str {
1264        eggress_pproxy_compat::classify_unsupported_feature_tier(self.inner.feature)
1265    }
1266
1267    fn __repr__(&self) -> String {
1268        format!("unsupported {}: {}", self.inner.feature, self.inner.detail)
1269    }
1270}
1271
1272#[pyclass]
1273struct PyTranslationResult {
1274    output: eggress_pproxy_compat::TranslationOutput,
1275}
1276
1277#[pymethods]
1278impl PyTranslationResult {
1279    #[getter]
1280    fn toml(&self) -> &str {
1281        &self.output.toml
1282    }
1283
1284    #[getter]
1285    fn warnings(&self, py: Python<'_>) -> PyResult<Py<PyList>> {
1286        let list = PyList::empty(py);
1287        for w in &self.output.warnings {
1288            list.append(PyTranslationWarning { inner: w.clone() })?;
1289        }
1290        Ok(list.into())
1291    }
1292
1293    #[getter]
1294    fn unsupported(&self, py: Python<'_>) -> PyResult<Py<PyList>> {
1295        let list = PyList::empty(py);
1296        for u in &self.output.unsupported {
1297            list.append(PyUnsupportedFeature { inner: u.clone() })?;
1298        }
1299        Ok(list.into())
1300    }
1301
1302    #[getter]
1303    fn ok(&self) -> bool {
1304        !self.output.has_unsupported()
1305    }
1306
1307    /// Manifest-aligned aggregate compatibility tier.
1308    ///
1309    /// This is the single source of truth for the five-tier compatibility
1310    /// classification; Python and the canonical manifest must agree with
1311    /// this value. The same value is reused by the CLI `pproxy check`
1312    /// reporter.
1313    #[getter]
1314    fn tier(&self) -> &'static str {
1315        eggress_pproxy_compat::classify_aggregate_tier(
1316            &self.output.warnings,
1317            &self.output.unsupported,
1318        )
1319        .as_str()
1320    }
1321
1322    fn config(&self, py: Python<'_>) -> PyResult<PyEggressConfig> {
1323        let config = py
1324            .detach(|| eggress_embed::EggressConfig::from_toml_str(&self.output.toml))
1325            .map_err(|e| map_error(py, e))?;
1326        Ok(PyEggressConfig { inner: config })
1327    }
1328
1329    fn __repr__(&self) -> String {
1330        format!(
1331            "TranslationResult(warnings={}, unsupported={})",
1332            self.output.warnings.len(),
1333            self.output.unsupported.len()
1334        )
1335    }
1336}
1337
1338#[pyfunction]
1339fn translate_pproxy_args(
1340    py: Python<'_>,
1341    args: &Bound<'_, PySequence>,
1342) -> PyResult<PyTranslationResult> {
1343    let len = args.len()?;
1344    let raw: Vec<String> = (0..len)
1345        .map(|i| args.get_item(i)?.extract::<String>())
1346        .collect::<PyResult<_>>()?;
1347
1348    let parsed = eggress_pproxy_compat::PproxyArgs::parse(&raw).map_err(|e| {
1349        UnsupportedFeatureError::new_err(format!("failed to parse pproxy args: {e}"))
1350    })?;
1351
1352    let output = py
1353        .detach(|| eggress_pproxy_compat::translate_pproxy_args(&parsed))
1354        .map_err(|e| UnsupportedFeatureError::new_err(format!("translation failed: {e}")))?;
1355
1356    Ok(PyTranslationResult { output })
1357}
1358
1359#[pyfunction]
1360fn translate_pproxy_uri(
1361    py: Python<'_>,
1362    local: &str,
1363    remotes: Option<&Bound<'_, PySequence>>,
1364) -> PyResult<PyTranslationResult> {
1365    let local_uri = eggress_pproxy_compat::uri::parse_pproxy_uri(local)
1366        .map_err(|e| UnsupportedFeatureError::new_err(format!("invalid local URI: {e}")))?;
1367
1368    let remote_chains: Vec<eggress_pproxy_compat::PproxyChain> = match remotes {
1369        Some(seq) => {
1370            let len = seq.len()?;
1371            (0..len)
1372                .map(|i| {
1373                    let s: String = seq.get_item(i)?.extract()?;
1374                    eggress_pproxy_compat::uri::parse_pproxy_chain(&s).map_err(|e| {
1375                        UnsupportedFeatureError::new_err(format!("invalid remote URI: {e}"))
1376                    })
1377                })
1378                .collect::<PyResult<_>>()?
1379        }
1380        None => Vec::new(),
1381    };
1382
1383    let output = py
1384        .detach(|| {
1385            eggress_pproxy_compat::translate_from_uris(
1386                &eggress_pproxy_compat::PproxyArgs::default_args(),
1387                &[local_uri],
1388                &remote_chains,
1389            )
1390        })
1391        .map_err(|e| UnsupportedFeatureError::new_err(format!("translation failed: {e}")))?;
1392
1393    Ok(PyTranslationResult { output })
1394}
1395
1396#[pyfunction]
1397fn check_pproxy_args(
1398    py: Python<'_>,
1399    args: &Bound<'_, PySequence>,
1400) -> PyResult<PyTranslationResult> {
1401    translate_pproxy_args(py, args)
1402}
1403
1404/// Validate the frozen executable parser contract without starting a service.
1405/// Migration helpers intentionally retain their broader translation-only
1406/// extension surface.
1407#[pyfunction]
1408fn validate_pproxy_args(args: &Bound<'_, PySequence>) -> PyResult<()> {
1409    let len = args.len()?;
1410    let raw: Vec<String> = (0..len)
1411        .map(|i| args.get_item(i)?.extract::<String>())
1412        .collect::<PyResult<_>>()?;
1413    let parsed = if raw.is_empty() {
1414        eggress_pproxy_compat::PproxyArgs::default_args()
1415    } else {
1416        eggress_pproxy_compat::PproxyArgs::parse(&raw)
1417            .map_err(|e| PyValueError::new_err(format!("pproxy argument error: {e}")))?
1418    };
1419    if let Some(flag) = parsed.strict_parser_violations().first() {
1420        return Err(PyValueError::new_err(format!(
1421            "pproxy: unknown option or positional argument '{flag}'"
1422        )));
1423    }
1424    parsed
1425        .validate_strict_values()
1426        .map_err(|e| PyValueError::new_err(format!("pproxy argument error: {e}")))
1427}
1428
1429/// Return compatibility runtime options using the canonical Rust parser.
1430/// Migration callers may still use the broader translation surface; strict
1431/// executable entry points perform their separate parser-gate validation.
1432#[pyfunction]
1433fn pproxy_runtime_options(args: &Bound<'_, PySequence>) -> PyResult<Py<PyDict>> {
1434    let len = args.len()?;
1435    let raw: Vec<String> = (0..len)
1436        .map(|i| args.get_item(i)?.extract::<String>())
1437        .collect::<PyResult<_>>()?;
1438    let parsed = if raw.is_empty() {
1439        eggress_pproxy_compat::PproxyArgs::default_args()
1440    } else {
1441        eggress_pproxy_compat::PproxyArgs::parse(&raw)
1442            .map_err(|e| PyValueError::new_err(format!("pproxy argument error: {e}")))?
1443    };
1444    let dict = PyDict::new(args.py());
1445    dict.set_item(
1446        "auth_timeout_seconds",
1447        parsed.effective_auth_timeout().as_secs(),
1448    )?;
1449    dict.set_item("system_proxy", parsed.system_proxy)?;
1450    dict.set_item("debug", parsed.debug)?;
1451    dict.set_item("verbose_level", parsed.verbose_level)?;
1452    dict.set_item("default_log_level", parsed.default_log_level())?;
1453    Ok(dict.into())
1454}
1455
1456/// Install compatibility-process logging for `python -m pproxy` while
1457/// respecting an existing embedded application's tracing subscriber.
1458#[pyfunction]
1459fn init_pproxy_logging(default_level: &str) -> PyResult<()> {
1460    let filter = tracing_subscriber::EnvFilter::try_from_default_env()
1461        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_level));
1462    let _ = tracing_subscriber::fmt()
1463        .with_env_filter(filter)
1464        .compact()
1465        .try_init();
1466    Ok(())
1467}
1468
1469/// Run the native compatibility upstream test without starting listeners.
1470#[pyfunction]
1471fn run_pproxy_test(py: Python<'_>, args: &Bound<'_, PySequence>, target: &str) -> PyResult<i32> {
1472    let len = args.len()?;
1473    let raw: Vec<String> = (0..len)
1474        .map(|i| args.get_item(i)?.extract::<String>())
1475        .collect::<PyResult<_>>()?;
1476    let parsed = if raw.is_empty() {
1477        eggress_pproxy_compat::PproxyArgs::default_args()
1478    } else {
1479        eggress_pproxy_compat::PproxyArgs::parse(&raw)
1480            .map_err(|e| PyValueError::new_err(format!("pproxy argument error: {e}")))?
1481    };
1482    if let Some(flag) = parsed.strict_parser_violations().first() {
1483        return Err(PyValueError::new_err(format!(
1484            "pproxy: unknown option or positional argument '{flag}'"
1485        )));
1486    }
1487    parsed
1488        .validate_strict_values()
1489        .map_err(|e| PyValueError::new_err(format!("pproxy argument error: {e}")))?;
1490    let output = eggress_pproxy_compat::translate_pproxy_args(&parsed)
1491        .map_err(|e| PyValueError::new_err(format!("pproxy translation error: {e}")))?;
1492    let gate = eggress_pproxy_compat::evaluate_execution_gate(&parsed, &output);
1493    if !gate.allows_start() {
1494        return Err(UnsupportedFeatureError::new_err(gate.blocker_summary()));
1495    }
1496    let (config, _) = eggress_config::validate_and_compile_toml_with_warnings(&output.toml)
1497        .map_err(|e| ConfigError::new_err(format!("pproxy config error: {e}")))?;
1498    let target = eggress_cli::parse_pproxy_test_target(target)
1499        .map_err(PyValueError::new_err)?
1500        .to_string();
1501    if config.upstreams.is_empty() {
1502        return Ok(0);
1503    }
1504    Ok(py.detach(|| {
1505        eggress_cli::run_upstream_test(&config, Some(&target), Duration::from_secs(10), false)
1506    }))
1507}
1508
1509#[pyclass]
1510struct PyReverseUriSummary {
1511    /// "server" or "client" or "unknown"
1512    role: String,
1513    scheme: String,
1514    /// "host:port" string in redacted form for display
1515    target: String,
1516    has_auth: bool,
1517    /// "reverse_servers" or "reverse_clients" or "unknown"
1518    toml_section: String,
1519    tls: bool,
1520    /// Modifiers parsed (e.g. "+tls", "+in")
1521    modifiers: Vec<String>,
1522}
1523
1524#[pymethods]
1525impl PyReverseUriSummary {
1526    #[getter]
1527    fn role(&self) -> &str {
1528        &self.role
1529    }
1530    #[getter]
1531    fn scheme(&self) -> &str {
1532        &self.scheme
1533    }
1534    #[getter]
1535    fn target(&self) -> &str {
1536        &self.target
1537    }
1538    #[getter]
1539    fn has_auth(&self) -> bool {
1540        self.has_auth
1541    }
1542    #[getter]
1543    fn toml_section(&self) -> &str {
1544        &self.toml_section
1545    }
1546    #[getter]
1547    fn tls(&self) -> bool {
1548        self.tls
1549    }
1550    #[getter]
1551    fn modifiers(&self) -> Vec<String> {
1552        self.modifiers.clone()
1553    }
1554    fn __repr__(&self) -> String {
1555        format!(
1556            "ReverseUriSummary(role={}, target={}, toml_section={}, has_auth={})",
1557            self.role, self.target, self.toml_section, self.has_auth
1558        )
1559    }
1560}
1561
1562#[pyfunction]
1563fn describe_reverse_pproxy_uri(uri: &str) -> PyResult<PyReverseUriSummary> {
1564    let parsed = eggress_pproxy_compat::uri::parse_pproxy_uri(uri)
1565        .map_err(|e| UnsupportedFeatureError::new_err(format!("invalid pproxy URI: {e}")))?;
1566
1567    let (role, toml_section) = if parsed.is_reverse_listener() {
1568        ("server", "reverse_servers")
1569    } else if parsed.is_backward() {
1570        ("client", "reverse_clients")
1571    } else {
1572        ("unknown", "unknown")
1573    };
1574
1575    let target = parsed.redacted_display();
1576
1577    // Modifiers encoded in the scheme: +tls, +ssl, +in, ...
1578    let mut modifiers: Vec<String> = Vec::new();
1579    if parsed.tls {
1580        modifiers.push("+tls".to_string());
1581    }
1582    if parsed.ssl {
1583        modifiers.push("+ssl".to_string());
1584    }
1585    for _ in 0..parsed.backward_num {
1586        modifiers.push("+in".to_string());
1587    }
1588
1589    Ok(PyReverseUriSummary {
1590        role: role.to_string(),
1591        scheme: parsed.scheme.clone(),
1592        target,
1593        has_auth: parsed.username.is_some(),
1594        toml_section: toml_section.to_string(),
1595        tls: parsed.tls,
1596        modifiers,
1597    })
1598}
1599
1600/// Python-accessible outbound connector for direct proxy chain connections.
1601///
1602/// This wraps the Rust `OutboundConnector` and provides a Python interface
1603/// for making outbound TCP connections through a configured proxy chain
1604/// without starting a listener service.
1605///
1606/// The returned stream owns the Tokio runtime needed by the Rust transport,
1607/// so it remains usable after the connector is dropped. Blocking methods
1608/// release the Python GIL while they wait for network I/O.
1609#[pyclass]
1610struct PyOutboundConnector {
1611    inner: eggress_embed::outbound::OutboundConnector,
1612}
1613
1614struct OutboundStreamState {
1615    stream: Option<eggress_core::BoxStream>,
1616}
1617
1618/// A connected native outbound stream.
1619///
1620/// This deliberately exposes a small socket/asyncio-stream compatible surface
1621/// instead of a file descriptor. Advanced transports (TLS, WebSocket, H2,
1622/// and multi-hop chains) do not necessarily have a meaningful OS socket after
1623/// the first hop. `recv`/`sendall` aliases are provided for pproxy programs;
1624/// `read`/`write` are the canonical APIs.
1625#[pyclass]
1626struct PyOutboundStream {
1627    runtime: Arc<tokio::runtime::Runtime>,
1628    state: std::sync::Mutex<OutboundStreamState>,
1629    peer_addr: Option<String>,
1630    local_addr: Option<String>,
1631    hop_count: usize,
1632}
1633
1634impl PyOutboundStream {
1635    fn with_stream<T>(
1636        &self,
1637        operation: impl FnOnce(&mut eggress_core::BoxStream) -> T,
1638    ) -> PyResult<T> {
1639        let mut state = self
1640            .state
1641            .lock()
1642            .map_err(|_| ConnectionError::new_err("outbound stream lock poisoned"))?;
1643        let stream = state
1644            .stream
1645            .as_mut()
1646            .ok_or_else(|| ConnectionClosedError::new_err("outbound stream is closed"))?;
1647        Ok(operation(stream))
1648    }
1649
1650    fn closed_inner(&self) -> bool {
1651        self.state
1652            .lock()
1653            .map(|state| state.stream.is_none())
1654            .unwrap_or(true)
1655    }
1656}
1657
1658impl Drop for PyOutboundStream {
1659    fn drop(&mut self) {
1660        // Dropping the BoxStream closes the transport. Do not block from a
1661        // destructor: Python may be shutting down and the runtime can be
1662        // unavailable at that point.
1663        if let Ok(mut state) = self.state.lock() {
1664            state.stream.take();
1665        }
1666    }
1667}
1668
1669#[pymethods]
1670impl PyOutboundStream {
1671    #[getter]
1672    fn closed(&self) -> bool {
1673        self.closed_inner()
1674    }
1675
1676    fn is_closing(&self) -> bool {
1677        self.closed_inner()
1678    }
1679
1680    #[getter]
1681    fn peername(&self) -> Option<&str> {
1682        self.peer_addr.as_deref()
1683    }
1684
1685    #[getter]
1686    fn sockname(&self) -> Option<&str> {
1687        self.local_addr.as_deref()
1688    }
1689
1690    fn get_extra_info(
1691        &self,
1692        py: Python<'_>,
1693        name: &str,
1694        default: Option<Py<PyAny>>,
1695    ) -> PyResult<Py<PyAny>> {
1696        match name {
1697            "peername" => match self.peer_addr.as_ref() {
1698                Some(value) => Ok(PyString::new(py, value).into_any().unbind()),
1699                None => Ok(default.unwrap_or_else(|| py.None())),
1700            },
1701            "sockname" => match self.local_addr.as_ref() {
1702                Some(value) => Ok(PyString::new(py, value).into_any().unbind()),
1703                None => Ok(default.unwrap_or_else(|| py.None())),
1704            },
1705            "hop_count" => {
1706                let hop_count = self.hop_count.to_string();
1707                Ok(PyString::new(py, &hop_count).into_any().unbind())
1708            }
1709            _ => Ok(default.unwrap_or_else(|| py.None())),
1710        }
1711    }
1712
1713    fn read(&self, py: Python<'_>, n: i64) -> PyResult<Vec<u8>> {
1714        if n < -1 {
1715            return Err(PyValueError::new_err(
1716                "read length must be -1 or non-negative",
1717            ));
1718        }
1719        let runtime = self.runtime.clone();
1720        self.with_stream(|stream| {
1721            py.detach(|| {
1722                runtime.block_on(async {
1723                    if n == -1 {
1724                        let mut data = Vec::new();
1725                        stream.read_to_end(&mut data).await.map(|_| data)
1726                    } else {
1727                        let mut data = vec![0_u8; n as usize];
1728                        let count = stream.read(&mut data).await?;
1729                        data.truncate(count);
1730                        Ok(data)
1731                    }
1732                })
1733            })
1734        })?
1735        .map_err(|e: std::io::Error| ConnectionError::new_err(format!("read failed: {e}")))
1736    }
1737
1738    fn readexactly(&self, py: Python<'_>, n: usize) -> PyResult<Vec<u8>> {
1739        let runtime = self.runtime.clone();
1740        self.with_stream(|stream| {
1741            py.detach(|| {
1742                runtime.block_on(async {
1743                    let mut data = vec![0_u8; n];
1744                    stream.read_exact(&mut data).await.map(|_| data)
1745                })
1746            })
1747        })?
1748        .map_err(|e: std::io::Error| ConnectionError::new_err(format!("readexactly failed: {e}")))
1749    }
1750
1751    fn write(&self, py: Python<'_>, data: &[u8]) -> PyResult<usize> {
1752        let runtime = self.runtime.clone();
1753        self.with_stream(|stream| {
1754            py.detach(|| runtime.block_on(async { stream.write(data).await }))
1755        })?
1756        .map_err(|e: std::io::Error| ConnectionError::new_err(format!("write failed: {e}")))
1757    }
1758
1759    fn sendall(&self, py: Python<'_>, data: &[u8]) -> PyResult<()> {
1760        let runtime = self.runtime.clone();
1761        self.with_stream(|stream| {
1762            py.detach(|| runtime.block_on(async { stream.write_all(data).await }))
1763        })?
1764        .map_err(|e: std::io::Error| ConnectionError::new_err(format!("sendall failed: {e}")))
1765    }
1766
1767    fn drain(&self, py: Python<'_>) -> PyResult<()> {
1768        let runtime = self.runtime.clone();
1769        self.with_stream(|stream| py.detach(|| runtime.block_on(async { stream.flush().await })))?
1770            .map_err(|e: std::io::Error| ConnectionError::new_err(format!("drain failed: {e}")))
1771    }
1772
1773    fn write_eof(&self, py: Python<'_>) -> PyResult<()> {
1774        let runtime = self.runtime.clone();
1775        self.with_stream(|stream| {
1776            py.detach(|| runtime.block_on(async { stream.shutdown().await }))
1777        })?
1778        .map_err(|e: std::io::Error| ConnectionError::new_err(format!("write_eof failed: {e}")))
1779    }
1780
1781    fn close(&self) -> PyResult<()> {
1782        let mut state = self
1783            .state
1784            .lock()
1785            .map_err(|_| ConnectionError::new_err("outbound stream lock poisoned"))?;
1786        state.stream.take();
1787        Ok(())
1788    }
1789
1790    fn wait_closed(&self) -> PyResult<()> {
1791        self.close()
1792    }
1793
1794    fn __enter__(slf: Py<Self>) -> Py<Self> {
1795        slf
1796    }
1797
1798    fn __exit__(
1799        &self,
1800        _py: Python<'_>,
1801        _exc_type: &Bound<'_, PyAny>,
1802        _exc_value: &Bound<'_, PyAny>,
1803        _traceback: &Bound<'_, PyAny>,
1804    ) -> PyResult<bool> {
1805        self.close()?;
1806        Ok(false)
1807    }
1808
1809    fn __del__(&self) {
1810        if !self.closed_inner() {
1811            eprintln!("Warning: outbound stream was not explicitly closed; cleaning up");
1812            let _ = self.close();
1813        }
1814    }
1815
1816    fn __repr__(&self) -> String {
1817        format!(
1818            "OutboundStream(peername={:?}, hop_count={}, closed={})",
1819            self.peer_addr,
1820            self.hop_count,
1821            self.closed_inner()
1822        )
1823    }
1824}
1825
1826#[pymethods]
1827impl PyOutboundConnector {
1828    /// Create a connector from a pproxy-style URI string.
1829    #[staticmethod]
1830    fn from_pproxy_uri(uri: &str) -> PyResult<Self> {
1831        let inner = eggress_embed::outbound::OutboundConnector::from_pproxy_uri(uri)
1832            .map_err(|e| ConnectionError::new_err(format!("failed to create connector: {e}")))?;
1833        Ok(Self { inner })
1834    }
1835
1836    /// Create a connector from a TOML config string.
1837    #[staticmethod]
1838    fn from_toml(config_toml: &str) -> PyResult<Self> {
1839        let inner = eggress_embed::outbound::OutboundConnector::from_toml(config_toml)
1840            .map_err(|e| ConnectionError::new_err(format!("failed to create connector: {e}")))?;
1841        Ok(Self { inner })
1842    }
1843
1844    /// Validate that a TOML config is usable for outbound connections.
1845    ///
1846    /// Returns the number of hops in the first upstream's chain.
1847    #[staticmethod]
1848    fn validate_config(config_toml: &str) -> PyResult<usize> {
1849        eggress_embed::outbound::OutboundConnector::validate_outbound_config(config_toml)
1850            .map_err(|e| ConnectionError::new_err(format!("config validation failed: {e}")))
1851    }
1852
1853    /// Get the number of upstreams configured.
1854    fn upstream_count(&self) -> usize {
1855        self.inner.upstream_count()
1856    }
1857
1858    /// Open a native outbound TCP stream. No local listener is created.
1859    #[pyo3(signature = (host, port, timeout=None))]
1860    fn connect_tcp(
1861        &self,
1862        py: Python<'_>,
1863        host: &str,
1864        port: u16,
1865        timeout: Option<f64>,
1866    ) -> PyResult<PyOutboundStream> {
1867        if host.is_empty() {
1868            return Err(PyValueError::new_err("host must not be empty"));
1869        }
1870        let timeout = timeout
1871            .map(|seconds| {
1872                if !seconds.is_finite() || seconds <= 0.0 {
1873                    Err(PyValueError::new_err("timeout must be finite and positive"))
1874                } else {
1875                    Ok(Duration::from_secs_f64(seconds))
1876                }
1877            })
1878            .transpose()?;
1879        let runtime = outbound_runtime().map_err(ConnectionError::new_err)?;
1880        let inner = &self.inner;
1881        let result = py.detach(|| {
1882            runtime.block_on(async {
1883                match timeout {
1884                    Some(timeout) => inner.connect_tcp_timeout(host, port, timeout).await,
1885                    None => inner.connect_tcp(host, port).await,
1886                }
1887            })
1888        });
1889        let (stream, info) = result
1890            .map_err(|e| ConnectionError::new_err(format!("outbound connect failed: {e}")))?;
1891        Ok(PyOutboundStream {
1892            runtime,
1893            state: std::sync::Mutex::new(OutboundStreamState {
1894                stream: Some(stream),
1895            }),
1896            peer_addr: info.peer_addr.map(|addr| addr.to_string()),
1897            local_addr: info.local_addr.map(|addr| addr.to_string()),
1898            hop_count: info.hop_count,
1899        })
1900    }
1901
1902    /// Get connection metadata for a target host:port.
1903    ///
1904    /// Resolves the first hop endpoint and returns metadata about the
1905    /// configured chain without actually connecting.
1906    fn preview_connect(&self, py: Python<'_>, host: &str, port: u16) -> PyResult<Py<PyDict>> {
1907        let dict = PyDict::new(py);
1908        dict.set_item("target_host", host)?;
1909        dict.set_item("target_port", port)?;
1910        dict.set_item("hop_count", self.inner.upstream_count())?;
1911        Ok(dict.into())
1912    }
1913}
1914
1915#[pymodule]
1916fn _eggress(m: &Bound<'_, PyModule>) -> PyResult<()> {
1917    m.add_class::<PyEggressConfig>()?;
1918    m.add_class::<PyEggressService>()?;
1919    m.add_class::<PyEggressHandle>()?;
1920    m.add_class::<PyAppliedSystemProxy>()?;
1921    m.add_class::<PyTranslationWarning>()?;
1922    m.add_class::<PyUnsupportedFeature>()?;
1923    m.add_class::<PyTranslationResult>()?;
1924    m.add_class::<PyReverseUriSummary>()?;
1925    m.add_class::<PyUriInfo>()?;
1926    m.add_class::<PyDiagnostic>()?;
1927    m.add_class::<PyConnection>()?;
1928    m.add_class::<PyOutboundConnector>()?;
1929    m.add_class::<PyOutboundStream>()?;
1930    m.add_function(wrap_pyfunction!(translate_pproxy_args, m)?)?;
1931    m.add_function(wrap_pyfunction!(translate_pproxy_uri, m)?)?;
1932    m.add_function(wrap_pyfunction!(check_pproxy_args, m)?)?;
1933    m.add_function(wrap_pyfunction!(validate_pproxy_args, m)?)?;
1934    m.add_function(wrap_pyfunction!(pproxy_runtime_options, m)?)?;
1935    m.add_function(wrap_pyfunction!(init_pproxy_logging, m)?)?;
1936    m.add_function(wrap_pyfunction!(run_pproxy_test, m)?)?;
1937    m.add_function(wrap_pyfunction!(describe_reverse_pproxy_uri, m)?)?;
1938    m.add_function(wrap_pyfunction!(check_pproxy_uri, m)?)?;
1939    m.add_function(wrap_pyfunction!(redact_pproxy_uri, m)?)?;
1940    m.add_function(wrap_pyfunction!(diagnostics_for_uri, m)?)?;
1941    m.add_function(wrap_pyfunction!(supported_features, m)?)?;
1942    m.add_function(wrap_pyfunction!(explain_config_toml, m)?)?;
1943    m.add_function(wrap_pyfunction!(explain_pproxy_args, m)?)?;
1944    m.add_function(wrap_pyfunction!(explain_pproxy_uri, m)?)?;
1945    m.add_function(wrap_pyfunction!(route_explain, m)?)?;
1946    m.add_function(wrap_pyfunction!(test_upstream_connect, m)?)?;
1947    m.add_function(wrap_pyfunction!(apply_system_proxy, m)?)?;
1948    m.add("EggressError", m.py().get_type::<EggressError>())?;
1949    m.add("ConfigError", m.py().get_type::<ConfigError>())?;
1950    m.add("StartupError", m.py().get_type::<StartupError>())?;
1951    m.add("ReloadError", m.py().get_type::<ReloadError>())?;
1952    m.add("ShutdownError", m.py().get_type::<ShutdownError>())?;
1953    m.add(
1954        "UnsupportedFeatureError",
1955        m.py().get_type::<UnsupportedFeatureError>(),
1956    )?;
1957    m.add("InternalError", m.py().get_type::<InternalError>())?;
1958    m.add("ConnectionError", m.py().get_type::<ConnectionError>())?;
1959    m.add(
1960        "ConnectionClosedError",
1961        m.py().get_type::<ConnectionClosedError>(),
1962    )?;
1963    m.add("TimeoutError", m.py().get_type::<TimeoutError>())?;
1964    m.add("DnsError", m.py().get_type::<DnsError>())?;
1965    m.add("AuthError", m.py().get_type::<AuthError>())?;
1966    m.add("TlsError", m.py().get_type::<TlsError>())?;
1967    m.add("LoopMismatchError", m.py().get_type::<LoopMismatchError>())?;
1968    m.add(
1969        "ConnectionCancelledError",
1970        m.py().get_type::<ConnectionCancelledError>(),
1971    )?;
1972    m.add(
1973        "UseAfterCloseError",
1974        m.py().get_type::<UseAfterCloseError>(),
1975    )?;
1976    m.add(
1977        "UdpAssociationError",
1978        m.py().get_type::<UdpAssociationError>(),
1979    )?;
1980    m.add(
1981        "UnsupportedCompositionError",
1982        m.py().get_type::<UnsupportedCompositionError>(),
1983    )?;
1984    m.add("__version__", env!("CARGO_PKG_VERSION"))?;
1985    Ok(())
1986}