diff --git a/Cargo.lock b/Cargo.lock index 7f7df50bd1766..651b9f0fc5793 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6684,7 +6684,11 @@ name = "uv-preview" version = "0.0.20" dependencies = [ "enumflags2", + "insta", "itertools 0.14.0", + "schemars", + "serde", + "serde_json", "thiserror 2.0.18", "uv-warnings", ] @@ -7020,6 +7024,7 @@ dependencies = [ "uv-normalize", "uv-options-metadata", "uv-pep508", + "uv-preview", "uv-pypi-types", "uv-python", "uv-redacted", diff --git a/crates/uv-build-backend/src/lib.rs b/crates/uv-build-backend/src/lib.rs index ded8627cf88e1..797d7de6991cc 100644 --- a/crates/uv-build-backend/src/lib.rs +++ b/crates/uv-build-backend/src/lib.rs @@ -1810,7 +1810,7 @@ mod tests { let build = build( src.path(), dist.path(), - Preview::new(&[PreviewFeature::MetadataJson]), + Preview::from_iter(&[PreviewFeature::MetadataJson]), ) .unwrap(); diff --git a/crates/uv-preview/Cargo.toml b/crates/uv-preview/Cargo.toml index 79fab503bcae4..be546269b40b0 100644 --- a/crates/uv-preview/Cargo.toml +++ b/crates/uv-preview/Cargo.toml @@ -18,11 +18,15 @@ workspace = true [dependencies] uv-warnings = { workspace = true } +schemars = { workspace = true, optional = true } +serde = { workspace = true } enumflags2 = { workspace = true } itertools = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +insta = { workspace = true } +serde_json = { workspace = true } [features] default = [] diff --git a/crates/uv-preview/src/lib.rs b/crates/uv-preview/src/lib.rs index df716023c9c57..dbb7254e63daf 100644 --- a/crates/uv-preview/src/lib.rs +++ b/crates/uv-preview/src/lib.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::{ fmt::{Debug, Display, Formatter}, ops::BitOr, @@ -117,6 +118,40 @@ impl FromStr for PreviewFeature { } } +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for PreviewFeature { + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed("PreviewFeature") + } + + fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + let choices: Vec<&str> = BitFlags::::all().iter().map(Self::as_str).collect(); + schemars::json_schema!({ + "type": "string", + "enum": choices, + }) + } +} + +impl<'de> serde::Deserialize<'de> for PreviewFeature { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s: Cow<'de, str> = serde::Deserialize::deserialize(deserializer)?; + Self::from_str(&s).map_err(serde::de::Error::custom) + } +} + +impl serde::Serialize for PreviewFeature { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + #[derive(Clone, Copy, PartialEq, Eq, Default)] pub struct Preview { flags: BitFlags, @@ -130,36 +165,34 @@ impl Debug for Preview { } impl Preview { - pub fn new(flags: &[PreviewFeature]) -> Self { - Self { - flags: flags.iter().copied().fold(BitFlags::empty(), BitOr::bitor), - } - } - pub fn all() -> Self { Self { flags: BitFlags::all(), } } - pub fn from_args(preview: bool, no_preview: bool, preview_features: &[PreviewFeature]) -> Self { - if no_preview { - return Self::default(); - } - - if preview { - return Self::all(); - } - - Self::new(preview_features) - } - /// Check if a single feature is enabled pub fn is_enabled(&self, flag: PreviewFeature) -> bool { self.flags.contains(flag) } } +impl<'flag> FromIterator<&'flag PreviewFeature> for Preview { + fn from_iter>(iter: T) -> Self { + let flags = iter + .into_iter() + .copied() + .fold(BitFlags::empty(), BitOr::bitor); + Self { flags } + } +} + +impl From for Preview { + fn from(value: bool) -> Self { + if value { Self::all() } else { Self::default() } + } +} + impl Display for Preview { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { if self.flags.is_empty() { @@ -251,7 +284,7 @@ mod tests { // Test disabled let preview = Preview::default(); assert_eq!(preview.to_string(), "disabled"); - let preview = Preview::new(&[]); + let preview = Preview::from_iter(&[]); assert_eq!(preview.to_string(), "disabled"); // Test enabled (all features) @@ -259,31 +292,28 @@ mod tests { assert_eq!(preview.to_string(), "enabled"); // Test single feature - let preview = Preview::new(&[PreviewFeature::PythonInstallDefault]); + let preview = Preview::from_iter(&[PreviewFeature::PythonInstallDefault]); assert_eq!(preview.to_string(), "python-install-default"); // Test multiple features - let preview = Preview::new(&[PreviewFeature::PythonUpgrade, PreviewFeature::Pylock]); + let preview = Preview::from_iter(&[PreviewFeature::PythonUpgrade, PreviewFeature::Pylock]); assert_eq!(preview.to_string(), "python-upgrade,pylock"); } #[test] fn test_preview_from_args() { - // Test no preview and no no_preview, and no features - let preview = Preview::from_args(false, false, &[]); - assert_eq!(preview.to_string(), "disabled"); - // Test no_preview - let preview = Preview::from_args(true, true, &[]); + let preview = Preview::default(); assert_eq!(preview.to_string(), "disabled"); // Test preview (all features) - let preview = Preview::from_args(true, false, &[]); + let preview = Preview::all(); assert_eq!(preview.to_string(), "enabled"); // Test specific features - let features = vec![PreviewFeature::PythonUpgrade, PreviewFeature::JsonOutput]; - let preview = Preview::from_args(false, false, &features); + let preview: Preview = [PreviewFeature::PythonUpgrade, PreviewFeature::JsonOutput] + .iter() + .collect(); assert!(preview.is_enabled(PreviewFeature::PythonUpgrade)); assert!(preview.is_enabled(PreviewFeature::JsonOutput)); assert!(!preview.is_enabled(PreviewFeature::Pylock)); @@ -344,4 +374,20 @@ mod tests { "relocatable-envs-default" ); } + + #[test] + fn test_serde_roundtrip() { + let input = r#"["python-upgrade", "format"]"#; + + let deserialized: Vec = serde_json::from_str(input).unwrap(); + assert_eq!(deserialized.len(), 2); + assert_eq!(deserialized[0], PreviewFeature::PythonUpgrade); + assert_eq!(deserialized[1], PreviewFeature::Format); + + let serialized = serde_json::to_string(&deserialized).unwrap(); + insta::assert_snapshot!(serialized, @r#"["python-upgrade","format"]"#); + + let roundtrip: Vec = serde_json::from_str(&serialized).unwrap(); + assert_eq!(roundtrip, deserialized); + } } diff --git a/crates/uv-python/src/lib.rs b/crates/uv-python/src/lib.rs index e2776063aca40..83bcab8ff71e3 100644 --- a/crates/uv-python/src/lib.rs +++ b/crates/uv-python/src/lib.rs @@ -1353,7 +1353,7 @@ mod tests { EnvironmentPreference::OnlyVirtual, PythonPreference::OnlySystem, &context.cache, - Preview::new(&[PreviewFeature::SpecialCondaEnvNames]), + Preview::from_iter(&[PreviewFeature::SpecialCondaEnvNames]), ) }, )? diff --git a/crates/uv-settings/Cargo.toml b/crates/uv-settings/Cargo.toml index 56c2f352f6304..799a99a061d29 100644 --- a/crates/uv-settings/Cargo.toml +++ b/crates/uv-settings/Cargo.toml @@ -28,6 +28,7 @@ uv-macros = { workspace = true } uv-normalize = { workspace = true, features = ["schemars"] } uv-options-metadata = { workspace = true } uv-pep508 = { workspace = true } +uv-preview = { workspace = true, features = ["schemars"] } uv-pypi-types = { workspace = true } uv-python = { workspace = true, features = ["schemars", "clap"] } uv-redacted = { workspace = true } diff --git a/crates/uv-settings/src/combine.rs b/crates/uv-settings/src/combine.rs index 90e91884b7b66..2a435cd66a197 100644 --- a/crates/uv-settings/src/combine.rs +++ b/crates/uv-settings/src/combine.rs @@ -12,6 +12,7 @@ use uv_distribution_types::{ PipFindLinks, PipIndex, }; use uv_install_wheel::LinkMode; +use uv_preview::Preview; use uv_pypi_types::{SchemaConflicts, SupportedEnvironments}; use uv_python::{PythonDownloads, PythonPreference, PythonVersion}; use uv_redacted::DisplaySafeUrl; @@ -100,6 +101,7 @@ impl_combine_or!(PipExtraIndex); impl_combine_or!(PipFindLinks); impl_combine_or!(PipIndex); impl_combine_or!(PrereleaseMode); +impl_combine_or!(Preview); impl_combine_or!(ProxyUrl); impl_combine_or!(PythonDownloads); impl_combine_or!(PythonPreference); diff --git a/crates/uv-settings/src/lib.rs b/crates/uv-settings/src/lib.rs index c3a1736ff712b..07e3fc0363dfc 100644 --- a/crates/uv-settings/src/lib.rs +++ b/crates/uv-settings/src/lib.rs @@ -297,6 +297,7 @@ fn warn_uv_toml_masked_fields(options: &Options) { no_cache, cache_dir, preview, + preview_features, python_preference, python_downloads, concurrent_downloads, @@ -394,6 +395,9 @@ fn warn_uv_toml_masked_fields(options: &Options) { if preview.is_some() { masked_fields.push("preview"); } + if preview_features.is_some() { + masked_fields.push("preview-features"); + } if python_preference.is_some() { masked_fields.push("python-preference"); } diff --git a/crates/uv-settings/src/settings.rs b/crates/uv-settings/src/settings.rs index 8d07ecaf747b9..2586584e44b9a 100644 --- a/crates/uv-settings/src/settings.rs +++ b/crates/uv-settings/src/settings.rs @@ -15,6 +15,7 @@ use uv_install_wheel::LinkMode; use uv_macros::{CombineOptions, OptionsMetadata}; use uv_normalize::{ExtraName, PackageName, PipGroupName}; use uv_pep508::Requirement; +use uv_preview::PreviewFeature; use uv_pypi_types::{SupportedEnvironments, VerbatimParsedUrl}; use uv_python::{PythonDownloads, PythonPreference, PythonVersion}; use uv_redacted::DisplaySafeUrl; @@ -250,7 +251,7 @@ pub struct GlobalOptions { "# )] pub cache_dir: Option, - /// Whether to enable experimental, preview features. + /// Whether to enable all experimental, preview features. #[option( default = "false", value_type = "bool", @@ -259,6 +260,15 @@ pub struct GlobalOptions { "# )] pub preview: Option, + /// Whether to enable specific experimental, preview features. + #[option( + default = "[]", + value_type = "list[str]", + example = r#" + preview-features = ["python-upgrade"] + "# + )] + pub preview_features: Option>, /// Whether to prefer using Python installations that are already present on the system, or /// those that are downloaded and installed by uv. #[option( @@ -2153,6 +2163,7 @@ pub struct OptionsWire { no_cache: Option, cache_dir: Option, preview: Option, + preview_features: Option>, python_preference: Option, python_downloads: Option, concurrent_downloads: Option, @@ -2252,6 +2263,7 @@ impl From for Options { no_cache, cache_dir, preview, + preview_features, python_preference, python_downloads, python_install_mirror, @@ -2328,6 +2340,7 @@ impl From for Options { no_cache, cache_dir, preview, + preview_features, python_preference, python_downloads, concurrent_downloads, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index b7a3caa539c01..078e869572014 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -144,11 +144,7 @@ impl GlobalSettings { .unwrap_or_else(Concurrency::threads), }, show_settings: args.show_settings, - preview: Preview::from_args( - resolve_preview(args, workspace, environment), - args.no_preview, - &args.preview_features, - ), + preview: resolve_preview(args, workspace, environment), python_preference, python_downloads: flag( args.allow_python_downloads, @@ -217,22 +213,34 @@ fn resolve_preview( args: &GlobalArgs, workspace: Option<&FilesystemOptions>, environment: &EnvironmentOptions, -) -> bool { - // CLI takes precedence - match flag(args.preview, args.no_preview, "preview") { - Some(value) => value, - None => { - // Check environment variable - if environment.preview.value == Some(true) { - true - } else { - // Fall back to workspace config - workspace - .and_then(|workspace| workspace.globals.preview) - .unwrap_or(false) - } - } +) -> Preview { + // Commandline arguments take priority. + if let Some(preview) = flag(args.preview, args.no_preview, "preview") { + return preview.into(); + } + + // Check environment variable + if let Some(true) = environment.preview.value { + return Preview::all(); } + + // From preview_features + if !args.preview_features.is_empty() { + return Preview::from_iter(&args.preview_features); + } + + // Fall back to workspace config + workspace + .and_then(|workspace| { + workspace.globals.preview.map(Preview::from).or_else(|| { + workspace + .globals + .preview_features + .as_ref() + .map(Preview::from_iter) + }) + }) + .unwrap_or_default() } /// The resolved network settings to use for any invocation of the CLI. diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index f7367c0b35161..196c9702d80ec 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -4735,7 +4735,7 @@ fn resolve_config_file() -> anyhow::Result<()> { .arg("--show-settings") .arg("--config-file") .arg(config.path()) - .arg("requirements.in"), @" + .arg("requirements.in"), @r" success: false exit_code: 2 ----- stdout ----- @@ -4746,7 +4746,7 @@ fn resolve_config_file() -> anyhow::Result<()> { | 1 | [project] | ^^^^^^^ - unknown field `project`, expected one of `required-version`, `native-tls`, `offline`, `no-cache`, `cache-dir`, `preview`, `python-preference`, `python-downloads`, `concurrent-downloads`, `concurrent-builds`, `concurrent-installs`, `index`, `index-url`, `extra-index-url`, `no-index`, `find-links`, `index-strategy`, `keyring-provider`, `http-proxy`, `https-proxy`, `no-proxy`, `allow-insecure-host`, `resolution`, `prerelease`, `fork-strategy`, `dependency-metadata`, `config-settings`, `config-settings-package`, `no-build-isolation`, `no-build-isolation-package`, `extra-build-dependencies`, `extra-build-variables`, `exclude-newer`, `exclude-newer-package`, `link-mode`, `compile-bytecode`, `no-sources`, `no-sources-package`, `upgrade`, `upgrade-package`, `reinstall`, `reinstall-package`, `no-build`, `no-build-package`, `no-binary`, `no-binary-package`, `torch-backend`, `python-install-mirror`, `pypy-install-mirror`, `python-downloads-json-url`, `publish-url`, `trusted-publishing`, `check-url`, `add-bounds`, `pip`, `cache-keys`, `override-dependencies`, `exclude-dependencies`, `constraint-dependencies`, `build-constraint-dependencies`, `environments`, `required-environments`, `conflicts`, `workspace`, `sources`, `managed`, `package`, `default-groups`, `dependency-groups`, `dev-dependencies`, `build-backend` + unknown field `project`, expected one of `required-version`, `native-tls`, `offline`, `no-cache`, `cache-dir`, `preview`, `preview-features`, `python-preference`, `python-downloads`, `concurrent-downloads`, `concurrent-builds`, `concurrent-installs`, `index`, `index-url`, `extra-index-url`, `no-index`, `find-links`, `index-strategy`, `keyring-provider`, `http-proxy`, `https-proxy`, `no-proxy`, `allow-insecure-host`, `resolution`, `prerelease`, `fork-strategy`, `dependency-metadata`, `config-settings`, `config-settings-package`, `no-build-isolation`, `no-build-isolation-package`, `extra-build-dependencies`, `extra-build-variables`, `exclude-newer`, `exclude-newer-package`, `link-mode`, `compile-bytecode`, `no-sources`, `no-sources-package`, `upgrade`, `upgrade-package`, `reinstall`, `reinstall-package`, `no-build`, `no-build-package`, `no-binary`, `no-binary-package`, `torch-backend`, `python-install-mirror`, `pypy-install-mirror`, `python-downloads-json-url`, `publish-url`, `trusted-publishing`, `check-url`, `add-bounds`, `pip`, `cache-keys`, `override-dependencies`, `exclude-dependencies`, `constraint-dependencies`, `build-constraint-dependencies`, `environments`, `required-environments`, `conflicts`, `workspace`, `sources`, `managed`, `package`, `default-groups`, `dependency-groups`, `dev-dependencies`, `build-backend` " ); @@ -8684,6 +8684,1095 @@ fn preview_features() { ); } +/// Test CLI preview feature flags vs config precedence. +/// The CLI should always have higher priority. +#[test] +#[cfg_attr( + windows, + ignore = "Configuration tests are not yet supported on Windows" +)] +fn preview_features_precedence() -> anyhow::Result<()> { + let context = TestContext::new("3.12"); + + let cmd = || { + let mut cmd = context.version(); + cmd.arg("--show-settings"); + add_shared_args(cmd) + }; + + let config = context.temp_dir.child("pyproject.toml"); + config.write_str(indoc::indoc! {r#" + [project] + name = "demo" + version = "0.1.0" + + [tool.uv] + preview = false + preview-features = ["format"] + "#})?; + + // `uv.tool.preview = false` disables 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, + offline: Disabled, + native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, + allow_insecure_host: [], + read_timeout: [TIME], + connect_timeout: [TIME], + retries: 3, + }, + concurrency: Concurrency { + downloads: 50, + builds: 16, + installs: 8, + }, + show_settings: true, + preview: Preview { + flags: [], + }, + 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: None, + 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: None, + torch_backend: None, + upgrade: None, + }, + compile_bytecode: false, + reinstall: None, + }, + } + + ----- stderr ----- + "# + ); + + // `tool.uv.preview-features` will not merge with CLI `--preview-features` + // if `tool.uv.preview = false` + 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, + offline: Disabled, + native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, + allow_insecure_host: [], + read_timeout: [TIME], + connect_timeout: [TIME], + retries: 3, + }, + concurrency: Concurrency { + downloads: 50, + builds: 16, + installs: 8, + }, + show_settings: true, + preview: Preview { + flags: [ + 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: None, + 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: None, + torch_backend: None, + upgrade: None, + }, + compile_bytecode: false, + reinstall: None, + }, + } + + ----- stderr ----- + "# + ); + + // 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, + offline: Disabled, + native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, + allow_insecure_host: [], + read_timeout: [TIME], + connect_timeout: [TIME], + retries: 3, + }, + concurrency: Concurrency { + downloads: 50, + builds: 16, + installs: 8, + }, + show_settings: true, + preview: Preview { + flags: [ + PythonInstallDefault, + PythonUpgrade, + JsonOutput, + Pylock, + AddBounds, + PackageConflicts, + ExtraBuildDependencies, + DetectModuleConflicts, + Format, + NativeAuth, + S3Endpoint, + CacheSize, + InitProjectFlag, + WorkspaceMetadata, + WorkspaceDir, + WorkspaceList, + SbomExport, + AuthHelper, + DirectPublish, + TargetWorkspaceDiscovery, + MetadataJson, + GcsEndpoint, + AdjustUlimit, + SpecialCondaEnvNames, + RelocatableEnvsDefault, + ], + }, + 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: None, + 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: None, + 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, + offline: Disabled, + native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, + allow_insecure_host: [], + read_timeout: [TIME], + connect_timeout: [TIME], + retries: 3, + }, + concurrency: Concurrency { + downloads: 50, + builds: 16, + installs: 8, + }, + show_settings: true, + preview: Preview { + flags: [ + PythonInstallDefault, + PythonUpgrade, + JsonOutput, + Pylock, + AddBounds, + PackageConflicts, + ExtraBuildDependencies, + DetectModuleConflicts, + Format, + NativeAuth, + S3Endpoint, + CacheSize, + InitProjectFlag, + WorkspaceMetadata, + WorkspaceDir, + WorkspaceList, + SbomExport, + AuthHelper, + DirectPublish, + TargetWorkspaceDiscovery, + MetadataJson, + GcsEndpoint, + AdjustUlimit, + SpecialCondaEnvNames, + RelocatableEnvsDefault, + ], + }, + 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: None, + 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: None, + 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, + offline: Disabled, + native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, + allow_insecure_host: [], + read_timeout: [TIME], + connect_timeout: [TIME], + retries: 3, + }, + concurrency: Concurrency { + downloads: 50, + builds: 16, + installs: 8, + }, + show_settings: true, + preview: Preview { + flags: [ + 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: None, + 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: None, + 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, + offline: Disabled, + native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, + allow_insecure_host: [], + read_timeout: [TIME], + connect_timeout: [TIME], + retries: 3, + }, + concurrency: Concurrency { + downloads: 50, + builds: 16, + installs: 8, + }, + show_settings: true, + preview: Preview { + flags: [], + }, + 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: None, + 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: None, + 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` + 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, + offline: Disabled, + native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, + allow_insecure_host: [], + read_timeout: [TIME], + connect_timeout: [TIME], + retries: 3, + }, + concurrency: Concurrency { + downloads: 50, + builds: 16, + installs: 8, + }, + show_settings: true, + preview: Preview { + flags: [ + 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: None, + 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: None, + 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", + "format", + ] + "#, + )?; + + // Duplicate preview features are reduced + 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, + offline: Disabled, + native_tls: false, + http_proxy: None, + https_proxy: None, + no_proxy: None, + allow_insecure_host: [], + read_timeout: [TIME], + connect_timeout: [TIME], + retries: 3, + }, + concurrency: Concurrency { + downloads: 50, + builds: 16, + installs: 8, + }, + show_settings: true, + preview: Preview { + flags: [ + Format, + ], + }, + 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: None, + 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: None, + torch_backend: None, + upgrade: None, + }, + compile_bytecode: false, + reinstall: None, + }, + } + + ----- stderr ----- + "# + ); + + Ok(()) +} + /// Track the interactions between `upgrade` and `upgrade-package` across the `uv pip` CLI and a /// configuration file. #[test] diff --git a/uv.schema.json b/uv.schema.json index d660b94ab1dab..8596bacd27dc4 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -421,9 +421,16 @@ ] }, "preview": { - "description": "Whether to enable experimental, preview features.", + "description": "Whether to enable all experimental, preview features.", "type": ["boolean", "null"] }, + "preview-features": { + "description": "Whether to enable specific experimental, preview features.", + "type": ["array", "null"], + "items": { + "$ref": "#/definitions/PreviewFeature" + } + }, "publish-url": { "description": "The URL for publishing packages to the Python package index (by default:\n).", "anyOf": [ @@ -1576,6 +1583,36 @@ } ] }, + "PreviewFeature": { + "type": "string", + "enum": [ + "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", + "direct-publish", + "target-workspace-discovery", + "metadata-json", + "gcs-endpoint", + "adjust-ulimit", + "special-conda-env-names", + "relocatable-envs-default" + ] + }, "ProxyUrl": { "description": "A proxy URL (e.g., `http://proxy.example.com:8080`).", "type": "string",