mirror of
https://github.com/astral-sh/uv.git
synced 2025-07-07 21:35:00 +00:00
Add support for pip-compile --extra <name>
(#239)
Adds support for `pip-compile --extra <name> ...` which includes optional dependencies in the specified group in the resolution. Following precedent in `pip-compile`, if a given extra is not found, there is no error. ~We could consider warning in this case.~ We should probably add an error but it expands scope and will be considered separately in #241
This commit is contained in:
parent
9244404102
commit
08f09e4743
9 changed files with 202 additions and 9 deletions
|
@ -8,6 +8,7 @@ use colored::Colorize;
|
||||||
use fs_err::File;
|
use fs_err::File;
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use pubgrub::report::Reporter;
|
use pubgrub::report::Reporter;
|
||||||
|
use puffin_package::extra_name::ExtraName;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
use pep508_rs::Requirement;
|
use pep508_rs::Requirement;
|
||||||
|
@ -31,6 +32,7 @@ const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
pub(crate) async fn pip_compile(
|
pub(crate) async fn pip_compile(
|
||||||
requirements: &[RequirementsSource],
|
requirements: &[RequirementsSource],
|
||||||
constraints: &[RequirementsSource],
|
constraints: &[RequirementsSource],
|
||||||
|
extras: Vec<ExtraName>,
|
||||||
output_file: Option<&Path>,
|
output_file: Option<&Path>,
|
||||||
resolution_mode: ResolutionMode,
|
resolution_mode: ResolutionMode,
|
||||||
prerelease_mode: PreReleaseMode,
|
prerelease_mode: PreReleaseMode,
|
||||||
|
@ -45,14 +47,14 @@ pub(crate) async fn pip_compile(
|
||||||
let RequirementsSpecification {
|
let RequirementsSpecification {
|
||||||
requirements,
|
requirements,
|
||||||
constraints,
|
constraints,
|
||||||
} = RequirementsSpecification::try_from_sources(requirements, constraints)?;
|
} = RequirementsSpecification::try_from_sources(requirements, constraints, &extras)?;
|
||||||
let preferences: Vec<Requirement> = output_file
|
let preferences: Vec<Requirement> = output_file
|
||||||
.filter(|_| upgrade_mode.is_prefer_pinned())
|
.filter(|_| upgrade_mode.is_prefer_pinned())
|
||||||
.filter(|output_file| output_file.exists())
|
.filter(|output_file| output_file.exists())
|
||||||
.map(Path::to_path_buf)
|
.map(Path::to_path_buf)
|
||||||
.map(RequirementsSource::from)
|
.map(RequirementsSource::from)
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(RequirementsSpecification::try_from_source)
|
.map(|source| RequirementsSpecification::try_from_source(source, &extras))
|
||||||
.transpose()?
|
.transpose()?
|
||||||
.map(|spec| spec.requirements)
|
.map(|spec| spec.requirements)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
|
@ -34,7 +34,7 @@ pub(crate) async fn pip_sync(
|
||||||
let RequirementsSpecification {
|
let RequirementsSpecification {
|
||||||
requirements,
|
requirements,
|
||||||
constraints: _,
|
constraints: _,
|
||||||
} = RequirementsSpecification::try_from_sources(sources, &[])?;
|
} = RequirementsSpecification::try_from_sources(sources, &[], &[])?;
|
||||||
|
|
||||||
if requirements.is_empty() {
|
if requirements.is_empty() {
|
||||||
writeln!(printer, "No requirements found")?;
|
writeln!(printer, "No requirements found")?;
|
||||||
|
|
|
@ -25,7 +25,7 @@ pub(crate) async fn pip_uninstall(
|
||||||
let RequirementsSpecification {
|
let RequirementsSpecification {
|
||||||
requirements,
|
requirements,
|
||||||
constraints: _,
|
constraints: _,
|
||||||
} = RequirementsSpecification::try_from_sources(sources, &[])?;
|
} = RequirementsSpecification::try_from_sources(sources, &[], &[])?;
|
||||||
|
|
||||||
// Detect the current Python interpreter.
|
// Detect the current Python interpreter.
|
||||||
let platform = Platform::current()?;
|
let platform = Platform::current()?;
|
||||||
|
|
|
@ -4,6 +4,7 @@ use std::process::ExitCode;
|
||||||
use clap::{Args, Parser, Subcommand};
|
use clap::{Args, Parser, Subcommand};
|
||||||
use colored::Colorize;
|
use colored::Colorize;
|
||||||
use directories::ProjectDirs;
|
use directories::ProjectDirs;
|
||||||
|
use puffin_package::extra_name::ExtraName;
|
||||||
use puffin_resolver::{PreReleaseMode, ResolutionMode};
|
use puffin_resolver::{PreReleaseMode, ResolutionMode};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
|
@ -71,6 +72,10 @@ struct PipCompileArgs {
|
||||||
#[clap(short, long)]
|
#[clap(short, long)]
|
||||||
constraint: Vec<PathBuf>,
|
constraint: Vec<PathBuf>,
|
||||||
|
|
||||||
|
/// Include optional dependencies in the given extra group name; may be provided more than once.
|
||||||
|
#[clap(long)]
|
||||||
|
extra: Vec<ExtraName>,
|
||||||
|
|
||||||
#[clap(long, value_enum)]
|
#[clap(long, value_enum)]
|
||||||
resolution: Option<ResolutionMode>,
|
resolution: Option<ResolutionMode>,
|
||||||
|
|
||||||
|
@ -201,6 +206,7 @@ async fn main() -> ExitCode {
|
||||||
commands::pip_compile(
|
commands::pip_compile(
|
||||||
&requirements,
|
&requirements,
|
||||||
&constraints,
|
&constraints,
|
||||||
|
args.extra,
|
||||||
args.output_file.as_deref(),
|
args.output_file.as_deref(),
|
||||||
args.resolution.unwrap_or_default(),
|
args.resolution.unwrap_or_default(),
|
||||||
args.prerelease.unwrap_or_default(),
|
args.prerelease.unwrap_or_default(),
|
||||||
|
|
|
@ -7,6 +7,7 @@ use anyhow::{Context, Result};
|
||||||
use fs_err as fs;
|
use fs_err as fs;
|
||||||
|
|
||||||
use pep508_rs::Requirement;
|
use pep508_rs::Requirement;
|
||||||
|
use puffin_package::extra_name::ExtraName;
|
||||||
use puffin_package::requirements_txt::RequirementsTxt;
|
use puffin_package::requirements_txt::RequirementsTxt;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
@ -45,7 +46,10 @@ pub(crate) struct RequirementsSpecification {
|
||||||
|
|
||||||
impl RequirementsSpecification {
|
impl RequirementsSpecification {
|
||||||
/// Read the requirements and constraints from a source.
|
/// Read the requirements and constraints from a source.
|
||||||
pub(crate) fn try_from_source(source: &RequirementsSource) -> Result<Self> {
|
pub(crate) fn try_from_source(
|
||||||
|
source: &RequirementsSource,
|
||||||
|
extras: &[ExtraName],
|
||||||
|
) -> Result<Self> {
|
||||||
Ok(match source {
|
Ok(match source {
|
||||||
RequirementsSource::Name(name) => {
|
RequirementsSource::Name(name) => {
|
||||||
let requirement = Requirement::from_str(name)
|
let requirement = Requirement::from_str(name)
|
||||||
|
@ -70,11 +74,27 @@ impl RequirementsSpecification {
|
||||||
let contents = fs::read_to_string(path)?;
|
let contents = fs::read_to_string(path)?;
|
||||||
let pyproject_toml = toml::from_str::<pyproject_toml::PyProjectToml>(&contents)
|
let pyproject_toml = toml::from_str::<pyproject_toml::PyProjectToml>(&contents)
|
||||||
.with_context(|| format!("Failed to read `{}`", path.display()))?;
|
.with_context(|| format!("Failed to read `{}`", path.display()))?;
|
||||||
let requirements = pyproject_toml
|
let requirements: Vec<Requirement> = pyproject_toml
|
||||||
.project
|
.project
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.flat_map(|project| project.dependencies.into_iter().flatten())
|
.flat_map(|project| {
|
||||||
|
project.dependencies.into_iter().flatten().chain(
|
||||||
|
// Include any optional dependencies specified in `extras`
|
||||||
|
project.optional_dependencies.into_iter().flat_map(
|
||||||
|
|optional_dependencies| {
|
||||||
|
extras.iter().flat_map(move |extra| {
|
||||||
|
optional_dependencies
|
||||||
|
.get(extra.as_ref())
|
||||||
|
.cloned()
|
||||||
|
// undefined extra requests are ignored silently
|
||||||
|
.unwrap_or_default()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
requirements,
|
requirements,
|
||||||
constraints: vec![],
|
constraints: vec![],
|
||||||
|
@ -87,6 +107,7 @@ impl RequirementsSpecification {
|
||||||
pub(crate) fn try_from_sources(
|
pub(crate) fn try_from_sources(
|
||||||
requirements: &[RequirementsSource],
|
requirements: &[RequirementsSource],
|
||||||
constraints: &[RequirementsSource],
|
constraints: &[RequirementsSource],
|
||||||
|
extras: &[ExtraName],
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let mut spec = Self::default();
|
let mut spec = Self::default();
|
||||||
|
|
||||||
|
@ -94,14 +115,14 @@ impl RequirementsSpecification {
|
||||||
// A `requirements.txt` can contain a `-c constraints.txt` directive within it, so reading
|
// A `requirements.txt` can contain a `-c constraints.txt` directive within it, so reading
|
||||||
// a requirements file can also add constraints.
|
// a requirements file can also add constraints.
|
||||||
for source in requirements {
|
for source in requirements {
|
||||||
let source = Self::try_from_source(source)?;
|
let source = Self::try_from_source(source, extras)?;
|
||||||
spec.requirements.extend(source.requirements);
|
spec.requirements.extend(source.requirements);
|
||||||
spec.constraints.extend(source.constraints);
|
spec.constraints.extend(source.constraints);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read all constraints, treating both requirements _and_ constraints as constraints.
|
// Read all constraints, treating both requirements _and_ constraints as constraints.
|
||||||
for source in constraints {
|
for source in constraints {
|
||||||
let source = Self::try_from_source(source)?;
|
let source = Self::try_from_source(source, extras)?;
|
||||||
spec.constraints.extend(source.requirements);
|
spec.constraints.extend(source.requirements);
|
||||||
spec.constraints.extend(source.constraints);
|
spec.constraints.extend(source.constraints);
|
||||||
}
|
}
|
||||||
|
|
|
@ -227,3 +227,56 @@ fn compile_constraints_inline() -> Result<()> {
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve a package from an extra in a `pyproject.toml` file.
|
||||||
|
#[test]
|
||||||
|
fn compile_pyproject_toml_extra() -> 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 = []
|
||||||
|
optional-dependencies.foo = [
|
||||||
|
"django==5.0b1",
|
||||||
|
]
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
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("--extra")
|
||||||
|
.arg("foo")
|
||||||
|
.arg("--cache-dir")
|
||||||
|
.arg(cache_dir.path())
|
||||||
|
.env("VIRTUAL_ENV", venv.as_os_str())
|
||||||
|
.current_dir(&temp_dir));
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
---
|
||||||
|
source: crates/puffin-cli/tests/pip_compile.rs
|
||||||
|
info:
|
||||||
|
program: puffin
|
||||||
|
args:
|
||||||
|
- pip-compile
|
||||||
|
- pyproject.toml
|
||||||
|
- "--extra"
|
||||||
|
- foo
|
||||||
|
- "--cache-dir"
|
||||||
|
- /var/folders/bc/qlsk3t6x7c9fhhbvvcg68k9c0000gp/T/.tmpAYEAdM
|
||||||
|
env:
|
||||||
|
VIRTUAL_ENV: /var/folders/bc/qlsk3t6x7c9fhhbvvcg68k9c0000gp/T/.tmp1xuOcV/.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 --extra foo --cache-dir [CACHE_DIR]
|
||||||
|
asgiref==3.7.2
|
||||||
|
# via django
|
||||||
|
django==5.0b1
|
||||||
|
sqlparse==0.4.4
|
||||||
|
# via django
|
||||||
|
|
||||||
|
----- stderr -----
|
||||||
|
Resolved 3 packages in [TIME]
|
||||||
|
|
82
crates/puffin-package/src/extra_name.rs
Normal file
82
crates/puffin-package/src/extra_name.rs
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
use std::fmt;
|
||||||
|
use std::fmt::{Display, Formatter};
|
||||||
|
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
|
pub struct ExtraName(String);
|
||||||
|
|
||||||
|
impl From<&ExtraName> for ExtraName {
|
||||||
|
fn from(extra_name: &ExtraName) -> Self {
|
||||||
|
extra_name.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for ExtraName {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||||
|
self.0.fmt(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static NAME_NORMALIZE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[-_.]+").unwrap());
|
||||||
|
|
||||||
|
impl ExtraName {
|
||||||
|
/// See: <https://peps.python.org/pep-0685/#specification/>
|
||||||
|
/// <https://packaging.python.org/en/latest/specifications/name-normalization/>
|
||||||
|
pub fn normalize(name: impl AsRef<str>) -> Self {
|
||||||
|
// TODO(charlie): Avoid allocating in the common case (when no normalization is required).
|
||||||
|
let mut normalized = NAME_NORMALIZE.replace_all(name.as_ref(), "-").to_string();
|
||||||
|
normalized.make_ascii_lowercase();
|
||||||
|
Self(normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsRef<str> for ExtraName {
|
||||||
|
fn as_ref(&self) -> &str {
|
||||||
|
self.0.as_ref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for ExtraName {
|
||||||
|
fn from(name: &str) -> Self {
|
||||||
|
Self::normalize(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize() {
|
||||||
|
assert_eq!(
|
||||||
|
ExtraName::normalize("friendly-bard").as_ref(),
|
||||||
|
"friendly-bard"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ExtraName::normalize("Friendly-Bard").as_ref(),
|
||||||
|
"friendly-bard"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ExtraName::normalize("FRIENDLY-BARD").as_ref(),
|
||||||
|
"friendly-bard"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ExtraName::normalize("friendly.bard").as_ref(),
|
||||||
|
"friendly-bard"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ExtraName::normalize("friendly_bard").as_ref(),
|
||||||
|
"friendly-bard"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ExtraName::normalize("friendly--bard").as_ref(),
|
||||||
|
"friendly-bard"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ExtraName::normalize("FrIeNdLy-._.-bArD").as_ref(),
|
||||||
|
"friendly-bard"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
pub mod dist_info_name;
|
pub mod dist_info_name;
|
||||||
|
pub mod extra_name;
|
||||||
pub mod metadata;
|
pub mod metadata;
|
||||||
pub mod package_name;
|
pub mod package_name;
|
||||||
pub mod requirements_txt;
|
pub mod requirements_txt;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue