Parse -r and -c entries as relative to containing file (#1421)

## Summary

In a `requirements.txt` file, it turns out that the `-c` and `-r`
entries should be interpreted as relative to the file in which they're
declared, while the `-e` entries should be interpreted as relative to
the current working directory, no matter where they're defined.

Previously, we always used the current working directory; now, we use
the declaring file's directory for `-c` and `-r`.

Closes https://github.com/astral-sh/uv/issues/1367.

Closes https://github.com/astral-sh/uv/issues/1416.
This commit is contained in:
Charlie Marsh
2024-02-15 23:19:43 -05:00
committed by GitHub
parent 8ef396e849
commit e48edf02fa
3 changed files with 109 additions and 10 deletions
+73 -9
View File
@@ -303,7 +303,10 @@ impl RequirementsTxt {
file: requirements_txt.as_ref().to_path_buf(),
error: RequirementsTxtParserError::IO(err),
})?;
let data = Self::parse_inner(&content, working_dir.as_ref()).map_err(|err| {
let working_dir = working_dir.as_ref();
let requirements_dir = requirements_txt.as_ref().parent().unwrap_or(working_dir);
let data = Self::parse_inner(&content, working_dir, requirements_dir).map_err(|err| {
RequirementsTxtFileError {
file: requirements_txt.as_ref().to_path_buf(),
error: err,
@@ -318,13 +321,16 @@ impl RequirementsTxt {
Ok(data)
}
/// See module level documentation
/// See module level documentation.
///
/// Note that all relative paths are dependent on the current working dir, not on the location
/// of the file
/// When parsing, relative paths to requirements (e.g., `-e ../editable/`) are resolved against
/// the current working directory. However, relative paths to sub-files (e.g., `-r ../requirements.txt`)
/// are resolved against the directory of the containing `requirements.txt` file, to match
/// `pip`'s behavior.
pub fn parse_inner(
content: &str,
working_dir: &Path,
requirements_dir: &Path,
) -> Result<Self, RequirementsTxtParserError> {
let mut s = Scanner::new(content);
@@ -336,7 +342,7 @@ impl RequirementsTxt {
start,
end,
} => {
let sub_file = working_dir.join(filename);
let sub_file = requirements_dir.join(filename);
let sub_requirements = Self::parse(&sub_file, working_dir).map_err(|err| {
RequirementsTxtParserError::Subfile {
source: Box::new(err),
@@ -352,7 +358,7 @@ impl RequirementsTxt {
start,
end,
} => {
let sub_file = working_dir.join(filename);
let sub_file = requirements_dir.join(filename);
let sub_constraints = Self::parse(&sub_file, working_dir).map_err(|err| {
RequirementsTxtParserError::Subfile {
source: Box::new(err),
@@ -980,6 +986,15 @@ mod test {
let working_dir = workspace_test_data_dir().join("requirements-txt");
let requirements_txt = working_dir.join(path);
// Copy the existing files over to a temporary directory.
let temp_dir = tempdir().unwrap();
for entry in fs::read_dir(&working_dir).unwrap() {
let entry = entry.unwrap();
let path = entry.path();
let dest = temp_dir.path().join(path.file_name().unwrap());
fs::copy(&path, &dest).unwrap();
}
// Replace line endings with the other choice. This works even if you use git with LF
// only on windows.
let contents = fs::read_to_string(requirements_txt).unwrap();
@@ -988,9 +1003,6 @@ mod test {
} else {
contents.replace('\n', "\r\n")
};
// Write to a new file.
let temp_dir = tempdir().unwrap();
let requirements_txt = temp_dir.path().join(path);
fs::write(&requirements_txt, contents).unwrap();
@@ -1181,6 +1193,58 @@ mod test {
Ok(())
}
#[test]
fn relative_requirement() -> Result<()> {
let temp_dir = assert_fs::TempDir::new()?;
// Create a requirements file with a relative entry, in a subdirectory.
let sub_dir = temp_dir.child("subdir");
let sibling_txt = sub_dir.child("sibling.txt");
sibling_txt.write_str(indoc! {"
flask
"})?;
let child_txt = sub_dir.child("child.txt");
child_txt.write_str(indoc! {"
-r sibling.txt
"})?;
// Create a requirements file that points at `requirements.txt`.
let parent_txt = temp_dir.child("parent.txt");
parent_txt.write_str(indoc! {"
-r subdir/child.txt
"})?;
let requirements = RequirementsTxt::parse(parent_txt.path(), temp_dir.path()).unwrap();
insta::assert_debug_snapshot!(requirements, @r###"
RequirementsTxt {
requirements: [
RequirementEntry {
requirement: Requirement {
name: PackageName(
"flask",
),
extras: [],
version_or_url: None,
marker: None,
},
hashes: [],
editable: false,
},
],
constraints: [],
editables: [],
index_url: None,
extra_index_urls: [],
find_links: [],
no_index: false,
}
"###);
Ok(())
}
#[test]
fn editable_extra() {
assert_eq!(
@@ -1,2 +1,2 @@
-c constraints-b.txt # We can't actually semantically deal with those yet
-c constraints-b.txt
django-debug-toolbar<2.2
+35
View File
@@ -3317,3 +3317,38 @@ fn offline_find_links() -> Result<()> {
Ok(())
}
/// Resolve nested `-r` requirements files with relative paths.
#[test]
fn compile_relative_subfile() -> Result<()> {
let context = TestContext::new("3.12");
let requirements_in = context.temp_dir.child("requirements.in");
requirements_in.write_str("-r subdir/requirements.in")?;
let subdir = context.temp_dir.child("subdir");
let requirements_in = subdir.child("requirements.in");
requirements_in.write_str("-r requirements-dev.in")?;
let requirements_dev_in = subdir.child("requirements-dev.in");
requirements_dev_in.write_str("anyio")?;
uv_snapshot!(context
.compile()
.arg("requirements.in"), @r###"
success: true
exit_code: 0
----- stdout -----
# This file was autogenerated by uv v[VERSION] via the following command:
# uv pip compile --cache-dir [CACHE_DIR] --exclude-newer 2023-11-18T12:00:00Z requirements.in
anyio==4.0.0
idna==3.4
# via anyio
sniffio==1.3.0
# via anyio
----- stderr -----
Resolved 3 packages in [TIME]
"###);
Ok(())
}