Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 27 additions & 8 deletions crates/uv/src/commands/project/check.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::path::{Path, PathBuf};
use std::str::FromStr;

use anyhow::Result;
use anyhow::{Context, Result};
use tracing::debug;

use uv_cache::Cache;
Expand All @@ -10,7 +10,7 @@ use uv_configuration::{
Concurrency, DependencyGroups, DependencyGroupsWithDefaults, DryRun, ExtrasSpecification,
InstallOptions,
};
use uv_fs::normalize_path;
use uv_fs::{Simplified, normalize_path};
use uv_normalize::{DEV_DEPENDENCIES, DefaultExtras, PackageName};
use uv_preview::{Preview, PreviewFeature};
use uv_python::{
Expand All @@ -34,7 +34,7 @@ use crate::commands::project::{
validate_project_requires_python,
};
use crate::commands::reporters::PythonDownloadReporter;
use crate::commands::workspace::list::find_scripts;
use crate::commands::workspace::list::{ScriptDiscoveryError, find_scripts};
use crate::commands::{ExitStatus, diagnostics, project};
use crate::printer::Printer;
use crate::settings::{FrozenSource, LockCheck, ResolverInstallerSettings};
Expand Down Expand Up @@ -275,13 +275,32 @@ pub(crate) async fn check(
// selected workspace members are not checked against the project environment.
if let Some(project) = project.as_ref() {
excluded_targets.extend(
find_scripts(project.workspace().install_path(), cache)?
.into_iter()
.filter(|script| {
check_targets
find_scripts(project.workspace().install_path(), cache)
.filter_map(|script| match script {
Ok(script) => check_targets
.iter()
.any(|target| script.starts_with(target))
}),
.then_some(Ok(script)),
Err(ScriptDiscoveryError::Parse { path, source }) => {
debug!(
"Excluding invalid PEP 723 script `{}` while checking project root `{}`: {source}",
path.simplified_display(),
project.root().simplified_display(),
);
check_targets
.iter()
.any(|target| path.starts_with(target))
.then_some(Ok(path))
}
Err(err) => Some(Err(err)),
})
.collect::<Result<Vec<_>, _>>()
.with_context(|| {
format!(
"Failed to discover PEP 723 scripts while checking project root `{}`",
project.root().simplified_display()
)
})?,
);
}

Expand Down
95 changes: 63 additions & 32 deletions crates/uv/src/commands/workspace/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result};

use owo_colors::OwoColorize;
use thiserror::Error;
use uv_cache::Cache;
use uv_fs::{CWD, Simplified, ValidatedReader, is_virtualenv_base, normalize_path};
use uv_preview::{Preview, PreviewFeature};
use uv_scripts::Pep723Metadata;
use uv_scripts::{Pep723Error, Pep723Metadata};
use uv_warnings::warn_user;
use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceCache};

