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.
- {dirigent_common-0.27.0 → dirigent_common-0.28.0}/PKG-INFO +4 -1
- {dirigent_common-0.27.0 → dirigent_common-0.28.0}/pyproject.toml +4 -1
- {dirigent_common-0.27.0 → dirigent_common-0.28.0}/pyproject.toml.orig +4 -1
- {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/__init__.py +26 -1
- dirigent_common-0.28.0/src/dirigent_common/bounded_yaml.py +116 -0
- dirigent_common-0.28.0/src/dirigent_common/hosts.py +13 -0
- {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/messages.py +30 -4
- {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/programs.py +6 -1
- dirigent_common-0.28.0/src/dirigent_common/redaction.py +64 -0
- dirigent_common-0.28.0/src/dirigent_common/schema_references.py +16 -0
- dirigent_common-0.28.0/src/dirigent_common/schema_values.py +318 -0
- dirigent_common-0.28.0/src/dirigent_common/storable.py +55 -0
- {dirigent_common-0.27.0 → dirigent_common-0.28.0}/LICENSE +0 -0
- {dirigent_common-0.27.0 → dirigent_common-0.28.0}/README.md +0 -0
- {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/clients.py +0 -0
- {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/docstrings.py +0 -0
- {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/durations.py +0 -0
- {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/formats.py +0 -0
- {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/formatters.py +0 -0
- {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/names.py +0 -0
- {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/py.typed +0 -0
- {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/requests.py +0 -0
- {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/schemas.py +0 -0
- {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/sizes.py +0 -0
- {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/templating.py +0 -0
- {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/types.py +0 -0
- {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/uris.py +0 -0
- {dirigent_common-0.27.0 → dirigent_common-0.28.0}/src/dirigent_common/values.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
#:
|
|
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)"
|
|
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
|