soma_provider_adapters/python/
lifecycle.rs1use 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#[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
40pub 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 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 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 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;