lumera 0.5.4__tar.gz → 0.7.0__tar.gz

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