Skip to main content

uv_test/packse/
scenario.rs

1//! Typed representation of the vendored Packse scenario TOML files.
2//!
3//! The nested TOML tables map directly onto [`Scenario::packages`]:
4//! `[packages.<name>.versions.<version>]` becomes a [`PackageName`] key, then a [`Version`] key.
5
6use std::collections::BTreeMap;
7use std::fmt;
8use std::path::Path;
9use std::str::FromStr;
10
11use anyhow::{Context, Result};
12use serde::Deserialize;
13
14use uv_configuration::TargetTriple;
15use uv_distribution_filename::WheelFilename;
16use uv_normalize::{ExtraName, PackageName};
17use uv_pep440::{Version, VersionSpecifiers};
18use uv_pep508::{MarkerTree, Requirement};
19use uv_python::PythonVersion;
20
21/// A complete packse scenario definition.
22#[derive(Debug, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct Scenario {
25    /// The scenario name (e.g., `"fork-basic"`).
26    pub name: String,
27
28    /// Human-readable description.
29    #[serde(default)]
30    pub description: Option<String>,
31
32    /// Packages keyed by the TOML segment in `[packages.<name>]`.
33    #[serde(default)]
34    pub packages: BTreeMap<PackageName, Package>,
35
36    /// The root (entrypoint) requirements.
37    pub root: RootPackage,
38
39    /// What we expect the resolver to produce.
40    pub expected: Expected,
41
42    /// Metadata about the Python environment.
43    #[serde(default)]
44    pub environment: Environment,
45
46    /// Additional resolver options.
47    #[serde(default)]
48    pub resolver_options: ResolverOptions,
49}
50
51impl Scenario {
52    /// Parse a single scenario from a TOML file path.
53    pub fn from_path(path: &Path) -> Result<Self> {
54        let contents = fs_err::read_to_string(path)
55            .with_context(|| format!("failed to read scenario file `{}`", path.display()))?;
56        toml::from_str(&contents)
57            .with_context(|| format!("failed to parse scenario file `{}`", path.display()))
58    }
59
60    /// Construct an otherwise-empty scenario for indexes that should only expose vendored files.
61    pub fn empty() -> Self {
62        Self {
63            name: String::new(),
64            description: None,
65            packages: BTreeMap::new(),
66            root: RootPackage {
67                requires_python: None,
68                requires: Vec::new(),
69            },
70            expected: Expected {
71                satisfiable: true,
72                packages: BTreeMap::new(),
73                explanation: None,
74            },
75            environment: Environment::default(),
76            resolver_options: ResolverOptions::default(),
77        }
78    }
79}
80
81/// A package with one or more versions.
82#[derive(Debug, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct Package {
85    pub versions: BTreeMap<Version, PackageMetadata>,
86}
87
88/// Metadata for a single version of a package.
89#[derive(Debug, Deserialize, Default)]
90#[serde(deny_unknown_fields)]
91pub struct PackageMetadata {
92    /// The `Requires-Python` specifier. Defaults to `">=3.12"`.
93    #[serde(default = "default_requires_python")]
94    pub requires_python: Option<VersionSpecifiers>,
95
96    /// Dependency requirements.
97    #[serde(default)]
98    pub requires: Vec<Requirement>,
99
100    /// Extra names mapped to their optional dependency requirements.
101    #[serde(default)]
102    pub extras: BTreeMap<ExtraName, Vec<Requirement>>,
103
104    /// Whether to produce a source distribution.
105    #[serde(default = "default_true")]
106    pub sdist: bool,
107
108    /// Whether to produce a wheel.
109    #[serde(default = "default_true")]
110    pub wheel: bool,
111
112    /// Whether this version is yanked.
113    #[serde(default)]
114    pub yanked: bool,
115
116    /// Specific wheel tags to produce (e.g., `["cp312-abi3-win_amd64"]`).
117    /// An empty list means produce only the default `py3-none-any` wheel.
118    #[serde(default)]
119    pub wheel_tags: Vec<WheelTag>,
120}
121
122/// A validated three-component compatibility tag for generated wheels.
123#[derive(Clone, Debug)]
124pub struct WheelTag(String);
125
126impl WheelTag {
127    /// Return the compatibility tag as it should appear in a wheel filename.
128    pub fn as_str(&self) -> &str {
129        &self.0
130    }
131}
132
133impl FromStr for WheelTag {
134    type Err = String;
135
136    fn from_str(tag: &str) -> Result<Self, Self::Err> {
137        if tag.split('-').count() != 3 {
138            return Err(format!(
139                "wheel tag `{tag}` must have exactly three components"
140            ));
141        }
142        WheelFilename::from_str(&format!("package-0-{tag}.whl"))
143            .map_err(|error| format!("wheel tag `{tag}` is invalid: {error}"))?;
144        Ok(Self(tag.to_string()))
145    }
146}
147
148impl<'de> Deserialize<'de> for WheelTag {
149    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
150    where
151        D: serde::Deserializer<'de>,
152    {
153        let tag = String::deserialize(deserializer)?;
154        Self::from_str(&tag).map_err(serde::de::Error::custom)
155    }
156}
157
158/// The root/entrypoint package.
159#[derive(Debug, Deserialize)]
160#[serde(deny_unknown_fields)]
161pub struct RootPackage {
162    /// `Requires-Python` for the root.
163    #[serde(default = "default_requires_python")]
164    pub requires_python: Option<VersionSpecifiers>,
165
166    /// Top-level requirements.
167    #[serde(default)]
168    pub requires: Vec<Requirement>,
169}
170
171/// Expected resolution outcome.
172#[derive(Debug, Deserialize)]
173#[serde(deny_unknown_fields)]
174pub struct Expected {
175    /// Whether the scenario is satisfiable.
176    pub satisfiable: bool,
177
178    /// Expected installed package names mapped to resolved versions.
179    #[serde(default)]
180    pub packages: BTreeMap<PackageName, Version>,
181
182    /// Optional explanation.
183    #[serde(default)]
184    pub explanation: Option<String>,
185}
186
187/// Python environment metadata.
188#[derive(Debug, Deserialize)]
189#[serde(deny_unknown_fields)]
190pub struct Environment {
191    /// Active Python version.
192    #[serde(default = "default_python")]
193    pub python: PythonVersion,
194
195    /// Additional Python versions available on the system.
196    #[serde(default)]
197    pub additional_python: Vec<PythonVersion>,
198}
199
200impl Default for Environment {
201    fn default() -> Self {
202        Self {
203            python: default_python(),
204            additional_python: Vec::new(),
205        }
206    }
207}
208
209/// Additional resolver options.
210#[derive(Debug, Default, Deserialize)]
211#[serde(deny_unknown_fields)]
212pub struct ResolverOptions {
213    /// Select the generated test command for this scenario.
214    #[serde(default)]
215    pub test: Option<ScenarioTest>,
216
217    /// Version selection strategy.
218    #[serde(default)]
219    pub resolution: Option<Resolution>,
220
221    /// Python version override for resolution.
222    #[serde(default)]
223    pub python: Option<PythonVersion>,
224
225    /// Enable pre-release selection.
226    #[serde(default)]
227    pub prereleases: bool,
228
229    /// Packages that must use pre-built wheels (no building from source).
230    #[serde(default)]
231    pub no_build: Vec<PackageName>,
232
233    /// Packages that must NOT use pre-built wheels (must build from source).
234    #[serde(default)]
235    pub no_binary: Vec<PackageName>,
236
237    /// Universal (multi-platform) resolution mode.
238    #[serde(default)]
239    pub universal: bool,
240
241    /// Python platform to resolve for.
242    #[serde(default)]
243    pub python_platform: Option<TargetTriple>,
244
245    /// Required environments (platform markers).
246    #[serde(default)]
247    pub required_environments: Vec<MarkerTree>,
248}
249
250/// The command template used to generate a scenario test.
251#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
252#[serde(rename_all = "kebab-case")]
253pub enum ScenarioTest {
254    Install,
255    Compile,
256    Lock,
257}
258
259/// The version selection strategy used by the resolver.
260#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
261#[serde(rename_all = "kebab-case")]
262pub enum Resolution {
263    Highest,
264    Lowest,
265    LowestDirect,
266}
267
268impl fmt::Display for Resolution {
269    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
270        match self {
271            Self::Highest => formatter.write_str("highest"),
272            Self::Lowest => formatter.write_str("lowest"),
273            Self::LowestDirect => formatter.write_str("lowest-direct"),
274        }
275    }
276}
277
278#[expect(clippy::unnecessary_wraps)] // Must return `Option` for serde `default`
279fn default_requires_python() -> Option<VersionSpecifiers> {
280    Some(VersionSpecifiers::from_str(">=3.12").expect("default requires-python should be valid"))
281}
282
283fn default_true() -> bool {
284    true
285}
286
287fn default_python() -> PythonVersion {
288    PythonVersion::from_str("3.12").expect("default Python version should be valid")
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn parse_basic_scenario() {
297        let toml = r#"
298name = "fork-basic"
299description = "An extremely basic test."
300
301[resolver_options]
302universal = true
303
304[expected]
305satisfiable = true
306
307[root]
308requires = ["a>=2 ; sys_platform == 'linux'", "a<2 ; sys_platform == 'darwin'"]
309
310[packages.a.versions."1.0.0"]
311[packages.a.versions."2.0.0"]
312"#;
313        let scenario: Scenario = toml::from_str(toml).expect("scenario should parse");
314        let package_name = PackageName::from_str("a").expect("valid package name");
315        assert_eq!(scenario.name, "fork-basic");
316        assert!(scenario.resolver_options.universal);
317        assert_eq!(scenario.packages.len(), 1);
318        assert_eq!(scenario.packages[&package_name].versions.len(), 2);
319    }
320
321    #[test]
322    fn parse_extras_scenario() {
323        let toml = r#"
324name = "all-extras-required"
325description = "Multiple optional dependencies."
326
327[root]
328requires = ["a[all]"]
329
330[expected]
331satisfiable = true
332
333[expected.packages]
334a = "1.0.0"
335b = "1.0.0"
336c = "1.0.0"
337
338[packages.b.versions."1.0.0"]
339[packages.c.versions."1.0.0"]
340
341[packages.a.versions."1.0.0".extras]
342all = ["a[extra_b]", "a[extra_c]"]
343extra_b = ["b"]
344extra_c = ["c"]
345"#;
346        let scenario: Scenario = toml::from_str(toml).expect("scenario should parse");
347        let package_name = PackageName::from_str("a").expect("valid package name");
348        let version = Version::from_str("1.0.0").expect("valid version");
349        let extra_name = ExtraName::from_str("extra_b").expect("valid extra name");
350        assert_eq!(scenario.name, "all-extras-required");
351        let a_meta = &scenario.packages[&package_name].versions[&version];
352        assert_eq!(a_meta.extras.len(), 3);
353        assert_eq!(
354            a_meta.extras[&extra_name],
355            vec![Requirement::from_str("b").expect("valid requirement")]
356        );
357    }
358
359    #[test]
360    fn parse_test_and_resolution() {
361        let toml = r#"
362name = "lowest-direct"
363
364[root]
365requires = ["a"]
366
367[expected]
368satisfiable = true
369
370[resolver_options]
371test = "compile"
372resolution = "lowest-direct"
373"#;
374        let scenario: Scenario = toml::from_str(toml).expect("scenario should parse");
375        assert_eq!(scenario.resolver_options.test, Some(ScenarioTest::Compile));
376        assert_eq!(
377            scenario.resolver_options.resolution,
378            Some(Resolution::LowestDirect)
379        );
380    }
381
382    #[test]
383    fn reject_invalid_requires_python() {
384        let toml = r#"
385name = "invalid-requires-python"
386
387[root]
388requires = []
389
390[expected]
391satisfiable = true
392
393[packages.a.versions."1.0.0"]
394requires_python = "not a specifier"
395"#;
396
397        assert!(toml::from_str::<Scenario>(toml).is_err());
398    }
399
400    #[test]
401    fn reject_unknown_metadata_field() {
402        let toml = r#"
403name = "unknown-metadata-field"
404
405[root]
406requires = ["a"]
407
408[expected]
409satisfiable = true
410
411[packages.a.versions."1.0.0"]
412wheels = false
413"#;
414
415        assert!(toml::from_str::<Scenario>(toml).is_err());
416    }
417
418    #[test]
419    fn reject_invalid_wheel_tag() {
420        let toml = r#"
421name = "invalid-wheel-tag"
422
423[root]
424requires = ["a"]
425
426[expected]
427satisfiable = true
428
429[packages.a.versions."1.0.0"]
430wheel_tags = ["1-py3-none-any"]
431"#;
432
433        assert!(toml::from_str::<Scenario>(toml).is_err());
434    }
435
436    #[test]
437    fn path_is_included_in_parse_errors() {
438        let temporary_directory =
439            tempfile::tempdir().expect("temporary directory should be created");
440        let path = temporary_directory.path().join("invalid.toml");
441        fs_err::write(&path, "not valid TOML = [").expect("invalid scenario should be written");
442
443        let error = Scenario::from_path(&path).expect_err("scenario should fail to parse");
444        insta::assert_snapshot!(
445            error
446                .to_string()
447                .replace(temporary_directory.path().to_string_lossy().as_ref(), "[TEMP_DIR]")
448                .replace('\\', "/"),
449            @"failed to parse scenario file `[TEMP_DIR]/invalid.toml`"
450        );
451    }
452}