dbdocs 1.0.2__tar.gz → 1.2.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.
Files changed (55) hide show
  1. {dbdocs-1.0.2 → dbdocs-1.2.0}/.gitignore +2 -0
  2. dbdocs-1.2.0/PKG-INFO +93 -0
  3. dbdocs-1.2.0/README.md +69 -0
  4. {dbdocs-1.0.2 → dbdocs-1.2.0}/dbdocs/cli/main.py +17 -5
  5. {dbdocs-1.0.2 → dbdocs-1.2.0}/dbdocs/core/config.py +25 -0
  6. {dbdocs-1.0.2 → dbdocs-1.2.0}/dbdocs/extract/column_lineage.py +153 -67
  7. {dbdocs-1.0.2 → dbdocs-1.2.0}/dbdocs/extract/erd.py +10 -1
  8. dbdocs-1.2.0/dbdocs/extract/health/__init__.py +12 -0
  9. dbdocs-1.2.0/dbdocs/extract/health/dimensions.py +262 -0
  10. dbdocs-1.2.0/dbdocs/extract/health/extractor.py +299 -0
  11. dbdocs-1.2.0/dbdocs/extract/health/rules/__init__.py +106 -0
  12. dbdocs-1.2.0/dbdocs/extract/health/rules/base.py +43 -0
  13. dbdocs-1.2.0/dbdocs/extract/health/rules/dimensions/__init__.py +7 -0
  14. dbdocs-1.2.0/dbdocs/extract/health/rules/dimensions/documentation.py +71 -0
  15. dbdocs-1.2.0/dbdocs/extract/health/rules/dimensions/governance.py +75 -0
  16. dbdocs-1.2.0/dbdocs/extract/health/rules/dimensions/modeling.py +241 -0
  17. dbdocs-1.2.0/dbdocs/extract/health/rules/dimensions/performance.py +65 -0
  18. dbdocs-1.2.0/dbdocs/extract/health/rules/dimensions/structure.py +79 -0
  19. dbdocs-1.2.0/dbdocs/extract/health/rules/dimensions/testing.py +51 -0
  20. dbdocs-1.2.0/dbdocs/extract/health/rules/registry.py +155 -0
  21. {dbdocs-1.0.2 → dbdocs-1.2.0}/dbdocs/site/builder.py +84 -11
  22. dbdocs-1.2.0/dbdocs/site/bundle/assets/css/icons.css +35 -0
  23. {dbdocs-1.0.2/dbdocs/site/bundle/assets → dbdocs-1.2.0/dbdocs/site/bundle/assets/css}/style.css +218 -4
  24. {dbdocs-1.0.2 → dbdocs-1.2.0}/dbdocs/site/bundle/assets/graph/index.css +1 -1
  25. {dbdocs-1.0.2 → dbdocs-1.2.0}/dbdocs/site/bundle/assets/graph/index.js +12 -12
  26. dbdocs-1.2.0/dbdocs/site/bundle/assets/js/app.js +20 -0
  27. dbdocs-1.2.0/dbdocs/site/bundle/assets/js/data.js +63 -0
  28. dbdocs-1.2.0/dbdocs/site/bundle/assets/js/service.js +234 -0
  29. dbdocs-1.2.0/dbdocs/site/bundle/assets/js/ui.js +866 -0
  30. {dbdocs-1.0.2 → dbdocs-1.2.0}/dbdocs/site/bundle/index.html +18 -4
  31. dbdocs-1.2.0/dbdocs/site/inject.py +16 -0
  32. {dbdocs-1.0.2 → dbdocs-1.2.0}/pyproject.toml +3 -1
  33. dbdocs-1.0.2/PKG-INFO +0 -78
  34. dbdocs-1.0.2/README.md +0 -56
  35. dbdocs-1.0.2/dbdocs/site/bundle/assets/app.js +0 -504
  36. dbdocs-1.0.2/dbdocs/site/inject.py +0 -32
  37. {dbdocs-1.0.2 → dbdocs-1.2.0}/LICENSE +0 -0
  38. {dbdocs-1.0.2 → dbdocs-1.2.0}/dbdocs/__init__.py +0 -0
  39. {dbdocs-1.0.2 → dbdocs-1.2.0}/dbdocs/__main__.py +0 -0
  40. {dbdocs-1.0.2 → dbdocs-1.2.0}/dbdocs/cli/__init__.py +0 -0
  41. {dbdocs-1.0.2 → dbdocs-1.2.0}/dbdocs/core/__init__.py +0 -0
  42. {dbdocs-1.0.2 → dbdocs-1.2.0}/dbdocs/core/artifacts.py +0 -0
  43. {dbdocs-1.0.2 → dbdocs-1.2.0}/dbdocs/core/exceptions.py +0 -0
  44. {dbdocs-1.0.2 → dbdocs-1.2.0}/dbdocs/core/log.py +0 -0
  45. {dbdocs-1.0.2 → dbdocs-1.2.0}/dbdocs/extract/__init__.py +0 -0
  46. {dbdocs-1.0.2 → dbdocs-1.2.0}/dbdocs/extract/_sqlglot_lineage.py +0 -0
  47. {dbdocs-1.0.2 → dbdocs-1.2.0}/dbdocs/extract/erd_json.py +0 -0
  48. {dbdocs-1.0.2 → dbdocs-1.2.0}/dbdocs/extract/graph.py +0 -0
  49. {dbdocs-1.0.2 → dbdocs-1.2.0}/dbdocs/extract/nodes.py +0 -0
  50. {dbdocs-1.0.2 → dbdocs-1.2.0}/dbdocs/main.py +0 -0
  51. {dbdocs-1.0.2 → dbdocs-1.2.0}/dbdocs/site/__init__.py +0 -0
  52. {dbdocs-1.0.2 → dbdocs-1.2.0}/dbdocs/site/bundle/assets/favicon.svg +0 -0
  53. {dbdocs-1.0.2 → dbdocs-1.2.0}/dbdocs/site/bundle/assets/vendor/marked.min.js +0 -0
  54. {dbdocs-1.0.2 → dbdocs-1.2.0}/dbdocs/site/bundle/assets/vendor/minisearch.min.js +0 -0
  55. {dbdocs-1.0.2 → dbdocs-1.2.0}/dbdocs/site/deploy.py +0 -0
