One context manager that freezes time, seeds all randomness, isolates the filesystem, and blocks network — keyed to a single replayable seed, with record/replay of nondeterminism from failing runs
Project description
hermetic
One context manager that makes a block of code deterministic — frozen time,
seeded randomness (uuid4 and secrets included), blocked network, and an
optional isolated filesystem, all keyed to a single seed you can replay.
Instead of stacking freezegun + pyfakefs + responses + a hand-rolled
random.seed() and hoping they compose, you write:
import hermetic
with hermetic.sandbox(seed=42) as sb:
... # same time, same uuids, same tokens, same random stream — every run
And the part nobody else has built: record/replay. Capture the actual nondeterminism from a failing CI run as a journal artifact, download it, and feed the exact same values back into the test on your laptop:
# CI
pytest --hermetic-all --hermetic-record=journals/ # upload journals/ on failure
# your machine, after downloading the artifact
pytest 'tests/test_checkout.py::test_flaky' --hermetic-replay=journals/tests-test_checkout...json.gz
A flaky test stops being folklore ("it fails sometimes on CI??") and becomes a file you can re-run.
Zero dependencies. Python 3.10+. MIT. Not a security boundary — code inside the sandbox can trivially escape via C extensions or subprocesses; this is a determinism tool for tests, nothing more.
Install
pip install hermetic-sandbox
(The distribution is named hermetic-sandbox on PyPI; the import is plain
import hermetic.)
The pytest plugin activates automatically (it does nothing until you use a
marker, fixture, or --hermetic-* option).
What it controls
| Subsystem | Default | What happens | Off switch |
|---|---|---|---|
| Wall clock | clock="virtual" |
time.time, datetime.now, localtime… frozen at now= (default 2020-01-01 UTC). time.sleep(n) advances the clock by n and never blocks. Naive datetime.now()/date.today() convert the frozen instant to the machine's local timezone, exactly like the real clock — use datetime.now(timezone.utc) or sb.clock.now() for TZ-independent values. |
clock="off" |
| Monotonic clocks | (same) | monotonic/perf_counter advance by tick= (default 1 µs) per reading, so elapsed-time math and deadline polls make progress. |
tick=0 freezes them too |
| Randomness | rng="all" |
Global random seeded; os.urandom and random._urandom become PRNG-backed — which makes uuid.uuid4(), secrets.*, and random.SystemRandom deterministic. |
rng="seed" (only random), rng="off" |
| Network | network="block" |
connect/connect_ex/sendto and DNS resolution raise NetworkBlockedError (a ConnectionRefusedError subclass, so existing error handling degrades gracefully). Loopback and unix sockets stay allowed (allow_loopback / allow_unix). |
network="off", or an allowlist: network=["api.example.com:443"] — with the default rng="all" an allowlist emits a UserWarning (real TLS fed deterministic entropy); pair it with rng="seed" |
| Filesystem | fs="off" |
Opt in with fs="isolate": fresh tempdir for cwd/HOME/TMPDIR, deleted on exit. (chdir=False redirects HOME/TMPDIR without moving cwd.) |
default |
| Environment | restore-on-exit | env={...} overrides (None deletes), scrub_env=True drops everything but a safe allowlist. |
— |
Every subsystem is independent: sandbox(clock="off", rng="off") is just a
network blocker.
The handle
with hermetic.sandbox(seed=42, now="2021-06-01T00:00:00+00:00") as sb:
sb.seed # 42 — always printed/printable for repro
sb.clock.advance(3600) # move virtual time
sb.clock.set("2022-01-01") # or jump (move_to is an alias)
sb.clock.now() # aware UTC datetime, without ticking
sb.real.time() # escape hatches to the un-patched world:
sb.real.urandom(16) # real entropy for the one call that needs it
sb.allow_network("localhost:5432") # extend the allowlist at runtime
hermetic.current() # the active handle from anywhere, or None
sandbox(...) is also a decorator (sync and async functions), creating fresh
state per call.
pytest
import pytest
@pytest.mark.hermetic # sandbox this test
def test_order_ids_are_stable(): ...
@pytest.mark.hermetic(network="off", seed=7) # marker kwargs = sandbox kwargs
def test_with_real_network(): ...
def test_time_travel(hermetic_sandbox): # fixture gives you the handle
hermetic_sandbox.clock.advance(86400)
The marker configures, the fixture accesses — using both on one test is
fine. --hermetic-all sandboxes every test (opt out per-test with
@pytest.mark.hermetic(off=True)).
Seeds: each test's seed is derived from its node id XOR a session base seed
(printed in the header, propagated to xdist workers), so re-running any failing
test with --hermetic-seed=<base> reproduces it regardless of test order or
parallelism. Failing sandboxed tests print a copy-pasteable repro command.
The CI record/replay loop
- CI runs
pytest --hermetic-all --hermetic-record=journals/. - Every sandboxed test records real values as it runs. Journals for failing
attempts are kept (a later passing retry from a rerun plugin never deletes
them); passing tests leave nothing.
journals/index.jsonmaps files to tests. - Upload
journals/as a CI artifact when the job fails. - Locally:
pytest --hermetic-replay=path/to/journal.json.gz. The plugin selects the recorded test automatically and feeds back the recorded clock readings, urandom draws, andrandomstate. The journal's recorded configuration wins over local options, and replay warns loudly about anything that makes it less than faithful (different Python/TZ/locale/ PYTHONHASHSEED, multi-threaded recording, event-cap overflow).
Set PYTHONHASHSEED explicitly in CI — dict/set-iteration-order flakes can't
be replayed without it, and the repro hint includes it when set.
What's in a journal (read before uploading anywhere public)
Clock readings, verbatim urandom draws (any token/key the test derived from
them is recoverable), the random state, environment variable names only —
except the values of TZ and PYTHONHASHSEED (and the locale string), which
the fingerprint stores for replay-fidelity warnings — plus hostname, platform,
and git SHA. Treat journals like logs:
private artifact storage, short retention (7–14 days is plenty — a journal is
only useful until the flake is fixed).
Honest limitations
- Threads: patches are process-global; the virtual clock affects all
threads, but
threading.Event.wait/Condition.wait/lock timeouts block in C against the real clock and can't be virtualized. A thread that calls the patched (non-blocking)sleepin a loop will busy-spin. Multi-threaded recording is detected and stamped in the journal; replay warns. - asyncio: virtual time is not supported in v0.1.
loop.time()does follow the patchedtime.monotonic, but the event loop blocks inselector.select()in C —await asyncio.sleep(n)under a frozen clock hangs. Useclock="off"for async tests that await sleeps/timeouts. (The spin detector catches synchronous poll loops, raising a diagnostic error instead of hanging CI.) - C-level entropy: OpenSSL's CSPRNG (TLS), C callers of
_PyOS_URandom,os.getrandom, and direct/dev/urandomreads bypass Python-level patches. - datetime in C extensions: the datetime swap covers Python code (including
from datetime import datetimereferences, via thedeep_datetimescan); C extensions using thePyDateTimeAPIcapsule (numpy, some DB drivers) see the real type. - Network replay: journals do not capture traffic (that's VCR's job); a recording made with a network allowlist replays with a warning that those calls are uncaptured.
- Subprocesses inherit nothing: patches live in this interpreter.
Recipes
numpy (deliberately not built in):
with hermetic.sandbox(seed=42) as sb:
rng = np.random.default_rng(sb.seed) # derive from the sandbox seed
pyfakefs: compose, don't stack — hermetic.sandbox(fs="off") plus pyfakefs's
own fixture works fine; hermetic's fs="isolate" is a real tempdir, not a fake.
Migrating from freezegun: sandbox(now=...) ≈ freeze_time(...);
sb.clock.move_to(...) matches; tick= exists but applies to
monotonic/perf_counter (wall time only moves via sleep/advance). One
difference: freezegun makes naive datetime.now() equal the frozen input
verbatim, while hermetic keeps real-clock semantics — the frozen instant
converted to local time. Freeze with an explicit offset or compare aware
datetimes to stay TZ-independent.
API summary
hermetic.sandbox(
seed=None, # int | None (None → generated, exposed on sb.seed)
now="2020-01-01T00:00:00+00:00", # ISO str | datetime | epoch seconds
tick=1e-6, # monotonic/perf auto-advance per reading; 0 = frozen
clock="virtual", # "virtual" | "off"
rng="all", # "all" | "seed" | "off"
network="block", # "block" | "off" | ["host", "host:port", ...]
allow_loopback=True, allow_unix=True,
fs="off", # "off" | "isolate"
chdir=True, # move cwd when isolating
env=None, scrub_env=False, # overrides / allowlist scrub
record=None, replay=None, # journal paths (.gz → gzip); mutually exclusive
strict_replay=True, # divergence raises ReplayExhausted/ReplayMismatch
deep_datetime=True, # rewrite `from datetime import datetime` refs
spin_limit=1_000_000, # frozen-clock poll-loop detector; 0 disables
)
Errors: SandboxError ⊃ SandboxActiveError (no nesting),
NetworkBlockedError (also a ConnectionRefusedError), ReplayError ⊃
ReplayExhausted, ReplayMismatch.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file hermetic_sandbox-0.1.0.tar.gz.
File metadata
- Download URL: hermetic_sandbox-0.1.0.tar.gz
- Upload date:
- Size: 44.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b4444580f43bee79f0886b137cd3b11c95db4f451ab4f222d66b47a3a736e7f9
|
|
| MD5 |
ee03bc38d9564a14d2247c529c216fe2
|
|
| BLAKE2b-256 |
9834ac0072421fb6195011e296e3aca45483d9c9fb4df3d3eb773566f6fcbd3b
|
Provenance
The following attestation bundles were made for hermetic_sandbox-0.1.0.tar.gz:
Publisher:
publish.yml on Therealdk8890/hermetic
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
hermetic_sandbox-0.1.0.tar.gz -
Subject digest:
b4444580f43bee79f0886b137cd3b11c95db4f451ab4f222d66b47a3a736e7f9 - Sigstore transparency entry: 2195630795
- Sigstore integration time:
-
Permalink:
Therealdk8890/hermetic@7b47c03c3bac3575fe869b0e524f392fbe3713bf -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/Therealdk8890
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@7b47c03c3bac3575fe869b0e524f392fbe3713bf -
Trigger Event:
push
-
Statement type:
File details
Details for the file hermetic_sandbox-0.1.0-py3-none-any.whl.
File metadata
- Download URL: hermetic_sandbox-0.1.0-py3-none-any.whl
- Upload date:
- Size: 38.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
aff0f208fda53692a81eaf2b392d94c26af8e5aad05074e466d578c312d359f7
|
|
| MD5 |
36d4b6d55bec5902dd91f2574366ac51
|
|
| BLAKE2b-256 |
ad2335fb4bc6a93f24edd3b51a410842c3b21cb86ae793fe9a298c358aefa54f
|
Provenance
The following attestation bundles were made for hermetic_sandbox-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on Therealdk8890/hermetic
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
hermetic_sandbox-0.1.0-py3-none-any.whl -
Subject digest:
aff0f208fda53692a81eaf2b392d94c26af8e5aad05074e466d578c312d359f7 - Sigstore transparency entry: 2195630799
- Sigstore integration time:
-
Permalink:
Therealdk8890/hermetic@7b47c03c3bac3575fe869b0e524f392fbe3713bf -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/Therealdk8890
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@7b47c03c3bac3575fe869b0e524f392fbe3713bf -
Trigger Event:
push
-
Statement type: