Add support for --all-extras to pip-compile (#259)

Closes #244

Notable decision to error if `--all-extra` and `--extra <name>` are both
provided.
This commit is contained in:
Zanie Blue 2023-11-01 13:39:49 -05:00 committed by GitHub
parent c6aa1cd7a3
commit 67e3e45839
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 230 additions and 30 deletions

View file

@ -8,7 +8,6 @@ use colored::Colorize;
use fs_err::File;
use itertools::Itertools;
use pubgrub::report::Reporter;
use puffin_package::extra_name::ExtraName;
use tracing::debug;
use pep508_rs::Requirement;
@ -23,7 +22,7 @@ use crate::commands::reporters::ResolverReporter;
use crate::commands::{elapsed, ExitStatus};
use crate::index_urls::IndexUrls;
use crate::printer::Printer;
use crate::requirements::{RequirementsSource, RequirementsSpecification};
use crate::requirements::{ExtrasSpecification, RequirementsSource, RequirementsSpecification};
const VERSION: &str = env!("CARGO_PKG_VERSION");
@ -32,7 +31,7 @@ const VERSION: &str = env!("CARGO_PKG_VERSION");
pub(crate) async fn pip_compile(
requirements: &[RequirementsSource],
constraints: &[RequirementsSource],
extras: Vec<ExtraName>,
extras: ExtrasSpecification<'_>,
output_file: Option<&Path>,
resolution_mode: ResolutionMode,
prerelease_mode: PreReleaseMode,
@ -51,18 +50,20 @@ pub(crate) async fn pip_compile(
} = RequirementsSpecification::try_from_sources(requirements, constraints, &extras)?;
// Check that all provided extras are used
let mut unused_extras = extras
.iter()
.filter(|extra| !used_extras.contains(extra))
.collect::<Vec<_>>();
if !unused_extras.is_empty() {
unused_extras.sort_unstable();
unused_extras.dedup();
let s = if unused_extras.len() == 1 { "" } else { "s" };
return Err(anyhow!(
"Requested extra{s} not found: {}",
unused_extras.iter().join(", ")
));
if let ExtrasSpecification::Some(extras) = extras {
let mut unused_extras = extras
.iter()
.filter(|extra| !used_extras.contains(extra))
.collect::<Vec<_>>();
if !unused_extras.is_empty() {
unused_extras.sort_unstable();
unused_extras.dedup();
let s = if unused_extras.len() == 1 { "" } else { "s" };
return Err(anyhow!(
"Requested extra{s} not found: {}",
unused_extras.iter().join(", ")
));
}
}
let preferences: Vec<Requirement> = output_file

View file

@ -21,7 +21,7 @@ use crate::commands::reporters::{
use crate::commands::{elapsed, ExitStatus};
use crate::index_urls::IndexUrls;
use crate::printer::Printer;
use crate::requirements::{RequirementsSource, RequirementsSpecification};
use crate::requirements::{ExtrasSpecification, RequirementsSource, RequirementsSpecification};
/// Install a set of locked requirements into the current Python environment.
pub(crate) async fn pip_sync(
@ -36,7 +36,7 @@ pub(crate) async fn pip_sync(
requirements,
constraints: _,
extras: _,
} = RequirementsSpecification::try_from_sources(sources, &[], &[])?;
} = RequirementsSpecification::try_from_sources(sources, &[], &ExtrasSpecification::None)?;
if requirements.is_empty() {
writeln!(printer, "No requirements found")?;

View file

@ -11,7 +11,7 @@ use puffin_package::package_name::PackageName;
use crate::commands::{elapsed, ExitStatus};
use crate::printer::Printer;
use crate::requirements::{RequirementsSource, RequirementsSpecification};
use crate::requirements::{ExtrasSpecification, RequirementsSource, RequirementsSpecification};
/// Uninstall packages from the current environment.
pub(crate) async fn pip_uninstall(
@ -26,7 +26,7 @@ pub(crate) async fn pip_uninstall(
requirements,
constraints: _,
extras: _,
} = RequirementsSpecification::try_from_sources(sources, &[], &[])?;
} = RequirementsSpecification::try_from_sources(sources, &[], &ExtrasSpecification::None)?;
// Detect the current Python interpreter.
let platform = Platform::current()?;

View file

@ -6,6 +6,7 @@ use colored::Colorize;
use directories::ProjectDirs;
use puffin_package::extra_name::ExtraName;
use puffin_resolver::{PreReleaseMode, ResolutionMode};
use requirements::ExtrasSpecification;
use url::Url;
use crate::commands::ExitStatus;
@ -73,9 +74,13 @@ struct PipCompileArgs {
constraint: Vec<PathBuf>,
/// Include optional dependencies in the given extra group name; may be provided more than once.
#[clap(long)]
#[clap(long, conflicts_with = "all_extras")]
extra: Vec<ExtraName>,
/// Include all optional dependencies.
#[clap(long, conflicts_with = "extra")]
all_extras: bool,
#[clap(long, value_enum)]
resolution: Option<ResolutionMode>,
@ -204,10 +209,19 @@ async fn main() -> ExitCode {
.collect::<Vec<_>>();
let index_urls =
IndexUrls::from_args(args.index_url, args.extra_index_url, args.no_index);
let extras = if args.all_extras {
ExtrasSpecification::All
} else if args.extra.is_empty() {
ExtrasSpecification::None
} else {
ExtrasSpecification::Some(&args.extra)
};
commands::pip_compile(
&requirements,
&constraints,
args.extra,
extras,
args.output_file.as_deref(),
args.resolution.unwrap_or_default(),
args.prerelease.unwrap_or_default(),

View file

@ -37,6 +37,25 @@ impl From<PathBuf> for RequirementsSource {
}
}
#[derive(Debug, Default)]
pub(crate) enum ExtrasSpecification<'a> {
#[default]
None,
All,
Some(&'a [ExtraName]),
}
impl ExtrasSpecification<'_> {
/// Returns true if a name is included in the extra specification.
fn contains(&self, name: &ExtraName) -> bool {
match self {
ExtrasSpecification::All => true,
ExtrasSpecification::None => false,
ExtrasSpecification::Some(extras) => extras.contains(name),
}
}
}
#[derive(Debug, Default)]
pub(crate) struct RequirementsSpecification {
/// The requirements for the project.
@ -51,7 +70,7 @@ impl RequirementsSpecification {
/// Read the requirements and constraints from a source.
pub(crate) fn try_from_source(
source: &RequirementsSource,
extras: &[ExtraName],
extras: &ExtrasSpecification,
) -> Result<Self> {
Ok(match source {
RequirementsSource::Name(name) => {
@ -84,13 +103,15 @@ impl RequirementsSpecification {
if let Some(project) = pyproject_toml.project {
requirements.extend(project.dependencies.unwrap_or_default());
// Include any optional dependencies specified in `extras`
for (name, optional_requirements) in
project.optional_dependencies.unwrap_or_default()
{
let normalized_name = ExtraName::normalize(name);
if extras.contains(&normalized_name) {
used_extras.insert(normalized_name);
requirements.extend(optional_requirements);
if !matches!(extras, ExtrasSpecification::None) {
for (name, optional_requirements) in
project.optional_dependencies.unwrap_or_default()
{
let normalized_name = ExtraName::normalize(name);
if extras.contains(&normalized_name) {
used_extras.insert(normalized_name);
requirements.extend(optional_requirements);
}
}
}
}
@ -108,7 +129,7 @@ impl RequirementsSpecification {
pub(crate) fn try_from_sources(
requirements: &[RequirementsSource],
constraints: &[RequirementsSource],
extras: &[ExtraName],
extras: &ExtrasSpecification,
) -> Result<Self> {
let mut spec = Self::default();

View file

@ -701,3 +701,127 @@ fn conflicting_transitive_url_dependency() -> Result<()> {
Ok(())
}
/// Resolve packages from all optional dependency groups in a `pyproject.toml` file.
#[test]
fn compile_pyproject_toml_all_extras() -> Result<()> {
let temp_dir = assert_fs::TempDir::new()?;
let cache_dir = assert_fs::TempDir::new()?;
let venv = temp_dir.child(".venv");
Command::new(get_cargo_bin(BIN_NAME))
.arg("venv")
.arg(venv.as_os_str())
.arg("--cache-dir")
.arg(cache_dir.path())
.current_dir(&temp_dir)
.assert()
.success();
venv.assert(predicates::path::is_dir());
let pyproject_toml = temp_dir.child("pyproject.toml");
pyproject_toml.touch()?;
pyproject_toml.write_str(
r#"[build-system]
requires = ["setuptools", "wheel"]
[project]
name = "project"
dependencies = ["django==5.0b1"]
optional-dependencies.foo = [
"anyio==4.0.0",
]
optional-dependencies.bar = [
"httpcore==0.18.0",
]
"#,
)?;
insta::with_settings!({
filters => vec![
(r"\d+(ms|s)", "[TIME]"),
(r"# .* pip-compile", "# [BIN_PATH] pip-compile"),
(r"--cache-dir .*", "--cache-dir [CACHE_DIR]"),
]
}, {
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
.arg("pip-compile")
.arg("pyproject.toml")
.arg("--all-extras")
.arg("--cache-dir")
.arg(cache_dir.path())
.env("VIRTUAL_ENV", venv.as_os_str())
.current_dir(&temp_dir));
});
Ok(())
}
/// Resolve packages from all optional dependency groups in a `pyproject.toml` file.
#[test]
fn compile_does_not_allow_both_extra_and_all_extras() -> Result<()> {
let temp_dir = assert_fs::TempDir::new()?;
let cache_dir = assert_fs::TempDir::new()?;
let venv = temp_dir.child(".venv");
Command::new(get_cargo_bin(BIN_NAME))
.arg("venv")
.arg(venv.as_os_str())
.arg("--cache-dir")
.arg(cache_dir.path())
.current_dir(&temp_dir)
.assert()
.success();
venv.assert(predicates::path::is_dir());
let pyproject_toml = temp_dir.child("pyproject.toml");
pyproject_toml.touch()?;
pyproject_toml.write_str(
r#"[build-system]
requires = ["setuptools", "wheel"]
[project]
name = "project"
dependencies = ["django==5.0b1"]
optional-dependencies.foo = [
"anyio==4.0.0",
]
optional-dependencies.bar = [
"httpcore==0.18.0",
]
"#,
)?;
insta::with_settings!({
filters => vec![
(r"\d+(ms|s)", "[TIME]"),
(r"# .* pip-compile", "# [BIN_PATH] pip-compile"),
(r"--cache-dir .*", "--cache-dir [CACHE_DIR]"),
]
}, {
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
.arg("pip-compile")
.arg("pyproject.toml")
.arg("--all-extras")
.arg("--extra")
.arg("foo")
.arg("--cache-dir")
.arg(cache_dir.path())
.env("VIRTUAL_ENV", venv.as_os_str())
.current_dir(&temp_dir),
@r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: the argument '--all-extras' cannot be used with '--extra <EXTRA>'
Usage: puffin pip-compile --all-extras --cache-dir [CACHE_DIR]
For more information, try '--help'.
"###);
});
Ok(())
}

View file

@ -0,0 +1,40 @@
---
source: crates/puffin-cli/tests/pip_compile.rs
info:
program: puffin
args:
- pip-compile
- pyproject.toml
- "--all-extras"
- "--cache-dir"
- /var/folders/bc/qlsk3t6x7c9fhhbvvcg68k9c0000gp/T/.tmpw8DJ9R
env:
VIRTUAL_ENV: /var/folders/bc/qlsk3t6x7c9fhhbvvcg68k9c0000gp/T/.tmppqOrk1/.venv
---
success: true
exit_code: 0
----- stdout -----
# This file was autogenerated by Puffin v0.0.1 via the following command:
# [BIN_PATH] pip-compile pyproject.toml --all-extras --cache-dir [CACHE_DIR]
anyio==4.0.0
# via httpcore
asgiref==3.7.2
# via django
certifi==2023.7.22
# via httpcore
django==5.0b1
h11==0.14.0
# via httpcore
httpcore==0.18.0
idna==3.4
# via anyio
sniffio==1.3.0
# via
# anyio
# httpcore
sqlparse==0.4.4
# via django
----- stderr -----
Resolved 9 packages in [TIME]