python-tty 0.1.0__py3-none-any.whl → 0.1.6__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.
@@ -6,9 +6,10 @@ from dataclasses import dataclass
6
6
  from typing import Callable, Dict, Optional
7
7
 
8
8
  from python_tty.config import ExecutorConfig
9
- from python_tty.ui.events import UIEvent, UIEventLevel
10
- from python_tty.ui.output import get_output_router
9
+ from python_tty.runtime.events import RuntimeEvent, RuntimeEventKind, UIEventLevel
10
+ from python_tty.runtime.provider import get_router
11
11
  from python_tty.executor.models import Invocation, RunState, RunStatus
12
+ from python_tty.exceptions.console_exception import ConsoleExit, SubConsoleExit
12
13
 
13
14
 
14
15
  @dataclass
@@ -29,16 +30,26 @@ class CommandExecutor:
29
30
  self._locks: Dict[str, asyncio.Lock] = {}
30
31
  self._runs: Dict[str, RunState] = {}
31
32
  self._event_queues: Dict[str, asyncio.Queue] = {}
33
+ self._event_seq: Dict[str, int] = {}
32
34
  self._run_futures: Dict[str, asyncio.Future] = {}
33
35
  self._retain_last_n = config.retain_last_n
34
36
  self._ttl_seconds = config.ttl_seconds
35
37
  self._pop_on_wait = config.pop_on_wait
36
- self._output_router = get_output_router()
38
+ self._emit_run_events = config.emit_run_events
39
+ if config.exempt_exceptions is None:
40
+ self._exempt_exceptions = (ConsoleExit, SubConsoleExit)
41
+ else:
42
+ self._exempt_exceptions = tuple(config.exempt_exceptions)
43
+ self._audit_sink = self._init_audit_sink(config)
37
44
 
38
45
  @property
39
46
  def runs(self):
40
47
  return self._runs
41
48
 
49
+ @property
50
+ def audit_sink(self):
51
+ return self._audit_sink
52
+
42
53
  def start(self, loop=None):
43
54
  if loop is not None:
44
55
  self._loop = loop
@@ -61,6 +72,8 @@ class CommandExecutor:
61
72
  handler = self._missing_handler
62
73
  run_id = invocation.run_id
63
74
  self._runs[run_id] = RunState(run_id=run_id)
75
+ self._audit_invocation(invocation)
76
+ self._audit_run_state(self._runs[run_id])
64
77
  if self._loop is None:
65
78
  try:
66
79
  self._loop = asyncio.get_running_loop()
@@ -124,11 +137,20 @@ class CommandExecutor:
124
137
  def publish_event(self, run_id: str, event):
125
138
  if getattr(event, "run_id", None) is None:
126
139
  event.run_id = run_id
140
+ if run_id is not None and getattr(event, "seq", None) is None:
141
+ event.seq = self._next_event_seq(run_id)
127
142
  if self._loop is not None and self._loop.is_running():
128
143
  queue = self._ensure_event_queue(run_id)
129
144
  self._queue_event(queue, event)
130
- if self._output_router is not None:
131
- self._output_router.emit(event)
145
+ output_router = get_router()
146
+ if output_router is not None:
147
+ output_router.emit(event)
148
+ if self._audit_sink is not None:
149
+ output_audit = None
150
+ if output_router is not None:
151
+ output_audit = getattr(output_router, "audit_sink", None)
152
+ if output_audit is None or output_audit is not self._audit_sink:
153
+ self._audit_sink.record_event(event)
132
154
 
133
155
  async def shutdown(self, wait: bool = True):
134
156
  workers = list(self._workers)
@@ -137,6 +159,7 @@ class CommandExecutor:
137
159
  task.cancel()
138
160
  if wait and workers:
139
161
  await asyncio.gather(*workers, return_exceptions=True)
162
+ self._close_audit_sink()
140
163
 
141
164
  def shutdown_threadsafe(self, wait: bool = True, timeout: Optional[float] = None):
142
165
  loop = self._loop
