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