uv_test/packse/
scenario.rs1use 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#[derive(Debug, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct Scenario {
24 pub name: String,
26
27 #[serde(default)]
29 pub description: Option<String>,
30
31 #[serde(default)]
33 pub packages: BTreeMap<PackageName, Package>,
34
35 pub root: RootPackage,
37
38 pub expected: Expected,
40
41 #[serde(default)]
43 pub environment: Environment,
44
45 #[serde(default)]
47 pub resolver_options: ResolverOptions,
48}
49
50impl Scenario {
51 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 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#[derive(Debug, Deserialize)]
82#[serde(deny_unknown_fields)]
83pub struct Package {
84 pub versions: BTreeMap<Version, PackageMetadata>,
85}
86
87#[derive(Debug, Deserialize, Default)]
89#[serde(deny_unknown_fields)]
90pub struct PackageMetadata {
91 #[serde(default = "default_requires_python")]
93 pub requires_python: Option<VersionSpecifiers>,
94
95 #[serde(default)]
97 pub requires: Vec<Requirement>,
98
99 #[serde(default)]
101 pub extras: BTreeMap<ExtraName, Vec<Requirement>>,
102
103 #[serde(default = "default_true")]
105 pub sdist: bool,
106
107 #[serde(default = "default_true")]
109 pub wheel: bool,
110
111 #[serde(default)]
113 pub yanked: bool,
114
115 #[serde(default)]
118 pub wheel_tags: Vec<WheelTag>,
119}
120
121#[derive(Clone, Debug)]
123pub struct WheelTag(String);
124
125impl WheelTag {
126 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#[derive(Debug, Deserialize)]
159#[serde(deny_unknown_fields)]
160pub struct RootPackage {
161 #[serde(default = "default_requires_python")]
163 pub requires_python: Option<VersionSpecifiers>,
164
165 #[serde(default)]
167 pub requires: Vec<Requirement>,
168}
169
170#[derive(Debug, Deserialize)]
172#[serde(deny_unknown_fields)]
173pub struct Expected {
174 pub satisfiable: bool,
176
177 #[serde(default)]
179 pub packages: BTreeMap<PackageName, Version>,
180
181 #[serde(default)]
183 pub explanation: Option<String>,
184}
185
186#[derive(Debug, Deserialize)]
188#[serde(deny_unknown_fields)]
189pub struct Environment {
190 #[serde(default = "default_python")]
192 pub python: PythonVersion,
193
194 #[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#[derive(Debug, Default, Deserialize)]
210#[serde(deny_unknown_fields)]
211pub struct ResolverOptions {
212 #[serde(default)]
214 pub python: Option<PythonVersion>,
215
216 #[serde(default)]
218 pub prereleases: bool,
219
220 #[serde(default)]
222 pub no_build: Vec<PackageName>,
223
224 #[serde(default)]
226 pub no_binary: Vec<PackageName>,
227
228 #[serde(default)]
230 pub universal: bool,
231
232 #[serde(default)]
234 pub python_platform: Option<TargetTriple>,
235
236 #[serde(default)]
238 pub required_environments: Vec<MarkerTree>,
239}
240
241#[expect(clippy::unnecessary_wraps)] fn 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}