seedloop 0.3.0__tar.gz

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 (38) hide show
  1. seedloop-0.3.0/LICENSE +21 -0
  2. seedloop-0.3.0/PKG-INFO +180 -0
  3. seedloop-0.3.0/README.md +152 -0
  4. seedloop-0.3.0/pyproject.toml +60 -0
  5. seedloop-0.3.0/setup.cfg +4 -0
  6. seedloop-0.3.0/src/seedloop/__init__.py +41 -0
  7. seedloop-0.3.0/src/seedloop/_audit.py +107 -0
  8. seedloop-0.3.0/src/seedloop/_entropy.py +96 -0
  9. seedloop-0.3.0/src/seedloop/_loop.py +165 -0
  10. seedloop-0.3.0/src/seedloop/_net.py +190 -0
  11. seedloop-0.3.0/src/seedloop/_run.py +88 -0
  12. seedloop-0.3.0/src/seedloop/_trace.py +29 -0
  13. seedloop-0.3.0/src/seedloop/_world.py +112 -0
  14. seedloop-0.3.0/src/seedloop/demos/__init__.py +1 -0
  15. seedloop-0.3.0/src/seedloop/demos/raft.py +181 -0
  16. seedloop-0.3.0/src/seedloop/errors.py +57 -0
  17. seedloop-0.3.0/src/seedloop/py.typed +0 -0
  18. seedloop-0.3.0/src/seedloop.egg-info/PKG-INFO +180 -0
  19. seedloop-0.3.0/src/seedloop.egg-info/SOURCES.txt +36 -0
  20. seedloop-0.3.0/src/seedloop.egg-info/dependency_links.txt +1 -0
  21. seedloop-0.3.0/src/seedloop.egg-info/requires.txt +6 -0
  22. seedloop-0.3.0/src/seedloop.egg-info/top_level.txt +1 -0
  23. seedloop-0.3.0/tests/test_audit.py +184 -0
  24. seedloop-0.3.0/tests/test_check_sweep.py +112 -0
  25. seedloop-0.3.0/tests/test_clock_autojump.py +84 -0
  26. seedloop-0.3.0/tests/test_entropy.py +169 -0
  27. seedloop-0.3.0/tests/test_errors.py +18 -0
  28. seedloop-0.3.0/tests/test_faults.py +288 -0
  29. seedloop-0.3.0/tests/test_invariants.py +141 -0
  30. seedloop-0.3.0/tests/test_loop_boundary.py +52 -0
  31. seedloop-0.3.0/tests/test_loop_integration.py +75 -0
  32. seedloop-0.3.0/tests/test_loop_order.py +159 -0
  33. seedloop-0.3.0/tests/test_raft_demo.py +99 -0
  34. seedloop-0.3.0/tests/test_replay_equivalence.py +80 -0
  35. seedloop-0.3.0/tests/test_smoke.py +12 -0
  36. seedloop-0.3.0/tests/test_timer_order.py +84 -0
  37. seedloop-0.3.0/tests/test_trace.py +22 -0
  38. seedloop-0.3.0/tests/test_transport.py +154 -0
