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.
- {lumera-0.7.3 → lumera-0.8.2}/PKG-INFO +1 -1
- {lumera-0.7.3 → lumera-0.8.2}/lumera/__init__.py +5 -3
- {lumera-0.7.3 → lumera-0.8.2}/lumera/_utils.py +22 -1
- {lumera-0.7.3 → lumera-0.8.2}/lumera/automations.py +9 -9
- lumera-0.8.2/lumera/google.py +47 -0
- {lumera-0.7.3 → lumera-0.8.2}/lumera/sdk.py +9 -8
- lumera-0.8.2/lumera/webhooks.py +304 -0
- {lumera-0.7.3 → lumera-0.8.2}/lumera.egg-info/PKG-INFO +1 -1
- {lumera-0.7.3 → lumera-0.8.2}/lumera.egg-info/SOURCES.txt +1 -0
- {lumera-0.7.3 → lumera-0.8.2}/pyproject.toml +1 -1
- {lumera-0.7.3 → lumera-0.8.2}/tests/test_sdk.py +74 -9
- lumera-0.7.3/lumera/google.py +0 -270
- {lumera-0.7.3 → lumera-0.8.2}/lumera/exceptions.py +0 -0
- {lumera-0.7.3 → lumera-0.8.2}/lumera/llm.py +0 -0
- {lumera-0.7.3 → lumera-0.8.2}/lumera/locks.py +0 -0
- {lumera-0.7.3 → lumera-0.8.2}/lumera/pb.py +0 -0
- {lumera-0.7.3 → lumera-0.8.2}/lumera/storage.py +0 -0
- {lumera-0.7.3 → lumera-0.8.2}/lumera.egg-info/dependency_links.txt +0 -0
- {lumera-0.7.3 → lumera-0.8.2}/lumera.egg-info/requires.txt +0 -0
- {lumera-0.7.3 → lumera-0.8.2}/lumera.egg-info/top_level.txt +0 -0
- {lumera-0.7.3 → 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
|
-
__version__ = "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
|
-
|
|
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:
|
|
@@ -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}"
|
|
@@ -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.7.3/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
|