dirigent-block-http 0.17.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ """The HTTP block family: calling an endpoint, waiting for one, and posting a signed body."""
2
+
3
+ from dirigent_block_http.connections import HttpConnectionKind
4
+ from dirigent_block_http.http import HttpReadySensor, HttpRequestOperator
5
+ from dirigent_block_http.webhooks import WebhookPostOperator
6
+ from dirigent_plugin import Contribution, extension
7
+
8
+
9
+ class HttpBlocks:
10
+ """The plugin object the host discovers under the dirigent.plugins.v1 entry-point group."""
11
+
12
+ @extension
13
+ def contribute(self) -> Contribution:
14
+ """Contribute the HTTP blocks and the connection kind they are addressed through."""
15
+ return Contribution(
16
+ operators=[HttpRequestOperator(), WebhookPostOperator()],
17
+ sensors=[HttpReadySensor()],
18
+ connection_kinds=[HttpConnectionKind()],
19
+ )
20
+
21
+
22
+ plugin = HttpBlocks()
23
+
24
+ __all__ = [
25
+ "HttpBlocks",
26
+ "HttpConnectionKind",
27
+ "HttpReadySensor",
28
+ "HttpRequestOperator",
29
+ "WebhookPostOperator",
30
+ "plugin",
31
+ ]
@@ -0,0 +1,36 @@
1
+ """The generic HTTP connection kind, and the client one is turned into.
2
+
3
+ The configuration it registers is :class:`dirigent_common.HttpConnectionConfig`, so an adapter
4
+ pack presents the same HTTP connection as this one rather than redefining base URL, auth, TLS
5
+ and timeouts into a fourth slightly different form.
6
+ """
7
+
8
+ from typing import ClassVar
9
+
10
+ import httpx2
11
+ from pydantic import BaseModel
12
+
13
+ from dirigent_common import HealthReport, HttpConnectionConfig, build_client
14
+ from dirigent_plugin import ConnectionKind
15
+
16
+
17
+ class HttpConnectionKind(ConnectionKind):
18
+ """The connection kind every generic HTTP block resolves its credentials through."""
19
+
20
+ id: ClassVar[str] = "http"
21
+ config_model: ClassVar[type[BaseModel]] = HttpConnectionConfig
22
+
23
+ async def check(self, config: BaseModel) -> HealthReport:
24
+ """Request the health path and report whether the service answered."""
25
+ settings = HttpConnectionConfig.model_validate(config.model_dump())
26
+ try:
27
+ async with build_client(settings) as client:
28
+ response = await client.get(settings.health_path)
29
+ except httpx2.HTTPError as error:
30
+ return HealthReport(healthy=False, detail=f"{type(error).__name__}: {error}")
31
+ healthy = response.status_code < 500
32
+ return HealthReport(
33
+ healthy=healthy,
34
+ detail=f"HTTP {response.status_code}",
35
+ version=response.headers.get("server"),
36
+ )
@@ -0,0 +1,323 @@
1
+ """The generic HTTP blocks: one operator that calls a service, one sensor that waits for one."""
2
+
3
+ import json
4
+ import time
5
+ from datetime import timedelta
6
+ from typing import ClassVar, Literal
7
+
8
+ import httpx2
9
+ from pydantic import BaseModel, Field, JsonValue, model_validator
10
+
11
+ from dirigent_block_http.connections import HttpConnectionConfig
12
+ from dirigent_block_http.messages import (
13
+ NO_TARGET,
14
+ RESPONSE_TOO_LARGE,
15
+ STATUS_REFUSED,
16
+ )
17
+ from dirigent_common import BlockModel, Duration, Size
18
+ from dirigent_plugin import (
19
+ BlockFailure,
20
+ ConnectionRef,
21
+ ErrorClass,
22
+ NotYet,
23
+ Operator,
24
+ OperatorSpec,
25
+ RemoteHandle,
26
+ Sensor,
27
+ SensorSpec,
28
+ StepContext,
29
+ )
30
+
31
+ type HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
32
+
33
+ DEFAULT_TEXT_CONTENT_TYPE = "text/plain; charset=utf-8"
34
+
35
+ JSON_CONTENT_TYPE = "application/json"
36
+
37
+
38
+ class HttpTarget(BlockModel):
39
+ """The half of an HTTP block's config that says which service to talk to."""
40
+
41
+ connection: ConnectionRef | None = None
42
+ """The code of the connection whose base URL, auth, TLS, and timeout apply."""
43
+
44
+ url: str | None = None
45
+ """An absolute URL, for the case where no connection is configured."""
46
+
47
+ path: str = "/"
48
+ """The path resolved against the connection's base URL."""
49
+
50
+ timeout: Duration | None = Field(default=None, gt=timedelta(0))
51
+ """Overrides the connection's timeout for this call alone."""
52
+
53
+ follow_redirects: bool = False
54
+ """Whether a 3xx is followed rather than returned as the answer.
55
+
56
+ A redirect to another host arrives without the connection's credentials: the client drops
57
+ the ``Authorization`` header when the origin changes, so a service that redirects before
58
+ authenticating must be configured with the URL it redirects to.
59
+ """
60
+
61
+ @model_validator(mode="after")
62
+ def _require_a_target(self) -> "HttpTarget":
63
+ """Reject a config that names neither a connection nor an absolute URL."""
64
+ if not self.connection and not self.url:
65
+ raise ValueError(NO_TARGET.render())
66
+ return self
67
+
68
+
69
+ def client_for(target: HttpTarget, ctx: StepContext) -> httpx2.AsyncClient:
70
+ """Build the client one call uses: the connection's, or a bare one for an absolute URL."""
71
+ if target.connection:
72
+ return ctx.http(target.connection)
73
+ fallback: timedelta = HttpConnectionConfig.model_fields["timeout"].default
74
+ return httpx2.AsyncClient(timeout=(target.timeout or fallback).total_seconds())
75
+
76
+
77
+ def request_timeout(target: HttpTarget, client: httpx2.AsyncClient) -> httpx2.Timeout:
78
+ """The timeout one request carries: the step's override, or the client's own.
79
+
80
+ ``build_request`` reads an explicit ``timeout=None`` as "no timeout on any phase" rather
81
+ than as "whatever the client is configured with", so a step that configures no override
82
+ is given the client's own timeout rather than the unset field.
83
+ """
84
+ if target.timeout is None:
85
+ return client.timeout
86
+ return httpx2.Timeout(target.timeout.total_seconds())
87
+
88
+
89
+ def request_url(target: HttpTarget) -> str:
90
+ """Resolve what one call requests: the absolute URL, or the path on the connection."""
91
+ return target.url or target.path
92
+
93
+
94
+ def decode(response: httpx2.Response, payload: bytes) -> tuple[JsonValue | None, str | None]:
95
+ """Split a body into its parsed JSON and its text, whichever it turned out to be."""
96
+ if "json" in response.headers.get("content-type", ""):
97
+ try:
98
+ parsed: JsonValue = json.loads(payload)
99
+ except ValueError:
100
+ return None, _as_text(payload)
101
+ return parsed, None
102
+ return None, _as_text(payload)
103
+
104
+
105
+ def body_of(response: httpx2.Response, payload: bytes) -> JsonValue:
106
+ """The one value an output carries: the parsed JSON when the answer is JSON, else the text."""
107
+ parsed, text = decode(response, payload)
108
+ return text if parsed is None else parsed
109
+
110
+
111
+ def _as_text(payload: bytes) -> str:
112
+ """Render a body as the text an output carries."""
113
+ return payload.decode("utf-8", errors="replace")
114
+
115
+
116
+ async def read_bounded(response: httpx2.Response, limit: int) -> bytes:
117
+ """Read a whole body, refusing one larger than the step said it would hold.
118
+
119
+ Counted as it arrives rather than trusted from a header, because content-length is the
120
+ service's claim and this is the worker's memory.
121
+ """
122
+ chunks: list[bytes] = []
123
+ total = 0
124
+ async for chunk in response.aiter_bytes():
125
+ total += len(chunk)
126
+ if total > limit:
127
+ raise BlockFailure(RESPONSE_TOO_LARGE, error_class=ErrorClass.REJECTED, limit=limit)
128
+ chunks.append(chunk)
129
+ return b"".join(chunks)
130
+
131
+
132
+ class HttpRequestConfig(HttpTarget):
133
+ """What one HTTP call sends, and which responses count as success."""
134
+
135
+ method: HttpMethod = "GET"
136
+ query: dict[str, str | int | float | bool] = Field(default_factory=dict[str, str | int | float | bool])
137
+ headers: dict[str, str] = Field(default_factory=dict[str, str])
138
+ body: JsonValue | None = None
139
+ """What the request sends, usually a reference to what an earlier step produced.
140
+
141
+ A string is sent as it stands, which is what a query language, an XML document or a csv
142
+ upload goes in; any other value is serialised and sent as JSON. A document held in
143
+ storage is read by ``storage.read`` first and referenced here.
144
+ """
145
+
146
+ content_type: str | None = None
147
+ """The content type the body is sent with, overriding the default for what it carries.
148
+
149
+ A string defaults to ``text/plain; charset=utf-8`` and any other value to
150
+ ``application/json``, so this is where an endpoint that wants ``text/csv`` or
151
+ ``application/xml`` is told.
152
+ """
153
+
154
+ success_status: list[int] = Field(default_factory=list[int])
155
+ """Status codes that count as success; empty means any 2xx."""
156
+
157
+ max_response: Size = 32 * 1024 * 1024
158
+ """How much of a response is read into memory.
159
+
160
+ A body has to be whole to be parsed, so one too large to hold is refused rather than
161
+ truncated: half a JSON document is not a smaller answer, it is a wrong one, and a step
162
+ that acted on it would be acting on something the service never said.
163
+ """
164
+
165
+ def request_body(self) -> tuple[dict[str, str], bytes | None]:
166
+ """The headers and the bytes one request is built with, the body serialised here.
167
+
168
+ Serialised by the block rather than by the client, so the content type and the bytes
169
+ are decided in one place.
170
+ """
171
+ if self.body is None:
172
+ return dict(self.headers), None
173
+ if isinstance(self.body, str):
174
+ content, declared = self.body.encode(), DEFAULT_TEXT_CONTENT_TYPE
175
+ else:
176
+ content = json.dumps(self.body, separators=(",", ":"), ensure_ascii=False).encode()
177
+ declared = JSON_CONTENT_TYPE
178
+ # Rebuilt without the header so one spelling of it reaches the client, whichever
179
+ # case the document wrote.
180
+ headers = {name: value for name, value in self.headers.items() if name.lower() != "content-type"}
181
+ named = next((value for name, value in self.headers.items() if name.lower() == "content-type"), None)
182
+ headers["content-type"] = self.content_type or named or declared
183
+ return headers, content
184
+
185
+
186
+ class HttpRequestOutput(BlockModel):
187
+ """What one HTTP call observed, which downstream steps reference by field."""
188
+
189
+ status: int
190
+ headers: dict[str, str]
191
+ body: JsonValue = None
192
+ """What the service answered: the parsed document when it is JSON, else the text."""
193
+
194
+ body_bytes: int
195
+ """How many bytes the answer was."""
196
+
197
+ duration_ms: int
198
+
199
+
200
+ class HttpRequestOperator(Operator[HttpRequestConfig, HttpRequestOutput]):
201
+ """Calls an HTTP service once and reports what it said."""
202
+
203
+ spec = OperatorSpec(id="http.request", summary="Call an HTTP endpoint.", idempotent=False)
204
+ config_model: ClassVar[type[BaseModel]] = HttpRequestConfig
205
+ output_model: ClassVar[type[BaseModel]] = HttpRequestOutput
206
+
207
+ async def execute(self, config: HttpRequestConfig, ctx: StepContext) -> HttpRequestOutput | RemoteHandle:
208
+ """Send the request, and turn an unsuccessful status into a classified failure."""
209
+ started = time.monotonic()
210
+ async with client_for(config, ctx) as client:
211
+ headers, content = config.request_body()
212
+ request = client.build_request(
213
+ config.method,
214
+ request_url(config),
215
+ params=dict(config.query) or None,
216
+ headers=headers or None,
217
+ content=content,
218
+ timeout=request_timeout(config, client),
219
+ )
220
+ response = await client.send(request, stream=True, follow_redirects=config.follow_redirects)
221
+ try:
222
+ payload = await read_bounded(response, config.max_response)
223
+ finally:
224
+ await response.aclose()
225
+ duration = round((time.monotonic() - started) * 1000)
226
+ ctx.log.info(
227
+ "http call",
228
+ method=config.method,
229
+ url=request_url(config),
230
+ status=response.status_code,
231
+ bytes=len(payload),
232
+ duration_ms=duration,
233
+ )
234
+ if not is_success(response.status_code, config.success_status):
235
+ raise BlockFailure(
236
+ STATUS_REFUSED,
237
+ error_class=status_class(response.status_code),
238
+ method=config.method,
239
+ url=request_url(config),
240
+ status=response.status_code,
241
+ )
242
+ return HttpRequestOutput(
243
+ status=response.status_code,
244
+ headers={name.lower(): value for name, value in response.headers.items()},
245
+ body=body_of(response, payload),
246
+ body_bytes=len(payload),
247
+ duration_ms=duration,
248
+ )
249
+
250
+
251
+ class HttpReadyConfig(HttpTarget):
252
+ """What readiness means for one service."""
253
+
254
+ expect_status: list[int] = Field(default_factory=list[int])
255
+ """Status codes that mean ready; empty means any 2xx."""
256
+
257
+ contains: str | None = None
258
+ """Optional body matcher; readiness also requires the response to contain this text."""
259
+
260
+ max_response: Size = 1024 * 1024
261
+ """How much of the answer is read while looking for ``contains``.
262
+
263
+ A probe asks whether a service is up. An endpoint answering a readiness check with more
264
+ than this is not answering the question, so the poke reads that much and stops rather
265
+ than holding whatever arrives on a worker that pokes it every few seconds.
266
+ """
267
+
268
+
269
+ class HttpReadyOutput(BlockModel):
270
+ """The observation that a service is up, passed downstream like any output."""
271
+
272
+ status: int
273
+ duration_ms: int
274
+ matched: bool
275
+
276
+
277
+ class HttpReadySensor(Sensor[HttpReadyConfig, HttpReadyOutput]):
278
+ """Waits for an endpoint to answer successfully; each poke is one short, read-only GET."""
279
+
280
+ spec = SensorSpec(id="http.ready", summary="Wait for an HTTP endpoint to report ready.")
281
+ config_model: ClassVar[type[BaseModel]] = HttpReadyConfig
282
+ output_model: ClassVar[type[BaseModel]] = HttpReadyOutput
283
+
284
+ async def poke(self, config: HttpReadyConfig, ctx: StepContext) -> HttpReadyOutput | NotYet:
285
+ """Observe once. A service that is not up yet is the condition, not an error."""
286
+ started = time.monotonic()
287
+ try:
288
+ async with client_for(config, ctx) as client:
289
+ request = client.build_request("GET", request_url(config), timeout=request_timeout(config, client))
290
+ response = await client.send(request, stream=True, follow_redirects=config.follow_redirects)
291
+ try:
292
+ answered = await read_bounded(response, config.max_response)
293
+ finally:
294
+ await response.aclose()
295
+ except httpx2.TransportError as error:
296
+ ctx.log.debug("endpoint is not reachable yet", error=str(error))
297
+ return NotYet()
298
+ duration = round((time.monotonic() - started) * 1000)
299
+ if not is_success(response.status_code, config.expect_status):
300
+ ctx.log.debug("endpoint is not ready yet", status=response.status_code)
301
+ return NotYet()
302
+ if config.contains is not None and config.contains not in _as_text(answered):
303
+ ctx.log.debug("endpoint answered but the body does not match yet")
304
+ return NotYet()
305
+ return HttpReadyOutput(status=response.status_code, duration_ms=duration, matched=config.contains is not None)
306
+
307
+
308
+ def status_class(status: int) -> ErrorClass:
309
+ """Classify an HTTP status the way retry policy needs it classified.
310
+
311
+ A 429 is the one client error that asks to be retried: the server is rate limiting, and
312
+ the same call succeeds once the window has passed.
313
+ """
314
+ if status >= 500 or status == 429:
315
+ return ErrorClass.TRANSIENT
316
+ if 400 <= status < 500:
317
+ return ErrorClass.REJECTED
318
+ return ErrorClass.UNKNOWN
319
+
320
+
321
+ def is_success(status: int, expected: list[int]) -> bool:
322
+ """Decide whether a status counts as success: the declared set, or any 2xx."""
323
+ return status in expected if expected else 200 <= status < 300
@@ -0,0 +1,26 @@
1
+ """Every refusal the http family makes, catalogued under the ``http`` prefix."""
2
+
3
+ from dirigent_common import Catalogue
4
+
5
+ HTTP = Catalogue("http")
6
+
7
+ RESPONSE_TOO_LARGE = HTTP.define(
8
+ "response_too_large",
9
+ "the response is larger than max_response ({limit} bytes) and is not being read; "
10
+ "raise max_response, or ask the endpoint for less",
11
+ )
12
+
13
+ STATUS_REFUSED = HTTP.define("status_refused", "{method} {url} answered {status}")
14
+
15
+ WEBHOOK_STATUS_REFUSED = HTTP.define("webhook.status_refused", "the receiver at {url} answered {status}")
16
+
17
+ WEBHOOK_NO_SECRET = HTTP.define(
18
+ "webhook.no_secret",
19
+ "connection {connection} has no hmac_secret, so this POST cannot be signed",
20
+ )
21
+
22
+
23
+ # What a config refuses at validation. Pydantic owns the code a validator's refusal reaches
24
+ # the wire under, so these are rendered into the ``ValueError`` it wraps.
25
+
26
+ NO_TARGET = HTTP.define("no_target", "an HTTP block needs either a connection or an absolute url")
File without changes
@@ -0,0 +1,150 @@
1
+ """``webhook.post``: an outbound, optionally HMAC-signed JSON POST.
2
+
3
+ The ``X-Dirigent-Signature`` it presents is computed the same way dirigent's own inbound
4
+ ``/hooks/{token}`` verifies one, so the two must stay in step.
5
+
6
+ The block serialises the body itself, and the signature covers the bytes that go on the
7
+ wire: letting the HTTP client re-encode the body after signing would produce a signature
8
+ that verifies nowhere.
9
+ """
10
+
11
+ import hashlib
12
+ import hmac
13
+ import json
14
+ import time
15
+ from typing import ClassVar, Final
16
+
17
+ from pydantic import BaseModel, ConfigDict, Field, JsonValue
18
+
19
+ from dirigent_block_http.connections import HttpConnectionConfig
20
+ from dirigent_block_http.http import (
21
+ HttpTarget,
22
+ client_for,
23
+ decode,
24
+ is_success,
25
+ read_bounded,
26
+ request_url,
27
+ status_class,
28
+ )
29
+ from dirigent_block_http.messages import WEBHOOK_NO_SECRET, WEBHOOK_STATUS_REFUSED
30
+ from dirigent_common import BlockModel, Size
31
+ from dirigent_plugin import BlockFailure, ConnectionRef, ErrorClass, Operator, OperatorSpec, RemoteHandle, StepContext
32
+
33
+ SIGNATURE_HEADER: Final = "X-Dirigent-Signature"
34
+
35
+ JSON_CONTENT_TYPE: Final = "application/json"
36
+
37
+ JSON_SEPARATORS: Final = (",", ":")
38
+
39
+
40
+ class WebhookPostConfig(HttpTarget):
41
+ """What to POST, where, and what to sign it with."""
42
+
43
+ model_config = ConfigDict(populate_by_name=True)
44
+
45
+ body: JsonValue = Field(default_factory=dict[str, JsonValue])
46
+ """The JSON payload, usually built from upstream outputs with ``${steps...}`` references."""
47
+
48
+ headers: dict[str, str] = Field(default_factory=dict[str, str])
49
+ """Extra headers the receiver wants, such as a routing key."""
50
+
51
+ max_response: Size = 1024 * 1024
52
+ """How much of the receiver's answer is read, before the step is failed instead.
53
+
54
+ An acknowledgement is small: what a receiver says back is that it took the delivery, not
55
+ the data itself. A megabyte is generous for that, and a receiver answering with more than
56
+ a step said it would hold is a receiver to find out about rather than to read.
57
+ """
58
+
59
+ sign_with: ConnectionRef | None = None
60
+ """The connection whose ``hmac_secret`` signs the body; unset means the POST is unsigned.
61
+
62
+ Named separately from ``connection`` so a POST to an absolute URL can still be signed
63
+ with a secret this instance holds, and so signing is something a document says out loud
64
+ rather than something that happens because a connection had a field set."""
65
+
66
+ success_status: list[int] = Field(default_factory=list[int])
67
+ """Status codes that count as delivered; empty means any 2xx."""
68
+
69
+
70
+ class WebhookPostOutput(BlockModel):
71
+ """What the receiver said, which downstream steps reference by field."""
72
+
73
+ status: int
74
+ signed: bool
75
+ duration_ms: int
76
+ json_body: JsonValue | None = None
77
+ text: str | None = None
78
+
79
+
80
+ class WebhookPostOperator(Operator[WebhookPostConfig, WebhookPostOutput]):
81
+ """POSTs a JSON body to a connection or a URL, optionally signed the way dirigent signs."""
82
+
83
+ spec = OperatorSpec(id="webhook.post", summary="POST a JSON body, optionally HMAC-signed.", idempotent=False)
84
+ config_model: ClassVar[type[BaseModel]] = WebhookPostConfig
85
+ output_model: ClassVar[type[BaseModel]] = WebhookPostOutput
86
+
87
+ async def execute(self, config: WebhookPostConfig, ctx: StepContext) -> WebhookPostOutput | RemoteHandle:
88
+ """Render the body once, sign those bytes, send them, and read the answer."""
89
+ payload = render(config.body)
90
+ headers = {**config.headers, "Content-Type": JSON_CONTENT_TYPE}
91
+ secret = _secret(config, ctx)
92
+ if secret is not None:
93
+ headers[SIGNATURE_HEADER] = sign(secret, payload)
94
+ started = time.monotonic()
95
+ async with client_for(config, ctx) as client:
96
+ request = client.build_request(
97
+ "POST",
98
+ request_url(config),
99
+ content=payload,
100
+ headers=headers,
101
+ timeout=config.timeout.total_seconds() if config.timeout is not None else None,
102
+ )
103
+ response = await client.send(request, stream=True, follow_redirects=config.follow_redirects)
104
+ try:
105
+ answered = await read_bounded(response, config.max_response)
106
+ finally:
107
+ await response.aclose()
108
+ duration = round((time.monotonic() - started) * 1000)
109
+ ctx.log.info(
110
+ "webhook delivered",
111
+ status=response.status_code,
112
+ bytes_sent=len(payload),
113
+ signed=secret is not None,
114
+ duration_ms=duration,
115
+ )
116
+ if not is_success(response.status_code, config.success_status):
117
+ raise BlockFailure(
118
+ WEBHOOK_STATUS_REFUSED,
119
+ error_class=status_class(response.status_code),
120
+ url=request_url(config),
121
+ status=response.status_code,
122
+ )
123
+ parsed, text = decode(response, answered)
124
+ return WebhookPostOutput(
125
+ status=response.status_code,
126
+ signed=secret is not None,
127
+ duration_ms=duration,
128
+ json_body=parsed,
129
+ text=text,
130
+ )
131
+
132
+
133
+ def render(body: JsonValue) -> bytes:
134
+ """Serialise the body once, into the exact bytes that are both signed and sent."""
135
+ return json.dumps(body, separators=JSON_SEPARATORS, ensure_ascii=False).encode()
136
+
137
+
138
+ def sign(secret: bytes, payload: bytes) -> str:
139
+ """Compute the signature over the raw body, the way dirigent's own intake verifies one."""
140
+ return hmac.new(secret, payload, hashlib.sha256).hexdigest()
141
+
142
+
143
+ def _secret(config: WebhookPostConfig, ctx: StepContext) -> bytes | None:
144
+ """Read the signing secret off the named connection, refusing one that has none."""
145
+ if config.sign_with is None:
146
+ return None
147
+ connection = ctx.connection(config.sign_with, HttpConnectionConfig)
148
+ if connection.hmac_secret is None:
149
+ raise BlockFailure(WEBHOOK_NO_SECRET, error_class=ErrorClass.REJECTED, connection=repr(config.sign_with))
150
+ return connection.hmac_secret.get_secret_value().encode()
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: dirigent-block-http
3
+ Version: 0.17.0
4
+ Summary: The HTTP block family for dirigent: http.request, http.ready, webhook.post, and the http connection kind.
5
+ License-Expression: LicenseRef-Proprietary
6
+ License-File: LICENSE
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.13
9
+ Requires-Dist: dirigent-common==0.17.0
10
+ Requires-Dist: dirigent-plugin==0.17.0
11
+ Requires-Dist: httpx2>=2.12.0
12
+ Requires-Python: >=3.13
13
+ Description-Content-Type: text/markdown
14
+
15
+ # dirigent-block-http
16
+
17
+ The HTTP block family: `http.request` calls an endpoint, `http.ready` waits for one to answer,
18
+ and `webhook.post` sends a JSON body that can carry an HMAC signature.
19
+
20
+ It registers the `http` connection kind, which is where a base URL, its credential, its TLS
21
+ settings and its timeouts are held, so a step names a connection rather than a URL and a
22
+ secret. The configuration and the client behind that kind are `dirigent-common`'s, and any
23
+ pack may use them; the registration is this family's.
@@ -0,0 +1,11 @@
1
+ dirigent_block_http/__init__.py,sha256=nf4T94blO2qbnN2rRSXA64C8oL8tOn2f85_plZ_0cSw,989
2
+ dirigent_block_http/connections.py,sha256=Kxk2Ha6TbZVf_KDClab7S0lJcQXS37__Oa20HvdCtR8,1448
3
+ dirigent_block_http/http.py,sha256=NMSDcnSm5oRKO5hKjLpZCzvhxXCYYMcv8-B1wnHbZr0,13161
4
+ dirigent_block_http/messages.py,sha256=Z57MYi9wZw6AjoaOE6FqLKcrgG91Un6ujy-ELjxiMrU,953
5
+ dirigent_block_http/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ dirigent_block_http/webhooks.py,sha256=j314Ir0iQLzZbhm7QkAu1EdKCO8ol76Ps8r76YGMgww,6059
7
+ dirigent_block_http-0.17.0.dist-info/licenses/LICENSE,sha256=LKBm7Cx-WBc1zca4DjGxq99VEpAiWGnZDxIKmntn1hQ,910
8
+ dirigent_block_http-0.17.0.dist-info/WHEEL,sha256=R1d3uUTbmXM1FHXH_itQashbrqrOSVj-hvBCpmkIIGE,81
9
+ dirigent_block_http-0.17.0.dist-info/entry_points.txt,sha256=_V_HZvF2K9-oBPX4nr4QZ8qsfvPjQ9pYmjwLIj7FuMk,63
10
+ dirigent_block_http-0.17.0.dist-info/METADATA,sha256=X28vqphyWMoYmiywQk_-YkjEEAsMEhszJfEPUWLUIBs,1032
11
+ dirigent_block_http-0.17.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.17
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [dirigent.plugins.v1]
2
+ block-http = dirigent_block_http:plugin
3
+
@@ -0,0 +1,18 @@
1
+ Copyright (c) 2026 Morten Olav Hansen <morten@winterop.com>. All rights reserved.
2
+
3
+ This source code and accompanying documentation are the property of
4
+ Morten Olav Hansen. No license, express or implied, is granted to use, copy,
5
+ modify, merge, publish, distribute, sublicense, or sell copies of this
6
+ software or its derivatives.
7
+
8
+ The source is published for reference only. Any use beyond reading
9
+ requires written permission from the copyright holder.
10
+
11
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
12
+ OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
13
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
14
+ IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES,
15
+ OR OTHER LIABILITY ARISING FROM THE USE OF THE SOFTWARE.
16
+
17
+ Third-party components redistributed with this software, and the licences they
18
+ carry, are listed in THIRD_PARTY_NOTICES.md.