25592fa6c2
ci / test (push) Successful in 6m14s
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.
161 lines
5.3 KiB
Python
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())
|