Skip to main content

uv_preview/
lib.rs

1use std::borrow::Cow;
2#[cfg(any(test, feature = "testing"))]
3use std::ops::BitOr;
4use std::sync::{Mutex, OnceLock};
5use std::{
6    fmt::{Debug, Display, Formatter},
7    str::FromStr,
8};
9
10use enumflags2::{BitFlags, bitflags};
11use thiserror::Error;
12use uv_macros::PreviewMetadata;
13use uv_warnings::warn_user_once;
14
15/// Indicates if the preview state has been finalized yet or not.
16enum PreviewState {
17    Provisional(Preview),
18    Final(Preview),
19}
20
21/// Indicates how the preview was initialised, to distinguish between normal
22/// code and unit tests.
23enum PreviewMode {
24    /// Initialised by a call to [`init`].
25    Normal(Mutex<PreviewState>),
26    /// Initialised by a call to [`test::with_features`].
27    #[cfg(feature = "testing")]
28    Test(std::sync::RwLock<Option<Preview>>),
29}
30
31static PREVIEW: OnceLock<PreviewMode> = OnceLock::new();
32
33/// Error type for global preview state initialization related errors
34#[derive(Debug, Error)]
35pub enum PreviewError {
36    /// Returned when [`set`] or [`finalize`] are called on a finalized state.
37    #[error("The preview configuration has already been finalized")]
38    AlreadyFinalized,
39
40    /// Returned when [`finalize`] is called on an uninitialized state.
41    #[error("The preview configuration has not been initialized yet")]
42    NotInitialized,
43
44    /// Returned when [`set`] or [`finalize`] are called on a test state.
45    #[cfg(feature = "testing")]
46    #[error("The preview configuration is in test mode and {}::{} cannot be used", module_path!(), .0)]
47    InTest(&'static str),
48}
49
50/// Initialize the global preview configuration.
51///
52/// This should be called once at startup with the resolved preview settings.
53pub fn set(preview: Preview) -> Result<(), PreviewError> {
54    let mode = PREVIEW.get_or_init(|| {
55        PreviewMode::Normal(Mutex::new(PreviewState::Provisional(Preview::default())))
56    });
57    match mode {
58        PreviewMode::Normal(mutex) => {
59            // Calling `set` in a test context is already disallowed, so a panic if
60            // the mutex is poisoned is fine.
61            let mut state = mutex.lock().unwrap();
62            match &*state {
63                PreviewState::Provisional(_) => {
64                    *state = PreviewState::Provisional(preview);
65                    Ok(())
66                }
67                PreviewState::Final(_) => Err(PreviewError::AlreadyFinalized),
68            }
69        }
70        #[cfg(feature = "testing")]
71        PreviewMode::Test(_) => Err(PreviewError::InTest("set")),
72    }
73}
74
75pub fn finalize() -> Result<(), PreviewError> {
76    match PREVIEW.get().ok_or(PreviewError::NotInitialized)? {
77        PreviewMode::Normal(mutex) => {
78            // Calling `set` in a test context is already disallowed, so a panic if
79            // the mutex is poisoned is fine.
80            let mut state = mutex.lock().unwrap();
81            match &*state {
82                PreviewState::Provisional(preview) => {
83                    *state = PreviewState::Final(*preview);
84                    Ok(())
85                }
86                PreviewState::Final(_) => Err(PreviewError::AlreadyFinalized),
87            }
88        }
89        #[cfg(feature = "testing")]
90        PreviewMode::Test(_) => Err(PreviewError::InTest("finalize")),
91    }
92}
93
94/// Get the current global preview configuration.
95///
96/// # Panics
97///
98/// When called before [`init`] or (with the `testing` feature) when the
99/// current thread does not hold a [`test::with_features`] guard.
100fn get() -> Preview {
101    match PREVIEW.get() {
102        Some(PreviewMode::Normal(mutex)) => match *mutex.lock().unwrap() {
103            PreviewState::Provisional(preview) => preview,
104            PreviewState::Final(preview) => preview,
105        },
106        #[cfg(feature = "testing")]
107        Some(PreviewMode::Test(rwlock)) => {
108            assert!(
109                test::HELD.get(),
110                "The preview configuration is in test mode but the current thread does not hold a `FeaturesGuard`\nHint: Use `{}::test::with_features` to get a `FeaturesGuard` and hold it when testing functions which rely on the global preview state",
111                module_path!()
112            );
113            // The unwrap may panic only if the current thread had panicked
114            // while attempting to write the value and then recovered with
115            // `catch_unwind`. This seems unlikely.
116            rwlock
117                .read()
118                .unwrap()
119                .expect("FeaturesGuard is held but preview value is not set")
120        }
121        #[cfg(feature = "testing")]
122        None => panic!(
123            "The preview configuration has not been initialized\nHint: Use `{}::init` or `{}::test::with_features` to initialize it",
124            module_path!(),
125            module_path!()
126        ),
127        #[cfg(not(feature = "testing"))]
128        None => panic!("The preview configuration has not been initialized"),
129    }
130}
131
132/// Check if a specific preview feature is enabled globally.
133pub fn is_enabled(flag: PreviewFeature) -> bool {
134    get().is_enabled(flag)
135}
136
137/// Functions for unit tests, do not use from normal code!
138#[cfg(feature = "testing")]
139pub mod test {
140    use super::{PREVIEW, Preview, PreviewMode};
141    use std::cell::Cell;
142    use std::sync::{Mutex, MutexGuard, RwLock};
143
144    /// The global preview state test mutex. It does not guard any data but is
145    /// simply used to ensure tests which rely on the global preview state are
146    /// ran serially.
147    static MUTEX: Mutex<()> = Mutex::new(());
148
149    thread_local! {
150        /// Whether the current thread holds the global mutex.
151        ///
152        /// This is used to catch situations where a test forgets to set the
153        /// global test state but happens to work anyway because of another test
154        /// setting the state.
155        pub(crate) static HELD: Cell<bool> = const { Cell::new(false) };
156    }
157
158    /// A scope guard which ensures that the global preview state is configured
159    /// and consistent for the duration of its lifetime.
160    #[derive(Debug)]
161    #[expect(unused)]
162    pub struct FeaturesGuard(MutexGuard<'static, ()>);
163
164    /// Temporarily set the state of preview features for the duration of the
165    /// lifetime of the returned guard.
166    ///
167    /// Calls cannot be nested, and this function must be used to set the global
168    /// preview features when testing functionality which uses it, otherwise
169    /// that functionality will panic.
170    ///
171    /// The preview state will only be valid for the thread which calls this
172    /// function, it will not be valid for any other thread. This is a
173    /// consequence of how `HELD` is used to check for tests which are missing
174    /// the guard.
175    pub fn with_features(features: &[super::PreviewFeature]) -> FeaturesGuard {
176        assert!(
177            !HELD.get(),
178            "Additional calls to `{}::with_features` are not allowed while holding a `FeaturesGuard`",
179            module_path!()
180        );
181
182        let guard = match MUTEX.lock() {
183            Ok(guard) => guard,
184            // This is okay because the mutex isn't guarding any data, so when
185            // it gets poisoned, it just means a test thread died while holding
186            // it, so it's safe to just re-grab it from the PoisonError, there's
187            // no chance of any corruption.
188            Err(err) => err.into_inner(),
189        };
190
191        HELD.set(true);
192
193        let state = PREVIEW.get_or_init(|| PreviewMode::Test(RwLock::new(None)));
194        match state {
195            PreviewMode::Test(rwlock) => {
196                *rwlock.write().unwrap() = Some(Preview::new(features));
197            }
198            PreviewMode::Normal(_) => {
199                panic!(
200                    "Cannot use `{}::with_features` after `uv_preview::init` has been called",
201                    module_path!()
202                );
203            }
204        }
205        FeaturesGuard(guard)
206    }
207
208    impl Drop for FeaturesGuard {
209        fn drop(&mut self) {
210            HELD.set(false);
211
212            match PREVIEW.get().unwrap() {
213                PreviewMode::Test(rwlock) => {
214                    *rwlock.write().unwrap() = None;
215                }
216                PreviewMode::Normal(_) => {
217                    unreachable!("FeaturesGuard should not exist when in Normal mode");
218                }
219            }
220        }
221    }
222}
223
224#[bitflags]
225#[expect(
226    clippy::use_self,
227    reason = "enumflags2 refers to the enum by name when inferring bits"
228)]
229#[repr(u64)]
230#[derive(Debug, Clone, Copy, PartialEq, Eq, PreviewMetadata)]
231pub enum PreviewFeature {
232    /// Allows [installing `python` and `python3` executables](./python-versions.md#installing-python-executables).
233    PythonInstallDefault,
234    /// Allows `--output-format json` for various uv commands.
235    JsonOutput,
236    /// Allows installing from `pylock.toml` files.
237    Pylock,
238    /// Allows configuring the [default bounds for `uv add`](../reference/settings.md#add-bounds) invocations.
239    AddBounds,
240    /// Allows defining workspace conflicts at the package level.
241    PackageConflicts,
242    /// Allows specifying additional dependencies for package builds.
243    ExtraBuildDependencies,
244    /// Warns when multiple packages would install conflicting Python modules into the same
245    /// environment.
246    DetectModuleConflicts,
247    /// Allows using `uv format`.
248    #[preview(alias = "format")]
249    FormatCommand,
250    /// Enables storage of credentials in a [system-native location](../concepts/authentication/http.md#the-uv-credentials-store).
251    NativeAuth,
252    /// Allows signing requests to configured S3-compatible endpoints.
253    S3Endpoint,
254    /// Allows using `uv cache size`.
255    CacheSize,
256    /// Reports the physical disk space reclaimed by cache cleanup, accounting for hardlinks and copy-on-write clones.
257    CachePhysicalSpace,
258    /// Rejects the deprecated `--project` option in `uv init`.
259    InitProjectFlag,
260    /// Allows using `uv workspace metadata`.
261    WorkspaceMetadata,
262    /// Allows using `uv workspace dir`.
263    WorkspaceDir,
264    /// Allows using `uv workspace list`.
265    WorkspaceList,
266    /// Allows using `uv export --format=cyclonedx1.5`.
267    SbomExport,
268    /// Allows using `uv auth helper` as a credential helper for external tools.
269    AuthHelper,
270    /// Allows publishing directly to a package index.
271    DirectPublish,
272    /// Uses the directory containing a local `uv run` target, rather than the current working
273    /// directory, as the starting point for project and workspace discovery. This feature takes
274    /// effect before configuration is loaded.
275    TargetWorkspaceDiscovery,
276    /// Includes JSON metadata files in built wheels.
277    MetadataJson,
278    /// Allows signing requests to configured Google Cloud Storage endpoints.
279    GcsEndpoint,
280    /// On Unix, raises the process's soft open-file limit at startup, up to the hard limit.
281    AdjustUlimit,
282    /// Stops treating Conda environments named `base` or `root` as special.
283    SpecialCondaEnvNames,
284    /// Creates relocatable virtual environments by default.
285    RelocatableEnvsDefault,
286    /// Requires normalized distribution filenames when publishing, skipping files whose names are
287    /// not normalized.
288    PublishRequireNormalized,
289    /// Allows using `uv audit` and `uv tool audit`.
290    #[preview(alias = "audit")]
291    AuditCommand,
292    /// Rejects an invalid `--project` path instead of warning and continuing. Except for `uv init`,
293    /// the path must already exist as a directory or point to a `pyproject.toml` file. This feature
294    /// takes effect before configuration is loaded.
295    ProjectDirectoryMustExist,
296    /// Allows setting `exclude-newer` on configured package indexes.
297    IndexExcludeNewer,
298    /// Allows signing requests to Azure Blob Storage endpoints with Azure credentials.
299    AzureEndpoint,
300    /// Rewrites `pyproject.toml` as TOML 1.0 when building source distributions, preserving the
301    /// original as `pyproject.toml.orig` to ensure compatibility with older build tools.
302    TomlBackwardsCompatibility,
303    /// Allows `uv sync` and other commands to check for malware using [OSV](https://osv.dev) before
304    /// installing packages.
305    MalwareCheck,
306    /// Prevents `uv venv --clear` from clearing a directory that does not contain a `pyvenv.cfg` file
307    /// unless `--force` is provided.
308    VenvSafeClear,
309    /// Allows using `uv check`.
310    #[preview(alias = "check")]
311    CheckCommand,
312    /// Makes `uv init` create a packaged application with a `src/` layout, build system, and script
313    /// entry point by default.
314    PackagedInit,
315    /// Stores [project virtual environments](./projects/layout.md#centralized-project-environments)
316    /// in the uv cache.
317    CentralizedProjectEnvs,
318    /// Stores a `uv.lock` alongside each installed tool and reuses it for reproducible installations,
319    /// upgrades, and audits.
320    ToolInstallLocks,
321    /// Allows using `uv workspace list --scripts`.
322    WorkspaceListScripts,
323    /// Stops installing the `_virtualenv.py` / `_virtualenv.pth` distutils configuration monkeypatch
324    /// in virtual environments for Python 3.10 and later.
325    NoDistutilsPatch,
326    /// Allows requiring a hash algorithm for configured package indexes.
327    IndexHashAlgorithm,
328    /// Rejects non-canonical lockfile formatting when using `--locked` or `--check`.
329    LockfileFormatCheck,
330    /// Omit `package.metadata` from `uv.lock`.
331    LockWithoutMetadata,
332}
333
334impl Display for PreviewFeature {
335    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
336        write!(f, "{}", self.as_str())
337    }
338}
339
340#[derive(Debug, Error, Clone)]
341#[error("Unknown feature flag")]
342pub struct PreviewFeatureParseError;
343
344impl FromStr for PreviewFeature {
345    type Err = PreviewFeatureParseError;
346
347    fn from_str(s: &str) -> Result<Self, Self::Err> {
348        Self::metadata()
349            .iter()
350            .find(|(feature, _, aliases)| feature.as_str() == s || aliases.contains(&s))
351            .map(|(feature, _, _)| *feature)
352            .ok_or(PreviewFeatureParseError)
353    }
354}
355
356#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
357#[error("preview feature name cannot be empty")]
358pub struct EmptyPreviewFeatureNameError;
359
360/// A user-provided preview feature name, which may refer to an unknown feature.
361#[derive(Debug, Clone)]
362pub enum MaybePreviewFeature {
363    Known(PreviewFeature),
364    Unknown(String),
365}
366
367impl FromStr for MaybePreviewFeature {
368    type Err = EmptyPreviewFeatureNameError;
369
370    fn from_str(s: &str) -> Result<Self, Self::Err> {
371        let s = s.trim();
372        if s.is_empty() {
373            return Err(EmptyPreviewFeatureNameError);
374        }
375
376        Ok(match PreviewFeature::from_str(s) {
377            Ok(feature) => Self::Known(feature),
378            Err(_) => Self::Unknown(s.to_string()),
379        })
380    }
381}
382
383impl<'de> serde::Deserialize<'de> for MaybePreviewFeature {
384    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
385    where
386        D: serde::Deserializer<'de>,
387    {
388        let name: Cow<'de, str> = serde::Deserialize::deserialize(deserializer)?;
389        Self::from_str(&name).map_err(serde::de::Error::custom)
390    }
391}
392
393#[cfg(feature = "schemars")]
394impl schemars::JsonSchema for MaybePreviewFeature {
395    fn schema_name() -> Cow<'static, str> {
396        Cow::Borrowed("PreviewFeature")
397    }
398
399    fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
400        // Advertise canonical names for editor completions, while accepting any nonempty name to
401        // match the forwards-compatible runtime parsing behavior.
402        let choices: Vec<&str> = BitFlags::<PreviewFeature>::all()
403            .iter()
404            .map(PreviewFeature::as_str)
405            .collect();
406        schemars::json_schema!({
407            "type": "string",
408            "anyOf": [
409                {
410                    "enum": choices,
411                },
412                {
413                    "pattern": "\\S",
414                },
415            ],
416        })
417    }
418}
419
420#[derive(Clone, Copy, PartialEq, Eq, Default)]
421pub struct Preview {
422    flags: BitFlags<PreviewFeature>,
423}
424
425impl Debug for Preview {
426    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
427        let flags: Vec<_> = self.flags.iter().collect();
428        f.debug_struct("Preview").field("flags", &flags).finish()
429    }
430}
431
432impl Preview {
433    #[cfg(any(test, feature = "testing"))]
434    fn new(flags: &[PreviewFeature]) -> Self {
435        Self {
436            flags: flags.iter().copied().fold(BitFlags::empty(), BitOr::bitor),
437        }
438    }
439
440    pub fn all() -> Self {
441        Self {
442            flags: BitFlags::all(),
443        }
444    }
445
446    /// Check if a single feature is enabled.
447    pub fn is_enabled(&self, flag: PreviewFeature) -> bool {
448        self.flags.contains(flag)
449    }
450
451    /// Check if all preview feature rae enabled.
452    pub fn all_enabled(&self) -> bool {
453        self.flags.is_all()
454    }
455
456    /// Check if any preview feature is enabled.
457    pub fn any_enabled(&self) -> bool {
458        !self.flags.is_empty()
459    }
460
461    /// Resolve preview feature names, warning and ignoring unknown names.
462    pub fn from_feature_names<'a>(
463        feature_names: impl IntoIterator<Item = &'a MaybePreviewFeature>,
464    ) -> Self {
465        let mut flags = BitFlags::empty();
466
467        for feature_name in feature_names {
468            match feature_name {
469                MaybePreviewFeature::Known(feature) => flags |= *feature,
470                MaybePreviewFeature::Unknown(feature_name) => {
471                    warn_user_once!("Unknown preview feature: `{feature_name}`");
472                }
473            }
474        }
475
476        Self { flags }
477    }
478}
479
480impl Display for Preview {
481    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
482        if self.flags.is_empty() {
483            write!(f, "disabled")
484        } else if self.flags.is_all() {
485            write!(f, "enabled")
486        } else {
487            write!(
488                f,
489                "{}",
490                itertools::join(self.flags.iter().map(PreviewFeature::as_str), ",")
491            )
492        }
493    }
494}
495
496impl FromStr for Preview {
497    type Err = EmptyPreviewFeatureNameError;
498
499    fn from_str(s: &str) -> Result<Self, Self::Err> {
500        let feature_names = s
501            .split(',')
502            .map(MaybePreviewFeature::from_str)
503            .collect::<Result<Vec<_>, _>>()?;
504
505        Ok(Self::from_feature_names(&feature_names))
506    }
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    #[test]
514    fn test_preview_feature_from_str() {
515        for &(feature, _, aliases) in PreviewFeature::metadata() {
516            assert_eq!(PreviewFeature::from_str(feature.as_str()).unwrap(), feature);
517
518            for &alias in aliases {
519                assert_eq!(PreviewFeature::from_str(alias).unwrap(), feature);
520            }
521        }
522    }
523
524    #[test]
525    fn test_preview_from_str() {
526        // Test single feature
527        let preview = Preview::from_str("python-install-default").unwrap();
528        assert_eq!(preview.flags, PreviewFeature::PythonInstallDefault);
529
530        // Test multiple features
531        let preview = Preview::from_str("json-output,pylock").unwrap();
532        assert!(preview.is_enabled(PreviewFeature::JsonOutput));
533        assert!(preview.is_enabled(PreviewFeature::Pylock));
534        assert_eq!(preview.flags.bits().count_ones(), 2);
535
536        let preview = Preview::from_str("tool-install-locks").unwrap();
537        assert!(preview.is_enabled(PreviewFeature::ToolInstallLocks));
538
539        // Test with whitespace
540        let preview = Preview::from_str("pylock , add-bounds").unwrap();
541        assert!(preview.is_enabled(PreviewFeature::Pylock));
542        assert!(preview.is_enabled(PreviewFeature::AddBounds));
543
544        // Test empty string error
545        assert_eq!(Preview::from_str(""), Err(EmptyPreviewFeatureNameError));
546        assert!(Preview::from_str("pylock,").is_err());
547        assert!(Preview::from_str(",pylock").is_err());
548
549        // Test unknown feature (should be ignored with warning)
550        let preview = Preview::from_str("unknown-feature,pylock").unwrap();
551        assert!(preview.is_enabled(PreviewFeature::Pylock));
552        assert_eq!(preview.flags.bits().count_ones(), 1);
553    }
554
555    #[test]
556    fn test_preview_display() {
557        // Test disabled
558        let preview = Preview::default();
559        assert_eq!(preview.to_string(), "disabled");
560        let preview = Preview::new(&[]);
561        assert_eq!(preview.to_string(), "disabled");
562
563        // Test enabled (all features)
564        let preview = Preview::all();
565        assert_eq!(preview.to_string(), "enabled");
566
567        // Test single feature
568        let preview = Preview::new(&[PreviewFeature::PythonInstallDefault]);
569        assert_eq!(preview.to_string(), "python-install-default");
570
571        // Test multiple features
572        let preview = Preview::new(&[PreviewFeature::JsonOutput, PreviewFeature::Pylock]);
573        assert_eq!(preview.to_string(), "json-output,pylock");
574    }
575
576    #[test]
577    fn test_global_preview() {
578        {
579            let _guard =
580                test::with_features(&[PreviewFeature::Pylock, PreviewFeature::WorkspaceMetadata]);
581            assert!(!is_enabled(PreviewFeature::InitProjectFlag));
582            assert!(is_enabled(PreviewFeature::Pylock));
583            assert!(is_enabled(PreviewFeature::WorkspaceMetadata));
584            assert!(!is_enabled(PreviewFeature::AuthHelper));
585        }
586        {
587            let _guard =
588                test::with_features(&[PreviewFeature::InitProjectFlag, PreviewFeature::AuthHelper]);
589            assert!(is_enabled(PreviewFeature::InitProjectFlag));
590            assert!(!is_enabled(PreviewFeature::Pylock));
591            assert!(!is_enabled(PreviewFeature::WorkspaceMetadata));
592            assert!(is_enabled(PreviewFeature::AuthHelper));
593        }
594    }
595
596    #[test]
597    #[should_panic(
598        expected = "Additional calls to `uv_preview::test::with_features` are not allowed while holding a `FeaturesGuard`"
599    )]
600    fn test_global_preview_panic_nested() {
601        let _guard =
602            test::with_features(&[PreviewFeature::Pylock, PreviewFeature::WorkspaceMetadata]);
603        let _guard2 =
604            test::with_features(&[PreviewFeature::InitProjectFlag, PreviewFeature::AuthHelper]);
605    }
606
607    #[test]
608    #[should_panic(expected = "uv_preview::test::with_features")]
609    fn test_global_preview_panic_uninitialized() {
610        let _preview = get();
611    }
612}