lightassay 0.3.0__py3-none-any.whl
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.
- lightassay/__init__.py +134 -0
- lightassay/adapter_pack/__init__.py +295 -0
- lightassay/adapter_pack/command.py +84 -0
- lightassay/adapter_pack/http_driver.py +75 -0
- lightassay/adapter_pack/python_callable.py +63 -0
- lightassay/analyzer.py +287 -0
- lightassay/backends.py +144 -0
- lightassay/bootstrap.py +469 -0
- lightassay/builtin_adapters/__init__.py +27 -0
- lightassay/builtin_adapters/_agent_cli_common.py +281 -0
- lightassay/builtin_adapters/claude_cli.py +29 -0
- lightassay/builtin_adapters/codex_cli.py +28 -0
- lightassay/builtin_adapters/stub.py +361 -0
- lightassay/cli.py +1077 -0
- lightassay/comparer.py +197 -0
- lightassay/diagnostics.py +104 -0
- lightassay/errors.py +94 -0
- lightassay/expert.py +440 -0
- lightassay/orchestrator.py +1219 -0
- lightassay/preparation_config.py +109 -0
- lightassay/preparer.py +1218 -0
- lightassay/run_artifact_io.py +407 -0
- lightassay/run_models.py +70 -0
- lightassay/runner.py +298 -0
- lightassay/runtime_state.py +240 -0
- lightassay/semantic_config.py +102 -0
- lightassay/surface.py +2635 -0
- lightassay/types.py +319 -0
- lightassay/workbook_models.py +151 -0
- lightassay/workbook_parser.py +824 -0
- lightassay/workbook_renderer.py +405 -0
- lightassay/workflow_config.py +239 -0
- lightassay/workflow_config_builder.py +141 -0
- lightassay-0.3.0.dist-info/METADATA +163 -0
- lightassay-0.3.0.dist-info/RECORD +39 -0
- lightassay-0.3.0.dist-info/WHEEL +5 -0
- lightassay-0.3.0.dist-info/entry_points.txt +2 -0
- lightassay-0.3.0.dist-info/licenses/LICENSE +21 -0
- lightassay-0.3.0.dist-info/top_level.txt +1 -0
lightassay/__init__.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""lightassay: file-based orchestrator for structured evaluation of applied LLM workflows.
|
|
2
|
+
|
|
3
|
+
One rule runs through the whole design: humans declare intent, LLMs do the
|
|
4
|
+
semantic reasoning, code orchestrates execution and measures raw facts — and
|
|
5
|
+
never judges output quality. The workbook (markdown), run artifact (JSON),
|
|
6
|
+
and analysis/compare artifacts (markdown) are the source of truth; the
|
|
7
|
+
library is an orchestrator around them.
|
|
8
|
+
|
|
9
|
+
The ordinary public entrypoint is the L1 library surface. Start here::
|
|
10
|
+
|
|
11
|
+
from lightassay import (
|
|
12
|
+
open_session,
|
|
13
|
+
init_workbook,
|
|
14
|
+
quick_try,
|
|
15
|
+
quick_try_workbook,
|
|
16
|
+
refine_workbook,
|
|
17
|
+
explore_workbook,
|
|
18
|
+
compare_runs,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Create a workbook (or use an existing one).
|
|
22
|
+
wb_path = init_workbook("my-eval", output_dir=".")
|
|
23
|
+
|
|
24
|
+
# Or run a one-shot quick try to see the full workbook shape.
|
|
25
|
+
quick = quick_try(
|
|
26
|
+
"my-quick-try",
|
|
27
|
+
target=EvalTarget(
|
|
28
|
+
kind="workflow",
|
|
29
|
+
name="summarize",
|
|
30
|
+
locator="myapp.pipeline.run",
|
|
31
|
+
boundary="high-level pipeline boundary",
|
|
32
|
+
sources=["myapp/pipeline.py", "myapp/prompts/summarize.py"],
|
|
33
|
+
),
|
|
34
|
+
user_request="Check how the pipeline handles obvious failures without over-correcting.",
|
|
35
|
+
preparation_config="prep.json",
|
|
36
|
+
output_dir=".",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Open a session.
|
|
40
|
+
session = open_session(
|
|
41
|
+
wb_path,
|
|
42
|
+
preparation_config="prep.json",
|
|
43
|
+
workflow_config="wf.json",
|
|
44
|
+
semantic_config="sem.json",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Inspect state, prepare, run, analyze.
|
|
48
|
+
state = session.state()
|
|
49
|
+
result = session.prepare()
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
# Compare runs (no session/workbook required).
|
|
53
|
+
compare_result = compare_runs(
|
|
54
|
+
["run_a.json", "run_b.json"],
|
|
55
|
+
semantic_config="sem.json",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
Deeper engine internals are not part of the ordinary L1 surface.
|
|
59
|
+
Use ``open_diagnostics()`` on a session to enter the L2
|
|
60
|
+
diagnostics/recovery layer with structured reports, evidence, and
|
|
61
|
+
bounded recovery actions. The ``DiagnosticsHandle`` type returned
|
|
62
|
+
by ``open_diagnostics()`` lives in ``lightassay.types`` but
|
|
63
|
+
is not part of the ordinary top-level export set. L2 detail types
|
|
64
|
+
live in ``lightassay.diagnostics``.
|
|
65
|
+
|
|
66
|
+
For deep inspection and bounded low-level control, escalate from
|
|
67
|
+
L2 to L3 via ``diag.open_expert()``. L3 types live in
|
|
68
|
+
``lightassay.expert``.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
__version__ = "0.3.0"
|
|
72
|
+
|
|
73
|
+
# L1 public surface ──────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
from .errors import EvalError
|
|
76
|
+
from .surface import (
|
|
77
|
+
EvalSession,
|
|
78
|
+
compare_runs,
|
|
79
|
+
continue_workbook,
|
|
80
|
+
explore_workbook,
|
|
81
|
+
init_workbook,
|
|
82
|
+
list_backends,
|
|
83
|
+
open_session,
|
|
84
|
+
quick_try,
|
|
85
|
+
quick_try_workbook,
|
|
86
|
+
quickstart,
|
|
87
|
+
refine_workbook,
|
|
88
|
+
)
|
|
89
|
+
from .types import (
|
|
90
|
+
AnalyzeResult,
|
|
91
|
+
CompareResult,
|
|
92
|
+
ContinueResult,
|
|
93
|
+
EvalState,
|
|
94
|
+
EvalTarget,
|
|
95
|
+
ExploreResult,
|
|
96
|
+
PreparationStage,
|
|
97
|
+
PrepareResult,
|
|
98
|
+
QuickstartResult,
|
|
99
|
+
QuickTryResult,
|
|
100
|
+
RefineResult,
|
|
101
|
+
RunResult,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
__all__ = [
|
|
105
|
+
# Version
|
|
106
|
+
"__version__",
|
|
107
|
+
# L1 control
|
|
108
|
+
"open_session",
|
|
109
|
+
"init_workbook",
|
|
110
|
+
"quick_try",
|
|
111
|
+
"quick_try_workbook",
|
|
112
|
+
"refine_workbook",
|
|
113
|
+
"explore_workbook",
|
|
114
|
+
"compare_runs",
|
|
115
|
+
"quickstart",
|
|
116
|
+
"continue_workbook",
|
|
117
|
+
"list_backends",
|
|
118
|
+
"EvalSession",
|
|
119
|
+
# L1 types
|
|
120
|
+
"EvalTarget",
|
|
121
|
+
"EvalState",
|
|
122
|
+
"ExploreResult",
|
|
123
|
+
"PreparationStage",
|
|
124
|
+
"PrepareResult",
|
|
125
|
+
"QuickstartResult",
|
|
126
|
+
"QuickTryResult",
|
|
127
|
+
"ContinueResult",
|
|
128
|
+
"RefineResult",
|
|
129
|
+
"RunResult",
|
|
130
|
+
"AnalyzeResult",
|
|
131
|
+
"CompareResult",
|
|
132
|
+
# L1 error boundary
|
|
133
|
+
"EvalError",
|
|
134
|
+
]
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""First-party adapter pack for common workflow integration shapes.
|
|
2
|
+
|
|
3
|
+
This module ships generic drivers for three common integration patterns:
|
|
4
|
+
|
|
5
|
+
- ``python-callable``: call a Python function directly (no subprocess)
|
|
6
|
+
- ``http``: call an HTTP endpoint with JSON request/response
|
|
7
|
+
- ``command``: run an explicit command list as a subprocess
|
|
8
|
+
|
|
9
|
+
Drivers are selected via the ``driver`` field in workflow config (see
|
|
10
|
+
``docs/adapter_pack_spec.md``). Each driver produces the same response
|
|
11
|
+
contract as the raw subprocess adapter protocol.
|
|
12
|
+
|
|
13
|
+
The legacy ``adapter`` field (raw executable path) remains supported.
|
|
14
|
+
Exactly one of ``adapter`` or ``driver`` must be present in a workflow
|
|
15
|
+
config.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Union
|
|
22
|
+
|
|
23
|
+
# ── Driver error ────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DriverError(Exception):
|
|
27
|
+
"""Raised when a first-party driver fails to execute.
|
|
28
|
+
|
|
29
|
+
The message is recorded as the ``execution_error`` in the case record,
|
|
30
|
+
identical to how subprocess adapter failures are recorded.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ── Driver config types ────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
DRIVER_TYPE_PYTHON_CALLABLE = "python-callable"
|
|
37
|
+
DRIVER_TYPE_HTTP = "http"
|
|
38
|
+
DRIVER_TYPE_COMMAND = "command"
|
|
39
|
+
|
|
40
|
+
KNOWN_DRIVER_TYPES = frozenset(
|
|
41
|
+
{
|
|
42
|
+
DRIVER_TYPE_PYTHON_CALLABLE,
|
|
43
|
+
DRIVER_TYPE_HTTP,
|
|
44
|
+
DRIVER_TYPE_COMMAND,
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class PythonCallableDriverConfig:
|
|
51
|
+
"""Config for the ``python-callable`` driver.
|
|
52
|
+
|
|
53
|
+
``module`` is a dotted Python module path (e.g. ``my_package.adapter``).
|
|
54
|
+
``function`` is the function name within that module.
|
|
55
|
+
|
|
56
|
+
The function must accept a single ``dict`` argument (the adapter request)
|
|
57
|
+
and return a ``dict`` (the adapter response) conforming to the standard
|
|
58
|
+
response contract.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
module: str
|
|
62
|
+
function: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class HttpDriverConfig:
|
|
67
|
+
"""Config for the ``http`` driver.
|
|
68
|
+
|
|
69
|
+
``url`` is the full HTTP endpoint URL.
|
|
70
|
+
``method`` is the HTTP method (e.g. ``"POST"``).
|
|
71
|
+
``headers`` is an optional dict of extra HTTP headers.
|
|
72
|
+
``timeout_seconds`` is an optional request timeout in seconds.
|
|
73
|
+
If ``timeout_seconds`` is absent, no timeout is enforced (consistent
|
|
74
|
+
with the v1 subprocess protocol).
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
url: str
|
|
78
|
+
method: str
|
|
79
|
+
headers: dict[str, str] | None
|
|
80
|
+
timeout_seconds: int | None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class CommandDriverConfig:
|
|
85
|
+
"""Config for the ``command`` driver.
|
|
86
|
+
|
|
87
|
+
``command`` is a non-empty list of strings forming the subprocess
|
|
88
|
+
command (e.g. ``["python3", "my_adapter.py"]``).
|
|
89
|
+
|
|
90
|
+
``config_dir`` is the absolute path to the directory containing the
|
|
91
|
+
workflow config file. When set, the subprocess runs with this as its
|
|
92
|
+
working directory, so relative paths in the command array resolve
|
|
93
|
+
against the config file location rather than the caller's cwd.
|
|
94
|
+
This field is injected by ``load_workflow_config``, not by the user's
|
|
95
|
+
JSON config.
|
|
96
|
+
|
|
97
|
+
The subprocess receives the adapter request JSON on stdin and must
|
|
98
|
+
write the adapter response JSON to stdout, identical to the raw
|
|
99
|
+
subprocess protocol.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
command: list[str]
|
|
103
|
+
config_dir: str | None = None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
DriverConfig = Union[
|
|
107
|
+
PythonCallableDriverConfig,
|
|
108
|
+
HttpDriverConfig,
|
|
109
|
+
CommandDriverConfig,
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ── Driver config validation ───────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
_PYTHON_CALLABLE_REQUIRED = {"module", "function"}
|
|
116
|
+
_HTTP_REQUIRED = {"url", "method"}
|
|
117
|
+
_HTTP_OPTIONAL = {"headers", "timeout_seconds"}
|
|
118
|
+
_COMMAND_REQUIRED = {"command"}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def validate_driver_config(data: dict) -> DriverConfig:
|
|
122
|
+
"""Validate a raw driver config dict and return a typed DriverConfig.
|
|
123
|
+
|
|
124
|
+
Raises ``ValueError`` with a descriptive message on any violation.
|
|
125
|
+
"""
|
|
126
|
+
if not isinstance(data, dict):
|
|
127
|
+
raise ValueError(f"Driver config must be a JSON object, got {type(data).__name__}")
|
|
128
|
+
|
|
129
|
+
if "type" not in data:
|
|
130
|
+
raise ValueError("Driver config missing required field: 'type'")
|
|
131
|
+
|
|
132
|
+
driver_type = data["type"]
|
|
133
|
+
if not isinstance(driver_type, str):
|
|
134
|
+
raise ValueError(
|
|
135
|
+
f"Driver config field 'type' must be a string, got {type(driver_type).__name__}"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if driver_type not in KNOWN_DRIVER_TYPES:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"Unknown driver type: {driver_type!r}. "
|
|
141
|
+
f"Known types: {', '.join(sorted(KNOWN_DRIVER_TYPES))}"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Remaining fields (excluding 'type') for per-type validation.
|
|
145
|
+
fields = {k: v for k, v in data.items() if k != "type"}
|
|
146
|
+
|
|
147
|
+
if driver_type == DRIVER_TYPE_PYTHON_CALLABLE:
|
|
148
|
+
return _validate_python_callable(fields)
|
|
149
|
+
elif driver_type == DRIVER_TYPE_HTTP:
|
|
150
|
+
return _validate_http(fields)
|
|
151
|
+
elif driver_type == DRIVER_TYPE_COMMAND:
|
|
152
|
+
return _validate_command(fields)
|
|
153
|
+
else:
|
|
154
|
+
# Unreachable due to KNOWN_DRIVER_TYPES check above.
|
|
155
|
+
raise ValueError(f"Unknown driver type: {driver_type!r}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _validate_python_callable(fields: dict) -> PythonCallableDriverConfig:
|
|
159
|
+
unknown = set(fields.keys()) - _PYTHON_CALLABLE_REQUIRED
|
|
160
|
+
if unknown:
|
|
161
|
+
raise ValueError(
|
|
162
|
+
f"python-callable driver has unknown fields: "
|
|
163
|
+
f"{', '.join(sorted(unknown))}. "
|
|
164
|
+
f"Allowed: {', '.join(sorted(_PYTHON_CALLABLE_REQUIRED))}"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
for name in sorted(_PYTHON_CALLABLE_REQUIRED):
|
|
168
|
+
if name not in fields:
|
|
169
|
+
raise ValueError(f"python-callable driver missing required field: {name!r}")
|
|
170
|
+
val = fields[name]
|
|
171
|
+
if not isinstance(val, str):
|
|
172
|
+
raise ValueError(
|
|
173
|
+
f"python-callable driver field {name!r} must be a string, got {type(val).__name__}"
|
|
174
|
+
)
|
|
175
|
+
if not val.strip():
|
|
176
|
+
raise ValueError(f"python-callable driver field {name!r} must be non-empty")
|
|
177
|
+
|
|
178
|
+
return PythonCallableDriverConfig(
|
|
179
|
+
module=fields["module"],
|
|
180
|
+
function=fields["function"],
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _validate_http(fields: dict) -> HttpDriverConfig:
|
|
185
|
+
allowed = _HTTP_REQUIRED | _HTTP_OPTIONAL
|
|
186
|
+
unknown = set(fields.keys()) - allowed
|
|
187
|
+
if unknown:
|
|
188
|
+
raise ValueError(
|
|
189
|
+
f"http driver has unknown fields: "
|
|
190
|
+
f"{', '.join(sorted(unknown))}. "
|
|
191
|
+
f"Allowed: {', '.join(sorted(allowed))}"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
for name in sorted(_HTTP_REQUIRED):
|
|
195
|
+
if name not in fields:
|
|
196
|
+
raise ValueError(f"http driver missing required field: {name!r}")
|
|
197
|
+
val = fields[name]
|
|
198
|
+
if not isinstance(val, str):
|
|
199
|
+
raise ValueError(
|
|
200
|
+
f"http driver field {name!r} must be a string, got {type(val).__name__}"
|
|
201
|
+
)
|
|
202
|
+
if not val.strip():
|
|
203
|
+
raise ValueError(f"http driver field {name!r} must be non-empty")
|
|
204
|
+
|
|
205
|
+
headers = None
|
|
206
|
+
if "headers" in fields:
|
|
207
|
+
h = fields["headers"]
|
|
208
|
+
if not isinstance(h, dict):
|
|
209
|
+
raise ValueError(
|
|
210
|
+
f"http driver field 'headers' must be a JSON object, got {type(h).__name__}"
|
|
211
|
+
)
|
|
212
|
+
for k, v in h.items():
|
|
213
|
+
if not isinstance(k, str) or not isinstance(v, str):
|
|
214
|
+
raise ValueError(
|
|
215
|
+
"http driver field 'headers' must be a dict of string keys and string values"
|
|
216
|
+
)
|
|
217
|
+
headers = h
|
|
218
|
+
|
|
219
|
+
timeout = None
|
|
220
|
+
if "timeout_seconds" in fields:
|
|
221
|
+
t = fields["timeout_seconds"]
|
|
222
|
+
if not isinstance(t, int) or isinstance(t, bool):
|
|
223
|
+
raise ValueError(
|
|
224
|
+
f"http driver field 'timeout_seconds' must be an integer, got {type(t).__name__}"
|
|
225
|
+
)
|
|
226
|
+
if t <= 0:
|
|
227
|
+
raise ValueError("http driver field 'timeout_seconds' must be positive")
|
|
228
|
+
timeout = t
|
|
229
|
+
|
|
230
|
+
return HttpDriverConfig(
|
|
231
|
+
url=fields["url"],
|
|
232
|
+
method=fields["method"],
|
|
233
|
+
headers=headers,
|
|
234
|
+
timeout_seconds=timeout,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _validate_command(fields: dict) -> CommandDriverConfig:
|
|
239
|
+
unknown = set(fields.keys()) - _COMMAND_REQUIRED
|
|
240
|
+
if unknown:
|
|
241
|
+
raise ValueError(
|
|
242
|
+
f"command driver has unknown fields: "
|
|
243
|
+
f"{', '.join(sorted(unknown))}. "
|
|
244
|
+
f"Allowed: {', '.join(sorted(_COMMAND_REQUIRED))}"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
if "command" not in fields:
|
|
248
|
+
raise ValueError("command driver missing required field: 'command'")
|
|
249
|
+
|
|
250
|
+
cmd = fields["command"]
|
|
251
|
+
if not isinstance(cmd, list):
|
|
252
|
+
raise ValueError(
|
|
253
|
+
f"command driver field 'command' must be a JSON array, got {type(cmd).__name__}"
|
|
254
|
+
)
|
|
255
|
+
if not cmd:
|
|
256
|
+
raise ValueError("command driver field 'command' must be a non-empty array")
|
|
257
|
+
for i, item in enumerate(cmd):
|
|
258
|
+
if not isinstance(item, str):
|
|
259
|
+
raise ValueError(
|
|
260
|
+
f"command driver field 'command[{i}]' must be a string, got {type(item).__name__}"
|
|
261
|
+
)
|
|
262
|
+
if not item.strip():
|
|
263
|
+
raise ValueError(f"command driver field 'command[{i}]' must be non-empty")
|
|
264
|
+
|
|
265
|
+
return CommandDriverConfig(command=cmd)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ── Driver dispatch ─────────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def execute_driver(config: DriverConfig, request_data: dict) -> dict:
|
|
272
|
+
"""Execute a first-party driver with the given request data.
|
|
273
|
+
|
|
274
|
+
Returns the adapter response dict on success.
|
|
275
|
+
Raises ``DriverError`` on any execution failure.
|
|
276
|
+
|
|
277
|
+
The response dict must conform to the standard adapter response
|
|
278
|
+
contract (``raw_response``, ``parsed_response``, ``usage``).
|
|
279
|
+
Response validation is the caller's responsibility (the runner
|
|
280
|
+
applies the same strict validation as for subprocess adapters).
|
|
281
|
+
"""
|
|
282
|
+
if isinstance(config, PythonCallableDriverConfig):
|
|
283
|
+
from .python_callable import execute as _execute_callable
|
|
284
|
+
|
|
285
|
+
return _execute_callable(config, request_data)
|
|
286
|
+
elif isinstance(config, HttpDriverConfig):
|
|
287
|
+
from .http_driver import execute as _execute_http
|
|
288
|
+
|
|
289
|
+
return _execute_http(config, request_data)
|
|
290
|
+
elif isinstance(config, CommandDriverConfig):
|
|
291
|
+
from .command import execute as _execute_command
|
|
292
|
+
|
|
293
|
+
return _execute_command(config, request_data)
|
|
294
|
+
else:
|
|
295
|
+
raise DriverError(f"Unknown driver config type: {type(config).__name__}")
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""command driver: run an explicit command list as a subprocess.
|
|
2
|
+
|
|
3
|
+
Similar to the legacy raw executable adapter path, but the command is
|
|
4
|
+
specified as an explicit list of strings rather than a single executable
|
|
5
|
+
path. This allows arguments, interpreters, and flags to be specified
|
|
6
|
+
directly in the workflow config.
|
|
7
|
+
|
|
8
|
+
The subprocess receives the adapter request JSON on stdin and must
|
|
9
|
+
write the adapter response JSON to stdout.
|
|
10
|
+
|
|
11
|
+
**Config-origin semantics:** when ``CommandDriverConfig.config_dir`` is
|
|
12
|
+
set (always the case when the config comes from ``load_workflow_config``),
|
|
13
|
+
the subprocess runs with ``cwd=config_dir``. This means relative paths
|
|
14
|
+
in the command array (e.g. ``"adapters/my_adapter.py"``) resolve against
|
|
15
|
+
the directory containing the workflow config file, not the caller's cwd.
|
|
16
|
+
|
|
17
|
+
**Non-zero exit diagnostics:** when the subprocess exits with a non-zero
|
|
18
|
+
code, a bounded excerpt of its stdout is included in the ``DriverError``
|
|
19
|
+
message so that adapter-side diagnostic output is not silently lost.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import subprocess
|
|
26
|
+
|
|
27
|
+
from . import CommandDriverConfig, DriverError
|
|
28
|
+
|
|
29
|
+
# Maximum number of characters to include from stdout when surfacing
|
|
30
|
+
# a non-zero exit error. Large enough to be diagnostic, bounded to
|
|
31
|
+
# avoid unbounded error messages.
|
|
32
|
+
_STDOUT_EXCERPT_LIMIT = 2000
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def execute(config: CommandDriverConfig, request_data: dict) -> dict:
|
|
36
|
+
"""Execute the command driver.
|
|
37
|
+
|
|
38
|
+
When ``config.config_dir`` is set, the subprocess runs with that
|
|
39
|
+
directory as its working directory (config-origin semantics).
|
|
40
|
+
|
|
41
|
+
Raises ``DriverError`` on subprocess failures (non-zero exit,
|
|
42
|
+
invalid JSON output, non-dict response, not found, not executable).
|
|
43
|
+
On non-zero exit, the error includes a bounded stdout excerpt.
|
|
44
|
+
"""
|
|
45
|
+
request_json = json.dumps(request_data, ensure_ascii=False)
|
|
46
|
+
|
|
47
|
+
run_kwargs: dict = {}
|
|
48
|
+
if config.config_dir is not None:
|
|
49
|
+
run_kwargs["cwd"] = config.config_dir
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
result = subprocess.run(
|
|
53
|
+
config.command,
|
|
54
|
+
input=request_json,
|
|
55
|
+
capture_output=True,
|
|
56
|
+
text=True,
|
|
57
|
+
**run_kwargs,
|
|
58
|
+
)
|
|
59
|
+
except FileNotFoundError:
|
|
60
|
+
raise DriverError(f"command driver: command not found: {config.command[0]!r}") from None
|
|
61
|
+
except PermissionError:
|
|
62
|
+
raise DriverError(
|
|
63
|
+
f"command driver: command not executable: {config.command[0]!r}"
|
|
64
|
+
) from None
|
|
65
|
+
|
|
66
|
+
if result.returncode != 0:
|
|
67
|
+
msg = f"command driver: command exited with code {result.returncode}"
|
|
68
|
+
stdout_excerpt = (result.stdout or "")[:_STDOUT_EXCERPT_LIMIT]
|
|
69
|
+
if stdout_excerpt:
|
|
70
|
+
msg += f"; stdout: {stdout_excerpt}"
|
|
71
|
+
raise DriverError(msg)
|
|
72
|
+
|
|
73
|
+
stdout = result.stdout
|
|
74
|
+
try:
|
|
75
|
+
response = json.loads(stdout)
|
|
76
|
+
except (json.JSONDecodeError, ValueError):
|
|
77
|
+
raise DriverError("command driver: command stdout is not valid JSON") from None
|
|
78
|
+
|
|
79
|
+
if not isinstance(response, dict):
|
|
80
|
+
raise DriverError(
|
|
81
|
+
f"command driver: response must be a JSON object, got {type(response).__name__}"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return response
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""http driver: call an HTTP endpoint with JSON request/response.
|
|
2
|
+
|
|
3
|
+
The adapter request dict is serialized as JSON and sent to the
|
|
4
|
+
configured URL using the configured HTTP method. The response body
|
|
5
|
+
is parsed as JSON and returned as the adapter response dict.
|
|
6
|
+
|
|
7
|
+
Uses ``urllib.request`` from the standard library (no third-party
|
|
8
|
+
dependency).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
|
|
17
|
+
from . import DriverError, HttpDriverConfig
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def execute(config: HttpDriverConfig, request_data: dict) -> dict:
|
|
21
|
+
"""Execute the http driver.
|
|
22
|
+
|
|
23
|
+
Raises ``DriverError`` on network errors, non-2xx responses,
|
|
24
|
+
invalid JSON in the response body, or if the response is not a dict.
|
|
25
|
+
"""
|
|
26
|
+
request_json = json.dumps(request_data, ensure_ascii=False).encode("utf-8")
|
|
27
|
+
|
|
28
|
+
req = urllib.request.Request(
|
|
29
|
+
config.url,
|
|
30
|
+
data=request_json,
|
|
31
|
+
method=config.method,
|
|
32
|
+
)
|
|
33
|
+
req.add_header("Content-Type", "application/json")
|
|
34
|
+
|
|
35
|
+
if config.headers is not None:
|
|
36
|
+
for key, value in config.headers.items():
|
|
37
|
+
req.add_header(key, value)
|
|
38
|
+
|
|
39
|
+
timeout_kwargs: dict = {}
|
|
40
|
+
if config.timeout_seconds is not None:
|
|
41
|
+
timeout_kwargs["timeout"] = config.timeout_seconds
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
with urllib.request.urlopen(req, **timeout_kwargs) as response:
|
|
45
|
+
try:
|
|
46
|
+
response_body = response.read().decode("utf-8")
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
raise DriverError(
|
|
49
|
+
f"http driver: failed to read response body: {type(exc).__name__}: {exc}"
|
|
50
|
+
) from exc
|
|
51
|
+
except urllib.error.HTTPError as exc:
|
|
52
|
+
exc.close()
|
|
53
|
+
raise DriverError(f"http driver: HTTP {exc.code} from {config.url!r}") from exc
|
|
54
|
+
except urllib.error.URLError as exc:
|
|
55
|
+
raise DriverError(
|
|
56
|
+
f"http driver: connection failed to {config.url!r}: {exc.reason}"
|
|
57
|
+
) from exc
|
|
58
|
+
except DriverError:
|
|
59
|
+
raise
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
raise DriverError(
|
|
62
|
+
f"http driver: request failed to {config.url!r}: {type(exc).__name__}: {exc}"
|
|
63
|
+
) from exc
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
result = json.loads(response_body)
|
|
67
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
68
|
+
raise DriverError("http driver: response body is not valid JSON") from exc
|
|
69
|
+
|
|
70
|
+
if not isinstance(result, dict):
|
|
71
|
+
raise DriverError(
|
|
72
|
+
f"http driver: response must be a JSON object, got {type(result).__name__}"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return result
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""python-callable driver: call a Python function directly.
|
|
2
|
+
|
|
3
|
+
The configured ``module`` is imported via ``importlib.import_module``
|
|
4
|
+
and the configured ``function`` is looked up as an attribute.
|
|
5
|
+
|
|
6
|
+
The function must accept a single ``dict`` (adapter request) and return
|
|
7
|
+
a ``dict`` (adapter response) conforming to the standard response
|
|
8
|
+
contract.
|
|
9
|
+
|
|
10
|
+
No subprocess overhead. The function runs in the same process.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import importlib
|
|
16
|
+
|
|
17
|
+
from . import DriverError, PythonCallableDriverConfig
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def execute(config: PythonCallableDriverConfig, request_data: dict) -> dict:
|
|
21
|
+
"""Execute the python-callable driver.
|
|
22
|
+
|
|
23
|
+
Raises ``DriverError`` on import failure, missing function, or
|
|
24
|
+
if the function raises an exception or returns a non-dict value.
|
|
25
|
+
"""
|
|
26
|
+
# Import the module.
|
|
27
|
+
try:
|
|
28
|
+
module = importlib.import_module(config.module)
|
|
29
|
+
except ImportError as exc:
|
|
30
|
+
raise DriverError(
|
|
31
|
+
f"python-callable driver: failed to import module {config.module!r}: {exc}"
|
|
32
|
+
) from exc
|
|
33
|
+
|
|
34
|
+
# Look up the function.
|
|
35
|
+
if not hasattr(module, config.function):
|
|
36
|
+
raise DriverError(
|
|
37
|
+
f"python-callable driver: module {config.module!r} has no attribute {config.function!r}"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
func = getattr(module, config.function)
|
|
41
|
+
if not callable(func):
|
|
42
|
+
raise DriverError(
|
|
43
|
+
f"python-callable driver: {config.module}.{config.function} is not callable"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Call the function.
|
|
47
|
+
try:
|
|
48
|
+
response = func(request_data)
|
|
49
|
+
except Exception as exc:
|
|
50
|
+
raise DriverError(
|
|
51
|
+
f"python-callable driver: function "
|
|
52
|
+
f"{config.module}.{config.function} raised {type(exc).__name__}: "
|
|
53
|
+
f"{exc}"
|
|
54
|
+
) from exc
|
|
55
|
+
|
|
56
|
+
if not isinstance(response, dict):
|
|
57
|
+
raise DriverError(
|
|
58
|
+
f"python-callable driver: function "
|
|
59
|
+
f"{config.module}.{config.function} must return a dict, "
|
|
60
|
+
f"got {type(response).__name__}"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return response
|