diff --git a/Cargo.lock b/Cargo.lock index fb8ce7f1e..01305faf5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6454,6 +6454,10 @@ name = "uv-preview" version = "0.0.8" dependencies = [ "bitflags 2.9.4", + "insta", + "schemars", + "serde", + "serde_json", "thiserror 2.0.17", "uv-warnings", ] @@ -6787,6 +6791,7 @@ dependencies = [ "uv-normalize", "uv-options-metadata", "uv-pep508", + "uv-preview", "uv-pypi-types", "uv-python", "uv-redacted", diff --git a/crates/uv-preview/Cargo.toml b/crates/uv-preview/Cargo.toml index a505f056b..67dc76f57 100644 --- a/crates/uv-preview/Cargo.toml +++ b/crates/uv-preview/Cargo.toml @@ -19,9 +19,13 @@ workspace = true uv-warnings = { workspace = true } bitflags = { workspace = true } +schemars = { workspace = true, optional = true } +serde = { 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 146c6e580..30e02fac2 100644 --- a/crates/uv-preview/src/lib.rs +++ b/crates/uv-preview/src/lib.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "schemars")] +use std::borrow::Cow; use std::{ fmt::{Display, Formatter}, str::FromStr, @@ -70,6 +72,67 @@ impl Display for PreviewFeatures { } } +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for PreviewFeatures { + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed("PreviewFeatures") + } + + fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + let choices: Vec<&str> = Self::all().iter().map(Self::flag_as_str).collect(); + schemars::json_schema!({ + "type": "string", + "enum": choices, + }) + } +} + +impl<'de> serde::Deserialize<'de> for PreviewFeatures { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct Visitor; + + impl serde::de::Visitor<'_> for Visitor { + type Value = PreviewFeatures; + + fn expecting(&self, f: &mut Formatter) -> std::fmt::Result { + f.write_str("a string") + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + PreviewFeatures::from_str(v).map_err(serde::de::Error::custom) + } + } + + deserializer.deserialize_str(Visitor) + } +} + +impl serde::Serialize for PreviewFeatures { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + if *self == Self::default() { + return Err(serde::ser::Error::custom( + "Cannot serialize disabled feature flags.", + )); + } + if self.bits().count_ones() > 1 { + return Err(serde::ser::Error::custom( + "More than one preview feature flag enabled. \ + Only individual flags should be serialized at a time.", + )); + } + serializer.serialize_str(self.flag_as_str()) + } +} + #[derive(Debug, Error, Clone)] pub enum PreviewFeaturesParseError { #[error("Empty string in preview features: {0}")] @@ -135,33 +198,27 @@ impl Preview { Self::new(PreviewFeatures::all()) } - pub fn from_args( - preview: bool, - no_preview: bool, - preview_features: &[PreviewFeatures], - ) -> Self { - if no_preview { - return Self::default(); - } - - if preview { - return Self::all(); - } - - let mut flags = PreviewFeatures::empty(); - - for features in preview_features { - flags |= *features; - } - - Self { flags } - } - pub fn is_enabled(&self, flag: PreviewFeatures) -> bool { self.flags.contains(flag) } } +impl<'flag> FromIterator<&'flag PreviewFeatures> for Preview { + fn from_iter>(iter: T) -> Self { + let flags = iter + .into_iter() + .copied() + .fold(PreviewFeatures::empty(), |f1, f2| f1 | f2); + Self::new(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() { @@ -239,19 +296,20 @@ mod tests { #[test] fn test_preview_from_args() { // 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![ + let preview: Preview = [ PreviewFeatures::PYTHON_UPGRADE, PreviewFeatures::JSON_OUTPUT, - ]; - let preview = Preview::from_args(false, false, &features); + ] + .iter() + .collect(); assert!(preview.is_enabled(PreviewFeatures::PYTHON_UPGRADE)); assert!(preview.is_enabled(PreviewFeatures::JSON_OUTPUT)); assert!(!preview.is_enabled(PreviewFeatures::PYLOCK)); @@ -293,4 +351,27 @@ mod tests { let features = PreviewFeatures::PYTHON_UPGRADE | PreviewFeatures::JSON_OUTPUT; let _ = features.flag_as_str(); } + + #[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], PreviewFeatures::PYTHON_UPGRADE); + assert_eq!(deserialized[1], PreviewFeatures::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); + } + + #[test] + #[should_panic(expected = "Cannot serialize disabled feature flags.")] + fn test_serialize_default() { + let disabled = PreviewFeatures::default(); + let _ = serde_json::to_string(&disabled).unwrap(); + } } diff --git a/crates/uv-settings/Cargo.toml b/crates/uv-settings/Cargo.toml index e32fc2894..6f41afb5c 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 15a63e1dc..3b26ba55f 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::PreviewFeatures; 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!(PreviewFeatures); impl_combine_or!(PythonDownloads); impl_combine_or!(PythonPreference); impl_combine_or!(PythonVersion); diff --git a/crates/uv-settings/src/lib.rs b/crates/uv-settings/src/lib.rs index ecc687229..52a2cbe63 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, @@ -390,6 +391,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 9bf98ada9..428fa5cce 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::PreviewFeatures; use uv_pypi_types::{SupportedEnvironments, VerbatimParsedUrl}; use uv_python::{PythonDownloads, PythonPreference, PythonVersion}; use uv_redacted::DisplaySafeUrl; @@ -248,7 +249,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", @@ -257,6 +258,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( @@ -2090,6 +2100,7 @@ pub struct OptionsWire { no_cache: Option, cache_dir: Option, preview: Option, + preview_features: Option>, python_preference: Option, python_downloads: Option, concurrent_downloads: Option, @@ -2185,6 +2196,7 @@ impl From for Options { no_cache, cache_dir, preview, + preview_features, python_preference, python_downloads, python_install_mirror, @@ -2257,6 +2269,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 d69da18fd..4e61a6574 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -139,13 +139,7 @@ impl GlobalSettings { .unwrap_or_else(Concurrency::threads), }, show_settings: args.show_settings, - preview: Preview::from_args( - flag(args.preview, args.no_preview, "preview") - .combine(workspace.and_then(|workspace| workspace.globals.preview)) - .unwrap_or(false), - args.no_preview, - &args.preview_features, - ), + preview: resolve_preview_settings(args, workspace), python_preference, python_downloads: flag( args.allow_python_downloads, @@ -179,6 +173,28 @@ fn resolve_python_preference( } } +fn resolve_preview_settings(args: &GlobalArgs, workspace: Option<&FilesystemOptions>) -> Preview { + // Commandline arguments take priority. + if let Some(preview) = flag(args.preview, args.no_preview, "preview") { + return Preview::from(preview); + } + if !args.preview_features.is_empty() { + return Preview::from_iter(&args.preview_features); + } + + 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. #[derive(Debug, Clone)] pub(crate) struct NetworkSettings { diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index f951d754d..60bedafe3 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -4686,7 +4686,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`, `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`, `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`, `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`, `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` " ); @@ -8503,6 +8503,1011 @@ 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, context.temp_dir.path()) + }; + + 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, + 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 ----- + "# + ); + + // `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, + 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 `--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` + 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 ----- + "# + ); + + 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, + 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( + 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: 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 ----- + "# + ); + + 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 9a19d39fc..859f4993f 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -385,9 +385,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/PreviewFeatures" + } + }, "publish-url": { "description": "The URL for publishing packages to the Python package index (by default:\n).", "anyOf": [ @@ -1521,6 +1528,29 @@ } ] }, + "PreviewFeatures": { + "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" + ] + }, "PythonDownloads": { "oneOf": [ {