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,208 @@
1
+ """A pipeline's persisted triggers: its schedules and its inbound webhooks."""
2
+
3
+ from datetime import datetime
4
+ from uuid import UUID
5
+
6
+ from pydantic import BaseModel, Field, SecretStr, field_validator
7
+
8
+ from dirigent_client.enums import (
9
+ FiringOutcome,
10
+ LogLevel,
11
+ ProvenanceSource,
12
+ RunPriority,
13
+ ScheduleKind,
14
+ WebhookOutcome,
15
+ )
16
+ from dirigent_client.schemas.common import WireModel
17
+ from dirigent_common import EntityName, JsonMap
18
+ from dirigent_common.durations import Duration
19
+
20
+
21
+ class ScheduleIn(BaseModel):
22
+ """A schedule as a caller declares it: exactly one clock, in its own timezone."""
23
+
24
+ code: EntityName
25
+ name: str | None = None
26
+ description: str | None = None
27
+ cron: str | None = None
28
+ interval: Duration | None = None
29
+ at: datetime | None = None
30
+ timezone: str = "UTC"
31
+ params: JsonMap = Field(default_factory=dict)
32
+ connection_pins: JsonMap = Field(default_factory=dict)
33
+ log_levels: dict[str, LogLevel] | None = Field(
34
+ default=None,
35
+ description=(
36
+ "The log-level map every fired run carries, as block-id patterns; the most "
37
+ "specific pattern wins. Omitted keeps info and up."
38
+ ),
39
+ )
40
+ priority: RunPriority | None = Field(
41
+ default=None,
42
+ description="The priority every fired run carries. Omitted takes the pipeline's own.",
43
+ )
44
+
45
+ @field_validator("log_levels")
46
+ @classmethod
47
+ def _a_pattern_names_something(cls, value: dict[str, LogLevel] | None) -> dict[str, LogLevel] | None:
48
+ """Refuse an empty pattern, which would match nothing and say nothing."""
49
+ if value is not None and any(not pattern for pattern in value):
50
+ raise ValueError("log_levels: a pattern may not be empty")
51
+ return value
52
+
53
+
54
+ class SchedulePreviewRequest(BaseModel):
55
+ """A clock as it is being written, for reading back what it would fire.
56
+
57
+ The three expressions are text rather than parsed values: a clock is previewed while it is
58
+ being typed, so a half-written interval is an answer saying what is wrong with it rather
59
+ than a request the server refuses to read.
60
+ """
61
+
62
+ cron: str | None = None
63
+ interval: str | None = None
64
+ at: str | None = None
65
+ timezone: str = "UTC"
66
+
67
+
68
+ class SchedulePreview(WireModel):
69
+ """The next firings of a clock nothing has declared yet, oldest first."""
70
+
71
+ firings: list[datetime]
72
+
73
+
74
+ class ScheduleOut(WireModel):
75
+ """A schedule as a listing shows it."""
76
+
77
+ id: UUID
78
+ code: str
79
+ name: str | None = None
80
+ description: str | None = None
81
+ kind: ScheduleKind
82
+ cron: str | None = None
83
+ interval: str | None = None
84
+ at: datetime | None = None
85
+ timezone: str
86
+ params: JsonMap = Field(default_factory=dict)
87
+ log_levels: dict[str, LogLevel] | None = None
88
+ priority: RunPriority | None = None
89
+ """The priority every fired run carries, or null when it takes the pipeline's own."""
90
+ paused: bool
91
+ managed: bool
92
+ trigger_document: str | None = None
93
+ """The triggers document that declares this row, or null when the pipeline's own does."""
94
+
95
+ next_fire_at: datetime | None = None
96
+ last_fired_at: datetime | None = None
97
+ created_at: datetime
98
+
99
+
100
+ class FiringOut(WireModel):
101
+ """One recorded firing: what it was due for, and what came of it."""
102
+
103
+ id: int
104
+ scheduled_for: datetime
105
+ created_at: datetime
106
+ outcome: FiringOutcome
107
+ misfired: bool
108
+ run_id: UUID | None = None
109
+ detail: str | None = None
110
+
111
+
112
+ class WebhookIn(BaseModel):
113
+ """A webhook as a caller declares it; the token is never supplied, only minted."""
114
+
115
+ code: EntityName
116
+ name: str | None = None
117
+ description: str | None = None
118
+ params_from_payload: dict[str, str] = Field(default_factory=dict[str, str])
119
+ hmac_secret: SecretStr | None = Field(default=None, min_length=1)
120
+ """The signing secret, or null for an unsigned webhook; an empty string is neither."""
121
+
122
+ rate_limit_per_minute: int = Field(default=60, ge=1)
123
+ priority: RunPriority | None = Field(
124
+ default=None,
125
+ description="The priority every accepted delivery's run carries. Omitted takes the pipeline's own.",
126
+ )
127
+
128
+
129
+ class WebhookOut(WireModel):
130
+ """A webhook as a listing shows it: everything about it except the token."""
131
+
132
+ id: UUID
133
+ code: str
134
+ name: str | None = None
135
+ description: str | None = None
136
+ token_prefix: str
137
+ params_from_payload: dict[str, str] = Field(default_factory=dict[str, str])
138
+ signed: bool
139
+ """Whether this webhook requires an HMAC signature; the secret itself never leaves."""
140
+
141
+ active: bool
142
+ managed: bool
143
+ trigger_document: str | None = None
144
+ """The triggers document that declares this row, or null when the pipeline's own does."""
145
+
146
+ rate_limit_per_minute: int
147
+ priority: RunPriority | None = None
148
+ """The priority every accepted delivery's run carries, or null when it takes the pipeline's own."""
149
+
150
+ last_delivery_at: datetime | None = None
151
+ created_at: datetime
152
+
153
+
154
+ class TriggerDocumentOut(WireModel):
155
+ """A ``kind: triggers`` document as a listing shows it."""
156
+
157
+ id: UUID
158
+ code: str
159
+ name: str | None = None
160
+ description: str | None = None
161
+ pipeline: str
162
+ """The code of the pipeline these triggers fire."""
163
+
164
+ digest: str
165
+ provenance_source: ProvenanceSource
166
+ provenance_ref: str | None = None
167
+ applied_by: str | None = None
168
+ created_at: datetime
169
+ updated_at: datetime
170
+
171
+
172
+ class TriggerDocumentDetail(TriggerDocumentOut):
173
+ """The document itself, and the rows it owns on the pipeline it names."""
174
+
175
+ document: JsonMap
176
+ schedules: list[str] = Field(default_factory=list[str])
177
+ webhooks: list[str] = Field(default_factory=list[str])
178
+
179
+
180
+ class WebhookTokenOut(WireModel):
181
+ """A freshly minted webhook token, returned once and never readable again."""
182
+
183
+ webhook_id: UUID
184
+ code: str
185
+ token: str
186
+ prefix: str
187
+ url_path: str
188
+ """Where to POST, so a caller can be configured without assembling the path by hand."""
189
+
190
+
191
+ class HookAccepted(WireModel):
192
+ """What an accepted delivery answers with: the run it started, or why it started none."""
193
+
194
+ run_id: UUID | None = None
195
+ outcome: WebhookOutcome
196
+ detail: str | None = None
197
+
198
+
199
+ class DeliveryOut(WireModel):
200
+ """One inbound delivery, and what came of it."""
201
+
202
+ id: int
203
+ created_at: datetime
204
+ outcome: WebhookOutcome
205
+ run_id: UUID | None = None
206
+ reason: str | None = None
207
+ mapped_params: JsonMap | None = None
208
+ source: str | None = None
@@ -0,0 +1,191 @@
1
+ """The one place a request is made, authenticated, retried, and turned into an exception."""
2
+
3
+ import asyncio
4
+ from collections.abc import AsyncGenerator, Mapping
5
+ from contextlib import asynccontextmanager
6
+ from typing import Any, Final, Self
7
+
8
+ import httpx2
9
+
10
+ from dirigent_client.errors import (
11
+ VERSION_HEADER,
12
+ DirigentError,
13
+ NotDirigent,
14
+ TransportError,
15
+ error_for,
16
+ parse_problem,
17
+ )
18
+
19
+ #: Where the API is unless an instance says otherwise. A server that sets ``api_prefix``
20
+ #: serves it somewhere else, and a connection has to be told the same thing.
21
+ API_PREFIX: Final = "/api/v1"
22
+
23
+
24
+ def _normalise_prefix(prefix: str) -> str:
25
+ """Spell a prefix the one way the server spells it: a leading slash, no trailing one.
26
+
27
+ The server normalises its own setting the same way, so a connection configured by hand
28
+ with "api/v1" or "/api/v1/" addresses the same instance as one configured with neither.
29
+ """
30
+ trimmed = prefix.strip().strip("/")
31
+ return f"/{trimmed}" if trimmed else ""
32
+
33
+
34
+ DEFAULT_TIMEOUT: Final = 30.0
35
+ DEFAULT_RETRIES: Final = 2
36
+ BACKOFF_SECONDS: Final = 0.25
37
+ BACKOFF_MAX_SECONDS: Final = 4.0
38
+
39
+ #: A POST or a PATCH can have taken effect before the connection broke, so repeating one
40
+ #: risks a second run, a second token, a second account.
41
+ IDEMPOTENT_METHODS: Final = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})
42
+
43
+
44
+ def backoff_for(attempt: int) -> float:
45
+ """Say how long to wait before the given retry."""
46
+ return min(BACKOFF_SECONDS * 2.0**attempt, BACKOFF_MAX_SECONDS)
47
+
48
+
49
+ def _retry_after(response: httpx2.Response) -> float | None:
50
+ """Read a Retry-After header as seconds, ignoring the HTTP-date spelling of it."""
51
+ raw: str | None = response.headers.get("retry-after")
52
+ if raw is None:
53
+ return None
54
+ try:
55
+ return float(raw)
56
+ except ValueError:
57
+ return None
58
+
59
+
60
+ class Transport:
61
+ """An authenticated connection to one instance."""
62
+
63
+ def __init__(
64
+ self,
65
+ *,
66
+ url: str,
67
+ token: str | None = None,
68
+ timeout: float = DEFAULT_TIMEOUT,
69
+ retries: int = DEFAULT_RETRIES,
70
+ headers: Mapping[str, str] | None = None,
71
+ http_transport: httpx2.AsyncBaseTransport | None = None,
72
+ api_prefix: str = API_PREFIX,
73
+ ) -> None:
74
+ """Open a connection pool bound to one base URL and one credential."""
75
+ self.url = url.rstrip("/")
76
+ self.api_prefix = _normalise_prefix(api_prefix)
77
+ self.retries = retries
78
+ sent = {**(headers or {})}
79
+ if token:
80
+ sent["Authorization"] = f"Bearer {token}"
81
+ self._client = httpx2.AsyncClient(
82
+ base_url=self.url,
83
+ headers=sent,
84
+ timeout=timeout,
85
+ transport=http_transport,
86
+ )
87
+
88
+ def session_cookie(self, name: str) -> str | None:
89
+ """Read a cookie the instance set on this connection, or report that it set none."""
90
+ return self._client.cookies.get(name)
91
+
92
+ def present(self, secret: str) -> None:
93
+ """Present this credential on every later request of this connection.
94
+
95
+ A session arrives as a cookie, and a cookie the instance marked ``Secure`` is never
96
+ sent back over plain HTTP -- so a client that relied on the jar could log in and then
97
+ be refused by the next call. A client is not a browser: it holds the credential
98
+ either way, and presenting it as a bearer token is what makes a session usable
99
+ wherever a token is.
100
+ """
101
+ self._client.headers["Authorization"] = f"Bearer {secret}"
102
+
103
+ async def __aenter__(self) -> Self:
104
+ """Enter the connection's context."""
105
+ return self
106
+
107
+ async def __aexit__(self, *_: object) -> None:
108
+ """Close the underlying connection pool."""
109
+ await self.aclose()
110
+
111
+ async def aclose(self) -> None:
112
+ """Close the underlying connection pool."""
113
+ await self._client.aclose()
114
+
115
+ def endpoint(self, path: str, *, prefixed: bool = True) -> str:
116
+ """Render one API path as the absolute URL it is fetched from."""
117
+ return f"{self.url}{self.api_prefix if prefixed else ''}{path}"
118
+
119
+ async def request(
120
+ self,
121
+ method: str,
122
+ path: str,
123
+ *,
124
+ timeout: float | None = None,
125
+ prefixed: bool = True,
126
+ accept: tuple[int, ...] = (),
127
+ **kwargs: Any,
128
+ ) -> httpx2.Response:
129
+ """Make one authenticated request, retrying what is safe and raising what is not."""
130
+ target = f"{self.api_prefix if prefixed else ''}{path}"
131
+ sent: dict[str, Any] = dict(kwargs)
132
+ if timeout is not None:
133
+ sent["timeout"] = timeout
134
+ retryable = method.upper() in IDEMPOTENT_METHODS
135
+ attempts = self.retries + 1 if retryable else 1
136
+ for attempt in range(attempts):
137
+ last = attempt == attempts - 1
138
+ try:
139
+ response = await self._client.request(method, target, **sent)
140
+ except httpx2.HTTPError as error:
141
+ if last:
142
+ raise TransportError(self.url, error) from error
143
+ await asyncio.sleep(backoff_for(attempt))
144
+ continue
145
+ if response.status_code in accept:
146
+ return response
147
+ if response.status_code >= 500 and not last:
148
+ await response.aclose()
149
+ await asyncio.sleep(backoff_for(attempt))
150
+ continue
151
+ if response.status_code >= 400:
152
+ raise self._refusal(response, target)
153
+ return response
154
+ raise DirigentError("the request was never made", url=self.endpoint(path)) # pragma: no cover
155
+
156
+ def _refusal(self, response: httpx2.Response, target: str) -> DirigentError:
157
+ """Turn an error response into the exception its status and body deserve."""
158
+ if VERSION_HEADER not in response.headers:
159
+ return NotDirigent(self.url, target, response.headers.get("content-type", ""))
160
+ try:
161
+ payload: object = response.json()
162
+ except ValueError:
163
+ payload = None
164
+ return error_for(
165
+ response.status_code,
166
+ f"{self.url}{target}",
167
+ parse_problem(payload),
168
+ retry_after=_retry_after(response),
169
+ )
170
+
171
+ async def json(self, method: str, path: str, **kwargs: Any) -> Any:
172
+ """Make one request and return its parsed body, or nothing when it answered 204."""
173
+ response = await self.request(method, path, **kwargs)
174
+ return None if response.status_code == 204 else response.json()
175
+
176
+ async def text(self, path: str, **kwargs: Any) -> str:
177
+ """GET a path that answers with text rather than JSON, such as an export."""
178
+ return (await self.request("GET", path, **kwargs)).text
179
+
180
+ @asynccontextmanager
181
+ async def stream(self, path: str, **kwargs: Any) -> AsyncGenerator[httpx2.Response]:
182
+ """Open a streaming GET, held open for as long as the caller reads it."""
183
+ target = f"{self.api_prefix}{path}"
184
+ try:
185
+ async with self._client.stream("GET", target, timeout=None, **kwargs) as response:
186
+ if response.status_code >= 400:
187
+ await response.aread()
188
+ raise self._refusal(response, target)
189
+ yield response
190
+ except httpx2.HTTPError as error:
191
+ raise TransportError(self.url, error) from error
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.4
2
+ Name: dirigent-client
3
+ Version: 0.9.0
4
+ Summary: The dirigent API contract: typed wire schemas and an async Python client.
5
+ License-Expression: LicenseRef-Proprietary
6
+ License-File: LICENSE
7
+ Requires-Dist: dirigent-common
8
+ Requires-Dist: httpx2>=2.12.0
9
+ Requires-Dist: pydantic>=2.13.5
10
+ Requires-Dist: pyyaml>=6.0.3
11
+ Requires-Python: >=3.13
12
+ Description-Content-Type: text/markdown
13
+
14
+ # dirigent-client
15
+
16
+ The dirigent API contract: typed wire schemas and an async Python client.
17
+
18
+ ```python
19
+ from datetime import timedelta
20
+
21
+ from dirigent_client import Dirigent
22
+
23
+ async with Dirigent(url="https://dirigent.example.org", token=token) as dg:
24
+ plan = await dg.pipelines.apply("pipelines/daily-climate-load.yaml", dry_run=True)
25
+ run = await dg.pipelines.run("daily-climate-load", params={"day": "2026-08-29"})
26
+ final = await dg.runs.wait(run.run_id, timeout=timedelta(hours=2))
27
+ async for entry in dg.runs.follow_logs(run.run_id):
28
+ print(entry.message)
29
+ ```
30
+
31
+ The package holds the pydantic schemas for every request and response the REST API speaks,
32
+ and `dirigent-server` imports them, so the client and the server can never disagree about a
33
+ shape. It depends on `dirigent-common`, `httpx2`, `pydantic`, and `pyyaml`, and on nothing else in the
34
+ workspace.
35
+
36
+ `docs/python.md` has the worked examples, and `examples/python/` has six runnable scripts.
@@ -0,0 +1,34 @@
1
+ dirigent_client/__init__.py,sha256=HEhLHTVb0awp1PfJiJBwnJecSDVAQaXSv4bgW9tuICs,4114
2
+ dirigent_client/client.py,sha256=XXPH8Th6aHWqR40cvVWvUPz81WnJVmm8OcRxIf89LPU,5408
3
+ dirigent_client/enums.py,sha256=54XXZvmMx9ejXzXF_mxpp6HILmecCon2Ib5WgYR0Ql0,5543
4
+ dirigent_client/errors.py,sha256=gf3EFi7DeB9ZYYLJUsKRaackbQHn0WWim2XmipkrL9U,5112
5
+ dirigent_client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ dirigent_client/resources/__init__.py,sha256=Ju5AWYzraLSqLbvOR2QKUBB71FWtW6C0VbejPONUs94,85
7
+ dirigent_client/resources/alerts.py,sha256=UJSbygTMZYWmBjvuuKp_vZqfFe8ccw8guQHUzarVouU,3889
8
+ dirigent_client/resources/auth.py,sha256=upCTGhpf5eHqsyKQgN9YXvnCfvfx2iEVNZ5dQ3v7bP0,6582
9
+ dirigent_client/resources/base.py,sha256=OV-5_7VKaGg_sRqhpSvggvtW6MJNcVqubzLhXkFsVz4,2319
10
+ dirigent_client/resources/blocks.py,sha256=D1V-AMSKICGaSiykALMjCXe6Drpo_rw7O5V4aUJxZGc,824
11
+ dirigent_client/resources/connections.py,sha256=pNWd-XT6EexSNHwtLfTvx5ICjhzjWdi2dyAyzbN8LjI,2326
12
+ dirigent_client/resources/pipelines.py,sha256=V3ny5VMR0_PkR0SPl6WLX8Cu0mLjyPzr1XM7PbAnGMk,8467
13
+ dirigent_client/resources/runs.py,sha256=OmdX9sqGM4ljl4y__MZpn73PvPWTQoQvVlitnFH3huo,9937
14
+ dirigent_client/resources/schedules.py,sha256=LS8mEayld3oWN__YnjuF-X5fZb3Xtr4B9vEjGVLAAWc,4955
15
+ dirigent_client/resources/schemas.py,sha256=x4as80oc5N2MijjqHAkSHjZvabrmyVUPIo9kRaQmBuA,1854
16
+ dirigent_client/resources/system.py,sha256=WrcwNVjXmX_iaoHNVrawUmpoUjzOFozw9VTyHgBkfP8,1574
17
+ dirigent_client/resources/trigger_documents.py,sha256=fnEooykY36R-vSUWeC01HoBKpso5RzK6L2ayBQYI9Dw,1071
18
+ dirigent_client/resources/webhooks.py,sha256=LAZ6wbx6MjrJmhvdStohuQazynIzYup1h68bdxLzmkk,3548
19
+ dirigent_client/schemas/__init__.py,sha256=PW_EXk2_1jEL1KiniS_MCK_74WAPQcHdEeTgOdQ5maQ,3412
20
+ dirigent_client/schemas/alerts.py,sha256=Oxy0DpxRYPZ1UfR7hLiLCegnui5-FYJlSTED8NQnURg,2468
21
+ dirigent_client/schemas/auth.py,sha256=4oQtgrwXCLL0uW77Dn29frAl9S3WqA3HGm2gR5jtdxM,3171
22
+ dirigent_client/schemas/catalog.py,sha256=KGCBeOKUcZ6moSiPGym3N_qhOZoZryI75lFmtwfPHOA,2328
23
+ dirigent_client/schemas/common.py,sha256=Zbdr9BOCA2fYjTMey7vA3k2vhuT9BDJp1CRCl3gWht8,1171
24
+ dirigent_client/schemas/connections.py,sha256=2CBN2VkMvdJ6R0L4pMg983P9bvIDqbb9oPhAx1cWsvs,1895
25
+ dirigent_client/schemas/pipelines.py,sha256=aekxsXFzKgGf25TMppqzCkKLLGM98LAlNfk43rAGTn4,11768
26
+ dirigent_client/schemas/runs.py,sha256=_g62IK_g0LNLy6j67SFjkBH67DYQboBc8pLgio62wsE,6017
27
+ dirigent_client/schemas/schemas.py,sha256=m5MFJxFTMzBubpNfnDkeTOwG6Q4ZS-qGIVSRUsRSL1s,1908
28
+ dirigent_client/schemas/system.py,sha256=iLUxsq8XoYrqgj-UN4q5fNAUkSECJLLpYHFuvgla5k4,2823
29
+ dirigent_client/schemas/triggers.py,sha256=nS7c-xFpL7kHDFR7JpmdFt7vcRaGWMpcgfWqAX--xVM,6489
30
+ dirigent_client/transport.py,sha256=E1jxuqoiwVoRoDE2rJF8eiRYNe2rk7gVoIMUp4i7_zw,7397
31
+ dirigent_client-0.9.0.dist-info/licenses/LICENSE,sha256=LKBm7Cx-WBc1zca4DjGxq99VEpAiWGnZDxIKmntn1hQ,910
32
+ dirigent_client-0.9.0.dist-info/WHEEL,sha256=mru_b36sH6joUMnwf7IlFCun3RoDjrNg9RcfBmEcqsE,81
33
+ dirigent_client-0.9.0.dist-info/METADATA,sha256=T4GYrcnOOtFmWGzdIqyBg99whB0v4fySng5VVW-XU8o,1366
34
+ dirigent_client-0.9.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.11
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,18 @@
1
+ Copyright (c) 2026 Morten Olav Hansen <morten@winterop.com>. All rights reserved.
2
+
3
+ This source code and accompanying documentation are the property of
4
+ Morten Olav Hansen. No license, express or implied, is granted to use, copy,
5
+ modify, merge, publish, distribute, sublicense, or sell copies of this
6
+ software or its derivatives.
7
+
8
+ The source is published for reference only. Any use beyond reading
9
+ requires written permission from the copyright holder.
10
+
11
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
12
+ OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
13
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
14
+ IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES,
15
+ OR OTHER LIABILITY ARISING FROM THE USE OF THE SOFTWARE.
16
+
17
+ Third-party components redistributed with this software, and the licences they
18
+ carry, are listed in THIRD_PARTY_NOTICES.md.