Skip to main content

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

  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.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

hermetic_sandbox-0.1.0.tar.gz (44.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

hermetic_sandbox-0.1.0-py3-none-any.whl (38.0 kB view details)

Uploaded Python 3

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

Hashes for hermetic_sandbox-0.1.0.tar.gz
Algorithm Hash digest
SHA256 b4444580f43bee79f0886b137cd3b11c95db4f451ab4f222d66b47a3a736e7f9
MD5 ee03bc38d9564a14d2247c529c216fe2
BLAKE2b-256 9834ac0072421fb6195011e296e3aca45483d9c9fb4df3d3eb773566f6fcbd3b

See more details on using hashes here.

Provenance

The following attestation bundles were made for hermetic_sandbox-0.1.0.tar.gz:

Publisher: publish.yml on Therealdk8890/hermetic

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file hermetic_sandbox-0.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for hermetic_sandbox-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 aff0f208fda53692a81eaf2b392d94c26af8e5aad05074e466d578c312d359f7
MD5 36d4b6d55bec5902dd91f2574366ac51
BLAKE2b-256 ad2335fb4bc6a93f24edd3b51a410842c3b21cb86ae793fe9a298c358aefa54f

See more details on using hashes here.

Provenance

The following attestation bundles were made for hermetic_sandbox-0.1.0-py3-none-any.whl:

Publisher: publish.yml on Therealdk8890/hermetic

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page