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 +135 -0
- ushabti/_base.py +80 -0
- ushabti/config.py +129 -0
- ushabti/errors.py +58 -0
- ushabti/field.py +42 -0
- ushabti/here_am_i.py +48 -0
- ushabti/implements/__init__.py +185 -0
- ushabti/implements/kubernetes.py +112 -0
- ushabti/implements/noop.py +50 -0
- ushabti/implements/prometheus.py +61 -0
- ushabti/implements/proxmox.py +81 -0
- ushabti/maat.py +285 -0
- ushabti/models/__init__.py +175 -0
- ushabti/models/anthropic.py +187 -0
- ushabti/models/fake.py +86 -0
- ushabti/models/openai_compatible.py +190 -0
- ushabti/omen.py +36 -0
- ushabti/papyrus.py +112 -0
- ushabti/py.typed +0 -0
- ushabti/reference.py +291 -0
- ushabti/spell.py +392 -0
- ushabti/summons.py +71 -0
- ushabti/weighing.py +160 -0
- ushabti-0.1.0.dev0.dist-info/METADATA +53 -0
- ushabti-0.1.0.dev0.dist-info/RECORD +27 -0
- ushabti-0.1.0.dev0.dist-info/WHEEL +4 -0
- ushabti-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
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())
|