Skip to content

Commit

Permalink
Add support for unnamed local directory requirements
Browse files Browse the repository at this point in the history
  • Loading branch information
charliermarsh committed Mar 20, 2024
1 parent c5e8a94 commit 81e1ded
Show file tree
Hide file tree
Showing 12 changed files with 284 additions and 3 deletions.
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 24 additions & 0 deletions crates/pypi-types/src/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,30 @@ impl Metadata23 {
}
}

/// Python Package Metadata 1.0 and later as specified in
/// <https://peps.python.org/pep-0241/>.
///
/// This is a subset of the full metadata specification, and only includes the
/// fields that have been consistent across all versions of the specification.
#[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub struct Metadata10 {
pub name: PackageName,
}

impl Metadata10 {
/// Parse the [`Metadata10`] from a `PKG-INFO` file, as included in a source distribution.
pub fn parse_pkg_info(content: &[u8]) -> Result<Self, Error> {
let headers = Headers::parse(content)?;
let name = PackageName::new(
headers
.get_first_value("Name")
.ok_or(Error::FieldNotFound("Name"))?,
)?;
Ok(Self { name })
}
}

/// Parse a `Metadata-Version` field into a (major, minor) tuple.
fn parse_version(metadata_version: &str) -> Result<(u8, u8), Error> {
let (major, minor) = metadata_version
Expand Down
5 changes: 4 additions & 1 deletion crates/uv/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ default-run = "uv"
workspace = true

[dependencies]
distribution-filename = { path = "../distribution-filename" }
distribution-types = { path = "../distribution-types" }
install-wheel-rs = { path = "../install-wheel-rs", features = ["clap"], default-features = false }
pep508_rs = { path = "../pep508-rs" }
Expand All @@ -40,6 +41,7 @@ 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 }
Expand All @@ -48,8 +50,10 @@ indexmap = { workspace = true }
indicatif = { workspace = true }
itertools = { workspace = true }
miette = { workspace = true, features = ["fancy"] }
once_cell = { workspace = true }
owo-colors = { workspace = true }
pyproject-toml = { workspace = true }
regex = { workspace = true }
rustc-hash = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
Expand All @@ -64,7 +68,6 @@ tracing-subscriber = { workspace = true, features = ["json"] }
tracing-tree = { workspace = true }
unicode-width = { workspace = true }
url = { workspace = true }
distribution-filename = { version = "0.0.1", path = "../distribution-filename" }

[target.'cfg(target_os = "windows")'.dependencies]
mimalloc = { version = "0.1.39" }
Expand Down
163 changes: 161 additions & 2 deletions crates/uv/src/requirements.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,21 @@ use std::path::{Path, PathBuf};
use std::str::FromStr;

use anyhow::{Context, Result};
use configparser::ini::Ini;
use console::Term;
use distribution_filename::{SourceDistFilename, WheelFilename};
use indexmap::IndexMap;
use once_cell::sync::Lazy;
use regex::Regex;
use rustc_hash::FxHashSet;
use tracing::{instrument, Level};
use serde::Deserialize;
use tracing::{debug, instrument, Level};

use distribution_types::{FlatIndexLocation, IndexUrl, RemoteSource};
use pep508_rs::{Requirement, RequirementsTxtRequirement, UnnamedRequirement, VersionOrUrl};
use pep508_rs::{
Requirement, RequirementsTxtRequirement, Scheme, UnnamedRequirement, VersionOrUrl,
};
use pypi_types::Metadata10;
use requirements_txt::{EditableRequirement, FindLink, RequirementsTxt};
use uv_client::Connectivity;
use uv_fs::Simplified;
Expand Down Expand Up @@ -576,8 +583,160 @@ impl NamedRequirements {
});
}

