dirigent-client 0.9.0__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.
- dirigent_client/__init__.py +219 -0
- dirigent_client/client.py +143 -0
- dirigent_client/enums.py +215 -0
- dirigent_client/errors.py +140 -0
- dirigent_client/py.typed +0 -0
- dirigent_client/resources/__init__.py +1 -0
- dirigent_client/resources/alerts.py +97 -0
- dirigent_client/resources/auth.py +155 -0
- dirigent_client/resources/base.py +52 -0
- dirigent_client/resources/blocks.py +16 -0
- dirigent_client/resources/connections.py +50 -0
- dirigent_client/resources/pipelines.py +209 -0
- dirigent_client/resources/runs.py +257 -0
- dirigent_client/resources/schedules.py +130 -0
- dirigent_client/resources/schemas.py +45 -0
- dirigent_client/resources/system.py +36 -0
- dirigent_client/resources/trigger_documents.py +20 -0
- dirigent_client/resources/webhooks.py +80 -0
- dirigent_client/schemas/__init__.py +164 -0
- dirigent_client/schemas/alerts.py +90 -0
- dirigent_client/schemas/auth.py +115 -0
- dirigent_client/schemas/catalog.py +66 -0
- dirigent_client/schemas/common.py +38 -0
- dirigent_client/schemas/connections.py +56 -0
- dirigent_client/schemas/pipelines.py +326 -0
- dirigent_client/schemas/runs.py +203 -0
- dirigent_client/schemas/schemas.py +54 -0
- dirigent_client/schemas/system.py +100 -0
- dirigent_client/schemas/triggers.py +208 -0
- dirigent_client/transport.py +191 -0
- dirigent_client-0.9.0.dist-info/METADATA +36 -0
- dirigent_client-0.9.0.dist-info/RECORD +34 -0
- dirigent_client-0.9.0.dist-info/WHEEL +4 -0
- dirigent_client-0.9.0.dist-info/licenses/LICENSE +18 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""One exception class per condition a caller has to handle differently."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Final, cast
|
|
4
|
+
|
|
5
|
+
from dirigent_client.schemas import Problem
|
|
6
|
+
|
|
7
|
+
#: The header a dirigent server puts on every response, including its errors.
|
|
8
|
+
VERSION_HEADER: Final = "X-Dirigent-Version"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DirigentError(Exception):
|
|
12
|
+
"""A call against a dirigent instance did not produce a result."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, message: str, *, status: int = 0, url: str = "", problem: Problem | None = None) -> None:
|
|
15
|
+
"""Carry the message, the status, the URL, and the problem the server sent."""
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
self.message = message
|
|
18
|
+
self.status = status
|
|
19
|
+
self.url = url
|
|
20
|
+
self.problem = problem
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def problems(self) -> list[str]:
|
|
24
|
+
"""List the individual failures, when the refusal was a list of them rather than one."""
|
|
25
|
+
return list(self.problem.problems) if self.problem else []
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TransportError(DirigentError):
|
|
29
|
+
"""The server could not be reached, or the connection failed mid-request."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, url: str, error: Exception) -> None:
|
|
32
|
+
"""Name the URL that could not be reached."""
|
|
33
|
+
super().__init__(f"cannot reach {url}: {type(error).__name__}: {error}", url=url)
|
|
34
|
+
self.cause = error
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NotDirigent(DirigentError):
|
|
38
|
+
"""Something answered, but it was not a dirigent instance."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, base_url: str, url: str, content_type: str) -> None:
|
|
41
|
+
"""Say what answered and where."""
|
|
42
|
+
described = content_type.split(";")[0].strip() or "no content type"
|
|
43
|
+
super().__init__(
|
|
44
|
+
f"the server at {base_url} does not look like a dirigent instance (got {described} from {url})",
|
|
45
|
+
url=url,
|
|
46
|
+
)
|
|
47
|
+
self.base_url = base_url
|
|
48
|
+
self.content_type = content_type
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Unauthorized(DirigentError):
|
|
52
|
+
"""The request carried no credential, or one the instance does not accept."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Forbidden(DirigentError):
|
|
56
|
+
"""The credential is valid and does not permit this."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class NotFound(DirigentError):
|
|
60
|
+
"""This instance holds no such thing."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Conflict(DirigentError):
|
|
64
|
+
"""The instance's current state does not allow this."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ValidationFailed(DirigentError):
|
|
68
|
+
"""The request was well formed and its content was refused, field by field."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class RateLimited(DirigentError):
|
|
72
|
+
"""The instance is refusing this caller for now."""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self, message: str, *, status: int, url: str, problem: Problem | None, retry_after: float | None
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Carry how long the server asked the caller to wait, when it said."""
|
|
78
|
+
super().__init__(message, status=status, url=url, problem=problem)
|
|
79
|
+
self.retry_after = retry_after
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ServerError(DirigentError):
|
|
83
|
+
"""The instance failed to handle the request; its own log has the detail."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class WaitTimeout(DirigentError):
|
|
87
|
+
"""A run was still running when the caller's patience ran out; it was not cancelled."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
_BY_STATUS: Final[dict[int, type[DirigentError]]] = {
|
|
91
|
+
401: Unauthorized,
|
|
92
|
+
403: Forbidden,
|
|
93
|
+
404: NotFound,
|
|
94
|
+
409: Conflict,
|
|
95
|
+
422: ValidationFailed,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_problem(payload: object) -> Problem | None:
|
|
100
|
+
"""Read an error body as the problem shape, or nothing when it is not one."""
|
|
101
|
+
if not isinstance(payload, dict):
|
|
102
|
+
return None
|
|
103
|
+
mapping = cast("dict[str, Any]", payload)
|
|
104
|
+
try:
|
|
105
|
+
return Problem.model_validate(mapping)
|
|
106
|
+
except ValueError:
|
|
107
|
+
return _loose(mapping)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _loose(mapping: "dict[str, Any]") -> Problem | None:
|
|
111
|
+
"""Read a body that carries a detail but not the whole envelope, such as a bare 404."""
|
|
112
|
+
detail = mapping.get("detail")
|
|
113
|
+
if isinstance(detail, str):
|
|
114
|
+
return Problem(status=0, title="Error", detail=detail)
|
|
115
|
+
if isinstance(detail, list):
|
|
116
|
+
rendered = [_one(item) for item in cast("list[object]", detail)]
|
|
117
|
+
return Problem(status=0, title="Error", detail="; ".join(rendered), problems=rendered)
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _one(item: object) -> str:
|
|
122
|
+
"""Render one entry of a problem list, whether it is a string or a pydantic error."""
|
|
123
|
+
if isinstance(item, str):
|
|
124
|
+
return item
|
|
125
|
+
if isinstance(item, dict):
|
|
126
|
+
mapping = cast("dict[str, Any]", item)
|
|
127
|
+
location = ".".join(str(part) for part in mapping.get("loc", []))
|
|
128
|
+
message = str(mapping.get("msg", mapping))
|
|
129
|
+
return f"{location}: {message}" if location else message
|
|
130
|
+
return str(item)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def error_for(status: int, url: str, problem: Problem | None, *, retry_after: float | None = None) -> DirigentError:
|
|
134
|
+
"""Build the exception a refusal deserves, from its status and whatever body it carried."""
|
|
135
|
+
detail = problem.detail if problem and problem.detail else f"HTTP {status}"
|
|
136
|
+
if status == 429:
|
|
137
|
+
return RateLimited(detail, status=status, url=url, problem=problem, retry_after=retry_after)
|
|
138
|
+
if status >= 500:
|
|
139
|
+
return ServerError(detail, status=status, url=url, problem=problem)
|
|
140
|
+
return _BY_STATUS.get(status, DirigentError)(detail, status=status, url=url, problem=problem)
|
dirigent_client/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""One accessor per API resource, each bound to the connection its calls go over."""
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Alert rules, and the notification queue they deliver through."""
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from dirigent_client.enums import AlertEvent, AlertScope
|
|
7
|
+
from dirigent_client.resources.base import Resource, query, request_body
|
|
8
|
+
from dirigent_client.schemas import (
|
|
9
|
+
AlertRuleIn,
|
|
10
|
+
AlertRuleOut,
|
|
11
|
+
AlertRuleUpdate,
|
|
12
|
+
NotificationOut,
|
|
13
|
+
Page,
|
|
14
|
+
TestQueued,
|
|
15
|
+
TestRequest,
|
|
16
|
+
)
|
|
17
|
+
from dirigent_common import to_timedelta
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Alerts(Resource):
|
|
21
|
+
"""Declare and remove alert rules, send a test message, and read the queue."""
|
|
22
|
+
|
|
23
|
+
async def rules(self, *, after: str | None = None, limit: int | None = None) -> Page[AlertRuleOut]:
|
|
24
|
+
"""List every alert rule, with the pipeline each one watches when it is scoped."""
|
|
25
|
+
return await self._many(AlertRuleOut, "GET", "/alert-rules", params=query(after=after, limit=limit))
|
|
26
|
+
|
|
27
|
+
async def create_rule(
|
|
28
|
+
self,
|
|
29
|
+
code: str,
|
|
30
|
+
*,
|
|
31
|
+
name: str | None = None,
|
|
32
|
+
description: str | None = None,
|
|
33
|
+
event: AlertEvent,
|
|
34
|
+
notifier: str,
|
|
35
|
+
scope: AlertScope = AlertScope.GLOBAL,
|
|
36
|
+
pipeline: str | None = None,
|
|
37
|
+
connection: str | None = None,
|
|
38
|
+
template: str | None = None,
|
|
39
|
+
throttle: timedelta | str = timedelta(0),
|
|
40
|
+
) -> AlertRuleOut:
|
|
41
|
+
"""Declare an alert rule, refusing a notifier or a pipeline this instance does not have."""
|
|
42
|
+
payload = AlertRuleIn(
|
|
43
|
+
code=code,
|
|
44
|
+
name=name,
|
|
45
|
+
description=description,
|
|
46
|
+
event=event,
|
|
47
|
+
notifier=notifier,
|
|
48
|
+
scope=scope,
|
|
49
|
+
pipeline=pipeline,
|
|
50
|
+
connection=connection,
|
|
51
|
+
template=template,
|
|
52
|
+
throttle=to_timedelta(throttle),
|
|
53
|
+
)
|
|
54
|
+
return await self._one(AlertRuleOut, "POST", "/alert-rules", json=request_body(payload))
|
|
55
|
+
|
|
56
|
+
async def set_rule_paused(self, code: str, *, paused: bool) -> AlertRuleOut:
|
|
57
|
+
"""Hold a rule's deliveries, or let them resume."""
|
|
58
|
+
payload = AlertRuleUpdate(paused=paused)
|
|
59
|
+
return await self._one(AlertRuleOut, "PATCH", f"/alert-rules/{code}", json=request_body(payload))
|
|
60
|
+
|
|
61
|
+
async def delete_rule(self, code: str) -> None:
|
|
62
|
+
"""Remove an alert rule; the notifications it already raised are kept."""
|
|
63
|
+
await self._transport.request("DELETE", f"/alert-rules/{code}")
|
|
64
|
+
|
|
65
|
+
async def test(
|
|
66
|
+
self,
|
|
67
|
+
*,
|
|
68
|
+
notifier: str,
|
|
69
|
+
connection: str | None = None,
|
|
70
|
+
subject: str | None = None,
|
|
71
|
+
body: str | None = None,
|
|
72
|
+
) -> TestQueued:
|
|
73
|
+
"""Queue one message through a channel, on the same path a real alert takes."""
|
|
74
|
+
declared = TestRequest(notifier=notifier, connection=connection)
|
|
75
|
+
payload = declared.model_copy(
|
|
76
|
+
update=query(subject=subject, body=body),
|
|
77
|
+
)
|
|
78
|
+
return await self._one(TestQueued, "POST", "/alert-rules/$test", json=request_body(payload))
|
|
79
|
+
|
|
80
|
+
async def notification(self, notification_id: UUID | str) -> NotificationOut:
|
|
81
|
+
"""Read one notification, which is how a caller watches a delivery it just queued."""
|
|
82
|
+
return await self._one(NotificationOut, "GET", f"/notifications/{notification_id}")
|
|
83
|
+
|
|
84
|
+
async def retry(self, notification_id: UUID | str) -> NotificationOut:
|
|
85
|
+
"""Put one notification back on the queue, due now."""
|
|
86
|
+
return await self._one(NotificationOut, "POST", f"/notifications/{notification_id}/$retry")
|
|
87
|
+
|
|
88
|
+
async def notifications(
|
|
89
|
+
self, *, run_id: UUID | str | None = None, after: str | None = None, limit: int | None = None
|
|
90
|
+
) -> Page[NotificationOut]:
|
|
91
|
+
"""Read the alert queue newest first, which is where an undelivered alert is visible."""
|
|
92
|
+
return await self._many(
|
|
93
|
+
NotificationOut,
|
|
94
|
+
"GET",
|
|
95
|
+
"/notifications",
|
|
96
|
+
params=query(run_id=str(run_id) if run_id else None, after=after, limit=limit),
|
|
97
|
+
)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Logging in, logging out, asking who you are, and the accounts and tokens an admin manages."""
|
|
2
|
+
|
|
3
|
+
from pydantic import SecretStr
|
|
4
|
+
|
|
5
|
+
from dirigent_client.enums import UserRole
|
|
6
|
+
from dirigent_client.resources.base import Resource, query, request_body
|
|
7
|
+
from dirigent_client.schemas import (
|
|
8
|
+
Identity,
|
|
9
|
+
IssuedTokenOut,
|
|
10
|
+
LoginRequest,
|
|
11
|
+
Page,
|
|
12
|
+
PasswordChangeRequest,
|
|
13
|
+
PasswordResetRequest,
|
|
14
|
+
TokenOut,
|
|
15
|
+
TokenRequest,
|
|
16
|
+
UserIn,
|
|
17
|
+
UserOut,
|
|
18
|
+
UserUpdate,
|
|
19
|
+
)
|
|
20
|
+
from dirigent_client.transport import Transport
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Auth(Resource):
|
|
24
|
+
"""Exchange a password for a session, end one, and describe the caller."""
|
|
25
|
+
|
|
26
|
+
#: What the instance names the session cookie it sets.
|
|
27
|
+
SESSION_COOKIE = "dirigent_session"
|
|
28
|
+
|
|
29
|
+
async def login(self, username: str, password: str) -> Identity:
|
|
30
|
+
"""Verify a username and password, and hold the session the instance answers with.
|
|
31
|
+
|
|
32
|
+
The session is presented as a bearer token from here on, so the rest of this
|
|
33
|
+
connection is authenticated whether or not a cookie could be sent back.
|
|
34
|
+
"""
|
|
35
|
+
payload = LoginRequest(username=username, password=SecretStr(password))
|
|
36
|
+
identity = await self._one(Identity, "POST", "/auth/login", json=request_body(payload))
|
|
37
|
+
session = self._transport.session_cookie(self.SESSION_COOKIE)
|
|
38
|
+
if session is not None:
|
|
39
|
+
self._transport.present(session)
|
|
40
|
+
return identity
|
|
41
|
+
|
|
42
|
+
async def logout(self) -> None:
|
|
43
|
+
"""Revoke the presented session."""
|
|
44
|
+
await self._transport.request("POST", "/auth/logout")
|
|
45
|
+
|
|
46
|
+
async def whoami(self) -> Identity:
|
|
47
|
+
"""Report who this connection is acting as, and which credential said so."""
|
|
48
|
+
return await self._one(Identity, "GET", "/auth/me")
|
|
49
|
+
|
|
50
|
+
async def change_password(self, current: str, new: str) -> None:
|
|
51
|
+
"""Replace this account's own password, ending every other session it holds."""
|
|
52
|
+
payload = PasswordChangeRequest(current_password=SecretStr(current), new_password=SecretStr(new))
|
|
53
|
+
await self._transport.request("POST", "/auth/password", json=request_body(payload))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Users(Resource):
|
|
57
|
+
"""The local accounts an instance holds."""
|
|
58
|
+
|
|
59
|
+
async def list(self, *, after: str | None = None, limit: int | None = None) -> Page[UserOut]:
|
|
60
|
+
"""List every account."""
|
|
61
|
+
return await self._many(UserOut, "GET", "/users", params=query(after=after, limit=limit))
|
|
62
|
+
|
|
63
|
+
async def create(
|
|
64
|
+
self,
|
|
65
|
+
username: str,
|
|
66
|
+
password: str,
|
|
67
|
+
*,
|
|
68
|
+
role: UserRole,
|
|
69
|
+
name: str | None = None,
|
|
70
|
+
email: str | None = None,
|
|
71
|
+
) -> UserOut:
|
|
72
|
+
"""Create an account with an Argon2id password hash."""
|
|
73
|
+
declared = UserIn(
|
|
74
|
+
username=username,
|
|
75
|
+
password=SecretStr(password),
|
|
76
|
+
role=role,
|
|
77
|
+
name=name,
|
|
78
|
+
email=email,
|
|
79
|
+
)
|
|
80
|
+
return await self._one(UserOut, "POST", "/users", json=request_body(declared))
|
|
81
|
+
|
|
82
|
+
async def update(
|
|
83
|
+
self,
|
|
84
|
+
username: str,
|
|
85
|
+
*,
|
|
86
|
+
name: str | None = None,
|
|
87
|
+
role: UserRole | None = None,
|
|
88
|
+
email: str | None = None,
|
|
89
|
+
) -> UserOut:
|
|
90
|
+
"""Change an account's display name, email or role, leaving out whatever was not named.
|
|
91
|
+
|
|
92
|
+
A field this call was not given is absent from the body, which is how the endpoint
|
|
93
|
+
tells "leave it alone" from "clear it".
|
|
94
|
+
"""
|
|
95
|
+
named: dict[str, object] = {}
|
|
96
|
+
if name is not None:
|
|
97
|
+
named["name"] = name
|
|
98
|
+
if email is not None:
|
|
99
|
+
named["email"] = email
|
|
100
|
+
if role is not None:
|
|
101
|
+
named["role"] = role
|
|
102
|
+
body = UserUpdate.model_validate(named).model_dump(mode="json", exclude_unset=True)
|
|
103
|
+
return await self._one(UserOut, "PATCH", f"/users/{username}", json=body)
|
|
104
|
+
|
|
105
|
+
async def deactivate(self, username: str) -> UserOut:
|
|
106
|
+
"""Bar an account from logging in and revoke the sessions it already holds."""
|
|
107
|
+
return await self._one(UserOut, "POST", f"/users/{username}/$deactivate")
|
|
108
|
+
|
|
109
|
+
async def activate(self, username: str) -> UserOut:
|
|
110
|
+
"""Let an account log in again; the sessions it lost are not restored."""
|
|
111
|
+
return await self._one(UserOut, "POST", f"/users/{username}/$activate")
|
|
112
|
+
|
|
113
|
+
async def reset_password(self, username: str, password: str) -> None:
|
|
114
|
+
"""Set an account's password without its old one, ending every session it holds."""
|
|
115
|
+
payload = PasswordResetRequest(password=SecretStr(password))
|
|
116
|
+
await self._transport.request("POST", f"/users/{username}/$reset-password", json=request_body(payload))
|
|
117
|
+
|
|
118
|
+
async def tokens(self, username: str, *, after: str | None = None, limit: int | None = None) -> Page[TokenOut]:
|
|
119
|
+
"""List the API tokens one account holds, without their secrets."""
|
|
120
|
+
return await self._many(TokenOut, "GET", f"/users/{username}/tokens", params=query(after=after, limit=limit))
|
|
121
|
+
|
|
122
|
+
async def create_token(self, username: str, name: str) -> IssuedTokenOut:
|
|
123
|
+
"""Mint a bearer token for another account; its secret is returned exactly once."""
|
|
124
|
+
payload = TokenRequest(name=name)
|
|
125
|
+
return await self._one(IssuedTokenOut, "POST", f"/users/{username}/tokens", json=request_body(payload))
|
|
126
|
+
|
|
127
|
+
async def revoke_token(self, username: str, name: str) -> None:
|
|
128
|
+
"""Revoke that account's live tokens of the given name."""
|
|
129
|
+
await self._transport.request("DELETE", f"/users/{username}/tokens/{name}")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class Tokens(Resource):
|
|
133
|
+
"""The bearer tokens automation authenticates with."""
|
|
134
|
+
|
|
135
|
+
async def list(self, *, after: str | None = None, limit: int | None = None) -> Page[TokenOut]:
|
|
136
|
+
"""List every account's API tokens, without their secrets; sessions are not listed."""
|
|
137
|
+
return await self._many(TokenOut, "GET", "/tokens", params=query(after=after, limit=limit))
|
|
138
|
+
|
|
139
|
+
async def create(self, name: str) -> IssuedTokenOut:
|
|
140
|
+
"""Mint a bearer token for this account; its secret is returned exactly once."""
|
|
141
|
+
payload = TokenRequest(name=name)
|
|
142
|
+
return await self._one(IssuedTokenOut, "POST", "/tokens", json=request_body(payload))
|
|
143
|
+
|
|
144
|
+
async def revoke(self, name: str) -> None:
|
|
145
|
+
"""Revoke this account's own live tokens of the given name."""
|
|
146
|
+
await self._transport.request("DELETE", f"/tokens/{name}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class Admin:
|
|
150
|
+
"""The account and token surfaces, which only an admin principal may call."""
|
|
151
|
+
|
|
152
|
+
def __init__(self, transport: Transport) -> None:
|
|
153
|
+
"""Bind both admin surfaces to the instance their calls go to."""
|
|
154
|
+
self.users = Users(transport)
|
|
155
|
+
self.tokens = Tokens(transport)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""What every namespaced accessor shares: the transport, and the parsing of a response."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, SecretStr
|
|
6
|
+
|
|
7
|
+
from dirigent_client.schemas import Page
|
|
8
|
+
from dirigent_client.transport import Transport
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Resource:
|
|
12
|
+
"""One group of endpoints, bound to the connection they are called over."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, transport: Transport) -> None:
|
|
15
|
+
"""Bind the accessor to the instance its calls go to."""
|
|
16
|
+
self._transport = transport
|
|
17
|
+
|
|
18
|
+
async def _one[T: BaseModel](self, model: type[T], method: str, path: str, **kwargs: Any) -> T:
|
|
19
|
+
"""Make one request and read its body as the schema the endpoint answers with."""
|
|
20
|
+
return model.model_validate(await self._transport.json(method, path, **kwargs))
|
|
21
|
+
|
|
22
|
+
async def _many[T: BaseModel](self, model: type[T], method: str, path: str, **kwargs: Any) -> Page[T]:
|
|
23
|
+
"""Make one request and read its body as one page of the schema it answers with."""
|
|
24
|
+
payload: dict[str, Any] = await self._transport.json(method, path, **kwargs)
|
|
25
|
+
return Page(items=[model.model_validate(row) for row in payload["items"]], next=payload.get("next"))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def query(**values: object) -> dict[str, Any]:
|
|
29
|
+
"""Build a query string from the arguments a caller actually supplied."""
|
|
30
|
+
return {name: value for name, value in values.items() if value is not None}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def request_body(schema: BaseModel) -> dict[str, Any]:
|
|
34
|
+
"""Render a request schema as the JSON body it is sent as.
|
|
35
|
+
|
|
36
|
+
A field's wire spelling is its alias where it has one, which is how a body can carry a
|
|
37
|
+
key that is a Python keyword. A secret field serialises to its mask everywhere else; this
|
|
38
|
+
is the one place the value itself has to travel, so it is put back.
|
|
39
|
+
"""
|
|
40
|
+
rendered = schema.model_dump(mode="json", by_alias=True)
|
|
41
|
+
for name, value in schema:
|
|
42
|
+
if isinstance(value, SecretStr):
|
|
43
|
+
rendered[_wire_name(schema, name)] = value.get_secret_value()
|
|
44
|
+
return rendered
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _wire_name(schema: BaseModel, name: str) -> str:
|
|
48
|
+
"""Name a field the way the body spells it."""
|
|
49
|
+
field = type(schema).model_fields.get(name)
|
|
50
|
+
if field is None: # pragma: no cover - the name came from iterating the model itself
|
|
51
|
+
return name
|
|
52
|
+
return field.serialization_alias or field.alias or name
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""The block catalog: what this instance can run, and the schemas a form is built from."""
|
|
2
|
+
|
|
3
|
+
from dirigent_client.resources.base import Resource, query
|
|
4
|
+
from dirigent_client.schemas import BlockEntry, BlockKind, Catalog
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Blocks(Resource):
|
|
8
|
+
"""Read the merged catalog every installed plugin contributes to."""
|
|
9
|
+
|
|
10
|
+
async def catalog(self, *, kind: BlockKind | None = None) -> Catalog:
|
|
11
|
+
"""Read every contributed operator, sensor, storage scheme, notifier, and connection kind."""
|
|
12
|
+
return await self._one(Catalog, "GET", "/blocks", params=query(kind=kind.value if kind else None))
|
|
13
|
+
|
|
14
|
+
async def get(self, block_id: str) -> BlockEntry:
|
|
15
|
+
"""Read one catalog entry, which is what a step's configuration form is built from."""
|
|
16
|
+
return await self._one(BlockEntry, "GET", f"/blocks/{block_id}")
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Connections: coded credential records of a contributed kind, redacted in every response."""
|
|
2
|
+
|
|
3
|
+
from dirigent_client.resources.base import Resource, query, request_body
|
|
4
|
+
from dirigent_client.schemas import ConnectionIn, ConnectionOut, ConnectionUpdate, Page
|
|
5
|
+
from dirigent_common import HealthReport, JsonMap
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Connections(Resource):
|
|
9
|
+
"""Create, read, edit, check, and remove the credential records an instance holds."""
|
|
10
|
+
|
|
11
|
+
async def list(self, *, after: str | None = None, limit: int | None = None) -> Page[ConnectionOut]:
|
|
12
|
+
"""List every stored credential record, with its secrets redacted."""
|
|
13
|
+
return await self._many(ConnectionOut, "GET", "/connections", params=query(after=after, limit=limit))
|
|
14
|
+
|
|
15
|
+
async def get(self, code: str) -> ConnectionOut:
|
|
16
|
+
"""Read one credential record, with its secrets redacted."""
|
|
17
|
+
return await self._one(ConnectionOut, "GET", f"/connections/{code}")
|
|
18
|
+
|
|
19
|
+
async def create(
|
|
20
|
+
self,
|
|
21
|
+
code: str,
|
|
22
|
+
*,
|
|
23
|
+
kind: str,
|
|
24
|
+
config: JsonMap | None = None,
|
|
25
|
+
name: str | None = None,
|
|
26
|
+
description: str | None = None,
|
|
27
|
+
) -> ConnectionOut:
|
|
28
|
+
"""Validate a credential against its kind, seal its secret half, and store it."""
|
|
29
|
+
payload = ConnectionIn(code=code, name=name, kind=kind, description=description, config=config or {})
|
|
30
|
+
return await self._one(ConnectionOut, "POST", "/connections", json=request_body(payload))
|
|
31
|
+
|
|
32
|
+
async def update(
|
|
33
|
+
self,
|
|
34
|
+
code: str,
|
|
35
|
+
*,
|
|
36
|
+
config: JsonMap | None = None,
|
|
37
|
+
name: str | None = None,
|
|
38
|
+
description: str | None = None,
|
|
39
|
+
) -> ConnectionOut:
|
|
40
|
+
"""Replace a connection's settings; the config is sent whole, not merged field by field."""
|
|
41
|
+
payload = ConnectionUpdate(name=name, description=description, config=config)
|
|
42
|
+
return await self._one(ConnectionOut, "PATCH", f"/connections/{code}", json=request_body(payload))
|
|
43
|
+
|
|
44
|
+
async def delete(self, code: str) -> None:
|
|
45
|
+
"""Remove a credential record."""
|
|
46
|
+
await self._transport.request("DELETE", f"/connections/{code}")
|
|
47
|
+
|
|
48
|
+
async def check(self, code: str) -> HealthReport:
|
|
49
|
+
"""Open the credential and ask its kind whether the external system answers."""
|
|
50
|
+
return await self._one(HealthReport, "POST", f"/connections/{code}/$check")
|