WillTools: precast shop-ticket QC and panel handling tools
ci / test (push) Has been cancelled

Public demo snapshot of an in-house pyRevit extension for precast concrete
shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel
handling solver (PCI Chapter 8).

- No client, job, plant or product data: knowledge layers ship as
  structural templates only.
- Ticket QC's model-read escalation targets a LAN model server endpoint
  (configurable), not a cloud provider.
- Built from the private development repo via tools/make_dist.py.
This commit is contained in:
2026-09-03 16:33:47 -05:00
commit 52d94303bc
281 changed files with 155656 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
# -*- coding: utf-8 -*-
"""Which job's knowledge layer a HOST-SIDE tool should read, with no doc open.
Inside Revit, `designspec.root_dir(doc)` finds a job's `_ticketqc` folder by
`doc.Title` -- WILLTOOLS_JOBS wins when it is set, APPDATA is the fallback for
a model with no path yet. That lookup needs a live document and cannot be
ported here: a terminal running `tools/snapshot_cycle.py` or
`tools/knowledge_drift.py` has no `doc.Title` to pick a folder by, and
guessing one from several candidates is a worse failure than asking, because a
wrong guess runs silently on the wrong job's settings and looks completely
normal -- the exact failure mode this whole codebase is built against
(see CLAUDE.md's "could not judge" discipline).
THE RESOLUTION ORDER, deliberately, and in this order only:
1. an EXPLICIT file (a `--knowledge` flag, or an env var that names a
FILE) wins outright. The caller already knows which file; nothing
here second-guesses it, and a bad path is reported the ordinary way a
missing file is reported (by whoever tries to read it), not specially
here.
2. WILLTOOLS_JOBS names a folder holding EXACTLY ONE job subfolder (one
that has a `knowledge.yaml` in it) -- unambiguous, so it is used with
no further input.
3. a NAMED job (`--job "sample_job"`) picks one out of several by
name.
4. otherwise: REFUSE, and name every candidate folder found. This is the
correct behaviour when several real job folders exist and none was
named -- not a hole to fill in later. Silently picking "the newest
one" or "the one that happens to set the key being asked for" is a
guess wearing the clothes of a rule, and the day it guesses wrong the
run looks completely normal while reading another job's tolerances.
This is intentionally the only place this order is written down. Two tools
reading job knowledge off disk had two different half-answers to "where is
it" before this module existed -- `tools/snapshot_cycle.py` required an
explicit `--knowledge` and reported the resulting absence as if the setting
itself were missing, and `tools/knowledge_drift.py` walked a path hardcoded
to this checkout and ignored WILLTOOLS_JOBS entirely. Both import this now.
`plantpath.py` is this file's sibling for the PLANT layer -- one FILE, chosen
by an env var that may name either a file or its folder. This is a different
shape (WILLTOOLS_JOBS names a folder that may hold several jobs, and there is
no `doc.Title` here to break the tie) so it earns its own module rather than
being bent to fit plantpath's shape, but the discipline is the same one:
never fall back to a plausible-looking wrong answer in silence, and say
which of "not configured", "not found" and "found but ambiguous" happened,
because they are different facts and read as different sentences.
IronPython 2.7: no f-strings, no pathlib. (This module has no Revit import at
module scope -- or at all -- so it is also safe to load from host CPython the
same way `plantpath.py` is: `tools/snapshot_cycle.py` and
`tools/knowledge_drift.py` both do, via the `vibe` package stub documented in
`WillTools.extension/tests/conftest.py`.)
"""
# pylint: disable=invalid-name
import os
JOBS_ENV = 'WILLTOOLS_JOBS' # same spelling as designspec.JOBS_ENV
KNOWLEDGE_FILENAME = 'knowledge.yaml'
class JobLocateResult(object):
"""What `resolve_job_knowledge()` found, or precisely why it did not.
`path` is set only on success -- callers should treat `bool(result)` (or
`result.path`) as the found/not-found test. `blocking` marks the
not-found cases that are a genuine misconfiguration worth stopping a run
over (an ambiguous WILLTOOLS_JOBS, a `--job` that names nothing, a
WILLTOOLS_JOBS that is not even a folder) as opposed to the ordinary
"nothing configured yet" case, which a caller may reasonably continue
past on DEFAULTS alone -- exactly as it always could when no `--knowledge`
was given.
"""
__slots__ = ('path', 'how', 'reason', 'candidates', 'root', 'blocking')
def __init__(self, path=None, how=None, reason=None, candidates=None,
root=None, blocking=False):
self.path = path
self.how = how
self.reason = reason
self.candidates = list(candidates or [])
self.root = root
self.blocking = bool(blocking)
def __bool__(self):
return self.path is not None
__nonzero__ = __bool__ # IronPython 2.7
def __repr__(self):
if self.path:
return 'JobLocateResult(path={0!r}, how={1!r})'.format(
self.path, self.how)
return 'JobLocateResult(reason={0!r})'.format(self.reason)
def job_candidates(jobs_root):
"""Every subfolder of `jobs_root` that carries a `knowledge.yaml`.
Sorted, so a refusal that lists them is stable across runs and a "single
unambiguous" check does not depend on directory-walk order. Nothing here
tries to tell a "real" job folder from a stray one (a leftover detach
copy, an old bootstrap) -- that would be exactly the judgement call this
module exists to refuse to make silently. Every folder with a
`knowledge.yaml` in it is a candidate; a human names the right one.
"""
out = []
if not jobs_root or not os.path.isdir(jobs_root):
return out
for name in sorted(os.listdir(jobs_root)):
folder = os.path.join(jobs_root, name)
if not os.path.isdir(folder):
continue
candidate = os.path.join(folder, KNOWLEDGE_FILENAME)
if os.path.isfile(candidate):
out.append(candidate)
return out
def _job_name_of(knowledge_path):
return os.path.basename(os.path.dirname(knowledge_path))
def resolve_job_knowledge(explicit=None, jobs_root=None, job_name=None,
env=None):
"""Which job knowledge.yaml a host-side tool should read. A JobLocateResult.
explicit a path stated outright -- a `--knowledge` flag, or an env var
that already names a FILE (`WILLTOOLS_JOB_KNOWLEDGE`, as
`tools/snapshot_cycle.py`'s argparse default already reads).
Wins over everything below. Not checked for existence here:
whoever reads the file reports a missing one the ordinary
way, and duplicating that check here would be a second,
possibly-drifted answer to "does this file exist".
jobs_root WILLTOOLS_JOBS, already resolved by the caller. Pass `None`
to have it read from `env` here instead.
job_name a folder name under `jobs_root`, stated explicitly (a `--job`
flag) to pick one out of several.
env `os.environ` by default; overridable so tests never touch the
real environment.
"""
env = os.environ if env is None else env
if explicit:
return JobLocateResult(path=explicit, how='explicit')
root = jobs_root if jobs_root is not None else env.get(JOBS_ENV)
if not root:
return JobLocateResult(
reason='no job knowledge file was named (--knowledge) and {0} '
'is not set, so there is no folder to look for a job '
'under.'.format(JOBS_ENV))
if not os.path.isdir(root):
return JobLocateResult(
root=root, blocking=True,
reason='{0} is set to {1}, but that is not a folder.'
.format(JOBS_ENV, root))
if job_name:
candidate = os.path.join(root, job_name, KNOWLEDGE_FILENAME)
if os.path.isfile(candidate):
return JobLocateResult(path=candidate, how='named', root=root)
candidates = job_candidates(root)
names = [_job_name_of(c) for c in candidates]
return JobLocateResult(
root=root, candidates=candidates, blocking=True,
reason='no job named {0!r} under {1} (looked for {2}). Job '
'folders found there: {3}'
.format(job_name, root, candidate,
', '.join(names) or '(none)'))
candidates = job_candidates(root)
if len(candidates) == 1:
return JobLocateResult(path=candidates[0], how='single', root=root)
if not candidates:
return JobLocateResult(
root=root,
reason='{0} is set to {1}, but no job folder under it has a '
'{2}.'.format(JOBS_ENV, root, KNOWLEDGE_FILENAME))
names = [_job_name_of(c) for c in candidates]
return JobLocateResult(
root=root, candidates=candidates, blocking=True,
reason='{0} is {1}, which holds {2} job folders ({3}) -- there is '
'no doc.Title here to pick one by, so nothing here guesses. '
'Say which with --job, or point --knowledge at the file '
'directly.'.format(JOBS_ENV, root, len(candidates),
', '.join(names)))