twosystems 0.1.0a1__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.
- twosystems/__init__.py +13 -0
- twosystems/cli.py +98 -0
- twosystems/contracts.py +528 -0
- twosystems/py.typed +0 -0
- twosystems/schemas/decisions-lock.schema.json +223 -0
- twosystems/schemas/events.schema.json +357 -0
- twosystems-0.1.0a1.dist-info/METADATA +55 -0
- twosystems-0.1.0a1.dist-info/RECORD +12 -0
- twosystems-0.1.0a1.dist-info/WHEEL +4 -0
- twosystems-0.1.0a1.dist-info/entry_points.txt +2 -0
- twosystems-0.1.0a1.dist-info/licenses/LICENSE +202 -0
- twosystems-0.1.0a1.dist-info/licenses/NOTICE +5 -0
twosystems/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""twosystems: calibration and checking tools for a cheap, honest watchman for AI coding agents.
|
|
2
|
+
|
|
3
|
+
Created by Shashikant. https://github.com/shashikantdev3/twosystems
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
__version__ = version("twosystems")
|
|
10
|
+
except PackageNotFoundError: # running from a source tree without installing
|
|
11
|
+
__version__ = "0.0.0+unknown"
|
|
12
|
+
|
|
13
|
+
__author__ = "Shashikant"
|
twosystems/cli.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""twosystems-calibrate: command line for the twosystems calibration tools.
|
|
2
|
+
|
|
3
|
+
Exit codes: 0 valid, 1 invalid, 2 usage or file problem.
|
|
4
|
+
Created by Shashikant. https://github.com/shashikantdev3/twosystems
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .contracts import MAX_LOCK_BYTES, iter_events_jsonl, validate_lock_text
|
|
15
|
+
|
|
16
|
+
#: Largest events log this command reads at once.
|
|
17
|
+
MAX_EVENTS_FILE_BYTES = 64 * 1024 * 1024
|
|
18
|
+
#: The command prints at most this many problems, and stops reading an events log there.
|
|
19
|
+
MAX_PROBLEMS = 100
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main(argv: list[str] | None = None) -> int:
|
|
23
|
+
parser = argparse.ArgumentParser(
|
|
24
|
+
prog="twosystems-calibrate",
|
|
25
|
+
description="Calibration and checking tools for twosystems, created by Shashikant.",
|
|
26
|
+
)
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--version", action="version", version=f"twosystems-calibrate {__version__}"
|
|
29
|
+
)
|
|
30
|
+
commands = parser.add_subparsers(dest="command")
|
|
31
|
+
validate = commands.add_parser(
|
|
32
|
+
"validate", help="check an events log or a decisions.lock file against the shared schemas"
|
|
33
|
+
)
|
|
34
|
+
validate.add_argument("kind", choices=["events", "lock"])
|
|
35
|
+
validate.add_argument("path", type=Path)
|
|
36
|
+
|
|
37
|
+
args = parser.parse_args(argv)
|
|
38
|
+
if args.command is None:
|
|
39
|
+
parser.print_help(sys.stderr)
|
|
40
|
+
return 2
|
|
41
|
+
|
|
42
|
+
# A lock may carry a 3-byte byte order mark on top of its size limit.
|
|
43
|
+
limit = MAX_EVENTS_FILE_BYTES if args.kind == "events" else MAX_LOCK_BYTES + 3
|
|
44
|
+
try:
|
|
45
|
+
with args.path.open("rb") as file:
|
|
46
|
+
data = file.read(limit + 1)
|
|
47
|
+
except OSError as error:
|
|
48
|
+
print(f"error: cannot read {args.path}: {error.strerror}", file=sys.stderr)
|
|
49
|
+
return 2
|
|
50
|
+
except MemoryError:
|
|
51
|
+
print(f"error: not enough memory to read {args.path}", file=sys.stderr)
|
|
52
|
+
return 2
|
|
53
|
+
if len(data) > limit:
|
|
54
|
+
if args.kind == "lock":
|
|
55
|
+
print(f"longer than {MAX_LOCK_BYTES} bytes", file=sys.stderr)
|
|
56
|
+
print("FAIL: 1 problem(s)", file=sys.stderr)
|
|
57
|
+
return 1
|
|
58
|
+
print(f"error: {args.path} is larger than 64 MiB; split it first", file=sys.stderr)
|
|
59
|
+
return 2
|
|
60
|
+
try:
|
|
61
|
+
text = data.decode("utf-8")
|
|
62
|
+
except UnicodeDecodeError:
|
|
63
|
+
print(f"error: {args.path} is not UTF-8 text", file=sys.stderr)
|
|
64
|
+
return 2
|
|
65
|
+
|
|
66
|
+
if args.kind == "events":
|
|
67
|
+
return _report_events(text)
|
|
68
|
+
result = validate_lock_text(text)
|
|
69
|
+
if result.ok:
|
|
70
|
+
print("OK: decisions.lock valid")
|
|
71
|
+
return 0
|
|
72
|
+
for error in result.errors[:MAX_PROBLEMS]:
|
|
73
|
+
print(error, file=sys.stderr)
|
|
74
|
+
shown = "" if len(result.errors) <= MAX_PROBLEMS else f", first {MAX_PROBLEMS} shown"
|
|
75
|
+
print(f"FAIL: {len(result.errors)} problem(s){shown}", file=sys.stderr)
|
|
76
|
+
return 1
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _report_events(text: str) -> int:
|
|
80
|
+
"""Check an events log line by line and stop after MAX_PROBLEMS problems."""
|
|
81
|
+
count = problems = 0
|
|
82
|
+
for number, errors in iter_events_jsonl(text):
|
|
83
|
+
count += 1
|
|
84
|
+
for error in errors:
|
|
85
|
+
if problems == MAX_PROBLEMS:
|
|
86
|
+
print(f"FAIL: stopped after {MAX_PROBLEMS} problems", file=sys.stderr)
|
|
87
|
+
return 1
|
|
88
|
+
print(f"line {number}: {error}", file=sys.stderr)
|
|
89
|
+
problems += 1
|
|
90
|
+
if problems:
|
|
91
|
+
print(f"FAIL: {problems} problem(s)", file=sys.stderr)
|
|
92
|
+
return 1
|
|
93
|
+
print(f"OK: {count} events valid")
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
sys.exit(main())
|
twosystems/contracts.py
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
"""Validators for the two shared files: the event log and decisions.lock.
|
|
2
|
+
|
|
3
|
+
The JSON Schemas in /schemas are the source of truth. This module applies
|
|
4
|
+
exactly the same rules as the TypeScript core, and both run the shared edge
|
|
5
|
+
cases in /schemas/examples, so any difference between the two fails CI.
|
|
6
|
+
Part of twosystems, created by Shashikant.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import calendar
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import Iterator
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from functools import cache
|
|
18
|
+
from importlib import resources
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from jsonschema import Draft202012Validator, FormatChecker, ValidationError, validators
|
|
22
|
+
|
|
23
|
+
#: Largest event record accepted, in UTF-8 bytes. Real records are under 2 KB.
|
|
24
|
+
MAX_EVENT_BYTES = 16_384
|
|
25
|
+
#: Largest decisions.lock accepted, in UTF-8 bytes.
|
|
26
|
+
MAX_LOCK_BYTES = 1_048_576
|
|
27
|
+
|
|
28
|
+
# RFC 3339 date-time with a mandatory time zone, checked by hand so that both
|
|
29
|
+
# languages apply the same rule: no leap seconds, no space separator, offsets
|
|
30
|
+
# as +HH:MM only.
|
|
31
|
+
_DATE_TIME = re.compile(
|
|
32
|
+
r"(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:[Zz]|[+-](\d{2}):(\d{2}))",
|
|
33
|
+
re.ASCII,
|
|
34
|
+
)
|
|
35
|
+
_UUID = re.compile(
|
|
36
|
+
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", re.ASCII
|
|
37
|
+
)
|
|
38
|
+
_BLANK = re.compile(r"[ \t]*")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_date_time(value: str) -> bool:
|
|
42
|
+
match = _DATE_TIME.fullmatch(value)
|
|
43
|
+
if match is None:
|
|
44
|
+
return False
|
|
45
|
+
year, month, day, hour, minute, second = (int(part) for part in match.groups()[:6])
|
|
46
|
+
offset_hour, offset_minute = (int(part or "0") for part in match.groups()[6:])
|
|
47
|
+
return (
|
|
48
|
+
year >= 1
|
|
49
|
+
and 1 <= month <= 12
|
|
50
|
+
and 1 <= day <= calendar.monthrange(year, month)[1]
|
|
51
|
+
and hour <= 23
|
|
52
|
+
and minute <= 59
|
|
53
|
+
and second <= 59
|
|
54
|
+
and offset_hour <= 23
|
|
55
|
+
and offset_minute <= 59
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_uuid(value: str) -> bool:
|
|
60
|
+
return _UUID.fullmatch(value) is not None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_formats = FormatChecker(formats=())
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@_formats.checks("date-time")
|
|
67
|
+
def _check_date_time(value: object) -> bool:
|
|
68
|
+
return not isinstance(value, str) or is_date_time(value)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@_formats.checks("uuid")
|
|
72
|
+
def _check_uuid(value: object) -> bool:
|
|
73
|
+
return not isinstance(value, str) or is_uuid(value)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _pattern(
|
|
77
|
+
validator: Any, pattern: str, instance: Any, schema: dict[str, Any]
|
|
78
|
+
) -> Iterator[ValidationError]:
|
|
79
|
+
"""Match "pattern" like ECMAScript.
|
|
80
|
+
|
|
81
|
+
A final "$" does not accept a trailing newline, and \\d and \\w match ASCII only.
|
|
82
|
+
"""
|
|
83
|
+
if not validator.is_type(instance, "string"):
|
|
84
|
+
return
|
|
85
|
+
backslashes = len(pattern[:-1]) - len(pattern[:-1].rstrip("\\"))
|
|
86
|
+
anchored = pattern.endswith("$") and backslashes % 2 == 0
|
|
87
|
+
regex = pattern[:-1] + r"\Z" if anchored else pattern
|
|
88
|
+
if re.search(regex, instance, re.ASCII) is None:
|
|
89
|
+
yield ValidationError("does not have the expected format")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _canonical(value: Any) -> Any:
|
|
93
|
+
"""A hashable stand-in for a JSON value: equal values give equal results.
|
|
94
|
+
|
|
95
|
+
1 and 1.0 are equal, true and 1 are not, and objects ignore key order, as in
|
|
96
|
+
JSON Schema. The TypeScript core builds the same kind of key. Nesting is already
|
|
97
|
+
limited, so the recursion is shallow.
|
|
98
|
+
"""
|
|
99
|
+
if isinstance(value, bool) or value is None or isinstance(value, str):
|
|
100
|
+
return (type(value).__name__, value)
|
|
101
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
102
|
+
return ("number", repr(value))
|
|
103
|
+
if isinstance(value, float) and value.is_integer():
|
|
104
|
+
return ("number", int(value))
|
|
105
|
+
if isinstance(value, (int, float)):
|
|
106
|
+
return ("number", value)
|
|
107
|
+
if isinstance(value, list):
|
|
108
|
+
return ("array", tuple(_canonical(item) for item in value))
|
|
109
|
+
if isinstance(value, dict):
|
|
110
|
+
return ("object", frozenset((key, _canonical(item)) for key, item in value.items()))
|
|
111
|
+
return ("other", id(value))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _unique_items(
|
|
115
|
+
validator: Any, unique: bool, instance: Any, schema: dict[str, Any]
|
|
116
|
+
) -> Iterator[ValidationError]:
|
|
117
|
+
"""uniqueItems in linear time. jsonschema compares every pair when items cannot be sorted."""
|
|
118
|
+
if not unique or not validator.is_type(instance, "array"):
|
|
119
|
+
return
|
|
120
|
+
seen: set[Any] = set()
|
|
121
|
+
for item in instance:
|
|
122
|
+
key = _canonical(item)
|
|
123
|
+
if key in seen:
|
|
124
|
+
yield ValidationError("must not contain duplicate items")
|
|
125
|
+
return
|
|
126
|
+
seen.add(key)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _is_number(checker: Any, instance: Any) -> bool:
|
|
130
|
+
"""A JSON number: finite, and small enough to fit in a 64-bit float, as in JavaScript."""
|
|
131
|
+
if isinstance(instance, bool) or not isinstance(instance, (int, float)):
|
|
132
|
+
return False
|
|
133
|
+
if isinstance(instance, int):
|
|
134
|
+
try:
|
|
135
|
+
float(instance)
|
|
136
|
+
except OverflowError:
|
|
137
|
+
return False
|
|
138
|
+
return True
|
|
139
|
+
return math.isfinite(instance)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _is_integer(checker: Any, instance: Any) -> bool:
|
|
143
|
+
if not _is_number(checker, instance):
|
|
144
|
+
return False
|
|
145
|
+
return isinstance(instance, int) or instance.is_integer()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
_types = Draft202012Validator.TYPE_CHECKER.redefine_many(
|
|
149
|
+
{"number": _is_number, "integer": _is_integer}
|
|
150
|
+
)
|
|
151
|
+
_Validator = validators.extend(
|
|
152
|
+
Draft202012Validator,
|
|
153
|
+
{"pattern": _pattern, "uniqueItems": _unique_items},
|
|
154
|
+
type_checker=_types,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass
|
|
159
|
+
class ValidationResult:
|
|
160
|
+
ok: bool
|
|
161
|
+
errors: list[str] = field(default_factory=list)
|
|
162
|
+
count: int = 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@cache
|
|
166
|
+
def _validator(name: str) -> Any:
|
|
167
|
+
text = resources.files("twosystems").joinpath("schemas", name).read_text(encoding="utf-8")
|
|
168
|
+
return _Validator(json.loads(text), format_checker=_formats)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
#: Deepest nesting of arrays and objects accepted. Real records use at most 6 levels.
|
|
172
|
+
MAX_DEPTH = 32
|
|
173
|
+
#: Most values (objects, arrays, strings, numbers, booleans and nulls) in one event.
|
|
174
|
+
#: Real events hold about 50.
|
|
175
|
+
MAX_EVENT_VALUES = 4_096
|
|
176
|
+
#: Most values in one decisions.lock. The largest real lock holds about 33,000.
|
|
177
|
+
MAX_LOCK_VALUES = 65_536
|
|
178
|
+
|
|
179
|
+
_SAFE_FIELD = re.compile(r"[A-Za-z0-9_.-]{1,32}", re.ASCII)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _field_name(name: str) -> str:
|
|
183
|
+
return f"'{name}'" if _SAFE_FIELD.fullmatch(name) else "(name not shown)"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _path(error: ValidationError) -> str:
|
|
187
|
+
"""JSON pointer to the bad value. Field names that are not plain short names show as *."""
|
|
188
|
+
if not error.absolute_path:
|
|
189
|
+
return "<root>"
|
|
190
|
+
parts = []
|
|
191
|
+
for part in error.absolute_path:
|
|
192
|
+
text = str(part)
|
|
193
|
+
parts.append(text if isinstance(part, int) or _SAFE_FIELD.fullmatch(text) else "*")
|
|
194
|
+
return "/" + "/".join(parts)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _js_number(value: Any) -> str:
|
|
198
|
+
"""Write a schema number the way JavaScript's JSON.stringify does."""
|
|
199
|
+
if isinstance(value, float) and value.is_integer():
|
|
200
|
+
value = int(value)
|
|
201
|
+
return json.dumps(value)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _count(limit: int, noun: str) -> str:
|
|
205
|
+
return f"{limit} {noun}{'' if limit == 1 else 's'}"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _describe(error: ValidationError) -> list[str]:
|
|
209
|
+
"""Fixed sentences built from the schema, never from the rejected value.
|
|
210
|
+
|
|
211
|
+
The TypeScript core builds exactly the same sentences, so blocked text cannot
|
|
212
|
+
leak into logs and both languages report the same list.
|
|
213
|
+
"""
|
|
214
|
+
path = _path(error)
|
|
215
|
+
keyword = error.validator
|
|
216
|
+
limit = error.validator_value
|
|
217
|
+
if "propertyNames" in error.absolute_schema_path:
|
|
218
|
+
return [f"{path} has an invalid field name"]
|
|
219
|
+
if keyword == "not":
|
|
220
|
+
return [f"{path} is not allowed for this event type"]
|
|
221
|
+
if keyword == "additionalProperties":
|
|
222
|
+
known = error.schema.get("properties", {})
|
|
223
|
+
extra = [key for key in error.instance if key not in known]
|
|
224
|
+
return [f"{path} has an unexpected field {_field_name(key)}" for key in extra]
|
|
225
|
+
if keyword == "required":
|
|
226
|
+
missing = [key for key in limit if key not in error.instance]
|
|
227
|
+
return [f"{path} is missing the required field '{key}'" for key in missing]
|
|
228
|
+
messages = {
|
|
229
|
+
"type": lambda: f"must be {' or '.join(limit) if isinstance(limit, list) else limit}",
|
|
230
|
+
"enum": lambda: "must be one of the allowed values",
|
|
231
|
+
"const": lambda: f"must equal {_js_number(limit)}",
|
|
232
|
+
"pattern": lambda: "does not have the expected format",
|
|
233
|
+
"format": lambda: f"must be a valid {limit}",
|
|
234
|
+
"minimum": lambda: f"must be >= {_js_number(limit)}",
|
|
235
|
+
"maximum": lambda: f"must be <= {_js_number(limit)}",
|
|
236
|
+
"exclusiveMinimum": lambda: f"must be > {_js_number(limit)}",
|
|
237
|
+
"exclusiveMaximum": lambda: f"must be < {_js_number(limit)}",
|
|
238
|
+
"minLength": lambda: f"must have at least {_count(limit, 'character')}",
|
|
239
|
+
"maxLength": lambda: f"must have at most {_count(limit, 'character')}",
|
|
240
|
+
"minItems": lambda: f"must have at least {_count(limit, 'item')}",
|
|
241
|
+
"maxItems": lambda: f"must have at most {_count(limit, 'item')}",
|
|
242
|
+
"uniqueItems": lambda: "must not contain duplicate items",
|
|
243
|
+
"minProperties": lambda: f"must have at least {_count(limit, 'field')}",
|
|
244
|
+
"maxProperties": lambda: f"must have at most {_count(limit, 'field')}",
|
|
245
|
+
"anyOf": lambda: "does not match any allowed shape",
|
|
246
|
+
}
|
|
247
|
+
message = messages.get(keyword)
|
|
248
|
+
return [f"{path} {message() if message else f'is invalid ({keyword})'}"]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _tidy(messages: list[str]) -> list[str]:
|
|
252
|
+
"""Sorted, without duplicates, so both languages produce the same list."""
|
|
253
|
+
return sorted(set(messages))
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _text_bytes(text: str, limit: int) -> int:
|
|
257
|
+
"""UTF-8 length of a string, or anything above the limit once it is clearly longer.
|
|
258
|
+
|
|
259
|
+
A UTF-8 string is never shorter in bytes than in characters.
|
|
260
|
+
"""
|
|
261
|
+
if len(text) > limit:
|
|
262
|
+
return limit + 1
|
|
263
|
+
return len(text.encode("utf-8", "surrogatepass"))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _measure(
|
|
267
|
+
value: Any, max_values: int, max_bytes: int, seen: dict[int, tuple[int, int]]
|
|
268
|
+
) -> tuple[int, int]:
|
|
269
|
+
"""Measure a value as if shared objects were copied out.
|
|
270
|
+
|
|
271
|
+
Returns how many values it holds and how many UTF-8 bytes its strings and field
|
|
272
|
+
names take. A value built in code then costs what the same JSON text would; text
|
|
273
|
+
that passed the size limit always passes here. Stops measuring past either limit.
|
|
274
|
+
Runs after the depth check, so the recursion is shallow and there are no cycles.
|
|
275
|
+
"""
|
|
276
|
+
if isinstance(value, str):
|
|
277
|
+
return 1, _text_bytes(value, max_bytes)
|
|
278
|
+
if not isinstance(value, (dict, list)):
|
|
279
|
+
return 1, 0
|
|
280
|
+
known = seen.get(id(value))
|
|
281
|
+
if known is not None:
|
|
282
|
+
return known
|
|
283
|
+
values, size = 1, 0
|
|
284
|
+
pairs = value.items() if isinstance(value, dict) else (("", item) for item in value)
|
|
285
|
+
for key, child in pairs:
|
|
286
|
+
size += _text_bytes(key, max_bytes)
|
|
287
|
+
child_values, child_bytes = _measure(child, max_values, max_bytes, seen)
|
|
288
|
+
values += child_values
|
|
289
|
+
size += child_bytes
|
|
290
|
+
if values > max_values or size > max_bytes:
|
|
291
|
+
break
|
|
292
|
+
seen[id(value)] = (values, size)
|
|
293
|
+
return values, size
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _shape_problem(value: Any, max_values: int, max_bytes: int) -> str | None:
|
|
297
|
+
"""Checks run before the schema, in the same order as the TypeScript core.
|
|
298
|
+
|
|
299
|
+
First nesting deeper than MAX_DEPTH (a cycle counts as endless) or a key that is
|
|
300
|
+
not a string, whichever the walk meets first; then more than max_values values;
|
|
301
|
+
then more than max_bytes of text in strings and field names. Values in code can
|
|
302
|
+
share objects: each object is walked again only when it is reached at a deeper
|
|
303
|
+
level than before, so the cost stays linear.
|
|
304
|
+
"""
|
|
305
|
+
deepest: dict[int, int] = {}
|
|
306
|
+
stack: list[tuple[Any, int]] = [(value, 1)]
|
|
307
|
+
while stack:
|
|
308
|
+
item, depth = stack.pop()
|
|
309
|
+
if not isinstance(item, (dict, list)):
|
|
310
|
+
continue
|
|
311
|
+
if depth > MAX_DEPTH:
|
|
312
|
+
return "nested too deeply"
|
|
313
|
+
if deepest.get(id(item), 0) >= depth:
|
|
314
|
+
continue
|
|
315
|
+
deepest[id(item)] = depth
|
|
316
|
+
if isinstance(item, dict):
|
|
317
|
+
if not all(isinstance(key, str) for key in item):
|
|
318
|
+
return "field names must be strings"
|
|
319
|
+
children: Any = item.values()
|
|
320
|
+
else:
|
|
321
|
+
children = item
|
|
322
|
+
stack.extend((child, depth + 1) for child in children)
|
|
323
|
+
values, size = _measure(value, max_values, max_bytes, {})
|
|
324
|
+
if values > max_values:
|
|
325
|
+
return "too many values"
|
|
326
|
+
if size > max_bytes:
|
|
327
|
+
return "too much text"
|
|
328
|
+
return None
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _schema_errors(validator: Any, value: Any, max_values: int, max_bytes: int) -> list[str]:
|
|
332
|
+
problem = _shape_problem(value, max_values, max_bytes)
|
|
333
|
+
if problem is not None:
|
|
334
|
+
return [problem]
|
|
335
|
+
return _tidy([m for error in validator.iter_errors(value) for m in _describe(error)])
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# A string literal, or an unterminated one running to the end of the text.
|
|
339
|
+
_STRING = re.compile(r'"[^"\\]*(?:\\[\s\S][^"\\]*)*"?')
|
|
340
|
+
_BRACKET = re.compile(r"[\[\]{}]")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _nesting_too_deep(text: str) -> bool:
|
|
344
|
+
"""Count brackets outside strings in one linear pass, before any parser runs."""
|
|
345
|
+
depth = 0
|
|
346
|
+
for match in _BRACKET.finditer(_STRING.sub("", text)):
|
|
347
|
+
if match.group() in "[{":
|
|
348
|
+
depth += 1
|
|
349
|
+
if depth > MAX_DEPTH:
|
|
350
|
+
return True
|
|
351
|
+
else:
|
|
352
|
+
depth -= 1
|
|
353
|
+
return False
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def is_well_formed(text: str) -> bool:
|
|
357
|
+
"""True when the text has no unpaired surrogate, so it is valid Unicode."""
|
|
358
|
+
try:
|
|
359
|
+
text.encode("utf-8")
|
|
360
|
+
except UnicodeEncodeError:
|
|
361
|
+
return False
|
|
362
|
+
return True
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
#: Integers above this are stored as floats, exactly as JavaScript stores them.
|
|
366
|
+
_SAFE_INT = 2**53
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _reject_constant(text: str) -> Any:
|
|
370
|
+
raise ValueError(f"{text} is not valid JSON")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def parse_json(text: str) -> tuple[bool, Any, str]:
|
|
374
|
+
"""Parse JSON exactly as the TypeScript core does.
|
|
375
|
+
|
|
376
|
+
The JSON grammar, at most MAX_DEPTH levels of nesting, no repeated keys, and
|
|
377
|
+
every number fitting in a 64-bit float. The text must be well-formed Unicode.
|
|
378
|
+
Problems are reported in the same order as in TypeScript: grammar first, then
|
|
379
|
+
number range, then repeated keys. Returns (ok, value, error).
|
|
380
|
+
"""
|
|
381
|
+
if not is_well_formed(text):
|
|
382
|
+
return False, None, "not well-formed Unicode text"
|
|
383
|
+
if _nesting_too_deep(text):
|
|
384
|
+
return False, None, "nested too deeply"
|
|
385
|
+
problems: set[str] = set()
|
|
386
|
+
|
|
387
|
+
def parse_int(digits: str) -> int | float:
|
|
388
|
+
if len(digits.lstrip("-")) > 309:
|
|
389
|
+
problems.add("range")
|
|
390
|
+
return 0
|
|
391
|
+
number = int(digits)
|
|
392
|
+
if abs(number) <= _SAFE_INT:
|
|
393
|
+
return number
|
|
394
|
+
try:
|
|
395
|
+
return float(number)
|
|
396
|
+
except OverflowError:
|
|
397
|
+
problems.add("range")
|
|
398
|
+
return 0
|
|
399
|
+
|
|
400
|
+
def parse_float(digits: str) -> float:
|
|
401
|
+
number = float(digits)
|
|
402
|
+
if not math.isfinite(number):
|
|
403
|
+
problems.add("range")
|
|
404
|
+
return number
|
|
405
|
+
|
|
406
|
+
def pairs(items: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
407
|
+
result = dict(items)
|
|
408
|
+
if len(result) != len(items):
|
|
409
|
+
problems.add("duplicate")
|
|
410
|
+
return result
|
|
411
|
+
|
|
412
|
+
try:
|
|
413
|
+
value = json.loads(
|
|
414
|
+
text,
|
|
415
|
+
parse_float=parse_float,
|
|
416
|
+
parse_int=parse_int,
|
|
417
|
+
parse_constant=_reject_constant,
|
|
418
|
+
object_pairs_hook=pairs,
|
|
419
|
+
)
|
|
420
|
+
except (ValueError, RecursionError):
|
|
421
|
+
return False, None, "not valid JSON"
|
|
422
|
+
if "range" in problems:
|
|
423
|
+
return False, None, "number out of range"
|
|
424
|
+
if "duplicate" in problems:
|
|
425
|
+
return False, None, "duplicate key"
|
|
426
|
+
return True, value, ""
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _precheck(text: str, max_bytes: int) -> str | None:
|
|
430
|
+
"""Checks done before parsing, in the same order as the TypeScript core."""
|
|
431
|
+
try:
|
|
432
|
+
size = len(text.encode("utf-8"))
|
|
433
|
+
except UnicodeEncodeError:
|
|
434
|
+
return "not well-formed Unicode text"
|
|
435
|
+
return f"longer than {max_bytes} bytes" if size > max_bytes else None
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _strip_bom(text: str) -> str:
|
|
439
|
+
return text.removeprefix("\ufeff")
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def validate_event(value: Any) -> ValidationResult:
|
|
443
|
+
"""Validate one event object against events.schema.json."""
|
|
444
|
+
errors = _schema_errors(
|
|
445
|
+
_validator("events.schema.json"), value, MAX_EVENT_VALUES, MAX_EVENT_BYTES
|
|
446
|
+
)
|
|
447
|
+
return ValidationResult(ok=not errors, errors=errors)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def validate_event_text(text: str) -> ValidationResult:
|
|
451
|
+
"""Parse and validate one event from JSON text. A leading byte order mark is ignored."""
|
|
452
|
+
body = _strip_bom(text)
|
|
453
|
+
problem = _precheck(body, MAX_EVENT_BYTES)
|
|
454
|
+
if problem is not None:
|
|
455
|
+
return ValidationResult(ok=False, errors=[problem])
|
|
456
|
+
ok, value, error = parse_json(body)
|
|
457
|
+
return validate_event(value) if ok else ValidationResult(ok=False, errors=[error])
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def iter_events_jsonl(text: str) -> Iterator[tuple[int, list[str]]]:
|
|
461
|
+
"""Yield (line number, errors) for each event line of an events.jsonl text, one at a time.
|
|
462
|
+
|
|
463
|
+
Lines are split on "\\n" only, with one trailing "\\r" removed, so files written
|
|
464
|
+
on Windows work. A byte order mark at the start of the file is ignored. Lines
|
|
465
|
+
holding only spaces or tabs are skipped. Use this to stop early on a large file.
|
|
466
|
+
"""
|
|
467
|
+
body = _strip_bom(text)
|
|
468
|
+
start, number = 0, 0
|
|
469
|
+
while start <= len(body):
|
|
470
|
+
end = body.find("\n", start)
|
|
471
|
+
if end == -1:
|
|
472
|
+
end = len(body)
|
|
473
|
+
raw = body[start:end]
|
|
474
|
+
start, number = end + 1, number + 1
|
|
475
|
+
line = raw[:-1] if raw.endswith("\r") else raw
|
|
476
|
+
if not _BLANK.fullmatch(line):
|
|
477
|
+
yield number, _line_errors(line)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _line_errors(line: str) -> list[str]:
|
|
481
|
+
problem = _precheck(line, MAX_EVENT_BYTES)
|
|
482
|
+
if problem is not None:
|
|
483
|
+
return [problem]
|
|
484
|
+
ok, value, error = parse_json(line)
|
|
485
|
+
return validate_event(value).errors if ok else [error]
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def validate_events_jsonl(text: str) -> ValidationResult:
|
|
489
|
+
"""Validate a whole events.jsonl text. Errors are prefixed with the line number.
|
|
490
|
+
|
|
491
|
+
See iter_events_jsonl for how lines are split.
|
|
492
|
+
"""
|
|
493
|
+
errors: list[str] = []
|
|
494
|
+
count = 0
|
|
495
|
+
for number, line_errors in iter_events_jsonl(text):
|
|
496
|
+
count += 1
|
|
497
|
+
errors.extend(f"line {number}: {error}" for error in line_errors)
|
|
498
|
+
return ValidationResult(ok=not errors, errors=errors, count=count)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def validate_lock(value: Any) -> ValidationResult:
|
|
502
|
+
"""Validate a decisions.lock object.
|
|
503
|
+
|
|
504
|
+
The JSON Schema runs first, then the rules a schema cannot express: every
|
|
505
|
+
threshold label must be one of the labels, and unsure must not exceed act.
|
|
506
|
+
"""
|
|
507
|
+
errors = _schema_errors(
|
|
508
|
+
_validator("decisions-lock.schema.json"), value, MAX_LOCK_VALUES, MAX_LOCK_BYTES
|
|
509
|
+
)
|
|
510
|
+
if errors:
|
|
511
|
+
return ValidationResult(ok=False, errors=errors)
|
|
512
|
+
for name, decision in value["decisions"].items():
|
|
513
|
+
for label, threshold in decision["thresholds"].items():
|
|
514
|
+
if label not in decision["labels"]:
|
|
515
|
+
errors.append(f"/decisions/{name}/thresholds/{label} is not one of the labels")
|
|
516
|
+
if threshold["unsure"] > threshold["act"]:
|
|
517
|
+
errors.append(f"/decisions/{name}/thresholds/{label} has unsure above act")
|
|
518
|
+
return ValidationResult(ok=not errors, errors=_tidy(errors))
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def validate_lock_text(text: str) -> ValidationResult:
|
|
522
|
+
"""Parse and validate a decisions.lock from JSON text. A leading byte order mark is ignored."""
|
|
523
|
+
body = _strip_bom(text)
|
|
524
|
+
problem = _precheck(body, MAX_LOCK_BYTES)
|
|
525
|
+
if problem is not None:
|
|
526
|
+
return ValidationResult(ok=False, errors=[problem])
|
|
527
|
+
ok, value, error = parse_json(body)
|
|
528
|
+
return validate_lock(value) if ok else ValidationResult(ok=False, errors=[error])
|
twosystems/py.typed
ADDED
|
File without changes
|