hydo-assert: the Python assertion SDK
A Python program running under hydo (the deterministic hypervisor of
github.com/kakoc/hydo) uses this package to say what must be
true and what should happen at least once. It writes the same console records as the Go SDK
(sdk/go), byte for byte, with the same deduplication rules, so hydo run and the explorer
(hydo explore) read them unchanged. The wire format and the verdict
rules are in hydo/ASSERTIONS.md of that repository; paths below are relative to it.
- Distribution name
hydo-assert, import namehydo_assert. - Standard library only; Python 3.8 or later; thread-safe and safe to call from asyncio code.
- Install it from PyPI (
pip install hydo-assert), or from a checkout (pip install ./sdk/python), or putsdk/python/srconPYTHONPATH, or copysrc/hydo_assert/into the guest image (thehydo/onboard/examples/python-kvexample does that).
Use
import hydo_assert as ha
# Declare at module level: the catalog is complete once the module is imported.
ACKED = ha.always("kv.acked_durable", "every acknowledged PUT reads back with its value")
PARSES = ha.always_or_unreachable("kv.log_parses", "every complete log line parses on replay")
SIZE = ha.always("kv.log_size", "the log holds at most 10000 entries")
REPLAYED = ha.sometimes("kv.recovered", "the server replayed a non-empty log at start")
TORN = ha.reachable("kv.torn_tail", "the log ended in a partial line")
BAD = ha.unreachable("kv.bad_command", "the server got a command it does not know")
BACK = ha.eventually("kv.back", "the server answers within 5 s", 5.0) # seconds or timedelta
LOST = ha.buggify("kv.put.lost_reply", "the PUT is logged but its reply is lost")
ha.catalog() # once, early: the HYDO-ASSERT-DECL lines
ACKED.check(got == want, {"key": k, "got": got}) # returns bool(cond)
REPLAYED.check(n > 0, {"entries": n})
TORN.hit({"bytes": len(tail)})
BAD.hit()
SIZE.cmp(n, "<=", 10000) # details: left, op, right
if LOST.fire({"key": k}): # BUGGIFY fault point
return
BACK.await_cond(lambda: ping() == "PONG", poll=0.2) # blocks; one record
w = BACK.begin(); ...; w.done() # or: anchor now, condition true now / w.expire()
ha.setup_complete() # HYDO-SETUP-COMPLETE
In asyncio code use await BACK.await_async(cond, poll): cond may be a plain function or an
async def, and the wait between checks is asyncio.sleep. Every other call is a short write
to the console under one process-wide lock that is never held across a blocking call or an
await, so it is safe from coroutines and from threads.
Go SDK (hydoassert) |
Python (hydo_assert) |
|---|---|
Always(id, msg), AlwaysOrUnreachable, Sometimes, Reachable, Unreachable |
always(id, msg), always_or_unreachable, sometimes, reachable, unreachable (return an Assertion) |
a.Check(cond, d), a.Hit(d) |
a.check(cond, details=None), a.hit(details=None) |
a.Cmp(left, op, right, d) |
a.cmp(left, op, right, details=None) (op is one of <, <=, ==, !=, >=, >) |
Eventually(id, msg, within), l.Await(cond, poll, d), l.Begin(), w.Done(d), w.Expire(d) |
eventually(id, msg, within), l.await_cond(cond, poll, details=None), await l.await_async(cond, poll, details=None), l.begin(), w.done(details=None), w.expire(details=None) |
Buggify(id, msg), p.Fire(d) |
buggify(id, msg), p.fire(details=None) |
Catalog(), SetupComplete() |
catalog(), setup_complete() |
D{...} |
any mapping (a dict); hydo_assert.D is dict |
The naming follows the other SDKs (sdk/js, sdk/rust): the Go names in Python's snake case,
and await_cond / await_async for Go's Await. That table, Liveness, Watch and Point
(the classes the calls return) and the history recorder below are the whole API: a comparison
has one form, cmp with its operator, and names that start with _ (the modules _core,
_buggify, _eventually, _json, _sink) are the package's own.
Records, deduplication, catalog
- Records go to
/dev/ttyS0(opened once,O_WRONLY|O_APPEND|O_NOCTTY, onewriteper record).HYDO_ASSERT_OUT=<path>writes to that existing file,stderrto standard error (plain logging outside hydo),offnowhere, andlogging(Python only) logs each record line at INFO on the loggerhydo_assert. If the file cannot be opened, standard error is used. - A process writes each (assertion, outcome) pair once: the first pass and the first failure,
with the details of that evaluation. A
sometimesthat evaluated false is never written.unreachableis written withok:false. A record whose details hold a NaN or an infinity is not written at all, but still counts as written (as in Go, wherejson.Marshalfails after the pair was marked). - The catalog (
catalog()) lists every declared assertion in declaration order: the order in which the declaring statements ran, which for module-level declarations is the import order. A module that is never imported contributes nothing; import it before callingcatalog(). Callcatalog()in the process whose console output is collected for each run (for a server started before the explorer's snapshot point, that is a client or the oracle, not the server; see the example). - Details are encoded exactly as Go's
encoding/jsonencodes the equivalent Go value: keys in UTF-8 byte order,<,>,&, U+2028 and U+2029 escaped, other non-ASCII text raw, Go's float format (1e+21,1.5e-7,3for3.0,-0). The caller's dict is never changed (Go addsleft/op/right,hit,elapsed_msto the caller's map; Python adds them to a copy, which gives the same record). - Python values Go has no form for: a tuple is an array;
bytesis a base64 string (as Go encodes[]byte); a set is an array sorted by encoded element; integer dict keys are written in decimal; any other object is the stringstr(obj). A lone surrogate (an undecodable byte read withsurrogateescape) is written as\ufffd, as the pinned guest Go 1.26.5 writes an invalid UTF-8 byte (Go 1.27 writes a raw U+FFFD instead). detailsthat is not a mapping is wrapped as{"value": details}.- After
os.fork()the child keeps the parent's deduplication state (it is one program's history) and gets fresh locks.
BUGGIFY
Same decisions as the Go SDK: configured by $HYDO_BUGGIFY, or else the file
$HYDO_BUGGIFY_FILE (default /tmp/hydo-buggify), holding
<seed-hex>[,<enable-permille>[,<fire-permille>]] (defaults 250 and 250, FoundationDB's
values), parsed as Go's strconv.ParseUint does (no 0x, no sign, no spaces inside a field;
an unparsable seed leaves BUGGIFY off, an unparsable permille keeps its default). It is read
once, at the first evaluation of any point, which also writes
HYDO-BUGGIFY {"seed":..,"enable_pm":..,"fire_pm":..,"enabled":[..]}. A point is enabled
for the run when splitmix64(seed ^ fnv1a(id)) % 1000 < enable; its k-th evaluation fires
when splitmix64(splitmix64(seed ^ fnv1a(id)) + k·0x9e3779b97f4a7c15) % 1000 < fire. Each
point is also a sometimes property buggify.<id> ("fault point fired: "), recorded at
its first firing with {"hit": k}. Without a configuration every point returns False and has
no other effect. Under hydo the schedule token B=<seed> (hydo explore --buggify) reaches the
file through the guest init: an onboarded project gets it with buggify: true in its
hydo.yaml (hydo/onboard/MANIFEST.md).
History recorder
The module hydo_assert.history records operation histories for hydo's host-side checkers
(linearizability of a register or compare-and-set object per key, and Elle-style list-append;
the format and the checkers are in hydo/HISTORIES.md). It writes the same HYDO-HIST lines as
the Go recorder (sdk/go/hydohist), byte for byte except the time field. It is a separate
module: import hydo_assert does not load it, so the assertion records are unchanged.
from hydo_assert import history as hh
kv = hh.History("kv")
op = kv.invoke(3, "write", "k1", hh.enc(v)) # process 3, before the request
try:
rev = put("k1", v)
op.ok("k1", hh.ver(v, rev)) # the version the store assigned, if any
except Rejected:
op.fail() # it definitely had no effect
except Exception:
op.info() # a timeout: the outcome is unknown
kv.invoke(3, "read", "k1").ok("k1", hh.value_or_nil(get("k1"))) # None means absent
la = hh.History("la")
tx = la.invoke(4, "txn", hh.append_mop("x", "1"), hh.read_mop("y"))
tx.ok(hh.append_mop("x", "1"), hh.read_result_mop("y", "1", "2"))
Go (hydohist) |
Python (hydo_assert.history) |
|---|---|
New(name) |
History(name) |
h.Invoke(proc, f, args...), h.Record(proc, typ, f, args...) |
h.invoke(proc, f, *args), h.record(proc, typ, f, *args) |
op.Ok(args...), op.Fail(), op.Info() |
op.ok(*args) (no args repeats the invocation's), op.fail(), op.info() |
Enc, Ver, ValOrNil(v, ok), List |
enc, ver, value_or_nil(v) (None means absent), list_value |
Append, Read, ReadResult, Nil |
append_mop, read_mop, read_result_mop, NIL |
list_valuehas a longer name than Go'sListso that it does not hide the builtinlistfor a program that usesfrom hydo_assert.history import *. The micro-op helpers have the Rust SDK's names.- A process is an int or a token without spaces. Arguments are tokens: a
stris written as it is (encode free-form values withenc), anintis written in decimal, andbytesare written as they are. Versions are int64 values; a value outside that range raisesValueError, and a value that is not an int (includingbool) raisesTypeError. - Records go to
/dev/console.HYDO_HIST_OUT=<path>appends to that file and creates it if it is missing (mode 0644),stderrwrites to standard error, andoffwrites nothing. If the file cannot be opened, standard error is used. The setting is read once, at the first record, and is separate fromHYDO_ASSERT_OUT. - Each record is one
os.writecall made under a lock, so threads never interleave lines. The time field istime.monotonic_ns()(CLOCK_MONOTONIC on Linux, deterministic under hydo). It is read under the lock, so the times of successive lines never decrease; Go reads it just before its lock, and the line format is the same. A forked child gets a new lock and keeps the descriptor. - Text is written as UTF-8. A
stris encoded with thesurrogateescaperule, so a string that came from bytes that way (os.fsdecode,sys.argv,os.environ, orencof bytes) gives back its original bytes. Any other lone surrogate is written as U+FFFD.encofbyteskeeps invalid UTF-8 bytes as they are, as Go'sEncdoes, and returns them as surrogateescape characters. - A process has at most one operation in flight: give each concurrent client (thread or task) its own process number. Record the invocation before sending the request and the completion after the reply, because the checkers order operations by the position of their records.
Determinism
The records are a pure function of the program's evaluation sequence: no clocks (except the
guest's monotonic clock for eventually, which hvx makes deterministic), no host state,
catalog in declaration order, sorted keys. A Python program is itself deterministic under hydo:
the guest CRNG is seeded identically every run, so even the hash seed is the same; setting
PYTHONHASHSEED=0 makes that independent of the CRNG. PYTHONDONTWRITEBYTECODE=1 avoids
writing .pyc files on the guest's read-only root.
Tests
python3 -m unittest discover -s sdk/python/tests: the encoder (Go's float and string forms), deduplication, details not mutated, NaN, catalog order, 16 threads writing 2000 evaluations each without an interleaved line, asyncioeventually, fork, BUGGIFY decisions and rates. Passes on Python 3.8.20 and 3.15.0.tests/test_cases.py(run by the sameunittestcommand): the shared cases (sdk/cases, written by the reference SDK,sdk/rust; format and rules insdk/rust/README.md, "The shared cases"). For every case and run it starts itself as a child process, makes the case's calls with this package andhydo_assert.history, and compares the bytes the sink received and the calls' results with the case's files: every assertion type, comparisons, details with tricky values (invalid UTF-8 assurrogateescapecharacters), the catalog,eventually, BUGGIFY under 18 configurations,HYDO_ASSERT_OUT, and the history records with every helper andHYDO_HIST_OUTsetting. Cases that need a float32 (f32) are skipped and listed. To add a case, add it to the Rust SDK'stests/cases/defs.rsand runHYDO_BLESS=1 cargo test --test casesthere; this test then runs it. A format change starts in the Rust SDK too: after it is blessed, this test fails on the lines this package must change. No other language is needed to run it. Passes on Python 3.8.20 and 3.15.0.tests/test_history.py(run by the sameunittestcommand): the exact lines of the Go recorder's own unit test, the encoding of every byte class, the argument checks, the sink settings, oneos.writeper record, 8 threads writing 400 records each without an interleaved line, and fork.- The files of
src/hydo_assert/are copied into the python-kv example's images, so a change to any of them changes those images' bytes and every python-kv fingerprint (the last such change, a reworded comment in_core.py, re-recorded them in milestone 157; docs/REBASELINE.md has the values).
Limits
- No call-site form that registers itself before it runs (the Rust SDK's macros do that with a linker section). A Python assertion is in the catalog once the statement that declares it has run, so declare at module level.
cmpcompares any two Python values that support the operator; values that cannot be compared evaluate to false. Go'sCmptakes onlyint64.
Metadata
Release files for hydo-assert 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| hydo_assert-0.2.0.tar.gz | 38.1 kB | Without provenance | Details for hydo_assert-0.2.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| hydo_assert-0.2.0-py3-none-any.whl | Python 3 | none | any | Details for hydo_assert-0.2.0-py3-none-any.whl |
Total release size: 65.5 kB
Release files / hydo_assert-0.2.0.tar.gz
| Download URL | hydo_assert-0.2.0.tar.gz |
|---|---|
| Size | 38.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
aca0f1b935568ce518e242a94b9bc3486aa5b5a1c9ac59a80ecd980eb2b6745c
|
|
BLAKE2b-256 checksum How to use checksums |
c5fff35139ffa2afe6e00eb4260465c722b3b55461f137f6758463275cf39826
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.15.0
|
Release files / hydo_assert-0.2.0-py3-none-any.whl
| Download URL | hydo_assert-0.2.0-py3-none-any.whl |
|---|---|
| Size | 27.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
674d119f7f2e7506eeef189481690773f4ad84a031021779fd0d40e5e7da2937
|
|
BLAKE2b-256 checksum How to use checksums |
333ea76d03fd6e2bf8508db736f536e88243b64bf2278c66a7f32465eee835f9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.15.0
|
Security for hydo-assert 0.2.0
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.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.