Files
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
2026-09-09 08:35: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())