Skip to main content

soma_provider_adapters/python/
lifecycle.rs

1//! Provider-source preparation that composes PEP 723 discovery, deterministic
2//! environment planning, and atomic uv materialization.
3//!
4//! The lifecycle is product-neutral: every path, digest, runtime identity, uv
5//! version, and offline policy is supplied explicitly by the caller.
6
7use std::{
8    fs,
9    path::{Path, PathBuf},
10};
11
12use sha2::{Digest, Sha256};
13use thiserror::Error;
14
15use super::{
16    environment::{
17        PythonEnvironmentError, PythonRuntimeFingerprint, parse_pep723_metadata,
18        plan_python_environment,
19    },
20    materializer::{
21        PreparedPythonEnvironment, PythonEnvironmentMaterializer, PythonEnvironmentRepairError,
22        PythonEnvironmentRepairReport, PythonEnvironmentUpdateError, PythonEnvironmentUpdateReport,
23        PythonEnvironmentUpdateRequest, PythonMaterializationError, PythonMaterializationRequest,
24        SystemUvRunner, UvRunner,
25    },
26};
27
28/// Immutable inputs used to plan and materialize one provider environment.
29#[derive(Debug, Clone)]
30pub struct PythonEnvironmentSpec {
31    pub cache_root: PathBuf,
32    pub runtime: PythonRuntimeFingerprint,
33    pub python_executable: PathBuf,
34    pub sdk_wheel: PathBuf,
35    pub sdk_wheel_sha256: String,
36    pub uv_version: String,
37    pub offline: bool,
38}
39
40/// Composes metadata discovery, planning, and materialization for provider files.
41pub struct PythonEnvironmentLifecycle<R = SystemUvRunner> {
42    spec: PythonEnvironmentSpec,
43    materializer: PythonEnvironmentMaterializer<R>,
44}
45
46impl PythonEnvironmentLifecycle<SystemUvRunner> {
47    pub fn new(uv_program: impl Into<PathBuf>, spec: PythonEnvironmentSpec) -> Self {
48        Self {
49            spec,
50            materializer: PythonEnvironmentMaterializer::new(uv_program),
51        }
52    }
53}
54
55impl<R: UvRunner> PythonEnvironmentLifecycle<R> {
56    pub fn with_runner(
57        uv_program: impl Into<PathBuf>,
58        spec: PythonEnvironmentSpec,
59        runner: R,
60    ) -> Self {
61        Self {
62            spec,
63            materializer: PythonEnvironmentMaterializer::with_runner(uv_program, runner),
64        }
65    }
66
67    /// Prepares the immutable environment for `provider_path`.
68    ///
69    /// Metadata parsing happens directly from source and never imports provider
70    /// code. A complete content-addressed cache is reopened without invoking uv.
71    pub fn prepare_provider(
72        &self,
73        provider_path: &Path,
74    ) -> Result<PreparedPythonEnvironment, PythonEnvironmentLifecycleError> {
75        let source = fs::read_to_string(provider_path).map_err(|source| {
76            PythonEnvironmentLifecycleError::ReadSource {
77                path: provider_path.to_path_buf(),
78                source,
79            }
80        })?;
81        let metadata = parse_pep723_metadata(&source)?;
82        let plan = plan_python_environment(
83            &self.spec.cache_root,
84            metadata.as_ref(),
85            &self.spec.runtime,
86            &self.spec.sdk_wheel,
87            &self.spec.sdk_wheel_sha256,
88            &self.spec.uv_version,
89        )?;
90        self.materializer
91            .prepare(
92                &plan,
93                PythonMaterializationRequest {
94                    metadata: metadata.as_ref(),
95                    python_executable: &self.spec.python_executable,
96                    sdk_wheel: &self.spec.sdk_wheel,
97                    offline: self.spec.offline,
98                },
99            )
100            .map_err(Into::into)
101    }
102
103    /// Resolves dependencies into a new immutable candidate generation.
104    ///
105    /// The current prepared environment is never replaced or activated here.
106    pub fn validate_provider_candidate(
107        &self,
108        provider_path: &Path,
109        candidate: &PreparedPythonEnvironment,
110    ) -> Result<PreparedPythonEnvironment, PythonEnvironmentLifecycleError> {
111        let source = fs::read_to_string(provider_path).map_err(|source| {
112            PythonEnvironmentLifecycleError::ReadSource {
113                path: provider_path.to_path_buf(),
114                source,
115            }
116        })?;
117        let metadata = parse_pep723_metadata(&source)?;
118        let input_plan = plan_python_environment(
119            &self.spec.cache_root,
120            metadata.as_ref(),
121            &self.spec.runtime,
122            &self.spec.sdk_wheel,
123            &self.spec.sdk_wheel_sha256,
124            &self.spec.uv_version,
125        )?;
126        let source_sha256 = normalized_source_sha256(&source);
127        if candidate.provider_source_sha256.as_deref() != Some(source_sha256.as_str()) {
128            return Err(PythonEnvironmentLifecycleError::CandidateSourceMismatch);
129        }
130        if candidate.input_plan_key.as_deref() != Some(input_plan.key.as_str()) {
131            return Err(PythonEnvironmentLifecycleError::CandidatePlanMismatch);
132        }
133        self.materializer
134            .validate_prepared(candidate)
135            .map_err(Into::into)
136    }
137
138    pub fn update_provider(
139        &self,
140        provider_path: &Path,
141    ) -> Result<PythonEnvironmentUpdateReport, PythonEnvironmentLifecycleError> {
142        let source = fs::read_to_string(provider_path).map_err(|source| {
143            PythonEnvironmentLifecycleError::ReadSource {
144                path: provider_path.to_path_buf(),
145                source,
146            }
147        })?;
148        let metadata = parse_pep723_metadata(&source)?;
149        let plan = plan_python_environment(
150            &self.spec.cache_root,
151            metadata.as_ref(),
152            &self.spec.runtime,
153            &self.spec.sdk_wheel,
154            &self.spec.sdk_wheel_sha256,
155            &self.spec.uv_version,
156        )?;
157        let source_sha256 = normalized_source_sha256(&source);
158        self.materializer
159            .update(
160                &plan,
161                PythonEnvironmentUpdateRequest {
162                    materialization: PythonMaterializationRequest {
163                        metadata: metadata.as_ref(),
164                        python_executable: &self.spec.python_executable,
165                        sdk_wheel: &self.spec.sdk_wheel,
166                        offline: self.spec.offline,
167                    },
168                    provider_source_sha256: &source_sha256,
169                },
170            )
171            .map_err(Into::into)
172    }
173
174    /// Verifies or rebuilds the exact immutable environment planned for one provider.
175    ///
176    /// Repair never imports provider code and restores the quarantined cache
177    /// entry when a rebuild fails.
178    pub fn repair_provider(
179        &self,
180        provider_path: &Path,
181    ) -> Result<PythonEnvironmentRepairReport, PythonEnvironmentLifecycleError> {
182        let source = fs::read_to_string(provider_path).map_err(|source| {
183            PythonEnvironmentLifecycleError::ReadSource {
184                path: provider_path.to_path_buf(),
185                source,
186            }
187        })?;
188        let metadata = parse_pep723_metadata(&source)?;
189        let plan = plan_python_environment(
190            &self.spec.cache_root,
191            metadata.as_ref(),
192            &self.spec.runtime,
193            &self.spec.sdk_wheel,
194            &self.spec.sdk_wheel_sha256,
195            &self.spec.uv_version,
196        )?;
197        self.materializer
198            .repair(
199                &plan,
200                PythonMaterializationRequest {
201                    metadata: metadata.as_ref(),
202                    python_executable: &self.spec.python_executable,
203                    sdk_wheel: &self.spec.sdk_wheel,
204                    offline: self.spec.offline,
205                },
206            )
207            .map_err(Into::into)
208    }
209}
210
211fn normalized_source_sha256(source: &str) -> String {
212    let normalized = source.replace("\r\n", "\n").replace('\r', "\n");
213    let digest = Sha256::digest(normalized.as_bytes());
214    digest.iter().map(|byte| format!("{byte:02x}")).collect()
215}
216
217#[derive(Debug, Error)]
218pub enum PythonEnvironmentLifecycleError {
219    #[error("failed to read Python provider source {}: {source}", path.display())]
220    ReadSource {
221        path: PathBuf,
222        #[source]
223        source: std::io::Error,
224    },
225    #[error(transparent)]
226    Environment(#[from] PythonEnvironmentError),
227    #[error(transparent)]
228    Materialization(#[from] PythonMaterializationError),
229    #[error(transparent)]
230    Update(#[from] PythonEnvironmentUpdateError),
231    #[error(transparent)]
232    Repair(#[from] PythonEnvironmentRepairError),
233    #[error("Python candidate source digest does not match the provider file")]
234    CandidateSourceMismatch,
235    #[error("Python candidate input plan does not match the provider file")]
236    CandidatePlanMismatch,
237}
238
239#[cfg(test)]
240#[path = "lifecycle_tests.rs"]
241mod tests;