termwright 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.
@@ -0,0 +1,108 @@
1
+ """Bridge from the standard library's :mod:`logging` to the semantic channel.
2
+
3
+ A TUI must not write diagnostics to the screen, so the usual advice is to send
4
+ them to a file. Under the driver they can go somewhere better: attach this
5
+ handler and every record the application already emits becomes assertable test
6
+ state, with the application's own logging calls unchanged.
7
+
8
+ from termwright import client_from_env
9
+ from termwright.logging_bridge import install_log_handler
10
+
11
+ client = client_from_env(adapter_name="my-tui", adapter_version="1.0.0",
12
+ capabilities=CAPABILITIES_WITH_LOGS)
13
+ if client is not None and await client.start():
14
+ install_log_handler(client)
15
+
16
+ Dormant by construction: with no client there is no handler, so an app that
17
+ ships this call unconditionally still logs exactly as it did before.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ from typing import Any, Dict, Optional
24
+
25
+ from .client import SemanticClient
26
+ from .logs import flatten_attrs
27
+
28
+ #: Python's ladder onto the wire's. `logging` has no `trace` or `fatal` of its
29
+ #: own: anything below DEBUG is trace, and CRITICAL is fatal.
30
+ _LEVEL_BY_NUMBER = (
31
+ (logging.CRITICAL, "fatal"),
32
+ (logging.ERROR, "error"),
33
+ (logging.WARNING, "warn"),
34
+ (logging.INFO, "info"),
35
+ (logging.DEBUG, "debug"),
36
+ )
37
+
38
+ #: Attributes every :class:`logging.LogRecord` carries; anything else on the
39
+ #: record was put there by the application and is worth forwarding.
40
+ _STANDARD_FIELDS = frozenset(
41
+ {
42
+ "args", "asctime", "created", "exc_info", "exc_text", "filename",
43
+ "funcName", "levelname", "levelno", "lineno", "message", "module",
44
+ "msecs", "msg", "name", "pathname", "process", "processName",
45
+ "relativeCreated", "stack_info", "taskName", "thread", "threadName",
46
+ }
47
+ )
48
+
49
+
50
+ def level_for(levelno: int) -> str:
51
+ """Map a :mod:`logging` level number onto a wire level."""
52
+ for threshold, name in _LEVEL_BY_NUMBER:
53
+ if levelno >= threshold:
54
+ return name
55
+ return "trace"
56
+
57
+
58
+ class TermwrightLogHandler(logging.Handler):
59
+ """A :class:`logging.Handler` that forwards records to the driver.
60
+
61
+ Never raises into the application: a handler that threw would turn a log
62
+ line into a crash, so failures are counted on the client and dropped.
63
+ """
64
+
65
+ def __init__(self, client: SemanticClient, level: int = logging.NOTSET) -> None:
66
+ super().__init__(level)
67
+ self._client = client
68
+
69
+ def emit(self, record: logging.LogRecord) -> None:
70
+ try:
71
+ self._client.log(
72
+ level_for(record.levelno),
73
+ self.format(record) if self.formatter else record.getMessage(),
74
+ attrs=self._attrs(record),
75
+ logger=record.name,
76
+ ts=int(record.created * 1000),
77
+ )
78
+ except Exception: # pragma: no cover - logging must never break the app
79
+ self.handleError(record)
80
+
81
+ def _attrs(self, record: logging.LogRecord) -> Optional[Dict[str, Any]]:
82
+ """Application-supplied `extra` fields, flattened to dotted keys."""
83
+ extra = {
84
+ key: value
85
+ for key, value in record.__dict__.items()
86
+ if key not in _STANDARD_FIELDS and not key.startswith("_")
87
+ }
88
+ if record.exc_info and record.exc_info[0] is not None:
89
+ extra["exception"] = record.exc_info[0].__name__
90
+ return flatten_attrs(extra) if extra else None
91
+
92
+
93
+ def install_log_handler(
94
+ client: Optional[SemanticClient],
95
+ logger: Optional[logging.Logger] = None,
96
+ level: int = logging.NOTSET,
97
+ ) -> Optional[TermwrightLogHandler]:
98
+ """Attach a handler to ``logger`` (the root logger by default).
99
+
100
+ :param client: A live client, or ``None`` — in which case nothing is
101
+ installed and ``None`` comes back, so the dormant path stays a no-op.
102
+ :returns: The installed handler, for later :meth:`logging.Logger.removeHandler`.
103
+ """
104
+ if client is None:
105
+ return None
106
+ handler = TermwrightLogHandler(client, level)
107
+ (logger or logging.getLogger()).addHandler(handler)
108
+ return handler
termwright/logs.py ADDED
@@ -0,0 +1,207 @@
1
+ """Application log records carried over the semantic channel.
2
+
3
+ A TUI cannot print diagnostics to the screen without corrupting the render, so
4
+ applications write them to a logger instead. The ``logs`` capability forwards
5
+ those records to the driver, where they become assertable test state rather
6
+ than invisible side effects.
7
+
8
+ Records are bounded exactly like snapshots: checked against a byte ceiling and
9
+ rejected wholesale on any violation, so a misbehaving logger degrades into
10
+ dropped records rather than unbounded driver memory.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from dataclasses import dataclass
17
+ from typing import Any, Dict, Mapping, Optional, Union
18
+
19
+ from .framing import encode_json, project_dto
20
+ from .errors import ProtocolViolation
21
+ from .limits import DEFAULT_LIMITS, ProtocolLimits
22
+
23
+ #: Severity ladder, least to most severe. The intersection of the ladders used
24
+ #: by Python ``logging``, Go ``slog``, Rust ``tracing``, pino and winston, so
25
+ #: every bridge maps onto it without inventing a level.
26
+ LOG_LEVELS = ("trace", "debug", "info", "warn", "error", "fatal")
27
+
28
+ LEVEL_SET = frozenset(LOG_LEVELS)
29
+
30
+ #: Numeric severity; higher is more severe.
31
+ LOG_LEVEL_SEVERITY: Dict[str, int] = {
32
+ "trace": 10,
33
+ "debug": 20,
34
+ "info": 30,
35
+ "warn": 40,
36
+ "error": 50,
37
+ "fatal": 60,
38
+ }
39
+
40
+ #: Maximum number of attribute keys on one record.
41
+ MAX_LOG_ATTRS = 64
42
+
43
+ RECORD_FIELDS = ("ts", "level", "message", "attrs", "logger", "seq", "revision")
44
+
45
+ _MAX_SAFE_INTEGER = 2**53 - 1
46
+
47
+ #: Values an attribute may hold. Scalars only: nested objects make record size
48
+ #: unbounded and depth-dependent, and every bridge already flattens for its own
49
+ #: transport.
50
+ LogAttrValue = Union[str, int, float, bool, None]
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class LogRecord:
55
+ """One application log record.
56
+
57
+ ``ts`` is Unix epoch milliseconds, not session-relative: an adapter has no
58
+ reliable view of when the driver considers the session to have started, so
59
+ the wall clock is the only clock both sides agree on without negotiating.
60
+ The driver rebases it onto the session timeline.
61
+ """
62
+
63
+ ts: int
64
+ level: str
65
+ message: str
66
+ seq: int
67
+ attrs: Optional[Mapping[str, LogAttrValue]] = None
68
+ logger: Optional[str] = None
69
+ revision: Optional[int] = None
70
+
71
+ def to_wire(self) -> Dict[str, Any]:
72
+ """Serialise, dropping unset optionals: the schema is strict."""
73
+ wire: Dict[str, Any] = {"ts": self.ts, "level": self.level, "message": self.message}
74
+ if self.attrs is not None:
75
+ wire["attrs"] = dict(self.attrs)
76
+ if self.logger is not None:
77
+ wire["logger"] = self.logger
78
+ wire["seq"] = self.seq
79
+ if self.revision is not None:
80
+ wire["revision"] = self.revision
81
+ return wire
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class LogValidationResult:
86
+ """Outcome of :func:`validate_log_record`."""
87
+
88
+ ok: bool
89
+ record: Optional[Dict[str, Any]] = None
90
+ code: Optional[str] = None
91
+ detail: str = ""
92
+
93
+
94
+ def _fail(code: str, detail: str) -> LogValidationResult:
95
+ return LogValidationResult(ok=False, code=code, detail=detail)
96
+
97
+
98
+ def _utf8_len(text: str) -> int:
99
+ return len(text.encode("utf-8", "surrogatepass"))
100
+
101
+
102
+ def _safe_non_negative(value: Any) -> bool:
103
+ return (
104
+ isinstance(value, int)
105
+ and not isinstance(value, bool)
106
+ and 0 <= value <= _MAX_SAFE_INTEGER
107
+ )
108
+
109
+
110
+ def validate_log_record(
111
+ value: Any, limits: ProtocolLimits = DEFAULT_LIMITS
112
+ ) -> LogValidationResult:
113
+ """Validate an untrusted log record against ``limits``.
114
+
115
+ Mirrors :func:`termwright.validate.validate_snapshot`: the value is
116
+ projected first, then measured against ``maxLogRecordBytes``, then checked
117
+ field by field. Never raises.
118
+ """
119
+ try:
120
+ projected = project_dto(value, limits.maxDepth)
121
+ except ProtocolViolation as error:
122
+ return _fail("depth" if error.code == "dto-depth" else "schema", str(error))
123
+
124
+ try:
125
+ serialised = encode_json(projected)
126
+ except ProtocolViolation:
127
+ return _fail("schema", "log record is not JSON-serialisable")
128
+ if len(serialised) > limits.maxLogRecordBytes:
129
+ return _fail(
130
+ "bytes",
131
+ f"log record is {len(serialised)} bytes, ceiling is {limits.maxLogRecordBytes}",
132
+ )
133
+
134
+ if not isinstance(projected, dict):
135
+ return _fail("schema", "log record must be an object")
136
+ record: Dict[str, Any] = projected
137
+
138
+ for key in record:
139
+ if key not in RECORD_FIELDS:
140
+ return _fail("schema", f'unknown log record property "{key}"')
141
+
142
+ if not _safe_non_negative(record.get("ts")) or record["ts"] == 0:
143
+ return _fail("schema", "ts must be a positive safe integer (epoch milliseconds)")
144
+ if record.get("level") not in LEVEL_SET:
145
+ return _fail("schema", f"level must be one of {', '.join(LOG_LEVELS)}")
146
+ if not isinstance(record.get("message"), str):
147
+ return _fail("schema", "message must be a string")
148
+ if _utf8_len(record["message"]) > limits.maxStringBytes:
149
+ return _fail("string-bytes", f"message exceeds {limits.maxStringBytes} UTF-8 bytes")
150
+ if not _safe_non_negative(record.get("seq")):
151
+ return _fail("schema", "seq must be a non-negative safe integer")
152
+
153
+ if "logger" in record:
154
+ if not isinstance(record["logger"], str):
155
+ return _fail("schema", "logger must be a string")
156
+ if _utf8_len(record["logger"]) > limits.maxStringBytes:
157
+ return _fail("string-bytes", f"logger exceeds {limits.maxStringBytes} UTF-8 bytes")
158
+
159
+ if "revision" in record:
160
+ if not _safe_non_negative(record["revision"]) or record["revision"] == 0:
161
+ return _fail("revision", "revision must be a positive safe integer")
162
+
163
+ if "attrs" in record:
164
+ attrs = record["attrs"]
165
+ if not isinstance(attrs, dict):
166
+ return _fail("schema", "attrs must be a flat object")
167
+ if len(attrs) > MAX_LOG_ATTRS:
168
+ return _fail("count", f"attrs carries {len(attrs)} keys, ceiling is {MAX_LOG_ATTRS}")
169
+ for key, attr in attrs.items():
170
+ if _utf8_len(key) > limits.maxStringBytes:
171
+ return _fail("string-bytes", f'attribute key "{key}" exceeds the string ceiling')
172
+ if attr is not None and not isinstance(attr, (str, int, float, bool)):
173
+ return _fail(
174
+ "schema", f'attribute "{key}" must be a string, number, boolean or null'
175
+ )
176
+ if isinstance(attr, float) and (attr != attr or attr in (float("inf"), float("-inf"))):
177
+ return _fail("schema", f'attribute "{key}" must be a finite number')
178
+ if isinstance(attr, str) and _utf8_len(attr) > limits.maxStringBytes:
179
+ return _fail("string-bytes", f'attribute "{key}" exceeds the string ceiling')
180
+
181
+ return LogValidationResult(ok=True, record=record)
182
+
183
+
184
+ def flatten_attrs(
185
+ value: Mapping[str, Any], prefix: str = "", depth: int = 0
186
+ ) -> Dict[str, LogAttrValue]:
187
+ """Flatten nested context into dotted keys, as the wire format requires.
188
+
189
+ ``{"db": {"host": "x"}}`` becomes ``{"db.host": "x"}``. Values that are not
190
+ scalars after flattening are rendered with :func:`repr`, because losing the
191
+ shape of a value is better than dropping the record that carries it.
192
+ """
193
+ flat: Dict[str, LogAttrValue] = {}
194
+ for key, item in value.items():
195
+ name = f"{prefix}{key}"
196
+ if isinstance(item, Mapping) and depth < 4:
197
+ flat.update(flatten_attrs(item, f"{name}.", depth + 1))
198
+ elif item is None or isinstance(item, (str, bool, int)):
199
+ flat[name] = item
200
+ elif isinstance(item, float):
201
+ flat[name] = item if item == item and item not in (float("inf"), float("-inf")) else str(item)
202
+ else:
203
+ try:
204
+ flat[name] = json.dumps(item, default=repr)
205
+ except (TypeError, ValueError):
206
+ flat[name] = repr(item)
207
+ return flat
termwright/marker.py ADDED
@@ -0,0 +1,128 @@
1
+ """Render-commit marker.
2
+
3
+ The adapter writes this OSC sequence to stdout *after* the last byte of the
4
+ render belonging to revision N. It is a frame-commit signal, never a data
5
+ carrier::
6
+
7
+ OSC 8487 ; twm;<revision>;<mac> BEL
8
+
9
+ with ``mac = base64url(HMAC-SHA256(token, f"{session_id}:{revision}"))[:16]``,
10
+ unpadded. The token is an opaque UTF-8 string end to end: whatever arrives in
11
+ ``TERMWRIGHT_TOKEN`` is fed to the HMAC as key bytes, never re-decoded.
12
+
13
+ OSC rather than DCS because ConPTY rewrites the stream it forwards: a
14
+ passthrough probe showed it dropping DCS, APC and OSC 8 while passing private
15
+ OSC with either terminator, so a DCS marker could not reach the driver on
16
+ Windows at all. One encoding everywhere beats negotiating per platform — the
17
+ path used least is the one that rots unnoticed.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import base64
23
+ import hmac
24
+ import re
25
+ from dataclasses import dataclass
26
+ from hashlib import sha256
27
+ from typing import Optional
28
+
29
+ from .errors import ProtocolViolation
30
+
31
+ #: The private OSC number carrying render-commit markers. Chosen clear of
32
+ #: everything in use (xterm's allocations, OSC 8, 9, 99, 133, 633, 697, 777+):
33
+ #: 84 and 87 are the ASCII codes of ``T`` and ``W``, for termwright.
34
+ MARKER_OSC_CODE = 8487
35
+
36
+ #: The tag opening a marker payload, immediately after ``OSC 8487;``. Kept as a
37
+ #: self-identifying guard: if anything ever claims 8487, a marker still says
38
+ #: what it is instead of being mistaken for that feature's payload.
39
+ MARKER_OSC_PREFIX = "twm;"
40
+
41
+ #: The terminator this implementation emits — the one ConPTY was observed to
42
+ #: forward most reliably.
43
+ BEL = "\x07"
44
+
45
+ #: The terminator a receiver must also accept.
46
+ ST = "\x1b\\"
47
+ MARKER_MAC_BYTES = 16
48
+ MARKER_MAC_CHARS = 22
49
+
50
+ _MAX_SAFE_INTEGER = 2**53 - 1
51
+
52
+ _REVISION_TEXT = re.compile(r"^[1-9][0-9]{0,15}$")
53
+ _MAC_TEXT = re.compile(r"^[A-Za-z0-9_-]{%d}$" % MARKER_MAC_CHARS)
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class RenderMarker:
58
+ """A verified marker: the revision it commits and the MAC that proved it."""
59
+
60
+ revision: int
61
+ mac: str
62
+
63
+
64
+ def compute_mac(token: str, session_id: str, revision: int) -> str:
65
+ """Return the unpadded base64url MAC bound to ``session_id`` and ``revision``."""
66
+ digest = hmac.new(
67
+ token.encode("utf-8"), f"{session_id}:{revision}".encode("utf-8"), sha256
68
+ ).digest()
69
+ return base64.urlsafe_b64encode(digest[:MARKER_MAC_BYTES]).decode("ascii").rstrip("=")
70
+
71
+
72
+ def encode_marker(token: str, session_id: str, revision: int) -> str:
73
+ """Build the full escape sequence committing ``revision``.
74
+
75
+ :raises ProtocolViolation: On an empty token/session id or a revision that
76
+ is not a positive safe integer.
77
+ """
78
+ if not token:
79
+ raise ProtocolViolation("marker-argument", "token must not be empty")
80
+ if not session_id:
81
+ raise ProtocolViolation("marker-argument", "sessionId must not be empty")
82
+ if isinstance(revision, bool) or not isinstance(revision, int):
83
+ raise ProtocolViolation("marker-argument", "revision must be a positive safe integer")
84
+ if revision <= 0 or revision > _MAX_SAFE_INTEGER:
85
+ raise ProtocolViolation("marker-argument", "revision must be a positive safe integer")
86
+ mac = compute_mac(token, session_id, revision)
87
+ return f"\x1b]{MARKER_OSC_CODE};{MARKER_OSC_PREFIX}{revision};{mac}{BEL}"
88
+
89
+
90
+ def verify_marker_payload(payload: str, token: str, session_id: str) -> Optional[RenderMarker]:
91
+ """Parse and verify an OSC payload — everything after ``OSC 8487;``.
92
+
93
+ Total function: hostile payloads return ``None``, never raise. Only
94
+ canonically formatted revisions are accepted, so ``1`` and ``01`` cannot
95
+ both authenticate the same commit, and the MAC compare is constant-time.
96
+
97
+ A trailing BEL or ST is tolerated: a VT parser consumes the terminator
98
+ before dispatching, so a handler normally passes a payload without one,
99
+ while a caller scanning raw output with a regex keeps it. Both must work.
100
+ """
101
+ if not token or not session_id:
102
+ return None
103
+
104
+ text = payload
105
+ if text.endswith(BEL):
106
+ text = text[: -len(BEL)]
107
+ elif text.endswith(ST):
108
+ text = text[: -len(ST)]
109
+
110
+ if not text.startswith(MARKER_OSC_PREFIX):
111
+ return None
112
+
113
+ body = text[len(MARKER_OSC_PREFIX) :]
114
+ separator = body.find(";")
115
+ if separator < 0:
116
+ return None
117
+
118
+ revision_text = body[:separator]
119
+ mac = body[separator + 1 :]
120
+ if not _REVISION_TEXT.match(revision_text) or not _MAC_TEXT.match(mac):
121
+ return None
122
+
123
+ revision = int(revision_text)
124
+ if revision <= 0 or revision > _MAX_SAFE_INTEGER:
125
+ return None
126
+ if not hmac.compare_digest(compute_mac(token, session_id, revision), mac):
127
+ return None
128
+ return RenderMarker(revision=revision, mac=mac)