From 295b58ad37c25a345df007dcd0b020cdd0d0aab5 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 16 Apr 2024 13:56:47 -0400 Subject: [PATCH] Add `uv-workspace` crate with settings discovery and deserialization (#3007) ## Summary This PR adds basic struct definitions along with a "workspace" concept for discovering settings. (The "workspace" terminology is used to match Ruff; I did not invent it.) A few notes: - We discover any `pyproject.toml` or `uv.toml` file in any parent directory of the current working directory. (We could adjust this to look at the directories of the input files.) - We don't actually do anything with the configuration yet; but those PRs are large and I want this to be reviewed in isolation. --- Cargo.lock | 67 +++++++++++++ Cargo.toml | 8 +- crates/install-wheel-rs/src/linker.rs | 3 +- crates/uv-auth/Cargo.toml | 1 + crates/uv-configuration/src/authentication.rs | 1 + crates/uv-configuration/src/build_options.rs | 1 + .../uv-configuration/src/config_settings.rs | 83 +++++++++++++--- .../uv-configuration/src/name_specifiers.rs | 38 ++++++++ crates/uv-resolver/Cargo.toml | 1 + crates/uv-resolver/src/exclude_newer.rs | 1 + crates/uv-resolver/src/prerelease_mode.rs | 1 + crates/uv-resolver/src/resolution.rs | 1 + crates/uv-resolver/src/resolution_mode.rs | 1 + crates/uv-toolchain/Cargo.toml | 5 +- crates/uv-toolchain/src/python_version.rs | 8 ++ crates/uv-workspace/Cargo.toml | 36 +++++++ crates/uv-workspace/src/lib.rs | 5 + crates/uv-workspace/src/settings.rs | 88 +++++++++++++++++ crates/uv-workspace/src/workspace.rs | 94 +++++++++++++++++++ crates/uv/Cargo.toml | 7 +- crates/uv/src/main.rs | 3 + 21 files changed, 433 insertions(+), 20 deletions(-) create mode 100644 crates/uv-workspace/Cargo.toml create mode 100644 crates/uv-workspace/src/lib.rs create mode 100644 crates/uv-workspace/src/settings.rs create mode 100644 crates/uv-workspace/src/workspace.rs diff --git a/Cargo.lock b/Cargo.lock index 807444dbf..f63f49f1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1052,6 +1052,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56ce8c6da7551ec6c462cbaf3bfbc75131ebbfa1c944aeaa9dab51ca1c5f0c3b" +[[package]] +name = "dyn-clone" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" + [[package]] name = "either" version = "1.11.0" @@ -3321,6 +3327,30 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "schemars" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45a28f4c49489add4ce10783f7911893516f15afe45d015608d41faca6bc4d29" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c767fd6fa65d9ccf9cf026122c1b555f2ef9a4f0cea69da4d7dbc3e258d30967" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 1.0.109", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -3382,6 +3412,17 @@ dependencies = [ "syn 2.0.58", ] +[[package]] +name = "serde_derive_internals" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bf8229e7920a9f636479437026331ce11aa132b4dde37d121944a44d6e5f3c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "serde_json" version = "1.0.115" @@ -4377,6 +4418,7 @@ dependencies = [ "uv-types", "uv-virtualenv", "uv-warnings", + "uv-workspace", ] [[package]] @@ -4391,6 +4433,7 @@ dependencies = [ "reqwest", "reqwest-middleware", "rust-netrc", + "serde", "tempfile", "test-log", "thiserror", @@ -4833,6 +4876,7 @@ dependencies = [ "requirements-txt", "rkyv", "rustc-hash", + "serde", "textwrap", "thiserror", "tokio", @@ -4861,6 +4905,7 @@ dependencies = [ "pep508_rs", "reqwest", "reqwest-middleware", + "serde", "tempfile", "thiserror", "tokio", @@ -4929,6 +4974,28 @@ dependencies = [ "rustc-hash", ] +[[package]] +name = "uv-workspace" +version = "0.0.1" +dependencies = [ + "distribution-types", + "fs-err", + "install-wheel-rs", + "pep508_rs", + "schemars", + "serde", + "serde_json", + "thiserror", + "toml", + "uv-auth", + "uv-configuration", + "uv-fs", + "uv-normalize", + "uv-resolver", + "uv-toolchain", + "uv-warnings", +] + [[package]] name = "valuable" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index eca69ddb1..44ebea813 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ uv-auth = { path = "crates/uv-auth" } uv-build = { path = "crates/uv-build" } uv-cache = { path = "crates/uv-cache" } uv-client = { path = "crates/uv-client" } +uv-configuration = { path = "crates/uv-configuration" } uv-dev = { path = "crates/uv-dev" } uv-dispatch = { path = "crates/uv-dispatch" } uv-distribution = { path = "crates/uv-distribution" } @@ -43,13 +44,13 @@ uv-interpreter = { path = "crates/uv-interpreter" } uv-normalize = { path = "crates/uv-normalize" } uv-requirements = { path = "crates/uv-requirements" } uv-resolver = { path = "crates/uv-resolver" } -uv-types = { path = "crates/uv-types" } -uv-configuration = { path = "crates/uv-configuration" } +uv-toolchain = { path = "crates/uv-toolchain" } uv-trampoline = { path = "crates/uv-trampoline" } +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-toolchain = { path = "crates/uv-toolchain" } +uv-workspace = { path = "crates/uv-workspace" } anstream = { version = "0.6.13" } anyhow = { version = "1.0.80" } @@ -118,6 +119,7 @@ rmp-serde = { version = "1.1.2" } rust-netrc = { version = "0.1.1" } rustc-hash = { version = "1.1.0" } same-file = { version = "1.0.6" } +schemars = { version = "0.8.16" } seahash = { version = "4.1.0" } serde = { version = "1.0.197" } serde_json = { version = "1.0.114" } diff --git a/crates/install-wheel-rs/src/linker.rs b/crates/install-wheel-rs/src/linker.rs index 930c3fa3b..6c6682fb0 100644 --- a/crates/install-wheel-rs/src/linker.rs +++ b/crates/install-wheel-rs/src/linker.rs @@ -8,6 +8,7 @@ use std::time::SystemTime; use fs_err as fs; use fs_err::{DirEntry, File}; use reflink_copy as reflink; +use serde::{Deserialize, Serialize}; use tempfile::tempdir_in; use tracing::{debug, instrument}; @@ -201,7 +202,7 @@ fn parse_scripts( scripts_from_ini(extras, python_minor, ini) } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[cfg_attr(feature = "clap", derive(clap::ValueEnum))] pub enum LinkMode { /// Clone (i.e., copy-on-write) packages from the wheel into the site packages. diff --git a/crates/uv-auth/Cargo.toml b/crates/uv-auth/Cargo.toml index 5dbec17ed..d6ec43701 100644 --- a/crates/uv-auth/Cargo.toml +++ b/crates/uv-auth/Cargo.toml @@ -11,6 +11,7 @@ once_cell = { workspace = true } reqwest = { workspace = true } reqwest-middleware = { workspace = true } rust-netrc = { workspace = true } +serde = { workspace = true, optional = true } thiserror = { workspace = true } tracing = { workspace = true } url = { workspace = true } diff --git a/crates/uv-configuration/src/authentication.rs b/crates/uv-configuration/src/authentication.rs index b4dc82320..2832aaee8 100644 --- a/crates/uv-configuration/src/authentication.rs +++ b/crates/uv-configuration/src/authentication.rs @@ -3,6 +3,7 @@ use uv_auth::{self, KeyringProvider}; /// Keyring provider type to use for credential lookup. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] #[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[cfg_attr(feature = "serde", derive(serde::Deserialize))] pub enum KeyringProviderType { /// Do not use keyring for credential lookup. #[default] diff --git a/crates/uv-configuration/src/build_options.rs b/crates/uv-configuration/src/build_options.rs index 14c8677a7..01710dd43 100644 --- a/crates/uv-configuration/src/build_options.rs +++ b/crates/uv-configuration/src/build_options.rs @@ -198,6 +198,7 @@ impl NoBuild { #[derive(Debug, Default, Clone, Hash, Eq, PartialEq)] #[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[cfg_attr(feature = "serde", derive(serde::Deserialize))] pub enum IndexStrategy { /// Only use results from the first index that returns a match for a given package name. /// diff --git a/crates/uv-configuration/src/config_settings.rs b/crates/uv-configuration/src/config_settings.rs index c7cefc64a..c6f3e5b60 100644 --- a/crates/uv-configuration/src/config_settings.rs +++ b/crates/uv-configuration/src/config_settings.rs @@ -1,3 +1,4 @@ +use serde::ser::SerializeMap; use std::{ collections::{btree_map::Entry, BTreeMap}, str::FromStr, @@ -35,12 +36,53 @@ enum ConfigSettingValue { List(Vec), } +#[cfg(feature = "serde")] +impl serde::Serialize for ConfigSettingValue { + fn serialize(&self, serializer: S) -> Result { + match self { + ConfigSettingValue::String(value) => serializer.serialize_str(value), + ConfigSettingValue::List(values) => serializer.collect_seq(values.iter()), + } + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for ConfigSettingValue { + fn deserialize>(deserializer: D) -> Result { + struct Visitor; + + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = ConfigSettingValue; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a string or list of strings") + } + + fn visit_str(self, value: &str) -> Result { + Ok(ConfigSettingValue::String(value.to_string())) + } + + fn visit_seq>( + self, + mut seq: A, + ) -> Result { + let mut values = Vec::new(); + while let Some(value) = seq.next_element()? { + values.push(value); + } + Ok(ConfigSettingValue::List(values)) + } + } + + deserializer.deserialize_any(Visitor) + } +} + /// Settings to pass to a PEP 517 build backend, structured as a map from (string) key to string or /// list of strings. /// /// See: #[derive(Debug, Default, Clone)] -#[cfg_attr(not(feature = "serde"), allow(dead_code))] pub struct ConfigSettings(BTreeMap); impl FromIterator for ConfigSettings { @@ -77,23 +119,42 @@ impl ConfigSettings { #[cfg(feature = "serde")] impl serde::Serialize for ConfigSettings { fn serialize(&self, serializer: S) -> Result { - use serde::ser::SerializeMap; - let mut map = serializer.serialize_map(Some(self.0.len()))?; for (key, value) in &self.0 { - match value { - ConfigSettingValue::String(value) => { - map.serialize_entry(&key, &value)?; - } - ConfigSettingValue::List(values) => { - map.serialize_entry(&key, &values)?; - } - } + map.serialize_entry(key, value)?; } map.end() } } +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for ConfigSettings { + fn deserialize>(deserializer: D) -> Result { + struct Visitor; + + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = ConfigSettings; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a map from string to string or list of strings") + } + + fn visit_map>( + self, + mut map: A, + ) -> Result { + let mut config = BTreeMap::default(); + while let Some((key, value)) = map.next_entry()? { + config.insert(key, value); + } + Ok(ConfigSettings(config)) + } + } + + deserializer.deserialize_map(Visitor) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/uv-configuration/src/name_specifiers.rs b/crates/uv-configuration/src/name_specifiers.rs index a17ab5e31..8496b9831 100644 --- a/crates/uv-configuration/src/name_specifiers.rs +++ b/crates/uv-configuration/src/name_specifiers.rs @@ -21,6 +21,44 @@ impl FromStr for PackageNameSpecifier { } } +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for PackageNameSpecifier { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct Visitor; + + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = PackageNameSpecifier; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a package name or `:all:` or `:none:`") + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + // Accept the special values `:all:` and `:none:`. + match value { + ":all:" => Ok(PackageNameSpecifier::All), + ":none:" => Ok(PackageNameSpecifier::None), + _ => { + // Otherwise, parse the value as a package name. + match PackageName::from_str(value) { + Ok(name) => Ok(PackageNameSpecifier::Package(name)), + Err(err) => Err(E::custom(err)), + } + } + } + } + } + + deserializer.deserialize_str(Visitor) + } +} + /// Package name specification. /// /// Consumes both package names and selection directives for compatibility with pip flags diff --git a/crates/uv-resolver/Cargo.toml b/crates/uv-resolver/Cargo.toml index 55269d8c6..08a853693 100644 --- a/crates/uv-resolver/Cargo.toml +++ b/crates/uv-resolver/Cargo.toml @@ -48,6 +48,7 @@ petgraph = { workspace = true } pubgrub = { workspace = true } rkyv = { workspace = true } rustc-hash = { workspace = true } +serde = { workspace = true, optional = true } textwrap = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["macros"] } diff --git a/crates/uv-resolver/src/exclude_newer.rs b/crates/uv-resolver/src/exclude_newer.rs index 260e202ad..68b8d8f74 100644 --- a/crates/uv-resolver/src/exclude_newer.rs +++ b/crates/uv-resolver/src/exclude_newer.rs @@ -4,6 +4,7 @@ use chrono::{DateTime, Days, NaiveDate, NaiveTime, Utc}; /// A timestamp that excludes files newer than it. #[derive(Debug, Copy, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct ExcludeNewer(DateTime); impl ExcludeNewer { diff --git a/crates/uv-resolver/src/prerelease_mode.rs b/crates/uv-resolver/src/prerelease_mode.rs index 7d911ef2a..db79e7c0d 100644 --- a/crates/uv-resolver/src/prerelease_mode.rs +++ b/crates/uv-resolver/src/prerelease_mode.rs @@ -7,6 +7,7 @@ use crate::Manifest; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] #[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[cfg_attr(feature = "serde", derive(serde::Deserialize))] pub enum PreReleaseMode { /// Disallow all pre-release versions. Disallow, diff --git a/crates/uv-resolver/src/resolution.rs b/crates/uv-resolver/src/resolution.rs index 533a979b0..8446bd82c 100644 --- a/crates/uv-resolver/src/resolution.rs +++ b/crates/uv-resolver/src/resolution.rs @@ -35,6 +35,7 @@ use crate::{Manifest, ResolveError}; /// package. #[derive(Debug, Default, Copy, Clone, PartialEq)] #[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[cfg_attr(feature = "serde", derive(serde::Deserialize))] pub enum AnnotationStyle { /// Render the annotations on a single, comma-separated line. Line, diff --git a/crates/uv-resolver/src/resolution_mode.rs b/crates/uv-resolver/src/resolution_mode.rs index c750dff78..7183cd4b3 100644 --- a/crates/uv-resolver/src/resolution_mode.rs +++ b/crates/uv-resolver/src/resolution_mode.rs @@ -7,6 +7,7 @@ use crate::Manifest; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] #[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[cfg_attr(feature = "serde", derive(serde::Deserialize))] pub enum ResolutionMode { /// Resolve the highest compatible version of each package. #[default] diff --git a/crates/uv-toolchain/Cargo.toml b/crates/uv-toolchain/Cargo.toml index 59bab562e..c9361c394 100644 --- a/crates/uv-toolchain/Cargo.toml +++ b/crates/uv-toolchain/Cargo.toml @@ -10,11 +10,11 @@ authors.workspace = true license.workspace = true [dependencies] +pep440_rs = { workspace = true } +pep508_rs = { workspace = true } uv-client = { workspace = true } uv-extract = { workspace = true } uv-fs = { workspace = true } -pep440_rs = { workspace = true } -pep508_rs = { workspace = true } anyhow = { workspace = true } fs-err = { workspace = true } @@ -22,6 +22,7 @@ futures = { workspace = true } once_cell = {workspace = true} reqwest = { workspace = true } reqwest-middleware = { workspace = true } +serde = { workspace = true, optional = true } tempfile = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } diff --git a/crates/uv-toolchain/src/python_version.rs b/crates/uv-toolchain/src/python_version.rs index ba3d36385..d15b35abe 100644 --- a/crates/uv-toolchain/src/python_version.rs +++ b/crates/uv-toolchain/src/python_version.rs @@ -41,6 +41,14 @@ impl FromStr for PythonVersion { } } +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for PythonVersion { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + PythonVersion::from_str(&s).map_err(serde::de::Error::custom) + } +} + impl Display for PythonVersion { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { Display::fmt(&self.0, f) diff --git a/crates/uv-workspace/Cargo.toml b/crates/uv-workspace/Cargo.toml new file mode 100644 index 000000000..b15a0495a --- /dev/null +++ b/crates/uv-workspace/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "uv-workspace" +version = "0.0.1" +edition = { workspace = true } +rust-version = { workspace = true } +homepage = { workspace = true } +documentation = { workspace = true } +repository = { workspace = true } +authors = { workspace = true } +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +distribution-types = { workspace = true } +install-wheel-rs = { workspace = true } +pep508_rs = { workspace = true } +uv-auth = { workspace = true, features = ["serde"] } +uv-configuration = { workspace = true, features = ["serde"] } +uv-fs = { workspace = true } +uv-normalize = { workspace = true } +uv-resolver = { workspace = true, features = ["serde"] } +uv-toolchain = { workspace = true, features = ["serde"] } +uv-warnings = { workspace = true } + +fs-err = { workspace = true } +schemars = { workspace = true, optional = true } +serde = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } +thiserror = { workspace = true } +toml = { workspace = true } + +[features] +default = [] +serde = ["dep:serde", "dep:serde_json"] diff --git a/crates/uv-workspace/src/lib.rs b/crates/uv-workspace/src/lib.rs new file mode 100644 index 000000000..2b3242e69 --- /dev/null +++ b/crates/uv-workspace/src/lib.rs @@ -0,0 +1,5 @@ +pub use crate::settings::*; +pub use crate::workspace::*; + +mod settings; +mod workspace; diff --git a/crates/uv-workspace/src/settings.rs b/crates/uv-workspace/src/settings.rs new file mode 100644 index 000000000..5e8ff6a31 --- /dev/null +++ b/crates/uv-workspace/src/settings.rs @@ -0,0 +1,88 @@ +use std::path::PathBuf; + +use serde::Deserialize; + +use distribution_types::{FlatIndexLocation, IndexUrl}; +use install_wheel_rs::linker::LinkMode; +use uv_configuration::{ConfigSettings, IndexStrategy, KeyringProviderType, PackageNameSpecifier}; +use uv_normalize::PackageName; +use uv_resolver::{AnnotationStyle, ExcludeNewer, PreReleaseMode, ResolutionMode}; +use uv_toolchain::PythonVersion; + +/// A `pyproject.toml` with an (optional) `[tool.uv]` section. +#[allow(dead_code)] +#[derive(Debug, Clone, Default, Deserialize)] +pub(crate) struct PyProjectToml { + pub(crate) tool: Option, +} + +/// A `[tool]` section. +#[allow(dead_code)] +#[derive(Debug, Clone, Default, Deserialize)] +pub(crate) struct Tools { + pub(crate) uv: Option, +} + +/// A `[tool.uv]` section. +#[allow(dead_code)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct Options { + pub quiet: Option, + pub verbose: Option, + pub native_tls: Option, + pub no_cache: bool, + pub cache_dir: Option, + pub pip: Option, +} + +/// A `[tool.uv.pip]` section. +#[allow(dead_code)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct PipOptions { + pub system: Option, + pub offline: Option, + pub index_url: Option, + pub extra_index_url: Option, + pub no_index: Option, + pub find_links: Option>, + pub index_strategy: Option, + pub keyring_provider: Option, + pub no_build: Option, + pub no_binary: Option>, + pub only_binary: Option>, + pub no_build_isolation: Option, + pub resolver: Option, + pub installer: Option, +} + +/// A `[tool.uv.pip.resolver]` section. +#[allow(dead_code)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct ResolverOptions { + pub resolution: Option, + pub prerelease: Option, + pub no_strip_extras: Option, + pub no_annotate: Option, + pub no_header: Option, + pub generate_hashes: Option, + pub legacy_setup_py: Option, + pub config_setting: Option, + pub python_version: Option, + pub exclude_newer: Option, + pub no_emit_package: Option>, + pub emit_index_url: Option, + pub emit_find_links: Option, + pub annotation_style: Option, +} + +/// A `[tool.uv.pip.installer]` section. +#[allow(dead_code)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct InstallerOptions { + pub link_mode: Option, + pub compile_bytecode: Option, +} diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs new file mode 100644 index 000000000..17d0b8f27 --- /dev/null +++ b/crates/uv-workspace/src/workspace.rs @@ -0,0 +1,94 @@ +use std::path::{Path, PathBuf}; + +use uv_fs::Simplified; +use uv_warnings::warn_user; + +use crate::{Options, PyProjectToml}; + +/// Represents a project workspace that contains a set of options and a root path. +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub struct Workspace { + options: Options, + root: PathBuf, +} + +impl Workspace { + /// Find the [`Workspace`] for the given path. + /// + /// The search starts at the given path and goes up the directory tree until a workspace is + /// found. + pub fn find(path: impl AsRef) -> Result, WorkspaceError> { + for ancestor in path.as_ref().ancestors() { + match read_options(ancestor) { + Ok(Some(options)) => { + return Ok(Some(Self { + options, + root: ancestor.to_path_buf(), + })) + } + Ok(None) => { + // Continue traversing the directory tree. + } + Err(err @ WorkspaceError::PyprojectToml(..)) => { + // If we see an invalid `pyproject.toml`, warn but continue. + warn_user!("{err}"); + } + Err(err) => { + // Otherwise, warn and stop. + return Err(err); + } + } + } + Ok(None) + } +} + +/// Read a `uv.toml` or `pyproject.toml` file in the given directory. +fn read_options(dir: &Path) -> Result, WorkspaceError> { + // Read a `uv.toml` file in the current directory. + let path = dir.join("uv.toml"); + match fs_err::read_to_string(&path) { + Ok(content) => { + let options: Options = toml::from_str(&content) + .map_err(|err| WorkspaceError::UvToml(path.user_display().to_string(), err))?; + return Ok(Some(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 = path.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| { + WorkspaceError::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); + }; + return Ok(Some(options)); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), + } + + Ok(None) +} + +#[derive(thiserror::Error, Debug)] +pub enum WorkspaceError { + #[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), +} diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index 3885b72d4..9589e2615 100644 --- a/crates/uv/Cargo.toml +++ b/crates/uv/Cargo.toml @@ -23,6 +23,7 @@ requirements-txt = { workspace = true, features = ["http"] } uv-auth = { workspace = true } uv-cache = { workspace = true, features = ["clap"] } uv-client = { workspace = true } +uv-configuration = { workspace = true, features = ["clap"] } uv-dispatch = { workspace = true } uv-distribution = { workspace = true } uv-fs = { workspace = true } @@ -31,11 +32,11 @@ uv-interpreter = { workspace = true } uv-normalize = { workspace = true } uv-requirements = { workspace = true } uv-resolver = { workspace = true, features = ["clap"] } -uv-types = { workspace = true, features = ["clap"] } -uv-configuration = { workspace = true, features = ["clap"] } -uv-virtualenv = { workspace = true } uv-toolchain = { workspace = true } +uv-types = { workspace = true, features = ["clap"] } +uv-virtualenv = { workspace = true } uv-warnings = { workspace = true } +uv-workspace = { workspace = true, features = ["serde", "schemars"] } anstream = { workspace = true } anyhow = { workspace = true } diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index 2259356b3..d62f9d28a 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -104,6 +104,9 @@ async fn run() -> Result { } }; + // Load the workspace settings. + let _ = uv_workspace::Workspace::find(env::current_dir()?)?; + let globals = cli.global_args; // Configure the `tracing` crate, which controls internal logging.