lumera 0.9.8__py3-none-any.whl → 0.10.0__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 +2 -1
- lumera/automations.py +173 -5
- lumera/email.py +156 -0
- {lumera-0.9.8.dist-info → lumera-0.10.0.dist-info}/METADATA +1 -1
- {lumera-0.9.8.dist-info → lumera-0.10.0.dist-info}/RECORD +7 -6
- {lumera-0.9.8.dist-info → lumera-0.10.0.dist-info}/WHEEL +0 -0
- {lumera-0.9.8.dist-info → lumera-0.10.0.dist-info}/top_level.txt +0 -0
lumera/__init__.py
CHANGED
|
@@ -13,7 +13,7 @@ except PackageNotFoundError:
|
|
|
13
13
|
__version__ = "unknown" # Not installed (e.g., running from source)
|
|
14
14
|
|
|
15
15
|
# Import new modules (as modules, not individual functions)
|
|
16
|
-
from . import automations, exceptions, integrations, llm, locks, pb, storage, webhooks
|
|
16
|
+
from . import automations, email, exceptions, integrations, llm, locks, pb, storage, webhooks
|
|
17
17
|
from ._utils import (
|
|
18
18
|
LumeraAPIError,
|
|
19
19
|
RecordNotUniqueError,
|
|
@@ -102,6 +102,7 @@ __all__ = [
|
|
|
102
102
|
"LockHeldError",
|
|
103
103
|
# New modules (use as lumera.pb, lumera.storage, etc.)
|
|
104
104
|
"automations",
|
|
105
|
+
"email",
|
|
105
106
|
"pb",
|
|
106
107
|
"storage",
|
|
107
108
|
"llm",
|
lumera/automations.py
CHANGED
|
@@ -57,18 +57,76 @@ __all__ = [
|
|
|
57
57
|
"create",
|
|
58
58
|
"update",
|
|
59
59
|
"upsert",
|
|
60
|
-
# Log
|
|
60
|
+
# Log functions
|
|
61
61
|
"stream_logs",
|
|
62
|
+
"get_logs",
|
|
62
63
|
"get_log_download_url",
|
|
63
64
|
# Classes
|
|
64
65
|
"Run",
|
|
65
66
|
"Automation",
|
|
67
|
+
"LogsResponse",
|
|
66
68
|
]
|
|
67
69
|
|
|
68
70
|
from ._utils import LumeraAPIError, _api_request
|
|
69
71
|
from .sdk import get_automation_run as _get_automation_run
|
|
70
72
|
from .sdk import run_automation as _run_automation
|
|
71
73
|
|
|
74
|
+
# ============================================================================
|
|
75
|
+
# LogsResponse Class
|
|
76
|
+
# ============================================================================
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class LogsResponse:
|
|
80
|
+
"""Response from fetching automation run logs.
|
|
81
|
+
|
|
82
|
+
Attributes:
|
|
83
|
+
data: Raw log content as a string (NDJSON format).
|
|
84
|
+
offset: Byte offset where this chunk starts.
|
|
85
|
+
size: Number of bytes in this chunk.
|
|
86
|
+
total_size: Total size of the log file.
|
|
87
|
+
has_more: True if there are more logs after this chunk.
|
|
88
|
+
source: Where logs came from ("live" or "archived").
|
|
89
|
+
truncated: True if logs were truncated at storage time (>50MB).
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, data: dict[str, Any]) -> None:
|
|
93
|
+
self._data = data
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def data(self) -> str:
|
|
97
|
+
return self._data.get("data", "")
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def offset(self) -> int:
|
|
101
|
+
return self._data.get("offset", 0)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def size(self) -> int:
|
|
105
|
+
return self._data.get("size", 0)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def total_size(self) -> int:
|
|
109
|
+
return self._data.get("total_size", 0)
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def has_more(self) -> bool:
|
|
113
|
+
return self._data.get("has_more", False)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def source(self) -> str:
|
|
117
|
+
return self._data.get("source", "")
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def truncated(self) -> bool:
|
|
121
|
+
return self._data.get("truncated", False)
|
|
122
|
+
|
|
123
|
+
def __repr__(self) -> str:
|
|
124
|
+
return (
|
|
125
|
+
f"LogsResponse(offset={self.offset}, size={self.size}, "
|
|
126
|
+
f"total_size={self.total_size}, has_more={self.has_more})"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
72
130
|
# ============================================================================
|
|
73
131
|
# Run Class
|
|
74
132
|
# ============================================================================
|
|
@@ -246,6 +304,66 @@ class Run:
|
|
|
246
304
|
raise ValueError("Cannot get log URL without run id")
|
|
247
305
|
return get_log_download_url(self.id)
|
|
248
306
|
|
|
307
|
+
def logs(
|
|
308
|
+
self,
|
|
309
|
+
*,
|
|
310
|
+
offset: int = 0,
|
|
311
|
+
limit: int = 1024 * 1024,
|
|
312
|
+
all: bool = False,
|
|
313
|
+
) -> LogsResponse:
|
|
314
|
+
"""Fetch logs for this run.
|
|
315
|
+
|
|
316
|
+
Works for both live (running) and archived (completed) runs.
|
|
317
|
+
Returns raw log data as a string (NDJSON format).
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
offset: Byte offset to start from. Negative values read from end
|
|
321
|
+
(e.g., -1048576 = last 1MB).
|
|
322
|
+
limit: Maximum bytes to return (default 1MB).
|
|
323
|
+
all: If True, fetch all logs at once. Returns 400 if logs > 10MB.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
A LogsResponse object with data, offset, size, total_size, has_more,
|
|
327
|
+
source ("live" or "archived"), and truncated flag.
|
|
328
|
+
|
|
329
|
+
Raises:
|
|
330
|
+
ValueError: If the run has no ID.
|
|
331
|
+
LumeraAPIError: If logs are not available or request fails.
|
|
332
|
+
|
|
333
|
+
Example:
|
|
334
|
+
>>> run = automations.get_run("run_id")
|
|
335
|
+
>>> resp = run.logs()
|
|
336
|
+
>>> print(resp.data) # Raw NDJSON log content
|
|
337
|
+
>>> while resp.has_more:
|
|
338
|
+
... resp = run.logs(offset=resp.offset + resp.size)
|
|
339
|
+
... print(resp.data)
|
|
340
|
+
"""
|
|
341
|
+
if not self.id:
|
|
342
|
+
raise ValueError("Cannot fetch logs without run id")
|
|
343
|
+
return get_logs(self.id, offset=offset, limit=limit, all=all)
|
|
344
|
+
|
|
345
|
+
def stream_logs(self, *, timeout: float = 30) -> Iterator[str]:
|
|
346
|
+
"""Stream logs from this run.
|
|
347
|
+
|
|
348
|
+
Works for both live (running) and archived (completed) runs.
|
|
349
|
+
For live runs, streams in real-time as logs are produced.
|
|
350
|
+
For archived runs, streams the entire log from S3.
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
timeout: HTTP connection timeout in seconds.
|
|
354
|
+
|
|
355
|
+
Yields:
|
|
356
|
+
Log lines as strings (raw NDJSON lines).
|
|
357
|
+
|
|
358
|
+
Example:
|
|
359
|
+
>>> run = automations.run("automation_id", inputs={})
|
|
360
|
+
>>> for line in run.stream_logs():
|
|
361
|
+
... print(line)
|
|
362
|
+
"""
|
|
363
|
+
if not self.id:
|
|
364
|
+
raise ValueError("Cannot stream logs without run id")
|
|
365
|
+
return stream_logs(self.id, timeout=timeout)
|
|
366
|
+
|
|
249
367
|
def to_dict(self) -> dict[str, Any]:
|
|
250
368
|
"""Return the underlying data dict."""
|
|
251
369
|
return self._data.copy()
|
|
@@ -795,17 +913,19 @@ def delete(automation_id: str) -> None:
|
|
|
795
913
|
|
|
796
914
|
|
|
797
915
|
def stream_logs(run_id: str, *, timeout: float = 30) -> Iterator[str]:
|
|
798
|
-
"""Stream
|
|
916
|
+
"""Stream logs from an automation run.
|
|
799
917
|
|
|
918
|
+
Works for both live (running) and archived (completed) runs.
|
|
800
919
|
Connects to the server-sent events endpoint and yields log lines
|
|
801
|
-
as they arrive.
|
|
920
|
+
as they arrive. For live runs, streams in real-time. For archived
|
|
921
|
+
runs, streams the entire log from storage.
|
|
802
922
|
|
|
803
923
|
Args:
|
|
804
924
|
run_id: The run ID to stream logs from.
|
|
805
925
|
timeout: HTTP connection timeout in seconds.
|
|
806
926
|
|
|
807
927
|
Yields:
|
|
808
|
-
Log lines as strings.
|
|
928
|
+
Log lines as strings (raw NDJSON lines).
|
|
809
929
|
|
|
810
930
|
Example:
|
|
811
931
|
>>> for line in automations.stream_logs("run_id"):
|
|
@@ -825,7 +945,7 @@ def stream_logs(run_id: str, *, timeout: float = 30) -> Iterator[str]:
|
|
|
825
945
|
if not token:
|
|
826
946
|
raise ValueError("LUMERA_TOKEN environment variable is required")
|
|
827
947
|
|
|
828
|
-
url = f"{base_url}/automation-runs/{run_id}/logs
|
|
948
|
+
url = f"{base_url}/automation-runs/{run_id}/logs?stream=true"
|
|
829
949
|
headers = {
|
|
830
950
|
"Authorization": f"token {token}",
|
|
831
951
|
"Accept": "text/event-stream",
|
|
@@ -902,3 +1022,51 @@ def get_log_download_url(run_id: str) -> str:
|
|
|
902
1022
|
if isinstance(result, dict) and "url" in result:
|
|
903
1023
|
return result["url"]
|
|
904
1024
|
raise RuntimeError("Unexpected response: no download URL returned")
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def get_logs(
|
|
1028
|
+
run_id: str,
|
|
1029
|
+
*,
|
|
1030
|
+
offset: int = 0,
|
|
1031
|
+
limit: int = 1024 * 1024,
|
|
1032
|
+
all: bool = False,
|
|
1033
|
+
) -> LogsResponse:
|
|
1034
|
+
"""Fetch logs for an automation run.
|
|
1035
|
+
|
|
1036
|
+
Works for both live (running) and archived (completed) runs.
|
|
1037
|
+
Returns raw log data as a string (NDJSON format).
|
|
1038
|
+
|
|
1039
|
+
Args:
|
|
1040
|
+
run_id: The run ID to get logs for.
|
|
1041
|
+
offset: Byte offset to start from. Negative values read from end
|
|
1042
|
+
(e.g., -1048576 = last 1MB).
|
|
1043
|
+
limit: Maximum bytes to return (default 1MB).
|
|
1044
|
+
all: If True, fetch all logs at once. Returns 400 if logs > 10MB.
|
|
1045
|
+
|
|
1046
|
+
Returns:
|
|
1047
|
+
A LogsResponse object with data, offset, size, total_size, has_more,
|
|
1048
|
+
source ("live" or "archived"), and truncated flag.
|
|
1049
|
+
|
|
1050
|
+
Raises:
|
|
1051
|
+
ValueError: If run_id is empty.
|
|
1052
|
+
LumeraAPIError: If logs are not available or request fails.
|
|
1053
|
+
|
|
1054
|
+
Example:
|
|
1055
|
+
>>> resp = automations.get_logs("run_id")
|
|
1056
|
+
>>> print(resp.data) # Raw NDJSON log content
|
|
1057
|
+
>>> while resp.has_more:
|
|
1058
|
+
... resp = automations.get_logs("run_id", offset=resp.offset + resp.size)
|
|
1059
|
+
... print(resp.data)
|
|
1060
|
+
"""
|
|
1061
|
+
run_id = run_id.strip()
|
|
1062
|
+
if not run_id:
|
|
1063
|
+
raise ValueError("run_id is required")
|
|
1064
|
+
|
|
1065
|
+
params: dict[str, Any] = {"offset": offset, "limit": limit}
|
|
1066
|
+
if all:
|
|
1067
|
+
params["all"] = "true"
|
|
1068
|
+
|
|
1069
|
+
result = _api_request("GET", f"automation-runs/{run_id}/logs", params=params)
|
|
1070
|
+
if isinstance(result, dict):
|
|
1071
|
+
return LogsResponse(result)
|
|
1072
|
+
raise RuntimeError("Unexpected response from logs endpoint")
|
lumera/email.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Email sending for Lumera automations.
|
|
3
|
+
|
|
4
|
+
Send transactional emails through the Lumera platform using AWS SES infrastructure.
|
|
5
|
+
Emails are logged to the lm_email_logs collection for auditing and debugging.
|
|
6
|
+
|
|
7
|
+
Functions:
|
|
8
|
+
send() - Send an email with HTML/text content
|
|
9
|
+
|
|
10
|
+
Example:
|
|
11
|
+
>>> from lumera import email
|
|
12
|
+
>>>
|
|
13
|
+
>>> # Send a simple email
|
|
14
|
+
>>> result = email.send(
|
|
15
|
+
... to="user@example.com",
|
|
16
|
+
... subject="Welcome to Lumera!",
|
|
17
|
+
... body_html="<h1>Hello!</h1><p>Welcome aboard.</p>"
|
|
18
|
+
... )
|
|
19
|
+
>>> print(result.message_id)
|
|
20
|
+
>>>
|
|
21
|
+
>>> # Send to multiple recipients with plain text fallback
|
|
22
|
+
>>> result = email.send(
|
|
23
|
+
... to=["alice@example.com", "bob@example.com"],
|
|
24
|
+
... subject="Team Update",
|
|
25
|
+
... body_html="<h1>Update</h1><p>See details below.</p>",
|
|
26
|
+
... body_text="Update\\n\\nSee details below.",
|
|
27
|
+
... tags={"type": "notification", "team": "engineering"}
|
|
28
|
+
... )
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from ._utils import _api_request, LumeraAPIError
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"send",
|
|
39
|
+
"SendResult",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SendResult:
|
|
44
|
+
"""Result of sending an email.
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
message_id: The unique identifier from AWS SES for the sent email.
|
|
48
|
+
log_id: The PocketBase record ID in lm_email_logs for auditing.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, data: dict[str, Any]):
|
|
52
|
+
self.message_id: str = data.get("message_id", "")
|
|
53
|
+
self.log_id: str = data.get("log_id", "")
|
|
54
|
+
self._raw = data
|
|
55
|
+
|
|
56
|
+
def __repr__(self) -> str:
|
|
57
|
+
return f"SendResult(message_id={self.message_id!r}, log_id={self.log_id!r})"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def send(
|
|
61
|
+
to: list[str] | str,
|
|
62
|
+
subject: str,
|
|
63
|
+
*,
|
|
64
|
+
body_html: str | None = None,
|
|
65
|
+
body_text: str | None = None,
|
|
66
|
+
cc: list[str] | str | None = None,
|
|
67
|
+
bcc: list[str] | str | None = None,
|
|
68
|
+
from_address: str | None = None,
|
|
69
|
+
from_name: str | None = None,
|
|
70
|
+
reply_to: str | None = None,
|
|
71
|
+
tags: dict[str, str] | None = None,
|
|
72
|
+
) -> SendResult:
|
|
73
|
+
"""Send an email.
|
|
74
|
+
|
|
75
|
+
At least one of body_html or body_text must be provided. If only body_html
|
|
76
|
+
is provided, a plain text version will be auto-generated.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
to: Recipient email address(es). Can be a single string or list.
|
|
80
|
+
subject: Email subject line.
|
|
81
|
+
body_html: HTML body content (optional if body_text provided).
|
|
82
|
+
body_text: Plain text body content (optional, auto-generated from HTML).
|
|
83
|
+
cc: CC recipient(s) (optional).
|
|
84
|
+
bcc: BCC recipient(s) (optional).
|
|
85
|
+
from_address: Sender email address (optional, uses platform default).
|
|
86
|
+
from_name: Sender display name (optional, uses platform default).
|
|
87
|
+
reply_to: Reply-to address (optional).
|
|
88
|
+
tags: Custom key-value pairs for tracking/filtering in logs (optional).
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
SendResult with message_id and log_id.
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
ValueError: If required fields are missing or invalid.
|
|
95
|
+
LumeraAPIError: If the API request fails.
|
|
96
|
+
|
|
97
|
+
Example:
|
|
98
|
+
>>> # Simple email
|
|
99
|
+
>>> result = email.send(
|
|
100
|
+
... to="user@example.com",
|
|
101
|
+
... subject="Password Reset",
|
|
102
|
+
... body_html="<p>Click <a href='...'>here</a> to reset.</p>"
|
|
103
|
+
... )
|
|
104
|
+
>>>
|
|
105
|
+
>>> # Email with all options
|
|
106
|
+
>>> result = email.send(
|
|
107
|
+
... to=["alice@example.com", "bob@example.com"],
|
|
108
|
+
... subject="Monthly Report",
|
|
109
|
+
... body_html="<h1>Report</h1><p>See attached.</p>",
|
|
110
|
+
... body_text="Report\\n\\nSee attached.",
|
|
111
|
+
... cc="manager@example.com",
|
|
112
|
+
... reply_to="reports@example.com",
|
|
113
|
+
... tags={"report_type": "monthly", "department": "sales"}
|
|
114
|
+
... )
|
|
115
|
+
"""
|
|
116
|
+
# Normalize to lists
|
|
117
|
+
to_list = [to] if isinstance(to, str) else list(to) if to else []
|
|
118
|
+
cc_list = [cc] if isinstance(cc, str) else (list(cc) if cc else None)
|
|
119
|
+
bcc_list = [bcc] if isinstance(bcc, str) else (list(bcc) if bcc else None)
|
|
120
|
+
|
|
121
|
+
# Validate
|
|
122
|
+
if not to_list:
|
|
123
|
+
raise ValueError("at least one recipient is required")
|
|
124
|
+
if not subject or not subject.strip():
|
|
125
|
+
raise ValueError("subject is required")
|
|
126
|
+
if not body_html and not body_text:
|
|
127
|
+
raise ValueError("at least one of body_html or body_text is required")
|
|
128
|
+
|
|
129
|
+
# Build payload
|
|
130
|
+
payload: dict[str, Any] = {
|
|
131
|
+
"to": to_list,
|
|
132
|
+
"subject": subject.strip(),
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if body_html:
|
|
136
|
+
payload["body_html"] = body_html
|
|
137
|
+
if body_text:
|
|
138
|
+
payload["body_text"] = body_text
|
|
139
|
+
if cc_list:
|
|
140
|
+
payload["cc"] = cc_list
|
|
141
|
+
if bcc_list:
|
|
142
|
+
payload["bcc"] = bcc_list
|
|
143
|
+
if from_address:
|
|
144
|
+
payload["from"] = from_address.strip()
|
|
145
|
+
if from_name:
|
|
146
|
+
payload["from_name"] = from_name.strip()
|
|
147
|
+
if reply_to:
|
|
148
|
+
payload["reply_to"] = reply_to.strip()
|
|
149
|
+
if tags:
|
|
150
|
+
payload["tags"] = tags
|
|
151
|
+
|
|
152
|
+
result = _api_request("POST", "email/send", json_body=payload)
|
|
153
|
+
if not isinstance(result, dict):
|
|
154
|
+
raise RuntimeError("unexpected response from email/send")
|
|
155
|
+
|
|
156
|
+
return SendResult(result)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
lumera/__init__.py,sha256=
|
|
1
|
+
lumera/__init__.py,sha256=5FlY5dSJ1WNM4ko7wgmcajO8G2voBGn4S19E91_WdqE,2687
|
|
2
2
|
lumera/_utils.py,sha256=b-l3Ebh4n2pC-9T5mR6h4hPf_Wl48VDlHES0pLo1zKE,25766
|
|
3
|
-
lumera/automations.py,sha256=
|
|
3
|
+
lumera/automations.py,sha256=KPP_rD7WKmBs865jiKoonZJjdTno-FSAU7hajPFyqs0,32851
|
|
4
|
+
lumera/email.py,sha256=lk8KUsRw1ZvxgM0FPQXH-jVKUQA5f0zLv88jlc3IWlA,5056
|
|
4
5
|
lumera/exceptions.py,sha256=bNsx4iYaroAAGsYxErfELC2B5ZJ3w5lVa1kKdIx5s9g,2173
|
|
5
6
|
lumera/files.py,sha256=xMJmLTSaQQDttM3AMmpOWc6soh4lvCCKBreV0fXWHQw,3159
|
|
6
7
|
lumera/google.py,sha256=zpWW1qSlzLZY5Ip7cGAzrv9sJrQf3JBKH2ODc1cCM_E,1130
|
|
@@ -12,7 +13,7 @@ lumera/storage.py,sha256=fWkscTvKDzQ-5tsfA1lREO2qgtjJ4Yvxj3hvYNLKiW0,10527
|
|
|
12
13
|
lumera/webhooks.py,sha256=L_Q5YHBJKQNpv7G9Nq0QqlGMRch6x9ptlwu1xD2qwUc,8661
|
|
13
14
|
lumera/integrations/__init__.py,sha256=LnJmAnFB_p3YMKyeGVdDP4LYlJ85XFNQFAxGo6zF7CI,937
|
|
14
15
|
lumera/integrations/google.py,sha256=QkbBbbDh3I_OToPDFqcivU6sWy2UieHBxZ_TPv5rqK0,11862
|
|
15
|
-
lumera-0.
|
|
16
|
-
lumera-0.
|
|
17
|
-
lumera-0.
|
|
18
|
-
lumera-0.
|
|
16
|
+
lumera-0.10.0.dist-info/METADATA,sha256=uWvSDuD868zVICFyVUppHMIrWe6A-JKeyxFRurjxieU,1612
|
|
17
|
+
lumera-0.10.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
18
|
+
lumera-0.10.0.dist-info/top_level.txt,sha256=HgfK4XQkpMTnM2E5iWM4kB711FnYqUY9dglzib3pWlE,7
|
|
19
|
+
lumera-0.10.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|