verify_wheel_metadata.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  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. 5. Metadata-Version equals the core-metadata-version pinned in pyproject.toml, which
  15. must be pinned identically for the wheel and sdist targets.
  16. """
  17. import importlib.util
  18. import re
  19. import sys
  20. import tomllib
  21. import zipfile
  22. from collections import defaultdict
  23. from email.parser import Parser
  24. from pathlib import Path
  25. from packaging.requirements import Requirement
  26. from packaging.utils import canonicalize_name
  27. # Every optional-dependency group in pyproject.toml, as normalized (PEP 685) extra names.
  28. EXPECTED_EXTRAS = frozenset({
  29. 'branching',
  30. 'custom-objects',
  31. 'dev',
  32. 'git',
  33. 'ldap',
  34. 'recommended-plugins',
  35. 'remote-auth',
  36. 's3',
  37. 'saml2',
  38. 'sentry',
  39. 'swift',
  40. })
  41. # Aggregate extra -> the component extras whose entries it must equal the union of.
  42. AGGREGATE_EXTRAS = {
  43. 'remote-auth': ('ldap', 'saml2'),
  44. 'recommended-plugins': ('branching', 'custom-objects'),
  45. }
  46. # hatchling 1.30 writes extra markers with single quotes; other tools use double quotes.
  47. EXTRA_MARKER = re.compile(r'\bextra\s*==\s*["\']([^"\']+)["\']')
  48. def read_metadata(wheel_path):
  49. with zipfile.ZipFile(wheel_path) as archive:
  50. name = next(n for n in archive.namelist() if n.endswith('.dist-info/METADATA'))
  51. return Parser().parsestr(archive.read(name).decode())
  52. def load_hatch_metadata():
  53. """Load scripts/packaging/hatch_metadata.py by path.
  54. scripts/packaging is not a package (no __init__.py), and importing it by name would
  55. collide with the third-party packaging distribution, so load it from its file path.
  56. """
  57. path = Path(__file__).resolve().parent / 'packaging' / 'hatch_metadata.py'
  58. spec = importlib.util.spec_from_file_location('netbox_hatch_metadata', path)
  59. module = importlib.util.module_from_spec(spec)
  60. spec.loader.exec_module(module)
  61. return module
  62. def normalize(requirement):
  63. return requirement.strip().lower().replace(' ', '')
  64. def split_requires(metadata):
  65. """Split Requires-Dist entries into core requirements and a per-extra mapping."""
  66. core = set()
  67. by_extra = defaultdict(set)
  68. for entry in metadata.get_all('Requires-Dist') or []:
  69. requirement, _, marker = entry.partition(';')
  70. match = EXTRA_MARKER.search(marker)
  71. if match:
  72. by_extra[match.group(1)].add(normalize(requirement))
  73. else:
  74. core.add(normalize(entry))
  75. return core, by_extra
  76. def read_core_metadata_versions(root):
  77. """Return the per-target core-metadata-version pins from pyproject.toml."""
  78. targets = tomllib.loads((root / 'pyproject.toml').read_text())['tool']['hatch']['build']['targets']
  79. return {name: targets.get(name, {}).get('core-metadata-version') for name in ('wheel', 'sdist')}
  80. def check_metadata_version(metadata, root):
  81. configured = read_core_metadata_versions(root)
  82. errors = [
  83. f'pyproject.toml does not pin core-metadata-version for the {name} target'
  84. for name, value in configured.items() if value is None
  85. ]
  86. if errors:
  87. return errors
  88. if configured['wheel'] != configured['sdist']:
  89. errors.append(
  90. 'core-metadata-version differs between targets: '
  91. f'wheel {configured["wheel"]}, sdist {configured["sdist"]}'
  92. )
  93. if metadata['Metadata-Version'] != configured['wheel']:
  94. errors.append(
  95. f'metadata version mismatch: wheel has {metadata["Metadata-Version"]}, '
  96. f'pyproject.toml pins {configured["wheel"]}'
  97. )
  98. return errors
  99. def check_version(metadata, root, hatch_metadata):
  100. release_text = (root / 'netbox' / 'release.yaml').read_text()
  101. version = hatch_metadata._read_release_field(release_text, 'version')
  102. if not version:
  103. return ['unable to read version from netbox/release.yaml']
  104. designation = hatch_metadata._read_release_field(release_text, 'designation')
  105. expected = hatch_metadata.compute_version(version, designation)
  106. if metadata['Version'] != expected:
  107. return [f'version mismatch: wheel has {metadata["Version"]}, release.yaml computes {expected}']
  108. return []
  109. def _diff_errors(expected, actual, label):
  110. """Build 'missing'/'unexpected' error messages for the set difference of expected vs actual."""
  111. errors = []
  112. if missing := sorted(expected - actual):
  113. errors.append(f'{label} missing from wheel: {missing}')
  114. if unexpected := sorted(actual - expected):
  115. errors.append(f'unexpected {label} in wheel: {unexpected}')
  116. return errors
  117. def check_core_requires(core, root, hatch_metadata):
  118. # Parse with the hook's own parser so the verifier cannot drift from the build.
  119. pins = hatch_metadata.read_requirements((root / 'requirements.txt').read_text())
  120. return _diff_errors({normalize(pin) for pin in pins}, core, 'core requirements')
  121. def check_extras(metadata, by_extra):
  122. provided = frozenset(metadata.get_all('Provides-Extra') or [])
  123. errors = _diff_errors(EXPECTED_EXTRAS, provided, 'extras')
  124. for aggregate, components in AGGREGATE_EXTRAS.items():
  125. expected = set().union(*(by_extra[component] for component in components))
  126. actual = by_extra[aggregate]
  127. if actual != expected:
  128. errors.append(
  129. f'extra [{aggregate}] must equal the union of {list(components)}: '
  130. f'missing {sorted(expected - actual)}, unexpected {sorted(actual - expected)}'
  131. )
  132. if self_refs := sorted(r for r in actual if canonicalize_name(Requirement(r).name) == 'netbox'):
  133. errors.append(f'extra [{aggregate}] must not reference netbox itself: {self_refs}')
  134. return errors
  135. def main(argv):
  136. if len(argv) != 2:
  137. print('usage: verify_wheel_metadata.py <wheel>')
  138. return 2
  139. root = Path(__file__).resolve().parent.parent
  140. hatch_metadata = load_hatch_metadata()
  141. metadata = read_metadata(argv[1])
  142. core, by_extra = split_requires(metadata)
  143. errors = [
  144. *check_metadata_version(metadata, root),
  145. *check_version(metadata, root, hatch_metadata),
  146. *check_core_requires(core, root, hatch_metadata),
  147. *check_extras(metadata, by_extra),
  148. ]
  149. if errors:
  150. print('Wheel metadata does not match the repository:')
  151. for error in errors:
  152. print(f' - {error}')
  153. return 1
  154. print(
  155. f'OK: wheel {metadata["Version"]} matches release.yaml, requirements.txt, expected extras, '
  156. 'and the core metadata pin'
  157. )
  158. return 0
  159. if __name__ == '__main__':
  160. sys.exit(main(sys.argv))