lumera 0.25.2__tar.gz → 0.26.0__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 (35) hide show
  1. {lumera-0.25.2 → lumera-0.26.0}/PKG-INFO +2 -1
  2. {lumera-0.25.2 → lumera-0.26.0}/lumera/_utils.py +32 -2
  3. {lumera-0.25.2 → lumera-0.26.0}/lumera/integrations/__init__.py +3 -1
  4. {lumera-0.25.2 → lumera-0.26.0}/lumera/integrations/netsuite.py +6 -0
  5. lumera-0.26.0/lumera/integrations/salesforce.py +62 -0
  6. {lumera-0.25.2 → lumera-0.26.0}/lumera.egg-info/PKG-INFO +2 -1
  7. {lumera-0.25.2 → lumera-0.26.0}/lumera.egg-info/SOURCES.txt +2 -0
  8. {lumera-0.25.2 → lumera-0.26.0}/lumera.egg-info/requires.txt +1 -0
  9. {lumera-0.25.2 → lumera-0.26.0}/pyproject.toml +2 -1
  10. lumera-0.26.0/tests/test_integrations_salesforce.py +59 -0
  11. {lumera-0.25.2 → lumera-0.26.0}/tests/test_sdk.py +26 -0
  12. {lumera-0.25.2 → lumera-0.26.0}/lumera/__init__.py +0 -0
  13. {lumera-0.25.2 → lumera-0.26.0}/lumera/_live.py +0 -0
  14. {lumera-0.25.2 → lumera-0.26.0}/lumera/agents.py +0 -0
  15. {lumera-0.25.2 → lumera-0.26.0}/lumera/automations.py +0 -0
  16. {lumera-0.25.2 → lumera-0.26.0}/lumera/email.py +0 -0
  17. {lumera-0.25.2 → lumera-0.26.0}/lumera/exceptions.py +0 -0
  18. {lumera-0.25.2 → lumera-0.26.0}/lumera/files.py +0 -0
  19. {lumera-0.25.2 → lumera-0.26.0}/lumera/flags.py +0 -0
  20. {lumera-0.25.2 → lumera-0.26.0}/lumera/google.py +0 -0
  21. {lumera-0.25.2 → lumera-0.26.0}/lumera/integrations/bill.py +0 -0
  22. {lumera-0.25.2 → lumera-0.26.0}/lumera/integrations/google.py +0 -0
  23. {lumera-0.25.2 → lumera-0.26.0}/lumera/integrations/slack.py +0 -0
  24. {lumera-0.25.2 → lumera-0.26.0}/lumera/llm.py +0 -0
  25. {lumera-0.25.2 → lumera-0.26.0}/lumera/pb.py +0 -0
  26. {lumera-0.25.2 → lumera-0.26.0}/lumera/sdk.py +0 -0
  27. {lumera-0.25.2 → lumera-0.26.0}/lumera/storage.py +0 -0
  28. {lumera-0.25.2 → lumera-0.26.0}/lumera/webhooks.py +0 -0
  29. {lumera-0.25.2 → lumera-0.26.0}/lumera.egg-info/dependency_links.txt +0 -0
  30. {lumera-0.25.2 → lumera-0.26.0}/lumera.egg-info/top_level.txt +0 -0
  31. {lumera-0.25.2 → lumera-0.26.0}/setup.cfg +0 -0
  32. {lumera-0.25.2 → lumera-0.26.0}/tests/test_agents_live.py +0 -0
  33. {lumera-0.25.2 → lumera-0.26.0}/tests/test_automations.py +0 -0
  34. {lumera-0.25.2 → lumera-0.26.0}/tests/test_flags.py +0 -0
  35. {lumera-0.25.2 → lumera-0.26.0}/tests/test_webhooks.py +0 -0
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lumera
3
- Version: 0.25.2
3
+ Version: 0.26.0
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: simple-salesforce==1.12.6
9
10
  Requires-Dist: slack_sdk==3.35.0
10
11
  Provides-Extra: dev
11
12
  Requires-Dist: ruff; extra == "dev"
