lumera 0.8.0__tar.gz → 0.8.2__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.8.0 → lumera-0.8.2}/PKG-INFO +1 -1
- {lumera-0.8.0 → lumera-0.8.2}/lumera/__init__.py +3 -2
- {lumera-0.8.0 → lumera-0.8.2}/lumera/_utils.py +22 -1
- {lumera-0.8.0 → lumera-0.8.2}/lumera/automations.py +9 -9
- lumera-0.8.2/lumera/google.py +47 -0
- {lumera-0.8.0 → lumera-0.8.2}/lumera/sdk.py +9 -8
- {lumera-0.8.0 → lumera-0.8.2}/lumera/webhooks.py +20 -11
- {lumera-0.8.0 → lumera-0.8.2}/lumera.egg-info/PKG-INFO +1 -1
- {lumera-0.8.0 → lumera-0.8.2}/pyproject.toml +1 -1
- {lumera-0.8.0 → lumera-0.8.2}/tests/test_sdk.py +74 -9
- lumera-0.8.0/lumera/google.py +0 -270
- {lumera-0.8.0 → lumera-0.8.2}/lumera/exceptions.py +0 -0
- {lumera-0.8.0 → lumera-0.8.2}/lumera/llm.py +0 -0
- {lumera-0.8.0 → lumera-0.8.2}/lumera/locks.py +0 -0
- {lumera-0.8.0 → lumera-0.8.2}/lumera/pb.py +0 -0
- {lumera-0.8.0 → lumera-0.8.2}/lumera/storage.py +0 -0
- {lumera-0.8.0 → lumera-0.8.2}/lumera.egg-info/SOURCES.txt +0 -0
- {lumera-0.8.0 → lumera-0.8.2}/lumera.egg-info/dependency_links.txt +0 -0
- {lumera-0.8.0 → lumera-0.8.2}/lumera.egg-info/requires.txt +0 -0
- {lumera-0.8.0 → lumera-0.8.2}/lumera.egg-info/top_level.txt +0 -0
- {lumera-0.8.0 → lumera-0.8.2}/setup.cfg +0 -0
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Lumera Agent SDK
|
|
3
3
|
|
|
4
|
-
This SDK provides helpers for
|
|
4
|
+
This SDK provides helpers for automations running within the Lumera environment
|
|
5
5
|
to interact with the Lumera API and define dynamic user interfaces.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
__version__ = "0.8.0"
|
|
9
9
|
|
|
10
10
|
# Import new modules (as modules, not individual functions)
|
|
11
|
-
from . import automations, exceptions, llm, locks, pb, storage, webhooks
|
|
11
|
+
from . import automations, exceptions, integrations, llm, locks, pb, storage, webhooks
|
|
12
12
|
from ._utils import (
|
|
13
13
|
LumeraAPIError,
|
|
14
14
|
RecordNotUniqueError,
|
|
@@ -98,4 +98,5 @@ __all__ = [
|
|
|
98
98
|
"locks",
|
|
99
99
|
"exceptions",
|
|
100
100
|
"webhooks",
|
|
101
|
+
"integrations",
|
|
101
102
|
]
|
|
@@ -13,6 +13,7 @@ from functools import wraps as _wraps
|
|
|
13
13
|
from typing import IO, Any, Callable, Iterable, Mapping, MutableMapping, Sequence, TypeVar
|
|
14
14
|
|
|
15
15
|
import requests
|
|
16
|
+
import requests.adapters
|
|
16
17
|
from dotenv import load_dotenv
|
|
17
18
|
|
|
18
19
|
TOKEN_ENV = "LUMERA_TOKEN"
|
|
@@ -35,6 +36,25 @@ MOUNT_ROOT = _mount_env
|
|
|
35
36
|
|
|
36
37
|
_token_cache: dict[str, tuple[str, float]] = {}
|
|
37
38
|
|
|
39
|
+
# Connection pooling for better performance
|
|
40
|
+
_http_session: requests.Session | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _get_session() -> requests.Session:
|
|
44
|
+
"""Get or create a shared requests Session with connection pooling."""
|
|
45
|
+
global _http_session
|
|
46
|
+
if _http_session is None:
|
|
47
|
+
_http_session = requests.Session()
|
|
48
|
+
# Configure connection pooling
|
|
49
|
+
adapter = requests.adapters.HTTPAdapter(
|
|
50
|
+
pool_connections=10,
|
|
51
|
+
pool_maxsize=20,
|
|
52
|
+
max_retries=0, # Don't retry automatically, let caller handle
|
|
53
|
+
)
|
|
54
|
+
_http_session.mount("https://", adapter)
|
|
55
|
+
_http_session.mount("http://", adapter)
|
|
56
|
+
return _http_session
|
|
57
|
+
|
|
38
58
|
|
|
39
59
|
def get_lumera_token() -> str:
|
|
40
60
|
token = os.getenv(TOKEN_ENV)
|
|
@@ -251,7 +271,8 @@ def _api_request(
|
|
|
251
271
|
"Accept": "application/json",
|
|
252
272
|
}
|
|
253
273
|
|
|
254
|
-
|
|
274
|
+
session = _get_session()
|
|
275
|
+
resp = session.request(
|
|
255
276
|
method,
|
|
256
277
|
url,
|
|
257
278
|
params=params,
|
|
@@ -25,7 +25,7 @@ Example:
|
|
|
25
25
|
>>> from lumera import automations
|
|
26
26
|
>>>
|
|
27
27
|
>>> # Run an automation
|
|
28
|
-
>>> run = automations.run("
|
|
28
|
+
>>> run = automations.run("automation_id", inputs={"limit": 100})
|
|
29
29
|
>>> print(run.id, run.status)
|
|
30
30
|
>>>
|
|
31
31
|
>>> # Wait for completion
|
|
@@ -78,7 +78,7 @@ class Run:
|
|
|
78
78
|
|
|
79
79
|
Attributes:
|
|
80
80
|
id: The run ID.
|
|
81
|
-
automation_id: The automation
|
|
81
|
+
automation_id: The automation ID.
|
|
82
82
|
status: Current status (queued, running, succeeded, failed, cancelled, timeout).
|
|
83
83
|
inputs: The inputs passed to the run.
|
|
84
84
|
result: The result returned by the automation (when succeeded).
|
|
@@ -329,7 +329,7 @@ def run(
|
|
|
329
329
|
"""Run an automation by ID.
|
|
330
330
|
|
|
331
331
|
Args:
|
|
332
|
-
automation_id: The automation
|
|
332
|
+
automation_id: The automation ID to run.
|
|
333
333
|
inputs: Input parameters (dict). Types are coerced based on input_schema.
|
|
334
334
|
files: File inputs to upload (mapping of input key to file path(s)).
|
|
335
335
|
external_id: Optional correlation ID for idempotency. Repeated calls
|
|
@@ -430,7 +430,7 @@ def list_runs(
|
|
|
430
430
|
List of Run objects.
|
|
431
431
|
|
|
432
432
|
Example:
|
|
433
|
-
>>> runs = automations.list_runs("
|
|
433
|
+
>>> runs = automations.list_runs("automation_id", status="succeeded", limit=10)
|
|
434
434
|
>>> for r in runs:
|
|
435
435
|
... print(r.id, r.created, r.status)
|
|
436
436
|
"""
|
|
@@ -509,8 +509,8 @@ def get(automation_id: str) -> Automation:
|
|
|
509
509
|
An Automation object.
|
|
510
510
|
|
|
511
511
|
Example:
|
|
512
|
-
>>>
|
|
513
|
-
>>> print(
|
|
512
|
+
>>> automation = automations.get("abc123")
|
|
513
|
+
>>> print(automation.name, automation.input_schema)
|
|
514
514
|
"""
|
|
515
515
|
automation_id = automation_id.strip()
|
|
516
516
|
if not automation_id:
|
|
@@ -535,8 +535,8 @@ def get_by_external_id(external_id: str) -> Automation:
|
|
|
535
535
|
LumeraAPIError: If no automation with that external_id exists.
|
|
536
536
|
|
|
537
537
|
Example:
|
|
538
|
-
>>>
|
|
539
|
-
>>> print(
|
|
538
|
+
>>> automation = automations.get_by_external_id("deposit_matching:step1")
|
|
539
|
+
>>> print(automation.id, automation.name)
|
|
540
540
|
"""
|
|
541
541
|
external_id = external_id.strip()
|
|
542
542
|
if not external_id:
|
|
@@ -582,7 +582,7 @@ def create(
|
|
|
582
582
|
The created Automation object.
|
|
583
583
|
|
|
584
584
|
Example:
|
|
585
|
-
>>>
|
|
585
|
+
>>> automation = automations.create(
|
|
586
586
|
... name="My Automation",
|
|
587
587
|
... code="def main(x): return {'result': x * 2}",
|
|
588
588
|
... input_schema={
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Backward compatibility shim for lumera.google.
|
|
3
|
+
|
|
4
|
+
This module has moved to lumera.integrations.google.
|
|
5
|
+
All imports are re-exported here for backward compatibility.
|
|
6
|
+
|
|
7
|
+
New code should use:
|
|
8
|
+
from lumera.integrations import google
|
|
9
|
+
# or
|
|
10
|
+
from lumera.integrations.google import get_sheets_service, get_drive_service
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
# Re-export everything from the new location
|
|
14
|
+
from lumera.integrations.google import (
|
|
15
|
+
MIME_EXCEL,
|
|
16
|
+
MIME_GOOGLE_SHEET,
|
|
17
|
+
delete_rows_api_call,
|
|
18
|
+
download_file_direct,
|
|
19
|
+
get_credentials,
|
|
20
|
+
get_drive_service,
|
|
21
|
+
get_google_credentials,
|
|
22
|
+
get_sheets_service,
|
|
23
|
+
get_spreadsheet_and_sheet_id,
|
|
24
|
+
read_cell,
|
|
25
|
+
sheet_name_from_gid,
|
|
26
|
+
upload_excel_as_google_sheet,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
# Authentication
|
|
31
|
+
"get_credentials",
|
|
32
|
+
"get_google_credentials",
|
|
33
|
+
# Services
|
|
34
|
+
"get_sheets_service",
|
|
35
|
+
"get_drive_service",
|
|
36
|
+
# Sheets helpers
|
|
37
|
+
"get_spreadsheet_and_sheet_id",
|
|
38
|
+
"sheet_name_from_gid",
|
|
39
|
+
"read_cell",
|
|
40
|
+
"delete_rows_api_call",
|
|
41
|
+
# Drive helpers
|
|
42
|
+
"download_file_direct",
|
|
43
|
+
"upload_excel_as_google_sheet",
|
|
44
|
+
# Constants
|
|
45
|
+
"MIME_GOOGLE_SHEET",
|
|
46
|
+
"MIME_EXCEL",
|
|
47
|
+
]
|
|
@@ -399,10 +399,10 @@ def run_automation(
|
|
|
399
399
|
external_id: str | None = None,
|
|
400
400
|
metadata: Mapping[str, Any] | None = None,
|
|
401
401
|
) -> dict[str, Any]:
|
|
402
|
-
"""Create an
|
|
402
|
+
"""Create an automation run and optionally upload files for file inputs.
|
|
403
403
|
|
|
404
404
|
Args:
|
|
405
|
-
automation_id: The automation
|
|
405
|
+
automation_id: The automation to run. Required.
|
|
406
406
|
inputs: Inputs payload (dict or JSON string). File refs are resolved automatically.
|
|
407
407
|
files: Mapping of input key -> path(s) to upload before run creation.
|
|
408
408
|
status: Optional initial status (defaults to ``queued``).
|
|
@@ -463,12 +463,13 @@ def get_automation_run(
|
|
|
463
463
|
run_id: str | None = None,
|
|
464
464
|
external_id: str | None = None,
|
|
465
465
|
) -> dict[str, Any]:
|
|
466
|
-
"""Fetch an
|
|
466
|
+
"""Fetch an automation run by id or by automation_id + external_id idempotency key.
|
|
467
467
|
|
|
468
468
|
Args:
|
|
469
|
-
automation_id:
|
|
470
|
-
|
|
471
|
-
|
|
469
|
+
automation_id: Automation id for external_id lookup.
|
|
470
|
+
Required when ``run_id`` is not provided.
|
|
471
|
+
run_id: Optional run id. When provided, takes precedence over external_id.
|
|
472
|
+
external_id: Optional idempotency key to look up the latest run for the automation.
|
|
472
473
|
|
|
473
474
|
Raises:
|
|
474
475
|
ValueError: If required identifiers are missing.
|
|
@@ -510,7 +511,7 @@ def update_automation_run(
|
|
|
510
511
|
error: str | None = None,
|
|
511
512
|
metadata: Mapping[str, Any] | None = None,
|
|
512
513
|
) -> dict[str, Any]:
|
|
513
|
-
"""Update an
|
|
514
|
+
"""Update an automation run with result, status, or other fields.
|
|
514
515
|
|
|
515
516
|
Args:
|
|
516
517
|
run_id: The run id to update. Required.
|
|
@@ -520,7 +521,7 @@ def update_automation_run(
|
|
|
520
521
|
metadata: Optional metadata update.
|
|
521
522
|
|
|
522
523
|
Returns:
|
|
523
|
-
The updated
|
|
524
|
+
The updated automation run record.
|
|
524
525
|
"""
|
|
525
526
|
run_id = run_id.strip() if isinstance(run_id, str) else ""
|
|
526
527
|
if not run_id:
|
|
@@ -25,7 +25,7 @@ Example:
|
|
|
25
25
|
>>> # Get the public URL to configure in Stripe
|
|
26
26
|
>>> webhook_url = webhooks.url("stripe-events")
|
|
27
27
|
>>> print(webhook_url)
|
|
28
|
-
https://app.lumerahq.com/webhooks/
|
|
28
|
+
https://app.lumerahq.com/webhooks/rec_abc123/stripe-events
|
|
29
29
|
>>>
|
|
30
30
|
>>> # List all endpoints
|
|
31
31
|
>>> for ep in webhooks.list():
|
|
@@ -171,11 +171,18 @@ def get(external_id: str) -> dict[str, Any]:
|
|
|
171
171
|
result = _api_request("GET", f"collections/{_COLLECTION}/records", params=params)
|
|
172
172
|
|
|
173
173
|
if not isinstance(result, dict):
|
|
174
|
-
raise LumeraAPIError(
|
|
174
|
+
raise LumeraAPIError(
|
|
175
|
+
404, "endpoint not found", url=f"collections/{_COLLECTION}/records", payload=None
|
|
176
|
+
)
|
|
175
177
|
|
|
176
178
|
items = result.get("items", [])
|
|
177
179
|
if not items:
|
|
178
|
-
raise LumeraAPIError(
|
|
180
|
+
raise LumeraAPIError(
|
|
181
|
+
404,
|
|
182
|
+
f"webhook endpoint '{external_id}' not found",
|
|
183
|
+
url=f"collections/{_COLLECTION}/records",
|
|
184
|
+
payload=None,
|
|
185
|
+
)
|
|
179
186
|
|
|
180
187
|
return items[0]
|
|
181
188
|
|
|
@@ -223,7 +230,9 @@ def update(
|
|
|
223
230
|
if not payload:
|
|
224
231
|
raise ValueError("at least one field (name or description) must be provided")
|
|
225
232
|
|
|
226
|
-
result = _api_request(
|
|
233
|
+
result = _api_request(
|
|
234
|
+
"PATCH", f"collections/{_COLLECTION}/records/{record_id}", json_body=payload
|
|
235
|
+
)
|
|
227
236
|
if not isinstance(result, dict):
|
|
228
237
|
raise RuntimeError("unexpected response payload")
|
|
229
238
|
return result
|
|
@@ -257,7 +266,7 @@ def url(external_id: str) -> str:
|
|
|
257
266
|
"""Get the public webhook URL for an endpoint.
|
|
258
267
|
|
|
259
268
|
This returns the full URL that external services should send webhooks to.
|
|
260
|
-
The URL format is: https://{base}/webhooks/{
|
|
269
|
+
The URL format is: https://{base}/webhooks/{company_id}/{external_id}
|
|
261
270
|
|
|
262
271
|
Args:
|
|
263
272
|
external_id: The external_id of the endpoint
|
|
@@ -267,12 +276,12 @@ def url(external_id: str) -> str:
|
|
|
267
276
|
|
|
268
277
|
Raises:
|
|
269
278
|
ValueError: If external_id is empty
|
|
270
|
-
RuntimeError: If
|
|
279
|
+
RuntimeError: If COMPANY_ID environment variable is not set
|
|
271
280
|
|
|
272
281
|
Example:
|
|
273
282
|
>>> url = webhooks.url("stripe-events")
|
|
274
283
|
>>> print(url)
|
|
275
|
-
https://app.lumerahq.com/webhooks/
|
|
284
|
+
https://app.lumerahq.com/webhooks/rec_abc123/stripe-events
|
|
276
285
|
|
|
277
286
|
# Use this URL when configuring webhooks in Stripe, GitHub, etc.
|
|
278
287
|
"""
|
|
@@ -280,10 +289,10 @@ def url(external_id: str) -> str:
|
|
|
280
289
|
if not external_id:
|
|
281
290
|
raise ValueError("external_id is required")
|
|
282
291
|
|
|
283
|
-
|
|
284
|
-
if not
|
|
292
|
+
company_id = os.getenv("COMPANY_ID", "").strip()
|
|
293
|
+
if not company_id:
|
|
285
294
|
raise RuntimeError(
|
|
286
|
-
"
|
|
295
|
+
"COMPANY_ID environment variable not set. "
|
|
287
296
|
"This is required to construct the webhook URL."
|
|
288
297
|
)
|
|
289
298
|
|
|
@@ -292,4 +301,4 @@ def url(external_id: str) -> str:
|
|
|
292
301
|
if base_url.endswith("/api"):
|
|
293
302
|
base_url = base_url[:-4]
|
|
294
303
|
|
|
295
|
-
return f"{base_url}/webhooks/{
|
|
304
|
+
return f"{base_url}/webhooks/{company_id}/{external_id}"
|
|
@@ -5,6 +5,7 @@ from typing import IO, Any, Mapping
|
|
|
5
5
|
import pytest
|
|
6
6
|
import requests
|
|
7
7
|
|
|
8
|
+
import lumera._utils as _utils
|
|
8
9
|
import lumera.sdk as sdk
|
|
9
10
|
from lumera.sdk import (
|
|
10
11
|
FileRef,
|
|
@@ -31,6 +32,14 @@ ROOT = sdk.MOUNT_ROOT.rstrip("/")
|
|
|
31
32
|
LEGACY_ROOT = "/lumera-files"
|
|
32
33
|
|
|
33
34
|
|
|
35
|
+
@pytest.fixture(autouse=True)
|
|
36
|
+
def reset_http_session() -> None:
|
|
37
|
+
"""Reset the cached HTTP session before each test to allow proper mocking."""
|
|
38
|
+
_utils._http_session = None
|
|
39
|
+
yield
|
|
40
|
+
_utils._http_session = None
|
|
41
|
+
|
|
42
|
+
|
|
34
43
|
class DummyResponse:
|
|
35
44
|
def __init__(
|
|
36
45
|
self,
|
|
@@ -127,7 +136,14 @@ def test_list_collections_uses_token_and_returns_payload(monkeypatch: pytest.Mon
|
|
|
127
136
|
recorded["headers"] = kwargs.get("headers")
|
|
128
137
|
return DummyResponse(json_data={"items": [{"id": "col"}]})
|
|
129
138
|
|
|
130
|
-
|
|
139
|
+
class MockSession:
|
|
140
|
+
def request(self, method: str, url: str, **kwargs: object) -> DummyResponse:
|
|
141
|
+
return fake_request(method, url, **kwargs)
|
|
142
|
+
|
|
143
|
+
def mount(self, prefix: str, adapter: object) -> None:
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
monkeypatch.setattr(_utils, "_get_session", lambda: MockSession())
|
|
131
147
|
|
|
132
148
|
resp = list_collections()
|
|
133
149
|
assert resp["items"][0]["id"] == "col"
|
|
@@ -147,7 +163,14 @@ def test_create_collection_posts_payload(monkeypatch: pytest.MonkeyPatch) -> Non
|
|
|
147
163
|
captured["json"] = kwargs.get("json")
|
|
148
164
|
return DummyResponse(status_code=201, json_data={"id": "new"})
|
|
149
165
|
|
|
150
|
-
|
|
166
|
+
class MockSession:
|
|
167
|
+
def request(self, method: str, url: str, **kwargs: object) -> DummyResponse:
|
|
168
|
+
return fake_request(method, url, **kwargs)
|
|
169
|
+
|
|
170
|
+
def mount(self, prefix: str, adapter: object) -> None:
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
monkeypatch.setattr(_utils, "_get_session", lambda: MockSession())
|
|
151
174
|
|
|
152
175
|
resp = create_collection(
|
|
153
176
|
"example", schema=[{"name": "field", "type": "text"}], indexes=["CREATE INDEX"]
|
|
@@ -170,7 +193,14 @@ def test_create_record_sends_json_payload(monkeypatch: pytest.MonkeyPatch) -> No
|
|
|
170
193
|
captured["json"] = kwargs.get("json")
|
|
171
194
|
return DummyResponse(status_code=201, json_data={"id": "rec"})
|
|
172
195
|
|
|
173
|
-
|
|
196
|
+
class MockSession:
|
|
197
|
+
def request(self, method: str, url: str, **kwargs: object) -> DummyResponse:
|
|
198
|
+
return fake_request(method, url, **kwargs)
|
|
199
|
+
|
|
200
|
+
def mount(self, prefix: str, adapter: object) -> None:
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
monkeypatch.setattr(_utils, "_get_session", lambda: MockSession())
|
|
174
204
|
|
|
175
205
|
resp = create_record("example", {"name": "value"})
|
|
176
206
|
assert resp["id"] == "rec"
|
|
@@ -332,7 +362,14 @@ def test_replay_hook_posts_payload(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
332
362
|
url=url,
|
|
333
363
|
)
|
|
334
364
|
|
|
335
|
-
|
|
365
|
+
class MockSession:
|
|
366
|
+
def request(self, method: str, url: str, **kwargs: object) -> DummyResponse:
|
|
367
|
+
return fake_request(method, url, **kwargs)
|
|
368
|
+
|
|
369
|
+
def mount(self, prefix: str, adapter: object) -> None:
|
|
370
|
+
pass
|
|
371
|
+
|
|
372
|
+
monkeypatch.setattr(_utils, "_get_session", lambda: MockSession())
|
|
336
373
|
|
|
337
374
|
results: list[HookReplayResult] = replay_hook(
|
|
338
375
|
" lm_event_log ",
|
|
@@ -371,7 +408,14 @@ def test_get_collection_error_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
371
408
|
def fake_request(_method: str, _url: str, **_kwargs: object) -> DummyResponse:
|
|
372
409
|
return DummyResponse(status_code=404, json_data={"error": "not found"}, url=_url)
|
|
373
410
|
|
|
374
|
-
|
|
411
|
+
class MockSession:
|
|
412
|
+
def request(self, method: str, url: str, **kwargs: object) -> DummyResponse:
|
|
413
|
+
return fake_request(method, url, **kwargs)
|
|
414
|
+
|
|
415
|
+
def mount(self, prefix: str, adapter: object) -> None:
|
|
416
|
+
pass
|
|
417
|
+
|
|
418
|
+
monkeypatch.setattr(_utils, "_get_session", lambda: MockSession())
|
|
375
419
|
|
|
376
420
|
with pytest.raises(LumeraAPIError) as exc:
|
|
377
421
|
get_collection("missing")
|
|
@@ -390,7 +434,14 @@ def test_create_record_unique_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
390
434
|
url="https://app.lumerahq.com/api/pb/collections/example/records",
|
|
391
435
|
)
|
|
392
436
|
|
|
393
|
-
|
|
437
|
+
class MockSession:
|
|
438
|
+
def request(self, method: str, url: str, **kwargs: object) -> DummyResponse:
|
|
439
|
+
return fake_request(method, url, **kwargs)
|
|
440
|
+
|
|
441
|
+
def mount(self, prefix: str, adapter: object) -> None:
|
|
442
|
+
pass
|
|
443
|
+
|
|
444
|
+
monkeypatch.setattr(_utils, "_get_session", lambda: MockSession())
|
|
394
445
|
|
|
395
446
|
with pytest.raises(RecordNotUniqueError):
|
|
396
447
|
create_record("example", {"external_id": "dup"})
|
|
@@ -407,7 +458,14 @@ def test_get_record_by_external_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
407
458
|
captured["params"] = kwargs.get("params")
|
|
408
459
|
return DummyResponse(json_data={"items": [{"id": "rec"}]})
|
|
409
460
|
|
|
410
|
-
|
|
461
|
+
class MockSession:
|
|
462
|
+
def request(self, method: str, url: str, **kwargs: object) -> DummyResponse:
|
|
463
|
+
return fake_request(method, url, **kwargs)
|
|
464
|
+
|
|
465
|
+
def mount(self, prefix: str, adapter: object) -> None:
|
|
466
|
+
pass
|
|
467
|
+
|
|
468
|
+
monkeypatch.setattr(_utils, "_get_session", lambda: MockSession())
|
|
411
469
|
|
|
412
470
|
record = get_record_by_external_id("example", "ext value")
|
|
413
471
|
assert record["id"] == "rec"
|
|
@@ -424,7 +482,14 @@ def test_get_record_by_external_id_not_found(monkeypatch: pytest.MonkeyPatch) ->
|
|
|
424
482
|
def fake_request(_method: str, _url: str, **_kwargs: object) -> DummyResponse:
|
|
425
483
|
return DummyResponse(json_data={"items": []})
|
|
426
484
|
|
|
427
|
-
|
|
485
|
+
class MockSession:
|
|
486
|
+
def request(self, method: str, url: str, **kwargs: object) -> DummyResponse:
|
|
487
|
+
return fake_request(method, url, **kwargs)
|
|
488
|
+
|
|
489
|
+
def mount(self, prefix: str, adapter: object) -> None:
|
|
490
|
+
pass
|
|
491
|
+
|
|
492
|
+
monkeypatch.setattr(_utils, "_get_session", lambda: MockSession())
|
|
428
493
|
|
|
429
494
|
with pytest.raises(LumeraAPIError) as exc:
|
|
430
495
|
get_record_by_external_id("example", "missing")
|
|
@@ -703,7 +768,7 @@ def test_get_automation_run_by_external_id(monkeypatch: pytest.MonkeyPatch) -> N
|
|
|
703
768
|
|
|
704
769
|
def test_claim_locks_default_provenance_uses_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
705
770
|
monkeypatch.setenv(sdk.TOKEN_ENV, "tok")
|
|
706
|
-
monkeypatch.setenv("
|
|
771
|
+
monkeypatch.setenv("LUMERA_AUTOMATION_ID", "agent-env")
|
|
707
772
|
monkeypatch.setenv("LUMERA_RUN_ID", "run-env")
|
|
708
773
|
|
|
709
774
|
captured: dict[str, object] = {}
|
lumera-0.8.0/lumera/google.py
DELETED
|
@@ -1,270 +0,0 @@
|
|
|
1
|
-
import io
|
|
2
|
-
import logging
|
|
3
|
-
import os
|
|
4
|
-
import re
|
|
5
|
-
from typing import TYPE_CHECKING, Optional, Tuple
|
|
6
|
-
|
|
7
|
-
# When type checking we want access to the concrete ``Resource`` class that
|
|
8
|
-
# ``googleapiclient.discovery.build`` returns. Importing it unconditionally
|
|
9
|
-
# would require ``googleapiclient`` to be available in every execution
|
|
10
|
-
# environment – something we cannot guarantee. By guarding the import with
|
|
11
|
-
# ``TYPE_CHECKING`` we give static analysers (ruff, mypy, etc.) the
|
|
12
|
-
# information they need without introducing a hard runtime dependency.
|
|
13
|
-
# During static analysis we want to import ``Resource`` so that it is a known
|
|
14
|
-
# name for type checkers, but we don't require this import at runtime. Guard
|
|
15
|
-
# it with ``TYPE_CHECKING`` to avoid hard dependencies.
|
|
16
|
-
if TYPE_CHECKING: # pragma: no cover
|
|
17
|
-
from googleapiclient.discovery import Resource # noqa: F401
|
|
18
|
-
|
|
19
|
-
# Always ensure that the symbol ``Resource`` exists at runtime to placate static
|
|
20
|
-
# analysers like ruff (F821) that inspect the AST without executing the code.
|
|
21
|
-
try: # pragma: no cover – optional runtime import
|
|
22
|
-
from googleapiclient.discovery import Resource # type: ignore
|
|
23
|
-
except ModuleNotFoundError: # pragma: no cover – provide a stub fallback
|
|
24
|
-
|
|
25
|
-
class Resource: # noqa: D401
|
|
26
|
-
"""Stub replacement for ``googleapiclient.discovery.Resource``."""
|
|
27
|
-
|
|
28
|
-
pass
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
from google.oauth2.credentials import Credentials
|
|
32
|
-
from googleapiclient.discovery import build
|
|
33
|
-
from googleapiclient.http import MediaFileUpload, MediaIoBaseDownload
|
|
34
|
-
|
|
35
|
-
from lumera import get_access_token
|
|
36
|
-
|
|
37
|
-
# Module logger
|
|
38
|
-
logger = logging.getLogger(__name__)
|
|
39
|
-
|
|
40
|
-
# =====================================================================================
|
|
41
|
-
# Configuration
|
|
42
|
-
# =====================================================================================
|
|
43
|
-
|
|
44
|
-
MIME_GOOGLE_SHEET = "application/vnd.google-apps.spreadsheet"
|
|
45
|
-
MIME_EXCEL = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
46
|
-
|
|
47
|
-
# =====================================================================================
|
|
48
|
-
# Authentication & Service Initialization
|
|
49
|
-
# =====================================================================================
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def get_google_credentials() -> Credentials:
|
|
53
|
-
"""
|
|
54
|
-
Retrieves a Google OAuth token from Lumera and
|
|
55
|
-
converts it into a Credentials object usable by googleapiclient.
|
|
56
|
-
"""
|
|
57
|
-
logger.debug("Fetching Google access token from Lumera…")
|
|
58
|
-
access_token = get_access_token("google")
|
|
59
|
-
logger.debug("Access token received.")
|
|
60
|
-
creds = Credentials(token=access_token)
|
|
61
|
-
logger.debug("Credentials object created.")
|
|
62
|
-
return creds
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def get_sheets_service(credentials: Optional[Credentials] = None) -> 'Resource':
|
|
66
|
-
"""
|
|
67
|
-
Initializes and returns the Google Sheets API service.
|
|
68
|
-
|
|
69
|
-
If no credentials are provided, this function will automatically fetch a
|
|
70
|
-
Google access token from Lumera and construct the appropriate
|
|
71
|
-
``google.oauth2.credentials.Credentials`` instance.
|
|
72
|
-
"""
|
|
73
|
-
if credentials is None:
|
|
74
|
-
logger.info("No credentials provided; fetching Google token…")
|
|
75
|
-
credentials = get_google_credentials()
|
|
76
|
-
logger.info("Google Sheets API service being initialized…")
|
|
77
|
-
return build('sheets', 'v4', credentials=credentials)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def get_drive_service(credentials: Optional[Credentials] = None) -> 'Resource':
|
|
81
|
-
"""
|
|
82
|
-
Initializes and returns the Google Drive API service.
|
|
83
|
-
|
|
84
|
-
If no credentials are provided, this function will automatically fetch a
|
|
85
|
-
Google access token from Lumera and construct the appropriate
|
|
86
|
-
``google.oauth2.credentials.Credentials`` instance.
|
|
87
|
-
"""
|
|
88
|
-
if credentials is None:
|
|
89
|
-
logger.info("No credentials provided; fetching Google token…")
|
|
90
|
-
credentials = get_google_credentials()
|
|
91
|
-
logger.info("Google Drive API service being initialized…")
|
|
92
|
-
return build('drive', 'v3', credentials=credentials)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
# =====================================================================================
|
|
96
|
-
# Google Sheets & Drive Utility Functions
|
|
97
|
-
# =====================================================================================
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def get_spreadsheet_and_sheet_id(
|
|
101
|
-
service: 'Resource', spreadsheet_url: str, tab_name: str
|
|
102
|
-
) -> Tuple[Optional[str], Optional[int]]:
|
|
103
|
-
"""
|
|
104
|
-
Given a Google Sheets URL and a tab (sheet) name, returns a tuple:
|
|
105
|
-
(spreadsheet_id, sheet_id)
|
|
106
|
-
"""
|
|
107
|
-
spreadsheet_id = _extract_spreadsheet_id(spreadsheet_url)
|
|
108
|
-
if not spreadsheet_id:
|
|
109
|
-
return None, None
|
|
110
|
-
|
|
111
|
-
sheet_id = _get_sheet_id_from_name(service, spreadsheet_id, tab_name)
|
|
112
|
-
return spreadsheet_id, sheet_id
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def _extract_spreadsheet_id(spreadsheet_url: str) -> Optional[str]:
|
|
116
|
-
"""Extracts the spreadsheet ID from a Google Sheets URL."""
|
|
117
|
-
logger.debug(f"Extracting spreadsheet ID from URL: {spreadsheet_url}")
|
|
118
|
-
pattern = r"/d/([a-zA-Z0-9-_]+)"
|
|
119
|
-
match = re.search(pattern, spreadsheet_url)
|
|
120
|
-
if match:
|
|
121
|
-
spreadsheet_id = match.group(1)
|
|
122
|
-
logger.debug(f"Spreadsheet ID extracted: {spreadsheet_id}")
|
|
123
|
-
return spreadsheet_id
|
|
124
|
-
logger.warning("Could not extract Spreadsheet ID.")
|
|
125
|
-
return None
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def _get_sheet_id_from_name(
|
|
129
|
-
service: 'Resource', spreadsheet_id: str, tab_name: str
|
|
130
|
-
) -> Optional[int]:
|
|
131
|
-
"""Uses the Google Sheets API to fetch the sheet ID corresponding to 'tab_name'."""
|
|
132
|
-
logger.debug(f"Requesting sheet metadata for spreadsheet ID: {spreadsheet_id}")
|
|
133
|
-
response = (
|
|
134
|
-
service.spreadsheets()
|
|
135
|
-
.get(spreadsheetId=spreadsheet_id, fields="sheets.properties")
|
|
136
|
-
.execute()
|
|
137
|
-
)
|
|
138
|
-
logger.debug("Metadata received. Searching for tab…")
|
|
139
|
-
|
|
140
|
-
for sheet in response.get("sheets", []):
|
|
141
|
-
properties = sheet.get("properties", {})
|
|
142
|
-
if properties.get("title") == tab_name:
|
|
143
|
-
sheet_id = properties.get("sheetId")
|
|
144
|
-
logger.debug(f"Match found for tab '{tab_name}'. Sheet ID is {sheet_id}")
|
|
145
|
-
return sheet_id
|
|
146
|
-
logger.warning(f"No sheet found with tab name '{tab_name}'.")
|
|
147
|
-
return None
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
def sheet_name_from_gid(service: 'Resource', spreadsheet_id: str, gid: int) -> Optional[str]:
|
|
151
|
-
"""Resolve a sheet's human-readable name (title) from its gid."""
|
|
152
|
-
logger.debug(f"Resolving sheet name from gid={gid} …")
|
|
153
|
-
meta = (
|
|
154
|
-
service.spreadsheets()
|
|
155
|
-
.get(
|
|
156
|
-
spreadsheetId=spreadsheet_id,
|
|
157
|
-
includeGridData=False,
|
|
158
|
-
fields="sheets(properties(sheetId,title))",
|
|
159
|
-
)
|
|
160
|
-
.execute()
|
|
161
|
-
)
|
|
162
|
-
|
|
163
|
-
for sheet in meta.get("sheets", []):
|
|
164
|
-
props = sheet.get("properties", {})
|
|
165
|
-
if props.get("sheetId") == gid:
|
|
166
|
-
title = props["title"]
|
|
167
|
-
logger.debug(f"Sheet gid={gid} corresponds to sheet name='{title}'.")
|
|
168
|
-
return title
|
|
169
|
-
logger.warning(f"No sheet found with gid={gid}")
|
|
170
|
-
return None
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def read_cell(service: 'Resource', spreadsheet_id: str, range_a1: str) -> Optional[str]:
|
|
174
|
-
"""Fetch a single cell value (as string); returns None if empty."""
|
|
175
|
-
logger.debug(f"Reading cell '{range_a1}' …")
|
|
176
|
-
resp = (
|
|
177
|
-
service.spreadsheets()
|
|
178
|
-
.values()
|
|
179
|
-
.get(spreadsheetId=spreadsheet_id, range=range_a1, majorDimension="ROWS")
|
|
180
|
-
.execute()
|
|
181
|
-
)
|
|
182
|
-
|
|
183
|
-
values = resp.get("values", [])
|
|
184
|
-
return values[0][0] if values and values[0] else None
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
# NOTE: The function performs I/O side-effects and does not return a value.
|
|
188
|
-
def download_file_direct(drive_service: 'Resource', file_id: str, dest_path: str) -> None:
|
|
189
|
-
"""
|
|
190
|
-
Downloads a file directly from Google Drive using files().get_media
|
|
191
|
-
without any format conversion.
|
|
192
|
-
"""
|
|
193
|
-
logger.info(f"Initiating direct download for file ID: {file_id}")
|
|
194
|
-
|
|
195
|
-
request = drive_service.files().get_media(fileId=file_id)
|
|
196
|
-
fh = io.BytesIO()
|
|
197
|
-
downloader = MediaIoBaseDownload(fh, request)
|
|
198
|
-
|
|
199
|
-
done = False
|
|
200
|
-
while not done:
|
|
201
|
-
status, done = downloader.next_chunk()
|
|
202
|
-
if status:
|
|
203
|
-
logger.debug(f"Download progress: {int(status.progress() * 100)}%")
|
|
204
|
-
|
|
205
|
-
with open(dest_path, "wb") as f:
|
|
206
|
-
f.write(fh.getvalue())
|
|
207
|
-
logger.info(f"File saved to: {dest_path}")
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
def upload_excel_as_google_sheet(
|
|
211
|
-
drive_service: 'Resource', local_path: str, desired_name: str
|
|
212
|
-
) -> Tuple[Optional[str], Optional[str]]:
|
|
213
|
-
"""
|
|
214
|
-
Uploads a local XLSX file to Google Drive, converting it to Google Sheets format.
|
|
215
|
-
Returns the file ID and web link.
|
|
216
|
-
"""
|
|
217
|
-
logger.info(f"Preparing to upload '{local_path}' as Google Sheet named '{desired_name}'")
|
|
218
|
-
|
|
219
|
-
if not os.path.isfile(local_path):
|
|
220
|
-
logger.error(f"Local file not found at '{local_path}'. Aborting.")
|
|
221
|
-
return None, None
|
|
222
|
-
|
|
223
|
-
media = MediaFileUpload(local_path, mimetype=MIME_EXCEL, resumable=True)
|
|
224
|
-
file_metadata = {"name": desired_name, "mimeType": MIME_GOOGLE_SHEET}
|
|
225
|
-
|
|
226
|
-
logger.info("Initiating Google Drive upload & conversion…")
|
|
227
|
-
request = drive_service.files().create(
|
|
228
|
-
body=file_metadata, media_body=media, fields="id, webViewLink"
|
|
229
|
-
)
|
|
230
|
-
|
|
231
|
-
response = None
|
|
232
|
-
while response is None:
|
|
233
|
-
status, response = request.next_chunk()
|
|
234
|
-
if status:
|
|
235
|
-
logger.debug(f"Upload progress: {int(status.progress() * 100)}%")
|
|
236
|
-
|
|
237
|
-
file_id = response.get("id")
|
|
238
|
-
web_view_link = response.get("webViewLink")
|
|
239
|
-
logger.info(f"Upload completed. File ID: {file_id}")
|
|
240
|
-
return file_id, web_view_link
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
# Remove rows from a sheet. All parameters are 1-based (both *start_row* and
|
|
244
|
-
# *end_row* are inclusive) mirroring the UI behaviour in Google Sheets.
|
|
245
|
-
def delete_rows_api_call(
|
|
246
|
-
service: 'Resource',
|
|
247
|
-
spreadsheet_id: str,
|
|
248
|
-
sheet_gid: int,
|
|
249
|
-
start_row: int,
|
|
250
|
-
end_row: int,
|
|
251
|
-
) -> None:
|
|
252
|
-
"""Executes the API call to delete rows."""
|
|
253
|
-
logger.info(f"Deleting rows {start_row}-{end_row} (1-based inclusive)…")
|
|
254
|
-
|
|
255
|
-
body = {
|
|
256
|
-
"requests": [
|
|
257
|
-
{
|
|
258
|
-
"deleteDimension": {
|
|
259
|
-
"range": {
|
|
260
|
-
"sheetId": sheet_gid,
|
|
261
|
-
"dimension": "ROWS",
|
|
262
|
-
"startIndex": start_row - 1, # 0-based
|
|
263
|
-
"endIndex": end_row, # end-exclusive
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
]
|
|
268
|
-
}
|
|
269
|
-
service.spreadsheets().batchUpdate(spreadsheetId=spreadsheet_id, body=body).execute()
|
|
270
|
-
logger.info("Rows deleted.")
|
|
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
|