Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 0b7b597

Browse files
committedDec 25, 2024·
Respect PEP 723 script lockfiles in uv run
1 parent 9ca5fd2 commit 0b7b597

File tree

10 files changed

+658
-266
lines changed

10 files changed

+658
-266
lines changed
 

‎crates/uv-configuration/src/dev.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ impl From<GroupsSpecification> for DevGroupsSpecification {
316316

317317
/// The manifest of `dependency-groups` to include, taking into account the user-provided
318318
/// [`DevGroupsSpecification`] and the project-specific default groups.
319-
#[derive(Debug, Clone)]
319+
#[derive(Debug, Default, Clone)]
320320
pub struct DevGroupsManifest {
321321
/// The specification for the development dependencies.
322322
pub(crate) spec: DevGroupsSpecification,
@@ -347,7 +347,7 @@ impl DevGroupsManifest {
347347
}
348348

349349
/// Returns `true` if the group was enabled by default.
350-
pub fn default(&self, group: &GroupName) -> bool {
350+
pub fn is_default(&self, group: &GroupName) -> bool {
351351
if self.spec.contains(group) {
352352
// If the group was explicitly requested, then it wasn't enabled by default.
353353
false

‎crates/uv-scripts/src/lib.rs

+20
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ impl Pep723Item {
5454
Self::Remote(_) => None,
5555
}
5656
}
57+
58+
/// Return the PEP 723 script, if any.
59+
pub fn as_script(&self) -> Option<&Pep723Script> {
60+
match self {
61+
Self::Script(script) => Some(script),
62+
_ => None,
63+
}
64+
}
5765
}
5866

5967
/// A PEP 723 script, including its [`Pep723Metadata`].
@@ -193,6 +201,18 @@ impl Pep723Script {
193201

194202
Ok(())
195203
}
204+
205+
/// Return the [`Sources`] defined in the PEP 723 metadata.
206+
pub fn sources(&self) -> &BTreeMap<PackageName, Sources> {
207+
static EMPTY: BTreeMap<PackageName, Sources> = BTreeMap::new();
208+
209+
self.metadata
210+
.tool
211+
.as_ref()
212+
.and_then(|tool| tool.uv.as_ref())
213+
.and_then(|uv| uv.sources.as_ref())
214+
.unwrap_or(&EMPTY)
215+
}
196216
}
197217

198218
/// PEP 723 metadata as parsed from a `script` comment block.

‎crates/uv/src/commands/project/add.rs

+1
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,7 @@ pub(crate) async fn add(
593593
Target::Project(project, environment) => (project, environment),
594594
// If `--script`, exit early. There's no reason to lock and sync.
595595
Target::Script(script, _) => {
596+
// TODO(charlie): Lock the script, if a lockfile already exists.
596597
writeln!(
597598
printer.stderr(),
598599
"Updated `{}`",

‎crates/uv/src/commands/project/environment.rs

+119-19
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use tracing::debug;
22

33
use crate::commands::pip::loggers::{InstallLogger, ResolveLogger};
4+
use crate::commands::project::install_target::InstallTarget;
45
use crate::commands::project::{
56
resolve_environment, sync_environment, EnvironmentSpecification, ProjectError,
67
};
@@ -9,10 +10,13 @@ use crate::settings::ResolverInstallerSettings;
910
use uv_cache::{Cache, CacheBucket};
1011
use uv_cache_key::{cache_digest, hash_digest};
1112
use uv_client::Connectivity;
12-
use uv_configuration::{Concurrency, PreviewMode, TrustedHost};
13+
use uv_configuration::{
14+
Concurrency, DevGroupsManifest, ExtrasSpecification, InstallOptions, PreviewMode, TrustedHost,
15+
};
1316
use uv_dispatch::SharedState;
1417
use uv_distribution_types::{Name, Resolution};
1518
use uv_python::{Interpreter, PythonEnvironment};
19+
use uv_resolver::Installable;
1620

1721
/// A [`PythonEnvironment`] stored in the cache.
1822
#[derive(Debug)]
@@ -25,9 +29,8 @@ impl From<CachedEnvironment> for PythonEnvironment {
2529
}
2630

2731
impl CachedEnvironment {
28-
/// Get or create an [`CachedEnvironment`] based on a given set of requirements and a base
29-
/// interpreter.
30-
pub(crate) async fn get_or_create(
32+
/// Get or create an [`CachedEnvironment`] based on a given set of requirements.
33+
pub(crate) async fn from_spec(
3134
spec: EnvironmentSpecification<'_>,
3235
interpreter: Interpreter,
3336
settings: &ResolverInstallerSettings,
@@ -43,21 +46,7 @@ impl CachedEnvironment {
4346
printer: Printer,
4447
preview: PreviewMode,
4548
) -> Result<Self, ProjectError> {
46-
// When caching, always use the base interpreter, rather than that of the virtual
47-
// environment.
48-
let interpreter = if let Some(interpreter) = interpreter.to_base_interpreter(cache)? {
49-
debug!(
50-
"Caching via base interpreter: `{}`",
51-
interpreter.sys_executable().display()
52-
);
53-
interpreter
54-
} else {
55-
debug!(
56-
"Caching via interpreter: `{}`",
57-
interpreter.sys_executable().display()
58-
);
59-
interpreter
60-
};
49+
let interpreter = Self::base_interpreter(interpreter, cache)?;
6150

6251
// Resolve the requirements with the interpreter.
6352
let resolution = Resolution::from(
@@ -78,6 +67,93 @@ impl CachedEnvironment {
7867
.await?,
7968
);
8069

70+
Self::from_resolution(
71+
resolution,
72+
interpreter,
73+
settings,
74+
state,
75+
install,
76+
installer_metadata,
77+
connectivity,
78+
concurrency,
79+
native_tls,
80+
allow_insecure_host,
81+
cache,
82+
printer,
83+
preview,
84+
)
85+
.await
86+
}
87+
88+
/// Get or create an [`CachedEnvironment`] based on a given [`InstallTarget`].
89+
pub(crate) async fn from_lock(
90+
target: InstallTarget<'_>,
91+
extras: &ExtrasSpecification,
92+
dev: &DevGroupsManifest,
93+
install_options: InstallOptions,
94+
settings: &ResolverInstallerSettings,
95+
interpreter: Interpreter,
96+
state: &SharedState,
97+
install: Box<dyn InstallLogger>,
98+
installer_metadata: bool,
99+
connectivity: Connectivity,
100+
concurrency: Concurrency,
101+
native_tls: bool,
102+
allow_insecure_host: &[TrustedHost],
103+
cache: &Cache,
104+
printer: Printer,
105+
preview: PreviewMode,
106+
) -> Result<Self, ProjectError> {
107+
let interpreter = Self::base_interpreter(interpreter, cache)?;
108+
109+
// Determine the tags, markers, and interpreter to use for resolution.
110+
let tags = interpreter.tags()?;
111+
let marker_env = interpreter.resolver_marker_environment();
112+
113+
// Read the lockfile.
114+
let resolution = target.to_resolution(
115+
&marker_env,
116+
tags,
117+
extras,
118+
dev,
119+
&settings.build_options,
120+
&install_options,
121+
)?;
122+
123+
Self::from_resolution(
124+
resolution,
125+
interpreter,
126+
settings,
127+
state,
128+
install,
129+
installer_metadata,
130+
connectivity,
131+
concurrency,
132+
native_tls,
133+
allow_insecure_host,
134+
cache,
135+
printer,
136+
preview,
137+
)
138+
.await
139+
}
140+
141+
/// Get or create an [`CachedEnvironment`] based on a given [`Resolution`].
142+
pub(crate) async fn from_resolution(
143+
resolution: Resolution,
144+
interpreter: Interpreter,
145+
settings: &ResolverInstallerSettings,
146+
state: &SharedState,
147+
install: Box<dyn InstallLogger>,
148+
installer_metadata: bool,
149+
connectivity: Connectivity,
150+
concurrency: Concurrency,
151+
native_tls: bool,
152+
allow_insecure_host: &[TrustedHost],
153+
cache: &Cache,
154+
printer: Printer,
155+
preview: PreviewMode,
156+
) -> Result<Self, ProjectError> {
81157
// Hash the resolution by hashing the generated lockfile.
82158
// TODO(charlie): If the resolution contains any mutable metadata (like a path or URL
83159
// dependency), skip this step.
@@ -144,4 +220,28 @@ impl CachedEnvironment {
144220
pub(crate) fn into_interpreter(self) -> Interpreter {
145221
self.0.into_interpreter()
146222
}
223+
224+
/// Return the [`Interpreter`] to use for the cached environment, based on a given
225+
/// [`Interpreter`].
226+
///
227+
/// When caching, always use the base interpreter, rather than that of the virtual
228+
/// environment.
229+
fn base_interpreter(
230+
interpreter: Interpreter,
231+
cache: &Cache,
232+
) -> Result<Interpreter, uv_python::Error> {
233+
if let Some(interpreter) = interpreter.to_base_interpreter(cache)? {
234+
debug!(
235+
"Caching via base interpreter: `{}`",
236+
interpreter.sys_executable().display()
237+
);
238+
Ok(interpreter)
239+
} else {
240+
debug!(
241+
"Caching via interpreter: `{}`",
242+
interpreter.sys_executable().display()
243+
);
244+
Ok(interpreter)
245+
}
246+
}
147247
}

‎crates/uv/src/commands/project/install_target.rs

+127-9
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1+
use std::borrow::Cow;
12
use std::path::Path;
3+
use std::str::FromStr;
24

35
use itertools::Either;
46

57
use uv_normalize::PackageName;
8+
use uv_pypi_types::{LenientRequirement, VerbatimParsedUrl};
69
use uv_resolver::{Installable, Lock, Package};
10+
use uv_scripts::Pep723Script;
11+
use uv_workspace::pyproject::{DependencyGroupSpecifier, Source, Sources, ToolUvSources};
712
use uv_workspace::Workspace;
813

914
/// A target that can be installed from a lockfile.
@@ -25,6 +30,11 @@ pub(crate) enum InstallTarget<'lock> {
2530
workspace: &'lock Workspace,
2631
lock: &'lock Lock,
2732
},
33+
/// A PEP 723 script.
34+
Script {
35+
script: &'lock Pep723Script,
36+
lock: &'lock Lock,
37+
},
2838
}
2939

3040
impl<'lock> Installable<'lock> for InstallTarget<'lock> {
@@ -33,6 +43,7 @@ impl<'lock> Installable<'lock> for InstallTarget<'lock> {
3343
Self::Project { workspace, .. } => workspace.install_path(),
3444
Self::Workspace { workspace, .. } => workspace.install_path(),
3545
Self::NonProjectWorkspace { workspace, .. } => workspace.install_path(),
46+
Self::Script { script, .. } => script.path.parent().unwrap(),
3647
}
3748
}
3849

@@ -41,24 +52,28 @@ impl<'lock> Installable<'lock> for InstallTarget<'lock> {
4152
Self::Project { lock, .. } => lock,
4253
Self::Workspace { lock, .. } => lock,
4354
Self::NonProjectWorkspace { lock, .. } => lock,
55+
Self::Script { lock, .. } => lock,
4456
}
4557
}
4658

4759
fn roots(&self) -> impl Iterator<Item = &PackageName> {
4860
match self {
49-
Self::Project { name, .. } => Either::Right(Either::Left(std::iter::once(*name))),
50-
Self::NonProjectWorkspace { lock, .. } => Either::Left(lock.members().iter()),
61+
Self::Project { name, .. } => Either::Left(Either::Left(std::iter::once(*name))),
62+
Self::NonProjectWorkspace { lock, .. } => {
63+
Either::Left(Either::Right(lock.members().iter()))
64+
}
5165
Self::Workspace { lock, .. } => {
5266
// Identify the workspace members.
5367
//
5468
// The members are encoded directly in the lockfile, unless the workspace contains a
5569
// single member at the root, in which case, we identify it by its source.
5670
if lock.members().is_empty() {
57-
Either::Right(Either::Right(lock.root().into_iter().map(Package::name)))
71+
Either::Right(Either::Left(lock.root().into_iter().map(Package::name)))
5872
} else {
59-
Either::Left(lock.members().iter())
73+
Either::Left(Either::Right(lock.members().iter()))
6074
}
6175
}
76+
Self::Script { .. } => Either::Right(Either::Right(std::iter::empty())),
6277
}
6378
}
6479

@@ -67,17 +82,120 @@ impl<'lock> Installable<'lock> for InstallTarget<'lock> {
6782
Self::Project { name, .. } => Some(name),
6883
Self::Workspace { .. } => None,
6984
Self::NonProjectWorkspace { .. } => None,
85+
Self::Script { .. } => None,
7086
}
7187
}
7288
}
7389

7490
impl<'lock> InstallTarget<'lock> {
75-
/// Return the [`Workspace`] of the target.
76-
pub(crate) fn workspace(&self) -> &'lock Workspace {
91+
/// Return an iterator over all [`Sources`] defined by the target.
92+
pub(crate) fn sources(&self) -> impl Iterator<Item = &Source> {
93+
match self {
94+
Self::Project { workspace, .. }
95+
| Self::Workspace { workspace, .. }
96+
| Self::NonProjectWorkspace { workspace, .. } => {
97+
Either::Left(workspace.sources().values().flat_map(Sources::iter).chain(
98+
workspace.packages().values().flat_map(|member| {
99+
member
100+
.pyproject_toml()
101+
.tool
102+
.as_ref()
103+
.and_then(|tool| tool.uv.as_ref())
104+
.and_then(|uv| uv.sources.as_ref())
105+
.map(ToolUvSources::inner)
106+
.into_iter()
107+
.flat_map(|sources| sources.values().flat_map(Sources::iter))
108+
}),
109+
))
110+
}
111+
Self::Script { script, .. } => {
112+
Either::Right(script.sources().values().flat_map(Sources::iter))
113+
}
114+
}
115+
}
116+
117+
/// Return an iterator over all requirements defined by the target.
118+
pub(crate) fn requirements(
119+
&self,
120+
) -> impl Iterator<Item = Cow<'lock, uv_pep508::Requirement<VerbatimParsedUrl>>> {
77121
match self {
78-
Self::Project { workspace, .. } => workspace,
79-
Self::Workspace { workspace, .. } => workspace,
80-
Self::NonProjectWorkspace { workspace, .. } => workspace,
122+
Self::Project { workspace, .. }
123+
| Self::Workspace { workspace, .. }
124+
| Self::NonProjectWorkspace { workspace, .. } => {
125+
Either::Left(
126+
// Iterate over the non-member requirements in the workspace.
127+
workspace
128+
.requirements()
129+
.into_iter()
130+
.map(Cow::Owned)
131+
.chain(workspace.dependency_groups().ok().into_iter().flat_map(
132+
|dependency_groups| {
133+
dependency_groups.into_values().flatten().map(Cow::Owned)
134+
},
135+
))
136+
.chain(workspace.packages().values().flat_map(|member| {
137+
// Iterate over all dependencies in each member.
138+
let dependencies = member
139+
.pyproject_toml()
140+
.project
141+
.as_ref()
142+
.and_then(|project| project.dependencies.as_ref())
143+
.into_iter()
144+
.flatten();
145+
let optional_dependencies = member
146+
.pyproject_toml()
147+
.project
148+
.as_ref()
149+
.and_then(|project| project.optional_dependencies.as_ref())
150+
.into_iter()
151+
.flat_map(|optional| optional.values())
152+
.flatten();
153+
let dependency_groups = member
154+
.pyproject_toml()
155+
.dependency_groups
156+
.as_ref()
157+
.into_iter()
158+
.flatten()
159+
.flat_map(|(_, dependencies)| {
160+
dependencies.iter().filter_map(|specifier| {
161+
if let DependencyGroupSpecifier::Requirement(requirement) =
162+
specifier
163+
{
164+
Some(requirement)
165+
} else {
166+
None
167+
}
168+
})
169+
});
170+
let dev_dependencies = member
171+
.pyproject_toml()
172+
.tool
173+
.as_ref()
174+
.and_then(|tool| tool.uv.as_ref())
175+
.and_then(|uv| uv.dev_dependencies.as_ref())
176+
.into_iter()
177+
.flatten();
178+
dependencies
179+
.chain(optional_dependencies)
180+
.chain(dependency_groups)
181+
.filter_map(|requires_dist| {
182+
LenientRequirement::<VerbatimParsedUrl>::from_str(requires_dist)
183+
.map(uv_pep508::Requirement::from)
184+
.map(Cow::Owned)
185+
.ok()
186+
})
187+
.chain(dev_dependencies.map(Cow::Borrowed))
188+
})),
189+
)
190+
}
191+
Self::Script { script, .. } => Either::Right(
192+
script
193+
.metadata
194+
.dependencies
195+
.iter()
196+
.flatten()
197+
.map(Cow::Borrowed),
198+
),
81199
}
82200
}
83201
}

