dirigent-client 0.27.0__tar.gz → 0.28.0__tar.gz

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.
Files changed (40) hide show
  1. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/PKG-INFO +2 -2
  2. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/pyproject.toml +2 -2
  3. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/pyproject.toml.orig +2 -2
  4. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/__init__.py +8 -0
  5. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/enums.py +6 -0
  6. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/resources/alerts.py +5 -5
  7. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/resources/auth.py +19 -18
  8. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/resources/base.py +14 -0
  9. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/resources/blocks.py +2 -2
  10. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/resources/connections.py +9 -9
  11. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/resources/examples.py +2 -2
  12. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/resources/pipelines.py +25 -19
  13. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/resources/runs.py +57 -13
  14. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/resources/schedules.py +13 -9
  15. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/resources/schemas.py +4 -4
  16. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/resources/sources.py +10 -8
  17. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/resources/trigger_documents.py +3 -3
  18. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/schemas/__init__.py +27 -1
  19. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/schemas/alerts.py +5 -5
  20. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/schemas/auth.py +18 -10
  21. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/schemas/common.py +13 -2
  22. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/schemas/connections.py +37 -4
  23. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/schemas/pipelines.py +10 -7
  24. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/schemas/runs.py +85 -3
  25. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/schemas/schemas.py +4 -4
  26. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/schemas/sources.py +13 -2
  27. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/schemas/triggers.py +4 -4
  28. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/LICENSE +0 -0
  29. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/README.md +0 -0
  30. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/client.py +0 -0
  31. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/errors.py +0 -0
  32. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/messages.py +0 -0
  33. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/py.typed +0 -0
  34. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/resources/__init__.py +0 -0
  35. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/resources/system.py +0 -0
  36. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/schemas/catalog.py +0 -0
  37. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/schemas/examples.py +0 -0
  38. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/schemas/labels.py +0 -0
  39. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/schemas/system.py +0 -0
  40. {dirigent_client-0.27.0 → dirigent_client-0.28.0}/src/dirigent_client/transport.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirigent-client
3
- Version: 0.27.0
3
+ Version: 0.28.0
4
4
  Summary: The dirigent API contract: typed wire schemas and an async Python client.
5
5
  License-Expression: LicenseRef-Proprietary
6
6
  License-File: LICENSE
7
7
  Classifier: Programming Language :: Python :: 3
8
8
  Classifier: Programming Language :: Python :: 3.13
9
- Requires-Dist: dirigent-common==0.27.0
9
+ Requires-Dist: dirigent-common==0.28.0
10
10
  Requires-Dist: httpx2>=2.12.0
11
11
  Requires-Dist: pydantic>=2.13.5
12
12
  Requires-Dist: pyyaml>=6.0.3
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-client"
3
- version = "0.27.0"
3
+ version = "0.28.0"
4
4
  description = "The dirigent API contract: typed wire schemas and an async Python client."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -11,7 +11,7 @@ classifiers = [
11
11
  "Programming Language :: Python :: 3.13",
12
12
  ]
