Skip to content

Commit 1409af8

Browse files
committed
Support uv export for non-project workspaces
1 parent ddde948 commit 1409af8

File tree

4 files changed

+505
-31
lines changed

4 files changed

+505
-31
lines changed

crates/uv-resolver/src/lock/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -630,7 +630,7 @@ impl Lock {
630630
.iter()
631631
.copied()
632632
.map(|marker| SimplifiedMarkerTree::new(&self.requires_python, marker))
633-
.filter_map(super::requires_python::SimplifiedMarkerTree::try_to_string),
633+
.filter_map(SimplifiedMarkerTree::try_to_string),
634634
);
635635
doc.insert("supported-markers", value(supported_environments));
636636
}
@@ -3567,7 +3567,7 @@ struct Dependency {
35673567
/// by assuming `requires-python` is satisfied. So if
35683568
/// `requires-python = '>=3.8'`, then
35693569
/// `python_version >= '3.8' and python_version < '3.12'`
3570-
/// gets simplfiied to `python_version < '3.12'`.
3570+
/// gets simplified to `python_version < '3.12'`.
35713571
///
35723572
/// Generally speaking, this marker should not be exposed to
35733573
/// anything outside this module unless it's for a specialized use

crates/uv-resolver/src/lock/requirements_txt.rs

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use uv_pep508::MarkerTree;
1919
use uv_pypi_types::{ParsedArchiveUrl, ParsedGitUrl};
2020

2121
use crate::graph_ops::marker_reachability;
22-
use crate::lock::{Package, PackageId, Source};
22+
use crate::lock::{LockErrorKind, Package, PackageId, Source};
2323
use crate::universal_marker::{ConflictMarker, UniversalMarker};
2424
use crate::{InstallTarget, LockError};
2525

@@ -50,7 +50,7 @@ impl<'lock> RequirementsTxtExport<'lock> {
5050

5151
let root = petgraph.add_node(Node::Root);
5252

53-
// Add the workspace package to the queue.
53+
// Add the workspace packages to the queue.
5454
for root_name in target.packages() {
5555
if prune.contains(root_name) {
5656
continue;
@@ -135,6 +135,98 @@ impl<'lock> RequirementsTxtExport<'lock> {
135135
}
136136
}
137137

138+
// Add requirements that are exclusive to the workspace root (e.g., dependency groups in
139+
// (legacy) non-project workspace roots).
140+
let root_groups = target
141+
.groups()
142+
.map_err(|err| LockErrorKind::DependencyGroup { err })?;
143+
let root_requirements = {
144+
root_groups
145+
.iter()
146+
.filter_map(|(group, deps)| {
147+
if dev.contains(group) {
148+
Some(deps)
149+
} else {
150+
None
151+
}
152+
})
153+
.flatten()
154+
.filter(|dep| !prune.contains(&dep.name))
155+
.collect::<Vec<_>>()
156+
};
157+
158+
// Index the lockfile by package name, to avoid making multiple passes over the lockfile.
159+
if !root_requirements.is_empty() {
160+
let by_name: FxHashMap<_, Vec<_>> = {
161+
let names = root_requirements
162+
.iter()
163+
.map(|dep| &dep.name)
164+
.collect::<FxHashSet<_>>();
165+
target.lock().packages().iter().fold(
166+
FxHashMap::with_capacity_and_hasher(size_guess, FxBuildHasher),
167+
|mut map, package| {
168+
if names.contains(&package.id.name) {
169+
map.entry(&package.id.name).or_default().push(package);
170+
}
171+
map
172+
},
173+
)
174+
};
175+
176+
for requirement in root_requirements {
177+
for dist in by_name.get(&requirement.name).into_iter().flatten() {
178+
// Determine whether this entry is "relevant" for the requirement, by intersecting
179+
// the markers.
180+
let marker = if dist.fork_markers.is_empty() {
181+
requirement.marker
182+
} else {
183+
let mut combined = MarkerTree::FALSE;
184+
for fork_marker in &dist.fork_markers {
185+
combined.or(fork_marker.pep508());
186+
}
187+
combined.and(requirement.marker);
188+
combined
189+
};
190+
191+
// Simplify the marker.
192+
let marker = target.lock().simplify_environment(marker);
193+
194+
// Add the dependency to the graph.
195+
if let Entry::Vacant(entry) = inverse.entry(&dist.id) {
196+
entry.insert(petgraph.add_node(Node::Package(dist)));
197+
}
198+
199+
// Add an edge from the root.
200+
let dep_index = inverse[&dist.id];
201+
petgraph.add_edge(
202+
root,
203+
dep_index,
204+
UniversalMarker::new(
205+
marker,
206+
// OK because we've verified above that we do not have any
207+
// conflicting extras/groups.
208+
//
209+
// So why use `UniversalMarker`? Because that's what
210+
// `marker_reachability` wants and it (probably) isn't
211+
// worth inventing a new abstraction so that it can accept
212+
// graphs with either `MarkerTree` or `UniversalMarker`.
213+
ConflictMarker::TRUE,
214+
),
215+
);
216+
217+
// Push its dependencies on the queue.
218+
if seen.insert((&dist.id, None)) {
219+
queue.push_back((dist, None));
220+
}
221+
for extra in &requirement.extras {
222+
if seen.insert((&dist.id, Some(extra))) {
223+
queue.push_back((dist, Some(extra)));
224+
}
225+
}
226+
}
227+
}
228+
}
229+
138230
// Create all the relevant nodes.
139231
while let Some((package, extra)) = queue.pop_front() {
140232
let index = inverse[&package.id];

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

Lines changed: 50 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -81,22 +81,23 @@ pub(crate) async fn export(
8181
VirtualProject::discover(project_dir, &DiscoveryOptions::default()).await?
8282
};
8383

84-
let VirtualProject::Project(project) = &project else {
85-
return Err(anyhow::anyhow!("Legacy non-project roots are not supported in `uv export`; add a `[project]` table to your `pyproject.toml` to enable exports"));
86-
};
87-
8884
// Validate that any referenced dependency groups are defined in the workspace.
8985
if !frozen {
90-
let target = if all_packages {
91-
DependencyGroupsTarget::Workspace(project.workspace())
92-
} else {
93-
DependencyGroupsTarget::Project(project)
86+
let target = match &project {
87+
VirtualProject::Project(project) => {
88+
if all_packages {
89+
DependencyGroupsTarget::Workspace(project.workspace())
90+
} else {
91+
DependencyGroupsTarget::Project(project)
92+
}
93+
}
94+
VirtualProject::NonProject(workspace) => DependencyGroupsTarget::Workspace(workspace),
9495
};
9596
target.validate(&dev)?;
9697
}
9798

9899
// Determine the default groups to include.
99-
let defaults = default_dependency_groups(project.current_project().pyproject_toml())?;
100+
let defaults = default_dependency_groups(project.pyproject_toml())?;
100101
let dev = dev.with_defaults(defaults);
101102

102103
// Determine the lock mode.
@@ -163,19 +164,47 @@ pub(crate) async fn export(
163164
detect_conflicts(&lock, &extras, &dev)?;
164165

165166
// Identify the installation target.
166-
let target = if all_packages {
167-
InstallTarget::Workspace {
168-
workspace: project.workspace(),
169-
lock: &lock,
167+
let target = match &project {
168+
VirtualProject::Project(project) => {
169+
if all_packages {
170+
InstallTarget::Workspace {
171+
workspace: project.workspace(),
172+
lock: &lock,
173+
}
174+
} else if let Some(package) = package.as_ref() {
175+
InstallTarget::Project {
176+
workspace: project.workspace(),
177+
name: package,
178+
lock: &lock,
179+
}
180+
} else {
181+
// By default, install the root package.
182+
InstallTarget::Project {
183+
workspace: project.workspace(),
184+
name: project.project_name(),
185+
lock: &lock,
186+
}
187+
}
170188
}
171-
} else {
172-
InstallTarget::Project {
173-
workspace: project.workspace(),
174-
// If `--frozen --package` is specified, and only the root `pyproject.toml` was
175-
// discovered, the child won't be present in the workspace; but we _know_ that
176-
// we want to install it, so we override the package name.
177-
name: package.as_ref().unwrap_or(project.project_name()),
178-
lock: &lock,
189+
VirtualProject::NonProject(workspace) => {
190+
if all_packages {
191+
InstallTarget::NonProjectWorkspace {
192+
workspace,
193+
lock: &lock,
194+
}
195+
} else if let Some(package) = package.as_ref() {
196+
InstallTarget::Project {
197+
workspace,
198+
name: package,
199+
lock: &lock,
200+
}
201+
} else {
202+
// By default, install the entire workspace.
203+
InstallTarget::NonProjectWorkspace {
204+
workspace,
205+
lock: &lock,
206+
}
207+
}
179208
}
180209
};
181210

0 commit comments

Comments
 (0)