1use 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;
21pub 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#[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#[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
150pub 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
198pub 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;