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.
- {lumera-0.25.2 → lumera-0.26.0}/PKG-INFO +2 -1
- {lumera-0.25.2 → lumera-0.26.0}/lumera/_utils.py +32 -2
- {lumera-0.25.2 → lumera-0.26.0}/lumera/integrations/__init__.py +3 -1
- {lumera-0.25.2 → lumera-0.26.0}/lumera/integrations/netsuite.py +6 -0
- lumera-0.26.0/lumera/integrations/salesforce.py +62 -0
- {lumera-0.25.2 → lumera-0.26.0}/lumera.egg-info/PKG-INFO +2 -1
- {lumera-0.25.2 → lumera-0.26.0}/lumera.egg-info/SOURCES.txt +2 -0
- {lumera-0.25.2 → lumera-0.26.0}/lumera.egg-info/requires.txt +1 -0
- {lumera-0.25.2 → lumera-0.26.0}/pyproject.toml +2 -1
- lumera-0.26.0/tests/test_integrations_salesforce.py +59 -0
- {lumera-0.25.2 → lumera-0.26.0}/tests/test_sdk.py +26 -0
- {lumera-0.25.2 → lumera-0.26.0}/lumera/__init__.py +0 -0
- {lumera-0.25.2 → lumera-0.26.0}/lumera/_live.py +0 -0
- {lumera-0.25.2 → lumera-0.26.0}/lumera/agents.py +0 -0
- {lumera-0.25.2 → lumera-0.26.0}/lumera/automations.py +0 -0
- {lumera-0.25.2 → lumera-0.26.0}/lumera/email.py +0 -0
- {lumera-0.25.2 → lumera-0.26.0}/lumera/exceptions.py +0 -0
- {lumera-0.25.2 → lumera-0.26.0}/lumera/files.py +0 -0
- {lumera-0.25.2 → lumera-0.26.0}/lumera/flags.py +0 -0
- {lumera-0.25.2 → lumera-0.26.0}/lumera/google.py +0 -0
- {lumera-0.25.2 → lumera-0.26.0}/lumera/integrations/bill.py +0 -0
- {lumera-0.25.2 → lumera-0.26.0}/lumera/integrations/google.py +0 -0
- {lumera-0.25.2 → lumera-0.26.0}/lumera/integrations/slack.py +0 -0
- {lumera-0.25.2 → lumera-0.26.0}/lumera/llm.py +0 -0
- {lumera-0.25.2 → lumera-0.26.0}/lumera/pb.py +0 -0
- {lumera-0.25.2 → lumera-0.26.0}/lumera/sdk.py +0 -0
- {lumera-0.25.2 → lumera-0.26.0}/lumera/storage.py +0 -0
- {lumera-0.25.2 → lumera-0.26.0}/lumera/webhooks.py +0 -0
- {lumera-0.25.2 → lumera-0.26.0}/lumera.egg-info/dependency_links.txt +0 -0
- {lumera-0.25.2 → lumera-0.26.0}/lumera.egg-info/top_level.txt +0 -0
- {lumera-0.25.2 → lumera-0.26.0}/setup.cfg +0 -0
- {lumera-0.25.2 → lumera-0.26.0}/tests/test_agents_live.py +0 -0
- {lumera-0.25.2 → lumera-0.26.0}/tests/test_automations.py +0 -0
- {lumera-0.25.2 → lumera-0.26.0}/tests/test_flags.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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.
|
|
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,12 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "lumera"
|
|
3
|
-
version = "0.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|