lumera 0.41.0.dev1__tar.gz → 0.41.0.dev2026090401__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/PKG-INFO +1 -1
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/_utils.py +68 -48
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/function_resources.py +13 -5
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/_remote.py +24 -3
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/_runtime.py +4 -1
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/_api_child.py +104 -27
- lumera-0.41.0.dev2026090401/lumera/releases/_durable_event.py +386 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/_projection.py +20 -2
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/_worker_child.py +196 -130
- lumera-0.41.0.dev2026090401/lumera/releases/_worker_protocol.py +84 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera.egg-info/PKG-INFO +1 -1
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera.egg-info/SOURCES.txt +2 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/pyproject.toml +1 -1
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/tests/test_function_resources.py +19 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/tests/test_sdk.py +13 -100
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/__init__.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/_live.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/agents.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/automations.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/email.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/exceptions.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/files.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/flags.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/__init__.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/__main__.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/_artifacts.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/_config.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/_context.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/_contract.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/_dependencies.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/_errors.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/_execution.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/_jsonschema.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/_registry.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/_runner.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/testing.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/google.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/integrations/__init__.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/integrations/bill.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/integrations/finch.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/integrations/google.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/integrations/kafka.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/integrations/netsuite.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/integrations/salesforce.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/integrations/slack.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/integrations/snowflake.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/llm.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/pb.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/__init__.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/__main__.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/_archive.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/_builder.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/_config.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/_contracts.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/_errors.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/_function_child.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/_runtime.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/_runtime_child.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/_schemas.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/_target.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/sdk.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/storage.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/webhooks.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera.egg-info/dependency_links.txt +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera.egg-info/requires.txt +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera.egg-info/top_level.txt +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/setup.cfg +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/tests/test_agents_live.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/tests/test_automations.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/tests/test_flags.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/tests/test_integrations_bill.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/tests/test_integrations_finch.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/tests/test_integrations_google.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/tests/test_integrations_kafka.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/tests/test_integrations_netsuite.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/tests/test_integrations_salesforce.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/tests/test_integrations_snowflake.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/tests/test_lazy_imports.py +0 -0
- {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/tests/test_webhooks.py +0 -0
|
@@ -9,6 +9,7 @@ import mimetypes
|
|
|
9
9
|
import os
|
|
10
10
|
import pathlib
|
|
11
11
|
import time as _time
|
|
12
|
+
from contextvars import ContextVar, Token
|
|
12
13
|
from dataclasses import dataclass
|
|
13
14
|
from functools import wraps as _wraps
|
|
14
15
|
from typing import IO, Any, Callable, Iterable, Mapping, MutableMapping, Sequence, TypeVar
|
|
@@ -27,6 +28,33 @@ ENV_PATH = "/root/.env"
|
|
|
27
28
|
LOCAL_CREDS_PATH = ".lumera/credentials.json"
|
|
28
29
|
GLOBAL_CREDS_PATH = os.path.join(os.path.expanduser("~"), ".config", "lumera", "credentials.json")
|
|
29
30
|
|
|
31
|
+
_runtime_actor_user_id: ContextVar[str] = ContextVar("lumera_runtime_actor_user_id", default="")
|
|
32
|
+
_runtime_lumera_token: ContextVar[str] = ContextVar("lumera_runtime_lumera_token", default="")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _set_runtime_actor_user_id(user_id: str) -> Token[str]:
|
|
36
|
+
"""Bind an authenticated runtime actor to the current async request."""
|
|
37
|
+
|
|
38
|
+
return _runtime_actor_user_id.set(user_id.strip())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _reset_runtime_actor_user_id(token: Token[str]) -> None:
|
|
42
|
+
"""Restore the runtime actor context after a request completes."""
|
|
43
|
+
|
|
44
|
+
_runtime_actor_user_id.reset(token)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _set_runtime_lumera_token(value: str) -> Token[str]:
|
|
48
|
+
"""Bind an execution-scoped credential to one runtime invocation."""
|
|
49
|
+
|
|
50
|
+
return _runtime_lumera_token.set(value.strip())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _reset_runtime_lumera_token(token: Token[str]) -> None:
|
|
54
|
+
"""Restore the credential active before the runtime invocation."""
|
|
55
|
+
|
|
56
|
+
_runtime_lumera_token.reset(token)
|
|
57
|
+
|
|
30
58
|
|
|
31
59
|
def _credentials_paths() -> tuple[str, ...]:
|
|
32
60
|
explicit = os.getenv(CREDENTIALS_PATH_ENV, "").strip()
|
|
@@ -117,27 +145,36 @@ def _read_token_from_creds_file(path: str) -> str | None:
|
|
|
117
145
|
|
|
118
146
|
|
|
119
147
|
def get_lumera_token() -> str:
|
|
120
|
-
"""Get Lumera API token from environment or credentials files.
|
|
148
|
+
"""Get Lumera API token from runtime context, environment, or credentials files.
|
|
121
149
|
|
|
122
150
|
Priority:
|
|
123
|
-
1.
|
|
124
|
-
2.
|
|
125
|
-
3.
|
|
126
|
-
4.
|
|
127
|
-
5. /
|
|
151
|
+
1. Execution-scoped runtime credential
|
|
152
|
+
2. LUMERA_TOKEN environment variable
|
|
153
|
+
3. LUMERA_CREDENTIALS_PATH (sandbox runtime credentials)
|
|
154
|
+
4. .lumera/credentials.json (project-local, from CLI login --local)
|
|
155
|
+
5. ~/.config/lumera/credentials.json (global, from CLI login)
|
|
156
|
+
6. /root/.env (legacy, for automations in sandbox)
|
|
128
157
|
"""
|
|
129
|
-
#
|
|
158
|
+
# The warmed API process handles different invocation identities over its
|
|
159
|
+
# lifetime, so changing the process environment would leak credentials
|
|
160
|
+
# across requests. Context variables follow async tasks and are explicitly
|
|
161
|
+
# copied into the runtime's sync-function threads.
|
|
162
|
+
token = _runtime_lumera_token.get().strip()
|
|
163
|
+
if token:
|
|
164
|
+
return token
|
|
165
|
+
|
|
166
|
+
# 2. Check environment variable.
|
|
130
167
|
token = os.getenv(TOKEN_ENV)
|
|
131
168
|
if token:
|
|
132
169
|
return token
|
|
133
170
|
|
|
134
|
-
#
|
|
171
|
+
# 3-5. Check explicit runtime, project-local, then global credentials.
|
|
135
172
|
for path in _credentials_paths():
|
|
136
173
|
token = _read_token_from_creds_file(path)
|
|
137
174
|
if token:
|
|
138
175
|
return token
|
|
139
176
|
|
|
140
|
-
#
|
|
177
|
+
# 6. /root/.env is already loaded via dotenv at module load time
|
|
141
178
|
# so if we get here, no token was found anywhere
|
|
142
179
|
raise RuntimeError(
|
|
143
180
|
f"{TOKEN_ENV} not found. Checked: environment, {_credentials_paths()}, "
|
|
@@ -205,7 +242,7 @@ def _parse_expiry(expires_at: int | float | str | None) -> float:
|
|
|
205
242
|
raise TypeError(f"Unsupported expires_at format: {type(expires_at)!r}")
|
|
206
243
|
|
|
207
244
|
|
|
208
|
-
def _fetch_access_token(provider: str) -> tuple[str, float
|
|
245
|
+
def _fetch_access_token(provider: str) -> tuple[str, float]:
|
|
209
246
|
provider = provider.lower().strip()
|
|
210
247
|
if not provider:
|
|
211
248
|
raise ValueError("provider is required")
|
|
@@ -218,16 +255,15 @@ def _fetch_access_token(provider: str) -> tuple[str, float, str]:
|
|
|
218
255
|
data = resp.json()
|
|
219
256
|
access_token = data.get("access_token")
|
|
220
257
|
expires_at = data.get("expires_at")
|
|
221
|
-
credential_scope = str(data.get("credential_scope") or "")
|
|
222
258
|
|
|
223
259
|
if not access_token:
|
|
224
260
|
raise RuntimeError(f"Malformed response from Lumera when fetching {provider} access token")
|
|
225
261
|
|
|
226
262
|
expiry_ts = _parse_expiry(expires_at)
|
|
227
|
-
return access_token, expiry_ts
|
|
263
|
+
return access_token, expiry_ts
|
|
228
264
|
|
|
229
265
|
|
|
230
|
-
def get_access_token(provider: str, min_valid_seconds: int = 900
|
|
266
|
+
def get_access_token(provider: str, min_valid_seconds: int = 900) -> str:
|
|
231
267
|
"""Get the raw access token string for *provider*.
|
|
232
268
|
|
|
233
269
|
.. deprecated::
|
|
@@ -240,8 +276,8 @@ def get_access_token(provider: str, min_valid_seconds: int = 900, *, cache: bool
|
|
|
240
276
|
providers this is the token itself; for basic-auth providers it is the
|
|
241
277
|
base64-encoded ``username:password``.
|
|
242
278
|
|
|
243
|
-
|
|
244
|
-
|
|
279
|
+
Results are cached in-process; a fresh fetch is performed when the cached
|
|
280
|
+
token is within *min_valid_seconds* of expiry (default 15 min).
|
|
245
281
|
"""
|
|
246
282
|
global _token_cache
|
|
247
283
|
|
|
@@ -251,22 +287,19 @@ def get_access_token(provider: str, min_valid_seconds: int = 900, *, cache: bool
|
|
|
251
287
|
|
|
252
288
|
now = _time.time()
|
|
253
289
|
|
|
254
|
-
cached = _token_cache.get(provider)
|
|
290
|
+
cached = _token_cache.get(provider)
|
|
255
291
|
if cached is not None:
|
|
256
292
|
access_token, expiry_ts = cached
|
|
257
293
|
if (expiry_ts - now) >= min_valid_seconds:
|
|
258
294
|
return access_token
|
|
259
295
|
|
|
260
|
-
access_token, expiry_ts
|
|
261
|
-
|
|
262
|
-
_evict_credential_caches(provider)
|
|
263
|
-
elif cache:
|
|
264
|
-
_token_cache[provider] = (access_token, expiry_ts)
|
|
296
|
+
access_token, expiry_ts = _fetch_access_token(provider)
|
|
297
|
+
_token_cache[provider] = (access_token, expiry_ts)
|
|
265
298
|
return access_token
|
|
266
299
|
|
|
267
300
|
|
|
268
|
-
def get_google_access_token(min_valid_seconds: int = 900
|
|
269
|
-
return get_access_token("google", min_valid_seconds=min_valid_seconds
|
|
301
|
+
def get_google_access_token(min_valid_seconds: int = 900) -> str:
|
|
302
|
+
return get_access_token("google", min_valid_seconds=min_valid_seconds)
|
|
270
303
|
|
|
271
304
|
|
|
272
305
|
@dataclass(frozen=True)
|
|
@@ -309,9 +342,6 @@ class Credentials:
|
|
|
309
342
|
extra_headers: dict[str, str] | None = None
|
|
310
343
|
"""Additional provider-required HTTP headers."""
|
|
311
344
|
|
|
312
|
-
credential_scope: str = ""
|
|
313
|
-
"""Credential isolation mode. ``"delegated"`` values are never cached."""
|
|
314
|
-
|
|
315
345
|
def as_header_dict(self) -> dict[str, str]:
|
|
316
346
|
"""Return headers suitable for ``requests.get(headers=...)``."""
|
|
317
347
|
headers = {self.header_name: self.header_value}
|
|
@@ -323,13 +353,6 @@ class Credentials:
|
|
|
323
353
|
_credentials_cache: dict[tuple[str, str], tuple[Credentials, float]] = {}
|
|
324
354
|
|
|
325
355
|
|
|
326
|
-
def _evict_credential_caches(api_name: str) -> None:
|
|
327
|
-
_token_cache.pop(api_name, None)
|
|
328
|
-
for key in tuple(_credentials_cache):
|
|
329
|
-
if key[0] == api_name:
|
|
330
|
-
_credentials_cache.pop(key, None)
|
|
331
|
-
|
|
332
|
-
|
|
333
356
|
def _fetch_credentials(api_name: str, provider: str | None = None) -> Credentials:
|
|
334
357
|
api_name = api_name.lower().strip()
|
|
335
358
|
if not api_name:
|
|
@@ -354,7 +377,6 @@ def _fetch_credentials(api_name: str, provider: str | None = None) -> Credential
|
|
|
354
377
|
# Collect provider-specific extra fields (anything beyond the standard set).
|
|
355
378
|
_standard_keys = {
|
|
356
379
|
"access_token",
|
|
357
|
-
"credential_scope",
|
|
358
380
|
"auth_type",
|
|
359
381
|
"header_name",
|
|
360
382
|
"header_value",
|
|
@@ -377,7 +399,6 @@ def _fetch_credentials(api_name: str, provider: str | None = None) -> Credential
|
|
|
377
399
|
token=access_token,
|
|
378
400
|
provider=str(data.get("provider") or provider_hint or api_name),
|
|
379
401
|
header_name=data.get("header_name", "Authorization"),
|
|
380
|
-
credential_scope=str(data.get("credential_scope") or ""),
|
|
381
402
|
header_value=data.get("header_value", f"Bearer {access_token}"),
|
|
382
403
|
username=data.get("username"),
|
|
383
404
|
expires_at=expiry_ts if expiry_ts != float("inf") else None,
|
|
@@ -390,16 +411,14 @@ def get_credentials(
|
|
|
390
411
|
api_name: str | None = None,
|
|
391
412
|
provider: str | None = None,
|
|
392
413
|
min_valid_seconds: int = 900,
|
|
393
|
-
*,
|
|
394
|
-
cache: bool = True,
|
|
395
414
|
) -> Credentials:
|
|
396
415
|
"""Get a :class:`Credentials` object for *api_name*.
|
|
397
416
|
|
|
398
417
|
This is the recommended way to obtain integration credentials. The
|
|
399
418
|
returned object includes the auth type, ready-to-use HTTP headers, and
|
|
400
|
-
(where applicable) the username.
|
|
401
|
-
|
|
402
|
-
|
|
419
|
+
(where applicable) the username. Results are cached in-process; a
|
|
420
|
+
fresh fetch is performed when the cached credential is within
|
|
421
|
+
*min_valid_seconds* of expiry (default 15 min).
|
|
403
422
|
|
|
404
423
|
Works with any auth type configured in the Lumera integrations page
|
|
405
424
|
(bearer token, basic auth, etc.).
|
|
@@ -450,7 +469,7 @@ def get_credentials(
|
|
|
450
469
|
|
|
451
470
|
now = _time.time()
|
|
452
471
|
cache_key = (api_name, provider_hint)
|
|
453
|
-
cached = _credentials_cache.get(cache_key)
|
|
472
|
+
cached = _credentials_cache.get(cache_key)
|
|
454
473
|
if cached is not None:
|
|
455
474
|
creds, expiry_ts = cached
|
|
456
475
|
if (expiry_ts - now) >= min_valid_seconds:
|
|
@@ -458,12 +477,10 @@ def get_credentials(
|
|
|
458
477
|
|
|
459
478
|
creds = _fetch_credentials(api_name, provider=provider_hint or None)
|
|
460
479
|
expiry_ts = creds.expires_at if creds.expires_at is not None else float("inf")
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
# Keep the legacy _token_cache in sync so get_access_token stays fast.
|
|
466
|
-
_token_cache[api_name] = (creds.token, expiry_ts)
|
|
480
|
+
_credentials_cache[cache_key] = (creds, expiry_ts)
|
|
481
|
+
|
|
482
|
+
# Keep the legacy _token_cache in sync so get_access_token stays fast.
|
|
483
|
+
_token_cache[api_name] = (creds.token, expiry_ts)
|
|
467
484
|
|
|
468
485
|
return creds
|
|
469
486
|
|
|
@@ -787,7 +804,7 @@ def _api_headers(
|
|
|
787
804
|
# User context: forward the human driving an agent turn so the backend
|
|
788
805
|
# attributes mutations to the user (with the agent preserved). Honored only
|
|
789
806
|
# for agent tokens; ignored otherwise. Mirrors the CLI and the lumera_api tool.
|
|
790
|
-
_user_id = os.getenv("LUMERA_USER_ID", "").strip()
|
|
807
|
+
_user_id = _runtime_actor_user_id.get().strip() or os.getenv("LUMERA_USER_ID", "").strip()
|
|
791
808
|
if _user_id:
|
|
792
809
|
headers["X-Lumera-User-Id"] = _user_id
|
|
793
810
|
|
|
@@ -802,10 +819,13 @@ def _api_request(
|
|
|
802
819
|
json_body: Mapping[str, Any] | None = None,
|
|
803
820
|
data: Mapping[str, Any] | None = None,
|
|
804
821
|
files: Mapping[str, Any] | None = None,
|
|
822
|
+
request_headers: Mapping[str, str] | None = None,
|
|
805
823
|
timeout: int = 30,
|
|
806
824
|
) -> object | None:
|
|
807
825
|
url = _api_url(path)
|
|
808
826
|
headers = _api_headers()
|
|
827
|
+
if request_headers:
|
|
828
|
+
headers.update(request_headers)
|
|
809
829
|
|
|
810
830
|
session = _get_session()
|
|
811
831
|
resp = session.request(
|
|
@@ -114,17 +114,25 @@ def list_function_executions(
|
|
|
114
114
|
return _list("function-executions", project, cursor=cursor, limit=limit)
|
|
115
115
|
|
|
116
116
|
|
|
117
|
-
def get_function_execution(
|
|
117
|
+
def get_function_execution(
|
|
118
|
+
project: str,
|
|
119
|
+
execution: str,
|
|
120
|
+
*,
|
|
121
|
+
wait_seconds: int | None = None,
|
|
122
|
+
) -> dict[str, Any]:
|
|
118
123
|
project_ref = _required_ref(project, "project")
|
|
119
124
|
execution_ref = _required_ref(execution, "execution")
|
|
120
125
|
path = (
|
|
121
126
|
f"projects/{quote(project_ref, safe='')}/function-executions/"
|
|
122
127
|
f"{quote(execution_ref, safe='')}"
|
|
123
128
|
)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
129
|
+
options: dict[str, Any] = {}
|
|
130
|
+
if wait_seconds is not None:
|
|
131
|
+
if isinstance(wait_seconds, bool) or not 1 <= wait_seconds <= 30:
|
|
132
|
+
raise ValueError("wait_seconds must be between 1 and 30")
|
|
133
|
+
options["request_headers"] = {"Prefer": f"wait={wait_seconds}"}
|
|
134
|
+
options["timeout"] = wait_seconds + 5
|
|
135
|
+
payload = _api_request("GET", path, **options)
|
|
128
136
|
return _mapping(payload, "Function execution")
|
|
129
137
|
|
|
130
138
|
|
|
@@ -11,6 +11,7 @@ from ._context import FunctionContext
|
|
|
11
11
|
from ._execution import DurableFunctionTransport, ExecutionHandle, ExecutionStatus, StartedExecution
|
|
12
12
|
|
|
13
13
|
_TERMINAL = {"succeeded", "failed", "indeterminate", "timed_out", "cancelled"}
|
|
14
|
+
_MAX_LONG_POLL_SECONDS = 20
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
class RemoteFunctionTransport(DurableFunctionTransport):
|
|
@@ -39,10 +40,18 @@ class RemoteFunctionTransport(DurableFunctionTransport):
|
|
|
39
40
|
return StartedExecution(execution_id=str(execution["id"]), status=str(execution["status"]))
|
|
40
41
|
|
|
41
42
|
async def get_status(self, execution_id: str) -> ExecutionStatus:
|
|
43
|
+
return await self._get_status(execution_id)
|
|
44
|
+
|
|
45
|
+
async def _get_status(
|
|
46
|
+
self, execution_id: str, *, wait_seconds: int | None = None
|
|
47
|
+
) -> ExecutionStatus:
|
|
42
48
|
from lumera import function_resources
|
|
43
49
|
|
|
44
50
|
response = await asyncio.to_thread(
|
|
45
|
-
function_resources.get_function_execution,
|
|
51
|
+
function_resources.get_function_execution,
|
|
52
|
+
self.project,
|
|
53
|
+
execution_id,
|
|
54
|
+
wait_seconds=wait_seconds,
|
|
46
55
|
)
|
|
47
56
|
execution = _execution(response)
|
|
48
57
|
error = None
|
|
@@ -61,7 +70,15 @@ class RemoteFunctionTransport(DurableFunctionTransport):
|
|
|
61
70
|
async def get_result(self, execution_id: str, *, timeout: float) -> object:
|
|
62
71
|
deadline = time.monotonic() + timeout
|
|
63
72
|
while True:
|
|
64
|
-
|
|
73
|
+
remaining = deadline - time.monotonic()
|
|
74
|
+
if remaining <= 0:
|
|
75
|
+
raise TimeoutError(f"Function execution {execution_id} did not finish in time")
|
|
76
|
+
wait_seconds = min(_MAX_LONG_POLL_SECONDS, int(remaining))
|
|
77
|
+
request_started = time.monotonic()
|
|
78
|
+
status = await self._get_status(
|
|
79
|
+
execution_id,
|
|
80
|
+
wait_seconds=wait_seconds if wait_seconds >= 1 else None,
|
|
81
|
+
)
|
|
65
82
|
if status.status in _TERMINAL:
|
|
66
83
|
if status.status != "succeeded":
|
|
67
84
|
raise RuntimeError(
|
|
@@ -72,7 +89,11 @@ class RemoteFunctionTransport(DurableFunctionTransport):
|
|
|
72
89
|
remaining = deadline - time.monotonic()
|
|
73
90
|
if remaining <= 0:
|
|
74
91
|
raise TimeoutError(f"Function execution {execution_id} did not finish in time")
|
|
75
|
-
|
|
92
|
+
# An older backend may ignore Prefer and return immediately. Preserve
|
|
93
|
+
# coarse one-second polling in that case instead of hammering its status
|
|
94
|
+
# endpoint; completion-aware backends normally hold this request.
|
|
95
|
+
request_elapsed = time.monotonic() - request_started
|
|
96
|
+
await asyncio.sleep(min(max(0.0, 1.0 - request_elapsed), remaining))
|
|
76
97
|
|
|
77
98
|
|
|
78
99
|
def invoke_remote(
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import contextvars
|
|
6
7
|
import json
|
|
7
8
|
import threading
|
|
8
9
|
import uuid
|
|
@@ -106,10 +107,12 @@ class _InvocationSyncLane:
|
|
|
106
107
|
self._thread_counter += 1
|
|
107
108
|
thread_number = self._thread_counter
|
|
108
109
|
|
|
110
|
+
request_context = contextvars.copy_context()
|
|
111
|
+
|
|
109
112
|
def invoke() -> None:
|
|
110
113
|
outcome: tuple[object | None, BaseException | None]
|
|
111
114
|
try:
|
|
112
|
-
result = callable_
|
|
115
|
+
result = request_context.run(callable_, *args)
|
|
113
116
|
except BaseException as exc:
|
|
114
117
|
outcome = (None, exc)
|
|
115
118
|
else:
|
|
@@ -10,14 +10,31 @@ import sys
|
|
|
10
10
|
import tempfile
|
|
11
11
|
import threading
|
|
12
12
|
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
from typing import Any
|
|
15
16
|
from urllib.parse import urlparse
|
|
16
17
|
from urllib.request import Request, urlopen
|
|
17
18
|
|
|
18
|
-
from .
|
|
19
|
+
from lumera.functions._registry import FunctionRegistry
|
|
20
|
+
|
|
21
|
+
from ._durable_event import execute_durable_event
|
|
22
|
+
from ._projection import ASGIApp, ASGIReceive, ASGISend, create_release_runtime
|
|
19
23
|
|
|
20
24
|
MAX_RELEASE_BYTES = 250 * 1024 * 1024
|
|
25
|
+
MAX_EVENT_INPUT = 1_000_000
|
|
26
|
+
WARMED_DURABLE_EVENTS_FEATURE = "durable_function_events_v2"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class _LoadedRelease:
|
|
31
|
+
app: ASGIApp
|
|
32
|
+
temporary_root: Path
|
|
33
|
+
registry: FunctionRegistry | None = None
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def root(self) -> Path:
|
|
37
|
+
return self.temporary_root / "release"
|
|
21
38
|
|
|
22
39
|
|
|
23
40
|
class LazyReleaseApp:
|
|
@@ -25,7 +42,7 @@ class LazyReleaseApp:
|
|
|
25
42
|
|
|
26
43
|
def __init__(self) -> None:
|
|
27
44
|
self._lock = threading.Lock()
|
|
28
|
-
self.
|
|
45
|
+
self._release: _LoadedRelease | None = None
|
|
29
46
|
self._startup_lock: asyncio.Lock | None = None
|
|
30
47
|
self._started = False
|
|
31
48
|
self._lifespan_input: asyncio.Queue[dict[str, object]] | None = None
|
|
@@ -46,28 +63,79 @@ class LazyReleaseApp:
|
|
|
46
63
|
await _json_response(
|
|
47
64
|
send,
|
|
48
65
|
200,
|
|
49
|
-
{"status": "healthy", "release_loaded": self.
|
|
66
|
+
{"status": "healthy", "release_loaded": self._release is not None},
|
|
50
67
|
)
|
|
51
68
|
return
|
|
52
|
-
|
|
69
|
+
method = str(scope.get("method", "GET")).upper()
|
|
70
|
+
if scope.get("type") == "http" and method == "POST" and path == "/__lumera/events":
|
|
71
|
+
await self._serve_durable_event(receive, send)
|
|
72
|
+
return
|
|
73
|
+
app = (await self._ready_release()).app
|
|
53
74
|
if scope.get("type") == "http" and path == "/__lumera/warm":
|
|
54
75
|
scope = {**scope, "path": "/__lumera/health", "raw_path": b"/__lumera/health"}
|
|
55
76
|
await app(scope, receive, send)
|
|
56
77
|
|
|
57
|
-
async def
|
|
58
|
-
if self._started and self.
|
|
59
|
-
return self.
|
|
78
|
+
async def _ready_release(self) -> _LoadedRelease:
|
|
79
|
+
if self._started and self._release is not None:
|
|
80
|
+
return self._release
|
|
60
81
|
if self._startup_lock is None:
|
|
61
82
|
self._startup_lock = asyncio.Lock()
|
|
62
83
|
async with self._startup_lock:
|
|
63
|
-
if self._started and self.
|
|
64
|
-
return self.
|
|
65
|
-
|
|
66
|
-
if
|
|
67
|
-
|
|
68
|
-
await self._start_project_lifespan(app)
|
|
84
|
+
if self._started and self._release is not None:
|
|
85
|
+
return self._release
|
|
86
|
+
release = self._release
|
|
87
|
+
if release is None:
|
|
88
|
+
release = await asyncio.to_thread(self._load)
|
|
89
|
+
await self._start_project_lifespan(release.app)
|
|
69
90
|
self._started = True
|
|
70
|
-
return
|
|
91
|
+
return release
|
|
92
|
+
|
|
93
|
+
async def _serve_durable_event(self, receive: ASGIReceive, send: ASGISend) -> None:
|
|
94
|
+
body = bytearray()
|
|
95
|
+
more = True
|
|
96
|
+
while more:
|
|
97
|
+
event = await receive()
|
|
98
|
+
if event.get("type") == "http.disconnect":
|
|
99
|
+
return
|
|
100
|
+
if event.get("type") != "http.request":
|
|
101
|
+
continue
|
|
102
|
+
body.extend(event.get("body", b""))
|
|
103
|
+
if len(body) > MAX_EVENT_INPUT:
|
|
104
|
+
await _json_response(send, 413, {"error": "event too large"})
|
|
105
|
+
return
|
|
106
|
+
more = bool(event.get("more_body"))
|
|
107
|
+
|
|
108
|
+
async def ready_registry() -> FunctionRegistry:
|
|
109
|
+
release = await self._ready_release()
|
|
110
|
+
if release.registry is None:
|
|
111
|
+
raise RuntimeError("release has no Functions")
|
|
112
|
+
return release.registry
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
message = json.loads(body or b"{}")
|
|
116
|
+
if not isinstance(message, dict):
|
|
117
|
+
raise ValueError("event must be an object")
|
|
118
|
+
status, response = await execute_durable_event(
|
|
119
|
+
message,
|
|
120
|
+
ready_registry=ready_registry,
|
|
121
|
+
)
|
|
122
|
+
except ValueError as exc:
|
|
123
|
+
status, response = 400, {"error": str(exc)}
|
|
124
|
+
except BaseException as exc:
|
|
125
|
+
correlation_id = f"fn_{os.urandom(16).hex()}"
|
|
126
|
+
_log_event(
|
|
127
|
+
"functions.app.dispatch_failed",
|
|
128
|
+
error_type=type(exc).__name__,
|
|
129
|
+
correlation_id=correlation_id,
|
|
130
|
+
)
|
|
131
|
+
status, response = (
|
|
132
|
+
500,
|
|
133
|
+
{
|
|
134
|
+
"error": "durable Function dispatch failed",
|
|
135
|
+
"correlation_id": correlation_id,
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
await _json_response(send, status, response)
|
|
71
139
|
|
|
72
140
|
async def _start_project_lifespan(self, app: ASGIApp) -> None:
|
|
73
141
|
input_queue: asyncio.Queue[dict[str, object]] = asyncio.Queue()
|
|
@@ -127,12 +195,12 @@ class LazyReleaseApp:
|
|
|
127
195
|
await self._lifespan_task
|
|
128
196
|
await send({"type": "lifespan.shutdown.complete"})
|
|
129
197
|
|
|
130
|
-
def _load(self) ->
|
|
198
|
+
def _load(self) -> _LoadedRelease:
|
|
131
199
|
with self._lock:
|
|
132
|
-
if self.
|
|
133
|
-
return self.
|
|
134
|
-
self.
|
|
135
|
-
return self.
|
|
200
|
+
if self._release is not None:
|
|
201
|
+
return self._release
|
|
202
|
+
self._release = _load_release_app()
|
|
203
|
+
return self._release
|
|
136
204
|
|
|
137
205
|
|
|
138
206
|
def _fresh_release_url(expected_digest: str) -> str:
|
|
@@ -182,7 +250,7 @@ def _download_release(url: str, destination: Path) -> int:
|
|
|
182
250
|
return total
|
|
183
251
|
|
|
184
252
|
|
|
185
|
-
def _load_release_app() ->
|
|
253
|
+
def _load_release_app() -> _LoadedRelease:
|
|
186
254
|
started = time.perf_counter()
|
|
187
255
|
digest = os.environ.get("LUMERA_RELEASE_DIGEST", "").strip()
|
|
188
256
|
if len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest):
|
|
@@ -236,14 +304,10 @@ def _load_release_app() -> ASGIApp:
|
|
|
236
304
|
await send({"type": "http.response.start", "status": 404, "headers": []})
|
|
237
305
|
await send({"type": "http.response.body", "body": b""})
|
|
238
306
|
|
|
239
|
-
|
|
307
|
+
runtime = create_release_runtime(
|
|
240
308
|
project_app, # type: ignore[arg-type]
|
|
241
309
|
manifest,
|
|
242
|
-
health=
|
|
243
|
-
"release_digest": digest,
|
|
244
|
-
"runtime_abi": expected_abi,
|
|
245
|
-
"image_digest": os.environ.get("FUNCTIONS_RUNTIME_IMAGE_DIGEST", ""),
|
|
246
|
-
},
|
|
310
|
+
health=_runtime_health(digest, expected_abi),
|
|
247
311
|
)
|
|
248
312
|
imported_ms = _elapsed_ms(import_started)
|
|
249
313
|
_log_event(
|
|
@@ -256,7 +320,20 @@ def _load_release_app() -> ASGIApp:
|
|
|
256
320
|
imported_ms=round(imported_ms, 1),
|
|
257
321
|
total_ms=round(_elapsed_ms(started), 1),
|
|
258
322
|
)
|
|
259
|
-
return
|
|
323
|
+
return _LoadedRelease(
|
|
324
|
+
app=runtime.app,
|
|
325
|
+
temporary_root=temporary_root,
|
|
326
|
+
registry=runtime.registry,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _runtime_health(release_digest: str, runtime_abi: str) -> dict[str, object]:
|
|
331
|
+
return {
|
|
332
|
+
"release_digest": release_digest,
|
|
333
|
+
"runtime_abi": runtime_abi,
|
|
334
|
+
"image_digest": os.environ.get("FUNCTIONS_RUNTIME_IMAGE_DIGEST", ""),
|
|
335
|
+
"runtime_features": [WARMED_DURABLE_EVENTS_FEATURE],
|
|
336
|
+
}
|
|
260
337
|
|
|
261
338
|
|
|
262
339
|
def _elapsed_ms(started: float) -> float:
|