dirigent-plugin 0.27.0__tar.gz → 0.28.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.27.0 → dirigent_plugin-0.28.0}/PKG-INFO +2 -2
- {dirigent_plugin-0.27.0 → dirigent_plugin-0.28.0}/pyproject.toml +2 -2
- {dirigent_plugin-0.27.0 → dirigent_plugin-0.28.0}/pyproject.toml.orig +2 -2
- {dirigent_plugin-0.27.0 → dirigent_plugin-0.28.0}/src/dirigent_plugin/__init__.py +16 -0
- {dirigent_plugin-0.27.0 → dirigent_plugin-0.28.0}/src/dirigent_plugin/blocks.py +151 -0
- {dirigent_plugin-0.27.0 → dirigent_plugin-0.28.0}/src/dirigent_plugin/messages.py +13 -0
- {dirigent_plugin-0.27.0 → dirigent_plugin-0.28.0}/src/dirigent_plugin/runners.py +6 -0
- {dirigent_plugin-0.27.0 → dirigent_plugin-0.28.0}/src/dirigent_plugin/sources.py +13 -2
- {dirigent_plugin-0.27.0 → dirigent_plugin-0.28.0}/src/dirigent_plugin/transforms.py +46 -1
- {dirigent_plugin-0.27.0 → dirigent_plugin-0.28.0}/LICENSE +0 -0
- {dirigent_plugin-0.27.0 → dirigent_plugin-0.28.0}/README.md +0 -0
- {dirigent_plugin-0.27.0 → dirigent_plugin-0.28.0}/src/dirigent_plugin/contribution.py +0 -0
- {dirigent_plugin-0.27.0 → dirigent_plugin-0.28.0}/src/dirigent_plugin/markers.py +0 -0
- {dirigent_plugin-0.27.0 → dirigent_plugin-0.28.0}/src/dirigent_plugin/py.typed +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dirigent-plugin
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.28.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.
|
|
9
|
+
Requires-Dist: dirigent-common==0.28.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.
|
|
3
|
+
version = "0.28.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.
|
|
14
|
+
"dirigent-common==0.28.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.
|
|
3
|
+
version = "0.28.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.
|
|
14
|
+
"dirigent-common==0.28.0",
|
|
15
15
|
"httpx2>=2.12.0",
|
|
16
16
|
"pluginkit>=0.5.0",
|
|
17
17
|
"pydantic>=2.13.5",
|
|
@@ -44,8 +44,13 @@ from dirigent_plugin.blocks import (
|
|
|
44
44
|
StorageBackend,
|
|
45
45
|
classify_default,
|
|
46
46
|
mark_refusal,
|
|
47
|
+
media_fields,
|
|
48
|
+
media_fields_of,
|
|
49
|
+
published_config_schema,
|
|
47
50
|
reference_fields,
|
|
51
|
+
shell_fields_of,
|
|
48
52
|
shell_string_fields,
|
|
53
|
+
sql_values_of,
|
|
49
54
|
)
|
|
50
55
|
from dirigent_plugin.contribution import Contribution, merge_contributions
|
|
51
56
|
from dirigent_plugin.markers import (
|
|
@@ -70,6 +75,9 @@ from dirigent_plugin.transforms import (
|
|
|
70
75
|
Transformer,
|
|
71
76
|
TransformError,
|
|
72
77
|
TransformOutput,
|
|
78
|
+
csv_header,
|
|
79
|
+
hold_csv_columns,
|
|
80
|
+
hold_csv_empty_cells,
|
|
73
81
|
)
|
|
74
82
|
|
|
75
83
|
__all__ = [
|
|
@@ -128,6 +136,9 @@ __all__ = [
|
|
|
128
136
|
"StorageBackend",
|
|
129
137
|
"TransformConfig",
|
|
130
138
|
"TransformError",
|
|
139
|
+
"csv_header",
|
|
140
|
+
"hold_csv_columns",
|
|
141
|
+
"hold_csv_empty_cells",
|
|
131
142
|
"TransformOutput",
|
|
132
143
|
"Transformer",
|
|
133
144
|
"classify_default",
|
|
@@ -140,4 +151,9 @@ __all__ = [
|
|
|
140
151
|
"merge_contributions",
|
|
141
152
|
"reference_fields",
|
|
142
153
|
"shell_string_fields",
|
|
154
|
+
"media_fields",
|
|
155
|
+
"media_fields_of",
|
|
156
|
+
"shell_fields_of",
|
|
157
|
+
"published_config_schema",
|
|
158
|
+
"sql_values_of",
|
|
143
159
|
]
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""The dirigent block contract: the only module a third-party plugin package needs to import."""
|
|
2
2
|
|
|
3
|
+
import functools
|
|
3
4
|
import re
|
|
4
5
|
from abc import ABC, abstractmethod
|
|
5
6
|
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Mapping
|
|
@@ -18,8 +19,10 @@ from pydantic_core import CoreSchema
|
|
|
18
19
|
|
|
19
20
|
from dirigent_common import (
|
|
20
21
|
SHELL_MEDIA_TYPE,
|
|
22
|
+
SQL_MEDIA_TYPE,
|
|
21
23
|
BlockModel,
|
|
22
24
|
HealthReport,
|
|
25
|
+
HumaneJsonSchema,
|
|
23
26
|
Issue,
|
|
24
27
|
JsonMap,
|
|
25
28
|
Message,
|
|
@@ -237,6 +240,9 @@ def reference_fields(schema: JsonMap, kind: str) -> set[str]:
|
|
|
237
240
|
return named
|
|
238
241
|
|
|
239
242
|
|
|
243
|
+
#: The schema keywords that say a value takes one or all of several shapes.
|
|
244
|
+
_UNION_KEYWORDS: Final = ("anyOf", "oneOf", "allOf")
|
|
245
|
+
|
|
240
246
|
#: What the engine names the variables it substitutes a shell string's references out into.
|
|
241
247
|
SHELL_VARIABLE_PREFIX: Final = "DIRIGENT_V"
|
|
242
248
|
|
|
@@ -275,6 +281,148 @@ def shell_string_fields(model: type[BaseModel]) -> frozenset[str]:
|
|
|
275
281
|
)
|
|
276
282
|
|
|
277
283
|
|
|
284
|
+
def shell_fields_of(schema: JsonMap) -> frozenset[str]:
|
|
285
|
+
"""Name the fields a config schema publishes as a shell string, or an optional one."""
|
|
286
|
+
properties = schema.get("properties")
|
|
287
|
+
if not isinstance(properties, dict):
|
|
288
|
+
return frozenset()
|
|
289
|
+
return frozenset(
|
|
290
|
+
str(name)
|
|
291
|
+
for name, declared in cast("dict[str, Any]", properties).items()
|
|
292
|
+
if isinstance(declared, dict) and _publishes_shell(cast("dict[str, Any]", declared))
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _publishes_shell(shape: dict[str, Any]) -> bool:
|
|
297
|
+
"""Whether a field's schema or a branch of its union carries the shell media type."""
|
|
298
|
+
options = cast("list[dict[str, Any]]", shape.get("anyOf") or [])
|
|
299
|
+
return shape.get("contentMediaType") == SHELL_MEDIA_TYPE or any(_publishes_shell(option) for option in options)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def sql_values_of(config: JsonMap, schema: JsonMap) -> dict[str, JsonValue]:
|
|
303
|
+
"""Find what a config holds where its schema publishes SQL, by dotted location.
|
|
304
|
+
|
|
305
|
+
A reference in one of these is refused, so a value only ever reaches SQL as a bound
|
|
306
|
+
parameter. The walk follows the config through the schema's objects, lists, maps, unions and
|
|
307
|
+
named definitions, so a pack's nested field is found wherever it sits. A string standing in
|
|
308
|
+
for a shape with SQL anywhere under it counts too, because a whole reference there would
|
|
309
|
+
resolve to the object or the list holding the statement.
|
|
310
|
+
"""
|
|
311
|
+
held = schema.get("$defs")
|
|
312
|
+
pool = cast("dict[str, Any]", held) if isinstance(held, dict) else {}
|
|
313
|
+
found: dict[str, JsonValue] = {}
|
|
314
|
+
_collect_sql(config, schema, pool, "", found)
|
|
315
|
+
return found
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _collect_sql(value: Any, shape: dict[str, Any], pool: dict[str, Any], where: str, found: dict[str, Any]) -> None:
|
|
319
|
+
"""Record every value under ``value`` at a place ``shape`` publishes as SQL."""
|
|
320
|
+
published = shape.get("contentMediaType") == SQL_MEDIA_TYPE
|
|
321
|
+
if where and (published or (isinstance(value, str) and _holds_sql(shape, pool))):
|
|
322
|
+
found[where] = value
|
|
323
|
+
target = _pointed_at(shape, pool)
|
|
324
|
+
if target is not None:
|
|
325
|
+
_collect_sql(value, target, pool, where, found)
|
|
326
|
+
for keyword in _UNION_KEYWORDS:
|
|
327
|
+
for option in cast("list[Any]", shape.get(keyword) or []):
|
|
328
|
+
if isinstance(option, dict):
|
|
329
|
+
_collect_sql(value, cast("dict[str, Any]", option), pool, where, found)
|
|
330
|
+
if isinstance(value, dict):
|
|
331
|
+
for key, element in cast("dict[str, Any]", value).items():
|
|
332
|
+
child = _property_shape(shape, str(key))
|
|
333
|
+
if child is not None:
|
|
334
|
+
_collect_sql(element, child, pool, _extended(where, str(key)), found)
|
|
335
|
+
elif isinstance(value, list):
|
|
336
|
+
elements: list[Any] = value # pyright: ignore[reportUnknownVariableType]
|
|
337
|
+
for index, element in enumerate(elements):
|
|
338
|
+
child = _item_shape(shape, index)
|
|
339
|
+
if child is not None:
|
|
340
|
+
_collect_sql(element, child, pool, _extended(where, str(index)), found)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _extended(where: str, part: str) -> str:
|
|
344
|
+
"""Add one step to a dotted location."""
|
|
345
|
+
return f"{where}.{part}" if where else part
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _property_shape(shape: dict[str, Any], key: str) -> dict[str, Any] | None:
|
|
349
|
+
"""The shape one key of an object takes, from its own property or the rest of the map."""
|
|
350
|
+
properties = shape.get("properties")
|
|
351
|
+
declared = cast("dict[str, Any]", properties).get(key) if isinstance(properties, dict) else None
|
|
352
|
+
if isinstance(declared, dict):
|
|
353
|
+
return cast("dict[str, Any]", declared)
|
|
354
|
+
patterns = shape.get("patternProperties")
|
|
355
|
+
if isinstance(patterns, dict):
|
|
356
|
+
for pattern, matched in cast("dict[str, Any]", patterns).items():
|
|
357
|
+
if isinstance(matched, dict) and re.search(pattern, key):
|
|
358
|
+
return cast("dict[str, Any]", matched)
|
|
359
|
+
rest = shape.get("additionalProperties")
|
|
360
|
+
return cast("dict[str, Any]", rest) if isinstance(rest, dict) else None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _item_shape(shape: dict[str, Any], index: int) -> dict[str, Any] | None:
|
|
364
|
+
"""The shape one position of a list takes, from its own place in a tuple or the items."""
|
|
365
|
+
positions = shape.get("prefixItems")
|
|
366
|
+
if isinstance(positions, list):
|
|
367
|
+
places: list[Any] = positions # pyright: ignore[reportUnknownVariableType]
|
|
368
|
+
if index < len(places):
|
|
369
|
+
return cast("dict[str, Any]", places[index]) if isinstance(places[index], dict) else None
|
|
370
|
+
items = shape.get("items")
|
|
371
|
+
return cast("dict[str, Any]", items) if isinstance(items, dict) else None
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _holds_sql(shape: dict[str, Any], pool: dict[str, Any], seen: frozenset[int] = frozenset()) -> bool:
|
|
375
|
+
"""Whether a shape publishes SQL itself or anywhere under it, across definitions that recurse."""
|
|
376
|
+
if id(shape) in seen:
|
|
377
|
+
return False
|
|
378
|
+
if shape.get("contentMediaType") == SQL_MEDIA_TYPE:
|
|
379
|
+
return True
|
|
380
|
+
walked = seen | {id(shape)}
|
|
381
|
+
below: list[Any] = []
|
|
382
|
+
target = _pointed_at(shape, pool)
|
|
383
|
+
if target is not None:
|
|
384
|
+
below.append(target)
|
|
385
|
+
for keyword in (*_UNION_KEYWORDS, "prefixItems"):
|
|
386
|
+
below.extend(cast("list[Any]", shape.get(keyword) or []))
|
|
387
|
+
for keyword in ("properties", "patternProperties"):
|
|
388
|
+
held = shape.get(keyword)
|
|
389
|
+
if isinstance(held, dict):
|
|
390
|
+
below.extend(cast("dict[str, Any]", held).values())
|
|
391
|
+
for keyword in ("items", "additionalProperties"):
|
|
392
|
+
below.append(shape.get(keyword))
|
|
393
|
+
return any(isinstance(one, dict) and _holds_sql(cast("dict[str, Any]", one), pool, walked) for one in below)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@functools.cache
|
|
397
|
+
def published_config_schema(model: type[BaseModel]) -> JsonMap:
|
|
398
|
+
"""The schema the catalog publishes for a config model, which SQL fields are read from."""
|
|
399
|
+
return model.model_json_schema(mode="serialization", schema_generator=HumaneJsonSchema)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def media_fields_of(schema: JsonMap, media_type: str) -> frozenset[str]:
|
|
403
|
+
"""Name the fields a config schema publishes as one media type: a string, or an optional one."""
|
|
404
|
+
properties = schema.get("properties")
|
|
405
|
+
if not isinstance(properties, dict):
|
|
406
|
+
return frozenset()
|
|
407
|
+
return frozenset(
|
|
408
|
+
str(name)
|
|
409
|
+
for name, declared in cast("dict[str, Any]", properties).items()
|
|
410
|
+
if isinstance(declared, dict) and _publishes(cast("dict[str, Any]", declared), media_type)
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _publishes(shape: dict[str, Any], media_type: str) -> bool:
|
|
415
|
+
"""Whether a field's schema or a branch of its union carries a media type."""
|
|
416
|
+
options = cast("list[dict[str, Any]]", shape.get("anyOf") or [])
|
|
417
|
+
return shape.get("contentMediaType") == media_type or any(_publishes(option, media_type) for option in options)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
@functools.cache
|
|
421
|
+
def media_fields(model: type[BaseModel], media_type: str) -> frozenset[str]:
|
|
422
|
+
"""List the config fields a model publishes as one media type, by the schema the catalog publishes."""
|
|
423
|
+
return media_fields_of(model.model_json_schema(mode="serialization", schema_generator=HumaneJsonSchema), media_type)
|
|
424
|
+
|
|
425
|
+
|
|
278
426
|
class RemoteHandle(BaseModel):
|
|
279
427
|
"""Serializable claim on a job running somewhere other than this block call.
|
|
280
428
|
|
|
@@ -526,6 +674,9 @@ class RunState(StrEnum):
|
|
|
526
674
|
|
|
527
675
|
QUEUED = "queued"
|
|
528
676
|
RUNNING = "running"
|
|
677
|
+
PAUSED = "paused"
|
|
678
|
+
"""Held by an operator; it goes on once resumed."""
|
|
679
|
+
|
|
529
680
|
SUCCEEDED = "succeeded"
|
|
530
681
|
COMPLETED_WITH_ERRORS = "completed_with_errors"
|
|
531
682
|
FAILED = "failed"
|
|
@@ -15,6 +15,19 @@ FILTER_ANSWER = PLUGIN.define(
|
|
|
15
15
|
"{block} answered {answer} for element {index}, and a filter's answer is true or false",
|
|
16
16
|
)
|
|
17
17
|
|
|
18
|
+
CSV_TOO_MANY_COLUMNS = PLUGIN.define(
|
|
19
|
+
"csv_too_many_columns",
|
|
20
|
+
"the records name more than {limit} distinct keys, and each is one csv column; "
|
|
21
|
+
"keep the keys you need with a transform first",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
CSV_TOO_SPARSE = PLUGIN.define(
|
|
25
|
+
"csv_too_sparse",
|
|
26
|
+
"{rows} rows by {columns} columns leave {empty} cells empty, more than the {limit} a csv here may hold; "
|
|
27
|
+
"records that each carry keys of their own have no table worth writing, "
|
|
28
|
+
"so give them shared keys with a transform first",
|
|
29
|
+
)
|
|
30
|
+
|
|
18
31
|
UNSUPPORTED_PAIR = PLUGIN.define(
|
|
19
32
|
"unsupported_pair",
|
|
20
33
|
"{block} does not convert {source_format} to {target_format} ({supported})",
|
|
@@ -24,6 +24,7 @@ pipe closing is how the parent says it is done.
|
|
|
24
24
|
import asyncio
|
|
25
25
|
import atexit
|
|
26
26
|
import json
|
|
27
|
+
import os
|
|
27
28
|
import subprocess
|
|
28
29
|
from collections.abc import Callable, Sequence
|
|
29
30
|
from contextvars import ContextVar
|
|
@@ -40,6 +41,10 @@ from dirigent_plugin.transforms import Engine, ProgramConfig, TransformError
|
|
|
40
41
|
#: TransformError, because nothing about the program says it will happen again.
|
|
41
42
|
RUNNER_STOPPED = "the process running the program stopped before it answered"
|
|
42
43
|
|
|
44
|
+
#: All of the worker's environment a runner starts with. A program runs behind no allowlist,
|
|
45
|
+
#: and the worker's environment holds the instance's secrets.
|
|
46
|
+
RUNNER_ENVIRONMENT = ("PATH", "LANG", "LC_ALL", "TZ")
|
|
47
|
+
|
|
43
48
|
|
|
44
49
|
class ProgramRunner:
|
|
45
50
|
"""One runner process, and the two pipes a program and its outputs cross."""
|
|
@@ -51,6 +56,7 @@ class ProgramRunner:
|
|
|
51
56
|
self.command,
|
|
52
57
|
stdin=subprocess.PIPE,
|
|
53
58
|
stdout=subprocess.PIPE,
|
|
59
|
+
env={name: os.environ[name] for name in RUNNER_ENVIRONMENT if name in os.environ},
|
|
54
60
|
)
|
|
55
61
|
# Popen types both pipes as optional; both were asked for.
|
|
56
62
|
self._to = cast("IO[bytes]", self.process.stdin)
|
|
@@ -5,6 +5,8 @@ already settled and its output is the input. A kind is hosted one of three ways:
|
|
|
5
5
|
receives it, a worker polls it, or a worker holds it open.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
import hashlib
|
|
9
|
+
import hmac
|
|
8
10
|
import re
|
|
9
11
|
from abc import ABC, abstractmethod
|
|
10
12
|
from contextlib import AbstractAsyncContextManager
|
|
@@ -76,8 +78,17 @@ class SourceAuth(BaseModel):
|
|
|
76
78
|
signature of the body, made with the source's signing secret; ``public`` admits anyone."""
|
|
77
79
|
|
|
78
80
|
header: str = Field(default="X-Dirigent-Signature", min_length=1)
|
|
79
|
-
"""For ``hmac``, the header the signature arrives in: the hex HMAC-SHA256 of the
|
|
80
|
-
with or without a leading ``sha256=``."""
|
|
81
|
+
"""For ``hmac``, the header the signature arrives in: the hex HMAC-SHA256 of the timestamp
|
|
82
|
+
in ``X-Dirigent-Timestamp``, a dot, and the raw body, with or without a leading ``sha256=``."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
#: The header a signed call carries the moment it was signed in, as whole Unix seconds.
|
|
86
|
+
SIGNATURE_TIMESTAMP_HEADER: Final = "X-Dirigent-Timestamp"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def sign_body(secret: bytes, timestamp: str, body: bytes) -> str:
|
|
90
|
+
"""Compute the hex signature a signed source verifies: HMAC-SHA256 over ``<timestamp>.<body>``."""
|
|
91
|
+
return hmac.new(secret, timestamp.encode("ascii") + b"." + body, hashlib.sha256).hexdigest()
|
|
81
92
|
|
|
82
93
|
|
|
83
94
|
class SourceRate(BaseModel):
|
|
@@ -25,7 +25,7 @@ misses both. Every frame hands a step's engine work to ``Engine.offload`` and aw
|
|
|
25
25
|
|
|
26
26
|
import asyncio
|
|
27
27
|
from abc import ABC, abstractmethod
|
|
28
|
-
from collections.abc import Callable
|
|
28
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
29
29
|
from typing import Any, ClassVar, Final, cast
|
|
30
30
|
|
|
31
31
|
from pydantic import BaseModel, Field, JsonValue
|
|
@@ -40,6 +40,8 @@ from dirigent_plugin.blocks import (
|
|
|
40
40
|
StepContext,
|
|
41
41
|
)
|
|
42
42
|
from dirigent_plugin.messages import (
|
|
43
|
+
CSV_TOO_MANY_COLUMNS,
|
|
44
|
+
CSV_TOO_SPARSE,
|
|
43
45
|
ELEMENT_FAILED,
|
|
44
46
|
FILTER_ANSWER,
|
|
45
47
|
NOT_AN_ARRAY,
|
|
@@ -59,6 +61,14 @@ MAP_PROMISE: Final = "replaces every element of a list"
|
|
|
59
61
|
FILTER_PROMISE: Final = "keeps some of the elements of a list"
|
|
60
62
|
|
|
61
63
|
|
|
64
|
+
#: The most columns a csv a converter writes may have.
|
|
65
|
+
CSV_COLUMN_LIMIT: Final = 10_000
|
|
66
|
+
|
|
67
|
+
#: The most empty cells a csv a converter writes may hold, each of them a separator with
|
|
68
|
+
#: nothing in the source behind it.
|
|
69
|
+
CSV_EMPTY_CELL_LIMIT: Final = 10_000_000
|
|
70
|
+
|
|
71
|
+
|
|
62
72
|
class TransformError(Exception):
|
|
63
73
|
"""An engine refused what it was given: a program it cannot compile, or a value it cannot reshape.
|
|
64
74
|
|
|
@@ -385,6 +395,41 @@ class Filterer(Engine, Operator[ProgramConfig, TransformOutput], ABC):
|
|
|
385
395
|
return []
|
|
386
396
|
|
|
387
397
|
|
|
398
|
+
def hold_csv_columns(columns: int) -> None:
|
|
399
|
+
"""Refuse a csv with more columns than a converter writes."""
|
|
400
|
+
if columns > CSV_COLUMN_LIMIT:
|
|
401
|
+
raise TransformError(CSV_TOO_MANY_COLUMNS.render(limit=f"{CSV_COLUMN_LIMIT:,}"))
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def hold_csv_empty_cells(*, rows: int, columns: int, empty: int) -> None:
|
|
405
|
+
"""Refuse a csv holding more empty cells than a converter writes."""
|
|
406
|
+
if empty > CSV_EMPTY_CELL_LIMIT:
|
|
407
|
+
raise TransformError(
|
|
408
|
+
CSV_TOO_SPARSE.render(
|
|
409
|
+
rows=f"{rows:,}",
|
|
410
|
+
columns=f"{columns:,}",
|
|
411
|
+
empty=f"{empty:,}",
|
|
412
|
+
limit=f"{CSV_EMPTY_CELL_LIMIT:,}",
|
|
413
|
+
)
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def csv_header(records: Sequence[Mapping[str, Any]]) -> list[str]:
|
|
418
|
+
"""The header of a csv these records write: every key any has, in first-seen order.
|
|
419
|
+
|
|
420
|
+
Every row spells a cell for every column, so records that each carry a key of their own
|
|
421
|
+
write rows times columns cells from a source the size of one of those. That is refused
|
|
422
|
+
before a row is written, while a dense table of any size is not.
|
|
423
|
+
"""
|
|
424
|
+
columns: dict[str, None] = {}
|
|
425
|
+
for record in records:
|
|
426
|
+
columns.update(dict.fromkeys(record))
|
|
427
|
+
hold_csv_columns(len(columns))
|
|
428
|
+
filled = sum(value is not None for record in records for value in record.values())
|
|
429
|
+
hold_csv_empty_cells(rows=len(records), columns=len(columns), empty=len(records) * len(columns) - filled)
|
|
430
|
+
return list(columns)
|
|
431
|
+
|
|
432
|
+
|
|
388
433
|
class Converter(Engine, Operator[ConvertConfig, ConvertOutput], ABC):
|
|
389
434
|
"""The ``convert`` verb: re-encode bytes from one format into another, content preserved.
|
|
390
435
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|