lumera 0.27.0__tar.gz → 0.28.0.dev1__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.
- {lumera-0.27.0 → lumera-0.28.0.dev1}/PKG-INFO +2 -2
- {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/__init__.py +6 -1
- lumera-0.28.0.dev1/lumera/functions/__init__.py +55 -0
- lumera-0.28.0.dev1/lumera/functions/__main__.py +5 -0
- lumera-0.28.0.dev1/lumera/functions/_bundle.py +194 -0
- lumera-0.28.0.dev1/lumera/functions/_core.py +382 -0
- lumera-0.28.0.dev1/lumera/functions/_runner.py +163 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera.egg-info/PKG-INFO +2 -2
- {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera.egg-info/SOURCES.txt +6 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/pyproject.toml +2 -2
- lumera-0.28.0.dev1/tests/test_functions.py +528 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/_live.py +0 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/_utils.py +0 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/agents.py +0 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/automations.py +0 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/email.py +0 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/exceptions.py +0 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/files.py +0 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/flags.py +0 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/google.py +0 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/integrations/__init__.py +0 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/integrations/bill.py +0 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/integrations/google.py +0 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/integrations/netsuite.py +0 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/integrations/salesforce.py +0 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/integrations/slack.py +0 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/llm.py +0 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/pb.py +0 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/sdk.py +0 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/storage.py +0 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/webhooks.py +0 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera.egg-info/dependency_links.txt +0 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera.egg-info/requires.txt +1 -1
- {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera.egg-info/top_level.txt +0 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/setup.cfg +0 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/tests/test_agents_live.py +0 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/tests/test_automations.py +0 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/tests/test_flags.py +0 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/tests/test_integrations_netsuite.py +0 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/tests/test_integrations_salesforce.py +0 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/tests/test_sdk.py +0 -0
- {lumera-0.27.0 → lumera-0.28.0.dev1}/tests/test_webhooks.py +0 -0
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lumera
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.28.0.dev1
|
|
4
4
|
Summary: SDK for building on Lumera platform
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Requires-Dist: requests
|
|
7
7
|
Requires-Dist: python-dotenv
|
|
8
8
|
Requires-Dist: google-api-python-client==2.173.0
|
|
9
|
+
Requires-Dist: pydantic<3.0.0,>=2.12.3
|
|
9
10
|
Requires-Dist: simple-salesforce==1.12.6
|
|
10
11
|
Requires-Dist: slack_sdk==3.35.0
|
|
11
12
|
Provides-Extra: dev
|
|
@@ -28,7 +29,6 @@ Requires-Dist: openai-agents<1.0.0,>=0.6.5; extra == "full"
|
|
|
28
29
|
Requires-Dist: openpyxl==3.1.5; extra == "full"
|
|
29
30
|
Requires-Dist: pandas==2.3.0; extra == "full"
|
|
30
31
|
Requires-Dist: pdfplumber; extra == "full"
|
|
31
|
-
Requires-Dist: pydantic<3.0.0,>=2.12.3; extra == "full"
|
|
32
32
|
Requires-Dist: PyGithub==2.6.1; extra == "full"
|
|
33
33
|
Requires-Dist: python-dotenv==1.1.0; extra == "full"
|
|
34
34
|
Requires-Dist: pyzmq; extra == "full"
|
|
@@ -18,6 +18,7 @@ from . import (
|
|
|
18
18
|
automations,
|
|
19
19
|
email,
|
|
20
20
|
exceptions,
|
|
21
|
+
functions,
|
|
21
22
|
integrations,
|
|
22
23
|
llm,
|
|
23
24
|
pb,
|
|
@@ -48,6 +49,7 @@ from .exceptions import (
|
|
|
48
49
|
# Import file types for Pydantic automation inputs
|
|
49
50
|
from .files import LumeraFile, LumeraFiles
|
|
50
51
|
from .flags import feature_flag, feature_flags, is_feature_enabled
|
|
52
|
+
from .functions import function
|
|
51
53
|
|
|
52
54
|
# Import key SDK helpers to expose them at the package root.
|
|
53
55
|
from .sdk import (
|
|
@@ -63,11 +65,11 @@ from .sdk import (
|
|
|
63
65
|
get_record_by_external_id,
|
|
64
66
|
list_collections,
|
|
65
67
|
list_records,
|
|
66
|
-
search_records,
|
|
67
68
|
query_sql,
|
|
68
69
|
replay_hook,
|
|
69
70
|
run_automation,
|
|
70
71
|
save_to_lumera,
|
|
72
|
+
search_records,
|
|
71
73
|
update_automation_run,
|
|
72
74
|
update_collection,
|
|
73
75
|
update_record,
|
|
@@ -89,6 +91,8 @@ __all__ = [
|
|
|
89
91
|
"feature_flag",
|
|
90
92
|
"feature_flags",
|
|
91
93
|
"is_feature_enabled",
|
|
94
|
+
# Callable Function authoring
|
|
95
|
+
"function",
|
|
92
96
|
# Collections (low-level API)
|
|
93
97
|
"list_collections",
|
|
94
98
|
"get_collection",
|
|
@@ -133,6 +137,7 @@ __all__ = [
|
|
|
133
137
|
"storage",
|
|
134
138
|
"llm",
|
|
135
139
|
"exceptions",
|
|
140
|
+
"functions",
|
|
136
141
|
"webhooks",
|
|
137
142
|
"integrations",
|
|
138
143
|
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Define, discover, inspect, and locally invoke typed Lumera Functions."""
|
|
2
|
+
|
|
3
|
+
from ._bundle import (
|
|
4
|
+
FUNCTION_BUNDLE_FORMAT_VERSION,
|
|
5
|
+
FUNCTION_BUNDLE_RUNTIME,
|
|
6
|
+
FunctionBundle,
|
|
7
|
+
build_bundle,
|
|
8
|
+
)
|
|
9
|
+
from ._core import (
|
|
10
|
+
FUNCTION_MANIFEST_SCHEMA_VERSION,
|
|
11
|
+
DuplicateFunctionError,
|
|
12
|
+
FunctionConfirmation,
|
|
13
|
+
FunctionDefinition,
|
|
14
|
+
FunctionDefinitionError,
|
|
15
|
+
FunctionDiscoveryError,
|
|
16
|
+
FunctionEffect,
|
|
17
|
+
FunctionError,
|
|
18
|
+
FunctionExecutionError,
|
|
19
|
+
FunctionMetadata,
|
|
20
|
+
FunctionNotFoundError,
|
|
21
|
+
FunctionOutputSerializationError,
|
|
22
|
+
FunctionValidationError,
|
|
23
|
+
build_manifest,
|
|
24
|
+
discover_modules,
|
|
25
|
+
function,
|
|
26
|
+
get_function_definition,
|
|
27
|
+
invoke_function,
|
|
28
|
+
list_function_definitions,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"FUNCTION_BUNDLE_FORMAT_VERSION",
|
|
33
|
+
"FUNCTION_BUNDLE_RUNTIME",
|
|
34
|
+
"FUNCTION_MANIFEST_SCHEMA_VERSION",
|
|
35
|
+
"DuplicateFunctionError",
|
|
36
|
+
"FunctionConfirmation",
|
|
37
|
+
"FunctionDefinition",
|
|
38
|
+
"FunctionDefinitionError",
|
|
39
|
+
"FunctionDiscoveryError",
|
|
40
|
+
"FunctionEffect",
|
|
41
|
+
"FunctionError",
|
|
42
|
+
"FunctionExecutionError",
|
|
43
|
+
"FunctionBundle",
|
|
44
|
+
"FunctionMetadata",
|
|
45
|
+
"FunctionNotFoundError",
|
|
46
|
+
"FunctionOutputSerializationError",
|
|
47
|
+
"FunctionValidationError",
|
|
48
|
+
"build_manifest",
|
|
49
|
+
"build_bundle",
|
|
50
|
+
"discover_modules",
|
|
51
|
+
"function",
|
|
52
|
+
"get_function_definition",
|
|
53
|
+
"invoke_function",
|
|
54
|
+
"list_function_definitions",
|
|
55
|
+
]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Build immutable, deterministic deployment bundles for Lumera Functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import gzip
|
|
6
|
+
import hashlib
|
|
7
|
+
import io
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import tarfile
|
|
12
|
+
from collections.abc import Iterator, Sequence
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path, PurePosixPath
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from ._core import build_manifest, discover_modules
|
|
19
|
+
|
|
20
|
+
FUNCTION_BUNDLE_FORMAT_VERSION = 1
|
|
21
|
+
FUNCTION_BUNDLE_RUNTIME = "python3.13"
|
|
22
|
+
|
|
23
|
+
_SOURCE_FILENAMES = {
|
|
24
|
+
"pyproject.toml",
|
|
25
|
+
"requirements.txt",
|
|
26
|
+
"requirements.lock",
|
|
27
|
+
"uv.lock",
|
|
28
|
+
}
|
|
29
|
+
_EXCLUDED_DIRECTORY_NAMES = {
|
|
30
|
+
".git",
|
|
31
|
+
".mypy_cache",
|
|
32
|
+
".pytest_cache",
|
|
33
|
+
".ruff_cache",
|
|
34
|
+
".venv",
|
|
35
|
+
"__pycache__",
|
|
36
|
+
"node_modules",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class FunctionBundle:
|
|
42
|
+
"""Metadata for one built Function deployment bundle."""
|
|
43
|
+
|
|
44
|
+
path: Path
|
|
45
|
+
sha256: str
|
|
46
|
+
size_bytes: int
|
|
47
|
+
manifest: dict[str, Any]
|
|
48
|
+
modules: tuple[str, ...]
|
|
49
|
+
source_files: tuple[str, ...]
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> dict[str, Any]:
|
|
52
|
+
"""Return a JSON-serializable build result."""
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
"path": str(self.path),
|
|
56
|
+
"sha256": self.sha256,
|
|
57
|
+
"size_bytes": self.size_bytes,
|
|
58
|
+
"manifest": self.manifest,
|
|
59
|
+
"modules": list(self.modules),
|
|
60
|
+
"source_files": list(self.source_files),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_bundle(
|
|
65
|
+
project_root: str | os.PathLike[str],
|
|
66
|
+
module_names: Sequence[str],
|
|
67
|
+
output_path: str | os.PathLike[str],
|
|
68
|
+
) -> FunctionBundle:
|
|
69
|
+
"""Build a deterministic ``tar.gz`` containing source and Function metadata."""
|
|
70
|
+
|
|
71
|
+
root = Path(project_root).expanduser().resolve()
|
|
72
|
+
if not root.is_dir():
|
|
73
|
+
raise ValueError(f"Function project root is not a directory: {root}")
|
|
74
|
+
|
|
75
|
+
modules = _normalize_modules(module_names)
|
|
76
|
+
output = Path(output_path).expanduser().resolve()
|
|
77
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
|
|
79
|
+
with _prepend_sys_path(root):
|
|
80
|
+
discover_modules(modules)
|
|
81
|
+
manifest = build_manifest()
|
|
82
|
+
|
|
83
|
+
source_entries = _collect_source_entries(root)
|
|
84
|
+
manifest_bytes = _canonical_json_bytes(manifest)
|
|
85
|
+
build_metadata = {
|
|
86
|
+
"format_version": FUNCTION_BUNDLE_FORMAT_VERSION,
|
|
87
|
+
"runtime": FUNCTION_BUNDLE_RUNTIME,
|
|
88
|
+
"modules": list(modules),
|
|
89
|
+
"manifest_sha256": hashlib.sha256(manifest_bytes).hexdigest(),
|
|
90
|
+
"source_files": [
|
|
91
|
+
{
|
|
92
|
+
"path": relative_path,
|
|
93
|
+
"sha256": hashlib.sha256(content).hexdigest(),
|
|
94
|
+
"size_bytes": len(content),
|
|
95
|
+
}
|
|
96
|
+
for relative_path, content in source_entries
|
|
97
|
+
],
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
archive_bytes = _build_archive(
|
|
101
|
+
manifest_bytes=manifest_bytes,
|
|
102
|
+
build_bytes=_canonical_json_bytes(build_metadata),
|
|
103
|
+
source_entries=source_entries,
|
|
104
|
+
)
|
|
105
|
+
output.write_bytes(archive_bytes)
|
|
106
|
+
|
|
107
|
+
return FunctionBundle(
|
|
108
|
+
path=output,
|
|
109
|
+
sha256=hashlib.sha256(archive_bytes).hexdigest(),
|
|
110
|
+
size_bytes=len(archive_bytes),
|
|
111
|
+
manifest=manifest,
|
|
112
|
+
modules=modules,
|
|
113
|
+
source_files=tuple(relative_path for relative_path, _ in source_entries),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _normalize_modules(module_names: Sequence[str]) -> tuple[str, ...]:
|
|
118
|
+
modules = tuple(dict.fromkeys(name.strip() for name in module_names if name.strip()))
|
|
119
|
+
if not modules:
|
|
120
|
+
raise ValueError("at least one Function module is required")
|
|
121
|
+
return modules
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _collect_source_entries(root: Path) -> list[tuple[str, bytes]]:
|
|
125
|
+
entries: list[tuple[str, bytes]] = []
|
|
126
|
+
for candidate in sorted(root.rglob("*")):
|
|
127
|
+
relative = candidate.relative_to(root)
|
|
128
|
+
if any(part in _EXCLUDED_DIRECTORY_NAMES for part in relative.parts):
|
|
129
|
+
continue
|
|
130
|
+
if candidate.is_symlink() or not candidate.is_file():
|
|
131
|
+
continue
|
|
132
|
+
if candidate.suffix != ".py" and candidate.name not in _SOURCE_FILENAMES:
|
|
133
|
+
continue
|
|
134
|
+
relative_path = PurePosixPath(*relative.parts).as_posix()
|
|
135
|
+
entries.append((relative_path, candidate.read_bytes()))
|
|
136
|
+
if not entries:
|
|
137
|
+
raise ValueError(f"Function project contains no Python source files: {root}")
|
|
138
|
+
return entries
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _build_archive(
|
|
142
|
+
*,
|
|
143
|
+
manifest_bytes: bytes,
|
|
144
|
+
build_bytes: bytes,
|
|
145
|
+
source_entries: Sequence[tuple[str, bytes]],
|
|
146
|
+
) -> bytes:
|
|
147
|
+
tar_buffer = io.BytesIO()
|
|
148
|
+
with tarfile.open(fileobj=tar_buffer, mode="w", format=tarfile.PAX_FORMAT) as archive:
|
|
149
|
+
_add_tar_file(archive, "manifest.json", manifest_bytes)
|
|
150
|
+
_add_tar_file(archive, "build.json", build_bytes)
|
|
151
|
+
for relative_path, content in source_entries:
|
|
152
|
+
_add_tar_file(archive, f"source/{relative_path}", content)
|
|
153
|
+
|
|
154
|
+
gzip_buffer = io.BytesIO()
|
|
155
|
+
with gzip.GzipFile(fileobj=gzip_buffer, mode="wb", filename="", mtime=0) as compressed:
|
|
156
|
+
compressed.write(tar_buffer.getvalue())
|
|
157
|
+
return gzip_buffer.getvalue()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _add_tar_file(archive: tarfile.TarFile, name: str, content: bytes) -> None:
|
|
161
|
+
info = tarfile.TarInfo(name)
|
|
162
|
+
info.size = len(content)
|
|
163
|
+
info.mode = 0o644
|
|
164
|
+
info.mtime = 0
|
|
165
|
+
info.uid = 0
|
|
166
|
+
info.gid = 0
|
|
167
|
+
info.uname = ""
|
|
168
|
+
info.gname = ""
|
|
169
|
+
archive.addfile(info, io.BytesIO(content))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _canonical_json_bytes(value: object) -> bytes:
|
|
173
|
+
return (
|
|
174
|
+
json.dumps(
|
|
175
|
+
value,
|
|
176
|
+
ensure_ascii=False,
|
|
177
|
+
separators=(",", ":"),
|
|
178
|
+
sort_keys=True,
|
|
179
|
+
)
|
|
180
|
+
+ "\n"
|
|
181
|
+
).encode()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@contextmanager
|
|
185
|
+
def _prepend_sys_path(root: Path) -> Iterator[None]:
|
|
186
|
+
root_value = str(root)
|
|
187
|
+
sys.path.insert(0, root_value)
|
|
188
|
+
try:
|
|
189
|
+
yield
|
|
190
|
+
finally:
|
|
191
|
+
try:
|
|
192
|
+
sys.path.remove(root_value)
|
|
193
|
+
except ValueError:
|
|
194
|
+
pass
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""Local authoring and invocation primitives for Lumera Functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
10
|
+
from copy import deepcopy
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Any, ParamSpec, TypeVar, cast, get_type_hints
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError, create_model
|
|
16
|
+
from pydantic_core import PydanticSerializationError
|
|
17
|
+
|
|
18
|
+
P = ParamSpec("P")
|
|
19
|
+
R = TypeVar("R")
|
|
20
|
+
|
|
21
|
+
FUNCTION_MANIFEST_SCHEMA_VERSION = 1
|
|
22
|
+
FUNCTION_METADATA_ATTRIBUTE = "__lumera_function__"
|
|
23
|
+
|
|
24
|
+
_FUNCTION_ID_PATTERN = re.compile(r"^[a-z][a-z0-9]*(?:[._:-][a-z0-9]+)*$")
|
|
25
|
+
_FUNCTIONS: dict[str, FunctionDefinition] = {}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FunctionEffect(str, Enum):
|
|
29
|
+
"""The externally visible effect category of a Function."""
|
|
30
|
+
|
|
31
|
+
READ = "read"
|
|
32
|
+
WRITE = "write"
|
|
33
|
+
EXTERNAL_WRITE = "external_write"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FunctionConfirmation(str, Enum):
|
|
37
|
+
"""The confirmation policy requested when a Function is projected as a tool."""
|
|
38
|
+
|
|
39
|
+
NEVER = "never"
|
|
40
|
+
REQUIRED_FOR_AGENTS = "required_for_agents"
|
|
41
|
+
ALWAYS = "always"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FunctionError(Exception):
|
|
45
|
+
"""Base class for local Function definition and invocation errors."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class FunctionDefinitionError(FunctionError):
|
|
49
|
+
"""Raised when a decorated Function does not define a valid contract."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class DuplicateFunctionError(FunctionDefinitionError):
|
|
53
|
+
"""Raised when two callables export the same stable Function ID."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class FunctionDiscoveryError(FunctionError):
|
|
57
|
+
"""Raised when an explicitly configured Function module cannot be imported."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class FunctionNotFoundError(FunctionError):
|
|
61
|
+
"""Raised when a caller asks for an unknown Function ID."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class FunctionValidationError(FunctionError):
|
|
65
|
+
"""Raised when Function input or output does not satisfy its declared schema."""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
function_id: str,
|
|
70
|
+
phase: str,
|
|
71
|
+
validation_error: ValidationError,
|
|
72
|
+
) -> None:
|
|
73
|
+
self.function_id = function_id
|
|
74
|
+
self.phase = phase
|
|
75
|
+
self.details = cast(
|
|
76
|
+
list[dict[str, Any]],
|
|
77
|
+
json.loads(validation_error.json(include_url=False)),
|
|
78
|
+
)
|
|
79
|
+
super().__init__(f"{phase} validation failed for Function {function_id!r}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class FunctionExecutionError(FunctionError):
|
|
83
|
+
"""Raised when user Function code fails during a runner invocation."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, function_id: str, cause: Exception) -> None:
|
|
86
|
+
self.function_id = function_id
|
|
87
|
+
self.cause_type = type(cause).__name__
|
|
88
|
+
super().__init__(f"Function {function_id!r} failed: {cause}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class FunctionOutputSerializationError(FunctionError):
|
|
92
|
+
"""Raised when a validated Function result cannot be encoded as JSON."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, function_id: str, cause: Exception) -> None:
|
|
95
|
+
self.function_id = function_id
|
|
96
|
+
self.cause_type = type(cause).__name__
|
|
97
|
+
super().__init__(f"Function {function_id!r} returned a non-JSON result: {cause}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class FunctionMetadata:
|
|
102
|
+
"""Serializable metadata derived from a decorated Python callable."""
|
|
103
|
+
|
|
104
|
+
id: str
|
|
105
|
+
description: str
|
|
106
|
+
effect: FunctionEffect
|
|
107
|
+
confirmation: FunctionConfirmation
|
|
108
|
+
module: str
|
|
109
|
+
qualname: str
|
|
110
|
+
input_schema: dict[str, Any]
|
|
111
|
+
output_schema: dict[str, Any]
|
|
112
|
+
is_async: bool
|
|
113
|
+
|
|
114
|
+
def to_manifest_entry(self) -> dict[str, Any]:
|
|
115
|
+
"""Return a detached JSON-serializable manifest entry."""
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
"id": self.id,
|
|
119
|
+
"description": self.description,
|
|
120
|
+
"effect": self.effect.value,
|
|
121
|
+
"confirmation": self.confirmation.value,
|
|
122
|
+
"module": self.module,
|
|
123
|
+
"qualname": self.qualname,
|
|
124
|
+
"input_schema": deepcopy(self.input_schema),
|
|
125
|
+
"output_schema": deepcopy(self.output_schema),
|
|
126
|
+
"is_async": self.is_async,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class FunctionDefinition:
|
|
132
|
+
"""Runtime definition used to validate and invoke an exported Function."""
|
|
133
|
+
|
|
134
|
+
metadata: FunctionMetadata
|
|
135
|
+
callable: Callable[..., Any]
|
|
136
|
+
input_model: type[BaseModel]
|
|
137
|
+
output_adapter: TypeAdapter[Any]
|
|
138
|
+
|
|
139
|
+
async def invoke(self, inputs: Mapping[str, Any]) -> object:
|
|
140
|
+
"""Validate JSON-compatible input, invoke the callable, and validate output."""
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
validated_input = self.input_model.model_validate(dict(inputs))
|
|
144
|
+
except ValidationError as exc:
|
|
145
|
+
raise FunctionValidationError(self.metadata.id, "input", exc) from exc
|
|
146
|
+
|
|
147
|
+
kwargs = {
|
|
148
|
+
parameter_name: getattr(validated_input, parameter_name)
|
|
149
|
+
for parameter_name in self.input_model.model_fields
|
|
150
|
+
}
|
|
151
|
+
try:
|
|
152
|
+
result = self.callable(**kwargs)
|
|
153
|
+
if inspect.isawaitable(result):
|
|
154
|
+
result = await cast(Awaitable[Any], result)
|
|
155
|
+
except Exception as exc:
|
|
156
|
+
raise FunctionExecutionError(self.metadata.id, exc) from exc
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
validated_output = self.output_adapter.validate_python(result)
|
|
160
|
+
except ValidationError as exc:
|
|
161
|
+
raise FunctionValidationError(self.metadata.id, "output", exc) from exc
|
|
162
|
+
try:
|
|
163
|
+
return json.loads(self.output_adapter.dump_json(validated_output))
|
|
164
|
+
except PydanticSerializationError as exc:
|
|
165
|
+
raise FunctionOutputSerializationError(self.metadata.id, exc) from exc
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def function(
|
|
169
|
+
*,
|
|
170
|
+
id: str,
|
|
171
|
+
description: str | None = None,
|
|
172
|
+
effect: FunctionEffect | str = FunctionEffect.READ,
|
|
173
|
+
confirmation: FunctionConfirmation | str | None = None,
|
|
174
|
+
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
|
175
|
+
"""Mark an ordinary Python callable as an externally callable Lumera Function."""
|
|
176
|
+
|
|
177
|
+
def decorate(callable_: Callable[P, R]) -> Callable[P, R]:
|
|
178
|
+
definition = _build_definition(
|
|
179
|
+
callable_,
|
|
180
|
+
function_id=id,
|
|
181
|
+
description=description,
|
|
182
|
+
effect=effect,
|
|
183
|
+
confirmation=confirmation,
|
|
184
|
+
)
|
|
185
|
+
_register(definition)
|
|
186
|
+
setattr(callable_, FUNCTION_METADATA_ATTRIBUTE, definition.metadata)
|
|
187
|
+
return callable_
|
|
188
|
+
|
|
189
|
+
return decorate
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def discover_modules(module_names: Sequence[str]) -> tuple[FunctionDefinition, ...]:
|
|
193
|
+
"""Import explicit Function modules and return all registered definitions."""
|
|
194
|
+
|
|
195
|
+
if not module_names:
|
|
196
|
+
raise FunctionDiscoveryError("at least one Function module is required")
|
|
197
|
+
for raw_module_name in module_names:
|
|
198
|
+
module_name = raw_module_name.strip()
|
|
199
|
+
if not module_name:
|
|
200
|
+
raise FunctionDiscoveryError("Function module names cannot be empty")
|
|
201
|
+
try:
|
|
202
|
+
importlib.import_module(module_name)
|
|
203
|
+
except FunctionError:
|
|
204
|
+
raise
|
|
205
|
+
except Exception as exc:
|
|
206
|
+
raise FunctionDiscoveryError(
|
|
207
|
+
f"failed to import Function module {module_name!r}: {exc}"
|
|
208
|
+
) from exc
|
|
209
|
+
return list_function_definitions()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def list_function_definitions() -> tuple[FunctionDefinition, ...]:
|
|
213
|
+
"""Return registered Function definitions sorted by stable ID."""
|
|
214
|
+
|
|
215
|
+
return tuple(_FUNCTIONS[function_id] for function_id in sorted(_FUNCTIONS))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def get_function_definition(function_id: str) -> FunctionDefinition:
|
|
219
|
+
"""Return one registered Function definition by stable ID."""
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
return _FUNCTIONS[function_id]
|
|
223
|
+
except KeyError as exc:
|
|
224
|
+
raise FunctionNotFoundError(f"Function {function_id!r} is not registered") from exc
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def build_manifest() -> dict[str, Any]:
|
|
228
|
+
"""Build the deterministic local registry artifact."""
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
"schema_version": FUNCTION_MANIFEST_SCHEMA_VERSION,
|
|
232
|
+
"functions": [
|
|
233
|
+
definition.metadata.to_manifest_entry() for definition in list_function_definitions()
|
|
234
|
+
],
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
async def invoke_function(function_id: str, inputs: Mapping[str, Any]) -> object:
|
|
239
|
+
"""Invoke one registered Function through its typed remote-call boundary."""
|
|
240
|
+
|
|
241
|
+
return await get_function_definition(function_id).invoke(inputs)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _build_definition(
|
|
245
|
+
callable_: Callable[..., Any],
|
|
246
|
+
*,
|
|
247
|
+
function_id: str,
|
|
248
|
+
description: str | None,
|
|
249
|
+
effect: FunctionEffect | str,
|
|
250
|
+
confirmation: FunctionConfirmation | str | None,
|
|
251
|
+
) -> FunctionDefinition:
|
|
252
|
+
normalized_id = _validate_function_id(function_id)
|
|
253
|
+
normalized_effect = _enum_value(FunctionEffect, effect, "effect")
|
|
254
|
+
normalized_confirmation = _confirmation_value(confirmation, normalized_effect)
|
|
255
|
+
normalized_description = (description or inspect.getdoc(callable_) or "").strip()
|
|
256
|
+
if not normalized_description:
|
|
257
|
+
raise FunctionDefinitionError(
|
|
258
|
+
f"Function {normalized_id!r} requires a description or docstring"
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
signature = inspect.signature(callable_)
|
|
262
|
+
try:
|
|
263
|
+
type_hints = get_type_hints(callable_, include_extras=True)
|
|
264
|
+
except Exception as exc:
|
|
265
|
+
raise FunctionDefinitionError(
|
|
266
|
+
f"could not resolve annotations for Function {normalized_id!r}: {exc}"
|
|
267
|
+
) from exc
|
|
268
|
+
|
|
269
|
+
fields: dict[str, tuple[Any, Any]] = {}
|
|
270
|
+
for parameter_name, parameter in signature.parameters.items():
|
|
271
|
+
if parameter.kind in {
|
|
272
|
+
inspect.Parameter.POSITIONAL_ONLY,
|
|
273
|
+
inspect.Parameter.VAR_POSITIONAL,
|
|
274
|
+
inspect.Parameter.VAR_KEYWORD,
|
|
275
|
+
}:
|
|
276
|
+
raise FunctionDefinitionError(
|
|
277
|
+
f"Function {normalized_id!r} parameter {parameter_name!r} "
|
|
278
|
+
"must be addressable as a named JSON field"
|
|
279
|
+
)
|
|
280
|
+
annotation = type_hints.get(parameter_name, parameter.annotation)
|
|
281
|
+
if annotation is inspect.Parameter.empty:
|
|
282
|
+
raise FunctionDefinitionError(
|
|
283
|
+
f"Function {normalized_id!r} parameter {parameter_name!r} "
|
|
284
|
+
"requires a type annotation"
|
|
285
|
+
)
|
|
286
|
+
default = ... if parameter.default is inspect.Parameter.empty else parameter.default
|
|
287
|
+
fields[parameter_name] = (annotation, default)
|
|
288
|
+
|
|
289
|
+
return_annotation = type_hints.get("return", signature.return_annotation)
|
|
290
|
+
if return_annotation is inspect.Signature.empty:
|
|
291
|
+
raise FunctionDefinitionError(
|
|
292
|
+
f"Function {normalized_id!r} requires a return type annotation"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
model_name = "_LumeraInput_" + re.sub(r"[^a-zA-Z0-9_]", "_", normalized_id)
|
|
296
|
+
try:
|
|
297
|
+
input_model = create_model(
|
|
298
|
+
model_name,
|
|
299
|
+
__config__=ConfigDict(extra="forbid"),
|
|
300
|
+
**fields,
|
|
301
|
+
)
|
|
302
|
+
output_adapter = TypeAdapter(return_annotation)
|
|
303
|
+
input_schema = input_model.model_json_schema()
|
|
304
|
+
output_schema = output_adapter.json_schema()
|
|
305
|
+
except Exception as exc:
|
|
306
|
+
raise FunctionDefinitionError(
|
|
307
|
+
f"could not build schemas for Function {normalized_id!r}: {exc}"
|
|
308
|
+
) from exc
|
|
309
|
+
|
|
310
|
+
metadata = FunctionMetadata(
|
|
311
|
+
id=normalized_id,
|
|
312
|
+
description=normalized_description,
|
|
313
|
+
effect=normalized_effect,
|
|
314
|
+
confirmation=normalized_confirmation,
|
|
315
|
+
module=callable_.__module__,
|
|
316
|
+
qualname=callable_.__qualname__,
|
|
317
|
+
input_schema=input_schema,
|
|
318
|
+
output_schema=output_schema,
|
|
319
|
+
is_async=inspect.iscoroutinefunction(callable_),
|
|
320
|
+
)
|
|
321
|
+
return FunctionDefinition(
|
|
322
|
+
metadata=metadata,
|
|
323
|
+
callable=callable_,
|
|
324
|
+
input_model=input_model,
|
|
325
|
+
output_adapter=output_adapter,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _validate_function_id(function_id: str) -> str:
|
|
330
|
+
normalized_id = function_id.strip()
|
|
331
|
+
if not _FUNCTION_ID_PATTERN.fullmatch(normalized_id):
|
|
332
|
+
raise FunctionDefinitionError(
|
|
333
|
+
"Function IDs must start with a lowercase letter and contain only "
|
|
334
|
+
"lowercase letters, numbers, '.', '_', ':', or '-' separated by "
|
|
335
|
+
f"alphanumeric segments; got {function_id!r}"
|
|
336
|
+
)
|
|
337
|
+
return normalized_id
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _enum_value(
|
|
341
|
+
enum_type: type[FunctionEffect],
|
|
342
|
+
value: FunctionEffect | str,
|
|
343
|
+
field_name: str,
|
|
344
|
+
) -> FunctionEffect:
|
|
345
|
+
try:
|
|
346
|
+
return enum_type(value)
|
|
347
|
+
except ValueError as exc:
|
|
348
|
+
allowed = ", ".join(item.value for item in enum_type)
|
|
349
|
+
raise FunctionDefinitionError(
|
|
350
|
+
f"invalid Function {field_name} {value!r}; expected one of: {allowed}"
|
|
351
|
+
) from exc
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _confirmation_value(
|
|
355
|
+
value: FunctionConfirmation | str | None,
|
|
356
|
+
effect: FunctionEffect,
|
|
357
|
+
) -> FunctionConfirmation:
|
|
358
|
+
if value is None:
|
|
359
|
+
if effect is FunctionEffect.READ:
|
|
360
|
+
return FunctionConfirmation.NEVER
|
|
361
|
+
return FunctionConfirmation.REQUIRED_FOR_AGENTS
|
|
362
|
+
try:
|
|
363
|
+
return FunctionConfirmation(value)
|
|
364
|
+
except ValueError as exc:
|
|
365
|
+
allowed = ", ".join(item.value for item in FunctionConfirmation)
|
|
366
|
+
raise FunctionDefinitionError(
|
|
367
|
+
f"invalid Function confirmation {value!r}; expected one of: {allowed}"
|
|
368
|
+
) from exc
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _register(definition: FunctionDefinition) -> None:
|
|
372
|
+
existing = _FUNCTIONS.get(definition.metadata.id)
|
|
373
|
+
if existing is not None:
|
|
374
|
+
raise DuplicateFunctionError(
|
|
375
|
+
f"Function ID {definition.metadata.id!r} is already exported by "
|
|
376
|
+
f"{existing.metadata.module}.{existing.metadata.qualname}"
|
|
377
|
+
)
|
|
378
|
+
_FUNCTIONS[definition.metadata.id] = definition
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _clear_registry_for_testing() -> None:
|
|
382
|
+
_FUNCTIONS.clear()
|