lumera 0.8.0__tar.gz → 0.8.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lumera
3
- Version: 0.8.0
3
+ Version: 0.8.3
4
4
  Summary: SDK for building on Lumera platform
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: requests
@@ -1,14 +1,14 @@
1
1
  """
2
2
  Lumera Agent SDK
3
3
 
4
- This SDK provides helpers for agents running within the Lumera Notebook environment
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
- resp = requests.request(
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("agent_id", inputs={"limit": 100})
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/agent ID.
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/agent ID to run.
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("agent_id", status="succeeded", limit=10)
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
- >>> agent = automations.get("abc123")
513
- >>> print(agent.name, agent.input_schema)
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
- >>> agent = automations.get_by_external_id("deposit_matching:step1")
539
- >>> print(agent.id, agent.name)
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
- >>> agent = automations.create(
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 .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 agent run and optionally upload files for file inputs.
402
+ """Create an automation run and optionally upload files for file inputs.
403
403
 
404
404
  Args:
405
- automation_id: The automation/agent to run. Required.
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 agent run by id or by automation_id + external_id idempotency key.
466
+ """Fetch an automation run by id or by automation_id + external_id idempotency key.
467
467
 
468
468
  Args:
469
- automation_id: Agent id for external_id lookup. Required when ``run_id`` is not provided.
470
- run_id: Optional run id. When provided, this takes precedence over external_id lookup.
471
- external_id: Optional idempotency key to look up the latest run for the agent.
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 agent run with result, status, or other fields.
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 agent run record.
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/acme/stripe-events
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(404, "endpoint not found", url=f"collections/{_COLLECTION}/records", payload=None)
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(404, f"webhook endpoint '{external_id}' not found", url=f"collections/{_COLLECTION}/records", payload=None)
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("PATCH", f"collections/{_COLLECTION}/records/{record_id}", json_body=payload)
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/{company_api}/{external_id}
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 COMPANY_API_NAME environment variable is not set
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/acme/stripe-events
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
- company_api = os.getenv("COMPANY_API_NAME", "").strip()
284
- if not company_api:
292
+ company_id = os.getenv("COMPANY_ID", "").strip()
293
+ if not company_id:
285
294
  raise RuntimeError(
286
- "COMPANY_API_NAME environment variable not set. "
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/{company_api}/{external_id}"
304
+ return f"{base_url}/webhooks/{company_id}/{external_id}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lumera
3
- Version: 0.8.0
3
+ Version: 0.8.3
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.8.0"
3
+ version = "0.8.3"
4
4
  description = "SDK for building on Lumera platform"
5
5
  requires-python = ">=3.11"
6
6
  dependencies = [
@@ -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
- monkeypatch.setattr(sdk.requests, "request", fake_request)
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
- monkeypatch.setattr(sdk.requests, "request", fake_request)
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
- monkeypatch.setattr(sdk.requests, "request", fake_request)
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
- monkeypatch.setattr(sdk.requests, "request", fake_request)
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
- monkeypatch.setattr(sdk.requests, "request", fake_request)
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
- monkeypatch.setattr(sdk.requests, "request", fake_request)
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
- monkeypatch.setattr(sdk.requests, "request", fake_request)
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
- monkeypatch.setattr(sdk.requests, "request", fake_request)
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("LUMERA_AGENT_ID", "agent-env")
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] = {}
@@ -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