Skip to content

Commit cede350

Browse files
committed
Add support for locking and installing scripts
1 parent 2f7f9ea commit cede350

File tree

7 files changed

+434
-23
lines changed

7 files changed

+434
-23
lines changed

crates/uv-cli/src/lib.rs

+7
Original file line numberDiff line numberDiff line change
@@ -3108,6 +3108,13 @@ pub struct LockArgs {
31083108
#[arg(long, conflicts_with = "check_exists", conflicts_with = "check")]
31093109
pub dry_run: bool,
31103110

3111+
/// Lock the specified Python script, rather than the current project.
3112+
///
3113+
/// If provided, uv will lock the script (based on its inline metadata table, in adherence with
3114+
/// PEP 723) to a `.lock` file adjacent to the script itself.
3115+
#[arg(long)]
3116+
pub script: Option<PathBuf>,
3117+
31113118
#[command(flatten)]
31123119
pub resolver: ResolverArgs,
31133120

crates/uv/src/commands/project/lock.rs

+44-19
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,15 @@ use uv_resolver::{
3232
FlatIndex, InMemoryIndex, Lock, Options, OptionsBuilder, PythonRequirement, RequiresPython,
3333
ResolverEnvironment, ResolverManifest, SatisfiesResult, UniversalMarker,
3434
};
35+
use uv_scripts::{Pep723ItemRef, Pep723Script};
3536
use uv_settings::PythonInstallMirrors;
3637
use uv_types::{BuildContext, BuildIsolation, EmptyInstalledPackages, HashStrategy};
3738
use uv_warnings::{warn_user, warn_user_once};
3839
use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceMember};
3940

