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 CHANGED
@@ -1,15 +1,25 @@
1
1
  from python_tty.config import Config
2
2
  from python_tty.console_factory import ConsoleFactory
3
- from python_tty.ui.events import UIEvent, UIEventLevel, UIEventListener, UIEventSpeaker
4
- from python_tty.ui.output import proxy_print
3
+ from python_tty.runtime.events import (
4
+ EventBase,
5
+ RuntimeEvent,
6
+ RuntimeEventKind,
7
+ UIEvent,
8
+ UIEventLevel,
9
+ UIEventListener,
10
+ UIEventSpeaker,
11
+ )
12
+ from python_tty.runtime.router import proxy_print
5
13
 
6
14
  __all__ = [
7
15
  "UIEvent",
8
16
  "UIEventLevel",
17
+ "EventBase",
18
+ "RuntimeEvent",
19
+ "RuntimeEventKind",
9
20
  "UIEventListener",
10
21
  "UIEventSpeaker",
11
22
  "ConsoleFactory",
12
23
  "Config",
13
24
  "proxy_print",
14
25
  ]
15
-
@@ -0,0 +1,7 @@
1
+ from python_tty.audit.sink import AuditSink
2
+ from python_tty.audit.ui_logger import ConsoleHandler
3
+
4
+ __all__ = [
5
+ "AuditSink",
6
+ "ConsoleHandler",
7
+ ]
@@ -0,0 +1,141 @@
1
+ import json
2
+ import queue
3
+ import threading
4
+ import time
5
+ from dataclasses import asdict, is_dataclass
6
+ from enum import Enum
7
+ from typing import Any, Iterable, Optional, TextIO
8
+
9
+
10
+ class AuditSink:
11
+ def __init__(self, file_path: Optional[str] = None, stream: Optional[TextIO] = None,
12
+ keep_in_memory: bool = False, async_mode: bool = False,
13
+ flush_interval: float = 1.0):
14
+ if file_path is not None and stream is not None:
15
+ raise ValueError("Only one of file_path or stream can be set")
16
+ self._path = file_path
17
+ self._stream = stream
18
+ self._owns_stream = False
19
+ if self._stream is None and self._path is not None:
20
+ self._stream = open(self._path, "a", encoding="utf-8")
21
+ self._owns_stream = True
22
+ self._buffer = [] if keep_in_memory else None
23
+ self._async_mode = async_mode and self._stream is not None
24
+ self._flush_interval = max(0.1, float(flush_interval))
25
+ self._lock = threading.Lock()
26
+ self._queue = None
27
+ self._stop_event = threading.Event()
28
+ self._worker = None
29
+ if self._async_mode:
30
+ self._queue = queue.Queue()
31
+ self._worker = threading.Thread(
32
+ target=self._worker_loop,
33
+ name="AuditSinkWriter",
34
+ daemon=True,
35
+ )
36
+ self._worker.start()
37
+
38
+ @property
39
+ def buffer(self):
40
+ return self._buffer
41
+
42
+ def record_invocation(self, invocation):
43
+ self._write("invocation", invocation)
44
+
45
+ def record_run_state(self, run_state):
46
+ self._write("run_state", run_state)
47
+
48
+ def record_event(self, event):
49
+ self._write("event", event)
50
+
51
+ def record_bundle(self, invocation=None, run_state=None, events: Optional[Iterable[Any]] = None):
52
+ if invocation is not None:
53
+ self.record_invocation(invocation)
54
+ if run_state is not None:
55
+ self.record_run_state(run_state)
56
+ if events:
57
+ for event in events:
58
+ self.record_event(event)
59
+
60
+ def close(self):
61
+ if self._async_mode and self._worker is not None:
62
+ self._stop_event.set()
63
+ self._worker.join()
64
+ if self._owns_stream and self._stream is not None:
65
+ self._stream.close()
66
+ self._stream = None
67
+ self._owns_stream = False
68
+
69
+ def _write(self, record_type: str, data):
70
+ record = {
71
+ "type": record_type,
72
+ "ts": time.time(),
73
+ "data": self._materialize(data),
74
+ }
75
+ if self._buffer is not None:
76
+ self._buffer.append(record)
77
+ if self._stream is None:
78
+ return
79
+ if self._async_mode and self._queue is not None:
80
+ self._queue.put(record)
81
+ return
82
+ self._write_record(record)
83
+
84
+ def _write_record(self, record):
85
+ payload = json.dumps(record, default=self._json_default)
86
+ with self._lock:
87
+ if self._stream is None:
88
+ return
89
+ self._stream.write(payload + "\n")
90
+ self._stream.flush()
91
+
92
+ def _write_batch(self, records):
93
+ payload = "\n".join(json.dumps(record, default=self._json_default) for record in records) + "\n"
94
+ with self._lock:
95
+ if self._stream is None:
96
+ return
97
+ self._stream.write(payload)
98
+ self._stream.flush()
99
+
100
+ def _worker_loop(self):
101
+ pending = []
102
+ while not self._stop_event.is_set() or (self._queue is not None and not self._queue.empty()):
103
+ try:
104
+ record = self._queue.get(timeout=self._flush_interval)
105
+ pending.append(record)
106
+ while True:
107
+ try:
108
+ pending.append(self._queue.get_nowait())
109
+ except queue.Empty:
110
+ break
111
+ except queue.Empty:
112
+ pass
113
+ if pending:
114
+ self._write_batch(pending)
115
+ pending.clear()
116
+
117
+ @staticmethod
118
+ def _materialize(value):
119
+ if is_dataclass(value):
120
+ return asdict(value)
121
+ if isinstance(value, Enum):
122
+ return value.value
123
+ if isinstance(value, BaseException):
124
+ return str(value)
125
+ if hasattr(value, "__dict__"):
126
+ return dict(value.__dict__)
127
+ return value
128
+
129
+ @staticmethod
130
+ def _json_default(value):
131
+ if is_dataclass(value):
132
+ return asdict(value)
133
+ if isinstance(value, Enum):
134
+ return value.value
135
+ if isinstance(value, BaseException):
136
+ return str(value)
137
+ if isinstance(value, bytes):
138
+ return value.decode("utf-8", errors="replace")
139
+ if hasattr(value, "__dict__"):
140
+ return dict(value.__dict__)
141
+ return str(value)
@@ -1,7 +1,7 @@
1
1
  import logging
