Skip to main content

soma_provider_adapters/
python.rs

1//! The generic Python (/ LangChain / LlamaIndex) provider kind: introspects
2//! and executes a drop-in `.py` provider through a bounded Python sidecar
3//! running `python_bridge::python_bridge_program`. Ported from
4//! `soma-service::providers::python`.
5//!
6//! `load_python_catalog` here applies only *generic* manifest validation
7//! (`soma_provider_core::validate_provider_manifest_value`) — Soma's own CLI
8//! reserved-command / env-prefix policy layer is applied downstream by the
9//! host's provider registry when it builds every provider's catalog
10//! (regardless of kind), so dropping the redundant Soma-specific pre-check
11//! here does not weaken overall enforcement — see the PR10 deviation notes.
12
13use std::{
14    io::{Read, Write},
15    path::{Path, PathBuf},
16    process::{Command as StdCommand, Stdio as StdStdio},
17    sync::Arc,
18    thread,
19    time::{Duration, Instant},
20};
21
22use async_trait::async_trait;
23use serde_json::Value;
24use soma_provider_core::{
25    Provider, ProviderCall, ProviderCatalog, ProviderError, ProviderOutput, ProviderTool,
26    validate_provider_manifest_value,
27};
28use tokio::time::Instant as TokioInstant;
29
30use crate::{
31    error::{SidecarError, redact_public},
32    python_bridge::python_bridge_program,
33    python_protocol::{
34        PYTHON_PROTOCOL_HEADROOM_BYTES, PythonProtocolError, PythonWorkerRequest,
35        PythonWorkerResponse, decode_python_response, encode_python_request,
36        validate_python_response,
37    },
38    sidecar::{
39        collect_provider_env, output_exceeded_message, resolve_sidecar_command,
40        run_bounded_sidecar, sidecar_base_env, spawn_retrying_busy_image_blocking,
41    },
42};
43use supervisor::{PythonSupervisorConfig, PythonWorkerIdentity, PythonWorkerSupervisor};
44
45pub mod cache;
46mod catalog;
47mod containment;
48pub mod environment;
49pub mod host;
50mod interpreter;
51pub mod lifecycle;
52pub mod materializer;
53pub mod supervisor;
54pub use interpreter::PythonInterpreter;
55pub(crate) use interpreter::default_python_command;
56use interpreter::select_python_command;
57
58const DEFAULT_TIMEOUT_MS: u64 = 10_000;
59const DEFAULT_MAX_INPUT_BYTES: usize = 64 * 1024;
60const DEFAULT_MAX_OUTPUT_BYTES: usize = 256 * 1024;
61
62pub use catalog::describe_persistent_catalog;
63
64fn protocol_output_limit(payload_limit: usize) -> usize {
65    payload_limit.saturating_add(PYTHON_PROTOCOL_HEADROOM_BYTES)
66}
67
68#[derive(Clone)]
69pub struct PythonProvider {
70    path: PathBuf,
71    catalog: ProviderCatalog,
72    env_prefix: String,
73    interpreter: PythonInterpreter,
74    supervisor: Option<Arc<PythonWorkerSupervisor>>,
75}
76
77impl PythonProvider {
78    /// Starts, negotiates, describes, and health-checks a persistent worker.
79    /// One-shot providers have no persistent preflight and return immediately.
80    pub async fn preflight(&self) -> Result<(), ProviderError> {
81        let Some(supervisor) = &self.supervisor else {
82            return Ok(());
83        };
84        supervisor.preflight().await.map(|_| ()).map_err(|error| {
85            ProviderError::new(
86                error.code(),
87                &self.catalog.provider.name,
88                None,
89                error.to_string(),
90                "Install the matching soma-provider wheel and inspect the provider source.",
91            )
92            .with_provider_kind(self.catalog.provider.kind.as_str())
93            .with_source(self.path.display().to_string())
94            .with_phase("persistent-preflight")
95        })
96    }
97
98    pub fn new(path: PathBuf, catalog: ProviderCatalog, env_prefix: impl Into<String>) -> Self {
99        Self::new_with_interpreter(path, catalog, env_prefix, PythonInterpreter::Ambient)
100    }
101
102    pub fn new_with_interpreter(
103        path: PathBuf,
104        catalog: ProviderCatalog,
105        env_prefix: impl Into<String>,
106        interpreter: PythonInterpreter,
107    ) -> Self {
108        Self {
109            path,
110            catalog,
111            env_prefix: env_prefix.into(),
112            interpreter,
113            supervisor: None,
114        }
115    }
116
117    /// Builds a provider backed by one lazily-started persistent worker.
118    pub fn new_persistent(
119        path: PathBuf,
120        catalog: ProviderCatalog,
121        env_prefix: impl Into<String>,
122        interpreter: PythonInterpreter,
123        config: PythonSupervisorConfig,
124    ) -> Result<Self, ProviderError> {
125        Self::new_persistent_inner(path, catalog, env_prefix, interpreter, config, None)
126    }
127
128    fn new_persistent_inner(
129        path: PathBuf,
130        catalog: ProviderCatalog,
131        env_prefix: impl Into<String>,
132        interpreter: PythonInterpreter,
133        config: PythonSupervisorConfig,
134        immutable_generation_digest: Option<String>,
135    ) -> Result<Self, ProviderError> {
136        if !catalog.env.is_empty() || catalog.tools.iter().any(|tool| !tool.env.is_empty()) {
137            return Err(ProviderError::validation(
138                &catalog.provider.name,
139                "",
140                "python_persistent_env_unsupported",
141                "Persistent Python providers cannot declare runtime environment requirements",
142            ));
143        }
144        let source = std::fs::read(&path).map_err(|error| {
145            ProviderError::execution(&catalog.provider.name, "", error)
146                .with_phase("persistent-preflight")
147        })?;
148        let source_digest = sha256_hex(&source);
149        let worker_group = immutable_generation_digest.unwrap_or_else(|| source_digest.clone());
150        if worker_group.len() != 64 || !worker_group.as_bytes().iter().all(u8::is_ascii_hexdigit) {
151            return Err(ProviderError::validation(
152                &catalog.provider.name,
153                "",
154                "python_generation_digest_invalid",
155                "immutable Python generation digest must contain exactly 64 ASCII hexadecimal characters",
156            ));
157        }
158        let catalog_fingerprint = python_catalog_fingerprint(&catalog)
159            .map_err(|error| ProviderError::execution(&catalog.provider.name, "", error))?;
160        let generation_id = format!("{}-{}", catalog.provider.name, &worker_group[..16]);
161        let supervisor = PythonWorkerSupervisor::new_with_capabilities(
162            PythonWorkerIdentity {
163                path: path.clone(),
164                generation_id,
165                worker_group,
166                source_digest,
167                catalog_fingerprint,
168            },
169            interpreter.clone(),
170            config,
171            &catalog.capabilities,
172        );
173        Ok(Self {
174            path,
175            catalog,
176            env_prefix: env_prefix.into(),
177            interpreter,
178            supervisor: Some(supervisor),
179        })
180    }
181
182    pub fn arc_persistent(
183        path: PathBuf,
184        catalog: ProviderCatalog,
185        env_prefix: impl Into<String>,
186        interpreter: PythonInterpreter,
187        config: PythonSupervisorConfig,
188    ) -> Result<Arc<Self>, ProviderError> {
189        Self::new_persistent(path, catalog, env_prefix, interpreter, config).map(Arc::new)
190    }
191
192    /// Builds a persistent provider whose identity and worker budget are bound
193    /// to the complete immutable source-tree generation.
194    pub fn arc_persistent_in_generation(
195        path: PathBuf,
196        catalog: ProviderCatalog,
197        env_prefix: impl Into<String>,
198        interpreter: PythonInterpreter,
199        config: PythonSupervisorConfig,
200        immutable_generation_digest: String,
201    ) -> Result<Arc<Self>, ProviderError> {
202        Self::new_persistent_inner(
203            path,
204            catalog,
205            env_prefix,
206            interpreter,
207            config,
208            Some(immutable_generation_digest),
209        )
210        .map(Arc::new)
211    }
212
213    pub fn arc(
214        path: PathBuf,
215        catalog: ProviderCatalog,
216        env_prefix: impl Into<String>,
217    ) -> Arc<Self> {
218        Arc::new(Self::new(path, catalog, env_prefix))
219    }
220
221    pub fn arc_with_interpreter(
222        path: PathBuf,
223        catalog: ProviderCatalog,
224        env_prefix: impl Into<String>,
225        interpreter: PythonInterpreter,
226    ) -> Arc<Self> {
227        Arc::new(Self::new_with_interpreter(
228            path,
229            catalog,
230            env_prefix,
231            interpreter,
232        ))
233    }
234}
235
236fn python_catalog_fingerprint(catalog: &ProviderCatalog) -> Result<String, serde_json::Error> {
237    let mut catalog = catalog.clone();
238    // The file source owns this location and rewrites it from the immutable
239    // execution snapshot to the operator-facing provider path.
240    catalog.provider.source = None;
241    serde_json::to_vec(&catalog).map(|bytes| sha256_hex(&bytes))
242}
243
244fn sha256_hex(bytes: &[u8]) -> String {
245    use sha2::{Digest, Sha256};
246    Sha256::digest(bytes)
247        .iter()
248        .map(|byte| format!("{byte:02x}"))
249        .collect()
250}
251
252#[async_trait]
253impl Provider for PythonProvider {
254    fn catalog(&self) -> ProviderCatalog {
255        self.catalog.clone()
256    }
257
258    async fn call(&self, call: ProviderCall) -> Result<ProviderOutput, ProviderError> {
259        let tool = self.tool(&call)?;
260        let runtime = PythonRuntime::from_tool(
261            &self.catalog,
262            tool,
263            &call,
264            &self.env_prefix,
265            &self.interpreter,
266        )?;
267        let source = self.path.display().to_string();
268        let env_keys = runtime.env.iter().map(|(key, _)| key.clone()).collect();
269        let request = PythonWorkerRequest::call(&self.path, &call, env_keys);
270        let input = encode_python_request(&request).map_err(|error| {
271            ProviderError::execution(&self.catalog.provider.name, "", error)
272                .with_provider_kind(self.catalog.provider.kind.as_str())
273                .with_source(source.clone())
274                .with_phase("input-serialization")
275        })?;
276
277        if input.len() > runtime.max_input_bytes {
278            return Err(ProviderError::validation(
279                &self.catalog.provider.name,
280                &call.action,
281                "python_input_too_large",
282                format!(
283                    "Python provider input exceeds {} bytes",
284                    runtime.max_input_bytes
285                ),
286            )
287            .with_provider_kind(self.catalog.provider.kind.as_str())
288            .with_source(source)
289            .with_phase("input-validation"));
290        }
291
292        if let Some(supervisor) = &self.supervisor {
293            let value = supervisor
294                .invoke_with_context(
295                    &call.provider,
296                    &call.action,
297                    call.params.clone(),
298                    supervisor::PythonInvocationOptions {
299                        surface: call.surface,
300                        snapshot_id: &call.snapshot_id,
301                        timeout: Duration::from_millis(runtime.timeout_ms),
302                        context: &call.context,
303                    },
304                )
305                .await
306                .map_err(|error| {
307                    ProviderError::new(
308                        error.code(),
309                        &self.catalog.provider.name,
310                        Some(call.action.clone()),
311                        error.to_string(),
312                        "Inspect the Python provider and persistent worker status, then retry.",
313                    )
314                    .with_provider_kind(self.catalog.provider.kind.as_str())
315                    .with_source(source.clone())
316                    .with_phase("persistent-execution")
317                })?;
318            let payload_size = serde_json::to_vec(&value)
319                .map_err(|error| {
320                    ProviderError::execution(&self.catalog.provider.name, &call.action, error)
321                })?
322                .len();
323            if payload_size > runtime.max_output_bytes {
324                return Err(ProviderError::validation(
325                    &self.catalog.provider.name,
326                    &call.action,
327                    "python_output_too_large",
328                    output_exceeded_message("result", runtime.max_output_bytes),
329                ));
330            }
331            return Ok(ProviderOutput::json(value));
332        }
333
334        let started = TokioInstant::now();
335        let sidecar = match run_bounded_sidecar(
336            &runtime.command,
337            &["-c", python_bridge_program()],
338            runtime.env,
339            &input,
340            runtime.timeout_ms,
341            protocol_output_limit(runtime.max_output_bytes),
342        )
343        .await
344        {
345            Ok(sidecar) => sidecar,
346            Err(SidecarError::Timeout) => {
347                return Err(ProviderError::new(
348                    "python_provider_timeout",
349                    &self.catalog.provider.name,
350                    Some(call.action.clone()),
351                    format!("Python provider exceeded {}ms timeout", runtime.timeout_ms),
352                    "Increase tool.limits.timeout_ms or fix the Python provider handler.",
353                )
354                .with_provider_kind(self.catalog.provider.kind.as_str())
355                .with_source(source)
356                .with_phase("execution"));
357            }
358            Err(error) => {
359                return Err(ProviderError::execution(
360                    &self.catalog.provider.name,
361                    call.action.clone(),
362                    error,
363                )
364                .with_provider_kind(self.catalog.provider.kind.as_str())
365                .with_source(source)
366                .with_phase("execution"));
367            }
368        };
369        let output = sidecar.output;
370
371        tracing::debug!(
372            provider = %self.catalog.provider.name,
373            action = %call.action,
374            elapsed_ms = started.elapsed().as_millis(),
375            "Python provider sidecar completed"
376        );
377
378        if sidecar.stdout_exceeded
379            || sidecar.stderr_exceeded
380            || output.stderr.len() > runtime.max_output_bytes
381        {
382            let stream = if sidecar.stdout_exceeded {
383                "stdout"
384            } else {
385                "stderr"
386            };
387            return Err(ProviderError::validation(
388                &self.catalog.provider.name,
389                &call.action,
390                "python_output_too_large",
391                output_exceeded_message(stream, runtime.max_output_bytes),
392            )
393            .with_provider_kind(self.catalog.provider.kind.as_str())
394            .with_source(source)
395            .with_phase("output-validation"));
396        }
397        if !output.status.success() {
398            let stderr = String::from_utf8_lossy(&output.stderr);
399            let code = if stderr.contains("python_provider_unserializable_output") {
400                "python_provider_unserializable_output"
401            } else {
402                "python_provider_failed"
403            };
404            return Err(ProviderError::new(
405                code,
406                &self.catalog.provider.name,
407                Some(call.action),
408                format!("Python provider failed: {}", redact_public(&stderr)),
409                "Fix the Python provider handler and retry.",
410            )
411            .with_provider_kind(self.catalog.provider.kind.as_str())
412            .with_source(source)
413            .with_phase("execution"));
414        }
415
416        let response = decode_python_response(&output.stdout).map_err(|error| {
417            let code = match &error {
418                PythonProtocolError::Json(_) => "python_invalid_json_output",
419                _ => "python_protocol_mismatch",
420            };
421            ProviderError::validation(
422                &self.catalog.provider.name,
423                &call.action,
424                code,
425                error.to_string(),
426            )
427            .with_provider_kind(self.catalog.provider.kind.as_str())
428            .with_source(source.clone())
429            .with_phase("output-validation")
430        })?;
431        validate_python_response(&request, &response).map_err(|error| {
432            ProviderError::validation(
433                &self.catalog.provider.name,
434                &call.action,
435                "python_protocol_mismatch",
436                error.to_string(),
437            )
438            .with_provider_kind(self.catalog.provider.kind.as_str())
439            .with_source(source.clone())
440            .with_phase("output-validation")
441        })?;
442        let value = match response {
443            PythonWorkerResponse::Call { output, .. } => output,
444            PythonWorkerResponse::Catalog { .. } => {
445                return Err(ProviderError::validation(
446                    &self.catalog.provider.name,
447                    &call.action,
448                    "python_protocol_mismatch",
449                    "Python worker returned a catalog response for a call request",
450                )
451                .with_provider_kind(self.catalog.provider.kind.as_str())
452                .with_source(source)
453                .with_phase("output-validation"));
454            }
455        };
456        let payload_size = serde_json::to_vec(&value)
457            .map_err(|error| {
458                ProviderError::execution(&self.catalog.provider.name, &call.action, error)
459                    .with_provider_kind(self.catalog.provider.kind.as_str())
460                    .with_source(source.clone())
461                    .with_phase("output-serialization")
462            })?
463            .len();
464        if payload_size > runtime.max_output_bytes {
465            return Err(ProviderError::validation(
466                &self.catalog.provider.name,
467                &call.action,
468                "python_output_too_large",
469                output_exceeded_message("stdout", runtime.max_output_bytes),
470            )
471            .with_provider_kind(self.catalog.provider.kind.as_str())
472            .with_source(source)
473            .with_phase("output-validation"));
474        }
475        Ok(ProviderOutput::json(value))
476    }
477
478    fn runtime_status(&self) -> Option<Value> {
479        self.supervisor
480            .as_ref()
481            .and_then(|supervisor| serde_json::to_value(supervisor.status()).ok())
482    }
483
484    fn cancel_active(&self) -> bool {
485        self.supervisor
486            .as_ref()
487            .is_some_and(|supervisor| supervisor.cancel_active())
488    }
489
490    async fn reset_quarantine(&self) {
491        if let Some(supervisor) = &self.supervisor {
492            supervisor.reset_quarantine().await;
493        }
494    }
495
496    async fn suspend(&self) {
497        if let Some(supervisor) = &self.supervisor {
498            supervisor.suspend().await;
499        }
500    }
501
502    fn deactivate(&self) {
503        if let Some(supervisor) = &self.supervisor {
504            supervisor.deactivate();
505        }
506    }
507
508    fn activate(&self) {
509        if let Some(supervisor) = &self.supervisor {
510            supervisor.activate();
511        }
512    }
513
514    fn acquire_dispatch(&self) -> bool {
515        self.supervisor
516            .as_ref()
517            .is_none_or(|supervisor| supervisor.acquire_dispatch())
518    }
519
520    fn release_dispatch(&self) {
521        if let Some(supervisor) = &self.supervisor {
522            supervisor.release_dispatch();
523        }
524    }
525
526    async fn retire(&self) {
527        if let Some(supervisor) = &self.supervisor {
528            supervisor.drain_and_shutdown().await;
529        }
530    }
531}
532
533impl PythonProvider {
534    fn tool(&self, call: &ProviderCall) -> Result<&ProviderTool, ProviderError> {
535        self.catalog
536            .tools
537            .iter()
538            .find(|tool| tool.name == call.action)
539            .ok_or_else(|| {
540                ProviderError::validation(
541                    &self.catalog.provider.name,
542                    &call.action,
543                    "unknown_python_action",
544                    format!("Python provider has no action `{}`", call.action),
545                )
546            })
547    }
548}
549
550/// Introspects a `.py` provider file by importing it (in "catalog" mode) in
551/// a bounded sidecar and validating the resulting manifest against the
552/// generic provider-core contract. Callers that layer additional product
553/// policy on top of every provider's catalog (e.g. Soma's reserved
554/// CLI-command / env-prefix checks) apply it to the returned catalog
555/// themselves — see the module docs above.
556pub fn load_python_catalog(path: &Path, env_prefix: &str) -> Result<ProviderCatalog, String> {
557    load_python_catalog_with_interpreter(path, env_prefix, &PythonInterpreter::Ambient)
558}
559
560pub fn load_python_catalog_with_interpreter(
561    path: &Path,
562    env_prefix: &str,
563    interpreter: &PythonInterpreter,
564) -> Result<ProviderCatalog, String> {
565    let runtime = PythonRuntime::for_catalog(env_prefix, interpreter);
566    let request = PythonWorkerRequest::catalog(path);
567    let input = encode_python_request(&request).map_err(|error| error.to_string())?;
568    let output = run_catalog_sidecar(&runtime, &input)?;
569    let response = decode_python_response(&output).map_err(|error| error.to_string())?;
570    validate_python_response(&request, &response).map_err(|error| error.to_string())?;
571    let value = match response {
572        PythonWorkerResponse::Catalog { catalog, .. } => catalog,
573        PythonWorkerResponse::Call { .. } => {
574            return Err("Python worker returned a call response for a catalog request".to_owned());
575        }
576    };
577    let payload_size = serde_json::to_vec(&value)
578        .map_err(|error| error.to_string())?
579        .len();
580    if payload_size > runtime.max_output_bytes {
581        return Err(format!(
582            "Python provider catalog {}",
583            output_exceeded_message("stdout", runtime.max_output_bytes)
584        ));
585    }
586    validate_provider_manifest_value(&value).map_err(|error| error.to_string())
587}
588
589struct PythonRuntime {
590    command: String,
591    env: Vec<(String, String)>,
592    timeout_ms: u64,
593    max_input_bytes: usize,
594    max_output_bytes: usize,
595}
596
597impl PythonRuntime {
598    fn for_catalog(env_prefix: &str, interpreter: &PythonInterpreter) -> Self {
599        let prefix = env_prefix.trim_matches('_').to_ascii_uppercase();
600        let timeout_var = format!("{prefix}_PYTHON_CATALOG_TIMEOUT_MS");
601        let command_var = format!("{prefix}_PYTHON_COMMAND");
602        let timeout_ms = match std::env::var(&timeout_var) {
603            Ok(value) => value.parse().unwrap_or_else(|error| {
604                tracing::warn!(
605                    variable = %timeout_var,
606                    value,
607                    error = %error,
608                    "invalid provider catalog timeout env var; falling back to the default"
609                );
610                DEFAULT_TIMEOUT_MS
611            }),
612            Err(_) => DEFAULT_TIMEOUT_MS,
613        };
614        Self {
615            command: select_python_command(None, std::env::var(command_var).ok(), interpreter),
616            env: Vec::new(),
617            timeout_ms,
618            max_input_bytes: DEFAULT_MAX_INPUT_BYTES,
619            max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES,
620        }
621    }
622
623    fn from_tool(
624        catalog: &ProviderCatalog,
625        tool: &ProviderTool,
626        call: &ProviderCall,
627        env_prefix: &str,
628        interpreter: &PythonInterpreter,
629    ) -> Result<Self, ProviderError> {
630        let provider_meta = catalog.meta.get("python");
631        let tool_meta = tool.meta.get("python");
632        let meta_field = |key: &str| {
633            tool_meta
634                .and_then(|value| value.get(key))
635                .or_else(|| provider_meta.and_then(|value| value.get(key)))
636        };
637        let command = select_python_command(
638            meta_field("command").and_then(Value::as_str),
639            std::env::var(format!(
640                "{}_PYTHON_COMMAND",
641                env_prefix.trim_matches('_').to_ascii_uppercase()
642            ))
643            .ok(),
644            interpreter,
645        );
646        let timeout_ms = tool
647            .limits
648            .as_ref()
649            .and_then(|limits| limits.timeout_ms)
650            .or_else(|| meta_field("timeout_ms").and_then(Value::as_u64))
651            .unwrap_or(DEFAULT_TIMEOUT_MS);
652        let max_input_bytes = tool
653            .limits
654            .as_ref()
655            .and_then(|limits| limits.max_input_bytes)
656            .unwrap_or(DEFAULT_MAX_INPUT_BYTES);
657        let max_output_bytes = tool
658            .limits
659            .as_ref()
660            .and_then(|limits| limits.max_response_bytes)
661            .unwrap_or(DEFAULT_MAX_OUTPUT_BYTES);
662        Ok(Self {
663            command,
664            env: collect_provider_env(
665                &catalog.env,
666                &tool.env,
667                env_prefix,
668                &call.provider,
669                &call.action,
670            )?,
671            timeout_ms,
672            max_input_bytes,
673            max_output_bytes,
674        })
675    }
676}
677
678fn run_catalog_sidecar(runtime: &PythonRuntime, input: &[u8]) -> Result<Vec<u8>, String> {
679    let mut command = StdCommand::new(resolve_sidecar_command(&runtime.command));
680    command
681        .args(["-c", python_bridge_program()])
682        .env_clear()
683        .stdin(StdStdio::piped())
684        .stdout(StdStdio::piped())
685        .stderr(StdStdio::piped());
686    for (key, value) in sidecar_base_env() {
687        command.env(key, value);
688    }
689    let mut child =
690        spawn_retrying_busy_image_blocking(&mut command).map_err(|error| error.to_string())?;
691    let stdout = child
692        .stdout
693        .take()
694        .ok_or_else(|| "Python provider catalog stdout pipe was not captured".to_owned())?;
695    let stderr = child
696        .stderr
697        .take()
698        .ok_or_else(|| "Python provider catalog stderr pipe was not captured".to_owned())?;
699    let capture_limit = protocol_output_limit(runtime.max_output_bytes);
700    let stdout_task = thread::spawn(move || read_bounded_sync(stdout, capture_limit));
701    let stderr_task = thread::spawn(move || read_bounded_sync(stderr, capture_limit));
702
703    if let Some(mut stdin) = child.stdin.take() {
704        stdin.write_all(input).map_err(|error| error.to_string())?;
705    }
706    let deadline = Instant::now() + Duration::from_millis(runtime.timeout_ms);
707    loop {
708        if let Some(status) = child.try_wait().map_err(|error| error.to_string())? {
709            let (stdout, stdout_exceeded) = stdout_task
710                .join()
711                .map_err(|_| "Python provider catalog stdout reader panicked".to_owned())?
712                .map_err(|error| error.to_string())?;
713            let (stderr, stderr_exceeded) = stderr_task
714                .join()
715                .map_err(|_| "Python provider catalog stderr reader panicked".to_owned())?
716                .map_err(|error| error.to_string())?;
717            if stdout_exceeded || stderr_exceeded || stderr.len() > runtime.max_output_bytes {
718                let stream = if stdout_exceeded { "stdout" } else { "stderr" };
719                return Err(format!(
720                    "Python provider catalog {}",
721                    output_exceeded_message(stream, runtime.max_output_bytes)
722                ));
723            }
724            if !status.success() {
725                return Err(format!(
726                    "Python provider catalog failed: {}",
727                    redact_public(&String::from_utf8_lossy(&stderr))
728                ));
729            }
730            return Ok(stdout);
731        }
732        if Instant::now() >= deadline {
733            let _ = child.kill();
734            let _ = child.wait();
735            return Err(format!(
736                "Python provider catalog exceeded {}ms timeout",
737                runtime.timeout_ms
738            ));
739        }
740        std::thread::sleep(Duration::from_millis(10));
741    }
742}
743
744fn read_bounded_sync<R: Read>(
745    mut reader: R,
746    max_output_bytes: usize,
747) -> std::io::Result<(Vec<u8>, bool)> {
748    let mut bytes = Vec::new();
749    let mut exceeded = false;
750    let mut chunk = [0u8; 8192];
751    loop {
752        let read = reader.read(&mut chunk)?;
753        if read == 0 {
754            return Ok((bytes, exceeded));
755        }
756        let remaining = max_output_bytes.saturating_sub(bytes.len());
757        if remaining >= read && !exceeded {
758            bytes.extend_from_slice(&chunk[..read]);
759        } else {
760            exceeded = true;
761            if remaining > 0 {
762                bytes.extend_from_slice(&chunk[..remaining]);
763            }
764        }
765    }
766}
767
768#[cfg(test)]
769#[path = "python_tests.rs"]
770mod tests;