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.
1076 lines
50 KiB
Python
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())
|