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