syncmill 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- syncmill/__init__.py +11 -0
- syncmill/agents.py +2064 -0
- syncmill/artifact_io.py +201 -0
- syncmill/artifacts.py +1946 -0
- syncmill/board.py +2060 -0
- syncmill/breaker.py +327 -0
- syncmill/cli.py +4986 -0
- syncmill/command_errors.py +32 -0
- syncmill/config.py +1118 -0
- syncmill/dashboard.py +935 -0
- syncmill/gates.py +179 -0
- syncmill/gateway.py +234 -0
- syncmill/git_env.py +99 -0
- syncmill/isolated_session.py +431 -0
- syncmill/matcher.py +127 -0
- syncmill/memory.py +1067 -0
- syncmill/memory_backend.py +1907 -0
- syncmill/memory_migration.py +962 -0
- syncmill/model_routing.py +482 -0
- syncmill/models.py +244 -0
- syncmill/monitor.py +775 -0
- syncmill/phoenix_exporter.py +339 -0
- syncmill/planning.py +512 -0
- syncmill/platform_support.py +14 -0
- syncmill/preflight.py +1129 -0
- syncmill/project.py +1067 -0
- syncmill/report.py +502 -0
- syncmill/review_candidates.py +107 -0
- syncmill/room_compare.py +2380 -0
- syncmill/room_raw.py +484 -0
- syncmill/room_viewer.py +604 -0
- syncmill/rooms.py +4044 -0
- syncmill/session.py +444 -0
- syncmill/strategies/__init__.py +82 -0
- syncmill/strategies/base.py +910 -0
- syncmill/strategies/compete.py +233 -0
- syncmill/strategies/council.py +429 -0
- syncmill/strategies/decompose.py +570 -0
- syncmill/strategies/pipeline.py +221 -0
- syncmill/strategies/route.py +130 -0
- syncmill/supervisor.py +1423 -0
- syncmill/teamfile.py +571 -0
- syncmill/telemetry_hook.py +231 -0
- syncmill/terminal.py +245 -0
- syncmill/tracer.py +548 -0
- syncmill/tui.py +3537 -0
- syncmill/tui_collaboration.py +499 -0
- syncmill/tui_jobs.py +1407 -0
- syncmill/tui_render.py +156 -0
- syncmill/workflow.py +3746 -0
- syncmill/worktree.py +1045 -0
- syncmill-0.2.0.dist-info/METADATA +853 -0
- syncmill-0.2.0.dist-info/RECORD +56 -0
- syncmill-0.2.0.dist-info/WHEEL +4 -0
- syncmill-0.2.0.dist-info/entry_points.txt +2 -0
- syncmill-0.2.0.dist-info/licenses/LICENSE +191 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Body-free workspace hook collector for Kimi Code and Antigravity CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import fcntl
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import tempfile
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Iterator
|
|
13
|
+
|
|
14
|
+
from syncmill.agents import normalize_tool_key
|
|
15
|
+
from syncmill.models import ToolCallRecord, ToolOutcome, ToolTelemetry
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@contextmanager
|
|
19
|
+
def _locked(path: Path) -> Iterator[None]:
|
|
20
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
22
|
+
os.chmod(path, 0o600)
|
|
23
|
+
fcntl.flock(handle, fcntl.LOCK_EX)
|
|
24
|
+
try:
|
|
25
|
+
yield
|
|
26
|
+
finally:
|
|
27
|
+
fcntl.flock(handle, fcntl.LOCK_UN)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _mark_incomplete(spool: Path, code: str) -> None:
|
|
31
|
+
marker = Path(f"{spool}.incomplete")
|
|
32
|
+
marker.write_text(code, encoding="utf-8")
|
|
33
|
+
os.chmod(marker, 0o600)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _read_records(spool: Path) -> tuple[list[dict], int]:
|
|
37
|
+
"""Parsed records plus the count of unreadable (torn / non-dict) lines —
|
|
38
|
+
the reader must surface a torn write as incompleteness, not silence."""
|
|
39
|
+
if not spool.exists():
|
|
40
|
+
return [], 0
|
|
41
|
+
records: list[dict] = []
|
|
42
|
+
unreadable = 0
|
|
43
|
+
for line in spool.read_text(encoding="utf-8").splitlines():
|
|
44
|
+
try:
|
|
45
|
+
item = json.loads(line)
|
|
46
|
+
except json.JSONDecodeError:
|
|
47
|
+
unreadable += 1
|
|
48
|
+
continue
|
|
49
|
+
if isinstance(item, dict):
|
|
50
|
+
records.append(item)
|
|
51
|
+
else:
|
|
52
|
+
unreadable += 1
|
|
53
|
+
return records, unreadable
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _next_sequence(spool: Path) -> int:
|
|
57
|
+
"""The next sequence number, tracked in a ``{spool}.seq`` sidecar.
|
|
58
|
+
|
|
59
|
+
Deriving it from a record COUNT was the bug: a torn line is skipped by the
|
|
60
|
+
reader, so the count under-reports and every later record reuses a live
|
|
61
|
+
sequence number (which the reader then sorts by). The sidecar is one int
|
|
62
|
+
read per event (also killing the O(n²) full-spool rescan); a missing or
|
|
63
|
+
unreadable sidecar (first event, or a legacy spool) seeds from the highest
|
|
64
|
+
sequence actually on disk — max, not count, so a torn line can only leave
|
|
65
|
+
a harmless gap, never a collision with a readable record.
|
|
66
|
+
"""
|
|
67
|
+
seq_path = Path(f"{spool}.seq")
|
|
68
|
+
try:
|
|
69
|
+
value = int(seq_path.read_text(encoding="utf-8"))
|
|
70
|
+
except (OSError, ValueError):
|
|
71
|
+
records, _ = _read_records(spool)
|
|
72
|
+
value = max(
|
|
73
|
+
(r["sequence"] for r in records if isinstance(r.get("sequence"), int)),
|
|
74
|
+
default=0,
|
|
75
|
+
)
|
|
76
|
+
return value + 1
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _append(spool: Path, *, provider: str, tool_key: str, outcome: str) -> None:
|
|
80
|
+
sequence = _next_sequence(spool)
|
|
81
|
+
record = {
|
|
82
|
+
"source": provider,
|
|
83
|
+
"sequence": sequence,
|
|
84
|
+
"tool_key": tool_key,
|
|
85
|
+
"outcome": outcome,
|
|
86
|
+
}
|
|
87
|
+
spool.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
# Reserve the sequence BEFORE appending the record: a crash in between
|
|
89
|
+
# skips a number (harmless gap); the reverse order could reuse one. The
|
|
90
|
+
# write must be ATOMIC (unique temp + fsync + replace): a plain write_text
|
|
91
|
+
# truncates first and a crash mid-write can leave a valid PREFIX of the
|
|
92
|
+
# old value ("12" → "1"), which _next_sequence would trust — re-minting
|
|
93
|
+
# already-used numbers, the exact bug the sidecar exists to prevent.
|
|
94
|
+
seq_path = Path(f"{spool}.seq")
|
|
95
|
+
fd, tmp = tempfile.mkstemp(dir=str(spool.parent), prefix=f".{seq_path.name}.")
|
|
96
|
+
try:
|
|
97
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
98
|
+
handle.write(str(sequence))
|
|
99
|
+
handle.flush()
|
|
100
|
+
os.fsync(handle.fileno())
|
|
101
|
+
os.chmod(tmp, 0o600)
|
|
102
|
+
os.replace(tmp, seq_path)
|
|
103
|
+
# fsync the DIRECTORY so the rename is durable before the record write
|
|
104
|
+
# below begins: without it a filesystem crash could keep the appended
|
|
105
|
+
# record while reverting the sidecar to an older value — whose next
|
|
106
|
+
# trusted read would re-mint an existing sequence number.
|
|
107
|
+
dir_fd = os.open(str(spool.parent), os.O_RDONLY)
|
|
108
|
+
try:
|
|
109
|
+
os.fsync(dir_fd)
|
|
110
|
+
finally:
|
|
111
|
+
os.close(dir_fd)
|
|
112
|
+
except BaseException:
|
|
113
|
+
with contextlib.suppress(OSError):
|
|
114
|
+
os.unlink(tmp)
|
|
115
|
+
raise
|
|
116
|
+
with spool.open("a", encoding="utf-8") as handle:
|
|
117
|
+
handle.write(json.dumps(record, separators=(",", ":")) + "\n")
|
|
118
|
+
handle.flush()
|
|
119
|
+
# Durable record after a durable reserve: with both ordered this way, a
|
|
120
|
+
# record that survives a crash implies its reservation did too, so the
|
|
121
|
+
# sidecar can never lag behind the spool's highest sequence.
|
|
122
|
+
os.fsync(handle.fileno())
|
|
123
|
+
os.chmod(spool, 0o600)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def collect_hook_event(
|
|
127
|
+
*,
|
|
128
|
+
provider: str,
|
|
129
|
+
event: str,
|
|
130
|
+
spool: Path,
|
|
131
|
+
payload: dict,
|
|
132
|
+
tool_map: dict[str, str] | None = None,
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Project a provider hook payload without ever persisting its body fields."""
|
|
135
|
+
lock = Path(f"{spool}.lock")
|
|
136
|
+
state_path = Path(f"{spool}.state")
|
|
137
|
+
with _locked(lock):
|
|
138
|
+
if provider == "kimi-code":
|
|
139
|
+
raw_name = payload.get("tool_name")
|
|
140
|
+
if not isinstance(raw_name, str):
|
|
141
|
+
_mark_incomplete(spool, "missing_tool_name")
|
|
142
|
+
return
|
|
143
|
+
tool_key = normalize_tool_key(raw_name, tool_map)
|
|
144
|
+
if tool_key is None:
|
|
145
|
+
_mark_incomplete(spool, "unqualified_tool_name")
|
|
146
|
+
return
|
|
147
|
+
outcome = "failed" if event == "failure" else "succeeded"
|
|
148
|
+
_append(spool, provider=provider, tool_key=tool_key, outcome=outcome)
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
if provider != "antigravity":
|
|
152
|
+
_mark_incomplete(spool, "unknown_provider")
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
conversation = payload.get("conversationId")
|
|
156
|
+
step = payload.get("stepIdx")
|
|
157
|
+
if not isinstance(conversation, str) or not isinstance(step, int):
|
|
158
|
+
_mark_incomplete(spool, "missing_correlation")
|
|
159
|
+
return
|
|
160
|
+
correlation = f"{conversation}:{step}"
|
|
161
|
+
state = {}
|
|
162
|
+
if state_path.exists():
|
|
163
|
+
try:
|
|
164
|
+
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
165
|
+
except (json.JSONDecodeError, OSError):
|
|
166
|
+
_mark_incomplete(spool, "invalid_state")
|
|
167
|
+
return
|
|
168
|
+
if event == "pre":
|
|
169
|
+
tool_call = payload.get("toolCall")
|
|
170
|
+
raw_name = tool_call.get("name") if isinstance(tool_call, dict) else None
|
|
171
|
+
tool_key = normalize_tool_key(raw_name, tool_map) if isinstance(raw_name, str) else None
|
|
172
|
+
if tool_key is None:
|
|
173
|
+
_mark_incomplete(spool, "unqualified_tool_name")
|
|
174
|
+
return
|
|
175
|
+
state[correlation] = tool_key
|
|
176
|
+
state_path.write_text(json.dumps(state, separators=(",", ":")), encoding="utf-8")
|
|
177
|
+
os.chmod(state_path, 0o600)
|
|
178
|
+
return
|
|
179
|
+
tool_key = state.pop(correlation, None)
|
|
180
|
+
if not isinstance(tool_key, str):
|
|
181
|
+
_mark_incomplete(spool, "missing_pre_event")
|
|
182
|
+
return
|
|
183
|
+
state_path.write_text(json.dumps(state, separators=(",", ":")), encoding="utf-8")
|
|
184
|
+
os.chmod(state_path, 0o600)
|
|
185
|
+
outcome = "failed" if event == "failure" or bool(payload.get("error")) else "succeeded"
|
|
186
|
+
_append(spool, provider=provider, tool_key=tool_key, outcome=outcome)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def load_hook_telemetry(spool: Path, *, source: str) -> ToolTelemetry:
|
|
190
|
+
calls: list[ToolCallRecord] = []
|
|
191
|
+
records, malformed = _read_records(spool)
|
|
192
|
+
for item in records:
|
|
193
|
+
try:
|
|
194
|
+
outcome = ToolOutcome(item["outcome"])
|
|
195
|
+
calls.append(
|
|
196
|
+
ToolCallRecord(
|
|
197
|
+
sequence=int(item["sequence"]),
|
|
198
|
+
tool_key=str(item["tool_key"]),
|
|
199
|
+
outcome=outcome,
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
except (KeyError, TypeError, ValueError):
|
|
203
|
+
malformed += 1
|
|
204
|
+
incomplete = Path(f"{spool}.incomplete").exists()
|
|
205
|
+
# A crash BETWEEN the sequence reserve and the spool append loses an event
|
|
206
|
+
# without a torn line or an .incomplete marker — the only evidence is the
|
|
207
|
+
# numbering itself: an internal gap in the recorded sequences, or a sidecar
|
|
208
|
+
# reservation newer than any record. Either must flip `complete`, or the
|
|
209
|
+
# consumer would trust a spool that silently dropped an event.
|
|
210
|
+
# Sequences are minted from 1, so completeness means EXACTLY 1..max — a
|
|
211
|
+
# min-to-max contiguity check would miss a lost PREFIX (event 1 reserved
|
|
212
|
+
# and lost, event 2 recorded → singleton [2] looked contiguous).
|
|
213
|
+
sequences = sorted({call.sequence for call in calls})
|
|
214
|
+
lost = bool(sequences) and (
|
|
215
|
+
sequences[0] != 1 or len(sequences) != sequences[-1]
|
|
216
|
+
)
|
|
217
|
+
try:
|
|
218
|
+
reserved = int(Path(f"{spool}.seq").read_text(encoding="utf-8"))
|
|
219
|
+
except (OSError, ValueError):
|
|
220
|
+
reserved = None
|
|
221
|
+
if reserved is not None:
|
|
222
|
+
if sequences:
|
|
223
|
+
lost = lost or reserved > sequences[-1]
|
|
224
|
+
else:
|
|
225
|
+
lost = True # a reservation exists but no record survived
|
|
226
|
+
return ToolTelemetry(
|
|
227
|
+
source=source,
|
|
228
|
+
complete=not incomplete and malformed == 0 and not lost,
|
|
229
|
+
calls=tuple(sorted(calls, key=lambda call: call.sequence)),
|
|
230
|
+
malformed_count=malformed,
|
|
231
|
+
)
|
syncmill/terminal.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""The one place syncmill writes to the terminal.
|
|
2
|
+
|
|
3
|
+
Everything the CLI prints may quote a stored record, an agent's output, a
|
|
4
|
+
provider's error, a configured path or an exception, and any of them can carry
|
|
5
|
+
characters a terminal interprets (C1 CSI, OSC sequences, bidi overrides, CR).
|
|
6
|
+
``echo``/``secho`` pass every message through ``printable_text`` before click
|
|
7
|
+
styles and writes it, so colour still works and line breaks survive while
|
|
8
|
+
nothing else non-printable reaches the terminal. ``echo_patch`` is the one
|
|
9
|
+
writer that can be byte-exact, and only off a terminal; a test pins where it may
|
|
10
|
+
be used and that nothing else in the package writes to the terminal directly.
|
|
11
|
+
|
|
12
|
+
``install_console_guards`` covers the interpreter's report of an uncaught
|
|
13
|
+
exception, in any thread, and syncmill's own log records; ``EscapingArgumentParser``
|
|
14
|
+
covers argparse's own output.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import io
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import shlex
|
|
24
|
+
import sys
|
|
25
|
+
import threading
|
|
26
|
+
import traceback
|
|
27
|
+
from collections.abc import Sequence
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
import click
|
|
31
|
+
|
|
32
|
+
from syncmill.artifacts import printable_name, printable_text
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _escaped(message: Any) -> Any:
|
|
36
|
+
"""The display form of anything echo may be handed. ``None`` stays (an
|
|
37
|
+
empty line); bytes are decoded the way a stored patch is, so an invalid
|
|
38
|
+
byte shows as ``\\xNN``; any other object is shown as ``str()`` -- click
|
|
39
|
+
would stringify it anyway, after this point, unescaped."""
|
|
40
|
+
if message is None:
|
|
41
|
+
return None
|
|
42
|
+
if isinstance(message, (bytes, bytearray)):
|
|
43
|
+
message = bytes(message).decode("utf-8", errors="surrogateescape")
|
|
44
|
+
return printable_text(str(message))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def echo(message: Any = None, **kwargs: Any) -> None:
|
|
48
|
+
click.echo(_escaped(message), **kwargs)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def secho(message: Any = None, **kwargs: Any) -> None:
|
|
52
|
+
click.secho(_escaped(message), **kwargs)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
NO_COMMAND = "a value has characters that cannot be shown, so no command is printed"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def shell_arg(value: str) -> str | None:
|
|
59
|
+
"""``value`` quoted for a command the user copies, or ``None`` when the
|
|
60
|
+
terminal cannot show it unchanged: an escaped argument in a printed command
|
|
61
|
+
names a different file, run or session, so such a command is not printed."""
|
|
62
|
+
return shlex.quote(value) if printable_name(value) == value else None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def printable_command(
|
|
66
|
+
*words: object, args: Sequence[object] = (), options: Sequence[object] = ()
|
|
67
|
+
) -> str:
|
|
68
|
+
"""One copy-paste command line: the fixed ``words``, the positional
|
|
69
|
+
``args``, then ``options``, each quoted; or a short notice when any of them
|
|
70
|
+
cannot be shown unchanged (see ``shell_arg``).
|
|
71
|
+
|
|
72
|
+
A positional value that starts with ``-`` would be parsed as an option
|
|
73
|
+
(``--discard=x`` selects a different, destructive action), so then the
|
|
74
|
+
options come first and ``--`` ends option parsing before the values."""
|
|
75
|
+
positional = [str(a) for a in args]
|
|
76
|
+
if any(a.startswith("-") for a in positional):
|
|
77
|
+
argv = [*map(str, words), *map(str, options), "--", *positional]
|
|
78
|
+
else:
|
|
79
|
+
argv = [*map(str, words), *positional, *map(str, options)]
|
|
80
|
+
quoted = [shell_arg(word) for word in argv]
|
|
81
|
+
if any(word is None for word in quoted):
|
|
82
|
+
return f"({NO_COMMAND})"
|
|
83
|
+
return " ".join(quoted)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _stdout_is_terminal() -> bool:
|
|
87
|
+
return sys.stdout is not None and sys.stdout.isatty()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def echo_patch(data: bytes) -> None:
|
|
91
|
+
"""Print a stored patch: escaped at a terminal, as ``echo`` shows any
|
|
92
|
+
bytes; otherwise the stored bytes unchanged, because redirected output is
|
|
93
|
+
data (``> fix.patch``, ``| git apply``). Bytes reach click's binary
|
|
94
|
+
writer, which neither decodes them nor strips ANSI sequences off a
|
|
95
|
+
terminal, as it does for text."""
|
|
96
|
+
if not isinstance(data, (bytes, bytearray)):
|
|
97
|
+
raise TypeError(f"a patch is bytes, not {type(data).__name__}")
|
|
98
|
+
if _stdout_is_terminal():
|
|
99
|
+
echo(data, nl=False)
|
|
100
|
+
else:
|
|
101
|
+
click.echo(bytes(data), nl=False)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class EscapedStderr(io.TextIOBase):
|
|
105
|
+
"""A text stream that writes to whatever ``sys.stderr`` is, escaped, for a
|
|
106
|
+
library that prints through a stream of its own (the TUI's error console)."""
|
|
107
|
+
|
|
108
|
+
def writable(self) -> bool:
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
def write(self, text: str) -> int:
|
|
112
|
+
stream = sys.stderr
|
|
113
|
+
if stream is not None:
|
|
114
|
+
stream.write(printable_text(text))
|
|
115
|
+
return len(text)
|
|
116
|
+
|
|
117
|
+
def flush(self) -> None:
|
|
118
|
+
stream = sys.stderr
|
|
119
|
+
if stream is not None:
|
|
120
|
+
stream.flush()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class EscapingArgumentParser(argparse.ArgumentParser):
|
|
124
|
+
"""argparse writes usage, help and errors itself (then exits), outside
|
|
125
|
+
click; subparsers inherit the class, so they are covered too."""
|
|
126
|
+
|
|
127
|
+
def _print_message(self, message: str, file=None) -> None:
|
|
128
|
+
super()._print_message(printable_text(message) if message else message, file)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _escaping_excepthook(exc_type, exc, tb) -> None:
|
|
132
|
+
text = "".join(traceback.format_exception(exc_type, exc, tb))
|
|
133
|
+
sys.stderr.write(printable_text(text))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _escaping_thread_excepthook(args: threading.ExceptHookArgs) -> None:
|
|
137
|
+
"""``threading.excepthook``'s report, escaped, thread name included."""
|
|
138
|
+
if args.exc_type is SystemExit:
|
|
139
|
+
return
|
|
140
|
+
stream = sys.stderr
|
|
141
|
+
if stream is None: # interpreter shutdown, as the default hook allows
|
|
142
|
+
return
|
|
143
|
+
name = args.thread.name if args.thread is not None else threading.get_ident()
|
|
144
|
+
text = "".join(
|
|
145
|
+
traceback.format_exception(args.exc_type, args.exc_value, args.exc_traceback)
|
|
146
|
+
)
|
|
147
|
+
stream.write(printable_text(f"Exception in thread {name}:\n{text}"))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class _EscapingFormatter(logging.Formatter):
|
|
151
|
+
"""lastResort's format (the message, then any traceback or stack), escaped.
|
|
152
|
+
|
|
153
|
+
The message is one line: ``printable_name`` shows an LF in an id, a path or
|
|
154
|
+
an exception string as ``\\x0a``, so an argument cannot forge a log line of
|
|
155
|
+
its own (#198). The traceback and stack keep their lines: the finished
|
|
156
|
+
string is escaped with ``printable_text``, which also covers a traceback
|
|
157
|
+
another formatter cached on the record first. ``msg`` and ``args`` are left
|
|
158
|
+
as they are."""
|
|
159
|
+
|
|
160
|
+
def formatMessage(self, record: logging.LogRecord) -> str:
|
|
161
|
+
return printable_name(super().formatMessage(record))
|
|
162
|
+
|
|
163
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
164
|
+
return printable_text(super().format(record))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class _ConsoleLogHandler(logging.StreamHandler):
|
|
168
|
+
"""Where syncmill's own log records are printed: like ``logging.lastResort``
|
|
169
|
+
it writes to whatever ``sys.stderr`` is when the record arrives (CliRunner,
|
|
170
|
+
pytest and Textual swap it), but everything it writes is escaped, including
|
|
171
|
+
the report of a record that failed to format."""
|
|
172
|
+
|
|
173
|
+
def __init__(self) -> None:
|
|
174
|
+
logging.Handler.__init__(self)
|
|
175
|
+
self.setFormatter(_EscapingFormatter())
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def stream(self):
|
|
179
|
+
return sys.stderr
|
|
180
|
+
|
|
181
|
+
def handleError(self, record: logging.LogRecord) -> None:
|
|
182
|
+
"""The stdlib report (``logging.Handler.handleError``), built in a
|
|
183
|
+
buffer and escaped before it is written: the stdlib version writes the
|
|
184
|
+
failing exception and the record's ``msg``/``args`` to ``sys.stderr``
|
|
185
|
+
unescaped."""
|
|
186
|
+
stream = sys.stderr
|
|
187
|
+
if not (logging.raiseExceptions and stream):
|
|
188
|
+
return
|
|
189
|
+
t, v, tb = sys.exc_info()
|
|
190
|
+
try:
|
|
191
|
+
buf = io.StringIO()
|
|
192
|
+
buf.write("--- Logging error ---\n")
|
|
193
|
+
traceback.print_exception(t, v, tb, None, buf)
|
|
194
|
+
buf.write("Call stack:\n")
|
|
195
|
+
# The first frame outside the logging package is the caller.
|
|
196
|
+
frame = tb.tb_frame if tb is not None else None
|
|
197
|
+
while frame and os.path.dirname(frame.f_code.co_filename) == _LOGGING_DIR:
|
|
198
|
+
frame = frame.f_back
|
|
199
|
+
if frame:
|
|
200
|
+
traceback.print_stack(frame, file=buf)
|
|
201
|
+
else:
|
|
202
|
+
buf.write(f"Logged from file {record.filename}, line {record.lineno}\n")
|
|
203
|
+
try:
|
|
204
|
+
buf.write("Message: %r\nArguments: %s\n" % (record.msg, record.args))
|
|
205
|
+
except RecursionError:
|
|
206
|
+
raise
|
|
207
|
+
except Exception:
|
|
208
|
+
buf.write(
|
|
209
|
+
"Unable to print the message and arguments - possible formatting "
|
|
210
|
+
"error.\nUse the traceback above to help find the error.\n"
|
|
211
|
+
)
|
|
212
|
+
stream.write(printable_text(buf.getvalue()))
|
|
213
|
+
except OSError:
|
|
214
|
+
pass
|
|
215
|
+
finally:
|
|
216
|
+
del t, v, tb
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
_LOGGING_DIR = os.path.dirname(logging.__file__)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _install_log_handler() -> None:
|
|
223
|
+
"""Print syncmill's records through ``_ConsoleLogHandler`` only. With
|
|
224
|
+
``propagate`` off they never reach a handler someone else installed on the
|
|
225
|
+
root (``serve``'s RichHandler), so no other handler, formatter or filter
|
|
226
|
+
ever sees, or has to be guessed about, a syncmill record, and nothing
|
|
227
|
+
outside the ``syncmill`` hierarchy is touched. The handler has no level of
|
|
228
|
+
its own: a record prints at its logger's effective level, as it did through
|
|
229
|
+
the root's handlers, rather than at lastResort's fixed WARNING."""
|
|
230
|
+
logger = logging.getLogger("syncmill")
|
|
231
|
+
if not any(isinstance(h, _ConsoleLogHandler) for h in logger.handlers):
|
|
232
|
+
logger.addHandler(_ConsoleLogHandler())
|
|
233
|
+
logger.propagate = False
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def install_console_guards() -> None:
|
|
237
|
+
"""Escape the interpreter's report of an uncaught exception, in the main
|
|
238
|
+
thread or any other, and every record logged under ``syncmill``. Idempotent.
|
|
239
|
+
Other libraries' log records (memtomem, mcp, uvicorn, asyncio) are not
|
|
240
|
+
covered: escaping inside stdlib logging means guessing what each handler,
|
|
241
|
+
formatter and filter reads, and attempts broke third-party formatters
|
|
242
|
+
(#202). Click's own help and usage text is not covered either."""
|
|
243
|
+
sys.excepthook = _escaping_excepthook
|
|
244
|
+
threading.excepthook = _escaping_thread_excepthook
|
|
245
|
+
_install_log_handler()
|