dirigent-plugin 0.16.7__tar.gz → 0.17.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.16.7
3
+ Version: 0.17.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.16.7
9
+ Requires-Dist: dirigent-common==0.17.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.16.7"
3
+ version = "0.17.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.16.7",
14
+ "dirigent-common==0.17.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.16.7"
3
+ version = "0.17.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.16.7",
14
+ "dirigent-common==0.17.0",
15
15
  "httpx2>=2.12.0",
16
16
  "pluginkit>=0.5.0",
17
17
  "pydantic>=2.13.5",
@@ -52,6 +52,7 @@ from dirigent_plugin.markers import (
52
52
  extension_point,
53
53
  formatters,
54
54
  )
55
+ from dirigent_plugin.runners import ProgramRunner, RunnerEngine
55
56
  from dirigent_plugin.transforms import (
56
57
  ConvertConfig,
57
58
  Converter,
@@ -94,12 +95,14 @@ __all__ = [
94
95
  "ProbeResult",
95
96
  "ProbeStatus",
96
97
  "ProgramConfig",
98
+ "ProgramRunner",
97
99
  "Reference",
98
100
  "RemoteHandle",
99
101
  "RunId",
100
102
  "RunRefused",
101
103
  "RunSnapshot",
102
104
  "RunState",
105
+ "RunnerEngine",
103
106
  "Runs",
104
107
  "SURFACE_ID_PATTERN",
105
108
  "SchemaRef",
@@ -15,7 +15,11 @@ from pydantic import BaseModel, ConfigDict, Field, GetJsonSchemaHandler, JsonVal
15
15
  from pydantic.json_schema import JsonSchemaValue, SkipJsonSchema
16
16
  from pydantic_core import CoreSchema
17
17
 
18
- from dirigent_common import API_VERSION, SHELL_MEDIA_TYPE, BlockModel, HealthReport, JsonMap
18
+ from dirigent_common import API_VERSION, SHELL_MEDIA_TYPE, BlockModel, HealthReport, Issue, JsonMap, Message
19
+ from dirigent_plugin.messages import (
20
+ DUPLICATE_ID,
21
+ UNSUPPORTED_API_VERSION,
22
+ )
19
23
 
20
24
  type RunId = UUID
21
25
 
@@ -47,12 +51,15 @@ class ErrorClass(StrEnum):
47
51
 
48
52
 
49
53
  class BlockFailure(Exception):
50
- """A failure a block reports deliberately, carrying its own error classification."""
51
-
52
- def __init__(self, message: str, *, error_class: ErrorClass = ErrorClass.UNKNOWN) -> None:
53
- """Record the message and the class the engine should retry (or not retry) on."""
54
- super().__init__(message)
55
- self.message = message
54
+ """A failure a block reports deliberately, carrying its code and its classification."""
55
+
56
+ def __init__(self, message: Message, /, *, error_class: ErrorClass = ErrorClass.UNKNOWN, **params: Any) -> None:
57
+ """Render the catalogued message and record the class the engine retries (or not) on."""
58
+ rendered = message.render(**params)
59
+ super().__init__(rendered)
60
+ self.code = message.code
61
+ self.message = rendered
62
+ self.params: JsonMap = dict(params)
56
63
  self.error_class = error_class
57
64
 
58
65
  def __str__(self) -> str:
@@ -387,8 +394,12 @@ class Storage(Protocol):
387
394
  """Stream the object at a URI, closeable so a reader that stops early releases it."""
388
395
  ...
389
396
 
390
- def open_write(self, uri: str) -> AbstractAsyncContextManager[ByteSink]:
391
- """Open a streamed writer for a URI."""
397
+ def open_write(self, uri: str, *, content_type: str | None = None) -> AbstractAsyncContextManager[ByteSink]:
398
+ """Open a streamed writer for a URI.
399
+
400
+ The backend records the content type on the object where its store can hold one, and
401
+ ignores it where it cannot.
402
+ """
392
403
  ...
393
404
 
394
405
  async def stat(self, uri: str) -> StatResult | None:
@@ -426,9 +437,9 @@ class RunState(StrEnum):
426
437
  class RunRefused(BlockFailure):
427
438
  """The instance refused to start the run a block asked for."""
428
439
 
429
- def __init__(self, message: str) -> None:
440
+ def __init__(self, message: Message, /, **params: Any) -> None:
430
441
  """Carry the reason, classified as the configuration error it always is."""
431
- super().__init__(message, error_class=ErrorClass.REJECTED)
442
+ super().__init__(message, error_class=ErrorClass.REJECTED, **params)
432
443
 
433
444
 
434
445
  class StartedRun(BaseModel):
@@ -612,10 +623,10 @@ class Operator[ConfigT: BaseModel, OutputT: BaseModel](ABC):
612
623
  """Best-effort, idempotent cancellation; False means the remote could not be told."""
613
624
  return False
614
625
 
615
- def check_config(self, config: BaseModel) -> list[str]:
626
+ def check_config(self, config: BaseModel) -> list[Issue]:
616
627
  """List the extra refusals this block makes at apply, beyond what its schema says.
617
628
 
618
- Each string is shown against the step's config location, so a document is refused
629
+ Each issue is shown against the step's config location, so a document is refused
619
630
  before it is stored rather than the first time it runs.
620
631
  """
621
632
  return []
@@ -637,10 +648,10 @@ class Sensor[ConfigT: BaseModel, OutputT: BaseModel](ABC):
637
648
  """Observe the world once, read-only and briefly; NotYet is not a failure."""
638
649
  ...
639
650
 
640
- def check_config(self, config: BaseModel) -> list[str]:
651
+ def check_config(self, config: BaseModel) -> list[Issue]:
641
652
  """List the extra refusals this block makes at apply, beyond what its schema says.
642
653
 
643
- Each string is shown against the step's config location, so a document is refused
654
+ Each issue is shown against the step's config location, so a document is refused
644
655
  before it is stored rather than the first time it runs.
645
656
  """
646
657
  return []
@@ -676,8 +687,12 @@ class StorageBackend(ABC):
676
687
  ...
677
688
 
678
689
  @abstractmethod
679
- def open_write(self, uri: str) -> AbstractAsyncContextManager[ByteSink]:
680
- """Open a streamed writer for a URI."""
690
+ def open_write(self, uri: str, *, content_type: str | None = None) -> AbstractAsyncContextManager[ByteSink]:
691
+ """Open a streamed writer for a URI.
692
+
693
+ The backend records the content type on the object where its store can hold one, and
694
+ ignores it where it cannot.
695
+ """
681
696
  ...
682
697
 
683
698
  @abstractmethod
@@ -745,7 +760,7 @@ class Contribution(BaseModel):
745
760
  def _check_api_version(cls, value: int) -> int:
746
761
  """Reject a contribution written against a different revision of this contract."""
747
762
  if value != API_VERSION:
748
- raise ValueError(f"unsupported api_version {value}; this host speaks {API_VERSION}")
763
+ raise ValueError(UNSUPPORTED_API_VERSION.render(version=value, host_version=API_VERSION))
749
764
  return value
750
765
 
751
766
  @model_validator(mode="after")
@@ -768,7 +783,7 @@ def _require_unique(label: str, values: list[str]) -> None:
768
783
  seen: set[str] = set()
769
784
  for value in values:
770
785
  if value in seen:
771
- raise ValueError(f"duplicate {label} {value!r} in contribution")
786
+ raise ValueError(DUPLICATE_ID.render(label=label, value=repr(value)))
772
787
  seen.add(value)
773
788
 
774
789
 
@@ -0,0 +1,39 @@
1
+ """Every refusal the block contract itself makes, catalogued under the ``plugin`` prefix."""
2
+
3
+ from dirigent_common import Catalogue
4
+
5
+ PLUGIN = Catalogue("plugin")
6
+
7
+ TRANSFORM_FAILED = PLUGIN.define("transform_failed", "{detail}")
8
+
9
+ PROGRAM_REFUSED = PLUGIN.define("program_refused", "{detail}")
10
+
11
+ ELEMENT_FAILED = PLUGIN.define("element_failed", "element {index}: {detail}")
12
+
13
+ FILTER_ANSWER = PLUGIN.define(
14
+ "filter_answer",
15
+ "{block} answered {answer} for element {index}, and a filter's answer is true or false",
16
+ )
17
+
18
+ UNSUPPORTED_PAIR = PLUGIN.define(
19
+ "unsupported_pair",
20
+ "{block} does not convert {source_format} to {target_format} ({supported})",
21
+ )
22
+
23
+ NOT_AN_ARRAY = PLUGIN.define(
24
+ "not_an_array",
25
+ "{block} {promise}, so its input has to be a JSON array, and this one is {described}",
26
+ )
27
+
28
+ NOTHING_TO_CONVERT = PLUGIN.define("nothing_to_convert", "there is nothing at {uri} to convert")
29
+
30
+
31
+ # What a config refuses at validation. Pydantic owns the code a validator's refusal reaches
32
+ # the wire under, so these are rendered into the ``ValueError`` it wraps.
33
+
34
+ UNSUPPORTED_API_VERSION = PLUGIN.define(
35
+ "unsupported_api_version",
36
+ "unsupported api_version {version}; this host speaks {host_version}",
37
+ )
38
+
39
+ DUPLICATE_ID = PLUGIN.define("duplicate_id", "duplicate {label} {value} in contribution")
@@ -0,0 +1,255 @@
1
+ """The runner protocol: a program engine whose programs are compiled and run in a process of its own.
2
+
3
+ An engine that computes inside a C extension holding the interpreter's lock holds the
4
+ worker's event loop from a thread as firmly as from the loop itself, and a thread cannot be
5
+ cancelled: the step's timeout never fires, the lease heartbeat never runs, and the sweeper
6
+ hands the attempt to another worker while this one is still computing. Such an engine names
7
+ the ``command`` that starts a runner, and its programs run there, where killing the process
8
+ is what ends them.
9
+
10
+ A runner reads one JSON object per line and answers one JSON object per line, one reply per
11
+ request and in the order the requests arrived:
12
+
13
+ - ``{"kind": "compile", "id": ..., "program": ...}``, answered with ``{"ok": true}``, or
14
+ with ``{"error": "..."}`` carrying the runner's own message about a program it refuses.
15
+ A program that compiled is held under the id it was named by.
16
+ - ``{"kind": "run", "id": ..., "value": ...}``, answered with ``{"outputs": [...]}``, the
17
+ stream the program produced, or with ``{"error": "..."}``.
18
+ - ``{"kind": "forget", "id": ...}``, answered with ``{"ok": true}``: the program is released.
19
+
20
+ A step's program therefore crosses the pipe once, however many elements it is run over. The
21
+ pipe closing is how the parent says it is done.
22
+ """
23
+
24
+ import asyncio
25
+ import atexit
26
+ import json
27
+ import subprocess
28
+ from collections.abc import Callable, Sequence
29
+ from contextvars import ContextVar
30
+ from typing import IO, Any, ClassVar, cast
31
+ from uuid import uuid4
32
+
33
+ from pydantic import BaseModel, JsonValue
34
+
35
+ from dirigent_common import Issue
36
+ from dirigent_plugin.messages import PROGRAM_REFUSED
37
+ from dirigent_plugin.transforms import Engine, ProgramConfig, TransformError
38
+
39
+ #: What a step is told when the process running its program stopped on its own. It is not a
40
+ #: TransformError, because nothing about the program says it will happen again.
41
+ RUNNER_STOPPED = "the process running the program stopped before it answered"
42
+
43
+
44
+ class ProgramRunner:
45
+ """One runner process, and the two pipes a program and its outputs cross."""
46
+
47
+ def __init__(self, command: Sequence[str]) -> None:
48
+ """Start the process the command names and hold the pipes it is spoken to over."""
49
+ self.command = tuple(command)
50
+ self.process = subprocess.Popen(
51
+ self.command,
52
+ stdin=subprocess.PIPE,
53
+ stdout=subprocess.PIPE,
54
+ )
55
+ # Popen types both pipes as optional; both were asked for.
56
+ self._to = cast("IO[bytes]", self.process.stdin)
57
+ self._from = cast("IO[bytes]", self.process.stdout)
58
+ self._compiled: set[str] = set()
59
+
60
+ @property
61
+ def alive(self) -> bool:
62
+ """Whether this process is still there to run a program."""
63
+ return self.process.poll() is None
64
+
65
+ def compile(self, source: str) -> str:
66
+ """Compile one program in this process and hand back the id it is run by."""
67
+ identifier = uuid4().hex
68
+ self._exchange({"kind": "compile", "id": identifier, "program": source})
69
+ # Held only once it has compiled, so a refused program is never one to forget.
70
+ self._compiled.add(identifier)
71
+ return identifier
72
+
73
+ def run(self, identifier: str, value: JsonValue) -> list[JsonValue]:
74
+ """Run the program compiled under an id over one value and read the outputs it produced."""
75
+ answered = self._exchange({"kind": "run", "id": identifier, "value": value})
76
+ return cast("list[JsonValue]", answered["outputs"])
77
+
78
+ def forget(self, identifier: str) -> None:
79
+ """Release one compiled program."""
80
+ self._compiled.discard(identifier)
81
+ self._exchange({"kind": "forget", "id": identifier})
82
+
83
+ def release(self) -> None:
84
+ """Forget every program compiled here, so a runner outliving a step keeps none of them.
85
+
86
+ A runner that has stopped took its programs with it, and there is nothing to say to
87
+ a closed pipe.
88
+ """
89
+ if not self.alive:
90
+ self._compiled.clear()
91
+ return
92
+ for identifier in sorted(self._compiled):
93
+ self.forget(identifier)
94
+
95
+ def kill(self) -> None:
96
+ """End whatever is running, which is the only way to stop a program, and close it out.
97
+
98
+ Waiting is what makes the process dead rather than dying, so the pool sees at once
99
+ that this one is not to be handed out again; a killed process is reaped at once.
100
+ Closing the pipes waits for the read a step's thread is still blocked in, which the
101
+ kill has just ended, and leaves no descriptor behind for a collection to complain of.
102
+ """
103
+ self.process.kill()
104
+ self.process.wait()
105
+ self._to.close()
106
+ self._from.close()
107
+
108
+ def _exchange(self, request: dict[str, Any]) -> dict[str, Any]:
109
+ """Send one request, read the one reply to it, and raise what the runner refused."""
110
+ try:
111
+ self._to.write(json.dumps(request).encode() + b"\n")
112
+ self._to.flush()
113
+ reply = self._from.readline()
114
+ except OSError as error:
115
+ raise RuntimeError(RUNNER_STOPPED) from error
116
+ if not reply:
117
+ # A killed process, which is how a cancelled step ends its program, and how a
118
+ # program that ran the process out of memory ends itself.
119
+ raise RuntimeError(RUNNER_STOPPED)
120
+ answered = cast("dict[str, Any]", json.loads(reply))
121
+ refusal = answered.get("error")
122
+ if refusal is not None:
123
+ raise TransformError(cast("str", refusal))
124
+ return answered
125
+
126
+
127
+ class _Runners:
128
+ """The runner processes this worker keeps: one per command, and one per step running at a time.
129
+
130
+ A step holds one for as long as its program runs and gives it back after, so two steps
131
+ never meet on one pipe. A process is started when there is none to hand out and then
132
+ lives on, because starting one costs more than every program a step runs through it; it
133
+ ends when the worker does, its pipe closing under it. A process that was killed is not
134
+ handed out again.
135
+ """
136
+
137
+ def __init__(self) -> None:
138
+ """Start with no processes; the first step that needs one starts it."""
139
+ self._idle: dict[tuple[str, ...], list[ProgramRunner]] = {}
140
+
141
+ async def take(self, command: Sequence[str]) -> ProgramRunner:
142
+ """Hand out an idle process for a command, starting one off the event loop when there is none."""
143
+ idle = self._idle.setdefault(tuple(command), [])
144
+ while idle:
145
+ runner = idle.pop()
146
+ if runner.alive:
147
+ return runner
148
+ starting = asyncio.ensure_future(asyncio.to_thread(ProgramRunner, command))
149
+ try:
150
+ return await asyncio.shield(starting)
151
+ except asyncio.CancelledError:
152
+ # A step can be cancelled while its process is still starting. The start is
153
+ # shielded so the process is finished and kept, rather than left running for a
154
+ # step that is already gone.
155
+ starting.add_done_callback(self._started)
156
+ raise
157
+
158
+ def give_back(self, runner: ProgramRunner) -> None:
159
+ """Take a process back for the next step that runs its command, unless it was killed."""
160
+ if runner.alive:
161
+ self._idle.setdefault(runner.command, []).append(runner)
162
+
163
+ def shutdown(self) -> None:
164
+ """Kill every idle process, so none is left running when this one exits."""
165
+ for idle in self._idle.values():
166
+ while idle:
167
+ idle.pop().kill()
168
+
169
+ def _started(self, starting: "asyncio.Future[ProgramRunner]") -> None:
170
+ """Keep a process whose step gave up on it while it was starting."""
171
+ if not starting.cancelled() and starting.exception() is None:
172
+ self.give_back(starting.result())
173
+
174
+
175
+ #: The runner processes this worker keeps. One pool for every engine, keyed by the command
176
+ #: that starts a runner: a step holds a runner, not a verb.
177
+ _RUNNERS = _Runners()
178
+
179
+ atexit.register(_RUNNERS.shutdown)
180
+
181
+ #: The runner the step running on this task talks to. A program is compiled and run from
182
+ #: inside the thread offload() hands the step's work to, which is where the engine reads it.
183
+ _RUNNING: ContextVar[ProgramRunner] = ContextVar("dirigent_program_runner")
184
+
185
+
186
+ def _running() -> ProgramRunner:
187
+ """The runner this step holds, which only a step running through offload() has."""
188
+ try:
189
+ return _RUNNING.get()
190
+ except LookupError as error:
191
+ missing = "a runner engine is called through offload(), which takes the process its program runs in"
192
+ raise RuntimeError(missing) from error
193
+
194
+
195
+ class RunnerEngine(Engine):
196
+ """A program engine whose programs are compiled and run in a runner process.
197
+
198
+ The engine supplies the command that starts one and the source its runner compiles; the
199
+ step holds one runner for its whole run, its program is compiled there once, every
200
+ element is run through the id that compile answered with, and the id is forgotten when
201
+ the runner is given back.
202
+
203
+ The three program verbs are what this is for: ``convert`` has no program, so a codec
204
+ engine has no runner either.
205
+ """
206
+
207
+ command: ClassVar[Sequence[str]]
208
+ """What starts one runner process for this engine."""
209
+
210
+ def source(self, program: str) -> str:
211
+ """The source the runner compiles: the author's program, wrapped as the engine needs it."""
212
+ return program
213
+
214
+ def check_program(self, program: str) -> None:
215
+ """Refuse a bad program at apply by raising TransformError, where an engine can read its own language.
216
+
217
+ There is no runner outside a step, so an engine that cannot parse its language on
218
+ the worker leaves a bad program to be refused when the step compiles it.
219
+ """
220
+ return
221
+
222
+ def compile(self, program: str) -> object:
223
+ """Compile the program in the runner this step holds and hand back the id it is run by."""
224
+ return _running().compile(self.source(program))
225
+
226
+ def outputs(self, compiled: object, value: JsonValue) -> list[JsonValue]:
227
+ """Run a compiled program over one value in this step's runner and read its outputs."""
228
+ return _running().run(cast("str", compiled), value)
229
+
230
+ async def offload[T](self, work: Callable[[], T]) -> T:
231
+ """Hold a runner for the step, and kill it if the step is cancelled or times out."""
232
+ runner = await _RUNNERS.take(self.command)
233
+ token = _RUNNING.set(runner)
234
+ try:
235
+ return await asyncio.to_thread(work)
236
+ except asyncio.CancelledError:
237
+ # Cancelling the await does not stop the thread: it is inside a blocking read,
238
+ # and killing the process is both what ends the program and what lets that read
239
+ # return, so nothing is left running behind a step that has already failed.
240
+ runner.kill()
241
+ raise
242
+ finally:
243
+ _RUNNING.reset(token)
244
+ runner.release()
245
+ _RUNNERS.give_back(runner)
246
+
247
+ def check_config(self, config: BaseModel) -> list[Issue]:
248
+ """Check the program at apply, where the engine's own reading of it is all there is."""
249
+ if not isinstance(config, ProgramConfig):
250
+ return []
251
+ try:
252
+ self.check_program(config.program)
253
+ except TransformError as error:
254
+ return [Issue.of(PROGRAM_REFUSED, detail=str(error))]
255
+ return []
@@ -30,7 +30,7 @@ from typing import Any, ClassVar, Final, cast
30
30
 
31
31
  from pydantic import BaseModel, Field, JsonValue
32
32
 
33
- from dirigent_common import BlockModel, StorageUri
33
+ from dirigent_common import BlockModel, Issue, JsonMap, StorageUri
34
34
  from dirigent_plugin.blocks import (
35
35
  BlockFailure,
36
36
  ErrorClass,
@@ -39,6 +39,15 @@ from dirigent_plugin.blocks import (
39
39
  RemoteHandle,
40
40
  StepContext,
41
41
  )
42
+ from dirigent_plugin.messages import (
43
+ ELEMENT_FAILED,
44
+ FILTER_ANSWER,
45
+ NOT_AN_ARRAY,
46
+ NOTHING_TO_CONVERT,
47
+ PROGRAM_REFUSED,
48
+ TRANSFORM_FAILED,
49
+ UNSUPPORTED_PAIR,
50
+ )
42
51
 
43
52
  #: How much is handed to a storage sink at a time.
44
53
  CHUNK_BYTES: Final = 64 * 1024
@@ -181,17 +190,17 @@ class Transformer(Engine, Operator[ProgramConfig, TransformOutput], ABC):
181
190
  try:
182
191
  result = await self.offload(lambda: self.apply(self.compile(config.program), config.input))
183
192
  except TransformError as error:
184
- raise BlockFailure(str(error), error_class=ErrorClass.REJECTED) from error
193
+ raise BlockFailure(TRANSFORM_FAILED, error_class=ErrorClass.REJECTED, detail=str(error)) from error
185
194
  return TransformOutput(value=result)
186
195
 
187
- def check_config(self, config: BaseModel) -> list[str]:
196
+ def check_config(self, config: BaseModel) -> list[Issue]:
188
197
  """Compile the program at apply, so a bad one is refused before the document is stored."""
189
198
  if not isinstance(config, ProgramConfig):
190
199
  return []
191
200
  try:
192
201
  self.compile(config.program)
193
202
  except TransformError as error:
194
- return [str(error)]
203
+ return [Issue.of(PROGRAM_REFUSED, detail=str(error))]
195
204
  return []
196
205
 
197
206
 
@@ -253,7 +262,7 @@ class Mapper(Engine, Operator[ProgramConfig, TransformOutput], ABC):
253
262
  try:
254
263
  compiled = self.compile(program)
255
264
  except TransformError as error:
256
- raise BlockFailure(str(error), error_class=ErrorClass.REJECTED) from error
265
+ raise BlockFailure(TRANSFORM_FAILED, error_class=ErrorClass.REJECTED, detail=str(error)) from error
257
266
  return self._map_each(compiled, elements)
258
267
 
259
268
  def _map_each(self, compiled: object, elements: list[JsonValue]) -> list[JsonValue]:
@@ -263,17 +272,19 @@ class Mapper(Engine, Operator[ProgramConfig, TransformOutput], ABC):
263
272
  try:
264
273
  mapped.append(self.apply(compiled, element))
265
274
  except TransformError as error:
266
- raise BlockFailure(f"element {index}: {error}", error_class=ErrorClass.REJECTED) from error
275
+ raise BlockFailure(
276
+ ELEMENT_FAILED, error_class=ErrorClass.REJECTED, index=index, detail=str(error)
277
+ ) from error
267
278
  return mapped
268
279
 
269
- def check_config(self, config: BaseModel) -> list[str]:
280
+ def check_config(self, config: BaseModel) -> list[Issue]:
270
281
  """Compile the program at apply, so a bad one is refused before the document is stored."""
271
282
  if not isinstance(config, ProgramConfig):
272
283
  return []
273
284
  try:
274
285
  self.compile(config.program)
275
286
  except TransformError as error:
276
- return [str(error)]
287
+ return [Issue.of(PROGRAM_REFUSED, detail=str(error))]
277
288
  return []
278
289
 
279
290
 
@@ -335,7 +346,7 @@ class Filterer(Engine, Operator[ProgramConfig, TransformOutput], ABC):
335
346
  try:
336
347
  compiled = self.compile(program)
337
348
  except TransformError as error:
338
- raise BlockFailure(str(error), error_class=ErrorClass.REJECTED) from error
349
+ raise BlockFailure(TRANSFORM_FAILED, error_class=ErrorClass.REJECTED, detail=str(error)) from error
339
350
  return [self._verdict(compiled, element, index) for index, element in enumerate(elements)]
340
351
 
341
352
  def _verdict(self, compiled: object, element: JsonValue, index: int) -> bool:
@@ -345,22 +356,27 @@ class Filterer(Engine, Operator[ProgramConfig, TransformOutput], ABC):
345
356
  # engine is held to it.
346
357
  answer = cast("object", self.keep(compiled, element))
347
358
  except TransformError as error:
348
- raise BlockFailure(f"element {index}: {error}", error_class=ErrorClass.REJECTED) from error
359
+ raise BlockFailure(
360
+ ELEMENT_FAILED, error_class=ErrorClass.REJECTED, index=index, detail=str(error)
361
+ ) from error
349
362
  if not isinstance(answer, bool):
350
363
  raise BlockFailure(
351
- f"{self.spec.id} answered {answer!r} for element {index}, and a filter's answer is true or false",
364
+ FILTER_ANSWER,
352
365
  error_class=ErrorClass.REJECTED,
366
+ block=self.spec.id,
367
+ answer=repr(answer),
368
+ index=index,
353
369
  )
354
370
  return answer
355
371
 
356
- def check_config(self, config: BaseModel) -> list[str]:
372
+ def check_config(self, config: BaseModel) -> list[Issue]:
357
373
  """Compile the program at apply, so a bad one is refused before the document is stored."""
358
374
  if not isinstance(config, ProgramConfig):
359
375
  return []
360
376
  try:
361
377
  self.compile(config.program)
362
378
  except TransformError as error:
363
- return [str(error)]
379
+ return [Issue.of(PROGRAM_REFUSED, detail=str(error))]
364
380
  return []
365
381
 
366
382
 
@@ -402,33 +418,37 @@ class Converter(Engine, Operator[ConvertConfig, ConvertOutput], ABC):
402
418
 
403
419
  async def execute(self, config: ConvertConfig, ctx: StepContext) -> ConvertOutput | RemoteHandle:
404
420
  """Refuse an unsupported pair, then re-encode the source object onto the target."""
405
- unsupported = self._pair_refusal(config.from_format, config.to_format)
421
+ unsupported = self._unsupported(config.from_format, config.to_format)
406
422
  if unsupported is not None:
407
- raise BlockFailure(unsupported, error_class=ErrorClass.REJECTED)
423
+ raise BlockFailure(UNSUPPORTED_PAIR, error_class=ErrorClass.REJECTED, **unsupported)
408
424
  source = await _read(ctx, config.source)
409
425
  try:
410
426
  produced = await self.offload(
411
427
  lambda: self.convert(source, source_format=config.from_format, target_format=config.to_format)
412
428
  )
413
429
  except TransformError as error:
414
- raise BlockFailure(str(error), error_class=ErrorClass.REJECTED) from error
430
+ raise BlockFailure(TRANSFORM_FAILED, error_class=ErrorClass.REJECTED, detail=str(error)) from error
415
431
  written = await _write(ctx, config.target, produced)
416
432
  return ConvertOutput(source=config.source, target=config.target, bytes_written=written)
417
433
 
418
- def check_config(self, config: BaseModel) -> list[str]:
434
+ def check_config(self, config: BaseModel) -> list[Issue]:
419
435
  """Refuse a format pair this engine has no codec for, at apply."""
420
436
  if not isinstance(config, ConvertConfig):
421
437
  return []
422
- unsupported = self._pair_refusal(config.from_format, config.to_format)
423
- return [] if unsupported is None else [unsupported]
438
+ unsupported = self._unsupported(config.from_format, config.to_format)
439
+ return [] if unsupported is None else [Issue.of(UNSUPPORTED_PAIR, **unsupported)]
424
440
 
425
- def _pair_refusal(self, source_format: str, target_format: str) -> str | None:
426
- """Word the refusal of a pair this engine does not support, naming the ones it does."""
441
+ def _unsupported(self, source_format: str, target_format: str) -> JsonMap | None:
442
+ """Name the params of a pair this engine does not support, or nothing when it does."""
427
443
  if (source_format, target_format) in self.pairs:
428
444
  return None
429
445
  listed = ", ".join(f"{one} to {other}" for one, other in sorted(self.pairs))
430
- supported = listed or "this engine converts nothing"
431
- return f"{self.spec.id} does not convert {source_format} to {target_format} ({supported})"
446
+ return {
447
+ "block": self.spec.id,
448
+ "source_format": source_format,
449
+ "target_format": target_format,
450
+ "supported": listed or "this engine converts nothing",
451
+ }
432
452
 
433
453
 
434
454
  def _elements(value: JsonValue, spec_id: str, promise: str) -> list[JsonValue]:
@@ -436,8 +456,11 @@ def _elements(value: JsonValue, spec_id: str, promise: str) -> list[JsonValue]:
436
456
  if isinstance(value, list):
437
457
  return value
438
458
  raise BlockFailure(
439
- f"{spec_id} {promise}, so its input has to be a JSON array, and this one is {_named(value)}",
459
+ NOT_AN_ARRAY,
440
460
  error_class=ErrorClass.REJECTED,
461
+ block=spec_id,
462
+ promise=promise,
463
+ described=_named(value),
441
464
  )
442
465
 
443
466
 
@@ -458,7 +481,7 @@ def _named(value: JsonValue) -> str:
458
481
  async def _read(ctx: StepContext, uri: str) -> bytes:
459
482
  """Read the object whole, refusing a URI that holds nothing."""
460
483
  if await ctx.storage.stat(uri) is None:
461
- raise BlockFailure(f"there is nothing at {uri} to convert", error_class=ErrorClass.REJECTED)
484
+ raise BlockFailure(NOTHING_TO_CONVERT, error_class=ErrorClass.REJECTED, uri=uri)
462
485
  chunks: list[bytes] = []
463
486
  async for chunk in ctx.storage.open_read(uri):
464
487
  chunks.append(chunk)