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/__init__.py +65 -0
- hydo_assert/_buggify.py +156 -0
- hydo_assert/_core.py +168 -0
- hydo_assert/_eventually.py +120 -0
- hydo_assert/_json.py +163 -0
- hydo_assert/_sink.py +93 -0
- hydo_assert/history.py +300 -0
- hydo_assert/py.typed +0 -0
- hydo_assert-0.2.0.dist-info/METADATA +238 -0
- hydo_assert-0.2.0.dist-info/RECORD +13 -0
- hydo_assert-0.2.0.dist-info/WHEEL +5 -0
- hydo_assert-0.2.0.dist-info/licenses/LICENSE +202 -0
- hydo_assert-0.2.0.dist-info/top_level.txt +1 -0
hydo_assert/__init__.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""hydo-assert: the Python assertion SDK of hydo, the deterministic hypervisor.
|
|
2
|
+
|
|
3
|
+
A Python program running under hydo uses this package to say what must be true and what should
|
|
4
|
+
happen at least once. It writes the same console records as the Rust, Go and JavaScript SDKs,
|
|
5
|
+
byte for byte, with the same deduplication rules; the wire format and verdict rules are in
|
|
6
|
+
hydo/ASSERTIONS.md. Standard library only; Python 3.8+.
|
|
7
|
+
|
|
8
|
+
Declare properties at module level, so the whole catalog is known once the module is imported::
|
|
9
|
+
|
|
10
|
+
import hydo_assert as ha
|
|
11
|
+
|
|
12
|
+
ACKED = ha.always("kv.acked_durable", "every acknowledged write reads back with its value")
|
|
13
|
+
REPLAY = ha.sometimes("kv.recovered", "the server replayed a non-empty log")
|
|
14
|
+
TORN = ha.reachable("kv.torn_tail", "the log ended in a partial line")
|
|
15
|
+
BAD = ha.unreachable("kv.bad_command", "the server got a command it does not know")
|
|
16
|
+
SIZE = ha.always("kv.log_size", "the log stays under its cap")
|
|
17
|
+
BACK = ha.eventually("kv.back", "the server answers within 5 s", 5.0)
|
|
18
|
+
LOST = ha.buggify("kv.put.lost_reply", "the write lands but the reply is lost")
|
|
19
|
+
|
|
20
|
+
ha.catalog() # once, early: the HYDO-ASSERT-DECL lines
|
|
21
|
+
ACKED.check(got == want, {"key": k, "got": got})
|
|
22
|
+
SIZE.cmp(n, "<=", cap) # op: < <= == != >= >
|
|
23
|
+
TORN.hit()
|
|
24
|
+
if LOST.fire():
|
|
25
|
+
return
|
|
26
|
+
BACK.await_cond(ping, poll=0.1) # or: await BACK.await_async(aping, 0.1)
|
|
27
|
+
ha.setup_complete() # HYDO-SETUP-COMPLETE
|
|
28
|
+
|
|
29
|
+
Records go to /dev/ttyS0; ``HYDO_ASSERT_OUT=<path>|stderr|off|logging`` overrides that.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from ._core import (
|
|
33
|
+
Assertion,
|
|
34
|
+
always,
|
|
35
|
+
always_or_unreachable,
|
|
36
|
+
catalog,
|
|
37
|
+
reachable,
|
|
38
|
+
setup_complete,
|
|
39
|
+
sometimes,
|
|
40
|
+
unreachable,
|
|
41
|
+
)
|
|
42
|
+
from ._buggify import Point, buggify
|
|
43
|
+
from ._eventually import Liveness, Watch, eventually
|
|
44
|
+
|
|
45
|
+
#: The details type: any mapping (a plain dict). The Go SDK calls it D.
|
|
46
|
+
D = dict
|
|
47
|
+
|
|
48
|
+
__version__ = "0.2.0"
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"Assertion",
|
|
52
|
+
"D",
|
|
53
|
+
"Liveness",
|
|
54
|
+
"Point",
|
|
55
|
+
"Watch",
|
|
56
|
+
"always",
|
|
57
|
+
"always_or_unreachable",
|
|
58
|
+
"buggify",
|
|
59
|
+
"catalog",
|
|
60
|
+
"eventually",
|
|
61
|
+
"reachable",
|
|
62
|
+
"setup_complete",
|
|
63
|
+
"sometimes",
|
|
64
|
+
"unreachable",
|
|
65
|
+
]
|
hydo_assert/_buggify.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""BUGGIFY: code-level fault points (FoundationDB style), as in the Go SDK (Milestone 56).
|
|
2
|
+
|
|
3
|
+
A fault point is a named place where a real dependency can misbehave. The program asks it::
|
|
4
|
+
|
|
5
|
+
LOST_REPLY = hydo_assert.buggify("kv.put.lost_reply", "the write lands but the reply is lost")
|
|
6
|
+
...
|
|
7
|
+
if LOST_REPLY.fire():
|
|
8
|
+
return # do the work, drop the reply
|
|
9
|
+
|
|
10
|
+
Activation is two-level, as in FoundationDB (P_BUGGIFIED_SECTION_ACTIVATED = 0.25,
|
|
11
|
+
P_BUGGIFIED_SECTION_FIRES = 0.25): for a run each point is ENABLED with probability
|
|
12
|
+
enable/1000; an enabled point FIRES on its k-th evaluation with probability fire/1000. Both
|
|
13
|
+
decisions are pure functions of the seed, the point's id and k (splitmix64 and FNV-1a, the
|
|
14
|
+
same arithmetic as the Go SDK), so a run stays a pure function of its schedule.
|
|
15
|
+
|
|
16
|
+
Off unless configured: ``$HYDO_BUGGIFY``, or else the file ``$HYDO_BUGGIFY_FILE`` (default
|
|
17
|
+
``/tmp/hydo-buggify``), holds ``<seed-hex>[,<enable-permille>[,<fire-permille>]]``. Under hydo
|
|
18
|
+
the schedule token ``B=<...>`` is written to that file by the guest init. The configuration is
|
|
19
|
+
read once, at the first evaluation of any point, which also writes::
|
|
20
|
+
|
|
21
|
+
HYDO-BUGGIFY {"seed":"...","enable_pm":250,"fire_pm":250,"enabled":["id",...]}
|
|
22
|
+
|
|
23
|
+
Each point is declared as a sometimes property ``buggify.<id>`` ("fault point fired: <msg>"),
|
|
24
|
+
recorded at its first firing with ``{"hit": k}``.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
|
|
30
|
+
from . import _core, _json, _sink
|
|
31
|
+
|
|
32
|
+
_M = (1 << 64) - 1
|
|
33
|
+
_GOLDEN = 0x9E3779B97F4A7C15
|
|
34
|
+
|
|
35
|
+
_points = []
|
|
36
|
+
_loaded = False
|
|
37
|
+
_on = False
|
|
38
|
+
_seed = 0
|
|
39
|
+
_enable = 250
|
|
40
|
+
_fire = 250
|
|
41
|
+
|
|
42
|
+
# Go's unicode.IsSpace set (strings.TrimSpace), which differs from str.strip()'s.
|
|
43
|
+
_GO_SPACE = (
|
|
44
|
+
"\t\n\x0b\x0c\r \x85\xa0 "
|
|
45
|
+
"
"
|
|
46
|
+
)
|
|
47
|
+
_HEXRE = re.compile(r"[0-9A-Fa-f]+\Z")
|
|
48
|
+
_DECRE = re.compile(r"[0-9]+\Z")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _mix(z): # splitmix64 finalizer
|
|
52
|
+
z = (z + _GOLDEN) & _M
|
|
53
|
+
z = ((z ^ (z >> 30)) * 0xBF58476D1CE4E5B9) & _M
|
|
54
|
+
z = ((z ^ (z >> 27)) * 0x94D049BB133111EB) & _M
|
|
55
|
+
return z ^ (z >> 31)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _key(id): # FNV-1a over the id's UTF-8 bytes
|
|
59
|
+
h = 14695981039346656037
|
|
60
|
+
for b in bytearray(id.encode("utf-8", "surrogateescape")):
|
|
61
|
+
h ^= b
|
|
62
|
+
h = (h * 1099511628211) & _M
|
|
63
|
+
return h
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parse_u64(s, rx, base):
|
|
67
|
+
"""strconv.ParseUint(s, base, 64): no sign, no prefix, no spaces, <= 2^64-1."""
|
|
68
|
+
if not rx.match(s):
|
|
69
|
+
return None
|
|
70
|
+
v = int(s, base)
|
|
71
|
+
return v if v <= _M else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Point(object):
|
|
75
|
+
"""One declared fault point (create it with buggify())."""
|
|
76
|
+
|
|
77
|
+
__slots__ = ("id", "_assertion", "_hits")
|
|
78
|
+
|
|
79
|
+
def __init__(self, id, msg):
|
|
80
|
+
self.id = id
|
|
81
|
+
self._assertion = _core.declare(_core.SOMETIMES, "buggify." + id, "fault point fired: " + msg)
|
|
82
|
+
self._hits = 0
|
|
83
|
+
|
|
84
|
+
def __repr__(self):
|
|
85
|
+
return "Point(%r)" % (self.id,)
|
|
86
|
+
|
|
87
|
+
def _enabled(self):
|
|
88
|
+
return _mix(_seed ^ _key(self.id)) % 1000 < _enable
|
|
89
|
+
|
|
90
|
+
def fire(self, details=None):
|
|
91
|
+
"""Whether the point misbehaves on this evaluation. Always False when BUGGIFY is not
|
|
92
|
+
configured; then it has no other effect either."""
|
|
93
|
+
with _core._mu:
|
|
94
|
+
if not _loaded:
|
|
95
|
+
_load()
|
|
96
|
+
if not _on or not self._enabled():
|
|
97
|
+
return False
|
|
98
|
+
self._hits += 1
|
|
99
|
+
k = self._hits
|
|
100
|
+
fire = _mix((_mix(_seed ^ _key(self.id)) + k * _GOLDEN) & _M) % 1000 < _fire
|
|
101
|
+
if fire:
|
|
102
|
+
d = _core.details_dict(details)
|
|
103
|
+
d["hit"] = k
|
|
104
|
+
self._assertion._record(True, d)
|
|
105
|
+
return fire
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def buggify(id, msg):
|
|
109
|
+
"""Declare a fault point (it also joins the catalog as sometimes ``buggify.<id>``)."""
|
|
110
|
+
p = Point(id, msg)
|
|
111
|
+
with _core._mu:
|
|
112
|
+
_points.append(p)
|
|
113
|
+
return p
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _load():
|
|
117
|
+
"""Read "<seed-hex>[,<enable-permille>[,<fire-permille>]]" (called under _core._mu)."""
|
|
118
|
+
global _loaded, _on, _seed, _enable, _fire
|
|
119
|
+
_loaded = True
|
|
120
|
+
cfg = os.environ.get("HYDO_BUGGIFY", "")
|
|
121
|
+
if cfg == "":
|
|
122
|
+
f = os.environ.get("HYDO_BUGGIFY_FILE", "") or "/tmp/hydo-buggify"
|
|
123
|
+
try:
|
|
124
|
+
with open(f, "rb") as fh:
|
|
125
|
+
cfg = fh.read().decode("utf-8", "surrogateescape")
|
|
126
|
+
except (OSError, IOError):
|
|
127
|
+
pass
|
|
128
|
+
parts = cfg.strip(_GO_SPACE).split(",")
|
|
129
|
+
seed = _parse_u64(parts[0], _HEXRE, 16)
|
|
130
|
+
if seed is None:
|
|
131
|
+
return # no configuration: every point stays off
|
|
132
|
+
_on, _seed = True, seed
|
|
133
|
+
if len(parts) > 1:
|
|
134
|
+
v = _parse_u64(parts[1], _DECRE, 10)
|
|
135
|
+
if v is not None:
|
|
136
|
+
_enable = v
|
|
137
|
+
if len(parts) > 2:
|
|
138
|
+
v = _parse_u64(parts[2], _DECRE, 10)
|
|
139
|
+
if v is not None:
|
|
140
|
+
_fire = v
|
|
141
|
+
enabled = [p.id for p in _points if p._enabled()]
|
|
142
|
+
_sink.write_line(
|
|
143
|
+
'HYDO-BUGGIFY {"seed":' + _json.quote(format(_seed, "x"))
|
|
144
|
+
+ ',"enable_pm":' + str(_enable)
|
|
145
|
+
+ ',"fire_pm":' + str(_fire)
|
|
146
|
+
+ ',"enabled":' + _json.encode(enabled) + "}"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _reset():
|
|
151
|
+
"""Forget the loaded configuration and every point's evaluation count (tests)."""
|
|
152
|
+
global _loaded, _on, _seed, _enable, _fire
|
|
153
|
+
with _core._mu:
|
|
154
|
+
_loaded, _on, _seed, _enable, _fire = False, False, 0, 250, 250
|
|
155
|
+
for p in _points:
|
|
156
|
+
p._hits = 0
|
hydo_assert/_core.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Assertions, the catalog and the lifecycle line (the Go SDK's hydoassert.go)."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import threading
|
|
5
|
+
|
|
6
|
+
from . import _json, _sink
|
|
7
|
+
|
|
8
|
+
ALWAYS = "always"
|
|
9
|
+
ALWAYS_OR_UNREACHABLE = "always_or_unreachable"
|
|
10
|
+
SOMETIMES = "sometimes"
|
|
11
|
+
REACHABLE = "reachable"
|
|
12
|
+
UNREACHABLE = "unreachable"
|
|
13
|
+
EVENTUALLY = "eventually"
|
|
14
|
+
|
|
15
|
+
# One process-wide lock, as Go's `mu`: held while the catalog grows, while a record is
|
|
16
|
+
# deduplicated, formatted and written, and while the BUGGIFY configuration is loaded. It is
|
|
17
|
+
# never held across a blocking call or an `await`. Reentrant, so an assertion evaluated from a
|
|
18
|
+
# signal handler that interrupted a record cannot deadlock.
|
|
19
|
+
_mu = threading.RLock()
|
|
20
|
+
_catalog = []
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _after_fork():
|
|
24
|
+
global _mu
|
|
25
|
+
_mu = threading.RLock()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Assertion(object):
|
|
29
|
+
"""One declared property. Create it with always(), sometimes(), ... (it joins the catalog)."""
|
|
30
|
+
|
|
31
|
+
__slots__ = ("_type", "id", "msg", "_seen")
|
|
32
|
+
|
|
33
|
+
def __init__(self, t, id, msg):
|
|
34
|
+
self._type = t
|
|
35
|
+
self.id = id
|
|
36
|
+
self.msg = msg
|
|
37
|
+
self._seen = [False, False] # outcome already emitted by this process: [false, true]
|
|
38
|
+
|
|
39
|
+
def __repr__(self):
|
|
40
|
+
return "Assertion(%r, %r, %r)" % (self._type, self.id, self.msg)
|
|
41
|
+
|
|
42
|
+
def _record(self, ok, details):
|
|
43
|
+
with _mu:
|
|
44
|
+
i = 1 if ok else 0
|
|
45
|
+
if self._seen[i]:
|
|
46
|
+
return
|
|
47
|
+
self._seen[i] = True
|
|
48
|
+
_emit_eval(self._type, self.id, ok, self.msg, details)
|
|
49
|
+
|
|
50
|
+
def check(self, cond, details=None):
|
|
51
|
+
"""Evaluate an always / always_or_unreachable / sometimes property. For sometimes only a
|
|
52
|
+
true outcome is recorded. Returns bool(cond), so it can be used inline."""
|
|
53
|
+
ok = bool(cond)
|
|
54
|
+
if self._type == SOMETIMES and not ok:
|
|
55
|
+
return ok
|
|
56
|
+
self._record(ok, details)
|
|
57
|
+
return ok
|
|
58
|
+
|
|
59
|
+
def hit(self, details=None):
|
|
60
|
+
"""Mark a reachable location (recorded ok=true) or an unreachable one (recorded
|
|
61
|
+
ok=false: reaching it is a violation) as reached."""
|
|
62
|
+
self._record(self._type != UNREACHABLE, details)
|
|
63
|
+
|
|
64
|
+
def cmp(self, left, op, right, details=None):
|
|
65
|
+
"""Evaluate ``left <op> right`` (op one of < <= == != >= >) as check(), with left, op
|
|
66
|
+
and right added to the details. An unknown op evaluates to false."""
|
|
67
|
+
try:
|
|
68
|
+
if op == "<":
|
|
69
|
+
ok = left < right
|
|
70
|
+
elif op == "<=":
|
|
71
|
+
ok = left <= right
|
|
72
|
+
elif op == "==":
|
|
73
|
+
ok = left == right
|
|
74
|
+
elif op == "!=":
|
|
75
|
+
ok = left != right
|
|
76
|
+
elif op == ">=":
|
|
77
|
+
ok = left >= right
|
|
78
|
+
elif op == ">":
|
|
79
|
+
ok = left > right
|
|
80
|
+
else:
|
|
81
|
+
ok = False
|
|
82
|
+
except TypeError:
|
|
83
|
+
ok = False
|
|
84
|
+
d = details_dict(details)
|
|
85
|
+
d["left"], d["op"], d["right"] = left, op, right
|
|
86
|
+
return self.check(ok, d)
|
|
87
|
+
|
|
88
|
+
def details_dict(details):
|
|
89
|
+
"""A fresh dict of ``details`` (None -> {}; a mapping is copied, so the caller's object is
|
|
90
|
+
never changed; any other value -> {"value": it})."""
|
|
91
|
+
if details is None:
|
|
92
|
+
return {}
|
|
93
|
+
if isinstance(details, _json.Mapping):
|
|
94
|
+
return dict(details)
|
|
95
|
+
return {"value": details}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def declare(t, id, msg):
|
|
99
|
+
a = Assertion(t, id, msg)
|
|
100
|
+
with _mu:
|
|
101
|
+
_catalog.append(a)
|
|
102
|
+
return a
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def always(id, msg):
|
|
106
|
+
"""Declare a property that must hold whenever it is evaluated (and be evaluated at least once)."""
|
|
107
|
+
return declare(ALWAYS, id, msg)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def always_or_unreachable(id, msg):
|
|
111
|
+
"""Declare a property that must hold whenever evaluated; never being evaluated is fine."""
|
|
112
|
+
return declare(ALWAYS_OR_UNREACHABLE, id, msg)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def sometimes(id, msg):
|
|
116
|
+
"""Declare a property that must be true at least once across an exploration."""
|
|
117
|
+
return declare(SOMETIMES, id, msg)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def reachable(id, msg):
|
|
121
|
+
"""Declare a code location that must be reached at least once."""
|
|
122
|
+
return declare(REACHABLE, id, msg)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def unreachable(id, msg):
|
|
126
|
+
"""Declare a code location that must never be reached."""
|
|
127
|
+
return declare(UNREACHABLE, id, msg)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _emit_eval(t, id, ok, msg, details):
|
|
131
|
+
if details is not None and not isinstance(details, _json.Mapping):
|
|
132
|
+
details = {"value": details}
|
|
133
|
+
try:
|
|
134
|
+
line = (
|
|
135
|
+
'HYDO-ASSERT {"t":' + _json.quote(t)
|
|
136
|
+
+ ',"id":' + _json.quote(id)
|
|
137
|
+
+ ',"ok":' + ("true" if ok else "false")
|
|
138
|
+
+ ',"msg":' + _json.quote(msg)
|
|
139
|
+
+ ((',"details":' + _json.encode(details)) if details else "")
|
|
140
|
+
+ "}"
|
|
141
|
+
)
|
|
142
|
+
except _json.Unencodable:
|
|
143
|
+
return # as Go: json.Marshal fails, the record is not written
|
|
144
|
+
_sink.write_line(line)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def catalog():
|
|
148
|
+
"""Write one HYDO-ASSERT-DECL line per declared assertion, in declaration order, so
|
|
149
|
+
properties that are never evaluated can still be reported. Call it once, early, from the
|
|
150
|
+
process that should carry the catalog into the console."""
|
|
151
|
+
with _mu:
|
|
152
|
+
for a in _catalog:
|
|
153
|
+
_sink.write_line(
|
|
154
|
+
'HYDO-ASSERT-DECL {"t":' + _json.quote(a._type)
|
|
155
|
+
+ ',"id":' + _json.quote(a.id)
|
|
156
|
+
+ ',"msg":' + _json.quote(a.msg) + "}"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def setup_complete():
|
|
161
|
+
"""Write the lifecycle line HYDO-SETUP-COMPLETE: the system is provisioned and ready,
|
|
162
|
+
faults may begin, and this is a good snapshot/branch point."""
|
|
163
|
+
_sink.write_line("HYDO-SETUP-COMPLETE")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
if hasattr(os, "register_at_fork"):
|
|
167
|
+
os.register_at_fork(after_in_child=_after_fork)
|
|
168
|
+
os.register_at_fork(after_in_child=_sink._after_fork)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Bounded liveness: ``eventually`` properties (the Go SDK's eventually.go).
|
|
2
|
+
|
|
3
|
+
An eventually property says "after an anchor, the condition becomes true within a deadline".
|
|
4
|
+
hvx makes guest time deterministic, so a deadline measured on the guest's monotonic clock is
|
|
5
|
+
deterministic too: the same schedule gives the same verdict and the same elapsed time.
|
|
6
|
+
|
|
7
|
+
One evaluation record per episode, with ``elapsed_ms`` and ``within_ms`` (integers) in the
|
|
8
|
+
details: ok=true when the condition became true by the deadline, ok=false when the deadline
|
|
9
|
+
passed first (or it became true too late). Each outcome is emitted at most once per process.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import datetime
|
|
14
|
+
import inspect
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
from . import _core
|
|
19
|
+
|
|
20
|
+
_NS_PER_MS = 1000000
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _now_ns():
|
|
24
|
+
return time.monotonic_ns()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _to_ns(d):
|
|
28
|
+
"""Seconds (int/float) or a datetime.timedelta, as integer nanoseconds."""
|
|
29
|
+
if isinstance(d, datetime.timedelta):
|
|
30
|
+
return (d // datetime.timedelta(microseconds=1)) * 1000
|
|
31
|
+
return int(round(d * 1000000000))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Liveness(object):
|
|
35
|
+
"""A declared eventually property (create it with eventually())."""
|
|
36
|
+
|
|
37
|
+
__slots__ = ("_assertion", "within_ns")
|
|
38
|
+
|
|
39
|
+
def __init__(self, id, msg, within):
|
|
40
|
+
self._assertion = _core.declare(_core.EVENTUALLY, id, msg)
|
|
41
|
+
self.within_ns = _to_ns(within)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def id(self):
|
|
45
|
+
return self._assertion.id
|
|
46
|
+
|
|
47
|
+
def begin(self):
|
|
48
|
+
"""Start an episode now (the anchor)."""
|
|
49
|
+
return Watch(self)
|
|
50
|
+
|
|
51
|
+
def await_cond(self, cond, poll, details=None):
|
|
52
|
+
"""Start an episode now, call ``cond()`` every ``poll`` seconds until it is true or
|
|
53
|
+
the deadline has passed, record the outcome and return it. Blocks the thread. The last
|
|
54
|
+
check happens at or after the deadline, so a condition that becomes true exactly at
|
|
55
|
+
the deadline still counts."""
|
|
56
|
+
w = Watch(self)
|
|
57
|
+
poll_s = _to_ns(poll) / 1e9
|
|
58
|
+
while True:
|
|
59
|
+
if cond():
|
|
60
|
+
return w.done(details)
|
|
61
|
+
if _now_ns() - w.start_ns > self.within_ns:
|
|
62
|
+
w.expire(details)
|
|
63
|
+
return False
|
|
64
|
+
time.sleep(poll_s)
|
|
65
|
+
|
|
66
|
+
async def await_async(self, cond, poll, details=None):
|
|
67
|
+
"""As await_cond, for asyncio code: ``cond`` may be a plain function or return an
|
|
68
|
+
awaitable (an ``async def``), and the wait between checks is ``asyncio.sleep``."""
|
|
69
|
+
w = Watch(self)
|
|
70
|
+
poll_s = _to_ns(poll) / 1e9
|
|
71
|
+
while True:
|
|
72
|
+
r = cond()
|
|
73
|
+
if inspect.isawaitable(r):
|
|
74
|
+
r = await r
|
|
75
|
+
if r:
|
|
76
|
+
return w.done(details)
|
|
77
|
+
if _now_ns() - w.start_ns > self.within_ns:
|
|
78
|
+
w.expire(details)
|
|
79
|
+
return False
|
|
80
|
+
await asyncio.sleep(poll_s)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Watch(object):
|
|
84
|
+
"""One episode of a Liveness property, started at its anchor."""
|
|
85
|
+
|
|
86
|
+
__slots__ = ("liveness", "start_ns", "_done", "_lock")
|
|
87
|
+
|
|
88
|
+
def __init__(self, liveness):
|
|
89
|
+
self.liveness = liveness
|
|
90
|
+
self.start_ns = _now_ns()
|
|
91
|
+
self._done = False
|
|
92
|
+
self._lock = threading.Lock()
|
|
93
|
+
|
|
94
|
+
def _finish(self, ok, elapsed_ns, details):
|
|
95
|
+
with self._lock:
|
|
96
|
+
if self._done:
|
|
97
|
+
return ok
|
|
98
|
+
self._done = True
|
|
99
|
+
d = _core.details_dict(details)
|
|
100
|
+
d["elapsed_ms"] = elapsed_ns // _NS_PER_MS
|
|
101
|
+
d["within_ms"] = self.liveness.within_ns // _NS_PER_MS
|
|
102
|
+
self.liveness._assertion._record(ok, d)
|
|
103
|
+
return ok
|
|
104
|
+
|
|
105
|
+
def done(self, details=None):
|
|
106
|
+
"""The condition became true now: recorded ok if within the deadline, else a violation
|
|
107
|
+
(true, but too late). Returns whether it was in time. Only the first done/expire of an
|
|
108
|
+
episode is recorded."""
|
|
109
|
+
el = _now_ns() - self.start_ns
|
|
110
|
+
return self._finish(el <= self.liveness.within_ns, el, details)
|
|
111
|
+
|
|
112
|
+
def expire(self, details=None):
|
|
113
|
+
"""The deadline passed with the condition still false (a violation)."""
|
|
114
|
+
self._finish(False, _now_ns() - self.start_ns, details)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def eventually(id, msg, within):
|
|
118
|
+
"""Declare a bounded-liveness property: after an anchor, the condition holds within
|
|
119
|
+
``within`` (seconds, or a datetime.timedelta)."""
|
|
120
|
+
return Liveness(id, msg, within)
|
hydo_assert/_json.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""A JSON encoder that writes exactly what Go's encoding/json writes for the same data.
|
|
2
|
+
|
|
3
|
+
The Go SDK (sdk/go/hydoassert) encodes its records with json.Marshal. To be byte-identical:
|
|
4
|
+
|
|
5
|
+
- object keys are sorted by their UTF-8 bytes (Go sorts map keys that way);
|
|
6
|
+
- strings are escaped as Go escapes them: ``"`` ``\\`` ``\\b`` ``\\f`` ``\\n`` ``\\r`` ``\\t``,
|
|
7
|
+
other control characters as ``\\u00xx``, the HTML characters ``<`` ``>`` ``&`` as
|
|
8
|
+
``\\u003c`` ``\\u003e`` ``\\u0026``, U+2028 and U+2029 as ``\\u2028`` ``\\u2029``; everything
|
|
9
|
+
else (all other non-ASCII text) is written as UTF-8. A lone surrogate (Python's stand-in for
|
|
10
|
+
an undecodable byte, e.g. from ``surrogateescape``) is written as ``\\ufffd``, as Go up to
|
|
11
|
+
1.26 writes each invalid UTF-8 byte (Go 1.27's encoder writes a raw U+FFFD instead; the
|
|
12
|
+
guest images are built with the pinned Go 1.26.5);
|
|
13
|
+
- floats use Go's format: the shortest digits that round-trip, plain notation when
|
|
14
|
+
1e-6 <= |f| < 1e21 (or f == 0), else exponent notation ``1e+21`` / ``1.5e-7``;
|
|
15
|
+
- a NaN or an infinity cannot be encoded (Go's Marshal fails): Unencodable is raised and the
|
|
16
|
+
caller drops the whole record.
|
|
17
|
+
|
|
18
|
+
Python-only value types (Go has no equivalent, so no Go program can produce them): a tuple is
|
|
19
|
+
an array; ``bytes``/``bytearray`` are base64 strings (as Go encodes ``[]byte``); a set or
|
|
20
|
+
frozenset is an array sorted by the encoded elements (so the output does not depend on hash
|
|
21
|
+
order); any other object is encoded as the string ``str(obj)``. Mapping keys that are ints are
|
|
22
|
+
written in decimal (as Go writes integer map keys); other non-string keys as ``str(key)``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import base64
|
|
26
|
+
import decimal
|
|
27
|
+
import math
|
|
28
|
+
|
|
29
|
+
try: # Python 3.3+
|
|
30
|
+
from collections.abc import Mapping
|
|
31
|
+
except ImportError: # pragma: no cover
|
|
32
|
+
from collections import Mapping
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Unencodable(ValueError):
|
|
36
|
+
"""A value Go's json.Marshal would refuse (NaN, +Inf, -Inf)."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_HEX = "0123456789abcdef"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _build_escape_table():
|
|
43
|
+
t = {}
|
|
44
|
+
for c in range(0x20):
|
|
45
|
+
t[c] = "\\u00" + _HEX[c >> 4] + _HEX[c & 0xF]
|
|
46
|
+
t[0x08] = "\\b"
|
|
47
|
+
t[0x0C] = "\\f"
|
|
48
|
+
t[0x0A] = "\\n"
|
|
49
|
+
t[0x0D] = "\\r"
|
|
50
|
+
t[0x09] = "\\t"
|
|
51
|
+
t[0x22] = '\\"'
|
|
52
|
+
t[0x5C] = "\\\\"
|
|
53
|
+
t[0x3C] = "\\u003c"
|
|
54
|
+
t[0x3E] = "\\u003e"
|
|
55
|
+
t[0x26] = "\\u0026"
|
|
56
|
+
t[0x2028] = "\\u2028"
|
|
57
|
+
t[0x2029] = "\\u2029"
|
|
58
|
+
for c in range(0xD800, 0xE000):
|
|
59
|
+
t[c] = "\\ufffd"
|
|
60
|
+
return t
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_ESCAPES = _build_escape_table()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def quote(s):
|
|
67
|
+
"""The JSON string literal of ``s``, as Go writes it (with HTML escaping)."""
|
|
68
|
+
return '"' + s.translate(_ESCAPES) + '"'
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _format_float(f):
|
|
72
|
+
if math.isnan(f) or math.isinf(f):
|
|
73
|
+
raise Unencodable("NaN or infinity")
|
|
74
|
+
if f == 0.0:
|
|
75
|
+
return "-0" if math.copysign(1.0, f) < 0 else "0"
|
|
76
|
+
sign, digits, exp = decimal.Decimal(repr(f)).as_tuple()
|
|
77
|
+
digits = list(digits)
|
|
78
|
+
while len(digits) > 1 and digits[-1] == 0: # repr may carry a trailing ".0"
|
|
79
|
+
digits.pop()
|
|
80
|
+
exp += 1
|
|
81
|
+
ds = "".join(str(d) for d in digits)
|
|
82
|
+
n = len(ds)
|
|
83
|
+
e10 = exp + n - 1 # the value is d.ddd x 10^e10
|
|
84
|
+
neg = "-" if sign else ""
|
|
85
|
+
a = abs(f)
|
|
86
|
+
if a < 1e-6 or a >= 1e21:
|
|
87
|
+
mant = ds[0] + ("." + ds[1:] if n > 1 else "")
|
|
88
|
+
if e10 < 0:
|
|
89
|
+
return neg + mant + "e-" + str(-e10) # Go cleans e-07 to e-7
|
|
90
|
+
return neg + mant + "e+" + ("%02d" % e10)
|
|
91
|
+
if exp >= 0:
|
|
92
|
+
return neg + ds + "0" * exp
|
|
93
|
+
if e10 >= 0:
|
|
94
|
+
return neg + ds[: e10 + 1] + "." + ds[e10 + 1:]
|
|
95
|
+
return neg + "0." + "0" * (-e10 - 1) + ds
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _key_str(k):
|
|
99
|
+
if isinstance(k, str):
|
|
100
|
+
return k
|
|
101
|
+
if isinstance(k, bool):
|
|
102
|
+
return "true" if k else "false"
|
|
103
|
+
if isinstance(k, int):
|
|
104
|
+
return str(int(k))
|
|
105
|
+
return str(k)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _utf8(s):
|
|
109
|
+
"""The bytes Go would compare: a surrogate from surrogateescape is its original byte."""
|
|
110
|
+
try:
|
|
111
|
+
return s.encode("utf-8", "surrogateescape")
|
|
112
|
+
except UnicodeEncodeError:
|
|
113
|
+
return s.encode("utf-8", "surrogatepass")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def encode(v):
|
|
117
|
+
"""The JSON text of ``v``, as Go's encoding/json writes the equivalent Go value."""
|
|
118
|
+
out = []
|
|
119
|
+
_enc(v, out)
|
|
120
|
+
return "".join(out)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _enc(v, out):
|
|
124
|
+
if v is None:
|
|
125
|
+
out.append("null")
|
|
126
|
+
elif v is True:
|
|
127
|
+
out.append("true")
|
|
128
|
+
elif v is False:
|
|
129
|
+
out.append("false")
|
|
130
|
+
elif isinstance(v, str):
|
|
131
|
+
out.append(quote(v))
|
|
132
|
+
elif isinstance(v, bool): # a bool subclass (numpy-like); after the singletons
|
|
133
|
+
out.append("true" if v else "false")
|
|
134
|
+
elif isinstance(v, int):
|
|
135
|
+
out.append(str(int(v)))
|
|
136
|
+
elif isinstance(v, float):
|
|
137
|
+
out.append(_format_float(float(v)))
|
|
138
|
+
elif isinstance(v, Mapping):
|
|
139
|
+
items = sorted(((_key_str(k), x) for k, x in v.items()), key=lambda kv: _utf8(kv[0]))
|
|
140
|
+
out.append("{")
|
|
141
|
+
first = True
|
|
142
|
+
for k, x in items:
|
|
143
|
+
if not first:
|
|
144
|
+
out.append(",")
|
|
145
|
+
first = False
|
|
146
|
+
out.append(quote(k))
|
|
147
|
+
out.append(":")
|
|
148
|
+
_enc(x, out)
|
|
149
|
+
out.append("}")
|
|
150
|
+
elif isinstance(v, (list, tuple)):
|
|
151
|
+
out.append("[")
|
|
152
|
+
for i, x in enumerate(v):
|
|
153
|
+
if i:
|
|
154
|
+
out.append(",")
|
|
155
|
+
_enc(x, out)
|
|
156
|
+
out.append("]")
|
|
157
|
+
elif isinstance(v, (bytes, bytearray, memoryview)):
|
|
158
|
+
out.append('"' + base64.b64encode(bytes(v)).decode("ascii") + '"')
|
|
159
|
+
elif isinstance(v, (set, frozenset)):
|
|
160
|
+
elems = sorted((encode(x) for x in v), key=_utf8)
|
|
161
|
+
out.append("[" + ",".join(elems) + "]")
|
|
162
|
+
else:
|
|
163
|
+
out.append(quote(str(v)))
|