python-tty 0.1.3__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.
- python_tty/__init__.py +13 -3
- python_tty/audit/__init__.py +7 -0
- python_tty/audit/sink.py +141 -0
- python_tty/{utils → audit}/ui_logger.py +3 -4
- python_tty/commands/examples/root_commands.py +2 -3
- python_tty/commands/mixins.py +4 -5
- python_tty/config/__init__.py +8 -1
- python_tty/config/config.py +60 -3
- python_tty/console_factory.py +52 -3
- python_tty/consoles/core.py +5 -5
- python_tty/consoles/manager.py +5 -5
- python_tty/consoles/registry.py +33 -0
- python_tty/executor/executor.py +115 -16
- python_tty/frontends/rpc/__init__.py +0 -0
- python_tty/frontends/web/__init__.py +0 -0
- python_tty/meta/__init__.py +96 -0
- python_tty/runtime/__init__.py +29 -0
- python_tty/runtime/events.py +147 -0
- python_tty/runtime/provider.py +62 -0
- python_tty/{ui/output.py → runtime/router.py} +53 -11
- python_tty/utils/__init__.py +1 -3
- {python_tty-0.1.3.dist-info → python_tty-0.1.6.dist-info}/METADATA +5 -1
- python_tty-0.1.6.dist-info/RECORD +47 -0
- {python_tty-0.1.3.dist-info → python_tty-0.1.6.dist-info}/WHEEL +1 -1
- python_tty/ui/__init__.py +0 -13
- python_tty/ui/events.py +0 -55
- python_tty-0.1.3.dist-info/RECORD +0 -42
- {python_tty-0.1.3.dist-info → python_tty-0.1.6.dist-info}/licenses/LICENSE +0 -0
- {python_tty-0.1.3.dist-info → python_tty-0.1.6.dist-info}/licenses/NOTICE +0 -0
- {python_tty-0.1.3.dist-info → python_tty-0.1.6.dist-info}/top_level.txt +0 -0
python_tty/executor/executor.py
CHANGED
|
@@ -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.
|
|
10
|
-
from python_tty.
|
|
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.
|
|
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
|
-
|
|
131
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
236
|
+
self._emit_run_event(
|
|
203
237
|
run_state.run_id,
|
|
204
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
283
|
+
self._emit_run_event(
|
|
238
284
|
run_state.run_id,
|
|
239
|
-
|
|
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
|
|
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
|
python_tty/meta/__init__.py
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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)
|