ushabti 0.1.0.dev0__py3-none-any.whl

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.
ushabti/__init__.py ADDED
@@ -0,0 +1,135 @@
1
+ """Ushabti SDK: the shared vocabulary and building blocks of the workforce."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ from ushabti.config import LogFormat, Mode, Settings, configure_logging
6
+ from ushabti.errors import (
7
+ MissingExtraError,
8
+ ModelError,
9
+ SpellError,
10
+ UnknownImplementError,
11
+ UnresolvedEnvError,
12
+ UnweighedWriteError,
13
+ UshabtiError,
14
+ )
15
+ from ushabti.field import Field, FieldKind
16
+ from ushabti.here_am_i import HereAmI
17
+ from ushabti.implements import (
18
+ Implement,
19
+ ImplementKind,
20
+ ImplementRegistry,
21
+ ImplementSpec,
22
+ default_registry,
23
+ )
24
+ from ushabti.maat import (
25
+ Comparison,
26
+ Condition,
27
+ Isfet,
28
+ Maat,
29
+ MaatAssertion,
30
+ MaatOutcome,
31
+ MaatStatus,
32
+ Observe,
33
+ )
34
+ from ushabti.models import (
35
+ Completion,
36
+ Message,
37
+ ModelProvider,
38
+ Role,
39
+ StopReason,
40
+ ToolCall,
41
+ Usage,
42
+ build_provider,
43
+ )
44
+ from ushabti.omen import Omen
45
+ from ushabti.papyrus import JsonlPapyrus, Papyrus, PapyrusEntry, PapyrusEvent
46
+ from ushabti.spell import (
47
+ ModelProviderName,
48
+ ModelSpec,
49
+ PapyrusSpec,
50
+ ResolvedSpell,
51
+ Spell,
52
+ SpellMetadata,
53
+ SpellSpec,
54
+ VigilSpec,
55
+ load_spell,
56
+ parse_spell,
57
+ resolve_spell,
58
+ )
59
+ from ushabti.summons import Summons, SummonsSource, SummonsState
60
+ from ushabti.weighing import (
61
+ DefaultWeighing,
62
+ Verdict,
63
+ Weighing,
64
+ WeighingPolicy,
65
+ WeighingRequest,
66
+ WeighingResult,
67
+ WeighingRule,
68
+ )
69
+
70
+ __version__ = version("ushabti")
71
+
72
+ __all__ = [
73
+ "Comparison",
74
+ "Completion",
75
+ "Condition",
76
+ "DefaultWeighing",
77
+ "Field",
78
+ "FieldKind",
79
+ "HereAmI",
80
+ "Implement",
81
+ "ImplementKind",
82
+ "ImplementRegistry",
83
+ "ImplementSpec",
84
+ "Isfet",
85
+ "JsonlPapyrus",
86
+ "LogFormat",
87
+ "Maat",
88
+ "MaatAssertion",
89
+ "MaatOutcome",
90
+ "MaatStatus",
91
+ "Message",
92
+ "MissingExtraError",
93
+ "Mode",
94
+ "ModelError",
95
+ "ModelProvider",
96
+ "ModelProviderName",
97
+ "ModelSpec",
98
+ "Observe",
99
+ "Omen",
100
+ "Papyrus",
101
+ "PapyrusEntry",
102
+ "PapyrusEvent",
103
+ "PapyrusSpec",
104
+ "ResolvedSpell",
105
+ "Role",
106
+ "Settings",
107
+ "Spell",
108
+ "SpellError",
109
+ "SpellMetadata",
110
+ "SpellSpec",
111
+ "StopReason",
112
+ "Summons",
113
+ "SummonsSource",
114
+ "SummonsState",
115
+ "ToolCall",
116
+ "UnknownImplementError",
117
+ "UnresolvedEnvError",
118
+ "UnweighedWriteError",
119
+ "Usage",
120
+ "UshabtiError",
121
+ "Verdict",
122
+ "VigilSpec",
123
+ "Weighing",
124
+ "WeighingPolicy",
125
+ "WeighingRequest",
126
+ "WeighingResult",
127
+ "WeighingRule",
128
+ "__version__",
129
+ "build_provider",
130
+ "configure_logging",
131
+ "default_registry",
132
+ "load_spell",
133
+ "parse_spell",
134
+ "resolve_spell",
135
+ ]
ushabti/_base.py ADDED
@@ -0,0 +1,80 @@
1
+ """Shared building blocks for the SDK's data models.
2
+
3
+ This module is private. Import the public models from ``ushabti`` instead.
4
+ """
5
+
6
+ import re
7
+ import uuid
8
+ from datetime import UTC, datetime, timedelta
9
+ from typing import Annotated
10
+
11
+ from pydantic import BaseModel, BeforeValidator, ConfigDict, PlainSerializer, StringConstraints
12
+
13
+
14
+ class StrictModel(BaseModel):
15
+ """Base for models a person writes by hand, such as a spell.
16
+
17
+ Unknown keys are an error, so a typo never passes silently.
18
+ """
19
+
20
+ model_config = ConfigDict(extra="forbid", frozen=True, use_attribute_docstrings=True)
21
+
22
+
23
+ class WireModel(BaseModel):
24
+ """Base for models sent between processes or written to Papyrus.
25
+
26
+ Unknown keys are ignored, so an older reader can accept a newer writer.
27
+ """
28
+
29
+ model_config = ConfigDict(extra="ignore", frozen=True, use_attribute_docstrings=True)
30
+
31
+
32
+ Name = Annotated[str, StringConstraints(pattern=r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", max_length=63)]
33
+ """A lowercase name in DNS label style: letters, digits, and inner hyphens."""
34
+
35
+
36
+ def utc_now() -> datetime:
37
+ """The current time, always timezone-aware and in UTC."""
38
+ return datetime.now(UTC)
39
+
40
+
41
+ def new_id() -> str:
42
+ """A fresh random identifier."""
43
+ return uuid.uuid4().hex
44
+
45
+
46
+ _DURATION = re.compile(r"^(\d+(?:\.\d+)?)(ms|s|m|h|d)$")
47
+ _UNIT_SECONDS = {"ms": 0.001, "s": 1.0, "m": 60.0, "h": 3600.0, "d": 86400.0}
48
+
49
+
50
+ def parse_duration(value: object) -> object:
51
+ """Turn ``"60s"``, ``"5m"``, or a number of seconds into a timedelta."""
52
+ if isinstance(value, bool):
53
+ raise ValueError("expected a duration such as 60s, not a boolean")
54
+ if isinstance(value, int | float):
55
+ return timedelta(seconds=value)
56
+ if isinstance(value, str):
57
+ match = _DURATION.match(value.strip())
58
+ if match is None:
59
+ raise ValueError("expected a duration such as 500ms, 60s, 5m, 1h, or 1d")
60
+ return timedelta(seconds=float(match.group(1)) * _UNIT_SECONDS[match.group(2)])
61
+ return value
62
+
63
+
64
+ def format_duration(value: timedelta) -> str:
65
+ """Render a timedelta in the same compact form ``parse_duration`` reads."""
66
+ seconds = value.total_seconds()
67
+ for unit, size in (("d", 86400), ("h", 3600), ("m", 60)):
68
+ if seconds >= size and seconds % size == 0:
69
+ return f"{int(seconds // size)}{unit}"
70
+ if seconds == int(seconds):
71
+ return f"{int(seconds)}s"
72
+ return f"{round(seconds * 1000)}ms"
73
+
74
+
75
+ Duration = Annotated[
76
+ timedelta,
77
+ BeforeValidator(parse_duration),
78
+ PlainSerializer(format_duration, return_type=str),
79
+ ]
80
+ """A timedelta written as a short string such as ``60s`` or ``5m``."""
ushabti/config.py ADDED
@@ -0,0 +1,129 @@
1
+ """Process settings, read from environment variables with the USHABTI_ prefix."""
2
+
3
+ import logging
4
+ import sys
5
+ from enum import StrEnum
6
+ from pathlib import Path
7
+ from typing import Self
8
+
9
+ import structlog
10
+ from pydantic import PositiveFloat, SecretStr, model_validator
11
+ from pydantic_settings import BaseSettings, SettingsConfigDict
12
+
13
+
14
+ class Mode(StrEnum):
15
+ """The topology an ushabti runs in."""
16
+
17
+ SOLO = "solo"
18
+ INDEPENDENT = "independent"
19
+ OVERSEEN = "overseen"
20
+
21
+
22
+ class LogFormat(StrEnum):
23
+ CONSOLE = "console"
24
+ JSON = "json"
25
+
26
+
27
+ class Settings(BaseSettings):
28
+ """Settings for one process. Every key maps to ``USHABTI_<KEY>``.
29
+
30
+ Secrets live here and nowhere else: they come from the environment, never
31
+ from a spell or any other file.
32
+ """
33
+
34
+ model_config = SettingsConfigDict(
35
+ env_prefix="USHABTI_",
36
+ env_ignore_empty=True,
37
+ extra="ignore",
38
+ use_attribute_docstrings=True,
39
+ )
40
+
41
+ mode: Mode = Mode.SOLO
42
+ """Topology: `solo`, `independent`, or `overseen`."""
43
+
44
+ name: str | None = None
45
+ """Name of this ushabti. Defaults to the name of its spell."""
46
+
47
+ admin_token: SecretStr | None = None
48
+ """Bearer token for every endpoint that makes an ushabti or overseer do
49
+ something: summons, halt, resume, and registration. Unset disables them."""
50
+
51
+ overseer_url: str | None = None
52
+ """Base URL of the overseer. Required in `overseen` mode."""
53
+
54
+ advertise_url: str | None = None
55
+ """URL where the overseer can reach this ushabti."""
56
+
57
+ here_am_i_interval: PositiveFloat = 30.0
58
+ """Seconds between "Here am I" messages to the overseer."""
59
+
60
+ papyrus_path: Path | None = None
61
+ """Overrides the Papyrus path the spell declares."""
62
+
63
+ fake_script: Path | None = None
64
+ """YAML file of scripted completions for the `fake` model provider."""
65
+
66
+ host: str = "127.0.0.1"
67
+ """Address the HTTP server binds to."""
68
+
69
+ port: int = 8080
70
+ """Port the HTTP server listens on."""
71
+
72
+ log_level: str = "INFO"
73
+ """Log level name, such as `INFO` or `DEBUG`."""
74
+
75
+ log_format: LogFormat = LogFormat.CONSOLE
76
+ """Log output: `console` for people, `json` for log shippers."""
77
+
78
+ @model_validator(mode="after")
79
+ def _mode_matches_overseer(self) -> Self:
80
+ if self.mode is Mode.OVERSEEN and self.overseer_url is None:
81
+ raise ValueError("USHABTI_MODE=overseen requires USHABTI_OVERSEER_URL")
82
+ if self.overseer_url is not None and self.mode is not Mode.OVERSEEN:
83
+ raise ValueError("USHABTI_OVERSEER_URL is set, so USHABTI_MODE must be overseen")
84
+ if self.mode is Mode.OVERSEEN and self.admin_token is None:
85
+ raise ValueError(
86
+ "USHABTI_MODE=overseen requires USHABTI_ADMIN_TOKEN: "
87
+ "an overseer only accepts an ushabti that presents it"
88
+ )
89
+ return self
90
+
91
+ def admin_token_value(self) -> str | None:
92
+ """The admin token in the clear, or None when the endpoints are disabled."""
93
+ return None if self.admin_token is None else self.admin_token.get_secret_value()
94
+
95
+
96
+ class _StderrLogger:
97
+ """Writes each log line to whatever ``sys.stderr`` is at that moment.
98
+
99
+ Looking the stream up on every call, and not once at setup, keeps logging
100
+ working after stderr is redirected or replaced.
101
+ """
102
+
103
+ def msg(self, message: str) -> None:
104
+ sys.stderr.write(message + "\n")
105
+ sys.stderr.flush()
106
+
107
+ log = debug = info = warn = warning = msg
108
+ fatal = failure = err = error = critical = exception = msg
109
+
110
+
111
+ def configure_logging(level: str = "INFO", log_format: LogFormat = LogFormat.CONSOLE) -> None:
112
+ """Send structured logs to stderr, leaving stdout for command output."""
113
+ renderer: structlog.typing.Processor = (
114
+ structlog.processors.JSONRenderer()
115
+ if log_format is LogFormat.JSON
116
+ else structlog.dev.ConsoleRenderer()
117
+ )
118
+ threshold = logging.getLevelNamesMapping().get(level.upper(), logging.INFO)
119
+ structlog.configure(
120
+ processors=[
121
+ structlog.contextvars.merge_contextvars,
122
+ structlog.processors.add_log_level,
123
+ structlog.processors.TimeStamper(fmt="iso", utc=True),
124
+ renderer,
125
+ ],
126
+ wrapper_class=structlog.make_filtering_bound_logger(threshold),
127
+ logger_factory=lambda *args: _StderrLogger(),
128
+ cache_logger_on_first_use=False,
129
+ )
ushabti/errors.py ADDED
@@ -0,0 +1,58 @@
1
+ """Errors the SDK raises on purpose."""
2
+
3
+ from collections.abc import Iterable
4
+
5
+
6
+ class UshabtiError(Exception):
7
+ """Base class for every error the SDK raises on purpose."""
8
+
9
+
10
+ class SpellError(UshabtiError, ValueError):
11
+ """A spell failed validation. ``problems`` lists every reason found."""
12
+
13
+ def __init__(self, problems: Iterable[str], *, source: str = "spell") -> None:
14
+ self.problems = tuple(problems)
15
+ self.source = source
16
+ lines = "\n".join(f" - {problem}" for problem in self.problems)
17
+ super().__init__(f"{source} is not a valid spell:\n{lines}")
18
+
19
+
20
+ class UnresolvedEnvError(UshabtiError):
21
+ """A value references environment variables that are not set."""
22
+
23
+ def __init__(self, variables: Iterable[str]) -> None:
24
+ self.variables = tuple(variables)
25
+ names = ", ".join(f"${{{name}}}" for name in self.variables)
26
+ super().__init__(f"environment variable not set: {names}")
27
+
28
+
29
+ class MissingExtraError(UshabtiError, ImportError):
30
+ """An optional dependency is needed but its extra is not installed."""
31
+
32
+ def __init__(self, extra: str, package: str) -> None:
33
+ self.extra = extra
34
+ self.package = package
35
+ super().__init__(
36
+ f'the "{package}" package is not installed; '
37
+ f'install it with: pip install "ushabti[{extra}]"'
38
+ )
39
+
40
+
41
+ class ModelError(UshabtiError):
42
+ """A model provider is misconfigured or its request failed."""
43
+
44
+
45
+ class UnknownImplementError(UshabtiError, LookupError):
46
+ """An implement name is not in the registry."""
47
+
48
+ def __init__(self, name: str) -> None:
49
+ self.name = name
50
+ super().__init__(f"{name} is not a registered implement")
51
+
52
+
53
+ class UnweighedWriteError(UshabtiError):
54
+ """An implement was asked to run without a PASS verdict from the weighing.
55
+
56
+ This is the guard behind the rule that no write implement runs without a
57
+ verdict. Reaching it means a caller skipped the weighing.
58
+ """
ushabti/field.py ADDED
@@ -0,0 +1,42 @@
1
+ """A field: a target an ushabti tends."""
2
+
3
+ from enum import StrEnum
4
+ from typing import Self
5
+
6
+ from pydantic import model_validator
7
+
8
+ from ushabti._base import Name, StrictModel
9
+
10
+
11
+ class FieldKind(StrEnum):
12
+ KUBERNETES = "kubernetes"
13
+ PROXMOX = "proxmox"
14
+ PROMETHEUS = "prometheus"
15
+ SERVICE = "service"
16
+
17
+
18
+ class Field(StrictModel):
19
+ """A target an ushabti tends: a K3s cluster, a namespace, a Proxmox node, a service."""
20
+
21
+ name: Name
22
+ """Name of the field. A summons (a unit of work) refers to a field by this name."""
23
+
24
+ kind: FieldKind
25
+ """What the field is: `kubernetes`, `proxmox`, `prometheus`, or `service`."""
26
+
27
+ namespaces: tuple[str, ...] = ()
28
+ """Kubernetes namespaces to tend. Only valid when `kind` is `kubernetes`."""
29
+
30
+ nodes: tuple[str, ...] = ()
31
+ """Proxmox node names to tend. Only valid when `kind` is `proxmox`."""
32
+
33
+ endpoint: str | None = None
34
+ """Base URL of the field, usually an environment reference such as `${PROMETHEUS_URL}`."""
35
+
36
+ @model_validator(mode="after")
37
+ def _keys_match_kind(self) -> Self:
38
+ if self.namespaces and self.kind is not FieldKind.KUBERNETES:
39
+ raise ValueError("namespaces is only valid when kind is kubernetes")
40
+ if self.nodes and self.kind is not FieldKind.PROXMOX:
41
+ raise ValueError("nodes is only valid when kind is proxmox")
42
+ return self
ushabti/here_am_i.py ADDED
@@ -0,0 +1,48 @@
1
+ """Here am I: the message an ushabti answers with."""
2
+
3
+ from datetime import datetime
4
+
5
+ import pydantic
6
+
7
+ from ushabti._base import WireModel, utc_now
8
+ from ushabti.config import Mode
9
+
10
+
11
+ class HereAmI(WireModel):
12
+ """The health signal and acknowledgement of one ushabti.
13
+
14
+ It is the body of the health endpoint, and it is what an ushabti sends to
15
+ its overseer to register and to stay registered. It carries the fields and
16
+ implements of the spell so the overseer can route a summons (a unit of
17
+ work) without holding the spell itself.
18
+ """
19
+
20
+ name: str
21
+ """Name of the ushabti."""
22
+
23
+ spell: str
24
+ """Name of the spell it runs."""
25
+
26
+ mode: Mode
27
+ """Topology it runs in: `solo`, `independent`, or `overseen`."""
28
+
29
+ halted: bool
30
+ """True when the ushabti is halted: it observes but does not act."""
31
+
32
+ last_vigil: datetime | None = None
33
+ """When the last vigil tick finished, or None before the first one."""
34
+
35
+ fields: tuple[str, ...] = ()
36
+ """Names of the fields the spell covers."""
37
+
38
+ implements: tuple[str, ...] = ()
39
+ """Names of the implements the spell may use."""
40
+
41
+ url: str | None = None
42
+ """Where the overseer can reach this ushabti."""
43
+
44
+ version: str | None = None
45
+ """Version of the agent runtime."""
46
+
47
+ sent_at: datetime = pydantic.Field(default_factory=utc_now)
48
+ """When this message was produced."""
@@ -0,0 +1,185 @@
1
+ """Implements: the tools an ushabti can use.
2
+
3
+ Every implement is either ``read`` or ``write``. A write implement only runs
4
+ when the weighing (the safety gate) returned ``PASS`` for it. That rule is
5
+ enforced here, in ``Implement.run``, so no caller can skip it by accident.
6
+ """
7
+
8
+ import re
9
+ from abc import ABC, abstractmethod
10
+ from collections.abc import Iterable, Iterator, Mapping
11
+ from enum import StrEnum
12
+ from typing import TYPE_CHECKING, ClassVar, Final, final
13
+
14
+ from pydantic import JsonValue
15
+
16
+ from ushabti._base import WireModel
17
+ from ushabti.errors import UnknownImplementError, UnweighedWriteError
18
+ from ushabti.field import Field
19
+
20
+ if TYPE_CHECKING:
21
+ from ushabti.weighing import WeighingResult
22
+
23
+ IMPLEMENT_NAME: Final = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$")
24
+ """Implement names look like ``kubernetes.list_pods``: a namespace, a dot, an operation."""
25
+
26
+ NO_PARAMETERS: Final[Mapping[str, JsonValue]] = {
27
+ "type": "object",
28
+ "properties": {},
29
+ "additionalProperties": False,
30
+ }
31
+
32
+
33
+ class ImplementKind(StrEnum):
34
+ """Whether an implement only observes or can change something."""
35
+
36
+ READ = "read"
37
+ WRITE = "write"
38
+
39
+
40
+ class ImplementSpec(WireModel):
41
+ """What a model is told about an implement."""
42
+
43
+ name: str
44
+ kind: ImplementKind
45
+ description: str
46
+ parameters: dict[str, JsonValue]
47
+
48
+
49
+ class Implement(ABC):
50
+ """A tool an ushabti can use.
51
+
52
+ Subclasses set ``name``, ``kind``, ``description``, and ``parameters`` (a
53
+ JSON Schema for the arguments), and implement ``_run``. Callers use
54
+ ``run``, which checks the verdict first.
55
+
56
+ This is an abstract base class and not a structural protocol on purpose:
57
+ the verdict check lives in ``run``, and only inheritance guarantees that
58
+ every implement carries it.
59
+ """
60
+
61
+ name: ClassVar[str]
62
+ kind: ClassVar[ImplementKind]
63
+ description: ClassVar[str]
64
+ parameters: ClassVar[Mapping[str, JsonValue]] = NO_PARAMETERS
65
+
66
+ @final
67
+ async def run(
68
+ self,
69
+ arguments: Mapping[str, JsonValue],
70
+ *,
71
+ field: Field | None = None,
72
+ weighed: "WeighingResult | None" = None,
73
+ ) -> JsonValue:
74
+ """Run the implement, refusing unless the weighing allows it.
75
+
76
+ A write implement needs a ``PASS`` verdict naming this implement. A
77
+ read implement may run without one, which is how the vigil observes,
78
+ but when a verdict is given it must also be ``PASS``.
79
+ """
80
+ self._require_pass(weighed)
81
+ return await self._run(arguments, field)
82
+
83
+ @abstractmethod
84
+ async def _run(self, arguments: Mapping[str, JsonValue], field: Field | None) -> JsonValue:
85
+ """Do the work. Never call this directly: go through ``run``."""
86
+
87
+ def spec(self) -> ImplementSpec:
88
+ """The description of this implement that a model sees."""
89
+ return ImplementSpec(
90
+ name=self.name,
91
+ kind=self.kind,
92
+ description=self.description,
93
+ parameters=dict(self.parameters),
94
+ )
95
+
96
+ def _require_pass(self, weighed: "WeighingResult | None") -> None:
97
+ # Imported here because weighing imports this module for ImplementKind.
98
+ from ushabti.weighing import Verdict
99
+
100
+ if weighed is None:
101
+ if self.kind is ImplementKind.READ:
102
+ return
103
+ raise UnweighedWriteError(
104
+ f"{self.name} is a write implement and was not weighed; "
105
+ "a write only runs with a PASS verdict"
106
+ )
107
+ if weighed.implement != self.name:
108
+ raise UnweighedWriteError(
109
+ f"the verdict is for {weighed.implement}, not for {self.name}"
110
+ )
111
+ if weighed.verdict is not Verdict.PASS:
112
+ raise UnweighedWriteError(
113
+ f"{self.name} was weighed {weighed.verdict.value}; only PASS may run"
114
+ )
115
+
116
+
117
+ class ImplementRegistry:
118
+ """The implements a process knows about, by name."""
119
+
120
+ def __init__(self, implements: Iterable[Implement] = ()) -> None:
121
+ self._implements: dict[str, Implement] = {}
122
+ for implement in implements:
123
+ self.register(implement)
124
+
125
+ def register(self, implement: Implement) -> None:
126
+ """Add an implement. Its name must be well formed and not taken."""
127
+ name = getattr(implement, "name", None)
128
+ if not isinstance(name, str) or IMPLEMENT_NAME.match(name) is None:
129
+ raise ValueError(
130
+ f"implement name {name!r} must look like namespace.operation, "
131
+ "in lowercase letters, digits, and underscores"
132
+ )
133
+ if not isinstance(getattr(implement, "kind", None), ImplementKind):
134
+ raise ValueError(f"implement {name} must declare kind as read or write")
135
+ if not getattr(implement, "description", ""):
136
+ raise ValueError(f"implement {name} must have a description")
137
+ if name in self._implements:
138
+ raise ValueError(f"implement {name} is already registered")
139
+ self._implements[name] = implement
140
+
141
+ def get(self, name: str) -> Implement:
142
+ """The implement with this name, or UnknownImplementError."""
143
+ try:
144
+ return self._implements[name]
145
+ except KeyError:
146
+ raise UnknownImplementError(name) from None
147
+
148
+ def find(self, name: str) -> Implement | None:
149
+ """The implement with this name, or None."""
150
+ return self._implements.get(name)
151
+
152
+ def kind_of(self, name: str) -> ImplementKind | None:
153
+ """The kind of the named implement, or None when it is not registered."""
154
+ implement = self._implements.get(name)
155
+ return None if implement is None else implement.kind
156
+
157
+ def names(self) -> tuple[str, ...]:
158
+ return tuple(sorted(self._implements))
159
+
160
+ def __contains__(self, name: object) -> bool:
161
+ return name in self._implements
162
+
163
+ def __iter__(self) -> Iterator[Implement]:
164
+ return iter(self._implements.values())
165
+
166
+ def __len__(self) -> int:
167
+ return len(self._implements)
168
+
169
+
170
+ def builtin_implements() -> tuple[Implement, ...]:
171
+ """Fresh instances of every implement that ships with the SDK."""
172
+ # Imported here because these modules import Implement from this one.
173
+ from ushabti.implements import kubernetes, noop, prometheus, proxmox
174
+
175
+ return (
176
+ *noop.implements(),
177
+ *kubernetes.implements(),
178
+ *proxmox.implements(),
179
+ *prometheus.implements(),
180
+ )
181
+
182
+
183
+ def default_registry() -> ImplementRegistry:
184
+ """A new registry holding the built-in implements."""
185
+ return ImplementRegistry(builtin_implements())