Skip to main content

uv_test/
lib.rs

1// The `unreachable_pub` is to silence false positives in RustRover.
2#![allow(dead_code, unreachable_pub)]
3
4use std::borrow::BorrowMut;
5use std::ffi::OsString;
6use std::io::Write as _;
7use std::iter::Iterator;
8use std::path::{Path, PathBuf};
9use std::process::{Command, ExitStatus, Output, Stdio};
10use std::str::FromStr;
11use std::{env, io};
12use uv_preview::Preview;
13use uv_python::downloads::ManagedPythonDownloadList;
14
15use assert_cmd::assert::{Assert, OutputAssertExt};
16use assert_fs::assert::PathAssert;
17use assert_fs::fixture::{
18    ChildPath, FileWriteStr, PathChild, PathCopy, PathCreateDir, SymlinkToFile,
19};
20use base64::{Engine, prelude::BASE64_STANDARD as base64};
21use futures::StreamExt;
22use indoc::{formatdoc, indoc};
23use itertools::Itertools;
24use predicates::prelude::predicate;
25use regex::Regex;
26use tokio::io::AsyncWriteExt;
27
28use uv_cache::{Cache, CacheBucket};
29use uv_fs::Simplified;
30use uv_python::managed::ManagedPythonInstallations;
31use uv_python::{
32    EnvironmentPreference, PythonInstallation, PythonPreference, PythonRequest, PythonVersion,
33};
34use uv_static::EnvVars;
35
36// Shared test timestamp for deterministic package availability and relative times.
37static TEST_TIMESTAMP: &str = "2024-03-25T00:00:00Z";
38
39pub const PACKSE_VERSION: &str = "0.3.59";
40pub const DEFAULT_PYTHON_VERSION: &str = "3.12";
41
42// The expected latest patch version for each Python minor version.
43pub const LATEST_PYTHON_3_15: &str = "3.15.0b1";
44pub const LATEST_PYTHON_3_14: &str = "3.14.5";
45pub const LATEST_PYTHON_3_13: &str = "3.13.13";
46pub const LATEST_PYTHON_3_12: &str = "3.12.13";
47pub const LATEST_PYTHON_3_11: &str = "3.11.15";
48pub const LATEST_PYTHON_3_10: &str = "3.10.20";
49
50/// Using a find links url allows using `--index-url` instead of `--extra-index-url` in tests
51/// to prevent dependency confusion attacks against our test suite.
52pub fn build_vendor_links_url() -> String {
53    env::var(EnvVars::UV_TEST_PACKSE_INDEX)
54        .map(|url| format!("{}/vendor/", url.trim_end_matches('/')))
55        .ok()
56        .unwrap_or(format!(
57            "https://astral-sh.github.io/packse/{PACKSE_VERSION}/vendor/"
58        ))
59}
60
61pub fn packse_index_url() -> String {
62    env::var(EnvVars::UV_TEST_PACKSE_INDEX)
63        .map(|url| format!("{}/simple-html/", url.trim_end_matches('/')))
64        .ok()
65        .unwrap_or(format!(
66            "https://astral-sh.github.io/packse/{PACKSE_VERSION}/simple-html/"
67        ))
68}
69
70/// Create a new [`TestContext`] with the given Python version.
71///
72/// Creates a virtual environment for the test.
73///
74/// This macro captures the uv binary path at compile time using `env!("CARGO_BIN_EXE_uv")`,
75/// which is only available in the test crate.
76#[macro_export]
77macro_rules! test_context {
78    ($python_version:expr) => {
79        $crate::TestContext::new_with_bin(
80            $python_version,
81            std::path::PathBuf::from(env!("CARGO_BIN_EXE_uv")),
82        )
83    };
84}
85
86/// Create a new [`TestContext`] with zero or more Python versions.
87///
88/// Unlike [`test_context!`], this does not create a virtual environment.
89///
90/// This macro captures the uv binary path at compile time using `env!("CARGO_BIN_EXE_uv")`,
91/// which is only available in the test crate.
92#[macro_export]
93macro_rules! test_context_with_versions {
94    ($python_versions:expr) => {
95        $crate::TestContext::new_with_versions_and_bin(
96            $python_versions,
97            std::path::PathBuf::from(env!("CARGO_BIN_EXE_uv")),
98        )
99    };
100}
101
102/// Return the path to the uv binary.
103///
104/// This macro captures the uv binary path at compile time using `env!("CARGO_BIN_EXE_uv")`,
105/// which is only available in the test crate.
106#[macro_export]
107macro_rules! get_bin {
108    () => {
109        std::path::PathBuf::from(env!("CARGO_BIN_EXE_uv"))
110    };
111}
112
113#[doc(hidden)] // Macro and test context only, don't use directly.
114pub const INSTA_FILTERS: &[(&str, &str)] = &[
115    (r"--cache-dir [^\s]+", "--cache-dir [CACHE_DIR]"),
116    // Operation times
117    (r"(\s|\()(\d+m )?(\d+\.)?\d+(ms|s)", "$1[TIME]"),
118    // File sizes
119    (r"(\s|\()(\d+\.)?\d+([KM]i)?B", "$1[SIZE]"),
120    // Timestamps
121    (r"tv_sec: \d+", "tv_sec: [TIME]"),
122    (r"tv_nsec: \d+", "tv_nsec: [TIME]"),
123    // Rewrite Windows output to Unix output
124    (r"\\([\w\d]|\.)", "/$1"),
125    (r"uv\.exe", "uv"),
126    // uv version display
127    (
128        r"uv(-.*)? \d+\.\d+\.\d+(-(alpha|beta|rc)\.\d+)?(\+\d+)?( \([^)]*\))?",
129        r"uv [VERSION] ([COMMIT] DATE)",
130    ),
131    // Trim end-of-line whitespaces, to allow removing them on save.
132    (r"([^\s])[ \t]+(\r?\n)", "$1$2"),
133    // Filter SSL certificate loading debug messages (environment-dependent)
134    (r"DEBUG Loaded \d+ certificate\(s\) from [^\n]+\n", ""),
135];
136
137/// Create a context for tests which simplifies shared behavior across tests.
138///
139/// * Set the current directory to a temporary directory (`temp_dir`).
140/// * Set the cache dir to a different temporary directory (`cache_dir`).
141/// * Set a shared test timestamp so snapshots don't change after a new release.
142/// * Set the venv to a fresh `.venv` in `temp_dir`
143pub struct TestContext {
144    pub root: ChildPath,
145    pub temp_dir: ChildPath,
146    pub cache_dir: ChildPath,
147    pub python_dir: ChildPath,
148    pub home_dir: ChildPath,
149    pub user_config_dir: ChildPath,
150    pub bin_dir: ChildPath,
151    pub venv: ChildPath,
152    pub workspace_root: PathBuf,
153
154    /// The Python version used for the virtual environment, if any.
155    pub python_version: Option<PythonVersion>,
156
157    /// All the Python versions available during this test context.
158    pub python_versions: Vec<(PythonVersion, PathBuf)>,
159
160    /// Path to the uv binary.
161    uv_bin: PathBuf,
162
163    /// Standard filters for this test context.
164    filters: Vec<(String, String)>,
165
166    /// Extra environment variables to apply to all commands.
167    extra_env: Vec<(OsString, OsString)>,
168
169    #[allow(dead_code)]
170    _root: tempfile::TempDir,
171
172    /// Extra temporary directories whose lifetimes are tied to this context (e.g., directories
173    /// on alternate filesystems created by [`TestContext::with_cache_on_cow_fs`]).
174    #[allow(dead_code)]
175    _extra_tempdirs: Vec<tempfile::TempDir>,
176}
177
178impl TestContext {
179    /// Create a new test context with a virtual environment and explicit uv binary path.
180    ///
181    /// This is called by the `test_context!` macro.
182    pub fn new_with_bin(python_version: &str, uv_bin: PathBuf) -> Self {
183        let new = Self::new_with_versions_and_bin(&[python_version], uv_bin);
184        new.create_venv();
185        new
186    }
187
188    /// Set the "exclude newer" timestamp for all commands in this context.
189    #[must_use]
190    pub fn with_exclude_newer(mut self, exclude_newer: &str) -> Self {
191        self.extra_env
192            .push((EnvVars::UV_EXCLUDE_NEWER.into(), exclude_newer.into()));
193        self
194    }
195
196    /// Set the "http timeout" for all commands in this context.
197    #[must_use]
198    pub fn with_http_timeout(mut self, http_timeout: &str) -> Self {
199        self.extra_env
200            .push((EnvVars::UV_HTTP_TIMEOUT.into(), http_timeout.into()));
201        self
202    }
203
204    /// Set the "concurrent installs" for all commands in this context.
205    #[must_use]
206    pub fn with_concurrent_installs(mut self, concurrent_installs: &str) -> Self {
207        self.extra_env.push((
208            EnvVars::UV_CONCURRENT_INSTALLS.into(),
209            concurrent_installs.into(),
210        ));
211        self
212    }
213
214    /// Add extra standard filtering for messages like "Resolved 10 packages" which
215    /// can differ between platforms.
216    ///
217    /// In some cases, these counts are helpful for the snapshot and should not be filtered.
218    #[must_use]
219    pub fn with_filtered_counts(mut self) -> Self {
220        for verb in &[
221            "Resolved",
222            "Prepared",
223            "Installed",
224            "Uninstalled",
225            "Checked",
226        ] {
227            self.filters.push((
228                format!("{verb} \\d+ packages?"),
229                format!("{verb} [N] packages"),
230            ));
231        }
232        self.filters.push((
233            "Removed \\d+ files?".to_string(),
234            "Removed [N] files".to_string(),
235        ));
236        self
237    }
238
239    /// Add extra filtering for cache size output
240    #[must_use]
241    pub fn with_filtered_cache_size(mut self) -> Self {
242        // Filter raw byte counts (numbers on their own line)
243        self.filters
244            .push((r"(?m)^\d+\n".to_string(), "[SIZE]\n".to_string()));
245        // Filter human-readable sizes (e.g., "384.2 KiB")
246        self.filters.push((
247            r"(?m)^\d+(\.\d+)? [KMGT]i?B\n".to_string(),
248            "[SIZE]\n".to_string(),
249        ));
250        self
251    }
252
253    /// Add extra standard filtering for Windows-compatible missing file errors.
254    #[must_use]
255    pub fn with_filtered_missing_file_error(mut self) -> Self {
256        // The exact message string depends on the system language, so we remove it.
257        // We want to only remove the phrase after `Caused by:`
258        self.filters.push((
259            r"[^:\n]* \(os error 2\)".to_string(),
260            " [OS ERROR 2]".to_string(),
261        ));
262        // Replace the Windows "The system cannot find the path specified. (os error 3)"
263        // with the Unix "No such file or directory (os error 2)"
264        // and mask the language-dependent message.
265        self.filters.push((
266            r"[^:\n]* \(os error 3\)".to_string(),
267            " [OS ERROR 2]".to_string(),
268        ));
269        self
270    }
271
272    /// Add extra standard filtering for executable suffixes on the current platform e.g.
273    /// drops `.exe` on Windows.
274    #[must_use]
275    pub fn with_filtered_exe_suffix(mut self) -> Self {
276        self.filters
277            .push((regex::escape(env::consts::EXE_SUFFIX), String::new()));
278        self
279    }
280
281    /// Add extra standard filtering for Python interpreter sources
282    #[must_use]
283    pub fn with_filtered_python_sources(mut self) -> Self {
284        self.filters.push((
285            "virtual environments, managed installations, or search path".to_string(),
286            "[PYTHON SOURCES]".to_string(),
287        ));
288        self.filters.push((
289            "virtual environments, managed installations, search path, or registry".to_string(),
290            "[PYTHON SOURCES]".to_string(),
291        ));
292        self.filters.push((
293            "virtual environments, search path, or registry".to_string(),
294            "[PYTHON SOURCES]".to_string(),
295        ));
296        self.filters.push((
297            "virtual environments, registry, or search path".to_string(),
298            "[PYTHON SOURCES]".to_string(),
299        ));
300        self.filters.push((
301            "virtual environments or search path".to_string(),
302            "[PYTHON SOURCES]".to_string(),
303        ));
304        self.filters.push((
305            "managed installations or search path".to_string(),
306            "[PYTHON SOURCES]".to_string(),
307        ));
308        self.filters.push((
309            "managed installations, search path, or registry".to_string(),
310            "[PYTHON SOURCES]".to_string(),
311        ));
312        self.filters.push((
313            "search path or registry".to_string(),
314            "[PYTHON SOURCES]".to_string(),
315        ));
316        self.filters.push((
317            "registry or search path".to_string(),
318            "[PYTHON SOURCES]".to_string(),
319        ));
320        self.filters
321            .push(("search path".to_string(), "[PYTHON SOURCES]".to_string()));
322        self
323    }
324
325    /// Add extra standard filtering for Python executable names, e.g., stripping version number
326    /// and `.exe` suffixes.
327    #[must_use]
328    pub fn with_filtered_python_names(mut self) -> Self {
329        for name in ["python", "pypy"] {
330            // Note we strip version numbers from the executable names because, e.g., on Windows
331            // `python.exe` is the equivalent to a Unix `python3.12`.`
332            let suffix = if cfg!(windows) {
333                // On Windows, we'll require a `.exe` suffix for disambiguation
334                // We'll also strip version numbers if present, which is not common for `python.exe`
335                // but can occur for, e.g., `pypy3.12.exe`
336                let exe_suffix = regex::escape(env::consts::EXE_SUFFIX);
337                format!(r"(\d\.\d+|\d)?{exe_suffix}")
338            } else {
339                // On Unix, we'll strip version numbers
340                if name == "python" {
341                    // We can't require them in this case since `/python` is common
342                    r"(\d\.\d+|\d)?(t|d|td)?".to_string()
343                } else {
344                    // However, for other names we'll require them to avoid over-matching
345                    r"(\d\.\d+|\d)(t|d|td)?".to_string()
346                }
347            };
348
349            self.filters.push((
350                // We use a leading path separator to help disambiguate cases where the name is not
351                // used in a path.
352                format!(r"[\\/]{name}{suffix}"),
353                format!("/[{}]", name.to_uppercase()),
354            ));
355        }
356
357        self
358    }
359
360    /// Add extra standard filtering for venv executable directories on the current platform e.g.
361    /// `Scripts` on Windows and `bin` on Unix.
362    #[must_use]
363    pub fn with_filtered_virtualenv_bin(mut self) -> Self {
364        self.filters.push((
365            format!(
366                r"[\\/]{}[\\/]",
367                venv_bin_path(PathBuf::new()).to_string_lossy()
368            ),
369            "/[BIN]/".to_string(),
370        ));
371        self.filters.push((
372            format!(r"[\\/]{}", venv_bin_path(PathBuf::new()).to_string_lossy()),
373            "/[BIN]".to_string(),
374        ));
375        self
376    }
377
378    /// Add extra standard filtering for Python installation `bin/` directories, which are not
379    /// present on Windows but are on Unix. See [`TestContext::with_filtered_virtualenv_bin`] for
380    /// the virtual environment equivalent.
381    #[must_use]
382    pub fn with_filtered_python_install_bin(mut self) -> Self {
383        // We don't want to eagerly match paths that aren't actually Python executables, so we
384        // do our best to detect that case
385        let suffix = if cfg!(windows) {
386            let exe_suffix = regex::escape(env::consts::EXE_SUFFIX);
387            // On Windows, we usually don't have a version attached but we might, e.g., for pypy3.12
388            format!(r"(\d\.\d+|\d)?{exe_suffix}")
389        } else {
390            // On Unix, we'll require a version to be attached to avoid over-matching
391            r"\d\.\d+|\d".to_string()
392        };
393
394        if cfg!(unix) {
395            self.filters.push((
396                format!(r"[\\/]bin/python({suffix})"),
397                "/[INSTALL-BIN]/python$1".to_string(),
398            ));
399            self.filters.push((
400                format!(r"[\\/]bin/pypy({suffix})"),
401                "/[INSTALL-BIN]/pypy$1".to_string(),
402            ));
403        } else {
404            self.filters.push((
405                format!(r"[\\/]python({suffix})"),
406                "/[INSTALL-BIN]/python$1".to_string(),
407            ));
408            self.filters.push((
409                format!(r"[\\/]pypy({suffix})"),
410                "/[INSTALL-BIN]/pypy$1".to_string(),
411            ));
412        }
413        self
414    }
415
416    /// Filtering for various keys in a `pyvenv.cfg` file that will vary
417    /// depending on the specific machine used:
418    /// - `home = foo/bar/baz/python3.X.X/bin`
419    /// - `uv = X.Y.Z`
420    /// - `extends-environment = <path/to/parent/venv>`
421    #[must_use]
422    pub fn with_pyvenv_cfg_filters(mut self) -> Self {
423        let added_filters = [
424            (r"home = .+".to_string(), "home = [PYTHON_HOME]".to_string()),
425            (
426                r"uv = \d+\.\d+\.\d+(-(alpha|beta|rc)\.\d+)?(\+\d+)?".to_string(),
427                "uv = [UV_VERSION]".to_string(),
428            ),
429            (
430                r"extends-environment = .+".to_string(),
431                "extends-environment = [PARENT_VENV]".to_string(),
432            ),
433        ];
434        for filter in added_filters {
435            self.filters.insert(0, filter);
436        }
437        self
438    }
439
440    /// Add extra filtering for ` -> <PATH>` symlink display for Python versions in the test
441    /// context, e.g., for use in `uv python list`.
442    #[must_use]
443    pub fn with_filtered_python_symlinks(mut self) -> Self {
444        for (version, executable) in &self.python_versions {
445            if fs_err::symlink_metadata(executable).unwrap().is_symlink() {
446                self.filters.extend(
447                    Self::path_patterns(executable.read_link().unwrap())
448                        .into_iter()
449                        .map(|pattern| (format! {" -> {pattern}"}, String::new())),
450                );
451            }
452            // Drop links that are byproducts of the test context too
453            self.filters.push((
454                regex::escape(&format!(" -> [PYTHON-{version}]")),
455                String::new(),
456            ));
457        }
458        self
459    }
460
461    /// Add extra standard filtering for a given path.
462    #[must_use]
463    pub fn with_filtered_path(mut self, path: &Path, name: &str) -> Self {
464        // Note this is sloppy, ideally we wouldn't push to the front of the `Vec` but we need
465        // this to come in front of other filters or we can transform the path (e.g., with `[TMP]`)
466        // before we reach this filter.
467        for pattern in Self::path_patterns(path)
468            .into_iter()
469            .map(|pattern| (pattern, format!("[{name}]/")))
470        {
471            self.filters.insert(0, pattern);
472        }
473        self
474    }
475
476    /// Adds a filter that specifically ignores the link mode warning.
477    ///
478    /// This occurs in some cases and can be used on an ad hoc basis to squash
479    /// the warning in the snapshots. This is useful because the warning does
480    /// not consistently appear. It is dependent on the environment. (For
481    /// example, sometimes it's dependent on whether `/tmp` and `~/.local` live
482    /// on the same file system.)
483    #[inline]
484    #[must_use]
485    pub fn with_filtered_link_mode_warning(mut self) -> Self {
486        let pattern = "warning: Failed to hardlink files; .*\n.*\n.*\n";
487        self.filters.push((pattern.to_string(), String::new()));
488        self
489    }
490
491    /// Adds a filter for platform-specific errors when a file is not executable.
492    #[inline]
493    #[must_use]
494    pub fn with_filtered_not_executable(mut self) -> Self {
495        let pattern = if cfg!(unix) {
496            r"Permission denied \(os error 13\)"
497        } else {
498            r"\%1 is not a valid Win32 application. \(os error 193\)"
499        };
500        self.filters
501            .push((pattern.to_string(), "[PERMISSION DENIED]".to_string()));
502        self
503    }
504
505    /// Adds a filter that ignores platform information in a Python installation key.
506    #[must_use]
507    pub fn with_filtered_python_keys(mut self) -> Self {
508        // Filter platform keys
509        let platform_re = r"(?x)
510  (                         # We capture the group before the platform
511    (?:cpython|pypy|graalpy)# Python implementation
512    -
513    \d+\.\d+                # Major and minor version
514    (?:                     # The patch version is handled separately
515      \.
516      (?:
517        \[X\]               # A previously filtered patch version [X]
518        |                   # OR
519        \[LATEST\]          # A previously filtered latest patch version [LATEST]
520        |                   # OR
521        \d+                 # An actual patch version
522      )
523    )?                      # (we allow the patch version to be missing entirely, e.g., in a request)
524    (?:(?:a|b|rc)[0-9]+)?   # Pre-release version component, e.g., `a6` or `rc2`
525    (?:[td])?               # A short variant, such as `t` (for freethreaded) or `d` (for debug)
526    (?:(\+[a-z]+)+)?        # A long variant, such as `+freethreaded` or `+freethreaded+debug`
527  )
528  -
529  [a-z0-9]+                 # Operating system (e.g., 'macos')
530  -
531  [a-z0-9_]+                # Architecture (e.g., 'aarch64')
532  -
533  [a-z]+                    # Libc (e.g., 'none')
534";
535        self.filters
536            .push((platform_re.to_string(), "$1-[PLATFORM]".to_string()));
537        self
538    }
539
540    /// Adds a filter that replaces the latest Python patch versions with `[LATEST]` placeholder.
541    #[must_use]
542    pub fn with_filtered_latest_python_versions(mut self) -> Self {
543        // Filter the latest patch versions with [LATEST] placeholder
544        // The order matters - we want to match the full version first
545        for (minor, patch) in [
546            ("3.15", LATEST_PYTHON_3_15.strip_prefix("3.15.").unwrap()),
547            ("3.14", LATEST_PYTHON_3_14.strip_prefix("3.14.").unwrap()),
548            ("3.13", LATEST_PYTHON_3_13.strip_prefix("3.13.").unwrap()),
549            ("3.12", LATEST_PYTHON_3_12.strip_prefix("3.12.").unwrap()),
550            ("3.11", LATEST_PYTHON_3_11.strip_prefix("3.11.").unwrap()),
551            ("3.10", LATEST_PYTHON_3_10.strip_prefix("3.10.").unwrap()),
552        ] {
553            // Match the full version in various contexts (cpython-X.Y.Z, Python X.Y.Z, etc.)
554            let pattern = format!(r"(\b){minor}\.{patch}(\b)");
555            let replacement = format!("${{1}}{minor}.[LATEST]${{2}}");
556            self.filters.push((pattern, replacement));
557        }
558        self
559    }
560
561    /// Add a filter that ignores temporary directory in path.
562    #[must_use]
563    pub fn with_filtered_windows_temp_dir(mut self) -> Self {
564        let pattern = regex::escape(
565            &self
566                .temp_dir
567                .simplified_display()
568                .to_string()
569                .replace('/', "\\"),
570        );
571        self.filters.push((pattern, "[TEMP_DIR]".to_string()));
572        self
573    }
574
575    /// Add a filter for (bytecode) compilation file counts
576    #[must_use]
577    pub fn with_filtered_compiled_file_count(mut self) -> Self {
578        self.filters.push((
579            r"compiled \d+ files".to_string(),
580            "compiled [COUNT] files".to_string(),
581        ));
582        self
583    }
584
585    /// Add a (not context aware) filter for the current uv version `v<major>.<minor>.<patch>`
586    #[must_use]
587    pub fn with_filtered_current_version(mut self) -> Self {
588        self.filters.push((
589            regex::escape(&format!("v{}", env!("CARGO_PKG_VERSION"))),
590            "v[CURRENT_VERSION]".to_string(),
591        ));
592        self
593    }
594
595    /// Adds filters for non-deterministic `CycloneDX` data
596    #[must_use]
597    pub fn with_cyclonedx_filters(mut self) -> Self {
598        self.filters.push((
599            r"urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}".to_string(),
600            "[SERIAL_NUMBER]".to_string(),
601        ));
602        self.filters.push((
603            r#""timestamp": "[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]+Z""#
604                .to_string(),
605            r#""timestamp": "[TIMESTAMP]""#.to_string(),
606        ));
607        self.filters.push((
608            r#""name": "uv",\s*"version": "\d+\.\d+\.\d+(-(alpha|beta|rc)\.\d+)?(\+\d+)?""#
609                .to_string(),
610            r#""name": "uv",
611        "version": "[VERSION]""#
612                .to_string(),
613        ));
614        self
615    }
616
617    /// Add a filter that collapses duplicate whitespace.
618    #[must_use]
619    pub fn with_collapsed_whitespace(mut self) -> Self {
620        self.filters.push((r"[ \t]+".to_string(), " ".to_string()));
621        self
622    }
623
624    /// Use a shared global cache for Python downloads.
625    #[must_use]
626    pub fn with_python_download_cache(mut self) -> Self {
627        self.extra_env.push((
628            EnvVars::UV_PYTHON_CACHE_DIR.into(),
629            // Respect `UV_PYTHON_CACHE_DIR` if set, or use the default cache directory
630            env::var_os(EnvVars::UV_PYTHON_CACHE_DIR).unwrap_or_else(|| {
631                uv_cache::Cache::from_settings(false, None)
632                    .unwrap()
633                    .bucket(CacheBucket::Python)
634                    .into()
635            }),
636        ));
637        self
638    }
639
640    #[must_use]
641    pub fn with_empty_python_install_mirror(mut self) -> Self {
642        self.extra_env.push((
643            EnvVars::UV_PYTHON_INSTALL_MIRROR.into(),
644            String::new().into(),
645        ));
646        self
647    }
648
649    /// Add extra directories and configuration for managed Python installations.
650    #[must_use]
651    pub fn with_managed_python_dirs(mut self) -> Self {
652        let managed = self.temp_dir.join("managed");
653
654        self.extra_env.push((
655            EnvVars::UV_PYTHON_BIN_DIR.into(),
656            self.bin_dir.as_os_str().to_owned(),
657        ));
658        self.extra_env
659            .push((EnvVars::UV_PYTHON_INSTALL_DIR.into(), managed.into()));
660        self.extra_env
661            .push((EnvVars::UV_PYTHON_DOWNLOADS.into(), "automatic".into()));
662
663        self
664    }
665
666    #[must_use]
667    pub fn with_versions_as_managed(mut self, versions: &[&str]) -> Self {
668        self.extra_env.push((
669            EnvVars::UV_INTERNAL__TEST_PYTHON_MANAGED.into(),
670            versions.iter().join(" ").into(),
671        ));
672
673        self
674    }
675
676    /// Add a custom filter to the `TestContext`.
677    #[must_use]
678    pub fn with_filter(mut self, filter: (impl Into<String>, impl Into<String>)) -> Self {
679        self.filters.push((filter.0.into(), filter.1.into()));
680        self
681    }
682
683    // Unsets the git credential helper using temp home gitconfig
684    #[must_use]
685    pub fn with_unset_git_credential_helper(self) -> Self {
686        let git_config = self.home_dir.child(".gitconfig");
687        git_config
688            .write_str(indoc! {r"
689                [credential]
690                    helper =
691            "})
692            .expect("Failed to unset git credential helper");
693
694        self
695    }
696
697    /// Clear filters on `TestContext`.
698    #[must_use]
699    pub fn clear_filters(mut self) -> Self {
700        self.filters.clear();
701        self
702    }
703
704    /// Use a cache directory on the filesystem specified by
705    /// [`EnvVars::UV_INTERNAL__TEST_COW_FS`].
706    ///
707    /// Returns `Ok(None)` if the environment variable is not set.
708    pub fn with_cache_on_cow_fs(self) -> anyhow::Result<Option<Self>> {
709        let Some(dir) = env::var(EnvVars::UV_INTERNAL__TEST_COW_FS).ok() else {
710            return Ok(None);
711        };
712        self.with_cache_on_fs(&dir, "COW_FS").map(Some)
713    }
714
715    /// Use a cache directory on the filesystem specified by
716    /// [`EnvVars::UV_INTERNAL__TEST_ALT_FS`].
717    ///
718    /// Returns `Ok(None)` if the environment variable is not set.
719    pub fn with_cache_on_alt_fs(self) -> anyhow::Result<Option<Self>> {
720        let Some(dir) = env::var(EnvVars::UV_INTERNAL__TEST_ALT_FS).ok() else {
721            return Ok(None);
722        };
723        self.with_cache_on_fs(&dir, "ALT_FS").map(Some)
724    }
725
726    /// Use a cache directory on the filesystem specified by
727    /// [`EnvVars::UV_INTERNAL__TEST_LOWLINKS_FS`].
728    ///
729    /// Returns `Ok(None)` if the environment variable is not set.
730    pub fn with_cache_on_lowlinks_fs(self) -> anyhow::Result<Option<Self>> {
731        let Some(dir) = env::var(EnvVars::UV_INTERNAL__TEST_LOWLINKS_FS).ok() else {
732            return Ok(None);
733        };
734        self.with_cache_on_fs(&dir, "LOWLINKS_FS").map(Some)
735    }
736
737    /// Use a cache directory on the filesystem specified by
738    /// [`EnvVars::UV_INTERNAL__TEST_NOCOW_FS`].
739    ///
740    /// Returns `Ok(None)` if the environment variable is not set.
741    pub fn with_cache_on_nocow_fs(self) -> anyhow::Result<Option<Self>> {
742        let Some(dir) = env::var(EnvVars::UV_INTERNAL__TEST_NOCOW_FS).ok() else {
743            return Ok(None);
744        };
745        self.with_cache_on_fs(&dir, "NOCOW_FS").map(Some)
746    }
747
748    /// Use a working directory on the filesystem specified by
749    /// [`EnvVars::UV_INTERNAL__TEST_COW_FS`].
750    ///
751    /// Returns `Ok(None)` if the environment variable is not set.
752    ///
753    /// Note a virtual environment is not created automatically.
754    pub fn with_working_dir_on_cow_fs(self) -> anyhow::Result<Option<Self>> {
755        let Some(dir) = env::var(EnvVars::UV_INTERNAL__TEST_COW_FS).ok() else {
756            return Ok(None);
757        };
758        self.with_working_dir_on_fs(&dir, "COW_FS").map(Some)
759    }
760
761    /// Use a working directory on the filesystem specified by
762    /// [`EnvVars::UV_INTERNAL__TEST_NOCOW_FS`].
763    ///
764    /// Returns `Ok(None)` if the environment variable is not set.
765    ///
766    /// Note a virtual environment is not created automatically.
767    pub fn with_working_dir_on_nocow_fs(self) -> anyhow::Result<Option<Self>> {
768        let Some(dir) = env::var(EnvVars::UV_INTERNAL__TEST_NOCOW_FS).ok() else {
769            return Ok(None);
770        };
771        self.with_working_dir_on_fs(&dir, "NOCOW_FS").map(Some)
772    }
773
774    fn with_cache_on_fs(mut self, dir: &str, name: &str) -> anyhow::Result<Self> {
775        fs_err::create_dir_all(dir)?;
776        let tmp = tempfile::TempDir::new_in(dir)?;
777        self.cache_dir = ChildPath::new(tmp.path()).child("cache");
778        fs_err::create_dir_all(&self.cache_dir)?;
779        let replacement = format!("[{name}]/[CACHE_DIR]/");
780        self.filters.extend(
781            Self::path_patterns(&self.cache_dir)
782                .into_iter()
783                .map(|pattern| (pattern, replacement.clone())),
784        );
785        self._extra_tempdirs.push(tmp);
786        Ok(self)
787    }
788
789    fn with_working_dir_on_fs(mut self, dir: &str, name: &str) -> anyhow::Result<Self> {
790        fs_err::create_dir_all(dir)?;
791        let tmp = tempfile::TempDir::new_in(dir)?;
792        self.temp_dir = ChildPath::new(tmp.path()).child("temp");
793        fs_err::create_dir_all(&self.temp_dir)?;
794        // Place the venv inside temp_dir (matching the default TestContext layout)
795        // so that `context.venv()` creates it at the same path that `VIRTUAL_ENV` points to.
796        let canonical_temp_dir = self.temp_dir.canonicalize()?;
797        self.venv = ChildPath::new(canonical_temp_dir.join(".venv"));
798        let temp_replacement = format!("[{name}]/[TEMP_DIR]/");
799        self.filters.extend(
800            Self::path_patterns(&self.temp_dir)
801                .into_iter()
802                .map(|pattern| (pattern, temp_replacement.clone())),
803        );
804        let venv_replacement = format!("[{name}]/[VENV]/");
805        self.filters.extend(
806            Self::path_patterns(&self.venv)
807                .into_iter()
808                .map(|pattern| (pattern, venv_replacement.clone())),
809        );
810        self._extra_tempdirs.push(tmp);
811        Ok(self)
812    }
813
814    /// Default to the canonicalized path to the temp directory. We need to do this because on
815    /// macOS (and Windows on GitHub Actions) the standard temp dir is a symlink. (On macOS, the
816    /// temporary directory is, like `/var/...`, which resolves to `/private/var/...`.)
817    ///
818    /// It turns out that, at least on macOS, if we pass a symlink as `current_dir`, it gets
819    /// _immediately_ resolved (such that if you call `current_dir` in the running `Command`, it
820    /// returns resolved symlink). This breaks some snapshot tests, since we _don't_ want to
821    /// resolve symlinks for user-provided paths.
822    pub fn test_bucket_dir() -> PathBuf {
823        std::env::temp_dir()
824            .simple_canonicalize()
825            .expect("failed to canonicalize temp dir")
826            .join("uv")
827            .join("tests")
828    }
829
830    /// Create a new test context with multiple Python versions and explicit uv binary path.
831    ///
832    /// Does not create a virtual environment by default, but the first Python version
833    /// can be used to create a virtual environment with [`TestContext::create_venv`].
834    ///
835    /// This is called by the `test_context_with_versions!` macro.
836    pub fn new_with_versions_and_bin(python_versions: &[&str], uv_bin: PathBuf) -> Self {
837        let bucket = Self::test_bucket_dir();
838        fs_err::create_dir_all(&bucket).expect("Failed to create test bucket");
839
840        let root = tempfile::TempDir::new_in(bucket).expect("Failed to create test root directory");
841
842        // Create a `.git` directory to isolate tests that search for git boundaries from the state
843        // of the file system
844        fs_err::create_dir_all(root.path().join(".git"))
845            .expect("Failed to create `.git` placeholder in test root directory");
846
847        let temp_dir = ChildPath::new(root.path()).child("temp");
848        fs_err::create_dir_all(&temp_dir).expect("Failed to create test working directory");
849
850        let cache_dir = ChildPath::new(root.path()).child("cache");
851        fs_err::create_dir_all(&cache_dir).expect("Failed to create test cache directory");
852
853        let python_dir = ChildPath::new(root.path()).child("python");
854        fs_err::create_dir_all(&python_dir).expect("Failed to create test Python directory");
855
856        let bin_dir = ChildPath::new(root.path()).child("bin");
857        fs_err::create_dir_all(&bin_dir).expect("Failed to create test bin directory");
858
859        // When the `git` feature is disabled, enforce that the test suite does not use `git`
860        if cfg!(not(feature = "git")) {
861            Self::disallow_git_cli(&bin_dir).expect("Failed to setup disallowed `git` command");
862        }
863
864        let home_dir = ChildPath::new(root.path()).child("home");
865        fs_err::create_dir_all(&home_dir).expect("Failed to create test home directory");
866
867        let user_config_dir = if cfg!(windows) {
868            ChildPath::new(home_dir.path())
869        } else {
870            ChildPath::new(home_dir.path()).child(".config")
871        };
872
873        // Canonicalize the temp dir for consistent snapshot behavior
874        let canonical_temp_dir = temp_dir.canonicalize().unwrap();
875        let venv = ChildPath::new(canonical_temp_dir.join(".venv"));
876
877        let python_version = python_versions
878            .first()
879            .map(|version| PythonVersion::from_str(version).unwrap());
880
881        let site_packages = python_version
882            .as_ref()
883            .map(|version| site_packages_path(&venv, &format!("python{version}")));
884
885        // The workspace root directory is not available without walking up the tree
886        // https://github.com/rust-lang/cargo/issues/3946
887        let workspace_root = Path::new(&env::var(EnvVars::CARGO_MANIFEST_DIR).unwrap())
888            .parent()
889            .expect("CARGO_MANIFEST_DIR should be nested in workspace")
890            .parent()
891            .expect("CARGO_MANIFEST_DIR should be doubly nested in workspace")
892            .to_path_buf();
893
894        let download_list = ManagedPythonDownloadList::new_only_embedded().unwrap();
895
896        let python_versions: Vec<_> = python_versions
897            .iter()
898            .map(|version| PythonVersion::from_str(version).unwrap())
899            .zip(
900                python_installations_for_versions(&temp_dir, python_versions, &download_list)
901                    .expect("Failed to find test Python versions"),
902            )
903            .collect();
904
905        // Construct directories for each Python executable on Unix where the executable names
906        // need to be normalized
907        if cfg!(unix) {
908            for (version, executable) in &python_versions {
909                let parent = python_dir.child(version.to_string());
910                parent.create_dir_all().unwrap();
911                parent.child("python3").symlink_to_file(executable).unwrap();
912            }
913        }
914
915        let mut filters = Vec::new();
916
917        filters.extend(
918            Self::path_patterns(&uv_bin)
919                .into_iter()
920                .map(|pattern| (pattern, "[UV]".to_string())),
921        );
922
923        // Exclude `link-mode` on Windows since we set it in the remote test suite
924        if cfg!(windows) {
925            filters.push((" --link-mode <LINK_MODE>".to_string(), String::new()));
926            filters.push((r#"link-mode = "copy"\n"#.to_string(), String::new()));
927            // Unix uses "exit status", Windows uses "exit code"
928            filters.push((r"exit code: ".to_string(), "exit status: ".to_string()));
929        }
930
931        for (version, executable) in &python_versions {
932            // Add filtering for the interpreter path
933            filters.extend(
934                Self::path_patterns(executable)
935                    .into_iter()
936                    .map(|pattern| (pattern, format!("[PYTHON-{version}]"))),
937            );
938
939            // And for the symlink we created in the test the Python path
940            filters.extend(
941                Self::path_patterns(python_dir.join(version.to_string()))
942                    .into_iter()
943                    .map(|pattern| {
944                        (
945                            format!("{pattern}[a-zA-Z0-9]*"),
946                            format!("[PYTHON-{version}]"),
947                        )
948                    }),
949            );
950
951            // Add Python patch version filtering unless explicitly requested to ensure
952            // snapshots are patch version agnostic when it is not a part of the test.
953            if version.patch().is_none() {
954                filters.push((
955                    format!(r"({})\.\d+", regex::escape(version.to_string().as_str())),
956                    "$1.[X]".to_string(),
957                ));
958            }
959        }
960
961        filters.extend(
962            Self::path_patterns(&bin_dir)
963                .into_iter()
964                .map(|pattern| (pattern, "[BIN]/".to_string())),
965        );
966        filters.extend(
967            Self::path_patterns(&cache_dir)
968                .into_iter()
969                .map(|pattern| (pattern, "[CACHE_DIR]/".to_string())),
970        );
971        if let Some(ref site_packages) = site_packages {
972            filters.extend(
973                Self::path_patterns(site_packages)
974                    .into_iter()
975                    .map(|pattern| (pattern, "[SITE_PACKAGES]/".to_string())),
976            );
977        }
978        filters.extend(
979            Self::path_patterns(&venv)
980                .into_iter()
981                .map(|pattern| (pattern, "[VENV]/".to_string())),
982        );
983
984        // Account for [`Simplified::user_display`] which is relative to the command working directory
985        if let Some(site_packages) = site_packages {
986            filters.push((
987                Self::path_pattern(
988                    site_packages
989                        .strip_prefix(&canonical_temp_dir)
990                        .expect("The test site-packages directory is always in the tempdir"),
991                ),
992                "[SITE_PACKAGES]/".to_string(),
993            ));
994        }
995
996        // Filter Python library path differences between Windows and Unix
997        filters.push((
998            r"[\\/]lib[\\/]python\d+\.\d+[\\/]".to_string(),
999            "/[PYTHON-LIB]/".to_string(),
1000        ));
1001        filters.push((r"[\\/]Lib[\\/]".to_string(), "/[PYTHON-LIB]/".to_string()));
1002
1003        filters.extend(
1004            Self::path_patterns(&temp_dir)
1005                .into_iter()
1006                .map(|pattern| (pattern, "[TEMP_DIR]/".to_string())),
1007        );
1008        filters.extend(
1009            Self::path_patterns(&python_dir)
1010                .into_iter()
1011                .map(|pattern| (pattern, "[PYTHON_DIR]/".to_string())),
1012        );
1013        let mut uv_user_config_dir = PathBuf::from(user_config_dir.path());
1014        uv_user_config_dir.push("uv");
1015        filters.extend(
1016            Self::path_patterns(&uv_user_config_dir)
1017                .into_iter()
1018                .map(|pattern| (pattern, "[UV_USER_CONFIG_DIR]/".to_string())),
1019        );
1020        filters.extend(
1021            Self::path_patterns(&user_config_dir)
1022                .into_iter()
1023                .map(|pattern| (pattern, "[USER_CONFIG_DIR]/".to_string())),
1024        );
1025        filters.extend(
1026            Self::path_patterns(&home_dir)
1027                .into_iter()
1028                .map(|pattern| (pattern, "[HOME]/".to_string())),
1029        );
1030        filters.extend(
1031            Self::path_patterns(&workspace_root)
1032                .into_iter()
1033                .map(|pattern| (pattern, "[WORKSPACE]/".to_string())),
1034        );
1035
1036        // Make virtual environment activation cross-platform and shell-agnostic
1037        filters.push((
1038            r"Activate with: (.*)\\Scripts\\activate".to_string(),
1039            "Activate with: source $1/[BIN]/activate".to_string(),
1040        ));
1041        filters.push((
1042            r"Activate with: Scripts\\activate".to_string(),
1043            "Activate with: source [BIN]/activate".to_string(),
1044        ));
1045        filters.push((
1046            r"Activate with: source (.*/|)bin/activate(?:\.\w+)?".to_string(),
1047            "Activate with: source $1[BIN]/activate".to_string(),
1048        ));
1049
1050        // Filter non-deterministic temporary directory names
1051        // Note we apply this _after_ all the full paths to avoid breaking their matching
1052        filters.push((r"(\\|\/)\.tmp.*(\\|\/)".to_string(), "/[TMP]/".to_string()));
1053
1054        // Account for platform prefix differences `file://` (Unix) vs `file:///` (Windows)
1055        filters.push((r"file:///".to_string(), "file://".to_string()));
1056
1057        // Destroy any remaining UNC prefixes (Windows only)
1058        filters.push((r"\\\\\?\\".to_string(), String::new()));
1059
1060        // Remove the version from the packse url in lockfile snapshots. This avoids having a huge
1061        // diff any time we upgrade packse
1062        filters.push((
1063            format!("https://astral-sh.github.io/packse/{PACKSE_VERSION}"),
1064            "https://astral-sh.github.io/packse/PACKSE_VERSION".to_string(),
1065        ));
1066        // Developer convenience
1067        if let Ok(packse_test_index) = env::var(EnvVars::UV_TEST_PACKSE_INDEX) {
1068            filters.push((
1069                packse_test_index.trim_end_matches('/').to_string(),
1070                "https://astral-sh.github.io/packse/PACKSE_VERSION".to_string(),
1071            ));
1072        }
1073        // For wiremock tests
1074        filters.push((r"127\.0\.0\.1:\d*".to_string(), "[LOCALHOST]".to_string()));
1075        // Avoid breaking the tests when bumping the uv version
1076        filters.push((
1077            format!(
1078                r#"requires = \["uv_build>={},<[0-9.]+"\]"#,
1079                uv_version::version()
1080            ),
1081            r#"requires = ["uv_build>=[CURRENT_VERSION],<[NEXT_BREAKING]"]"#.to_string(),
1082        ));
1083        // Filter script environment hashes
1084        filters.push((
1085            r"environments-v(\d+)[\\/](\w+)-[a-z0-9]+".to_string(),
1086            "environments-v$1/$2-[HASH]".to_string(),
1087        ));
1088        // Filter archive hashes
1089        filters.push((
1090            r"archive-v(\d+)[\\/][A-Za-z0-9\-\_]+".to_string(),
1091            "archive-v$1/[HASH]".to_string(),
1092        ));
1093
1094        Self {
1095            root: ChildPath::new(root.path()),
1096            temp_dir,
1097            cache_dir,
1098            python_dir,
1099            home_dir,
1100            user_config_dir,
1101            bin_dir,
1102            venv,
1103            workspace_root,
1104            python_version,
1105            python_versions,
1106            uv_bin,
1107            filters,
1108            extra_env: vec![],
1109            _root: root,
1110            _extra_tempdirs: vec![],
1111        }
1112    }
1113
1114    /// Create a uv command for testing.
1115    pub fn command(&self) -> Command {
1116        let mut command = self.new_command();
1117        self.add_shared_options(&mut command, true);
1118        command
1119    }
1120
1121    pub fn disallow_git_cli(bin_dir: &Path) -> std::io::Result<()> {
1122        let contents = r"#!/bin/sh
1123    echo 'error: `git` operations are not allowed — are you missing a cfg for the `git` feature?' >&2
1124    exit 127";
1125        let git = bin_dir.join(format!("git{}", env::consts::EXE_SUFFIX));
1126        fs_err::write(&git, contents)?;
1127
1128        #[cfg(unix)]
1129        {
1130            use std::os::unix::fs::PermissionsExt;
1131            let mut perms = fs_err::metadata(&git)?.permissions();
1132            perms.set_mode(0o755);
1133            fs_err::set_permissions(&git, perms)?;
1134        }
1135
1136        Ok(())
1137    }
1138
1139    /// Setup Git LFS Filters
1140    ///
1141    /// You can find the default filters in <https://github.com/git-lfs/git-lfs/blob/v3.7.1/lfs/attribute.go#L66-L71>
1142    /// We set required to true to get a full stacktrace when these commands fail.
1143    #[must_use]
1144    pub fn with_git_lfs_config(mut self) -> Self {
1145        let git_lfs_config = self.root.child(".gitconfig");
1146        git_lfs_config
1147            .write_str(indoc! {r#"
1148                [filter "lfs"]
1149                    clean = git-lfs clean -- %f
1150                    smudge = git-lfs smudge -- %f
1151                    process = git-lfs filter-process
1152                    required = true
1153            "#})
1154            .expect("Failed to setup `git-lfs` filters");
1155
1156        // Its possible your system config can cause conflicts with the Git LFS tests.
1157        // In such cases, add self.extra_env.push(("GIT_CONFIG_NOSYSTEM".into(), "1".into()));
1158        self.extra_env.push((
1159            EnvVars::GIT_CONFIG_GLOBAL.into(),
1160            git_lfs_config.as_os_str().into(),
1161        ));
1162        self
1163    }
1164
1165    /// Shared behaviour for almost all test commands.
1166    ///
1167    /// * Use a temporary cache directory
1168    /// * Use a temporary virtual environment with the Python version of [`Self`]
1169    /// * Don't wrap text output based on the terminal we're in, the test output doesn't get printed
1170    ///   but snapshotted to a string.
1171    /// * Use a fake `HOME` to avoid accidentally changing the developer's machine.
1172    /// * Ignore system configuration to avoid reading machine-specific settings.
1173    /// * Hide other Pythons with `UV_PYTHON_INSTALL_DIR` and installed interpreters with
1174    ///   `UV_PYTHON_SEARCH_PATH` and an active venv (if applicable) by removing `VIRTUAL_ENV`.
1175    /// * Increase the stack size to avoid stack overflows on windows due to large async functions.
1176    pub fn add_shared_options(&self, command: &mut Command, activate_venv: bool) {
1177        self.add_shared_args(command);
1178        self.add_shared_env(command, activate_venv);
1179    }
1180
1181    /// Only the arguments of [`TestContext::add_shared_options`].
1182    pub fn add_shared_args(&self, command: &mut Command) {
1183        command.arg("--cache-dir").arg(self.cache_dir.path());
1184    }
1185
1186    /// Only the environment variables of [`TestContext::add_shared_options`].
1187    pub fn add_shared_env(&self, command: &mut Command, activate_venv: bool) {
1188        // Push the test context bin to the front of the PATH
1189        let path = env::join_paths(std::iter::once(self.bin_dir.to_path_buf()).chain(
1190            env::split_paths(&env::var(EnvVars::PATH).unwrap_or_default()),
1191        ))
1192        .unwrap();
1193
1194        // Ensure the tests aren't sensitive to the running user's shell without forcing
1195        // `bash` on Windows
1196        if cfg!(not(windows)) {
1197            command.env(EnvVars::SHELL, "bash");
1198        }
1199
1200        command
1201            // When running the tests in a venv, ignore that venv, otherwise we'll capture warnings.
1202            .env_remove(EnvVars::VIRTUAL_ENV)
1203            // Disable wrapping of uv output for readability / determinism in snapshots.
1204            .env(EnvVars::UV_NO_WRAP, "1")
1205            // While we disable wrapping in uv above, invoked tools may still wrap their output so
1206            // we set a fixed `COLUMNS` value for isolation from terminal width.
1207            .env(EnvVars::COLUMNS, "100")
1208            .env(EnvVars::PATH, path)
1209            .env(EnvVars::HOME, self.home_dir.as_os_str())
1210            .env(EnvVars::APPDATA, self.home_dir.as_os_str())
1211            .env(EnvVars::USERPROFILE, self.home_dir.as_os_str())
1212            .env(
1213                EnvVars::XDG_CONFIG_DIRS,
1214                self.home_dir.join("config").as_os_str(),
1215            )
1216            .env(
1217                EnvVars::XDG_DATA_HOME,
1218                self.home_dir.join("data").as_os_str(),
1219            )
1220            .env(EnvVars::UV_NO_SYSTEM_CONFIG, "1")
1221            .env(EnvVars::UV_PYTHON_INSTALL_DIR, "")
1222            // Installations are not allowed by default; see `Self::with_managed_python_dirs`
1223            .env(EnvVars::UV_PYTHON_DOWNLOADS, "never")
1224            .env(EnvVars::UV_PYTHON_SEARCH_PATH, self.python_path())
1225            .env(EnvVars::UV_EXCLUDE_NEWER, TEST_TIMESTAMP)
1226            .env(EnvVars::UV_TEST_CURRENT_TIMESTAMP, TEST_TIMESTAMP)
1227            .env(EnvVars::UV_TEST_AVAILABLE_VERSION_CUTOFF, TEST_TIMESTAMP)
1228            // Keep Python discovery hermetic and avoid mutating global state, like the Windows
1229            // registry, unless a test opts in explicitly.
1230            .env(EnvVars::UV_PYTHON_NO_REGISTRY, "1")
1231            .env(EnvVars::UV_PYTHON_INSTALL_REGISTRY, "0")
1232            // Since downloads, fetches and builds run in parallel, their message output order is
1233            // non-deterministic, so can't capture them in test output.
1234            .env(EnvVars::UV_TEST_NO_CLI_PROGRESS, "1")
1235            // I believe the intent of all tests is that they are run outside the
1236            // context of an existing git repository. And when they aren't, state
1237            // from the parent git repository can bleed into the behavior of `uv
1238            // init` in a way that makes it difficult to test consistently. By
1239            // setting GIT_CEILING_DIRECTORIES, we specifically prevent git from
1240            // climbing up past the root of our test directory to look for any
1241            // other git repos.
1242            //
1243            // If one wants to write a test specifically targeting uv within a
1244            // pre-existing git repository, then the test should make the parent
1245            // git repo explicitly. The GIT_CEILING_DIRECTORIES here shouldn't
1246            // impact it, since it only prevents git from discovering repositories
1247            // at or above the root.
1248            .env(EnvVars::GIT_CEILING_DIRECTORIES, self.root.path())
1249            .current_dir(self.temp_dir.path());
1250
1251        for (key, value) in &self.extra_env {
1252            command.env(key, value);
1253        }
1254
1255        if activate_venv {
1256            command.env(EnvVars::VIRTUAL_ENV, self.venv.as_os_str());
1257        }
1258
1259        if cfg!(unix) {
1260            // Avoid locale issues in tests
1261            command.env(EnvVars::LC_ALL, "C");
1262        }
1263    }
1264
1265    /// Create a `pip compile` command for testing.
1266    pub fn pip_compile(&self) -> Command {
1267        let mut command = self.new_command();
1268        command.arg("pip").arg("compile");
1269        self.add_shared_options(&mut command, true);
1270        command
1271    }
1272
1273    /// Create a `pip compile` command for testing.
1274    pub fn pip_sync(&self) -> Command {
1275        let mut command = self.new_command();
1276        command.arg("pip").arg("sync");
1277        self.add_shared_options(&mut command, true);
1278        command
1279    }
1280
1281    pub fn pip_show(&self) -> Command {
1282        let mut command = self.new_command();
1283        command.arg("pip").arg("show");
1284        self.add_shared_options(&mut command, true);
1285        command
1286    }
1287
1288    /// Create a `pip freeze` command with options shared across scenarios.
1289    pub fn pip_freeze(&self) -> Command {
1290        let mut command = self.new_command();
1291        command.arg("pip").arg("freeze");
1292        self.add_shared_options(&mut command, true);
1293        command
1294    }
1295
1296    /// Create a `pip check` command with options shared across scenarios.
1297    pub fn pip_check(&self) -> Command {
1298        let mut command = self.new_command();
1299        command.arg("pip").arg("check");
1300        self.add_shared_options(&mut command, true);
1301        command
1302    }
1303
1304    pub fn pip_list(&self) -> Command {
1305        let mut command = self.new_command();
1306        command.arg("pip").arg("list");
1307        self.add_shared_options(&mut command, true);
1308        command
1309    }
1310
1311    /// Create a `uv venv` command
1312    pub fn venv(&self) -> Command {
1313        let mut command = self.new_command();
1314        command.arg("venv");
1315        self.add_shared_options(&mut command, false);
1316        command
1317    }
1318
1319    /// Create a `pip install` command with options shared across scenarios.
1320    pub fn pip_install(&self) -> Command {
1321        let mut command = self.new_command();
1322        command.arg("pip").arg("install");
1323        self.add_shared_options(&mut command, true);
1324        command
1325    }
1326
1327    /// Create a `pip uninstall` command with options shared across scenarios.
1328    pub fn pip_uninstall(&self) -> Command {
1329        let mut command = self.new_command();
1330        command.arg("pip").arg("uninstall");
1331        self.add_shared_options(&mut command, true);
1332        command
1333    }
1334
1335    /// Create a `pip tree` command for testing.
1336    pub fn pip_tree(&self) -> Command {
1337        let mut command = self.new_command();
1338        command.arg("pip").arg("tree");
1339        self.add_shared_options(&mut command, true);
1340        command
1341    }
1342
1343    /// Create a `pip debug` command for testing.
1344    pub fn pip_debug(&self) -> Command {
1345        let mut command = self.new_command();
1346        command.arg("pip").arg("debug");
1347        self.add_shared_options(&mut command, true);
1348        command
1349    }
1350
1351    /// Create a `uv help` command with options shared across scenarios.
1352    pub fn help(&self) -> Command {
1353        let mut command = self.new_command();
1354        command.arg("help");
1355        self.add_shared_env(&mut command, false);
1356        command
1357    }
1358
1359    /// Create a `uv init` command with options shared across scenarios and
1360    /// isolated from any git repository that may exist in a parent directory.
1361    pub fn init(&self) -> Command {
1362        let mut command = self.new_command();
1363        command.arg("init");
1364        self.add_shared_options(&mut command, false);
1365        command
1366    }
1367
1368    /// Create a `uv sync` command with options shared across scenarios.
1369    pub fn sync(&self) -> Command {
1370        let mut command = self.new_command();
1371        command.arg("sync");
1372        self.add_shared_options(&mut command, false);
1373        command
1374    }
1375
1376    /// Create a `uv lock` command with options shared across scenarios.
1377    pub fn lock(&self) -> Command {
1378        let mut command = self.new_command();
1379        command.arg("lock");
1380        self.add_shared_options(&mut command, false);
1381        command
1382    }
1383
1384    /// Create a `uv audit` command with options shared across scenarios.
1385    pub fn audit(&self) -> Command {
1386        let mut command = self.new_command();
1387        command.arg("audit");
1388        self.add_shared_options(&mut command, false);
1389        command
1390    }
1391
1392    /// Create a `uv workspace metadata` command with options shared across scenarios.
1393    pub fn workspace_metadata(&self) -> Command {
1394        let mut command = self.new_command();
1395        command.arg("workspace").arg("metadata");
1396        self.add_shared_options(&mut command, false);
1397        command
1398    }
1399
1400    /// Create a `uv workspace dir` command with options shared across scenarios.
1401    pub fn workspace_dir(&self) -> Command {
1402        let mut command = self.new_command();
1403        command.arg("workspace").arg("dir");
1404        self.add_shared_options(&mut command, false);
1405        command
1406    }
1407
1408    /// Create a `uv workspace list` command with options shared across scenarios.
1409    pub fn workspace_list(&self) -> Command {
1410        let mut command = self.new_command();
1411        command.arg("workspace").arg("list");
1412        self.add_shared_options(&mut command, false);
1413        command
1414    }
1415
1416    /// Create a `uv export` command with options shared across scenarios.
1417    pub fn export(&self) -> Command {
1418        let mut command = self.new_command();
1419        command.arg("export");
1420        self.add_shared_options(&mut command, false);
1421        command
1422    }
1423
1424    /// Create a `uv format` command with options shared across scenarios.
1425    pub fn format(&self) -> Command {
1426        let mut command = self.new_command();
1427        command.arg("format");
1428        self.add_shared_options(&mut command, false);
1429        // Override to a more recent date for ruff version resolution
1430        command.env(EnvVars::UV_EXCLUDE_NEWER, "2026-02-15T00:00:00Z");
1431        command
1432    }
1433
1434    /// Create a `uv build` command with options shared across scenarios.
1435    pub fn build(&self) -> Command {
1436        let mut command = self.new_command();
1437        command.arg("build");
1438        self.add_shared_options(&mut command, false);
1439        command
1440    }
1441
1442    pub fn version(&self) -> Command {
1443        let mut command = self.new_command();
1444        command.arg("version");
1445        self.add_shared_options(&mut command, false);
1446        command
1447    }
1448
1449    pub fn self_version(&self) -> Command {
1450        let mut command = self.new_command();
1451        command.arg("self").arg("version");
1452        self.add_shared_options(&mut command, false);
1453        command
1454    }
1455
1456    pub fn self_update(&self) -> Command {
1457        let mut command = self.new_command();
1458        command.arg("self").arg("update");
1459        self.add_shared_options(&mut command, false);
1460        command
1461    }
1462
1463    /// Create a `uv publish` command with options shared across scenarios.
1464    pub fn publish(&self) -> Command {
1465        let mut command = self.new_command();
1466        command.arg("publish");
1467        self.add_shared_options(&mut command, false);
1468        command
1469    }
1470
1471    /// Create a `uv python find` command with options shared across scenarios.
1472    pub fn python_find(&self) -> Command {
1473        let mut command = self.new_command();
1474        command
1475            .arg("python")
1476            .arg("find")
1477            .env(EnvVars::UV_PREVIEW, "1")
1478            .env(EnvVars::UV_PYTHON_INSTALL_DIR, "");
1479        self.add_shared_options(&mut command, false);
1480        command
1481    }
1482
1483    /// Create a `uv python list` command with options shared across scenarios.
1484    pub fn python_list(&self) -> Command {
1485        let mut command = self.new_command();
1486        command
1487            .arg("python")
1488            .arg("list")
1489            .env(EnvVars::UV_PYTHON_INSTALL_DIR, "");
1490        self.add_shared_options(&mut command, false);
1491        command
1492    }
1493
1494    /// Create a `uv python install` command with options shared across scenarios.
1495    pub fn python_install(&self) -> Command {
1496        let mut command = self.new_command();
1497        command.arg("python").arg("install");
1498        self.add_shared_options(&mut command, true);
1499        command
1500    }
1501
1502    /// Create a `uv python uninstall` command with options shared across scenarios.
1503    pub fn python_uninstall(&self) -> Command {
1504        let mut command = self.new_command();
1505        command.arg("python").arg("uninstall");
1506        self.add_shared_options(&mut command, true);
1507        command
1508    }
1509
1510    /// Create a `uv python upgrade` command with options shared across scenarios.
1511    pub fn python_upgrade(&self) -> Command {
1512        let mut command = self.new_command();
1513        command.arg("python").arg("upgrade");
1514        self.add_shared_options(&mut command, true);
1515        command
1516    }
1517
1518    /// Create a `uv python pin` command with options shared across scenarios.
1519    pub fn python_pin(&self) -> Command {
1520        let mut command = self.new_command();
1521        command.arg("python").arg("pin");
1522        self.add_shared_options(&mut command, true);
1523        command
1524    }
1525
1526    /// Create a `uv python dir` command with options shared across scenarios.
1527    pub fn python_dir(&self) -> Command {
1528        let mut command = self.new_command();
1529        command.arg("python").arg("dir");
1530        self.add_shared_options(&mut command, true);
1531        command
1532    }
1533
1534    /// Create a `uv run` command with options shared across scenarios.
1535    pub fn run(&self) -> Command {
1536        let mut command = self.new_command();
1537        command.arg("run").env(EnvVars::UV_SHOW_RESOLUTION, "1");
1538        self.add_shared_options(&mut command, true);
1539        command
1540    }
1541
1542    /// Create a `uv tool run` command with options shared across scenarios.
1543    pub fn tool_run(&self) -> Command {
1544        let mut command = self.new_command();
1545        command
1546            .arg("tool")
1547            .arg("run")
1548            .env(EnvVars::UV_SHOW_RESOLUTION, "1");
1549        self.add_shared_options(&mut command, false);
1550        command
1551    }
1552
1553    /// Create a `uv upgrade run` command with options shared across scenarios.
1554    pub fn tool_upgrade(&self) -> Command {
1555        let mut command = self.new_command();
1556        command.arg("tool").arg("upgrade");
1557        self.add_shared_options(&mut command, false);
1558        command
1559    }
1560
1561    /// Create a `uv tool install` command with options shared across scenarios.
1562    pub fn tool_install(&self) -> Command {
1563        let mut command = self.new_command();
1564        command.arg("tool").arg("install");
1565        self.add_shared_options(&mut command, false);
1566        command
1567    }
1568
1569    /// Create a `uv tool list` command with options shared across scenarios.
1570    pub fn tool_list(&self) -> Command {
1571        let mut command = self.new_command();
1572        command.arg("tool").arg("list");
1573        self.add_shared_options(&mut command, false);
1574        command
1575    }
1576
1577    /// Create a `uv tool dir` command with options shared across scenarios.
1578    pub fn tool_dir(&self) -> Command {
1579        let mut command = self.new_command();
1580        command.arg("tool").arg("dir");
1581        self.add_shared_options(&mut command, false);
1582        command
1583    }
1584
1585    /// Create a `uv tool uninstall` command with options shared across scenarios.
1586    pub fn tool_uninstall(&self) -> Command {
1587        let mut command = self.new_command();
1588        command.arg("tool").arg("uninstall");
1589        self.add_shared_options(&mut command, false);
1590        command
1591    }
1592
1593    /// Create a `uv add` command for the given requirements.
1594    pub fn add(&self) -> Command {
1595        let mut command = self.new_command();
1596        command.arg("add");
1597        self.add_shared_options(&mut command, false);
1598        command
1599    }
1600
1601    /// Create a `uv remove` command for the given requirements.
1602    pub fn remove(&self) -> Command {
1603        let mut command = self.new_command();
1604        command.arg("remove");
1605        self.add_shared_options(&mut command, false);
1606        command
1607    }
1608
1609    /// Create a `uv tree` command with options shared across scenarios.
1610    pub fn tree(&self) -> Command {
1611        let mut command = self.new_command();
1612        command.arg("tree");
1613        self.add_shared_options(&mut command, false);
1614        command
1615    }
1616
1617    /// Create a `uv cache clean` command.
1618    pub fn clean(&self) -> Command {
1619        let mut command = self.new_command();
1620        command.arg("cache").arg("clean");
1621        self.add_shared_options(&mut command, false);
1622        command
1623    }
1624
1625    /// Create a `uv cache prune` command.
1626    pub fn prune(&self) -> Command {
1627        let mut command = self.new_command();
1628        command.arg("cache").arg("prune");
1629        self.add_shared_options(&mut command, false);
1630        command
1631    }
1632
1633    /// Create a `uv cache size` command.
1634    pub fn cache_size(&self) -> Command {
1635        let mut command = self.new_command();
1636        command.arg("cache").arg("size");
1637        self.add_shared_options(&mut command, false);
1638        command
1639    }
1640
1641    /// Create a `uv build_backend` command.
1642    ///
1643    /// Note that this command is hidden and only invoking it through a build frontend is supported.
1644    pub fn build_backend(&self) -> Command {
1645        let mut command = self.new_command();
1646        command.arg("build-backend");
1647        self.add_shared_options(&mut command, false);
1648        command
1649    }
1650
1651    /// The path to the Python interpreter in the venv.
1652    ///
1653    /// Don't use this for `Command::new`, use `Self::python_command` instead.
1654    pub fn interpreter(&self) -> PathBuf {
1655        let venv = &self.venv;
1656        if cfg!(unix) {
1657            venv.join("bin").join("python")
1658        } else if cfg!(windows) {
1659            venv.join("Scripts").join("python.exe")
1660        } else {
1661            unimplemented!("Only Windows and Unix are supported")
1662        }
1663    }
1664
1665    pub fn python_command(&self) -> Command {
1666        let mut interpreter = self.interpreter();
1667
1668        // If there's not a virtual environment, use the first Python interpreter in the context
1669        if !interpreter.exists() {
1670            interpreter.clone_from(
1671                &self
1672                    .python_versions
1673                    .first()
1674                    .expect("At least one Python version is required")
1675                    .1,
1676            );
1677        }
1678
1679        let mut command = Self::new_command_with(&interpreter);
1680        command
1681            // Our tests change files in <1s, so we must disable CPython bytecode caching or we'll get stale files
1682            // https://github.com/python/cpython/issues/75953
1683            .arg("-B")
1684            // Python on windows
1685            .env(EnvVars::PYTHONUTF8, "1");
1686
1687        self.add_shared_env(&mut command, false);
1688
1689        command
1690    }
1691
1692    /// Create a `uv auth login` command.
1693    pub fn auth_login(&self) -> Command {
1694        let mut command = self.new_command();
1695        command.arg("auth").arg("login");
1696        self.add_shared_options(&mut command, false);
1697        command
1698    }
1699
1700    /// Create a `uv auth logout` command.
1701    pub fn auth_logout(&self) -> Command {
1702        let mut command = self.new_command();
1703        command.arg("auth").arg("logout");
1704        self.add_shared_options(&mut command, false);
1705        command
1706    }
1707
1708    /// Create a `uv auth helper --protocol bazel get` command.
1709    pub fn auth_helper(&self) -> Command {
1710        let mut command = self.new_command();
1711        command.arg("auth").arg("helper");
1712        self.add_shared_options(&mut command, false);
1713        command
1714    }
1715
1716    /// Create a `uv auth token` command.
1717    pub fn auth_token(&self) -> Command {
1718        let mut command = self.new_command();
1719        command.arg("auth").arg("token");
1720        self.add_shared_options(&mut command, false);
1721        command
1722    }
1723
1724    /// Set `HOME` to the real home directory.
1725    ///
1726    /// We need this for testing commands which use the macOS keychain.
1727    #[must_use]
1728    pub fn with_real_home(mut self) -> Self {
1729        if let Some(home) = env::var_os(EnvVars::HOME) {
1730            self.extra_env
1731                .push((EnvVars::HOME.to_string().into(), home));
1732        }
1733        // Use the test's isolated config directory to avoid reading user
1734        // configuration files (like `.python-version`) that could interfere with tests.
1735        self.extra_env.push((
1736            EnvVars::XDG_CONFIG_HOME.into(),
1737            self.user_config_dir.as_os_str().into(),
1738        ));
1739        self
1740    }
1741
1742    /// Run the given python code and check whether it succeeds.
1743    pub fn assert_command(&self, command: &str) -> Assert {
1744        self.python_command()
1745            .arg("-c")
1746            .arg(command)
1747            .current_dir(&self.temp_dir)
1748            .assert()
1749    }
1750
1751    /// Run the given python file and check whether it succeeds.
1752    pub fn assert_file(&self, file: impl AsRef<Path>) -> Assert {
1753        self.python_command()
1754            .arg(file.as_ref())
1755            .current_dir(&self.temp_dir)
1756            .assert()
1757    }
1758
1759    /// Assert a package is installed with the given version.
1760    pub fn assert_installed(&self, package: &'static str, version: &'static str) {
1761        self.assert_command(
1762            format!("import {package} as package; print(package.__version__, end='')").as_str(),
1763        )
1764        .success()
1765        .stdout(version);
1766    }
1767
1768    /// Assert a package is not installed.
1769    pub fn assert_not_installed(&self, package: &'static str) {
1770        self.assert_command(format!("import {package}").as_str())
1771            .failure();
1772    }
1773
1774    /// Generate various escaped regex patterns for the given path.
1775    pub fn path_patterns(path: impl AsRef<Path>) -> Vec<String> {
1776        let mut patterns = Vec::new();
1777
1778        // We can only canonicalize paths that exist already
1779        if path.as_ref().exists() {
1780            patterns.push(Self::path_pattern(
1781                path.as_ref()
1782                    .canonicalize()
1783                    .expect("Failed to create canonical path"),
1784            ));
1785        }
1786
1787        // Include a non-canonicalized version
1788        patterns.push(Self::path_pattern(path));
1789
1790        patterns
1791    }
1792
1793    /// Generate an escaped regex pattern for the given path.
1794    fn path_pattern(path: impl AsRef<Path>) -> String {
1795        format!(
1796            // Trim the trailing separator for cross-platform directories filters
1797            r"{}\\?/?",
1798            regex::escape(&path.as_ref().simplified_display().to_string())
1799                // Make separators platform agnostic because on Windows we will display
1800                // paths with Unix-style separators sometimes
1801                .replace(r"\\", r"(\\|\/)")
1802        )
1803    }
1804
1805    pub fn python_path(&self) -> OsString {
1806        if cfg!(unix) {
1807            // On Unix, we needed to normalize the Python executable names to `python3` for the tests
1808            env::join_paths(
1809                self.python_versions
1810                    .iter()
1811                    .map(|(version, _)| self.python_dir.join(version.to_string())),
1812            )
1813            .unwrap()
1814        } else {
1815            // On Windows, just join the parent directories of the executables
1816            env::join_paths(
1817                self.python_versions
1818                    .iter()
1819                    .map(|(_, executable)| executable.parent().unwrap().to_path_buf()),
1820            )
1821            .unwrap()
1822        }
1823    }
1824
1825    /// Standard snapshot filters _plus_ those for this test context.
1826    pub fn filters(&self) -> Vec<(&str, &str)> {
1827        // Put test context snapshots before the default filters
1828        // This ensures we don't replace other patterns inside paths from the test context first
1829        self.filters
1830            .iter()
1831            .map(|(p, r)| (p.as_str(), r.as_str()))
1832            .chain(INSTA_FILTERS.iter().copied())
1833            .collect()
1834    }
1835
1836    /// Only the filters added to this test context.
1837    pub fn filters_without_standard_filters(&self) -> Vec<(&str, &str)> {
1838        self.filters
1839            .iter()
1840            .map(|(p, r)| (p.as_str(), r.as_str()))
1841            .collect()
1842    }
1843
1844    /// For when we add pypy to the test suite.
1845    pub fn python_kind(&self) -> &'static str {
1846        "python"
1847    }
1848
1849    /// Returns the site-packages folder inside the venv.
1850    pub fn site_packages(&self) -> PathBuf {
1851        site_packages_path(
1852            &self.venv,
1853            &format!(
1854                "{}{}",
1855                self.python_kind(),
1856                self.python_version.as_ref().expect(
1857                    "A Python version must be provided to retrieve the test site packages path"
1858                )
1859            ),
1860        )
1861    }
1862
1863    /// Reset the virtual environment in the test context.
1864    pub fn reset_venv(&self) {
1865        self.create_venv();
1866    }
1867
1868    /// Create a new virtual environment named `.venv` in the test context.
1869    fn create_venv(&self) {
1870        let executable = get_python(
1871            self.python_version
1872                .as_ref()
1873                .expect("A Python version must be provided to create a test virtual environment"),
1874        );
1875        create_venv_from_executable(&self.venv, &self.cache_dir, &executable, &self.uv_bin);
1876    }
1877
1878    /// Copies the files from the ecosystem project given into this text
1879    /// context.
1880    ///
1881    /// This will almost always write at least a `pyproject.toml` into this
1882    /// test context.
1883    ///
1884    /// The given name should correspond to the name of a sub-directory (not a
1885    /// path to it) in the `test/ecosystem` directory.
1886    ///
1887    /// This panics (fails the current test) for any failure.
1888    pub fn copy_ecosystem_project(&self, name: &str) {
1889        let project_dir = PathBuf::from(format!("../../test/ecosystem/{name}"));
1890        self.temp_dir.copy_from(project_dir, &["*"]).unwrap();
1891        // If there is a (gitignore) lockfile, remove it.
1892        if let Err(err) = fs_err::remove_file(self.temp_dir.join("uv.lock")) {
1893            assert_eq!(
1894                err.kind(),
1895                io::ErrorKind::NotFound,
1896                "Failed to remove uv.lock: {err}"
1897            );
1898        }
1899    }
1900
1901    /// Creates a way to compare the changes made to a lock file.
1902    ///
1903    /// This routine starts by copying (not moves) the generated lock file to
1904    /// memory. It then calls the given closure with this test context to get a
1905    /// `Command` and runs the command. The diff between the old lock file and
1906    /// the new one is then returned.
1907    ///
1908    /// This assumes that a lock has already been performed.
1909    pub fn diff_lock(&self, change: impl Fn(&Self) -> Command) -> String {
1910        static TRIM_TRAILING_WHITESPACE: std::sync::LazyLock<Regex> =
1911            std::sync::LazyLock::new(|| Regex::new(r"(?m)^\s+$").unwrap());
1912
1913        let lock_path = ChildPath::new(self.temp_dir.join("uv.lock"));
1914        let old_lock = fs_err::read_to_string(&lock_path).unwrap();
1915        let (snapshot, _, status) = run_and_format_with_status(
1916            change(self),
1917            self.filters(),
1918            "diff_lock",
1919            Some(WindowsFilters::Platform),
1920            None,
1921        );
1922        assert!(status.success(), "{snapshot}");
1923        let new_lock = fs_err::read_to_string(&lock_path).unwrap();
1924        diff_snapshot(&old_lock, &new_lock)
1925    }
1926
1927    /// Read a file in the temporary directory
1928    pub fn read(&self, file: impl AsRef<Path>) -> String {
1929        fs_err::read_to_string(self.temp_dir.join(&file))
1930            .unwrap_or_else(|_| panic!("Missing file: `{}`", file.user_display()))
1931    }
1932
1933    /// Creates a new `Command` that is intended to be suitable for use in
1934    /// all tests.
1935    fn new_command(&self) -> Command {
1936        Self::new_command_with(&self.uv_bin)
1937    }
1938
1939    /// Creates a new `Command` that is intended to be suitable for use in
1940    /// all tests, but with the given binary.
1941    ///
1942    /// Clears environment variables defined in [`EnvVars`] to avoid reading
1943    /// test host settings.
1944    fn new_command_with(bin: &Path) -> Command {
1945        let mut command = Command::new(bin);
1946
1947        let passthrough = [
1948            // For linux distributions
1949            EnvVars::PATH,
1950            // For debugging tests.
1951            EnvVars::RUST_LOG,
1952            EnvVars::RUST_BACKTRACE,
1953            // Windows System configuration.
1954            EnvVars::SYSTEMDRIVE,
1955            // Work around small default stack sizes and large futures in debug builds.
1956            EnvVars::RUST_MIN_STACK,
1957            EnvVars::UV_STACK_SIZE,
1958            // Allow running tests with custom network settings.
1959            EnvVars::ALL_PROXY,
1960            EnvVars::HTTPS_PROXY,
1961            EnvVars::HTTP_PROXY,
1962            EnvVars::NO_PROXY,
1963            EnvVars::SSL_CERT_DIR,
1964            EnvVars::SSL_CERT_FILE,
1965            EnvVars::UV_NATIVE_TLS,
1966            EnvVars::UV_SYSTEM_CERTS,
1967        ];
1968
1969        for env_var in EnvVars::all_names()
1970            .iter()
1971            .filter(|name| !passthrough.contains(name))
1972        {
1973            command.env_remove(env_var);
1974        }
1975
1976        command
1977    }
1978}
1979
1980/// Creates a "unified" diff between the two line-oriented strings suitable
1981/// for snapshotting.
1982pub fn diff_snapshot(old: &str, new: &str) -> String {
1983    static TRIM_TRAILING_WHITESPACE: std::sync::LazyLock<Regex> =
1984        std::sync::LazyLock::new(|| Regex::new(r"(?m)^\s+$").unwrap());
1985
1986    let diff = similar::TextDiff::from_lines(old, new);
1987    let unified = diff
1988        .unified_diff()
1989        .context_radius(10)
1990        .header("old", "new")
1991        .to_string();
1992    // Not totally clear why, but some lines end up containing only
1993    // whitespace in the diff, even though they don't appear in the
1994    // original data. So just strip them here.
1995    TRIM_TRAILING_WHITESPACE
1996        .replace_all(&unified, "")
1997        .into_owned()
1998}
1999
2000pub fn site_packages_path(venv: &Path, python: &str) -> PathBuf {
2001    if cfg!(unix) {
2002        venv.join("lib").join(python).join("site-packages")
2003    } else if cfg!(windows) {
2004        venv.join("Lib").join("site-packages")
2005    } else {
2006        unimplemented!("Only Windows and Unix are supported")
2007    }
2008}
2009
2010pub fn venv_bin_path(venv: impl AsRef<Path>) -> PathBuf {
2011    if cfg!(unix) {
2012        venv.as_ref().join("bin")
2013    } else if cfg!(windows) {
2014        venv.as_ref().join("Scripts")
2015    } else {
2016        unimplemented!("Only Windows and Unix are supported")
2017    }
2018}
2019
2020/// Get the path to the python interpreter for a specific python version.
2021pub fn get_python(version: &PythonVersion) -> PathBuf {
2022    ManagedPythonInstallations::from_settings(None)
2023        .map(|installed_pythons| {
2024            installed_pythons
2025                .find_version(version)
2026                .expect("Tests are run on a supported platform")
2027                .next()
2028                .as_ref()
2029                .map(|python| python.executable(false))
2030        })
2031        // We'll search for the request Python on the PATH if not found in the python versions
2032        // We hack this into a `PathBuf` to satisfy the compiler but it's just a string
2033        .unwrap_or_default()
2034        .unwrap_or(PathBuf::from(version.to_string()))
2035}
2036
2037/// Create a virtual environment at the given path.
2038pub fn create_venv_from_executable<P: AsRef<Path>>(
2039    path: P,
2040    cache_dir: &ChildPath,
2041    python: &Path,
2042    uv_bin: &Path,
2043) {
2044    TestContext::new_command_with(uv_bin)
2045        .arg("venv")
2046        .arg(path.as_ref().as_os_str())
2047        .arg("--clear")
2048        .arg("--cache-dir")
2049        .arg(cache_dir.path())
2050        .arg("--python")
2051        .arg(python)
2052        .current_dir(path.as_ref().parent().unwrap())
2053        .assert()
2054        .success();
2055    ChildPath::new(path.as_ref()).assert(predicate::path::is_dir());
2056}
2057
2058/// Create a `PATH` with the requested Python versions available in order.
2059///
2060/// Generally this should be used with `UV_PYTHON_SEARCH_PATH`.
2061pub fn python_path_with_versions(
2062    temp_dir: &ChildPath,
2063    python_versions: &[&str],
2064) -> anyhow::Result<OsString> {
2065    let download_list = ManagedPythonDownloadList::new_only_embedded().unwrap();
2066    Ok(env::join_paths(
2067        python_installations_for_versions(temp_dir, python_versions, &download_list)?
2068            .into_iter()
2069            .map(|path| path.parent().unwrap().to_path_buf()),
2070    )?)
2071}
2072
2073/// Returns a list of Python executables for the given versions.
2074///
2075/// Generally this should be used with `UV_PYTHON_SEARCH_PATH`.
2076pub fn python_installations_for_versions(
2077    temp_dir: &ChildPath,
2078    python_versions: &[&str],
2079    download_list: &ManagedPythonDownloadList,
2080) -> anyhow::Result<Vec<PathBuf>> {
2081    let cache = Cache::from_path(temp_dir.child("cache").to_path_buf())
2082        .init_no_wait()?
2083        .expect("No cache contention when setting up Python in tests");
2084    let selected_pythons = python_versions
2085        .iter()
2086        .map(|python_version| {
2087            if let Ok(python) = PythonInstallation::find(
2088                &PythonRequest::parse(python_version),
2089                EnvironmentPreference::OnlySystem,
2090                PythonPreference::Managed,
2091                download_list,
2092                &cache,
2093                Preview::default(),
2094            ) {
2095                python.into_interpreter().sys_executable().to_owned()
2096            } else {
2097                panic!("Could not find Python {python_version} for test\nTry `cargo run python install` first, or refer to CONTRIBUTING.md");
2098            }
2099        })
2100        .collect::<Vec<_>>();
2101
2102    assert!(
2103        python_versions.is_empty() || !selected_pythons.is_empty(),
2104        "Failed to fulfill requested test Python versions: {selected_pythons:?}"
2105    );
2106
2107    Ok(selected_pythons)
2108}
2109
2110#[derive(Debug, Copy, Clone)]
2111pub enum WindowsFilters {
2112    Platform,
2113    Universal,
2114}
2115
2116/// Helper method to apply filters to a string. Useful when `!uv_snapshot` cannot be used.
2117pub fn apply_filters<T: AsRef<str>>(mut snapshot: String, filters: impl AsRef<[(T, T)]>) -> String {
2118    for (matcher, replacement) in filters.as_ref() {
2119        // TODO(konstin): Cache regex compilation
2120        let re = Regex::new(matcher.as_ref()).expect("Do you need to regex::escape your filter?");
2121        if re.is_match(&snapshot) {
2122            snapshot = re.replace_all(&snapshot, replacement.as_ref()).to_string();
2123        }
2124    }
2125    snapshot
2126}
2127
2128/// Execute the command and format its output status, stdout and stderr into a snapshot string.
2129///
2130/// This function is derived from `insta_cmd`s `spawn_with_info`.
2131pub fn run_and_format<T: AsRef<str>>(
2132    command: impl BorrowMut<Command>,
2133    filters: impl AsRef<[(T, T)]>,
2134    function_name: &str,
2135    windows_filters: Option<WindowsFilters>,
2136    input: Option<&str>,
2137) -> (String, Output) {
2138    let (snapshot, output, _) =
2139        run_and_format_with_status(command, filters, function_name, windows_filters, input);
2140    (snapshot, output)
2141}
2142
2143/// Execute the command and format its output status, stdout and stderr into a snapshot string.
2144///
2145/// This function is derived from `insta_cmd`s `spawn_with_info`.
2146#[expect(clippy::print_stderr)]
2147pub fn run_and_format_with_status<T: AsRef<str>>(
2148    mut command: impl BorrowMut<Command>,
2149    filters: impl AsRef<[(T, T)]>,
2150    function_name: &str,
2151    windows_filters: Option<WindowsFilters>,
2152    input: Option<&str>,
2153) -> (String, Output, ExitStatus) {
2154    let program = command
2155        .borrow_mut()
2156        .get_program()
2157        .to_string_lossy()
2158        .to_string();
2159
2160    // Support profiling test run commands with traces.
2161    if let Ok(root) = env::var(EnvVars::TRACING_DURATIONS_TEST_ROOT) {
2162        // We only want to fail if the variable is set at runtime.
2163        #[expect(clippy::assertions_on_constants)]
2164        {
2165            assert!(
2166                cfg!(feature = "tracing-durations-export"),
2167                "You need to enable the tracing-durations-export feature to use `TRACING_DURATIONS_TEST_ROOT`"
2168            );
2169        }
2170        command.borrow_mut().env(
2171            EnvVars::TRACING_DURATIONS_FILE,
2172            Path::new(&root).join(function_name).with_extension("jsonl"),
2173        );
2174    }
2175
2176    let output = if let Some(input) = input {
2177        let mut child = command
2178            .borrow_mut()
2179            .stdin(Stdio::piped())
2180            .stdout(Stdio::piped())
2181            .stderr(Stdio::piped())
2182            .spawn()
2183            .unwrap_or_else(|err| panic!("Failed to spawn {program}: {err}"));
2184        child
2185            .stdin
2186            .as_mut()
2187            .expect("Failed to open stdin")
2188            .write_all(input.as_bytes())
2189            .expect("Failed to write to stdin");
2190
2191        child
2192            .wait_with_output()
2193            .unwrap_or_else(|err| panic!("Failed to read output from {program}: {err}"))
2194    } else {
2195        command
2196            .borrow_mut()
2197            .output()
2198            .unwrap_or_else(|err| panic!("Failed to spawn {program}: {err}"))
2199    };
2200
2201    eprintln!("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Unfiltered output ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
2202    eprintln!(
2203        "----- stdout -----\n{}\n----- stderr -----\n{}",
2204        String::from_utf8_lossy(&output.stdout),
2205        String::from_utf8_lossy(&output.stderr),
2206    );
2207    eprintln!("────────────────────────────────────────────────────────────────────────────────\n");
2208
2209    let mut snapshot = apply_filters(
2210        format!(
2211            "success: {:?}\nexit_code: {}\n----- stdout -----\n{}\n----- stderr -----\n{}",
2212            output.status.success(),
2213            output.status.code().unwrap_or(!0),
2214            String::from_utf8_lossy(&output.stdout),
2215            String::from_utf8_lossy(&output.stderr),
2216        ),
2217        filters,
2218    );
2219
2220    // This is a heuristic filter meant to try and make *most* of our tests
2221    // pass whether it's on Windows or Unix. In particular, there are some very
2222    // common Windows-only dependencies that, when removed from a resolution,
2223    // cause the set of dependencies to be the same across platforms.
2224    if cfg!(windows) {
2225        if let Some(windows_filters) = windows_filters {
2226            // The optional leading +/-/~ is for install logs, the optional next line is for lockfiles
2227            let windows_only_deps = [
2228                (r"( ?[-+~] ?)?colorama==\d+(\.\d+)+( [\\]\n\s+--hash=.*)?\n(\s+# via .*\n)?"),
2229                (r"( ?[-+~] ?)?colorama==\d+(\.\d+)+(\s+[-+~]?\s+# via .*)?\n"),
2230                (r"( ?[-+~] ?)?tzdata==\d+(\.\d+)+( [\\]\n\s+--hash=.*)?\n(\s+# via .*\n)?"),
2231                (r"( ?[-+~] ?)?tzdata==\d+(\.\d+)+(\s+[-+~]?\s+# via .*)?\n"),
2232            ];
2233            let mut removed_packages = 0;
2234            for windows_only_dep in windows_only_deps {
2235                // TODO(konstin): Cache regex compilation
2236                let re = Regex::new(windows_only_dep).unwrap();
2237                if re.is_match(&snapshot) {
2238                    snapshot = re.replace(&snapshot, "").to_string();
2239                    removed_packages += 1;
2240                }
2241            }
2242            if removed_packages > 0 {
2243                for i in 1..20 {
2244                    for verb in match windows_filters {
2245                        WindowsFilters::Platform => [
2246                            "Resolved",
2247                            "Prepared",
2248                            "Installed",
2249                            "Checked",
2250                            "Uninstalled",
2251                        ]
2252                        .iter(),
2253                        WindowsFilters::Universal => {
2254                            ["Prepared", "Installed", "Checked", "Uninstalled"].iter()
2255                        }
2256                    } {
2257                        snapshot = snapshot.replace(
2258                            &format!("{verb} {} packages", i + removed_packages),
2259                            &format!("{verb} {} package{}", i, if i > 1 { "s" } else { "" }),
2260                        );
2261                    }
2262                }
2263            }
2264        }
2265    }
2266
2267    let status = output.status;
2268    (snapshot, output, status)
2269}
2270
2271/// Recursively copy a directory and its contents, skipping gitignored files.
2272pub fn copy_dir_ignore(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> anyhow::Result<()> {
2273    for entry in ignore::Walk::new(&src) {
2274        let entry = entry?;
2275        let relative = entry.path().strip_prefix(&src)?;
2276        let ty = entry.file_type().unwrap();
2277        if ty.is_dir() {
2278            fs_err::create_dir(dst.as_ref().join(relative))?;
2279        } else {
2280            fs_err::copy(entry.path(), dst.as_ref().join(relative))?;
2281        }
2282    }
2283    Ok(())
2284}
2285
2286/// Create a stub package `name` in `dir` with the given `pyproject.toml` body.
2287pub fn make_project(dir: &Path, name: &str, body: &str) -> anyhow::Result<()> {
2288    let pyproject_toml = formatdoc! {r#"
2289        [project]
2290        name = "{name}"
2291        version = "0.1.0"
2292        requires-python = ">=3.11,<3.13"
2293        {body}
2294
2295        [build-system]
2296        requires = ["uv_build>=0.9.0,<10000"]
2297        build-backend = "uv_build"
2298        "#
2299    };
2300    fs_err::create_dir_all(dir)?;
2301    fs_err::write(dir.join("pyproject.toml"), pyproject_toml)?;
2302    fs_err::create_dir_all(dir.join("src").join(name))?;
2303    fs_err::write(dir.join("src").join(name).join("__init__.py"), "")?;
2304    Ok(())
2305}
2306
2307// This is a fine-grained token that only has read-only access to the `uv-private-pypackage` repository
2308pub const READ_ONLY_GITHUB_TOKEN: &[&str] = &[
2309    "Z2l0aHViCg==",
2310    "cGF0Cg==",
2311    "MTFBQlVDUjZBMERMUTQ3aVphN3hPdV9qQmhTMkZUeHZ4ZE13OHczakxuZndsV2ZlZjc2cE53eHBWS2tiRUFwdnpmUk8zV0dDSUhicDFsT01aago=",
2312];
2313
2314// This is a fine-grained token that only has read-only access to the `uv-private-pypackage-2` repository
2315#[cfg(not(windows))]
2316pub const READ_ONLY_GITHUB_TOKEN_2: &[&str] = &[
2317    "Z2l0aHViCg==",
2318    "cGF0Cg==",
2319    "MTFBQlVDUjZBMDJTOFYwMTM4YmQ0bV9uTXpueWhxZDBrcllROTQ5SERTeTI0dENKZ2lmdzIybDFSR2s1SE04QW8xTUVYQ1I0Q1YxYUdPRGpvZQo=",
2320];
2321
2322pub const READ_ONLY_GITHUB_SSH_DEPLOY_KEY: &str = "LS0tLS1CRUdJTiBPUEVOU1NIIFBSSVZBVEUgS0VZLS0tLS0KYjNCbGJuTnphQzFyWlhrdGRqRUFBQUFBQkc1dmJtVUFBQUFFYm05dVpRQUFBQUFBQUFBQkFBQUFNd0FBQUF0emMyZ3RaVwpReU5UVXhPUUFBQUNBeTF1SnNZK1JXcWp1NkdIY3Z6a3AwS21yWDEwdmo3RUZqTkpNTkRqSGZPZ0FBQUpqWUpwVnAyQ2FWCmFRQUFBQXR6YzJndFpXUXlOVFV4T1FBQUFDQXkxdUpzWStSV3FqdTZHSGN2emtwMEttclgxMHZqN0VGak5KTU5EakhmT2cKQUFBRUMwbzBnd1BxbGl6TFBJOEFXWDVaS2dVZHJyQ2ptMDhIQm9FenB4VDg3MXBqTFc0bXhqNUZhcU83b1lkeS9PU25RcQphdGZYUytQc1FXTTBrdzBPTWQ4NkFBQUFFR3R2Ym5OMGFVQmhjM1J5WVd3dWMyZ0JBZ01FQlE9PQotLS0tLUVORCBPUEVOU1NIIFBSSVZBVEUgS0VZLS0tLS0K";
2323
2324/// Decode a split, base64 encoded authentication token.
2325/// We split and encode the token to bypass revoke by GitHub's secret scanning
2326pub fn decode_token(content: &[&str]) -> String {
2327    content
2328        .iter()
2329        .map(|part| base64.decode(part).unwrap())
2330        .map(|decoded| {
2331            std::str::from_utf8(decoded.as_slice())
2332                .unwrap()
2333                .trim_end()
2334                .to_string()
2335        })
2336        .join("_")
2337}
2338
2339/// Simulates `reqwest::blocking::get` but returns bytes directly, and disables
2340/// certificate verification, passing through the `BaseClient`
2341#[tokio::main(flavor = "current_thread")]
2342pub async fn download_to_disk(url: &str, path: &Path) {
2343    let trusted_hosts: Vec<_> = env::var(EnvVars::UV_INSECURE_HOST)
2344        .unwrap_or_default()
2345        .split(' ')
2346        .map(|h| uv_configuration::TrustedHost::from_str(h).unwrap())
2347        .collect();
2348
2349    let client = uv_client::BaseClientBuilder::default()
2350        .allow_insecure_host(trusted_hosts)
2351        .build()
2352        .expect("failed to build base client");
2353    let url = url.parse().unwrap();
2354    let response = client
2355        .for_host(&url)
2356        .get(reqwest::Url::from(url))
2357        .send()
2358        .await
2359        .unwrap();
2360
2361    let mut file = fs_err::tokio::File::create(path).await.unwrap();
2362    let mut stream = response.bytes_stream();
2363    while let Some(chunk) = stream.next().await {
2364        file.write_all(&chunk.unwrap()).await.unwrap();
2365    }
2366    file.sync_all().await.unwrap();
2367}
2368
2369/// A guard that sets a directory to read-only and restores original permissions when dropped.
2370///
2371/// This is useful for tests that need to make a directory read-only and ensure
2372/// the permissions are restored even if the test panics.
2373#[cfg(unix)]
2374pub struct ReadOnlyDirectoryGuard {
2375    path: PathBuf,
2376    original_mode: u32,
2377}
2378
2379#[cfg(unix)]
2380impl ReadOnlyDirectoryGuard {
2381    /// Sets the directory to read-only (removes write permission) and returns a guard
2382    /// that will restore the original permissions when dropped.
2383    pub fn new(path: impl Into<PathBuf>) -> std::io::Result<Self> {
2384        use std::os::unix::fs::PermissionsExt;
2385        let path = path.into();
2386        let metadata = fs_err::metadata(&path)?;
2387        let original_mode = metadata.permissions().mode();
2388        // Remove write permissions (keep read and execute)
2389        let readonly_mode = original_mode & !0o222;
2390        fs_err::set_permissions(&path, std::fs::Permissions::from_mode(readonly_mode))?;
2391        Ok(Self {
2392            path,
2393            original_mode,
2394        })
2395    }
2396}
2397
2398#[cfg(unix)]
2399impl Drop for ReadOnlyDirectoryGuard {
2400    fn drop(&mut self) {
2401        use std::os::unix::fs::PermissionsExt;
2402        let _ = fs_err::set_permissions(
2403            &self.path,
2404            std::fs::Permissions::from_mode(self.original_mode),
2405        );
2406    }
2407}
2408
2409/// Utility macro to return the name of the current function.
2410///
2411/// https://stackoverflow.com/a/40234666/3549270
2412#[doc(hidden)]
2413#[macro_export]
2414macro_rules! function_name {
2415    () => {{
2416        fn f() {}
2417        fn type_name_of_val<T>(_: T) -> &'static str {
2418            std::any::type_name::<T>()
2419        }
2420        let mut name = type_name_of_val(f).strip_suffix("::f").unwrap_or("");
2421        while let Some(rest) = name.strip_suffix("::{{closure}}") {
2422            name = rest;
2423        }
2424        name
2425    }};
2426}
2427
2428/// Run [`assert_cmd_snapshot!`], with default filters or with custom filters.
2429///
2430/// By default, the filters will search for the generally windows-only deps colorama and tzdata,
2431/// filter them out and decrease the package counts by one for each match.
2432#[macro_export]
2433macro_rules! uv_snapshot {
2434    ($spawnable:expr, @$snapshot:literal) => {{
2435        uv_snapshot!($crate::INSTA_FILTERS.to_vec(), $spawnable, @$snapshot)
2436    }};
2437    ($filters:expr, $spawnable:expr, @$snapshot:literal) => {{
2438        // Take a reference for backwards compatibility with the vec-expecting insta filters.
2439        let (snapshot, output) = $crate::run_and_format($spawnable, &$filters, $crate::function_name!(), Some($crate::WindowsFilters::Platform), None);
2440        ::insta::assert_snapshot!(snapshot, @$snapshot);
2441        output
2442    }};
2443    ($filters:expr, $spawnable:expr, input=$input:expr, @$snapshot:literal) => {{
2444        // Take a reference for backwards compatibility with the vec-expecting insta filters.
2445        let (snapshot, output) = $crate::run_and_format($spawnable, &$filters, $crate::function_name!(), Some($crate::WindowsFilters::Platform), Some($input));
2446        ::insta::assert_snapshot!(snapshot, @$snapshot);
2447        output
2448    }};
2449    ($filters:expr, windows_filters=false, $spawnable:expr, @$snapshot:literal) => {{
2450        // Take a reference for backwards compatibility with the vec-expecting insta filters.
2451        let (snapshot, output) = $crate::run_and_format($spawnable, &$filters, $crate::function_name!(), None, None);
2452        ::insta::assert_snapshot!(snapshot, @$snapshot);
2453        output
2454    }};
2455    ($filters:expr, universal_windows_filters=true, $spawnable:expr, @$snapshot:literal) => {{
2456        // Take a reference for backwards compatibility with the vec-expecting insta filters.
2457        let (snapshot, output) = $crate::run_and_format($spawnable, &$filters, $crate::function_name!(), Some($crate::WindowsFilters::Universal), None);
2458        ::insta::assert_snapshot!(snapshot, @$snapshot);
2459        output
2460    }};
2461}