4041
use crate::commands::pip::loggers::{DefaultResolveLogger, ResolveLogger, SummaryResolveLogger};
4142
use crate::commands::project::lock_target::LockTarget;
42-
use crate::commands::project::{ProjectError, ProjectInterpreter};
43+
use crate::commands::project::{ProjectError, ProjectInterpreter, ScriptInterpreter};
4344
use crate::commands::reporters::ResolverReporter;
4445
use crate::commands::{diagnostics, pip, ExitStatus};
4546
use crate::printer::Printer;
@@ -80,6 +81,7 @@ pub(crate) async fn lock(
8081
python: Option<String>,
8182
install_mirrors: PythonInstallMirrors,
8283
settings: ResolverSettings,
84+
script: Option<Pep723Script>,
8385
python_preference: PythonPreference,
8486
python_downloads: PythonDownloads,
8587
connectivity: Connectivity,
@@ -92,29 +94,52 @@ pub(crate) async fn lock(
9294
preview: PreviewMode,
9395
) -> anyhow::Result<ExitStatus> {
9496
// Find the project requirements.
95-
let workspace = Workspace::discover(project_dir, &DiscoveryOptions::default()).await?;
97+
let workspace;
98+
let target = if let Some(script) = script.as_ref() {
99+
LockTarget::Script(script)
100+
} else {
101+
workspace = Workspace::discover(project_dir, &DiscoveryOptions::default()).await?;
102+
LockTarget::Workspace(&workspace)
103+
};
96104

97105
// Determine the lock mode.
98106
let interpreter;
99107
let mode = if frozen {
100108
LockMode::Frozen
101109
} else {
102-
interpreter = ProjectInterpreter::discover(
103-
&workspace,
104-
project_dir,
105-
python.as_deref().map(PythonRequest::parse),
106-
python_preference,
107-
python_downloads,
108-
connectivity,
109-
native_tls,
110-
allow_insecure_host,
111-
&install_mirrors,
112-
no_config,
113-
cache,
114-
printer,
115-
)
116-
.await?
117-
.into_interpreter();
110+
interpreter = match target {
111+
LockTarget::Workspace(workspace) => ProjectInterpreter::discover(
112+
workspace,
113+
project_dir,
114+
python.as_deref().map(PythonRequest::parse),
115+
python_preference,
116+
python_downloads,
117+
connectivity,
118+
native_tls,
119+
allow_insecure_host,
120+
&install_mirrors,
121+
no_config,
122+
cache,
123+
printer,
124+
)
125+
.await?
126+
.into_interpreter(),
127+
LockTarget::Script(script) => ScriptInterpreter::discover(
128+
Pep723ItemRef::Script(script),
129+
python.as_deref().map(PythonRequest::parse),
130+
python_preference,
131+
python_downloads,
132+
connectivity,
133+
native_tls,
134+
allow_insecure_host,
135+
&install_mirrors,
136+
no_config,
137+
cache,
138+
printer,
139+
)
140+
.await?
141+
.into_interpreter(),
142+
};
118143

119144
if locked {
120145
LockMode::Locked(&interpreter)
@@ -131,7 +156,7 @@ pub(crate) async fn lock(
131156
// Perform the lock operation.
132157
match do_safe_lock(
133158
mode,
134-
(&workspace).into(),
159+
target,
135160
settings.as_ref(),
136161
LowerBound::Warn,
137162
&state,

crates/uv/src/commands/project/lock_target.rs

+112-2
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
use std::collections::BTreeMap;
22
use std::path::{Path, PathBuf};
33

4+
use itertools::Either;
5+
46
use uv_configuration::{LowerBound, SourceStrategy};
7+
use uv_distribution::LoweredRequirement;
58
use uv_distribution_types::IndexLocations;
69
use uv_normalize::{GroupName, PackageName};
710
use uv_pep508::RequirementOrigin;
811
use uv_pypi_types::{Conflicts, Requirement, SupportedEnvironments, VerbatimParsedUrl};
912
use uv_resolver::{Lock, LockVersion, RequiresPython, VERSION};
13+
use uv_scripts::Pep723Script;
1014
use uv_workspace::dependency_groups::DependencyGroupError;
1115
use uv_workspace::{Workspace, WorkspaceMember};
1216

@@ -16,6 +20,7 @@ use crate::commands::project::{find_requires_python, ProjectError};
1620
#[derive(Debug, Copy, Clone)]
1721
pub(crate) enum LockTarget<'lock> {
1822
Workspace(&'lock Workspace),
23+
Script(&'lock Pep723Script),
1924
}
2025

2126
impl<'lock> From<&'lock Workspace> for LockTarget<'lock> {
@@ -24,26 +29,53 @@ impl<'lock> From<&'lock Workspace> for LockTarget<'lock> {
2429
}
2530
}
2631

32+
impl<'lock> From<&'lock Pep723Script> for LockTarget<'lock> {
33+
fn from(script: &'lock Pep723Script) -> Self {
34+
LockTarget::Script(script)
35+
}
36+
}
37+
2738
impl<'lock> LockTarget<'lock> {
2839
/// Return the set of requirements that are attached to the target directly, as opposed to being
2940
/// attached to any members within the target.
3041
pub(crate) fn requirements(self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
3142
match self {
3243
Self::Workspace(workspace) => workspace.requirements(),
44+
Self::Script(script) => script.metadata.dependencies.clone().unwrap_or_default(),
3345
}
3446
}
3547

3648
/// Returns the set of overrides for the [`LockTarget`].
3749
pub(crate) fn overrides(self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
3850
match self {
3951
Self::Workspace(workspace) => workspace.overrides(),
52+
Self::Script(script) => script
53+
.metadata
54+
.tool
55+
.as_ref()
56+
.and_then(|tool| tool.uv.as_ref())
57+
.and_then(|uv| uv.override_dependencies.as_ref())
58+
.into_iter()
59+
.flatten()
60+
.cloned()
61+
.collect(),
4062
}
4163
}
4264

4365
/// Returns the set of constraints for the [`LockTarget`].
4466
pub(crate) fn constraints(self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
4567
match self {
4668
Self::Workspace(workspace) => workspace.constraints(),
69+
Self::Script(script) => script
70+
.metadata
71+
.tool
72+
.as_ref()
73+
.and_then(|tool| tool.uv.as_ref())
74+
.and_then(|uv| uv.constraint_dependencies.as_ref())
75+
.into_iter()
76+
.flatten()
77+
.cloned()
78+
.collect(),
4779
}
4880
}
4981

@@ -57,20 +89,23 @@ impl<'lock> LockTarget<'lock> {
5789
> {
5890
match self {
5991
Self::Workspace(workspace) => workspace.dependency_groups(),
92+
Self::Script(_) => Ok(BTreeMap::new()),
6093
}
6194
}
6295

6396
/// Returns the set of all members within the target.
6497
pub(crate) fn members_requirements(self) -> impl Iterator<Item = Requirement> + 'lock {
6598
match self {
66-
Self::Workspace(workspace) => workspace.members_requirements(),
99+
Self::Workspace(workspace) => Either::Left(workspace.members_requirements()),
100+
Self::Script(_) => Either::Right(std::iter::empty()),
67101
}
68102
}
69103

70104
/// Returns the set of all dependency groups within the target.
71105
pub(crate) fn group_requirements(self) -> impl Iterator<Item = Requirement> + 'lock {
72106
match self {
73-
Self::Workspace(workspace) => workspace.group_requirements(),
107+
Self::Workspace(workspace) => Either::Left(workspace.group_requirements()),
108+
Self::Script(_) => Either::Right(std::iter::empty()),
74109
}
75110
}
76111

@@ -90,48 +125,74 @@ impl<'lock> LockTarget<'lock> {
90125

91126
members
92127
}
128+
Self::Script(_) => Vec::new(),
93129
}
94130
}
95131

96132
/// Return the list of packages.
97133
pub(crate) fn packages(self) -> &'lock BTreeMap<PackageName, WorkspaceMember> {
98134
match self {
99135
Self::Workspace(workspace) => workspace.packages(),
136+
Self::Script(_) => {
137+
static EMPTY: BTreeMap<PackageName, WorkspaceMember> = BTreeMap::new();
138+
&EMPTY
139+
}
100140
}
101141
}
102142

103143
/// Returns the set of supported environments for the [`LockTarget`].
104144
pub(crate) fn environments(self) -> Option<&'lock SupportedEnvironments> {
105145
match self {
106146
Self::Workspace(workspace) => workspace.environments(),
147+
Self::Script(_) => {
148+
// TODO(charlie): Add support for environments in scripts.
149+
None
150+
}
107151
}
108152
}
109153

