Skip to content

Commit

Permalink
Accept file:// URLs for all requirements.txt and pyproject.toml refer…
Browse files Browse the repository at this point in the history
…ences
  • Loading branch information
charliermarsh committed Jun 7, 2024
1 parent bcfe88d commit 470a277
Show file tree
Hide file tree
Showing 3 changed files with 104 additions and 14 deletions.
49 changes: 49 additions & 0 deletions crates/requirements-txt/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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())
};
Expand Down Expand Up @@ -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())
};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}")
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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())
}
Expand Down
43 changes: 29 additions & 14 deletions crates/uv/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,25 +253,40 @@ fn parse_index_url(input: &str) -> Result<Maybe<IndexUrl>, String> {
}
}

/// Parse a string into a [`PathBuf`], mapping the empty string to `None`.
fn parse_file_path(input: &str) -> Result<Maybe<PathBuf>, 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<PathBuf, String> {
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<Maybe<PathBuf>, 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<PathBuf>,

/// Constrain versions using the given requirements files.
Expand All @@ -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<Maybe<PathBuf>>,

/// Override versions using the given requirements files.
Expand All @@ -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<PathBuf>,

/// Include optional dependencies in the given extra group name; may be provided more than once.
Expand Down Expand Up @@ -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<PathBuf>,

/// Constrain versions using the given requirements files.
Expand All @@ -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<Maybe<PathBuf>>,

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

/// 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<PathBuf>,

/// Install the editable package based on the provided local file path.
Expand All @@ -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<Maybe<PathBuf>>,

/// Override versions using the given requirements files.
Expand All @@ -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<PathBuf>,

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

/// 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<PathBuf>,

/// The Python interpreter from which packages should be uninstalled.
Expand Down
26 changes: 26 additions & 0 deletions crates/uv/tests/pip_compile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).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 [TEMP_DIR]/requirements file.txt
----- stderr -----
Resolved 1 package in [TIME]
"###);

Ok(())
}

0 comments on commit 470a277

Please sign in to comment.