lumera 0.4.6__py3-none-any.whl → 0.9.6__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/pb.py ADDED
@@ -0,0 +1,679 @@
1
+ """
2
+ Record and collection operations for Lumera.
3
+
4
+ This module provides a clean interface for working with Lumera collections,
5
+ using the `pb.` namespace convention familiar from automation contexts.
6
+
7
+ Record functions:
8
+ search() - Query records with filters, pagination, sorting
9
+ get() - Get single record by ID
10
+ get_by_external_id() - Get record by external_id field
11
+ create() - Create new record
12
+ update() - Update existing record
13
+ upsert() - Create or update by external_id
14
+ delete() - Delete record
15
+ iter_all() - Iterate all matching records (auto-pagination)
16
+
17
+ Collection functions:
18
+ list_collections() - List all collections
19
+ get_collection() - Get collection by name
20
+ ensure_collection() - Create or update collection (idempotent)
21
+ create_collection() - Create new collection
22
+ update_collection() - Update collection schema
23
+ delete_collection() - Delete collection
24
+
25
+ Filter Syntax:
26
+ Filters can be passed as dict or JSON string to search() and iter_all().
27
+
28
+ Simple equality:
29
+ {"status": "pending"}
30
+ {"external_id": "dep-001"}
31
+
32
+ Comparison operators (eq, gt, gte, lt, lte):
33
+ {"amount": {"gt": 1000}}
34
+ {"amount": {"gte": 100, "lte": 500}}
35
+
36
+ OR logic:
37
+ {"or": [{"status": "pending"}, {"status": "review"}]}
38
+
39
+ AND logic (implicit - multiple fields at same level):
40
+ {"status": "pending", "amount": {"gt": 1000}}
41
+
42
+ Combined:
43
+ {"status": "active", "or": [{"priority": "high"}, {"amount": {"gt": 5000}}]}
44
+
45
+ Example:
46
+ >>> from lumera import pb
47
+ >>> results = pb.search("deposits", filter={"status": "pending"})
48
+ >>> deposit = pb.get("deposits", "rec_abc123")
49
+ """
50
+
51
+ import warnings
52
+ from typing import Any, Iterator, Mapping, Sequence
53
+
54
+ __all__ = [
55
+ # Record operations
56
+ "search",
57
+ "get",
58
+ "get_by_external_id",
59
+ "create",
60
+ "update",
61
+ "upsert",
62
+ "delete",
63
+ "iter_all",
64
+ # Bulk operations
65
+ "bulk_delete",
66
+ "bulk_update",
67
+ "bulk_upsert",
68
+ "bulk_insert",
69
+ # Collection operations
70
+ "list_collections",
71
+ "get_collection",
72
+ "ensure_collection",
73
+ "create_collection",
74
+ "update_collection",
75
+ "delete_collection",
76
+ ]
77
+
78
+ # Import underlying SDK functions (prefixed with _ to indicate internal use)
79
+ from ._utils import LumeraAPIError
80
+ from .sdk import (
81
+ bulk_delete_records as _bulk_delete_records,
82
+ )
83
+ from .sdk import (
84
+ bulk_insert_records as _bulk_insert_records,
85
+ )
86
+ from .sdk import (
87
+ bulk_update_records as _bulk_update_records,
88
+ )
89
+ from .sdk import (
90
+ bulk_upsert_records as _bulk_upsert_records,
91
+ )
92
+ from .sdk import (
93
+ create_record as _create_record,
94
+ )
95
+ from .sdk import (
96
+ delete_collection as _delete_collection,
97
+ )
98
+ from .sdk import (
99
+ delete_record as _delete_record,
100
+ )
101
+ from .sdk import (
102
+ ensure_collection as _ensure_collection,
103
+ )
104
+ from .sdk import (
105
+ get_collection as _get_collection,
106
+ )
107
+ from .sdk import (
108
+ get_record as _get_record,
109
+ )
110
+ from .sdk import (
111
+ get_record_by_external_id as _get_record_by_external_id,
112
+ )
113
+ from .sdk import (
114
+ list_collections as _list_collections,
115
+ )
116
+ from .sdk import (
117
+ list_records as _list_records,
118
+ )
119
+ from .sdk import (
120
+ update_record as _update_record,
121
+ )
122
+ from .sdk import (
123
+ upsert_record as _upsert_record,
124
+ )
125
+
126
+
127
+ def search(
128
+ collection: str,
129
+ *,
130
+ filter: Mapping[str, Any] | str | None = None,
131
+ per_page: int = 50,
132
+ page: int = 1,
133
+ sort: str | None = None,
134
+ expand: str | None = None,
135
+ ) -> dict[str, Any]:
136
+ """Search records in a collection.
137
+
138
+ Args:
139
+ collection: Collection name or ID
140
+ filter: Filter as dict or JSON string. See module docstring for syntax.
141
+ Examples:
142
+ - {"status": "pending"} - equality
143
+ - {"amount": {"gt": 1000}} - comparison
144
+ - {"or": [{"status": "a"}, {"status": "b"}]} - OR logic
145
+ per_page: Results per page (max 500, default 50)
146
+ page: Page number, 1-indexed (default 1)
147
+ sort: Sort expression (e.g., "-created,name" for created DESC, name ASC)
148
+ expand: Comma-separated relation fields to expand (e.g., "user_id,company_id")
149
+
150
+ Returns:
151
+ Paginated results with structure:
152
+ {
153
+ "items": [...], # List of records
154
+ "page": 1,
155
+ "perPage": 50,
156
+ "totalItems": 100,
157
+ "totalPages": 2
158
+ }
159
+
160
+ Example:
161
+ >>> results = pb.search("deposits",
162
+ ... filter={"status": "pending", "amount": {"gt": 1000}},
163
+ ... per_page=100,
164
+ ... sort="-created"
165
+ ... )
166
+ >>> for deposit in results["items"]:
167
+ ... print(deposit["id"], deposit["amount"])
168
+ """
169
+ # Note: _list_records handles both dict and str filters via JSON encoding
170
+ return _list_records(
171
+ collection,
172
+ filter=filter,
173
+ per_page=per_page,
174
+ page=page,
175
+ sort=sort,
176
+ expand=expand,
177
+ )
178
+
179
+
180
+ def get(collection: str, record_id: str) -> dict[str, Any]:
181
+ """Get a single record by ID.
182
+
183
+ Args:
184
+ collection: Collection name or ID
185
+ record_id: Record ID (15-character alphanumeric ID)
186
+
187
+ Returns:
188
+ Record data with all fields
189
+
190
+ Raises:
191
+ LumeraAPIError: If record doesn't exist (404)
192
+
193
+ Example:
194
+ >>> deposit = pb.get("deposits", "dep_abc123")
195
+ >>> print(deposit["amount"])
196
+ """
197
+ return _get_record(collection, record_id)
198
+
199
+
200
+ def get_by_external_id(collection: str, external_id: str) -> dict[str, Any]:
201
+ """Get a single record by external_id (unique field).
202
+
203
+ Args:
204
+ collection: Collection name or ID
205
+ external_id: Value of the external_id field (your business identifier)
206
+
207
+ Returns:
208
+ Record data
209
+
210
+ Raises:
211
+ LumeraAPIError: If no record with that external_id (404)
212
+
213
+ Example:
214
+ >>> deposit = pb.get_by_external_id("deposits", "dep-2024-001")
215
+ """
216
+ return _get_record_by_external_id(collection, external_id)
217
+
218
+
219
+ def create(collection: str, data: dict[str, Any]) -> dict[str, Any]:
220
+ """Create a new record.
221
+
222
+ Args:
223
+ collection: Collection name or ID
224
+ data: Record data as dict mapping field names to values
225
+
226
+ Returns:
227
+ Created record with id, created, and updated timestamps
228
+
229
+ Raises:
230
+ LumeraAPIError: If validation fails or unique constraint violated
231
+
232
+ Example:
233
+ >>> deposit = pb.create("deposits", {
234
+ ... "external_id": "dep-001",
235
+ ... "amount": 1000,
236
+ ... "status": "pending"
237
+ ... })
238
+ >>> print(deposit["id"])
239
+ """
240
+ return _create_record(collection, data)
241
+
242
+
243
+ def update(collection: str, record_id: str, data: dict[str, Any]) -> dict[str, Any]:
244
+ """Update an existing record (partial update).
245
+
246
+ Args:
247
+ collection: Collection name or ID
248
+ record_id: Record ID to update
249
+ data: Fields to update (only include fields you want to change)
250
+
251
+ Returns:
252
+ Updated record
253
+
254
+ Raises:
255
+ LumeraAPIError: If record doesn't exist or validation fails
256
+
257
+ Example:
258
+ >>> deposit = pb.update("deposits", "dep_abc123", {
259
+ ... "status": "processed",
260
+ ... "processed_at": datetime.utcnow().isoformat()
261
+ ... })
262
+ """
263
+ return _update_record(collection, record_id, data)
264
+
265
+
266
+ def upsert(collection: str, data: dict[str, Any]) -> dict[str, Any]:
267
+ """Create or update a record by external_id.
268
+
269
+ If a record with the given external_id exists, updates it.
270
+ Otherwise, creates a new record. This is useful for idempotent imports.
271
+
272
+ Args:
273
+ collection: Collection name or ID
274
+ data: Record data (MUST include "external_id" field)
275
+
276
+ Returns:
277
+ Created or updated record
278
+
279
+ Raises:
280
+ ValueError: If data doesn't contain "external_id"
281
+ LumeraAPIError: If validation fails
282
+
283
+ Example:
284
+ >>> deposit = pb.upsert("deposits", {
285
+ ... "external_id": "dep-001",
286
+ ... "amount": 1000,
287
+ ... "status": "pending"
288
+ ... })
289
+ >>> # Second call updates the existing record
290
+ >>> deposit = pb.upsert("deposits", {
291
+ ... "external_id": "dep-001",
292
+ ... "amount": 2000
293
+ ... })
294
+ """
295
+ return _upsert_record(collection, data)
296
+
297
+
298
+ def delete(collection: str, record_id: str) -> None:
299
+ """Delete a record.
300
+
301
+ Args:
302
+ collection: Collection name or ID
303
+ record_id: Record ID to delete
304
+
305
+ Raises:
306
+ LumeraAPIError: If record doesn't exist
307
+
308
+ Example:
309
+ >>> pb.delete("deposits", "dep_abc123")
310
+ """
311
+ _delete_record(collection, record_id)
312
+
313
+
314
+ # =============================================================================
315
+ # Bulk Operations
316
+ # =============================================================================
317
+
318
+
319
+ def bulk_delete(
320
+ collection: str,
321
+ record_ids: Sequence[str],
322
+ *,
323
+ transaction: bool = False,
324
+ ) -> dict[str, Any]:
325
+ """Delete multiple records by ID.
326
+
327
+ Args:
328
+ collection: Collection name or ID
329
+ record_ids: List of record IDs to delete (max 1000)
330
+ transaction: If True, use all-or-nothing semantics (rollback on any failure).
331
+ If False (default), partial success is allowed.
332
+
333
+ Returns:
334
+ Result with succeeded/failed counts and any errors:
335
+ {
336
+ "succeeded": 10,
337
+ "failed": 0,
338
+ "errors": [],
339
+ "rolled_back": false # only present if transaction=True and failed
340
+ }
341
+
342
+ Example:
343
+ >>> result = pb.bulk_delete("deposits", ["id1", "id2", "id3"])
344
+ >>> print(f"Deleted {result['succeeded']} records")
345
+
346
+ >>> # Use transaction mode for all-or-nothing
347
+ >>> result = pb.bulk_delete("deposits", ids, transaction=True)
348
+ """
349
+ return _bulk_delete_records(collection, record_ids, transaction=transaction)
350
+
351
+
352
+ def bulk_update(
353
+ collection: str,
354
+ records: Sequence[dict[str, Any]],
355
+ *,
356
+ transaction: bool = False,
357
+ ) -> dict[str, Any]:
358
+ """Update multiple records with individual data per record.
359
+
360
+ Each record must have an 'id' field plus fields to update.
361
+ Records must exist (returns error if not found, does not create).
362
+
363
+ Args:
364
+ collection: Collection name or ID
365
+ records: List of records to update (max 1000). Each must have 'id' field.
366
+ transaction: If True, use all-or-nothing semantics (rollback on any failure).
367
+ If False (default), partial success is allowed.
368
+
369
+ Returns:
370
+ Result with succeeded/failed counts:
371
+ {
372
+ "succeeded": 2,
373
+ "failed": 0,
374
+ "errors": [],
375
+ "rolled_back": false # only present if transaction=True and failed
376
+ }
377
+
378
+ Example:
379
+ >>> result = pb.bulk_update("deposits", [
380
+ ... {"id": "rec1", "status": "approved", "amount": 100},
381
+ ... {"id": "rec2", "status": "rejected", "amount": 200},
382
+ ... ])
383
+ >>> print(f"Updated {result['succeeded']} records")
384
+
385
+ >>> # Use transaction mode for all-or-nothing
386
+ >>> result = pb.bulk_update("deposits", records, transaction=True)
387
+ """
388
+ return _bulk_update_records(collection, records, transaction=transaction)
389
+
390
+
391
+ def bulk_upsert(
392
+ collection: str,
393
+ records: Sequence[dict[str, Any]],
394
+ *,
395
+ transaction: bool = False,
396
+ ) -> dict[str, Any]:
397
+ """Create or update multiple records by ID.
398
+
399
+ Each record can include an "id" field. Records with matching IDs will be
400
+ updated; records without IDs or with non-existent IDs create new records.
401
+
402
+ Args:
403
+ collection: Collection name or ID
404
+ records: List of records (max 1000)
405
+ transaction: If True, use all-or-nothing semantics (rollback on any failure).
406
+ If False (default), partial success is allowed.
407
+
408
+ Returns:
409
+ Result with succeeded/failed counts and created record IDs:
410
+ {
411
+ "succeeded": 2,
412
+ "failed": 0,
413
+ "errors": [],
414
+ "records": [{"id": "..."}, ...],
415
+ "rolled_back": false # only present if transaction=True and failed
416
+ }
417
+
418
+ Example:
419
+ >>> result = pb.bulk_upsert("deposits", [
420
+ ... {"id": "existing_id", "amount": 100},
421
+ ... {"amount": 200}, # creates new record
422
+ ... ])
423
+
424
+ >>> # Use transaction mode for all-or-nothing
425
+ >>> result = pb.bulk_upsert("deposits", records, transaction=True)
426
+ """
427
+ return _bulk_upsert_records(collection, records, transaction=transaction)
428
+
429
+
430
+ def bulk_insert(
431
+ collection: str,
432
+ records: Sequence[dict[str, Any]],
433
+ *,
434
+ transaction: bool = False,
435
+ ) -> dict[str, Any]:
436
+ """Insert multiple new records.
437
+
438
+ Args:
439
+ collection: Collection name or ID
440
+ records: List of records to create (max 1000)
441
+ transaction: If True, use all-or-nothing semantics (rollback on any failure).
442
+ If False (default), partial success is allowed.
443
+
444
+ Returns:
445
+ Result with succeeded/failed counts and created record IDs:
446
+ {
447
+ "succeeded": 2,
448
+ "failed": 0,
449
+ "errors": [],
450
+ "records": [{"id": "..."}, {"id": "..."}],
451
+ "rolled_back": false # only present if transaction=True and failed
452
+ }
453
+
454
+ Example:
455
+ >>> result = pb.bulk_insert("deposits", [
456
+ ... {"external_id": "dep-001", "amount": 100},
457
+ ... {"external_id": "dep-002", "amount": 200},
458
+ ... ])
459
+ >>> for rec in result["records"]:
460
+ ... print(f"Created: {rec['id']}")
461
+
462
+ >>> # Use transaction mode for all-or-nothing
463
+ >>> result = pb.bulk_insert("deposits", records, transaction=True)
464
+ """
465
+ return _bulk_insert_records(collection, records, transaction=transaction)
466
+
467
+
468
+ def iter_all(
469
+ collection: str,
470
+ *,
471
+ filter: Mapping[str, Any] | str | None = None,
472
+ sort: str | None = None,
473
+ expand: str | None = None,
474
+ batch_size: int = 500,
475
+ ) -> Iterator[dict[str, Any]]:
476
+ """Iterate over all matching records, handling pagination automatically.
477
+
478
+ This is a convenience function for processing large result sets without
479
+ manually handling pagination. Use this instead of manual page loops.
480
+
481
+ Args:
482
+ collection: Collection name or ID
483
+ filter: Optional filter as dict or JSON string (e.g., {"status": "pending"})
484
+ sort: Optional sort expression (e.g., "-created" for newest first)
485
+ expand: Optional comma-separated relation fields to expand
486
+ batch_size: Records per API request (max 500, default 500)
487
+
488
+ Yields:
489
+ Individual records
490
+
491
+ Example:
492
+ >>> for deposit in pb.iter_all("deposits", filter={"status": "pending"}):
493
+ ... process(deposit)
494
+ """
495
+ page = 1
496
+ while True:
497
+ result = search(
498
+ collection,
499
+ filter=filter,
500
+ per_page=batch_size,
501
+ page=page,
502
+ sort=sort,
503
+ expand=expand,
504
+ )
505
+
506
+ items = result.get("items", [])
507
+ if not items:
508
+ break
509
+
510
+ yield from items
511
+
512
+ # Check if there are more pages
513
+ # Use `or 0` instead of default to handle None values
514
+ total_pages = result.get("totalPages") or 0
515
+ if page >= total_pages:
516
+ break
517
+
518
+ page += 1
519
+
520
+
521
+ # =============================================================================
522
+ # Collection Management Functions
523
+ # =============================================================================
524
+
525
+
526
+ def list_collections() -> list[dict[str, Any]]:
527
+ """List all collections.
528
+
529
+ Returns:
530
+ List of collection objects with id, name, schema, etc.
531
+
532
+ Example:
533
+ >>> collections = pb.list_collections()
534
+ >>> for col in collections:
535
+ ... print(col["name"])
536
+ """
537
+ result = _list_collections()
538
+ return result.get("items", [])
539
+
540
+
541
+ def get_collection(name: str) -> dict[str, Any] | None:
542
+ """Get a collection by name.
543
+
544
+ Args:
545
+ name: Collection name
546
+
547
+ Returns:
548
+ Collection object if found, None if not found
549
+
550
+ Example:
551
+ >>> col = pb.get_collection("deposits")
552
+ >>> if col:
553
+ ... print(col["schema"])
554
+ """
555
+ try:
556
+ return _get_collection(name)
557
+ except LumeraAPIError as e:
558
+ if e.status_code == 404:
559
+ return None
560
+ raise
561
+
562
+
563
+ def ensure_collection(
564
+ name: str,
565
+ schema: Sequence[dict[str, Any]] | None = None,
566
+ *,
567
+ id: str | None = None,
568
+ indexes: Sequence[str] | None = None,
569
+ ) -> dict[str, Any]:
570
+ """Ensure a collection exists with the given schema (idempotent).
571
+
572
+ This is the recommended way to manage collections. It creates the collection
573
+ if it doesn't exist, or updates it if it does. Safe to call multiple times.
574
+
575
+ IMPORTANT: The schema and indexes are declarative:
576
+ - schema: The COMPLETE list of user fields you want (replaces all existing user fields)
577
+ - indexes: The COMPLETE list of user indexes you want (replaces all existing user indexes)
578
+ - System fields (id, created, updated, etc.) are automatically managed
579
+ - System indexes (external_id, updated) are automatically managed
580
+
581
+ If you omit schema or indexes, existing values are preserved.
582
+
583
+ Args:
584
+ name: Collection name (can be renamed later in UI)
585
+ schema: List of field definitions. If provided, replaces all user fields.
586
+ Each field is a dict with:
587
+ - name: Field name (required)
588
+ - type: Field type (required): text, number, bool, date, json,
589
+ relation, select, editor, lumera_file
590
+ - required: Whether field is required (default False)
591
+ - options: Type-specific options (e.g., collectionId for relations)
592
+ id: Optional stable identifier for the collection. If provided on creation,
593
+ this ID will be used instead of an auto-generated one. The ID remains
594
+ stable even if the collection is renamed, making it ideal for use in
595
+ automations and hooks. Must be alphanumeric with underscores only.
596
+ Cannot be changed after creation.
597
+ indexes: Optional list of user index DDL statements. If provided,
598
+ replaces all user indexes.
599
+
600
+ Returns:
601
+ Collection object with:
602
+ - id: The collection's stable identifier
603
+ - name: The collection's display name
604
+ - schema: User-defined fields only (what you can modify)
605
+ - indexes: User-defined indexes only (what you can modify)
606
+ - systemInfo: Read-only system fields and indexes (automatically managed)
607
+
608
+ Example:
609
+ >>> # Create with stable ID for use in automations
610
+ >>> col = pb.ensure_collection(
611
+ ... "Customer Orders Q1",
612
+ ... schema=[
613
+ ... {"name": "amount", "type": "number", "required": True},
614
+ ... {"name": "status", "type": "text"},
615
+ ... ],
616
+ ... id="orders", # stable reference
617
+ ... )
618
+ >>>
619
+ >>> # Later, collection can be renamed but ID stays "orders"
620
+ >>> # Automations using pb.search("orders", ...) still work!
621
+ >>>
622
+ >>> # Create without custom ID (auto-generated)
623
+ >>> col = pb.ensure_collection("deposits", [...])
624
+ """
625
+ return _ensure_collection(name, schema=schema, id=id, indexes=indexes)
626
+
627
+
628
+ def delete_collection(name: str) -> None:
629
+ """Delete a collection.
630
+
631
+ Args:
632
+ name: Collection name to delete
633
+
634
+ Raises:
635
+ LumeraAPIError: If collection doesn't exist or is protected
636
+
637
+ Example:
638
+ >>> pb.delete_collection("old_deposits")
639
+ """
640
+ _delete_collection(name)
641
+
642
+
643
+ # Backwards compatibility aliases
644
+ def create_collection(
645
+ name: str,
646
+ schema: Sequence[dict[str, Any]],
647
+ *,
648
+ indexes: Sequence[str] | None = None,
649
+ ) -> dict[str, Any]:
650
+ """Create a new collection.
651
+
652
+ .. deprecated::
653
+ Use :func:`ensure_collection` instead, which handles both create and update.
654
+ """
655
+ warnings.warn(
656
+ "create_collection() is deprecated, use ensure_collection() instead",
657
+ DeprecationWarning,
658
+ stacklevel=2,
659
+ )
660
+ return ensure_collection(name, schema, indexes=indexes)
661
+
662
+
663
+ def update_collection(
664
+ name: str,
665
+ schema: Sequence[dict[str, Any]],
666
+ *,
667
+ indexes: Sequence[str] | None = None,
668
+ ) -> dict[str, Any]:
669
+ """Update a collection's schema.
670
+
671
+ .. deprecated::
672
+ Use :func:`ensure_collection` instead, which handles both create and update.
673
+ """
674
+ warnings.warn(
675
+ "update_collection() is deprecated, use ensure_collection() instead",
676
+ DeprecationWarning,
677
+ stacklevel=2,
678
+ )
679
+ return ensure_collection(name, schema, indexes=indexes)