dbdocs 1.1.0__tar.gz → 1.3.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 (53) hide show
  1. {dbdocs-1.1.0 → dbdocs-1.3.0}/.gitignore +2 -0
  2. {dbdocs-1.1.0 → dbdocs-1.3.0}/PKG-INFO +27 -14
  3. {dbdocs-1.1.0 → dbdocs-1.3.0}/README.md +23 -12
  4. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/cli/main.py +16 -4
  5. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/core/config.py +14 -0
  6. dbdocs-1.3.0/dbdocs/extract/health/__init__.py +12 -0
  7. dbdocs-1.3.0/dbdocs/extract/health/dimensions.py +284 -0
  8. dbdocs-1.3.0/dbdocs/extract/health/extractor.py +299 -0
  9. dbdocs-1.3.0/dbdocs/extract/health/rules/__init__.py +119 -0
  10. dbdocs-1.3.0/dbdocs/extract/health/rules/base.py +58 -0
  11. dbdocs-1.3.0/dbdocs/extract/health/rules/dimensions/__init__.py +7 -0
  12. dbdocs-1.3.0/dbdocs/extract/health/rules/dimensions/documentation.py +101 -0
  13. dbdocs-1.3.0/dbdocs/extract/health/rules/dimensions/governance.py +75 -0
  14. dbdocs-1.3.0/dbdocs/extract/health/rules/dimensions/modeling.py +337 -0
  15. dbdocs-1.3.0/dbdocs/extract/health/rules/dimensions/performance.py +65 -0
  16. dbdocs-1.3.0/dbdocs/extract/health/rules/dimensions/structure.py +97 -0
  17. dbdocs-1.3.0/dbdocs/extract/health/rules/dimensions/testing.py +70 -0
  18. dbdocs-1.3.0/dbdocs/extract/health/rules/registry.py +168 -0
  19. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/site/builder.py +25 -1
  20. dbdocs-1.3.0/dbdocs/site/bundle/assets/css/icons.css +35 -0
  21. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/site/bundle/assets/css/style.css +167 -3
  22. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/site/bundle/assets/graph/index.js +12 -12
  23. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/site/bundle/assets/js/data.js +9 -1
  24. dbdocs-1.3.0/dbdocs/site/bundle/assets/js/service.js +402 -0
  25. dbdocs-1.3.0/dbdocs/site/bundle/assets/js/ui.js +986 -0
  26. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/site/bundle/index.html +10 -2
  27. {dbdocs-1.1.0 → dbdocs-1.3.0}/pyproject.toml +3 -1
  28. dbdocs-1.1.0/dbdocs/site/bundle/assets/js/service.js +0 -111
  29. dbdocs-1.1.0/dbdocs/site/bundle/assets/js/ui.js +0 -510
  30. {dbdocs-1.1.0 → dbdocs-1.3.0}/LICENSE +0 -0
  31. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/__init__.py +0 -0
  32. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/__main__.py +0 -0
  33. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/cli/__init__.py +0 -0
  34. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/core/__init__.py +0 -0
  35. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/core/artifacts.py +0 -0
  36. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/core/exceptions.py +0 -0
  37. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/core/log.py +0 -0
  38. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/extract/__init__.py +0 -0
  39. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/extract/_sqlglot_lineage.py +0 -0
  40. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/extract/column_lineage.py +0 -0
  41. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/extract/erd.py +0 -0
  42. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/extract/erd_json.py +0 -0
  43. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/extract/graph.py +0 -0
  44. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/extract/nodes.py +0 -0
  45. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/main.py +0 -0
  46. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/site/__init__.py +0 -0
  47. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/site/bundle/assets/favicon.svg +0 -0
  48. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/site/bundle/assets/graph/index.css +0 -0
  49. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/site/bundle/assets/js/app.js +0 -0
  50. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/site/bundle/assets/vendor/marked.min.js +0 -0
  51. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/site/bundle/assets/vendor/minisearch.min.js +0 -0
  52. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/site/deploy.py +0 -0
  53. {dbdocs-1.1.0 → dbdocs-1.3.0}/dbdocs/site/inject.py +0 -0
