Skip to content

Commit

Permalink
Load configuration options from workspace root (#4295)
Browse files Browse the repository at this point in the history
## Summary

In a workspace, we now read configuration from the workspace root.
Previously, we read configuration from the first `pyproject.toml` or
`uv.toml` file in path -- but in a workspace, that would often be the
_project_ rather than the workspace configuration.

We need to read configuration from the workspace root, rather than its
members, because we lock the workspace globally, so all configuration
applies to the workspace globally.

As part of this change, the `uv-workspace` crate has been renamed to
`uv-settings` and its purpose has been narrowed significantly (it no
longer discovers a workspace; instead, it just reads the settings from a
directory).

If a user has a `uv.toml` in their directory or in a parent directory
but is _not_ in a workspace, we will still respect that use-case as
before.

Closes #4249.
  • Loading branch information
charliermarsh authored Jun 14, 2024
1 parent e0a3890 commit cacd1a2
Show file tree
Hide file tree
Showing 15 changed files with 338 additions and 357 deletions.
49 changes: 24 additions & 25 deletions Cargo.lock

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

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,16 @@ uv-extract = { path = "crates/uv-extract" }
uv-fs = { path = "crates/uv-fs" }
uv-git = { path = "crates/uv-git" }
uv-installer = { path = "crates/uv-installer" }
uv-toolchain = { path = "crates/uv-toolchain" }
uv-normalize = { path = "crates/uv-normalize" }
uv-requirements = { path = "crates/uv-requirements" }
uv-resolver = { path = "crates/uv-resolver" }
uv-settings = { path = "crates/uv-settings" }
uv-state = { path = "crates/uv-state" }
uv-toolchain = { path = "crates/uv-toolchain" }
uv-types = { path = "crates/uv-types" }
uv-version = { path = "crates/uv-version" }
uv-virtualenv = { path = "crates/uv-virtualenv" }
uv-warnings = { path = "crates/uv-warnings" }
uv-workspace = { path = "crates/uv-workspace" }

anstream = { version = "0.6.13" }
anyhow = { version = "1.0.80" }
Expand Down
4 changes: 2 additions & 2 deletions crates/uv-dev/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ uv-distribution = { workspace = true, features = ["schemars"] }
uv-fs = { workspace = true }
uv-git = { workspace = true }
uv-installer = { workspace = true }
uv-toolchain = { workspace = true }
uv-resolver = { workspace = true }
uv-settings = { workspace = true, features = ["schemars"] }
uv-toolchain = { workspace = true }
uv-types = { workspace = true }
uv-workspace = { workspace = true, features = ["schemars"] }

# Any dependencies that are exclusively used in `uv-dev` should be listed as non-workspace
# dependencies, to ensure that we're forced to think twice before including them in other crates.
Expand Down
2 changes: 1 addition & 1 deletion crates/uv-dev/src/generate_json_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use pretty_assertions::StrComparison;
use schemars::{schema_for, JsonSchema};
use serde::Deserialize;

use uv_workspace::Options;
use uv_settings::Options;

use crate::ROOT_DIR;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[package]
name = "uv-workspace"
name = "uv-settings"
version = "0.0.1"
edition = { workspace = true }
rust-version = { workspace = true }
Expand All @@ -22,7 +22,6 @@ uv-fs = { workspace = true }
uv-normalize = { workspace = true, features = ["schemars"] }
uv-resolver = { workspace = true, features = ["schemars"] }
uv-toolchain = { workspace = true, features = ["schemars"] }
uv-warnings = { workspace = true }

dirs-sys = { workspace = true }
fs-err = { workspace = true }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use uv_configuration::{ConfigSettings, IndexStrategy, KeyringProviderType, Targe
use uv_resolver::{AnnotationStyle, ExcludeNewer, PreReleaseMode, ResolutionMode};
use uv_toolchain::PythonVersion;

use crate::{GlobalOptions, Options, PipOptions, ResolverInstallerOptions, Workspace};
use crate::{FilesystemOptions, GlobalOptions, Options, PipOptions, ResolverInstallerOptions};

pub trait Combine {
/// Combine two values, preferring the values in `self`.
Expand All @@ -25,14 +25,13 @@ pub trait Combine {
fn combine(self, other: Self) -> Self;
}

impl Combine for Option<Workspace> {
/// Combine the options used in two [`Workspace`]s. Retains the root of `self`.
fn combine(self, other: Option<Workspace>) -> Option<Workspace> {
impl Combine for Option<FilesystemOptions> {
/// Combine the options used in two [`FilesystemOptions`]s. Retains the root of `self`.
fn combine(self, other: Option<FilesystemOptions>) -> Option<FilesystemOptions> {
match (self, other) {
(Some(mut a), Some(b)) => {
a.options = a.options.combine(b.options);
Some(a)
}
(Some(a), Some(b)) => Some(FilesystemOptions(
a.into_options().combine(b.into_options()),
)),
(a, b) => a.or(b),
}
}
Expand Down
167 changes: 167 additions & 0 deletions crates/uv-settings/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
use std::ops::Deref;
use std::path::{Path, PathBuf};

use tracing::debug;

use uv_fs::Simplified;

pub use crate::combine::*;
pub use crate::settings::*;

mod combine;
mod settings;

/// The [`Options`] as loaded from a configuration file on disk.
#[derive(Debug, Clone)]
pub struct FilesystemOptions(Options);

impl FilesystemOptions {
/// Convert the [`FilesystemOptions`] into [`Options`].
pub fn into_options(self) -> Options {
self.0
}
}

impl Deref for FilesystemOptions {
type Target = Options;

fn deref(&self) -> &Self::Target {
&self.0
}
}

impl FilesystemOptions {
/// Load the user [`FilesystemOptions`].
pub fn user() -> Result<Option<Self>, Error> {
let Some(dir) = config_dir() else {
return Ok(None);
};
let root = dir.join("uv");
let file = root.join("uv.toml");

debug!("Loading user configuration from: `{}`", file.display());
match read_file(&file) {
Ok(options) => Ok(Some(Self(options))),
Err(Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(_) if !dir.is_dir() => {
// Ex) `XDG_CONFIG_HOME=/dev/null`
debug!(
"User configuration directory `{}` does not exist or is not a directory",
dir.display()
);
Ok(None)
}
Err(err) => Err(err),
}
}

/// Find the [`FilesystemOptions`] for the given path.
///
/// The search starts at the given path and goes up the directory tree until a `uv.toml` file is
/// found.
pub fn find(path: impl AsRef<Path>) -> Result<Option<Self>, Error> {
for ancestor in path.as_ref().ancestors() {
// Read a `uv.toml` file in the current directory.
let path = ancestor.join("uv.toml");
match fs_err::read_to_string(&path) {
Ok(content) => {
let options: Options = toml::from_str(&content)
.map_err(|err| Error::UvToml(path.user_display().to_string(), err))?;

debug!("Found workspace configuration at `{}`", path.display());
return Ok(Some(Self(options)));
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(err.into()),
}
}
Ok(None)
}

/// Load a [`FilesystemOptions`] from a directory, preferring a `uv.toml` file over a
/// `pyproject.toml` file.
pub fn from_directory(dir: impl AsRef<Path>) -> Result<Option<Self>, Error> {
// Read a `uv.toml` file in the current directory.
let path = dir.as_ref().join("uv.toml");
match fs_err::read_to_string(&path) {
Ok(content) => {
let options: Options = toml::from_str(&content)
.map_err(|err| Error::UvToml(path.user_display().to_string(), err))?;

debug!("Found workspace configuration at `{}`", path.display());
return Ok(Some(Self(options)));
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(err.into()),
}

// Read a `pyproject.toml` file in the current directory.
let path = dir.as_ref().join("pyproject.toml");
match fs_err::read_to_string(&path) {
Ok(content) => {
// Parse, but skip any `pyproject.toml` that doesn't have a `[tool.uv]` section.
let pyproject: PyProjectToml = toml::from_str(&content)
.map_err(|err| Error::PyprojectToml(path.user_display().to_string(), err))?;
let Some(tool) = pyproject.tool else {
return Ok(None);
};
let Some(options) = tool.uv else {
return Ok(None);
};

debug!("Found workspace configuration at `{}`", path.display());
return Ok(Some(Self(options)));
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(err.into()),
}

Ok(None)
}

/// Load a [`FilesystemOptions`] from a `uv.toml` file.
pub fn from_file(path: impl AsRef<Path>) -> Result<Self, Error> {
Ok(Self(read_file(path.as_ref())?))
}
}

/// Returns the path to the user configuration directory.
///
/// This is similar to the `config_dir()` returned by the `dirs` crate, but it uses the
/// `XDG_CONFIG_HOME` environment variable on both Linux _and_ macOS, rather than the
/// `Application Support` directory on macOS.
fn config_dir() -> Option<PathBuf> {
// On Windows, use, e.g., C:\Users\Alice\AppData\Roaming
#[cfg(windows)]
{
dirs_sys::known_folder_roaming_app_data()
}

// On Linux and macOS, use, e.g., /home/alice/.config.
#[cfg(not(windows))]
{
std::env::var_os("XDG_CONFIG_HOME")
.and_then(dirs_sys::is_absolute_path)
.or_else(|| dirs_sys::home_dir().map(|path| path.join(".config")))
}
}

/// Load [`Options`] from a `uv.toml` file.
fn read_file(path: &Path) -> Result<Options, Error> {
let content = fs_err::read_to_string(path)?;
let options: Options = toml::from_str(&content)
.map_err(|err| Error::UvToml(path.user_display().to_string(), err))?;
Ok(options)
}

#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error(transparent)]
Io(#[from] std::io::Error),

#[error("Failed to parse: `{0}`")]
PyprojectToml(String, #[source] toml::de::Error),

#[error("Failed to parse: `{0}`")]
UvToml(String, #[source] toml::de::Error),
}
File renamed without changes.
7 changes: 0 additions & 7 deletions crates/uv-workspace/src/lib.rs

This file was deleted.

Loading

0 comments on commit cacd1a2

Please sign in to comment.