@adhd/apigen-python-env 0.2.0 → 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.
- package/CHANGELOG.md +8 -0
- package/index.js +6 -6
- package/index.mjs +56 -53
- package/lib/python-env.d.ts +14 -4
- package/package.json +2 -2
- package/python/apigen_logical.py +628 -0
- package/python/apigen_python/__init__.py +1 -0
- package/python/apigen_python/errors.py +99 -0
- package/python/apigen_python/extractor.py +578 -0
- package/python/apigen_python/flask_server.py +748 -0
- package/python/apigen_python/gateway_adapter.py +260 -0
- package/python/apigen_python/grpc_server.py +954 -0
- package/python/apigen_python/plugin_echo.py +100 -0
- package/python/apigen_python/runtime.py +228 -0
- package/python/apigen_python/validator.py +135 -0
- package/python/conformance_vectors.json +865 -0
- package/python/pyproject.toml +59 -0
- package/python/run_tests.py +2118 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
apigen_python.errors — §9.1 canonical error taxonomy for the Python host.
|
|
3
|
+
|
|
4
|
+
Mirrors the TS @adhd/apigen-errors package exactly so the Python runtime
|
|
5
|
+
raises the same codes and the gateway maps them identically.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# §9 — Canonical error code set (gRPC-style)
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
ApiErrorCode = Literal[
|
|
17
|
+
"invalid_argument",
|
|
18
|
+
"unauthenticated",
|
|
19
|
+
"permission_denied",
|
|
20
|
+
"not_found",
|
|
21
|
+
"internal",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
ERROR_CODES: tuple[ApiErrorCode, ...] = (
|
|
25
|
+
"invalid_argument",
|
|
26
|
+
"unauthenticated",
|
|
27
|
+
"permission_denied",
|
|
28
|
+
"not_found",
|
|
29
|
+
"internal",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# §9.1 — Per-transport status maps (normative — must mirror SPEC §9 table)
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
HTTP_STATUS: dict[ApiErrorCode, int] = {
|
|
37
|
+
"invalid_argument": 400,
|
|
38
|
+
"unauthenticated": 401,
|
|
39
|
+
"permission_denied": 403,
|
|
40
|
+
"not_found": 404,
|
|
41
|
+
"internal": 500,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
GRPC_CODE: dict[ApiErrorCode, str] = {
|
|
45
|
+
"invalid_argument": "INVALID_ARGUMENT",
|
|
46
|
+
"unauthenticated": "UNAUTHENTICATED",
|
|
47
|
+
"permission_denied": "PERMISSION_DENIED",
|
|
48
|
+
"not_found": "NOT_FOUND",
|
|
49
|
+
"internal": "INTERNAL",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
CLI_EXIT_CODE: dict[ApiErrorCode, int] = {
|
|
53
|
+
"invalid_argument": 2,
|
|
54
|
+
"unauthenticated": 3,
|
|
55
|
+
"permission_denied": 3,
|
|
56
|
+
"not_found": 4,
|
|
57
|
+
"internal": 1,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
MCP_ERROR_KIND: dict[ApiErrorCode, Literal["error"]] = {
|
|
61
|
+
"invalid_argument": "error",
|
|
62
|
+
"unauthenticated": "error",
|
|
63
|
+
"permission_denied": "error",
|
|
64
|
+
"not_found": "error",
|
|
65
|
+
"internal": "error",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# ApiError — the raised error class
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ApiError(Exception):
|
|
74
|
+
"""The canonical apigen error (§9).
|
|
75
|
+
|
|
76
|
+
Every transport adapter catches this and maps ``code`` to the native
|
|
77
|
+
status using the tables above.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
code: ApiErrorCode,
|
|
83
|
+
message: str,
|
|
84
|
+
details: Any = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
super().__init__(message)
|
|
87
|
+
self.code: ApiErrorCode = code
|
|
88
|
+
self.message: str = message
|
|
89
|
+
self.details: Any = details
|
|
90
|
+
|
|
91
|
+
def to_json(self) -> dict[str, Any]:
|
|
92
|
+
"""Serialise to a plain dict suitable for JSON transport."""
|
|
93
|
+
out: dict[str, Any] = {"code": self.code, "message": self.message}
|
|
94
|
+
if self.details is not None:
|
|
95
|
+
out["details"] = self.details
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
def __repr__(self) -> str:
|
|
99
|
+
return f"ApiError(code={self.code!r}, message={self.message!r})"
|
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
"""
|
|
2
|
+
apigen_python.extractor — Python source → canonical Operation[] descriptors (SPEC §4 / §14).
|
|
3
|
+
|
|
4
|
+
Extracts every public export from a Python module and emits a list of Operation
|
|
5
|
+
dicts that match the canonical §4 JSON shape:
|
|
6
|
+
|
|
7
|
+
id, host('python'), namespace, path, kind, async, streaming, safe,
|
|
8
|
+
input (JSON-Schema-2020-12 object), output (JSON-Schema-2020-12),
|
|
9
|
+
envelope ({} — middleware is TS-side only for now), typeText (None).
|
|
10
|
+
|
|
11
|
+
Usage (subprocess / CLI):
|
|
12
|
+
python -m apigen_python.extractor <module_file_or_path> --emit-json [--namespace <ns>]
|
|
13
|
+
→ JSON array on stdout
|
|
14
|
+
|
|
15
|
+
`--emit-json` is REQUIRED (apigen-serve-core py-flask-serve-split /
|
|
16
|
+
py-extract-serve-split-findings.md §1.4): this CLI is now a stable, two-phase
|
|
17
|
+
subprocess-protocol contract the TS `py-flask`/`py-grpc` plugins spawn as
|
|
18
|
+
phase 1 (extract-only) before computing route/verb via the REAL
|
|
19
|
+
`@adhd/apigen-engine-naming` `project()` and re-spawning `flask_server`/
|
|
20
|
+
`grpc_server` with the result (`--plan-file`). Requiring the flag explicitly
|
|
21
|
+
— rather than making JSON-on-stdout the silent, implicit default — means a
|
|
22
|
+
future non-JSON output mode (e.g. a human-readable tree) can be added to
|
|
23
|
+
this CLI later without silently changing what the two-phase callers depend
|
|
24
|
+
on.
|
|
25
|
+
|
|
26
|
+
Rules:
|
|
27
|
+
- Every exported name (not starting with '_') that is callable → kind='action'.
|
|
28
|
+
- Every exported name that holds a serialisable primitive/dict/list → kind='query'.
|
|
29
|
+
- Non-serialisable, non-callable exports → skipped + warned to stderr.
|
|
30
|
+
- 'ctx' first-parameter is excluded from input schema (§4 inv:ctx-name-only).
|
|
31
|
+
- Async functions → async:true; generators / async-generators → streaming:true.
|
|
32
|
+
- kind='query' → safe:true by default; kind='action' → safe:false.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import ast
|
|
38
|
+
import importlib.util
|
|
39
|
+
import inspect
|
|
40
|
+
import json
|
|
41
|
+
import os
|
|
42
|
+
import re
|
|
43
|
+
import sys
|
|
44
|
+
import types
|
|
45
|
+
import warnings
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from typing import Any
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# §4 Segment helpers
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def _tokenise(raw: str) -> list[str]:
|
|
55
|
+
"""Tokenise a camelCase / snake_case / kebab-case name into lower-cased words.
|
|
56
|
+
|
|
57
|
+
Examples:
|
|
58
|
+
'humanizeBytes' → ['humanize', 'bytes']
|
|
59
|
+
'get_user' → ['get', 'user']
|
|
60
|
+
'file-name' → ['file', 'name']
|
|
61
|
+
"""
|
|
62
|
+
# Insert split boundary between lower→upper transitions (camelCase).
|
|
63
|
+
s = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", raw)
|
|
64
|
+
# Split on non-alphanumeric separators.
|
|
65
|
+
parts = re.split(r"[^a-zA-Z0-9]+", s)
|
|
66
|
+
return [p.lower() for p in parts if p]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _seg(raw: str) -> dict[str, Any]:
|
|
70
|
+
"""Build a casing-neutral Segment dict from a raw token."""
|
|
71
|
+
return {"raw": raw, "words": _tokenise(raw)}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _normalise_filename(raw: str) -> str:
|
|
75
|
+
"""Strip extension; dots/underscores → hyphens (SPEC §5)."""
|
|
76
|
+
no_ext = re.sub(r"\.[^.]+$", "", raw)
|
|
77
|
+
return re.sub(r"[._]+", "-", no_ext)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# JSON-Schema inference (stdlib only — no jsonschema dependency for extraction)
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def _py_type_hint_to_schema(annotation: Any) -> dict[str, Any]:
|
|
85
|
+
"""Best-effort conversion of a Python type annotation to JSON Schema 2020-12.
|
|
86
|
+
|
|
87
|
+
Only covers the common cases; falls back to {} (any) for complex generics.
|
|
88
|
+
Returns a plain dict.
|
|
89
|
+
|
|
90
|
+
Logical-type mappings (mirrors the TS apigen-logical codecs):
|
|
91
|
+
decimal.Decimal → {"type": "string", "format": "decimal"}
|
|
92
|
+
datetime.datetime → {"type": "string", "format": "date-time"}
|
|
93
|
+
uuid.UUID → {"type": "string", "format": "uuid"}
|
|
94
|
+
"""
|
|
95
|
+
import typing
|
|
96
|
+
|
|
97
|
+
origin = getattr(annotation, "__origin__", None)
|
|
98
|
+
args = getattr(annotation, "__args__", ())
|
|
99
|
+
|
|
100
|
+
# None / NoneType
|
|
101
|
+
if annotation is type(None):
|
|
102
|
+
return {"type": "null"}
|
|
103
|
+
|
|
104
|
+
# Logical types — must be checked before the primitive table so that
|
|
105
|
+
# `Decimal` / `datetime` / `UUID` are not silently swallowed as {}
|
|
106
|
+
# (they are not in the _primitives dict, so they would fall through to
|
|
107
|
+
# the dataclass check and ultimately to the {} fallback without this guard).
|
|
108
|
+
try:
|
|
109
|
+
from decimal import Decimal as _Decimal
|
|
110
|
+
if annotation is _Decimal:
|
|
111
|
+
return {"type": "string", "format": "decimal"}
|
|
112
|
+
except ImportError:
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
from datetime import datetime as _Datetime
|
|
117
|
+
if annotation is _Datetime:
|
|
118
|
+
return {"type": "string", "format": "date-time"}
|
|
119
|
+
except ImportError:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
from uuid import UUID as _UUID
|
|
124
|
+
if annotation is _UUID:
|
|
125
|
+
return {"type": "string", "format": "uuid"}
|
|
126
|
+
except ImportError:
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
# Primitives
|
|
130
|
+
_primitives: dict[Any, str] = {
|
|
131
|
+
str: "string",
|
|
132
|
+
int: "integer",
|
|
133
|
+
float: "number",
|
|
134
|
+
bool: "boolean",
|
|
135
|
+
bytes: "string",
|
|
136
|
+
}
|
|
137
|
+
if annotation in _primitives:
|
|
138
|
+
return {"type": _primitives[annotation]}
|
|
139
|
+
|
|
140
|
+
# typing.Optional[X] == Union[X, None]
|
|
141
|
+
if origin is typing.Union:
|
|
142
|
+
non_none = [a for a in args if a is not type(None)]
|
|
143
|
+
if len(non_none) == 1 and type(None) in args:
|
|
144
|
+
inner = _py_type_hint_to_schema(non_none[0])
|
|
145
|
+
return {"oneOf": [inner, {"type": "null"}]}
|
|
146
|
+
schemas = [_py_type_hint_to_schema(a) for a in args]
|
|
147
|
+
return {"oneOf": schemas}
|
|
148
|
+
|
|
149
|
+
# list / List[T]
|
|
150
|
+
if origin is list:
|
|
151
|
+
items = _py_type_hint_to_schema(args[0]) if args else {}
|
|
152
|
+
return {"type": "array", "items": items}
|
|
153
|
+
|
|
154
|
+
# dict / Dict[K, V]
|
|
155
|
+
if origin is dict:
|
|
156
|
+
value_schema = _py_type_hint_to_schema(args[1]) if len(args) > 1 else {}
|
|
157
|
+
return {"type": "object", "additionalProperties": value_schema}
|
|
158
|
+
|
|
159
|
+
# dataclasses.dataclass
|
|
160
|
+
try:
|
|
161
|
+
import dataclasses
|
|
162
|
+
if dataclasses.is_dataclass(annotation) and isinstance(annotation, type):
|
|
163
|
+
props: dict[str, Any] = {}
|
|
164
|
+
required: list[str] = []
|
|
165
|
+
for f in dataclasses.fields(annotation):
|
|
166
|
+
props[f.name] = _py_type_hint_to_schema(f.type)
|
|
167
|
+
if f.default is dataclasses.MISSING and f.default_factory is dataclasses.MISSING: # type: ignore[misc]
|
|
168
|
+
required.append(f.name)
|
|
169
|
+
schema: dict[str, Any] = {"type": "object", "properties": props}
|
|
170
|
+
if required:
|
|
171
|
+
schema["required"] = required
|
|
172
|
+
return schema
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
# Fall back: open object schema (any)
|
|
177
|
+
return {}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _resolve_hints(fn: Any) -> dict[str, Any]:
|
|
181
|
+
"""Resolve all annotations for a callable, handling PEP-563 stringized forms.
|
|
182
|
+
|
|
183
|
+
When a module uses ``from __future__ import annotations`` all annotations are
|
|
184
|
+
stored as strings rather than type objects. ``typing.get_type_hints`` evaluates
|
|
185
|
+
those strings against the function's module globals (and the optional
|
|
186
|
+
``localns`` dict), returning a ``{param_name: type}`` mapping with real type
|
|
187
|
+
objects.
|
|
188
|
+
|
|
189
|
+
Falls back gracefully if resolution fails (e.g. a forward reference that
|
|
190
|
+
cannot be resolved in the execution context) — unresolvable params get ``{}``
|
|
191
|
+
(any) schema, preserving backward compatibility.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
fn: Any callable (function, method, lambda).
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
Dict mapping parameter / return annotation names to resolved type objects.
|
|
198
|
+
The return type is keyed under ``"return"`` (matching ``typing.get_type_hints``
|
|
199
|
+
convention).
|
|
200
|
+
"""
|
|
201
|
+
import typing
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
# include_extras=False strips Annotated[] wrappers to bare types.
|
|
205
|
+
hints = typing.get_type_hints(fn, include_extras=False)
|
|
206
|
+
return hints
|
|
207
|
+
except Exception:
|
|
208
|
+
# Fall back to raw __annotations__ (may be strings for PEP-563 modules).
|
|
209
|
+
raw = getattr(fn, "__annotations__", {})
|
|
210
|
+
return {k: v for k, v in raw.items() if not isinstance(v, str)}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _params_to_input_schema(sig: inspect.Signature, fn: Any = None) -> dict[str, Any]:
|
|
214
|
+
"""Convert a function's signature to a §4 input JSON-Schema object.
|
|
215
|
+
|
|
216
|
+
- Excludes the first parameter named 'ctx' (§4 inv:ctx-name-only).
|
|
217
|
+
- Each remaining parameter becomes a property.
|
|
218
|
+
- Parameters without defaults are required.
|
|
219
|
+
- Resolves PEP-563 stringized annotations via ``typing.get_type_hints`` when
|
|
220
|
+
``fn`` is supplied, so that ``Decimal``/``datetime``/``UUID`` type hints work
|
|
221
|
+
correctly even in modules that use ``from __future__ import annotations``.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
sig: The ``inspect.Signature`` for the function.
|
|
225
|
+
fn: Optional callable — used to resolve string annotations (PEP 563).
|
|
226
|
+
"""
|
|
227
|
+
props: dict[str, Any] = {}
|
|
228
|
+
required: list[str] = []
|
|
229
|
+
|
|
230
|
+
# Resolve annotations via get_type_hints when a callable is supplied.
|
|
231
|
+
# This handles both eager annotations (fn.__annotations__ = real types) and
|
|
232
|
+
# PEP-563 stringized annotations (fn.__annotations__ = strings).
|
|
233
|
+
resolved_hints: dict[str, Any] = _resolve_hints(fn) if fn is not None else {}
|
|
234
|
+
|
|
235
|
+
params = list(sig.parameters.values())
|
|
236
|
+
# Drop 'self' for bound methods.
|
|
237
|
+
if params and params[0].name == "self":
|
|
238
|
+
params = params[1:]
|
|
239
|
+
# Drop 'ctx' first-param (name-only match, SPEC §4).
|
|
240
|
+
if params and params[0].name == "ctx":
|
|
241
|
+
params = params[1:]
|
|
242
|
+
# Drop *args / **kwargs from schema (not serialisable as named params).
|
|
243
|
+
named = [
|
|
244
|
+
p for p in params
|
|
245
|
+
if p.kind not in (
|
|
246
|
+
inspect.Parameter.VAR_POSITIONAL,
|
|
247
|
+
inspect.Parameter.VAR_KEYWORD,
|
|
248
|
+
)
|
|
249
|
+
]
|
|
250
|
+
|
|
251
|
+
for p in named:
|
|
252
|
+
# Prefer the get_type_hints-resolved annotation; fall back to the
|
|
253
|
+
# raw signature annotation (which may be a string for PEP-563 modules).
|
|
254
|
+
ann = resolved_hints.get(p.name, p.annotation)
|
|
255
|
+
if ann is inspect.Parameter.empty or ann is None:
|
|
256
|
+
schema_frag: dict[str, Any] = {}
|
|
257
|
+
else:
|
|
258
|
+
schema_frag = _py_type_hint_to_schema(ann)
|
|
259
|
+
props[p.name] = schema_frag
|
|
260
|
+
if p.default is inspect.Parameter.empty:
|
|
261
|
+
required.append(p.name)
|
|
262
|
+
|
|
263
|
+
result: dict[str, Any] = {"type": "object", "properties": props}
|
|
264
|
+
if required:
|
|
265
|
+
result["required"] = required
|
|
266
|
+
return result
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _return_to_output_schema(sig: inspect.Signature, fn: Any = None) -> dict[str, Any]:
|
|
270
|
+
"""Convert a function's return annotation to a §4 output JSON-Schema.
|
|
271
|
+
|
|
272
|
+
Promise<T> / Awaitable[T] and generator wrappers are unwrapped to T.
|
|
273
|
+
Resolves PEP-563 stringized return annotations via ``typing.get_type_hints``
|
|
274
|
+
when ``fn`` is supplied.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
sig: The ``inspect.Signature`` for the function.
|
|
278
|
+
fn: Optional callable — used to resolve string annotations (PEP 563).
|
|
279
|
+
"""
|
|
280
|
+
# Prefer get_type_hints-resolved return annotation.
|
|
281
|
+
resolved_hints: dict[str, Any] = _resolve_hints(fn) if fn is not None else {}
|
|
282
|
+
ann = resolved_hints.get("return", sig.return_annotation)
|
|
283
|
+
if ann is inspect.Parameter.empty:
|
|
284
|
+
return {}
|
|
285
|
+
# Unwrap Coroutine / Awaitable / Generator wrappers.
|
|
286
|
+
import typing
|
|
287
|
+
|
|
288
|
+
origin = getattr(ann, "__origin__", None)
|
|
289
|
+
# AsyncGenerator[T, ...] → T
|
|
290
|
+
if origin is not None:
|
|
291
|
+
name = getattr(origin, "__name__", "") or getattr(origin, "_name", "")
|
|
292
|
+
if name in ("AsyncGenerator", "Generator", "AsyncIterator", "Iterator"):
|
|
293
|
+
args = getattr(ann, "__args__", ())
|
|
294
|
+
if args:
|
|
295
|
+
return _py_type_hint_to_schema(args[0])
|
|
296
|
+
# Coroutine[_, _, T] → T
|
|
297
|
+
try:
|
|
298
|
+
if origin is not None and hasattr(origin, "__name__") and origin.__name__ == "Coroutine":
|
|
299
|
+
args = getattr(ann, "__args__", ())
|
|
300
|
+
if len(args) >= 3:
|
|
301
|
+
return _py_type_hint_to_schema(args[2])
|
|
302
|
+
except Exception:
|
|
303
|
+
pass
|
|
304
|
+
|
|
305
|
+
return _py_type_hint_to_schema(ann)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ---------------------------------------------------------------------------
|
|
309
|
+
# Operation building
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
def _is_streaming(fn: Any) -> bool:
|
|
313
|
+
"""True if the function is an async generator or regular generator."""
|
|
314
|
+
return inspect.isasyncgenfunction(fn) or inspect.isgeneratorfunction(fn)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _is_async(fn: Any) -> bool:
|
|
318
|
+
"""True if the function is a coroutine function or async generator."""
|
|
319
|
+
return inspect.iscoroutinefunction(fn) or inspect.isasyncgenfunction(fn)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _is_serialisable(value: Any) -> bool:
|
|
323
|
+
"""Cheaply check if a non-callable export can be JSON-serialised."""
|
|
324
|
+
try:
|
|
325
|
+
json.dumps(value)
|
|
326
|
+
return True
|
|
327
|
+
except (TypeError, ValueError):
|
|
328
|
+
return False
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _derive_id(namespace_raw: str, path: list[dict[str, Any]]) -> str:
|
|
332
|
+
"""Derive the canonical §4 id: namespace/path (kebab-joined).
|
|
333
|
+
|
|
334
|
+
Mirrors the TS implementation: namespace slug + "/" + path segments joined
|
|
335
|
+
with "/" where each segment is tokenised words joined with "-".
|
|
336
|
+
"""
|
|
337
|
+
def seg_to_slug(s: dict[str, Any]) -> str:
|
|
338
|
+
return "-".join(s["words"])
|
|
339
|
+
|
|
340
|
+
parts = [namespace_raw] + [seg_to_slug(s) for s in path]
|
|
341
|
+
return "/".join(parts)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def extract_module(
|
|
345
|
+
module_path: str,
|
|
346
|
+
namespace: str | None = None,
|
|
347
|
+
) -> list[dict[str, Any]]:
|
|
348
|
+
"""Extract Operation descriptors from a Python source file.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
module_path: Absolute or relative path to the .py file.
|
|
352
|
+
namespace: The §4 namespace slug. Defaults to the file stem.
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
List of Operation dicts (§4 JSON shape).
|
|
356
|
+
|
|
357
|
+
Note:
|
|
358
|
+
Supports modules that use ``from __future__ import annotations`` (PEP 563).
|
|
359
|
+
The module is registered in ``sys.modules`` before ``exec_module`` so that
|
|
360
|
+
``dataclasses._is_type`` (which calls ``sys.modules.get(cls.__module__)``)
|
|
361
|
+
can resolve the module and ``typing.get_type_hints`` can resolve stringized
|
|
362
|
+
annotations to real type objects.
|
|
363
|
+
"""
|
|
364
|
+
path = Path(module_path).resolve()
|
|
365
|
+
file_stem = path.stem
|
|
366
|
+
|
|
367
|
+
# Normalise the file segment: strip extension, dots/underscores → hyphens (SPEC §5).
|
|
368
|
+
norm_file = _normalise_filename(path.name)
|
|
369
|
+
ns_raw = namespace or norm_file
|
|
370
|
+
namespace_seg = _seg(ns_raw)
|
|
371
|
+
|
|
372
|
+
# Dynamically import the module.
|
|
373
|
+
spec = importlib.util.spec_from_file_location("_apigen_target_", str(path))
|
|
374
|
+
if spec is None or spec.loader is None:
|
|
375
|
+
raise ImportError(f"Cannot load module from {path}")
|
|
376
|
+
mod = importlib.util.module_from_spec(spec)
|
|
377
|
+
|
|
378
|
+
# CANONICAL PATTERN (importlib docs): register in sys.modules BEFORE exec_module
|
|
379
|
+
# so that:
|
|
380
|
+
# 1. dataclasses._is_type() can resolve cls.__module__ via sys.modules — without
|
|
381
|
+
# this, @dataclass definitions crash with AttributeError when the module uses
|
|
382
|
+
# `from __future__ import annotations` (PEP 563).
|
|
383
|
+
# 2. typing.get_type_hints() can resolve string annotations back to real types
|
|
384
|
+
# (it needs the module's globals to be reachable via sys.modules).
|
|
385
|
+
sys.modules[spec.name] = mod
|
|
386
|
+
try:
|
|
387
|
+
spec.loader.exec_module(mod) # type: ignore[union-attr]
|
|
388
|
+
except Exception:
|
|
389
|
+
# On failure, remove the partial module so subsequent imports are not poisoned.
|
|
390
|
+
sys.modules.pop(spec.name, None)
|
|
391
|
+
raise
|
|
392
|
+
|
|
393
|
+
operations: list[dict[str, Any]] = []
|
|
394
|
+
|
|
395
|
+
# Collect public names (not starting with '_').
|
|
396
|
+
# Use __all__ if defined, otherwise all non-private names.
|
|
397
|
+
if hasattr(mod, "__all__"):
|
|
398
|
+
export_names: list[str] = list(mod.__all__)
|
|
399
|
+
else:
|
|
400
|
+
export_names = [
|
|
401
|
+
name for name in dir(mod)
|
|
402
|
+
if not name.startswith("_")
|
|
403
|
+
]
|
|
404
|
+
|
|
405
|
+
# For index files (stem == 'index' or 'main'), drop the file segment from path (SPEC §5).
|
|
406
|
+
include_file_seg = file_stem.lower() not in ("index", "main")
|
|
407
|
+
|
|
408
|
+
# Determine the module's __name__ as assigned by importlib — we use this to
|
|
409
|
+
# filter out names that were *imported into* the module (e.g. `from decimal
|
|
410
|
+
# import Decimal`) from names that were *defined in* the module. An imported
|
|
411
|
+
# callable's `__module__` attribute points to its defining package, not to
|
|
412
|
+
# `_apigen_target_`; a locally-defined function has `__module__ == mod.__name__`.
|
|
413
|
+
mod_name: str = mod.__name__ # '_apigen_target_'
|
|
414
|
+
|
|
415
|
+
for name in export_names:
|
|
416
|
+
try:
|
|
417
|
+
value = getattr(mod, name)
|
|
418
|
+
except AttributeError:
|
|
419
|
+
continue
|
|
420
|
+
|
|
421
|
+
# -----------------------------------------------------------------
|
|
422
|
+
# FIX: skip names that are imported from another module, not defined
|
|
423
|
+
# here. This prevents `from decimal import Decimal` or
|
|
424
|
+
# `from datetime import datetime` from being extracted as operations.
|
|
425
|
+
#
|
|
426
|
+
# Rule: a callable or type whose `__module__` differs from the loaded
|
|
427
|
+
# module's `__name__` is an *import* — skip it.
|
|
428
|
+
#
|
|
429
|
+
# Non-callables (module-level constants) have no `__module__`, so
|
|
430
|
+
# they bypass this check and fall through to the serialisability gate
|
|
431
|
+
# below (CONSTANT = 42 → kind='query').
|
|
432
|
+
#
|
|
433
|
+
# Exception: when the module explicitly declares `__all__`, trust the
|
|
434
|
+
# author's intent and do NOT filter by __module__ (they may re-export
|
|
435
|
+
# third-party callables deliberately).
|
|
436
|
+
# -----------------------------------------------------------------
|
|
437
|
+
if not hasattr(mod, "__all__"):
|
|
438
|
+
obj_module = getattr(value, "__module__", None)
|
|
439
|
+
if obj_module is not None and obj_module != mod_name:
|
|
440
|
+
# Imported callable or type — not defined in this module.
|
|
441
|
+
continue
|
|
442
|
+
|
|
443
|
+
# Build the §4 path.
|
|
444
|
+
if include_file_seg:
|
|
445
|
+
path_segs = [_seg(norm_file), _seg(name)]
|
|
446
|
+
else:
|
|
447
|
+
path_segs = [_seg(name)]
|
|
448
|
+
|
|
449
|
+
op_id = _derive_id(ns_raw, path_segs)
|
|
450
|
+
|
|
451
|
+
if callable(value) and not isinstance(value, type):
|
|
452
|
+
# --- action (callable export) ---
|
|
453
|
+
try:
|
|
454
|
+
sig = inspect.signature(value)
|
|
455
|
+
except (ValueError, TypeError):
|
|
456
|
+
sig = inspect.Signature()
|
|
457
|
+
|
|
458
|
+
streaming = _is_streaming(value)
|
|
459
|
+
is_async = _is_async(value)
|
|
460
|
+
# Pass `value` so PEP-563 stringized annotations are resolved via
|
|
461
|
+
# typing.get_type_hints before JSON-schema inference.
|
|
462
|
+
input_schema = _params_to_input_schema(sig, fn=value)
|
|
463
|
+
output_schema = _return_to_output_schema(sig, fn=value)
|
|
464
|
+
|
|
465
|
+
op: dict[str, Any] = {
|
|
466
|
+
"id": op_id,
|
|
467
|
+
"host": "python",
|
|
468
|
+
"namespace": namespace_seg,
|
|
469
|
+
"path": path_segs,
|
|
470
|
+
"kind": "action",
|
|
471
|
+
"async": is_async,
|
|
472
|
+
"streaming": streaming,
|
|
473
|
+
"safe": False,
|
|
474
|
+
"input": input_schema,
|
|
475
|
+
"output": output_schema,
|
|
476
|
+
"envelope": {},
|
|
477
|
+
"typeText": None,
|
|
478
|
+
}
|
|
479
|
+
operations.append(op)
|
|
480
|
+
|
|
481
|
+
elif isinstance(value, type):
|
|
482
|
+
# Class export → constructor + public instance methods (SPEC §10).
|
|
483
|
+
# Constructor.
|
|
484
|
+
try:
|
|
485
|
+
sig = inspect.signature(value.__init__)
|
|
486
|
+
except (ValueError, TypeError):
|
|
487
|
+
sig = inspect.Signature()
|
|
488
|
+
|
|
489
|
+
input_schema = _params_to_input_schema(sig, fn=value.__init__)
|
|
490
|
+
ctor_path = path_segs + [] if include_file_seg else path_segs
|
|
491
|
+
ctor_op: dict[str, Any] = {
|
|
492
|
+
"id": op_id,
|
|
493
|
+
"host": "python",
|
|
494
|
+
"namespace": namespace_seg,
|
|
495
|
+
"path": path_segs,
|
|
496
|
+
"kind": "constructor",
|
|
497
|
+
"async": False,
|
|
498
|
+
"streaming": False,
|
|
499
|
+
"safe": False,
|
|
500
|
+
"input": input_schema,
|
|
501
|
+
"output": {"type": "object", "properties": {"instanceId": {"type": "string"}}},
|
|
502
|
+
"envelope": {},
|
|
503
|
+
"typeText": None,
|
|
504
|
+
}
|
|
505
|
+
operations.append(ctor_op)
|
|
506
|
+
|
|
507
|
+
elif not callable(value):
|
|
508
|
+
# Serialisable const → query.
|
|
509
|
+
if _is_serialisable(value):
|
|
510
|
+
# Infer schema from value type.
|
|
511
|
+
val_type = type(value)
|
|
512
|
+
type_map = {str: "string", int: "integer", float: "number", bool: "boolean",
|
|
513
|
+
list: "array", dict: "object"}
|
|
514
|
+
json_type = type_map.get(val_type, "object")
|
|
515
|
+
output_schema = {"type": json_type}
|
|
516
|
+
|
|
517
|
+
query_op: dict[str, Any] = {
|
|
518
|
+
"id": op_id,
|
|
519
|
+
"host": "python",
|
|
520
|
+
"namespace": namespace_seg,
|
|
521
|
+
"path": path_segs,
|
|
522
|
+
"kind": "query",
|
|
523
|
+
"async": False,
|
|
524
|
+
"streaming": False,
|
|
525
|
+
"safe": True,
|
|
526
|
+
"input": {"type": "object", "properties": {}},
|
|
527
|
+
"output": output_schema,
|
|
528
|
+
"envelope": {},
|
|
529
|
+
"typeText": None,
|
|
530
|
+
}
|
|
531
|
+
operations.append(query_op)
|
|
532
|
+
else:
|
|
533
|
+
warnings.warn(
|
|
534
|
+
f"apigen-python extractor: skipping non-serialisable, non-callable export '{name}'",
|
|
535
|
+
stacklevel=2,
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
return operations
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
# ---------------------------------------------------------------------------
|
|
542
|
+
# CLI entry point (subprocess protocol: JSON on stdout)
|
|
543
|
+
# ---------------------------------------------------------------------------
|
|
544
|
+
|
|
545
|
+
def _main() -> None:
|
|
546
|
+
import argparse
|
|
547
|
+
|
|
548
|
+
parser = argparse.ArgumentParser(
|
|
549
|
+
description="Extract apigen Operation descriptors from a Python source file."
|
|
550
|
+
)
|
|
551
|
+
parser.add_argument("source", help="Path to the Python source file")
|
|
552
|
+
parser.add_argument(
|
|
553
|
+
"--namespace", "-n", default=None,
|
|
554
|
+
help="Namespace slug (default: normalised file stem)"
|
|
555
|
+
)
|
|
556
|
+
parser.add_argument(
|
|
557
|
+
"--emit-json", action="store_true", required=True,
|
|
558
|
+
help=(
|
|
559
|
+
"Required. Emit the extracted Operation[] as JSON on stdout. "
|
|
560
|
+
"This is the stable extract-only half of the apigen-serve-core "
|
|
561
|
+
"two-phase extract/serve protocol (see module docstring) — "
|
|
562
|
+
"required rather than implicit so this CLI's default behavior "
|
|
563
|
+
"is never silently relied upon by a caller that didn't ask for "
|
|
564
|
+
"it."
|
|
565
|
+
),
|
|
566
|
+
)
|
|
567
|
+
args = parser.parse_args()
|
|
568
|
+
|
|
569
|
+
try:
|
|
570
|
+
ops = extract_module(args.source, namespace=args.namespace)
|
|
571
|
+
print(json.dumps(ops, indent=2))
|
|
572
|
+
except Exception as exc:
|
|
573
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
574
|
+
sys.exit(1)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
if __name__ == "__main__":
|
|
578
|
+
_main()
|