diff --git a/crates/uv-settings/src/settings.rs b/crates/uv-settings/src/settings.rs index 55b1f7a945d8..c7ff7a702062 100644 --- a/crates/uv-settings/src/settings.rs +++ b/crates/uv-settings/src/settings.rs @@ -33,13 +33,15 @@ pub(crate) struct Tools { /// A `[tool.uv]` section. #[allow(dead_code)] #[derive(Debug, Clone, Default, Deserialize, CombineOptions, OptionsMetadata)] -#[serde(rename_all = "kebab-case", deny_unknown_fields)] +#[serde(from = "OptionsWire", rename_all = "kebab-case")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct Options { #[serde(flatten)] pub globals: GlobalOptions, + #[serde(flatten)] pub top_level: ResolverInstallerOptions, + #[option_group] pub pip: Option, @@ -79,7 +81,6 @@ pub struct Options { cache-keys = [{ file = "pyproject.toml" }, { file = "requirements.txt" }, { git = true }] "# )] - #[serde(default, skip_serializing)] cache_keys: Option>, // NOTE(charlie): These fields are shared with `ToolUv` in @@ -92,28 +93,6 @@ pub struct Options { #[cfg_attr(feature = "schemars", schemars(skip))] pub environments: Option, - - // NOTE(charlie): These fields should be kept in-sync with `ToolUv` in - // `crates/uv-workspace/src/pyproject.rs`. - #[serde(default, skip_serializing)] - #[cfg_attr(feature = "schemars", schemars(skip))] - workspace: serde::de::IgnoredAny, - - #[serde(default, skip_serializing)] - #[cfg_attr(feature = "schemars", schemars(skip))] - sources: serde::de::IgnoredAny, - - #[serde(default, skip_serializing)] - #[cfg_attr(feature = "schemars", schemars(skip))] - dev_dependencies: serde::de::IgnoredAny, - - #[serde(default, skip_serializing)] - #[cfg_attr(feature = "schemars", schemars(skip))] - managed: serde::de::IgnoredAny, - - #[serde(default, skip_serializing)] - #[cfg_attr(feature = "schemars", schemars(skip))] - r#package: serde::de::IgnoredAny, } impl Options { @@ -1472,3 +1451,171 @@ impl From for ResolverInstallerOptions { } } } + +/// Like [`Options]`, but with any `#[serde(flatten)]` fields inlined. This leads to far, far +/// better error messages when deserializing. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct OptionsWire { + // #[serde(flatten)] + // globals: GlobalOptions, + native_tls: Option, + offline: Option, + no_cache: Option, + cache_dir: Option, + preview: Option, + python_preference: Option, + python_downloads: Option, + concurrent_downloads: Option, + concurrent_builds: Option, + concurrent_installs: Option, + + // #[serde(flatten)] + // top_level: ResolverInstallerOptions, + index_url: Option, + extra_index_url: Option>, + no_index: Option, + find_links: Option>, + index_strategy: Option, + keyring_provider: Option, + allow_insecure_host: Option>, + resolution: Option, + prerelease: Option, + dependency_metadata: Option>, + config_settings: Option, + no_build_isolation: Option, + no_build_isolation_package: Option>, + exclude_newer: Option, + link_mode: Option, + compile_bytecode: Option, + no_sources: Option, + upgrade: Option, + upgrade_package: Option>>, + reinstall: Option, + reinstall_package: Option>, + no_build: Option, + no_build_package: Option>, + no_binary: Option, + no_binary_package: Option>, + + pip: Option, + cache_keys: Option>, + + // NOTE(charlie): These fields are shared with `ToolUv` in + // `crates/uv-workspace/src/pyproject.rs`, and the documentation lives on that struct. + override_dependencies: Option>>, + constraint_dependencies: Option>>, + environments: Option, + + // NOTE(charlie): These fields should be kept in-sync with `ToolUv` in + // `crates/uv-workspace/src/pyproject.rs`. + #[allow(dead_code)] + workspace: Option, + #[allow(dead_code)] + sources: Option, + #[allow(dead_code)] + dev_dependencies: Option, + #[allow(dead_code)] + managed: Option, + #[allow(dead_code)] + r#package: Option, +} + +impl From for Options { + fn from(value: OptionsWire) -> Self { + let OptionsWire { + native_tls, + offline, + no_cache, + cache_dir, + preview, + python_preference, + python_downloads, + concurrent_downloads, + concurrent_builds, + concurrent_installs, + index_url, + extra_index_url, + no_index, + find_links, + index_strategy, + keyring_provider, + allow_insecure_host, + resolution, + prerelease, + dependency_metadata, + config_settings, + no_build_isolation, + no_build_isolation_package, + exclude_newer, + link_mode, + compile_bytecode, + no_sources, + upgrade, + upgrade_package, + reinstall, + reinstall_package, + no_build, + no_build_package, + no_binary, + no_binary_package, + pip, + cache_keys, + override_dependencies, + constraint_dependencies, + environments, + workspace: _, + sources: _, + dev_dependencies: _, + managed: _, + package: _, + } = value; + + Self { + globals: GlobalOptions { + native_tls, + offline, + no_cache, + cache_dir, + preview, + python_preference, + python_downloads, + concurrent_downloads, + concurrent_builds, + concurrent_installs, + }, + top_level: ResolverInstallerOptions { + index_url, + extra_index_url, + no_index, + find_links, + index_strategy, + keyring_provider, + allow_insecure_host, + resolution, + prerelease, + dependency_metadata, + config_settings, + no_build_isolation, + no_build_isolation_package, + exclude_newer, + link_mode, + compile_bytecode, + no_sources, + upgrade, + upgrade_package, + reinstall, + reinstall_package, + no_build, + no_build_package, + no_binary, + no_binary_package, + }, + pip, + cache_keys, + override_dependencies, + constraint_dependencies, + environments, + } + } +} diff --git a/crates/uv/tests/pip_install.rs b/crates/uv/tests/pip_install.rs index e4ca3cac4f35..f7954f0c9c80 100644 --- a/crates/uv/tests/pip_install.rs +++ b/crates/uv/tests/pip_install.rs @@ -113,7 +113,7 @@ fn invalid_pyproject_toml_syntax() -> Result<()> { } #[test] -fn invalid_pyproject_toml_schema() -> Result<()> { +fn invalid_pyproject_toml_project_schema() -> Result<()> { let context = TestContext::new("3.12"); let pyproject_toml = context.temp_dir.child("pyproject.toml"); pyproject_toml.write_str("[project]")?; @@ -139,6 +139,71 @@ fn invalid_pyproject_toml_schema() -> Result<()> { Ok(()) } +#[test] +fn invalid_pyproject_toml_option_schema() -> Result<()> { + let context = TestContext::new("3.12"); + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r" + [tool.uv] + index-url = true + "})?; + + uv_snapshot!(context.pip_install() + .arg("iniconfig"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: Failed to parse `pyproject.toml` during settings discovery: + TOML parse error at line 2, column 13 + | + 2 | index-url = true + | ^^^^ + invalid type: boolean `true`, expected a string + + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 + "### + ); + + Ok(()) +} + +#[test] +fn invalid_pyproject_toml_option_unknown_field() -> Result<()> { + let context = TestContext::new("3.12"); + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [tool.uv] + unknown = "field" + "#})?; + + uv_snapshot!(context.pip_install() + .arg("-r") + .arg("pyproject.toml"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: Failed to parse `pyproject.toml` during settings discovery: + TOML parse error at line 2, column 1 + | + 2 | unknown = "field" + | ^^^^^^^ + unknown field `unknown`, expected one of `native-tls`, `offline`, `no-cache`, `cache-dir`, `preview`, `python-preference`, `python-downloads`, `concurrent-downloads`, `concurrent-builds`, `concurrent-installs`, `index-url`, `extra-index-url`, `no-index`, `find-links`, `index-strategy`, `keyring-provider`, `allow-insecure-host`, `resolution`, `prerelease`, `dependency-metadata`, `config-settings`, `no-build-isolation`, `no-build-isolation-package`, `exclude-newer`, `link-mode`, `compile-bytecode`, `no-sources`, `upgrade`, `upgrade-package`, `reinstall`, `reinstall-package`, `no-build`, `no-build-package`, `no-binary`, `no-binary-package`, `pip`, `cache-keys`, `override-dependencies`, `constraint-dependencies`, `environments`, `workspace`, `sources`, `dev-dependencies`, `managed`, `package` + + Resolved in [TIME] + Audited in [TIME] + "### + ); + + Ok(()) +} + /// 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 diff --git a/crates/uv/tests/show_settings.rs b/crates/uv/tests/show_settings.rs index 6719ed4682b9..4709dacf566d 100644 --- a/crates/uv/tests/show_settings.rs +++ b/crates/uv/tests/show_settings.rs @@ -3146,11 +3146,11 @@ fn resolve_config_file() -> anyhow::Result<()> { ----- stderr ----- error: Failed to parse: `[CACHE_DIR]/uv.toml` - Caused by: TOML parse error at line 1, column 1 + Caused by: TOML parse error at line 1, column 2 | 1 | [project] - | ^ - unknown field `project` + | ^^^^^^^ + unknown field `project`, expected one of `native-tls`, `offline`, `no-cache`, `cache-dir`, `preview`, `python-preference`, `python-downloads`, `concurrent-downloads`, `concurrent-builds`, `concurrent-installs`, `index-url`, `extra-index-url`, `no-index`, `find-links`, `index-strategy`, `keyring-provider`, `allow-insecure-host`, `resolution`, `prerelease`, `dependency-metadata`, `config-settings`, `no-build-isolation`, `no-build-isolation-package`, `exclude-newer`, `link-mode`, `compile-bytecode`, `no-sources`, `upgrade`, `upgrade-package`, `reinstall`, `reinstall-package`, `no-build`, `no-build-package`, `no-binary`, `no-binary-package`, `pip`, `cache-keys`, `override-dependencies`, `constraint-dependencies`, `environments`, `workspace`, `sources`, `dev-dependencies`, `managed`, `package` "### ); diff --git a/uv.schema.json b/uv.schema.json index 15cdc4729ca4..f1e3834258fe 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -23,7 +23,6 @@ }, "cache-keys": { "description": "The keys to consider when caching builds for the project.\n\nCache keys enable you to specify the files or directories that should trigger a rebuild when modified. By default, uv will rebuild a project whenever the `pyproject.toml`, `setup.py`, or `setup.cfg` files in the project directory are modified, i.e.:\n\n```toml cache-keys = [{ file = \"pyproject.toml\" }, { file = \"setup.py\" }, { file = \"setup.cfg\" }] ```\n\nAs an example: if a project uses dynamic metadata to read its dependencies from a `requirements.txt` file, you can specify `cache-keys = [{ file = \"requirements.txt\" }, { file = \"pyproject.toml\" }]` to ensure that the project is rebuilt whenever the `requirements.txt` file is modified (in addition to watching the `pyproject.toml`).\n\nGlobs are supported, following the syntax of the [`glob`](https://docs.rs/glob/0.3.1/glob/struct.Pattern.html) crate. For example, to invalidate the cache whenever a `.toml` file in the project directory or any of its subdirectories is modified, you can specify `cache-keys = [{ file = \"**/*.toml\" }]`. Note that the use of globs can be expensive, as uv may need to walk the filesystem to determine whether any files have changed.\n\nCache keys can also include version control information. For example, if a project uses `setuptools_scm` to read its version from a Git tag, you can specify `cache-keys = [{ git = true }, { file = \"pyproject.toml\" }]` to include the current Git commit hash in the cache key (in addition to the `pyproject.toml`).\n\nCache keys only affect the project defined by the `pyproject.toml` in which they're specified (as opposed to, e.g., affecting all members in a workspace), and all paths and globs are interpreted as relative to the project directory.", - "writeOnly": true, "type": [ "array", "null"