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.0a5";
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        command
1291    }
1292
1293    /// Create a `uv build` command with options shared across scenarios.
1294    pub fn build(&self) -> Command {
1295        let mut command = self.new_command();
1296        command.arg("build");
1297        self.add_shared_options(&mut command, false);
1298        command
1299    }
1300
1301    pub fn version(&self) -> Command {
1302        let mut command = self.new_command();
1303        command.arg("version");
1304        self.add_shared_options(&mut command, false);
1305        command
1306    }
1307
1308    pub fn self_version(&self) -> Command {
1309        let mut command = self.new_command();
1310        command.arg("self").arg("version");
1311        self.add_shared_options(&mut command, false);
1312        command
1313    }
1314
1315    pub fn self_update(&self) -> Command {
1316        let mut command = self.new_command();
1317        command.arg("self").arg("update");
1318        self.add_shared_options(&mut command, false);
1319        command
1320    }
1321
1322    /// Create a `uv publish` command with options shared across scenarios.
1323    pub fn publish(&self) -> Command {
1324        let mut command = self.new_command();
1325        command.arg("publish");
1326        self.add_shared_options(&mut command, false);
1327        command
1328    }
1329
1330    /// Create a `uv python find` command with options shared across scenarios.
1331    pub fn python_find(&self) -> Command {
1332        let mut command = self.new_command();
1333        command
1334            .arg("python")
1335            .arg("find")
1336            .env(EnvVars::UV_PREVIEW, "1")
1337            .env(EnvVars::UV_PYTHON_INSTALL_DIR, "");
1338        self.add_shared_options(&mut command, false);
1339        command
1340    }
1341
1342    /// Create a `uv python list` command with options shared across scenarios.
1343    pub fn python_list(&self) -> Command {
1344        let mut command = self.new_command();
1345        command
1346            .arg("python")
1347            .arg("list")
1348            .env(EnvVars::UV_PYTHON_INSTALL_DIR, "");
1349        self.add_shared_options(&mut command, false);
1350        command
1351    }
1352
1353    /// Create a `uv python install` command with options shared across scenarios.
1354    pub fn python_install(&self) -> Command {
1355        let mut command = self.new_command();
1356        command.arg("python").arg("install");
1357        self.add_shared_options(&mut command, true);
1358        command
1359    }
1360
1361    /// Create a `uv python uninstall` command with options shared across scenarios.
1362    pub fn python_uninstall(&self) -> Command {
1363        let mut command = self.new_command();
1364        command.arg("python").arg("uninstall");
1365        self.add_shared_options(&mut command, true);
1366        command
1367    }
1368
1369    /// Create a `uv python upgrade` command with options shared across scenarios.
1370    pub fn python_upgrade(&self) -> Command {
1371        let mut command = self.new_command();
1372        command.arg("python").arg("upgrade");
1373        self.add_shared_options(&mut command, true);
1374        command
1375    }
1376
1377    /// Create a `uv python pin` command with options shared across scenarios.
1378    pub fn python_pin(&self) -> Command {
1379        let mut command = self.new_command();
1380        command.arg("python").arg("pin");
1381        self.add_shared_options(&mut command, true);
1382        command
1383    }
1384
1385    /// Create a `uv python dir` command with options shared across scenarios.
1386    pub fn python_dir(&self) -> Command {
1387        let mut command = self.new_command();
1388        command.arg("python").arg("dir");
1389        self.add_shared_options(&mut command, true);
1390        command
1391    }
1392
1393    /// Create a `uv run` command with options shared across scenarios.
1394    pub fn run(&self) -> Command {
1395        let mut command = self.new_command();
1396        command.arg("run").env(EnvVars::UV_SHOW_RESOLUTION, "1");
1397        self.add_shared_options(&mut command, true);
1398        command
1399    }
1400
1401    /// Create a `uv tool run` command with options shared across scenarios.
1402    pub fn tool_run(&self) -> Command {
1403        let mut command = self.new_command();
1404        command
1405            .arg("tool")
1406            .arg("run")
1407            .env(EnvVars::UV_SHOW_RESOLUTION, "1");
1408        self.add_shared_options(&mut command, false);
1409        command
1410    }
1411
1412    /// Create a `uv upgrade run` command with options shared across scenarios.
1413    pub fn tool_upgrade(&self) -> Command {
1414        let mut command = self.new_command();
1415        command.arg("tool").arg("upgrade");
1416        self.add_shared_options(&mut command, false);
1417        command
1418    }
1419
1420    /// Create a `uv tool install` command with options shared across scenarios.
1421    pub fn tool_install(&self) -> Command {
1422        let mut command = self.new_command();
1423        command.arg("tool").arg("install");
1424        self.add_shared_options(&mut command, false);
1425        command
1426    }
1427
1428    /// Create a `uv tool list` command with options shared across scenarios.
1429    pub fn tool_list(&self) -> Command {
1430        let mut command = self.new_command();
1431        command.arg("tool").arg("list");
1432        self.add_shared_options(&mut command, false);
1433        command
1434    }
1435
1436    /// Create a `uv tool dir` command with options shared across scenarios.
1437    pub fn tool_dir(&self) -> Command {
1438        let mut command = self.new_command();
1439        command.arg("tool").arg("dir");
1440        self.add_shared_options(&mut command, false);
1441        command
1442    }
1443
1444    /// Create a `uv tool uninstall` command with options shared across scenarios.
1445    pub fn tool_uninstall(&self) -> Command {
1446        let mut command = self.new_command();
1447        command.arg("tool").arg("uninstall");
1448        self.add_shared_options(&mut command, false);
1449        command
1450    }
1451
1452    /// Create a `uv add` command for the given requirements.
1453    pub fn add(&self) -> Command {
1454        let mut command = self.new_command();
1455        command.arg("add");
1456        self.add_shared_options(&mut command, false);
1457        command
1458    }
1459
1460    /// Create a `uv remove` command for the given requirements.
1461    pub fn remove(&self) -> Command {
1462        let mut command = self.new_command();
1463        command.arg("remove");
1464        self.add_shared_options(&mut command, false);
1465        command
1466    }
1467
1468    /// Create a `uv tree` command with options shared across scenarios.
1469    pub fn tree(&self) -> Command {
1470        let mut command = self.new_command();
1471        command.arg("tree");
1472        self.add_shared_options(&mut command, false);
1473        command
1474    }
1475
1476    /// Create a `uv cache clean` command.
1477    pub fn clean(&self) -> Command {
1478        let mut command = self.new_command();
1479        command.arg("cache").arg("clean");
1480        self.add_shared_options(&mut command, false);
1481        command
1482    }
1483
1484    /// Create a `uv cache prune` command.
1485    pub fn prune(&self) -> Command {
1486        let mut command = self.new_command();
1487        command.arg("cache").arg("prune");
1488        self.add_shared_options(&mut command, false);
1489        command
1490    }
1491
1492    /// Create a `uv cache size` command.
1493    pub fn cache_size(&self) -> Command {
1494        let mut command = self.new_command();
1495        command.arg("cache").arg("size");
1496        self.add_shared_options(&mut command, false);
1497        command
1498    }
1499
1500    /// Create a `uv build_backend` command.
1501    ///
1502    /// Note that this command is hidden and only invoking it through a build frontend is supported.
1503    pub fn build_backend(&self) -> Command {
1504        let mut command = self.new_command();
1505        command.arg("build-backend");
1506        self.add_shared_options(&mut command, false);
1507        command
1508    }
1509
1510    /// The path to the Python interpreter in the venv.
1511    ///
1512    /// Don't use this for `Command::new`, use `Self::python_command` instead.
1513    pub fn interpreter(&self) -> PathBuf {
1514        let venv = &self.venv;
1515        if cfg!(unix) {
1516            venv.join("bin").join("python")
1517        } else if cfg!(windows) {
1518            venv.join("Scripts").join("python.exe")
1519        } else {
1520            unimplemented!("Only Windows and Unix are supported")
1521        }
1522    }
1523
1524    pub fn python_command(&self) -> Command {
1525        let mut interpreter = self.interpreter();
1526
1527        // If there's not a virtual environment, use the first Python interpreter in the context
1528        if !interpreter.exists() {
1529            interpreter.clone_from(
1530                &self
1531                    .python_versions
1532                    .first()
1533                    .expect("At least one Python version is required")
1534                    .1,
1535            );
1536        }
1537
1538        let mut command = Self::new_command_with(&interpreter);
1539        command
1540            // Our tests change files in <1s, so we must disable CPython bytecode caching or we'll get stale files
1541            // https://github.com/python/cpython/issues/75953
1542            .arg("-B")
1543            // Python on windows
1544            .env(EnvVars::PYTHONUTF8, "1");
1545
1546        self.add_shared_env(&mut command, false);
1547
1548        command
1549    }
1550
1551    /// Create a `uv auth login` command.
1552    pub fn auth_login(&self) -> Command {
1553        let mut command = self.new_command();
1554        command.arg("auth").arg("login");
1555        self.add_shared_options(&mut command, false);
1556        command
1557    }
1558
1559    /// Create a `uv auth logout` command.
1560    pub fn auth_logout(&self) -> Command {
1561        let mut command = self.new_command();
1562        command.arg("auth").arg("logout");
1563        self.add_shared_options(&mut command, false);
1564        command
1565    }
1566
1567    /// Create a `uv auth helper --protocol bazel get` command.
1568    pub fn auth_helper(&self) -> Command {
1569        let mut command = self.new_command();
1570        command.arg("auth").arg("helper");
1571        self.add_shared_options(&mut command, false);
1572        command
1573    }
1574
1575    /// Create a `uv auth token` command.
1576    pub fn auth_token(&self) -> Command {
1577        let mut command = self.new_command();
1578        command.arg("auth").arg("token");
1579        self.add_shared_options(&mut command, false);
1580        command
1581    }
1582
1583    /// Set `HOME` to the real home directory.
1584    ///
1585    /// We need this for testing commands which use the macOS keychain.
1586    #[must_use]
1587    pub fn with_real_home(mut self) -> Self {
1588        if let Some(home) = env::var_os(EnvVars::HOME) {
1589            self.extra_env
1590                .push((EnvVars::HOME.to_string().into(), home));
1591        }
1592        // Use the test's isolated config directory to avoid reading user
1593        // configuration files (like `.python-version`) that could interfere with tests.
1594        self.extra_env.push((
1595            EnvVars::XDG_CONFIG_HOME.into(),
1596            self.user_config_dir.as_os_str().into(),
1597        ));
1598        self
1599    }
1600
1601    /// Run the given python code and check whether it succeeds.
1602    pub fn assert_command(&self, command: &str) -> Assert {
1603        self.python_command()
1604            .arg("-c")
1605            .arg(command)
1606            .current_dir(&self.temp_dir)
1607            .assert()
1608    }
1609
1610    /// Run the given python file and check whether it succeeds.
1611    pub fn assert_file(&self, file: impl AsRef<Path>) -> Assert {
1612        self.python_command()
1613            .arg(file.as_ref())
1614            .current_dir(&self.temp_dir)
1615            .assert()
1616    }
1617
1618    /// Assert a package is installed with the given version.
1619    pub fn assert_installed(&self, package: &'static str, version: &'static str) {
1620        self.assert_command(
1621            format!("import {package} as package; print(package.__version__, end='')").as_str(),
1622        )
1623        .success()
1624        .stdout(version);
1625    }
1626
1627    /// Assert a package is not installed.
1628    pub fn assert_not_installed(&self, package: &'static str) {
1629        self.assert_command(format!("import {package}").as_str())
1630            .failure();
1631    }
1632
1633    /// Generate various escaped regex patterns for the given path.
1634    pub fn path_patterns(path: impl AsRef<Path>) -> Vec<String> {
1635        let mut patterns = Vec::new();
1636
1637        // We can only canonicalize paths that exist already
1638        if path.as_ref().exists() {
1639            patterns.push(Self::path_pattern(
1640                path.as_ref()
1641                    .canonicalize()
1642                    .expect("Failed to create canonical path"),
1643            ));
1644        }
1645
1646        // Include a non-canonicalized version
1647        patterns.push(Self::path_pattern(path));
1648
1649        patterns
1650    }
1651
1652    /// Generate an escaped regex pattern for the given path.
1653    fn path_pattern(path: impl AsRef<Path>) -> String {
1654        format!(
1655            // Trim the trailing separator for cross-platform directories filters
1656            r"{}\\?/?",
1657            regex::escape(&path.as_ref().simplified_display().to_string())
1658                // Make separators platform agnostic because on Windows we will display
1659                // paths with Unix-style separators sometimes
1660                .replace(r"\\", r"(\\|\/)")
1661        )
1662    }
1663
1664    pub fn python_path(&self) -> OsString {
1665        if cfg!(unix) {
1666            // On Unix, we needed to normalize the Python executable names to `python3` for the tests
1667            env::join_paths(
1668                self.python_versions
1669                    .iter()
1670                    .map(|(version, _)| self.python_dir.join(version.to_string())),
1671            )
1672            .unwrap()
1673        } else {
1674            // On Windows, just join the parent directories of the executables
1675            env::join_paths(
1676                self.python_versions
1677                    .iter()
1678                    .map(|(_, executable)| executable.parent().unwrap().to_path_buf()),
1679            )
1680            .unwrap()
1681        }
1682    }
1683
1684    /// Standard snapshot filters _plus_ those for this test context.
1685    pub fn filters(&self) -> Vec<(&str, &str)> {
1686        // Put test context snapshots before the default filters
1687        // This ensures we don't replace other patterns inside paths from the test context first
1688        self.filters
1689            .iter()
1690            .map(|(p, r)| (p.as_str(), r.as_str()))
1691            .chain(INSTA_FILTERS.iter().copied())
1692            .collect()
1693    }
1694
1695    /// Only the filters added to this test context.
1696    pub fn filters_without_standard_filters(&self) -> Vec<(&str, &str)> {
1697        self.filters
1698            .iter()
1699            .map(|(p, r)| (p.as_str(), r.as_str()))
1700            .collect()
1701    }
1702
1703    /// For when we add pypy to the test suite.
1704    #[allow(clippy::unused_self)]
1705    pub fn python_kind(&self) -> &'static str {
1706        "python"
1707    }
1708
1709    /// Returns the site-packages folder inside the venv.
1710    pub fn site_packages(&self) -> PathBuf {
1711        site_packages_path(
1712            &self.venv,
1713            &format!(
1714                "{}{}",
1715                self.python_kind(),
1716                self.python_version.as_ref().expect(
1717                    "A Python version must be provided to retrieve the test site packages path"
1718                )
1719            ),
1720        )
1721    }
1722
1723    /// Reset the virtual environment in the test context.
1724    pub fn reset_venv(&self) {
1725        self.create_venv();
1726    }
1727
1728    /// Create a new virtual environment named `.venv` in the test context.
1729    fn create_venv(&self) {
1730        let executable = get_python(
1731            self.python_version
1732                .as_ref()
1733                .expect("A Python version must be provided to create a test virtual environment"),
1734        );
1735        create_venv_from_executable(&self.venv, &self.cache_dir, &executable, &self.uv_bin);
1736    }
1737
1738    /// Copies the files from the ecosystem project given into this text
1739    /// context.
1740    ///
1741    /// This will almost always write at least a `pyproject.toml` into this
1742    /// test context.
1743    ///
1744    /// The given name should correspond to the name of a sub-directory (not a
1745    /// path to it) in the `test/ecosystem` directory.
1746    ///
1747    /// This panics (fails the current test) for any failure.
1748    pub fn copy_ecosystem_project(&self, name: &str) {
1749        let project_dir = PathBuf::from(format!("../../test/ecosystem/{name}"));
1750        self.temp_dir.copy_from(project_dir, &["*"]).unwrap();
1751        // If there is a (gitignore) lockfile, remove it.
1752        if let Err(err) = fs_err::remove_file(self.temp_dir.join("uv.lock")) {
1753            assert_eq!(
1754                err.kind(),
1755                io::ErrorKind::NotFound,
1756                "Failed to remove uv.lock: {err}"
1757            );
1758        }
1759    }
1760
1761    /// Creates a way to compare the changes made to a lock file.
1762    ///
1763    /// This routine starts by copying (not moves) the generated lock file to
1764    /// memory. It then calls the given closure with this test context to get a
1765    /// `Command` and runs the command. The diff between the old lock file and
1766    /// the new one is then returned.
1767    ///
1768    /// This assumes that a lock has already been performed.
1769    pub fn diff_lock(&self, change: impl Fn(&Self) -> Command) -> String {
1770        static TRIM_TRAILING_WHITESPACE: std::sync::LazyLock<Regex> =
1771            std::sync::LazyLock::new(|| Regex::new(r"(?m)^\s+$").unwrap());
1772
1773        let lock_path = ChildPath::new(self.temp_dir.join("uv.lock"));
1774        let old_lock = fs_err::read_to_string(&lock_path).unwrap();
1775        let (snapshot, _, status) = run_and_format_with_status(
1776            change(self),
1777            self.filters(),
1778            "diff_lock",
1779            Some(WindowsFilters::Platform),
1780            None,
1781        );
1782        assert!(status.success(), "{snapshot}");
1783        let new_lock = fs_err::read_to_string(&lock_path).unwrap();
1784        diff_snapshot(&old_lock, &new_lock)
1785    }
1786
1787    /// Read a file in the temporary directory
1788    pub fn read(&self, file: impl AsRef<Path>) -> String {
1789        fs_err::read_to_string(self.temp_dir.join(&file))
1790            .unwrap_or_else(|_| panic!("Missing file: `{}`", file.user_display()))
1791    }
1792
1793    /// Creates a new `Command` that is intended to be suitable for use in
1794    /// all tests.
1795    fn new_command(&self) -> Command {
1796        Self::new_command_with(&self.uv_bin)
1797    }
1798
1799    /// Creates a new `Command` that is intended to be suitable for use in
1800    /// all tests, but with the given binary.
1801    ///
1802    /// Clears environment variables defined in [`EnvVars`] to avoid reading
1803    /// test host settings.
1804    fn new_command_with(bin: &Path) -> Command {
1805        let mut command = Command::new(bin);
1806
1807        let passthrough = [
1808            // For linux distributions
1809            EnvVars::PATH,
1810            // For debugging tests.
1811            EnvVars::RUST_LOG,
1812            EnvVars::RUST_BACKTRACE,
1813            // Windows System configuration.
1814            EnvVars::SYSTEMDRIVE,
1815            // Work around small default stack sizes and large futures in debug builds.
1816            EnvVars::RUST_MIN_STACK,
1817            EnvVars::UV_STACK_SIZE,
1818            // Allow running tests with custom network settings.
1819            EnvVars::ALL_PROXY,
1820            EnvVars::HTTPS_PROXY,
1821            EnvVars::HTTP_PROXY,
1822            EnvVars::NO_PROXY,
1823            EnvVars::SSL_CERT_DIR,
1824            EnvVars::SSL_CERT_FILE,
1825            EnvVars::UV_NATIVE_TLS,
1826        ];
1827
1828        for env_var in EnvVars::all_names()
1829            .iter()
1830            .filter(|name| !passthrough.contains(name))
1831        {
1832            command.env_remove(env_var);
1833        }
1834
1835        command
1836    }
1837}
1838
1839/// Creates a "unified" diff between the two line-oriented strings suitable
1840/// for snapshotting.
1841pub fn diff_snapshot(old: &str, new: &str) -> String {
1842    static TRIM_TRAILING_WHITESPACE: std::sync::LazyLock<Regex> =
1843        std::sync::LazyLock::new(|| Regex::new(r"(?m)^\s+$").unwrap());
1844
1845    let diff = similar::TextDiff::from_lines(old, new);
1846    let unified = diff
1847        .unified_diff()
1848        .context_radius(10)
1849        .header("old", "new")
1850        .to_string();
1851    // Not totally clear why, but some lines end up containing only
1852    // whitespace in the diff, even though they don't appear in the
1853    // original data. So just strip them here.
1854    TRIM_TRAILING_WHITESPACE
1855        .replace_all(&unified, "")
1856        .into_owned()
1857}
1858
1859pub fn site_packages_path(venv: &Path, python: &str) -> PathBuf {
1860    if cfg!(unix) {
1861        venv.join("lib").join(python).join("site-packages")
1862    } else if cfg!(windows) {
1863        venv.join("Lib").join("site-packages")
1864    } else {
1865        unimplemented!("Only Windows and Unix are supported")
1866    }
1867}
1868
1869pub fn venv_bin_path(venv: impl AsRef<Path>) -> PathBuf {
1870    if cfg!(unix) {
1871        venv.as_ref().join("bin")
1872    } else if cfg!(windows) {
1873        venv.as_ref().join("Scripts")
1874    } else {
1875        unimplemented!("Only Windows and Unix are supported")
1876    }
1877}
1878
1879/// Get the path to the python interpreter for a specific python version.
1880pub fn get_python(version: &PythonVersion) -> PathBuf {
1881    ManagedPythonInstallations::from_settings(None)
1882        .map(|installed_pythons| {
1883            installed_pythons
1884                .find_version(version)
1885                .expect("Tests are run on a supported platform")
1886                .next()
1887                .as_ref()
1888                .map(|python| python.executable(false))
1889        })
1890        // We'll search for the request Python on the PATH if not found in the python versions
1891        // We hack this into a `PathBuf` to satisfy the compiler but it's just a string
1892        .unwrap_or_default()
1893        .unwrap_or(PathBuf::from(version.to_string()))
1894}
1895
1896/// Create a virtual environment at the given path.
1897pub fn create_venv_from_executable<P: AsRef<Path>>(
1898    path: P,
1899    cache_dir: &ChildPath,
1900    python: &Path,
1901    uv_bin: &Path,
1902) {
1903    TestContext::new_command_with(uv_bin)
1904        .arg("venv")
1905        .arg(path.as_ref().as_os_str())
1906        .arg("--clear")
1907        .arg("--cache-dir")
1908        .arg(cache_dir.path())
1909        .arg("--python")
1910        .arg(python)
1911        .current_dir(path.as_ref().parent().unwrap())
1912        .assert()
1913        .success();
1914    ChildPath::new(path.as_ref()).assert(predicate::path::is_dir());
1915}
1916
1917/// Create a `PATH` with the requested Python versions available in order.
1918///
1919/// Generally this should be used with `UV_TEST_PYTHON_PATH`.
1920pub fn python_path_with_versions(
1921    temp_dir: &ChildPath,
1922    python_versions: &[&str],
1923) -> anyhow::Result<OsString> {
1924    let download_list = ManagedPythonDownloadList::new_only_embedded().unwrap();
1925    Ok(env::join_paths(
1926        python_installations_for_versions(temp_dir, python_versions, &download_list)?
1927            .into_iter()
1928            .map(|path| path.parent().unwrap().to_path_buf()),
1929    )?)
1930}
1931
1932/// Returns a list of Python executables for the given versions.
1933///
1934/// Generally this should be used with `UV_TEST_PYTHON_PATH`.
1935pub fn python_installations_for_versions(
1936    temp_dir: &ChildPath,
1937    python_versions: &[&str],
1938    download_list: &ManagedPythonDownloadList,
1939) -> anyhow::Result<Vec<PathBuf>> {
1940    let cache = Cache::from_path(temp_dir.child("cache").to_path_buf())
1941        .init_no_wait()?
1942        .expect("No cache contention when setting up Python in tests");
1943    let selected_pythons = python_versions
1944        .iter()
1945        .map(|python_version| {
1946            if let Ok(python) = PythonInstallation::find(
1947                &PythonRequest::parse(python_version),
1948                EnvironmentPreference::OnlySystem,
1949                PythonPreference::Managed,
1950                download_list,
1951                &cache,
1952                Preview::default(),
1953            ) {
1954                python.into_interpreter().sys_executable().to_owned()
1955            } else {
1956                panic!("Could not find Python {python_version} for test\nTry `cargo run python install` first, or refer to CONTRIBUTING.md");
1957            }
1958        })
1959        .collect::<Vec<_>>();
1960
1961    assert!(
1962        python_versions.is_empty() || !selected_pythons.is_empty(),
1963        "Failed to fulfill requested test Python versions: {selected_pythons:?}"
1964    );
1965
1966    Ok(selected_pythons)
1967}
1968
1969#[derive(Debug, Copy, Clone)]
1970pub enum WindowsFilters {
1971    Platform,
1972    Universal,
1973}
1974
1975/// Helper method to apply filters to a string. Useful when `!uv_snapshot` cannot be used.
1976pub fn apply_filters<T: AsRef<str>>(mut snapshot: String, filters: impl AsRef<[(T, T)]>) -> String {
1977    for (matcher, replacement) in filters.as_ref() {
1978        // TODO(konstin): Cache regex compilation
1979        let re = Regex::new(matcher.as_ref()).expect("Do you need to regex::escape your filter?");
1980        if re.is_match(&snapshot) {
1981            snapshot = re.replace_all(&snapshot, replacement.as_ref()).to_string();
1982        }
1983    }
1984    snapshot
1985}
1986
1987/// Execute the command and format its output status, stdout and stderr into a snapshot string.
1988///
1989/// This function is derived from `insta_cmd`s `spawn_with_info`.
1990pub fn run_and_format<T: AsRef<str>>(
1991    command: impl BorrowMut<Command>,
1992    filters: impl AsRef<[(T, T)]>,
1993    function_name: &str,
1994    windows_filters: Option<WindowsFilters>,
1995    input: Option<&str>,
1996) -> (String, Output) {
1997    let (snapshot, output, _) =
1998        run_and_format_with_status(command, filters, function_name, windows_filters, input);
1999    (snapshot, output)
2000}
2001
2002/// Execute the command and format its output status, stdout and stderr into a snapshot string.
2003///
2004/// This function is derived from `insta_cmd`s `spawn_with_info`.
2005#[expect(clippy::print_stderr)]
2006pub fn run_and_format_with_status<T: AsRef<str>>(
2007    mut command: impl BorrowMut<Command>,
2008    filters: impl AsRef<[(T, T)]>,
2009    function_name: &str,
2010    windows_filters: Option<WindowsFilters>,
2011    input: Option<&str>,
2012) -> (String, Output, ExitStatus) {
2013    let program = command
2014        .borrow_mut()
2015        .get_program()
2016        .to_string_lossy()
2017        .to_string();
2018
2019    // Support profiling test run commands with traces.
2020    if let Ok(root) = env::var(EnvVars::TRACING_DURATIONS_TEST_ROOT) {
2021        // We only want to fail if the variable is set at runtime.
2022        #[allow(clippy::assertions_on_constants)]
2023        {
2024            assert!(
2025                cfg!(feature = "tracing-durations-export"),
2026                "You need to enable the tracing-durations-export feature to use `TRACING_DURATIONS_TEST_ROOT`"
2027            );
2028        }
2029        command.borrow_mut().env(
2030            EnvVars::TRACING_DURATIONS_FILE,
2031            Path::new(&root).join(function_name).with_extension("jsonl"),
2032        );
2033    }
2034
2035    let output = if let Some(input) = input {
2036        let mut child = command
2037            .borrow_mut()
2038            .stdin(Stdio::piped())
2039            .stdout(Stdio::piped())
2040            .stderr(Stdio::piped())
2041            .spawn()
2042            .unwrap_or_else(|err| panic!("Failed to spawn {program}: {err}"));
2043        child
2044            .stdin
2045            .as_mut()
2046            .expect("Failed to open stdin")
2047            .write_all(input.as_bytes())
2048            .expect("Failed to write to stdin");
2049
2050        child
2051            .wait_with_output()
2052            .unwrap_or_else(|err| panic!("Failed to read output from {program}: {err}"))
2053    } else {
2054        command
2055            .borrow_mut()
2056            .output()
2057            .unwrap_or_else(|err| panic!("Failed to spawn {program}: {err}"))
2058    };
2059
2060    eprintln!("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Unfiltered output ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
2061    eprintln!(
2062        "----- stdout -----\n{}\n----- stderr -----\n{}",
2063        String::from_utf8_lossy(&output.stdout),
2064        String::from_utf8_lossy(&output.stderr),
2065    );
2066    eprintln!("────────────────────────────────────────────────────────────────────────────────\n");
2067
2068    let mut snapshot = apply_filters(
2069        format!(
2070            "success: {:?}\nexit_code: {}\n----- stdout -----\n{}\n----- stderr -----\n{}",
2071            output.status.success(),
2072            output.status.code().unwrap_or(!0),
2073            String::from_utf8_lossy(&output.stdout),
2074            String::from_utf8_lossy(&output.stderr),
2075        ),
2076        filters,
2077    );
2078
2079    // This is a heuristic filter meant to try and make *most* of our tests
2080    // pass whether it's on Windows or Unix. In particular, there are some very
2081    // common Windows-only dependencies that, when removed from a resolution,
2082    // cause the set of dependencies to be the same across platforms.
2083    if cfg!(windows) {
2084        if let Some(windows_filters) = windows_filters {
2085            // The optional leading +/-/~ is for install logs, the optional next line is for lockfiles
2086            let windows_only_deps = [
2087                (r"( ?[-+~] ?)?colorama==\d+(\.\d+)+( [\\]\n\s+--hash=.*)?\n(\s+# via .*\n)?"),
2088                (r"( ?[-+~] ?)?colorama==\d+(\.\d+)+(\s+[-+~]?\s+# via .*)?\n"),
2089                (r"( ?[-+~] ?)?tzdata==\d+(\.\d+)+( [\\]\n\s+--hash=.*)?\n(\s+# via .*\n)?"),
2090                (r"( ?[-+~] ?)?tzdata==\d+(\.\d+)+(\s+[-+~]?\s+# via .*)?\n"),
2091            ];
2092            let mut removed_packages = 0;
2093            for windows_only_dep in windows_only_deps {
2094                // TODO(konstin): Cache regex compilation
2095                let re = Regex::new(windows_only_dep).unwrap();
2096                if re.is_match(&snapshot) {
2097                    snapshot = re.replace(&snapshot, "").to_string();
2098                    removed_packages += 1;
2099                }
2100            }
2101            if removed_packages > 0 {
2102                for i in 1..20 {
2103                    for verb in match windows_filters {
2104                        WindowsFilters::Platform => [
2105                            "Resolved",
2106                            "Prepared",
2107                            "Installed",
2108                            "Audited",
2109                            "Uninstalled",
2110                        ]
2111                        .iter(),
2112                        WindowsFilters::Universal => {
2113                            ["Prepared", "Installed", "Audited", "Uninstalled"].iter()
2114                        }
2115                    } {
2116                        snapshot = snapshot.replace(
2117                            &format!("{verb} {} packages", i + removed_packages),
2118                            &format!("{verb} {} package{}", i, if i > 1 { "s" } else { "" }),
2119                        );
2120                    }
2121                }
2122            }
2123        }
2124    }
2125
2126    let status = output.status;
2127    (snapshot, output, status)
2128}
2129
2130/// Recursively copy a directory and its contents, skipping gitignored files.
2131pub fn copy_dir_ignore(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> anyhow::Result<()> {
2132    for entry in ignore::Walk::new(&src) {
2133        let entry = entry?;
2134        let relative = entry.path().strip_prefix(&src)?;
2135        let ty = entry.file_type().unwrap();
2136        if ty.is_dir() {
2137            fs_err::create_dir(dst.as_ref().join(relative))?;
2138        } else {
2139            fs_err::copy(entry.path(), dst.as_ref().join(relative))?;
2140        }
2141    }
2142    Ok(())
2143}
2144
2145/// Create a stub package `name` in `dir` with the given `pyproject.toml` body.
2146pub fn make_project(dir: &Path, name: &str, body: &str) -> anyhow::Result<()> {
2147    let pyproject_toml = formatdoc! {r#"
2148        [project]
2149        name = "{name}"
2150        version = "0.1.0"
2151        requires-python = ">=3.11,<3.13"
2152        {body}
2153
2154        [build-system]
2155        requires = ["uv_build>=0.9.0,<10000"]
2156        build-backend = "uv_build"
2157        "#
2158    };
2159    fs_err::create_dir_all(dir)?;
2160    fs_err::write(dir.join("pyproject.toml"), pyproject_toml)?;
2161    fs_err::create_dir_all(dir.join("src").join(name))?;
2162    fs_err::write(dir.join("src").join(name).join("__init__.py"), "")?;
2163    Ok(())
2164}
2165
2166// This is a fine-grained token that only has read-only access to the `uv-private-pypackage` repository
2167pub const READ_ONLY_GITHUB_TOKEN: &[&str] = &[
2168    "Z2l0aHViCg==",
2169    "cGF0Cg==",
2170    "MTFBQlVDUjZBMERMUTQ3aVphN3hPdV9qQmhTMkZUeHZ4ZE13OHczakxuZndsV2ZlZjc2cE53eHBWS2tiRUFwdnpmUk8zV0dDSUhicDFsT01aago=",
2171];
2172
2173// This is a fine-grained token that only has read-only access to the `uv-private-pypackage-2` repository
2174#[cfg(not(windows))]
2175pub const READ_ONLY_GITHUB_TOKEN_2: &[&str] = &[
2176    "Z2l0aHViCg==",
2177    "cGF0Cg==",
2178    "MTFBQlVDUjZBMDJTOFYwMTM4YmQ0bV9uTXpueWhxZDBrcllROTQ5SERTeTI0dENKZ2lmdzIybDFSR2s1SE04QW8xTUVYQ1I0Q1YxYUdPRGpvZQo=",
2179];
2180
2181pub const READ_ONLY_GITHUB_SSH_DEPLOY_KEY: &str = "LS0tLS1CRUdJTiBPUEVOU1NIIFBSSVZBVEUgS0VZLS0tLS0KYjNCbGJuTnphQzFyWlhrdGRqRUFBQUFBQkc1dmJtVUFBQUFFYm05dVpRQUFBQUFBQUFBQkFBQUFNd0FBQUF0emMyZ3RaVwpReU5UVXhPUUFBQUNBeTF1SnNZK1JXcWp1NkdIY3Z6a3AwS21yWDEwdmo3RUZqTkpNTkRqSGZPZ0FBQUpqWUpwVnAyQ2FWCmFRQUFBQXR6YzJndFpXUXlOVFV4T1FBQUFDQXkxdUpzWStSV3FqdTZHSGN2emtwMEttclgxMHZqN0VGak5KTU5EakhmT2cKQUFBRUMwbzBnd1BxbGl6TFBJOEFXWDVaS2dVZHJyQ2ptMDhIQm9FenB4VDg3MXBqTFc0bXhqNUZhcU83b1lkeS9PU25RcQphdGZYUytQc1FXTTBrdzBPTWQ4NkFBQUFFR3R2Ym5OMGFVQmhjM1J5WVd3dWMyZ0JBZ01FQlE9PQotLS0tLUVORCBPUEVOU1NIIFBSSVZBVEUgS0VZLS0tLS0K";
2182
2183/// Decode a split, base64 encoded authentication token.
2184/// We split and encode the token to bypass revoke by GitHub's secret scanning
2185pub fn decode_token(content: &[&str]) -> String {
2186    content
2187        .iter()
2188        .map(|part| base64.decode(part).unwrap())
2189        .map(|decoded| {
2190            std::str::from_utf8(decoded.as_slice())
2191                .unwrap()
2192                .trim_end()
2193                .to_string()
2194        })
2195        .join("_")
2196}
2197
2198/// Simulates `reqwest::blocking::get` but returns bytes directly, and disables
2199/// certificate verification, passing through the `BaseClient`
2200#[tokio::main(flavor = "current_thread")]
2201pub async fn download_to_disk(url: &str, path: &Path) {
2202    let trusted_hosts: Vec<_> = env::var(EnvVars::UV_INSECURE_HOST)
2203        .unwrap_or_default()
2204        .split(' ')
2205        .map(|h| uv_configuration::TrustedHost::from_str(h).unwrap())
2206        .collect();
2207
2208    let client = uv_client::BaseClientBuilder::default()
2209        .allow_insecure_host(trusted_hosts)
2210        .build();
2211    let url = url.parse().unwrap();
2212    let response = client
2213        .for_host(&url)
2214        .get(reqwest::Url::from(url))
2215        .send()
2216        .await
2217        .unwrap();
2218
2219    let mut file = fs_err::tokio::File::create(path).await.unwrap();
2220    let mut stream = response.bytes_stream();
2221    while let Some(chunk) = stream.next().await {
2222        file.write_all(&chunk.unwrap()).await.unwrap();
2223    }
2224    file.sync_all().await.unwrap();
2225}
2226
2227/// A guard that sets a directory to read-only and restores original permissions when dropped.
2228///
2229/// This is useful for tests that need to make a directory read-only and ensure
2230/// the permissions are restored even if the test panics.
2231#[cfg(unix)]
2232pub struct ReadOnlyDirectoryGuard {
2233    path: PathBuf,
2234    original_mode: u32,
2235}
2236
2237#[cfg(unix)]
2238impl ReadOnlyDirectoryGuard {
2239    /// Sets the directory to read-only (removes write permission) and returns a guard
2240    /// that will restore the original permissions when dropped.
2241    pub fn new(path: impl Into<PathBuf>) -> std::io::Result<Self> {
2242        use std::os::unix::fs::PermissionsExt;
2243        let path = path.into();
2244        let metadata = fs_err::metadata(&path)?;
2245        let original_mode = metadata.permissions().mode();
2246        // Remove write permissions (keep read and execute)
2247        let readonly_mode = original_mode & !0o222;
2248        fs_err::set_permissions(&path, std::fs::Permissions::from_mode(readonly_mode))?;
2249        Ok(Self {
2250            path,
2251            original_mode,
2252        })
2253    }
2254}
2255
2256#[cfg(unix)]
2257impl Drop for ReadOnlyDirectoryGuard {
2258    fn drop(&mut self) {
2259        use std::os::unix::fs::PermissionsExt;
2260        let _ = fs_err::set_permissions(
2261            &self.path,
2262            std::fs::Permissions::from_mode(self.original_mode),
2263        );
2264    }
2265}
2266
2267/// Utility macro to return the name of the current function.
2268///
2269/// https://stackoverflow.com/a/40234666/3549270
2270#[doc(hidden)]
2271#[macro_export]
2272macro_rules! function_name {
2273    () => {{
2274        fn f() {}
2275        fn type_name_of_val<T>(_: T) -> &'static str {
2276            std::any::type_name::<T>()
2277        }
2278        let mut name = type_name_of_val(f).strip_suffix("::f").unwrap_or("");
2279        while let Some(rest) = name.strip_suffix("::{{closure}}") {
2280            name = rest;
2281        }
2282        name
2283    }};
2284}
2285
2286/// Run [`assert_cmd_snapshot!`], with default filters or with custom filters.
2287///
2288/// By default, the filters will search for the generally windows-only deps colorama and tzdata,
2289/// filter them out and decrease the package counts by one for each match.
2290#[macro_export]
2291macro_rules! uv_snapshot {
2292    ($spawnable:expr, @$snapshot:literal) => {{
2293        uv_snapshot!($crate::INSTA_FILTERS.to_vec(), $spawnable, @$snapshot)
2294    }};
2295    ($filters:expr, $spawnable:expr, @$snapshot:literal) => {{
2296        // Take a reference for backwards compatibility with the vec-expecting insta filters.
2297        let (snapshot, output) = $crate::run_and_format($spawnable, &$filters, $crate::function_name!(), Some($crate::WindowsFilters::Platform), None);
2298        ::insta::assert_snapshot!(snapshot, @$snapshot);
2299        output
2300    }};
2301    ($filters:expr, $spawnable:expr, input=$input:expr, @$snapshot:literal) => {{
2302        // Take a reference for backwards compatibility with the vec-expecting insta filters.
2303        let (snapshot, output) = $crate::run_and_format($spawnable, &$filters, $crate::function_name!(), Some($crate::WindowsFilters::Platform), Some($input));
2304        ::insta::assert_snapshot!(snapshot, @$snapshot);
2305        output
2306    }};
2307    ($filters:expr, windows_filters=false, $spawnable:expr, @$snapshot:literal) => {{
2308        // Take a reference for backwards compatibility with the vec-expecting insta filters.
2309        let (snapshot, output) = $crate::run_and_format($spawnable, &$filters, $crate::function_name!(), None, None);
2310        ::insta::assert_snapshot!(snapshot, @$snapshot);
2311        output
2312    }};
2313    ($filters:expr, universal_windows_filters=true, $spawnable:expr, @$snapshot:literal) => {{
2314        // Take a reference for backwards compatibility with the vec-expecting insta filters.
2315        let (snapshot, output) = $crate::run_and_format($spawnable, &$filters, $crate::function_name!(), Some($crate::WindowsFilters::Universal), None);
2316        ::insta::assert_snapshot!(snapshot, @$snapshot);
2317        output
2318    }};
2319}