From 8587c440febb7e6ac6bd093d971cfe377bc20bc2 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 25 Mar 2024 15:23:26 -0400 Subject: [PATCH] Support `file://localhost/` schemes (#2657) ## Summary `uv` was failing to install requirements defined like: ``` file://localhost/Users/crmarsh/Downloads/iniconfig-2.0.0-py3-none-any.whl ``` Closes https://github.com/astral-sh/uv/issues/2652. --- crates/distribution-types/src/index_url.rs | 7 +- crates/pep508-rs/src/lib.rs | 12 ++-- crates/pep508-rs/src/verbatim_url.rs | 18 +++++ crates/requirements-txt/src/lib.rs | 10 +-- crates/uv-distribution/src/source/mod.rs | 2 +- crates/uv/tests/pip_compile.rs | 83 ++++++++++++++++++++++ 6 files changed, 119 insertions(+), 13 deletions(-) diff --git a/crates/distribution-types/src/index_url.rs b/crates/distribution-types/src/index_url.rs index 93c176b97..edfdfea3d 100644 --- a/crates/distribution-types/src/index_url.rs +++ b/crates/distribution-types/src/index_url.rs @@ -9,7 +9,7 @@ use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; use url::Url; -use pep508_rs::{expand_env_vars, split_scheme, Scheme, VerbatimUrl}; +use pep508_rs::{expand_env_vars, split_scheme, strip_host, Scheme, VerbatimUrl}; use uv_fs::normalize_url_path; use crate::Verbatim; @@ -124,9 +124,10 @@ impl FromStr for FlatIndexLocation { // Parse the expanded path. if let Some((scheme, path)) = split_scheme(&expanded) { match Scheme::parse(scheme) { - // Ex) `file:///home/ferris/project/scripts/...` or `file:../ferris/` + // Ex) `file:///home/ferris/project/scripts/...`, `file://localhost/home/ferris/project/scripts/...`, or `file:../ferris/` Some(Scheme::File) => { - let path = path.strip_prefix("//").unwrap_or(path); + // Strip the leading slashes, along with the `localhost` host, if present. + let path = strip_host(path); // Transform, e.g., `/C:/Users/ferris/wheel-0.42.0.tar.gz` to `C:\Users\ferris\wheel-0.42.0.tar.gz`. let path = normalize_url_path(path); diff --git a/crates/pep508-rs/src/lib.rs b/crates/pep508-rs/src/lib.rs index 932d5dd97..1aa88cd34 100644 --- a/crates/pep508-rs/src/lib.rs +++ b/crates/pep508-rs/src/lib.rs @@ -46,7 +46,7 @@ use uv_fs::normalize_url_path; // Parity with the crates.io version of pep508_rs use crate::verbatim_url::VerbatimUrlError; pub use uv_normalize::{ExtraName, InvalidNameError, PackageName}; -pub use verbatim_url::{expand_env_vars, split_scheme, Scheme, VerbatimUrl}; +pub use verbatim_url::{expand_env_vars, split_scheme, strip_host, Scheme, VerbatimUrl}; mod marker; mod verbatim_url; @@ -1018,9 +1018,10 @@ fn preprocess_url( if let Some((scheme, path)) = split_scheme(&expanded) { match Scheme::parse(scheme) { - // Ex) `file:///home/ferris/project/scripts/...` or `file:../editable/`. + // Ex) `file:///home/ferris/project/scripts/...`, `file://localhost/home/ferris/project/scripts/...`, or `file:../ferris/` Some(Scheme::File) => { - let path = path.strip_prefix("//").unwrap_or(path); + // Strip the leading slashes, along with the `localhost` host, if present. + let path = strip_host(path); // Transform, e.g., `/C:/Users/ferris/wheel-0.42.0.tar.gz` to `C:\Users\ferris\wheel-0.42.0.tar.gz`. let path = normalize_url_path(path); @@ -1156,9 +1157,10 @@ fn preprocess_unnamed_url( if let Some((scheme, path)) = split_scheme(&expanded) { match Scheme::parse(scheme) { - // Ex) `file:///home/ferris/project/scripts/...` or `file:../editable/`. + // Ex) `file:///home/ferris/project/scripts/...`, `file://localhost/home/ferris/project/scripts/...`, or `file:../ferris/` Some(Scheme::File) => { - let path = path.strip_prefix("//").unwrap_or(path); + // Strip the leading slashes, along with the `localhost` host, if present. + let path = strip_host(path); // Transform, e.g., `/C:/Users/ferris/wheel-0.42.0.tar.gz` to `C:\Users\ferris\wheel-0.42.0.tar.gz`. let path = normalize_url_path(path); diff --git a/crates/pep508-rs/src/verbatim_url.rs b/crates/pep508-rs/src/verbatim_url.rs index 48d9e684d..218fec7e4 100644 --- a/crates/pep508-rs/src/verbatim_url.rs +++ b/crates/pep508-rs/src/verbatim_url.rs @@ -264,6 +264,24 @@ pub fn split_scheme(s: &str) -> Option<(&str, &str)> { Some((scheme, rest)) } +/// Strip the `file://localhost/` host from a file path. +pub fn strip_host(path: &str) -> &str { + // Ex) `file://localhost/...`. + if let Some(path) = path + .strip_prefix("//localhost") + .filter(|path| path.starts_with('/')) + { + return path; + } + + // Ex) `file:///...`. + if let Some(path) = path.strip_prefix("//") { + return path; + } + + path +} + /// Split the fragment from a URL. /// /// For example, given `file:///home/ferris/project/scripts#hash=somehash`, returns diff --git a/crates/requirements-txt/src/lib.rs b/crates/requirements-txt/src/lib.rs index c71ca2362..0e44ef072 100644 --- a/crates/requirements-txt/src/lib.rs +++ b/crates/requirements-txt/src/lib.rs @@ -46,7 +46,7 @@ use unscanny::{Pattern, Scanner}; use url::Url; use pep508_rs::{ - expand_env_vars, split_scheme, Extras, Pep508Error, Pep508ErrorSource, Requirement, + expand_env_vars, split_scheme, strip_host, Extras, Pep508Error, Pep508ErrorSource, Requirement, RequirementsTxtRequirement, Scheme, VerbatimUrl, }; #[cfg(feature = "http")] @@ -104,9 +104,10 @@ impl FindLink { if let Some((scheme, path)) = split_scheme(&expanded) { match Scheme::parse(scheme) { - // Ex) `file:///home/ferris/project/scripts/...` or `file:../ferris/` + // Ex) `file:///home/ferris/project/scripts/...`, `file://localhost/home/ferris/project/scripts/...`, or `file:../ferris/` Some(Scheme::File) => { - let path = path.strip_prefix("//").unwrap_or(path); + // Strip the leading slashes, along with the `localhost` host, if present. + let path = strip_host(path); // Transform, e.g., `/C:/Users/ferris/wheel-0.42.0.tar.gz` to `C:\Users\ferris\wheel-0.42.0.tar.gz`. let path = normalize_url_path(path); @@ -221,7 +222,8 @@ impl EditableRequirement { match Scheme::parse(scheme) { // Ex) `file:///home/ferris/project/scripts/...` or `file:../editable/` Some(Scheme::File) => { - let path = path.strip_prefix("//").unwrap_or(path); + // Strip the leading slashes, along with the `localhost` host, if present. + let path = strip_host(path); // Transform, e.g., `/C:/Users/ferris/wheel-0.42.0.tar.gz` to `C:\Users\ferris\wheel-0.42.0.tar.gz`. let path = normalize_url_path(path); diff --git a/crates/uv-distribution/src/source/mod.rs b/crates/uv-distribution/src/source/mod.rs index f2afe6b5b..6507d4272 100644 --- a/crates/uv-distribution/src/source/mod.rs +++ b/crates/uv-distribution/src/source/mod.rs @@ -1284,7 +1284,7 @@ pub async fn download_and_extract_archive( client: &RegistryClient, ) -> Result { match Scheme::parse(url.scheme()) { - // Ex) `file:///home/ferris/project/scripts/...` or `file:../editable/`. + // Ex) `file:///home/ferris/project/scripts/...`, `file://localhost/home/ferris/project/scripts/...`, or `file:../ferris/` Some(Scheme::File) => { let path = url.to_file_path().expect("URL to be a file path"); extract_archive(&path, cache).await diff --git a/crates/uv/tests/pip_compile.rs b/crates/uv/tests/pip_compile.rs index 2240f5119..24cc979e4 100644 --- a/crates/uv/tests/pip_compile.rs +++ b/crates/uv/tests/pip_compile.rs @@ -2053,6 +2053,89 @@ fn compile_wheel_path_dependency() -> Result<()> { "### ); + // Run the same operation, but this time with an absolute path (rather than a URL), including + // the `file://` prefix. + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str(&format!("flask @ file://{}", flask_wheel.path().display()))?; + + // In addition to the standard filters, remove the temporary directory from the snapshot. + let filter_path = regex::escape(&flask_wheel.user_display().to_string()); + let filters: Vec<_> = [(filter_path.as_str(), "/[TEMP_DIR]/")] + .into_iter() + .chain(INSTA_FILTERS.to_vec()) + .collect(); + + uv_snapshot!(filters, context.compile() + .arg("requirements.in"), @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 2024-03-25T00:00:00Z requirements.in + blinker==1.7.0 + # via flask + click==8.1.7 + # via flask + flask @ file:///[TEMP_DIR]/ + itsdangerous==2.1.2 + # via flask + jinja2==3.1.3 + # via flask + markupsafe==2.1.5 + # via + # jinja2 + # werkzeug + werkzeug==3.0.1 + # via flask + + ----- stderr ----- + Resolved 7 packages in [TIME] + "### + ); + + // Run the same operation, but this time with an absolute path (rather than a URL), including + // the `file://localhost/` prefix. + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str(&format!( + "flask @ file://localhost/{}", + flask_wheel.path().display() + ))?; + + // In addition to the standard filters, remove the temporary directory from the snapshot. + let filter_path = regex::escape(&flask_wheel.user_display().to_string()); + let filters: Vec<_> = [(filter_path.as_str(), "/[TEMP_DIR]/")] + .into_iter() + .chain(INSTA_FILTERS.to_vec()) + .collect(); + + uv_snapshot!(filters, context.compile() + .arg("requirements.in"), @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 2024-03-25T00:00:00Z requirements.in + blinker==1.7.0 + # via flask + click==8.1.7 + # via flask + flask @ file://localhost//[TEMP_DIR]/ + itsdangerous==2.1.2 + # via flask + jinja2==3.1.3 + # via flask + markupsafe==2.1.5 + # via + # jinja2 + # werkzeug + werkzeug==3.0.1 + # via flask + + ----- stderr ----- + Resolved 7 packages in [TIME] + "### + ); + Ok(()) }