lumera 0.9.2__tar.gz → 0.9.3__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.9.2
3
+ Version: 0.9.3
4
4
  Summary: SDK for building on Lumera platform
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: requests
@@ -57,8 +57,9 @@ __all__ = [
57
57
  "create",
58
58
  "update",
59
59
  "upsert",
60
- # Log streaming
60
+ # Log streaming and download
61
61
  "stream_logs",
62
+ "get_log_download_url",
62
63
  # Classes
63
64
  "Run",
64
65
  "Automation",
@@ -218,6 +219,33 @@ class Run:
218
219
  self._data = result
219
220
  return self
220
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
+
221
249
  def to_dict(self) -> dict[str, Any]:
222
250
  """Return the underlying data dict."""
223
251
  return self._data.copy()
@@ -833,3 +861,44 @@ def stream_logs(run_id: str, *, timeout: float = 30) -> Iterator[str]:
833
861
  return
834
862
  current_event = ""
835
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")
@@ -48,6 +48,7 @@ Example:
48
48
  >>> deposit = pb.get("deposits", "rec_abc123")
49
49
  """
50
50
 
51
+ import warnings
51
52
  from typing import Any, Iterator, Mapping, Sequence
52
53
 
53
54
  __all__ = [
@@ -88,9 +89,6 @@ from .sdk import (
88
89
  from .sdk import (
89
90
  bulk_upsert_records as _bulk_upsert_records,
90
91
  )
91
- from .sdk import (
92
- create_collection as _create_collection,
93
- )
94
92
  from .sdk import (
95
93
  create_record as _create_record,
96
94
  )
@@ -100,6 +98,9 @@ from .sdk import (
100
98
  from .sdk import (
101
99
  delete_record as _delete_record,
102
100
  )
101
+ from .sdk import (
102
+ ensure_collection as _ensure_collection,
103
+ )
103
104
  from .sdk import (
104
105
  get_collection as _get_collection,
105
106
  )
@@ -115,9 +116,6 @@ from .sdk import (
115
116
  from .sdk import (
116
117
  list_records as _list_records,
117
118
  )
118
- from .sdk import (
119
- update_collection as _update_collection,
120
- )
121
119
  from .sdk import (
122
120
  update_record as _update_record,
123
121
  )
@@ -561,62 +559,61 @@ def get_collection(name: str) -> dict[str, Any] | None:
561
559
  raise
562
560
 
563
561
 
564
- def create_collection(
562
+ def ensure_collection(
565
563
  name: str,
566
- schema: Sequence[dict[str, Any]],
564
+ schema: Sequence[dict[str, Any]] | None = None,
567
565
  *,
568
566
  indexes: Sequence[str] | None = None,
569
567
  ) -> dict[str, Any]:
570
- """Create a new collection.
568
+ """Ensure a collection exists with the given schema (idempotent).
571
569
 
572
- Args:
573
- name: Collection name (must not start with '_')
574
- schema: List of field definitions. Each field is a dict with:
575
- - name: Field name (required)
576
- - type: Field type (required): text, number, bool, date, json,
577
- relation, select, editor, lumera_file
578
- - required: Whether field is required (default False)
579
- - options: Type-specific options (e.g., collectionId for relations)
580
- indexes: Optional list of index definitions
570
+ This is the recommended way to manage collections. It creates the collection
571
+ if it doesn't exist, or updates it if it does. Safe to call multiple times.
581
572
 
582
- Returns:
583
- Created collection object
573
+ IMPORTANT: The schema and indexes are declarative:
574
+ - schema: The COMPLETE list of user fields you want (replaces all existing user fields)
575
+ - indexes: The COMPLETE list of user indexes you want (replaces all existing user indexes)
576
+ - System fields (id, created, updated, etc.) are automatically managed
577
+ - System indexes (external_id, updated) are automatically managed
584
578
 
585
- Example:
586
- >>> col = pb.create_collection("deposits", [
587
- ... {"name": "amount", "type": "number", "required": True},
588
- ... {"name": "status", "type": "text"},
589
- ... {"name": "account_id", "type": "relation",
590
- ... "options": {"collectionId": "accounts"}}
591
- ... ])
592
- """
593
- return _create_collection(name, schema=schema, indexes=indexes)
594
-
595
-
596
- def update_collection(
597
- name: str,
598
- schema: Sequence[dict[str, Any]],
599
- *,
600
- indexes: Sequence[str] | None = None,
601
- ) -> dict[str, Any]:
602
- """Update a collection's schema.
579
+ If you omit schema or indexes, existing values are preserved.
603
580
 
604
581
  Args:
605
- name: Collection name
606
- schema: New schema (replaces existing user fields)
607
- indexes: Optional new indexes
582
+ name: Collection name (must not start with '_')
583
+ schema: List of field definitions. If provided, replaces all user fields.
584
+ Each field is a dict with:
585
+ - name: Field name (required)
586
+ - type: Field type (required): text, number, bool, date, json,
587
+ relation, select, editor, lumera_file
588
+ - required: Whether field is required (default False)
589
+ - options: Type-specific options (e.g., collectionId for relations)
590
+ indexes: Optional list of user index DDL statements. If provided,
591
+ replaces all user indexes.
608
592
 
609
593
  Returns:
610
- Updated collection object
594
+ Collection object with:
595
+ - schema: User-defined fields only (what you can modify)
596
+ - indexes: User-defined indexes only (what you can modify)
597
+ - systemInfo: Read-only system fields and indexes (automatically managed)
611
598
 
612
599
  Example:
613
- >>> col = pb.update_collection("deposits", [
600
+ >>> # Create or update a collection
601
+ >>> col = pb.ensure_collection("deposits", [
614
602
  ... {"name": "amount", "type": "number", "required": True},
615
603
  ... {"name": "status", "type": "text"},
616
- ... {"name": "notes", "type": "text"} # New field
604
+ ... ])
605
+ >>>
606
+ >>> # Add a field using copy-modify-send pattern
607
+ >>> col = pb.get_collection("deposits")
608
+ >>> col["schema"].append({"name": "notes", "type": "text"})
609
+ >>> pb.ensure_collection("deposits", col["schema"])
610
+ >>>
611
+ >>> # Add an index
612
+ >>> pb.ensure_collection("deposits", indexes=[
613
+ ... "CREATE INDEX idx_status ON deposits (status)"
617
614
  ... ])
618
615
  """
619
- return _update_collection(name, schema=schema, indexes=indexes)
616
+ return _ensure_collection(name, schema=schema, indexes=indexes)
620
617
 
621
618
 
622
619
  def delete_collection(name: str) -> None:
@@ -634,56 +631,40 @@ def delete_collection(name: str) -> None:
634
631
  _delete_collection(name)
635
632
 
636
633
 
637
- def ensure_collection(
634
+ # Backwards compatibility aliases
635
+ def create_collection(
638
636
  name: str,
639
637
  schema: Sequence[dict[str, Any]],
640
638
  *,
641
- update_schema: bool = False,
642
639
  indexes: Sequence[str] | None = None,
643
640
  ) -> dict[str, Any]:
644
- """Ensure a collection exists with the given schema (idempotent).
645
-
646
- This is the recommended way to set up collections in automation scripts.
647
- Safe to call multiple times - will not fail if collection already exists.
648
-
649
- Args:
650
- name: Collection name
651
- schema: List of field definitions
652
- update_schema: If True, update existing collection's schema.
653
- If False (default), leave existing collection unchanged.
654
- indexes: Optional list of index definitions
655
-
656
- Returns:
657
- Collection object (created, updated, or existing)
658
-
659
- Behavior:
660
- - Collection doesn't exist → create it
661
- - Collection exists, update_schema=False → return existing (no-op)
662
- - Collection exists, update_schema=True → update schema
641
+ """Create a new collection.
663
642
 
664
- Example:
665
- >>> # Safe to run multiple times
666
- >>> col = pb.ensure_collection("deposits", [
667
- ... {"name": "amount", "type": "number", "required": True},
668
- ... {"name": "status", "type": "text"},
669
- ... ])
670
- >>>
671
- >>> # Update schema if collection exists
672
- >>> col = pb.ensure_collection("deposits", [
673
- ... {"name": "amount", "type": "number", "required": True},
674
- ... {"name": "status", "type": "text"},
675
- ... {"name": "notes", "type": "text"}, # Add new field
676
- ... ], update_schema=True)
643
+ .. deprecated::
644
+ Use :func:`ensure_collection` instead, which handles both create and update.
677
645
  """
678
- existing = get_collection(name)
646
+ warnings.warn(
647
+ "create_collection() is deprecated, use ensure_collection() instead",
648
+ DeprecationWarning,
649
+ stacklevel=2,
650
+ )
651
+ return ensure_collection(name, schema, indexes=indexes)
679
652
 
680
- if existing is None:
681
- # Collection doesn't exist, create it
682
- return create_collection(name, schema, indexes=indexes)
683
653
 
684
- if update_schema:
685
- # Update existing collection's schema
686
- return update_collection(name, schema, indexes=indexes)
654
+ def update_collection(
655
+ name: str,
656
+ schema: Sequence[dict[str, Any]],
657
+ *,
658
+ indexes: Sequence[str] | None = None,
659
+ ) -> dict[str, Any]:
660
+ """Update a collection's schema.
687
661
 
688
- # Collection exists and update_schema=False, return as-is
689
- return existing
662
+ .. deprecated::
663
+ Use :func:`ensure_collection` instead, which handles both create and update.
664
+ """
665
+ warnings.warn(
666
+ "update_collection() is deprecated, use ensure_collection() instead",
667
+ DeprecationWarning,
668
+ stacklevel=2,
669
+ )
670
+ return ensure_collection(name, schema, indexes=indexes)
@@ -23,6 +23,7 @@ Direct usage is discouraged unless you need low-level control.
23
23
 
24
24
  import json
25
25
  import os
26
+ import warnings
26
27
  from typing import Any, Iterable, Mapping, MutableMapping, Sequence, TypedDict
27
28
 
28
29
  import requests as _requests
@@ -179,27 +180,99 @@ def get_collection(collection_id_or_name: str) -> dict[str, Any]:
179
180
  return _api_request("GET", f"collections/{collection_id_or_name}")
180
181
 
181
182
 
182
- def create_collection(
183
+ def ensure_collection(
183
184
  name: str,
184
185
  *,
185
186
  collection_type: str = "base",
186
- schema: Iterable[CollectionField] | None = None,
187
- indexes: Iterable[str] | None = None,
187
+ schema: Iterable[CollectionField] | object = _UNSET,
188
+ indexes: Iterable[str] | object = _UNSET,
188
189
  ) -> dict[str, Any]:
189
- """Create a new PocketBase collection and return the server payload."""
190
+ """Ensure a collection exists with the given schema and indexes.
191
+
192
+ This is an idempotent operation - it creates the collection if it doesn't exist,
193
+ or updates it if it does. Safe to call multiple times with the same arguments.
194
+
195
+ The `schema` field should contain ONLY user-defined fields. System fields
196
+ (id, created, updated, created_by, updated_by, external_id, lm_provenance)
197
+ are automatically managed by Lumera and should not be included.
190
198
 
199
+ The `indexes` field should contain ONLY user-defined indexes. System indexes
200
+ (external_id unique index, updated index) are automatically managed.
201
+
202
+ Args:
203
+ name: Collection name. Required.
204
+ collection_type: Collection type, defaults to "base".
205
+ schema: List of field definitions. If provided, replaces all user fields.
206
+ If omitted, existing fields are preserved.
207
+ indexes: List of index DDL statements. If provided, replaces all user indexes.
208
+ If omitted, existing indexes are preserved.
209
+
210
+ Returns:
211
+ The collection data including:
212
+ - schema: User-defined fields only
213
+ - indexes: User-defined indexes only
214
+ - systemInfo: Object with system-managed fields and indexes (read-only)
215
+
216
+ Example:
217
+ # Create or update a collection
218
+ coll = ensure_collection(
219
+ "customers",
220
+ schema=[
221
+ {"name": "title", "type": "text", "required": True},
222
+ {"name": "email", "type": "text"},
223
+ ],
224
+ indexes=["CREATE INDEX idx_email ON customers (email)"]
225
+ )
226
+
227
+ # Add a field (copy-modify-send pattern)
228
+ coll = get_collection("customers")
229
+ coll["schema"].append({"name": "phone", "type": "text"})
230
+ ensure_collection("customers", schema=coll["schema"])
231
+ """
191
232
  if not name or not name.strip():
192
233
  raise ValueError("name is required")
193
234
 
194
- payload: dict[str, Any] = {"name": name.strip()}
235
+ name = name.strip()
236
+ payload: dict[str, Any] = {}
237
+
195
238
  if collection_type:
196
239
  payload["type"] = collection_type
197
- if schema is not None:
240
+
241
+ if schema is not _UNSET:
242
+ if schema is None:
243
+ raise ValueError("schema cannot be None; provide an iterable of fields or omit")
198
244
  payload["schema"] = [dict(field) for field in schema]
199
- if indexes is not None:
200
- payload["indexes"] = list(indexes)
201
245
 
202
- return _api_request("POST", "collections", json_body=payload)
246
+ if indexes is not _UNSET:
247
+ payload["indexes"] = list(indexes) if indexes is not None else []
248
+
249
+ return _api_request("PUT", f"collections/{name}", json_body=payload)
250
+
251
+
252
+ # Backwards compatibility aliases
253
+ def create_collection(
254
+ name: str,
255
+ *,
256
+ collection_type: str = "base",
257
+ schema: Iterable[CollectionField] | None = None,
258
+ indexes: Iterable[str] | None = None,
259
+ ) -> dict[str, Any]:
260
+ """Create a new PocketBase collection.
261
+
262
+ .. deprecated::
263
+ Use :func:`ensure_collection` instead, which handles both create and update.
264
+ """
265
+ warnings.warn(
266
+ "create_collection() is deprecated, use ensure_collection() instead",
267
+ DeprecationWarning,
268
+ stacklevel=2,
269
+ )
270
+ return ensure_collection(
271
+ name,
272
+ collection_type=collection_type,
273
+ schema=schema if schema is not None else [],
274
+ indexes=indexes if indexes is not None else [],
275
+ )
203
276
 
204
277
 
205
278
  def update_collection(
@@ -210,33 +283,26 @@ def update_collection(
210
283
  schema: Iterable[CollectionField] | object = _UNSET,
211
284
  indexes: Iterable[str] | object = _UNSET,
212
285
  ) -> dict[str, Any]:
213
- """Update a PocketBase collection."""
214
-
215
- if not collection_id_or_name:
216
- raise ValueError("collection_id_or_name is required")
217
-
218
- payload: dict[str, Any] = {}
219
-
220
- if name is not _UNSET:
221
- if name is None or not str(name).strip():
222
- raise ValueError("name cannot be empty")
223
- payload["name"] = str(name).strip()
224
-
225
- if collection_type is not _UNSET:
226
- payload["type"] = collection_type
286
+ """Update a PocketBase collection.
227
287
 
228
- if schema is not _UNSET:
229
- if schema is None:
230
- raise ValueError("schema cannot be None; provide an iterable of fields")
231
- payload["schema"] = [dict(field) for field in schema]
232
-
233
- if indexes is not _UNSET:
234
- payload["indexes"] = list(indexes) if indexes is not None else []
235
-
236
- if not payload:
237
- raise ValueError("no fields provided to update")
288
+ .. deprecated::
289
+ Use :func:`ensure_collection` instead, which handles both create and update.
290
+ Note: The 'name' parameter for renaming is no longer supported.
291
+ """
292
+ warnings.warn(
293
+ "update_collection() is deprecated, use ensure_collection() instead",
294
+ DeprecationWarning,
295
+ stacklevel=2,
296
+ )
297
+ if name is not _UNSET and name != collection_id_or_name:
298
+ raise ValueError("Renaming collections via 'name' parameter is no longer supported")
238
299
 
239
- return _api_request("PATCH", f"collections/{collection_id_or_name}", json_body=payload)
300
+ return ensure_collection(
301
+ collection_id_or_name,
302
+ collection_type=collection_type if collection_type is not _UNSET else "base",
303
+ schema=schema,
304
+ indexes=indexes,
305
+ )
240
306
 
241
307
 
242
308
  def delete_collection(collection_id_or_name: str) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lumera
3
- Version: 0.9.2
3
+ Version: 0.9.3
4
4
  Summary: SDK for building on Lumera platform
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: requests
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lumera"
3
- version = "0.9.2"
3
+ version = "0.9.3"
4
4
  description = "SDK for building on Lumera platform"
5
5
  requires-python = ">=3.11"
6
6
  dependencies = [
@@ -154,14 +154,17 @@ def test_list_collections_uses_token_and_returns_payload(monkeypatch: pytest.Mon
154
154
  assert headers["Authorization"] == "token tok"
155
155
 
156
156
 
157
- def test_create_collection_posts_payload(monkeypatch: pytest.MonkeyPatch) -> None:
157
+ def test_ensure_collection_uses_put(monkeypatch: pytest.MonkeyPatch) -> None:
158
+ """Test that ensure_collection uses PUT method and includes schema in payload."""
158
159
  monkeypatch.setenv(sdk.TOKEN_ENV, "tok")
159
160
 
160
161
  captured: dict[str, object] = {}
161
162
 
162
- def fake_request(_method: str, _url: str, **kwargs: object) -> DummyResponse:
163
+ def fake_request(method: str, url: str, **kwargs: object) -> DummyResponse:
164
+ captured["method"] = method
165
+ captured["url"] = url
163
166
  captured["json"] = kwargs.get("json")
164
- return DummyResponse(status_code=201, json_data={"id": "new"})
167
+ return DummyResponse(status_code=200, json_data={"id": "new", "name": "example"})
165
168
 
166
169
  class MockSession:
167
170
  def request(self, method: str, url: str, **kwargs: object) -> DummyResponse:
@@ -172,18 +175,52 @@ def test_create_collection_posts_payload(monkeypatch: pytest.MonkeyPatch) -> Non
172
175
 
173
176
  monkeypatch.setattr(_utils, "_get_session", lambda: MockSession())
174
177
 
175
- resp = create_collection(
178
+ resp = sdk.ensure_collection(
176
179
  "example", schema=[{"name": "field", "type": "text"}], indexes=["CREATE INDEX"]
177
180
  )
178
181
 
179
182
  assert resp["id"] == "new"
183
+ assert captured["method"] == "PUT"
184
+ assert str(captured["url"]).endswith("/collections/example")
180
185
  payload = captured["json"]
181
186
  assert isinstance(payload, dict)
182
- assert payload["name"] == "example"
187
+ # Name is not in payload (it's in URL path)
188
+ assert "name" not in payload
183
189
  assert payload["schema"][0]["name"] == "field"
184
190
  assert payload["indexes"] == ["CREATE INDEX"]
185
191
 
186
192
 
193
+ def test_create_collection_uses_ensure(monkeypatch: pytest.MonkeyPatch) -> None:
194
+ """Test that create_collection (deprecated) calls ensure_collection."""
195
+ monkeypatch.setenv(sdk.TOKEN_ENV, "tok")
196
+
197
+ captured: dict[str, object] = {}
198
+
199
+ def fake_request(method: str, url: str, **kwargs: object) -> DummyResponse:
200
+ captured["method"] = method
201
+ captured["url"] = url
202
+ return DummyResponse(status_code=200, json_data={"id": "new"})
203
+
204
+ class MockSession:
205
+ def request(self, method: str, url: str, **kwargs: object) -> DummyResponse:
206
+ return fake_request(method, url, **kwargs)
207
+
208
+ def mount(self, prefix: str, adapter: object) -> None:
209
+ pass
210
+
211
+ monkeypatch.setattr(_utils, "_get_session", lambda: MockSession())
212
+
213
+ # Should emit deprecation warning
214
+ with pytest.warns(DeprecationWarning, match="create_collection.*deprecated"):
215
+ resp = create_collection(
216
+ "example", schema=[{"name": "field", "type": "text"}], indexes=["CREATE INDEX"]
217
+ )
218
+
219
+ assert resp["id"] == "new"
220
+ # Should use PUT (via ensure_collection)
221
+ assert captured["method"] == "PUT"
222
+
223
+
187
224
  def test_create_record_sends_json_payload(monkeypatch: pytest.MonkeyPatch) -> None:
188
225
  monkeypatch.setenv(sdk.TOKEN_ENV, "tok")
189
226
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes