diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 4387fc58f741..6cdcf04e7665 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -4557,6 +4557,11 @@ pub enum PythonCommand { #[derive(Args)] #[allow(clippy::struct_excessive_bools)] pub struct PythonListArgs { + /// A Python request to filter by. + /// + /// See `uv help python` to view supported request formats. + pub request: Option, + /// List all Python versions, including old patch versions. /// /// By default, only the latest patch version is shown for each minor version. diff --git a/crates/uv/src/commands/python/list.rs b/crates/uv/src/commands/python/list.rs index e818af4f3dba..d71850aca822 100644 --- a/crates/uv/src/commands/python/list.rs +++ b/crates/uv/src/commands/python/list.rs @@ -52,6 +52,7 @@ struct PrintData { /// List available Python installations. #[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)] pub(crate) async fn list( + request: Option, kinds: PythonListKinds, all_versions: bool, all_platforms: bool, @@ -63,23 +64,31 @@ pub(crate) async fn list( cache: &Cache, printer: Printer, ) -> Result { + let request = request.as_deref().map(PythonRequest::parse); + let base_download_request = if python_preference == PythonPreference::OnlySystem { + None + } else { + // If the user request cannot be mapped to a download request, we won't show any downloads + PythonDownloadRequest::from_request(request.as_ref().unwrap_or(&PythonRequest::Any)) + }; + let mut output = BTreeSet::new(); - if python_preference != PythonPreference::OnlySystem { + if let Some(base_download_request) = base_download_request { let download_request = match kinds { PythonListKinds::Installed => None, PythonListKinds::Downloads => Some(if all_platforms { - PythonDownloadRequest::default() + base_download_request } else { - PythonDownloadRequest::from_env()? + base_download_request.fill()? }), PythonListKinds::Default => { if python_downloads.is_automatic() { Some(if all_platforms { - PythonDownloadRequest::default() + base_download_request } else if all_arches { - PythonDownloadRequest::from_env()?.with_any_arch() + base_download_request.fill()?.with_any_arch() } else { - PythonDownloadRequest::from_env()? + base_download_request.fill()? }) } else { // If fetching is not automatic, then don't show downloads as available by default @@ -109,7 +118,7 @@ pub(crate) async fn list( match kinds { PythonListKinds::Installed | PythonListKinds::Default => { Some(find_python_installations( - &PythonRequest::Any, + request.as_ref().unwrap_or(&PythonRequest::Any), EnvironmentPreference::OnlySystem, python_preference, cache, diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index f5a5e25f8a1c..49ef86e9cb6f 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1244,6 +1244,7 @@ async fn run(mut cli: Cli) -> Result { let cache = cache.init()?; commands::python_list( + args.request, args.kinds, args.all_versions, args.all_platforms, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 8e916178cc63..c105cebd3feb 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -820,6 +820,7 @@ pub(crate) enum PythonListKinds { #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone)] pub(crate) struct PythonListSettings { + pub(crate) request: Option, pub(crate) kinds: PythonListKinds, pub(crate) all_platforms: bool, pub(crate) all_arches: bool, @@ -833,6 +834,7 @@ impl PythonListSettings { #[allow(clippy::needless_pass_by_value)] pub(crate) fn resolve(args: PythonListArgs, _filesystem: Option) -> Self { let PythonListArgs { + request, all_versions, all_platforms, all_arches, @@ -851,6 +853,7 @@ impl PythonListSettings { }; Self { + request, kinds, all_platforms, all_arches, diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index 7407cba7c98b..1bec68666958 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -205,10 +205,10 @@ impl TestContext { self.filters .push(("python.exe".to_string(), "python".to_string())); } else { - self.filters - .push((r"python\d".to_string(), "python".to_string())); self.filters .push((r"python\d.\d\d".to_string(), "python".to_string())); + self.filters + .push((r"python\d".to_string(), "python".to_string())); } self } @@ -224,6 +224,46 @@ impl TestContext { self } + /// Add extra standard filtering for Python installation `bin/` directories, which are not + /// present on Windows but are on Unix. See [`TestContext::with_filtered_virtualenv_bin`] for + /// the virtual environment equivalent. + #[must_use] + pub fn with_filtered_python_install_bin(mut self) -> Self { + if cfg!(unix) { + self.filters.push(( + r"[\\/]bin/python".to_string(), + "/[INSTALL-BIN]/python".to_string(), + )); + } else { + self.filters.push(( + r"[\\/]/python".to_string(), + "/[INSTALL-BIN]/python".to_string(), + )); + } + self + } + + /// Add extra filtering for ` -> ` symlink display for Python versions in the test + /// context, e.g., for use in `uv python list`. + #[must_use] + pub fn with_filtered_python_symlinks(mut self) -> Self { + for (version, executable) in &self.python_versions { + if fs_err::symlink_metadata(executable).unwrap().is_symlink() { + self.filters.extend( + Self::path_patterns(executable.read_link().unwrap()) + .into_iter() + .map(|pattern| (format! {" -> {pattern}"}, String::new())), + ); + } + // Drop links that are byproducts of the test context too + self.filters.push(( + regex::escape(&format!(" -> [PYTHON-{version}]")), + String::new(), + )); + } + self + } + /// Add extra standard filtering for a given path. #[must_use] pub fn with_filtered_path(mut self, path: &Path, name: &str) -> Self { @@ -256,11 +296,33 @@ impl TestContext { /// Adds a filter that ignores platform information in a Python installation key. pub fn with_filtered_python_keys(mut self) -> Self { // Filter platform keys - self.filters.push(( - r"((?:cpython|pypy)-\d+\.\d+(?:\.(?:\[X\]|\d+))?[a-z]?(?:\+[a-z]+)?)-[a-z0-9]+-[a-z0-9_]+-[a-z]+" - .to_string(), - "$1-[PLATFORM]".to_string(), - )); + let platform_re = r"(?x) + ( # We capture the group before the platform + (?:cpython|pypy) # Python implementation + - + \d+\.\d+ # Major and minor version + (?: # The patch version is handled separately + \. + (?: + \[X\] # A previously filtered patch version [X] + | # OR + \d+ # An actual patch version + ) + )? # (we allow the patch version to be missing entirely, e.g., in a request) + ([a-z]+[0-9]*)? # Pre-release version component, e.g., `a6` or `rc2` + (?: + \+[a-z]+ # An optional variant variant, such as `+free-threaded + )? + ) + - + [a-z0-9]+ # Operating system (e.g., 'macos') + - + [a-z0-9_]+ # Architecture (e.g., 'aarch64') + - + [a-z]+ # Libc (e.g., 'none') +"; + self.filters + .push((platform_re.to_string(), "$1-[PLATFORM]".to_string())); self } @@ -803,6 +865,18 @@ impl TestContext { command } + /// Create a `uv python list` command with options shared across scenarios. + pub fn python_list(&self) -> Command { + let mut command = self.new_command(); + command + .arg("python") + .arg("list") + .env(EnvVars::UV_PYTHON_INSTALL_DIR, "") + .current_dir(&self.temp_dir); + self.add_shared_options(&mut command, false); + command + } + /// Create a `uv python install` command with options shared across scenarios. pub fn python_install(&self) -> Command { let mut command = self.new_command(); diff --git a/crates/uv/tests/it/main.rs b/crates/uv/tests/it/main.rs index 5ffe6349e722..9632c9a3b66a 100644 --- a/crates/uv/tests/it/main.rs +++ b/crates/uv/tests/it/main.rs @@ -72,6 +72,9 @@ mod python_dir; #[cfg(feature = "python")] mod python_find; +#[cfg(feature = "python")] +mod python_list; + #[cfg(feature = "python-managed")] mod python_install; diff --git a/crates/uv/tests/it/python_list.rs b/crates/uv/tests/it/python_list.rs new file mode 100644 index 000000000000..02efb521a183 --- /dev/null +++ b/crates/uv/tests/it/python_list.rs @@ -0,0 +1,393 @@ +use uv_python::platform::{Arch, Os}; +use uv_static::EnvVars; + +use crate::common::{uv_snapshot, TestContext}; + +#[test] +fn python_list() { + let mut context: TestContext = TestContext::new_with_versions(&["3.11", "3.12"]) + .with_filtered_python_symlinks() + .with_filtered_python_keys(); + + uv_snapshot!(context.filters(), context.python_list().env(EnvVars::UV_TEST_PYTHON_PATH, ""), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + "); + + // We show all interpreters + uv_snapshot!(context.filters(), context.python_list(), @r" + success: true + exit_code: 0 + ----- stdout ----- + cpython-3.12.[X]-[PLATFORM] [PYTHON-3.12] + cpython-3.11.[X]-[PLATFORM] [PYTHON-3.11] + + ----- stderr ----- + "); + + // Request Python 3.12 + uv_snapshot!(context.filters(), context.python_list().arg("3.12"), @r" + success: true + exit_code: 0 + ----- stdout ----- + cpython-3.12.[X]-[PLATFORM] [PYTHON-3.12] + + ----- stderr ----- + "); + + // Request Python 3.11 + uv_snapshot!(context.filters(), context.python_list().arg("3.11"), @r" + success: true + exit_code: 0 + ----- stdout ----- + cpython-3.11.[X]-[PLATFORM] [PYTHON-3.11] + + ----- stderr ----- + "); + + // Request CPython + uv_snapshot!(context.filters(), context.python_list().arg("cpython"), @r" + success: true + exit_code: 0 + ----- stdout ----- + cpython-3.12.[X]-[PLATFORM] [PYTHON-3.12] + cpython-3.11.[X]-[PLATFORM] [PYTHON-3.11] + + ----- stderr ----- + "); + + // Request CPython 3.12 + uv_snapshot!(context.filters(), context.python_list().arg("cpython@3.12"), @r" + success: true + exit_code: 0 + ----- stdout ----- + cpython-3.12.[X]-[PLATFORM] [PYTHON-3.12] + + ----- stderr ----- + "); + + // Request CPython 3.12 via partial key syntax + uv_snapshot!(context.filters(), context.python_list().arg("cpython-3.12"), @r" + success: true + exit_code: 0 + ----- stdout ----- + cpython-3.12.[X]-[PLATFORM] [PYTHON-3.12] + + ----- stderr ----- + "); + + // Request CPython 3.12 for the current platform + let os = Os::from_env(); + let arch = Arch::from_env(); + + uv_snapshot!(context.filters(), context.python_list().arg(format!("cpython-3.12-{os}-{arch}")), @r" + success: true + exit_code: 0 + ----- stdout ----- + cpython-3.12.[X]-[PLATFORM] [PYTHON-3.12] + + ----- stderr ----- + "); + + // Request PyPy (which should be missing) + uv_snapshot!(context.filters(), context.python_list().arg("pypy"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + "); + + // Swap the order of the Python versions + context.python_versions.reverse(); + + uv_snapshot!(context.filters(), context.python_list(), @r" + success: true + exit_code: 0 + ----- stdout ----- + cpython-3.12.[X]-[PLATFORM] [PYTHON-3.12] + cpython-3.11.[X]-[PLATFORM] [PYTHON-3.11] + + ----- stderr ----- + "); + + // Request Python 3.11 + uv_snapshot!(context.filters(), context.python_list().arg("3.11"), @r" + success: true + exit_code: 0 + ----- stdout ----- + cpython-3.11.[X]-[PLATFORM] [PYTHON-3.11] + + ----- stderr ----- + "); +} + +#[test] +fn python_list_pin() { + let context: TestContext = TestContext::new_with_versions(&["3.11", "3.12"]) + .with_filtered_python_symlinks() + .with_filtered_python_keys(); + + // Pin to a version + uv_snapshot!(context.filters(), context.python_pin().arg("3.12"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Pinned `.python-version` to `3.12` + + ----- stderr ----- + "###); + + // The pin should not affect the listing + uv_snapshot!(context.filters(), context.python_list(), @r" + success: true + exit_code: 0 + ----- stdout ----- + cpython-3.12.[X]-[PLATFORM] [PYTHON-3.12] + cpython-3.11.[X]-[PLATFORM] [PYTHON-3.11] + + ----- stderr ----- + "); + + // So `--no-config` has no effect + uv_snapshot!(context.filters(), context.python_list().arg("--no-config"), @r" + success: true + exit_code: 0 + ----- stdout ----- + cpython-3.12.[X]-[PLATFORM] [PYTHON-3.12] + cpython-3.11.[X]-[PLATFORM] [PYTHON-3.11] + + ----- stderr ----- + "); +} + +#[test] +fn python_list_venv() { + let context: TestContext = TestContext::new_with_versions(&["3.11", "3.12"]) + .with_filtered_python_symlinks() + .with_filtered_python_keys() + .with_filtered_exe_suffix() + .with_filtered_python_names() + .with_filtered_virtualenv_bin(); + + // Create a virtual environment + uv_snapshot!(context.filters(), context.venv().arg("--python").arg("3.12").arg("-q"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + "###); + + // We should not display the virtual environment + uv_snapshot!(context.filters(), context.python_list(), @r" + success: true + exit_code: 0 + ----- stdout ----- + cpython-3.12.[X]-[PLATFORM] [PYTHON-3.12] + cpython-3.11.[X]-[PLATFORM] [PYTHON-3.11] + + ----- stderr ----- + "); + + // Same if the `VIRTUAL_ENV` is not set (the test context includes it by default) + uv_snapshot!(context.filters(), context.python_list().env_remove(EnvVars::VIRTUAL_ENV), @r" + success: true + exit_code: 0 + ----- stdout ----- + cpython-3.12.[X]-[PLATFORM] [PYTHON-3.12] + cpython-3.11.[X]-[PLATFORM] [PYTHON-3.11] + + ----- stderr ----- + "); +} + +#[cfg(unix)] +#[test] +fn python_list_unsupported_version() { + let context: TestContext = TestContext::new_with_versions(&["3.12"]) + .with_filtered_python_symlinks() + .with_filtered_python_keys(); + + // Request a low version + uv_snapshot!(context.filters(), context.python_list().arg("3.6"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Invalid version request: Python <3.7 is not supported but 3.6 was requested. + "); + + // Request a low version with a patch + uv_snapshot!(context.filters(), context.python_list().arg("3.6.9"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Invalid version request: Python <3.7 is not supported but 3.6.9 was requested. + "); + + // Request a really low version + uv_snapshot!(context.filters(), context.python_list().arg("2.6"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Invalid version request: Python <3.7 is not supported but 2.6 was requested. + "); + + // Request a really low version with a patch + uv_snapshot!(context.filters(), context.python_list().arg("2.6.8"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Invalid version request: Python <3.7 is not supported but 2.6.8 was requested. + "); + + // Request a future version + uv_snapshot!(context.filters(), context.python_list().arg("4.2"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + "); + + // Request a low version with a range + uv_snapshot!(context.filters(), context.python_list().arg("<3.0"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + "); + + // Request free-threaded Python on unsupported version + uv_snapshot!(context.filters(), context.python_list().arg("3.12t"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Invalid version request: Python <3.13 does not support free-threading but 3.12t was requested. + "); +} + +#[test] +fn python_list_downloads() { + let context: TestContext = TestContext::new_with_versions(&[]).with_filtered_python_keys(); + + // We do not test showing all interpreters — as it differs per platform + // Instead, we choose a Python version where our available distributions are stable + + // Test the default display, which requires reverting the test context disabling Python downloads + uv_snapshot!(context.filters(), context.python_list().arg("3.10").env_remove("UV_PYTHON_DOWNLOADS"), @r" + success: true + exit_code: 0 + ----- stdout ----- + cpython-3.10.16-[PLATFORM] + + ----- stderr ----- + "); + + // Show patch versions + uv_snapshot!(context.filters(), context.python_list().arg("3.10").arg("--all-versions").env_remove("UV_PYTHON_DOWNLOADS"), @r" + success: true + exit_code: 0 + ----- stdout ----- + cpython-3.10.16-[PLATFORM] + cpython-3.10.15-[PLATFORM] + cpython-3.10.14-[PLATFORM] + cpython-3.10.13-[PLATFORM] + cpython-3.10.12-[PLATFORM] + cpython-3.10.11-[PLATFORM] + cpython-3.10.9-[PLATFORM] + cpython-3.10.8-[PLATFORM] + cpython-3.10.7-[PLATFORM] + cpython-3.10.6-[PLATFORM] + cpython-3.10.5-[PLATFORM] + cpython-3.10.4-[PLATFORM] + cpython-3.10.3-[PLATFORM] + cpython-3.10.2-[PLATFORM] + cpython-3.10.0-[PLATFORM] + + ----- stderr ----- + "); +} + +#[test] +#[cfg(feature = "python-managed")] +fn python_list_downloads_installed() { + use assert_cmd::assert::OutputAssertExt; + + let context: TestContext = TestContext::new_with_versions(&[]) + .with_filtered_python_keys() + .with_filtered_python_names() + .with_filtered_python_install_bin() + .with_managed_python_dirs(); + + // We do not test showing all interpreters — as it differs per platform + // Instead, we choose a Python version where our available distributions are stable + + // First, the download is shown as available + uv_snapshot!(context.filters(), context.python_list().arg("3.10").env_remove("UV_PYTHON_DOWNLOADS"), @r" + success: true + exit_code: 0 + ----- stdout ----- + cpython-3.10.16-[PLATFORM] + + ----- stderr ----- + "); + + // TODO(zanieb): It'd be nice to test `--show-urls` here too but we need special filtering for + // the URL + + // But not if `--only-installed` is used + uv_snapshot!(context.filters(), context.python_list().arg("3.10").arg("--only-installed").env_remove("UV_PYTHON_DOWNLOADS"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + "); + + // Install a Python version + context.python_install().arg("3.10").assert().success(); + + // Then, it should be listed as installed instead of available + uv_snapshot!(context.filters(), context.python_list().arg("3.10").env_remove("UV_PYTHON_DOWNLOADS"), @r" + success: true + exit_code: 0 + ----- stdout ----- + cpython-3.10.16-[PLATFORM] managed/cpython-3.10.16-[PLATFORM]/[INSTALL-BIN]/python + + ----- stderr ----- + "); + + // But, the display should be reverted if `--only-downloads` is used + uv_snapshot!(context.filters(), context.python_list().arg("3.10").arg("--only-downloads").env_remove("UV_PYTHON_DOWNLOADS"), @r" + success: true + exit_code: 0 + ----- stdout ----- + cpython-3.10.16-[PLATFORM] + + ----- stderr ----- + "); + + // And should not be shown if `--no-managed-python` is used + uv_snapshot!(context.filters(), context.python_list().arg("3.10").arg("--no-managed-python").env_remove("UV_PYTHON_DOWNLOADS"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + "); +} diff --git a/docs/concepts/python-versions.md b/docs/concepts/python-versions.md index 73b4510fd063..1e2d13a2290c 100644 --- a/docs/concepts/python-versions.md +++ b/docs/concepts/python-versions.md @@ -173,6 +173,18 @@ To list installed and available Python versions: $ uv python list ``` +To filter the Python versions, provide a request, e.g., to show all Python 3.13 interpreters: + +```console +$ uv python list 3.13 +``` + +Or, to show all PyPy interpreters: + +```console +$ uv python list pypy +``` + By default, downloads for other platforms and old patch versions are hidden. To view all versions: diff --git a/docs/reference/cli.md b/docs/reference/cli.md index d9c428a8146f..7abbd91168e5 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -4591,9 +4591,17 @@ Use `--only-installed` to omit available downloads.

Usage

``` -uv python list [OPTIONS] +uv python list [OPTIONS] [REQUEST] ``` +

Arguments

+ +
REQUEST

A Python request to filter by.

+ +

See uv python to view supported request formats.

+ +
+

Options

--all-arches

List Python downloads for all architectures.