Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions tests/project/test_conda_vendor_1814.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
neither the network nor any vendored package installed.
* The committed list holds every requirement :file:`pyproject.toml` makes live on
a conda build, with its marker, and none that is dead there.
* ``parse`` and ``--check`` report lines pinning one package differently under
one marker, which pip cannot install together, and accept an identical
duplicate (#1852).
* ``--smoke`` passes on a conda package whose vendored distributions are complete
and fails on one missing a data file. Its platform checks (#1822) are
:file:`test_conda_native_platforms_1822.py`'s.
Expand Down Expand Up @@ -266,6 +269,77 @@ def test_check_passes_on_its_own_output_and_fails_on_a_stale_file(self) -> None:
module.FILE, module.ROOT, module._requires, module._version = saved


class DuplicatePinTests(unittest.TestCase):
"""Lines pinning one package differently under one marker, which pip rejects (#1852)."""

#: A pin of aenum the committed file does not hold, as ``pip download -r`` got
#: beside it in the issue and failed with ``ResolutionImpossible``.
OTHER = '3.1.16'

def setUp(self) -> None:
self.module = vendor()
self.text = (ROOT / 'conda' / 'requirements.txt').read_text(encoding='utf-8')
self.envs = self.module.environments(self.module.conda_pythons())
# Stand-in metadata: what the committed file says that this does not
# derive is the same with the duplicate as without it.
self.found = self.module.derive(self.module.direct(), self.envs, lambda name: [])
self.pinned = self.module.parse(self.text)['aenum'][None]
self.assertNotEqual(self.pinned, self.OTHER, 'pick another conflicting aenum pin')

def added(self, line: 'str') -> 'list[str]':
"""What ``--check`` reports of the committed file with ``line`` appended, and not without it."""
before = self.module.compare(self.text, self.found, self.envs)
after = self.module.compare(self.text + line + '\n', self.found, self.envs)
return [problem for problem in after if problem not in before]

def test_the_committed_file_has_no_conflict(self) -> None:
self.assertEqual([problem for problem in self.module.compare(self.text, self.found, self.envs)
if problem.startswith('conflict:')], [])

def test_a_conflicting_pin_is_reported(self) -> None:
conflict = f"conflict: aenum pins '=={self.pinned}' and '=={self.OTHER}' under the marker None"
with self.assertRaises(self.module.Conflicting) as raised:
self.module.parse(self.text + f'aenum=={self.OTHER}\n')
self.assertEqual(str(raised.exception), conflict)
self.assertEqual(self.added(f'aenum=={self.OTHER}'), [conflict])

def test_markers_are_compared_once_normalised(self) -> None:
text = requirement_lines('Six==1.0; python_version == "3.12"', "six==2.0; python_version=='3.12'",
'six==3.0; python_version == "3.13"')
with self.assertRaises(self.module.Conflicting) as raised:
self.module.parse(text)
self.assertEqual(str(raised.exception),
"conflict: six pins '==1.0' and '==2.0' under the marker 'python_version == \"3.12\"'")

def test_an_identical_duplicate_is_accepted(self) -> None:
line = f'aenum=={self.pinned}'
self.assertEqual(self.module.parse(self.text + line + '\n'), self.module.parse(self.text))
self.assertEqual(self.added(line), [])

def test_check_exits_1_on_a_conflicting_duplicate_of_its_own_output(self) -> None:
module = self.module
with tempfile.TemporaryDirectory() as temp:
target = pathlib.Path(temp) / 'requirements.txt'
saved = module.FILE, module.ROOT, module._requires, module._version
module.FILE, module.ROOT = target, pathlib.Path(temp)
module._requires = lambda name: []
module._version = lambda name: '1.0'
try:
self.assertEqual(module.main([]), 0)
written = target.read_text(encoding='utf-8')
target.write_text(written + 'aenum==1.0\n', encoding='utf-8')
with contextlib.redirect_stdout(io.StringIO()) as quiet:
self.assertEqual(module.main(['--check']), 0)
self.assertEqual(quiet.getvalue(), '')
target.write_text(written + 'aenum==0.9\n', encoding='utf-8')
with contextlib.redirect_stdout(io.StringIO()) as report:
self.assertEqual(module.main(['--check']), 1)
self.assertEqual(report.getvalue(), "requirements.txt: conflict: aenum pins '==1.0' and "
"'==0.9' under the marker None\n")
finally:
module.FILE, module.ROOT, module._requires, module._version = saved


#: A ``pcapkit/__init__.py`` reduced to its ``_extern`` shim.
SHIM = textwrap.dedent('''\
import os, sys
Expand Down
46 changes: 40 additions & 6 deletions util/conda_vendor.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@
packages or markers than it derives, a platform pin other than
:data:`PLATFORM_PINS`, or when that table names a package no build vendors --
the other pins aside, since they move with every release and the weekly run
refreshes them. With ``--smoke PACKAGE`` it unpacks a
refreshes them. Lines pinning one package differently under one marker fail it
too, since pip cannot install them together when the pins disagree (#1852).
With ``--smoke PACKAGE`` it unpacks a
built ``.tar.bz2`` conda package and checks that every distribution vendored in
it shipped every file its ``RECORD`` lists, and that each of its modules imports
from :file:`pcapkit/_extern` with nothing else on the path. It also checks that
Expand Down Expand Up @@ -299,19 +301,50 @@ def lines(found: 'dict[str, dict[str, object]]', envs: 'list[dict[str, str]]',
return result


def parse(text: 'str') -> 'dict[str, dict[Optional[str], str]]':
"""A requirements file's packages, by canonical name, each as {its markers: the versions they pin}."""
class Conflicting(ValueError):
"""A requirements file pinning one package differently on lines under one marker (#1852)."""


def _read(text: 'str') -> 'tuple[dict[str, dict[Optional[str], str]], list[str]]':
""":func:`parse`'s result, with each marker's first pin, and each conflict it would raise."""
from packaging.requirements import Requirement # pylint: disable=import-outside-toplevel
from packaging.utils import canonicalize_name # pylint: disable=import-outside-toplevel
result = {} # type: dict[str, dict[Optional[str], str]]
specifiers = {} # type: dict[tuple[str, Optional[str]], list[str]]
for line in text.splitlines():
line = line.split('#', 1)[0].strip()
if not line:
continue
req = Requirement(line)
name, marker = canonicalize_name(req.name), None if req.marker is None else str(req.marker)
versions = [spec.version for spec in req.specifier if spec.operator == '==']
markers = result.setdefault(canonicalize_name(req.name), {})
markers[None if req.marker is None else str(req.marker)] = versions[0] if versions else ''
result.setdefault(name, {}).setdefault(marker, versions[0] if versions else '')
seen = specifiers.setdefault((name, marker), [])
if str(req.specifier) not in seen:
seen.append(str(req.specifier))
conflicts = [] # type: list[str]
for (package, under), pinned in sorted(specifiers.items(), key=lambda item: str(item[0])):
if len(pinned) > 1:
conflicts.append(f'conflict: {package} pins {" and ".join(map(repr, pinned))} '
f'under the marker {under!r}')
return result, conflicts


def parse(text: 'str') -> 'dict[str, dict[Optional[str], str]]':
"""A requirements file's packages, by canonical name, each as {its markers: the versions they pin}.

pip applies every line whose marker holds, so two lines naming one package
under one marker, with version specifiers that cannot both hold, make ``pip
download -r`` fail. Once the whole text is read, :class:`Conflicting` names
each package and marker whose lines pin it differently, whether or not the
pins could both hold, since the writer gives each a single line (#1852). An
identical duplicate line is accepted: pip resolves it, and it changes nothing
this reads.

"""
result, conflicts = _read(text)
if conflicts:
raise Conflicting('\n'.join(conflicts))
return result


Expand All @@ -338,7 +371,8 @@ def compare(text: 'str', found: 'dict[str, dict[str, object]]',
"""Why ``text`` is stale against ``found``, one line per package; empty when current."""
from packaging.markers import Marker # pylint: disable=import-outside-toplevel
problems = unpinnable(found)
have = parse(text)
have, conflicts = _read(text)
problems.extend(conflicts)
want = {name: {None if marker is None else str(Marker(marker)): fixed
for marker, fixed in pins(name, entry, envs)}
for name, entry in found.items() if entry['class'] != 'dead'}
Expand Down
Loading