diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index d6560014f..e1084f035 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -1202,6 +1202,14 @@ pub struct PipCompileArgs { #[arg(long, overrides_with("all_extras"), hide = true)] pub no_all_extras: bool, + /// Install the specified dependency group from a `pyproject.toml`. + /// + /// If no path is provided, the `pyproject.toml` in the working directory is used. + /// + /// May be provided multiple times. + #[arg(long, group = "sources")] + pub group: Vec, + #[command(flatten)] pub resolver: ResolverArgs, @@ -1216,14 +1224,6 @@ pub struct PipCompileArgs { #[arg(long, overrides_with("no_deps"), hide = true)] pub deps: bool, - /// Install the specified dependency group from a `pyproject.toml`. - /// - /// If no path is provided, the `pyproject.toml` in the working directory is used. - /// - /// May be provided multiple times. - #[arg(long, group = "sources")] - pub group: Vec, - /// Write the compiled requirements to the given `requirements.txt` or `pylock.toml` file. /// /// If the file already exists, the existing versions will be preferred when resolving @@ -1518,6 +1518,30 @@ pub struct PipSyncArgs { #[arg(long, short, alias = "build-constraint", env = EnvVars::UV_BUILD_CONSTRAINT, value_delimiter = ' ', value_parser = parse_maybe_file_path)] pub build_constraints: Vec>, + /// Include optional dependencies from the specified extra name; may be provided more than once. + /// + /// Only applies to `pylock.toml`, `pyproject.toml`, `setup.py`, and `setup.cfg` sources. + #[arg(long, conflicts_with = "all_extras", value_parser = extra_name_with_clap_error)] + pub extra: Option>, + + /// Include all optional dependencies. + /// + /// Only applies to `pylock.toml`, `pyproject.toml`, `setup.py`, and `setup.cfg` sources. + #[arg(long, conflicts_with = "extra", overrides_with = "no_all_extras")] + pub all_extras: bool, + + #[arg(long, overrides_with("all_extras"), hide = true)] + pub no_all_extras: bool, + + /// Install the specified dependency group from a `pylock.toml` or `pyproject.toml`. + /// + /// If no path is provided, the `pylock.toml` or `pyproject.toml` in the working directory is + /// used. + /// + /// May be provided multiple times. + #[arg(long, group = "sources")] + pub group: Vec, + #[command(flatten)] pub installer: InstallerArgs, @@ -1798,19 +1822,28 @@ pub struct PipInstallArgs { /// Include optional dependencies from the specified extra name; may be provided more than once. /// - /// Only applies to `pyproject.toml`, `setup.py`, and `setup.cfg` sources. + /// Only applies to `pylock.toml`, `pyproject.toml`, `setup.py`, and `setup.cfg` sources. #[arg(long, conflicts_with = "all_extras", value_parser = extra_name_with_clap_error)] pub extra: Option>, /// Include all optional dependencies. /// - /// Only applies to `pyproject.toml`, `setup.py`, and `setup.cfg` sources. + /// Only applies to `pylock.toml`, `pyproject.toml`, `setup.py`, and `setup.cfg` sources. #[arg(long, conflicts_with = "extra", overrides_with = "no_all_extras")] pub all_extras: bool, #[arg(long, overrides_with("all_extras"), hide = true)] pub no_all_extras: bool, + /// Install the specified dependency group from a `pylock.toml` or `pyproject.toml`. + /// + /// If no path is provided, the `pylock.toml` or `pyproject.toml` in the working directory is + /// used. + /// + /// May be provided multiple times. + #[arg(long, group = "sources")] + pub group: Vec, + #[command(flatten)] pub installer: ResolverInstallerArgs, @@ -1825,14 +1858,6 @@ pub struct PipInstallArgs { #[arg(long, overrides_with("no_deps"), hide = true)] pub deps: bool, - /// Install the specified dependency group from a `pyproject.toml`. - /// - /// If no path is provided, the `pyproject.toml` in the working directory is used. - /// - /// May be provided multiple times. - #[arg(long, group = "sources")] - pub group: Vec, - /// Require a matching hash for each requirement. /// /// By default, uv will verify any available hashes in the requirements file, but will not diff --git a/crates/uv-configuration/src/dependency_groups.rs b/crates/uv-configuration/src/dependency_groups.rs index a3b90ea5f..70dd9db08 100644 --- a/crates/uv-configuration/src/dependency_groups.rs +++ b/crates/uv-configuration/src/dependency_groups.rs @@ -186,6 +186,18 @@ impl DependencyGroupsInner { self.include.names().chain(&self.exclude) } + /// Returns an iterator over all groups that are included in the specification, + /// assuming `all_names` is an iterator over all groups. + pub fn group_names<'a, Names>( + &'a self, + all_names: Names, + ) -> impl Iterator + 'a + where + Names: Iterator + 'a, + { + all_names.filter(move |name| self.contains(name)) + } + /// Iterate over all groups the user explicitly asked for on the CLI pub fn explicit_names(&self) -> impl Iterator { let DependencyGroupsHistory { diff --git a/crates/uv-pep508/src/marker/tree.rs b/crates/uv-pep508/src/marker/tree.rs index 95e7327ed..756c90ade 100644 --- a/crates/uv-pep508/src/marker/tree.rs +++ b/crates/uv-pep508/src/marker/tree.rs @@ -754,6 +754,51 @@ impl Display for MarkerExpression { } } +/// The extra and dependency group names to use when evaluating a marker tree. +#[derive(Debug, Copy, Clone)] +enum ExtrasEnvironment<'a> { + /// E.g., `extra == '...'` + Extras(&'a [ExtraName]), + /// E.g., `'...' in extras` or `'...' in dependency_groups` + Pep751(&'a [ExtraName], &'a [GroupName]), +} + +impl<'a> ExtrasEnvironment<'a> { + /// Creates a new [`ExtrasEnvironment`] for the given `extra` names. + fn from_extras(extras: &'a [ExtraName]) -> Self { + Self::Extras(extras) + } + + /// Creates a new [`ExtrasEnvironment`] for the given PEP 751 `extras` and `dependency_groups`. + fn from_pep751(extras: &'a [ExtraName], dependency_groups: &'a [GroupName]) -> Self { + Self::Pep751(extras, dependency_groups) + } + + /// Returns the `extra` names in this environment. + fn extra(&self) -> &[ExtraName] { + match self { + ExtrasEnvironment::Extras(extra) => extra, + ExtrasEnvironment::Pep751(..) => &[], + } + } + + /// Returns the `extras` names in this environment, as in a PEP 751 lockfile. + fn extras(&self) -> &[ExtraName] { + match self { + ExtrasEnvironment::Extras(..) => &[], + ExtrasEnvironment::Pep751(extras, ..) => extras, + } + } + + /// Returns the `dependency_group` group names in this environment, as in a PEP 751 lockfile. + fn dependency_groups(&self) -> &[GroupName] { + match self { + ExtrasEnvironment::Extras(..) => &[], + ExtrasEnvironment::Pep751(.., groups) => groups, + } + } +} + /// Represents one or more nested marker expressions with and/or/parentheses. /// /// Marker trees are canonical, meaning any two functionally equivalent markers @@ -1001,7 +1046,27 @@ impl MarkerTree { /// Does this marker apply in the given environment? pub fn evaluate(self, env: &MarkerEnvironment, extras: &[ExtraName]) -> bool { - self.evaluate_reporter_impl(env, extras, &mut TracingReporter) + self.evaluate_reporter_impl( + env, + ExtrasEnvironment::from_extras(extras), + &mut TracingReporter, + ) + } + + /// Evaluate a marker in the context of a PEP 751 lockfile, which exposes several additional + /// markers (`extras` and `dependency_groups`) that are not available in any other context, + /// per the spec. + pub fn evaluate_pep751( + self, + env: &MarkerEnvironment, + extras: &[ExtraName], + groups: &[GroupName], + ) -> bool { + self.evaluate_reporter_impl( + env, + ExtrasEnvironment::from_pep751(extras, groups), + &mut TracingReporter, + ) } /// Evaluates this marker tree against an optional environment and a @@ -1018,7 +1083,11 @@ impl MarkerTree { ) -> bool { match env { None => self.evaluate_extras(extras), - Some(env) => self.evaluate_reporter_impl(env, extras, &mut TracingReporter), + Some(env) => self.evaluate_reporter_impl( + env, + ExtrasEnvironment::from_extras(extras), + &mut TracingReporter, + ), } } @@ -1030,13 +1099,13 @@ impl MarkerTree { extras: &[ExtraName], reporter: &mut impl Reporter, ) -> bool { - self.evaluate_reporter_impl(env, extras, reporter) + self.evaluate_reporter_impl(env, ExtrasEnvironment::from_extras(extras), reporter) } fn evaluate_reporter_impl( self, env: &MarkerEnvironment, - extras: &[ExtraName], + extras: ExtrasEnvironment, reporter: &mut impl Reporter, ) -> bool { match self.kind() { @@ -1088,12 +1157,18 @@ impl MarkerTree { } MarkerTreeKind::Extra(marker) => { return marker - .edge(extras.contains(marker.name().extra())) + .edge(extras.extra().contains(marker.name().extra())) .evaluate_reporter_impl(env, extras, reporter); } - // TODO(charlie): Add support for evaluating container extras in PEP 751 lockfiles. - MarkerTreeKind::Extras(..) | MarkerTreeKind::DependencyGroups(..) => { - return false; + MarkerTreeKind::Extras(marker) => { + return marker + .edge(extras.extras().contains(marker.name().extra())) + .evaluate_reporter_impl(env, extras, reporter); + } + MarkerTreeKind::DependencyGroups(marker) => { + return marker + .edge(extras.dependency_groups().contains(marker.name().group())) + .evaluate_reporter_impl(env, extras, reporter); } } diff --git a/crates/uv-requirements/src/sources.rs b/crates/uv-requirements/src/sources.rs index 090a72e5c..024ac5ebf 100644 --- a/crates/uv-requirements/src/sources.rs +++ b/crates/uv-requirements/src/sources.rs @@ -273,13 +273,13 @@ impl RequirementsSource { pub fn allows_extras(&self) -> bool { matches!( self, - Self::PyprojectToml(_) | Self::SetupPy(_) | Self::SetupCfg(_) + Self::PylockToml(_) | Self::PyprojectToml(_) | Self::SetupPy(_) | Self::SetupCfg(_) ) } /// Returns `true` if the source allows groups to be specified. pub fn allows_groups(&self) -> bool { - matches!(self, Self::PyprojectToml(_)) + matches!(self, Self::PylockToml(_) | Self::PyprojectToml(_)) } } diff --git a/crates/uv-requirements/src/specification.rs b/crates/uv-requirements/src/specification.rs index deead2c82..88a5eba21 100644 --- a/crates/uv-requirements/src/specification.rs +++ b/crates/uv-requirements/src/specification.rs @@ -250,10 +250,13 @@ impl RequirementsSpecification { // If we have a `pylock.toml`, don't allow additional requirements, constraints, or // overrides. - if requirements - .iter() - .any(|source| matches!(source, RequirementsSource::PylockToml(..))) - { + if let Some(pylock_toml) = requirements.iter().find_map(|source| { + if let RequirementsSource::PylockToml(path) = source { + Some(path) + } else { + None + } + }) { if requirements .iter() .any(|source| !matches!(source, RequirementsSource::PylockToml(..))) @@ -272,22 +275,38 @@ impl RequirementsSpecification { "Cannot specify constraints with a `pylock.toml` file" )); } - if groups.is_some_and(|groups| !groups.groups.is_empty()) { - return Err(anyhow::anyhow!( - "Cannot specify groups with a `pylock.toml` file" - )); + + // If we have a `pylock.toml`, disallow specifying paths for groups; instead, require + // that all groups refer to the `pylock.toml` file. + if let Some(groups) = groups { + let mut names = Vec::new(); + for group in &groups.groups { + if group.path.is_some() { + return Err(anyhow::anyhow!( + "Cannot specify paths for groups with a `pylock.toml` file; all groups must refer to the `pylock.toml` file" + )); + } + names.push(group.name.clone()); + } + + if !names.is_empty() { + spec.groups.insert( + pylock_toml.clone(), + DependencyGroups::from_args( + false, + false, + false, + Vec::new(), + Vec::new(), + false, + names, + false, + ), + ); + } } - } - - // Resolve sources into specifications so we know their `source_tree`. - let mut requirement_sources = Vec::new(); - for source in requirements { - let source = Self::from_source(source, client_builder).await?; - requirement_sources.push(source); - } - - // pip `--group` flags specify their own sources, which we need to process here - if let Some(groups) = groups { + } else if let Some(groups) = groups { + // pip `--group` flags specify their own sources, which we need to process here. // First, we collect all groups by their path. let mut groups_by_path = BTreeMap::new(); for group in &groups.groups { @@ -320,6 +339,13 @@ impl RequirementsSpecification { spec.groups = group_specs; } + // Resolve sources into specifications so we know their `source_tree`. + let mut requirement_sources = Vec::new(); + for source in requirements { + let source = Self::from_source(source, client_builder).await?; + requirement_sources.push(source); + } + // Read all requirements, and keep track of all requirements _and_ constraints. // A `requirements.txt` can contain a `-c constraints.txt` directive within it, so reading // a requirements file can also add constraints. diff --git a/crates/uv-resolver/src/lock/export/pylock_toml.rs b/crates/uv-resolver/src/lock/export/pylock_toml.rs index 80cd54be2..ef3ad8615 100644 --- a/crates/uv-resolver/src/lock/export/pylock_toml.rs +++ b/crates/uv-resolver/src/lock/export/pylock_toml.rs @@ -188,11 +188,11 @@ pub struct PylockToml { #[serde(skip_serializing_if = "Option::is_none")] requires_python: Option, #[serde(skip_serializing_if = "Vec::is_empty", default)] - extras: Vec, + pub extras: Vec, #[serde(skip_serializing_if = "Vec::is_empty", default)] - dependency_groups: Vec, + pub dependency_groups: Vec, #[serde(skip_serializing_if = "Vec::is_empty", default)] - default_groups: Vec, + pub default_groups: Vec, #[serde(skip_serializing_if = "Vec::is_empty", default)] pub packages: Vec, #[serde(skip_serializing_if = "Vec::is_empty", default)] @@ -966,9 +966,12 @@ impl<'lock> PylockToml { self, install_path: &Path, markers: &MarkerEnvironment, + extras: &[ExtraName], + groups: &[GroupName], tags: &Tags, build_options: &BuildOptions, ) -> Result { + // Convert the extras and dependency groups specifications to a concrete environment. let mut graph = petgraph::graph::DiGraph::with_capacity(self.packages.len(), self.packages.len()); @@ -977,7 +980,7 @@ impl<'lock> PylockToml { for package in self.packages { // Omit packages that aren't relevant to the current environment. - if !package.marker.evaluate(markers, &[]) { + if !package.marker.evaluate_pep751(markers, extras, groups) { continue; } diff --git a/crates/uv/src/commands/pip/install.rs b/crates/uv/src/commands/pip/install.rs index b9edad20e..72c532d7b 100644 --- a/crates/uv/src/commands/pip/install.rs +++ b/crates/uv/src/commands/pip/install.rs @@ -22,6 +22,7 @@ use uv_distribution_types::{ use uv_fs::Simplified; use uv_install_wheel::LinkMode; use uv_installer::{SatisfiesResult, SitePackages}; +use uv_normalize::{DefaultExtras, DefaultGroups}; use uv_pep508::PackageName; use uv_pypi_types::Conflicts; use uv_python::{ @@ -439,11 +440,35 @@ pub(crate) async fn pip_install( let install_path = std::path::absolute(&pylock)?; let install_path = install_path.parent().unwrap(); let content = fs_err::tokio::read_to_string(&pylock).await?; - let lock = toml::from_str::(&content) - .with_context(|| format!("Not a valid pylock.toml file: {}", pylock.user_display()))?; + let lock = toml::from_str::(&content).with_context(|| { + format!("Not a valid `pylock.toml` file: {}", pylock.user_display()) + })?; - let resolution = - lock.to_resolution(install_path, marker_env.markers(), &tags, &build_options)?; + // Convert the extras and groups specifications into a concrete form. + let extras = extras.with_defaults(DefaultExtras::default()); + let extras = extras + .extra_names(lock.extras.iter()) + .cloned() + .collect::>(); + + let groups = groups + .get(&pylock) + .cloned() + .unwrap_or_default() + .with_defaults(DefaultGroups::List(lock.default_groups.clone())); + let groups = groups + .group_names(lock.dependency_groups.iter()) + .cloned() + .collect::>(); + + let resolution = lock.to_resolution( + install_path, + marker_env.markers(), + &extras, + &groups, + &tags, + &build_options, + )?; let hasher = HashStrategy::from_resolution(&resolution, HashCheckingMode::Verify)?; (resolution, hasher) diff --git a/crates/uv/src/commands/pip/operations.rs b/crates/uv/src/commands/pip/operations.rs index 809f8bfdc..b5879ecf6 100644 --- a/crates/uv/src/commands/pip/operations.rs +++ b/crates/uv/src/commands/pip/operations.rs @@ -70,7 +70,7 @@ pub(crate) async fn read_requirements( "Use `package[extra]` syntax instead." }; return Err(anyhow!( - "Requesting extras requires a `pyproject.toml`, `setup.cfg`, or `setup.py` file. {hint}" + "Requesting extras requires a `pylock.toml`, `pyproject.toml`, `setup.cfg`, or `setup.py` file. {hint}" ) .into()); } diff --git a/crates/uv/src/commands/pip/sync.rs b/crates/uv/src/commands/pip/sync.rs index 47d180a74..2f46ef502 100644 --- a/crates/uv/src/commands/pip/sync.rs +++ b/crates/uv/src/commands/pip/sync.rs @@ -18,13 +18,14 @@ use uv_distribution_types::{DependencyMetadata, Index, IndexLocations, Origin, R use uv_fs::Simplified; use uv_install_wheel::LinkMode; use uv_installer::SitePackages; +use uv_normalize::{DefaultExtras, DefaultGroups}; use uv_pep508::PackageName; use uv_pypi_types::Conflicts; use uv_python::{ EnvironmentPreference, Prefix, PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, PythonVersion, Target, }; -use uv_requirements::{RequirementsSource, RequirementsSpecification}; +use uv_requirements::{GroupsSpecification, RequirementsSource, RequirementsSpecification}; use uv_resolver::{ DependencyMode, ExcludeNewer, FlatIndex, OptionsBuilder, PrereleaseMode, PylockToml, PythonRequirement, ResolutionMode, ResolverEnvironment, @@ -48,6 +49,8 @@ pub(crate) async fn pip_sync( requirements: &[RequirementsSource], constraints: &[RequirementsSource], build_constraints: &[RequirementsSource], + extras: &ExtrasSpecification, + groups: &GroupsSpecification, reinstall: Reinstall, link_mode: LinkMode, compile: bool, @@ -91,8 +94,6 @@ pub(crate) async fn pip_sync( // Initialize a few defaults. let overrides = &[]; - let extras = ExtrasSpecification::default(); - let groups = None; let upgrade = Upgrade::default(); let resolution_mode = ResolutionMode::default(); let prerelease_mode = PrereleaseMode::default(); @@ -118,8 +119,8 @@ pub(crate) async fn pip_sync( requirements, constraints, overrides, - &extras, - groups, + extras, + Some(groups), &client_builder, ) .await?; @@ -377,11 +378,35 @@ pub(crate) async fn pip_sync( let install_path = std::path::absolute(&pylock)?; let install_path = install_path.parent().unwrap(); let content = fs_err::tokio::read_to_string(&pylock).await?; - let lock = toml::from_str::(&content) - .with_context(|| format!("Not a valid pylock.toml file: {}", pylock.user_display()))?; + let lock = toml::from_str::(&content).with_context(|| { + format!("Not a valid `pylock.toml` file: {}", pylock.user_display()) + })?; - let resolution = - lock.to_resolution(install_path, marker_env.markers(), &tags, &build_options)?; + // Convert the extras and groups specifications into a concrete form. + let extras = extras.with_defaults(DefaultExtras::default()); + let extras = extras + .extra_names(lock.extras.iter()) + .cloned() + .collect::>(); + + let groups = groups + .get(&pylock) + .cloned() + .unwrap_or_default() + .with_defaults(DefaultGroups::List(lock.default_groups.clone())); + let groups = groups + .group_names(lock.dependency_groups.iter()) + .cloned() + .collect::>(); + + let resolution = lock.to_resolution( + install_path, + marker_env.markers(), + &extras, + &groups, + &tags, + &build_options, + )?; let hasher = HashStrategy::from_resolution(&resolution, HashCheckingMode::Verify)?; (resolution, hasher) @@ -406,7 +431,7 @@ pub(crate) async fn pip_sync( source_trees, project, BTreeSet::default(), - &extras, + extras, &groups, preferences, site_packages.clone(), diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 6ca03a470..9a67bb877 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -566,11 +566,17 @@ async fn run(mut cli: Cli) -> Result { .into_iter() .map(RequirementsSource::from_constraints_txt) .collect::, _>>()?; + let groups = GroupsSpecification { + root: project_dir.to_path_buf(), + groups: args.settings.groups, + }; commands::pip_sync( &requirements, &constraints, &build_constraints, + &args.settings.extras, + &groups, args.settings.reinstall, args.settings.link_mode, args.settings.compile_bytecode, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index aa105cf97..534640f94 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -2058,6 +2058,10 @@ impl PipSyncSettings { src_file, constraints, build_constraints, + extra, + all_extras, + no_all_extras, + group, installer, refresh, require_hashes, @@ -2122,6 +2126,9 @@ impl PipSyncSettings { python_version, python_platform, strict: flag(strict, no_strict, "strict"), + extra, + all_extras: flag(all_extras, no_all_extras, "all-extras"), + group: Some(group), torch_backend, ..PipOptions::from(installer) }, diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index a977ac813..e1d48b86d 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -1298,27 +1298,27 @@ fn install_extras() -> Result<()> { uv_snapshot!(context.filters(), context.pip_install() .arg("--all-extras") .arg("-e") - .arg(context.workspace_root.join("scripts/packages/poetry_editable")), @r###" + .arg(context.workspace_root.join("scripts/packages/poetry_editable")), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- - error: Requesting extras requires a `pyproject.toml`, `setup.cfg`, or `setup.py` file. Use `[extra]` syntax or `-r ` instead. - "### + error: Requesting extras requires a `pylock.toml`, `pyproject.toml`, `setup.cfg`, or `setup.py` file. Use `[extra]` syntax or `-r ` instead. + " ); // Request extras for a source tree uv_snapshot!(context.filters(), context.pip_install() .arg("--all-extras") - .arg(context.workspace_root.join("scripts/packages/poetry_editable")), @r###" + .arg(context.workspace_root.join("scripts/packages/poetry_editable")), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- - error: Requesting extras requires a `pyproject.toml`, `setup.cfg`, or `setup.py` file. Use `package[extra]` syntax instead. - "### + error: Requesting extras requires a `pylock.toml`, `pyproject.toml`, `setup.cfg`, or `setup.py` file. Use `package[extra]` syntax instead. + " ); let requirements_txt = context.temp_dir.child("requirements.txt"); @@ -1327,14 +1327,14 @@ fn install_extras() -> Result<()> { // Request extras for a requirements file uv_snapshot!(context.filters(), context.pip_install() .arg("--all-extras") - .arg("-r").arg("requirements.txt"), @r###" + .arg("-r").arg("requirements.txt"), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- - error: Requesting extras requires a `pyproject.toml`, `setup.cfg`, or `setup.py` file. Use `package[extra]` syntax instead. - "### + error: Requesting extras requires a `pylock.toml`, `pyproject.toml`, `setup.cfg`, or `setup.py` file. Use `package[extra]` syntax instead. + " ); let pyproject_toml = context.temp_dir.child("pyproject.toml"); @@ -11392,6 +11392,205 @@ fn pep_751_multiple_sources() -> Result<()> { Ok(()) } +#[test] +fn pep_751_groups() -> Result<()> { + let context = TestContext::new("3.13"); + + let pylock_toml = context.temp_dir.child("pylock.toml"); + pylock_toml.write_str( + r#" +lock-version = "1.0" +requires-python = "==3.13.*" +environments = [ + "python_version == \"3.13\"", +] +extras = ["async", "dev"] +dependency-groups = ["default", "test"] +default-groups = ["default"] +created-by = "pdm" +[[packages]] +name = "anyio" +version = "4.9.0" +requires-python = ">=3.9" +sdist = {name = "anyio-4.9.0.tar.gz", url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hashes = {sha256 = "673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}} +wheels = [ + {name = "anyio-4.9.0-py3-none-any.whl",url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl",hashes = {sha256 = "9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}}, +] +marker = "\"async\" in extras" + +[packages.tool.pdm] +dependencies = [ + "exceptiongroup>=1.0.2; python_version < \"3.11\"", + "idna>=2.8", + "sniffio>=1.1", + "typing-extensions>=4.5; python_version < \"3.13\"", +] + +[[packages]] +name = "blinker" +version = "1.9.0" +requires-python = ">=3.9" +sdist = {name = "blinker-1.9.0.tar.gz", url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hashes = {sha256 = "b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"}} +wheels = [ + {name = "blinker-1.9.0-py3-none-any.whl",url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl",hashes = {sha256 = "ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"}}, +] +marker = "\"dev\" in extras" + +[packages.tool.pdm] +dependencies = [] + +[[packages]] +name = "idna" +version = "3.10" +requires-python = ">=3.6" +sdist = {name = "idna-3.10.tar.gz", url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hashes = {sha256 = "12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}} +wheels = [ + {name = "idna-3.10-py3-none-any.whl",url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl",hashes = {sha256 = "946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}}, +] +marker = "\"async\" in extras" + +[packages.tool.pdm] +dependencies = [] + +[[packages]] +name = "iniconfig" +version = "2.1.0" +requires-python = ">=3.8" +sdist = {name = "iniconfig-2.1.0.tar.gz", url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hashes = {sha256 = "3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}} +wheels = [ + {name = "iniconfig-2.1.0-py3-none-any.whl",url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl",hashes = {sha256 = "9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}}, +] +marker = "\"default\" in dependency_groups" + +[packages.tool.pdm] +dependencies = [] + +[[packages]] +name = "pygments" +version = "2.19.2" +requires-python = ">=3.8" +sdist = {name = "pygments-2.19.2.tar.gz", url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hashes = {sha256 = "636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}} +wheels = [ + {name = "pygments-2.19.2-py3-none-any.whl",url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl",hashes = {sha256 = "86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}}, +] +marker = "\"test\" in dependency_groups" + +[packages.tool.pdm] +dependencies = [] + +[[packages]] +name = "sniffio" +version = "1.3.1" +requires-python = ">=3.7" +sdist = {name = "sniffio-1.3.1.tar.gz", url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hashes = {sha256 = "f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}} +wheels = [ + {name = "sniffio-1.3.1-py3-none-any.whl",url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl",hashes = {sha256 = "2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}}, +] +marker = "\"async\" in extras" + +[packages.tool.pdm] +dependencies = [] + +[tool.pdm] +hashes = {sha256 = "51795362d337720c28bd6c3a26eb33751f2b69590261f599ffb4172ee2c441c6"} + +[[tool.pdm.targets]] +requires_python = "==3.13.*" + "#, + )?; + + // By default, only `iniconfig` should be installed, since it's in the default group. + uv_snapshot!(context.filters(), context.pip_install() + .arg("--preview") + .arg("-r") + .arg("pylock.toml"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.1.0 + " + ); + + // With `--extra async`, `anyio` should be installed. + uv_snapshot!(context.filters(), context.pip_install() + .arg("--preview") + .arg("-r") + .arg("pylock.toml") + .arg("--extra") + .arg("async"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 3 packages in [TIME] + Installed 3 packages in [TIME] + + anyio==4.9.0 + + idna==3.10 + + sniffio==1.3.1 + " + ); + + // With `--group test`, `pygments` should be installed. + uv_snapshot!(context.filters(), context.pip_install() + .arg("--preview") + .arg("-r") + .arg("pylock.toml") + .arg("--group") + .arg("test"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + pygments==2.19.2 + " + ); + + // With `--all-extras`, `blinker` should be installed. + uv_snapshot!(context.filters(), context.pip_install() + .arg("--preview") + .arg("-r") + .arg("pylock.toml") + .arg("--all-extras"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + blinker==1.9.0 + " + ); + + // `--group pylock.toml:test` should be rejeceted. + uv_snapshot!(context.filters(), context.pip_install() + .arg("--preview") + .arg("-r") + .arg("pylock.toml") + .arg("--group") + .arg("pylock.toml:test"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: invalid value 'pylock.toml:test' for '--group ': The `--group` path is required to end in 'pyproject.toml' for compatibility with pip; got: pylock.toml + + For more information, try '--help'. + " + ); + + Ok(()) +} + /// Test that uv doesn't hang if an index returns a distribution for the wrong package. #[tokio::test] async fn bogus_redirect() -> Result<()> { diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 2ca95dce0..409ef5911 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -3637,7 +3637,9 @@ uv pip sync [OPTIONS] ...

Options

-
--allow-empty-requirements

Allow sync of empty requirements, which will clear the environment of all packages

+
--all-extras

Include all optional dependencies.

+

Only applies to pylock.toml, pyproject.toml, setup.py, and setup.cfg sources.

+
--allow-empty-requirements

Allow sync of empty requirements, which will clear the environment of all packages

--allow-insecure-host, --trusted-host allow-insecure-host

Allow insecure connections to a host.

Can be provided multiple times.

Expects to receive either a hostname (e.g., localhost), a host-port pair (e.g., localhost:8080), or a URL (e.g., https://localhost).

@@ -3675,13 +3677,18 @@ uv pip sync [OPTIONS] ...
--dry-run

Perform a dry run, i.e., don't actually install anything but resolve the dependencies and print the resulting plan

--exclude-newer exclude-newer

Limit candidate packages to those that were uploaded prior to the given date.

Accepts both RFC 3339 timestamps (e.g., 2006-12-02T02:07:43Z) and local dates in the same format (e.g., 2006-12-02) in your system's configured time zone.

-

May also be set with the UV_EXCLUDE_NEWER environment variable.

--extra-index-url extra-index-url

(Deprecated: use --index instead) Extra URLs of package indexes to use, in addition to --index-url.

+

May also be set with the UV_EXCLUDE_NEWER environment variable.

--extra extra

Include optional dependencies from the specified extra name; may be provided more than once.

+

Only applies to pylock.toml, pyproject.toml, setup.py, and setup.cfg sources.

+
--extra-index-url extra-index-url

(Deprecated: use --index instead) Extra URLs of package indexes to use, in addition to --index-url.

Accepts either a repository compliant with PEP 503 (the simple repository API), or a local directory laid out in the same format.

All indexes provided via this flag take priority over the index specified by --index-url (which defaults to PyPI). When multiple --extra-index-url flags are provided, earlier values take priority.

May also be set with the UV_EXTRA_INDEX_URL environment variable.

Locations to search for candidate distributions, in addition to those found in the registry indexes.

If a path, the target must be a directory that contains packages as wheel files (.whl) or source distributions (e.g., .tar.gz or .zip) at the top level.

If a URL, the page must contain a flat list of links to package files adhering to the formats described above.

-

May also be set with the UV_FIND_LINKS environment variable.

--help, -h

Display the concise help for this command

+

May also be set with the UV_FIND_LINKS environment variable.

--group group

Install the specified dependency group from a pylock.toml or pyproject.toml.

+

If no path is provided, the pylock.toml or pyproject.toml in the working directory is used.

+

May be provided multiple times.

+
--help, -h

Display the concise help for this command

--index index

The URLs to use when resolving dependencies, in addition to the default index.

Accepts either a repository compliant with PEP 503 (the simple repository API), or a local directory laid out in the same format.

All indexes provided via this flag take priority over the index specified by --default-index (which defaults to PyPI). When multiple --index flags are provided, earlier values take priority.

@@ -3888,7 +3895,7 @@ uv pip install [OPTIONS] |--editable Options
--all-extras

Include all optional dependencies.

-

Only applies to pyproject.toml, setup.py, and setup.cfg sources.

+

Only applies to pylock.toml, pyproject.toml, setup.py, and setup.cfg sources.

--allow-insecure-host, --trusted-host allow-insecure-host

Allow insecure connections to a host.

Can be provided multiple times.

Expects to receive either a hostname (e.g., localhost), a host-port pair (e.g., localhost:8080), or a URL (e.g., https://localhost).

@@ -3930,7 +3937,7 @@ uv pip install [OPTIONS] |--editable
--exclude-newer exclude-newer

Limit candidate packages to those that were uploaded prior to the given date.

Accepts both RFC 3339 timestamps (e.g., 2006-12-02T02:07:43Z) and local dates in the same format (e.g., 2006-12-02) in your system's configured time zone.

May also be set with the UV_EXCLUDE_NEWER environment variable.

--extra extra

Include optional dependencies from the specified extra name; may be provided more than once.

-

Only applies to pyproject.toml, setup.py, and setup.cfg sources.

+

Only applies to pylock.toml, pyproject.toml, setup.py, and setup.cfg sources.

--extra-index-url extra-index-url

(Deprecated: use --index instead) Extra URLs of package indexes to use, in addition to --index-url.

Accepts either a repository compliant with PEP 503 (the simple repository API), or a local directory laid out in the same format.

All indexes provided via this flag take priority over the index specified by --index-url (which defaults to PyPI). When multiple --extra-index-url flags are provided, earlier values take priority.

@@ -3944,8 +3951,8 @@ uv pip install [OPTIONS] |--editable
  • fewest: Optimize for selecting the fewest number of versions for each package. Older versions may be preferred if they are compatible with a wider range of supported Python versions or platforms
  • requires-python: Optimize for selecting latest supported version of each package, for each supported Python version
  • -
    --group group

    Install the specified dependency group from a pyproject.toml.

    -

    If no path is provided, the pyproject.toml in the working directory is used.

    +
    --group group

    Install the specified dependency group from a pylock.toml or pyproject.toml.

    +

    If no path is provided, the pylock.toml or pyproject.toml in the working directory is used.

    May be provided multiple times.

    --help, -h

    Display the concise help for this command

    --index index

    The URLs to use when resolving dependencies, in addition to the default index.