softschema 0.4.0__tar.gz → 0.5.0__tar.gz
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.
- {softschema-0.4.0 → softschema-0.5.0}/PKG-INFO +1 -1
- {softschema-0.4.0 → softschema-0.5.0}/docs/softschema-python-design.md +10 -1
- {softschema-0.4.0 → softschema-0.5.0}/docs/softschema-spec.md +10 -4
- {softschema-0.4.0 → softschema-0.5.0}/packages/python/src/softschema/__init__.py +2 -0
- {softschema-0.4.0 → softschema-0.5.0}/packages/python/src/softschema/_portable.py +34 -25
- {softschema-0.4.0 → softschema-0.5.0}/packages/python/src/softschema/validate.py +95 -20
- {softschema-0.4.0 → softschema-0.5.0}/packages/python/tests/test_core.py +154 -2
- {softschema-0.4.0 → softschema-0.5.0}/.gitignore +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/AGENTS.md +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/LICENSE +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/README.md +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/docs/development.md +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/docs/e2e-testing.runbook.md +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/docs/installation.md +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/docs/publishing-npm.md +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/docs/publishing.md +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/docs/softschema-guide.md +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/docs/softschema-typescript-design.md +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/examples/__init__.py +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/examples/movie_page/README.md +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/examples/movie_page/__init__.py +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/examples/movie_page/host_integration.py +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/examples/movie_page/model.py +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/examples/movie_page/movie-page.schema.yaml +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/examples/movie_page/spirited-away.md +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/examples/parity/__init__.py +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/examples/parity/model.py +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/examples/parity/parity.schema.yaml +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/packages/python/README.md +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/packages/python/src/softschema/canonicalize.py +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/packages/python/src/softschema/cli.py +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/packages/python/src/softschema/compile.py +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/packages/python/src/softschema/errors.py +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/packages/python/src/softschema/generate.py +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/packages/python/src/softschema/models.py +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/packages/python/src/softschema/py.typed +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/packages/python/src/softschema/registry.py +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/packages/python/src/softschema/schema_view.py +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/packages/python/src/softschema/soft_field.py +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/packages/python/tests/test_canonicalize.py +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/packages/python/tests/test_cli.py +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/packages/python/tests/test_doc_topics_resolve.py +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/packages/python/tests/test_enforced_extras.py +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/packages/python/tests/test_errors.py +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/packages/python/tests/test_generate.py +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/packages/python/tests/test_metadata_rules.py +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/packages/python/tests/test_movie_page_example.py +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/packages/python/tests/test_parity.py +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/packages/python/tests/test_schema_view.py +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/packages/python/tests/test_skill_mirror_drift.py +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/packages/python/tests/test_soft_field.py +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/packages/python/tests/test_warning_codes.py +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/packages/typescript/LICENSE +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/packages/typescript/README.md +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/pyproject.toml +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/skills/softschema/SKILL.md +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/tests/golden/README.md +0 -0
- {softschema-0.4.0 → softschema-0.5.0}/tests/vectors/README.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: softschema
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Soft schema conventions and validation tools for Markdown/YAML artifacts
|
|
5
5
|
Project-URL: Homepage, https://github.com/jlevy/softschema
|
|
6
6
|
Project-URL: Repository, https://github.com/jlevy/softschema
|
|
@@ -169,6 +169,16 @@ free-form mappings are unaffected.
|
|
|
169
169
|
The overlay is validation-time only; compiled schemas never change.
|
|
170
170
|
`validate_structural` exposes the same behavior via its `strict_extras` keyword.
|
|
171
171
|
|
|
172
|
+
Compiling a schema is a pure function of the schema text and `strict_extras`, so
|
|
173
|
+
`validate_structural` memoizes it rather than reparsing and recompiling on every call.
|
|
174
|
+
The cache is keyed on the schema text itself, not on the file’s path or stat, so a
|
|
175
|
+
regenerated schema can never be served from a stale entry and identical bytes at two
|
|
176
|
+
paths share one entry.
|
|
177
|
+
Validation that passes `resources` builds a fresh validator, because that mapping is
|
|
178
|
+
neither hashable nor cheap to fingerprint, and compile failures are not cached.
|
|
179
|
+
`clear_validator_cache` drops every entry, for a long-lived process that regenerates
|
|
180
|
+
compiled schemas in place; ordinary callers never need it.
|
|
181
|
+
|
|
172
182
|
There are two public entry points: `validate_artifact` (above) for Markdown/YAML
|
|
173
183
|
documents, and `validate_values` for an already-extracted mapping (a body-form runtime,
|
|
174
184
|
a structured-output adapter, a fixture).
|
|
@@ -287,7 +297,6 @@ The current first-release kinds:
|
|
|
287
297
|
| --- | --- |
|
|
288
298
|
| `artifact_unreadable` | The artifact cannot be opened. |
|
|
289
299
|
| `artifact_invalid_utf8` | The artifact is not valid UTF-8. |
|
|
290
|
-
| `artifact_too_large` | The artifact exceeds the bounded input size. |
|
|
291
300
|
| `yaml_parse_error` | YAML or frontmatter could not be parsed. |
|
|
292
301
|
| `no_frontmatter` | Frontmatter block is missing in a Markdown artifact. |
|
|
293
302
|
| `frontmatter_not_mapping` | Frontmatter parsed but is not a mapping at the top level. |
|
|
@@ -110,10 +110,16 @@ date or timestamp; a semantic model or explicit structural assertion may impose
|
|
|
110
110
|
constraint. An explicit tag such as `!!timestamp` remains unsupported under the
|
|
111
111
|
explicit-tag rule.
|
|
112
112
|
|
|
113
|
-
One
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
One YAML document has at most 64 simultaneously open collections, including the root.
|
|
114
|
+
Exceeding that depth is `yaml_limit`, as is a host YAML parser stack overflow.
|
|
115
|
+
The depth rule is a portability rule rather than a resource ceiling: left to the host,
|
|
116
|
+
the two runtimes disagree by an order of magnitude about how deep a document may be, so
|
|
117
|
+
a fixed bound is what makes depth mean the same thing in both.
|
|
118
|
+
|
|
119
|
+
Input size, scalar size, and node count are not bounded.
|
|
120
|
+
Those ceilings only answered whether a hostile document could exhaust the parser, and
|
|
121
|
+
softschema reads artifacts its own callers just wrote; a cap that can only fire after
|
|
122
|
+
the artifact is written destroys completed work rather than rejecting bad input.
|
|
117
123
|
|
|
118
124
|
For `frontmatter-md`, the opening and closing delimiters begin at column one and contain
|
|
119
125
|
`---` plus optional trailing whitespace.
|
|
@@ -27,6 +27,7 @@ from softschema.validate import (
|
|
|
27
27
|
SemanticResult,
|
|
28
28
|
StructuralResult,
|
|
29
29
|
ValidationResult,
|
|
30
|
+
clear_validator_cache,
|
|
30
31
|
infer_envelope_key,
|
|
31
32
|
validate_artifact,
|
|
32
33
|
validate_semantic,
|
|
@@ -56,6 +57,7 @@ __all__ = [
|
|
|
56
57
|
"StructuralResult",
|
|
57
58
|
"ValidationResult",
|
|
58
59
|
"WarningCode",
|
|
60
|
+
"clear_validator_cache",
|
|
59
61
|
"compile_model",
|
|
60
62
|
"infer_envelope_key",
|
|
61
63
|
"parse_schema_metadata",
|
|
@@ -19,11 +19,30 @@ from ruamel.yaml.events import (
|
|
|
19
19
|
)
|
|
20
20
|
from ruamel.yaml.nodes import ScalarNode
|
|
21
21
|
|
|
22
|
-
MAX_INPUT_BYTES = 1_048_576
|
|
23
|
-
MAX_DEPTH = 64
|
|
24
|
-
MAX_NODES = 100_000
|
|
25
|
-
MAX_SCALAR_BYTES = 262_144
|
|
26
22
|
MAX_SAFE_INTEGER = 9_007_199_254_740_991
|
|
23
|
+
"""Largest integer that survives a round trip through a JS number."""
|
|
24
|
+
|
|
25
|
+
MAX_DEPTH = 64
|
|
26
|
+
"""Simultaneously open collections, including the root.
|
|
27
|
+
|
|
28
|
+
A portability rule, not a resource guard, which is why it survives while the input,
|
|
29
|
+
node, and scalar ceilings did not. Those three only answered whether a hostile document
|
|
30
|
+
could exhaust the parser, and softschema reads artifacts its own callers just wrote.
|
|
31
|
+
Depth answers a different question: how deep a document still means the same thing in
|
|
32
|
+
both runtimes.
|
|
33
|
+
|
|
34
|
+
Without an explicit rule the host stack decides, and the two hosts disagree by an order
|
|
35
|
+
of magnitude. CPython's default recursion limit of 1,000 lets this constructor reach
|
|
36
|
+
depth 491 from an empty caller stack — less from a real one, since the caller's own
|
|
37
|
+
frames come out of the same budget — while V8 parses past 10,000. A document between
|
|
38
|
+
those two bounds would be valid in TypeScript and a crash in Python, which is exactly
|
|
39
|
+
what the shared vectors exist to prevent.
|
|
40
|
+
|
|
41
|
+
64 is far above any real artifact and leaves roughly 7x headroom under the measured
|
|
42
|
+
ceiling, so the check below always fires before the recursion does. Raising it is safe
|
|
43
|
+
only while that headroom holds; past a few hundred the Python guard stops being reachable
|
|
44
|
+
and `RecursionError` takes over.
|
|
45
|
+
"""
|
|
27
46
|
|
|
28
47
|
|
|
29
48
|
def _construct_timestamp_as_string(_constructor: SafeConstructor, node: ScalarNode) -> str:
|
|
@@ -48,12 +67,7 @@ class PortableInputError(ValueError):
|
|
|
48
67
|
|
|
49
68
|
|
|
50
69
|
def read_utf8(path: Path) -> str:
|
|
51
|
-
"""Read one
|
|
52
|
-
size = path.stat().st_size
|
|
53
|
-
if size > MAX_INPUT_BYTES:
|
|
54
|
-
raise PortableInputError(
|
|
55
|
-
"input_too_large", f"input is {size} bytes; limit is {MAX_INPUT_BYTES}"
|
|
56
|
-
)
|
|
70
|
+
"""Read one UTF-8 artifact."""
|
|
57
71
|
try:
|
|
58
72
|
return path.read_bytes().decode("utf-8", errors="strict")
|
|
59
73
|
except UnicodeDecodeError as exc:
|
|
@@ -65,7 +79,6 @@ def parse_yaml(text: str) -> Any:
|
|
|
65
79
|
yaml = YAML(typ="safe")
|
|
66
80
|
yaml.Constructor = _PortableConstructor
|
|
67
81
|
stack: list[tuple[str, bool]] = []
|
|
68
|
-
nodes = 0
|
|
69
82
|
has_alias = False
|
|
70
83
|
|
|
71
84
|
def consume_parent_slot() -> None:
|
|
@@ -80,14 +93,10 @@ def parse_yaml(text: str) -> Any:
|
|
|
80
93
|
if getattr(event, "tag", None) is not None:
|
|
81
94
|
raise PortableInputError("yaml_custom_tag", "explicit YAML tags are not supported")
|
|
82
95
|
if isinstance(event, ScalarEvent):
|
|
83
|
-
nodes += 1
|
|
84
|
-
if len(event.value.encode("utf-8", errors="surrogatepass")) > MAX_SCALAR_BYTES:
|
|
85
|
-
raise PortableInputError("yaml_limit", "YAML scalar exceeds the size limit")
|
|
86
96
|
if stack and stack[-1] == ("map", True) and event.value == "<<":
|
|
87
97
|
raise PortableInputError("yaml_merge_key", "YAML merge keys are not supported")
|
|
88
98
|
consume_parent_slot()
|
|
89
99
|
elif isinstance(event, (MappingStartEvent, SequenceStartEvent)):
|
|
90
|
-
nodes += 1
|
|
91
100
|
consume_parent_slot()
|
|
92
101
|
stack.append(
|
|
93
102
|
("map", True) if isinstance(event, MappingStartEvent) else ("seq", False)
|
|
@@ -96,13 +105,17 @@ def parse_yaml(text: str) -> Any:
|
|
|
96
105
|
raise PortableInputError("yaml_limit", "YAML nesting exceeds the depth limit")
|
|
97
106
|
elif isinstance(event, (MappingEndEvent, SequenceEndEvent)):
|
|
98
107
|
stack.pop()
|
|
99
|
-
if nodes > MAX_NODES:
|
|
100
|
-
raise PortableInputError("yaml_limit", "YAML exceeds the node limit")
|
|
101
108
|
if has_alias:
|
|
102
109
|
raise PortableInputError("yaml_alias", "YAML aliases and anchors are not supported")
|
|
103
110
|
value = yaml.load(text)
|
|
104
111
|
except PortableInputError:
|
|
105
112
|
raise
|
|
113
|
+
except RecursionError as exc:
|
|
114
|
+
# The preflight above rejects anything past MAX_DEPTH, so construction should
|
|
115
|
+
# never recurse this far. Mirror the TypeScript RangeError handler anyway: a
|
|
116
|
+
# caller already deep in its own stack can exhaust the budget at a legal depth,
|
|
117
|
+
# and that has to stay a structured result rather than escape validate_artifact.
|
|
118
|
+
raise PortableInputError("yaml_limit", "YAML nesting exhausted the parser stack") from exc
|
|
106
119
|
except YAMLError as exc:
|
|
107
120
|
code = "yaml_duplicate_key" if "duplicate key" in str(exc).lower() else "yaml_parse_error"
|
|
108
121
|
raise PortableInputError(code, str(exc)) from exc
|
|
@@ -113,13 +126,9 @@ def parse_yaml(text: str) -> Any:
|
|
|
113
126
|
|
|
114
127
|
|
|
115
128
|
def _check_value(root: Any) -> None:
|
|
116
|
-
stack: list[
|
|
117
|
-
nodes = 0
|
|
129
|
+
stack: list[Any] = [root]
|
|
118
130
|
while stack:
|
|
119
|
-
value
|
|
120
|
-
nodes += 1
|
|
121
|
-
if nodes > MAX_NODES or depth > MAX_DEPTH:
|
|
122
|
-
raise PortableInputError("yaml_limit", "YAML value exceeds the structure limit")
|
|
131
|
+
value = stack.pop()
|
|
123
132
|
if value is None or isinstance(value, bool):
|
|
124
133
|
continue
|
|
125
134
|
if isinstance(value, str):
|
|
@@ -144,13 +153,13 @@ def _check_value(root: Any) -> None:
|
|
|
144
153
|
"host-native date and datetime values are not portable; use an ISO string",
|
|
145
154
|
)
|
|
146
155
|
if isinstance(value, list):
|
|
147
|
-
stack.extend(
|
|
156
|
+
stack.extend(value)
|
|
148
157
|
continue
|
|
149
158
|
if isinstance(value, dict):
|
|
150
159
|
for key, item in value.items():
|
|
151
160
|
if not isinstance(key, str):
|
|
152
161
|
raise PortableInputError("yaml_non_string_key", "mapping keys must be strings")
|
|
153
|
-
stack.append(
|
|
162
|
+
stack.append(item)
|
|
154
163
|
continue
|
|
155
164
|
raise PortableInputError(
|
|
156
165
|
"yaml_unsupported_scalar", f"unsupported YAML value: {type(value).__name__}"
|
|
@@ -17,6 +17,7 @@ from __future__ import annotations
|
|
|
17
17
|
import re
|
|
18
18
|
from collections.abc import Iterator, Mapping
|
|
19
19
|
from dataclasses import dataclass, field
|
|
20
|
+
from functools import lru_cache
|
|
20
21
|
from pathlib import Path
|
|
21
22
|
from typing import Any, Literal
|
|
22
23
|
|
|
@@ -83,7 +84,7 @@ class ArtifactValidationResult:
|
|
|
83
84
|
outcome: Literal["valid", "invalid", "input_error"] = field(init=False)
|
|
84
85
|
|
|
85
86
|
def __post_init__(self) -> None:
|
|
86
|
-
input_codes = {"artifact_unreadable", "artifact_invalid_utf8"
|
|
87
|
+
input_codes = {"artifact_unreadable", "artifact_invalid_utf8"}
|
|
87
88
|
first_kind = self.structural.errors[0].get("kind") if self.structural.errors else None
|
|
88
89
|
outcome = "valid" if self.ok else "input_error" if first_kind in input_codes else "invalid"
|
|
89
90
|
object.__setattr__(self, "outcome", outcome)
|
|
@@ -97,6 +98,96 @@ class ArtifactValidationResult:
|
|
|
97
98
|
return [warning.code for warning in self.warnings]
|
|
98
99
|
|
|
99
100
|
|
|
101
|
+
class _SchemaRootNotAMapping(Exception):
|
|
102
|
+
"""A compiled schema file did not parse to a mapping."""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
_VALIDATOR_CACHE_SIZE = 256
|
|
106
|
+
"""Compiled validators to retain.
|
|
107
|
+
|
|
108
|
+
Sized well above the number of distinct compiled schemas a large run touches — a
|
|
109
|
+
downstream suite validated 521 documents against far fewer schemas — so a whole
|
|
110
|
+
run reuses entries rather than evicting them.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _build_validator(
|
|
115
|
+
schema_text: str,
|
|
116
|
+
strict_extras: bool,
|
|
117
|
+
resources: Mapping[str, dict[str, Any]] | None,
|
|
118
|
+
) -> Draft202012Validator:
|
|
119
|
+
"""Build the validator a compiled schema describes.
|
|
120
|
+
|
|
121
|
+
Every step here is a pure function of ``schema_text`` plus ``strict_extras``
|
|
122
|
+
and ``resources`` — nothing depends on the document being validated.
|
|
123
|
+
"""
|
|
124
|
+
schema = parse_yaml(schema_text)
|
|
125
|
+
if not isinstance(schema, dict):
|
|
126
|
+
raise _SchemaRootNotAMapping
|
|
127
|
+
_check_patterns(schema)
|
|
128
|
+
Draft202012Validator.check_schema(schema)
|
|
129
|
+
_check_schema_identities(schema, resources or {})
|
|
130
|
+
if strict_extras:
|
|
131
|
+
schema = apply_enforced_extras(schema)
|
|
132
|
+
registry: Registry[Any] = Registry()
|
|
133
|
+
for key, resource_schema in (resources or {}).items():
|
|
134
|
+
resource_id = str(resource_schema.get("$id", key))
|
|
135
|
+
registry = registry.with_resource(
|
|
136
|
+
resource_id,
|
|
137
|
+
Resource.from_contents(resource_schema, default_specification=DRAFT202012),
|
|
138
|
+
)
|
|
139
|
+
return Draft202012Validator(schema, registry=registry.crawl())
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@lru_cache(maxsize=_VALIDATOR_CACHE_SIZE)
|
|
143
|
+
def _cached_validator(schema_text: str, strict_extras: bool) -> Draft202012Validator:
|
|
144
|
+
"""Memoize :func:`_build_validator` for the no-``resources`` case.
|
|
145
|
+
|
|
146
|
+
A compiled schema is a build output, so parsing it, checking it against the
|
|
147
|
+
dialect, applying the ``enforced`` overlay, and crawling the registry produce
|
|
148
|
+
the same validator every time. Doing that work per validation call dominated
|
|
149
|
+
large suites: one schema file was recompiled 137 times in a single run, and
|
|
150
|
+
schema compilation accounted for roughly a quarter of a consumer's total test
|
|
151
|
+
wall clock.
|
|
152
|
+
|
|
153
|
+
Keyed on the schema text itself rather than on the file's path and stat, so a
|
|
154
|
+
rewritten schema can never be served from a stale entry: a file whose mtime
|
|
155
|
+
and size both survive an edit — a same-length rewrite, or any restore that
|
|
156
|
+
preserves timestamps, such as ``cp -p``, ``rsync -t``, ``tar -x``, or a CI
|
|
157
|
+
cache — still hashes differently and misses. Two paths holding identical
|
|
158
|
+
bytes correctly share one entry.
|
|
159
|
+
|
|
160
|
+
Reading and hashing the text is negligible against what a hit avoids: the
|
|
161
|
+
YAML parse, the dialect check, the overlay, and the registry crawl.
|
|
162
|
+
"""
|
|
163
|
+
return _build_validator(schema_text, strict_extras, None)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def clear_validator_cache() -> None:
|
|
167
|
+
"""Drop every memoized validator.
|
|
168
|
+
|
|
169
|
+
Only needed by a long-lived process that regenerates compiled schemas in
|
|
170
|
+
place, such as a watch mode or a language server; ordinary callers never have
|
|
171
|
+
to call this, because a rewritten schema misses the cache on its own.
|
|
172
|
+
"""
|
|
173
|
+
_cached_validator.cache_clear()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _validator_for(
|
|
177
|
+
schema_yaml_path: Path,
|
|
178
|
+
strict_extras: bool,
|
|
179
|
+
resources: Mapping[str, dict[str, Any]] | None,
|
|
180
|
+
) -> Draft202012Validator:
|
|
181
|
+
"""Return a validator, reusing a cached one when it is safe to do so."""
|
|
182
|
+
schema_text = read_utf8(schema_yaml_path)
|
|
183
|
+
if resources:
|
|
184
|
+
# `resources` is a plain mapping of dicts, so it is neither hashable
|
|
185
|
+
# nor cheap to fingerprint. Registry-backed validation is the rare
|
|
186
|
+
# path; build it fresh rather than risk a wrong cache key.
|
|
187
|
+
return _build_validator(schema_text, strict_extras, resources)
|
|
188
|
+
return _cached_validator(schema_text, strict_extras)
|
|
189
|
+
|
|
190
|
+
|
|
100
191
|
def validate_structural(
|
|
101
192
|
values: Any,
|
|
102
193
|
schema_yaml_path: Path,
|
|
@@ -112,23 +203,7 @@ def validate_structural(
|
|
|
112
203
|
:func:`softschema.canonicalize.apply_enforced_extras`.
|
|
113
204
|
"""
|
|
114
205
|
try:
|
|
115
|
-
|
|
116
|
-
if not isinstance(schema, dict):
|
|
117
|
-
return _schema_invalid("syntax", "compiled schema root must be a mapping")
|
|
118
|
-
_check_patterns(schema)
|
|
119
|
-
Draft202012Validator.check_schema(schema)
|
|
120
|
-
_check_schema_identities(schema, resources or {})
|
|
121
|
-
if strict_extras:
|
|
122
|
-
schema = apply_enforced_extras(schema)
|
|
123
|
-
registry: Registry[Any] = Registry()
|
|
124
|
-
for key, resource_schema in (resources or {}).items():
|
|
125
|
-
resource_id = str(resource_schema.get("$id", key))
|
|
126
|
-
registry = registry.with_resource(
|
|
127
|
-
resource_id,
|
|
128
|
-
Resource.from_contents(resource_schema, default_specification=DRAFT202012),
|
|
129
|
-
)
|
|
130
|
-
registry = registry.crawl()
|
|
131
|
-
validator = Draft202012Validator(schema, registry=registry)
|
|
206
|
+
validator = _validator_for(schema_yaml_path, strict_extras, resources)
|
|
132
207
|
errors = [
|
|
133
208
|
structural_error_record(
|
|
134
209
|
path=list(error.absolute_path),
|
|
@@ -138,6 +213,8 @@ def validate_structural(
|
|
|
138
213
|
)
|
|
139
214
|
for error in validator.iter_errors(values)
|
|
140
215
|
]
|
|
216
|
+
except _SchemaRootNotAMapping:
|
|
217
|
+
return _schema_invalid("syntax", "compiled schema root must be a mapping")
|
|
141
218
|
except PortableInputError as exc:
|
|
142
219
|
return _schema_invalid("syntax", str(exc))
|
|
143
220
|
except SchemaError as exc:
|
|
@@ -747,8 +824,6 @@ def _portable_error_kind(error: Exception) -> str:
|
|
|
747
824
|
return "yaml_parse_error"
|
|
748
825
|
if error.code == "invalid_utf8":
|
|
749
826
|
return "artifact_invalid_utf8"
|
|
750
|
-
if error.code == "input_too_large":
|
|
751
|
-
return "artifact_too_large"
|
|
752
827
|
return error.code
|
|
753
828
|
|
|
754
829
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
from datetime import date, datetime
|
|
4
5
|
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
5
7
|
|
|
6
8
|
import pytest
|
|
7
9
|
from pydantic import BaseModel, ConfigDict, ValidationError, model_validator
|
|
@@ -13,6 +15,7 @@ from softschema import (
|
|
|
13
15
|
Contracts,
|
|
14
16
|
SchemaProfile,
|
|
15
17
|
SchemaStatus,
|
|
18
|
+
clear_validator_cache,
|
|
16
19
|
compile_model,
|
|
17
20
|
parse_schema_metadata,
|
|
18
21
|
validate_artifact,
|
|
@@ -20,6 +23,7 @@ from softschema import (
|
|
|
20
23
|
validate_structural,
|
|
21
24
|
validate_values,
|
|
22
25
|
)
|
|
26
|
+
from softschema import validate as validate_module
|
|
23
27
|
from softschema._portable import parse_yaml
|
|
24
28
|
|
|
25
29
|
|
|
@@ -73,6 +77,7 @@ def test_package_root_exports_only_the_supported_surface() -> None:
|
|
|
73
77
|
"StructuralResult",
|
|
74
78
|
"ValidationResult",
|
|
75
79
|
"WarningCode",
|
|
80
|
+
"clear_validator_cache",
|
|
76
81
|
"compile_model",
|
|
77
82
|
"infer_envelope_key",
|
|
78
83
|
"parse_schema_metadata",
|
|
@@ -514,8 +519,6 @@ def test_shared_artifact_input_vectors(tmp_path: Path) -> None:
|
|
|
514
519
|
path = tmp_path / f"{case['id']}.yaml"
|
|
515
520
|
if case.get("source") == "invalid_utf8":
|
|
516
521
|
path.write_bytes(b"value: \xff")
|
|
517
|
-
elif case.get("source") == "too_large":
|
|
518
|
-
path.write_bytes(b"x" * 1_048_577)
|
|
519
522
|
elif "text" in case:
|
|
520
523
|
path.write_text(case["text"])
|
|
521
524
|
result = validate_artifact(path, contract=contract)
|
|
@@ -639,3 +642,152 @@ def test_contract_grammar_accepts_valid_ids(contract_id: str) -> None:
|
|
|
639
642
|
def test_contract_grammar_rejects_malformed_ids(contract_id: str) -> None:
|
|
640
643
|
with pytest.raises(ValidationError):
|
|
641
644
|
parse_schema_metadata(contract_id)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _write_schema(path: Path, *, required_field: str) -> None:
|
|
648
|
+
path.write_text(
|
|
649
|
+
"$schema: https://json-schema.org/draft/2020-12/schema\n"
|
|
650
|
+
"type: object\n"
|
|
651
|
+
"properties:\n"
|
|
652
|
+
f" {required_field}:\n"
|
|
653
|
+
" type: string\n"
|
|
654
|
+
"required:\n"
|
|
655
|
+
f" - {required_field}\n",
|
|
656
|
+
encoding="utf-8",
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def test_repeated_validation_reuses_one_compiled_validator(
|
|
661
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
662
|
+
) -> None:
|
|
663
|
+
"""Compiling a schema is pure, so it should happen once per schema file.
|
|
664
|
+
|
|
665
|
+
Recompiling per call — parse, dialect check, enforced overlay, registry
|
|
666
|
+
crawl — dominated large suites well ahead of the actual validation work.
|
|
667
|
+
"""
|
|
668
|
+
clear_validator_cache()
|
|
669
|
+
schema = tmp_path / "sample.schema.yaml"
|
|
670
|
+
_write_schema(schema, required_field="name")
|
|
671
|
+
|
|
672
|
+
parses: list[str] = []
|
|
673
|
+
original = parse_yaml
|
|
674
|
+
|
|
675
|
+
def counting_parse(text: str) -> Any:
|
|
676
|
+
parses.append(text)
|
|
677
|
+
return original(text)
|
|
678
|
+
|
|
679
|
+
monkeypatch.setattr(validate_module, "parse_yaml", counting_parse)
|
|
680
|
+
for _ in range(5):
|
|
681
|
+
assert validate_structural({"name": "ok"}, schema).ok
|
|
682
|
+
|
|
683
|
+
assert parses.count(schema.read_text()) == 1, "compiled schema should be parsed once"
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def test_rewriting_a_schema_is_not_served_from_the_cache(tmp_path: Path) -> None:
|
|
687
|
+
"""A regenerated schema must take effect, never a stale compiled copy."""
|
|
688
|
+
schema = tmp_path / "sample.schema.yaml"
|
|
689
|
+
_write_schema(schema, required_field="name")
|
|
690
|
+
assert validate_structural({"name": "ok"}, schema).ok
|
|
691
|
+
assert not validate_structural({"other": "ok"}, schema).ok
|
|
692
|
+
|
|
693
|
+
_write_schema(schema, required_field="other_name")
|
|
694
|
+
|
|
695
|
+
assert validate_structural({"other_name": "ok"}, schema).ok
|
|
696
|
+
assert not validate_structural({"name": "ok"}, schema).ok
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def test_same_size_rewrite_with_preserved_mtime_is_not_served_from_the_cache(
|
|
700
|
+
tmp_path: Path,
|
|
701
|
+
) -> None:
|
|
702
|
+
"""Content decides the entry, so no stat pair can alias two schemas.
|
|
703
|
+
|
|
704
|
+
A key of (path, mtime, size) served a stale validator here: the rewrite is
|
|
705
|
+
the same length, and restoring the original mtime is what ``cp -p``,
|
|
706
|
+
``rsync -t``, ``tar -x``, and a restored CI cache all do.
|
|
707
|
+
"""
|
|
708
|
+
clear_validator_cache()
|
|
709
|
+
schema = tmp_path / "sample.schema.yaml"
|
|
710
|
+
_write_schema(schema, required_field="aaaa")
|
|
711
|
+
before = schema.stat()
|
|
712
|
+
assert validate_structural({"aaaa": "ok"}, schema).ok
|
|
713
|
+
|
|
714
|
+
_write_schema(schema, required_field="cccc") # identical length
|
|
715
|
+
os.utime(schema, ns=(before.st_mtime_ns, before.st_mtime_ns))
|
|
716
|
+
after = schema.stat()
|
|
717
|
+
assert (after.st_size, after.st_mtime_ns) == (before.st_size, before.st_mtime_ns)
|
|
718
|
+
|
|
719
|
+
assert validate_structural({"cccc": "ok"}, schema).ok
|
|
720
|
+
assert not validate_structural({"aaaa": "ok"}, schema).ok
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def test_identical_schemas_at_two_paths_share_one_entry(
|
|
724
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
725
|
+
) -> None:
|
|
726
|
+
"""Keying on content means a copied schema costs nothing to compile twice."""
|
|
727
|
+
clear_validator_cache()
|
|
728
|
+
first = tmp_path / "first.schema.yaml"
|
|
729
|
+
second = tmp_path / "second.schema.yaml"
|
|
730
|
+
_write_schema(first, required_field="name")
|
|
731
|
+
_write_schema(second, required_field="name")
|
|
732
|
+
|
|
733
|
+
parses: list[str] = []
|
|
734
|
+
original = parse_yaml
|
|
735
|
+
|
|
736
|
+
def counting_parse(text: str) -> Any:
|
|
737
|
+
parses.append(text)
|
|
738
|
+
return original(text)
|
|
739
|
+
|
|
740
|
+
monkeypatch.setattr(validate_module, "parse_yaml", counting_parse)
|
|
741
|
+
assert validate_structural({"name": "ok"}, first).ok
|
|
742
|
+
assert validate_structural({"name": "ok"}, second).ok
|
|
743
|
+
|
|
744
|
+
assert parses.count(first.read_text()) == 1
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def test_clear_validator_cache_forces_a_rebuild(
|
|
748
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
749
|
+
) -> None:
|
|
750
|
+
"""The escape hatch exists for processes that regenerate schemas in place."""
|
|
751
|
+
clear_validator_cache()
|
|
752
|
+
schema = tmp_path / "sample.schema.yaml"
|
|
753
|
+
_write_schema(schema, required_field="name")
|
|
754
|
+
assert validate_structural({"name": "ok"}, schema).ok
|
|
755
|
+
|
|
756
|
+
parses: list[str] = []
|
|
757
|
+
original = parse_yaml
|
|
758
|
+
|
|
759
|
+
def counting_parse(text: str) -> Any:
|
|
760
|
+
parses.append(text)
|
|
761
|
+
return original(text)
|
|
762
|
+
|
|
763
|
+
monkeypatch.setattr(validate_module, "parse_yaml", counting_parse)
|
|
764
|
+
assert validate_structural({"name": "ok"}, schema).ok
|
|
765
|
+
assert parses == [], "a warm entry should not reparse"
|
|
766
|
+
|
|
767
|
+
clear_validator_cache()
|
|
768
|
+
assert validate_structural({"name": "ok"}, schema).ok
|
|
769
|
+
assert parses.count(schema.read_text()) == 1
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def test_strict_extras_is_part_of_the_cache_key(tmp_path: Path) -> None:
|
|
773
|
+
"""The enforced overlay changes the validator, so it cannot share an entry."""
|
|
774
|
+
schema = tmp_path / "sample.schema.yaml"
|
|
775
|
+
_write_schema(schema, required_field="name")
|
|
776
|
+
payload = {"name": "ok", "unexpected": 1}
|
|
777
|
+
|
|
778
|
+
assert validate_structural(payload, schema, strict_extras=False).ok
|
|
779
|
+
assert not validate_structural(payload, schema, strict_extras=True).ok
|
|
780
|
+
# Re-run both to prove neither direction poisoned the other's entry.
|
|
781
|
+
assert validate_structural(payload, schema, strict_extras=False).ok
|
|
782
|
+
assert not validate_structural(payload, schema, strict_extras=True).ok
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def test_a_schema_whose_root_is_not_a_mapping_still_reports_syntax(tmp_path: Path) -> None:
|
|
786
|
+
"""The cached path must preserve the original structured failure."""
|
|
787
|
+
schema = tmp_path / "bad.schema.yaml"
|
|
788
|
+
schema.write_text("- not\n- a mapping\n", encoding="utf-8")
|
|
789
|
+
|
|
790
|
+
result = validate_structural({"name": "ok"}, schema)
|
|
791
|
+
|
|
792
|
+
assert not result.ok
|
|
793
|
+
assert result.errors[0]["kind"] == "schema_invalid"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|