Re-add lowering unit tests (#3935)

Re-add the lowering unit tests removed in #3904. This also adds a
`stop_discovery_at` feature to avoid running actual workspace discovery.
This commit is contained in:
konsti 2024-05-31 14:17:49 +02:00 committed by GitHub
parent 9bb0679618
commit 3c074142f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 335 additions and 19 deletions

1
Cargo.lock generated
View file

@ -4659,6 +4659,7 @@ dependencies = [
"fs-err",
"futures",
"glob",
"indoc",
"insta",
"install-wheel-rs",
"nanoid",

View file

@ -54,6 +54,7 @@ url = { workspace = true }
zip = { workspace = true }
[dev-dependencies]
indoc = { version = "2.0.5" }
insta = { version = "1.39.0", features = ["filters", "json", "redactions"] }
regex = { workspace = true }

View file

@ -34,8 +34,8 @@ mod workspace;
pub enum MetadataLoweringError {
#[error(transparent)]
Workspace(#[from] WorkspaceError),
#[error(transparent)]
Lowering(#[from] LoweringError),
#[error("Failed to parse entry for: `{0}`")]
LoweringError(PackageName, #[source] LoweringError),
}
#[derive(Debug, Clone)]
@ -76,11 +76,19 @@ impl Metadata {
// TODO(konsti): Limit discovery for Git checkouts to Git root.
// TODO(konsti): Cache workspace discovery.
let Some(project_workspace) =
ProjectWorkspace::from_maybe_project_root(project_root).await?
ProjectWorkspace::from_maybe_project_root(project_root, None).await?
else {
return Ok(Self::from_metadata23(metadata));
};
Self::from_project_workspace(metadata, &project_workspace, preview_mode)
}
pub fn from_project_workspace(
metadata: Metadata23,
project_workspace: &ProjectWorkspace,
preview_mode: PreviewMode,
) -> Result<Metadata, MetadataLoweringError> {
let empty = BTreeMap::default();
let sources = project_workspace
.current_project()
@ -95,6 +103,7 @@ impl Metadata {
.requires_dist
.into_iter()
.map(|requirement| {
let requirement_name = requirement.name.clone();
lower_requirement(
requirement,
&metadata.name,
@ -103,6 +112,7 @@ impl Metadata {
project_workspace.workspace(),
preview_mode,
)
.map_err(|err| MetadataLoweringError::LoweringError(requirement_name.clone(), err))
})
.collect::<Result<_, _>>()?;

View file

@ -239,3 +239,258 @@ fn path_source(
editable,
})
}
#[cfg(test)]
mod test {
use std::path::Path;
use std::str::FromStr;
use indoc::indoc;
use insta::assert_snapshot;
use pypi_types::Metadata23;
use uv_configuration::PreviewMode;
use uv_normalize::PackageName;
use crate::pyproject::PyProjectToml;
use crate::{Metadata, ProjectWorkspace};
async fn metadata_from_pyproject_toml(contents: &str) -> anyhow::Result<Metadata> {
let pyproject_toml: PyProjectToml = toml::from_str(contents)?;
let path = Path::new("pyproject.toml");
let project_name = PackageName::from_str("foo").unwrap();
let project_workspace =
ProjectWorkspace::from_project(path, &pyproject_toml, project_name, Some(path)).await?;
let metadata = Metadata23::parse_pyproject_toml(contents)?;
Ok(Metadata::from_project_workspace(
metadata,
&project_workspace,
PreviewMode::Enabled,
)?)
}
async fn format_err(input: &str) -> String {
let err = metadata_from_pyproject_toml(input).await.unwrap_err();
let mut causes = err.chain();
let mut message = String::new();
message.push_str(&format!("error: {}\n", causes.next().unwrap()));
for err in causes {
message.push_str(&format!(" Caused by: {err}\n"));
}
message
}
#[tokio::test]
async fn conflict_project_and_sources() {
let input = indoc! {r#"
[project]
name = "foo"
version = "0.0.0"
dependencies = [
"tqdm @ git+https://github.com/tqdm/tqdm",
]
[tool.uv.sources]
tqdm = { url = "https://files.pythonhosted.org/packages/a5/d6/502a859bac4ad5e274255576cd3e15ca273cdb91731bc39fb840dd422ee9/tqdm-4.66.0-py3-none-any.whl" }
"#};
assert_snapshot!(format_err(input).await, @r###"
error: Failed to parse entry for: `tqdm`
Caused by: Can't combine URLs from both `project.dependencies` and `tool.uv.sources`
"###);
}
#[tokio::test]
async fn too_many_git_specs() {
let input = indoc! {r#"
[project]
name = "foo"
version = "0.0.0"
dependencies = [
"tqdm",
]
[tool.uv.sources]
tqdm = { git = "https://github.com/tqdm/tqdm", rev = "baaaaaab", tag = "v1.0.0" }
"#};
assert_snapshot!(format_err(input).await, @r###"
error: Failed to parse entry for: `tqdm`
Caused by: Can only specify one of: `rev`, `tag`, or `branch`
"###);
}
#[tokio::test]
async fn too_many_git_typo() {
let input = indoc! {r#"
[project]
name = "foo"
version = "0.0.0"
dependencies = [
"tqdm",
]
[tool.uv.sources]
tqdm = { git = "https://github.com/tqdm/tqdm", ref = "baaaaaab" }
"#};
// TODO(konsti): This should tell you the set of valid fields
assert_snapshot!(format_err(input).await, @r###"
error: TOML parse error at line 8, column 8
|
8 | tqdm = { git = "https://github.com/tqdm/tqdm", ref = "baaaaaab" }
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
data did not match any variant of untagged enum Source
"###);
}
#[tokio::test]
async fn you_cant_mix_those() {
let input = indoc! {r#"
[project]
name = "foo"
version = "0.0.0"
dependencies = [
"tqdm",
]
[tool.uv.sources]
tqdm = { path = "tqdm", index = "torch" }
"#};
// TODO(konsti): This should tell you the set of valid fields
assert_snapshot!(format_err(input).await, @r###"
error: TOML parse error at line 8, column 8
|
8 | tqdm = { path = "tqdm", index = "torch" }
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
data did not match any variant of untagged enum Source
"###);
}
#[tokio::test]
async fn missing_constraint() {
let input = indoc! {r#"
[project]
name = "foo"
version = "0.0.0"
dependencies = [
"tqdm",
]
"#};
assert!(metadata_from_pyproject_toml(input).await.is_ok());
}
#[tokio::test]
async fn invalid_syntax() {
let input = indoc! {r#"
[project]
name = "foo"
version = "0.0.0"
dependencies = [
"tqdm ==4.66.0",
]
[tool.uv.sources]
tqdm = { url = invalid url to tqdm-4.66.0-py3-none-any.whl" }
"#};
assert_snapshot!(format_err(input).await, @r###"
error: TOML parse error at line 8, column 16
|
8 | tqdm = { url = invalid url to tqdm-4.66.0-py3-none-any.whl" }
| ^
invalid string
expected `"`, `'`
"###);
}
#[tokio::test]
async fn invalid_url() {
let input = indoc! {r#"
[project]
name = "foo"
version = "0.0.0"
dependencies = [
"tqdm ==4.66.0",
]
[tool.uv.sources]
tqdm = { url = "§invalid#+#*Ä" }
"#};
assert_snapshot!(format_err(input).await, @r###"
error: TOML parse error at line 8, column 8
|
8 | tqdm = { url = "§invalid#+#*Ä" }
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^
data did not match any variant of untagged enum Source
"###);
}
#[tokio::test]
async fn workspace_and_url_spec() {
let input = indoc! {r#"
[project]
name = "foo"
version = "0.0.0"
dependencies = [
"tqdm @ git+https://github.com/tqdm/tqdm",
]
[tool.uv.sources]
tqdm = { workspace = true }
"#};
assert_snapshot!(format_err(input).await, @r###"
error: Failed to parse entry for: `tqdm`
Caused by: Can't combine URLs from both `project.dependencies` and `tool.uv.sources`
"###);
}
#[tokio::test]
async fn missing_workspace_package() {
let input = indoc! {r#"
[project]
name = "foo"
version = "0.0.0"
dependencies = [
"tqdm ==4.66.0",
]
[tool.uv.sources]
tqdm = { workspace = true }
"#};
assert_snapshot!(format_err(input).await, @r###"
error: Failed to parse entry for: `tqdm`
Caused by: Package is not included as workspace package in `tool.uv.workspace`
"###);
}
#[tokio::test]
async fn cant_be_dynamic() {
let input = indoc! {r#"
[project]
name = "foo"
version = "0.0.0"
dynamic = [
"dependencies"
]
[tool.uv.sources]
tqdm = { workspace = true }
"#};
assert_snapshot!(format_err(input).await, @r###"
error: The following field was marked as dynamic: dependencies
"###);
}
#[tokio::test]
async fn missing_project_section() {
let input = indoc! {"
[tool.uv.sources]
tqdm = { workspace = true }
"};
assert_snapshot!(format_err(input).await, @r###"
error: metadata field project not found
"###);
}
}

View file

@ -179,10 +179,22 @@ pub struct ProjectWorkspace {
impl ProjectWorkspace {
/// Find the current project and workspace, given the current directory.
pub async fn discover(path: impl AsRef<Path>) -> Result<Self, WorkspaceError> {
///
/// `stop_discovery_at` must be either `None` or an ancestor of the current directory. If set,
/// only directories between the current path and `stop_discovery_at` are considered.
pub async fn discover(
path: impl AsRef<Path>,
stop_discovery_at: Option<&Path>,
) -> Result<Self, WorkspaceError> {
let project_root = path
.as_ref()
.ancestors()
.take_while(|path| {
// Only walk up the given directory, if any.
stop_discovery_at
.map(|stop_discovery_at| stop_discovery_at != *path)
.unwrap_or(true)
})
.find(|path| path.join("pyproject.toml").is_file())
.ok_or(WorkspaceError::MissingPyprojectToml)?;
@ -191,11 +203,14 @@ impl ProjectWorkspace {
project_root.simplified_display()
);
Self::from_project_root(project_root).await
Self::from_project_root(project_root, stop_discovery_at).await
}
/// Discover the workspace starting from the directory containing the `pyproject.toml`.
pub async fn from_project_root(project_root: &Path) -> Result<Self, WorkspaceError> {
pub async fn from_project_root(
project_root: &Path,
stop_discovery_at: Option<&Path>,
) -> Result<Self, WorkspaceError> {
// Read the current `pyproject.toml`.
let pyproject_path = project_root.join("pyproject.toml");
let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
@ -208,13 +223,20 @@ impl ProjectWorkspace {
.clone()
.ok_or_else(|| WorkspaceError::MissingProject(pyproject_path.clone()))?;
Self::from_project(project_root, &pyproject_toml, project.name).await
Self::from_project(
project_root,
&pyproject_toml,
project.name,
stop_discovery_at,
)
.await
}
/// If the current directory contains a `pyproject.toml` with a `project` table, discover the
/// workspace and return it, otherwise it is a dynamic path dependency and we return `Ok(None)`.
pub async fn from_maybe_project_root(
project_root: &Path,
stop_discovery_at: Option<&Path>,
) -> Result<Option<Self>, WorkspaceError> {
// Read the `pyproject.toml`.
let pyproject_path = project_root.join("pyproject.toml");
@ -232,7 +254,13 @@ impl ProjectWorkspace {
};
Ok(Some(
Self::from_project(project_root, &pyproject_toml, project.name).await?,
Self::from_project(
project_root,
&pyproject_toml,
project.name,
stop_discovery_at,
)
.await?,
))
}
@ -279,10 +307,11 @@ impl ProjectWorkspace {
}
/// Find the workspace for a project.
async fn from_project(
pub async fn from_project(
project_path: &Path,
project: &PyProjectToml,
project_name: PackageName,
stop_discovery_at: Option<&Path>,
) -> Result<Self, WorkspaceError> {
let project_path = absolutize_path(project_path)
.map_err(WorkspaceError::Normalize)?
@ -322,7 +351,7 @@ impl ProjectWorkspace {
if workspace.is_none() {
// The project isn't an explicit workspace root, check if we're a regular workspace
// member by looking for an explicit workspace root above.
workspace = find_workspace(&project_path).await?;
workspace = find_workspace(&project_path, stop_discovery_at).await?;
}
let Some((workspace_root, workspace_definition, workspace_pyproject_toml)) = workspace
@ -410,7 +439,7 @@ impl ProjectWorkspace {
.and_then(|uv| uv.sources.clone())
.unwrap_or_default();
check_nested_workspaces(&workspace_root);
check_nested_workspaces(&workspace_root, stop_discovery_at);
Ok(Self {
project_root: project_path.clone(),
@ -454,9 +483,19 @@ impl ProjectWorkspace {
/// Find the workspace root above the current project, if any.
async fn find_workspace(
project_root: &Path,
stop_discovery_at: Option<&Path>,
) -> Result<Option<(PathBuf, ToolUvWorkspace, PyProjectToml)>, WorkspaceError> {
// Skip 1 to ignore the current project itself.
for workspace_root in project_root.ancestors().skip(1) {
for workspace_root in project_root
.ancestors()
.take_while(|path| {
// Only walk up the given directory, if any.
stop_discovery_at
.map(|stop_discovery_at| stop_discovery_at != *path)
.unwrap_or(true)
})
.skip(1)
{
let pyproject_path = workspace_root.join("pyproject.toml");
if !pyproject_path.is_file() {
continue;
@ -529,8 +568,17 @@ async fn find_workspace(
}
/// Warn when the valid workspace is included in another workspace.
fn check_nested_workspaces(inner_workspace_root: &Path) {
for outer_workspace_root in inner_workspace_root.ancestors().skip(1) {
fn check_nested_workspaces(inner_workspace_root: &Path, stop_discovery_at: Option<&Path>) {
for outer_workspace_root in inner_workspace_root
.ancestors()
.take_while(|path| {
// Only walk up the given directory, if any.
stop_discovery_at
.map(|stop_discovery_at| stop_discovery_at != *path)
.unwrap_or(true)
})
.skip(1)
{
let pyproject_toml_path = outer_workspace_root.join("pyproject.toml");
if !pyproject_toml_path.is_file() {
continue;
@ -636,7 +684,7 @@ mod tests {
.unwrap()
.join("scripts")
.join("workspaces");
let project = ProjectWorkspace::discover(root_dir.join(folder))
let project = ProjectWorkspace::discover(root_dir.join(folder), None)
.await
.unwrap();
let root_escaped = regex::escape(root_dir.to_string_lossy().as_ref());

View file

@ -35,7 +35,7 @@ pub(crate) async fn lock(
}
// Find the project requirements.
let project = ProjectWorkspace::discover(std::env::current_dir()?).await?;
let project = ProjectWorkspace::discover(std::env::current_dir()?, None).await?;
// Discover or create the virtual environment.
let venv = project::init_environment(&project, preview, cache, printer)?;

View file

@ -45,7 +45,7 @@ pub(crate) async fn run(
} else {
debug!("Syncing project environment.");
let project = ProjectWorkspace::discover(std::env::current_dir()?).await?;
let project = ProjectWorkspace::discover(std::env::current_dir()?, None).await?;
let venv = project::init_environment(&project, preview, cache, printer)?;
// Lock and sync the environment.

View file

@ -34,7 +34,7 @@ pub(crate) async fn sync(
}
// Find the project requirements.
let project = ProjectWorkspace::discover(std::env::current_dir()?).await?;
let project = ProjectWorkspace::discover(std::env::current_dir()?, None).await?;
// Discover or create the virtual environment.
let venv = project::init_environment(&project, preview, cache, printer)?;

View file

@ -4977,7 +4977,8 @@ fn tool_uv_sources_is_in_preview() -> Result<()> {
----- stdout -----
----- stderr -----
error: `tool.uv.sources` is a preview feature; use `--preview` or set `UV_PREVIEW=1` to enable it
error: Failed to parse entry for: `tqdm`
Caused by: `tool.uv.sources` is a preview feature; use `--preview` or set `UV_PREVIEW=1` to enable it
"###
);