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.
Files changed (50) hide show
  1. keygraph/__init__.py +8 -0
  2. keygraph/__main__.py +3 -0
  3. keygraph/adapters/__init__.py +33 -0
  4. keygraph/adapters/base.py +57 -0
  5. keygraph/adapters/clickhouse.py +67 -0
  6. keygraph/adapters/dump.py +169 -0
  7. keygraph/adapters/fetch.py +93 -0
  8. keygraph/adapters/live.py +50 -0
  9. keygraph/adapters/metabase.py +78 -0
  10. keygraph/adapters/mongo.py +93 -0
  11. keygraph/adapters/mysql.py +30 -0
  12. keygraph/adapters/postgres.py +68 -0
  13. keygraph/adapters/sqlite.py +24 -0
  14. keygraph/adapters/sqlserver.py +65 -0
  15. keygraph/areas.py +225 -0
  16. keygraph/cli.py +497 -0
  17. keygraph/context.py +387 -0
  18. keygraph/docs_out.py +199 -0
  19. keygraph/explorer.py +128 -0
  20. keygraph/graph.py +163 -0
  21. keygraph/hubs.py +48 -0
  22. keygraph/infer.py +344 -0
  23. keygraph/mcp_server.py +147 -0
  24. keygraph/mirrors.py +109 -0
  25. keygraph/model.py +149 -0
  26. keygraph/notes.py +114 -0
  27. keygraph/overrides.py +112 -0
  28. keygraph/probe.py +119 -0
  29. keygraph/profile.py +262 -0
  30. keygraph/query.py +376 -0
  31. keygraph/report.py +92 -0
  32. keygraph/search.py +175 -0
  33. keygraph/static/DAGRE_LICENSE +19 -0
  34. keygraph/static/OFL-Inter.txt +93 -0
  35. keygraph/static/OFL-JetBrainsMono.txt +93 -0
  36. keygraph/static/dagre-1.1.4.min.js +801 -0
  37. keygraph/static/explorer.html +69 -0
  38. keygraph/static/explorer.js +1076 -0
  39. keygraph/static/favicon.svg +1 -0
  40. keygraph/static/keygraph-logo.svg +1 -0
  41. keygraph/static/keygraph-mark.svg +1 -0
  42. keygraph/static/keygraph.inline.css +843 -0
  43. keygraph/static/kg-diagram.js +267 -0
  44. keygraph/static/kg-theme.js +269 -0
  45. keygraph/summary.py +60 -0
  46. keygraph-0.2.0.dist-info/METADATA +334 -0
  47. keygraph-0.2.0.dist-info/RECORD +50 -0
  48. keygraph-0.2.0.dist-info/WHEEL +4 -0
  49. keygraph-0.2.0.dist-info/entry_points.txt +2 -0
  50. 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())