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 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 streaming and download
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 live logs from a running automation.
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. Stops when the run completes.
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/live"
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,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lumera
3
- Version: 0.9.8
3
+ Version: 0.10.0
4
4
  Summary: SDK for building on Lumera platform
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: requests
@@ -1,6 +1,7 @@
1
- lumera/__init__.py,sha256=nIEzrBMFoW6-xJ5L5CQrq6feDJUH119Vttm-L8Si6aE,2667
1
+ lumera/__init__.py,sha256=5FlY5dSJ1WNM4ko7wgmcajO8G2voBGn4S19E91_WdqE,2687
2
2
  lumera/_utils.py,sha256=b-l3Ebh4n2pC-9T5mR6h4hPf_Wl48VDlHES0pLo1zKE,25766
3
- lumera/automations.py,sha256=UKKtgmsYWIzJvMnIf0K5ywXCtgIu8kb9DmsAX_upubM,27397
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.9.8.dist-info/METADATA,sha256=rAooBJr5-_9xpam1ctyqqB7C9jNpcXdf-KmzBuj8iGw,1611
16
- lumera-0.9.8.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
17
- lumera-0.9.8.dist-info/top_level.txt,sha256=HgfK4XQkpMTnM2E5iWM4kB711FnYqUY9dglzib3pWlE,7
18
- lumera-0.9.8.dist-info/RECORD,,
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,,