13
13
  dependencies = [
14
- "dirigent-common==0.27.0",
14
+ "dirigent-common==0.28.0",
15
15
  "httpx2>=2.12.0",
16
16
  "pydantic>=2.13.5",
17
17
  "pyyaml>=6.0.3",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-client"
3
- version = "0.27.0"
3
+ version = "0.28.0"
4
4
  description = "The dirigent API contract: typed wire schemas and an async Python client."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -11,7 +11,7 @@ classifiers = [
11
11
  "Programming Language :: Python :: 3.13",
12
12
  ]
13
13
  dependencies = [
14
- "dirigent-common==0.27.0",
14
+ "dirigent-common==0.28.0",
15
15
  "httpx2>=2.12.0",
16
16
  "pydantic>=2.13.5",
17
17
  "pyyaml>=6.0.3",
@@ -61,6 +61,7 @@ from dirigent_client.schemas import (
61
61
  Catalog,
62
62
  CheckResult,
63
63
  CheckStatus,
64
+ ConnectionDetail,
64
65
  ConnectionHealth,
65
66
  ConnectionIn,
66
67
  ConnectionOut,
@@ -93,11 +94,13 @@ from dirigent_client.schemas import (
93
94
  Readiness,
94
95
  RequestFacts,
95
96
  Requirements,
97
+ RerunRequest,
96
98
  RunAccepted,
97
99
  RunDetail,
98
100
  RunOut,
99
101
  RunReport,
100
102
  RunRequest,
103
+ RunStats,
101
104
  ScheduleIn,
102
105
  ScheduleOut,
103
106
  SchedulePreview,
@@ -115,6 +118,7 @@ from dirigent_client.schemas import (
115
118
  SystemInfo,
116
119
  TestQueued,
117
120
  TestRequest,
121
+ TokenLifetime,
118
122
  TokenOut,
119
123
  TokenRequest,
120
124
  TriggerDocumentDetail,
@@ -165,6 +169,7 @@ __all__ = [
165
169
  "CheckResult",
166
170
  "CheckStatus",
167
171
  "Conflict",
172
+ "ConnectionDetail",
168
173
  "ConnectionHealth",
169
174
  "ConnectionIn",
170
175
  "ConnectionOut",
@@ -219,6 +224,7 @@ __all__ = [
219
224
  "ProvenanceSource",
220
225
  "RateLimited",
221
226
  "Readiness",
227
+ "RerunRequest",
222
228
  "Requirements",
223
229
  "RunAccepted",
224
230
  "RunDetail",
@@ -227,6 +233,7 @@ __all__ = [
227
233
  "RunPriority",
228
234
  "RunReport",
229
235
  "RunRequest",
236
+ "RunStats",
230
237
  "RunStatus",
231
238
  "ScheduleIn",
232
239
  "ScheduleKind",
@@ -242,6 +249,7 @@ __all__ = [
242
249
  "TestRequest",
243
250
  "TokenKind",
244
251
  "TokenOut",
252
+ "TokenLifetime",
245
253
  "TokenRequest",
246
254
  "TriggerDocumentDetail",
247
255
  "TriggerDocumentOut",
@@ -8,6 +8,9 @@ class RunStatus(StrEnum):
8
8
 
9
9
  QUEUED = "queued"
10
10
  RUNNING = "running"
11
+ PAUSED = "paused"
12
+ """Held by an operator: nothing new is claimed and its deadlines stand still until it resumes."""
13
+
11
14
  SUCCEEDED = "succeeded"
12
15
  COMPLETED_WITH_ERRORS = "completed_with_errors"
13
16
  FAILED = "failed"
@@ -114,6 +117,9 @@ class TriggerKind(StrEnum):
114
117
  SOURCE = "source"
115
118
  """An input arrived at a source node, and ``triggered_by_id`` names the source."""
116
119
 
120
+ RERUN = "rerun"
121
+ """An operator reran a settled run, and ``triggered_by_id`` names that run."""
122
+
117
123
 
118
124
  class ScheduleKind(StrEnum):
119
125
  """How a schedule computes its next firing."""
@@ -4,7 +4,7 @@ from datetime import timedelta
4
4
  from uuid import UUID
5
5
 
6
6
  from dirigent_client.enums import AlertEvent, AlertScope, Importance
7
- from dirigent_client.resources.base import Resource, query, request_body
7
+ from dirigent_client.resources.base import Resource, query, request_body, segment
8
8
  from dirigent_client.schemas import (
9
9
  AlertRuleIn,
10
10
  AlertRuleOut,
@@ -76,11 +76,11 @@ class Alerts(Resource):
76
76
  """
77
77
  named = query(paused=paused, template=template, body=body, importance=importance, connection=connection)
78
78
  body_json = AlertRuleUpdate.model_validate(named).model_dump(mode="json", exclude_unset=True)
79
- return await self._one(AlertRuleOut, "PATCH", f"/alert-rules/{code}", json=body_json)
79
+ return await self._one(AlertRuleOut, "PATCH", f"/alert-rules/{segment(code)}", json=body_json)
80
80
 
81
81
  async def delete_rule(self, code: str) -> None:
82
82
  """Remove an alert rule; the notifications it already raised are kept."""
83
- await self._transport.request("DELETE", f"/alert-rules/{code}")
83
+ await self._transport.request("DELETE", f"/alert-rules/{segment(code)}")
84
84
 
85
85
  async def test(
86
86
  self,
@@ -98,11 +98,11 @@ class Alerts(Resource):
98
98
 
99
99
  async def notification(self, notification_id: UUID | str) -> NotificationOut:
100
100
  """Read one notification, which is how a caller watches a delivery it just queued."""
101
- return await self._one(NotificationOut, "GET", f"/notifications/{notification_id}")
101
+ return await self._one(NotificationOut, "GET", f"/notifications/{segment(notification_id)}")
102
102
 
103
103
  async def retry(self, notification_id: UUID | str) -> NotificationOut:
104
104
  """Put one notification back on the queue, due now."""
105
- return await self._one(NotificationOut, "POST", f"/notifications/{notification_id}/$retry")
105
+ return await self._one(NotificationOut, "POST", f"/notifications/{segment(notification_id)}/$retry")
106
106
 
107
107
  async def notifications(
108
108
  self, *, run_id: UUID | str | None = None, after: str | None = None, limit: int | None = None
@@ -1,9 +1,12 @@
1
1
  """Logging in, logging out, asking who you are, and the accounts and tokens an admin manages."""
2
2
 
3
+ from datetime import timedelta
4
+ from uuid import UUID
5
+
3
6
  from pydantic import SecretStr
4
7
 
5
8
  from dirigent_client.enums import UserRole
6
- from dirigent_client.resources.base import Resource, query, request_body
9
+ from dirigent_client.resources.base import Resource, query, request_body, segment
7
10
  from dirigent_client.schemas import (
8
11
  Identity,
9
12
  IssuedTokenOut,
@@ -100,33 +103,31 @@ class Users(Resource):
100
103
  if role is not None:
101
104
  named["role"] = role
102
105
  body = UserUpdate.model_validate(named).model_dump(mode="json", exclude_unset=True)
103
- return await self._one(UserOut, "PATCH", f"/users/{username}", json=body)
106
+ return await self._one(UserOut, "PATCH", f"/users/{segment(username)}", json=body)
104
107
 
105
108
  async def deactivate(self, username: str) -> UserOut:
106
109
  """Bar an account from logging in and revoke the sessions it already holds."""
107
- return await self._one(UserOut, "POST", f"/users/{username}/$deactivate")
110
+ return await self._one(UserOut, "POST", f"/users/{segment(username)}/$deactivate")
108
111
 
109
112
  async def activate(self, username: str) -> UserOut:
110
113
  """Let an account log in again; the sessions it lost are not restored."""
111
- return await self._one(UserOut, "POST", f"/users/{username}/$activate")
114
+ return await self._one(UserOut, "POST", f"/users/{segment(username)}/$activate")
112
115
 
113
116
  async def reset_password(self, username: str, password: str) -> None:
114
117
  """Set an account's password without its old one, ending every session it holds."""
115
118
  payload = PasswordResetRequest(password=SecretStr(password))
116
- await self._transport.request("POST", f"/users/{username}/$reset-password", json=request_body(payload))
119
+ await self._transport.request("POST", f"/users/{segment(username)}/$reset-password", json=request_body(payload))
117
120
 
118
121
  async def tokens(self, username: str, *, after: str | None = None, limit: int | None = None) -> Page[TokenOut]:
119
122
  """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))
123
+ return await self._many(
124
+ TokenOut, "GET", f"/users/{segment(username)}/tokens", params=query(after=after, limit=limit)
125
+ )
121
126
 
122
- async def create_token(self, username: str, name: str) -> IssuedTokenOut:
127
+ async def create_token(self, username: str, name: str, *, expires_in: timedelta | None = None) -> IssuedTokenOut:
123
128
  """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}")
129
+ payload = TokenRequest(name=name, expires_in=expires_in)
130
+ return await self._one(IssuedTokenOut, "POST", f"/users/{segment(username)}/tokens", json=request_body(payload))
130
131
 
131
132
 
132
133
  class Tokens(Resource):
@@ -136,14 +137,14 @@ class Tokens(Resource):
136
137
  """List every account's API tokens, without their secrets; sessions are not listed."""
137
138
  return await self._many(TokenOut, "GET", "/tokens", params=query(after=after, limit=limit))
138
139
 
139
- async def create(self, name: str) -> IssuedTokenOut:
140
+ async def create(self, name: str, *, expires_in: timedelta | None = None) -> IssuedTokenOut:
140
141
  """Mint a bearer token for this account; its secret is returned exactly once."""
141
- payload = TokenRequest(name=name)
142
+ payload = TokenRequest(name=name, expires_in=expires_in)
142
143
  return await self._one(IssuedTokenOut, "POST", "/tokens", json=request_body(payload))
143
144
 
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}")
145
+ async def revoke(self, token_id: UUID | str) -> None:
146
+ """Revoke the one live token with this id, whichever account holds it."""
147
+ await self._transport.request("DELETE", f"/tokens/{segment(str(token_id))}")
147
148
 
148
149
 
149
150
  class Admin:
@@ -2,9 +2,11 @@
2
2
 
3
3
  from enum import Enum
4
4
  from typing import Any, Final
5
+ from urllib.parse import quote
5
6
 
6
7
  from pydantic import BaseModel, SecretStr
7
8
 
9
+ from dirigent_client.errors import DirigentError
8
10
  from dirigent_client.schemas import Page
9
11
  from dirigent_client.transport import Transport
10
12
 
@@ -40,6 +42,18 @@ class Resource:
40
42
  return Page(items=[model.model_validate(row) for row in payload["items"]], next=payload.get("next"))
41
43
 
42
44
 
45
+ def segment(value: object) -> str:
46
+ """Percent-encode an identifier as exactly one path segment of a request.
47
+
48
+ A value that cannot be one is refused: an empty one or a dot segment is collapsed out of the
49
+ path, and a slash is decoded back into a separator by the server that routes the request.
50
+ """
51
+ text = str(value)
52
+ if text in ("", ".", "..") or "/" in text:
53
+ raise DirigentError(f"{text!r} is not an identifier this instance could hold")
54
+ return quote(text, safe="")
55
+
56
+
43
57
  def query(**values: object) -> dict[str, Any]:
44
58
  """Build a query string from the arguments a caller actually supplied."""
45
59
  return {name: value for name, value in values.items() if value is not None}
@@ -1,6 +1,6 @@
1
1
  """The block catalog: what this instance can run, and the schemas a form is built from."""
2
2
 
3
- from dirigent_client.resources.base import Resource, query
3
+ from dirigent_client.resources.base import Resource, query, segment
4
4
  from dirigent_client.schemas import BlockEntry, BlockKind, Catalog
5
5
 
6
6
 
@@ -13,4 +13,4 @@ class Blocks(Resource):
13
13
 
14
14
  async def get(self, block_id: str) -> BlockEntry:
15
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}")
16
+ return await self._one(BlockEntry, "GET", f"/blocks/{segment(block_id)}")
@@ -1,7 +1,7 @@
1
1
  """Connections: coded credential records of a contributed kind, redacted in every response."""
2
2
 
3
- from dirigent_client.resources.base import Resource, edit_body, query, request_body
4
- from dirigent_client.schemas import ConnectionIn, ConnectionOut, ConnectionUpdate, Page
3
+ from dirigent_client.resources.base import Resource, edit_body, query, request_body, segment
4
+ from dirigent_client.schemas import ConnectionDetail, ConnectionIn, ConnectionOut, ConnectionUpdate, Page
5
5
  from dirigent_common import HealthReport, JsonMap
6
6
 
7
7
 
@@ -12,9 +12,9 @@ class Connections(Resource):
12
12
  """List every stored credential record, with its secrets redacted."""
13
13
  return await self._many(ConnectionOut, "GET", "/connections", params=query(after=after, limit=limit))
14
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}")
15
+ async def get(self, code: str) -> ConnectionDetail:
16
+ """Read one credential record, with its secrets redacted and what uses it."""
17
+ return await self._one(ConnectionDetail, "GET", f"/connections/{segment(code)}")
18
18
 
19
19
  async def create(
20
20
  self,
@@ -43,12 +43,12 @@ class Connections(Resource):
43
43
  their own keep the name and the description the credential was created with.
44
44
  """
45
45
  sent = edit_body(ConnectionUpdate, name=name, description=description, config=config)
46
- return await self._one(ConnectionOut, "PATCH", f"/connections/{code}", json=sent)
46
+ return await self._one(ConnectionOut, "PATCH", f"/connections/{segment(code)}", json=sent)
47
47
 
48
48
  async def delete(self, code: str) -> None:
49
- """Remove a credential record."""
50
- await self._transport.request("DELETE", f"/connections/{code}")
49
+ """Remove a credential record, which the server refuses while anything still uses it."""
50
+ await self._transport.request("DELETE", f"/connections/{segment(code)}")
51
51
 
52
52
  async def check(self, code: str) -> HealthReport:
53
53
  """Open the credential and ask its kind whether the external system answers."""
54
- return await self._one(HealthReport, "POST", f"/connections/{code}/$check")
54
+ return await self._one(HealthReport, "POST", f"/connections/{segment(code)}/$check")
@@ -2,7 +2,7 @@
2
2
 
3
3
  from collections.abc import Sequence
4
4
 
5
- from dirigent_client.resources.base import Resource, query
5
+ from dirigent_client.resources.base import Resource, query, segment
6
6
  from dirigent_client.schemas import ExampleDetail, ExampleOut, Page
7
7
 
8
8
 
@@ -25,4 +25,4 @@ class Examples(Resource):
25
25
 
26
26
  async def get(self, code: str) -> ExampleDetail:
27
27
  """Read one example by its code, with the text a copy of it copies."""
28
- return await self._one(ExampleDetail, "GET", f"/examples/{code}")
28
+ return await self._one(ExampleDetail, "GET", f"/examples/{segment(code)}")
@@ -11,7 +11,7 @@ from pydantic import JsonValue
11
11
 
12
12
  from dirigent_client.enums import LogLevel, ProvenanceSource, RunPriority
13
13
  from dirigent_client.errors import DirigentError
14
- from dirigent_client.resources.base import Resource, query, request_body
14
+ from dirigent_client.resources.base import Resource, query, request_body, segment
15
15
  from dirigent_client.schemas import (
16
16
  ApplyRequest,
17
17
  ApplyResult,
@@ -28,6 +28,7 @@ from dirigent_client.schemas import (
28
28
  RunRequest,
29
29
  ValidationIssue,
30
30
  )
31
+ from dirigent_common import bounded_load
31
32
 
32
33
  type DocumentSource = JsonMap | str | Path
33
34
 
@@ -48,7 +49,7 @@ class Pipelines(Resource):
48
49
 
49
50
  async def get(self, code: str) -> PipelineDetail:
50
51
  """Read a pipeline and the document its current version holds."""
51
- return await self._one(PipelineDetail, "GET", f"/pipelines/{code}")
52
+ return await self._one(PipelineDetail, "GET", f"/pipelines/{segment(code)}")
52
53
 
53
54
  async def apply(
54
55
  self,
@@ -58,13 +59,14 @@ class Pipelines(Resource):
58
59
  source: ProvenanceSource | None = None,
59
60
  source_ref: str | None = None,
60
61
  dry_run: bool = False,
61
- pause_schedules: bool = False,
62
+ paused: bool = False,
62
63
  ) -> ApplyResult:
63
64
  """Validate a document against this instance and commit a new version, or plan one.
64
65
 
65
66
  A document this instance cannot run arrives as a 200 with ``action`` of ``invalid``
66
- and the issues on the plan, not as a refusal. ``pause_schedules`` creates the
67
- schedules this apply brings into being paused, and leaves existing ones alone.
67
+ and the issues on the plan, not as a refusal. ``paused`` creates the schedules and the
68
+ polled or held sources this apply brings into being paused, and leaves a received source
69
+ and everything the instance already holds alone.
68
70
  """
69
71
  parsed, provenance, ref = read_document(document)
70
72
  payload = ApplyRequest(
@@ -72,7 +74,7 @@ class Pipelines(Resource):
72
74
  code=code,
73
75
  source=source if source is not None else provenance,
74
76
  source_ref=source_ref if source_ref is not None else ref,
75
- pause_schedules=pause_schedules,
77
+ paused=paused,
76
78
  )
77
79
  return await self._one(
78
80
  ApplyResult,
@@ -102,12 +104,12 @@ class Pipelines(Resource):
102
104
  ) -> Page[PipelineVersionOut]:
103
105
  """List every immutable version, newest first, with its provenance."""
104
106
  return await self._many(
105
- PipelineVersionOut, "GET", f"/pipelines/{code}/versions", params=query(after=after, limit=limit)
107
+ PipelineVersionOut, "GET", f"/pipelines/{segment(code)}/versions", params=query(after=after, limit=limit)
106
108
  )
107
109
 
108
110
  async def export(self, code: str, *, version: int | None = None) -> str:
109
111
  """Render a stored pipeline as the canonical YAML a git repository holds."""
110
- return await self._transport.text(f"/pipelines/{code}/$export", params=query(version=version))
112
+ return await self._transport.text(f"/pipelines/{segment(code)}/$export", params=query(version=version))
111
113
 
112
114
  async def validate(self, code: str, *, version: int | None = None) -> builtins.list[ValidationIssue]:
113
115
  """Report what a stored version would fail on if it ran now, against this instance.
@@ -115,20 +117,22 @@ class Pipelines(Resource):
115
117
  The issues are bounded by the document, so this one answers a bare array, not a page.
116
118
  builtins names the type because this class has a method called ``list``.
117
119
  """
118
- rows = await self._transport.json("POST", f"/pipelines/{code}/$validate", params=query(version=version))
120
+ rows = await self._transport.json(
121
+ "POST", f"/pipelines/{segment(code)}/$validate", params=query(version=version)
122
+ )
119
123
  return [ValidationIssue.model_validate(row) for row in rows]
120
124
 
121
125
  async def activate(self, code: str) -> PipelineOut:
122
126
  """Make a pipeline runnable again, and let its schedules fire."""
123
- return await self._one(PipelineOut, "POST", f"/pipelines/{code}/$activate")
127
+ return await self._one(PipelineOut, "POST", f"/pipelines/{segment(code)}/$activate")
124
128
 
125
129
  async def deactivate(self, code: str) -> PipelineOut:
126
130
  """Deregister a pipeline: schedules pause, it stops being runnable, history is kept."""
127
- return await self._one(PipelineOut, "POST", f"/pipelines/{code}/$deactivate")
131
+ return await self._one(PipelineOut, "POST", f"/pipelines/{segment(code)}/$deactivate")
128
132
 
129
133
  async def delete(self, code: str) -> None:
130
134
  """Delete a pipeline and every run ever attributed to it, refusing while any is in flight."""
131
- await self._transport.request("DELETE", f"/pipelines/{code}")
135
+ await self._transport.request("DELETE", f"/pipelines/{segment(code)}")
132
136
 
133
137
  async def run(
134
138
  self,
@@ -167,7 +171,7 @@ class Pipelines(Resource):
167
171
  log_levels=log_levels,
168
172
  priority=priority,
169
173
  )
170
- return await self._one(RunAccepted, "POST", f"/pipelines/{code}/$run", json=request_body(payload))
174
+ return await self._one(RunAccepted, "POST", f"/pipelines/{segment(code)}/$run", json=request_body(payload))
171
175
 
172
176
  async def backfill(
173
177
  self,
@@ -191,7 +195,9 @@ class Pipelines(Resource):
191
195
  params=params,
192
196
  dry_run=dry_run,
193
197
  )
194
- return await self._one(BackfillAccepted, "POST", f"/pipelines/{code}/$backfill", json=request_body(payload))
198
+ return await self._one(
199
+ BackfillAccepted, "POST", f"/pipelines/{segment(code)}/$backfill", json=request_body(payload)
200
+ )
195
201
 
196
202
 
197
203
  def read_document(document: DocumentSource) -> tuple[JsonMap, ProvenanceSource, str | None]:
@@ -199,17 +205,17 @@ def read_document(document: DocumentSource) -> tuple[JsonMap, ProvenanceSource,
199
205
  if isinstance(document, dict):
200
206
  return dict(document), ProvenanceSource.API, None
201
207
  if isinstance(document, Path):
202
- return _parse(document.read_text()), ProvenanceSource.FILE, str(document)
208
+ return parse_document(document.read_text()), ProvenanceSource.FILE, str(document)
203
209
  path = Path(document)
204
210
  if len(document) < 4096 and "\n" not in document and path.is_file():
205
- return _parse(path.read_text()), ProvenanceSource.FILE, document
206
- return _parse(document), ProvenanceSource.API, None
211
+ return parse_document(path.read_text()), ProvenanceSource.FILE, document
212
+ return parse_document(document), ProvenanceSource.API, None
207
213
 
208
214
 
209
- def _parse(text: str) -> JsonMap:
215
+ def parse_document(text: str) -> JsonMap:
210
216
  """Read a document's text as YAML, which accepts its JSON form verbatim."""
211
217
  try:
212
- loaded: object = yaml.safe_load(text)
218
+ loaded: object = bounded_load(text)
213
219
  except yaml.YAMLError as error:
214
220
  raise DirigentError(f"the document is not valid YAML or JSON: {error}") from error
215
221
  if not isinstance(loaded, dict):
@@ -12,7 +12,7 @@ import httpx2
12
12
 
13
13
  from dirigent_client.enums import AttemptStatus, RunStatus
14
14
  from dirigent_client.errors import TransportError, WaitTimeout
15
- from dirigent_client.resources.base import Resource, query
15
+ from dirigent_client.resources.base import Resource, query, request_body, segment
16
16
  from dirigent_client.schemas import (
17
17
  ArtifactOut,
18
18
  AttemptEvent,
@@ -20,9 +20,12 @@ from dirigent_client.schemas import (
20
20
  ItemOut,
21
21
  LogEntryOut,
22
22
  Page,
23
+ RerunRequest,
24
+ RunAccepted,
23
25
  RunDetail,
24
26
  RunOut,
25
27
  RunReport,
28
+ RunStats,
26
29
  )
27
30
 
28
31
  #: What a run's own report document is stored as, which is how it is told from a step's output.
@@ -67,9 +70,25 @@ class Runs(Resource):
67
70
  ),
68
71
  )
69
72
 
73
+ async def stats(
74
+ self,
75
+ *,
76
+ since: str | None = None,
77
+ pipeline: str | None = None,
78
+ tags: Sequence[str] = (),
79
+ ) -> RunStats:
80
+ """Summarise the runs that settled within a window, ``7d`` when none is named.
81
+
82
+ Per day by status, per pipeline how long they took, and the errors and steps that
83
+ failed most. Naming more than one tag narrows, as it does on the listing.
84
+ """
85
+ return await self._one(
86
+ RunStats, "GET", "/runs/$stats", params=query(since=since, pipeline=pipeline, tag=[*tags] or None)
87
+ )
88
+
70
89
  async def get(self, run_id: UUID | str) -> RunDetail:
71
90
  """Read a run with the DAG view model, and how many items and attempts it has."""
72
- return await self._one(RunDetail, "GET", f"/runs/{run_id}")
91
+ return await self._one(RunDetail, "GET", f"/runs/{segment(run_id)}")
73
92
 
74
93
  async def items(
75
94
  self,
@@ -79,7 +98,9 @@ class Runs(Resource):
79
98
  limit: int | None = None,
80
99
  ) -> Page[ItemOut]:
81
100
  """List a run's fan-out items in the order they were created, which is grid order."""
82
- return await self._many(ItemOut, "GET", f"/runs/{run_id}/items", params=query(after=after, limit=limit))
101
+ return await self._many(
102
+ ItemOut, "GET", f"/runs/{segment(run_id)}/items", params=query(after=after, limit=limit)
103
+ )
83
104
 
84
105
  async def attempts(
85
106
  self,
@@ -94,7 +115,7 @@ class Runs(Resource):
94
115
  return await self._many(
95
116
  AttemptOut,
96
117
  "GET",
97
- f"/runs/{run_id}/attempts",
118
+ f"/runs/{segment(run_id)}/attempts",
98
119
  params=query(step=step, status=status.value if status else None, after=after, limit=limit),
99
120
  )
100
121
 
@@ -106,11 +127,13 @@ class Runs(Resource):
106
127
  limit: int | None = None,
107
128
  ) -> Page[ArtifactOut]:
108
129
  """List what a run wrote down: each step's stored output, and its report document."""
109
- return await self._many(ArtifactOut, "GET", f"/runs/{run_id}/artifacts", params=query(after=after, limit=limit))
130
+ return await self._many(
131
+ ArtifactOut, "GET", f"/runs/{segment(run_id)}/artifacts", params=query(after=after, limit=limit)
132
+ )
110
133
 
111
134
  async def artifact_text(self, artifact_id: UUID | str) -> str:
112
135
  """Read one artifact's content as the text it was stored as."""
113
- return await self._transport.text(f"/artifacts/{artifact_id}")
136
+ return await self._transport.text(f"/artifacts/{segment(artifact_id)}")
114
137
 
115
138
  async def report_document(self, run_id: UUID | str) -> str | None:
116
139
  """Read the markdown document this run rendered when it settled, if it rendered one."""
@@ -123,12 +146,31 @@ class Runs(Resource):
123
146
 
124
147
  async def cancel(self, run_id: UUID | str) -> RunOut:
125
148
  """Stop what has not started, and tell the remote about what has."""
126
- return await self._one(RunOut, "POST", f"/runs/{run_id}/$cancel")
149
+ return await self._one(RunOut, "POST", f"/runs/{segment(run_id)}/$cancel")
150
+
151
+ async def rerun(self, run_id: UUID | str, *, current: bool = False) -> RunAccepted:
152
+ """Run a settled run again, with its parameters and, at a source, its input.
153
+
154
+ The new run pins the version the original ran, or the pipeline's current one with
155
+ ``current``. Its ``triggered_by_id`` names the original.
156
+ """
157
+ body = RerunRequest(version="current" if current else "pinned")
158
+ return await self._one(RunAccepted, "POST", f"/runs/{segment(run_id)}/$rerun", json=request_body(body))
159
+
160
+ async def pause(self, run_id: UUID | str) -> RunOut:
161
+ """Hold a queued or running run: nothing more of it starts, and its deadlines stand still."""
162
+ return await self._one(RunOut, "POST", f"/runs/{segment(run_id)}/$pause")
163
+
164
+ async def resume(self, run_id: UUID | str) -> RunOut:
165
+ """Let a paused run go on, its open deadlines moved on by as long as it was paused."""
166
+ return await self._one(RunOut, "POST", f"/runs/{segment(run_id)}/$resume")
127
167
 
128
168
  async def retry(self, attempt_id: UUID | str, *, idempotency_key: str | None = None) -> AttemptOut:
129
169
  """Create one manual attempt of a failed step, reading its upstream stored outputs."""
130
170
  key = idempotency_key or str(uuid.uuid4())
131
- return await self._one(AttemptOut, "POST", f"/attempts/{attempt_id}/$retry", headers={"Idempotency-Key": key})
171
+ return await self._one(
172
+ AttemptOut, "POST", f"/attempts/{segment(attempt_id)}/$retry", headers={"Idempotency-Key": key}
173
+ )
132
174
 
133
175
  async def logs(
134
176
  self,
@@ -140,12 +182,12 @@ class Runs(Resource):
140
182
  ) -> Page[LogEntryOut]:
141
183
  """Read one page of a run's log entries, in write order."""
142
184
  return await self._many(
143
- LogEntryOut, "GET", f"/runs/{run_id}/$logs", params=query(after=after, limit=limit, step=step)
185
+ LogEntryOut, "GET", f"/runs/{segment(run_id)}/$logs", params=query(after=after, limit=limit, step=step)
144
186
  )
145
187
 
146
188
  async def report(self, run_id: UUID | str) -> RunReport:
147
189
  """Summarise a run: what each step amounted to, and how long the whole thing took."""
148
- return await self._one(RunReport, "GET", f"/runs/{run_id}/$report")
190
+ return await self._one(RunReport, "GET", f"/runs/{segment(run_id)}/$report")
149
191
 
150
192
  async def wait(
151
193
  self,
@@ -164,7 +206,7 @@ class Runs(Resource):
164
206
  if time.monotonic() + interval > deadline:
165
207
  raise WaitTimeout(
166
208
  f"run {run_id} was still {run.status.value} after {timeout}",
167
- url=self._transport.endpoint(f"/runs/{run_id}"),
209
+ url=self._transport.endpoint(f"/runs/{segment(run_id)}"),
168
210
  )
169
211
  await asyncio.sleep(interval)
170
212
  interval = min(interval * POLL_BACKOFF, POLL_MAX_SECONDS)
@@ -194,7 +236,9 @@ class Runs(Resource):
194
236
  expired = False
195
237
  advanced = False
196
238
  try:
197
- async with self._transport.stream(f"/runs/{run_id}/$events", params=query(after=cursor)) as response:
239
+ async with self._transport.stream(
240
+ f"/runs/{segment(run_id)}/$events", params=query(after=cursor)
241
+ ) as response:
198
242
  async for event in httpx2.EventSource(response):
199
243
  if event.event == "end":
200
244
  ended = True
@@ -254,7 +298,7 @@ class Runs(Resource):
254
298
  advanced = False
255
299
  try:
256
300
  params = query(follow="sse", after=cursor, step=step)
257
- async with self._transport.stream(f"/runs/{run_id}/$logs", params=params) as response:
301
+ async with self._transport.stream(f"/runs/{segment(run_id)}/$logs", params=params) as response:
258
302
  async for event in httpx2.EventSource(response):
259
303
  if event.event == "end":
260
304
  ended = True