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.
Files changed (91) hide show
  1. apte/__init__.py +55 -0
  2. apte/__main__.py +5 -0
  3. apte/api.py +194 -0
  4. apte/assertions.py +152 -0
  5. apte/cache/__init__.py +6 -0
  6. apte/cache/plugin.py +94 -0
  7. apte/cache/storage.py +132 -0
  8. apte/cli/__init__.py +0 -0
  9. apte/cli/main.py +342 -0
  10. apte/compat.py +15 -0
  11. apte/console.py +85 -0
  12. apte/core/__init__.py +0 -0
  13. apte/core/collector.py +242 -0
  14. apte/core/execution/__init__.py +7 -0
  15. apte/core/execution/parallel.py +304 -0
  16. apte/core/execution/suite_manager.py +93 -0
  17. apte/core/execution/test_executor.py +371 -0
  18. apte/core/fixture.py +14 -0
  19. apte/core/outcome.py +137 -0
  20. apte/core/runner.py +206 -0
  21. apte/core/session.py +382 -0
  22. apte/core/suite.py +236 -0
  23. apte/core/tracker.py +50 -0
  24. apte/di/__init__.py +0 -0
  25. apte/di/container.py +851 -0
  26. apte/di/decorators.py +220 -0
  27. apte/di/factory.py +79 -0
  28. apte/di/hashable.py +57 -0
  29. apte/di/hints.py +163 -0
  30. apte/di/markers.py +79 -0
  31. apte/di/proxy.py +81 -0
  32. apte/di/validation.py +38 -0
  33. apte/entities/__init__.py +70 -0
  34. apte/entities/core.py +158 -0
  35. apte/entities/events.py +171 -0
  36. apte/entities/log_capture.py +28 -0
  37. apte/entities/retry.py +31 -0
  38. apte/entities/skip.py +63 -0
  39. apte/entities/suite_path.py +70 -0
  40. apte/entities/xfail.py +24 -0
  41. apte/evals/__init__.py +45 -0
  42. apte/evals/evaluator.py +420 -0
  43. apte/evals/evaluators.py +199 -0
  44. apte/evals/hashing.py +109 -0
  45. apte/evals/results_writer.py +175 -0
  46. apte/evals/suite.py +98 -0
  47. apte/evals/types.py +356 -0
  48. apte/evals/wrapper.py +309 -0
  49. apte/events/__init__.py +0 -0
  50. apte/events/bus.py +231 -0
  51. apte/events/types.py +38 -0
  52. apte/exceptions.py +188 -0
  53. apte/execution/__init__.py +0 -0
  54. apte/execution/async_bridge.py +36 -0
  55. apte/execution/capture.py +264 -0
  56. apte/execution/context.py +73 -0
  57. apte/execution/interrupt.py +118 -0
  58. apte/execution/runner.py +0 -0
  59. apte/filters/__init__.py +4 -0
  60. apte/filters/keyword.py +52 -0
  61. apte/filters/kind.py +37 -0
  62. apte/filters/suite.py +43 -0
  63. apte/fixtures/__init__.py +0 -0
  64. apte/fixtures/builtins.py +38 -0
  65. apte/fixtures/mocker.py +145 -0
  66. apte/history/__init__.py +17 -0
  67. apte/history/collector.py +80 -0
  68. apte/history/plugin.py +254 -0
  69. apte/history/storage.py +295 -0
  70. apte/loader.py +85 -0
  71. apte/plugin.py +221 -0
  72. apte/py.typed +0 -0
  73. apte/reporting/__init__.py +10 -0
  74. apte/reporting/ascii.py +419 -0
  75. apte/reporting/ctrf.py +252 -0
  76. apte/reporting/factory.py +31 -0
  77. apte/reporting/format.py +39 -0
  78. apte/reporting/log_file.py +111 -0
  79. apte/reporting/rich_reporter.py +523 -0
  80. apte/reporting/verbosity.py +18 -0
  81. apte/reporting/web.py +347 -0
  82. apte/shell.py +200 -0
  83. apte/tags/__init__.py +5 -0
  84. apte/tags/plugin.py +77 -0
  85. apte/utils.py +26 -0
  86. apte-0.3.0.dist-info/METADATA +211 -0
  87. apte-0.3.0.dist-info/RECORD +91 -0
  88. apte-0.3.0.dist-info/WHEEL +5 -0
  89. apte-0.3.0.dist-info/entry_points.txt +2 -0
  90. apte-0.3.0.dist-info/licenses/LICENSE +21 -0
  91. 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"]