Add support for locking PEP 723 scripts (#10135)

## Summary

You can now run `uv lock --script main.py` to lock a given script
(though as of this PR, the script itself isn't used anywhere).

Closes https://github.com/astral-sh/uv/issues/6318.
This commit is contained in:
Charlie Marsh 2025-01-08 13:36:53 -05:00 committed by GitHub
parent 2f7f9ea571
commit 5d33b94c42
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 434 additions and 23 deletions

View File

@ -3108,6 +3108,13 @@ pub struct LockArgs {
#[arg(long, conflicts_with = "check_exists", conflicts_with = "check")]
pub dry_run: bool,
/// Lock the specified Python script, rather than the current project.
///
/// If provided, uv will lock the script (based on its inline metadata table, in adherence with
/// PEP 723) to a `.lock` file adjacent to the script itself.
#[arg(long)]
pub script: Option<PathBuf>,
#[command(flatten)]
pub resolver: ResolverArgs,

View File

@ -32,6 +32,7 @@ use uv_resolver::{
FlatIndex, InMemoryIndex, Lock, Options, OptionsBuilder, PythonRequirement, RequiresPython,
ResolverEnvironment, ResolverManifest, SatisfiesResult, UniversalMarker,
};
use uv_scripts::{Pep723ItemRef, Pep723Script};
use uv_settings::PythonInstallMirrors;
use uv_types::{BuildContext, BuildIsolation, EmptyInstalledPackages, HashStrategy};
use uv_warnings::{warn_user, warn_user_once};
@ -39,7 +40,7 @@ use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceMember};
use crate::commands::pip::loggers::{DefaultResolveLogger, ResolveLogger, SummaryResolveLogger};
use crate::commands::project::lock_target::LockTarget;
use crate::commands::project::{ProjectError, ProjectInterpreter};
use crate::commands::project::{ProjectError, ProjectInterpreter, ScriptInterpreter};
use crate::commands::reporters::ResolverReporter;
use crate::commands::{diagnostics, pip, ExitStatus};
use crate::printer::Printer;
@ -80,6 +81,7 @@ pub(crate) async fn lock(
python: Option<String>,
install_mirrors: PythonInstallMirrors,
settings: ResolverSettings,
script: Option<Pep723Script>,
python_preference: PythonPreference,
python_downloads: PythonDownloads,
connectivity: Connectivity,
@ -92,29 +94,52 @@ pub(crate) async fn lock(
preview: PreviewMode,
) -> anyhow::Result<ExitStatus> {
// Find the project requirements.
let workspace = Workspace::discover(project_dir, &DiscoveryOptions::default()).await?;
let workspace;
let target = if let Some(script) = script.as_ref() {
LockTarget::Script(script)
} else {
workspace = Workspace::discover(project_dir, &DiscoveryOptions::default()).await?;
LockTarget::Workspace(&workspace)
};
// Determine the lock mode.
let interpreter;
let mode = if frozen {
LockMode::Frozen
} else {
interpreter = ProjectInterpreter::discover(
&workspace,
project_dir,
python.as_deref().map(PythonRequest::parse),
python_preference,
python_downloads,
connectivity,
native_tls,
allow_insecure_host,
&install_mirrors,
no_config,
cache,
printer,
)
.await?
.into_interpreter();
interpreter = match target {
LockTarget::Workspace(workspace) => ProjectInterpreter::discover(
workspace,
project_dir,
python.as_deref().map(PythonRequest::parse),
python_preference,
python_downloads,
connectivity,
native_tls,
allow_insecure_host,
&install_mirrors,
no_config,
cache,
printer,
)
.await?
.into_interpreter(),
LockTarget::Script(script) => ScriptInterpreter::discover(
Pep723ItemRef::Script(script),
python.as_deref().map(PythonRequest::parse),
python_preference,
python_downloads,
connectivity,
native_tls,
allow_insecure_host,
&install_mirrors,
no_config,
cache,
printer,
)
.await?
.into_interpreter(),
};
if locked {
LockMode::Locked(&interpreter)
@ -131,7 +156,7 @@ pub(crate) async fn lock(
// Perform the lock operation.
match do_safe_lock(
mode,
(&workspace).into(),
target,
settings.as_ref(),
LowerBound::Warn,
&state,

View File

@ -1,12 +1,16 @@
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use itertools::Either;
use uv_configuration::{LowerBound, SourceStrategy};
use uv_distribution::LoweredRequirement;
use uv_distribution_types::IndexLocations;
use uv_normalize::{GroupName, PackageName};
use uv_pep508::RequirementOrigin;
use uv_pypi_types::{Conflicts, Requirement, SupportedEnvironments, VerbatimParsedUrl};
use uv_resolver::{Lock, LockVersion, RequiresPython, VERSION};
use uv_scripts::Pep723Script;
use uv_workspace::dependency_groups::DependencyGroupError;
use uv_workspace::{Workspace, WorkspaceMember};
@ -16,6 +20,7 @@ use crate::commands::project::{find_requires_python, ProjectError};
#[derive(Debug, Copy, Clone)]
pub(crate) enum LockTarget<'lock> {
Workspace(&'lock Workspace),
Script(&'lock Pep723Script),
}
impl<'lock> From<&'lock Workspace> for LockTarget<'lock> {
@ -24,12 +29,19 @@ impl<'lock> From<&'lock Workspace> for LockTarget<'lock> {
}
}
impl<'lock> From<&'lock Pep723Script> for LockTarget<'lock> {
fn from(script: &'lock Pep723Script) -> Self {
LockTarget::Script(script)
}
}
impl<'lock> LockTarget<'lock> {
/// Return the set of requirements that are attached to the target directly, as opposed to being
/// attached to any members within the target.
pub(crate) fn requirements(self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
match self {
Self::Workspace(workspace) => workspace.requirements(),
Self::Script(script) => script.metadata.dependencies.clone().unwrap_or_default(),
}
}
@ -37,6 +49,16 @@ impl<'lock> LockTarget<'lock> {
pub(crate) fn overrides(self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
match self {
Self::Workspace(workspace) => workspace.overrides(),
Self::Script(script) => script
.metadata
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.override_dependencies.as_ref())
.into_iter()
.flatten()
.cloned()
.collect(),
}
}
@ -44,6 +66,16 @@ impl<'lock> LockTarget<'lock> {
pub(crate) fn constraints(self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
match self {
Self::Workspace(workspace) => workspace.constraints(),
Self::Script(script) => script
.metadata
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.constraint_dependencies.as_ref())
.into_iter()
.flatten()
.cloned()
.collect(),
}
}
@ -57,20 +89,23 @@ impl<'lock> LockTarget<'lock> {
> {
match self {
Self::Workspace(workspace) => workspace.dependency_groups(),
Self::Script(_) => Ok(BTreeMap::new()),
}
}
/// Returns the set of all members within the target.
pub(crate) fn members_requirements(self) -> impl Iterator<Item = Requirement> + 'lock {
match self {
Self::Workspace(workspace) => workspace.members_requirements(),
Self::Workspace(workspace) => Either::Left(workspace.members_requirements()),
Self::Script(_) => Either::Right(std::iter::empty()),
}
}
/// Returns the set of all dependency groups within the target.
pub(crate) fn group_requirements(self) -> impl Iterator<Item = Requirement> + 'lock {
match self {
Self::Workspace(workspace) => workspace.group_requirements(),
Self::Workspace(workspace) => Either::Left(workspace.group_requirements()),
Self::Script(_) => Either::Right(std::iter::empty()),
}
}
@ -90,6 +125,7 @@ impl<'lock> LockTarget<'lock> {
members
}
Self::Script(_) => Vec::new(),
}
}
@ -97,6 +133,10 @@ impl<'lock> LockTarget<'lock> {
pub(crate) fn packages(self) -> &'lock BTreeMap<PackageName, WorkspaceMember> {
match self {
Self::Workspace(workspace) => workspace.packages(),
Self::Script(_) => {
static EMPTY: BTreeMap<PackageName, WorkspaceMember> = BTreeMap::new();
&EMPTY
}
}
}
@ -104,6 +144,10 @@ impl<'lock> LockTarget<'lock> {
pub(crate) fn environments(self) -> Option<&'lock SupportedEnvironments> {
match self {
Self::Workspace(workspace) => workspace.environments(),
Self::Script(_) => {
// TODO(charlie): Add support for environments in scripts.
None
}
}
}
@ -111,6 +155,7 @@ impl<'lock> LockTarget<'lock> {
pub(crate) fn conflicts(self) -> Conflicts {
match self {
Self::Workspace(workspace) => workspace.conflicts(),
Self::Script(_) => Conflicts::empty(),
}
}
@ -118,6 +163,11 @@ impl<'lock> LockTarget<'lock> {
pub(crate) fn requires_python(self) -> Option<RequiresPython> {
match self {
Self::Workspace(workspace) => find_requires_python(workspace),
Self::Script(script) => script
.metadata
.requires_python
.as_ref()
.map(RequiresPython::from_specifiers),
}
}
@ -125,13 +175,24 @@ impl<'lock> LockTarget<'lock> {
pub(crate) fn install_path(self) -> &'lock Path {
match self {
Self::Workspace(workspace) => workspace.install_path(),
Self::Script(script) => script.path.parent().unwrap(),
}
}
/// Return the path to the lockfile.
pub(crate) fn lock_path(self) -> PathBuf {
match self {
// `uv.lock`
Self::Workspace(workspace) => workspace.install_path().join("uv.lock"),
// `script.py.lock`
Self::Script(script) => {
let mut file_name = match script.path.file_name() {
Some(f) => f.to_os_string(),
None => panic!("Script path has no file name"),
};
file_name.push(".lock");
script.path.with_file_name(file_name)
}
}
}
@ -223,6 +284,55 @@ impl<'lock> LockTarget<'lock> {
.map(|requirement| requirement.with_origin(RequirementOrigin::Workspace))
.collect::<Vec<_>>())
}
Self::Script(script) => {
// Collect any `tool.uv.index` from the script.
let empty = Vec::default();
let indexes = match sources {
SourceStrategy::Enabled => script
.metadata
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.top_level.index.as_deref())
.unwrap_or(&empty),
SourceStrategy::Disabled => &empty,
};
// Collect any `tool.uv.sources` from the script.
let empty = BTreeMap::default();
let sources = match sources {
SourceStrategy::Enabled => script
.metadata
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.sources.as_ref())
.unwrap_or(&empty),
SourceStrategy::Disabled => &empty,
};
Ok(requirements
.into_iter()
.flat_map(|requirement| {
let requirement_name = requirement.name.clone();
LoweredRequirement::from_non_workspace_requirement(
requirement,
script.path.parent().unwrap(),
sources,
indexes,
locations,
LowerBound::Allow,
)
.map(move |requirement| match requirement {
Ok(requirement) => Ok(requirement.into_inner()),
Err(err) => Err(uv_distribution::MetadataError::LoweringError(
requirement_name.clone(),
Box::new(err),
)),
})
})
.collect::<Result<_, _>>()?)
}
}
}
}