2
2
 
3
- from python_tty.ui.events import UIEventLevel
4
- from python_tty.ui.output import proxy_print
3
+ from python_tty.runtime.events import UIEventLevel
4
+ from python_tty.runtime.router import proxy_print
5
5
 
6
6
 
7
7
  class ConsoleHandler(logging.Handler):
@@ -11,7 +11,6 @@ class ConsoleHandler(logging.Handler):
11
11
  def emit(self, record):
12
12
  try:
13
13
  log = self.format(record)
14
- proxy_print(log, UIEventLevel.DEBUG)
14
+ proxy_print(log, UIEventLevel.DEBUG, source="tty")
15
15
  except Exception:
16
16
  self.handleError(record)
17
-
@@ -2,8 +2,8 @@ from python_tty.commands import BaseCommands
2
2
  from python_tty.commands.decorators import register_command
3
3
  from python_tty.commands.general import GeneralValidator
4
4
  from python_tty.commands.mixins import HelpMixin, QuitMixin
5
- from python_tty.ui.events import UIEventLevel
6
- from python_tty.ui.output import proxy_print
5
+ from python_tty.runtime.events import UIEventLevel
6
+ from python_tty.runtime.router import proxy_print
7
7
 
8
8
 
9
9
  class RootCommands(BaseCommands, HelpMixin, QuitMixin):
@@ -30,4 +30,3 @@ class RootCommands(BaseCommands, HelpMixin, QuitMixin):
30
30
 
31
31
  if __name__ == '__main__':
32
32
  pass
33
-
@@ -1,6 +1,6 @@
1
1
  import inspect
2
2
 
3
- from python_tty.ui.output import proxy_print
3
+ from python_tty.runtime.router import proxy_print
4
4
  from python_tty.commands import BaseCommands
5
5
  from python_tty.commands.decorators import register_command
6
6
  from python_tty.commands.general import GeneralValidator
@@ -34,7 +34,7 @@ class HelpMixin(CommandMixin):
34
34
  for cls in self.__class__.mro():
35
35
  if cls is CommandMixin:
36
36
  continue
37
- if issubclass(cls, CommandMixin):
37
+ if issubclass(cls, CommandMixin) and not issubclass(cls, BaseCommands):
38
38
  base_commands_funcs.extend([member[1] for member in inspect.getmembers(cls, inspect.isfunction)])
39
39
  for name, func in self.command_funcs.items():
40
40
  row = [name, func.info.func_description]
@@ -43,10 +43,9 @@ class HelpMixin(CommandMixin):
43
43
  else:
44
44
  custom_funcs.append(row)
45
45
  if base_funcs:
46
- proxy_print(Table(header, base_funcs, "Core Commands"))
46
+ proxy_print(Table(header, base_funcs, "Core Commands"), source="tty")
47
47
  if custom_funcs:
