Skip to content

Commit 7b72b55

Browse files
authored
Opt-out tool.uv.sources support for uv add (#4406)
## Summary After this change, `uv add` will try to use `tool.uv.sources` for all source requirements. If a source cannot be resolved, i.e. an ambiguous Git reference is provided, it will error. Git references can be specified with the `--tag`, `--branch`, or `--rev` arguments. Editables are also supported with `--editable`. Users can opt-out of `tool.uv.sources` support with the `--raw` flag, which will force uv to use `project.dependencies`. Part of #3959.
1 parent 3c5b136 commit 7b72b55

File tree

8 files changed

+484
-40
lines changed

8 files changed

+484
-40
lines changed

crates/pep508-rs/src/lib.rs

+9
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,15 @@ pub struct Requirement<T: Pep508Url = VerbatimUrl> {
153153
pub origin: Option<RequirementOrigin>,
154154
}
155155

156+
impl<T: Pep508Url> Requirement<T> {
157+
/// Removes the URL specifier from this requirement.
158+
pub fn clear_url(&mut self) {
159+
if matches!(self.version_or_url, Some(VersionOrUrl::Url(_))) {
160+
self.version_or_url = None;
161+
}
162+
}
163+
}
164+
156165
impl<T: Pep508Url + Display> Display for Requirement<T> {
157166
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
158167
write!(f, "{}", self.name)?;

crates/uv-distribution/src/pyproject.rs

+88-2
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,17 @@
66
//!
77
//! Then lowers them into a dependency specification.
88
9-
use std::collections::BTreeMap;
109
use std::ops::Deref;
10+
use std::{collections::BTreeMap, mem};
1111

1212
use glob::Pattern;
1313
use serde::{Deserialize, Serialize};
14+
use thiserror::Error;
1415
use url::Url;
1516

1617
use pep440_rs::VersionSpecifiers;
17-
use pypi_types::VerbatimParsedUrl;
18+
use pypi_types::{RequirementSource, VerbatimParsedUrl};
19+
use uv_git::GitReference;
1820
use uv_normalize::{ExtraName, PackageName};
1921

2022
/// A `pyproject.toml` as specified in PEP 517.
@@ -182,6 +184,90 @@ pub enum Source {
182184
},
183185
}
184186

187+
#[derive(Error, Debug)]
188+
pub enum SourceError {
189+
#[error("Cannot resolve git reference `{0}`.")]
190+
UnresolvedReference(String),
191+
#[error("Workspace dependency must be a local path.")]
192+
InvalidWorkspaceRequirement,
193+
}
194+
195+
impl Source {
196+
pub fn from_requirement(
197+
source: RequirementSource,
198+
workspace: bool,
199+
editable: Option<bool>,
200+
rev: Option<String>,
201+
tag: Option<String>,
202+
branch: Option<String>,
203+
) -> Result<Option<Source>, SourceError> {
204+
if workspace {
205+
match source {
206+
RequirementSource::Registry { .. } | RequirementSource::Directory { .. } => {}
207+
_ => return Err(SourceError::InvalidWorkspaceRequirement),
208+
}
209+
210+
return Ok(Some(Source::Workspace {
211+
editable,
212+
workspace: true,
213+
}));
214+
}
215+
216+
let source = match source {
217+
RequirementSource::Registry { .. } => return Ok(None),
218+
RequirementSource::Path { lock_path, .. } => Source::Path {
219+
editable,
220+
path: lock_path.to_string_lossy().into_owned(),
221+
},
222+
RequirementSource::Directory { lock_path, .. } => Source::Path {
223+
editable,
224+
path: lock_path.to_string_lossy().into_owned(),
225+
},
226+
RequirementSource::Url {
227+
subdirectory, url, ..
228+
} => Source::Url {
229+
url: url.to_url(),
230+
subdirectory: subdirectory.map(|path| path.to_string_lossy().into_owned()),
231+
},
232+
RequirementSource::Git {
233+
repository,
234+
mut reference,
235+
subdirectory,
236+
..
237+
} => {
238+
// We can only resolve a full commit hash from a pep508 URL, everything else is ambiguous.
239+
let rev = match reference {
240+
GitReference::FullCommit(ref mut rev) => Some(mem::take(rev)),
241+
_ => None,
242+
}
243+
// Give precedence to an explicit argument.
244+
.or(rev);
245+
246+
// Error if the user tried to specify a reference but didn't disambiguate.
247+
if reference != GitReference::DefaultBranch
248+
&& rev.is_none()
249+
&& tag.is_none()
250+
&& branch.is_none()
251+
{
252+
return Err(SourceError::UnresolvedReference(
253+
reference.as_str().unwrap().to_owned(),
254+
));
255+
}
256+
257+
Source::Git {
258+
rev,
259+
tag,
260+
branch,
261+
git: repository,
262+
subdirectory: subdirectory.map(|path| path.to_string_lossy().into_owned()),
263+
}
264+
}
265+
};
266+
267+
Ok(Some(source))
268+
}
269+
}
270+
185271
/// <https://github.com/serde-rs/serde/issues/1316#issue-332908452>
186272
mod serde_from_and_to_string {
187273
use std::fmt::Display;

crates/uv-distribution/src/pyproject_mut.rs

+17-19
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
use std::fmt;
21
use std::str::FromStr;
2+
use std::{fmt, mem};
33

44
use thiserror::Error;
5-
use toml_edit::{Array, DocumentMut, InlineTable, Item, RawString, Table, TomlError, Value};
5+
use toml_edit::{Array, DocumentMut, Item, RawString, Table, TomlError, Value};
66

77
use pep508_rs::{PackageName, Requirement};
88
use pypi_types::VerbatimParsedUrl;
@@ -21,6 +21,8 @@ pub struct PyProjectTomlMut {
2121
pub enum Error {
2222
#[error("Failed to parse `pyproject.toml`")]
2323
Parse(#[from] Box<TomlError>),
24+
#[error("Failed to serialize `pyproject.toml`")]
25+
Serialize(#[from] Box<toml::ser::Error>),
2426
#[error("Dependencies in `pyproject.toml` are malformed")]
2527
MalformedDependencies,
2628
#[error("Sources in `pyproject.toml` are malformed")]
@@ -72,7 +74,7 @@ impl PyProjectTomlMut {
7274
.as_table_mut()
7375
.ok_or(Error::MalformedSources)?;
7476

75-
add_source(req, source, sources);
77+
add_source(req, source, sources)?;
7678
}
7779

7880
Ok(())
@@ -113,7 +115,7 @@ impl PyProjectTomlMut {
113115
.as_table_mut()
114116
.ok_or(Error::MalformedSources)?;
115117

116-
add_source(req, source, sources);
118+
add_source(req, source, sources)?;
117119
}
118120

119121
Ok(())
@@ -244,21 +246,17 @@ fn find_dependencies(name: &PackageName, deps: &Array) -> Vec<usize> {
244246
}
245247

246248
// Add a source to `tool.uv.sources`.
247-
fn add_source(req: &Requirement, source: &Source, sources: &mut Table) {
248-
match source {
249-
Source::Workspace {
250-
workspace,
251-
editable,
252-
} => {
253-
let mut value = InlineTable::new();
254-
value.insert("workspace", Value::from(*workspace));
255-
if let Some(editable) = editable {
256-
value.insert("editable", Value::from(*editable));
257-
}
258-
sources.insert(req.name.as_ref(), Item::Value(Value::InlineTable(value)));
259-
}
260-
_ => unimplemented!(),
261-
}
249+
fn add_source(req: &Requirement, source: &Source, sources: &mut Table) -> Result<(), Error> {
250+
// Serialize as an inline table.
251+
let mut doc = toml::to_string(source)
252+
.map_err(Box::new)?
253+
.parse::<DocumentMut>()
254+
.unwrap();
255+
let table = mem::take(doc.as_table_mut()).into_inline_table();
256+
257+
sources.insert(req.name.as_ref(), Item::Value(Value::InlineTable(table)));
258+
259+
Ok(())
262260
}
263261

264262
impl fmt::Display for PyProjectTomlMut {

crates/uv/src/cli.rs

+22
Original file line numberDiff line numberDiff line change
@@ -1638,6 +1638,28 @@ pub(crate) struct AddArgs {
16381638
#[arg(long)]
16391639
pub(crate) workspace: bool,
16401640

1641+
/// Add the requirements as editables.
1642+
#[arg(long, default_missing_value = "true", num_args(0..=1))]
1643+
pub(crate) editable: Option<bool>,
1644+
1645+
/// Add source requirements to the `project.dependencies` section of the `pyproject.toml`.
1646+
///
1647+
/// Without this flag uv will try to use `tool.uv.sources` for any sources.
1648+
#[arg(long)]
1649+
pub(crate) raw: bool,
1650+
1651+
/// Specific commit to use when adding from Git.
1652+
#[arg(long)]
1653+
pub(crate) rev: Option<String>,
1654+
1655+
/// Tag to use when adding from git.
1656+
#[arg(long)]
1657+
pub(crate) tag: Option<String>,
1658+
1659+
/// Branch to use when adding from git.
1660+
#[arg(long)]
1661+
pub(crate) branch: Option<String>,
1662+
16411663
#[command(flatten)]
16421664
pub(crate) installer: ResolverInstallerArgs,
16431665

crates/uv/src/commands/project/add.rs

+34-9
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use anyhow::Result;
22
use uv_client::{BaseClientBuilder, Connectivity, FlatIndexClient, RegistryClientBuilder};
33
use uv_dispatch::BuildDispatch;
4-
use uv_distribution::pyproject::Source;
4+
use uv_distribution::pyproject::{Source, SourceError};
55
use uv_distribution::pyproject_mut::PyProjectTomlMut;
66
use uv_git::GitResolver;
77
use uv_requirements::{NamedRequirementsResolver, RequirementsSource, RequirementsSpecification};
@@ -22,11 +22,16 @@ use crate::printer::Printer;
2222
use crate::settings::ResolverInstallerSettings;
2323

2424
/// Add one or more packages to the project requirements.
25-
#[allow(clippy::too_many_arguments)]
25+
#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
2626
pub(crate) async fn add(
2727
requirements: Vec<RequirementsSource>,
2828
workspace: bool,
2929
dev: bool,
30+
editable: Option<bool>,
31+
raw: bool,
32+
rev: Option<String>,
33+
tag: Option<String>,
34+
branch: Option<String>,
3035
python: Option<String>,
3136
settings: ResolverInstallerSettings,
3237
preview: PreviewMode,
@@ -135,14 +140,34 @@ pub(crate) async fn add(
135140

136141
// Add the requirements to the `pyproject.toml`.
137142
let mut pyproject = PyProjectTomlMut::from_toml(project.current_project().pyproject_toml())?;
138-
for req in requirements.into_iter().map(pep508_rs::Requirement::from) {
139-
let source = if workspace {
140-
Some(Source::Workspace {
141-
workspace: true,
142-
editable: None,
143-
})
143+
for req in requirements {
144+
let (req, source) = if raw {
145+
// Use the PEP 508 requirement directly.
146+
(pep508_rs::Requirement::from(req), None)
144147
} else {
145-
None
148+
// Otherwise, try to construct the source.
149+
let result = Source::from_requirement(
150+
req.source.clone(),
151+
workspace,
152+
editable,
153+
rev.clone(),
154+
tag.clone(),
155+
branch.clone(),
156+
);
157+
158+
let source = match result {
159+
Ok(source) => source,
160+
Err(SourceError::UnresolvedReference(rev)) => {
161+
anyhow::bail!("Cannot resolve Git reference `{rev}` for requirement `{}`. Specify the reference with one of `--tag`, `--branch`, or `--rev`, or use the `--raw` flag.", req.name)
162+
}
163+
Err(err) => return Err(err.into()),
164+
};
165+
166+
// Ignore the PEP 508 source.
167+
let mut req = pep508_rs::Requirement::from(req);
168+
req.clear_url();
169+
170+
(req, source)
146171
};
147172

148173
if dev {

crates/uv/src/main.rs

+5
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,11 @@ async fn run() -> Result<ExitStatus> {
689689
args.requirements,
690690
args.workspace,
691691
args.dev,
692+
args.editable,
693+
args.raw,
694+
args.rev,
695+
args.tag,
696+
args.branch,
692697
args.python,
693698
args.settings,
694699
globals.preview,

crates/uv/src/settings.rs

+16-1
Original file line numberDiff line numberDiff line change
@@ -368,8 +368,13 @@ impl LockSettings {
368368
#[derive(Debug, Clone)]
369369
pub(crate) struct AddSettings {
370370
pub(crate) requirements: Vec<RequirementsSource>,
371-
pub(crate) workspace: bool,
372371
pub(crate) dev: bool,
372+
pub(crate) workspace: bool,
373+
pub(crate) editable: Option<bool>,
374+
pub(crate) raw: bool,
375+
pub(crate) rev: Option<String>,
376+
pub(crate) tag: Option<String>,
377+
pub(crate) branch: Option<String>,
373378
pub(crate) python: Option<String>,
374379
pub(crate) refresh: Refresh,
375380
pub(crate) settings: ResolverInstallerSettings,
@@ -383,6 +388,11 @@ impl AddSettings {
383388
requirements,
384389
dev,
385390
workspace,
391+
editable,
392+
raw,
393+
rev,
394+
tag,
395+
branch,
386396
installer,
387397
build,
388398
refresh,
@@ -398,6 +408,11 @@ impl AddSettings {
398408
requirements,
399409
workspace,
400410
dev,
411+
editable,
412+
raw,
413+
rev,
414+
tag,
415+
branch,
401416
python,
402417
refresh: Refresh::from(refresh),
403418
settings: ResolverInstallerSettings::combine(

0 commit comments

Comments
 (0)