‎crates/uv/src/commands/project/mod.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ impl std::fmt::Display for ConflictError {
271271
self.conflicts
272272
.iter()
273273
.map(|conflict| match conflict {
274-
ConflictPackage::Group(ref group) if self.dev.default(group) =>
274+
ConflictPackage::Group(ref group) if self.dev.is_default(group) =>
275275
format!("`{group}` (enabled by default)"),
276276
ConflictPackage::Group(ref group) => format!("`{group}`"),
277277
ConflictPackage::Extra(..) => unreachable!(),
@@ -290,7 +290,7 @@ impl std::fmt::Display for ConflictError {
290290
.map(|(i, conflict)| {
291291
let conflict = match conflict {
292292
ConflictPackage::Extra(ref extra) => format!("extra `{extra}`"),
293-
ConflictPackage::Group(ref group) if self.dev.default(group) => {
293+
ConflictPackage::Group(ref group) if self.dev.is_default(group) => {
294294
format!("group `{group}` (enabled by default)")
295295
}
296296
ConflictPackage::Group(ref group) => format!("group `{group}`"),

‎crates/uv/src/commands/project/run.rs

+189-109
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ use uv_cache::Cache;
1717
use uv_cli::ExternalCommand;
1818
use uv_client::{BaseClientBuilder, Connectivity};
1919
use uv_configuration::{
20-
Concurrency, DevGroupsSpecification, EditableMode, ExtrasSpecification, GroupsSpecification,
21-
InstallOptions, LowerBound, PreviewMode, SourceStrategy, TrustedHost,
20+
Concurrency, DevGroupsManifest, DevGroupsSpecification, EditableMode, ExtrasSpecification,
21+
GroupsSpecification, InstallOptions, LowerBound, PreviewMode, SourceStrategy, TrustedHost,
2222
};
2323
use uv_dispatch::SharedState;
2424
use uv_distribution::LoweredRequirement;
@@ -200,109 +200,57 @@ pub(crate) async fn run(
200200
.await?
201201
.into_interpreter();
202202

203-
// Determine the working directory for the script.
204-
let script_dir = match &script {
205-
Pep723Item::Script(script) => std::path::absolute(&script.path)?
206-
.parent()
207-
.expect("script path has no parent")
208-
.to_owned(),
209-
Pep723Item::Stdin(..) | Pep723Item::Remote(..) => std::env::current_dir()?,
210-
};
211-
let script = script.into_metadata();
212-
213-
// Install the script requirements, if necessary. Otherwise, use an isolated environment.
214-
if let Some(dependencies) = script.dependencies {
215-
// Collect any `tool.uv.index` from the script.
216-
let empty = Vec::default();
217-
let script_indexes = match settings.sources {
218-
SourceStrategy::Enabled => script
219-
.tool
220-
.as_ref()
221-
.and_then(|tool| tool.uv.as_ref())
222-
.and_then(|uv| uv.top_level.index.as_deref())
223-
.unwrap_or(&empty),
224-
SourceStrategy::Disabled => &empty,
225-
};
203+
// If a lockfile already exists, lock the script.
204+
if let Some(target) = script
205+
.as_script()
206+
.map(LockTarget::from)
207+
.filter(|target| target.lock_path().is_file())
208+
{
209+
debug!("Found existing lockfile for script");
226210

227-
// Collect any `tool.uv.sources` from the script.
228-
let empty = BTreeMap::default();
229-
let script_sources = match settings.sources {
230-
SourceStrategy::Enabled => script
231-
.tool
232-
.as_ref()
233-
.and_then(|tool| tool.uv.as_ref())
234-
.and_then(|uv| uv.sources.as_ref())
235-
.unwrap_or(&empty),
236-
SourceStrategy::Disabled => &empty,
211+
// Determine the lock mode.
212+
let mode = if frozen {
213+
LockMode::Frozen
214+
} else if locked {
215+
LockMode::Locked(&interpreter)
216+
} else {
217+
LockMode::Write(&interpreter)
237218
};
238219

239-
let requirements = dependencies
240-
.into_iter()
241-
.flat_map(|requirement| {
242-
LoweredRequirement::from_non_workspace_requirement(
243-
requirement,
244-
script_dir.as_ref(),
245-
script_sources,
246-
script_indexes,
247-
&settings.index_locations,
248-
LowerBound::Allow,
249-
)
250-
.map_ok(LoweredRequirement::into_inner)
251-
})
252-
.collect::<Result<_, _>>()?;
253-
let constraints = script
254-
.tool
255-
.as_ref()
256-
.and_then(|tool| tool.uv.as_ref())
257-
.and_then(|uv| uv.constraint_dependencies.as_ref())
258-
.into_iter()
259-
.flatten()
260-
.cloned()
261-
.flat_map(|requirement| {
262-
LoweredRequirement::from_non_workspace_requirement(
263-
requirement,
264-
script_dir.as_ref(),
265-
script_sources,
266-
script_indexes,
267-
&settings.index_locations,
268-
LowerBound::Allow,
269-
)
270-
.map_ok(LoweredRequirement::into_inner)
271-
})
272-
.collect::<Result<Vec<_>, _>>()?;
273-
let overrides = script
274-
.tool
275-
.as_ref()
276-
.and_then(|tool| tool.uv.as_ref())
277-
.and_then(|uv| uv.override_dependencies.as_ref())
278-
.into_iter()
279-
.flatten()
280-
.cloned()
281-
.flat_map(|requirement| {
282-
LoweredRequirement::from_non_workspace_requirement(
283-
requirement,
284-
script_dir.as_ref(),
285-
script_sources,
286-
script_indexes,
287-
&settings.index_locations,
288-
LowerBound::Allow,
289-
)
290-
.map_ok(LoweredRequirement::into_inner)
291-
})
292-
.collect::<Result<Vec<_>, _>>()?;
293-
294-
let spec =
295-
RequirementsSpecification::from_overrides(requirements, constraints, overrides);
296-
let result = CachedEnvironment::get_or_create(
297-
EnvironmentSpecification::from(spec),
298-
interpreter,
299-
&settings,
220+
// Generate a lockfile.
221+
let lock = project::lock::do_safe_lock(
222+
mode,
223+
target,
224+
settings.as_ref().into(),
225+
LowerBound::Allow,
300226
&state,
301227
if show_resolution {
302228
Box::new(DefaultResolveLogger)
303229
} else {
304230
Box::new(SummaryResolveLogger)
305231
},
232+
connectivity,
233+
concurrency,
234+
native_tls,
235+
allow_insecure_host,
236+
cache,
237+
printer,
238+
preview,
239+
)
240+
.await?
241+
.into_lock();
242+
243+
let result = CachedEnvironment::from_lock(
244+
InstallTarget::Script {
245+
script: script.as_script().unwrap(),
246+
lock: &lock,
247+
},
248+
&ExtrasSpecification::default(),
249+
&DevGroupsManifest::default(),
250+
InstallOptions::default(),
251+
&settings,
252+
interpreter,
253+
&state,
306254
if show_resolution {
307255
Box::new(DefaultInstallLogger)
308256
} else {
@@ -331,19 +279,151 @@ pub(crate) async fn run(
331279

332280
Some(environment.into_interpreter())
333281
} else {
334-
// Create a virtual environment.
335-
temp_dir = cache.venv_dir()?;
336-
let environment = uv_virtualenv::create_venv(
337-
temp_dir.path(),
338-
interpreter,
339-
uv_virtualenv::Prompt::None,
340-
false,
341-
false,
342-
false,
343-
false,
344-
)?;
282+
// Determine the working directory for the script.
283+
let script_dir = match &script {
284+
Pep723Item::Script(script) => std::path::absolute(&script.path)?
285+
.parent()
286+
.expect("script path has no parent")
287+
.to_owned(),
288+
Pep723Item::Stdin(..) | Pep723Item::Remote(..) => std::env::current_dir()?,
289+
};
290+
let script = script.into_metadata();
291+
292+
// Install the script requirements, if necessary. Otherwise, use an isolated environment.
293+
if let Some(dependencies) = script.dependencies {
294+
// Collect any `tool.uv.index` from the script.
295+
let empty = Vec::default();
296+
let script_indexes = match settings.sources {
297+
SourceStrategy::Enabled => script
298+
.tool
299+
.as_ref()
300+
.and_then(|tool| tool.uv.as_ref())
301+
.and_then(|uv| uv.top_level.index.as_deref())
302+
.unwrap_or(&empty),
303+
SourceStrategy::Disabled => &empty,
304+
};
345305

346-
Some(environment.into_interpreter())
306+
// Collect any `tool.uv.sources` from the script.
307+
let empty = BTreeMap::default();
308+
let script_sources = match settings.sources {
309+
SourceStrategy::Enabled => script
310+
.tool
311+
.as_ref()
312+
.and_then(|tool| tool.uv.as_ref())
313+
.and_then(|uv| uv.sources.as_ref())
314+
.unwrap_or(&empty),
315+
SourceStrategy::Disabled => &empty,
316+
};
317+
318+
let requirements = dependencies
319+
.into_iter()
320+
.flat_map(|requirement| {
321+
LoweredRequirement::from_non_workspace_requirement(
322+
requirement,
323+
script_dir.as_ref(),
324+
script_sources,
325+
script_indexes,
326+
&settings.index_locations,
327+
LowerBound::Allow,
328+
)
329+
.map_ok(LoweredRequirement::into_inner)
330+
})
331+
.collect::<Result<_, _>>()?;
332+
let constraints = script
333+
.tool
334+
.as_ref()
335+
.and_then(|tool| tool.uv.as_ref())
336+
.and_then(|uv| uv.constraint_dependencies.as_ref())
337+
.into_iter()
338+
.flatten()
339+
.cloned()
340+
.flat_map(|requirement| {
341+
LoweredRequirement::from_non_workspace_requirement(
342+
requirement,
343+
script_dir.as_ref(),
344+
script_sources,
345+
script_indexes,
346+
&settings.index_locations,
347+
LowerBound::Allow,
348+
)
349+
.map_ok(LoweredRequirement::into_inner)
350+
})
351+
.collect::<Result<Vec<_>, _>>()?;
352+
let overrides = script
353+
.tool
354+
.as_ref()
355+
.and_then(|tool| tool.uv.as_ref())
356+
.and_then(|uv| uv.override_dependencies.as_ref())
357+
.into_iter()
358+
.flatten()
359+
.cloned()
360+
.flat_map(|requirement| {
361+
LoweredRequirement::from_non_workspace_requirement(
362+
requirement,
363+
script_dir.as_ref(),
364+
script_sources,
365+
script_indexes,
366+
&settings.index_locations,
367+
LowerBound::Allow,
368+
)
369+
.map_ok(LoweredRequirement::into_inner)
370+
})
371+
.collect::<Result<Vec<_>, _>>()?;
372+
373+
let spec =
374+
RequirementsSpecification::from_overrides(requirements, constraints, overrides);
375+
let result = CachedEnvironment::from_spec(
376+
EnvironmentSpecification::from(spec),
377+
interpreter,
378+
&settings,
379+
&state,
380+
if show_resolution {
381+
Box::new(DefaultResolveLogger)
382+
} else {
383+
Box::new(SummaryResolveLogger)
384+
},
385+
if show_resolution {
386+
Box::new(DefaultInstallLogger)
387+
} else {
388+
Box::new(SummaryInstallLogger)
389+
},
390+
installer_metadata,
391+
connectivity,
392+
concurrency,
393+
native_tls,
394+
allow_insecure_host,
395+
cache,
396+
printer,
397+
preview,
398+
)
399+
.await;
400+
401+
let environment = match result {
402+
Ok(resolution) => resolution,
403+
Err(ProjectError::Operation(err)) => {
404+
return diagnostics::OperationDiagnostic::with_context("script")
405+
.report(err)
406+
.map_or(Ok(ExitStatus::Failure), |err| Err(err.into()))
407+
}
408+
Err(err) => return Err(err.into()),
409+
};
410+
411+
Some(environment.into_interpreter())
412+
} else {
413+
// Create a virtual environment.
414+
temp_dir = cache.venv_dir()?;
415+
let environment = uv_virtualenv::create_venv(
416+
temp_dir.path(),
417+
interpreter,
418+
uv_virtualenv::Prompt::None,
419+
false,
420+
false,
421+
false,
422+
false,
423+
)?;
424+
425+
Some(environment.into_interpreter())
426+
}
347427
}
348428
} else {
349429
None
@@ -846,7 +926,7 @@ pub(crate) async fn run(
846926
Some(spec) => {
847927
debug!("Syncing ephemeral requirements");
848928

849-
let result = CachedEnvironment::get_or_create(
929+
let result = CachedEnvironment::from_spec(
850930
EnvironmentSpecification::from(spec).with_lock(
851931
lock.as_ref()
852932
.map(|(lock, install_path)| (lock, install_path.as_ref())),

‎crates/uv/src/commands/project/sync.rs

+9-124
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
use std::borrow::Cow;
21
use std::path::Path;
3-
use std::str::FromStr;
42

53
use anyhow::{Context, Result};
64
use itertools::Itertools;
@@ -18,16 +16,14 @@ use uv_distribution_types::{
1816
};
1917
use uv_installer::SitePackages;
2018
use uv_normalize::PackageName;
21-
use uv_pep508::{MarkerTree, Requirement, VersionOrUrl};
22-
use uv_pypi_types::{
23-
LenientRequirement, ParsedArchiveUrl, ParsedGitUrl, ParsedUrl, VerbatimParsedUrl,
24-
};
19+
use uv_pep508::{MarkerTree, VersionOrUrl};
20+
use uv_pypi_types::{ParsedArchiveUrl, ParsedGitUrl, ParsedUrl};
2521
use uv_python::{PythonDownloads, PythonEnvironment, PythonPreference, PythonRequest};
2622
use uv_resolver::{FlatIndex, Installable};
2723
use uv_settings::PythonInstallMirrors;
2824
use uv_types::{BuildIsolation, HashStrategy};
2925
use uv_warnings::warn_user;
30-
use uv_workspace::pyproject::{DependencyGroupSpecifier, Source, Sources, ToolUvSources};
26+
use uv_workspace::pyproject::Source;
3127
use uv_workspace::{DiscoveryOptions, MemberDiscovery, VirtualProject, Workspace};
3228

3329
use crate::commands::pip::loggers::{DefaultInstallLogger, DefaultResolveLogger, InstallLogger};
@@ -368,7 +364,7 @@ pub(super) async fn do_sync(
368364
}
369365

370366
// Populate credentials from the workspace.
371-
store_credentials_from_workspace(target.workspace());
367+
store_credentials_from_workspace(target);
372368

373369
// Initialize the registry client.
374370
let client = RegistryClientBuilder::new(cache.clone())
@@ -526,9 +522,9 @@ fn apply_editable_mode(resolution: Resolution, editable: EditableMode) -> Resolu
526522
///
527523
/// These credentials can come from any of `tool.uv.sources`, `tool.uv.dev-dependencies`,
528524
/// `project.dependencies`, and `project.optional-dependencies`.
529-
fn store_credentials_from_workspace(workspace: &Workspace) {
530-
// Iterate over any sources in the workspace root.
531-
for source in workspace.sources().values().flat_map(Sources::iter) {
525+
fn store_credentials_from_workspace(target: InstallTarget<'_>) {
526+
// Iterate over any sources in the target.
527+
for source in target.sources() {
532528
match source {
533529
Source::Git { git, .. } => {
534530
uv_git::store_credentials_from_url(git);
@@ -540,29 +536,8 @@ fn store_credentials_from_workspace(workspace: &Workspace) {
540536
}
541537
}
542538

543-
// Iterate over any dependencies defined in the workspace root.
544-
for requirement in &workspace.requirements() {
545-
let Some(VersionOrUrl::Url(url)) = &requirement.version_or_url else {
546-
continue;
547-
};
548-
match &url.parsed_url {
549-
ParsedUrl::Git(ParsedGitUrl { url, .. }) => {
550-
uv_git::store_credentials_from_url(url.repository());
551-
}
552-
ParsedUrl::Archive(ParsedArchiveUrl { url, .. }) => {
553-
uv_auth::store_credentials_from_url(url);
554-
}
555-
_ => {}
556-
}
557-
}
558-
559-
// Iterate over any dependency groups defined in the workspace root.
560-
for requirement in workspace
561-
.dependency_groups()
562-
.ok()
563-
.iter()
564-
.flat_map(|groups| groups.values().flat_map(|group| group.iter()))
565-
{
539+
// Iterate over any dependencies defined in the target.
540+
for requirement in target.requirements() {
566541
let Some(VersionOrUrl::Url(url)) = &requirement.version_or_url else {
567542
continue;
568543
};
@@ -576,94 +551,4 @@ fn store_credentials_from_workspace(workspace: &Workspace) {
576551
_ => {}
577552
}
578553
}
579-
580-
// Iterate over each workspace member.
581-
for member in workspace.packages().values() {
582-
// Iterate over the `tool.uv.sources`.
583-
for source in member
584-
.pyproject_toml()
585-
.tool
586-
.as_ref()
587-
.and_then(|tool| tool.uv.as_ref())
588-
.and_then(|uv| uv.sources.as_ref())
589-
.map(ToolUvSources::inner)
590-
.iter()
591-
.flat_map(|sources| sources.values().flat_map(Sources::iter))
592-
{
593-
match source {
594-
Source::Git { git, .. } => {
595-
uv_git::store_credentials_from_url(git);
596-
}
597-
Source::Url { url, .. } => {
598-
uv_auth::store_credentials_from_url(url);
599-
}
600-
_ => {}
601-
}
602-
}
603-
604-
// Iterate over all dependencies.
605-
let dependencies = member
606-
.pyproject_toml()
607-
.project
608-
.as_ref()
609-
.and_then(|project| project.dependencies.as_ref())
610-
.into_iter()
611-
.flatten();
612-
let optional_dependencies = member
613-
.pyproject_toml()
614-
.project
615-
.as_ref()
616-
.and_then(|project| project.optional_dependencies.as_ref())
617-
.into_iter()
618-
.flat_map(|optional| optional.values())
619-
.flatten();
620-
let dependency_groups = member
621-
.pyproject_toml()
622-
.dependency_groups
623-
.as_ref()
624-
.into_iter()
625-
.flatten()
626-
.flat_map(|(_, dependencies)| {
627-
dependencies.iter().filter_map(|specifier| {
628-
if let DependencyGroupSpecifier::Requirement(requirement) = specifier {
629-
Some(requirement)
630-
} else {
631-
None
632-
}
633-
})
634-
});
635-
let dev_dependencies = member
636-
.pyproject_toml()
637-
.tool
638-
.as_ref()
639-
.and_then(|tool| tool.uv.as_ref())
640-
.and_then(|uv| uv.dev_dependencies.as_ref())
641-
.into_iter()
642-
.flatten();
643-
644-
for requirement in dependencies
645-
.chain(optional_dependencies)
646-
.chain(dependency_groups)
647-
.filter_map(|requires_dist| {
648-
LenientRequirement::<VerbatimParsedUrl>::from_str(requires_dist)
649-
.map(Requirement::from)
650-
.map(Cow::Owned)
651-
.ok()
652-
})
653-
.chain(dev_dependencies.map(Cow::Borrowed))
654-
{
655-
let Some(VersionOrUrl::Url(url)) = &requirement.version_or_url else {
656-
continue;
657-
};
658-
match &url.parsed_url {
659-
ParsedUrl::Git(ParsedGitUrl { url, .. }) => {
660-
uv_git::store_credentials_from_url(url.repository());
661-
}
662-
ParsedUrl::Archive(ParsedArchiveUrl { url, .. }) => {
663-
uv_auth::store_credentials_from_url(url);
664-
}
665-
_ => {}
666-
}
667-
}
668-
}
669554
}

‎crates/uv/src/commands/tool/run.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -621,7 +621,7 @@ async fn get_or_create_environment(
621621
// TODO(zanieb): When implementing project-level tools, discover the project and check if it has the tool.
622622
// TODO(zanieb): Determine if we should layer on top of the project environment if it is present.
623623

624-
let environment = CachedEnvironment::get_or_create(
624+
let environment = CachedEnvironment::from_spec(
625625
EnvironmentSpecification::from(spec),
626626
interpreter,
627627
settings,

‎crates/uv/tests/it/run.rs

+188
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,9 @@ fn run_pep723_script() -> Result<()> {
323323
Resolved 1 package in [TIME]
324324
"###);
325325

326+
// But neither invocation should create a lockfile.
327+
assert!(!context.temp_dir.child("main.py.lock").exists());
328+
326329
// Otherwise, the script requirements should _not_ be available, but the project requirements
327330
// should.
328331
let test_non_script = context.temp_dir.child("main.py");
@@ -773,6 +776,191 @@ fn run_pep723_script_overrides() -> Result<()> {
773776
Ok(())
774777
}
775778

779+
/// Run a PEP 723-compatible script with a lockfile.
780+
#[test]
781+
fn run_pep723_script_lock() -> Result<()> {
782+
let context = TestContext::new("3.12");
783+
784+
785+
let test_script = context.temp_dir.child("main.py");
786+
test_script.write_str(indoc! { r#"
787+
# /// script
788+
# requires-python = ">=3.11"
789+
# dependencies = [
790+
# "iniconfig",
791+
# ]
792+
# ///
793+
794+
import iniconfig
795+
796+
print("Hello, world!")
797+
"#
798+
})?;
799+
800+
// Explicitly lock the script.
801+
uv_snapshot!(context.filters(), context.lock().arg("--script").arg("main.py"), @r###"
802+
success: true
803+
exit_code: 0
804+
----- stdout -----
805+
806+
----- stderr -----
807+
Resolved 1 package in [TIME]
808+
"###);
809+
810+
let lock = context.read("main.py.lock");
811+
812+
insta::with_settings!({
813+
filters => context.filters(),
814+
}, {
815+
assert_snapshot!(
816+
lock, @r###"
817+
version = 1
818+
requires-python = ">=3.11"
819+
820+
[options]
821+
exclude-newer = "2024-03-25T00:00:00Z"
822+
823+
[manifest]
824+
requirements = [{ name = "iniconfig" }]
825+
826+
[[package]]
827+
name = "iniconfig"
828+
version = "2.0.0"
829+
source = { registry = "https://pypi.org/simple" }
830+
sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 }
831+
wheels = [
832+
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 },
833+
]
834+
"###
835+
);
836+
});
837+
838+
uv_snapshot!(context.filters(), context.run().arg("main.py"), @r###"
839+
success: true
840+
exit_code: 0
841+
----- stdout -----
842+
Hello, world!
843+
844+
----- stderr -----
845+
Reading inline script metadata from `main.py`
846+
Resolved 1 package in [TIME]
847+
Prepared 1 package in [TIME]
848+
Installed 1 package in [TIME]
849+
+ iniconfig==2.0.0
850+
"###);
851+
852+
// Modify the metadata.
853+
test_script.write_str(indoc! { r#"
854+
# /// script
855+
# requires-python = ">=3.11"
856+
# dependencies = [
857+
# "anyio",
858+
# ]
859+
# ///
860+
861+
import anyio
862+
863+
print("Hello, world!")
864+
"#
865+
})?;
866+
867+
// Re-running the script with `--locked` should error.
868+
uv_snapshot!(context.filters(), context.run().arg("--locked").arg("main.py"), @r###"
869+
success: false
870+
exit_code: 2
871+
----- stdout -----
872+
873+
----- stderr -----
874+
Reading inline script metadata from `main.py`
875+
Resolved 3 packages in [TIME]
876+
error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`.
877+
"###);
878+
879+
// Re-running the script with `--frozen` should also error, but at runtime.
880+
uv_snapshot!(context.filters(), context.run().arg("--frozen").arg("main.py"), @r###"
881+
success: false
882+
exit_code: 1
883+
----- stdout -----
884+
885+
----- stderr -----
886+
Reading inline script metadata from `main.py`
887+
warning: `--frozen` is a no-op for Python scripts with inline metadata, which always run in isolation
888+
Traceback (most recent call last):
889+
File "[TEMP_DIR]/main.py", line 8, in <module>
890+
import anyio
891+
ModuleNotFoundError: No module named 'anyio'
892+
"###);
893+
894+
// Re-running the script should update the lockfile.
895+
uv_snapshot!(context.filters(), context.run().arg("main.py"), @r###"
896+
success: true
897+
exit_code: 0
898+
----- stdout -----
899+
Hello, world!
900+
901+
----- stderr -----
902+
Reading inline script metadata from `main.py`
903+
Resolved 3 packages in [TIME]
904+
Prepared 3 packages in [TIME]
905+
Installed 3 packages in [TIME]
906+
+ anyio==4.3.0
907+
+ idna==3.6
908+
+ sniffio==1.3.1
909+
"###);
910+
911+
let lock = context.read("main.py.lock");
912+
913+
insta::with_settings!({
914+
filters => context.filters(),
915+
}, {
916+
assert_snapshot!(
917+
lock, @r###"
918+
version = 1
919+
requires-python = ">=3.11"
920+
921+
[options]
922+
exclude-newer = "2024-03-25T00:00:00Z"
923+
924+
[manifest]
925+
requirements = [{ name = "anyio" }]
926+
927+
[[package]]
928+
name = "anyio"
929+
version = "4.3.0"
930+
source = { registry = "https://pypi.org/simple" }
931+
dependencies = [
932+
{ name = "idna" },
933+
{ name = "sniffio" },
934+
]
935+
sdist = { url = "https://files.pythonhosted.org/packages/db/4d/3970183622f0330d3c23d9b8a5f52e365e50381fd484d08e3285104333d3/anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6", size = 159642 }
936+
wheels = [
937+
{ url = "https://files.pythonhosted.org/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8", size = 85584 },
938+
]
939+
940+
[[package]]
941+
name = "idna"
942+
version = "3.6"
943+
source = { registry = "https://pypi.org/simple" }
944+
sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426 }
945+
wheels = [
946+
{ url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567 },
947+
]
948+
949+
[[package]]
950+
name = "sniffio"
951+
version = "1.3.1"
952+
source = { registry = "https://pypi.org/simple" }
953+
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 }
954+
wheels = [
955+
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
956+
]
957+
"###
958+
);
959+
});
960+
961+
Ok(())
962+
}
963+
776964
/// With `managed = false`, we should avoid installing the project itself.
777965
#[test]
778966
fn run_managed_false() -> Result<()> {

0 commit comments

Comments
 (0)
Please sign in to comment.