lumera 0.28.0.dev1__py3-none-any.whl → 0.28.0.dev2__py3-none-any.whl

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/__init__.py CHANGED
@@ -5,79 +5,17 @@ This SDK provides helpers for automations running within the Lumera environment
5
5
  to interact with the Lumera API and define dynamic user interfaces.
6
6
  """
7
7
 
8
+ from importlib import import_module as _import_module
8
9
  from importlib.metadata import PackageNotFoundError, version
9
10
 
10
11
  try:
11
12
  __version__ = version("lumera")
12
13
  except PackageNotFoundError:
13
- __version__ = "unknown" # Not installed (e.g., running from source)
14
+ __version__ = "unknown" # Not installed (e.g. running from source)
14
15
 
15
- # Import new modules (as modules, not individual functions)
16
- from . import (
17
- agents,
18
- automations,
19
- email,
20
- exceptions,
21
- functions,
22
- integrations,
23
- llm,
24
- pb,
25
- storage,
26
- webhooks,
27
- )
28
- from ._utils import (
29
- Credentials,
30
- IntegrationNotEnabledForProjectError,
31
- LumeraAPIError,
32
- RecordNotUniqueError,
33
- get_access_token,
34
- get_credentials,
35
- get_google_access_token,
36
- list_integration_connections,
37
- list_integrations,
38
- log_timed,
39
- )
40
-
41
- # Import specific exceptions for convenience
42
- from .exceptions import (
43
- LumeraError,
44
- RecordNotFoundError,
45
- UniqueConstraintError,
46
- ValidationError,
47
- )
48
-
49
- # Import file types for Pydantic automation inputs
50
- from .files import LumeraFile, LumeraFiles
51
- from .flags import feature_flag, feature_flags, is_feature_enabled
52
- from .functions import function
53
-
54
- # Import key SDK helpers to expose them at the package root.
55
- from .sdk import (
56
- CollectionField,
57
- HookReplayResult,
58
- create_collection,
59
- create_record,
60
- delete_collection,
61
- delete_record,
62
- get_automation_run,
63
- get_collection,
64
- get_record,
65
- get_record_by_external_id,
66
- list_collections,
67
- list_records,
68
- query_sql,
69
- replay_hook,
70
- run_automation,
71
- save_to_lumera,
72
- search_records,
73
- update_automation_run,
74
- update_collection,
75
- update_record,
76
- upload_lumera_file,
77
- upsert_record,
78
- )
79
-
80
- # Define what `from lumera import *` imports.
16
+ # Define what ``from lumera import *`` imports. Keep this list stable: callers
17
+ # have historically imported these modules, helpers, types, and exceptions from
18
+ # the package root.
81
19
  __all__ = [
82
20
  # Authentication & utilities
83
21
  "Credentials",
@@ -91,8 +29,6 @@ __all__ = [
91
29
  "feature_flag",
92
30
  "feature_flags",
93
31
  "is_feature_enabled",
94
- # Callable Function authoring
95
- "function",
96
32
  # Collections (low-level API)
97
33
  "list_collections",
98
34
  "get_collection",
@@ -129,7 +65,7 @@ __all__ = [
129
65
  "ValidationError",
130
66
  "UniqueConstraintError",
131
67
  "RecordNotFoundError",
132
- # New modules (use as lumera.pb, lumera.storage, etc.)
68
+ # High-level modules (use as lumera.pb, lumera.storage, etc.)
133
69
  "agents",
134
70
  "automations",
135
71
  "email",
@@ -137,7 +73,103 @@ __all__ = [
137
73
  "storage",
138
74
  "llm",
139
75
  "exceptions",
140
- "functions",
141
76
  "webhooks",
142
77
  "integrations",
143
78
  ]
79
+
80
+ # Importing the package root used to import every SDK module, including all
81
+ # feature-specific integration clients. Resolve the same public API on first
82
+ # access so existing import statements retain their behavior without paying
83
+ # that cost when the corresponding feature is unused.
84
+ _LAZY_MODULES = {
85
+ "_utils": "._utils",
86
+ "agents": ".agents",
87
+ "automations": ".automations",
88
+ "email": ".email",
89
+ "exceptions": ".exceptions",
90
+ "files": ".files",
91
+ "flags": ".flags",
92
+ "integrations": ".integrations",
93
+ "llm": ".llm",
94
+ "pb": ".pb",
95
+ "sdk": ".sdk",
96
+ "storage": ".storage",
97
+ "webhooks": ".webhooks",
98
+ }
99
+
100
+ _LAZY_ATTRIBUTES = {
101
+ # ._utils
102
+ "Credentials": ("._utils", "Credentials"),
103
+ "IntegrationNotEnabledForProjectError": (
104
+ "._utils",
105
+ "IntegrationNotEnabledForProjectError",
106
+ ),
107
+ "LumeraAPIError": ("._utils", "LumeraAPIError"),
108
+ "RecordNotUniqueError": ("._utils", "RecordNotUniqueError"),
109
+ "get_access_token": ("._utils", "get_access_token"),
110
+ "get_credentials": ("._utils", "get_credentials"),
111
+ "get_google_access_token": ("._utils", "get_google_access_token"),
112
+ "list_integration_connections": ("._utils", "list_integration_connections"),
113
+ "list_integrations": ("._utils", "list_integrations"),
114
+ "log_timed": ("._utils", "log_timed"),
115
+ # .exceptions
116
+ "LumeraError": (".exceptions", "LumeraError"),
117
+ "RecordNotFoundError": (".exceptions", "RecordNotFoundError"),
118
+ "UniqueConstraintError": (".exceptions", "UniqueConstraintError"),
119
+ "ValidationError": (".exceptions", "ValidationError"),
120
+ # .files
121
+ "LumeraFile": (".files", "LumeraFile"),
122
+ "LumeraFiles": (".files", "LumeraFiles"),
123
+ # .flags
124
+ "feature_flag": (".flags", "feature_flag"),
125
+ "feature_flags": (".flags", "feature_flags"),
126
+ "is_feature_enabled": (".flags", "is_feature_enabled"),
127
+ # .sdk
128
+ "CollectionField": (".sdk", "CollectionField"),
129
+ "HookReplayResult": (".sdk", "HookReplayResult"),
130
+ "create_collection": (".sdk", "create_collection"),
131
+ "create_record": (".sdk", "create_record"),
132
+ "delete_collection": (".sdk", "delete_collection"),
133
+ "delete_record": (".sdk", "delete_record"),
134
+ "get_automation_run": (".sdk", "get_automation_run"),
135
+ "get_collection": (".sdk", "get_collection"),
136
+ "get_record": (".sdk", "get_record"),
137
+ "get_record_by_external_id": (".sdk", "get_record_by_external_id"),
138
+ "list_collections": (".sdk", "list_collections"),
139
+ "list_records": (".sdk", "list_records"),
140
+ "query_sql": (".sdk", "query_sql"),
141
+ "replay_hook": (".sdk", "replay_hook"),
142
+ "run_automation": (".sdk", "run_automation"),
143
+ "save_to_lumera": (".sdk", "save_to_lumera"),
144
+ "search_records": (".sdk", "search_records"),
145
+ "update_automation_run": (".sdk", "update_automation_run"),
146
+ "update_collection": (".sdk", "update_collection"),
147
+ "update_record": (".sdk", "update_record"),
148
+ "upload_lumera_file": (".sdk", "upload_lumera_file"),
149
+ "upsert_record": (".sdk", "upsert_record"),
150
+ }
151
+
152
+
153
+ def __getattr__(name: str) -> object:
154
+ """Load a root SDK export the first time it is requested."""
155
+
156
+ module_name = _LAZY_MODULES.get(name)
157
+ if module_name is not None:
158
+ value = _import_module(module_name, __name__)
159
+ globals()[name] = value
160
+ return value
161
+
162
+ attribute = _LAZY_ATTRIBUTES.get(name)
163
+ if attribute is not None:
164
+ module_name, attribute_name = attribute
165
+ value = getattr(_import_module(module_name, __name__), attribute_name)
166
+ globals()[name] = value
167
+ return value
168
+
169
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
170
+
171
+
172
+ def __dir__() -> list[str]:
173
+ """Include lazily available exports in interactive discovery."""
174
+
175
+ return sorted(set(globals()) | set(__all__) | set(_LAZY_MODULES))
@@ -3,9 +3,9 @@ Lumera NetSuite Integration
3
3
 
4
4
  Provides two NetSuite clients:
5
5
 
6
- * :class:`NetSuiteProxyClient` is recommended for new code. It routes requests
7
- through Lumera's integration proxy, so NetSuite credentials stay inside the
8
- Lumera backend.
6
+ * :class:`NetSuiteProxyClient` is recommended for new code. It routes SuiteQL,
7
+ REST records, and RESTlets through Lumera's integration proxy, so NetSuite
8
+ credentials stay inside the Lumera backend.
9
9
  * :class:`NetSuiteClient` preserves the existing direct-call behavior for
10
10
  backward compatibility. It downloads the NetSuite access token into the
11
11
  automation process and calls SuiteTalk directly.
@@ -99,6 +99,10 @@ def _base_url(account_id: str) -> str:
99
99
  return f"https://{account_id.lower()}.suitetalk.api.netsuite.com"
100
100
 
101
101
 
102
+ def _restlet_base_url(account_id: str) -> str:
103
+ return f"https://{account_id.lower()}.restlets.api.netsuite.com"
104
+
105
+
102
106
  def _fetch_account_id(api_name: str) -> str:
103
107
  """Resolve the NetSuite account ID from connection metadata (no secrets)."""
104
108
  cached = _account_id_cache.get(api_name)
@@ -417,6 +421,39 @@ class NetSuiteProxyClient:
417
421
  timeout=self._timeout,
418
422
  )
419
423
 
424
+ def call_restlet(
425
+ self,
426
+ script_id: str | int,
427
+ deployment_id: str | int,
428
+ *,
429
+ method: str = "POST",
430
+ body: dict[str, Any] | list[Any] | str | None = None,
431
+ headers: dict[str, str] | None = None,
432
+ params: dict[str, Any] | None = None,
433
+ ) -> requests.Response:
434
+ """Call a deployed RESTlet through Lumera's integration proxy.
435
+
436
+ RESTlet access must first be enabled for the NetSuite connection in
437
+ Lumera. The raw response is returned so callers can handle each
438
+ RESTlet's custom response format.
439
+ """
440
+ query: dict[str, Any] = {
441
+ "script": script_id,
442
+ "deploy": deployment_id,
443
+ }
444
+ for key, value in (params or {}).items():
445
+ if key not in query:
446
+ query[key] = value
447
+ url = f"{_restlet_base_url(self.account_id)}/app/site/hosting/restlet.nl?{urlencode(query)}"
448
+ return _proxy_request(
449
+ self._api_name,
450
+ method.upper(),
451
+ url,
452
+ body=body,
453
+ headers=headers,
454
+ timeout=self._timeout,
455
+ )
456
+
420
457
  # ── SuiteQL ──────────────────────────────────────────────────────────
421
458
 
422
459
  def suiteql(
@@ -1,12 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lumera
3
- Version: 0.28.0.dev1
3
+ Version: 0.28.0.dev2
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
10
9
  Requires-Dist: simple-salesforce==1.12.6
11
10
  Requires-Dist: slack_sdk==3.35.0
12
11
  Provides-Extra: dev
@@ -29,6 +28,7 @@ Requires-Dist: openai-agents<1.0.0,>=0.6.5; extra == "full"
29
28
  Requires-Dist: openpyxl==3.1.5; extra == "full"
30
29
  Requires-Dist: pandas==2.3.0; extra == "full"
31
30
  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"
@@ -1,4 +1,4 @@
1
- lumera/__init__.py,sha256=ednyPgTBleMeFC9Zi8YLv8qjRXGbTFCOS5iEYaR2qvo,3312
1
+ lumera/__init__.py,sha256=-KrO86tPsjRGUn0FVyxGCnOcTR74DkQupocVE4eQjzA,5860
2
2
  lumera/_live.py,sha256=OAB0gQIeALdDf5QZjIA6jtXl11f8cYpJv_FcEUcKGWI,31707
3
3
  lumera/_utils.py,sha256=OoNJh0K-Wqze_m6234LMN3tvzV6znteHa4bXX7mxUI0,42498
4
4
  lumera/agents.py,sha256=MTKaRoBtWIVCH-_Km0yg4ZX8GlNaVXPMfdHLE3cr2vo,20720
@@ -13,18 +13,13 @@ lumera/pb.py,sha256=jlAxWoxvt0iBlHmK-ugSwn3SOpLSkTST3qc13-VFXh8,22053
13
13
  lumera/sdk.py,sha256=ZmoZ6h3ndMmHRiww4hg96zLOfhCiE1Tk8HhlGq82PGU,30481
14
14
  lumera/storage.py,sha256=kmkRmpmc1CfA--2Y5rJvKTHEv5VIAXFyWMeajr-mUwM,10305
15
15
  lumera/webhooks.py,sha256=0glrf_9UwPcF1g-h-uJwr_TEM0v0OdCNg7Kd2svbvvo,9419
16
- lumera/functions/__init__.py,sha256=nekNrs-MDSuOdRC4X2R9lIj9N0GxxB5h86bVIOaULcQ,1378
17
- lumera/functions/__main__.py,sha256=RNkrl16scYCPi5aKEOTYZ1d5s1_Fwp8kl6pqhvPHYEM,111
18
- lumera/functions/_bundle.py,sha256=CqZX8vQHAv23jC3thF4FvESBsA-_U9GTXCg3_p0XaS8,5719
19
- lumera/functions/_core.py,sha256=1qEw2FocZXu1960NOefHYQVdpJ7LAt90R1xqvePTwRs,13228
20
- lumera/functions/_runner.py,sha256=p0At9oHzCWReefAXHL_bXHtN5IsLOSlu9z8gx7K8k-0,5224
21
16
  lumera/integrations/__init__.py,sha256=PFUEtmJyMYRJla_a_kXSdWqKUvoD-C4y04gDtjXAQcc,2418
22
17
  lumera/integrations/bill.py,sha256=FWRCVu9JP7Wm4dilCbga6hG4e2pVfnIj6omWiAUKc-k,4298
23
18
  lumera/integrations/google.py,sha256=QkbBbbDh3I_OToPDFqcivU6sWy2UieHBxZ_TPv5rqK0,11862
24
- lumera/integrations/netsuite.py,sha256=QLHU4rbm4I_1TpWMVDwyeTDLDQcHd7Z_ZRib0M7-ys0,16225
19
+ lumera/integrations/netsuite.py,sha256=acbwP_NwKFnX46h39-_oJ9EOUQbDdsBVWDBqXsLVeTc,17503
25
20
  lumera/integrations/salesforce.py,sha256=THn8YHW4FBWuhCoyNsGnLtfHM_JaUGPM1ylFF2z819g,1915
26
21
  lumera/integrations/slack.py,sha256=iHmb1Ryc7E5CRb7Kqb-UoZvhNGICpdRu3FzXCDh-jt4,3471
27
- lumera-0.28.0.dev1.dist-info/METADATA,sha256=r4LazrF3gKajgfEUDBP8jVwEPhAr4G1PltQwDtU85jg,1674
28
- lumera-0.28.0.dev1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
29
- lumera-0.28.0.dev1.dist-info/top_level.txt,sha256=HgfK4XQkpMTnM2E5iWM4kB711FnYqUY9dglzib3pWlE,7
30
- lumera-0.28.0.dev1.dist-info/RECORD,,
22
+ lumera-0.28.0.dev2.dist-info/METADATA,sha256=SEeFf40VTfJJgy8BF9eACPZq_fhtd_eg0u03SAgGFHM,1691
23
+ lumera-0.28.0.dev2.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
24
+ lumera-0.28.0.dev2.dist-info/top_level.txt,sha256=HgfK4XQkpMTnM2E5iWM4kB711FnYqUY9dglzib3pWlE,7
25
+ lumera-0.28.0.dev2.dist-info/RECORD,,
@@ -1,55 +0,0 @@
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
- ]
@@ -1,5 +0,0 @@
1
- """CLI entry point for ``python -m lumera.functions``."""
2
-
3
- from ._runner import main
4
-
5
- raise SystemExit(main())
@@ -1,194 +0,0 @@
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
lumera/functions/_core.py DELETED
@@ -1,382 +0,0 @@
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()
@@ -1,163 +0,0 @@
1
- """Machine-readable local runner for discovered Lumera Functions."""
2
-
3
- from __future__ import annotations
4
-
5
- import argparse
6
- import asyncio
7
- import json
8
- import sys
9
- from collections.abc import Sequence
10
- from contextlib import redirect_stdout
11
- from typing import Any
12
-
13
- from ._bundle import build_bundle
14
- from ._core import (
15
- FunctionDefinitionError,
16
- FunctionDiscoveryError,
17
- FunctionExecutionError,
18
- FunctionNotFoundError,
19
- FunctionOutputSerializationError,
20
- FunctionValidationError,
21
- build_manifest,
22
- discover_modules,
23
- invoke_function,
24
- )
25
-
26
-
27
- def main(argv: Sequence[str] | None = None) -> int:
28
- """Run the local Function protocol and emit exactly one JSON result envelope."""
29
-
30
- parser = _build_parser()
31
- try:
32
- args = parser.parse_args(argv)
33
- with redirect_stdout(sys.stderr):
34
- if args.command == "bundle":
35
- bundle = build_bundle(args.project_root, args.modules, args.output)
36
- payload = {"ok": True, "bundle": bundle.to_dict()}
37
- else:
38
- discover_modules(args.modules)
39
- if args.command == "list":
40
- payload = {"ok": True, "manifest": build_manifest()}
41
- elif args.command == "invoke":
42
- inputs = _load_inputs(args.input)
43
- result = asyncio.run(invoke_function(args.function_id, inputs))
44
- payload = {
45
- "ok": True,
46
- "function_id": args.function_id,
47
- "result": result,
48
- }
49
- except FunctionValidationError as exc:
50
- payload = _error_payload(
51
- f"{exc.phase}_validation_error",
52
- str(exc),
53
- details=exc.details,
54
- function_id=exc.function_id,
55
- )
56
- except FunctionNotFoundError as exc:
57
- payload = _error_payload("function_not_found", str(exc))
58
- except FunctionExecutionError as exc:
59
- payload = _error_payload(
60
- "function_execution_error",
61
- str(exc),
62
- function_id=exc.function_id,
63
- cause_type=exc.cause_type,
64
- )
65
- except FunctionOutputSerializationError as exc:
66
- payload = _error_payload(
67
- "output_serialization_error",
68
- str(exc),
69
- function_id=exc.function_id,
70
- cause_type=exc.cause_type,
71
- )
72
- except FunctionDiscoveryError as exc:
73
- payload = _error_payload("function_discovery_error", str(exc))
74
- except FunctionDefinitionError as exc:
75
- payload = _error_payload("function_definition_error", str(exc))
76
- except (json.JSONDecodeError, ValueError) as exc:
77
- payload = _error_payload("invalid_input_json", str(exc))
78
- except Exception as exc:
79
- payload = _error_payload(
80
- "runner_error",
81
- str(exc),
82
- cause_type=type(exc).__name__,
83
- )
84
-
85
- _emit(payload)
86
- return 0 if payload["ok"] else 1
87
-
88
-
89
- def _build_parser() -> argparse.ArgumentParser:
90
- parser = _ProtocolArgumentParser(prog="python -m lumera.functions")
91
- subparsers = parser.add_subparsers(dest="command", required=True)
92
-
93
- list_parser = subparsers.add_parser("list", help="discover and list Function metadata")
94
- _add_modules_argument(list_parser)
95
-
96
- invoke_parser = subparsers.add_parser("invoke", help="invoke one discovered Function")
97
- _add_modules_argument(invoke_parser)
98
- invoke_parser.add_argument("--function-id", required=True)
99
- invoke_parser.add_argument(
100
- "--input",
101
- help="JSON object input; when omitted, read the JSON object from stdin",
102
- )
103
-
104
- bundle_parser = subparsers.add_parser(
105
- "bundle",
106
- help="build a deterministic deployment bundle",
107
- )
108
- _add_modules_argument(bundle_parser)
109
- bundle_parser.add_argument(
110
- "--project-root",
111
- required=True,
112
- help="root containing Function source packages",
113
- )
114
- bundle_parser.add_argument(
115
- "--output",
116
- required=True,
117
- help="output path for the .tar.gz bundle",
118
- )
119
- return parser
120
-
121
-
122
- class _ProtocolArgumentParser(argparse.ArgumentParser):
123
- def error(self, message: str) -> None:
124
- raise ValueError(message)
125
-
126
-
127
- def _add_modules_argument(parser: argparse.ArgumentParser) -> None:
128
- parser.add_argument(
129
- "--module",
130
- dest="modules",
131
- action="append",
132
- required=True,
133
- help="explicit import path containing decorated Functions; may be repeated",
134
- )
135
-
136
-
137
- def _load_inputs(raw_input: str | None) -> dict[str, Any]:
138
- if raw_input is None:
139
- raw_input = sys.stdin.read()
140
- if not raw_input.strip():
141
- raw_input = "{}"
142
- decoded = json.loads(raw_input)
143
- if not isinstance(decoded, dict):
144
- raise ValueError("Function input must be a JSON object")
145
- return decoded
146
-
147
-
148
- def _error_payload(error_type: str, message: str, **extra: object) -> dict[str, Any]:
149
- error: dict[str, object] = {"type": error_type, "message": message}
150
- error.update(extra)
151
- return {"ok": False, "error": error}
152
-
153
-
154
- def _emit(payload: dict[str, Any]) -> None:
155
- sys.stdout.write(
156
- json.dumps(
157
- payload,
158
- ensure_ascii=False,
159
- separators=(",", ":"),
160
- sort_keys=True,
161
- )
162
- + "\n"
163
- )