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.
- curupira_sdk-0.1.0/.gitignore +198 -0
- curupira_sdk-0.1.0/PKG-INFO +7 -0
- curupira_sdk-0.1.0/pyproject.toml +22 -0
- curupira_sdk-0.1.0/src/curupira_sdk/__init__.py +25 -0
- curupira_sdk-0.1.0/src/curupira_sdk/app.py +182 -0
- curupira_sdk-0.1.0/src/curupira_sdk/errors.py +26 -0
- curupira_sdk-0.1.0/src/curupira_sdk/framing.py +39 -0
- curupira_sdk-0.1.0/src/curupira_sdk/runtime.py +39 -0
- curupira_sdk-0.1.0/src/curupira_sdk/serialize.py +224 -0
- curupira_sdk-0.1.0/src/curupira_sdk/server.py +107 -0
- curupira_sdk-0.1.0/src/curupira_sdk/signature.py +215 -0
- curupira_sdk-0.1.0/src/curupira_sdk/types.py +323 -0
- curupira_sdk-0.1.0/tests/test_curupira_sdk.py +421 -0
|
@@ -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,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
|