dirigent-plugin 0.12.0__tar.gz → 0.13.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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirigent-plugin
3
- Version: 0.12.0
3
+ Version: 0.13.0
4
4
  Summary: The dirigent plugin contract: block specs, protocols, and markers.
5
5
  License-Expression: LicenseRef-Proprietary
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-plugin"
3
- version = "0.12.0"
3
+ version = "0.13.0"
4
4
  description = "The dirigent plugin contract: block specs, protocols, and markers."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-plugin"
3
- version = "0.12.0"
3
+ version = "0.13.0"
4
4
  description = "The dirigent plugin contract: block specs, protocols, and markers."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -7,19 +7,24 @@ content-preserving re-encoding from one format to another; ``map``, whose output
7
7
  same length as its input; and ``filter``, whose output is a subset of its input with the
8
8
  elements unmodified.
9
9
 
10
- The frame owns everything an engine would otherwise repeat: where the input comes from and
11
- how much of it may be read, where the result goes, the catalog entry, and the apply-time
12
- check that refuses a bad program or an unsupported format pair before a document is stored.
13
- An engine supplies only the part that is specific to it.
10
+ The frame owns everything an engine would otherwise repeat: where the input comes from,
11
+ where the result goes, the catalog entry, and the apply-time check that refuses a bad
12
+ program or an unsupported format pair before a document is stored. An engine supplies only
13
+ the part that is specific to it.
14
+
15
+ A program verb works on a value and answers with one, so its input is a value an earlier
16
+ step produced and its output is read by a later one; a value comes in from storage through
17
+ ``storage.read`` and goes out through ``storage.write``. ``convert`` is the exception,
18
+ because its operand is a storage object rather than a value: it reads one URI and writes
19
+ another, the way ``storage.copy`` does.
14
20
  """
15
21
 
16
- import json
17
22
  from abc import ABC, abstractmethod
18
23
  from typing import Any, ClassVar, Final, cast
19
24
 
20
- from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator
25
+ from pydantic import BaseModel, Field, JsonValue
21
26
 
22
- from dirigent_common import BlockModel, Size, StorageUri
27
+ from dirigent_common import BlockModel, StorageUri
23
28
  from dirigent_plugin.blocks import (
24
29
  BlockFailure,
25
30
  ErrorClass,
@@ -29,9 +34,6 @@ from dirigent_plugin.blocks import (
29
34
  StepContext,
30
35
  )
31
36
 
32
- #: The same bound ``http.request`` puts on a body it holds in memory.
33
- MAX_INPUT_DEFAULT: Final = 32 * 1024 * 1024
34
-
35
37
  #: How much is handed to a storage sink at a time.
36
38
  CHUNK_BYTES: Final = 64 * 1024
37
39
 
@@ -53,40 +55,11 @@ class TransformError(Exception):
53
55
  class TransformConfig(BlockModel):
54
56
  """The half of a transform's config the frame owns, whatever the verb or the engine."""
55
57
 
56
- model_config = ConfigDict(
57
- use_attribute_docstrings=True,
58
- # The published schema carries the exactly-one rule, so a document that breaks it is
59
- # refused where every other config mistake is, rather than on the first run.
60
- json_schema_extra={"oneOf": [{"required": ["input"]}, {"required": ["input_uri"]}]},
61
- )
62
-
63
- input: JsonValue | None = None
64
- """The value to work on, written inline in the document.
65
-
66
- A ``null`` written here is read as no input at all, which is the one value that cannot
67
- be passed inline."""
68
-
69
- input_uri: StorageUri | None = None
70
- """A storage URI to read the input from instead of writing it inline."""
71
-
72
- save_to: StorageUri | None = None
73
- """A storage URI to stream the result to, instead of carrying it inline.
58
+ input: JsonValue
59
+ """The value to work on, written inline or referenced from an earlier step's output.
74
60
 
75
- The step's output then carries ``output_uri`` and ``output_bytes``: a result worth
76
- saving is one the next step reads from storage."""
77
-
78
- max_input: Size = MAX_INPUT_DEFAULT
79
- """How much of ``input_uri`` is read into memory.
80
-
81
- A value has to be whole to be reshaped, so one too large to hold is refused rather than
82
- truncated: half a document is not a smaller input, it is a wrong one."""
83
-
84
- @model_validator(mode="after")
85
- def _require_one_input(self) -> "TransformConfig":
86
- """Reject a config that gives both an inline value and a URI, or neither."""
87
- if (self.input is None) == (self.input_uri is None):
88
- raise ValueError("a transform reads either input or input_uri, and needs exactly one of the two")
89
- return self
61
+ An object held in storage reaches a transform through ``storage.read``, whose ``value``
62
+ this reads."""
90
63
 
