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.
Files changed (58) hide show
  1. {softschema-0.4.0 → softschema-0.5.0}/PKG-INFO +1 -1
  2. {softschema-0.4.0 → softschema-0.5.0}/docs/softschema-python-design.md +10 -1
  3. {softschema-0.4.0 → softschema-0.5.0}/docs/softschema-spec.md +10 -4
  4. {softschema-0.4.0 → softschema-0.5.0}/packages/python/src/softschema/__init__.py +2 -0
  5. {softschema-0.4.0 → softschema-0.5.0}/packages/python/src/softschema/_portable.py +34 -25
  6. {softschema-0.4.0 → softschema-0.5.0}/packages/python/src/softschema/validate.py +95 -20
  7. {softschema-0.4.0 → softschema-0.5.0}/packages/python/tests/test_core.py +154 -2
  8. {softschema-0.4.0 → softschema-0.5.0}/.gitignore +0 -0
  9. {softschema-0.4.0 → softschema-0.5.0}/AGENTS.md +0 -0
  10. {softschema-0.4.0 → softschema-0.5.0}/LICENSE +0 -0
  11. {softschema-0.4.0 → softschema-0.5.0}/README.md +0 -0
  12. {softschema-0.4.0 → softschema-0.5.0}/docs/development.md +0 -0
  13. {softschema-0.4.0 → softschema-0.5.0}/docs/e2e-testing.runbook.md +0 -0
  14. {softschema-0.4.0 → softschema-0.5.0}/docs/installation.md +0 -0
  15. {softschema-0.4.0 → softschema-0.5.0}/docs/publishing-npm.md +0 -0
  16. {softschema-0.4.0 → softschema-0.5.0}/docs/publishing.md +0 -0
  17. {softschema-0.4.0 → softschema-0.5.0}/docs/softschema-guide.md +0 -0
  18. {softschema-0.4.0 → softschema-0.5.0}/docs/softschema-typescript-design.md +0 -0
  19. {softschema-0.4.0 → softschema-0.5.0}/examples/__init__.py +0 -0
  20. {softschema-0.4.0 → softschema-0.5.0}/examples/movie_page/README.md +0 -0
  21. {softschema-0.4.0 → softschema-0.5.0}/examples/movie_page/__init__.py +0 -0
  22. {softschema-0.4.0 → softschema-0.5.0}/examples/movie_page/host_integration.py +0 -0
  23. {softschema-0.4.0 → softschema-0.5.0}/examples/movie_page/model.py +0 -0
  24. {softschema-0.4.0 → softschema-0.5.0}/examples/movie_page/movie-page.schema.yaml +0 -0
  25. {softschema-0.4.0 → softschema-0.5.0}/examples/movie_page/spirited-away.md +0 -0
  26. {softschema-0.4.0 → softschema-0.5.0}/examples/parity/__init__.py +0 -0
  27. {softschema-0.4.0 → softschema-0.5.0}/examples/parity/model.py +0 -0
  28. {softschema-0.4.0 → softschema-0.5.0}/examples/parity/parity.schema.yaml +0 -0
  29. {softschema-0.4.0 → softschema-0.5.0}/packages/python/README.md +0 -0
  30. {softschema-0.4.0 → softschema-0.5.0}/packages/python/src/softschema/canonicalize.py +0 -0
  31. {softschema-0.4.0 → softschema-0.5.0}/packages/python/src/softschema/cli.py +0 -0
  32. {softschema-0.4.0 → softschema-0.5.0}/packages/python/src/softschema/compile.py +0 -0
  33. {softschema-0.4.0 → softschema-0.5.0}/packages/python/src/softschema/errors.py +0 -0
  34. {softschema-0.4.0 → softschema-0.5.0}/packages/python/src/softschema/generate.py +0 -0
  35. {softschema-0.4.0 → softschema-0.5.0}/packages/python/src/softschema/models.py +0 -0
  36. {softschema-0.4.0 → softschema-0.5.0}/packages/python/src/softschema/py.typed +0 -0
  37. {softschema-0.4.0 → softschema-0.5.0}/packages/python/src/softschema/registry.py +0 -0
  38. {softschema-0.4.0 → softschema-0.5.0}/packages/python/src/softschema/schema_view.py +0 -0
  39. {softschema-0.4.0 → softschema-0.5.0}/packages/python/src/softschema/soft_field.py +0 -0
  40. {softschema-0.4.0 → softschema-0.5.0}/packages/python/tests/test_canonicalize.py +0 -0
  41. {softschema-0.4.0 → softschema-0.5.0}/packages/python/tests/test_cli.py +0 -0
  42. {softschema-0.4.0 → softschema-0.5.0}/packages/python/tests/test_doc_topics_resolve.py +0 -0
  43. {softschema-0.4.0 → softschema-0.5.0}/packages/python/tests/test_enforced_extras.py +0 -0
  44. {softschema-0.4.0 → softschema-0.5.0}/packages/python/tests/test_errors.py +0 -0
  45. {softschema-0.4.0 → softschema-0.5.0}/packages/python/tests/test_generate.py +0 -0
  46. {softschema-0.4.0 → softschema-0.5.0}/packages/python/tests/test_metadata_rules.py +0 -0
  47. {softschema-0.4.0 → softschema-0.5.0}/packages/python/tests/test_movie_page_example.py +0 -0
  48. {softschema-0.4.0 → softschema-0.5.0}/packages/python/tests/test_parity.py +0 -0
  49. {softschema-0.4.0 → softschema-0.5.0}/packages/python/tests/test_schema_view.py +0 -0
  50. {softschema-0.4.0 → softschema-0.5.0}/packages/python/tests/test_skill_mirror_drift.py +0 -0
  51. {softschema-0.4.0 → softschema-0.5.0}/packages/python/tests/test_soft_field.py +0 -0
  52. {softschema-0.4.0 → softschema-0.5.0}/packages/python/tests/test_warning_codes.py +0 -0
  53. {softschema-0.4.0 → softschema-0.5.0}/packages/typescript/LICENSE +0 -0
  54. {softschema-0.4.0 → softschema-0.5.0}/packages/typescript/README.md +0 -0
  55. {softschema-0.4.0 → softschema-0.5.0}/pyproject.toml +0 -0
  56. {softschema-0.4.0 → softschema-0.5.0}/skills/softschema/SKILL.md +0 -0
  57. {softschema-0.4.0 → softschema-0.5.0}/tests/golden/README.md +0 -0
  58. {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.4.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 input is at most 1 MiB, one scalar is at most 256 KiB, and one YAML document has at
114
- most 100,000 scalar or collection nodes and 64 simultaneously open collections,
115
- including the root. Exceeding a structure limit is `yaml_limit`, including a host YAML
116
- parser stack overflow caused by hostile nesting.
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 modest UTF-8 file after checking its byte size."""
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[tuple[Any, int]] = [(root, 0)]
117
- nodes = 0
129
+ stack: list[Any] = [root]
118
130
  while stack:
119
- value, depth = stack.pop()
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((item, depth + 1) for item in value)
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((item, depth + 1))
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", "artifact_too_large"}
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
- schema = _read_yaml(schema_yaml_path)
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