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.
Files changed (42) hide show
  1. {lumera-0.27.0 → lumera-0.28.0.dev1}/PKG-INFO +2 -2
  2. {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/__init__.py +6 -1
  3. lumera-0.28.0.dev1/lumera/functions/__init__.py +55 -0
  4. lumera-0.28.0.dev1/lumera/functions/__main__.py +5 -0
  5. lumera-0.28.0.dev1/lumera/functions/_bundle.py +194 -0
  6. lumera-0.28.0.dev1/lumera/functions/_core.py +382 -0
  7. lumera-0.28.0.dev1/lumera/functions/_runner.py +163 -0
  8. {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera.egg-info/PKG-INFO +2 -2
  9. {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera.egg-info/SOURCES.txt +6 -0
  10. {lumera-0.27.0 → lumera-0.28.0.dev1}/pyproject.toml +2 -2
  11. lumera-0.28.0.dev1/tests/test_functions.py +528 -0
  12. {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/_live.py +0 -0
  13. {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/_utils.py +0 -0
  14. {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/agents.py +0 -0
  15. {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/automations.py +0 -0
  16. {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/email.py +0 -0
  17. {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/exceptions.py +0 -0
  18. {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/files.py +0 -0
  19. {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/flags.py +0 -0
  20. {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/google.py +0 -0
  21. {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/integrations/__init__.py +0 -0
  22. {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/integrations/bill.py +0 -0
  23. {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/integrations/google.py +0 -0
  24. {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/integrations/netsuite.py +0 -0
  25. {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/integrations/salesforce.py +0 -0
  26. {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/integrations/slack.py +0 -0
  27. {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/llm.py +0 -0
  28. {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/pb.py +0 -0
  29. {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/sdk.py +0 -0
  30. {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/storage.py +0 -0
  31. {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera/webhooks.py +0 -0
  32. {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera.egg-info/dependency_links.txt +0 -0
  33. {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera.egg-info/requires.txt +1 -1
  34. {lumera-0.27.0 → lumera-0.28.0.dev1}/lumera.egg-info/top_level.txt +0 -0
  35. {lumera-0.27.0 → lumera-0.28.0.dev1}/setup.cfg +0 -0
  36. {lumera-0.27.0 → lumera-0.28.0.dev1}/tests/test_agents_live.py +0 -0
  37. {lumera-0.27.0 → lumera-0.28.0.dev1}/tests/test_automations.py +0 -0
  38. {lumera-0.27.0 → lumera-0.28.0.dev1}/tests/test_flags.py +0 -0
  39. {lumera-0.27.0 → lumera-0.28.0.dev1}/tests/test_integrations_netsuite.py +0 -0
  40. {lumera-0.27.0 → lumera-0.28.0.dev1}/tests/test_integrations_salesforce.py +0 -0
  41. {lumera-0.27.0 → lumera-0.28.0.dev1}/tests/test_sdk.py +0 -0
  42. {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.27.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,5 @@
1
+ """CLI entry point for ``python -m lumera.functions``."""
2
+
3
+ from ._runner import main
4
+
5
+ raise SystemExit(main())
@@ -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()