uc-toffee 2.0.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.
- toffee/__init__.py +267 -0
- toffee/__main__.py +3 -0
- toffee/_artifacts.py +56 -0
- toffee/_asyncio_observer.py +164 -0
- toffee/_cleanup.py +27 -0
- toffee/_context.py +26 -0
- toffee/_coverage_capture.py +43 -0
- toffee/_coverage_exclusions.py +119 -0
- toffee/_coverage_runtime.py +623 -0
- toffee/_driver_runtime.py +36 -0
- toffee/_native_coverage.py +238 -0
- toffee/_pytest.py +384 -0
- toffee/_scoreboard_matching.py +41 -0
- toffee/agent.py +298 -0
- toffee/async_drivers.py +384 -0
- toffee/backend.py +1552 -0
- toffee/cli.py +58 -0
- toffee/combinators.py +79 -0
- toffee/components.py +97 -0
- toffee/coverage.py +1850 -0
- toffee/coverage_report.py +659 -0
- toffee/data.py +844 -0
- toffee/declarative/__init__.py +22 -0
- toffee/declarative/_compiler.py +422 -0
- toffee/declarative/_declarations.py +404 -0
- toffee/declarative/_runtime.py +232 -0
- toffee/decorators.py +181 -0
- toffee/events.py +83 -0
- toffee/execution.py +285 -0
- toffee/external.py +79 -0
- toffee/interfaces.py +166 -0
- toffee/ir.py +682 -0
- toffee/monitors.py +259 -0
- toffee/protocols.py +106 -0
- toffee/py.typed +0 -0
- toffee/reactor.py +391 -0
- toffee/scoreboard.py +963 -0
- toffee/signals.py +131 -0
- toffee/staged_drivers.py +174 -0
- toffee/subscriptions.py +64 -0
- toffee/sync_drivers.py +94 -0
- toffee/templates/coverage_site.css +3 -0
- toffee/templates/unified_coverage.html +108 -0
- toffee/transfers.py +220 -0
- toffee/triggers.py +129 -0
- uc_toffee-2.0.0.dist-info/METADATA +117 -0
- uc_toffee-2.0.0.dist-info/RECORD +51 -0
- uc_toffee-2.0.0.dist-info/WHEEL +5 -0
- uc_toffee-2.0.0.dist-info/entry_points.txt +3 -0
- uc_toffee-2.0.0.dist-info/licenses/LICENSE +127 -0
- uc_toffee-2.0.0.dist-info/top_level.txt +1 -0
toffee/__init__.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
from .backend import (
|
|
2
|
+
BackendCapabilities,
|
|
3
|
+
BackendHandle,
|
|
4
|
+
BackendHit,
|
|
5
|
+
MemoryBackend,
|
|
6
|
+
RunLimit,
|
|
7
|
+
RunResult,
|
|
8
|
+
StopReason,
|
|
9
|
+
SimulationBackend,
|
|
10
|
+
XCommClockBackend,
|
|
11
|
+
)
|
|
12
|
+
from ._asyncio_observer import SimulationNotSettledError
|
|
13
|
+
from .decorators import PythonTriggerDefinition, TriggerDefinition, pytrigger, xtrigger
|
|
14
|
+
from .components import Driver, Monitor, SignalDriver
|
|
15
|
+
from .agent import Agent
|
|
16
|
+
from .scoreboard import (
|
|
17
|
+
AcceptedDriver,
|
|
18
|
+
CheckContext,
|
|
19
|
+
Difference,
|
|
20
|
+
MissingExpectedError,
|
|
21
|
+
ObservedMonitor,
|
|
22
|
+
Scoreboard,
|
|
23
|
+
ScoreboardAssociationError,
|
|
24
|
+
ScoreboardError,
|
|
25
|
+
ScoreboardIncompleteError,
|
|
26
|
+
ScoreboardMismatch,
|
|
27
|
+
ScoreboardMode,
|
|
28
|
+
ScoreboardStatus,
|
|
29
|
+
ScoreboardTimeoutError,
|
|
30
|
+
structural_differences,
|
|
31
|
+
)
|
|
32
|
+
from .transfers import MISSING, TransferState, XTransfer
|
|
33
|
+
from .coverage import (
|
|
34
|
+
Bin,
|
|
35
|
+
BinKind,
|
|
36
|
+
BinSpec,
|
|
37
|
+
CoverageDatabase,
|
|
38
|
+
CoverageMergeError,
|
|
39
|
+
CoverageSample,
|
|
40
|
+
CoverageSchemaError,
|
|
41
|
+
CoverGroup,
|
|
42
|
+
CoverGroupDef,
|
|
43
|
+
CoverPointDef,
|
|
44
|
+
CrossDef,
|
|
45
|
+
Iff,
|
|
46
|
+
IllegalBinError,
|
|
47
|
+
IllegalHit,
|
|
48
|
+
IllegalPolicy,
|
|
49
|
+
OverlapPolicy,
|
|
50
|
+
)
|
|
51
|
+
from .data import (
|
|
52
|
+
Bundle,
|
|
53
|
+
BundleValue,
|
|
54
|
+
Field,
|
|
55
|
+
PackedArray,
|
|
56
|
+
PackedLayout,
|
|
57
|
+
PackedView,
|
|
58
|
+
split_packed,
|
|
59
|
+
)
|
|
60
|
+
from .async_drivers import AsyncDriver, AsyncSingleCycleDriver, DriverIncompleteError
|
|
61
|
+
from .sync_drivers import SyncDriver, SyncSingleCycleDriver
|
|
62
|
+
from .staged_drivers import AsyncMultiCycleDriver, DriveStage, SyncMultiCycleDriver
|
|
63
|
+
from .combinators import AllOf, AnyOf
|
|
64
|
+
from .events import (
|
|
65
|
+
ConditionEvent,
|
|
66
|
+
EdgeEvent,
|
|
67
|
+
FsmEvent,
|
|
68
|
+
LogicValue,
|
|
69
|
+
PhaseEvent,
|
|
70
|
+
XEvent,
|
|
71
|
+
XEventKind,
|
|
72
|
+
XPhase,
|
|
73
|
+
)
|
|
74
|
+
from .ir import (
|
|
75
|
+
bits,
|
|
76
|
+
BitsType,
|
|
77
|
+
BoolType,
|
|
78
|
+
FSM,
|
|
79
|
+
FsmSpec,
|
|
80
|
+
Hold,
|
|
81
|
+
Next,
|
|
82
|
+
Sequence,
|
|
83
|
+
SequenceSpec,
|
|
84
|
+
State,
|
|
85
|
+
Wait,
|
|
86
|
+
Within,
|
|
87
|
+
XExpr,
|
|
88
|
+
)
|
|
89
|
+
from .external import AsyncioEventTrigger, QueueTrigger, TaskComplete, WallTimeout
|
|
90
|
+
from .interfaces import Decoupled, ReadyValid, Role, Transfer
|
|
91
|
+
from .monitors import MonitorClosedError, MonitorOverflowError, ReadyValidMonitor, SamplingMonitor
|
|
92
|
+
from .reactor import (
|
|
93
|
+
Registration,
|
|
94
|
+
Subscription,
|
|
95
|
+
SubscriptionOverflowError,
|
|
96
|
+
Reactor,
|
|
97
|
+
)
|
|
98
|
+
from .subscriptions import (
|
|
99
|
+
BoundSubscriptionSpec,
|
|
100
|
+
XSubscriptionSpec,
|
|
101
|
+
on,
|
|
102
|
+
)
|
|
103
|
+
from .execution import Execution
|
|
104
|
+
from .protocols import drive_ready_valid
|
|
105
|
+
from .signals import as_xdata
|
|
106
|
+
from .triggers import (
|
|
107
|
+
ClockCycles,
|
|
108
|
+
CompiledTrigger,
|
|
109
|
+
ConditionMode,
|
|
110
|
+
DriveStable,
|
|
111
|
+
FallingEdge,
|
|
112
|
+
PhaseTrigger,
|
|
113
|
+
PythonPredicateTrigger,
|
|
114
|
+
RisingEdge,
|
|
115
|
+
SimTimeout,
|
|
116
|
+
Value,
|
|
117
|
+
ValueChange,
|
|
118
|
+
XTrigger,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
__all__ = [
|
|
122
|
+
"bits",
|
|
123
|
+
"BitsType",
|
|
124
|
+
"BoolType",
|
|
125
|
+
"Agent",
|
|
126
|
+
"SimulationNotSettledError",
|
|
127
|
+
"AcceptedDriver",
|
|
128
|
+
"BackendHandle",
|
|
129
|
+
"BackendCapabilities",
|
|
130
|
+
"BackendHit",
|
|
131
|
+
"Bundle",
|
|
132
|
+
"BundleValue",
|
|
133
|
+
"Bin",
|
|
134
|
+
"BinKind",
|
|
135
|
+
"BinSpec",
|
|
136
|
+
"BoundSubscriptionSpec",
|
|
137
|
+
"AllOf",
|
|
138
|
+
"AnyOf",
|
|
139
|
+
"AsyncioEventTrigger",
|
|
140
|
+
"ClockCycles",
|
|
141
|
+
"CheckContext",
|
|
142
|
+
"CompiledTrigger",
|
|
143
|
+
"ConditionMode",
|
|
144
|
+
"ConditionEvent",
|
|
145
|
+
"CoverageDatabase",
|
|
146
|
+
"CoverageMergeError",
|
|
147
|
+
"CoverageSample",
|
|
148
|
+
"CoverageSchemaError",
|
|
149
|
+
"build_unified_coverage_model",
|
|
150
|
+
"CoverGroup",
|
|
151
|
+
"CoverGroupDef",
|
|
152
|
+
"CoverPointDef",
|
|
153
|
+
"CrossDef",
|
|
154
|
+
"Decoupled",
|
|
155
|
+
"DriveStable",
|
|
156
|
+
"Difference",
|
|
157
|
+
"Driver",
|
|
158
|
+
"SignalDriver",
|
|
159
|
+
"SyncDriver",
|
|
160
|
+
"SyncSingleCycleDriver",
|
|
161
|
+
"SyncMultiCycleDriver",
|
|
162
|
+
"EdgeEvent",
|
|
163
|
+
"FallingEdge",
|
|
164
|
+
"Field",
|
|
165
|
+
"FSM",
|
|
166
|
+
"FsmSpec",
|
|
167
|
+
"FsmEvent",
|
|
168
|
+
"generate_unified_coverage_report",
|
|
169
|
+
"generate_unified_coverage_site",
|
|
170
|
+
"Hold",
|
|
171
|
+
"Next",
|
|
172
|
+
"LogicValue",
|
|
173
|
+
"Iff",
|
|
174
|
+
"IllegalBinError",
|
|
175
|
+
"IllegalHit",
|
|
176
|
+
"IllegalPolicy",
|
|
177
|
+
"MemoryBackend",
|
|
178
|
+
"MISSING",
|
|
179
|
+
"MissingExpectedError",
|
|
180
|
+
"MonitorOverflowError",
|
|
181
|
+
"MonitorClosedError",
|
|
182
|
+
"Monitor",
|
|
183
|
+
"ObservedMonitor",
|
|
184
|
+
"OverlapPolicy",
|
|
185
|
+
"PackedArray",
|
|
186
|
+
"PackedLayout",
|
|
187
|
+
"PackedView",
|
|
188
|
+
"parse_lcov",
|
|
189
|
+
"PhaseEvent",
|
|
190
|
+
"PhaseTrigger",
|
|
191
|
+
"PythonPredicateTrigger",
|
|
192
|
+
"QueueTrigger",
|
|
193
|
+
"AsyncDriver",
|
|
194
|
+
"DriverIncompleteError",
|
|
195
|
+
"AsyncSingleCycleDriver",
|
|
196
|
+
"AsyncMultiCycleDriver",
|
|
197
|
+
"DriveStage",
|
|
198
|
+
"Registration",
|
|
199
|
+
"render_unified_coverage_html",
|
|
200
|
+
"ReadyValid",
|
|
201
|
+
"ReadyValidMonitor",
|
|
202
|
+
"SamplingMonitor",
|
|
203
|
+
"RisingEdge",
|
|
204
|
+
"RunLimit",
|
|
205
|
+
"RunResult",
|
|
206
|
+
"Role",
|
|
207
|
+
"Scoreboard",
|
|
208
|
+
"ScoreboardAssociationError",
|
|
209
|
+
"ScoreboardError",
|
|
210
|
+
"ScoreboardIncompleteError",
|
|
211
|
+
"ScoreboardMismatch",
|
|
212
|
+
"ScoreboardMode",
|
|
213
|
+
"ScoreboardStatus",
|
|
214
|
+
"ScoreboardTimeoutError",
|
|
215
|
+
"StopReason",
|
|
216
|
+
"Sequence",
|
|
217
|
+
"SequenceSpec",
|
|
218
|
+
"Execution",
|
|
219
|
+
"SimulationBackend",
|
|
220
|
+
"SimTimeout",
|
|
221
|
+
"State",
|
|
222
|
+
"Subscription",
|
|
223
|
+
"SubscriptionOverflowError",
|
|
224
|
+
"TaskComplete",
|
|
225
|
+
"Transfer",
|
|
226
|
+
"TransferState",
|
|
227
|
+
"Value",
|
|
228
|
+
"ValueChange",
|
|
229
|
+
"WallTimeout",
|
|
230
|
+
"Wait",
|
|
231
|
+
"Within",
|
|
232
|
+
"XEvent",
|
|
233
|
+
"XEventKind",
|
|
234
|
+
"XExpr",
|
|
235
|
+
"XPhase",
|
|
236
|
+
"Reactor",
|
|
237
|
+
"XCommClockBackend",
|
|
238
|
+
"XTrigger",
|
|
239
|
+
"XTransfer",
|
|
240
|
+
"XSubscriptionSpec",
|
|
241
|
+
"on",
|
|
242
|
+
"as_xdata",
|
|
243
|
+
"pytrigger",
|
|
244
|
+
"split_packed",
|
|
245
|
+
"structural_differences",
|
|
246
|
+
"xtrigger",
|
|
247
|
+
"TriggerDefinition",
|
|
248
|
+
"PythonTriggerDefinition",
|
|
249
|
+
"drive_ready_valid",
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
_LAZY_REPORT_EXPORTS = {
|
|
254
|
+
"build_unified_coverage_model",
|
|
255
|
+
"generate_unified_coverage_report",
|
|
256
|
+
"generate_unified_coverage_site",
|
|
257
|
+
"parse_lcov",
|
|
258
|
+
"render_unified_coverage_html",
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def __getattr__(name: str):
|
|
263
|
+
if name in _LAZY_REPORT_EXPORTS:
|
|
264
|
+
from . import coverage_report
|
|
265
|
+
|
|
266
|
+
return getattr(coverage_report, name)
|
|
267
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
toffee/__main__.py
ADDED
toffee/_artifacts.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Atomic files and identities for the runner's exclusively owned directory."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import gzip
|
|
5
|
+
from hashlib import file_digest
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import tempfile
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def fingerprint(path: Path) -> dict[str, Any]:
|
|
14
|
+
if path.is_symlink() or not path.is_file() or path.resolve() != path:
|
|
15
|
+
raise ValueError(f"artifact is not a regular non-symlink file: {path}")
|
|
16
|
+
with path.open("rb") as stream:
|
|
17
|
+
digest = file_digest(stream, "sha256").hexdigest()
|
|
18
|
+
return {"path": str(path), "size": path.stat().st_size, "sha256": digest}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def write(path: Path, value: Any, *, compressed: bool = False) -> dict[str, Any]:
|
|
22
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
descriptor, name = tempfile.mkstemp(prefix=".pending-", dir=path.parent)
|
|
24
|
+
pending = Path(name)
|
|
25
|
+
try:
|
|
26
|
+
with os.fdopen(descriptor, "wb") as raw:
|
|
27
|
+
if compressed:
|
|
28
|
+
with gzip.GzipFile(fileobj=raw, mode="wb", filename="", mtime=0) as stream:
|
|
29
|
+
for part in json.JSONEncoder(separators=(",", ":")).iterencode(value):
|
|
30
|
+
stream.write(part.encode())
|
|
31
|
+
else:
|
|
32
|
+
raw.write((json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode())
|
|
33
|
+
raw.flush()
|
|
34
|
+
os.fsync(raw.fileno())
|
|
35
|
+
pending.replace(path)
|
|
36
|
+
sync_directory(path.parent)
|
|
37
|
+
finally:
|
|
38
|
+
pending.unlink(missing_ok=True)
|
|
39
|
+
return fingerprint(path)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def sync_directory(path: Path) -> None:
|
|
43
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
|
|
44
|
+
try:
|
|
45
|
+
os.fsync(descriptor)
|
|
46
|
+
finally:
|
|
47
|
+
os.close(descriptor)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def read_checked(row: dict[str, Any], root: Path, *, compressed: bool = False) -> Any:
|
|
51
|
+
path = Path(row["path"])
|
|
52
|
+
if not path.is_relative_to(root) or fingerprint(path) != row:
|
|
53
|
+
raise ValueError(f"artifact identity or ownership mismatch: {path}")
|
|
54
|
+
opener = gzip.open if compressed else open
|
|
55
|
+
with opener(path, "rt", encoding="utf-8") as stream:
|
|
56
|
+
return json.load(stream)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Observe native asyncio callbacks; never select or execute tasks ourselves."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from contextvars import ContextVar
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
current_domain: ContextVar[CallbackDomain | None] = ContextVar(
|
|
11
|
+
"toffee_callback_domain", default=None,
|
|
12
|
+
)
|
|
13
|
+
_observers: dict[asyncio.AbstractEventLoop, _LoopObserver] = {}
|
|
14
|
+
_MISSING = object()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SimulationNotSettledError(RuntimeError):
|
|
18
|
+
"""Immediate simulation work exceeded the configured settling budget."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _PendingCall:
|
|
22
|
+
__slots__ = ("domain", "callback", "handle")
|
|
23
|
+
|
|
24
|
+
def __init__(self, domain: CallbackDomain, callback: Any) -> None:
|
|
25
|
+
self.domain = domain
|
|
26
|
+
self.callback = callback
|
|
27
|
+
self.handle: asyncio.Handle | None = None
|
|
28
|
+
|
|
29
|
+
def __call__(self, *args: Any) -> None:
|
|
30
|
+
try:
|
|
31
|
+
self.callback(*args)
|
|
32
|
+
finally:
|
|
33
|
+
self.domain.pending.discard(self)
|
|
34
|
+
# Break the Handle -> callback -> Handle cycle on the hot path.
|
|
35
|
+
self.handle = None
|
|
36
|
+
|
|
37
|
+
def __repr__(self) -> str:
|
|
38
|
+
return f"observed({self.callback!r})"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _LoopObserver:
|
|
42
|
+
def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
|
|
43
|
+
if not isinstance(loop, asyncio.BaseEventLoop):
|
|
44
|
+
raise RuntimeError(
|
|
45
|
+
"Execution callback observation requires an asyncio.BaseEventLoop; "
|
|
46
|
+
f"unsupported loop: {type(loop).__module__}.{type(loop).__name__}"
|
|
47
|
+
)
|
|
48
|
+
self.loop = loop
|
|
49
|
+
self.original = original = loop.call_soon
|
|
50
|
+
self.original_attribute = vars(loop).get("call_soon", _MISSING)
|
|
51
|
+
self.users: set[CallbackDomain] = set()
|
|
52
|
+
|
|
53
|
+
def call_soon(callback, *args, context=None):
|
|
54
|
+
domain = current_domain.get() if context is None else context.get(current_domain)
|
|
55
|
+
if domain is None or domain.closed or domain.observer is not self:
|
|
56
|
+
# Task yields commonly have no arguments. Avoid allocating an
|
|
57
|
+
# unpacked keyword call on every unrelated host continuation.
|
|
58
|
+
if not args:
|
|
59
|
+
return original(callback, context=context)
|
|
60
|
+
return original(callback, *args, context=context)
|
|
61
|
+
# Let the original entry validate invalid callbacks, including its
|
|
62
|
+
# debug-only coroutine check. Wrapping must not hide those errors.
|
|
63
|
+
if not callable(callback) or (
|
|
64
|
+
loop.get_debug()
|
|
65
|
+
and (asyncio.iscoroutine(callback) or asyncio.iscoroutinefunction(callback))
|
|
66
|
+
):
|
|
67
|
+
return original(callback, *args, context=context)
|
|
68
|
+
call = _PendingCall(domain, callback)
|
|
69
|
+
domain.pending.add(call)
|
|
70
|
+
try:
|
|
71
|
+
if not args:
|
|
72
|
+
handle = original(call, context=context)
|
|
73
|
+
elif len(args) == 1:
|
|
74
|
+
handle = original(call, args[0], context=context)
|
|
75
|
+
else:
|
|
76
|
+
handle = original(call, *args, context=context)
|
|
77
|
+
except BaseException:
|
|
78
|
+
domain.pending.discard(call)
|
|
79
|
+
raise
|
|
80
|
+
call.handle = handle
|
|
81
|
+
return handle
|
|
82
|
+
|
|
83
|
+
self.hook = call_soon
|
|
84
|
+
try:
|
|
85
|
+
loop.call_soon = call_soon
|
|
86
|
+
except (AttributeError, TypeError) as error:
|
|
87
|
+
raise RuntimeError("event loop does not support callback observation") from error
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def attach(cls, domain: CallbackDomain) -> _LoopObserver:
|
|
91
|
+
loop = asyncio.get_running_loop()
|
|
92
|
+
observer = _observers.get(loop)
|
|
93
|
+
if observer is None:
|
|
94
|
+
observer = cls(loop)
|
|
95
|
+
_observers[loop] = observer
|
|
96
|
+
else:
|
|
97
|
+
observer.check()
|
|
98
|
+
observer.users.add(domain)
|
|
99
|
+
return observer
|
|
100
|
+
|
|
101
|
+
def check(self) -> None:
|
|
102
|
+
if self.loop.call_soon is not self.hook:
|
|
103
|
+
raise RuntimeError(
|
|
104
|
+
"loop.call_soon changed during Execution; "
|
|
105
|
+
"callback settlement can no longer be guaranteed"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def detach(self, domain: CallbackDomain) -> None:
|
|
109
|
+
self.users.discard(domain)
|
|
110
|
+
if self.users:
|
|
111
|
+
return
|
|
112
|
+
# A host-installed replacement belongs to the host, even if it wraps us.
|
|
113
|
+
if self.loop.call_soon is self.hook:
|
|
114
|
+
if self.original_attribute is _MISSING:
|
|
115
|
+
delattr(self.loop, "call_soon")
|
|
116
|
+
else:
|
|
117
|
+
self.loop.call_soon = self.original_attribute
|
|
118
|
+
_observers.pop(self.loop, None)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class CallbackDomain:
|
|
122
|
+
def __init__(self, max_settle_rounds: int) -> None:
|
|
123
|
+
self.pending: set[_PendingCall] = set()
|
|
124
|
+
self.closed = False
|
|
125
|
+
self.max_settle_rounds = max_settle_rounds
|
|
126
|
+
self._detached = False
|
|
127
|
+
self.observer = _LoopObserver.attach(self)
|
|
128
|
+
|
|
129
|
+
async def settle(self, backend: Any) -> None:
|
|
130
|
+
self.observer.check()
|
|
131
|
+
rounds = 0
|
|
132
|
+
while self.pending and not self.closed:
|
|
133
|
+
# Only our registered Handles are inspected, never the loop queue
|
|
134
|
+
# or Task internals. A cancelled Handle won't call our finally.
|
|
135
|
+
for call in tuple(self.pending):
|
|
136
|
+
if call.handle is not None and call.handle.cancelled():
|
|
137
|
+
self.pending.discard(call)
|
|
138
|
+
call.handle = None
|
|
139
|
+
call.callback = None
|
|
140
|
+
if not self.pending:
|
|
141
|
+
return
|
|
142
|
+
if rounds >= self.max_settle_rounds:
|
|
143
|
+
callbacks = ", ".join(repr(call) for call in list(self.pending)[:3])
|
|
144
|
+
phase = getattr(backend, "phase", None)
|
|
145
|
+
raise SimulationNotSettledError(
|
|
146
|
+
f"simulation did not settle at tick={getattr(backend, 'tick', '?')} "
|
|
147
|
+
f"phase={getattr(phase, 'name', phase)} after {rounds} rounds; "
|
|
148
|
+
f"{len(self.pending)} pending callbacks: {callbacks}"
|
|
149
|
+
)
|
|
150
|
+
await asyncio.sleep(0)
|
|
151
|
+
rounds += 1
|
|
152
|
+
self.observer.check()
|
|
153
|
+
|
|
154
|
+
def stop(self) -> None:
|
|
155
|
+
self.closed = True
|
|
156
|
+
# Scheduled callbacks still run normally, with their original owner.
|
|
157
|
+
self.pending.clear()
|
|
158
|
+
|
|
159
|
+
def close(self) -> None:
|
|
160
|
+
if self._detached:
|
|
161
|
+
return
|
|
162
|
+
self.stop()
|
|
163
|
+
self.observer.detach(self)
|
|
164
|
+
self._detached = True
|
toffee/_cleanup.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Preserve the original failure while reporting independent cleanup errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def raise_cleanup_errors(
|
|
7
|
+
message: str, errors: list[BaseException], primary: BaseException | None = None,
|
|
8
|
+
) -> None:
|
|
9
|
+
unique: list[BaseException] = [primary] if primary is not None else []
|
|
10
|
+
|
|
11
|
+
def retain(error: BaseException) -> None:
|
|
12
|
+
if any(error is previous for previous in unique):
|
|
13
|
+
return
|
|
14
|
+
if isinstance(error, BaseExceptionGroup):
|
|
15
|
+
for nested in error.exceptions:
|
|
16
|
+
retain(nested)
|
|
17
|
+
else:
|
|
18
|
+
unique.append(error)
|
|
19
|
+
|
|
20
|
+
for error in errors:
|
|
21
|
+
retain(error)
|
|
22
|
+
if len(unique) == 1:
|
|
23
|
+
if unique[0] is not primary:
|
|
24
|
+
raise unique[0]
|
|
25
|
+
elif unique:
|
|
26
|
+
raise BaseExceptionGroup(message, unique)
|
|
27
|
+
# With only the primary failure, let the owning context propagate it.
|
toffee/_context.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextvars import ContextVar, Token
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from .reactor import Reactor
|
|
8
|
+
|
|
9
|
+
_current_reactor: ContextVar["Reactor | None"] = ContextVar(
|
|
10
|
+
"toffee_current_reactor", default=None
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def current_reactor() -> "Reactor":
|
|
15
|
+
reactor = _current_reactor.get()
|
|
16
|
+
if reactor is None:
|
|
17
|
+
raise RuntimeError("XTrigger must be awaited inside an active Execution")
|
|
18
|
+
return reactor
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def bind_reactor(reactor: "Reactor | None") -> Token["Reactor | None"]:
|
|
22
|
+
return _current_reactor.set(reactor)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def reset_reactor(token: Token["Reactor | None"]) -> None:
|
|
26
|
+
_current_reactor.reset(token)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Private, runner-owned capture. Ordinary library use has no global collector."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
from typing import Any
|
|
7
|
+
from weakref import WeakSet
|
|
8
|
+
|
|
9
|
+
_sink: ContextVar[Any] = ContextVar("toffee_coverage_capture", default=None)
|
|
10
|
+
_suspended: ContextVar[bool] = ContextVar("toffee_coverage_capture_suspended", default=False)
|
|
11
|
+
_live: WeakSet[Any] = WeakSet()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def register(group: Any) -> None:
|
|
15
|
+
if _suspended.get():
|
|
16
|
+
return
|
|
17
|
+
_live.add(group)
|
|
18
|
+
sink = _sink.get()
|
|
19
|
+
if sink is not None:
|
|
20
|
+
sink.register(group)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def live_groups():
|
|
24
|
+
return tuple(_live)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@contextmanager
|
|
28
|
+
def capture(sink: Any):
|
|
29
|
+
token = _sink.set(sink)
|
|
30
|
+
try:
|
|
31
|
+
yield
|
|
32
|
+
finally:
|
|
33
|
+
_sink.reset(token)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@contextmanager
|
|
37
|
+
def suspended():
|
|
38
|
+
token = _suspended.set(True)
|
|
39
|
+
try:
|
|
40
|
+
with capture(None):
|
|
41
|
+
yield
|
|
42
|
+
finally:
|
|
43
|
+
_suspended.reset(token)
|