dirigent-block-storage 0.17.1__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.
@@ -0,0 +1,18 @@
1
+ Copyright (c) 2026 Morten Olav Hansen <morten@winterop.com>. All rights reserved.
2
+
3
+ This source code and accompanying documentation are the property of
4
+ Morten Olav Hansen. No license, express or implied, is granted to use, copy,
5
+ modify, merge, publish, distribute, sublicense, or sell copies of this
6
+ software or its derivatives.
7
+
8
+ The source is published for reference only. Any use beyond reading
9
+ requires written permission from the copyright holder.
10
+
11
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
12
+ OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
13
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
14
+ IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES,
15
+ OR OTHER LIABILITY ARISING FROM THE USE OF THE SOFTWARE.
16
+
17
+ Third-party components redistributed with this software, and the licences they
18
+ carry, are listed in THIRD_PARTY_NOTICES.md.
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: dirigent-block-storage
3
+ Version: 0.17.1
4
+ Summary: The storage block family for dirigent: copy, read, write, and wait for an object at a URI.
5
+ License-Expression: LicenseRef-Proprietary
6
+ License-File: LICENSE
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.13
9
+ Requires-Dist: dirigent-common==0.17.1
10
+ Requires-Dist: dirigent-plugin==0.17.1
11
+ Requires-Python: >=3.13
12
+ Description-Content-Type: text/markdown
13
+
14
+ # dirigent-block-storage
15
+
16
+ The storage block family: `storage.copy` moves an object from one URI to another,
17
+ `storage.write` puts a value or text at one, `storage.read` brings one back as a value, and
18
+ the `storage.exists` sensor waits for one to appear.
19
+
20
+ Every block here speaks whatever URI schemes the instance has registered, so a backend pack
21
+ such as `dirigent-storage-s3` widens what these four can address without changing them. The
22
+ family depends on nothing but the contract packages.
@@ -0,0 +1,9 @@
1
+ # dirigent-block-storage
2
+
3
+ The storage block family: `storage.copy` moves an object from one URI to another,
4
+ `storage.write` puts a value or text at one, `storage.read` brings one back as a value, and
5
+ the `storage.exists` sensor waits for one to appear.
6
+
7
+ Every block here speaks whatever URI schemes the instance has registered, so a backend pack
8
+ such as `dirigent-storage-s3` widens what these four can address without changing them. The
9
+ family depends on nothing but the contract packages.
@@ -0,0 +1,29 @@
1
+ [project]
2
+ name = "dirigent-block-storage"
3
+ version = "0.17.1"
4
+ description = "The storage block family for dirigent: copy, read, write, and wait for an object at a URI."
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ license = "LicenseRef-Proprietary"
8
+ license-files = ["LICENSE"]
9
+ classifiers = [
10
+ "Programming Language :: Python :: 3",
11
+ "Programming Language :: Python :: 3.13",
12
+ ]
13
+ dependencies = [
14
+ "dirigent-common==0.17.1",
15
+ "dirigent-plugin==0.17.1",
16
+ ]
17
+
18
+ [project.entry-points."dirigent.plugins.v1"]
19
+ block-storage = "dirigent_block_storage:plugin"
20
+
21
+ [build-system]
22
+ requires = ["uv_build>=0.12.0,<0.13.0"]
23
+ build-backend = "uv_build"
24
+
25
+ [tool.uv.sources.dirigent-common]
26
+ workspace = true
27
+
28
+ [tool.uv.sources.dirigent-plugin]
29
+ workspace = true
@@ -0,0 +1,27 @@
1
+ [project]
2
+ name = "dirigent-block-storage"
3
+ version = "0.17.1"
4
+ description = "The storage block family for dirigent: copy, read, write, and wait for an object at a URI."
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ license = "LicenseRef-Proprietary"
8
+ license-files = ["LICENSE"]
9
+ classifiers = [
10
+ "Programming Language :: Python :: 3",
11
+ "Programming Language :: Python :: 3.13",
12
+ ]
13
+ dependencies = [
14
+ "dirigent-common==0.17.1",
15
+ "dirigent-plugin==0.17.1",
16
+ ]
17
+
18
+ [project.entry-points."dirigent.plugins.v1"]
19
+ block-storage = "dirigent_block_storage:plugin"
20
+
21
+ [build-system]
22
+ requires = ["uv_build>=0.12.0,<0.13.0"]
23
+ build-backend = "uv_build"
24
+
25
+ [tool.uv.sources]
26
+ dirigent-common = { workspace = true }
27
+ dirigent-plugin = { workspace = true }
@@ -0,0 +1,33 @@
1
+ """The storage block family: moving objects between URIs, and waiting for one to appear."""
2
+
3
+ from dirigent_block_storage.storage import (
4
+ StorageCopyOperator,
5
+ StorageExistsSensor,
6
+ StorageReadOperator,
7
+ StorageWriteOperator,
8
+ )
9
+ from dirigent_plugin import Contribution, extension
10
+
11
+
12
+ class StorageBlocks:
13
+ """The plugin object the host discovers under the dirigent.plugins.v1 entry-point group."""
14
+
15
+ @extension
16
+ def contribute(self) -> Contribution:
17
+ """Contribute the storage blocks, which speak every registered URI scheme."""
18
+ return Contribution(
19
+ operators=[StorageCopyOperator(), StorageReadOperator(), StorageWriteOperator()],
20
+ sensors=[StorageExistsSensor()],
21
+ )
22
+
23
+
24
+ plugin = StorageBlocks()
25
+
26
+ __all__ = [
27
+ "StorageBlocks",
28
+ "StorageCopyOperator",
29
+ "StorageExistsSensor",
30
+ "StorageReadOperator",
31
+ "StorageWriteOperator",
32
+ "plugin",
33
+ ]
@@ -0,0 +1,29 @@
1
+ """Every refusal the storage family makes, catalogued under the ``storage`` prefix."""
2
+
3
+ from dirigent_common import Catalogue
4
+
5
+ STORAGE = Catalogue("storage")
6
+
7
+ NOTHING_THERE = STORAGE.define("nothing_there", "there is nothing at {source}")
8
+
9
+ TOO_LARGE = STORAGE.define(
10
+ "too_large",
11
+ "{source} is larger than max_size ({maximum} bytes); raise max_size, "
12
+ "or move the bytes with storage.copy instead of carrying them",
13
+ )
14
+
15
+ UNREADABLE_AS_A_VALUE = STORAGE.define(
16
+ "unreadable_as_a_value",
17
+ "{source} is {content_type}, which this step has no way to read as a value; "
18
+ "set content_type to say what it really is, or move the bytes with storage.copy",
19
+ )
20
+
21
+ NOT_UTF8 = STORAGE.define("not_utf8", "{source} is not the utf-8 its content type promises: {detail}")
22
+
23
+ NOT_JSON = STORAGE.define("not_json", "{source} is not the json its content type promises: {detail}")
24
+
25
+
26
+ # What a config refuses at validation. Pydantic owns the code a validator's refusal reaches
27
+ # the wire under, so these are rendered into the ``ValueError`` it wraps.
28
+
29
+ WRITE_TAKES_ONE_SOURCE = STORAGE.define("write_takes_one_source", "a write needs either text or value{named}")
@@ -0,0 +1,321 @@
1
+ """The generic storage blocks: bytes in, bytes out, bytes between URIs, and a wait.
2
+
3
+ ``storage.read`` is the only way a value comes in from storage and ``storage.write`` the only
4
+ way one goes out, so a step that has a value hands it to a write and a step that needs one
5
+ takes it from a read. ``storage.copy`` is neither: it moves bytes nobody has to look at.
6
+ """
7
+
8
+ import json
9
+ import mimetypes
10
+ from contextlib import aclosing
11
+ from datetime import datetime
12
+ from typing import ClassVar, cast
13
+
14
+ from pydantic import BaseModel, Field, JsonValue, model_validator
15
+
16
+ from dirigent_block_storage.messages import (
17
+ NOT_JSON,
18
+ NOT_UTF8,
19
+ NOTHING_THERE,
20
+ TOO_LARGE,
21
+ UNREADABLE_AS_A_VALUE,
22
+ WRITE_TAKES_ONE_SOURCE,
23
+ )
24
+ from dirigent_common import BlockModel, Size, StorageUri
25
+ from dirigent_plugin import (
26
+ BlockFailure,
27
+ ErrorClass,
28
+ NotYet,
29
+ Operator,
30
+ OperatorSpec,
31
+ RemoteHandle,
32
+ Sensor,
33
+ SensorSpec,
34
+ StatResult,
35
+ StepContext,
36
+ )
37
+
38
+ GLOB_CHARACTERS = ("*", "?", "[")
39
+
40
+ #: What a text value is written as when the step names no content type.
41
+ TEXT_CONTENT_TYPE = "text/plain"
42
+
43
+ #: What a JSON value is written as when the step names no content type.
44
+ JSON_CONTENT_TYPE = "application/json"
45
+
46
+ #: What an object is read as when nothing -- the step, the backend, the extension -- says.
47
+ OCTET_STREAM = "application/octet-stream"
48
+
49
+ #: Content types outside the JSON family whose objects are text a step can hold.
50
+ TEXT_TYPES = ("application/x-ndjson", "application/yaml", "application/xml")
51
+
52
+
53
+ class StorageCopyConfig(BlockModel):
54
+ """Which object to move, and where to put it."""
55
+
56
+ source: StorageUri = Field(min_length=1)
57
+ target: StorageUri = Field(min_length=1)
58
+
59
+
60
+ class StorageCopyOutput(BlockModel):
61
+ """What the copy moved, so a downstream step can address the result."""
62
+
63
+ source: str
64
+ target: str
65
+ bytes_copied: int
66
+
67
+
68
+ class StorageCopyOperator(Operator[StorageCopyConfig, StorageCopyOutput]):
69
+ """Streams one object onto another, across backends, without buffering it whole."""
70
+
71
+ spec = OperatorSpec(id="storage.copy", summary="Copy an object from one URI to another.", idempotent=True)
72
+ config_model: ClassVar[type[BaseModel]] = StorageCopyConfig
73
+ output_model: ClassVar[type[BaseModel]] = StorageCopyOutput
74
+
75
+ async def execute(self, config: StorageCopyConfig, ctx: StepContext) -> StorageCopyOutput | RemoteHandle:
76
+ """Copy source to target through the storage facade, refusing a missing source."""
77
+ found = await ctx.storage.stat(config.source)
78
+ if found is None:
79
+ raise BlockFailure(NOTHING_THERE, error_class=ErrorClass.REJECTED, source=config.source)
80
+ copied = 0
81
+ async with ctx.storage.open_write(config.target, content_type=found.content_type) as sink:
82
+ async for chunk in ctx.storage.open_read(config.source):
83
+ copied += await sink.write(chunk)
84
+ ctx.log.info("copied", source=config.source, target=config.target, bytes_copied=copied)
85
+ return StorageCopyOutput(source=config.source, target=config.target, bytes_copied=copied)
86
+
87
+
88
+ class StorageWriteConfig(BlockModel):
89
+ """What to write, and where to put it."""
90
+
91
+ target: StorageUri = Field(min_length=1)
92
+ """The URI the object is written to, replacing whatever is there."""
93
+
94
+ text: str | None = None
95
+ """A string written as UTF-8, for a report, a csv, or any document that is already text."""
96
+
97
+ value: JsonValue | None = None
98
+ """A value written as canonical JSON, for what an earlier step produced as structure."""
99
+
100
+ content_type: str | None = None
101
+ """What the object is, recorded where the backend can record it.
102
+
103
+ Unset, it is ``text/plain`` for ``text`` and ``application/json`` for ``value``; an
104
+ explicit one wins, which is how a markdown page or a csv says what it is. S3 keeps it on
105
+ the object and hands it back to ``storage.read``; a filesystem has nowhere to keep it, so
106
+ a reader there recovers the type from the extension."""
107
+
108
+ @model_validator(mode="after")
109
+ def _one_payload(self) -> "StorageWriteConfig":
110
+ """Reject a config that names neither a text nor a value, or both."""
111
+ named = [name for name in ("text", "value") if getattr(self, name) is not None]
112
+ if len(named) != 1:
113
+ raise ValueError(
114
+ WRITE_TAKES_ONE_SOURCE.render(
115
+ named=", and this step names both" if named else ", and this step names neither"
116
+ )
117
+ )
118
+ return self
119
+
120
+ def payload(self) -> bytes:
121
+ """The bytes this step writes, in the encoding its content type promises."""
122
+ if self.text is not None:
123
+ return self.text.encode()
124
+ # The engine's canonical JSON: sorted keys and no spaces, written as UTF-8.
125
+ return json.dumps(self.value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
126
+
127
+ def declared_content_type(self) -> str:
128
+ """What the object is: the step's own answer, or the default for what it carries."""
129
+ if self.content_type is not None:
130
+ return self.content_type
131
+ return TEXT_CONTENT_TYPE if self.text is not None else JSON_CONTENT_TYPE
132
+
133
+
134
+ class StorageWriteOutput(BlockModel):
135
+ """Where the object landed, so a downstream step can address it."""
136
+
137
+ uri: str
138
+ bytes_written: int
139
+ content_type: str
140
+
141
+
142
+ class StorageWriteOperator(Operator[StorageWriteConfig, StorageWriteOutput]):
143
+ """Writes one value or one string to a URI, which is the only way a value leaves a run."""
144
+
145
+ spec = OperatorSpec(id="storage.write", summary="Write a value or text to a storage URI.", idempotent=True)
146
+ config_model: ClassVar[type[BaseModel]] = StorageWriteConfig
147
+ output_model: ClassVar[type[BaseModel]] = StorageWriteOutput
148
+
149
+ async def execute(self, config: StorageWriteConfig, ctx: StepContext) -> StorageWriteOutput | RemoteHandle:
150
+ """Encode what the step carries and stream it to the target."""
151
+ payload = config.payload()
152
+ content_type = config.declared_content_type()
153
+ written = 0
154
+ async with ctx.storage.open_write(config.target, content_type=content_type) as sink:
155
+ written += await sink.write(payload)
156
+ ctx.log.info("wrote", uri=config.target, bytes_written=written, content_type=content_type)
157
+ return StorageWriteOutput(uri=config.target, bytes_written=written, content_type=content_type)
158
+
159
+
160
+ class StorageReadConfig(BlockModel):
161
+ """Which object to read, and what to read it as."""
162
+
163
+ source: StorageUri = Field(min_length=1)
164
+ """The URI the object is read from."""
165
+
166
+ content_type: str | None = None
167
+ """What to read the object as, overriding what the backend and the extension say.
168
+
169
+ A backend that records no content type and a file named without an extension leave the
170
+ object as bytes nobody can decode, and this is where a step says what it actually is."""
171
+
172
+ max_size: Size = 1 * 1024 * 1024
173
+ """How much of an object this step will hold, such as ``8mb``.
174
+
175
+ The value is carried in the step's output, so an object too large to hold is refused
176
+ rather than truncated: half a document is not a smaller one, it is a wrong one. Bytes
177
+ nobody reads move with ``storage.copy``, which is bounded by the storage rather than
178
+ by this."""
179
+
180
+
181
+ class StorageReadOutput(BlockModel):
182
+ """What the object turned out to be, in the one field its content type decides."""
183
+
184
+ content_type: str
185
+ text: str | None = None
186
+ value: JsonValue | None = None
187
+ bytes_read: int
188
+
189
+
190
+ class StorageReadOperator(Operator[StorageReadConfig, StorageReadOutput]):
191
+ """Reads one object into the run as a value, which is the only way a value comes in."""
192
+
193
+ spec = OperatorSpec(id="storage.read", summary="Read an object from a storage URI as a value.", idempotent=True)
194
+ config_model: ClassVar[type[BaseModel]] = StorageReadConfig
195
+ output_model: ClassVar[type[BaseModel]] = StorageReadOutput
196
+
197
+ async def execute(self, config: StorageReadConfig, ctx: StepContext) -> StorageReadOutput | RemoteHandle:
198
+ """Resolve what the object is, read it bounded, and decode it accordingly."""
199
+ found = await ctx.storage.stat(config.source)
200
+ if found is None:
201
+ raise BlockFailure(NOTHING_THERE, error_class=ErrorClass.REJECTED, source=config.source)
202
+ if found.size > config.max_size:
203
+ raise _too_large(config)
204
+ content_type = _content_type(config, found)
205
+ payload = await _read_bounded(config, ctx)
206
+ ctx.log.info("read", uri=config.source, bytes_read=len(payload), content_type=content_type)
207
+ return StorageReadOutput(
208
+ content_type=content_type,
209
+ text=None if _is_json(content_type) else _as_text(config, payload),
210
+ value=_as_value(config, payload) if _is_json(content_type) else None,
211
+ bytes_read=len(payload),
212
+ )
213
+
214
+
215
+ def _content_type(config: StorageReadConfig, found: StatResult) -> str:
216
+ """What the object is: the step's override, the backend's answer, the extension, or bytes."""
217
+ # Guessed from the last path segment: guess_type reads a URL's path, and a URI whose only
218
+ # segment sits where a host would (file://orders.json) has none.
219
+ guessed = mimetypes.guess_type(config.source.rsplit("/", 1)[-1])[0]
220
+ resolved = config.content_type or found.content_type or guessed or OCTET_STREAM
221
+ if _is_json(resolved) or resolved.startswith("text/") or resolved in TEXT_TYPES:
222
+ return resolved
223
+ raise BlockFailure(
224
+ UNREADABLE_AS_A_VALUE, error_class=ErrorClass.REJECTED, source=config.source, content_type=resolved
225
+ )
226
+
227
+
228
+ def _is_json(content_type: str) -> bool:
229
+ """Say whether a content type is the JSON family, which is parsed rather than decoded."""
230
+ bare = content_type.split(";")[0].strip()
231
+ return bare == "application/json" or bare.endswith("+json")
232
+
233
+
234
+ async def _read_bounded(config: StorageReadConfig, ctx: StepContext) -> bytes:
235
+ """Read the object a chunk at a time, stopping the moment it passes the cap.
236
+
237
+ Counted as it arrives rather than trusted from ``stat``, because the size a backend
238
+ reports is the backend's claim and this is the worker's memory.
239
+ """
240
+ chunks: list[bytes] = []
241
+ total = 0
242
+ async with aclosing(ctx.storage.open_read(config.source)) as stream:
243
+ async for chunk in stream:
244
+ total += len(chunk)
245
+ if total > config.max_size:
246
+ raise _too_large(config)
247
+ chunks.append(chunk)
248
+ return b"".join(chunks)
249
+
250
+
251
+ def _too_large(config: StorageReadConfig) -> BlockFailure:
252
+ """The refusal of an object bigger than the step said it would hold."""
253
+ return BlockFailure(TOO_LARGE, error_class=ErrorClass.REJECTED, source=config.source, maximum=config.max_size)
254
+
255
+
256
+ def _as_text(config: StorageReadConfig, payload: bytes) -> str:
257
+ """Decode an object its content type says is text."""
258
+ try:
259
+ return payload.decode()
260
+ except UnicodeDecodeError as error:
261
+ raise BlockFailure(
262
+ NOT_UTF8, error_class=ErrorClass.REJECTED, source=config.source, detail=str(error)
263
+ ) from error
264
+
265
+
266
+ def _as_value(config: StorageReadConfig, payload: bytes) -> JsonValue:
267
+ """Parse an object its content type says is JSON."""
268
+ try:
269
+ return cast("JsonValue", json.loads(payload))
270
+ except ValueError as error:
271
+ raise BlockFailure(
272
+ NOT_JSON, error_class=ErrorClass.REJECTED, source=config.source, detail=str(error)
273
+ ) from error
274
+
275
+
276
+ class StorageExistsConfig(BlockModel):
277
+ """Which object, or which pattern of objects, to wait for."""
278
+
279
+ uri: str = Field(min_length=1)
280
+ """A URI, which may contain a glob pattern in its final segments."""
281
+
282
+ min_size: Size = Field(default=0, ge=0)
283
+ """Ignore an object until it is at least this large, such as ``1mb``."""
284
+
285
+
286
+ class StorageExistsOutput(BlockModel):
287
+ """The observation that an object arrived, passed downstream like any output."""
288
+
289
+ uri: str
290
+ size: int
291
+ modified_at: datetime
292
+
293
+
294
+ class StorageExistsSensor(Sensor[StorageExistsConfig, StorageExistsOutput]):
295
+ """Waits for an object to appear at a URI; each poke is one stat or one listing."""
296
+
297
+ spec = SensorSpec(id="storage.exists", summary="Wait for an object to appear at a URI.")
298
+ config_model: ClassVar[type[BaseModel]] = StorageExistsConfig
299
+ output_model: ClassVar[type[BaseModel]] = StorageExistsOutput
300
+
301
+ async def poke(self, config: StorageExistsConfig, ctx: StepContext) -> StorageExistsOutput | NotYet:
302
+ """Observe once, read-only: stat a plain URI, list a pattern, take the first match."""
303
+ found = await _first_match(config, ctx)
304
+ if found is None:
305
+ ctx.log.debug("nothing at the uri yet", uri=config.uri, min_size=config.min_size)
306
+ return NotYet()
307
+ ctx.log.info("object found", uri=found.uri, bytes=found.size)
308
+ return StorageExistsOutput(uri=found.uri, size=found.size, modified_at=found.modified_at)
309
+
310
+
311
+ async def _first_match(config: StorageExistsConfig, ctx: StepContext) -> StatResult | None:
312
+ """Find the first object satisfying the config, whether it names one or a pattern."""
313
+ if any(character in config.uri for character in GLOB_CHARACTERS):
314
+ async for result in ctx.storage.list(config.uri):
315
+ if result.size >= config.min_size:
316
+ return result
317
+ return None
318
+ found = await ctx.storage.stat(config.uri)
319
+ if found is None or found.size < config.min_size:
320
+ return None
321
+ return found