Files
willtools-demo/tools/package.py
T
will 8e6ee31596
ci / test (push) Successful in 6m18s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at cbe45c5
(2026-09-09). Code, docs and public catalogue only; no plant or job
knowledge layers, no client or job names (tools/scrub.py clean). The
intro deck is left out of this snapshot because its slide images could
not be checked for job drawings.
2026-09-09 08:32:15 -05:00

161 lines
5.3 KiB
Python

#!/usr/bin/env python3
"""Build a deployable zip of this dev folder.
python tools/package.py -> dist/willtools-<version>-<date>.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())