@adhd/apigen-python-env 0.1.5 → 0.2.1

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.
@@ -0,0 +1,100 @@
1
+ """
2
+ apigen_python.plugin_echo — trivial echo plugin for round-trip testing (SPEC §14).
3
+
4
+ This is the "one echo plugin" required by the host contract: a simple Python
5
+ function that the gateway can call to prove the full IPC round-trip works.
6
+
7
+ The echo operation accepts any input dict and returns it unchanged together with
8
+ a 'host' marker, proving that the Python runtime received the call and responded.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+
16
+ def echo(message: str = "") -> dict[str, Any]:
17
+ """Echo the message back with a host marker.
18
+
19
+ Args:
20
+ message: Any string message.
21
+
22
+ Returns:
23
+ A dict containing the echoed message and the host identifier.
24
+ """
25
+ return {
26
+ "message": message,
27
+ "host": "python",
28
+ "echo": True,
29
+ }
30
+
31
+
32
+ def ping() -> dict[str, Any]:
33
+ """Health-check ping — returns a minimal ok payload.
34
+
35
+ Used by the gateway adapter's readiness probe.
36
+ """
37
+ return {"status": "ok", "host": "python"}
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Plugin operation descriptors — used by the gateway adapter to register these
42
+ # fns without running a full extraction pass.
43
+ # ---------------------------------------------------------------------------
44
+
45
+ ECHO_OPERATION: dict[str, Any] = {
46
+ "id": "echo/echo",
47
+ "host": "python",
48
+ "namespace": {"raw": "echo", "words": ["echo"]},
49
+ "path": [{"raw": "echo", "words": ["echo"]}],
50
+ "kind": "action",
51
+ "async": False,
52
+ "streaming": False,
53
+ "safe": False,
54
+ "input": {
55
+ "type": "object",
56
+ "properties": {
57
+ "message": {"type": "string"},
58
+ },
59
+ "required": [],
60
+ },
61
+ "output": {
62
+ "type": "object",
63
+ "properties": {
64
+ "message": {"type": "string"},
65
+ "host": {"type": "string"},
66
+ "echo": {"type": "boolean"},
67
+ },
68
+ },
69
+ "envelope": {},
70
+ "typeText": None,
71
+ }
72
+
73
+ PING_OPERATION: dict[str, Any] = {
74
+ "id": "echo/ping",
75
+ "host": "python",
76
+ "namespace": {"raw": "echo", "words": ["echo"]},
77
+ "path": [{"raw": "ping", "words": ["ping"]}],
78
+ "kind": "query",
79
+ "async": False,
80
+ "streaming": False,
81
+ "safe": True,
82
+ "input": {"type": "object", "properties": {}},
83
+ "output": {
84
+ "type": "object",
85
+ "properties": {
86
+ "status": {"type": "string"},
87
+ "host": {"type": "string"},
88
+ },
89
+ },
90
+ "envelope": {},
91
+ "typeText": None,
92
+ }
93
+
94
+ # Registry entries used by the gateway adapter.
95
+ ECHO_REGISTRY: dict[str, Any] = {
96
+ "echo/echo": echo,
97
+ "echo/ping": ping,
98
+ }
99
+
100
+ ECHO_OPERATIONS: list[dict[str, Any]] = [ECHO_OPERATION, PING_OPERATION]
@@ -0,0 +1,228 @@
1
+ """
2
+ apigen_python.runtime — Python host harness: invoke / validate / dispatch (SPEC §8 / §14).
3
+
4
+ The runtime receives a HostRequest (from the gateway or directly from a plugin),
5
+ validates input against the Operation's JSON-Schema, dispatches to the Python fn,
6
+ and maps exceptions to §9 ApiError codes.
7
+
8
+ Lifecycle:
9
+ runtime = Runtime(fn_registry)
10
+ result = await runtime.invoke(request, signal) # async path
11
+ result = runtime.invoke_sync(request) # sync path (for tests / WSGI)
12
+
13
+ The fn_registry maps operation id → callable.
14
+
15
+ SPEC §6: validation is a fast-fail pre-filter, NOT the authoritative type gate.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import inspect
22
+ from typing import Any
23
+
24
+ from .errors import ApiError, ApiErrorCode
25
+ from .validator import validate, ValidationError
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # HostRequest — matches gateway.ts HostRequest (the cross-language IPC contract)
30
+ # ---------------------------------------------------------------------------
31
+
32
+ class HostRequest:
33
+ """The single request the gateway forwards to the Python host runtime.
34
+
35
+ Fields mirror gateway.ts::HostRequest exactly so the IPC adapter can
36
+ deserialise directly into this class.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ operation: dict[str, Any],
42
+ data: dict[str, Any],
43
+ envelope: dict[str, Any],
44
+ transport: str,
45
+ pre_validated: bool = False,
46
+ ) -> None:
47
+ self.operation = operation # full §4 Operation dict
48
+ self.data = data # bare domain params (ctx excluded)
49
+ self.envelope = envelope # request side-channel (session, auth, …)
50
+ self.transport = transport # 'http' | 'grpc' | 'mcp' | 'cli'
51
+ self.pre_validated = pre_validated # True when the transport layer already
52
+ # validated wire data before logical decode;
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Runtime
57
+ # ---------------------------------------------------------------------------
58
+
59
+ class Runtime:
60
+ """Python host harness — validate → dispatch (SPEC §8 / §14).
61
+
62
+ Args:
63
+ fn_registry: Mapping of operation id → Python callable.
64
+ The callable receives keyword arguments matching the operation's
65
+ input schema properties. The 'ctx' parameter (if present) receives
66
+ the envelope dict.
67
+ """
68
+
69
+ def __init__(self, fn_registry: dict[str, Any]) -> None:
70
+ self._registry = fn_registry
71
+
72
+ # ------------------------------------------------------------------
73
+ # Core dispatch
74
+ # ------------------------------------------------------------------
75
+
76
+ def _resolve(self, op_id: str) -> Any:
77
+ """Look up the function for an operation id.
78
+
79
+ Raises:
80
+ ApiError(not_found): if no function is registered for op_id.
81
+ """
82
+ fn = self._registry.get(op_id)
83
+ if fn is None:
84
+ raise ApiError("not_found", f"no handler registered for operation '{op_id}'")
85
+ return fn
86
+
87
+ def _validate_input(self, op: dict[str, Any], data: dict[str, Any]) -> None:
88
+ """Validate *data* against the operation's input schema (SPEC §6).
89
+
90
+ Raises:
91
+ ApiError(invalid_argument): if validation fails.
92
+ """
93
+ input_schema = op.get("input", {})
94
+ if not input_schema:
95
+ return
96
+ try:
97
+ validate(input_schema, data)
98
+ except ValidationError as exc:
99
+ raise ApiError("invalid_argument", f"input validation failed: {exc}") from exc
100
+
101
+ def _validate_input_if_needed(self, req: "HostRequest") -> None:
102
+ """Validate request input unless the transport already pre-validated it.
103
+
104
+ When a transport layer (e.g. flask_server) validates wire data BEFORE
105
+ logical-type decoding and marks the request as pre_validated=True, the
106
+ runtime skips its own re-validation. This prevents decoded native
107
+ values (e.g. decimal.Decimal, datetime) from being re-validated against
108
+ the wire schema ({"type":"string","format":"decimal"}) and incorrectly
109
+ failing.
110
+ """
111
+ if req.pre_validated:
112
+ return
113
+ self._validate_input(req.operation, req.data)
114
+
115
+ def _build_kwargs(self, fn: Any, data: dict[str, Any], envelope: dict[str, Any]) -> dict[str, Any]:
116
+ """Build keyword-argument dict for the function from data + envelope.
117
+
118
+ If the function has a first parameter named 'ctx', it receives the
119
+ envelope dict. All other parameters are pulled from data.
120
+ """
121
+ try:
122
+ sig = inspect.signature(fn)
123
+ except (ValueError, TypeError):
124
+ return dict(data)
125
+
126
+ params = list(sig.parameters.values())
127
+ kwargs: dict[str, Any] = {}
128
+
129
+ # Strip 'self' for bound methods.
130
+ if params and params[0].name == "self":
131
+ params = params[1:]
132
+
133
+ # 'ctx' first-param receives the envelope (§4 inv:ctx-name-only).
134
+ if params and params[0].name == "ctx":
135
+ kwargs["ctx"] = envelope
136
+ params = params[1:]
137
+
138
+ # Remaining params from data.
139
+ for p in params:
140
+ if p.kind in (
141
+ inspect.Parameter.VAR_POSITIONAL,
142
+ inspect.Parameter.VAR_KEYWORD,
143
+ ):
144
+ continue
145
+ if p.name in data:
146
+ kwargs[p.name] = data[p.name]
147
+
148
+ return kwargs
149
+
150
+ def _call_fn(self, fn: Any, kwargs: dict[str, Any]) -> Any:
151
+ """Call a synchronous function, mapping exceptions to ApiError."""
152
+ try:
153
+ return fn(**kwargs)
154
+ except ApiError:
155
+ raise
156
+ except TypeError as exc:
157
+ raise ApiError("invalid_argument", f"dispatch type error: {exc}") from exc
158
+ except Exception as exc:
159
+ raise ApiError("internal", f"dispatch error: {exc}") from exc
160
+
161
+ async def _call_fn_async(self, fn: Any, kwargs: dict[str, Any]) -> Any:
162
+ """Call an async function (coroutine), mapping exceptions to ApiError."""
163
+ try:
164
+ result = fn(**kwargs)
165
+ if inspect.isawaitable(result):
166
+ return await result
167
+ return result
168
+ except ApiError:
169
+ raise
170
+ except TypeError as exc:
171
+ raise ApiError("invalid_argument", f"dispatch type error: {exc}") from exc
172
+ except Exception as exc:
173
+ raise ApiError("internal", f"dispatch error: {exc}") from exc
174
+
175
+ # ------------------------------------------------------------------
176
+ # Public invoke API
177
+ # ------------------------------------------------------------------
178
+
179
+ async def invoke(self, req: HostRequest, signal: Any = None) -> Any:
180
+ """Invoke an operation asynchronously.
181
+
182
+ Validates input, resolves the function, dispatches, and returns the result.
183
+ Cancellation via *signal* is not yet propagated to the fn (future work).
184
+
185
+ Args:
186
+ req: The HostRequest from the gateway or plugin.
187
+ signal: Optional cancellation signal (reserved; not yet consumed).
188
+
189
+ Returns:
190
+ The function's return value (awaited if async).
191
+
192
+ Raises:
193
+ ApiError: on validation failure, not-found, or dispatch error.
194
+ """
195
+ op = req.operation
196
+ fn = self._resolve(op["id"])
197
+ self._validate_input_if_needed(req)
198
+ kwargs = self._build_kwargs(fn, req.data, req.envelope)
199
+
200
+ if inspect.iscoroutinefunction(fn) or inspect.isasyncgenfunction(fn):
201
+ return await self._call_fn_async(fn, kwargs)
202
+ # Sync fn — run in thread pool to avoid blocking the event loop.
203
+ loop = asyncio.get_event_loop()
204
+ return await loop.run_in_executor(None, lambda: self._call_fn(fn, kwargs))
205
+
206
+ def invoke_sync(self, req: HostRequest) -> Any:
207
+ """Invoke an operation synchronously (for tests and WSGI contexts).
208
+
209
+ For async functions this runs them in a new event loop; for sync
210
+ functions it calls them directly.
211
+
212
+ Args:
213
+ req: The HostRequest.
214
+
215
+ Returns:
216
+ The function's return value.
217
+
218
+ Raises:
219
+ ApiError: on validation failure, not-found, or dispatch error.
220
+ """
221
+ op = req.operation
222
+ fn = self._resolve(op["id"])
223
+ self._validate_input_if_needed(req)
224
+ kwargs = self._build_kwargs(fn, req.data, req.envelope)
225
+
226
+ if inspect.iscoroutinefunction(fn) or inspect.isasyncgenfunction(fn):
227
+ return asyncio.run(self._call_fn_async(fn, kwargs))
228
+ return self._call_fn(fn, kwargs)
@@ -0,0 +1,135 @@
1
+ """
2
+ apigen_python.validator — JSON-Schema 2020-12 validation for the Python host (SPEC §6).
3
+
4
+ Implements validation as a fast-fail pre-filter. Tries to use the ``jsonschema``
5
+ library if installed; falls back to a minimal stdlib-only validator that covers
6
+ the common cases used by the conformance vectors.
7
+
8
+ SPEC §6 note: validation is necessary-not-sufficient. The Python host validates
9
+ as a pre-filter; typed dispatch is the authoritative boundary.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Try to import jsonschema (optional dependency)
19
+ # ---------------------------------------------------------------------------
20
+
21
+ try:
22
+ import jsonschema # type: ignore[import]
23
+ _HAS_JSONSCHEMA = True
24
+ except ImportError:
25
+ _HAS_JSONSCHEMA = False
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Minimal stdlib validator (fallback when jsonschema is not installed)
30
+ #
31
+ # Covers: type, required, properties, oneOf, items, additionalProperties.
32
+ # Sufficient for all conformance vector schemas.
33
+ # ---------------------------------------------------------------------------
34
+
35
+ def _check_type(schema_type: str, value: Any) -> bool:
36
+ type_map = {
37
+ "object": lambda v: isinstance(v, dict),
38
+ "array": lambda v: isinstance(v, list),
39
+ "string": lambda v: isinstance(v, str),
40
+ "integer": lambda v: isinstance(v, int) and not isinstance(v, bool),
41
+ "number": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool),
42
+ "boolean": lambda v: isinstance(v, bool),
43
+ "null": lambda v: v is None,
44
+ }
45
+ checker = type_map.get(schema_type)
46
+ return checker(value) if checker else True
47
+
48
+
49
+ def _minimal_validate(schema: dict[str, Any], value: Any) -> bool:
50
+ """Validate *value* against *schema* using a minimal subset of JSON Schema.
51
+
52
+ Returns True if valid, False if invalid.
53
+ Does NOT raise; the caller decides how to surface failure.
54
+ """
55
+ if not isinstance(schema, dict):
56
+ return True # empty / non-schema → accept
57
+
58
+ # type keyword
59
+ schema_type = schema.get("type")
60
+ if schema_type is not None:
61
+ if not _check_type(schema_type, value):
62
+ return False
63
+
64
+ # oneOf
65
+ one_of = schema.get("oneOf")
66
+ if one_of is not None:
67
+ matches = sum(1 for s in one_of if _minimal_validate(s, value))
68
+ if matches != 1:
69
+ # Relax to "at least one" (some schemas use oneOf where anyOf is meant).
70
+ if matches == 0:
71
+ return False
72
+
73
+ # required + properties (object only)
74
+ if schema_type == "object" and isinstance(value, dict):
75
+ required = schema.get("required", [])
76
+ for key in required:
77
+ if key not in value:
78
+ return False
79
+ properties = schema.get("properties", {})
80
+ for prop_name, prop_schema in properties.items():
81
+ if prop_name in value:
82
+ if not _minimal_validate(prop_schema, value[prop_name]):
83
+ return False
84
+
85
+ # items (array only)
86
+ if schema_type == "array" and isinstance(value, list):
87
+ items_schema = schema.get("items")
88
+ if items_schema:
89
+ for item in value:
90
+ if not _minimal_validate(items_schema, item):
91
+ return False
92
+
93
+ return True
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Public API
98
+ # ---------------------------------------------------------------------------
99
+
100
+ class ValidationError(Exception):
101
+ """Raised when input data fails JSON-Schema validation."""
102
+
103
+ def __init__(self, message: str, path: str = "") -> None:
104
+ super().__init__(message)
105
+ self.path = path
106
+
107
+
108
+ def validate(schema: dict[str, Any], value: Any) -> None:
109
+ """Validate *value* against *schema*.
110
+
111
+ Uses ``jsonschema`` if installed (full JSON-Schema 2020-12), otherwise falls
112
+ back to the minimal stdlib validator.
113
+
114
+ Raises:
115
+ ValidationError: if the value does not satisfy the schema.
116
+ """
117
+ if _HAS_JSONSCHEMA:
118
+ try:
119
+ jsonschema.validate(value, schema)
120
+ except jsonschema.ValidationError as exc:
121
+ raise ValidationError(str(exc.message)) from exc
122
+ else:
123
+ if not _minimal_validate(schema, value):
124
+ raise ValidationError(
125
+ f"Value does not satisfy schema (minimal validator): {value!r}"
126
+ )
127
+
128
+
129
+ def is_valid(schema: dict[str, Any], value: Any) -> bool:
130
+ """Return True if *value* satisfies *schema*, False otherwise. Never raises."""
131
+ try:
132
+ validate(schema, value)
133
+ return True
134
+ except ValidationError:
135
+ return False