Add override namespace to pyproject.toml/uv.toml (#3839)

<!--
Thank you for contributing to uv! To help us out with reviewing, please
consider the following:

- Does this pull request include a summary of the change? (See below.)
- Does this pull request include a descriptive title?
- Does this pull request include references to any relevant issues?
-->

## Summary

See #3834 .

This PR adds a new namespace, `override-dependencies`, to
pyproject.toml/uv.toml.
This namespace assumes that the dependencies you want to override are
written in the form of `requirements.txt`.


a example of pyproject.toml
```toml
[project]
name = "example"
version = "0.0.0"
dependencies = [
  "flask==3.0.0"
]

[tool.uv]
override-dependencies = [
  "werkzeug==2.3.0"
]
```

This will improve usability by allowing you to override dependencies
without having to specify the --override option when running `uv pip
compile/install`.

## Test Plan

added test to `crates/uv/tests/pip_compile.rs`.

---------

Co-authored-by: konstin <konstin@mailbox.org>
This commit is contained in:
Di-Is 2024-06-03 19:15:51 +09:00 committed by GitHub
parent 1b769b054c
commit 5c776939d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 293 additions and 8 deletions

View file

@ -25,13 +25,25 @@ impl std::fmt::Display for SourceAnnotation {
RequirementOrigin::Project(path, project_name) => {
write!(f, "{project_name} ({})", path.portable_display())
}
RequirementOrigin::Workspace => {
write!(f, "(workspace)")
}
},
Self::Constraint(origin) => {
write!(f, "-c {}", origin.path().portable_display())
}
Self::Override(origin) => {
write!(f, "--override {}", origin.path().portable_display())
}
Self::Override(origin) => match origin {
RequirementOrigin::File(path) => {
write!(f, "--override {}", path.portable_display())
}
RequirementOrigin::Project(path, project_name) => {
// Project is not used for override
write!(f, "--override {project_name} ({})", path.portable_display())
}
RequirementOrigin::Workspace => {
write!(f, "--override (workspace)")
}
},
}
}
}

View file

@ -9,6 +9,8 @@ pub enum RequirementOrigin {
File(PathBuf),
/// The requirement was provided via a local project (e.g., a `pyproject.toml` file).
Project(PathBuf, PackageName),
/// The requirement was provided via a workspace.
Workspace,
}
impl RequirementOrigin {
@ -17,6 +19,8 @@ impl RequirementOrigin {
match self {
RequirementOrigin::File(path) => path.as_path(),
RequirementOrigin::Project(path, _) => path.as_path(),
// Multiple toml are merged and difficult to track files where Requirement is defined. Returns a dummy path instead.
RequirementOrigin::Workspace => Path::new("(workspace)"),
}
}
}

View file

@ -15,6 +15,8 @@ workspace = true
[dependencies]
distribution-types = { workspace = true, features = ["schemars"] }
install-wheel-rs = { workspace = true, features = ["schemars"] }
pep508_rs = { workspace = true }
pypi-types = { workspace = true }
uv-configuration = { workspace = true, features = ["schemars"] }
uv-fs = { workspace = true }
uv-normalize = { workspace = true, features = ["schemars"] }

View file

@ -47,6 +47,9 @@ impl Combine for Options {
preview: self.preview.combine(other.preview),
cache_dir: self.cache_dir.combine(other.cache_dir),
pip: self.pip.combine(other.pip),
override_dependencies: self
.override_dependencies
.combine(other.override_dependencies),
}
}
}

View file

