dominus-sdk-python-dev 2.1.8__py3-none-any.whl → 2.2.0__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.
dominus/namespaces/db.py CHANGED
@@ -45,6 +45,24 @@ class DbNamespace:
45
45
  def __init__(self, client: "Dominus"):
46
46
  self._client = client
47
47
 
48
+ async def schemas(self) -> List[str]:
49
+ """
50
+ List accessible schemas.
51
+
52
+ Returns schemas that the current user can access (public, tenant_*, etc.)
53
+ Excludes system schemas (auth, logs, meta, object, etc.)
54
+
55
+ Returns:
56
+ List of schema names
57
+ """
58
+ result = await self._client._request(
59
+ endpoint="/api/scribe/schema/list",
60
+ method="GET"
61
+ )
62
+ if isinstance(result, list):
63
+ return result
64
+ return result.get("schemas", [])
65
+
48
66
  async def tables(self, schema: str = "public") -> List[Dict[str, Any]]:
49
67
  """
50
68
  List tables in a schema.
@@ -123,11 +141,18 @@ class DbNamespace:
123
141
  if actor:
124
142
  body["actor"] = actor
125
143
 
126
- return await self._client._request(
144
+ # Backend returns {items, total, limit, offset} but we normalize to {rows, total}
145
+ result = await self._client._request(
127
146
  endpoint=f"/api/scribe/data/{schema}/{table}/query",
128
147
  body=body
129
148
  )
130
149
 
150
+ # Normalize response - map 'items' to 'rows' for consistent interface
151
+ return {
152
+ "rows": result.get("items", result.get("rows", [])),
153
+ "total": result.get("total", 0)
154
+ }
155
+
131
156
  async def insert(
132
157
  self,
133
158
  table: str,
dominus/namespaces/ddl.py CHANGED
@@ -588,3 +588,209 @@ class DdlNamespace:
588
588
  endpoint=f"/api/smith/provision/tenants-by-category/{category_slug}",
589
589
  method="GET"
590
590
  )
591
+
592
+ # ========================================
593
+ # SCHEMA BUILDER (UI SUPPORT)
594
+ # ========================================
595
+
596
+ async def get_category_structure(self, category_slug: str) -> Dict[str, Any]:
597
+ """
598
+ Get schema structure for a category (for Schema Builder UI).
599
+
600
+ Returns tables, columns, indexes, constraints for all tables.
601
+
602
+ Returns:
603
+ Dict with category, tenant_count, tables, current_version, migration_count
604
+ """
605
+ return await self._client._request(
606
+ endpoint=f"/api/smith/builder/category/{category_slug}/structure",
607
+ method="GET"
608
+ )
609
+
610
+ async def get_category_migration_history(self, category_slug: str) -> Dict[str, Any]:
611
+ """
612
+ Get migration history for a category.
613
+
614
+ Returns:
615
+ Dict with migrations, stored_versions, current_version, total
616
+ """
617
+ return await self._client._request(
618
+ endpoint=f"/api/smith/builder/category/{category_slug}/migrations",
619
+ method="GET"
620
+ )
621
+
622
+ async def preview_migration(
623
+ self,
624
+ category_slug: str,
625
+ operation: str,
626
+ params: Dict[str, Any],
627
+ migration_name: str
628
+ ) -> Dict[str, Any]:
629
+ """
630
+ Preview a migration before applying.
631
+
632
+ Args:
633
+ category_slug: Category identifier
634
+ operation: Operation type (create_table, add_column, etc.)
635
+ params: Operation parameters
636
+ migration_name: Human-readable migration name
637
+
638
+ Returns:
639
+ Dict with affected_tenants, migration_preview, sql_preview
640
+ """
641
+ return await self._client._request(
642
+ endpoint=f"/api/smith/builder/category/{category_slug}/preview-migration",
643
+ body={
644
+ "operation": operation,
645
+ "params": params,
646
+ "migration_name": migration_name
647
+ }
648
+ )
649
+
650
+ async def apply_builder_migration(
651
+ self,
652
+ category_slug: str,
653
+ operation: str,
654
+ params: Dict[str, Any],
655
+ migration_name: str
656
+ ) -> Dict[str, Any]:
657
+ """
658
+ Apply a migration from Schema Builder.
659
+
660
+ Args:
661
+ category_slug: Category identifier
662
+ operation: Operation type (create_table, add_column, etc.)
663
+ params: Operation parameters
664
+ migration_name: Human-readable migration name
665
+
666
+ Returns:
667
+ Dict with success, migration_id, b2_key, applied_schemas, tenant_count, versions_recorded
668
+ """
669
+ return await self._client._request(
670
+ endpoint=f"/api/smith/builder/category/{category_slug}/apply-migration",
671
+ body={
672
+ "operation": operation,
673
+ "params": params,
674
+ "migration_name": migration_name
675
+ },
676
+ timeout=120000 # 2 minutes for migration operations
677
+ )
678
+
679
+ async def get_builder_migration_file(
680
+ self,
681
+ category_slug: str,
682
+ version_id: str
683
+ ) -> Dict[str, Any]:
684
+ """
685
+ Get a specific migration file content from B2 storage.
686
+
687
+ Args:
688
+ category_slug: Category identifier
689
+ version_id: Migration version ID
690
+
691
+ Returns:
692
+ Dict with version_id, content, migration_name, b2_key
693
+ """
694
+ return await self._client._request(
695
+ endpoint=f"/api/smith/builder/category/{category_slug}/migrations/{version_id}",
696
+ method="GET"
697
+ )
698
+
699
+ async def get_tenant_sync_status(self, category_slug: str) -> Dict[str, Any]:
700
+ """
701
+ Get sync status for all tenants in a category.
702
+
703
+ Shows which tenants are on which migration version.
704
+
705
+ Returns:
706
+ Dict with category_slug, latest_version, total_tenants, synced_count,
707
+ pending_count, tenants
708
+ """
709
+ return await self._client._request(
710
+ endpoint=f"/api/smith/builder/category/{category_slug}/sync-status",
711
+ method="GET"
712
+ )
713
+
714
+ async def sync_tenants_to_latest(self, category_slug: str) -> Dict[str, Any]:
715
+ """
716
+ Sync all tenants in a category to the latest migration version.
717
+
718
+ Applies any pending migrations to tenants that are behind.
719
+
720
+ Returns:
721
+ Dict with success, message, synced_count, failed_count, results
722
+ """
723
+ return await self._client._request(
724
+ endpoint=f"/api/smith/builder/category/{category_slug}/sync-tenants",
725
+ method="POST",
726
+ timeout=120000 # 2 minutes for sync operations
727
+ )
728
+
729
+ # ========================================
730
+ # RESET / DELETE OPERATIONS
731
+ # ========================================
732
+
733
+ async def reset_category_schema(self, category_slug: str) -> Dict[str, Any]:
734
+ """
735
+ Factory reset a category's schema.
736
+
737
+ WARNING: This is a destructive operation that:
738
+ - Drops ALL tenant schemas in this category (CASCADE)
739
+ - Clears ALL migration history from meta.alembic_version
740
+ - Recreates empty schemas for each tenant
741
+ - Deletes ALL migration files from B2 storage
742
+
743
+ After reset, the category is back to a blank slate.
744
+
745
+ Returns:
746
+ Dict with success, message, reset_count, failed_count, migrations_deleted, results
747
+ """
748
+ return await self._client._request(
749
+ endpoint=f"/api/smith/builder/category/{category_slug}/reset",
750
+ method="DELETE",
751
+ timeout=120000 # 2 minutes for reset operations
752
+ )
753
+
754
+ async def reset_tenant_schema(
755
+ self,
756
+ category_slug: str,
757
+ tenant_slug: str
758
+ ) -> Dict[str, Any]:
759
+ """
760
+ Reset a single tenant's schema to blank.
761
+
762
+ WARNING: This is a destructive operation that:
763
+ - Clears the tenant's migration history from meta.alembic_version
764
+ - Drops the tenant's schema with CASCADE
765
+ - Recreates an empty schema
766
+
767
+ The tenant can then re-sync to apply current category migrations.
768
+
769
+ Returns:
770
+ Dict with success, message, tenant_slug, schema, versions_cleared
771
+ """
772
+ return await self._client._request(
773
+ endpoint=f"/api/smith/builder/category/{category_slug}/tenants/{tenant_slug}/reset",
774
+ method="DELETE",
775
+ timeout=60000 # 1 minute for single tenant reset
776
+ )
777
+
778
+ async def delete_migration(
779
+ self,
780
+ category_slug: str,
781
+ version_id: str
782
+ ) -> Dict[str, Any]:
783
+ """
784
+ Delete a single migration file from B2 storage.
785
+
786
+ WARNING: This does NOT clean up meta.alembic_version records.
787
+ Tenants that have already applied this migration keep their history.
788
+ Use with caution - typically only for cleaning up test migrations.
789
+
790
+ Returns:
791
+ Dict with success, message, version_id, category_slug
792
+ """
793
+ return await self._client._request(
794
+ endpoint=f"/api/smith/builder/category/{category_slug}/migrations/{version_id}",
795
+ method="DELETE"
796
+ )
@@ -0,0 +1,252 @@
1
+ """
2
+ FastAPI Namespace - Decorators for route authentication.
3
+
4
+ Provides decorator-style auth for FastAPI routes:
5
+
6
+ from dominus import dominus
7
+
8
+ @router.get("/protected")
9
+ @dominus.fastapi.jwt
10
+ async def route(claims: dict):
11
+ # claims contains JWT payload
12
+ ...
13
+
14
+ @router.get("/scoped")
15
+ @dominus.fastapi.scopes(["api:read", "data:read"])
16
+ async def route(claims: dict):
17
+ # claims validated to have at least one scope
18
+ ...
19
+
20
+ @router.get("/psk-protected")
21
+ @dominus.fastapi.psk
22
+ async def route(client: dict):
23
+ # client contains {id, name, tenant_id, scopes, roles, status}
24
+ ...
25
+
26
+ Parameter naming conventions:
27
+ - @jwt and @scopes: function must have a 'claims' parameter
28
+ - @psk: function must have a 'client' parameter
29
+ """
30
+ from functools import wraps
31
+ from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING
32
+ import inspect
33
+
34
+ if TYPE_CHECKING:
35
+ from ..start import Dominus
36
+
37
+
38
+ class FastAPINamespace:
39
+ """
40
+ FastAPI authentication decorators namespace.
41
+
42
+ Usage:
43
+ from dominus import dominus
44
+
45
+ @router.get("/protected")
46
+ @dominus.fastapi.jwt
47
+ async def my_route(claims: dict):
48
+ return {"user": claims.get("sub")}
49
+ """
50
+
51
+ def __init__(self, client: "Dominus"):
52
+ self._client = client
53
+
54
+ def jwt(self, func: Callable) -> Callable:
55
+ """
56
+ Decorator: Require valid JWT in Authorization header.
57
+
58
+ Injects JWT claims into the 'claims' parameter.
59
+
60
+ Usage:
61
+ @router.get("/protected")
62
+ @dominus.fastapi.jwt
63
+ async def route(claims: dict):
64
+ return {"user": claims.get("sub")}
65
+
66
+ Raises:
67
+ HTTPException 401: Missing/invalid Authorization header or token
68
+ """
69
+ return self._create_auth_decorator(func, auth_type="jwt")
70
+
71
+ def psk(self, func: Callable) -> Callable:
72
+ """
73
+ Decorator: Require valid PSK in Authorization: Bearer header.
74
+
75
+ Injects client info into the 'client' parameter.
76
+
77
+ Usage:
78
+ @router.get("/protected")
79
+ @dominus.fastapi.psk
80
+ async def route(client: dict):
81
+ return {"client_name": client.get("name")}
82
+
83
+ Raises:
84
+ HTTPException 401: Missing/invalid Authorization header or PSK
85
+ """
86
+ return self._create_auth_decorator(func, auth_type="psk")
87
+
88
+ def scopes(self, allowed_scopes: List[str]) -> Callable:
89
+ """
90
+ Decorator factory: Require JWT with specific scopes.
91
+
92
+ Access granted if client has ANY of the allowed scopes.
93
+ Injects JWT claims into the 'claims' parameter.
94
+
95
+ Usage:
96
+ @router.get("/admin")
97
+ @dominus.fastapi.scopes(["admin:read", "admin:write"])
98
+ async def route(claims: dict):
99
+ return {"admin": True}
100
+
101
+ Args:
102
+ allowed_scopes: List of scope strings, any one grants access
103
+
104
+ Raises:
105
+ HTTPException 401: Missing/invalid Authorization header or token
106
+ HTTPException 403: Token valid but missing required scopes
107
+ """
108
+ def decorator(func: Callable) -> Callable:
109
+ return self._create_auth_decorator(func, auth_type="jwt", required_scopes=allowed_scopes)
110
+ return decorator
111
+
112
+ def _create_auth_decorator(
113
+ self,
114
+ func: Callable,
115
+ auth_type: str,
116
+ required_scopes: Optional[List[str]] = None
117
+ ) -> Callable:
118
+ """
119
+ Internal: Create an auth decorator for the given function.
120
+
121
+ Args:
122
+ func: The route function to wrap
123
+ auth_type: "jwt" or "psk"
124
+ required_scopes: Optional list of scopes to require (jwt only)
125
+ """
126
+ # Import here to avoid circular imports and make fastapi optional
127
+ from fastapi import Header, HTTPException
128
+
129
+ # Determine the parameter name to inject
130
+ param_name = "claims" if auth_type == "jwt" else "client"
131
+
132
+ # Validate the function has the required parameter
133
+ sig = inspect.signature(func)
134
+ if param_name not in sig.parameters:
135
+ raise ValueError(
136
+ f"@dominus.fastapi.{auth_type} requires a '{param_name}' parameter. "
137
+ f"Add '{param_name}: dict' to your function signature."
138
+ )
139
+
140
+ @wraps(func)
141
+ async def wrapper(*args, **kwargs):
142
+ # Get Authorization header from the request
143
+ # FastAPI injects this via the wrapper's signature
144
+ authorization = kwargs.pop("_dominus_authorization", None)
145
+
146
+ if not authorization:
147
+ raise HTTPException(
148
+ status_code=401,
149
+ detail="Missing Authorization header",
150
+ headers={"WWW-Authenticate": "Bearer"}
151
+ )
152
+
153
+ # Extract Bearer token
154
+ token = self._extract_bearer(authorization)
155
+ if not token:
156
+ raise HTTPException(
157
+ status_code=401,
158
+ detail="Invalid Authorization header format. Expected: Bearer <token>",
159
+ headers={"WWW-Authenticate": "Bearer"}
160
+ )
161
+
162
+ # Validate based on auth type
163
+ if auth_type == "jwt":
164
+ auth_data = await self._validate_jwt(token)
165
+ if not auth_data:
166
+ raise HTTPException(
167
+ status_code=401,
168
+ detail="Invalid or expired token",
169
+ headers={"WWW-Authenticate": "Bearer"}
170
+ )
171
+
172
+ # Check scopes if required
173
+ if required_scopes:
174
+ client_scopes = auth_data.get("scopes", [])
175
+ if not any(scope in client_scopes for scope in required_scopes):
176
+ raise HTTPException(
177
+ status_code=403,
178
+ detail=f"Missing required scope. Need one of: {required_scopes}"
179
+ )
180
+ else: # psk
181
+ auth_data = await self._validate_psk(token)
182
+ if not auth_data:
183
+ raise HTTPException(
184
+ status_code=401,
185
+ detail="Invalid API key",
186
+ headers={"WWW-Authenticate": "Bearer"}
187
+ )
188
+
189
+ # Inject the auth data into the function
190
+ kwargs[param_name] = auth_data
191
+
192
+ # Call the original function
193
+ return await func(*args, **kwargs)
194
+
195
+ # Modify the wrapper signature to include Authorization header
196
+ # This allows FastAPI to inject it automatically
197
+ old_sig = inspect.signature(func)
198
+ old_params = list(old_sig.parameters.values())
199
+
200
+ # Remove the claims/client param (we'll inject it manually)
201
+ new_params = [p for p in old_params if p.name != param_name]
202
+
203
+ # Add the Authorization header param
204
+ auth_param = inspect.Parameter(
205
+ "_dominus_authorization",
206
+ inspect.Parameter.KEYWORD_ONLY,
207
+ default=Header(None, alias="Authorization"),
208
+ annotation=Optional[str]
209
+ )
210
+ new_params.append(auth_param)
211
+
212
+ # Create new signature
213
+ wrapper.__signature__ = old_sig.replace(parameters=new_params)
214
+
215
+ return wrapper
216
+
217
+ def _extract_bearer(self, auth_header: str) -> Optional[str]:
218
+ """Extract token from Authorization: Bearer <token> header."""
219
+ if not auth_header or not auth_header.startswith("Bearer "):
220
+ return None
221
+ return auth_header[7:]
222
+
223
+ async def _validate_jwt(self, token: str) -> Optional[Dict[str, Any]]:
224
+ """Validate JWT and return claims."""
225
+ try:
226
+ return await self._client.validate_jwt(token)
227
+ except (ValueError, Exception):
228
+ return None
229
+
230
+ async def _validate_psk(self, psk: str) -> Optional[Dict[str, Any]]:
231
+ """
232
+ Validate PSK and return client info with scopes/roles.
233
+
234
+ Uses portal.login_client to exchange PSK for JWT, then decodes
235
+ the JWT to get the client's claims (scopes, roles, tenants, etc.).
236
+ """
237
+ try:
238
+ # Exchange PSK for JWT via portal.login_client
239
+ # This uses the SDK's project JWT + the subsidiary client's PSK
240
+ result = await self._client.portal.login_client(psk=psk)
241
+ if not result:
242
+ return None
243
+
244
+ jwt = result.get("access_token")
245
+ if not jwt:
246
+ return None
247
+
248
+ # Decode JWT to get claims (includes scopes, roles, tenants, etc.)
249
+ claims = await self._client.validate_jwt(jwt)
250
+ return claims
251
+ except Exception:
252
+ return None
@@ -297,3 +297,183 @@ class FilesNamespace:
297
297
  endpoint="/api/archivist/update",
298
298
  body=body
299
299
  )
300
+
301
+ # ========================================
302
+ # STORAGE BROWSER METHODS
303
+ # ========================================
304
+
305
+ async def browse(
306
+ self,
307
+ path: str = "/",
308
+ category: Optional[str] = None,
309
+ view: Optional[str] = None,
310
+ limit: int = 100,
311
+ cursor: Optional[str] = None
312
+ ) -> Dict[str, Any]:
313
+ """
314
+ Browse files and folders at a path.
315
+
316
+ Args:
317
+ path: Directory path to browse (default: "/")
318
+ category: Filter by category
319
+ view: View name (e.g., "all", "compliance")
320
+ limit: Maximum items to return (default: 100)
321
+ cursor: Pagination cursor
322
+
323
+ Returns:
324
+ Dict with "path", "folders", "files", and optional "cursor"
325
+ """
326
+ body = {
327
+ "path": path,
328
+ "limit": limit
329
+ }
330
+
331
+ if category:
332
+ body["category"] = category
333
+ if view:
334
+ body["view"] = view
335
+ if cursor:
336
+ body["cursor"] = cursor
337
+
338
+ return await self._client._request(
339
+ endpoint="/api/archivist/browse",
340
+ body=body
341
+ )
342
+
343
+ async def get_storage_stats(self) -> Dict[str, Any]:
344
+ """
345
+ Get storage statistics.
346
+
347
+ Returns:
348
+ Dict with "total_objects", "total_size_bytes", "by_category"
349
+ """
350
+ return await self._client._request(
351
+ endpoint="/api/archivist/stats",
352
+ method="GET"
353
+ )
354
+
355
+ async def list_categories(self) -> Dict[str, Any]:
356
+ """
357
+ List all file categories.
358
+
359
+ Returns:
360
+ Dict with "categories" list containing name, object_count, total_size_bytes
361
+ """
362
+ return await self._client._request(
363
+ endpoint="/api/archivist/categories",
364
+ method="GET"
365
+ )
366
+
367
+ async def list_views(self, include_reserved: bool = False) -> Dict[str, Any]:
368
+ """
369
+ List available views.
370
+
371
+ Args:
372
+ include_reserved: Include reserved system views (default: False)
373
+
374
+ Returns:
375
+ Dict with "views" list, "total_views", "is_admin"
376
+ """
377
+ body = {"include_reserved": include_reserved}
378
+
379
+ return await self._client._request(
380
+ endpoint="/api/archivist/views",
381
+ body=body
382
+ )
383
+
384
+ async def list_categories_in_view(self, view: str) -> Dict[str, Any]:
385
+ """
386
+ List categories within a specific view.
387
+
388
+ Args:
389
+ view: View name
390
+
391
+ Returns:
392
+ Dict with "categories" list
393
+ """
394
+ return await self._client._request(
395
+ endpoint=f"/api/archivist/views/{view}/categories",
396
+ method="GET"
397
+ )
398
+
399
+ async def create_folder(
400
+ self,
401
+ path: str,
402
+ category: Optional[str] = None
403
+ ) -> Dict[str, Any]:
404
+ """
405
+ Create a folder.
406
+
407
+ Args:
408
+ path: Folder path to create
409
+ category: Category for the folder (optional)
410
+
411
+ Returns:
412
+ Dict with "created", "path", optional "placeholder_id", "message"
413
+ """
414
+ body = {"path": path}
415
+
416
+ if category:
417
+ body["category"] = category
418
+
419
+ return await self._client._request(
420
+ endpoint="/api/archivist/folder/create",
421
+ body=body
422
+ )
423
+
424
+ async def delete_folder(
425
+ self,
426
+ path: str,
427
+ category: Optional[str] = None,
428
+ force: bool = False
429
+ ) -> Dict[str, Any]:
430
+ """
431
+ Delete a folder.
432
+
433
+ Args:
434
+ path: Folder path to delete
435
+ category: Category of the folder (optional)
436
+ force: Force delete non-empty folders (default: False)
437
+
438
+ Returns:
439
+ Dict with "deleted", "path", "deleted_count", etc.
440
+ """
441
+ body = {
442
+ "path": path,
443
+ "force": force
444
+ }
445
+
446
+ if category:
447
+ body["category"] = category
448
+
449
+ return await self._client._request(
450
+ endpoint="/api/archivist/folder/delete",
451
+ body=body
452
+ )
453
+
454
+ async def factory_reset(
455
+ self,
456
+ confirm: bool = False,
457
+ preserve_compliance: bool = True
458
+ ) -> Dict[str, Any]:
459
+ """
460
+ Factory reset storage (delete all non-compliance files).
461
+
462
+ WARNING: This is a destructive operation.
463
+
464
+ Args:
465
+ confirm: Must be True to proceed (safety check)
466
+ preserve_compliance: Keep compliance files (default: True)
467
+
468
+ Returns:
469
+ Dict with "reset", "deleted_count", "retained_count", "by_category"
470
+ """
471
+ body = {
472
+ "confirm": confirm,
473
+ "preserve_compliance": preserve_compliance
474
+ }
475
+
476
+ return await self._client._request(
477
+ endpoint="/api/archivist/factory-reset",
478
+ body=body
479
+ )