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.
@@ -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
@@ -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)
@@ -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,7 @@
1
+ Metadata-Version: 2.5
2
+ Name: curupira-sdk
3
+ Version: 0.1.0.dev0
4
+ Summary: Declarative SDK for Curupira JSON-RPC process extensions
5
+ Requires-Python: >=3.11
6
+ Provides-Extra: pydantic
7
+ Requires-Dist: pydantic<3,>=2; extra == 'pydantic'
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any