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 +103 -71
- lumera/integrations/netsuite.py +40 -3
- {lumera-0.28.0.dev1.dist-info → lumera-0.28.0.dev2.dist-info}/METADATA +2 -2
- {lumera-0.28.0.dev1.dist-info → lumera-0.28.0.dev2.dist-info}/RECORD +6 -11
- lumera/functions/__init__.py +0 -55
- lumera/functions/__main__.py +0 -5
- lumera/functions/_bundle.py +0 -194
- lumera/functions/_core.py +0 -382
- lumera/functions/_runner.py +0 -163
- {lumera-0.28.0.dev1.dist-info → lumera-0.28.0.dev2.dist-info}/WHEEL +0 -0
- {lumera-0.28.0.dev1.dist-info → lumera-0.28.0.dev2.dist-info}/top_level.txt +0 -0
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
|
|
14
|
+
__version__ = "unknown" # Not installed (e.g. running from source)
|
|
14
15
|
|
|
15
|
-
#
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
#
|
|
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))
|
lumera/integrations/netsuite.py
CHANGED
|
@@ -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
|
|
7
|
-
through Lumera's integration proxy, so NetSuite
|
|
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.
|
|
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
|
|
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=
|
|
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.
|
|
28
|
-
lumera-0.28.0.
|
|
29
|
-
lumera-0.28.0.
|
|
30
|
-
lumera-0.28.0.
|
|
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,,
|
lumera/functions/__init__.py
DELETED
|
@@ -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
|
-
]
|
lumera/functions/__main__.py
DELETED
lumera/functions/_bundle.py
DELETED
|
@@ -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()
|
lumera/functions/_runner.py
DELETED
|
@@ -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
|
-
)
|
|
File without changes
|
|
File without changes
|