lumera 0.40.0__tar.gz → 0.41.0.dev1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. {lumera-0.40.0 → lumera-0.41.0.dev1}/PKG-INFO +1 -1
  2. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/_utils.py +38 -18
  3. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera.egg-info/PKG-INFO +1 -1
  4. {lumera-0.40.0 → lumera-0.41.0.dev1}/pyproject.toml +1 -1
  5. {lumera-0.40.0 → lumera-0.41.0.dev1}/tests/test_sdk.py +100 -0
  6. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/__init__.py +0 -0
  7. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/_live.py +0 -0
  8. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/agents.py +0 -0
  9. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/automations.py +0 -0
  10. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/email.py +0 -0
  11. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/exceptions.py +0 -0
  12. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/files.py +0 -0
  13. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/flags.py +0 -0
  14. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/function_resources.py +0 -0
  15. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/functions/__init__.py +0 -0
  16. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/functions/__main__.py +0 -0
  17. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/functions/_artifacts.py +0 -0
  18. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/functions/_config.py +0 -0
  19. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/functions/_context.py +0 -0
  20. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/functions/_contract.py +0 -0
  21. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/functions/_dependencies.py +0 -0
  22. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/functions/_errors.py +0 -0
  23. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/functions/_execution.py +0 -0
  24. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/functions/_jsonschema.py +0 -0
  25. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/functions/_registry.py +0 -0
  26. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/functions/_remote.py +0 -0
  27. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/functions/_runner.py +0 -0
  28. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/functions/_runtime.py +0 -0
  29. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/functions/testing.py +0 -0
  30. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/google.py +0 -0
  31. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/integrations/__init__.py +0 -0
  32. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/integrations/bill.py +0 -0
  33. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/integrations/finch.py +0 -0
  34. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/integrations/google.py +0 -0
  35. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/integrations/kafka.py +0 -0
  36. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/integrations/netsuite.py +0 -0
  37. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/integrations/salesforce.py +0 -0
  38. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/integrations/slack.py +0 -0
  39. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/integrations/snowflake.py +0 -0
  40. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/llm.py +0 -0
  41. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/pb.py +0 -0
  42. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/releases/__init__.py +0 -0
  43. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/releases/__main__.py +0 -0
  44. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/releases/_api_child.py +0 -0
  45. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/releases/_archive.py +0 -0
  46. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/releases/_builder.py +0 -0
  47. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/releases/_config.py +0 -0
  48. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/releases/_contracts.py +0 -0
  49. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/releases/_errors.py +0 -0
  50. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/releases/_function_child.py +0 -0
  51. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/releases/_projection.py +0 -0
  52. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/releases/_runtime.py +0 -0
  53. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/releases/_runtime_child.py +0 -0
  54. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/releases/_schemas.py +0 -0
  55. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/releases/_target.py +0 -0
  56. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/releases/_worker_child.py +0 -0
  57. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/sdk.py +0 -0
  58. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/storage.py +0 -0
  59. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera/webhooks.py +0 -0
  60. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera.egg-info/SOURCES.txt +0 -0
  61. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera.egg-info/dependency_links.txt +0 -0
  62. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera.egg-info/requires.txt +0 -0
  63. {lumera-0.40.0 → lumera-0.41.0.dev1}/lumera.egg-info/top_level.txt +0 -0
  64. {lumera-0.40.0 → lumera-0.41.0.dev1}/setup.cfg +0 -0
  65. {lumera-0.40.0 → lumera-0.41.0.dev1}/tests/test_agents_live.py +0 -0
  66. {lumera-0.40.0 → lumera-0.41.0.dev1}/tests/test_automations.py +0 -0
  67. {lumera-0.40.0 → lumera-0.41.0.dev1}/tests/test_flags.py +0 -0
  68. {lumera-0.40.0 → lumera-0.41.0.dev1}/tests/test_function_resources.py +0 -0
  69. {lumera-0.40.0 → lumera-0.41.0.dev1}/tests/test_integrations_bill.py +0 -0
  70. {lumera-0.40.0 → lumera-0.41.0.dev1}/tests/test_integrations_finch.py +0 -0
  71. {lumera-0.40.0 → lumera-0.41.0.dev1}/tests/test_integrations_google.py +0 -0
  72. {lumera-0.40.0 → lumera-0.41.0.dev1}/tests/test_integrations_kafka.py +0 -0
  73. {lumera-0.40.0 → lumera-0.41.0.dev1}/tests/test_integrations_netsuite.py +0 -0
  74. {lumera-0.40.0 → lumera-0.41.0.dev1}/tests/test_integrations_salesforce.py +0 -0
  75. {lumera-0.40.0 → lumera-0.41.0.dev1}/tests/test_integrations_snowflake.py +0 -0
  76. {lumera-0.40.0 → lumera-0.41.0.dev1}/tests/test_lazy_imports.py +0 -0
  77. {lumera-0.40.0 → lumera-0.41.0.dev1}/tests/test_webhooks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lumera
