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.
termwright/client.py ADDED
@@ -0,0 +1,708 @@
1
+ """asyncio client for the semantic side-channel.
2
+
3
+ **Dormant rule.** Without ``TERMWRIGHT_ENDPOINT`` and ``TERMWRIGHT_TOKEN`` in
4
+ the environment, :func:`client_from_env` returns ``None`` and the application
5
+ opens no socket, writes no marker, and renders exactly the bytes it would have
6
+ rendered anyway. Instrumentation is something the driver switches on, never
7
+ something the app does on its own.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import os
14
+ import time
15
+ from collections import OrderedDict
16
+ from typing import Any, Dict, Mapping, MutableMapping, Optional, Sequence, Tuple
17
+
18
+ from .debug import DebugLog, describe_endpoint
19
+ from .errors import ProtocolViolation, TermwrightError
20
+ from .framing import FrameDecoder, encode_frame
21
+ from .limits import DEFAULT_LIMITS, ProtocolLimits
22
+ from .marker import encode_marker
23
+ from .diffing import build_delta
24
+ from .logs import LogRecord, flatten_attrs, validate_log_record
25
+ from .messages import (
26
+ PROTOCOL_ID,
27
+ PROTOCOL_V2_ID,
28
+ get_tree_result,
29
+ hello,
30
+ log_message,
31
+ parse_driver_message,
32
+ protocol_error,
33
+ revision_commit,
34
+ snapshot_message,
35
+ )
36
+ from .tree import SemanticSnapshot
37
+ from .validate import validate_snapshot
38
+
39
+ ENV_ENDPOINT = "TERMWRIGHT_ENDPOINT"
40
+ ENV_TOKEN = "TERMWRIGHT_TOKEN"
41
+ ENV_PROTOCOL = "TERMWRIGHT_PROTOCOL"
42
+
43
+ DEFAULT_CAPABILITIES = (
44
+ "tree",
45
+ "bounds",
46
+ "absolute-bounds",
47
+ "states",
48
+ "actions",
49
+ "render-revisions",
50
+ )
51
+
52
+ #: Capabilities for an adapter that also forwards application logs. Announcing
53
+ #: `logs` is what makes the driver send a budget back; without it the driver
54
+ #: sends none and the adapter must stay silent.
55
+ CAPABILITIES_WITH_LOGS = DEFAULT_CAPABILITIES + ("logs",)
56
+
57
+ #: How many recent snapshots stay answerable by a ``get-tree`` for a past revision.
58
+ _SNAPSHOT_HISTORY = 8
59
+
60
+ #: Seconds a single frame write may wait for the driver to read.
61
+ #:
62
+ #: A probe publishes from the render path, so an unbounded write turns a driver
63
+ #: that stopped reading into an application that stopped drawing. asyncio keeps
64
+ #: the loop turning either way, but an unbounded ``drain`` queues frames in
65
+ #: memory for as long as the driver stays away, which is its own failure. A
66
+ #: driver that cannot take a frame in a quarter of a second is not keeping up,
67
+ #: and the next frame carries newer state anyway.
68
+ DEFAULT_WRITE_TIMEOUT = 0.25
69
+
70
+
71
+ class WriteTimeout(TermwrightError):
72
+ """The driver did not read within the write deadline.
73
+
74
+ Distinguishable on purpose: a caller reacting to a slow driver does
75
+ something quite different from one whose snapshot was refused for being
76
+ invalid, which raises :class:`ProtocolViolation` and will do so again for
77
+ the same tree.
78
+ """
79
+
80
+
81
+
82
+ def _is_pipe_path(endpoint: str) -> bool:
83
+ """Whether the endpoint names a Windows pipe rather than a unix socket."""
84
+ return endpoint.startswith("\\\\.\\pipe\\") or endpoint.startswith("\\\\?\\pipe\\")
85
+
86
+
87
+ async def _open_connection(endpoint: str):
88
+ """Open the driver's endpoint on whichever transport it needs.
89
+
90
+ The driver listens on a unix socket everywhere but Windows, where it
91
+ listens on a named pipe (``\\\\.\\pipe\\termwright-<hex>``). asyncio reaches a
92
+ pipe only through the proactor loop's ``create_pipe_connection``, and does
93
+ not expose ``open_unix_connection`` on Windows at all — so choosing by the
94
+ endpoint's shape is what keeps one client working on both.
95
+ """
96
+ loop = asyncio.get_event_loop()
97
+ if _is_pipe_path(endpoint):
98
+ connect = getattr(loop, "create_pipe_connection", None)
99
+ if connect is None:
100
+ # A pipe path under a loop that cannot open one: nothing to do but
101
+ # stay silent, which the caller treats as no side channel.
102
+ raise NotImplementedError("this event loop cannot open a named pipe")
103
+ reader = asyncio.StreamReader(loop=loop)
104
+ protocol = asyncio.StreamReaderProtocol(reader, loop=loop)
105
+ transport, _ = await connect(lambda: protocol, endpoint)
106
+ writer = asyncio.StreamWriter(transport, protocol, reader, loop)
107
+ return reader, writer
108
+ return await asyncio.open_unix_connection(endpoint)
109
+
110
+
111
+ class _TokenBucket:
112
+ """Rate limiter for the log channel: `burst` capacity, refilled per second.
113
+
114
+ The adapter enforces its own budget and drops locally, which is what keeps
115
+ a log storm from eating the frame budget the semantic tree needs.
116
+ """
117
+
118
+ def __init__(self, per_second: int, burst: int, now: float) -> None:
119
+ self._per_second = max(0, per_second)
120
+ self._capacity = float(max(0, burst) + max(0, per_second))
121
+ self._tokens = self._capacity
122
+ self._updated = now
123
+
124
+ def take(self, now: float) -> bool:
125
+ """Consume one token, refilling first. False means "over budget"."""
126
+ if self._per_second <= 0:
127
+ return False
128
+ elapsed = max(0.0, now - self._updated)
129
+ self._updated = now
130
+ self._tokens = min(self._capacity, self._tokens + elapsed * self._per_second)
131
+ if self._tokens < 1.0:
132
+ return False
133
+ self._tokens -= 1.0
134
+ return True
135
+
136
+
137
+ class SemanticClient:
138
+ """One semantic session: handshake, snapshot publishing, render markers.
139
+
140
+ The client owns the revision counter. :meth:`publish` allocates the next
141
+ revision, sends the snapshot and its commit, and returns the marker string
142
+ the caller must write to stdout **after** the render's last byte.
143
+ """
144
+
145
+ def __init__(
146
+ self,
147
+ endpoint: str,
148
+ token: str,
149
+ *,
150
+ adapter_name: str,
151
+ adapter_version: str,
152
+ capabilities: Sequence[str] = DEFAULT_CAPABILITIES,
153
+ limits: ProtocolLimits = DEFAULT_LIMITS,
154
+ debug: Optional[DebugLog] = None,
155
+ probe: Optional[Mapping[str, Any]] = None,
156
+ write_timeout: float = DEFAULT_WRITE_TIMEOUT,
157
+ protocol: str = PROTOCOL_ID,
158
+ ) -> None:
159
+ self._endpoint = endpoint
160
+ self._token = token
161
+ self._adapter_name = adapter_name
162
+ self._adapter_version = adapter_version
163
+ self._capabilities = tuple(capabilities)
164
+ self._limits = limits
165
+ #: Diagnostic log, or None. Every use is guarded; the client behaves
166
+ #: identically with and without one.
167
+ self._debug = debug
168
+ #: What a probe says it can observe, sent with `hello`. None for a
169
+ #: hand-written adapter, which is what the driver assumes by default.
170
+ self._probe = probe
171
+ #: Seconds one frame write may wait. Non-positive disables the bound,
172
+ #: which is only sane for a caller that publishes off the render path.
173
+ self._write_timeout = write_timeout
174
+ self.protocol = protocol
175
+ #: Set when the producer lost something and owes a whole tree.
176
+ self._force_full = False
177
+
178
+ self._reader: Optional[asyncio.StreamReader] = None
179
+ self._writer: Optional[asyncio.StreamWriter] = None
180
+ self._decoder = FrameDecoder(limits.maxFrameBytes, limits.maxDepth)
181
+ self._reader_task: Optional[asyncio.Task] = None
182
+ self._ready: Optional[asyncio.Future] = None
183
+ self._history: MutableMapping[int, Dict[str, Any]] = OrderedDict()
184
+ #: The last tree the driver has, which every delta is based on.
185
+ self._published: Optional[Dict[str, Any]] = None
186
+ #: Counters a test or a diagnostic can read.
187
+ self.deltas_sent = 0
188
+ self.snapshots_sent = 0
189
+
190
+ self.session_id: Optional[str] = None
191
+ self.revision = 0
192
+ self.marker_enabled = False
193
+ #: Log-channel budget from ``hello-ack``; ``None`` means logs are off.
194
+ self.log_budget: Optional[Dict[str, Any]] = None
195
+ self._log_seq = 0
196
+ self._log_bucket: Optional[_TokenBucket] = None
197
+ #: Records dropped locally for being over budget or over a limit.
198
+ self.logs_dropped = 0
199
+ self.subscribe = "snapshots"
200
+ self.closed = False
201
+
202
+ # -- lifecycle ---------------------------------------------------------
203
+
204
+ @property
205
+ def connected(self) -> bool:
206
+ """True once the driver has acknowledged the handshake."""
207
+ return self.session_id is not None and not self.closed
208
+
209
+ async def start(self, timeout: float = 5.0) -> bool:
210
+ """Connect, send ``hello`` and wait for ``hello-ack``.
211
+
212
+ :returns: ``True`` on a completed handshake. Returns ``False`` — never
213
+ raises — when the endpoint is unreachable or the driver rejects us:
214
+ a failed side-channel must not take the application down with it.
215
+ """
216
+ self._log("sem", f"dial {describe_endpoint(self._endpoint)} timeout={int(timeout * 1000)}ms")
217
+ try:
218
+ self._reader, self._writer = await asyncio.wait_for(
219
+ _open_connection(self._endpoint), timeout
220
+ )
221
+ except (OSError, asyncio.TimeoutError, NotImplementedError, AttributeError) as error:
222
+ # AttributeError belongs here: `asyncio.open_unix_connection` does
223
+ # not exist on Windows at all, so a wrong transport choice raises
224
+ # rather than failing to connect, and that must not reach the app.
225
+ self._log("diag", f"dial failed, staying dormant: {_error_label(error)}")
226
+ self.closed = True
227
+ return False
228
+
229
+ loop = asyncio.get_event_loop()
230
+ self._ready = loop.create_future()
231
+ self._reader_task = asyncio.ensure_future(self._read_loop())
232
+
233
+ try:
234
+ await self._send(
235
+ hello(
236
+ self._token,
237
+ self._adapter_name,
238
+ self._adapter_version,
239
+ self._capabilities,
240
+ self._probe,
241
+ protocol=self.protocol,
242
+ )
243
+ )
244
+ self._log(
245
+ "sem",
246
+ f"hello sent adapter={self._adapter_name}/{self._adapter_version} "
247
+ f"caps={','.join(self._capabilities)}",
248
+ )
249
+ await asyncio.wait_for(asyncio.shield(self._ready), timeout)
250
+ except (asyncio.TimeoutError, asyncio.CancelledError, OSError, TermwrightError) as error:
251
+ self._log("diag", f"handshake failed, staying dormant: {_error_label(error)}")
252
+ await self.close()
253
+ return False
254
+ if self.session_id is None:
255
+ self._log("diag", "handshake ended without a session, staying dormant")
256
+ return self.session_id is not None
257
+
258
+ async def close(self) -> None:
259
+ """Close the channel. Safe to call more than once."""
260
+ if not self.closed:
261
+ self._log(
262
+ "sem",
263
+ f"close r{self.revision} snapshots={self.snapshots_sent} "
264
+ f"deltas={self.deltas_sent} logs_dropped={self.logs_dropped}",
265
+ )
266
+ self.closed = True
267
+ if self._reader_task is not None:
268
+ self._reader_task.cancel()
269
+ self._reader_task = None
270
+ writer, self._writer = self._writer, None
271
+ if writer is not None:
272
+ try:
273
+ writer.close()
274
+ except OSError:
275
+ pass
276
+ self._reader = None
277
+
278
+ # -- publishing --------------------------------------------------------
279
+
280
+ def prepare(self, snapshot: SemanticSnapshot) -> Optional[Dict[str, Any]]:
281
+ """Allocate the next revision and return the validated wire snapshot.
282
+
283
+ Synchronous on purpose: the caller emits the marker for this revision
284
+ immediately after the render's last byte, so the revision number cannot
285
+ wait on a socket write.
286
+
287
+ The snapshot's ``sessionId``/``revision`` are overwritten with the
288
+ session's own — an adapter never picks its own revision numbers.
289
+
290
+ :raises ProtocolViolation: If the snapshot fails validation. That is an
291
+ adapter bug, not hostile input, so it is loud rather than silent.
292
+ """
293
+ if not self.connected or self._writer is None or self.session_id is None:
294
+ return None
295
+
296
+ self.revision += 1
297
+ wire = snapshot.to_wire()
298
+ wire["sessionId"] = self.session_id
299
+ wire["revision"] = self.revision
300
+
301
+ result = validate_snapshot(wire, self._limits)
302
+ if not result.ok:
303
+ self.revision -= 1
304
+ raise ProtocolViolation("snapshot-invalid", f"{result.code}: {result.detail}")
305
+
306
+ return wire
307
+
308
+ async def publish(self, snapshot: SemanticSnapshot) -> Optional[str]:
309
+ """Send a snapshot for the next revision and return its marker sequence.
310
+
311
+ :returns: The OSC marker to write to stdout after the render, or
312
+ ``None`` when the session is not (or no longer) live.
313
+ """
314
+ wire = self.prepare(snapshot)
315
+ if wire is None:
316
+ return None
317
+ try:
318
+ frames, tree_kind, forced = self._encode_snapshot(wire)
319
+ except ProtocolViolation:
320
+ self._reject_snapshot(wire)
321
+ raise
322
+ if not await self._send_snapshot(frames):
323
+ self._reject_snapshot(wire)
324
+ return None
325
+ self._accept_snapshot(wire, tree_kind, forced)
326
+ return self.marker(wire["revision"])
327
+
328
+ def publish_nowait(self, snapshot: SemanticSnapshot) -> Optional[str]:
329
+ """Same as :meth:`publish`, but the frames are sent on a background task.
330
+
331
+ The marker comes back at once so it can follow the render immediately;
332
+ the frames still reach the driver in revision order, because each task
333
+ writes to the transport before its first suspension point.
334
+ """
335
+ wire = self.prepare(snapshot)
336
+ if wire is None:
337
+ return None
338
+ try:
339
+ frames, tree_kind, forced = self._encode_snapshot(wire)
340
+ except ProtocolViolation:
341
+ self._reject_snapshot(wire)
342
+ return None
343
+
344
+ writer = self._writer
345
+ if writer is None:
346
+ self._reject_snapshot(wire)
347
+ return None
348
+ try:
349
+ for frame in frames:
350
+ writer.write(frame)
351
+ except (OSError, ConnectionResetError):
352
+ self._reject_snapshot(wire)
353
+ asyncio.ensure_future(self.close())
354
+ return None
355
+
356
+ self._accept_snapshot(wire, tree_kind, forced)
357
+ asyncio.ensure_future(self._drain(writer))
358
+ return self.marker(wire["revision"])
359
+
360
+ def _encode_snapshot(
361
+ self, wire: Dict[str, Any]
362
+ ) -> Tuple[Tuple[bytes, ...], Optional[str], bool]:
363
+ """Build and encode a whole publication before writing any of it.
364
+
365
+ ``maxFrameBytes`` may be tighter than ``maxSnapshotBytes``. Encoding
366
+ every message first keeps that local refusal atomic: no tree, commit or
367
+ marker escapes, and the last tree the driver actually received remains
368
+ the only legal delta base.
369
+ """
370
+
371
+ forced = self._force_full
372
+ messages = []
373
+ tree_kind: Optional[str] = None
374
+ if self.subscribe != "revisions":
375
+ tree, tree_kind = self._tree_message(wire, forced)
376
+ messages.append(tree)
377
+ messages.append(revision_commit(wire["revision"]))
378
+ return (
379
+ tuple(encode_frame(message, self._limits.maxFrameBytes) for message in messages),
380
+ tree_kind,
381
+ forced,
382
+ )
383
+
384
+ async def _send_snapshot(self, frames: Sequence[bytes]) -> bool:
385
+ writer = self._writer
386
+ if writer is None:
387
+ return False
388
+ try:
389
+ for frame in frames:
390
+ writer.write(frame)
391
+ except (OSError, ConnectionResetError):
392
+ await self.close()
393
+ return False
394
+ return await self._drain(writer)
395
+
396
+ def _accept_snapshot(
397
+ self, wire: Dict[str, Any], tree_kind: Optional[str], forced: bool
398
+ ) -> None:
399
+ """Commit bookkeeping only after every frame was accepted for writing."""
400
+
401
+ self._remember(wire["revision"], wire)
402
+ if tree_kind is not None:
403
+ self._published = wire
404
+ if tree_kind == "snapshot":
405
+ self.snapshots_sent += 1
406
+ elif tree_kind == "delta":
407
+ self.deltas_sent += 1
408
+ if forced:
409
+ self._force_full = False
410
+
411
+ def _reject_snapshot(self, wire: Dict[str, Any]) -> None:
412
+ """Undo a locally refused revision and require a full recovery tree."""
413
+
414
+ revision = wire["revision"]
415
+ if self.revision == revision:
416
+ self.revision -= 1
417
+ self._history.pop(revision, None)
418
+ self._force_full = True
419
+
420
+ def _tree_message(
421
+ self, wire: Dict[str, Any], forced: bool
422
+ ) -> Tuple[Dict[str, Any], str]:
423
+ """A delta when the driver asked for one and it is worth sending.
424
+
425
+ Falls back to the whole tree on the first publish, when the driver
426
+ wants snapshots, and whenever the delta would carry more than about
427
+ half the tree — past that a patch costs more than the thing it
428
+ replaces. The base is only advanced once a message is built from it,
429
+ so a skipped publish cannot leave the driver applying a delta onto a
430
+ tree it never received.
431
+ """
432
+ delta = None
433
+ if self.subscribe == "diffs" and self._published is not None and not forced:
434
+ delta = build_delta(self._published, wire)
435
+ if forced:
436
+ self._log("io", f"r{wire['revision']} full snapshot: the producer reported a gap")
437
+
438
+ if delta is None:
439
+ self._log("io", f"r{wire['revision']} snapshot nodes={len(wire.get('nodes', ()))}")
440
+ return snapshot_message(wire), "snapshot"
441
+ self._log(
442
+ "io",
443
+ f"r{wire['revision']} delta changed={len(delta.get('changed', ()))} "
444
+ f"removed={len(delta.get('removed', ()))}",
445
+ )
446
+ return delta, "delta"
447
+
448
+ def log(
449
+ self,
450
+ level: str,
451
+ message: str,
452
+ *,
453
+ attrs: Optional[Mapping[str, Any]] = None,
454
+ logger: Optional[str] = None,
455
+ ts: Optional[int] = None,
456
+ ) -> bool:
457
+ """Forward one application log record, if the driver asked for logs.
458
+
459
+ Returns whether the record went out. A record is dropped when the
460
+ session is not live, when the driver granted no budget, when this
461
+ adapter is over its rate, or when the record breaks a limit.
462
+
463
+ Every attempt consumes a sequence number, dropped or not: the gap left
464
+ in ``seq`` is precisely how the driver learns records were lost here
465
+ rather than in transit.
466
+ """
467
+ if not self.connected or self._log_bucket is None:
468
+ return False
469
+
470
+ self._log_seq += 1
471
+ record = LogRecord(
472
+ ts=int(time.time() * 1000) if ts is None else ts,
473
+ level=level,
474
+ message=message,
475
+ seq=self._log_seq,
476
+ attrs=flatten_attrs(attrs) if attrs else None,
477
+ logger=logger,
478
+ revision=self.revision or None,
479
+ )
480
+
481
+ if not self._log_bucket.take(time.monotonic()):
482
+ self.logs_dropped += 1
483
+ return False
484
+
485
+ wire = record.to_wire()
486
+ result = validate_log_record(wire, self._limits)
487
+ if not result.ok:
488
+ # An oversized or malformed record is dropped locally rather than
489
+ # taking the channel down; the gap in seq reports it.
490
+ self.logs_dropped += 1
491
+ return False
492
+
493
+ asyncio.ensure_future(self._send(log_message(record)))
494
+ return True
495
+
496
+ def marker(self, revision: int) -> Optional[str]:
497
+ """Marker sequence committing ``revision``, or ``None`` if not enabled."""
498
+ if not self.marker_enabled or self.session_id is None:
499
+ return None
500
+ return encode_marker(self._token, self.session_id, revision)
501
+
502
+ def require_full_snapshot(self) -> None:
503
+ """Make the next publish send a whole tree.
504
+
505
+ The producer's obligation from D5: a probe that lost anything from its
506
+ own stream of facts — a dropped frame, a coalesced burst, a write that
507
+ failed — must not follow it with a patch. The driver would apply that
508
+ patch to a tree that never accounted for what was lost, and the
509
+ divergence would be silent.
510
+ """
511
+ self._force_full = True
512
+
513
+ @property
514
+ def full_snapshot_required(self) -> bool:
515
+ """Whether the obligation is outstanding."""
516
+ return self._force_full
517
+
518
+ def _log(self, category: str, message: str) -> None:
519
+ """Write one diagnostic line, when diagnostics are on."""
520
+ if self._debug is not None:
521
+ self._debug.line(category, message)
522
+
523
+ def _remember(self, revision: int, wire: Dict[str, Any]) -> None:
524
+ self._history[revision] = wire
525
+ while len(self._history) > _SNAPSHOT_HISTORY:
526
+ self._history.pop(next(iter(self._history)))
527
+
528
+ async def _send(self, message: Mapping[str, Any]) -> None:
529
+ writer = self._writer
530
+ if writer is None:
531
+ return
532
+ frame = encode_frame(message, self._limits.maxFrameBytes)
533
+ try:
534
+ writer.write(frame)
535
+ except (OSError, ConnectionResetError):
536
+ await self.close()
537
+ return
538
+ await self._drain(writer)
539
+
540
+ async def _drain(self, writer: Any) -> bool:
541
+ """Drain already-written frames and make background failures quiet."""
542
+
543
+ try:
544
+ if self._write_timeout > 0:
545
+ await asyncio.wait_for(writer.drain(), self._write_timeout)
546
+ else:
547
+ await writer.drain()
548
+ except asyncio.TimeoutError:
549
+ # Part of a length-prefixed frame may already be on the wire and
550
+ # there is no resynchronisation point, so the session is over
551
+ # rather than merely delayed.
552
+ self._log(
553
+ "diag",
554
+ f"write deadline of {int(self._write_timeout * 1000)}ms exceeded; "
555
+ "session is unrecoverable",
556
+ )
557
+ await self.close()
558
+ return False
559
+ except (OSError, ConnectionResetError):
560
+ await self.close()
561
+ return False
562
+ return True
563
+
564
+ # -- receiving ---------------------------------------------------------
565
+
566
+ async def _read_loop(self) -> None:
567
+ reader = self._reader
568
+ if reader is None:
569
+ return
570
+ try:
571
+ while True:
572
+ chunk = await reader.read(64 * 1024)
573
+ if not chunk:
574
+ break
575
+ for raw in self._decoder.push(chunk):
576
+ await self._handle(raw)
577
+ except (asyncio.CancelledError, ProtocolViolation, OSError):
578
+ pass
579
+ finally:
580
+ if self._ready is not None and not self._ready.done():
581
+ self._ready.set_result(False)
582
+ self.closed = True
583
+
584
+ async def _handle(self, raw: Any) -> None:
585
+ parsed = parse_driver_message(raw, self._limits)
586
+ if not parsed.ok:
587
+ self._log("diag", f"rejected a driver message: {parsed.detail[:200]}")
588
+ await self._send(protocol_error("malformed", parsed.detail[:512]))
589
+ await self.close()
590
+ return
591
+ message = parsed.message
592
+ assert message is not None
593
+
594
+ if message["type"] == "hello-ack":
595
+ if message["protocol"] != self.protocol:
596
+ self._log("diag", f"driver acknowledged {message['protocol']} after requesting {self.protocol}")
597
+ await self.close()
598
+ return
599
+ self.session_id = message["sessionId"]
600
+ self.marker_enabled = bool(message["marker"]["enabled"])
601
+ self.log_budget = message.get("logs")
602
+ budget = self.log_budget
603
+ if budget is not None and budget.get("enabled"):
604
+ self._log_bucket = _TokenBucket(
605
+ int(budget["maxRecordsPerSecond"]), int(budget["burst"]), time.monotonic()
606
+ )
607
+ else:
608
+ self._log_bucket = None
609
+ self.subscribe = message["subscribe"]
610
+ self._limits = ProtocolLimits.from_wire(message["limits"])
611
+ if self._debug is not None:
612
+ self._debug.label = self.session_id or ""
613
+ self._log(
614
+ "sem",
615
+ f"hello-ack session={self.session_id} marker={'on' if self.marker_enabled else 'off'} "
616
+ f"subscribe={self.subscribe} logs={'on' if self._log_bucket is not None else 'off'}",
617
+ )
618
+ if self._ready is not None and not self._ready.done():
619
+ self._ready.set_result(True)
620
+ elif message["type"] == "get-tree":
621
+ requested = message.get("revision", self.revision)
622
+ held = self._history.get(requested)
623
+ if held is None:
624
+ await self._send(
625
+ get_tree_result(message["requestId"], error=f"revision {requested} is not retained")
626
+ )
627
+ else:
628
+ await self._send(get_tree_result(message["requestId"], snapshot=held))
629
+ elif message["type"] == "error":
630
+ self._log("diag", f"driver ended the session: {message.get('code')}")
631
+ await self.close()
632
+
633
+
634
+ def client_from_env(
635
+ *,
636
+ adapter_name: str,
637
+ adapter_version: str,
638
+ capabilities: Sequence[str] = DEFAULT_CAPABILITIES,
639
+ env: Optional[Mapping[str, str]] = None,
640
+ limits: ProtocolLimits = DEFAULT_LIMITS,
641
+ debug: Optional[DebugLog] = None,
642
+ probe: Optional[Mapping[str, Any]] = None,
643
+ qualified_capabilities: Sequence[str] = (),
644
+ ) -> Optional[SemanticClient]:
645
+ """Build a client from ``TERMWRIGHT_*``, or ``None`` when not instrumented.
646
+
647
+ This is the dormant rule in one function: no endpoint or no token means no
648
+ client, and the caller must then do nothing at all.
649
+
650
+ When diagnostics are enabled — by ``TERMWRIGHT_DEBUG_FILE``, or by passing
651
+ ``debug`` — the *reason* for staying dormant is written to the log before
652
+ returning ``None``. That line is the whole point of the file: a run where
653
+ the adapter never attached otherwise leaves no trace anywhere.
654
+ """
655
+ source: Mapping[str, str] = os.environ if env is None else env
656
+ log = DebugLog.from_env(source, adapter=adapter_name) if debug is None else debug
657
+ endpoint = source.get(ENV_ENDPOINT)
658
+ token = source.get(ENV_TOKEN)
659
+ if not endpoint or not token:
660
+ if log is not None:
661
+ missing = [
662
+ name
663
+ for name, value in ((ENV_ENDPOINT, endpoint), (ENV_TOKEN, token))
664
+ if not value
665
+ ]
666
+ log.line("diag", f"dormant: {' and '.join(missing)} not set")
667
+ return None
668
+ protocol = source.get(ENV_PROTOCOL)
669
+ if protocol is not None and protocol not in ("", PROTOCOL_ID, PROTOCOL_V2_ID, "1", "2"):
670
+ if log is not None:
671
+ log.line(
672
+ "diag",
673
+ f"dormant: {ENV_PROTOCOL}={protocol!r} is not {PROTOCOL_ID!r}",
674
+ )
675
+ return None
676
+ selected_protocol = PROTOCOL_V2_ID if protocol in (PROTOCOL_V2_ID, "2") else PROTOCOL_ID
677
+ selected_capabilities = tuple(capabilities)
678
+ if selected_protocol == PROTOCOL_V2_ID and "qualified-observations" not in selected_capabilities:
679
+ selected_capabilities += ("qualified-observations",)
680
+ if selected_protocol == PROTOCOL_V2_ID:
681
+ selected_capabilities += tuple(
682
+ item for item in qualified_capabilities if item not in selected_capabilities
683
+ )
684
+ return SemanticClient(
685
+ endpoint,
686
+ token,
687
+ adapter_name=adapter_name,
688
+ adapter_version=adapter_version,
689
+ capabilities=selected_capabilities,
690
+ limits=limits,
691
+ debug=log,
692
+ probe=probe,
693
+ protocol=selected_protocol,
694
+ )
695
+
696
+
697
+ def _error_label(error: BaseException) -> str:
698
+ """One-line description of a failure: class, errno and first message line.
699
+
700
+ The class alone is what usually settles a Windows question — a
701
+ ``FileNotFoundError`` on a pipe path means the driver was never listening,
702
+ while a ``NotImplementedError`` means the loop could not open one at all —
703
+ so it is always printed, even when the message is empty.
704
+ """
705
+ code = getattr(error, "errno", None)
706
+ suffix = f" [errno {code}]" if code is not None else ""
707
+ text = str(error).split("\n")[0]
708
+ return f"{type(error).__name__}{suffix}" + (f": {text}" if text else "")