python-tty 0.2.1__py3-none-any.whl → 0.2.2__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,18 @@
1
- from python_tty.config import Config
2
- from python_tty.console_factory import ConsoleFactory
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
1
+ from typing import TYPE_CHECKING
2
+
3
+ if TYPE_CHECKING:
4
+ from python_tty.config import Config
5
+ from python_tty.console_factory import ConsoleFactory
6
+ from python_tty.runtime.events import (
7
+ EventBase,
8
+ RuntimeEvent,
9
+ RuntimeEventKind,
10
+ UIEvent,
11
+ UIEventLevel,
12
+ UIEventListener,
13
+ UIEventSpeaker,
14
+ )
15
+ from python_tty.runtime.router import proxy_print
13
16
 
14
17
  __all__ = [
15
18
  "UIEvent",
@@ -23,3 +26,44 @@ __all__ = [
23
26
  "Config",
24
27
  "proxy_print",
25
28
  ]
29
+
30
+
31
+ def __getattr__(name):
32
+ if name == "Config":
33
+ from python_tty.config import Config
34
+ return Config
35
+ if name == "ConsoleFactory":
36
+ from python_tty.console_factory import ConsoleFactory
37
+ return ConsoleFactory
38
+ if name == "proxy_print":
39
+ from python_tty.runtime.router import proxy_print
40
+ return proxy_print
41
+ if name in {
42
+ "UIEvent",
43
+ "UIEventLevel",
44
+ "EventBase",
45
+ "RuntimeEvent",
46
+ "RuntimeEventKind",
47
+ "UIEventListener",
48
+ "UIEventSpeaker",
49
+ }:
50
+ from python_tty.runtime.events import (
51
+ EventBase,
52
+ RuntimeEvent,
53
+ RuntimeEventKind,
54
+ UIEvent,
55
+ UIEventLevel,
56
+ UIEventListener,
57
+ UIEventSpeaker,
58
+ )
59
+ mapping = {
60
+ "UIEvent": UIEvent,
61
+ "UIEventLevel": UIEventLevel,
62
+ "EventBase": EventBase,
63
+ "RuntimeEvent": RuntimeEvent,
64
+ "RuntimeEventKind": RuntimeEventKind,
65
+ "UIEventListener": UIEventListener,
66
+ "UIEventSpeaker": UIEventSpeaker,
67
+ }
68
+ return mapping[name]
69
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,12 @@
1
+ from python_tty.testing.decorators import tty_testable
2
+ from python_tty.testing.discovery import discover_tests
3
+ from python_tty.testing.harness import CommandHarness, InvocationHarness
4
+ from python_tty.testing.results import TestRunResult
5
+
6
+ __all__ = [
7
+ "tty_testable",
8
+ "discover_tests",
9
+ "CommandHarness",
10
+ "InvocationHarness",
11
+ "TestRunResult",
12
+ ]
@@ -0,0 +1,72 @@
1
+ from contextlib import contextmanager
2
+ from typing import Dict, Iterable, List, Optional
3
+
4
+ from python_tty.runtime.events import RuntimeEvent, UIEvent
5
+ from python_tty.runtime.router import BaseRouter
6
+
7
+
8
+ class CollectingRouter(BaseRouter):
9
+ """In-memory router used by test harnesses to capture UI events."""
10
+
11
+ def __init__(self):
12
+ self.events: List[object] = []
13
+
14
+ def emit(self, event):
15
+ self.events.append(event)
16
+
17
+
18
+ @contextmanager
19
+ def capture_executor_events(executor, sink: List[object]):
20
+ """Temporarily intercept executor.publish_event and append events to sink."""
21
+
22
+ original = executor.publish_event
23
+
24
+ def _publish(run_id, event):
25
+ sink.append(event)
26
+ return original(run_id, event)
27
+
28
+ executor.publish_event = _publish
29
+ try:
30
+ yield sink
31
+ finally:
32
+ executor.publish_event = original
33
+
34
+
35
+ def build_output_records(runtime_events: Iterable[object], ui_events: Optional[Iterable[object]] = None):
36
+ """Normalize runtime/UI events into a lightweight output record list."""
37
+
38
+ outputs: List[Dict[str, object]] = []
39
+ for event in runtime_events:
40
+ outputs.append(_event_to_record(event, channel="runtime"))
41
+ for event in ui_events or []:
42
+ outputs.append(_event_to_record(event, channel="ui"))
43
+ return outputs
44
+
45
+
46
+ def _event_to_record(event: object, channel: str):
47
+ if isinstance(event, RuntimeEvent):
48
+ kind = getattr(event.kind, "value", event.kind)
49
+ else:
50
+ kind = None
51
+ if isinstance(event, (RuntimeEvent, UIEvent)):
52
+ level = getattr(getattr(event, "level", None), "value", getattr(event, "level", None))
53
+ return {
54
+ "channel": channel,
55
+ "kind": kind,
56
+ "message": getattr(event, "msg", None),
57
+ "level": level,
58
+ "source": getattr(event, "source", None),
59
+ "event_type": getattr(event, "event_type", None),
60
+ "run_id": getattr(event, "run_id", None),
61
+ "seq": getattr(event, "seq", None),
62
+ }
63
+ return {
64
+ "channel": channel,
65
+ "kind": kind,
66
+ "message": str(event),
67
+ "level": None,
68
+ "source": None,
69
+ "event_type": None,
70
+ "run_id": None,
71
+ "seq": None,
72
+ }
@@ -0,0 +1,67 @@
1
+ import inspect
2
+ from typing import Any, Dict
3
+
4
+ from python_tty.commands import BaseCommands
5
+ from python_tty.commands.registry import CommandInfo
6
+ from python_tty.exceptions.console_exception import ConsoleInitException
7
+
8
+
9
+ TESTABLE_ATTR = "__tty_testable__"
10
+ TEST_META_ATTR = "__tty_test_meta__"
11
+ TEST_SCOPE_ATTR = "__tty_test_scope__"
12
+
13
+
14
+ def tty_testable(target=None, **meta):
15
+ """Mark command classes/methods as discoverable by python_tty.testing."""
16
+
17
+ if target is None:
18
+ return lambda real_target: tty_testable(real_target, **meta)
19
+
20
+ if inspect.isclass(target):
21
+ if not issubclass(target, BaseCommands):
22
+ raise ConsoleInitException(
23
+ "@tty_testable class target must inherit BaseCommands"
24
+ )
25
+ _mark_testable(target, scope="class", meta=meta)
26
+ return target
27
+
28
+ if callable(target):
29
+ func = _unwrap_callable(target)
30
+ if not _is_registered_command_method(func):
31
+ raise ConsoleInitException(
32
+ "@tty_testable method target must be a registered command method "
33
+ "(decorate with @register_command first)"
34
+ )
35
+ _mark_testable(func, scope="method", meta=meta)
36
+ return target
37
+
38
+ raise ConsoleInitException(
39
+ "@tty_testable target must be a BaseCommands subclass or registered command method"
40
+ )
41
+
42
+
43
+ def _unwrap_callable(value):
44
+ return getattr(value, "__func__", value)
45
+
46
+
47
+ def _is_registered_command_method(func) -> bool:
48
+ if not callable(func):
49
+ return False
50
+ info = getattr(func, "info", None)
51
+ return isinstance(info, CommandInfo)
52
+
53
+
54
+ def _mark_testable(target, scope: str, meta: Dict[str, Any]):
55
+ existing_meta = _read_meta(target)
56
+ merged_meta = dict(existing_meta)
57
+ merged_meta.update(meta)
58
+ setattr(target, TESTABLE_ATTR, True)
59
+ setattr(target, TEST_META_ATTR, merged_meta)
60
+ setattr(target, TEST_SCOPE_ATTR, scope)
61
+
62
+
63
+ def _read_meta(target):
64
+ meta = getattr(target, TEST_META_ATTR, None)
65
+ if isinstance(meta, dict):
66
+ return meta
67
+ return {}
@@ -0,0 +1,263 @@
1
+ import inspect
2
+ import types
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple
5
+
6
+ from python_tty.commands import BaseCommands
7
+ from python_tty.commands.registry import COMMAND_REGISTRY
8
+ from python_tty.consoles.registry import REGISTRY
9
+ from python_tty.testing.decorators import TEST_META_ATTR, TESTABLE_ATTR
10
+
11
+
12
+ @dataclass
13
+ class DiscoveredTestCase:
14
+ """Metadata for a single command test case discovered from command classes."""
15
+
16
+ command_id: str
17
+ command_name: str
18
+ console_name: Optional[str]
19
+ console_cls: Optional[type]
20
+ commands_cls: type
21
+ method_name: str
22
+ method: object
23
+ class_marked: bool
24
+ method_marked: bool
25
+ test_meta: Dict[str, Any] = field(default_factory=dict)
26
+
27
+
28
+ class DiscoveredTestSuite:
29
+ """Stable suite wrapper returned by discover_tests()."""
30
+
31
+ def __init__(self, cases: Sequence[DiscoveredTestCase]):
32
+ ordered = sorted(cases, key=lambda c: c.command_id)
33
+ deduped: Dict[str, DiscoveredTestCase] = {}
34
+ for case in ordered:
35
+ deduped[case.command_id] = case
36
+ self._cases: Tuple[DiscoveredTestCase, ...] = tuple(deduped.values())
37
+ self._cases_by_id: Dict[str, DiscoveredTestCase] = dict(deduped)
38
+
39
+ @property
40
+ def cases(self) -> Tuple[DiscoveredTestCase, ...]:
41
+ return self._cases
42
+
43
+ def list_cases(self) -> List[DiscoveredTestCase]:
44
+ return list(self._cases)
45
+
46
+ def get_case(self, command_id: str) -> Optional[DiscoveredTestCase]:
47
+ return self._cases_by_id.get(command_id)
48
+
49
+ def ids(self) -> List[str]:
50
+ return [case.command_id for case in self._cases]
51
+
52
+ def __len__(self):
53
+ return len(self._cases)
54
+
55
+ def __iter__(self):
56
+ return iter(self._cases)
57
+
58
+
59
+ def discover_tests(*targets, command_ids: Optional[Iterable[str]] = None):
60
+ """Explicitly discover command test cases from modules/classes/methods/ids."""
61
+
62
+ expanded_targets = _expand_targets(targets)
63
+ explicit_command_ids: Set[str] = set(command_ids or [])
64
+ commands_classes: List[type] = []
65
+ explicit_classes: Set[type] = set()
66
+ explicit_methods: Set[object] = set()
67
+
68
+ for target in expanded_targets:
69
+ if isinstance(target, str):
70
+ explicit_command_ids.add(target)
71
+ continue
72
+
73
+ if inspect.isclass(target) and issubclass(target, BaseCommands):
74
+ commands_classes.append(target)
75
+ explicit_classes.add(target)
76
+ continue
77
+
78
+ if isinstance(target, types.ModuleType):
79
+ commands_classes.extend(_collect_commands_classes_from_module(target))
80
+ continue
81
+
82
+ if isinstance(target, BaseCommands):
83
+ commands_cls = target.__class__
84
+ commands_classes.append(commands_cls)
85
+ explicit_classes.add(commands_cls)
86
+ continue
87
+
88
+ func = _unwrap_callable(target)
89
+ if callable(func) and hasattr(func, "info"):
90
+ explicit_methods.add(func)
91
+ owner_cls = _resolve_owner_commands_cls(func)
92
+ if owner_cls is not None:
93
+ commands_classes.append(owner_cls)
94
+ continue
95
+
96
+ raise ValueError(f"Unsupported discover_tests target: {target!r}")
97
+
98
+ if explicit_command_ids:
99
+ commands_classes.extend(_collect_classes_by_command_ids(explicit_command_ids))
100
+
101
+ unique_classes = _unique_ordered(commands_classes)
102
+ cases: List[DiscoveredTestCase] = []
103
+
104
+ for commands_cls in unique_classes:
105
+ class_marked = bool(getattr(commands_cls, TESTABLE_ATTR, False))
106
+ class_meta = _read_meta(commands_cls)
107
+ is_explicit_class = commands_cls in explicit_classes
108
+ console_bindings = _resolve_console_bindings(commands_cls)
109
+ command_defs = COMMAND_REGISTRY.collect_from_commands_cls(commands_cls)
110
+
111
+ for command_def in command_defs:
112
+ method = _resolve_method(commands_cls, command_def.func)
113
+ method_marked = bool(getattr(method, TESTABLE_ATTR, False))
114
+ method_meta = _read_meta(method)
115
+ method_selected = _is_method_selected(method, explicit_methods)
116
+
117
+ for console_name, console_cls in console_bindings:
118
+ command_id = _build_command_id(console_name, command_def.func_name)
119
+ by_id = command_id in explicit_command_ids
120
+ include = is_explicit_class or class_marked or method_marked or method_selected or by_id
121
+ if not include:
122
+ continue
123
+ case_meta = dict(class_meta)
124
+ case_meta.update(method_meta)
125
+ cases.append(
126
+ DiscoveredTestCase(
127
+ command_id=command_id,
128
+ command_name=command_def.func_name,
129
+ console_name=console_name,
130
+ console_cls=console_cls,
131
+ commands_cls=commands_cls,
132
+ method_name=method.__name__,
133
+ method=method,
134
+ class_marked=class_marked,
135
+ method_marked=method_marked,
136
+ test_meta=case_meta,
137
+ )
138
+ )
139
+
140
+ return DiscoveredTestSuite(cases)
141
+
142
+
143
+ def _expand_targets(targets) -> List[object]:
144
+ expanded: List[object] = []
145
+ for target in targets:
146
+ if target is None:
147
+ continue
148
+ if isinstance(target, (list, tuple, set)):
149
+ expanded.extend(_expand_targets(tuple(target)))
150
+ continue
151
+ expanded.append(target)
152
+ return expanded
153
+
154
+
155
+ def _collect_commands_classes_from_module(module: types.ModuleType) -> List[type]:
156
+ classes: List[type] = []
157
+ for _, cls in inspect.getmembers(module, inspect.isclass):
158
+ if not issubclass(cls, BaseCommands) or cls is BaseCommands:
159
+ continue
160
+ if cls.__module__ != module.__name__:
161
+ continue
162
+ classes.append(cls)
163
+ return classes
164
+
165
+
166
+ def _collect_classes_by_command_ids(command_ids: Iterable[str]) -> List[type]:
167
+ classes: List[type] = []
168
+ for command_id in command_ids:
169
+ console_name = _extract_console_name(command_id)
170
+ if not console_name:
171
+ continue
172
+ for registry_name, console_cls, _ in REGISTRY.iter_consoles():
173
+ if registry_name != console_name:
174
+ continue
175
+ commands_cls = COMMAND_REGISTRY.get_commands_cls(console_cls)
176
+ if commands_cls is not None:
177
+ classes.append(commands_cls)
178
+ return classes
179
+
180
+
181
+ def _resolve_console_bindings(commands_cls) -> List[Tuple[str, Optional[type]]]:
182
+ bindings: List[Tuple[str, Optional[type]]] = []
183
+ for console_name, console_cls, _ in REGISTRY.iter_consoles():
184
+ resolved = COMMAND_REGISTRY.get_commands_cls(console_cls)
185
+ if resolved is commands_cls:
186
+ bindings.append((console_name, console_cls))
187
+ if bindings:
188
+ return bindings
189
+ fallback = getattr(commands_cls, "console_name", None)
190
+ if not fallback:
191
+ fallback = commands_cls.__name__.lower()
192
+ return [(fallback, None)]
193
+
194
+
195
+ def _extract_console_name(command_id: str) -> Optional[str]:
196
+ if not isinstance(command_id, str):
197
+ return None
198
+ if not command_id.startswith("cmd:"):
199
+ return None
200
+ parts = command_id.split(":", 2)
201
+ if len(parts) != 3:
202
+ return None
203
+ return parts[1] or None
204
+
205
+
206
+ def _resolve_owner_commands_cls(func) -> Optional[type]:
207
+ module = inspect.getmodule(func)
208
+ if module is None:
209
+ return None
210
+ qualname = getattr(func, "__qualname__", "")
211
+ qualname = qualname.split(".<locals>", 1)[0]
212
+ if "." not in qualname:
213
+ return None
214
+ cls_path = qualname.rsplit(".", 1)[0]
215
+ owner = module
216
+ for part in cls_path.split("."):
217
+ owner = getattr(owner, part, None)
218
+ if owner is None:
219
+ return None
220
+ if inspect.isclass(owner) and issubclass(owner, BaseCommands):
221
+ return owner
222
+ return None
223
+
224
+
225
+ def _resolve_method(commands_cls, fallback):
226
+ name = getattr(fallback, "__name__", None)
227
+ if name and hasattr(commands_cls, name):
228
+ return getattr(commands_cls, name)
229
+ return fallback
230
+
231
+
232
+ def _is_method_selected(method, selected_methods: Set[object]) -> bool:
233
+ method = _unwrap_callable(method)
234
+ for selected in selected_methods:
235
+ if _unwrap_callable(selected) is method:
236
+ return True
237
+ return False
238
+
239
+
240
+ def _unwrap_callable(value):
241
+ return getattr(value, "__func__", value)
242
+
243
+
244
+ def _read_meta(target) -> Dict[str, Any]:
245
+ meta = getattr(target, TEST_META_ATTR, None)
246
+ if isinstance(meta, dict):
247
+ return dict(meta)
248
+ return {}
249
+
250
+
251
+ def _unique_ordered(values: Iterable[type]) -> List[type]:
252
+ seen: Set[type] = set()
253
+ ordered: List[type] = []
254
+ for value in values:
255
+ if value in seen:
256
+ continue
257
+ seen.add(value)
258
+ ordered.append(value)
259
+ return ordered
260
+
261
+
262
+ def _build_command_id(console_name: str, command_name: str):
263
+ return f"cmd:{console_name}:{command_name}"
@@ -0,0 +1,295 @@
1
+ import uuid
2
+ from typing import Dict, Iterable, List, Optional
3
+
4
+ from prompt_toolkit.document import Document
5
+
6
+ from python_tty.config import ExecutorConfig
7
+ from python_tty.executor import CommandExecutor, Invocation
8
+ from python_tty.executor.execution import ExecutionBinding, ExecutionContext
9
+ from python_tty.runtime.context import use_run_context
10
+ from python_tty.runtime.provider import use_router
11
+ from python_tty.testing.capture import CollectingRouter, build_output_records, capture_executor_events
12
+ from python_tty.testing.discovery import DiscoveredTestCase, DiscoveredTestSuite, discover_tests
13
+ from python_tty.testing.results import TestRunResult
14
+
15
+
16
+ class CommandHarness:
17
+ """Lightweight harness for directly invoking a single command method."""
18
+
19
+ def __init__(self, service=None, manager=None, suite: Optional[DiscoveredTestSuite] = None, source: str = "test"):
20
+ self._service = service
21
+ self._manager = manager
22
+ self._suite = suite
23
+ self._source = source
24
+
25
+ def run(
26
+ self,
27
+ target,
28
+ *,
29
+ command_id: Optional[str] = None,
30
+ argv: Optional[Iterable[str]] = None,
31
+ kwargs: Optional[Dict[str, object]] = None,
32
+ raw_text: Optional[str] = None,
33
+ validate: bool = True,
34
+ service=None,
35
+ manager=None,
36
+ raise_exceptions: bool = False,
37
+ run_id: Optional[str] = None,
38
+ ) -> TestRunResult:
39
+ """Execute one discovered command and return a structured TestRunResult."""
40
+
41
+ case = _resolve_case(target, command_id=command_id, suite=self._suite)
42
+ resolved_service = self._service if service is None else service
43
+ resolved_manager = self._manager if manager is None else manager
44
+ resolved_kwargs = dict(kwargs or {})
45
+
46
+ console = _build_console_stub(case, resolved_service, resolved_manager)
47
+ commands = case.commands_cls(console)
48
+ console.commands = commands
49
+ command_def = _resolve_command_def(commands, case)
50
+
51
+ resolved_argv = _resolve_argv(commands, command_def, argv=argv, raw_text=raw_text)
52
+ invocation = _build_invocation(case, resolved_argv, resolved_kwargs, raw_text, self._source, run_id)
53
+
54
+ runtime_events: List[object] = []
55
+ router = CollectingRouter()
56
+ return_value = None
57
+ exception = None
58
+ validator_ran = False
59
+
60
+ try:
61
+ with use_router(router):
62
+ with use_run_context(
63
+ run_id=invocation.run_id,
64
+ source=invocation.source,
65
+ emitter=lambda event: runtime_events.append(event),
66
+ command_id=invocation.command_id,
67
+ ):
68
+ if validate and command_def.validator is not None:
69
+ validator_ran = True
70
+ _run_validator(commands, command_def, raw_text, resolved_argv)
71
+ return_value = _invoke(commands, command_def, resolved_argv, resolved_kwargs)
72
+ except Exception as exc: # pragma: no cover - exercised via tests
73
+ exception = exc
74
+ if raise_exceptions:
75
+ raise
76
+
77
+ outputs = build_output_records(runtime_events, router.events)
78
+ return TestRunResult(
79
+ ok=exception is None,
80
+ command_id=case.command_id,
81
+ return_value=return_value,
82
+ exception=exception,
83
+ outputs=outputs,
84
+ runtime_events=list(runtime_events),
85
+ validator_ran=validator_ran,
86
+ invocation=invocation,
87
+ run_id=invocation.run_id,
88
+ )
89
+
90
+
91
+ class InvocationHarness:
92
+ """Harness for testing command invocation via ExecutionContext/ExecutionBinding."""
93
+
94
+ def __init__(
95
+ self,
96
+ service=None,
97
+ manager=None,
98
+ suite: Optional[DiscoveredTestSuite] = None,
99
+ source: str = "test",
100
+ executor: Optional[CommandExecutor] = None,
101
+ ):
102
+ self._service = service
103
+ self._manager = manager
104
+ self._suite = suite
105
+ self._source = source
106
+ self._executor = executor
107
+
108
+ def run(
109
+ self,
110
+ target,
111
+ *,
112
+ command_id: Optional[str] = None,
113
+ argv: Optional[Iterable[str]] = None,
114
+ kwargs: Optional[Dict[str, object]] = None,
115
+ raw_text: Optional[str] = None,
116
+ through_executor: bool = False,
117
+ validate: bool = True,
118
+ service=None,
119
+ manager=None,
120
+ executor: Optional[CommandExecutor] = None,
121
+ raise_exceptions: bool = False,
122
+ run_id: Optional[str] = None,
123
+ ) -> TestRunResult:
124
+ """Run one command through the framework invocation path."""
125
+
126
+ case = _resolve_case(target, command_id=command_id, suite=self._suite)
127
+ resolved_service = self._service if service is None else service
128
+ resolved_manager = self._manager if manager is None else manager
129
+ resolved_kwargs = dict(kwargs or {})
130
+
131
+ console = _build_console_stub(case, resolved_service, resolved_manager)
132
+ commands = case.commands_cls(console)
133
+ console.commands = commands
134
+ command_def = _resolve_command_def(commands, case)
135
+ resolved_argv = _resolve_argv(commands, command_def, argv=argv, raw_text=raw_text)
136
+
137
+ invocation = _build_invocation(case, resolved_argv, resolved_kwargs, raw_text, self._source, run_id)
138
+ ctx = ExecutionContext(
139
+ source=invocation.source,
140
+ console_name=case.console_name,
141
+ command_name=case.command_name,
142
+ command_id=case.command_id,
143
+ argv=list(resolved_argv),
144
+ kwargs=dict(resolved_kwargs),
145
+ raw_cmd=raw_text,
146
+ run_id=invocation.run_id,
147
+ )
148
+ binding = ExecutionBinding(service=resolved_service, manager=resolved_manager, ctx=ctx, console=console)
149
+
150
+ runtime_events: List[object] = []
151
+ router = CollectingRouter()
152
+ return_value = None
153
+ exception = None
154
+ validator_ran = False
155
+
156
+ try:
157
+ with use_router(router):
158
+ if validate and command_def.validator is not None:
159
+ validator_ran = True
160
+ _run_validator(commands, command_def, raw_text, resolved_argv)
161
+
162
+ if through_executor:
163
+ active_executor, owns_executor = _resolve_executor(executor or self._executor)
164
+ with capture_executor_events(active_executor, runtime_events):
165
+ run_ref = active_executor.submit_threadsafe(invocation, handler=lambda inv: binding.execute(inv))
166
+ invocation.run_id = run_ref
167
+ return_value = active_executor.wait_result_sync(run_ref)
168
+ if owns_executor:
169
+ active_executor.shutdown_threadsafe(wait=False)
170
+ else:
171
+ with use_run_context(
172
+ run_id=invocation.run_id,
173
+ source=invocation.source,
174
+ emitter=lambda event: runtime_events.append(event),
175
+ command_id=invocation.command_id,
176
+ ):
177
+ return_value = binding.execute(invocation)
178
+ except Exception as exc: # pragma: no cover - exercised via tests
179
+ exception = exc
180
+ if raise_exceptions:
181
+ raise
182
+
183
+ outputs = build_output_records(runtime_events, router.events)
184
+ return TestRunResult(
185
+ ok=exception is None,
186
+ command_id=case.command_id,
187
+ return_value=return_value,
188
+ exception=exception,
189
+ outputs=outputs,
190
+ runtime_events=list(runtime_events),
191
+ validator_ran=validator_ran,
192
+ invocation=invocation,
193
+ run_id=invocation.run_id,
194
+ )
195
+
196
+
197
+ def _resolve_case(target, command_id: Optional[str], suite: Optional[DiscoveredTestSuite]) -> DiscoveredTestCase:
198
+ if isinstance(target, DiscoveredTestCase):
199
+ return target
200
+
201
+ if isinstance(target, str):
202
+ if suite is None:
203
+ raise ValueError("Command id target requires a discovery suite")
204
+ case = suite.get_case(target)
205
+ if case is None:
206
+ raise ValueError(f"Command id not found in suite: {target}")
207
+ return case
208
+
209
+ if suite is not None and command_id is not None:
210
+ case = suite.get_case(command_id)
211
+ if case is not None:
212
+ return case
213
+
214
+ command_ids = [command_id] if command_id is not None else None
215
+ discovered = discover_tests(target, command_ids=command_ids)
216
+
217
+ if command_id is not None:
218
+ case = discovered.get_case(command_id)
219
+ if case is None:
220
+ raise ValueError(f"Command id not discovered: {command_id}")
221
+ return case
222
+
223
+ if len(discovered) != 1:
224
+ raise ValueError(
225
+ "Target resolves to multiple cases; pass command_id or a DiscoveredTestCase"
226
+ )
227
+ return discovered.cases[0]
228
+
229
+
230
+ def _build_console_stub(case: DiscoveredTestCase, service, manager):
231
+ class _ConsoleStub:
232
+ pass
233
+
234
+ stub = _ConsoleStub()
235
+ stub.service = service
236
+ stub.manager = manager
237
+ stub.console_name = case.console_name
238
+ return stub
239
+
240
+
241
+ def _resolve_command_def(commands, case: DiscoveredTestCase):
242
+ command_def = commands.get_command_def_by_id(case.command_id)
243
+ if command_def is None:
244
+ command_def = commands.get_command_def(case.command_name)
245
+ if command_def is None:
246
+ raise ValueError(f"Command definition not found for {case.command_id}")
247
+ return command_def
248
+
249
+
250
+ def _resolve_argv(commands, command_def, argv: Optional[Iterable[str]], raw_text: Optional[str]) -> List[str]:
251
+ if argv is not None:
252
+ return [str(value) for value in argv]
253
+ if raw_text is None:
254
+ return []
255
+ return list(commands.deserialize_args(command_def, raw_text))
256
+
257
+
258
+ def _run_validator(commands, command_def, raw_text: Optional[str], argv: List[str]):
259
+ text = raw_text if raw_text is not None else " ".join(argv)
260
+ validator = commands._build_validator(command_def)
261
+ validator.validate(Document(text=text))
262
+
263
+
264
+ def _invoke(commands, command_def, argv: List[str], kwargs: Dict[str, object]):
265
+ if argv or kwargs:
266
+ return command_def.func(commands, *argv, **kwargs)
267
+ return command_def.func(commands)
268
+
269
+
270
+ def _build_invocation(
271
+ case: DiscoveredTestCase,
272
+ argv: List[str],
273
+ kwargs: Dict[str, object],
274
+ raw_text: Optional[str],
275
+ source: str,
276
+ run_id: Optional[str],
277
+ ):
278
+ resolved_run_id = run_id or str(uuid.uuid4())
279
+ return Invocation(
280
+ run_id=resolved_run_id,
281
+ source=source,
282
+ console_id=case.console_name,
283
+ command_id=case.command_id,
284
+ command_name=case.command_name,
285
+ argv=list(argv),
286
+ kwargs=dict(kwargs),
287
+ raw_cmd=raw_text,
288
+ )
289
+
290
+
291
+ def _resolve_executor(executor: Optional[CommandExecutor]):
292
+ if executor is not None:
293
+ return executor, False
294
+ config = ExecutorConfig(workers=1, sync_in_threadpool=False, emit_run_events=True)
295
+ return CommandExecutor(config=config), True
@@ -0,0 +1,17 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Any, Dict, List, Optional
3
+
4
+
5
+ @dataclass
6
+ class TestRunResult:
7
+ """Structured test execution result for the testing harnesses."""
8
+
9
+ ok: bool
10
+ command_id: Optional[str]
11
+ return_value: Any = None
12
+ exception: Optional[BaseException] = None
13
+ outputs: List[Dict[str, Any]] = field(default_factory=list)
14
+ runtime_events: List[object] = field(default_factory=list)
15
+ validator_ran: bool = False
16
+ invocation: Optional[object] = None
17
+ run_id: Optional[str] = None
@@ -0,0 +1,290 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-tty
3
+ Version: 0.2.2
4
+ Summary: A multi-console TTY framework for complex CLI/TTY apps
5
+ Home-page: https://github.com/ROOKIEMIE/python-tty
6
+ Author: ROOKIEMIE
7
+ License: Apache-2.0
8
+ Classifier: License :: OSI Approved :: Apache Software License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3 :: Only
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ License-File: NOTICE
15
+ Requires-Dist: fastapi>=0.110.0
16
+ Requires-Dist: grpcio>=1.60.0
17
+ Requires-Dist: prompt_toolkit>=3.0.32
18
+ Requires-Dist: protobuf>=4.25.0
19
+ Requires-Dist: tqdm
20
+ Requires-Dist: uvicorn>=0.27.0
21
+ Dynamic: author
22
+ Dynamic: classifier
23
+ Dynamic: description
24
+ Dynamic: description-content-type
25
+ Dynamic: home-page
26
+ Dynamic: license
27
+ Dynamic: license-file
28
+ Dynamic: requires-dist
29
+ Dynamic: requires-python
30
+ Dynamic: summary
31
+
32
+ # Command Line Framework (TTY + Executor + RPC/Web)
33
+
34
+ [中文](README_zh.md)
35
+
36
+ ## Project Introduction
37
+
38
+ `python-tty` is a multi-console command framework centered on TTY interaction, with a shared runtime execution model (`Invocation -> Executor -> RuntimeEvent`) that can be reused by RPC/Web frontends.
39
+
40
+ This repository now includes an official lightweight testing API (`python_tty.testing`) so external projects can test individual command methods without booting the full TTY kernel.
41
+
42
+ Architecture and module-relationship details were moved out of README and are maintained in [docs/Schema.md](docs/Schema.md).
43
+
44
+ ## Quick Start Example
45
+
46
+ ```python
47
+ from python_tty.console_factory import ConsoleFactory
48
+
49
+ # service can be any business object that commands access via self.console.service
50
+ service = object()
51
+
52
+ factory = ConsoleFactory(service=service)
53
+ factory.start()
54
+ ```
55
+
56
+ Typical setup flow:
57
+ 1. Define command classes with `@register_command`.
58
+ 2. Bind command classes to consoles with `@commands`.
59
+ 3. Register console topology with `@root` / `@sub` / `@multi`.
60
+ 4. Start `ConsoleFactory`.
61
+
62
+ Decorator registration example (`commands`, `root`, `sub`, `multi`):
63
+
64
+ ```python
65
+ from prompt_toolkit.styles import Style
66
+
67
+ from python_tty.commands import BaseCommands
68
+ from python_tty.commands.decorators import commands, register_command
69
+ from python_tty.consoles import MainConsole, SubConsole, multi, root, sub
70
+
71
+
72
+ class RootCommands(BaseCommands):
73
+ @register_command("ping", "health check")
74
+ def run_ping(self):
75
+ return "pong"
76
+
77
+
78
+ class ModuleCommands(BaseCommands):
79
+ @register_command("status", "module status")
80
+ def run_status(self):
81
+ return "ok"
82
+
83
+
84
+ @root
85
+ @commands(RootCommands)
86
+ class RootConsole(MainConsole):
87
+ console_name = "root"
88
+ def __init__(self, parent=None, manager=None):
89
+ super().__init__([("class:prompt", "root> ")], Style.from_dict({"": ""}), parent=parent, manager=manager)
90
+
91
+
92
+ @sub("root")
93
+ @commands(ModuleCommands)
94
+ class ModuleConsole(SubConsole):
95
+ console_name = "module"
96
+ def __init__(self, parent=None, manager=None):
97
+ super().__init__([("class:prompt", "module> ")], Style.from_dict({"": ""}), parent=parent, manager=manager)
98
+
99
+
100
+ @multi({"root": "tools", "module": "tools"})
101
+ @commands(ModuleCommands)
102
+ class ToolsConsole(SubConsole):
103
+ # runtime names become root_tools / module_tools
104
+ def __init__(self, parent=None, manager=None):
105
+ super().__init__([("class:prompt", "tools> ")], Style.from_dict({"": ""}), parent=parent, manager=manager)
106
+ ```
107
+
108
+ ## Detailed Configuration Explanation
109
+
110
+ Configuration entry: `python_tty/config/config.py`.
111
+ Top-level type: `Config`.
112
+
113
+ ### ConsoleFactoryConfig
114
+
115
+ - `run_mode`: `"tty"` or `"concurrent"`.
116
+ - `start_executor`: auto start executor during factory startup.
117
+ - `executor_in_thread`: in tty mode, run executor loop in a background thread.
118
+ - `executor_thread_name`: executor thread name.
119
+ - `tty_thread_name`: tty thread name in concurrent mode.
120
+ - `shutdown_executor`: shutdown executor when factory stops.
121
+
122
+ ### ExecutorConfig
123
+
124
+ - `workers`: worker task count.
125
+ - `retain_last_n`: keep only last N completed runs.
126
+ - `ttl_seconds`: TTL for completed runs.
127
+ - `pop_on_wait`: remove run state after wait result.
128
+ - `exempt_exceptions`: exceptions treated as cancellation.
129
+ - `emit_run_events`: emit state events (`start/success/failure/...`).
130
+ - `event_history_max`: max history events per run.
131
+ - `event_history_ttl`: history TTL per run.
132
+ - `sync_in_threadpool`: run sync handlers in threadpool.
133
+ - `threadpool_workers`: threadpool size.
134
+ - `audit`: nested `AuditConfig`.
135
+
136
+ ### AuditConfig
137
+
138
+ - `enabled`: enable audit sink.
139
+ - `file_path`: jsonl output file.
140
+ - `stream`: output stream (mutually exclusive with `file_path`).
141
+ - `async_mode`: async sink writer.
142
+ - `flush_interval`: async flush interval.
143
+ - `keep_in_memory`: keep records in memory for tests.
144
+ - `sink`: custom sink instance.
145
+
146
+ ### RPCConfig
147
+
148
+ - `enabled`: start gRPC server.
149
+ - `bind_host` / `port`: bind address.
150
+ - `max_message_bytes`: gRPC payload limit.
151
+ - `keepalive_time_ms` / `keepalive_timeout_ms` / `keepalive_permit_without_calls`: keepalive controls.
152
+ - `max_concurrent_rpcs`: concurrency limit.
153
+ - `max_streams_per_client`: stream fanout limit.
154
+ - `stream_backpressure_queue_size`: stream queue size.
155
+ - `default_deny`: deny by default when exposure is missing.
156
+ - `require_rpc_exposed`: require `exposure.rpc=True`.
157
+ - `allowed_principals`: principal allowlist.
158
+ - `admin_principals`: admin principal list (allowlist bypass only).
159
+ - `require_audit`: require audit for RPC invoke.
160
+ - `trust_client_principal`: trust request principal directly.
161
+ - `mtls`: nested `MTLSServerConfig`.
162
+
163
+ ### MTLSServerConfig
164
+
165
+ - `enabled`: enable mTLS.
166
+ - `server_cert_file` / `server_key_file`: server cert/key.
167
+ - `client_ca_file`: CA bundle for client cert verification.
168
+ - `require_client_cert`: enforce client cert.
169
+ - `principal_keys`: auth_context keys for principal extraction.
170
+
171
+ ### WebConfig
172
+
173
+ - `enabled`: start web server.
174
+ - `bind_host` / `port`: bind address.
175
+ - `root_path`: reverse proxy root path.
176
+ - `cors_allow_origins` / `cors_allow_credentials` / `cors_allow_methods` / `cors_allow_headers`: CORS controls.
177
+ - `meta_enabled`: enable `/meta` endpoint.
178
+ - `meta_cache_control_max_age`: `/meta` cache-control max-age.
179
+ - `ws_snapshot_enabled`: enable snapshot websocket.
180
+ - `ws_snapshot_include_jobs`: include running jobs in snapshots.
181
+ - `ws_max_connections`: websocket connection limit.
182
+ - `ws_heartbeat_interval`: heartbeat interval.
183
+ - `ws_send_queue_size`: websocket send queue size.
184
+
185
+ ## Testing API Usage Examples
186
+
187
+ Public imports:
188
+
189
+ ```python
190
+ from python_tty.testing import (
191
+ tty_testable,
192
+ discover_tests,
193
+ CommandHarness,
194
+ InvocationHarness,
195
+ TestRunResult,
196
+ )
197
+ ```
198
+
199
+ ### 1. Mark command class or command method
200
+
201
+ ```python
202
+ from python_tty.commands import BaseCommands
203
+ from python_tty.commands.decorators import register_command
204
+ from python_tty.testing import tty_testable
205
+
206
+ @tty_testable(component="smoke")
207
+ class UserCommands(BaseCommands):
208
+ @register_command("ping", "health check")
209
+ def run_ping(self):
210
+ return "pong"
211
+
212
+ class PartialCommands(BaseCommands):
213
+ @tty_testable(component="critical")
214
+ @register_command("login", "login flow")
215
+ def run_login(self, username):
216
+ return username
217
+
218
+ @register_command("debug", "not included unless explicitly selected")
219
+ def run_debug(self):
220
+ return "debug"
221
+ ```
222
+
223
+ ### 2. Explicit discovery (test-only, no startup auto-scan)
224
+
225
+ ```python
226
+ import my_project.commands as command_module
227
+ from python_tty.testing import discover_tests
228
+
229
+ suite = discover_tests(command_module)
230
+ all_cases = suite.list_cases()
231
+
232
+ case = suite.get_case("cmd:usercommands:ping")
233
+
234
+ # explicit selection still works without decorators
235
+ explicit_suite = discover_tests(
236
+ command_module,
237
+ command_ids=["cmd:partialcommands:debug"],
238
+ )
239
+ ```
240
+
241
+ ### 3. Run a command with CommandHarness
242
+
243
+ ```python
244
+ from types import SimpleNamespace
245
+ from python_tty.testing import CommandHarness
246
+
247
+ harness = CommandHarness(service=SimpleNamespace(name="svc"), suite=suite)
248
+ result: TestRunResult = harness.run("cmd:usercommands:ping")
249
+
250
+ assert result.ok
251
+ assert result.return_value == "pong"
252
+ ```
253
+
254
+ ### 4. Toggle validation on/off
255
+
256
+ ```python
257
+ # validator enabled
258
+ result_on = harness.run("cmd:partialcommands:login", argv=[], validate=True)
259
+ assert result_on.ok is False
260
+ assert result_on.validator_ran is True
261
+
262
+ # validator disabled
263
+ result_off = harness.run("cmd:partialcommands:login", argv=[], validate=False)
264
+ assert result_off.validator_ran is False
265
+ ```
266
+
267
+ ### 5. Run through InvocationHarness runtime path
268
+
269
+ ```python
270
+ inv_harness = InvocationHarness(suite=suite)
271
+
272
+ # direct runtime binding execution
273
+ direct_result = inv_harness.run("cmd:usercommands:ping", through_executor=False)
274
+
275
+ # executor-backed execution (captures state/runtime events)
276
+ executor_result = inv_harness.run("cmd:usercommands:ping", through_executor=True)
277
+ ```
278
+
279
+ ### 6. Inspect TestRunResult
280
+
281
+ ```python
282
+ result = inv_harness.run("cmd:usercommands:ping", through_executor=True)
283
+
284
+ print(result.ok)
285
+ print(result.return_value)
286
+ print(result.exception)
287
+ print(result.outputs) # normalized output records
288
+ print(result.runtime_events) # raw RuntimeEvent objects
289
+ print(result.run_id)
290
+ ```
@@ -1,4 +1,4 @@
1
- python_tty/__init__.py,sha256=BcJf6R5DOsMPCZCsCMJ_6WUUO8NqJ8Fnbq4WbK7-cL0,521
1
+ python_tty/__init__.py,sha256=XmsJQetBnxtcm_6S4T8_0IuS0k8S8LuyszNd-a5Ge0Y,1842
2
2
  python_tty/console_factory.py,sha256=N-JKToa8AnVwZtrq-BksMH0lM6vN_xHVzo8LjsfwFuk,7552
3
3
  python_tty/audit/__init__.py,sha256=4VjFUH4KWjO-ZRBOCAZed5IT7zNVTfJOO-HaMTm762E,152
4
4
  python_tty/audit/sink.py,sha256=9jyfhe6FEbsde-VIHpqJ4O7OEpoG3glOigE-guhRgJE,4821
@@ -52,12 +52,18 @@ python_tty/session/manager.py,sha256=OrNtz6IGUGmdceiOvf7cUUZ324eE8baAYQ-GVdc689c
52
52
  python_tty/session/models.py,sha256=Op4rQFuDb4P0bNGK1J7KOelYlKo2hlAtFz0rxkoxZ7E,476
53
53
  python_tty/session/policy.py,sha256=Hm-NYrCG0obLrPCvNdhtdziD4hP2gOYeLYNirFc_sQ0,348
54
54
  python_tty/session/store.py,sha256=cYHmja_0g8VcXqXrztuHOjd0v_cG4l7awHfHhRB-qqk,3571
55
+ python_tty/testing/__init__.py,sha256=eeMjfp_s3zZIqU8aB33_8IHVqRXWVpMOENqJ-PCrc7w,362
56
+ python_tty/testing/capture.py,sha256=2_GxARCz-Q27V_v4b6Mk2E08Ilx-5MBJukI4Tig0tHM,2225
57
+ python_tty/testing/decorators.py,sha256=r4qAzf_atfQuijnqjy90J-43mIPrf2XEQ70xxm4q9HA,2048
58
+ python_tty/testing/discovery.py,sha256=mnc9aI6Y0PtohmDpKt3QxpBwWxeiZt7qbBqIjlSqr0M,9000
59
+ python_tty/testing/harness.py,sha256=oZhrPyzBRphlbCRu1ow0iYYEUwbxWjUd5ecJYQFDjh8,11046
60
+ python_tty/testing/results.py,sha256=rZ9gbpPLdotKIeabHSFkJrjhxXv0klaXJLtrFN943Gc,541
55
61
  python_tty/utils/__init__.py,sha256=iU2MEk7j-8Sl4fiT4IsoOxQdq7MK70qpaD-87F0As9g,261
56
62
  python_tty/utils/table.py,sha256=oTjdhSM3C-NbjdghLysb7c3rgu_B-IbJtcactBGFtN0,10764
57
63
  python_tty/utils/tokenize.py,sha256=TnZd1o6LmvqbMWON_RTVk4q664TviIQTFVkNX4CTUBs,1055
58
- python_tty-0.2.1.dist-info/licenses/LICENSE,sha256=bR2Wj7Il7KNny38LiDGrASo12StUfpReF--OewXD5cw,10349
59
- python_tty-0.2.1.dist-info/licenses/NOTICE,sha256=UiuDFTGN2ACyjBHAg4AoH6jM9aQRSgXjt2AYNUCI4ck,86
60
- python_tty-0.2.1.dist-info/METADATA,sha256=xwSQCmLa6Z3AJUq4xsZxOHFct5mgFjfDju_jjTTsVIU,7656
61
- python_tty-0.2.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
62
- python_tty-0.2.1.dist-info/top_level.txt,sha256=ZAEMWTGLkGwrlNN-lOeuJzAHB_xHAiEAecJ7CzS7FnY,11
63
- python_tty-0.2.1.dist-info/RECORD,,
64
+ python_tty-0.2.2.dist-info/licenses/LICENSE,sha256=bR2Wj7Il7KNny38LiDGrASo12StUfpReF--OewXD5cw,10349
65
+ python_tty-0.2.2.dist-info/licenses/NOTICE,sha256=UiuDFTGN2ACyjBHAg4AoH6jM9aQRSgXjt2AYNUCI4ck,86
66
+ python_tty-0.2.2.dist-info/METADATA,sha256=veeUPHKfI05vBAIArMfkTLNwDnBTPH7qRoZpMAG1vpw,9081
67
+ python_tty-0.2.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
68
+ python_tty-0.2.2.dist-info/top_level.txt,sha256=ZAEMWTGLkGwrlNN-lOeuJzAHB_xHAiEAecJ7CzS7FnY,11
69
+ python_tty-0.2.2.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.10.2)
2
+ Generator: setuptools (82.0.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,215 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: python-tty
3
- Version: 0.2.1
4
- Summary: A multi-console TTY framework for complex CLI/TTY apps
5
- Home-page: https://github.com/ROOKIEMIE/python-tty
6
- Author: ROOKIEMIE
7
- License: Apache-2.0
8
- Classifier: License :: OSI Approved :: Apache Software License
9
- Classifier: Programming Language :: Python :: 3
10
- Classifier: Programming Language :: Python :: 3 :: Only
11
- Requires-Python: >=3.10
12
- Description-Content-Type: text/markdown
13
- License-File: LICENSE
14
- License-File: NOTICE
15
- Requires-Dist: fastapi>=0.110.0
16
- Requires-Dist: grpcio>=1.60.0
17
- Requires-Dist: prompt_toolkit>=3.0.32
18
- Requires-Dist: protobuf>=4.25.0
19
- Requires-Dist: tqdm
20
- Requires-Dist: uvicorn>=0.27.0
21
- Dynamic: author
22
- Dynamic: classifier
23
- Dynamic: description
24
- Dynamic: description-content-type
25
- Dynamic: home-page
26
- Dynamic: license
27
- Dynamic: license-file
28
- Dynamic: requires-dist
29
- Dynamic: requires-python
30
- Dynamic: summary
31
-
32
- # Command Line Framework (TTY + Executor + RPC/Web)
33
-
34
- [中文](README_zh.md)
35
-
36
- This project focuses on the TTY core while providing a unified executor, runtime events, RPC/Web frontends, and session management for complex CLI/TTY apps and lightweight service endpoints.
37
-
38
- ## Concepts
39
-
40
- Console layer:
41
- - `python_tty/consoles/core.py`: `BaseConsole`, `MainConsole`, `SubConsole`
42
- - `python_tty/consoles/manager.py` and `python_tty/consoles/registry.py` for lifecycle and registration
43
-
44
- Commands layer:
45
- - `python_tty/commands/core.py`: `BaseCommands`, `CommandValidator`
46
- - `python_tty/commands/registry.py`: `CommandRegistry`, `ArgSpec`
47
- - `python_tty/commands/general.py`: `GeneralValidator`, `GeneralCompleter`
48
- - `python_tty/commands/mixins.py`: `CommandMixin` and built-in mixins
49
-
50
- Execution and runtime:
51
- - `python_tty/executor/executor.py`: `CommandExecutor` (unified execution entry)
52
- - `python_tty/runtime/jobs.py`: `JobStore` (RunState/Invocation/event history)
53
- - `python_tty/runtime/events.py`: `RuntimeEvent`, `UIEvent`, `UIEventLevel`
54
- - `python_tty/runtime/router.py`: `proxy_print` and output routing
55
-
56
- Session and callbacks:
57
- - `python_tty/session/manager.py`: `SessionManager` (lifecycle + submit + constraints)
58
- - `python_tty/session/store.py`: `SessionStore` (in-memory session state)
59
- - `python_tty/session/callbacks.py`: callback subscriptions (thread-safe cancel)
60
-
61
- RPC/Web:
62
- - `python_tty/frontends/rpc`: gRPC Invoke + event streaming
63
- - `python_tty/frontends/web`: FastAPI + WS snapshot (Meta)
64
-
65
- Table rendering:
66
- - `python_tty/utils/table.py`: table rendering (auto wrap supported)
67
-
68
- TTY / RPC scheduling logic in Executor:
69
- - TTY: builds `Invocation`, submits via `executor.submit_threadsafe()`, workers emit `RuntimeEvent`.
70
- - RPC: builds `Invocation`, enforces exposure/allowlist/audit, `StreamEvents` subscribes to event queues.
71
-
72
- ## Configuration
73
-
74
- Config entry is `python_tty/config/config.py`.
75
-
76
- ConsoleFactoryConfig:
77
- - `run_mode`: `"tty"` or `"concurrent"`; decides whether the main thread runs the loop + TTY thread.
78
- - `start_executor`: auto-start executor on factory start.
79
- - `executor_in_thread`: start executor in a background thread in TTY mode.
80
- - `executor_thread_name`: executor loop thread name.
81
- - `tty_thread_name`: TTY thread name in concurrent mode.
82
- - `shutdown_executor`: shutdown executor when factory stops.
83
-
84
- ExecutorConfig:
85
- - `workers`: number of workers (execution concurrency).
86
- - `retain_last_n`: keep last N completed runs in memory.
87
- - `ttl_seconds`: TTL for completed runs.
88
- - `pop_on_wait`: drop run state after wait_result.
89
- - `exempt_exceptions`: treat these as cancellation.
90
- - `emit_run_events`: emit start/success/failure state events.
91
- - `event_history_max`: max events per run.
92
- - `event_history_ttl`: TTL for per-run history.
93
- - `sync_in_threadpool`: run sync handlers in a thread pool.
94
- - `threadpool_workers`: max thread pool workers.
95
- - `audit`: `AuditConfig` for audit sink.
96
-
97
- AuditConfig:
98
- - `enabled`: enable audit.
99
- - `file_path`: JSONL file output.
100
- - `stream`: stream output (exclusive with file_path).
101
- - `async_mode`: async writer mode.
102
- - `flush_interval`: async flush interval.
103
- - `keep_in_memory`: keep records in memory (tests).
104
- - `sink`: custom AuditSink instance.
105
-
106
- RPCConfig:
107
- - `enabled`: start RPC server.
108
- - `bind_host` / `port`: listen address and port.
109
- - `max_message_bytes`: max gRPC message size.
110
- - `keepalive_time_ms` / `keepalive_timeout_ms` / `keepalive_permit_without_calls`: keepalive options.
111
- - `max_concurrent_rpcs`: max RPC concurrency.
112
- - `max_streams_per_client`: max concurrent streams per client.
113
- - `stream_backpressure_queue_size`: per-stream queue limit.
114
- - `default_deny`: deny when exposure is missing.
115
- - `require_rpc_exposed`: require `exposure.rpc=True`.
116
- - `allowed_principals`: principal allowlist.
117
- - `admin_principals`: admin principals (bypass allowlist only).
118
- - `require_audit`: RPC requires audit sink.
119
- - `trust_client_principal`: trust `request.principal` (default False).
120
- - `mtls`: `MTLSServerConfig`.
121
-
122
- MTLSServerConfig:
123
- - `enabled`: enable mTLS.
124
- - `server_cert_file` / `server_key_file`: server cert/key.
125
- - `client_ca_file`: client CA bundle.
126
- - `require_client_cert`: require client certs.
127
- - `principal_keys`: auth_context keys for principal extraction.
128
-
129
- WebConfig:
130
- - `enabled`: start Web server.
131
- - `bind_host` / `port`: listen address and port.
132
- - `root_path`: reverse-proxy root path.
133
- - `cors_*`: CORS options.
134
- - `meta_enabled`: enable `/meta`.
135
- - `meta_cache_control_max_age`: cache max-age for `/meta`.
136
- - `ws_snapshot_enabled`: enable `/meta/snapshot` websocket.
137
- - `ws_snapshot_include_jobs`: include running job summary.
138
- - `ws_max_connections`: max WS connections.
139
- - `ws_heartbeat_interval`: WS heartbeat seconds.
140
- - `ws_send_queue_size`: WS send queue size.
141
-
142
- Factory impact:
143
- - `run_mode="tty"`: TTY runs on main thread; executor can be started in main or background thread.
144
- - `run_mode="concurrent"`: main thread runs asyncio loop; TTY runs in background thread.
145
- - `rpc.enabled=True` / `web.enabled=True`: services are attached during factory startup.
146
- - `start_executor=False`: RPC/Web/Session submit will be unavailable or raise (no executor loop).
147
-
148
- ## Examples
149
-
150
- Quick start (TTY core):
151
- 1. Define your consoles and commands (see examples in `python_tty/consoles/examples` and `python_tty/commands/examples`).
152
- 2. Ensure console modules are imported so decorators can register them: update `DEFAULT_CONSOLE_MODULES` in `python_tty/consoles/loader.py` or call `load_consoles([...])`.
153
- 3. Start the factory:
154
- ```python
155
- from python_tty.console_factory import ConsoleFactory
156
-
157
- factory = ConsoleFactory(service=my_business_core)
158
- factory.start()
159
- ```
160
-
161
- Job/Session basic usage:
162
- ```python
163
- from python_tty.console_factory import ConsoleFactory
164
-
165
- factory = ConsoleFactory(service=my_service)
166
- factory.start_executor()
167
- sm = factory.session_manager
168
-
169
- sid = sm.open_session(principal="local")
170
- run_id = sm.submit_command(sid, "cmd:root:help")
171
- result = await sm.result(run_id)
172
- ```
173
-
174
- Mode A: synchronous orchestration (outer awaits inner)
175
- ```python
176
- async def outer():
177
- inner_id = sm.submit_command(sid, "cmd:root:long_task", await_result=True)
178
- inner_result = await sm.result(inner_id)
179
- return inner_result
180
- ```
181
-
182
- Mode B: async orchestration (callbacks)
183
- ```python
184
- def on_event(evt):
185
- pass
186
-
187
- def on_done(evt):
188
- pass
189
-
190
- inner_id = sm.submit_command(sid, "cmd:root:long_task")
191
- sub_id = sm.register_callback(inner_id, on_event=on_event, on_done=on_done)
192
- ```
193
-
194
- Table rendering:
195
- ```python
196
- from python_tty.utils import Table
197
-
198
- header = ["name", "status", "detail"]
199
- data = [
200
- ["job-1", "running", "very long text ..."],
201
- ["job-2", "done", "short"],
202
- ]
203
-
204
- table = Table(header, data, title="jobs", wrap=True)
205
- print(table)
206
- ```
207
-
208
- Single row (debug):
209
- ```python
210
- print(table.print_line(["job-3", "queued", "pending"]))
211
- ```
212
-
213
- ## 展望
214
-
215
- 待定