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/__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
@@ -0,0 +1,5 @@
1
+ """Allow running apte as: python -m apte / coverage run -m apte."""
2
+
3
+ from apte.cli.main import main
4
+
5
+ main()
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
@@ -0,0 +1,6 @@
1
+ """Cache module for Apte."""
2
+
3
+ from apte.cache.plugin import CachePlugin
4
+ from apte.cache.storage import CacheStorage, TestCacheEntry
5
+
6
+ __all__ = ["CachePlugin", "CacheStorage", "TestCacheEntry"]
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