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 https://github.com/astral-sh/uv/issues/8539.

---------

Co-authored-by: Zanie Blue <contact@zanie.dev>
Co-authored-by: Charlie Marsh <charlie.r.marsh@gmail.com>
Co-authored-by: konsti <konstin@mailbox.org>
This commit is contained in:
Zanie Blue 2024-11-05 10:58:32 -06:00
parent 383d4e774b
commit b6c531f4dd
6 changed files with 129 additions and 39 deletions

View File

@ -1,5 +1,6 @@
use std::num::NonZeroUsize; use std::num::NonZeroUsize;
use std::path::PathBuf; use std::path::PathBuf;
use url::Url; use url::Url;
use uv_configuration::{ use uv_configuration::{
@ -124,3 +125,9 @@ impl Combine for serde::de::IgnoredAny {
self self
} }
} }
impl Combine for Option<serde::de::IgnoredAny> {
fn combine(self, _other: Self) -> Self {
self
}
}

View File

@ -46,6 +46,7 @@ impl FilesystemOptions {
match read_file(&file) { match read_file(&file) {
Ok(options) => { Ok(options) => {
tracing::debug!("Found user configuration in: `{}`", file.display()); tracing::debug!("Found user configuration in: `{}`", file.display());
validate_uv_toml(&file, &options)?;
Ok(Some(Self(options))) Ok(Some(Self(options)))
} }
Err(Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
@ -82,11 +83,11 @@ impl FilesystemOptions {
Ok(None) => { Ok(None) => {
// Continue traversing the directory tree. // Continue traversing the directory tree.
} }
Err(Error::PyprojectToml(file, err)) => { Err(Error::PyprojectToml(path, err)) => {
// If we see an invalid `pyproject.toml`, warn but continue. // If we see an invalid `pyproject.toml`, warn but continue.
warn_user!( warn_user!(
"Failed to parse `{}` during settings discovery:\n{}", "Failed to parse `{}` during settings discovery:\n{}",
file.cyan(), path.user_display().cyan(),
textwrap::indent(&err.to_string(), " ") textwrap::indent(&err.to_string(), " ")
); );
} }
@ -107,7 +108,7 @@ impl FilesystemOptions {
match fs_err::read_to_string(&path) { match fs_err::read_to_string(&path) {
Ok(content) => { Ok(content) => {
let options: Options = toml::from_str(&content) let options: Options = toml::from_str(&content)
.map_err(|err| Error::UvToml(path.user_display().to_string(), err))?; .map_err(|err| Error::UvToml(path.clone(), Box::new(err)))?;
// If the directory also contains a `[tool.uv]` table in a `pyproject.toml` file, // If the directory also contains a `[tool.uv]` table in a `pyproject.toml` file,
// warn. // warn.
@ -124,6 +125,7 @@ impl FilesystemOptions {
} }
tracing::debug!("Found workspace configuration at `{}`", path.display()); tracing::debug!("Found workspace configuration at `{}`", path.display());
validate_uv_toml(&path, &options)?;
return Ok(Some(Self(options))); return Ok(Some(Self(options)));
} }
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
@ -136,7 +138,7 @@ impl FilesystemOptions {
Ok(content) => { Ok(content) => {
// Parse, but skip any `pyproject.toml` that doesn't have a `[tool.uv]` section. // Parse, but skip any `pyproject.toml` that doesn't have a `[tool.uv]` section.
let pyproject: PyProjectToml = toml::from_str(&content) let pyproject: PyProjectToml = toml::from_str(&content)
.map_err(|err| Error::PyprojectToml(path.user_display().to_string(), err))?; .map_err(|err| Error::PyprojectToml(path.clone(), Box::new(err)))?;
let Some(tool) = pyproject.tool else { let Some(tool) = pyproject.tool else {
tracing::debug!( tracing::debug!(
"Skipping `pyproject.toml` in `{}` (no `[tool]` section)", "Skipping `pyproject.toml` in `{}` (no `[tool]` section)",
@ -244,21 +246,56 @@ fn system_config_file() -> Option<PathBuf> {
/// Load [`Options`] from a `uv.toml` file. /// Load [`Options`] from a `uv.toml` file.
fn read_file(path: &Path) -> Result<Options, Error> { fn read_file(path: &Path) -> Result<Options, Error> {
let content = fs_err::read_to_string(path)?; let content = fs_err::read_to_string(path)?;
let options: Options = toml::from_str(&content) let options: Options =
.map_err(|err| Error::UvToml(path.user_display().to_string(), err))?; toml::from_str(&content).map_err(|err| Error::UvToml(path.to_path_buf(), Box::new(err)))?;
Ok(options) Ok(options)
} }
/// Validate that an [`Options`] schema is compatible with `uv.toml`.
fn validate_uv_toml(path: &Path, options: &Options) -> Result<(), Error> {
// The `uv.toml` format is not allowed to include any of the following, which are
// permitted by the schema since they _can_ be included in `pyproject.toml` files
// (and we want to use `deny_unknown_fields`).
if options.workspace.is_some() {
return Err(Error::PyprojectOnlyField(path.to_path_buf(), "workspace"));
}
if options.sources.is_some() {
return Err(Error::PyprojectOnlyField(path.to_path_buf(), "sources"));
}
if options.dev_dependencies.is_some() {
return Err(Error::PyprojectOnlyField(
path.to_path_buf(),
"dev-dependencies",
));
}
if options.default_groups.is_some() {
return Err(Error::PyprojectOnlyField(
path.to_path_buf(),
"default-groups",
));
}
if options.managed.is_some() {
return Err(Error::PyprojectOnlyField(path.to_path_buf(), "managed"));
}
if options.package.is_some() {
return Err(Error::PyprojectOnlyField(path.to_path_buf(), "package"));
}
Ok(())
}
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum Error { pub enum Error {
#[error(transparent)] #[error(transparent)]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
#[error("Failed to parse: `{0}`")] #[error("Failed to parse: `{}`", _0.user_display())]
PyprojectToml(String, #[source] toml::de::Error), PyprojectToml(PathBuf, #[source] Box<toml::de::Error>),
#[error("Failed to parse: `{0}`")] #[error("Failed to parse: `{}`", _0.user_display())]
UvToml(String, #[source] toml::de::Error), UvToml(PathBuf, #[source] Box<toml::de::Error>),
#[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())]
PyprojectOnlyField(PathBuf, &'static str),
} }
#[cfg(test)] #[cfg(test)]

View File

@ -86,7 +86,8 @@ pub struct Options {
cache_keys: Option<Vec<CacheKey>>, cache_keys: Option<Vec<CacheKey>>,
// NOTE(charlie): These fields are shared with `ToolUv` in // NOTE(charlie): These fields are shared with `ToolUv` in
// `crates/uv-workspace/src/pyproject.rs`, and the documentation lives on that struct. // `crates/uv-workspace/src/pyproject.rs`. The documentation lives on that struct.
// They're respected in both `pyproject.toml` and `uv.toml` files.
#[cfg_attr(feature = "schemars", schemars(skip))] #[cfg_attr(feature = "schemars", schemars(skip))]
pub override_dependencies: Option<Vec<Requirement<VerbatimParsedUrl>>>, pub override_dependencies: Option<Vec<Requirement<VerbatimParsedUrl>>>,
@ -95,6 +96,27 @@ pub struct Options {
#[cfg_attr(feature = "schemars", schemars(skip))] #[cfg_attr(feature = "schemars", schemars(skip))]
pub environments: Option<SupportedEnvironments>, pub environments: Option<SupportedEnvironments>,
// NOTE(charlie): These fields should be kept in-sync with `ToolUv` in
// `crates/uv-workspace/src/pyproject.rs`. The documentation lives on that struct.
// They're only respected in `pyproject.toml` files, and should be rejected in `uv.toml` files.
#[cfg_attr(feature = "schemars", schemars(skip))]
pub workspace: Option<serde::de::IgnoredAny>,
#[cfg_attr(feature = "schemars", schemars(skip))]
pub sources: Option<serde::de::IgnoredAny>,
#[cfg_attr(feature = "schemars", schemars(skip))]
pub dev_dependencies: Option<serde::de::IgnoredAny>,
#[cfg_attr(feature = "schemars", schemars(skip))]
pub default_groups: Option<serde::de::IgnoredAny>,
#[cfg_attr(feature = "schemars", schemars(skip))]
pub managed: Option<serde::de::IgnoredAny>,
#[cfg_attr(feature = "schemars", schemars(skip))]
pub r#package: Option<serde::de::IgnoredAny>,
} }
impl Options { impl Options {
@ -1551,24 +1573,20 @@ pub struct OptionsWire {
cache_keys: Option<Vec<CacheKey>>, cache_keys: Option<Vec<CacheKey>>,
// NOTE(charlie): These fields are shared with `ToolUv` in // NOTE(charlie): These fields are shared with `ToolUv` in
// `crates/uv-workspace/src/pyproject.rs`, and the documentation lives on that struct. // `crates/uv-workspace/src/pyproject.rs`. The documentation lives on that struct.
// They're respected in both `pyproject.toml` and `uv.toml` files.
override_dependencies: Option<Vec<Requirement<VerbatimParsedUrl>>>, override_dependencies: Option<Vec<Requirement<VerbatimParsedUrl>>>,
constraint_dependencies: Option<Vec<Requirement<VerbatimParsedUrl>>>, constraint_dependencies: Option<Vec<Requirement<VerbatimParsedUrl>>>,
environments: Option<SupportedEnvironments>, environments: Option<SupportedEnvironments>,
// NOTE(charlie): These fields should be kept in-sync with `ToolUv` in // NOTE(charlie): These fields should be kept in-sync with `ToolUv` in
// `crates/uv-workspace/src/pyproject.rs`. // `crates/uv-workspace/src/pyproject.rs`. The documentation lives on that struct.
#[allow(dead_code)] // They're only respected in `pyproject.toml` files, and should be rejected in `uv.toml` files.
workspace: Option<serde::de::IgnoredAny>, workspace: Option<serde::de::IgnoredAny>,
#[allow(dead_code)]
sources: Option<serde::de::IgnoredAny>, sources: Option<serde::de::IgnoredAny>,
#[allow(dead_code)]
managed: Option<serde::de::IgnoredAny>, managed: Option<serde::de::IgnoredAny>,
#[allow(dead_code)]
r#package: Option<serde::de::IgnoredAny>, r#package: Option<serde::de::IgnoredAny>,
#[allow(dead_code)]
default_groups: Option<serde::de::IgnoredAny>, default_groups: Option<serde::de::IgnoredAny>,
#[allow(dead_code)]
dev_dependencies: Option<serde::de::IgnoredAny>, dev_dependencies: Option<serde::de::IgnoredAny>,
} }
@ -1618,12 +1636,12 @@ impl From<OptionsWire> for Options {
environments, environments,
publish_url, publish_url,
trusted_publishing, trusted_publishing,
workspace: _, workspace,
sources: _, sources,
managed: _, default_groups,
package: _, dev_dependencies,
default_groups: _, managed,
dev_dependencies: _, package,
} = value; } = value;
Self { Self {
@ -1667,15 +1685,21 @@ impl From<OptionsWire> for Options {
no_binary, no_binary,
no_binary_package, no_binary_package,
}, },
publish: PublishOptions {
publish_url,
trusted_publishing,
},
pip, pip,
cache_keys, cache_keys,
override_dependencies, override_dependencies,
constraint_dependencies, constraint_dependencies,
environments, environments,
publish: PublishOptions {
publish_url,
trusted_publishing,
},
workspace,
sources,
dev_dependencies,
default_groups,
managed,
package,
} }
} }
} }

View File

@ -230,7 +230,7 @@ pub struct ToolUv {
/// ///
/// See [Dependencies](../concepts/dependencies.md) for more. /// See [Dependencies](../concepts/dependencies.md) for more.
#[option( #[option(
default = "\"[]\"", default = "{}",
value_type = "dict", value_type = "dict",
example = r#" example = r#"
[tool.uv.sources] [tool.uv.sources]
@ -269,7 +269,7 @@ pub struct ToolUv {
/// given the lowest priority when resolving packages. Additionally, marking an index as default will disable the /// given the lowest priority when resolving packages. Additionally, marking an index as default will disable the
/// PyPI default index. /// PyPI default index.
#[option( #[option(
default = "\"[]\"", default = "[]",
value_type = "dict", value_type = "dict",
example = r#" example = r#"
[[tool.uv.index]] [[tool.uv.index]]
@ -340,7 +340,7 @@ pub struct ToolUv {
) )
)] )]
#[option( #[option(
default = r#"[]"#, default = "[]",
value_type = "list[str]", value_type = "list[str]",
example = r#" example = r#"
dev-dependencies = ["ruff==0.5.0"] dev-dependencies = ["ruff==0.5.0"]
@ -374,7 +374,7 @@ pub struct ToolUv {
) )
)] )]
#[option( #[option(
default = r#"[]"#, default = "[]",
value_type = "list[str]", value_type = "list[str]",
example = r#" example = r#"
# Always install Werkzeug 2.3.0, regardless of whether transitive dependencies request # Always install Werkzeug 2.3.0, regardless of whether transitive dependencies request
@ -405,7 +405,7 @@ pub struct ToolUv {
) )
)] )]
#[option( #[option(
default = r#"[]"#, default = "[]",
value_type = "list[str]", value_type = "list[str]",
example = r#" example = r#"
# Ensure that the grpcio version is always less than 1.65, if it's requested by a # Ensure that the grpcio version is always less than 1.65, if it's requested by a
@ -431,7 +431,7 @@ pub struct ToolUv {
) )
)] )]
#[option( #[option(
default = r#"[]"#, default = "[]",
value_type = "str | list[str]", value_type = "str | list[str]",
example = r#" example = r#"
# Resolve for macOS, but not for Linux or Windows. # Resolve for macOS, but not for Linux or Windows.
@ -511,7 +511,7 @@ pub struct ToolUvWorkspace {
/// ///
/// For more information on the glob syntax, refer to the [`glob` documentation](https://docs.rs/glob/latest/glob/struct.Pattern.html). /// For more information on the glob syntax, refer to the [`glob` documentation](https://docs.rs/glob/latest/glob/struct.Pattern.html).
#[option( #[option(
default = r#"[]"#, default = "[]",
value_type = "list[str]", value_type = "list[str]",
example = r#" example = r#"
members = ["member1", "path/to/member2", "libs/*"] members = ["member1", "path/to/member2", "libs/*"]
@ -525,7 +525,7 @@ pub struct ToolUvWorkspace {
/// ///
/// For more information on the glob syntax, refer to the [`glob` documentation](https://docs.rs/glob/latest/glob/struct.Pattern.html). /// For more information on the glob syntax, refer to the [`glob` documentation](https://docs.rs/glob/latest/glob/struct.Pattern.html).
#[option( #[option(
default = r#"[]"#, default = "[]",
value_type = "list[str]", value_type = "list[str]",
example = r#" example = r#"
exclude = ["member1", "path/to/member2", "libs/*"] exclude = ["member1", "path/to/member2", "libs/*"]

View File

@ -201,6 +201,28 @@ fn invalid_pyproject_toml_option_unknown_field() -> Result<()> {
Ok(()) Ok(())
} }
#[test]
fn invalid_uv_toml_option_disallowed() -> Result<()> {
let context = TestContext::new("3.12");
let uv_toml = context.temp_dir.child("uv.toml");
uv_toml.write_str(indoc! {r"
managed = true
"})?;
uv_snapshot!(context.pip_install()
.arg("iniconfig"), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
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.
"###
);
Ok(())
}
/// For indirect, non-user controlled pyproject.toml, we don't enforce correctness. /// For indirect, non-user controlled pyproject.toml, we don't enforce correctness.
/// ///
/// If we fail to extract the PEP 621 metadata, we fall back to treating it as a source /// If we fail to extract the PEP 621 metadata, we fall back to treating it as a source

View File

@ -127,7 +127,7 @@ If an index is marked as `default = true`, it will be moved to the end of the pr
given the lowest priority when resolving packages. Additionally, marking an index as default will disable the given the lowest priority when resolving packages. Additionally, marking an index as default will disable the
PyPI default index. PyPI default index.
**Default value**: `"[]"` **Default value**: `[]`
**Type**: `dict` **Type**: `dict`
@ -232,7 +232,7 @@ alternative registry.
See [Dependencies](../concepts/dependencies.md) for more. See [Dependencies](../concepts/dependencies.md) for more.
**Default value**: `"[]"` **Default value**: `{}`
**Type**: `dict` **Type**: `dict`