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.
@@ -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,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,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)