dirigent-common 0.9.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.9.0/LICENSE +18 -0
- dirigent_common-0.9.0/PKG-INFO +23 -0
- dirigent_common-0.9.0/README.md +11 -0
- dirigent_common-0.9.0/pyproject.toml +17 -0
- dirigent_common-0.9.0/pyproject.toml.orig +19 -0
- dirigent_common-0.9.0/src/dirigent_common/__init__.py +96 -0
- dirigent_common-0.9.0/src/dirigent_common/clients.py +27 -0
- dirigent_common-0.9.0/src/dirigent_common/docstrings.py +18 -0
- dirigent_common-0.9.0/src/dirigent_common/durations.py +182 -0
- dirigent_common-0.9.0/src/dirigent_common/formats.py +109 -0
- dirigent_common-0.9.0/src/dirigent_common/formatters.py +25 -0
- dirigent_common-0.9.0/src/dirigent_common/names.py +65 -0
- dirigent_common-0.9.0/src/dirigent_common/programs.py +9 -0
- dirigent_common-0.9.0/src/dirigent_common/py.typed +0 -0
- dirigent_common-0.9.0/src/dirigent_common/schemas.py +65 -0
- dirigent_common-0.9.0/src/dirigent_common/sizes.py +139 -0
- dirigent_common-0.9.0/src/dirigent_common/types.py +9 -0
- dirigent_common-0.9.0/src/dirigent_common/uris.py +16 -0
- dirigent_common-0.9.0/src/dirigent_common/values.py +40 -0
- dirigent_common-0.9.0/src/dirigent_common/versions.py +6 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Copyright (c) 2026 Morten Olav Hansen <morten@winterop.com>. All rights reserved.
|
|
2
|
+
|
|
3
|
+
This source code and accompanying documentation are the property of
|
|
4
|
+
Morten Olav Hansen. No license, express or implied, is granted to use, copy,
|
|
5
|
+
modify, merge, publish, distribute, sublicense, or sell copies of this
|
|
6
|
+
software or its derivatives.
|
|
7
|
+
|
|
8
|
+
The source is published for reference only. Any use beyond reading
|
|
9
|
+
requires written permission from the copyright holder.
|
|
10
|
+
|
|
11
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
12
|
+
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
13
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
|
|
14
|
+
IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES,
|
|
15
|
+
OR OTHER LIABILITY ARISING FROM THE USE OF THE SOFTWARE.
|
|
16
|
+
|
|
17
|
+
Third-party components redistributed with this software, and the licences they
|
|
18
|
+
carry, are listed in THIRD_PARTY_NOTICES.md.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dirigent-common
|
|
3
|
+
Version: 0.9.0
|
|
4
|
+
Summary: Value types and shared schemas every dirigent package may depend on.
|
|
5
|
+
License-Expression: LicenseRef-Proprietary
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Dist: httpx2>=2.12.0
|
|
8
|
+
Requires-Dist: jsonschema[format-nongpl]>=4.23.0
|
|
9
|
+
Requires-Dist: pydantic>=2.13.5
|
|
10
|
+
Requires-Python: >=3.13
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# dirigent-common
|
|
14
|
+
|
|
15
|
+
Value types and shared schemas every other dirigent package may depend on, and which depend on
|
|
16
|
+
nothing of dirigent's in turn.
|
|
17
|
+
|
|
18
|
+
A `Duration` a document writes as `30s`, a `Size` it writes as `16KB`, the name rules an entity
|
|
19
|
+
obeys, and the small schemas more than one package has to agree on. It holds nothing about
|
|
20
|
+
blocks, the wire, or the engine: those are `dirigent-plugin`, `dirigent-client` and
|
|
21
|
+
`dirigent-core`, all of which may depend on this and none of which it knows about.
|
|
22
|
+
|
|
23
|
+
See [the architecture page](../../docs/architecture.md) for what belongs here and what does not.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# dirigent-common
|
|
2
|
+
|
|
3
|
+
Value types and shared schemas every other dirigent package may depend on, and which depend on
|
|
4
|
+
nothing of dirigent's in turn.
|
|
5
|
+
|
|
6
|
+
A `Duration` a document writes as `30s`, a `Size` it writes as `16KB`, the name rules an entity
|
|
7
|
+
obeys, and the small schemas more than one package has to agree on. It holds nothing about
|
|
8
|
+
blocks, the wire, or the engine: those are `dirigent-plugin`, `dirigent-client` and
|
|
9
|
+
`dirigent-core`, all of which may depend on this and none of which it knows about.
|
|
10
|
+
|
|
11
|
+
See [the architecture page](../../docs/architecture.md) for what belongs here and what does not.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "dirigent-common"
|
|
3
|
+
version = "0.9.0"
|
|
4
|
+
description = "Value types and shared schemas every dirigent package may depend on."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
license = "LicenseRef-Proprietary"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
dependencies = [
|
|
10
|
+
"httpx2>=2.12.0",
|
|
11
|
+
"jsonschema[format-nongpl]>=4.23.0",
|
|
12
|
+
"pydantic>=2.13.5",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["uv_build>=0.12.0,<0.13.0"]
|
|
17
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "dirigent-common"
|
|
3
|
+
version = "0.9.0"
|
|
4
|
+
description = "Value types and shared schemas every dirigent package may depend on."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
license = "LicenseRef-Proprietary"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
dependencies = [
|
|
10
|
+
"httpx2>=2.12.0",
|
|
11
|
+
# nongpl: the format-nongpl extra pulls permissively-licensed format validators, not the
|
|
12
|
+
# GPL rfc3987 the plain format extra carries -- safe while the project's own licence is undecided.
|
|
13
|
+
"jsonschema[format-nongpl]>=4.23.0",
|
|
14
|
+
"pydantic>=2.13.5",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["uv_build>=0.12.0,<0.13.0"]
|
|
19
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Value types and shared schemas every dirigent package may depend on.
|
|
2
|
+
|
|
3
|
+
This package knows nothing of blocks, the wire, or the engine. Everything here is something
|
|
4
|
+
more than one package has to agree on: how a document spells a duration or a size, what a
|
|
5
|
+
name may be, and the small schemas a plugin and the API both name.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dirigent_common.clients import build_client
|
|
9
|
+
from dirigent_common.docstrings import as_markdown
|
|
10
|
+
from dirigent_common.durations import (
|
|
11
|
+
DURATION_PATTERN,
|
|
12
|
+
Duration,
|
|
13
|
+
DurationError,
|
|
14
|
+
HumaneJsonSchema,
|
|
15
|
+
NegativeDuration,
|
|
16
|
+
format_duration,
|
|
17
|
+
parse_duration,
|
|
18
|
+
to_timedelta,
|
|
19
|
+
)
|
|
20
|
+
from dirigent_common.formats import base_format_checker, format_checker_with
|
|
21
|
+
from dirigent_common.formatters import Formatter
|
|
22
|
+
from dirigent_common.names import (
|
|
23
|
+
EMAIL_MAX_LENGTH,
|
|
24
|
+
EMAIL_PATTERN,
|
|
25
|
+
ENTITY_NAME_MAX_LENGTH,
|
|
26
|
+
ENTITY_NAME_PATTERN,
|
|
27
|
+
STEP_NAME_MAX_LENGTH,
|
|
28
|
+
STEP_NAME_PATTERN,
|
|
29
|
+
Email,
|
|
30
|
+
EntityName,
|
|
31
|
+
StepName,
|
|
32
|
+
entity_name_error,
|
|
33
|
+
is_entity_name,
|
|
34
|
+
is_step_name,
|
|
35
|
+
step_name_error,
|
|
36
|
+
)
|
|
37
|
+
from dirigent_common.programs import JQ_MEDIA_TYPE, SHELL_MEDIA_TYPE
|
|
38
|
+
from dirigent_common.schemas import BlockModel, HealthReport, HttpConnectionConfig
|
|
39
|
+
from dirigent_common.sizes import (
|
|
40
|
+
SIZE_PATTERN,
|
|
41
|
+
NegativeSize,
|
|
42
|
+
Size,
|
|
43
|
+
SizeError,
|
|
44
|
+
format_size,
|
|
45
|
+
parse_size,
|
|
46
|
+
)
|
|
47
|
+
from dirigent_common.types import JsonList, JsonMap
|
|
48
|
+
from dirigent_common.uris import STORAGE_URI_FORMAT, StorageUri
|
|
49
|
+
from dirigent_common.values import spelled
|
|
50
|
+
from dirigent_common.versions import API_VERSION
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"base_format_checker",
|
|
54
|
+
"format_checker_with",
|
|
55
|
+
"API_VERSION",
|
|
56
|
+
"DURATION_PATTERN",
|
|
57
|
+
"EMAIL_MAX_LENGTH",
|
|
58
|
+
"EMAIL_PATTERN",
|
|
59
|
+
"ENTITY_NAME_MAX_LENGTH",
|
|
60
|
+
"ENTITY_NAME_PATTERN",
|
|
61
|
+
"JQ_MEDIA_TYPE",
|
|
62
|
+
"SHELL_MEDIA_TYPE",
|
|
63
|
+
"SIZE_PATTERN",
|
|
64
|
+
"STORAGE_URI_FORMAT",
|
|
65
|
+
"STEP_NAME_MAX_LENGTH",
|
|
66
|
+
"STEP_NAME_PATTERN",
|
|
67
|
+
"BlockModel",
|
|
68
|
+
"Duration",
|
|
69
|
+
"DurationError",
|
|
70
|
+
"Email",
|
|
71
|
+
"EntityName",
|
|
72
|
+
"Formatter",
|
|
73
|
+
"HealthReport",
|
|
74
|
+
"HttpConnectionConfig",
|
|
75
|
+
"HumaneJsonSchema",
|
|
76
|
+
"JsonList",
|
|
77
|
+
"JsonMap",
|
|
78
|
+
"NegativeDuration",
|
|
79
|
+
"NegativeSize",
|
|
80
|
+
"Size",
|
|
81
|
+
"SizeError",
|
|
82
|
+
"StepName",
|
|
83
|
+
"StorageUri",
|
|
84
|
+
"as_markdown",
|
|
85
|
+
"entity_name_error",
|
|
86
|
+
"build_client",
|
|
87
|
+
"format_duration",
|
|
88
|
+
"format_size",
|
|
89
|
+
"is_entity_name",
|
|
90
|
+
"is_step_name",
|
|
91
|
+
"parse_duration",
|
|
92
|
+
"parse_size",
|
|
93
|
+
"spelled",
|
|
94
|
+
"step_name_error",
|
|
95
|
+
"to_timedelta",
|
|
96
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Turning a connection's configuration into the client that talks to it.
|
|
2
|
+
|
|
3
|
+
This lives beside the configuration rather than in the standard library of blocks, so an
|
|
4
|
+
adapter pack can use the shared HTTP connection without depending on that library.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import httpx2
|
|
8
|
+
|
|
9
|
+
from dirigent_common.schemas import HttpConnectionConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_client(config: HttpConnectionConfig) -> httpx2.AsyncClient:
|
|
13
|
+
"""Build a client carrying a connection's base URL, auth, TLS setting, and timeout."""
|
|
14
|
+
headers: dict[str, str] = {}
|
|
15
|
+
if config.bearer_token is not None:
|
|
16
|
+
headers["Authorization"] = f"Bearer {config.bearer_token.get_secret_value()}"
|
|
17
|
+
auth: httpx2.Auth | None = None
|
|
18
|
+
if config.basic_username:
|
|
19
|
+
password = config.basic_password.get_secret_value() if config.basic_password else ""
|
|
20
|
+
auth = httpx2.BasicAuth(config.basic_username, password)
|
|
21
|
+
return httpx2.AsyncClient(
|
|
22
|
+
base_url=config.base_url,
|
|
23
|
+
headers=headers,
|
|
24
|
+
auth=auth,
|
|
25
|
+
verify=config.verify_tls,
|
|
26
|
+
timeout=config.timeout.total_seconds(),
|
|
27
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Reading a Python docstring as the markdown every reader of it renders.
|
|
2
|
+
|
|
3
|
+
A description of a block's config, a setting or a surface is authored as a docstring, so it is
|
|
4
|
+
written in reStructuredText; the UI, the CLI and the generated reference all render markdown.
|
|
5
|
+
This is the rule for crossing between the two, and it is applied where a docstring becomes a
|
|
6
|
+
description rather than by anything that later reads one.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import Final
|
|
11
|
+
|
|
12
|
+
#: A reStructuredText inline literal, which is how a docstring writes code.
|
|
13
|
+
RST_LITERAL: Final = re.compile(r"``(.+?)``", re.DOTALL)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def as_markdown(text: str) -> str:
|
|
17
|
+
"""Rewrite a docstring's inline literals as markdown inline code."""
|
|
18
|
+
return RST_LITERAL.sub(r"`\1`", text)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Humane durations: ``30s`` and ``5m`` in a document, ``timedelta`` in the engine."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
from typing import Annotated, Any, Final, cast
|
|
6
|
+
|
|
7
|
+
from pydantic import AfterValidator, BeforeValidator, PlainSerializer, WithJsonSchema
|
|
8
|
+
from pydantic.json_schema import GenerateJsonSchema, JsonSchemaMode, JsonSchemaValue
|
|
9
|
+
from pydantic_core import CoreSchema
|
|
10
|
+
|
|
11
|
+
#: Ordered largest first; the greedy decomposition in :func:`format_duration` depends on it.
|
|
12
|
+
UNITS: Final[tuple[tuple[str, float], ...]] = (
|
|
13
|
+
("w", 604800.0),
|
|
14
|
+
("d", 86400.0),
|
|
15
|
+
("h", 3600.0),
|
|
16
|
+
("m", 60.0),
|
|
17
|
+
("s", 1.0),
|
|
18
|
+
("ms", 0.001),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_TERM: Final = re.compile(r"(\d+(?:\.\d+)?)(ms|[wdhms])")
|
|
22
|
+
|
|
23
|
+
#: A whole duration: one or more suffixed terms. Public, because it is the grammar an export
|
|
24
|
+
#: must never write outside of.
|
|
25
|
+
DURATION_PATTERN: Final = re.compile(r"^(?:\d+(?:\.\d+)?(?:ms|[wdhms]))+$")
|
|
26
|
+
|
|
27
|
+
#: The same grammar spelled for JSON Schema, with the bare number of seconds allowed as well.
|
|
28
|
+
DURATION_JSON_PATTERN: Final = r"^(?:(?:\d+(?:\.\d+)?(?:ms|[wdhms]))+|\d+(?:\.\d+)?)$"
|
|
29
|
+
|
|
30
|
+
ZERO: Final = "0s"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NegativeDuration(ValueError):
|
|
34
|
+
"""A duration was negative, which the grammar has no spelling for."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, value: object) -> None:
|
|
37
|
+
"""Name the offending value."""
|
|
38
|
+
super().__init__(
|
|
39
|
+
f"{value!r} is negative, and a duration is a delay, a cadence, or a budget: "
|
|
40
|
+
"the format has no way to write one that runs backwards"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class DurationError(ValueError):
|
|
45
|
+
"""A duration was written in a way the format does not define."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, value: object) -> None:
|
|
48
|
+
"""Name the value and the grammar it failed."""
|
|
49
|
+
super().__init__(
|
|
50
|
+
f"{value!r} is not a duration: write a number of seconds, or unit-suffixed terms "
|
|
51
|
+
f"such as '30s', '5m', '6h', '1h30m', '250ms' (units: w, d, h, m, s, ms)"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_duration(value: object) -> object:
|
|
56
|
+
"""Turn a humane duration string into a timedelta, passing anything else through."""
|
|
57
|
+
match value:
|
|
58
|
+
case timedelta():
|
|
59
|
+
return value
|
|
60
|
+
case bool():
|
|
61
|
+
raise DurationError(value)
|
|
62
|
+
case int() | float():
|
|
63
|
+
return timedelta(seconds=float(value))
|
|
64
|
+
case str():
|
|
65
|
+
return _parse_text(value.strip())
|
|
66
|
+
case _:
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_text(text: str) -> timedelta:
|
|
71
|
+
"""Parse the string form: a bare number of seconds, or a sequence of suffixed terms."""
|
|
72
|
+
if not text:
|
|
73
|
+
raise DurationError(text)
|
|
74
|
+
try:
|
|
75
|
+
return timedelta(seconds=float(text))
|
|
76
|
+
except ValueError:
|
|
77
|
+
pass
|
|
78
|
+
if DURATION_PATTERN.fullmatch(text) is None:
|
|
79
|
+
raise DurationError(text)
|
|
80
|
+
lengths = dict(UNITS)
|
|
81
|
+
seconds = sum(float(amount) * lengths[unit] for amount, unit in _TERM.findall(text))
|
|
82
|
+
return timedelta(seconds=seconds)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def to_timedelta(value: object) -> timedelta:
|
|
86
|
+
"""Read a humane duration as a timedelta, refusing anything the grammar does not define."""
|
|
87
|
+
parsed = parse_duration(value)
|
|
88
|
+
if not isinstance(parsed, timedelta):
|
|
89
|
+
raise DurationError(value)
|
|
90
|
+
return parsed
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def format_duration(value: timedelta) -> str:
|
|
94
|
+
"""Render a timedelta the one way the canonical document writes it.
|
|
95
|
+
|
|
96
|
+
Every duration must have exactly one spelling, or an export stops being byte-stable.
|
|
97
|
+
"""
|
|
98
|
+
total = value.total_seconds()
|
|
99
|
+
if total < 0:
|
|
100
|
+
raise NegativeDuration(value)
|
|
101
|
+
if total == 0:
|
|
102
|
+
return ZERO
|
|
103
|
+
remaining = round(total * 1000) / 1000
|
|
104
|
+
parts: list[str] = []
|
|
105
|
+
for unit, length in UNITS:
|
|
106
|
+
if length > remaining and unit != "ms":
|
|
107
|
+
continue
|
|
108
|
+
count = int(remaining / length) if unit != "ms" else round(remaining / length)
|
|
109
|
+
if count == 0:
|
|
110
|
+
continue
|
|
111
|
+
parts.append(f"{count}{unit}")
|
|
112
|
+
remaining = round((remaining - count * length) * 1000) / 1000
|
|
113
|
+
if remaining <= 0:
|
|
114
|
+
break
|
|
115
|
+
return "".join(parts) or ZERO
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def refuse_negative(value: timedelta) -> timedelta:
|
|
119
|
+
"""Refuse a negative duration, because the grammar has no way to write one down."""
|
|
120
|
+
if value < timedelta(0):
|
|
121
|
+
raise NegativeDuration(value)
|
|
122
|
+
return value
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def is_duration(value: object) -> bool:
|
|
126
|
+
"""Whether the value is a duration as a document writes one: humane spelling, not negative."""
|
|
127
|
+
if not isinstance(value, str):
|
|
128
|
+
return False
|
|
129
|
+
try:
|
|
130
|
+
return to_timedelta(value) >= timedelta(0)
|
|
131
|
+
except DurationError:
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
#: A duration as a document writes it and as the code uses it: a timedelta.
|
|
136
|
+
#:
|
|
137
|
+
#: The published schema is the humane grammar, because a document writes ``5m`` and never
|
|
138
|
+
#: ``PT5M``, and the catalog check is what a document is validated against before it is
|
|
139
|
+
#: ever run. The format is named ``humane-duration`` rather than the standard ``duration``,
|
|
140
|
+
#: which names ISO 8601 and would refuse every value this type accepts.
|
|
141
|
+
type Duration = Annotated[
|
|
142
|
+
timedelta,
|
|
143
|
+
BeforeValidator(parse_duration),
|
|
144
|
+
AfterValidator(refuse_negative),
|
|
145
|
+
PlainSerializer(format_duration, return_type=str, when_used="json"),
|
|
146
|
+
WithJsonSchema({"type": "string", "pattern": DURATION_JSON_PATTERN, "format": "humane-duration"}),
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
#: Bounds pydantic leaves on a field it could not express in JSON Schema, which is every
|
|
150
|
+
#: bound on a duration: JSON Schema has no keyword for one.
|
|
151
|
+
_UNEXPRESSED_BOUNDS: Final = ("gt", "ge", "lt", "le")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class HumaneJsonSchema(GenerateJsonSchema):
|
|
155
|
+
"""The schema generator every schema dirigent publishes is built with.
|
|
156
|
+
|
|
157
|
+
A default reaches JSON Schema through pydantic's own encoder, which renders a timedelta
|
|
158
|
+
in ISO 8601 whatever the field's serializer says, so a schema built without this writes
|
|
159
|
+
``PT5M`` where the document it describes writes ``5m``.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
def encode_default(self, dft: Any) -> Any:
|
|
163
|
+
"""Encode a default, writing a duration the one way a document spells it."""
|
|
164
|
+
if isinstance(dft, timedelta):
|
|
165
|
+
return format_duration(dft)
|
|
166
|
+
return super().encode_default(dft)
|
|
167
|
+
|
|
168
|
+
def generate(self, schema: CoreSchema, mode: JsonSchemaMode = "validation") -> JsonSchemaValue:
|
|
169
|
+
"""Generate the schema, dropping the bounds JSON Schema has no keyword for."""
|
|
170
|
+
return cast("JsonSchemaValue", _without_unexpressed_bounds(super().generate(schema, mode=mode)))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _without_unexpressed_bounds(node: object) -> object:
|
|
174
|
+
"""Walk a schema, dropping every bound pydantic could not express as a keyword."""
|
|
175
|
+
if isinstance(node, dict):
|
|
176
|
+
entries = cast("dict[str, object]", node)
|
|
177
|
+
return {
|
|
178
|
+
key: _without_unexpressed_bounds(value) for key, value in entries.items() if key not in _UNEXPRESSED_BOUNDS
|
|
179
|
+
}
|
|
180
|
+
if isinstance(node, list):
|
|
181
|
+
return [_without_unexpressed_bounds(item) for item in cast("list[object]", node)]
|
|
182
|
+
return node
|
|
@@ -0,0 +1,109 @@
|
|
|
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
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""The formatter contract: a name, a version, and one method turning a record into a line.
|
|
2
|
+
|
|
3
|
+
A formatter is contributed by a package that depends on neither the CLI nor the block
|
|
4
|
+
contract, so the protocol every implementation is checked against lives here.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
from dirigent_common.types import JsonMap
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@runtime_checkable
|
|
13
|
+
class Formatter(Protocol):
|
|
14
|
+
"""One way of turning a record into a line."""
|
|
15
|
+
|
|
16
|
+
name: str
|
|
17
|
+
version: str
|
|
18
|
+
|
|
19
|
+
def render(self, record: JsonMap) -> object:
|
|
20
|
+
"""Render one record: a line, or a line with what the kind puts beneath it.
|
|
21
|
+
|
|
22
|
+
What the CLI prints is a string, or anything rich's console renders -- a table, a
|
|
23
|
+
group of both. The type is left open here so that this package names no renderer.
|
|
24
|
+
"""
|
|
25
|
+
...
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""The two name grammars every dirigent identifier obeys, as shared validated types."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Annotated, Final
|
|
5
|
+
|
|
6
|
+
from pydantic import StringConstraints
|
|
7
|
+
|
|
8
|
+
#: The grammar reads most naturally as ``^[a-z]([a-z0-9]|-(?=[a-z0-9]))*$``, but this
|
|
9
|
+
#: pattern is published in JSON Schema and compiled by pydantic-core's Rust engine, which
|
|
10
|
+
#: supports no look-ahead. The form below is its exact equivalent: every hyphen must be
|
|
11
|
+
#: followed by an alphanumeric, which forbids a trailing hyphen and a doubled one alike.
|
|
12
|
+
ENTITY_NAME_PATTERN: Final = r"^[a-z](-?[a-z0-9])*$"
|
|
13
|
+
|
|
14
|
+
ENTITY_NAME_MAX_LENGTH: Final = 63
|
|
15
|
+
|
|
16
|
+
STEP_NAME_PATTERN: Final = r"^[a-z][a-z0-9_]*$"
|
|
17
|
+
|
|
18
|
+
STEP_NAME_MAX_LENGTH: Final = 63
|
|
19
|
+
|
|
20
|
+
#: An address with one ``@`` and a dotted host, which is as far as a pattern can honestly go.
|
|
21
|
+
EMAIL_PATTERN: Final = r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
|
|
22
|
+
|
|
23
|
+
EMAIL_MAX_LENGTH: Final = 320
|
|
24
|
+
|
|
25
|
+
type EntityName = Annotated[
|
|
26
|
+
str, StringConstraints(pattern=ENTITY_NAME_PATTERN, max_length=ENTITY_NAME_MAX_LENGTH, min_length=1)
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
type StepName = Annotated[
|
|
30
|
+
str, StringConstraints(pattern=STEP_NAME_PATTERN, max_length=STEP_NAME_MAX_LENGTH, min_length=1)
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
type Email = Annotated[
|
|
34
|
+
str, StringConstraints(strip_whitespace=True, pattern=EMAIL_PATTERN, max_length=EMAIL_MAX_LENGTH)
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
_ENTITY_NAME = re.compile(ENTITY_NAME_PATTERN)
|
|
38
|
+
_STEP_NAME = re.compile(STEP_NAME_PATTERN)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_entity_name(value: str) -> bool:
|
|
42
|
+
"""Report whether a string is a valid kebab-case entity name."""
|
|
43
|
+
return len(value) <= ENTITY_NAME_MAX_LENGTH and _ENTITY_NAME.fullmatch(value) is not None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_step_name(value: str) -> bool:
|
|
47
|
+
"""Report whether a string is a valid snake_case step name."""
|
|
48
|
+
return len(value) <= STEP_NAME_MAX_LENGTH and _STEP_NAME.fullmatch(value) is not None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def entity_name_error(label: str, value: str) -> str:
|
|
52
|
+
"""Render the one message every entity-name rejection uses, so the advice never varies."""
|
|
53
|
+
return (
|
|
54
|
+
f"{label} {value!r} is not a valid name: names are lowercase letters, digits, and single "
|
|
55
|
+
f"hyphens, start with a letter, end alphanumeric, and are at most "
|
|
56
|
+
f"{ENTITY_NAME_MAX_LENGTH} characters"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def step_name_error(value: str) -> str:
|
|
61
|
+
"""Render the one message every step-name rejection uses, including why it differs."""
|
|
62
|
+
return (
|
|
63
|
+
f"step name {value!r} is not valid: step names are snake_case ({STEP_NAME_PATTERN}), because "
|
|
64
|
+
f"they appear inside ${{steps.<name>.output.…}} where a hyphen or a dot would be ambiguous"
|
|
65
|
+
)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""The media types a config field carrying a program publishes itself under."""
|
|
2
|
+
|
|
3
|
+
from typing import Final
|
|
4
|
+
|
|
5
|
+
#: A jq program, as ``transform.jq``, ``map.jq`` and ``filter.jq`` take one.
|
|
6
|
+
JQ_MEDIA_TYPE: Final = "application/jq"
|
|
7
|
+
|
|
8
|
+
#: A string handed to a shell to parse, as ``shell.run`` and ``docker.run`` take one.
|
|
9
|
+
SHELL_MEDIA_TYPE: Final = "text/x-shellscript"
|
|
File without changes
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""The pydantic types more than one package has to agree on.
|
|
2
|
+
|
|
3
|
+
A block's config and output are published as JSON Schema, so their base makes a field's
|
|
4
|
+
docstring its description. The HTTP connection is here rather than in the standard library
|
|
5
|
+
so an adapter pack presents the same one, instead of redefining base URL, auth, TLS and
|
|
6
|
+
timeouts into a fourth slightly different form.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from datetime import timedelta
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
|
12
|
+
|
|
13
|
+
from dirigent_common.durations import Duration
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BlockModel(BaseModel):
|
|
17
|
+
"""The base every block's config and output model should use.
|
|
18
|
+
|
|
19
|
+
Each field's docstring becomes its description in the published JSON Schema, and an
|
|
20
|
+
unknown key is refused rather than ignored -- which also publishes the schema with
|
|
21
|
+
``additionalProperties: false``, so a document is refused at apply and not at the run.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
model_config = ConfigDict(use_attribute_docstrings=True, extra="forbid")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class HealthReport(BaseModel):
|
|
28
|
+
"""The outcome of checking a connection against its external system."""
|
|
29
|
+
|
|
30
|
+
healthy: bool
|
|
31
|
+
detail: str | None = None
|
|
32
|
+
version: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class HttpConnectionConfig(BlockModel):
|
|
36
|
+
"""Everything needed to talk to one HTTP service, credentials included."""
|
|
37
|
+
|
|
38
|
+
base_url: str = Field(min_length=1)
|
|
39
|
+
"""The service root every request path is resolved against."""
|
|
40
|
+
|
|
41
|
+
bearer_token: SecretStr | None = None
|
|
42
|
+
"""A bearer credential, sent as an Authorization header."""
|
|
43
|
+
|
|
44
|
+
basic_username: str | None = None
|
|
45
|
+
"""The user half of HTTP basic authentication."""
|
|
46
|
+
|
|
47
|
+
basic_password: SecretStr | None = None
|
|
48
|
+
"""The secret half of HTTP basic authentication."""
|
|
49
|
+
|
|
50
|
+
hmac_secret: SecretStr | None = None
|
|
51
|
+
"""A shared secret an outbound POST is signed with, mirroring an inbound webhook's.
|
|
52
|
+
|
|
53
|
+
It lives on the connection rather than in a document for the same reason every other
|
|
54
|
+
credential does: a document is portable and a secret is not. It is not sent as a header
|
|
55
|
+
and never authenticates a request on its own -- ``webhook.post`` uses it to sign the
|
|
56
|
+
exact bytes it puts on the wire."""
|
|
57
|
+
|
|
58
|
+
verify_tls: bool = True
|
|
59
|
+
"""Whether certificates are verified; turning this off is a per-connection decision."""
|
|
60
|
+
|
|
61
|
+
timeout: Duration = Field(default=timedelta(seconds=30), gt=timedelta(0))
|
|
62
|
+
"""The timeout applied to every request through this connection."""
|
|
63
|
+
|
|
64
|
+
health_path: str = "/"
|
|
65
|
+
"""What a connection check requests to decide whether the service is reachable."""
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Humane sizes: ``64mb`` in a document, an ``int`` of bytes in the code.
|
|
2
|
+
|
|
3
|
+
Both unit families are accepted and both are powers of 1024, which is what an operator
|
|
4
|
+
means by "64 megabytes of memory" whatever the SI prefix says. ``64mb`` and ``64mib`` are
|
|
5
|
+
therefore the same number, and the canonical rendering is the short spelling.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
from typing import Annotated, Final
|
|
11
|
+
|
|
12
|
+
from pydantic import AfterValidator, BeforeValidator, PlainSerializer, WithJsonSchema
|
|
13
|
+
|
|
14
|
+
#: Ordered largest first; the greedy rendering in :func:`format_size` depends on it.
|
|
15
|
+
UNITS: Final[tuple[tuple[str, int], ...]] = (
|
|
16
|
+
("tb", 1024**4),
|
|
17
|
+
("gb", 1024**3),
|
|
18
|
+
("mb", 1024**2),
|
|
19
|
+
("kb", 1024),
|
|
20
|
+
("b", 1),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
#: The long spelling of each unit, accepted on the way in and never written on the way out.
|
|
24
|
+
ALIASES: Final[dict[str, str]] = {"tib": "tb", "gib": "gb", "mib": "mb", "kib": "kb"}
|
|
25
|
+
|
|
26
|
+
#: A whole size: one number and one unit. Public, because it is the grammar an export must
|
|
27
|
+
#: never write outside of.
|
|
28
|
+
SIZE_PATTERN: Final = re.compile(r"^\d+(?:\.\d+)?(?:t|g|m|k)?i?b$", re.IGNORECASE)
|
|
29
|
+
|
|
30
|
+
#: The same grammar spelled for JSON Schema, which has no portable case-insensitive flag,
|
|
31
|
+
#: with the bare number of bytes allowed as well.
|
|
32
|
+
SIZE_JSON_PATTERN: Final = r"^\d+(?:\.\d+)?(?:[TtGgMmKk]?[Ii]?[Bb])?$"
|
|
33
|
+
|
|
34
|
+
#: How a zero size is written.
|
|
35
|
+
ZERO: Final = "0b"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class NegativeSize(ValueError):
|
|
39
|
+
"""A size was negative, which the grammar has no spelling for."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, value: object) -> None:
|
|
42
|
+
"""Name the value, since the fix is always to drop the sign."""
|
|
43
|
+
super().__init__(f"{value!r} is negative, and a size is a quantity of bytes: it cannot run backwards")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SizeError(ValueError):
|
|
47
|
+
"""A size was written in a way the format does not define."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, value: object) -> None:
|
|
50
|
+
"""Name the value and the grammar, because the fix is always a spelling change."""
|
|
51
|
+
super().__init__(
|
|
52
|
+
f"{value!r} is not a size: write a number of bytes, or a unit-suffixed amount such as "
|
|
53
|
+
f"'512kb', '64mb', '1.5gb' (units: b, kb, mb, gb, tb, and the kib/mib/gib/tib spelling of each, "
|
|
54
|
+
f"all powers of 1024)"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_size(value: object) -> object:
|
|
59
|
+
"""Turn a humane size string into a count of bytes, passing anything else through.
|
|
60
|
+
|
|
61
|
+
Numbers are read as bytes, so ``min_size: 4096`` means the obvious thing.
|
|
62
|
+
"""
|
|
63
|
+
match value:
|
|
64
|
+
case bool():
|
|
65
|
+
raise SizeError(value)
|
|
66
|
+
case int():
|
|
67
|
+
return value
|
|
68
|
+
case float():
|
|
69
|
+
return _whole(value, value)
|
|
70
|
+
case str():
|
|
71
|
+
return _parse_text(value.strip())
|
|
72
|
+
case _:
|
|
73
|
+
return value
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _parse_text(text: str) -> int:
|
|
77
|
+
"""Parse the string form: a bare number of bytes, or one unit-suffixed amount."""
|
|
78
|
+
if not text:
|
|
79
|
+
raise SizeError(text)
|
|
80
|
+
try:
|
|
81
|
+
return _whole(float(text), text)
|
|
82
|
+
except ValueError:
|
|
83
|
+
pass
|
|
84
|
+
if SIZE_PATTERN.fullmatch(text) is None:
|
|
85
|
+
raise SizeError(text)
|
|
86
|
+
lowered = text.lower()
|
|
87
|
+
sizes: dict[str, int] = dict(UNITS)
|
|
88
|
+
suffix = next(unit for unit in (*ALIASES, *sizes) if lowered.endswith(unit))
|
|
89
|
+
amount = lowered[: -len(suffix)] or "0"
|
|
90
|
+
return _whole(float(amount) * sizes[ALIASES.get(suffix, suffix)], text)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _whole(value: float, written: object) -> int:
|
|
94
|
+
"""Refuse a size that is not a whole number of bytes, which nothing can store."""
|
|
95
|
+
if not math.isfinite(value) or value != int(value):
|
|
96
|
+
raise SizeError(written)
|
|
97
|
+
return int(value)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def format_size(value: int) -> str:
|
|
101
|
+
"""Render a count of bytes the one way the canonical document writes it.
|
|
102
|
+
|
|
103
|
+
The largest unit that divides the value exactly, so a rendered size is never a decimal
|
|
104
|
+
and reading it back yields the same number of bytes.
|
|
105
|
+
"""
|
|
106
|
+
if value < 0:
|
|
107
|
+
raise NegativeSize(value)
|
|
108
|
+
if value == 0:
|
|
109
|
+
return ZERO
|
|
110
|
+
unit, multiplier = next((unit, size) for unit, size in UNITS if value % size == 0)
|
|
111
|
+
return f"{value // multiplier}{unit}"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def refuse_negative(value: int) -> int:
|
|
115
|
+
"""Refuse a negative size, because the grammar has no way to write one down."""
|
|
116
|
+
if value < 0:
|
|
117
|
+
raise NegativeSize(value)
|
|
118
|
+
return value
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
#: A size as a document writes it and as the code uses it: a whole number of bytes.
|
|
122
|
+
#:
|
|
123
|
+
#: The published schema names both spellings, because a document may write either and the
|
|
124
|
+
#: catalog check is what a document is validated against before it is ever run.
|
|
125
|
+
type Size = Annotated[
|
|
126
|
+
int,
|
|
127
|
+
BeforeValidator(parse_size),
|
|
128
|
+
AfterValidator(refuse_negative),
|
|
129
|
+
PlainSerializer(format_size, return_type=str, when_used="json"),
|
|
130
|
+
WithJsonSchema(
|
|
131
|
+
{
|
|
132
|
+
"anyOf": [
|
|
133
|
+
{"type": "string", "pattern": SIZE_JSON_PATTERN},
|
|
134
|
+
{"type": "integer", "minimum": 0},
|
|
135
|
+
],
|
|
136
|
+
"format": "size",
|
|
137
|
+
}
|
|
138
|
+
),
|
|
139
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""The JSON shapes a document, a record and a wire body are all made of."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
#: A JSON object, as a document's config, a block's output and a response body all are.
|
|
6
|
+
type JsonMap = dict[str, Any]
|
|
7
|
+
|
|
8
|
+
#: A JSON array.
|
|
9
|
+
type JsonList = list[Any]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""A storage URI as a block declares it, so an instance can check the scheme before a run."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Final
|
|
4
|
+
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
|
|
7
|
+
#: The JSON Schema ``format`` a storage URI is published under. A document's value for a field
|
|
8
|
+
#: carrying it is checked against the schemes this instance's backends claim, at apply, rather
|
|
9
|
+
#: than failing the first time a step runs.
|
|
10
|
+
STORAGE_URI_FORMAT: Final = "storage-uri"
|
|
11
|
+
|
|
12
|
+
#: A URI addressing stored bytes: ``file://`` or ``s3://``, whatever a backend registered.
|
|
13
|
+
#:
|
|
14
|
+
#: The marker is what tells a validator this string is storage and not, say, the URL an HTTP
|
|
15
|
+
#: request is sent to. Nothing distinguishes the two by looking.
|
|
16
|
+
StorageUri = Annotated[str, Field(json_schema_extra={"format": STORAGE_URI_FORMAT})]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""How a value that is not already JSON is spelled as JSON.
|
|
2
|
+
|
|
3
|
+
One conversion, so a parquet column, a database column and anything else that arrives as a
|
|
4
|
+
Python object reach a document as the same string. A caller that has already refused a shape
|
|
5
|
+
its own format cannot carry -- a nested arrow column, say -- refuses it before asking here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import datetime
|
|
10
|
+
import decimal
|
|
11
|
+
import math
|
|
12
|
+
import uuid
|
|
13
|
+
|
|
14
|
+
from pydantic import JsonValue
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def spelled(value: object) -> JsonValue:
|
|
18
|
+
"""Give one Python value its JSON spelling, refusing what has none.
|
|
19
|
+
|
|
20
|
+
A date, time or timestamp becomes ISO 8601. A decimal becomes its exact digits as a
|
|
21
|
+
string, because a float would round it. A UUID becomes its canonical text. Bytes become
|
|
22
|
+
standard base64. A float that is not finite becomes null, JSON having no ``NaN``.
|
|
23
|
+
"""
|
|
24
|
+
if isinstance(value, datetime.datetime | datetime.date | datetime.time):
|
|
25
|
+
return value.isoformat()
|
|
26
|
+
if isinstance(value, datetime.timedelta):
|
|
27
|
+
return value.total_seconds()
|
|
28
|
+
if isinstance(value, decimal.Decimal):
|
|
29
|
+
return str(value)
|
|
30
|
+
if isinstance(value, uuid.UUID):
|
|
31
|
+
return str(value)
|
|
32
|
+
if isinstance(value, bytes | bytearray):
|
|
33
|
+
return base64.b64encode(value).decode()
|
|
34
|
+
if isinstance(value, memoryview):
|
|
35
|
+
return base64.b64encode(value.tobytes()).decode()
|
|
36
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
37
|
+
return None
|
|
38
|
+
if value is None or isinstance(value, bool | int | float | str):
|
|
39
|
+
return value
|
|
40
|
+
raise ValueError(f"a value of type {type(value).__name__} has no JSON spelling")
|