@@ -157,6 +157,36 @@ def _raise_if_project_scope_denied(resp: requests.Response, provider: str, url:
157
157
  )
158
158
 
159
159
 
160
+ def _raise_credentials_http_error(resp: requests.Response, api_name: str) -> None:
161
+ """Raise HTTPError for a failed credentials fetch with the server's message.
162
+
163
+ A bare ``raise_for_status()`` only reports "400 Client Error: Bad
164
+ Request", hiding actionable backend messages such as "Provider not
165
+ connected" or the candidate api_names of an ambiguous provider lookup.
166
+ Keeps raising ``requests.HTTPError`` (with ``.response`` attached) so
167
+ existing callers that catch it continue to work.
168
+ """
169
+ if resp.ok:
170
+ return
171
+ detail = ""
172
+ try:
173
+ payload = resp.json()
174
+ except ValueError:
175
+ payload = None
176
+ if isinstance(payload, dict):
177
+ for key in ("error", "message", "detail"):
178
+ value = payload.get(key)
179
+ if isinstance(value, str) and value.strip():
180
+ detail = value.strip()
181
+ break
182
+ if not detail:
183
+ detail = (resp.text or "").strip()[:300]
184
+ message = f"{resp.status_code} error fetching {api_name} credentials from Lumera"
185
+ if detail:
186
+ message += f": {detail}"
187
+ raise requests.HTTPError(message, response=resp)
188
+
189
+
160
190
  def _parse_expiry(expires_at: int | float | str | None) -> float:
161
191
  if not expires_at:
162
192
  return float("inf")
@@ -177,7 +207,7 @@ def _fetch_access_token(provider: str) -> tuple[str, float]:
177
207
  url = f"{API_BASE}/connections/{provider}/access-token"
178
208
  resp = _get_session().get(url, headers=_api_headers(), timeout=30)
179
209
  _raise_if_project_scope_denied(resp, provider, url)
180
- resp.raise_for_status()
210
+ _raise_credentials_http_error(resp, provider)
181
211
 
182
212
  data = resp.json()
183
213
  access_token = data.get("access_token")
@@ -292,7 +322,7 @@ def _fetch_credentials(api_name: str, provider: str | None = None) -> Credential
292
322
  params = {"provider": provider_hint} if provider_hint else None
293
323
  resp = _get_session().get(url, headers=_api_headers(), params=params, timeout=30)
294
324
  _raise_if_project_scope_denied(resp, api_name, url)
295
- resp.raise_for_status()
325
+ _raise_credentials_http_error(resp, api_name)
296
326
 
297
327
  data = resp.json()
298
328
  access_token = data.get("access_token")
@@ -43,6 +43,7 @@ Available integrations:
43
43
  - ``bill`` - BILL AP/AR API
44
44
  - ``google`` - Google APIs (Sheets, Drive)
45
45
  - ``netsuite`` - NetSuite REST API and SuiteQL (M2M or OAuth)
46
+ - ``salesforce`` - Salesforce REST API via customer-owned Client Credentials
46
47
  - ``slack`` - Slack (messages, files, reactions)
47
48
 
48
49
  Utilities:
@@ -64,7 +65,7 @@ from .._utils import (
64
65
  list_integration_connections,
65
66
  list_integrations,
66
67
  )
67
- from . import bill, google, netsuite, slack
68
+ from . import bill, google, netsuite, salesforce, slack
68
69
 
