From 72a388a3852b7474cbee4edf03afa8e65cda9099 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Fri, 16 Oct 2026 19:33:11 -0400 Subject: [PATCH] ci(conda): report a package pinned differently under one marker (#1852) - util/conda_vendor.py: parse() kept only the last line per package and marker, so a conflicting duplicate pin passed --check while pip download -r rejected the file. It now raises Conflicting naming each such pair, and compare() reports each as a conflict: problem, so --check exits 1. - An identical duplicate line is accepted, since pip resolves it. - tests/project/test_conda_vendor_1814.py: a conflicting aenum pin appended to the committed file is reported through parse() and compare(), the committed file and an identical duplicate are not, markers are compared once normalised, and --check exits 1 on a conflict in its own output. Closes #1852 --- tests/project/test_conda_vendor_1814.py | 74 +++++++++++++++++++++++++ util/conda_vendor.py | 46 +++++++++++++-- 2 files changed, 114 insertions(+), 6 deletions(-) diff --git a/tests/project/test_conda_vendor_1814.py b/tests/project/test_conda_vendor_1814.py index ed99294ae..799be6a5b 100644 --- a/tests/project/test_conda_vendor_1814.py +++ b/tests/project/test_conda_vendor_1814.py @@ -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. @@ -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 diff --git a/util/conda_vendor.py b/util/conda_vendor.py index d1c0087ad..b9d8cba2c 100644 --- a/util/conda_vendor.py +++ b/util/conda_vendor.py @@ -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 @@ -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 @@ -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'}