Skip to main content

soma_provider_adapters/python/
environment.rs

1//! Non-executing PEP 723 discovery and deterministic Python environment plans.
2//!
3//! This module is intentionally side-effect free. Parsing never imports provider
4//! code, and planning never creates a virtual environment or contacts a package
5//! index. A later cache manager consumes the immutable plan.
6
7use std::{
8    collections::BTreeMap,
9    path::{Path, PathBuf},
10    str::FromStr,
11};
12
13use pep440_rs::{Version, VersionSpecifiers};
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16use thiserror::Error;
17
18const PEP_723_START: &str = "# /// script";
19const PEP_723_END: &str = "# ///";
20const MAX_METADATA_BYTES: usize = 64 * 1024;
21/// Cache-policy schema version used by immutable Python environment plans.
22pub const ENVIRONMENT_PLAN_VERSION: u32 = 2;
23
24#[derive(Debug, Clone, Default, PartialEq, Serialize)]
25pub struct Pep723Metadata {
26    pub requires_python: Option<String>,
27    pub dependencies: Vec<String>,
28    pub uv: Option<toml::Value>,
29}
30
31#[derive(Debug, Deserialize)]
32struct RawPep723Metadata {
33    #[serde(rename = "requires-python")]
34    requires_python: Option<String>,
35    #[serde(default)]
36    dependencies: Vec<String>,
37    #[serde(default)]
38    tool: BTreeMap<String, toml::Value>,
39}
40
41/// Immutable identity of the selected Python runtime and wheel target.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct PythonRuntimeFingerprint {
44    pub implementation: String,
45    pub version: String,
46    pub platform: String,
47    pub wheel_platform_tag: String,
48}
49
50impl PythonRuntimeFingerprint {
51    pub fn new(
52        implementation: impl Into<String>,
53        version: impl Into<String>,
54        platform: impl Into<String>,
55        wheel_platform_tag: impl Into<String>,
56    ) -> Result<Self, PythonEnvironmentError> {
57        let implementation =
58            normalize_runtime_component("implementation", implementation.into(), |byte| {
59                byte.is_ascii_alphanumeric() || byte == b'_'
60            })?;
61        let version = required_component("version", version.into())?;
62        let version = Version::from_str(&version).map_err(|error| {
63            PythonEnvironmentError::InvalidRuntimeVersion {
64                version,
65                message: error.to_string(),
66            }
67        })?;
68        let platform = normalize_runtime_component("platform", platform.into(), |byte| {
69            byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')
70        })?;
71        let wheel_platform_tag =
72            normalize_runtime_component("wheel_platform_tag", wheel_platform_tag.into(), |byte| {
73                byte.is_ascii_alphanumeric() || byte == b'_'
74            })?;
75
76        Ok(Self {
77            implementation: normalize_implementation(&implementation),
78            version: version.to_string(),
79            platform,
80            wheel_platform_tag,
81        })
82    }
83}
84
85/// One expanded compatibility tag selected from an SDK wheel filename.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct PythonWheelTag {
88    pub python: String,
89    pub abi: String,
90    pub platform: String,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct PythonEnvironmentPlan {
95    pub key: String,
96    pub directory: PathBuf,
97    pub plan_version: u32,
98    pub dependency_count: usize,
99    pub runtime: PythonRuntimeFingerprint,
100    pub sdk_wheel_tag: PythonWheelTag,
101    pub sdk_wheel_sha256: String,
102    pub uv_version: String,
103}
104
105#[derive(Debug, Error, PartialEq, Eq)]
106pub enum PythonEnvironmentError {
107    #[error("PEP 723 script metadata exceeds {MAX_METADATA_BYTES} bytes")]
108    MetadataTooLarge,
109    #[error("multiple PEP 723 script metadata blocks are not allowed")]
110    MultipleScriptBlocks,
111    #[error("PEP 723 script metadata block is not terminated")]
112    UnterminatedScriptBlock,
113    #[error("PEP 723 line {line} must remain a Python comment")]
114    NonCommentLine { line: usize },
115    #[error("invalid PEP 723 script metadata: {0}")]
116    InvalidToml(String),
117    #[error("invalid PEP 723 {field}: {message}")]
118    InvalidMetadata {
119        field: &'static str,
120        message: String,
121    },
122    #[error("Python environment fingerprint {field} must not be empty")]
123    EmptyFingerprint { field: &'static str },
124    #[error("Python environment fingerprint {field} contains unsupported characters")]
125    InvalidFingerprintComponent { field: &'static str },
126    #[error("invalid Python runtime version `{version}`: {message}")]
127    InvalidRuntimeVersion { version: String, message: String },
128    #[error("invalid PEP 723 requires-python `{specifier}`: {message}")]
129    InvalidRequiresPython { specifier: String, message: String },
130    #[error("Python {version} does not satisfy PEP 723 requires-python `{requires_python}`")]
131    IncompatiblePython {
132        version: String,
133        requires_python: String,
134    },
135    #[error("invalid SDK wheel filename `{filename}`: {message}")]
136    InvalidSdkWheelFilename { filename: String, message: String },
137    #[error(
138        "SDK wheel `{filename}` is incompatible with {implementation} {version} and platform tag `{wheel_platform_tag}`"
139    )]
140    IncompatibleSdkWheel {
141        filename: String,
142        implementation: String,
143        version: String,
144        wheel_platform_tag: String,
145    },
146    #[error("SDK wheel SHA-256 must contain exactly 64 hexadecimal characters")]
147    InvalidSdkDigest,
148}
149
150/// Parse the PEP 723 script block without importing or evaluating the source.
151pub fn parse_pep723_metadata(
152    source: &str,
153) -> Result<Option<Pep723Metadata>, PythonEnvironmentError> {
154    let mut body = None;
155    let mut lines = source.lines().enumerate();
156
157    while let Some((index, line)) = lines.next() {
158        if line != PEP_723_START {
159            continue;
160        }
161        if body.is_some() {
162            return Err(PythonEnvironmentError::MultipleScriptBlocks);
163        }
164
165        let mut block = String::new();
166        let mut terminated = false;
167        for (body_index, body_line) in lines.by_ref() {
168            if body_line == PEP_723_END {
169                terminated = true;
170                break;
171            }
172            if body_line == PEP_723_START {
173                return Err(PythonEnvironmentError::MultipleScriptBlocks);
174            }
175            let comment =
176                body_line
177                    .strip_prefix('#')
178                    .ok_or(PythonEnvironmentError::NonCommentLine {
179                        line: body_index + 1,
180                    })?;
181            let content = comment.strip_prefix(' ').unwrap_or(comment);
182            if block.len().saturating_add(content.len()).saturating_add(1) > MAX_METADATA_BYTES {
183                return Err(PythonEnvironmentError::MetadataTooLarge);
184            }
185            block.push_str(content);
186            block.push('\n');
187        }
188        if !terminated {
189            return Err(PythonEnvironmentError::UnterminatedScriptBlock);
190        }
191        body = Some((index + 1, block));
192    }
193
194    body.map(|(_, block)| normalize_metadata(&block))
195        .transpose()
196}
197
198/// Produce an immutable cache plan. This function performs no filesystem I/O.
199pub fn plan_python_environment(
200    cache_root: &Path,
201    metadata: Option<&Pep723Metadata>,
202    runtime: &PythonRuntimeFingerprint,
203    sdk_wheel: &Path,
204    sdk_wheel_sha256: &str,
205    uv_version: &str,
206) -> Result<PythonEnvironmentPlan, PythonEnvironmentError> {
207    let sdk_wheel_sha256 = sdk_wheel_sha256.trim().to_ascii_lowercase();
208    if sdk_wheel_sha256.len() != 64
209        || !sdk_wheel_sha256
210            .bytes()
211            .all(|byte| byte.is_ascii_hexdigit())
212    {
213        return Err(PythonEnvironmentError::InvalidSdkDigest);
214    }
215    let uv_version = required_component("uv_version", uv_version.to_owned())?;
216    let metadata = metadata.cloned().unwrap_or_default();
217    validate_requires_python(&metadata, runtime)?;
218    let sdk_wheel_tag = select_compatible_sdk_wheel_tag(sdk_wheel, runtime)?;
219    let fingerprint = EnvironmentFingerprint {
220        plan_version: ENVIRONMENT_PLAN_VERSION,
221        metadata: &metadata,
222        runtime,
223        sdk_wheel_tag: &sdk_wheel_tag,
224        sdk_wheel_sha256: &sdk_wheel_sha256,
225        uv_version: &uv_version,
226    };
227    let canonical = serde_json::to_vec(&fingerprint)
228        .expect("environment fingerprint contains only serializable values");
229    let key = sha256_hex(&canonical);
230    let directory = cache_root
231        .join("python")
232        .join(format!("v{ENVIRONMENT_PLAN_VERSION}"))
233        .join(&key);
234
235    Ok(PythonEnvironmentPlan {
236        key,
237        directory,
238        plan_version: ENVIRONMENT_PLAN_VERSION,
239        dependency_count: metadata.dependencies.len(),
240        runtime: runtime.clone(),
241        sdk_wheel_tag,
242        sdk_wheel_sha256,
243        uv_version,
244    })
245}
246
247fn validate_requires_python(
248    metadata: &Pep723Metadata,
249    runtime: &PythonRuntimeFingerprint,
250) -> Result<(), PythonEnvironmentError> {
251    let Some(requires_python) = &metadata.requires_python else {
252        return Ok(());
253    };
254    let specifiers = VersionSpecifiers::from_str(requires_python).map_err(|error| {
255        PythonEnvironmentError::InvalidRequiresPython {
256            specifier: requires_python.clone(),
257            message: error.to_string(),
258        }
259    })?;
260    let version = Version::from_str(&runtime.version).map_err(|error| {
261        PythonEnvironmentError::InvalidRuntimeVersion {
262            version: runtime.version.clone(),
263            message: error.to_string(),
264        }
265    })?;
266    if specifiers.contains(&version) {
267        Ok(())
268    } else {
269        Err(PythonEnvironmentError::IncompatiblePython {
270            version: runtime.version.clone(),
271            requires_python: requires_python.clone(),
272        })
273    }
274}
275
276fn select_compatible_sdk_wheel_tag(
277    sdk_wheel: &Path,
278    runtime: &PythonRuntimeFingerprint,
279) -> Result<PythonWheelTag, PythonEnvironmentError> {
280    let filename = sdk_wheel
281        .file_name()
282        .and_then(|name| name.to_str())
283        .ok_or_else(|| PythonEnvironmentError::InvalidSdkWheelFilename {
284            filename: sdk_wheel.display().to_string(),
285            message: "wheel filename must be valid UTF-8".to_owned(),
286        })?;
287    let WheelTagFields {
288        python: python_tags,
289        abi: abi_tags,
290        platform: platform_tags,
291    } = parse_wheel_filename_tags(filename)?;
292    let runtime_version = Version::from_str(&runtime.version).map_err(|error| {
293        PythonEnvironmentError::InvalidRuntimeVersion {
294            version: runtime.version.clone(),
295            message: error.to_string(),
296        }
297    })?;
298
299    for python in python_tags {
300        for abi in &abi_tags {
301            for platform in &platform_tags {
302                if platform == &runtime.wheel_platform_tag
303                    && sdk_python_abi_compatible(&python, abi, runtime, &runtime_version)
304                {
305                    return Ok(PythonWheelTag {
306                        python,
307                        abi: abi.clone(),
308                        platform: platform.clone(),
309                    });
310                }
311            }
312        }
313    }
314
315    Err(PythonEnvironmentError::IncompatibleSdkWheel {
316        filename: filename.to_owned(),
317        implementation: runtime.implementation.clone(),
318        version: runtime.version.clone(),
319        wheel_platform_tag: runtime.wheel_platform_tag.clone(),
320    })
321}
322
323struct WheelTagFields {
324    python: Vec<String>,
325    abi: Vec<String>,
326    platform: Vec<String>,
327}
328
329fn parse_wheel_filename_tags(filename: &str) -> Result<WheelTagFields, PythonEnvironmentError> {
330    let stem = filename.strip_suffix(".whl").ok_or_else(|| {
331        PythonEnvironmentError::InvalidSdkWheelFilename {
332            filename: filename.to_owned(),
333            message: "wheel filename must end in .whl".to_owned(),
334        }
335    })?;
336    let mut parts = stem.rsplitn(4, '-');
337    let platform = parts.next();
338    let abi = parts.next();
339    let python = parts.next();
340    let distribution_and_version = parts.next();
341    if distribution_and_version.is_none_or(|prefix| !prefix.contains('-')) {
342        return Err(PythonEnvironmentError::InvalidSdkWheelFilename {
343            filename: filename.to_owned(),
344            message: "expected distribution-version-python-abi-platform tags".to_owned(),
345        });
346    }
347
348    Ok(WheelTagFields {
349        python: split_wheel_tag_field(filename, "python", python.unwrap_or_default())?,
350        abi: split_wheel_tag_field(filename, "ABI", abi.unwrap_or_default())?,
351        platform: split_wheel_tag_field(filename, "platform", platform.unwrap_or_default())?,
352    })
353}
354
355fn split_wheel_tag_field(
356    filename: &str,
357    field: &'static str,
358    value: &str,
359) -> Result<Vec<String>, PythonEnvironmentError> {
360    let tags = value
361        .split('.')
362        .map(str::trim)
363        .filter(|tag| !tag.is_empty())
364        .collect::<Vec<_>>();
365    if tags.is_empty()
366        || tags.iter().any(|tag| {
367            !tag.bytes()
368                .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
369        })
370    {
371        return Err(PythonEnvironmentError::InvalidSdkWheelFilename {
372            filename: filename.to_owned(),
373            message: format!("invalid {field} tag field"),
374        });
375    }
376    Ok(tags.into_iter().map(str::to_owned).collect())
377}
378
379fn sdk_python_abi_compatible(
380    python_tag: &str,
381    abi_tag: &str,
382    runtime: &PythonRuntimeFingerprint,
383    runtime_version: &Version,
384) -> bool {
385    if runtime.implementation != "cpython" || abi_tag != "abi3" {
386        return false;
387    }
388    let Some((required_major, required_minor)) = compact_python_tag(python_tag, "cp") else {
389        return false;
390    };
391    let release = runtime_version.release();
392    let runtime_major = release.first().copied().unwrap_or_default();
393    let runtime_minor = release.get(1).copied().unwrap_or_default();
394    runtime_major == required_major && runtime_minor >= required_minor
395}
396
397fn compact_python_tag(tag: &str, prefix: &str) -> Option<(u64, u64)> {
398    let digits = tag.strip_prefix(prefix)?;
399    if digits.len() < 2 || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
400        return None;
401    }
402    let (major, minor) = digits.split_at(1);
403    Some((major.parse().ok()?, minor.parse().ok()?))
404}
405
406fn sha256_hex(bytes: &[u8]) -> String {
407    const HEX: &[u8; 16] = b"0123456789abcdef";
408    let digest = Sha256::digest(bytes);
409    let mut encoded = String::with_capacity(digest.len() * 2);
410    for byte in digest {
411        encoded.push(HEX[(byte >> 4) as usize] as char);
412        encoded.push(HEX[(byte & 0x0f) as usize] as char);
413    }
414    encoded
415}
416
417#[derive(Serialize)]
418struct EnvironmentFingerprint<'a> {
419    plan_version: u32,
420    metadata: &'a Pep723Metadata,
421    runtime: &'a PythonRuntimeFingerprint,
422    sdk_wheel_tag: &'a PythonWheelTag,
423    sdk_wheel_sha256: &'a str,
424    uv_version: &'a str,
425}
426
427fn normalize_metadata(block: &str) -> Result<Pep723Metadata, PythonEnvironmentError> {
428    let raw: RawPep723Metadata = toml::from_str(block)
429        .map_err(|error| PythonEnvironmentError::InvalidToml(error.to_string()))?;
430    let requires_python = raw
431        .requires_python
432        .map(normalize_requires_python)
433        .transpose()?;
434    let mut dependencies = raw
435        .dependencies
436        .into_iter()
437        .map(|value| normalize_nonempty("dependency", value))
438        .collect::<Result<Vec<_>, _>>()?;
439    dependencies.sort();
440    dependencies.dedup();
441
442    let uv = raw.tool.get("uv").cloned();
443    if uv.as_ref().is_some_and(|value| !value.is_table()) {
444        return Err(PythonEnvironmentError::InvalidMetadata {
445            field: "tool.uv",
446            message: "must be a table".to_owned(),
447        });
448    }
449
450    Ok(Pep723Metadata {
451        requires_python,
452        dependencies,
453        uv,
454    })
455}
456
457fn normalize_requires_python(value: String) -> Result<String, PythonEnvironmentError> {
458    let value = normalize_nonempty("requires-python", value)?;
459    VersionSpecifiers::from_str(&value)
460        .map(|specifiers| specifiers.to_string())
461        .map_err(|error| PythonEnvironmentError::InvalidRequiresPython {
462            specifier: value,
463            message: error.to_string(),
464        })
465}
466
467fn normalize_nonempty(
468    field: &'static str,
469    value: String,
470) -> Result<String, PythonEnvironmentError> {
471    let value = value.trim();
472    if value.is_empty() {
473        return Err(PythonEnvironmentError::InvalidMetadata {
474            field,
475            message: "must not be empty".to_owned(),
476        });
477    }
478    Ok(value.to_owned())
479}
480
481fn required_component(
482    field: &'static str,
483    value: String,
484) -> Result<String, PythonEnvironmentError> {
485    let value = value.trim();
486    if value.is_empty() {
487        return Err(PythonEnvironmentError::EmptyFingerprint { field });
488    }
489    Ok(value.to_owned())
490}
491
492fn normalize_runtime_component(
493    field: &'static str,
494    value: String,
495    allowed: impl Fn(u8) -> bool,
496) -> Result<String, PythonEnvironmentError> {
497    let value = required_component(field, value)?.to_ascii_lowercase();
498    if !value.bytes().all(allowed) {
499        return Err(PythonEnvironmentError::InvalidFingerprintComponent { field });
500    }
501    Ok(value)
502}
503
504fn normalize_implementation(value: &str) -> String {
505    match value {
506        "cp" | "cpython" => "cpython".to_owned(),
507        "pp" | "pypy" => "pypy".to_owned(),
508        other => other.to_owned(),
509    }
510}
511
512#[cfg(test)]
513#[path = "environment_tests.rs"]
514mod tests;