3
- Version: 0.40.0
3
+ Version: 0.41.0.dev1
4
4
  Summary: SDK for building on Lumera platform
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: requests
@@ -205,7 +205,7 @@ def _parse_expiry(expires_at: int | float | str | None) -> float:
205
205
  raise TypeError(f"Unsupported expires_at format: {type(expires_at)!r}")
206
206
 
207
207
 
208
- def _fetch_access_token(provider: str) -> tuple[str, float]:
208
+ def _fetch_access_token(provider: str) -> tuple[str, float, str]:
209
209
  provider = provider.lower().strip()
210
210
  if not provider:
211
211
  raise ValueError("provider is required")
@@ -218,15 +218,16 @@ def _fetch_access_token(provider: str) -> tuple[str, float]:
218
218
  data = resp.json()
219
219
  access_token = data.get("access_token")
220
220
  expires_at = data.get("expires_at")
221
+ credential_scope = str(data.get("credential_scope") or "")
221
222
 
222
223
  if not access_token:
223
224
  raise RuntimeError(f"Malformed response from Lumera when fetching {provider} access token")
224
225
 
225
226
  expiry_ts = _parse_expiry(expires_at)
226
- return access_token, expiry_ts
227
+ return access_token, expiry_ts, credential_scope
227
228
 
228
229
 
229
- def get_access_token(provider: str, min_valid_seconds: int = 900) -> str:
230
+ def get_access_token(provider: str, min_valid_seconds: int = 900, *, cache: bool = True) -> str:
230
231
  """Get the raw access token string for *provider*.
231
232
 
232
233
  .. deprecated::
@@ -239,8 +240,8 @@ def get_access_token(provider: str, min_valid_seconds: int = 900) -> str:
239
240
  providers this is the token itself; for basic-auth providers it is the
240
241
  base64-encoded ``username:password``.
241
242
 
242
- Results are cached in-process; a fresh fetch is performed when the cached
243
- token is within *min_valid_seconds* of expiry (default 15 min).
243
+ Shared results are cached in-process; delegated results are never cached.
244
+ Pass ``cache=False`` to bypass and avoid populating shared caches.
244
245
  """
245
246
  global _token_cache
246
247
 
@@ -250,19 +251,22 @@ def get_access_token(provider: str, min_valid_seconds: int = 900) -> str:
250
251
 
251
252
  now = _time.time()
252
253
 
253
- cached = _token_cache.get(provider)
254
+ cached = _token_cache.get(provider) if cache else None
254
255
  if cached is not None:
255
256
  access_token, expiry_ts = cached
256
257
  if (expiry_ts - now) >= min_valid_seconds:
257
258
  return access_token
258
259
 
259
- access_token, expiry_ts = _fetch_access_token(provider)
260
- _token_cache[provider] = (access_token, expiry_ts)
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)
261
265
  return access_token
262
266
 
263
267
 
264
- def get_google_access_token(min_valid_seconds: int = 900) -> str:
265
- return get_access_token("google", min_valid_seconds=min_valid_seconds)
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)
266
270
 
267
271
 
268
272
  @dataclass(frozen=True)
@@ -305,6 +309,9 @@ class Credentials:
305
309
  extra_headers: dict[str, str] | None = None
306
310
  """Additional provider-required HTTP headers."""
