Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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,

/// 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.
///
/// 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
4 changes: 4 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,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";

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
25 changes: 25 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,14 @@ 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")
};
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"));
Expand Down Expand Up @@ -472,6 +481,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 +553,7 @@ impl TestContext {
cache_dir,
python_dir,
home_dir,
user_config_dir,
bin_dir,
venv,
workspace_root,
Expand Down Expand Up @@ -606,6 +628,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 +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 {
Expand Down
Loading
Loading