91
64
 
92
65
  class ProgramConfig(TransformConfig):
@@ -97,42 +70,34 @@ class ProgramConfig(TransformConfig):
97
70
 
98
71
 
99
72
  class TransformOutput(BlockModel):
100
- """What one reshape produced: the value itself, or where it was written."""
73
+ """What one reshape produced."""
101
74
 
102
- value: JsonValue | None = None
103
- """The reshaped value, when it was not streamed to storage."""
75
+ value: JsonValue = None
76
+ """The reshaped value, which a later step reads or hands to ``storage.write``."""
104
77
 
105
- output_uri: str | None = None
106
- """Where the result was written, when ``save_to`` asked for it."""
107
78
 
108
- output_bytes: int | None = None
109
- """How many bytes were written there."""
79
+ class ConvertConfig(BlockModel):
80
+ """What one re-encoding is told: which object to read, where to put it, and the pair of formats."""
110
81
 
82
+ source: StorageUri = Field(min_length=1)
83
+ """The URI the bytes to re-encode are read from."""
111
84
 
112
- class ConvertConfig(TransformConfig):
113
- """What one re-encoding is told: where the bytes come from, and the pair of formats."""
114
-
115
- input: str | None = None # pyright: ignore[reportIncompatibleVariableOverride] - a codec reads text, not JSON
116
- """The text to re-encode, written inline in the document."""
85
+ target: StorageUri = Field(min_length=1)
86
+ """The URI the re-encoded bytes are written to, replacing whatever is there."""
117
87
 
118
88
  from_format: str = Field(alias="from", min_length=1)
119
- """The format the input is in, named as the engine names it."""
89
+ """The format the source is in, named as the engine names it."""
120
90
 
121
91
  to_format: str = Field(alias="to", min_length=1)
122
92
  """The format to produce."""
123
93
 
124
94
 
125
95
  class ConvertOutput(BlockModel):
126
- """What one re-encoding produced: the text itself, or where it was written."""
127
-
128
- text: str | None = None
129
- """The re-encoded text, when it was not streamed to storage."""
96
+ """Where the re-encoding read from and wrote to, so a later step can address the result."""
130
97
 
131
- output_uri: str | None = None
132
- """Where the result was written, when ``save_to`` asked for it."""
133
-
134
- output_bytes: int | None = None
135
- """How many bytes were written there."""
98
+ source: str
99
+ target: str
100
+ bytes_written: int
136
101
 
137
102
 
138
103
  class Transformer(Operator[ProgramConfig, TransformOutput], ABC):
@@ -140,8 +105,8 @@ class Transformer(Operator[ProgramConfig, TransformOutput], ABC):
140
105
 
141
106
  An engine names its kind, summarises itself in one line, and supplies the two halves of
142
107
  running a program: compiling it, which is also what the apply-time check runs, and
143
- applying it to a value. The frame derives the catalog entry, resolves the input, and
144
- disposes of the result.
108
+ applying it to a value. The frame derives the catalog entry and hands the result on as
109
+ the step's output.
145
110
 
146
111
  An engine touches no HTTP, no file outside storage, and nothing in the environment. It
147
112
  is handed a value and returns a value; everything that reaches the world is the frame's.
@@ -183,16 +148,12 @@ class Transformer(Operator[ProgramConfig, TransformOutput], ABC):
183
148
  ...
184
149
 
185
150
  async def execute(self, config: ProgramConfig, ctx: StepContext) -> TransformOutput | RemoteHandle:
186
- """Resolve the input, run the program over it, and inline or store the result."""
187
- value = await _read_value(config, ctx)
151
+ """Run the program over the input value and hand the result on as the step's output."""
188
152
  try:
189
- result = self.apply(self.compile(config.program), value)
153
+ result = self.apply(self.compile(config.program), config.input)
190
154
  except TransformError as error:
191
155
  raise BlockFailure(str(error), error_class=ErrorClass.REJECTED) from error
192
- if config.save_to is None:
193
- return TransformOutput(value=result)
194
- written = await _write(ctx, config.save_to, json.dumps(result, separators=(",", ":")).encode())
195
- return TransformOutput(output_uri=config.save_to, output_bytes=written)
156
+ return TransformOutput(value=result)
196
157
 
197
158
  def check_config(self, config: BaseModel) -> list[str]:
198
159
  """Compile the program at apply, so a bad one is refused before the document is stored."""
@@ -210,8 +171,8 @@ class Mapper(Operator[ProgramConfig, TransformOutput], ABC):
210
171
 
