dirigent-plugin 0.26.1__tar.gz → 0.27.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.26.1
3
+ Version: 0.27.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.26.1
9
+ Requires-Dist: dirigent-common==0.27.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.26.1"
3
+ version = "0.27.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.26.1",
14
+ "dirigent-common==0.27.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.26.1"
3
+ version = "0.27.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.26.1",
14
+ "dirigent-common==0.27.0",
15
15
  "httpx2>=2.12.0",
16
16
  "pluginkit>=0.5.0",
17
17
  "pydantic>=2.13.5",
@@ -16,7 +16,6 @@ from dirigent_plugin.blocks import (
16
16
  ConnectionKind,
17
17
  ConnectionRef,
18
18
  ContainerResult,
19
- Contribution,
20
19
  ErrorClass,
21
20
  FormatCheck,
22
21
  Logger,
@@ -45,10 +44,10 @@ from dirigent_plugin.blocks import (
45
44
  StorageBackend,
46
45
  classify_default,
47
46
  mark_refusal,
48
- merge_contributions,
49
47
  reference_fields,
50
48
  shell_string_fields,
51
49
  )
50
+ from dirigent_plugin.contribution import Contribution, merge_contributions
52
51
  from dirigent_plugin.markers import (
53
52
  ENTRY_POINT_GROUP,
54
53
  PROJECT_NAME,
@@ -12,24 +12,20 @@ from uuid import UUID
12
12
 
13
13
  import httpx2
14
14
  from jsonschema import FormatChecker
15
- from pydantic import BaseModel, ConfigDict, Field, GetJsonSchemaHandler, JsonValue, field_validator, model_validator
15
+ from pydantic import BaseModel, ConfigDict, Field, GetJsonSchemaHandler, JsonValue, model_validator
16
16
  from pydantic.json_schema import JsonSchemaValue, SkipJsonSchema
17
17
  from pydantic_core import CoreSchema
18
18
 
19
19
  from dirigent_common import (
20
- API_VERSION,
21
20
  SHELL_MEDIA_TYPE,
22
21
  BlockModel,
23
- Catalogue,
24
22
  HealthReport,
25
23
  Issue,
26
24
  JsonMap,
27
25
  Message,
28
26
  )
29
27
  from dirigent_plugin.messages import (
30
- DUPLICATE_ID,
31
28
  INVALID_MARK,
32
- UNSUPPORTED_API_VERSION,
33
29
  )
34
30
 
35
31
  type RunId = UUID
@@ -347,8 +343,8 @@ class NotYet(BaseModel):
347
343
  from again. So a poke must tolerate reading the same ground twice.
348
344
 
349
345
  The cursor's life is the waiting attempt. A poke that succeeds ends the step, and only
350
- :meth:`Sensor.resume_cursor` carries a cursor past it: a watch hands what that returns to
351
- the first poke of the run it arms next.
346
+ :meth:`Sensor.resume_cursor` carries a cursor past it: a ``sensor`` source hands what that
347
+ returns to the poke that starts its next wait.
352
348
  """
353
349
 
354
350
 
@@ -400,6 +396,14 @@ class SensorSpec(BaseModel):
400
396
  default_poll: timedelta = timedelta(minutes=1)
401
397
  default_deadline: timedelta = timedelta(hours=24)
402
398
 
399
+ source: bool = False
400
+ """Whether a ``sensor`` source may poke it outside any run, a run started by each success.
401
+
402
+ Such a sensor reads nothing of a run but ``ctx.cursor`` and ``ctx.started_at``, which is
403
+ when the source began waiting, and says through :meth:`Sensor.resume_cursor` where the
404
+ next wait carries on, so the same ground does not succeed twice.
405
+ """
406
+
403
407
  @model_validator(mode="before")
404
408
  @classmethod
405
409
  def _shelve(cls, data: Any) -> Any:
@@ -636,9 +640,9 @@ class StepContext(Protocol):
636
640
  cursor: JsonMap | None
637
641
  """The cursor the last committed :class:`NotYet` returned, and None on the first poke.
638
642
 
639
- The first poke of a run a watch armed is handed where the watch's last success left off
640
- instead, which is what :meth:`Sensor.resume_cursor` returned. Only a sensor's poke reads
641
- it; every other call sees None.
643
+ A poke by a ``sensor`` source is handed where the source's last success left off instead,
644
+ which is what :meth:`Sensor.resume_cursor` returned. Only a sensor's poke reads it; every
645
+ other call sees None.
642
646
  """
643
647
 
644
648
  def connection[C: BaseModel](self, ref: ConnectionRef, model: type[C]) -> C:
@@ -777,10 +781,10 @@ class Sensor[ConfigT: BaseModel, OutputT: BaseModel](ABC):
777
781
  def resume_cursor(self, output: OutputT) -> JsonMap | None:
778
782
  """Say where a poke that succeeded left off, read from the output it succeeded with.
779
783
 
780
- A watch stores what this returns and hands it to the first poke of the next run it
781
- arms as ``ctx.cursor``. None, the default, starts every run fresh. It is stored in the
782
- transaction that settles the step, so it is only as far along as the last success that
783
- committed, and the next poke may read that ground again.
784
+ A ``sensor`` source stores what this returns and hands it to the poke that starts its
785
+ next wait as ``ctx.cursor``. None, the default, starts every wait fresh. It is stored in
786
+ the transaction that starts the run the success became, so it is only as far along as
787
+ the last success that committed, and the next poke may read that ground again.
784
788
  """
785
789
  return None
786
790
 
@@ -875,7 +879,7 @@ _PATH_DATA: Final = re.compile(r"^[MmZzLlHhVvCcSsQqTtAaEe0-9,.+\-\s]+$")
875
879
 
876
880
 
877
881
  def mark_refusal(kind: str, mark: str | None) -> str | None:
878
- """Say why a connection kind may not wear the mark it declares, or ``None`` when it may.
882
+ """Say why a connection or source kind may not wear the mark it declares, or ``None`` when it may.
879
883
 
880
884
  A mark reaches a browser as the ``d`` of one path, so anything that is not path data is
881
885
  refused here rather than drawn: non-empty, ASCII, path-data characters only, and at most
@@ -923,89 +927,3 @@ class ConnectionKind(ABC):
923
927
 
924
928
  type AnyOperator = Operator[Any, Any]
925
929
  type AnySensor = Sensor[Any, Any]
926
-
927
-
928
- class Contribution(BaseModel):
929
- """Everything one plugin adds, across all seven surfaces, gathered by the host at startup."""
930
-
931
- model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True)
932
-
933
- api_version: int = Field(default=API_VERSION, ge=1)
934
- operators: list[AnyOperator] = Field(default_factory=list[AnyOperator])
935
- sensors: list[AnySensor] = Field(default_factory=list[AnySensor])
936
- storage_backends: list[StorageBackend] = Field(default_factory=list[StorageBackend])
937
- notifiers: list[Notifier] = Field(default_factory=list[Notifier])
938
- connection_kinds: list[ConnectionKind] = Field(default_factory=list[ConnectionKind])
939
- formats: dict[str, FormatCheck] = Field(default_factory=dict[str, FormatCheck])
940
- """JSON Schema format checkers this plugin adds, by format name. A schema that writes
941
- ``format: <name>`` then asserts wherever the contributing pack is installed, and stays a
942
- passing annotation on an instance without it."""
943
-
944
- labels: list[Catalogue] = Field(default_factory=list[Catalogue])
945
- """The message catalogues this plugin refuses out of, so a surface can render its codes.
946
-
947
- A pack's refusal reaches a browser under the pack's own code, and the browser holds no table
948
- for it. Contributing the catalogue is what puts one there: the host serves every code and its
949
- template, and a renderer that knows the code says its own sentence rather than repeating the
950
- English this process happened to mint."""
951
-
952
- @field_validator("api_version")
953
- @classmethod
954
- def _check_api_version(cls, value: int) -> int:
955
- """Reject a contribution written against a different revision of this contract."""
956
- if value != API_VERSION:
957
- raise ValueError(UNSUPPORTED_API_VERSION.render(version=value, host_version=API_VERSION))
958
- return value
959
-
960
- @model_validator(mode="after")
961
- def _check_ids(self) -> "Contribution":
962
- """Reject a contribution whose blocks or surfaces collide on an id."""
963
- _require_unique("block id", [*(op.spec.id for op in self.operators), *(se.spec.id for se in self.sensors)])
964
- _require_unique("storage scheme", [backend.scheme for backend in self.storage_backends])
965
- _require_unique("notifier id", [notifier.id for notifier in self.notifiers])
966
- _require_unique("connection kind id", [connection.id for connection in self.connection_kinds])
967
- _require_unique("format", list(self.formats))
968
- _require_unique("label prefix", [catalogue.prefix for catalogue in self.labels])
969
- return self
970
-
971
- @model_validator(mode="after")
972
- def _check_marks(self) -> "Contribution":
973
- """Reject a connection kind whose declared mark is not path data a browser may draw."""
974
- for connection in self.connection_kinds:
975
- refusal = mark_refusal(connection.id, connection.mark)
976
- if refusal is not None:
977
- raise ValueError(refusal)
978
- return self
979
-
980
- def block_ids(self) -> list[str]:
981
- """List every operator and sensor id this contribution registers."""
982
- return [*(operator.spec.id for operator in self.operators), *(sensor.spec.id for sensor in self.sensors)]
983
-
984
-
985
- def _require_unique(label: str, values: list[str]) -> None:
986
- """Raise when a list of contributed ids contains a duplicate."""
987
- seen: set[str] = set()
988
- for value in values:
989
- if value in seen:
990
- raise ValueError(DUPLICATE_ID.render(label=label, value=repr(value)))
991
- seen.add(value)
992
-
993
-
994
- def merge_contributions(contributions: list[Contribution]) -> Contribution:
995
- """Merge every plugin's contribution into the single catalog the host dispatches from."""
996
- merged: dict[str, list[Any]] = {
997
- "operators": [],
998
- "sensors": [],
999
- "storage_backends": [],
1000
- "notifiers": [],
1001
- "connection_kinds": [],
1002
- "labels": [],
1003
- }
1004
- for contribution in contributions:
1005
- for surface, collected in merged.items():
1006
- collected.extend(getattr(contribution, surface))
1007
- formats: dict[str, FormatCheck] = {}
1008
- _require_unique("format", [name for contribution in contributions for name in contribution.formats])
1009
- for contribution in contributions:
1010
- formats.update(contribution.formats)
1011
- return Contribution(api_version=API_VERSION, formats=formats, **merged)
@@ -0,0 +1,129 @@
1
+ """What one plugin contributes, across every surface, and how the host merges them."""
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
6
+
7
+ from dirigent_common import API_VERSION, Catalogue
8
+ from dirigent_plugin.blocks import (
9
+ AnyOperator,
10
+ AnySensor,
11
+ ConnectionKind,
12
+ FormatCheck,
13
+ Notifier,
14
+ StorageBackend,
15
+ mark_refusal,
16
+ )
17
+ from dirigent_plugin.messages import DUPLICATE_ID, SOURCE_NOT_HOSTED, UNSUPPORTED_API_VERSION
18
+ from dirigent_plugin.sources import AnySource, HeldSource, PolledSource, ReceivedSource, SourceHosting
19
+
20
+
21
+ class Contribution(BaseModel):
22
+ """Everything one plugin adds, across all eight surfaces, gathered by the host at startup."""
23
+
24
+ model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True)
25
+
26
+ api_version: int = Field(default=API_VERSION, ge=1)
27
+ operators: list[AnyOperator] = Field(default_factory=list[AnyOperator])
28
+ sensors: list[AnySensor] = Field(default_factory=list[AnySensor])
29
+ storage_backends: list[StorageBackend] = Field(default_factory=list[StorageBackend])
30
+ notifiers: list[Notifier] = Field(default_factory=list[Notifier])
31
+ connection_kinds: list[ConnectionKind] = Field(default_factory=list[ConnectionKind])
32
+ sources: list[AnySource] = Field(default_factory=list[AnySource])
33
+ """The source kinds this plugin adds, each keyed by the ``kind`` a document writes under ``source``."""
34
+
35
+ formats: dict[str, FormatCheck] = Field(default_factory=dict[str, FormatCheck])
36
+ """JSON Schema format checkers this plugin adds, by format name. A schema that writes
37
+ ``format: <name>`` then asserts wherever the contributing pack is installed, and stays a
38
+ passing annotation on an instance without it."""
39
+
40
+ labels: list[Catalogue] = Field(default_factory=list[Catalogue])
41
+ """The message catalogues this plugin refuses out of, so a surface can render its codes.
42
+
43
+ A pack's refusal reaches a browser under the pack's own code, and the browser holds no table
44
+ for it. Contributing the catalogue is what puts one there: the host serves every code and its
45
+ template, and a renderer that knows the code says its own sentence rather than repeating the
46
+ English this process happened to mint."""
47
+
48
+ @field_validator("api_version")
49
+ @classmethod
50
+ def _check_api_version(cls, value: int) -> int:
51
+ """Reject a contribution written against a different revision of this contract."""
52
+ if value != API_VERSION:
53
+ raise ValueError(UNSUPPORTED_API_VERSION.render(version=value, host_version=API_VERSION))
54
+ return value
55
+
56
+ @model_validator(mode="after")
57
+ def _check_ids(self) -> "Contribution":
58
+ """Reject a contribution whose blocks or surfaces collide on an id."""
59
+ _require_unique("block id", [*(op.spec.id for op in self.operators), *(se.spec.id for se in self.sensors)])
60
+ _require_unique("storage scheme", [backend.scheme for backend in self.storage_backends])
61
+ _require_unique("notifier id", [notifier.id for notifier in self.notifiers])
62
+ _require_unique("connection kind id", [connection.id for connection in self.connection_kinds])
63
+ _require_unique("source kind", [source.spec.kind for source in self.sources])
64
+ _require_unique("format", list(self.formats))
65
+ _require_unique("label prefix", [catalogue.prefix for catalogue in self.labels])
66
+ return self
67
+
68
+ @model_validator(mode="after")
69
+ def _check_marks(self) -> "Contribution":
70
+ """Reject a connection or source kind whose declared mark is not path data a browser may draw."""
71
+ marks = [
72
+ *((connection.id, connection.mark) for connection in self.connection_kinds),
73
+ *((source.spec.kind, source.mark) for source in self.sources),
74
+ ]
75
+ for kind, mark in marks:
76
+ refusal = mark_refusal(kind, mark)
77
+ if refusal is not None:
78
+ raise ValueError(refusal)
79
+ return self
80
+
81
+ @model_validator(mode="after")
82
+ def _check_sources(self) -> "Contribution":
83
+ """Reject a source kind whose class is not the one its hosting names, or that nothing hosts."""
84
+ hosted = {
85
+ SourceHosting.RECEIVED: ReceivedSource,
86
+ SourceHosting.POLLED: PolledSource,
87
+ SourceHosting.HELD: HeldSource,
88
+ }
89
+ for source in self.sources:
90
+ contract = hosted.get(source.spec.hosting)
91
+ if contract is None or not isinstance(source, contract):
92
+ raise ValueError(
93
+ SOURCE_NOT_HOSTED.render(kind=repr(source.spec.kind), hosting=source.spec.hosting.value)
94
+ )
95
+ return self
96
+
97
+ def block_ids(self) -> list[str]:
98
+ """List every operator and sensor id this contribution registers."""
99
+ return [*(operator.spec.id for operator in self.operators), *(sensor.spec.id for sensor in self.sensors)]
100
+
101
+
102
+ def _require_unique(label: str, values: list[str]) -> None:
103
+ """Raise when a list of contributed ids contains a duplicate."""
104
+ seen: set[str] = set()
105
+ for value in values:
106
+ if value in seen:
107
+ raise ValueError(DUPLICATE_ID.render(label=label, value=repr(value)))
108
+ seen.add(value)
109
+
110
+
111
+ def merge_contributions(contributions: list[Contribution]) -> Contribution:
112
+ """Merge every plugin's contribution into the single catalog the host dispatches from."""
113
+ merged: dict[str, list[Any]] = {
114
+ "operators": [],
115
+ "sensors": [],
116
+ "storage_backends": [],
117
+ "notifiers": [],
118
+ "connection_kinds": [],
119
+ "sources": [],
120
+ "labels": [],
121
+ }
122
+ for contribution in contributions:
123
+ for surface, collected in merged.items():
124
+ collected.extend(getattr(contribution, surface))
125
+ formats: dict[str, FormatCheck] = {}
126
+ _require_unique("format", [name for contribution in contributions for name in contribution.formats])
127
+ for contribution in contributions:
128
+ formats.update(contribution.formats)
129
+ return Contribution(api_version=API_VERSION, formats=formats, **merged)
@@ -9,7 +9,7 @@ from pluginkit import Extension, ExtensionPoint
9
9
  from dirigent_common import Formatter
10
10
 
11
11
  if TYPE_CHECKING:
12
- from dirigent_plugin.blocks import Contribution
12
+ from dirigent_plugin.contribution import Contribution
13
13
 
14
14
  PROJECT_NAME = "dirigent"
15
15
 
@@ -22,7 +22,7 @@ extension = Extension(PROJECT_NAME)
22
22
 
23
23
  @extension_point
24
24
  def contribute() -> "Contribution":
25
- """Collect everything a plugin adds across the five surfaces, once at host startup."""
25
+ """Collect everything a plugin adds across every surface, once at host startup."""
26
26
  raise NotImplementedError("an extension point is a declaration; call it via PluginManager.caller(...)")
27
27
 
28
28
 
@@ -39,7 +39,13 @@ DUPLICATE_ID = PLUGIN.define("duplicate_id", "duplicate {label} {value} in contr
39
39
 
40
40
  INVALID_MARK = PLUGIN.define(
41
41
  "invalid_mark",
42
- "the mark connection kind {kind} declares is not SVG path data: {detail}",
42
+ "the mark that kind {kind} declares is not SVG path data: {detail}",
43
+ )
44
+
45
+ SOURCE_NOT_HOSTED = PLUGIN.define(
46
+ "source_not_hosted",
47
+ "source kind {kind} is {hosting}, and a contributed kind is the ReceivedSource, PolledSource "
48
+ "or HeldSource its hosting names",
43
49
  )
44
50
 
45
51
  # What a received source's path template refuses.
@@ -7,6 +7,7 @@ receives it, a worker polls it, or a worker holds it open.
7
7
 
8
8
  import re
9
9
  from abc import ABC, abstractmethod
10
+ from contextlib import AbstractAsyncContextManager
10
11
  from datetime import datetime, timedelta
11
12
  from enum import StrEnum
12
13
  from typing import Any, ClassVar, Final, Literal, Protocol
@@ -15,7 +16,7 @@ from jsonschema import FormatChecker
15
16
  from pydantic import BaseModel, ConfigDict, Field
16
17
 
17
18
  from dirigent_common import Issue, JsonMap, Message
18
- from dirigent_plugin.blocks import ErrorClass, Logger, classify_default
19
+ from dirigent_plugin.blocks import ConnectionRef, ErrorClass, Logger, Storage, classify_default
19
20
  from dirigent_plugin.messages import (
20
21
  ROUTE_BAD_PARAMETER,
21
22
  ROUTE_BAD_SEGMENT,
@@ -70,8 +71,13 @@ class SourceAuth(BaseModel):
70
71
 
71
72
  model_config = ConfigDict(frozen=True, extra="forbid", use_attribute_docstrings=True)
72
73
 
73
- kind: Literal["token", "public"] = "token"
74
- """``token`` takes ``Authorization: Bearer`` with the token the instance minted; ``public`` admits anyone."""
74
+ kind: Literal["token", "hmac", "public"] = "token"
75
+ """``token`` takes ``Authorization: Bearer`` with the token the instance minted; ``hmac`` takes a
76
+ signature of the body, made with the source's signing secret; ``public`` admits anyone."""
77
+
78
+ header: str = Field(default="X-Dirigent-Signature", min_length=1)
79
+ """For ``hmac``, the header the signature arrives in: the hex HMAC-SHA256 of the raw body,
80
+ with or without a leading ``sha256=``."""
75
81
 
76
82
 
77
83
  class SourceRate(BaseModel):
@@ -247,6 +253,63 @@ class ReceiveContext(Protocol):
247
253
  ...
248
254
 
249
255
 
256
+ class PollContext(Protocol):
257
+ """What a polled kind may reach while it asks for new inputs: storage bound as the instance configures it."""
258
+
259
+ @property
260
+ def log(self) -> Logger:
261
+ """Where the kind says what it found."""
262
+ ...
263
+
264
+ @property
265
+ def storage(self) -> Storage:
266
+ """Storage, every scheme configured from the connection the instance names for it."""
267
+ ...
268
+
269
+
270
+ class HoldContext(Protocol):
271
+ """What a held kind may reach while it listens: storage, and the connections the instance holds."""
272
+
273
+ @property
274
+ def log(self) -> Logger:
275
+ """Where the kind says what it is doing."""
276
+ ...
277
+
278
+ @property
279
+ def storage(self) -> Storage:
280
+ """Storage, every scheme configured from the connection the instance names for it."""
281
+ ...
282
+
283
+ def connection[C: BaseModel](self, ref: ConnectionRef, model: type[C]) -> C:
284
+ """Resolve a connection by code, opened and validated against the kind's own model."""
285
+ ...
286
+
287
+
288
+ class Listener[OutputT: BaseModel](Protocol):
289
+ """One open consumer a held kind answers ``listen`` with."""
290
+
291
+ async def receive(self, limit: int, wait: timedelta) -> list[OutputT]:
292
+ """Take at most ``limit`` inputs, waiting no longer than ``wait`` for the first."""
293
+ ...
294
+
295
+ async def ack(self) -> None:
296
+ """Acknowledge every input received so far, once each has become a run."""
297
+ ...
298
+
299
+
300
+ class Polled[OutputT: BaseModel](BaseModel):
301
+ """What one poll found: the inputs, oldest first, and where the next poll carries on from."""
302
+
303
+ model_config = ConfigDict(frozen=True)
304
+
305
+ inputs: list[OutputT] = Field(default_factory=list[OutputT])
306
+ cursor: JsonMap | None = None
307
+ """Stored once every input became a run, and handed to the next poll."""
308
+
309
+ next_poll_in: timedelta | None = None
310
+ """How long until the next poll, or None for the source's own cadence."""
311
+
312
+
250
313
  class Source[ConfigT: BaseModel, OutputT: BaseModel](ABC):
251
314
  """One source kind: its config, its output, and what it refuses at apply."""
252
315
 
@@ -322,4 +385,49 @@ class ReceivedSource[ConfigT: BaseModel, OutputT: BaseModel](Source[ConfigT, Out
322
385
  ...
323
386
 
324
387
 
388
+ class PolledSource[ConfigT: BaseModel, OutputT: BaseModel](Source[ConfigT, OutputT]):
389
+ """A kind a worker asks on a cadence, carrying a cursor from one poll to the next."""
390
+
391
+ def poll_every(self, config: ConfigT) -> timedelta:
392
+ """Say how long a worker waits between two polls of this source."""
393
+ return self.spec.default_poll or DEFAULT_POLL
394
+
395
+ def label_of(self, output: OutputT) -> str | None:
396
+ """Name one input in a line of the source's journal, such as the object it found."""
397
+ return None
398
+
399
+ @abstractmethod
400
+ async def poll(self, config: ConfigT, cursor: JsonMap | None, ctx: PollContext, limit: int) -> Polled[OutputT]:
401
+ """Find what is new since the cursor, at most ``limit`` inputs, oldest first.
402
+
403
+ A failure it raises is the source's: it is recorded on the row and the next poll backs
404
+ off, from the same cursor.
405
+ """
406
+ ...
407
+
408
+
409
+ class HeldSource[ConfigT: BaseModel, OutputT: BaseModel](Source[ConfigT, OutputT]):
410
+ """A kind a worker holds open under a lease: a consumer whose messages are the inputs.
411
+
412
+ The worker makes a run of each input it receives, commits them, and only then acknowledges
413
+ them, so an input is never lost and may, after a crash between the two, arrive twice.
414
+ """
415
+
416
+ def label_of(self, output: OutputT) -> str | None:
417
+ """Name one input in a line of the source's journal, such as where it was read from."""
418
+ return None
419
+
420
+ @abstractmethod
421
+ def listen(self, config: ConfigT, ctx: HoldContext) -> AbstractAsyncContextManager[Listener[OutputT]]:
422
+ """Open a consumer for as long as the worker holds the source; leaving it closes the consumer.
423
+
424
+ A failure it raises, opening or receiving, is the source's: it is recorded on the row,
425
+ the lease is let go, and the source is taken up again after a backoff.
426
+ """
427
+ ...
428
+
429
+
430
+ #: How long a worker waits between two polls of a kind whose spec names no cadence.
431
+ DEFAULT_POLL: Final = timedelta(minutes=1)
432
+
325
433
  type AnySource = Source[Any, Any]