dirigent-common 0.27.0__tar.gz → 0.28.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 (29) hide show
  1. {dirigent_common-0.27.0 → dirigent_common-0.28.0}/PKG-INFO +4 -1
  2. {dirigent_common-0.27.0 → dirigent_common-0.28.0}/pyproject.toml +4 -1
  3. {dirigent_common-0.27.0 → dirigent_common-0.28.0}/pyproject.toml.orig +4 -1
  4. {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/__init__.py +26 -1
  5. dirigent_common-0.28.0/src/dirigent_common/bounded_yaml.py +116 -0
  6. dirigent_common-0.28.0/src/dirigent_common/hosts.py +13 -0
  7. {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/messages.py +30 -4
  8. {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/programs.py +6 -1
  9. dirigent_common-0.28.0/src/dirigent_common/redaction.py +64 -0
  10. dirigent_common-0.28.0/src/dirigent_common/schema_references.py +16 -0
  11. dirigent_common-0.28.0/src/dirigent_common/schema_values.py +318 -0
  12. dirigent_common-0.28.0/src/dirigent_common/storable.py +55 -0
  13. {dirigent_common-0.27.0 → dirigent_common-0.28.0}/LICENSE +0 -0
  14. {dirigent_common-0.27.0 → dirigent_common-0.28.0}/README.md +0 -0
  15. {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/clients.py +0 -0
  16. {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/docstrings.py +0 -0
  17. {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/durations.py +0 -0
  18. {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/formats.py +0 -0
  19. {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/formatters.py +0 -0
  20. {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/names.py +0 -0
  21. {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/py.typed +0 -0
  22. {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/requests.py +0 -0
  23. {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/schemas.py +0 -0
  24. {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/sizes.py +0 -0
  25. {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/templating.py +0 -0
  26. {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/types.py +0 -0
  27. {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/uris.py +0 -0
  28. {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/values.py +0 -0
  29. {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/versions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirigent-common
3
- Version: 0.27.0
3
+ Version: 0.28.0
4
4
  Summary: Value types and shared schemas every dirigent package may depend on.
5
5
  License-Expression: LicenseRef-Proprietary
6
6
  License-File: LICENSE
@@ -10,6 +10,9 @@ Requires-Dist: httpx2>=2.12.0
10
10
  Requires-Dist: jinja2>=3.1.6
11
11
  Requires-Dist: jsonschema[format-nongpl]>=4.23.0
12
12
  Requires-Dist: pydantic>=2.13.5
13
+ Requires-Dist: pyyaml>=6.0.3
14
+ Requires-Dist: referencing>=0.37.0
15
+ Requires-Dist: regex>=2026.9.29
13
16
  Requires-Python: >=3.13
14
17
  Description-Content-Type: text/markdown
15
18
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-common"
3
- version = "0.27.0"
3
+ version = "0.28.0"
4
4
  description = "Value types and shared schemas every dirigent package may depend on."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -15,6 +15,9 @@ dependencies = [
15
15
  "jinja2>=3.1.6",
16
16
  "jsonschema[format-nongpl]>=4.23.0",
17
17
  "pydantic>=2.13.5",
18
+ "pyyaml>=6.0.3",
19
+ "referencing>=0.37.0",
20
+ "regex>=2026.9.29",
18
21
  ]
19
22
 
20
23
  [build-system]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-common"
3
- version = "0.27.0"
3
+ version = "0.28.0"
4
4
  description = "Value types and shared schemas every dirigent package may depend on."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -17,6 +17,9 @@ dependencies = [
17
17
  # GPL rfc3987 the plain format extra carries -- safe while the project's own licence is undecided.
18
18
  "jsonschema[format-nongpl]>=4.23.0",
19
19
  "pydantic>=2.13.5",
20
+ "pyyaml>=6.0.3",
21
+ "referencing>=0.37.0",
22
+ "regex>=2026.9.29",
20
23
  ]
21
24
 
22
25
  [build-system]
@@ -5,6 +5,7 @@ more than one package has to agree on: how a document spells a duration or a siz
5
5
  name may be, and the small schemas a plugin and the API both name.
6
6
  """
7
7
 
8
+ from dirigent_common.bounded_yaml import BoundedLoader, YamlExpansionError, bounded_load, bounded_load_all
8
9
  from dirigent_common.clients import BaseRedirect, build_client
9
10
  from dirigent_common.docstrings import as_markdown
10
11
  from dirigent_common.durations import (
@@ -43,7 +44,16 @@ from dirigent_common.names import (
43
44
  is_step_name,
44
45
  step_name_error,
45
46
  )
46
- from dirigent_common.programs import JQ_MEDIA_TYPE, SHELL_MEDIA_TYPE, SQL_MEDIA_TYPE, TEMPLATE_MEDIA_TYPE
47
+ from dirigent_common.programs import (
48
+ COMPOSE_MEDIA_TYPE,
49
+ JQ_MEDIA_TYPE,
50
+ SHELL_MEDIA_TYPE,
51
+ SQL_MEDIA_TYPE,
52
+ TEMPLATE_MEDIA_TYPE,
53
+ )
54
+ from dirigent_common.redaction import REDACTED, scrub, scrub_json, secrets_of, spellings
55
+ from dirigent_common.schema_references import LOCAL_REFERENCES, Unresolvable
56
+ from dirigent_common.schema_values import EvaluationOverrun, ValueValidator, bounded_evaluation
47
57
  from dirigent_common.schemas import BlockModel, HealthReport, HttpConnectionConfig
48
58
  from dirigent_common.sizes import (
49
59
  FILLER,
@@ -62,6 +72,16 @@ from dirigent_common.values import spelled
62
72
  from dirigent_common.versions import API_VERSION
63
73
 
64
74
  __all__ = [
75
+ "LOCAL_REFERENCES",
76
+ "REDACTED",
77
+ "BoundedLoader",
78
+ "EvaluationOverrun",
79
+ "Unresolvable",
80
+ "ValueValidator",
81
+ "bounded_evaluation",
82
+ "YamlExpansionError",
83
+ "bounded_load",
84
+ "bounded_load_all",
65
85
  "base_format_checker",
66
86
  "format_checker_with",
67
87
  "API_VERSION",
@@ -71,6 +91,7 @@ __all__ = [
71
91
  "EMAIL_PATTERN",
72
92
  "ENTITY_NAME_MAX_LENGTH",
73
93
  "ENTITY_NAME_PATTERN",
94
+ "COMPOSE_MEDIA_TYPE",
74
95
  "JQ_MEDIA_TYPE",
75
96
  "SHELL_MEDIA_TYPE",
76
97
  "SQL_MEDIA_TYPE",
@@ -115,7 +136,11 @@ __all__ = [
115
136
  "parse_duration",
116
137
  "parse_size",
117
138
  "render",
139
+ "scrub",
140
+ "scrub_json",
141
+ "secrets_of",
118
142
  "spelled",
143
+ "spellings",
119
144
  "step_name_error",
120
145
  "to_timedelta",
121
146
  "raised_detail",
@@ -0,0 +1,116 @@
1
+ """Reading YAML whose aliases cannot expand past a bound once something writes them out.
2
+
3
+ PyYAML builds an alias as a second reference to one object, so loading a few hundred bytes of
4
+ nested aliases is cheap, and the cost arrives later: a pydantic dump, a digest or ``json.dumps``
5
+ walks every path and expands them to billions of values. So a document is measured before it is
6
+ built, and refused here rather than wherever it is first written out.
7
+ """
8
+
9
+ from typing import Any, Final, cast
10
+
11
+ import yaml
12
+ from pydantic import BaseModel
13
+
14
+ from dirigent_common.messages import (
15
+ YAML_ALIAS_CYCLE,
16
+ YAML_ALIASES_EXPAND_TOO_FAR,
17
+ YAML_ALIASES_WRITE_TOO_MUCH,
18
+ YAML_NESTED_TOO_DEEP,
19
+ )
20
+
21
+ #: How many values a YAML stream's aliases may add once each is written out in place.
22
+ ALIAS_EXPANSION_LIMIT: Final = 100_000
23
+
24
+ #: How many bytes of scalar text a YAML stream's aliases may add once each is written out in place.
25
+ ALIAS_EXPANSION_BYTES: Final = 16 * 1024 * 1024
26
+
27
+
28
+ class YamlExpansionError(yaml.YAMLError):
29
+ """A YAML stream's aliases go past a bound, a node contains itself, or it nests too deeply."""
30
+
31
+
32
+ class _Added(BaseModel):
33
+ """What the aliases of a YAML stream add once written out, across its documents so far."""
34
+
35
+ values: int = 0
36
+ scalar_bytes: int = 0
37
+
38
+
39
+ class BoundedLoader(yaml.SafeLoader):
40
+ """The safe loader, refusing a document before it is built when its aliases expand too far.
41
+
42
+ A document is refused when the aliases of the stream so far would add more than
43
+ ``ALIAS_EXPANSION_LIMIT`` values or ``ALIAS_EXPANSION_BYTES`` bytes of scalar text once
44
+ written out. A document's own text is not counted: it is already in the input.
45
+ """
46
+
47
+ def __init__(self, stream: str) -> None:
48
+ """Start the stream with nothing added by its aliases."""
49
+ super().__init__(stream)
50
+ self.added = _Added()
51
+
52
+ def construct_document(self, node: yaml.Node) -> Any:
53
+ """Build the document once its written-out size is known to be bounded."""
54
+ _hold_expansion(node, self.added)
55
+ return super().construct_document(node) # pyright: ignore[reportUnknownMemberType]
56
+
57
+
58
+ def _scalar_bytes(node: yaml.Node) -> int:
59
+ """How many bytes of text one node itself holds, which is nothing unless it is a scalar."""
60
+ if not isinstance(node, yaml.ScalarNode):
61
+ return 0
62
+ return len(cast("str", node.value).encode(errors="replace"))
63
+
64
+
65
+ def _hold_expansion(root: yaml.Node, added: _Added) -> None:
66
+ """Refuse a node graph whose aliases take the stream past a limit, or that contains itself."""
67
+ sizes: dict[int, tuple[int, int]] = {}
68
+ open_nodes: set[int] = set()
69
+ distinct_bytes = 0
70
+
71
+ def size(node: yaml.Node) -> tuple[int, int]:
72
+ nonlocal distinct_bytes
73
+ key = id(node)
74
+ if key in sizes:
75
+ return sizes[key]
76
+ if key in open_nodes:
77
+ raise YamlExpansionError(YAML_ALIAS_CYCLE.render())
78
+ open_nodes.add(key)
79
+ children: list[yaml.Node] = []
80
+ if isinstance(node, yaml.SequenceNode):
81
+ children = cast("list[yaml.Node]", node.value)
82
+ elif isinstance(node, yaml.MappingNode):
83
+ pairs = cast("list[tuple[yaml.Node, yaml.Node]]", node.value)
84
+ children = [child for pair in pairs for child in pair]
85
+ measured = [size(child) for child in children]
86
+ own = _scalar_bytes(node)
87
+ values = 1 + sum(child[0] for child in measured)
88
+ scalar_bytes = own + sum(child[1] for child in measured)
89
+ open_nodes.discard(key)
90
+ sizes[key] = (values, scalar_bytes)
91
+ distinct_bytes += own
92
+ if added.values + values - len(sizes) > ALIAS_EXPANSION_LIMIT:
93
+ raise YamlExpansionError(YAML_ALIASES_EXPAND_TOO_FAR.render(limit=f"{ALIAS_EXPANSION_LIMIT:,}"))
94
+ if added.scalar_bytes + scalar_bytes - distinct_bytes > ALIAS_EXPANSION_BYTES:
95
+ raise YamlExpansionError(YAML_ALIASES_WRITE_TOO_MUCH.render(limit=f"{ALIAS_EXPANSION_BYTES:,}"))
96
+ return sizes[key]
97
+
98
+ values, scalar_bytes = size(root)
99
+ added.values += values - len(sizes)
100
+ added.scalar_bytes += scalar_bytes - distinct_bytes
101
+
102
+
103
+ def bounded_load(text: str, loader: type[BoundedLoader] = BoundedLoader) -> Any:
104
+ """Read one YAML document, refusing what expands too far or nests past Python's own stack."""
105
+ try:
106
+ return yaml.load(text, Loader=loader) # noqa: S506 - a BoundedLoader is a SafeLoader
107
+ except RecursionError as error:
108
+ raise YamlExpansionError(YAML_NESTED_TOO_DEEP.render()) from error
109
+
110
+
111
+ def bounded_load_all(text: str, loader: type[BoundedLoader] = BoundedLoader) -> list[Any]:
112
+ """Read every document of a YAML stream under one bound, refusing as ``bounded_load`` does."""
113
+ try:
114
+ return list(yaml.load_all(text, Loader=loader)) # noqa: S506 - a BoundedLoader is a SafeLoader
115
+ except RecursionError as error:
116
+ raise YamlExpansionError(YAML_NESTED_TOO_DEEP.render()) from error
@@ -0,0 +1,13 @@
1
+ """What a host name says about where it reaches."""
2
+
3
+ import ipaddress
4
+
5
+
6
+ def is_loopback_host(host: str) -> bool:
7
+ """Whether a host names this machine alone: ``localhost`` or a loopback address, and nothing else."""
8
+ if host == "localhost":
9
+ return True
10
+ try:
11
+ return ipaddress.ip_address(host).is_loopback
12
+ except ValueError:
13
+ return False
@@ -6,12 +6,13 @@ code and render the same refusal in another language without anything else chang
6
6
  """
7
7
 
8
8
  import re
9
- from collections.abc import Mapping, Sequence
9
+ from collections.abc import Collection, Mapping, Sequence
10
10
  from string import Formatter
11
11
  from typing import Any, ClassVar, Final, Self
12
12
 
13
13
  from pydantic import BaseModel, ConfigDict, Field
14
14
 
15
+ from dirigent_common.redaction import scrub
15
16
  from dirigent_common.types import JsonMap
16
17
 
17
18
  #: A message name: lowercase words joined by underscores, and dotted where a family of
@@ -155,6 +156,25 @@ TEMPLATE_FAILED = COMMON.define("template_failed", "{detail}")
155
156
 
156
157
  NO_JSON_SPELLING = COMMON.define("no_json_spelling", "a value of type {kind} has no JSON spelling")
157
158
 
159
+ YAML_ALIASES_EXPAND_TOO_FAR = COMMON.define(
160
+ "yaml_aliases_expand_too_far",
161
+ "the YAML's aliases would add more than {limit} values once each is written out; "
162
+ "write the repeated parts out in the source, or repeat them less",
163
+ )
164
+
165
+ YAML_ALIASES_WRITE_TOO_MUCH = COMMON.define(
166
+ "yaml_aliases_write_too_much",
167
+ "the YAML's aliases would add more than {limit} bytes of text once each is written out; "
168
+ "write the repeated parts out in the source, or repeat them less",
169
+ )
170
+
171
+ YAML_ALIAS_CYCLE = COMMON.define(
172
+ "yaml_alias_cycle",
173
+ "the YAML holds an alias inside the node it names, and a value cannot be written out that contains itself",
174
+ )
175
+
176
+ YAML_NESTED_TOO_DEEP = COMMON.define("yaml_nested_too_deep", "the YAML nests too deeply to be read")
177
+
158
178
  VALIDATION = Catalogue("validation")
159
179
 
160
180
  #: Pydantic names the condition, so the second half of a validation code is its own error
@@ -166,6 +186,11 @@ _PYDANTIC_TEXT: Final = "{msg}"
166
186
  #: closest to when the document format knows one.
167
187
  EXTRA_FORBIDDEN = VALIDATION.define("extra_forbidden", "unknown key{suggestion}")
168
188
 
189
+ #: Text a caller wrote that no database stores as written, refused where it arrives.
190
+ TEXT_UNSTORABLE = VALIDATION.define(
191
+ "text_unstorable", "holds a NUL character or half a surrogate pair, which cannot be stored; remove it"
192
+ )
193
+
169
194
 
170
195
  def _validation_message(error_type: str) -> Message:
171
196
  """Name the message one pydantic error type renders through, minting it on first sight."""
@@ -198,15 +223,16 @@ def validation_issues(errors: Sequence[Mapping[str, Any]]) -> list[Issue]:
198
223
  RAISED_DETAIL_CHARS: Final = 2000
199
224
 
200
225
 
201
- def raised_detail(error: Exception) -> str:
226
+ def raised_detail(error: Exception, *, redact: Collection[str] = ()) -> str:
202
227
  """Say what an exception said about itself, falling back to its class when it said nothing.
203
228
 
204
229
  A Python class name is not a sentence anybody wrote, so it is the last thing left to say
205
230
  rather than the first thing said. What comes back is an operator's detail: it belongs in a
206
231
  log, in a refusal's params, or in the ``{detail}`` of a message that supplies the sentence
207
- around it, and never alone in front of a person.
232
+ around it, and never alone in front of a person. ``redact`` is scrubbed before the cut, which
233
+ would otherwise leave a long secret too short to be found.
208
234
  """
209
- detail = str(error).strip() or type(error).__name__
235
+ detail = scrub(str(error).strip(), redact) or type(error).__name__
210
236
  if len(detail) > RAISED_DETAIL_CHARS:
211
237
  return f"{detail[:RAISED_DETAIL_CHARS]} [truncated, {len(detail)} characters]"
212
238
  return detail
@@ -11,5 +11,10 @@ SHELL_MEDIA_TYPE: Final = "text/x-shellscript"
11
11
  #: A Jinja template rendering text, as the run report and ``report.render`` take one.
12
12
  TEMPLATE_MEDIA_TYPE: Final = "text/x-jinja"
13
13
 
14
- #: One SQL statement, as ``sql.query`` takes one.
14
+ #: A compose file, as ``docker.compose.up`` and ``docker.compose.down`` take one inline. A field
15
+ #: publishing this takes no ``${...}`` reference, at apply or at run.
16
+ COMPOSE_MEDIA_TYPE: Final = "application/x-compose+yaml"
17
+
18
+ #: One SQL statement, as ``sql.query`` and ``sql.execute`` take one. A field publishing this
19
+ #: takes no ``${...}`` reference, at apply or at run.
15
20
  SQL_MEDIA_TYPE: Final = "application/sql"
@@ -0,0 +1,64 @@
1
+ """Keeping a sealed value out of text somebody else reads.
2
+
3
+ An exception says what it was handed, and an HTTP library refusing a header value says it whole.
4
+ Whatever writes such text beside a viewer's reach scrubs it first, with every spelling the value
5
+ takes once a library has quoted it.
6
+ """
7
+
8
+ from collections.abc import Collection
9
+ from typing import Final
10
+
11
+ from pydantic import BaseModel, JsonValue, SecretStr
12
+
13
+ #: What a scrubbed secret is replaced by, matching the marker the API redacts a connection with.
14
+ REDACTED: Final = "***"
15
+
16
+
17
+ def spellings(secret: str) -> set[str]:
18
+ """Name every way a secret reads in an error: as it is, trimmed, and escaped as ``repr`` escapes it."""
19
+ written = {
20
+ secret,
21
+ secret.strip(),
22
+ repr(secret)[1:-1],
23
+ repr(secret.strip())[1:-1],
24
+ repr(secret.encode("utf-8", "surrogateescape"))[2:-1],
25
+ }
26
+ return {one for one in written if one}
27
+
28
+
29
+ def secrets_of(config: BaseModel) -> set[str]:
30
+ """Name every spelling of every secret an opened config holds."""
31
+ found: set[str] = set()
32
+ for name in type(config).model_fields:
33
+ value = getattr(config, name, None)
34
+ if isinstance(value, SecretStr):
35
+ found |= spellings(value.get_secret_value())
36
+ return found
37
+
38
+
39
+ def scrub(text: str, secrets: Collection[str] = ()) -> str:
40
+ """Replace every occurrence of a secret with the redaction marker.
41
+
42
+ A tool prints what it was given. ``git`` echoes a remote URL, an ssh command line and,
43
+ when a credential is wrong, sometimes the credential itself, so a block that hands a
44
+ process a secret hands this the same strings and nothing reaches a log or a failure
45
+ message with them still in it. Longest first, so a secret that contains another is
46
+ replaced whole rather than left in pieces.
47
+ """
48
+ for secret in sorted((one for one in secrets if one), key=len, reverse=True):
49
+ text = text.replace(secret, REDACTED)
50
+ return text
51
+
52
+
53
+ def scrub_json(value: JsonValue, secrets: Collection[str]) -> JsonValue:
54
+ """Scrub every string in a JSON value, its keys included."""
55
+ if isinstance(value, str):
56
+ return scrub(value, secrets)
57
+ if isinstance(value, list):
58
+ return [scrub_json(element, secrets) for element in value]
59
+ if isinstance(value, dict):
60
+ return {scrub(key, secrets): scrub_json(element, secrets) for key, element in value.items()}
61
+ return value
62
+
63
+
64
+ __all__ = ["REDACTED", "scrub", "scrub_json", "secrets_of", "spellings"]
@@ -0,0 +1,16 @@
1
+ """Where a JSON Schema ``$ref`` may lead: into its own document and the drafts the library ships.
2
+
3
+ A validator built without a registry retrieves a ``$ref`` it cannot resolve over HTTP, on
4
+ whichever process is validating. A schema is written by whoever can apply a document, so that
5
+ is a request from the server's network to an address they chose. Every validator here is built
6
+ with this registry, which retrieves nothing; the library adds its bundled metaschemas to it.
7
+ """
8
+
9
+ from typing import Any, Final
10
+
11
+ from referencing import Registry
12
+ from referencing.exceptions import Unresolvable
13
+
14
+ LOCAL_REFERENCES: Final[Registry[Any]] = Registry()
15
+
16
+ __all__ = ["LOCAL_REFERENCES", "Unresolvable"]
@@ -0,0 +1,318 @@
1
+ """The validator a run's values are checked with, and the bound every evaluation runs under.
2
+
3
+ Draft 2020-12, saying what the library says, with these differences in how:
4
+
5
+ * ``uniqueItems`` compares each element's canonical text through a set, under JSON Schema's rule
6
+ of equality: ``1`` and ``1.0`` are equal, ``true`` and ``1`` are not, and an object's keys have
7
+ no order.
8
+ * ``unevaluatedItems`` and ``unevaluatedProperties`` look up what was evaluated in a set.
9
+ * ``pattern``, ``patternProperties``, and the patterns ``additionalProperties`` and
10
+ ``unevaluatedProperties`` read match with the ``regex`` package in its ``re``-compatible mode,
11
+ which releases the interpreter's lock while it matches and stops at the evaluation's deadline.
12
+ * Every keyword checks the bound :func:`bounded_evaluation` sets before it runs, and raises
13
+ :class:`EvaluationOverrun` once the deadline has passed or the caller has stopped waiting.
14
+
15
+ Outside :func:`bounded_evaluation` nothing is bounded.
16
+ """
17
+
18
+ import json
19
+ import threading
20
+ import time
21
+ from collections.abc import Callable, Generator, Iterator
22
+ from contextlib import contextmanager
23
+ from contextvars import ContextVar
24
+ from datetime import timedelta
25
+ from functools import lru_cache
26
+ from typing import Any, Final
27
+
28
+ import regex
29
+ from jsonschema import Draft202012Validator, ValidationError
30
+ from jsonschema.validators import extend as extend_validator # pyright: ignore[reportUnknownVariableType]
31
+ from pydantic import JsonValue
32
+
33
+ from dirigent_common.durations import format_duration
34
+
35
+ #: The keyword signature the library calls: validator, the keyword's value, instance, schema.
36
+ type Keyword = Callable[[Any, Any, Any, Any], Any]
37
+
38
+
39
+ class EvaluationOverrun(Exception):
40
+ """An evaluation that passed its deadline, or whose caller stopped waiting for it."""
41
+
42
+ def __init__(self, within: timedelta) -> None:
43
+ """Name the bound the evaluation passed."""
44
+ super().__init__(f"the evaluation did not finish within {format_duration(within)}")
45
+ self.within = within
46
+
47
+
48
+ class _Bound:
49
+ """When the current evaluation must stop, and the event that stops it sooner."""
50
+
51
+ __slots__ = ("deadline", "stopped", "within")
52
+
53
+ def __init__(self, within: timedelta, stopped: threading.Event | None) -> None:
54
+ self.within = within
55
+ self.deadline = time.monotonic() + within.total_seconds()
56
+ self.stopped = stopped
57
+
58
+
59
+ _BOUND: ContextVar[_Bound | None] = ContextVar("dirigent_schema_value_bound", default=None)
60
+
61
+
62
+ @contextmanager
63
+ def bounded_evaluation(within: timedelta, stopped: threading.Event | None = None) -> Generator[None]:
64
+ """Bound every evaluation inside the block to ``within``, and to ``stopped`` staying unset.
65
+
66
+ The bound is held in a context variable, so it covers the thread or task that entered the
67
+ block and nothing else.
68
+ """
69
+ token = _BOUND.set(_Bound(within, stopped))
70
+ try:
71
+ yield
72
+ finally:
73
+ _BOUND.reset(token)
74
+
75
+
76
+ def _remaining(bound: _Bound | None) -> float | None:
77
+ """Seconds an evaluation under ``bound`` has left, or None when it is unbounded.
78
+
79
+ Raises:
80
+ EvaluationOverrun: The deadline has passed, or the caller has stopped waiting.
81
+ """
82
+ if bound is None:
83
+ return None
84
+ left = bound.deadline - time.monotonic()
85
+ if left <= 0 or (bound.stopped is not None and bound.stopped.is_set()):
86
+ raise EvaluationOverrun(bound.within)
87
+ return left
88
+
89
+
90
+ @lru_cache(maxsize=512)
91
+ def _compiled(pattern: str) -> "regex.Pattern[str]":
92
+ """Compile a schema's pattern with ``re``'s syntax and meaning."""
93
+ return regex.compile(pattern, regex.VERSION0)
94
+
95
+
96
+ def _found(pattern: str, text: str) -> bool:
97
+ """Whether ``pattern`` matches anywhere in ``text``, within what the evaluation has left.
98
+
99
+ The match releases the interpreter's lock while it runs.
100
+ """
101
+ bound = _BOUND.get()
102
+ left = _remaining(bound)
103
+ try:
104
+ return _compiled(pattern).search(text, concurrent=True, timeout=left) is not None
105
+ except TimeoutError as error:
106
+ raise EvaluationOverrun(bound.within if bound is not None else timedelta(0)) from error
107
+
108
+
109
+ def canonical_json(value: JsonValue) -> str:
110
+ """Write a JSON value so two values have the same text exactly when JSON Schema calls them equal."""
111
+ if isinstance(value, float) and value.is_integer():
112
+ return str(int(value))
113
+ if isinstance(value, list):
114
+ return "[" + ",".join(canonical_json(element) for element in value) + "]"
115
+ if isinstance(value, dict):
116
+ members = sorted(f"{json.dumps(key)}:{canonical_json(element)}" for key, element in value.items())
117
+ return "{" + ",".join(members) + "}"
118
+ return json.dumps(value)
119
+
120
+
121
+ def _extras(extras: list[Any]) -> tuple[str, str]:
122
+ """The library's wording for the items or properties a keyword did not expect."""
123
+ return ", ".join(repr(extra) for extra in extras), "was" if len(extras) == 1 else "were"
124
+
125
+
126
+ def _unique_items(validator: Any, wanted: Any, instance: Any, schema: Any) -> Iterator[ValidationError]:
127
+ """Say what the library's ``uniqueItems`` says, comparing through a set of canonical text."""
128
+ if not wanted or not validator.is_type(instance, "array"):
129
+ return
130
+ seen: set[str] = set()
131
+ for element in instance:
132
+ text = canonical_json(element)
133
+ if text in seen:
134
+ yield ValidationError(f"{instance!r} has non-unique elements")
135
+ return
136
+ seen.add(text)
137
+
138
+
139
+ def _pattern(validator: Any, pattern: str, instance: Any, schema: Any) -> Iterator[ValidationError]:
140
+ """Say what the library's ``pattern`` says."""
141
+ if validator.is_type(instance, "string") and not _found(pattern, instance):
142
+ yield ValidationError(f"{instance!r} does not match {pattern!r}")
143
+
144
+
145
+ def _pattern_properties(validator: Any, patterns: Any, instance: Any, schema: Any) -> Iterator[ValidationError]:
146
+ """Say what the library's ``patternProperties`` says."""
147
+ if not validator.is_type(instance, "object"):
148
+ return
149
+ for pattern, subschema in patterns.items():
150
+ for key, value in instance.items():
151
+ if _found(pattern, key):
152
+ yield from validator.descend(value, subschema, path=key, schema_path=pattern)
153
+
154
+
155
+ def _additional(instance: Any, schema: Any) -> list[str]:
156
+ """The keys of ``instance`` neither ``properties`` nor ``patternProperties`` names, in order."""
157
+ properties = schema.get("properties", {})
158
+ patterns = list(schema.get("patternProperties", {}))
159
+ return [key for key in instance if key not in properties and not any(_found(pattern, key) for pattern in patterns)]
160
+
161
+
162
+ def _additional_properties(validator: Any, extra: Any, instance: Any, schema: Any) -> Iterator[ValidationError]:
163
+ """Say what the library's ``additionalProperties`` says."""
164
+ if not validator.is_type(instance, "object"):
165
+ return
166
+ extras = set(_additional(instance, schema))
167
+ if validator.is_type(extra, "object"):
168
+ for key in extras:
169
+ yield from validator.descend(instance[key], extra, path=key)
170
+ elif not extra and extras:
171
+ if "patternProperties" in schema:
172
+ verb = "does" if len(extras) == 1 else "do"
173
+ joined = ", ".join(repr(key) for key in sorted(extras))
174
+ patterns = ", ".join(repr(pattern) for pattern in sorted(schema["patternProperties"]))
175
+ yield ValidationError(f"{joined} {verb} not match any of the regexes: {patterns}")
176
+ else:
177
+ listed, verb = _extras(sorted(extras, key=str))
178
+ yield ValidationError(f"Additional properties are not allowed ({listed} {verb} unexpected)")
179
+
180
+
181
+ def _referenced(validator: Any, schema: Any) -> Iterator[tuple[Any, Any]]:
182
+ """The validator and schema each of ``$ref`` and ``$dynamicRef`` in ``schema`` leads to."""
183
+ for keyword in ("$ref", "$dynamicRef"):
184
+ reference = schema.get(keyword)
185
+ if reference is not None:
186
+ resolved = validator._resolver.lookup(reference)
187
+ yield validator.evolve(schema=resolved.contents, _resolver=resolved.resolver), resolved.contents
188
+
189
+
190
+ def _is_valid(errors: Iterator[ValidationError]) -> bool:
191
+ """Whether an iterator of errors yields none."""
192
+ return next(errors, None) is None
193
+
194
+
195
+ def _evaluated_indexes(validator: Any, instance: Any, schema: Any) -> set[int]:
196
+ """The indexes of ``instance`` that ``schema`` evaluates, as the library finds them."""
197
+ if validator.is_type(schema, "boolean"):
198
+ return set()
199
+ if "items" in schema:
200
+ return set(range(len(instance)))
201
+ evaluated: set[int] = set()
202
+ for referred, contents in _referenced(validator, schema):
203
+ evaluated |= _evaluated_indexes(referred, instance, contents)
204
+ if "prefixItems" in schema:
205
+ evaluated.update(range(len(schema["prefixItems"])))
206
+ if "if" in schema:
207
+ if validator.evolve(schema=schema["if"]).is_valid(instance):
208
+ evaluated |= _evaluated_indexes(validator, instance, schema["if"])
209
+ if "then" in schema:
210
+ evaluated |= _evaluated_indexes(validator, instance, schema["then"])
211
+ elif "else" in schema:
212
+ evaluated |= _evaluated_indexes(validator, instance, schema["else"])
213
+ for keyword in ("contains", "unevaluatedItems"):
214
+ if keyword in schema:
215
+ checker = validator.evolve(schema=schema[keyword])
216
+ evaluated.update(index for index, item in enumerate(instance) if checker.is_valid(item))
217
+ for keyword in ("allOf", "oneOf", "anyOf"):
218
+ for subschema in schema.get(keyword, ()):
219
+ if _is_valid(validator.descend(instance, subschema)):
220
+ evaluated |= _evaluated_indexes(validator, instance, subschema)
221
+ return evaluated
222
+
223
+
224
+ def _evaluated_keys(validator: Any, instance: Any, schema: Any) -> set[str]:
225
+ """The keys of ``instance`` that ``schema`` evaluates, as the library finds them."""
226
+ if validator.is_type(schema, "boolean"):
227
+ return set()
228
+ evaluated: set[str] = set()
229
+ for referred, contents in _referenced(validator, schema):
230
+ evaluated |= _evaluated_keys(referred, instance, contents)
231
+ properties = schema.get("properties")
232
+ if validator.is_type(properties, "object"):
233
+ evaluated |= properties.keys() & instance.keys()
234
+ for keyword in ("additionalProperties", "unevaluatedProperties"):
235
+ subschema = schema.get(keyword)
236
+ if subschema is not None:
237
+ evaluated.update(key for key, value in instance.items() if _is_valid(validator.descend(value, subschema)))
238
+ if "patternProperties" in schema:
239
+ patterns = list(schema["patternProperties"])
240
+ evaluated.update(key for key in instance if any(_found(pattern, key) for pattern in patterns))
241
+ for key, subschema in schema.get("dependentSchemas", {}).items():
242
+ if key in instance:
243
+ evaluated |= _evaluated_keys(validator, instance, subschema)
244
+ for keyword in ("allOf", "oneOf", "anyOf"):
245
+ for subschema in schema.get(keyword, ()):
246
+ if _is_valid(validator.descend(instance, subschema)):
247
+ evaluated |= _evaluated_keys(validator, instance, subschema)
248
+ if "if" in schema:
249
+ if validator.evolve(schema=schema["if"]).is_valid(instance):
250
+ evaluated |= _evaluated_keys(validator, instance, schema["if"])
251
+ if "then" in schema:
252
+ evaluated |= _evaluated_keys(validator, instance, schema["then"])
253
+ elif "else" in schema:
254
+ evaluated |= _evaluated_keys(validator, instance, schema["else"])
255
+ return evaluated
256
+
257
+
258
+ def _unevaluated_items(validator: Any, unevaluated: Any, instance: Any, schema: Any) -> Iterator[ValidationError]:
259
+ """Say what the library's ``unevaluatedItems`` says."""
260
+ if not validator.is_type(instance, "array"):
261
+ return
262
+ evaluated = _evaluated_indexes(validator, instance, schema)
263
+ left = [item for index, item in enumerate(instance) if index not in evaluated]
264
+ if left:
265
+ listed, verb = _extras(left)
266
+ yield ValidationError(f"Unevaluated items are not allowed ({listed} {verb} unexpected)")
267
+
268
+
269
+ def _unevaluated_properties(validator: Any, unevaluated: Any, instance: Any, schema: Any) -> Iterator[ValidationError]:
270
+ """Say what the library's ``unevaluatedProperties`` says."""
271
+ if not validator.is_type(instance, "object"):
272
+ return
273
+ evaluated = _evaluated_keys(validator, instance, schema)
274
+ refused: list[str] = []
275
+ for key in instance:
276
+ if key not in evaluated:
277
+ refused.extend(key for _ in validator.descend(instance[key], unevaluated, path=key, schema_path=key))
278
+ if not refused:
279
+ return
280
+ if unevaluated is False:
281
+ listed, verb = _extras(sorted(refused, key=str))
282
+ yield ValidationError(f"Unevaluated properties are not allowed ({listed} {verb} unexpected)")
283
+ else:
284
+ listed, verb = _extras(refused)
285
+ yield ValidationError(
286
+ f"Unevaluated properties are not valid under the given schema ({listed} {verb} unevaluated and invalid)"
287
+ )
288
+
289
+
290
+ def _watched(keyword: Keyword) -> Keyword:
291
+ """Run ``keyword`` only while the evaluation's bound holds."""
292
+
293
+ def watched(validator: Any, value: Any, instance: Any, schema: Any) -> Any:
294
+ _remaining(_BOUND.get())
295
+ return keyword(validator, value, instance, schema)
296
+
297
+ return watched
298
+
299
+
300
+ _KEYWORDS: Final[dict[str, Keyword]] = {
301
+ **Draft202012Validator.VALIDATORS,
302
+ "additionalProperties": _additional_properties,
303
+ "pattern": _pattern,
304
+ "patternProperties": _pattern_properties,
305
+ "unevaluatedItems": _unevaluated_items,
306
+ "unevaluatedProperties": _unevaluated_properties,
307
+ "uniqueItems": _unique_items,
308
+ }
309
+
310
+ #: Draft 2020-12 with those keywords. Naming the version also makes this the class a subschema
311
+ #: naming that ``$schema`` resolves to, which a ``$ref`` back to the root would be.
312
+ ValueValidator: Final[Any] = extend_validator( # pyright: ignore[reportUnknownVariableType]
313
+ Draft202012Validator,
314
+ {name: _watched(keyword) for name, keyword in _KEYWORDS.items()},
315
+ version="draft2020-12",
316
+ )
317
+
318
+ __all__ = ["EvaluationOverrun", "ValueValidator", "bounded_evaluation", "canonical_json"]
@@ -0,0 +1,55 @@
1
+ """Text a database stores as written: no NUL, and no half of a surrogate pair.
2
+
3
+ PostgreSQL refuses a NUL in text and in JSONB, and no driver encodes half a pair, so text a
4
+ caller writes holding either is refused where it arrives rather than when it is written.
5
+ """
6
+
7
+ import re
8
+ from typing import Final, cast
9
+
10
+ from pydantic_core import PydanticCustomError
11
+
12
+ from dirigent_common.messages import TEXT_UNSTORABLE
13
+
14
+ #: The pydantic error type the refusal is raised as, which names its catalogued message.
15
+ UNSTORABLE_KIND: Final = "text_unstorable"
16
+
17
+ #: A NUL, or half a surrogate pair, which JSON and YAML escapes both reach.
18
+ UNSTORABLE: Final = re.compile("[\x00\ud800-\udfff]")
19
+
20
+
21
+ def is_storable(text: str) -> bool:
22
+ """Report whether a database stores this text as written."""
23
+ return UNSTORABLE.search(text) is None
24
+
25
+
26
+ def refuse_unstorable[T](value: T) -> T:
27
+ """Pass a value through, refusing text no database stores as written."""
28
+ if isinstance(value, str) and not is_storable(value):
29
+ raise PydanticCustomError(UNSTORABLE_KIND, "{sentence}", {"sentence": TEXT_UNSTORABLE.text})
30
+ return value
31
+
32
+
33
+ def unstorable_at(value: object, at: tuple[str | int, ...] = ()) -> list[str]:
34
+ """Name every place in a parsed document whose key or text no database stores as written.
35
+
36
+ A key that holds one is named by the mapping it is in, and what is under it is not read.
37
+ """
38
+ if isinstance(value, str):
39
+ return [] if is_storable(value) else [_where(at)]
40
+ found: list[str] = []
41
+ if isinstance(value, dict):
42
+ for key, inner in cast("dict[object, object]", value).items():
43
+ if not is_storable(str(key)):
44
+ found.append(_where(at))
45
+ continue
46
+ found.extend(unstorable_at(inner, (*at, str(key))))
47
+ elif isinstance(value, list):
48
+ for index, inner in enumerate(cast("list[object]", value)):
49
+ found.extend(unstorable_at(inner, (*at, index)))
50
+ return found
51
+
52
+
53
+ def _where(at: tuple[str | int, ...]) -> str:
54
+ """Spell a place in a document the way a validation issue does."""
55
+ return ".".join(str(part) for part in at) or "(document)"