detangle 0.1.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.
detangle/__init__.py ADDED
@@ -0,0 +1,118 @@
1
+ """detangle -- deterministic simulation testing for Python asyncio.
2
+
3
+ Run your async code under thousands of different (but perfectly
4
+ reproducible) schedules, network conditions and fault patterns; get the
5
+ race conditions, deadlocks and cancellation bugs back as minimal, replayable
6
+ counterexamples.
7
+
8
+ Quick start::
9
+
10
+ import asyncio, detangle
11
+
12
+ @detangle.test
13
+ async def test_counter():
14
+ counter = {"value": 0}
15
+
16
+ async def incr():
17
+ current = counter["value"]
18
+ await asyncio.sleep(0) # e.g. a database round-trip
19
+ counter["value"] = current + 1
20
+
21
+ await asyncio.gather(incr(), incr())
22
+ assert counter["value"] == 2 # fails: lost update found & shrunk
23
+
24
+ See https://github.com/louistarwars/Detangle for the documentation.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from . import lin, net, strategies
30
+ from ._api import (
31
+ choice,
32
+ flip,
33
+ in_simulation,
34
+ inject_cancellation,
35
+ invariant,
36
+ maybe_timeout,
37
+ network,
38
+ note,
39
+ now,
40
+ randint,
41
+ shuffle,
42
+ spawn,
43
+ uniform,
44
+ )
45
+ from ._choices import Decision, decode_token, encode_token
46
+ from ._config import NetConfig, SimConfig
47
+ from ._loop import SimLoop, current_loop
48
+ from ._runner import Failure, RunResult, execute
49
+ from ._version import __version__
50
+ from .errors import (
51
+ BugFound,
52
+ DeadlockError,
53
+ DetangleError,
54
+ InjectedTimeout,
55
+ InvariantViolation,
56
+ NotLinearizable,
57
+ RealIOError,
58
+ SimulationError,
59
+ StepLimitExceeded,
60
+ TimeLimitExceeded,
61
+ )
62
+ from .explore import BugReport, ExploreStats, explore, replay, run, test
63
+ from .lin import History
64
+ from .strategies import DFS, FIFO, PCT, Portfolio, RandomWalk, Replay, Strategy
65
+
66
+ __all__ = [
67
+ "DFS",
68
+ "FIFO",
69
+ "PCT",
70
+ "BugFound",
71
+ "BugReport",
72
+ "DeadlockError",
73
+ "Decision",
74
+ "DetangleError",
75
+ "ExploreStats",
76
+ "Failure",
77
+ "History",
78
+ "InjectedTimeout",
79
+ "InvariantViolation",
80
+ "NetConfig",
81
+ "NotLinearizable",
82
+ "Portfolio",
83
+ "RandomWalk",
84
+ "RealIOError",
85
+ "Replay",
86
+ "RunResult",
87
+ "SimConfig",
88
+ "SimLoop",
89
+ "SimulationError",
90
+ "StepLimitExceeded",
91
+ "Strategy",
92
+ "TimeLimitExceeded",
93
+ "__version__",
94
+ "choice",
95
+ "current_loop",
96
+ "decode_token",
97
+ "encode_token",
98
+ "execute",
99
+ "explore",
100
+ "flip",
101
+ "in_simulation",
102
+ "inject_cancellation",
103
+ "invariant",
104
+ "lin",
105
+ "maybe_timeout",
106
+ "net",
107
+ "network",
108
+ "note",
109
+ "now",
110
+ "randint",
111
+ "replay",
112
+ "run",
113
+ "shuffle",
114
+ "spawn",
115
+ "strategies",
116
+ "test",
117
+ "uniform",
118
+ ]
detangle/__main__.py ADDED
@@ -0,0 +1,157 @@
1
+ """Command-line interface.
2
+
3
+ Usage::
4
+
5
+ detangle explore tests/test_bank.py:test_transfer --runs 2000
6
+ detangle replay dt1-eJxj... tests/test_bank.py:test_transfer --html report.html
7
+ detangle decode dt1-eJxj...
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import importlib
14
+ import importlib.util
15
+ import json
16
+ import os
17
+ import sys
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from ._choices import decode_token
22
+ from ._version import __version__
23
+
24
+
25
+ def _load(target: str) -> Any:
26
+ if ":" not in target:
27
+ raise SystemExit(
28
+ f"error: target must look like 'module:function' or 'path.py:function', got {target!r}"
29
+ )
30
+ module_part, _, attr = target.rpartition(":")
31
+ if module_part.endswith(".py") or os.sep in module_part or "/" in module_part:
32
+ path = Path(module_part).resolve()
33
+ if not path.exists():
34
+ raise SystemExit(f"error: no such file: {module_part}")
35
+ sys.path.insert(0, str(path.parent))
36
+ name = path.stem
37
+ spec = importlib.util.spec_from_file_location(name, path)
38
+ if spec is None or spec.loader is None:
39
+ raise SystemExit(f"error: cannot import {module_part}")
40
+ module = importlib.util.module_from_spec(spec)
41
+ sys.modules[name] = module
42
+ spec.loader.exec_module(module)
43
+ else:
44
+ sys.path.insert(0, os.getcwd())
45
+ module = importlib.import_module(module_part)
46
+ obj: Any = module
47
+ for part in attr.split("."):
48
+ obj = getattr(obj, part)
49
+ return getattr(obj, "_detangle_inner", obj)
50
+
51
+
52
+ def _cmd_explore(args: argparse.Namespace) -> int:
53
+ from .errors import BugFound
54
+ from .explore import explore
55
+
56
+ fn = _load(args.target)
57
+ try:
58
+ stats = explore(
59
+ fn,
60
+ runs=args.runs,
61
+ strategy=args.strategy,
62
+ seed=args.seed,
63
+ max_duration=args.max_duration,
64
+ report_dir=args.report_dir,
65
+ database=False if args.no_db else None,
66
+ )
67
+ except BugFound as exc:
68
+ print(exc.report.render(trace_limit=args.trace_limit))
69
+ return 1
70
+ print(stats.summary())
71
+ return 0
72
+
73
+
74
+ def _cmd_replay(args: argparse.Namespace) -> int:
75
+ from .explore import BugReport, _count_non_seed, _name_of, replay
76
+
77
+ fn = _load(args.target)
78
+ result = replay(fn, args.token, raise_on_failure=False)
79
+ if result.failure is None:
80
+ print(
81
+ f"detangle: replaying {args.token}: no failure ({result.steps} steps, "
82
+ f"virtual time {result.virtual_time:.6f}s)"
83
+ )
84
+ if result.trace is not None and args.trace:
85
+ print(result.trace.render(limit=args.trace_limit))
86
+ return 0
87
+ report = BugReport(
88
+ name=_name_of(fn),
89
+ failure=result.failure,
90
+ token=args.token,
91
+ values=decode_token(args.token),
92
+ runs=1,
93
+ strategy="replay",
94
+ seed=None,
95
+ trace=result.trace,
96
+ deviations=_count_non_seed(result),
97
+ secondary=result.secondary,
98
+ source="replay",
99
+ )
100
+ if args.html:
101
+ from ._html import render_html
102
+
103
+ Path(args.html).write_text(render_html(report), encoding="utf-8")
104
+ report.html_report = args.html
105
+ if args.json:
106
+ print(json.dumps(report.to_dict(), indent=2, default=str))
107
+ else:
108
+ print(report.render(trace_limit=args.trace_limit))
109
+ return 1
110
+
111
+
112
+ def _cmd_decode(args: argparse.Namespace) -> int:
113
+ values = decode_token(args.token)
114
+ nonzero = [(i, v) for i, v in enumerate(values) if v]
115
+ print(f"{len(values)} decisions, {len(nonzero)} non-default:")
116
+ for i, v in nonzero:
117
+ print(f" decision #{i}: {v}")
118
+ return 0
119
+
120
+
121
+ def main(argv: list[str] | None = None) -> int:
122
+ parser = argparse.ArgumentParser(
123
+ prog="detangle", description="Deterministic simulation testing for Python asyncio."
124
+ )
125
+ parser.add_argument("--version", action="version", version=f"detangle {__version__}")
126
+ sub = parser.add_subparsers(dest="command", required=True)
127
+
128
+ p = sub.add_parser("explore", help="explore the schedules of an async function")
129
+ p.add_argument("target", help="module:function or path.py:function")
130
+ p.add_argument("--runs", type=int, default=None)
131
+ p.add_argument("--strategy", default=None)
132
+ p.add_argument("--seed", type=lambda s: int(s, 0), default=None)
133
+ p.add_argument("--max-duration", type=float, default=None)
134
+ p.add_argument("--report-dir", default=None)
135
+ p.add_argument("--no-db", action="store_true")
136
+ p.add_argument("--trace-limit", type=int, default=80)
137
+ p.set_defaults(func=_cmd_explore)
138
+
139
+ p = sub.add_parser("replay", help="replay a token printed by a failing run")
140
+ p.add_argument("token")
141
+ p.add_argument("target", help="module:function or path.py:function")
142
+ p.add_argument("--html", default=None, help="write an interactive HTML report")
143
+ p.add_argument("--json", action="store_true", help="print the report as JSON")
144
+ p.add_argument("--trace", action="store_true", help="print the trace even if the run passes")
145
+ p.add_argument("--trace-limit", type=int, default=0, help="max trace lines (0 = all)")
146
+ p.set_defaults(func=_cmd_replay)
147
+
148
+ p = sub.add_parser("decode", help="show the decisions inside a token")
149
+ p.add_argument("token")
150
+ p.set_defaults(func=_cmd_decode)
151
+
152
+ args = parser.parse_args(argv)
153
+ return int(args.func(args))
154
+
155
+
156
+ if __name__ == "__main__":
157
+ raise SystemExit(main())
detangle/_api.py ADDED
@@ -0,0 +1,144 @@
1
+ """Helpers meant to be called from code running *inside* a simulation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Awaitable, Callable, Coroutine, MutableSequence, Sequence
7
+ from typing import Any, TypeVar
8
+
9
+ from ._choices import DATA
10
+ from ._context import current_host
11
+ from ._frames import caller_location
12
+ from ._loop import SimLoop, current_loop
13
+ from .errors import InjectedTimeout
14
+
15
+ __all__ = [
16
+ "choice",
17
+ "flip",
18
+ "in_simulation",
19
+ "inject_cancellation",
20
+ "invariant",
21
+ "maybe_timeout",
22
+ "network",
23
+ "note",
24
+ "now",
25
+ "randint",
26
+ "shuffle",
27
+ "spawn",
28
+ "uniform",
29
+ ]
30
+
31
+ _T = TypeVar("_T")
32
+
33
+
34
+ def in_simulation() -> bool:
35
+ """True when called from code running inside a detangle simulation."""
36
+ return isinstance(asyncio.events._get_running_loop(), SimLoop)
37
+
38
+
39
+ def now() -> float:
40
+ """Current virtual time, in seconds (same as ``loop.time()``)."""
41
+ return current_loop().time()
42
+
43
+
44
+ def choice(options: Sequence[_T]) -> _T:
45
+ """Pick one of *options*. Explored by the strategy; shrinks to the first one."""
46
+ if not options:
47
+ raise IndexError("cannot choose from an empty sequence")
48
+ return options[current_loop().choose(len(options), DATA)]
49
+
50
+
51
+ def randint(a: int, b: int) -> int:
52
+ """An integer in ``[a, b]``. Explored by the strategy; shrinks towards *a*."""
53
+ if b < a:
54
+ raise ValueError(f"empty range for randint({a}, {b})")
55
+ return a + current_loop().choose(b - a + 1, DATA)
56
+
57
+
58
+ def uniform(a: float, b: float, steps: int = 64) -> float:
59
+ """A float in ``[a, b]`` (one of *steps* evenly spaced values; shrinks towards *a*)."""
60
+ if steps < 2:
61
+ return a
62
+ k = current_loop().choose(steps, DATA)
63
+ return a + (b - a) * k / (steps - 1)
64
+
65
+
66
+ def flip(p: float = 0.5) -> bool:
67
+ """True with probability *p*. Shrinks towards False."""
68
+ return current_loop().flip(p, DATA)
69
+
70
+
71
+ def shuffle(items: MutableSequence[Any]) -> None:
72
+ """Shuffle *items* in place. The default (all zeros) is the identity."""
73
+ loop = current_loop()
74
+ for i in range(len(items) - 1, 0, -1):
75
+ j = i - loop.choose(i + 1, DATA)
76
+ items[i], items[j] = items[j], items[i]
77
+
78
+
79
+ def invariant(check: Callable[[], object], name: str | None = None) -> Callable[[], object]:
80
+ """Check ``check()`` after *every* step of the simulation.
81
+
82
+ The check fails if it raises ``AssertionError`` (or any exception) or
83
+ returns ``False``. Returns *check* so it can be used as a decorator.
84
+ """
85
+ current_loop().add_invariant(check, name)
86
+ return check
87
+
88
+
89
+ def note(message: str) -> None:
90
+ """Add a line to the trace shown when a bug is found (no-op otherwise)."""
91
+ loop = current_loop()
92
+ if loop.tracer is not None:
93
+ loop.tracer.on_note(asyncio.current_task(), message, caller_location(1))
94
+
95
+
96
+ def inject_cancellation(task: asyncio.Task[Any], points: int = 8) -> None:
97
+ """Allow the strategy to cancel *task* at one of its next *points* awaits.
98
+
99
+ Models timeouts and client disconnects hitting at the worst moment.
100
+ """
101
+ current_loop().mark_interruptible(task, points)
102
+
103
+
104
+ async def maybe_timeout(aw: Awaitable[_T], points: int = 8) -> _T:
105
+ """Await *aw*, but let the strategy "time it out" at any of its awaits.
106
+
107
+ Equivalent to ``asyncio.wait_for(aw, timeout=<adversarial>)``: returns the
108
+ result, or cancels the operation at one of its first *points* suspension
109
+ points and raises :class:`TimeoutError`. Use it to find code that is not
110
+ cancellation-safe (leaked locks, connections, half-applied updates...).
111
+ """
112
+ loop = current_loop()
113
+ task = asyncio.ensure_future(aw)
114
+ loop.mark_interruptible(task, points)
115
+ try:
116
+ return await task
117
+ except asyncio.CancelledError:
118
+ if task in loop.injected and task.cancelled():
119
+ current = asyncio.current_task()
120
+ if current is None or not getattr(current, "cancelling", lambda: 0)():
121
+ raise InjectedTimeout(
122
+ f"detangle: injected timeout (cancelled at {loop.injected[task]})"
123
+ ) from None
124
+ raise
125
+
126
+
127
+ def spawn(
128
+ coro: Coroutine[Any, Any, _T], *, host: str | None = None, name: str | None = None
129
+ ) -> asyncio.Task[_T]:
130
+ """Create a task, optionally running on simulated *host* (see :mod:`detangle.net`)."""
131
+ loop = current_loop()
132
+ if host is None:
133
+ return loop.create_task(coro, name=name)
134
+ loop.net.ensure_host(host)
135
+ token = current_host.set(host)
136
+ try:
137
+ return loop.create_task(coro, name=name)
138
+ finally:
139
+ current_host.reset(token)
140
+
141
+
142
+ def network() -> Any:
143
+ """The simulated network (:class:`detangle.net.SimNetwork`) of this run."""
144
+ return current_loop().net
detangle/_choices.py ADDED
@@ -0,0 +1,121 @@
1
+ """Decisions, choice sequences and replay tokens.
2
+
3
+ Every source of nondeterminism in a simulation -- which ready task runs next,
4
+ how late a timer fires, how long a network packet takes, whether a fault is
5
+ injected -- is funnelled through a single primitive: *pick an integer in
6
+ ``range(n)``*. The value ``0`` always means "what asyncio would do by
7
+ default" (run the oldest ready callback, fire the timer on time, deliver the
8
+ packet with minimum latency, inject no fault).
9
+
10
+ A run is therefore fully described by the list of integers it picked. That
11
+ list can be replayed, mutated, shrunk and serialised into a short *token*
12
+ which reproduces the run bit-for-bit.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import zlib
19
+ from collections.abc import Iterable, Sequence
20
+ from typing import NamedTuple
21
+
22
+ __all__ = ["Decision", "decode_token", "encode_token"]
23
+
24
+ TOKEN_PREFIX = "dt1-"
25
+
26
+ #: Decision kinds.
27
+ SCHED = "sched" # which ready lane runs next
28
+ TIMER = "timer" # timer jitter bucket
29
+ NET = "net" # network latency bucket / fragmentation
30
+ FAULT = "fault" # injected failures (drops, duplicates, cancellations)
31
+ DATA = "data" # user-level nondeterministic values (detangle.choice & co)
32
+ SEED = "seed" # seed for the global ``random`` module
33
+
34
+
35
+ class Decision(NamedTuple):
36
+ """One nondeterministic choice made during a run."""
37
+
38
+ kind: str
39
+ n: int
40
+ value: int
41
+
42
+
43
+ def _write_varint(out: bytearray, value: int) -> None:
44
+ if value < 0:
45
+ raise ValueError("choice values must be non-negative")
46
+ while True:
47
+ byte = value & 0x7F
48
+ value >>= 7
49
+ if value:
50
+ out.append(byte | 0x80)
51
+ else:
52
+ out.append(byte)
53
+ return
54
+
55
+
56
+ def _read_varints(data: bytes) -> list[int]:
57
+ values: list[int] = []
58
+ shift = 0
59
+ current = 0
60
+ for byte in data:
61
+ current |= (byte & 0x7F) << shift
62
+ if byte & 0x80:
63
+ shift += 7
64
+ if shift > 70:
65
+ raise ValueError("corrupt detangle token (varint too long)")
66
+ else:
67
+ values.append(current)
68
+ current = 0
69
+ shift = 0
70
+ if shift:
71
+ raise ValueError("corrupt detangle token (truncated varint)")
72
+ return values
73
+
74
+
75
+ def strip_trailing_zeros(values: Sequence[int]) -> list[int]:
76
+ """Trailing zeros are implicit: replaying past the end picks 0."""
77
+ end = len(values)
78
+ while end and values[end - 1] == 0:
79
+ end -= 1
80
+ return list(values[:end])
81
+
82
+
83
+ def encode_token(values: Iterable[int]) -> str:
84
+ """Serialise a choice sequence into a compact, URL/shell-safe token."""
85
+ raw = bytearray()
86
+ for v in strip_trailing_zeros(list(values)):
87
+ _write_varint(raw, v)
88
+ compressed = zlib.compress(bytes(raw), 9)
89
+ payload = b"z" + compressed if len(compressed) < len(raw) else b"r" + bytes(raw)
90
+ text = base64.urlsafe_b64encode(payload).rstrip(b"=").decode("ascii")
91
+ return TOKEN_PREFIX + text
92
+
93
+
94
+ def decode_token(token: str) -> list[int]:
95
+ """Inverse of :func:`encode_token`."""
96
+ token = token.strip()
97
+ if not token.startswith(TOKEN_PREFIX):
98
+ raise ValueError(f"not a detangle token (expected prefix {TOKEN_PREFIX!r}): {token!r}")
99
+ text = token[len(TOKEN_PREFIX) :]
100
+ padded = text + "=" * (-len(text) % 4)
101
+ try:
102
+ payload = base64.urlsafe_b64decode(padded.encode("ascii"))
103
+ except ValueError as exc: # binascii.Error is a ValueError
104
+ raise ValueError(f"corrupt detangle token: {exc}") from None
105
+ if not payload:
106
+ raise ValueError("corrupt detangle token (empty payload)")
107
+ tag, body = payload[:1], payload[1:]
108
+ if tag == b"z":
109
+ try:
110
+ body = zlib.decompress(body)
111
+ except zlib.error as exc:
112
+ raise ValueError(f"corrupt detangle token: {exc}") from None
113
+ elif tag != b"r":
114
+ raise ValueError("corrupt detangle token (unknown encoding)")
115
+ return _read_varints(body)
116
+
117
+
118
+ def complexity(values: Sequence[int]) -> tuple[int, int, int]:
119
+ """Ordering used by the shrinker: fewer deviations, then smaller, then shorter."""
120
+ nonzero = sum(1 for v in values if v)
121
+ return (nonzero, sum(min(v, 1 << 20) for v in values), len(strip_trailing_zeros(values)))
detangle/_config.py ADDED
@@ -0,0 +1,111 @@
1
+ """Simulation configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ __all__ = ["NetConfig", "SimConfig"]
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class NetConfig:
14
+ """Behaviour of the simulated network.
15
+
16
+ All durations are in virtual seconds. Latencies are drawn from
17
+ ``latency_steps`` evenly spaced values between ``latency[0]`` and
18
+ ``latency[1]``; the minimum is the default (choice 0).
19
+ """
20
+
21
+ latency: tuple[float, float] = (0.0005, 0.005)
22
+ latency_steps: int = 4
23
+ #: Probability that a datagram / mailbox message is lost.
24
+ drop: float = 0.0
25
+ #: Probability that a datagram / mailbox message is delivered twice.
26
+ duplicate: float = 0.0
27
+ #: Probability that a TCP write is split into several reads on the peer.
28
+ fragment: float = 0.0
29
+ #: Maximum number of pieces a fragmented write is split into.
30
+ max_fragments: int = 3
31
+ #: Time before a connection attempt to an unreachable host fails.
32
+ connect_timeout: float = 10.0
33
+ #: Time a TCP connection survives a partition before being reset.
34
+ tcp_timeout: float = 30.0
35
+ #: Retransmission interval while a link is partitioned.
36
+ retransmit_interval: float = 0.2
37
+ #: Deep-copy mailbox messages (simulates serialisation, catches aliasing).
38
+ copy_messages: bool = True
39
+
40
+ def __post_init__(self) -> None:
41
+ lo, hi = self.latency
42
+ if lo < 0 or hi < lo:
43
+ raise ValueError(f"invalid latency range {self.latency!r}")
44
+ for name in ("drop", "duplicate", "fragment"):
45
+ value = getattr(self, name)
46
+ if not 0.0 <= value <= 1.0:
47
+ raise ValueError(f"{name} must be a probability, got {value!r}")
48
+ if self.latency_steps < 1 or self.max_fragments < 1:
49
+ raise ValueError("latency_steps and max_fragments must be >= 1")
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class SimConfig:
54
+ """Knobs controlling what a simulation explores and what counts as a bug."""
55
+
56
+ #: Explore reorderings of ready tasks. ``False`` keeps asyncio's strict
57
+ #: FIFO order and only explores time/network/fault nondeterminism.
58
+ reorder: bool = True
59
+ #: Maximum extra delay (virtual seconds) a timer may fire late.
60
+ timer_jitter: float = 0.0
61
+ #: Number of distinct jitter values explored between 0 and ``timer_jitter``.
62
+ jitter_steps: int = 4
63
+ #: Virtual time consumed by every step (models CPU time; 0 = infinitely fast).
64
+ step_cost: float = 0.0
65
+ #: Abort the run (as a failure) after this many steps.
66
+ max_steps: int = 200_000
67
+ #: Abort the run (as a failure) when virtual time exceeds this value.
68
+ max_time: float | None = None
69
+ #: Exceptions in background tasks that nobody retrieved are failures.
70
+ fail_on_unobserved: bool = True
71
+ #: Exceptions escaping plain callbacks (``loop.call_soon`` & co) are failures.
72
+ fail_on_callback_error: bool = True
73
+ #: Tasks still pending when the main coroutine returns are failures.
74
+ check_leaks: bool = False
75
+ #: Seed the global :mod:`random` module from the schedule (restored after).
76
+ seed_random: bool = True
77
+ #: Patch ``time.time``/``time.monotonic``/``time.perf_counter`` to virtual time.
78
+ patch_time: bool = False
79
+ #: Network simulation parameters.
80
+ net: NetConfig = field(default_factory=NetConfig)
81
+
82
+ def __post_init__(self) -> None:
83
+ if self.timer_jitter < 0 or self.step_cost < 0:
84
+ raise ValueError("timer_jitter and step_cost must be >= 0")
85
+ if self.jitter_steps < 2 and self.timer_jitter > 0:
86
+ raise ValueError("jitter_steps must be >= 2 when timer_jitter > 0")
87
+ if self.max_steps < 1:
88
+ raise ValueError("max_steps must be >= 1")
89
+
90
+ def replace(self, **changes: Any) -> SimConfig:
91
+ return dataclasses.replace(self, **changes)
92
+
93
+
94
+ _SIM_FIELDS = {f.name for f in dataclasses.fields(SimConfig)}
95
+ _NET_FIELDS = {f.name for f in dataclasses.fields(NetConfig)}
96
+
97
+
98
+ def build_config(config: SimConfig | None, options: dict[str, Any]) -> SimConfig:
99
+ """Merge keyword options (``timer_jitter=...``, ``net=...``) into a config."""
100
+ base = config or SimConfig()
101
+ sim_changes: dict[str, Any] = {}
102
+ for key, value in options.items():
103
+ if key not in _SIM_FIELDS:
104
+ raise TypeError(f"unknown simulation option {key!r}")
105
+ if key == "net" and isinstance(value, dict):
106
+ bad = set(value) - _NET_FIELDS
107
+ if bad:
108
+ raise TypeError(f"unknown network option(s): {', '.join(sorted(bad))}")
109
+ value = dataclasses.replace(base.net, **value)
110
+ sim_changes[key] = value
111
+ return base.replace(**sim_changes) if sim_changes else base
detangle/_context.py ADDED
@@ -0,0 +1,14 @@
1
+ """Context variables shared by the loop and the network simulation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextvars import ContextVar
6
+
7
+ #: Simulated host the current task runs on (``None`` = the default host).
8
+ current_host: ContextVar[str | None] = ContextVar("detangle_current_host", default=None)
9
+
10
+ DEFAULT_HOST = "localhost"
11
+
12
+
13
+ def host_name() -> str:
14
+ return current_host.get() or DEFAULT_HOST