@@ -144,6 +167,7 @@ class CommandExecutor:
144
167
  for task in list(self._workers):
145
168
  task.cancel()
146
169
  self._workers.clear()
170
+ self._close_audit_sink()
147
171
  return None
148
172
  future = asyncio.run_coroutine_threadsafe(self.shutdown(wait=wait), loop)
149
173
  return future.result(timeout)
@@ -164,6 +188,8 @@ class CommandExecutor:
164
188
  running_loop = None
165
189
  if running_loop == self._loop:
166
190
  return self.submit(invocation, handler=handler)
191
+ self._audit_invocation(invocation)
192
+ self._audit_run_state(self._runs[run_id])
167
193
 
168
194
  async def _enqueue():
169
195
  self.start()
@@ -187,25 +213,38 @@ class CommandExecutor:
187
213
  return
188
214
  run_state.status = RunStatus.RUNNING
189
215
  run_state.started_at = time.time()
190
- self.publish_event(run_state.run_id, self._build_run_event("start", UIEventLevel.INFO))
216
+ self._audit_run_state(run_state)
217
+ self._emit_run_event(run_state.run_id, "start", UIEventLevel.INFO, source=work_item.invocation.source)
191
218
  try:
192
219
  result = work_item.handler(work_item.invocation)
193
220
  if inspect.isawaitable(result):
194
221
  result = await result
195
222
  run_state.result = result
196
223
  run_state.status = RunStatus.SUCCEEDED
197
- self.publish_event(run_state.run_id, self._build_run_event("success", UIEventLevel.SUCCESS))
224
+ self._emit_run_event(run_state.run_id, "success", UIEventLevel.SUCCESS,
225
+ source=work_item.invocation.source)
198
226
  self._resolve_future(run_state, result=result)
227
+ except self._exempt_exceptions as exc:
228
+ run_state.error = exc
229
+ run_state.status = RunStatus.CANCELLED
230
+ self._emit_run_event(run_state.run_id, "cancelled", UIEventLevel.INFO,
231
+ source=work_item.invocation.source)
232
+ self._resolve_future(run_state, error=exc)
199
233
  except Exception as exc:
200
234
  run_state.error = exc
201
235
  run_state.status = RunStatus.FAILED
