Skip to content

Commit

Permalink
Merge branch 'main' into main
Browse files Browse the repository at this point in the history
  • Loading branch information
hugovk authored Oct 8, 2024
2 parents d73c673 + 102d818 commit 03cc4ca
Show file tree
Hide file tree
Showing 12 changed files with 93 additions and 15 deletions.
2 changes: 2 additions & 0 deletions news/12653.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Detect recursively referencing requirements files and help users identify
the source.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ select = [
"PLR0",
"W",
"RUF100",
"UP032",
"UP",
]

[tool.ruff.lint.isort]
Expand Down
2 changes: 1 addition & 1 deletion src/pip/_internal/cli/index_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class SessionCommandMixin(CommandContextMixIn):

def __init__(self) -> None:
super().__init__()
self._session: Optional["PipSession"] = None
self._session: Optional[PipSession] = None

@classmethod
def _get_index_urls(cls, options: Values) -> Optional[List[str]]:
Expand Down
2 changes: 1 addition & 1 deletion src/pip/_internal/commands/list.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ def run(self, options: Values, args: List[str]) -> int:
if options.excludes:
skip.update(canonicalize_name(n) for n in options.excludes)

packages: "_ProcessedDists" = [
packages: _ProcessedDists = [
cast("_DistWithLatestInfo", d)
for d in get_environment(options.path).iter_installed_distributions(
local_only=options.local,
Expand Down
2 changes: 1 addition & 1 deletion src/pip/_internal/commands/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ def transform_hits(hits: List[Dict[str, str]]) -> List["TransformedHit"]:
packages with the list of versions stored inline. This converts the
list from pypi into one we can use.
"""
packages: Dict[str, "TransformedHit"] = OrderedDict()
packages: Dict[str, TransformedHit] = OrderedDict()
for hit in hits:
name = hit["name"]
summary = hit["summary"]
Expand Down
2 changes: 1 addition & 1 deletion src/pip/_internal/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@ class HashErrors(InstallationError):
"""Multiple HashError instances rolled into one for reporting"""

def __init__(self) -> None:
self.errors: List["HashError"] = []
self.errors: List[HashError] = []

def append(self, error: "HashError") -> None:
self.errors.append(error)
Expand Down
2 changes: 1 addition & 1 deletion src/pip/_internal/req/constructors.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ def _set_requirement_extras(req: Requirement, new_extras: Set[str]) -> Requireme
assert (
pre is not None and post is not None
), f"regex group selection for requirement {req} failed, this should never happen"
extras: str = "[%s]" % ",".join(sorted(new_extras)) if new_extras else ""
extras: str = "[{}]".format(",".join(sorted(new_extras)) if new_extras else "")
return get_requirement(f"{pre}{extras}{post}")


Expand Down
26 changes: 22 additions & 4 deletions src/pip/_internal/req/req_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,11 +324,15 @@ def __init__(
) -> None:
self._session = session
self._line_parser = line_parser
self._parsed_files: dict[str, Optional[str]] = {}

def parse(
self, filename: str, constraint: bool
) -> Generator[ParsedLine, None, None]:
"""Parse a given file, yielding parsed lines."""
self._parsed_files[os.path.abspath(filename)] = (
None # The primary requirements file passed
)
yield from self._parse_and_recurse(filename, constraint)

def _parse_and_recurse(
Expand All @@ -353,11 +357,25 @@ def _parse_and_recurse(
# original file and nested file are paths
elif not SCHEME_RE.search(req_path):
# do a join so relative paths work
req_path = os.path.join(
os.path.dirname(filename),
req_path,
# and then abspath so that we can identify recursive references
req_path = os.path.abspath(
os.path.join(
os.path.dirname(filename),
req_path,
)
)

if req_path in self._parsed_files:
initial_file = self._parsed_files[req_path]
tail = (
f" and again in {initial_file}"
if initial_file is not None
else ""
)
raise RequirementsFileParseError(
f"{req_path} recursively references itself in {filename}{tail}"
)
# Keeping a track where was each file first included in
self._parsed_files[req_path] = filename
yield from self._parse_and_recurse(req_path, nested_constraint)
else:
yield line
Expand Down
6 changes: 3 additions & 3 deletions tests/functional/test_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def test_pypi_xml_transformation() -> None:
"version": "1.0",
},
]
expected: List["TransformedHit"] = [
expected: List[TransformedHit] = [
{
"versions": ["1.0", "2.0"],
"name": "foo",
Expand Down Expand Up @@ -159,7 +159,7 @@ def test_latest_prerelease_install_message(
"""
Test documentation for installing pre-release packages is displayed
"""
hits: List["TransformedHit"] = [
hits: List[TransformedHit] = [
{
"name": "ni",
"summary": "For knights who say Ni!",
Expand Down Expand Up @@ -188,7 +188,7 @@ def test_search_print_results_should_contain_latest_versions(
"""
Test that printed search results contain the latest package versions
"""
hits: List["TransformedHit"] = [
hits: List[TransformedHit] = [
{
"name": "testlib1",
"summary": "Test library 1.",
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/resolution_resolvelib/test_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def build_requirement_information(
install_requirement = install_req_from_req_string(name)
# RequirementInformation is typed as a tuple, but it is a namedtupled.
# https://github.com/sarugaku/resolvelib/blob/7bc025aa2a4e979597c438ad7b17d2e8a08a364e/src/resolvelib/resolvers.pyi#L20-L22
requirement_information: "PreferenceInformation" = RequirementInformation(
requirement_information: PreferenceInformation = RequirementInformation(
requirement=SpecifierRequirement(install_requirement), # type: ignore[call-arg]
parent=parent,
)
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/resolution_resolvelib/test_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def _make_graph(
) -> "DirectedGraph[Optional[str]]":
"""Build graph from edge declarations."""

graph: "DirectedGraph[Optional[str]]" = DirectedGraph()
graph: DirectedGraph[Optional[str]] = DirectedGraph()
for parent, child in edges:
parent = cast(str, canonicalize_name(parent)) if parent else None
child = cast(str, canonicalize_name(child)) if child else None
Expand Down
58 changes: 58 additions & 0 deletions tests/unit/test_req_file.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import collections
import logging
import os
import re
import textwrap
from optparse import Values
from pathlib import Path
Expand Down Expand Up @@ -345,6 +346,63 @@ def test_nested_constraints_file(
assert reqs[0].name == req_name
assert reqs[0].constraint

def test_recursive_requirements_file(
self, tmpdir: Path, session: PipSession
) -> None:
req_files: list[Path] = []
req_file_count = 4
for i in range(req_file_count):
req_file = tmpdir / f"{i}.txt"
req_file.write_text(f"-r {(i+1) % req_file_count}.txt")
req_files.append(req_file)

# When the passed requirements file recursively references itself
with pytest.raises(
RequirementsFileParseError,
match=(
f"{re.escape(str(req_files[0]))} recursively references itself"
f" in {re.escape(str(req_files[req_file_count - 1]))}"
),
):
list(parse_requirements(filename=str(req_files[0]), session=session))

# When one of other the requirements file recursively references itself
req_files[req_file_count - 1].write_text(
# Just name since they are in the same folder
f"-r {req_files[req_file_count - 2].name}"
)
with pytest.raises(
RequirementsFileParseError,
match=(
f"{re.escape(str(req_files[req_file_count - 2]))} recursively"
" references itself in"
f" {re.escape(str(req_files[req_file_count - 1]))} and again in"
f" {re.escape(str(req_files[req_file_count - 3]))}"
),
):
list(parse_requirements(filename=str(req_files[0]), session=session))

def test_recursive_relative_requirements_file(
self, tmpdir: Path, session: PipSession
) -> None:
root_req_file = tmpdir / "root.txt"
(tmpdir / "nest" / "nest").mkdir(parents=True)
level_1_req_file = tmpdir / "nest" / "level_1.txt"
level_2_req_file = tmpdir / "nest" / "nest" / "level_2.txt"

root_req_file.write_text("-r nest/level_1.txt")
level_1_req_file.write_text("-r nest/level_2.txt")
level_2_req_file.write_text("-r ../../root.txt")

with pytest.raises(
RequirementsFileParseError,
match=(
f"{re.escape(str(root_req_file))} recursively references itself in"
f" {re.escape(str(level_2_req_file))}"
),
):
list(parse_requirements(filename=str(root_req_file), session=session))

def test_options_on_a_requirement_line(self, line_processor: LineProcessor) -> None:
line = (
'SomeProject --global-option="yo3" --global-option "yo4" '
Expand Down

0 comments on commit 03cc4ca

Please sign in to comment.