Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for global uv python pin #12115

Merged
merged 7 commits into from
Mar 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
7 changes: 7 additions & 0 deletions crates/uv-dirs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,13 @@ pub fn user_config_dir() -> Option<PathBuf> {
.ok()
}

pub fn user_uv_config_dir() -> Option<PathBuf> {
user_config_dir().map(|mut path| {
path.push("uv");
path
})
}

#[cfg(not(windows))]
fn locate_system_config_xdg(value: Option<&str>) -> Option<PathBuf> {
// On Linux and macOS, read the `XDG_CONFIG_DIRS` environment variable.
Expand Down
2 changes: 0 additions & 2 deletions crates/uv-pypi-types/src/conflicts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 24 additions & 1 deletion crates/uv-python/src/version_files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -69,7 +70,13 @@ impl PythonVersionFile {
options: &DiscoveryOptions<'_>,
) -> Result<Option<Self>, 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)
Copy link
Member

@zanieb zanieb Mar 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should co-locate this with the uv.toml? Like, if

pub fn system_config_file() -> Option<PathBuf> {
exists and a user-level config does not should we prefer that? I'm leaning away from it right now, but wanted to put the idea out there. Maybe can can consider a --system flag separately in the future once we have more feedback.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's an interesting question. But I agree we should consider it separately from this PR.

.await?
.or(None),
None => None,
});
};

if options.no_config {
Expand All @@ -84,6 +91,22 @@ impl PythonVersionFile {
Self::try_from_path(path).await
}

pub async fn discover_user_config(
user_config_working_directory: impl AsRef<Path>,
options: &DiscoveryOptions<'_>,
) -> Result<Option<Self>, 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<Path>, options: &DiscoveryOptions<'_>) -> Option<PathBuf> {
path.as_ref()
.ancestors()
Expand Down
6 changes: 6 additions & 0 deletions crates/uv-static/src/env_vars.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
1 change: 1 addition & 0 deletions crates/uv/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
26 changes: 22 additions & 4 deletions crates/uv/src/commands/python/pin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -25,6 +26,7 @@ pub(crate) async fn pin(
resolved: bool,
python_preference: PythonPreference,
no_project: bool,
global: bool,
cache: &Cache,
printer: Printer,
) -> Result<ExitStatus> {
Expand All @@ -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
Expand Down Expand Up @@ -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?;

Expand Down
1 change: 1 addition & 0 deletions crates/uv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1257,6 +1257,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
args.resolved,
globals.python_preference,
args.no_project,
args.global,
&cache,
printer,
)
Expand Down
3 changes: 3 additions & 0 deletions crates/uv/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -979,6 +979,7 @@ pub(crate) struct PythonPinSettings {
pub(crate) request: Option<String>,
pub(crate) resolved: bool,
pub(crate) no_project: bool,
pub(crate) global: bool,
}

impl PythonPinSettings {
Expand All @@ -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,
}
}
}
Expand Down
23 changes: 23 additions & 0 deletions crates/uv/tests/it/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -532,6 +551,7 @@ impl TestContext {
cache_dir,
python_dir,
home_dir,
user_config_dir,
bin_dir,
venv,
workspace_root,
Expand Down Expand Up @@ -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")
Expand All @@ -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 {
Expand Down
Loading
Loading