@ -1,9 +1,10 @@
use std::{num::NonZeroUsize, path::PathBuf};
use std::{fmt::Debug, num::NonZeroUsize, path::PathBuf};
use serde::Deserialize;
use distribution_types::{FlatIndexLocation, IndexUrl};
use install_wheel_rs::linker::LinkMode;
use pypi_types::VerbatimParsedUrl;
use uv_configuration::{
ConfigSettings, IndexStrategy, KeyringProviderType, PackageNameSpecifier, TargetTriple,
};
@ -37,6 +38,14 @@ pub struct Options {
pub preview: Option<bool>,
pub cache_dir: Option<PathBuf>,
pub pip: Option<PipOptions>,
#[cfg_attr(
feature = "schemars",
schemars(
with = "Option<Vec<String>>",
description = "PEP 508 style requirements, e.g. `flask==3.0.0`, or `black @ https://...`."
)
)]
pub override_dependencies: Option<Vec<pep508_rs::Requirement<VerbatimParsedUrl>>>,
}
/// A `[tool.uv.pip]` section.

View file

@ -47,9 +47,9 @@ impl Workspace {
Ok(None) => {
// Continue traversing the directory tree.
}
Err(err @ WorkspaceError::PyprojectToml(..)) => {
Err(WorkspaceError::PyprojectToml(file, err)) => {
// If we see an invalid `pyproject.toml`, warn but continue.
warn_user!("{err}");
warn_user!("Failed to parse `{file}`: {err}");
}
Err(err) => {
// Otherwise, warn and stop.

View file

@ -11,9 +11,13 @@ use anyhow::{anyhow, Result};
use fs_err as fs;
use itertools::Itertools;
use owo_colors::OwoColorize;
use pypi_types::Requirement;
use tracing::debug;
use distribution_types::{IndexLocations, SourceAnnotation, SourceAnnotations, Verbatim};
use distribution_types::{
IndexLocations, SourceAnnotation, SourceAnnotations, UnresolvedRequirementSpecification,
Verbatim,
};
use install_wheel_rs::linker::LinkMode;
use platform_tags::Tags;
use uv_auth::store_credentials_from_url;
@ -57,6 +61,7 @@ pub(crate) async fn pip_compile(
requirements: &[RequirementsSource],
constraints: &[RequirementsSource],
overrides: &[RequirementsSource],
overrides_from_workspace: Vec<Requirement>,
extras: ExtrasSpecification,
output_file: Option<&Path>,
resolution_mode: ResolutionMode,
@ -398,6 +403,17 @@ pub(crate) async fn pip_compile(
requirements
};
// Merge workspace overrides.
let overrides: Vec<UnresolvedRequirementSpecification> = overrides
.iter()
.cloned()
.chain(
overrides_from_workspace
.into_iter()
.map(UnresolvedRequirementSpecification::from),
)
.collect();
// Resolve the overrides from the provided sources.
let overrides = NamedRequirementsResolver::new(
overrides,

View file

@ -7,9 +7,10 @@ use itertools::Itertools;
use owo_colors::OwoColorize;
use tracing::{debug, enabled, Level};
use distribution_types::{IndexLocations, Resolution};
use distribution_types::{IndexLocations, Resolution, UnresolvedRequirementSpecification};
use install_wheel_rs::linker::LinkMode;
use platform_tags::Tags;
use pypi_types::Requirement;
use uv_auth::store_credentials_from_url;
use uv_cache::Cache;
use uv_client::{BaseClientBuilder, Connectivity, FlatIndexClient, RegistryClientBuilder};
@ -42,6 +43,7 @@ pub(crate) async fn pip_install(
requirements: &[RequirementsSource],
constraints: &[RequirementsSource],
overrides: &[RequirementsSource],
overrides_from_workspace: Vec<Requirement>,
extras: &ExtrasSpecification,
resolution_mode: ResolutionMode,
prerelease_mode: PreReleaseMode,
@ -106,6 +108,16 @@ pub(crate) async fn pip_install(
)
.await?;
let overrides: Vec<UnresolvedRequirementSpecification> = overrides
.iter()
.cloned()
.chain(
overrides_from_workspace
.into_iter()
.map(UnresolvedRequirementSpecification::from),
)
.collect();
// Detect the current Python interpreter.
let system = if system {
SystemPython::Required

View file

@ -205,6 +205,7 @@ async fn run() -> Result<ExitStatus> {
&requirements,
&constraints,
&overrides,
args.overrides_from_workspace,
args.shared.extras,
args.shared.output_file.as_deref(),
args.shared.resolution,
@ -345,6 +346,7 @@ async fn run() -> Result<ExitStatus> {
&requirements,
&constraints,
&overrides,
args.overrides_from_workspace,
&args.shared.extras,
args.shared.resolution,
args.shared.prerelease,

View file

@ -7,6 +7,8 @@ use std::str::FromStr;
use distribution_types::IndexLocations;
use install_wheel_rs::linker::LinkMode;
use pep508_rs::RequirementOrigin;
use pypi_types::Requirement;
use uv_cache::{CacheArgs, Refresh};
use uv_client::Connectivity;
use uv_configuration::{
@ -229,6 +231,8 @@ pub(crate) struct PipCompileSettings {
// Shared settings.
pub(crate) shared: PipSharedSettings,
// Override dependencies from workspace.
pub(crate) overrides_from_workspace: Vec<Requirement>,
}
impl PipCompileSettings {
@ -298,6 +302,21 @@ impl PipCompileSettings {
compat_args: _,
} = args;
let overrides_from_workspace: Vec<Requirement> = if let Some(workspace) = &workspace {
workspace
.options
.override_dependencies
.clone()
.unwrap_or_default()
.into_iter()
.map(|requirement| {
Requirement::from(requirement.with_origin(RequirementOrigin::Workspace))
})
.collect()
} else {
Vec::new()
};
Self {
// CLI-only settings.
src_file,
@ -309,6 +328,7 @@ impl PipCompileSettings {
refresh: Refresh::from_args(flag(refresh, no_refresh), refresh_package),
upgrade: Upgrade::from_args(flag(upgrade, no_upgrade), upgrade_package),
uv_lock: flag(unstable_uv_lock_file, no_unstable_uv_lock_file).unwrap_or(false),
overrides_from_workspace,
// Shared settings.
shared: PipSharedSettings::combine(
@ -503,6 +523,7 @@ pub(crate) struct PipInstallSettings {
pub(crate) refresh: Refresh,
pub(crate) dry_run: bool,
pub(crate) uv_lock: Option<String>,
pub(crate) overrides_from_workspace: Vec<Requirement>,
// Shared settings.
pub(crate) shared: PipSharedSettings,
@ -570,6 +591,22 @@ impl PipInstallSettings {
compat_args: _,
} = args;
let overrides_from_workspace: Vec<pypi_types::Requirement> =
if let Some(workspace) = &workspace {
workspace
.options
.override_dependencies
.clone()
.unwrap_or_default()
.into_iter()
.map(|requirement| {
Requirement::from(requirement.with_origin(RequirementOrigin::Workspace))
})
.collect()
} else {
Vec::new()
};
Self {
// CLI-only settings.
package,
@ -585,6 +622,7 @@ impl PipInstallSettings {
refresh: Refresh::from_args(flag(refresh, no_refresh), refresh_package),
dry_run,
uv_lock: unstable_uv_lock_file,
overrides_from_workspace,
// Shared settings.
shared: PipSharedSettings::combine(

View file

@ -2880,6 +2880,126 @@ fn override_dependency() -> Result<()> {
Ok(())
}
/// Check that `tool.uv.override-dependencies` in `pyproject.toml` is respected.
#[test]
fn override_dependency_from_pyproject() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"[project]
name = "example"
version = "0.0.0"
dependencies = [
"flask==3.0.0"
]
[tool.uv]
override-dependencies = [
"werkzeug==2.3.0"
]
"#,
)?;
uv_snapshot!(context.compile()
.arg("pyproject.toml")
.current_dir(&context.temp_dir)
, @r###"
success: true
exit_code: 0
----- stdout -----
# This file was autogenerated by uv via the following command:
# uv pip compile --cache-dir [CACHE_DIR] --exclude-newer 2024-03-25T00:00:00Z pyproject.toml
blinker==1.7.0
# via flask
click==8.1.7
# via flask
flask==3.0.0
# via example (pyproject.toml)
itsdangerous==2.1.2
# via flask
jinja2==3.1.3
# via flask
markupsafe==2.1.5
# via
# jinja2
# werkzeug
werkzeug==2.3.0
# via
# --override (workspace)
# flask
----- stderr -----
Resolved 7 packages in [TIME]
"###
);
Ok(())
}
/// Check that `override-dependencies` in `uv.toml` is respected.
#[test]
fn override_dependency_from_specific_uv_toml() -> Result<()> {
let context = TestContext::new("3.12");
let _ = context.temp_dir.child("project").create_dir_all();
let pyproject_toml = context.temp_dir.child("project/pyproject.toml");
pyproject_toml.write_str(
r#"[project]
name = "example"
version = "0.0.0"
dependencies = [
"flask==3.0.0"
]
"#,
)?;
let _ = context.temp_dir.child("uv").create_dir_all();
let uv_toml: assert_fs::fixture::ChildPath = context.temp_dir.child("uv").child("uv.toml");
uv_toml.write_str(
r#"
override-dependencies = [
"werkzeug==2.3.0"
]
"#,
)?;
uv_snapshot!(context.compile()
.arg("pyproject.toml")
.arg("--config-file")
.arg("../uv/uv.toml")
.current_dir(&context.temp_dir.child("project"))
, @r###"
success: true
exit_code: 0
----- stdout -----
# This file was autogenerated by uv via the following command:
# uv pip compile --cache-dir [CACHE_DIR] --exclude-newer 2024-03-25T00:00:00Z pyproject.toml --config-file ../uv/uv.toml
blinker==1.7.0
# via flask
click==8.1.7
# via flask
flask==3.0.0
# via example (pyproject.toml)
itsdangerous==2.1.2
# via flask
jinja2==3.1.3
# via flask
markupsafe==2.1.5
# via
# jinja2
# werkzeug
werkzeug==2.3.0
# via
# --override (workspace)
# flask
----- stderr -----
Resolved 7 packages in [TIME]
"###
);
Ok(())
}
/// Black==23.10.1 depends on tomli>=1.1.0 for Python versions below 3.11. Demonstrate that we can
/// override it with a multi-line override.
#[test]
@ -2927,6 +3047,61 @@ fn override_multi_dependency() -> Result<()> {
Ok(())
}
/// Check how invalid `tool.uv.override-dependencies` is handled in `pyproject.toml`.
// TODO(konsti): We should show a warnings here or better fail parsing.
#[test]
fn override_dependency_from_workspace_invalid_syntax() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"[project]
name = "example"
version = "0.0.0"
dependencies = [
"flask==3.0.0"
]
[tool.uv]
override-dependencies = [
"werkzeug=2.3.0"
]
"#,
)?;
uv_snapshot!(context.compile()
.arg("pyproject.toml")
.current_dir(&context.temp_dir)
, @r###"
success: true
exit_code: 0
----- stdout -----
# This file was autogenerated by uv via the following command:
# uv pip compile --cache-dir [CACHE_DIR] --exclude-newer 2024-03-25T00:00:00Z pyproject.toml
blinker==1.7.0
# via flask
click==8.1.7
# via flask
flask==3.0.0
# via example (pyproject.toml)
itsdangerous==2.1.2
# via flask
jinja2==3.1.3
# via flask
markupsafe==2.1.5
# via
# jinja2
# werkzeug
werkzeug==3.0.1
# via flask
----- stderr -----
Resolved 7 packages in [TIME]
"###
);
Ok(())
}
/// Flask==3.0.0 depends on Werkzeug>=3.0.0. Demonstrate that we can override this
/// requirement with a URL.
#[test]