@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,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()