48
- proxy_print(Table(header, custom_funcs, "Custom Commands"))
48
+ proxy_print(Table(header, custom_funcs, "Custom Commands"), source="tty")
49
49
 
50
50
  class DefaultCommands(BaseCommands, HelpMixin, QuitMixin):
51
51
  pass
52
-
@@ -1,7 +1,14 @@
1
- from python_tty.config.config import Config, ConsoleFactoryConfig, ConsoleManagerConfig, ExecutorConfig
1
+ from python_tty.config.config import (
2
+ AuditConfig,
3
+ Config,
4
+ ConsoleFactoryConfig,
5
+ ConsoleManagerConfig,
6
+ ExecutorConfig,
7
+ )
2
8
 
3
9
  __all__ = [
4
10
  "Config",
11
+ "AuditConfig",
5
12
  "ConsoleFactoryConfig",
6
13
  "ConsoleManagerConfig",
7
14
  "ExecutorConfig",
@@ -1,35 +1,92 @@
1
1
  from dataclasses import dataclass, field
2
- from typing import Optional, TYPE_CHECKING
2
+ from typing import Optional, TYPE_CHECKING, TextIO, Tuple, Type
3
3
 
4
4
  if TYPE_CHECKING:
5
- from python_tty.ui.output import OutputRouter
5
+ from python_tty.audit.sink import AuditSink
6
+ from python_tty.runtime.router import OutputRouter
7
+
8
+
9
+ @dataclass
10
+ class AuditConfig:
11
+ """Audit sink configuration.
12
+
13
+ Attributes:
14
+ enabled: Toggle audit recording.
15
+ file_path: File path to append JSONL audit records.
16
+ stream: File-like stream to write audit records.
17
+ async_mode: Enable async background writer when stream is set.
18
+ flush_interval: Flush interval (seconds) for async writer.
19
+ keep_in_memory: Keep records in memory buffer for testing.
20
+ sink: Custom AuditSink instance to use instead of file/stream.
21
+ """
22
+ enabled: bool = False
23
+ file_path: Optional[str] = None
24
+ stream: Optional[TextIO] = None
25
+ async_mode: bool = False
26
+ flush_interval: float = 1.0
27
+ keep_in_memory: bool = False
28
+ sink: Optional["AuditSink"] = None
6
29
 
7
30
 
8
31
  @dataclass
9
32
  class ExecutorConfig:
33
+ """Executor runtime configuration.
34
+
35
+ Attributes:
36
+ workers: Number of worker tasks to consume invocations.
37
+ retain_last_n: Keep only the last N completed runs in memory.
38
+ ttl_seconds: Time-to-live for completed runs.
39
+ pop_on_wait: Drop run state after wait_result completion.
40
+ exempt_exceptions: Exceptions treated as cancellations.
41
+ emit_run_events: Emit start/success/failure RuntimeEvent state.
42
+ audit: Audit sink configuration.
43
+ """
10
44
  workers: int = 1
11
45
  retain_last_n: Optional[int] = None
12
46
  ttl_seconds: Optional[float] = None
13
47
  pop_on_wait: bool = False
48
+ exempt_exceptions: Optional[Tuple[Type[BaseException], ...]] = None
49
+ emit_run_events: bool = False
50
+ audit: AuditConfig = field(default_factory=AuditConfig)
14
51
 
15
52
 
16
53
  @dataclass
17
54
  class ConsoleManagerConfig:
55
+ """Console manager configuration.
56
+
57
+ Attributes:
58
+ use_patch_stdout: Patch stdout for prompt_toolkit rendering.
59
+ output_router: Output router instance for UI events.
60
+ """
18
61
  use_patch_stdout: bool = True
19
62
  output_router: Optional["OutputRouter"] = None
20
63
 
21
64
 
22
65
  @dataclass
23
66
  class ConsoleFactoryConfig:
67
+ """Console factory bootstrap configuration.
68
+
69
+ Attributes:
70
+ run_mode: "tty" for single-thread TTY mode, "concurrent" for
71
+ main-thread asyncio loop with TTY in a background thread.
72
+ start_executor: Auto-start the executor when the factory starts.
73
+ executor_in_thread: Start executor in a background thread (tty mode).
74
+ executor_thread_name: Thread name for the executor loop thread.
75
+ tty_thread_name: Thread name for the TTY loop (concurrent mode).
76
+ shutdown_executor: Shutdown executor when the factory stops.
77
+ """
78
+ run_mode: str = "tty"
24
79
  start_executor: bool = True
25
80
  executor_in_thread: bool = True
26
81
  executor_thread_name: str = "ExecutorLoop"
82
+ tty_thread_name: str = "TTYLoop"
27
83
  shutdown_executor: bool = True
28
84
 
29
85
 
30
86
  @dataclass
31
87
  class Config:
88
+ """Top-level configuration for python-tty."""
32
89
  console_manager: ConsoleManagerConfig = field(default_factory=ConsoleManagerConfig)
33
90
  executor: ExecutorConfig = field(default_factory=ExecutorConfig)
34
91
  console_factory: ConsoleFactoryConfig = field(default_factory=ConsoleFactoryConfig)
35
-
92
+
@@ -6,6 +6,8 @@ from python_tty.consoles.loader import load_consoles
6
6
  from python_tty.consoles.manager import ConsoleManager
7
7
  from python_tty.consoles.registry import REGISTRY
8
8
  from python_tty.executor import CommandExecutor
9
+ from python_tty.runtime.provider import set_default_router
10
+ from python_tty.runtime.router import OutputRouter
9
11
 
10
12
 
11
13
  class ConsoleFactory:
@@ -27,6 +29,9 @@ class ConsoleFactory:
27
29
  if config is None:
28
30
  config = Config()
29
31
  self.config = config
32
+ if self.config.console_manager.output_router is None:
33
+ self.config.console_manager.output_router = OutputRouter()
34
+ set_default_router(self.config.console_manager.output_router)
30
35
  self.executor = CommandExecutor(config=config.executor)
31
36
  self._executor_loop = None
32
37
  self._executor_thread = None
@@ -36,13 +41,50 @@ class ConsoleFactory:
36
41
  on_shutdown=self.shutdown,
37
42
  config=config.console_manager,
38
43
  )
