use std::collections::BTreeMap;
use std::path::Path;
use std::str::FromStr;
use anyhow::{Context, Result};
use serde::Deserialize;
use uv_configuration::TargetTriple;
use uv_distribution_filename::WheelFilename;
use uv_normalize::{ExtraName, PackageName};
use uv_pep440::{Version, VersionSpecifiers};
use uv_pep508::{MarkerTree, Requirement};
use uv_python::PythonVersion;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Scenario {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub packages: BTreeMap<PackageName, Package>,
pub root: RootPackage,
pub expected: Expected,
#[serde(default)]
pub environment: Environment,
#[serde(default)]
pub resolver_options: ResolverOptions,
}
impl Scenario {
pub fn from_path(path: &Path) -> Result<Self> {
let contents = fs_err::read_to_string(path)
.with_context(|| format!("failed to read scenario file `{}`", path.display()))?;
toml::from_str(&contents)
.with_context(|| format!("failed to parse scenario file `{}`", path.display()))
}
pub fn empty() -> Self {
Self {
name: String::new(),
description: None,
packages: BTreeMap::new(),
root: RootPackage {
requires_python: None,
requires: Vec::new(),
},
expected: Expected {
satisfiable: true,
packages: BTreeMap::new(),
explanation: None,
},
environment: Environment::default(),
resolver_options: ResolverOptions::default(),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Package {
pub versions: BTreeMap<Version, PackageMetadata>,
}
#[derive(Debug, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct PackageMetadata {
#[serde(default = "default_requires_python")]
pub requires_python: Option<VersionSpecifiers>,
#[serde(default)]
pub requires: Vec<Requirement>,
#[serde(default)]
pub extras: BTreeMap<ExtraName, Vec<Requirement>>,
#[serde(default = "default_true")]
pub sdist: bool,
#[serde(default = "default_true")]
pub wheel: bool,
#[serde(default)]
pub yanked: bool,
#[serde(default)]
pub wheel_tags: Vec<WheelTag>,
}
#[derive(Clone, Debug)]
pub struct WheelTag(String);
impl WheelTag {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl FromStr for WheelTag {
type Err = String;
fn from_str(tag: &str) -> Result<Self, Self::Err> {
if tag.split('-').count() != 3 {
return Err(format!(
"wheel tag `{tag}` must have exactly three components"
));
}
WheelFilename::from_str(&format!("package-0-{tag}.whl"))
.map_err(|error| format!("wheel tag `{tag}` is invalid: {error}"))?;
Ok(Self(tag.to_string()))
}
}
impl<'de> Deserialize<'de> for WheelTag {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let tag = String::deserialize(deserializer)?;
Self::from_str(&tag).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RootPackage {
#[serde(default = "default_requires_python")]
pub requires_python: Option<VersionSpecifiers>,
#[serde(default)]
pub requires: Vec<Requirement>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Expected {
pub satisfiable: bool,
#[serde(default)]
pub packages: BTreeMap<PackageName, Version>,
#[serde(default)]
pub explanation: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Environment {
#[serde(default = "default_python")]
pub python: PythonVersion,
#[serde(default)]
pub additional_python: Vec<PythonVersion>,
}
impl Default for Environment {
fn default() -> Self {
Self {
python: default_python(),
additional_python: Vec::new(),
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ResolverOptions {
#[serde(default)]
pub python: Option<PythonVersion>,
#[serde(default)]
pub prereleases: bool,
#[serde(default)]
pub no_build: Vec<PackageName>,
#[serde(default)]
pub no_binary: Vec<PackageName>,
#[serde(default)]
pub universal: bool,
#[serde(default)]
pub python_platform: Option<TargetTriple>,
#[serde(default)]
pub required_environments: Vec<MarkerTree>,
}
#[expect(clippy::unnecessary_wraps)] fn default_requires_python() -> Option<VersionSpecifiers> {
Some(VersionSpecifiers::from_str(">=3.12").expect("default requires-python should be valid"))
}
fn default_true() -> bool {
true
}
fn default_python() -> PythonVersion {
PythonVersion::from_str("3.12").expect("default Python version should be valid")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_basic_scenario() {
let toml = r#"
name = "fork-basic"
description = "An extremely basic test."
[resolver_options]
universal = true
[expected]
satisfiable = true
[root]
requires = ["a>=2 ; sys_platform == 'linux'", "a<2 ; sys_platform == 'darwin'"]
[packages.a.versions."1.0.0"]
[packages.a.versions."2.0.0"]
"#;
let scenario: Scenario = toml::from_str(toml).expect("scenario should parse");
let package_name = PackageName::from_str("a").expect("valid package name");
assert_eq!(scenario.name, "fork-basic");
assert!(scenario.resolver_options.universal);
assert_eq!(scenario.packages.len(), 1);
assert_eq!(scenario.packages[&package_name].versions.len(), 2);
}
#[test]
fn parse_extras_scenario() {
let toml = r#"
name = "all-extras-required"
description = "Multiple optional dependencies."
[root]
requires = ["a[all]"]
[expected]
satisfiable = true
[expected.packages]
a = "1.0.0"
b = "1.0.0"
c = "1.0.0"
[packages.b.versions."1.0.0"]
[packages.c.versions."1.0.0"]
[packages.a.versions."1.0.0".extras]
all = ["a[extra_b]", "a[extra_c]"]
extra_b = ["b"]
extra_c = ["c"]
"#;
let scenario: Scenario = toml::from_str(toml).expect("scenario should parse");
let package_name = PackageName::from_str("a").expect("valid package name");
let version = Version::from_str("1.0.0").expect("valid version");
let extra_name = ExtraName::from_str("extra_b").expect("valid extra name");
assert_eq!(scenario.name, "all-extras-required");
let a_meta = &scenario.packages[&package_name].versions[&version];
assert_eq!(a_meta.extras.len(), 3);
assert_eq!(
a_meta.extras[&extra_name],
vec![Requirement::from_str("b").expect("valid requirement")]
);
}
#[test]
fn reject_invalid_requires_python() {
let toml = r#"
name = "invalid-requires-python"
[root]
requires = []
[expected]
satisfiable = true
[packages.a.versions."1.0.0"]
requires_python = "not a specifier"
"#;
assert!(toml::from_str::<Scenario>(toml).is_err());
}
#[test]
fn reject_unknown_metadata_field() {
let toml = r#"
name = "unknown-metadata-field"
[root]
requires = ["a"]
[expected]
satisfiable = true
[packages.a.versions."1.0.0"]
wheels = false
"#;
assert!(toml::from_str::<Scenario>(toml).is_err());
}
#[test]
fn reject_invalid_wheel_tag() {
let toml = r#"
name = "invalid-wheel-tag"
[root]
requires = ["a"]
[expected]
satisfiable = true
[packages.a.versions."1.0.0"]
wheel_tags = ["1-py3-none-any"]
"#;
assert!(toml::from_str::<Scenario>(toml).is_err());
}
#[test]
fn path_is_included_in_parse_errors() {
let temporary_directory =
tempfile::tempdir().expect("temporary directory should be created");
let path = temporary_directory.path().join("invalid.toml");
fs_err::write(&path, "not valid TOML = [").expect("invalid scenario should be written");
let error = Scenario::from_path(&path).expect_err("scenario should fail to parse");
insta::assert_snapshot!(
error
.to_string()
.replace(temporary_directory.path().to_string_lossy().as_ref(), "[TEMP_DIR]")
.replace('\\', "/"),
@"failed to parse scenario file `[TEMP_DIR]/invalid.toml`"
);
}
}