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 +56 -12
- python_tty/testing/__init__.py +12 -0
- python_tty/testing/capture.py +72 -0
- python_tty/testing/decorators.py +67 -0
- python_tty/testing/discovery.py +263 -0
- python_tty/testing/harness.py +295 -0
- python_tty/testing/results.py +17 -0
- python_tty-0.2.2.dist-info/METADATA +290 -0
- {python_tty-0.2.1.dist-info → python_tty-0.2.2.dist-info}/RECORD +13 -7
- {python_tty-0.2.1.dist-info → python_tty-0.2.2.dist-info}/WHEEL +1 -1
- python_tty-0.2.1.dist-info/METADATA +0 -215
- {python_tty-0.2.1.dist-info → python_tty-0.2.2.dist-info}/licenses/LICENSE +0 -0
- {python_tty-0.2.1.dist-info → python_tty-0.2.2.dist-info}/licenses/NOTICE +0 -0
- {python_tty-0.2.1.dist-info → python_tty-0.2.2.dist-info}/top_level.txt +0 -0
python_tty/__init__.py
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
|
-
from
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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=
|
|
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.
|
|
59
|
-
python_tty-0.2.
|
|
60
|
-
python_tty-0.2.
|
|
61
|
-
python_tty-0.2.
|
|
62
|
-
python_tty-0.2.
|
|
63
|
-
python_tty-0.2.
|
|
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,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
|
-
待定
|
|
File without changes
|
|
File without changes
|
|
File without changes
|