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.
- {dirigent_plugin-0.26.1 → dirigent_plugin-0.27.0}/PKG-INFO +2 -2
- {dirigent_plugin-0.26.1 → dirigent_plugin-0.27.0}/pyproject.toml +2 -2
- {dirigent_plugin-0.26.1 → dirigent_plugin-0.27.0}/pyproject.toml.orig +2 -2
- {dirigent_plugin-0.26.1 → dirigent_plugin-0.27.0}/src/dirigent_plugin/__init__.py +1 -2
- {dirigent_plugin-0.26.1 → dirigent_plugin-0.27.0}/src/dirigent_plugin/blocks.py +19 -101
- dirigent_plugin-0.27.0/src/dirigent_plugin/contribution.py +129 -0
- {dirigent_plugin-0.26.1 → dirigent_plugin-0.27.0}/src/dirigent_plugin/markers.py +2 -2
- {dirigent_plugin-0.26.1 → dirigent_plugin-0.27.0}/src/dirigent_plugin/messages.py +7 -1
- {dirigent_plugin-0.26.1 → dirigent_plugin-0.27.0}/src/dirigent_plugin/sources.py +111 -3
- {dirigent_plugin-0.26.1 → dirigent_plugin-0.27.0}/LICENSE +0 -0
- {dirigent_plugin-0.26.1 → dirigent_plugin-0.27.0}/README.md +0 -0
- {dirigent_plugin-0.26.1 → dirigent_plugin-0.27.0}/src/dirigent_plugin/py.typed +0 -0
- {dirigent_plugin-0.26.1 → dirigent_plugin-0.27.0}/src/dirigent_plugin/runners.py +0 -0
- {dirigent_plugin-0.26.1 → dirigent_plugin-0.27.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.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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
|
351
|
-
the
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
|
781
|
-
|
|
782
|
-
transaction 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.
|
|
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
|
|
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
|
|
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; ``
|
|
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]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|