44
+ self._attach_audit_sink()
39
45
  load_consoles()
40
46
  REGISTRY.register_all(self.manager)
41
47
 
42
48
  def start(self):
43
49
  """Start the console loop with the registered root console."""
44
- self._start_executor_if_needed()
45
- self.manager.run()
50
+ run_mode = self.config.console_factory.run_mode
51
+ if run_mode == "tty":
52
+ self._start_executor_if_needed()
53
+ self.manager.run()
54
+ return
55
+ if run_mode == "concurrent":
56
+ self.start_concurrent()
57
+ return
58
+ raise ValueError(f"Unsupported run_mode: {run_mode}")
59
+
60
+ def start_concurrent(self):
61
+ """Start executor on the main loop and run TTY in a background thread."""
62
+ loop = asyncio.new_event_loop()
63
+ self._executor_loop = loop
64
+ asyncio.set_event_loop(loop)
65
+ if self.config.console_factory.start_executor:
66
+ self.start_executor(loop=loop)
67
+
68
+ def _run_tty():
69
+ try:
70
+ self.manager.run()
71
+ finally:
72
+ if loop.is_running():
73
+ loop.call_soon_threadsafe(loop.stop)
74
+
75
+ tty_thread = threading.Thread(
76
+ target=_run_tty,
77
+ name=self.config.console_factory.tty_thread_name,
78
+ daemon=True,
79
+ )
80
+ tty_thread.start()
81
+ loop.run_forever()
82
+ pending = asyncio.all_tasks(loop)
83
+ if pending:
84
+ for task in pending:
85
+ task.cancel()
86
+ loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
87
+ loop.close()
46
88
 
47
89
  def start_executor(self, loop=None):
48
90
  """Start executor workers on the provided asyncio loop."""
@@ -97,4 +139,11 @@ class ConsoleFactory:
97
139
  daemon=True,
98
140
  )
99
141
  self._executor_thread.start()
100
-
142
+
143
+ def _attach_audit_sink(self):
144
+ audit_sink = getattr(self.executor, "audit_sink", None)
145
+ if audit_sink is None:
146
+ return
147
+ default_router = self.config.console_manager.output_router
148
+ if default_router is not None:
149
+ default_router.attach_audit_sink(audit_sink)
@@ -3,10 +3,10 @@ from abc import ABC
3
3
 
4
4
  from prompt_toolkit import PromptSession
5
5
 
6
- from python_tty.ui.events import UIEventListener, UIEventSpeaker
6
+ from python_tty.runtime.events import UIEventListener, UIEventSpeaker
7
7
  from python_tty.executor import Invocation
8
8
  from python_tty.exceptions.console_exception import ConsoleExit, ConsoleInitException, SubConsoleExit
9
- from python_tty.ui.output import proxy_print
9
+ from python_tty.runtime.router import proxy_print
10
10
  from python_tty.utils import split_cmd
11
11
 
12
12
 
@@ -47,7 +47,7 @@ class BaseConsole(ABC, UIEventListener):
47
47
 
