python-tty 0.1.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.
- python_tty/__init__.py +15 -0
- python_tty/commands/__init__.py +24 -0
- python_tty/commands/core.py +119 -0
- python_tty/commands/decorators.py +58 -0
- python_tty/commands/examples/__init__.py +8 -0
- python_tty/commands/examples/root_commands.py +33 -0
- python_tty/commands/examples/sub_commands.py +11 -0
- python_tty/commands/general.py +107 -0
- python_tty/commands/mixins.py +52 -0
- python_tty/commands/registry.py +185 -0
- python_tty/config/__init__.py +9 -0
- python_tty/config/config.py +35 -0
- python_tty/console_factory.py +100 -0
- python_tty/consoles/__init__.py +16 -0
- python_tty/consoles/core.py +140 -0
- python_tty/consoles/decorators.py +42 -0
- python_tty/consoles/examples/__init__.py +8 -0
- python_tty/consoles/examples/root_console.py +34 -0
- python_tty/consoles/examples/sub_console.py +34 -0
- python_tty/consoles/loader.py +14 -0
- python_tty/consoles/manager.py +146 -0
- python_tty/consoles/registry.py +102 -0
- python_tty/exceptions/__init__.py +7 -0
- python_tty/exceptions/console_exception.py +12 -0
- python_tty/executor/__init__.py +10 -0
- python_tty/executor/executor.py +335 -0
- python_tty/executor/models.py +38 -0
- python_tty/frontends/__init__.py +0 -0
- python_tty/meta/__init__.py +0 -0
- python_tty/ui/__init__.py +13 -0
- python_tty/ui/events.py +55 -0
- python_tty/ui/output.py +102 -0
- python_tty/utils/__init__.py +13 -0
- python_tty/utils/table.py +126 -0
- python_tty/utils/tokenize.py +45 -0
- python_tty/utils/ui_logger.py +17 -0
- python_tty-0.1.0.dist-info/METADATA +66 -0
- python_tty-0.1.0.dist-info/RECORD +40 -0
- python_tty-0.1.0.dist-info/WHEEL +5 -0
- python_tty-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import inspect
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Callable, Dict, Optional
|
|
7
|
+
|
|
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
|
|
11
|
+
from python_tty.executor.models import Invocation, RunState, RunStatus
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class WorkItem:
|
|
16
|
+
invocation: Invocation
|
|
17
|
+
handler: Callable[[Invocation], object]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CommandExecutor:
|
|
21
|
+
def __init__(self, workers: int = 1, loop=None, config: ExecutorConfig = None):
|
|
22
|
+
if config is None:
|
|
23
|
+
config = ExecutorConfig(workers=workers)
|
|
24
|
+
self._config = config
|
|
25
|
+
self._worker_count = config.workers
|
|
26
|
+
self._loop = loop
|
|
27
|
+
self._queue = None
|
|
28
|
+
self._workers = []
|
|
29
|
+
self._locks: Dict[str, asyncio.Lock] = {}
|
|
30
|
+
self._runs: Dict[str, RunState] = {}
|
|
31
|
+
self._event_queues: Dict[str, asyncio.Queue] = {}
|
|
32
|
+
self._run_futures: Dict[str, asyncio.Future] = {}
|
|
33
|
+
self._retain_last_n = config.retain_last_n
|
|
34
|
+
self._ttl_seconds = config.ttl_seconds
|
|
35
|
+
self._pop_on_wait = config.pop_on_wait
|
|
36
|
+
self._output_router = get_output_router()
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def runs(self):
|
|
40
|
+
return self._runs
|
|
41
|
+
|
|
42
|
+
def start(self, loop=None):
|
|
43
|
+
if loop is not None:
|
|
44
|
+
self._loop = loop
|
|
45
|
+
if self._loop is None:
|
|
46
|
+
try:
|
|
47
|
+
self._loop = asyncio.get_running_loop()
|
|
48
|
+
except RuntimeError as exc:
|
|
49
|
+
raise RuntimeError("Executor start requires a running event loop") from exc
|
|
50
|
+
if self._queue is None:
|
|
51
|
+
self._queue = asyncio.Queue()
|
|
52
|
+
if self._workers:
|
|
53
|
+
return
|
|
54
|
+
for _ in range(self._worker_count):
|
|
55
|
+
self._workers.append(self._loop.create_task(self._worker_loop()))
|
|
56
|
+
|
|
57
|
+
def submit(self, invocation: Invocation, handler: Optional[Callable[[Invocation], object]] = None) -> str:
|
|
58
|
+
if invocation.run_id is None:
|
|
59
|
+
invocation.run_id = str(uuid.uuid4())
|
|
60
|
+
if handler is None:
|
|
61
|
+
handler = self._missing_handler
|
|
62
|
+
run_id = invocation.run_id
|
|
63
|
+
self._runs[run_id] = RunState(run_id=run_id)
|
|
64
|
+
if self._loop is None:
|
|
65
|
+
try:
|
|
66
|
+
self._loop = asyncio.get_running_loop()
|
|
67
|
+
except RuntimeError:
|
|
68
|
+
self._loop = None
|
|
69
|
+
if self._loop is None or not self._loop.is_running():
|
|
70
|
+
self._run_inline(invocation, handler)
|
|
71
|
+
return run_id
|
|
72
|
+
self.start()
|
|
73
|
+
self._run_futures[run_id] = self._loop.create_future()
|
|
74
|
+
self._queue.put_nowait(WorkItem(invocation=invocation, handler=handler))
|
|
75
|
+
return run_id
|
|
76
|
+
|
|
77
|
+
async def wait_result(self, run_id: str):
|
|
78
|
+
try:
|
|
79
|
+
future = self._run_futures.get(run_id)
|
|
80
|
+
if future is None:
|
|
81
|
+
run_state = self._runs.get(run_id)
|
|
82
|
+
if run_state is None:
|
|
83
|
+
return None
|
|
84
|
+
if run_state.error is not None:
|
|
85
|
+
raise run_state.error
|
|
86
|
+
return run_state.result
|
|
87
|
+
return await future
|
|
88
|
+
finally:
|
|
89
|
+
if self._pop_on_wait:
|
|
90
|
+
self.pop_run(run_id)
|
|
91
|
+
|
|
92
|
+
def wait_result_sync(self, run_id: str, timeout: Optional[float] = None):
|
|
93
|
+
try:
|
|
94
|
+
try:
|
|
95
|
+
running_loop = asyncio.get_running_loop()
|
|
96
|
+
except RuntimeError:
|
|
97
|
+
running_loop = None
|
|
98
|
+
if running_loop is not None and running_loop == self._loop:
|
|
99
|
+
raise RuntimeError("wait_result_sync cannot be called from the executor loop thread")
|
|
100
|
+
future = self._run_futures.get(run_id)
|
|
101
|
+
if future is not None and self._loop is not None and self._loop.is_running():
|
|
102
|
+
result_future = asyncio.run_coroutine_threadsafe(self.wait_result(run_id), self._loop)
|
|
103
|
+
return result_future.result(timeout)
|
|
104
|
+
run_state = self._runs.get(run_id)
|
|
105
|
+
if run_state is None:
|
|
106
|
+
return None
|
|
107
|
+
if run_state.status in (RunStatus.PENDING, RunStatus.RUNNING):
|
|
108
|
+
if self._loop is not None and self._loop.is_running():
|
|
109
|
+
result_future = asyncio.run_coroutine_threadsafe(self.wait_result(run_id), self._loop)
|
|
110
|
+
return result_future.result(timeout)
|
|
111
|
+
raise RuntimeError("Run is still pending but executor loop is not running")
|
|
112
|
+
if run_state.error is not None:
|
|
113
|
+
raise run_state.error
|
|
114
|
+
return run_state.result
|
|
115
|
+
finally:
|
|
116
|
+
if self._pop_on_wait:
|
|
117
|
+
self.pop_run(run_id)
|
|
118
|
+
|
|
119
|
+
def stream_events(self, run_id: str):
|
|
120
|
+
if self._loop is None or not self._loop.is_running():
|
|
121
|
+
raise RuntimeError("Event loop is not running")
|
|
122
|
+
return self._ensure_event_queue(run_id)
|
|
123
|
+
|
|
124
|
+
def publish_event(self, run_id: str, event):
|
|
125
|
+
if getattr(event, "run_id", None) is None:
|
|
126
|
+
event.run_id = run_id
|
|
127
|
+
if self._loop is not None and self._loop.is_running():
|
|
128
|
+
queue = self._ensure_event_queue(run_id)
|
|
129
|
+
self._queue_event(queue, event)
|
|
130
|
+
if self._output_router is not None:
|
|
131
|
+
self._output_router.emit(event)
|
|
132
|
+
|
|
133
|
+
async def shutdown(self, wait: bool = True):
|
|
134
|
+
workers = list(self._workers)
|
|
135
|
+
self._workers.clear()
|
|
136
|
+
for task in workers:
|
|
137
|
+
task.cancel()
|
|
138
|
+
if wait and workers:
|
|
139
|
+
await asyncio.gather(*workers, return_exceptions=True)
|
|
140
|
+
|
|
141
|
+
def shutdown_threadsafe(self, wait: bool = True, timeout: Optional[float] = None):
|
|
142
|
+
loop = self._loop
|
|
143
|
+
if loop is None or not loop.is_running():
|
|
144
|
+
for task in list(self._workers):
|
|
145
|
+
task.cancel()
|
|
146
|
+
self._workers.clear()
|
|
147
|
+
return None
|
|
148
|
+
future = asyncio.run_coroutine_threadsafe(self.shutdown(wait=wait), loop)
|
|
149
|
+
return future.result(timeout)
|
|
150
|
+
|
|
151
|
+
def submit_threadsafe(self, invocation: Invocation,
|
|
152
|
+
handler: Optional[Callable[[Invocation], object]] = None) -> str:
|
|
153
|
+
if invocation.run_id is None:
|
|
154
|
+
invocation.run_id = str(uuid.uuid4())
|
|
155
|
+
if handler is None:
|
|
156
|
+
handler = self._missing_handler
|
|
157
|
+
run_id = invocation.run_id
|
|
158
|
+
self._runs[run_id] = RunState(run_id=run_id)
|
|
159
|
+
if self._loop is None or not self._loop.is_running():
|
|
160
|
+
return self.submit(invocation, handler=handler)
|
|
161
|
+
try:
|
|
162
|
+
running_loop = asyncio.get_running_loop()
|
|
163
|
+
except RuntimeError:
|
|
164
|
+
running_loop = None
|
|
165
|
+
if running_loop == self._loop:
|
|
166
|
+
return self.submit(invocation, handler=handler)
|
|
167
|
+
|
|
168
|
+
async def _enqueue():
|
|
169
|
+
self.start()
|
|
170
|
+
self._run_futures[run_id] = self._loop.create_future()
|
|
171
|
+
self._queue.put_nowait(WorkItem(invocation=invocation, handler=handler))
|
|
172
|
+
|
|
173
|
+
asyncio.run_coroutine_threadsafe(_enqueue(), self._loop).result()
|
|
174
|
+
return run_id
|
|
175
|
+
|
|
176
|
+
async def _worker_loop(self):
|
|
177
|
+
while True:
|
|
178
|
+
work_item = await self._queue.get()
|
|
179
|
+
run_state = self._runs.get(work_item.invocation.run_id)
|
|
180
|
+
lock = self._locks.setdefault(work_item.invocation.lock_key, asyncio.Lock())
|
|
181
|
+
async with lock:
|
|
182
|
+
await self._execute_work_item(work_item, run_state)
|
|
183
|
+
self._queue.task_done()
|
|
184
|
+
|
|
185
|
+
async def _execute_work_item(self, work_item: WorkItem, run_state: Optional[RunState]):
|
|
186
|
+
if run_state is None:
|
|
187
|
+
return
|
|
188
|
+
run_state.status = RunStatus.RUNNING
|
|
189
|
+
run_state.started_at = time.time()
|
|
190
|
+
self.publish_event(run_state.run_id, self._build_run_event("start", UIEventLevel.INFO))
|
|
191
|
+
try:
|
|
192
|
+
result = work_item.handler(work_item.invocation)
|
|
193
|
+
if inspect.isawaitable(result):
|
|
194
|
+
result = await result
|
|
195
|
+
run_state.result = result
|
|
196
|
+
run_state.status = RunStatus.SUCCEEDED
|
|
197
|
+
self.publish_event(run_state.run_id, self._build_run_event("success", UIEventLevel.SUCCESS))
|
|
198
|
+
self._resolve_future(run_state, result=result)
|
|
199
|
+
except Exception as exc:
|
|
200
|
+
run_state.error = exc
|
|
201
|
+
run_state.status = RunStatus.FAILED
|
|
202
|
+
self.publish_event(
|
|
203
|
+
run_state.run_id,
|
|
204
|
+
self._build_run_event("failure", UIEventLevel.ERROR, payload={"error": str(exc)}),
|
|
205
|
+
)
|
|
206
|
+
self._resolve_future(run_state, error=exc)
|
|
207
|
+
finally:
|
|
208
|
+
run_state.finished_at = time.time()
|
|
209
|
+
self._cleanup_runs()
|
|
210
|
+
|
|
211
|
+
def _resolve_future(self, run_state: RunState, result=None, error: Optional[BaseException] = None):
|
|
212
|
+
future = self._run_futures.get(run_state.run_id)
|
|
213
|
+
if future is None or future.done():
|
|
214
|
+
return
|
|
215
|
+
if error is not None:
|
|
216
|
+
future.set_exception(error)
|
|
217
|
+
else:
|
|
218
|
+
future.set_result(result)
|
|
219
|
+
|
|
220
|
+
def _run_inline(self, invocation: Invocation, handler):
|
|
221
|
+
run_state = self._runs.get(invocation.run_id)
|
|
222
|
+
if run_state is None:
|
|
223
|
+
return
|
|
224
|
+
run_state.status = RunStatus.RUNNING
|
|
225
|
+
run_state.started_at = time.time()
|
|
226
|
+
self.publish_event(run_state.run_id, self._build_run_event("start", UIEventLevel.INFO))
|
|
227
|
+
try:
|
|
228
|
+
result = handler(invocation)
|
|
229
|
+
if inspect.isawaitable(result):
|
|
230
|
+
result = self._run_awaitable_inline(result)
|
|
231
|
+
run_state.result = result
|
|
232
|
+
run_state.status = RunStatus.SUCCEEDED
|
|
233
|
+
self.publish_event(run_state.run_id, self._build_run_event("success", UIEventLevel.SUCCESS))
|
|
234
|
+
except Exception as exc:
|
|
235
|
+
run_state.error = exc
|
|
236
|
+
run_state.status = RunStatus.FAILED
|
|
237
|
+
self.publish_event(
|
|
238
|
+
run_state.run_id,
|
|
239
|
+
self._build_run_event("failure", UIEventLevel.ERROR, payload={"error": str(exc)}),
|
|
240
|
+
)
|
|
241
|
+
finally:
|
|
242
|
+
run_state.finished_at = time.time()
|
|
243
|
+
self._cleanup_runs()
|
|
244
|
+
|
|
245
|
+
@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)
|
|
248
|
+
|
|
249
|
+
@staticmethod
|
|
250
|
+
def _missing_handler(invocation: Invocation):
|
|
251
|
+
raise RuntimeError("No handler provided for invocation execution")
|
|
252
|
+
|
|
253
|
+
def _ensure_event_queue(self, run_id: str):
|
|
254
|
+
if self._loop is None or not self._loop.is_running():
|
|
255
|
+
return self._event_queues.setdefault(run_id, asyncio.Queue())
|
|
256
|
+
try:
|
|
257
|
+
running_loop = asyncio.get_running_loop()
|
|
258
|
+
except RuntimeError:
|
|
259
|
+
running_loop = None
|
|
260
|
+
if running_loop == self._loop:
|
|
261
|
+
return self._event_queues.setdefault(run_id, asyncio.Queue())
|
|
262
|
+
future = asyncio.run_coroutine_threadsafe(self._create_event_queue(run_id), self._loop)
|
|
263
|
+
return future.result()
|
|
264
|
+
|
|
265
|
+
async def _create_event_queue(self, run_id: str):
|
|
266
|
+
return self._event_queues.setdefault(run_id, asyncio.Queue())
|
|
267
|
+
|
|
268
|
+
def _queue_event(self, queue: asyncio.Queue, event):
|
|
269
|
+
try:
|
|
270
|
+
running_loop = asyncio.get_running_loop()
|
|
271
|
+
except RuntimeError:
|
|
272
|
+
running_loop = None
|
|
273
|
+
if running_loop == self._loop:
|
|
274
|
+
queue.put_nowait(event)
|
|
275
|
+
else:
|
|
276
|
+
self._loop.call_soon_threadsafe(queue.put_nowait, event)
|
|
277
|
+
|
|
278
|
+
def _run_awaitable_inline(self, awaitable):
|
|
279
|
+
try:
|
|
280
|
+
running_loop = asyncio.get_running_loop()
|
|
281
|
+
except RuntimeError:
|
|
282
|
+
running_loop = None
|
|
283
|
+
if asyncio.isfuture(awaitable):
|
|
284
|
+
raise RuntimeError("Inline awaitable must be a coroutine, not a Future/Task")
|
|
285
|
+
if running_loop is None:
|
|
286
|
+
return asyncio.run(self._awaitable_to_coroutine(awaitable))
|
|
287
|
+
if self._loop is not None and self._loop.is_running():
|
|
288
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
289
|
+
self._awaitable_to_coroutine(awaitable),
|
|
290
|
+
self._loop,
|
|
291
|
+
)
|
|
292
|
+
return future.result()
|
|
293
|
+
raise RuntimeError("Cannot run awaitable inline while an event loop is running")
|
|
294
|
+
|
|
295
|
+
@staticmethod
|
|
296
|
+
def _awaitable_to_coroutine(awaitable):
|
|
297
|
+
if asyncio.iscoroutine(awaitable):
|
|
298
|
+
return awaitable
|
|
299
|
+
|
|
300
|
+
async def _await_obj():
|
|
301
|
+
return await awaitable
|
|
302
|
+
|
|
303
|
+
return _await_obj()
|
|
304
|
+
|
|
305
|
+
def pop_run(self, run_id: str):
|
|
306
|
+
run_state = self._runs.pop(run_id, None)
|
|
307
|
+
future = self._run_futures.pop(run_id, None)
|
|
308
|
+
if future is not None and not future.done():
|
|
309
|
+
future.cancel()
|
|
310
|
+
self._event_queues.pop(run_id, None)
|
|
311
|
+
return run_state
|
|
312
|
+
|
|
313
|
+
def _cleanup_runs(self):
|
|
314
|
+
if self._retain_last_n is None and self._ttl_seconds is None:
|
|
315
|
+
return
|
|
316
|
+
now = time.time()
|
|
317
|
+
completed = []
|
|
318
|
+
for run_id, run_state in self._runs.items():
|
|
319
|
+
if run_state.status in (RunStatus.PENDING, RunStatus.RUNNING):
|
|
320
|
+
continue
|
|
321
|
+
completed.append((run_state.finished_at, run_id))
|
|
322
|
+
remove_ids = set()
|
|
323
|
+
if self._ttl_seconds is not None:
|
|
324
|
+
for finished_at, run_id in completed:
|
|
325
|
+
if finished_at is None:
|
|
326
|
+
continue
|
|
327
|
+
if now - finished_at >= self._ttl_seconds:
|
|
328
|
+
remove_ids.add(run_id)
|
|
329
|
+
if self._retain_last_n is not None and self._retain_last_n >= 0:
|
|
330
|
+
completed.sort(reverse=True)
|
|
331
|
+
for _, run_id in completed[self._retain_last_n:]:
|
|
332
|
+
remove_ids.add(run_id)
|
|
333
|
+
for run_id in remove_ids:
|
|
334
|
+
self.pop_run(run_id)
|
|
335
|
+
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RunStatus(Enum):
|
|
7
|
+
PENDING = "pending"
|
|
8
|
+
RUNNING = "running"
|
|
9
|
+
SUCCEEDED = "succeeded"
|
|
10
|
+
FAILED = "failed"
|
|
11
|
+
CANCELLED = "cancelled"
|
|
12
|
+
TIMEOUT = "timeout"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Invocation:
|
|
17
|
+
run_id: Optional[str] = None
|
|
18
|
+
source: str = "tty"
|
|
19
|
+
principal: Optional[str] = None
|
|
20
|
+
console_id: Optional[str] = None
|
|
21
|
+
command_id: Optional[str] = None
|
|
22
|
+
command_name: Optional[str] = None
|
|
23
|
+
argv: List[str] = field(default_factory=list)
|
|
24
|
+
kwargs: Dict[str, Any] = field(default_factory=dict)
|
|
25
|
+
lock_key: str = "global"
|
|
26
|
+
timeout_ms: Optional[int] = None
|
|
27
|
+
audit_policy: Optional[str] = None
|
|
28
|
+
raw_cmd: Optional[str] = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class RunState:
|
|
33
|
+
run_id: str
|
|
34
|
+
status: RunStatus = RunStatus.PENDING
|
|
35
|
+
result: Any = None
|
|
36
|
+
error: Optional[BaseException] = None
|
|
37
|
+
started_at: Optional[float] = None
|
|
38
|
+
finished_at: Optional[float] = None
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from python_tty.ui.events import UIEvent, UIEventLevel, UIEventListener, UIEventSpeaker
|
|
2
|
+
from python_tty.ui.output import OutputRouter, get_output_router, proxy_print
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"UIEvent",
|
|
6
|
+
"UIEventLevel",
|
|
7
|
+
"UIEventListener",
|
|
8
|
+
"UIEventSpeaker",
|
|
9
|
+
"OutputRouter",
|
|
10
|
+
"get_output_router",
|
|
11
|
+
"proxy_print",
|
|
12
|
+
]
|
|
13
|
+
|
python_tty/ui/events.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class UIEventLevel(enum.Enum):
|
|
5
|
+
TEXT = -1
|
|
6
|
+
INFO = 0
|
|
7
|
+
WARNING = 1
|
|
8
|
+
ERROR = 2
|
|
9
|
+
SUCCESS = 3
|
|
10
|
+
FAILURE = 4
|
|
11
|
+
DEBUG = 5
|
|
12
|
+
|
|
13
|
+
@staticmethod
|
|
14
|
+
def map_level(code):
|
|
15
|
+
if code == 0:
|
|
16
|
+
return UIEventLevel.INFO
|
|
17
|
+
elif code == 1:
|
|
18
|
+
return UIEventLevel.WARNING
|
|
19
|
+
elif code == 2:
|
|
20
|
+
return UIEventLevel.ERROR
|
|
21
|
+
elif code == 3:
|
|
22
|
+
return UIEventLevel.SUCCESS
|
|
23
|
+
elif code == 4:
|
|
24
|
+
return UIEventLevel.FAILURE
|
|
25
|
+
elif code == 5:
|
|
26
|
+
return UIEventLevel.DEBUG
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class UIEvent:
|
|
30
|
+
def __init__(self, msg, level=UIEventLevel.TEXT, run_id=None, event_type=None, payload=None):
|
|
31
|
+
self.msg = msg
|
|
32
|
+
self.level = level
|
|
33
|
+
self.run_id = run_id
|
|
34
|
+
self.event_type = event_type
|
|
35
|
+
self.payload = payload
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class UIEventListener:
|
|
39
|
+
def handler_event(self, event: UIEvent):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class UIEventSpeaker:
|
|
44
|
+
def __init__(self):
|
|
45
|
+
self._event_listener = []
|
|
46
|
+
|
|
47
|
+
def add_event_listener(self, listener: UIEventListener):
|
|
48
|
+
self._event_listener.append(listener)
|
|
49
|
+
|
|
50
|
+
def remove_event_listener(self, listener: UIEventListener):
|
|
51
|
+
self._event_listener.remove(listener)
|
|
52
|
+
|
|
53
|
+
def notify_event_listeners(self, event: UIEvent):
|
|
54
|
+
for listener in self._event_listener:
|
|
55
|
+
listener.handler_event(event)
|
python_tty/ui/output.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
|
|
3
|
+
from prompt_toolkit import print_formatted_text
|
|
4
|
+
from prompt_toolkit.formatted_text import FormattedText
|
|
5
|
+
from prompt_toolkit.styles import Style
|
|
6
|
+
|
|
7
|
+
from python_tty.ui.events import UIEvent, UIEventLevel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
MSG_LEVEL_SYMBOL = {
|
|
11
|
+
0: "[*] ",
|
|
12
|
+
1: "[!] ",
|
|
13
|
+
2: "[x] ",
|
|
14
|
+
3: "[+] ",
|
|
15
|
+
4: "[-] ",
|
|
16
|
+
5: "[@] "
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
MSG_LEVEL_SYMBOL_STYLE = {
|
|
20
|
+
0: "fg:green",
|
|
21
|
+
1: "fg:yellow",
|
|
22
|
+
2: "fg:red",
|
|
23
|
+
3: "fg:blue",
|
|
24
|
+
4: "fg:white",
|
|
25
|
+
5: "fg:pink"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OutputRouter:
|
|
30
|
+
def __init__(self):
|
|
31
|
+
self._lock = threading.Lock()
|
|
32
|
+
self._app = None
|
|
33
|
+
self._output = None
|
|
34
|
+
|
|
35
|
+
def bind_session(self, session):
|
|
36
|
+
if session is None:
|
|
37
|
+
return
|
|
38
|
+
with self._lock:
|
|
39
|
+
self._app = getattr(session, "app", None)
|
|
40
|
+
self._output = getattr(session, "output", None)
|
|
41
|
+
|
|
42
|
+
def clear_session(self, session=None):
|
|
43
|
+
with self._lock:
|
|
44
|
+
if session is None or getattr(session, "app", None) == self._app:
|
|
45
|
+
self._app = None
|
|
46
|
+
self._output = None
|
|
47
|
+
|
|
48
|
+
def emit(self, event: UIEvent):
|
|
49
|
+
with self._lock:
|
|
50
|
+
app = self._app
|
|
51
|
+
output = self._output
|
|
52
|
+
|
|
53
|
+
def _render():
|
|
54
|
+
text, style = _format_event(event)
|
|
55
|
+
if output is not None:
|
|
56
|
+
print_formatted_text(text, style=style, output=output)
|
|
57
|
+
else:
|
|
58
|
+
print_formatted_text(text, style=style)
|
|
59
|
+
|
|
60
|
+
if app is not None and getattr(app, "is_running", False):
|
|
61
|
+
if hasattr(app, "call_from_executor") and hasattr(app, "run_in_terminal"):
|
|
62
|
+
app.call_from_executor(lambda: app.run_in_terminal(_render))
|
|
63
|
+
return
|
|
64
|
+
_render()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _normalize_level(level):
|
|
68
|
+
if isinstance(level, UIEventLevel):
|
|
69
|
+
return level
|
|
70
|
+
if level is None:
|
|
71
|
+
return UIEventLevel.TEXT
|
|
72
|
+
if level == UIEventLevel.TEXT.value:
|
|
73
|
+
return UIEventLevel.TEXT
|
|
74
|
+
mapped = UIEventLevel.map_level(level)
|
|
75
|
+
return UIEventLevel.TEXT if mapped is None else mapped
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _format_event(event: UIEvent):
|
|
79
|
+
level = _normalize_level(event.level)
|
|
80
|
+
if level == UIEventLevel.TEXT:
|
|
81
|
+
return event.msg, None
|
|
82
|
+
formatted_text = FormattedText([
|
|
83
|
+
("class:level", MSG_LEVEL_SYMBOL[level.value]),
|
|
84
|
+
("class:text", str(event.msg)),
|
|
85
|
+
])
|
|
86
|
+
style = Style.from_dict({
|
|
87
|
+
"level": MSG_LEVEL_SYMBOL_STYLE[level.value]
|
|
88
|
+
})
|
|
89
|
+
return formatted_text, style
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
_OUTPUT_ROUTER = OutputRouter()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_output_router() -> OutputRouter:
|
|
96
|
+
return _OUTPUT_ROUTER
|
|
97
|
+
|
|
98
|
+
|
|
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
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from python_tty.utils.table import Table
|
|
2
|
+
from python_tty.utils.tokenize import get_command_token, get_func_param_strs, split_cmd, tokenize_cmd
|
|
3
|
+
from python_tty.utils.ui_logger import ConsoleHandler
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"ConsoleHandler",
|
|
7
|
+
"Table",
|
|
8
|
+
"get_command_token",
|
|
9
|
+
"get_func_param_strs",
|
|
10
|
+
"split_cmd",
|
|
11
|
+
"tokenize_cmd",
|
|
12
|
+
]
|
|
13
|
+
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Cell:
|
|
5
|
+
def __init__(self, data):
|
|
6
|
+
self.data = data
|
|
7
|
+
self.data_str = str(self.data)
|
|
8
|
+
self.data_width = len(self.data_str)
|
|
9
|
+
self.padding = ""
|
|
10
|
+
|
|
11
|
+
def update_max_width(self, padding_len: int):
|
|
12
|
+
if padding_len > 0:
|
|
13
|
+
self.padding = " " * padding_len
|
|
14
|
+
|
|
15
|
+
def __str__(self):
|
|
16
|
+
return "".join([self.data_str, self.padding])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HeaderCell(Cell):
|
|
20
|
+
def __init__(self, data, seq="-"):
|
|
21
|
+
super().__init__(data)
|
|
22
|
+
self.seq_str = self.data_width * seq
|
|
23
|
+
self.data_str = str(self.data)
|
|
24
|
+
|
|
25
|
+
def get_seq_str(self):
|
|
26
|
+
return "".join([self.seq_str, self.padding])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Table:
|
|
30
|
+
def __init__(self, header: [], data: [[]], title="",
|
|
31
|
+
title_indent=0, data_indent=4, data_seq_len=4,
|
|
32
|
+
title_seq="=", header_seq="-", header_footer=True):
|
|
33
|
+
self.title = title
|
|
34
|
+
self.title_indent = title_indent
|
|
35
|
+
self.data_indent = data_indent
|
|
36
|
+
self.data_seq_len = data_seq_len
|
|
37
|
+
self.title_seq = title_seq
|
|
38
|
+
self.header_seq = header_seq
|
|
39
|
+
self.header_footer = header_footer
|
|
40
|
+
self.header = copy.deepcopy(header)
|
|
41
|
+
self.data = copy.deepcopy(data)
|
|
42
|
+
self._padding_data()
|
|
43
|
+
self._format_header()
|
|
44
|
+
self._merge_data()
|
|
45
|
+
self._padding_max_width()
|
|
46
|
+
|
|
47
|
+
def _padding_data(self):
|
|
48
|
+
table_header_item_num = len(self.header)
|
|
49
|
+
for i in range(len(self.data)):
|
|
50
|
+
row = self.data[i]
|
|
51
|
+
if len(row) < table_header_item_num:
|
|
52
|
+
self.data[i].append("")
|
|
53
|
+
elif len(row) > table_header_item_num:
|
|
54
|
+
self.data[i] = row[:table_header_item_num]
|
|
55
|
+
|
|
56
|
+
def _format_header(self):
|
|
57
|
+
for i in range(len(self.header)):
|
|
58
|
+
cell = self.header[i]
|
|
59
|
+
self.header[i] = str(cell)[0:1].upper() + str(cell)[1:]
|
|
60
|
+
|
|
61
|
+
def _merge_data(self):
|
|
62
|
+
data = []
|
|
63
|
+
header = []
|
|
64
|
+
for cell in self.header:
|
|
65
|
+
header.append(HeaderCell(cell, self.header_seq))
|
|
66
|
+
data.append(header)
|
|
67
|
+
for row in self.data:
|
|
68
|
+
line = []
|
|
69
|
+
for cell in row:
|
|
70
|
+
line.append(Cell(cell))
|
|
71
|
+
data.append(line)
|
|
72
|
+
self.data = data
|
|
73
|
+
|
|
74
|
+
def _padding_max_width(self):
|
|
75
|
+
max_widths = [len(cell) for cell in self.header]
|
|
76
|
+
for i in range(len(self.data)):
|
|
77
|
+
for j in range(len(self.data[i])):
|
|
78
|
+
max_width = max_widths[j]
|
|
79
|
+
cell = self.data[i][j]
|
|
80
|
+
if cell.data_width > max_width:
|
|
81
|
+
max_widths[j] = cell.data_width
|
|
82
|
+
for i in range(len(self.data)):
|
|
83
|
+
for j in range(len(self.data[i])):
|
|
84
|
+
max_width = max_widths[j]
|
|
85
|
+
cell = self.data[i][j]
|
|
86
|
+
if cell.data_width < max_width:
|
|
87
|
+
cell.update_max_width(max_width - cell.data_width)
|
|
88
|
+
|
|
89
|
+
def print_row(self, row: []):
|
|
90
|
+
cells = []
|
|
91
|
+
seqs = []
|
|
92
|
+
for cell in row:
|
|
93
|
+
if isinstance(cell, HeaderCell):
|
|
94
|
+
seqs.append(cell.get_seq_str())
|
|
95
|
+
cells.append(str(cell))
|
|
96
|
+
if len(seqs) > 0:
|
|
97
|
+
return " "*self.data_indent + (" " * self.data_seq_len).join(cells),\
|
|
98
|
+
" "*self.data_indent + (" " * self.data_seq_len).join(seqs)
|
|
99
|
+
else:
|
|
100
|
+
return " "*self.data_indent + (" " * self.data_seq_len).join(cells), None
|
|
101
|
+
|
|
102
|
+
def print_data(self):
|
|
103
|
+
lines = []
|
|
104
|
+
for row in self.data:
|
|
105
|
+
line, seq = self.print_row(row)
|
|
106
|
+
lines.append(line)
|
|
107
|
+
if seq is not None:
|
|
108
|
+
lines.append(seq)
|
|
109
|
+
return "\n".join(lines)
|
|
110
|
+
|
|
111
|
+
def print_title(self):
|
|
112
|
+
title_str = str(self.title)
|
|
113
|
+
if title_str != "":
|
|
114
|
+
if not title_str[0:1].isupper():
|
|
115
|
+
title_str = title_str[0:1].upper() + title_str[1:].lower()
|
|
116
|
+
title_line = " "*self.title_indent + title_str
|
|
117
|
+
seq_line = " "*self.title_indent + self.title_seq*len(self.title)
|
|
118
|
+
return "\n".join([title_line, seq_line])
|
|
119
|
+
|
|
120
|
+
def __str__(self):
|
|
121
|
+
if str(self.title) != "":
|
|
122
|
+
title = self.print_title() + "\n\n"
|
|
123
|
+
table_str = title + self.print_data()
|
|
124
|
+
else:
|
|
125
|
+
table_str = self.print_data()
|
|
126
|
+
return "\n" + table_str + "\n" if self.header_footer else table_str
|