| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158 |
- #!/usr/bin/env python3
- """Verify a built wheel's metadata matches the repository's declared inputs.
- Checks:
- 1. Version equals the PEP 440 version computed from netbox/release.yaml, reusing the
- same compute_version the hatchling metadata hook uses at build time.
- 2. Core Requires-Dist entries (those without an "extra ==" marker) match
- requirements.txt exactly, so the published wheel pins the tested dependency set.
- 3. Provides-Extra equals the expected set of optional-dependency groups.
- 4. Each aggregate extra equals the union of its component extras, comparing the wheel
- metadata against itself (immune to backend specifier normalization). pyproject.toml
- duplicates these requirement strings literally; this catches drift, for example a
- plugin pin bumped in one place only. Aggregates must not reference netbox itself,
- which would defeat this guard.
- """
- import importlib.util
- import re
- import sys
- import zipfile
- from collections import defaultdict
- from email.parser import Parser
- from pathlib import Path
- from packaging.requirements import Requirement
- from packaging.utils import canonicalize_name
- # Every optional-dependency group in pyproject.toml, as normalized (PEP 685) extra names.
- EXPECTED_EXTRAS = frozenset({
- 'branching',
- 'custom-objects',
- 'dev',
- 'git',
- 'ldap',
- 'recommended-plugins',
- 'remote-auth',
- 's3',
- 'saml2',
- 'sentry',
- 'swift',
- })
- # Aggregate extra -> the component extras whose entries it must equal the union of.
- AGGREGATE_EXTRAS = {
- 'remote-auth': ('ldap', 'saml2'),
- 'recommended-plugins': ('branching', 'custom-objects'),
- }
- # hatchling 1.30 writes extra markers with single quotes; other tools use double quotes.
- EXTRA_MARKER = re.compile(r'\bextra\s*==\s*["\']([^"\']+)["\']')
- def read_metadata(wheel_path):
- with zipfile.ZipFile(wheel_path) as archive:
- name = next(n for n in archive.namelist() if n.endswith('.dist-info/METADATA'))
- return Parser().parsestr(archive.read(name).decode())
- def load_hatch_metadata():
- """Load scripts/packaging/hatch_metadata.py by path.
- scripts/packaging is not a package (no __init__.py), and importing it by name would
- collide with the third-party packaging distribution, so load it from its file path.
- """
- path = Path(__file__).resolve().parent / 'packaging' / 'hatch_metadata.py'
- spec = importlib.util.spec_from_file_location('netbox_hatch_metadata', path)
- module = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(module)
- return module
- def normalize(requirement):
- return requirement.strip().lower().replace(' ', '')
- def split_requires(metadata):
- """Split Requires-Dist entries into core requirements and a per-extra mapping."""
- core = set()
- by_extra = defaultdict(set)
- for entry in metadata.get_all('Requires-Dist') or []:
- requirement, _, marker = entry.partition(';')
- match = EXTRA_MARKER.search(marker)
- if match:
- by_extra[match.group(1)].add(normalize(requirement))
- else:
- core.add(normalize(entry))
- return core, by_extra
- def check_version(metadata, root, hatch_metadata):
- release_text = (root / 'netbox' / 'release.yaml').read_text()
- version = hatch_metadata._read_release_field(release_text, 'version')
- if not version:
- return ['unable to read version from netbox/release.yaml']
- designation = hatch_metadata._read_release_field(release_text, 'designation')
- expected = hatch_metadata.compute_version(version, designation)
- if metadata['Version'] != expected:
- return [f'version mismatch: wheel has {metadata["Version"]}, release.yaml computes {expected}']
- return []
- def check_core_requires(core, root, hatch_metadata):
- # Parse with the hook's own parser so the verifier cannot drift from the build.
- pins = hatch_metadata.read_requirements((root / 'requirements.txt').read_text())
- errors = []
- missing = sorted(pin for pin in pins if normalize(pin) not in core)
- unexpected = sorted(core - {normalize(pin) for pin in pins})
- if missing:
- errors.append(f'requirements.txt pins missing from wheel: {missing}')
- if unexpected:
- errors.append(f'unexpected core requirements in wheel: {unexpected}')
- return errors
- def check_extras(metadata, by_extra):
- errors = []
- provided = frozenset(metadata.get_all('Provides-Extra') or [])
- if missing := sorted(EXPECTED_EXTRAS - provided):
- errors.append(f'extras missing from wheel: {missing}')
- if unexpected := sorted(provided - EXPECTED_EXTRAS):
- errors.append(f'unexpected extras in wheel: {unexpected}')
- for aggregate, components in AGGREGATE_EXTRAS.items():
- expected = set().union(*(by_extra[component] for component in components))
- actual = by_extra[aggregate]
- if actual != expected:
- errors.append(
- f'extra [{aggregate}] must equal the union of {list(components)}: '
- f'missing {sorted(expected - actual)}, unexpected {sorted(actual - expected)}'
- )
- if self_refs := sorted(r for r in actual if canonicalize_name(Requirement(r).name) == 'netbox'):
- errors.append(f'extra [{aggregate}] must not reference netbox itself: {self_refs}')
- return errors
- def main(argv):
- if len(argv) != 2:
- print('usage: verify_wheel_metadata.py <wheel>')
- return 2
- root = Path(__file__).resolve().parent.parent
- hatch_metadata = load_hatch_metadata()
- metadata = read_metadata(argv[1])
- core, by_extra = split_requires(metadata)
- errors = [
- *check_version(metadata, root, hatch_metadata),
- *check_core_requires(core, root, hatch_metadata),
- *check_extras(metadata, by_extra),
- ]
- if errors:
- print('Wheel metadata does not match the repository:')
- for error in errors:
- print(f' - {error}')
- return 1
- print(f'OK: wheel {metadata["Version"]} matches release.yaml, requirements.txt, and expected extras')
- return 0
- if __name__ == '__main__':
- sys.exit(main(sys.argv))
|