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.
Files changed (40) hide show
  1. python_tty/__init__.py +15 -0
  2. python_tty/commands/__init__.py +24 -0
  3. python_tty/commands/core.py +119 -0
  4. python_tty/commands/decorators.py +58 -0
  5. python_tty/commands/examples/__init__.py +8 -0
  6. python_tty/commands/examples/root_commands.py +33 -0
  7. python_tty/commands/examples/sub_commands.py +11 -0
  8. python_tty/commands/general.py +107 -0
  9. python_tty/commands/mixins.py +52 -0
  10. python_tty/commands/registry.py +185 -0
  11. python_tty/config/__init__.py +9 -0
  12. python_tty/config/config.py +35 -0
  13. python_tty/console_factory.py +100 -0
  14. python_tty/consoles/__init__.py +16 -0
  15. python_tty/consoles/core.py +140 -0
  16. python_tty/consoles/decorators.py +42 -0
  17. python_tty/consoles/examples/__init__.py +8 -0
  18. python_tty/consoles/examples/root_console.py +34 -0
  19. python_tty/consoles/examples/sub_console.py +34 -0
  20. python_tty/consoles/loader.py +14 -0
  21. python_tty/consoles/manager.py +146 -0
  22. python_tty/consoles/registry.py +102 -0
  23. python_tty/exceptions/__init__.py +7 -0
  24. python_tty/exceptions/console_exception.py +12 -0
  25. python_tty/executor/__init__.py +10 -0
  26. python_tty/executor/executor.py +335 -0
  27. python_tty/executor/models.py +38 -0
  28. python_tty/frontends/__init__.py +0 -0
  29. python_tty/meta/__init__.py +0 -0
  30. python_tty/ui/__init__.py +13 -0
  31. python_tty/ui/events.py +55 -0
  32. python_tty/ui/output.py +102 -0
  33. python_tty/utils/__init__.py +13 -0
  34. python_tty/utils/table.py +126 -0
  35. python_tty/utils/tokenize.py +45 -0
  36. python_tty/utils/ui_logger.py +17 -0
  37. python_tty-0.1.0.dist-info/METADATA +66 -0
  38. python_tty-0.1.0.dist-info/RECORD +40 -0
  39. python_tty-0.1.0.dist-info/WHEEL +5 -0
  40. 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
+
@@ -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)
@@ -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