202
- self.publish_event(
236
+ self._emit_run_event(
203
237
  run_state.run_id,
204
- self._build_run_event("failure", UIEventLevel.ERROR, payload={"error": str(exc)}),
238
+ "failure",
239
+ UIEventLevel.ERROR,
240
+ payload={"error": str(exc)},
241
+ source=work_item.invocation.source,
242
+ force=True,
205
243
  )
206
244
  self._resolve_future(run_state, error=exc)
207
245
  finally:
208
246
  run_state.finished_at = time.time()
247
+ self._audit_run_state(run_state)
209
248
  self._cleanup_runs()
210
249
 
211
250
  def _resolve_future(self, run_state: RunState, result=None, error: Optional[BaseException] = None):
@@ -223,28 +262,52 @@ class CommandExecutor:
223
262
  return
224
263
  run_state.status = RunStatus.RUNNING
225
264
  run_state.started_at = time.time()
226
- self.publish_event(run_state.run_id, self._build_run_event("start", UIEventLevel.INFO))
265
+ self._audit_run_state(run_state)
266
+ self._emit_run_event(run_state.run_id, "start", UIEventLevel.INFO, source=invocation.source)
227
267
  try:
228
268
  result = handler(invocation)
229
269
  if inspect.isawaitable(result):
230
270
  result = self._run_awaitable_inline(result)
231
271
  run_state.result = result
232
272
  run_state.status = RunStatus.SUCCEEDED
233
- self.publish_event(run_state.run_id, self._build_run_event("success", UIEventLevel.SUCCESS))
273
+ self._emit_run_event(run_state.run_id, "success", UIEventLevel.SUCCESS,
274
+ source=invocation.source)
275
+ except self._exempt_exceptions as exc:
276
+ run_state.error = exc
277
+ run_state.status = RunStatus.CANCELLED
278
+ self._emit_run_event(run_state.run_id, "cancelled", UIEventLevel.INFO,
279
+ source=invocation.source)
234
280
  except Exception as exc:
235
281
  run_state.error = exc
236
282
  run_state.status = RunStatus.FAILED
237
- self.publish_event(
283
+ self._emit_run_event(
238
284
  run_state.run_id,
239
- self._build_run_event("failure", UIEventLevel.ERROR, payload={"error": str(exc)}),
285
+ "failure",
286
+ UIEventLevel.ERROR,
287
+ payload={"error": str(exc)},
288
+ source=invocation.source,
289
+ force=True,
240
290
  )
241
291
  finally:
242
292
  run_state.finished_at = time.time()
293
+ self._audit_run_state(run_state)
243
294
  self._cleanup_runs()
244
295
 
245
296
  @staticmethod
246
- def _build_run_event(event_type: str, level: UIEventLevel, payload=None):
247
- return UIEvent(msg=event_type, level=level, event_type=event_type, payload=payload)
297
+ def _build_run_event(event_type: str, level: UIEventLevel, payload=None, source=None):
298
+ return RuntimeEvent(
299
+ kind=RuntimeEventKind.STATE,
300
+ msg=event_type,
301
+ level=level,
302
+ event_type=event_type,
303
+ payload=payload,
304
+ source=source,
305
+ )
306
+
307
+ def _emit_run_event(self, run_id: str, event_type: str, level: UIEventLevel,
308
+ payload=None, source=None, force: bool = False):
309
+ if force or self._emit_run_events:
310
+ self.publish_event(run_id, self._build_run_event(event_type, level, payload=payload, source=source))
248
311
 
249
312
  @staticmethod
250
313
  def _missing_handler(invocation: Invocation):
@@ -275,6 +338,41 @@ class CommandExecutor:
275
338
  else:
276
339
  self._loop.call_soon_threadsafe(queue.put_nowait, event)
277
340
 
341
+ def _init_audit_sink(self, config: ExecutorConfig):
342
+ audit_config = getattr(config, "audit", None)
343
+ if audit_config is None or not audit_config.enabled:
344
+ return None
345
+ if audit_config.sink is not None:
346
+ return audit_config.sink
347
+ from python_tty.audit import AuditSink
348
+ return AuditSink(
349
+ file_path=audit_config.file_path,
350
+ stream=audit_config.stream,
351
+ keep_in_memory=audit_config.keep_in_memory,
352
+ async_mode=audit_config.async_mode,
353
+ flush_interval=audit_config.flush_interval,
354
+ )
355
+
356
+ def _audit_invocation(self, invocation: Invocation):
357
+ if self._audit_sink is None:
358
+ return
359
+ self._audit_sink.record_invocation(invocation)
360
+
361
+ def _audit_run_state(self, run_state: RunState):
362
+ if self._audit_sink is None:
363
+ return
364
+ self._audit_sink.record_run_state(run_state)
365
+
366
+ def _close_audit_sink(self):
367
+ if self._audit_sink is None:
368
+ return
369
+ self._audit_sink.close()
370
+
371
+ def _next_event_seq(self, run_id: str) -> int:
372
+ next_seq = self._event_seq.get(run_id, 0) + 1
373
+ self._event_seq[run_id] = next_seq
374
+ return next_seq
375
+
278
376
  def _run_awaitable_inline(self, awaitable):
279
377
  try:
280
378
  running_loop = asyncio.get_running_loop()
@@ -308,6 +406,7 @@ class CommandExecutor:
308
406
  if future is not None and not future.done():
309
407
  future.cancel()
310
408
  self._event_queues.pop(run_id, None)
409
+ self._event_seq.pop(run_id, None)
311
410
  return run_state
312
411
 
313
412
  def _cleanup_runs(self):
@@ -332,4 +431,4 @@ class CommandExecutor:
332
431
  remove_ids.add(run_id)
333
432
  for run_id in remove_ids:
334
433
  self.pop_run(run_id)
335
-
434
+
File without changes
File without changes
@@ -0,0 +1,96 @@
1
+ import hashlib
2
+ import json
3
+ from dataclasses import dataclass
4
+ from typing import List, Optional
5
+
6
+ from python_tty.commands.mixins import DefaultCommands
7
+ from python_tty.commands.registry import ArgSpec, COMMAND_REGISTRY
8
+ from python_tty.consoles.registry import REGISTRY
9
+
10
+
11
+ @dataclass
12
+ class _ConsoleEntry:
13
+ name: str
14
+ console_cls: type
15
+ parent: Optional[str]
16
+
17
+
18
+ def export_meta(console_registry=REGISTRY, command_registry=COMMAND_REGISTRY,
19
+ include_default_commands: bool = True):
20
+ """Export console/command metadata as a dict with a revision hash."""
21
+ consoles = []
22
+ entries = _collect_console_entries(console_registry)
23
+ for entry in entries:
24
+ command_defs = command_registry.get_command_defs_for_console(entry.console_cls)
25
+ if not command_defs and include_default_commands:
26
+ command_defs = command_registry.collect_from_commands_cls(DefaultCommands)
27
+ commands = _export_commands(entry.name, command_defs)
28
+ consoles.append({
29
+ "name": entry.name,
30
+ "parent": entry.parent,
31
+ "type": entry.console_cls.__name__,
32
+ "module": entry.console_cls.__module__,
33
+ "commands": commands,
34
+ })
35
+ consoles.sort(key=lambda item: item["name"])
36
+ meta = {
37
+ "version": 1,
38
+ "consoles": consoles,
39
+ }
40
+ tree = None
41
+ if hasattr(console_registry, "get_console_tree"):
42
+ tree = console_registry.get_console_tree()
43
+ if tree is not None:
44
+ meta["tree"] = tree
45
+ console_map = None
46
+ if hasattr(console_registry, "get_console_map"):
47
+ console_map = console_registry.get_console_map()
48
+ if console_map is not None:
49
+ meta["console_map"] = console_map
50
+ meta["revision"] = _compute_revision(meta)
51
+ return meta
52
+
53
+
54
+ def _collect_console_entries(console_registry):
55
+ entries: List[_ConsoleEntry] = []
56
+ iter_consoles = getattr(console_registry, "iter_consoles", None)
57
+ if not callable(iter_consoles):
58
+ raise RuntimeError("Console registry must implement iter_consoles()")
59
+ for name, console_cls, parent in iter_consoles():
60
+ entries.append(_ConsoleEntry(name=name, console_cls=console_cls, parent=parent))
61
+ return entries
62
+
63
+
64
+ def _export_commands(console_name: str, command_defs):
65
+ commands = []
66
+ for command_def in command_defs or []:
67
+ arg_spec = command_def.arg_spec or ArgSpec.from_signature(command_def.func)
68
+ commands.append({
69
+ "id": _build_command_id(console_name, command_def.func_name),
70
+ "name": command_def.func_name,
71
+ "aliases": list(command_def.alias or []),
72
+ "description": command_def.func_description,
73
+ "argspec": {
74
+ "min": arg_spec.min_args,
75
+ "max": arg_spec.max_args,
76
+ "variadic": arg_spec.variadic,
77
+ },
78
+ })
79
+ commands.sort(key=lambda item: item["id"])
80
+ return commands
81
+
82
+
83
+ def _build_command_id(console_name: str, command_name: str):
84
+ return f"cmd:{console_name}:{command_name}"
85
+
86
+
87
+ def _compute_revision(meta):
88
+ payload = dict(meta)
89
+ payload.pop("revision", None)
90
+ canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
91
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
92
+
93
+
94
+ __all__ = [
95
+ "export_meta",
96
+ ]
@@ -0,0 +1,29 @@
1
+ from python_tty.runtime.events import (
2
+ EventBase,
3
+ RuntimeEvent,
4
+ RuntimeEventKind,
5
+ UIEvent,
6
+ UIEventLevel,
7
+ UIEventListener,
8
+ UIEventSpeaker,
9
+ )
10
+ from python_tty.runtime.provider import get_default_router, get_router, set_default_router, use_router
11
+ from python_tty.runtime.router import BaseRouter, OutputRouter, get_output_router, proxy_print
12
+
13
+ __all__ = [
14
+ "UIEvent",
15
+ "UIEventLevel",
16
+ "EventBase",
17
+ "RuntimeEvent",
18
+ "RuntimeEventKind",
19
+ "UIEventListener",
20
+ "UIEventSpeaker",
21
+ "BaseRouter",
22
+ "OutputRouter",
23
+ "get_default_router",
24
+ "get_router",
25
+ "get_output_router",
26
+ "set_default_router",
27
+ "proxy_print",
28
+ "use_router",
29
+ ]
@@ -0,0 +1,147 @@
1
+ import enum
2
+ import time
3
+
4
+
5
+ class UIEventLevel(enum.Enum):
6
+ TEXT = -1
7
+ INFO = 0
8
+ WARNING = 1
9
+ ERROR = 2
10
+ SUCCESS = 3
11
+ FAILURE = 4
12
+ DEBUG = 5
13
+
14
+ @staticmethod
15
+ def map_level(code):
16
+ if code == 0:
17
+ return UIEventLevel.INFO
18
+ elif code == 1:
19
+ return UIEventLevel.WARNING
20
+ elif code == 2:
21
+ return UIEventLevel.ERROR
22
+ elif code == 3:
23
+ return UIEventLevel.SUCCESS
24
+ elif code == 4:
25
+ return UIEventLevel.FAILURE
26
+ elif code == 5:
27
+ return UIEventLevel.DEBUG
28
+
29
+
30
+ class RuntimeEventKind(enum.Enum):
31
+ STATE = "state"
32
+ STDOUT = "stdout"
33
+ LOG = "log"
34
+
35
+
36
+ def _normalize_runtime_kind(kind):
37
+ if isinstance(kind, RuntimeEventKind):
38
+ return kind
39
+ if kind is None:
40
+ return None
41
+ try:
42
+ return RuntimeEventKind(kind)
43
+ except ValueError:
44
+ return None
45
+
46
+
47
+ class EventBase:
48
+ """Common event fields shared by RuntimeEvent and UIEvent."""
49
+ def __init__(self, msg, level=UIEventLevel.TEXT, run_id=None, event_type=None,
50
+ payload=None, source=None, ts=None, seq=None):
51
+ self.msg = msg
52
+ self.level = level
53
+ self.run_id = run_id
54
+ self.event_type = event_type
55
+ self.payload = payload
56
+ self.source = source
57
+ self.ts = time.time() if ts is None else ts
58
+ self.seq = seq
59
+
60
+
61
+ class UIEvent(EventBase):
62
+ """UI event payload for rendering.
63
+
64
+ Fields:
65
+ msg: Display text or structured data for the event.
66
+ level: UIEventLevel (or int) that drives rendering style.
67
+ run_id: Run identifier when the event is tied to a command invocation.
68
+ event_type: A short event type label (e.g., "start", "success").
69
+ payload: Structured payload for downstream consumers.
70
+ source: Event origin (framework should pass "tty"/"rpc" explicitly;
71
+ external callers via proxy_print default to "custom").
72
+ ts: Unix timestamp (seconds) when the event was created.
73
+ seq: Per-run sequence number when emitted by the executor.
74
+ """
75
+ def __init__(self, msg, level=UIEventLevel.TEXT, run_id=None, event_type=None,
76
+ payload=None, source=None, ts=None, seq=None):
77
+ super().__init__(
78
+ msg=msg,
79
+ level=level,
80
+ run_id=run_id,
81
+ event_type=event_type,
82
+ payload=payload,
83
+ source=source,
84
+ ts=ts,
85
+ seq=seq,
86
+ )
87
+
88
+
89
+ class RuntimeEvent(EventBase):
90
+ """Runtime event payload for executor/audit pipelines.
91
+
92
+ Fields:
93
+ kind: RuntimeEventKind (state/stdout/log).
94
+ msg: Text or payload for stdout/log events.
95
+ level: UIEventLevel or int for log/stdout severity.
96
+ run_id: Run identifier for correlation.
97
+ event_type: State label for state events (e.g., "start", "success").
98
+ payload: Structured payload for downstream consumers.
99
+ source: Event origin (framework should pass "tty"/"rpc").
100
+ ts: Unix timestamp (seconds) when the event was created.
101
+ seq: Per-run sequence number assigned by the executor.
102
+ """
103
+ def __init__(self, kind, msg=None, level=UIEventLevel.TEXT, run_id=None, event_type=None,
104
+ payload=None, source=None, ts=None, seq=None):
105
+ self.kind = _normalize_runtime_kind(kind)
106
+ super().__init__(
107
+ msg=msg,
108
+ level=level,
109
+ run_id=run_id,
110
+ event_type=event_type,
111
+ payload=payload,
112
+ source=source,
113
+ ts=ts,
114
+ seq=seq,
115
+ )
116
+
117
+ def to_ui_event(self):
118
+ return UIEvent(
119
+ msg=self.msg,
120
+ level=self.level,
121
+ run_id=self.run_id,
122
+ event_type=self.event_type,
123
+ payload=self.payload,
124
+ source=self.source,
125
+ ts=self.ts,
126
+ seq=self.seq,
127
+ )
128
+
129
+
130
+ class UIEventListener:
131
+ def handler_event(self, event: UIEvent):
132
+ pass
133
+
134
+
135
+ class UIEventSpeaker:
136
+ def __init__(self):
137
+ self._event_listener = []
138
+
139
+ def add_event_listener(self, listener: UIEventListener):
140
+ self._event_listener.append(listener)
141
+
142
+ def remove_event_listener(self, listener: UIEventListener):
143
+ self._event_listener.remove(listener)
144
+
145
+ def notify_event_listeners(self, event: UIEvent):
146
+ for listener in self._event_listener:
147
+ listener.handler_event(event)
@@ -0,0 +1,62 @@
1
+ import contextvars
2
+ import threading
3
+ from contextlib import contextmanager
4
+ from typing import Optional, TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from python_tty.runtime.router import BaseRouter
8
+
9
+
10
+ class RouterProvider:
11
+ def __init__(self):
12
+ self._default_router: Optional["BaseRouter"] = None
13
+ self._lock = threading.Lock()
14
+ self._current_router = contextvars.ContextVar("python_tty_current_router", default=None)
15
+
16
+ def set_default_router(self, router: Optional["BaseRouter"]):
17
+ with self._lock:
18
+ self._default_router = router
19
+ return router
20
+
21
+ def get_default_router(self) -> Optional["BaseRouter"]:
22
+ with self._lock:
23
+ return self._default_router
24
+
25
+ def get_router(self) -> Optional["BaseRouter"]:
26
+ current = self._current_router.get()
27
+ if current is not None:
28
+ return current
29
+ return self.get_default_router()
30
+
31
+ def set_current_router(self, router: Optional["BaseRouter"]):
32
+ return self._current_router.set(router)
33
+
34
+ def reset_current_router(self, token):
35
+ self._current_router.reset(token)
36
+
37
+ @contextmanager
38
+ def use_router(self, router: Optional["BaseRouter"]):
39
+ token = self._current_router.set(router)
40
+ try:
41
+ yield router
42
+ finally:
43
+ self._current_router.reset(token)
44
+
45
+
46
+ _PROVIDER = RouterProvider()
47
+
48
+
49
+ def set_default_router(router):
50
+ return _PROVIDER.set_default_router(router)
51
+
52
+
53
+ def get_default_router():
54
+ return _PROVIDER.get_default_router()
55
+
56
+
57
+ def get_router():
58
+ return _PROVIDER.get_router()
59
+
60
+
61
+ def use_router(router):
62
+ return _PROVIDER.use_router(router)
@@ -1,10 +1,13 @@
1
1
  import threading
2
+ from abc import ABC, abstractmethod
3
+ from typing import Optional
2
4
 
3
5
  from prompt_toolkit import print_formatted_text
4
6
  from prompt_toolkit.formatted_text import FormattedText
5
7
  from prompt_toolkit.styles import Style
6
8
 
7
- from python_tty.ui.events import UIEvent, UIEventLevel
9
+ from python_tty.runtime.events import RuntimeEvent, RuntimeEventKind, UIEvent, UIEventLevel
10
+ from python_tty.runtime.provider import get_router
8
11
 
9
12
 
10
13
  MSG_LEVEL_SYMBOL = {
@@ -26,11 +29,18 @@ MSG_LEVEL_SYMBOL_STYLE = {
26
29
  }
27
30
 
28
31
 
29
- class OutputRouter:
32
+ class BaseRouter(ABC):
33
+ @abstractmethod
34
+ def emit(self, event):
35
+ raise NotImplementedError
36
+
37
+
38
+ class OutputRouter(BaseRouter):
30
39
  def __init__(self):
31
40
  self._lock = threading.Lock()
32
41
  self._app = None
33
42
  self._output = None
43
+ self._audit_sink = None
34
44
 
35
45
  def bind_session(self, session):
36
46
  if session is None:
@@ -45,10 +55,34 @@ class OutputRouter:
45
55
  self._app = None
46
56
  self._output = None
47
57
 
48
- def emit(self, event: UIEvent):
58
+ def attach_audit_sink(self, audit_sink):
59
+ with self._lock:
60
+ self._audit_sink = audit_sink
61
+
62
+ def clear_audit_sink(self, audit_sink=None):
63
+ with self._lock:
64
+ if audit_sink is None or audit_sink == self._audit_sink:
65
+ self._audit_sink = None
66
+
67
+ @property
68
+ def audit_sink(self):
69
+ with self._lock:
70
+ return self._audit_sink
71
+
72
+ def emit(self, event):
73
+ audit_event = event
74
+ if isinstance(event, RuntimeEvent):
75
+ if event.kind in (RuntimeEventKind.STDOUT, RuntimeEventKind.STATE, RuntimeEventKind.LOG):
76
+ event = event.to_ui_event()
77
+ else:
78
+ return
49
79
  with self._lock:
50
80
  app = self._app
51
81
  output = self._output
82
+ audit_sink = self._audit_sink
83
+
84
+ if audit_sink is not None:
85
+ audit_sink.record_event(audit_event)
52
86
 
53
87
  def _render():
54
88
  text, style = _format_event(event)
@@ -89,14 +123,22 @@ def _format_event(event: UIEvent):
89
123
  return formatted_text, style
90
124
 
91
125
 
92
- _OUTPUT_ROUTER = OutputRouter()
93
-
126
+ def get_output_router() -> Optional[BaseRouter]:
127
+ return get_router()
94
128
 
95
- def get_output_router() -> OutputRouter:
96
- return _OUTPUT_ROUTER
97
129
 
130
+ def proxy_print(text="", text_type=UIEventLevel.TEXT, source="custom", run_id=None):
131
+ """Emit a UIEvent for display.
98
132
 
99
- def proxy_print(text="", text_type=UIEventLevel.TEXT):
100
- event = UIEvent(msg=text, level=_normalize_level(text_type))
101
- get_output_router().emit(event)
102
-
133
+ Args:
134
+ text: Display text or object to render.
135
+ text_type: UIEventLevel or int.
136
+ source: Event source. Use "tty"/"rpc" for framework events.
137
+ External callers can rely on the default "custom".
138
+ run_id: Optional run identifier to correlate output with an invocation.
139
+ """
140
+ event = UIEvent(msg=text, level=_normalize_level(text_type), source=source, run_id=run_id)
141
+ router = get_router()
142
+ if router is None:
143
+ return
144
+ router.emit(event)