File indexing completed on 2026-10-27 08:41:17
0001
0002
0003
0004 """Checks built pyrobird distributions before an upload.
0005
0006 Usage:
0007 python scripts/check_dist.py dist/ # every .whl and .tar.gz in dist/
0008 python scripts/check_dist.py dist/pyrobird-*.whl # specific files
0009 python scripts/check_dist.py --manifest-only # MANIFEST.in rules only, no build needed
0010
0011 For every wheel and sdist it checks that:
0012 - the files the application needs at run time are present, including the
0013 sample event file that the documentation links to;
0014 - the license and the third-party license notices of the frontend are present;
0015 - the archive is smaller than PyPI's upload limit (100 MiB unless the
0016 project was granted more; see --max-bytes).
0017
0018 It also checks that no MANIFEST.in exclusion rule matches a required file.
0019 Exits with 1 when a check fails.
0020 """
0021 import argparse
0022 import fnmatch
0023 import os
0024 import sys
0025 import tarfile
0026 import zipfile
0027
0028
0029 PYPI_MAX_BYTES = 100 * 1024 * 1024
0030
0031
0032 REQUIRED_FILES = (
0033 "LICENSE",
0034 "pyrobird/__init__.py",
0035 "pyrobird/server/__init__.py",
0036 "pyrobird/data/eic_geo_process_rules.yaml",
0037 "pyrobird/server/static/index.html",
0038 "pyrobird/server/static/3rdpartylicenses.txt",
0039 "pyrobird/server/static/assets/config.jsonc",
0040 "pyrobird/server/static/assets/data/example-cherenkov.firebird.json",
0041 )
0042
0043 PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
0044
0045
0046 def archive_members(path):
0047 """Returns member paths of a wheel or sdist, relative to the project root."""
0048 if path.endswith(".whl"):
0049 with zipfile.ZipFile(path) as archive:
0050
0051
0052
0053 members = set()
0054 for name in archive.namelist():
0055 if name.endswith("/"):
0056 continue
0057 top, _, rest = name.partition("/")
0058 if top.endswith(".dist-info") and rest.startswith("licenses/"):
0059 name = rest[len("licenses/"):]
0060 members.add(name)
0061 return members
0062 if path.endswith((".tar.gz", ".tgz")):
0063 with tarfile.open(path, "r:gz") as archive:
0064
0065 return {member.name.split("/", 1)[1] for member in archive.getmembers()
0066 if member.isfile() and "/" in member.name}
0067 raise ValueError(f"Not a wheel or an sdist: {path}")
0068
0069
0070 def check_archive(path, required=REQUIRED_FILES, max_bytes=PYPI_MAX_BYTES):
0071 """Returns a list of problems found in one archive; empty when it passes."""
0072 problems = []
0073 size = os.path.getsize(path)
0074 if size >= max_bytes:
0075 problems.append(f"{os.path.basename(path)} is {size:,} bytes; the limit is {max_bytes:,} bytes")
0076
0077 members = archive_members(path)
0078 for name in required:
0079 if name not in members:
0080 problems.append(f"{os.path.basename(path)} lacks {name}")
0081 return problems
0082
0083
0084 def manifest_exclusions(manifest_path, paths=REQUIRED_FILES):
0085 """Returns (path, rule) pairs where a MANIFEST.in exclusion rule drops one of `paths`.
0086
0087 Understands the exclusion commands of the MANIFEST.in template:
0088 exclude, recursive-exclude, global-exclude and prune. An include rule
0089 that comes after the exclusion is not taken into account.
0090 """
0091 hits = []
0092 with open(manifest_path) as manifest:
0093 rules = [line.strip() for line in manifest if line.strip() and not line.strip().startswith("#")]
0094
0095 for rule in rules:
0096 words = rule.split()
0097 command, args = words[0], words[1:]
0098 for path in paths:
0099 name = os.path.basename(path)
0100 if command == "exclude":
0101 matched = any(fnmatch.fnmatch(path, pattern) for pattern in args)
0102 elif command == "recursive-exclude" and args:
0103 directory = args[0].rstrip("/") + "/"
0104 matched = path.startswith(directory) and any(fnmatch.fnmatch(name, p) for p in args[1:])
0105 elif command == "global-exclude":
0106 matched = any(fnmatch.fnmatch(name, pattern) for pattern in args)
0107 elif command == "prune" and args:
0108 matched = path.startswith(args[0].rstrip("/") + "/")
0109 else:
0110 matched = False
0111 if matched:
0112 hits.append((path, rule))
0113 return hits
0114
0115
0116 def find_archives(targets):
0117 archives = []
0118 for target in targets:
0119 if os.path.isdir(target):
0120 archives.extend(sorted(os.path.join(target, name) for name in os.listdir(target)
0121 if name.endswith((".whl", ".tar.gz"))))
0122 else:
0123 archives.append(target)
0124 return archives
0125
0126
0127 def main(argv=None):
0128 parser = argparse.ArgumentParser(description="Check built pyrobird distributions.")
0129 parser.add_argument("targets", nargs="*", help="Wheels, sdists, or directories that hold them")
0130 parser.add_argument("--max-bytes", type=int, default=PYPI_MAX_BYTES,
0131 help=f"Size limit per archive (default: {PYPI_MAX_BYTES})")
0132 parser.add_argument("--manifest", default=os.path.join(PROJECT_DIR, "MANIFEST.in"),
0133 help="MANIFEST.in to check")
0134 parser.add_argument("--manifest-only", action="store_true", help="Check MANIFEST.in rules only")
0135 args = parser.parse_args(argv)
0136
0137 problems = [f"MANIFEST.in rule '{rule}' excludes {path}"
0138 for path, rule in manifest_exclusions(args.manifest)]
0139
0140 if not args.manifest_only:
0141 archives = find_archives(args.targets)
0142 if not archives:
0143 problems.append("No wheel or sdist to check")
0144 for archive in archives:
0145 archive_problems = check_archive(archive, max_bytes=args.max_bytes)
0146 problems.extend(archive_problems)
0147 if not archive_problems:
0148 size = os.path.getsize(archive)
0149 print(f"OK {os.path.basename(archive)}: {size:,} bytes, "
0150 f"{args.max_bytes - size:,} bytes below the limit")
0151
0152 for problem in problems:
0153 print(f"FAIL {problem}")
0154 return 1 if problems else 0
0155
0156
0157 if __name__ == "__main__":
0158 sys.exit(main())