Skip to content

Commit 1abfeb4

Browse files
committed
Add support for locking and installing scripts
1 parent c382f2f commit 1abfeb4

File tree

6 files changed

+192
-29
lines changed

6 files changed

+192
-29
lines changed

crates/uv-cli/src/lib.rs

+7
Original file line numberDiff line numberDiff line change
@@ -3089,6 +3089,13 @@ pub struct LockArgs {
30893089
#[arg(long, conflicts_with = "check_exists", conflicts_with = "check")]
30903090
pub dry_run: bool,
30913091

3092+
/// Lock the specified Python script, rather than the current project.
3093+
///
3094+
/// If provided, uv will lock the script based on its inline metadata table, in adherence
3095+
/// with PEP 723.
3096+
#[arg(long)]
3097+
pub script: Option<PathBuf>,
3098+
30923099
#[command(flatten)]
30933100
pub resolver: ResolverArgs,
30943101

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

+51-25
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,6 @@ use owo_colors::OwoColorize;
88
use rustc_hash::{FxBuildHasher, FxHashMap};
99
use tracing::debug;
1010

11-
use crate::commands::pip::loggers::{DefaultResolveLogger, ResolveLogger, SummaryResolveLogger};
12-
use crate::commands::project::lock_target::LockTarget;
13-
use crate::commands::project::{ProjectError, ProjectInterpreter};
14-
use crate::commands::reporters::ResolverReporter;
15-
use crate::commands::{diagnostics, pip, ExitStatus};
16-
use crate::printer::Printer;
17-
use crate::settings::{ResolverSettings, ResolverSettingsRef};
1811
use uv_cache::Cache;
1912
use uv_client::{Connectivity, FlatIndexClient, RegistryClientBuilder};
2013
use uv_configuration::{
@@ -38,11 +31,20 @@ use uv_resolver::{
3831
FlatIndex, InMemoryIndex, Lock, Options, OptionsBuilder, PythonRequirement, RequiresPython,
3932
ResolverEnvironment, ResolverManifest, SatisfiesResult, UniversalMarker,
4033
};
34+
use uv_scripts::{Pep723Item, Pep723Script};
4135
use uv_settings::PythonInstallMirrors;
4236
use uv_types::{BuildContext, BuildIsolation, EmptyInstalledPackages, HashStrategy};
4337
use uv_warnings::{warn_user, warn_user_once};
4438
use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceMember};
4539

40+
use crate::commands::pip::loggers::{DefaultResolveLogger, ResolveLogger, SummaryResolveLogger};
41+
use crate::commands::project::lock_target::LockTarget;
42+
use crate::commands::project::{ProjectError, ProjectInterpreter, ScriptInterpreter};
43+
use crate::commands::reporters::ResolverReporter;
44+
use crate::commands::{diagnostics, pip, ExitStatus};
45+
use crate::printer::Printer;
46+
use crate::settings::{ResolverSettings, ResolverSettingsRef};
47+
4648
/// The result of running a lock operation.
4749
#[derive(Debug, Clone)]
4850
pub(crate) enum LockResult {
@@ -78,6 +80,7 @@ pub(crate) async fn lock(
7880
python: Option<String>,
7981
install_mirrors: PythonInstallMirrors,
8082
settings: ResolverSettings,
83+
script: Option<Pep723Script>,
8184
python_preference: PythonPreference,
8285
python_downloads: PythonDownloads,
8386
connectivity: Connectivity,
@@ -90,29 +93,52 @@ pub(crate) async fn lock(
9093
preview: PreviewMode,
9194
) -> anyhow::Result<ExitStatus> {
9295
// Find the project requirements.
93-
let workspace = Workspace::discover(project_dir, &DiscoveryOptions::default()).await?;
96+
let workspace;
97+
let target = if let Some(script) = script.as_ref() {
98+
LockTarget::Script(script)
99+
} else {
100+
workspace = Workspace::discover(project_dir, &DiscoveryOptions::default()).await?;
101+
LockTarget::Workspace(&workspace)
102+
};
94103

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

117143
if locked {
118144
LockMode::Locked(&interpreter)
@@ -129,7 +155,7 @@ pub(crate) async fn lock(
129155
// Perform the lock operation.
130156
match do_safe_lock(
131157
mode,
132-
(&workspace).into(),
158+
target,
133159
settings.as_ref(),
134160
LowerBound::Warn,
135161
&state,

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

+111-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::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,6 +29,12 @@ 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
/// Returns any requirements that are exclusive to the workspace root, i.e., not included in
2940
/// any of the workspace members.
@@ -32,20 +43,41 @@ impl<'lock> LockTarget<'lock> {
3243
) -> Result<Vec<uv_pep508::Requirement<VerbatimParsedUrl>>, DependencyGroupError> {
3344
match self {
3445
Self::Workspace(workspace) => workspace.non_project_requirements(),
46+
Self::Script(script) => Ok(script.metadata.dependencies.clone().unwrap_or_default()),
3547
}
3648
}
3749

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

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

@@ -83,6 +115,55 @@ impl<'lock> LockTarget<'lock> {
83115
.map(|requirement| requirement.with_origin(RequirementOrigin::Workspace))
84116
.collect::<Vec<_>>())
85117
}
118+
Self::Script(script) => {
119+
// Collect any `tool.uv.index` from the script.
120+
let empty = Vec::default();
121+
let indexes = match sources {
122+
SourceStrategy::Enabled => script
123+
.metadata
124+
.tool
125+
.as_ref()
126+
.and_then(|tool| tool.uv.as_ref())
127+
.and_then(|uv| uv.top_level.index.as_deref())
128+
.unwrap_or(&empty),
129+
SourceStrategy::Disabled => &empty,
130+
};
131+
132+
// Collect any `tool.uv.sources` from the script.
133+
let empty = BTreeMap::default();
134+
let sources = match sources {
135+
SourceStrategy::Enabled => script
136+
.metadata
137+
.tool
138+
.as_ref()
139+
.and_then(|tool| tool.uv.as_ref())
140+
.and_then(|uv| uv.sources.as_ref())
141+
.unwrap_or(&empty),
142+
SourceStrategy::Disabled => &empty,
143+
};
144+
145+
Ok(requirements
146+
.into_iter()
147+
.flat_map(|requirement| {
148+
let requirement_name = requirement.name.clone();
149+
LoweredRequirement::from_non_workspace_requirement(
150+
requirement,
151+
script.path.parent().unwrap(),
152+
sources,
153+
indexes,
154+
locations,
155+
LowerBound::Allow,
156+
)
157+
.map(move |requirement| match requirement {
158+
Ok(requirement) => Ok(requirement.into_inner()),
159+
Err(err) => Err(uv_distribution::MetadataError::LoweringError(
160+
requirement_name.clone(),
161+
Box::new(err),
162+
)),
163+
})
164+
})
165+
.collect::<Result<_, _>>()?)
166+
}
86167
}
87168
}
88169

@@ -102,62 +183,90 @@ impl<'lock> LockTarget<'lock> {
102183

103184
members
104185
}
186+
Self::Script(_) => Vec::new(),
105187
}
106188
}
107189

108190
/// Return the list of packages.
109191
pub(crate) fn packages(self) -> &'lock BTreeMap<PackageName, WorkspaceMember> {
110192
match self {
111193
Self::Workspace(workspace) => workspace.packages(),
194+
Self::Script(_) => {
195+
static EMPTY: BTreeMap<PackageName, WorkspaceMember> = BTreeMap::new();
196+
&EMPTY
197+
}
112198
}
113199
}
114200

115201
/// Returns the set of supported environments for the [`LockTarget`].
116202
pub(crate) fn environments(self) -> Option<&'lock SupportedEnvironments> {
117203
match self {
118204
Self::Workspace(workspace) => workspace.environments(),
205+
Self::Script(_) => {
206+
// TODO(charlie): Add support for environments in scripts.
207+
None
208+
}
119209
}
120210
}
121211

