Skip to main content

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

  1. CI runs pytest --hermetic-all --hermetic-record=journals/.
  2. 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.json maps files to tests.
  3. Upload journals/ as a CI artifact when the job fails.
  4. 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, and random state. 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) sleep in 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 patched time.monotonic, but the event loop blocks in selector.select() in C — await asyncio.sleep(n) under a frozen clock hangs. Use clock="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/urandom reads bypass Python-level patches.
  • datetime in C extensions: the datetime swap covers Python code (including from datetime import datetime references, via the deep_datetime scan); C extensions using the PyDateTimeAPI capsule (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: SandboxErrorSandboxActiveError (no nesting), NetworkBlockedError (also a ConnectionRefusedError), ReplayErrorReplayExhausted, ReplayMismatch.

Release files for hermetic-sandbox 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for hermetic-sandbox 0.1.0
File Size Uploaded
hermetic_sandbox-0.1.0.tar.gz 44.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for hermetic-sandbox 0.1.0
File Interpreter ABI Platform
hermetic_sandbox-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 82.9 kB

Release files / hermetic_sandbox-0.1.0.tar.gz

Download URL hermetic_sandbox-0.1.0.tar.gz
Size 44.9 kB
Tags Source
SHA-256 checksum
How to use checksums
b4444580f43bee79f0886b137cd3b11c95db4f451ab4f222d66b47a3a736e7f9
BLAKE2b-256 checksum
How to use checksums
9834ac0072421fb6195011e296e3aca45483d9c9fb4df3d3eb773566f6fcbd3b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.12

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 19, 2026.

Transparency log

Release files / hermetic_sandbox-0.1.0-py3-none-any.whl

Download URL hermetic_sandbox-0.1.0-py3-none-any.whl
Size 38.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
aff0f208fda53692a81eaf2b392d94c26af8e5aad05074e466d578c312d359f7
BLAKE2b-256 checksum
How to use checksums
ad2335fb4bc6a93f24edd3b51a410842c3b21cb86ae793fe9a298c358aefa54f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.12

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 19, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page