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.
Files changed (31) hide show
  1. {dirigent_common-0.28.0 → dirigent_common-0.29.0}/PKG-INFO +1 -1
  2. {dirigent_common-0.28.0 → dirigent_common-0.29.0}/pyproject.toml +1 -1
  3. {dirigent_common-0.28.0 → dirigent_common-0.29.0}/pyproject.toml.orig +1 -1
  4. dirigent_common-0.29.0/src/dirigent_common/depth.py +59 -0
  5. dirigent_common-0.29.0/src/dirigent_common/formats.py +223 -0
  6. {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/messages.py +2 -0
  7. {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/schema_values.py +22 -2
  8. dirigent_common-0.28.0/src/dirigent_common/formats.py +0 -109
  9. {dirigent_common-0.28.0 → dirigent_common-0.29.0}/LICENSE +0 -0
  10. {dirigent_common-0.28.0 → dirigent_common-0.29.0}/README.md +0 -0
  11. {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/__init__.py +0 -0
  12. {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/bounded_yaml.py +0 -0
  13. {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/clients.py +0 -0
  14. {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/docstrings.py +0 -0
  15. {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/durations.py +0 -0
  16. {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/formatters.py +0 -0
  17. {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/hosts.py +0 -0
  18. {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/names.py +0 -0
  19. {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/programs.py +0 -0
  20. {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/py.typed +0 -0
  21. {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/redaction.py +0 -0
  22. {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/requests.py +0 -0
  23. {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/schema_references.py +0 -0
  24. {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/schemas.py +0 -0
  25. {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/sizes.py +0 -0
  26. {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/storable.py +0 -0
  27. {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/templating.py +0 -0
  28. {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/types.py +0 -0
  29. {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/uris.py +0 -0
  30. {dirigent_common-0.28.0 → dirigent_common-0.29.0}/src/dirigent_common/values.py +0 -0
  31. {dirigent_common-0.28.0 → dirigent_common-0.29.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.28.0
3
+ Version: 0.29.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-common"
3
- version = "0.28.0"
3
+ version = "0.29.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"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-common"
3
- version = "0.28.0"
3
+ version = "0.29.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"
@@ -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
- return _compiled(pattern).search(text, concurrent=True, timeout=left) is not None
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