lumera 0.7.3__py3-none-any.whl → 0.8.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/__init__.py +5 -3
- lumera/_utils.py +22 -1
- lumera/automations.py +9 -9
- lumera/google.py +47 -270
- lumera/sdk.py +9 -8
- lumera/webhooks.py +304 -0
- {lumera-0.7.3.dist-info → lumera-0.8.2.dist-info}/METADATA +1 -1
- lumera-0.8.2.dist-info/RECORD +15 -0
- lumera-0.7.3.dist-info/RECORD +0 -14
- {lumera-0.7.3.dist-info → lumera-0.8.2.dist-info}/WHEEL +0 -0
- {lumera-0.7.3.dist-info → lumera-0.8.2.dist-info}/top_level.txt +0 -0
lumera/__init__.py
CHANGED
|
@@ -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
|
]
|
lumera/_utils.py
CHANGED
|
@@ -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,
|
lumera/automations.py
CHANGED
|
@@ -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={
|
lumera/google.py
CHANGED
|
@@ -1,270 +1,47 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
#
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
#
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
#
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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.")
|
|
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
|
+
]
|
lumera/sdk.py
CHANGED
|
@@ -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:
|
lumera/webhooks.py
ADDED
|
@@ -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}"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
lumera/__init__.py,sha256=rOTVv9u-Wtl0ky4oPN5yF9AMRO3UOSZo-BEMwqmMoV4,2352
|
|
2
|
+
lumera/_utils.py,sha256=Gr3WRaU6MI-jpx3e5OMxAkVYYQmkUXg2J56Sy7AuEGE,24443
|
|
3
|
+
lumera/automations.py,sha256=8BTvq91SXBtO5PO5SKEBdKcz7JRsKl2o4bzp3VCS8_U,24813
|
|
4
|
+
lumera/exceptions.py,sha256=bNsx4iYaroAAGsYxErfELC2B5ZJ3w5lVa1kKdIx5s9g,2173
|
|
5
|
+
lumera/google.py,sha256=MwgPd3hNNuOj6CKmxJMYTQ7F81a_R4x0Wx19GSiOGoU,1136
|
|
6
|
+
lumera/llm.py,sha256=pUTZK7t3GTK0vfxMI1PJgJwNendyuiJc5MB1pUj2vxE,14412
|
|
7
|
+
lumera/locks.py,sha256=8l_qxb8nrxge7YJ-ApUTJ5MeYpIdxDeEa94Eim9O-YM,6806
|
|
8
|
+
lumera/pb.py,sha256=EZABRPVbrSJ8_9Yh-BLt9cp4omkwo5r10Qs8v7wc1qc,18236
|
|
9
|
+
lumera/sdk.py,sha256=EZVH1FnT7smkvFgT6QjVf4gDnVuktF1w7RZFaeTPgDE,30470
|
|
10
|
+
lumera/storage.py,sha256=b0W6JNSGfmhJIcmK3vrATXAwxIr_bfrj-hPuQRVLTYU,8206
|
|
11
|
+
lumera/webhooks.py,sha256=L_Q5YHBJKQNpv7G9Nq0QqlGMRch6x9ptlwu1xD2qwUc,8661
|
|
12
|
+
lumera-0.8.2.dist-info/METADATA,sha256=UJHFpJG3igC8bKiDnEFYom92fzXb0d_zZ7St127aGpc,1611
|
|
13
|
+
lumera-0.8.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
14
|
+
lumera-0.8.2.dist-info/top_level.txt,sha256=HgfK4XQkpMTnM2E5iWM4kB711FnYqUY9dglzib3pWlE,7
|
|
15
|
+
lumera-0.8.2.dist-info/RECORD,,
|
lumera-0.7.3.dist-info/RECORD
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
lumera/__init__.py,sha256=NuZgCTtxQNdj1HKltc76XIcx8kUuE4PKY7mUy5XCXiU,2296
|
|
2
|
-
lumera/_utils.py,sha256=Ur4ZEf-khH3zFzX-aY4Y5uDqgYSWz9kJEkoR9WKy5yc,23727
|
|
3
|
-
lumera/automations.py,sha256=ZHN4m1pIO7py_F3DkUTTDTsr3dbJy7Mly_-gXt3h31o,24780
|
|
4
|
-
lumera/exceptions.py,sha256=bNsx4iYaroAAGsYxErfELC2B5ZJ3w5lVa1kKdIx5s9g,2173
|
|
5
|
-
lumera/google.py,sha256=3IVNL1HaOtsTmunl0alnGFuUAkzQQRyCEA3CKjlPqO0,10183
|
|
6
|
-
lumera/llm.py,sha256=pUTZK7t3GTK0vfxMI1PJgJwNendyuiJc5MB1pUj2vxE,14412
|
|
7
|
-
lumera/locks.py,sha256=8l_qxb8nrxge7YJ-ApUTJ5MeYpIdxDeEa94Eim9O-YM,6806
|
|
8
|
-
lumera/pb.py,sha256=EZABRPVbrSJ8_9Yh-BLt9cp4omkwo5r10Qs8v7wc1qc,18236
|
|
9
|
-
lumera/sdk.py,sha256=GVu1Pe_r7qNPDJ5g7qeACAfW5w5TDOyK8LN3VvDwk0s,30446
|
|
10
|
-
lumera/storage.py,sha256=b0W6JNSGfmhJIcmK3vrATXAwxIr_bfrj-hPuQRVLTYU,8206
|
|
11
|
-
lumera-0.7.3.dist-info/METADATA,sha256=G0fM5Y0DkPMr6QsNOuUaUZPnpKiqpo25_hEMlB7T1yg,1611
|
|
12
|
-
lumera-0.7.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
13
|
-
lumera-0.7.3.dist-info/top_level.txt,sha256=HgfK4XQkpMTnM2E5iWM4kB711FnYqUY9dglzib3pWlE,7
|
|
14
|
-
lumera-0.7.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|