dirigent-plugin 0.18.2__tar.gz → 0.18.4__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.18.2
3
+ Version: 0.18.4
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.18.2
9
+ Requires-Dist: dirigent-common==0.18.4
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.18.2"
3
+ version = "0.18.4"
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.18.2",
14
+ "dirigent-common==0.18.4",
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.18.2"
3
+ version = "0.18.4"
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.18.2",
14
+ "dirigent-common==0.18.4",
15
15
  "httpx2>=2.12.0",
16
16
  "pluginkit>=0.5.0",
17
17
  "pydantic>=2.13.5",
@@ -2,6 +2,7 @@
2
2
 
3
3
  from dirigent_plugin.blocks import (
4
4
  BLOCK_ID_PATTERN,
5
+ MARK_LIMIT,
5
6
  SHELL_VARIABLE_PREFIX,
6
7
  SHELL_VARIABLES_FIELD,
7
8
  SURFACE_ID_PATTERN,
@@ -13,6 +14,7 @@ from dirigent_plugin.blocks import (
13
14
  Capture,
14
15
  ConnectionKind,
15
16
  ConnectionRef,
17
+ ContainerResult,
16
18
  Contribution,
17
19
  ErrorClass,
18
20
  FormatCheck,
@@ -41,6 +43,7 @@ from dirigent_plugin.blocks import (
41
43
  Storage,
42
44
  StorageBackend,
43
45
  classify_default,
46
+ mark_refusal,
44
47
  merge_contributions,
45
48
  shell_string_fields,
46
49
  )
@@ -78,6 +81,7 @@ __all__ = [
78
81
  "Capture",
79
82
  "ConnectionKind",
80
83
  "ConnectionRef",
84
+ "ContainerResult",
81
85
  "Contribution",
82
86
  "ConvertConfig",
83
87
  "ConvertOutput",
@@ -88,6 +92,7 @@ __all__ = [
88
92
  "Filterer",
89
93
  "FormatCheck",
90
94
  "Logger",
95
+ "MARK_LIMIT",
91
96
  "Mapper",
92
97
  "NotYet",
93
98
  "Notifier",
@@ -129,6 +134,7 @@ __all__ = [
129
134
  "extension",
130
135
  "extension_point",
131
136
  "formatters",
137
+ "mark_refusal",
132
138
  "merge_contributions",
133
139
  "shell_string_fields",
134
140
  ]
@@ -1,5 +1,6 @@
1
1
  """The dirigent block contract: the only module a third-party plugin package needs to import."""
2
2
 
3
+ import re
3
4
  from abc import ABC, abstractmethod
4
5
  from collections.abc import AsyncGenerator, AsyncIterator, Callable, Mapping
5
6
  from contextlib import AbstractAsyncContextManager
@@ -18,6 +19,7 @@ from pydantic_core import CoreSchema
18
19
  from dirigent_common import API_VERSION, SHELL_MEDIA_TYPE, BlockModel, HealthReport, Issue, JsonMap, Message
19
20
  from dirigent_plugin.messages import (
20
21
  DUPLICATE_ID,
22
+ INVALID_MARK,
21
23
  UNSUPPORTED_API_VERSION,
22
24
  )
23
25
 
@@ -347,6 +349,19 @@ class StatResult(BaseModel):
347
349
  content_type: str | None = None
348
350
 
349
351
 
352
+ class ContainerResult(BaseModel):
353
+ """What a backend did when asked to make sure a URI's container is there."""
354
+
355
+ container: str | None = None
356
+ """What the container is called, or None for a store that has none to make."""
357
+
358
+ created: bool = False
359
+ """Whether this call made it, as opposed to finding it already there."""
360
+
361
+ endpoint: str | None = None
362
+ """Where the store answers, carrying no credential; None for one with no endpoint."""
363
+
364
+
350
365
  class AlertMessage(BaseModel):
351
366
  """What an alert rule hands a notifier: the event, a rendered summary, and links back."""
352
367
 
@@ -729,6 +744,14 @@ class StorageBackend(ABC):
729
744
  """Remove the object at a URI."""
730
745
  ...
731
746
 
747
+ async def ensure_container(self, uri: str) -> ContainerResult:
748
+ """Create the container a URI's objects live in, unless it is already there.
749
+
750
+ A store whose containers a write makes for itself -- a filesystem and its
751
+ directories -- has nothing to create and says so by leaving the result empty.
752
+ """
753
+ return ContainerResult()
754
+
732
755
 
733
756
  class Notifier(ABC):
734
757
  """A pluggable message sender that alert rules deliver through."""
@@ -742,12 +765,50 @@ class Notifier(ABC):
742
765
  ...
743
766
 
744
767
 
768
+ #: How much path data one mark may carry.
769
+ MARK_LIMIT: Final = 4096
770
+
771
+ #: Everything an SVG path's ``d`` is written out of: the commands, numbers, and separators.
772
+ _PATH_DATA: Final = re.compile(r"^[MmZzLlHhVvCcSsQqTtAaEe0-9,.+\-\s]+$")
773
+
774
+
775
+ def mark_refusal(kind: str, mark: str | None) -> str | None:
776
+ """Say why a connection kind may not wear the mark it declares, or ``None`` when it may.
777
+
778
+ A mark reaches a browser as the ``d`` of one path, so anything that is not path data is
779
+ refused here rather than drawn: non-empty, ASCII, path-data characters only, and at most
780
+ ``MARK_LIMIT`` of them.
781
+ """
782
+ if mark is None:
783
+ return None
784
+ detail: str | None = None
785
+ if not mark.strip():
786
+ detail = "it is empty"
787
+ elif not mark.isascii():
788
+ detail = "it is not ASCII"
789
+ elif len(mark) > MARK_LIMIT:
790
+ detail = f"it is {len(mark)} characters, and a mark holds at most {MARK_LIMIT}"
791
+ elif not _PATH_DATA.match(mark):
792
+ stray = sorted({character for character in mark if not _PATH_DATA.match(character)})
793
+ detail = f"it carries {''.join(stray)!r}, which path data does not"
794
+ if detail is None:
795
+ return None
796
+ return INVALID_MARK.render(kind=repr(kind), detail=detail)
797
+
798
+
745
799
  class ConnectionKind(ABC):
746
800
  """A named credential record of a contributed kind whose secret fields the server redacts."""
747
801
 
748
802
  id: ClassVar[str]
749
803
  config_model: ClassVar[type[BaseModel]]
750
804
 
805
+ mark: ClassVar[str | None] = None
806
+ """The ``d`` of one SVG path on a 24-unit grid, which is how a screen draws this kind.
807
+
808
+ Monochrome and filled in ``currentColor``, at the size every other kind's mark is drawn at;
809
+ a kind that declares none is drawn by the neutral mark.
810
+ """
811
+
751
812
  @abstractmethod
752
813
  async def check(self, config: BaseModel) -> HealthReport:
753
814
  """Verify that the configured connection can reach its external system.
@@ -796,6 +857,15 @@ class Contribution(BaseModel):
796
857
  _require_unique("format", list(self.formats))
797
858
  return self
798
859
 
860
+ @model_validator(mode="after")
861
+ def _check_marks(self) -> "Contribution":
862
+ """Reject a connection kind whose declared mark is not path data a browser may draw."""
863
+ for connection in self.connection_kinds:
864
+ refusal = mark_refusal(connection.id, connection.mark)
865
+ if refusal is not None:
866
+ raise ValueError(refusal)
867
+ return self
868
+
799
869
  def block_ids(self) -> list[str]:
800
870
  """List every operator and sensor id this contribution registers."""
801
871
  return [*(operator.spec.id for operator in self.operators), *(sensor.spec.id for sensor in self.sensors)]
@@ -37,3 +37,8 @@ UNSUPPORTED_API_VERSION = PLUGIN.define(
37
37
  )
38
38
 
39
39
  DUPLICATE_ID = PLUGIN.define("duplicate_id", "duplicate {label} {value} in contribution")
40
+
41
+ INVALID_MARK = PLUGIN.define(
42
+ "invalid_mark",
43
+ "the mark connection kind {kind} declares is not SVG path data: {detail}",
44
+ )