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.
- {dirigent_plugin-0.12.0 → dirigent_plugin-0.13.0}/PKG-INFO +1 -1
- {dirigent_plugin-0.12.0 → dirigent_plugin-0.13.0}/pyproject.toml +1 -1
- {dirigent_plugin-0.12.0 → dirigent_plugin-0.13.0}/pyproject.toml.orig +1 -1
- {dirigent_plugin-0.12.0 → dirigent_plugin-0.13.0}/src/dirigent_plugin/transforms.py +56 -134
- {dirigent_plugin-0.12.0 → dirigent_plugin-0.13.0}/LICENSE +0 -0
- {dirigent_plugin-0.12.0 → dirigent_plugin-0.13.0}/README.md +0 -0
- {dirigent_plugin-0.12.0 → dirigent_plugin-0.13.0}/src/dirigent_plugin/__init__.py +0 -0
- {dirigent_plugin-0.12.0 → dirigent_plugin-0.13.0}/src/dirigent_plugin/blocks.py +0 -0
- {dirigent_plugin-0.12.0 → dirigent_plugin-0.13.0}/src/dirigent_plugin/markers.py +0 -0
- {dirigent_plugin-0.12.0 → dirigent_plugin-0.13.0}/src/dirigent_plugin/py.typed +0 -0
|
@@ -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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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,
|
|
25
|
+
from pydantic import BaseModel, Field, JsonValue
|
|
21
26
|
|
|
22
|
-
from dirigent_common import BlockModel,
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
|
73
|
+
"""What one reshape produced."""
|
|
101
74
|
|
|
102
|
-
value: JsonValue
|
|
103
|
-
"""The reshaped value,
|
|
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
|
-
|
|
109
|
-
"""
|
|
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
|
-
|
|
113
|
-
"""
|
|
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
|
|
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
|
-
"""
|
|
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
|
-
|
|
132
|
-
|
|
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
|
|
144
|
-
|
|
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
|
-
"""
|
|
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),
|
|
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
|
-
|
|
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,
|
|
214
|
-
|
|
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
|
-
"""
|
|
263
|
-
elements = _elements(
|
|
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
|
-
|
|
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,
|
|
304
|
-
|
|
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
|
-
"""
|
|
353
|
-
elements = _elements(
|
|
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
|
-
|
|
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,
|
|
398
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
448
|
-
|
|
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
|
|
493
|
-
"""
|
|
494
|
-
if
|
|
495
|
-
|
|
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("
|
|
461
|
+
ctx.log.info("conversion written", uri=uri, bytes=written)
|
|
540
462
|
return written
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|