Expand Down Expand Up @@ -42,7 +43,16 @@ pub(crate) async fn list(
.await?;

if scripts {
for script in find_scripts(workspace.install_path(), cache)? {
let mut scripts = find_scripts(workspace.install_path(), cache)
.collect::<Result<Vec<_>, _>>()
.with_context(|| {
format!(
"Failed to discover PEP 723 scripts under workspace root `{}`",
workspace.install_path().simplified_display()
)
})?;
scripts.sort_unstable();
for script in scripts {
let script = script
.strip_prefix(workspace.install_path())
.context("PEP 723 script was discovered outside the workspace root")?;
Expand All @@ -66,11 +76,37 @@ pub(crate) async fn list(
Ok(ExitStatus::Success)
}

/// A failure encountered while discovering PEP 723 scripts.
#[derive(Debug, Error)]
pub(crate) enum ScriptDiscoveryError {
/// The workspace could not be traversed.
#[error("Failed to walk workspace while discovering PEP 723 scripts")]
Walk(#[source] ignore::Error),
/// A candidate script could not be read.
#[error("Failed to read candidate PEP 723 script: {}", path.simplified_display())]
Read {
path: PathBuf,
#[source]
source: io::Error,
},
/// A candidate script contains invalid PEP 723 metadata.
#[error("Failed to parse PEP 723 script: {}", path.simplified_display())]
Parse {
path: PathBuf,
#[source]
source: Pep723Error,
},
}

/// Find PEP 723 scripts under a workspace root.
///
/// Respects ignore files and excludes repository internals, virtual environments, and the uv cache
/// from traversal.
pub(crate) fn find_scripts(workspace_root: &Path, cache: &Cache) -> Result<Vec<PathBuf>> {
/// from traversal. Script-specific errors are returned individually so callers can decide whether
/// invalid candidates should fail discovery.
pub(crate) fn find_scripts(
workspace_root: &Path,
cache: &Cache,
) -> impl Iterator<Item = Result<PathBuf, ScriptDiscoveryError>> {
// Avoid descending into the cache when it is inside the workspace. If the workspace itself is
// inside the cache, it is still the requested search root and must not be excluded.
let cache_root = if cache.root().is_absolute() {
Expand Down Expand Up @@ -122,43 +158,38 @@ pub(crate) fn find_scripts(workspace_root: &Path, cache: &Cache) -> Result<Vec<P
// handled too.
!is_virtualenv_base(path)
});
let walker = builder.build();

let mut scripts = Vec::new();
for entry in walker {
let entry = entry.context("Failed to walk workspace while discovering PEP 723 scripts")?;
builder.build().filter_map(|entry| {
let entry = match entry {
Ok(entry) => entry,
Err(source) => return Some(Err(ScriptDiscoveryError::Walk(source))),
};
if !entry
.file_type()
.is_some_and(|file_type| file_type.is_file())
|| !is_python_script_path(entry.path())
{
continue;
return None;
}

let Some(contents) = read_script_candidate(entry.path()).with_context(|| {
format!(
"Failed to read candidate PEP 723 script: {}",
entry.path().simplified_display()
)
})?
else {
continue;
let contents = match read_script_candidate(entry.path()) {
Ok(Some(contents)) => contents,
Ok(None) => return None,
Err(source) => {
return Some(Err(ScriptDiscoveryError::Read {
path: entry.into_path(),
source,
}));
}
};
if Pep723Metadata::parse(&contents)
.with_context(|| {
format!(
"Failed to parse PEP 723 script: {}",
entry.path().simplified_display()
)
})?
.is_some()
{
scripts.push(entry.into_path());
match Pep723Metadata::parse(&contents) {
Ok(Some(_)) => Some(Ok(entry.into_path())),
Ok(None) => None,
Err(source) => Some(Err(ScriptDiscoveryError::Parse {
path: entry.into_path(),
source,
})),
}
}

scripts.sort_unstable();
Ok(scripts)
})
}

/// Read a candidate script.
Expand Down
47 changes: 47 additions & 0 deletions crates/uv/tests/project/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,53 @@ fn check_workspace_excludes_pep723_scripts() -> Result<()> {
Ok(())
}

/// Invalid inline metadata should not prevent checking the surrounding project.
#[test]
fn check_project_ignores_invalid_pep723_scripts() -> Result<()> {
let context = uv_test::test_context!("3.12");
context
.temp_dir
.child("pyproject.toml")
.write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
"#})?;
context.temp_dir.child("main.py").write_str("value = 1\n")?;
context
.temp_dir
.child("fixtures/invalid-script.py")
.write_str(indoc! {r"
# /// script
# dependencies = []
# ///

# /// script
# dependencies = []
# ///
value: str = 1
"})?;

uv_snapshot!(context.filters(), context.check(), @"
exit_code: 0 (success)
----- stdout -----
All checks passed!

----- stderr -----
warning: `uv check` is experimental and may change without warning. Pass `--preview-features check-command` to disable this warning.
");

uv_snapshot!(context.filters(), context.check().arg("--script").arg("fixtures/invalid-script.py"), @"
exit_code: 2 (failure)
----- stderr -----
error: The script contains multiple PEP 723 metadata blocks
");

Ok(())
}

/// Check only the selected workspace member, whether selected implicitly or explicitly.
#[test]
fn check_workspace_member_selection() -> Result<()> {
Expand Down
39 changes: 39 additions & 0 deletions crates/uv/tests/workspace/workspace_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -364,3 +364,42 @@ fn workspace_list_scripts() -> Result<()> {

Ok(())
}

/// Explicit script discovery should identify both the invalid script and the workspace root.
#[test]
fn workspace_list_scripts_invalid_metadata() -> Result<()> {
let context = uv_test::test_context!("3.12");
context
.temp_dir
.child("pyproject.toml")
.write_str(indoc::indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
"#})?;
context
.temp_dir
.child("fixtures/invalid-script.py")
.write_str(indoc::indoc! {r"
# /// script
# dependencies = []
# ///

# /// script
# dependencies = []
# ///
"})?;

uv_snapshot!(context.filters(), context.workspace_list().arg("--scripts"), @"
exit_code: 2 (failure)
----- stderr -----
warning: The `--scripts` option is experimental and may change without warning. Pass `--preview-features workspace-list-scripts` to disable this warning.
error: Failed to discover PEP 723 scripts under workspace root `[TEMP_DIR]/`
Caused by: Failed to parse PEP 723 script: [TEMP_DIR]/fixtures/invalid-script.py
Caused by: The script contains multiple PEP 723 metadata blocks
");

Ok(())
}
Loading