dbdocs 0.0.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,181 @@
1
+ """Column-level lineage: trace each model column back to its source columns.
2
+
3
+ For every model we parse its **compiled** SQL with sqlglot, qualify it against a
4
+ schema built from the dbt catalog (so ``SELECT *`` and unqualified columns
5
+ resolve), then walk each output column's lineage tree to its leaf table columns
6
+ and map those tables back to dbt unique_ids. The heavy lifting lives in the
7
+ vendored :mod:`dbdocs.extract._sqlglot_lineage` (a self-contained lineage
8
+ builder over sqlglot's optimizer).
9
+
10
+ Design: **fail-soft per model.** A single model with SQL sqlglot can't parse
11
+ must never sink the whole ``generate`` — it's caught, logged, and skipped, and
12
+ the run reports how many were skipped.
13
+ """
14
+
15
+ from typing import Any
16
+
17
+ from sqlglot import exp
18
+ from sqlglot.errors import SqlglotError
19
+
20
+ from dbdocs.core.artifacts import db_schema, node_name
21
+ from dbdocs.core.exceptions import LineageError
22
+ from dbdocs.core.log import logger
23
+ from dbdocs.extract._sqlglot_lineage import Node, lineage
24
+
25
+ #: dbt adapter_type → sqlglot dialect, when the names differ. Most match 1:1.
26
+ _DIALECT_ALIASES = {
27
+ "databricks": "spark",
28
+ }
29
+
30
+
31
+ def _to_dialect(adapter_type: "str | None") -> "str | None":
32
+ if not adapter_type:
33
+ return None
34
+ return _DIALECT_ALIASES.get(adapter_type, adapter_type)
35
+
36
+
37
+ class ColumnLineageExtractor:
38
+ """Build the ``columnLineage`` map for a dbt project's models.
39
+
40
+ The output maps a fully-qualified output column to the upstream columns it is
41
+ derived from::
42
+
43
+ {"model.shop.customers.customer_id": [{"node": "model.shop.stg_customers",
44
+ "column": "customer_id"}, ...]}
45
+ """
46
+
47
+ def __init__(self, manifest: Any, catalog: Any, dialect: "str | None" = None) -> None:
48
+ self.manifest = manifest
49
+ self.catalog = catalog
50
+ self.dialect = _to_dialect(dialect)
51
+ self.schema = self._schema_from_catalog()
52
+ # Map a lower-cased ``db.schema.table`` relation back to its unique_id.
53
+ self._relation_to_node = self._relation_index()
54
+ self.skipped = 0
55
+
56
+ def extract(self) -> dict:
57
+ """Return the ``columnLineage`` map across all models (fail-soft)."""
58
+ result: dict = {}
59
+ for unique_id, model in (getattr(self.manifest, "nodes", {}) or {}).items():
60
+ if not str(unique_id).startswith("model."):
61
+ continue
62
+ compiled = getattr(model, "compiled_code", "") or ""
63
+ if not compiled.strip():
64
+ continue
65
+ try:
66
+ self._extract_model(unique_id, compiled, result)
67
+ except (SqlglotError, LineageError, KeyError, ValueError, RecursionError) as exc:
68
+ self.skipped += 1
69
+ logger.warning("Column lineage skipped for %s: %s", node_name(unique_id), exc)
70
+ if self.skipped:
71
+ logger.info("Column lineage: skipped %s model(s) that failed to parse.", self.skipped)
72
+ return result
73
+
74
+ def _extract_model(self, unique_id: str, compiled: str, result: dict) -> None:
75
+ node = self.manifest.nodes[unique_id]
76
+ output_columns = [c for c in (getattr(node, "columns", {}) or {})]
77
+ # Fall back to the catalog's column list when the manifest has none.
78
+ if not output_columns:
79
+ catalog_node = (getattr(self.catalog, "nodes", {}) or {}).get(unique_id)
80
+ output_columns = (
81
+ list(getattr(catalog_node, "columns", {}) or {}) if catalog_node else []
82
+ )
83
+ for column in output_columns:
84
+ try:
85
+ root = lineage(column, compiled, schema=self.schema, dialect=self.dialect)
86
+ except SqlglotError:
87
+ # One unresolvable column shouldn't drop the rest of the model.
88
+ continue
89
+ upstream = self._leaf_columns(root)
90
+ if upstream:
91
+ result[f"{unique_id}.{column}"] = upstream
92
+
93
+ def _leaf_columns(self, root: Node) -> list:
94
+ """Collect distinct upstream ``{node, column}`` leaves of a lineage tree.
95
+
96
+ A leaf is a node whose source is a real ``Table`` (not a CTE/subquery
97
+ scope) that we can map back to a dbt node. The root itself is skipped.
98
+ """
99
+ seen = set()
100
+ upstream = []
101
+ for node in root.walk():
102
+ if node is root:
103
+ continue
104
+ source = node.source
105
+ if not isinstance(source, exp.Table):
106
+ continue
107
+ mapped = self._map_table(source)
108
+ if mapped is None:
109
+ continue
110
+ column = node_name(node.name)
111
+ key = (mapped, column)
112
+ if key in seen:
113
+ continue
114
+ seen.add(key)
115
+ upstream.append({"node": mapped, "column": column})
116
+ return upstream
117
+
118
+ def _map_table(self, table: exp.Table) -> "str | None":
119
+ catalog = table.catalog
120
+ db = table.db
121
+ name = table.name
122
+ candidates = [
123
+ f"{catalog}.{db}.{name}",
124
+ f"{db}.{name}",
125
+ name,
126
+ ]
127
+ for candidate in candidates:
128
+ mapped = self._relation_to_node.get(candidate.lower().strip("."))
129
+ if mapped:
130
+ return mapped
131
+ return None
132
+
133
+ def _relation_index(self) -> dict:
134
+ """Map ``db.schema.table`` (and shorter forms) → dbt unique_id."""
135
+ index: dict = {}
136
+ for unique_id, entity in self._all_entities():
137
+ database, schema = db_schema(entity)
138
+ table = getattr(entity, "alias", None) or getattr(entity, "name", None)
139
+ if not table:
140
+ continue
141
+ full = f"{database}.{schema}.{table}".lower()
142
+ index[full] = unique_id
143
+ index.setdefault(f"{schema}.{table}".lower(), unique_id)
144
+ index.setdefault(str(table).lower(), unique_id)
145
+ relation = getattr(entity, "relation_name", None)
146
+ if relation:
147
+ index.setdefault(str(relation).replace('"', "").lower(), unique_id)
148
+ return index
149
+
150
+ def _schema_from_catalog(self) -> dict:
151
+ """Build sqlglot's nested ``{db: {schema: {table: {col: type}}}}`` schema."""
152
+ schema: dict = {}
153
+ for _, entity, columns in self._catalog_entities():
154
+ database, db_schema_name = db_schema(entity)
155
+ table = getattr(entity, "alias", None) or getattr(entity, "name", None)
156
+ if not table:
157
+ continue
158
+ col_types = {name: (col.type or "UNKNOWN") for name, col in columns.items()}
159
+ schema.setdefault(database, {}).setdefault(db_schema_name, {})[table] = col_types
160
+ return schema
161
+
162
+ def _all_entities(self):
163
+ yield from (getattr(self.manifest, "nodes", {}) or {}).items()
164
+ yield from (getattr(self.manifest, "sources", {}) or {}).items()
165
+
166
+ def _catalog_entities(self):
167
+ """Yield ``(unique_id, manifest_entity, catalog_columns)`` for schema build.
168
+
169
+ Pairs the catalog's column list (types) with the manifest entity (the
170
+ authoritative database/schema, read via ``schema_``).
171
+ """
172
+ manifest_nodes = getattr(self.manifest, "nodes", {}) or {}
173
+ manifest_sources = getattr(self.manifest, "sources", {}) or {}
174
+ for unique_id, catalog_node in (getattr(self.catalog, "nodes", {}) or {}).items():
175
+ entity = manifest_nodes.get(unique_id)
176
+ if entity is not None:
177
+ yield unique_id, entity, getattr(catalog_node, "columns", {}) or {}
178
+ for unique_id, catalog_source in (getattr(self.catalog, "sources", {}) or {}).items():
179
+ entity = manifest_sources.get(unique_id)
180
+ if entity is not None:
181
+ yield unique_id, entity, getattr(catalog_source, "columns", {}) or {}
dbdocs/extract/erd.py ADDED
@@ -0,0 +1,102 @@
1
+ """Structured ERD data via dbterd's ``json`` target.
2
+
3
+ dbterd's built-in targets emit diagram text; the SPA renders its ERD with React
4
+ Flow, which needs structured node/edge data. We register a ``json`` target
5
+ (:mod:`dbdocs.extract.erd_json`) and parse its ``{tables, relationships}`` output
6
+ into the SPA's ``{nodes, edges}`` — entities with columns (PK/FK flags) and
7
+ foreign-key edges between them, all keyed by dbt unique_id.
8
+ """
9
+
10
+ import json
11
+
12
+ from dbterd.api import DbtErd
13
+
14
+ # Importing the module registers the "json" target with dbterd's PluginRegistry.
15
+ from dbdocs.extract import erd_json # noqa: F401
16
+
17
+
18
+ def build_erd(dbterd_options: "dict | None" = None, artifacts_dir: "str | None" = None) -> DbtErd:
19
+ """Build the ERD generator (json target) from dbdocs' ``dbterd`` options.
20
+
21
+ ``dbterd_options`` is the ``dbterd:`` block of ``dbdocs.yml`` (``algo``,
22
+ ``entity_name_format``, ``resource_type``, ``select``, …) passed straight to
23
+ ``DbtErd``. We force ``target="json"`` — the SPA needs structured data — but
24
+ let everything else come from the project's config so the ERD matches what
25
+ the team configured. (Config lives in ``dbdocs.yml``, not a separate
26
+ ``.dbterd.yml``.)
27
+
28
+ ``artifacts_dir`` is the dbt target dir (``config.target_dir``). dbterd reads
29
+ the manifest/catalog directly from this dir; without it dbterd would default
30
+ to ``./target`` and ignore the configured ``target_dir``. An explicit
31
+ ``artifacts_dir`` in ``dbterd_options`` still wins.
32
+ """
33
+ dbterd_kwargs = {k: v for k, v in (dbterd_options or {}).items() if k != "target"}
34
+ if artifacts_dir is not None:
35
+ dbterd_kwargs.setdefault("artifacts_dir", artifacts_dir)
36
+ return DbtErd(target="json", **dbterd_kwargs)
37
+
38
+
39
+ def build_erd_data(erd: DbtErd) -> dict:
40
+ """Parse the json target into ``{"nodes": [...], "edges": [...]}``.
41
+
42
+ Nodes are entities (with columns, ``is_primary_key``/``is_foreign_key`` flags
43
+ and the resolved dbt unique_id); edges are foreign-key relationships between
44
+ them. dbterd's relationships reference tables by *name*, so we map those back
45
+ to unique_ids via each table's ``node_name``.
46
+ """
47
+ payload = json.loads(erd.get_erd())
48
+ tables = payload.get("tables", [])
49
+ relationships = payload.get("relationships", [])
50
+
51
+ # table name (as dbterd refers to it in relationships) → dbt unique_id.
52
+ name_to_id = {t["name"]: (t.get("node_name") or t["name"]) for t in tables}
53
+
54
+ edges, fk_columns = _build_edges(relationships, name_to_id)
55
+ nodes = [_build_node(t, fk_columns.get(t.get("node_name") or t["name"], set())) for t in tables]
56
+ return {"nodes": nodes, "edges": edges}
57
+
58
+
59
+ def _build_edges(relationships: list, name_to_id: dict) -> "tuple[list, dict]":
60
+ """Map relationships → edges and collect each node's FK column names."""
61
+ edges = []
62
+ fk_columns: dict = {}
63
+ for index, rel in enumerate(relationships):
64
+ parent_name, child_name = rel["table_map"]
65
+ parent_cols, child_cols = rel["column_map"]
66
+ source = name_to_id.get(parent_name, parent_name)
67
+ target = name_to_id.get(child_name, child_name)
68
+ # The child side holds the foreign key columns.
69
+ fk_columns.setdefault(target, set()).update(child_cols)
70
+ edges.append(
71
+ {
72
+ "id": rel.get("name") or f"e{index}",
73
+ "source": source,
74
+ "target": target,
75
+ "from_columns": list(parent_cols),
76
+ "to_columns": list(child_cols),
77
+ "label": rel.get("relationship_label"),
78
+ "type": rel.get("type", ""),
79
+ }
80
+ )
81
+ return edges, fk_columns
82
+
83
+
84
+ def _build_node(table: dict, fk_cols: set) -> dict:
85
+ node_id = table.get("node_name") or table["name"]
86
+ return {
87
+ "id": node_id,
88
+ "label": table["name"],
89
+ "database": table.get("database") or "",
90
+ "schema": table.get("schema") or "",
91
+ "resource_type": table.get("resource_type") or "model",
92
+ "columns": [
93
+ {
94
+ "name": c["name"],
95
+ "type": c.get("data_type") or "",
96
+ "description": c.get("description") or "",
97
+ "is_primary_key": bool(c.get("is_primary_key")),
98
+ "is_foreign_key": c["name"] in fk_cols,
99
+ }
100
+ for c in table.get("columns", [])
101
+ ],
102
+ }
@@ -0,0 +1,80 @@
1
+ """A dbterd ``json`` target adapter: structured tables + relationships.
2
+
3
+ dbterd's built-in targets emit diagram *text* (Mermaid, DBML, …). The dbdocs SPA
4
+ renders its ERD with React Flow, which needs structured node/edge data, not a
5
+ diagram string. Registering this adapter makes ``DbtErd(target="json").get_erd()``
6
+ return a JSON document of ``{tables, relationships}`` that ``erd.build_erd_data``
7
+ turns into the SPA's ``{nodes, edges}``.
8
+
9
+ The ``Table``/``Column``/``Ref`` → dict serializers are pure and tested in
10
+ isolation; the adapter is the thin dbterd-contract shell over them.
11
+ """
12
+
13
+ import json
14
+ from typing import Any
15
+
16
+ from dbterd.core.adapters.target import BaseTargetAdapter
17
+ from dbterd.core.models import Column, Ref, Table
18
+ from dbterd.core.registry.decorators import register_target
19
+
20
+
21
+ def column_to_dict(column: Column) -> dict:
22
+ """A dbterd ``Column`` as a plain dict (name, type, description, PK flag)."""
23
+ return {
24
+ "name": column.name,
25
+ "data_type": column.data_type,
26
+ "description": column.description,
27
+ "is_primary_key": bool(getattr(column, "is_primary_key", False)),
28
+ }
29
+
30
+
31
+ def table_to_dict(table: Table) -> dict:
32
+ """A dbterd ``Table`` as a plain dict, keyed for ERD rendering."""
33
+ return {
34
+ "name": table.name,
35
+ "database": table.database,
36
+ "schema": table.schema,
37
+ "resource_type": table.resource_type,
38
+ "node_name": table.node_name,
39
+ "raw_sql": table.raw_sql,
40
+ "description": table.description,
41
+ "label": table.label,
42
+ "columns": [column_to_dict(c) for c in (table.columns or [])],
43
+ }
44
+
45
+
46
+ def relationship_to_dict(ref: Ref) -> dict:
47
+ """A dbterd ``Ref`` as a plain dict: endpoints + the joined columns."""
48
+ parent, child = ref.table_map
49
+ parent_cols, child_cols = ref.column_map
50
+ return {
51
+ "name": ref.name,
52
+ "type": ref.type,
53
+ "table_map": [parent, child],
54
+ "column_map": [list(parent_cols), list(child_cols)],
55
+ "relationship_label": getattr(ref, "relationship_label", None),
56
+ }
57
+
58
+
59
+ @register_target("json", description="Structured JSON of tables and relationships")
60
+ class JsonAdapter(BaseTargetAdapter):
61
+ """Emit dbterd tables + relationships as one structured JSON document."""
62
+
63
+ file_extension = ".json"
64
+ default_filename = "output.json"
65
+
66
+ def build_erd(self, tables: list, relationships: list, **kwargs: Any) -> str:
67
+ payload = {
68
+ "tables": [table_to_dict(t) for t in tables],
69
+ "relationships": [relationship_to_dict(r) for r in relationships],
70
+ }
71
+ return json.dumps(payload)
72
+
73
+ def format_table(self, table: Table, **kwargs: Any) -> str:
74
+ return json.dumps(table_to_dict(table))
75
+
76
+ def format_relationship(self, relationship: Ref, **kwargs: Any) -> str:
77
+ return json.dumps(relationship_to_dict(relationship))
78
+
79
+ def get_rel_symbol(self, relationship_type: str) -> str:
80
+ return ""
@@ -0,0 +1,72 @@
1
+ """Node-level lineage (the DAG) from a dbt manifest.
2
+
3
+ ``LineageGraph`` turns the manifest's ``parent_map`` (falling back to per-node
4
+ ``depends_on.nodes``) into directed parent→child edges plus adjacency maps,
5
+ restricted to the nodes the SPA actually surfaces (models/seeds/snapshots/
6
+ sources) so test/macro dependencies don't dangle. The result feeds the
7
+ interactive DAG view.
8
+ """
9
+
10
+ from typing import Any
11
+
12
+ from dbdocs.core.artifacts import NODE_PREFIXES
13
+
14
+
15
+ class LineageGraph:
16
+ """The project lineage as ``{edges, parents, children}`` over surfaced nodes."""
17
+
18
+ def __init__(self, manifest: Any, node_ids: "set | None" = None) -> None:
19
+ self.manifest = manifest
20
+ #: Restrict edges to these ids. Defaults to models/seeds/snapshots +
21
+ #: sources derived from the manifest, matching ``nodes.build_nodes``.
22
+ self.node_ids = node_ids if node_ids is not None else self._default_node_ids()
23
+
24
+ def _default_node_ids(self) -> set:
25
+ ids = {
26
+ uid
27
+ for uid in (getattr(self.manifest, "nodes", {}) or {})
28
+ if str(uid).startswith(NODE_PREFIXES)
29
+ }
30
+ ids.update(getattr(self.manifest, "sources", {}) or {})
31
+ return ids
32
+
33
+ def build(self) -> dict:
34
+ """Return ``{"edges": [...], "parents": {...}, "children": {...}}``."""
35
+ edges = self._edges()
36
+ parents: dict = {n: [] for n in self.node_ids}
37
+ children: dict = {n: [] for n in self.node_ids}
38
+ for edge in edges:
39
+ parents[edge["target"]].append(edge["source"])
40
+ children[edge["source"]].append(edge["target"])
41
+ return {"edges": edges, "parents": parents, "children": children}
42
+
43
+ def _edges(self) -> list:
44
+ seen = set()
45
+ edges = []
46
+ for child, raw_parents in self._parent_pairs():
47
+ if child not in self.node_ids:
48
+ continue
49
+ for parent in raw_parents:
50
+ if parent not in self.node_ids:
51
+ continue
52
+ key = (parent, child)
53
+ if key in seen:
54
+ continue
55
+ seen.add(key)
56
+ edges.append({"source": parent, "target": child})
57
+ return edges
58
+
59
+ def _parent_pairs(self):
60
+ parent_map = getattr(self.manifest, "parent_map", None)
61
+ if parent_map:
62
+ yield from parent_map.items()
63
+ return
64
+ for unique_id in self.node_ids:
65
+ entity = self._lookup(unique_id)
66
+ depends_on = getattr(entity, "depends_on", None)
67
+ yield unique_id, list(getattr(depends_on, "nodes", []) or [])
68
+
69
+ def _lookup(self, unique_id: str) -> Any:
70
+ if str(unique_id).startswith("source."):
71
+ return (getattr(self.manifest, "sources", {}) or {}).get(unique_id)
72
+ return (getattr(self.manifest, "nodes", {}) or {}).get(unique_id)
@@ -0,0 +1,119 @@
1
+ """Extract the SPA's ``nodes`` and ``tree`` data from dbt artifacts.
2
+
3
+ ``build_nodes`` flattens every model/source/seed/snapshot into a display record
4
+ (columns merged from manifest descriptions + catalog types, transformation code,
5
+ resolved macros). ``build_tree`` groups those into the ``database → schema``
6
+ navigation tree. Pure functions — no I/O, no dbterd calls beyond reading the
7
+ already-parsed objects — so they're trivially testable with lightweight fakes.
8
+ """
9
+
10
+ from typing import Any
11
+
12
+ from dbdocs.core.artifacts import NODE_PREFIXES, db_schema, node_name
13
+
14
+
15
+ def _columns(model: Any, catalog_node: Any) -> list:
16
+ """Merge manifest column metadata (description/tags) with catalog types.
17
+
18
+ Iterates the catalog's columns (the warehouse truth for which columns exist
19
+ and their types) and layers on the manifest description/tags when present.
20
+ Newlines in descriptions become ``<br>`` so they survive HTML rendering.
21
+ """
22
+ manifest_columns = getattr(model, "columns", {}) or {}
23
+ catalog_columns = getattr(catalog_node, "columns", {}) or {} if catalog_node else {}
24
+ columns = []
25
+ for name in catalog_columns:
26
+ manifest_column = manifest_columns.get(name)
27
+ description = getattr(manifest_column, "description", "") or "" if manifest_column else ""
28
+ columns.append(
29
+ {
30
+ "name": name,
31
+ "type": catalog_columns[name].type,
32
+ "tags": (getattr(manifest_column, "tags", []) or []) if manifest_column else [],
33
+ "description": description.replace("\n", "<br>"),
34
+ }
35
+ )
36
+ return columns
37
+
38
+
39
+ def macros_used(manifest: Any, node: Any) -> list:
40
+ """The macros a node depends on, resolved to ``{name, package, sql}`` dicts.
41
+
42
+ ``depends_on.macros`` holds macro unique_ids; each is looked up in
43
+ ``manifest.macros``. Project macros come first (what a reader most wants),
44
+ then everything else, each group name-sorted.
45
+ """
46
+ macros = getattr(manifest, "macros", {}) or {}
47
+ depends_on = getattr(node, "depends_on", None)
48
+ macro_ids = list(getattr(depends_on, "macros", []) or [])
49
+ resolved = []
50
+ for macro_id in macro_ids:
51
+ macro = macros.get(macro_id)
52
+ if macro is None:
53
+ continue
54
+ resolved.append(
55
+ {
56
+ "name": getattr(macro, "name", node_name(macro_id)),
57
+ "package": getattr(macro, "package_name", "") or "",
58
+ "sql": getattr(macro, "macro_sql", "") or "",
59
+ }
60
+ )
61
+ project_pkg = getattr(node, "package_name", "") or ""
62
+ resolved.sort(key=lambda m: (m["package"] != project_pkg, m["package"], m["name"]))
63
+ return resolved
64
+
65
+
66
+ def _node_record(unique_id: str, entity: Any, catalog_node: Any, resource_type: str, manifest: Any):
67
+ database, schema = db_schema(entity)
68
+ return {
69
+ "id": unique_id,
70
+ "name": getattr(entity, "name", node_name(unique_id)),
71
+ "label": node_name(unique_id),
72
+ "resource_type": resource_type,
73
+ "database": database,
74
+ "schema": schema,
75
+ "package": getattr(entity, "package_name", "") or "",
76
+ "description": getattr(entity, "description", "") or "",
77
+ "tags": list(getattr(entity, "tags", []) or []),
78
+ "relation_name": getattr(entity, "relation_name", "") or "",
79
+ "columns": _columns(entity, catalog_node),
80
+ "language": getattr(entity, "language", "") or "",
81
+ "raw_code": getattr(entity, "raw_code", "") or "",
82
+ "compiled_code": getattr(entity, "compiled_code", "") or "",
83
+ "macros": macros_used(manifest, entity),
84
+ }
85
+
86
+
87
+ def build_nodes(manifest: Any, catalog: Any) -> dict:
88
+ """Return the ``nodes`` dict keyed by unique_id (models + sources)."""
89
+ catalog_nodes = getattr(catalog, "nodes", {}) or {}
90
+ catalog_sources = getattr(catalog, "sources", {}) or {}
91
+ nodes: dict = {}
92
+ for unique_id, entity in (getattr(manifest, "nodes", {}) or {}).items():
93
+ if not str(unique_id).startswith(NODE_PREFIXES):
94
+ continue
95
+ resource_type = str(unique_id).split(".")[0]
96
+ nodes[unique_id] = _node_record(
97
+ unique_id, entity, catalog_nodes.get(unique_id), resource_type, manifest
98
+ )
99
+ for unique_id, entity in (getattr(manifest, "sources", {}) or {}).items():
100
+ nodes[unique_id] = _node_record(
101
+ unique_id, entity, catalog_sources.get(unique_id), "source", manifest
102
+ )
103
+ return nodes
104
+
105
+
106
+ def build_tree(nodes: dict) -> dict:
107
+ """Group node ids into an ordered ``{database: {schema: [ids]}}`` nav tree."""
108
+ by_database: dict = {}
109
+ for unique_id, record in nodes.items():
110
+ database = record["database"]
111
+ schema = record["schema"]
112
+ by_database.setdefault(database, {}).setdefault(schema, []).append(unique_id)
113
+ return {
114
+ database: {
115
+ schema: sorted(by_database[database][schema], key=lambda i: nodes[i]["label"])
116
+ for schema in sorted(by_database[database])
117
+ }
118
+ for database in sorted(by_database)
119
+ }
dbdocs/main.py ADDED
@@ -0,0 +1,6 @@
1
+ from dbdocs.cli import main as cli
2
+
3
+
4
+ def main():
5
+ """dbdocs entrypoint"""
6
+ cli.dbdocs()
File without changes