dirigent-plugin 0.21.0__tar.gz → 0.23.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.21.0
3
+ Version: 0.23.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.21.0
9
+ Requires-Dist: dirigent-common==0.23.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.21.0"
3
+ version = "0.23.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.21.0",
14
+ "dirigent-common==0.23.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.21.0"
3
+ version = "0.23.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.21.0",
14
+ "dirigent-common==0.23.0",
15
15
  "httpx2>=2.12.0",
16
16
  "pluginkit>=0.5.0",
17
17
  "pydantic>=2.13.5",
@@ -3,6 +3,7 @@
3
3
  from dirigent_plugin.blocks import (
4
4
  BLOCK_ID_PATTERN,
5
5
  MARK_LIMIT,
6
+ REFERENCE_KEYWORD,
6
7
  SHELL_VARIABLE_PREFIX,
7
8
  SHELL_VARIABLES_FIELD,
8
9
  SURFACE_ID_PATTERN,
@@ -45,6 +46,7 @@ from dirigent_plugin.blocks import (
45
46
  classify_default,
46
47
  mark_refusal,
47
48
  merge_contributions,
49
+ reference_fields,
48
50
  shell_string_fields,
49
51
  )
50
52
  from dirigent_plugin.markers import (
@@ -112,6 +114,7 @@ __all__ = [
112
114
  "RunnerEngine",
113
115
  "Runs",
114
116
  "SURFACE_ID_PATTERN",
117
+ "REFERENCE_KEYWORD",
115
118
  "SchemaRef",
116
119
  "Sensor",
117
120
  "SensorSpec",
@@ -136,5 +139,6 @@ __all__ = [
136
139
  "formatters",
137
140
  "mark_refusal",
138
141
  "merge_contributions",
142
+ "reference_fields",
139
143
  "shell_string_fields",
140
144
  ]
@@ -16,7 +16,16 @@ from pydantic import BaseModel, ConfigDict, Field, GetJsonSchemaHandler, JsonVal
16
16
  from pydantic.json_schema import JsonSchemaValue, SkipJsonSchema
17
17
  from pydantic_core import CoreSchema
18
18
 
19
- from dirigent_common import API_VERSION, SHELL_MEDIA_TYPE, BlockModel, HealthReport, Issue, JsonMap, Message
19
+ from dirigent_common import (
20
+ API_VERSION,
21
+ SHELL_MEDIA_TYPE,
22
+ BlockModel,
23
+ Catalogue,
24
+ HealthReport,
25
+ Issue,
26
+ JsonMap,
27
+ Message,
28
+ )
20
29
  from dirigent_plugin.messages import (
21
30
  DUPLICATE_ID,
22
31
  INVALID_MARK,
@@ -138,6 +147,14 @@ class ShellString:
138
147
  return published
139
148
 
140
149
 
150
+ #: What :class:`Reference` publishes, and what reading one back looks for.
151
+ REFERENCE_KEYWORD: Final = "x-dirigent-ref"
152
+
153
+ #: The only pointer shape a block's own config schema writes, which pydantic emits for a
154
+ #: field whose type is a named alias.
155
+ _LOCAL_DEFS: Final = "#/$defs/"
156
+
157
+
141
158
  class Reference:
142
159
  """Marks a config field whose value is the code of another thing this instance holds.
143
160
 
@@ -164,7 +181,7 @@ class Reference:
164
181
  ) -> JsonSchemaValue:
165
182
  """Publish what the marked field's code names, leaving what it validates untouched."""
166
183
  published = handler(schema)
167
- published["x-dirigent-ref"] = self.kind
184
+ published[REFERENCE_KEYWORD] = self.kind
168
185
  return published
169
186
 
170
187
 
@@ -175,6 +192,55 @@ type ConnectionRef = Annotated[str, Reference("connection")]
175
192
  type SchemaRef = Annotated[str, Reference("schema")]
176
193
 
177
194
 
195
+ def _pointed_at(shape: dict[str, Any], pool: dict[str, Any]) -> dict[str, Any] | None:
196
+ """The definition a local ``$ref`` names, or nothing where it names none."""
197
+ pointer = shape.get("$ref")
198
+ if not isinstance(pointer, str) or not pointer.startswith(_LOCAL_DEFS):
199
+ return None
200
+ target = pool.get(pointer.removeprefix(_LOCAL_DEFS))
201
+ return cast("dict[str, Any]", target) if isinstance(target, dict) else None
202
+
203
+
204
+ def _shapes(shape: dict[str, Any], pool: dict[str, Any]) -> list[dict[str, Any]]:
205
+ """One field's own shape, the definition it points at, and each branch of a union."""
206
+ found: list[dict[str, Any]] = [shape]
207
+ target = _pointed_at(shape, pool)
208
+ if target is not None:
209
+ found.append(target)
210
+ for option in cast("list[Any]", shape.get("anyOf") or []):
211
+ if not isinstance(option, dict):
212
+ continue
213
+ branch = cast("dict[str, Any]", option)
214
+ found.append(branch)
215
+ pointed = _pointed_at(branch, pool)
216
+ if pointed is not None:
217
+ found.append(pointed)
218
+ return found
219
+
220
+
221
+ def reference_fields(schema: JsonMap, kind: str) -> set[str]:
222
+ """Name the config fields a block publishes as references to one kind of thing.
223
+
224
+ A block says which of its strings hold a code, so nothing here guesses from a value: a
225
+ code and a value of the same shape are not told apart by looking. The marker rides on the
226
+ field's own shape or on the definition that shape points at, because a field typed as a
227
+ named alias is published as a ``$ref``.
228
+ """
229
+ properties = schema.get("properties")
230
+ if not isinstance(properties, dict):
231
+ return set()
232
+ held = schema.get("$defs")
233
+ pool = cast("dict[str, Any]", held) if isinstance(held, dict) else {}
234
+ named: set[str] = set()
235
+ for field, declared in cast("dict[str, Any]", properties).items():
236
+ if not isinstance(declared, dict):
237
+ continue
238
+ shapes = _shapes(cast("dict[str, Any]", declared), pool)
239
+ if any(one.get(REFERENCE_KEYWORD) == kind for one in shapes):
240
+ named.add(str(field))
241
+ return named
242
+
243
+
178
244
  #: What the engine names the variables it substitutes a shell string's references out into.
179
245
  SHELL_VARIABLE_PREFIX: Final = "DIRIGENT_V"
180
246
 
@@ -364,7 +430,13 @@ class ContainerResult(BaseModel):
364
430
 
365
431
 
366
432
  class AlertMessage(BaseModel):
367
- """What an alert rule hands a notifier: the event, a rendered summary, and links back."""
433
+ """What an alert rule hands a notifier: the event, a rendered summary, and links back.
434
+
435
+ The subject and the body are the rule author's own template rendered over the run, so they
436
+ are that author's words in that author's language and carry no code. What dirigent itself
437
+ said is ``error_code`` and ``error_params``: the refusal the run ended with, which a
438
+ notifier with a catalogue can render in the language of wherever it delivers.
439
+ """
368
440
 
369
441
  event: str = Field(min_length=1)
370
442
  subject: str = Field(min_length=1)
@@ -372,6 +444,10 @@ class AlertMessage(BaseModel):
372
444
  run_id: RunId | None = None
373
445
  pipeline: str | None = None
374
446
  url: str | None = None
447
+ error_code: str | None = None
448
+ """The dotted code of the refusal the alerted run ended with, when it ended with one."""
449
+ error_params: dict[str, JsonValue] = Field(default_factory=dict)
450
+ """The specifics that refusal rendered, for a re-render in another language."""
375
451
  context: dict[str, JsonValue] = Field(default_factory=dict)
376
452
 
377
453
 
@@ -493,6 +569,10 @@ class RunSnapshot(BaseModel):
493
569
  total_steps: int = Field(default=0, ge=0)
494
570
  finished_steps: int = Field(default=0, ge=0)
495
571
  error: str | None = None
572
+ error_code: str | None = None
573
+ """The dotted code of the refusal this run ended with, when it ended with one."""
574
+ error_params: JsonMap = Field(default_factory=dict)
575
+ """The specifics that refusal rendered, for a re-render in another language."""
496
576
 
497
577
  @property
498
578
  def progress(self) -> float | None:
@@ -524,8 +604,12 @@ class Runs(Protocol):
524
604
  """Describe a run this instance holds, or return None when it holds no such run."""
525
605
  ...
526
606
 
527
- async def cancel(self, run_id: RunId, *, reason: str) -> bool:
528
- """Cancel a run; False means it had already settled and there was nothing to stop."""
607
+ async def cancel(self, run_id: RunId, reason: Message, /, **params: Any) -> bool:
608
+ """Cancel a run; False means it had already settled and there was nothing to stop.
609
+
610
+ The reason is a catalogued message, so the cancelled run carries a code and whoever
611
+ reads it renders their own sentence rather than this block's English.
612
+ """
529
613
  ...
530
614
 
531
615
 
@@ -575,7 +659,12 @@ class StepContext(Protocol):
575
659
  ...
576
660
 
577
661
  def schema(self, code: str) -> JsonMap:
578
- """Resolve a named JSON Schema the instance holds by code; an unknown code fails the step."""
662
+ """Resolve one of the JSON Schemas this run holds by code; any other code fails the step.
663
+
664
+ A run holds the body of every schema its document named when it was created, so a
665
+ stored schema edited mid-run does not change what the rest of that run checks
666
+ against. A code the document does not name is not one the run holds.
667
+ """
579
668
  ...
580
669
 
581
670
  def format_checker(self) -> FormatChecker:
@@ -837,7 +926,7 @@ type AnySensor = Sensor[Any, Any]
837
926
 
838
927
 
839
928
  class Contribution(BaseModel):
840
- """Everything one plugin adds, across all six surfaces, gathered by the host at startup."""
929
+ """Everything one plugin adds, across all seven surfaces, gathered by the host at startup."""
841
930
 
842
931
  model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True)
843
932
 
@@ -852,6 +941,14 @@ class Contribution(BaseModel):
852
941
  ``format: <name>`` then asserts wherever the contributing pack is installed, and stays a
853
942
  passing annotation on an instance without it."""
854
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
+
855
952
  @field_validator("api_version")
856
953
  @classmethod
857
954
  def _check_api_version(cls, value: int) -> int:
@@ -868,6 +965,7 @@ class Contribution(BaseModel):
868
965
  _require_unique("notifier id", [notifier.id for notifier in self.notifiers])
869
966
  _require_unique("connection kind id", [connection.id for connection in self.connection_kinds])
870
967
  _require_unique("format", list(self.formats))
968
+ _require_unique("label prefix", [catalogue.prefix for catalogue in self.labels])
871
969
  return self
872
970
 
873
971
  @model_validator(mode="after")
@@ -901,6 +999,7 @@ def merge_contributions(contributions: list[Contribution]) -> Contribution:
901
999
  "storage_backends": [],
902
1000
  "notifiers": [],
903
1001
  "connection_kinds": [],
1002
+ "labels": [],
904
1003
  }
905
1004
  for contribution in contributions:
906
1005
  for surface, collected in merged.items():