#!/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 ') 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))