lumera 0.4.6__py3-none-any.whl → 0.9.4__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/automations.py ADDED
@@ -0,0 +1,904 @@
1
+ """
2
+ Automation execution and management for Lumera.
3
+
4
+ This module provides a high-level interface for running and managing Lumera
5
+ automations (background Python scripts).
6
+
7
+ Run functions:
8
+ run() - Run an automation by ID
9
+ run_by_external_id() - Run an automation by external_id
10
+ get_run() - Get a run by ID
11
+ list_runs() - List runs for an automation
12
+
13
+ Automation management:
14
+ list() - List all automations
15
+ get() - Get automation by ID
16
+ get_by_external_id() - Get automation by external_id
17
+ create() - Create a new automation
18
+ update() - Update an automation
19
+ upsert() - Create or update by external_id
20
+
21
+ Log streaming:
22
+ stream_logs() - Stream live logs from a running automation
23
+
24
+ Example:
25
+ >>> from lumera import automations
26
+ >>>
27
+ >>> # Run an automation
28
+ >>> run = automations.run("automation_id", inputs={"limit": 100})
29
+ >>> print(run.id, run.status)
30
+ >>>
31
+ >>> # Wait for completion
32
+ >>> result = run.wait(timeout=300)
33
+ >>>
34
+ >>> # Or poll manually
35
+ >>> while run.status in ["queued", "running"]:
36
+ ... time.sleep(5)
37
+ ... run.refresh()
38
+ >>> print(run.result)
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import json
44
+ import time
45
+ from typing import Any, Iterator, Mapping
46
+
47
+ __all__ = [
48
+ # Run operations
49
+ "run",
50
+ "run_by_external_id",
51
+ "get_run",
52
+ "list_runs",
53
+ # Automation management
54
+ "list",
55
+ "get",
56
+ "get_by_external_id",
57
+ "create",
58
+ "update",
59
+ "upsert",
60
+ # Log streaming and download
61
+ "stream_logs",
62
+ "get_log_download_url",
63
+ # Classes
64
+ "Run",
65
+ "Automation",
66
+ ]
67
+
68
+ from ._utils import LumeraAPIError, _api_request
69
+ from .sdk import get_automation_run as _get_automation_run
70
+ from .sdk import run_automation as _run_automation
71
+
72
+ # ============================================================================
73
+ # Run Class
74
+ # ============================================================================
75
+
76
+
77
+ class Run:
78
+ """Represents an automation run with methods for polling and waiting.
79
+
80
+ Attributes:
81
+ id: The run ID.
82
+ automation_id: The automation ID.
83
+ status: Current status (queued, running, succeeded, failed, cancelled, timeout).
84
+ inputs: The inputs passed to the run.
85
+ result: The result returned by the automation (when succeeded).
86
+ error: Error message (when failed).
87
+ created: Creation timestamp.
88
+ started_at: Execution start timestamp.
89
+ finished_at: Completion timestamp.
90
+ external_id: Optional correlation ID.
91
+ trigger: How the run was initiated (manual, webhook, schedule, assistant).
92
+ log_pointer: S3 location of archived logs (after completion).
93
+ artifacts: List of output files created during execution.
94
+ """
95
+
96
+ def __init__(self, data: dict[str, Any]) -> None:
97
+ self._data = data
98
+
99
+ @property
100
+ def id(self) -> str:
101
+ return self._data.get("id", "")
102
+
103
+ @property
104
+ def automation_id(self) -> str:
105
+ return self._data.get("automation_id", "")
106
+
107
+ @property
108
+ def status(self) -> str:
109
+ return self._data.get("status", "")
110
+
111
+ @property
112
+ def inputs(self) -> dict[str, Any]:
113
+ raw = self._data.get("inputs", "{}")
114
+ if isinstance(raw, str):
115
+ try:
116
+ return json.loads(raw)
117
+ except json.JSONDecodeError:
118
+ return {}
119
+ return raw if isinstance(raw, dict) else {}
120
+
121
+ @property
122
+ def result(self) -> dict[str, Any] | None:
123
+ return self._data.get("result")
124
+
125
+ @property
126
+ def error(self) -> str | None:
127
+ return self._data.get("error")
128
+
129
+ @property
130
+ def created(self) -> str | None:
131
+ return self._data.get("created")
132
+
133
+ @property
134
+ def started_at(self) -> str | None:
135
+ return self._data.get("started_at")
136
+
137
+ @property
138
+ def finished_at(self) -> str | None:
139
+ return self._data.get("finished_at")
140
+
141
+ @property
142
+ def external_id(self) -> str | None:
143
+ return self._data.get("external_id")
144
+
145
+ @property
146
+ def trigger(self) -> str | None:
147
+ return self._data.get("trigger")
148
+
149
+ @property
150
+ def log_pointer(self) -> dict[str, Any] | None:
151
+ return self._data.get("log_pointer")
152
+
153
+ @property
154
+ def artifacts(self) -> list[dict[str, Any]]:
155
+ return self._data.get("artifacts") or []
156
+
157
+ @property
158
+ def is_terminal(self) -> bool:
159
+ """Returns True if the run has completed (success, failure, or cancelled)."""
160
+ return self.status in ("succeeded", "failed", "cancelled", "timeout")
161
+
162
+ def refresh(self) -> Run:
163
+ """Refresh run status from the server.
164
+
165
+ Returns:
166
+ self (for chaining).
167
+ """
168
+ if not self.id:
169
+ raise ValueError("Cannot refresh run without id")
170
+ updated = _get_automation_run(run_id=self.id)
171
+ self._data = updated
172
+ return self
173
+
174
+ def wait(
175
+ self,
176
+ timeout: float = 300,
177
+ poll_interval: float = 2.0,
178
+ ) -> dict[str, Any] | None:
179
+ """Block until the run completes or timeout is reached.
180
+
181
+ Args:
182
+ timeout: Maximum seconds to wait (default 300 = 5 minutes).
183
+ poll_interval: Seconds between status checks (default 2).
184
+
185
+ Returns:
186
+ The result dict if succeeded, None otherwise.
187
+
188
+ Raises:
189
+ TimeoutError: If timeout is reached before completion.
190
+ RuntimeError: If the run failed.
191
+ """
192
+ start = time.monotonic()
193
+ while not self.is_terminal:
194
+ elapsed = time.monotonic() - start
195
+ if elapsed >= timeout:
196
+ raise TimeoutError(
197
+ f"Run {self.id} did not complete within {timeout}s (status: {self.status})"
198
+ )
199
+ time.sleep(poll_interval)
200
+ self.refresh()
201
+
202
+ if self.status == "failed":
203
+ raise RuntimeError(f"Run {self.id} failed: {self.error}")
204
+ if self.status in ("cancelled", "timeout"):
205
+ raise RuntimeError(f"Run {self.id} was {self.status}")
206
+
207
+ return self.result
208
+
209
+ def cancel(self) -> Run:
210
+ """Cancel the run if it's still running.
211
+
212
+ Returns:
213
+ self (for chaining).
214
+ """
215
+ if not self.id:
216
+ raise ValueError("Cannot cancel run without id")
217
+ result = _api_request("POST", f"automation-runs/{self.id}/cancel")
218
+ if isinstance(result, dict):
219
+ self._data = result
220
+ return self
221
+
222
+ def get_log_download_url(self) -> str:
223
+ """Get a presigned URL to download the run's logs.
224
+
225
+ Logs are archived to S3 after the run completes. This method returns
226
+ a presigned URL that can be used to download the log file.
227
+
228
+ **Caution for coding agents:** Automation logs can be very large (up to 50MB).
229
+ Avoid reading entire log contents into context. Instead, download to a file
230
+ and use tools like `grep`, `tail`, or `head` to extract relevant portions.
231
+
232
+ Returns:
233
+ A presigned URL string for downloading the logs.
234
+
235
+ Raises:
236
+ ValueError: If the run has no ID.
237
+ RuntimeError: If logs are not yet available (run still in progress).
238
+
239
+ Example:
240
+ >>> run = automations.get_run("run_id")
241
+ >>> if run.is_terminal:
242
+ ... url = run.get_log_download_url()
243
+ ... # Download to file, then use grep/tail to inspect
244
+ """
245
+ if not self.id:
246
+ raise ValueError("Cannot get log URL without run id")
247
+ return get_log_download_url(self.id)
248
+
249
+ def to_dict(self) -> dict[str, Any]:
250
+ """Return the underlying data dict."""
251
+ return self._data.copy()
252
+
253
+ def __repr__(self) -> str:
254
+ return f"Run(id={self.id!r}, status={self.status!r}, automation_id={self.automation_id!r})"
255
+
256
+
257
+ # ============================================================================
258
+ # Automation Class
259
+ # ============================================================================
260
+
261
+
262
+ class Automation:
263
+ """Represents an automation definition.
264
+
265
+ Attributes:
266
+ id: The automation ID.
267
+ name: Display name.
268
+ description: Human-readable description.
269
+ external_id: Stable identifier for programmatic access.
270
+ code: Python source code.
271
+ input_schema: JSON schema defining inputs.
272
+ status: Automation status (active, inactive, archived).
273
+ schedule: Cron schedule (if scheduled).
274
+ last_run_status: Status of the most recent run.
275
+ """
276
+
277
+ def __init__(self, data: dict[str, Any]) -> None:
278
+ self._data = data
279
+
280
+ @property
281
+ def id(self) -> str:
282
+ return self._data.get("id", "")
283
+
284
+ @property
285
+ def name(self) -> str:
286
+ return self._data.get("name", "")
287
+
288
+ @property
289
+ def description(self) -> str | None:
290
+ return self._data.get("description")
291
+
292
+ @property
293
+ def external_id(self) -> str | None:
294
+ return self._data.get("external_id")
295
+
296
+ @property
297
+ def code(self) -> str:
298
+ return self._data.get("code", "")
299
+
300
+ @property
301
+ def input_schema(self) -> dict[str, Any] | None:
302
+ """Return the input schema as a dict. API always returns JSON object."""
303
+ return self._data.get("input_schema")
304
+
305
+ @property
306
+ def status(self) -> str | None:
307
+ return self._data.get("status")
308
+
309
+ @property
310
+ def schedule(self) -> str | None:
311
+ return self._data.get("schedule")
312
+
313
+ @property
314
+ def last_run_status(self) -> str | None:
315
+ return self._data.get("last_run_status")
316
+
317
+ def run(
318
+ self,
319
+ inputs: Mapping[str, Any] | None = None,
320
+ *,
321
+ external_id: str | None = None,
322
+ metadata: Mapping[str, Any] | None = None,
323
+ ) -> Run:
324
+ """Run this automation.
325
+
326
+ Args:
327
+ inputs: Input parameters for the run.
328
+ external_id: Optional correlation ID for idempotency.
329
+ metadata: Optional metadata to attach to the run.
330
+
331
+ Returns:
332
+ A Run object representing the execution.
333
+ """
334
+ return run(self.id, inputs=inputs, external_id=external_id, metadata=metadata)
335
+
336
+ def to_dict(self) -> dict[str, Any]:
337
+ """Return the underlying data dict."""
338
+ return self._data.copy()
339
+
340
+ def __repr__(self) -> str:
341
+ return f"Automation(id={self.id!r}, name={self.name!r})"
342
+
343
+
344
+ # ============================================================================
345
+ # Run Functions
346
+ # ============================================================================
347
+
348
+
349
+ def run(
350
+ automation_id: str,
351
+ inputs: Mapping[str, Any] | None = None,
352
+ *,
353
+ files: Mapping[str, Any] | None = None,
354
+ external_id: str | None = None,
355
+ metadata: Mapping[str, Any] | None = None,
356
+ ) -> Run:
357
+ """Run an automation by ID.
358
+
359
+ Args:
360
+ automation_id: The automation ID to run.
361
+ inputs: Input parameters (dict). Types are coerced based on input_schema.
362
+ files: File inputs to upload (mapping of input key to file path(s)).
363
+ external_id: Optional correlation ID for idempotency. Repeated calls
364
+ with the same external_id return the existing run.
365
+ metadata: Optional metadata to attach to the run.
366
+
367
+ Returns:
368
+ A Run object for tracking execution status.
369
+
370
+ Example:
371
+ >>> run = automations.run("abc123", inputs={"limit": 100})
372
+ >>> print(run.id, run.status)
373
+ >>> result = run.wait(timeout=300)
374
+ """
375
+ result = _run_automation(
376
+ automation_id,
377
+ inputs=inputs,
378
+ files=files,
379
+ external_id=external_id,
380
+ metadata=metadata,
381
+ )
382
+ return Run(result)
383
+
384
+
385
+ def run_by_external_id(
386
+ external_id: str,
387
+ inputs: Mapping[str, Any] | None = None,
388
+ *,
389
+ files: Mapping[str, Any] | None = None,
390
+ run_external_id: str | None = None,
391
+ metadata: Mapping[str, Any] | None = None,
392
+ ) -> Run:
393
+ """Run an automation by its external_id (more stable than internal ID).
394
+
395
+ Args:
396
+ external_id: The automation's external_id (e.g., "deposit_matching:step1").
397
+ inputs: Input parameters (dict).
398
+ files: File inputs to upload.
399
+ run_external_id: Optional correlation ID for the run itself.
400
+ metadata: Optional metadata to attach to the run.
401
+
402
+ Returns:
403
+ A Run object for tracking execution status.
404
+
405
+ Example:
406
+ >>> run = automations.run_by_external_id(
407
+ ... "deposit_matching:step1",
408
+ ... inputs={"limit": 100}
409
+ ... )
410
+ """
411
+ automation = get_by_external_id(external_id)
412
+ return run(
413
+ automation.id,
414
+ inputs=inputs,
415
+ files=files,
416
+ external_id=run_external_id,
417
+ metadata=metadata,
418
+ )
419
+
420
+
421
+ def get_run(run_id: str) -> Run:
422
+ """Get a run by its ID.
423
+
424
+ Args:
425
+ run_id: The run ID.
426
+
427
+ Returns:
428
+ A Run object with current status.
429
+
430
+ Example:
431
+ >>> run = automations.get_run("run_abc123")
432
+ >>> print(run.status, run.result)
433
+ """
434
+ result = _get_automation_run(run_id=run_id)
435
+ return Run(result)
436
+
437
+
438
+ def list_runs(
439
+ automation_id: str | None = None,
440
+ *,
441
+ status: str | None = None,
442
+ limit: int = 100,
443
+ offset: int = 0,
444
+ sort: str = "created",
445
+ dir: str = "desc",
446
+ ) -> list[Run]:
447
+ """List runs, optionally filtered by automation and status.
448
+
449
+ Args:
450
+ automation_id: Filter by automation ID (optional).
451
+ status: Filter by status (queued, running, succeeded, failed, etc.).
452
+ limit: Maximum number of results (default 100).
453
+ offset: Pagination offset.
454
+ sort: Sort field (default "created").
455
+ dir: Sort direction ("asc" or "desc", default "desc").
456
+
457
+ Returns:
458
+ List of Run objects.
459
+
460
+ Example:
461
+ >>> runs = automations.list_runs("automation_id", status="succeeded", limit=10)
462
+ >>> for r in runs:
463
+ ... print(r.id, r.created, r.status)
464
+ """
465
+ params: dict[str, Any] = {
466
+ "limit": limit,
467
+ "offset": offset,
468
+ "sort": sort,
469
+ "dir": dir,
470
+ }
471
+ if automation_id:
472
+ params["automation_id"] = automation_id
473
+ if status:
474
+ params["status"] = status
475
+
476
+ result = _api_request("GET", "automation-runs", params=params)
477
+ items = []
478
+ if isinstance(result, dict):
479
+ # API returns runs in different keys depending on context
480
+ items = result.get("automation_runs") or result.get("data") or []
481
+ return [Run(item) for item in items if isinstance(item, dict)]
482
+
483
+
484
+ # ============================================================================
485
+ # Automation Management Functions
486
+ # ============================================================================
487
+
488
+
489
+ def list(
490
+ *,
491
+ q: str | None = None,
492
+ limit: int = 50,
493
+ offset: int = 0,
494
+ sort: str = "created",
495
+ dir: str = "desc",
496
+ ) -> list[Automation]:
497
+ """List all automations.
498
+
499
+ Args:
500
+ q: Search query (case-insensitive name/description search).
501
+ limit: Maximum number of results (default 50).
502
+ offset: Pagination offset.
503
+ sort: Sort field (default "created").
504
+ dir: Sort direction ("asc" or "desc", default "desc").
505
+
506
+ Returns:
507
+ List of Automation objects.
508
+
509
+ Example:
510
+ >>> all_automations = automations.list()
511
+ >>> for a in all_automations:
512
+ ... print(a.id, a.name)
513
+ """
514
+ params: dict[str, Any] = {
515
+ "limit": limit,
516
+ "offset": offset,
517
+ "sort": sort,
518
+ "dir": dir,
519
+ }
520
+ if q:
521
+ params["q"] = q
522
+
523
+ result = _api_request("GET", "automations", params=params)
524
+ items = []
525
+ if isinstance(result, dict):
526
+ items = result.get("automations") or []
527
+ return [Automation(item) for item in items if isinstance(item, dict)]
528
+
529
+
530
+ def get(automation_id: str) -> Automation:
531
+ """Get an automation by ID.
532
+
533
+ Args:
534
+ automation_id: The automation ID.
535
+
536
+ Returns:
537
+ An Automation object.
538
+
539
+ Example:
540
+ >>> automation = automations.get("abc123")
541
+ >>> print(automation.name, automation.input_schema)
542
+ """
543
+ automation_id = automation_id.strip()
544
+ if not automation_id:
545
+ raise ValueError("automation_id is required")
546
+
547
+ result = _api_request("GET", f"automations/{automation_id}")
548
+ if not isinstance(result, dict):
549
+ raise RuntimeError("Unexpected response")
550
+ return Automation(result)
551
+
552
+
553
+ def get_by_external_id(external_id: str) -> Automation:
554
+ """Get an automation by its external_id.
555
+
556
+ Args:
557
+ external_id: The automation's external_id.
558
+
559
+ Returns:
560
+ An Automation object.
561
+
562
+ Raises:
563
+ LumeraAPIError: If no automation with that external_id exists.
564
+
565
+ Example:
566
+ >>> automation = automations.get_by_external_id("deposit_matching:step1")
567
+ >>> print(automation.id, automation.name)
568
+ """
569
+ external_id = external_id.strip()
570
+ if not external_id:
571
+ raise ValueError("external_id is required")
572
+
573
+ result = _api_request("GET", "automations", params={"external_id": external_id, "limit": 1})
574
+ if isinstance(result, dict):
575
+ items = result.get("automations") or []
576
+ if items and isinstance(items[0], dict):
577
+ return Automation(items[0])
578
+
579
+ raise LumeraAPIError(
580
+ 404,
581
+ f"Automation with external_id '{external_id}' not found",
582
+ url="automations",
583
+ payload=None,
584
+ )
585
+
586
+
587
+ def create(
588
+ name: str,
589
+ code: str,
590
+ *,
591
+ input_schema: Mapping[str, Any] | None = None,
592
+ description: str | None = None,
593
+ external_id: str | None = None,
594
+ schedule: str | None = None,
595
+ schedule_tz: str | None = None,
596
+ ) -> Automation:
597
+ """Create a new automation.
598
+
599
+ Args:
600
+ name: Display name for the automation.
601
+ code: Python code with the entrypoint function.
602
+ input_schema: JSON schema defining inputs. Should include
603
+ ``function.name`` and ``function.parameters``.
604
+ description: Human-readable description.
605
+ external_id: Stable identifier for programmatic access.
606
+ schedule: Cron expression for scheduled runs.
607
+ schedule_tz: Timezone for schedule (e.g., "America/New_York").
608
+
609
+ Returns:
610
+ The created Automation object.
611
+
612
+ Example:
613
+ >>> automation = automations.create(
614
+ ... name="My Automation",
615
+ ... code="def main(x): return {'result': x * 2}",
616
+ ... input_schema={
617
+ ... "type": "function",
618
+ ... "function": {
619
+ ... "name": "main",
620
+ ... "parameters": {
621
+ ... "type": "object",
622
+ ... "properties": {"x": {"type": "integer"}},
623
+ ... "required": ["x"]
624
+ ... }
625
+ ... }
626
+ ... }
627
+ ... )
628
+ """
629
+ payload: dict[str, Any] = {
630
+ "name": name,
631
+ "code": code,
632
+ }
633
+ if input_schema is not None:
634
+ payload["input_schema"] = input_schema
635
+ if description is not None:
636
+ payload["description"] = description
637
+ if external_id is not None:
638
+ payload["external_id"] = external_id
639
+ if schedule is not None:
640
+ payload["schedule"] = schedule
641
+ if schedule_tz is not None:
642
+ payload["schedule_tz"] = schedule_tz
643
+
644
+ result = _api_request("POST", "automations", json_body=payload)
645
+ if not isinstance(result, dict):
646
+ raise RuntimeError("Unexpected response")
647
+ return Automation(result)
648
+
649
+
650
+ def update(
651
+ automation_id: str,
652
+ *,
653
+ name: str | None = None,
654
+ code: str | None = None,
655
+ input_schema: Mapping[str, Any] | None = None,
656
+ description: str | None = None,
657
+ external_id: str | None = None,
658
+ schedule: str | None = None,
659
+ schedule_tz: str | None = None,
660
+ ) -> Automation:
661
+ """Update an existing automation.
662
+
663
+ Args:
664
+ automation_id: The automation ID to update.
665
+ name: New display name.
666
+ code: New Python code.
667
+ input_schema: New input schema.
668
+ description: New description.
669
+ external_id: New external_id.
670
+ schedule: New cron schedule (empty string to clear).
671
+ schedule_tz: New schedule timezone.
672
+
673
+ Returns:
674
+ The updated Automation object.
675
+
676
+ Example:
677
+ >>> automations.update("abc123", code=new_code)
678
+ """
679
+ automation_id = automation_id.strip()
680
+ if not automation_id:
681
+ raise ValueError("automation_id is required")
682
+
683
+ payload: dict[str, Any] = {}
684
+ if name is not None:
685
+ payload["name"] = name
686
+ if code is not None:
687
+ payload["code"] = code
688
+ if input_schema is not None:
689
+ payload["input_schema"] = input_schema
690
+ if description is not None:
691
+ payload["description"] = description
692
+ if external_id is not None:
693
+ payload["external_id"] = external_id
694
+ if schedule is not None:
695
+ payload["schedule"] = schedule
696
+ if schedule_tz is not None:
697
+ payload["schedule_tz"] = schedule_tz
698
+
699
+ if not payload:
700
+ raise ValueError("At least one field to update is required")
701
+
702
+ result = _api_request("PATCH", f"automations/{automation_id}", json_body=payload)
703
+ if not isinstance(result, dict):
704
+ raise RuntimeError("Unexpected response")
705
+ return Automation(result)
706
+
707
+
708
+ def upsert(
709
+ external_id: str,
710
+ *,
711
+ name: str,
712
+ code: str,
713
+ input_schema: Mapping[str, Any] | None = None,
714
+ description: str | None = None,
715
+ schedule: str | None = None,
716
+ schedule_tz: str | None = None,
717
+ ) -> Automation:
718
+ """Create or update an automation by external_id (idempotent deploy).
719
+
720
+ If an automation with the given external_id exists, it will be updated.
721
+ Otherwise, a new automation will be created.
722
+
723
+ Args:
724
+ external_id: Stable identifier (required for upsert).
725
+ name: Display name.
726
+ code: Python code.
727
+ input_schema: JSON schema defining inputs.
728
+ description: Human-readable description.
729
+ schedule: Cron expression for scheduled runs.
730
+ schedule_tz: Timezone for schedule.
731
+
732
+ Returns:
733
+ The created or updated Automation object.
734
+
735
+ Example:
736
+ >>> # Idempotent deployment
737
+ >>> automations.upsert(
738
+ ... external_id="my_app:my_automation",
739
+ ... name="My Automation",
740
+ ... code=open("my_script.py").read(),
741
+ ... input_schema=schema
742
+ ... )
743
+ """
744
+ external_id = external_id.strip()
745
+ if not external_id:
746
+ raise ValueError("external_id is required for upsert")
747
+
748
+ # Try to find existing
749
+ try:
750
+ existing = get_by_external_id(external_id)
751
+ # Update existing
752
+ return update(
753
+ existing.id,
754
+ name=name,
755
+ code=code,
756
+ input_schema=input_schema,
757
+ description=description,
758
+ schedule=schedule,
759
+ schedule_tz=schedule_tz,
760
+ )
761
+ except LumeraAPIError as e:
762
+ if e.status_code != 404:
763
+ raise
764
+ # Create new
765
+ return create(
766
+ name=name,
767
+ code=code,
768
+ input_schema=input_schema,
769
+ description=description,
770
+ external_id=external_id,
771
+ schedule=schedule,
772
+ schedule_tz=schedule_tz,
773
+ )
774
+
775
+
776
+ def delete(automation_id: str) -> None:
777
+ """Delete an automation.
778
+
779
+ Args:
780
+ automation_id: The automation ID to delete.
781
+
782
+ Example:
783
+ >>> automations.delete("abc123")
784
+ """
785
+ automation_id = automation_id.strip()
786
+ if not automation_id:
787
+ raise ValueError("automation_id is required")
788
+
789
+ _api_request("DELETE", f"automations/{automation_id}")
790
+
791
+
792
+ # ============================================================================
793
+ # Log Streaming
794
+ # ============================================================================
795
+
796
+
797
+ def stream_logs(run_id: str, *, timeout: float = 30) -> Iterator[str]:
798
+ """Stream live logs from a running automation.
799
+
800
+ Connects to the server-sent events endpoint and yields log lines
801
+ as they arrive. Stops when the run completes.
802
+
803
+ Args:
804
+ run_id: The run ID to stream logs from.
805
+ timeout: HTTP connection timeout in seconds.
806
+
807
+ Yields:
808
+ Log lines as strings.
809
+
810
+ Example:
811
+ >>> for line in automations.stream_logs("run_id"):
812
+ ... print(line)
813
+ """
814
+ import base64
815
+ import os
816
+
817
+ import requests
818
+
819
+ run_id = run_id.strip()
820
+ if not run_id:
821
+ raise ValueError("run_id is required")
822
+
823
+ base_url = os.environ.get("LUMERA_BASE_URL", "https://app.lumerahq.com/api").rstrip("/")
824
+ token = os.environ.get("LUMERA_TOKEN", "")
825
+ if not token:
826
+ raise ValueError("LUMERA_TOKEN environment variable is required")
827
+
828
+ url = f"{base_url}/automation-runs/{run_id}/logs/live"
829
+ headers = {
830
+ "Authorization": f"token {token}",
831
+ "Accept": "text/event-stream",
832
+ }
833
+
834
+ with requests.get(url, headers=headers, stream=True, timeout=timeout) as resp:
835
+ resp.raise_for_status()
836
+
837
+ current_event = ""
838
+ current_data = ""
839
+
840
+ for line in resp.iter_lines(decode_unicode=True):
841
+ if line is None:
842
+ continue
843
+
844
+ if line.startswith("event:"):
845
+ current_event = line[6:].strip()
846
+ elif line.startswith("data:"):
847
+ current_data = line[5:].strip()
848
+ elif line == "":
849
+ # End of event
850
+ if current_event == "chunk" and current_data:
851
+ try:
852
+ data = json.loads(current_data)
853
+ if "data" in data:
854
+ # Data is base64-encoded
855
+ raw = base64.b64decode(data["data"])
856
+ decoded = raw.decode("utf-8", errors="replace")
857
+ yield from decoded.splitlines()
858
+ except (json.JSONDecodeError, KeyError):
859
+ pass
860
+ elif current_event == "complete":
861
+ return
862
+ current_event = ""
863
+ current_data = ""
864
+
865
+
866
+ def get_log_download_url(run_id: str) -> str:
867
+ """Get a presigned URL to download the logs for a completed run.
868
+
869
+ Logs are archived to S3 after a run completes. This function returns
870
+ a presigned URL that can be used to download the log file directly.
871
+
872
+ **Caution for coding agents:** Automation logs can be very large (up to 50MB).
873
+ Avoid reading entire log contents into context. Instead, download to a file
874
+ and use tools like `grep`, `tail`, or `head` to extract relevant portions.
875
+
876
+ Args:
877
+ run_id: The run ID to get logs for.
878
+
879
+ Returns:
880
+ A presigned URL string for downloading the logs.
881
+
882
+ Raises:
883
+ ValueError: If run_id is empty.
884
+ LumeraAPIError: If the run doesn't exist or logs aren't available.
885
+
886
+ Example:
887
+ >>> url = automations.get_log_download_url("run_abc123")
888
+ >>> # Download to file, then inspect with shell tools
889
+ >>> import subprocess
890
+ >>> subprocess.run(["curl", "-o", "run.log", url])
891
+ >>> # Use grep/tail to extract relevant parts
892
+ """
893
+ run_id = run_id.strip()
894
+ if not run_id:
895
+ raise ValueError("run_id is required")
896
+
897
+ result = _api_request(
898
+ "GET",
899
+ f"automation-runs/{run_id}/files/download-url",
900
+ params={"name": "run.log"},
901
+ )
902
+ if isinstance(result, dict) and "url" in result:
903
+ return result["url"]
904
+ raise RuntimeError("Unexpected response: no download URL returned")