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