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.
@@ -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
+ ]
@@ -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)))