lumera 0.9.1__py3-none-any.whl → 0.9.2__py3-none-any.whl
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/integrations/__init__.py +34 -0
- lumera/integrations/google.py +338 -0
- {lumera-0.9.1.dist-info → lumera-0.9.2.dist-info}/METADATA +1 -1
- {lumera-0.9.1.dist-info → lumera-0.9.2.dist-info}/RECORD +6 -4
- {lumera-0.9.1.dist-info → lumera-0.9.2.dist-info}/WHEEL +0 -0
- {lumera-0.9.1.dist-info → lumera-0.9.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Lumera SDK Integrations
|
|
3
|
+
|
|
4
|
+
Third-party service integrations with Lumera credential management.
|
|
5
|
+
|
|
6
|
+
Each integration module provides:
|
|
7
|
+
- A `get_*_client()` or `get_*_service()` function that returns an authenticated client
|
|
8
|
+
- Optional helper functions for common Lumera patterns
|
|
9
|
+
|
|
10
|
+
Example:
|
|
11
|
+
from lumera.integrations import google, get_access_token
|
|
12
|
+
|
|
13
|
+
# Google Sheets with Lumera-managed OAuth
|
|
14
|
+
sheets = google.get_sheets_service()
|
|
15
|
+
data = sheets.spreadsheets().values().get(...)
|
|
16
|
+
|
|
17
|
+
# Google Drive
|
|
18
|
+
drive = google.get_drive_service()
|
|
19
|
+
files = drive.files().list().execute()
|
|
20
|
+
|
|
21
|
+
# Get raw access token for any provider
|
|
22
|
+
token = get_access_token("slack")
|
|
23
|
+
|
|
24
|
+
Available integrations:
|
|
25
|
+
- `google` - Google APIs (Sheets, Drive)
|
|
26
|
+
|
|
27
|
+
Utilities:
|
|
28
|
+
- `get_access_token(provider)` - Get OAuth token for any Lumera-connected provider
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from .._utils import get_access_token
|
|
32
|
+
from . import google
|
|
33
|
+
|
|
34
|
+
__all__ = ["get_access_token", "google"]
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Google API Integration
|
|
3
|
+
|
|
4
|
+
Provides authenticated Google API clients using Lumera-managed OAuth credentials.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
from lumera.integrations import google
|
|
8
|
+
|
|
9
|
+
# Get authenticated Sheets service
|
|
10
|
+
sheets = google.get_sheets_service()
|
|
11
|
+
data = sheets.spreadsheets().values().get(
|
|
12
|
+
spreadsheetId="...",
|
|
13
|
+
range="Sheet1!A1:D10"
|
|
14
|
+
).execute()
|
|
15
|
+
|
|
16
|
+
# Get authenticated Drive service
|
|
17
|
+
drive = google.get_drive_service()
|
|
18
|
+
files = drive.files().list().execute()
|
|
19
|
+
|
|
20
|
+
# Share credentials between services
|
|
21
|
+
creds = google.get_credentials()
|
|
22
|
+
sheets = google.get_sheets_service(credentials=creds)
|
|
23
|
+
drive = google.get_drive_service(credentials=creds)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import io
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
from typing import TYPE_CHECKING, Optional, Tuple
|
|
31
|
+
|
|
32
|
+
# When type checking we want access to the concrete ``Resource`` class that
|
|
33
|
+
# ``googleapiclient.discovery.build`` returns. Importing it unconditionally
|
|
34
|
+
# would require ``googleapiclient`` to be available in every execution
|
|
35
|
+
# environment – something we cannot guarantee. By guarding the import with
|
|
36
|
+
# ``TYPE_CHECKING`` we give static analysers (ruff, mypy, etc.) the
|
|
37
|
+
# information they need without introducing a hard runtime dependency.
|
|
38
|
+
# During static analysis we want to import ``Resource`` so that it is a known
|
|
39
|
+
# name for type checkers, but we don't require this import at runtime. Guard
|
|
40
|
+
# it with ``TYPE_CHECKING`` to avoid hard dependencies.
|
|
41
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
42
|
+
from googleapiclient.discovery import Resource # noqa: F401
|
|
43
|
+
|
|
44
|
+
# Always ensure that the symbol ``Resource`` exists at runtime to placate static
|
|
45
|
+
# analysers like ruff (F821) that inspect the AST without executing the code.
|
|
46
|
+
try: # pragma: no cover – optional runtime import
|
|
47
|
+
from googleapiclient.discovery import Resource # type: ignore
|
|
48
|
+
except ModuleNotFoundError: # pragma: no cover – provide a stub fallback
|
|
49
|
+
|
|
50
|
+
class Resource: # noqa: D401
|
|
51
|
+
"""Stub replacement for ``googleapiclient.discovery.Resource``."""
|
|
52
|
+
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
from google.oauth2.credentials import Credentials
|
|
57
|
+
from googleapiclient.discovery import build
|
|
58
|
+
from googleapiclient.http import MediaFileUpload, MediaIoBaseDownload
|
|
59
|
+
|
|
60
|
+
from .._utils import get_access_token
|
|
61
|
+
|
|
62
|
+
# Module logger
|
|
63
|
+
logger = logging.getLogger(__name__)
|
|
64
|
+
|
|
65
|
+
# =====================================================================================
|
|
66
|
+
# Configuration
|
|
67
|
+
# =====================================================================================
|
|
68
|
+
|
|
69
|
+
MIME_GOOGLE_SHEET = "application/vnd.google-apps.spreadsheet"
|
|
70
|
+
MIME_EXCEL = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
71
|
+
|
|
72
|
+
# =====================================================================================
|
|
73
|
+
# Authentication & Service Initialization
|
|
74
|
+
# =====================================================================================
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_credentials() -> Credentials:
|
|
78
|
+
"""
|
|
79
|
+
Retrieves a Google OAuth token from Lumera and
|
|
80
|
+
converts it into a Credentials object usable by googleapiclient.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
google.oauth2.credentials.Credentials: Authenticated credentials object.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
RuntimeError: If LUMERA_TOKEN is not set or token fetch fails.
|
|
87
|
+
"""
|
|
88
|
+
logger.debug("Fetching Google access token from Lumera…")
|
|
89
|
+
access_token = get_access_token("google")
|
|
90
|
+
logger.debug("Access token received.")
|
|
91
|
+
creds = Credentials(token=access_token)
|
|
92
|
+
logger.debug("Credentials object created.")
|
|
93
|
+
return creds
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# Backward compatibility alias
|
|
97
|
+
get_google_credentials = get_credentials
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_sheets_service(credentials: Optional[Credentials] = None) -> "Resource":
|
|
101
|
+
"""
|
|
102
|
+
Initializes and returns the Google Sheets API service.
|
|
103
|
+
|
|
104
|
+
If no credentials are provided, this function will automatically fetch a
|
|
105
|
+
Google access token from Lumera and construct the appropriate
|
|
106
|
+
``google.oauth2.credentials.Credentials`` instance.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
credentials: Optional pre-fetched credentials. If None, fetches from Lumera.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Google Sheets API service resource.
|
|
113
|
+
"""
|
|
114
|
+
if credentials is None:
|
|
115
|
+
logger.info("No credentials provided; fetching Google token…")
|
|
116
|
+
credentials = get_credentials()
|
|
117
|
+
logger.info("Google Sheets API service being initialized…")
|
|
118
|
+
return build("sheets", "v4", credentials=credentials)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_drive_service(credentials: Optional[Credentials] = None) -> "Resource":
|
|
122
|
+
"""
|
|
123
|
+
Initializes and returns the Google Drive API service.
|
|
124
|
+
|
|
125
|
+
If no credentials are provided, this function will automatically fetch a
|
|
126
|
+
Google access token from Lumera and construct the appropriate
|
|
127
|
+
``google.oauth2.credentials.Credentials`` instance.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
credentials: Optional pre-fetched credentials. If None, fetches from Lumera.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Google Drive API service resource.
|
|
134
|
+
"""
|
|
135
|
+
if credentials is None:
|
|
136
|
+
logger.info("No credentials provided; fetching Google token…")
|
|
137
|
+
credentials = get_credentials()
|
|
138
|
+
logger.info("Google Drive API service being initialized…")
|
|
139
|
+
return build("drive", "v3", credentials=credentials)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# =====================================================================================
|
|
143
|
+
# Google Sheets & Drive Utility Functions
|
|
144
|
+
# =====================================================================================
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def get_spreadsheet_and_sheet_id(
|
|
148
|
+
service: "Resource", spreadsheet_url: str, tab_name: str
|
|
149
|
+
) -> Tuple[Optional[str], Optional[int]]:
|
|
150
|
+
"""
|
|
151
|
+
Given a Google Sheets URL and a tab (sheet) name, returns a tuple:
|
|
152
|
+
(spreadsheet_id, sheet_id)
|
|
153
|
+
"""
|
|
154
|
+
spreadsheet_id = _extract_spreadsheet_id(spreadsheet_url)
|
|
155
|
+
if not spreadsheet_id:
|
|
156
|
+
return None, None
|
|
157
|
+
|
|
158
|
+
sheet_id = _get_sheet_id_from_name(service, spreadsheet_id, tab_name)
|
|
159
|
+
return spreadsheet_id, sheet_id
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _extract_spreadsheet_id(spreadsheet_url: str) -> Optional[str]:
|
|
163
|
+
"""Extracts the spreadsheet ID from a Google Sheets URL."""
|
|
164
|
+
logger.debug(f"Extracting spreadsheet ID from URL: {spreadsheet_url}")
|
|
165
|
+
pattern = r"/d/([a-zA-Z0-9-_]+)"
|
|
166
|
+
match = re.search(pattern, spreadsheet_url)
|
|
167
|
+
if match:
|
|
168
|
+
spreadsheet_id = match.group(1)
|
|
169
|
+
logger.debug(f"Spreadsheet ID extracted: {spreadsheet_id}")
|
|
170
|
+
return spreadsheet_id
|
|
171
|
+
logger.warning("Could not extract Spreadsheet ID.")
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _get_sheet_id_from_name(
|
|
176
|
+
service: "Resource", spreadsheet_id: str, tab_name: str
|
|
177
|
+
) -> Optional[int]:
|
|
178
|
+
"""Uses the Google Sheets API to fetch the sheet ID corresponding to 'tab_name'."""
|
|
179
|
+
logger.debug(f"Requesting sheet metadata for spreadsheet ID: {spreadsheet_id}")
|
|
180
|
+
response = (
|
|
181
|
+
service.spreadsheets()
|
|
182
|
+
.get(spreadsheetId=spreadsheet_id, fields="sheets.properties")
|
|
183
|
+
.execute()
|
|
184
|
+
)
|
|
185
|
+
logger.debug("Metadata received. Searching for tab…")
|
|
186
|
+
|
|
187
|
+
for sheet in response.get("sheets", []):
|
|
188
|
+
properties = sheet.get("properties", {})
|
|
189
|
+
if properties.get("title") == tab_name:
|
|
190
|
+
sheet_id = properties.get("sheetId")
|
|
191
|
+
logger.debug(f"Match found for tab '{tab_name}'. Sheet ID is {sheet_id}")
|
|
192
|
+
return sheet_id
|
|
193
|
+
logger.warning(f"No sheet found with tab name '{tab_name}'.")
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def sheet_name_from_gid(service: "Resource", spreadsheet_id: str, gid: int) -> Optional[str]:
|
|
198
|
+
"""Resolve a sheet's human-readable name (title) from its gid."""
|
|
199
|
+
logger.debug(f"Resolving sheet name from gid={gid} …")
|
|
200
|
+
meta = (
|
|
201
|
+
service.spreadsheets()
|
|
202
|
+
.get(
|
|
203
|
+
spreadsheetId=spreadsheet_id,
|
|
204
|
+
includeGridData=False,
|
|
205
|
+
fields="sheets(properties(sheetId,title))",
|
|
206
|
+
)
|
|
207
|
+
.execute()
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
for sheet in meta.get("sheets", []):
|
|
211
|
+
props = sheet.get("properties", {})
|
|
212
|
+
if props.get("sheetId") == gid:
|
|
213
|
+
title = props["title"]
|
|
214
|
+
logger.debug(f"Sheet gid={gid} corresponds to sheet name='{title}'.")
|
|
215
|
+
return title
|
|
216
|
+
logger.warning(f"No sheet found with gid={gid}")
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def read_cell(service: "Resource", spreadsheet_id: str, range_a1: str) -> Optional[str]:
|
|
221
|
+
"""Fetch a single cell value (as string); returns None if empty."""
|
|
222
|
+
logger.debug(f"Reading cell '{range_a1}' …")
|
|
223
|
+
resp = (
|
|
224
|
+
service.spreadsheets()
|
|
225
|
+
.values()
|
|
226
|
+
.get(spreadsheetId=spreadsheet_id, range=range_a1, majorDimension="ROWS")
|
|
227
|
+
.execute()
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
values = resp.get("values", [])
|
|
231
|
+
return values[0][0] if values and values[0] else None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# NOTE: The function performs I/O side-effects and does not return a value.
|
|
235
|
+
def download_file_direct(drive_service: "Resource", file_id: str, dest_path: str) -> None:
|
|
236
|
+
"""
|
|
237
|
+
Downloads a file directly from Google Drive using files().get_media
|
|
238
|
+
without any format conversion.
|
|
239
|
+
"""
|
|
240
|
+
logger.info(f"Initiating direct download for file ID: {file_id}")
|
|
241
|
+
|
|
242
|
+
request = drive_service.files().get_media(fileId=file_id)
|
|
243
|
+
fh = io.BytesIO()
|
|
244
|
+
downloader = MediaIoBaseDownload(fh, request)
|
|
245
|
+
|
|
246
|
+
done = False
|
|
247
|
+
while not done:
|
|
248
|
+
status, done = downloader.next_chunk()
|
|
249
|
+
if status:
|
|
250
|
+
logger.debug(f"Download progress: {int(status.progress() * 100)}%")
|
|
251
|
+
|
|
252
|
+
with open(dest_path, "wb") as f:
|
|
253
|
+
f.write(fh.getvalue())
|
|
254
|
+
logger.info(f"File saved to: {dest_path}")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def upload_excel_as_google_sheet(
|
|
258
|
+
drive_service: "Resource", local_path: str, desired_name: str
|
|
259
|
+
) -> Tuple[Optional[str], Optional[str]]:
|
|
260
|
+
"""
|
|
261
|
+
Uploads a local XLSX file to Google Drive, converting it to Google Sheets format.
|
|
262
|
+
Returns the file ID and web link.
|
|
263
|
+
"""
|
|
264
|
+
logger.info(f"Preparing to upload '{local_path}' as Google Sheet named '{desired_name}'")
|
|
265
|
+
|
|
266
|
+
if not os.path.isfile(local_path):
|
|
267
|
+
logger.error(f"Local file not found at '{local_path}'. Aborting.")
|
|
268
|
+
return None, None
|
|
269
|
+
|
|
270
|
+
media = MediaFileUpload(local_path, mimetype=MIME_EXCEL, resumable=True)
|
|
271
|
+
file_metadata = {"name": desired_name, "mimeType": MIME_GOOGLE_SHEET}
|
|
272
|
+
|
|
273
|
+
logger.info("Initiating Google Drive upload & conversion…")
|
|
274
|
+
request = drive_service.files().create(
|
|
275
|
+
body=file_metadata, media_body=media, fields="id, webViewLink"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
response = None
|
|
279
|
+
while response is None:
|
|
280
|
+
status, response = request.next_chunk()
|
|
281
|
+
if status:
|
|
282
|
+
logger.debug(f"Upload progress: {int(status.progress() * 100)}%")
|
|
283
|
+
|
|
284
|
+
file_id = response.get("id")
|
|
285
|
+
web_view_link = response.get("webViewLink")
|
|
286
|
+
logger.info(f"Upload completed. File ID: {file_id}")
|
|
287
|
+
return file_id, web_view_link
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# Remove rows from a sheet. All parameters are 1-based (both *start_row* and
|
|
291
|
+
# *end_row* are inclusive) mirroring the UI behaviour in Google Sheets.
|
|
292
|
+
def delete_rows_api_call(
|
|
293
|
+
service: "Resource",
|
|
294
|
+
spreadsheet_id: str,
|
|
295
|
+
sheet_gid: int,
|
|
296
|
+
start_row: int,
|
|
297
|
+
end_row: int,
|
|
298
|
+
) -> None:
|
|
299
|
+
"""Executes the API call to delete rows."""
|
|
300
|
+
logger.info(f"Deleting rows {start_row}-{end_row} (1-based inclusive)…")
|
|
301
|
+
|
|
302
|
+
body = {
|
|
303
|
+
"requests": [
|
|
304
|
+
{
|
|
305
|
+
"deleteDimension": {
|
|
306
|
+
"range": {
|
|
307
|
+
"sheetId": sheet_gid,
|
|
308
|
+
"dimension": "ROWS",
|
|
309
|
+
"startIndex": start_row - 1, # 0-based
|
|
310
|
+
"endIndex": end_row, # end-exclusive
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
]
|
|
315
|
+
}
|
|
316
|
+
service.spreadsheets().batchUpdate(spreadsheetId=spreadsheet_id, body=body).execute()
|
|
317
|
+
logger.info("Rows deleted.")
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
__all__ = [
|
|
321
|
+
# Authentication
|
|
322
|
+
"get_credentials",
|
|
323
|
+
"get_google_credentials", # backward compat alias
|
|
324
|
+
# Services
|
|
325
|
+
"get_sheets_service",
|
|
326
|
+
"get_drive_service",
|
|
327
|
+
# Sheets helpers
|
|
328
|
+
"get_spreadsheet_and_sheet_id",
|
|
329
|
+
"sheet_name_from_gid",
|
|
330
|
+
"read_cell",
|
|
331
|
+
"delete_rows_api_call",
|
|
332
|
+
# Drive helpers
|
|
333
|
+
"download_file_direct",
|
|
334
|
+
"upload_excel_as_google_sheet",
|
|
335
|
+
# Constants
|
|
336
|
+
"MIME_GOOGLE_SHEET",
|
|
337
|
+
"MIME_EXCEL",
|
|
338
|
+
]
|
|
@@ -9,7 +9,9 @@ lumera/pb.py,sha256=fTGjcclIGmXxQsBCDhXVrPyKBkdPJ3DRwNZfICT5X8E,20434
|
|
|
9
9
|
lumera/sdk.py,sha256=0Q4b0LXbr7Vl0SpstH1IKEZwGek33Jz-4ICMRe_Z60A,31217
|
|
10
10
|
lumera/storage.py,sha256=b0W6JNSGfmhJIcmK3vrATXAwxIr_bfrj-hPuQRVLTYU,8206
|
|
11
11
|
lumera/webhooks.py,sha256=L_Q5YHBJKQNpv7G9Nq0QqlGMRch6x9ptlwu1xD2qwUc,8661
|
|
12
|
-
lumera
|
|
13
|
-
lumera
|
|
14
|
-
lumera-0.9.
|
|
15
|
-
lumera-0.9.
|
|
12
|
+
lumera/integrations/__init__.py,sha256=LnJmAnFB_p3YMKyeGVdDP4LYlJ85XFNQFAxGo6zF7CI,937
|
|
13
|
+
lumera/integrations/google.py,sha256=QkbBbbDh3I_OToPDFqcivU6sWy2UieHBxZ_TPv5rqK0,11862
|
|
14
|
+
lumera-0.9.2.dist-info/METADATA,sha256=Z26CWbc_x9lmH4nKp_7FQ4JWd0QSsgxiLQC-K6O_QgA,1611
|
|
15
|
+
lumera-0.9.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
16
|
+
lumera-0.9.2.dist-info/top_level.txt,sha256=HgfK4XQkpMTnM2E5iWM4kB711FnYqUY9dglzib3pWlE,7
|
|
17
|
+
lumera-0.9.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|