diff --git a/Cargo.lock b/Cargo.lock
index 8b16e2fb5818..bad56abcdfa0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4355,6 +4355,7 @@ dependencies = [
"chrono",
"clap",
"clap_complete_command",
+ "configparser",
"console",
"ctrlc",
"distribution-filename",
@@ -4370,6 +4371,7 @@ dependencies = [
"itertools 0.12.1",
"miette 6.0.1",
"mimalloc",
+ "once_cell",
"owo-colors 4.0.0",
"pep508_rs",
"platform-tags",
diff --git a/crates/pypi-types/src/metadata.rs b/crates/pypi-types/src/metadata.rs
index 4636f50b6cb1..93e508ed1207 100644
--- a/crates/pypi-types/src/metadata.rs
+++ b/crates/pypi-types/src/metadata.rs
@@ -210,6 +210,30 @@ impl Metadata23 {
}
}
+/// Python Package Metadata 1.0 and later as specified in
+/// .
+///
+/// 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 {
+ 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
diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml
index c68b44f10f3b..87a7066e146d 100644
--- a/crates/uv/Cargo.toml
+++ b/crates/uv/Cargo.toml
@@ -14,6 +14,7 @@ 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 }
@@ -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 }
@@ -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 }
@@ -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" }
diff --git a/crates/uv/src/requirements.rs b/crates/uv/src/requirements.rs
index 1c2a5ba422d1..272b929b99f6 100644
--- a/crates/uv/src/requirements.rs
+++ b/crates/uv/src/requirements.rs
@@ -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;
@@ -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::(&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,
+ });
+ }
+ }
+ }
+ }
+
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/tests/pip_compile.rs b/crates/uv/tests/pip_compile.rs
index 980b4078b79f..58d5d2325aa0 100644
--- a/crates/uv/tests/pip_compile.rs
+++ b/crates/uv/tests/pip_compile.rs
@@ -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(())
+}
diff --git a/scripts/editable-installs/setup_cfg_editable/.gitignore b/scripts/editable-installs/setup_cfg_editable/.gitignore
new file mode 100644
index 000000000000..eaa9f051bc40
--- /dev/null
+++ b/scripts/editable-installs/setup_cfg_editable/.gitignore
@@ -0,0 +1,2 @@
+# Artifacts from the build process.
+*.egg-info/
diff --git a/scripts/editable-installs/setup_cfg_editable/setup.cfg b/scripts/editable-installs/setup_cfg_editable/setup.cfg
new file mode 100644
index 000000000000..a907ed6354ca
--- /dev/null
+++ b/scripts/editable-installs/setup_cfg_editable/setup.cfg
@@ -0,0 +1,3 @@
+[metadata]
+name = setup-cfg-editable
+version = 0.0.1
diff --git a/scripts/editable-installs/setup_cfg_editable/setup.py b/scripts/editable-installs/setup_cfg_editable/setup.py
new file mode 100644
index 000000000000..7e49ce652982
--- /dev/null
+++ b/scripts/editable-installs/setup_cfg_editable/setup.py
@@ -0,0 +1,8 @@
+from setuptools import setup
+
+setup(
+ install_requires=[
+ "requests",
+ 'importlib-metadata; python_version<"3.10"',
+ ],
+)
diff --git a/scripts/editable-installs/setup_cfg_editable/setup_cfg_editable/__init__.py b/scripts/editable-installs/setup_cfg_editable/setup_cfg_editable/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/scripts/editable-installs/setup_py_editable/.gitignore b/scripts/editable-installs/setup_py_editable/.gitignore
new file mode 100644
index 000000000000..eaa9f051bc40
--- /dev/null
+++ b/scripts/editable-installs/setup_py_editable/.gitignore
@@ -0,0 +1,2 @@
+# Artifacts from the build process.
+*.egg-info/
diff --git a/scripts/editable-installs/setup_py_editable/setup.py b/scripts/editable-installs/setup_py_editable/setup.py
new file mode 100644
index 000000000000..6308361a3434
--- /dev/null
+++ b/scripts/editable-installs/setup_py_editable/setup.py
@@ -0,0 +1,9 @@
+from setuptools import setup
+
+setup(
+ name="setup-py-editable",
+ version="0.0.1",
+ install_requires=[
+ "httpx",
+ ],
+)
diff --git a/scripts/editable-installs/setup_py_editable/setup_py_editable/__init__.py b/scripts/editable-installs/setup_py_editable/setup_py_editable/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1