diff --git a/crates/uv-settings/src/lib.rs b/crates/uv-settings/src/lib.rs index 52a2cbe631f..0799b1c362e 100644 --- a/crates/uv-settings/src/lib.rs +++ b/crates/uv-settings/src/lib.rs @@ -12,8 +12,11 @@ use uv_warnings::warn_user; pub use crate::combine::*; pub use crate::settings::*; +use crate::validation::{Context, ValidationError, Validator}; + mod combine; mod settings; +mod validation; /// The [`Options`] as loaded from a configuration file on disk. #[derive(Debug, Clone)] @@ -47,6 +50,7 @@ impl FilesystemOptions { match read_file(&file) { Ok(options) => { tracing::debug!("Found user configuration in: `{}`", file.display()); + options.validate(&Context { path: &file })?; validate_uv_toml(&file, &options)?; Ok(Some(Self(options))) } @@ -71,6 +75,7 @@ impl FilesystemOptions { tracing::debug!("Found system configuration in: `{}`", file.display()); let options = read_file(&file)?; + options.validate(&Context { path: &file })?; validate_uv_toml(&file, &options)?; Ok(Some(Self(options))) } @@ -130,6 +135,7 @@ impl FilesystemOptions { } tracing::debug!("Found workspace configuration at `{}`", path.display()); + options.validate(&Context { path: &path })?; validate_uv_toml(&path, &options)?; return Ok(Some(Self(options))); } @@ -162,6 +168,7 @@ impl FilesystemOptions { let options = options.relative_to(&std::path::absolute(dir)?)?; tracing::debug!("Found workspace configuration at `{}`", path.display()); + options.validate(&Context { path: &path })?; return Ok(Some(Self(options))); } Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} @@ -177,6 +184,7 @@ impl FilesystemOptions { tracing::debug!("Reading user configuration from: `{}`", path.display()); let options = read_file(path)?; + options.validate(&Context { path })?; validate_uv_toml(path, &options)?; Ok(Self(options)) } @@ -577,6 +585,9 @@ pub enum Error { value: String, err: String, }, + + #[error(transparent)] + Validation(#[from] ValidationError), } #[derive(Copy, Clone, Debug)] diff --git a/crates/uv-settings/src/validation.rs b/crates/uv-settings/src/validation.rs new file mode 100644 index 00000000000..d927d2b94d6 --- /dev/null +++ b/crates/uv-settings/src/validation.rs @@ -0,0 +1,48 @@ +use std::path::{Path, PathBuf}; + +use uv_fs::Simplified; + +use crate::{GlobalOptions, Options}; + +pub(crate) trait Validator { + fn validate(&self, ctx: &Context) -> Result<(), Err>; +} + +pub(crate) struct Context<'p> { + pub path: &'p Path, +} + +impl<'path> Validator> for Options { + /// Validate that an [`Options`] struct has correct values. + fn validate(&self, ctx: &Context<'path>) -> Result<(), ValidationError> { + let Self { globals, .. } = self; + globals.validate(ctx) + } +} + +#[derive(thiserror::Error, Debug)] +pub enum ValidationError { + #[error(transparent)] + GlobalOptions(#[from] GlobalOptionsError), +} + +impl<'path> Validator> for GlobalOptions { + /// Validate that a [`GlobalOptions`] struct has correct values. + fn validate(&self, ctx: &Context<'path>) -> Result<(), ValidationError> { + let Self { + preview, + preview_features, + .. + } = self; + if preview.is_some() && preview_features.is_some() { + return Err(GlobalOptionsError::PreviewFeatures(ctx.path.to_path_buf()).into()); + } + Ok(()) + } +} + +#[derive(thiserror::Error, Debug)] +pub enum GlobalOptionsError { + #[error("Failed to parse: `{}`. Cannot specify both `preview` and `preview-features`.", _0.user_display())] + PreviewFeatures(PathBuf), +} diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index 60bedafe37a..fec8517d404 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -4334,6 +4334,201 @@ fn resolve_both_special_fields() -> anyhow::Result<()> { Ok(()) } +/// Parsing both `uv.toml` and `pyproject.toml` with invalid options in the ignored file succeeds with a warning. +#[test] +#[cfg_attr( + windows, + ignore = "Configuration tests are not yet supported on Windows" +)] +fn resolve_both_with_ignored_invalid_options() -> anyhow::Result<()> { + let context = TestContext::new("3.12"); + + let config = context.temp_dir.child("uv.toml"); + config.write_str(indoc::indoc! {r" + preview = false + "})?; + + // Invalid options in `pyproject.toml` will be ignored in favor of `uv.toml`. + let config = context.temp_dir.child("pyproject.toml"); + config.write_str(indoc::indoc! {r#" + [tool.uv] + preview = false + preview-features = ["pylock"] + "#})?; + + uv_snapshot!(context.filters(), add_shared_args(context.pip_compile(), context.temp_dir.path()) + .arg("--show-settings") + .arg("requirements.in"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + GlobalSettings { + required_version: None, + quiet: 0, + verbose: 0, + color: Auto, + network_settings: NetworkSettings { + connectivity: Online, + native_tls: false, + allow_insecure_host: [], + timeout: [TIME], + retries: 3, + }, + concurrency: Concurrency { + downloads: 50, + builds: 16, + installs: 8, + }, + show_settings: true, + preview: Preview { + flags: PreviewFeatures( + 0x0, + ), + }, + python_preference: Managed, + python_downloads: Automatic, + no_progress: false, + installer_metadata: true, + } + CacheSettings { + no_cache: false, + cache_dir: Some( + "[CACHE_DIR]/", + ), + } + PipCompileSettings { + format: None, + src_file: [ + "requirements.in", + ], + constraints: [], + overrides: [], + excludes: [], + build_constraints: [], + constraints_from_workspace: [], + overrides_from_workspace: [], + excludes_from_workspace: [], + build_constraints_from_workspace: [], + environments: SupportedEnvironments( + [], + ), + refresh: None( + Timestamp( + SystemTime { + tv_sec: [TIME], + tv_nsec: [TIME], + }, + ), + ), + settings: PipSettings { + index_locations: IndexLocations { + indexes: [], + flat_index: [], + no_index: false, + }, + python: None, + install_mirrors: PythonInstallMirrors { + python_install_mirror: None, + pypy_install_mirror: None, + python_downloads_json_url: None, + }, + system: false, + extras: ExtrasSpecification( + ExtrasSpecificationInner { + include: Some( + [], + ), + exclude: [], + only_extras: false, + history: ExtrasSpecificationHistory { + extra: [], + only_extra: [], + no_extra: [], + all_extras: false, + no_default_extras: false, + defaults: List( + [], + ), + }, + }, + ), + groups: [], + break_system_packages: false, + target: None, + prefix: None, + index_strategy: FirstIndex, + keyring_provider: Disabled, + torch_backend: None, + build_isolation: Isolate, + extra_build_dependencies: ExtraBuildDependencies( + {}, + ), + extra_build_variables: ExtraBuildVariables( + {}, + ), + build_options: BuildOptions { + no_binary: None, + no_build: None, + }, + allow_empty_requirements: false, + strict: false, + dependency_mode: Transitive, + resolution: Highest, + prerelease: IfNecessaryOrExplicit, + fork_strategy: RequiresPython, + dependency_metadata: DependencyMetadata( + {}, + ), + output_file: None, + no_strip_extras: false, + no_strip_markers: false, + no_annotate: false, + no_header: false, + custom_compile_command: None, + generate_hashes: false, + config_setting: ConfigSettings( + {}, + ), + config_settings_package: PackageConfigSettings( + {}, + ), + python_version: None, + python_platform: None, + universal: false, + exclude_newer: ExcludeNewer { + global: None, + package: ExcludeNewerPackage( + {}, + ), + }, + no_emit_package: [], + emit_index_url: false, + emit_find_links: false, + emit_build_options: false, + emit_marker_expression: false, + emit_index_annotation: false, + annotation_style: Split, + link_mode: Clone, + compile_bytecode: false, + sources: Enabled, + hash_checking: Some( + Verify, + ), + upgrade: None, + reinstall: None, + }, + } + + ----- stderr ----- + warning: Found both a `uv.toml` file and a `[tool.uv]` section in an adjacent `pyproject.toml`. The following fields from `[tool.uv]` will be ignored in favor of the `uv.toml` file: + - preview + - preview-features + "# + ); + + Ok(()) +} + /// Tests that errors when parsing `conflicts` are reported. #[test] fn invalid_conflicts() -> anyhow::Result<()> { @@ -8530,8 +8725,33 @@ fn preview_features_precedence() -> anyhow::Result<()> { preview-features = ["format"] "#})?; - // `uv.tool.preview = false` disables all features regardless of `uv.tool.preview-features`. - uv_snapshot!(context.filters(), cmd(), @r#" + // Both `uv.tool.preview` and `uv.tool.preview-features` cannot both be specified. + uv_snapshot!(context.filters(), cmd(), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to parse: `pyproject.toml`. Cannot specify both `preview` and `preview-features`. + " + ); + + let config = context.temp_dir.child("pyproject.toml"); + config.write_str( + r#" + [project] + name = "demo" + version = "0.1.0" + + [tool.uv] + preview-features = ["format"] + "#, + )?; + + // CLI `--preview` takes precedence over config settings. + uv_snapshot!(context.filters(), cmd() + .arg("--preview") + .arg("pylock"), @r#" success: true exit_code: 0 ----- stdout ----- @@ -8555,7 +8775,7 @@ fn preview_features_precedence() -> anyhow::Result<()> { show_settings: true, preview: Preview { flags: PreviewFeatures( - 0x0, + PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH | S3_ENDPOINT | CACHE_SIZE | INIT_PROJECT_FLAG | WORKSPACE_METADATA | WORKSPACE_DIR | WORKSPACE_LIST | SBOM_EXPORT | AUTH_HELPER, ), }, python_preference: Managed, @@ -8570,7 +8790,9 @@ fn preview_features_precedence() -> anyhow::Result<()> { ), } VersionSettings { - value: None, + value: Some( + "pylock", + ), bump: [], short: false, output_format: Text, @@ -8646,10 +8868,9 @@ fn preview_features_precedence() -> anyhow::Result<()> { "# ); - // `tool.uv.preview-features` will not merge with CLI `--preview-features` - // if `tool.uv.preview = false` + // CLI `--no-preview` takes precedence over config settings. uv_snapshot!(context.filters(), cmd() - .arg("--preview-features") + .arg("--no-preview") .arg("pylock"), @r#" success: true exit_code: 0 @@ -8674,7 +8895,7 @@ fn preview_features_precedence() -> anyhow::Result<()> { show_settings: true, preview: Preview { flags: PreviewFeatures( - PYLOCK, + 0x0, ), }, python_preference: Managed, @@ -8689,7 +8910,9 @@ fn preview_features_precedence() -> anyhow::Result<()> { ), } VersionSettings { - value: None, + value: Some( + "pylock", + ), bump: [], short: false, output_format: Text, @@ -8765,498 +8988,7 @@ fn preview_features_precedence() -> anyhow::Result<()> { "# ); - // CLI `--preview` takes precedence over configs settings. - uv_snapshot!(context.filters(), cmd().arg("--preview") , @r#" - success: true - exit_code: 0 - ----- stdout ----- - GlobalSettings { - required_version: None, - quiet: 0, - verbose: 0, - color: Auto, - network_settings: NetworkSettings { - connectivity: Online, - native_tls: false, - allow_insecure_host: [], - timeout: [TIME], - retries: 3, - }, - concurrency: Concurrency { - downloads: 50, - builds: 16, - installs: 8, - }, - show_settings: true, - preview: Preview { - flags: PreviewFeatures( - PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH | S3_ENDPOINT | CACHE_SIZE | INIT_PROJECT_FLAG | WORKSPACE_METADATA | WORKSPACE_DIR | WORKSPACE_LIST | SBOM_EXPORT | AUTH_HELPER, - ), - }, - python_preference: Managed, - python_downloads: Automatic, - no_progress: false, - installer_metadata: true, - } - CacheSettings { - no_cache: false, - cache_dir: Some( - "[CACHE_DIR]/", - ), - } - VersionSettings { - value: None, - bump: [], - short: false, - output_format: Text, - dry_run: false, - lock_check: Disabled, - frozen: false, - active: None, - no_sync: false, - package: None, - python: None, - install_mirrors: PythonInstallMirrors { - python_install_mirror: None, - pypy_install_mirror: None, - python_downloads_json_url: None, - }, - refresh: None( - Timestamp( - SystemTime { - tv_sec: [TIME], - tv_nsec: [TIME], - }, - ), - ), - settings: ResolverInstallerSettings { - resolver: ResolverSettings { - build_options: BuildOptions { - no_binary: None, - no_build: None, - }, - config_setting: ConfigSettings( - {}, - ), - config_settings_package: PackageConfigSettings( - {}, - ), - dependency_metadata: DependencyMetadata( - {}, - ), - exclude_newer: ExcludeNewer { - global: None, - package: ExcludeNewerPackage( - {}, - ), - }, - fork_strategy: RequiresPython, - index_locations: IndexLocations { - indexes: [], - flat_index: [], - no_index: false, - }, - index_strategy: FirstIndex, - keyring_provider: Disabled, - link_mode: Clone, - build_isolation: Isolate, - extra_build_dependencies: ExtraBuildDependencies( - {}, - ), - extra_build_variables: ExtraBuildVariables( - {}, - ), - prerelease: IfNecessaryOrExplicit, - resolution: Highest, - sources: Enabled, - torch_backend: None, - upgrade: None, - }, - compile_bytecode: false, - reinstall: None, - }, - } - - ----- stderr ----- - "# - ); - - let config = context.temp_dir.child("pyproject.toml"); - config.write_str( - r#" - [project] - name = "demo" - version = "0.1.0" - - [tool.uv] - preview = true - preview-features = ["format"] - "#, - )?; - - // `uv.tool.preview = true` enables all features, regardless of `uv.tool.preview-features` - uv_snapshot!(context.filters(), cmd(), @r#" - success: true - exit_code: 0 - ----- stdout ----- - GlobalSettings { - required_version: None, - quiet: 0, - verbose: 0, - color: Auto, - network_settings: NetworkSettings { - connectivity: Online, - native_tls: false, - allow_insecure_host: [], - timeout: [TIME], - retries: 3, - }, - concurrency: Concurrency { - downloads: 50, - builds: 16, - installs: 8, - }, - show_settings: true, - preview: Preview { - flags: PreviewFeatures( - PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH | S3_ENDPOINT | CACHE_SIZE | INIT_PROJECT_FLAG | WORKSPACE_METADATA | WORKSPACE_DIR | WORKSPACE_LIST | SBOM_EXPORT | AUTH_HELPER, - ), - }, - python_preference: Managed, - python_downloads: Automatic, - no_progress: false, - installer_metadata: true, - } - CacheSettings { - no_cache: false, - cache_dir: Some( - "[CACHE_DIR]/", - ), - } - VersionSettings { - value: None, - bump: [], - short: false, - output_format: Text, - dry_run: false, - lock_check: Disabled, - frozen: false, - active: None, - no_sync: false, - package: None, - python: None, - install_mirrors: PythonInstallMirrors { - python_install_mirror: None, - pypy_install_mirror: None, - python_downloads_json_url: None, - }, - refresh: None( - Timestamp( - SystemTime { - tv_sec: [TIME], - tv_nsec: [TIME], - }, - ), - ), - settings: ResolverInstallerSettings { - resolver: ResolverSettings { - build_options: BuildOptions { - no_binary: None, - no_build: None, - }, - config_setting: ConfigSettings( - {}, - ), - config_settings_package: PackageConfigSettings( - {}, - ), - dependency_metadata: DependencyMetadata( - {}, - ), - exclude_newer: ExcludeNewer { - global: None, - package: ExcludeNewerPackage( - {}, - ), - }, - fork_strategy: RequiresPython, - index_locations: IndexLocations { - indexes: [], - flat_index: [], - no_index: false, - }, - index_strategy: FirstIndex, - keyring_provider: Disabled, - link_mode: Clone, - build_isolation: Isolate, - extra_build_dependencies: ExtraBuildDependencies( - {}, - ), - extra_build_variables: ExtraBuildVariables( - {}, - ), - prerelease: IfNecessaryOrExplicit, - resolution: Highest, - sources: Enabled, - torch_backend: None, - upgrade: None, - }, - compile_bytecode: false, - reinstall: None, - }, - } - - ----- stderr ----- - "# - ); - - // CLI `--preview-features` takes precedence over `uv.tool.preview = true`. - uv_snapshot!(context.filters(), cmd() - .arg("--preview-features") - .arg("pylock"), @r#" - success: true - exit_code: 0 - ----- stdout ----- - GlobalSettings { - required_version: None, - quiet: 0, - verbose: 0, - color: Auto, - network_settings: NetworkSettings { - connectivity: Online, - native_tls: false, - allow_insecure_host: [], - timeout: [TIME], - retries: 3, - }, - concurrency: Concurrency { - downloads: 50, - builds: 16, - installs: 8, - }, - show_settings: true, - preview: Preview { - flags: PreviewFeatures( - PYLOCK, - ), - }, - python_preference: Managed, - python_downloads: Automatic, - no_progress: false, - installer_metadata: true, - } - CacheSettings { - no_cache: false, - cache_dir: Some( - "[CACHE_DIR]/", - ), - } - VersionSettings { - value: None, - bump: [], - short: false, - output_format: Text, - dry_run: false, - lock_check: Disabled, - frozen: false, - active: None, - no_sync: false, - package: None, - python: None, - install_mirrors: PythonInstallMirrors { - python_install_mirror: None, - pypy_install_mirror: None, - python_downloads_json_url: None, - }, - refresh: None( - Timestamp( - SystemTime { - tv_sec: [TIME], - tv_nsec: [TIME], - }, - ), - ), - settings: ResolverInstallerSettings { - resolver: ResolverSettings { - build_options: BuildOptions { - no_binary: None, - no_build: None, - }, - config_setting: ConfigSettings( - {}, - ), - config_settings_package: PackageConfigSettings( - {}, - ), - dependency_metadata: DependencyMetadata( - {}, - ), - exclude_newer: ExcludeNewer { - global: None, - package: ExcludeNewerPackage( - {}, - ), - }, - fork_strategy: RequiresPython, - index_locations: IndexLocations { - indexes: [], - flat_index: [], - no_index: false, - }, - index_strategy: FirstIndex, - keyring_provider: Disabled, - link_mode: Clone, - build_isolation: Isolate, - extra_build_dependencies: ExtraBuildDependencies( - {}, - ), - extra_build_variables: ExtraBuildVariables( - {}, - ), - prerelease: IfNecessaryOrExplicit, - resolution: Highest, - sources: Enabled, - torch_backend: None, - upgrade: None, - }, - compile_bytecode: false, - reinstall: None, - }, - } - - ----- stderr ----- - "# - ); - - // CLI `--no-preview` takes precedence over config settings. - uv_snapshot!(context.filters(), cmd().arg("--no-preview"), @r#" - success: true - exit_code: 0 - ----- stdout ----- - GlobalSettings { - required_version: None, - quiet: 0, - verbose: 0, - color: Auto, - network_settings: NetworkSettings { - connectivity: Online, - native_tls: false, - allow_insecure_host: [], - timeout: [TIME], - retries: 3, - }, - concurrency: Concurrency { - downloads: 50, - builds: 16, - installs: 8, - }, - show_settings: true, - preview: Preview { - flags: PreviewFeatures( - 0x0, - ), - }, - python_preference: Managed, - python_downloads: Automatic, - no_progress: false, - installer_metadata: true, - } - CacheSettings { - no_cache: false, - cache_dir: Some( - "[CACHE_DIR]/", - ), - } - VersionSettings { - value: None, - bump: [], - short: false, - output_format: Text, - dry_run: false, - lock_check: Disabled, - frozen: false, - active: None, - no_sync: false, - package: None, - python: None, - install_mirrors: PythonInstallMirrors { - python_install_mirror: None, - pypy_install_mirror: None, - python_downloads_json_url: None, - }, - refresh: None( - Timestamp( - SystemTime { - tv_sec: [TIME], - tv_nsec: [TIME], - }, - ), - ), - settings: ResolverInstallerSettings { - resolver: ResolverSettings { - build_options: BuildOptions { - no_binary: None, - no_build: None, - }, - config_setting: ConfigSettings( - {}, - ), - config_settings_package: PackageConfigSettings( - {}, - ), - dependency_metadata: DependencyMetadata( - {}, - ), - exclude_newer: ExcludeNewer { - global: None, - package: ExcludeNewerPackage( - {}, - ), - }, - fork_strategy: RequiresPython, - index_locations: IndexLocations { - indexes: [], - flat_index: [], - no_index: false, - }, - index_strategy: FirstIndex, - keyring_provider: Disabled, - link_mode: Clone, - build_isolation: Isolate, - extra_build_dependencies: ExtraBuildDependencies( - {}, - ), - extra_build_variables: ExtraBuildVariables( - {}, - ), - prerelease: IfNecessaryOrExplicit, - resolution: Highest, - sources: Enabled, - torch_backend: None, - upgrade: None, - }, - compile_bytecode: false, - reinstall: None, - }, - } - - ----- stderr ----- - "# - ); - - let config = context.temp_dir.child("pyproject.toml"); - config.write_str( - r#" - [project] - name = "demo" - version = "0.1.0" - - [tool.uv] - preview-features = ["format"] - "#, - )?; - - // CLI `--preview-features` takes precedence over `uv.tool.preview-features` + // CLI `--preview-features` takes precedence over config settings. uv_snapshot!(context.filters(), cmd() .arg("--preview-features") .arg("pylock"), @r#"