View File

@ -185,6 +185,12 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
script: Some(script),
..
}) = &**command
{
Pep723Script::read(&script).await?.map(Pep723Item::Script)
} else if let ProjectCommand::Lock(uv_cli::LockArgs {
script: Some(script),
..
}) = &**command
{
Pep723Script::read(&script).await?.map(Pep723Item::Script)
} else {
@ -1508,7 +1514,14 @@ async fn run_project(
.combine(Refresh::from(args.settings.upgrade.clone())),
);
commands::lock(
// Unwrap the script.
let script = script.map(|script| match script {
Pep723Item::Script(script) => script,
Pep723Item::Stdin(_) => unreachable!("`uv lock` does not support stdin"),
Pep723Item::Remote(_) => unreachable!("`uv lock` does not support remote files"),
});
Box::pin(commands::lock(
project_dir,
args.locked,
args.frozen,
@ -1516,6 +1529,7 @@ async fn run_project(
args.python,
args.install_mirrors,
args.settings,
script,
globals.python_preference,
globals.python_downloads,
globals.connectivity,
@ -1526,7 +1540,7 @@ async fn run_project(
&cache,
printer,
globals.preview,
)
))
.await
}
ProjectCommand::Add(args) => {

View File

@ -1023,6 +1023,7 @@ pub(crate) struct LockSettings {
pub(crate) locked: bool,
pub(crate) frozen: bool,
pub(crate) dry_run: bool,
pub(crate) script: Option<PathBuf>,
pub(crate) python: Option<String>,
pub(crate) install_mirrors: PythonInstallMirrors,
pub(crate) refresh: Refresh,
@ -1037,6 +1038,7 @@ impl LockSettings {
check,
check_exists,
dry_run,
script,
resolver,
build,
refresh,
@ -1052,6 +1054,7 @@ impl LockSettings {
locked: check,
frozen: check_exists,
dry_run,
script,
python: python.and_then(Maybe::into_option),
refresh: Refresh::from(refresh),
settings: ResolverSettings::combine(resolver_options(resolver, build), filesystem),

View File

@ -21360,6 +21360,254 @@ fn lock_missing_git_prefix() -> Result<()> {
Ok(())
}
#[test]
fn lock_script() -> Result<()> {
let context = TestContext::new("3.12");
let script = context.temp_dir.child("script.py");
script.write_str(indoc! { r#"
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "anyio",
# ]
# ///
import anyio
"#
})?;
uv_snapshot!(context.filters(), context.lock().arg("--script").arg("script.py"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 3 packages in [TIME]
"###);
let lock = context.read("script.py.lock");
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r###"
version = 1
requires-python = ">=3.11"
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[manifest]
requirements = [{ name = "anyio" }]
[[package]]
name = "anyio"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/db/4d/3970183622f0330d3c23d9b8a5f52e365e50381fd484d08e3285104333d3/anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6", size = 159642 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8", size = 85584 },
]
[[package]]
name = "idna"
version = "3.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567 },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
]
"###
);
});
// Re-run with `--locked`.
uv_snapshot!(context.filters(), context.lock().arg("--script").arg("script.py").arg("--locked"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 3 packages in [TIME]
"###);
// Modify the script metadata.
script.write_str(indoc! { r#"
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "anyio",
# "iniconfig",
# ]
# ///
import anyio
"#
})?;
// Re-run with `--locked`.
uv_snapshot!(context.filters(), context.lock().arg("--script").arg("script.py").arg("--locked"), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
Resolved 4 packages in [TIME]
error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`.
"###);
Ok(())
}
#[test]
fn lock_script_path() -> Result<()> {
let context = TestContext::new("3.12");
let script = context.temp_dir.child("script.py");
script.write_str(indoc! { r#"
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "anyio",
# "child",
# ]
#
# [tool.uv.sources]
# child = { path = "child" }
# ///
import anyio
"#
})?;
let child = context.temp_dir.child("child");
fs_err::create_dir_all(&child)?;
let pyproject_toml = child.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "child"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["iniconfig"]
[build-system]
requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta"
"#,
)?;
uv_snapshot!(context.filters(), context.lock().arg("--script").arg("script.py"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 5 packages in [TIME]
"###);
let lock = context.read("script.py.lock");
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r###"
version = 1
requires-python = ">=3.11"
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[manifest]
requirements = [
{ name = "anyio" },
{ name = "child", directory = "child" },
]
[[package]]
name = "anyio"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/db/4d/3970183622f0330d3c23d9b8a5f52e365e50381fd484d08e3285104333d3/anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6", size = 159642 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8", size = 85584 },
]
[[package]]
name = "child"
version = "0.1.0"
source = { directory = "child" }
dependencies = [
{ name = "iniconfig" },
]
[package.metadata]
requires-dist = [{ name = "iniconfig" }]
[[package]]
name = "idna"
version = "3.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567 },
]
[[package]]
name = "iniconfig"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
]
"###
);
});
// Re-run with `--locked`.
uv_snapshot!(context.filters(), context.lock().arg("--script").arg("script.py").arg("--locked"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 5 packages in [TIME]
"###);
Ok(())
}
#[test]
fn lock_pytorch_cpu() -> Result<()> {
let context = TestContext::new("3.12");

View File

@ -2135,6 +2135,10 @@ uv lock [OPTIONS]
<li><code>lowest-direct</code>: Resolve the lowest compatible version of any direct dependencies, and the highest compatible version of any transitive dependencies</li>
</ul>
</dd><dt><code>--script</code> <i>script</i></dt><dd><p>Lock the specified Python script, rather than the current project.</p>
<p>If provided, uv will lock the script (based on its inline metadata table, in adherence with PEP 723) to a <code>.lock</code> file adjacent to the script itself.</p>
</dd><dt><code>--upgrade</code>, <code>-U</code></dt><dd><p>Allow package upgrades, ignoring pinned versions in any existing output file. Implies <code>--refresh</code></p>
</dd><dt><code>--upgrade-package</code>, <code>-P</code> <i>upgrade-package</i></dt><dd><p>Allow upgrades for a specific package, ignoring pinned versions in any existing output file. Implies <code>--refresh-package</code></p>