Skip to content

Commit d7cc622

Browse files
Accept file:// URLs for requirements.txt et all references (#4145)
## Summary Closes #4124.
1 parent e3b2744 commit d7cc622

File tree

3 files changed

+104
-14
lines changed

3 files changed

+104
-14
lines changed

crates/requirements-txt/src/lib.rs

+49
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,7 @@ impl RequirementsTxt {
262262
read_url_to_string(&requirements_txt, client).await
263263
}
264264
} else {
265+
// Ex) `file:///home/ferris/project/requirements.txt`
265266
uv_fs::read_to_string_transcode(&requirements_txt)
266267
.await
267268
.map_err(RequirementsTxtParserError::IO)
@@ -321,6 +322,22 @@ impl RequirementsTxt {
321322
let sub_file =
322323
if filename.starts_with("http://") || filename.starts_with("https://") {
323324
PathBuf::from(filename.as_ref())
325+
} else if filename.starts_with("file://") {
326+
requirements_txt.join(
327+
Url::parse(filename.as_ref())
328+
.map_err(|err| RequirementsTxtParserError::Url {
329+
source: err,
330+
url: filename.to_string(),
331+
start,
332+
end,
333+
})?
334+
.to_file_path()
335+
.map_err(|()| RequirementsTxtParserError::FileUrl {
336+
url: filename.to_string(),
337+
start,
338+
end,
339+
})?,
340+
)
324341
} else {
325342
requirements_dir.join(filename.as_ref())
326343
};
@@ -360,6 +377,22 @@ impl RequirementsTxt {
360377
let sub_file =
361378
if filename.starts_with("http://") || filename.starts_with("https://") {
362379
PathBuf::from(filename.as_ref())
380+
} else if filename.starts_with("file://") {
381+
requirements_txt.join(
382+
Url::parse(filename.as_ref())
383+
.map_err(|err| RequirementsTxtParserError::Url {
384+
source: err,
385+
url: filename.to_string(),
386+
start,
387+
end,
388+
})?
389+
.to_file_path()
390+
.map_err(|()| RequirementsTxtParserError::FileUrl {
391+
url: filename.to_string(),
392+
start,
393+
end,
394+
})?,
395+
)
363396
} else {
364397
requirements_dir.join(filename.as_ref())
365398
};
@@ -815,6 +848,11 @@ pub enum RequirementsTxtParserError {
815848
start: usize,
816849
end: usize,
817850
},
851+
FileUrl {
852+
url: String,
853+
start: usize,
854+
end: usize,
855+
},
818856
VerbatimUrl {
819857
source: pep508_rs::VerbatimUrlError,
820858
url: String,
@@ -882,6 +920,9 @@ impl Display for RequirementsTxtParserError {
882920
Self::Url { url, start, .. } => {
883921
write!(f, "Invalid URL at position {start}: `{url}`")
884922
}
923+
Self::FileUrl { url, start, .. } => {
924+
write!(f, "Invalid file URL at position {start}: `{url}`")
925+
}
885926
Self::VerbatimUrl { source, url } => {
886927
write!(f, "Invalid URL: `{url}`: {source}")
887928
}
@@ -945,6 +986,7 @@ impl std::error::Error for RequirementsTxtParserError {
945986
match &self {
946987
Self::IO(err) => err.source(),
947988
Self::Url { source, .. } => Some(source),
989+
Self::FileUrl { .. } => None,
948990
Self::VerbatimUrl { source, .. } => Some(source),
949991
Self::UrlConversion(_) => None,
950992
Self::UnsupportedUrl(_) => None,
@@ -976,6 +1018,13 @@ impl Display for RequirementsTxtFileError {
9761018
self.file.user_display(),
9771019
)
9781020
}
1021+
RequirementsTxtParserError::FileUrl { url, start, .. } => {
1022+
write!(
1023+
f,
1024+
"Invalid file URL in `{}` at position {start}: `{url}`",
1025+
self.file.user_display(),
1026+
)
1027+
}
9791028
RequirementsTxtParserError::VerbatimUrl { url, .. } => {
9801029
write!(f, "Invalid URL in `{}`: `{url}`", self.file.user_display())
9811030
}

crates/uv/src/cli.rs

+29-14
Original file line numberDiff line numberDiff line change
@@ -253,25 +253,40 @@ fn parse_index_url(input: &str) -> Result<Maybe<IndexUrl>, String> {
253253
}
254254
}
255255

256-
/// Parse a string into a [`PathBuf`], mapping the empty string to `None`.
257-
fn parse_file_path(input: &str) -> Result<Maybe<PathBuf>, String> {
258-
if input.is_empty() {
259-
Ok(Maybe::None)
256+
/// Parse a string into a [`PathBuf`]. The string can represent a file, either as a path or a
257+
/// `file://` URL.
258+
fn parse_file_path(input: &str) -> Result<PathBuf, String> {
259+
if input.starts_with("file://") {
260+
let url = match url::Url::from_str(input) {
261+
Ok(url) => url,
262+
Err(err) => return Err(err.to_string()),
263+
};
264+
url.to_file_path()
265+
.map_err(|()| "invalid file URL".to_string())
260266
} else {
261267
match PathBuf::from_str(input) {
262-
Ok(path) => Ok(Maybe::Some(path)),
268+
Ok(path) => Ok(path),
263269
Err(err) => Err(err.to_string()),
264270
}
265271
}
266272
}
267273

274+
/// Parse a string into a [`PathBuf`], mapping the empty string to `None`.
275+
fn parse_maybe_file_path(input: &str) -> Result<Maybe<PathBuf>, String> {
276+
if input.is_empty() {
277+
Ok(Maybe::None)
278+
} else {
279+
parse_file_path(input).map(Maybe::Some)
280+
}
281+
}
282+
268283
#[derive(Args)]
269284
#[allow(clippy::struct_excessive_bools)]
270285
pub(crate) struct PipCompileArgs {
271286
/// Include all packages listed in the given `requirements.in` files.
272287
///
273288
/// When the path is `-`, then requirements are read from stdin.
274-
#[arg(required(true))]
289+
#[arg(required(true), value_parser = parse_file_path)]
275290
pub(crate) src_file: Vec<PathBuf>,
276291

277292
/// Constrain versions using the given requirements files.
@@ -281,7 +296,7 @@ pub(crate) struct PipCompileArgs {
281296
/// trigger the installation of that package.
282297
///
283298
/// This is equivalent to pip's `--constraint` option.
284-
#[arg(long, short, env = "UV_CONSTRAINT", value_delimiter = ' ', value_parser = parse_file_path)]
299+
#[arg(long, short, env = "UV_CONSTRAINT", value_delimiter = ' ', value_parser = parse_maybe_file_path)]
285300
pub(crate) constraint: Vec<Maybe<PathBuf>>,
286301

287302
/// Override versions using the given requirements files.
@@ -293,7 +308,7 @@ pub(crate) struct PipCompileArgs {
293308
/// While constraints are _additive_, in that they're combined with the requirements of the
294309
/// constituent packages, overrides are _absolute_, in that they completely replace the
295310
/// requirements of the constituent packages.
296-
#[arg(long)]
311+
#[arg(long, value_parser = parse_file_path)]
297312
pub(crate) r#override: Vec<PathBuf>,
298313

299314
/// Include optional dependencies in the given extra group name; may be provided more than once.
@@ -593,7 +608,7 @@ pub(crate) struct PipCompileArgs {
593608
#[allow(clippy::struct_excessive_bools)]
594609
pub(crate) struct PipSyncArgs {
595610
/// Include all packages listed in the given `requirements.txt` files.
596-
#[arg(required(true))]
611+
#[arg(required(true), value_parser = parse_file_path)]
597612
pub(crate) src_file: Vec<PathBuf>,
598613

599614
/// Constrain versions using the given requirements files.
@@ -603,7 +618,7 @@ pub(crate) struct PipSyncArgs {
603618
/// trigger the installation of that package.
604619
///
605620
/// This is equivalent to pip's `--constraint` option.
606-
#[arg(long, short, env = "UV_CONSTRAINT", value_delimiter = ' ', value_parser = parse_file_path)]
621+
#[arg(long, short, env = "UV_CONSTRAINT", value_delimiter = ' ', value_parser = parse_maybe_file_path)]
607622
pub(crate) constraint: Vec<Maybe<PathBuf>>,
608623

609624
/// Reinstall all packages, regardless of whether they're already installed.
@@ -892,7 +907,7 @@ pub(crate) struct PipInstallArgs {
892907
pub(crate) package: Vec<String>,
893908

894909
/// Install all packages listed in the given requirements files.
895-
#[arg(long, short, group = "sources")]
910+
#[arg(long, short, group = "sources", value_parser = parse_file_path)]
896911
pub(crate) requirement: Vec<PathBuf>,
897912

898913
/// Install the editable package based on the provided local file path.
@@ -906,7 +921,7 @@ pub(crate) struct PipInstallArgs {
906921
/// trigger the installation of that package.
907922
///
908923
/// This is equivalent to pip's `--constraint` option.
909-
#[arg(long, short, env = "UV_CONSTRAINT", value_delimiter = ' ', value_parser = parse_file_path)]
924+
#[arg(long, short, env = "UV_CONSTRAINT", value_delimiter = ' ', value_parser = parse_maybe_file_path)]
910925
pub(crate) constraint: Vec<Maybe<PathBuf>>,
911926

912927
/// Override versions using the given requirements files.
@@ -918,7 +933,7 @@ pub(crate) struct PipInstallArgs {
918933
/// While constraints are _additive_, in that they're combined with the requirements of the
919934
/// constituent packages, overrides are _absolute_, in that they completely replace the
920935
/// requirements of the constituent packages.
921-
#[arg(long)]
936+
#[arg(long, value_parser = parse_file_path)]
922937
pub(crate) r#override: Vec<PathBuf>,
923938

924939
/// Include optional dependencies in the given extra group name; may be provided more than once.
@@ -1259,7 +1274,7 @@ pub(crate) struct PipUninstallArgs {
12591274
pub(crate) package: Vec<String>,
12601275

12611276
/// Uninstall all packages listed in the given requirements files.
1262-
#[arg(long, short, group = "sources")]
1277+
#[arg(long, short, group = "sources", value_parser = parse_file_path)]
12631278
pub(crate) requirement: Vec<PathBuf>,
12641279

12651280
/// The Python interpreter from which packages should be uninstalled.

crates/uv/tests/pip_compile.rs

+26
Original file line numberDiff line numberDiff line change
@@ -9629,3 +9629,29 @@ fn dynamic_pyproject_toml() -> Result<()> {
96299629

96309630
Ok(())
96319631
}
9632+
9633+
/// Accept `file://` URLs as installation sources.
9634+
#[test]
9635+
fn file_url() -> Result<()> {
9636+
let context = TestContext::new("3.12");
9637+
9638+
let requirements_txt = context.temp_dir.child("requirements file.txt");
9639+
requirements_txt.write_str("iniconfig")?;
9640+
9641+
let url = Url::from_file_path(requirements_txt.simple_canonicalize()?).expect("valid file URL");
9642+
9643+
uv_snapshot!(context.filters(), context.compile().arg(url.to_string()), @r###"
9644+
success: true
9645+
exit_code: 0
9646+
----- stdout -----
9647+
# This file was autogenerated by uv via the following command:
9648+
# uv pip compile --cache-dir [CACHE_DIR] --exclude-newer 2024-03-25T00:00:00Z file://[TEMP_DIR]/requirements%20file.txt
9649+
iniconfig==2.0.0
9650+
# via -r requirements file.txt
9651+
9652+
----- stderr -----
9653+
Resolved 1 package in [TIME]
9654+
"###);
9655+
9656+
Ok(())
9657+
}

0 commit comments

Comments
 (0)