dirigent-plugin 0.16.7__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.7 → dirigent_plugin-0.17.0}/PKG-INFO +2 -2
- {dirigent_plugin-0.16.7 → dirigent_plugin-0.17.0}/pyproject.toml +2 -2
- {dirigent_plugin-0.16.7 → dirigent_plugin-0.17.0}/pyproject.toml.orig +2 -2
- {dirigent_plugin-0.16.7 → dirigent_plugin-0.17.0}/src/dirigent_plugin/__init__.py +3 -0
- {dirigent_plugin-0.16.7 → dirigent_plugin-0.17.0}/src/dirigent_plugin/blocks.py +34 -19
- 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.7 → dirigent_plugin-0.17.0}/src/dirigent_plugin/transforms.py +48 -25
- {dirigent_plugin-0.16.7 → dirigent_plugin-0.17.0}/LICENSE +0 -0
- {dirigent_plugin-0.16.7 → dirigent_plugin-0.17.0}/README.md +0 -0
- {dirigent_plugin-0.16.7 → dirigent_plugin-0.17.0}/src/dirigent_plugin/markers.py +0 -0
- {dirigent_plugin-0.16.7 → 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",
|
|
@@ -52,6 +52,7 @@ from dirigent_plugin.markers import (
|
|
|
52
52
|
extension_point,
|
|
53
53
|
formatters,
|
|
54
54
|
)
|
|
55
|
+
from dirigent_plugin.runners import ProgramRunner, RunnerEngine
|
|
55
56
|
from dirigent_plugin.transforms import (
|
|
56
57
|
ConvertConfig,
|
|
57
58
|
Converter,
|
|
@@ -94,12 +95,14 @@ __all__ = [
|
|
|
94
95
|
"ProbeResult",
|
|
95
96
|
"ProbeStatus",
|
|
96
97
|
"ProgramConfig",
|
|
98
|
+
"ProgramRunner",
|
|
97
99
|
"Reference",
|
|
98
100
|
"RemoteHandle",
|
|
99
101
|
"RunId",
|
|
100
102
|
"RunRefused",
|
|
101
103
|
"RunSnapshot",
|
|
102
104
|
"RunState",
|
|
105
|
+
"RunnerEngine",
|
|
103
106
|
"Runs",
|
|
104
107
|
"SURFACE_ID_PATTERN",
|
|
105
108
|
"SchemaRef",
|
|
@@ -15,7 +15,11 @@ 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
|
|
|
@@ -47,12 +51,15 @@ class ErrorClass(StrEnum):
|
|
|
47
51
|
|
|
48
52
|
|
|
49
53
|
class BlockFailure(Exception):
|
|
50
|
-
"""A failure a block reports deliberately, carrying its
|
|
51
|
-
|
|
52
|
-
def __init__(self, message:
|
|
53
|
-
"""
|
|
54
|
-
|
|
55
|
-
|
|
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)
|
|
56
63
|
self.error_class = error_class
|
|
57
64
|
|
|
58
65
|
def __str__(self) -> str:
|
|
@@ -387,8 +394,12 @@ class Storage(Protocol):
|
|
|
387
394
|
"""Stream the object at a URI, closeable so a reader that stops early releases it."""
|
|
388
395
|
...
|
|
389
396
|
|
|
390
|
-
def open_write(self, uri: str) -> AbstractAsyncContextManager[ByteSink]:
|
|
391
|
-
"""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
|
+
"""
|
|
392
403
|
...
|
|
393
404
|
|
|
394
405
|
async def stat(self, uri: str) -> StatResult | None:
|
|
@@ -426,9 +437,9 @@ class RunState(StrEnum):
|
|
|
426
437
|
class RunRefused(BlockFailure):
|
|
427
438
|
"""The instance refused to start the run a block asked for."""
|
|
428
439
|
|
|
429
|
-
def __init__(self, message:
|
|
440
|
+
def __init__(self, message: Message, /, **params: Any) -> None:
|
|
430
441
|
"""Carry the reason, classified as the configuration error it always is."""
|
|
431
|
-
super().__init__(message, error_class=ErrorClass.REJECTED)
|
|
442
|
+
super().__init__(message, error_class=ErrorClass.REJECTED, **params)
|
|
432
443
|
|
|
433
444
|
|
|
434
445
|
class StartedRun(BaseModel):
|
|
@@ -612,10 +623,10 @@ class Operator[ConfigT: BaseModel, OutputT: BaseModel](ABC):
|
|
|
612
623
|
"""Best-effort, idempotent cancellation; False means the remote could not be told."""
|
|
613
624
|
return False
|
|
614
625
|
|
|
615
|
-
def check_config(self, config: BaseModel) -> list[
|
|
626
|
+
def check_config(self, config: BaseModel) -> list[Issue]:
|
|
616
627
|
"""List the extra refusals this block makes at apply, beyond what its schema says.
|
|
617
628
|
|
|
618
|
-
Each
|
|
629
|
+
Each issue is shown against the step's config location, so a document is refused
|
|
619
630
|
before it is stored rather than the first time it runs.
|
|
620
631
|
"""
|
|
621
632
|
return []
|
|
@@ -637,10 +648,10 @@ class Sensor[ConfigT: BaseModel, OutputT: BaseModel](ABC):
|
|
|
637
648
|
"""Observe the world once, read-only and briefly; NotYet is not a failure."""
|
|
638
649
|
...
|
|
639
650
|
|
|
640
|
-
def check_config(self, config: BaseModel) -> list[
|
|
651
|
+
def check_config(self, config: BaseModel) -> list[Issue]:
|
|
641
652
|
"""List the extra refusals this block makes at apply, beyond what its schema says.
|
|
642
653
|
|
|
643
|
-
Each
|
|
654
|
+
Each issue is shown against the step's config location, so a document is refused
|
|
644
655
|
before it is stored rather than the first time it runs.
|
|
645
656
|
"""
|
|
646
657
|
return []
|
|
@@ -676,8 +687,12 @@ class StorageBackend(ABC):
|
|
|
676
687
|
...
|
|
677
688
|
|
|
678
689
|
@abstractmethod
|
|
679
|
-
def open_write(self, uri: str) -> AbstractAsyncContextManager[ByteSink]:
|
|
680
|
-
"""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
|
+
"""
|
|
681
696
|
...
|
|
682
697
|
|
|
683
698
|
@abstractmethod
|
|
@@ -745,7 +760,7 @@ class Contribution(BaseModel):
|
|
|
745
760
|
def _check_api_version(cls, value: int) -> int:
|
|
746
761
|
"""Reject a contribution written against a different revision of this contract."""
|
|
747
762
|
if value != API_VERSION:
|
|
748
|
-
raise ValueError(
|
|
763
|
+
raise ValueError(UNSUPPORTED_API_VERSION.render(version=value, host_version=API_VERSION))
|
|
749
764
|
return value
|
|
750
765
|
|
|
751
766
|
@model_validator(mode="after")
|
|
@@ -768,7 +783,7 @@ def _require_unique(label: str, values: list[str]) -> None:
|
|
|
768
783
|
seen: set[str] = set()
|
|
769
784
|
for value in values:
|
|
770
785
|
if value in seen:
|
|
771
|
-
raise ValueError(
|
|
786
|
+
raise ValueError(DUPLICATE_ID.render(label=label, value=repr(value)))
|
|
772
787
|
seen.add(value)
|
|
773
788
|
|
|
774
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
|