110154
/// Returns the set of conflicts for the [`LockTarget`].
111155
pub(crate) fn conflicts(self) -> Conflicts {
112156
match self {
113157
Self::Workspace(workspace) => workspace.conflicts(),
158+
Self::Script(_) => Conflicts::empty(),
114159
}
115160
}
116161

117162
/// Return the `Requires-Python` bound for the [`LockTarget`].
118163
pub(crate) fn requires_python(self) -> Option<RequiresPython> {
119164
match self {
120165
Self::Workspace(workspace) => find_requires_python(workspace),
166+
Self::Script(script) => script
167+
.metadata
168+
.requires_python
169+
.as_ref()
170+
.map(RequiresPython::from_specifiers),
121171
}
122172
}
123173

124174
/// Return the path to the lock root.
125175
pub(crate) fn install_path(self) -> &'lock Path {
126176
match self {
127177
Self::Workspace(workspace) => workspace.install_path(),
178+
Self::Script(script) => script.path.parent().unwrap(),
128179
}
129180
}
130181

131182
/// Return the path to the lockfile.
132183
pub(crate) fn lock_path(self) -> PathBuf {
133184
match self {
185+
// `uv.lock`
134186
Self::Workspace(workspace) => workspace.install_path().join("uv.lock"),
187+
// `script.py.lock`
188+
Self::Script(script) => {
189+
let mut file_name = match script.path.file_name() {
190+
Some(f) => f.to_os_string(),
191+
None => panic!("Script path has no file name"),
192+
};
193+
file_name.push(".lock");
194+
script.path.with_file_name(file_name)
195+
}
135196
}
136197
}
137198

