Skip to content
Open
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
1 change: 1 addition & 0 deletions crates/uv/src/commands/project/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,7 @@ impl<'env> LockOperation<'env> {
prev.map(Box::new),
Box::new(cur),
lock_source,
target.into(),
));
}

Expand Down
20 changes: 20 additions & 0 deletions crates/uv/src/commands/project/lock_target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,26 @@ pub(crate) enum LockTarget<'lock> {
Script(&'lock Pep723Script),
}

/// The owned subset of [`LockTarget`] needed by lock-mismatch diagnostics, which cannot retain
/// the borrowed target.
#[derive(Debug, Clone)]
pub(crate) struct LockTargetKind {
pub(crate) lock_filename: PathBuf,
pub(crate) script: Option<PathBuf>,
}

impl From<LockTarget<'_>> for LockTargetKind {
fn from(target: LockTarget<'_>) -> Self {
Self {
lock_filename: target.lock_filename(),
script: match target {
LockTarget::Workspace(_) => None,
LockTarget::Script(script) => Some(script.path.clone()),
},
}
}
}

impl<'lock> From<&'lock Workspace> for LockTarget<'lock> {
fn from(workspace: &'lock Workspace) -> Self {
Self::Workspace(workspace)
Expand Down
25 changes: 23 additions & 2 deletions crates/uv/src/commands/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ use uv_resolver::{
};
use uv_scripts::Pep723ItemRef;
use uv_settings::PythonInstallMirrors;
use uv_shell::shlex_posix;
use uv_static::EnvVars;
use uv_torch::{TorchSource, TorchStrategy};
use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy, SourceTreeEditablePolicy};
Expand All @@ -59,6 +60,7 @@ use uv_workspace::{ProjectEnvironmentSelection, RequiresPythonSources, Workspace
use crate::commands::pip::loggers::{InstallLogger, ResolveLogger};
use crate::commands::pip::operations::{Changelog, Modifications};
use crate::commands::project::install_target::InstallTarget;
use crate::commands::project::lock_target::LockTargetKind;
use crate::commands::reporters::{PythonDownloadReporter, ResolverReporter};
use crate::commands::{capitalize, conjunction, pip};
use crate::printer::Printer;
Expand Down Expand Up @@ -141,8 +143,13 @@ impl From<FrozenSource> for MissingLockfileSource {

#[derive(thiserror::Error, Debug)]
pub(crate) enum ProjectError {
#[error("The lockfile at `uv.lock` needs to be updated, but `{2}` was provided.")]
LockMismatch(Option<Box<Lock>>, Box<Lock>, LockCheckSource),
#[error("The lockfile at `{lockfile}` needs to be updated, but `{2}` was provided.", lockfile = .3.lock_filename.display())]
LockMismatch(
Option<Box<Lock>>,
Box<Lock>,
LockCheckSource,
LockTargetKind,
),

#[error(
"Unable to find lockfile at `{1}`, but {0} was provided. To create a lockfile, run `uv lock` or `uv sync` without the flag."
Expand Down Expand Up @@ -400,6 +407,20 @@ impl std::fmt::Display for MalwareFindings {
impl uv_errors::Hint for ProjectError {
fn hints(&self) -> uv_errors::Hints<'_> {
match self {
Self::LockMismatch(
_,
_,
_,
LockTargetKind {
script: Some(script),
..
},
) => {
let script = shlex_posix(script);
uv_errors::Hints::from(format!(
"To update the lockfile, run `uv lock --script {script}`."
))
}
Self::LockMismatch(..) | Self::LockWorkspaceMismatch(..) => {
uv_errors::Hints::from("To update the lockfile, run `uv lock`.")
}
Expand Down
32 changes: 21 additions & 11 deletions crates/uv/src/commands/project/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ use crate::commands::pip::resolution_markers;
use crate::commands::pip::{operations, resolution_tags};
use crate::commands::project::install_target::InstallTarget;
use crate::commands::project::lock::{LockMode, LockOperation, LockResult};
use crate::commands::project::lock_target::LockTarget;
use crate::commands::project::lock_target::{LockTarget, LockTargetKind};
use crate::commands::project::{
EnvironmentUpdate, LinkErrorReporting, MalwareFindings, PlatformState, ProjectEnvironment,
ProjectError, ScriptEnvironment, UniversalState, default_dependency_groups, detect_conflicts,
Expand Down Expand Up @@ -367,15 +367,19 @@ pub(crate) async fn sync(
.report(err)
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()));
}
Err(ProjectError::LockMismatch(prev, cur, lock_source)) => {
Err(ProjectError::LockMismatch(prev, cur, lock_source, target_kind)) => {
if dry_run.enabled() {
// The lockfile is mismatched, but we're in dry-run mode. We should proceed with the
// sync operation, but exit with a non-zero status.
Outcome::LockMismatch(prev, cur, lock_source)
Outcome::LockMismatch(prev, cur, lock_source, target_kind)
} else {
return Err(
UvError::user(ProjectError::LockMismatch(prev, cur, lock_source)).into(),
);
return Err(UvError::user(ProjectError::LockMismatch(
prev,
cur,
lock_source,
target_kind,
))
.into());
}
}
Err(err) => return Err(err.into()),
Expand Down Expand Up @@ -468,9 +472,10 @@ pub(crate) async fn sync(

match outcome {
Outcome::Success(..) => Ok(ExitStatus::Success),
Outcome::LockMismatch(prev, cur, lock_source) => {
Err(UvError::user(ProjectError::LockMismatch(prev, cur, lock_source)).into())
}
Outcome::LockMismatch(prev, cur, lock_source, target_kind) => Err(UvError::user(
ProjectError::LockMismatch(prev, cur, lock_source, target_kind),
)
.into()),
}
}

Expand All @@ -481,7 +486,12 @@ enum Outcome {
/// The `lock` operation was successful.
Success(LockResult),
/// The `lock` operation successfully resolved, but failed due to a mismatch (e.g., with `--locked`).
LockMismatch(Option<Box<Lock>>, Box<Lock>, LockCheckSource),
LockMismatch(
Option<Box<Lock>>,
Box<Lock>,
LockCheckSource,
LockTargetKind,
),
}

impl Outcome {
Expand All @@ -492,7 +502,7 @@ impl Outcome {
LockResult::Changed(_, lock) => lock,
LockResult::Unchanged(lock) => lock,
},
Self::LockMismatch(_prev, cur, _lock_source) => cur,
Self::LockMismatch(_prev, cur, _lock_source, _target_kind) => cur,
}
}
}
Expand Down
50 changes: 48 additions & 2 deletions crates/uv/tests/lock/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31135,9 +31135,55 @@ fn lock_script() -> Result<()> {

----- stderr -----
Resolved 4 packages in [TIME]
error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.
error: The lockfile at `script.py.lock` needs to be updated, but `--locked` was provided.

hint: To update the lockfile, run `uv lock`.
hint: To update the lockfile, run `uv lock --script script.py`.
");

Ok(())
}

#[cfg(feature = "test-universal")]
#[test]
fn lock_script_locked_hint_with_spaces() -> Result<()> {
let context = uv_test::test_context!("3.12");

let scripts = context.temp_dir.child("scripts");
scripts.create_dir_all()?;
let script = scripts.child("script with spaces.py");
script.write_str(indoc! { r#"
# /// script
# requires-python = ">=3.12"
# dependencies = []
# ///
"#
})?;

context
.lock()
.arg("--script")
.arg("scripts/script with spaces.py")
.assert()
.success();

script.write_str(indoc! { r#"
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"#
})?;

uv_snapshot!(context.filters(), context.lock().arg("--script").arg("scripts/script with spaces.py").arg("--locked"), @"
success: false
exit_code: 1
----- stdout -----

----- stderr -----
Resolved in [TIME]
error: The lockfile at `script with spaces.py.lock` needs to be updated, but `--locked` was provided.

hint: To update the lockfile, run `uv lock --script 'scripts/script with spaces.py'`.
");

Ok(())
Expand Down
4 changes: 2 additions & 2 deletions crates/uv/tests/project/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1205,9 +1205,9 @@ fn run_pep723_script_lock() -> Result<()> {

----- stderr -----
Resolved 3 packages in [TIME]
error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.
error: The lockfile at `main.py.lock` needs to be updated, but `--locked` was provided.

hint: To update the lockfile, run `uv lock`.
hint: To update the lockfile, run `uv lock --script main.py`.
");

// Re-running the script with `--frozen` should also error, but at runtime.
Expand Down
8 changes: 4 additions & 4 deletions crates/uv/tests/sync/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12682,9 +12682,9 @@ fn sync_locked_script() -> Result<()> {
----- stderr -----
Using script environment at: [CACHE_DIR]/environments-v2/script-[HASH]
Resolved 4 packages in [TIME]
error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.
error: The lockfile at `script.py.lock` needs to be updated, but `--locked` was provided.

hint: To update the lockfile, run `uv lock`.
hint: To update the lockfile, run `uv lock --script script.py`.
");

uv_snapshot!(context.filters(), context.sync().arg("--script").arg("script.py"), @"
Expand Down Expand Up @@ -12787,9 +12787,9 @@ fn sync_locked_script() -> Result<()> {
Updating script environment at: [CACHE_DIR]/environments-v2/script-[HASH]
warning: Resolving despite existing lockfile due to fork markers being disjoint with `requires-python`: `python_full_version >= '3.11'` vs `python_full_version >= '3.8' and python_full_version < '3.11'`
Resolved 6 packages in [TIME]
error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.
error: The lockfile at `script.py.lock` needs to be updated, but `--locked` was provided.

hint: To update the lockfile, run `uv lock`.
hint: To update the lockfile, run `uv lock --script script.py`.
");

uv_snapshot!(context.filters(), context.sync().arg("--script").arg("script.py"), @"
Expand Down
Loading