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    /// Uses the new `tar-codec` encoding/decoding backend, instead of `astral-tokio-tar`.
333    TarCodec,
334}
335
336impl Display for PreviewFeature {
337    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
338        write!(f, "{}", self.as_str())
339    }
340}
341
342#[derive(Debug, Error, Clone)]
343#[error("Unknown feature flag")]
344pub struct PreviewFeatureParseError;
345
346impl FromStr for PreviewFeature {
347    type Err = PreviewFeatureParseError;
348
349    fn from_str(s: &str) -> Result<Self, Self::Err> {
350        Self::metadata()
351            .iter()
352            .find(|(feature, _, aliases)| feature.as_str() == s || aliases.contains(&s))
353            .map(|(feature, _, _)| *feature)
354            .ok_or(PreviewFeatureParseError)
355    }
356}
357
358#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
359#[error("preview feature name cannot be empty")]
360pub struct EmptyPreviewFeatureNameError;
361
362/// A user-provided preview feature name, which may refer to an unknown feature.
363#[derive(Debug, Clone)]
364pub enum MaybePreviewFeature {
365    Known(PreviewFeature),
366    Unknown(String),
367}
368
369impl FromStr for MaybePreviewFeature {
370    type Err = EmptyPreviewFeatureNameError;
371
372    fn from_str(s: &str) -> Result<Self, Self::Err> {
373        let s = s.trim();
374        if s.is_empty() {
375            return Err(EmptyPreviewFeatureNameError);
376        }
377
378        Ok(match PreviewFeature::from_str(s) {
379            Ok(feature) => Self::Known(feature),
380            Err(_) => Self::Unknown(s.to_string()),
381        })
382    }
383}
384
385impl<'de> serde::Deserialize<'de> for MaybePreviewFeature {
386    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
387    where
388        D: serde::Deserializer<'de>,
389    {
390        let name: Cow<'de, str> = serde::Deserialize::deserialize(deserializer)?;
391        Self::from_str(&name).map_err(serde::de::Error::custom)
392    }
393}
394
395#[cfg(feature = "schemars")]
396impl schemars::JsonSchema for MaybePreviewFeature {
397    fn schema_name() -> Cow<'static, str> {
398        Cow::Borrowed("PreviewFeature")
399    }
400
401    fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
402        // Advertise canonical names for editor completions, while accepting any nonempty name to
403        // match the forwards-compatible runtime parsing behavior.
404        let choices: Vec<&str> = BitFlags::<PreviewFeature>::all()
405            .iter()
406            .map(PreviewFeature::as_str)
407            .collect();
408        schemars::json_schema!({
409            "type": "string",
410            "anyOf": [
411                {
412                    "enum": choices,
413                },
414                {
415                    "pattern": "\\S",
416                },
417            ],
418        })
419    }
420}
421
422#[derive(Clone, Copy, PartialEq, Eq, Default)]
423pub struct Preview {
424    flags: BitFlags<PreviewFeature>,
425}
426
427impl Debug for Preview {
428    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
429        let flags: Vec<_> = self.flags.iter().collect();
430        f.debug_struct("Preview").field("flags", &flags).finish()
431    }
432}
433
434impl Preview {
435    #[cfg(any(test, feature = "testing"))]
436    fn new(flags: &[PreviewFeature]) -> Self {
437        Self {
438            flags: flags.iter().copied().fold(BitFlags::empty(), BitOr::bitor),
439        }
440    }
441
442    pub fn all() -> Self {
443        Self {
444            flags: BitFlags::all(),
445        }
446    }
447
448    /// Check if a single feature is enabled.
449    pub fn is_enabled(&self, flag: PreviewFeature) -> bool {
450        self.flags.contains(flag)
451    }
452
453    /// Check if all preview feature rae enabled.
454    pub fn all_enabled(&self) -> bool {
455        self.flags.is_all()
456    }
457
458    /// Check if any preview feature is enabled.
459    pub fn any_enabled(&self) -> bool {
460        !self.flags.is_empty()
461    }
462
463    /// Resolve preview feature names, warning and ignoring unknown names.
464    pub fn from_feature_names<'a>(
465        feature_names: impl IntoIterator<Item = &'a MaybePreviewFeature>,
466    ) -> Self {
467        let mut flags = BitFlags::empty();
468
469        for feature_name in feature_names {
470            match feature_name {
471                MaybePreviewFeature::Known(feature) => flags |= *feature,
472                MaybePreviewFeature::Unknown(feature_name) => {
473                    warn_user_once!("Unknown preview feature: `{feature_name}`");
474                }
475            }
476        }
477
478        Self { flags }
479    }
480}
481
482impl Display for Preview {
483    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
484        if self.flags.is_empty() {
485            write!(f, "disabled")
486        } else if self.flags.is_all() {
487            write!(f, "enabled")
488        } else {
489            write!(
490                f,
491                "{}",
492                itertools::join(self.flags.iter().map(PreviewFeature::as_str), ",")
493            )
494        }
495    }
496}
497
498impl FromStr for Preview {
499    type Err = EmptyPreviewFeatureNameError;
500
501    fn from_str(s: &str) -> Result<Self, Self::Err> {
502        let feature_names = s
503            .split(',')
504            .map(MaybePreviewFeature::from_str)
505            .collect::<Result<Vec<_>, _>>()?;
506
507        Ok(Self::from_feature_names(&feature_names))
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    #[test]
516    fn test_preview_feature_from_str() {
517        for &(feature, _, aliases) in PreviewFeature::metadata() {
518            assert_eq!(PreviewFeature::from_str(feature.as_str()).unwrap(), feature);
519
520            for &alias in aliases {
521                assert_eq!(PreviewFeature::from_str(alias).unwrap(), feature);
522            }
523        }
524
525        let feature = PreviewFeature::from_str("tar-codec").unwrap();
526        assert_eq!(feature, PreviewFeature::TarCodec);
527        assert_eq!(feature.to_string(), "tar-codec");
528    }
529
530    #[test]
531    fn test_preview_from_str() {
532        // Test single feature
533        let preview = Preview::from_str("python-install-default").unwrap();
534        assert_eq!(preview.flags, PreviewFeature::PythonInstallDefault);
535
536        let preview = Preview::from_str("tar-codec").unwrap();
537        assert!(preview.is_enabled(PreviewFeature::TarCodec));
538
539        // Test multiple features
540        let preview = Preview::from_str("json-output,pylock").unwrap();
541        assert!(preview.is_enabled(PreviewFeature::JsonOutput));
542        assert!(preview.is_enabled(PreviewFeature::Pylock));
543        assert_eq!(preview.flags.bits().count_ones(), 2);
544
545        let preview = Preview::from_str("tool-install-locks").unwrap();
546        assert!(preview.is_enabled(PreviewFeature::ToolInstallLocks));
547
548        // Test with whitespace
549        let preview = Preview::from_str("pylock , add-bounds").unwrap();
550        assert!(preview.is_enabled(PreviewFeature::Pylock));
551        assert!(preview.is_enabled(PreviewFeature::AddBounds));
552
553        // Test empty string error
554        assert_eq!(Preview::from_str(""), Err(EmptyPreviewFeatureNameError));
555        assert!(Preview::from_str("pylock,").is_err());
556        assert!(Preview::from_str(",pylock").is_err());
557
558        // Test unknown feature (should be ignored with warning)
559        let preview = Preview::from_str("unknown-feature,pylock").unwrap();
560        assert!(preview.is_enabled(PreviewFeature::Pylock));
561        assert_eq!(preview.flags.bits().count_ones(), 1);
562    }
563
564    #[test]
565    fn test_preview_display() {
566        // Test disabled
567        let preview = Preview::default();
568        assert_eq!(preview.to_string(), "disabled");
569        let preview = Preview::new(&[]);
570        assert_eq!(preview.to_string(), "disabled");
571
572        // Test enabled (all features)
573        let preview = Preview::all();
574        assert_eq!(preview.to_string(), "enabled");
575        assert!(preview.is_enabled(PreviewFeature::TarCodec));
576
577        // Test single feature
578        let preview = Preview::new(&[PreviewFeature::PythonInstallDefault]);
579        assert_eq!(preview.to_string(), "python-install-default");
580
581        // Test multiple features
582        let preview = Preview::new(&[PreviewFeature::JsonOutput, PreviewFeature::Pylock]);
583        assert_eq!(preview.to_string(), "json-output,pylock");
584    }
585
586    #[test]
587    fn test_global_preview() {
588        {
589            let _guard =
590                test::with_features(&[PreviewFeature::Pylock, PreviewFeature::WorkspaceMetadata]);
591            assert!(!is_enabled(PreviewFeature::InitProjectFlag));
592            assert!(is_enabled(PreviewFeature::Pylock));
593            assert!(is_enabled(PreviewFeature::WorkspaceMetadata));
594            assert!(!is_enabled(PreviewFeature::AuthHelper));
595        }
596        {
597            let _guard =
598                test::with_features(&[PreviewFeature::InitProjectFlag, PreviewFeature::AuthHelper]);
599            assert!(is_enabled(PreviewFeature::InitProjectFlag));
600            assert!(!is_enabled(PreviewFeature::Pylock));
601            assert!(!is_enabled(PreviewFeature::WorkspaceMetadata));
602            assert!(is_enabled(PreviewFeature::AuthHelper));
603        }
604    }
605
606    #[test]
607    #[should_panic(
608        expected = "Additional calls to `uv_preview::test::with_features` are not allowed while holding a `FeaturesGuard`"
609    )]
610    fn test_global_preview_panic_nested() {
611        let _guard =
612            test::with_features(&[PreviewFeature::Pylock, PreviewFeature::WorkspaceMetadata]);
613        let _guard2 =
614            test::with_features(&[PreviewFeature::InitProjectFlag, PreviewFeature::AuthHelper]);
615    }
616
617    #[test]
618    #[should_panic(expected = "uv_preview::test::with_features")]
619    fn test_global_preview_panic_uninitialized() {
620        let _preview = get();
621    }
622}