dbdocs 1.7.0__tar.gz → 1.8.0__tar.gz
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.
- {dbdocs-1.7.0 → dbdocs-1.8.0}/PKG-INFO +15 -5
- {dbdocs-1.7.0 → dbdocs-1.8.0}/README.md +9 -1
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/cli/main.py +36 -5
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/core/artifacts.py +16 -1
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/core/config.py +16 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/column_lineage.py +7 -12
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/deploy.py +8 -2
- dbdocs-1.8.0/dbdocs/sources/__init__.py +0 -0
- dbdocs-1.8.0/dbdocs/sources/factory.py +24 -0
- dbdocs-1.8.0/dbdocs/sources/info_schema/__init__.py +0 -0
- dbdocs-1.8.0/dbdocs/sources/info_schema/builder.py +65 -0
- dbdocs-1.8.0/dbdocs/sources/info_schema/reader.py +45 -0
- dbdocs-1.8.0/dbdocs/sources/info_schema/synth.py +604 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/pyproject.toml +6 -2
- {dbdocs-1.7.0 → dbdocs-1.8.0}/.gitignore +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/LICENSE +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/__init__.py +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/__main__.py +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/cli/__init__.py +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/core/__init__.py +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/core/exceptions.py +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/core/log.py +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/__init__.py +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/_sqlglot_lineage.py +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/erd.py +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/graph.py +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/health/__init__.py +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/health/dimensions.py +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/health/extractor.py +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/health/rules/__init__.py +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/health/rules/base.py +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/health/rules/dimensions/__init__.py +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/health/rules/dimensions/documentation.py +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/health/rules/dimensions/governance.py +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/health/rules/dimensions/modeling.py +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/health/rules/dimensions/performance.py +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/health/rules/dimensions/structure.py +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/health/rules/dimensions/testing.py +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/health/rules/registry.py +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/nodes.py +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/tests_index.py +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/main.py +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/__init__.py +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/api_schema.py +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/builder.py +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/css/icons.css +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/css/style.css +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/favicon.svg +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/graph/index.css +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/graph/index.js +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/js/app.js +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/js/data/data.js +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/js/service/service.js +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/js/ui/dom.js +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/js/ui/overlays.js +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/js/ui/ui.js +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/vendor/marked.min.js +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/vendor/minisearch.min.js +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/bundle/index.html +0 -0
- {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/inject.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: dbdocs
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.8.0
|
|
4
4
|
Summary: Alternative dbt docs site: Catalog + ERD + column-level lineage
|
|
5
5
|
Project-URL: Homepage, https://github.com/datnguye/dbt-docs
|
|
6
6
|
Project-URL: Repository, https://github.com/datnguye/dbt-docs
|
|
@@ -14,12 +14,14 @@ Classifier: Topic :: Software Development :: Documentation
|
|
|
14
14
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
15
|
Classifier: Topic :: Software Development :: Quality Assurance
|
|
16
16
|
Requires-Python: >=3.10
|
|
17
|
-
Requires-Dist: artifact-parser[dbt]>=1.
|
|
17
|
+
Requires-Dist: artifact-parser[dbt]>=1.1.0
|
|
18
18
|
Requires-Dist: click>=8.1
|
|
19
|
-
Requires-Dist: dbterd>=1.
|
|
19
|
+
Requires-Dist: dbterd>=1.35.1
|
|
20
20
|
Requires-Dist: pydantic>=2.13.4
|
|
21
21
|
Requires-Dist: pyyaml>=6.0
|
|
22
22
|
Requires-Dist: sqlglot>=25
|
|
23
|
+
Provides-Extra: info-schema
|
|
24
|
+
Requires-Dist: duckdb>=1.1; extra == 'info-schema'
|
|
23
25
|
Description-Content-Type: text/markdown
|
|
24
26
|
|
|
25
27
|
<p align="center">
|
|
@@ -71,12 +73,20 @@ Requires Python 3.10+.
|
|
|
71
73
|
|
|
72
74
|
```bash
|
|
73
75
|
dbt docs generate # writes target/manifest.json + target/catalog.json
|
|
74
|
-
dbdocs generate # builds
|
|
76
|
+
dbdocs generate # builds target/site/ with index.html + dbdocs-data.json.gz
|
|
75
77
|
dbdocs serve # static http server on http://127.0.0.1:8000
|
|
76
78
|
```
|
|
77
79
|
|
|
78
80
|
The site must be served over HTTP (not opened as a local file) because it fetches the data payload at load time. `dbdocs serve` handles that locally; any static host works for deployment.
|
|
79
81
|
|
|
82
|
+
### On dbt 2.0
|
|
83
|
+
|
|
84
|
+
dbt 2.0 stops writing `catalog.json` and can write a typed parquet **information schema** instead — on dbt Fusion with `--static-analysis strict`, complete with column types and dbt's own column-level lineage. dbdocs builds from either; to read the info schema, `pip install "dbdocs[info-schema]"` and set `info_schema: true` in `dbdocs.yml`:
|
|
85
|
+
|
|
86
|
+

|
|
87
|
+
|
|
88
|
+
The [dbt 2.0 & Info Schema](https://dbdocs.datnguye.me/latest/nav/guide/dbt-2-info-schema.html) guide walks through it.
|
|
89
|
+
|
|
80
90
|
Full walkthrough, configuration, and architecture live in the **[documentation](https://dbdocs.datnguye.me/)**.
|
|
81
91
|
|
|
82
92
|
## Contributing
|
|
@@ -47,12 +47,20 @@ Requires Python 3.10+.
|
|
|
47
47
|
|
|
48
48
|
```bash
|
|
49
49
|
dbt docs generate # writes target/manifest.json + target/catalog.json
|
|
50
|
-
dbdocs generate # builds
|
|
50
|
+
dbdocs generate # builds target/site/ with index.html + dbdocs-data.json.gz
|
|
51
51
|
dbdocs serve # static http server on http://127.0.0.1:8000
|
|
52
52
|
```
|
|
53
53
|
|
|
54
54
|
The site must be served over HTTP (not opened as a local file) because it fetches the data payload at load time. `dbdocs serve` handles that locally; any static host works for deployment.
|
|
55
55
|
|
|
56
|
+
### On dbt 2.0
|
|
57
|
+
|
|
58
|
+
dbt 2.0 stops writing `catalog.json` and can write a typed parquet **information schema** instead — on dbt Fusion with `--static-analysis strict`, complete with column types and dbt's own column-level lineage. dbdocs builds from either; to read the info schema, `pip install "dbdocs[info-schema]"` and set `info_schema: true` in `dbdocs.yml`:
|
|
59
|
+
|
|
60
|
+

|
|
61
|
+
|
|
62
|
+
The [dbt 2.0 & Info Schema](https://dbdocs.datnguye.me/latest/nav/guide/dbt-2-info-schema.html) guide walks through it.
|
|
63
|
+
|
|
56
64
|
Full walkthrough, configuration, and architecture live in the **[documentation](https://dbdocs.datnguye.me/)**.
|
|
57
65
|
|
|
58
66
|
## Contributing
|
|
@@ -9,11 +9,31 @@ from dbdocs.core.config import DbDocsConfig
|
|
|
9
9
|
from dbdocs.core.exceptions import DbDocsError
|
|
10
10
|
from dbdocs.core.log import logger
|
|
11
11
|
from dbdocs.site import deploy as deploy_module
|
|
12
|
-
from dbdocs.
|
|
12
|
+
from dbdocs.sources.factory import builder_for
|
|
13
13
|
|
|
14
14
|
__version__ = importlib.metadata.version("dbdocs")
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
def info_schema_options(command):
|
|
18
|
+
command = click.option(
|
|
19
|
+
"--info-schema-dir",
|
|
20
|
+
default=None,
|
|
21
|
+
help="Directory of the info-schema parquet files (default: <target_dir>/info_schema/v1).",
|
|
22
|
+
)(command)
|
|
23
|
+
return click.option(
|
|
24
|
+
"--info-schema/--no-info-schema",
|
|
25
|
+
default=None,
|
|
26
|
+
help="Read dbt 2.0's parquet information schema as the base artifact source.",
|
|
27
|
+
)(command)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _apply_info_schema(config: DbDocsConfig, info_schema, info_schema_dir) -> None:
|
|
31
|
+
if info_schema is not None:
|
|
32
|
+
config.info_schema = info_schema
|
|
33
|
+
if info_schema_dir is not None:
|
|
34
|
+
config.info_schema_dir = info_schema_dir
|
|
35
|
+
|
|
36
|
+
|
|
17
37
|
# dbdocs
|
|
18
38
|
@click.group(
|
|
19
39
|
context_settings={"help_option_names": ["-h", "--help"]},
|
|
@@ -37,6 +57,7 @@ def dbdocs(ctx, config_path):
|
|
|
37
57
|
|
|
38
58
|
|
|
39
59
|
@dbdocs.command(name="generate")
|
|
60
|
+
@info_schema_options
|
|
40
61
|
@click.option("-o", "--output-dir", default=None, help="Where to write the site (default: config).")
|
|
41
62
|
@click.option(
|
|
42
63
|
"--dialect", default=None, help="SQL dialect for column lineage (default: adapter_type)."
|
|
@@ -49,14 +70,15 @@ def dbdocs(ctx, config_path):
|
|
|
49
70
|
),
|
|
50
71
|
)
|
|
51
72
|
@click.pass_obj
|
|
52
|
-
def generate(config: DbDocsConfig, output_dir, dialect, run_results):
|
|
73
|
+
def generate(config: DbDocsConfig, output_dir, dialect, run_results, info_schema, info_schema_dir):
|
|
53
74
|
"""Build the site from dbt artifacts (served over HTTP; data loaded externally)."""
|
|
54
75
|
if dialect is not None:
|
|
55
76
|
config.dialect = dialect
|
|
56
77
|
if run_results is not None:
|
|
57
78
|
config.run_results = run_results
|
|
79
|
+
_apply_info_schema(config, info_schema, info_schema_dir)
|
|
58
80
|
try:
|
|
59
|
-
|
|
81
|
+
builder_for(config).generate(output_dir=output_dir)
|
|
60
82
|
except DbDocsError as exc:
|
|
61
83
|
raise click.ClickException(str(exc)) from exc
|
|
62
84
|
# The builder already logs "Generated site at <path> (N nodes, M edges)".
|
|
@@ -84,15 +106,24 @@ def serve(config: DbDocsConfig, port):
|
|
|
84
106
|
"--delete", "delete", is_flag=True, default=False, help="Delete this version instead."
|
|
85
107
|
)
|
|
86
108
|
@click.option("--push/--no-push", default=False, help="Publish to the gh-pages branch.")
|
|
109
|
+
@info_schema_options
|
|
87
110
|
@click.pass_obj
|
|
88
|
-
def deploy(config: DbDocsConfig, version, alias, title, delete, push):
|
|
111
|
+
def deploy(config: DbDocsConfig, version, alias, title, delete, push, info_schema, info_schema_dir):
|
|
89
112
|
"""Generate a versioned build and update the version index (or --delete one)."""
|
|
113
|
+
_apply_info_schema(config, info_schema, info_schema_dir)
|
|
90
114
|
try:
|
|
91
115
|
if delete:
|
|
92
116
|
deploy_module.delete(config, version=version, push=push)
|
|
93
117
|
click.echo(f"Deleted version {version}")
|
|
94
118
|
return
|
|
95
|
-
out = deploy_module.deploy(
|
|
119
|
+
out = deploy_module.deploy(
|
|
120
|
+
config,
|
|
121
|
+
version=version,
|
|
122
|
+
alias=alias,
|
|
123
|
+
push=push,
|
|
124
|
+
title=title,
|
|
125
|
+
builder=builder_for(config),
|
|
126
|
+
)
|
|
96
127
|
except DbDocsError as exc:
|
|
97
128
|
raise click.ClickException(str(exc)) from exc
|
|
98
129
|
click.echo(f"Deployed version {version} into {out}")
|
|
@@ -19,6 +19,8 @@ from typing import Any
|
|
|
19
19
|
|
|
20
20
|
from dbterd.helpers import file
|
|
21
21
|
|
|
22
|
+
from dbdocs.core.log import logger
|
|
23
|
+
|
|
22
24
|
#: Bucket label used when a node/source has no database or schema set.
|
|
23
25
|
UNKNOWN = "_unknown"
|
|
24
26
|
|
|
@@ -54,10 +56,23 @@ def artifact_version(target_path: str, artifact: str) -> "int | None":
|
|
|
54
56
|
|
|
55
57
|
|
|
56
58
|
def load_artifacts(target_path: str) -> "tuple[Any, Any]":
|
|
57
|
-
"""Return the dbterd-parsed ``(manifest, catalog)`` for a dbt target dir.
|
|
59
|
+
"""Return the dbterd-parsed ``(manifest, catalog)`` for a dbt target dir.
|
|
60
|
+
|
|
61
|
+
``catalog.json`` is optional (dbt 2.0's ``docs generate`` does not write it):
|
|
62
|
+
when absent, ``catalog`` is ``None`` and every consumer falls back to the
|
|
63
|
+
manifest — columns lose their warehouse type and column-level lineage can't
|
|
64
|
+
resolve ``SELECT *`` / unqualified columns.
|
|
65
|
+
"""
|
|
58
66
|
manifest = file.read_manifest(
|
|
59
67
|
path=target_path, version=artifact_version(target_path, "manifest")
|
|
60
68
|
)
|
|
69
|
+
if not (Path(target_path) / "catalog.json").is_file():
|
|
70
|
+
logger.warning(
|
|
71
|
+
"No catalog.json in %s — building from the manifest only "
|
|
72
|
+
"(no warehouse column types; reduced column-level lineage).",
|
|
73
|
+
target_path,
|
|
74
|
+
)
|
|
75
|
+
return manifest, None
|
|
61
76
|
catalog = file.read_catalog(path=target_path, version=artifact_version(target_path, "catalog"))
|
|
62
77
|
return manifest, catalog
|
|
63
78
|
|
|
@@ -81,6 +81,13 @@ class DbDocsConfig:
|
|
|
81
81
|
#: dotted import path whose ``register_rule`` calls add custom rules). Entry
|
|
82
82
|
#: points under ``dbdocs.health_rules`` are also discovered automatically.
|
|
83
83
|
health: dict = field(default_factory=dict)
|
|
84
|
+
#: Opt in to reading dbt 2.0's parquet information schema as the base artifact
|
|
85
|
+
#: source (``manifest.json`` / ``catalog.json`` / ``run_results.json`` in
|
|
86
|
+
#: ``target_dir`` then only enrich it). Needs ``dbdocs[info-schema]``.
|
|
87
|
+
info_schema: bool = False
|
|
88
|
+
#: Directory holding the information-schema parquet files; ``None`` ⇒
|
|
89
|
+
#: ``<target_dir>/info_schema/v1``.
|
|
90
|
+
info_schema_dir: "str | None" = None
|
|
84
91
|
#: The pinned About link shows by default; set false to hide it.
|
|
85
92
|
show_about: bool = True
|
|
86
93
|
#: Links rendered on the About page as external CTA buttons. Each entry is a
|
|
@@ -129,6 +136,8 @@ class DbDocsConfig:
|
|
|
129
136
|
"favicon",
|
|
130
137
|
"run_results",
|
|
131
138
|
"health",
|
|
139
|
+
"info_schema",
|
|
140
|
+
"info_schema_dir",
|
|
132
141
|
)
|
|
133
142
|
|
|
134
143
|
def render_context(self) -> dict:
|
|
@@ -164,3 +173,10 @@ class DbDocsConfig:
|
|
|
164
173
|
A relative path that would escape the cwd raises ``DbDocsConfigError``.
|
|
165
174
|
"""
|
|
166
175
|
return str(_resolve_within_cwd(self.output_dir, "output_dir"))
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def info_schema_path(self) -> str:
|
|
179
|
+
"""Directory of the information-schema parquet files (see ``info_schema_dir``)."""
|
|
180
|
+
if self.info_schema_dir:
|
|
181
|
+
return str(_resolve_within_cwd(self.info_schema_dir, "info_schema_dir"))
|
|
182
|
+
return str(Path(self.target_path) / "info_schema" / "v1")
|
|
@@ -61,10 +61,11 @@ def _leaf_columns(root: Node, relation_to_node: dict) -> list:
|
|
|
61
61
|
"""Collect distinct upstream ``{node, column}`` leaves of a lineage tree.
|
|
62
62
|
|
|
63
63
|
A leaf is a node whose source is a real ``Table`` (not a CTE/subquery scope)
|
|
64
|
-
that maps back to a dbt node. The root itself is skipped.
|
|
64
|
+
that maps back to a dbt node. The root itself is skipped. Leaves are sorted
|
|
65
|
+
by ``(node, column)``: the tree's walk order follows sqlglot's internal set
|
|
66
|
+
iteration, which varies with ``PYTHONHASHSEED``.
|
|
65
67
|
"""
|
|
66
|
-
|
|
67
|
-
upstream = []
|
|
68
|
+
leaves = set()
|
|
68
69
|
for node in root.walk():
|
|
69
70
|
if node is root:
|
|
70
71
|
continue
|
|
@@ -72,15 +73,9 @@ def _leaf_columns(root: Node, relation_to_node: dict) -> list:
|
|
|
72
73
|
if not isinstance(source, exp.Table):
|
|
73
74
|
continue
|
|
74
75
|
mapped = _map_table(source, relation_to_node)
|
|
75
|
-
if mapped is None:
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
key = (mapped, column)
|
|
79
|
-
if key in seen:
|
|
80
|
-
continue
|
|
81
|
-
seen.add(key)
|
|
82
|
-
upstream.append({"node": mapped, "column": column})
|
|
83
|
-
return upstream
|
|
76
|
+
if mapped is not None:
|
|
77
|
+
leaves.add((mapped, node_name(node.name)))
|
|
78
|
+
return [{"node": mapped, "column": column} for mapped, column in sorted(leaves)]
|
|
84
79
|
|
|
85
80
|
|
|
86
81
|
def _extract_model_columns(
|
|
@@ -67,8 +67,14 @@ def deploy(
|
|
|
67
67
|
alias: "str | None" = None,
|
|
68
68
|
push: bool = False,
|
|
69
69
|
title: "str | None" = None,
|
|
70
|
+
builder: "ReportBuilder | None" = None,
|
|
70
71
|
) -> str:
|
|
71
|
-
"""Generate ``version`` into the output root and update the version index.
|
|
72
|
+
"""Generate ``version`` into the output root and update the version index.
|
|
73
|
+
|
|
74
|
+
``builder`` renders the version; the CLI passes ``sources.factory.builder_for``'s
|
|
75
|
+
pick so an opt-in artifact source applies, keeping ``site/`` free of ``sources/``.
|
|
76
|
+
It defaults to a plain ``ReportBuilder``.
|
|
77
|
+
"""
|
|
72
78
|
_validate_segment(version, "version")
|
|
73
79
|
if alias is not None:
|
|
74
80
|
_validate_segment(alias, "alias")
|
|
@@ -77,7 +83,7 @@ def deploy(
|
|
|
77
83
|
if version_dir.exists():
|
|
78
84
|
rmtree(version_dir)
|
|
79
85
|
|
|
80
|
-
ReportBuilder(config).generate(output_dir=str(version_dir), versioned=True)
|
|
86
|
+
(builder or ReportBuilder(config)).generate(output_dir=str(version_dir), versioned=True)
|
|
81
87
|
|
|
82
88
|
versions = _upsert_version(_read_versions(output_root), version, title or version, alias)
|
|
83
89
|
(output_root / VERSIONS_FILE).write_text(json.dumps(versions, indent=2), encoding="utf-8")
|
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Pick the report builder for the configured artifact source."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
|
|
5
|
+
from dbdocs.core.config import DbDocsConfig
|
|
6
|
+
from dbdocs.core.exceptions import DbDocsConfigError
|
|
7
|
+
from dbdocs.site.builder import ReportBuilder
|
|
8
|
+
|
|
9
|
+
INFO_SCHEMA_BUILDER_MODULE = "dbdocs.sources.info_schema.builder"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def builder_for(config: DbDocsConfig) -> ReportBuilder:
|
|
13
|
+
"""``ReportBuilder`` for the JSON artifacts; the info-schema builder when opted in."""
|
|
14
|
+
if not config.info_schema:
|
|
15
|
+
return ReportBuilder(config)
|
|
16
|
+
try:
|
|
17
|
+
module = importlib.import_module(INFO_SCHEMA_BUILDER_MODULE)
|
|
18
|
+
except ModuleNotFoundError as exc:
|
|
19
|
+
if exc.name != "duckdb":
|
|
20
|
+
raise
|
|
21
|
+
raise DbDocsConfigError(
|
|
22
|
+
"info_schema needs DuckDB: install it with `pip install 'dbdocs[info-schema]'`."
|
|
23
|
+
) from exc
|
|
24
|
+
return module.InfoSchemaReportBuilder(config)
|
|
File without changes
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Report builder that reads dbt 2.0's parquet information schema."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import tempfile
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from dbdocs.core.config import DbDocsConfig
|
|
9
|
+
from dbdocs.core.log import logger
|
|
10
|
+
from dbdocs.site.builder import ReportBuilder
|
|
11
|
+
from dbdocs.sources.info_schema import synth
|
|
12
|
+
from dbdocs.sources.info_schema.reader import InfoSchemaReader
|
|
13
|
+
|
|
14
|
+
ARTIFACT_SOURCE = "info_schema"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _read_json(path: Path) -> "dict | None":
|
|
18
|
+
"""An optional JSON enrichment artifact; ``None`` when absent or unusable (logged)."""
|
|
19
|
+
try:
|
|
20
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
21
|
+
except FileNotFoundError:
|
|
22
|
+
return None
|
|
23
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
24
|
+
logger.warning("Ignoring %s as info_schema enrichment: %s", path, exc)
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InfoSchemaReportBuilder(ReportBuilder):
|
|
29
|
+
"""Stages synthesized artifacts, then runs the unchanged ``build_data`` on them.
|
|
30
|
+
|
|
31
|
+
Parquet is the base; ``manifest.json`` / ``catalog.json`` / ``run_results.json``
|
|
32
|
+
in ``target_dir`` enrich it when present. ``dbt.column_lineage`` rows replace
|
|
33
|
+
the sqlglot column lineage; an empty table keeps the sqlglot result.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, config: DbDocsConfig) -> None:
|
|
37
|
+
super().__init__(config)
|
|
38
|
+
self.reader = InfoSchemaReader(config.info_schema_path)
|
|
39
|
+
|
|
40
|
+
def build_data(self) -> dict:
|
|
41
|
+
target = Path(self.config.target_path)
|
|
42
|
+
run_results = _read_json(Path(self._resolve_run_results_path()))
|
|
43
|
+
with tempfile.TemporaryDirectory() as staging:
|
|
44
|
+
self._stage(Path(staging), target, run_results)
|
|
45
|
+
staged = ReportBuilder(replace(self.config, target_dir=staging, run_results=None))
|
|
46
|
+
data = staged.build_data()
|
|
47
|
+
data["metadata"]["artifact_source"] = ARTIFACT_SOURCE
|
|
48
|
+
lineage = synth.column_lineage(self.reader)
|
|
49
|
+
if lineage:
|
|
50
|
+
data["columnLineage"] = lineage
|
|
51
|
+
else:
|
|
52
|
+
logger.info("dbt.column_lineage is empty — keeping sqlglot column lineage.")
|
|
53
|
+
return data
|
|
54
|
+
|
|
55
|
+
def _stage(self, staging: Path, target: Path, run_results: "dict | None") -> None:
|
|
56
|
+
artifacts = {
|
|
57
|
+
"manifest": synth.build_manifest(self.reader, _read_json(target / "manifest.json")),
|
|
58
|
+
"catalog": synth.build_catalog(self.reader, _read_json(target / "catalog.json")),
|
|
59
|
+
"run_results": synth.build_run_results(self.reader, run_results),
|
|
60
|
+
}
|
|
61
|
+
for name, artifact in artifacts.items():
|
|
62
|
+
if artifact is not None:
|
|
63
|
+
(staging / f"{name}.json").write_text(
|
|
64
|
+
json.dumps(artifact, default=str), encoding="utf-8"
|
|
65
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Read dbt 2.0's parquet information schema (``dbt.*`` / ``dbt_rt.*`` tables)."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import duckdb
|
|
6
|
+
|
|
7
|
+
from dbdocs.core.exceptions import DbDocsConfigError
|
|
8
|
+
|
|
9
|
+
SUPPORTED_SCHEMA_VERSION = 1
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InfoSchemaReader:
|
|
13
|
+
"""Rows of the information-schema parquet files under ``directory``.
|
|
14
|
+
|
|
15
|
+
A table with no parquet file reads as ``[]``. Construction fails unless the
|
|
16
|
+
directory exists and ``dbt.project.schema_version`` is the supported one.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, directory: "str | Path") -> None:
|
|
20
|
+
self.directory = Path(directory)
|
|
21
|
+
self._tables: dict[str, list[dict]] = {}
|
|
22
|
+
if not self.directory.is_dir():
|
|
23
|
+
raise DbDocsConfigError(f"info_schema directory not found: {self.directory}")
|
|
24
|
+
project = self.table("dbt.project")
|
|
25
|
+
version = project[0].get("schema_version") if project else None
|
|
26
|
+
if version != SUPPORTED_SCHEMA_VERSION:
|
|
27
|
+
raise DbDocsConfigError(
|
|
28
|
+
f"Unsupported info_schema schema_version {version!r} in {self.directory} "
|
|
29
|
+
f"(supported: {SUPPORTED_SCHEMA_VERSION})."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def table(self, name: str) -> "list[dict]":
|
|
33
|
+
"""The table's rows, read once per reader and served from memory after."""
|
|
34
|
+
if name not in self._tables:
|
|
35
|
+
self._tables[name] = self._read(self.directory / f"{name}.parquet")
|
|
36
|
+
return self._tables[name]
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def _read(path: Path) -> "list[dict]":
|
|
40
|
+
if not path.is_file():
|
|
41
|
+
return []
|
|
42
|
+
with duckdb.connect() as connection:
|
|
43
|
+
cursor = connection.execute("SELECT * FROM read_parquet(?)", [str(path)])
|
|
44
|
+
columns = [col[0] for col in cursor.description]
|
|
45
|
+
return [dict(zip(columns, row, strict=True)) for row in cursor.fetchall()]
|
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
"""Synthesize standard dbt artifacts (manifest v12 / catalog v1 / run_results v6)
|
|
2
|
+
from the parquet information schema.
|
|
3
|
+
|
|
4
|
+
Merge rule, applied per record and field: the parquet set decides which records
|
|
5
|
+
exist, a non-empty parquet value wins, and the matching JSON artifact record (when
|
|
6
|
+
one was supplied) fills whatever parquet left empty. Column types are the one
|
|
7
|
+
exception: warehouse ``catalog.json`` type, then ``data_type_actual``, then the
|
|
8
|
+
declared type, then ``data_type_inferred``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from artifact_parser.dbt_info_schema.base import decode_json_column
|
|
16
|
+
|
|
17
|
+
from dbdocs.sources.info_schema.reader import InfoSchemaReader
|
|
18
|
+
|
|
19
|
+
MANIFEST_SCHEMA = "https://schemas.getdbt.com/dbt/manifest/v12.json"
|
|
20
|
+
CATALOG_SCHEMA = "https://schemas.getdbt.com/dbt/catalog/v1.json"
|
|
21
|
+
RUN_RESULTS_SCHEMA = "https://schemas.getdbt.com/dbt/run-results/v6.json"
|
|
22
|
+
|
|
23
|
+
_NODE_TABLES = (
|
|
24
|
+
("dbt.models", "model"),
|
|
25
|
+
("dbt.seeds", "seed"),
|
|
26
|
+
("dbt.snapshots", "snapshot"),
|
|
27
|
+
("dbt.analyses", "analysis"),
|
|
28
|
+
("dbt.data_tests", "test"),
|
|
29
|
+
)
|
|
30
|
+
_DEPENDENT_SECTIONS = (
|
|
31
|
+
"nodes",
|
|
32
|
+
"sources",
|
|
33
|
+
"unit_tests",
|
|
34
|
+
"metrics",
|
|
35
|
+
"semantic_models",
|
|
36
|
+
"saved_queries",
|
|
37
|
+
"exposures",
|
|
38
|
+
)
|
|
39
|
+
_ALL_SECTIONS = _DEPENDENT_SECTIONS + ("macros", "docs", "groups")
|
|
40
|
+
_COLUMN_DEFAULTS = {"description": "", "meta": {}, "tags": [], "constraints": []}
|
|
41
|
+
_READ_ONLY_EVOLUTIONS = frozenset({"scan"})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _now() -> str:
|
|
45
|
+
return datetime.now(timezone.utc).isoformat()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _empty(value: Any) -> bool:
|
|
49
|
+
return value is None or (isinstance(value, (str, list, dict)) and not value)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _compact(record: dict) -> dict:
|
|
53
|
+
return {k: v for k, v in record.items() if not _empty(v)}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _fill(base: Any, extra: Any) -> Any:
|
|
57
|
+
"""``base`` where it has a value, else ``extra``; dicts merge key by key."""
|
|
58
|
+
if _empty(base):
|
|
59
|
+
return extra
|
|
60
|
+
if isinstance(base, dict) and isinstance(extra, dict):
|
|
61
|
+
merged = dict(base)
|
|
62
|
+
for key, value in extra.items():
|
|
63
|
+
merged[key] = _fill(base.get(key), value)
|
|
64
|
+
return merged
|
|
65
|
+
return base
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _path(original_file_path: "str | None") -> "str | None":
|
|
69
|
+
return original_file_path.split("/", 1)[-1] if original_file_path else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _common(row: dict, kind: str) -> dict:
|
|
73
|
+
properties = row.get("properties_yml_file_path")
|
|
74
|
+
package = row.get("package_name") or row["unique_id"].split(".")[1]
|
|
75
|
+
return {
|
|
76
|
+
"unique_id": row["unique_id"],
|
|
77
|
+
"name": row.get("name"),
|
|
78
|
+
"resource_type": row.get("resource_type") or kind,
|
|
79
|
+
"package_name": package,
|
|
80
|
+
"original_file_path": row.get("original_file_path"),
|
|
81
|
+
"path": _path(row.get("original_file_path")),
|
|
82
|
+
"fqn": row.get("fqn"),
|
|
83
|
+
"description": row.get("description"),
|
|
84
|
+
"tags": row.get("tags"),
|
|
85
|
+
"meta": decode_json_column(row.get("meta")),
|
|
86
|
+
"patch_path": f"{package}://{properties}" if properties else None,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _config(row: dict) -> dict:
|
|
91
|
+
config = decode_json_column(row.get("config"))
|
|
92
|
+
config = dict(config) if isinstance(config, dict) else {}
|
|
93
|
+
config["materialized"] = row.get("materialized")
|
|
94
|
+
return _compact(config)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _physical(row: dict, kind: str) -> dict:
|
|
98
|
+
compiled_code = row.get("compiled_code")
|
|
99
|
+
record = _common(row, kind)
|
|
100
|
+
record.update(
|
|
101
|
+
database=row.get("database_name"),
|
|
102
|
+
schema=row.get("schema_name"),
|
|
103
|
+
alias=row.get("alias") or row.get("name"),
|
|
104
|
+
relation_name=row.get("relation_name"),
|
|
105
|
+
raw_code=row.get("raw_code"),
|
|
106
|
+
compiled_code=compiled_code,
|
|
107
|
+
compiled=True if compiled_code else None,
|
|
108
|
+
compiled_path=row.get("compiled_path"),
|
|
109
|
+
language=row.get("node_language"),
|
|
110
|
+
config=_config(row),
|
|
111
|
+
access=row.get("access"),
|
|
112
|
+
group=row.get("group"),
|
|
113
|
+
contract={"enforced": bool(row.get("contract_enforced"))},
|
|
114
|
+
version=decode_json_column(row.get("version")),
|
|
115
|
+
latest_version=decode_json_column(row.get("latest_version")),
|
|
116
|
+
docs={"show": row["docs_show"]} if row.get("docs_show") is not None else None,
|
|
117
|
+
)
|
|
118
|
+
if kind == "test":
|
|
119
|
+
record.update(
|
|
120
|
+
test_metadata=_compact(
|
|
121
|
+
{
|
|
122
|
+
"name": row.get("test_name"),
|
|
123
|
+
"namespace": row.get("test_definition_package"),
|
|
124
|
+
"kwargs": decode_json_column(row.get("arguments")),
|
|
125
|
+
}
|
|
126
|
+
),
|
|
127
|
+
column_name=row.get("column_name"),
|
|
128
|
+
attached_node=row.get("node_unique_id"),
|
|
129
|
+
)
|
|
130
|
+
return record
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _source(row: dict, kind: str) -> dict:
|
|
134
|
+
record = _physical(row, kind)
|
|
135
|
+
record.update(
|
|
136
|
+
source_name=row.get("source_name"),
|
|
137
|
+
source_description=row.get("source_description"),
|
|
138
|
+
loader=row.get("loader"),
|
|
139
|
+
identifier=row.get("identifier"),
|
|
140
|
+
loaded_at_field=row.get("loaded_at_field"),
|
|
141
|
+
freshness=decode_json_column(row.get("freshness")),
|
|
142
|
+
)
|
|
143
|
+
return record
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _unit_test(row: dict, kind: str) -> dict:
|
|
147
|
+
record = _common(row, kind)
|
|
148
|
+
record.update(
|
|
149
|
+
model=row.get("model"),
|
|
150
|
+
given=decode_json_column(row.get("given")),
|
|
151
|
+
expect=decode_json_column(row.get("expect")),
|
|
152
|
+
overrides=decode_json_column(row.get("overrides")),
|
|
153
|
+
config=decode_json_column(row.get("config")),
|
|
154
|
+
)
|
|
155
|
+
return record
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _metric(row: dict, kind: str) -> dict:
|
|
159
|
+
record = _common(row, kind)
|
|
160
|
+
record.update(
|
|
161
|
+
label=row.get("label"),
|
|
162
|
+
type=row.get("metric_type"),
|
|
163
|
+
type_params=decode_json_column(row.get("type_params")),
|
|
164
|
+
filter=decode_json_column(row.get("metric_filter")),
|
|
165
|
+
time_granularity=row.get("time_granularity"),
|
|
166
|
+
group=row.get("group"),
|
|
167
|
+
config=decode_json_column(row.get("config")),
|
|
168
|
+
created_at=row.get("created_at"),
|
|
169
|
+
)
|
|
170
|
+
return record
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _semantic_model(row: dict, kind: str) -> dict:
|
|
174
|
+
record = _common(row, kind)
|
|
175
|
+
record.update(
|
|
176
|
+
label=row.get("label"),
|
|
177
|
+
model=row.get("model"),
|
|
178
|
+
node_relation=decode_json_column(row.get("node_relation")),
|
|
179
|
+
primary_entity=row.get("primary_entity"),
|
|
180
|
+
defaults=decode_json_column(row.get("defaults")),
|
|
181
|
+
group=row.get("group"),
|
|
182
|
+
config=decode_json_column(row.get("config")),
|
|
183
|
+
created_at=row.get("created_at"),
|
|
184
|
+
)
|
|
185
|
+
return record
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _saved_query(row: dict, kind: str) -> dict:
|
|
189
|
+
record = _common(row, kind)
|
|
190
|
+
record.update(
|
|
191
|
+
label=row.get("label"),
|
|
192
|
+
query_params=decode_json_column(row.get("query_params")),
|
|
193
|
+
exports=decode_json_column(row.get("exports")),
|
|
194
|
+
group=row.get("group"),
|
|
195
|
+
config=decode_json_column(row.get("config")),
|
|
196
|
+
created_at=row.get("created_at"),
|
|
197
|
+
)
|
|
198
|
+
return record
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _exposure(row: dict, kind: str) -> dict:
|
|
202
|
+
record = _common(row, kind)
|
|
203
|
+
record.update(
|
|
204
|
+
label=row.get("label"),
|
|
205
|
+
type=row.get("exposure_type"),
|
|
206
|
+
url=row.get("url"),
|
|
207
|
+
maturity=row.get("maturity"),
|
|
208
|
+
owner=_compact({"name": row.get("owner_name"), "email": row.get("owner_email")}),
|
|
209
|
+
config=decode_json_column(row.get("config")),
|
|
210
|
+
created_at=row.get("created_at"),
|
|
211
|
+
)
|
|
212
|
+
return record
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _group(row: dict, kind: str) -> dict:
|
|
216
|
+
record = _common(row, kind)
|
|
217
|
+
record["owner"] = _compact({"name": row.get("owner_name"), "email": row.get("owner_email")})
|
|
218
|
+
return record
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _macro(row: dict, kind: str) -> dict:
|
|
222
|
+
record = _common(row, kind)
|
|
223
|
+
arguments = decode_json_column(row.get("arguments"))
|
|
224
|
+
record.update(
|
|
225
|
+
macro_sql=row.get("macro_sql"),
|
|
226
|
+
depends_on={"macros": row.get("depends_on_macros")},
|
|
227
|
+
arguments=arguments if isinstance(arguments, list) else None,
|
|
228
|
+
)
|
|
229
|
+
return record
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _doc(row: dict, kind: str) -> dict:
|
|
233
|
+
record = _common(row, kind)
|
|
234
|
+
record["block_contents"] = row.get("content")
|
|
235
|
+
return record
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
_COLLECTIONS = (
|
|
239
|
+
("dbt.sources", "sources", "source", _source),
|
|
240
|
+
("dbt.unit_tests", "unit_tests", "unit_test", _unit_test),
|
|
241
|
+
("dbt.metrics", "metrics", "metric", _metric),
|
|
242
|
+
("dbt.semantic_models", "semantic_models", "semantic_model", _semantic_model),
|
|
243
|
+
("dbt.saved_queries", "saved_queries", "saved_query", _saved_query),
|
|
244
|
+
("dbt.exposures", "exposures", "exposure", _exposure),
|
|
245
|
+
("dbt.groups", "groups", "group", _group),
|
|
246
|
+
("dbt.macros", "macros", "macro", _macro),
|
|
247
|
+
("dbt.docs_blocks", "docs", "doc", _doc),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
_DEFAULTS = {
|
|
251
|
+
"nodes": {
|
|
252
|
+
"description": "",
|
|
253
|
+
"tags": [],
|
|
254
|
+
"meta": {},
|
|
255
|
+
"columns": {},
|
|
256
|
+
"config": {},
|
|
257
|
+
"checksum": {"name": "none", "checksum": ""},
|
|
258
|
+
},
|
|
259
|
+
"sources": {
|
|
260
|
+
"description": "",
|
|
261
|
+
"tags": [],
|
|
262
|
+
"meta": {},
|
|
263
|
+
"columns": {},
|
|
264
|
+
"source_description": "",
|
|
265
|
+
"loader": "",
|
|
266
|
+
},
|
|
267
|
+
"unit_tests": {"description": "", "tags": [], "meta": {}, "checksum": ""},
|
|
268
|
+
"metrics": {"description": "", "label": "", "type_params": {}},
|
|
269
|
+
"semantic_models": {"description": "", "node_relation": {}},
|
|
270
|
+
"saved_queries": {"description": "", "exports": [], "query_params": {}},
|
|
271
|
+
"exposures": {"description": "", "owner": {}},
|
|
272
|
+
"groups": {"owner": {}},
|
|
273
|
+
"macros": {"description": "", "macro_sql": ""},
|
|
274
|
+
"docs": {"block_contents": ""},
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _group_by(rows: "list[dict]", key: str) -> "dict[str, list[dict]]":
|
|
279
|
+
grouped: dict[str, list[dict]] = defaultdict(list)
|
|
280
|
+
for row in rows:
|
|
281
|
+
grouped[row[key]].append(row)
|
|
282
|
+
return grouped
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _columns(reader: InfoSchemaReader) -> "dict[str, dict]":
|
|
286
|
+
columns: dict[str, dict] = defaultdict(dict)
|
|
287
|
+
for row in sorted(reader.table("dbt.node_columns"), key=lambda r: r.get("column_index") or 0):
|
|
288
|
+
columns[row["node_unique_id"]][row["column_name"]] = _compact(
|
|
289
|
+
{
|
|
290
|
+
"name": row["column_name"],
|
|
291
|
+
"description": row.get("description"),
|
|
292
|
+
"data_type": row.get("data_type_declared"),
|
|
293
|
+
"tags": row.get("tags"),
|
|
294
|
+
"meta": decode_json_column(row.get("meta")),
|
|
295
|
+
"granularity": row.get("granularity"),
|
|
296
|
+
}
|
|
297
|
+
)
|
|
298
|
+
return columns
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _semantic_parts(reader: InfoSchemaReader) -> "dict[str, dict]":
|
|
302
|
+
parts: dict[str, dict] = defaultdict(lambda: {"entities": [], "measures": [], "dimensions": []})
|
|
303
|
+
for row in reader.table("dbt.semantic_entities"):
|
|
304
|
+
parts[row["unique_id"]]["entities"].append(
|
|
305
|
+
{
|
|
306
|
+
"name": row["name"],
|
|
307
|
+
"type": row.get("entity_type"),
|
|
308
|
+
"description": row.get("description"),
|
|
309
|
+
"label": row.get("label"),
|
|
310
|
+
"role": row.get("entity_role"),
|
|
311
|
+
"expr": row.get("expr"),
|
|
312
|
+
}
|
|
313
|
+
)
|
|
314
|
+
for row in reader.table("dbt.semantic_measures"):
|
|
315
|
+
parts[row["unique_id"]]["measures"].append(
|
|
316
|
+
{
|
|
317
|
+
"name": row["name"],
|
|
318
|
+
"agg": row.get("agg"),
|
|
319
|
+
"description": row.get("description"),
|
|
320
|
+
"label": row.get("label"),
|
|
321
|
+
"expr": row.get("expr"),
|
|
322
|
+
"create_metric": row.get("create_metric"),
|
|
323
|
+
"agg_time_dimension": row.get("agg_time_dimension"),
|
|
324
|
+
"agg_params": decode_json_column(row.get("agg_params")),
|
|
325
|
+
"non_additive_dimension": decode_json_column(row.get("non_additive_dimension")),
|
|
326
|
+
}
|
|
327
|
+
)
|
|
328
|
+
for row in reader.table("dbt.semantic_dimensions"):
|
|
329
|
+
granularity = row.get("time_granularity")
|
|
330
|
+
parts[row["unique_id"]]["dimensions"].append(
|
|
331
|
+
_compact(
|
|
332
|
+
{
|
|
333
|
+
"name": row["name"],
|
|
334
|
+
"type": row.get("dimension_type"),
|
|
335
|
+
"description": row.get("description"),
|
|
336
|
+
"label": row.get("label"),
|
|
337
|
+
"expr": row.get("expr"),
|
|
338
|
+
"is_partition": row.get("is_partition"),
|
|
339
|
+
"type_params": {
|
|
340
|
+
"time_granularity": granularity,
|
|
341
|
+
"validity_params": decode_json_column(row.get("validity_params")),
|
|
342
|
+
}
|
|
343
|
+
if granularity
|
|
344
|
+
else None,
|
|
345
|
+
}
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
return parts
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _edges(reader: InfoSchemaReader) -> "tuple[dict, dict]":
|
|
352
|
+
parents: dict[str, list[str]] = defaultdict(list)
|
|
353
|
+
children: dict[str, list[str]] = defaultdict(list)
|
|
354
|
+
for row in reader.table("dbt.edges"):
|
|
355
|
+
parent, child = row["parent_unique_id"], row["child_unique_id"]
|
|
356
|
+
if parent.startswith("macro.") or child.startswith("macro."):
|
|
357
|
+
continue
|
|
358
|
+
parents[child].append(parent)
|
|
359
|
+
children[parent].append(child)
|
|
360
|
+
return parents, children
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _known_json_records(json_manifest: "dict | None") -> "dict[str, dict]":
|
|
364
|
+
known: dict[str, dict] = {}
|
|
365
|
+
for section in _ALL_SECTIONS:
|
|
366
|
+
known.update((json_manifest or {}).get(section) or {})
|
|
367
|
+
return known
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _finish(record: dict, section: str, json_record: "dict | None") -> dict:
|
|
371
|
+
merged = _fill(_compact(record), json_record or {})
|
|
372
|
+
for key, value in _DEFAULTS[section].items():
|
|
373
|
+
merged.setdefault(key, value)
|
|
374
|
+
merged.setdefault("path", "")
|
|
375
|
+
merged.setdefault("original_file_path", "")
|
|
376
|
+
if section in _DEPENDENT_SECTIONS:
|
|
377
|
+
merged.setdefault("fqn", [merged.get("package_name"), merged.get("name")])
|
|
378
|
+
if section == "unit_tests" and isinstance(merged["checksum"], dict):
|
|
379
|
+
merged["checksum"] = merged["checksum"].get("checksum") or ""
|
|
380
|
+
for column in (merged.get("columns") or {}).values():
|
|
381
|
+
for key, value in _COLUMN_DEFAULTS.items():
|
|
382
|
+
column.setdefault(key, value)
|
|
383
|
+
return merged
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def build_manifest(reader: InfoSchemaReader, json_manifest: "dict | None" = None) -> dict:
|
|
387
|
+
known = _known_json_records(json_manifest)
|
|
388
|
+
parents, children = _edges(reader)
|
|
389
|
+
columns = _columns(reader)
|
|
390
|
+
semantic_parts = _semantic_parts(reader)
|
|
391
|
+
manifest: dict = {section: {} for section in _ALL_SECTIONS}
|
|
392
|
+
|
|
393
|
+
jobs = [
|
|
394
|
+
(section, kind, builder, row)
|
|
395
|
+
for table, section, kind, builder in _COLLECTIONS
|
|
396
|
+
for row in reader.table(table)
|
|
397
|
+
]
|
|
398
|
+
jobs += [
|
|
399
|
+
("nodes", kind, _physical, row)
|
|
400
|
+
for table, kind in _NODE_TABLES
|
|
401
|
+
for row in reader.table(table)
|
|
402
|
+
]
|
|
403
|
+
for section, kind, builder, row in jobs:
|
|
404
|
+
record = builder(row, kind)
|
|
405
|
+
unique_id = record["unique_id"]
|
|
406
|
+
if section in _DEPENDENT_SECTIONS:
|
|
407
|
+
record["depends_on"] = {"nodes": parents.get(unique_id)}
|
|
408
|
+
if unique_id in columns:
|
|
409
|
+
record["columns"] = columns[unique_id]
|
|
410
|
+
record.update(semantic_parts.get(unique_id, {}) if section == "semantic_models" else {})
|
|
411
|
+
manifest[section][unique_id] = _finish(record, section, known.get(unique_id))
|
|
412
|
+
|
|
413
|
+
project = (reader.table("dbt.project") or [{}])[0]
|
|
414
|
+
metadata = _fill(
|
|
415
|
+
_compact(
|
|
416
|
+
{
|
|
417
|
+
"dbt_version": project.get("dbt_version"),
|
|
418
|
+
"project_name": project.get("project_name"),
|
|
419
|
+
"adapter_type": project.get("adapter_type"),
|
|
420
|
+
"quoting": decode_json_column(project.get("quoting")),
|
|
421
|
+
}
|
|
422
|
+
),
|
|
423
|
+
(json_manifest or {}).get("metadata") or {},
|
|
424
|
+
)
|
|
425
|
+
metadata["dbt_schema_version"] = MANIFEST_SCHEMA
|
|
426
|
+
metadata.setdefault("generated_at", _now())
|
|
427
|
+
manifest["metadata"] = metadata
|
|
428
|
+
|
|
429
|
+
ids = [uid for section in _DEPENDENT_SECTIONS for uid in manifest[section]]
|
|
430
|
+
manifest["parent_map"] = _fill(
|
|
431
|
+
{uid: parents.get(uid, []) for uid in ids}, (json_manifest or {}).get("parent_map")
|
|
432
|
+
)
|
|
433
|
+
manifest["child_map"] = _fill(
|
|
434
|
+
{uid: children.get(uid, []) for uid in ids}, (json_manifest or {}).get("child_map")
|
|
435
|
+
)
|
|
436
|
+
group_map: dict[str, list[str]] = defaultdict(list)
|
|
437
|
+
for uid, node in manifest["nodes"].items():
|
|
438
|
+
if node.get("group"):
|
|
439
|
+
group_map[node["group"]].append(uid)
|
|
440
|
+
manifest["group_map"] = dict(group_map)
|
|
441
|
+
manifest["selectors"] = (json_manifest or {}).get("selectors") or {}
|
|
442
|
+
manifest["disabled"] = (json_manifest or {}).get("disabled") or {}
|
|
443
|
+
return manifest
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _catalog_type(json_column: "dict | None", row: dict) -> "str | None":
|
|
447
|
+
return (
|
|
448
|
+
(json_column or {}).get("type")
|
|
449
|
+
or row.get("data_type_actual")
|
|
450
|
+
or row.get("data_type_declared")
|
|
451
|
+
or row.get("data_type_inferred")
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def build_catalog(reader: InfoSchemaReader, json_catalog: "dict | None" = None) -> dict:
|
|
456
|
+
json_catalog = json_catalog or {}
|
|
457
|
+
json_records = {**(json_catalog.get("sources") or {}), **(json_catalog.get("nodes") or {})}
|
|
458
|
+
manifest_rows = {
|
|
459
|
+
row["unique_id"]: row
|
|
460
|
+
for table in ("dbt.models", "dbt.seeds", "dbt.snapshots", "dbt.sources")
|
|
461
|
+
for row in reader.table(table)
|
|
462
|
+
}
|
|
463
|
+
by_node = _group_by(reader.table("dbt.node_columns"), "node_unique_id")
|
|
464
|
+
catalog: dict = {"nodes": {}, "sources": {}}
|
|
465
|
+
for unique_id, rows in by_node.items():
|
|
466
|
+
owner = manifest_rows.get(unique_id)
|
|
467
|
+
if owner is None:
|
|
468
|
+
continue
|
|
469
|
+
json_record = json_records.get(unique_id) or {}
|
|
470
|
+
json_columns = {str(k).lower(): v for k, v in (json_record.get("columns") or {}).items()}
|
|
471
|
+
columns = {}
|
|
472
|
+
for index, row in enumerate(sorted(rows, key=lambda r: r.get("column_index") or 0)):
|
|
473
|
+
name = row["column_name"]
|
|
474
|
+
columns[name] = _compact(
|
|
475
|
+
{
|
|
476
|
+
"name": name,
|
|
477
|
+
"index": index,
|
|
478
|
+
"type": _catalog_type(json_columns.get(name.lower()), row),
|
|
479
|
+
"comment": None,
|
|
480
|
+
}
|
|
481
|
+
)
|
|
482
|
+
columns[name].setdefault("type", "")
|
|
483
|
+
record = {
|
|
484
|
+
"unique_id": unique_id,
|
|
485
|
+
"metadata": {
|
|
486
|
+
"type": owner.get("materialized") or "table",
|
|
487
|
+
"schema": owner.get("schema_name"),
|
|
488
|
+
"name": owner.get("identifier") or owner.get("name"),
|
|
489
|
+
"database": owner.get("database_name"),
|
|
490
|
+
},
|
|
491
|
+
"columns": columns,
|
|
492
|
+
"stats": {},
|
|
493
|
+
}
|
|
494
|
+
section = "sources" if unique_id.startswith("source.") else "nodes"
|
|
495
|
+
catalog[section][unique_id] = _fill(record, json_record)
|
|
496
|
+
for section in ("nodes", "sources"):
|
|
497
|
+
for unique_id, json_record in (json_catalog.get(section) or {}).items():
|
|
498
|
+
catalog[section].setdefault(unique_id, json_record)
|
|
499
|
+
project = (reader.table("dbt.project") or [{}])[0]
|
|
500
|
+
catalog.update(
|
|
501
|
+
metadata={
|
|
502
|
+
"dbt_schema_version": CATALOG_SCHEMA,
|
|
503
|
+
"dbt_version": project.get("dbt_version") or "",
|
|
504
|
+
"generated_at": _now(),
|
|
505
|
+
"env": {},
|
|
506
|
+
},
|
|
507
|
+
errors=None,
|
|
508
|
+
)
|
|
509
|
+
return catalog
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _run_result(row: dict, json_result: "dict | None") -> dict:
|
|
513
|
+
result = _fill(
|
|
514
|
+
_compact(
|
|
515
|
+
{
|
|
516
|
+
"unique_id": row["unique_id"],
|
|
517
|
+
"status": row.get("status"),
|
|
518
|
+
"timing": decode_json_column(row.get("timing")),
|
|
519
|
+
"thread_id": row.get("thread_id"),
|
|
520
|
+
"execution_time": row.get("execution_time"),
|
|
521
|
+
"adapter_response": decode_json_column(row.get("adapter_response")),
|
|
522
|
+
"message": row.get("message"),
|
|
523
|
+
"failures": row.get("failures"),
|
|
524
|
+
"compiled": row.get("compiled"),
|
|
525
|
+
"relation_name": row.get("relation_name"),
|
|
526
|
+
}
|
|
527
|
+
),
|
|
528
|
+
json_result or {},
|
|
529
|
+
)
|
|
530
|
+
defaults = {
|
|
531
|
+
"timing": [],
|
|
532
|
+
"thread_id": "",
|
|
533
|
+
"execution_time": 0.0,
|
|
534
|
+
"adapter_response": {},
|
|
535
|
+
"message": "",
|
|
536
|
+
"failures": None,
|
|
537
|
+
"compiled": False,
|
|
538
|
+
"compiled_code": None,
|
|
539
|
+
"relation_name": None,
|
|
540
|
+
}
|
|
541
|
+
for key, value in defaults.items():
|
|
542
|
+
result.setdefault(key, value)
|
|
543
|
+
return result
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _latest_invocation(reader: InfoSchemaReader, rows: "list[dict]") -> "dict | None":
|
|
547
|
+
with_tests = {
|
|
548
|
+
r["invocation_id"] for r in rows if r["unique_id"].split(".")[0] in ("test", "unit_test")
|
|
549
|
+
}
|
|
550
|
+
candidates = [i for i in reader.table("dbt_rt.invocations") if i["invocation_id"] in with_tests]
|
|
551
|
+
if not candidates:
|
|
552
|
+
return None
|
|
553
|
+
return max(
|
|
554
|
+
candidates, key=lambda i: i.get("generated_at") or datetime.min.replace(tzinfo=timezone.utc)
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def build_run_results(
|
|
559
|
+
reader: InfoSchemaReader, json_run_results: "dict | None" = None
|
|
560
|
+
) -> "dict | None":
|
|
561
|
+
rows = reader.table("dbt_rt.run_results")
|
|
562
|
+
invocation = _latest_invocation(reader, rows)
|
|
563
|
+
if invocation is None:
|
|
564
|
+
return json_run_results
|
|
565
|
+
json_by_id = {r.get("unique_id"): r for r in (json_run_results or {}).get("results") or []}
|
|
566
|
+
results = [
|
|
567
|
+
_run_result(row, json_by_id.get(row["unique_id"]))
|
|
568
|
+
for row in rows
|
|
569
|
+
if row["invocation_id"] == invocation["invocation_id"]
|
|
570
|
+
]
|
|
571
|
+
return {
|
|
572
|
+
"metadata": {
|
|
573
|
+
"dbt_schema_version": RUN_RESULTS_SCHEMA,
|
|
574
|
+
"dbt_version": invocation.get("dbt_version") or "",
|
|
575
|
+
"generated_at": invocation["generated_at"].isoformat()
|
|
576
|
+
if invocation.get("generated_at")
|
|
577
|
+
else _now(),
|
|
578
|
+
"invocation_id": invocation["invocation_id"],
|
|
579
|
+
"env": {},
|
|
580
|
+
},
|
|
581
|
+
"results": results,
|
|
582
|
+
"elapsed_time": invocation.get("elapsed_time") or 0.0,
|
|
583
|
+
"args": {},
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def column_lineage(reader: InfoSchemaReader) -> dict:
|
|
588
|
+
"""``dbt.column_lineage`` value lineage as ``{"<node>.<column>": [{node, column}, ...]}``.
|
|
589
|
+
|
|
590
|
+
Rows whose ``evolution`` is in ``_READ_ONLY_EVOLUTIONS`` (``scan``: a join, filter
|
|
591
|
+
or grouping key that is read but does not feed the column's value) are dropped,
|
|
592
|
+
so the result means what the sqlglot lineage means. Refs are deduplicated and
|
|
593
|
+
sorted by ``(node, column)``.
|
|
594
|
+
"""
|
|
595
|
+
refs: dict[str, set] = defaultdict(set)
|
|
596
|
+
for row in reader.table("dbt.column_lineage"):
|
|
597
|
+
if row.get("evolution") in _READ_ONLY_EVOLUTIONS:
|
|
598
|
+
continue
|
|
599
|
+
key = f"{row['child_node_unique_id']}.{row['child_column_name']}"
|
|
600
|
+
refs[key].add((row["parent_node_unique_id"], row["parent_column_name"]))
|
|
601
|
+
return {
|
|
602
|
+
key: [{"node": node, "column": column} for node, column in sorted(pairs)]
|
|
603
|
+
for key, pairs in refs.items()
|
|
604
|
+
}
|
|
@@ -15,14 +15,17 @@ classifiers = [
|
|
|
15
15
|
"Topic :: Software Development :: Quality Assurance",
|
|
16
16
|
]
|
|
17
17
|
dependencies = [
|
|
18
|
-
"artifact-parser[dbt]>=1.
|
|
18
|
+
"artifact-parser[dbt]>=1.1.0",
|
|
19
19
|
"click>=8.1",
|
|
20
|
-
"dbterd>=1.
|
|
20
|
+
"dbterd>=1.35.1",
|
|
21
21
|
"pydantic>=2.13.4",
|
|
22
22
|
"pyyaml>=6.0",
|
|
23
23
|
"sqlglot>=25",
|
|
24
24
|
]
|
|
25
25
|
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
info-schema = ["duckdb>=1.1"]
|
|
28
|
+
|
|
26
29
|
[project.urls]
|
|
27
30
|
Homepage = "https://github.com/datnguye/dbt-docs"
|
|
28
31
|
Repository = "https://github.com/datnguye/dbt-docs"
|
|
@@ -58,6 +61,7 @@ dev = [
|
|
|
58
61
|
"dbt-postgres>=1.5",
|
|
59
62
|
"pdoc>=14.4.0",
|
|
60
63
|
"jsonschema>=4.20",
|
|
64
|
+
"duckdb>=1.1",
|
|
61
65
|
]
|
|
62
66
|
docs = [
|
|
63
67
|
"mkdocs>=1.6,<2.0",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|