schemaingest 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.
@@ -0,0 +1,3 @@
1
+ """SchemaIngest — local database schema introspection agent."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Allow running as `python -m schemaingest`."""
2
+
3
+ from schemaingest.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
schemaingest/cli.py ADDED
@@ -0,0 +1,99 @@
1
+ """CLI entry point for SchemaIngest agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+
8
+ import click
9
+
10
+ from schemaingest import __version__
11
+ from schemaingest.security import generate_pairing_code, redact_password, set_pairing_code
12
+
13
+ WEB_UI_URL = "https://jinethbosilu.github.io/SchemaIngest/"
14
+
15
+
16
+ def _banner(lines: list[str]) -> str:
17
+ """A box sized to its contents. Plain ASCII, so it lines up on any console
18
+ (Windows code pages included) - emoji are double-width and would not."""
19
+ width = max(len(line) for line in lines) + 4
20
+ rule = "+" + "-" * width + "+"
21
+ body = [f"| {line.ljust(width - 4)} |" for line in lines]
22
+ return "\n".join([rule, *body, rule])
23
+
24
+
25
+ @click.group()
26
+ @click.version_option(__version__, prog_name="schemaingest")
27
+ def main():
28
+ """SchemaIngest — local database schema introspection agent."""
29
+
30
+
31
+ @main.command()
32
+ @click.option("--port", default=8420, show_default=True, help="Port to listen on (127.0.0.1 only).")
33
+ def agent(port: int):
34
+ """Start the SchemaIngest agent server."""
35
+ import uvicorn
36
+
37
+ from schemaingest.server import create_app
38
+
39
+ # The agent only ever binds to loopback: it holds database credentials.
40
+ host = "127.0.0.1"
41
+ code = generate_pairing_code()
42
+ set_pairing_code(code)
43
+
44
+ click.echo("")
45
+ click.echo(_banner([
46
+ f"SchemaIngest Agent v{__version__}",
47
+ "",
48
+ f"Pairing code: {code}",
49
+ "Enter this code in the web UI to connect.",
50
+ "",
51
+ f"Agent: http://{host}:{port}",
52
+ f"Web UI: {WEB_UI_URL}",
53
+ ]))
54
+ click.echo("")
55
+
56
+ uvicorn.run(create_app(), host=host, port=port, log_level="info")
57
+
58
+
59
+ @main.command()
60
+ @click.option(
61
+ "--conn", required=True,
62
+ help="Connection string: postgresql://user:pass@host/db or mysql://user:pass@host/db.",
63
+ )
64
+ @click.option("--out", required=True, type=click.Path(dir_okay=False), help="Output file path.")
65
+ @click.option(
66
+ "--schema", default=None,
67
+ help="Schema to introspect. PostgreSQL: defaults to public. MySQL: the database in --conn.",
68
+ )
69
+ @click.option(
70
+ "--format", "fmt",
71
+ type=click.Choice(["json", "txt"]), default="json", show_default=True,
72
+ help="json: the full schema pack. txt: the compact text for pasting into an AI.",
73
+ )
74
+ def pull(conn: str, out: str, schema: str | None, fmt: str):
75
+ """Export the schema without the web UI."""
76
+ from schemaingest.introspect import introspect
77
+ from schemaingest.models import ConnectRequest
78
+ from schemaingest.renderers import render_schema_txt
79
+
80
+ click.echo(f"Connecting to: {redact_password(conn)}")
81
+
82
+ try:
83
+ pack = introspect(ConnectRequest(connectionString=conn, schema=schema))
84
+ except Exception as e:
85
+ click.echo(f"Connection failed: {redact_password(str(e))}", err=True)
86
+ sys.exit(1)
87
+
88
+ with open(out, "w", encoding="utf-8") as f:
89
+ if fmt == "txt":
90
+ f.write(render_schema_txt(pack))
91
+ else:
92
+ json.dump(pack.model_dump(by_alias=True), f, indent=2)
93
+
94
+ click.echo(f"Schema written to {out}")
95
+ click.echo(f" Tables: {len(pack.tables)}, Relationships: {len(pack.relationships)}")
96
+
97
+
98
+ if __name__ == "__main__":
99
+ main()
@@ -0,0 +1,77 @@
1
+ """FastAPI route handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, Depends
6
+ from fastapi.responses import JSONResponse
7
+
8
+ from schemaingest.introspect import introspect
9
+ from schemaingest.models import ConnectRequest, PairRequest, SchemaPack
10
+ from schemaingest.renderers import render_erd_mermaid, render_schema_txt
11
+ from schemaingest.security import (
12
+ rate_limit_dep,
13
+ redact_password,
14
+ require_session,
15
+ verify_pairing_code,
16
+ )
17
+ from schemaingest import __version__
18
+
19
+ router = APIRouter()
20
+
21
+
22
+ # Handlers are plain `def`: the database drivers block, so FastAPI runs them in its
23
+ # threadpool instead of on the event loop.
24
+
25
+ # ─── Health ───────────────────────────────────────────────────────────
26
+
27
+ @router.get("/health")
28
+ def health():
29
+ return {"status": "ok", "version": __version__}
30
+
31
+
32
+ # ─── Pairing ──────────────────────────────────────────────────────────
33
+
34
+ @router.post("/pair/start", dependencies=[Depends(rate_limit_dep)])
35
+ def pair_start(body: PairRequest):
36
+ token = verify_pairing_code(body.code)
37
+ if token is None:
38
+ return JSONResponse(status_code=403, content={"detail": "Invalid pairing code"})
39
+ return {"sessionToken": token}
40
+
41
+
42
+ # ─── Introspection ────────────────────────────────────────────────────
43
+
44
+ def _do_introspect(req: ConnectRequest) -> SchemaPack:
45
+ """Shared introspection helper."""
46
+ try:
47
+ return introspect(req)
48
+ except Exception as e:
49
+ # Sanitise the error message to avoid leaking credentials
50
+ raise ValueError(redact_password(str(e))) from None
51
+
52
+
53
+ @router.post("/introspect", dependencies=[Depends(rate_limit_dep)])
54
+ def introspect_endpoint(body: ConnectRequest, _token: str = Depends(require_session)):
55
+ try:
56
+ pack = _do_introspect(body)
57
+ except ValueError as e:
58
+ return JSONResponse(status_code=400, content={"detail": str(e)})
59
+ return pack.model_dump(by_alias=True)
60
+
61
+
62
+ @router.post("/render/schema.txt", dependencies=[Depends(rate_limit_dep)])
63
+ def render_schema_txt_endpoint(body: ConnectRequest, _token: str = Depends(require_session)):
64
+ try:
65
+ pack = _do_introspect(body)
66
+ except ValueError as e:
67
+ return JSONResponse(status_code=400, content={"detail": str(e)})
68
+ return {"text": render_schema_txt(pack)}
69
+
70
+
71
+ @router.post("/render/erd.mmd", dependencies=[Depends(rate_limit_dep)])
72
+ def render_erd_mmd_endpoint(body: ConnectRequest, _token: str = Depends(require_session)):
73
+ try:
74
+ pack = _do_introspect(body)
75
+ except ValueError as e:
76
+ return JSONResponse(status_code=400, content={"detail": str(e)})
77
+ return {"mermaid": render_erd_mermaid(pack)}
@@ -0,0 +1,19 @@
1
+ """Schema introspection, one module per database engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from schemaingest.models import ConnectRequest, SchemaPack
6
+
7
+
8
+ def introspect(req: ConnectRequest) -> SchemaPack:
9
+ """Introspect the database a connect request names, whichever engine it is."""
10
+ # Drivers load on demand, so one engine's driver failing to import does
11
+ # not take the other engine down with it.
12
+ if req.resolved_engine() == "mysql":
13
+ from schemaingest.introspect.mysql import introspect_mysql
14
+
15
+ return introspect_mysql(req.to_mysql_params(), schema=req.resolved_schema())
16
+
17
+ from schemaingest.introspect.postgres import introspect_postgres
18
+
19
+ return introspect_postgres(req.to_dsn(), schema=req.resolved_schema())
@@ -0,0 +1,94 @@
1
+ """Pack assembly shared by every engine.
2
+
3
+ Each engine reads its own catalog and hands over plain rows; turning those
4
+ rows into columns, keys and relationships happens here, once."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from datetime import datetime, timezone
9
+ from typing import Iterable, Optional, TypedDict
10
+
11
+ from schemaingest import __version__
12
+ from schemaingest.models import (
13
+ ColumnInfo,
14
+ ConstraintInfo,
15
+ DbMeta,
16
+ FkRef,
17
+ IndexInfo,
18
+ Relationship,
19
+ TableInfo,
20
+ )
21
+
22
+
23
+ class RawColumn(TypedDict):
24
+ name: str
25
+ type: str
26
+ nullable: bool
27
+ default: Optional[str]
28
+
29
+
30
+ class RawForeignKey(TypedDict):
31
+ """One column pair of a foreign key; a composite key is several rows
32
+ sharing constraint_name, in key order."""
33
+ from_column: str
34
+ to_table: str
35
+ to_column: str
36
+ constraint_name: str
37
+
38
+
39
+ def build_table(
40
+ tname: str,
41
+ schema: str,
42
+ columns: Iterable[RawColumn],
43
+ pk_cols: list[str],
44
+ fks: Iterable[RawForeignKey],
45
+ constraints: list[ConstraintInfo],
46
+ indexes: list[IndexInfo],
47
+ ) -> tuple[TableInfo, list[Relationship]]:
48
+ relationships: list[Relationship] = []
49
+ fk_map: dict[str, FkRef] = {}
50
+ for fk in fks:
51
+ # A column in two foreign keys keeps its first target for fkRef; every
52
+ # pairing is still listed in relationships.
53
+ fk_map.setdefault(fk["from_column"], FkRef(table=fk["to_table"], column=fk["to_column"]))
54
+ relationships.append(
55
+ Relationship(
56
+ fromTable=tname,
57
+ fromColumn=fk["from_column"],
58
+ toTable=fk["to_table"],
59
+ toColumn=fk["to_column"],
60
+ constraintName=fk["constraint_name"],
61
+ )
62
+ )
63
+
64
+ table = TableInfo(
65
+ name=tname,
66
+ schema=schema,
67
+ columns=[
68
+ ColumnInfo(
69
+ name=c["name"],
70
+ type=c["type"],
71
+ nullable=c["nullable"],
72
+ default=c["default"],
73
+ isPrimaryKey=c["name"] in pk_cols,
74
+ isForeignKey=c["name"] in fk_map,
75
+ fkRef=fk_map.get(c["name"]),
76
+ )
77
+ for c in columns
78
+ ],
79
+ primaryKey=pk_cols,
80
+ indexes=indexes,
81
+ constraints=constraints,
82
+ )
83
+ return table, relationships
84
+
85
+
86
+ def make_meta(engine: str, db_name: str, db_version: str, schema: str) -> DbMeta:
87
+ return DbMeta(
88
+ engine=engine,
89
+ dbName=db_name,
90
+ dbVersion=db_version,
91
+ schema=schema,
92
+ generatedAt=datetime.now(timezone.utc).isoformat(),
93
+ agentVersion=__version__,
94
+ )
@@ -0,0 +1,254 @@
1
+ """MySQL and MariaDB introspection via information_schema.
2
+
3
+ information_schema is slow on MySQL - each view is materialised from the data
4
+ dictionary on every read - so every view is read once for the whole database
5
+ and grouped by table here, rather than queried table by table."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections import defaultdict
10
+ from typing import Any
11
+
12
+ import pymysql
13
+ import pymysql.cursors
14
+
15
+ from schemaingest.introspect.common import RawColumn, RawForeignKey, build_table, make_meta
16
+ from schemaingest.models import ConstraintInfo, IndexInfo, Relationship, SchemaPack, TableInfo
17
+
18
+ # ─── SQL queries ──────────────────────────────────────────────────────
19
+ # Every column is aliased: MySQL 8 returns information_schema names in
20
+ # upper case, MariaDB as written.
21
+
22
+ _TABLES_SQL = """
23
+ SELECT TABLE_NAME AS table_name
24
+ FROM information_schema.TABLES
25
+ WHERE TABLE_SCHEMA = %s AND TABLE_TYPE = 'BASE TABLE'
26
+ ORDER BY TABLE_NAME;
27
+ """
28
+
29
+ _COLUMNS_SQL = """
30
+ SELECT
31
+ TABLE_NAME AS table_name,
32
+ COLUMN_NAME AS column_name,
33
+ COLUMN_TYPE AS column_type,
34
+ IS_NULLABLE AS is_nullable,
35
+ COLUMN_DEFAULT AS column_default,
36
+ EXTRA AS extra
37
+ FROM information_schema.COLUMNS
38
+ WHERE TABLE_SCHEMA = %s
39
+ ORDER BY TABLE_NAME, ORDINAL_POSITION;
40
+ """
41
+
42
+ # Key columns of every PRIMARY KEY, UNIQUE and FOREIGN KEY constraint, in key
43
+ # order. A foreign key's rows pair up column by column, so a composite key
44
+ # comes out as one row per column pair.
45
+ _KEY_COLUMNS_SQL = """
46
+ SELECT
47
+ TABLE_NAME AS table_name,
48
+ CONSTRAINT_NAME AS constraint_name,
49
+ COLUMN_NAME AS column_name,
50
+ REFERENCED_TABLE_NAME AS to_table,
51
+ REFERENCED_COLUMN_NAME AS to_column
52
+ FROM information_schema.KEY_COLUMN_USAGE
53
+ WHERE TABLE_SCHEMA = %s
54
+ ORDER BY TABLE_NAME, CONSTRAINT_NAME, ORDINAL_POSITION;
55
+ """
56
+
57
+ _TABLE_CONSTRAINTS_SQL = """
58
+ SELECT
59
+ TABLE_NAME AS table_name,
60
+ CONSTRAINT_NAME AS constraint_name,
61
+ CONSTRAINT_TYPE AS constraint_type
62
+ FROM information_schema.TABLE_CONSTRAINTS
63
+ WHERE TABLE_SCHEMA = %s AND CONSTRAINT_TYPE IN ('PRIMARY KEY', 'UNIQUE', 'FOREIGN KEY')
64
+ ORDER BY TABLE_NAME, CONSTRAINT_NAME;
65
+ """
66
+
67
+ # MariaDB names a check per table and says which table in CHECK_CONSTRAINTS;
68
+ # MySQL names it per database, and the table comes from TABLE_CONSTRAINTS.
69
+ _CHECKS_MARIADB_SQL = """
70
+ SELECT
71
+ TABLE_NAME AS table_name,
72
+ CONSTRAINT_NAME AS constraint_name,
73
+ CHECK_CLAUSE AS definition
74
+ FROM information_schema.CHECK_CONSTRAINTS
75
+ WHERE CONSTRAINT_SCHEMA = %s
76
+ ORDER BY TABLE_NAME, CONSTRAINT_NAME;
77
+ """
78
+
79
+ _CHECKS_MYSQL_SQL = """
80
+ SELECT
81
+ tc.TABLE_NAME AS table_name,
82
+ tc.CONSTRAINT_NAME AS constraint_name,
83
+ cc.CHECK_CLAUSE AS definition
84
+ FROM information_schema.TABLE_CONSTRAINTS tc
85
+ JOIN information_schema.CHECK_CONSTRAINTS cc
86
+ ON cc.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA AND cc.CONSTRAINT_NAME = tc.CONSTRAINT_NAME
87
+ WHERE tc.TABLE_SCHEMA = %s AND tc.CONSTRAINT_TYPE = 'CHECK'
88
+ ORDER BY tc.TABLE_NAME, tc.CONSTRAINT_NAME;
89
+ """
90
+
91
+ _INDEXES_SQL = """
92
+ SELECT
93
+ TABLE_NAME AS table_name,
94
+ INDEX_NAME AS index_name,
95
+ NON_UNIQUE AS non_unique,
96
+ COLUMN_NAME AS column_name,
97
+ SUB_PART AS sub_part,
98
+ {expression} AS expression
99
+ FROM information_schema.STATISTICS
100
+ WHERE TABLE_SCHEMA = %s
101
+ ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX;
102
+ """
103
+
104
+ # Which optional information_schema columns this server has: CHECK_CONSTRAINTS
105
+ # arrived in MySQL 8.0.16 and MariaDB 10.2, STATISTICS.EXPRESSION (functional
106
+ # indexes) in MySQL 8.0.13, and MariaDB has none.
107
+ _FEATURES_SQL = """
108
+ SELECT UPPER(TABLE_NAME) AS table_name, UPPER(COLUMN_NAME) AS column_name
109
+ FROM information_schema.COLUMNS
110
+ WHERE TABLE_SCHEMA = 'information_schema'
111
+ AND UPPER(TABLE_NAME) IN ('CHECK_CONSTRAINTS', 'STATISTICS');
112
+ """
113
+
114
+
115
+ # ─── Introspection logic ─────────────────────────────────────────────
116
+
117
+ def _text(value: Any) -> Any:
118
+ """MySQL 8 hands some information_schema columns back as bytes."""
119
+ return value.decode("utf-8") if isinstance(value, (bytes, bytearray)) else value
120
+
121
+
122
+ def _rows(cur, sql: str, schema: str) -> list[dict]:
123
+ cur.execute(sql, (schema,))
124
+ return [{k: _text(v) for k, v in row.items()} for row in cur.fetchall()]
125
+
126
+
127
+ def _server_version(raw: str) -> tuple[str, bool]:
128
+ """"10.4.32-MariaDB-log" -> ("MariaDB 10.4.32", True); "8.4.2" -> ("MySQL 8.4.2", False)."""
129
+ is_mariadb = "mariadb" in raw.lower()
130
+ return f"{'MariaDB' if is_mariadb else 'MySQL'} {raw.split('-')[0]}", is_mariadb
131
+
132
+
133
+ def _column_default(row: dict, is_mariadb: bool) -> str | None:
134
+ if "auto_increment" in (row["extra"] or "").lower():
135
+ return "auto_increment"
136
+ default = row["column_default"]
137
+ # MariaDB reports "no default" on a nullable column as the text NULL, and
138
+ # quotes real string defaults, so the bare word is never a literal there.
139
+ if is_mariadb and default == "NULL":
140
+ return None
141
+ return None if default is None else str(default)
142
+
143
+
144
+ def introspect_mysql(params: dict[str, Any], schema: str) -> SchemaPack:
145
+ """Connect to MySQL or MariaDB and introspect one database.
146
+
147
+ Args:
148
+ params: Keyword arguments for pymysql.connect (see ConnectRequest.to_mysql_params).
149
+ schema: The database to introspect - in MySQL a database is a schema.
150
+ """
151
+ conn = pymysql.connect(
152
+ **params,
153
+ charset="utf8mb4",
154
+ connect_timeout=10,
155
+ autocommit=True,
156
+ cursorclass=pymysql.cursors.DictCursor,
157
+ )
158
+ try:
159
+ with conn.cursor() as cur:
160
+ cur.execute("SET SESSION TRANSACTION READ ONLY")
161
+ cur.execute("SELECT VERSION() AS version")
162
+ db_version, is_mariadb = _server_version(_text(cur.fetchone()["version"]))
163
+
164
+ cur.execute(_FEATURES_SQL)
165
+ features = {(_text(r["table_name"]), _text(r["column_name"])) for r in cur.fetchall()}
166
+ has_checks = any(t == "CHECK_CONSTRAINTS" for t, _ in features)
167
+ has_expressions = ("STATISTICS", "EXPRESSION") in features
168
+
169
+ # Sorted here: ORDER BY follows the server's collation, which puts
170
+ # "orders" before "order_lines" on some servers and not others.
171
+ table_names = sorted(r["table_name"] for r in _rows(cur, _TABLES_SQL, schema))
172
+ columns = _rows(cur, _COLUMNS_SQL, schema)
173
+ key_columns = _rows(cur, _KEY_COLUMNS_SQL, schema)
174
+ table_constraints = _rows(cur, _TABLE_CONSTRAINTS_SQL, schema)
175
+ checks = []
176
+ if has_checks:
177
+ has_table_name = ("CHECK_CONSTRAINTS", "TABLE_NAME") in features
178
+ checks = _rows(cur, _CHECKS_MARIADB_SQL if has_table_name else _CHECKS_MYSQL_SQL, schema)
179
+ index_rows = _rows(
180
+ cur,
181
+ _INDEXES_SQL.format(expression="EXPRESSION" if has_expressions else "NULL"),
182
+ schema,
183
+ )
184
+ finally:
185
+ conn.close()
186
+
187
+ by_table: dict[str, dict[str, list]] = defaultdict(lambda: defaultdict(list))
188
+
189
+ for c in columns:
190
+ by_table[c["table_name"]]["columns"].append(RawColumn(
191
+ name=c["column_name"],
192
+ type=c["column_type"],
193
+ nullable=c["is_nullable"] == "YES",
194
+ default=_column_default(c, is_mariadb),
195
+ ))
196
+
197
+ # Constraint columns, in key order, and the foreign keys among them.
198
+ constraint_cols: dict[tuple[str, str], list[str]] = defaultdict(list)
199
+ for k in key_columns:
200
+ constraint_cols[(k["table_name"], k["constraint_name"])].append(k["column_name"])
201
+ if k["to_table"] is not None:
202
+ by_table[k["table_name"]]["fks"].append(RawForeignKey(
203
+ from_column=k["column_name"],
204
+ to_table=k["to_table"],
205
+ to_column=k["to_column"],
206
+ constraint_name=k["constraint_name"],
207
+ ))
208
+
209
+ constraints: list[tuple[str, ConstraintInfo]] = [
210
+ (tc["table_name"], ConstraintInfo(
211
+ name=tc["constraint_name"],
212
+ type=tc["constraint_type"],
213
+ columns=constraint_cols[(tc["table_name"], tc["constraint_name"])],
214
+ ))
215
+ for tc in table_constraints
216
+ ]
217
+ # MySQL does not say which columns a check reads.
218
+ constraints += [
219
+ (ck["table_name"], ConstraintInfo(
220
+ name=ck["constraint_name"], type="CHECK", columns=[], definition=ck["definition"],
221
+ ))
222
+ for ck in checks
223
+ ]
224
+ for tname, con in sorted(constraints, key=lambda tc: (tc[0], tc[1].name)):
225
+ by_table[tname]["constraints"].append(con)
226
+
227
+ indexes: dict[tuple[str, str], IndexInfo] = {}
228
+ for ix in index_rows:
229
+ key = (ix["table_name"], ix["index_name"])
230
+ if key not in indexes:
231
+ indexes[key] = IndexInfo(name=ix["index_name"], columns=[], isUnique=int(ix["non_unique"]) == 0)
232
+ by_table[ix["table_name"]]["indexes"].append(indexes[key])
233
+ if ix["column_name"] is None:
234
+ part = ix["expression"] or "?" # a functional index on a server we could not read
235
+ elif ix["sub_part"] is not None:
236
+ part = f"{ix['column_name']}({ix['sub_part']})" # a prefix index
237
+ else:
238
+ part = ix["column_name"]
239
+ indexes[key].columns.append(part)
240
+ if ix["index_name"] == "PRIMARY":
241
+ by_table[ix["table_name"]]["pk"].append(ix["column_name"])
242
+
243
+ tables: list[TableInfo] = []
244
+ all_relationships: list[Relationship] = []
245
+ for tname in table_names:
246
+ t = by_table[tname]
247
+ table, relationships = build_table(
248
+ tname, schema, t["columns"], t["pk"], t["fks"], t["constraints"], t["indexes"],
249
+ )
250
+ tables.append(table)
251
+ all_relationships.extend(relationships)
252
+
253
+ meta = make_meta("mysql", schema, db_version, schema)
254
+ return SchemaPack(meta=meta, tables=tables, relationships=all_relationships)