WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m18s
ci / test (push) Successful in 6m18s
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.
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user