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 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())
@@ -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