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.
This commit is contained in:
Charlie Marsh 2024-04-16 13:56:47 -04:00 committed by GitHub
parent c0efeeddf6
commit 295b58ad37
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 433 additions and 20 deletions

View file

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

View file

@ -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 }

View file

@ -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]

View file

@ -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.
///

View file

@ -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<String>),
}
#[cfg(feature = "serde")]
impl serde::Serialize for ConfigSettingValue {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
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<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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<E: serde::de::Error>(self, value: &str) -> Result<Self::Value, E> {
Ok(ConfigSettingValue::String(value.to_string()))
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(
self,
mut seq: A,
) -> Result<Self::Value, A::Error> {
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: <https://peps.python.org/pep-0517/#config-settings>
#[derive(Debug, Default, Clone)]
#[cfg_attr(not(feature = "serde"), allow(dead_code))]
pub struct ConfigSettings(BTreeMap<String, ConfigSettingValue>);
impl FromIterator<ConfigSettingEntry> for ConfigSettings {
@ -77,23 +119,42 @@ impl ConfigSettings {
#[cfg(feature = "serde")]
impl serde::Serialize for ConfigSettings {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
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<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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<A: serde::de::MapAccess<'de>>(
self,
mut map: A,
) -> Result<Self::Value, A::Error> {
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::*;

View file

@ -21,6 +21,44 @@ impl FromStr for PackageNameSpecifier {
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for PackageNameSpecifier {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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<E>(self, value: &str) -> Result<Self::Value, E>
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

View file

@ -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"] }

View file

@ -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<Utc>);
impl ExcludeNewer {

View file

@ -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,

View file

@ -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,

View file

@ -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]

View file

@ -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 }

View file

@ -41,6 +41,14 @@ impl FromStr for PythonVersion {
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for PythonVersion {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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)

View file

@ -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"]

View file

@ -0,0 +1,5 @@
pub use crate::settings::*;
pub use crate::workspace::*;
mod settings;
mod workspace;

View file

@ -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<Tools>,
}
/// A `[tool]` section.
#[allow(dead_code)]
#[derive(Debug, Clone, Default, Deserialize)]
pub(crate) struct Tools {
pub(crate) uv: Option<Options>,
}
/// 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<bool>,
pub verbose: Option<bool>,
pub native_tls: Option<bool>,
pub no_cache: bool,
pub cache_dir: Option<PathBuf>,
pub pip: Option<PipOptions>,
}
/// 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<bool>,
pub offline: Option<bool>,
pub index_url: Option<IndexUrl>,
pub extra_index_url: Option<IndexUrl>,
pub no_index: Option<bool>,
pub find_links: Option<Vec<FlatIndexLocation>>,
pub index_strategy: Option<IndexStrategy>,
pub keyring_provider: Option<KeyringProviderType>,
pub no_build: Option<bool>,
pub no_binary: Option<Vec<PackageNameSpecifier>>,
pub only_binary: Option<Vec<PackageNameSpecifier>>,
pub no_build_isolation: Option<bool>,
pub resolver: Option<ResolverOptions>,
pub installer: Option<InstallerOptions>,
}
/// 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<ResolutionMode>,
pub prerelease: Option<PreReleaseMode>,
pub no_strip_extras: Option<bool>,
pub no_annotate: Option<bool>,
pub no_header: Option<bool>,
pub generate_hashes: Option<bool>,
pub legacy_setup_py: Option<bool>,
pub config_setting: Option<ConfigSettings>,
pub python_version: Option<PythonVersion>,
pub exclude_newer: Option<ExcludeNewer>,
pub no_emit_package: Option<Vec<PackageName>>,
pub emit_index_url: Option<bool>,
pub emit_find_links: Option<bool>,
pub annotation_style: Option<AnnotationStyle>,
}
/// 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<LinkMode>,
pub compile_bytecode: Option<bool>,
}

View file

@ -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<Path>) -> Result<Option<Self>, 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<Option<Options>, 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),
}

View file

@ -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 }

View file

@ -104,6 +104,9 @@ async fn run() -> Result<ExitStatus> {
}
};
// 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.