feedple-sdk 0.0.1__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.
@@ -0,0 +1,4 @@
1
+ # SPDX-FileCopyrightText: 2026-present Umar Bello Kanwa <umarbellokanwa@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ __version__ = "0.0.1"
@@ -0,0 +1,176 @@
1
+ import asyncio
2
+ import threading
3
+ from sqlalchemy import Engine, text
4
+ from typing import Optional
5
+ from .core.schema_services import get_schema, _generate_schema_hash
6
+ from .core.policy import Identity, PolicyEngine
7
+ from .core.compiler import SQLCompiler
8
+ from .core.ir_builder import build_query_from_ir
9
+ from .core.websocket import FeedpleWebSocket
10
+ from .core.request import FEEDPLE_WS_URL
11
+ import logging
12
+ import time
13
+
14
+ logger = logging.getLogger("feedple_sdk")
15
+ if not logger.handlers:
16
+ handler = logging.StreamHandler()
17
+ handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s:%(message)s"))
18
+ logger.addHandler(handler)
19
+ logger.setLevel(logging.INFO)
20
+
21
+ class FeedpleSDK:
22
+ def __init__(
23
+ self,
24
+ *,
25
+ api_key: str,
26
+ db: Engine,
27
+ identity: Identity,
28
+ auto_sync: bool = True,
29
+ sync_interval: int = 60,
30
+ # connection options
31
+ reconnect_enabled: bool = True,
32
+ max_retries: Optional[int] = None,
33
+ probe_before_connect: bool = False,
34
+ ) -> None:
35
+ if not api_key:
36
+ raise ValueError(
37
+ "api_key is required. Please provide a valid Feedple API key."
38
+ )
39
+ self.api_key = api_key
40
+ self.db = db
41
+ self.identity = identity
42
+ self.sync_interval = sync_interval
43
+
44
+ self._previous_hash: Optional[str] = None
45
+ self._loop = asyncio.new_event_loop()
46
+
47
+ logger.info("Feedple: initializing SDK...")
48
+
49
+ self._ws = FeedpleWebSocket(
50
+ ws_url=FEEDPLE_WS_URL,
51
+ api_key=api_key,
52
+ )
53
+ # apply connection options
54
+ self._ws.reconnect_enabled = reconnect_enabled
55
+ self._ws.max_retries = max_retries
56
+ self._ws.probe_before_connect = probe_before_connect
57
+ self._ws.on_ir_request(self._execute_ir)
58
+ logger.info("Feedple: WebSocket client configured")
59
+
60
+ self._verify_db_connection()
61
+
62
+ self._thread = threading.Thread(
63
+ target=self._run_loop,
64
+ daemon=True,
65
+ name="feedple-worker",
66
+ )
67
+ self._thread.start()
68
+ logger.info("Feedple: background worker thread started")
69
+
70
+ if auto_sync:
71
+ logger.info(f"Feedple: auto-sync enabled (interval: {self.sync_interval}s)")
72
+
73
+ logger.info("Feedple: SDK initialized successfully")
74
+
75
+ def _run_loop(self) -> None:
76
+ logger.info("Feedple: event loop running")
77
+ asyncio.set_event_loop(self._loop)
78
+ self._loop.run_until_complete(self._main())
79
+ logger.info("Feedple: event loop stopped")
80
+
81
+ async def _main(self) -> None:
82
+ logger.info("Feedple: starting WebSocket and sync worker...")
83
+ await asyncio.gather(
84
+ self._ws.connect(),
85
+ self._sync_worker(),
86
+ )
87
+
88
+ def _verify_db_connection(self) -> None:
89
+ logger.info("Feedple: verifying database connection...")
90
+ try:
91
+ with self.db.connect() as conn:
92
+ conn.execute(text("SELECT 1"))
93
+ logger.info("Feedple: database connection verified")
94
+ except Exception as e:
95
+ raise ConnectionError(f"Feedple SDK could not connect to the database: {e}")
96
+
97
+ async def sync_schema(self) -> None:
98
+ logger.info("Feedple: inspecting database schema...")
99
+
100
+ # run get_schema in thread executor so it doesn't block the event loop
101
+ # this keeps the WebSocket heartbeat running during inspection
102
+ loop = asyncio.get_event_loop()
103
+ schema = await loop.run_in_executor(
104
+ None,
105
+ lambda: get_schema(db=self.db, identity=self.identity)
106
+ )
107
+
108
+ logger.info(f"Feedple: schema inspected ({len(schema)} tables found)")
109
+
110
+ # check hash before sending
111
+ new_hash = _generate_schema_hash(schema)
112
+
113
+ if self._previous_hash and self._previous_hash == new_hash:
114
+ logger.info("Feedple: schema unchanged, skipping sync")
115
+ return # If nothing changed, return empty (None)
116
+
117
+
118
+ logger.info("Feedple: sending schema via WebSocket...")
119
+ await self._ws.send_schema(schema)
120
+ self._previous_hash = new_hash # store after successful send
121
+ logger.info("Feedple: schema sent successfully")
122
+
123
+ async def _sync_worker(self) -> None:
124
+ logger.info("Feedple: sync worker started, waiting for WebSocket authentication...")
125
+
126
+ while True:
127
+ try:
128
+ # wait for auth on every iteration, covers first connect and every reconnect
129
+ await self._ws.wait_until_authenticated(timeout=30)
130
+ logger.info("Feedple: sync worker authenticated, running sync...")
131
+ await self.sync_schema()
132
+ except asyncio.TimeoutError:
133
+ logger.warning("Feedple: timed out waiting for WebSocket authentication, retrying...")
134
+ continue
135
+ except Exception as e:
136
+ logger.warning(f"Feedple: schema sync failed: {e}")
137
+ self._ws._auth_event.clear() # force re-auth wait on next iteration
138
+ continue
139
+
140
+ logger.info(f"Feedple: next sync in {self.sync_interval}s")
141
+ await asyncio.sleep(self.sync_interval)
142
+
143
+ def _build_compiler(self) -> SQLCompiler:
144
+ policy = PolicyEngine(self.identity)
145
+ return SQLCompiler(policy) # Only use for when there is direct use of SQL codes
146
+
147
+ async def _execute_ir(self, ir: dict) -> dict:
148
+ logger.info(f"Feedple: validating IR request...")
149
+
150
+ policy = PolicyEngine(self.identity)
151
+ policy.validate_ir_access(ir=ir)
152
+
153
+ logger.info(f"Feedple: executing IR request...")
154
+ stmt = build_query_from_ir(ir=ir)
155
+
156
+ logger.info(f"Feedple: executing query against database...")
157
+ start = time.time()
158
+
159
+ with self.db.connect() as conn:
160
+ result = conn.execute(stmt) # passing the Select object directly — params stay bound, never stringified
161
+ rows = [dict(r._mapping) for r in result]
162
+
163
+ duration = int((time.time() - start) * 1000)
164
+ logger.info(f"Feedple: IR executed ({len(rows)} rows, {duration}ms)")
165
+
166
+ return {
167
+ "rows": rows,
168
+ "count": len(rows),
169
+ "duration_ms": duration,
170
+ }
171
+
172
+ def stop(self) -> None:
173
+ logger.info("Feedple: stopping SDK...")
174
+ self._ws.stop()
175
+ self._loop.call_soon_threadsafe(self._loop.stop)
176
+ logger.info("Feedple: SDK stopped")
@@ -0,0 +1,30 @@
1
+ import sqlglot
2
+ from sqlglot import exp
3
+ from typing import List
4
+ from .policy import PolicyEngine
5
+
6
+
7
+ class SQLCompiler:
8
+ def __init__(self, policy: PolicyEngine, dialect: str = "postgres"):
9
+ self.policy = policy
10
+ self.dialect = dialect
11
+
12
+ def parse(self, sql: str) -> exp.Expression:
13
+ return sqlglot.parse_one(sql, read=self.dialect)
14
+
15
+ def extract_tables(self, ast: exp.Expression) -> List[str]:
16
+ return [t.name for t in ast.find_all(exp.Table) if t.name]
17
+
18
+ def validate_access(self, tables: List[str]) -> None:
19
+ if self.policy.identity.all_tables:
20
+ return
21
+
22
+ for table in tables:
23
+ if not self.policy.can_access_table(table):
24
+ raise PermissionError(f"Access denied to table: {table}")
25
+
26
+ def compile(self, sql: str) -> str:
27
+ ast = self.parse(sql)
28
+ tables = self.extract_tables(ast)
29
+ self.validate_access(tables)
30
+ return ast.sql(dialect=self.dialect)
@@ -0,0 +1,161 @@
1
+ # feedple_sdk/core/ir_builder.py
2
+ from sqlalchemy import select, table, column, and_, func
3
+ from sqlalchemy.sql import ColumnElement
4
+ from sqlalchemy.sql.selectable import Select
5
+
6
+ _ALLOWED_FUNCTIONS = {"count", "sum", "avg", "min", "max"}
7
+
8
+ _OPERATOR_BUILDERS = {
9
+ "eq": lambda col, val: col == val,
10
+ "=": lambda col, val: col == val,
11
+ "neq": lambda col, val: col != val,
12
+ "!=": lambda col, val: col != val,
13
+ "gt": lambda col, val: col > val,
14
+ ">": lambda col, val: col > val,
15
+ "gte": lambda col, val: col >= val,
16
+ ">=": lambda col, val: col >= val,
17
+ "lt": lambda col, val: col < val,
18
+ "<": lambda col, val: col < val,
19
+ "lte": lambda col, val: col <= val,
20
+ "<=": lambda col, val: col <= val,
21
+ }
22
+
23
+
24
+ def build_query_from_ir(ir: dict) -> Select:
25
+ """
26
+ Translates the flat, clause-based SQLIntermediateRepresentation IR schema
27
+ into an executable SQLAlchemy Core Select construct.
28
+ """
29
+ if ir.get("operation") != "query":
30
+ raise NotImplementedError(f"Unsupported IR operation: {ir.get('operation')}")
31
+
32
+ # Initialize tables map starting with the primary base FROM table
33
+ base_table_name = ir.get("table")
34
+ if not base_table_name:
35
+ raise ValueError("IR is missing primary 'table'")
36
+
37
+ tables_by_name = {base_table_name: table(base_table_name)}
38
+ for j in ir.get("joins") or []:
39
+ tables_by_name[j["table"]] = table(j["table"])
40
+
41
+ # 1. Build SELECT elements
42
+ select_cols = _build_select_columns(ir.get("fields") or [], tables_by_name)
43
+ stmt = select(*select_cols).select_from(tables_by_name[base_table_name])
44
+
45
+ # 2. Build JOIN clauses linearly
46
+ for j in ir.get("joins") or []:
47
+ join_tbl = tables_by_name[j["table"]]
48
+
49
+ # Build the condition explicitly via on_left and on_right keys
50
+ left_col = _resolve_column(j["on_left"], tables_by_name)
51
+ right_col = _resolve_column(j["on_right"], tables_by_name)
52
+ on_clause = (left_col == right_col)
53
+
54
+ # Determine join type
55
+ is_outer = j.get("join_type", "INNER").upper() in ("LEFT", "LEFT OUTER")
56
+ stmt = stmt.join(join_tbl, on_clause, isouter=is_outer)
57
+
58
+ # 3. Build WHERE clauses (filters)
59
+ filters = ir.get("filters") or []
60
+ if filters:
61
+ stmt = stmt.where(and_(*[_build_filter_condition(f, tables_by_name) for f in filters]))
62
+
63
+ # 4. Build GROUP BY clauses
64
+ if ir.get("group_by"):
65
+ group_by = ir["group_by"] if isinstance(ir["group_by"], list) else [ir["group_by"]]
66
+ stmt = stmt.group_by(*[_resolve_column(g, tables_by_name) for g in group_by])
67
+
68
+ # 5. Build HAVING clauses (Aggregated filters)
69
+ having_filters = ir.get("having") or []
70
+ if having_filters:
71
+ stmt = stmt.having(and_(*[_build_filter_condition(h, tables_by_name) for h in having_filters]))
72
+
73
+ # 6. Build ORDER BY clauses
74
+ if ir.get("order_by"):
75
+ order_by = ir["order_by"] if isinstance(ir["order_by"], list) else [ir["order_by"]]
76
+ stmt = stmt.order_by(*[_resolve_order_by_column(o, tables_by_name) for o in order_by])
77
+
78
+ # 7. Apply limits and offsets
79
+ if ir.get("limit") is not None:
80
+ stmt = stmt.limit(int(ir["limit"]))
81
+
82
+ if ir.get("offset") is not None:
83
+ stmt = stmt.offset(int(ir["offset"]))
84
+
85
+ return stmt
86
+
87
+
88
+ def _build_select_columns(fields: list[dict], tables_by_name: dict) -> list[ColumnElement]:
89
+ if not fields:
90
+ return [tables_by_name[name] for name in tables_by_name]
91
+
92
+ result = []
93
+ for f in fields:
94
+ # Corrected key from f["field"] to f["column"]
95
+ col = _resolve_column(f["column"], tables_by_name)
96
+ expr_raw = f.get("expression")
97
+
98
+ if expr_raw:
99
+ # Handle case-insensitivity or distinct qualifiers (e.g., "COUNT(DISTINCT)" -> "count")
100
+ expr_clean = expr_raw.lower()
101
+
102
+ if "count" in expr_clean:
103
+ # If the string contains distinct keywords, handle counting uniquely
104
+ if "distinct" in expr_clean:
105
+ node = func.count(col.distinct())
106
+ else:
107
+ node = func.count(col)
108
+ elif expr_clean in _ALLOWED_FUNCTIONS:
109
+ node = getattr(func, expr_clean)(col)
110
+ else:
111
+ raise ValueError(f"Unsupported field expression aggregation: {expr_raw}")
112
+ else:
113
+ node = col
114
+
115
+ if f.get("alias"):
116
+ node = node.label(f["alias"])
117
+
118
+ result.append(node)
119
+
120
+ return result
121
+
122
+
123
+ def _build_filter_condition(f: dict, tables_by_name: dict) -> ColumnElement:
124
+ operator = f["operator"].lower()
125
+ col = _resolve_column(f["column"], tables_by_name)
126
+ value = f["value"]
127
+
128
+ if operator in _OPERATOR_BUILDERS:
129
+ return _OPERATOR_BUILDERS[operator](col, value)
130
+
131
+ if operator in ("in", "in_"):
132
+ return col.in_(value)
133
+
134
+ if operator in ("like", "ilike"):
135
+ if operator == "ilike":
136
+ return col.ilike(value)
137
+ return col.like(value)
138
+
139
+ raise ValueError(f"Unsupported filter operator: {operator}")
140
+
141
+
142
+ def _resolve_column(field: str, tables_by_name: dict) -> ColumnElement:
143
+ if "." in field:
144
+ table_name, col_name = field.split(".", 1)
145
+ if table_name not in tables_by_name:
146
+ raise ValueError(f"Unknown table reference in field definition: {field}")
147
+ return column(col_name, _selectable=tables_by_name[table_name])
148
+
149
+ # Fallback to structural context if no prefix was generated
150
+ return column(field)
151
+
152
+
153
+ def _resolve_order_by_column(order_string: str, tables_by_name: dict) -> ColumnElement:
154
+ """Splits string parameters like 'articles.created_at DESC' into clean columns with modifiers."""
155
+ parts = order_string.strip().split()
156
+ col_ref = parts[0]
157
+ col_element = _resolve_column(col_ref, tables_by_name)
158
+
159
+ if len(parts) > 1 and parts[1].upper() == "DESC":
160
+ return col_element.desc()
161
+ return col_element.asc()
@@ -0,0 +1,22 @@
1
+ import json
2
+ from uuid import UUID
3
+ from datetime import datetime, date
4
+
5
+ class SafeJSONEncoder(json.JSONEncoder):
6
+ def default(self, obj):
7
+ # 1. Handle UUIDs safely
8
+ if isinstance(obj, UUID):
9
+ return str(obj)
10
+
11
+ # 2. Handle datetimes cleanly (strips internal timezone circular dependencies)
12
+ if isinstance(obj, (datetime, date)):
13
+ return obj.isoformat()
14
+
15
+ # 3. Handle SQLAlchemy Row/Mapping objects if passed directly
16
+ if hasattr(obj, "_asdict"):
17
+ return obj._asdict()
18
+ if hasattr(obj, "__dict__"):
19
+ # Fallback to prevent serialization crashes on complex custom objects
20
+ return {k: v for k, v in obj.__dict__.items() if not k.startswith('_')}
21
+
22
+ return super().default(obj)
@@ -0,0 +1,29 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import List, Optional
3
+
4
+
5
+ @dataclass
6
+ class Identity:
7
+ name: Optional[str]
8
+ allowed_tables: List[str] = field(default_factory=list)
9
+ all_tables: bool = False
10
+
11
+ class PolicyEngine:
12
+ def __init__(self, identity: Identity):
13
+ self.identity = identity
14
+
15
+ # ---------------- RBAC ----------------
16
+ def can_access_table(self, table: str) -> bool:
17
+ if self.identity.all_tables:
18
+ return True
19
+ return table in self.identity.allowed_tables
20
+
21
+
22
+ def validate_ir_access(self, ir: dict) -> None:
23
+ if self.identity.all_tables:
24
+ return
25
+
26
+ tables = [ir["table"]] + [j["table"] for j in (ir.get("joins") or [])]
27
+ for table in tables:
28
+ if not self.can_access_table(table):
29
+ raise PermissionError(f"Access denied to table: {table}")
@@ -0,0 +1,50 @@
1
+ import json
2
+ from typing import Dict, Any, Optional
3
+ import urllib.request
4
+ import urllib.error
5
+
6
+ DEV_ENV = True
7
+ # Note: the server sets a FastAPI `root_path` of '/api/py'. To avoid duplicating
8
+ # that prefix we target the internal versioned API paths here (e.g. '/api/v1/...').
9
+ FEEDPLE_API_BASE_URL = ("localhost:8000" if DEV_ENV else "https://feedple-ai.onrender.com") + "/api/v1"
10
+
11
+ SCHEMA_SYNC_URL = f"{FEEDPLE_API_BASE_URL}/schema/sync"
12
+ FEEDPLE_WS_URL = ("ws://localhost:8000" if DEV_ENV else "wss://feedple-ai.onrender.com") + "/api/v1/tenants/ws"
13
+
14
+ class SchemaSyncError(Exception):
15
+ pass
16
+
17
+ def _post_json_schema(
18
+ payload: Dict[str, Any],
19
+ headers: Optional[Dict[str, str]] = None,
20
+ timeout: int = 10,
21
+ ) -> Dict[str, Any]:
22
+ """
23
+ Minimal HTTP POST using stdlib only. No external dependencies.
24
+ """
25
+ data = json.dumps(payload).encode("utf-8")
26
+
27
+ request_headers = {
28
+ "Content-Type": "application/json",
29
+ "Accept": "application/json",
30
+ }
31
+
32
+ if headers:
33
+ request_headers.update(headers)
34
+
35
+ req = urllib.request.Request(
36
+ url=SCHEMA_SYNC_URL,
37
+ data=data,
38
+ headers=request_headers,
39
+ method="POST",
40
+ )
41
+
42
+ try:
43
+ with urllib.request.urlopen(req, timeout=timeout) as res:
44
+ return json.loads(res.read().decode("utf-8"))
45
+ except urllib.error.HTTPError as e:
46
+ body = e.read().decode("utf-8")
47
+ raise SchemaSyncError(f"HTTP {e.code}: {body}")
48
+ except Exception as e:
49
+ raise SchemaSyncError(f"Network error: {str(e)}")
50
+
@@ -0,0 +1,151 @@
1
+ import json
2
+ import hashlib
3
+ import time
4
+ from typing import Dict, Any, Optional
5
+ from sqlalchemy import inspect, Engine
6
+ from .request import _post_json_schema, SchemaSyncError
7
+ from feedple_sdk.core.policy import Identity
8
+ from sqlalchemy.exc import NoSuchTableError
9
+
10
+ def _normalize_schema(schema: Dict[str, Any]) -> str:
11
+ """
12
+ Normalize schema into a deterministic JSON string for hashing.
13
+ sort_keys ensures consistent ordering across environments.
14
+ """
15
+ return json.dumps(schema, sort_keys=True, separators=(",", ":"))
16
+
17
+
18
+ def _generate_schema_hash(schema: Dict[str, Any]) -> str:
19
+ """
20
+ SHA-256 hash of the normalized schema for change detection.
21
+ """
22
+ return hashlib.sha256(_normalize_schema(schema).encode("utf-8")).hexdigest()
23
+
24
+
25
+ def send_schema_to_api(
26
+ *,
27
+ schema: Dict[str, Any],
28
+ previous_hash: Optional[str] = None,
29
+ api_key: Optional[str] = None,
30
+ version: Optional[int] = None,
31
+ retry: int = 3,
32
+ backoff_seconds: float = 0.5,
33
+ ) -> Dict[str, Any]:
34
+ """
35
+ Sends database schema to your central API.
36
+
37
+ - Skips sync if schema has not changed in the central API
38
+ - Attaches Authorization header if api_key is provided
39
+ - Retries with exponential backoff on failure
40
+ """
41
+ schema_hash = _generate_schema_hash(schema)
42
+
43
+ if previous_hash and previous_hash == schema_hash:
44
+ return {
45
+ "status": "skipped",
46
+ "reason": "no_change",
47
+ "schema_hash": schema_hash,
48
+ }
49
+
50
+ payload = {
51
+ "schema": schema,
52
+ "schema_version": version,
53
+ "timestamp": int(time.time()),
54
+ }
55
+
56
+ headers = {}
57
+ if api_key:
58
+ headers["Authorization"] = f"Bearer {api_key}"
59
+
60
+ last_error = None
61
+
62
+ for attempt in range(retry):
63
+ try:
64
+ response = _post_json_schema(payload, headers=headers)
65
+ return {
66
+ "status": "success",
67
+ "schema_hash": schema_hash,
68
+ "response": response,
69
+ }
70
+ except SchemaSyncError as e:
71
+ last_error = str(e)
72
+ if attempt < retry - 1:
73
+ time.sleep(backoff_seconds * (2 ** attempt))
74
+
75
+ raise SchemaSyncError(f"Failed after {retry} attempts: {last_error}")
76
+
77
+
78
+ def should_sync_schema(old_schema: Dict[str, Any], new_schema: Dict[str, Any]) -> bool:
79
+ """
80
+ Lightweight pre-check before making an API call.
81
+ Returns True if schemas differ and a sync is needed.
82
+ """
83
+ return _generate_schema_hash(old_schema) != _generate_schema_hash(new_schema)
84
+
85
+ def get_schema(db: Engine, identity: Identity) -> dict:
86
+ with db.connect() as conn:
87
+ inspector = inspect(conn)
88
+ tables = inspector.get_table_names() if identity.all_tables else identity.allowed_tables
89
+ result = {}
90
+
91
+ for table in tables:
92
+ try:
93
+ pk = inspector.get_pk_constraint(table)
94
+ fks = inspector.get_foreign_keys(table)
95
+ indexes = inspector.get_indexes(table)
96
+ unique = inspector.get_unique_constraints(table)
97
+ columns = inspector.get_columns(table)
98
+
99
+ result[table] = {
100
+ "columns": [
101
+ {
102
+ "name": col["name"],
103
+ "type": str(col["type"]),
104
+ "nullable": col["nullable"],
105
+ "default": str(col["default"]) if col["default"] is not None else None,
106
+ }
107
+ for col in columns
108
+ ],
109
+ "primary_key": pk.get("constrained_columns", []),
110
+ "foreign_keys": [
111
+ {
112
+ "columns": fk["constrained_columns"],
113
+ "references_table": fk["referred_table"],
114
+ "references_columns": fk["referred_columns"],
115
+ }
116
+ for fk in fks
117
+ ],
118
+ "indexes": [
119
+ {
120
+ "name": idx["name"],
121
+ "columns": idx["column_names"],
122
+ "unique": idx["unique"],
123
+ }
124
+ for idx in indexes
125
+ ],
126
+ "unique_constraints": [
127
+ {
128
+ "name": uc["name"],
129
+ "columns": uc["column_names"],
130
+ }
131
+ for uc in unique
132
+ ],
133
+ }
134
+ except NoSuchTableError:
135
+ raise ValueError(f"Table '{table}' does not exist in the database")
136
+
137
+ return result
138
+
139
+ SENSITIVE_COLUMNS = {"password", "token", "secret", "hash", "salt", "ssn", "credit_card"}
140
+
141
+ def filter_sensitive_columns(schema: dict) -> dict:
142
+ for table in schema.values():
143
+ table["columns"] = [
144
+ col for col in table["columns"]
145
+ if col["name"].lower() not in SENSITIVE_COLUMNS
146
+ ]
147
+ return schema
148
+
149
+ # def on_migration_complete():
150
+ # schema = introspect_db() #db
151
+ # sdk.sync(schema)