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.
@@ -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")
@@ -0,0 +1,6 @@
1
+ """The contract version the plugin surface and the wire catalog both name."""
2
+
3
+ from typing import Final
4
+
5
+ #: Bumped when the contract changes incompatibly; the entry point group carries it too.
6
+ API_VERSION: Final = 1