curupira-sdk 0.1.0.dev0__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.
- curupira_sdk/__init__.py +25 -0
- curupira_sdk/app.py +180 -0
- curupira_sdk/errors.py +26 -0
- curupira_sdk/framing.py +39 -0
- curupira_sdk/runtime.py +39 -0
- curupira_sdk/serialize.py +128 -0
- curupira_sdk/server.py +107 -0
- curupira_sdk/signature.py +215 -0
- curupira_sdk/types.py +325 -0
- curupira_sdk-0.1.0.dev0.dist-info/METADATA +7 -0
- curupira_sdk-0.1.0.dev0.dist-info/RECORD +12 -0
- curupira_sdk-0.1.0.dev0.dist-info/WHEEL +4 -0
curupira_sdk/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Declarative SDK for Curupira process extensions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .app import Curupira
|
|
6
|
+
from .errors import RegistrationError, RpcError, SerializationError, ValidationError
|
|
7
|
+
from .framing import read_frame, write_frame
|
|
8
|
+
from .server import ExtensionServer, serve_stdio
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Curupira",
|
|
12
|
+
"ExtensionServer",
|
|
13
|
+
"RegistrationError",
|
|
14
|
+
"RpcError",
|
|
15
|
+
"SerializationError",
|
|
16
|
+
"ValidationError",
|
|
17
|
+
"read_frame",
|
|
18
|
+
"serve_stdio",
|
|
19
|
+
"write_frame",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
from importlib.metadata import version
|
|
23
|
+
|
|
24
|
+
# Read from the installed distribution, whose version comes from the release tag.
|
|
25
|
+
__version__ = version("curupira-sdk")
|
curupira_sdk/app.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Declarative Curupira extension application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any, TypeVar, overload
|
|
8
|
+
|
|
9
|
+
from .errors import RegistrationError, RpcError, SerializationError, ValidationError
|
|
10
|
+
from .runtime import invoke, shutdown_runtime
|
|
11
|
+
from .serialize import serialize_trigger_result
|
|
12
|
+
from .server import ExtensionServer, serve_stdio
|
|
13
|
+
from .signature import TriggerSpec, bind_arguments, build_trigger_spec, normalize_configuration
|
|
14
|
+
|
|
15
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Curupira:
|
|
19
|
+
"""Extension application that registers typed trigger functions."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
*,
|
|
24
|
+
name: str,
|
|
25
|
+
version: str,
|
|
26
|
+
agents: list[dict[str, Any]] | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.name = name
|
|
29
|
+
self.version = version
|
|
30
|
+
self.agents = agents or []
|
|
31
|
+
self._triggers: dict[str, TriggerSpec] = {}
|
|
32
|
+
self._agent_handlers: dict[str, Callable[[Any], Any]] = {}
|
|
33
|
+
self._lifecycle: dict[str, Callable[[Any], Any]] = {}
|
|
34
|
+
self._param_checks: dict[str, Callable[[dict[str, Any]], None]] = {}
|
|
35
|
+
|
|
36
|
+
@overload
|
|
37
|
+
def trigger(self, func: F) -> F: ...
|
|
38
|
+
|
|
39
|
+
@overload
|
|
40
|
+
def trigger(self, *, alias: str) -> Callable[[F], F]: ...
|
|
41
|
+
|
|
42
|
+
def trigger(
|
|
43
|
+
self, func: F | None = None, *, alias: str | None = None
|
|
44
|
+
) -> F | Callable[[F], F]:
|
|
45
|
+
"""Register ``func`` as a trigger.
|
|
46
|
+
|
|
47
|
+
Use ``@app.trigger`` with no parentheses to derive the local name from
|
|
48
|
+
``func.__name__`` in kebab-case (``github_issues`` → ``github-issues``).
|
|
49
|
+
Use ``@app.trigger(alias="issues")`` to override the local name.
|
|
50
|
+
|
|
51
|
+
The host addresses triggers as ``<plugins-alias>/<local-name>`` after the
|
|
52
|
+
extension is registered under ``[plugins.<alias>]`` in settings TOML.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def decorator(fn: F) -> F:
|
|
56
|
+
spec = build_trigger_spec(fn, name=alias)
|
|
57
|
+
if spec.name in self._triggers:
|
|
58
|
+
raise RegistrationError(f"duplicate trigger name {spec.name!r}")
|
|
59
|
+
self._triggers[spec.name] = spec
|
|
60
|
+
return fn
|
|
61
|
+
|
|
62
|
+
if func is not None:
|
|
63
|
+
if alias is not None:
|
|
64
|
+
raise RegistrationError("use @app.trigger(alias=...) as a decorator factory")
|
|
65
|
+
return decorator(func)
|
|
66
|
+
return decorator
|
|
67
|
+
|
|
68
|
+
def param_check(
|
|
69
|
+
self, trigger_name: str, fn: Callable[[dict[str, Any]], None]
|
|
70
|
+
) -> Callable[[dict[str, Any]], None]:
|
|
71
|
+
"""Register an extra argument check run during validate and poll."""
|
|
72
|
+
self._param_checks[trigger_name] = fn
|
|
73
|
+
return fn
|
|
74
|
+
|
|
75
|
+
def on(self, method: str, handler: Callable[[Any], Any]) -> None:
|
|
76
|
+
"""Register a low-level JSON-RPC handler (agent/lifecycle methods)."""
|
|
77
|
+
if method.startswith("agent/"):
|
|
78
|
+
self._agent_handlers[method] = handler
|
|
79
|
+
else:
|
|
80
|
+
self._lifecycle[method] = handler
|
|
81
|
+
|
|
82
|
+
def triggers(self) -> list[TriggerSpec]:
|
|
83
|
+
"""Return registered trigger specs in registration order."""
|
|
84
|
+
return list(self._triggers.values())
|
|
85
|
+
|
|
86
|
+
def run(self) -> None:
|
|
87
|
+
"""Serve the extension protocol on stdin/stdout until shutdown or EOF."""
|
|
88
|
+
server = ExtensionServer(
|
|
89
|
+
name=self.name,
|
|
90
|
+
version=self.version,
|
|
91
|
+
triggers=[
|
|
92
|
+
{"trigger_type": spec.name, "prompt_fields": list(spec.prompt_fields)}
|
|
93
|
+
for spec in self._triggers.values()
|
|
94
|
+
],
|
|
95
|
+
agents=self.agents,
|
|
96
|
+
)
|
|
97
|
+
server.on("trigger/schema", self._handle_schema)
|
|
98
|
+
server.on("trigger/validate", self._handle_validate)
|
|
99
|
+
server.on("trigger/poll", self._handle_poll)
|
|
100
|
+
server.on("trigger/onStarted", self._handle_lifecycle("trigger/onStarted"))
|
|
101
|
+
server.on("trigger/onFinished", self._handle_lifecycle("trigger/onFinished"))
|
|
102
|
+
for method, handler in self._agent_handlers.items():
|
|
103
|
+
server.on(method, handler)
|
|
104
|
+
for method, handler in self._lifecycle.items():
|
|
105
|
+
if method not in {"trigger/onStarted", "trigger/onFinished"}:
|
|
106
|
+
server.on(method, handler)
|
|
107
|
+
try:
|
|
108
|
+
serve_stdio(server)
|
|
109
|
+
finally:
|
|
110
|
+
shutdown_runtime()
|
|
111
|
+
|
|
112
|
+
def print_manifest(self, manifest_template: str) -> None:
|
|
113
|
+
"""Print a filled manifest template to stdout (for plugins install)."""
|
|
114
|
+
print(manifest_template.format(version=self.version, python=sys.executable))
|
|
115
|
+
|
|
116
|
+
def _get_spec(self, trigger_type: str) -> TriggerSpec:
|
|
117
|
+
# Host may send the namespaced form ``alias/local``; accept either.
|
|
118
|
+
local = trigger_type.rsplit("/", 1)[-1]
|
|
119
|
+
spec = self._triggers.get(local) or self._triggers.get(trigger_type)
|
|
120
|
+
if spec is None:
|
|
121
|
+
raise RpcError(-32003, f"unsupported trigger_type {trigger_type!r}")
|
|
122
|
+
return spec
|
|
123
|
+
|
|
124
|
+
def _handle_schema(self, params: Any) -> dict[str, Any]:
|
|
125
|
+
trigger_type = (params or {}).get("trigger_type")
|
|
126
|
+
if not isinstance(trigger_type, str):
|
|
127
|
+
raise RpcError(-32602, "trigger_type is required")
|
|
128
|
+
spec = self._get_spec(trigger_type)
|
|
129
|
+
return {"schema": spec.schema, "prompt_fields": list(spec.prompt_fields)}
|
|
130
|
+
|
|
131
|
+
def _handle_validate(self, params: Any) -> dict[str, Any]:
|
|
132
|
+
trigger_type = (params or {}).get("trigger_type")
|
|
133
|
+
configuration = dict((params or {}).get("configuration") or {})
|
|
134
|
+
if not isinstance(trigger_type, str):
|
|
135
|
+
raise RpcError(-32602, "trigger_type is required")
|
|
136
|
+
try:
|
|
137
|
+
spec = self._get_spec(trigger_type)
|
|
138
|
+
kwargs = bind_arguments(spec, configuration)
|
|
139
|
+
self._run_param_check(spec.name, kwargs)
|
|
140
|
+
normalized = normalize_configuration(spec, configuration)
|
|
141
|
+
except RpcError as exc:
|
|
142
|
+
return {"ok": False, "error": str(exc)}
|
|
143
|
+
except (ValidationError, RegistrationError) as exc:
|
|
144
|
+
return {"ok": False, "error": str(exc)}
|
|
145
|
+
return {"ok": True, "configuration": normalized}
|
|
146
|
+
|
|
147
|
+
def _run_param_check(self, trigger_name: str, kwargs: dict[str, Any]) -> None:
|
|
148
|
+
check = self._param_checks.get(trigger_name)
|
|
149
|
+
if check is not None:
|
|
150
|
+
check(kwargs)
|
|
151
|
+
|
|
152
|
+
def _handle_poll(self, params: Any) -> dict[str, Any]:
|
|
153
|
+
trigger_type = (params or {}).get("trigger_type")
|
|
154
|
+
automation = dict((params or {}).get("automation") or {})
|
|
155
|
+
if not isinstance(trigger_type, str):
|
|
156
|
+
raise RpcError(-32602, "trigger_type is required")
|
|
157
|
+
spec = self._get_spec(trigger_type)
|
|
158
|
+
try:
|
|
159
|
+
kwargs = bind_arguments(spec, automation)
|
|
160
|
+
self._run_param_check(spec.name, kwargs)
|
|
161
|
+
result = invoke(spec.func, kwargs, is_async=spec.is_async)
|
|
162
|
+
task = serialize_trigger_result(result, spec.return_annotation, trigger_name=spec.name)
|
|
163
|
+
except RpcError:
|
|
164
|
+
raise
|
|
165
|
+
except (ValidationError, SerializationError, RegistrationError) as exc:
|
|
166
|
+
raise RpcError(-32002, str(exc)) from exc
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
raise RpcError(-32002, f"execution error: {exc}") from exc
|
|
169
|
+
return {"task": task}
|
|
170
|
+
|
|
171
|
+
def _handle_lifecycle(self, method: str) -> Callable[[Any], Any]:
|
|
172
|
+
custom = self._lifecycle.get(method)
|
|
173
|
+
|
|
174
|
+
def handler(params: Any) -> dict[str, Any]:
|
|
175
|
+
if custom is not None:
|
|
176
|
+
result = custom(params)
|
|
177
|
+
return result if isinstance(result, dict) else {}
|
|
178
|
+
return {}
|
|
179
|
+
|
|
180
|
+
return handler
|
curupira_sdk/errors.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""SDK and JSON-RPC errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RegistrationError(Exception):
|
|
9
|
+
"""Raised when a trigger cannot be registered."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ValidationError(Exception):
|
|
13
|
+
"""Raised when configuration or arguments fail validation."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SerializationError(Exception):
|
|
17
|
+
"""Raised when a trigger return value cannot be serialized."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RpcError(Exception):
|
|
21
|
+
"""JSON-RPC error with numeric code."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, code: int, message: str, data: Any = None) -> None:
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.code = code
|
|
26
|
+
self.data = data
|
curupira_sdk/framing.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Length-prefixed JSON frames (8 hex digits + body)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import BinaryIO
|
|
7
|
+
|
|
8
|
+
DEFAULT_MAX_FRAME_BYTES = 16 * 1024 * 1024
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def write_frame(writer: BinaryIO, payload: bytes) -> None:
|
|
12
|
+
"""Write one frame to a binary writer (typically stdout.buffer)."""
|
|
13
|
+
if len(payload) > 0xFFFFFFFF:
|
|
14
|
+
raise ValueError("frame too large")
|
|
15
|
+
writer.write(f"{len(payload):08x}".encode("ascii"))
|
|
16
|
+
writer.write(payload)
|
|
17
|
+
writer.flush()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def read_frame(reader: BinaryIO, max_bytes: int = DEFAULT_MAX_FRAME_BYTES) -> bytes:
|
|
21
|
+
"""Read one frame from a binary reader (typically stdin.buffer)."""
|
|
22
|
+
header = reader.read(8)
|
|
23
|
+
if len(header) < 8:
|
|
24
|
+
raise EOFError("unexpected EOF while reading frame header")
|
|
25
|
+
try:
|
|
26
|
+
length = int(header.decode("ascii"), 16)
|
|
27
|
+
except ValueError as exc:
|
|
28
|
+
raise ValueError("invalid frame header") from exc
|
|
29
|
+
if length > max_bytes:
|
|
30
|
+
raise ValueError(f"frame length {length} exceeds limit {max_bytes}")
|
|
31
|
+
body = reader.read(length)
|
|
32
|
+
if len(body) < length:
|
|
33
|
+
raise EOFError("unexpected EOF while reading frame body")
|
|
34
|
+
return body
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def log(message: str) -> None:
|
|
38
|
+
"""Write a diagnostic line to stderr (never stdout)."""
|
|
39
|
+
print(message, file=sys.stderr, flush=True)
|
curupira_sdk/runtime.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Invoke synchronous and asynchronous trigger functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import concurrent.futures
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
_LOOP: asyncio.AbstractEventLoop | None = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_event_loop() -> asyncio.AbstractEventLoop:
|
|
13
|
+
"""Return a process-wide event loop for async triggers."""
|
|
14
|
+
global _LOOP
|
|
15
|
+
if _LOOP is None or _LOOP.is_closed():
|
|
16
|
+
_LOOP = asyncio.new_event_loop()
|
|
17
|
+
asyncio.set_event_loop(_LOOP)
|
|
18
|
+
return _LOOP
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def invoke(func: Any, kwargs: dict[str, Any], *, is_async: bool) -> Any:
|
|
22
|
+
"""Call a sync or async function and return its result."""
|
|
23
|
+
if not is_async:
|
|
24
|
+
return func(**kwargs)
|
|
25
|
+
|
|
26
|
+
loop = get_event_loop()
|
|
27
|
+
if loop.is_running():
|
|
28
|
+
# Nested call from within a running loop: use a worker thread with its own loop.
|
|
29
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
30
|
+
return pool.submit(asyncio.run, func(**kwargs)).result()
|
|
31
|
+
return loop.run_until_complete(func(**kwargs))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def shutdown_runtime() -> None:
|
|
35
|
+
"""Close the shared event loop if present."""
|
|
36
|
+
global _LOOP
|
|
37
|
+
if _LOOP is not None and not _LOOP.is_closed():
|
|
38
|
+
_LOOP.close()
|
|
39
|
+
_LOOP = None
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Validate and serialize a single trigger result or None."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import collections.abc
|
|
6
|
+
import dataclasses
|
|
7
|
+
import types
|
|
8
|
+
import typing
|
|
9
|
+
from typing import Any, get_origin
|
|
10
|
+
|
|
11
|
+
from .errors import SerializationError, ValidationError
|
|
12
|
+
from .types import dump_value, is_collection_type, unwrap_optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def serialize_trigger_result(
|
|
16
|
+
value: Any, return_annotation: Any, *, trigger_name: str
|
|
17
|
+
) -> dict[str, Any] | None:
|
|
18
|
+
"""Return a host task envelope, or None when the trigger has no item."""
|
|
19
|
+
if _is_multi_value(value):
|
|
20
|
+
raise SerializationError(
|
|
21
|
+
f"trigger {trigger_name!r}: must return a single item or None, not a collection"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if value is None:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
if is_collection_type(return_annotation):
|
|
28
|
+
raise SerializationError(
|
|
29
|
+
f"trigger {trigger_name!r}: return annotation must not be a collection"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if return_annotation is not typing.Any and return_annotation is not None:
|
|
33
|
+
_validate_against_annotation(value, return_annotation, trigger_name=trigger_name)
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
payload = dump_value(value)
|
|
37
|
+
except SerializationError as exc:
|
|
38
|
+
raise SerializationError(f"trigger {trigger_name!r}: {exc}") from exc
|
|
39
|
+
|
|
40
|
+
return envelope_from_payload(payload, trigger_name=trigger_name)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def envelope_from_payload(payload: Any, *, trigger_name: str) -> dict[str, Any]:
|
|
44
|
+
"""Build `{id, title, url, item}` for the Rust host."""
|
|
45
|
+
if isinstance(payload, (str, int, float, bool)):
|
|
46
|
+
text = str(payload)
|
|
47
|
+
return {"id": text, "title": text, "url": "", "item": {"value": payload}}
|
|
48
|
+
|
|
49
|
+
if not isinstance(payload, dict):
|
|
50
|
+
raise SerializationError(
|
|
51
|
+
f"trigger {trigger_name!r}: return value must serialize to an object or scalar"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
item = dict(payload)
|
|
55
|
+
task_id = item.get("id")
|
|
56
|
+
if task_id is None and "number" in item:
|
|
57
|
+
task_id = item["number"]
|
|
58
|
+
if task_id is None:
|
|
59
|
+
raise SerializationError(
|
|
60
|
+
f"trigger {trigger_name!r}: return object must include an 'id' field "
|
|
61
|
+
"(or 'number' for issue-like items)"
|
|
62
|
+
)
|
|
63
|
+
title = item.get("title") or item.get("name") or str(task_id)
|
|
64
|
+
url = item.get("url") or ""
|
|
65
|
+
return {
|
|
66
|
+
"id": str(task_id),
|
|
67
|
+
"title": str(title),
|
|
68
|
+
"url": str(url),
|
|
69
|
+
"item": item,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _is_multi_value(value: Any) -> bool:
|
|
74
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
75
|
+
return True
|
|
76
|
+
if isinstance(value, types.GeneratorType):
|
|
77
|
+
return True
|
|
78
|
+
return isinstance(value, collections.abc.Iterator) and not isinstance(
|
|
79
|
+
value, (str, bytes, dict, collections.abc.Mapping)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _validate_against_annotation(value: Any, annotation: Any, *, trigger_name: str) -> None:
|
|
84
|
+
from .types import coerce_value
|
|
85
|
+
|
|
86
|
+
inner, _allows_none = unwrap_optional(annotation)
|
|
87
|
+
origin = get_origin(inner)
|
|
88
|
+
|
|
89
|
+
if origin is list or origin is tuple or origin is set:
|
|
90
|
+
raise SerializationError(
|
|
91
|
+
f"trigger {trigger_name!r}: return annotation must not be a collection"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Scalars: isinstance check.
|
|
95
|
+
if inner in {str, int, float, bool}:
|
|
96
|
+
if not isinstance(value, inner) or (inner is int and isinstance(value, bool)):
|
|
97
|
+
raise SerializationError(
|
|
98
|
+
f"trigger {trigger_name!r}: return value does not match annotation {annotation!r}"
|
|
99
|
+
)
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
# Exact instance of the annotated class is already valid.
|
|
103
|
+
if isinstance(inner, type) and isinstance(value, inner):
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
# Structured types: coerce via schema (dict / dataclass / pydantic / TypedDict).
|
|
107
|
+
try:
|
|
108
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
109
|
+
candidate: Any = dump_value(value)
|
|
110
|
+
elif _is_pydantic_instance(value):
|
|
111
|
+
candidate = dump_value(value)
|
|
112
|
+
elif isinstance(value, dict):
|
|
113
|
+
candidate = value
|
|
114
|
+
else:
|
|
115
|
+
candidate = value
|
|
116
|
+
coerce_value(annotation, candidate, context=f"trigger {trigger_name!r} return")
|
|
117
|
+
except (ValidationError, SerializationError) as exc:
|
|
118
|
+
raise SerializationError(
|
|
119
|
+
f"trigger {trigger_name!r}: return value incompatible with annotation: {exc}"
|
|
120
|
+
) from exc
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _is_pydantic_instance(value: Any) -> bool:
|
|
124
|
+
try:
|
|
125
|
+
from pydantic import BaseModel
|
|
126
|
+
except ImportError:
|
|
127
|
+
return False
|
|
128
|
+
return isinstance(value, BaseModel)
|
curupira_sdk/server.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""JSON-RPC 2.0 extension server loop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
import traceback
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .errors import RpcError
|
|
12
|
+
from .framing import log, read_frame, write_frame
|
|
13
|
+
|
|
14
|
+
PROTOCOL_VERSION = 1
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ExtensionServer:
|
|
18
|
+
"""Dispatch initialize/describe/trigger/agent methods to callables."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
name: str,
|
|
24
|
+
version: str,
|
|
25
|
+
triggers: list[dict[str, Any]] | None = None,
|
|
26
|
+
agents: list[dict[str, Any]] | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.name = name
|
|
29
|
+
self.version = version
|
|
30
|
+
self.triggers = triggers or []
|
|
31
|
+
self.agents = agents or []
|
|
32
|
+
self._handlers: dict[str, Callable[[Any], Any]] = {
|
|
33
|
+
"initialize": self._initialize,
|
|
34
|
+
"describe": self._describe,
|
|
35
|
+
"shutdown": self._shutdown,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
def on(self, method: str, handler: Callable[[Any], Any]) -> None:
|
|
39
|
+
"""Register a JSON-RPC method handler."""
|
|
40
|
+
self._handlers[method] = handler
|
|
41
|
+
|
|
42
|
+
def _initialize(self, params: Any) -> dict[str, Any]:
|
|
43
|
+
peer = int((params or {}).get("protocol_version", 0))
|
|
44
|
+
if peer != PROTOCOL_VERSION:
|
|
45
|
+
raise RpcError(-32001, f"incompatible protocol version {peer}")
|
|
46
|
+
return {
|
|
47
|
+
"protocol_version": PROTOCOL_VERSION,
|
|
48
|
+
"server_info": {"name": self.name, "version": self.version},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
def _describe(self, _params: Any) -> dict[str, Any]:
|
|
52
|
+
return {"triggers": self.triggers, "agents": self.agents}
|
|
53
|
+
|
|
54
|
+
def _shutdown(self, _params: Any) -> dict[str, Any]:
|
|
55
|
+
return {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def serve_stdio(server: ExtensionServer) -> None:
|
|
59
|
+
"""Serve until EOF or shutdown."""
|
|
60
|
+
stdin = sys.stdin.buffer
|
|
61
|
+
stdout = sys.stdout.buffer
|
|
62
|
+
while True:
|
|
63
|
+
try:
|
|
64
|
+
raw = read_frame(stdin)
|
|
65
|
+
except EOFError:
|
|
66
|
+
return
|
|
67
|
+
except Exception as exc: # noqa: BLE001 — surface parse failures on the wire
|
|
68
|
+
_write_error(stdout, None, -32700, f"parse error: {exc}")
|
|
69
|
+
continue
|
|
70
|
+
try:
|
|
71
|
+
request = json.loads(raw.decode("utf-8"))
|
|
72
|
+
except json.JSONDecodeError as exc:
|
|
73
|
+
_write_error(stdout, None, -32700, f"invalid JSON: {exc}")
|
|
74
|
+
continue
|
|
75
|
+
req_id = request.get("id")
|
|
76
|
+
method = request.get("method")
|
|
77
|
+
params = request.get("params")
|
|
78
|
+
if request.get("jsonrpc") != "2.0" or not isinstance(method, str):
|
|
79
|
+
_write_error(stdout, req_id, -32600, "invalid request")
|
|
80
|
+
continue
|
|
81
|
+
handler = server._handlers.get(method)
|
|
82
|
+
if handler is None:
|
|
83
|
+
_write_error(stdout, req_id, -32601, f"method not found: {method}")
|
|
84
|
+
continue
|
|
85
|
+
try:
|
|
86
|
+
result = handler(params)
|
|
87
|
+
write_frame(
|
|
88
|
+
stdout,
|
|
89
|
+
json.dumps({"jsonrpc": "2.0", "id": req_id, "result": result}).encode("utf-8"),
|
|
90
|
+
)
|
|
91
|
+
if method == "shutdown":
|
|
92
|
+
return
|
|
93
|
+
except RpcError as exc:
|
|
94
|
+
_write_error(stdout, req_id, exc.code, str(exc), exc.data)
|
|
95
|
+
except Exception as exc: # noqa: BLE001
|
|
96
|
+
log(traceback.format_exc())
|
|
97
|
+
_write_error(stdout, req_id, -32002, f"execution error: {exc}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _write_error(stdout: Any, req_id: Any, code: int, message: str, data: Any = None) -> None:
|
|
101
|
+
error: dict[str, Any] = {"code": code, "message": message}
|
|
102
|
+
if data is not None:
|
|
103
|
+
error["data"] = data
|
|
104
|
+
write_frame(
|
|
105
|
+
stdout,
|
|
106
|
+
json.dumps({"jsonrpc": "2.0", "id": req_id, "error": error}).encode("utf-8"),
|
|
107
|
+
)
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Function signature introspection for trigger parameters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import typing
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, get_type_hints
|
|
9
|
+
|
|
10
|
+
from .errors import RegistrationError, ValidationError
|
|
11
|
+
from .types import coerce_value, is_collection_type, prompt_fields_for_return, type_to_json_schema
|
|
12
|
+
|
|
13
|
+
# Core automation fields owned by the Rust host — not function parameters.
|
|
14
|
+
CORE_CONFIG_KEYS = frozenset(
|
|
15
|
+
{
|
|
16
|
+
"trigger_type",
|
|
17
|
+
"repository",
|
|
18
|
+
"prompt",
|
|
19
|
+
"profile",
|
|
20
|
+
"checkout",
|
|
21
|
+
}
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class ParameterSpec:
|
|
27
|
+
name: str
|
|
28
|
+
annotation: Any
|
|
29
|
+
required: bool
|
|
30
|
+
default: Any
|
|
31
|
+
has_default: bool
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class TriggerSpec:
|
|
36
|
+
name: str
|
|
37
|
+
func: Any
|
|
38
|
+
is_async: bool
|
|
39
|
+
parameters: tuple[ParameterSpec, ...]
|
|
40
|
+
return_annotation: Any
|
|
41
|
+
prompt_fields: tuple[str, ...]
|
|
42
|
+
schema: dict[str, Any]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def to_kebab_case(name: str) -> str:
|
|
46
|
+
"""Convert a Python identifier to kebab-case (``github_issues`` → ``github-issues``)."""
|
|
47
|
+
if not name:
|
|
48
|
+
raise RegistrationError("trigger name must not be empty")
|
|
49
|
+
parts = [part for part in name.replace("-", "_").split("_") if part]
|
|
50
|
+
if not parts:
|
|
51
|
+
raise RegistrationError(f"trigger name {name!r} is not a valid identifier")
|
|
52
|
+
return "-".join(part.lower() for part in parts)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def validate_trigger_local_name(name: str) -> str:
|
|
56
|
+
"""Accept a kebab-case local trigger name (no slash; used inside an extension)."""
|
|
57
|
+
if not name:
|
|
58
|
+
raise RegistrationError("trigger name must not be empty")
|
|
59
|
+
if "/" in name:
|
|
60
|
+
raise RegistrationError(
|
|
61
|
+
f"trigger name {name!r} must not contain '/'; "
|
|
62
|
+
"the host prefixes the [plugins.<alias>] registration name"
|
|
63
|
+
)
|
|
64
|
+
if name.startswith("-") or name.endswith("-") or "--" in name:
|
|
65
|
+
raise RegistrationError(f"trigger name {name!r} is not valid kebab-case")
|
|
66
|
+
if not all(c.isalnum() or c == "-" for c in name):
|
|
67
|
+
raise RegistrationError(f"trigger name {name!r} is not valid kebab-case")
|
|
68
|
+
if not any(c.isalpha() for c in name):
|
|
69
|
+
raise RegistrationError(f"trigger name {name!r} is not valid kebab-case")
|
|
70
|
+
return name
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def build_trigger_spec(func: Any, *, name: str | None = None) -> TriggerSpec:
|
|
74
|
+
"""Introspect a trigger function without calling it.
|
|
75
|
+
|
|
76
|
+
When ``name`` is omitted, the local trigger name is the kebab-case form of
|
|
77
|
+
``func.__name__``. Pass ``name`` (or ``@app.trigger(alias=...)``) to override.
|
|
78
|
+
"""
|
|
79
|
+
if not callable(func):
|
|
80
|
+
raise RegistrationError("trigger must be a callable")
|
|
81
|
+
func_name = func.__name__
|
|
82
|
+
if not func_name.isidentifier():
|
|
83
|
+
raise RegistrationError(f"trigger function {func_name!r} is not a valid identifier")
|
|
84
|
+
local_name = validate_trigger_local_name(name if name is not None else to_kebab_case(func_name))
|
|
85
|
+
|
|
86
|
+
signature = inspect.signature(func)
|
|
87
|
+
try:
|
|
88
|
+
hints = get_type_hints(func, include_extras=True)
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
raise RegistrationError(f"trigger {local_name!r}: cannot resolve type hints: {exc}") from exc
|
|
91
|
+
|
|
92
|
+
parameters: list[ParameterSpec] = []
|
|
93
|
+
for param_name, param in signature.parameters.items():
|
|
94
|
+
if param.kind is inspect.Parameter.VAR_POSITIONAL:
|
|
95
|
+
raise RegistrationError(f"trigger {local_name!r}: *args is not supported")
|
|
96
|
+
if param.kind is inspect.Parameter.VAR_KEYWORD:
|
|
97
|
+
raise RegistrationError(f"trigger {local_name!r}: **kwargs is not supported")
|
|
98
|
+
if param.kind is inspect.Parameter.POSITIONAL_ONLY:
|
|
99
|
+
raise RegistrationError(
|
|
100
|
+
f"trigger {local_name!r}: positional-only parameter {param_name!r} "
|
|
101
|
+
"is not supported"
|
|
102
|
+
)
|
|
103
|
+
if param.kind not in (
|
|
104
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
105
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
106
|
+
):
|
|
107
|
+
raise RegistrationError(
|
|
108
|
+
f"trigger {local_name!r}: unsupported parameter kind for {param_name!r}"
|
|
109
|
+
)
|
|
110
|
+
if param_name in CORE_CONFIG_KEYS:
|
|
111
|
+
raise RegistrationError(
|
|
112
|
+
f"trigger {local_name!r}: parameter {param_name!r} conflicts with a core "
|
|
113
|
+
"automation field; use a different name (for example `repo`)"
|
|
114
|
+
)
|
|
115
|
+
if param.annotation is inspect.Parameter.empty:
|
|
116
|
+
raise RegistrationError(
|
|
117
|
+
f"trigger {local_name!r}: parameter {param_name!r} requires a type annotation"
|
|
118
|
+
)
|
|
119
|
+
annotation = hints.get(param_name, param.annotation)
|
|
120
|
+
context = f"trigger {local_name!r} parameter {param_name!r}"
|
|
121
|
+
# Ensure the type is representable (raises RegistrationError if not).
|
|
122
|
+
type_to_json_schema(annotation, context=context)
|
|
123
|
+
has_default = param.default is not inspect.Parameter.empty
|
|
124
|
+
parameters.append(
|
|
125
|
+
ParameterSpec(
|
|
126
|
+
name=param_name,
|
|
127
|
+
annotation=annotation,
|
|
128
|
+
required=not has_default,
|
|
129
|
+
default=param.default if has_default else None,
|
|
130
|
+
has_default=has_default,
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
return_annotation = hints.get("return", signature.return_annotation)
|
|
135
|
+
if return_annotation is inspect.Signature.empty:
|
|
136
|
+
return_annotation = typing.Any
|
|
137
|
+
if is_collection_type(return_annotation):
|
|
138
|
+
raise RegistrationError(
|
|
139
|
+
f"trigger {local_name!r}: return type must be a single item or None, "
|
|
140
|
+
"not a collection"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
schema = _parameters_schema(local_name, parameters)
|
|
144
|
+
prompt_fields = tuple(prompt_fields_for_return(return_annotation))
|
|
145
|
+
return TriggerSpec(
|
|
146
|
+
name=local_name,
|
|
147
|
+
func=func,
|
|
148
|
+
is_async=inspect.iscoroutinefunction(func),
|
|
149
|
+
parameters=tuple(parameters),
|
|
150
|
+
return_annotation=return_annotation,
|
|
151
|
+
prompt_fields=prompt_fields,
|
|
152
|
+
schema=schema,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _parameters_schema(trigger_name: str, parameters: list[ParameterSpec]) -> dict[str, Any]:
|
|
157
|
+
properties: dict[str, Any] = {}
|
|
158
|
+
required: list[str] = []
|
|
159
|
+
for param in parameters:
|
|
160
|
+
context = f"trigger {trigger_name!r} parameter {param.name!r}"
|
|
161
|
+
prop = type_to_json_schema(param.annotation, context=context)
|
|
162
|
+
if param.has_default:
|
|
163
|
+
from .errors import SerializationError
|
|
164
|
+
from .types import dump_value
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
prop = {**prop, "default": dump_value(param.default)}
|
|
168
|
+
except SerializationError:
|
|
169
|
+
pass
|
|
170
|
+
properties[param.name] = prop
|
|
171
|
+
if param.required:
|
|
172
|
+
required.append(param.name)
|
|
173
|
+
schema: dict[str, Any] = {
|
|
174
|
+
"type": "object",
|
|
175
|
+
"properties": properties,
|
|
176
|
+
"additionalProperties": False,
|
|
177
|
+
}
|
|
178
|
+
if required:
|
|
179
|
+
schema["required"] = required
|
|
180
|
+
return schema
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def bind_arguments(spec: TriggerSpec, configuration: dict[str, Any]) -> dict[str, Any]:
|
|
184
|
+
"""Extract and coerce function kwargs from an automation configuration."""
|
|
185
|
+
param_names = {p.name for p in spec.parameters}
|
|
186
|
+
extras = sorted(
|
|
187
|
+
key for key in configuration if key not in CORE_CONFIG_KEYS and key not in param_names
|
|
188
|
+
)
|
|
189
|
+
if extras:
|
|
190
|
+
raise ValidationError(f"trigger {spec.name!r}: unknown parameters {extras}")
|
|
191
|
+
|
|
192
|
+
kwargs: dict[str, Any] = {}
|
|
193
|
+
for param in spec.parameters:
|
|
194
|
+
if param.name in configuration:
|
|
195
|
+
kwargs[param.name] = coerce_value(
|
|
196
|
+
param.annotation,
|
|
197
|
+
configuration[param.name],
|
|
198
|
+
context=f"trigger {spec.name!r} parameter {param.name!r}",
|
|
199
|
+
)
|
|
200
|
+
elif param.has_default:
|
|
201
|
+
kwargs[param.name] = param.default
|
|
202
|
+
else:
|
|
203
|
+
raise ValidationError(
|
|
204
|
+
f"trigger {spec.name!r}: missing required parameter {param.name!r}"
|
|
205
|
+
)
|
|
206
|
+
return kwargs
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def normalize_configuration(spec: TriggerSpec, configuration: dict[str, Any]) -> dict[str, Any]:
|
|
210
|
+
"""Validate trigger params and return a full configuration with defaults applied."""
|
|
211
|
+
kwargs = bind_arguments(spec, configuration)
|
|
212
|
+
normalized = dict(configuration)
|
|
213
|
+
for param in spec.parameters:
|
|
214
|
+
normalized[param.name] = kwargs[param.name]
|
|
215
|
+
return normalized
|
curupira_sdk/types.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Type introspection helpers for JSON Schema and value conversion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import types
|
|
7
|
+
import typing
|
|
8
|
+
from typing import Any, get_args, get_origin
|
|
9
|
+
|
|
10
|
+
from .errors import RegistrationError, SerializationError, ValidationError
|
|
11
|
+
|
|
12
|
+
_SCALAR_SCHEMA: dict[type, dict[str, Any]] = {
|
|
13
|
+
str: {"type": "string"},
|
|
14
|
+
int: {"type": "integer"},
|
|
15
|
+
float: {"type": "number"},
|
|
16
|
+
bool: {"type": "boolean"},
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _is_typed_dict(annotation: Any) -> bool:
|
|
21
|
+
return isinstance(annotation, type) and typing.is_typeddict(annotation)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _is_pydantic_model(annotation: Any) -> bool:
|
|
25
|
+
try:
|
|
26
|
+
from pydantic import BaseModel
|
|
27
|
+
except ImportError:
|
|
28
|
+
return False
|
|
29
|
+
return isinstance(annotation, type) and issubclass(annotation, BaseModel)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _require_pydantic(context: str) -> Any:
|
|
33
|
+
try:
|
|
34
|
+
from pydantic import BaseModel
|
|
35
|
+
except ImportError as exc:
|
|
36
|
+
raise RegistrationError(
|
|
37
|
+
f"{context}: Pydantic models require the optional dependency "
|
|
38
|
+
"`curupira-sdk[pydantic]` (pydantic>=2,<3)"
|
|
39
|
+
) from exc
|
|
40
|
+
return BaseModel
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def unwrap_optional(annotation: Any) -> tuple[Any, bool]:
|
|
44
|
+
"""Return (inner_type, allows_none)."""
|
|
45
|
+
origin = get_origin(annotation)
|
|
46
|
+
args = get_args(annotation)
|
|
47
|
+
if origin is typing.Union or origin is types.UnionType:
|
|
48
|
+
non_none = [a for a in args if a is not type(None)]
|
|
49
|
+
allows_none = len(non_none) != len(args)
|
|
50
|
+
if len(non_none) == 1:
|
|
51
|
+
return non_none[0], allows_none
|
|
52
|
+
if allows_none:
|
|
53
|
+
combined = non_none[0]
|
|
54
|
+
for option in non_none[1:]:
|
|
55
|
+
combined |= option
|
|
56
|
+
return combined, True
|
|
57
|
+
return annotation, False
|
|
58
|
+
return annotation, False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def type_to_json_schema(annotation: Any, *, context: str) -> dict[str, Any]:
|
|
62
|
+
"""Convert a type annotation to a JSON Schema fragment."""
|
|
63
|
+
if annotation is typing.Any or annotation is None:
|
|
64
|
+
raise RegistrationError(f"{context}: untyped parameters are not supported")
|
|
65
|
+
|
|
66
|
+
inner, allows_none = unwrap_optional(annotation)
|
|
67
|
+
schema = _concrete_schema(inner, context=context)
|
|
68
|
+
if allows_none:
|
|
69
|
+
return {"anyOf": [schema, {"type": "null"}]}
|
|
70
|
+
return schema
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _concrete_schema(annotation: Any, *, context: str) -> dict[str, Any]:
|
|
74
|
+
origin = get_origin(annotation)
|
|
75
|
+
args = get_args(annotation)
|
|
76
|
+
|
|
77
|
+
if annotation is type(None):
|
|
78
|
+
return {"type": "null"}
|
|
79
|
+
|
|
80
|
+
if isinstance(annotation, type) and annotation in _SCALAR_SCHEMA:
|
|
81
|
+
return dict(_SCALAR_SCHEMA[annotation])
|
|
82
|
+
|
|
83
|
+
if origin is list or annotation is list:
|
|
84
|
+
if not args:
|
|
85
|
+
raise RegistrationError(f"{context}: list requires an item type annotation")
|
|
86
|
+
return {"type": "array", "items": type_to_json_schema(args[0], context=f"{context}[]")}
|
|
87
|
+
|
|
88
|
+
if origin is dict or annotation is dict:
|
|
89
|
+
if len(args) != 2 or args[0] is not str:
|
|
90
|
+
raise RegistrationError(f"{context}: only dict[str, T] is supported")
|
|
91
|
+
return {
|
|
92
|
+
"type": "object",
|
|
93
|
+
"additionalProperties": type_to_json_schema(args[1], context=f"{context}.value"),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if origin is typing.Union or origin is types.UnionType:
|
|
97
|
+
return {"anyOf": [type_to_json_schema(a, context=context) for a in args]}
|
|
98
|
+
|
|
99
|
+
if dataclasses.is_dataclass(annotation) and isinstance(annotation, type):
|
|
100
|
+
return _dataclass_schema(annotation, context=context)
|
|
101
|
+
|
|
102
|
+
if _is_typed_dict(annotation):
|
|
103
|
+
return _typed_dict_schema(annotation, context=context)
|
|
104
|
+
|
|
105
|
+
if _is_pydantic_model(annotation):
|
|
106
|
+
_require_pydantic(context)
|
|
107
|
+
return annotation.model_json_schema() # type: ignore[no-any-return]
|
|
108
|
+
|
|
109
|
+
# Detect pydantic-looking BaseModel subclasses when pydantic is missing.
|
|
110
|
+
if isinstance(annotation, type) and any(
|
|
111
|
+
base.__name__ == "BaseModel" for base in getattr(annotation, "__mro__", ())
|
|
112
|
+
):
|
|
113
|
+
_require_pydantic(context)
|
|
114
|
+
|
|
115
|
+
raise RegistrationError(
|
|
116
|
+
f"{context}: unsupported type {annotation!r}; use scalars, list[T], "
|
|
117
|
+
"dict[str, T], dataclass, TypedDict, or optional Pydantic models"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _dataclass_field_types(cls: type) -> dict[str, Any]:
|
|
122
|
+
try:
|
|
123
|
+
return typing.get_type_hints(cls)
|
|
124
|
+
except Exception: # noqa: BLE001
|
|
125
|
+
return {f.name: f.type for f in dataclasses.fields(cls)}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _dataclass_schema(cls: type, *, context: str) -> dict[str, Any]:
|
|
129
|
+
properties: dict[str, Any] = {}
|
|
130
|
+
required: list[str] = []
|
|
131
|
+
hints = _dataclass_field_types(cls)
|
|
132
|
+
for field in dataclasses.fields(cls):
|
|
133
|
+
annotation = hints.get(field.name, field.type)
|
|
134
|
+
properties[field.name] = type_to_json_schema(annotation, context=f"{context}.{field.name}")
|
|
135
|
+
if (
|
|
136
|
+
field.default is dataclasses.MISSING and field.default_factory is dataclasses.MISSING # type: ignore[comparison-overlap]
|
|
137
|
+
):
|
|
138
|
+
required.append(field.name)
|
|
139
|
+
elif field.default is not dataclasses.MISSING:
|
|
140
|
+
try:
|
|
141
|
+
properties[field.name] = {
|
|
142
|
+
**properties[field.name],
|
|
143
|
+
"default": _json_safe(field.default),
|
|
144
|
+
}
|
|
145
|
+
except SerializationError:
|
|
146
|
+
pass
|
|
147
|
+
schema: dict[str, Any] = {"type": "object", "properties": properties}
|
|
148
|
+
if required:
|
|
149
|
+
schema["required"] = required
|
|
150
|
+
schema["additionalProperties"] = False
|
|
151
|
+
return schema
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _typed_dict_schema(cls: type, *, context: str) -> dict[str, Any]:
|
|
155
|
+
hints = typing.get_type_hints(cls)
|
|
156
|
+
required_keys = set(getattr(cls, "__required_keys__", hints.keys()))
|
|
157
|
+
properties = {
|
|
158
|
+
name: type_to_json_schema(hint, context=f"{context}.{name}") for name, hint in hints.items()
|
|
159
|
+
}
|
|
160
|
+
schema: dict[str, Any] = {"type": "object", "properties": properties}
|
|
161
|
+
required = [name for name in hints if name in required_keys]
|
|
162
|
+
if required:
|
|
163
|
+
schema["required"] = required
|
|
164
|
+
schema["additionalProperties"] = False
|
|
165
|
+
return schema
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def coerce_value(annotation: Any, value: Any, *, context: str) -> Any:
|
|
169
|
+
"""Validate and coerce a JSON value against an annotation."""
|
|
170
|
+
inner, allows_none = unwrap_optional(annotation)
|
|
171
|
+
if value is None:
|
|
172
|
+
if allows_none:
|
|
173
|
+
return None
|
|
174
|
+
raise ValidationError(f"{context}: expected a value, got null")
|
|
175
|
+
return _coerce_concrete(inner, value, context=context)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _coerce_concrete(annotation: Any, value: Any, *, context: str) -> Any:
|
|
179
|
+
origin = get_origin(annotation)
|
|
180
|
+
args = get_args(annotation)
|
|
181
|
+
|
|
182
|
+
if annotation is str:
|
|
183
|
+
if not isinstance(value, str):
|
|
184
|
+
raise ValidationError(f"{context}: expected string")
|
|
185
|
+
return value
|
|
186
|
+
if annotation is int:
|
|
187
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
188
|
+
raise ValidationError(f"{context}: expected integer")
|
|
189
|
+
return value
|
|
190
|
+
if annotation is float:
|
|
191
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
192
|
+
raise ValidationError(f"{context}: expected number")
|
|
193
|
+
return float(value)
|
|
194
|
+
if annotation is bool:
|
|
195
|
+
if not isinstance(value, bool):
|
|
196
|
+
raise ValidationError(f"{context}: expected boolean")
|
|
197
|
+
return value
|
|
198
|
+
|
|
199
|
+
if origin is list:
|
|
200
|
+
if not isinstance(value, list):
|
|
201
|
+
raise ValidationError(f"{context}: expected array")
|
|
202
|
+
return [
|
|
203
|
+
coerce_value(args[0], item, context=f"{context}[{i}]") for i, item in enumerate(value)
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
if origin is dict:
|
|
207
|
+
if not isinstance(value, dict):
|
|
208
|
+
raise ValidationError(f"{context}: expected object")
|
|
209
|
+
return {
|
|
210
|
+
str(key): coerce_value(args[1], item, context=f"{context}.{key}")
|
|
211
|
+
for key, item in value.items()
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if origin is typing.Union or origin is types.UnionType:
|
|
215
|
+
errors: list[str] = []
|
|
216
|
+
for option in args:
|
|
217
|
+
if option is type(None):
|
|
218
|
+
continue
|
|
219
|
+
try:
|
|
220
|
+
return coerce_value(option, value, context=context)
|
|
221
|
+
except (ValidationError, RegistrationError) as exc:
|
|
222
|
+
errors.append(str(exc))
|
|
223
|
+
raise ValidationError(
|
|
224
|
+
f"{context}: value matched none of the union variants ({'; '.join(errors)})"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
if dataclasses.is_dataclass(annotation) and isinstance(annotation, type):
|
|
228
|
+
if not isinstance(value, dict):
|
|
229
|
+
raise ValidationError(f"{context}: expected object for dataclass")
|
|
230
|
+
kwargs: dict[str, Any] = {}
|
|
231
|
+
fields = {f.name: f for f in dataclasses.fields(annotation)}
|
|
232
|
+
hints = _dataclass_field_types(annotation)
|
|
233
|
+
unknown = set(value) - set(fields)
|
|
234
|
+
if unknown:
|
|
235
|
+
raise ValidationError(f"{context}: unknown fields {sorted(unknown)}")
|
|
236
|
+
for name, field in fields.items():
|
|
237
|
+
field_type = hints.get(name, field.type)
|
|
238
|
+
if name in value:
|
|
239
|
+
kwargs[name] = coerce_value(field_type, value[name], context=f"{context}.{name}")
|
|
240
|
+
elif field.default is not dataclasses.MISSING:
|
|
241
|
+
kwargs[name] = field.default
|
|
242
|
+
elif field.default_factory is not dataclasses.MISSING: # type: ignore[comparison-overlap]
|
|
243
|
+
kwargs[name] = field.default_factory() # type: ignore[misc]
|
|
244
|
+
else:
|
|
245
|
+
raise ValidationError(f"{context}: missing required field {name!r}")
|
|
246
|
+
return annotation(**kwargs)
|
|
247
|
+
|
|
248
|
+
if _is_typed_dict(annotation):
|
|
249
|
+
if not isinstance(value, dict):
|
|
250
|
+
raise ValidationError(f"{context}: expected object for TypedDict")
|
|
251
|
+
hints = typing.get_type_hints(annotation)
|
|
252
|
+
required_keys = set(getattr(annotation, "__required_keys__", hints.keys()))
|
|
253
|
+
unknown = set(value) - set(hints)
|
|
254
|
+
if unknown:
|
|
255
|
+
raise ValidationError(f"{context}: unknown fields {sorted(unknown)}")
|
|
256
|
+
out: dict[str, Any] = {}
|
|
257
|
+
for name, hint in hints.items():
|
|
258
|
+
if name in value:
|
|
259
|
+
out[name] = coerce_value(hint, value[name], context=f"{context}.{name}")
|
|
260
|
+
elif name in required_keys:
|
|
261
|
+
raise ValidationError(f"{context}: missing required field {name!r}")
|
|
262
|
+
return out
|
|
263
|
+
|
|
264
|
+
if _is_pydantic_model(annotation):
|
|
265
|
+
_require_pydantic(context)
|
|
266
|
+
try:
|
|
267
|
+
return annotation.model_validate(value) # type: ignore[no-any-return]
|
|
268
|
+
except Exception as exc:
|
|
269
|
+
raise ValidationError(f"{context}: {exc}") from exc
|
|
270
|
+
|
|
271
|
+
raise ValidationError(f"{context}: unsupported type {annotation!r}")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def dump_value(value: Any) -> Any:
|
|
275
|
+
"""Convert a Python value to a JSON-serializable structure."""
|
|
276
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
277
|
+
return value
|
|
278
|
+
if isinstance(value, list):
|
|
279
|
+
return [dump_value(item) for item in value]
|
|
280
|
+
if isinstance(value, dict):
|
|
281
|
+
return {str(key): dump_value(item) for key, item in value.items()}
|
|
282
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
283
|
+
return {f.name: dump_value(getattr(value, f.name)) for f in dataclasses.fields(value)}
|
|
284
|
+
if _is_pydantic_model(type(value)):
|
|
285
|
+
return value.model_dump(mode="json") # type: ignore[no-any-return]
|
|
286
|
+
raise SerializationError(f"value of type {type(value)!r} is not JSON-serializable")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _json_safe(value: Any) -> Any:
|
|
290
|
+
return dump_value(value)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def prompt_fields_for_return(annotation: Any) -> list[str]:
|
|
294
|
+
"""Derive prompt placeholder field names from a return annotation."""
|
|
295
|
+
if annotation is typing.Any or annotation is None or annotation is type(None):
|
|
296
|
+
return []
|
|
297
|
+
inner, _ = unwrap_optional(annotation)
|
|
298
|
+
if dataclasses.is_dataclass(inner) and isinstance(inner, type):
|
|
299
|
+
return [f.name for f in dataclasses.fields(inner)]
|
|
300
|
+
if _is_typed_dict(inner):
|
|
301
|
+
return list(typing.get_type_hints(inner))
|
|
302
|
+
if _is_pydantic_model(inner):
|
|
303
|
+
return list(inner.model_fields) # type: ignore[attr-defined]
|
|
304
|
+
if isinstance(inner, type) and inner in _SCALAR_SCHEMA:
|
|
305
|
+
return ["value"]
|
|
306
|
+
origin = get_origin(inner)
|
|
307
|
+
if origin is dict:
|
|
308
|
+
return []
|
|
309
|
+
return []
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def is_collection_type(annotation: Any) -> bool:
|
|
313
|
+
"""Return True when the annotation denotes a multi-value collection."""
|
|
314
|
+
if annotation is typing.Any or annotation is None:
|
|
315
|
+
return False
|
|
316
|
+
inner, _ = unwrap_optional(annotation)
|
|
317
|
+
origin = get_origin(inner) or inner
|
|
318
|
+
if origin in {list, tuple, set, frozenset}:
|
|
319
|
+
return True
|
|
320
|
+
return origin in {
|
|
321
|
+
typing.Iterable,
|
|
322
|
+
typing.Iterator,
|
|
323
|
+
typing.Generator,
|
|
324
|
+
typing.AsyncGenerator,
|
|
325
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
curupira_sdk/__init__.py,sha256=hxqiflm11Iq7H4CO68nik2ons3DOrhV2lOQecmtgErc,658
|
|
2
|
+
curupira_sdk/app.py,sha256=lQzZH7g3qCi58JCFEgyjUc9WiXjebAKVtfzvFOmzsvI,7460
|
|
3
|
+
curupira_sdk/errors.py,sha256=t300CJpMPk5H8F6_VuKDwuT9HO5C2AGikoa_MiiD27c,624
|
|
4
|
+
curupira_sdk/framing.py,sha256=GknokQAAzS08T1aCTpZEKkLbdN-B_PLbMLp09rzZ2rI,1299
|
|
5
|
+
curupira_sdk/runtime.py,sha256=pMIKIH5oSujntmMNJAfw7gxoUXUe9niSvrOtJQebal8,1202
|
|
6
|
+
curupira_sdk/serialize.py,sha256=9jP5ZZErwWDA6ASQnyuj2HzsM4pTHEfCBi-UkWendtA,4503
|
|
7
|
+
curupira_sdk/server.py,sha256=Tnms_nSADeEilO5rvRUVIe0osp7djwg5CJri6sgwBT4,3628
|
|
8
|
+
curupira_sdk/signature.py,sha256=BTDLMdIWxPncLWEia4plGgsulh0MDUnSyVifG9KNuYk,8222
|
|
9
|
+
curupira_sdk/types.py,sha256=lXxf0TthcQLoV6RqLSfaBn4vj3G44Qc6n4s3_KHcRLQ,12545
|
|
10
|
+
curupira_sdk-0.1.0.dev0.dist-info/METADATA,sha256=31HAhdSJzetpcxISpLXCE5H1MaHwKy-FKgYNpl5nS9w,227
|
|
11
|
+
curupira_sdk-0.1.0.dev0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
12
|
+
curupira_sdk-0.1.0.dev0.dist-info/RECORD,,
|