Files
willtools-demo/WillTools.extension/lib/vibe/runlog.py
T
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

411 lines
15 KiB
Python

# -*- 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:])