python-tty 0.2.0__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}")
@@ -1,5 +1,8 @@
1
- from python_tty.executor.executor import CommandExecutor
2
- from python_tty.executor.models import Invocation, RunState, RunStatus
1
+ from typing import TYPE_CHECKING
2
+
3
+ if TYPE_CHECKING:
4
+ from python_tty.executor.executor import CommandExecutor
5
+ from python_tty.executor.models import Invocation, RunState, RunStatus
3
6
 
4
7
  __all__ = [
5
8
  "CommandExecutor",
@@ -7,4 +10,20 @@ __all__ = [
7
10
  "RunState",
8
11
  "RunStatus",
9
12
  ]
13
+
14
+
15
+ def __getattr__(name):
16
+ if name == "CommandExecutor":
17
+ from python_tty.executor.executor import CommandExecutor
18
+ return CommandExecutor
19
+ if name == "Invocation":
20
+ from python_tty.executor.models import Invocation
21
+ return Invocation
22
+ if name == "RunState":
23
+ from python_tty.executor.models import RunState
24
+ return RunState
25
+ if name == "RunStatus":
26
+ from python_tty.executor.models import RunStatus
27
+ return RunStatus
28
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
10
29
 
@@ -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}"