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,209 @@
|
|
|
1
|
+
"""Pipelines: applying a document, and everything that can be done to a stored one."""
|
|
2
|
+
|
|
3
|
+
import builtins
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from dirigent_client.enums import LogLevel, ProvenanceSource, RunPriority
|
|
12
|
+
from dirigent_client.errors import DirigentError
|
|
13
|
+
from dirigent_client.resources.base import Resource, query, request_body
|
|
14
|
+
from dirigent_client.schemas import (
|
|
15
|
+
ApplyRequest,
|
|
16
|
+
ApplyResult,
|
|
17
|
+
BackfillAccepted,
|
|
18
|
+
BackfillRequest,
|
|
19
|
+
JsonMap,
|
|
20
|
+
Page,
|
|
21
|
+
PipelineDetail,
|
|
22
|
+
PipelineOut,
|
|
23
|
+
PipelineVersionOut,
|
|
24
|
+
PruneRequest,
|
|
25
|
+
PruneResult,
|
|
26
|
+
RunAccepted,
|
|
27
|
+
RunRequest,
|
|
28
|
+
ValidationIssue,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
type DocumentSource = JsonMap | str | Path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Pipelines(Resource):
|
|
35
|
+
"""Apply, export, activate, delete, and run the definitions an instance holds."""
|
|
36
|
+
|
|
37
|
+
async def list(
|
|
38
|
+
self, *, after: str | None = None, limit: int | None = None, tags: Sequence[str] = ()
|
|
39
|
+
) -> Page[PipelineOut]:
|
|
40
|
+
"""List every pipeline, with how many of its runs are still in flight.
|
|
41
|
+
|
|
42
|
+
Naming more than one tag narrows: a pipeline is listed only if it wears all of them.
|
|
43
|
+
"""
|
|
44
|
+
return await self._many(
|
|
45
|
+
PipelineOut, "GET", "/pipelines", params=query(after=after, limit=limit, tag=[*tags] or None)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
async def get(self, code: str) -> PipelineDetail:
|
|
49
|
+
"""Read a pipeline and the document its current version holds."""
|
|
50
|
+
return await self._one(PipelineDetail, "GET", f"/pipelines/{code}")
|
|
51
|
+
|
|
52
|
+
async def apply(
|
|
53
|
+
self,
|
|
54
|
+
document: DocumentSource,
|
|
55
|
+
*,
|
|
56
|
+
code: str | None = None,
|
|
57
|
+
source: ProvenanceSource | None = None,
|
|
58
|
+
source_ref: str | None = None,
|
|
59
|
+
dry_run: bool = False,
|
|
60
|
+
pause_schedules: bool = False,
|
|
61
|
+
) -> ApplyResult:
|
|
62
|
+
"""Validate a document against this instance and commit a new version, or plan one.
|
|
63
|
+
|
|
64
|
+
A document this instance cannot run arrives as a 200 with ``action`` of ``invalid``
|
|
65
|
+
and the issues on the plan, not as a refusal. ``pause_schedules`` creates the
|
|
66
|
+
schedules this apply brings into being paused, and leaves existing ones alone.
|
|
67
|
+
"""
|
|
68
|
+
parsed, provenance, ref = read_document(document)
|
|
69
|
+
payload = ApplyRequest(
|
|
70
|
+
document=parsed,
|
|
71
|
+
code=code,
|
|
72
|
+
source=source if source is not None else provenance,
|
|
73
|
+
source_ref=source_ref if source_ref is not None else ref,
|
|
74
|
+
pause_schedules=pause_schedules,
|
|
75
|
+
)
|
|
76
|
+
return await self._one(
|
|
77
|
+
ApplyResult,
|
|
78
|
+
"POST",
|
|
79
|
+
"/pipelines/$apply",
|
|
80
|
+
json=request_body(payload),
|
|
81
|
+
params={"dry_run": dry_run},
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
async def prune(self, keep: Sequence[str], *, dry_run: bool = False) -> PruneResult:
|
|
85
|
+
"""Deactivate every directory-provenance pipeline whose code is not in ``keep``.
|
|
86
|
+
|
|
87
|
+
The reconcile half of a directory apply: what the directory no longer holds is
|
|
88
|
+
deactivated, never deleted, and a pipeline applied any other way is never touched.
|
|
89
|
+
"""
|
|
90
|
+
payload = PruneRequest(keep=[*keep])
|
|
91
|
+
return await self._one(
|
|
92
|
+
PruneResult,
|
|
93
|
+
"POST",
|
|
94
|
+
"/pipelines/$prune",
|
|
95
|
+
json=request_body(payload),
|
|
96
|
+
params={"dry_run": dry_run},
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
async def versions(
|
|
100
|
+
self, code: str, *, after: str | None = None, limit: int | None = None
|
|
101
|
+
) -> Page[PipelineVersionOut]:
|
|
102
|
+
"""List every immutable version, newest first, with its provenance."""
|
|
103
|
+
return await self._many(
|
|
104
|
+
PipelineVersionOut, "GET", f"/pipelines/{code}/versions", params=query(after=after, limit=limit)
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
async def export(self, code: str, *, version: int | None = None) -> str:
|
|
108
|
+
"""Render a stored pipeline as the canonical YAML a git repository holds."""
|
|
109
|
+
return await self._transport.text(f"/pipelines/{code}/$export", params=query(version=version))
|
|
110
|
+
|
|
111
|
+
async def validate(self, code: str, *, version: int | None = None) -> builtins.list[ValidationIssue]:
|
|
112
|
+
"""Report what a stored version would fail on if it ran now, against this instance.
|
|
113
|
+
|
|
114
|
+
The issues are bounded by the document, so this one answers a bare array, not a page.
|
|
115
|
+
builtins names the type because this class has a method called ``list``.
|
|
116
|
+
"""
|
|
117
|
+
rows = await self._transport.json("POST", f"/pipelines/{code}/$validate", params=query(version=version))
|
|
118
|
+
return [ValidationIssue.model_validate(row) for row in rows]
|
|
119
|
+
|
|
120
|
+
async def activate(self, code: str) -> PipelineOut:
|
|
121
|
+
"""Make a pipeline runnable again, and let its schedules fire."""
|
|
122
|
+
return await self._one(PipelineOut, "POST", f"/pipelines/{code}/$activate")
|
|
123
|
+
|
|
124
|
+
async def deactivate(self, code: str) -> PipelineOut:
|
|
125
|
+
"""Deregister a pipeline: schedules pause, it stops being runnable, history is kept."""
|
|
126
|
+
return await self._one(PipelineOut, "POST", f"/pipelines/{code}/$deactivate")
|
|
127
|
+
|
|
128
|
+
async def delete(self, code: str) -> None:
|
|
129
|
+
"""Delete a pipeline and every run ever attributed to it, refusing while any is in flight."""
|
|
130
|
+
await self._transport.request("DELETE", f"/pipelines/{code}")
|
|
131
|
+
|
|
132
|
+
async def run(
|
|
133
|
+
self,
|
|
134
|
+
code: str,
|
|
135
|
+
*,
|
|
136
|
+
params: JsonMap | None = None,
|
|
137
|
+
window: tuple[datetime, datetime] | None = None,
|
|
138
|
+
log_levels: dict[str, LogLevel] | None = None,
|
|
139
|
+
priority: RunPriority | None = None,
|
|
140
|
+
) -> RunAccepted:
|
|
141
|
+
"""Start an ad hoc run, with parameters validated against the pipeline's own schema.
|
|
142
|
+
|
|
143
|
+
A ``run_id`` of ``None`` is not a failure: the pipeline's concurrency policy declined
|
|
144
|
+
to start a second run while one is in flight, and ``detail`` says so.
|
|
145
|
+
|
|
146
|
+
``window`` is the logical data interval the run covers, half-open, which its document
|
|
147
|
+
reads as ``${run.window.start}`` and ``${run.window.end}``.
|
|
148
|
+
|
|
149
|
+
``log_levels`` is which levels the run keeps, by block-id pattern; omitted keeps
|
|
150
|
+
info and up.
|
|
151
|
+
|
|
152
|
+
``priority`` is how far ahead of other runs this one's attempts are claimed; omitted
|
|
153
|
+
takes the pipeline's own.
|
|
154
|
+
"""
|
|
155
|
+
payload = RunRequest(
|
|
156
|
+
params=params or {},
|
|
157
|
+
window_start=window[0] if window else None,
|
|
158
|
+
window_end=window[1] if window else None,
|
|
159
|
+
log_levels=log_levels,
|
|
160
|
+
priority=priority,
|
|
161
|
+
)
|
|
162
|
+
return await self._one(RunAccepted, "POST", f"/pipelines/{code}/$run", json=request_body(payload))
|
|
163
|
+
|
|
164
|
+
async def backfill(
|
|
165
|
+
self,
|
|
166
|
+
code: str,
|
|
167
|
+
*,
|
|
168
|
+
schedule: str,
|
|
169
|
+
start: datetime,
|
|
170
|
+
end: datetime,
|
|
171
|
+
params: JsonMap | None = None,
|
|
172
|
+
dry_run: bool = False,
|
|
173
|
+
) -> BackfillAccepted:
|
|
174
|
+
"""Fill the windows a schedule's cadence puts inside ``[start, end)``, oldest first.
|
|
175
|
+
|
|
176
|
+
The schedule's clock is not touched: this creates the runs that interval should
|
|
177
|
+
already have had. ``dry_run`` answers with the same plan and creates nothing.
|
|
178
|
+
"""
|
|
179
|
+
payload = BackfillRequest(
|
|
180
|
+
schedule=schedule,
|
|
181
|
+
from_=start,
|
|
182
|
+
to=end,
|
|
183
|
+
params=params,
|
|
184
|
+
dry_run=dry_run,
|
|
185
|
+
)
|
|
186
|
+
return await self._one(BackfillAccepted, "POST", f"/pipelines/{code}/$backfill", json=request_body(payload))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def read_document(document: DocumentSource) -> tuple[JsonMap, ProvenanceSource, str | None]:
|
|
190
|
+
"""Read a document from a mapping, a path, or its own text, and say where it came from."""
|
|
191
|
+
if isinstance(document, dict):
|
|
192
|
+
return dict(document), ProvenanceSource.API, None
|
|
193
|
+
if isinstance(document, Path):
|
|
194
|
+
return _parse(document.read_text()), ProvenanceSource.FILE, str(document)
|
|
195
|
+
path = Path(document)
|
|
196
|
+
if len(document) < 4096 and "\n" not in document and path.is_file():
|
|
197
|
+
return _parse(path.read_text()), ProvenanceSource.FILE, document
|
|
198
|
+
return _parse(document), ProvenanceSource.API, None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _parse(text: str) -> JsonMap:
|
|
202
|
+
"""Read a document's text as YAML, which accepts its JSON form verbatim."""
|
|
203
|
+
try:
|
|
204
|
+
loaded: object = yaml.safe_load(text)
|
|
205
|
+
except yaml.YAMLError as error:
|
|
206
|
+
raise DirigentError(f"the document is not valid YAML or JSON: {error}") from error
|
|
207
|
+
if not isinstance(loaded, dict):
|
|
208
|
+
raise DirigentError(f"a document is a mapping, not {type(loaded).__name__}")
|
|
209
|
+
return cast("JsonMap", loaded)
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Runs: reading them, acting on them, waiting for one, and following the story one tells."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
from collections.abc import AsyncIterator, Sequence
|
|
7
|
+
from datetime import timedelta
|
|
8
|
+
from typing import Final
|
|
9
|
+
from uuid import UUID
|
|
10
|
+
|
|
11
|
+
import httpx2
|
|
12
|
+
|
|
13
|
+
from dirigent_client.enums import AttemptStatus, RunStatus
|
|
14
|
+
from dirigent_client.errors import TransportError, WaitTimeout
|
|
15
|
+
from dirigent_client.resources.base import Resource, query
|
|
16
|
+
from dirigent_client.schemas import (
|
|
17
|
+
AttemptEvent,
|
|
18
|
+
AttemptOut,
|
|
19
|
+
ItemOut,
|
|
20
|
+
LogEntryOut,
|
|
21
|
+
Page,
|
|
22
|
+
RunDetail,
|
|
23
|
+
RunOut,
|
|
24
|
+
RunReport,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
POLL_SECONDS: Final = 1.0
|
|
28
|
+
POLL_MAX_SECONDS: Final = 15.0
|
|
29
|
+
POLL_BACKOFF: Final = 1.5
|
|
30
|
+
DEFAULT_WAIT: Final = timedelta(hours=1)
|
|
31
|
+
DEFAULT_RECONNECTS: Final = 5
|
|
32
|
+
RECONNECT_SECONDS: Final = 1.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Runs(Resource):
|
|
36
|
+
"""List, read, cancel, retry, and follow the runs an instance has executed."""
|
|
37
|
+
|
|
38
|
+
async def list(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
pipeline: str | None = None,
|
|
42
|
+
status: RunStatus | None = None,
|
|
43
|
+
since: str | None = None,
|
|
44
|
+
tags: Sequence[str] = (),
|
|
45
|
+
after: str | None = None,
|
|
46
|
+
limit: int | None = None,
|
|
47
|
+
) -> Page[RunOut]:
|
|
48
|
+
"""List runs newest first, filtered by pipeline, status, tag, and how far back to look.
|
|
49
|
+
|
|
50
|
+
Naming more than one tag narrows: a run is listed only if its pipeline wears all of them.
|
|
51
|
+
"""
|
|
52
|
+
return await self._many(
|
|
53
|
+
RunOut,
|
|
54
|
+
"GET",
|
|
55
|
+
"/runs",
|
|
56
|
+
params=query(
|
|
57
|
+
pipeline=pipeline,
|
|
58
|
+
status=status.value if status else None,
|
|
59
|
+
since=since,
|
|
60
|
+
tag=[*tags] or None,
|
|
61
|
+
after=after,
|
|
62
|
+
limit=limit,
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
async def get(self, run_id: UUID | str) -> RunDetail:
|
|
67
|
+
"""Read a run with the DAG view model, and how many items and attempts it has."""
|
|
68
|
+
return await self._one(RunDetail, "GET", f"/runs/{run_id}")
|
|
69
|
+
|
|
70
|
+
async def items(
|
|
71
|
+
self,
|
|
72
|
+
run_id: UUID | str,
|
|
73
|
+
*,
|
|
74
|
+
after: str | None = None,
|
|
75
|
+
limit: int | None = None,
|
|
76
|
+
) -> Page[ItemOut]:
|
|
77
|
+
"""List a run's fan-out items in the order they were created, which is grid order."""
|
|
78
|
+
return await self._many(ItemOut, "GET", f"/runs/{run_id}/items", params=query(after=after, limit=limit))
|
|
79
|
+
|
|
80
|
+
async def attempts(
|
|
81
|
+
self,
|
|
82
|
+
run_id: UUID | str,
|
|
83
|
+
*,
|
|
84
|
+
step: str | None = None,
|
|
85
|
+
status: AttemptStatus | None = None,
|
|
86
|
+
after: str | None = None,
|
|
87
|
+
limit: int | None = None,
|
|
88
|
+
) -> Page[AttemptOut]:
|
|
89
|
+
"""List a run's attempts in the order they were created, filtered by step and by state."""
|
|
90
|
+
return await self._many(
|
|
91
|
+
AttemptOut,
|
|
92
|
+
"GET",
|
|
93
|
+
f"/runs/{run_id}/attempts",
|
|
94
|
+
params=query(step=step, status=status.value if status else None, after=after, limit=limit),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
async def cancel(self, run_id: UUID | str) -> RunOut:
|
|
98
|
+
"""Stop what has not started, and tell the remote about what has."""
|
|
99
|
+
return await self._one(RunOut, "POST", f"/runs/{run_id}/$cancel")
|
|
100
|
+
|
|
101
|
+
async def retry(self, attempt_id: UUID | str, *, idempotency_key: str | None = None) -> AttemptOut:
|
|
102
|
+
"""Create one manual attempt of a failed step, reading its upstream stored outputs."""
|
|
103
|
+
key = idempotency_key or str(uuid.uuid4())
|
|
104
|
+
return await self._one(AttemptOut, "POST", f"/attempts/{attempt_id}/$retry", headers={"Idempotency-Key": key})
|
|
105
|
+
|
|
106
|
+
async def logs(
|
|
107
|
+
self,
|
|
108
|
+
run_id: UUID | str,
|
|
109
|
+
*,
|
|
110
|
+
after: str | None = None,
|
|
111
|
+
limit: int | None = None,
|
|
112
|
+
step: str | None = None,
|
|
113
|
+
) -> Page[LogEntryOut]:
|
|
114
|
+
"""Read one page of a run's log entries, in write order."""
|
|
115
|
+
return await self._many(
|
|
116
|
+
LogEntryOut, "GET", f"/runs/{run_id}/$logs", params=query(after=after, limit=limit, step=step)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
async def report(self, run_id: UUID | str) -> RunReport:
|
|
120
|
+
"""Summarise a run: what each step amounted to, and how long the whole thing took."""
|
|
121
|
+
return await self._one(RunReport, "GET", f"/runs/{run_id}/$report")
|
|
122
|
+
|
|
123
|
+
async def wait(
|
|
124
|
+
self,
|
|
125
|
+
run_id: UUID | str,
|
|
126
|
+
*,
|
|
127
|
+
timeout: timedelta = DEFAULT_WAIT,
|
|
128
|
+
poll: float = POLL_SECONDS,
|
|
129
|
+
) -> RunOut:
|
|
130
|
+
"""Poll a run until it reaches a status nothing will move it out of."""
|
|
131
|
+
deadline = time.monotonic() + timeout.total_seconds()
|
|
132
|
+
interval = poll
|
|
133
|
+
while True:
|
|
134
|
+
run = (await self.get(run_id)).run
|
|
135
|
+
if run.terminal:
|
|
136
|
+
return run
|
|
137
|
+
if time.monotonic() + interval > deadline:
|
|
138
|
+
raise WaitTimeout(
|
|
139
|
+
f"run {run_id} was still {run.status.value} after {timeout}",
|
|
140
|
+
url=self._transport.endpoint(f"/runs/{run_id}"),
|
|
141
|
+
)
|
|
142
|
+
await asyncio.sleep(interval)
|
|
143
|
+
interval = min(interval * POLL_BACKOFF, POLL_MAX_SECONDS)
|
|
144
|
+
|
|
145
|
+
async def events(
|
|
146
|
+
self,
|
|
147
|
+
run_id: UUID | str,
|
|
148
|
+
*,
|
|
149
|
+
after: int = 0,
|
|
150
|
+
reconnects: int = DEFAULT_RECONNECTS,
|
|
151
|
+
) -> AsyncIterator[AttemptEvent | LogEntryOut | RunOut]:
|
|
152
|
+
"""Stream one run's story: its attempts as they move, what they log, and how it ended.
|
|
153
|
+
|
|
154
|
+
The server closes a stream two ways. ``end`` says the run settled, and the terminal
|
|
155
|
+
state is the last thing sent before it. ``expired`` says the stream reached the
|
|
156
|
+
server's wall-clock limit with the run still going; it is reopened from the last
|
|
157
|
+
entry yielded, and costs no reconnect. A stream that closes saying neither was cut,
|
|
158
|
+
and is reopened at the cost of one of ``reconnects`` -- the same budget a transport
|
|
159
|
+
failure spends, and a log entry delivered restores it in full.
|
|
160
|
+
|
|
161
|
+
Every attempt is replayed on connect, so a consumer dedupes what it has already read.
|
|
162
|
+
"""
|
|
163
|
+
cursor = after
|
|
164
|
+
remaining = reconnects
|
|
165
|
+
while True:
|
|
166
|
+
ended = False
|
|
167
|
+
expired = False
|
|
168
|
+
advanced = False
|
|
169
|
+
try:
|
|
170
|
+
async with self._transport.stream(f"/runs/{run_id}/$events", params=query(after=cursor)) as response:
|
|
171
|
+
async for event in httpx2.EventSource(response):
|
|
172
|
+
if event.event == "end":
|
|
173
|
+
ended = True
|
|
174
|
+
break
|
|
175
|
+
if event.event == "expired":
|
|
176
|
+
expired = True
|
|
177
|
+
break
|
|
178
|
+
if not event.data:
|
|
179
|
+
continue
|
|
180
|
+
if event.event == "attempt":
|
|
181
|
+
yield AttemptEvent.model_validate_json(event.data)
|
|
182
|
+
elif event.event == "log":
|
|
183
|
+
entry = LogEntryOut.model_validate_json(event.data)
|
|
184
|
+
cursor = entry.id
|
|
185
|
+
advanced = True
|
|
186
|
+
yield entry
|
|
187
|
+
elif event.event == "run":
|
|
188
|
+
yield RunOut.model_validate_json(event.data)
|
|
189
|
+
except (TransportError, httpx2.StreamError):
|
|
190
|
+
if remaining <= 0:
|
|
191
|
+
raise
|
|
192
|
+
remaining -= 1
|
|
193
|
+
await asyncio.sleep(RECONNECT_SECONDS)
|
|
194
|
+
continue
|
|
195
|
+
if ended:
|
|
196
|
+
return
|
|
197
|
+
if advanced:
|
|
198
|
+
remaining = reconnects
|
|
199
|
+
elif not expired:
|
|
200
|
+
if remaining <= 0:
|
|
201
|
+
return
|
|
202
|
+
remaining -= 1
|
|
203
|
+
await asyncio.sleep(RECONNECT_SECONDS)
|
|
204
|
+
|
|
205
|
+
async def follow_logs(
|
|
206
|
+
self,
|
|
207
|
+
run_id: UUID | str,
|
|
208
|
+
*,
|
|
209
|
+
after: int = 0,
|
|
210
|
+
step: str | None = None,
|
|
211
|
+
reconnects: int = DEFAULT_RECONNECTS,
|
|
212
|
+
) -> AsyncIterator[LogEntryOut]:
|
|
213
|
+
"""Stream a run's log entries until the run settles, reopening a dropped connection.
|
|
214
|
+
|
|
215
|
+
The server closes a stream two ways. ``end`` says the run is terminal and there is
|
|
216
|
+
nothing more to read. ``expired`` says the stream reached the server's wall-clock
|
|
217
|
+
limit with the run still going; it is reopened from the last entry yielded, and
|
|
218
|
+
costs no reconnect. A stream that closes saying neither was cut, and is reopened at
|
|
219
|
+
the cost of one of ``reconnects`` -- the same budget a transport failure spends, and
|
|
220
|
+
an entry delivered restores it in full.
|
|
221
|
+
"""
|
|
222
|
+
cursor = after
|
|
223
|
+
remaining = reconnects
|
|
224
|
+
while True:
|
|
225
|
+
ended = False
|
|
226
|
+
expired = False
|
|
227
|
+
advanced = False
|
|
228
|
+
try:
|
|
229
|
+
params = query(follow="sse", after=cursor, step=step)
|
|
230
|
+
async with self._transport.stream(f"/runs/{run_id}/$logs", params=params) as response:
|
|
231
|
+
async for event in httpx2.EventSource(response):
|
|
232
|
+
if event.event == "end":
|
|
233
|
+
ended = True
|
|
234
|
+
break
|
|
235
|
+
if event.event == "expired":
|
|
236
|
+
expired = True
|
|
237
|
+
break
|
|
238
|
+
if event.event == "log" and event.data:
|
|
239
|
+
entry = LogEntryOut.model_validate_json(event.data)
|
|
240
|
+
cursor = entry.id
|
|
241
|
+
advanced = True
|
|
242
|
+
yield entry
|
|
243
|
+
except (TransportError, httpx2.StreamError):
|
|
244
|
+
if remaining <= 0:
|
|
245
|
+
raise
|
|
246
|
+
remaining -= 1
|
|
247
|
+
await asyncio.sleep(RECONNECT_SECONDS)
|
|
248
|
+
continue
|
|
249
|
+
if ended:
|
|
250
|
+
return
|
|
251
|
+
if advanced:
|
|
252
|
+
remaining = reconnects
|
|
253
|
+
elif not expired:
|
|
254
|
+
if remaining <= 0:
|
|
255
|
+
return
|
|
256
|
+
remaining -= 1
|
|
257
|
+
await asyncio.sleep(RECONNECT_SECONDS)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""A pipeline's schedules: their clocks, their state, and what they have actually done."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
|
|
5
|
+
from dirigent_client.enums import LogLevel, RunPriority
|
|
6
|
+
from dirigent_client.resources.base import Resource, query, request_body
|
|
7
|
+
from dirigent_client.schemas import FiringOut, Page, ScheduleIn, ScheduleOut
|
|
8
|
+
from dirigent_common import JsonMap, to_timedelta
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Schedules(Resource):
|
|
12
|
+
"""Declare, redeclare, pause, and read the schedules hanging from a pipeline."""
|
|
13
|
+
|
|
14
|
+
async def list(self, pipeline: str, *, after: str | None = None, limit: int | None = None) -> Page[ScheduleOut]:
|
|
15
|
+
"""List every schedule on a pipeline, with when each one next fires."""
|
|
16
|
+
return await self._many(
|
|
17
|
+
ScheduleOut,
|
|
18
|
+
"GET",
|
|
19
|
+
f"/pipelines/{pipeline}/triggers/schedules",
|
|
20
|
+
params=query(after=after, limit=limit),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
async def get(self, pipeline: str, code: str) -> ScheduleOut:
|
|
24
|
+
"""Read one schedule by code."""
|
|
25
|
+
return await self._one(ScheduleOut, "GET", f"/pipelines/{pipeline}/triggers/schedules/{code}")
|
|
26
|
+
|
|
27
|
+
async def create(
|
|
28
|
+
self,
|
|
29
|
+
pipeline: str,
|
|
30
|
+
code: str,
|
|
31
|
+
*,
|
|
32
|
+
name: str | None = None,
|
|
33
|
+
description: str | None = None,
|
|
34
|
+
cron: str | None = None,
|
|
35
|
+
interval: timedelta | str | None = None,
|
|
36
|
+
at: datetime | None = None,
|
|
37
|
+
timezone: str = "UTC",
|
|
38
|
+
params: JsonMap | None = None,
|
|
39
|
+
connection_pins: JsonMap | None = None,
|
|
40
|
+
log_levels: dict[str, LogLevel] | None = None,
|
|
41
|
+
priority: RunPriority | None = None,
|
|
42
|
+
) -> ScheduleOut:
|
|
43
|
+
"""Declare a schedule on a pipeline and compute when it first fires."""
|
|
44
|
+
return await self._one(
|
|
45
|
+
ScheduleOut,
|
|
46
|
+
"POST",
|
|
47
|
+
f"/pipelines/{pipeline}/triggers/schedules",
|
|
48
|
+
json=_declaration(
|
|
49
|
+
code, name, description, cron, interval, at, timezone, params, connection_pins, log_levels, priority
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
async def update(
|
|
54
|
+
self,
|
|
55
|
+
pipeline: str,
|
|
56
|
+
code: str,
|
|
57
|
+
*,
|
|
58
|
+
name: str | None = None,
|
|
59
|
+
description: str | None = None,
|
|
60
|
+
cron: str | None = None,
|
|
61
|
+
interval: timedelta | str | None = None,
|
|
62
|
+
at: datetime | None = None,
|
|
63
|
+
timezone: str = "UTC",
|
|
64
|
+
params: JsonMap | None = None,
|
|
65
|
+
connection_pins: JsonMap | None = None,
|
|
66
|
+
log_levels: dict[str, LogLevel] | None = None,
|
|
67
|
+
priority: RunPriority | None = None,
|
|
68
|
+
) -> ScheduleOut:
|
|
69
|
+
"""Change a schedule's clock or parameters, keeping whether it is paused."""
|
|
70
|
+
return await self._one(
|
|
71
|
+
ScheduleOut,
|
|
72
|
+
"PATCH",
|
|
73
|
+
f"/pipelines/{pipeline}/triggers/schedules/{code}",
|
|
74
|
+
json=_declaration(
|
|
75
|
+
code, name, description, cron, interval, at, timezone, params, connection_pins, log_levels, priority
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
async def pause(self, pipeline: str, code: str) -> ScheduleOut:
|
|
80
|
+
"""Stop a schedule firing, without losing it or its history."""
|
|
81
|
+
return await self._one(ScheduleOut, "POST", f"/pipelines/{pipeline}/triggers/schedules/{code}/$pause")
|
|
82
|
+
|
|
83
|
+
async def resume(self, pipeline: str, code: str) -> ScheduleOut:
|
|
84
|
+
"""Start a schedule firing again, from the next slot rather than the ones it missed."""
|
|
85
|
+
return await self._one(ScheduleOut, "POST", f"/pipelines/{pipeline}/triggers/schedules/{code}/$resume")
|
|
86
|
+
|
|
87
|
+
async def delete(self, pipeline: str, code: str) -> None:
|
|
88
|
+
"""Remove a schedule and its firing history."""
|
|
89
|
+
await self._transport.request("DELETE", f"/pipelines/{pipeline}/triggers/schedules/{code}")
|
|
90
|
+
|
|
91
|
+
async def firings(
|
|
92
|
+
self, pipeline: str, code: str, *, after: str | None = None, limit: int | None = None
|
|
93
|
+
) -> Page[FiringOut]:
|
|
94
|
+
"""Read what a schedule has actually done, newest first, including what it skipped."""
|
|
95
|
+
return await self._many(
|
|
96
|
+
FiringOut,
|
|
97
|
+
"GET",
|
|
98
|
+
f"/pipelines/{pipeline}/triggers/schedules/{code}/firings",
|
|
99
|
+
params=query(after=after, limit=limit),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _declaration(
|
|
104
|
+
code: str,
|
|
105
|
+
name: str | None,
|
|
106
|
+
description: str | None,
|
|
107
|
+
cron: str | None,
|
|
108
|
+
interval: timedelta | str | None,
|
|
109
|
+
at: datetime | None,
|
|
110
|
+
timezone: str,
|
|
111
|
+
params: JsonMap | None,
|
|
112
|
+
connection_pins: JsonMap | None,
|
|
113
|
+
log_levels: dict[str, LogLevel] | None,
|
|
114
|
+
priority: RunPriority | None,
|
|
115
|
+
) -> JsonMap:
|
|
116
|
+
"""Render a schedule declaration as the body the API takes."""
|
|
117
|
+
payload = ScheduleIn(
|
|
118
|
+
code=code,
|
|
119
|
+
name=name,
|
|
120
|
+
description=description,
|
|
121
|
+
cron=cron,
|
|
122
|
+
interval=to_timedelta(interval) if interval is not None else None,
|
|
123
|
+
at=at,
|
|
124
|
+
timezone=timezone,
|
|
125
|
+
params=params or {},
|
|
126
|
+
connection_pins=connection_pins or {},
|
|
127
|
+
log_levels=log_levels,
|
|
128
|
+
priority=priority,
|
|
129
|
+
)
|
|
130
|
+
return request_body(payload)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Schemas: named JSON Schemas an instance holds, addressable by code and referenced by name."""
|
|
2
|
+
|
|
3
|
+
from dirigent_client.resources.base import Resource, query, request_body
|
|
4
|
+
from dirigent_client.schemas import Page, SchemaIn, SchemaOut, SchemaUpdate
|
|
5
|
+
from dirigent_common import JsonMap
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Schemas(Resource):
|
|
9
|
+
"""Create, read, edit, and remove the JSON Schemas an instance holds."""
|
|
10
|
+
|
|
11
|
+
async def list(self, *, after: str | None = None, limit: int | None = None) -> Page[SchemaOut]:
|
|
12
|
+
"""List every stored schema."""
|
|
13
|
+
return await self._many(SchemaOut, "GET", "/schemas", params=query(after=after, limit=limit))
|
|
14
|
+
|
|
15
|
+
async def get(self, code: str) -> SchemaOut:
|
|
16
|
+
"""Read one schema by its code."""
|
|
17
|
+
return await self._one(SchemaOut, "GET", f"/schemas/{code}")
|
|
18
|
+
|
|
19
|
+
async def create(
|
|
20
|
+
self,
|
|
21
|
+
body: JsonMap,
|
|
22
|
+
*,
|
|
23
|
+
code: str | None = None,
|
|
24
|
+
name: str | None = None,
|
|
25
|
+
description: str | None = None,
|
|
26
|
+
) -> SchemaOut:
|
|
27
|
+
"""Store a JSON Schema, taking its identity from its own keywords when none is given."""
|
|
28
|
+
payload = SchemaIn(code=code, name=name, description=description, body=body)
|
|
29
|
+
return await self._one(SchemaOut, "POST", "/schemas", json=request_body(payload))
|
|
30
|
+
|
|
31
|
+
async def update(
|
|
32
|
+
self,
|
|
33
|
+
code: str,
|
|
34
|
+
*,
|
|
35
|
+
body: JsonMap | None = None,
|
|
36
|
+
name: str | None = None,
|
|
37
|
+
description: str | None = None,
|
|
38
|
+
) -> SchemaOut:
|
|
39
|
+
"""Replace a schema's body or its labels; the code is fixed."""
|
|
40
|
+
payload = SchemaUpdate(name=name, description=description, body=body)
|
|
41
|
+
return await self._one(SchemaOut, "PATCH", f"/schemas/{code}", json=request_body(payload))
|
|
42
|
+
|
|
43
|
+
async def delete(self, code: str) -> None:
|
|
44
|
+
"""Remove a schema."""
|
|
45
|
+
await self._transport.request("DELETE", f"/schemas/{code}")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""What this instance is, whether it is well, and which nodes are picking work up."""
|
|
2
|
+
|
|
3
|
+
from typing import Final
|
|
4
|
+
|
|
5
|
+
from dirigent_client.resources.base import Resource, query
|
|
6
|
+
from dirigent_client.schemas import Health, Page, Readiness, SystemInfo, WorkerOut
|
|
7
|
+
|
|
8
|
+
HTTP_SERVICE_UNAVAILABLE: Final = 503
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class System(Resource):
|
|
12
|
+
"""Describe the instance, and read the two probes a load balancer reads."""
|
|
13
|
+
|
|
14
|
+
async def info(self) -> SystemInfo:
|
|
15
|
+
"""Describe the instance, and fan out over every connection's own health check."""
|
|
16
|
+
return await self._one(SystemInfo, "GET", "/system/info")
|
|
17
|
+
|
|
18
|
+
async def health(self) -> Health:
|
|
19
|
+
"""Ask whether the process is alive, without touching any dependency."""
|
|
20
|
+
return await self._one(Health, "GET", "/health", prefixed=False)
|
|
21
|
+
|
|
22
|
+
async def ready(self) -> Readiness:
|
|
23
|
+
"""Run every registered check and report the worst status.
|
|
24
|
+
|
|
25
|
+
An unhealthy instance answers 503 with the same report, so a caller reads
|
|
26
|
+
``status`` rather than catching an exception.
|
|
27
|
+
"""
|
|
28
|
+
return await self._one(Readiness, "GET", "/health/ready", prefixed=False, accept=(HTTP_SERVICE_UNAVAILABLE,))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Workers(Resource):
|
|
32
|
+
"""Read the worker registry: who is alive, on what version, with which plugins."""
|
|
33
|
+
|
|
34
|
+
async def list(self, *, after: str | None = None, limit: int | None = None) -> Page[WorkerOut]:
|
|
35
|
+
"""List the worker registry, flagging anything stale or running different code."""
|
|
36
|
+
return await self._many(WorkerOut, "GET", "/workers", params=query(after=after, limit=limit))
|