#!/usr/bin/env python3 """Build a deployable zip of this dev folder. python tools/package.py -> dist/willtools--.zip python tools/package.py --out somewhere.zip Runs on the host CPython, never inside Revit, so modern syntax is fine here. What ships is the extension, the bridge client, and the docs. What does not ship is local state: session logs, caches, editor settings, and anything under a model's `_ticketqc` folder -- specs belong to their model, not to the tool. The zip unpacks to a single `pyrevit-dev/` folder, which is exactly the folder you point pyRevit's custom-extension setting at. """ from __future__ import annotations import argparse import datetime import hashlib import json import os import subprocess import sys import zipfile ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) TOP = 'pyrevit-dev' # Included verbatim, in this order. A directory brings its whole tree. INCLUDE = [ 'INSTALL.md', 'CLAUDE.md', 'docs', 'knowledge', 'tools', 'panellift/src', 'WillTools.extension', ] EXCLUDE_DIRS = {'__pycache__', '.git', '.claude', '.vscode', 'dist', '_ticketqc', 'scratch'} EXCLUDE_EXTS = {'.pyc', '.pyo', '.log'} EXCLUDE_NAMES = {'.bridge-sessions.log', '.DS_Store', 'Thumbs.db'} def version() -> str: with open(os.path.join(ROOT, 'WillTools.extension', 'extension.json'), encoding='utf-8') as fh: return json.load(fh).get('version', '0.0.0') def wanted(path: str) -> bool: name = os.path.basename(path) if name in EXCLUDE_NAMES: return False if os.path.splitext(name)[1].lower() in EXCLUDE_EXTS: return False parts = os.path.relpath(path, ROOT).split(os.sep) return not any(part in EXCLUDE_DIRS for part in parts) def collect() -> list[str]: """Absolute paths of everything that ships, sorted for a stable zip.""" found = [] for entry in INCLUDE: full = os.path.join(ROOT, entry) if os.path.isfile(full): if wanted(full): found.append(full) elif os.path.isdir(full): for folder, dirs, files in os.walk(full): dirs[:] = sorted(d for d in dirs if d not in EXCLUDE_DIRS) for f in sorted(files): path = os.path.join(folder, f) if wanted(path): found.append(path) else: print(' missing, skipped: {}'.format(entry), file=sys.stderr) return found def manifest(paths: list[str], ver: str) -> str: """A listing with hashes, so a deployed copy can be checked against source.""" lines = [ 'WillTools {}'.format(ver), 'built {}'.format(datetime.datetime.now().strftime('%Y-%m-%d %H:%M')), 'developed on Revit 2025.4 / pyRevit 6.5.3 / IronPython 2.7.12', 'see INSTALL.md', '', '{:<8} {:>8} {}'.format('sha256', 'bytes', 'file'), ] for path in paths: with open(path, 'rb') as fh: blob = fh.read() lines.append('{} {:>8} {}'.format( hashlib.sha256(blob).hexdigest()[:8], len(blob), os.path.relpath(path, ROOT).replace(os.sep, '/'))) return '\n'.join(lines) + '\n' def build(out: str) -> str: ver = version() paths = collect() os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True) with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as zf: for path in paths: arc = os.path.join(TOP, os.path.relpath(path, ROOT)) zf.write(path, arc.replace(os.sep, '/')) zf.writestr(TOP + '/MANIFEST.txt', manifest(paths, ver)) return out def scrub_gate(force: bool) -> bool: """Refuse to build a zip carrying a client name. See tools/scrub.py. A package is the form the work takes when it leaves this machine, so it is the right place to fail closed. --force exists because a false positive should not block a build outright, but it has to be typed on purpose. """ result = subprocess.run( [sys.executable, os.path.join(ROOT, 'tools', 'scrub.py'), '--worktree'], cwd=ROOT, check=False, capture_output=True, text=True, errors='replace') if result.returncode == 0: return True print(result.stdout, file=sys.stderr) print(result.stderr, file=sys.stderr) if force: print('scrub found hits; building anyway because --force was given.', file=sys.stderr) return True print('refusing to build. Fix the hits, or pass --force if they are noise.', file=sys.stderr) return False def main() -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument('--out', help='output zip path') ap.add_argument('--force', action='store_true', help='build even if the client-name scan finds something') args = ap.parse_args() if not scrub_gate(args.force): return 2 ver = version() default = os.path.join(ROOT, 'dist', 'willtools-{}-{}.zip'.format( ver, datetime.date.today().strftime('%Y%m%d'))) out = build(args.out or default) size = os.path.getsize(out) with zipfile.ZipFile(out) as zf: count = len(zf.namelist()) print('{}\n{} files, {:.1f} KB'.format(out, count, size / 1024.0)) return 0 if __name__ == '__main__': sys.exit(main())