lumera 0.6.0__py3-none-any.whl → 0.7.3__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
@@ -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,
@@ -34,7 +34,7 @@ from .sdk import (
34
34
  create_record,
35
35
  delete_collection,
36
36
  delete_record,
37
- get_agent_run,
37
+ get_automation_run,
38
38
  get_collection,
39
39
  get_record,
40
40
  get_record_by_external_id,
@@ -42,8 +42,9 @@ from .sdk import (
42
42
  list_records,
43
43
  query_sql,
44
44
  replay_hook,
45
- run_agent,
45
+ run_automation,
46
46
  save_to_lumera,
47
+ update_automation_run,
47
48
  update_collection,
48
49
  update_record,
49
50
  upload_lumera_file,
@@ -73,8 +74,9 @@ __all__ = [
73
74
  # Other operations
74
75
  "replay_hook",
75
76
  "query_sql",
76
- "run_agent",
77
- "get_agent_run",
77
+ "run_automation",
78
+ "get_automation_run",
79
+ "update_automation_run",
78
80
  "upload_lumera_file",
79
81
  "save_to_lumera",
80
82
  # Type definitions
@@ -89,6 +91,7 @@ __all__ = [
89
91
  "RecordNotFoundError",
90
92
  "LockHeldError",
91
93
  # New modules (use as lumera.pb, lumera.storage, etc.)
94
+ "automations",
92
95
  "pb",
93
96
  "storage",
94
97
  "llm",
lumera/_utils.py CHANGED
@@ -117,10 +117,10 @@ def _utcnow_iso() -> str:
117
117
  )
118
118
 
119
119
 
120
- def _default_provenance(agent_id: str, run_id: str | None) -> dict[str, Any]:
120
+ def _default_provenance(automation_id: str, run_id: str | None) -> dict[str, Any]:
121
121
  recorded_at = _utcnow_iso()
122
- env_agent_id = os.getenv("LUMERA_AGENT_ID", "").strip()
123
- agent_id = (agent_id or "").strip() or env_agent_id
122
+ env_automation_id = os.getenv("LUMERA_AUTOMATION_ID", "").strip()
123
+ automation_id = (automation_id or "").strip() or env_automation_id
124
124
 
125
125
  run_id = (run_id or "").strip() or os.getenv("LUMERA_RUN_ID", "").strip()
126
126
 
@@ -132,8 +132,8 @@ def _default_provenance(agent_id: str, run_id: str | None) -> dict[str, Any]:
132
132
  "recorded_at": recorded_at,
133
133
  }
134
134
 
135
- if agent_id:
136
- payload["agent"] = {"id": agent_id}
135
+ if automation_id:
136
+ payload["agent"] = {"id": automation_id} # Backend still expects "agent" key
137
137
 
138
138
  if run_id:
139
139
  payload["agent_run"] = {"id": run_id}
@@ -284,7 +284,7 @@ def _ensure_mapping(payload: Mapping[str, Any] | None, *, name: str) -> dict[str
284
284
  return dict(payload)
285
285
 
286
286
 
287
- def _prepare_agent_inputs(
287
+ def _prepare_automation_inputs(
288
288
  inputs: Mapping[str, Any] | str | None,
289
289
  ) -> dict[str, Any] | None:
290
290
  if inputs is None:
@@ -345,7 +345,7 @@ def _upload_file_to_presigned(upload_url: str, path: pathlib.Path, content_type:
345
345
  resp.raise_for_status()
346
346
 
347
347
 
348
- def _upload_agent_files(
348
+ def _upload_automation_files(
349
349
  run_id: str | None,
350
350
  files: Mapping[str, str | os.PathLike[str] | Sequence[str | os.PathLike[str]]],
351
351
  *,
@@ -370,7 +370,7 @@ def _upload_agent_files(
370
370
  size = file_path.stat().st_size
371
371
 
372
372
  body: dict[str, Any] = {
373
- "scope": "agent_run",
373
+ "scope": "automation_run",
374
374
  "filename": filename,
375
375
  "content_type": content_type,
376
376
  "size": size,
@@ -631,7 +631,7 @@ def _upload_session_file(file_path: str, session_id: str) -> dict:
631
631
  return {"name": filename, "notebook_path": notebook_path}
632
632
 
633
633
 
634
- def _upload_agent_run_file(file_path: str, run_id: str) -> dict:
634
+ def _upload_automation_run_file(file_path: str, run_id: str) -> dict:
635
635
  token = get_lumera_token()
636
636
  path = pathlib.Path(file_path).expanduser().resolve()
637
637
  if not path.is_file():
@@ -644,7 +644,7 @@ def _upload_agent_run_file(file_path: str, run_id: str) -> dict:
644
644
  headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
645
645
 
646
646
  resp = requests.post(
647
- f"{API_BASE}/agent-runs/{run_id}/files/upload-url",
647
+ f"{API_BASE}/automation-runs/{run_id}/files/upload-url",
648
648
  json={"filename": filename, "content_type": mimetype, "size": size},
649
649
  headers=headers,
650
650
  timeout=30,
@@ -742,10 +742,10 @@ __all__ = [
742
742
  "_is_sequence",
743
743
  "_normalize_mount_path",
744
744
  "_pretty_size",
745
- "_prepare_agent_inputs",
745
+ "_prepare_automation_inputs",
746
746
  "_record_mutation",
747
- "_upload_agent_files",
748
- "_upload_agent_run_file",
747
+ "_upload_automation_files",
748
+ "_upload_automation_run_file",
749
749
  "_upload_file_to_presigned",
750
750
  "_upload_document",
751
751
  "_upload_session_file",
lumera/automations.py ADDED
@@ -0,0 +1,835 @@
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_automation_run as _get_automation_run
69
+ from .sdk import run_automation as _run_automation
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("automation_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_automation_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"automation-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
+ """Return the input schema as a dict. API always returns JSON object."""
275
+ return self._data.get("input_schema")
276
+
277
+ @property
278
+ def status(self) -> str | None:
279
+ return self._data.get("status")
280
+
281
+ @property
282
+ def schedule(self) -> str | None:
283
+ return self._data.get("schedule")
284
+
285
+ @property
286
+ def last_run_status(self) -> str | None:
287
+ return self._data.get("last_run_status")
288
+
289
+ def run(
290
+ self,
291
+ inputs: Mapping[str, Any] | None = None,
292
+ *,
293
+ external_id: str | None = None,
294
+ metadata: Mapping[str, Any] | None = None,
295
+ ) -> Run:
296
+ """Run this automation.
297
+
298
+ Args:
299
+ inputs: Input parameters for the run.
300
+ external_id: Optional correlation ID for idempotency.
301
+ metadata: Optional metadata to attach to the run.
302
+
303
+ Returns:
304
+ A Run object representing the execution.
305
+ """
306
+ return run(self.id, inputs=inputs, external_id=external_id, metadata=metadata)
307
+
308
+ def to_dict(self) -> dict[str, Any]:
309
+ """Return the underlying data dict."""
310
+ return self._data.copy()
311
+
312
+ def __repr__(self) -> str:
313
+ return f"Automation(id={self.id!r}, name={self.name!r})"
314
+
315
+
316
+ # ============================================================================
317
+ # Run Functions
318
+ # ============================================================================
319
+
320
+
321
+ def run(
322
+ automation_id: str,
323
+ inputs: Mapping[str, Any] | None = None,
324
+ *,
325
+ files: Mapping[str, Any] | None = None,
326
+ external_id: str | None = None,
327
+ metadata: Mapping[str, Any] | None = None,
328
+ ) -> Run:
329
+ """Run an automation by ID.
330
+
331
+ Args:
332
+ automation_id: The automation/agent ID to run.
333
+ inputs: Input parameters (dict). Types are coerced based on input_schema.
334
+ files: File inputs to upload (mapping of input key to file path(s)).
335
+ external_id: Optional correlation ID for idempotency. Repeated calls
336
+ with the same external_id return the existing run.
337
+ metadata: Optional metadata to attach to the run.
338
+
339
+ Returns:
340
+ A Run object for tracking execution status.
341
+
342
+ Example:
343
+ >>> run = automations.run("abc123", inputs={"limit": 100})
344
+ >>> print(run.id, run.status)
345
+ >>> result = run.wait(timeout=300)
346
+ """
347
+ result = _run_automation(
348
+ automation_id,
349
+ inputs=inputs,
350
+ files=files,
351
+ external_id=external_id,
352
+ metadata=metadata,
353
+ )
354
+ return Run(result)
355
+
356
+
357
+ def run_by_external_id(
358
+ external_id: str,
359
+ inputs: Mapping[str, Any] | None = None,
360
+ *,
361
+ files: Mapping[str, Any] | None = None,
362
+ run_external_id: str | None = None,
363
+ metadata: Mapping[str, Any] | None = None,
364
+ ) -> Run:
365
+ """Run an automation by its external_id (more stable than internal ID).
366
+
367
+ Args:
368
+ external_id: The automation's external_id (e.g., "deposit_matching:step1").
369
+ inputs: Input parameters (dict).
370
+ files: File inputs to upload.
371
+ run_external_id: Optional correlation ID for the run itself.
372
+ metadata: Optional metadata to attach to the run.
373
+
374
+ Returns:
375
+ A Run object for tracking execution status.
376
+
377
+ Example:
378
+ >>> run = automations.run_by_external_id(
379
+ ... "deposit_matching:step1",
380
+ ... inputs={"limit": 100}
381
+ ... )
382
+ """
383
+ automation = get_by_external_id(external_id)
384
+ return run(
385
+ automation.id,
386
+ inputs=inputs,
387
+ files=files,
388
+ external_id=run_external_id,
389
+ metadata=metadata,
390
+ )
391
+
392
+
393
+ def get_run(run_id: str) -> Run:
394
+ """Get a run by its ID.
395
+
396
+ Args:
397
+ run_id: The run ID.
398
+
399
+ Returns:
400
+ A Run object with current status.
401
+
402
+ Example:
403
+ >>> run = automations.get_run("run_abc123")
404
+ >>> print(run.status, run.result)
405
+ """
406
+ result = _get_automation_run(run_id=run_id)
407
+ return Run(result)
408
+
409
+
410
+ def list_runs(
411
+ automation_id: str | None = None,
412
+ *,
413
+ status: str | None = None,
414
+ limit: int = 100,
415
+ offset: int = 0,
416
+ sort: str = "created",
417
+ dir: str = "desc",
418
+ ) -> list[Run]:
419
+ """List runs, optionally filtered by automation and status.
420
+
421
+ Args:
422
+ automation_id: Filter by automation ID (optional).
423
+ status: Filter by status (queued, running, succeeded, failed, etc.).
424
+ limit: Maximum number of results (default 100).
425
+ offset: Pagination offset.
426
+ sort: Sort field (default "created").
427
+ dir: Sort direction ("asc" or "desc", default "desc").
428
+
429
+ Returns:
430
+ List of Run objects.
431
+
432
+ Example:
433
+ >>> runs = automations.list_runs("agent_id", status="succeeded", limit=10)
434
+ >>> for r in runs:
435
+ ... print(r.id, r.created, r.status)
436
+ """
437
+ params: dict[str, Any] = {
438
+ "limit": limit,
439
+ "offset": offset,
440
+ "sort": sort,
441
+ "dir": dir,
442
+ }
443
+ if automation_id:
444
+ params["automation_id"] = automation_id
445
+ if status:
446
+ params["status"] = status
447
+
448
+ result = _api_request("GET", "automation-runs", params=params)
449
+ items = []
450
+ if isinstance(result, dict):
451
+ # API returns runs in different keys depending on context
452
+ items = result.get("automation_runs") or result.get("data") or []
453
+ return [Run(item) for item in items if isinstance(item, dict)]
454
+
455
+
456
+ # ============================================================================
457
+ # Automation Management Functions
458
+ # ============================================================================
459
+
460
+
461
+ def list(
462
+ *,
463
+ q: str | None = None,
464
+ limit: int = 50,
465
+ offset: int = 0,
466
+ sort: str = "created",
467
+ dir: str = "desc",
468
+ ) -> list[Automation]:
469
+ """List all automations.
470
+
471
+ Args:
472
+ q: Search query (case-insensitive name/description search).
473
+ limit: Maximum number of results (default 50).
474
+ offset: Pagination offset.
475
+ sort: Sort field (default "created").
476
+ dir: Sort direction ("asc" or "desc", default "desc").
477
+
478
+ Returns:
479
+ List of Automation objects.
480
+
481
+ Example:
482
+ >>> all_automations = automations.list()
483
+ >>> for a in all_automations:
484
+ ... print(a.id, a.name)
485
+ """
486
+ params: dict[str, Any] = {
487
+ "limit": limit,
488
+ "offset": offset,
489
+ "sort": sort,
490
+ "dir": dir,
491
+ }
492
+ if q:
493
+ params["q"] = q
494
+
495
+ result = _api_request("GET", "automations", params=params)
496
+ items = []
497
+ if isinstance(result, dict):
498
+ items = result.get("automations") or []
499
+ return [Automation(item) for item in items if isinstance(item, dict)]
500
+
501
+
502
+ def get(automation_id: str) -> Automation:
503
+ """Get an automation by ID.
504
+
505
+ Args:
506
+ automation_id: The automation ID.
507
+
508
+ Returns:
509
+ An Automation object.
510
+
511
+ Example:
512
+ >>> agent = automations.get("abc123")
513
+ >>> print(agent.name, agent.input_schema)
514
+ """
515
+ automation_id = automation_id.strip()
516
+ if not automation_id:
517
+ raise ValueError("automation_id is required")
518
+
519
+ result = _api_request("GET", f"automations/{automation_id}")
520
+ if not isinstance(result, dict):
521
+ raise RuntimeError("Unexpected response")
522
+ return Automation(result)
523
+
524
+
525
+ def get_by_external_id(external_id: str) -> Automation:
526
+ """Get an automation by its external_id.
527
+
528
+ Args:
529
+ external_id: The automation's external_id.
530
+
531
+ Returns:
532
+ An Automation object.
533
+
534
+ Raises:
535
+ LumeraAPIError: If no automation with that external_id exists.
536
+
537
+ Example:
538
+ >>> agent = automations.get_by_external_id("deposit_matching:step1")
539
+ >>> print(agent.id, agent.name)
540
+ """
541
+ external_id = external_id.strip()
542
+ if not external_id:
543
+ raise ValueError("external_id is required")
544
+
545
+ result = _api_request("GET", "automations", params={"external_id": external_id, "limit": 1})
546
+ if isinstance(result, dict):
547
+ items = result.get("automations") or []
548
+ if items and isinstance(items[0], dict):
549
+ return Automation(items[0])
550
+
551
+ raise LumeraAPIError(
552
+ 404,
553
+ f"Automation with external_id '{external_id}' not found",
554
+ url="automations",
555
+ payload=None,
556
+ )
557
+
558
+
559
+ def create(
560
+ name: str,
561
+ code: str,
562
+ *,
563
+ input_schema: Mapping[str, Any] | None = None,
564
+ description: str | None = None,
565
+ external_id: str | None = None,
566
+ schedule: str | None = None,
567
+ schedule_tz: str | None = None,
568
+ ) -> Automation:
569
+ """Create a new automation.
570
+
571
+ Args:
572
+ name: Display name for the automation.
573
+ code: Python code with the entrypoint function.
574
+ input_schema: JSON schema defining inputs. Should include
575
+ ``function.name`` and ``function.parameters``.
576
+ description: Human-readable description.
577
+ external_id: Stable identifier for programmatic access.
578
+ schedule: Cron expression for scheduled runs.
579
+ schedule_tz: Timezone for schedule (e.g., "America/New_York").
580
+
581
+ Returns:
582
+ The created Automation object.
583
+
584
+ Example:
585
+ >>> agent = automations.create(
586
+ ... name="My Automation",
587
+ ... code="def main(x): return {'result': x * 2}",
588
+ ... input_schema={
589
+ ... "type": "function",
590
+ ... "function": {
591
+ ... "name": "main",
592
+ ... "parameters": {
593
+ ... "type": "object",
594
+ ... "properties": {"x": {"type": "integer"}},
595
+ ... "required": ["x"]
596
+ ... }
597
+ ... }
598
+ ... }
599
+ ... )
600
+ """
601
+ payload: dict[str, Any] = {
602
+ "name": name,
603
+ "code": code,
604
+ }
605
+ if input_schema is not None:
606
+ payload["input_schema"] = input_schema
607
+ if description is not None:
608
+ payload["description"] = description
609
+ if external_id is not None:
610
+ payload["external_id"] = external_id
611
+ if schedule is not None:
612
+ payload["schedule"] = schedule
613
+ if schedule_tz is not None:
614
+ payload["schedule_tz"] = schedule_tz
615
+
616
+ result = _api_request("POST", "automations", json_body=payload)
617
+ if not isinstance(result, dict):
618
+ raise RuntimeError("Unexpected response")
619
+ return Automation(result)
620
+
621
+
622
+ def update(
623
+ automation_id: str,
624
+ *,
625
+ name: str | None = None,
626
+ code: str | None = None,
627
+ input_schema: Mapping[str, Any] | None = None,
628
+ description: str | None = None,
629
+ external_id: str | None = None,
630
+ schedule: str | None = None,
631
+ schedule_tz: str | None = None,
632
+ ) -> Automation:
633
+ """Update an existing automation.
634
+
635
+ Args:
636
+ automation_id: The automation ID to update.
637
+ name: New display name.
638
+ code: New Python code.
639
+ input_schema: New input schema.
640
+ description: New description.
641
+ external_id: New external_id.
642
+ schedule: New cron schedule (empty string to clear).
643
+ schedule_tz: New schedule timezone.
644
+
645
+ Returns:
646
+ The updated Automation object.
647
+
648
+ Example:
649
+ >>> automations.update("abc123", code=new_code)
650
+ """
651
+ automation_id = automation_id.strip()
652
+ if not automation_id:
653
+ raise ValueError("automation_id is required")
654
+
655
+ payload: dict[str, Any] = {}
656
+ if name is not None:
657
+ payload["name"] = name
658
+ if code is not None:
659
+ payload["code"] = code
660
+ if input_schema is not None:
661
+ payload["input_schema"] = input_schema
662
+ if description is not None:
663
+ payload["description"] = description
664
+ if external_id is not None:
665
+ payload["external_id"] = external_id
666
+ if schedule is not None:
667
+ payload["schedule"] = schedule
668
+ if schedule_tz is not None:
669
+ payload["schedule_tz"] = schedule_tz
670
+
671
+ if not payload:
672
+ raise ValueError("At least one field to update is required")
673
+
674
+ result = _api_request("PATCH", f"automations/{automation_id}", json_body=payload)
675
+ if not isinstance(result, dict):
676
+ raise RuntimeError("Unexpected response")
677
+ return Automation(result)
678
+
679
+
680
+ def upsert(
681
+ external_id: str,
682
+ *,
683
+ name: str,
684
+ code: str,
685
+ input_schema: Mapping[str, Any] | None = None,
686
+ description: str | None = None,
687
+ schedule: str | None = None,
688
+ schedule_tz: str | None = None,
689
+ ) -> Automation:
690
+ """Create or update an automation by external_id (idempotent deploy).
691
+
692
+ If an automation with the given external_id exists, it will be updated.
693
+ Otherwise, a new automation will be created.
694
+
695
+ Args:
696
+ external_id: Stable identifier (required for upsert).
697
+ name: Display name.
698
+ code: Python code.
699
+ input_schema: JSON schema defining inputs.
700
+ description: Human-readable description.
701
+ schedule: Cron expression for scheduled runs.
702
+ schedule_tz: Timezone for schedule.
703
+
704
+ Returns:
705
+ The created or updated Automation object.
706
+
707
+ Example:
708
+ >>> # Idempotent deployment
709
+ >>> automations.upsert(
710
+ ... external_id="my_app:my_automation",
711
+ ... name="My Automation",
712
+ ... code=open("my_script.py").read(),
713
+ ... input_schema=schema
714
+ ... )
715
+ """
716
+ external_id = external_id.strip()
717
+ if not external_id:
718
+ raise ValueError("external_id is required for upsert")
719
+
720
+ # Try to find existing
721
+ try:
722
+ existing = get_by_external_id(external_id)
723
+ # Update existing
724
+ return update(
725
+ existing.id,
726
+ name=name,
727
+ code=code,
728
+ input_schema=input_schema,
729
+ description=description,
730
+ schedule=schedule,
731
+ schedule_tz=schedule_tz,
732
+ )
733
+ except LumeraAPIError as e:
734
+ if e.status_code != 404:
735
+ raise
736
+ # Create new
737
+ return create(
738
+ name=name,
739
+ code=code,
740
+ input_schema=input_schema,
741
+ description=description,
742
+ external_id=external_id,
743
+ schedule=schedule,
744
+ schedule_tz=schedule_tz,
745
+ )
746
+
747
+
748
+ def delete(automation_id: str) -> None:
749
+ """Delete an automation.
750
+
751
+ Args:
752
+ automation_id: The automation ID to delete.
753
+
754
+ Example:
755
+ >>> automations.delete("abc123")
756
+ """
757
+ automation_id = automation_id.strip()
758
+ if not automation_id:
759
+ raise ValueError("automation_id is required")
760
+
761
+ _api_request("DELETE", f"automations/{automation_id}")
762
+
763
+
764
+ # ============================================================================
765
+ # Log Streaming
766
+ # ============================================================================
767
+
768
+
769
+ def stream_logs(run_id: str, *, timeout: float = 30) -> Iterator[str]:
770
+ """Stream live logs from a running automation.
771
+
772
+ Connects to the server-sent events endpoint and yields log lines
773
+ as they arrive. Stops when the run completes.
774
+
775
+ Args:
776
+ run_id: The run ID to stream logs from.
777
+ timeout: HTTP connection timeout in seconds.
778
+
779
+ Yields:
780
+ Log lines as strings.
781
+
782
+ Example:
783
+ >>> for line in automations.stream_logs("run_id"):
784
+ ... print(line)
785
+ """
786
+ import base64
787
+ import os
788
+
789
+ import requests
790
+
791
+ run_id = run_id.strip()
792
+ if not run_id:
793
+ raise ValueError("run_id is required")
794
+
795
+ base_url = os.environ.get("LUMERA_BASE_URL", "https://app.lumerahq.com/api").rstrip("/")
796
+ token = os.environ.get("LUMERA_TOKEN", "")
797
+ if not token:
798
+ raise ValueError("LUMERA_TOKEN environment variable is required")
799
+
800
+ url = f"{base_url}/automation-runs/{run_id}/logs/live"
801
+ headers = {
802
+ "Authorization": f"token {token}",
803
+ "Accept": "text/event-stream",
804
+ }
805
+
806
+ with requests.get(url, headers=headers, stream=True, timeout=timeout) as resp:
807
+ resp.raise_for_status()
808
+
809
+ current_event = ""
810
+ current_data = ""
811
+
812
+ for line in resp.iter_lines(decode_unicode=True):
813
+ if line is None:
814
+ continue
815
+
816
+ if line.startswith("event:"):
817
+ current_event = line[6:].strip()
818
+ elif line.startswith("data:"):
819
+ current_data = line[5:].strip()
820
+ elif line == "":
821
+ # End of event
822
+ if current_event == "chunk" and current_data:
823
+ try:
824
+ data = json.loads(current_data)
825
+ if "data" in data:
826
+ # Data is base64-encoded
827
+ raw = base64.b64decode(data["data"])
828
+ decoded = raw.decode("utf-8", errors="replace")
829
+ yield from decoded.splitlines()
830
+ except (json.JSONDecodeError, KeyError):
831
+ pass
832
+ elif current_event == "complete":
833
+ return
834
+ current_event = ""
835
+ current_data = ""
lumera/pb.py CHANGED
@@ -76,6 +76,18 @@ __all__ = [
76
76
 
77
77
  # Import underlying SDK functions (prefixed with _ to indicate internal use)
78
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
+ )
79
91
  from .sdk import (
80
92
  create_collection as _create_collection,
81
93
  )
@@ -112,18 +124,6 @@ from .sdk import (
112
124
  from .sdk import (
113
125
  upsert_record as _upsert_record,
114
126
  )
115
- from .sdk import (
116
- bulk_delete_records as _bulk_delete_records,
117
- )
118
- from .sdk import (
119
- bulk_update_records as _bulk_update_records,
120
- )
121
- from .sdk import (
122
- bulk_upsert_records as _bulk_upsert_records,
123
- )
124
- from .sdk import (
125
- bulk_insert_records as _bulk_insert_records,
126
- )
127
127
 
128
128
 
129
129
  def search(
lumera/sdk.py CHANGED
@@ -51,10 +51,10 @@ from ._utils import (
51
51
  _default_provenance,
52
52
  _ensure_mapping,
53
53
  _is_sequence,
54
- _prepare_agent_inputs,
54
+ _prepare_automation_inputs,
55
55
  _record_mutation,
56
- _upload_agent_files,
57
- _upload_agent_run_file,
56
+ _upload_automation_files,
57
+ _upload_automation_run_file,
58
58
  _upload_document,
59
59
  _upload_lumera_file,
60
60
  _upload_session_file,
@@ -388,8 +388,8 @@ def get_record_by_external_id(collection_id_or_name: str, external_id: str) -> d
388
388
  return first
389
389
 
390
390
 
391
- def run_agent(
392
- agent_id: str,
391
+ def run_automation(
392
+ automation_id: str,
393
393
  *,
394
394
  inputs: Mapping[str, Any] | str | None = None,
395
395
  files: Mapping[str, str | os.PathLike[str] | Sequence[str | os.PathLike[str]]] | None = None,
@@ -402,7 +402,7 @@ def run_agent(
402
402
  """Create an agent run and optionally upload files for file inputs.
403
403
 
404
404
  Args:
405
- agent_id: The automation/agent to run. Required.
405
+ automation_id: The automation/agent to run. Required.
406
406
  inputs: Inputs payload (dict or JSON string). File refs are resolved automatically.
407
407
  files: Mapping of input key -> path(s) to upload before run creation.
408
408
  status: Optional initial status (defaults to ``queued``).
@@ -413,16 +413,18 @@ def run_agent(
413
413
  metadata: Arbitrary JSON metadata to persist with the run (e.g., callback_url).
414
414
  """
415
415
 
416
- agent_id = agent_id.strip()
417
- if not agent_id:
418
- raise ValueError("agent_id is required")
416
+ automation_id = automation_id.strip()
417
+ if not automation_id:
418
+ raise ValueError("automation_id is required")
419
419
 
420
420
  run_id: str | None = None
421
421
 
422
- prepared_inputs = _prepare_agent_inputs(inputs) or {}
422
+ prepared_inputs = _prepare_automation_inputs(inputs) or {}
423
423
 
424
424
  file_map = files or {}
425
- run_id, upload_descriptors = _upload_agent_files(run_id, file_map, api_request=_api_request)
425
+ run_id, upload_descriptors = _upload_automation_files(
426
+ run_id, file_map, api_request=_api_request
427
+ )
426
428
 
427
429
  final_inputs = json.loads(json.dumps(prepared_inputs)) if prepared_inputs else {}
428
430
  for key, descriptors in upload_descriptors.items():
@@ -433,7 +435,7 @@ def run_agent(
433
435
 
434
436
  cleaned_status = status.strip() if isinstance(status, str) else ""
435
437
  payload: dict[str, Any] = {
436
- "agent_id": agent_id,
438
+ "automation_id": automation_id,
437
439
  "inputs": json.dumps(final_inputs),
438
440
  "status": cleaned_status or "queued",
439
441
  }
@@ -447,24 +449,24 @@ def run_agent(
447
449
  payload["metadata"] = _ensure_mapping(metadata, name="metadata")
448
450
  payload["lm_provenance"] = _ensure_mapping(
449
451
  provenance, name="provenance"
450
- ) or _default_provenance(agent_id, run_id)
452
+ ) or _default_provenance(automation_id, run_id)
451
453
 
452
- run = _api_request("POST", "agent-runs", json_body=payload)
454
+ run = _api_request("POST", "automation-runs", json_body=payload)
453
455
  if not isinstance(run, dict):
454
456
  raise RuntimeError("unexpected response payload")
455
457
  return run
456
458
 
457
459
 
458
- def get_agent_run(
459
- agent_id: str | None = None,
460
+ def get_automation_run(
461
+ automation_id: str | None = None,
460
462
  *,
461
463
  run_id: str | None = None,
462
464
  external_id: str | None = None,
463
465
  ) -> dict[str, Any]:
464
- """Fetch an agent run by id or by agent_id + external_id idempotency key.
466
+ """Fetch an agent run by id or by automation_id + external_id idempotency key.
465
467
 
466
468
  Args:
467
- agent_id: Agent id for external_id lookup. Required when ``run_id`` is not provided.
469
+ automation_id: Agent id for external_id lookup. Required when ``run_id`` is not provided.
468
470
  run_id: Optional run id. When provided, this takes precedence over external_id lookup.
469
471
  external_id: Optional idempotency key to look up the latest run for the agent.
470
472
 
@@ -474,29 +476,33 @@ def get_agent_run(
474
476
  """
475
477
 
476
478
  if run_id:
477
- return _api_request("GET", f"agent-runs/{run_id}")
479
+ return _api_request("GET", f"automation-runs/{run_id}")
478
480
 
479
- agent_id = agent_id.strip() if isinstance(agent_id, str) else ""
481
+ automation_id = automation_id.strip() if isinstance(automation_id, str) else ""
480
482
  external_id = external_id.strip() if isinstance(external_id, str) else ""
481
- if not agent_id:
482
- raise ValueError("agent_id is required when run_id is not provided")
483
+ if not automation_id:
484
+ raise ValueError("automation_id is required when run_id is not provided")
483
485
  if not external_id:
484
486
  raise ValueError("external_id is required when run_id is not provided")
485
487
 
486
488
  resp = _api_request(
487
489
  "GET",
488
- "agent-runs",
489
- params={"agent_id": agent_id, "external_id": external_id, "limit": 1},
490
+ "automation-runs",
491
+ params={
492
+ "automation_id": automation_id,
493
+ "external_id": external_id,
494
+ "limit": 1,
495
+ },
490
496
  )
491
- runs = resp.get("agent_runs") if isinstance(resp, dict) else None
497
+ runs = resp.get("data") if isinstance(resp, dict) else None # Backend returns "data" key
492
498
  if runs and isinstance(runs, list) and runs and isinstance(runs[0], dict):
493
499
  return runs[0]
494
500
 
495
- url = _api_url("agent-runs")
496
- raise _LumeraAPIError(404, "agent run not found", url=url, payload=None)
501
+ url = _api_url("automation-runs")
502
+ raise _LumeraAPIError(404, "automation run not found", url=url, payload=None)
497
503
 
498
504
 
499
- def update_agent_run(
505
+ def update_automation_run(
500
506
  run_id: str,
501
507
  *,
502
508
  result: Mapping[str, Any] | None = None,
@@ -533,7 +539,7 @@ def update_agent_run(
533
539
  if not payload:
534
540
  raise ValueError("at least one field to update is required")
535
541
 
536
- response = _api_request("PATCH", f"agent-runs/{run_id}", json_body=payload)
542
+ response = _api_request("PATCH", f"automation-runs/{run_id}", json_body=payload)
537
543
  if not isinstance(response, dict):
538
544
  raise RuntimeError("unexpected response payload")
539
545
  return response
@@ -627,9 +633,7 @@ def bulk_update_records(
627
633
  raise ValueError("data is required")
628
634
 
629
635
  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
- )
636
+ result = _api_request("POST", path, json_body={"ids": list(record_ids), "data": dict(data)})
633
637
  return result if isinstance(result, dict) else {}
634
638
 
635
639
 
@@ -652,9 +656,7 @@ def bulk_upsert_records(
652
656
  raise ValueError("records is required")
653
657
 
654
658
  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
- )
659
+ result = _api_request("POST", path, json_body={"records": [dict(r) for r in records]})
658
660
  return result if isinstance(result, dict) else {}
659
661
 
660
662
 
@@ -677,9 +679,7 @@ def bulk_insert_records(
677
679
  raise ValueError("records is required")
678
680
 
679
681
  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
- )
682
+ result = _api_request("POST", path, json_body={"records": [dict(r) for r in records]})
683
683
  return result if isinstance(result, dict) else {}
684
684
 
685
685
 
@@ -912,7 +912,7 @@ def save_to_lumera(file_path: str) -> dict:
912
912
 
913
913
  run_id = os.getenv("LUMERA_RUN_ID", "").strip()
914
914
  if run_id:
915
- return _upload_agent_run_file(file_path, run_id)
915
+ return _upload_automation_run_file(file_path, run_id)
916
916
 
917
917
  session_id = os.getenv("LUMERA_SESSION_ID", "").strip()
918
918
  if session_id:
lumera/storage.py CHANGED
@@ -116,7 +116,7 @@ def upload(
116
116
 
117
117
  # Request presigned upload URL
118
118
  resp = requests.post(
119
- f"{API_BASE}/agent-runs/{run_id}/files/upload-url",
119
+ f"{API_BASE}/automation-runs/{run_id}/files/upload-url",
120
120
  json={"filename": filename, "content_type": content_type, "size": size},
121
121
  headers=headers,
122
122
  timeout=30,
@@ -215,7 +215,7 @@ def download_url(path: str) -> str:
215
215
  headers = {"Authorization": f"token {token}"}
216
216
 
217
217
  resp = requests.get(
218
- f"{API_BASE}/agent-runs/{run_id}/files/download-url",
218
+ f"{API_BASE}/automation-runs/{run_id}/files/download-url",
219
219
  params={"name": filename},
220
220
  headers=headers,
221
221
  timeout=30,
@@ -257,7 +257,7 @@ def list_files(prefix: str | None = None) -> list[dict[str, Any]]:
257
257
  token = get_lumera_token()
258
258
  headers = {"Authorization": f"token {token}"}
259
259
 
260
- resp = requests.get(f"{API_BASE}/agent-runs/{run_id}/files", headers=headers, timeout=30)
260
+ resp = requests.get(f"{API_BASE}/automation-runs/{run_id}/files", headers=headers, timeout=30)
261
261
  resp.raise_for_status()
262
262
 
263
263
  data = resp.json()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lumera
3
- Version: 0.6.0
3
+ Version: 0.7.3
4
4
  Summary: SDK for building on Lumera platform
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: requests
@@ -0,0 +1,14 @@
1
+ lumera/__init__.py,sha256=NuZgCTtxQNdj1HKltc76XIcx8kUuE4PKY7mUy5XCXiU,2296
2
+ lumera/_utils.py,sha256=Ur4ZEf-khH3zFzX-aY4Y5uDqgYSWz9kJEkoR9WKy5yc,23727
3
+ lumera/automations.py,sha256=ZHN4m1pIO7py_F3DkUTTDTsr3dbJy7Mly_-gXt3h31o,24780
4
+ lumera/exceptions.py,sha256=bNsx4iYaroAAGsYxErfELC2B5ZJ3w5lVa1kKdIx5s9g,2173
5
+ lumera/google.py,sha256=3IVNL1HaOtsTmunl0alnGFuUAkzQQRyCEA3CKjlPqO0,10183
6
+ lumera/llm.py,sha256=pUTZK7t3GTK0vfxMI1PJgJwNendyuiJc5MB1pUj2vxE,14412
7
+ lumera/locks.py,sha256=8l_qxb8nrxge7YJ-ApUTJ5MeYpIdxDeEa94Eim9O-YM,6806
8
+ lumera/pb.py,sha256=EZABRPVbrSJ8_9Yh-BLt9cp4omkwo5r10Qs8v7wc1qc,18236
9
+ lumera/sdk.py,sha256=GVu1Pe_r7qNPDJ5g7qeACAfW5w5TDOyK8LN3VvDwk0s,30446
10
+ lumera/storage.py,sha256=b0W6JNSGfmhJIcmK3vrATXAwxIr_bfrj-hPuQRVLTYU,8206
11
+ lumera-0.7.3.dist-info/METADATA,sha256=G0fM5Y0DkPMr6QsNOuUaUZPnpKiqpo25_hEMlB7T1yg,1611
12
+ lumera-0.7.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
13
+ lumera-0.7.3.dist-info/top_level.txt,sha256=HgfK4XQkpMTnM2E5iWM4kB711FnYqUY9dglzib3pWlE,7
14
+ lumera-0.7.3.dist-info/RECORD,,
@@ -1,13 +0,0 @@
1
- lumera/__init__.py,sha256=AtbfmFoPuLEIcjBS4L-XWL-ARpjUIfR370-A7PAfsC8,2188
2
- lumera/_utils.py,sha256=QyAaphxXGEK8XNPO0ghKLgTOYhAxcF_j3W0T8StzjxA,23610
3
- lumera/exceptions.py,sha256=bNsx4iYaroAAGsYxErfELC2B5ZJ3w5lVa1kKdIx5s9g,2173
4
- lumera/google.py,sha256=3IVNL1HaOtsTmunl0alnGFuUAkzQQRyCEA3CKjlPqO0,10183
5
- lumera/llm.py,sha256=pUTZK7t3GTK0vfxMI1PJgJwNendyuiJc5MB1pUj2vxE,14412
6
- lumera/locks.py,sha256=8l_qxb8nrxge7YJ-ApUTJ5MeYpIdxDeEa94Eim9O-YM,6806
7
- lumera/pb.py,sha256=oJf37awGIoJ25VA1eSA3lMUXorOtNynVC7yG30X4-lU,18236
8
- lumera/sdk.py,sha256=XnzVtAGonhhQ2pDMtNKEOG5ZVH1sPLK-gsyTfCfBLSA,30233
9
- lumera/storage.py,sha256=kQQJYVRnxcpDZQ_gB2iZy6anb3hyZN7b8a_oNuE2yYE,8191
10
- lumera-0.6.0.dist-info/METADATA,sha256=4rhyTGFp6Iw1Zv-ZOtDcyThUvMSljTTtyLp-EKnEyvE,1611
11
- lumera-0.6.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
- lumera-0.6.0.dist-info/top_level.txt,sha256=HgfK4XQkpMTnM2E5iWM4kB711FnYqUY9dglzib3pWlE,7
13
- lumera-0.6.0.dist-info/RECORD,,
File without changes