211
172
  An engine names its kind, summarises itself in one line, and supplies compiling a
212
173
  program and applying it -- to one element at a time, not to the whole list. The frame
213
- derives the catalog entry, resolves the input, refuses an input that is not an array,
214
- runs the loop, and disposes of the result.
174
+ derives the catalog entry, refuses an input that is not an array, runs the loop, and
175
+ hands the list on as the step's output.
215
176
 
216
177
  The promise is length: the output has one element for every element of the input, in
217
178
  input order. The frame builds it one element at a time, so the promise holds by
@@ -259,8 +220,8 @@ class Mapper(Operator[ProgramConfig, TransformOutput], ABC):
259
220
  ...
260
221
 
261
222
  async def execute(self, config: ProgramConfig, ctx: StepContext) -> TransformOutput | RemoteHandle:
262
- """Resolve the input, replace every element, and inline or store the list."""
263
- elements = _elements(await _read_value(config, ctx), self.spec.id, MAP_PROMISE)
223
+ """Replace every element of the input list and hand the list on as the step's output."""
224
+ elements = _elements(config.input, self.spec.id, MAP_PROMISE)
264
225
  try:
265
226
  compiled = self.compile(config.program)
266
227
  except TransformError as error:
@@ -269,10 +230,7 @@ class Mapper(Operator[ProgramConfig, TransformOutput], ABC):
269
230
  assert len(mapped) == len(elements), (
270
231
  f"{self.spec.id} produced {len(mapped)} elements from {len(elements)}, breaking the map promise"
271
232
  )
272
- if config.save_to is None:
273
- return TransformOutput(value=mapped)
274
- written = await _write(ctx, config.save_to, json.dumps(mapped, separators=(",", ":")).encode())
275
- return TransformOutput(output_uri=config.save_to, output_bytes=written)
233
+ return TransformOutput(value=mapped)
276
234
 
277
235
  def _map_each(self, compiled: object, elements: list[JsonValue]) -> list[JsonValue]:
278
236
  """Apply the engine once per element, in order, naming the element it refused."""
@@ -300,8 +258,8 @@ class Filterer(Operator[ProgramConfig, TransformOutput], ABC):
300
258
 
301
259
  An engine names its kind, summarises itself in one line, supplies compiling a program,
302
260
  and answers one question about one element: keep it, or not. The frame derives the
303
- catalog entry, resolves the input, refuses an input that is not an array, runs the loop,
304
- and disposes of the result.
261
+ catalog entry, refuses an input that is not an array, runs the loop, and hands the kept
262
+ elements on as the step's output.
305
263
 
306
264
  The promise is a subset with the elements unmodified. The frame keeps the element it was
307
265
  given rather than anything the engine produced, so an engine has no way to change an
@@ -349,8 +307,8 @@ class Filterer(Operator[ProgramConfig, TransformOutput], ABC):
349
307
  ...
350
308
 
351
309
  async def execute(self, config: ProgramConfig, ctx: StepContext) -> TransformOutput | RemoteHandle:
352
- """Resolve the input, keep the elements the engine answers true for, and inline or store them."""
353
- elements = _elements(await _read_value(config, ctx), self.spec.id, FILTER_PROMISE)
310
+ """Keep the elements the engine answers true for and hand them on as the step's output."""
311
+ elements = _elements(config.input, self.spec.id, FILTER_PROMISE)
354
312
  try:
355
313
  compiled = self.compile(config.program)
356
314
  except TransformError as error:
@@ -358,10 +316,7 @@ class Filterer(Operator[ProgramConfig, TransformOutput], ABC):
358
316
  # The element the frame was given, never anything the engine returned: what a filter
359
317
  # keeps is what arrived.
360
318
  kept = [element for index, element in enumerate(elements) if self._verdict(compiled, element, index)]
361
- if config.save_to is None:
362
- return TransformOutput(value=kept)
363
- written = await _write(ctx, config.save_to, json.dumps(kept, separators=(",", ":")).encode())
364
- return TransformOutput(output_uri=config.save_to, output_bytes=written)
319
+ return TransformOutput(value=kept)
365
320
 
366
321
  def _verdict(self, compiled: object, element: JsonValue, index: int) -> bool:
367
322
  """Ask the engine about one element, refusing an answer that is not a boolean."""
@@ -394,8 +349,8 @@ class Converter(Operator[ConvertConfig, ConvertOutput], ABC):
394
349
 
395
350
  A converter is a codec, not a language: there is no program. An engine names its kind,
396
351
  declares the ``(from, to)`` format pairs it supports, and re-encodes bytes. The frame
