mirror of
https://github.com/astral-sh/uv.git
synced 2025-08-04 19:08:04 +00:00
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:
parent
9bb0679618
commit
3c074142f5
9 changed files with 335 additions and 19 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -4659,6 +4659,7 @@ dependencies = [
|
|||
"fs-err",
|
||||
"futures",
|
||||
"glob",
|
||||
"indoc",
|
||||
"insta",
|
||||
"install-wheel-rs",
|
||||
"nanoid",
|
||||
|
|
|
@ -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 }
|
||||
|
||||
|
|
|
@ -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<_, _>>()?;
|
||||
|
||||
|
|
|
@ -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
|
||||
"###);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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)?;
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)?;
|
||||
|
|
|
@ -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
|
||||
"###
|
||||
);
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue