diff --git a/Cargo.lock b/Cargo.lock index 9a1631f88..815aaa052 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4355,15 +4355,10 @@ dependencies = [ "chrono", "clap", "clap_complete_command", - "configparser", - "console", - "ctrlc", - "distribution-filename", "distribution-types", "filetime", "flate2", "fs-err", - "futures", "indexmap 2.2.5", "indicatif", "indoc", @@ -4401,11 +4396,11 @@ dependencies = [ "uv-cache", "uv-client", "uv-dispatch", - "uv-distribution", "uv-fs", "uv-installer", "uv-interpreter", "uv-normalize", + "uv-requirements", "uv-resolver", "uv-traits", "uv-virtualenv", @@ -4769,6 +4764,44 @@ dependencies = [ "serde", ] +[[package]] +name = "uv-requirements" +version = "0.1.0" +dependencies = [ + "anyhow", + "configparser", + "console", + "ctrlc", + "distribution-filename", + "distribution-types", + "fs-err", + "futures", + "indexmap 2.2.5", + "itertools 0.12.1", + "once_cell", + "pep508_rs", + "pypi-types", + "pyproject-toml", + "regex", + "requirements-txt", + "rustc-hash", + "serde", + "serde_json", + "tempfile", + "textwrap", + "thiserror", + "tokio", + "toml", + "tracing", + "uv-cache", + "uv-client", + "uv-distribution", + "uv-fs", + "uv-normalize", + "uv-resolver", + "uv-warnings", +] + [[package]] name = "uv-resolver" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 4b6f576f0..3ab0cf8a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ uv-git = { path = "crates/uv-git" } uv-installer = { path = "crates/uv-installer" } uv-interpreter = { path = "crates/uv-interpreter" } uv-normalize = { path = "crates/uv-normalize" } +uv-requirements = { path = "crates/uv-requirements" } uv-resolver = { path = "crates/uv-resolver" } uv-traits = { path = "crates/uv-traits" } uv-trampoline = { path = "crates/uv-trampoline" } diff --git a/crates/README.md b/crates/README.md index c0c0c3447..a339db4ab 100644 --- a/crates/README.md +++ b/crates/README.md @@ -101,6 +101,10 @@ Normalize package and extra names as per Python specifications. Types and functionality for working with Python packages, e.g., parsing wheel files. +## [uv-requirements](./uv-requirements) + +Utilities for reading package requirements from `pyproject.toml` and `requirements.txt` files. + ## [uv-resolver](./uv-resolver) Functionality for resolving Python packages and their dependencies. diff --git a/crates/uv-requirements/Cargo.toml b/crates/uv-requirements/Cargo.toml new file mode 100644 index 000000000..1e8e456b3 --- /dev/null +++ b/crates/uv-requirements/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "uv-requirements" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +homepage.workspace = true +documentation.workspace = true +repository.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +distribution-filename = { workspace = true } +distribution-types = { workspace = true } +pep508_rs = { workspace = true } +pypi-types = { workspace = true } +requirements-txt = { workspace = true, features = ["reqwest"] } +uv-cache = { workspace = true, features = ["clap"] } +uv-client = { workspace = true } +uv-distribution = { workspace = true } +uv-fs = { workspace = true } +uv-normalize = { workspace = true } +uv-resolver = { workspace = true, features = ["clap"] } +uv-warnings = { workspace = true } + +anyhow = { workspace = true } +configparser = { workspace = true } +console = { workspace = true } +ctrlc = { workspace = true } +fs-err = { workspace = true, features = ["tokio"] } +futures = { workspace = true } +indexmap = { workspace = true } +itertools = { workspace = true } +once_cell = { workspace = true } +pyproject-toml = { workspace = true } +regex = { workspace = true } +rustc-hash = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tempfile = { workspace = true } +textwrap = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +toml = { workspace = true } +tracing = { workspace = true } + +[lints] +workspace = true diff --git a/crates/uv/src/confirm.rs b/crates/uv-requirements/src/confirm.rs similarity index 100% rename from crates/uv/src/confirm.rs rename to crates/uv-requirements/src/confirm.rs diff --git a/crates/uv-requirements/src/lib.rs b/crates/uv-requirements/src/lib.rs new file mode 100644 index 000000000..fda4731e0 --- /dev/null +++ b/crates/uv-requirements/src/lib.rs @@ -0,0 +1,9 @@ +pub use crate::named::*; +pub use crate::sources::*; +pub use crate::specification::*; + +mod confirm; +mod named; +mod sources; +mod specification; +pub mod upgrade; diff --git a/crates/uv-requirements/src/named.rs b/crates/uv-requirements/src/named.rs new file mode 100644 index 000000000..6f780f9aa --- /dev/null +++ b/crates/uv-requirements/src/named.rs @@ -0,0 +1,260 @@ +use std::path::Path; +use std::str::FromStr; + +use anyhow::{Context, Result}; +use configparser::ini::Ini; +use futures::{StreamExt, TryStreamExt}; +use once_cell::sync::Lazy; +use regex::Regex; +use serde::Deserialize; +use tracing::debug; + +use distribution_filename::{SourceDistFilename, WheelFilename}; +use distribution_types::RemoteSource; +use pep508_rs::{Requirement, RequirementsTxtRequirement, UnnamedRequirement, VersionOrUrl}; +use pypi_types::Metadata10; +use requirements_txt::EditableRequirement; +use uv_cache::Cache; +use uv_client::RegistryClient; +use uv_distribution::download_and_extract_archive; +use uv_normalize::PackageName; + +/// Like [`RequirementsSpecification`], but with concrete names for all requirements. +#[derive(Debug, Default)] +pub struct NamedRequirements { + /// The requirements for the project. + pub requirements: Vec, + /// The constraints for the project. + pub constraints: Vec, + /// The overrides for the project. + pub overrides: Vec, + /// Package to install as editable installs + pub editables: Vec, +} + +impl NamedRequirements { + /// Convert a [`RequirementsSpecification`] into a [`NamedRequirements`]. + pub async fn from_spec( + requirements: Vec, + constraints: Vec, + overrides: Vec, + editables: Vec, + cache: &Cache, + client: &RegistryClient, + ) -> Result { + // Resolve all unnamed references. + let requirements = futures::stream::iter(requirements) + .map(|requirement| async { + match requirement { + RequirementsTxtRequirement::Pep508(requirement) => Ok(requirement), + RequirementsTxtRequirement::Unnamed(requirement) => { + Self::name_requirement(requirement, cache, client).await + } + } + }) + .buffer_unordered(50) + .try_collect() + .await?; + + Ok(Self { + requirements, + constraints, + overrides, + editables, + }) + } + + /// Infer the package name for a given "unnamed" requirement. + async fn name_requirement( + requirement: UnnamedRequirement, + cache: &Cache, + client: &RegistryClient, + ) -> Result { + // If the requirement is a wheel, extract the package name from the wheel filename. + // + // Ex) `anyio-4.3.0-py3-none-any.whl` + if Path::new(requirement.url.path()) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("whl")) + { + let filename = WheelFilename::from_str(&requirement.url.filename()?)?; + return Ok(Requirement { + name: filename.name, + extras: requirement.extras, + version_or_url: Some(VersionOrUrl::Url(requirement.url)), + marker: requirement.marker, + }); + } + + // If the requirement is a source archive, try to extract the package name from the archive + // filename. This isn't guaranteed to work. + // + // Ex) `anyio-4.3.0.tar.gz` + if let Some(filename) = requirement + .url + .filename() + .ok() + .and_then(|filename| SourceDistFilename::parsed_normalized_filename(&filename).ok()) + { + return Ok(Requirement { + name: filename.name, + extras: requirement.extras, + version_or_url: Some(VersionOrUrl::Url(requirement.url)), + marker: requirement.marker, + }); + } + + // Download the archive and attempt to infer the package name from the archive contents. + let source = download_and_extract_archive(&requirement.url, cache, client) + .await + .with_context(|| { + format!("Unable to infer package name for the unnamed requirement: {requirement}") + })?; + + // Extract the path to the root of the distribution. + let path = source.path(); + + // Attempt to read a `PKG-INFO` from the directory. + if let Some(metadata) = fs_err::read(path.join("PKG-INFO")) + .ok() + .and_then(|contents| Metadata10::parse_pkg_info(&contents).ok()) + { + debug!( + "Found PKG-INFO metadata for {path} ({name})", + path = path.display(), + name = metadata.name + ); + return Ok(Requirement { + name: metadata.name, + extras: requirement.extras, + version_or_url: Some(VersionOrUrl::Url(requirement.url)), + marker: requirement.marker, + }); + } + + // Attempt to read a `pyproject.toml` file. + if let Some(pyproject) = fs_err::read_to_string(path.join("pyproject.toml")) + .ok() + .and_then(|contents| toml::from_str::(&contents).ok()) + { + // Read PEP 621 metadata from the `pyproject.toml`. + if let Some(project) = pyproject.project { + debug!( + "Found PEP 621 metadata for {path} in `pyproject.toml` ({name})", + path = path.display(), + name = project.name + ); + return Ok(Requirement { + name: project.name, + extras: requirement.extras, + version_or_url: Some(VersionOrUrl::Url(requirement.url)), + marker: requirement.marker, + }); + } + + // Read Poetry-specific metadata from the `pyproject.toml`. + if let Some(tool) = pyproject.tool { + if let Some(poetry) = tool.poetry { + if let Some(name) = poetry.name { + debug!( + "Found Poetry metadata for {path} in `pyproject.toml` ({name})", + path = path.display(), + name = name + ); + return Ok(Requirement { + name, + extras: requirement.extras, + version_or_url: Some(VersionOrUrl::Url(requirement.url)), + marker: requirement.marker, + }); + } + } + } + } + + // Attempt to read a `setup.cfg` from the directory. + if let Some(setup_cfg) = fs_err::read_to_string(path.join("setup.cfg")) + .ok() + .and_then(|contents| { + let mut ini = Ini::new_cs(); + ini.set_multiline(true); + ini.read(contents).ok() + }) + { + if let Some(section) = setup_cfg.get("metadata") { + if let Some(Some(name)) = section.get("name") { + if let Ok(name) = PackageName::from_str(name) { + debug!( + "Found setuptools metadata for {path} in `setup.cfg` ({name})", + path = path.display(), + name = name + ); + return Ok(Requirement { + name, + extras: requirement.extras, + version_or_url: Some(VersionOrUrl::Url(requirement.url)), + marker: requirement.marker, + }); + } + } + } + } + + // Attempt to read a `setup.py` from the directory. + if let Ok(setup_py) = fs_err::read_to_string(path.join("setup.py")) { + static SETUP_PY_NAME: Lazy = + Lazy::new(|| Regex::new(r#"name\s*[=:]\s*['"](?P[^'"]+)['"]"#).unwrap()); + + if let Some(name) = SETUP_PY_NAME + .captures(&setup_py) + .and_then(|captures| captures.name("name")) + .map(|name| name.as_str()) + { + if let Ok(name) = PackageName::from_str(name) { + debug!( + "Found setuptools metadata for {path} in `setup.py` ({name})", + path = path.display(), + name = name + ); + return Ok(Requirement { + name, + extras: requirement.extras, + version_or_url: Some(VersionOrUrl::Url(requirement.url)), + marker: requirement.marker, + }); + } + } + } + + // TODO(charlie): If this is common, consider running the PEP 517 build hooks. + Err(anyhow::anyhow!( + "Unable to infer package name for the unnamed requirement: {requirement}" + )) + } +} + +/// A pyproject.toml as specified in PEP 517. +#[derive(Deserialize, Debug)] +#[serde(rename_all = "kebab-case")] +struct PyProjectToml { + project: Option, + tool: Option, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "kebab-case")] +struct Project { + name: PackageName, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "kebab-case")] +struct Tool { + poetry: Option, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "kebab-case")] +struct ToolPoetry { + name: Option, +} diff --git a/crates/uv-requirements/src/sources.rs b/crates/uv-requirements/src/sources.rs new file mode 100644 index 000000000..55f6af1e5 --- /dev/null +++ b/crates/uv-requirements/src/sources.rs @@ -0,0 +1,86 @@ +use std::path::{Path, PathBuf}; + +use console::Term; + +use uv_fs::Simplified; +use uv_normalize::ExtraName; + +use crate::confirm; + +#[derive(Debug)] +pub enum RequirementsSource { + /// A package was provided on the command line (e.g., `pip install flask`). + Package(String), + /// An editable path was provided on the command line (e.g., `pip install -e ../flask`). + Editable(String), + /// Dependencies were provided via a `requirements.txt` file (e.g., `pip install -r requirements.txt`). + RequirementsTxt(PathBuf), + /// Dependencies were provided via a `pyproject.toml` file (e.g., `pip-compile pyproject.toml`). + PyprojectToml(PathBuf), +} + +impl RequirementsSource { + /// Parse a [`RequirementsSource`] from a [`PathBuf`]. + pub fn from_path(path: PathBuf) -> Self { + if path.ends_with("pyproject.toml") { + Self::PyprojectToml(path) + } else { + Self::RequirementsTxt(path) + } + } + + /// Parse a [`RequirementsSource`] from a user-provided string, assumed to be a package. + /// + /// If the user provided a value that appears to be a `requirements.txt` file or a local + /// directory, prompt them to correct it (if the terminal is interactive). + pub fn from_package(name: String) -> Self { + // If the user provided a `requirements.txt` file without `-r` (as in + // `uv pip install requirements.txt`), prompt them to correct it. + #[allow(clippy::case_sensitive_file_extension_comparisons)] + if (name.ends_with(".txt") || name.ends_with(".in")) && Path::new(&name).is_file() { + let term = Term::stderr(); + if term.is_term() { + let prompt = format!( + "`{name}` looks like a requirements file but was passed as a package name. Did you mean `-r {name}`?" + ); + let confirmation = confirm::confirm(&prompt, &term, true).unwrap(); + if confirmation { + return Self::RequirementsTxt(name.into()); + } + } + } + + Self::Package(name) + } +} + +impl std::fmt::Display for RequirementsSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Editable(path) => write!(f, "-e {path}"), + Self::RequirementsTxt(path) | Self::PyprojectToml(path) => { + write!(f, "{}", path.simplified_display()) + } + Self::Package(package) => write!(f, "{package}"), + } + } +} + +#[derive(Debug, Default, Clone)] +pub enum ExtrasSpecification<'a> { + #[default] + None, + All, + Some(&'a [ExtraName]), +} + +impl ExtrasSpecification<'_> { + /// Returns true if a name is included in the extra specification. + pub fn contains(&self, name: &ExtraName) -> bool { + match self { + ExtrasSpecification::All => true, + ExtrasSpecification::None => false, + ExtrasSpecification::Some(extras) => extras.contains(name), + } + } +} diff --git a/crates/uv-requirements/src/specification.rs b/crates/uv-requirements/src/specification.rs new file mode 100644 index 000000000..fd26d3837 --- /dev/null +++ b/crates/uv-requirements/src/specification.rs @@ -0,0 +1,368 @@ +use std::str::FromStr; + +use anyhow::{Context, Result}; +use indexmap::IndexMap; +use rustc_hash::FxHashSet; +use tracing::{instrument, Level}; + +use crate::{ExtrasSpecification, RequirementsSource}; +use distribution_types::{FlatIndexLocation, IndexUrl}; +use pep508_rs::{Requirement, RequirementsTxtRequirement}; +use requirements_txt::{EditableRequirement, FindLink, RequirementsTxt}; +use uv_client::Connectivity; +use uv_fs::Simplified; +use uv_normalize::{ExtraName, PackageName}; +use uv_warnings::warn_user; + +#[derive(Debug, Default)] +pub struct RequirementsSpecification { + /// The name of the project specifying requirements. + pub project: Option, + /// The requirements for the project. + pub requirements: Vec, + /// The constraints for the project. + pub constraints: Vec, + /// The overrides for the project. + pub overrides: Vec, + /// Package to install as editable installs + pub editables: Vec, + /// The extras used to collect requirements. + pub extras: FxHashSet, + /// The index URL to use for fetching packages. + pub index_url: Option, + /// The extra index URLs to use for fetching packages. + pub extra_index_urls: Vec, + /// Whether to disallow index usage. + pub no_index: bool, + /// The `--find-links` locations to use for fetching packages. + pub find_links: Vec, +} + +impl RequirementsSpecification { + /// Read the requirements and constraints from a source. + #[instrument(skip_all, level = Level::DEBUG, fields(source = % source))] + pub async fn from_source( + source: &RequirementsSource, + extras: &ExtrasSpecification<'_>, + connectivity: Connectivity, + ) -> Result { + Ok(match source { + RequirementsSource::Package(name) => { + let requirement = RequirementsTxtRequirement::parse(name, std::env::current_dir()?) + .with_context(|| format!("Failed to parse `{name}`"))?; + Self { + project: None, + requirements: vec![requirement], + constraints: vec![], + overrides: vec![], + editables: vec![], + extras: FxHashSet::default(), + index_url: None, + extra_index_urls: vec![], + no_index: false, + find_links: vec![], + } + } + RequirementsSource::Editable(name) => { + let requirement = EditableRequirement::parse(name, std::env::current_dir()?) + .with_context(|| format!("Failed to parse `{name}`"))?; + Self { + project: None, + requirements: vec![], + constraints: vec![], + overrides: vec![], + editables: vec![requirement], + extras: FxHashSet::default(), + index_url: None, + extra_index_urls: vec![], + no_index: false, + find_links: vec![], + } + } + RequirementsSource::RequirementsTxt(path) => { + let requirements_txt = + RequirementsTxt::parse(path, std::env::current_dir()?, connectivity).await?; + Self { + project: None, + requirements: requirements_txt + .requirements + .into_iter() + .map(|entry| entry.requirement) + .collect(), + constraints: requirements_txt.constraints, + editables: requirements_txt.editables, + overrides: vec![], + extras: FxHashSet::default(), + index_url: requirements_txt.index_url.map(IndexUrl::from), + extra_index_urls: requirements_txt + .extra_index_urls + .into_iter() + .map(IndexUrl::from) + .collect(), + no_index: requirements_txt.no_index, + find_links: requirements_txt + .find_links + .into_iter() + .map(|link| match link { + FindLink::Url(url) => FlatIndexLocation::Url(url), + FindLink::Path(path) => FlatIndexLocation::Path(path), + }) + .collect(), + } + } + RequirementsSource::PyprojectToml(path) => { + let contents = uv_fs::read_to_string(path).await?; + let pyproject_toml = toml::from_str::(&contents) + .with_context(|| format!("Failed to parse `{}`", path.user_display()))?; + let mut used_extras = FxHashSet::default(); + let mut requirements = Vec::new(); + let mut project_name = None; + + if let Some(project) = pyproject_toml.project { + // Parse the project name. + let parsed_project_name = + PackageName::new(project.name).with_context(|| { + format!("Invalid `project.name` in {}", path.user_display()) + })?; + + // Include the default dependencies. + requirements.extend(project.dependencies.unwrap_or_default()); + + // Include any optional dependencies specified in `extras`. + if !matches!(extras, ExtrasSpecification::None) { + if let Some(optional_dependencies) = project.optional_dependencies { + for (extra_name, optional_requirements) in &optional_dependencies { + // TODO(konstin): It's not ideal that pyproject-toml doesn't use + // `ExtraName` + let normalized_name = ExtraName::from_str(extra_name)?; + if extras.contains(&normalized_name) { + used_extras.insert(normalized_name); + requirements.extend(flatten_extra( + &parsed_project_name, + optional_requirements, + &optional_dependencies, + )?); + } + } + } + } + + project_name = Some(parsed_project_name); + } + + if requirements.is_empty() + && pyproject_toml.build_system.is_some_and(|build_system| { + build_system.requires.iter().any(|requirement| { + requirement.name.as_dist_info_name().starts_with("poetry") + }) + }) + { + warn_user!("`{}` does not contain any dependencies (hint: specify dependencies in the `project.dependencies` section; `tool.poetry.dependencies` is not currently supported)", path.user_display()); + } + + Self { + project: project_name, + requirements: requirements + .into_iter() + .map(RequirementsTxtRequirement::Pep508) + .collect(), + constraints: vec![], + overrides: vec![], + editables: vec![], + extras: used_extras, + index_url: None, + extra_index_urls: vec![], + no_index: false, + find_links: vec![], + } + } + }) + } + + /// Read the combined requirements and constraints from a set of sources. + pub async fn from_sources( + requirements: &[RequirementsSource], + constraints: &[RequirementsSource], + overrides: &[RequirementsSource], + extras: &ExtrasSpecification<'_>, + connectivity: Connectivity, + ) -> Result { + let mut spec = Self::default(); + + // 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. + for source in requirements { + let source = Self::from_source(source, extras, connectivity).await?; + spec.requirements.extend(source.requirements); + spec.constraints.extend(source.constraints); + spec.overrides.extend(source.overrides); + spec.extras.extend(source.extras); + spec.editables.extend(source.editables); + + // Use the first project name discovered. + if spec.project.is_none() { + spec.project = source.project; + } + + if let Some(url) = source.index_url { + if let Some(existing) = spec.index_url { + return Err(anyhow::anyhow!( + "Multiple index URLs specified: `{existing}` vs.` {url}", + )); + } + spec.index_url = Some(url); + } + spec.no_index |= source.no_index; + spec.extra_index_urls.extend(source.extra_index_urls); + spec.find_links.extend(source.find_links); + } + + // Read all constraints, treating _everything_ as a constraint. + for source in constraints { + let source = Self::from_source(source, extras, connectivity).await?; + for requirement in source.requirements { + match requirement { + RequirementsTxtRequirement::Pep508(requirement) => { + spec.constraints.push(requirement); + } + RequirementsTxtRequirement::Unnamed(requirement) => { + return Err(anyhow::anyhow!( + "Unnamed requirements are not allowed as constraints (found: `{requirement}`)" + )); + } + } + } + spec.constraints.extend(source.constraints); + spec.constraints.extend(source.overrides); + + if let Some(url) = source.index_url { + if let Some(existing) = spec.index_url { + return Err(anyhow::anyhow!( + "Multiple index URLs specified: `{existing}` vs.` {url}", + )); + } + spec.index_url = Some(url); + } + spec.no_index |= source.no_index; + spec.extra_index_urls.extend(source.extra_index_urls); + spec.find_links.extend(source.find_links); + } + + // Read all overrides, treating both requirements _and_ constraints as overrides. + for source in overrides { + let source = Self::from_source(source, extras, connectivity).await?; + for requirement in source.requirements { + match requirement { + RequirementsTxtRequirement::Pep508(requirement) => { + spec.overrides.push(requirement); + } + RequirementsTxtRequirement::Unnamed(requirement) => { + return Err(anyhow::anyhow!( + "Unnamed requirements are not allowed as overrides (found: `{requirement}`)" + )); + } + } + } + spec.overrides.extend(source.constraints); + spec.overrides.extend(source.overrides); + + if let Some(url) = source.index_url { + if let Some(existing) = spec.index_url { + return Err(anyhow::anyhow!( + "Multiple index URLs specified: `{existing}` vs.` {url}", + )); + } + spec.index_url = Some(url); + } + spec.no_index |= source.no_index; + spec.extra_index_urls.extend(source.extra_index_urls); + spec.find_links.extend(source.find_links); + } + + Ok(spec) + } + + /// Read the requirements from a set of sources. + pub async fn from_simple_sources( + requirements: &[RequirementsSource], + connectivity: Connectivity, + ) -> Result { + Self::from_sources( + requirements, + &[], + &[], + &ExtrasSpecification::None, + connectivity, + ) + .await + } +} + +/// Given an extra in a project that may contain references to the project +/// itself, flatten it into a list of requirements. +/// +/// For example: +/// ```toml +/// [project] +/// name = "my-project" +/// version = "0.0.1" +/// dependencies = [ +/// "tomli", +/// ] +/// +/// [project.optional-dependencies] +/// test = [ +/// "pep517", +/// ] +/// dev = [ +/// "my-project[test]", +/// ] +/// ``` +fn flatten_extra( + project_name: &PackageName, + requirements: &[Requirement], + extras: &IndexMap>, +) -> Result> { + fn inner( + project_name: &PackageName, + requirements: &[Requirement], + extras: &IndexMap>, + seen: &mut FxHashSet, + ) -> Result> { + let mut flattened = Vec::with_capacity(requirements.len()); + for requirement in requirements { + if requirement.name == *project_name { + for extra in &requirement.extras { + // Avoid infinite recursion on mutually recursive extras. + if !seen.insert(extra.clone()) { + continue; + } + + // Flatten the extra requirements. + for (name, extra_requirements) in extras { + let normalized_name = ExtraName::from_str(name)?; + if normalized_name == *extra { + flattened.extend(inner( + project_name, + extra_requirements, + extras, + seen, + )?); + } + } + } + } else { + flattened.push(requirement.clone()); + } + } + Ok(flattened) + } + + inner( + project_name, + requirements, + extras, + &mut FxHashSet::default(), + ) +} diff --git a/crates/uv-requirements/src/upgrade.rs b/crates/uv-requirements/src/upgrade.rs new file mode 100644 index 000000000..401b46588 --- /dev/null +++ b/crates/uv-requirements/src/upgrade.rs @@ -0,0 +1,83 @@ +use std::path::Path; + +use anyhow::Result; +use rustc_hash::FxHashSet; + +use requirements_txt::RequirementsTxt; +use uv_client::Connectivity; +use uv_normalize::PackageName; +use uv_resolver::{Preference, PreferenceError}; + +/// Whether to allow package upgrades. +#[derive(Debug)] +pub enum Upgrade { + /// Prefer pinned versions from the existing lockfile, if possible. + None, + + /// Allow package upgrades for all packages, ignoring the existing lockfile. + All, + + /// Allow package upgrades, but only for the specified packages. + Packages(FxHashSet), +} + +impl Upgrade { + /// Determine the upgrade strategy from the command-line arguments. + pub fn from_args(upgrade: bool, upgrade_package: Vec) -> Self { + if upgrade { + Self::All + } else if !upgrade_package.is_empty() { + Self::Packages(upgrade_package.into_iter().collect()) + } else { + Self::None + } + } + + /// Returns `true` if no packages should be upgraded. + pub fn is_none(&self) -> bool { + matches!(self, Self::None) + } + + /// Returns `true` if all packages should be upgraded. + pub fn is_all(&self) -> bool { + matches!(self, Self::All) + } +} + +/// Load the preferred requirements from an existing lockfile, applying the upgrade strategy. +pub async fn read_lockfile( + output_file: Option<&Path>, + upgrade: Upgrade, +) -> Result> { + // As an optimization, skip reading the lockfile is we're upgrading all packages anyway. + let Some(output_file) = output_file + .filter(|_| !upgrade.is_all()) + .filter(|output_file| output_file.exists()) + else { + return Ok(Vec::new()); + }; + + // Parse the requirements from the lockfile. + let requirements_txt = + RequirementsTxt::parse(output_file, std::env::current_dir()?, Connectivity::Offline) + .await?; + let preferences = requirements_txt + .requirements + .into_iter() + .filter(|entry| !entry.editable) + .map(Preference::from_entry) + .collect::, PreferenceError>>()?; + + // Apply the upgrade strategy to the requirements. + Ok(match upgrade { + // Respect all pinned versions from the existing lockfile. + Upgrade::None => preferences, + // Ignore all pinned versions from the existing lockfile. + Upgrade::All => vec![], + // Ignore pinned versions for the specified packages. + Upgrade::Packages(packages) => preferences + .into_iter() + .filter(|preference| !packages.contains(preference.name())) + .collect(), + }) +} diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index 51af69e52..6f1729645 100644 --- a/crates/uv/Cargo.toml +++ b/crates/uv/Cargo.toml @@ -14,7 +14,6 @@ default-run = "uv" workspace = true [dependencies] -distribution-filename = { workspace = true } distribution-types = { workspace = true } install-wheel-rs = { workspace = true, features = ["clap"], default-features = false } pep508_rs = { workspace = true } @@ -25,12 +24,12 @@ uv-auth = { workspace = true, features = ["clap"] } uv-cache = { workspace = true, features = ["clap"] } uv-client = { workspace = true } uv-dispatch = { workspace = true } -uv-distribution = { workspace = true } uv-fs = { workspace = true } uv-installer = { workspace = true } uv-interpreter = { workspace = true } uv-normalize = { workspace = true } uv-resolver = { workspace = true, features = ["clap"] } +uv-requirements = { workspace = true } uv-traits = { workspace = true } uv-virtualenv = { workspace = true } uv-warnings = { workspace = true } @@ -38,16 +37,11 @@ uv-warnings = { workspace = true } anstream = { workspace = true } anyhow = { workspace = true } axoupdater = { workspace = true, features = ["github_releases", "tokio"] } -base64 = { workspace = true } chrono = { workspace = true } clap = { workspace = true, features = ["derive", "string"] } clap_complete_command = { workspace = true } -configparser = { workspace = true } -console = { workspace = true } -ctrlc = { workspace = true } flate2 = { workspace = true, default-features = false } fs-err = { workspace = true, features = ["tokio"] } -futures = { workspace = true } indexmap = { workspace = true } indicatif = { workspace = true } itertools = { workspace = true } @@ -80,6 +74,7 @@ tikv-jemallocator = { version = "0.5.4" } [dev-dependencies] assert_cmd = { version = "2.0.14" } assert_fs = { version = "1.1.0" } +base64 = { version = "0.21.7" } byteorder = { version = "1.5.0" } filetime = { version = "0.2.23" } indoc = { version = "2.0.4" } diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index 1ec679ed2..1b3e5d22d 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -8,7 +8,7 @@ pub(crate) use cache_clean::cache_clean; pub(crate) use cache_dir::cache_dir; use distribution_types::InstalledMetadata; pub(crate) use pip_check::pip_check; -pub(crate) use pip_compile::{extra_name_with_clap_error, pip_compile, Upgrade}; +pub(crate) use pip_compile::{extra_name_with_clap_error, pip_compile}; pub(crate) use pip_freeze::pip_freeze; pub(crate) use pip_install::pip_install; pub(crate) use pip_list::pip_list; diff --git a/crates/uv/src/commands/pip_compile.rs b/crates/uv/src/commands/pip_compile.rs index b7118a92c..14320dcff 100644 --- a/crates/uv/src/commands/pip_compile.rs +++ b/crates/uv/src/commands/pip_compile.rs @@ -11,7 +11,6 @@ use anyhow::{anyhow, Context, Result}; use chrono::{DateTime, Utc}; use itertools::Itertools; use owo_colors::OwoColorize; -use rustc_hash::FxHashSet; use tempfile::tempdir_in; use tracing::debug; @@ -26,6 +25,10 @@ use uv_fs::Simplified; use uv_installer::{Downloader, NoBinary}; use uv_interpreter::{find_best_python, PythonEnvironment, PythonVersion}; use uv_normalize::{ExtraName, PackageName}; +use uv_requirements::{ + upgrade::{read_lockfile, Upgrade}, + ExtrasSpecification, NamedRequirements, RequirementsSource, RequirementsSpecification, +}; use uv_resolver::{ AnnotationStyle, DependencyMode, DisplayResolutionGraph, InMemoryIndex, Manifest, OptionsBuilder, PreReleaseMode, PythonRequirement, ResolutionMode, Resolver, @@ -36,10 +39,6 @@ use uv_warnings::warn_user; use crate::commands::reporters::{DownloadReporter, ResolverReporter}; use crate::commands::{elapsed, ExitStatus}; use crate::printer::Printer; -use crate::requirements::{ - read_lockfile, ExtrasSpecification, NamedRequirements, RequirementsSource, - RequirementsSpecification, -}; /// Resolve a set of requirements into a set of pinned versions. #[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)] @@ -577,42 +576,6 @@ impl OutputWriter { } } -/// Whether to allow package upgrades. -#[derive(Debug)] -pub(crate) enum Upgrade { - /// Prefer pinned versions from the existing lockfile, if possible. - None, - - /// Allow package upgrades for all packages, ignoring the existing lockfile. - All, - - /// Allow package upgrades, but only for the specified packages. - Packages(FxHashSet), -} - -impl Upgrade { - /// Determine the upgrade strategy from the command-line arguments. - pub(crate) fn from_args(upgrade: bool, upgrade_package: Vec) -> Self { - if upgrade { - Self::All - } else if !upgrade_package.is_empty() { - Self::Packages(upgrade_package.into_iter().collect()) - } else { - Self::None - } - } - - /// Returns `true` if no packages should be upgraded. - pub(crate) fn is_none(&self) -> bool { - matches!(self, Self::None) - } - - /// Returns `true` if all packages should be upgraded. - pub(crate) fn is_all(&self) -> bool { - matches!(self, Self::All) - } -} - pub(crate) fn extra_name_with_clap_error(arg: &str) -> Result { ExtraName::from_str(arg).map_err(|_err| { anyhow!( diff --git a/crates/uv/src/commands/pip_install.rs b/crates/uv/src/commands/pip_install.rs index b5771985f..1a97b5564 100644 --- a/crates/uv/src/commands/pip_install.rs +++ b/crates/uv/src/commands/pip_install.rs @@ -30,6 +30,10 @@ use uv_installer::{ }; use uv_interpreter::{Interpreter, PythonEnvironment}; use uv_normalize::PackageName; +use uv_requirements::{ + upgrade::Upgrade, ExtrasSpecification, NamedRequirements, RequirementsSource, + RequirementsSpecification, +}; use uv_resolver::{ DependencyMode, InMemoryIndex, Manifest, Options, OptionsBuilder, PreReleaseMode, Preference, ResolutionGraph, ResolutionMode, Resolver, @@ -40,11 +44,8 @@ use uv_warnings::warn_user; use crate::commands::reporters::{DownloadReporter, InstallReporter, ResolverReporter}; use crate::commands::{compile_bytecode, elapsed, ChangeEvent, ChangeEventKind, ExitStatus}; use crate::printer::Printer; -use crate::requirements::{ - ExtrasSpecification, NamedRequirements, RequirementsSource, RequirementsSpecification, -}; -use super::{DryRunEvent, Upgrade}; +use super::DryRunEvent; /// Install packages into the current environment. #[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)] diff --git a/crates/uv/src/commands/pip_sync.rs b/crates/uv/src/commands/pip_sync.rs index 57db4cdc6..54979f801 100644 --- a/crates/uv/src/commands/pip_sync.rs +++ b/crates/uv/src/commands/pip_sync.rs @@ -26,7 +26,7 @@ use uv_warnings::warn_user; use crate::commands::reporters::{DownloadReporter, FinderReporter, InstallReporter}; use crate::commands::{compile_bytecode, elapsed, ChangeEvent, ChangeEventKind, ExitStatus}; use crate::printer::Printer; -use crate::requirements::{NamedRequirements, RequirementsSource, RequirementsSpecification}; +use uv_requirements::{NamedRequirements, RequirementsSource, RequirementsSpecification}; /// Install a set of locked requirements into the current Python environment. #[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)] diff --git a/crates/uv/src/commands/pip_uninstall.rs b/crates/uv/src/commands/pip_uninstall.rs index 0dfe5726b..995562de9 100644 --- a/crates/uv/src/commands/pip_uninstall.rs +++ b/crates/uv/src/commands/pip_uninstall.rs @@ -14,7 +14,7 @@ use uv_interpreter::PythonEnvironment; use crate::commands::{elapsed, ExitStatus}; use crate::printer::Printer; -use crate::requirements::{RequirementsSource, RequirementsSpecification}; +use uv_requirements::{RequirementsSource, RequirementsSpecification}; /// Uninstall packages from the current environment. pub(crate) async fn pip_uninstall( diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index 513362162..cd3c8b4ab 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -13,21 +13,20 @@ use owo_colors::OwoColorize; use tracing::instrument; use distribution_types::{FlatIndexLocation, IndexLocations, IndexUrl}; -use requirements::ExtrasSpecification; use uv_auth::KeyringProvider; use uv_cache::{Cache, CacheArgs, Refresh}; use uv_client::Connectivity; use uv_installer::{NoBinary, Reinstall}; use uv_interpreter::PythonVersion; use uv_normalize::{ExtraName, PackageName}; +use uv_requirements::{upgrade::Upgrade, ExtrasSpecification, RequirementsSource}; use uv_resolver::{AnnotationStyle, DependencyMode, PreReleaseMode, ResolutionMode}; use uv_traits::{ ConfigSettingEntry, ConfigSettings, NoBuild, PackageNameSpecifier, SetupPyStrategy, }; -use crate::commands::{extra_name_with_clap_error, ExitStatus, ListFormat, Upgrade, VersionFormat}; +use crate::commands::{extra_name_with_clap_error, ExitStatus, ListFormat, VersionFormat}; use crate::compat::CompatArgs; -use crate::requirements::RequirementsSource; #[cfg(target_os = "windows")] #[global_allocator] @@ -47,10 +46,8 @@ static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; mod commands; mod compat; -mod confirm; mod logging; mod printer; -mod requirements; mod shell; mod version;