397
- derives the catalog entry, resolves the input, refuses an unsupported pair at apply, and
398
- disposes of the result.
352
+ derives the catalog entry, reads the source object, refuses an unsupported pair at apply,
353
+ and writes the target.
399
354
 
400
355
  An engine touches no HTTP, no file outside storage, and nothing in the environment. It
401
356
  is handed bytes and returns bytes; everything that reaches the world is the frame's.
@@ -435,19 +390,17 @@ class Converter(Operator[ConvertConfig, ConvertOutput], ABC):
435
390
  ...
436
391
 
437
392
  async def execute(self, config: ConvertConfig, ctx: StepContext) -> ConvertOutput | RemoteHandle:
438
- """Refuse an unsupported pair, then re-encode the input and inline or store the result."""
393
+ """Refuse an unsupported pair, then re-encode the source object onto the target."""
439
394
  unsupported = self._pair_refusal(config.from_format, config.to_format)
440
395
  if unsupported is not None:
441
396
  raise BlockFailure(unsupported, error_class=ErrorClass.REJECTED)
442
- source = await _read_bytes(config, ctx)
397
+ source = await _read(ctx, config.source)
443
398
  try:
444
399
  produced = self.convert(source, source_format=config.from_format, target_format=config.to_format)
445
400
  except TransformError as error:
446
401
  raise BlockFailure(str(error), error_class=ErrorClass.REJECTED) from error
447
- if config.save_to is None:
448
- return ConvertOutput(text=produced.decode("utf-8", errors="replace"))
449
- written = await _write(ctx, config.save_to, produced)
450
- return ConvertOutput(output_uri=config.save_to, output_bytes=written)
402
+ written = await _write(ctx, config.target, produced)
403
+ return ConvertOutput(source=config.source, target=config.target, bytes_written=written)
451
404
 
452
405
  def check_config(self, config: BaseModel) -> list[str]:
453
406
  """Refuse a format pair this engine has no codec for, at apply."""
@@ -489,43 +442,12 @@ def _named(value: JsonValue) -> str:
489
442
  return "a number"
490
443
 
491
444
 
492
- async def _read_value(config: ProgramConfig, ctx: StepContext) -> JsonValue:
493
- """Resolve a program engine's input: the inline value, or the JSON stored at a URI."""
494
- if config.input_uri is None:
495
- return config.input
496
- payload = await _read_bounded(ctx, config.input_uri, config.max_input)
497
- try:
498
- parsed: JsonValue = json.loads(payload)
499
- except ValueError as error:
500
- raise BlockFailure(
501
- f"{config.input_uri} does not hold JSON: {error}", error_class=ErrorClass.REJECTED
502
- ) from error
503
- return parsed
504
-
505
-
506
- async def _read_bytes(config: ConvertConfig, ctx: StepContext) -> bytes:
507
- """Resolve a codec engine's input: the inline text, or the bytes stored at a URI."""
508
- if config.input_uri is None:
509
- return (config.input or "").encode()
510
- return await _read_bounded(ctx, config.input_uri, config.max_input)
511
-
512
-
513
- async def _read_bounded(ctx: StepContext, uri: str, limit: int) -> bytes:
514
- """Read a stored object whole, refusing one larger than the step said it would hold.
515
-
516
- Counted as it arrives rather than trusted from a stat, because a recorded size is the
517
- backend's claim and this is the worker's memory.
518
- """
445
+ async def _read(ctx: StepContext, uri: str) -> bytes:
446
+ """Read the object whole, refusing a URI that holds nothing."""
447
+ if await ctx.storage.stat(uri) is None:
448
+ raise BlockFailure(f"there is nothing at {uri} to convert", error_class=ErrorClass.REJECTED)
519
449
  chunks: list[bytes] = []
520
- total = 0
521
450
  async for chunk in ctx.storage.open_read(uri):
522
- total += len(chunk)
523
- if total > limit:
524
- raise BlockFailure(
525
- f"{uri} is larger than max_input ({limit} bytes) and is not being read; "
526
- f"raise max_input, or transform it in pieces",
527
- error_class=ErrorClass.REJECTED,
528
- )
529
451
  chunks.append(chunk)
530
452
  return b"".join(chunks)
531
453
 
@@ -536,5 +458,5 @@ async def _write(ctx: StepContext, uri: str, payload: bytes) -> int:
536
458
  async with ctx.storage.open_write(uri) as sink:
537
459
  for start in range(0, len(payload), CHUNK_BYTES):
538
460
  written += await sink.write(payload[start : start + CHUNK_BYTES])
539
- ctx.log.info("transform result saved", uri=uri, bytes=written)
461
+ ctx.log.info("conversion written", uri=uri, bytes=written)
540
462
  return written