@@ -152,6 +152,8 @@ frontend/dist/
152
152
  frontend/.vite/
153
153
  frontend/coverage/
154
154
  frontend/*.tsbuildinfo
155
+ frontend/test-results/
156
+ frontend/playwright-report/
155
157
  .eslintcache
156
158
 
157
159
  # npm / yarn / pnpm logs & debug output
dbdocs-1.2.0/PKG-INFO ADDED
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: dbdocs
3
+ Version: 1.2.0
4
+ Summary: Alternative dbt docs site: Catalog + ERD + column-level lineage
5
+ Project-URL: Homepage, https://github.com/datnguye/dbt-docs
6
+ Project-URL: Repository, https://github.com/datnguye/dbt-docs
7
+ Author-email: Dat Nguyen <datnguyen.it09@gmail.com>
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: dbt,dbterd,documentation,erd,lineage,sqlglot
11
+ Classifier: Environment :: Console
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Topic :: Software Development :: Documentation
14
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Classifier: Topic :: Software Development :: Quality Assurance
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: artifact-parser[dbt]>=1.0.0
18
+ Requires-Dist: click>=8.1
19
+ Requires-Dist: dbterd>=1.27
20
+ Requires-Dist: pydantic>=2.13.4
21
+ Requires-Dist: pyyaml>=6.0
22
+ Requires-Dist: sqlglot>=25
23
+ Description-Content-Type: text/markdown
24
+
25
+ <p align="center">
26
+ <img src="docs/assets/logo.svg" alt="dbdocs logo" width="220" height="88">
27
+ </p>
28
+
29
+ <p align="center"><b>An alternative dbt docs site — catalog + ERD + column-level lineage + versioned deploys, all in one CLI.</b></p>
30
+
31
+ <p align="center">
32
+ <a href="https://dbdocs.datnguye.me/latest/demo/latest/"><img src="https://img.shields.io/badge/live-demo-FF694A?style=flat&logo=rocket&logoColor=white" alt="live demo"></a>
33
+ <a href="https://dbdocs.datnguye.me/"><img src="https://img.shields.io/badge/docs-visit%20site-blue?style=flat&logo=gitbook&logoColor=white" alt="docs"></a>
34
+ <a href="https://pypi.org/project/dbdocs/"><img src="https://badge.fury.io/py/dbdocs.svg" alt="PyPI version"></a>
35
+ <img src="https://img.shields.io/badge/CLI-Python-FFCE3E?labelColor=14354C&logo=python&logoColor=white" alt="python-cli">
36
+ <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
37
+ <a href="https://www.python.org"><img src="https://img.shields.io/badge/Python-3.10|3.11|3.12|3.13-3776AB.svg?style=flat&logo=python&logoColor=white" alt="python"></a>
38
+ </p>
39
+
40
+ Turn your dbt artifacts into a self-contained docs site: a browsable catalog, an entity-relationship diagram, an interactive lineage DAG, and **column-level lineage** traced from your compiled SQL — all in one `dbdocs generate`. Serve it with `dbdocs serve`, or deploy versioned builds anywhere a static host will take them.
41
+
42
+ | Catalog | Model page | Lineage DAG | Health Check |
43
+ |---|---|---|---|
44
+ | ![catalog](docs/assets/img/demo-catalog.png) | ![model page](docs/assets/img/demo-model-page.png) | ![dag](docs/assets/img/demo-model-dag.png) | ![health check](docs/assets/img/demo-health.png) |
45
+
46
+ ## Why dbdocs?
47
+
48
+ dbt's built-in docs are great — right up until you want to know *which upstream column fed this downstream column*, or *which tables relate to each other*, or *what changed between last week's docs and today's*. dbdocs fills all three gaps without asking you to install a documentation framework or maintain a separate ERD tool.
49
+
50
+ **What you get that nothing else bundles together:**
51
+
52
+ - **ERD + column-level lineage, side by side.** The entity-relationship diagram (powered by [dbterd](https://github.com/datnguye/dbterd)) shows table relationships; column lineage (traced by [sqlglot](https://github.com/tobymao/sqlglot) from compiled SQL) shows exactly which column fed which. Most alternatives give you one or the other — dbdocs gives you both.
53
+ - **Column impact analysis.** Select any column and see its downstream dependents across the project, so you know what a schema change will break before you run it.
54
+ - **Deep-link URLs.** Every focused node, column, and filtered DAG view has a shareable URL. Paste it in Slack and your teammate lands on exactly the right model, column, or graph state.
55
+ - **Any sqlglot-supported dialect.** The dialect for column-lineage parsing is auto-detected from your manifest's `adapter_type` (Snowflake, BigQuery, Redshift, DuckDB, PostgreSQL, Databricks/Spark, Trino, and more — anything sqlglot understands). Override it per-project with `dialect:` in `dbdocs.yml` when auto-detection isn't enough.
56
+ - **Scales without freezing.** Column-lineage parsing fans out across CPU cores automatically above 500 models, so large projects finish in roughly the same wall-clock time as small ones. The DAG is windowed by React Flow, so a 1 000-model graph doesn't turn your browser into a space heater. The payload ships as an external gzip (`dbdocs-data.json.gz`, decompressed client-side) so `index.html` stays tiny regardless of project size.
57
+ - **Fail-soft.** One model with SQL sqlglot can't parse gets skipped and logged — it never sinks the whole generate run.
58
+ - **Project Health Check.** A scorecard across the six [dbt-project-evaluator](https://dbt-labs.github.io/dbt-project-evaluator/) dimensions (testing, modeling, documentation, structure, performance, governance), computed straight from your `manifest.json` — no extra dbt package, no warehouse. When a `run_results.json` is also present (any `dbt build`/`dbt test`; default `<target_dir>/run_results.json`, override with `--run-results`), each test additionally shows up as a pass/fail finding grouped by what it checks (integrity, referential, validity, business logic, freshness). dbdocs only reads the artifacts — it never runs dbt or touches your warehouse. Fail-soft: a missing `run_results.json` just drops the per-test detail; the dimensions still render.
59
+ - **Versioned deploys, no plugins.** `dbdocs deploy --version v1.2 --alias latest` generates into a plain directory tree, writes a `versions.json` index, and the SPA renders a version dropdown. No mike, no external tooling, no surprise dependencies.
60
+ - **Catalog navigation + client-side search.** Models, seeds, and snapshots grouped by database and schema; full-text search without a backend.
61
+ - **Dark / light theme.**
62
+
63
+ ## Install
64
+
65
+ ```bash
66
+ pip install dbdocs --upgrade
67
+ ```
68
+
69
+ Requires Python 3.10+.
70
+
71
+ ## Quickstart
72
+
73
+ ```bash
74
+ dbt docs generate # writes target/manifest.json + target/catalog.json
75
+ dbdocs generate # builds ./site/ with index.html + dbdocs-data.json.gz
76
+ dbdocs serve # static http server on http://127.0.0.1:8000
77
+ ```
78
+
79
+ 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.
80
+
81
+ Full walkthrough, configuration, and architecture live in the **[documentation](https://dbdocs.datnguye.me/)**.
82
+
83
+ ## Contributing
84
+
85
+ Contributions are welcome — bugs, features, docs, typos. See the **[Contributing Guide](https://dbdocs.datnguye.me/latest/nav/development/contributing-guide.html)**.
86
+
87
+ If dbdocs saves you some clicks, consider [buying me a coffee](https://www.buymeacoffee.com/datnguye).
88
+
89
+ <a href="https://www.buymeacoffee.com/datnguye"><img src="https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?logo=buy-me-a-coffee&logoColor=white&labelColor=ff813f&style=for-the-badge" alt="buy me a coffee"></a>
90
+
91
+ ## License
92
+
93
+ [MIT](./LICENSE) © Dat Nguyen
dbdocs-1.2.0/README.md ADDED
@@ -0,0 +1,69 @@
1
+ <p align="center">
2
+ <img src="docs/assets/logo.svg" alt="dbdocs logo" width="220" height="88">
3
+ </p>
4
+
5
+ <p align="center"><b>An alternative dbt docs site — catalog + ERD + column-level lineage + versioned deploys, all in one CLI.</b></p>
6
+
7
+ <p align="center">
8
+ <a href="https://dbdocs.datnguye.me/latest/demo/latest/"><img src="https://img.shields.io/badge/live-demo-FF694A?style=flat&logo=rocket&logoColor=white" alt="live demo"></a>
9
+ <a href="https://dbdocs.datnguye.me/"><img src="https://img.shields.io/badge/docs-visit%20site-blue?style=flat&logo=gitbook&logoColor=white" alt="docs"></a>
10
+ <a href="https://pypi.org/project/dbdocs/"><img src="https://badge.fury.io/py/dbdocs.svg" alt="PyPI version"></a>
11
+ <img src="https://img.shields.io/badge/CLI-Python-FFCE3E?labelColor=14354C&logo=python&logoColor=white" alt="python-cli">
12
+ <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
13
+ <a href="https://www.python.org"><img src="https://img.shields.io/badge/Python-3.10|3.11|3.12|3.13-3776AB.svg?style=flat&logo=python&logoColor=white" alt="python"></a>
14
+ </p>
15
+
16
+ Turn your dbt artifacts into a self-contained docs site: a browsable catalog, an entity-relationship diagram, an interactive lineage DAG, and **column-level lineage** traced from your compiled SQL — all in one `dbdocs generate`. Serve it with `dbdocs serve`, or deploy versioned builds anywhere a static host will take them.
17
+
18
+ | Catalog | Model page | Lineage DAG | Health Check |
19
+ |---|---|---|---|
20
+ | ![catalog](docs/assets/img/demo-catalog.png) | ![model page](docs/assets/img/demo-model-page.png) | ![dag](docs/assets/img/demo-model-dag.png) | ![health check](docs/assets/img/demo-health.png) |
21
+
22
+ ## Why dbdocs?
23
+
24
+ dbt's built-in docs are great — right up until you want to know *which upstream column fed this downstream column*, or *which tables relate to each other*, or *what changed between last week's docs and today's*. dbdocs fills all three gaps without asking you to install a documentation framework or maintain a separate ERD tool.
25
+
26
+ **What you get that nothing else bundles together:**
27
+
28
+ - **ERD + column-level lineage, side by side.** The entity-relationship diagram (powered by [dbterd](https://github.com/datnguye/dbterd)) shows table relationships; column lineage (traced by [sqlglot](https://github.com/tobymao/sqlglot) from compiled SQL) shows exactly which column fed which. Most alternatives give you one or the other — dbdocs gives you both.
29
+ - **Column impact analysis.** Select any column and see its downstream dependents across the project, so you know what a schema change will break before you run it.
30
+ - **Deep-link URLs.** Every focused node, column, and filtered DAG view has a shareable URL. Paste it in Slack and your teammate lands on exactly the right model, column, or graph state.
31
+ - **Any sqlglot-supported dialect.** The dialect for column-lineage parsing is auto-detected from your manifest's `adapter_type` (Snowflake, BigQuery, Redshift, DuckDB, PostgreSQL, Databricks/Spark, Trino, and more — anything sqlglot understands). Override it per-project with `dialect:` in `dbdocs.yml` when auto-detection isn't enough.
32
+ - **Scales without freezing.** Column-lineage parsing fans out across CPU cores automatically above 500 models, so large projects finish in roughly the same wall-clock time as small ones. The DAG is windowed by React Flow, so a 1 000-model graph doesn't turn your browser into a space heater. The payload ships as an external gzip (`dbdocs-data.json.gz`, decompressed client-side) so `index.html` stays tiny regardless of project size.
33
+ - **Fail-soft.** One model with SQL sqlglot can't parse gets skipped and logged — it never sinks the whole generate run.
34
+ - **Project Health Check.** A scorecard across the six [dbt-project-evaluator](https://dbt-labs.github.io/dbt-project-evaluator/) dimensions (testing, modeling, documentation, structure, performance, governance), computed straight from your `manifest.json` — no extra dbt package, no warehouse. When a `run_results.json` is also present (any `dbt build`/`dbt test`; default `<target_dir>/run_results.json`, override with `--run-results`), each test additionally shows up as a pass/fail finding grouped by what it checks (integrity, referential, validity, business logic, freshness). dbdocs only reads the artifacts — it never runs dbt or touches your warehouse. Fail-soft: a missing `run_results.json` just drops the per-test detail; the dimensions still render.
35
+ - **Versioned deploys, no plugins.** `dbdocs deploy --version v1.2 --alias latest` generates into a plain directory tree, writes a `versions.json` index, and the SPA renders a version dropdown. No mike, no external tooling, no surprise dependencies.
36
+ - **Catalog navigation + client-side search.** Models, seeds, and snapshots grouped by database and schema; full-text search without a backend.
37
+ - **Dark / light theme.**
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install dbdocs --upgrade
43
+ ```
44
+
45
+ Requires Python 3.10+.
46
+
47
+ ## Quickstart
48
+
49
+ ```bash
50
+ dbt docs generate # writes target/manifest.json + target/catalog.json
51
+ dbdocs generate # builds ./site/ with index.html + dbdocs-data.json.gz
52
+ dbdocs serve # static http server on http://127.0.0.1:8000
53
+ ```
54
+
55
+ 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.
56
+
57
+ Full walkthrough, configuration, and architecture live in the **[documentation](https://dbdocs.datnguye.me/)**.
58
+
59
+ ## Contributing
60
+
61
+ Contributions are welcome — bugs, features, docs, typos. See the **[Contributing Guide](https://dbdocs.datnguye.me/latest/nav/development/contributing-guide.html)**.
62
+
63
+ If dbdocs saves you some clicks, consider [buying me a coffee](https://www.buymeacoffee.com/datnguye).
64
+
65
+ <a href="https://www.buymeacoffee.com/datnguye"><img src="https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?logo=buy-me-a-coffee&logoColor=white&labelColor=ff813f&style=for-the-badge" alt="buy me a coffee"></a>
66
+
67
+ ## License
68
+
69
+ [MIT](./LICENSE) © Dat Nguyen
@@ -26,7 +26,10 @@ __version__ = importlib.metadata.version("dbdocs")
26
26
  @click.pass_context
27
27
  def dbdocs(ctx, config_path):
28
28
  """Alternative dbt docs site: dbt docs + ERD + column-level lineage."""
29
- logger.info("Run with dbdocs==%s", __version__)
29
+ # The version banner is useful for the build commands (generate/deploy) but
30
+ # is noise for the long-running `serve`, which prints its own start line.
31
+ if ctx.invoked_subcommand != "serve":
32
+ logger.info("Run with dbdocs==%s", __version__)
30
33
  try:
31
34
  ctx.obj = DbDocsConfig.load(config_path)
32
35
  except DbDocsError as exc:
@@ -38,16 +41,25 @@ def dbdocs(ctx, config_path):
38
41
  @click.option(
39
42
  "--dialect", default=None, help="SQL dialect for column lineage (default: adapter_type)."
40
43
  )
44
+ @click.option(
45
+ "--run-results",
46
+ default=None,
47
+ help=(
48
+ "Path to run_results.json for the Health Check (default: <target_dir>/run_results.json)."
49
+ ),
50
+ )
41
51
  @click.pass_obj
42
- def generate(config: DbDocsConfig, output_dir, dialect):
43
- """Build the self-contained site from dbt artifacts."""
52
+ def generate(config: DbDocsConfig, output_dir, dialect, run_results):
53
+ """Build the site from dbt artifacts (served over HTTP; data loaded externally)."""
44
54
  if dialect is not None:
45
55
  config.dialect = dialect
56
+ if run_results is not None:
57
+ config.run_results = run_results
46
58
  try:
47
- out = ReportBuilder(config).generate(output_dir=output_dir)
59
+ ReportBuilder(config).generate(output_dir=output_dir)
48
60
  except DbDocsError as exc:
49
61
  raise click.ClickException(str(exc)) from exc
50
- click.echo(f"Generated site into {out}")
62
+ # The builder already logs "Generated site at <path> (N nodes, M edges)".
51
63
 
52
64
 
53
65
  @dbdocs.command(name="serve")
@@ -49,6 +49,13 @@ class DbDocsConfig:
49
49
  #: Project README rendered on the overview (relative to the working dir). Set
50
50
  #: empty to omit the README section. Missing file ⇒ section simply absent.
51
51
  readme: str = "README.md"
52
+ #: Custom header logo (path relative to the working dir). Empty ⇒ the bundled
53
+ #: default mark. A missing file or a path escaping the cwd is ignored
54
+ #: (fail-soft, like ``readme``) ⇒ the default mark.
55
+ logo: str = ""
56
+ #: Custom favicon (path relative to the working dir). Empty ⇒ the bundled
57
+ #: default favicon. Same fail-soft handling as ``logo``.
58
+ favicon: str = ""
52
59
  target_dir: str = "target"
53
60
  #: Where the generated site is written. Nested under the dbt ``target/`` by
54
61
  #: default so docs sit alongside the artifacts they're built from.
@@ -62,6 +69,18 @@ class DbDocsConfig:
62
69
  #: ``resource_type``, …) passed straight to ``DbtErd``. Configured here so the
63
70
  #: ERD shape lives in ``dbdocs.yml`` rather than a separate ``.dbterd.yml``.
64
71
  dbterd: dict = field(default_factory=dict)
72
+ #: Path to the ``run_results.json`` parsed for the Health Check (always on).
73
+ #: If ``None`` (the default), ``<target_dir>/run_results.json`` is used. A
74
+ #: relative path is resolved against the cwd; a path that escapes the cwd is
75
+ #: silently ignored (fail-soft, like ``readme`` / ``logo``). When the file is
76
+ #: absent the Health Check page is simply empty.
77
+ run_results: "str | None" = None
78
+ #: Health Check rule engine config (all optional):
79
+ #: ``thresholds`` (e.g. ``model_fanout: 5``), ``disable`` (rule names to skip),
80
+ #: ``disable_dimensions`` (whole dimensions to skip), and ``rules_module`` (a
81
+ #: dotted import path whose ``register_rule`` calls add custom rules). Entry
82
+ #: points under ``dbdocs.health_rules`` are also discovered automatically.
83
+ health: dict = field(default_factory=dict)
65
84
 
66
85
  @classmethod
67
86
  def load(cls, path: "str | Path | None" = None) -> "DbDocsConfig":
@@ -92,6 +111,8 @@ class DbDocsConfig:
92
111
  return cls(**raw)
93
112
 
94
113
  #: Build-control fields that are not part of the site's display metadata.
114
+ #: ``logo``/``favicon`` are excluded too — they're *source* paths; the builder
115
+ #: copies the files into the output and injects their *deployed* asset URLs.
95
116
  _NON_METADATA_FIELDS = (
96
117
  "target_dir",
97
118
  "output_dir",
@@ -99,6 +120,10 @@ class DbDocsConfig:
99
120
  "default_version",
100
121
  "dbterd",
101
122
  "readme",
123
+ "logo",
124
+ "favicon",
125
+ "run_results",
126
+ "health",
102
127
  )
103
128
 
104
129
  def render_context(self) -> dict:
@@ -12,6 +12,7 @@ must never sink the whole ``generate`` — it's caught, logged, and skipped, and
12
12
  the run reports how many were skipped.
13
13
  """
14
14
 
15
+ from concurrent.futures import ProcessPoolExecutor
15
16
  from typing import Any
16
17
 
17
18
  from sqlglot import exp
@@ -27,6 +28,14 @@ _DIALECT_ALIASES = {
27
28
  "databricks": "spark",
28
29
  }
29
30
 
31
+ #: Parse + qualify is CPU-bound and embarrassingly parallel across models. Below
32
+ #: this model count the ProcessPoolExecutor's spawn + pickle overhead outweighs
33
+ #: the win, so we stay serial; at or above it we fan out across cores.
34
+ _PARALLEL_THRESHOLD = 500
35
+
36
+ #: Errors a single model's lineage may raise that must not sink the whole run.
37
+ _MODEL_ERRORS = (SqlglotError, LineageError, KeyError, ValueError, RecursionError)
38
+
30
39
 
31
40
  def _to_dialect(adapter_type: "str | None") -> "str | None":
32
41
  if not adapter_type:
@@ -34,6 +43,95 @@ def _to_dialect(adapter_type: "str | None") -> "str | None":
34
43
  return _DIALECT_ALIASES.get(adapter_type, adapter_type)
35
44
 
36
45
 
46
+ def _map_table(table: exp.Table, relation_to_node: dict) -> "str | None":
47
+ """Map a sqlglot ``Table`` to a dbt unique_id via the relation index."""
48
+ candidates = [
49
+ f"{table.catalog}.{table.db}.{table.name}",
50
+ f"{table.db}.{table.name}",
51
+ table.name,
52
+ ]
53
+ for candidate in candidates:
54
+ mapped = relation_to_node.get(candidate.lower().strip("."))
55
+ if mapped:
56
+ return mapped
57
+ return None
58
+
59
+
60
+ def _leaf_columns(root: Node, relation_to_node: dict) -> list:
61
+ """Collect distinct upstream ``{node, column}`` leaves of a lineage tree.
62
+
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.
65
+ """
66
+ seen = set()
67
+ upstream = []
68
+ for node in root.walk():
69
+ if node is root:
70
+ continue
71
+ source = node.source
72
+ if not isinstance(source, exp.Table):
73
+ continue
74
+ mapped = _map_table(source, relation_to_node)
75
+ if mapped is None:
76
+ continue
77
+ column = node_name(node.name)
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
84
+
85
+
86
+ def _extract_model_columns(
87
+ unique_id: str,
88
+ compiled: str,
89
+ output_columns: list,
90
+ schema: dict,
91
+ dialect: "str | None",
92
+ relation_to_node: dict,
93
+ ) -> dict:
94
+ """Trace every output column of one model to its upstream columns.
95
+
96
+ Pure and picklable (plain str/list/dict args, no manifest/catalog objects) so
97
+ it runs unchanged in the serial loop or a ``ProcessPoolExecutor`` worker.
98
+ Builds the scope once per model — qualify is the expensive part and is
99
+ identical for every column — then traces each column against it.
100
+ """
101
+ out: dict = {}
102
+ scope = prepare_scope(compiled, schema=schema, dialect=dialect)
103
+ for column in output_columns:
104
+ try:
105
+ root = lineage(column, compiled, dialect=dialect, scope=scope)
106
+ except SqlglotError:
107
+ # One unresolvable column shouldn't drop the rest of the model.
108
+ continue
109
+ upstream = _leaf_columns(root, relation_to_node)
110
+ if upstream:
111
+ out[f"{unique_id}.{column}"] = upstream
112
+ return out
113
+
114
+
115
+ def _extract_model_task(work: tuple) -> tuple:
116
+ """Worker wrapper: returns ``(unique_id, columns_map, error_str_or_None)``.
117
+
118
+ Catching here (rather than letting the exception cross the process boundary)
119
+ keeps the fail-soft contract: one unparseable model is reported and skipped,
120
+ never sinking the pool.
121
+ """
122
+ unique_id, compiled, output_columns, schema, dialect, relation_to_node = work
123
+ try:
124
+ return (
125
+ unique_id,
126
+ _extract_model_columns(
127
+ unique_id, compiled, output_columns, schema, dialect, relation_to_node
128
+ ),
129
+ None,
130
+ )
131
+ except _MODEL_ERRORS as exc:
132
+ return unique_id, {}, str(exc)
133
+
134
+
37
135
  class ColumnLineageExtractor:
38
136
  """Build the ``columnLineage`` map for a dbt project's models.
39
137
 
@@ -54,86 +152,74 @@ class ColumnLineageExtractor:
54
152
  self.skipped = 0
55
153
 
56
154
  def extract(self) -> dict:
57
- """Return the ``columnLineage`` map across all models (fail-soft)."""
155
+ """Return the ``columnLineage`` map across all models (fail-soft).
156
+
157
+ Runs serially for small projects; fans the per-model sqlglot work across
158
+ processes once a project has ``_PARALLEL_THRESHOLD`` models or more, where
159
+ the CPU-bound parse/qualify dominates the pool's spawn cost.
160
+ """
161
+ work = self._work_items()
162
+ if not work:
163
+ return {}
164
+ if len(work) >= _PARALLEL_THRESHOLD:
165
+ results = self._extract_parallel(work)
166
+ else:
167
+ results = (_extract_model_task(item) for item in work)
168
+
58
169
  result: dict = {}
170
+ for unique_id, columns, error in results:
171
+ if error is not None:
172
+ self.skipped += 1
173
+ logger.warning("Column lineage skipped for %s: %s", node_name(unique_id), error)
174
+ continue
175
+ result.update(columns)
176
+ if self.skipped:
177
+ logger.info("Column lineage: skipped %s model(s) that failed to parse.", self.skipped)
178
+ return result
179
+
180
+ def _extract_parallel(self, work: list) -> list:
181
+ """Run the per-model tasks across a process pool, returning their results."""
182
+ with ProcessPoolExecutor() as pool:
183
+ return list(pool.map(_extract_model_task, work))
184
+
185
+ def _work_items(self) -> list:
186
+ """Build the picklable per-model work tuples for the (serial or pool) run.
187
+
188
+ Each item is ``(unique_id, compiled, output_columns, schema, dialect,
189
+ relation_to_node)`` — all plain str/list/dict, so it crosses a process
190
+ boundary cleanly (the Pydantic manifest/catalog never do).
191
+ """
192
+ work = []
59
193
  for unique_id, model in (getattr(self.manifest, "nodes", {}) or {}).items():
60
194
  if not str(unique_id).startswith("model."):
61
195
  continue
62
196
  compiled = getattr(model, "compiled_code", "") or ""
63
197
  if not compiled.strip():
64
198
  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
199
+ output_columns = self._output_columns(unique_id, model)
200
+ if not output_columns:
201
+ continue
202
+ work.append(
203
+ (
204
+ unique_id,
205
+ compiled,
206
+ output_columns,
207
+ self.schema,
208
+ self.dialect,
209
+ self._relation_to_node,
210
+ )
211
+ )
212
+ return work
73
213
 
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.
214
+ def _output_columns(self, unique_id: str, model: Any) -> list:
215
+ """The model's documented columns, falling back to the catalog's list."""
216
+ output_columns = [c for c in (getattr(model, "columns", {}) or {})]
78
217
  if not output_columns:
79
218
  catalog_node = (getattr(self.catalog, "nodes", {}) or {}).get(unique_id)
80
219
  output_columns = (
81
220
  list(getattr(catalog_node, "columns", {}) or {}) if catalog_node else []
82
221
  )
83
- if not output_columns:
84
- return
85
- # Build the scope once per model, then trace each column against it —
86
- # qualify is the expensive part and is identical for every column.
87
- scope = prepare_scope(compiled, schema=self.schema, dialect=self.dialect)
88
- for column in output_columns:
89
- try:
90
- root = lineage(column, compiled, dialect=self.dialect, scope=scope)
91
- except SqlglotError:
92
- # One unresolvable column shouldn't drop the rest of the model.
93
- continue
94
- upstream = self._leaf_columns(root)
95
- if upstream:
96
- result[f"{unique_id}.{column}"] = upstream
97
-
98
- def _leaf_columns(self, root: Node) -> list:
99
- """Collect distinct upstream ``{node, column}`` leaves of a lineage tree.
100
-
101
- A leaf is a node whose source is a real ``Table`` (not a CTE/subquery
102
- scope) that we can map back to a dbt node. The root itself is skipped.
103
- """
104
- seen = set()
105
- upstream = []
106
- for node in root.walk():
107
- if node is root:
108
- continue
109
- source = node.source
110
- if not isinstance(source, exp.Table):
111
- continue
112
- mapped = self._map_table(source)
113
- if mapped is None:
114
- continue
115
- column = node_name(node.name)
116
- key = (mapped, column)
117
- if key in seen:
118
- continue
119
- seen.add(key)
120
- upstream.append({"node": mapped, "column": column})
121
- return upstream
122
-
123
- def _map_table(self, table: exp.Table) -> "str | None":
124
- catalog = table.catalog
125
- db = table.db
126
- name = table.name
127
- candidates = [
128
- f"{catalog}.{db}.{name}",
129
- f"{db}.{name}",
130
- name,
131
- ]
132
- for candidate in candidates:
133
- mapped = self._relation_to_node.get(candidate.lower().strip("."))
134
- if mapped:
135
- return mapped
136
- return None
222
+ return output_columns
137
223
 
138
224
  def _relation_index(self) -> dict:
139
225
  """Map ``db.schema.table`` (and shorter forms) → dbt unique_id."""
@@ -9,12 +9,21 @@ foreign-key edges between them, all keyed by dbt unique_id.
9
9
 
10
10
  import json
11
11
 
12
- from dbterd.api import DbtErd
12
+ from dbterd.api import DbtErd, default
13
13
 
14
14
  # Importing the module registers the "json" target with dbterd's PluginRegistry.
15
15
  from dbdocs.extract import erd_json # noqa: F401
16
16
 
17
17
 
18
+ def erd_algo(dbterd_options: "dict | None" = None) -> str:
19
+ """The dbterd algorithm that detected the ERD relationships.
20
+
21
+ Reads the configured ``algo`` from the ``dbterd:`` block, falling back to
22
+ dbterd's own default. Surfaced into the SPA so it can explain an empty ERD.
23
+ """
24
+ return (dbterd_options or {}).get("algo") or default.default_algo()
25
+
26
+
18
27
  def build_erd(dbterd_options: "dict | None" = None, artifacts_dir: "str | None" = None) -> DbtErd:
19
28
  """Build the ERD generator (json target) from dbdocs' ``dbterd`` options.
20
29
 
@@ -0,0 +1,12 @@
1
+ """Project Health Check — the six dbt-project-evaluator dimensions.
2
+
3
+ The public entry point is :class:`HealthCheckExtractor`, which composes the
4
+ manifest-derived rule engine (:mod:`dbdocs.extract.health.dimensions` +
5
+ :mod:`dbdocs.extract.health.rules`) with the optional ``run_results.json``
6
+ per-test detail. Custom rules can be registered via
7
+ :func:`dbdocs.extract.health.rules.register_rule` (see the plugin docs).
8
+ """
9
+
10
+ from dbdocs.extract.health.extractor import HealthCheckExtractor
11
+
12
+ __all__ = ["HealthCheckExtractor"]