diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 339836f5b..714a36a91 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -3343,10 +3343,20 @@ pub struct ExportArgs { #[arg(long, value_enum, default_value_t = ExportFormat::default())] pub format: ExportFormat, + /// Export the entire workspace. + /// + /// The dependencies for all workspace members will be included in the + /// exported requirements file. + /// + /// Any extras or groups specified via `--extra`, `--group`, or related options + /// will be applied to all workspace members. + #[arg(long, conflicts_with = "package")] + pub all_packages: bool, + /// Export the dependencies for a specific package in the workspace. /// /// If the workspace member does not exist, uv will exit with an error. - #[arg(long)] + #[arg(long, conflicts_with = "all_packages")] pub package: Option, /// Include optional dependencies from the specified extra name. diff --git a/crates/uv-resolver/src/lock/requirements_txt.rs b/crates/uv-resolver/src/lock/requirements_txt.rs index b58c99649..967beaa5c 100644 --- a/crates/uv-resolver/src/lock/requirements_txt.rs +++ b/crates/uv-resolver/src/lock/requirements_txt.rs @@ -10,17 +10,17 @@ use petgraph::{Directed, Graph}; use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet}; use url::Url; +use crate::graph_ops::marker_reachability; +use crate::lock::{Package, PackageId, Source}; +use crate::{Lock, LockError}; use uv_configuration::{DevGroupsManifest, EditableMode, ExtrasSpecification, InstallOptions}; use uv_distribution_filename::{DistExtension, SourceDistExtension}; use uv_fs::Simplified; use uv_git::GitReference; -use uv_normalize::{ExtraName, PackageName}; +use uv_normalize::ExtraName; use uv_pep508::MarkerTree; use uv_pypi_types::{ParsedArchiveUrl, ParsedGitUrl}; - -use crate::graph_ops::marker_reachability; -use crate::lock::{Package, PackageId, Source}; -use crate::{Lock, LockError}; +use uv_workspace::InstallTarget; type LockGraph<'lock> = Graph, Edge, Directed>; @@ -35,7 +35,7 @@ pub struct RequirementsTxtExport<'lock> { impl<'lock> RequirementsTxtExport<'lock> { pub fn from_lock( lock: &'lock Lock, - root_name: &PackageName, + target: InstallTarget<'lock>, extras: &ExtrasSpecification, dev: &DevGroupsManifest, editable: EditableMode, @@ -52,65 +52,67 @@ impl<'lock> RequirementsTxtExport<'lock> { let root = petgraph.add_node(Node::Root); // Add the workspace package to the queue. - let dist = lock - .find_by_name(root_name) - .expect("found too many packages matching root") - .expect("could not find root"); + for root_name in target.packages() { + let dist = lock + .find_by_name(root_name) + .expect("found too many packages matching root") + .expect("could not find root"); - if dev.prod() { - // Add the workspace package to the graph. - if let Entry::Vacant(entry) = inverse.entry(&dist.id) { - entry.insert(petgraph.add_node(Node::Package(dist))); - } - - // Add an edge from the root. - let index = inverse[&dist.id]; - petgraph.add_edge(root, index, MarkerTree::TRUE); - - // Push its dependencies on the queue. - queue.push_back((dist, None)); - match extras { - ExtrasSpecification::None => {} - ExtrasSpecification::All => { - for extra in dist.optional_dependencies.keys() { - queue.push_back((dist, Some(extra))); - } - } - ExtrasSpecification::Some(extras) => { - for extra in extras { - queue.push_back((dist, Some(extra))); - } - } - } - } - - // Add any development dependencies. - for group in dev.iter() { - for dep in dist.dependency_groups.get(group).into_iter().flatten() { - let dep_dist = lock.find_by_id(&dep.package_id); - - // Add the dependency to the graph. - if let Entry::Vacant(entry) = inverse.entry(&dep.package_id) { - entry.insert(petgraph.add_node(Node::Package(dep_dist))); + if dev.prod() { + // Add the workspace package to the graph. + if let Entry::Vacant(entry) = inverse.entry(&dist.id) { + entry.insert(petgraph.add_node(Node::Package(dist))); } - // Add an edge from the root. Development dependencies may be installed without - // installing the workspace package itself (which can never have markers on it - // anyway), so they're directly connected to the root. - let dep_index = inverse[&dep.package_id]; - petgraph.add_edge( - root, - dep_index, - dep.simplified_marker.as_simplified_marker_tree().clone(), - ); + // Add an edge from the root. + let index = inverse[&dist.id]; + petgraph.add_edge(root, index, MarkerTree::TRUE); // Push its dependencies on the queue. - if seen.insert((&dep.package_id, None)) { - queue.push_back((dep_dist, None)); + queue.push_back((dist, None)); + match extras { + ExtrasSpecification::None => {} + ExtrasSpecification::All => { + for extra in dist.optional_dependencies.keys() { + queue.push_back((dist, Some(extra))); + } + } + ExtrasSpecification::Some(extras) => { + for extra in extras { + queue.push_back((dist, Some(extra))); + } + } } - for extra in &dep.extra { - if seen.insert((&dep.package_id, Some(extra))) { - queue.push_back((dep_dist, Some(extra))); + } + + // Add any development dependencies. + for group in dev.iter() { + for dep in dist.dependency_groups.get(group).into_iter().flatten() { + let dep_dist = lock.find_by_id(&dep.package_id); + + // Add the dependency to the graph. + if let Entry::Vacant(entry) = inverse.entry(&dep.package_id) { + entry.insert(petgraph.add_node(Node::Package(dep_dist))); + } + + // Add an edge from the root. Development dependencies may be installed without + // installing the workspace package itself (which can never have markers on it + // anyway), so they're directly connected to the root. + let dep_index = inverse[&dep.package_id]; + petgraph.add_edge( + root, + dep_index, + dep.simplified_marker.as_simplified_marker_tree().clone(), + ); + + // Push its dependencies on the queue. + if seen.insert((&dep.package_id, None)) { + queue.push_back((dep_dist, None)); + } + for extra in &dep.extra { + if seen.insert((&dep.package_id, Some(extra))) { + queue.push_back((dep_dist, Some(extra))); + } } } } @@ -170,7 +172,11 @@ impl<'lock> RequirementsTxtExport<'lock> { Node::Package(package) => Some((index, package)), }) .filter(|(_index, package)| { - install_options.include_package(&package.id.name, Some(root_name), lock.members()) + install_options.include_package( + &package.id.name, + target.project_name(), + lock.members(), + ) }) .map(|(index, package)| Requirement { package, diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs index d02a3a6fd..43c56b41f 100644 --- a/crates/uv/src/commands/project/export.rs +++ b/crates/uv/src/commands/project/export.rs @@ -30,6 +30,7 @@ use crate::settings::ResolverSettings; pub(crate) async fn export( project_dir: &Path, format: ExportFormat, + all_packages: bool, package: Option, hashes: bool, install_options: InstallOptions, @@ -52,14 +53,7 @@ pub(crate) async fn export( printer: Printer, ) -> Result { // Identify the project. - let project = if let Some(package) = package { - VirtualProject::Project( - Workspace::discover(project_dir, &DiscoveryOptions::default()) - .await? - .with_current_project(package.clone()) - .with_context(|| format!("Package `{package}` not found in workspace"))?, - ) - } else if frozen { + let project = if frozen && !all_packages { VirtualProject::discover( project_dir, &DiscoveryOptions { @@ -68,15 +62,18 @@ pub(crate) async fn export( }, ) .await? + } else if let Some(package) = package.as_ref() { + VirtualProject::Project( + Workspace::discover(project_dir, &DiscoveryOptions::default()) + .await? + .with_current_project(package.clone()) + .with_context(|| format!("Package `{package}` not found in workspace"))?, + ) } else { VirtualProject::discover(project_dir, &DiscoveryOptions::default()).await? }; - // Determine the default groups to include. - validate_dependency_groups(InstallTarget::from_project(&project), &dev)?; - let defaults = default_dependency_groups(project.pyproject_toml())?; - - let VirtualProject::Project(project) = project else { + if project.is_non_project() { 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")); }; @@ -147,6 +144,19 @@ pub(crate) async fn export( Err(err) => return Err(err.into()), }; + // Identify the target. + let target = if let Some(package) = package.as_ref().filter(|_| frozen) { + InstallTarget::frozen(&project, package) + } else if all_packages { + InstallTarget::from_workspace(&project) + } else { + InstallTarget::from_project(&project) + }; + + // Determine the default groups to include. + validate_dependency_groups(target, &dev)?; + let defaults = default_dependency_groups(project.pyproject_toml())?; + // Write the resolved dependencies to the output channel. let mut writer = OutputWriter::new(!quiet || output_file.is_none(), output_file.as_deref()); @@ -155,7 +165,7 @@ pub(crate) async fn export( ExportFormat::RequirementsTxt => { let export = RequirementsTxtExport::from_lock( &lock, - project.project_name(), + target, &extras, &dev.with_defaults(defaults), editable, diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 8dd92be30..24c4f4683 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -81,6 +81,14 @@ pub(crate) async fn sync( VirtualProject::discover(project_dir, &DiscoveryOptions::default()).await? }; + // TODO(lucab): improve warning content + // + if project.workspace().pyproject_toml().has_scripts() + && !project.workspace().pyproject_toml().is_package() + { + warn_user!("Skipping installation of entry points (`project.scripts`) because this project is not packaged; to install entry points, set `tool.uv.package = true` or define a `build-system`"); + } + // Identify the target. let target = if let Some(package) = package.as_ref().filter(|_| frozen) { InstallTarget::frozen(&project, package) @@ -90,14 +98,6 @@ pub(crate) async fn sync( InstallTarget::from_project(&project) }; - // TODO(lucab): improve warning content - // - if project.workspace().pyproject_toml().has_scripts() - && !project.workspace().pyproject_toml().is_package() - { - warn_user!("Skipping installation of entry points (`project.scripts`) because this project is not packaged; to install entry points, set `tool.uv.package = true` or define a `build-system`"); - } - // Determine the default groups to include. validate_dependency_groups(target, &dev)?; let defaults = default_dependency_groups(project.pyproject_toml())?; diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index f29fc424e..b171de7b1 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1510,6 +1510,7 @@ async fn run_project( commands::export( project_dir, args.format, + args.all_packages, args.package, args.hashes, args.install_options, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index a677eea21..3dedd2408 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1094,6 +1094,7 @@ impl TreeSettings { #[derive(Debug, Clone)] pub(crate) struct ExportSettings { pub(crate) format: ExportFormat, + pub(crate) all_packages: bool, pub(crate) package: Option, pub(crate) extras: ExtrasSpecification, pub(crate) dev: DevGroupsSpecification, @@ -1115,6 +1116,7 @@ impl ExportSettings { pub(crate) fn resolve(args: ExportArgs, filesystem: Option) -> Self { let ExportArgs { format, + all_packages, package, extra, all_extras, @@ -1143,8 +1145,9 @@ impl ExportSettings { } = args; Self { - package, format, + all_packages, + package, extras: ExtrasSpecification::from_args( flag(all_extras, no_all_extras).unwrap_or_default(), extra.unwrap_or_default(), diff --git a/crates/uv/tests/it/export.rs b/crates/uv/tests/it/export.rs index 3b6f0666f..2adcc3e08 100644 --- a/crates/uv/tests/it/export.rs +++ b/crates/uv/tests/it/export.rs @@ -503,6 +503,105 @@ fn non_root() -> Result<()> { Ok(()) } +#[test] +fn all() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["anyio==3.7.0", "child"] + + [tool.uv.workspace] + members = ["child"] + + [tool.uv.sources] + child = { workspace = true } + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + let child = context.temp_dir.child("child"); + child.child("pyproject.toml").write_str( + r#" + [project] + name = "child" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig>=2"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + context.lock().assert().success(); + + uv_snapshot!(context.filters(), context.export().arg("--all-packages"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv export --cache-dir [CACHE_DIR] --all-packages + -e . + -e ./child + anyio==3.7.0 \ + --hash=sha256:275d9973793619a5374e1c89a4f4ad3f4b0a5510a2b5b939444bee8f4c4d37ce \ + --hash=sha256:eddca883c4175f14df8aedce21054bfca3adb70ffe76a9f607aef9d7fa2ea7f0 + idna==3.6 \ + --hash=sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca \ + --hash=sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f + iniconfig==2.0.0 \ + --hash=sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3 \ + --hash=sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374 + sniffio==1.3.1 \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 + + ----- stderr ----- + Resolved 6 packages in [TIME] + "###); + + Ok(()) +} + +#[test] +fn non_project() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [tool.uv.workspace] + members = [] + + [tool.uv] + dev-dependencies = ["anyio"] + "#, + )?; + + context.lock().assert().success(); + + uv_snapshot!(context.filters(), context.export().arg("--dev"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Legacy non-project roots are not supported in `uv export`; add a `[project]` table to your `pyproject.toml` to enable exports + "###); + + Ok(()) +} + #[test] fn relative_path() -> Result<()> { let context = TestContext::new("3.12"); diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 002c56b60..7ae6e6ab4 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -2034,6 +2034,12 @@ uv export [OPTIONS]
--all-extras

Include all optional dependencies

+
--all-packages

Export the entire workspace.

+ +

The dependencies for all workspace members will be included in the exported requirements file.

+ +

Any extras or groups specified via --extra, --group, or related options will be applied to all workspace members.

+
--allow-insecure-host allow-insecure-host

Allow insecure connections to a host.

Can be provided multiple times.