// Otherwise, download and/or extract the source archive.
if Scheme::parse(requirement.url.scheme()) == Some(Scheme::File) {
let path = requirement.url.to_file_path().map_err(|()| {
anyhow::anyhow!("Unable to convert file URL to path: {requirement}")
})?;

if !path.exists() {
return Err(anyhow::anyhow!(
"Unnamed requirement at {path} not found",
path = path.simplified_display()
));
}

// 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::<PyProjectToml>(&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<Regex> =
Lazy::new(|| Regex::new(r#"name\s*[=:]\s*['"](?P<name>[^'"]+)['"]"#).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,
});
}
}
}
}

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<Project>,
tool: Option<Tool>,
}

#[derive(Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
struct Project {
name: PackageName,
}

#[derive(Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
struct Tool {
poetry: Option<ToolPoetry>,
}

#[derive(Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
struct ToolPoetry {
name: Option<PackageName>,
}
69 changes: 69 additions & 0 deletions crates/uv/tests/pip_compile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5746,3 +5746,72 @@ fn preserve_hashes_newer_version() -> Result<()> {

Ok(())
}

/// Detect the package name from metadata sources from local directories.
#[test]
fn detect_package_name() -> Result<()> {
let context = TestContext::new("3.12");
let requirements_in = context.temp_dir.child("requirements.in");
requirements_in.write_str(indoc! {r"
../../scripts/editable-installs/poetry_editable
../../scripts/editable-installs/black_editable
../../scripts/editable-installs/setup_py_editable
../../scripts/editable-installs/setup_cfg_editable
"
})?;

let filter_path = regex::escape(&requirements_in.user_display().to_string());
let filters: Vec<_> = [(filter_path.as_str(), "requirements.in")]
.into_iter()
.chain(INSTA_FILTERS.to_vec())
.collect();

uv_snapshot!(filters, context.compile()
.arg(requirements_in.path())
.current_dir(current_dir()?), @r###"
success: true
exit_code: 0
----- stdout -----
# This file was autogenerated by uv via the following command:
# uv pip compile --cache-dir [CACHE_DIR] --exclude-newer 2023-11-18T12:00:00Z requirements.in
anyio==4.0.0
# via
# httpx
# poetry-editable
black @ ../../scripts/editable-installs/black_editable
certifi==2023.11.17
# via
# httpcore
# httpx
# requests
charset-normalizer==3.3.2
# via requests
h11==0.14.0
# via httpcore
httpcore==1.0.2
# via httpx
httpx==0.25.1
# via setup-py-editable
idna==3.4
# via
# anyio
# httpx
# requests
poetry-editable @ ../../scripts/editable-installs/poetry_editable
requests==2.31.0
# via setup-cfg-editable
setup-cfg-editable @ ../../scripts/editable-installs/setup_cfg_editable
setup-py-editable @ ../../scripts/editable-installs/setup_py_editable
sniffio==1.3.0
# via
# anyio
# httpx
urllib3==2.1.0
# via requests
----- stderr -----
Resolved 14 packages in [TIME]
"###);

Ok(())
}
2 changes: 2 additions & 0 deletions scripts/editable-installs/setup_cfg_editable/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Artifacts from the build process.
*.egg-info/
3 changes: 3 additions & 0 deletions scripts/editable-installs/setup_cfg_editable/setup.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[metadata]
name = setup-cfg-editable
version = 0.0.1
8 changes: 8 additions & 0 deletions scripts/editable-installs/setup_cfg_editable/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from setuptools import setup

setup(
install_requires=[
"requests",
'importlib-metadata; python_version<"3.10"',
],
)
Empty file.
2 changes: 2 additions & 0 deletions scripts/editable-installs/setup_py_editable/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Artifacts from the build process.
*.egg-info/
9 changes: 9 additions & 0 deletions scripts/editable-installs/setup_py_editable/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from setuptools import setup

setup(
name="setup-py-editable",
version="0.0.1",
install_requires=[
"httpx",
],
)
Empty file.

0 comments on commit 81e1ded

Please sign in to comment.