@@ -154,6 +154,8 @@ frontend/coverage/
154
154
  frontend/*.tsbuildinfo
155
155
  frontend/test-results/
156
156
  frontend/playwright-report/
157
+ test-results/
158
+ playwright-report/
157
159
  .eslintcache
158
160
 
159
161
  # npm / yarn / pnpm logs & debug output
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dbdocs
3
- Version: 1.1.0
3
+ Version: 1.3.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,8 +14,10 @@ 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.0.0
17
18
  Requires-Dist: click>=8.1
18
- Requires-Dist: dbterd>=1.26
19
+ Requires-Dist: dbterd>=1.27
20
+ Requires-Dist: pydantic>=2.13.4
19
21
  Requires-Dist: pyyaml>=6.0
20
22
  Requires-Dist: sqlglot>=25
21
23
  Description-Content-Type: text/markdown
@@ -24,7 +26,7 @@ Description-Content-Type: text/markdown
24
26
  <img src="docs/assets/logo.svg" alt="dbdocs logo" width="220" height="88">
25
27
  </p>
26
28
 
27
- <p align="center"><b>An alternative dbt docs site — catalog + ERD + column-level lineage, baked into one file.</b></p>
29
+ <p align="center"><b>An alternative dbt docs site — catalog + ERD + column-level lineage + versioned deploys, all in one CLI.</b></p>
28
30
 
29
31
  <p align="center">
30
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>
@@ -35,11 +37,26 @@ Description-Content-Type: text/markdown
35
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>
36
38
  </p>
37
39
 
38
- Turn your dbt artifacts into a single self-contained `index.html`: a browsable catalog, an interactive lineage DAG and ERD, and **column-level lineage** from your compiled SQL. No server, no database, no build step — just a file you can open or host anywhere.
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.
39
41
 
40
- | Catalog | Model page | Lineage DAG |
41
- |---|---|---|
42
- | ![catalog](docs/assets/img/demo-catalog.png) | ![model page](docs/assets/img/demo-model-page.png) | ![dag](docs/assets/img/demo-model-dag.png) |
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 stop short of telling you *which upstream column fed this downstream column*, *which tables relate to each other*, or *what changed between builds*. dbdocs fills those gaps — no documentation framework or separate ERD tool to install.
49
+
50
+ - **ERD + column-level lineage** — table relationships ([dbterd](https://github.com/datnguye/dbterd)) and column lineage from compiled SQL ([sqlglot](https://github.com/tobymao/sqlglot)).
51
+ - **Column impact analysis** — downstream dependents for any column.
52
+ - **Deep-link URLs** for every node, column, and DAG view.
53
+ - **Any sqlglot dialect**, auto-detected from your manifest.
54
+ - **Scales to 1 000s of models** without freezing the browser.
55
+ - **Fail-soft** — an unparseable model is skipped, not fatal.
56
+ - **Project Health Check** across the six [dbt-project-evaluator](https://dbt-labs.github.io/dbt-project-evaluator/) dimensions.
57
+ - **Versioned deploys** with a built-in version switcher, no plugins.
58
+ - **Full-text search** across names, columns, descriptions, tags, and SQL at the client-side, no backend.
59
+ - **Dark / light theme.**
43
60
 
44
61
  ## Install
45
62
 
@@ -53,17 +70,13 @@ Requires Python 3.10+.
53
70
 
54
71
  ```bash
55
72
  dbt docs generate # writes target/manifest.json + target/catalog.json
56
- dbdocs generate # builds ./site/index.html with all data baked in
73
+ dbdocs generate # builds ./site/ with index.html + dbdocs-data.json.gz
57
74
  dbdocs serve # static http server on http://127.0.0.1:8000
58
75
  ```
59
76
 
60
- Full walkthrough, configuration, and architecture live in the **[documentation](https://dbdocs.datnguye.me/)**.
77
+ 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.
61
78
 
62
- ## Why dbdocs?
63
-
64
- dbt's own docs are great until you want lineage at the *column* level — that's the gap this fills. Everything is derived from your dbt `manifest.json` / `catalog.json` and baked into one offline-friendly SPA: catalog navigation grouped by database/schema, per-model SQL and columns, interactive React Flow graphs, column-level lineage via sqlglot, client-side search, a dark/light theme, and versioned deploys with a built-in version switcher.
65
-
66
- See the [docs](https://dbdocs.datnguye.me/) for the deep dives.
79
+ Full walkthrough, configuration, and architecture live in the **[documentation](https://dbdocs.datnguye.me/)**.
67
80
 
68
81
  ## Contributing
69
82
 
@@ -2,7 +2,7 @@
2
2
  <img src="docs/assets/logo.svg" alt="dbdocs logo" width="220" height="88">
3
3
  </p>
4
4
 
5
- <p align="center"><b>An alternative dbt docs site — catalog + ERD + column-level lineage, baked into one file.</b></p>
5
+ <p align="center"><b>An alternative dbt docs site — catalog + ERD + column-level lineage + versioned deploys, all in one CLI.</b></p>
6
6
 
7
7
  <p align="center">
8
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>
@@ -13,11 +13,26 @@
13
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
14
  </p>
15
15
 
16
- Turn your dbt artifacts into a single self-contained `index.html`: a browsable catalog, an interactive lineage DAG and ERD, and **column-level lineage** from your compiled SQL. No server, no database, no build step — just a file you can open or host anywhere.
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
17
 
18
- | Catalog | Model page | Lineage DAG |
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) |
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 stop short of telling you *which upstream column fed this downstream column*, *which tables relate to each other*, or *what changed between builds*. dbdocs fills those gaps — no documentation framework or separate ERD tool to install.
25
+
26
+ - **ERD + column-level lineage** — table relationships ([dbterd](https://github.com/datnguye/dbterd)) and column lineage from compiled SQL ([sqlglot](https://github.com/tobymao/sqlglot)).
27
+ - **Column impact analysis** — downstream dependents for any column.
28
+ - **Deep-link URLs** for every node, column, and DAG view.
29
+ - **Any sqlglot dialect**, auto-detected from your manifest.
30
+ - **Scales to 1 000s of models** without freezing the browser.
31
+ - **Fail-soft** — an unparseable model is skipped, not fatal.
32
+ - **Project Health Check** across the six [dbt-project-evaluator](https://dbt-labs.github.io/dbt-project-evaluator/) dimensions.
33
+ - **Versioned deploys** with a built-in version switcher, no plugins.
34
+ - **Full-text search** across names, columns, descriptions, tags, and SQL at the client-side, no backend.
35
+ - **Dark / light theme.**
21
36
 
22
37
  ## Install
23
38
 
@@ -31,17 +46,13 @@ Requires Python 3.10+.
31
46
 
32
47
  ```bash
33
48
  dbt docs generate # writes target/manifest.json + target/catalog.json
34
- dbdocs generate # builds ./site/index.html with all data baked in
49
+ dbdocs generate # builds ./site/ with index.html + dbdocs-data.json.gz
35
50
  dbdocs serve # static http server on http://127.0.0.1:8000
36
51
  ```
37
52
 
38
- Full walkthrough, configuration, and architecture live in the **[documentation](https://dbdocs.datnguye.me/)**.
53
+ 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.
39
54
 
40
- ## Why dbdocs?
41
-
42
- dbt's own docs are great until you want lineage at the *column* level — that's the gap this fills. Everything is derived from your dbt `manifest.json` / `catalog.json` and baked into one offline-friendly SPA: catalog navigation grouped by database/schema, per-model SQL and columns, interactive React Flow graphs, column-level lineage via sqlglot, client-side search, a dark/light theme, and versioned deploys with a built-in version switcher.
43
-
44
- See the [docs](https://dbdocs.datnguye.me/) for the deep dives.
55
+ Full walkthrough, configuration, and architecture live in the **[documentation](https://dbdocs.datnguye.me/)**.
45
56
 
46
57
  ## Contributing
47
58
 
@@ -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):
52
+ def generate(config: DbDocsConfig, output_dir, dialect, run_results):
43
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")
@@ -69,6 +69,18 @@ class DbDocsConfig:
69
69
  #: ``resource_type``, …) passed straight to ``DbtErd``. Configured here so the
70
70
  #: ERD shape lives in ``dbdocs.yml`` rather than a separate ``.dbterd.yml``.
71
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)
72
84
 
73
85
  @classmethod
74
86
  def load(cls, path: "str | Path | None" = None) -> "DbDocsConfig":
@@ -110,6 +122,8 @@ class DbDocsConfig:
110
122
  "readme",
111
123
  "logo",
112
124
  "favicon",
125
+ "run_results",
126
+ "health",
113
127
  )
114
128
 
115
129
  def render_context(self) -> dict:
@@ -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"]
@@ -0,0 +1,284 @@
1
+ """Build the manifest-derived health *dimensions* (the DPE rule engine's host).
2
+
3
+ ``ManifestGraph`` indexes the dbt manifest once — parents/children adjacency,
4
+ plus layer / materialization / access / contract helpers — and the rule functions
5
+ in :mod:`dbdocs.extract.health.rules` run against it. ``DimensionAnalyzer.analyze()``
6
+ runs every rule, groups findings by dimension, and returns the ``dimensions``
7
+ block of the health data dict:
8
+
9
+ {
10
+ "modeling": {"issues": N, "checked": M, "findings": [...]},
11
+ "documentation": {...}, "structure": {...}, "performance": {...},
12
+ "governance": {...},
13
+ }
14
+
15
+ ``checked`` is how many nodes the dimension's rules could apply to (models for
16
+ most, models+sources for some) — it gives the SPA a denominator for a score.
17
+ Everything is pure + DOM-free; a malformed/empty manifest yields empty dimensions
18
+ rather than raising (the caller, ``HealthCheckExtractor``, is fail-soft).
19
+ """
20
+
21
+ from typing import Any
22
+
23
+ from dbdocs.core.log import logger
24
+ from dbdocs.extract.health.rules import (
25
+ DEFAULT_THRESHOLDS,
26
+ DIMENSION_RULES,
27
+ NON_PHYSICAL,
28
+ load_entry_point_rules,
29
+ load_rules_module,
30
+ )
31
+
32
+
33
+ class ManifestGraph:
34
+ """Adjacency + node metadata over a dbt manifest, for the health rules.
35
+
36
+ Wraps the dbterd-parsed manifest: ``models``/``sources``/``exposures`` lists,
37
+ a ``parents``/``children`` adjacency built from each node's ``depends_on``, and
38
+ accessors that smooth over where dbt stores a field (``config.materialized``
39
+ vs node, ``config.access`` vs node, ``schema_`` alias, …).
40
+ """
41
+
42
+ def __init__(self, manifest: "Any | None", thresholds: "dict | None" = None) -> None:
43
+ nodes = getattr(manifest, "nodes", None)
44
+ self._nodes: dict = nodes if isinstance(nodes, dict) else {}
45
+ sources = getattr(manifest, "sources", None)
46
+ self._sources: dict = sources if isinstance(sources, dict) else {}
47
+ exposures = getattr(manifest, "exposures", None)
48
+ self._exposures: dict = exposures if isinstance(exposures, dict) else {}
49
+
50
+ self.models = [n for uid, n in self._nodes.items() if uid.startswith("model.")]
51
+ self.sources = list(self._sources.values())
52
+ self.exposures = list(self._exposures.values())
53
+ # Singular tests are custom-SQL test nodes (no test_metadata); generic
54
+ # tests (unique/not_null/…) carry test_metadata and are excluded.
55
+ self.singular_tests = [
56
+ n
57
+ for uid, n in self._nodes.items()
58
+ if uid.startswith("test.") and getattr(n, "test_metadata", None) is None
59
+ ]
60
+
61
+ # Rule thresholds: per-run overrides layered over the DPE defaults.
62
+ self._thresholds = {**DEFAULT_THRESHOLDS, **(thresholds or {})}
63
+
64
+ self._parents: dict[str, list[str]] = {}
65
+ self._children: dict[str, list[str]] = {}
66
+ self._build_adjacency()
67
+
68
+ self._tests_by_model: dict[str, set] = {}
69
+ self._index_tests()
70
+
71
+ # Memo for non_physical_chain_depth (O(nodes) across the whole pass).
72
+ self._chain_cache: dict[str, int] = {}
73
+
74
+ def threshold(self, name: str) -> "int | float":
75
+ """The configured threshold for *name* (DPE default when unset)."""
76
+ return self._thresholds[name]
77
+
78
+ def _build_adjacency(self) -> None:
79
+ """Index parent/child edges from every node + exposure ``depends_on.nodes``."""
80
+ everything = list(self._nodes.values()) + self.exposures
81
+ for node in everything:
82
+ uid = str(getattr(node, "unique_id", "") or "")
83
+ if not uid:
84
+ continue
85
+ depends_on = getattr(node, "depends_on", None)
86
+ parents = list(getattr(depends_on, "nodes", None) or [])
87
+ self._parents[uid] = parents
88
+ for parent in parents:
89
+ self._children.setdefault(parent, []).append(uid)
90
+
91
+ def _index_tests(self) -> None:
92
+ """Map each tested node to the set of test types attached to it."""
93
+ for uid, node in self._nodes.items():
94
+ if not uid.startswith("test."):
95
+ continue
96
+ attached = str(getattr(node, "attached_node", "") or "")
97
+ if not attached:
98
+ continue
99
+ metadata = getattr(node, "test_metadata", None)
100
+ test_type = str(getattr(metadata, "name", "") or "") if metadata else "singular"
101
+ self._tests_by_model.setdefault(attached, set()).add(test_type)
102
+
103
+ def tests_for(self, unique_id: str) -> set:
104
+ """The set of test types attached to *unique_id* (empty if untested)."""
105
+ return self._tests_by_model.get(unique_id, set())
106
+
107
+ # -- adjacency -----------------------------------------------------------
108
+
109
+ def parents(self, unique_id: str) -> "list[str]":
110
+ return self._parents.get(unique_id, [])
111
+
112
+ def children(self, unique_id: str) -> "list[str]":
113
+ return self._children.get(unique_id, [])
114
+
115
+ def node(self, unique_id: str) -> "Any | None":
116
+ """The node object for *unique_id* (model/seed/snapshot or source)."""
117
+ return self._nodes.get(unique_id) or self._sources.get(unique_id)
118
+
119
+ # -- node metadata -------------------------------------------------------
120
+
121
+ @staticmethod
122
+ def layer(model: Any) -> str:
123
+ """The model's layer (staging/intermediate/marts/other) from its ``fqn``.
124
+
125
+ dbt's ``fqn`` is ``[package, ...folders, name]`` — the folder segments name
126
+ the layer. We match the known layer folders anywhere in the fqn so a
127
+ ``marts/finance/`` nesting still resolves to ``marts``.
128
+ """
129
+ fqn = [str(p).lower() for p in (getattr(model, "fqn", None) or [])]
130
+ for layer in ("staging", "intermediate", "marts"):
131
+ if layer in fqn:
132
+ return layer
133
+ # dbt also uses "stg"/"int"/"core"/"mart" folder spellings; map the common ones.
134
+ if "stg" in fqn:
135
+ return "staging"
136
+ if "int" in fqn:
137
+ return "intermediate"
138
+ if "mart" in fqn or "marts" in fqn:
139
+ return "marts"
140
+ return "other"
141
+
142
+ @staticmethod
143
+ def materialization(node: Any) -> str:
144
+ """The node's materialization (``config.materialized``), lowercased."""
145
+ config = getattr(node, "config", None)
146
+ materialized = getattr(config, "materialized", None) if config else None
147
+ return str(materialized or "").lower()
148
+
149
+ @staticmethod
150
+ def access(model: Any) -> str:
151
+ """The model's access level (``config.access`` or node ``access``)."""
152
+ config = getattr(model, "config", None)
153
+ access = getattr(config, "access", None) if config else None
154
+ access = access or getattr(model, "access", None)
155
+ return str(access or "protected").lower()
156
+
157
+ @staticmethod
158
+ def has_source_freshness(source: Any) -> bool:
159
+ """Whether a source has a freshness check: a ``loaded_at_field`` plus a
160
+ ``warn_after``/``error_after`` threshold count."""
161
+ if not str(getattr(source, "loaded_at_field", "") or "").strip():
162
+ return False
163
+ freshness = getattr(source, "freshness", None)
164
+ if freshness is None:
165
+ return False
166
+ for bound in ("warn_after", "error_after"):
167
+ period = getattr(freshness, bound, None)
168
+ if period is not None and getattr(period, "count", None) is not None:
169
+ return True
170
+ return False
171
+
172
+ @staticmethod
173
+ def contract_enforced(model: Any) -> bool:
174
+ """Whether the model has an enforced contract (``contract.enforced``)."""
175
+ contract = getattr(model, "contract", None)
176
+ return bool(getattr(contract, "enforced", False)) if contract else False
177
+
178
+ def non_physical_chain_depth(self, unique_id: str) -> int:
179
+ """Longest chain of consecutive non-physical (view/ephemeral) ancestors + self.
180
+
181
+ Counts this model plus the longest run of view/ephemeral models directly
182
+ upstream; a table/source ancestor (or a model boundary) stops the chain.
183
+ Mirrors DPE's ``chained_view_dependencies`` distance.
184
+
185
+ Iterative + memoized: depth is computed once per node and cached on the
186
+ graph (``_chain_cache``), so the whole pass is ``O(nodes)`` even on a
187
+ 3000-deep view chain — recursion here would blow Python's stack limit and
188
+ be ``O(nodes²)``.
189
+ """
190
+ cache = self._chain_cache
191
+ if unique_id in cache:
192
+ return cache[unique_id]
193
+ # Post-order DFS via an explicit stack: push a node, then its unresolved
194
+ # non-physical parents; resolve a node once all its parents are cached.
195
+ stack = [unique_id]
196
+ on_stack = {unique_id}
197
+ while stack:
198
+ uid = stack[-1]
199
+ node = self._nodes.get(uid)
200
+ if node is None or self.materialization(node) not in NON_PHYSICAL:
201
+ cache[uid] = 0
202
+ stack.pop()
203
+ on_stack.discard(uid)
204
+ continue
205
+ best = 0
206
+ pending = None
207
+ for parent in self.parents(uid):
208
+ if parent in cache:
209
+ best = max(best, cache[parent])
210
+ elif parent not in on_stack: # unresolved, not already in progress
211
+ pending = parent
212
+ break
213
+ # parent in on_stack ⇒ a cycle; treat as a stop (contributes 0)
214
+ if pending is not None:
215
+ stack.append(pending)
216
+ on_stack.add(pending)
217
+ continue
218
+ cache[uid] = 1 + best
219
+ stack.pop()
220
+ on_stack.discard(uid)
221
+ return cache[unique_id]
222
+
223
+
224
+ class DimensionAnalyzer:
225
+ """Run every manifest-derived health rule and group findings by dimension.
226
+
227
+ *config* is the optional ``health`` block from ``dbdocs.yml``:
228
+ ``thresholds`` (per-rule overrides), ``disable`` (rule names to skip),
229
+ ``disable_dimensions`` (whole dimensions to skip), and ``rules_module`` (a
230
+ dotted path whose ``register_rule`` calls add custom rules). Entry-point
231
+ plugins under ``dbdocs.health_rules`` are always discovered.
232
+ """
233
+
234
+ def __init__(self, manifest: "Any | None", config: "dict | None" = None) -> None:
235
+ config = config or {}
236
+ thresholds = config.get("thresholds") if isinstance(config.get("thresholds"), dict) else {}
237
+ self._graph = ManifestGraph(manifest, thresholds=thresholds)
238
+ self._disabled_rules = set(config.get("disable") or [])
239
+ self._disabled_dimensions = set(config.get("disable_dimensions") or [])
240
+
241
+ # Plugin loading: entry points always; an explicit rules_module on request.
242
+ load_entry_point_rules()
243
+ rules_module = config.get("rules_module")
244
+ if rules_module:
245
+ load_rules_module(str(rules_module))
246
+
247
+ def analyze(self) -> dict:
248
+ """Return the ``dimensions`` block: one entry per (enabled) dimension."""
249
+ graph = self._graph
250
+ model_count = len(graph.models)
251
+ source_count = len(graph.sources)
252
+ dimensions: dict = {}
253
+ for dimension, rules in DIMENSION_RULES.items():
254
+ if dimension in self._disabled_dimensions:
255
+ continue
256
+ findings: list = []
257
+ for rule in rules:
258
+ if getattr(rule, "__name__", "") in self._disabled_rules:
259
+ continue
260
+ try:
261
+ findings.extend(rule(graph))
262
+ except (AttributeError, TypeError, ValueError, KeyError, RecursionError) as exc:
263
+ # A single malformed node / bad plugin must never sink the report.
264
+ logger.warning(
265
+ "Health dimension %s: rule %s failed: %s — skipping.",
266
+ dimension,
267
+ getattr(rule, "__name__", rule),
268
+ exc,
269
+ )
270
+ dimensions[dimension] = {
271
+ "issues": len(findings),
272
+ "checked": self._checked(dimension, model_count, source_count),
273
+ "findings": findings,
274
+ }
275
+ return dimensions
276
+
277
+ @staticmethod
278
+ def _checked(dimension: str, model_count: int, source_count: int) -> int:
279
+ """How many nodes a dimension's rules could apply to (the score denominator)."""
280
+ # Modeling/documentation/structure span models + sources; performance and
281
+ # governance are model/exposure-centric — count models as the denominator.
282
+ if dimension in ("modeling", "documentation", "structure"):
283
+ return model_count + source_count
284
+ return model_count