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/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from apte import console
|
|
2
|
+
from apte.api import collect_tests, list_tags, run_session
|
|
3
|
+
from apte.assertions import ExceptionInfo, RaisesContext, raises, warns
|
|
4
|
+
from apte.core.session import ApteSession
|
|
5
|
+
from apte.core.suite import ApteSuite
|
|
6
|
+
from apte.di.decorators import factory, fixture
|
|
7
|
+
from apte.di.factory import FixtureFactory
|
|
8
|
+
from apte.di.markers import ForEach, From, Use
|
|
9
|
+
from apte.entities import FixtureCallable, Retry, Skip, Xfail
|
|
10
|
+
from apte.exceptions import ApteError, CircularDependencyError, FixtureError
|
|
11
|
+
from apte.fixtures.builtins import caplog, mocker, tmp_path
|
|
12
|
+
from apte.fixtures.mocker import AsyncMockType, Mocker, MockType
|
|
13
|
+
from apte.loader import LoadError, load_session
|
|
14
|
+
from apte.plugin import PluginBase
|
|
15
|
+
from apte.shell import CommandResult, Shell
|
|
16
|
+
|
|
17
|
+
__version__ = "0.3.0"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"ApteError",
|
|
21
|
+
"ApteSession",
|
|
22
|
+
"ApteSuite",
|
|
23
|
+
"AsyncMockType",
|
|
24
|
+
"CircularDependencyError",
|
|
25
|
+
"CommandResult",
|
|
26
|
+
"ExceptionInfo",
|
|
27
|
+
"FixtureCallable",
|
|
28
|
+
"FixtureError",
|
|
29
|
+
"FixtureFactory",
|
|
30
|
+
"ForEach",
|
|
31
|
+
"From",
|
|
32
|
+
"LoadError",
|
|
33
|
+
"MockType",
|
|
34
|
+
"Mocker",
|
|
35
|
+
"PluginBase",
|
|
36
|
+
"RaisesContext",
|
|
37
|
+
"Retry",
|
|
38
|
+
"Shell",
|
|
39
|
+
"Skip",
|
|
40
|
+
"Use",
|
|
41
|
+
"Xfail",
|
|
42
|
+
"__version__",
|
|
43
|
+
"caplog",
|
|
44
|
+
"collect_tests",
|
|
45
|
+
"console",
|
|
46
|
+
"factory",
|
|
47
|
+
"fixture",
|
|
48
|
+
"list_tags",
|
|
49
|
+
"load_session",
|
|
50
|
+
"mocker",
|
|
51
|
+
"raises",
|
|
52
|
+
"run_session",
|
|
53
|
+
"tmp_path",
|
|
54
|
+
"warns",
|
|
55
|
+
]
|
apte/__main__.py
ADDED
apte/api.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Public API for running Apte sessions.
|
|
2
|
+
|
|
3
|
+
This module provides the main entry points for running tests programmatically,
|
|
4
|
+
independent of any CLI or adapter implementation.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
from apte import ApteSession
|
|
8
|
+
from apte.api import run_session
|
|
9
|
+
|
|
10
|
+
session = ApteSession()
|
|
11
|
+
|
|
12
|
+
@session.test()
|
|
13
|
+
def test_example():
|
|
14
|
+
assert True
|
|
15
|
+
|
|
16
|
+
success = run_session(session)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
from typing import TYPE_CHECKING
|
|
23
|
+
|
|
24
|
+
from apte.core.collector import Collector
|
|
25
|
+
from apte.core.runner import TestRunner
|
|
26
|
+
from apte.core.suite import (
|
|
27
|
+
ApteSuite, # noqa: TC001 - used at runtime in list_tags
|
|
28
|
+
)
|
|
29
|
+
from apte.events.types import Event
|
|
30
|
+
from apte.filters.keyword import KeywordFilterPlugin
|
|
31
|
+
from apte.filters.kind import KindFilterPlugin
|
|
32
|
+
from apte.filters.suite import SuiteFilterPlugin
|
|
33
|
+
from apte.plugin import PluginBase, PluginContext
|
|
34
|
+
from apte.tags.plugin import TagFilterPlugin
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from apte.core.session import ApteSession
|
|
38
|
+
from apte.entities import RunResult, TestItem
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def run_session( # noqa: PLR0913 - public API with many optional params
|
|
42
|
+
session: ApteSession,
|
|
43
|
+
concurrency: int | None = None,
|
|
44
|
+
exitfirst: bool = False,
|
|
45
|
+
last_failed: bool = False,
|
|
46
|
+
cache_clear: bool = False,
|
|
47
|
+
include_tags: set[str] | None = None,
|
|
48
|
+
exclude_tags: set[str] | None = None,
|
|
49
|
+
capture: bool = True,
|
|
50
|
+
suite_filter: str | None = None,
|
|
51
|
+
keyword_patterns: list[str] | None = None,
|
|
52
|
+
log_file: bool = True,
|
|
53
|
+
force_no_color: bool = False,
|
|
54
|
+
*,
|
|
55
|
+
ctx: PluginContext | None = None,
|
|
56
|
+
) -> RunResult:
|
|
57
|
+
"""Run a test session and return result with success and interrupted status.
|
|
58
|
+
|
|
59
|
+
This is the main entry point for running tests programmatically.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
session: The ApteSession to run.
|
|
63
|
+
concurrency: Number of concurrent workers (None = use session default).
|
|
64
|
+
exitfirst: Stop after first failure.
|
|
65
|
+
last_failed: Only run tests that failed in the last run.
|
|
66
|
+
cache_clear: Clear the cache before running.
|
|
67
|
+
include_tags: Only run tests with these tags (OR logic).
|
|
68
|
+
exclude_tags: Exclude tests with these tags.
|
|
69
|
+
capture: Capture stdout/stderr during tests (default: True).
|
|
70
|
+
suite_filter: Only run tests in this suite (::SuiteName syntax).
|
|
71
|
+
keyword_patterns: Only run tests matching these patterns (-k flag).
|
|
72
|
+
log_file: Write output to .apte/last_run.log (default: True).
|
|
73
|
+
force_no_color: Disable colors (--no-color flag).
|
|
74
|
+
ctx: Plugin context (if provided, overrides individual params above).
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
RunResult with success status and interrupted flag.
|
|
78
|
+
"""
|
|
79
|
+
# Apply session-level settings from ctx or params
|
|
80
|
+
if ctx is not None:
|
|
81
|
+
if ctx.get("concurrency") is not None:
|
|
82
|
+
session.concurrency = ctx.get("concurrency")
|
|
83
|
+
session.exitfirst = ctx.get("exitfirst", False)
|
|
84
|
+
session.capture = not ctx.get("no_capture", False)
|
|
85
|
+
else:
|
|
86
|
+
if concurrency is not None:
|
|
87
|
+
session.concurrency = concurrency
|
|
88
|
+
session.exitfirst = exitfirst
|
|
89
|
+
session.capture = capture
|
|
90
|
+
|
|
91
|
+
# Register default plugins if none registered
|
|
92
|
+
if not session.plugin_classes:
|
|
93
|
+
session.register_default_plugins()
|
|
94
|
+
|
|
95
|
+
# Build context from parameters if not provided
|
|
96
|
+
if ctx is None:
|
|
97
|
+
ctx = PluginContext(
|
|
98
|
+
args={
|
|
99
|
+
"last_failed": last_failed,
|
|
100
|
+
"cache_clear": cache_clear,
|
|
101
|
+
"tags": list(include_tags) if include_tags else [],
|
|
102
|
+
"exclude_tags": list(exclude_tags) if exclude_tags else [],
|
|
103
|
+
"target_suite": suite_filter,
|
|
104
|
+
"keywords": keyword_patterns or [],
|
|
105
|
+
"no_log_file": not log_file,
|
|
106
|
+
"no_color": force_no_color,
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
session.activate_plugins(ctx)
|
|
111
|
+
|
|
112
|
+
runner = TestRunner(session)
|
|
113
|
+
return runner.run()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def collect_tests( # noqa: PLR0913 - public API with many optional params
|
|
117
|
+
session: ApteSession,
|
|
118
|
+
include_tags: set[str] | None = None,
|
|
119
|
+
exclude_tags: set[str] | None = None,
|
|
120
|
+
suite_filter: str | None = None,
|
|
121
|
+
keyword_patterns: list[str] | None = None,
|
|
122
|
+
*,
|
|
123
|
+
ctx: PluginContext | None = None,
|
|
124
|
+
) -> list[TestItem]:
|
|
125
|
+
"""Collect tests from a session without running them.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
session: The ApteSession to collect from.
|
|
129
|
+
include_tags: Only include tests with these tags.
|
|
130
|
+
exclude_tags: Exclude tests with these tags.
|
|
131
|
+
suite_filter: Only include tests in this suite.
|
|
132
|
+
keyword_patterns: Only include tests matching these patterns.
|
|
133
|
+
ctx: Plugin context (if provided, overrides individual params above).
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
List of collected TestItem objects.
|
|
137
|
+
"""
|
|
138
|
+
# Build context from parameters if not provided
|
|
139
|
+
if ctx is None:
|
|
140
|
+
ctx = PluginContext(
|
|
141
|
+
args={
|
|
142
|
+
"tags": list(include_tags) if include_tags else [],
|
|
143
|
+
"exclude_tags": list(exclude_tags) if exclude_tags else [],
|
|
144
|
+
"target_suite": suite_filter,
|
|
145
|
+
"keywords": keyword_patterns or [],
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Activate filter plugins
|
|
150
|
+
filter_plugins: list[type[PluginBase]] = [
|
|
151
|
+
TagFilterPlugin,
|
|
152
|
+
SuiteFilterPlugin,
|
|
153
|
+
KeywordFilterPlugin,
|
|
154
|
+
KindFilterPlugin,
|
|
155
|
+
]
|
|
156
|
+
for plugin_class in filter_plugins:
|
|
157
|
+
instance = plugin_class.activate(ctx)
|
|
158
|
+
if instance is not None:
|
|
159
|
+
session.register_plugin(instance)
|
|
160
|
+
|
|
161
|
+
collector = Collector()
|
|
162
|
+
items = collector.collect(session)
|
|
163
|
+
return asyncio.run(session.events.emit_and_collect(Event.COLLECTION_FINISH, items))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def list_tags(session: ApteSession) -> set[str]:
|
|
167
|
+
"""List all declared tags in a session.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
session: The ApteSession to inspect.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Set of all tag names declared on fixtures, suites, and tests.
|
|
174
|
+
"""
|
|
175
|
+
all_tags: set[str] = set()
|
|
176
|
+
|
|
177
|
+
for fixture_reg in session.fixtures:
|
|
178
|
+
all_tags.update(fixture_reg.tags)
|
|
179
|
+
|
|
180
|
+
for test_reg in session.tests:
|
|
181
|
+
all_tags.update(test_reg.tags)
|
|
182
|
+
|
|
183
|
+
def collect_from_suites(suites: list[ApteSuite]) -> None:
|
|
184
|
+
for suite in suites:
|
|
185
|
+
all_tags.update(suite.tags)
|
|
186
|
+
for fixture_reg in suite.fixtures:
|
|
187
|
+
all_tags.update(fixture_reg.tags)
|
|
188
|
+
for test_reg in suite.tests:
|
|
189
|
+
all_tags.update(test_reg.tags)
|
|
190
|
+
collect_from_suites(suite.suites)
|
|
191
|
+
|
|
192
|
+
collect_from_suites(session.suites)
|
|
193
|
+
|
|
194
|
+
return all_tags
|
apte/assertions.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import warnings
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from re import Pattern
|
|
5
|
+
from types import TracebackType
|
|
6
|
+
from typing import TYPE_CHECKING, Generic, TypeVar
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Generator
|
|
10
|
+
|
|
11
|
+
E = TypeVar("E", bound=BaseException)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ExceptionInfo(Generic[E]):
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self._value: E | None = None
|
|
17
|
+
self._type: type[E] | None = None
|
|
18
|
+
self._traceback: TracebackType | None = None
|
|
19
|
+
|
|
20
|
+
def _populate(
|
|
21
|
+
self,
|
|
22
|
+
exc_type: type[E],
|
|
23
|
+
exc_value: E,
|
|
24
|
+
exc_tb: TracebackType | None,
|
|
25
|
+
) -> None:
|
|
26
|
+
self._type = exc_type
|
|
27
|
+
self._value = exc_value
|
|
28
|
+
self._traceback = exc_tb
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def value(self) -> E:
|
|
32
|
+
if self._value is None:
|
|
33
|
+
raise AssertionError("No exception captured yet")
|
|
34
|
+
return self._value
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def type(self) -> type[E]:
|
|
38
|
+
if self._type is None:
|
|
39
|
+
raise AssertionError("No exception captured yet")
|
|
40
|
+
return self._type
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def traceback(self) -> TracebackType | None:
|
|
44
|
+
return self._traceback
|
|
45
|
+
|
|
46
|
+
def match(self, pattern: str | Pattern[str]) -> re.Match[str]:
|
|
47
|
+
compiled = re.compile(pattern) if isinstance(pattern, str) else pattern
|
|
48
|
+
result = compiled.search(str(self.value))
|
|
49
|
+
if result is None:
|
|
50
|
+
raise AssertionError(
|
|
51
|
+
f"Pattern '{pattern}' not found in '{self.value}'"
|
|
52
|
+
) from self._value
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RaisesContext(Generic[E]):
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
expected_exception: type[E],
|
|
60
|
+
match: str | Pattern[str] | None,
|
|
61
|
+
) -> None:
|
|
62
|
+
self._expected = expected_exception
|
|
63
|
+
self._match_pattern: Pattern[str] | None = None
|
|
64
|
+
if match is not None:
|
|
65
|
+
self._match_pattern = re.compile(match) if isinstance(match, str) else match
|
|
66
|
+
self._exc_info: ExceptionInfo[E] = ExceptionInfo()
|
|
67
|
+
|
|
68
|
+
def __enter__(self) -> ExceptionInfo[E]:
|
|
69
|
+
return self._exc_info
|
|
70
|
+
|
|
71
|
+
def __exit__(
|
|
72
|
+
self,
|
|
73
|
+
exc_type: type[BaseException] | None,
|
|
74
|
+
exc_val: BaseException | None,
|
|
75
|
+
exc_tb: TracebackType | None,
|
|
76
|
+
) -> bool:
|
|
77
|
+
if exc_type is None:
|
|
78
|
+
raise AssertionError(f"DID NOT RAISE {self._expected.__name__}")
|
|
79
|
+
|
|
80
|
+
if not issubclass(exc_type, self._expected):
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
self._exc_info._populate(exc_type, exc_val, exc_tb) # type: ignore[arg-type]
|
|
84
|
+
|
|
85
|
+
if self._match_pattern is not None:
|
|
86
|
+
result = self._match_pattern.search(str(exc_val))
|
|
87
|
+
if result is None:
|
|
88
|
+
raise AssertionError(
|
|
89
|
+
f"Pattern '{self._match_pattern.pattern}' not found in '{exc_val}'"
|
|
90
|
+
) from exc_val
|
|
91
|
+
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def raises(
|
|
96
|
+
expected_exception: type[E],
|
|
97
|
+
match: str | Pattern[str] | None = None,
|
|
98
|
+
) -> RaisesContext[E]:
|
|
99
|
+
return RaisesContext(expected_exception, match=match)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@contextmanager
|
|
103
|
+
def warns(
|
|
104
|
+
expected_warning: type[Warning] | tuple[type[Warning], ...] | None = None,
|
|
105
|
+
match: str | Pattern[str] | None = None,
|
|
106
|
+
) -> "Generator[list[warnings.WarningMessage], None, None]":
|
|
107
|
+
"""Context manager for capturing and validating warnings.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
expected_warning: Warning type(s) expected. If None, captures all warnings.
|
|
111
|
+
match: Optional regex pattern to match against warning messages.
|
|
112
|
+
|
|
113
|
+
Yields:
|
|
114
|
+
List of captured warnings (stdlib warnings.WarningMessage objects).
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
AssertionError: If expected warning not raised or pattern not matched.
|
|
118
|
+
|
|
119
|
+
Examples:
|
|
120
|
+
with warns(DeprecationWarning):
|
|
121
|
+
warnings.warn("deprecated", DeprecationWarning)
|
|
122
|
+
|
|
123
|
+
with warns(UserWarning, match=r"value.*\\d+"):
|
|
124
|
+
warnings.warn("value is 42", UserWarning)
|
|
125
|
+
|
|
126
|
+
with warns() as record:
|
|
127
|
+
warnings.warn("hello", UserWarning)
|
|
128
|
+
assert record[0].category is UserWarning
|
|
129
|
+
"""
|
|
130
|
+
with warnings.catch_warnings(record=True) as record:
|
|
131
|
+
warnings.simplefilter("always")
|
|
132
|
+
yield record
|
|
133
|
+
|
|
134
|
+
if expected_warning is None:
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
matching = [w for w in record if issubclass(w.category, expected_warning)]
|
|
138
|
+
|
|
139
|
+
if not matching:
|
|
140
|
+
if isinstance(expected_warning, tuple):
|
|
141
|
+
names = ", ".join(e.__name__ for e in expected_warning)
|
|
142
|
+
else:
|
|
143
|
+
names = expected_warning.__name__
|
|
144
|
+
raise AssertionError(f"DID NOT WARN with {names}")
|
|
145
|
+
|
|
146
|
+
if match is not None:
|
|
147
|
+
pattern = re.compile(match) if isinstance(match, str) else match
|
|
148
|
+
if not any(pattern.search(str(w.message)) for w in matching):
|
|
149
|
+
messages = [str(w.message) for w in matching]
|
|
150
|
+
raise AssertionError(
|
|
151
|
+
f"Pattern '{pattern.pattern}' not found in: {messages}"
|
|
152
|
+
)
|
apte/cache/__init__.py
ADDED
apte/cache/plugin.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Cache plugin for --lf (last-failed) and --cache-clear functionality."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from typing_extensions import Self
|
|
8
|
+
|
|
9
|
+
from apte.plugin import PluginBase, PluginContext
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from argparse import ArgumentParser
|
|
13
|
+
|
|
14
|
+
from apte.cache.storage import CacheStorage
|
|
15
|
+
from apte.core.session import ApteSession
|
|
16
|
+
from apte.entities import SessionResult, TestItem, TestResult
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CachePlugin(PluginBase):
|
|
20
|
+
"""Plugin that caches test results and provides --lf filtering."""
|
|
21
|
+
|
|
22
|
+
name = "cache"
|
|
23
|
+
description = "Test result caching with --lf support"
|
|
24
|
+
|
|
25
|
+
def __init__(self, last_failed: bool = False, cache_clear: bool = False) -> None:
|
|
26
|
+
self._last_failed = last_failed
|
|
27
|
+
self._cache_clear = cache_clear
|
|
28
|
+
self._cache: CacheStorage | None = None
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def add_cli_options(cls, parser: ArgumentParser) -> None:
|
|
32
|
+
group = parser.add_argument_group(f"{cls.name} - {cls.description}")
|
|
33
|
+
group.add_argument(
|
|
34
|
+
"--lf",
|
|
35
|
+
"--last-failed",
|
|
36
|
+
dest="last_failed",
|
|
37
|
+
action="store_true",
|
|
38
|
+
help="Re-run only failed tests from last run",
|
|
39
|
+
)
|
|
40
|
+
group.add_argument(
|
|
41
|
+
"--cache-clear",
|
|
42
|
+
dest="cache_clear",
|
|
43
|
+
action="store_true",
|
|
44
|
+
help="Clear cache before run",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def activate(cls, ctx: PluginContext) -> Self:
|
|
49
|
+
return cls(
|
|
50
|
+
last_failed=ctx.get("last_failed", False),
|
|
51
|
+
cache_clear=ctx.get("cache_clear", False),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def setup(self, session: ApteSession) -> None:
|
|
55
|
+
self._cache = session.cache
|
|
56
|
+
if self._cache_clear:
|
|
57
|
+
self._cache.clear()
|
|
58
|
+
else:
|
|
59
|
+
self._cache.load()
|
|
60
|
+
|
|
61
|
+
def on_collection_finish(self, items: list[TestItem]) -> list[TestItem]:
|
|
62
|
+
"""Filter tests based on --lf flag."""
|
|
63
|
+
if self._last_failed and self._cache is not None:
|
|
64
|
+
return self._filter_last_failed(items)
|
|
65
|
+
return items
|
|
66
|
+
|
|
67
|
+
def on_test_pass(self, result: TestResult) -> None:
|
|
68
|
+
if self._cache is not None:
|
|
69
|
+
self._cache.set_result(result.node_id, "passed", result.duration)
|
|
70
|
+
|
|
71
|
+
def on_test_fail(self, result: TestResult) -> None:
|
|
72
|
+
if self._cache is not None:
|
|
73
|
+
status = "error" if result.is_fixture_error else "failed"
|
|
74
|
+
self._cache.set_result(result.node_id, status, result.duration)
|
|
75
|
+
|
|
76
|
+
def on_session_end(self, result: SessionResult) -> None:
|
|
77
|
+
if self._cache is not None:
|
|
78
|
+
self._cache.save()
|
|
79
|
+
|
|
80
|
+
def _filter_last_failed(self, items: list[TestItem]) -> list[TestItem]:
|
|
81
|
+
"""Keep only tests that failed in the last run.
|
|
82
|
+
|
|
83
|
+
If no failed tests exist in cache, returns all items.
|
|
84
|
+
If failed tests exist but none match current items, returns empty list.
|
|
85
|
+
"""
|
|
86
|
+
if self._cache is None:
|
|
87
|
+
raise RuntimeError(
|
|
88
|
+
"CachePlugin improperly configured: setup() must be called before "
|
|
89
|
+
"filtering. This indicates a bug in the plugin lifecycle."
|
|
90
|
+
)
|
|
91
|
+
failed_ids = self._cache.get_failed_node_ids()
|
|
92
|
+
if not failed_ids:
|
|
93
|
+
return items
|
|
94
|
+
return [item for item in items if item.node_id in failed_ids]
|
apte/cache/storage.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Shared cache storage for inter-plugin data sharing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class TestCacheEntry:
|
|
14
|
+
status: str
|
|
15
|
+
duration: float
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CacheStorage:
|
|
19
|
+
"""Shared cache storage accessible via session.cache.
|
|
20
|
+
|
|
21
|
+
Provides a clean API for plugins to read/write cached test data
|
|
22
|
+
without direct file dependencies.
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
# In a plugin
|
|
26
|
+
def setup(self, session: ApteSession) -> None:
|
|
27
|
+
results = session.cache.get_results()
|
|
28
|
+
durations = session.cache.get_durations()
|
|
29
|
+
|
|
30
|
+
def on_test_pass(self, result: TestResult) -> None:
|
|
31
|
+
session.cache.set_result(result.node_id, "passed", result.duration)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
DEFAULT_DIR = Path(".apte")
|
|
35
|
+
DEFAULT_FILE = "cache.json"
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self, cache_dir: Path | None = None, cache_file: str | None = None
|
|
39
|
+
) -> None:
|
|
40
|
+
self._cache_dir = cache_dir or self.DEFAULT_DIR
|
|
41
|
+
self._cache_file = self._cache_dir / (cache_file or self.DEFAULT_FILE)
|
|
42
|
+
self._data: dict[str, Any] = {}
|
|
43
|
+
self._results: dict[str, TestCacheEntry] = {}
|
|
44
|
+
self._dirty = False
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def cache_dir(self) -> Path:
|
|
48
|
+
return self._cache_dir
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def cache_file(self) -> Path:
|
|
52
|
+
return self._cache_file
|
|
53
|
+
|
|
54
|
+
def load(self) -> None:
|
|
55
|
+
"""Load cache from disk."""
|
|
56
|
+
if self._cache_file.exists():
|
|
57
|
+
try:
|
|
58
|
+
raw_data = json.loads(self._cache_file.read_text())
|
|
59
|
+
self._data = raw_data
|
|
60
|
+
self._load_results_from_data(raw_data)
|
|
61
|
+
except json.JSONDecodeError:
|
|
62
|
+
self._data = {}
|
|
63
|
+
self._results = {}
|
|
64
|
+
|
|
65
|
+
def _load_results_from_data(self, data: dict[str, Any]) -> None:
|
|
66
|
+
raw_results = data.get("results", {})
|
|
67
|
+
if not isinstance(raw_results, dict):
|
|
68
|
+
self._results = {}
|
|
69
|
+
return
|
|
70
|
+
self._results = {}
|
|
71
|
+
for node_id, entry in raw_results.items():
|
|
72
|
+
if isinstance(entry, dict):
|
|
73
|
+
self._results[node_id] = TestCacheEntry(
|
|
74
|
+
status=entry.get("status", "unknown"),
|
|
75
|
+
duration=float(entry.get("duration", 0.0)),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def save(self) -> None:
|
|
79
|
+
"""Save cache to disk."""
|
|
80
|
+
self._cache_dir.mkdir(exist_ok=True)
|
|
81
|
+
results_dict = {
|
|
82
|
+
node_id: {"status": entry.status, "duration": entry.duration}
|
|
83
|
+
for node_id, entry in self._results.items()
|
|
84
|
+
}
|
|
85
|
+
data = {
|
|
86
|
+
"version": 1,
|
|
87
|
+
"timestamp": time.time(),
|
|
88
|
+
"results": results_dict,
|
|
89
|
+
}
|
|
90
|
+
self._cache_file.write_text(json.dumps(data, indent=2))
|
|
91
|
+
self._dirty = False
|
|
92
|
+
|
|
93
|
+
def clear(self) -> None:
|
|
94
|
+
"""Clear all cached data."""
|
|
95
|
+
if self._cache_file.exists():
|
|
96
|
+
self._cache_file.unlink()
|
|
97
|
+
self._data = {}
|
|
98
|
+
self._results = {}
|
|
99
|
+
self._dirty = False
|
|
100
|
+
|
|
101
|
+
def get_results(self) -> dict[str, TestCacheEntry]:
|
|
102
|
+
"""Get all cached test results."""
|
|
103
|
+
return dict(self._results)
|
|
104
|
+
|
|
105
|
+
def get_result(self, node_id: str) -> TestCacheEntry | None:
|
|
106
|
+
"""Get cached result for a specific test."""
|
|
107
|
+
return self._results.get(node_id)
|
|
108
|
+
|
|
109
|
+
def set_result(self, node_id: str, status: str, duration: float) -> None:
|
|
110
|
+
"""Set a test result in the cache."""
|
|
111
|
+
self._results[node_id] = TestCacheEntry(status=status, duration=duration)
|
|
112
|
+
self._dirty = True
|
|
113
|
+
|
|
114
|
+
def get_durations(self) -> dict[str, float]:
|
|
115
|
+
"""Get all cached test durations (useful for duration-based ordering)."""
|
|
116
|
+
return {node_id: entry.duration for node_id, entry in self._results.items()}
|
|
117
|
+
|
|
118
|
+
def get_failed_node_ids(self) -> set[str]:
|
|
119
|
+
"""Get node IDs of tests that failed in the last run."""
|
|
120
|
+
return {
|
|
121
|
+
node_id
|
|
122
|
+
for node_id, entry in self._results.items()
|
|
123
|
+
if entry.status in ("failed", "error")
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
def get_passed_node_ids(self) -> set[str]:
|
|
127
|
+
"""Get node IDs of tests that passed in the last run."""
|
|
128
|
+
return {
|
|
129
|
+
node_id
|
|
130
|
+
for node_id, entry in self._results.items()
|
|
131
|
+
if entry.status == "passed"
|
|
132
|
+
}
|
apte/cli/__init__.py
ADDED
|
File without changes
|