test_requirements_lock.py 2.8 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
  1. """requirements.lock covers requirements.txt.
  2. Docker, the installers and CI install the pinned versions in requirements.lock;
  3. requirements.txt only holds the lower bounds it is compiled from. A dependency
  4. added or raised in requirements.txt without regenerating the lock would ship
  5. without it. Checked here statically (no network), so it fails in a local test
  6. run too, not only in CI.
  7. """
  8. import re
  9. from pathlib import Path
  10. from packaging.markers import Marker
  11. from packaging.requirements import Requirement
  12. from packaging.utils import canonicalize_name
  13. from packaging.version import Version
  14. REPO = Path(__file__).resolve().parents[3]
  15. def _requirements() -> list[Requirement]:
  16. reqs = []
  17. for line in (REPO / "requirements.txt").read_text(encoding="utf-8").splitlines():
  18. line = line.split("#", 1)[0].strip()
  19. if line and not line.startswith("-"):
  20. reqs.append(Requirement(line))
  21. return reqs
  22. def _pins() -> dict[str, list[tuple[Version, Marker | None]]]:
  23. pins: dict[str, list[tuple[Version, Marker | None]]] = {}
  24. for line in (REPO / "requirements.lock").read_text(encoding="utf-8").splitlines():
  25. m = re.match(r"^([A-Za-z0-9_.\[\]-]+)==([^\s;]+)\s*(?:;\s*(.+))?$", line)
  26. if m:
  27. name, version, marker = m.groups()
  28. name = name.split("[", 1)[0]
  29. pins.setdefault(canonicalize_name(name), []).append((Version(version), Marker(marker) if marker else None))
  30. return pins
  31. def test_every_requirement_is_pinned_in_the_lock():
  32. pins = _pins()
  33. problems = []
  34. for req in _requirements():
  35. if req.marker is not None and not req.marker.evaluate():
  36. continue # not for this platform/Python
  37. candidates = [
  38. v for v, marker in pins.get(canonicalize_name(req.name), []) if marker is None or marker.evaluate()
  39. ]
  40. if not candidates:
  41. problems.append(f"{req.name}: not in requirements.lock")
  42. elif not any(req.specifier.contains(v, prereleases=True) for v in candidates):
  43. problems.append(f"{req}: lock pins {', '.join(map(str, candidates))}")
  44. assert not problems, (
  45. "requirements.lock does not cover requirements.txt; regenerate it (see CONTRIBUTING.md):\n "
  46. + "\n ".join(problems)
  47. )
  48. def test_the_lock_is_universal():
  49. """The check above only evaluates markers for the machine it runs on. A lock
  50. compiled for Linux alone passes it but has no tzdata and an unconditional
  51. uvloop, which breaks the Windows installer, a build only release tags run."""
  52. header = (REPO / "requirements.lock").read_text(encoding="utf-8").split("\n", 3)[:3]
  53. assert any("uv pip compile" in line and "--universal" in line for line in header), (
  54. "requirements.lock was not compiled with --universal; regenerate it (see CONTRIBUTING.md)"
  55. )