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/explorer.py ADDED
@@ -0,0 +1,128 @@
1
+ """Build the self-contained keygraph.html explorer.
2
+
3
+ The page is one file: the keygraph design system CSS (fonts embedded), dagre, the theme helpers, the diagram
4
+ renderer and the app script are inlined from static/ (copied there from design/ by `design/build.py --export-static`),
5
+ and the graph plus context is embedded as compact JSON (gzip + base64 when large). Nothing is fetched at runtime.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import gzip
12
+ import json
13
+ import re
14
+ from pathlib import Path
15
+
16
+ from .context import safe_source
17
+ from .graph import Graph
18
+ from .query import _is_log, table_info_line
19
+
20
+ STATIC = Path(__file__).parent / "static"
21
+ STATUS = {"declared": 0, "inferred": 1, "confirmed": 2, "added": 3, "probed": 4}
22
+ CARD = {"many-to-one": 0, "one-to-one": 1, "many-to-many": 2, "one-to-many": 3}
23
+ NOTE_KEYS = ("extra", "generated", "charset", "collation", "comment")
24
+
25
+
26
+ def _col(c: dict) -> list:
27
+ """[name, type, nullable, default|None, enum/set members|None, other details]."""
28
+ note = "; ".join(f"{k}: {c[k]}" for k in NOTE_KEYS if c.get(k))
29
+ return [c["name"], c["type"], int(c.get("nullable", True)), c.get("default"), c.get("values") or None, note]
30
+
31
+
32
+ def compact(graph: Graph, ctx_dump: dict | None = None) -> dict:
33
+ schemas = sorted({n["schema"] for n in graph.nodes.values()})
34
+ sidx = {s: i for i, s in enumerate(schemas)}
35
+ ids = sorted(graph.nodes)
36
+ tidx = {t: i for i, t in enumerate(ids)}
37
+ hubs = set(graph.data.get("hubs") or [])
38
+ tables = []
39
+ for t in ids:
40
+ n = graph.nodes[t]
41
+ ci = {c["name"]: i for i, c in enumerate(n["columns"])}
42
+ info = n.get("info") or {}
43
+ tables.append([
44
+ n["table"], sidx[n["schema"]], [_col(c) for c in n["columns"]],
45
+ [ci[c] for c in n["primary_key"] if c in ci],
46
+ [[i["name"], int(i["unique"]), int(i["primary"]), [ci[c] for c in i["columns"] if c in ci]] for i in n["indexes"]],
47
+ [[ci.get(p["id_column"], -1), ci.get(p["type_column"], -1)] for p in n["polymorphic"]],
48
+ table_info_line(n),
49
+ int(_is_log(graph, t)) | (2 if t in hubs else 0) | (4 if n.get("mirror_of") else 0),
50
+ info.get("rows_estimate"),
51
+ ])
52
+ edges = []
53
+ for e in graph.edges:
54
+ fi, ti = tidx[e["from"]], tidx[e["to"]]
55
+ fci = {c["name"]: i for i, c in enumerate(graph.nodes[e["from"]]["columns"])}
56
+ tci = {c["name"]: i for i, c in enumerate(graph.nodes[e["to"]]["columns"])}
57
+ edges.append([fi, ti, [fci[c] for c in e["from_columns"] if c in fci], [tci[c] for c in e["to_columns"] if c in tci],
58
+ e["confidence"], STATUS.get(e["status"], 1), CARD.get(e["cardinality"], 0),
59
+ int(e["ambiguous"]), e["evidence"], (1 if e.get("hub") else 0) | (2 if e.get("role") else 0)])
60
+ # area flags: 1 unconnected (no relations left after hubs), 2 mirror schema
61
+ areas = [[a["id"], a["name"], [tidx[t] for t in a["tables"] if t in tidx], int(bool(a.get("auto", True))),
62
+ (1 if a.get("unconnected") else 0) | (2 if a.get("mirror") else 0)]
63
+ for a in graph.data.get("areas") or []]
64
+ aidx = {a[0]: i for i, a in enumerate(areas)}
65
+ links = [[aidx[l["a"]], aidx[l["b"]], l["edges"]] for l in graph.data.get("area_links") or []
66
+ if l["a"] in aidx and l["b"] in aidx]
67
+ c = {"concepts": [], "rels": [], "cols": [], "metrics": [], "tn": []}
68
+ if ctx_dump:
69
+ for x in ctx_dump["concepts"]:
70
+ c["concepts"].append({"n": x["name"], "d": x["definition"], "dm": x["domain"], "sy": x["synonyms"], "v": int(x["verified"]),
71
+ "l": [[tidx[l["table"]], l["column"], l["role"], l["note"]] for l in x["links"] if l["table"] in tidx]})
72
+ c["rels"] = [[r["from"], r["rel"], r["to"], r["note"], int(r["verified"])] for r in ctx_dump["relations"]]
73
+ c["cols"] = [{"t": tidx[x["table"]], "c": x["column"], "m": x["meaning"], "u": x["unit"], "k": x["codes"], "nm": x["null_meaning"],
74
+ "pii": int(x["pii"]), "f": x["typical_filters"], "ih": x["index_hint"], "v": int(x["verified"]), "s": safe_source(x["source"])}
75
+ for x in ctx_dump["columns"] if x["table"] in tidx]
76
+ c["metrics"] = [{"n": x["name"], "d": x["definition"], "e": x["expression"], "g": x["grain"], "t": [tidx[t] for t in x["tables"] if t in tidx],
77
+ "f": x["filters"], "cv": x["caveats"], "v": int(x["verified"])} for x in ctx_dump["metrics"]]
78
+ c["tn"] = [{"t": tidx[x["table"]], "g": x["grain"], "fr": x["freshness"], "sd": x["soft_delete"], "dc": x["date_column"],
79
+ "ri": x["recommended_indexes"], "p": x["pitfalls"], "sm": x["summary"], "v": int(x["verified"]), "s": safe_source(x["source"])}
80
+ for x in ctx_dump["tables"] if x["table"] in tidx]
81
+ prof = {}
82
+ for key, p in (graph.data.get("profiles") or {}).items():
83
+ tid, col = key.rsplit(".", 1)
84
+ if tid in tidx:
85
+ names = [c["name"] for c in graph.nodes[tid]["columns"]]
86
+ if col in names:
87
+ if p.get("pii"): # PII: never embed raw values, whatever an older profile stored
88
+ p = {k: v for k, v in p.items() if k not in ("top_values", "numeric", "dates", "length")}
89
+ prof[f"{tidx[tid]}:{names.index(col)}"] = p
90
+ c["p"] = prof
91
+ return {"v": 2, "s": schemas, "t": tables, "e": edges, "a": areas, "al": links, "mi": graph.data.get("mirrors") or {},
92
+ "c": c, "st": graph.data["stats"]}
93
+
94
+
95
+ def _svg(name: str) -> str:
96
+ s = (STATIC / name).read_text(encoding="utf-8")
97
+ s = re.sub(r"<title>.*?</title>", "", s, flags=re.S)
98
+ return s.replace("#17181A", "var(--kg-text)").replace("#E2A317", "var(--kg-accent)")
99
+
100
+
101
+ def _js(name: str) -> str:
102
+ return (STATIC / name).read_text(encoding="utf-8").replace("</script", "<\\/script")
103
+
104
+
105
+ def build_html(graph: Graph, ctx_dump: dict | None = None, gzip_over: int = 300_000) -> str:
106
+ payload = json.dumps(compact(graph, ctx_dump), ensure_ascii=False, separators=(",", ":"))
107
+ if len(payload) > gzip_over:
108
+ data = "GZ:" + base64.b64encode(gzip.compress(payload.encode("utf-8"), 9)).decode("ascii")
109
+ else:
110
+ data = payload.replace("<", "\\u003c") # JSON-safe; no "</script" or "<!--" can end or alter the block
111
+ lic = "\n\n".join((STATIC / f).read_text(encoding="utf-8") for f in ("DAGRE_LICENSE", "OFL-Inter.txt", "OFL-JetBrainsMono.txt"))
112
+ fav = (STATIC / "favicon.svg").read_text(encoding="utf-8").replace("#", "%23").replace('"', "'").replace("\n", "")
113
+ logo = _svg("keygraph-logo.svg").replace("<svg ", '<svg style="fill:var(--kg-text)" ', 1)
114
+ mark = _svg("keygraph-mark.svg").replace("<svg ", '<svg width="24" height="24" ', 1)
115
+ parts = {
116
+ "LICENSES": lic.replace("--", "- -"),
117
+ "FAVICON": fav,
118
+ "CSS": (STATIC / "keygraph.inline.css").read_text(encoding="utf-8").replace("</style", "<\\/style"),
119
+ "LOGOS": json.dumps({"logo": logo, "mark": mark}).replace("</", "<\\/"),
120
+ "DAGRE": _js("dagre-1.1.4.min.js"),
121
+ "THEME": _js("kg-theme.js"),
122
+ "DIAGRAM": _js("kg-diagram.js"),
123
+ "APP": _js("explorer.js"),
124
+ "DATA": data,
125
+ }
126
+ tpl = (STATIC / "explorer.html").read_text(encoding="utf-8")
127
+ # one pass over the template only, so text inside the data or the scripts is never treated as a marker
128
+ return re.sub(r"/\*__([A-Z]+)__\*/", lambda m: parts[m.group(1)], tpl)
keygraph/graph.py ADDED
@@ -0,0 +1,163 @@
1
+ """Graph assembly, persistence and table-name resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import json
7
+ from pathlib import Path
8
+
9
+ from .infer import Inferer, Options, declared_edges, redundant_indexes
10
+ from .model import Schema, type_family
11
+ from .overrides import Overrides, apply_overrides
12
+
13
+ VERSION = 1
14
+
15
+
16
+ class AmbiguousTable(ValueError):
17
+ def __init__(self, name: str, candidates: list[str]):
18
+ super().__init__(f"'{name}' is ambiguous; use schema.table: " + ", ".join(candidates))
19
+ self.candidates = candidates
20
+
21
+
22
+ class UnknownTable(ValueError):
23
+ def __init__(self, name: str, suggestions: list[str]):
24
+ msg = f"unknown table '{name}'" + (f"; did you mean: {', '.join(suggestions)}" if suggestions else "")
25
+ super().__init__(msg)
26
+ self.suggestions = suggestions
27
+
28
+
29
+ def edge_id(e: dict) -> str:
30
+ return f"{e['from']}({','.join(e['from_columns'])})->{e['to']}({','.join(e['to_columns'])})"
31
+
32
+
33
+ def build_graph(schema: Schema, overrides: Overrides | None = None, options: Options | None = None) -> "Graph":
34
+ opts = options or Options()
35
+ if overrides:
36
+ overrides.configure(opts)
37
+ warnings: list[str] = []
38
+ inf = Inferer(schema, opts)
39
+ declared = declared_edges(schema, warnings)
40
+ inferred = inf.run()
41
+ taken = {(e["from"], tuple(e["from_columns"]), e["to"]) for e in declared}
42
+ edges = declared + [e for e in inferred if (e["from"], tuple(e["from_columns"]), e["to"]) not in taken]
43
+ nodes = []
44
+ for t in sorted(schema.tables.values(), key=lambda x: x.id):
45
+ nodes.append({
46
+ "id": t.id, "schema": t.schema, "table": t.name,
47
+ "info": t.info,
48
+ "columns": [{"name": c.name, "type": c.type, "family": type_family(c.type), "nullable": c.nullable,
49
+ **({"key": c.key} if c.key else {}),
50
+ **{k: v for k, v in (("default", c.default), ("extra", c.extra), ("charset", c.charset),
51
+ ("collation", c.collation), ("comment", c.comment), ("values", c.values),
52
+ ("generated", c.generated)) if v not in (None, "", [])}}
53
+ for c in sorted(t.columns, key=lambda c: (c.position, c.name))],
54
+ "primary_key": t.primary_key,
55
+ "unique_keys": [{"name": ix.name, "columns": ix.columns, **({"approximate": True} if ix.approximate else {})}
56
+ for ix in t.indexes if ix.unique and not ix.primary],
57
+ "indexes": [{"name": ix.name, "columns": ix.columns, "unique": ix.unique, "primary": ix.primary,
58
+ **({"approximate": True} if ix.approximate else {})}
59
+ for ix in sorted(t.indexes, key=lambda i: (not i.primary, i.name))],
60
+ "polymorphic": inf.polymorphic.get(t.id, []),
61
+ "unresolved_id_columns": sorted(inf.unresolved.get(t.id, [])),
62
+ "redundant_indexes": redundant_indexes(t),
63
+ })
64
+ data = {"version": VERSION, "nodes": nodes, "edges": edges, "warnings": warnings, "overrides": {}}
65
+ if overrides:
66
+ apply_overrides(data, overrides)
67
+ for e in data["edges"]:
68
+ e["id"] = edge_id(e)
69
+ data["edges"].sort(key=lambda e: e["id"])
70
+ data["warnings"] = sorted(set(data["warnings"]))
71
+ g = Graph(data)
72
+ g.refresh_stats()
73
+ return g
74
+
75
+
76
+ class Graph:
77
+ def __init__(self, data: dict):
78
+ self.data = data
79
+ self.nodes: dict[str, dict] = {n["id"]: n for n in data["nodes"]}
80
+ self.edges: list[dict] = data["edges"]
81
+ self._by_name: dict[str, list[str]] = {}
82
+ for nid, n in self.nodes.items():
83
+ self._by_name.setdefault(n["table"].lower(), []).append(nid)
84
+ self._adj: dict[str, list[dict]] | None = None
85
+
86
+ @classmethod
87
+ def load(cls, path: str | Path) -> "Graph":
88
+ return cls(json.loads(Path(path).read_text(encoding="utf-8")))
89
+
90
+ def save(self, path: str | Path) -> None:
91
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
92
+ Path(path).write_text(json.dumps(self.data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
93
+
94
+ def refresh_stats(self) -> None:
95
+ by_kind: dict[str, int] = {}
96
+ bands = {"1.0": 0, "0.8-0.99": 0, "0.6-0.79": 0, "0.4-0.59": 0, "<0.4": 0}
97
+ for e in self.edges:
98
+ by_kind[e["kind"]] = by_kind.get(e["kind"], 0) + 1
99
+ c = e["confidence"]
100
+ bands["1.0" if c >= 1 else "0.8-0.99" if c >= .8 else "0.6-0.79" if c >= .6 else "0.4-0.59" if c >= .4 else "<0.4"] += 1
101
+ self.data["stats"] = {
102
+ "tables": len(self.nodes), "columns": sum(len(n["columns"]) for n in self.nodes.values()),
103
+ "schemas": len({n["schema"] for n in self.nodes.values()}),
104
+ "indexes": sum(len(n["indexes"]) for n in self.nodes.values()),
105
+ "edges": len(self.edges), "edges_by_kind": by_kind, "edges_by_confidence": bands,
106
+ "polymorphic_columns": sum(len(n["polymorphic"]) for n in self.nodes.values()),
107
+ }
108
+ self._adj = None
109
+
110
+ # --- name resolution -------------------------------------------------------
111
+ def resolve(self, name: str) -> str:
112
+ if name in self.nodes:
113
+ return name
114
+ low = name.lower()
115
+ for nid in self.nodes:
116
+ if nid.lower() == low:
117
+ return nid
118
+ if "." not in name:
119
+ hits = self._by_name.get(low, [])
120
+ if len(hits) == 1:
121
+ return hits[0]
122
+ if len(hits) > 1:
123
+ raise AmbiguousTable(name, sorted(hits))
124
+ sug = difflib.get_close_matches(name, list(self.nodes), n=4, cutoff=0.6)
125
+ sug += [h for k in difflib.get_close_matches(low, list(self._by_name), n=3, cutoff=0.7) for h in self._by_name[k]]
126
+ raise UnknownTable(name, sorted(set(sug)))
127
+
128
+ def split_column(self, ref: str) -> tuple[str, str | None]:
129
+ """Parse 'table', 'table.col' or 'schema.table.col' into (table_id, column or None)."""
130
+ try:
131
+ return self.resolve(ref), None
132
+ except (UnknownTable, AmbiguousTable) as first:
133
+ if "." not in ref:
134
+ raise
135
+ head, col = ref.rsplit(".", 1)
136
+ tid = self.resolve(head)
137
+ if col not in {c["name"] for c in self.nodes[tid]["columns"]}:
138
+ raise ValueError(f"table {tid} has no column '{col}'") from first
139
+ return tid, col
140
+
141
+ # --- adjacency -------------------------------------------------------------
142
+ def adjacency(self) -> dict[str, list[dict]]:
143
+ if self._adj is None:
144
+ adj: dict[str, list[dict]] = {n: [] for n in self.nodes}
145
+ for e in self.edges:
146
+ adj.setdefault(e["from"], []).append(oriented(e, False))
147
+ if e["to"] != e["from"]:
148
+ adj.setdefault(e["to"], []).append(oriented(e, True))
149
+ self._adj = adj
150
+ return self._adj
151
+
152
+
153
+ def flip_card(c: str) -> str:
154
+ return {"many-to-one": "one-to-many", "one-to-many": "many-to-one"}.get(c, c)
155
+
156
+
157
+ def oriented(e: dict, reverse: bool) -> dict:
158
+ """View an edge from one end: near/far table and columns as walked."""
159
+ if not reverse:
160
+ return {"edge": e, "near": e["from"], "near_cols": e["from_columns"], "far": e["to"],
161
+ "far_cols": e["to_columns"], "cardinality": e["cardinality"], "reverse": False}
162
+ return {"edge": e, "near": e["to"], "near_cols": e["to_columns"], "far": e["from"],
163
+ "far_cols": e["from_columns"], "cardinality": flip_card(e["cardinality"]), "reverse": True}
keygraph/hubs.py ADDED
@@ -0,0 +1,48 @@
1
+ """Hub tables: tables so many others point to that drawing their edges would swamp every view."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from keygraph.graph import Graph
8
+
9
+ ROLE_PATTERNS = [r"(^|_)(created|updated|deleted|modified|approved|rejected|changed|edited)_by(_id)?$",
10
+ r"^(owner|author|creator|editor|operator|admin)_id$", r"_by_id$", r"_by$"]
11
+
12
+
13
+ def is_role_column(name: str, patterns=ROLE_PATTERNS) -> bool:
14
+ low = name.lower()
15
+ return any(re.search(p, low) for p in patterns)
16
+
17
+
18
+ def _edge_is_role(e: dict, patterns) -> bool:
19
+ return any(is_role_column(c, patterns) for c in e["from_columns"])
20
+
21
+
22
+ def detect_hubs(graph: Graph, percentile: float = 99, role_share: float = 0.6, include=(), exclude=(),
23
+ patterns=ROLE_PATTERNS) -> set[str]:
24
+ incoming: dict[str, int] = {}
25
+ role_in: dict[str, int] = {}
26
+ mirrors = {t for t, n in graph.nodes.items() if n.get("mirror_of")} # a copy's edges would double the counts
27
+ for e in graph.edges:
28
+ if e["from"] == e["to"] or e["from"] in mirrors or e["to"] in mirrors:
29
+ continue
30
+ incoming[e["to"]] = incoming.get(e["to"], 0) + 1
31
+ if _edge_is_role(e, patterns):
32
+ role_in[e["to"]] = role_in.get(e["to"], 0) + 1
33
+ counts = sorted(incoming.values())
34
+ hubs: set[str] = set()
35
+ if counts:
36
+ cut = counts[min(len(counts) - 1, int(len(counts) * percentile / 100))]
37
+ hubs |= {t for t, n in incoming.items() if n >= max(cut, 5)}
38
+ hubs |= {t for t, n in incoming.items() if n >= 5 and role_in.get(t, 0) / n >= role_share}
39
+ hubs |= {graph.resolve(t) for t in include}
40
+ hubs -= {graph.resolve(t) for t in exclude}
41
+ return hubs
42
+
43
+
44
+ def mark_hubs(graph: Graph, hubs: set[str], patterns=ROLE_PATTERNS) -> None:
45
+ for e in graph.edges:
46
+ e["role"] = _edge_is_role(e, patterns)
47
+ e["hub"] = e["to"] in hubs and e["from"] != e["to"]
48
+ graph.data["hubs"] = sorted(hubs)