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

1076 lines
50 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Wire this checkout to a private KNOWLEDGE repo, install dependencies, set
environment variables, and PROVE the result with real answers, not exit codes.
python tools/setup_machine.py --knowledge <path-or-git-url>
python tools/setup_machine.py --check # doctor: verify only
python tools/setup_machine.py --knowledge <path> --force
WHY THIS EXISTS (2026-09-01)
-----------------------------
"I pulled everything down on another machine and everything I ran errored."
Reproduced from a clean clone with every WILLTOOLS_* variable unset and
APPDATA/LOCALAPPDATA redirected to empty folders -- a genuinely fresh
machine, not just an unconfigured shell on an already-set-up one. Every
failure found that way is described in machine-setup.md's troubleshooting
table (in the private KNOWLEDGE repo) and reproduced here as a check.
WHAT "VERIFIED" MEANS IN THIS FILE
-----------------------------------
`tools/knowledgelint.py` and `tools/knowledge_drift.py` are correct to
degrade gracefully when the plant/job layers are empty -- "nothing here" is
the honest answer for a lint tool running on a checkout that was never wired.
An INSTALLER asking the same question needs the opposite instinct: an empty
knowledge/plant/ after wiring is supposed to have happened is not "nothing to
report", it is the thing this script exists to catch. So verification here
does not stop at "the command didn't crash" -- it reads the plant layer's own
declared paths and the panellift CLI's own computed numbers, and fails loudly
if they are not there or not right.
WHAT THIS SCRIPT DOES NOT DO
-----------------------------
Touch `<KNOWLEDGE>/sessions/` -- that folder is intentional (Claude Code
session transcripts, kept as knowledge on purpose, per periodic backup
commits from the owner), not scratch to be cleaned up. This script only
reads it indirectly, by cloning the repo it lives in; it is verified as
present and correctly checked out, never modified.
Runs on plain CPython 3.9+, no Revit, no pyRevit, no Claude Code CLI --
exactly the owner's situation on a machine at home.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
import tempfile
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
REQUIREMENTS = os.path.join(ROOT, 'requirements.txt')
PANELLIFT_DIR = os.path.join(ROOT, 'panellift')
# The public catalogue every owner checkout carries tracked in git (see the
# .gitignore comment: "catalogue.yaml is the exception and is deliberately
# tracked here"). tools/make_dist.py strips it from a colleague distribution
# and ships this template in its place -- so "the template is here and the
# real file is not" is a clean, checkable marker for "this checkout is a
# stripped trial install", distinct from "this is a dev checkout that simply
# has not been wired to the private repo yet" (which still has the real
# catalogue.yaml, tracked, always).
CATALOGUE = os.path.join(ROOT, 'knowledge', 'catalogue.yaml')
CATALOGUE_TEMPLATE = os.path.join(ROOT, 'knowledge', 'catalogue.yaml.template')
# A place to record a DELIBERATE install choice, so this script can tell
# "not installed because the machine is broken" from "not installed on
# purpose" -- the same distinction the knowledge layer draws between a bare
# empty key (a value went missing, refused) and an explicit `{}` (looked, and
# none, on purpose, passes). Beside the repo root because it is a property of
# THIS checkout's install, same tier as requirements.txt, not of one knowledge
# layer or one tool.
#
# Format: one `<step>: <mode>` per line, `#` comments and blank lines ignored.
# The only step opt-able today:
#
# panellift: dev-mode
#
# meaning "not pip-installed on purpose while the package is still being
# shaped -- PYTHONPATH=src is the supported route here, not a workaround."
# See .setup-opts.example beside this file's repo root for the annotated
# template; that file is never read, only this exact name is.
OPTS_FILE = os.path.join(ROOT, '.setup-opts')
# step name -> the modes it accepts. An opt-file line naming a step not in
# this dict, or a mode that step does not accept, is reported as a failure
# rather than silently ignored -- an unrecognised opt is a blindfold nobody
# is checking any more (the same rule tools/knowledge_drift_allow.txt applies
# to its own stale suppression lines), and a script that PROVES its answers
# does not get to shrug at an opt it does not understand.
KNOWN_OPTS = {
'panellift': ('dev-mode',),
}
# local relative path (under ROOT) -> private-repo relative path.
# NOT one-to-one: no "knowledge/" prefix on the private side, and qna -> QNA
# changes case. This mapping is the whole reason machine-setup.md's old
# "or point the env vars there instead" advice was incomplete -- the env vars
# only reach two of these four, and tools/knowledge_drift.py reaches neither
# (it walks ROOT/knowledge/plant and ROOT/knowledge/job directly, hardcoded).
LINKS = [
(os.path.join('knowledge', 'plant'), 'plant'),
(os.path.join('knowledge', 'job'), 'job'),
(os.path.join('knowledge', 'library'), 'library'),
('QNA', 'qna'),
]
ENV_VARS = {
# name -> (relative-to-ROOT template, description)
'WILLTOOLS_KNOWLEDGE': (os.path.join('knowledge', 'plant', 'knowledge.yaml'),
'plant layer'),
'WILLTOOLS_JOBS': (os.path.join('knowledge', 'job'),
'job layer root'),
'PYTHONIOENCODING': ('utf-8', 'console default is cp1252; this repo prints '
'non-ASCII plant names and comments'),
}
DEPENDENCIES = ['PyYAML>=6.0', 'pytest>=7.0', 'pymupdf>=1.23']
REQUIREMENTS_HEADER = """\
# WillTools host-side (CPython) dependencies -- tools/*.py and both pytest
# suites. NOT the Revit extension: that runs under pyRevit's own IronPython
# and never imports from a site-packages directory.
#
# Written by tools/setup_machine.py (2026-09-01) because nothing in this repo
# named these before that, and a script should not be the only place a
# dependency list lives. Install with:
# python -m pip install -r requirements.txt
# or let tools/setup_machine.py do it.
"""
# The published ASPIRE Winter 2019 worked example (Brown, "Calculations for
# Handling and Shipping Precast Concrete Deck Panels"), the same fixture
# panellift's own test suite checks itself against. Used here, not to test
# panellift's arithmetic (its own suite does that), but to prove the
# INSTALLED CLI -- the thing a user actually runs -- reaches that arithmetic
# with no PYTHONPATH tricks.
ASPIRE_JOB = {
'schema': 'panellift/1',
'panel': {
'mark': 'ASPIRE-1', 'length_ft': 32.0, 'width_ft': 10.0,
'thickness_in': 8.75, 'unit_weight_pcf': 150.0,
'fci_psi': 4000.0, 'fc_psi': 5000.0,
'lifters': [
{'mark': 'L1', 'family': 'TEST_LIFTER', 'x': 6.627416997969522,
'y': 2.5, 'capacity_lb': 12000.0},
{'mark': 'L2', 'family': 'TEST_LIFTER', 'x': 6.627416997969522,
'y': 7.5, 'capacity_lb': 12000.0},
{'mark': 'L3', 'family': 'TEST_LIFTER', 'x': 25.372583002030478,
'y': 2.5, 'capacity_lb': 12000.0},
{'mark': 'L4', 'family': 'TEST_LIFTER', 'x': 25.372583002030478,
'y': 7.5, 'capacity_lb': 12000.0},
],
},
'rigging': {'mode': 'rolling', 'sling_angle_deg': 90.0},
'stages': [{'name': 'stripping', 'multiplier': 1.3, 'fc_psi': 4000.0}],
}
ASPIRE_PUBLISHED_PSI = 244.0
ASPIRE_TOLERANCE_PSI = 1.5
# --------------------------------------------------------------- reporting
class Step:
"""One line of PASS/FAIL/SKIP output, with enough in it to act on.
SKIP is a third state, not a shade of PASS or FAIL: it means "this step
was deliberately not run, and here is exactly why" (per a declared
.setup-opts choice), as opposed to PASS ("ran, and it's right") or FAIL
("ran, or should have, and it's wrong"). A permanently-FAILing step gets
ignored -- the day a real failure lands on that line, nobody looks -- and
an ignored check is the same disease as a check that always passes.
Collapsing SKIP into either PASS or FAIL would recreate that problem, so
it is tracked and printed separately, and the summary line always counts
it, the same discipline knowledge_drift_allow.txt applies to its own
suppression count: a shrinking report must never be mistaken for a
healthy one.
"""
def __init__(self, out):
self._out = out
self.failures = []
self.passed = []
self.skipped = []
def ok(self, name, detail=''):
self._out('PASS {0}{1}'.format(name, ' -- ' + detail if detail else ''))
self.passed.append(name)
def fail(self, name, error, fix):
self._out('FAIL {0}'.format(name))
self._out(' error: {0}'.format(error))
self._out(' fix: {0}'.format(fix))
self.failures.append(name)
def skip(self, name, reason):
self._out('SKIP {0} -- {1}'.format(name, reason))
self.skipped.append(name)
def info(self, msg):
self._out(' ' + msg)
def _run(cmd, **kw):
kw.setdefault('capture_output', True)
kw.setdefault('text', True)
kw.setdefault('encoding', 'utf-8')
kw.setdefault('errors', 'replace')
return subprocess.run(cmd, **kw)
def _ps(script):
"""Run a PowerShell snippet, return CompletedProcess. Avoids cmd.exe's
quoting rules entirely -- every path is passed through as a PowerShell
single-quoted literal, doubling embedded quotes, which is the one escape
rule that needs applying instead of guessing at cmd's."""
return _run(['powershell', '-NoProfile', '-NonInteractive', '-Command', script])
def _ps_quote(s):
return "'" + s.replace("'", "''") + "'"
# ------------------------------------------------------------------- opt-file
def load_opts(step):
"""Read `.setup-opts` -- deliberate install-choice overrides -- and
return `{step_name: mode}`. Absence is not an error: no file means no
opts declared, which is today's behaviour, unchanged; every step runs
for real and is judged for real.
Every line that IS present is checked against KNOWN_OPTS. A step name
this script does not recognise, or a mode that step does not accept, is
reported as a FAILURE here (not silently dropped) -- the blindfold-left-
behind rule tools/knowledge_drift_allow.txt applies to its own stale
suppression lines: an opt file only stays trustworthy if a line that no
longer matches anything is loud, not quiet. An empty value (`panellift:`
with nothing after the colon) is refused the same way a bare empty
knowledge key is -- it cannot be told apart from "I meant to write a
mode and forgot", which is a mistake, not a deliberate absence.
"""
opts = {}
if not os.path.isfile(OPTS_FILE):
return opts
name = os.path.basename(OPTS_FILE)
with open(OPTS_FILE, encoding='utf-8') as fh:
text = fh.read()
for num, raw in enumerate(text.splitlines(), 1):
line = raw.split('#', 1)[0].strip()
if not line:
continue
if ':' not in line:
step.fail('{0}:{1}'.format(name, num),
'unreadable line {0!r} -- expected "<step>: <mode>"'
.format(raw.strip()),
'fix or remove that line in {0}'.format(OPTS_FILE))
continue
key, _, value = line.partition(':')
key = key.strip()
value = value.strip()
if not value:
step.fail('{0}:{1}'.format(name, num),
'"{0}:" has no value -- an unstated choice is not a '
'deliberate one'.format(key),
'write "{0}: <mode>", or delete the line if it was '
'not meant to be there'.format(key))
continue
if key not in KNOWN_OPTS:
step.fail('{0} entry: {1}'.format(name, key),
'stale -- {0!r} names no step this script knows '
'about (known: {1})'
.format(key, ', '.join(sorted(KNOWN_OPTS))),
'delete the line from {0}, or fix the step name -- a '
'stale opt is a blindfold nobody is checking any '
'more'.format(OPTS_FILE))
continue
if value not in KNOWN_OPTS[key]:
step.fail('{0} entry: {1}'.format(name, key),
'{0!r} is not a mode {1!r} understands (known: {2})'
.format(value, key, ', '.join(KNOWN_OPTS[key])),
'use one of: {0}'.format(', '.join(KNOWN_OPTS[key])))
continue
opts[key] = value
return opts
# ----------------------------------------------------------------- private repo
def _looks_like_url(s):
return s.startswith(('http://', 'https://', 'git@', 'ssh://', 'file://'))
def dist_layout_detected():
"""True on a stripped colleague distribution: the public catalogue every
owner checkout carries is absent AND the template that stands in for it
on a trial install is present. Both conditions, not just the first --
an owner checkout mid-repair with a temporarily missing file should not
silently reclassify itself as a trial install."""
return os.path.isfile(CATALOGUE_TEMPLATE) and not os.path.isfile(CATALOGUE)
# `git status --porcelain` (v1) lines are always `XY path`, or
# `XY orig -> dest` for a rename -- a fixed two-character code, one space,
# then the path. X is the index (staged) state, Y is the worktree state
# relative to the index. Per `git status`'s own long-format table:
#
# X Y meaning
# -------------------------------------------------
# [ MARC] index matches HEAD, or a staged change
# [ MARC] M work tree changed since index
# [ MARC] D deleted in work tree <-- TRUNCATION EVIDENCE
# ? ? untracked -- git never tracked this path
# D deleted from index (staged deletion, e.g. `git rm`)
# [DAU] [UDA] unmerged / conflict markers -- cannot arise on a
# plain clone+status path with no merge in progress
#
# Only `Y == 'D'` with `X` one of `' ', 'M', 'A', 'R', 'C'` means what the
# long-path truncation bug actually produces: git's index still names the
# path (however that entry got there), but the file never made it onto
# disk. That is a TRACKED file missing from the working tree, and it is the
# only shape of porcelain output this completeness guard may fail on.
#
# Everything else is ordinary uncommitted work, not corruption:
# '??' untracked -- nothing was ever checked out here,
# so it cannot be evidence of a checkout that lost
# something.
# 'D ' (X='D', Y=' ') a DELIBERATE staged deletion (`git rm`, or
# `git add` after deleting by hand) -- index and
# worktree already agree the file is gone on
# purpose.
# 'M ', ' M', 'MM', 'A ', 'R ', 'C ', ... (no 'D' in Y)
# ordinary modified/staged/renamed work.
# any code containing 'U', or 'DD'/'AA'
# merge-conflict markers -- not reachable from a
# plain `git status` outside a merge, so treated
# as ordinary "other" rather than guessed at.
_TRUNCATION_X = (' ', 'M', 'A', 'R', 'C')
def _parse_porcelain_status(porcelain_output):
"""Classify `git status --porcelain` output into what the private-repo
completeness guard cares about vs. what is none of its business.
Returns `(missing_tracked, untracked, other)`, three lists of path
strings (a rename reports its destination path). `missing_tracked` is
the only one that should ever fail the completeness check -- see the
code table above.
"""
missing_tracked = []
untracked = []
other = []
for line in porcelain_output.splitlines():
if not line:
continue
code = line[:2]
path = line[3:]
if ' -> ' in path:
path = path.split(' -> ', 1)[1]
x, y = code[0], code[1]
if code == '??':
untracked.append(path)
elif x == 'U' or y == 'U' or code in ('DD', 'AA'):
other.append(path)
elif y == 'D' and x in _TRUNCATION_X:
missing_tracked.append(path)
else:
other.append(path)
return missing_tracked, untracked, other
def _report_incidental_status(step, missing, untracked, other, where):
"""Log the untracked/modified paths a completeness check found but did
not fail on -- informational only, so a reader of the PASS line is not
left wondering whether they were silently ignored."""
if untracked or other:
step.info('{0} untracked / {1} modified path(s) in {2}, not the '
"installer's business".format(len(untracked), len(other), where))
def resolve_private_repo(step, arg, dest_hint=None, force=False):
"""Return an absolute path to a usable, COMPLETE checkout of KNOWLEDGE,
or None (a failure already recorded on `step`).
Cloning is verified, not assumed: a partial checkout that git reported an
error on is exactly the failure this project keeps getting bitten by --
the KNOWLEDGE repo carries a sessions/ folder of Claude Code transcripts
(intentional, not a mistake -- see module docstring) whose filenames
encode long absolute paths, and a plain `git clone` to a moderately deep
directory fails partway through with 'Filename too long', leaving a
checkout git itself calls broken: 'Clone succeeded, but checkout
failed.' `git status --porcelain` on a truly complete fresh clone prints
nothing at all -- but the truncation this guards against is not the only
thing that can put output there. An untracked job folder or a locally
modified tracked file are ordinary uncommitted work and never indicate a
truncated checkout; only a TRACKED file missing from the working tree
(porcelain worktree status `D`) does. See `_parse_porcelain_status`.
"""
if not arg:
step.fail('private repo location',
'no --knowledge argument and nothing already wired',
'pass --knowledge <path-to-KNOWLEDGE-checkout-or-git-url>')
return None
if not _looks_like_url(arg):
path = os.path.abspath(arg)
if not os.path.isdir(path):
step.fail('private repo location',
'{0} does not exist and is not a git URL'.format(path),
'pass an existing checkout, or a URL to clone')
return None
rc = _run(['git', '-C', path, 'status', '--porcelain'])
if rc.returncode != 0:
step.fail('private repo location',
'{0} is not a git working tree ({1})'.format(path, rc.stderr.strip()),
'pass the KNOWLEDGE repo checkout itself')
return None
missing, untracked, other = _parse_porcelain_status(rc.stdout)
if missing:
step.fail('private repo checkout is complete',
'{0} has {1} tracked path(s) missing from the working '
'tree per git status'.format(path, len(missing)),
"this usually means an earlier clone was interrupted "
"(long-path failure or otherwise) -- run "
"'git -C \"{0}\" status' to see what is missing, then "
"either 're-clone with git -c core.longpaths=true' or "
"'git -C \"{0}\" restore --source=HEAD :/' to retry the "
"checkout in place".format(path))
return None
step.ok('private repo location', path)
_report_incidental_status(step, missing, untracked, other, path)
return path
dest = dest_hint or os.path.join(os.path.dirname(ROOT), 'KNOWLEDGE')
if os.path.isdir(dest) and not force:
rc = _run(['git', '-C', dest, 'status', '--porcelain'])
if rc.returncode == 0:
missing, untracked, other = _parse_porcelain_status(rc.stdout)
if not missing:
step.ok('private repo location', dest + ' (already cloned, verified complete)')
_report_incidental_status(step, missing, untracked, other, dest)
return dest
if force:
shutil.rmtree(dest)
else:
step.fail('private repo location',
'{0} exists and is not a clean complete checkout'.format(dest),
'pass --force to re-clone, or point --knowledge at a '
'different destination')
return None
clone = _run(['git', '-c', 'core.longpaths=true', 'clone', arg, dest])
if clone.returncode != 0:
step.fail('private repo clone',
clone.stderr.strip() or 'git clone failed, exit {0}'.format(clone.returncode),
'check the URL and credentials (Windows Credential Manager '
'must have a token cached); this already used '
'-c core.longpaths=true, which is the fix for the long-path '
'failure this repo is known to hit')
return None
status = _run(['git', '-C', dest, 'status', '--porcelain'])
missing, untracked, other = _parse_porcelain_status(status.stdout)
if missing:
step.fail('private repo checkout is complete',
'git reported success but {0} tracked path(s) are missing '
'from the working tree per git status -- this is the '
'exact silent-partial-clone failure mode this script '
'exists to catch'.format(len(missing)),
"run: git -C \"{0}\" status to see what's missing, then "
"git -C \"{0}\" restore --source=HEAD :/ to complete the "
"checkout in place (already cloned with core.longpaths=true, "
"so a short destination path is the next thing to try if "
"restore also fails)".format(dest))
return None
step.ok('private repo clone', '{0} <- {1} (verified complete: git status clean)'.format(dest, arg))
_report_incidental_status(step, missing, untracked, other, dest)
return dest
# ----------------------------------------------------------------------- wiring
def _junction_target(path):
"""The resolved target of a junction/symlink at `path`, or None if `path`
is not a reparse point (including: does not exist, or is an ordinary
directory). os.path.islink() returns False for NTFS junctions, so this
goes through PowerShell rather than trusting the os module here."""
if not os.path.exists(path):
return None
rc = _ps('(Get-Item -LiteralPath {0} -Force).Target'.format(_ps_quote(path)))
target = rc.stdout.strip()
return target or None
def _same_path(a, b):
return os.path.normcase(os.path.normpath(a)) == os.path.normcase(os.path.normpath(b))
def wire_links(step, private_root, force=False):
for local_rel, private_rel in LINKS:
local = os.path.join(ROOT, local_rel)
target = os.path.join(private_root, private_rel)
name = 'link {0} -> {1}'.format(local_rel, private_rel)
if not os.path.isdir(target):
step.fail(name, '{0} does not exist in the private repo'.format(target),
'the private repo checkout is missing this folder -- '
'confirm it is the right repo/branch')
continue
existing = _junction_target(local)
if existing:
if _same_path(existing, target):
step.ok(name, 'already linked, unchanged')
continue
if not force:
step.fail(name,
'{0} is already a junction, pointing at {1} instead'
.format(local, existing),
'pass --force to relink, or remove the existing '
'junction by hand if it points somewhere deliberate')
continue
_ps('Remove-Item -LiteralPath {0} -Force'.format(_ps_quote(local)))
elif os.path.isdir(local):
# A real directory sits here already. knowledge/plant, knowledge/job
# and knowledge/library are entirely gitignored so this is always
# safe to replace. QNA/ is the one exception: QNA/README.md is
# TRACKED (the gitignore carves out exactly that one file) -- after
# linking, QNA/README.md resolves through the junction to the
# private repo's own qna/README.md, which is expected and fine
# (both exist and both are meant to explain the same convention),
# but `git status` may show it as modified if the wording differs.
# That is not a bug; it is two copies of one sentence, and this is
# the tradeoff of not vendoring the private repo's README a second
# time.
entries = os.listdir(local)
trivial = set(entries) <= {'README.md'}
if not trivial and not force:
step.fail(name,
'{0} already exists with real content: {1}'
.format(local, ', '.join(entries[:5])),
'pass --force to replace it (only do this if you '
'are sure nothing here is unique -- it will be '
'deleted, not merged)')
continue
shutil.rmtree(local)
if local_rel == 'QNA' and trivial and 'README.md' in entries:
step.info('QNA/README.md was tracked; after linking it now '
'reads through to the private repo\'s qna/README.md '
'-- git status may show it as modified, which is '
'expected, not a wiring failure')
rc = _ps('New-Item -ItemType Junction -Path {0} -Target {1} -Force'
.format(_ps_quote(local), _ps_quote(target)))
if rc.returncode != 0:
step.fail(name, rc.stderr.strip() or 'New-Item failed',
'run as the same user that owns both directories; '
'junctions need no admin rights but do need both '
'paths on a local (non-network) volume')
continue
step.ok(name, 'junction created')
# ------------------------------------------------------------------- env vars
_NO_KNOWLEDGE_PATH_VARS = ('WILLTOOLS_KNOWLEDGE', 'WILLTOOLS_JOBS')
def set_env_vars(step, no_knowledge=False):
for name, (rel_or_value, desc) in ENV_VARS.items():
if no_knowledge and name in _NO_KNOWLEDGE_PATH_VARS:
# Do NOT point these at a path that will never exist. Pointing
# WILLTOOLS_KNOWLEDGE at a missing file is not the same state as
# never setting it at all -- knowledge.load()'s own comment says
# so: "if somebody has explicitly set WILLTOOLS_KNOWLEDGE and the
# file it names is not there, that is a definite finding" and
# sets `_plant_env_missing`. On a genuinely knowledge-less trial
# install that finding would be noise on every single run, for a
# state that is expected rather than wrong -- so this script
# leaves the variable unset, which is the "ordinary case" branch
# the same comment describes as quiet on purpose.
step.skip('env {0}'.format(name),
'no knowledge layers declared -- left unset so '
'knowledge.load() treats this as the ordinary '
'"nothing configured" case rather than "pointed '
'somewhere and found nothing"')
continue
value = rel_or_value if name == 'PYTHONIOENCODING' else os.path.join(ROOT, rel_or_value)
current = _run(['powershell', '-NoProfile', '-NonInteractive', '-Command',
'[Environment]::GetEnvironmentVariable({0},"User")'.format(_ps_quote(name))])
already = current.stdout.strip() == value
rc = _ps('[Environment]::SetEnvironmentVariable({0},{1},"User")'
.format(_ps_quote(name), _ps_quote(value)))
os.environ[name] = value # so this same process's verification sees it too
if rc.returncode != 0:
step.fail('env {0}'.format(name), rc.stderr.strip(),
'set it by hand: setx {0} "{1}"'.format(name, value))
continue
step.ok('env {0}'.format(name),
'{0}{1}'.format(value, ' (unchanged)' if already else ' (set)'))
step.info('{0} -- {1}'.format(name, desc))
step.info('durable for NEW shells; this process has them too for the '
'checks below, no restart needed to finish this run')
# --------------------------------------------------------------- dependencies
def write_requirements(step):
if os.path.isfile(REQUIREMENTS):
step.ok('requirements.txt', 'already present, not overwritten')
return
with open(REQUIREMENTS, 'w', encoding='utf-8') as fh:
fh.write(REQUIREMENTS_HEADER)
for dep in DEPENDENCIES:
fh.write(dep + '\n')
step.ok('requirements.txt', 'written: ' + ', '.join(DEPENDENCIES))
def _panellift_pythonpath_route(step, verb):
"""Prove `PYTHONPATH=src` reaches panellift's CLI -- the supported route
for a checkout that has deliberately not `pip install -e`d it. `verb` is
'runs' (full setup, actually installing nothing) so the PASS line reads
naturally either way this is called from."""
src = os.path.join(PANELLIFT_DIR, 'src')
env = dict(os.environ)
env['PYTHONPATH'] = src
rc = _run([sys.executable, '-m', 'panellift', '--help'], env=env)
if rc.returncode != 0:
step.fail('panellift {0} via PYTHONPATH=src (dev mode)'.format(verb),
(rc.stdout + rc.stderr).strip()[-2000:],
'confirm {0} contains a panellift package importable this '
'way, or drop the "panellift: dev-mode" line in {1} to '
'install normally instead'.format(src, OPTS_FILE))
return False
step.ok('panellift {0} via PYTHONPATH=src (dev mode)'.format(verb),
'verified route: PYTHONPATH={0} python -m panellift --help'
.format(src))
return True
def install_dependencies(step, opts=None):
opts = opts or {}
write_requirements(step)
rc = _run([sys.executable, '-m', 'pip', 'install', '-r', REQUIREMENTS])
if rc.returncode != 0:
step.fail('pip install -r requirements.txt', rc.stderr.strip()[-2000:],
'check network access / pip index; retry by hand: '
'python -m pip install -r requirements.txt')
return False
step.ok('pip install -r requirements.txt')
if opts.get('panellift') == 'dev-mode':
step.info('panellift: dev-mode declared in {0} -- skipping pip '
'install -e panellift, verifying the PYTHONPATH route '
'instead'.format(OPTS_FILE))
return _panellift_pythonpath_route(step, 'runs')
rc = _run([sys.executable, '-m', 'pip', 'install', '-e', PANELLIFT_DIR])
if rc.returncode != 0:
step.fail('pip install -e panellift', rc.stderr.strip()[-2000:],
'retry by hand: python -m pip install -e "{0}"'.format(PANELLIFT_DIR))
return False
step.ok('pip install -e panellift')
return True
# ------------------------------------------------------------------ verification
def _populated(path, min_bytes=64):
"""`path` is more than an empty (or link-to-empty) directory: at least one
non-backup file with real content underneath it. A junction that resolves
but resolves to nothing is exactly the failure mode a lint tool is right
to shrug at and an installer is wrong to."""
if not os.path.isdir(path):
return False, 0, 0
total = 0
n = 0
for dirpath, dirnames, filenames in os.walk(path):
dirnames[:] = [d for d in dirnames if not d.startswith('.bak')]
for fn in filenames:
if '.bak-' in fn or fn.endswith(('.bak',)):
continue
fp = os.path.join(dirpath, fn)
try:
size = os.path.getsize(fp)
except OSError:
continue
total += size
if size > 0:
n += 1
return (n > 0 and total >= min_bytes), n, total
def verify_plant_layer_populated(step, no_knowledge=False):
if no_knowledge:
step.skip('plant layer is populated',
'no knowledge layers declared -- checks that need them '
'stand down (expected for a trial install). Ask the '
'owner for the plant/job layers and re-run with '
'--knowledge <path> to check them for real.')
step.skip('job layer is populated',
'no knowledge layers declared -- same as the plant layer above')
return True
plant = os.path.join(ROOT, 'knowledge', 'plant')
ok, n, total = _populated(plant)
if not ok:
step.fail('plant layer is populated',
'{0} is linked/present but has {1} file(s), {2} byte(s) of '
'real content'.format(plant, n, total),
'the link target is empty, or the private repo checkout at '
'that path never had plant data -- confirm --knowledge '
'pointed at the right checkout')
return False
step.ok('plant layer is populated', '{0} file(s), {1} bytes'.format(n, total))
job = os.path.join(ROOT, 'knowledge', 'job')
ok, n, total = _populated(job)
if not ok:
step.fail('job layer is populated',
'{0} has {1} file(s), {2} byte(s)'.format(job, n, total),
'same as above -- link target is empty or wrong')
return False
step.ok('job layer is populated', '{0} file(s), {1} bytes'.format(n, total))
return True
def verify_knowledgelint(step, no_knowledge=False):
rc = _run([sys.executable, os.path.join(ROOT, 'tools', 'knowledgelint.py')])
if rc.returncode != 0:
step.fail('knowledgelint.py', (rc.stdout + rc.stderr).strip()[-2000:],
'a FAIL line names the broken YAML file; a PyYAML import '
'error means dependencies did not install -- rerun this '
'script, or: python -m pip install -r requirements.txt')
return False
if no_knowledge and 'no knowledge layers configured' in rc.stdout:
step.skip('knowledgelint.py',
'no knowledge layers -- nothing to lint (expected for a '
'trial install)')
return True
n = None
for line in rc.stdout.splitlines():
if line.strip().endswith('layer file(s) parse.'):
try:
n = int(line.strip().split()[0])
except ValueError:
pass
if n is None:
step.fail('knowledgelint.py', 'ran clean but printed no summary line -- unexpected output',
'run it by hand and read the output: python tools/knowledgelint.py')
return False
if n < 3:
if no_knowledge:
step.skip('knowledgelint.py',
'{0} layer file(s) parse -- fewer than the full '
'catalogue+plant+job set, expected with no knowledge '
'layers declared'.format(n))
return True
step.fail('knowledgelint.py',
'only {0} layer file(s) parsed -- expected the catalogue plus '
'at least a plant and a job layer'.format(n),
'the wiring step likely failed or linked an empty folder; '
're-run this script without --check')
return False
step.ok('knowledgelint.py', '{0} layer file(s) parse, 0 FAIL'.format(n))
return True
def verify_libindex(step):
script_path = os.path.join(ROOT, 'tools', 'libindex.py')
if not os.path.isfile(script_path):
# tools/libindex.py manages the owner's private PDF library and
# never ships in a colleague distribution (see tools/make_dist.py's
# manifest) -- absence here is the tool not being part of this
# checkout at all, not a broken install. Without this guard,
# `python <missing file>` fails with an unhelpful interpreter error
# that this step would otherwise report as a mysterious FAIL.
step.skip('libindex.py status',
'tools/libindex.py is not part of this distribution')
return True
rc = _run([sys.executable, script_path, 'status'])
out = (rc.stdout + rc.stderr).strip()
# Two coherent outcomes: no index built yet (fine -- building needs the
# PDF drop folder populated, which is not part of a bare install), or a
# real status report. Anything else -- a traceback -- is a failure.
if 'no index built yet' in out or 'ledger:' in out or out.startswith('index:'):
step.ok('libindex.py status', out.splitlines()[0] if out else '(empty)')
return True
step.fail('libindex.py status', out[-2000:],
'unexpected output shape -- run by hand: python tools/libindex.py status')
return False
def verify_panellift(step, tmp_dir, opts=None):
opts = opts or {}
if opts.get('panellift') == 'dev-mode':
# The installed-CLI question does not apply here on purpose -- dev
# mode means panellift was never pip-installed, so asking "does the
# installed CLI run with no PYTHONPATH" would FAIL every single time,
# for a reason that has nothing to do with today's checkout. That is
# exactly the permanently-failing check the whole opt-file exists to
# retire: reported once as SKIP, with the file that explains it named
# in the line itself, rather than as a FAIL nobody has to read twice
# to learn to ignore. The route this checkout actually uses IS
# verified -- during `install_dependencies` on a real run (see
# `_panellift_pythonpath_route`); `--check` verifies nothing and
# changes nothing by design, so it reports the skip alone.
step.skip('panellift CLI runs installed (no PYTHONPATH)',
'dev mode, per {0}'.format(os.path.basename(OPTS_FILE)))
return True
job_path = os.path.join(tmp_dir, 'aspire_job.json')
report_path = os.path.join(tmp_dir, 'aspire_report.html')
with open(job_path, 'w', encoding='utf-8') as fh:
json.dump(ASPIRE_JOB, fh)
# Deliberately NOT setting PYTHONPATH here: this is the same command a
# user would type, and it must work off the `pip install -e` above alone.
rc = _run([sys.executable, '-m', 'panellift', job_path, '-o', report_path, '--json'])
if rc.returncode != 0:
step.fail('panellift CLI runs installed (no PYTHONPATH)',
(rc.stdout + rc.stderr).strip()[-2000:],
'"No module named panellift" means the editable install did '
'not take -- retry: python -m pip install -e "{0}"'
.format(PANELLIFT_DIR))
return False
try:
result = json.loads(rc.stdout)
peak = result['stages'][0]['peak_tension_psi']
verdict = result['verdict']
except Exception as exc:
step.fail('panellift CLI runs installed (no PYTHONPATH)',
'ran, but output did not parse as expected: {0}'.format(exc),
'run by hand and inspect: python -m panellift "{0}" -o out.html --json'
.format(job_path))
return False
step.ok('panellift CLI runs installed (no PYTHONPATH)')
if abs(peak - ASPIRE_PUBLISHED_PSI) > ASPIRE_TOLERANCE_PSI:
step.fail('panellift computes the published ASPIRE stress',
'peak_tension_psi={0:.2f}, expected {1} +/- {2}'
.format(peak, ASPIRE_PUBLISHED_PSI, ASPIRE_TOLERANCE_PSI),
'the install reaches panellift but the arithmetic disagrees '
'with the published worked example -- this is a code '
'regression, not a setup problem; do not "fix" it by '
'loosening the tolerance')
return False
step.ok('panellift computes the published ASPIRE stress',
'peak_tension_psi={0:.2f} (published {1}), verdict={2}'
.format(peak, ASPIRE_PUBLISHED_PSI, verdict))
if not os.path.isfile(report_path) or os.path.getsize(report_path) < 1000:
step.fail('panellift writes a report', 'aspire_report.html missing or tiny',
'run by hand and inspect the -o path')
return False
step.ok('panellift writes a report', '{0} bytes'.format(os.path.getsize(report_path)))
return True
def verify_dependencies_importable(step):
rc = _run([sys.executable, '-c', 'import yaml, pytest, fitz'])
if rc.returncode != 0:
step.fail('yaml/pytest/fitz importable', rc.stderr.strip()[-1000:],
'python -m pip install -r requirements.txt')
return False
step.ok('yaml/pytest/fitz importable')
return True
# What Panel Analysis' CPython side imports at module scope (tools/deckcheck.py
# and tools/panelanalysis.py both sys.exit(2) without it). Kept in step with
# PA_NEEDS in the PanelAnalysis button by hand; the test names both.
BUTTON_PYTHON_NEEDS = ('yaml',)
def _load_liftexport():
"""`vibe.liftexport` on host CPython, or None. Same stub `tools/deckcheck.py`
uses: `vibe/__init__.py` imports the Revit API at module scope, so a bare
package object pointed at the directory keeps it from ever running."""
import types
lib = os.path.join(ROOT, 'WillTools.extension', 'lib')
if lib not in sys.path:
sys.path.insert(0, lib)
if 'vibe' not in sys.modules:
stub = types.ModuleType('vibe')
stub.__path__ = [os.path.join(lib, 'vibe')]
sys.modules['vibe'] = stub
try:
from vibe import liftexport
except Exception:
return None
return liftexport
def verify_button_python(step, liftexport=None):
"""Ask the extension's OWN interpreter chooser which CPython the ribbon
buttons will start, and require that one to import what they need.
`verify_dependencies_importable` checks `sys.executable` -- whichever
python ran this script -- and on 2026-09-05 that passed on a machine
where every Panel Analysis mark went NOT RUN: PATH had a Python 3.11 with
PyYAML, `liftexport.find_python()` chose a bare Python 3.13 by folder
name, and no check had ever asked the chooser for its answer (Gitea #16).
This one does. It runs the same `find_python_report` the buttons call,
in the same environment, and prints every candidate it turned down.
One gap, stated: this runs with no open model, so a `handling.python_exe`
set in the knowledge layers is not read here -- WILLTOOLS_PYTHON is.
"""
liftexport = liftexport or _load_liftexport()
if liftexport is None:
step.fail('the buttons\' CPython imports {0}'.format(
', '.join(BUTTON_PYTHON_NEEDS)),
'vibe.liftexport could not be imported on this host',
'python -m pytest WillTools.extension/tests -q -k liftexport')
return False
exe, tried = liftexport.find_python_report(None, BUTTON_PYTHON_NEEDS)
name = 'the buttons\' CPython imports {0}'.format(
', '.join(BUTTON_PYTHON_NEEDS))
if exe:
step.ok(name, '{0} -- {1}'.format(exe, tried[-1][1]))
return True
step.fail(name, liftexport.describe_python_search(tried, BUTTON_PYTHON_NEEDS),
'pip install into the interpreter Revit will pick (the first '
'one listed), or set handling.python_exe / WILLTOOLS_PYTHON '
'to the one that has the packages')
return False
def run_verification(step, opts=None, no_knowledge=False):
"""Returns True iff every check passed (a SKIP counts as passed -- it is
a deliberate non-answer, not a wrong one). Order matters: dependency
problems should surface before knowledge-layer problems, which should
surface before anything that depends on both (panellift's install check
depends on neither the plant layer nor pyyaml, so it can run regardless
and often gives the clearest single answer)."""
opts = opts or {}
ok = True
ok &= verify_dependencies_importable(step)
ok &= verify_button_python(step)
ok &= verify_plant_layer_populated(step, no_knowledge)
ok &= verify_knowledgelint(step, no_knowledge)
ok &= verify_libindex(step)
with tempfile.TemporaryDirectory(prefix='setup_machine_verify_') as tmp:
ok &= verify_panellift(step, tmp, opts)
return ok
# --------------------------------------------------------------------- main
def _verdict(step):
"""Turn the accumulated Step state into the VERDICT line(s) and an exit
code. Split out from main() so the summary shape -- how the passed and
skipped counts get worded, and when the skip clause appears at all -- can
be tested without shelling out to pip and PowerShell.
The skipped-count clause is appended only when `step.skipped` is
non-empty, so a checkout with no `.setup-opts` and knowledge declared
(today's owner-machine default: no file, nothing skipped) prints exactly
the old unadorned VERDICT line -- absence of both changes nothing about
this shape, only presence of either does. It no longer names
`.setup-opts` specifically in the tail: a skip can now come from either
that file or a knowledge-less install being declared/auto-detected (see
`dist_layout_detected`), and attributing every skip to the opt-file
would be wrong on a trial install where none exists at all.
"""
tail = ''
if step.skipped:
tail = ', {0} skipped (see SKIP lines above for why each one)'.format(
len(step.skipped))
if step.failures:
lines = ['VERDICT: FAIL -- {0} step(s) failed: {1}'
.format(len(step.failures), ', '.join(step.failures))]
if step.skipped:
lines.append('({0} passed{1})'.format(len(step.passed), tail))
lines.append('Fix the first one and re-run this script (or --check '
'to just re-verify) -- failures often cascade from '
'one root cause.')
return 1, lines
return 0, ['VERDICT: PASS -- {0} passed{1} -- everything above is a '
'real, checked answer, not just an exit code.'
.format(len(step.passed), tail)]
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__.split('\n\n')[0],
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('--knowledge', metavar='PATH_OR_URL',
help='the private KNOWLEDGE repo: an existing checkout, '
'or a git URL to clone. Not required with --check, '
'or on a re-run where the links already exist.')
ap.add_argument('--check', action='store_true',
help='doctor mode: run only the verification checks '
'against whatever is already set up, change nothing')
ap.add_argument('--force', action='store_true',
help='replace an existing link that points somewhere '
'else, or a non-empty directory sitting where a '
'link needs to go')
ap.add_argument('--no-knowledge', action='store_true',
help='declare a knowledge-less install explicitly: no '
'private KNOWLEDGE repo, no plant/job layers. '
'Auto-detected on a colleague distribution built '
'by tools/make_dist.py (knowledge/'
'catalogue.yaml.template present, the real '
'catalogue.yaml absent); pass this by hand to force '
'the same behaviour on any other checkout.')
args = ap.parse_args(argv)
no_knowledge = args.no_knowledge or dist_layout_detected()
step = Step(print)
print('=' * 72)
print('WillTools machine setup' + (' -- CHECK ONLY' if args.check else ''))
print('=' * 72)
print('repo root: {0}'.format(ROOT))
if no_knowledge:
print('no knowledge layers declared{0} -- this is a trial install; '
'checks that need a plant/job layer will report SKIP, not '
'FAIL. Ask the owner for real layers when you have a job to '
'run for real.'.format(
' (auto-detected)' if not args.no_knowledge else ''))
print()
# Loaded before anything else, in BOTH modes: --check needs to know
# panellift is in dev mode before it reaches verify_panellift, and a
# malformed or stale opt-file entry is itself a failure worth surfacing
# up front rather than only discovering it mid-run.
opts = load_opts(step)
if opts:
print()
if not args.check:
if no_knowledge and not args.knowledge:
# No --knowledge argument and no private repo is not a failure
# here the way resolve_private_repo() would ordinarily treat it
# -- it is the declared or auto-detected state of a colleague
# trial install, which has nowhere to point one. Skip wiring
# entirely rather than failing on "no --knowledge argument and
# nothing already wired".
step.skip('private repo wiring',
'no --knowledge and no private repo declared -- '
'knowledge-less trial install (pass --knowledge '
'<path-or-url> once you have a private KNOWLEDGE '
'repo to wire in)')
print()
else:
private_root = resolve_private_repo(step, args.knowledge, force=args.force)
print()
if private_root:
wire_links(step, private_root, force=args.force)
print()
set_env_vars(step, no_knowledge)
print()
install_dependencies(step, opts)
print()
print('-' * 72)
print('verification')
print('-' * 72)
all_ok = run_verification(step, opts, no_knowledge)
print()
print('=' * 72)
code, lines = _verdict(step)
for line in lines:
print(line)
return code
if __name__ == '__main__':
raise SystemExit(main())