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.
@@ -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)
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")