apte 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apte/__init__.py +55 -0
- apte/__main__.py +5 -0
- apte/api.py +194 -0
- apte/assertions.py +152 -0
- apte/cache/__init__.py +6 -0
- apte/cache/plugin.py +94 -0
- apte/cache/storage.py +132 -0
- apte/cli/__init__.py +0 -0
- apte/cli/main.py +342 -0
- apte/compat.py +15 -0
- apte/console.py +85 -0
- apte/core/__init__.py +0 -0
- apte/core/collector.py +242 -0
- apte/core/execution/__init__.py +7 -0
- apte/core/execution/parallel.py +304 -0
- apte/core/execution/suite_manager.py +93 -0
- apte/core/execution/test_executor.py +371 -0
- apte/core/fixture.py +14 -0
- apte/core/outcome.py +137 -0
- apte/core/runner.py +206 -0
- apte/core/session.py +382 -0
- apte/core/suite.py +236 -0
- apte/core/tracker.py +50 -0
- apte/di/__init__.py +0 -0
- apte/di/container.py +851 -0
- apte/di/decorators.py +220 -0
- apte/di/factory.py +79 -0
- apte/di/hashable.py +57 -0
- apte/di/hints.py +163 -0
- apte/di/markers.py +79 -0
- apte/di/proxy.py +81 -0
- apte/di/validation.py +38 -0
- apte/entities/__init__.py +70 -0
- apte/entities/core.py +158 -0
- apte/entities/events.py +171 -0
- apte/entities/log_capture.py +28 -0
- apte/entities/retry.py +31 -0
- apte/entities/skip.py +63 -0
- apte/entities/suite_path.py +70 -0
- apte/entities/xfail.py +24 -0
- apte/evals/__init__.py +45 -0
- apte/evals/evaluator.py +420 -0
- apte/evals/evaluators.py +199 -0
- apte/evals/hashing.py +109 -0
- apte/evals/results_writer.py +175 -0
- apte/evals/suite.py +98 -0
- apte/evals/types.py +356 -0
- apte/evals/wrapper.py +309 -0
- apte/events/__init__.py +0 -0
- apte/events/bus.py +231 -0
- apte/events/types.py +38 -0
- apte/exceptions.py +188 -0
- apte/execution/__init__.py +0 -0
- apte/execution/async_bridge.py +36 -0
- apte/execution/capture.py +264 -0
- apte/execution/context.py +73 -0
- apte/execution/interrupt.py +118 -0
- apte/execution/runner.py +0 -0
- apte/filters/__init__.py +4 -0
- apte/filters/keyword.py +52 -0
- apte/filters/kind.py +37 -0
- apte/filters/suite.py +43 -0
- apte/fixtures/__init__.py +0 -0
- apte/fixtures/builtins.py +38 -0
- apte/fixtures/mocker.py +145 -0
- apte/history/__init__.py +17 -0
- apte/history/collector.py +80 -0
- apte/history/plugin.py +254 -0
- apte/history/storage.py +295 -0
- apte/loader.py +85 -0
- apte/plugin.py +221 -0
- apte/py.typed +0 -0
- apte/reporting/__init__.py +10 -0
- apte/reporting/ascii.py +419 -0
- apte/reporting/ctrf.py +252 -0
- apte/reporting/factory.py +31 -0
- apte/reporting/format.py +39 -0
- apte/reporting/log_file.py +111 -0
- apte/reporting/rich_reporter.py +523 -0
- apte/reporting/verbosity.py +18 -0
- apte/reporting/web.py +347 -0
- apte/shell.py +200 -0
- apte/tags/__init__.py +5 -0
- apte/tags/plugin.py +77 -0
- apte/utils.py +26 -0
- apte-0.3.0.dist-info/METADATA +211 -0
- apte-0.3.0.dist-info/RECORD +91 -0
- apte-0.3.0.dist-info/WHEEL +5 -0
- apte-0.3.0.dist-info/entry_points.txt +2 -0
- apte-0.3.0.dist-info/licenses/LICENSE +21 -0
- apte-0.3.0.dist-info/top_level.txt +1 -0
apte/cli/main.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import functools
|
|
5
|
+
import sys
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from apte.api import collect_tests, list_tags, run_session
|
|
9
|
+
from apte.core.session import ApteSession
|
|
10
|
+
from apte.loader import LoadError, load_session, parse_target
|
|
11
|
+
from apte.plugin import PluginContext
|
|
12
|
+
from apte.reporting.verbosity import Verbosity
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from apte.entities import TestItem
|
|
16
|
+
|
|
17
|
+
HELP_EPILOG = """
|
|
18
|
+
Examples:
|
|
19
|
+
apte run demo:session Run all tests
|
|
20
|
+
apte run demo:session::API Run tests in API suite only
|
|
21
|
+
apte run demo:session -k login Run tests matching 'login'
|
|
22
|
+
apte run demo:session -n 4 Run with 4 concurrent workers
|
|
23
|
+
apte run demo:session --lf Re-run only failed tests
|
|
24
|
+
apte run demo:session --collect-only List tests without running
|
|
25
|
+
apte run demo:session --tag slow Run tests with 'slow' tag
|
|
26
|
+
apte run demo:session -s Disable capture (show print output)
|
|
27
|
+
apte eval demo:session Run all evaluations
|
|
28
|
+
apte eval demo:session --show-output Show inputs/output/expected per case
|
|
29
|
+
apte live Start live reporter server
|
|
30
|
+
apte tags list demo:session List all available tags
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _handle_tags_command() -> None:
|
|
35
|
+
"""Handle 'apte tags' subcommand."""
|
|
36
|
+
parser = argparse.ArgumentParser(
|
|
37
|
+
prog="apte tags",
|
|
38
|
+
description="Tag inspection commands",
|
|
39
|
+
)
|
|
40
|
+
subparsers = parser.add_subparsers(dest="subcommand", required=True)
|
|
41
|
+
|
|
42
|
+
list_parser = subparsers.add_parser("list", help="List all available tags")
|
|
43
|
+
list_parser.add_argument(
|
|
44
|
+
"target",
|
|
45
|
+
help="Module and session: 'module:session' (e.g., 'demo:session')",
|
|
46
|
+
)
|
|
47
|
+
list_parser.add_argument(
|
|
48
|
+
"--app-dir",
|
|
49
|
+
default=".",
|
|
50
|
+
help="Look for module in this directory (default: .)",
|
|
51
|
+
)
|
|
52
|
+
list_parser.add_argument(
|
|
53
|
+
"-r",
|
|
54
|
+
"--recursive",
|
|
55
|
+
action="store_true",
|
|
56
|
+
help="Show effective tags per test (including inherited from fixtures)",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
args = parser.parse_args(sys.argv[2:])
|
|
60
|
+
|
|
61
|
+
if args.subcommand == "list":
|
|
62
|
+
_list_tags(args.target, app_dir=args.app_dir, recursive=args.recursive)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _list_tags(target: str, app_dir: str, recursive: bool = False) -> None:
|
|
66
|
+
"""List all tags in a session."""
|
|
67
|
+
try:
|
|
68
|
+
session = load_session(target, app_dir)
|
|
69
|
+
except LoadError as exc:
|
|
70
|
+
print(f"Error: {exc}")
|
|
71
|
+
sys.exit(1)
|
|
72
|
+
|
|
73
|
+
if recursive:
|
|
74
|
+
items = collect_tests(session)
|
|
75
|
+
_print_tags_recursive(items)
|
|
76
|
+
else:
|
|
77
|
+
tags = list_tags(session)
|
|
78
|
+
_print_tags_summary(tags)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _print_tags_summary(tags: set[str]) -> None:
|
|
82
|
+
"""Print summary of all declared tags."""
|
|
83
|
+
for tag in sorted(tags):
|
|
84
|
+
print(tag)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _print_tags_recursive(items: list[TestItem]) -> None:
|
|
88
|
+
"""Print effective tags per test."""
|
|
89
|
+
if not items:
|
|
90
|
+
print("No tests found.")
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
print(f"Effective tags for {len(items)} test(s):\n")
|
|
94
|
+
for item in items:
|
|
95
|
+
tags_str = ", ".join(sorted(item.tags)) if item.tags else "(none)"
|
|
96
|
+
print(f" {item.node_id}")
|
|
97
|
+
print(f" tags: {tags_str}\n")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main() -> None:
|
|
101
|
+
if len(sys.argv) < 2:
|
|
102
|
+
_print_help()
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
command = sys.argv[1]
|
|
106
|
+
|
|
107
|
+
if command in ("--help", "-h"):
|
|
108
|
+
_print_help()
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
commands: dict[str, Any] = {
|
|
112
|
+
"tags": _handle_tags_command,
|
|
113
|
+
"run": functools.partial(_handle_run_command, kind_filter="test"),
|
|
114
|
+
"eval": functools.partial(_handle_run_command, kind_filter="eval"),
|
|
115
|
+
"live": _handle_live_command,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
handler = commands.get(command)
|
|
119
|
+
if handler:
|
|
120
|
+
handler()
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
valid = ", ".join(f"'{c}'" for c in commands)
|
|
124
|
+
print(f"Error: Unknown command '{command}'. Use {valid}.")
|
|
125
|
+
sys.exit(1)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _handle_live_command() -> None:
|
|
129
|
+
"""Handle 'apte live' subcommand - starts persistent live server."""
|
|
130
|
+
parser = argparse.ArgumentParser(
|
|
131
|
+
prog="apte live",
|
|
132
|
+
description="Start live reporter server (keeps running for multiple test runs)",
|
|
133
|
+
)
|
|
134
|
+
parser.add_argument(
|
|
135
|
+
"-p",
|
|
136
|
+
"--port",
|
|
137
|
+
type=int,
|
|
138
|
+
default=8765,
|
|
139
|
+
help="Port to listen on (default: 8765)",
|
|
140
|
+
)
|
|
141
|
+
args = parser.parse_args(sys.argv[2:])
|
|
142
|
+
|
|
143
|
+
from apte.reporting.web import run_live_server # noqa: PLC0415 - optional dep
|
|
144
|
+
|
|
145
|
+
run_live_server(port=args.port)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _print_help() -> None:
|
|
149
|
+
"""Print main help."""
|
|
150
|
+
print("Apte - Async-first Python test framework\n")
|
|
151
|
+
print("Commands:")
|
|
152
|
+
print(" run Run tests")
|
|
153
|
+
print(" eval Run evaluations")
|
|
154
|
+
print(" live Start live reporter server")
|
|
155
|
+
print(" tags Tag inspection commands")
|
|
156
|
+
print(HELP_EPILOG)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _create_base_parser() -> argparse.ArgumentParser:
|
|
160
|
+
"""Parser with just target and app-dir for initial loading."""
|
|
161
|
+
parser = argparse.ArgumentParser(
|
|
162
|
+
prog="apte run",
|
|
163
|
+
description="Run tests",
|
|
164
|
+
add_help=False, # We'll add help to the full parser
|
|
165
|
+
)
|
|
166
|
+
parser.add_argument(
|
|
167
|
+
"target",
|
|
168
|
+
nargs="?",
|
|
169
|
+
help="Module and session: 'module:session' (e.g., 'demo:session')",
|
|
170
|
+
)
|
|
171
|
+
parser.add_argument(
|
|
172
|
+
"--app-dir",
|
|
173
|
+
default=".",
|
|
174
|
+
help="Look for module in this directory (default: .)",
|
|
175
|
+
)
|
|
176
|
+
return parser
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _create_run_parser(
|
|
180
|
+
*,
|
|
181
|
+
include_eval_options: bool = False,
|
|
182
|
+
) -> argparse.ArgumentParser:
|
|
183
|
+
"""Base parser with core run options. Plugin options added dynamically.
|
|
184
|
+
|
|
185
|
+
`include_eval_options=True` adds eval-only flags (e.g. ``--show-output``).
|
|
186
|
+
Set when building the parser for ``apte eval``; left False for
|
|
187
|
+
``apte run`` so the eval-only flags don't pollute the test help/parsing.
|
|
188
|
+
"""
|
|
189
|
+
parser = argparse.ArgumentParser(
|
|
190
|
+
prog="apte eval" if include_eval_options else "apte run",
|
|
191
|
+
description="Run evals" if include_eval_options else "Run tests",
|
|
192
|
+
)
|
|
193
|
+
parser.add_argument(
|
|
194
|
+
"target",
|
|
195
|
+
help="Module and session: 'module:session' (e.g., 'demo:session')",
|
|
196
|
+
)
|
|
197
|
+
parser.add_argument(
|
|
198
|
+
"--app-dir",
|
|
199
|
+
default=".",
|
|
200
|
+
help="Look for module in this directory (default: .)",
|
|
201
|
+
)
|
|
202
|
+
parser.add_argument(
|
|
203
|
+
"-n",
|
|
204
|
+
"--concurrency",
|
|
205
|
+
type=int,
|
|
206
|
+
default=1,
|
|
207
|
+
help="Number of concurrent tests (default: 1)",
|
|
208
|
+
)
|
|
209
|
+
parser.add_argument(
|
|
210
|
+
"--collect-only",
|
|
211
|
+
dest="collect_only",
|
|
212
|
+
action="store_true",
|
|
213
|
+
help="Only collect and list tests, don't run them",
|
|
214
|
+
)
|
|
215
|
+
parser.add_argument(
|
|
216
|
+
"-x",
|
|
217
|
+
"--exitfirst",
|
|
218
|
+
action="store_true",
|
|
219
|
+
help="Exit after first failed test",
|
|
220
|
+
)
|
|
221
|
+
parser.add_argument(
|
|
222
|
+
"-s",
|
|
223
|
+
"--no-capture",
|
|
224
|
+
dest="no_capture",
|
|
225
|
+
action="store_true",
|
|
226
|
+
help="Disable stdout/stderr capture (show print output)",
|
|
227
|
+
)
|
|
228
|
+
parser.add_argument(
|
|
229
|
+
"-q",
|
|
230
|
+
"--quiet",
|
|
231
|
+
dest="quiet",
|
|
232
|
+
action="store_true",
|
|
233
|
+
help="Minimal output (summary only)",
|
|
234
|
+
)
|
|
235
|
+
parser.add_argument(
|
|
236
|
+
"-v",
|
|
237
|
+
"--verbose",
|
|
238
|
+
dest="verbosity",
|
|
239
|
+
action="count",
|
|
240
|
+
default=0,
|
|
241
|
+
help="Increase verbosity (-v for lifecycle, -vv for fixtures)",
|
|
242
|
+
)
|
|
243
|
+
parser.add_argument(
|
|
244
|
+
"--show-logs",
|
|
245
|
+
dest="show_logs",
|
|
246
|
+
nargs="?",
|
|
247
|
+
const="INFO",
|
|
248
|
+
default=None,
|
|
249
|
+
metavar="LEVEL",
|
|
250
|
+
help="Show captured log records (default: INFO+)",
|
|
251
|
+
)
|
|
252
|
+
if include_eval_options:
|
|
253
|
+
parser.add_argument(
|
|
254
|
+
"--show-output",
|
|
255
|
+
dest="show_output",
|
|
256
|
+
action="store_true",
|
|
257
|
+
help="Show eval inputs/output/expected per case",
|
|
258
|
+
)
|
|
259
|
+
parser.add_argument(
|
|
260
|
+
"--short",
|
|
261
|
+
dest="short",
|
|
262
|
+
action="store_true",
|
|
263
|
+
help="Compact eval output: only print scores that failed per case",
|
|
264
|
+
)
|
|
265
|
+
return parser
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _handle_run_command(kind_filter: str | None = None) -> None:
|
|
269
|
+
"""Handle 'apte run' / 'apte eval' with two-phase parsing."""
|
|
270
|
+
argv = sys.argv[2:]
|
|
271
|
+
include_eval_options = kind_filter == "eval"
|
|
272
|
+
|
|
273
|
+
# Phase 1: Parse base args to get target
|
|
274
|
+
base_parser = _create_base_parser()
|
|
275
|
+
base_args, remaining = base_parser.parse_known_args(argv)
|
|
276
|
+
|
|
277
|
+
# If --help without target, show full help with all plugin options
|
|
278
|
+
if ("--help" in remaining or "-h" in remaining) and not base_args.target:
|
|
279
|
+
full_parser = _create_run_parser(include_eval_options=include_eval_options)
|
|
280
|
+
for plugin_class in ApteSession.default_plugin_classes():
|
|
281
|
+
plugin_class.add_cli_options(full_parser)
|
|
282
|
+
full_parser.parse_args(["--help"])
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
if not base_args.target:
|
|
286
|
+
_create_run_parser(include_eval_options=include_eval_options).print_help()
|
|
287
|
+
sys.exit(1)
|
|
288
|
+
|
|
289
|
+
# Phase 2: Load session and register default plugins
|
|
290
|
+
session_target, suite_filter = parse_target(base_args.target)
|
|
291
|
+
try:
|
|
292
|
+
session = load_session(session_target, base_args.app_dir)
|
|
293
|
+
except LoadError as exc:
|
|
294
|
+
print(f"Error: {exc}")
|
|
295
|
+
sys.exit(1)
|
|
296
|
+
|
|
297
|
+
session.register_default_plugins()
|
|
298
|
+
|
|
299
|
+
# Phase 3: Build full parser with plugin options
|
|
300
|
+
full_parser = _create_run_parser(include_eval_options=include_eval_options)
|
|
301
|
+
for plugin_class in session.plugin_classes:
|
|
302
|
+
plugin_class.add_cli_options(full_parser)
|
|
303
|
+
|
|
304
|
+
# Phase 4: Full parse
|
|
305
|
+
args = full_parser.parse_args(argv)
|
|
306
|
+
|
|
307
|
+
# Phase 5: Build context
|
|
308
|
+
effective_verbosity = Verbosity.QUIET if args.quiet else args.verbosity
|
|
309
|
+
ctx_args: dict[str, Any] = {
|
|
310
|
+
**vars(args),
|
|
311
|
+
"target_suite": suite_filter,
|
|
312
|
+
"verbosity": effective_verbosity,
|
|
313
|
+
}
|
|
314
|
+
if kind_filter:
|
|
315
|
+
ctx_args["kind_filter"] = kind_filter
|
|
316
|
+
ctx = PluginContext(args=ctx_args)
|
|
317
|
+
|
|
318
|
+
# Phase 6: Run tests (api.run_session handles plugin activation)
|
|
319
|
+
run_tests(session, ctx, collect_only=args.collect_only)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def run_tests(
|
|
323
|
+
session: ApteSession,
|
|
324
|
+
ctx: PluginContext,
|
|
325
|
+
collect_only: bool = False,
|
|
326
|
+
) -> None:
|
|
327
|
+
if collect_only:
|
|
328
|
+
items = collect_tests(session, ctx=ctx)
|
|
329
|
+
print(f"Collected {len(items)} test(s):\n")
|
|
330
|
+
for item in items:
|
|
331
|
+
print(f" {item.node_id}")
|
|
332
|
+
sys.exit(0)
|
|
333
|
+
|
|
334
|
+
result = run_session(session, ctx=ctx)
|
|
335
|
+
exit_code_interrupted = 130
|
|
336
|
+
if result.interrupted:
|
|
337
|
+
sys.exit(exit_code_interrupted)
|
|
338
|
+
sys.exit(0 if result.success else 1)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
if __name__ == "__main__": # pragma: no cover
|
|
342
|
+
main()
|
apte/compat.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Compatibility imports for typing features across Python versions."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
if sys.version_info >= (3, 11):
|
|
6
|
+
from typing import LiteralString, Self
|
|
7
|
+
else:
|
|
8
|
+
from typing_extensions import LiteralString, Self
|
|
9
|
+
|
|
10
|
+
if sys.version_info >= (3, 13):
|
|
11
|
+
from typing import TypeIs
|
|
12
|
+
else:
|
|
13
|
+
from typing_extensions import TypeIs
|
|
14
|
+
|
|
15
|
+
__all__ = ["LiteralString", "Self", "TypeIs"]
|
apte/console.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""apte.console - progress output that bypasses test capture.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from apte import console
|
|
6
|
+
|
|
7
|
+
@fixture()
|
|
8
|
+
async def pipeline():
|
|
9
|
+
for i, scene in enumerate(scenes):
|
|
10
|
+
console.print(f"[bold]pipeline:[/] importing {scene.name} ({i+1}/{len(scenes)})")
|
|
11
|
+
await import_scene(scene)
|
|
12
|
+
|
|
13
|
+
# Raw mode - no markup processing
|
|
14
|
+
console.print("debug: raw bytes here", raw=True)
|
|
15
|
+
|
|
16
|
+
# Section mode - no per-test prefix (use for suite/session-level lines)
|
|
17
|
+
console.print(f" Results: {run_dir}", prefix=False)
|
|
18
|
+
|
|
19
|
+
Messages go through the event bus → reporters display them inline.
|
|
20
|
+
If no event bus is available (outside a apte session), falls back to stderr.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import contextlib
|
|
26
|
+
import re
|
|
27
|
+
|
|
28
|
+
from apte.events.types import Event
|
|
29
|
+
from apte.execution.capture import get_event_bus, real_stderr
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def print(msg: str, *, raw: bool = False, prefix: bool = True) -> None:
|
|
33
|
+
"""Print a message that bypasses test capture.
|
|
34
|
+
|
|
35
|
+
Goes through the event bus so reporters display it at the right place.
|
|
36
|
+
Supports Rich markup (stripped for ASCII reporter).
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
msg: The message to print. Supports Rich markup unless raw=True.
|
|
40
|
+
raw: If True, no markup processing - message passed as-is.
|
|
41
|
+
prefix: If False, omit the per-test indent/bar prefix. Use for
|
|
42
|
+
suite-level or session-level lines (e.g. "Results: <dir>") that
|
|
43
|
+
visually belong outside any single case's output block.
|
|
44
|
+
"""
|
|
45
|
+
bus = get_event_bus()
|
|
46
|
+
if bus is None:
|
|
47
|
+
_fallback_print(msg, raw)
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
# Intentional private access to `bus._handlers`: we need sync dispatch
|
|
51
|
+
# so messages appear immediately (not after the test). An earlier public
|
|
52
|
+
# `EventBus.emit_sync` was removed (commit e14ffd5) because its signal-
|
|
53
|
+
# handler use case was async-signal-unsafe, and we don't want to offer
|
|
54
|
+
# that API to users. Kept private here - the framework itself is the
|
|
55
|
+
# only caller, and console.print is never invoked from a signal handler.
|
|
56
|
+
for handler_entry in bus._handlers.get(Event.USER_PRINT, []):
|
|
57
|
+
try:
|
|
58
|
+
handler_entry.func((msg, raw, prefix))
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
# Surface handler failures (typically: malformed Rich markup) on
|
|
61
|
+
# real stderr so users don't conclude `console.print` is silently
|
|
62
|
+
# broken. Wrapped in suppress() to guarantee the loop continues
|
|
63
|
+
# even if the fallback write itself raises.
|
|
64
|
+
with contextlib.suppress(Exception):
|
|
65
|
+
stream = real_stderr()
|
|
66
|
+
stream.write(f"console.print: handler raised {exc!r}\n")
|
|
67
|
+
stream.flush()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _fallback_print(msg: str, raw: bool) -> None:
|
|
71
|
+
"""Fallback when no event bus - write to real stderr (bypassing capture)."""
|
|
72
|
+
text = msg if raw else strip_markup(msg)
|
|
73
|
+
stream = real_stderr()
|
|
74
|
+
stream.write(text + "\n")
|
|
75
|
+
stream.flush()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def strip_markup(msg: str) -> str:
|
|
79
|
+
"""Strip Rich markup tags from a string.
|
|
80
|
+
|
|
81
|
+
Handles escaped brackets (``\\[text]`` → ``[text]``).
|
|
82
|
+
"""
|
|
83
|
+
msg = msg.replace("\\[", "\x00")
|
|
84
|
+
msg = re.sub(r"\[/?[^\]]*\]", "", msg)
|
|
85
|
+
return msg.replace("\x00", "[")
|
apte/core/__init__.py
ADDED
|
File without changes
|
apte/core/collector.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from inspect import signature
|
|
4
|
+
from itertools import groupby, product
|
|
5
|
+
from typing import TYPE_CHECKING, Annotated, Any, get_args, get_origin
|
|
6
|
+
|
|
7
|
+
from apte.di.decorators import get_fixture_marker, unwrap_fixture
|
|
8
|
+
from apte.di.hints import get_type_hints_compat
|
|
9
|
+
from apte.di.markers import Use
|
|
10
|
+
from apte.di.validation import _extract_from_params
|
|
11
|
+
from apte.entities import FixtureCallable, SuitePath, TestItem, TestRegistration
|
|
12
|
+
from apte.evals.evaluator import EvalCase
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
|
|
17
|
+
from apte.core.session import ApteSession
|
|
18
|
+
from apte.core.suite import ApteSuite
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _extract_use_fixtures(func: Callable[..., Any]) -> list[FixtureCallable]:
|
|
22
|
+
"""Extract fixtures referenced via Use() markers in function parameters."""
|
|
23
|
+
type_hints = get_type_hints_compat(func)
|
|
24
|
+
|
|
25
|
+
fixtures: list[FixtureCallable] = []
|
|
26
|
+
for param_name in signature(func).parameters:
|
|
27
|
+
annotation = type_hints.get(param_name)
|
|
28
|
+
if annotation is not None and get_origin(annotation) is Annotated:
|
|
29
|
+
for metadata in get_args(annotation)[1:]:
|
|
30
|
+
if isinstance(metadata, Use):
|
|
31
|
+
# Unwrap FixtureWrapper to get the original function
|
|
32
|
+
fixtures.append(unwrap_fixture(metadata.dependency))
|
|
33
|
+
break
|
|
34
|
+
return fixtures
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_transitive_fixtures(
|
|
38
|
+
func: Callable[..., Any],
|
|
39
|
+
visited: set[FixtureCallable] | None = None,
|
|
40
|
+
) -> set[FixtureCallable]:
|
|
41
|
+
"""Get all fixtures (direct + transitive) used by a function.
|
|
42
|
+
|
|
43
|
+
Recursively follows Use() markers to collect all fixtures in the
|
|
44
|
+
dependency chain. Handles cycles via visited set.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
func: The function to analyze (test or fixture).
|
|
48
|
+
visited: Set of already-visited fixtures (for cycle prevention).
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Set of all fixture functions used directly or transitively.
|
|
52
|
+
"""
|
|
53
|
+
if visited is None:
|
|
54
|
+
visited = set()
|
|
55
|
+
|
|
56
|
+
result: set[FixtureCallable] = set()
|
|
57
|
+
direct = _extract_use_fixtures(func)
|
|
58
|
+
|
|
59
|
+
for fixture_func in direct:
|
|
60
|
+
if fixture_func in visited:
|
|
61
|
+
continue
|
|
62
|
+
visited.add(fixture_func)
|
|
63
|
+
result.add(fixture_func)
|
|
64
|
+
# Recursively get fixtures used by this fixture
|
|
65
|
+
result.update(get_transitive_fixtures(fixture_func, visited))
|
|
66
|
+
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Collector:
|
|
71
|
+
"""Collects tests from a session, recursively traversing nested suites."""
|
|
72
|
+
|
|
73
|
+
def __init__(self) -> None:
|
|
74
|
+
self._fixture_tags: dict[FixtureCallable, set[str]] = {}
|
|
75
|
+
self._fixture_deps: dict[FixtureCallable, list[FixtureCallable]] = {}
|
|
76
|
+
|
|
77
|
+
def collect(self, session: ApteSession) -> list[TestItem]:
|
|
78
|
+
"""Collect all tests from session and nested suites into TestItems."""
|
|
79
|
+
self._build_fixture_index(session)
|
|
80
|
+
|
|
81
|
+
items: list[TestItem] = []
|
|
82
|
+
|
|
83
|
+
for test_reg in session.tests:
|
|
84
|
+
items.extend(self._expand_registration(test_reg, suite=None))
|
|
85
|
+
|
|
86
|
+
for suite in session.suites:
|
|
87
|
+
items.extend(self._collect_from_suite(suite))
|
|
88
|
+
|
|
89
|
+
return items
|
|
90
|
+
|
|
91
|
+
def _build_fixture_index(self, session: ApteSession) -> None:
|
|
92
|
+
"""Build index of all fixtures with their tags and dependencies."""
|
|
93
|
+
for fixture_reg in session.fixtures:
|
|
94
|
+
self._fixture_tags[fixture_reg.func] = fixture_reg.tags
|
|
95
|
+
self._fixture_deps[fixture_reg.func] = _extract_use_fixtures(
|
|
96
|
+
fixture_reg.func
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
self._index_suite_fixtures(session.suites)
|
|
100
|
+
|
|
101
|
+
def _index_suite_fixtures(self, suites: list[ApteSuite]) -> None:
|
|
102
|
+
"""Recursively index fixtures from suites."""
|
|
103
|
+
for suite in suites:
|
|
104
|
+
for fixture_reg in suite.fixtures:
|
|
105
|
+
self._fixture_tags[fixture_reg.func] = fixture_reg.tags
|
|
106
|
+
self._fixture_deps[fixture_reg.func] = _extract_use_fixtures(
|
|
107
|
+
fixture_reg.func
|
|
108
|
+
)
|
|
109
|
+
self._index_suite_fixtures(suite.suites)
|
|
110
|
+
|
|
111
|
+
def _get_transitive_fixture_tags(
|
|
112
|
+
self, func: FixtureCallable, visited: set[FixtureCallable] | None = None
|
|
113
|
+
) -> set[str]:
|
|
114
|
+
"""Get all tags from a fixture and its dependencies (transitive)."""
|
|
115
|
+
if visited is None:
|
|
116
|
+
visited = set()
|
|
117
|
+
if func in visited:
|
|
118
|
+
return set()
|
|
119
|
+
visited.add(func)
|
|
120
|
+
|
|
121
|
+
# Try indexed tags first, fallback to marker for unbound fixtures
|
|
122
|
+
if func in self._fixture_tags:
|
|
123
|
+
tags = self._fixture_tags[func].copy()
|
|
124
|
+
else:
|
|
125
|
+
marker = get_fixture_marker(func)
|
|
126
|
+
tags = set(marker.tags) if marker else set()
|
|
127
|
+
|
|
128
|
+
# Get dependencies: indexed or extract from signature for unbound
|
|
129
|
+
deps = self._fixture_deps.get(func) or _extract_use_fixtures(func)
|
|
130
|
+
for dep_func in deps:
|
|
131
|
+
tags.update(self._get_transitive_fixture_tags(dep_func, visited))
|
|
132
|
+
|
|
133
|
+
return tags
|
|
134
|
+
|
|
135
|
+
def _compute_test_tags(
|
|
136
|
+
self, test_reg: TestRegistration, suite: ApteSuite | None
|
|
137
|
+
) -> set[str]:
|
|
138
|
+
"""Compute all tags for a test: explicit + suite + fixture (transitive)."""
|
|
139
|
+
tags = test_reg.tags.copy()
|
|
140
|
+
|
|
141
|
+
if suite:
|
|
142
|
+
tags.update(suite.all_tags)
|
|
143
|
+
|
|
144
|
+
for fixture_func in _extract_use_fixtures(test_reg.func):
|
|
145
|
+
tags.update(self._get_transitive_fixture_tags(fixture_func))
|
|
146
|
+
|
|
147
|
+
return tags
|
|
148
|
+
|
|
149
|
+
def _expand_registration(
|
|
150
|
+
self, test_reg: TestRegistration, suite: ApteSuite | None
|
|
151
|
+
) -> list[TestItem]:
|
|
152
|
+
"""Expand a TestRegistration into TestItems with computed tags."""
|
|
153
|
+
tags = self._compute_test_tags(test_reg, suite)
|
|
154
|
+
from_params = _extract_from_params(test_reg.func)
|
|
155
|
+
|
|
156
|
+
if not from_params:
|
|
157
|
+
return [
|
|
158
|
+
TestItem(
|
|
159
|
+
func=test_reg.func,
|
|
160
|
+
suite=suite,
|
|
161
|
+
tags=tags,
|
|
162
|
+
skip=test_reg.skip,
|
|
163
|
+
xfail=test_reg.xfail,
|
|
164
|
+
timeout=test_reg.timeout,
|
|
165
|
+
retry=test_reg.retry,
|
|
166
|
+
is_eval=test_reg.is_eval,
|
|
167
|
+
)
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
param_names = list(from_params.keys())
|
|
171
|
+
sources = [from_params[name] for name in param_names]
|
|
172
|
+
|
|
173
|
+
items: list[TestItem] = []
|
|
174
|
+
for combination in product(*sources):
|
|
175
|
+
case_kwargs = dict(zip(param_names, combination, strict=True))
|
|
176
|
+
case_ids = [
|
|
177
|
+
sources[index].get_id(value) for index, value in enumerate(combination)
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
item_tags = tags.copy()
|
|
181
|
+
for value in combination:
|
|
182
|
+
if isinstance(value, EvalCase) and value.tags:
|
|
183
|
+
item_tags.update(value.tags)
|
|
184
|
+
|
|
185
|
+
items.append(
|
|
186
|
+
TestItem(
|
|
187
|
+
func=test_reg.func,
|
|
188
|
+
suite=suite,
|
|
189
|
+
tags=item_tags,
|
|
190
|
+
case_kwargs=case_kwargs,
|
|
191
|
+
case_ids=case_ids,
|
|
192
|
+
skip=test_reg.skip,
|
|
193
|
+
xfail=test_reg.xfail,
|
|
194
|
+
timeout=test_reg.timeout,
|
|
195
|
+
retry=test_reg.retry,
|
|
196
|
+
is_eval=test_reg.is_eval,
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return items
|
|
201
|
+
|
|
202
|
+
def _collect_from_suite(self, suite: ApteSuite) -> list[TestItem]:
|
|
203
|
+
"""Recursively collect tests from suite and its children."""
|
|
204
|
+
items: list[TestItem] = []
|
|
205
|
+
|
|
206
|
+
for test_reg in suite.tests:
|
|
207
|
+
items.extend(self._expand_registration(test_reg, suite))
|
|
208
|
+
|
|
209
|
+
for child in suite.suites:
|
|
210
|
+
items.extend(self._collect_from_suite(child))
|
|
211
|
+
|
|
212
|
+
return items
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def chunk_by_suite(items: list[TestItem]) -> list[list[TestItem]]:
|
|
216
|
+
"""Group contiguous tests by suite."""
|
|
217
|
+
if not items:
|
|
218
|
+
return []
|
|
219
|
+
return [list(group) for _, group in groupby(items, key=lambda item: item.suite)]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def get_last_chunk_index_per_suite(
|
|
223
|
+
chunks: list[list[TestItem]],
|
|
224
|
+
) -> dict[SuitePath, int]:
|
|
225
|
+
"""For each suite, return the index of its last chunk (including descendants)."""
|
|
226
|
+
last_indices: dict[SuitePath, int] = {}
|
|
227
|
+
for chunk_idx, chunk in enumerate(chunks):
|
|
228
|
+
suite = chunk[0].suite
|
|
229
|
+
if suite:
|
|
230
|
+
last_indices[suite.full_path] = chunk_idx
|
|
231
|
+
_update_parent_indices(last_indices, suite, chunk_idx)
|
|
232
|
+
return last_indices
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _update_parent_indices(
|
|
236
|
+
last_indices: dict[SuitePath, int], suite: ApteSuite, chunk_idx: int
|
|
237
|
+
) -> None:
|
|
238
|
+
"""Update parent suite indices when a child chunk is seen."""
|
|
239
|
+
parent = suite._parent_suite
|
|
240
|
+
while parent:
|
|
241
|
+
last_indices[parent.full_path] = chunk_idx
|
|
242
|
+
parent = parent._parent_suite
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Execution components extracted from runner.py."""
|
|
2
|
+
|
|
3
|
+
from apte.core.execution.parallel import ParallelExecutor
|
|
4
|
+
from apte.core.execution.suite_manager import SuiteManager
|
|
5
|
+
from apte.core.execution.test_executor import TestExecutor
|
|
6
|
+
|
|
7
|
+
__all__ = ["ParallelExecutor", "SuiteManager", "TestExecutor"]
|