Skip to content

Commit b6c531f

Browse files
zaniebcharliermarshkonstin
committed
Error when disallowed settings are defined in uv.toml (#8550)
These settings can only be defined in `pyproject.toml`, since they're project-centric, and not _configuration_. Closes #8539. --------- Co-authored-by: Zanie Blue <[email protected]> Co-authored-by: Charlie Marsh <[email protected]> Co-authored-by: konsti <[email protected]>
1 parent 383d4e7 commit b6c531f

File tree

6 files changed

+129
-39
lines changed

6 files changed

+129
-39
lines changed

crates/uv-settings/src/combine.rs

+7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use std::num::NonZeroUsize;
22
use std::path::PathBuf;
3+
34
use url::Url;
45

56
use uv_configuration::{
@@ -124,3 +125,9 @@ impl Combine for serde::de::IgnoredAny {
124125
self
125126
}
126127
}
128+
129+
impl Combine for Option<serde::de::IgnoredAny> {
130+
fn combine(self, _other: Self) -> Self {
131+
self
132+
}
133+
}

crates/uv-settings/src/lib.rs

+47-10
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ impl FilesystemOptions {
4646
match read_file(&file) {
4747
Ok(options) => {
4848
tracing::debug!("Found user configuration in: `{}`", file.display());
49+
validate_uv_toml(&file, &options)?;
4950
Ok(Some(Self(options)))
5051
}
5152
Err(Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
@@ -82,11 +83,11 @@ impl FilesystemOptions {
8283
Ok(None) => {
8384
// Continue traversing the directory tree.
8485
}
85-
Err(Error::PyprojectToml(file, err)) => {
86+
Err(Error::PyprojectToml(path, err)) => {
8687
// If we see an invalid `pyproject.toml`, warn but continue.
8788
warn_user!(
8889
"Failed to parse `{}` during settings discovery:\n{}",
89-
file.cyan(),
90+
path.user_display().cyan(),
9091
textwrap::indent(&err.to_string(), " ")
9192
);
9293
}
@@ -107,7 +108,7 @@ impl FilesystemOptions {
107108
match fs_err::read_to_string(&path) {
108109
Ok(content) => {
109110
let options: Options = toml::from_str(&content)
110-
.map_err(|err| Error::UvToml(path.user_display().to_string(), err))?;
111+
.map_err(|err| Error::UvToml(path.clone(), Box::new(err)))?;
111112

112113
// If the directory also contains a `[tool.uv]` table in a `pyproject.toml` file,
113114
// warn.
@@ -124,6 +125,7 @@ impl FilesystemOptions {
124125
}
125126

126127
tracing::debug!("Found workspace configuration at `{}`", path.display());
128+
validate_uv_toml(&path, &options)?;
127129
return Ok(Some(Self(options)));
128130
}
129131
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
@@ -136,7 +138,7 @@ impl FilesystemOptions {
136138
Ok(content) => {
137139
// Parse, but skip any `pyproject.toml` that doesn't have a `[tool.uv]` section.
138140
let pyproject: PyProjectToml = toml::from_str(&content)
139-
.map_err(|err| Error::PyprojectToml(path.user_display().to_string(), err))?;
141+
.map_err(|err| Error::PyprojectToml(path.clone(), Box::new(err)))?;
140142
let Some(tool) = pyproject.tool else {
141143
tracing::debug!(
142144
"Skipping `pyproject.toml` in `{}` (no `[tool]` section)",
@@ -244,21 +246,56 @@ fn system_config_file() -> Option<PathBuf> {
244246
/// Load [`Options`] from a `uv.toml` file.
245247
fn read_file(path: &Path) -> Result<Options, Error> {
246248
let content = fs_err::read_to_string(path)?;
247-
let options: Options = toml::from_str(&content)
248-
.map_err(|err| Error::UvToml(path.user_display().to_string(), err))?;
249+
let options: Options =
250+
toml::from_str(&content).map_err(|err| Error::UvToml(path.to_path_buf(), Box::new(err)))?;
249251
Ok(options)
250252
}
251253

254+
/// Validate that an [`Options`] schema is compatible with `uv.toml`.
255+
fn validate_uv_toml(path: &Path, options: &Options) -> Result<(), Error> {
256+
// The `uv.toml` format is not allowed to include any of the following, which are
257+
// permitted by the schema since they _can_ be included in `pyproject.toml` files
258+
// (and we want to use `deny_unknown_fields`).
259+
if options.workspace.is_some() {
260+
return Err(Error::PyprojectOnlyField(path.to_path_buf(), "workspace"));
261+
}
262+
if options.sources.is_some() {
263+
return Err(Error::PyprojectOnlyField(path.to_path_buf(), "sources"));
264+
}
265+
if options.dev_dependencies.is_some() {
266+
return Err(Error::PyprojectOnlyField(
267+
path.to_path_buf(),
268+
"dev-dependencies",
269+
));
270+
}
271+
if options.default_groups.is_some() {
272+
return Err(Error::PyprojectOnlyField(
273+
path.to_path_buf(),
274+
"default-groups",
275+
));
276+
}
277+
if options.managed.is_some() {
278+
return Err(Error::PyprojectOnlyField(path.to_path_buf(), "managed"));
279+
}
280+
if options.package.is_some() {
281+
return Err(Error::PyprojectOnlyField(path.to_path_buf(), "package"));
282+
}
283+
Ok(())
284+
}
285+
252286
#[derive(thiserror::Error, Debug)]
253287
pub enum Error {
254288
#[error(transparent)]
255289
Io(#[from] std::io::Error),
256290

257-
#[error("Failed to parse: `{0}`")]
258-
PyprojectToml(String, #[source] toml::de::Error),
291+
#[error("Failed to parse: `{}`", _0.user_display())]
292+
PyprojectToml(PathBuf, #[source] Box<toml::de::Error>),
293+
294+
#[error("Failed to parse: `{}`", _0.user_display())]
295+
UvToml(PathBuf, #[source] Box<toml::de::Error>),
259296

260-
#[error("Failed to parse: `{0}`")]
261-
UvToml(String, #[source] toml::de::Error),
297+
#[error("Failed to parse: `{}`. The `{1}` field is not allowed in a `uv.toml` file. `{1}` is only applicable in the context of a project, and should be placed in a `pyproject.toml` file instead.", _0.user_display())]
298+
PyprojectOnlyField(PathBuf, &'static str),
262299
}
263300

264301
#[cfg(test)]

crates/uv-settings/src/settings.rs

+43-19
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ pub struct Options {
8686
cache_keys: Option<Vec<CacheKey>>,
8787

8888
// NOTE(charlie): These fields are shared with `ToolUv` in
89-
// `crates/uv-workspace/src/pyproject.rs`, and the documentation lives on that struct.
89+
// `crates/uv-workspace/src/pyproject.rs`. The documentation lives on that struct.
90+
// They're respected in both `pyproject.toml` and `uv.toml` files.
9091
#[cfg_attr(feature = "schemars", schemars(skip))]
9192
pub override_dependencies: Option<Vec<Requirement<VerbatimParsedUrl>>>,
9293

@@ -95,6 +96,27 @@ pub struct Options {
9596

9697
#[cfg_attr(feature = "schemars", schemars(skip))]
9798
pub environments: Option<SupportedEnvironments>,
99+
100+
// NOTE(charlie): These fields should be kept in-sync with `ToolUv` in
101+
// `crates/uv-workspace/src/pyproject.rs`. The documentation lives on that struct.
102+
// They're only respected in `pyproject.toml` files, and should be rejected in `uv.toml` files.
103+
#[cfg_attr(feature = "schemars", schemars(skip))]
104+
pub workspace: Option<serde::de::IgnoredAny>,
105+
106+
#[cfg_attr(feature = "schemars", schemars(skip))]
107+
pub sources: Option<serde::de::IgnoredAny>,
108+
109+
#[cfg_attr(feature = "schemars", schemars(skip))]
110+
pub dev_dependencies: Option<serde::de::IgnoredAny>,
111+
112+
#[cfg_attr(feature = "schemars", schemars(skip))]
113+
pub default_groups: Option<serde::de::IgnoredAny>,
114+
115+
#[cfg_attr(feature = "schemars", schemars(skip))]
116+
pub managed: Option<serde::de::IgnoredAny>,
117+
118+
#[cfg_attr(feature = "schemars", schemars(skip))]
119+
pub r#package: Option<serde::de::IgnoredAny>,
98120
}
99121

100122
impl Options {
@@ -1551,24 +1573,20 @@ pub struct OptionsWire {
15511573
cache_keys: Option<Vec<CacheKey>>,
15521574

15531575
// NOTE(charlie): These fields are shared with `ToolUv` in
1554-
// `crates/uv-workspace/src/pyproject.rs`, and the documentation lives on that struct.
1576+
// `crates/uv-workspace/src/pyproject.rs`. The documentation lives on that struct.
1577+
// They're respected in both `pyproject.toml` and `uv.toml` files.
15551578
override_dependencies: Option<Vec<Requirement<VerbatimParsedUrl>>>,
15561579
constraint_dependencies: Option<Vec<Requirement<VerbatimParsedUrl>>>,
15571580
environments: Option<SupportedEnvironments>,
15581581

15591582
// NOTE(charlie): These fields should be kept in-sync with `ToolUv` in
1560-
// `crates/uv-workspace/src/pyproject.rs`.
1561-
#[allow(dead_code)]
1583+
// `crates/uv-workspace/src/pyproject.rs`. The documentation lives on that struct.
1584+
// They're only respected in `pyproject.toml` files, and should be rejected in `uv.toml` files.
15621585
workspace: Option<serde::de::IgnoredAny>,
1563-
#[allow(dead_code)]
15641586
sources: Option<serde::de::IgnoredAny>,
1565-
#[allow(dead_code)]
15661587
managed: Option<serde::de::IgnoredAny>,
1567-
#[allow(dead_code)]
15681588
r#package: Option<serde::de::IgnoredAny>,
1569-
#[allow(dead_code)]
15701589
default_groups: Option<serde::de::IgnoredAny>,
1571-
#[allow(dead_code)]
15721590
dev_dependencies: Option<serde::de::IgnoredAny>,
15731591
}
15741592

@@ -1618,12 +1636,12 @@ impl From<OptionsWire> for Options {
16181636
environments,
16191637
publish_url,
16201638
trusted_publishing,
1621-
workspace: _,
1622-
sources: _,
1623-
managed: _,
1624-
package: _,
1625-
default_groups: _,
1626-
dev_dependencies: _,
1639+
workspace,
1640+
sources,
1641+
default_groups,
1642+
dev_dependencies,
1643+
managed,
1644+
package,
16271645
} = value;
16281646

16291647
Self {
@@ -1667,15 +1685,21 @@ impl From<OptionsWire> for Options {
16671685
no_binary,
16681686
no_binary_package,
16691687
},
1670-
publish: PublishOptions {
1671-
publish_url,
1672-
trusted_publishing,
1673-
},
16741688
pip,
16751689
cache_keys,
16761690
override_dependencies,
16771691
constraint_dependencies,
16781692
environments,
1693+
publish: PublishOptions {
1694+
publish_url,
1695+
trusted_publishing,
1696+
},
1697+
workspace,
1698+
sources,
1699+
dev_dependencies,
1700+
default_groups,
1701+
managed,
1702+
package,
16791703
}
16801704
}
16811705
}

crates/uv-workspace/src/pyproject.rs

+8-8
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ pub struct ToolUv {
230230
///
231231
/// See [Dependencies](../concepts/dependencies.md) for more.
232232
#[option(
233-
default = "\"[]\"",
233+
default = "{}",
234234
value_type = "dict",
235235
example = r#"
236236
[tool.uv.sources]
@@ -269,7 +269,7 @@ pub struct ToolUv {
269269
/// given the lowest priority when resolving packages. Additionally, marking an index as default will disable the
270270
/// PyPI default index.
271271
#[option(
272-
default = "\"[]\"",
272+
default = "[]",
273273
value_type = "dict",
274274
example = r#"
275275
[[tool.uv.index]]
@@ -340,7 +340,7 @@ pub struct ToolUv {
340340
)
341341
)]
342342
#[option(
343-
default = r#"[]"#,
343+
default = "[]",
344344
value_type = "list[str]",
345345
example = r#"
346346
dev-dependencies = ["ruff==0.5.0"]
@@ -374,7 +374,7 @@ pub struct ToolUv {
374374
)
375375
)]
376376
#[option(
377-
default = r#"[]"#,
377+
default = "[]",
378378
value_type = "list[str]",
379379
example = r#"
380380
# Always install Werkzeug 2.3.0, regardless of whether transitive dependencies request
@@ -405,7 +405,7 @@ pub struct ToolUv {
405405
)
406406
)]
407407
#[option(
408-
default = r#"[]"#,
408+
default = "[]",
409409
value_type = "list[str]",
410410
example = r#"
411411
# Ensure that the grpcio version is always less than 1.65, if it's requested by a
@@ -431,7 +431,7 @@ pub struct ToolUv {
431431
)
432432
)]
433433
#[option(
434-
default = r#"[]"#,
434+
default = "[]",
435435
value_type = "str | list[str]",
436436
example = r#"
437437
# Resolve for macOS, but not for Linux or Windows.
@@ -511,7 +511,7 @@ pub struct ToolUvWorkspace {
511511
///
512512
/// For more information on the glob syntax, refer to the [`glob` documentation](https://docs.rs/glob/latest/glob/struct.Pattern.html).
513513
#[option(
514-
default = r#"[]"#,
514+
default = "[]",
515515
value_type = "list[str]",
516516
example = r#"
517517
members = ["member1", "path/to/member2", "libs/*"]
@@ -525,7 +525,7 @@ pub struct ToolUvWorkspace {
525525
///
526526
/// For more information on the glob syntax, refer to the [`glob` documentation](https://docs.rs/glob/latest/glob/struct.Pattern.html).
527527
#[option(
528-
default = r#"[]"#,
528+
default = "[]",
529529
value_type = "list[str]",
530530
example = r#"
531531
exclude = ["member1", "path/to/member2", "libs/*"]

crates/uv/tests/it/pip_install.rs

+22
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,28 @@ fn invalid_pyproject_toml_option_unknown_field() -> Result<()> {
201201
Ok(())
202202
}
203203

204+
#[test]
205+
fn invalid_uv_toml_option_disallowed() -> Result<()> {
206+
let context = TestContext::new("3.12");
207+
let uv_toml = context.temp_dir.child("uv.toml");
208+
uv_toml.write_str(indoc! {r"
209+
managed = true
210+
"})?;
211+
212+
uv_snapshot!(context.pip_install()
213+
.arg("iniconfig"), @r###"
214+
success: false
215+
exit_code: 2
216+
----- stdout -----
217+
218+
----- stderr -----
219+
error: Failed to parse: `uv.toml`. The `managed` field is not allowed in a `uv.toml` file. `managed` is only applicable in the context of a project, and should be placed in a `pyproject.toml` file instead.
220+
"###
221+
);
222+
223+
Ok(())
224+
}
225+
204226
/// For indirect, non-user controlled pyproject.toml, we don't enforce correctness.
205227
///
206228
/// If we fail to extract the PEP 621 metadata, we fall back to treating it as a source

docs/reference/settings.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ If an index is marked as `default = true`, it will be moved to the end of the pr
127127
given the lowest priority when resolving packages. Additionally, marking an index as default will disable the
128128
PyPI default index.
129129

130-
**Default value**: `"[]"`
130+
**Default value**: `[]`
131131

132132
**Type**: `dict`
133133

@@ -232,7 +232,7 @@ alternative registry.
232232

233233
See [Dependencies](../concepts/dependencies.md) for more.
234234

235-
**Default value**: `"[]"`
235+
**Default value**: `{}`
236236

237237
**Type**: `dict`
238238

0 commit comments

Comments
 (0)