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..2e8330692f43a 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -4676,6 +4676,19 @@ pub struct PythonPinArgs { /// `requires-python` constraint. #[arg(long, alias = "no-workspace")] pub no_project: bool, + + /// 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. + /// + /// 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`. + #[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-static/src/env_vars.rs b/crates/uv-static/src/env_vars.rs index 953cc235445da..73e4e5b201402 100644 --- a/crates/uv-static/src/env_vars.rs +++ b/crates/uv-static/src/env_vars.rs @@ -347,6 +347,12 @@ 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"; + + /// 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. pub const XDG_CONFIG_HOME: &'static str = "XDG_CONFIG_HOME"; 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..6ff533caded1c 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,12 @@ 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 = if cfg!(windows) { + ChildPath::new(home_dir.path()) + } else { + ChildPath::new(home_dir.path()).child(".config") + }; + // 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")); @@ -472,6 +479,18 @@ 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(&uv_user_config_dir) + .into_iter() + .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() @@ -532,6 +551,7 @@ impl TestContext { cache_dir, python_dir, home_dir, + user_config_dir, bin_dir, venv, workspace_root, @@ -606,6 +626,8 @@ impl TestContext { .env(EnvVars::COLUMNS, "100") .env(EnvVars::PATH, path) .env(EnvVars::HOME, self.home_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") @@ -616,6 +638,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 c814b83d4d094..940f6aeca9c24 100644 --- a/crates/uv/tests/it/python_pin.rs +++ b/crates/uv/tests/it/python_pin.rs @@ -1,6 +1,8 @@ +use std::path::PathBuf; + 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}, @@ -198,6 +200,134 @@ 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) + uv_snapshot!(context.filters(), context.python_pin(), @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("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(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + 3.11 + + ----- stderr ----- + "###); + + 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"]); + let uv = context.user_config_dir.child("uv"); + uv.create_dir_all()?; + + // Given an argument, we globally pin to that version + uv_snapshot!(context.filters(), context.python_pin().arg("3.12").arg("--global"), @r" + success: true + exit_code: 0 + ----- stdout ----- + Pinned `[UV_USER_CONFIG_DIR]/.python-version` to `3.12` + + ----- stderr ----- + "); + + // With no local, we get the global pin + uv_snapshot!(context.filters(), context.python_pin(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + 3.12 + + ----- 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 + exit_code: 0 + ----- stdout ----- + Pinned `.python-version` to `3.11` + + ----- stderr ----- + "###); + + // Local should override global + uv_snapshot!(context.filters(), context.python_pin(), @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"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + 3.12 + + ----- 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(()) +} + /// 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..65dff0eda8f64 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -2387,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"); @@ -3618,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. @@ -3632,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 6435f5b8d64bb..8dcf9969dace9 100644 --- a/crates/uv/tests/it/tool_install.rs +++ b/crates/uv/tests/it/tool_install.rs @@ -175,6 +175,111 @@ 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 uv = context.user_config_dir.child("uv"); + let versions = uv.child(".python-version"); + versions.write_str("3.11")?; + + // 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()), @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()); + + 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 ----- + "###); + + // 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(()) +} + #[test] fn tool_install_with_editable() -> Result<()> { let context = TestContext::new("3.12") @@ -1471,7 +1576,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 ----- @@ -1499,7 +1604,7 @@ fn tool_install_uninstallable() { 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/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 06c5d128eb859..230c57de71f53 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` + +Path to root directory of user's profile on Windows systems. + ### `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 32473a03dc08e..31f4a032923f4 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -5186,6 +5186,14 @@ uv python pin [OPTIONS] [REQUEST]

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

+
--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.

+ +

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

--native-tls

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