From ba48a8ad8c99d223fea4bd2b54d4737b5568d1ab Mon Sep 17 00:00:00 2001 From: John Mumm Date: Fri, 7 Mar 2025 16:37:34 +0100 Subject: [PATCH 1/7] Add support for global `uv python pin` --- Cargo.lock | 1 + crates/uv-cli/src/lib.rs | 9 ++ crates/uv-dirs/src/lib.rs | 7 ++ crates/uv-pypi-types/src/conflicts.rs | 2 - crates/uv-python/src/version_files.rs | 25 +++- crates/uv/Cargo.toml | 1 + crates/uv/src/commands/python/pin.rs | 26 ++++- crates/uv/src/lib.rs | 1 + crates/uv/src/settings.rs | 3 + crates/uv/tests/it/common/mod.rs | 12 ++ crates/uv/tests/it/python_pin.rs | 128 +++++++++++++++++++- crates/uv/tests/it/show_settings.rs | 3 +- crates/uv/tests/it/tool_install.rs | 162 +++++++++++++++++++++++++- docs/reference/cli.md | 4 + 14 files changed, 372 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 26dd902abba0a..25929a9f8e144 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4579,6 +4579,7 @@ dependencies = [ "uv-client", "uv-configuration", "uv-console", + "uv-dirs", "uv-dispatch", "uv-distribution", "uv-distribution-filename", diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index af874e3e4d3a4..1b66e0aa2d854 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -4676,6 +4676,15 @@ pub struct PythonPinArgs { /// `requires-python` constraint. #[arg(long, alias = "no-workspace")] pub no_project: bool, + + /// Pin the global (user-level) Python version. + /// + /// This causes uv to write the specified version to a user-level + /// `.python-version` file. This will be used as a fallback for any + /// project that doesn't contain a `.python-version` file in its + /// local directory or ancestor directories. + #[arg(long)] + pub global: bool, } #[derive(Args)] diff --git a/crates/uv-dirs/src/lib.rs b/crates/uv-dirs/src/lib.rs index 23c39b9b07b6a..c5710b8a54bdf 100644 --- a/crates/uv-dirs/src/lib.rs +++ b/crates/uv-dirs/src/lib.rs @@ -103,6 +103,13 @@ pub fn user_config_dir() -> Option { .ok() } +pub fn user_uv_config_dir() -> Option { + user_config_dir().map(|mut path| { + path.push("uv"); + path + }) +} + #[cfg(not(windows))] fn locate_system_config_xdg(value: Option<&str>) -> Option { // On Linux and macOS, read the `XDG_CONFIG_DIRS` environment variable. diff --git a/crates/uv-pypi-types/src/conflicts.rs b/crates/uv-pypi-types/src/conflicts.rs index 4c65eb1b61d8d..94366bfd2f9c4 100644 --- a/crates/uv-pypi-types/src/conflicts.rs +++ b/crates/uv-pypi-types/src/conflicts.rs @@ -149,8 +149,6 @@ impl Conflicts { } let Ok(topo_nodes) = toposort(&graph, None) else { - // FIXME: If we hit a cycle, we are currently bailing and waiting for - // more detailed cycle detection downstream. Is this what we want? return; }; // Propagate canonical items through the graph and populate substitutions. diff --git a/crates/uv-python/src/version_files.rs b/crates/uv-python/src/version_files.rs index 4e243b07e8c23..475afe81c2193 100644 --- a/crates/uv-python/src/version_files.rs +++ b/crates/uv-python/src/version_files.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use fs_err as fs; use itertools::Itertools; use tracing::debug; +use uv_dirs::user_uv_config_dir; use uv_fs::Simplified; use crate::PythonRequest; @@ -69,7 +70,13 @@ impl PythonVersionFile { options: &DiscoveryOptions<'_>, ) -> Result, std::io::Error> { let Some(path) = Self::find_nearest(working_directory, options) else { - return Ok(None); + // Not found in directory or its ancestors. Looking in user-level config. + return Ok(match user_uv_config_dir() { + Some(user_dir) => Self::discover_user_config(user_dir, options) + .await? + .or(None), + None => None, + }); }; if options.no_config { @@ -84,6 +91,22 @@ impl PythonVersionFile { Self::try_from_path(path).await } + pub async fn discover_user_config( + user_config_working_directory: impl AsRef, + options: &DiscoveryOptions<'_>, + ) -> Result, std::io::Error> { + if !options.no_config { + if let Some(path) = + Self::find_in_directory(user_config_working_directory.as_ref(), options) + .into_iter() + .find(|path| path.is_file()) + { + return Self::try_from_path(path).await; + } + } + Ok(None) + } + fn find_nearest(path: impl AsRef, options: &DiscoveryOptions<'_>) -> Option { path.as_ref() .ancestors() diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index 5d2465d1cf42a..730eba454149d 100644 --- a/crates/uv/Cargo.toml +++ b/crates/uv/Cargo.toml @@ -24,6 +24,7 @@ uv-cli = { workspace = true } uv-client = { workspace = true } uv-configuration = { workspace = true } uv-console = { workspace = true } +uv-dirs = { workspace = true } uv-dispatch = { workspace = true } uv-distribution = { workspace = true } uv-distribution-filename = { workspace = true } diff --git a/crates/uv/src/commands/python/pin.rs b/crates/uv/src/commands/python/pin.rs index 5d728e2f63100..33bc07dd40218 100644 --- a/crates/uv/src/commands/python/pin.rs +++ b/crates/uv/src/commands/python/pin.rs @@ -7,6 +7,7 @@ use owo_colors::OwoColorize; use tracing::debug; use uv_cache::Cache; +use uv_dirs::user_uv_config_dir; use uv_fs::Simplified; use uv_python::{ EnvironmentPreference, PythonInstallation, PythonPreference, PythonRequest, PythonVersionFile, @@ -25,6 +26,7 @@ pub(crate) async fn pin( resolved: bool, python_preference: PythonPreference, no_project: bool, + global: bool, cache: &Cache, printer: Printer, ) -> Result { @@ -43,8 +45,16 @@ pub(crate) async fn pin( } }; - let version_file = - PythonVersionFile::discover(project_dir, &VersionFileDiscoveryOptions::default()).await; + let version_file = if global { + if let Some(path) = user_uv_config_dir() { + PythonVersionFile::discover_user_config(path, &VersionFileDiscoveryOptions::default()) + .await + } else { + Ok(None) + } + } else { + PythonVersionFile::discover(project_dir, &VersionFileDiscoveryOptions::default()).await + }; let Some(request) = request else { // Display the current pinned Python version @@ -130,8 +140,16 @@ pub(crate) async fn pin( let existing = version_file.ok().flatten(); // TODO(zanieb): Allow updating the discovered version file with an `--update` flag. - let new = PythonVersionFile::new(project_dir.join(PYTHON_VERSION_FILENAME)) - .with_versions(vec![request]); + let new = if global { + let Some(config_dir) = user_uv_config_dir() else { + return Err(anyhow::anyhow!("No user-level config directory found.")); + }; + PythonVersionFile::new(config_dir.join(PYTHON_VERSION_FILENAME)) + .with_versions(vec![request]) + } else { + PythonVersionFile::new(project_dir.join(PYTHON_VERSION_FILENAME)) + .with_versions(vec![request]) + }; new.write().await?; diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 0c27b352d8e17..c66489f5a82a8 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1257,6 +1257,7 @@ async fn run(mut cli: Cli) -> Result { args.resolved, globals.python_preference, args.no_project, + args.global, &cache, printer, ) diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index c1053f5b9536b..71e7db36da215 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -979,6 +979,7 @@ pub(crate) struct PythonPinSettings { pub(crate) request: Option, pub(crate) resolved: bool, pub(crate) no_project: bool, + pub(crate) global: bool, } impl PythonPinSettings { @@ -990,12 +991,14 @@ impl PythonPinSettings { no_resolved, resolved, no_project, + global, } = args; Self { request, resolved: flag(resolved, no_resolved).unwrap_or(false), no_project, + global, } } } diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index 6b1a255619df7..b9188019a46b7 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -89,6 +89,7 @@ pub struct TestContext { pub cache_dir: ChildPath, pub python_dir: ChildPath, pub home_dir: ChildPath, + pub user_config_dir: ChildPath, pub bin_dir: ChildPath, pub venv: ChildPath, pub workspace_root: PathBuf, @@ -357,6 +358,10 @@ impl TestContext { let home_dir = ChildPath::new(root.path()).child("home"); fs_err::create_dir_all(&home_dir).expect("Failed to create test home directory"); + let user_config_dir = ChildPath::new(home_dir.path()).child(".config"); + fs_err::create_dir_all(&user_config_dir) + .expect("Failed to create test user config directory"); + // Canonicalize the temp dir for consistent snapshot behavior let canonical_temp_dir = temp_dir.canonicalize().unwrap(); let venv = ChildPath::new(canonical_temp_dir.join(".venv")); @@ -477,6 +482,11 @@ impl TestContext { .into_iter() .map(|pattern| (pattern, "[HOME]/".to_string())), ); + filters.extend( + Self::path_patterns(&user_config_dir) + .into_iter() + .map(|pattern| (pattern, "[USER_CONFIG_DIR]/".to_string())), + ); filters.extend( Self::path_patterns(&workspace_root) .into_iter() @@ -532,6 +542,7 @@ impl TestContext { cache_dir, python_dir, home_dir, + user_config_dir, bin_dir, venv, workspace_root, @@ -606,6 +617,7 @@ impl TestContext { .env(EnvVars::COLUMNS, "100") .env(EnvVars::PATH, path) .env(EnvVars::HOME, self.home_dir.as_os_str()) + .env(EnvVars::XDG_CONFIG_HOME, self.user_config_dir.as_os_str()) .env(EnvVars::UV_PYTHON_INSTALL_DIR, "") // Installations are not allowed by default; see `Self::with_managed_python_dirs` .env(EnvVars::UV_PYTHON_DOWNLOADS, "never") diff --git a/crates/uv/tests/it/python_pin.rs b/crates/uv/tests/it/python_pin.rs index c814b83d4d094..c8427cacbdfd0 100644 --- a/crates/uv/tests/it/python_pin.rs +++ b/crates/uv/tests/it/python_pin.rs @@ -1,11 +1,12 @@ use crate::common::{uv_snapshot, TestContext}; use anyhow::Result; -use assert_fs::fixture::{FileWriteStr, PathChild}; +use assert_fs::fixture::{FileWriteStr, PathChild, PathCreateDir}; use insta::assert_snapshot; use uv_python::{ platform::{Arch, Os}, PYTHON_VERSIONS_FILENAME, PYTHON_VERSION_FILENAME, }; +use uv_static::EnvVars; #[test] fn python_pin() { @@ -198,6 +199,131 @@ fn python_pin() { } } +#[test] +fn python_pin_use_global_if_no_local() -> Result<()> { + let context: TestContext = TestContext::new_with_versions(&["3.11", "3.12"]); + + let xdg = assert_fs::TempDir::new().expect("Failed to create temp dir"); + let uv = xdg.child("uv"); + + // Without arguments, we attempt to read the current pin (which does not exist yet) + uv_snapshot!(context.filters(), context.python_pin().env(EnvVars::XDG_CONFIG_HOME, xdg.path()), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No pinned Python version found + "###); + + let versions = uv.child(".python-version"); + versions.write_str("3.11")?; + + // If no local pin, use global. + uv_snapshot!(context.filters(), context.python_pin().env(EnvVars::XDG_CONFIG_HOME, xdg.path()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + 3.11 + + ----- stderr ----- + "###); + + Ok(()) +} + +#[test] +fn python_pin_global() -> Result<()> { + let xdg = assert_fs::TempDir::new().expect("Failed to create temp dir"); + let uv = xdg.child("uv"); + uv.create_dir_all()?; + + let context: TestContext = TestContext::new_with_versions(&["3.11", "3.12"]) + .with_filtered_path(uv.as_ref(), "USER_CONFIG_PATH"); + + // Without arguments, we attempt to read the current pin (which does not exist yet) + uv_snapshot!(context.filters(), context.python_pin().env(EnvVars::XDG_CONFIG_HOME, xdg.path()), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No pinned Python version found + "###); + + // Given an argument, we globally pin to that version + uv_snapshot!(context.filters(), context.python_pin().arg("any").arg("--global").env(EnvVars::XDG_CONFIG_HOME, xdg.path()), @r" + success: true + exit_code: 0 + ----- stdout ----- + Pinned `[USER_CONFIG_PATH]/.python-version` to `any` + + ----- stderr ----- + "); + + // Without arguments, we read the current pin + uv_snapshot!(context.filters(), context.python_pin().env(EnvVars::XDG_CONFIG_HOME, xdg.path()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + any + + ----- stderr ----- + "###); + + // Request Python 3.12 globally + uv_snapshot!(context.filters(), context.python_pin().arg("3.12").arg("--global").env(EnvVars::XDG_CONFIG_HOME, xdg.path()), @r" + success: true + exit_code: 0 + ----- stdout ----- + Updated `[USER_CONFIG_PATH]/.python-version` from `any` -> `3.12` + + ----- stderr ----- + "); + + // With no local, we get global + uv_snapshot!(context.filters(), context.python_pin().env(EnvVars::XDG_CONFIG_HOME, xdg.path()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + 3.12 + + ----- stderr ----- + "###); + + // Request Python 3.11 for local .python-version + uv_snapshot!(context.filters(), context.python_pin().arg("3.11").env(EnvVars::XDG_CONFIG_HOME, xdg.path()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Pinned `.python-version` to `3.11` + + ----- stderr ----- + "###); + + // Local should override global + uv_snapshot!(context.filters(), context.python_pin().env(EnvVars::XDG_CONFIG_HOME, xdg.path()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + 3.11 + + ----- stderr ----- + "###); + + // We should still be able to check global pin + uv_snapshot!(context.filters(), context.python_pin().arg("--global").env(EnvVars::XDG_CONFIG_HOME, xdg.path()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + 3.12 + + ----- stderr ----- + "###); + + Ok(()) +} + /// We do not need a Python interpreter to pin without `--resolved` /// (skip on Windows because the snapshot is different and the behavior is not platform dependent) #[cfg(unix)] diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index e57eb26934834..ddec3226037c7 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -18,7 +18,8 @@ fn add_shared_args(mut command: Command, cwd: &Path) -> Command { .env(EnvVars::UV_CONCURRENT_BUILDS, "16") .env(EnvVars::UV_CONCURRENT_INSTALLS, "8") // Set an explicit `XDG_CONFIG_DIRS` to avoid loading system configuration. - .env(EnvVars::XDG_CONFIG_DIRS, cwd); + .env(EnvVars::XDG_CONFIG_DIRS, cwd) + .env(EnvVars::XDG_CONFIG_HOME, cwd); if cfg!(unix) { // Avoid locale issues in tests diff --git a/crates/uv/tests/it/tool_install.rs b/crates/uv/tests/it/tool_install.rs index 6435f5b8d64bb..5df2664b84537 100644 --- a/crates/uv/tests/it/tool_install.rs +++ b/crates/uv/tests/it/tool_install.rs @@ -175,6 +175,162 @@ fn tool_install() { }); } +#[test] +fn tool_install_with_global_python() -> Result<()> { + let context = TestContext::new_with_versions(&["3.11", "3.12"]) + .with_filtered_counts() + .with_filtered_exe_suffix(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + let xdg = assert_fs::TempDir::new().expect("Failed to create temp dir"); + let uv = xdg.child("uv"); + let versions = uv.child(".python-version"); + versions.write_str("3.11")?; + + // Install `black` + uv_snapshot!(context.filters(), context.tool_install() + .arg("black") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .env(EnvVars::PATH, bin_dir.as_os_str()) + .env(EnvVars::XDG_CONFIG_HOME, xdg.path()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + black==24.3.0 + + click==8.1.7 + + mypy-extensions==1.0.0 + + packaging==24.0 + + pathspec==0.12.1 + + platformdirs==4.2.0 + Installed 2 executables: black, blackd + "###); + + tool_dir.child("black").assert(predicate::path::is_dir()); + tool_dir + .child("black") + .child("uv-receipt.toml") + .assert(predicate::path::exists()); + + let executable = bin_dir.child(format!("black{}", std::env::consts::EXE_SUFFIX)); + assert!(executable.exists()); + + // On Windows, we can't snapshot an executable file. + #[cfg(not(windows))] + insta::with_settings!({ + filters => context.filters(), + }, { + // Should run black in the virtual environment + assert_snapshot!(fs_err::read_to_string(executable).unwrap(), @r###" + #![TEMP_DIR]/tools/black/bin/python + # -*- coding: utf-8 -*- + import sys + from black import patched_main + if __name__ == "__main__": + if sys.argv[0].endswith("-script.pyw"): + sys.argv[0] = sys.argv[0][:-11] + elif sys.argv[0].endswith(".exe"): + sys.argv[0] = sys.argv[0][:-4] + sys.exit(patched_main()) + "###); + + }); + + insta::with_settings!({ + filters => context.filters(), + }, { + // We should have a tool receipt + assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###" + [tool] + requirements = [{ name = "black" }] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black" }, + { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" }, + ] + + [tool.options] + exclude-newer = "2024-03-25T00:00:00Z" + "###); + }); + + uv_snapshot!(context.filters(), Command::new("black").arg("--version").env(EnvVars::PATH, bin_dir.as_os_str()).env(EnvVars::XDG_CONFIG_HOME, xdg.path()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + black, 24.3.0 (compiled: yes) + Python (CPython) 3.11.[X] + + ----- stderr ----- + "###); + + // Install another tool + uv_snapshot!(context.filters(), context.tool_install() + .arg("flask") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .env(EnvVars::PATH, bin_dir.as_os_str()) + .env(EnvVars::XDG_CONFIG_HOME, xdg.path()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + blinker==1.7.0 + + click==8.1.7 + + flask==3.0.2 + + itsdangerous==2.1.2 + + jinja2==3.1.3 + + markupsafe==2.1.5 + + werkzeug==3.0.1 + Installed 1 executable: flask + "###); + + tool_dir.child("flask").assert(predicate::path::is_dir()); + assert!(bin_dir + .child(format!("flask{}", std::env::consts::EXE_SUFFIX)) + .exists()); + + #[cfg(not(windows))] + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!(fs_err::read_to_string(bin_dir.join("flask")).unwrap(), @r###" + #![TEMP_DIR]/tools/flask/bin/python + # -*- coding: utf-8 -*- + import sys + from flask.cli import main + if __name__ == "__main__": + if sys.argv[0].endswith("-script.pyw"): + sys.argv[0] = sys.argv[0][:-11] + elif sys.argv[0].endswith(".exe"): + sys.argv[0] = sys.argv[0][:-4] + sys.exit(main()) + "###); + }); + + uv_snapshot!(context.filters(), Command::new("flask").arg("--version").env(EnvVars::PATH, bin_dir.as_os_str()).env(EnvVars::XDG_CONFIG_HOME, xdg.path()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.11.[X] + Flask 3.0.2 + Werkzeug 3.0.1 + + ----- stderr ----- + "###); + + Ok(()) +} + #[test] fn tool_install_with_editable() -> Result<()> { let context = TestContext::new("3.12") @@ -1471,7 +1627,7 @@ fn tool_install_uninstallable() { .arg("pyenv") .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) - .env(EnvVars::PATH, bin_dir.as_os_str()), @r###" + .env(EnvVars::PATH, bin_dir.as_os_str()), @r" success: false exit_code: 1 ----- stdout ----- @@ -1493,13 +1649,13 @@ fn tool_install_uninstallable() { We are sorry, but this package is not installable with pip. Please read the installation instructions at: - + https://github.com/pyenv/pyenv#installation # hint: This usually indicates a problem with the package or the build environment. - "###); + "); // Ensure the tool environment is not created. tool_dir.child("pyenv").assert(predicate::path::missing()); diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 32473a03dc08e..3835c14325e92 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -5186,6 +5186,10 @@ uv python pin [OPTIONS] [REQUEST]

See --project to only change the project root directory.

+
--global

Pin the global (user-level) Python version.

+ +

This causes uv to write the specified version to a user-level .python-version file. This will be used as a fallback for any project that doesn’t contain a .python-version file in its local directory or ancestor directories.

+
--help, -h

Display the concise help for this command

--native-tls

Whether to load TLS certificates from the platform’s native certificate store.

From 5c7bdbaa71611b269c88cad42bc2eb8554709475 Mon Sep 17 00:00:00 2001 From: John Mumm Date: Tue, 11 Mar 2025 16:30:25 +0100 Subject: [PATCH 2/7] .. --- crates/uv/tests/it/tool_install.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/uv/tests/it/tool_install.rs b/crates/uv/tests/it/tool_install.rs index 5df2664b84537..acf950ce78dd1 100644 --- a/crates/uv/tests/it/tool_install.rs +++ b/crates/uv/tests/it/tool_install.rs @@ -1649,7 +1649,7 @@ fn tool_install_uninstallable() { We are sorry, but this package is not installable with pip. Please read the installation instructions at: - + https://github.com/pyenv/pyenv#installation # From abd51884e2a3f0191bc4efb85ec29818e1718c04 Mon Sep 17 00:00:00 2001 From: John Mumm Date: Wed, 12 Mar 2025 11:43:09 +0100 Subject: [PATCH 3/7] .. --- crates/uv-cli/src/lib.rs | 14 ++- crates/uv-static/src/env_vars.rs | 4 + crates/uv/tests/it/common/mod.rs | 21 +++- crates/uv/tests/it/python_pin.rs | 78 +++++--------- crates/uv/tests/it/show_settings.rs | 8 +- crates/uv/tests/it/tool_install.rs | 161 ++++++++++------------------ docs/configuration/environment.md | 8 ++ docs/reference/cli.md | 8 +- 8 files changed, 128 insertions(+), 174 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 1b66e0aa2d854..5e1b2dafe8398 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -4677,12 +4677,16 @@ pub struct PythonPinArgs { #[arg(long, alias = "no-workspace")] pub no_project: bool, - /// Pin the global (user-level) Python version. + /// Pin to a specific Python version. + /// + /// Writes the pinned Python version to a .python-version file in the uv user configuration + /// directory: `XDG_CONFIG_HOME/uv` on Linux/macOS and `%APPDATA%/uv` on Windows. + /// + /// When a local Python version pin is not found in the working directory or an ancestor + /// directory, this version will be used instead. /// - /// This causes uv to write the specified version to a user-level - /// `.python-version` file. This will be used as a fallback for any - /// project that doesn't contain a `.python-version` file in its - /// local directory or ancestor directories. + /// Unlike local version pins, this version is used as the default for commands that mutate + /// global state, like uv tool install. #[arg(long)] pub global: bool, } diff --git a/crates/uv-static/src/env_vars.rs b/crates/uv-static/src/env_vars.rs index 953cc235445da..c9edcebd9df98 100644 --- a/crates/uv-static/src/env_vars.rs +++ b/crates/uv-static/src/env_vars.rs @@ -347,6 +347,10 @@ impl EnvVars { /// Path to system-level configuration directory on Windows systems. pub const SYSTEMDRIVE: &'static str = "SYSTEMDRIVE"; + /// Path to user-level configuration directory on Windows systems. + pub const APPDATA: &'static str = "APPDATA"; + pub const USERPROFILE: &'static str = "USERPROFILE"; + /// Path to user-level configuration directory on Unix systems. pub const XDG_CONFIG_HOME: &'static str = "XDG_CONFIG_HOME"; diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index b9188019a46b7..0335854758842 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -358,7 +358,11 @@ impl TestContext { let home_dir = ChildPath::new(root.path()).child("home"); fs_err::create_dir_all(&home_dir).expect("Failed to create test home directory"); - let user_config_dir = ChildPath::new(home_dir.path()).child(".config"); + let user_config_dir = if cfg!(windows) { + ChildPath::new(home_dir.path()) + } else { + ChildPath::new(home_dir.path()).child(".config") + }; fs_err::create_dir_all(&user_config_dir) .expect("Failed to create test user config directory"); @@ -477,16 +481,23 @@ impl TestContext { .into_iter() .map(|pattern| (pattern, "[PYTHON_DIR]/".to_string())), ); + let mut uv_user_config_dir = PathBuf::from(user_config_dir.path()); + uv_user_config_dir.push("uv"); filters.extend( - Self::path_patterns(&home_dir) + Self::path_patterns(&uv_user_config_dir) .into_iter() - .map(|pattern| (pattern, "[HOME]/".to_string())), + .map(|pattern| (pattern, "[UV_USER_CONFIG_DIR]/".to_string())), ); filters.extend( Self::path_patterns(&user_config_dir) .into_iter() .map(|pattern| (pattern, "[USER_CONFIG_DIR]/".to_string())), ); + filters.extend( + Self::path_patterns(&home_dir) + .into_iter() + .map(|pattern| (pattern, "[HOME]/".to_string())), + ); filters.extend( Self::path_patterns(&workspace_root) .into_iter() @@ -617,7 +628,8 @@ impl TestContext { .env(EnvVars::COLUMNS, "100") .env(EnvVars::PATH, path) .env(EnvVars::HOME, self.home_dir.as_os_str()) - .env(EnvVars::XDG_CONFIG_HOME, self.user_config_dir.as_os_str()) + .env(EnvVars::APPDATA, self.home_dir.as_os_str()) + .env(EnvVars::USERPROFILE, self.home_dir.as_os_str()) .env(EnvVars::UV_PYTHON_INSTALL_DIR, "") // Installations are not allowed by default; see `Self::with_managed_python_dirs` .env(EnvVars::UV_PYTHON_DOWNLOADS, "never") @@ -628,6 +640,7 @@ impl TestContext { .env(EnvVars::UV_TEST_NO_CLI_PROGRESS, "1") .env_remove(EnvVars::UV_CACHE_DIR) .env_remove(EnvVars::UV_TOOL_BIN_DIR) + .env_remove(EnvVars::XDG_CONFIG_HOME) .current_dir(self.temp_dir.path()); for (key, value) in &self.extra_env { diff --git a/crates/uv/tests/it/python_pin.rs b/crates/uv/tests/it/python_pin.rs index c8427cacbdfd0..d6d3c7abf1523 100644 --- a/crates/uv/tests/it/python_pin.rs +++ b/crates/uv/tests/it/python_pin.rs @@ -6,7 +6,6 @@ use uv_python::{ platform::{Arch, Os}, PYTHON_VERSIONS_FILENAME, PYTHON_VERSION_FILENAME, }; -use uv_static::EnvVars; #[test] fn python_pin() { @@ -200,14 +199,13 @@ fn python_pin() { } #[test] -fn python_pin_use_global_if_no_local() -> Result<()> { +fn python_pin_global_if_no_local() -> Result<()> { let context: TestContext = TestContext::new_with_versions(&["3.11", "3.12"]); + let uv = context.user_config_dir.child("uv"); + uv.create_dir_all()?; - let xdg = assert_fs::TempDir::new().expect("Failed to create temp dir"); - let uv = xdg.child("uv"); - - // Without arguments, we attempt to read the current pin (which does not exist yet) - uv_snapshot!(context.filters(), context.python_pin().env(EnvVars::XDG_CONFIG_HOME, xdg.path()), @r###" + // // Without arguments, we attempt to read the current pin (which does not exist yet) + uv_snapshot!(context.filters(), context.python_pin(), @r###" success: false exit_code: 2 ----- stdout ----- @@ -216,11 +214,18 @@ fn python_pin_use_global_if_no_local() -> Result<()> { error: No pinned Python version found "###); - let versions = uv.child(".python-version"); - versions.write_str("3.11")?; + // Given an argument, we globally pin to that version + uv_snapshot!(context.filters(), context.python_pin().arg("3.11").arg("--global"), @r" + success: true + exit_code: 0 + ----- stdout ----- + Pinned `[UV_USER_CONFIG_DIR]/.python-version` to `3.11` + + ----- stderr ----- + "); // If no local pin, use global. - uv_snapshot!(context.filters(), context.python_pin().env(EnvVars::XDG_CONFIG_HOME, xdg.path()), @r###" + uv_snapshot!(context.filters(), context.python_pin(), @r###" success: true exit_code: 0 ----- stdout ----- @@ -233,56 +238,23 @@ fn python_pin_use_global_if_no_local() -> Result<()> { } #[test] -fn python_pin_global() -> Result<()> { - let xdg = assert_fs::TempDir::new().expect("Failed to create temp dir"); - let uv = xdg.child("uv"); +fn python_pin_global_use_local_if_available() -> Result<()> { + let context: TestContext = TestContext::new_with_versions(&["3.11", "3.12"]); + let uv = context.user_config_dir.child("uv"); uv.create_dir_all()?; - let context: TestContext = TestContext::new_with_versions(&["3.11", "3.12"]) - .with_filtered_path(uv.as_ref(), "USER_CONFIG_PATH"); - - // Without arguments, we attempt to read the current pin (which does not exist yet) - uv_snapshot!(context.filters(), context.python_pin().env(EnvVars::XDG_CONFIG_HOME, xdg.path()), @r###" - success: false - exit_code: 2 - ----- stdout ----- - - ----- stderr ----- - error: No pinned Python version found - "###); - // Given an argument, we globally pin to that version - uv_snapshot!(context.filters(), context.python_pin().arg("any").arg("--global").env(EnvVars::XDG_CONFIG_HOME, xdg.path()), @r" + uv_snapshot!(context.filters(), context.python_pin().arg("3.12").arg("--global"), @r" success: true exit_code: 0 ----- stdout ----- - Pinned `[USER_CONFIG_PATH]/.python-version` to `any` + Pinned `[UV_USER_CONFIG_DIR]/.python-version` to `3.12` ----- stderr ----- "); - // Without arguments, we read the current pin - uv_snapshot!(context.filters(), context.python_pin().env(EnvVars::XDG_CONFIG_HOME, xdg.path()), @r###" - success: true - exit_code: 0 - ----- stdout ----- - any - - ----- stderr ----- - "###); - - // Request Python 3.12 globally - uv_snapshot!(context.filters(), context.python_pin().arg("3.12").arg("--global").env(EnvVars::XDG_CONFIG_HOME, xdg.path()), @r" - success: true - exit_code: 0 - ----- stdout ----- - Updated `[USER_CONFIG_PATH]/.python-version` from `any` -> `3.12` - - ----- stderr ----- - "); - - // With no local, we get global - uv_snapshot!(context.filters(), context.python_pin().env(EnvVars::XDG_CONFIG_HOME, xdg.path()), @r###" + // With no local, we get the global pin + uv_snapshot!(context.filters(), context.python_pin(), @r###" success: true exit_code: 0 ----- stdout ----- @@ -292,7 +264,7 @@ fn python_pin_global() -> Result<()> { "###); // Request Python 3.11 for local .python-version - uv_snapshot!(context.filters(), context.python_pin().arg("3.11").env(EnvVars::XDG_CONFIG_HOME, xdg.path()), @r###" + uv_snapshot!(context.filters(), context.python_pin().arg("3.11"), @r###" success: true exit_code: 0 ----- stdout ----- @@ -302,7 +274,7 @@ fn python_pin_global() -> Result<()> { "###); // Local should override global - uv_snapshot!(context.filters(), context.python_pin().env(EnvVars::XDG_CONFIG_HOME, xdg.path()), @r###" + uv_snapshot!(context.filters(), context.python_pin(), @r###" success: true exit_code: 0 ----- stdout ----- @@ -312,7 +284,7 @@ fn python_pin_global() -> Result<()> { "###); // We should still be able to check global pin - uv_snapshot!(context.filters(), context.python_pin().arg("--global").env(EnvVars::XDG_CONFIG_HOME, xdg.path()), @r###" + uv_snapshot!(context.filters(), context.python_pin().arg("--global"), @r###" success: true exit_code: 0 ----- stdout ----- diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index ddec3226037c7..65dff0eda8f64 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -18,8 +18,7 @@ fn add_shared_args(mut command: Command, cwd: &Path) -> Command { .env(EnvVars::UV_CONCURRENT_BUILDS, "16") .env(EnvVars::UV_CONCURRENT_INSTALLS, "8") // Set an explicit `XDG_CONFIG_DIRS` to avoid loading system configuration. - .env(EnvVars::XDG_CONFIG_DIRS, cwd) - .env(EnvVars::XDG_CONFIG_HOME, cwd); + .env(EnvVars::XDG_CONFIG_DIRS, cwd); if cfg!(unix) { // Avoid locale issues in tests @@ -2388,7 +2387,6 @@ fn resolve_top_level() -> anyhow::Result<()> { ignore = "Configuration tests are not yet supported on Windows" )] fn resolve_user_configuration() -> anyhow::Result<()> { - // Create a temporary directory to store the user configuration. let xdg = assert_fs::TempDir::new().expect("Failed to create temp dir"); let uv = xdg.child("uv"); let config = uv.child("uv.toml"); @@ -3619,6 +3617,7 @@ fn invalid_conflicts() -> anyhow::Result<()> { #[test] fn valid_conflicts() -> anyhow::Result<()> { let context = TestContext::new("3.12"); + let xdg = assert_fs::TempDir::new().expect("Failed to create temp dir"); let pyproject = context.temp_dir.child("pyproject.toml"); // Write in `pyproject.toml` schema. @@ -3633,7 +3632,8 @@ fn valid_conflicts() -> anyhow::Result<()> { [{extra = "x1"}, {extra = "x2"}], ] "#})?; - uv_snapshot!(context.filters(), add_shared_args(context.lock(), context.temp_dir.path()), @r###" + uv_snapshot!(context.filters(), add_shared_args(context.lock(), context.temp_dir.path()) + .env("XDG_CONFIG_HOME", xdg.path()), @r###" success: true exit_code: 0 ----- stdout ----- diff --git a/crates/uv/tests/it/tool_install.rs b/crates/uv/tests/it/tool_install.rs index acf950ce78dd1..41de93af83a0b 100644 --- a/crates/uv/tests/it/tool_install.rs +++ b/crates/uv/tests/it/tool_install.rs @@ -182,100 +182,16 @@ fn tool_install_with_global_python() -> Result<()> { .with_filtered_exe_suffix(); let tool_dir = context.temp_dir.child("tools"); let bin_dir = context.temp_dir.child("bin"); - - let xdg = assert_fs::TempDir::new().expect("Failed to create temp dir"); - let uv = xdg.child("uv"); + let uv = context.user_config_dir.child("uv"); let versions = uv.child(".python-version"); versions.write_str("3.11")?; - // Install `black` - uv_snapshot!(context.filters(), context.tool_install() - .arg("black") - .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) - .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) - .env(EnvVars::PATH, bin_dir.as_os_str()) - .env(EnvVars::XDG_CONFIG_HOME, xdg.path()), @r###" - success: true - exit_code: 0 - ----- stdout ----- - - ----- stderr ----- - Resolved [N] packages in [TIME] - Prepared [N] packages in [TIME] - Installed [N] packages in [TIME] - + black==24.3.0 - + click==8.1.7 - + mypy-extensions==1.0.0 - + packaging==24.0 - + pathspec==0.12.1 - + platformdirs==4.2.0 - Installed 2 executables: black, blackd - "###); - - tool_dir.child("black").assert(predicate::path::is_dir()); - tool_dir - .child("black") - .child("uv-receipt.toml") - .assert(predicate::path::exists()); - - let executable = bin_dir.child(format!("black{}", std::env::consts::EXE_SUFFIX)); - assert!(executable.exists()); - - // On Windows, we can't snapshot an executable file. - #[cfg(not(windows))] - insta::with_settings!({ - filters => context.filters(), - }, { - // Should run black in the virtual environment - assert_snapshot!(fs_err::read_to_string(executable).unwrap(), @r###" - #![TEMP_DIR]/tools/black/bin/python - # -*- coding: utf-8 -*- - import sys - from black import patched_main - if __name__ == "__main__": - if sys.argv[0].endswith("-script.pyw"): - sys.argv[0] = sys.argv[0][:-11] - elif sys.argv[0].endswith(".exe"): - sys.argv[0] = sys.argv[0][:-4] - sys.exit(patched_main()) - "###); - - }); - - insta::with_settings!({ - filters => context.filters(), - }, { - // We should have a tool receipt - assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###" - [tool] - requirements = [{ name = "black" }] - entrypoints = [ - { name = "black", install-path = "[TEMP_DIR]/bin/black" }, - { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" }, - ] - - [tool.options] - exclude-newer = "2024-03-25T00:00:00Z" - "###); - }); - - uv_snapshot!(context.filters(), Command::new("black").arg("--version").env(EnvVars::PATH, bin_dir.as_os_str()).env(EnvVars::XDG_CONFIG_HOME, xdg.path()), @r###" - success: true - exit_code: 0 - ----- stdout ----- - black, 24.3.0 (compiled: yes) - Python (CPython) 3.11.[X] - - ----- stderr ----- - "###); - - // Install another tool + // Install a tool uv_snapshot!(context.filters(), context.tool_install() .arg("flask") .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) - .env(EnvVars::PATH, bin_dir.as_os_str()) - .env(EnvVars::XDG_CONFIG_HOME, xdg.path()), @r###" + .env(EnvVars::PATH, bin_dir.as_os_str()), @r###" success: true exit_code: 0 ----- stdout ----- @@ -299,25 +215,7 @@ fn tool_install_with_global_python() -> Result<()> { .child(format!("flask{}", std::env::consts::EXE_SUFFIX)) .exists()); - #[cfg(not(windows))] - insta::with_settings!({ - filters => context.filters(), - }, { - assert_snapshot!(fs_err::read_to_string(bin_dir.join("flask")).unwrap(), @r###" - #![TEMP_DIR]/tools/flask/bin/python - # -*- coding: utf-8 -*- - import sys - from flask.cli import main - if __name__ == "__main__": - if sys.argv[0].endswith("-script.pyw"): - sys.argv[0] = sys.argv[0][:-11] - elif sys.argv[0].endswith(".exe"): - sys.argv[0] = sys.argv[0][:-4] - sys.exit(main()) - "###); - }); - - uv_snapshot!(context.filters(), Command::new("flask").arg("--version").env(EnvVars::PATH, bin_dir.as_os_str()).env(EnvVars::XDG_CONFIG_HOME, xdg.path()), @r###" + uv_snapshot!(context.filters(), Command::new("flask").arg("--version").env(EnvVars::PATH, bin_dir.as_os_str()), @r###" success: true exit_code: 0 ----- stdout ----- @@ -328,6 +226,57 @@ fn tool_install_with_global_python() -> Result<()> { ----- stderr ----- "###); + // FIXME: Do we want reinstall of a tool to respect the changed global pin? + // // Change global version + // uv_snapshot!(context.filters(), context.python_pin().arg("3.12").arg("--global"), + // @r" + // success: true + // exit_code: 0 + // ----- stdout ----- + // Updated `[USER_CONFIG_DIR]/uv/.python-version` from `3.11` -> `3.12` + + // ----- stderr ----- + // " + // ); + + // // Install flask again + // uv_snapshot!(context.filters(), context.tool_install() + // .arg("flask") + // .arg("--reinstall") + // .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + // .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + // .env(EnvVars::PATH, bin_dir.as_os_str()), @r" + // success: true + // exit_code: 0 + // ----- stdout ----- + + // ----- stderr ----- + // Resolved [N] packages in [TIME] + // Prepared [N] packages in [TIME] + // Uninstalled [N] packages in [TIME] + // Installed [N] packages in [TIME] + // ~ blinker==1.7.0 + // ~ click==8.1.7 + // ~ flask==3.0.2 + // ~ itsdangerous==2.1.2 + // ~ jinja2==3.1.3 + // ~ markupsafe==2.1.5 + // ~ werkzeug==3.0.1 + // Installed 1 executable: flask + // "); + + // // Check new global version is respected + // uv_snapshot!(context.filters(), Command::new("flask").arg("--version").env(EnvVars::PATH, bin_dir.as_os_str()), @r###" + // success: true + // exit_code: 0 + // ----- stdout ----- + // Python 3.12.[X] + // Flask 3.0.2 + // Werkzeug 3.0.1 + + // ----- stderr ----- + // "###); + Ok(()) } diff --git a/docs/configuration/environment.md b/docs/configuration/environment.md index 06c5d128eb859..82f1237697f29 100644 --- a/docs/configuration/environment.md +++ b/docs/configuration/environment.md @@ -407,6 +407,10 @@ Used for trusted publishing via `uv publish`. Contains the oidc token url. General proxy for all network requests. +### `APPDATA` + +Path to user-level configuration directory on Windows systems. + ### `BASH_VERSION` Used to detect Bash shell usage. @@ -567,6 +571,10 @@ Path to system-level configuration directory on Windows systems. Use to create the tracing durations file via the `tracing-durations-export` feature. +### `USERPROFILE` + + + ### `UV` The path to the binary that was used to invoke uv. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 3835c14325e92..ed580ed7b4f92 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -5186,9 +5186,13 @@ uv python pin [OPTIONS] [REQUEST]

See --project to only change the project root directory.

-
--global

Pin the global (user-level) Python version.

+
--global

Pin to a specific Python version.

-

This causes uv to write the specified version to a user-level .python-version file. This will be used as a fallback for any project that doesn’t contain a .python-version file in its local directory or ancestor directories.

+

Writes the pinned Python version to a .python-version file in the uv user configuration directory: XDG_CONFIG_HOME/uv on Linux/macOS and %APPDATA%/uv on Windows.

+ +

When a local Python version pin is not found in the working directory or an ancestor directory, this version will be used instead.

+ +

Unlike local version pins, this version is used as the default for commands that mutate global state, like uv tool install.

--help, -h

Display the concise help for this command

From 1236ddd79cc8073731b4ac4d136d367cb06bc5e9 Mon Sep 17 00:00:00 2001 From: John Mumm Date: Wed, 12 Mar 2025 20:46:21 +0100 Subject: [PATCH 4/7] .. --- crates/uv/tests/it/python_pin.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/uv/tests/it/python_pin.rs b/crates/uv/tests/it/python_pin.rs index d6d3c7abf1523..7725db9acb548 100644 --- a/crates/uv/tests/it/python_pin.rs +++ b/crates/uv/tests/it/python_pin.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use crate::common::{uv_snapshot, TestContext}; use anyhow::Result; use assert_fs::fixture::{FileWriteStr, PathChild, PathCreateDir}; @@ -263,6 +265,17 @@ fn python_pin_global_use_local_if_available() -> Result<()> { ----- stderr ----- "###); + let mut global_version_path = PathBuf::from(uv.path()); + global_version_path.push(PYTHON_VERSION_FILENAME); + let global_python_version = context.read(&global_version_path); + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!(global_python_version, @r###" + 3.12 + "###); + }); + // Request Python 3.11 for local .python-version uv_snapshot!(context.filters(), context.python_pin().arg("3.11"), @r###" success: true @@ -293,6 +306,22 @@ fn python_pin_global_use_local_if_available() -> Result<()> { ----- stderr ----- "###); + // Local .python-version exists and has the right version. + let local_python_version = context.read(PYTHON_VERSION_FILENAME); + assert_snapshot!(local_python_version, @r###" + 3.11 + "###); + + // Global .python-version still exists and has the right version. + let global_python_version = context.read(&global_version_path); + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!(global_python_version, @r###" + 3.12 + "###); + }); + Ok(()) } From 8b7f256b8a5be9b86100e1913046830c17f5fde4 Mon Sep 17 00:00:00 2001 From: John Mumm Date: Thu, 13 Mar 2025 11:11:57 +0100 Subject: [PATCH 5/7] .. --- crates/uv-cli/src/lib.rs | 6 +- crates/uv-static/src/env_vars.rs | 2 + crates/uv/tests/it/python_pin.rs | 5 +- crates/uv/tests/it/tool_install.rs | 102 ++++++++++++++--------------- docs/concepts/python-versions.md | 12 ++-- docs/configuration/environment.md | 2 +- docs/reference/cli.md | 6 +- 7 files changed, 72 insertions(+), 63 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 5e1b2dafe8398..2e8330692f43a 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -4677,16 +4677,16 @@ pub struct PythonPinArgs { #[arg(long, alias = "no-workspace")] pub no_project: bool, - /// Pin to a specific Python version. + /// Update the global Python version pin. /// - /// Writes the pinned Python version to a .python-version file in the uv user configuration + /// Writes the pinned Python version to a `.python-version` file in the uv user configuration /// directory: `XDG_CONFIG_HOME/uv` on Linux/macOS and `%APPDATA%/uv` on Windows. /// /// When a local Python version pin is not found in the working directory or an ancestor /// directory, this version will be used instead. /// /// Unlike local version pins, this version is used as the default for commands that mutate - /// global state, like uv tool install. + /// global state, like `uv tool install`. #[arg(long)] pub global: bool, } diff --git a/crates/uv-static/src/env_vars.rs b/crates/uv-static/src/env_vars.rs index c9edcebd9df98..73e4e5b201402 100644 --- a/crates/uv-static/src/env_vars.rs +++ b/crates/uv-static/src/env_vars.rs @@ -349,6 +349,8 @@ impl EnvVars { /// Path to user-level configuration directory on Windows systems. pub const APPDATA: &'static str = "APPDATA"; + + /// Path to root directory of user's profile on Windows systems. pub const USERPROFILE: &'static str = "USERPROFILE"; /// Path to user-level configuration directory on Unix systems. diff --git a/crates/uv/tests/it/python_pin.rs b/crates/uv/tests/it/python_pin.rs index 7725db9acb548..940f6aeca9c24 100644 --- a/crates/uv/tests/it/python_pin.rs +++ b/crates/uv/tests/it/python_pin.rs @@ -200,13 +200,14 @@ fn python_pin() { } } +// If there is no project-level `.python-version` file, respect the global pin. #[test] fn python_pin_global_if_no_local() -> Result<()> { let context: TestContext = TestContext::new_with_versions(&["3.11", "3.12"]); let uv = context.user_config_dir.child("uv"); uv.create_dir_all()?; - // // Without arguments, we attempt to read the current pin (which does not exist yet) + // Without arguments, we attempt to read the current pin (which does not exist yet) uv_snapshot!(context.filters(), context.python_pin(), @r###" success: false exit_code: 2 @@ -239,6 +240,8 @@ fn python_pin_global_if_no_local() -> Result<()> { Ok(()) } +// If there is a project-level `.python-version` file, it takes precedence over +// the global pin. #[test] fn python_pin_global_use_local_if_available() -> Result<()> { let context: TestContext = TestContext::new_with_versions(&["3.11", "3.12"]); diff --git a/crates/uv/tests/it/tool_install.rs b/crates/uv/tests/it/tool_install.rs index 41de93af83a0b..d02e65ac71cb7 100644 --- a/crates/uv/tests/it/tool_install.rs +++ b/crates/uv/tests/it/tool_install.rs @@ -226,56 +226,56 @@ fn tool_install_with_global_python() -> Result<()> { ----- stderr ----- "###); - // FIXME: Do we want reinstall of a tool to respect the changed global pin? - // // Change global version - // uv_snapshot!(context.filters(), context.python_pin().arg("3.12").arg("--global"), - // @r" - // success: true - // exit_code: 0 - // ----- stdout ----- - // Updated `[USER_CONFIG_DIR]/uv/.python-version` from `3.11` -> `3.12` - - // ----- stderr ----- - // " - // ); - - // // Install flask again - // uv_snapshot!(context.filters(), context.tool_install() - // .arg("flask") - // .arg("--reinstall") - // .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) - // .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) - // .env(EnvVars::PATH, bin_dir.as_os_str()), @r" - // success: true - // exit_code: 0 - // ----- stdout ----- - - // ----- stderr ----- - // Resolved [N] packages in [TIME] - // Prepared [N] packages in [TIME] - // Uninstalled [N] packages in [TIME] - // Installed [N] packages in [TIME] - // ~ blinker==1.7.0 - // ~ click==8.1.7 - // ~ flask==3.0.2 - // ~ itsdangerous==2.1.2 - // ~ jinja2==3.1.3 - // ~ markupsafe==2.1.5 - // ~ werkzeug==3.0.1 - // Installed 1 executable: flask - // "); - - // // Check new global version is respected - // uv_snapshot!(context.filters(), Command::new("flask").arg("--version").env(EnvVars::PATH, bin_dir.as_os_str()), @r###" - // success: true - // exit_code: 0 - // ----- stdout ----- - // Python 3.12.[X] - // Flask 3.0.2 - // Werkzeug 3.0.1 - - // ----- stderr ----- - // "###); + // Change global version + uv_snapshot!(context.filters(), context.python_pin().arg("3.12").arg("--global"), + @r" + success: true + exit_code: 0 + ----- stdout ----- + Updated `[UV_USER_CONFIG_DIR]/.python-version` from `3.11` -> `3.12` + + ----- stderr ----- + " + ); + + // Install flask again + uv_snapshot!(context.filters(), context.tool_install() + .arg("flask") + .arg("--reinstall") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .env(EnvVars::PATH, bin_dir.as_os_str()), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Uninstalled [N] packages in [TIME] + Installed [N] packages in [TIME] + ~ blinker==1.7.0 + ~ click==8.1.7 + ~ flask==3.0.2 + ~ itsdangerous==2.1.2 + ~ jinja2==3.1.3 + ~ markupsafe==2.1.5 + ~ werkzeug==3.0.1 + Installed 1 executable: flask + "); + + // Currently, when reinstalling a tool we use the original version the tool + // was installed with, not the most up-to-date global version + uv_snapshot!(context.filters(), Command::new("flask").arg("--version").env(EnvVars::PATH, bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.11.[X] + Flask 3.0.2 + Werkzeug 3.0.1 + + ----- stderr ----- + "###); Ok(()) } @@ -1598,7 +1598,7 @@ fn tool_install_uninstallable() { We are sorry, but this package is not installable with pip. Please read the installation instructions at: - + https://github.com/pyenv/pyenv#installation # diff --git a/docs/concepts/python-versions.md b/docs/concepts/python-versions.md index d1dcabecc24e5..311a6e718192f 100644 --- a/docs/concepts/python-versions.md +++ b/docs/concepts/python-versions.md @@ -52,16 +52,20 @@ This behavior can be ### Python version files The `.python-version` file can be used to create a default Python version request. uv searches for a -`.python-version` file in the working directory and each of its parents. Any of the request formats -described above can be used, though use of a version number is recommended for interoperability with -other tools. +`.python-version` file in the working directory and each of its parents. If none is found, uv will +check the user-level configuration directory. Any of the request formats described above can be +used, though use of a version number is recommended for interoperability with other tools. A `.python-version` file can be created in the current directory with the [`uv python pin`](../reference/cli.md/#uv-python-pin) command. +A global `.python-version` file can be created in the user configuration directory with the +[`uv python pin --global`](../reference/cli.md/#uv-python-pin) command. + Discovery of `.python-version` files can be disabled with `--no-config`. -uv will not search for `.python-version` files beyond project or workspace boundaries. +uv will not search for `.python-version` files beyond project or workspace boundaries (with the +exception of the user configuration directory). ## Installing a Python version diff --git a/docs/configuration/environment.md b/docs/configuration/environment.md index 82f1237697f29..230c57de71f53 100644 --- a/docs/configuration/environment.md +++ b/docs/configuration/environment.md @@ -573,7 +573,7 @@ Use to create the tracing durations file via the `tracing-durations-export` feat ### `USERPROFILE` - +Path to root directory of user's profile on Windows systems. ### `UV` diff --git a/docs/reference/cli.md b/docs/reference/cli.md index ed580ed7b4f92..31f4a032923f4 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -5186,13 +5186,13 @@ uv python pin [OPTIONS] [REQUEST]

See --project to only change the project root directory.

-
--global

Pin to a specific Python version.

+
--global

Update the global Python version pin.

-

Writes the pinned Python version to a .python-version file in the uv user configuration directory: XDG_CONFIG_HOME/uv on Linux/macOS and %APPDATA%/uv on Windows.

+

Writes the pinned Python version to a .python-version file in the uv user configuration directory: XDG_CONFIG_HOME/uv on Linux/macOS and %APPDATA%/uv on Windows.

When a local Python version pin is not found in the working directory or an ancestor directory, this version will be used instead.

-

Unlike local version pins, this version is used as the default for commands that mutate global state, like uv tool install.

+

Unlike local version pins, this version is used as the default for commands that mutate global state, like uv tool install.

--help, -h

Display the concise help for this command

From bdf8cc37719c0bc1b171d9b04fef4f93724fd36f Mon Sep 17 00:00:00 2001 From: John Mumm Date: Thu, 13 Mar 2025 11:34:26 +0100 Subject: [PATCH 6/7] .. --- crates/uv/tests/it/tool_install.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/uv/tests/it/tool_install.rs b/crates/uv/tests/it/tool_install.rs index d02e65ac71cb7..8dcf9969dace9 100644 --- a/crates/uv/tests/it/tool_install.rs +++ b/crates/uv/tests/it/tool_install.rs @@ -1598,7 +1598,7 @@ fn tool_install_uninstallable() { We are sorry, but this package is not installable with pip. Please read the installation instructions at: - + https://github.com/pyenv/pyenv#installation # From f1db392a22985531efa5436cc48ee60d416f0284 Mon Sep 17 00:00:00 2001 From: John Mumm Date: Thu, 13 Mar 2025 13:22:10 +0100 Subject: [PATCH 7/7] .. --- crates/uv/tests/it/common/mod.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index 0335854758842..6ff533caded1c 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -363,8 +363,6 @@ impl TestContext { } else { ChildPath::new(home_dir.path()).child(".config") }; - fs_err::create_dir_all(&user_config_dir) - .expect("Failed to create test user config directory"); // Canonicalize the temp dir for consistent snapshot behavior let canonical_temp_dir = temp_dir.canonicalize().unwrap();