verify_wheel_metadata.py 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. #!/usr/bin/env python3
  2. """Verify a built wheel's metadata matches the repository's declared inputs.
  3. Checks:
  4. 1. Version equals the PEP 440 version computed from netbox/release.yaml, reusing the
  5. same compute_version the hatchling metadata hook uses at build time.
  6. 2. Core Requires-Dist entries (those without an "extra ==" marker) match
  7. requirements.txt exactly, so the published wheel pins the tested dependency set.
  8. 3. Provides-Extra equals the expected set of optional-dependency groups.
  9. 4. Each aggregate extra equals the union of its component extras, comparing the wheel
  10. metadata against itself (immune to backend specifier normalization). pyproject.toml
  11. duplicates these requirement strings literally; this catches drift, for example a
  12. plugin pin bumped in one place only. Aggregates must not reference netbox itself,
  13. which would defeat this guard.
  14. """
  15. import importlib.util
  16. import re
  17. import sys
  18. import zipfile
  19. from collections import defaultdict
  20. from email.parser import Parser
  21. from pathlib import Path
  22. from packaging.requirements import Requirement
  23. from packaging.utils import canonicalize_name
  24. # Every optional-dependency group in pyproject.toml, as normalized (PEP 685) extra names.
  25. EXPECTED_EXTRAS = frozenset({
  26. 'branching',
  27. 'custom-objects',
  28. 'dev',
  29. 'git',
  30. 'ldap',
  31. 'recommended-plugins',
  32. 'remote-auth',
  33. 's3',
  34. 'saml2',
  35. 'sentry',
  36. 'swift',
  37. })
  38. # Aggregate extra -> the component extras whose entries it must equal the union of.
  39. AGGREGATE_EXTRAS = {
  40. 'remote-auth': ('ldap', 'saml2'),
  41. 'recommended-plugins': ('branching', 'custom-objects'),
  42. }
  43. # hatchling 1.30 writes extra markers with single quotes; other tools use double quotes.
  44. EXTRA_MARKER = re.compile(r'\bextra\s*==\s*["\']([^"\']+)["\']')
  45. def read_metadata(wheel_path):
  46. with zipfile.ZipFile(wheel_path) as archive:
  47. name = next(n for n in archive.namelist() if n.endswith('.dist-info/METADATA'))
  48. return Parser().parsestr(archive.read(name).decode())
  49. def load_hatch_metadata():
  50. """Load scripts/packaging/hatch_metadata.py by path.
  51. scripts/packaging is not a package (no __init__.py), and importing it by name would
  52. collide with the third-party packaging distribution, so load it from its file path.
  53. """
  54. path = Path(__file__).resolve().parent / 'packaging' / 'hatch_metadata.py'
  55. spec = importlib.util.spec_from_file_location('netbox_hatch_metadata', path)
  56. module = importlib.util.module_from_spec(spec)
  57. spec.loader.exec_module(module)
  58. return module
  59. def normalize(requirement):
  60. return requirement.strip().lower().replace(' ', '')
  61. def split_requires(metadata):
  62. """Split Requires-Dist entries into core requirements and a per-extra mapping."""
  63. core = set()
  64. by_extra = defaultdict(set)
  65. for entry in metadata.get_all('Requires-Dist') or []:
  66. requirement, _, marker = entry.partition(';')
  67. match = EXTRA_MARKER.search(marker)
  68. if match:
  69. by_extra[match.group(1)].add(normalize(requirement))
  70. else:
  71. core.add(normalize(entry))
  72. return core, by_extra
  73. def check_version(metadata, root, hatch_metadata):
  74. release_text = (root / 'netbox' / 'release.yaml').read_text()
  75. version = hatch_metadata._read_release_field(release_text, 'version')
  76. if not version:
  77. return ['unable to read version from netbox/release.yaml']
  78. designation = hatch_metadata._read_release_field(release_text, 'designation')
  79. expected = hatch_metadata.compute_version(version, designation)
  80. if metadata['Version'] != expected:
  81. return [f'version mismatch: wheel has {metadata["Version"]}, release.yaml computes {expected}']
  82. return []
  83. def check_core_requires(core, root, hatch_metadata):
  84. # Parse with the hook's own parser so the verifier cannot drift from the build.
  85. pins = hatch_metadata.read_requirements((root / 'requirements.txt').read_text())
  86. errors = []
  87. missing = sorted(pin for pin in pins if normalize(pin) not in core)
  88. unexpected = sorted(core - {normalize(pin) for pin in pins})
  89. if missing:
  90. errors.append(f'requirements.txt pins missing from wheel: {missing}')
  91. if unexpected:
  92. errors.append(f'unexpected core requirements in wheel: {unexpected}')
  93. return errors
  94. def check_extras(metadata, by_extra):
  95. errors = []
  96. provided = frozenset(metadata.get_all('Provides-Extra') or [])
  97. if missing := sorted(EXPECTED_EXTRAS - provided):
  98. errors.append(f'extras missing from wheel: {missing}')
  99. if unexpected := sorted(provided - EXPECTED_EXTRAS):
  100. errors.append(f'unexpected extras in wheel: {unexpected}')
  101. for aggregate, components in AGGREGATE_EXTRAS.items():
  102. expected = set().union(*(by_extra[component] for component in components))
  103. actual = by_extra[aggregate]
  104. if actual != expected:
  105. errors.append(
  106. f'extra [{aggregate}] must equal the union of {list(components)}: '
  107. f'missing {sorted(expected - actual)}, unexpected {sorted(actual - expected)}'
  108. )
  109. if self_refs := sorted(r for r in actual if canonicalize_name(Requirement(r).name) == 'netbox'):
  110. errors.append(f'extra [{aggregate}] must not reference netbox itself: {self_refs}')
  111. return errors
  112. def main(argv):
  113. if len(argv) != 2:
  114. print('usage: verify_wheel_metadata.py <wheel>')
  115. return 2
  116. root = Path(__file__).resolve().parent.parent
  117. hatch_metadata = load_hatch_metadata()
  118. metadata = read_metadata(argv[1])
  119. core, by_extra = split_requires(metadata)
  120. errors = [
  121. *check_version(metadata, root, hatch_metadata),
  122. *check_core_requires(core, root, hatch_metadata),
  123. *check_extras(metadata, by_extra),
  124. ]
  125. if errors:
  126. print('Wheel metadata does not match the repository:')
  127. for error in errors:
  128. print(f' - {error}')
  129. return 1
  130. print(f'OK: wheel {metadata["Version"]} matches release.yaml, requirements.txt, and expected extras')
  131. return 0
  132. if __name__ == '__main__':
  133. sys.exit(main(sys.argv))