dirigent-plugin 0.16.6__tar.gz → 0.17.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.16.6 → dirigent_plugin-0.17.0}/PKG-INFO +2 -2
- {dirigent_plugin-0.16.6 → dirigent_plugin-0.17.0}/pyproject.toml +2 -2
- {dirigent_plugin-0.16.6 → dirigent_plugin-0.17.0}/pyproject.toml.orig +2 -2
- {dirigent_plugin-0.16.6 → dirigent_plugin-0.17.0}/src/dirigent_plugin/__init__.py +7 -0
- {dirigent_plugin-0.16.6 → dirigent_plugin-0.17.0}/src/dirigent_plugin/blocks.py +72 -23
- dirigent_plugin-0.17.0/src/dirigent_plugin/messages.py +39 -0
- dirigent_plugin-0.17.0/src/dirigent_plugin/runners.py +255 -0
- {dirigent_plugin-0.16.6 → dirigent_plugin-0.17.0}/src/dirigent_plugin/transforms.py +48 -25
- {dirigent_plugin-0.16.6 → dirigent_plugin-0.17.0}/LICENSE +0 -0
- {dirigent_plugin-0.16.6 → dirigent_plugin-0.17.0}/README.md +0 -0
- {dirigent_plugin-0.16.6 → dirigent_plugin-0.17.0}/src/dirigent_plugin/markers.py +0 -0
- {dirigent_plugin-0.16.6 → dirigent_plugin-0.17.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.17.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.17.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.17.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.17.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.17.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.17.0",
|
|
15
15
|
"httpx2>=2.12.0",
|
|
16
16
|
"pluginkit>=0.5.0",
|
|
17
17
|
"pydantic>=2.13.5",
|
|
@@ -22,12 +22,14 @@ from dirigent_plugin.blocks import (
|
|
|
22
22
|
OperatorSpec,
|
|
23
23
|
ProbeResult,
|
|
24
24
|
ProbeStatus,
|
|
25
|
+
Reference,
|
|
25
26
|
RemoteHandle,
|
|
26
27
|
RunId,
|
|
27
28
|
RunRefused,
|
|
28
29
|
Runs,
|
|
29
30
|
RunSnapshot,
|
|
30
31
|
RunState,
|
|
32
|
+
SchemaRef,
|
|
31
33
|
Sensor,
|
|
32
34
|
SensorSpec,
|
|
33
35
|
ShellString,
|
|
@@ -50,6 +52,7 @@ from dirigent_plugin.markers import (
|
|
|
50
52
|
extension_point,
|
|
51
53
|
formatters,
|
|
52
54
|
)
|
|
55
|
+
from dirigent_plugin.runners import ProgramRunner, RunnerEngine
|
|
53
56
|
from dirigent_plugin.transforms import (
|
|
54
57
|
ConvertConfig,
|
|
55
58
|
Converter,
|
|
@@ -92,13 +95,17 @@ __all__ = [
|
|
|
92
95
|
"ProbeResult",
|
|
93
96
|
"ProbeStatus",
|
|
94
97
|
"ProgramConfig",
|
|
98
|
+
"ProgramRunner",
|
|
99
|
+
"Reference",
|
|
95
100
|
"RemoteHandle",
|
|
96
101
|
"RunId",
|
|
97
102
|
"RunRefused",
|
|
98
103
|
"RunSnapshot",
|
|
99
104
|
"RunState",
|
|
105
|
+
"RunnerEngine",
|
|
100
106
|
"Runs",
|
|
101
107
|
"SURFACE_ID_PATTERN",
|
|
108
|
+
"SchemaRef",
|
|
102
109
|
"Sensor",
|
|
103
110
|
"SensorSpec",
|
|
104
111
|
"SHELL_VARIABLES_FIELD",
|
|
@@ -6,7 +6,7 @@ from contextlib import AbstractAsyncContextManager
|
|
|
6
6
|
from datetime import datetime, timedelta
|
|
7
7
|
from enum import StrEnum
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import Annotated, Any, ClassVar, Final, Protocol, cast
|
|
9
|
+
from typing import Annotated, Any, ClassVar, Final, Literal, Protocol, cast
|
|
10
10
|
from uuid import UUID
|
|
11
11
|
|
|
12
12
|
import httpx2
|
|
@@ -15,13 +15,14 @@ from pydantic import BaseModel, ConfigDict, Field, GetJsonSchemaHandler, JsonVal
|
|
|
15
15
|
from pydantic.json_schema import JsonSchemaValue, SkipJsonSchema
|
|
16
16
|
from pydantic_core import CoreSchema
|
|
17
17
|
|
|
18
|
-
from dirigent_common import API_VERSION, SHELL_MEDIA_TYPE, BlockModel, HealthReport, JsonMap
|
|
18
|
+
from dirigent_common import API_VERSION, SHELL_MEDIA_TYPE, BlockModel, HealthReport, Issue, JsonMap, Message
|
|
19
|
+
from dirigent_plugin.messages import (
|
|
20
|
+
DUPLICATE_ID,
|
|
21
|
+
UNSUPPORTED_API_VERSION,
|
|
22
|
+
)
|
|
19
23
|
|
|
20
24
|
type RunId = UUID
|
|
21
25
|
|
|
22
|
-
#: A connection is referenced by name, never by id, so documents stay portable.
|
|
23
|
-
type ConnectionRef = str
|
|
24
|
-
|
|
25
26
|
#: A JSON Schema format checker: a predicate that returns True when a value satisfies the
|
|
26
27
|
#: format, False when it does not, and may instead raise to signal the value is invalid --
|
|
27
28
|
#: exactly what ``jsonschema.FormatChecker.checks`` registers.
|
|
@@ -50,12 +51,15 @@ class ErrorClass(StrEnum):
|
|
|
50
51
|
|
|
51
52
|
|
|
52
53
|
class BlockFailure(Exception):
|
|
53
|
-
"""A failure a block reports deliberately, carrying its
|
|
54
|
-
|
|
55
|
-
def __init__(self, message:
|
|
56
|
-
"""
|
|
57
|
-
|
|
58
|
-
|
|
54
|
+
"""A failure a block reports deliberately, carrying its code and its classification."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, message: Message, /, *, error_class: ErrorClass = ErrorClass.UNKNOWN, **params: Any) -> None:
|
|
57
|
+
"""Render the catalogued message and record the class the engine retries (or not) on."""
|
|
58
|
+
rendered = message.render(**params)
|
|
59
|
+
super().__init__(rendered)
|
|
60
|
+
self.code = message.code
|
|
61
|
+
self.message = rendered
|
|
62
|
+
self.params: JsonMap = dict(params)
|
|
59
63
|
self.error_class = error_class
|
|
60
64
|
|
|
61
65
|
def __str__(self) -> str:
|
|
@@ -132,6 +136,43 @@ class ShellString:
|
|
|
132
136
|
return published
|
|
133
137
|
|
|
134
138
|
|
|
139
|
+
class Reference:
|
|
140
|
+
"""Marks a config field whose value is the code of another thing this instance holds.
|
|
141
|
+
|
|
142
|
+
It publishes ``x-dirigent-ref`` carrying what the code names, and a form generated from the
|
|
143
|
+
schema draws that thing under the field. It is a bare class rather than a model: annotation
|
|
144
|
+
metadata pydantic recognises as a model would be read as the field's schema, and a marker
|
|
145
|
+
must stay invisible to validation.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
__slots__ = ("kind",)
|
|
149
|
+
|
|
150
|
+
def __init__(self, kind: Literal["connection", "schema"]) -> None:
|
|
151
|
+
"""Record what a value of the marked field names."""
|
|
152
|
+
self.kind = kind
|
|
153
|
+
|
|
154
|
+
def __repr__(self) -> str:
|
|
155
|
+
"""Render the marker the way it is written."""
|
|
156
|
+
return f"Reference({self.kind!r})"
|
|
157
|
+
|
|
158
|
+
def __get_pydantic_json_schema__(
|
|
159
|
+
self,
|
|
160
|
+
schema: CoreSchema,
|
|
161
|
+
handler: GetJsonSchemaHandler,
|
|
162
|
+
) -> JsonSchemaValue:
|
|
163
|
+
"""Publish what the marked field's code names, leaving what it validates untouched."""
|
|
164
|
+
published = handler(schema)
|
|
165
|
+
published["x-dirigent-ref"] = self.kind
|
|
166
|
+
return published
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
#: A connection is referenced by code, never by id, so documents stay portable.
|
|
170
|
+
type ConnectionRef = Annotated[str, Reference("connection")]
|
|
171
|
+
|
|
172
|
+
#: A schema is referenced by code: one the instance holds, or one the document carries.
|
|
173
|
+
type SchemaRef = Annotated[str, Reference("schema")]
|
|
174
|
+
|
|
175
|
+
|
|
135
176
|
#: What the engine names the variables it substitutes a shell string's references out into.
|
|
136
177
|
SHELL_VARIABLE_PREFIX: Final = "DIRIGENT_V"
|
|
137
178
|
|
|
@@ -353,8 +394,12 @@ class Storage(Protocol):
|
|
|
353
394
|
"""Stream the object at a URI, closeable so a reader that stops early releases it."""
|
|
354
395
|
...
|
|
355
396
|
|
|
356
|
-
def open_write(self, uri: str) -> AbstractAsyncContextManager[ByteSink]:
|
|
357
|
-
"""Open a streamed writer for a URI.
|
|
397
|
+
def open_write(self, uri: str, *, content_type: str | None = None) -> AbstractAsyncContextManager[ByteSink]:
|
|
398
|
+
"""Open a streamed writer for a URI.
|
|
399
|
+
|
|
400
|
+
The backend records the content type on the object where its store can hold one, and
|
|
401
|
+
ignores it where it cannot.
|
|
402
|
+
"""
|
|
358
403
|
...
|
|
359
404
|
|
|
360
405
|
async def stat(self, uri: str) -> StatResult | None:
|
|
@@ -392,9 +437,9 @@ class RunState(StrEnum):
|
|
|
392
437
|
class RunRefused(BlockFailure):
|
|
393
438
|
"""The instance refused to start the run a block asked for."""
|
|
394
439
|
|
|
395
|
-
def __init__(self, message:
|
|
440
|
+
def __init__(self, message: Message, /, **params: Any) -> None:
|
|
396
441
|
"""Carry the reason, classified as the configuration error it always is."""
|
|
397
|
-
super().__init__(message, error_class=ErrorClass.REJECTED)
|
|
442
|
+
super().__init__(message, error_class=ErrorClass.REJECTED, **params)
|
|
398
443
|
|
|
399
444
|
|
|
400
445
|
class StartedRun(BaseModel):
|
|
@@ -578,10 +623,10 @@ class Operator[ConfigT: BaseModel, OutputT: BaseModel](ABC):
|
|
|
578
623
|
"""Best-effort, idempotent cancellation; False means the remote could not be told."""
|
|
579
624
|
return False
|
|
580
625
|
|
|
581
|
-
def check_config(self, config: BaseModel) -> list[
|
|
626
|
+
def check_config(self, config: BaseModel) -> list[Issue]:
|
|
582
627
|
"""List the extra refusals this block makes at apply, beyond what its schema says.
|
|
583
628
|
|
|
584
|
-
Each
|
|
629
|
+
Each issue is shown against the step's config location, so a document is refused
|
|
585
630
|
before it is stored rather than the first time it runs.
|
|
586
631
|
"""
|
|
587
632
|
return []
|
|
@@ -603,10 +648,10 @@ class Sensor[ConfigT: BaseModel, OutputT: BaseModel](ABC):
|
|
|
603
648
|
"""Observe the world once, read-only and briefly; NotYet is not a failure."""
|
|
604
649
|
...
|
|
605
650
|
|
|
606
|
-
def check_config(self, config: BaseModel) -> list[
|
|
651
|
+
def check_config(self, config: BaseModel) -> list[Issue]:
|
|
607
652
|
"""List the extra refusals this block makes at apply, beyond what its schema says.
|
|
608
653
|
|
|
609
|
-
Each
|
|
654
|
+
Each issue is shown against the step's config location, so a document is refused
|
|
610
655
|
before it is stored rather than the first time it runs.
|
|
611
656
|
"""
|
|
612
657
|
return []
|
|
@@ -642,8 +687,12 @@ class StorageBackend(ABC):
|
|
|
642
687
|
...
|
|
643
688
|
|
|
644
689
|
@abstractmethod
|
|
645
|
-
def open_write(self, uri: str) -> AbstractAsyncContextManager[ByteSink]:
|
|
646
|
-
"""Open a streamed writer for a URI.
|
|
690
|
+
def open_write(self, uri: str, *, content_type: str | None = None) -> AbstractAsyncContextManager[ByteSink]:
|
|
691
|
+
"""Open a streamed writer for a URI.
|
|
692
|
+
|
|
693
|
+
The backend records the content type on the object where its store can hold one, and
|
|
694
|
+
ignores it where it cannot.
|
|
695
|
+
"""
|
|
647
696
|
...
|
|
648
697
|
|
|
649
698
|
@abstractmethod
|
|
@@ -711,7 +760,7 @@ class Contribution(BaseModel):
|
|
|
711
760
|
def _check_api_version(cls, value: int) -> int:
|
|
712
761
|
"""Reject a contribution written against a different revision of this contract."""
|
|
713
762
|
if value != API_VERSION:
|
|
714
|
-
raise ValueError(
|
|
763
|
+
raise ValueError(UNSUPPORTED_API_VERSION.render(version=value, host_version=API_VERSION))
|
|
715
764
|
return value
|
|
716
765
|
|
|
717
766
|
@model_validator(mode="after")
|
|
@@ -734,7 +783,7 @@ def _require_unique(label: str, values: list[str]) -> None:
|
|
|
734
783
|
seen: set[str] = set()
|
|
735
784
|
for value in values:
|
|
736
785
|
if value in seen:
|
|
737
|
-
raise ValueError(
|
|
786
|
+
raise ValueError(DUPLICATE_ID.render(label=label, value=repr(value)))
|
|
738
787
|
seen.add(value)
|
|
739
788
|
|
|
740
789
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Every refusal the block contract itself makes, catalogued under the ``plugin`` prefix."""
|
|
2
|
+
|
|
3
|
+
from dirigent_common import Catalogue
|
|
4
|
+
|
|
5
|
+
PLUGIN = Catalogue("plugin")
|
|
6
|
+
|
|
7
|
+
TRANSFORM_FAILED = PLUGIN.define("transform_failed", "{detail}")
|
|
8
|
+
|
|
9
|
+
PROGRAM_REFUSED = PLUGIN.define("program_refused", "{detail}")
|
|
10
|
+
|
|
11
|
+
ELEMENT_FAILED = PLUGIN.define("element_failed", "element {index}: {detail}")
|
|
12
|
+
|
|
13
|
+
FILTER_ANSWER = PLUGIN.define(
|
|
14
|
+
"filter_answer",
|
|
15
|
+
"{block} answered {answer} for element {index}, and a filter's answer is true or false",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
UNSUPPORTED_PAIR = PLUGIN.define(
|
|
19
|
+
"unsupported_pair",
|
|
20
|
+
"{block} does not convert {source_format} to {target_format} ({supported})",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
NOT_AN_ARRAY = PLUGIN.define(
|
|
24
|
+
"not_an_array",
|
|
25
|
+
"{block} {promise}, so its input has to be a JSON array, and this one is {described}",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
NOTHING_TO_CONVERT = PLUGIN.define("nothing_to_convert", "there is nothing at {uri} to convert")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# What a config refuses at validation. Pydantic owns the code a validator's refusal reaches
|
|
32
|
+
# the wire under, so these are rendered into the ``ValueError`` it wraps.
|
|
33
|
+
|
|
34
|
+
UNSUPPORTED_API_VERSION = PLUGIN.define(
|
|
35
|
+
"unsupported_api_version",
|
|
36
|
+
"unsupported api_version {version}; this host speaks {host_version}",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
DUPLICATE_ID = PLUGIN.define("duplicate_id", "duplicate {label} {value} in contribution")
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""The runner protocol: a program engine whose programs are compiled and run in a process of its own.
|
|
2
|
+
|
|
3
|
+
An engine that computes inside a C extension holding the interpreter's lock holds the
|
|
4
|
+
worker's event loop from a thread as firmly as from the loop itself, and a thread cannot be
|
|
5
|
+
cancelled: the step's timeout never fires, the lease heartbeat never runs, and the sweeper
|
|
6
|
+
hands the attempt to another worker while this one is still computing. Such an engine names
|
|
7
|
+
the ``command`` that starts a runner, and its programs run there, where killing the process
|
|
8
|
+
is what ends them.
|
|
9
|
+
|
|
10
|
+
A runner reads one JSON object per line and answers one JSON object per line, one reply per
|
|
11
|
+
request and in the order the requests arrived:
|
|
12
|
+
|
|
13
|
+
- ``{"kind": "compile", "id": ..., "program": ...}``, answered with ``{"ok": true}``, or
|
|
14
|
+
with ``{"error": "..."}`` carrying the runner's own message about a program it refuses.
|
|
15
|
+
A program that compiled is held under the id it was named by.
|
|
16
|
+
- ``{"kind": "run", "id": ..., "value": ...}``, answered with ``{"outputs": [...]}``, the
|
|
17
|
+
stream the program produced, or with ``{"error": "..."}``.
|
|
18
|
+
- ``{"kind": "forget", "id": ...}``, answered with ``{"ok": true}``: the program is released.
|
|
19
|
+
|
|
20
|
+
A step's program therefore crosses the pipe once, however many elements it is run over. The
|
|
21
|
+
pipe closing is how the parent says it is done.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import atexit
|
|
26
|
+
import json
|
|
27
|
+
import subprocess
|
|
28
|
+
from collections.abc import Callable, Sequence
|
|
29
|
+
from contextvars import ContextVar
|
|
30
|
+
from typing import IO, Any, ClassVar, cast
|
|
31
|
+
from uuid import uuid4
|
|
32
|
+
|
|
33
|
+
from pydantic import BaseModel, JsonValue
|
|
34
|
+
|
|
35
|
+
from dirigent_common import Issue
|
|
36
|
+
from dirigent_plugin.messages import PROGRAM_REFUSED
|
|
37
|
+
from dirigent_plugin.transforms import Engine, ProgramConfig, TransformError
|
|
38
|
+
|
|
39
|
+
#: What a step is told when the process running its program stopped on its own. It is not a
|
|
40
|
+
#: TransformError, because nothing about the program says it will happen again.
|
|
41
|
+
RUNNER_STOPPED = "the process running the program stopped before it answered"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ProgramRunner:
|
|
45
|
+
"""One runner process, and the two pipes a program and its outputs cross."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, command: Sequence[str]) -> None:
|
|
48
|
+
"""Start the process the command names and hold the pipes it is spoken to over."""
|
|
49
|
+
self.command = tuple(command)
|
|
50
|
+
self.process = subprocess.Popen(
|
|
51
|
+
self.command,
|
|
52
|
+
stdin=subprocess.PIPE,
|
|
53
|
+
stdout=subprocess.PIPE,
|
|
54
|
+
)
|
|
55
|
+
# Popen types both pipes as optional; both were asked for.
|
|
56
|
+
self._to = cast("IO[bytes]", self.process.stdin)
|
|
57
|
+
self._from = cast("IO[bytes]", self.process.stdout)
|
|
58
|
+
self._compiled: set[str] = set()
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def alive(self) -> bool:
|
|
62
|
+
"""Whether this process is still there to run a program."""
|
|
63
|
+
return self.process.poll() is None
|
|
64
|
+
|
|
65
|
+
def compile(self, source: str) -> str:
|
|
66
|
+
"""Compile one program in this process and hand back the id it is run by."""
|
|
67
|
+
identifier = uuid4().hex
|
|
68
|
+
self._exchange({"kind": "compile", "id": identifier, "program": source})
|
|
69
|
+
# Held only once it has compiled, so a refused program is never one to forget.
|
|
70
|
+
self._compiled.add(identifier)
|
|
71
|
+
return identifier
|
|
72
|
+
|
|
73
|
+
def run(self, identifier: str, value: JsonValue) -> list[JsonValue]:
|
|
74
|
+
"""Run the program compiled under an id over one value and read the outputs it produced."""
|
|
75
|
+
answered = self._exchange({"kind": "run", "id": identifier, "value": value})
|
|
76
|
+
return cast("list[JsonValue]", answered["outputs"])
|
|
77
|
+
|
|
78
|
+
def forget(self, identifier: str) -> None:
|
|
79
|
+
"""Release one compiled program."""
|
|
80
|
+
self._compiled.discard(identifier)
|
|
81
|
+
self._exchange({"kind": "forget", "id": identifier})
|
|
82
|
+
|
|
83
|
+
def release(self) -> None:
|
|
84
|
+
"""Forget every program compiled here, so a runner outliving a step keeps none of them.
|
|
85
|
+
|
|
86
|
+
A runner that has stopped took its programs with it, and there is nothing to say to
|
|
87
|
+
a closed pipe.
|
|
88
|
+
"""
|
|
89
|
+
if not self.alive:
|
|
90
|
+
self._compiled.clear()
|
|
91
|
+
return
|
|
92
|
+
for identifier in sorted(self._compiled):
|
|
93
|
+
self.forget(identifier)
|
|
94
|
+
|
|
95
|
+
def kill(self) -> None:
|
|
96
|
+
"""End whatever is running, which is the only way to stop a program, and close it out.
|
|
97
|
+
|
|
98
|
+
Waiting is what makes the process dead rather than dying, so the pool sees at once
|
|
99
|
+
that this one is not to be handed out again; a killed process is reaped at once.
|
|
100
|
+
Closing the pipes waits for the read a step's thread is still blocked in, which the
|
|
101
|
+
kill has just ended, and leaves no descriptor behind for a collection to complain of.
|
|
102
|
+
"""
|
|
103
|
+
self.process.kill()
|
|
104
|
+
self.process.wait()
|
|
105
|
+
self._to.close()
|
|
106
|
+
self._from.close()
|
|
107
|
+
|
|
108
|
+
def _exchange(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
109
|
+
"""Send one request, read the one reply to it, and raise what the runner refused."""
|
|
110
|
+
try:
|
|
111
|
+
self._to.write(json.dumps(request).encode() + b"\n")
|
|
112
|
+
self._to.flush()
|
|
113
|
+
reply = self._from.readline()
|
|
114
|
+
except OSError as error:
|
|
115
|
+
raise RuntimeError(RUNNER_STOPPED) from error
|
|
116
|
+
if not reply:
|
|
117
|
+
# A killed process, which is how a cancelled step ends its program, and how a
|
|
118
|
+
# program that ran the process out of memory ends itself.
|
|
119
|
+
raise RuntimeError(RUNNER_STOPPED)
|
|
120
|
+
answered = cast("dict[str, Any]", json.loads(reply))
|
|
121
|
+
refusal = answered.get("error")
|
|
122
|
+
if refusal is not None:
|
|
123
|
+
raise TransformError(cast("str", refusal))
|
|
124
|
+
return answered
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class _Runners:
|
|
128
|
+
"""The runner processes this worker keeps: one per command, and one per step running at a time.
|
|
129
|
+
|
|
130
|
+
A step holds one for as long as its program runs and gives it back after, so two steps
|
|
131
|
+
never meet on one pipe. A process is started when there is none to hand out and then
|
|
132
|
+
lives on, because starting one costs more than every program a step runs through it; it
|
|
133
|
+
ends when the worker does, its pipe closing under it. A process that was killed is not
|
|
134
|
+
handed out again.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(self) -> None:
|
|
138
|
+
"""Start with no processes; the first step that needs one starts it."""
|
|
139
|
+
self._idle: dict[tuple[str, ...], list[ProgramRunner]] = {}
|
|
140
|
+
|
|
141
|
+
async def take(self, command: Sequence[str]) -> ProgramRunner:
|
|
142
|
+
"""Hand out an idle process for a command, starting one off the event loop when there is none."""
|
|
143
|
+
idle = self._idle.setdefault(tuple(command), [])
|
|
144
|
+
while idle:
|
|
145
|
+
runner = idle.pop()
|
|
146
|
+
if runner.alive:
|
|
147
|
+
return runner
|
|
148
|
+
starting = asyncio.ensure_future(asyncio.to_thread(ProgramRunner, command))
|
|
149
|
+
try:
|
|
150
|
+
return await asyncio.shield(starting)
|
|
151
|
+
except asyncio.CancelledError:
|
|
152
|
+
# A step can be cancelled while its process is still starting. The start is
|
|
153
|
+
# shielded so the process is finished and kept, rather than left running for a
|
|
154
|
+
# step that is already gone.
|
|
155
|
+
starting.add_done_callback(self._started)
|
|
156
|
+
raise
|
|
157
|
+
|
|
158
|
+
def give_back(self, runner: ProgramRunner) -> None:
|
|
159
|
+
"""Take a process back for the next step that runs its command, unless it was killed."""
|
|
160
|
+
if runner.alive:
|
|
161
|
+
self._idle.setdefault(runner.command, []).append(runner)
|
|
162
|
+
|
|
163
|
+
def shutdown(self) -> None:
|
|
164
|
+
"""Kill every idle process, so none is left running when this one exits."""
|
|
165
|
+
for idle in self._idle.values():
|
|
166
|
+
while idle:
|
|
167
|
+
idle.pop().kill()
|
|
168
|
+
|
|
169
|
+
def _started(self, starting: "asyncio.Future[ProgramRunner]") -> None:
|
|
170
|
+
"""Keep a process whose step gave up on it while it was starting."""
|
|
171
|
+
if not starting.cancelled() and starting.exception() is None:
|
|
172
|
+
self.give_back(starting.result())
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
#: The runner processes this worker keeps. One pool for every engine, keyed by the command
|
|
176
|
+
#: that starts a runner: a step holds a runner, not a verb.
|
|
177
|
+
_RUNNERS = _Runners()
|
|
178
|
+
|
|
179
|
+
atexit.register(_RUNNERS.shutdown)
|
|
180
|
+
|
|
181
|
+
#: The runner the step running on this task talks to. A program is compiled and run from
|
|
182
|
+
#: inside the thread offload() hands the step's work to, which is where the engine reads it.
|
|
183
|
+
_RUNNING: ContextVar[ProgramRunner] = ContextVar("dirigent_program_runner")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _running() -> ProgramRunner:
|
|
187
|
+
"""The runner this step holds, which only a step running through offload() has."""
|
|
188
|
+
try:
|
|
189
|
+
return _RUNNING.get()
|
|
190
|
+
except LookupError as error:
|
|
191
|
+
missing = "a runner engine is called through offload(), which takes the process its program runs in"
|
|
192
|
+
raise RuntimeError(missing) from error
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class RunnerEngine(Engine):
|
|
196
|
+
"""A program engine whose programs are compiled and run in a runner process.
|
|
197
|
+
|
|
198
|
+
The engine supplies the command that starts one and the source its runner compiles; the
|
|
199
|
+
step holds one runner for its whole run, its program is compiled there once, every
|
|
200
|
+
element is run through the id that compile answered with, and the id is forgotten when
|
|
201
|
+
the runner is given back.
|
|
202
|
+
|
|
203
|
+
The three program verbs are what this is for: ``convert`` has no program, so a codec
|
|
204
|
+
engine has no runner either.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
command: ClassVar[Sequence[str]]
|
|
208
|
+
"""What starts one runner process for this engine."""
|
|
209
|
+
|
|
210
|
+
def source(self, program: str) -> str:
|
|
211
|
+
"""The source the runner compiles: the author's program, wrapped as the engine needs it."""
|
|
212
|
+
return program
|
|
213
|
+
|
|
214
|
+
def check_program(self, program: str) -> None:
|
|
215
|
+
"""Refuse a bad program at apply by raising TransformError, where an engine can read its own language.
|
|
216
|
+
|
|
217
|
+
There is no runner outside a step, so an engine that cannot parse its language on
|
|
218
|
+
the worker leaves a bad program to be refused when the step compiles it.
|
|
219
|
+
"""
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
def compile(self, program: str) -> object:
|
|
223
|
+
"""Compile the program in the runner this step holds and hand back the id it is run by."""
|
|
224
|
+
return _running().compile(self.source(program))
|
|
225
|
+
|
|
226
|
+
def outputs(self, compiled: object, value: JsonValue) -> list[JsonValue]:
|
|
227
|
+
"""Run a compiled program over one value in this step's runner and read its outputs."""
|
|
228
|
+
return _running().run(cast("str", compiled), value)
|
|
229
|
+
|
|
230
|
+
async def offload[T](self, work: Callable[[], T]) -> T:
|
|
231
|
+
"""Hold a runner for the step, and kill it if the step is cancelled or times out."""
|
|
232
|
+
runner = await _RUNNERS.take(self.command)
|
|
233
|
+
token = _RUNNING.set(runner)
|
|
234
|
+
try:
|
|
235
|
+
return await asyncio.to_thread(work)
|
|
236
|
+
except asyncio.CancelledError:
|
|
237
|
+
# Cancelling the await does not stop the thread: it is inside a blocking read,
|
|
238
|
+
# and killing the process is both what ends the program and what lets that read
|
|
239
|
+
# return, so nothing is left running behind a step that has already failed.
|
|
240
|
+
runner.kill()
|
|
241
|
+
raise
|
|
242
|
+
finally:
|
|
243
|
+
_RUNNING.reset(token)
|
|
244
|
+
runner.release()
|
|
245
|
+
_RUNNERS.give_back(runner)
|
|
246
|
+
|
|
247
|
+
def check_config(self, config: BaseModel) -> list[Issue]:
|
|
248
|
+
"""Check the program at apply, where the engine's own reading of it is all there is."""
|
|
249
|
+
if not isinstance(config, ProgramConfig):
|
|
250
|
+
return []
|
|
251
|
+
try:
|
|
252
|
+
self.check_program(config.program)
|
|
253
|
+
except TransformError as error:
|
|
254
|
+
return [Issue.of(PROGRAM_REFUSED, detail=str(error))]
|
|
255
|
+
return []
|
|
@@ -30,7 +30,7 @@ from typing import Any, ClassVar, Final, cast
|
|
|
30
30
|
|
|
31
31
|
from pydantic import BaseModel, Field, JsonValue
|
|
32
32
|
|
|
33
|
-
from dirigent_common import BlockModel, StorageUri
|
|
33
|
+
from dirigent_common import BlockModel, Issue, JsonMap, StorageUri
|
|
34
34
|
from dirigent_plugin.blocks import (
|
|
35
35
|
BlockFailure,
|
|
36
36
|
ErrorClass,
|
|
@@ -39,6 +39,15 @@ from dirigent_plugin.blocks import (
|
|
|
39
39
|
RemoteHandle,
|
|
40
40
|
StepContext,
|
|
41
41
|
)
|
|
42
|
+
from dirigent_plugin.messages import (
|
|
43
|
+
ELEMENT_FAILED,
|
|
44
|
+
FILTER_ANSWER,
|
|
45
|
+
NOT_AN_ARRAY,
|
|
46
|
+
NOTHING_TO_CONVERT,
|
|
47
|
+
PROGRAM_REFUSED,
|
|
48
|
+
TRANSFORM_FAILED,
|
|
49
|
+
UNSUPPORTED_PAIR,
|
|
50
|
+
)
|
|
42
51
|
|
|
43
52
|
#: How much is handed to a storage sink at a time.
|
|
44
53
|
CHUNK_BYTES: Final = 64 * 1024
|
|
@@ -181,17 +190,17 @@ class Transformer(Engine, Operator[ProgramConfig, TransformOutput], ABC):
|
|
|
181
190
|
try:
|
|
182
191
|
result = await self.offload(lambda: self.apply(self.compile(config.program), config.input))
|
|
183
192
|
except TransformError as error:
|
|
184
|
-
raise BlockFailure(
|
|
193
|
+
raise BlockFailure(TRANSFORM_FAILED, error_class=ErrorClass.REJECTED, detail=str(error)) from error
|
|
185
194
|
return TransformOutput(value=result)
|
|
186
195
|
|
|
187
|
-
def check_config(self, config: BaseModel) -> list[
|
|
196
|
+
def check_config(self, config: BaseModel) -> list[Issue]:
|
|
188
197
|
"""Compile the program at apply, so a bad one is refused before the document is stored."""
|
|
189
198
|
if not isinstance(config, ProgramConfig):
|
|
190
199
|
return []
|
|
191
200
|
try:
|
|
192
201
|
self.compile(config.program)
|
|
193
202
|
except TransformError as error:
|
|
194
|
-
return [str(error)]
|
|
203
|
+
return [Issue.of(PROGRAM_REFUSED, detail=str(error))]
|
|
195
204
|
return []
|
|
196
205
|
|
|
197
206
|
|
|
@@ -253,7 +262,7 @@ class Mapper(Engine, Operator[ProgramConfig, TransformOutput], ABC):
|
|
|
253
262
|
try:
|
|
254
263
|
compiled = self.compile(program)
|
|
255
264
|
except TransformError as error:
|
|
256
|
-
raise BlockFailure(
|
|
265
|
+
raise BlockFailure(TRANSFORM_FAILED, error_class=ErrorClass.REJECTED, detail=str(error)) from error
|
|
257
266
|
return self._map_each(compiled, elements)
|
|
258
267
|
|
|
259
268
|
def _map_each(self, compiled: object, elements: list[JsonValue]) -> list[JsonValue]:
|
|
@@ -263,17 +272,19 @@ class Mapper(Engine, Operator[ProgramConfig, TransformOutput], ABC):
|
|
|
263
272
|
try:
|
|
264
273
|
mapped.append(self.apply(compiled, element))
|
|
265
274
|
except TransformError as error:
|
|
266
|
-
raise BlockFailure(
|
|
275
|
+
raise BlockFailure(
|
|
276
|
+
ELEMENT_FAILED, error_class=ErrorClass.REJECTED, index=index, detail=str(error)
|
|
277
|
+
) from error
|
|
267
278
|
return mapped
|
|
268
279
|
|
|
269
|
-
def check_config(self, config: BaseModel) -> list[
|
|
280
|
+
def check_config(self, config: BaseModel) -> list[Issue]:
|
|
270
281
|
"""Compile the program at apply, so a bad one is refused before the document is stored."""
|
|
271
282
|
if not isinstance(config, ProgramConfig):
|
|
272
283
|
return []
|
|
273
284
|
try:
|
|
274
285
|
self.compile(config.program)
|
|
275
286
|
except TransformError as error:
|
|
276
|
-
return [str(error)]
|
|
287
|
+
return [Issue.of(PROGRAM_REFUSED, detail=str(error))]
|
|
277
288
|
return []
|
|
278
289
|
|
|
279
290
|
|
|
@@ -335,7 +346,7 @@ class Filterer(Engine, Operator[ProgramConfig, TransformOutput], ABC):
|
|
|
335
346
|
try:
|
|
336
347
|
compiled = self.compile(program)
|
|
337
348
|
except TransformError as error:
|
|
338
|
-
raise BlockFailure(
|
|
349
|
+
raise BlockFailure(TRANSFORM_FAILED, error_class=ErrorClass.REJECTED, detail=str(error)) from error
|
|
339
350
|
return [self._verdict(compiled, element, index) for index, element in enumerate(elements)]
|
|
340
351
|
|
|
341
352
|
def _verdict(self, compiled: object, element: JsonValue, index: int) -> bool:
|
|
@@ -345,22 +356,27 @@ class Filterer(Engine, Operator[ProgramConfig, TransformOutput], ABC):
|
|
|
345
356
|
# engine is held to it.
|
|
346
357
|
answer = cast("object", self.keep(compiled, element))
|
|
347
358
|
except TransformError as error:
|
|
348
|
-
raise BlockFailure(
|
|
359
|
+
raise BlockFailure(
|
|
360
|
+
ELEMENT_FAILED, error_class=ErrorClass.REJECTED, index=index, detail=str(error)
|
|
361
|
+
) from error
|
|
349
362
|
if not isinstance(answer, bool):
|
|
350
363
|
raise BlockFailure(
|
|
351
|
-
|
|
364
|
+
FILTER_ANSWER,
|
|
352
365
|
error_class=ErrorClass.REJECTED,
|
|
366
|
+
block=self.spec.id,
|
|
367
|
+
answer=repr(answer),
|
|
368
|
+
index=index,
|
|
353
369
|
)
|
|
354
370
|
return answer
|
|
355
371
|
|
|
356
|
-
def check_config(self, config: BaseModel) -> list[
|
|
372
|
+
def check_config(self, config: BaseModel) -> list[Issue]:
|
|
357
373
|
"""Compile the program at apply, so a bad one is refused before the document is stored."""
|
|
358
374
|
if not isinstance(config, ProgramConfig):
|
|
359
375
|
return []
|
|
360
376
|
try:
|
|
361
377
|
self.compile(config.program)
|
|
362
378
|
except TransformError as error:
|
|
363
|
-
return [str(error)]
|
|
379
|
+
return [Issue.of(PROGRAM_REFUSED, detail=str(error))]
|
|
364
380
|
return []
|
|
365
381
|
|
|
366
382
|
|
|
@@ -402,33 +418,37 @@ class Converter(Engine, Operator[ConvertConfig, ConvertOutput], ABC):
|
|
|
402
418
|
|
|
403
419
|
async def execute(self, config: ConvertConfig, ctx: StepContext) -> ConvertOutput | RemoteHandle:
|
|
404
420
|
"""Refuse an unsupported pair, then re-encode the source object onto the target."""
|
|
405
|
-
unsupported = self.
|
|
421
|
+
unsupported = self._unsupported(config.from_format, config.to_format)
|
|
406
422
|
if unsupported is not None:
|
|
407
|
-
raise BlockFailure(
|
|
423
|
+
raise BlockFailure(UNSUPPORTED_PAIR, error_class=ErrorClass.REJECTED, **unsupported)
|
|
408
424
|
source = await _read(ctx, config.source)
|
|
409
425
|
try:
|
|
410
426
|
produced = await self.offload(
|
|
411
427
|
lambda: self.convert(source, source_format=config.from_format, target_format=config.to_format)
|
|
412
428
|
)
|
|
413
429
|
except TransformError as error:
|
|
414
|
-
raise BlockFailure(
|
|
430
|
+
raise BlockFailure(TRANSFORM_FAILED, error_class=ErrorClass.REJECTED, detail=str(error)) from error
|
|
415
431
|
written = await _write(ctx, config.target, produced)
|
|
416
432
|
return ConvertOutput(source=config.source, target=config.target, bytes_written=written)
|
|
417
433
|
|
|
418
|
-
def check_config(self, config: BaseModel) -> list[
|
|
434
|
+
def check_config(self, config: BaseModel) -> list[Issue]:
|
|
419
435
|
"""Refuse a format pair this engine has no codec for, at apply."""
|
|
420
436
|
if not isinstance(config, ConvertConfig):
|
|
421
437
|
return []
|
|
422
|
-
unsupported = self.
|
|
423
|
-
return [] if unsupported is None else [unsupported]
|
|
438
|
+
unsupported = self._unsupported(config.from_format, config.to_format)
|
|
439
|
+
return [] if unsupported is None else [Issue.of(UNSUPPORTED_PAIR, **unsupported)]
|
|
424
440
|
|
|
425
|
-
def
|
|
426
|
-
"""
|
|
441
|
+
def _unsupported(self, source_format: str, target_format: str) -> JsonMap | None:
|
|
442
|
+
"""Name the params of a pair this engine does not support, or nothing when it does."""
|
|
427
443
|
if (source_format, target_format) in self.pairs:
|
|
428
444
|
return None
|
|
429
445
|
listed = ", ".join(f"{one} to {other}" for one, other in sorted(self.pairs))
|
|
430
|
-
|
|
431
|
-
|
|
446
|
+
return {
|
|
447
|
+
"block": self.spec.id,
|
|
448
|
+
"source_format": source_format,
|
|
449
|
+
"target_format": target_format,
|
|
450
|
+
"supported": listed or "this engine converts nothing",
|
|
451
|
+
}
|
|
432
452
|
|
|
433
453
|
|
|
434
454
|
def _elements(value: JsonValue, spec_id: str, promise: str) -> list[JsonValue]:
|
|
@@ -436,8 +456,11 @@ def _elements(value: JsonValue, spec_id: str, promise: str) -> list[JsonValue]:
|
|
|
436
456
|
if isinstance(value, list):
|
|
437
457
|
return value
|
|
438
458
|
raise BlockFailure(
|
|
439
|
-
|
|
459
|
+
NOT_AN_ARRAY,
|
|
440
460
|
error_class=ErrorClass.REJECTED,
|
|
461
|
+
block=spec_id,
|
|
462
|
+
promise=promise,
|
|
463
|
+
described=_named(value),
|
|
441
464
|
)
|
|
442
465
|
|
|
443
466
|
|
|
@@ -458,7 +481,7 @@ def _named(value: JsonValue) -> str:
|
|
|
458
481
|
async def _read(ctx: StepContext, uri: str) -> bytes:
|
|
459
482
|
"""Read the object whole, refusing a URI that holds nothing."""
|
|
460
483
|
if await ctx.storage.stat(uri) is None:
|
|
461
|
-
raise BlockFailure(
|
|
484
|
+
raise BlockFailure(NOTHING_TO_CONVERT, error_class=ErrorClass.REJECTED, uri=uri)
|
|
462
485
|
chunks: list[bytes] = []
|
|
463
486
|
async for chunk in ctx.storage.open_read(uri):
|
|
464
487
|
chunks.append(chunk)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|