funcd-shim 0.2.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.
- funcd_shim/__init__.py +20 -0
- funcd_shim/__main__.py +10 -0
- funcd_shim/_poolworker.py +134 -0
- funcd_shim/blob.py +117 -0
- funcd_shim/build.py +318 -0
- funcd_shim/contract.py +89 -0
- funcd_shim/funclog.py +166 -0
- funcd_shim/invcontext.py +44 -0
- funcd_shim/invoke.py +51 -0
- funcd_shim/kv.py +107 -0
- funcd_shim/pool.py +182 -0
- funcd_shim/py.typed +0 -0
- funcd_shim/runtime.py +82 -0
- funcd_shim/shim.py +238 -0
- funcd_shim/tracespan.py +133 -0
- funcd_shim/types.py +76 -0
- funcd_shim-0.2.0.dist-info/METADATA +27 -0
- funcd_shim-0.2.0.dist-info/RECORD +20 -0
- funcd_shim-0.2.0.dist-info/WHEEL +4 -0
- funcd_shim-0.2.0.dist-info/entry_points.txt +2 -0
funcd_shim/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""funcd Python runtime shim + the typed authoring contract.
|
|
2
|
+
|
|
3
|
+
Function authors import the contract types::
|
|
4
|
+
|
|
5
|
+
from funcd_shim import Handler, CloudEvent, FunctionContext
|
|
6
|
+
|
|
7
|
+
def handle(context: FunctionContext, event: CloudEvent) -> dict:
|
|
8
|
+
...
|
|
9
|
+
|
|
10
|
+
The shim entrypoint is ``python -m funcd_shim`` (see :mod:`funcd_shim.shim`). It serves the
|
|
11
|
+
runtime-shim HTTP contract and validates ``event.data`` / the result against the optional
|
|
12
|
+
``FuncInput`` / ``FuncOutput`` **pydantic models** an artifact declares (ADR-0058, supersedes the
|
|
13
|
+
ADR-0038 JTD ``event_schema``). Requires pydantic at runtime.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from .types import CloudEvent, FunctionContext, Handler, Json
|
|
19
|
+
|
|
20
|
+
__all__ = ["CloudEvent", "FunctionContext", "Handler", "Json"]
|
funcd_shim/__main__.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Worker-side logic for the funcd Python pool host (ADR-0050), run INSIDE each subinterpreter by
|
|
2
|
+
``InterpreterPoolExecutor``. ``init`` loads the handler + the optional I/O validators once per worker
|
|
3
|
+
interpreter (state persists across invocations); ``invoke`` runs the contract + handler for one
|
|
4
|
+
request and returns a status-tagged envelope; ``ready`` is a side-effect-free load probe.
|
|
5
|
+
|
|
6
|
+
No ``concurrent.*`` here — plain per-interpreter Python. ``init``/``invoke``/``ready`` are referenced
|
|
7
|
+
by the executor across the interpreter boundary, so they live in this small importable module (the
|
|
8
|
+
host puts the package dir on ``PYTHONPATH`` so the worker can import ``funcd_shim``)."""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from .runtime import Validators
|
|
17
|
+
from .types import CloudEvent, Handler
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from .blob import BlobClient
|
|
21
|
+
from .kv import KVClient
|
|
22
|
+
|
|
23
|
+
# Per-interpreter state, set by init() and read by invoke() — isolated to this worker interpreter.
|
|
24
|
+
_handler: Handler | None = None
|
|
25
|
+
_validators: Validators = Validators()
|
|
26
|
+
_channel: Any = None # the shared telemetry channel (ADR-0101), opened once in init()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def init(src: str, artifact: str, handler: str, contract_path: str | None = None) -> None:
|
|
30
|
+
"""Load the handler + I/O validators into this interpreter (the materialization shape-gate,
|
|
31
|
+
ADR-0058/0123). Runs once per worker; a failure breaks the pool → exit 3.
|
|
32
|
+
|
|
33
|
+
ADR-0123: when *contract_path* is given, compile the validators from the delivered schema
|
|
34
|
+
(``fastjsonschema.compile``) **before** the untrusted handler module is imported — the bounded
|
|
35
|
+
eval-free reversal + the m3 reorder. A set-but-broken path fails the worker closed. When absent,
|
|
36
|
+
fall back to the module-baked ``__funcd_validate_*`` (transition back-compat)."""
|
|
37
|
+
global _handler, _validators, _channel
|
|
38
|
+
if src not in sys.path:
|
|
39
|
+
sys.path.insert(0, src)
|
|
40
|
+
from funcd_shim import contract, runtime
|
|
41
|
+
from funcd_shim.funclog import install_log_capture, open_channel
|
|
42
|
+
|
|
43
|
+
# Path B capture (ADR-0081) + traces (ADR-0101): each pool worker runs in its own subinterpreter
|
|
44
|
+
# with its own root logger, so open the channel + install capture here (per-interpreter), before
|
|
45
|
+
# the handler loads. One shared channel per worker. No-op unless FUNCD_LOG_FD/SOCK is set.
|
|
46
|
+
_channel = open_channel()
|
|
47
|
+
install_log_capture(_channel)
|
|
48
|
+
|
|
49
|
+
# ADR-0123: compile the delivered contract AHEAD of the handler import (m3 reorder).
|
|
50
|
+
delivered = contract.load_from_path(contract_path) if contract_path else None
|
|
51
|
+
module = runtime.load_module(artifact)
|
|
52
|
+
_handler = runtime.resolve_handler(module, handler)
|
|
53
|
+
_validators = delivered if delivered is not None else runtime.resolve_validators(module)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def ready() -> bool:
|
|
57
|
+
"""A load probe: True once init() succeeded (no handler call). The host submits this at startup
|
|
58
|
+
so a bad member surfaces as a broken pool before serving."""
|
|
59
|
+
return _handler is not None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _Ctx:
|
|
63
|
+
def log(self, *args: object) -> None:
|
|
64
|
+
print(*args, flush=True)
|
|
65
|
+
|
|
66
|
+
def invoke(self, alias: str, payload: Any) -> Any:
|
|
67
|
+
from .invoke import invoke as _invoke
|
|
68
|
+
|
|
69
|
+
return _invoke(alias, payload)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def kv(self) -> KVClient:
|
|
73
|
+
from .kv import KVClient
|
|
74
|
+
|
|
75
|
+
return KVClient()
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def blob(self) -> BlobClient:
|
|
79
|
+
from .blob import BlobClient
|
|
80
|
+
|
|
81
|
+
return BlobClient()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def invoke(
|
|
85
|
+
body: str,
|
|
86
|
+
traceparent: str | None = None,
|
|
87
|
+
fn_name: str = "invoke",
|
|
88
|
+
span_id: str | None = None,
|
|
89
|
+
links: list[str] | None = None,
|
|
90
|
+
) -> dict[str, Any]:
|
|
91
|
+
"""Run one request: parse → optional input validation → handler → optional output validation →
|
|
92
|
+
a status-tagged envelope the host maps to the HTTP response (identical to the solo shim). ADR-0101:
|
|
93
|
+
a successful-past-input-validation request emits a SERVER span on the worker's channel."""
|
|
94
|
+
if _handler is None: # defensive — init() always runs first
|
|
95
|
+
return {"status": 500, "body": {"error": "handler not loaded"}}
|
|
96
|
+
try:
|
|
97
|
+
event: CloudEvent[Any] = json.loads(body) if body else CloudEvent()
|
|
98
|
+
except (json.JSONDecodeError, ValueError):
|
|
99
|
+
return {"status": 400, "body": {"error": "request body is not valid JSON"}}
|
|
100
|
+
if not isinstance(event, dict):
|
|
101
|
+
# A valid-JSON but non-object body (null / array / scalar) is not a CloudEvent envelope.
|
|
102
|
+
# Reject it cleanly — never let `event.get("data")` raise AttributeError and crash the pooled
|
|
103
|
+
# worker (that surfaced as a gateway `proxy error: EOF` / empty-body 502).
|
|
104
|
+
return {
|
|
105
|
+
"status": 400,
|
|
106
|
+
"body": {"error": "request body must be a JSON object (CloudEvent envelope)"},
|
|
107
|
+
}
|
|
108
|
+
if _validators.input is not None:
|
|
109
|
+
errors = _validators.input(event.get("data"))
|
|
110
|
+
if errors:
|
|
111
|
+
# ADR-0101: input-mismatch short-circuits before the handler → no invocation, no span.
|
|
112
|
+
return {
|
|
113
|
+
"status": 422,
|
|
114
|
+
"body": {"error": "event data does not match the input contract", "details": errors},
|
|
115
|
+
}
|
|
116
|
+
from .tracespan import InvocationSpan
|
|
117
|
+
|
|
118
|
+
with InvocationSpan(_channel, fn_name, traceparent, span_id, links) as span:
|
|
119
|
+
try:
|
|
120
|
+
result = _handler(_Ctx(), event)
|
|
121
|
+
except Exception as err: # noqa: BLE001 - user handler errors become 500
|
|
122
|
+
span.fail(str(err))
|
|
123
|
+
return {"status": 500, "body": {"error": str(err)}}
|
|
124
|
+
if _validators.output is not None:
|
|
125
|
+
errors = _validators.output(result)
|
|
126
|
+
if errors:
|
|
127
|
+
span.fail("handler result does not match the output contract")
|
|
128
|
+
return {
|
|
129
|
+
"status": 500,
|
|
130
|
+
"body": {"error": "handler result does not match the output contract", "details": errors},
|
|
131
|
+
}
|
|
132
|
+
if result is None:
|
|
133
|
+
return {"status": 204}
|
|
134
|
+
return {"status": 200, "body": result}
|
funcd_shim/blob.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Function-facing blob over the worker-node local API (HTTP-over-UDS, ADR-0127).
|
|
2
|
+
|
|
3
|
+
Dials the same per-sandbox socket as ``context.kv``/``context.invoke`` (``FUNCD_INVOKE_SOCKET``); the
|
|
4
|
+
platform routes ``/blob/…`` to the binding-gated, PDP-authorized Facade with the sandbox's function
|
|
5
|
+
identity (bind-as-grant on ``spec.blob``). Stdlib-only — no boto3, no keypair. The blob twin of
|
|
6
|
+
``context.kv``; v1 is bytes-in-memory (streaming is a v2 follow-up).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import http.client
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import socket
|
|
15
|
+
from urllib.parse import quote
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _UnixHTTPConnection(http.client.HTTPConnection):
|
|
19
|
+
"""An HTTPConnection that dials a Unix domain socket instead of TCP."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, socket_path: str) -> None:
|
|
22
|
+
super().__init__("localhost")
|
|
23
|
+
self._socket_path = socket_path
|
|
24
|
+
|
|
25
|
+
def connect(self) -> None:
|
|
26
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
27
|
+
sock.connect(self._socket_path)
|
|
28
|
+
self.sock = sock
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _conn() -> _UnixHTTPConnection:
|
|
32
|
+
socket_path = os.environ.get("FUNCD_INVOKE_SOCKET")
|
|
33
|
+
if not socket_path:
|
|
34
|
+
raise RuntimeError(
|
|
35
|
+
"context.blob: worker-node local API socket unavailable (FUNCD_INVOKE_SOCKET unset)"
|
|
36
|
+
)
|
|
37
|
+
return _UnixHTTPConnection(socket_path)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _key_path(binding: str, key: str) -> str:
|
|
41
|
+
# key may be hierarchical ("a/b"); keep the "/" separators (the server's {key...} captures them).
|
|
42
|
+
segs = "/".join(quote(s, safe="") for s in key.split("/"))
|
|
43
|
+
return f"/blob/{quote(binding, safe='')}/{segs}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class BlobClient:
|
|
47
|
+
"""A function's binding-scoped blob storage (ADR-0127): get/put/delete/list a bound prefix's objects,
|
|
48
|
+
or mint a presigned URL — the blob twin of :class:`KVClient`."""
|
|
49
|
+
|
|
50
|
+
def get(self, binding: str, key: str) -> bytes | None:
|
|
51
|
+
conn = _conn()
|
|
52
|
+
try:
|
|
53
|
+
conn.request("GET", _key_path(binding, key))
|
|
54
|
+
resp = conn.getresponse()
|
|
55
|
+
data = resp.read()
|
|
56
|
+
if resp.status == 404:
|
|
57
|
+
return None
|
|
58
|
+
if not 200 <= resp.status < 300:
|
|
59
|
+
text = data.decode("utf-8", "replace")
|
|
60
|
+
raise RuntimeError(f"context.blob.get failed: {resp.status} {text}")
|
|
61
|
+
return data
|
|
62
|
+
finally:
|
|
63
|
+
conn.close()
|
|
64
|
+
|
|
65
|
+
def put(self, binding: str, key: str, data: bytes) -> None:
|
|
66
|
+
conn = _conn()
|
|
67
|
+
try:
|
|
68
|
+
conn.request("PUT", _key_path(binding, key), body=data)
|
|
69
|
+
resp = conn.getresponse()
|
|
70
|
+
text = resp.read().decode("utf-8", "replace")
|
|
71
|
+
if not 200 <= resp.status < 300:
|
|
72
|
+
raise RuntimeError(f"context.blob.put failed: {resp.status} {text}")
|
|
73
|
+
finally:
|
|
74
|
+
conn.close()
|
|
75
|
+
|
|
76
|
+
def delete(self, binding: str, key: str) -> None:
|
|
77
|
+
conn = _conn()
|
|
78
|
+
try:
|
|
79
|
+
conn.request("DELETE", _key_path(binding, key))
|
|
80
|
+
resp = conn.getresponse()
|
|
81
|
+
text = resp.read().decode("utf-8", "replace")
|
|
82
|
+
if not 200 <= resp.status < 300:
|
|
83
|
+
raise RuntimeError(f"context.blob.delete failed: {resp.status} {text}")
|
|
84
|
+
finally:
|
|
85
|
+
conn.close()
|
|
86
|
+
|
|
87
|
+
def list(self, binding: str, prefix: str = "") -> list[str]:
|
|
88
|
+
path = f"/blob/{quote(binding, safe='')}"
|
|
89
|
+
if prefix:
|
|
90
|
+
path += f"?prefix={quote(prefix, safe='')}"
|
|
91
|
+
conn = _conn()
|
|
92
|
+
try:
|
|
93
|
+
conn.request("GET", path)
|
|
94
|
+
resp = conn.getresponse()
|
|
95
|
+
text = resp.read().decode("utf-8")
|
|
96
|
+
if not 200 <= resp.status < 300:
|
|
97
|
+
raise RuntimeError(f"context.blob.list failed: {resp.status} {text}")
|
|
98
|
+
return json.loads(text) if text else []
|
|
99
|
+
finally:
|
|
100
|
+
conn.close()
|
|
101
|
+
|
|
102
|
+
def signed_url(self, binding: str, key: str, method: str = "GET", expiry: float | None = None) -> str:
|
|
103
|
+
"""Return a presigned external URL for the object. ``method`` is GET (read) / PUT / DELETE (write);
|
|
104
|
+
``expiry`` is in seconds (the driver's default when ``None``). A PUT/DELETE URL requires s3::write."""
|
|
105
|
+
path = f"{_key_path(binding, key)}?sign=1&method={quote(method, safe='')}"
|
|
106
|
+
if expiry is not None:
|
|
107
|
+
path += f"&expiry={expiry}s"
|
|
108
|
+
conn = _conn()
|
|
109
|
+
try:
|
|
110
|
+
conn.request("GET", path)
|
|
111
|
+
resp = conn.getresponse()
|
|
112
|
+
text = resp.read().decode("utf-8", "replace")
|
|
113
|
+
if not 200 <= resp.status < 300:
|
|
114
|
+
raise RuntimeError(f"context.blob.signed_url failed: {resp.status} {text}")
|
|
115
|
+
return text
|
|
116
|
+
finally:
|
|
117
|
+
conn.close()
|
funcd_shim/build.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""funcd_build — the BUILD-TIME contract compiler for Python artifacts (ADR-0058/0060).
|
|
2
|
+
|
|
3
|
+
Runs on the push box (pydantic available), **never** in the worker. For an author source declaring
|
|
4
|
+
``FuncInput`` / ``FuncOutput`` pydantic models, it:
|
|
5
|
+
|
|
6
|
+
1. loads the module, reads the models → **JSON Schema** (``pydantic.model_json_schema``);
|
|
7
|
+
2. compiles a precompiled validator from each schema (``fastjsonschema.compile_to_code``);
|
|
8
|
+
3. **AST-transforms** the source into the RUNTIME artifact: the ``pydantic`` imports + the
|
|
9
|
+
``FuncInput``/``FuncOutput`` classes are stripped, ``from __future__ import annotations`` is
|
|
10
|
+
ensured (so any leftover annotation referencing a removed class is a string, never evaluated),
|
|
11
|
+
and the precompiled ``__funcd_validate_*`` are injected.
|
|
12
|
+
|
|
13
|
+
Returns the runtime source + the schemas. The schemas feed ``funcdctl push --contract`` → the Go
|
|
14
|
+
profile gate (``internal/contract``); funcd compiled the validator FROM the gated schema, so the
|
|
15
|
+
runtime enforcement and the advertised schema share one source (the ADR-0060 integrity invariant).
|
|
16
|
+
This module is NOT embedded into the binary (see ``embed.go``) — it never reaches a worker.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import ast
|
|
22
|
+
import sys
|
|
23
|
+
import types
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Any, cast
|
|
26
|
+
|
|
27
|
+
import fastjsonschema
|
|
28
|
+
from pydantic import TypeAdapter
|
|
29
|
+
|
|
30
|
+
_CONTRACT = ("FuncInput", "FuncOutput")
|
|
31
|
+
|
|
32
|
+
_build_seq = 0 # makes each synthetic contract module's name unique (avoids pydantic's type cache)
|
|
33
|
+
|
|
34
|
+
# The canonical void side (ADR-0090): "takes/returns nothing" is the explicit JSON Schema
|
|
35
|
+
# {"type":"null"}, not an omission. Its validator is compiled FROM this schema through the same
|
|
36
|
+
# fastjsonschema path as every other side (a null-only check), so ADR-0060's "validator ≡ advertised
|
|
37
|
+
# schema" invariant holds uniformly — there is no hand-baked void validator.
|
|
38
|
+
_VOID_SCHEMA: dict[str, Any] = {"type": "null"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class BuildResult:
|
|
43
|
+
"""The runtime artifact source + the generated schemas (for the gate / OCI metadata).
|
|
44
|
+
|
|
45
|
+
Contracts are mandatory (ADR-0090): both ``input_schema`` and ``output_schema`` are always
|
|
46
|
+
present — a void side is ``{"type": "null"}``, never ``None``."""
|
|
47
|
+
|
|
48
|
+
runtime_source: str
|
|
49
|
+
input_schema: dict[str, Any]
|
|
50
|
+
output_schema: dict[str, Any]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build(source: str) -> BuildResult:
|
|
54
|
+
"""Compile an author source into its runtime artifact + I/O JSON Schemas (ADR-0058/0060)."""
|
|
55
|
+
global _build_seq
|
|
56
|
+
_build_seq += 1
|
|
57
|
+
# Exec into a REAL, sys.modules-registered module (not a bare dict) so the contract classes get a
|
|
58
|
+
# resolvable __module__ — pydantic then resolves string annotations (the project's default
|
|
59
|
+
# `from __future__ import annotations`, or any forward ref like `Json`/`Literal`) against this
|
|
60
|
+
# module's globals. A bare-dict exec leaves __module__ == "builtins", where those don't resolve.
|
|
61
|
+
name = f"_funcd_contract_{_build_seq}"
|
|
62
|
+
module = types.ModuleType(name)
|
|
63
|
+
sys.modules[name] = module
|
|
64
|
+
try:
|
|
65
|
+
exec(compile(source, "<funcd_function>", "exec"), module.__dict__) # noqa: S102 - trusted author source, build-time only
|
|
66
|
+
ns = module.__dict__
|
|
67
|
+
in_schema = _side_schema(ns, "FuncInput")
|
|
68
|
+
out_schema = _side_schema(ns, "FuncOutput")
|
|
69
|
+
finally:
|
|
70
|
+
sys.modules.pop(name, None)
|
|
71
|
+
|
|
72
|
+
# Every side — void included — is compiled to its validator FROM its emitted schema through the
|
|
73
|
+
# SAME fastjsonschema path (ADR-0090/0060 invariant): {"type": "null"} compiles to a null-only
|
|
74
|
+
# check, so there is no place where the advertised schema and the enforced validator diverge.
|
|
75
|
+
baked = [
|
|
76
|
+
_validator_source(in_schema, "__funcd_validate_input", "i"),
|
|
77
|
+
_validator_source(out_schema, "__funcd_validate_output", "o"),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
runtime = _strip_and_bake(source, "\n".join(baked))
|
|
81
|
+
return BuildResult(runtime, in_schema, out_schema)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _side_schema(ns: dict[str, Any], marker: str) -> dict[str, Any]:
|
|
85
|
+
"""Resolve one contract side (``FuncInput`` / ``FuncOutput``) to its mandatory JSON Schema
|
|
86
|
+
(ADR-0090). ``None`` is the explicit void marker → ``{"type": "null"}``; a declared type derives
|
|
87
|
+
its schema; an **undeclared** side is a build error (the "unchecked" path is gone — the author
|
|
88
|
+
must declare the type, ``None`` for void)."""
|
|
89
|
+
if marker not in ns:
|
|
90
|
+
raise ValueError(
|
|
91
|
+
f"{marker} is not declared — every function must declare an I/O contract "
|
|
92
|
+
f"(ADR-0090); use `{marker} = None` for a void side."
|
|
93
|
+
)
|
|
94
|
+
value = ns[marker]
|
|
95
|
+
if value is None:
|
|
96
|
+
return dict(_VOID_SCHEMA) # explicit void: {"type": "null"}
|
|
97
|
+
schema = _schema_of(value)
|
|
98
|
+
if schema is None:
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"{marker} does not resolve to a JSON Schema — declare a pydantic model, a TypedDict, "
|
|
101
|
+
f"a discriminated union, `Json`, or `None` for a void side."
|
|
102
|
+
)
|
|
103
|
+
return schema
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _schema_of(obj: Any) -> dict[str, Any] | None:
|
|
107
|
+
"""Generate the JSON Schema for any contract type — a pydantic ``BaseModel``, a ``TypedDict``
|
|
108
|
+
(the type-honest ``CloudEvent[FuncInput]`` DX), a discriminated/plain **union** of those, or the
|
|
109
|
+
``Json`` (any) form — via a single ``TypeAdapter``, then normalize it to the funcd profile:
|
|
110
|
+
inline ``$ref``/``$defs`` (the profile forbids ``$ref``), rewrite a tagged ``anyOf`` into the
|
|
111
|
+
gate's discriminated ``oneOf``, and close every record (pydantic emits them open). Returns None
|
|
112
|
+
when *obj* yields no schema (e.g. ``None``, the void marker)."""
|
|
113
|
+
if obj is None:
|
|
114
|
+
return None
|
|
115
|
+
try:
|
|
116
|
+
schema = TypeAdapter(obj).json_schema()
|
|
117
|
+
except Exception: # noqa: BLE001 - any un-adaptable type ⇒ "no schema" (treated as unvalidated)
|
|
118
|
+
return None
|
|
119
|
+
schema = _inline_refs(schema)
|
|
120
|
+
_discriminate(schema)
|
|
121
|
+
return _close_records(schema)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _inline_refs(schema: dict[str, Any]) -> dict[str, Any]:
|
|
125
|
+
"""Replace every ``{"$ref": "#/$defs/Name"}`` with the referenced definition inlined (the profile
|
|
126
|
+
forbids ``$ref``; pydantic emits one per nested model/union variant), then drop ``$defs``. A
|
|
127
|
+
reference cycle (a recursive type — forbidden by the profile) is left as-is so the gate rejects
|
|
128
|
+
it with a clear ``$ref`` error rather than looping here."""
|
|
129
|
+
defs: dict[str, Any] = {}
|
|
130
|
+
for key in ("$defs", "definitions"):
|
|
131
|
+
node = schema.get(key)
|
|
132
|
+
if isinstance(node, dict):
|
|
133
|
+
defs.update(node)
|
|
134
|
+
|
|
135
|
+
def resolve(node: Any, stack: frozenset[str]) -> Any:
|
|
136
|
+
if isinstance(node, list):
|
|
137
|
+
return [resolve(item, stack) for item in node]
|
|
138
|
+
if not isinstance(node, dict):
|
|
139
|
+
return node
|
|
140
|
+
ref = node.get("$ref")
|
|
141
|
+
if isinstance(ref, str) and ref.startswith("#/") and ref.split("/")[-1] in defs:
|
|
142
|
+
name = ref.split("/")[-1]
|
|
143
|
+
if name in stack:
|
|
144
|
+
return node # cycle ⇒ recursive type; leave the $ref for the gate to reject
|
|
145
|
+
target = resolve(defs[name], stack | {name})
|
|
146
|
+
siblings = {k: resolve(v, stack) for k, v in node.items() if k != "$ref"}
|
|
147
|
+
return {**target, **siblings} if siblings else target
|
|
148
|
+
return {k: resolve(v, stack) for k, v in node.items()}
|
|
149
|
+
|
|
150
|
+
body = {k: v for k, v in schema.items() if k not in ("$defs", "definitions")}
|
|
151
|
+
return cast("dict[str, Any]", resolve(body, frozenset()))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _discriminate(node: Any) -> None:
|
|
155
|
+
"""Rewrite every tagged ``anyOf`` into the profile's discriminated ``oneOf`` + ``discriminator``
|
|
156
|
+
(the gate accepts a union only as a tagged ``oneOf``; a plain ``A | B`` union is ``anyOf``).
|
|
157
|
+
Also drops pydantic's discriminator ``mapping`` (stale ``$ref`` strings the gate ignores)."""
|
|
158
|
+
if isinstance(node, list):
|
|
159
|
+
for item in node:
|
|
160
|
+
_discriminate(item)
|
|
161
|
+
return
|
|
162
|
+
if not isinstance(node, dict):
|
|
163
|
+
return
|
|
164
|
+
for value in node.values():
|
|
165
|
+
_discriminate(value)
|
|
166
|
+
branches = node.get("anyOf")
|
|
167
|
+
if isinstance(branches, list):
|
|
168
|
+
tag = _discriminator_tag(branches)
|
|
169
|
+
if tag:
|
|
170
|
+
node["oneOf"] = node.pop("anyOf")
|
|
171
|
+
node["discriminator"] = {"propertyName": tag}
|
|
172
|
+
disc = node.get("discriminator")
|
|
173
|
+
if isinstance(disc, dict):
|
|
174
|
+
disc.pop("mapping", None)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _discriminator_tag(branches: list[Any]) -> str | None:
|
|
178
|
+
"""The property that discriminates an ``anyOf``'s branches: present + required + single-valued
|
|
179
|
+
(``const`` or one-element ``enum``) in every branch, with distinct values. Else None."""
|
|
180
|
+
|
|
181
|
+
def is_record(b: Any) -> bool:
|
|
182
|
+
return isinstance(b, dict) and isinstance(b.get("properties"), dict)
|
|
183
|
+
|
|
184
|
+
if not branches or not all(is_record(b) for b in branches):
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
def literal(field: Any) -> tuple[bool, Any]:
|
|
188
|
+
if isinstance(field, dict):
|
|
189
|
+
if "const" in field:
|
|
190
|
+
return True, field["const"]
|
|
191
|
+
enum = field.get("enum")
|
|
192
|
+
if isinstance(enum, list) and len(enum) == 1:
|
|
193
|
+
return True, enum[0]
|
|
194
|
+
return False, None
|
|
195
|
+
|
|
196
|
+
for name in branches[0]["properties"]:
|
|
197
|
+
values: list[Any] = []
|
|
198
|
+
ok = True
|
|
199
|
+
for b in branches:
|
|
200
|
+
required = b.get("required")
|
|
201
|
+
single, value = literal(b["properties"].get(name))
|
|
202
|
+
if not single or not isinstance(required, list) or name not in required:
|
|
203
|
+
ok = False
|
|
204
|
+
break
|
|
205
|
+
values.append(value)
|
|
206
|
+
if ok and len({repr(v) for v in values}) == len(values):
|
|
207
|
+
return str(name)
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _close_records(node: dict[str, Any]) -> dict[str, Any]:
|
|
212
|
+
"""Set ``additionalProperties: false`` on every record (an object with ``properties`` that does
|
|
213
|
+
not already pin it) — the funcd profile forbids open records, but pydantic emits them open. A
|
|
214
|
+
typed map (``additionalProperties`` is a schema) and an already-closed record are left as-is.
|
|
215
|
+
Recurses through ``$defs``, ``properties``, ``items``, ``additionalProperties``, and unions."""
|
|
216
|
+
for defs in (node.get("$defs"), node.get("definitions")):
|
|
217
|
+
if isinstance(defs, dict):
|
|
218
|
+
for sub in defs.values():
|
|
219
|
+
if isinstance(sub, dict):
|
|
220
|
+
_close_records(sub)
|
|
221
|
+
for sub in (node.get("properties") or {}).values():
|
|
222
|
+
if isinstance(sub, dict):
|
|
223
|
+
_close_records(sub)
|
|
224
|
+
if isinstance(node.get("items"), dict):
|
|
225
|
+
_close_records(node["items"])
|
|
226
|
+
if isinstance(node.get("additionalProperties"), dict):
|
|
227
|
+
_close_records(node["additionalProperties"])
|
|
228
|
+
for key in ("oneOf", "anyOf", "allOf"):
|
|
229
|
+
for sub in node.get(key, []):
|
|
230
|
+
if isinstance(sub, dict):
|
|
231
|
+
_close_records(sub)
|
|
232
|
+
if node.get("type") == "object" and "properties" in node and "additionalProperties" not in node:
|
|
233
|
+
node["additionalProperties"] = False
|
|
234
|
+
return node
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _validator_source(schema: dict[str, Any], export: str, prefix: str) -> str:
|
|
238
|
+
"""fastjsonschema-compile *schema* and wrap it as ``export(d) -> list`` ([] ⇒ valid). The
|
|
239
|
+
generated functions are AST-renamed with a per-side prefix so baking input AND output never
|
|
240
|
+
collide on fastjsonschema's fixed ``validate`` name."""
|
|
241
|
+
tree = ast.parse(fastjsonschema.compile_to_code(schema))
|
|
242
|
+
names = {n.name for n in tree.body if isinstance(n, ast.FunctionDef)}
|
|
243
|
+
_Prefixer(names, f"_funcd_{prefix}_").visit(tree)
|
|
244
|
+
renamed = ast.unparse(ast.fix_missing_locations(tree))
|
|
245
|
+
wrapper = (
|
|
246
|
+
f"def {export}(d):\n"
|
|
247
|
+
f" try:\n"
|
|
248
|
+
f" _funcd_{prefix}_validate(d)\n"
|
|
249
|
+
f" return []\n"
|
|
250
|
+
f" except JsonSchemaValueException as e:\n"
|
|
251
|
+
f" return [str(e)]\n"
|
|
252
|
+
)
|
|
253
|
+
return renamed + "\n" + wrapper
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class _Prefixer(ast.NodeTransformer):
|
|
257
|
+
"""Renames a fixed set of top-level function names (and their references) with a prefix."""
|
|
258
|
+
|
|
259
|
+
def __init__(self, names: set[str], prefix: str) -> None:
|
|
260
|
+
self._names = names
|
|
261
|
+
self._prefix = prefix
|
|
262
|
+
|
|
263
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.FunctionDef:
|
|
264
|
+
if node.name in self._names:
|
|
265
|
+
node.name = self._prefix + node.name
|
|
266
|
+
self.generic_visit(node)
|
|
267
|
+
return node
|
|
268
|
+
|
|
269
|
+
def visit_Name(self, node: ast.Name) -> ast.Name:
|
|
270
|
+
if node.id in self._names:
|
|
271
|
+
node.id = self._prefix + node.id
|
|
272
|
+
return node
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _strip_and_bake(source: str, baked: str) -> str:
|
|
276
|
+
"""Remove the pydantic imports + the FuncInput/FuncOutput contract decls from *source*, ensure
|
|
277
|
+
`from __future__ import annotations` at the top, and inject the baked validators ahead of the
|
|
278
|
+
handler — producing a runtime artifact a subinterpreter can load with no pydantic."""
|
|
279
|
+
tree = ast.parse(source)
|
|
280
|
+
body: list[ast.stmt] = []
|
|
281
|
+
for i, node in enumerate(tree.body):
|
|
282
|
+
if isinstance(node, ast.ImportFrom) and node.module == "__future__":
|
|
283
|
+
continue # re-added at the very top
|
|
284
|
+
if _is_pydantic_import(node):
|
|
285
|
+
continue
|
|
286
|
+
if isinstance(node, ast.ClassDef) and node.name in _CONTRACT:
|
|
287
|
+
continue # contract declaration — build-time only
|
|
288
|
+
if isinstance(node, ast.Assign) and _targets_contract_only(node):
|
|
289
|
+
continue # e.g. `FuncOutput = None` (void marker)
|
|
290
|
+
if i == 0 and _is_module_docstring(node):
|
|
291
|
+
continue # drop the module docstring (a string after __future__ is harmless but pointless)
|
|
292
|
+
body.append(node)
|
|
293
|
+
|
|
294
|
+
future = ast.ImportFrom(module="__future__", names=[ast.alias(name="annotations")], level=0)
|
|
295
|
+
baked_nodes = ast.parse(baked).body if baked.strip() else []
|
|
296
|
+
final = ast.Module(body=[future, *baked_nodes, *body], type_ignores=[])
|
|
297
|
+
return ast.unparse(ast.fix_missing_locations(final))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _is_pydantic_import(node: ast.stmt) -> bool:
|
|
301
|
+
if isinstance(node, ast.Import):
|
|
302
|
+
return any(a.name.split(".")[0] == "pydantic" for a in node.names)
|
|
303
|
+
if isinstance(node, ast.ImportFrom):
|
|
304
|
+
return (node.module or "").split(".")[0] == "pydantic"
|
|
305
|
+
return False
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _targets_contract_only(node: ast.Assign) -> bool:
|
|
309
|
+
return all(isinstance(t, ast.Name) and t.id in _CONTRACT for t in node.targets)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _is_module_docstring(node: ast.stmt) -> bool:
|
|
313
|
+
if not isinstance(node, ast.Expr) or not isinstance(node.value, ast.Constant):
|
|
314
|
+
return False
|
|
315
|
+
return isinstance(node.value.value, str)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
__all__ = ["BuildResult", "build"]
|