69
70
  __all__ = [
70
71
  "Credentials",
@@ -75,5 +76,6 @@ __all__ = [
75
76
  "bill",
76
77
  "google",
77
78
  "netsuite",
79
+ "salesforce",
78
80
  "slack",
79
81
  ]
@@ -21,6 +21,12 @@ Quick start::
21
21
  ns = NetSuiteClient(api_name="netsuite_prod")
22
22
  accounts = ns.suiteql("SELECT COUNT(*) AS cnt FROM account")
23
23
  je_id = ns.create_record("journalentry", payload)
24
+
25
+ The default ``api_name="netsuite"`` resolves to the connection named
26
+ "netsuite", or — when no connection has that exact name — to the company's
27
+ single connected NetSuite connection regardless of its api_name. Pass an
28
+ explicit ``api_name`` only when the company has multiple NetSuite
29
+ connections.
24
30
  """
25
31
 
26
32
  from __future__ import annotations
@@ -0,0 +1,62 @@
1
+ """Lumera Salesforce integration helpers.
2
+
3
+ Salesforce connections use a customer-owned External Client App with the OAuth
4
+ 2.0 Client Credentials flow. Lumera acquires and renews the short-lived access
5
+ token; SDK callers receive a ready-to-use ``simple-salesforce`` client.
6
+
7
+ Quick start::
8
+
9
+ from lumera.integrations.salesforce import get_client
10
+
11
+ sf = get_client("salesforce_prod")
12
+ accounts = sf.query("SELECT Id, Name FROM Account LIMIT 10")
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+
19
+ from simple_salesforce import Salesforce
20
+
21
+ from lumera._utils import get_credentials
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class SalesforceToken:
26
+ """A Lumera-managed Salesforce access token and org endpoint."""
27
+
28
+ access_token: str
29
+ instance_url: str
30
+ api_version: str | None = None
31
+
32
+
33
+ def get_token(api_name: str = "salesforce") -> SalesforceToken:
34
+ """Return the current Salesforce token and org-specific instance URL."""
35
+
36
+ credentials = get_credentials(api_name, provider="salesforce")
37
+ extra = credentials.extra or {}
38
+ instance_url = str(extra.get("instance_url") or "").strip()
39
+ if not instance_url:
40
+ raise ValueError(
41
+ "Salesforce instance_url not returned by Lumera. "
42
+ "Test or reconnect the Salesforce integration."
43
+ )
44
+ api_version = str(extra.get("api_version") or "").strip() or None
45
+ return SalesforceToken(
46
+ access_token=credentials.token,
47
+ instance_url=instance_url.rstrip("/"),
48
+ api_version=api_version,
49
+ )
50
+
51
+
52
+ def get_client(api_name: str = "salesforce") -> Salesforce:
53
+ """Return an authenticated ``simple-salesforce`` client."""
54
+
55
+ token = get_token(api_name)
56
+ kwargs: dict[str, str] = {
57
+ "instance_url": token.instance_url,
58
+ "session_id": token.access_token,
59
+ }
60
+ if token.api_version:
61
+ kwargs["version"] = token.api_version
62
+ return Salesforce(**kwargs)
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lumera
3
- Version: 0.25.2
3
+ Version: 0.26.0
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: simple-salesforce==1.12.6
9
10
  Requires-Dist: slack_sdk==3.35.0
10
11
  Provides-Extra: dev
11
12
  Requires-Dist: ruff; extra == "dev"
@@ -23,9 +23,11 @@ lumera/integrations/__init__.py
23
23
  lumera/integrations/bill.py
24
24
  lumera/integrations/google.py
25
25
  lumera/integrations/netsuite.py
26
+ lumera/integrations/salesforce.py
26
27
  lumera/integrations/slack.py
27
28
  tests/test_agents_live.py
28
29
  tests/test_automations.py
29
30
  tests/test_flags.py
31
+ tests/test_integrations_salesforce.py
30
32
  tests/test_sdk.py
31
33
  tests/test_webhooks.py
@@ -1,6 +1,7 @@
1
1
  requests
2
2
  python-dotenv
3
3
  google-api-python-client==2.173.0
4
+ simple-salesforce==1.12.6
4
5
  slack_sdk==3.35.0
5
6
 
6
7
  [dev]
@@ -1,12 +1,13 @@
1
1
  [project]
2
2
  name = "lumera"
3
- version = "0.25.2"
3
+ version = "0.26.0"
4
4
  description = "SDK for building on Lumera platform"
5
5
  requires-python = ">=3.11"
6
6
  dependencies = [
7
7
  "requests",
8
8
  "python-dotenv",
9
9
  "google-api-python-client==2.173.0",
10
+ "simple-salesforce==1.12.6",
10
11
  "slack_sdk==3.35.0",
11
12
  ]
12
13
 
@@ -0,0 +1,59 @@
1
+ from types import SimpleNamespace
2
+ from unittest.mock import Mock
3
+
4
+ import pytest
5
+
6
+ from lumera.integrations import salesforce
7
+
8
+
9
+ def test_get_token_reads_salesforce_runtime_metadata(monkeypatch: pytest.MonkeyPatch) -> None:
10
+ get_credentials = Mock(
11
+ return_value=SimpleNamespace(
12
+ token="access-token",
13
+ extra={
14
+ "instance_url": "https://acme.my.salesforce.com/",
15
+ "api_version": "65.0",
16
+ },
17
+ )
18
+ )
19
+ monkeypatch.setattr(salesforce, "get_credentials", get_credentials)
20
+
21
+ token = salesforce.get_token("salesforce_prod")
22
+
23
+ get_credentials.assert_called_once_with("salesforce_prod", provider="salesforce")
24
+ assert token.access_token == "access-token"
25
+ assert token.instance_url == "https://acme.my.salesforce.com"
26
+ assert token.api_version == "65.0"
27
+
28
+
29
+ def test_get_client_builds_simple_salesforce_client(monkeypatch: pytest.MonkeyPatch) -> None:
30
+ client = object()
31
+ constructor = Mock(return_value=client)
32
+ monkeypatch.setattr(salesforce, "Salesforce", constructor)
33
+ monkeypatch.setattr(
34
+ salesforce,
35
+ "get_token",
36
+ lambda _api_name: salesforce.SalesforceToken(
37
+ access_token="access-token",
38
+ instance_url="https://acme.my.salesforce.com",
39
+ api_version="65.0",
40
+ ),
41
+ )
42
+
43
+ assert salesforce.get_client("salesforce_prod") is client
44
+ constructor.assert_called_once_with(
45
+ instance_url="https://acme.my.salesforce.com",
46
+ session_id="access-token",
47
+ version="65.0",
48
+ )
49
+
50
+
51
+ def test_get_token_requires_instance_url(monkeypatch: pytest.MonkeyPatch) -> None:
52
+ monkeypatch.setattr(
53
+ salesforce,
54
+ "get_credentials",
55
+ lambda *_args, **_kwargs: SimpleNamespace(token="access-token", extra={}),
56
+ )
57
+
58
+ with pytest.raises(ValueError, match="instance_url"):
59
+ salesforce.get_token()
@@ -342,6 +342,32 @@ def test_get_credentials_sends_lumera_client_header(monkeypatch: pytest.MonkeyPa
342
342
  assert headers["X-Lumera-Client"].startswith("lumera-sdk/")
343
343
 
344
344
 
345
+ def test_get_credentials_error_includes_server_message(monkeypatch: pytest.MonkeyPatch) -> None:
346
+ monkeypatch.setenv(sdk.TOKEN_ENV, "tok")
347
+ _utils._credentials_cache.clear()
348
+ _utils._token_cache.clear()
349
+
350
+ class MockSession:
351
+ def get(self, url: str, **kwargs: object) -> DummyResponse:
352
+ del url, kwargs
353
+ return DummyResponse(
354
+ status_code=400,
355
+ json_data={"error": "Provider not connected. Complete OAuth flow first."},
356
+ )
357
+
358
+ def mount(self, prefix: str, adapter: object) -> None:
359
+ del prefix, adapter
360
+
361
+ monkeypatch.setattr(_utils, "_get_session", lambda: MockSession())
362
+
363
+ with pytest.raises(requests.HTTPError) as excinfo:
364
+ _utils.get_credentials("netsuite")
365
+
366
+ assert "Provider not connected" in str(excinfo.value)
367
+ assert "netsuite" in str(excinfo.value)
368
+ assert excinfo.value.response.status_code == 400
369
+
370
+
345
371
  def test_get_credentials_sends_provider_validation_param(
346
372
  monkeypatch: pytest.MonkeyPatch,
347
373
  ) -> 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
File without changes
File without changes
File without changes
File without changes