lumera 0.7.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lumera
3
- Version: 0.7.3
3
+ Version: 0.8.2
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
- __version__ = "0.7.0"
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
11
+ from . import automations, exceptions, integrations, llm, locks, pb, storage, webhooks
12
12
  from ._utils import (
13
13
  LumeraAPIError,
14
14
  RecordNotUniqueError,
@@ -97,4 +97,6 @@ __all__ = [
97
97
  "llm",
98
98
  "locks",
99
99
  "exceptions",
100
+ "webhooks",
101
+ "integrations",
100
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 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 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:
@@ -0,0 +1,304 @@
1
+ """
2
+ Webhook endpoint management for Lumera.
3
+
4
+ This module provides functions for managing webhook endpoints that receive
5
+ external events from third-party services (Stripe, GitHub, etc.).
6
+
7
+ Functions:
8
+ create() - Create a new webhook endpoint
9
+ list() - List all webhook endpoints
10
+ get() - Get endpoint by external_id
11
+ update() - Update an existing endpoint
12
+ delete() - Delete an endpoint
13
+ url() - Get the public webhook URL for an endpoint
14
+
15
+ Example:
16
+ >>> from lumera import webhooks
17
+ >>>
18
+ >>> # Create a webhook endpoint
19
+ >>> endpoint = webhooks.create(
20
+ ... name="Stripe Events",
21
+ ... external_id="stripe-events",
22
+ ... description="Receives Stripe payment webhooks"
23
+ ... )
24
+ >>>
25
+ >>> # Get the public URL to configure in Stripe
26
+ >>> webhook_url = webhooks.url("stripe-events")
27
+ >>> print(webhook_url)
28
+ https://app.lumerahq.com/webhooks/rec_abc123/stripe-events
29
+ >>>
30
+ >>> # List all endpoints
31
+ >>> for ep in webhooks.list():
32
+ ... print(ep["name"], ep["external_id"])
33
+
34
+ External ID Format:
35
+ The external_id is used as the URL slug and must follow these rules:
36
+ - 3-50 characters
37
+ - Lowercase alphanumeric and hyphens only
38
+ - Must start with a letter
39
+ - Cannot end with a hyphen
40
+ - No consecutive hyphens
41
+
42
+ Valid examples: "stripe-events", "github-webhooks", "acme-orders"
43
+ Invalid examples: "1-start", "end-", "double--hyphen"
44
+ """
45
+
46
+ import os
47
+ from typing import Any
48
+
49
+ __all__ = [
50
+ "create",
51
+ "list",
52
+ "get",
53
+ "update",
54
+ "delete",
55
+ "url",
56
+ ]
57
+
58
+ from ._utils import API_BASE, LumeraAPIError, _api_request
59
+
60
+ # Collection name for webhook endpoints
61
+ _COLLECTION = "lm_webhook_endpoints"
62
+
63
+
64
+ def create(
65
+ name: str,
66
+ external_id: str,
67
+ *,
68
+ description: str | None = None,
69
+ ) -> dict[str, Any]:
70
+ """Create a new webhook endpoint.
71
+
72
+ Args:
73
+ name: Human-readable name for the endpoint (e.g., "Stripe Events")
74
+ external_id: URL-safe identifier used in the webhook URL.
75
+ Must be 3-50 chars, lowercase alphanumeric with hyphens,
76
+ start with a letter. Example: "stripe-events"
77
+ description: Optional description of what this webhook receives
78
+
79
+ Returns:
80
+ Created endpoint record with id, external_id, name, etc.
81
+
82
+ Raises:
83
+ ValueError: If name or external_id is empty
84
+ LumeraAPIError: If external_id format is invalid or already exists
85
+
86
+ Example:
87
+ >>> endpoint = webhooks.create(
88
+ ... name="Stripe Events",
89
+ ... external_id="stripe-events",
90
+ ... description="Payment and subscription events from Stripe"
91
+ ... )
92
+ >>> print(endpoint["id"])
93
+ """
94
+ name = (name or "").strip()
95
+ external_id = (external_id or "").strip()
96
+
97
+ if not name:
98
+ raise ValueError("name is required")
99
+ if not external_id:
100
+ raise ValueError("external_id is required")
101
+
102
+ payload: dict[str, Any] = {
103
+ "name": name,
104
+ "external_id": external_id,
105
+ }
106
+ if description is not None:
107
+ payload["description"] = description.strip()
108
+
109
+ result = _api_request("POST", f"collections/{_COLLECTION}/records", json_body=payload)
110
+ if not isinstance(result, dict):
111
+ raise RuntimeError("unexpected response payload")
112
+ return result
113
+
114
+
115
+ def list(
116
+ *,
117
+ per_page: int = 100,
118
+ page: int = 1,
119
+ ) -> list[dict[str, Any]]:
120
+ """List all webhook endpoints.
121
+
122
+ Args:
123
+ per_page: Number of results per page (default 100, max 500)
124
+ page: Page number, 1-indexed (default 1)
125
+
126
+ Returns:
127
+ List of endpoint records
128
+
129
+ Example:
130
+ >>> endpoints = webhooks.list()
131
+ >>> for ep in endpoints:
132
+ ... print(f"{ep['name']}: {ep['external_id']}")
133
+ """
134
+ params: dict[str, Any] = {
135
+ "perPage": per_page,
136
+ "page": page,
137
+ "sort": "-created",
138
+ }
139
+
140
+ result = _api_request("GET", f"collections/{_COLLECTION}/records", params=params)
141
+ if not isinstance(result, dict):
142
+ return []
143
+ return result.get("items", [])
144
+
145
+
146
+ def get(external_id: str) -> dict[str, Any]:
147
+ """Get a webhook endpoint by its external_id.
148
+
149
+ Args:
150
+ external_id: The external_id of the endpoint (e.g., "stripe-events")
151
+
152
+ Returns:
153
+ Endpoint record
154
+
155
+ Raises:
156
+ ValueError: If external_id is empty
157
+ LumeraAPIError: If endpoint not found (404)
158
+
159
+ Example:
160
+ >>> endpoint = webhooks.get("stripe-events")
161
+ >>> print(endpoint["name"])
162
+ Stripe Events
163
+ """
164
+ external_id = (external_id or "").strip()
165
+ if not external_id:
166
+ raise ValueError("external_id is required")
167
+
168
+ import json
169
+
170
+ params = {"filter": json.dumps({"external_id": external_id}), "perPage": 1}
171
+ result = _api_request("GET", f"collections/{_COLLECTION}/records", params=params)
172
+
173
+ if not isinstance(result, dict):
174
+ raise LumeraAPIError(
175
+ 404, "endpoint not found", url=f"collections/{_COLLECTION}/records", payload=None
176
+ )
177
+
178
+ items = result.get("items", [])
179
+ if not items:
180
+ raise LumeraAPIError(
181
+ 404,
182
+ f"webhook endpoint '{external_id}' not found",
183
+ url=f"collections/{_COLLECTION}/records",
184
+ payload=None,
185
+ )
186
+
187
+ return items[0]
188
+
189
+
190
+ def update(
191
+ external_id: str,
192
+ *,
193
+ name: str | None = None,
194
+ description: str | None = None,
195
+ ) -> dict[str, Any]:
196
+ """Update a webhook endpoint.
197
+
198
+ Args:
199
+ external_id: The external_id of the endpoint to update
200
+ name: New name (optional)
201
+ description: New description (optional)
202
+
203
+ Returns:
204
+ Updated endpoint record
205
+
206
+ Raises:
207
+ ValueError: If external_id is empty or no fields to update
208
+ LumeraAPIError: If endpoint not found
209
+
210
+ Example:
211
+ >>> endpoint = webhooks.update(
212
+ ... "stripe-events",
213
+ ... description="Updated: Now includes refund events"
214
+ ... )
215
+ """
216
+ external_id = (external_id or "").strip()
217
+ if not external_id:
218
+ raise ValueError("external_id is required")
219
+
220
+ # First, find the endpoint to get its record ID
221
+ endpoint = get(external_id)
222
+ record_id = endpoint["id"]
223
+
224
+ payload: dict[str, Any] = {}
225
+ if name is not None:
226
+ payload["name"] = name.strip()
227
+ if description is not None:
228
+ payload["description"] = description.strip()
229
+
230
+ if not payload:
231
+ raise ValueError("at least one field (name or description) must be provided")
232
+
233
+ result = _api_request(
234
+ "PATCH", f"collections/{_COLLECTION}/records/{record_id}", json_body=payload
235
+ )
236
+ if not isinstance(result, dict):
237
+ raise RuntimeError("unexpected response payload")
238
+ return result
239
+
240
+
241
+ def delete(external_id: str) -> None:
242
+ """Delete a webhook endpoint.
243
+
244
+ Args:
245
+ external_id: The external_id of the endpoint to delete
246
+
247
+ Raises:
248
+ ValueError: If external_id is empty
249
+ LumeraAPIError: If endpoint not found
250
+
251
+ Example:
252
+ >>> webhooks.delete("old-stripe-endpoint")
253
+ """
254
+ external_id = (external_id or "").strip()
255
+ if not external_id:
256
+ raise ValueError("external_id is required")
257
+
258
+ # First, find the endpoint to get its record ID
259
+ endpoint = get(external_id)
260
+ record_id = endpoint["id"]
261
+
262
+ _api_request("DELETE", f"collections/{_COLLECTION}/records/{record_id}")
263
+
264
+
265
+ def url(external_id: str) -> str:
266
+ """Get the public webhook URL for an endpoint.
267
+
268
+ This returns the full URL that external services should send webhooks to.
269
+ The URL format is: https://{base}/webhooks/{company_id}/{external_id}
270
+
271
+ Args:
272
+ external_id: The external_id of the endpoint
273
+
274
+ Returns:
275
+ Full public webhook URL
276
+
277
+ Raises:
278
+ ValueError: If external_id is empty
279
+ RuntimeError: If COMPANY_ID environment variable is not set
280
+
281
+ Example:
282
+ >>> url = webhooks.url("stripe-events")
283
+ >>> print(url)
284
+ https://app.lumerahq.com/webhooks/rec_abc123/stripe-events
285
+
286
+ # Use this URL when configuring webhooks in Stripe, GitHub, etc.
287
+ """
288
+ external_id = (external_id or "").strip()
289
+ if not external_id:
290
+ raise ValueError("external_id is required")
291
+
292
+ company_id = os.getenv("COMPANY_ID", "").strip()
293
+ if not company_id:
294
+ raise RuntimeError(
295
+ "COMPANY_ID environment variable not set. "
296
+ "This is required to construct the webhook URL."
297
+ )
298
+
299
+ # API_BASE is like "https://app.lumerahq.com/api" - we need the base without /api
300
+ base_url = API_BASE.rstrip("/")
301
+ if base_url.endswith("/api"):
302
+ base_url = base_url[:-4]
303
+
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.7.3
3
+ Version: 0.8.2
4
4
  Summary: SDK for building on Lumera platform
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: requests
@@ -9,6 +9,7 @@ lumera/locks.py
9
9
  lumera/pb.py
10
10
  lumera/sdk.py
11
11
  lumera/storage.py
12
+ lumera/webhooks.py
12
13
  lumera.egg-info/PKG-INFO
13
14
  lumera.egg-info/SOURCES.txt
14
15
  lumera.egg-info/dependency_links.txt
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lumera"
3
- version = "0.7.3"
3
+ version = "0.8.2"
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