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.
Files changed (60) hide show
  1. {dbdocs-1.7.0 → dbdocs-1.8.0}/PKG-INFO +15 -5
  2. {dbdocs-1.7.0 → dbdocs-1.8.0}/README.md +9 -1
  3. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/cli/main.py +36 -5
  4. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/core/artifacts.py +16 -1
  5. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/core/config.py +16 -0
  6. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/column_lineage.py +7 -12
  7. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/deploy.py +8 -2
  8. dbdocs-1.8.0/dbdocs/sources/__init__.py +0 -0
  9. dbdocs-1.8.0/dbdocs/sources/factory.py +24 -0
  10. dbdocs-1.8.0/dbdocs/sources/info_schema/__init__.py +0 -0
  11. dbdocs-1.8.0/dbdocs/sources/info_schema/builder.py +65 -0
  12. dbdocs-1.8.0/dbdocs/sources/info_schema/reader.py +45 -0
  13. dbdocs-1.8.0/dbdocs/sources/info_schema/synth.py +604 -0
  14. {dbdocs-1.7.0 → dbdocs-1.8.0}/pyproject.toml +6 -2
  15. {dbdocs-1.7.0 → dbdocs-1.8.0}/.gitignore +0 -0
  16. {dbdocs-1.7.0 → dbdocs-1.8.0}/LICENSE +0 -0
  17. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/__init__.py +0 -0
  18. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/__main__.py +0 -0
  19. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/cli/__init__.py +0 -0
  20. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/core/__init__.py +0 -0
  21. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/core/exceptions.py +0 -0
  22. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/core/log.py +0 -0
  23. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/__init__.py +0 -0
  24. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/_sqlglot_lineage.py +0 -0
  25. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/erd.py +0 -0
  26. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/graph.py +0 -0
  27. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/health/__init__.py +0 -0
  28. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/health/dimensions.py +0 -0
  29. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/health/extractor.py +0 -0
  30. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/health/rules/__init__.py +0 -0
  31. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/health/rules/base.py +0 -0
  32. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/health/rules/dimensions/__init__.py +0 -0
  33. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/health/rules/dimensions/documentation.py +0 -0
  34. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/health/rules/dimensions/governance.py +0 -0
  35. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/health/rules/dimensions/modeling.py +0 -0
  36. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/health/rules/dimensions/performance.py +0 -0
  37. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/health/rules/dimensions/structure.py +0 -0
  38. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/health/rules/dimensions/testing.py +0 -0
  39. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/health/rules/registry.py +0 -0
  40. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/nodes.py +0 -0
  41. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/extract/tests_index.py +0 -0
  42. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/main.py +0 -0
  43. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/__init__.py +0 -0
  44. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/api_schema.py +0 -0
  45. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/builder.py +0 -0
  46. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/css/icons.css +0 -0
  47. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/css/style.css +0 -0
  48. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/favicon.svg +0 -0
  49. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/graph/index.css +0 -0
  50. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/graph/index.js +0 -0
  51. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/js/app.js +0 -0
  52. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/js/data/data.js +0 -0
  53. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/js/service/service.js +0 -0
  54. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/js/ui/dom.js +0 -0
  55. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/js/ui/overlays.js +0 -0
  56. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/js/ui/ui.js +0 -0
  57. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/vendor/marked.min.js +0 -0
  58. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/vendor/minisearch.min.js +0 -0
  59. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/bundle/index.html +0 -0
  60. {dbdocs-1.7.0 → dbdocs-1.8.0}/dbdocs/site/inject.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: dbdocs
3
- Version: 1.7.0
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.0.0
17
+ Requires-Dist: artifact-parser[dbt]>=1.1.0
18
18
  Requires-Dist: click>=8.1
19
- Requires-Dist: dbterd>=1.28
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 ./site/ with index.html + dbdocs-data.json.gz
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
+ ![dbt Fusion writes the info schema, dbdocs builds and serves the site from it](docs/assets/img/dbt-2-info-schema.gif)
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 ./site/ with index.html + dbdocs-data.json.gz
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
+ ![dbt Fusion writes the info schema, dbdocs builds and serves the site from it](docs/assets/img/dbt-2-info-schema.gif)
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.site.builder import ReportBuilder
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
- ReportBuilder(config).generate(output_dir=output_dir)
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(config, version=version, alias=alias, push=push, title=title)
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
- seen = set()
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
- 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
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.0.0",
18
+ "artifact-parser[dbt]>=1.1.0",
19
19
  "click>=8.1",
20
- "dbterd>=1.28",
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