keygraph 0.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.
- keygraph/__init__.py +8 -0
- keygraph/__main__.py +3 -0
- keygraph/adapters/__init__.py +33 -0
- keygraph/adapters/base.py +57 -0
- keygraph/adapters/clickhouse.py +67 -0
- keygraph/adapters/dump.py +169 -0
- keygraph/adapters/fetch.py +93 -0
- keygraph/adapters/live.py +50 -0
- keygraph/adapters/metabase.py +78 -0
- keygraph/adapters/mongo.py +93 -0
- keygraph/adapters/mysql.py +30 -0
- keygraph/adapters/postgres.py +68 -0
- keygraph/adapters/sqlite.py +24 -0
- keygraph/adapters/sqlserver.py +65 -0
- keygraph/areas.py +225 -0
- keygraph/cli.py +497 -0
- keygraph/context.py +387 -0
- keygraph/docs_out.py +199 -0
- keygraph/explorer.py +128 -0
- keygraph/graph.py +163 -0
- keygraph/hubs.py +48 -0
- keygraph/infer.py +344 -0
- keygraph/mcp_server.py +147 -0
- keygraph/mirrors.py +109 -0
- keygraph/model.py +149 -0
- keygraph/notes.py +114 -0
- keygraph/overrides.py +112 -0
- keygraph/probe.py +119 -0
- keygraph/profile.py +262 -0
- keygraph/query.py +376 -0
- keygraph/report.py +92 -0
- keygraph/search.py +175 -0
- keygraph/static/DAGRE_LICENSE +19 -0
- keygraph/static/OFL-Inter.txt +93 -0
- keygraph/static/OFL-JetBrainsMono.txt +93 -0
- keygraph/static/dagre-1.1.4.min.js +801 -0
- keygraph/static/explorer.html +69 -0
- keygraph/static/explorer.js +1076 -0
- keygraph/static/favicon.svg +1 -0
- keygraph/static/keygraph-logo.svg +1 -0
- keygraph/static/keygraph-mark.svg +1 -0
- keygraph/static/keygraph.inline.css +843 -0
- keygraph/static/kg-diagram.js +267 -0
- keygraph/static/kg-theme.js +269 -0
- keygraph/summary.py +60 -0
- keygraph-0.2.0.dist-info/METADATA +334 -0
- keygraph-0.2.0.dist-info/RECORD +50 -0
- keygraph-0.2.0.dist-info/WHEEL +4 -0
- keygraph-0.2.0.dist-info/entry_points.txt +2 -0
- keygraph-0.2.0.dist-info/licenses/LICENSE +21 -0
keygraph/cli.py
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
"""keygraph command line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
import webbrowser
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .areas import apply_curation, area_links, cluster_areas
|
|
14
|
+
from .context import ContextDB
|
|
15
|
+
from .docs_out import write_docs
|
|
16
|
+
from .explorer import build_html
|
|
17
|
+
from .mirrors import detect_mirrors, mark_mirrors
|
|
18
|
+
from .hubs import detect_hubs, mark_hubs
|
|
19
|
+
from .graph import AmbiguousTable, Graph, UnknownTable, build_graph
|
|
20
|
+
from .infer import Options
|
|
21
|
+
from .model import Schema
|
|
22
|
+
from .overrides import Overrides
|
|
23
|
+
from .report import write_report
|
|
24
|
+
from .summary import relation_summary
|
|
25
|
+
from . import query as Q
|
|
26
|
+
from . import search as S
|
|
27
|
+
|
|
28
|
+
CUR_PREFIX = "curation: "
|
|
29
|
+
DEFAULT_DIR = os.environ.get("KEYGRAPH_DIR", "keygraph-out")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _dir_args(p):
|
|
33
|
+
p.add_argument("--dir", default=DEFAULT_DIR, help="output directory holding graph.json and keygraph.db (default keygraph-out)")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _json_arg(v: str | None) -> dict:
|
|
37
|
+
if not v:
|
|
38
|
+
return {}
|
|
39
|
+
if v.startswith("{"):
|
|
40
|
+
return json.loads(v)
|
|
41
|
+
return json.loads(Path(v).read_text(encoding="utf-8"))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _emit(args, data, text: str) -> None:
|
|
45
|
+
print(json.dumps(data, ensure_ascii=False, indent=1) if getattr(args, "json", False) else text)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_graph(dir) -> Graph:
|
|
49
|
+
path = Path(dir) / "graph.json"
|
|
50
|
+
if not path.exists():
|
|
51
|
+
raise SystemExit(f"no graph at {path}; run `keygraph scan` or `keygraph load` first")
|
|
52
|
+
return Graph.load(path)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _load(args) -> Graph:
|
|
56
|
+
return load_graph(args.dir)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _ctx(args, g: Graph) -> ContextDB:
|
|
60
|
+
return ContextDB(Path(args.dir) / "keygraph.db", g)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
MANIFEST = ".keygraph-pages.json"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _drop_stale_pages(out: Path, written: list[str]) -> None:
|
|
67
|
+
"""Delete only the pages the previous build wrote and this one did not (areas can be renamed).
|
|
68
|
+
|
|
69
|
+
The previous build's page list is kept in a manifest. Only *.md files directly inside a real
|
|
70
|
+
(non-symlink) areas/ or tables/ directory are ever removed, so a user's own files survive.
|
|
71
|
+
"""
|
|
72
|
+
mf = out / MANIFEST
|
|
73
|
+
try:
|
|
74
|
+
old = json.loads(mf.read_text(encoding="utf-8")) if mf.exists() else []
|
|
75
|
+
except (OSError, ValueError):
|
|
76
|
+
old = []
|
|
77
|
+
old = [x for x in old if isinstance(x, str)] if isinstance(old, list) else []
|
|
78
|
+
keep = set(written)
|
|
79
|
+
for rel in old:
|
|
80
|
+
parts = rel.split("/")
|
|
81
|
+
if rel in keep or len(parts) != 2 or parts[0] not in ("areas", "tables") or not rel.endswith(".md"):
|
|
82
|
+
continue
|
|
83
|
+
d = out / parts[0]
|
|
84
|
+
if d.is_symlink() or not d.is_dir():
|
|
85
|
+
continue
|
|
86
|
+
f = d / parts[1]
|
|
87
|
+
if f.is_file() and not f.is_symlink():
|
|
88
|
+
f.unlink()
|
|
89
|
+
mf.write_text(json.dumps(sorted(keep)), encoding="utf-8")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def render(out: Path, g: Graph, ctx: ContextDB | None) -> dict:
|
|
93
|
+
"""Write graph.json, RELATIONS_REPORT.md and keygraph.html."""
|
|
94
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
cur = ctx.curation() if ctx else {"areas": [], "hubs": {}, "mirrors": {}}
|
|
96
|
+
warns: list[str] = list(cur.get("warnings", []))
|
|
97
|
+
names: dict[str, list[str]] = {}
|
|
98
|
+
for k in ("include", "exclude"):
|
|
99
|
+
names[k] = []
|
|
100
|
+
for t in cur["hubs"].get(k, []):
|
|
101
|
+
try:
|
|
102
|
+
g.resolve(t)
|
|
103
|
+
names[k].append(t)
|
|
104
|
+
except ValueError as exc:
|
|
105
|
+
warns.append(f"hubs {k} '{t}' ignored: {exc}")
|
|
106
|
+
mcur = cur.get("mirrors") or {}
|
|
107
|
+
schemas = {n["schema"] for n in g.nodes.values()}
|
|
108
|
+
for m, o in mcur.items():
|
|
109
|
+
if o in (False, None) and m not in schemas:
|
|
110
|
+
warns.append(f"mirrors.yaml: '{m}: {o}' ignored: unknown schema")
|
|
111
|
+
mark_mirrors(g, detect_mirrors(g, force={m: o for m, o in mcur.items() if isinstance(o, str)},
|
|
112
|
+
never={m for m, o in mcur.items() if o in (False, None)}, warnings=warns))
|
|
113
|
+
mark_hubs(g, detect_hubs(g, include=names["include"], exclude=names["exclude"]))
|
|
114
|
+
areas, cwarns = apply_curation(g, cluster_areas(g), cur["areas"])
|
|
115
|
+
g.data["areas"], g.data["area_links"] = areas, area_links(g, areas)
|
|
116
|
+
# a re-render reloads graph.json, so drop curation warnings from the previous render first
|
|
117
|
+
g.data["warnings"] = [w for w in g.data.get("warnings", []) if not w.startswith(CUR_PREFIX)]
|
|
118
|
+
g.data["warnings"] += [CUR_PREFIX + w for w in warns + cwarns]
|
|
119
|
+
dump = ctx.dump() if ctx else None # one dump for the docs and the HTML (write_docs only reads it)
|
|
120
|
+
docs = write_docs(out, g, areas, dump)
|
|
121
|
+
_drop_stale_pages(out, docs["files"])
|
|
122
|
+
g.save(out / "graph.json")
|
|
123
|
+
(out / "RELATIONS_REPORT.md").write_text(write_report(g, ctx), encoding="utf-8")
|
|
124
|
+
html = build_html(g, dump)
|
|
125
|
+
(out / "keygraph.html").write_text(html, encoding="utf-8")
|
|
126
|
+
return {"graph.json": (out / "graph.json").stat().st_size, "RELATIONS_REPORT.md": (out / "RELATIONS_REPORT.md").stat().st_size,
|
|
127
|
+
"keygraph.html": len(html.encode("utf-8"))}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def run_pipeline(schema: Schema, out: str | Path, overrides: str | None = None, context: list[str] | None = None,
|
|
131
|
+
notes: list[dict] | None = None, options: Options | None = None, verbose=True) -> dict:
|
|
132
|
+
out = Path(out)
|
|
133
|
+
t0 = time.time()
|
|
134
|
+
ov = Overrides.load(overrides) if overrides else None
|
|
135
|
+
g = build_graph(schema, ov, options)
|
|
136
|
+
ctx = ContextDB(out / "keygraph.db", g)
|
|
137
|
+
ctx.index_graph(g)
|
|
138
|
+
if context:
|
|
139
|
+
ctx.clear_curation() # rules from files that are no longer imported must not apply
|
|
140
|
+
for path in context or []:
|
|
141
|
+
ctx.import_path(path)
|
|
142
|
+
ctx.reindex()
|
|
143
|
+
prof = ctx.load_profiles()
|
|
144
|
+
if prof:
|
|
145
|
+
g.data["profiles"] = prof
|
|
146
|
+
S.apply_context_to_graph(g, ctx)
|
|
147
|
+
sizes = render(out, g, ctx)
|
|
148
|
+
result = {"stats": g.data["stats"], "context": ctx.counts(), "files": sizes, "seconds": round(time.time() - t0, 1),
|
|
149
|
+
"warnings": g.data["warnings"][:10] + ctx.warnings[:10]}
|
|
150
|
+
ctx.close()
|
|
151
|
+
if verbose:
|
|
152
|
+
st = result["stats"]
|
|
153
|
+
print(f"keygraph: {st['tables']} tables, {st['columns']} columns, {st['indexes']} indexes, {st['edges']} edges "
|
|
154
|
+
f"{st['edges_by_kind']} -> {out}/ ({result['seconds']}s)")
|
|
155
|
+
return result
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def cmd_scan(a):
|
|
159
|
+
from .adapters.live import scan_url
|
|
160
|
+
run_pipeline(scan_url(a.url, a.schema, a.all_schemas), a.dir, a.overrides, a.context)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def cmd_load(a):
|
|
164
|
+
from .adapters.dump import load_dump
|
|
165
|
+
filt = None
|
|
166
|
+
if a.where:
|
|
167
|
+
pairs = [w.split("=", 1) for w in a.where]
|
|
168
|
+
filt = lambda r: all(str(r.get(k)) == v for k, v in pairs) # noqa: E731
|
|
169
|
+
cm = _json_arg(a.columns_map)
|
|
170
|
+
if a.flatten:
|
|
171
|
+
cm["_flatten"] = a.flatten
|
|
172
|
+
sch = load_dump(a.columns, a.statistics, a.key_columns, a.default_schema, cm, _json_arg(a.statistics_map),
|
|
173
|
+
_json_arg(a.kcu_map), filt, tables=a.tables)
|
|
174
|
+
run_pipeline(sch, a.dir, a.overrides, a.context)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def cmd_render(a):
|
|
178
|
+
g = _load(a)
|
|
179
|
+
ctx = _ctx(a, g)
|
|
180
|
+
ctx.index_graph(g)
|
|
181
|
+
ctx.reindex()
|
|
182
|
+
g.data["profiles"] = ctx.load_profiles() or g.data.get("profiles", {})
|
|
183
|
+
print(json.dumps(render(Path(a.dir), g, ctx)))
|
|
184
|
+
ctx.close()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def cmd_probe(a):
|
|
188
|
+
from .probe import SelectOnlyRunner, probe_graph
|
|
189
|
+
g = _load(a)
|
|
190
|
+
runner = SelectOnlyRunner.from_url(a.url)
|
|
191
|
+
quote = "`" if a.url.startswith("mysql") else '"'
|
|
192
|
+
res = probe_graph(g, runner, a.sample, quote, a.max_edges, a.min_confidence)
|
|
193
|
+
ctx = _ctx(a, g)
|
|
194
|
+
render(Path(a.dir), g, ctx)
|
|
195
|
+
ctx.close()
|
|
196
|
+
print(json.dumps(res, indent=1))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def cmd_profile(a):
|
|
200
|
+
from .adapters import open_source
|
|
201
|
+
from .adapters.fetch import command_runner
|
|
202
|
+
from .profile import run_profile
|
|
203
|
+
g = _load(a)
|
|
204
|
+
ctx = _ctx(a, g)
|
|
205
|
+
if a.source in ("mongo", "mongodb"):
|
|
206
|
+
from pymongo import MongoClient # optional extra
|
|
207
|
+
src = open_source("mongodb", db=MongoClient(a.url)[a.db])
|
|
208
|
+
elif a.cmd:
|
|
209
|
+
src = open_source(a.source, run=command_runner(a.cmd, a.timeout))
|
|
210
|
+
else:
|
|
211
|
+
src = open_source(a.source, url=a.url)
|
|
212
|
+
src.bare = a.bare_tables
|
|
213
|
+
rep = run_profile(g, src, ctx, tables=[t for t in (a.tables or "").split(",") if t], top_hubs=a.top_hubs, with_context=a.with_context,
|
|
214
|
+
total_budget_s=a.total_budget, n=a.sample, strategy=a.strategy, budget_s=a.budget,
|
|
215
|
+
skip_over_rows=a.skip_over_rows, force=a.force)
|
|
216
|
+
ctx.reindex()
|
|
217
|
+
render(Path(a.dir), g, ctx)
|
|
218
|
+
ctx.close()
|
|
219
|
+
print(json.dumps(rep, indent=1, ensure_ascii=False))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def path_result(g: Graph, a: str, b: str, max_hops=4, min_confidence=0.6, k=3, include_mirrors=False) -> dict:
|
|
223
|
+
ps = Q.find_paths(g, a, b, max_hops, min_confidence, k, include_mirrors=include_mirrors)
|
|
224
|
+
return {"from": g.resolve(a), "to": g.resolve(b), "paths": ps}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def describe_result(dir, g: Graph, table: str, readonly: bool = False) -> dict:
|
|
228
|
+
with ContextDB(Path(dir) / "keygraph.db", g, readonly) as ctx:
|
|
229
|
+
r = S.describe(g, ctx, table)
|
|
230
|
+
r["relation_summary"] = relation_summary(g, table, g.data.get("areas", []))
|
|
231
|
+
return r
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def area_result(dir, g: Graph, name: str, include_mirrors=False, readonly: bool = False) -> dict:
|
|
235
|
+
with ContextDB(Path(dir) / "keygraph.db", g, readonly) as ctx:
|
|
236
|
+
grains = {r["table_id"]: r["grain"] for r in ctx.rows("table_notes") if r.get("grain")}
|
|
237
|
+
return Q.area_detail(g, name, grains, include_mirrors=include_mirrors)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def cmd_path(a):
|
|
241
|
+
g = _load(a)
|
|
242
|
+
r = path_result(g, a.a, a.b, a.max_hops, a.min_confidence, a.k, a.include_mirrors)
|
|
243
|
+
_emit(a, r, Q.format_paths(a.a, a.b, r["paths"]))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def cmd_neighbors(a):
|
|
247
|
+
g = _load(a)
|
|
248
|
+
r = Q.neighbors(g, a.table, a.depth, a.min_confidence)
|
|
249
|
+
_emit(a, r, Q.format_neighbors(r))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def cmd_explain(a):
|
|
253
|
+
g = _load(a)
|
|
254
|
+
r = Q.explain(g, a.ref)
|
|
255
|
+
_emit(a, r, Q.format_explain(r))
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def cmd_keys(a):
|
|
259
|
+
g = _load(a)
|
|
260
|
+
r = Q.keys(g, a.table)
|
|
261
|
+
_emit(a, r, Q.format_keys(r))
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def cmd_search(a):
|
|
265
|
+
g = _load(a)
|
|
266
|
+
with _ctx(a, g) as ctx:
|
|
267
|
+
r = S.search(g, ctx, " ".join(a.query), a.limit)
|
|
268
|
+
_emit(a, r, S.format_search(r))
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def cmd_describe(a):
|
|
272
|
+
g = _load(a)
|
|
273
|
+
r = describe_result(a.dir, g, a.table)
|
|
274
|
+
_emit(a, r, S.format_describe(r) + "\n" + Q.format_relation_summary(r["relation_summary"]))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def cmd_areas(a):
|
|
278
|
+
g = _load(a)
|
|
279
|
+
rows = Q.list_areas(g, include_mirrors=a.include_mirrors)
|
|
280
|
+
_emit(a, rows, Q.format_areas(rows))
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def cmd_area(a):
|
|
284
|
+
g = _load(a)
|
|
285
|
+
r = area_result(a.dir, g, a.name, a.include_mirrors)
|
|
286
|
+
_emit(a, r, Q.format_area(r))
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _kv(pairs: list[str]) -> dict:
|
|
290
|
+
d: dict = {}
|
|
291
|
+
for p in pairs:
|
|
292
|
+
k, _, v = p.partition("=")
|
|
293
|
+
if k == "codes":
|
|
294
|
+
d[k] = dict(x.split(":", 1) for x in v.split(",") if ":" in x)
|
|
295
|
+
elif k == "link":
|
|
296
|
+
d.setdefault("links", []).append(dict(zip(("table", "column", "role"), (v.split(":") + ["", ""])[:3])))
|
|
297
|
+
else:
|
|
298
|
+
d[k] = v
|
|
299
|
+
return d
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def cmd_context(a):
|
|
303
|
+
g = _load(a)
|
|
304
|
+
ctx = _ctx(a, g)
|
|
305
|
+
if a.action == "import":
|
|
306
|
+
counts = ctx.import_path(a.path, a.verified)
|
|
307
|
+
elif a.action == "import-notes":
|
|
308
|
+
from .notes import notes_to_context
|
|
309
|
+
data = notes_to_context(a.path, a.schema_key, a.default_schema)
|
|
310
|
+
if a.out:
|
|
311
|
+
if a.out.endswith(".json"):
|
|
312
|
+
Path(a.out).write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
|
|
313
|
+
else:
|
|
314
|
+
import yaml
|
|
315
|
+
Path(a.out).write_text(yaml.safe_dump(data, allow_unicode=True, sort_keys=False), encoding="utf-8")
|
|
316
|
+
for kind, key in (("table", "tables"), ("column", "columns")):
|
|
317
|
+
for item in data[key]:
|
|
318
|
+
ctx.upsert(kind, item, item.get("source", ""), item.get("verified", False))
|
|
319
|
+
ctx.reindex()
|
|
320
|
+
counts = {"tables": len(data["tables"]), "columns": len(data["columns"])}
|
|
321
|
+
elif a.action == "add":
|
|
322
|
+
d = _kv(a.fields)
|
|
323
|
+
ctx.upsert(a.kind, d, d.get("source") or "agent", d.get("verified") in ("true", "yes", "1"))
|
|
324
|
+
ctx.reindex()
|
|
325
|
+
counts = {a.kind: 1}
|
|
326
|
+
elif a.action == "export":
|
|
327
|
+
data = ctx.dump()
|
|
328
|
+
text = json.dumps(data, ensure_ascii=False, indent=1)
|
|
329
|
+
if a.out and not a.out.endswith(".json"):
|
|
330
|
+
import yaml
|
|
331
|
+
text = yaml.safe_dump(data, allow_unicode=True, sort_keys=False)
|
|
332
|
+
(Path(a.out).write_text(text, encoding="utf-8") if a.out else print(text))
|
|
333
|
+
ctx.close()
|
|
334
|
+
return
|
|
335
|
+
else: # stats
|
|
336
|
+
print(json.dumps(ctx.counts(), indent=1))
|
|
337
|
+
ctx.close()
|
|
338
|
+
return
|
|
339
|
+
S.apply_context_to_graph(g, ctx)
|
|
340
|
+
render(Path(a.dir), g, ctx)
|
|
341
|
+
print(json.dumps({"imported": counts, "warnings": ctx.warnings}, ensure_ascii=False, indent=1))
|
|
342
|
+
ctx.close()
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def cmd_fetch(a):
|
|
346
|
+
from .adapters.fetch import command_runner, fetch_metadata
|
|
347
|
+
run = command_runner(a.cmd, a.timeout)
|
|
348
|
+
stats, kcu = fetch_metadata(run, a.schema_expr, a.limit)
|
|
349
|
+
out = Path(a.out)
|
|
350
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
351
|
+
for name, rows in (("statistics.jsonl", stats), ("key_columns.jsonl", kcu)):
|
|
352
|
+
(out / name).write_text("\n".join(json.dumps(r, ensure_ascii=False, default=str) for r in rows) + "\n", encoding="utf-8")
|
|
353
|
+
print(f"fetched {len(stats)} index rows and {len(kcu)} foreign-key rows into {out}")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def cmd_mcp(a):
|
|
357
|
+
try:
|
|
358
|
+
from .mcp_server import build_server
|
|
359
|
+
except ImportError:
|
|
360
|
+
raise SystemExit("the MCP server needs the mcp extra: pip install 'keygraph[mcp]' (or uvx --from 'keygraph[mcp]' keygraph mcp)")
|
|
361
|
+
build_server(a.dir).run("stdio")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def cmd_ui(a):
|
|
365
|
+
p = (Path(a.dir) / "keygraph.html").resolve()
|
|
366
|
+
if not p.exists():
|
|
367
|
+
raise SystemExit(f"{p} not found; run a build first")
|
|
368
|
+
print(p)
|
|
369
|
+
webbrowser.open(p.as_uri())
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def main(argv=None) -> int:
|
|
373
|
+
ap = argparse.ArgumentParser(prog="keygraph", description="Map database keys and table relations; find join paths.")
|
|
374
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
375
|
+
|
|
376
|
+
def add(name, fn, help_, *aliases):
|
|
377
|
+
p = sub.add_parser(name, help=help_, aliases=list(aliases))
|
|
378
|
+
p.set_defaults(fn=fn)
|
|
379
|
+
_dir_args(p)
|
|
380
|
+
return p
|
|
381
|
+
|
|
382
|
+
def build_opts(p):
|
|
383
|
+
p.add_argument("--overrides", help="YAML/JSON overrides file")
|
|
384
|
+
p.add_argument("--context", action="append", help="context YAML/JSON file or directory to import (repeatable)")
|
|
385
|
+
|
|
386
|
+
p = add("scan", cmd_scan, "scan a live database through SQLAlchemy (metadata only)")
|
|
387
|
+
p.add_argument("--url", required=True)
|
|
388
|
+
p.add_argument("--schema", action="append")
|
|
389
|
+
p.add_argument("--all-schemas", action="store_true")
|
|
390
|
+
build_opts(p)
|
|
391
|
+
p = add("load", cmd_load, "build from information_schema dumps (JSON/JSONL/CSV)", "build")
|
|
392
|
+
p.add_argument("--columns", required=True)
|
|
393
|
+
p.add_argument("--statistics")
|
|
394
|
+
p.add_argument("--key-columns")
|
|
395
|
+
p.add_argument("--default-schema", default="main")
|
|
396
|
+
p.add_argument("--columns-map", help="JSON (inline or file): canonical field -> your field, e.g. {\"TABLE_SCHEMA\":\"db\"}")
|
|
397
|
+
p.add_argument("--statistics-map")
|
|
398
|
+
p.add_argument("--kcu-map")
|
|
399
|
+
p.add_argument("--flatten", action="append", help="flatten a nested dict field (e.g. raw) into the row")
|
|
400
|
+
p.add_argument("--tables", help="information_schema.TABLES dump (engine, rows, sizes, comment)")
|
|
401
|
+
p.add_argument("--where", action="append", help="keep only COLUMNS rows with field=value (repeatable)")
|
|
402
|
+
build_opts(p)
|
|
403
|
+
add("render", cmd_render, "rewrite report and explorer from graph.json + keygraph.db")
|
|
404
|
+
p = add("probe", cmd_probe, "sample value overlap for inferred edges (SELECT only)")
|
|
405
|
+
p.add_argument("--url", required=True)
|
|
406
|
+
p.add_argument("--sample", type=int, default=500)
|
|
407
|
+
p.add_argument("--max-edges", type=int, default=200)
|
|
408
|
+
p.add_argument("--min-confidence", type=float, default=0.0)
|
|
409
|
+
p = add("profile", cmd_profile, "sample column values (read-only, bounded) and store value insights")
|
|
410
|
+
p.add_argument("--source", required=True, choices=["mysql", "mariadb", "postgres", "sqlite", "sqlserver", "clickhouse", "mongodb"])
|
|
411
|
+
p.add_argument("--url", help="SQLAlchemy URL (or Mongo URI)")
|
|
412
|
+
p.add_argument("--cmd", help="external read-only command template with {sql}, printing JSON rows")
|
|
413
|
+
p.add_argument("--db", help="Mongo database name")
|
|
414
|
+
p.add_argument("--tables", help="comma-separated tables")
|
|
415
|
+
p.add_argument("--top-hubs", type=int, default=0, help="also the N most-connected tables")
|
|
416
|
+
p.add_argument("--with-context", action="store_true", help="also every table that has context notes")
|
|
417
|
+
p.add_argument("--sample", type=int, default=10000)
|
|
418
|
+
p.add_argument("--strategy", default="auto", choices=["auto", "limit", "recent", "tablesample"])
|
|
419
|
+
p.add_argument("--budget", type=float, default=20.0, help="seconds per table; slower tables are flagged")
|
|
420
|
+
p.add_argument("--total-budget", type=float, default=600.0)
|
|
421
|
+
p.add_argument("--skip-over-rows", type=int, default=50_000_000)
|
|
422
|
+
p.add_argument("--force", action="store_true", help="profile tables over the row limit")
|
|
423
|
+
p.add_argument("--bare-tables", action="store_true", help="use bare table names (graph schema is only a label)")
|
|
424
|
+
p.add_argument("--timeout", type=int, default=180)
|
|
425
|
+
p = add("path", cmd_path, "best join paths between two tables")
|
|
426
|
+
p.add_argument("a")
|
|
427
|
+
p.add_argument("b")
|
|
428
|
+
p.add_argument("--max-hops", type=int, default=4)
|
|
429
|
+
p.add_argument("--min-confidence", type=float, default=0.6)
|
|
430
|
+
p.add_argument("-k", type=int, default=3)
|
|
431
|
+
p.add_argument("--json", action="store_true")
|
|
432
|
+
p.add_argument("--include-mirrors", action="store_true", help="also use tables in mirror schemas (copies of another schema)")
|
|
433
|
+
p = add("neighbors", cmd_neighbors, "tables related to a table")
|
|
434
|
+
p.add_argument("table")
|
|
435
|
+
p.add_argument("--depth", type=int, default=1)
|
|
436
|
+
p.add_argument("--min-confidence", type=float, default=0.0)
|
|
437
|
+
p.add_argument("--json", action="store_true")
|
|
438
|
+
p = add("explain", cmd_explain, "what a column references / is referenced by, and why")
|
|
439
|
+
p.add_argument("ref")
|
|
440
|
+
p.add_argument("--json", action="store_true")
|
|
441
|
+
p = add("keys", cmd_keys, "primary key, unique keys and indexes of a table")
|
|
442
|
+
p.add_argument("table")
|
|
443
|
+
p.add_argument("--json", action="store_true")
|
|
444
|
+
p = add("search", cmd_search, "find tables by business meaning")
|
|
445
|
+
p.add_argument("query", nargs="+")
|
|
446
|
+
p.add_argument("--limit", type=int, default=8)
|
|
447
|
+
p.add_argument("--json", action="store_true")
|
|
448
|
+
p = add("describe", cmd_describe, "keys plus business context for a table")
|
|
449
|
+
p.add_argument("table")
|
|
450
|
+
p.add_argument("--json", action="store_true")
|
|
451
|
+
p = add("areas", cmd_areas, "list the business areas (clusters of related tables)")
|
|
452
|
+
p.add_argument("--json", action="store_true")
|
|
453
|
+
p.add_argument("--include-mirrors", action="store_true", help="also use tables in mirror schemas (copies of another schema)")
|
|
454
|
+
p = add("area", cmd_area, "one area: tables with grain, internal relations, neighbouring areas, hub labels")
|
|
455
|
+
p.add_argument("name", help="area id, area name (any case) or a table")
|
|
456
|
+
p.add_argument("--json", action="store_true")
|
|
457
|
+
p.add_argument("--include-mirrors", action="store_true", help="also use tables in mirror schemas (copies of another schema)")
|
|
458
|
+
p = add("context", cmd_context, "import / add / export business context")
|
|
459
|
+
p.add_argument("action", choices=["import", "import-notes", "add", "export", "stats"])
|
|
460
|
+
p.add_argument("path", nargs="?")
|
|
461
|
+
p.add_argument("--verified", action="store_true")
|
|
462
|
+
p.add_argument("--schema-key", default="schema", help="front-matter key holding the schema name (import-notes)")
|
|
463
|
+
p.add_argument("--default-schema", default="main")
|
|
464
|
+
p.add_argument("--out")
|
|
465
|
+
p.add_argument("--kind", choices=sorted(["concept", "relation", "link", "column", "metric", "table"]))
|
|
466
|
+
p.add_argument("fields", nargs="*", help="for add: key=value pairs (codes=1:new,2:paid; link=table:col:role)")
|
|
467
|
+
p = add("fetch", cmd_fetch, "fetch STATISTICS / KEY_COLUMN_USAGE through an external read-only command")
|
|
468
|
+
p.add_argument("--cmd", required=True, help="command template with {sql}, printing JSON rows")
|
|
469
|
+
p.add_argument("--schema-expr", default="DATABASE()")
|
|
470
|
+
p.add_argument("--limit", type=int, default=10000)
|
|
471
|
+
p.add_argument("--timeout", type=int, default=180)
|
|
472
|
+
p.add_argument("--out", required=True)
|
|
473
|
+
add("mcp", cmd_mcp, "serve keygraph to AI agents over MCP (stdio); needs keygraph[mcp]")
|
|
474
|
+
add("ui", cmd_ui, "open keygraph.html in the browser")
|
|
475
|
+
a, extra = ap.parse_known_args(argv)
|
|
476
|
+
if extra:
|
|
477
|
+
# Python 3.11 argparse stops filling `path`/`fields` once an option (--kind) appears between
|
|
478
|
+
# positionals, so the trailing key=value pairs arrive here. Put them back where 3.12+ puts them.
|
|
479
|
+
if a.cmd != "context" or any(x.startswith("-") for x in extra):
|
|
480
|
+
ap.error("unrecognized arguments: " + " ".join(extra))
|
|
481
|
+
if a.path is None:
|
|
482
|
+
a.path, extra = extra[0], extra[1:]
|
|
483
|
+
a.fields = list(a.fields or []) + extra
|
|
484
|
+
if a.cmd == "context" and a.action == "add" and not a.kind:
|
|
485
|
+
ap.error("context add needs --kind")
|
|
486
|
+
if a.cmd == "context" and a.action == "add" and a.path:
|
|
487
|
+
a.fields = [a.path] + a.fields
|
|
488
|
+
try:
|
|
489
|
+
a.fn(a)
|
|
490
|
+
except (AmbiguousTable, UnknownTable, ValueError) as exc:
|
|
491
|
+
print(f"keygraph: {exc}", file=sys.stderr)
|
|
492
|
+
return 2
|
|
493
|
+
return 0
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
if __name__ == "__main__":
|
|
497
|
+
raise SystemExit(main())
|