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.
Files changed (39) hide show
  1. lightassay/__init__.py +134 -0
  2. lightassay/adapter_pack/__init__.py +295 -0
  3. lightassay/adapter_pack/command.py +84 -0
  4. lightassay/adapter_pack/http_driver.py +75 -0
  5. lightassay/adapter_pack/python_callable.py +63 -0
  6. lightassay/analyzer.py +287 -0
  7. lightassay/backends.py +144 -0
  8. lightassay/bootstrap.py +469 -0
  9. lightassay/builtin_adapters/__init__.py +27 -0
  10. lightassay/builtin_adapters/_agent_cli_common.py +281 -0
  11. lightassay/builtin_adapters/claude_cli.py +29 -0
  12. lightassay/builtin_adapters/codex_cli.py +28 -0
  13. lightassay/builtin_adapters/stub.py +361 -0
  14. lightassay/cli.py +1077 -0
  15. lightassay/comparer.py +197 -0
  16. lightassay/diagnostics.py +104 -0
  17. lightassay/errors.py +94 -0
  18. lightassay/expert.py +440 -0
  19. lightassay/orchestrator.py +1219 -0
  20. lightassay/preparation_config.py +109 -0
  21. lightassay/preparer.py +1218 -0
  22. lightassay/run_artifact_io.py +407 -0
  23. lightassay/run_models.py +70 -0
  24. lightassay/runner.py +298 -0
  25. lightassay/runtime_state.py +240 -0
  26. lightassay/semantic_config.py +102 -0
  27. lightassay/surface.py +2635 -0
  28. lightassay/types.py +319 -0
  29. lightassay/workbook_models.py +151 -0
  30. lightassay/workbook_parser.py +824 -0
  31. lightassay/workbook_renderer.py +405 -0
  32. lightassay/workflow_config.py +239 -0
  33. lightassay/workflow_config_builder.py +141 -0
  34. lightassay-0.3.0.dist-info/METADATA +163 -0
  35. lightassay-0.3.0.dist-info/RECORD +39 -0
  36. lightassay-0.3.0.dist-info/WHEEL +5 -0
  37. lightassay-0.3.0.dist-info/entry_points.txt +2 -0
  38. lightassay-0.3.0.dist-info/licenses/LICENSE +21 -0
  39. 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