dirigent-plugin 0.27.1__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.
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirigent-plugin
3
- Version: 0.27.1
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.27.1
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.27.1"
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.27.1",
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.27.1"
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.27.1",
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 raw body,
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