307
311
 
312
+ credential_scope: str = ""
313
+ """Credential isolation mode. ``"delegated"`` values are never cached."""
314
+
308
315
  def as_header_dict(self) -> dict[str, str]:
309
316
  """Return headers suitable for ``requests.get(headers=...)``."""
310
317
  headers = {self.header_name: self.header_value}
@@ -316,6 +323,13 @@ class Credentials:
316
323
  _credentials_cache: dict[tuple[str, str], tuple[Credentials, float]] = {}
317
324
 
318
325
 
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
+
319
333
  def _fetch_credentials(api_name: str, provider: str | None = None) -> Credentials:
320
334
  api_name = api_name.lower().strip()
321
335
  if not api_name:
@@ -340,6 +354,7 @@ def _fetch_credentials(api_name: str, provider: str | None = None) -> Credential
340
354
  # Collect provider-specific extra fields (anything beyond the standard set).
341
355
  _standard_keys = {
342
356
  "access_token",
357
+ "credential_scope",
343
358
  "auth_type",
344
359
  "header_name",
345
360
  "header_value",
@@ -362,6 +377,7 @@ def _fetch_credentials(api_name: str, provider: str | None = None) -> Credential
362
377
  token=access_token,
363
378
  provider=str(data.get("provider") or provider_hint or api_name),
364
379
  header_name=data.get("header_name", "Authorization"),
380
+ credential_scope=str(data.get("credential_scope") or ""),
365
381
  header_value=data.get("header_value", f"Bearer {access_token}"),
366
382
  username=data.get("username"),
367
383
  expires_at=expiry_ts if expiry_ts != float("inf") else None,
@@ -374,14 +390,16 @@ def get_credentials(
374
390
  api_name: str | None = None,
375
391
  provider: str | None = None,
376
392
  min_valid_seconds: int = 900,
393
+ *,
394
+ cache: bool = True,
377
395
  ) -> Credentials:
378
396
  """Get a :class:`Credentials` object for *api_name*.
379
397
 
380
398
  This is the recommended way to obtain integration credentials. The
381
399
  returned object includes the auth type, ready-to-use HTTP headers, and
382
- (where applicable) the username. Results are cached in-process; a
383
- fresh fetch is performed when the cached credential is within
384
- *min_valid_seconds* of expiry (default 15 min).
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.
385
403
 
386
404
  Works with any auth type configured in the Lumera integrations page
387
405
  (bearer token, basic auth, etc.).
@@ -432,7 +450,7 @@ def get_credentials(
432
450
 
433
451
  now = _time.time()
434
452
  cache_key = (api_name, provider_hint)
435
- cached = _credentials_cache.get(cache_key)
453
+ cached = _credentials_cache.get(cache_key) if cache else None
436
454
  if cached is not None:
437
455
  creds, expiry_ts = cached
438
456
  if (expiry_ts - now) >= min_valid_seconds:
@@ -440,10 +458,12 @@ def get_credentials(
440
458
 
441
459
  creds = _fetch_credentials(api_name, provider=provider_hint or None)
442
460
  expiry_ts = creds.expires_at if creds.expires_at is not None else float("inf")
443
- _credentials_cache[cache_key] = (creds, expiry_ts)
444
-
445
- # Keep the legacy _token_cache in sync so get_access_token stays fast.
446
- _token_cache[api_name] = (creds.token, expiry_ts)
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)
447
467
 
448
468
  return creds
449
469
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lumera
3
- Version: 0.40.0
3
+ Version: 0.41.0.dev1
4
4
  Summary: SDK for building on Lumera platform
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: requests
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lumera"
3
- version = "0.40.0"
3
+ version = "0.41.0.dev1"
4
4
  description = "SDK for building on Lumera platform"
5
5
  requires-python = ">=3.11"
6
6
  dependencies = [
@@ -648,6 +648,106 @@ def test_get_credentials_cache_key_includes_provider_hint(
648
648
  assert calls == [{"provider": "slack"}, {"provider": "teams"}]
649
649
 
650
650
 
651
+ def test_delegated_credentials_are_never_cached(monkeypatch: pytest.MonkeyPatch) -> None:
652
+ monkeypatch.setenv(sdk.TOKEN_ENV, "tok")
653
+ _utils._credentials_cache.clear()
654
+ _utils._token_cache.clear()
655
+ calls = 0
656
+
657
+ class MockSession:
658
+ def get(self, url: str, **kwargs: object) -> DummyResponse:
659
+ nonlocal calls
660
+ del url, kwargs
661
+ calls += 1
662
+ return DummyResponse(
663
+ status_code=200,
664
+ json_data={
665
+ "access_token": f"member-token-{calls}",
666
+ "provider": "google",
667
+ "credential_scope": "delegated",
668
+ "expires_at": "2030-01-01T00:00:00Z",
669
+ },
670
+ )
671
+
672
+ monkeypatch.setattr(_utils, "_get_session", lambda: MockSession())
673
+
674
+ tokens = [_utils.get_credentials("google_drive", provider="google").token for _ in range(3)]
675
+
676
+ assert tokens == ["member-token-1", "member-token-2", "member-token-3"]
677
+ assert calls == 3
678
+ assert _utils._credentials_cache == {}
679
+ assert _utils._token_cache == {}
680
+
681
+
682
+ def test_cache_false_bootstraps_and_evicts_delegated_credentials(
683
+ monkeypatch: pytest.MonkeyPatch,
684
+ ) -> None:
685
+ monkeypatch.setenv(sdk.TOKEN_ENV, "tok")
686
+ _utils._credentials_cache.clear()
687
+ _utils._token_cache.clear()
688
+ responses = ["", "delegated", "delegated"]
689
+ calls = 0
690
+
691
+ class MockSession:
692
+ def get(self, url: str, **kwargs: object) -> DummyResponse:
693
+ nonlocal calls
694
+ del url, kwargs
695
+ scope = responses[calls]
696
+ calls += 1
697
+ return DummyResponse(
698
+ status_code=200,
699
+ json_data={
700
+ "access_token": f"token-{calls}",
701
+ "provider": "google",
702
+ "credential_scope": scope,
703
+ "expires_at": "2030-01-01T00:00:00Z",
704
+ },
705
+ )
706
+
707
+ monkeypatch.setattr(_utils, "_get_session", lambda: MockSession())
708
+
709
+ shared = _utils.get_credentials("google_drive", provider="google")
710
+ delegated = _utils.get_credentials("google_drive", provider="google", cache=False)
711
+ fetched_again = _utils.get_credentials("google_drive", provider="google")
712
+
713
+ assert shared.credential_scope == ""
714
+ assert delegated.credential_scope == "delegated"
715
+ assert fetched_again.token == "token-3"
716
+ assert calls == 3
717
+ assert _utils._credentials_cache == {}
718
+ assert _utils._token_cache == {}
719
+
720
+
721
+ def test_delegated_legacy_access_tokens_are_never_cached(
722
+ monkeypatch: pytest.MonkeyPatch,
723
+ ) -> None:
724
+ monkeypatch.setenv(sdk.TOKEN_ENV, "tok")
725
+ _utils._credentials_cache.clear()
726
+ _utils._token_cache.clear()
727
+ calls = 0
728
+
729
+ class MockSession:
730
+ def get(self, url: str, **kwargs: object) -> DummyResponse:
731
+ nonlocal calls
732
+ del url, kwargs
733
+ calls += 1
734
+ return DummyResponse(
735
+ status_code=200,
736
+ json_data={
737
+ "access_token": f"legacy-{calls}",
738
+ "credential_scope": "delegated",
739
+ "expires_at": "2030-01-01T00:00:00Z",
740
+ },
741
+ )
742
+
743
+ monkeypatch.setattr(_utils, "_get_session", lambda: MockSession())
744
+
745
+ assert _utils.get_access_token("google") == "legacy-1"
746
+ assert _utils.get_access_token("google") == "legacy-2"
747
+ assert calls == 2
748
+ assert _utils._token_cache == {}
749
+
750
+
651
751
  def test_get_credentials_preserves_positional_min_valid_seconds(
652
752
  monkeypatch: pytest.MonkeyPatch,
653
753
  ) -> None:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes