From 744ab6560b7cb914ac1b069a31d540ed736dea58 Mon Sep 17 00:00:00 2001 From: nelsonduarte Date: Sun, 11 Oct 2026 15:06:28 +0100 Subject: [PATCH] build(deps): keep PySide6 below 6.12 for 1.15.1 PySide6 6.12.0, uploaded to PyPI on 2026-10-08, moved WebEngine and Pdf into wheels of their own, PySide6_WebEngine==6.12.0.140 and PySide6_Pdf==6.12.0.140, which it now requires; 6.11 already shipped both inside PySide6_Addons. With only a floor, requirements.txt resolved to 6.12.0, so build.yml, tests.yml, the AppImage and the snap would all install it. A Windows PyInstaller build barely changes: PDFApps.exe is 95,893,742 bytes with 6.11.2 and 96,414,643 with 6.12.0 (+0.54%). Neither bundle contains WebEngine; both contain Qt6Pdf.dll, pulled in by the imageformats/qpdf.dll plugin. The frozen macOS and Linux builds, the snap and the AppImage have not been tested with 6.12, so cap it at <6.12 for the 1.15.1 release. The cap is temporary: it goes, together with its guard test, once the frozen Windows, macOS and Linux builds, the snap and (while it ships) the AppImage all pass the smoke test with 6.12. Until then, Dependabot PRs that widen it are closed by hand. Before and after, resolved with: pip install --dry-run --ignore-installed --report report.json \ -r requirements.txt - cp314 win_amd64: PySide6, Addons, Essentials and shiboken6 go from 6.12.0 to 6.11.2, and PySide6_WebEngine / PySide6_Pdf drop out (35 packages -> 33). Nothing else changes. - cp310 (snap), adding --only-binary=:all: --python-version 3.10 and one --platform each for manylinux_2_34_x86_64, manylinux_2_28_x86_64 and manylinux2014_x86_64: the same diff (35 -> 33). Environment markers are still evaluated against the host interpreter, not the target. pip-audit -r requirements.txt --strict: no known vulnerabilities. The cap broke test_flatpak_pins_satisfy_the_root_requirements_floor: it read everything after ">=" as the floor, so ">=6.11.1,<6.12" became (6, 11, 1, 6) and the 6.11.1 Flatpak pin looked stale. The check is now _stale_pins(root_text, pin_text). It strips inline comments, parses each line with packaging.Requirement, takes the highest floor of any clause and compares packaging Versions. It fails closed: a root line with an environment marker or with no floor, and a pin that is not an exact "==", are reported instead of skipped. Parametrized tests cover comments, markers, "~=", reordered clauses, the cap, two floors on one line, no floor, a pin that is not exact, and the real files with and without the cap. The parser is the one test_dependency_security_floors.py already had, moved to tests/_requirements_parsing.py so the three dependency test files share it; pytest does not collect that module. New guard, tests/test_pyside6_ceiling.py: it fails if the PySide6 specifier admits 6.12.0rc1, 6.12.0.dev0, 6.12.0, 6.12.1, 6.13.0 or 7.0.0 (pre-releases count), admits no 6.11.x release, or carries an environment marker. Mutations in a scratch copy, all now caught: the pin comparison replaced by "if False", lowest_admitted() always None, markers accepted (in each of the three test files), and in requirements.txt the cap widened to <6.13, replaced by !=6.12.0, or removed. Dropping prereleases=True from the guard is caught with packaging 25.0 but not 26.3, where contains() already counts pre-releases by default. Also correct the comment above the pypdf floor in requirements.txt: since #190, tests.yml runs pytest on every pull request to main and every push to main. Full suite on PySide6 6.11.2: Windows (Py 3.14.6) 876 passed, 2 skipped; Linux (Ubuntu, Py 3.14.4) 875 passed, 3 skipped. Co-Authored-By: Claude Opus 5.5 (1M context) --- requirements.txt | 19 ++-- tests/_requirements_parsing.py | 66 ++++++++++++++ tests/test_dependency_security_floors.py | 46 ++-------- tests/test_flatpak_dependency_pins.py | 106 +++++++++++++++++++---- tests/test_pyside6_ceiling.py | 75 ++++++++++++++++ 5 files changed, 249 insertions(+), 63 deletions(-) create mode 100644 tests/_requirements_parsing.py create mode 100644 tests/test_pyside6_ceiling.py diff --git a/requirements.txt b/requirements.txt index 9aedaab..c1d2b4e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,13 @@ -PySide6>=6.11.1 +# Temporary ceiling for the 1.15.1 release. PySide6 6.12.0 (2026-10-08) +# moved WebEngine and Pdf into wheels of their own; 6.11 already shipped +# both inside PySide6_Addons. A Windows PyInstaller build barely changes +# (PDFApps.exe +0.54%), but the frozen macOS and Linux builds, the snap +# and the AppImage have not been tested with 6.12. Remove the ceiling, +# together with tests/test_pyside6_ceiling.py, once the frozen Windows, +# macOS and Linux builds, the snap and (while it ships) the AppImage +# all pass the smoke test with 6.12. Until then, close by hand any +# Dependabot PR that widens it. +PySide6>=6.11.1,<6.12 # PYSEC-2026-3655 / PYSEC-2026-3656 (fixed in 6.15.0), CVE-2026-82398 # (6.15.0), CVE-2026-84309 (6.16.0) and CVE-2026-84310 / CVE-2026-84311 # (6.16.1). Then PYSEC-2026-4158 (6.17.0), PYSEC-2026-4153 (6.18.0), @@ -11,10 +20,10 @@ PySide6>=6.11.1 # floor, so a floor that admits vulnerable releases audits clean for as # long as a fixed release exists. # tests/test_dependency_security_floors.py fails if the floor drops -# below 6.19.0, but only where pytest runs (as of 2026-10-05 no -# workflow on main runs pytest). An advisory published later against -# the floor turns nothing that audits this file red once a fixed -# release exists. Do not lower it. +# below 6.19.0, wherever pytest runs; tests.yml runs it on every pull +# request to main and every push to main. An advisory published later +# against the floor turns nothing that audits this file red once a +# fixed release exists. Do not lower it. pypdf>=6.19.0 # GHSA-537c-gmf6-5ccf: signature verification bypass in legacy OpenSSL # providers fixed in 48.0.1, and PYSEC-2026-3552 / CVE-2026-69247 diff --git a/tests/_requirements_parsing.py b/tests/_requirements_parsing.py new file mode 100644 index 0000000..f080940 --- /dev/null +++ b/tests/_requirements_parsing.py @@ -0,0 +1,66 @@ +"""requirements.txt parsing shared by the dependency tests. + +Not a test module: pytest does not collect it because its name matches +neither ``test_*.py`` nor ``*_test.py``, the default ``python_files`` +patterns. The tests import it by bare name, which works because +``tests/`` has no ``__init__.py``, so pytest's default import mode +puts ``tests/`` on ``sys.path``. +""" + +from __future__ import annotations + +import re + +from packaging.requirements import Requirement +from packaging.specifiers import Specifier, SpecifierSet +from packaging.utils import canonicalize_name +from packaging.version import InvalidVersion, Version + +# pip strips "#" to end of line when it starts the line or follows +# whitespace; "pypdf>=6.19.0 # note" is a valid requirements line. +INLINE_COMMENT = re.compile(r"(^|\s)#.*$") + + +def parse_line(line: str) -> Requirement | None: + s = INLINE_COMMENT.sub("", line).strip() + return Requirement(s) if s else None + + +def parse_requirements_text(text: str) -> dict[str, Requirement]: + """Map PEP 503 name -> Requirement, failing on a repeated name.""" + out: dict[str, Requirement] = {} + for line in text.splitlines(): + req = parse_line(line) + if req is None: + continue + name = canonicalize_name(req.name) + assert name not in out, f"{req.name} listed twice" + out[name] = req + return out + + +def lowest_admitted(clause: Specifier) -> Version | None: + """Lowest version ``clause`` can admit, or None if it has no floor. + + ``==X.*`` admits X's dev and pre-releases, which sort below X, so its + floor is ``X.dev0``. Every other bounding operator admits nothing + below its own version (``>X`` admits nothing at or below X). + """ + if clause.operator in ("<", "<=", "!="): + return None + ver = clause.version + if ver.endswith(".*"): + ver = ver[:-2] + ".dev0" + try: + return Version(ver) + except InvalidVersion: + return None # "===" with a non-PEP 440 string + + +def floor_of(spec: SpecifierSet) -> Version | None: + """The highest clause floor in ``spec``, or None if no clause has one. + + Clauses are ANDed, so the highest floor is the one that binds. + """ + floors = [f for f in map(lowest_admitted, spec) if f is not None] + return max(floors) if floors else None diff --git a/tests/test_dependency_security_floors.py b/tests/test_dependency_security_floors.py index 1be28e5..e90e532 100644 --- a/tests/test_dependency_security_floors.py +++ b/tests/test_dependency_security_floors.py @@ -19,14 +19,15 @@ from __future__ import annotations -import re from pathlib import Path import pytest +from _requirements_parsing import lowest_admitted as _lowest_admitted +from _requirements_parsing import parse_line as _parse_line +from _requirements_parsing import parse_requirements_text from packaging.requirements import Requirement -from packaging.specifiers import Specifier, SpecifierSet -from packaging.utils import canonicalize_name -from packaging.version import InvalidVersion, Version +from packaging.specifiers import SpecifierSet +from packaging.version import Version ROOT = Path(__file__).resolve().parent.parent REQUIREMENTS = ROOT / "requirements.txt" @@ -51,46 +52,11 @@ "idna": ("3.15", ("3.13", "3.14")), } -# pip strips "#" to end of line when it starts the line or follows -# whitespace; "pypdf>=6.19.0 # note" is a valid requirements line. -_INLINE_COMMENT = re.compile(r"(^|\s)#.*$") - - -def _parse_line(line: str) -> Requirement | None: - s = _INLINE_COMMENT.sub("", line).strip() - return Requirement(s) if s else None - def _requirements(text: str | None = None) -> dict[str, Requirement]: if text is None: text = REQUIREMENTS.read_text(encoding="utf-8") - out: dict[str, Requirement] = {} - for line in text.splitlines(): - req = _parse_line(line) - if req is None: - continue - name = canonicalize_name(req.name) - assert name not in out, f"{req.name} listed twice" - out[name] = req - return out - - -def _lowest_admitted(clause: Specifier) -> Version | None: - """Lowest version ``clause`` can admit, or None if it has no floor. - - ``==X.*`` admits X's dev and pre-releases, which sort below X, so its - floor is ``X.dev0``. Every other bounding operator admits nothing - below its own version (``>X`` admits nothing at or below X). - """ - if clause.operator in ("<", "<=", "!="): - return None - ver = clause.version - if ver.endswith(".*"): - ver = ver[:-2] + ".dev0" - try: - return Version(ver) - except InvalidVersion: - return None # "===" with a non-PEP 440 string + return parse_requirements_text(text) def _rejects_everything_below(spec: SpecifierSet, minimum: Version) -> bool: diff --git a/tests/test_flatpak_dependency_pins.py b/tests/test_flatpak_dependency_pins.py index debb276..9720ed4 100644 --- a/tests/test_flatpak_dependency_pins.py +++ b/tests/test_flatpak_dependency_pins.py @@ -19,6 +19,8 @@ from pathlib import Path import pytest +from _requirements_parsing import floor_of, parse_requirements_text +from packaging.version import Version try: import yaml @@ -136,30 +138,98 @@ def test_build_command_installs_every_pinned_package(): ) +def _stale_pins(root_text: str, pin_text: str) -> list[str]: + """Flatpak pins below their requirements.txt floor, one line each. + + Fail-closed: a root line this check cannot judge (an environment + marker, no floor at all) or a pin that is not an exact ``==`` is + reported too. Skipping such lines would be fail-open: a floor raised + as ``pypdf>=6.99.0 # note`` would then let a 6.19.0 pin pass. + """ + root = parse_requirements_text(root_text) + problems = [] + for name, pin in parse_requirements_text(pin_text).items(): + req = root.get(name) + if req is None: + continue # e.g. shiboken6, a PySide6 transitive not pinned at root + if req.marker is not None: + problems.append( + f"{name}: requirements.txt '{req}' carries an environment " + "marker, so its floor does not hold everywhere" + ) + continue + floor = floor_of(req.specifier) + if floor is None: + problems.append(f"{name}: requirements.txt '{req}' has no floor") + continue + clauses = list(pin.specifier) + if ( + len(clauses) != 1 + or clauses[0].operator != "==" + or clauses[0].version.endswith(".*") + ): + problems.append(f"{name}: flatpak pin '{pin}' is not an exact '==' pin") + continue + got = Version(clauses[0].version) + if got < floor: + problems.append(f"{name}: flatpak pin {got} < requirements floor {floor}") + return problems + + +_SAMPLE_PINS = "PySide6==6.11.1\nshiboken6==6.11.1\npypdf==6.19.0\n" + + +@pytest.mark.parametrize( + ("root_line", "expected"), + [ + ("pypdf>=6.99.0 # note", "< requirements floor 6.99.0"), + ('pypdf>=6.99.0; sys_platform != "emscripten"', "environment marker"), + ("PySide6~=6.11.2", "< requirements floor 6.11.2"), + ("PySide6<6.12,>=6.11.2", "< requirements floor 6.11.2"), + ("PySide6>=6.11.1,<6.12 # cap", None), + ("pypdf>=6.19.0", None), + ("pypdf>=6.19.1", "< requirements floor 6.19.1"), + ("pypdf>=6.0,>=6.99.0", "< requirements floor 6.99.0"), + ("PySide6<6.12", "has no floor"), + ("pypdf", "has no floor"), + ], +) +def test_stale_pins_flags_stale_and_unjudgeable_root_lines(root_line, expected): + problems = _stale_pins(root_line, _SAMPLE_PINS) + if expected is None: + assert problems == [] + else: + assert len(problems) == 1 and expected in problems[0], problems + + +@pytest.mark.parametrize("pin", [">=6.19.0", "==6.19.*", "", "==6.19.0,<7"]) +def test_stale_pins_rejects_a_pin_that_is_not_exact(pin): + problems = _stale_pins("pypdf>=6.19.0", f"pypdf{pin}") + assert len(problems) == 1 and "not an exact" in problems[0], problems + + +@pytest.mark.parametrize("ceiling", ["", ",<6.12"]) +def test_stale_pins_accepts_the_real_files_with_and_without_a_ceiling(ceiling): + real = REQUIREMENTS.read_text(encoding="utf-8") + floor = floor_of(parse_requirements_text(real)["pyside6"].specifier) + root_text, n = re.subn( + r"(?m)^PySide6\b.*$", f"PySide6>={floor}{ceiling}", real + ) + assert n == 1 + assert _stale_pins(root_text, PINNED.read_text(encoding="utf-8")) == [] + + def test_flatpak_pins_satisfy_the_root_requirements_floor(): """Every Flatpak pin must be >= the floor in requirements.txt. Without this, a security bump to requirements.txt can land while the Flatpak keeps installing the vulnerable version. """ - root = _parse_requirements(REQUIREMENTS) - pinned = _parse_requirements(PINNED) - - def parts(v: str) -> tuple[int, ...]: - return tuple(int(x) for x in re.findall(r"\d+", v)[:4]) - - stale = [] - for name, spec in pinned.items(): - floor_spec = root.get(name) - if floor_spec is None: - continue # e.g. shiboken6, a PySide6 transitive not pinned at root - m = re.fullmatch(r">=\s*(.+)", floor_spec) - if not m: - continue - want, got = parts(m.group(1)), parts(spec.lstrip("= ")) - if got < want: - stale.append(f"{name}: flatpak pin {spec.lstrip('= ')} < requirements floor {m.group(1)}") - assert not stale, "\n".join(stale) + problems = _stale_pins( + REQUIREMENTS.read_text(encoding="utf-8"), + PINNED.read_text(encoding="utf-8"), + ) + assert not problems, "\n".join(problems) def test_no_root_dependency_is_silently_dropped_from_the_flatpak(): diff --git a/tests/test_pyside6_ceiling.py b/tests/test_pyside6_ceiling.py new file mode 100644 index 0000000..eeafaf2 --- /dev/null +++ b/tests/test_pyside6_ceiling.py @@ -0,0 +1,75 @@ +"""requirements.txt must keep PySide6 below 6.12 while the ceiling lasts. + +The ceiling is temporary, for the 1.15.1 release: the frozen macOS and +Linux builds, the snap and the AppImage have not been tested with +PySide6 6.12. It goes once the frozen Windows, macOS and Linux builds, +the snap and (while it ships) the AppImage all pass the smoke test +with 6.12. This test exists only to stop the ceiling being widened or +dropped by accident (a Dependabot PR, a hand edit), and it is removed +together with the ceiling. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from _requirements_parsing import parse_line, parse_requirements_text +from packaging.requirements import Requirement + +REQUIREMENTS = Path(__file__).resolve().parent.parent / "requirements.txt" + +# Checked with prereleases=True, so a 6.12 pre-release counts as admitted +# whatever the packaging release: before packaging 26.0, contains() with +# the default rejected a pre-release unless the specifier named one. +_BEYOND_CEILING = ("6.12.0rc1", "6.12.0.dev0", "6.12.0", "6.12.1", "6.13.0", "7.0.0") +# A ceiling must not shut out the 6.11 series the app is built on; a +# floor raised within 6.11 is fine, so any 6.11.x will do. +_SERIES_6_11 = tuple(f"6.11.{i}" for i in range(100)) + + +def _ceiling_problems(req: Requirement) -> list[str]: + problems = [] + if req.marker is not None: + problems.append(f"'{req}' carries an environment marker") + admitted = [ + v for v in _BEYOND_CEILING if req.specifier.contains(v, prereleases=True) + ] + if admitted: + problems.append(f"'{req}' admits {admitted}") + if not any(req.specifier.contains(v) for v in _SERIES_6_11): + problems.append(f"'{req}' admits no 6.11.x release") + return problems + + +@pytest.mark.parametrize( + ("line", "ok"), + [ + ("PySide6>=6.11.1,<6.12", True), + ("PySide6>=6.11.2,<6.12", True), + ("PySide6>=6.11.1,<6.12 # cap", True), + ("PySide6~=6.11.1", True), + ("PySide6>=6.11.1", False), + ("PySide6>=6.11.1,<6.13", False), + ("PySide6>=6.11.1,!=6.12.0", False), + ("PySide6>=6.11.1,<=6.12.0rc1", False), + ("PySide6>=6.11.1,<6.12.0.dev1", False), + ("PySide6>=6.11.1,!=6.12.0,!=6.12.1,<6.13", False), # 6.12.0rc1 + ("PySide6<6.11", False), + ("PySide6>=6.12", False), + ('PySide6>=6.11.1,<6.12; sys_platform == "win32"', False), + ], +) +def test_ceiling_check_accepts_6_11_only_specifiers(line, ok): + assert (_ceiling_problems(parse_line(line)) == []) is ok + + +def test_requirements_keep_pyside6_below_6_12(): + req = parse_requirements_text(REQUIREMENTS.read_text(encoding="utf-8"))["pyside6"] + problems = _ceiling_problems(req) + assert not problems, ( + "requirements.txt must keep PySide6 below 6.12 until the " + "frozen Windows, macOS and Linux builds, the snap and (while " + "it ships) the AppImage all pass the smoke test with 6.12: " + + "; ".join(problems) + )