dominus-sdk-python-dev 2.1.8__py3-none-any.whl → 2.3.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
@@ -308,6 +308,22 @@ class DdlNamespace:
308
308
  )
309
309
  return result if isinstance(result, list) else []
310
310
 
311
+ async def apply_migration(self, schema: str, migration_id: str) -> Dict[str, Any]:
312
+ """
313
+ Apply a specific migration to a schema.
314
+
315
+ Args:
316
+ schema: Schema name (e.g., "tenant_acme")
317
+ migration_id: Migration version ID to apply
318
+
319
+ Returns:
320
+ Dict with application result
321
+ """
322
+ return await self._client._request(
323
+ endpoint=f"/api/smith/migrations/apply/{schema}",
324
+ body={"migration_id": migration_id}
325
+ )
326
+
311
327
  async def get_current_version(self, schema: str) -> Dict[str, Any]:
312
328
  """Get current migration version for a schema."""
313
329
  return await self._client._request(
@@ -588,3 +604,209 @@ class DdlNamespace:
588
604
  endpoint=f"/api/smith/provision/tenants-by-category/{category_slug}",
589
605
  method="GET"
590
606
  )
607
+
608
+ # ========================================
609
+ # SCHEMA BUILDER (UI SUPPORT)
610
+ # ========================================
611
+
612
+ async def get_category_structure(self, category_slug: str) -> Dict[str, Any]:
613
+ """
614
+ Get schema structure for a category (for Schema Builder UI).
615
+
616
+ Returns tables, columns, indexes, constraints for all tables.
617
+
618
+ Returns:
619
+ Dict with category, tenant_count, tables, current_version, migration_count
620
+ """
621
+ return await self._client._request(
622
+ endpoint=f"/api/smith/builder/category/{category_slug}/structure",
623
+ method="GET"
624
+ )
625
+
626
+ async def get_category_migration_history(self, category_slug: str) -> Dict[str, Any]:
627
+ """
628
+ Get migration history for a category.
629
+
630
+ Returns:
631
+ Dict with migrations, stored_versions, current_version, total
632
+ """
633
+ return await self._client._request(
634
+ endpoint=f"/api/smith/builder/category/{category_slug}/migrations",
635
+ method="GET"
636
+ )
637
+
638
+ async def preview_migration(
639
+ self,
640
+ category_slug: str,
641
+ operation: str,
642
+ params: Dict[str, Any],
643
+ migration_name: str
644
+ ) -> Dict[str, Any]:
645
+ """
646
+ Preview a migration before applying.
647
+
648
+ Args:
649
+ category_slug: Category identifier
650
+ operation: Operation type (create_table, add_column, etc.)
651
+ params: Operation parameters
652
+ migration_name: Human-readable migration name
653
+
654
+ Returns:
655
+ Dict with affected_tenants, migration_preview, sql_preview
656
+ """
657
+ return await self._client._request(
658
+ endpoint=f"/api/smith/builder/category/{category_slug}/preview-migration",
659
+ body={
660
+ "operation": operation,
661
+ "params": params,
662
+ "migration_name": migration_name
663
+ }
664
+ )
665
+
666
+ async def apply_builder_migration(
667
+ self,
668
+ category_slug: str,
669
+ operation: str,
670
+ params: Dict[str, Any],
671
+ migration_name: str
672
+ ) -> Dict[str, Any]:
673
+ """
674
+ Apply a migration from Schema Builder.
675
+
676
+ Args:
677
+ category_slug: Category identifier
678
+ operation: Operation type (create_table, add_column, etc.)
679
+ params: Operation parameters
680
+ migration_name: Human-readable migration name
681
+
682
+ Returns:
683
+ Dict with success, migration_id, b2_key, applied_schemas, tenant_count, versions_recorded
684
+ """
685
+ return await self._client._request(
686
+ endpoint=f"/api/smith/builder/category/{category_slug}/apply-migration",
687
+ body={
688
+ "operation": operation,
689
+ "params": params,
690
+ "migration_name": migration_name
691
+ },
692
+ timeout=120000 # 2 minutes for migration operations
693
+ )
694
+
695
+ async def get_builder_migration_file(
696
+ self,
697
+ category_slug: str,
698
+ version_id: str
699
+ ) -> Dict[str, Any]:
700
+ """
701
+ Get a specific migration file content from B2 storage.
702
+
703
+ Args:
704
+ category_slug: Category identifier
705
+ version_id: Migration version ID
706
+
707
+ Returns:
708
+ Dict with version_id, content, migration_name, b2_key
709
+ """
710
+ return await self._client._request(
711
+ endpoint=f"/api/smith/builder/category/{category_slug}/migrations/{version_id}",
712
+ method="GET"
713
+ )
714
+
715
+ async def get_tenant_sync_status(self, category_slug: str) -> Dict[str, Any]:
716
+ """
717
+ Get sync status for all tenants in a category.
718
+
719
+ Shows which tenants are on which migration version.
720
+
721
+ Returns:
722
+ Dict with category_slug, latest_version, total_tenants, synced_count,
723
+ pending_count, tenants
724
+ """
725
+ return await self._client._request(
726
+ endpoint=f"/api/smith/builder/category/{category_slug}/sync-status",
727
+ method="GET"
728
+ )
729
+
730
+ async def sync_tenants_to_latest(self, category_slug: str) -> Dict[str, Any]:
731
+ """
732
+ Sync all tenants in a category to the latest migration version.
733
+
734
+ Applies any pending migrations to tenants that are behind.
735
+
736
+ Returns:
737
+ Dict with success, message, synced_count, failed_count, results
738
+ """
739
+ return await self._client._request(
740
+ endpoint=f"/api/smith/builder/category/{category_slug}/sync-tenants",
741
+ method="POST",
742
+ timeout=120000 # 2 minutes for sync operations
743
+ )
744
+
745
+ # ========================================
746
+ # RESET / DELETE OPERATIONS
747
+ # ========================================
748
+
749
+ async def reset_category_schema(self, category_slug: str) -> Dict[str, Any]:
750
+ """
751
+ Factory reset a category's schema.
752
+
753
+ WARNING: This is a destructive operation that:
754
+ - Drops ALL tenant schemas in this category (CASCADE)
755
+ - Clears ALL migration history from meta.alembic_version
756
+ - Recreates empty schemas for each tenant
757
+ - Deletes ALL migration files from B2 storage
758
+
759
+ After reset, the category is back to a blank slate.
760
+
761
+ Returns:
762
+ Dict with success, message, reset_count, failed_count, migrations_deleted, results
763
+ """
764
+ return await self._client._request(
765
+ endpoint=f"/api/smith/builder/category/{category_slug}/reset",
766
+ method="DELETE",
767
+ timeout=120000 # 2 minutes for reset operations
768
+ )
769
+
770
+ async def reset_tenant_schema(
771
+ self,
772
+ category_slug: str,
773
+ tenant_slug: str
774
+ ) -> Dict[str, Any]:
775
+ """
776
+ Reset a single tenant's schema to blank.
777
+
778
+ WARNING: This is a destructive operation that:
779
+ - Clears the tenant's migration history from meta.alembic_version
780
+ - Drops the tenant's schema with CASCADE
781
+ - Recreates an empty schema
782
+
783
+ The tenant can then re-sync to apply current category migrations.
784
+
785
+ Returns:
786
+ Dict with success, message, tenant_slug, schema, versions_cleared
787
+ """
788
+ return await self._client._request(
789
+ endpoint=f"/api/smith/builder/category/{category_slug}/tenants/{tenant_slug}/reset",
790
+ method="DELETE",
791
+ timeout=60000 # 1 minute for single tenant reset
792
+ )
793
+
794
+ async def delete_migration(
795
+ self,
796
+ category_slug: str,
797
+ version_id: str
798
+ ) -> Dict[str, Any]:
799
+ """
800
+ Delete a single migration file from B2 storage.
801
+
802
+ WARNING: This does NOT clean up meta.alembic_version records.
803
+ Tenants that have already applied this migration keep their history.
804
+ Use with caution - typically only for cleaning up test migrations.
805
+
806
+ Returns:
807
+ Dict with success, message, version_id, category_slug
808
+ """
809
+ return await self._client._request(
810
+ endpoint=f"/api/smith/builder/category/{category_slug}/migrations/{version_id}",
811
+ method="DELETE"
812
+ )
@@ -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