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.
Files changed (79) hide show
  1. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/PKG-INFO +1 -1
  2. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/_utils.py +68 -48
  3. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/function_resources.py +13 -5
  4. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/_remote.py +24 -3
  5. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/_runtime.py +4 -1
  6. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/_api_child.py +104 -27
  7. lumera-0.41.0.dev2026090401/lumera/releases/_durable_event.py +386 -0
  8. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/_projection.py +20 -2
  9. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/_worker_child.py +196 -130
  10. lumera-0.41.0.dev2026090401/lumera/releases/_worker_protocol.py +84 -0
  11. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera.egg-info/PKG-INFO +1 -1
  12. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera.egg-info/SOURCES.txt +2 -0
  13. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/pyproject.toml +1 -1
  14. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/tests/test_function_resources.py +19 -0
  15. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/tests/test_sdk.py +13 -100
  16. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/__init__.py +0 -0
  17. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/_live.py +0 -0
  18. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/agents.py +0 -0
  19. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/automations.py +0 -0
  20. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/email.py +0 -0
  21. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/exceptions.py +0 -0
  22. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/files.py +0 -0
  23. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/flags.py +0 -0
  24. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/__init__.py +0 -0
  25. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/__main__.py +0 -0
  26. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/_artifacts.py +0 -0
  27. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/_config.py +0 -0
  28. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/_context.py +0 -0
  29. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/_contract.py +0 -0
  30. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/_dependencies.py +0 -0
  31. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/_errors.py +0 -0
  32. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/_execution.py +0 -0
  33. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/_jsonschema.py +0 -0
  34. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/_registry.py +0 -0
  35. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/_runner.py +0 -0
  36. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/functions/testing.py +0 -0
  37. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/google.py +0 -0
  38. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/integrations/__init__.py +0 -0
  39. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/integrations/bill.py +0 -0
  40. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/integrations/finch.py +0 -0
  41. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/integrations/google.py +0 -0
  42. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/integrations/kafka.py +0 -0
  43. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/integrations/netsuite.py +0 -0
  44. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/integrations/salesforce.py +0 -0
  45. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/integrations/slack.py +0 -0
  46. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/integrations/snowflake.py +0 -0
  47. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/llm.py +0 -0
  48. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/pb.py +0 -0
  49. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/__init__.py +0 -0
  50. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/__main__.py +0 -0
  51. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/_archive.py +0 -0
  52. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/_builder.py +0 -0
  53. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/_config.py +0 -0
  54. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/_contracts.py +0 -0
  55. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/_errors.py +0 -0
  56. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/_function_child.py +0 -0
  57. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/_runtime.py +0 -0
  58. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/_runtime_child.py +0 -0
  59. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/_schemas.py +0 -0
  60. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/releases/_target.py +0 -0
  61. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/sdk.py +0 -0
  62. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/storage.py +0 -0
  63. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera/webhooks.py +0 -0
  64. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera.egg-info/dependency_links.txt +0 -0
  65. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera.egg-info/requires.txt +0 -0
  66. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/lumera.egg-info/top_level.txt +0 -0
  67. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/setup.cfg +0 -0
  68. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/tests/test_agents_live.py +0 -0
  69. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/tests/test_automations.py +0 -0
  70. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/tests/test_flags.py +0 -0
  71. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/tests/test_integrations_bill.py +0 -0
  72. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/tests/test_integrations_finch.py +0 -0
  73. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/tests/test_integrations_google.py +0 -0
  74. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/tests/test_integrations_kafka.py +0 -0
  75. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/tests/test_integrations_netsuite.py +0 -0
  76. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/tests/test_integrations_salesforce.py +0 -0
  77. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/tests/test_integrations_snowflake.py +0 -0
  78. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/tests/test_lazy_imports.py +0 -0
  79. {lumera-0.41.0.dev1 → lumera-0.41.0.dev2026090401}/tests/test_webhooks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lumera
3
- Version: 0.41.0.dev1
3
+ Version: 0.41.0.dev2026090401
4
4
  Summary: SDK for building on Lumera platform
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: requests
@@ -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. LUMERA_TOKEN environment variable
124
- 2. LUMERA_CREDENTIALS_PATH (sandbox runtime credentials)
125
- 3. .lumera/credentials.json (project-local, from CLI login --local)
126
- 4. ~/.config/lumera/credentials.json (global, from CLI login)
127
- 5. /root/.env (legacy, for automations in sandbox)
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
- # 1. Check environment variable (highest priority)
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
- # 2-4. Check explicit runtime, project-local, then global credentials.
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
- # 5. /root/.env is already loaded via dotenv at module load time
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, str]:
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, credential_scope
263
+ return access_token, expiry_ts
228
264
 
229
265
 
230
- def get_access_token(provider: str, min_valid_seconds: int = 900, *, cache: bool = True) -> str:
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
- Shared results are cached in-process; delegated results are never cached.
244
- Pass ``cache=False`` to bypass and avoid populating shared caches.
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) if cache else None
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, credential_scope = _fetch_access_token(provider)
261
- if credential_scope == "delegated":
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, *, cache: bool = True) -> str:
269
- return get_access_token("google", min_valid_seconds=min_valid_seconds, cache=cache)
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. Shared results are cached in-process;
401
- delegated results are never cached. Pass ``cache=False`` to bypass and
402
- avoid populating shared caches.
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) if cache else None
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
- if creds.credential_scope == "delegated":
462
- _evict_credential_caches(api_name)
463
- elif cache:
464
- _credentials_cache[cache_key] = (creds, expiry_ts)
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(project: str, execution: str) -> dict[str, Any]:
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
- payload = _api_request(
125
- "GET",
126
- path,
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, self.project, execution_id
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
- status = await self.get_status(execution_id)
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
- await asyncio.sleep(min(1.0, remaining))
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_(*args)
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 ._projection import ASGIApp, ASGIReceive, ASGISend, create_runtime_app
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._app: ASGIApp | None = None
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._app is not None},
66
+ {"status": "healthy", "release_loaded": self._release is not None},
50
67
  )
51
68
  return
52
- app = await self._ready_app()
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 _ready_app(self) -> ASGIApp:
58
- if self._started and self._app is not None:
59
- return self._app
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._app is not None:
64
- return self._app
65
- app = self._app
66
- if app is None:
67
- app = await asyncio.to_thread(self._load)
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 app
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) -> ASGIApp:
198
+ def _load(self) -> _LoadedRelease:
131
199
  with self._lock:
132
- if self._app is not None:
133
- return self._app
134
- self._app = _load_release_app()
135
- return self._app
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() -> ASGIApp:
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
- app = create_runtime_app(
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 app
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: