dirigent-plugin 0.23.4__tar.gz → 0.25.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.
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirigent-plugin
3
- Version: 0.23.4
3
+ Version: 0.25.0
4
4
  Summary: The dirigent plugin contract: block specs, protocols, and markers.
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.23.4
9
+ Requires-Dist: dirigent-common==0.25.0
10
10
  Requires-Dist: httpx2>=2.12.0
11
11
  Requires-Dist: pluginkit>=0.5.0
12
12
  Requires-Dist: pydantic>=2.13.5
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-plugin"
3
- version = "0.23.4"
3
+ version = "0.25.0"
4
4
  description = "The dirigent plugin contract: block specs, protocols, and markers."
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.23.4",
14
+ "dirigent-common==0.25.0",
15
15
  "httpx2>=2.12.0",
16
16
  "pluginkit>=0.5.0",
17
17
  "pydantic>=2.13.5",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-plugin"
3
- version = "0.23.4"
3
+ version = "0.25.0"
4
4
  description = "The dirigent plugin contract: block specs, protocols, and markers."
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.23.4",
14
+ "dirigent-common==0.25.0",
15
15
  "httpx2>=2.12.0",
16
16
  "pluginkit>=0.5.0",
17
17
  "pydantic>=2.13.5",
@@ -27,7 +27,6 @@ NOT_AN_ARRAY = PLUGIN.define(
27
27
 
28
28
  NOTHING_TO_CONVERT = PLUGIN.define("nothing_to_convert", "there is nothing at {uri} to convert")
29
29
 
30
-
31
30
  # What a config refuses at validation. Pydantic owns the code a validator's refusal reaches
32
31
  # the wire under, so these are rendered into the ``ValueError`` it wraps.
33
32
 
@@ -42,3 +41,25 @@ INVALID_MARK = PLUGIN.define(
42
41
  "invalid_mark",
43
42
  "the mark connection kind {kind} declares is not SVG path data: {detail}",
44
43
  )
44
+
45
+ # What a received source's path template refuses.
46
+
47
+ ROUTE_NOT_ROOTED = PLUGIN.define("route_not_rooted", "the path {template} does not start with '/'")
48
+
49
+ ROUTE_BAD_PARAMETER = PLUGIN.define(
50
+ "route_bad_parameter",
51
+ "the path {template} has the segment {segment}, and a parameter is a whole segment written "
52
+ "as {{name}}, or {{name:path}} for the rest of the path, with a snake_case name",
53
+ )
54
+
55
+ ROUTE_BAD_SEGMENT = PLUGIN.define(
56
+ "route_bad_segment", "the path {template} has the segment {segment}, which a path cannot carry"
57
+ )
58
+
59
+ ROUTE_REPEATED_PARAMETER = PLUGIN.define(
60
+ "route_repeated_parameter", "the path {template} names the parameter {name} twice"
61
+ )
62
+
63
+ ROUTE_TAIL_NOT_LAST = PLUGIN.define(
64
+ "route_tail_not_last", "the path {template} has {segment} before its end, and a tail takes the rest of the path"
65
+ )
@@ -0,0 +1,325 @@
1
+ """The source contract: where an input enters a pipeline, and how a kind turns it into one.
2
+
3
+ A source is a pipeline's first node. Each input it takes becomes one run, in which the node is
4
+ already settled and its output is the input. A kind is hosted one of three ways: the server
5
+ receives it, a worker polls it, or a worker holds it open.
6
+ """
7
+
8
+ import re
9
+ from abc import ABC, abstractmethod
10
+ from datetime import datetime, timedelta
11
+ from enum import StrEnum
12
+ from typing import Any, ClassVar, Final, Literal, Protocol
13
+
14
+ from jsonschema import FormatChecker
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+ from dirigent_common import Issue, JsonMap, Message
18
+ from dirigent_plugin.blocks import ErrorClass, Logger, classify_default
19
+ from dirigent_plugin.messages import (
20
+ ROUTE_BAD_PARAMETER,
21
+ ROUTE_BAD_SEGMENT,
22
+ ROUTE_NOT_ROOTED,
23
+ ROUTE_REPEATED_PARAMETER,
24
+ ROUTE_TAIL_NOT_LAST,
25
+ )
26
+
27
+ #: What a document writes as ``source.kind``: a bare word, or a pack's dotted one.
28
+ SOURCE_KIND_PATTERN: Final = r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$"
29
+
30
+ #: The methods a received source may declare.
31
+ ROUTE_METHODS: Final = ("GET", "POST", "PUT", "PATCH", "DELETE")
32
+
33
+ #: One template parameter: ``{name}`` matches a segment, ``{name:path}`` the rest of the path.
34
+ _PARAMETER: Final = re.compile(r"^\{([a-z_][a-z0-9_]*)(:path)?\}$")
35
+
36
+ #: What a literal segment may hold: anything a path segment carries but a brace or a query.
37
+ _LITERAL: Final = re.compile(r"^[^{}?#/]+$")
38
+
39
+
40
+ class SourceHosting(StrEnum):
41
+ """Where a kind's inputs come from, which decides what runs it."""
42
+
43
+ RECEIVED = "received"
44
+ """The server takes a request and turns it into an input."""
45
+
46
+ POLLED = "polled"
47
+ """A worker asks on a cadence, from the source's row."""
48
+
49
+ HELD = "held"
50
+ """A worker holds a listener open under a lease."""
51
+
52
+
53
+ class SourceSpec(BaseModel):
54
+ """The catalog entry for a source kind."""
55
+
56
+ model_config = ConfigDict(frozen=True)
57
+
58
+ kind: str = Field(pattern=SOURCE_KIND_PATTERN)
59
+ summary: str = Field(min_length=1)
60
+ group: str | None = None
61
+ hosting: SourceHosting
62
+ replies: bool = False
63
+ """Whether a reply node may answer an input of this kind."""
64
+
65
+ default_poll: timedelta | None = None
66
+
67
+
68
+ class SourceAuth(BaseModel):
69
+ """How a received source authenticates its callers."""
70
+
71
+ model_config = ConfigDict(frozen=True, extra="forbid", use_attribute_docstrings=True)
72
+
73
+ kind: Literal["token", "public"] = "token"
74
+ """``token`` takes ``Authorization: Bearer`` with the token the instance minted; ``public`` admits anyone."""
75
+
76
+
77
+ class SourceRate(BaseModel):
78
+ """How fast one received source takes calls: a bucket refilled at ``per_minute``, holding ``burst``."""
79
+
80
+ model_config = ConfigDict(frozen=True, extra="forbid", use_attribute_docstrings=True)
81
+
82
+ per_minute: int = Field(default=600, ge=1)
83
+ burst: int | None = Field(default=None, ge=1)
84
+ """How many calls the bucket holds when it is full; ``per_minute`` when unset."""
85
+
86
+
87
+ class Route(BaseModel):
88
+ """The methods and the path template a received source answers.
89
+
90
+ A template is rooted, and each segment is a literal, ``{name}``, or a closing
91
+ ``{name:path}`` that takes the rest of the path.
92
+ """
93
+
94
+ model_config = ConfigDict(frozen=True)
95
+
96
+ methods: tuple[str, ...]
97
+ template: str
98
+
99
+ @property
100
+ def normalised(self) -> str:
101
+ """Spell the template without its parameter names, which is what two routes collide on."""
102
+ return "/".join(_blank(segment) for segment in self.template.split("/"))
103
+
104
+ @property
105
+ def literal_segments(self) -> int:
106
+ """Count the literal segments, which is how specific a match on this template is."""
107
+ return sum(1 for segment in _segments(self.template) if _PARAMETER.match(segment) is None)
108
+
109
+ def match(self, path: str) -> dict[str, str] | None:
110
+ """Read the path parameters this template takes from a concrete path, or None when it does not match."""
111
+ written = _segments(self.template)
112
+ given = _segments(path)
113
+ taken: dict[str, str] = {}
114
+ for index, segment in enumerate(written):
115
+ parameter = _PARAMETER.match(segment)
116
+ if parameter is not None and parameter.group(2):
117
+ rest = given[index:]
118
+ if not rest:
119
+ return None
120
+ taken[parameter.group(1)] = "/".join(rest)
121
+ return taken
122
+ if index >= len(given):
123
+ return None
124
+ if parameter is not None:
125
+ taken[parameter.group(1)] = given[index]
126
+ elif segment != given[index]:
127
+ return None
128
+ return taken if len(given) == len(written) else None
129
+
130
+ def fill(self, path_params: dict[str, str]) -> str:
131
+ """Write the concrete path this template takes for a set of parameters, leaving a missing one as written."""
132
+ filled: list[str] = []
133
+ for segment in self.template.split("/"):
134
+ parameter = _PARAMETER.match(segment)
135
+ filled.append(path_params.get(parameter.group(1), segment) if parameter is not None else segment)
136
+ return "/".join(filled)
137
+
138
+
139
+ def _segments(path: str) -> list[str]:
140
+ """Split a path into its segments, ignoring the leading slash and a trailing one."""
141
+ return [segment for segment in path.strip("/").split("/") if segment != ""] if path.strip("/") else []
142
+
143
+
144
+ def _blank(segment: str) -> str:
145
+ """Replace a parameter's name with nothing, keeping whether it takes a tail."""
146
+ parameter = _PARAMETER.match(segment)
147
+ if parameter is None:
148
+ return segment
149
+ return "{:path}" if parameter.group(2) else "{}"
150
+
151
+
152
+ def template_issues(template: str) -> list[Issue]:
153
+ """Say what is wrong with a path template, or nothing when it is one."""
154
+ if not template.startswith("/"):
155
+ return [Issue.of(ROUTE_NOT_ROOTED, template=repr(template))]
156
+ segments = _segments(template)
157
+ issues: list[Issue] = []
158
+ seen: set[str] = set()
159
+ for index, segment in enumerate(segments):
160
+ parameter = _PARAMETER.match(segment)
161
+ if parameter is None:
162
+ if "{" in segment or "}" in segment:
163
+ issues.append(Issue.of(ROUTE_BAD_PARAMETER, template=repr(template), segment=repr(segment)))
164
+ elif _LITERAL.match(segment) is None:
165
+ issues.append(Issue.of(ROUTE_BAD_SEGMENT, template=repr(template), segment=repr(segment)))
166
+ continue
167
+ name = parameter.group(1)
168
+ if name in seen:
169
+ issues.append(Issue.of(ROUTE_REPEATED_PARAMETER, template=repr(template), name=repr(name)))
170
+ seen.add(name)
171
+ if parameter.group(2) and index != len(segments) - 1:
172
+ issues.append(Issue.of(ROUTE_TAIL_NOT_LAST, template=repr(template), segment=repr(segment)))
173
+ return issues
174
+
175
+
176
+ class InboundRequest(BaseModel):
177
+ """One request as a received kind is handed it: the head, and the body once it is read.
178
+
179
+ ``headers`` holds every header lowercased, credentials included, because a kind that
180
+ verifies a signature needs the one it was sent; what a kind writes into its output is its
181
+ own business.
182
+ """
183
+
184
+ model_config = ConfigDict(frozen=True)
185
+
186
+ method: str
187
+ path: str
188
+ route: str
189
+ """The template the path matched."""
190
+
191
+ path_params: dict[str, str] = Field(default_factory=dict[str, str])
192
+ query: list[tuple[str, str]] = Field(default_factory=list[tuple[str, str]])
193
+ """The query string's pairs in the order they were written, a repeated name repeated."""
194
+
195
+ headers: dict[str, str] = Field(default_factory=dict[str, str])
196
+ body: bytes = b""
197
+ received_at: datetime
198
+
199
+ @property
200
+ def content_type(self) -> str | None:
201
+ """The content type the body arrived under, as the header spelled it."""
202
+ return self.headers.get("content-type")
203
+
204
+
205
+ class Ignore(BaseModel):
206
+ """An input that is a sign of life and creates no run, such as a handshake or a heartbeat."""
207
+
208
+ model_config = ConfigDict(frozen=True)
209
+
210
+ reason: str | None = None
211
+
212
+
213
+ class Refusal(BaseModel):
214
+ """An input a kind refuses before any run exists, and the status its caller is answered with."""
215
+
216
+ model_config = ConfigDict(frozen=True)
217
+
218
+ status: int
219
+ code: str
220
+ message: str
221
+ params: JsonMap = Field(default_factory=dict)
222
+ headers: dict[str, str] = Field(default_factory=dict[str, str])
223
+
224
+ @classmethod
225
+ def of(cls, message: Message, /, *, status: int, headers: dict[str, str] | None = None, **params: Any) -> "Refusal":
226
+ """Build a refusal by rendering a catalogued message with its params."""
227
+ return cls(
228
+ status=status,
229
+ code=message.code,
230
+ message=message.render(**params),
231
+ params=params,
232
+ headers=dict(headers or {}),
233
+ )
234
+
235
+
236
+ class ReceiveContext(Protocol):
237
+ """What a received kind may reach while it turns a request into an input: no connection, no secret."""
238
+
239
+ @property
240
+ def log(self) -> Logger:
241
+ """Where the kind says what it did with the request."""
242
+ ...
243
+
244
+ @property
245
+ def format_checker(self) -> FormatChecker:
246
+ """The instance's assembled format checker."""
247
+ ...
248
+
249
+
250
+ class Source[ConfigT: BaseModel, OutputT: BaseModel](ABC):
251
+ """One source kind: its config, its output, and what it refuses at apply."""
252
+
253
+ spec: ClassVar[SourceSpec]
254
+ config_model: ClassVar[type[BaseModel]]
255
+ output_model: ClassVar[type[BaseModel]]
256
+ mark: ClassVar[str | None] = None
257
+ """The ``d`` of one SVG path on a 24-unit grid the kind is drawn by, or None to be drawn by its hosting."""
258
+
259
+ def check_config(self, config: ConfigT) -> list[Issue]:
260
+ """List the refusals this kind makes at apply beyond its config schema, located under ``source``."""
261
+ return []
262
+
263
+ def max_in_flight(self, config: ConfigT) -> int | None:
264
+ """Say how many unsettled runs from this source may exist before an input is refused, or None for no bound."""
265
+ return None
266
+
267
+ def cursor_key(self, config: ConfigT) -> JsonMap | None:
268
+ """Name the config that a stored cursor belongs to; a change of it resets the cursor."""
269
+ return None
270
+
271
+ def classify_error(self, error: Exception) -> ErrorClass:
272
+ """Classify a failure the kind raised."""
273
+ return classify_default(error)
274
+
275
+ @abstractmethod
276
+ def complete(self, config: ConfigT, given: JsonMap, *, received_at: datetime) -> OutputT:
277
+ """Build a whole input from a partial one, filling what it omits from the config.
278
+
279
+ A hand run and a sample are written as much of an input as matters, and this is what
280
+ makes them one the next step can read. It raises ``ValueError`` for one it cannot make
281
+ whole.
282
+ """
283
+ ...
284
+
285
+ def summary_of(self, config: ConfigT) -> str | None:
286
+ """Say in one line what this source listens to, for a listing."""
287
+ return None
288
+
289
+
290
+ class ReceivedSource[ConfigT: BaseModel, OutputT: BaseModel](Source[ConfigT, OutputT]):
291
+ """A kind the server receives: a route, a check on the head, then the body read into an input."""
292
+
293
+ @abstractmethod
294
+ def route(self, config: ConfigT) -> Route:
295
+ """Name the methods and the template this source answers."""
296
+ ...
297
+
298
+ def max_body(self, config: ConfigT) -> int | None:
299
+ """Say how many bytes of body this source reads at most, or None for the instance's cap."""
300
+ return None
301
+
302
+ def auth(self, config: ConfigT) -> SourceAuth:
303
+ """Say how a caller authenticates: a token the instance mints, unless the config writes out public."""
304
+ return SourceAuth()
305
+
306
+ def rate(self, config: ConfigT) -> SourceRate | None:
307
+ """Say how fast this source takes calls, or None for the instance's intake bucket alone."""
308
+ return None
309
+
310
+ def admit(self, config: ConfigT, head: InboundRequest) -> Refusal | None:
311
+ """Refuse a request on its head alone, before a byte of its body is read."""
312
+ return None
313
+
314
+ @abstractmethod
315
+ async def receive(
316
+ self, config: ConfigT, request: InboundRequest, ctx: ReceiveContext
317
+ ) -> OutputT | Ignore | Refusal:
318
+ """Turn one request into an input, an ignored sign of life, or a refusal.
319
+
320
+ It runs in the server process: it does no I/O beyond the request it was handed.
321
+ """
322
+ ...
323
+
324
+
325
+ type AnySource = Source[Any, Any]