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    /// Rejects the deprecated `--project` option in `uv init`.
257    InitProjectFlag,
258    /// Allows using `uv workspace metadata`.
259    WorkspaceMetadata,
260    /// Allows using `uv workspace dir`.
261    WorkspaceDir,
262    /// Allows using `uv workspace list`.
263    WorkspaceList,
264    /// Allows using `uv export --format=cyclonedx1.5`.
265    SbomExport,
266    /// Allows using `uv auth helper` as a credential helper for external tools.
267    AuthHelper,
268    /// Allows publishing directly to a package index.
269    DirectPublish,
270    /// Uses the directory containing a local `uv run` target, rather than the current working
271    /// directory, as the starting point for project and workspace discovery. This feature takes
272    /// effect before configuration is loaded.
273    TargetWorkspaceDiscovery,
274    /// Includes JSON metadata files in built wheels.
275    MetadataJson,
276    /// Allows signing requests to configured Google Cloud Storage endpoints.
277    GcsEndpoint,
278    /// On Unix, raises the process's soft open-file limit at startup, up to the hard limit.
279    AdjustUlimit,
280    /// Stops treating Conda environments named `base` or `root` as special.
281    SpecialCondaEnvNames,
282    /// Creates relocatable virtual environments by default.
283    RelocatableEnvsDefault,
284    /// Requires normalized distribution filenames when publishing, skipping files whose names are
285    /// not normalized.
286    PublishRequireNormalized,
287    /// Allows using `uv audit`.
288    #[preview(alias = "audit")]
289    AuditCommand,
290    /// Rejects an invalid `--project` path instead of warning and continuing. Except for `uv init`,
291    /// the path must already exist as a directory or point to a `pyproject.toml` file. This feature
292    /// takes effect before configuration is loaded.
293    ProjectDirectoryMustExist,
294    /// Allows setting `exclude-newer` on configured package indexes.
295    IndexExcludeNewer,
296    /// Allows signing requests to Azure Blob Storage endpoints with Azure credentials.
297    AzureEndpoint,
298    /// Rewrites `pyproject.toml` as TOML 1.0 when building source distributions, preserving the
299    /// original as `pyproject.toml.orig` to ensure compatibility with older build tools.
300    TomlBackwardsCompatibility,
301    /// Allows `uv sync` and other commands to check for malware using [OSV](https://osv.dev) before
302    /// installing packages.
303    MalwareCheck,
304    /// Prevents `uv venv --clear` from clearing a directory that does not contain a `pyvenv.cfg` file
305    /// unless `--force` is provided.
306    VenvSafeClear,
307    /// Allows using `uv check`.
308    #[preview(alias = "check")]
309    CheckCommand,
310    /// Makes `uv init` create a packaged application with a `src/` layout, build system, and script
311    /// entry point by default.
312    PackagedInit,
313    /// Stores [project virtual environments](./projects/layout.md#centralized-project-environments)
314    /// in the uv cache.
315    CentralizedProjectEnvs,
316    /// Stores a `uv.lock` alongside each installed tool and reuses it for reproducible installations
317    /// and upgrades.
318    ToolInstallLocks,
319    /// Allows using `uv workspace list --scripts`.
320    WorkspaceListScripts,
321    /// Stops installing the `_virtualenv.py` / `_virtualenv.pth` distutils configuration monkeypatch
322    /// in virtual environments for Python 3.10 and later.
323    NoDistutilsPatch,
324    /// Allows requiring a hash algorithm for configured package indexes.
325    IndexHashAlgorithm,
326    /// Rejects non-canonical lockfile formatting when using `--locked` or `--check`.
327    LockfileFormatCheck,
328    /// Omit `package.metadata` from `uv.lock`.
329    LockWithoutMetadata,
330}
331
332impl Display for PreviewFeature {
333    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
334        write!(f, "{}", self.as_str())
335    }
336}
337
338#[derive(Debug, Error, Clone)]
339#[error("Unknown feature flag")]
340pub struct PreviewFeatureParseError;
341
342impl FromStr for PreviewFeature {
343    type Err = PreviewFeatureParseError;
344
345    fn from_str(s: &str) -> Result<Self, Self::Err> {
346        Self::metadata()
347            .iter()
348            .find(|(feature, _, aliases)| feature.as_str() == s || aliases.contains(&s))
349            .map(|(feature, _, _)| *feature)
350            .ok_or(PreviewFeatureParseError)
351    }
352}
353
354#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
355#[error("preview feature name cannot be empty")]
356pub struct EmptyPreviewFeatureNameError;
357
358/// A user-provided preview feature name, which may refer to an unknown feature.
359#[derive(Debug, Clone)]
360pub enum MaybePreviewFeature {
361    Known(PreviewFeature),
362    Unknown(String),
363}
364
365impl FromStr for MaybePreviewFeature {
366    type Err = EmptyPreviewFeatureNameError;
367
368    fn from_str(s: &str) -> Result<Self, Self::Err> {
369        let s = s.trim();
370        if s.is_empty() {
371            return Err(EmptyPreviewFeatureNameError);
372        }
373
374        Ok(match PreviewFeature::from_str(s) {
375            Ok(feature) => Self::Known(feature),
376            Err(_) => Self::Unknown(s.to_string()),
377        })
378    }
379}
380
381impl<'de> serde::Deserialize<'de> for MaybePreviewFeature {
382    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
383    where
384        D: serde::Deserializer<'de>,
385    {
386        let name: Cow<'de, str> = serde::Deserialize::deserialize(deserializer)?;
387        Self::from_str(&name).map_err(serde::de::Error::custom)
388    }
389}
390
391#[cfg(feature = "schemars")]
392impl schemars::JsonSchema for MaybePreviewFeature {
393    fn schema_name() -> Cow<'static, str> {
394        Cow::Borrowed("PreviewFeature")
395    }
396
397    fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
398        // Advertise canonical names for editor completions, while accepting any nonempty name to
399        // match the forwards-compatible runtime parsing behavior.
400        let choices: Vec<&str> = BitFlags::<PreviewFeature>::all()
401            .iter()
402            .map(PreviewFeature::as_str)
403            .collect();
404        schemars::json_schema!({
405            "type": "string",
406            "anyOf": [
407                {
408                    "enum": choices,
409                },
410                {
411                    "pattern": "\\S",
412                },
413            ],
414        })
415    }
416}
417
418#[derive(Clone, Copy, PartialEq, Eq, Default)]
419pub struct Preview {
420    flags: BitFlags<PreviewFeature>,
421}
422
423impl Debug for Preview {
424    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
425        let flags: Vec<_> = self.flags.iter().collect();
426        f.debug_struct("Preview").field("flags", &flags).finish()
427    }
428}
429
430impl Preview {
431    #[cfg(any(test, feature = "testing"))]
432    fn new(flags: &[PreviewFeature]) -> Self {
433        Self {
434            flags: flags.iter().copied().fold(BitFlags::empty(), BitOr::bitor),
435        }
436    }
437
438    pub fn all() -> Self {
439        Self {
440            flags: BitFlags::all(),
441        }
442    }
443
444    /// Check if a single feature is enabled.
445    pub fn is_enabled(&self, flag: PreviewFeature) -> bool {
446        self.flags.contains(flag)
447    }
448
449    /// Check if all preview feature rae enabled.
450    pub fn all_enabled(&self) -> bool {
451        self.flags.is_all()
452    }
453
454    /// Check if any preview feature is enabled.
455    pub fn any_enabled(&self) -> bool {
456        !self.flags.is_empty()
457    }
458
459    /// Resolve preview feature names, warning and ignoring unknown names.
460    pub fn from_feature_names<'a>(
461        feature_names: impl IntoIterator<Item = &'a MaybePreviewFeature>,
462    ) -> Self {
463        let mut flags = BitFlags::empty();
464
465        for feature_name in feature_names {
466            match feature_name {
467                MaybePreviewFeature::Known(feature) => flags |= *feature,
468                MaybePreviewFeature::Unknown(feature_name) => {
469                    warn_user_once!("Unknown preview feature: `{feature_name}`");
470                }
471            }
472        }
473
474        Self { flags }
475    }
476}
477
478impl Display for Preview {
479    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
480        if self.flags.is_empty() {
481            write!(f, "disabled")
482        } else if self.flags.is_all() {
483            write!(f, "enabled")
484        } else {
485            write!(
486                f,
487                "{}",
488                itertools::join(self.flags.iter().map(PreviewFeature::as_str), ",")
489            )
490        }
491    }
492}
493
494impl FromStr for Preview {
495    type Err = EmptyPreviewFeatureNameError;
496
497    fn from_str(s: &str) -> Result<Self, Self::Err> {
498        let feature_names = s
499            .split(',')
500            .map(MaybePreviewFeature::from_str)
501            .collect::<Result<Vec<_>, _>>()?;
502
503        Ok(Self::from_feature_names(&feature_names))
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    #[test]
512    fn test_preview_feature_from_str() {
513        for &(feature, _, aliases) in PreviewFeature::metadata() {
514            assert_eq!(PreviewFeature::from_str(feature.as_str()).unwrap(), feature);
515
516            for &alias in aliases {
517                assert_eq!(PreviewFeature::from_str(alias).unwrap(), feature);
518            }
519        }
520    }
521
522    #[test]
523    fn test_preview_from_str() {
524        // Test single feature
525        let preview = Preview::from_str("python-install-default").unwrap();
526        assert_eq!(preview.flags, PreviewFeature::PythonInstallDefault);
527
528        // Test multiple features
529        let preview = Preview::from_str("json-output,pylock").unwrap();
530        assert!(preview.is_enabled(PreviewFeature::JsonOutput));
531        assert!(preview.is_enabled(PreviewFeature::Pylock));
532        assert_eq!(preview.flags.bits().count_ones(), 2);
533
534        let preview = Preview::from_str("tool-install-locks").unwrap();
535        assert!(preview.is_enabled(PreviewFeature::ToolInstallLocks));
536
537        // Test with whitespace
538        let preview = Preview::from_str("pylock , add-bounds").unwrap();
539        assert!(preview.is_enabled(PreviewFeature::Pylock));
540        assert!(preview.is_enabled(PreviewFeature::AddBounds));
541
542        // Test empty string error
543        assert_eq!(Preview::from_str(""), Err(EmptyPreviewFeatureNameError));
544        assert!(Preview::from_str("pylock,").is_err());
545        assert!(Preview::from_str(",pylock").is_err());
546
547        // Test unknown feature (should be ignored with warning)
548        let preview = Preview::from_str("unknown-feature,pylock").unwrap();
549        assert!(preview.is_enabled(PreviewFeature::Pylock));
550        assert_eq!(preview.flags.bits().count_ones(), 1);
551    }
552
553    #[test]
554    fn test_preview_display() {
555        // Test disabled
556        let preview = Preview::default();
557        assert_eq!(preview.to_string(), "disabled");
558        let preview = Preview::new(&[]);
559        assert_eq!(preview.to_string(), "disabled");
560
561        // Test enabled (all features)
562        let preview = Preview::all();
563        assert_eq!(preview.to_string(), "enabled");
564
565        // Test single feature
566        let preview = Preview::new(&[PreviewFeature::PythonInstallDefault]);
567        assert_eq!(preview.to_string(), "python-install-default");
568
569        // Test multiple features
570        let preview = Preview::new(&[PreviewFeature::JsonOutput, PreviewFeature::Pylock]);
571        assert_eq!(preview.to_string(), "json-output,pylock");
572    }
573
574    #[test]
575    fn test_global_preview() {
576        {
577            let _guard =
578                test::with_features(&[PreviewFeature::Pylock, PreviewFeature::WorkspaceMetadata]);
579            assert!(!is_enabled(PreviewFeature::InitProjectFlag));
580            assert!(is_enabled(PreviewFeature::Pylock));
581            assert!(is_enabled(PreviewFeature::WorkspaceMetadata));
582            assert!(!is_enabled(PreviewFeature::AuthHelper));
583        }
584        {
585            let _guard =
586                test::with_features(&[PreviewFeature::InitProjectFlag, PreviewFeature::AuthHelper]);
587            assert!(is_enabled(PreviewFeature::InitProjectFlag));
588            assert!(!is_enabled(PreviewFeature::Pylock));
589            assert!(!is_enabled(PreviewFeature::WorkspaceMetadata));
590            assert!(is_enabled(PreviewFeature::AuthHelper));
591        }
592    }
593
594    #[test]
595    #[should_panic(
596        expected = "Additional calls to `uv_preview::test::with_features` are not allowed while holding a `FeaturesGuard`"
597    )]
598    fn test_global_preview_panic_nested() {
599        let _guard =
600            test::with_features(&[PreviewFeature::Pylock, PreviewFeature::WorkspaceMetadata]);
601        let _guard2 =
602            test::with_features(&[PreviewFeature::InitProjectFlag, PreviewFeature::AuthHelper]);
603    }
604
605    #[test]
606    #[should_panic(expected = "uv_preview::test::with_features")]
607    fn test_global_preview_panic_uninitialized() {
608        let _preview = get();
609    }
610}