curupira-sdk 0.1.0__tar.gz

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,198 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ # The npm CLI wrapper lives in packages/curupira-npm/lib/. The rule above is the
20
+ # Python packaging ignore and would hide those sources.
21
+ !packages/curupira-npm/lib/
22
+ !packages/curupira-npm/lib/**
23
+ parts/
24
+ sdist/
25
+ var/
26
+ wheels/
27
+ share/python-wheels/
28
+ *.egg-info/
29
+ .installed.cfg
30
+ *.egg
31
+ MANIFEST
32
+
33
+ # PyInstaller
34
+ # Usually these files are written by a python script from a template
35
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
36
+ *.manifest
37
+ *.spec
38
+
39
+ # Installer logs
40
+ pip-log.txt
41
+ pip-delete-this-directory.txt
42
+
43
+ # Local curupira configuration and runtime state
44
+ /*-dispatch.toml
45
+ *.sqlite3
46
+ *.sqlite3-journal
47
+ workspaces/
48
+
49
+ # Unit test / coverage reports
50
+ htmlcov/
51
+ .tox/
52
+ .nox/
53
+ .coverage
54
+ .coverage.*
55
+ .cache
56
+ nosetests.xml
57
+ coverage.xml
58
+ *.cover
59
+ *.py,cover
60
+ .hypothesis/
61
+ .pytest_cache/
62
+ cover/
63
+
64
+ # Translations
65
+ *.mo
66
+ *.pot
67
+
68
+ # Django stuff:
69
+ *.log
70
+ local_settings.py
71
+ db.sqlite3
72
+ db.sqlite3-journal
73
+
74
+ # Flask stuff:
75
+ instance/
76
+ .webassets-cache
77
+
78
+ # Scrapy stuff:
79
+ .scrapy
80
+
81
+ # Sphinx documentation
82
+ docs/_build/
83
+
84
+ # PyBuilder
85
+ .pybuilder/
86
+ target/
87
+
88
+ # Jupyter Notebook
89
+ .ipynb_checkpoints
90
+
91
+ # IPython
92
+ profile_default/
93
+ ipython_config.py
94
+
95
+ # pyenv
96
+ # For a library or package, you might want to ignore these files since the code is
97
+ # intended to run in multiple environments; otherwise, check them in:
98
+ # .python-version
99
+
100
+ # pipenv
101
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
102
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
103
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
104
+ # install all needed dependencies.
105
+ #Pipfile.lock
106
+
107
+ # UV
108
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
109
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
110
+ # commonly ignored for libraries.
111
+ #uv.lock
112
+
113
+ # poetry
114
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
115
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
116
+ # commonly ignored for libraries.
117
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
118
+ #poetry.lock
119
+
120
+ # pdm
121
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
122
+ #pdm.lock
123
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
124
+ # in version control.
125
+ # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
126
+ .pdm.toml
127
+ .pdm-python
128
+ .pdm-build/
129
+
130
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
131
+ __pypackages__/
132
+
133
+ # Celery stuff
134
+ celerybeat-schedule
135
+ celerybeat.pid
136
+
137
+ # SageMath parsed files
138
+ *.sage.py
139
+
140
+ # Environments
141
+ .env
142
+ .venv
143
+ env/
144
+ venv/
145
+ ENV/
146
+ env.bak/
147
+ venv.bak/
148
+
149
+ # Spyder project settings
150
+ .spyderproject
151
+ .spyproject
152
+
153
+ # Rope project settings
154
+ .ropeproject
155
+
156
+ # mkdocs documentation
157
+ /site
158
+
159
+ # mypy
160
+ .mypy_cache/
161
+ .dmypy.json
162
+ dmypy.json
163
+
164
+ # Pyre type checker
165
+ .pyre/
166
+
167
+ # pytype static type analyzer
168
+ .pytype/
169
+
170
+ # Cython debug symbols
171
+ cython_debug/
172
+
173
+ # PyCharm
174
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
175
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
176
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
177
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
178
+ #.idea/
179
+
180
+ # Ruff stuff:
181
+ .ruff_cache/
182
+
183
+ # Pyrefly stuff:
184
+ .pyrefly/
185
+
186
+ # PyPI configuration file
187
+ .pypirc
188
+
189
+ # Cursor
190
+ # Cursor is an AI-powered code editor.`.cursorignore` specifies files/directories to
191
+ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
192
+ # refer to https://docs.cursor.com/context/ignore-files
193
+ .cursorignore
194
+ .cursorindexingignore
195
+
196
+ # Node.js (JS/TS SDK and fixtures)
197
+ node_modules/
198
+
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.5
2
+ Name: curupira-sdk
3
+ Version: 0.1.0
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,22 @@
1
+ [project]
2
+ name = "curupira-sdk"
3
+ dynamic = ["version"]
4
+ description = "Declarative SDK for Curupira JSON-RPC process extensions"
5
+ requires-python = ">=3.11"
6
+ dependencies = []
7
+
8
+ [project.optional-dependencies]
9
+ pydantic = ["pydantic>=2,<3"]
10
+
11
+ [build-system]
12
+ requires = ["hatchling>=1.32.4,<2", "hatch-vcs>=0.5,<1"]
13
+ build-backend = "hatchling.build"
14
+
15
+ # The version is the publish/curupira-sdk/<version> git tag. Untagged builds get a
16
+ # development version derived from the nearest tag of this package only.
17
+ [tool.hatch.version]
18
+ source = "vcs"
19
+ raw-options = { root = "../..", tag_regex = "^publish/curupira-sdk/(?P<version>.+)$", git_describe_command = "git describe --tags --long --match publish/curupira-sdk/*", fallback_version = "0.1.0.dev0" }
20
+
21
+ [tool.hatch.build.targets.wheel]
22
+ packages = ["src/curupira_sdk"]
@@ -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")
@@ -0,0 +1,182 @@
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
+ values = serialize_trigger_result(
163
+ result, spec.return_annotation, trigger_name=spec.name
164
+ )
165
+ except RpcError:
166
+ raise
167
+ except (ValidationError, SerializationError, RegistrationError) as exc:
168
+ raise RpcError(-32002, str(exc)) from exc
169
+ except Exception as exc:
170
+ raise RpcError(-32002, f"execution error: {exc}") from exc
171
+ return {"values": values}
172
+
173
+ def _handle_lifecycle(self, method: str) -> Callable[[Any], Any]:
174
+ custom = self._lifecycle.get(method)
175
+
176
+ def handler(params: Any) -> dict[str, Any]:
177
+ if custom is not None:
178
+ result = custom(params)
179
+ return result if isinstance(result, dict) else {}
180
+ return {}
181
+
182
+ return handler
@@ -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