1use std::{
8 fs, io,
9 path::{Path, PathBuf},
10 time::UNIX_EPOCH,
11};
12
13use serde::Serialize;
14use sha2::{Digest, Sha256};
15use thiserror::Error;
16
17use super::{
18 environment::{PythonRuntimeFingerprint, PythonWheelTag},
19 materializer::{READY_FILE, READY_SCHEMA_VERSION, ReadyMarker},
20};
21
22#[path = "cache_prune.rs"]
23mod prune;
24pub use prune::{
25 PythonEnvironmentPruneCandidate, PythonEnvironmentPruneError, PythonEnvironmentPruneOutcome,
26 PythonEnvironmentPrunePlan, PythonEnvironmentPrunePolicy, PythonEnvironmentPruneReport,
27};
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
30#[serde(rename_all = "snake_case")]
31pub enum PythonEnvironmentCacheState {
32 Ready,
33 Incomplete,
34 Invalid,
35 Staging,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
39pub struct PythonEnvironmentCacheMetadata {
40 pub schema_version: u32,
41 pub environment_key: String,
42 pub plan_version: u32,
43 pub dependency_count: usize,
44 pub runtime: PythonRuntimeFingerprint,
45 pub sdk_wheel_tag: PythonWheelTag,
46 pub sdk_wheel_sha256: String,
47 pub uv_version: String,
48 pub lock_sha256: String,
49 pub provider_source_sha256: Option<String>,
50 pub input_plan_key: Option<String>,
51}
52
53impl From<ReadyMarker> for PythonEnvironmentCacheMetadata {
54 fn from(marker: ReadyMarker) -> Self {
55 Self {
56 schema_version: marker.schema_version,
57 environment_key: marker.environment_key,
58 plan_version: marker.plan_version,
59 dependency_count: marker.dependency_count,
60 runtime: marker.runtime,
61 sdk_wheel_tag: marker.sdk_wheel_tag,
62 sdk_wheel_sha256: marker.sdk_wheel_sha256,
63 uv_version: marker.uv_version,
64 lock_sha256: marker.lock_sha256,
65 provider_source_sha256: marker.provider_source_sha256,
66 input_plan_key: marker.input_plan_key,
67 }
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
72pub struct PythonEnvironmentCacheEntry {
73 pub directory: PathBuf,
74 pub key: Option<String>,
75 pub plan_directory_version: Option<u32>,
76 pub state: PythonEnvironmentCacheState,
77 pub size_bytes: u64,
78 pub file_count: u64,
79 pub modified_unix_seconds: Option<u64>,
80 pub metadata: Option<PythonEnvironmentCacheMetadata>,
81 pub issue: Option<String>,
82}
83
84#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
85pub struct PythonEnvironmentCacheSummary {
86 pub ready: usize,
87 pub incomplete: usize,
88 pub invalid: usize,
89 pub staging: usize,
90 pub total_size_bytes: u64,
91 pub total_file_count: u64,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
95pub struct PythonEnvironmentCacheInventory {
96 pub root: PathBuf,
97 pub entries: Vec<PythonEnvironmentCacheEntry>,
98 pub summary: PythonEnvironmentCacheSummary,
99}
100
101#[derive(Debug, Error)]
102pub enum PythonEnvironmentCacheError {
103 #[error("Python environment cache I/O failed at {}: {source}", path.display())]
104 Io {
105 path: PathBuf,
106 #[source]
107 source: io::Error,
108 },
109 #[error("Python environment cache root is not a real directory: {}", path.display())]
110 UnsafeRoot { path: PathBuf },
111}
112
113#[derive(Debug, Clone)]
114pub struct PythonEnvironmentCache {
115 root: PathBuf,
116}
117
118impl PythonEnvironmentCache {
119 pub fn new(cache_root: impl Into<PathBuf>) -> Self {
120 Self {
121 root: cache_root.into().join("python"),
122 }
123 }
124
125 pub fn root(&self) -> &Path {
126 &self.root
127 }
128
129 pub fn inventory(
130 &self,
131 ) -> Result<PythonEnvironmentCacheInventory, PythonEnvironmentCacheError> {
132 let root_metadata = match fs::symlink_metadata(&self.root) {
133 Ok(metadata) => metadata,
134 Err(error) if error.kind() == io::ErrorKind::NotFound => {
135 return Ok(PythonEnvironmentCacheInventory {
136 root: self.root.clone(),
137 entries: Vec::new(),
138 summary: PythonEnvironmentCacheSummary::default(),
139 });
140 }
141 Err(source) => {
142 return Err(PythonEnvironmentCacheError::Io {
143 path: self.root.clone(),
144 source,
145 });
146 }
147 };
148 if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() {
149 return Err(PythonEnvironmentCacheError::UnsafeRoot {
150 path: self.root.clone(),
151 });
152 }
153
154 let mut version_directories = read_paths(&self.root)?;
155 version_directories.sort();
156 let mut entries = Vec::new();
157 for version_directory in version_directories {
158 let version_metadata = symlink_metadata(&version_directory)?;
159 if version_metadata.file_type().is_symlink() || !version_metadata.is_dir() {
160 entries.push(classify_non_directory(
161 &version_directory,
162 None,
163 version_metadata,
164 ));
165 continue;
166 }
167 let plan_directory_version = version_directory
168 .file_name()
169 .and_then(|name| name.to_str())
170 .and_then(|name| name.strip_prefix('v'))
171 .and_then(|version| version.parse::<u32>().ok());
172 let mut children = read_paths(&version_directory)?;
173 children.sort();
174 for child in children {
175 entries.push(inspect_entry(&child, plan_directory_version)?);
176 }
177 }
178 entries.sort_by(|left, right| left.directory.cmp(&right.directory));
179 let summary = summarize(&entries);
180 Ok(PythonEnvironmentCacheInventory {
181 root: self.root.clone(),
182 entries,
183 summary,
184 })
185 }
186}
187
188fn inspect_entry(
189 directory: &Path,
190 plan_directory_version: Option<u32>,
191) -> Result<PythonEnvironmentCacheEntry, PythonEnvironmentCacheError> {
192 let metadata = symlink_metadata(directory)?;
193 if metadata.file_type().is_symlink() || !metadata.is_dir() {
194 return Ok(classify_non_directory(
195 directory,
196 plan_directory_version,
197 metadata,
198 ));
199 }
200 let stats = tree_stats(directory)?;
201 let name = directory
202 .file_name()
203 .and_then(|name| name.to_str())
204 .unwrap_or_default();
205 if name.starts_with('.')
206 && (name.contains(".tmp-")
207 || name.contains(".prune-")
208 || name.contains(".repair-")
209 || name.contains(".update-"))
210 {
211 let issue = if name.contains(".prune-") {
212 "temporary prune quarantine"
213 } else if name.contains(".repair-") {
214 "temporary repair quarantine"
215 } else if name.contains(".update-") {
216 "temporary update candidate"
217 } else {
218 "temporary materialization directory"
219 };
220 return Ok(entry(
221 directory,
222 None,
223 plan_directory_version,
224 PythonEnvironmentCacheState::Staging,
225 stats,
226 None,
227 Some(issue.to_owned()),
228 ));
229 }
230
231 let key = Some(name.to_owned());
232 let marker_path = directory.join(READY_FILE);
233 let marker_metadata = match fs::symlink_metadata(&marker_path) {
234 Ok(metadata) => metadata,
235 Err(error) if error.kind() == io::ErrorKind::NotFound => {
236 return Ok(entry(
237 directory,
238 key,
239 plan_directory_version,
240 PythonEnvironmentCacheState::Incomplete,
241 stats,
242 None,
243 Some("readiness marker is missing".to_owned()),
244 ));
245 }
246 Err(source) => {
247 return Err(PythonEnvironmentCacheError::Io {
248 path: marker_path,
249 source,
250 });
251 }
252 };
253 if marker_metadata.file_type().is_symlink() || !marker_metadata.is_file() {
254 return Ok(entry(
255 directory,
256 key,
257 plan_directory_version,
258 PythonEnvironmentCacheState::Invalid,
259 stats,
260 None,
261 Some("readiness marker is not a regular file".to_owned()),
262 ));
263 }
264 let marker_bytes =
265 fs::read(&marker_path).map_err(|source| PythonEnvironmentCacheError::Io {
266 path: marker_path,
267 source,
268 })?;
269 let marker: ReadyMarker = match serde_json::from_slice(&marker_bytes) {
270 Ok(marker) => marker,
271 Err(error) => {
272 return Ok(entry(
273 directory,
274 key,
275 plan_directory_version,
276 PythonEnvironmentCacheState::Invalid,
277 stats,
278 None,
279 Some(format!("readiness marker is invalid: {error}")),
280 ));
281 }
282 };
283 let cache_metadata = PythonEnvironmentCacheMetadata::from(marker);
284 let issue = validate_ready_entry(directory, name, plan_directory_version, &cache_metadata)?;
285 let state = if issue.is_some() {
286 PythonEnvironmentCacheState::Invalid
287 } else {
288 PythonEnvironmentCacheState::Ready
289 };
290 Ok(entry(
291 directory,
292 key,
293 plan_directory_version,
294 state,
295 stats,
296 Some(cache_metadata),
297 issue,
298 ))
299}
300
301fn validate_ready_entry(
302 directory: &Path,
303 directory_key: &str,
304 plan_directory_version: Option<u32>,
305 metadata: &PythonEnvironmentCacheMetadata,
306) -> Result<Option<String>, PythonEnvironmentCacheError> {
307 if metadata.schema_version != READY_SCHEMA_VERSION {
308 return Ok(Some(format!(
309 "unsupported readiness schema version {}; expected {READY_SCHEMA_VERSION}",
310 metadata.schema_version
311 )));
312 }
313 if metadata.environment_key != directory_key {
314 return Ok(Some(
315 "readiness marker key does not match directory name".to_owned(),
316 ));
317 }
318 if plan_directory_version != Some(metadata.plan_version) {
319 return Ok(Some(
320 "readiness marker plan version does not match version directory".to_owned(),
321 ));
322 }
323 let python = environment_python_path(directory);
324 let python_metadata = match fs::metadata(&python) {
325 Ok(metadata) => metadata,
326 Err(error) if error.kind() == io::ErrorKind::NotFound => {
327 return Ok(Some("prepared Python interpreter is missing".to_owned()));
328 }
329 Err(source) => {
330 return Err(PythonEnvironmentCacheError::Io {
331 path: python,
332 source,
333 });
334 }
335 };
336 if !python_metadata.is_file() {
337 return Ok(Some(
338 "prepared Python interpreter is not a regular file".to_owned(),
339 ));
340 }
341 let lockfile = directory.join("uv.lock");
342 let lock_metadata = match fs::symlink_metadata(&lockfile) {
343 Ok(metadata) => metadata,
344 Err(error) if error.kind() == io::ErrorKind::NotFound => {
345 return Ok(Some("uv.lock is missing".to_owned()));
346 }
347 Err(source) => {
348 return Err(PythonEnvironmentCacheError::Io {
349 path: lockfile,
350 source,
351 });
352 }
353 };
354 if lock_metadata.file_type().is_symlink() || !lock_metadata.is_file() {
355 return Ok(Some("uv.lock is not a regular file".to_owned()));
356 }
357 let lock = fs::read(&lockfile).map_err(|source| PythonEnvironmentCacheError::Io {
358 path: lockfile,
359 source,
360 })?;
361 if sha256_hex(&lock) != metadata.lock_sha256 {
362 return Ok(Some(
363 "uv.lock digest does not match readiness marker".to_owned(),
364 ));
365 }
366 Ok(None)
367}
368
369fn classify_non_directory(
370 path: &Path,
371 plan_directory_version: Option<u32>,
372 metadata: fs::Metadata,
373) -> PythonEnvironmentCacheEntry {
374 let file_type = if metadata.file_type().is_symlink() {
375 "symbolic link"
376 } else {
377 "non-directory entry"
378 };
379 entry(
380 path,
381 path.file_name()
382 .and_then(|name| name.to_str())
383 .map(str::to_owned),
384 plan_directory_version,
385 PythonEnvironmentCacheState::Invalid,
386 EntryStats::from_metadata(&metadata),
387 None,
388 Some(format!("cache entry is a {file_type}")),
389 )
390}
391
392fn entry(
393 directory: &Path,
394 key: Option<String>,
395 plan_directory_version: Option<u32>,
396 state: PythonEnvironmentCacheState,
397 stats: EntryStats,
398 metadata: Option<PythonEnvironmentCacheMetadata>,
399 issue: Option<String>,
400) -> PythonEnvironmentCacheEntry {
401 PythonEnvironmentCacheEntry {
402 directory: directory.to_path_buf(),
403 key,
404 plan_directory_version,
405 state,
406 size_bytes: stats.size_bytes,
407 file_count: stats.file_count,
408 modified_unix_seconds: stats.modified_unix_seconds,
409 metadata,
410 issue,
411 }
412}
413
414fn summarize(entries: &[PythonEnvironmentCacheEntry]) -> PythonEnvironmentCacheSummary {
415 let mut summary = PythonEnvironmentCacheSummary::default();
416 for entry in entries {
417 match entry.state {
418 PythonEnvironmentCacheState::Ready => summary.ready += 1,
419 PythonEnvironmentCacheState::Incomplete => summary.incomplete += 1,
420 PythonEnvironmentCacheState::Invalid => summary.invalid += 1,
421 PythonEnvironmentCacheState::Staging => summary.staging += 1,
422 }
423 summary.total_size_bytes = summary.total_size_bytes.saturating_add(entry.size_bytes);
424 summary.total_file_count = summary.total_file_count.saturating_add(entry.file_count);
425 }
426 summary
427}
428
429#[derive(Debug, Clone, Copy, Default)]
430struct EntryStats {
431 size_bytes: u64,
432 file_count: u64,
433 modified_unix_seconds: Option<u64>,
434}
435
436impl EntryStats {
437 fn from_metadata(metadata: &fs::Metadata) -> Self {
438 Self {
439 size_bytes: metadata.len(),
440 file_count: u64::from(metadata.is_file()),
441 modified_unix_seconds: modified_unix_seconds(metadata),
442 }
443 }
444
445 fn merge(&mut self, other: Self) {
446 self.size_bytes = self.size_bytes.saturating_add(other.size_bytes);
447 self.file_count = self.file_count.saturating_add(other.file_count);
448 self.modified_unix_seconds = self.modified_unix_seconds.max(other.modified_unix_seconds);
449 }
450}
451
452fn tree_stats(path: &Path) -> Result<EntryStats, PythonEnvironmentCacheError> {
453 let mut stats = EntryStats::default();
454 let mut pending = vec![path.to_path_buf()];
455 while let Some(current) = pending.pop() {
456 let metadata = symlink_metadata(¤t)?;
457 stats.merge(EntryStats::from_metadata(&metadata));
458 if metadata.file_type().is_symlink() || !metadata.is_dir() {
459 continue;
460 }
461 pending.extend(read_paths(¤t)?);
462 }
463 Ok(stats)
464}
465
466fn read_paths(path: &Path) -> Result<Vec<PathBuf>, PythonEnvironmentCacheError> {
467 fs::read_dir(path)
468 .map_err(|source| PythonEnvironmentCacheError::Io {
469 path: path.to_path_buf(),
470 source,
471 })?
472 .map(|entry| {
473 entry
474 .map(|entry| entry.path())
475 .map_err(|source| PythonEnvironmentCacheError::Io {
476 path: path.to_path_buf(),
477 source,
478 })
479 })
480 .collect()
481}
482
483fn symlink_metadata(path: &Path) -> Result<fs::Metadata, PythonEnvironmentCacheError> {
484 fs::symlink_metadata(path).map_err(|source| PythonEnvironmentCacheError::Io {
485 path: path.to_path_buf(),
486 source,
487 })
488}
489
490fn modified_unix_seconds(metadata: &fs::Metadata) -> Option<u64> {
491 metadata
492 .modified()
493 .ok()?
494 .duration_since(UNIX_EPOCH)
495 .ok()
496 .map(|duration| duration.as_secs())
497}
498
499fn environment_python_path(directory: &Path) -> PathBuf {
500 let unix = directory.join(".venv/bin/python");
501 if fs::symlink_metadata(&unix).is_ok() {
502 unix
503 } else {
504 directory.join(".venv/Scripts/python.exe")
505 }
506}
507
508fn sha256_hex(bytes: &[u8]) -> String {
509 const HEX: &[u8; 16] = b"0123456789abcdef";
510 let digest = Sha256::digest(bytes);
511 let mut encoded = String::with_capacity(digest.len() * 2);
512 for byte in digest {
513 encoded.push(HEX[(byte >> 4) as usize] as char);
514 encoded.push(HEX[(byte & 0x0f) as usize] as char);
515 }
516 encoded
517}
518
519#[cfg(test)]
520#[path = "cache_tests.rs"]
521mod tests;