hydo-assert 0.2.0__py3-none-any.whl

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.
hydo_assert/_sink.py ADDED
@@ -0,0 +1,93 @@
1
+ """Where records go: the guest console, opened once.
2
+
3
+ ``HYDO_ASSERT_OUT`` (read once, at the first record) selects the target, as in the Go SDK:
4
+
5
+ - unset or empty: ``/dev/ttyS0`` (the hvx guest console);
6
+ - a path: that existing file, opened ``O_WRONLY|O_APPEND|O_NOCTTY`` (never created);
7
+ - ``stderr``: standard error (file descriptor 2), i.e. plain logging outside hydo;
8
+ - ``off``: nowhere;
9
+ - ``logging`` (Python only): each record line is logged at INFO on the logger ``hydo_assert``.
10
+
11
+ If the file cannot be opened the records go to standard error. Every record is written with
12
+ one ``write()`` call (a loop only if the kernel accepts part of it), so it is never interleaved
13
+ with other output on the console.
14
+ """
15
+
16
+ import os
17
+ import threading
18
+
19
+ _lock = threading.Lock()
20
+ _ready = False
21
+ _mode = "fd" # "fd", "off" or "logging"
22
+ _fd = 2
23
+
24
+
25
+ def _init():
26
+ global _ready, _mode, _fd
27
+ p = os.environ.get("HYDO_ASSERT_OUT", "")
28
+ if p == "off":
29
+ _mode = "off"
30
+ elif p == "stderr":
31
+ _mode, _fd = "fd", 2
32
+ elif p == "logging":
33
+ _mode = "logging"
34
+ else:
35
+ if p == "":
36
+ p = "/dev/ttyS0"
37
+ try:
38
+ _fd = os.open(p, os.O_WRONLY | os.O_APPEND | getattr(os, "O_NOCTTY", 0))
39
+ _mode = "fd"
40
+ except OSError:
41
+ _mode, _fd = "fd", 2
42
+ _ready = True
43
+
44
+
45
+ def _ensure():
46
+ if not _ready:
47
+ with _lock:
48
+ if not _ready:
49
+ _init()
50
+
51
+
52
+ def enabled():
53
+ """False when HYDO_ASSERT_OUT=off (records are dropped)."""
54
+ _ensure()
55
+ return _mode != "off"
56
+
57
+
58
+ def write_line(line):
59
+ """Write one record line (``line`` without the trailing newline) to the sink."""
60
+ _ensure()
61
+ if _mode == "off":
62
+ return
63
+ if _mode == "logging":
64
+ import logging
65
+
66
+ logging.getLogger("hydo_assert").info("%s", line)
67
+ return
68
+ data = (line + "\n").encode("utf-8", "surrogatepass")
69
+ try:
70
+ while data:
71
+ n = os.write(_fd, data)
72
+ if n <= 0:
73
+ break
74
+ data = data[n:]
75
+ except OSError:
76
+ pass # like Go's f.Write: a failed console write is not the program's problem
77
+
78
+
79
+ def _reset():
80
+ """Forget the sink (tests). The next record re-reads HYDO_ASSERT_OUT."""
81
+ global _ready, _mode, _fd
82
+ with _lock:
83
+ if _ready and _mode == "fd" and _fd > 2:
84
+ try:
85
+ os.close(_fd)
86
+ except OSError:
87
+ pass
88
+ _ready, _mode, _fd = False, "fd", 2
89
+
90
+
91
+ def _after_fork():
92
+ global _lock
93
+ _lock = threading.Lock()
hydo_assert/history.py ADDED
@@ -0,0 +1,300 @@
1
+ """Operation histories for hydo's host-side checkers (hydo/HISTORIES.md).
2
+
3
+ A workload client records every operation it performs -- the invocation before the request,
4
+ the completion after the reply -- and the host checks the whole history after the run
5
+ (linearizability of a register / CAS object per key, Elle-style list-append). The lines are byte
6
+ for byte those of the Go recorder (sdk/go/hydohist)::
7
+
8
+ HYDO-HIST <history> <process> <invoke|ok|fail|info> <time-ns> <f> <arg>...
9
+
10
+ Use::
11
+
12
+ from hydo_assert import history as hh
13
+
14
+ kv = hh.History("kv")
15
+ op = kv.invoke(3, "write", "k1", hh.enc(v)) # process 3, before the request
16
+ try:
17
+ rev = put("k1", v)
18
+ op.ok("k1", hh.ver(v, rev)) # the version the store assigned, if any
19
+ except Rejected:
20
+ op.fail() # definitely had no effect
21
+ except Exception:
22
+ op.info() # a timeout: unknown outcome
23
+ op = kv.invoke(3, "read", "k1")
24
+ op.ok("k1", hh.value_or_nil(get("k1"))) # None = absent
25
+
26
+ One record = one line = one ``os.write`` to ``/dev/console``; ``HYDO_HIST_OUT=<path>|stderr|off``
27
+ overrides it (the assertion records keep ``HYDO_ASSERT_OUT``). A path is opened
28
+ ``O_WRONLY|O_APPEND|O_CREAT|O_NOCTTY`` (mode 0644), once, at the first record; if it cannot be
29
+ opened the records go to standard error. The host orders operations by the position of their
30
+ records, so record the invocation before sending and the completion after receiving. A process
31
+ has at most one operation in flight: give each concurrent client (thread, task) its own process
32
+ number. The time field is CLOCK_MONOTONIC in ns (``time.monotonic_ns()``, deterministic under
33
+ hydo); the checkers do not order by it.
34
+
35
+ Text: tokens are written as UTF-8. A ``str`` is encoded with the ``surrogateescape`` rule, so a
36
+ string that came from bytes that way (``os.fsdecode``, ``sys.argv``, ``os.environ``, or the result
37
+ of ``enc`` on bytes) gives back its original bytes; any other lone surrogate is written as U+FFFD.
38
+ ``bytes`` are accepted wherever a ``str`` is and are taken as they are, so invalid UTF-8 can be
39
+ recorded exactly as Go records it.
40
+
41
+ This module is not imported by ``hydo_assert`` itself: importing the assertion SDK writes and
42
+ opens nothing new.
43
+ """
44
+
45
+ import operator
46
+ import os
47
+ import threading
48
+ import time
49
+
50
+ __all__ = [
51
+ "NIL",
52
+ "History",
53
+ "Op",
54
+ "enc",
55
+ "ver",
56
+ "value_or_nil",
57
+ "list_value",
58
+ "append_mop",
59
+ "read_mop",
60
+ "read_result_mop",
61
+ ]
62
+
63
+ #: The value of an absent key (register model); the empty list is ``[]``.
64
+ NIL = "nil"
65
+
66
+ _INT64_MIN = -(1 << 63)
67
+ _INT64_MAX = (1 << 63) - 1
68
+
69
+
70
+ def _utf8(s):
71
+ """The bytes of a str: UTF-8 with surrogateescape; other lone surrogates become U+FFFD."""
72
+ try:
73
+ return s.encode("utf-8", "surrogateescape")
74
+ except UnicodeEncodeError:
75
+ out = bytearray()
76
+ for ch in s:
77
+ o = ord(ch)
78
+ if 0xDC80 <= o <= 0xDCFF:
79
+ out.append(o - 0xDC00)
80
+ elif 0xD800 <= o <= 0xDFFF:
81
+ out += b"\xef\xbf\xbd"
82
+ else:
83
+ out += ch.encode("utf-8")
84
+ return bytes(out)
85
+
86
+
87
+ def _int(x, what):
88
+ """An int64 in decimal, as Go's strconv.FormatInt writes it (bool is refused)."""
89
+ try:
90
+ if isinstance(x, bool):
91
+ raise TypeError
92
+ x = operator.index(x)
93
+ except TypeError:
94
+ raise TypeError("hydo_assert.history: %s must be an int, got %s" % (what, type(x).__name__)) from None
95
+ if not _INT64_MIN <= x <= _INT64_MAX:
96
+ raise ValueError("hydo_assert.history: %s %d is outside int64" % (what, x))
97
+ return str(int(x))
98
+
99
+
100
+ def _token(x, what="argument"):
101
+ """A record argument: a str (one token already), bytes (taken as they are) or an int."""
102
+ if isinstance(x, str):
103
+ return x
104
+ if isinstance(x, (bytes, bytearray, memoryview)):
105
+ return bytes(x).decode("utf-8", "surrogateescape")
106
+ return _int(x, what)
107
+
108
+
109
+ # Bytes 0x00-0x7f: the byte itself, or %XX for '%', '@', whitespace and control characters
110
+ # (<= ' '), DEL and the list / txn separators ',[]:' (Go's Enc). Bytes >= 0x80 stay.
111
+ _TABLE = []
112
+ for _c in range(256):
113
+ if _c == 0x25 or _c == 0x40 or _c <= 0x20 or _c == 0x7F or _c in b",[]:":
114
+ _TABLE.append(("%%%02X" % _c).encode("ascii"))
115
+ else:
116
+ _TABLE.append(bytes((_c,)))
117
+ del _c
118
+
119
+
120
+ def enc(s):
121
+ """One token from a value, as Go's ``hydohist.Enc`` makes it from the value's bytes.
122
+
123
+ '%', '@', whitespace, control characters, DEL and ``,[]:`` become ``%XX``; other bytes
124
+ (all of non-ASCII UTF-8, and invalid bytes given as ``bytes``) stay. ``""`` is ``"%"`` and
125
+ ``"nil"`` is ``"%6Eil"`` (the token ``nil`` means absent). ``s`` is a str, bytes or an int.
126
+ """
127
+ if isinstance(s, str):
128
+ b = _utf8(s)
129
+ elif isinstance(s, (bytes, bytearray, memoryview)):
130
+ b = bytes(s)
131
+ else:
132
+ b = _int(s, "enc value").encode("ascii")
133
+ if b == b"":
134
+ return "%"
135
+ if b == b"nil":
136
+ return "%6Eil"
137
+ t = _TABLE
138
+ return b"".join([t[c] for c in b]).decode("utf-8", "surrogateescape")
139
+
140
+
141
+ def ver(v, version):
142
+ """Value ``v`` (encoded) with the version the store assigned it (an int64): ``"v@83"``."""
143
+ return enc(v) + "@" + _int(version, "version")
144
+
145
+
146
+ def value_or_nil(v):
147
+ """``enc(v)``, or ``NIL`` when ``v`` is None (Go's ``ValOrNil(v, ok)`` with ok = v is not None)."""
148
+ return NIL if v is None else enc(v)
149
+
150
+
151
+ def list_value(*elems):
152
+ """A list value ``"[e1,e2,...]"`` (list-append reads), each element encoded.
153
+
154
+ Go and Rust call it ``List`` / ``list``; here a function named ``list`` would hide the
155
+ builtin for every ``from hydo_assert.history import *`` user.
156
+ """
157
+ return "[" + ",".join([enc(e) for e in elems]) + "]"
158
+
159
+
160
+ def append_mop(k, e):
161
+ """The list-append txn micro-op ``"a:<k>:<e>"``."""
162
+ return "a:" + enc(k) + ":" + enc(e)
163
+
164
+
165
+ def read_mop(k):
166
+ """The list-append txn micro-op ``"r:<k>"`` (invocation)."""
167
+ return "r:" + enc(k)
168
+
169
+
170
+ def read_result_mop(k, *l):
171
+ """The list-append txn micro-op ``"r:<k>:[...]"`` (completion)."""
172
+ return "r:" + enc(k) + ":" + list_value(*l)
173
+
174
+
175
+ # ---------------------------------------------------------------------------------------------
176
+ # Sink: opened once, at the first record.
177
+
178
+ _lock = threading.Lock()
179
+ _ready = False
180
+ _off = False
181
+ _fd = 2
182
+
183
+
184
+ def _init():
185
+ global _ready, _off, _fd
186
+ p = os.environ.get("HYDO_HIST_OUT", "")
187
+ _off, _fd = False, 2
188
+ if p == "off":
189
+ _off = True
190
+ elif p != "stderr":
191
+ if p == "":
192
+ p = "/dev/console"
193
+ try:
194
+ _fd = os.open(p, os.O_WRONLY | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOCTTY", 0), 0o644)
195
+ except OSError:
196
+ _fd = 2
197
+ _ready = True
198
+
199
+
200
+ def _write(name, proc, typ, f, args):
201
+ if not _ready:
202
+ with _lock:
203
+ if not _ready:
204
+ _init()
205
+ if _off:
206
+ return
207
+ parts = [name, proc, typ, None, f]
208
+ for a in args:
209
+ parts.append(a if a != "" else "%") # the empty string (enc("") gives it too)
210
+ with _lock:
211
+ # The time is read under the lock, so the times of the records on one console never
212
+ # decrease (Go reads it just before taking its lock; the format is the same).
213
+ parts[3] = str(time.monotonic_ns())
214
+ data = _utf8("HYDO-HIST " + " ".join(parts) + "\n")
215
+ try:
216
+ while data: # one write(); a short write (never on the console) is continued
217
+ n = os.write(_fd, data)
218
+ if n <= 0:
219
+ break
220
+ data = data[n:]
221
+ except OSError:
222
+ pass # like Go's f.Write: a failed console write is ignored
223
+
224
+
225
+ def _reset():
226
+ """Forget the sink (tests). The next record re-reads HYDO_HIST_OUT."""
227
+ global _ready, _off, _fd
228
+ with _lock:
229
+ if _ready and not _off and _fd > 2:
230
+ try:
231
+ os.close(_fd)
232
+ except OSError:
233
+ pass
234
+ _ready, _off, _fd = False, False, 2
235
+
236
+
237
+ def _after_fork():
238
+ # The child inherits the descriptor; a lock held by another thread at the fork is not.
239
+ global _lock
240
+ _lock = threading.Lock()
241
+
242
+
243
+ if hasattr(os, "register_at_fork"):
244
+ os.register_at_fork(after_in_child=_after_fork)
245
+
246
+
247
+ class History(object):
248
+ """One named history (a check's ``history:``; ``[A-Za-z0-9_.-]+``)."""
249
+
250
+ __slots__ = ("name",)
251
+
252
+ def __init__(self, name):
253
+ self.name = str(name)
254
+
255
+ def invoke(self, proc, f, *args):
256
+ """Record the invocation of ``f(args...)`` by process ``proc`` and return the operation.
257
+
258
+ ``proc`` is an int or a token without spaces. ``args`` are tokens: encode free-form
259
+ values with ``enc`` (``ver``, ``list_value`` and ``NIL`` help); ints are written in
260
+ decimal, bytes as they are.
261
+ """
262
+ op = Op(self, _token(proc, "process"), str(f), tuple(_token(a) for a in args))
263
+ _write(self.name, op.proc, "invoke", op.f, op.args)
264
+ return op
265
+
266
+ def record(self, proc, typ, f, *args):
267
+ """Write one raw record (for programs that keep their own bookkeeping)."""
268
+ _write(self.name, _token(proc, "process"), str(typ), str(f), [_token(a) for a in args])
269
+
270
+ def __repr__(self):
271
+ return "History(%r)" % (self.name,)
272
+
273
+
274
+ class Op(object):
275
+ """An invoked operation waiting for its completion."""
276
+
277
+ __slots__ = ("history", "proc", "f", "args")
278
+
279
+ def __init__(self, history, proc, f, args):
280
+ self.history = history
281
+ self.proc = proc
282
+ self.f = f
283
+ self.args = args
284
+
285
+ def ok(self, *args):
286
+ """A successful completion: the results in the invocation's layout (a read lists
287
+ ``<key> <value>`` pairs, a txn its micro-ops with read results); with no args the
288
+ invocation's are repeated."""
289
+ _write(self.history.name, self.proc, "ok", self.f, [_token(a) for a in args] if args else self.args)
290
+
291
+ def fail(self):
292
+ """The operation definitely had no effect."""
293
+ _write(self.history.name, self.proc, "fail", self.f, self.args)
294
+
295
+ def info(self):
296
+ """The outcome is unknown (it may take effect at any later time, or never)."""
297
+ _write(self.history.name, self.proc, "info", self.f, self.args)
298
+
299
+ def __repr__(self):
300
+ return "Op(%r, %r, %r, %r)" % (self.history.name, self.proc, self.f, self.args)
hydo_assert/py.typed ADDED
File without changes
@@ -0,0 +1,238 @@
1
+ Metadata-Version: 2.4
2
+ Name: hydo-assert
3
+ Version: 0.2.0
4
+ Summary: Assertion SDK for programs tested under hydo, a deterministic hypervisor (always/sometimes properties, BUGGIFY fault points, bounded liveness, operation histories)
5
+ License-Expression: Apache-2.0
6
+ Project-URL: Homepage, https://github.com/kakoc/hydo
7
+ Project-URL: Documentation, https://github.com/kakoc/hydo/tree/main/sdk/python#readme
8
+ Project-URL: Repository, https://github.com/kakoc/hydo
9
+ Project-URL: Issues, https://github.com/kakoc/hydo/issues
10
+ Keywords: testing,assertions,deterministic,fault-injection,simulation,hydo
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.8
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Dynamic: license-file
22
+
23
+ # hydo-assert: the Python assertion SDK
24
+
25
+ A Python program running under hydo (the deterministic hypervisor of
26
+ [github.com/kakoc/hydo](https://github.com/kakoc/hydo)) uses this package to say what must be
27
+ true and what should happen at least once. It writes the same console records as the Go SDK
28
+ (`sdk/go`), byte for byte, with the same deduplication rules, so `hydo run` and the explorer
29
+ (`hydo explore`) read them unchanged. The wire format and the verdict
30
+ rules are in `hydo/ASSERTIONS.md` of that repository; paths below are relative to it.
31
+
32
+ - Distribution name `hydo-assert`, import name `hydo_assert`.
33
+ - Standard library only; Python 3.8 or later; thread-safe and safe to call from asyncio code.
34
+ - Install it from PyPI (`pip install hydo-assert`), or from a checkout (`pip install ./sdk/python`),
35
+ or put `sdk/python/src` on `PYTHONPATH`, or copy `src/hydo_assert/` into the guest image (the
36
+ `hydo/onboard/examples/python-kv` example does that).
37
+
38
+ ## Use
39
+
40
+ ```python
41
+ import hydo_assert as ha
42
+
43
+ # Declare at module level: the catalog is complete once the module is imported.
44
+ ACKED = ha.always("kv.acked_durable", "every acknowledged PUT reads back with its value")
45
+ PARSES = ha.always_or_unreachable("kv.log_parses", "every complete log line parses on replay")
46
+ SIZE = ha.always("kv.log_size", "the log holds at most 10000 entries")
47
+ REPLAYED = ha.sometimes("kv.recovered", "the server replayed a non-empty log at start")
48
+ TORN = ha.reachable("kv.torn_tail", "the log ended in a partial line")
49
+ BAD = ha.unreachable("kv.bad_command", "the server got a command it does not know")
50
+ BACK = ha.eventually("kv.back", "the server answers within 5 s", 5.0) # seconds or timedelta
51
+ LOST = ha.buggify("kv.put.lost_reply", "the PUT is logged but its reply is lost")
52
+
53
+ ha.catalog() # once, early: the HYDO-ASSERT-DECL lines
54
+ ACKED.check(got == want, {"key": k, "got": got}) # returns bool(cond)
55
+ REPLAYED.check(n > 0, {"entries": n})
56
+ TORN.hit({"bytes": len(tail)})
57
+ BAD.hit()
58
+ SIZE.cmp(n, "<=", 10000) # details: left, op, right
59
+ if LOST.fire({"key": k}): # BUGGIFY fault point
60
+ return
61
+ BACK.await_cond(lambda: ping() == "PONG", poll=0.2) # blocks; one record
62
+ w = BACK.begin(); ...; w.done() # or: anchor now, condition true now / w.expire()
63
+ ha.setup_complete() # HYDO-SETUP-COMPLETE
64
+ ```
65
+
66
+ In asyncio code use `await BACK.await_async(cond, poll)`: `cond` may be a plain function or an
67
+ `async def`, and the wait between checks is `asyncio.sleep`. Every other call is a short write
68
+ to the console under one process-wide lock that is never held across a blocking call or an
69
+ `await`, so it is safe from coroutines and from threads.
70
+
71
+ | Go SDK (`hydoassert`) | Python (`hydo_assert`) |
72
+ |---|---|
73
+ | `Always(id, msg)`, `AlwaysOrUnreachable`, `Sometimes`, `Reachable`, `Unreachable` | `always(id, msg)`, `always_or_unreachable`, `sometimes`, `reachable`, `unreachable` (return an `Assertion`) |
74
+ | `a.Check(cond, d)`, `a.Hit(d)` | `a.check(cond, details=None)`, `a.hit(details=None)` |
75
+ | `a.Cmp(left, op, right, d)` | `a.cmp(left, op, right, details=None)` (`op` is one of `<`, `<=`, `==`, `!=`, `>=`, `>`) |
76
+ | `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)` |
77
+ | `Buggify(id, msg)`, `p.Fire(d)` | `buggify(id, msg)`, `p.fire(details=None)` |
78
+ | `Catalog()`, `SetupComplete()` | `catalog()`, `setup_complete()` |
79
+ | `D{...}` | any mapping (a `dict`); `hydo_assert.D` is `dict` |
80
+
81
+ The naming follows the other SDKs (`sdk/js`, `sdk/rust`): the Go names in Python's snake case,
82
+ and `await_cond` / `await_async` for Go's `Await`. That table, `Liveness`, `Watch` and `Point`
83
+ (the classes the calls return) and the history recorder below are the whole API: a comparison
84
+ has one form, `cmp` with its operator, and names that start with `_` (the modules `_core`,
85
+ `_buggify`, `_eventually`, `_json`, `_sink`) are the package's own.
86
+
87
+ ## Records, deduplication, catalog
88
+
89
+ - Records go to `/dev/ttyS0` (opened once, `O_WRONLY|O_APPEND|O_NOCTTY`, one `write` per
90
+ record). `HYDO_ASSERT_OUT=<path>` writes to that existing file, `stderr` to standard error
91
+ (plain logging outside hydo), `off` nowhere, and `logging` (Python only) logs each record line
92
+ at INFO on the logger `hydo_assert`. If the file cannot be opened, standard error is used.
93
+ - A process writes each (assertion, outcome) pair once: the first pass and the first failure,
94
+ with the details of that evaluation. A `sometimes` that evaluated false is never written.
95
+ `unreachable` is written with `ok:false`. A record whose details hold a NaN or an infinity is
96
+ not written at all, but still counts as written (as in Go, where `json.Marshal` fails after
97
+ the pair was marked).
98
+ - The catalog (`catalog()`) lists every declared assertion in declaration order: the order in
99
+ which the declaring statements ran, which for module-level declarations is the import order.
100
+ A module that is never imported contributes nothing; import it before calling `catalog()`.
101
+ Call `catalog()` in the process whose console output is collected for each run (for a
102
+ server started before the explorer's snapshot point, that is a client or the oracle, not the
103
+ server; see the example).
104
+ - Details are encoded exactly as Go's `encoding/json` encodes the equivalent Go value: keys in
105
+ UTF-8 byte order, `<`, `>`, `&`, U+2028 and U+2029 escaped, other non-ASCII text raw, Go's
106
+ float format (`1e+21`, `1.5e-7`, `3` for `3.0`, `-0`). The caller's dict is never changed
107
+ (Go adds `left`/`op`/`right`, `hit`, `elapsed_ms` to the caller's map; Python adds them to a
108
+ copy, which gives the same record).
109
+ - Python values Go has no form for: a tuple is an array; `bytes` is a base64 string (as Go
110
+ encodes `[]byte`); a set is an array sorted by encoded element; integer dict keys are written
111
+ in decimal; any other object is the string `str(obj)`. A lone surrogate (an undecodable byte
112
+ read with `surrogateescape`) is written as `\ufffd`, as the pinned guest Go 1.26.5 writes an
113
+ invalid UTF-8 byte (Go 1.27 writes a raw U+FFFD instead).
114
+ - `details` that is not a mapping is wrapped as `{"value": details}`.
115
+ - After `os.fork()` the child keeps the parent's deduplication state (it is one program's
116
+ history) and gets fresh locks.
117
+
118
+ ## BUGGIFY
119
+
120
+ Same decisions as the Go SDK: configured by `$HYDO_BUGGIFY`, or else the file
121
+ `$HYDO_BUGGIFY_FILE` (default `/tmp/hydo-buggify`), holding
122
+ `<seed-hex>[,<enable-permille>[,<fire-permille>]]` (defaults 250 and 250, FoundationDB's
123
+ values), parsed as Go's `strconv.ParseUint` does (no `0x`, no sign, no spaces inside a field;
124
+ an unparsable seed leaves BUGGIFY off, an unparsable permille keeps its default). It is read
125
+ once, at the first evaluation of any point, which also writes
126
+ `HYDO-BUGGIFY {"seed":..,"enable_pm":..,"fire_pm":..,"enabled":[..]}`. A point is enabled
127
+ for the run when `splitmix64(seed ^ fnv1a(id)) % 1000 < enable`; its k-th evaluation fires
128
+ when `splitmix64(splitmix64(seed ^ fnv1a(id)) + k·0x9e3779b97f4a7c15) % 1000 < fire`. Each
129
+ point is also a `sometimes` property `buggify.<id>` ("fault point fired: <msg>"), recorded at
130
+ its first firing with `{"hit": k}`. Without a configuration every point returns False and has
131
+ no other effect. Under hydo the schedule token `B=<seed>` (`hydo explore --buggify`) reaches the
132
+ file through the guest init: an onboarded project gets it with `buggify: true` in its
133
+ `hydo.yaml` (`hydo/onboard/MANIFEST.md`).
134
+
135
+ ## History recorder
136
+
137
+ The module `hydo_assert.history` records operation histories for hydo's host-side checkers
138
+ (linearizability of a register or compare-and-set object per key, and Elle-style list-append;
139
+ the format and the checkers are in `hydo/HISTORIES.md`). It writes the same `HYDO-HIST` lines as
140
+ the Go recorder (`sdk/go/hydohist`), byte for byte except the time field. It is a separate
141
+ module: `import hydo_assert` does not load it, so the assertion records are unchanged.
142
+
143
+ ```python
144
+ from hydo_assert import history as hh
145
+
146
+ kv = hh.History("kv")
147
+ op = kv.invoke(3, "write", "k1", hh.enc(v)) # process 3, before the request
148
+ try:
149
+ rev = put("k1", v)
150
+ op.ok("k1", hh.ver(v, rev)) # the version the store assigned, if any
151
+ except Rejected:
152
+ op.fail() # it definitely had no effect
153
+ except Exception:
154
+ op.info() # a timeout: the outcome is unknown
155
+ kv.invoke(3, "read", "k1").ok("k1", hh.value_or_nil(get("k1"))) # None means absent
156
+
157
+ la = hh.History("la")
158
+ tx = la.invoke(4, "txn", hh.append_mop("x", "1"), hh.read_mop("y"))
159
+ tx.ok(hh.append_mop("x", "1"), hh.read_result_mop("y", "1", "2"))
160
+ ```
161
+
162
+ | Go (`hydohist`) | Python (`hydo_assert.history`) |
163
+ |---|---|
164
+ | `New(name)` | `History(name)` |
165
+ | `h.Invoke(proc, f, args...)`, `h.Record(proc, typ, f, args...)` | `h.invoke(proc, f, *args)`, `h.record(proc, typ, f, *args)` |
166
+ | `op.Ok(args...)`, `op.Fail()`, `op.Info()` | `op.ok(*args)` (no args repeats the invocation's), `op.fail()`, `op.info()` |
167
+ | `Enc`, `Ver`, `ValOrNil(v, ok)`, `List` | `enc`, `ver`, `value_or_nil(v)` (None means absent), `list_value` |
168
+ | `Append`, `Read`, `ReadResult`, `Nil` | `append_mop`, `read_mop`, `read_result_mop`, `NIL` |
169
+
170
+ - `list_value` has a longer name than Go's `List` so that it does not hide the builtin `list`
171
+ for a program that uses `from hydo_assert.history import *`. The micro-op helpers have the
172
+ Rust SDK's names.
173
+ - A process is an int or a token without spaces. Arguments are tokens: a `str` is written as
174
+ it is (encode free-form values with `enc`), an `int` is written in decimal, and `bytes` are
175
+ written as they are. Versions are int64 values; a value outside that range raises
176
+ `ValueError`, and a value that is not an int (including `bool`) raises `TypeError`.
177
+ - Records go to `/dev/console`. `HYDO_HIST_OUT=<path>` appends to that file and creates it if
178
+ it is missing (mode 0644), `stderr` writes to standard error, and `off` writes nothing. If the
179
+ file cannot be opened, standard error is used. The setting is read once, at the first record,
180
+ and is separate from `HYDO_ASSERT_OUT`.
181
+ - Each record is one `os.write` call made under a lock, so threads never interleave lines. The
182
+ time field is `time.monotonic_ns()` (CLOCK_MONOTONIC on Linux, deterministic under hydo). It
183
+ is read under the lock, so the times of successive lines never decrease; Go reads it just
184
+ before its lock, and the line format is the same. A forked child gets a new lock and keeps the
185
+ descriptor.
186
+ - Text is written as UTF-8. A `str` is encoded with the `surrogateescape` rule, so a string
187
+ that came from bytes that way (`os.fsdecode`, `sys.argv`, `os.environ`, or `enc` of bytes)
188
+ gives back its original bytes. Any other lone surrogate is written as U+FFFD. `enc` of
189
+ `bytes` keeps invalid UTF-8 bytes as they are, as Go's `Enc` does, and returns them as
190
+ surrogateescape characters.
191
+ - A process has at most one operation in flight: give each concurrent client (thread or task)
192
+ its own process number. Record the invocation before sending the request and the completion
193
+ after the reply, because the checkers order operations by the position of their records.
194
+
195
+ ## Determinism
196
+
197
+ The records are a pure function of the program's evaluation sequence: no clocks (except the
198
+ guest's monotonic clock for `eventually`, which hvx makes deterministic), no host state,
199
+ catalog in declaration order, sorted keys. A Python program is itself deterministic under hydo:
200
+ the guest CRNG is seeded identically every run, so even the hash seed is the same; setting
201
+ `PYTHONHASHSEED=0` makes that independent of the CRNG. `PYTHONDONTWRITEBYTECODE=1` avoids
202
+ writing `.pyc` files on the guest's read-only root.
203
+
204
+ ## Tests
205
+
206
+ - `python3 -m unittest discover -s sdk/python/tests`: the encoder (Go's float and string
207
+ forms), deduplication, details not mutated, NaN, catalog order, 16 threads writing 2000
208
+ evaluations each without an interleaved line, asyncio `eventually`, fork, BUGGIFY decisions
209
+ and rates. Passes on Python 3.8.20 and 3.15.0.
210
+ - `tests/test_cases.py` (run by the same `unittest` command): the shared cases (`sdk/cases`,
211
+ written by the reference SDK, `sdk/rust`; format and rules in `sdk/rust/README.md`, "The
212
+ shared cases"). For every case and run it starts itself as a child process, makes the
213
+ case's calls with this package and `hydo_assert.history`, and compares the bytes the sink
214
+ received and the calls' results with the case's files: every assertion type, comparisons,
215
+ details with tricky values (invalid UTF-8 as `surrogateescape` characters), the catalog,
216
+ `eventually`, BUGGIFY under 18 configurations, `HYDO_ASSERT_OUT`, and the
217
+ history records with every helper and `HYDO_HIST_OUT` setting. Cases that need a float32
218
+ (`f32`) are skipped and listed. To add a case, add it to the Rust SDK's
219
+ `tests/cases/defs.rs` and run `HYDO_BLESS=1 cargo test --test cases` there; this test then
220
+ runs it. A format change starts in the Rust SDK too: after it is blessed, this test fails on
221
+ the lines this package must change. No other language is needed to run it. Passes on Python
222
+ 3.8.20 and 3.15.0.
223
+ - `tests/test_history.py` (run by the same `unittest` command): the exact lines of the Go
224
+ recorder's own unit test, the encoding of every byte class, the argument checks, the sink
225
+ settings, one `os.write` per record, 8 threads writing 400 records each without an
226
+ interleaved line, and fork.
227
+ - The files of `src/hydo_assert/` are copied into the python-kv example's images, so a change
228
+ to any of them changes those images' bytes and every python-kv fingerprint (the last such
229
+ change, a reworded comment in `_core.py`, re-recorded them in milestone 157;
230
+ docs/REBASELINE.md has the values).
231
+
232
+ ## Limits
233
+
234
+ - No call-site form that registers itself before it runs (the Rust SDK's macros do that with a
235
+ linker section). A Python assertion is in the catalog once the statement that declares it has
236
+ run, so declare at module level.
237
+ - `cmp` compares any two Python values that support the operator; values that cannot be
238
+ compared evaluate to false. Go's `Cmp` takes only `int64`.
@@ -0,0 +1,13 @@
1
+ hydo_assert/__init__.py,sha256=PIsdaxsu7pYz9O0IazFp3FXxYRXqD3hZgylfVTRcJso,2251
2
+ hydo_assert/_buggify.py,sha256=83PHDJ-A-9ekGroZYsrlN_Oh3GVr92nddNIwKSCXoh8,5215
3
+ hydo_assert/_core.py,sha256=G7gEXpJKKUE6GiyAI-F5my_41Tcenu876dqlC-vU1hA,5568
4
+ hydo_assert/_eventually.py,sha256=9vJnxwLZnmgFbj82ngszGoqoTqMzvjomOUS2P50nsZI,4244
5
+ hydo_assert/_json.py,sha256=bJtpC2QKPEd8wpql5S5eXhfk2fUCoINIV1tKfHbvCjQ,5520
6
+ hydo_assert/_sink.py,sha256=iEboDP_MtFoui1y4zdnCPcpugYBN98YAg4YU8aYgDQg,2536
7
+ hydo_assert/history.py,sha256=z7O_gxpivlpRy0nCYgsqFj76QEHUjYKEA7IA_dd0WY0,10368
8
+ hydo_assert/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ hydo_assert-0.2.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
10
+ hydo_assert-0.2.0.dist-info/METADATA,sha256=qnObnT7Ki0Nvb8BDqL5TwsgqmjGOZ_chnDVNh3ngRFs,15367
11
+ hydo_assert-0.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
12
+ hydo_assert-0.2.0.dist-info/top_level.txt,sha256=TvdT9tTbjxBQu9Qv2RRfQrJM8iKJy0eEtM-ptlVZbqY,12
13
+ hydo_assert-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+