curupira-sdk 0.1.0.dev0__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.dev0/.gitignore +190 -0
- curupira_sdk-0.1.0.dev0/PKG-INFO +7 -0
- curupira_sdk-0.1.0.dev0/pyproject.toml +22 -0
- curupira_sdk-0.1.0.dev0/src/curupira_sdk/__init__.py +25 -0
- curupira_sdk-0.1.0.dev0/src/curupira_sdk/app.py +180 -0
- curupira_sdk-0.1.0.dev0/src/curupira_sdk/errors.py +26 -0
- curupira_sdk-0.1.0.dev0/src/curupira_sdk/framing.py +39 -0
- curupira_sdk-0.1.0.dev0/src/curupira_sdk/runtime.py +39 -0
- curupira_sdk-0.1.0.dev0/src/curupira_sdk/serialize.py +128 -0
- curupira_sdk-0.1.0.dev0/src/curupira_sdk/server.py +107 -0
- curupira_sdk-0.1.0.dev0/src/curupira_sdk/signature.py +215 -0
- curupira_sdk-0.1.0.dev0/src/curupira_sdk/types.py +325 -0
- curupira_sdk-0.1.0.dev0/tests/test_curupira_sdk.py +305 -0
|
@@ -0,0 +1,190 @@
|
|
|
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
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Local curupira configuration and runtime state
|
|
40
|
+
/*-dispatch.toml
|
|
41
|
+
*.sqlite3
|
|
42
|
+
*.sqlite3-journal
|
|
43
|
+
workspaces/
|
|
44
|
+
|
|
45
|
+
# Unit test / coverage reports
|
|
46
|
+
htmlcov/
|
|
47
|
+
.tox/
|
|
48
|
+
.nox/
|
|
49
|
+
.coverage
|
|
50
|
+
.coverage.*
|
|
51
|
+
.cache
|
|
52
|
+
nosetests.xml
|
|
53
|
+
coverage.xml
|
|
54
|
+
*.cover
|
|
55
|
+
*.py,cover
|
|
56
|
+
.hypothesis/
|
|
57
|
+
.pytest_cache/
|
|
58
|
+
cover/
|
|
59
|
+
|
|
60
|
+
# Translations
|
|
61
|
+
*.mo
|
|
62
|
+
*.pot
|
|
63
|
+
|
|
64
|
+
# Django stuff:
|
|
65
|
+
*.log
|
|
66
|
+
local_settings.py
|
|
67
|
+
db.sqlite3
|
|
68
|
+
db.sqlite3-journal
|
|
69
|
+
|
|
70
|
+
# Flask stuff:
|
|
71
|
+
instance/
|
|
72
|
+
.webassets-cache
|
|
73
|
+
|
|
74
|
+
# Scrapy stuff:
|
|
75
|
+
.scrapy
|
|
76
|
+
|
|
77
|
+
# Sphinx documentation
|
|
78
|
+
docs/_build/
|
|
79
|
+
|
|
80
|
+
# PyBuilder
|
|
81
|
+
.pybuilder/
|
|
82
|
+
target/
|
|
83
|
+
|
|
84
|
+
# Jupyter Notebook
|
|
85
|
+
.ipynb_checkpoints
|
|
86
|
+
|
|
87
|
+
# IPython
|
|
88
|
+
profile_default/
|
|
89
|
+
ipython_config.py
|
|
90
|
+
|
|
91
|
+
# pyenv
|
|
92
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
93
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
94
|
+
# .python-version
|
|
95
|
+
|
|
96
|
+
# pipenv
|
|
97
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
98
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
99
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
100
|
+
# install all needed dependencies.
|
|
101
|
+
#Pipfile.lock
|
|
102
|
+
|
|
103
|
+
# UV
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
#uv.lock
|
|
108
|
+
|
|
109
|
+
# poetry
|
|
110
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
111
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
112
|
+
# commonly ignored for libraries.
|
|
113
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
114
|
+
#poetry.lock
|
|
115
|
+
|
|
116
|
+
# pdm
|
|
117
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
118
|
+
#pdm.lock
|
|
119
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
120
|
+
# in version control.
|
|
121
|
+
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
|
122
|
+
.pdm.toml
|
|
123
|
+
.pdm-python
|
|
124
|
+
.pdm-build/
|
|
125
|
+
|
|
126
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
127
|
+
__pypackages__/
|
|
128
|
+
|
|
129
|
+
# Celery stuff
|
|
130
|
+
celerybeat-schedule
|
|
131
|
+
celerybeat.pid
|
|
132
|
+
|
|
133
|
+
# SageMath parsed files
|
|
134
|
+
*.sage.py
|
|
135
|
+
|
|
136
|
+
# Environments
|
|
137
|
+
.env
|
|
138
|
+
.venv
|
|
139
|
+
env/
|
|
140
|
+
venv/
|
|
141
|
+
ENV/
|
|
142
|
+
env.bak/
|
|
143
|
+
venv.bak/
|
|
144
|
+
|
|
145
|
+
# Spyder project settings
|
|
146
|
+
.spyderproject
|
|
147
|
+
.spyproject
|
|
148
|
+
|
|
149
|
+
# Rope project settings
|
|
150
|
+
.ropeproject
|
|
151
|
+
|
|
152
|
+
# mkdocs documentation
|
|
153
|
+
/site
|
|
154
|
+
|
|
155
|
+
# mypy
|
|
156
|
+
.mypy_cache/
|
|
157
|
+
.dmypy.json
|
|
158
|
+
dmypy.json
|
|
159
|
+
|
|
160
|
+
# Pyre type checker
|
|
161
|
+
.pyre/
|
|
162
|
+
|
|
163
|
+
# pytype static type analyzer
|
|
164
|
+
.pytype/
|
|
165
|
+
|
|
166
|
+
# Cython debug symbols
|
|
167
|
+
cython_debug/
|
|
168
|
+
|
|
169
|
+
# PyCharm
|
|
170
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
171
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
172
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
173
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
174
|
+
#.idea/
|
|
175
|
+
|
|
176
|
+
# Ruff stuff:
|
|
177
|
+
.ruff_cache/
|
|
178
|
+
|
|
179
|
+
# Pyrefly stuff:
|
|
180
|
+
.pyrefly/
|
|
181
|
+
|
|
182
|
+
# PyPI configuration file
|
|
183
|
+
.pypirc
|
|
184
|
+
|
|
185
|
+
# Cursor
|
|
186
|
+
# Cursor is an AI-powered code editor.`.cursorignore` specifies files/directories to
|
|
187
|
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
|
188
|
+
# refer to https://docs.cursor.com/context/ignore-files
|
|
189
|
+
.cursorignore
|
|
190
|
+
.cursorindexingignore
|
|
@@ -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,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
|
|
@@ -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)
|