diff --git a/crates/uv-dev/src/generate_scenarios.rs b/crates/uv-dev/src/generate_scenarios.rs index edec0926b1588..0662d1f4c73cd 100644 --- a/crates/uv-dev/src/generate_scenarios.rs +++ b/crates/uv-dev/src/generate_scenarios.rs @@ -471,6 +471,12 @@ fn render_lock(output: &mut String, cases: &[&ScenarioCase]) -> Result<()> { output.push_str("use assert_fs::prelude::*;\n"); output.push_str("use insta::assert_snapshot;\n\n"); output.push_str("use uv_static::EnvVars;\n"); + if cases + .iter() + .any(|case| case.scenario.root.workspace.is_some()) + { + output.push_str("use uv_test::packse::scenario::Scenario;\n"); + } output.push_str("use uv_test::packse::PackseServer;\n"); output.push_str("use uv_test::uv_snapshot;\n\n"); @@ -495,49 +501,65 @@ fn render_lock_case(output: &mut String, case: &ScenarioCase) -> Result<()> { case.scenario.environment.python ) .unwrap(); - writeln!( - output, - " let server = PackseServer::new(\"{}\");", - case.path - ) - .unwrap(); - output.push('\n'); - output.push_str(" let pyproject_toml = context.temp_dir.child(\"pyproject.toml\");\n"); - output.push_str(" pyproject_toml.write_str(\n"); - output.push_str(" r###\"\n"); - output.push_str(" [project]\n"); - output.push_str(" name = \"project\"\n"); - output.push_str(" version = \"0.1.0\"\n"); - output.push_str(" dependencies = [\n"); - for requirement in &case.scenario.root.requires { - writeln!(output, " '''{requirement}''',").unwrap(); - } - output.push_str(" ]\n"); - if let Some(requires_python) = &case.scenario.root.requires_python { - writeln!(output, " requires-python = \"{requires_python}\"").unwrap(); - } - if !case - .scenario - .resolver_options - .required_environments - .is_empty() - { - output.push_str(" [tool.uv]\n"); - output.push_str(" required-environments = [\n"); - for environment in &case.scenario.resolver_options.required_environments { - let environment = environment - .contents() - .context("required environment markers should not be empty")?; - writeln!(output, " '''{environment}''',").unwrap(); + let workspace = case.scenario.root.workspace.is_some(); + if workspace { + writeln!( + output, + " let scenario = Scenario::load(\"{}\")?;", + case.path + ) + .unwrap(); + output.push_str(" scenario.materialize_workspace(&context.temp_dir)?;\n"); + output.push_str(" let server = PackseServer::from_scenario(&scenario);\n\n"); + } else { + writeln!( + output, + " let server = PackseServer::new(\"{}\");", + case.path + ) + .unwrap(); + output.push('\n'); + output.push_str(" let pyproject_toml = context.temp_dir.child(\"pyproject.toml\");\n"); + output.push_str(" pyproject_toml.write_str(\n"); + output.push_str(" r###\"\n"); + output.push_str(" [project]\n"); + output.push_str(" name = \"project\"\n"); + output.push_str(" version = \"0.1.0\"\n"); + output.push_str(" dependencies = [\n"); + for requirement in &case.scenario.root.requires { + writeln!(output, " '''{requirement}''',").unwrap(); } output.push_str(" ]\n"); + if let Some(requires_python) = &case.scenario.root.requires_python { + writeln!(output, " requires-python = \"{requires_python}\"").unwrap(); + } + if !case + .scenario + .resolver_options + .required_environments + .is_empty() + { + output.push_str(" [tool.uv]\n"); + output.push_str(" required-environments = [\n"); + for environment in &case.scenario.resolver_options.required_environments { + let environment = environment + .contents() + .context("required environment markers should not be empty")?; + writeln!(output, " '''{environment}''',").unwrap(); + } + output.push_str(" ]\n"); + } + output.push_str(" \"###\n"); + output.push_str(" )?;\n\n"); } - output.push_str(" \"###\n"); - output.push_str(" )?;\n\n"); output.push_str(" let filters = context.filters();\n\n"); output.push_str(" let mut cmd = context.lock();\n"); - output.push_str(" cmd.env_remove(EnvVars::UV_EXCLUDE_NEWER);\n"); - output.push_str(" cmd.arg(\"--index-url\").arg(server.index_url());\n"); + if workspace { + output.push_str(" server.configure(&mut cmd);\n"); + } else { + output.push_str(" cmd.env_remove(EnvVars::UV_EXCLUDE_NEWER);\n"); + output.push_str(" cmd.arg(\"--index-url\").arg(server.index_url());\n"); + } render_expected_explanation(output, &case.scenario, " // "); output.push_str(" uv_snapshot!(filters, cmd, @r###\"\n"); output.push_str(" \"###\n"); @@ -553,14 +575,21 @@ fn render_lock_case(output: &mut String, case: &ScenarioCase) -> Result<()> { output.push_str(" );\n"); output.push_str(" });\n\n"); output.push_str(" // Assert the idempotence of `uv lock` when resolving from the lockfile (`--locked`).\n"); - output.push_str(" context\n"); - output.push_str(" .lock()\n"); - output.push_str(" .arg(\"--locked\")\n"); - output.push_str(" .env_remove(EnvVars::UV_EXCLUDE_NEWER)\n"); - output.push_str(" .arg(\"--index-url\")\n"); - output.push_str(" .arg(server.index_url())\n"); - output.push_str(" .assert()\n"); - output.push_str(" .success();\n"); + if workspace { + output.push_str(" let mut locked = context.lock();\n"); + output.push_str(" locked.arg(\"--locked\");\n"); + output.push_str(" server.configure(&mut locked);\n"); + output.push_str(" locked.assert().success();\n"); + } else { + output.push_str(" context\n"); + output.push_str(" .lock()\n"); + output.push_str(" .arg(\"--locked\")\n"); + output.push_str(" .env_remove(EnvVars::UV_EXCLUDE_NEWER)\n"); + output.push_str(" .arg(\"--index-url\")\n"); + output.push_str(" .arg(server.index_url())\n"); + output.push_str(" .assert()\n"); + output.push_str(" .success();\n"); + } } output.push('\n'); output.push_str(" Ok(())\n"); @@ -957,6 +986,41 @@ mod tests { assert!(!scenarios.is_empty()); } + #[test] + fn workspace_lock_scenario_uses_shared_materializer() { + let temporary_directory = + tempfile::tempdir().expect("temporary directory should be created"); + fs_err::write( + temporary_directory.path().join("workspace.toml"), + r#" +name = "workspace" + +[resolver_options] +universal = true + +[root] +requires = [] + +[root.workspace.project] +name = "project" +version = "0.1.0" + +[expected] +satisfiable = true +"#, + ) + .expect("scenario should be written"); + let scenarios = load_scenarios_from(temporary_directory.path()) + .expect("scenario directory should load"); + let cases = scenarios.iter().collect::>(); + + let output = render(TemplateKind::Lock, &cases).expect("lock suite should render"); + + assert!(output.contains("use uv_test::packse::scenario::Scenario;")); + assert!(output.contains("scenario.materialize_workspace(&context.temp_dir)?;")); + assert!(!output.contains("let pyproject_toml")); + } + #[test] fn stale_generated_file_is_an_error() { let temporary_directory = diff --git a/crates/uv-test/src/packse/scenario.rs b/crates/uv-test/src/packse/scenario.rs index 3654a2fe0576e..eee67f243fe27 100644 --- a/crates/uv-test/src/packse/scenario.rs +++ b/crates/uv-test/src/packse/scenario.rs @@ -4,11 +4,12 @@ //! `[packages..versions.]` becomes a [`PackageName`] key, then a [`Version`] key. use std::collections::BTreeMap; +use std::io::Write; use std::path::Path; use std::str::FromStr; -use anyhow::{Context, Result}; -use serde::Deserialize; +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; use uv_configuration::TargetTriple; use uv_distribution_filename::WheelFilename; @@ -17,6 +18,8 @@ use uv_pep440::{Version, VersionSpecifiers}; use uv_pep508::{MarkerTree, Requirement}; use uv_python::PythonVersion; +use super::scenarios_dir; + /// A complete packse scenario definition. #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] @@ -48,12 +51,82 @@ pub struct Scenario { } impl Scenario { + /// Parse a vendored scenario by its path relative to `test/scenarios`. + pub fn load(path: &str) -> Result { + Self::from_path(&scenarios_dir().join(path)) + } + /// Parse a single scenario from a TOML file path. pub fn from_path(path: &Path) -> Result { 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())) + let scenario: Self = toml::from_str(&contents) + .with_context(|| format!("failed to parse scenario file `{}`", path.display()))?; + scenario + .validate() + .with_context(|| format!("invalid scenario file `{}`", path.display()))?; + Ok(scenario) + } + + fn validate(&self) -> Result<()> { + if self.root.workspace.is_some() && !self.resolver_options.universal { + bail!("root workspaces are only supported for universal lock scenarios"); + } + Ok(()) + } + + /// Render this scenario's root workspace as a `pyproject.toml` document. + fn pyproject_toml(&self) -> Result { + let workspace = self + .root + .workspace + .as_ref() + .context("scenario root does not define a workspace")?; + let required_environments = self + .resolver_options + .required_environments + .iter() + .map(|environment| { + environment + .contents() + .context("required environment markers should not be empty") + .map(|contents| contents.to_string()) + }) + .collect::>>()?; + let tool = (!required_environments.is_empty()).then_some(PyProjectTool { + uv: PyProjectUv { + required_environments, + }, + }); + let pyproject = PyProjectToml { + project: PyProjectProject { + name: workspace.project.name.to_string(), + version: workspace.project.version.to_string(), + requires_python: self.root.requires_python.as_ref().map(ToString::to_string), + dependencies: self.root.requires.iter().map(ToString::to_string).collect(), + }, + tool, + }; + toml::to_string_pretty(&pyproject).context("failed to serialize scenario workspace") + } + + /// Materialize this scenario's root workspace into `destination`. + pub fn materialize_workspace(&self, destination: impl AsRef) -> Result<()> { + let destination = destination.as_ref(); + fs_err::create_dir_all(destination).with_context(|| { + format!( + "failed to create workspace directory `{}`", + destination.display() + ) + })?; + let pyproject_toml = destination.join("pyproject.toml"); + let mut file = fs_err::OpenOptions::new() + .write(true) + .create_new(true) + .open(&pyproject_toml) + .with_context(|| format!("failed to create `{}`", pyproject_toml.display()))?; + file.write_all(self.pyproject_toml()?.as_bytes()) + .with_context(|| format!("failed to write `{}`", pyproject_toml.display())) } /// Construct an otherwise-empty scenario for indexes that should only expose vendored files. @@ -65,6 +138,7 @@ impl Scenario { root: RootPackage { requires_python: None, requires: Vec::new(), + workspace: None, }, expected: Expected { satisfiable: true, @@ -165,6 +239,53 @@ pub struct RootPackage { /// Top-level requirements. #[serde(default)] pub requires: Vec, + + /// Project metadata to use when materializing this root as a workspace. + #[serde(default)] + pub workspace: Option, +} + +/// An inline workspace definition for a scenario root. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RootWorkspace { + /// The project at the workspace root. + project: RootWorkspaceProject, +} + +/// The single project supported by an inline root workspace. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RootWorkspaceProject { + name: PackageName, + version: Version, +} + +#[derive(Serialize)] +struct PyProjectToml { + project: PyProjectProject, + #[serde(skip_serializing_if = "Option::is_none")] + tool: Option, +} + +#[derive(Serialize)] +struct PyProjectProject { + name: String, + version: String, + #[serde(rename = "requires-python", skip_serializing_if = "Option::is_none")] + requires_python: Option, + dependencies: Vec, +} + +#[derive(Serialize)] +struct PyProjectTool { + uv: PyProjectUv, +} + +#[derive(Serialize)] +struct PyProjectUv { + #[serde(rename = "required-environments")] + required_environments: Vec, } /// Expected resolution outcome. @@ -281,6 +402,176 @@ requires = ["a>=2 ; sys_platform == 'linux'", "a<2 ; sys_platform == 'darwin'"] assert_eq!(scenario.packages[&package_name].versions.len(), 2); } + #[test] + fn parse_root_workspace() { + let toml = r#" +name = "workspace" + +[root] +requires = [] + +[root.workspace.project] +name = "project" +version = "0.1.0" + +[expected] +satisfiable = true +"#; + let scenario: Scenario = toml::from_str(toml).expect("scenario should parse"); + let project = scenario + .root + .workspace + .expect("root should define a workspace") + .project; + assert_eq!(project.name.as_ref(), "project"); + assert_eq!( + project.version, + Version::from_str("0.1.0").expect("valid version") + ); + } + + #[test] + fn materialize_root_workspace() { + let toml = r#" +name = "workspace" + +[root] +requires_python = ">=3.9" +requires = ["a ; python_version < '3.10'"] + +[root.workspace.project] +name = "project" +version = "0.1.0" + +[resolver_options] +required_environments = ["sys_platform == 'darwin'"] + +[expected] +satisfiable = true +"#; + let scenario: Scenario = toml::from_str(toml).expect("scenario should parse"); + let temporary_directory = + tempfile::tempdir().expect("temporary directory should be created"); + scenario + .materialize_workspace(temporary_directory.path()) + .expect("workspace should materialize"); + + let pyproject_toml = + fs_err::read_to_string(temporary_directory.path().join("pyproject.toml")) + .expect("pyproject.toml should be readable"); + let pyproject: toml::Value = + toml::from_str(&pyproject_toml).expect("pyproject.toml should parse"); + assert_eq!(pyproject["project"]["name"].as_str(), Some("project")); + assert_eq!(pyproject["project"]["version"].as_str(), Some("0.1.0")); + assert_eq!( + pyproject["project"]["requires-python"].as_str(), + Some(">=3.9") + ); + assert_eq!( + pyproject["project"]["dependencies"][0].as_str(), + Some("a ; python_full_version < '3.10'") + ); + assert_eq!( + pyproject["tool"]["uv"]["required-environments"][0].as_str(), + Some("sys_platform == 'darwin'") + ); + } + + #[test] + fn materialize_root_workspace_requires_configuration() { + let scenario = Scenario::empty(); + let temporary_directory = + tempfile::tempdir().expect("temporary directory should be created"); + assert!( + scenario + .materialize_workspace(temporary_directory.path()) + .is_err() + ); + } + + #[test] + fn materialize_root_workspace_does_not_overwrite() { + let toml = r#" +name = "workspace" + +[root] +requires = [] + +[root.workspace.project] +name = "project" +version = "0.1.0" + +[expected] +satisfiable = true +"#; + let scenario: Scenario = toml::from_str(toml).expect("scenario should parse"); + let temporary_directory = + tempfile::tempdir().expect("temporary directory should be created"); + fs_err::write( + temporary_directory.path().join("pyproject.toml"), + "existing contents", + ) + .expect("existing pyproject.toml should be written"); + + assert!( + scenario + .materialize_workspace(temporary_directory.path()) + .is_err() + ); + assert_eq!( + fs_err::read_to_string(temporary_directory.path().join("pyproject.toml")) + .expect("existing pyproject.toml should be readable"), + "existing contents" + ); + } + + #[test] + fn reject_unknown_root_workspace_field() { + let toml = r#" +name = "workspace" + +[root] +requires = [] + +[root.workspace] +members = [] + +[root.workspace.project] +name = "project" +version = "0.1.0" + +[expected] +satisfiable = true +"#; + assert!(toml::from_str::(toml).is_err()); + } + + #[test] + fn reject_root_workspace_for_non_universal_scenario() { + let temporary_directory = + tempfile::tempdir().expect("temporary directory should be created"); + let path = temporary_directory.path().join("workspace.toml"); + fs_err::write( + &path, + r#" +name = "workspace" + +[root] +requires = [] + +[root.workspace.project] +name = "project" +version = "0.1.0" + +[expected] +satisfiable = true +"#, + ) + .expect("scenario should be written"); + + assert!(Scenario::from_path(&path).is_err()); + } + #[test] fn parse_extras_scenario() { let toml = r#" diff --git a/crates/uv-test/src/packse/server.rs b/crates/uv-test/src/packse/server.rs index 7adcf28204ba6..79c88813def8f 100644 --- a/crates/uv-test/src/packse/server.rs +++ b/crates/uv-test/src/packse/server.rs @@ -9,6 +9,7 @@ use std::collections::HashMap; use std::path::Path; +use std::process::Command; use std::str::FromStr; use std::sync::Arc; @@ -18,12 +19,12 @@ use wiremock::{Request, ResponseTemplate}; use uv_distribution_filename::WheelFilename; use uv_normalize::PackageName; use uv_pep440::VersionSpecifiers; +use uv_static::EnvVars; use crate::http_server::{HttpServer, content_type_for_filename}; use crate::vendor::{VendorArtifact, vendor_artifacts}; use super::scenario::{Scenario, WheelTag}; -use super::scenarios_dir; use super::wheel::{generate_sdist, generate_wheel, sha256_hex}; const PACKSE_UPLOAD_TIME: &str = "2024-03-24T00:00:00Z"; @@ -76,9 +77,8 @@ impl PackseServer { /// Load a scenario from a TOML path (relative to the vendored scenarios directory) /// and start a mock server for it. pub fn new(scenario_path: &str) -> Self { - let full_path = scenarios_dir().join(scenario_path); let scenario = - Scenario::from_path(&full_path).expect("vendored Packse scenario should parse"); + Scenario::load(scenario_path).expect("vendored Packse scenario should parse"); Self::from_scenario(&scenario) } @@ -103,6 +103,13 @@ impl PackseServer { pub fn index_url(&self) -> String { format!("{}/simple/", self.server.url()) } + + /// Configure a uv command to use this server as its package index. + pub fn configure(&self, command: &mut Command) { + command + .env(EnvVars::UV_DEFAULT_INDEX, self.index_url()) + .env_remove(EnvVars::UV_EXCLUDE_NEWER); + } } /// Build the complete [`ServerIndex`] from a scenario and cached build dependencies.