binggio 0.1.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.
- binggio/__init__.py +10 -0
- binggio/__main__.py +5 -0
- binggio/catalog/__init__.py +3 -0
- binggio/catalog/catalog.py +206 -0
- binggio/catalog/connectors.py +172 -0
- binggio/cli.py +300 -0
- binggio/connect.py +86 -0
- binggio/connectors/__init__.py +93 -0
- binggio/connectors/database.py +203 -0
- binggio/connectors/excel.py +107 -0
- binggio/context/__init__.py +3 -0
- binggio/context/context.py +189 -0
- binggio/context/execution.py +313 -0
- binggio/context/standard_analytics.py +34 -0
- binggio/demo_bank/data/complaints/complaint_log.csv +11 -0
- binggio/demo_bank/data/connector_profiles/bigquery_binggio_google_bigquery.json +26 -0
- binggio/demo_bank/data/connector_profiles/mysql_binggio_mysql.json +37 -0
- binggio/demo_bank/data/connector_profiles/parquet_binggio_parquet_files.json +39 -0
- binggio/demo_bank/data/connector_profiles/s3_binggio_s3-compatible_object_storage.json +37 -0
- binggio/demo_bank/data/connector_profiles/snowflake_binggio_snowflake.json +26 -0
- binggio/demo_bank/data/connector_profiles/sqlserver_binggio_microsoft_sql_server.json +35 -0
- binggio/demo_bank/data/customers/customer_master.csv +16 -0
- binggio/demo_bank/data/external_phase1/bigquery/Binggio_Google_BigQuery/_snapshot_manifest.json +29 -0
- binggio/demo_bank/data/external_phase1/bigquery/Binggio_Google_BigQuery/card_transactions.csv +601 -0
- binggio/demo_bank/data/external_phase1/bigquery/Binggio_Google_BigQuery/customer_digital_profile.csv +101 -0
- binggio/demo_bank/data/external_phase1/bigquery/Binggio_Google_BigQuery/digital_activity.csv +301 -0
- binggio/demo_bank/data/external_phase1/mysql/Binggio_MySQL/_snapshot_manifest.json +35 -0
- binggio/demo_bank/data/external_phase1/mysql/Binggio_MySQL/customer_interactions.csv +361 -0
- binggio/demo_bank/data/external_phase1/mysql/Binggio_MySQL/fraud_alerts.csv +301 -0
- binggio/demo_bank/data/external_phase1/mysql/Binggio_MySQL/merchants.csv +41 -0
- binggio/demo_bank/data/external_phase1/mysql/Binggio_MySQL/payment_disputes.csv +241 -0
- binggio/demo_bank/data/external_phase1/parquet/Binggio_Parquet_files/_snapshot_manifest.json +41 -0
- binggio/demo_bank/data/external_phase1/parquet/Binggio_Parquet_files/branch_devices.csv +121 -0
- binggio/demo_bank/data/external_phase1/parquet/Binggio_Parquet_files/branches.csv +25 -0
- binggio/demo_bank/data/external_phase1/parquet/Binggio_Parquet_files/customer_branch_visits.csv +301 -0
- binggio/demo_bank/data/external_phase1/parquet/Binggio_Parquet_files/device_incidents.csv +481 -0
- binggio/demo_bank/data/external_phase1/parquet/Binggio_Parquet_files/vendor_sla.csv +181 -0
- binggio/demo_bank/data/external_phase1/s3/Binggio_S3_compatible_object_storage/_snapshot_manifest.json +35 -0
- binggio/demo_bank/data/external_phase1/s3/Binggio_S3_compatible_object_storage/branch_compliance_audits.csv +181 -0
- binggio/demo_bank/data/external_phase1/s3/Binggio_S3_compatible_object_storage/cash_deliveries.csv +221 -0
- binggio/demo_bank/data/external_phase1/s3/Binggio_S3_compatible_object_storage/environmental_readings.csv +241 -0
- binggio/demo_bank/data/external_phase1/s3/Binggio_S3_compatible_object_storage/logistics_partners.csv +33 -0
- binggio/demo_bank/data/external_phase1/snowflake/Binggio_Snowflake/CUSTOMER_PORTFOLIO.csv +101 -0
- binggio/demo_bank/data/external_phase1/snowflake/Binggio_Snowflake/LOAN_BALANCE_HISTORY.csv +301 -0
- binggio/demo_bank/data/external_phase1/snowflake/Binggio_Snowflake/PORTFOLIO_RISK_HISTORY.csv +301 -0
- binggio/demo_bank/data/external_phase1/snowflake/Binggio_Snowflake/_snapshot_manifest.json +29 -0
- binggio/demo_bank/data/external_phase1/sqlserver/Binggio_Microsoft_SQL_Server/_snapshot_manifest.json +29 -0
- binggio/demo_bank/data/external_phase1/sqlserver/Binggio_Microsoft_SQL_Server/account_status_history.csv +301 -0
- binggio/demo_bank/data/external_phase1/sqlserver/Binggio_Microsoft_SQL_Server/account_transactions.csv +601 -0
- binggio/demo_bank/data/external_phase1/sqlserver/Binggio_Microsoft_SQL_Server/customer_accounts.csv +101 -0
- binggio/demo_bank/data/external_postgres/binggio_poc/_snapshot_manifest.json +24 -0
- binggio/demo_bank/data/external_postgres/binggio_poc/loan_accounts.csv +101 -0
- binggio/demo_bank/data/external_postgres/binggio_poc/loan_delinquency.csv +301 -0
- binggio/demo_bank/data/external_postgres/binggio_poc/loan_repayments.csv +601 -0
- binggio/demo_bank/data/parquet_sources/operations_v1/branch_devices.parquet +0 -0
- binggio/demo_bank/data/parquet_sources/operations_v1/branches.parquet +0 -0
- binggio/demo_bank/data/parquet_sources/operations_v1/customer_branch_visits.parquet +0 -0
- binggio/demo_bank/data/parquet_sources/operations_v1/device_incidents.parquet +0 -0
- binggio/demo_bank/data/parquet_sources/operations_v1/fixture_manifest.json +31 -0
- binggio/demo_bank/data/parquet_sources/operations_v1/vendor_sla.parquet +0 -0
- binggio/demo_bank/data/phase4_catalog/catalog.sqlite3 +0 -0
- binggio/demo_bank/data/phase4_workbench/_phase4_seed.json +11 -0
- binggio/demo_bank/data/phase4_workbench/csv_clean/account_fees.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_clean/account_interest.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_clean/account_products.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_clean/branch_budget.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_clean/complaint_feedback.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_clean/complaint_resolutions.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_clean/customer_households.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_clean/customer_kyc_profiles.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_clean/customer_master.csv +121 -0
- binggio/demo_bank/data/phase4_workbench/csv_clean/customer_segments.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_clean/interest_income.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_clean/merchant_directory.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_clean/monitoring_rules.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_clean/product_lines.csv +7 -0
- binggio/demo_bank/data/phase4_workbench/csv_clean/risk_assessments.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_clean/risk_cases.csv +76 -0
- binggio/demo_bank/data/phase4_workbench/csv_clean/risk_mitigations.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_clean/service_interactions.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_clean/transaction_types.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_clean/transfer_requests.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_messy/account_balances.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_messy/account_events.csv +47 -0
- binggio/demo_bank/data/phase4_workbench/csv_messy/account_statements.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_messy/complaint_categories.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_messy/complaint_events.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_messy/customer_addresses.csv +47 -0
- binggio/demo_bank/data/phase4_workbench/csv_messy/customer_notes.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_messy/customer_preferences.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_messy/customer_revenue.csv +47 -0
- binggio/demo_bank/data/phase4_workbench/csv_messy/customer_surveys.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_messy/exposure_limits.csv +47 -0
- binggio/demo_bank/data/phase4_workbench/csv_messy/fee_income.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_messy/finance_periods.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_messy/payment_reversals.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_messy/payment_status_events.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_messy/revenue_adjustments.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_messy/risk_alerts.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_messy/sanctions_screenings.csv +46 -0
- binggio/demo_bank/data/phase4_workbench/csv_messy/transaction_disputes.csv +47 -0
- binggio/demo_bank/data/phase4_workbench/csv_messy/transaction_history.csv +301 -0
- binggio/demo_bank/data/phase4_workbench/sqlite_imperfect/enterprise_demo.db +0 -0
- binggio/demo_bank/data/phase5_profiles/profiles.sqlite3 +0 -0
- binggio/demo_bank/data/phase6_graph/graph.sqlite3 +0 -0
- binggio/demo_bank/data/phase7_governance/autonomy.sqlite3 +0 -0
- binggio/demo_bank/data/phase7_governance/registry.sqlite3 +0 -0
- binggio/demo_bank/data/risk/risk_assessment.csv +21 -0
- binggio/demo_bank/data/transactions/txn_hist.csv +35 -0
- binggio/discovery/__init__.py +4 -0
- binggio/discovery/discovery.py +284 -0
- binggio/discovery/store.py +261 -0
- binggio/engine/__init__.py +3 -0
- binggio/engine/core.py +1794 -0
- binggio/engine/runtime.py +107 -0
- binggio/engine/sessions.py +126 -0
- binggio/impact.py +144 -0
- binggio/mcp_server.py +247 -0
- binggio/onboard.py +110 -0
- binggio/profiling/__init__.py +3 -0
- binggio/profiling/profiler.py +389 -0
- binggio/profiling/store.py +160 -0
- binggio/registry/__init__.py +7 -0
- binggio/registry/autonomy.py +343 -0
- binggio/registry/concepts.py +148 -0
- binggio/registry/dependency.py +65 -0
- binggio/registry/migrate.py +114 -0
- binggio/registry/store.py +425 -0
- binggio/registry/terms.py +166 -0
- binggio/story.py +301 -0
- binggio/ui/__init__.py +3 -0
- binggio/ui/demo_questions.json +491 -0
- binggio/ui/server.py +757 -0
- binggio/ui/static/index.html +1096 -0
- binggio/workspace.py +278 -0
- binggio-0.1.0.dist-info/METADATA +227 -0
- binggio-0.1.0.dist-info/RECORD +141 -0
- binggio-0.1.0.dist-info/WHEEL +5 -0
- binggio-0.1.0.dist-info/entry_points.txt +2 -0
- binggio-0.1.0.dist-info/licenses/LICENSE +661 -0
- binggio-0.1.0.dist-info/top_level.txt +1 -0
binggio/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 Ashish Kumar
|
|
3
|
+
"""Binggio: governed answers from your data.
|
|
4
|
+
|
|
5
|
+
Binggio answers questions only from meanings a person has approved (column roles,
|
|
6
|
+
relationships, business terms, metrics), and refuses with the reason when it cannot.
|
|
7
|
+
"""
|
|
8
|
+
__version__ = '0.1.0'
|
|
9
|
+
|
|
10
|
+
from binggio.workspace import Workspace, WorkspaceError # noqa: E402,F401
|
binggio/__main__.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 Ashish Kumar
|
|
3
|
+
"""Persistent Phase4 local catalog. No auto-discovery or approval claims.
|
|
4
|
+
|
|
5
|
+
Metadata scans are bounded, read-only and stored in a separate SQLite catalog.
|
|
6
|
+
This module never imports test-only ground truth.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
import json
|
|
10
|
+
import sqlite3
|
|
11
|
+
import threading
|
|
12
|
+
import uuid
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
from .connectors import (ConnectionErrorSafe, list_csv_tables, list_sqlite_tables,
|
|
17
|
+
read_csv_metadata, read_sqlite_metadata, resolve_source)
|
|
18
|
+
|
|
19
|
+
_LOCK = threading.RLock()
|
|
20
|
+
DEFAULTS = [] # the package registers no seed sources
|
|
21
|
+
_POC_DEFAULTS = [
|
|
22
|
+
("csv_clean", "CSV — clean reference", "csv_folder", "phase4_workbench/csv_clean"),
|
|
23
|
+
("sqlite_imperfect", "SQLite — imperfect reference", "sqlite", "phase4_workbench/sqlite_imperfect/enterprise_demo.db"),
|
|
24
|
+
("csv_messy", "CSV — messy reference", "csv_folder", "phase4_workbench/csv_messy"),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _utc() -> str:
|
|
29
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _db(project: Path) -> sqlite3.Connection:
|
|
33
|
+
target = Path(project).resolve() / "data" / "phase4_catalog" / "catalog.sqlite3"
|
|
34
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
conn = sqlite3.connect(target, timeout=10)
|
|
36
|
+
conn.row_factory = sqlite3.Row
|
|
37
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
38
|
+
conn.execute("PRAGMA busy_timeout = 5000")
|
|
39
|
+
conn.execute("""CREATE TABLE IF NOT EXISTS connections (
|
|
40
|
+
id TEXT PRIMARY KEY, name TEXT NOT NULL, kind TEXT NOT NULL,
|
|
41
|
+
source_path TEXT NOT NULL, created_at TEXT NOT NULL, last_scan TEXT,
|
|
42
|
+
scan_status TEXT NOT NULL DEFAULT 'pending', last_error TEXT,
|
|
43
|
+
UNIQUE(kind, source_path))""")
|
|
44
|
+
conn.execute("""CREATE TABLE IF NOT EXISTS catalog_tables (
|
|
45
|
+
connection_id TEXT NOT NULL, schema_name TEXT NOT NULL,
|
|
46
|
+
table_name TEXT NOT NULL, row_count INTEGER, column_count INTEGER,
|
|
47
|
+
scan_status TEXT NOT NULL, warnings_json TEXT NOT NULL,
|
|
48
|
+
columns_json TEXT NOT NULL, scanned_at TEXT NOT NULL,
|
|
49
|
+
PRIMARY KEY(connection_id, schema_name, table_name),
|
|
50
|
+
FOREIGN KEY(connection_id) REFERENCES connections(id) ON DELETE CASCADE)""")
|
|
51
|
+
conn.commit()
|
|
52
|
+
return conn
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def init_catalog(project: Path) -> None:
|
|
56
|
+
with _LOCK, _db(project) as db:
|
|
57
|
+
for cid, name, kind, rel in DEFAULTS:
|
|
58
|
+
try:resolve_source(project, rel, kind)
|
|
59
|
+
except ConnectionErrorSafe:continue
|
|
60
|
+
db.execute("""INSERT OR IGNORE INTO connections
|
|
61
|
+
(id,name,kind,source_path,created_at,scan_status)
|
|
62
|
+
VALUES (?,?,?,?,?,'pending')""", (cid,name,kind,rel,_utc()))
|
|
63
|
+
db.commit()
|
|
64
|
+
for cid, *_ in DEFAULTS:
|
|
65
|
+
row = db.execute("SELECT id, last_scan FROM connections WHERE id=?", (cid,)).fetchone()
|
|
66
|
+
if row and row["last_scan"] is None:
|
|
67
|
+
refresh_connection(project, cid)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def list_connections(project: Path) -> list[dict]:
|
|
71
|
+
with _LOCK, _db(project) as db:
|
|
72
|
+
return [dict(r) for r in db.execute("""SELECT c.*,
|
|
73
|
+
(SELECT COUNT(*) FROM catalog_tables t WHERE t.connection_id=c.id) AS table_count
|
|
74
|
+
FROM connections c ORDER BY c.created_at,c.name""").fetchall()]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def add_connection(project: Path, *, name: str, kind: str, source_path: str) -> dict:
|
|
78
|
+
name = str(name or "").strip()
|
|
79
|
+
if not (1 <= len(name) <= 80):raise ConnectionErrorSafe("Connection name must contain 1–80 characters")
|
|
80
|
+
resolve_source(project, source_path, kind)
|
|
81
|
+
cid = "conn_" + uuid.uuid4().hex[:14]
|
|
82
|
+
with _LOCK, _db(project) as db:
|
|
83
|
+
try:
|
|
84
|
+
db.execute("INSERT INTO connections (id,name,kind,source_path,created_at) VALUES (?,?,?,?,?)",
|
|
85
|
+
(cid,name,kind,source_path,_utc()))
|
|
86
|
+
db.commit()
|
|
87
|
+
except sqlite3.IntegrityError as exc:
|
|
88
|
+
raise ConnectionErrorSafe("This source is already registered") from exc
|
|
89
|
+
try:
|
|
90
|
+
refresh_connection(project, cid)
|
|
91
|
+
except Exception:
|
|
92
|
+
# Keep connection with failed scan status for diagnostic visibility.
|
|
93
|
+
pass
|
|
94
|
+
return next(c for c in list_connections(project) if c["id"] == cid)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def remove_connection(project: Path, cid: str) -> None:
|
|
98
|
+
if cid in {c[0] for c in DEFAULTS}:
|
|
99
|
+
raise ConnectionErrorSafe("Seed connections cannot be deleted; add another source instead")
|
|
100
|
+
with _LOCK, _db(project) as db:
|
|
101
|
+
cur = db.execute("DELETE FROM connections WHERE id=?", (cid,))
|
|
102
|
+
db.commit()
|
|
103
|
+
if not cur.rowcount:raise ConnectionErrorSafe("Connection not found")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def refresh_connection(project: Path, cid: str) -> dict:
|
|
107
|
+
with _LOCK, _db(project) as db:
|
|
108
|
+
row = db.execute("SELECT * FROM connections WHERE id=?", (cid,)).fetchone()
|
|
109
|
+
if row is None:raise ConnectionErrorSafe("Connection not found")
|
|
110
|
+
try:
|
|
111
|
+
source = resolve_source(project, row["source_path"], row["kind"])
|
|
112
|
+
names = list_csv_tables(source) if row["kind"] == "csv_folder" else list_sqlite_tables(source)
|
|
113
|
+
if not names:raise ConnectionErrorSafe("No supported tables found")
|
|
114
|
+
staged = []
|
|
115
|
+
failures = 0
|
|
116
|
+
for name in names:
|
|
117
|
+
try:
|
|
118
|
+
if row["kind"] == "csv_folder":
|
|
119
|
+
p = source / (name + ".csv")
|
|
120
|
+
if not p.is_file() or not p.resolve().is_relative_to(source):
|
|
121
|
+
raise ConnectionErrorSafe("Source CSV escaped folder or was removed")
|
|
122
|
+
meta = read_csv_metadata(p, preview=0)
|
|
123
|
+
else:meta = read_sqlite_metadata(source, name, preview=0)
|
|
124
|
+
except (OSError, ValueError, sqlite3.Error) as exc:
|
|
125
|
+
meta = {"columns": [], "row_count": None, "warnings": [str(exc)[:180]],
|
|
126
|
+
"scan_status": "error"}
|
|
127
|
+
failures += 1
|
|
128
|
+
staged.append((cid, "files" if row["kind"] == "csv_folder" else "main", name,
|
|
129
|
+
meta.get("row_count"), len(meta["columns"]),meta["scan_status"],
|
|
130
|
+
json.dumps(meta["warnings"]), json.dumps(meta["columns"]),_utc()))
|
|
131
|
+
# Swap metadata atomically; failed connection scans don't discard old state.
|
|
132
|
+
db.execute("DELETE FROM catalog_tables WHERE connection_id=?", (cid,))
|
|
133
|
+
db.executemany("""INSERT INTO catalog_tables
|
|
134
|
+
(connection_id,schema_name,table_name,row_count,column_count,scan_status,
|
|
135
|
+
warnings_json,columns_json,scanned_at) VALUES (?,?,?,?,?,?,?,?,?)""", staged)
|
|
136
|
+
db.execute("UPDATE connections SET last_scan=?,scan_status=?,last_error=NULL WHERE id=?",
|
|
137
|
+
(_utc(),"partial" if failures else "scanned",cid))
|
|
138
|
+
db.commit()
|
|
139
|
+
except Exception as exc:
|
|
140
|
+
db.execute("UPDATE connections SET scan_status='error',last_error=? WHERE id=?",(str(exc)[:220],cid))
|
|
141
|
+
db.commit()
|
|
142
|
+
raise
|
|
143
|
+
return next(c for c in list_connections(project) if c["id"] == cid)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def list_schemas(project: Path, connection_id: str | None = None) -> list[dict]:
|
|
148
|
+
with _LOCK, _db(project) as db:
|
|
149
|
+
sql = """SELECT connection_id,schema_name,COUNT(*) AS table_count
|
|
150
|
+
FROM catalog_tables WHERE 1=1"""
|
|
151
|
+
args = []
|
|
152
|
+
if connection_id:
|
|
153
|
+
sql += " AND connection_id=?";args.append(connection_id)
|
|
154
|
+
sql += " GROUP BY connection_id,schema_name ORDER BY connection_id,schema_name"
|
|
155
|
+
return [dict(r) for r in db.execute(sql,args).fetchall()]
|
|
156
|
+
|
|
157
|
+
def list_tables(project: Path, *, connection_id: str | None = None, search: str = "", limit: int = 250) -> list[dict]:
|
|
158
|
+
limit = min(max(int(limit),1), 5000)
|
|
159
|
+
with _LOCK, _db(project) as db:
|
|
160
|
+
sql = """SELECT t.connection_id,t.schema_name,t.table_name,t.row_count,t.column_count,
|
|
161
|
+
t.scan_status,t.warnings_json,t.scanned_at,c.name AS connection_name,c.kind
|
|
162
|
+
FROM catalog_tables t JOIN connections c ON c.id=t.connection_id WHERE 1=1"""
|
|
163
|
+
args: list[Any] = []
|
|
164
|
+
if connection_id:
|
|
165
|
+
sql += " AND t.connection_id=?";args.append(connection_id)
|
|
166
|
+
if search:
|
|
167
|
+
sql += " AND LOWER(t.table_name) LIKE ?";args.append("%"+search.lower().replace("%","\\%").replace("_","\\_")+"%")
|
|
168
|
+
sql += " ESCAPE '\\'"
|
|
169
|
+
sql += " ORDER BY t.connection_id,t.table_name LIMIT ?";args.append(limit)
|
|
170
|
+
result=[]
|
|
171
|
+
for row in db.execute(sql,args).fetchall():
|
|
172
|
+
d=dict(row);d["warnings"]=json.loads(d.pop("warnings_json"));result.append(d)
|
|
173
|
+
return result
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def table_detail(project: Path, cid: str, name: str, *, preview: int = 10) -> dict:
|
|
177
|
+
preview = min(max(preview,0),25)
|
|
178
|
+
with _LOCK, _db(project) as db:
|
|
179
|
+
c=db.execute("SELECT * FROM connections WHERE id=?",(cid,)).fetchone()
|
|
180
|
+
if not c:raise ConnectionErrorSafe("Connection not found")
|
|
181
|
+
t=db.execute("SELECT * FROM catalog_tables WHERE connection_id=? AND table_name=?",(cid,name)).fetchone()
|
|
182
|
+
if not t:raise ConnectionErrorSafe("Table is not in the catalog; refresh the connection")
|
|
183
|
+
source=resolve_source(project,c["source_path"],c["kind"])
|
|
184
|
+
if c["kind"] == "csv_folder":
|
|
185
|
+
p=(source/(name+".csv")).resolve()
|
|
186
|
+
if not p.is_relative_to(source):raise ConnectionErrorSafe("CSV source escaped folder")
|
|
187
|
+
meta=read_csv_metadata(p,preview=preview)
|
|
188
|
+
else:meta=read_sqlite_metadata(source,name,preview=preview)
|
|
189
|
+
return {"connection_id":cid,"connection_name":c["name"],"schema":t["schema_name"],
|
|
190
|
+
"table":name,"source_kind":c["kind"],**meta,
|
|
191
|
+
"note":"Basic technical metadata only. Semantic roles and relationships are not yet verified."}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def overview(project: Path) -> dict:
|
|
195
|
+
with _LOCK, _db(project) as db:
|
|
196
|
+
connections=db.execute("SELECT COUNT(*) FROM connections").fetchone()[0]
|
|
197
|
+
tables=db.execute("SELECT COUNT(*) FROM catalog_tables").fetchone()[0]
|
|
198
|
+
columns=db.execute("SELECT COALESCE(SUM(column_count),0) FROM catalog_tables").fetchone()[0]
|
|
199
|
+
warnings=db.execute("SELECT warnings_json FROM catalog_tables").fetchall()
|
|
200
|
+
warning_tables=sum(bool(json.loads(r[0])) for r in warnings)
|
|
201
|
+
errors=db.execute("SELECT COUNT(*) FROM catalog_tables WHERE scan_status!='scanned'").fetchone()[0]
|
|
202
|
+
return {"connections":connections,"tables":tables,"columns":columns,
|
|
203
|
+
"tables_with_warnings":warning_tables,"tables_with_scan_errors":errors,
|
|
204
|
+
"phase":"4","data_label":"Synthetic local demonstration",
|
|
205
|
+
"relationship_discovery":"not_implemented","human_review":"not_implemented",
|
|
206
|
+
"session_memory":"planned_phase_8B"}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 Ashish Kumar
|
|
3
|
+
"""Phase 4 local CSV and SQLite read-only metadata connectors.
|
|
4
|
+
|
|
5
|
+
No arbitrary SQL endpoint, no external credentials, no network connections.
|
|
6
|
+
Path sandbox is <project>/data only. This is not production connector infrastructure.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
import csv
|
|
10
|
+
import io
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sqlite3
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
MAX_CSV_BYTES = 2 * 1024 * 1024 * 1024 # 2 GB per CSV table; files are streamed, never loaded whole
|
|
18
|
+
MAX_PREVIEW = 25
|
|
19
|
+
MAX_COLUMNS = 200
|
|
20
|
+
MAX_TABLES = 2000
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ConnectionErrorSafe(ValueError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def resolve_source(project: Path, relative_path: str, kind: str) -> Path:
|
|
28
|
+
if kind not in ("csv_folder", "sqlite"):
|
|
29
|
+
raise ConnectionErrorSafe("Supported connection types are CSV folder and SQLite database")
|
|
30
|
+
rel = str(relative_path or "").strip().replace("\\", "/")
|
|
31
|
+
if not rel or os.path.isabs(rel) or re.match(r"^[A-Za-z]:", rel):
|
|
32
|
+
raise ConnectionErrorSafe("Use a relative path under the project's data/ folder")
|
|
33
|
+
root = (Path(project) / "data").resolve()
|
|
34
|
+
candidate = (root / rel).resolve()
|
|
35
|
+
if candidate == root or not candidate.is_relative_to(root):
|
|
36
|
+
raise ConnectionErrorSafe("Connection path must be inside a subfolder of data/")
|
|
37
|
+
if kind == "csv_folder" and not candidate.is_dir():
|
|
38
|
+
raise ConnectionErrorSafe("CSV folder does not exist")
|
|
39
|
+
if kind == "sqlite" and (not candidate.is_file() or candidate.suffix.lower() not in (".db", ".sqlite", ".sqlite3")):
|
|
40
|
+
raise ConnectionErrorSafe("SQLite database does not exist or has an unsupported extension")
|
|
41
|
+
return candidate
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _unwrapped_row(row: list[str], delim: str) -> list[str]:
|
|
45
|
+
# Some legacy exports wrap an entire comma-separated line in one quoted field.
|
|
46
|
+
if len(row) == 1 and delim in row[0]:
|
|
47
|
+
try:
|
|
48
|
+
parsed = next(csv.reader([row[0]], delimiter=delim))
|
|
49
|
+
if len(parsed) > 1:return parsed
|
|
50
|
+
except (csv.Error, StopIteration):pass
|
|
51
|
+
return row
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def read_csv_metadata(path: Path, *, preview: int = MAX_PREVIEW) -> dict[str, Any]:
|
|
55
|
+
warnings: list[str] = []
|
|
56
|
+
if path.stat().st_size > MAX_CSV_BYTES:
|
|
57
|
+
return {"columns": [], "row_count": None, "preview": [],
|
|
58
|
+
"warnings": ["File exceeds the 2 GB per-table limit; not scanned"],
|
|
59
|
+
"scan_status": "limited"}
|
|
60
|
+
try:
|
|
61
|
+
handle = path.open("r", encoding="utf-8-sig", errors="replace", newline="")
|
|
62
|
+
first = handle.read(12000)
|
|
63
|
+
except OSError as exc:
|
|
64
|
+
raise ConnectionErrorSafe("Cannot read CSV file") from exc
|
|
65
|
+
if not first.strip():
|
|
66
|
+
handle.close()
|
|
67
|
+
return {"columns": [], "row_count": 0, "preview": [],
|
|
68
|
+
"warnings": ["Empty CSV"], "scan_status": "limited"}
|
|
69
|
+
handle.seek(0)
|
|
70
|
+
try:
|
|
71
|
+
dialect = csv.Sniffer().sniff(first, delimiters=",;\t|")
|
|
72
|
+
delimiter = dialect.delimiter
|
|
73
|
+
except csv.Error:
|
|
74
|
+
delimiter = ";" if first.splitlines()[0].count(";") > first.splitlines()[0].count(",") else ","
|
|
75
|
+
warnings.append("Delimiter inferred heuristically")
|
|
76
|
+
try:
|
|
77
|
+
reader = csv.reader(handle, delimiter=delimiter)
|
|
78
|
+
header = _unwrapped_row(next(reader), delimiter)
|
|
79
|
+
except (csv.Error, StopIteration) as exc:
|
|
80
|
+
raise ConnectionErrorSafe("Cannot parse CSV header") from exc
|
|
81
|
+
header = [str(x).strip().lstrip("\ufeff") for x in header]
|
|
82
|
+
if len(header) > MAX_COLUMNS:
|
|
83
|
+
return {"columns": [], "row_count": None, "preview": [],
|
|
84
|
+
"warnings": ["More than 200 columns; scan stopped"], "scan_status": "limited"}
|
|
85
|
+
if len(set(x.casefold() for x in header)) != len(header):
|
|
86
|
+
warnings.append("Duplicate column names detected")
|
|
87
|
+
if any(not h for h in header):warnings.append("Blank column names detected")
|
|
88
|
+
if len(header) < 2:warnings.append("Single-column CSV; verify delimiter and export formatting")
|
|
89
|
+
rows = []
|
|
90
|
+
row_count = 0
|
|
91
|
+
replaced = False
|
|
92
|
+
malformed = 0
|
|
93
|
+
padded_cells = 0
|
|
94
|
+
blanks = [0] * len(header)
|
|
95
|
+
sample_values: list[list[str]] = [[] for _ in header]
|
|
96
|
+
try:
|
|
97
|
+
for raw in reader:
|
|
98
|
+
if not raw or not any(x.strip() for x in raw):continue
|
|
99
|
+
row = _unwrapped_row(raw, delimiter)
|
|
100
|
+
row_count += 1
|
|
101
|
+
if len(row) != len(header):malformed += 1
|
|
102
|
+
padded_cells += sum(str(v) != str(v).strip() for v in row)
|
|
103
|
+
if not replaced and any("\ufffd" in str(v) for v in row):
|
|
104
|
+
replaced = True; warnings.append("Some invalid UTF-8 characters were replaced during scan")
|
|
105
|
+
values = [str(row[i]).strip() if i < len(row) else "" for i in range(len(header))]
|
|
106
|
+
for i, v in enumerate(values):
|
|
107
|
+
if not v or v.upper() in ("NULL", "N/A", "NA"):
|
|
108
|
+
blanks[i] += 1
|
|
109
|
+
if len(sample_values[i]) < 100:sample_values[i].append(v)
|
|
110
|
+
if len(rows) < preview:rows.append(dict(zip(header, values)))
|
|
111
|
+
except csv.Error:
|
|
112
|
+
warnings.append("CSV contains malformed quoting; partial scan only")
|
|
113
|
+
finally:
|
|
114
|
+
handle.close()
|
|
115
|
+
if malformed:warnings.append(f"{malformed} rows differ from header width")
|
|
116
|
+
if padded_cells:warnings.append(f"{padded_cells} values have leading/trailing whitespace")
|
|
117
|
+
if any(blanks):warnings.append("Missing or null-like values observed; detailed profiling is Phase 5")
|
|
118
|
+
def inferred(values: list[str]) -> str:
|
|
119
|
+
present = [v for v in values if v and v.upper() not in ("NULL", "NA", "N/A")]
|
|
120
|
+
if not present:return "unknown"
|
|
121
|
+
if all(re.fullmatch(r"[+-]?\d+", v) for v in present):return "integer-like"
|
|
122
|
+
if all(re.fullmatch(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)", v) for v in present):return "decimal-like"
|
|
123
|
+
if all(re.fullmatch(r"\d{4}[-/]\d{2}[-/]\d{2}", v) for v in present):return "date-like"
|
|
124
|
+
return "text/mixed"
|
|
125
|
+
columns = [{"name": h, "declared_type": None, "observed_type": inferred(sample_values[i]),
|
|
126
|
+
"missing_count": blanks[i], "missing_pct": round(100 * blanks[i] / row_count, 1) if row_count else 0}
|
|
127
|
+
for i, h in enumerate(header)]
|
|
128
|
+
return {"columns": columns, "row_count": row_count, "preview": rows,
|
|
129
|
+
"warnings": warnings, "scan_status": "scanned", "delimiter": "TAB" if delimiter == "\t" else delimiter}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def list_csv_tables(folder: Path) -> list[str]:
|
|
133
|
+
return [p.stem for p in sorted(folder.iterdir(), key=lambda p:p.name.lower())
|
|
134
|
+
if p.is_file() and p.suffix.lower() == ".csv"][:MAX_TABLES]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _readonly(db: Path) -> sqlite3.Connection:
|
|
138
|
+
# mode=ro ensures metadata/preview calls cannot mutate customer-provided DBs.
|
|
139
|
+
conn = sqlite3.connect(db.as_uri() + "?mode=ro", uri=True, timeout=5)
|
|
140
|
+
conn.execute("PRAGMA query_only = ON")
|
|
141
|
+
conn.execute("PRAGMA busy_timeout = 5000")
|
|
142
|
+
return conn
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _quote_identifier(name: str) -> str:
|
|
146
|
+
return '"' + name.replace('"', '""') + '"'
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def list_sqlite_tables(db: Path) -> list[str]:
|
|
150
|
+
with _readonly(db) as conn:
|
|
151
|
+
return [r[0] for r in conn.execute(
|
|
152
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name LIMIT ?",
|
|
153
|
+
(MAX_TABLES,)).fetchall()]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def read_sqlite_metadata(db: Path, table: str, *, preview: int = MAX_PREVIEW) -> dict[str, Any]:
|
|
157
|
+
with _readonly(db) as conn:
|
|
158
|
+
allowed = set(list_sqlite_tables(db))
|
|
159
|
+
if table not in allowed:raise ConnectionErrorSafe("Table not found in this SQLite connection")
|
|
160
|
+
quoted = _quote_identifier(table)
|
|
161
|
+
rawcols = conn.execute(f"PRAGMA table_info({quoted})").fetchall()
|
|
162
|
+
cols = [r[1] for r in rawcols]
|
|
163
|
+
if len(cols) > MAX_COLUMNS:
|
|
164
|
+
return {"columns": [], "row_count": None, "preview": [],
|
|
165
|
+
"warnings": ["More than 200 columns; scan stopped"], "scan_status": "limited"}
|
|
166
|
+
count = conn.execute(f"SELECT COUNT(*) FROM {quoted}").fetchone()[0]
|
|
167
|
+
data = conn.execute(f"SELECT * FROM {quoted} LIMIT ?", (min(MAX_PREVIEW, max(0, preview)),)).fetchall()
|
|
168
|
+
columns = [{"name": r[1], "declared_type": r[2] or None, "observed_type": "not profiled",
|
|
169
|
+
"missing_count": None, "missing_pct": None, "primary_key": bool(r[5])} for r in rawcols]
|
|
170
|
+
return {"columns": columns, "row_count": count,
|
|
171
|
+
"preview": [{cols[i]:str(v) if v is not None else None for i,v in enumerate(row)} for row in data],
|
|
172
|
+
"warnings": [], "scan_status": "scanned"}
|