@@ -223,6 +284,55 @@ impl<'lock> LockTarget<'lock> {
223284
.map(|requirement| requirement.with_origin(RequirementOrigin::Workspace))
224285
.collect::<Vec<_>>())
225286
}
287+
Self::Script(script) => {
288+
// Collect any `tool.uv.index` from the script.
289+
let empty = Vec::default();
290+
let indexes = match sources {
291+
SourceStrategy::Enabled => script
292+
.metadata
293+
.tool
294+
.as_ref()
295+
.and_then(|tool| tool.uv.as_ref())
296+
.and_then(|uv| uv.top_level.index.as_deref())
297+
.unwrap_or(&empty),
298+
SourceStrategy::Disabled => &empty,
299+
};
300+
301+
// Collect any `tool.uv.sources` from the script.
302+
let empty = BTreeMap::default();
303+
let sources = match sources {
304+
SourceStrategy::Enabled => script
305+
.metadata
306+
.tool
307+
.as_ref()
308+
.and_then(|tool| tool.uv.as_ref())
309+
.and_then(|uv| uv.sources.as_ref())
310+
.unwrap_or(&empty),
311+
SourceStrategy::Disabled => &empty,
312+
};
313+
314+
Ok(requirements
315+
.into_iter()
316+
.flat_map(|requirement| {
317+
let requirement_name = requirement.name.clone();
318+
LoweredRequirement::from_non_workspace_requirement(
319+
requirement,
320+
script.path.parent().unwrap(),
321+
sources,
322+
indexes,
323+
locations,
324+
LowerBound::Allow,
325+
)
326+
.map(move |requirement| match requirement {
327+
Ok(requirement) => Ok(requirement.into_inner()),
328+
Err(err) => Err(uv_distribution::MetadataError::LoweringError(
329+
requirement_name.clone(),
330+
Box::new(err),
331+
)),
332+
})
333+
})
334+
.collect::<Result<_, _>>()?)
335+
}
226336
}
227337
}
228338
}

crates/uv/src/lib.rs

+16-2
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,12 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
185185
script: Some(script),
186186
..
187187
}) = &**command
188+
{
189+
Pep723Script::read(&script).await?.map(Pep723Item::Script)
190+
} else if let ProjectCommand::Lock(uv_cli::LockArgs {
191+
script: Some(script),
192+
..
193+
}) = &**command
188194
{
189195
Pep723Script::read(&script).await?.map(Pep723Item::Script)
190196
} else {
@@ -1508,14 +1514,22 @@ async fn run_project(
15081514
.combine(Refresh::from(args.settings.upgrade.clone())),
15091515
);
15101516

1511-
commands::lock(
1517+
// Unwrap the script.
1518+
let script = script.map(|script| match script {
1519+
Pep723Item::Script(script) => script,
1520+
Pep723Item::Stdin(_) => unreachable!("`uv lock` does not support stdin"),
1521+
Pep723Item::Remote(_) => unreachable!("`uv lock` does not support remote files"),
1522+
});
1523+
1524+
Box::pin(commands::lock(
15121525
project_dir,
15131526
args.locked,
15141527
args.frozen,
15151528
args.dry_run,
15161529
args.python,
15171530
args.install_mirrors,
15181531
args.settings,
1532+
script,
15191533
globals.python_preference,
15201534
globals.python_downloads,
15211535
globals.connectivity,
@@ -1526,7 +1540,7 @@ async fn run_project(
15261540
&cache,
15271541
printer,
15281542
globals.preview,
1529-
)
1543+
))
15301544
.await
15311545
}
15321546
ProjectCommand::Add(args) => {

0 commit comments

Comments
 (0)