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