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.
- {dirigent_plugin-0.23.4 → dirigent_plugin-0.25.0}/PKG-INFO +2 -2
- {dirigent_plugin-0.23.4 → dirigent_plugin-0.25.0}/pyproject.toml +2 -2
- {dirigent_plugin-0.23.4 → dirigent_plugin-0.25.0}/pyproject.toml.orig +2 -2
- {dirigent_plugin-0.23.4 → dirigent_plugin-0.25.0}/src/dirigent_plugin/messages.py +22 -1
- dirigent_plugin-0.25.0/src/dirigent_plugin/sources.py +325 -0
- {dirigent_plugin-0.23.4 → dirigent_plugin-0.25.0}/LICENSE +0 -0
- {dirigent_plugin-0.23.4 → dirigent_plugin-0.25.0}/README.md +0 -0
- {dirigent_plugin-0.23.4 → dirigent_plugin-0.25.0}/src/dirigent_plugin/__init__.py +0 -0
- {dirigent_plugin-0.23.4 → dirigent_plugin-0.25.0}/src/dirigent_plugin/blocks.py +0 -0
- {dirigent_plugin-0.23.4 → dirigent_plugin-0.25.0}/src/dirigent_plugin/markers.py +0 -0
- {dirigent_plugin-0.23.4 → dirigent_plugin-0.25.0}/src/dirigent_plugin/py.typed +0 -0
- {dirigent_plugin-0.23.4 → dirigent_plugin-0.25.0}/src/dirigent_plugin/runners.py +0 -0
- {dirigent_plugin-0.23.4 → dirigent_plugin-0.25.0}/src/dirigent_plugin/transforms.py +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dirigent-plugin
|
|
3
|
-
Version: 0.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|