strictspec 0.1.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.
- strictspec/__init__.py +437 -0
- strictspec/_codes.py +1320 -0
- strictspec/_diag.py +375 -0
- strictspec/_doc.py +215 -0
- strictspec/_ir.py +1565 -0
- strictspec/_jsondoc.py +523 -0
- strictspec/_launcher.py +212 -0
- strictspec/_render.py +239 -0
- strictspec/_schema.py +790 -0
- strictspec/_strdecode.py +145 -0
- strictspec/_tomldoc.py +256 -0
- strictspec/py.typed +0 -0
- strictspec-0.1.0.dist-info/METADATA +32 -0
- strictspec-0.1.0.dist-info/RECORD +17 -0
- strictspec-0.1.0.dist-info/WHEEL +4 -0
- strictspec-0.1.0.dist-info/entry_points.txt +2 -0
- strictspec-0.1.0.dist-info/licenses/LICENSE +32 -0
strictspec/__init__.py
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""strictspec -- the public Python runtime.
|
|
2
|
+
|
|
3
|
+
The stable surface that generated strictspec validator code imports. It
|
|
4
|
+
re-exports the diagnostics model, document loading for the three syntaxes, the
|
|
5
|
+
version-pairing guard, the tagged document value (the second entry point) with
|
|
6
|
+
its coercers, and the constraint-engine entry (validation is driven by the
|
|
7
|
+
shared emitter IR, so a generated Python validator runs the identical checks as
|
|
8
|
+
the reference interpreter and the Go runtime).
|
|
9
|
+
|
|
10
|
+
Invariants: no lenient modes; loading and validation are inseparable; unknown
|
|
11
|
+
keys are always a hard error; there is no severity -- every diagnostic is an
|
|
12
|
+
error; raw untagged dicts are never a validation input (tagged values only).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from enum import IntEnum
|
|
19
|
+
|
|
20
|
+
from . import _diag as _diag
|
|
21
|
+
from . import _doc as _doc
|
|
22
|
+
from . import _ir as _ir
|
|
23
|
+
from . import _jsondoc as _jsondoc
|
|
24
|
+
from . import _render as _render
|
|
25
|
+
from . import _schema as _schema
|
|
26
|
+
from . import _strdecode as _strdecode
|
|
27
|
+
from . import _tomldoc as _tomldoc
|
|
28
|
+
|
|
29
|
+
# Version is the strictspec release this runtime was built at. Generated code is
|
|
30
|
+
# paired to it exactly (see require_runtime_version). rlsbl bumps this
|
|
31
|
+
# __version__ in source on release (Python pypi target); the version-pairing
|
|
32
|
+
# test asserts it matches pyproject, so drift is impossible.
|
|
33
|
+
__version__ = "0.1.0"
|
|
34
|
+
Version = __version__
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"Version",
|
|
38
|
+
"__version__",
|
|
39
|
+
"Diagnostic",
|
|
40
|
+
"Result",
|
|
41
|
+
"Program",
|
|
42
|
+
"Value",
|
|
43
|
+
"KV",
|
|
44
|
+
"Kind",
|
|
45
|
+
"compile_embedded",
|
|
46
|
+
"load_value",
|
|
47
|
+
"load_values",
|
|
48
|
+
"check_runtime_version",
|
|
49
|
+
"require_runtime_version",
|
|
50
|
+
"PairingError",
|
|
51
|
+
"version_gate",
|
|
52
|
+
"GateResult",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PairingError(Exception):
|
|
57
|
+
"""Raised by require_runtime_version on a version-pairing mismatch."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def check_runtime_version(generated_by: str) -> str | None:
|
|
61
|
+
"""Return a structured remediation message if generated code pinned to
|
|
62
|
+
generated_by is not paired with this runtime, else None. Under the
|
|
63
|
+
always-latest dependency rule this is the INTENDED surfacing of skew: the
|
|
64
|
+
remediation is regeneration, never pinning.
|
|
65
|
+
"""
|
|
66
|
+
if generated_by == Version:
|
|
67
|
+
return None
|
|
68
|
+
return (
|
|
69
|
+
"strictspec version pairing mismatch:\n"
|
|
70
|
+
f" generated by: {generated_by}\n"
|
|
71
|
+
f" runtime: {Version}\n"
|
|
72
|
+
" remediation: regenerate with `strictspec gen` against the current runtime "
|
|
73
|
+
"(do not pin -- the always-latest rule surfaces skew so it gets fixed)"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def require_runtime_version(generated_by: str) -> None:
|
|
78
|
+
"""Hard-error form used in generated init: raise PairingError when
|
|
79
|
+
generated_by does not exactly match this runtime version.
|
|
80
|
+
"""
|
|
81
|
+
msg = check_runtime_version(generated_by)
|
|
82
|
+
if msg is not None:
|
|
83
|
+
raise PairingError(msg)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class Diagnostic:
|
|
88
|
+
"""One validation error: a stable STRICTSPEC_* code, the rendered path, and
|
|
89
|
+
the pinned message text. There is no severity field: every diagnostic is an
|
|
90
|
+
error.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
code: str
|
|
94
|
+
path: str
|
|
95
|
+
message: str
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class Result:
|
|
100
|
+
"""A validation outcome: whether the document validated, and the ordered
|
|
101
|
+
diagnostics (empty iff valid). Diagnostics are in emission order.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
valid: bool
|
|
105
|
+
diagnostics: tuple[Diagnostic, ...] = ()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Kind(IntEnum):
|
|
109
|
+
"""The lexeme class of a tagged document value (the public projection)."""
|
|
110
|
+
|
|
111
|
+
RECORD = 0
|
|
112
|
+
ARRAY = 1
|
|
113
|
+
STRING = 2
|
|
114
|
+
INTEGER = 3
|
|
115
|
+
FLOAT = 4
|
|
116
|
+
BOOL = 5
|
|
117
|
+
NULL = 6
|
|
118
|
+
DATETIME = 7
|
|
119
|
+
DATE = 8
|
|
120
|
+
TIME = 9
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class Program:
|
|
124
|
+
"""A compiled strictspec schema. Generated code builds one at import from
|
|
125
|
+
its embedded schema (compile_embedded) and calls validate.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
__slots__ = ("_prog",)
|
|
129
|
+
|
|
130
|
+
def __init__(self, prog: _ir.Program) -> None:
|
|
131
|
+
self._prog = prog
|
|
132
|
+
|
|
133
|
+
def validate(self, input: bytes, syntax: str) -> Result:
|
|
134
|
+
"""RAW-BYTES entry point: lossless parse of input in the given syntax
|
|
135
|
+
("json" | "toml" | "jsonl"), then validate. Loading and validation are
|
|
136
|
+
inseparable. For JSONL every line is validated with per-line anchors.
|
|
137
|
+
"""
|
|
138
|
+
return self.validate_with_evidence(input, syntax, None)
|
|
139
|
+
|
|
140
|
+
def validate_with_evidence(
|
|
141
|
+
self, input: bytes, syntax: str, evidence: dict | None
|
|
142
|
+
) -> Result:
|
|
143
|
+
if syntax == "jsonl":
|
|
144
|
+
diags = self._validate_jsonl(input, evidence)
|
|
145
|
+
elif syntax == "toml":
|
|
146
|
+
try:
|
|
147
|
+
d = _tomldoc.parse(input)
|
|
148
|
+
except _doc.ParseError as pe:
|
|
149
|
+
diags = [_parse_diag(pe)]
|
|
150
|
+
else:
|
|
151
|
+
diags = _ir.execute(
|
|
152
|
+
self._prog, d.root, _ir.ExecOptions(format=_doc.FORMAT_TOML, evidence=evidence)
|
|
153
|
+
)
|
|
154
|
+
else: # json
|
|
155
|
+
try:
|
|
156
|
+
d = _jsondoc.parse(input)
|
|
157
|
+
except _doc.ParseError as pe:
|
|
158
|
+
diags = [_parse_diag(pe)]
|
|
159
|
+
else:
|
|
160
|
+
diags = _ir.execute(
|
|
161
|
+
self._prog, d.root, _ir.ExecOptions(format=_doc.FORMAT_JSON, evidence=evidence)
|
|
162
|
+
)
|
|
163
|
+
return _render_result(diags)
|
|
164
|
+
|
|
165
|
+
def validate_value(self, v: "Value") -> Result:
|
|
166
|
+
"""TAGGED-VALUE entry point: validate an already-parsed tagged document
|
|
167
|
+
value (from load_value or a generated typed constructor). Raw untagged
|
|
168
|
+
dicts are never accepted.
|
|
169
|
+
"""
|
|
170
|
+
return self.validate_value_with_evidence(v, None)
|
|
171
|
+
|
|
172
|
+
def validate_value_with_evidence(self, v: "Value", evidence: dict | None) -> Result:
|
|
173
|
+
diags = _ir.execute(
|
|
174
|
+
self._prog, v._node, _ir.ExecOptions(format=v._format, evidence=evidence)
|
|
175
|
+
)
|
|
176
|
+
return _render_result(diags)
|
|
177
|
+
|
|
178
|
+
def _validate_jsonl(self, src: bytes, evidence: dict | None) -> list:
|
|
179
|
+
try:
|
|
180
|
+
docs = _jsondoc.parse_lines(src)
|
|
181
|
+
except _doc.ParseError as pe:
|
|
182
|
+
return [_parse_diag(pe)]
|
|
183
|
+
starts = _line_starts(src)
|
|
184
|
+
out: list = []
|
|
185
|
+
for i, d in enumerate(docs):
|
|
186
|
+
ls = starts[i] if i < len(starts) else 0
|
|
187
|
+
out.extend(
|
|
188
|
+
_ir.execute(
|
|
189
|
+
self._prog,
|
|
190
|
+
d.root,
|
|
191
|
+
_ir.ExecOptions(
|
|
192
|
+
format=_doc.FORMAT_JSONL,
|
|
193
|
+
evidence=evidence,
|
|
194
|
+
jsonl=True,
|
|
195
|
+
line=i + 1,
|
|
196
|
+
line_start=ls,
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
return out
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def compile_embedded(files: dict[str, str], main_file: str) -> Program:
|
|
204
|
+
"""Compile a schema carried IN MEMORY by generated code: files maps each
|
|
205
|
+
referenced file name (the schema, imported type-definition files, and the
|
|
206
|
+
scalar manifest) to its exact text, and main_file names the entry point.
|
|
207
|
+
Import resolution and custom-scalar binding happen against the FileSet,
|
|
208
|
+
never disk. A schema-authoring problem surfaces as ValueError.
|
|
209
|
+
"""
|
|
210
|
+
s, sdiags = _schema.parse_from(files, main_file)
|
|
211
|
+
sdiags = list(sdiags) + _schema.resolve_imports_from(s, files)
|
|
212
|
+
if sdiags:
|
|
213
|
+
raise ValueError(_embedded_schema_error(sdiags))
|
|
214
|
+
scalars = _schema.load_manifest_scalars_from(files)
|
|
215
|
+
return Program(_ir.compile_program(s, scalars))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass(frozen=True)
|
|
219
|
+
class KV:
|
|
220
|
+
"""One ordered key/value binding of a record or map."""
|
|
221
|
+
|
|
222
|
+
key: str
|
|
223
|
+
value: "Value"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class Value:
|
|
227
|
+
"""A TAGGED, lexeme-retaining document value: the second entry point's input
|
|
228
|
+
and what generated typed constructors bind from. It comes from a lossless
|
|
229
|
+
parse (load_value) or from generated typed constructors. Raw untagged dicts
|
|
230
|
+
are never Values -- ambiguity never enters the model.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
__slots__ = ("_node", "_format")
|
|
234
|
+
|
|
235
|
+
def __init__(self, node: _doc.Node | None, fmt: str) -> None:
|
|
236
|
+
self._node = node
|
|
237
|
+
self._format = fmt
|
|
238
|
+
|
|
239
|
+
def kind(self) -> Kind:
|
|
240
|
+
if self._node is None:
|
|
241
|
+
return Kind.NULL
|
|
242
|
+
k = self._node.kind
|
|
243
|
+
return {
|
|
244
|
+
_doc.Kind.RECORD: Kind.RECORD,
|
|
245
|
+
_doc.Kind.ARRAY: Kind.ARRAY,
|
|
246
|
+
_doc.Kind.STRING: Kind.STRING,
|
|
247
|
+
_doc.Kind.INTEGER: Kind.INTEGER,
|
|
248
|
+
_doc.Kind.FLOAT: Kind.FLOAT,
|
|
249
|
+
_doc.Kind.BOOL: Kind.BOOL,
|
|
250
|
+
_doc.Kind.NULL: Kind.NULL,
|
|
251
|
+
_doc.Kind.DATETIME_OFFSET: Kind.DATETIME,
|
|
252
|
+
_doc.Kind.DATETIME_LOCAL: Kind.DATETIME,
|
|
253
|
+
_doc.Kind.DATE_LOCAL: Kind.DATE,
|
|
254
|
+
_doc.Kind.TIME_LOCAL: Kind.TIME,
|
|
255
|
+
}.get(k, Kind.NULL)
|
|
256
|
+
|
|
257
|
+
def field(self, name: str) -> "tuple[Value, bool]":
|
|
258
|
+
if self._node is None or self._node.kind != _doc.Kind.RECORD:
|
|
259
|
+
return Value(None, self._format), False
|
|
260
|
+
for e in self._node.entries:
|
|
261
|
+
if e.key == name:
|
|
262
|
+
return Value(e.value, self._format), True
|
|
263
|
+
return Value(None, self._format), False
|
|
264
|
+
|
|
265
|
+
def entries(self) -> list[KV]:
|
|
266
|
+
if self._node is None or self._node.kind != _doc.Kind.RECORD:
|
|
267
|
+
return []
|
|
268
|
+
return [KV(e.key, Value(e.value, self._format)) for e in self._node.entries]
|
|
269
|
+
|
|
270
|
+
def items(self) -> list["Value"]:
|
|
271
|
+
if self._node is None or self._node.kind != _doc.Kind.ARRAY:
|
|
272
|
+
return []
|
|
273
|
+
return [Value(it, self._format) for it in self._node.items]
|
|
274
|
+
|
|
275
|
+
# --- coercers ------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
def string(self) -> tuple[str, bool]:
|
|
278
|
+
if self._node is None or self._node.kind != _doc.Kind.STRING:
|
|
279
|
+
return "", False
|
|
280
|
+
return self._decode_string(), True
|
|
281
|
+
|
|
282
|
+
def int(self) -> tuple[int, bool]:
|
|
283
|
+
if self._node is None or self._node.kind != _doc.Kind.INTEGER:
|
|
284
|
+
return 0, False
|
|
285
|
+
v = _schema._go_parse_int(self._node.lexeme)
|
|
286
|
+
if v is None:
|
|
287
|
+
return 0, False
|
|
288
|
+
return v, True
|
|
289
|
+
|
|
290
|
+
def float(self) -> tuple[float, bool]:
|
|
291
|
+
if self._node is None or self._node.kind != _doc.Kind.FLOAT:
|
|
292
|
+
return 0.0, False
|
|
293
|
+
f = _schema._go_parse_float(self._node.lexeme)
|
|
294
|
+
if f is None:
|
|
295
|
+
return 0.0, False
|
|
296
|
+
return f, True
|
|
297
|
+
|
|
298
|
+
def number(self) -> tuple[float, bool]:
|
|
299
|
+
if self._node is None or self._node.kind not in (_doc.Kind.INTEGER, _doc.Kind.FLOAT):
|
|
300
|
+
return 0.0, False
|
|
301
|
+
f = _schema._go_parse_float(self._node.lexeme)
|
|
302
|
+
if f is None:
|
|
303
|
+
return 0.0, False
|
|
304
|
+
return f, True
|
|
305
|
+
|
|
306
|
+
def bool(self) -> tuple[bool, bool]:
|
|
307
|
+
if self._node is None or self._node.kind != _doc.Kind.BOOL:
|
|
308
|
+
return False, False
|
|
309
|
+
return self._node.lexeme == "true", True
|
|
310
|
+
|
|
311
|
+
def datetime(self) -> tuple[str, bool]:
|
|
312
|
+
if self._node is None:
|
|
313
|
+
return "", False
|
|
314
|
+
k = self._node.kind
|
|
315
|
+
if k in (
|
|
316
|
+
_doc.Kind.DATETIME_OFFSET,
|
|
317
|
+
_doc.Kind.DATETIME_LOCAL,
|
|
318
|
+
_doc.Kind.DATE_LOCAL,
|
|
319
|
+
_doc.Kind.TIME_LOCAL,
|
|
320
|
+
):
|
|
321
|
+
return self._node.lexeme, True
|
|
322
|
+
if k == _doc.Kind.STRING:
|
|
323
|
+
return self._decode_string(), True
|
|
324
|
+
return "", False
|
|
325
|
+
|
|
326
|
+
def is_null(self) -> bool:
|
|
327
|
+
return self._node is None or self._node.kind == _doc.Kind.NULL
|
|
328
|
+
|
|
329
|
+
def _decode_string(self) -> str:
|
|
330
|
+
if self._node is None:
|
|
331
|
+
return ""
|
|
332
|
+
if self._format == _doc.FORMAT_TOML:
|
|
333
|
+
return _strdecode.decode_toml(self._node.lexeme)
|
|
334
|
+
return _strdecode.decode_json(self._node.lexeme)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def load_value(input: bytes, syntax: str) -> Value:
|
|
338
|
+
"""Losslessly parse raw bytes in the given syntax into a tagged Value. For
|
|
339
|
+
"jsonl" it parses the FIRST line (use load_values for a stream). A parse
|
|
340
|
+
failure raises -- there is no lenient mode.
|
|
341
|
+
"""
|
|
342
|
+
if syntax == "toml":
|
|
343
|
+
d = _tomldoc.parse(input)
|
|
344
|
+
return Value(d.root, _doc.FORMAT_TOML)
|
|
345
|
+
if syntax == "jsonl":
|
|
346
|
+
docs = _jsondoc.parse_lines(input)
|
|
347
|
+
if not docs:
|
|
348
|
+
raise ValueError("strictspec: empty JSONL stream")
|
|
349
|
+
return Value(docs[0].root, _doc.FORMAT_JSONL)
|
|
350
|
+
d = _jsondoc.parse(input)
|
|
351
|
+
return Value(d.root, _doc.FORMAT_JSON)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def load_values(input: bytes) -> list[Value]:
|
|
355
|
+
"""Losslessly parse a JSONL stream into one Value per line."""
|
|
356
|
+
docs = _jsondoc.parse_lines(input)
|
|
357
|
+
return [Value(d.root, _doc.FORMAT_JSONL) for d in docs]
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# --- version gate helper -----------------------------------------------------
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@dataclass(frozen=True)
|
|
364
|
+
class GateResult:
|
|
365
|
+
"""The structured outcome of the inline version gate: whether the document's
|
|
366
|
+
format_version is accepted, plus the remediation payload on failure.
|
|
367
|
+
"""
|
|
368
|
+
|
|
369
|
+
ok: bool
|
|
370
|
+
diagnostics: tuple[Diagnostic, ...] = ()
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def version_gate(program: Program, input: bytes, syntax: str) -> GateResult:
|
|
374
|
+
"""Run ONLY the version gate against the document, returning its structured
|
|
375
|
+
result (the three-message pattern + remediation payload). A convenience for
|
|
376
|
+
consumers that want to surface the gate verdict before full validation.
|
|
377
|
+
"""
|
|
378
|
+
try:
|
|
379
|
+
if syntax == "toml":
|
|
380
|
+
d = _tomldoc.parse(input)
|
|
381
|
+
root = d.root
|
|
382
|
+
fmt = _doc.FORMAT_TOML
|
|
383
|
+
elif syntax == "jsonl":
|
|
384
|
+
docs = _jsondoc.parse_lines(input)
|
|
385
|
+
root = docs[0].root if docs else None
|
|
386
|
+
fmt = _doc.FORMAT_JSONL
|
|
387
|
+
else:
|
|
388
|
+
d = _jsondoc.parse(input)
|
|
389
|
+
root = d.root
|
|
390
|
+
fmt = _doc.FORMAT_JSON
|
|
391
|
+
except _doc.ParseError as pe:
|
|
392
|
+
r = _render_result([_parse_diag(pe)])
|
|
393
|
+
return GateResult(ok=False, diagnostics=r.diagnostics)
|
|
394
|
+
v = _ir._Exec(program._prog, root, _ir.ExecOptions(format=fmt))
|
|
395
|
+
ok = v.gate(root)
|
|
396
|
+
r = _render_result(v.diags.all())
|
|
397
|
+
return GateResult(ok=ok, diagnostics=r.diagnostics)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# --- internal helpers --------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _render_result(diags: list) -> Result:
|
|
404
|
+
out = tuple(
|
|
405
|
+
Diagnostic(code=d.code, path=d.path.render(), message=_render.render(d)) for d in diags
|
|
406
|
+
)
|
|
407
|
+
return Result(valid=len(out) == 0, diagnostics=out)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _parse_diag(pe: _doc.ParseError) -> _diag.Diagnostic:
|
|
411
|
+
code = "STRICTSPEC_PARSE_JSON_SYNTAX"
|
|
412
|
+
if pe.format == _doc.FORMAT_TOML:
|
|
413
|
+
code = "STRICTSPEC_PARSE_TOML_SYNTAX"
|
|
414
|
+
elif pe.format == _doc.FORMAT_JSONL:
|
|
415
|
+
code = "STRICTSPEC_PARSE_JSONL_LINE_SYNTAX"
|
|
416
|
+
slots = {"detail": _diag.SlotString(pe.message)}
|
|
417
|
+
# Only the JSONL line-syntax template carries a {line} placeholder; binding
|
|
418
|
+
# it for JSON/TOML (as the Go code literally does) would be an unknown-slot
|
|
419
|
+
# render error. The JSON/TOML templates take only {path} and {detail}.
|
|
420
|
+
if code == "STRICTSPEC_PARSE_JSONL_LINE_SYNTAX":
|
|
421
|
+
slots["line"] = _diag.SlotInt(pe.position.line)
|
|
422
|
+
return _diag.Diagnostic(code=code, path=_diag.new_path(), slots=slots)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _line_starts(src: bytes) -> list[int]:
|
|
426
|
+
starts = [0]
|
|
427
|
+
for i, b in enumerate(src):
|
|
428
|
+
if b == 0x0A:
|
|
429
|
+
starts.append(i + 1)
|
|
430
|
+
return starts
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _embedded_schema_error(diags: list) -> str:
|
|
434
|
+
parts = ["strictspec: embedded schema failed meta-schema validation (regenerate):"]
|
|
435
|
+
for d in diags:
|
|
436
|
+
parts.append("\n " + d.code + " at " + d.path.render())
|
|
437
|
+
return "".join(parts)
|