122212
/// Returns the set of conflicts for the [`LockTarget`].
123213
pub(crate) fn conflicts(self) -> Conflicts {
124214
match self {
125215
Self::Workspace(workspace) => workspace.conflicts(),
216+
Self::Script(_) => Conflicts::empty(),
126217
}
127218
}
128219

129220
/// Return the `Requires-Python` bound for the [`LockTarget`].
130221
pub(crate) fn requires_python(self) -> Option<RequiresPython> {
131222
match self {
132223
Self::Workspace(workspace) => find_requires_python(workspace),
224+
Self::Script(script) => script
225+
.metadata
226+
.requires_python
227+
.as_ref()
228+
.map(RequiresPython::from_specifiers),
133229
}
134230
}
135231

136232
/// Returns the set of requirements that include all packages in the workspace.
137233
pub(crate) fn members_requirements(self) -> impl Iterator<Item = Requirement> + 'lock {
138234
match self {
139-
Self::Workspace(workspace) => workspace.members_requirements(),
235+
Self::Workspace(workspace) => Either::Left(workspace.members_requirements()),
236+
Self::Script(_) => Either::Right(std::iter::empty()),
140237
}
141238
}
142239

143240
/// Returns the set of requirements that include all packages in the workspace.
144241
pub(crate) fn group_requirements(self) -> impl Iterator<Item = Requirement> + 'lock {
145242
match self {
146-
Self::Workspace(workspace) => workspace.group_requirements(),
243+
Self::Workspace(workspace) => Either::Left(workspace.group_requirements()),
244+
Self::Script(_) => Either::Right(std::iter::empty()),
147245
}
148246
}
149247

150248
/// Return the path to the lock root.
151249
pub(crate) fn install_path(self) -> &'lock Path {
152250
match self {
153251
Self::Workspace(workspace) => workspace.install_path(),
252+
Self::Script(script) => script.path.parent().unwrap(),
154253
}
155254
}
156255

157256
/// Return the path to the lockfile.
158257
fn lock_path(self) -> PathBuf {
159258
match self {
259+
// `uv.lock`
160260
Self::Workspace(workspace) => workspace.install_path().join("uv.lock"),
261+
// `script.py.lock`
262+
Self::Script(script) => {
263+
let mut file_name = match script.path.file_name() {
264+
Some(f) => f.to_os_string(),
265+
None => panic!("Script path has no file name"),
266+
};
267+
file_name.push(".lock");
268+
script.path.with_file_name(file_name)
269+
}
161270
}
162271
}
163272

0 commit comments

Comments
 (0)