verify_dependencies.py 2.8 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
  1. #!/usr/bin/env python3
  2. """Verify requirements.txt is consistent with base_requirements.txt.
  3. Guards against dependency drift before publishing. The wheel sources its pinned
  4. dependencies from requirements.txt, which must stay in sync with the maintainer
  5. policy in base_requirements.txt (same package set, and every pin satisfies its
  6. declared constraint).
  7. """
  8. import importlib.util
  9. import sys
  10. from pathlib import Path
  11. from packaging.requirements import Requirement
  12. def load_hatch_metadata():
  13. """Load scripts/packaging/hatch_metadata.py by path.
  14. scripts/packaging is not a package (no __init__.py), and importing it by name would
  15. collide with the third-party packaging distribution, so load it from its file path.
  16. """
  17. path = Path(__file__).resolve().parent / 'packaging' / 'hatch_metadata.py'
  18. spec = importlib.util.spec_from_file_location('netbox_hatch_metadata', path)
  19. module = importlib.util.module_from_spec(spec)
  20. spec.loader.exec_module(module)
  21. return module
  22. def parse(path, hatch_metadata):
  23. reqs = {}
  24. for line in hatch_metadata.read_requirements(Path(path).read_text()):
  25. req = Requirement(line)
  26. reqs[req.name.lower().replace('_', '-')] = req
  27. return reqs
  28. def check(base, pinned):
  29. errors = []
  30. only_base = sorted(set(base) - set(pinned))
  31. only_pinned = sorted(set(pinned) - set(base))
  32. if only_base:
  33. errors.append(f"In base_requirements.txt but not requirements.txt: {only_base}")
  34. if only_pinned:
  35. errors.append(f"In requirements.txt but not base_requirements.txt: {only_pinned}")
  36. for name in sorted(set(base) & set(pinned)):
  37. if base[name].extras != pinned[name].extras:
  38. errors.append(
  39. f"{name}: extras differ (base {sorted(base[name].extras)} vs "
  40. f"requirements.txt {sorted(pinned[name].extras)})"
  41. )
  42. spec = pinned[name].specifier
  43. if not spec or not all(s.operator == '==' for s in spec):
  44. errors.append(f"{name}: requirements.txt must pin exactly (got '{pinned[name]}')")
  45. continue
  46. version = next(iter(spec)).version
  47. if not base[name].specifier.contains(version, prereleases=True):
  48. errors.append(f"{name}: pinned {version} violates base constraint '{base[name].specifier}'")
  49. return errors
  50. def main():
  51. root = Path(__file__).resolve().parent.parent
  52. hatch_metadata = load_hatch_metadata()
  53. errors = check(
  54. parse(root / 'base_requirements.txt', hatch_metadata),
  55. parse(root / 'requirements.txt', hatch_metadata),
  56. )
  57. if errors:
  58. print("Dependency drift detected:")
  59. for error in errors:
  60. print(f" - {error}")
  61. return 1
  62. print("OK: requirements.txt is consistent with base_requirements.txt")
  63. return 0
  64. if __name__ == '__main__':
  65. sys.exit(main())