WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m18s
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""One JSON line per button run -- the run log (`vibe.runlog`).
|
||||
|
||||
Owner requirement, 2026-09-03: "开发阶段,我做的一切,都留下 log,别我按完了
|
||||
以后还得手动复制 report 给你。" Every button press must leave a machine-readable
|
||||
record on disk, so the developer can read what happened without the owner
|
||||
pasting reports by hand.
|
||||
|
||||
The stream is an append-only JSONL file, `runlog.jsonl`, inside a folder chosen
|
||||
by `log_dir()`: $WILLTOOLS_RUNLOG_DIR when set, else %APPDATA%/WillTools/runlog,
|
||||
else ~/WillTools/runlog. `record()` writes one JSON object per line; buttons
|
||||
wrap themselves in `run(...)` so the outcome and any error land automatically,
|
||||
and the module doubles as a CLI: `python lib/vibe/runlog.py [n]` prints the
|
||||
last n records as compact one-liners. `tail(n)` is the same reader for the
|
||||
developer's `tools/` helpers.
|
||||
|
||||
This module is engine-neutral on purpose -- IronPython 2.7 inside Revit *and*
|
||||
host CPython 3. Nothing is imported from `vibe` at module scope (a bare
|
||||
`vibe.runlog` import must work on a host that has no Revit at all, and
|
||||
`vibe/__init__.py` pulls the Revit API in the moment it runs), so the tiny
|
||||
py2/py3 text-type split that `vibe.compat` normally provides is repeated here
|
||||
as a local `text_type` instead of imported. The rest is written in the
|
||||
intersection of the two languages: no f-strings, no pathlib, `io.open`,
|
||||
`'{0}'.format(...)`, and every string encoded to UTF-8 at the byte boundary.
|
||||
|
||||
One deliberate decision: `record()` never raises. A log that takes a button
|
||||
down with it is worse than no log, so any failure to write is swallowed after
|
||||
a best-effort one-line `runlog-failed` marker on stderr, and `record()`
|
||||
returns None. Callers that need to know the write succeeded compare the
|
||||
return value.
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import traceback as _traceback
|
||||
|
||||
try: # IronPython 2.7
|
||||
text_type = unicode # noqa: F821
|
||||
except NameError: # CPython 3 / IronPython 3
|
||||
text_type = str
|
||||
|
||||
|
||||
LOG_DIR_ENV = 'WILLTOOLS_RUNLOG_DIR'
|
||||
"""Environment variable naming the run-log folder, when the developer sets it."""
|
||||
|
||||
_FILE_NAME = 'runlog.jsonl'
|
||||
_REPORT_TEXT_CAP = 20000
|
||||
_TRUNCATED_MARKER = '...[truncated]'
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ location
|
||||
|
||||
def _home_dir():
|
||||
"""The user's home folder, tolerantly, for the final fallback."""
|
||||
try:
|
||||
home = os.path.expanduser('~')
|
||||
except Exception:
|
||||
home = None
|
||||
if home:
|
||||
return home
|
||||
# Last resort: a temp folder. Whatever record() gets for its base is only
|
||||
# used once, to mkdir a folder and open one file -- and if even that fails
|
||||
# record() swallows it and returns None.
|
||||
try:
|
||||
return os.environ.get('TEMP') or os.environ.get('TMP') or os.getcwd()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _base_dir():
|
||||
"""The resolved folder name for the run log, never created here.
|
||||
|
||||
Order, matching the module contract: $WILLTOOLS_RUNLOG_DIR when set (a
|
||||
developer or a test pointing the log somewhere specific), else
|
||||
%APPDATA%/WillTools/runlog, else ~/WillTools/runlog.
|
||||
"""
|
||||
env = os.environ.get(LOG_DIR_ENV)
|
||||
if env:
|
||||
return env
|
||||
appdata = os.environ.get('APPDATA')
|
||||
if appdata:
|
||||
return os.path.join(appdata, 'WillTools', 'runlog')
|
||||
home = _home_dir()
|
||||
if home:
|
||||
return os.path.join(home, 'WillTools', 'runlog')
|
||||
return None
|
||||
|
||||
|
||||
def log_dir():
|
||||
"""The run-log folder: $WILLTOOLS_RUNLOG_DIR, else %APPDATA%/WillTools/runlog,
|
||||
else ~/WillTools/runlog.
|
||||
|
||||
The folder is resolved fresh on every call -- the resolution is a couple of
|
||||
environment lookups, and a stale cache would make a test (or a developer
|
||||
switching the log location) write to yesterday's folder. The folder itself
|
||||
is created on demand: the first record() that needs it. Returns None when
|
||||
no base folder can be resolved at all.
|
||||
"""
|
||||
return _base_dir()
|
||||
|
||||
|
||||
def _log_path():
|
||||
"""The full path of the run-log file, or None when no folder is resolvable.
|
||||
|
||||
Nothing is created here -- mkdir happens only inside record(), so asking
|
||||
where the log lives never touches the disk.
|
||||
"""
|
||||
folder = log_dir()
|
||||
if not folder:
|
||||
return None
|
||||
return os.path.join(folder, _FILE_NAME)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- JSON help
|
||||
|
||||
def _safe_json_values(values):
|
||||
"""A `values` dict with every value JSON-serialisable.
|
||||
|
||||
Buttons log whatever they happen to be holding -- ElementIds, tuples, units,
|
||||
Decimals. `json.dumps` would raise on the first non-serialisable one and
|
||||
the whole record would be lost, so each value is probed with a real
|
||||
serialisation: a value that survives is kept as-is, one that raises is
|
||||
flattened with str(), and if even that fails the slot becomes None.
|
||||
"""
|
||||
out = {}
|
||||
if not values:
|
||||
return out
|
||||
for key, value in values.items():
|
||||
try:
|
||||
json.dumps(value)
|
||||
out[key] = value
|
||||
except Exception:
|
||||
try:
|
||||
out[key] = text_type(value)
|
||||
except Exception:
|
||||
out[key] = None
|
||||
return out
|
||||
|
||||
|
||||
def _clean_text(value, cap, marker):
|
||||
"""One text field flattened for JSON: bytes decoded, capped at `cap`.
|
||||
|
||||
A report body can run to tens of thousands of characters; the log caps it
|
||||
so a runaway report cannot balloon runlog.jsonl. Text longer than the cap
|
||||
is cut at the cap and the marker is appended, exactly as the module
|
||||
contract describes. `text_type(...)` rather than `str(...)`: under
|
||||
IronPython 2.7 `str()` of a non-ASCII unicode tries an ASCII encode and
|
||||
raises, where `unicode()` copies it untouched.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bytes):
|
||||
try:
|
||||
value = value.decode('utf-8', 'replace')
|
||||
except Exception:
|
||||
value = repr(value)
|
||||
try:
|
||||
text = text_type(value)
|
||||
except Exception:
|
||||
text = repr(value)
|
||||
if len(text) > cap:
|
||||
return text[:cap] + marker
|
||||
return text
|
||||
|
||||
|
||||
def _write_failure(path, exc):
|
||||
"""Best-effort one-line marker when the record could not be written."""
|
||||
try:
|
||||
sys.stderr.write('runlog-failed: could not write {0}: {1}\n'
|
||||
.format(path or '<no log dir>', repr(exc)))
|
||||
sys.stderr.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ record()
|
||||
|
||||
def record(button, doc_title=None, marks=None, outcome=None, report_text=None,
|
||||
report_path=None, error=None, traceback_text=None, values=None,
|
||||
extra=None):
|
||||
"""Append one JSON line to <log_dir>/runlog.jsonl. Never raises.
|
||||
|
||||
Fields written: ts (ISO 8601), button, doc_title, marks (list), outcome,
|
||||
report_path, report_text (capped at 20000 characters with a
|
||||
'...[truncated]' marker), error, traceback, values (a dict,
|
||||
JSON-serialised with default=str), extra (a dict). Missing fields are
|
||||
written as None so a reader never has to guess which key was omitted.
|
||||
|
||||
Any failure -- a folder that cannot be made, a path that is really a file,
|
||||
an unwritable stream, a value that refuses to flatten -- is swallowed after
|
||||
a best-effort one-line 'runlog-failed' marker on stderr. Returns the path
|
||||
written, or None.
|
||||
"""
|
||||
path = None
|
||||
try:
|
||||
path = _log_path()
|
||||
if not path:
|
||||
_write_failure(path, RuntimeError('no run-log folder resolvable'))
|
||||
return None
|
||||
folder = os.path.dirname(path)
|
||||
if not os.path.isdir(folder):
|
||||
os.makedirs(folder)
|
||||
marks_list = None
|
||||
if marks is not None:
|
||||
try:
|
||||
marks_list = list(marks)
|
||||
except Exception:
|
||||
marks_list = None
|
||||
extra_dict = extra if isinstance(extra, dict) else None
|
||||
entry = {
|
||||
'ts': time.strftime('%Y-%m-%dT%H:%M:%S'),
|
||||
'button': button,
|
||||
'doc_title': doc_title,
|
||||
'marks': marks_list,
|
||||
'outcome': outcome,
|
||||
'report_path': report_path,
|
||||
'report_text': _clean_text(report_text, _REPORT_TEXT_CAP,
|
||||
_TRUNCATED_MARKER),
|
||||
'error': error,
|
||||
'traceback': traceback_text,
|
||||
'values': (_safe_json_values(values)
|
||||
if isinstance(values, dict) else None),
|
||||
'extra': extra_dict,
|
||||
}
|
||||
line = json.dumps(entry, ensure_ascii=False)
|
||||
# json.dumps returns unicode on py2 whenever any character is
|
||||
# non-ASCII and a plain str otherwise -- normalise both to bytes and
|
||||
# append through a binary handle so the file is always UTF-8.
|
||||
if not isinstance(line, bytes):
|
||||
line = line.encode('utf-8')
|
||||
with io.open(path, 'ab') as handle:
|
||||
handle.write(line + b'\n')
|
||||
return path
|
||||
except Exception as exc:
|
||||
_write_failure(path, exc)
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------- run() context
|
||||
|
||||
class _Run(object):
|
||||
"""The object `run()` yields: the body's window into the record.
|
||||
|
||||
The body sets rl.outcome, rl.report_text, rl.report_path, rl.values and
|
||||
rl.extra; anything it appends to rl.lines is folded into report_text when
|
||||
the body never set one itself. Everything is written by __exit__, never
|
||||
here.
|
||||
"""
|
||||
|
||||
def __init__(self, button, doc_title, marks):
|
||||
self.button = button
|
||||
self.doc_title = doc_title
|
||||
self.marks = list(marks) if marks else None
|
||||
self.outcome = None
|
||||
self.report_text = None
|
||||
self.report_path = None
|
||||
self.values = None
|
||||
self.extra = None
|
||||
self.lines = []
|
||||
self.path = None
|
||||
|
||||
|
||||
def _folded_report(rl):
|
||||
"""The body's report_text, or its collected `lines` when text is unset."""
|
||||
if rl.report_text is not None:
|
||||
return rl.report_text
|
||||
if rl.lines:
|
||||
return '\n'.join(rl.lines)
|
||||
return None
|
||||
|
||||
|
||||
class _RunContext(object):
|
||||
"""Context manager behind `run()`; __exit__ does the writing."""
|
||||
|
||||
def __init__(self, button, doc_title, marks):
|
||||
self.button = button
|
||||
self.doc_title = doc_title
|
||||
self.marks = marks
|
||||
self.rl = _Run(button, doc_title, marks)
|
||||
|
||||
def __enter__(self):
|
||||
return self.rl
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
rl = self.rl
|
||||
if exc is not None:
|
||||
rl.path = record(rl.button, rl.doc_title, rl.marks,
|
||||
outcome='error',
|
||||
report_text=_folded_report(rl),
|
||||
report_path=rl.report_path,
|
||||
error=repr(exc),
|
||||
traceback_text=''.join(
|
||||
_traceback.format_exception(exc_type, exc,
|
||||
tb)),
|
||||
values=rl.values, extra=rl.extra)
|
||||
return False # re-raise: the button decides
|
||||
rl.path = record(rl.button, rl.doc_title, rl.marks,
|
||||
outcome=rl.outcome or 'ok',
|
||||
report_text=_folded_report(rl),
|
||||
report_path=rl.report_path,
|
||||
values=rl.values, extra=rl.extra)
|
||||
return False
|
||||
|
||||
|
||||
def run(button, doc_title=None, marks=None):
|
||||
"""A context manager recording one button run.
|
||||
|
||||
Usage:
|
||||
|
||||
with runlog.run('Panel Lift Check', doc_title, marks) as rl:
|
||||
rl.report_text = ...
|
||||
rl.values = {...}
|
||||
|
||||
The body may set rl.outcome, rl.report_text, rl.report_path, rl.values and
|
||||
rl.extra. On normal exit the record is written with the body's fields,
|
||||
defaulting the outcome to 'ok'. On an exception it records outcome='error'
|
||||
with `error=repr(exc)` and the formatted traceback, then RE-RAISES, so the
|
||||
button's own error handling still sees the exception. Anything the body
|
||||
appends to rl.lines (a list of strings) is joined into report_text when
|
||||
the body left report_text unset. The path of the record that was written
|
||||
is left on rl.path afterwards (None when the write failed -- record()
|
||||
never raises, so a logging failure must not look like a run failure).
|
||||
"""
|
||||
return _RunContext(button, doc_title, marks)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- tail()
|
||||
|
||||
def _read_lines_backwards(path):
|
||||
"""Yield the non-empty lines of a file, last line first.
|
||||
|
||||
Walks the file backwards byte by byte so reading the tail of a long log
|
||||
costs O(tail), not O(log). A final line with no trailing newline and the
|
||||
first line (which has no preceding newline) are both handled; a blank line
|
||||
between records is skipped.
|
||||
"""
|
||||
with io.open(path, 'rb') as handle:
|
||||
handle.seek(0, os.SEEK_END)
|
||||
position = handle.tell()
|
||||
buf = []
|
||||
while position > 0:
|
||||
position -= 1
|
||||
handle.seek(position)
|
||||
byte = handle.read(1)
|
||||
if byte == b'\n':
|
||||
if buf:
|
||||
yield b''.join(reversed(buf))
|
||||
buf = []
|
||||
else:
|
||||
buf.append(byte)
|
||||
# Everything read before the scan hit byte 0: the first line, or the
|
||||
# whole file when it never contained a newline at all.
|
||||
if buf:
|
||||
yield b''.join(reversed(buf))
|
||||
|
||||
|
||||
def tail(n=20, path=None):
|
||||
"""Read the last n records of a run log, newest last: a list of dicts.
|
||||
|
||||
Records are returned in written order (oldest of the n first). Lines that
|
||||
fail to parse are skipped -- a torn tail from a crash mid-write is a fact
|
||||
of an append-only log, not a reason for the reader to die. `path`
|
||||
overrides the log location for the developer's helpers; the default is
|
||||
the same folder record() writes to. An absent or unreadable log is an
|
||||
empty list, never an exception.
|
||||
"""
|
||||
if path is None:
|
||||
path = _log_path()
|
||||
if not path or not os.path.isfile(path):
|
||||
return []
|
||||
records = []
|
||||
try:
|
||||
for raw in _read_lines_backwards(path):
|
||||
if len(records) >= n:
|
||||
break
|
||||
if not raw.strip():
|
||||
continue
|
||||
try:
|
||||
records.append(json.loads(raw.decode('utf-8')))
|
||||
except Exception:
|
||||
pass
|
||||
records.reverse()
|
||||
except Exception:
|
||||
return []
|
||||
return records
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------- CLI
|
||||
|
||||
def _cli(args):
|
||||
"""Run `runlog.py [n]` and print the last n records as one-liners."""
|
||||
count = 20
|
||||
if args:
|
||||
try:
|
||||
count = int(args[0])
|
||||
except (TypeError, ValueError):
|
||||
count = 20
|
||||
if count <= 0:
|
||||
count = 20
|
||||
for entry in tail(count):
|
||||
print('{0} | {1} | {2} | {3} | {4} | {5}'.format(
|
||||
entry.get('ts'), entry.get('button'), entry.get('outcome'),
|
||||
entry.get('marks'), entry.get('report_path'),
|
||||
entry.get('error')))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
_cli(sys.argv[1:])
|
||||
Reference in New Issue
Block a user