48
48
  def handler_event(self, event):
49
49
  if BaseConsole.forward_console is not None and BaseConsole.forward_console == self:
50
- proxy_print(event.msg, event.level)
50
+ proxy_print(event.msg, event.level, source=event.source or "tty")
51
51
 
52
52
  def run(self, invocation: Invocation):
53
53
  command_def = self.commands.get_command_def_by_id(invocation.command_id)
@@ -70,7 +70,8 @@ class BaseConsole(ABC, UIEventListener):
70
70
  if executor is None:
71
71
  self.run(invocation)
72
72
  return
73
- executor.submit_threadsafe(invocation, handler=self.run)
73
+ run_id = executor.submit_threadsafe(invocation, handler=self.run)
74
+ executor.wait_result_sync(run_id)
74
75
  except ValueError:
75
76
  return
76
77
 
@@ -137,4 +138,3 @@ class SubConsole(BaseConsole):
137
138
  if parent is None:
138
139
  raise ConsoleInitException("SubConsole parent is None")
139
140
  super().__init__(console_message, console_style, parent=parent, manager=manager)
140
-
@@ -2,8 +2,9 @@ from prompt_toolkit.patch_stdout import patch_stdout
2
2
 
3
3
  from python_tty.config import ConsoleManagerConfig
4
4
  from python_tty.exceptions.console_exception import ConsoleExit, ConsoleInitException, SubConsoleExit
5
- from python_tty.ui.events import UIEventLevel, UIEventSpeaker
6
- from python_tty.ui.output import get_output_router, proxy_print
5
+ from python_tty.runtime.events import UIEventLevel, UIEventSpeaker
6
+ from python_tty.runtime.provider import get_router
7
+ from python_tty.runtime.router import proxy_print
7
8
 
8
9
 
9
10
  class ConsoleEntry:
@@ -23,7 +24,7 @@ class ConsoleManager:
23
24
  self._on_shutdown = on_shutdown
24
25
  self._shutdown_called = False
25
26
  self._config = config if config is not None else ConsoleManagerConfig()
26
- self._output_router = self._config.output_router or get_output_router()
27
+ self._output_router = self._config.output_router or get_router()
27
28
  self._use_patch_stdout = self._config.use_patch_stdout
28
29
  self._warn_service_if_needed(service)
29
30
 
@@ -64,7 +65,7 @@ class ConsoleManager:
64
65
  if service is not None and not isinstance(service, UIEventSpeaker):
65
66
  msg = f"The Service core[{service.__class__}] doesn't extend the [UIEventSpeaker],"\
66
67
  " which may affect the output of the Service core on the UI!"
67
- proxy_print(msg, UIEventLevel.WARNING)
68
+ proxy_print(msg, UIEventLevel.WARNING, source="tty")
68
69
 
69
70
  @property
70
71
  def current(self):
@@ -143,4 +144,3 @@ class ConsoleManager:
143
144
  self._output_router.clear_session()
144
145
  return
145
146
  self._output_router.bind_session(current.session)
146
-
@@ -58,6 +58,39 @@ class ConsoleRegistry:
58
58
  raise ConsoleInitException(f"Console name duplicate [{resolved_name}]")
59
59
  self._subs[resolved_name] = SubConsoleEntry(console_cls, parent_name, resolved_name)
60
60
 
61
+ def get_root(self):
62
+ if self._root_cls is None:
63
+ return None, None
64
+ root_name = self._root_name or _get_console_name(self._root_cls)
65
+ return self._root_cls, root_name
66
+
67
+ def get_subs(self):
68
+ return dict(self._subs)
69
+
70
+ def iter_consoles(self):
71
+ root_cls, root_name = self.get_root()
72
+ if root_cls is not None and root_name is not None:
73
+ yield root_name, root_cls, None
74
+ for name, entry in self._subs.items():
75
+ yield name, entry.console_cls, entry.parent_name
76
+
77
+ def get_console_tree(self):
78
+ _, root_name = self.get_root()
79
+ if not root_name:
80
+ return None
81
+ return _build_console_tree(root_name, self._subs)
82
+
83
+ def get_console_map(self):
84
+ console_map = {}
85
+ for name, console_cls, parent in self.iter_consoles():
86
+ console_map[name] = {
87
+ "name": name,
88
+ "parent": parent,
89
+ "type": console_cls.__name__,
90
+ "module": console_cls.__module__,
91
+ }
92
+ return console_map
93
+
61
94
  def register_all(self, manager):
62
95
  if self._root_cls is None:
63
96
  raise ConsoleInitException("Root console not set")