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