seedloop-0.3.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vojtěch Klíma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,180 @@
1
+ Metadata-Version: 2.4
2
+ Name: seedloop
3
+ Version: 0.3.0
4
+ Summary: Deterministic simulation testing for Python asyncio.
5
+ Author-email: Vojtěch Klíma <vojtechklima02@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/klimavojtech2002/seedloop
8
+ Project-URL: Repository, https://github.com/klimavojtech2002/seedloop
9
+ Project-URL: Issues, https://github.com/klimavojtech2002/seedloop/issues
10
+ Project-URL: Changelog, https://github.com/klimavojtech2002/seedloop/blob/main/CHANGELOG.md
11
+ Keywords: asyncio,testing,determinism,simulation,concurrency
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Testing
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.12
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Provides-Extra: dev
23
+ Requires-Dist: ruff>=0.6; extra == "dev"
24
+ Requires-Dist: mypy>=1.10; extra == "dev"
25
+ Requires-Dist: pytest>=8; extra == "dev"
26
+ Requires-Dist: pytest-timeout>=2; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # seedloop
30
+
31
+ Deterministic simulation testing for Python. Run your concurrent async logic through thousands of
32
+ controlled, reproducible timelines — varying message timing and delivery order, injecting network
33
+ faults, partitions, and delays — to surface the rare concurrency bug that shows up once in a million
34
+ runs, and replay it exactly from a seed.
35
+
36
+ It brings the FoundationDB / TigerBeetle / Antithesis style of reliability testing — until now living
37
+ only in Rust, C++, and Java — to Python's `asyncio`, as a `pip`-installable library.
38
+
39
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
40
+
41
+ ## The problem
42
+
43
+ Concurrency bugs are the worst bugs. A protocol or state machine works in every test, then once a
44
+ week in CI a test fails, and nobody can reproduce it — because the failure depended on an exact
45
+ interleaving of events, a message arriving late, a partition healing at the wrong moment. You cannot
46
+ fix what you cannot reproduce, so these bugs are patched by guesswork and survive for years.
47
+
48
+ Deterministic simulation testing (DST) inverts this. It takes total control of every source of
49
+ nondeterminism — scheduling order, time, randomness, the network — and drives them all from a single
50
+ seed. The same seed produces the same timeline, so the same bug, every time. You explore thousands of
51
+ seeds to hunt for failures, and when one is found, the seed *is* the reproduction: replay it and the
52
+ bug happens again, deterministically, every run.
53
+
54
+ This is how FoundationDB reached its reliability record. It exists as a polished library in Rust
55
+ (`madsim`, `turmoil`). In Python — where a great deal of distributed and protocol code is written — it
56
+ does not exist at all. `seedloop` is that library.
57
+
58
+ ## What you do with it
59
+
60
+ You write your protocol or algorithm against an abstract transport (the
61
+ [sans-I/O](https://sans-io.readthedocs.io/) style), and `seedloop` runs it inside a deterministic
62
+ world it fully controls. A test looks like this (`World`, `check`, `replay`, the network `world.net`
63
+ with loss/duplication/partitions, the `world.always` invariant API, and the `audit=True`
64
+ non-determinism auditor are all implemented; the seed-scheduled `world.run_for` fault schedule is the
65
+ next phase, specified in [docs/api.md](docs/api.md)):
66
+
67
+ ```python
68
+ import seedloop
69
+
70
+ async def scenario(world: seedloop.World) -> None:
71
+ # Spin up your nodes; they send messages through the simulated network.
72
+ nodes = [RaftNode(addr, world.net) for addr in range(5)]
73
+ world.start(*nodes)
74
+
75
+ # State the invariant that must hold at every step, not just at the end.
76
+ world.always(lambda: at_most_one_leader(nodes), name="at-most-one-leader")
77
+
78
+ # Inject chaos the seed decides the details of.
79
+ await world.run_for(seconds=10, faults=[world.partition(), world.slow_link()])
80
+
81
+ # Hunt across 10,000 seeded timelines; on failure, print the seed.
82
+ seedloop.check(scenario, seeds=10_000)
83
+ # A failing run prints: seed=4823 → replay with seedloop.replay(scenario, seed=4823)
84
+ ```
85
+
86
+ `seedloop.replay(scenario, seed=4823)` re-runs that exact timeline, deterministically, as many times
87
+ as you need to debug it. The full API is in [docs/api.md](docs/api.md).
88
+
89
+ ## The worked proof: a Raft split-brain, found and replayed
90
+
91
+ A small Raft leader election ships as a demo. With a deliberate, labelled flaw — a node that omits the
92
+ single-vote-per-term rule — a seed sweep finds the timing where two nodes both win an election in the
93
+ same term (split-brain), and replays it from the seed. The corrected election passes the same sweep, so
94
+ the violation is the toggled flaw, not the harness: in a three-node cluster the shared third voter can
95
+ only break the tie once under the single-vote rule, so one candidate gets two votes and the other one —
96
+ never two leaders.
97
+
98
+ ```
99
+ $ python -m seedloop.demos.raft
100
+ seedloop Raft election demo - hunting for split-brain
101
+
102
+ buggy election: split-brain found at seed=7
103
+ reproduce it: seedloop.replay(election_scenario(buggy=True), seed=7)
104
+ replay reproduces it: invariant 'at-most-one-leader-per-term' violated at t=0.229...
105
+ correct election (single-vote rule enforced): no violation over the same 200 seeds
106
+ -> the violation is the toggled flaw, not the harness.
107
+ ```
108
+
109
+ The election logic is in [`src/seedloop/demos/raft.py`](src/seedloop/demos/raft.py). It is election only
110
+ (terms, `RequestVote`, majority, heartbeats) — log replication, persistence, and membership changes are
111
+ out of scope.
112
+
113
+ ## What it does
114
+
115
+ - A **deterministic event loop** that makes `asyncio` task scheduling reproducible and drives the I/O
116
+ seam — where nondeterminism actually enters — from the seed.
117
+ - A **virtual clock** — `sleep` and timeouts advance simulated time instantly; no run is slower for
118
+ testing a 10-second scenario.
119
+ - **Seeded randomness** everywhere, so a run is a pure function of its seed.
120
+ - A **simulated network** with seeded latency, reordering, message loss, and partitions.
121
+ - **Fault injection** driven by the seed, so chaos is reproducible rather than random.
122
+ - **Invariants** — `world.always(...)` checks a continuous safety property at every step.
123
+ - A **non-determinism auditor** — `audit=True` turns any uncontrolled entropy source into a loud,
124
+ reproducible failure, so the determinism boundary is enforced, not just stated.
125
+ - **Seed replay** — the whole point: any failure reduces to a single integer you can replay forever.
126
+
127
+ ## Scope — what it tests, and what it deliberately does not
128
+
129
+ The honesty in this section is the point. `seedloop` makes your async *logic* deterministic; it does
130
+ not make your *infrastructure* deterministic, and it does not pretend to. The full boundary, and the engineering reasons behind it, are in
131
+ [docs/scope.md](docs/scope.md). In short:
132
+
133
+ - **It is for** pure-Python async code that talks to an abstract transport: consensus (Raft/Paxos),
134
+ replication, gossip, CRDTs, custom wire protocols, schedulers, retry/backoff/circuit-breaker logic,
135
+ rate limiters — code where the *logic* holds the concurrency bugs.
136
+ - **It is not for** I/O-heavy applications bound to real drivers. Real threads, `multiprocessing`,
137
+ `uvloop`, and C-extension drivers (`asyncpg`, `grpcio`) are explicitly out of scope, because their
138
+ scheduling cannot be controlled from Python — the same wall that stops deterministic testing in Go.
139
+ `seedloop` tests your algorithm, not your database driver.
140
+
141
+ Choosing this boundary deliberately — rather than promising determinism it cannot deliver — is what
142
+ keeps the guarantee real.
143
+
144
+ ## Status
145
+
146
+ The planned build is **complete through v0.3.0**: the deterministic core (custom event loop, virtual
147
+ clock with autojump, seeded entropy, the `World` / `check` / `replay` API), the simulated network with
148
+ fault injection (loss, duplication, partitions), the `world.always` invariant API, the non-determinism
149
+ auditor (`audit=True`), and the worked Raft demo (which runs today) — so `asyncio` runs are reproducible
150
+ and instant, a partition- or timing-dependent bug replays identically from its seed, and an uncontrolled
151
+ entropy source fails loudly under audit. Deferred: the seed-scheduled `world.run_for` fault schedule and
152
+ an optional Hypothesis integration (`seedloop[hypothesis]`). The full API target is in
153
+ [docs/api.md](docs/api.md) and the phased build in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
154
+
155
+ ## Why it exists
156
+
157
+ There is no `pip`-installable deterministic simulation testing framework for Python `asyncio` — the
158
+ capability lives in Rust (`madsim`, `turmoil`), C++ (FoundationDB), Java (OpenDST), and behind a
159
+ commercial hypervisor (Antithesis), but not in Python. Meanwhile the discipline is rising fast among
160
+ serious engineers (Antithesis raised a $105M round led by Jane Street to standardize DST; AWS has
161
+ codified deterministic and formal methods as standing practice). As one of its proponents puts it:
162
+ *writing code is no longer the bottleneck — making sure it does the right thing is.* `seedloop` is a
163
+ tool for exactly that, in the language that lacked it.
164
+
165
+ ## Documentation
166
+
167
+ The design is specified before the code:
168
+
169
+ - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — how `asyncio` is made deterministic, and the phased build.
170
+ - [docs/api.md](docs/api.md) — the public API: `World`, `check`/`replay`, the transport, faults.
171
+ - [docs/internals.md](docs/internals.md) — the loop, virtual clock, entropy control, network and fault scheduling.
172
+ - [docs/network.md](docs/network.md) — the simulated transport and fault model.
173
+ - [docs/scope.md](docs/scope.md) — the determinism boundary: what is controlled and what is not.
174
+ - [docs/testing.md](docs/testing.md) — how determinism is proven by replay.
175
+ - [docs/decisions.md](docs/decisions.md) — the decision records (ADRs).
176
+ - [docs/glossary.md](docs/glossary.md) — the vocabulary.
177
+
178
+ ## License
179
+
180
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,152 @@
1
+ # seedloop
2
+
3
+ Deterministic simulation testing for Python. Run your concurrent async logic through thousands of
4
+ controlled, reproducible timelines — varying message timing and delivery order, injecting network
5
+ faults, partitions, and delays — to surface the rare concurrency bug that shows up once in a million
6
+ runs, and replay it exactly from a seed.
7
+
8
+ It brings the FoundationDB / TigerBeetle / Antithesis style of reliability testing — until now living
9
+ only in Rust, C++, and Java — to Python's `asyncio`, as a `pip`-installable library.
10
+
11
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
12
+
13
+ ## The problem
14
+
15
+ Concurrency bugs are the worst bugs. A protocol or state machine works in every test, then once a
16
+ week in CI a test fails, and nobody can reproduce it — because the failure depended on an exact
17
+ interleaving of events, a message arriving late, a partition healing at the wrong moment. You cannot
18
+ fix what you cannot reproduce, so these bugs are patched by guesswork and survive for years.
19
+
20
+ Deterministic simulation testing (DST) inverts this. It takes total control of every source of
21
+ nondeterminism — scheduling order, time, randomness, the network — and drives them all from a single
22
+ seed. The same seed produces the same timeline, so the same bug, every time. You explore thousands of
23
+ seeds to hunt for failures, and when one is found, the seed *is* the reproduction: replay it and the
24
+ bug happens again, deterministically, every run.
25
+
26
+ This is how FoundationDB reached its reliability record. It exists as a polished library in Rust
27
+ (`madsim`, `turmoil`). In Python — where a great deal of distributed and protocol code is written — it
28
+ does not exist at all. `seedloop` is that library.
29
+
30
+ ## What you do with it
31
+
32
+ You write your protocol or algorithm against an abstract transport (the
33
+ [sans-I/O](https://sans-io.readthedocs.io/) style), and `seedloop` runs it inside a deterministic
34
+ world it fully controls. A test looks like this (`World`, `check`, `replay`, the network `world.net`
35
+ with loss/duplication/partitions, the `world.always` invariant API, and the `audit=True`
36
+ non-determinism auditor are all implemented; the seed-scheduled `world.run_for` fault schedule is the
37
+ next phase, specified in [docs/api.md](docs/api.md)):
38
+
39
+ ```python
40
+ import seedloop
41
+
42
+ async def scenario(world: seedloop.World) -> None:
43
+ # Spin up your nodes; they send messages through the simulated network.
44
+ nodes = [RaftNode(addr, world.net) for addr in range(5)]
45
+ world.start(*nodes)
46
+
47
+ # State the invariant that must hold at every step, not just at the end.
48
+ world.always(lambda: at_most_one_leader(nodes), name="at-most-one-leader")
49
+
50
+ # Inject chaos the seed decides the details of.
51
+ await world.run_for(seconds=10, faults=[world.partition(), world.slow_link()])
52
+
53
+ # Hunt across 10,000 seeded timelines; on failure, print the seed.
54
+ seedloop.check(scenario, seeds=10_000)
55
+ # A failing run prints: seed=4823 → replay with seedloop.replay(scenario, seed=4823)
56
+ ```
57
+
58
+ `seedloop.replay(scenario, seed=4823)` re-runs that exact timeline, deterministically, as many times
59
+ as you need to debug it. The full API is in [docs/api.md](docs/api.md).
60
+
61
+ ## The worked proof: a Raft split-brain, found and replayed
62
+
63
+ A small Raft leader election ships as a demo. With a deliberate, labelled flaw — a node that omits the
64
+ single-vote-per-term rule — a seed sweep finds the timing where two nodes both win an election in the
65
+ same term (split-brain), and replays it from the seed. The corrected election passes the same sweep, so
66
+ the violation is the toggled flaw, not the harness: in a three-node cluster the shared third voter can
67
+ only break the tie once under the single-vote rule, so one candidate gets two votes and the other one —
68
+ never two leaders.
69
+
70
+ ```
71
+ $ python -m seedloop.demos.raft
72
+ seedloop Raft election demo - hunting for split-brain
73
+
74
+ buggy election: split-brain found at seed=7
75
+ reproduce it: seedloop.replay(election_scenario(buggy=True), seed=7)
76
+ replay reproduces it: invariant 'at-most-one-leader-per-term' violated at t=0.229...
77
+ correct election (single-vote rule enforced): no violation over the same 200 seeds
78
+ -> the violation is the toggled flaw, not the harness.
79
+ ```
80
+
81
+ The election logic is in [`src/seedloop/demos/raft.py`](src/seedloop/demos/raft.py). It is election only
82
+ (terms, `RequestVote`, majority, heartbeats) — log replication, persistence, and membership changes are
83
+ out of scope.
84
+
85
+ ## What it does
86
+
87
+ - A **deterministic event loop** that makes `asyncio` task scheduling reproducible and drives the I/O
88
+ seam — where nondeterminism actually enters — from the seed.
89
+ - A **virtual clock** — `sleep` and timeouts advance simulated time instantly; no run is slower for
90
+ testing a 10-second scenario.
91
+ - **Seeded randomness** everywhere, so a run is a pure function of its seed.
92
+ - A **simulated network** with seeded latency, reordering, message loss, and partitions.
93
+ - **Fault injection** driven by the seed, so chaos is reproducible rather than random.
94
+ - **Invariants** — `world.always(...)` checks a continuous safety property at every step.
95
+ - A **non-determinism auditor** — `audit=True` turns any uncontrolled entropy source into a loud,
96
+ reproducible failure, so the determinism boundary is enforced, not just stated.
97
+ - **Seed replay** — the whole point: any failure reduces to a single integer you can replay forever.
98
+
99
+ ## Scope — what it tests, and what it deliberately does not
100
+
101
+ The honesty in this section is the point. `seedloop` makes your async *logic* deterministic; it does
102
+ not make your *infrastructure* deterministic, and it does not pretend to. The full boundary, and the engineering reasons behind it, are in
103
+ [docs/scope.md](docs/scope.md). In short:
104
+
105
+ - **It is for** pure-Python async code that talks to an abstract transport: consensus (Raft/Paxos),
106
+ replication, gossip, CRDTs, custom wire protocols, schedulers, retry/backoff/circuit-breaker logic,
107
+ rate limiters — code where the *logic* holds the concurrency bugs.
108
+ - **It is not for** I/O-heavy applications bound to real drivers. Real threads, `multiprocessing`,
109
+ `uvloop`, and C-extension drivers (`asyncpg`, `grpcio`) are explicitly out of scope, because their
110
+ scheduling cannot be controlled from Python — the same wall that stops deterministic testing in Go.
111
+ `seedloop` tests your algorithm, not your database driver.
112
+
113
+ Choosing this boundary deliberately — rather than promising determinism it cannot deliver — is what
114
+ keeps the guarantee real.
115
+
116
+ ## Status
117
+
118
+ The planned build is **complete through v0.3.0**: the deterministic core (custom event loop, virtual
119
+ clock with autojump, seeded entropy, the `World` / `check` / `replay` API), the simulated network with
120
+ fault injection (loss, duplication, partitions), the `world.always` invariant API, the non-determinism
121
+ auditor (`audit=True`), and the worked Raft demo (which runs today) — so `asyncio` runs are reproducible
122
+ and instant, a partition- or timing-dependent bug replays identically from its seed, and an uncontrolled
123
+ entropy source fails loudly under audit. Deferred: the seed-scheduled `world.run_for` fault schedule and
124
+ an optional Hypothesis integration (`seedloop[hypothesis]`). The full API target is in
125
+ [docs/api.md](docs/api.md) and the phased build in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
126
+
127
+ ## Why it exists
128
+
129
+ There is no `pip`-installable deterministic simulation testing framework for Python `asyncio` — the
130
+ capability lives in Rust (`madsim`, `turmoil`), C++ (FoundationDB), Java (OpenDST), and behind a
131
+ commercial hypervisor (Antithesis), but not in Python. Meanwhile the discipline is rising fast among
132
+ serious engineers (Antithesis raised a $105M round led by Jane Street to standardize DST; AWS has
133
+ codified deterministic and formal methods as standing practice). As one of its proponents puts it:
134
+ *writing code is no longer the bottleneck — making sure it does the right thing is.* `seedloop` is a
135
+ tool for exactly that, in the language that lacked it.
136
+
137
+ ## Documentation
138
+
139
+ The design is specified before the code:
140
+
141
+ - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — how `asyncio` is made deterministic, and the phased build.
142
+ - [docs/api.md](docs/api.md) — the public API: `World`, `check`/`replay`, the transport, faults.
143
+ - [docs/internals.md](docs/internals.md) — the loop, virtual clock, entropy control, network and fault scheduling.
144
+ - [docs/network.md](docs/network.md) — the simulated transport and fault model.
145
+ - [docs/scope.md](docs/scope.md) — the determinism boundary: what is controlled and what is not.
146
+ - [docs/testing.md](docs/testing.md) — how determinism is proven by replay.
147
+ - [docs/decisions.md](docs/decisions.md) — the decision records (ADRs).
148
+ - [docs/glossary.md](docs/glossary.md) — the vocabulary.
149
+
150
+ ## License
151
+
152
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,60 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "seedloop"
7
+ description = "Deterministic simulation testing for Python asyncio."
8
+ readme = "README.md"
9
+ requires-python = ">=3.12"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Vojtěch Klíma", email = "vojtechklima02@gmail.com" }]
13
+ keywords = ["asyncio", "testing", "determinism", "simulation", "concurrency"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Topic :: Software Development :: Testing",
21
+ "Typing :: Typed",
22
+ ]
23
+ dynamic = ["version"]
24
+ dependencies = []
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/klimavojtech2002/seedloop"
28
+ Repository = "https://github.com/klimavojtech2002/seedloop"
29
+ Issues = "https://github.com/klimavojtech2002/seedloop/issues"
30
+ Changelog = "https://github.com/klimavojtech2002/seedloop/blob/main/CHANGELOG.md"
31
+
32
+ [project.optional-dependencies]
33
+ dev = ["ruff>=0.6", "mypy>=1.10", "pytest>=8", "pytest-timeout>=2"]
34
+
35
+ [tool.setuptools.dynamic]
36
+ version = { attr = "seedloop.__version__" }
37
+
38
+ [tool.setuptools.packages.find]
39
+ where = ["src"]
40
+
41
+ [tool.ruff]
42
+ line-length = 100
43
+ target-version = "py312"
44
+ src = ["src", "tests"]
45
+
46
+ [tool.ruff.lint]
47
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
48
+
49
+ [tool.mypy]
50
+ python_version = "3.12"
51
+ strict = true
52
+ files = ["src", "tests"]
53
+ exclude = ['(^|/)(\.venv|build|dist|\.eggs)/']
54
+
55
+ [tool.pytest.ini_options]
56
+ testpaths = ["tests"]
57
+ addopts = "-q"
58
+ # A simulated run can livelock on a loop bug; cap every test so a hang fails fast instead of
59
+ # blocking CI. The suite otherwise runs in virtual time and finishes in milliseconds.
60
+ timeout = 30
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,41 @@
1
+ """seedloop — deterministic simulation testing for Python asyncio.
2
+
3
+ Write a scenario against a :class:`World`, then ``check`` it across many seeds; a failing seed is
4
+ the reproduction — ``replay`` it to debug. The deterministic core (loop, virtual clock, seeded
5
+ entropy), the simulated network with fault injection (loss, duplication, partitions), the invariant
6
+ API, and the non-determinism auditor are in place; a worked Raft demo ships in ``seedloop.demos``.
7
+ """
8
+
9
+ from seedloop._audit import audit_mode
10
+ from seedloop._entropy import ensure_hash_seed
11
+ from seedloop._net import Address, Endpoint, Message, Transport
12
+ from seedloop._run import CheckResult, Scenario, check, replay
13
+ from seedloop._world import Node, World
14
+ from seedloop.errors import (
15
+ BoundaryError,
16
+ DeadlockError,
17
+ EntropyLeakError,
18
+ InvariantError,
19
+ SeedloopError,
20
+ )
21
+
22
+ __all__ = [
23
+ "Address",
24
+ "BoundaryError",
25
+ "CheckResult",
26
+ "DeadlockError",
27
+ "Endpoint",
28
+ "EntropyLeakError",
29
+ "InvariantError",
30
+ "Message",
31
+ "Node",
32
+ "Scenario",
33
+ "SeedloopError",
34
+ "Transport",
35
+ "World",
36
+ "audit_mode",
37
+ "check",
38
+ "ensure_hash_seed",
39
+ "replay",
40
+ ]
41
+ __version__ = "0.3.0"
@@ -0,0 +1,107 @@
1
+ """The non-determinism auditor: runtime tripwires for uncontrolled entropy (ADR-0008).
2
+
3
+ A run is a pure function of its seed only if every entropy source it touches is the World's seeded
4
+ one. The loop already rejects the I/O boundary (``run_in_executor``, real sockets, DNS) in every
5
+ mode. This adds an opt-in *audit mode* that closes the Python-level entropy sources the loop does
6
+ not see: real wall-clock time, the unseeded global ``random``, ``os.urandom``/``secrets``, and a
7
+ bare ``threading.Thread``. In audit mode each raises instead of running, so a leak is a loud,
8
+ reproducible failure on the seed that hit it — the boundary enforced, not just stated (scope.md).
9
+
10
+ The tripwires patch only module-level entry points, never ``random.Random`` itself, so the World's
11
+ seeded ``rng`` keeps working; they are pure raises that touch no entropy and leave a clean run's
12
+ timeline unchanged; and they are restored on exit even on error.
13
+
14
+ Like any monkeypatch (and the CSPRNG shim), a tripwire catches a call that looks the name up at call
15
+ time — ``time.monotonic()``, ``random.random()`` — but not a reference bound *before* audit started
16
+ (``from time import monotonic`` then ``monotonic()``). The common attribute-call form is caught; the
17
+ same C-level caveat as ``scope.md`` applies below Python.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import random
24
+ import threading
25
+ import time
26
+ from collections.abc import Callable, Iterator
27
+ from contextlib import contextmanager
28
+ from typing import Any
29
+
30
+ from seedloop.errors import BoundaryError, EntropyLeakError
31
+
32
+ # Real-time entry points (the loop owns virtual time via loop.time(), so any direct call is a leak).
33
+ _REAL_TIME = ("time", "monotonic", "perf_counter", "time_ns", "monotonic_ns", "perf_counter_ns")
34
+
35
+ # Every entropy-drawing module-level `random` function — the *complete* set on the global unseeded
36
+ # instance, not a subset, so a leak through any (e.g. expovariate for latency jitter) is caught.
37
+ # These are module functions; `random.Random` instances such as the seeded rng are untouched.
38
+ _RANDOM_FUNCS = (
39
+ "random",
40
+ "uniform",
41
+ "triangular",
42
+ "randint",
43
+ "randrange",
44
+ "choice",
45
+ "choices",
46
+ "shuffle",
47
+ "sample",
48
+ "getrandbits",
49
+ "randbytes",
50
+ "betavariate",
51
+ "expovariate",
52
+ "gammavariate",
53
+ "gauss",
54
+ "lognormvariate",
55
+ "normalvariate",
56
+ "vonmisesvariate",
57
+ "paretovariate",
58
+ "weibullvariate",
59
+ "binomialvariate", # 3.12+
60
+ )
61
+
62
+ # Each tripwire is (module, attribute, display name). hasattr-guarded so a name absent on a given
63
+ # interpreter is skipped rather than crashing the patcher. os.urandom and the random._urandom alias
64
+ # that secrets draws through are intercepted too.
65
+ _ENTROPY_SURFACES: list[tuple[Any, str, str]] = [
66
+ *((time, name, f"time.{name}") for name in _REAL_TIME if hasattr(time, name)),
67
+ (os, "urandom", "os.urandom"),
68
+ (random, "_urandom", "secrets/os.urandom"),
69
+ *((random, name, f"random.{name}") for name in _RANDOM_FUNCS if hasattr(random, name)),
70
+ ]
71
+
72
+
73
+ def _entropy_tripwire(source: str) -> Callable[..., Any]:
74
+ def tripwire(*_args: Any, **_kwargs: Any) -> Any:
75
+ raise EntropyLeakError(source)
76
+
77
+ return tripwire
78
+
79
+
80
+ def _thread_tripwire(*_args: Any, **_kwargs: Any) -> Any:
81
+ raise BoundaryError(
82
+ "threading.Thread (a real thread) cannot be made deterministic and is out of scope in a "
83
+ "simulated run (see docs/scope.md)"
84
+ )
85
+
86
+
87
+ @contextmanager
88
+ def audit_mode() -> Iterator[None]:
89
+ """Trip on uncontrolled entropy for the duration of the context.
90
+
91
+ Inside the context, real time, the unseeded global ``random``, ``os.urandom``/``secrets``, and
92
+ ``threading.Thread.start`` raise (``EntropyLeakError`` for entropy, ``BoundaryError`` for the
93
+ thread) instead of running. The World's seeded ``rng`` and virtual clock are unaffected. Use it
94
+ via ``check(..., audit=True)`` / ``replay(..., audit=True)``, or directly to wrap your own run.
95
+ All patches are restored on exit, even on error.
96
+ """
97
+ saved = [(mod, attr, getattr(mod, attr)) for mod, attr, _ in _ENTROPY_SURFACES]
98
+ saved_thread_start = threading.Thread.start
99
+ for mod, attr, name in _ENTROPY_SURFACES:
100
+ setattr(mod, attr, _entropy_tripwire(name))
101
+ threading.Thread.start = _thread_tripwire # type: ignore[method-assign]
102
+ try:
103
+ yield
104
+ finally:
105
+ for mod, attr, original in saved:
106
+ setattr(mod, attr, original)
107
+ threading.Thread.start = saved_thread_start # type: ignore[method-assign]
@@ -0,0 +1,96 @@
1
+ """Seeded entropy: per-component sub-streams, a CSPRNG shim, and a hash-seed launcher.
2
+
3
+ A run is a pure function of its seed, so every source of randomness must derive from it. The root
4
+ seed is split into independent named sub-streams (ADR-0009) so adding a draw in one component does
5
+ not perturb another's sequence. The CSPRNG shim routes ``os.urandom``/``secrets`` to a seeded
6
+ source for the duration of a run, and the launcher pins ``PYTHONHASHSEED`` before the interpreter
7
+ starts so set/dict iteration order is fixed (ADR-0010).
8
+
9
+ Verified against the interpreter during design: shimming ``os.urandom`` alone does *not* control
10
+ ``secrets``/``random``, because ``random`` binds ``from os import urandom as _urandom`` at import —
11
+ so the shim patches ``random._urandom`` too; and two child processes launched with the same
12
+ ``PYTHONHASHSEED`` hash identically while a different value differs.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import os
19
+ import random
20
+ import sys
21
+ from collections.abc import Callable, Iterator
22
+ from contextlib import contextmanager
23
+
24
+ _REEXEC_GUARD = "_SEEDLOOP_HASHSEED_REEXEC"
25
+
26
+
27
+ def substream(root_seed: int, label: str) -> random.Random:
28
+ """Derive an independent, reproducible ``random.Random`` for a named component.
29
+
30
+ The stream is a pure function of ``(root_seed, label)``. Derivation hashes the canonical text
31
+ ``f"{root_seed}:{label}"`` with ``blake2b`` — never the builtin ``hash()``, which is randomized
32
+ per process — so the same pair yields the same stream in every process, and any ``int`` seed
33
+ works (negative, or larger than 64 bits).
34
+ """
35
+ digest = hashlib.blake2b(f"{root_seed}:{label}".encode(), digest_size=32).digest()
36
+ return random.Random(int.from_bytes(digest, "big"))
37
+
38
+
39
+ @contextmanager
40
+ def csprng_shim(stream: random.Random) -> Iterator[None]:
41
+ """Route ``os.urandom`` and ``secrets`` to ``stream`` for the duration of the context.
42
+
43
+ Patches both ``os.urandom`` and the ``random._urandom`` alias that ``secrets`` draws through;
44
+ restores both originals on exit, even on error. Scoped to a single run; runs do not overlap in
45
+ one process.
46
+ """
47
+ seeded = _seeded_urandom(stream)
48
+ orig_os = os.urandom
49
+ orig_random = random._urandom # type: ignore[attr-defined] # private alias secrets draws through
50
+ os.urandom = seeded
51
+ random._urandom = seeded # type: ignore[attr-defined]
52
+ try:
53
+ yield
54
+ finally:
55
+ os.urandom = orig_os
56
+ random._urandom = orig_random # type: ignore[attr-defined]
57
+
58
+
59
+ def _seeded_urandom(stream: random.Random) -> Callable[[int], bytes]:
60
+ def seeded_urandom(n: int) -> bytes:
61
+ return stream.getrandbits(n * 8).to_bytes(n, "big") if n else b""
62
+
63
+ return seeded_urandom
64
+
65
+
66
+ def hash_seed_for(root_seed: int) -> int:
67
+ """The ``PYTHONHASHSEED`` value (0..4294967295) a run pins, derived from its root seed."""
68
+ digest = hashlib.blake2b(f"{root_seed}:hashseed".encode(), digest_size=4).digest()
69
+ return int.from_bytes(digest, "big")
70
+
71
+
72
+ def ensure_hash_seed(root_seed: int) -> None:
73
+ """Ensure the interpreter runs with the run's pinned ``PYTHONHASHSEED``.
74
+
75
+ ``PYTHONHASHSEED`` is read once at interpreter start, so it cannot be set from inside a run;
76
+ this re-runs the interpreter with the pinned value when needed. If already pinned, returns and
77
+ the caller proceeds in-process. Otherwise it launches a pinned child running the same command
78
+ and does not return — on POSIX by replacing the process (``execve``), on Windows (no true
79
+ ``exec``) by spawning a child and exiting with its return code. A guard env var prevents
80
+ infinite recursion.
81
+ """
82
+ target = str(hash_seed_for(root_seed))
83
+ if os.environ.get(_REEXEC_GUARD) == target or os.environ.get("PYTHONHASHSEED") == target:
84
+ return # already pinned (our child, or started correctly); proceed in-process
85
+ child_env = dict(os.environ, PYTHONHASHSEED=target, **{_REEXEC_GUARD: target})
86
+ # sys.orig_argv is the full original command (including -c / -m and their payload), so the
87
+ # child re-runs exactly what the parent ran; reconstructing from sys.argv would drop -c code.
88
+ argv = [sys.executable, *sys.orig_argv[1:]]
89
+ if os.name == "posix":
90
+ os.execve(sys.executable, argv, child_env)
91
+ else:
92
+ # Windows has no in-place exec; spawn a pinned child and propagate its exit code.
93
+ import subprocess
94
+
95
+ completed = subprocess.run(argv, env=child_env)
96
+ sys.exit(completed.returncode)