diff --git a/crates/requirements-txt/src/lib.rs b/crates/requirements-txt/src/lib.rs index 16ad58699020..ca00632ecb6c 100644 --- a/crates/requirements-txt/src/lib.rs +++ b/crates/requirements-txt/src/lib.rs @@ -262,6 +262,7 @@ impl RequirementsTxt { read_url_to_string(&requirements_txt, client).await } } else { + // Ex) `file:///home/ferris/project/requirements.txt` uv_fs::read_to_string_transcode(&requirements_txt) .await .map_err(RequirementsTxtParserError::IO) @@ -321,6 +322,22 @@ impl RequirementsTxt { let sub_file = if filename.starts_with("http://") || filename.starts_with("https://") { PathBuf::from(filename.as_ref()) + } else if filename.starts_with("file://") { + requirements_txt.join( + Url::parse(filename.as_ref()) + .map_err(|err| RequirementsTxtParserError::Url { + source: err, + url: filename.to_string(), + start, + end, + })? + .to_file_path() + .map_err(|()| RequirementsTxtParserError::FileUrl { + url: filename.to_string(), + start, + end, + })?, + ) } else { requirements_dir.join(filename.as_ref()) }; @@ -360,6 +377,22 @@ impl RequirementsTxt { let sub_file = if filename.starts_with("http://") || filename.starts_with("https://") { PathBuf::from(filename.as_ref()) + } else if filename.starts_with("file://") { + requirements_txt.join( + Url::parse(filename.as_ref()) + .map_err(|err| RequirementsTxtParserError::Url { + source: err, + url: filename.to_string(), + start, + end, + })? + .to_file_path() + .map_err(|()| RequirementsTxtParserError::FileUrl { + url: filename.to_string(), + start, + end, + })?, + ) } else { requirements_dir.join(filename.as_ref()) }; @@ -815,6 +848,11 @@ pub enum RequirementsTxtParserError { start: usize, end: usize, }, + FileUrl { + url: String, + start: usize, + end: usize, + }, VerbatimUrl { source: pep508_rs::VerbatimUrlError, url: String, @@ -882,6 +920,9 @@ impl Display for RequirementsTxtParserError { Self::Url { url, start, .. } => { write!(f, "Invalid URL at position {start}: `{url}`") } + Self::FileUrl { url, start, .. } => { + write!(f, "Invalid file URL at position {start}: `{url}`") + } Self::VerbatimUrl { source, url } => { write!(f, "Invalid URL: `{url}`: {source}") } @@ -945,6 +986,7 @@ impl std::error::Error for RequirementsTxtParserError { match &self { Self::IO(err) => err.source(), Self::Url { source, .. } => Some(source), + Self::FileUrl { .. } => None, Self::VerbatimUrl { source, .. } => Some(source), Self::UrlConversion(_) => None, Self::UnsupportedUrl(_) => None, @@ -976,6 +1018,13 @@ impl Display for RequirementsTxtFileError { self.file.user_display(), ) } + RequirementsTxtParserError::FileUrl { url, start, .. } => { + write!( + f, + "Invalid file URL in `{}` at position {start}: `{url}`", + self.file.user_display(), + ) + } RequirementsTxtParserError::VerbatimUrl { url, .. } => { write!(f, "Invalid URL in `{}`: `{url}`", self.file.user_display()) } diff --git a/crates/uv/src/cli.rs b/crates/uv/src/cli.rs index d30c4ef4a058..3b262f2a60c8 100644 --- a/crates/uv/src/cli.rs +++ b/crates/uv/src/cli.rs @@ -253,25 +253,40 @@ fn parse_index_url(input: &str) -> Result, String> { } } -/// Parse a string into a [`PathBuf`], mapping the empty string to `None`. -fn parse_file_path(input: &str) -> Result, String> { - if input.is_empty() { - Ok(Maybe::None) +/// Parse a string into a [`PathBuf`]. The string can represent a file, either as a path or a +/// `file://` URL. +fn parse_file_path(input: &str) -> Result { + if input.starts_with("file://") { + let url = match url::Url::from_str(input) { + Ok(url) => url, + Err(err) => return Err(err.to_string()), + }; + url.to_file_path() + .map_err(|()| "invalid file URL".to_string()) } else { match PathBuf::from_str(input) { - Ok(path) => Ok(Maybe::Some(path)), + Ok(path) => Ok(path), Err(err) => Err(err.to_string()), } } } +/// Parse a string into a [`PathBuf`], mapping the empty string to `None`. +fn parse_maybe_file_path(input: &str) -> Result, String> { + if input.is_empty() { + Ok(Maybe::None) + } else { + parse_file_path(input).map(Maybe::Some) + } +} + #[derive(Args)] #[allow(clippy::struct_excessive_bools)] pub(crate) struct PipCompileArgs { /// Include all packages listed in the given `requirements.in` files. /// /// When the path is `-`, then requirements are read from stdin. - #[arg(required(true))] + #[arg(required(true), value_parser = parse_file_path)] pub(crate) src_file: Vec, /// Constrain versions using the given requirements files. @@ -281,7 +296,7 @@ pub(crate) struct PipCompileArgs { /// trigger the installation of that package. /// /// This is equivalent to pip's `--constraint` option. - #[arg(long, short, env = "UV_CONSTRAINT", value_delimiter = ' ', value_parser = parse_file_path)] + #[arg(long, short, env = "UV_CONSTRAINT", value_delimiter = ' ', value_parser = parse_maybe_file_path)] pub(crate) constraint: Vec>, /// Override versions using the given requirements files. @@ -293,7 +308,7 @@ pub(crate) struct PipCompileArgs { /// While constraints are _additive_, in that they're combined with the requirements of the /// constituent packages, overrides are _absolute_, in that they completely replace the /// requirements of the constituent packages. - #[arg(long)] + #[arg(long, value_parser = parse_file_path)] pub(crate) r#override: Vec, /// Include optional dependencies in the given extra group name; may be provided more than once. @@ -593,7 +608,7 @@ pub(crate) struct PipCompileArgs { #[allow(clippy::struct_excessive_bools)] pub(crate) struct PipSyncArgs { /// Include all packages listed in the given `requirements.txt` files. - #[arg(required(true))] + #[arg(required(true), value_parser = parse_file_path)] pub(crate) src_file: Vec, /// Constrain versions using the given requirements files. @@ -603,7 +618,7 @@ pub(crate) struct PipSyncArgs { /// trigger the installation of that package. /// /// This is equivalent to pip's `--constraint` option. - #[arg(long, short, env = "UV_CONSTRAINT", value_delimiter = ' ', value_parser = parse_file_path)] + #[arg(long, short, env = "UV_CONSTRAINT", value_delimiter = ' ', value_parser = parse_maybe_file_path)] pub(crate) constraint: Vec>, /// Reinstall all packages, regardless of whether they're already installed. @@ -892,7 +907,7 @@ pub(crate) struct PipInstallArgs { pub(crate) package: Vec, /// Install all packages listed in the given requirements files. - #[arg(long, short, group = "sources")] + #[arg(long, short, group = "sources", value_parser = parse_file_path)] pub(crate) requirement: Vec, /// Install the editable package based on the provided local file path. @@ -906,7 +921,7 @@ pub(crate) struct PipInstallArgs { /// trigger the installation of that package. /// /// This is equivalent to pip's `--constraint` option. - #[arg(long, short, env = "UV_CONSTRAINT", value_delimiter = ' ', value_parser = parse_file_path)] + #[arg(long, short, env = "UV_CONSTRAINT", value_delimiter = ' ', value_parser = parse_maybe_file_path)] pub(crate) constraint: Vec>, /// Override versions using the given requirements files. @@ -918,7 +933,7 @@ pub(crate) struct PipInstallArgs { /// While constraints are _additive_, in that they're combined with the requirements of the /// constituent packages, overrides are _absolute_, in that they completely replace the /// requirements of the constituent packages. - #[arg(long)] + #[arg(long, value_parser = parse_file_path)] pub(crate) r#override: Vec, /// Include optional dependencies in the given extra group name; may be provided more than once. @@ -1259,7 +1274,7 @@ pub(crate) struct PipUninstallArgs { pub(crate) package: Vec, /// Uninstall all packages listed in the given requirements files. - #[arg(long, short, group = "sources")] + #[arg(long, short, group = "sources", value_parser = parse_file_path)] pub(crate) requirement: Vec, /// The Python interpreter from which packages should be uninstalled. diff --git a/crates/uv/tests/pip_compile.rs b/crates/uv/tests/pip_compile.rs index 9de8cc7cfe75..5fc5f2eda8d4 100644 --- a/crates/uv/tests/pip_compile.rs +++ b/crates/uv/tests/pip_compile.rs @@ -9629,3 +9629,29 @@ fn dynamic_pyproject_toml() -> Result<()> { Ok(()) } + +/// Accept `file://` URLs as installation sources. +#[test] +fn file_url() -> Result<()> { + let context = TestContext::new("3.12"); + + let requirements_txt = context.temp_dir.child("requirements file.txt"); + requirements_txt.write_str("iniconfig")?; + + let url = Url::from_file_path(requirements_txt.canonicalize()?).expect("valid file URL"); + + uv_snapshot!(context.filters(), context.compile().arg(url.to_string()), @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 file://[TEMP_DIR]/requirements%20file.txt + iniconfig==2.0.0 + # via -r requirements file.txt + + ----- stderr ----- + Resolved 1 package in [TIME] + "###); + + Ok(()) +}