dirigent-common 0.28.0__tar.gz → 0.29.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.28.0 → dirigent_common-0.29.0}/PKG-INFO +1 -1
- {dirigent_common-0.28.0 → dirigent_common-0.29.0}/pyproject.toml +1 -1
- {dirigent_common-0.28.0 → dirigent_common-0.29.0}/pyproject.toml.orig +1 -1
- dirigent_common-0.29.0/src/dirigent_common/depth.py +59 -0
- dirigent_common-0.29.0/src/dirigent_common/formats.py +223 -0
- {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/messages.py +2 -0
- {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/schema_values.py +22 -2
- dirigent_common-0.28.0/src/dirigent_common/formats.py +0 -109
- {dirigent_common-0.28.0 → dirigent_common-0.29.0}/LICENSE +0 -0
- {dirigent_common-0.28.0 → dirigent_common-0.29.0}/README.md +0 -0
- {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/__init__.py +0 -0
- {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/bounded_yaml.py +0 -0
- {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/clients.py +0 -0
- {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/docstrings.py +0 -0
- {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/durations.py +0 -0
- {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/formatters.py +0 -0
- {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/hosts.py +0 -0
- {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/names.py +0 -0
- {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/programs.py +0 -0
- {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/py.typed +0 -0
- {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/redaction.py +0 -0
- {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/requests.py +0 -0
- {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/schema_references.py +0 -0
- {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/schemas.py +0 -0
- {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/sizes.py +0 -0
- {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/storable.py +0 -0
- {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/templating.py +0 -0
- {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/types.py +0 -0
- {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/uris.py +0 -0
- {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/values.py +0 -0
- {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/versions.py +0 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""How deep a JSON value a run stores may nest, and how a deeper one is shown.
|
|
2
|
+
|
|
3
|
+
Pydantic walks a value 255 levels down and no further, and every read nests a stored value a
|
|
4
|
+
few levels deeper again: in a page, a report, an alert's context or a resolved config. A value
|
|
5
|
+
past :data:`MAX_STORED_DEPTH` is refused where it would be stored, and one stored before that
|
|
6
|
+
bound held is shown cut at it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Final, cast
|
|
10
|
+
|
|
11
|
+
from pydantic import JsonValue
|
|
12
|
+
|
|
13
|
+
from dirigent_common.messages import VALUE_NOT_SHOWN
|
|
14
|
+
|
|
15
|
+
#: The most levels of lists and objects a stored value may nest: a step's output, a source's
|
|
16
|
+
#: input, a run's parameters. Above the 128 an endpoint body may nest, and well inside the 255
|
|
17
|
+
#: pydantic walks.
|
|
18
|
+
MAX_STORED_DEPTH: Final = 160
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def nests_past(value: JsonValue, limit: int = MAX_STORED_DEPTH) -> bool:
|
|
22
|
+
"""Say whether a value holds a list or object more than ``limit`` levels down.
|
|
23
|
+
|
|
24
|
+
The value itself is the first level, so a value nesting exactly ``limit`` levels passes.
|
|
25
|
+
"""
|
|
26
|
+
level = [value]
|
|
27
|
+
for _ in range(limit):
|
|
28
|
+
level = [
|
|
29
|
+
child
|
|
30
|
+
for held in level
|
|
31
|
+
if isinstance(held, dict | list)
|
|
32
|
+
for child in (held.values() if isinstance(held, dict) else held)
|
|
33
|
+
]
|
|
34
|
+
if not level:
|
|
35
|
+
return False
|
|
36
|
+
return any(isinstance(held, dict | list) for held in level)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def shown[V: JsonValue](value: V, limit: int = MAX_STORED_DEPTH) -> V:
|
|
40
|
+
"""Return a value as it is, or when it nests past ``limit``, a copy cut there.
|
|
41
|
+
|
|
42
|
+
Each list or object more than ``limit`` levels down is replaced by the sentence
|
|
43
|
+
:data:`VALUE_NOT_SHOWN` says.
|
|
44
|
+
"""
|
|
45
|
+
if not nests_past(value, limit):
|
|
46
|
+
return value
|
|
47
|
+
marker = VALUE_NOT_SHOWN.render(limit=limit)
|
|
48
|
+
return cast("V", _cut(value, limit, marker))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _cut(value: JsonValue, left: int, marker: str) -> JsonValue:
|
|
52
|
+
"""Copy a value down to ``left`` levels, putting ``marker`` where a deeper list or object was."""
|
|
53
|
+
if not isinstance(value, dict | list):
|
|
54
|
+
return value
|
|
55
|
+
if left <= 0:
|
|
56
|
+
return marker
|
|
57
|
+
if isinstance(value, dict):
|
|
58
|
+
return {key: _cut(inner, left - 1, marker) for key, inner in value.items()}
|
|
59
|
+
return [_cut(inner, left - 1, marker) for inner in value]
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""The base JSON Schema format checker every validation in dirigent draws from.
|
|
2
|
+
|
|
3
|
+
A JSON Schema ``format`` keyword only asserts when a validator is given a checker for it;
|
|
4
|
+
without one it is an annotation the value ignores. This is the one checker the engine and
|
|
5
|
+
the blocks share, so ``format: date-time``, ``uuid`` and the like actually gate -- and it is
|
|
6
|
+
the seam a plugin pack later adds its own formats to, which is why it lives in the shared
|
|
7
|
+
leaf rather than in any one package.
|
|
8
|
+
|
|
9
|
+
It seeds from the library's standard formats (the non-GPL extra provides their validators)
|
|
10
|
+
and adds dirigent's own. ``regex`` is read without being compiled, and ``uri``, ``iri`` and their
|
|
11
|
+
references are matched by the ``regex`` package under the evaluation's deadline, so a check over
|
|
12
|
+
text a caller chose costs time in step with it and stops with the evaluation.
|
|
13
|
+
|
|
14
|
+
None of dirigent's own are standard JSON Schema formats, so a tool that does not know them
|
|
15
|
+
treats them as annotations and passes anything -- the graceful degradation every non-core
|
|
16
|
+
format has; on a dirigent instance they assert. Each guards on a
|
|
17
|
+
string first and parses where parsing is more correct than a pattern. The draft is 2020-12.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import base64
|
|
21
|
+
import binascii
|
|
22
|
+
import re
|
|
23
|
+
import uuid
|
|
24
|
+
from collections.abc import Callable, Mapping
|
|
25
|
+
from importlib import import_module
|
|
26
|
+
from typing import Any, Final
|
|
27
|
+
|
|
28
|
+
import regex
|
|
29
|
+
from jsonschema import FormatChecker
|
|
30
|
+
|
|
31
|
+
from dirigent_common.durations import is_duration
|
|
32
|
+
from dirigent_common.schema_values import matches_whole
|
|
33
|
+
|
|
34
|
+
#: Python's own reader of ``re`` syntax, which ``re.compile`` parses with before it compiles.
|
|
35
|
+
_regex_syntax: Any = import_module("re._parser")
|
|
36
|
+
_regex_ops: Any = import_module("re._constants")
|
|
37
|
+
|
|
38
|
+
#: RFC 3986's IPv6address, as the library's ``uri`` check spells it.
|
|
39
|
+
_IPV6: Final[str] = import_module("rfc3986_validator").IPv6_RE
|
|
40
|
+
|
|
41
|
+
#: A Crockford base32 ULID: 26 characters, the first at most ``7`` so the timestamp fits 48 bits.
|
|
42
|
+
_ULID = re.compile(r"[0-7][0-9A-HJKMNP-TV-Z]{25}")
|
|
43
|
+
|
|
44
|
+
#: Hex digests, by their fixed nibble length; a digest is hex of either case.
|
|
45
|
+
_HEX = {"md5": 32, "sha1": 40, "sha256": 64, "sha512": 128}
|
|
46
|
+
|
|
47
|
+
#: The longest text ``format: regex`` reads as a pattern; anything longer is not one.
|
|
48
|
+
MAX_FORMAT_REGEX: Final = 8192
|
|
49
|
+
|
|
50
|
+
#: RFC 3987's ``ucschar``: the characters past ASCII an IRI may hold unescaped.
|
|
51
|
+
_UCSCHAR: Final = "\u00a0-\ud7ff\uf900-\ufdcf\ufdf0-\uffef" + "".join(
|
|
52
|
+
f"{chr(plane * 0x10000 + (0x1000 if plane == 14 else 0))}-{chr(plane * 0x10000 + 0xFFFD)}" for plane in range(1, 15)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
#: RFC 3987's ``iprivate``, which only an IRI's query may hold.
|
|
56
|
+
_IPRIVATE: Final = "\ue000-\uf8ff\U000f0000-\U000ffffd\U00100000-\U0010fffd"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _reference_patterns(wide: str, private: str) -> tuple["regex.Pattern[str]", "regex.Pattern[str]"]:
|
|
60
|
+
"""Compile RFC 3986's URI and URI-reference, or with ``wide`` and ``private`` RFC 3987's IRI forms.
|
|
61
|
+
|
|
62
|
+
Every repetition is possessive: none of them holds the character that ends it, so giving
|
|
63
|
+
one back can never let the rest match, and the match costs time in step with the text.
|
|
64
|
+
"""
|
|
65
|
+
unreserved = rf"a-zA-Z0-9_.~\-{wide}"
|
|
66
|
+
sub = r"!$&'()*+,;="
|
|
67
|
+
pct = r"%[0-9A-Fa-f]{2}"
|
|
68
|
+
octet = r"(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)"
|
|
69
|
+
authority = (
|
|
70
|
+
rf"(?:(?:[{unreserved}{sub}:]|{pct})*+@)?"
|
|
71
|
+
rf"(?:\[(?:{_IPV6}|v[0-9A-Fa-f]+\.[a-zA-Z0-9_.~\-{sub}:]+)\]"
|
|
72
|
+
rf"|(?:{octet}\.){{3}}{octet}"
|
|
73
|
+
rf"|(?:[{unreserved}{sub}]|{pct})*+)"
|
|
74
|
+
r"(?::[0-9]*+)?"
|
|
75
|
+
)
|
|
76
|
+
pchar = rf"(?:[{unreserved}{sub}:@]|{pct})"
|
|
77
|
+
segments = rf"(?:/{pchar}*+)*+"
|
|
78
|
+
tail = rf"(?:\?(?:{pchar}|[/?{private}])*+)?(?:\#(?:{pchar}|[/?])*+)?"
|
|
79
|
+
absolute = rf"/(?:{pchar}++{segments})?"
|
|
80
|
+
uri = rf"[a-zA-Z][a-zA-Z0-9+.\-]*+:(?://{authority}{segments}|{absolute}|{pchar}++{segments}|){tail}"
|
|
81
|
+
noscheme = rf"(?:[{unreserved}{sub}@]|{pct})++{segments}"
|
|
82
|
+
relative = rf"(?://{authority}{segments}|{absolute}|{noscheme}|){tail}"
|
|
83
|
+
return regex.compile(uri, regex.VERSION0), regex.compile(rf"(?:{uri}|{relative})", regex.VERSION0)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
_URI, _URI_REFERENCE = _reference_patterns("", "")
|
|
87
|
+
_IRI, _IRI_REFERENCE = _reference_patterns(_UCSCHAR, _IPRIVATE)
|
|
88
|
+
|
|
89
|
+
#: The operators of a parsed pattern whose argument holds further parsed patterns.
|
|
90
|
+
_NESTING: Final = {
|
|
91
|
+
_regex_ops.SUBPATTERN,
|
|
92
|
+
_regex_ops.BRANCH,
|
|
93
|
+
_regex_ops.MAX_REPEAT,
|
|
94
|
+
_regex_ops.MIN_REPEAT,
|
|
95
|
+
_regex_ops.POSSESSIVE_REPEAT,
|
|
96
|
+
_regex_ops.ASSERT,
|
|
97
|
+
_regex_ops.ASSERT_NOT,
|
|
98
|
+
_regex_ops.ATOMIC_GROUP,
|
|
99
|
+
_regex_ops.GROUPREF_EXISTS,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _is_pattern(value: str) -> bool:
|
|
104
|
+
"""Whether ``value`` is a pattern Python's ``re`` compiles, read without compiling it.
|
|
105
|
+
|
|
106
|
+
The parse costs time in step with the text. A look-behind must have one width, which
|
|
107
|
+
``re`` checks only when it compiles.
|
|
108
|
+
"""
|
|
109
|
+
if len(value) > MAX_FORMAT_REGEX:
|
|
110
|
+
return False
|
|
111
|
+
try:
|
|
112
|
+
pending: list[Any] = [_regex_syntax.parse(value)]
|
|
113
|
+
while pending:
|
|
114
|
+
parsed = pending.pop()
|
|
115
|
+
if not isinstance(parsed, _regex_syntax.SubPattern):
|
|
116
|
+
pending.extend(item for item in parsed if isinstance(item, list | tuple | _regex_syntax.SubPattern))
|
|
117
|
+
continue
|
|
118
|
+
for operator, argument in parsed.data:
|
|
119
|
+
if operator in (_regex_ops.ASSERT, _regex_ops.ASSERT_NOT) and argument[0] < 0:
|
|
120
|
+
low, high = argument[1].getwidth()
|
|
121
|
+
if low != high:
|
|
122
|
+
return False
|
|
123
|
+
if operator in _NESTING:
|
|
124
|
+
pending.append(argument)
|
|
125
|
+
except (re.error, RecursionError, OverflowError):
|
|
126
|
+
return False
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _is_uuid_version(value: object, version: int) -> bool:
|
|
131
|
+
"""Whether the value parses as a UUID of exactly this version."""
|
|
132
|
+
if not isinstance(value, str):
|
|
133
|
+
return False
|
|
134
|
+
try:
|
|
135
|
+
return uuid.UUID(value).version == version
|
|
136
|
+
except ValueError:
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _is_hex_digest(value: object, length: int) -> bool:
|
|
141
|
+
"""Whether the value is a hex string of exactly this many characters."""
|
|
142
|
+
return isinstance(value, str) and re.fullmatch(rf"[0-9a-fA-F]{{{length}}}", value) is not None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _is_base64(value: object) -> bool:
|
|
146
|
+
"""Whether the value is standard base64. The empty string is valid: it decodes to no bytes."""
|
|
147
|
+
if not isinstance(value, str):
|
|
148
|
+
return False
|
|
149
|
+
try:
|
|
150
|
+
base64.b64decode(value, validate=True)
|
|
151
|
+
except (binascii.Error, ValueError):
|
|
152
|
+
return False
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def base_format_checker() -> FormatChecker:
|
|
157
|
+
"""Build the format checker dirigent validates values against, standard formats plus its own."""
|
|
158
|
+
checker = FormatChecker()
|
|
159
|
+
|
|
160
|
+
@checker.checks("regex")
|
|
161
|
+
def _regex(value: object) -> bool: # pyright: ignore[reportUnusedFunction] - the decorator registers it
|
|
162
|
+
"""A pattern Python's ``re`` takes, at most ``MAX_FORMAT_REGEX`` characters long."""
|
|
163
|
+
return not isinstance(value, str) or _is_pattern(value)
|
|
164
|
+
|
|
165
|
+
for _name, _reference in {
|
|
166
|
+
"uri": _URI,
|
|
167
|
+
"uri-reference": _URI_REFERENCE,
|
|
168
|
+
"iri": _IRI,
|
|
169
|
+
"iri-reference": _IRI_REFERENCE,
|
|
170
|
+
}.items():
|
|
171
|
+
|
|
172
|
+
@checker.checks(_name)
|
|
173
|
+
def _is_reference(value: object, pattern: "regex.Pattern[str]" = _reference) -> bool: # pyright: ignore[reportUnusedFunction] - the decorator registers it
|
|
174
|
+
"""An RFC 3986 URI or RFC 3987 IRI, matched within the evaluation's deadline."""
|
|
175
|
+
return not isinstance(value, str) or matches_whole(pattern, value)
|
|
176
|
+
|
|
177
|
+
@checker.checks("ulid")
|
|
178
|
+
def _is_ulid(value: object) -> bool: # pyright: ignore[reportUnusedFunction] - the decorator registers it
|
|
179
|
+
"""A 26-character Crockford base32 ULID. Canonically uppercase; lowercase decodes too."""
|
|
180
|
+
return isinstance(value, str) and _ULID.fullmatch(value.upper()) is not None
|
|
181
|
+
|
|
182
|
+
@checker.checks("uuid4")
|
|
183
|
+
def _is_uuid4(value: object) -> bool: # pyright: ignore[reportUnusedFunction] - the decorator registers it
|
|
184
|
+
"""A UUID that is specifically version 4 (random)."""
|
|
185
|
+
return _is_uuid_version(value, 4)
|
|
186
|
+
|
|
187
|
+
@checker.checks("uuid7")
|
|
188
|
+
def _is_uuid7(value: object) -> bool: # pyright: ignore[reportUnusedFunction] - the decorator registers it
|
|
189
|
+
"""A UUID that is specifically version 7 (time-ordered) -- what dirigent's own ids are."""
|
|
190
|
+
return _is_uuid_version(value, 7)
|
|
191
|
+
|
|
192
|
+
for _name, _length in _HEX.items():
|
|
193
|
+
|
|
194
|
+
@checker.checks(_name)
|
|
195
|
+
def _is_digest(value: object, length: int = _length) -> bool: # pyright: ignore[reportUnusedFunction] - the decorator registers it
|
|
196
|
+
"""A hex digest of the fixed length this algorithm produces, either case."""
|
|
197
|
+
return _is_hex_digest(value, length)
|
|
198
|
+
|
|
199
|
+
@checker.checks("humane-duration")
|
|
200
|
+
def _humane_duration(value: object) -> bool: # pyright: ignore[reportUnusedFunction] - the decorator registers it
|
|
201
|
+
"""A duration in dirigent's spelling, such as ``30s`` or ``1h30m``; never ISO 8601."""
|
|
202
|
+
return is_duration(value)
|
|
203
|
+
|
|
204
|
+
@checker.checks("base64")
|
|
205
|
+
def _base64(value: object) -> bool: # pyright: ignore[reportUnusedFunction] - the decorator registers it
|
|
206
|
+
"""A standard base64 string; the empty string counts, decoding to no bytes."""
|
|
207
|
+
return _is_base64(value)
|
|
208
|
+
|
|
209
|
+
return checker
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def format_checker_with(contributed: Mapping[str, Callable[[object], bool]]) -> FormatChecker:
|
|
213
|
+
"""Build the checker an instance validates against: the base formats plus contributed ones.
|
|
214
|
+
|
|
215
|
+
A pack contributes a predicate per format name; a value is invalid when the predicate
|
|
216
|
+
returns False or raises, which is the whole shape of a jsonschema format check. The base
|
|
217
|
+
checker is copied rather than added to, so each host's assembly is its own and the shared
|
|
218
|
+
base stays exactly the standard-plus-dirigent set every call builds.
|
|
219
|
+
"""
|
|
220
|
+
checker = base_format_checker()
|
|
221
|
+
for name, predicate in contributed.items():
|
|
222
|
+
checker.checks(name, raises=(Exception,))(predicate)
|
|
223
|
+
return checker
|
|
@@ -175,6 +175,8 @@ YAML_ALIAS_CYCLE = COMMON.define(
|
|
|
175
175
|
|
|
176
176
|
YAML_NESTED_TOO_DEEP = COMMON.define("yaml_nested_too_deep", "the YAML nests too deeply to be read")
|
|
177
177
|
|
|
178
|
+
VALUE_NOT_SHOWN = COMMON.define("value_not_shown", "nested deeper than {limit} levels, not shown")
|
|
179
|
+
|
|
178
180
|
VALIDATION = Catalogue("validation")
|
|
179
181
|
|
|
180
182
|
#: Pydantic names the condition, so the second half of a validation code is its own error
|
|
@@ -9,6 +9,7 @@ Draft 2020-12, saying what the library says, with these differences in how:
|
|
|
9
9
|
* ``pattern``, ``patternProperties``, and the patterns ``additionalProperties`` and
|
|
10
10
|
``unevaluatedProperties`` read match with the ``regex`` package in its ``re``-compatible mode,
|
|
11
11
|
which releases the interpreter's lock while it matches and stops at the evaluation's deadline.
|
|
12
|
+
:func:`matches_whole` gives a format predicate the same match.
|
|
12
13
|
* Every keyword checks the bound :func:`bounded_evaluation` sets before it runs, and raises
|
|
13
14
|
:class:`EvaluationOverrun` once the deadline has passed or the caller has stopped waiting.
|
|
14
15
|
|
|
@@ -98,10 +99,29 @@ def _found(pattern: str, text: str) -> bool:
|
|
|
98
99
|
|
|
99
100
|
The match releases the interpreter's lock while it runs.
|
|
100
101
|
"""
|
|
102
|
+
return _matched(_compiled(pattern), text, whole=False)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def matches_whole(pattern: "regex.Pattern[str]", text: str) -> bool:
|
|
106
|
+
"""Whether ``pattern`` matches all of ``text``, within what the evaluation has left.
|
|
107
|
+
|
|
108
|
+
The match releases the interpreter's lock while it runs, and outside
|
|
109
|
+
:func:`bounded_evaluation` it is unbounded.
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
EvaluationOverrun: The deadline passed before or during the match, or the caller stopped waiting.
|
|
113
|
+
"""
|
|
114
|
+
return _matched(pattern, text, whole=True)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _matched(pattern: "regex.Pattern[str]", text: str, *, whole: bool) -> bool:
|
|
118
|
+
"""Search ``text`` for ``pattern``, or match all of it, stopping at the evaluation's deadline."""
|
|
101
119
|
bound = _BOUND.get()
|
|
102
120
|
left = _remaining(bound)
|
|
103
121
|
try:
|
|
104
|
-
|
|
122
|
+
if whole:
|
|
123
|
+
return pattern.fullmatch(text, concurrent=True, timeout=left) is not None
|
|
124
|
+
return pattern.search(text, concurrent=True, timeout=left) is not None
|
|
105
125
|
except TimeoutError as error:
|
|
106
126
|
raise EvaluationOverrun(bound.within if bound is not None else timedelta(0)) from error
|
|
107
127
|
|
|
@@ -315,4 +335,4 @@ ValueValidator: Final[Any] = extend_validator( # pyright: ignore[reportUnknownV
|
|
|
315
335
|
version="draft2020-12",
|
|
316
336
|
)
|
|
317
337
|
|
|
318
|
-
__all__ = ["EvaluationOverrun", "ValueValidator", "bounded_evaluation", "canonical_json"]
|
|
338
|
+
__all__ = ["EvaluationOverrun", "ValueValidator", "bounded_evaluation", "canonical_json", "matches_whole"]
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
"""The base JSON Schema format checker every validation in dirigent draws from.
|
|
2
|
-
|
|
3
|
-
A JSON Schema ``format`` keyword only asserts when a validator is given a checker for it;
|
|
4
|
-
without one it is an annotation the value ignores. This is the one checker the engine and
|
|
5
|
-
the blocks share, so ``format: date-time``, ``uuid`` and the like actually gate -- and it is
|
|
6
|
-
the seam a plugin pack later adds its own formats to, which is why it lives in the shared
|
|
7
|
-
leaf rather than in any one package.
|
|
8
|
-
|
|
9
|
-
It seeds from the library's standard formats (the non-GPL extra provides their validators)
|
|
10
|
-
and adds dirigent's own. None of the ones added here are standard JSON Schema formats, so a
|
|
11
|
-
tool that does not know them treats them as annotations and passes anything -- the graceful
|
|
12
|
-
degradation every non-core format has; on a dirigent instance they assert. Each guards on a
|
|
13
|
-
string first and parses where parsing is more correct than a pattern. The draft is 2020-12.
|
|
14
|
-
"""
|
|
15
|
-
|
|
16
|
-
import base64
|
|
17
|
-
import binascii
|
|
18
|
-
import re
|
|
19
|
-
import uuid
|
|
20
|
-
from collections.abc import Callable, Mapping
|
|
21
|
-
|
|
22
|
-
from jsonschema import FormatChecker
|
|
23
|
-
|
|
24
|
-
from dirigent_common.durations import is_duration
|
|
25
|
-
|
|
26
|
-
#: A Crockford base32 ULID: 26 characters, the first at most ``7`` so the timestamp fits 48 bits.
|
|
27
|
-
_ULID = re.compile(r"[0-7][0-9A-HJKMNP-TV-Z]{25}")
|
|
28
|
-
|
|
29
|
-
#: Hex digests, by their fixed nibble length; a digest is hex of either case.
|
|
30
|
-
_HEX = {"md5": 32, "sha1": 40, "sha256": 64, "sha512": 128}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def _is_uuid_version(value: object, version: int) -> bool:
|
|
34
|
-
"""Whether the value parses as a UUID of exactly this version."""
|
|
35
|
-
if not isinstance(value, str):
|
|
36
|
-
return False
|
|
37
|
-
try:
|
|
38
|
-
return uuid.UUID(value).version == version
|
|
39
|
-
except ValueError:
|
|
40
|
-
return False
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def _is_hex_digest(value: object, length: int) -> bool:
|
|
44
|
-
"""Whether the value is a hex string of exactly this many characters."""
|
|
45
|
-
return isinstance(value, str) and re.fullmatch(rf"[0-9a-fA-F]{{{length}}}", value) is not None
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def _is_base64(value: object) -> bool:
|
|
49
|
-
"""Whether the value is standard base64. The empty string is valid: it decodes to no bytes."""
|
|
50
|
-
if not isinstance(value, str):
|
|
51
|
-
return False
|
|
52
|
-
try:
|
|
53
|
-
base64.b64decode(value, validate=True)
|
|
54
|
-
except (binascii.Error, ValueError):
|
|
55
|
-
return False
|
|
56
|
-
return True
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def base_format_checker() -> FormatChecker:
|
|
60
|
-
"""Build the format checker dirigent validates values against, standard formats plus its own."""
|
|
61
|
-
checker = FormatChecker()
|
|
62
|
-
|
|
63
|
-
@checker.checks("ulid")
|
|
64
|
-
def _is_ulid(value: object) -> bool: # pyright: ignore[reportUnusedFunction] - the decorator registers it
|
|
65
|
-
"""A 26-character Crockford base32 ULID. Canonically uppercase; lowercase decodes too."""
|
|
66
|
-
return isinstance(value, str) and _ULID.fullmatch(value.upper()) is not None
|
|
67
|
-
|
|
68
|
-
@checker.checks("uuid4")
|
|
69
|
-
def _is_uuid4(value: object) -> bool: # pyright: ignore[reportUnusedFunction] - the decorator registers it
|
|
70
|
-
"""A UUID that is specifically version 4 (random)."""
|
|
71
|
-
return _is_uuid_version(value, 4)
|
|
72
|
-
|
|
73
|
-
@checker.checks("uuid7")
|
|
74
|
-
def _is_uuid7(value: object) -> bool: # pyright: ignore[reportUnusedFunction] - the decorator registers it
|
|
75
|
-
"""A UUID that is specifically version 7 (time-ordered) -- what dirigent's own ids are."""
|
|
76
|
-
return _is_uuid_version(value, 7)
|
|
77
|
-
|
|
78
|
-
for _name, _length in _HEX.items():
|
|
79
|
-
|
|
80
|
-
@checker.checks(_name)
|
|
81
|
-
def _is_digest(value: object, length: int = _length) -> bool: # pyright: ignore[reportUnusedFunction] - the decorator registers it
|
|
82
|
-
"""A hex digest of the fixed length this algorithm produces, either case."""
|
|
83
|
-
return _is_hex_digest(value, length)
|
|
84
|
-
|
|
85
|
-
@checker.checks("humane-duration")
|
|
86
|
-
def _humane_duration(value: object) -> bool: # pyright: ignore[reportUnusedFunction] - the decorator registers it
|
|
87
|
-
"""A duration in dirigent's spelling, such as ``30s`` or ``1h30m``; never ISO 8601."""
|
|
88
|
-
return is_duration(value)
|
|
89
|
-
|
|
90
|
-
@checker.checks("base64")
|
|
91
|
-
def _base64(value: object) -> bool: # pyright: ignore[reportUnusedFunction] - the decorator registers it
|
|
92
|
-
"""A standard base64 string; the empty string counts, decoding to no bytes."""
|
|
93
|
-
return _is_base64(value)
|
|
94
|
-
|
|
95
|
-
return checker
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def format_checker_with(contributed: Mapping[str, Callable[[object], bool]]) -> FormatChecker:
|
|
99
|
-
"""Build the checker an instance validates against: the base formats plus contributed ones.
|
|
100
|
-
|
|
101
|
-
A pack contributes a predicate per format name; a value is invalid when the predicate
|
|
102
|
-
returns False or raises, which is the whole shape of a jsonschema format check. The base
|
|
103
|
-
checker is copied rather than added to, so each host's assembly is its own and the shared
|
|
104
|
-
base stays exactly the standard-plus-dirigent set every call builds.
|
|
105
|
-
"""
|
|
106
|
-
checker = base_format_checker()
|
|
107
|
-
for name, predicate in contributed.items():
|
|
108
|
-
checker.checks(name, raises=(Exception,))(predicate)
|
|
109
|
-
return checker
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|