dbdocs 1.0.0__tar.gz → 1.0.2__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 (34) hide show
  1. {dbdocs-1.0.0 → dbdocs-1.0.2}/.gitignore +1 -0
  2. {dbdocs-1.0.0 → dbdocs-1.0.2}/PKG-INFO +1 -1
  3. {dbdocs-1.0.0 → dbdocs-1.0.2}/dbdocs/core/config.py +21 -2
  4. {dbdocs-1.0.0 → dbdocs-1.0.2}/dbdocs/extract/nodes.py +39 -14
  5. {dbdocs-1.0.0 → dbdocs-1.0.2}/dbdocs/site/builder.py +14 -7
  6. {dbdocs-1.0.0 → dbdocs-1.0.2}/dbdocs/site/bundle/assets/app.js +16 -12
  7. {dbdocs-1.0.0 → dbdocs-1.0.2}/dbdocs/site/bundle/assets/style.css +6 -10
  8. {dbdocs-1.0.0 → dbdocs-1.0.2}/dbdocs/site/bundle/index.html +1 -1
  9. {dbdocs-1.0.0 → dbdocs-1.0.2}/dbdocs/site/deploy.py +17 -0
  10. {dbdocs-1.0.0 → dbdocs-1.0.2}/dbdocs/site/inject.py +3 -3
  11. {dbdocs-1.0.0 → dbdocs-1.0.2}/LICENSE +0 -0
  12. {dbdocs-1.0.0 → dbdocs-1.0.2}/README.md +0 -0
  13. {dbdocs-1.0.0 → dbdocs-1.0.2}/dbdocs/__init__.py +0 -0
  14. {dbdocs-1.0.0 → dbdocs-1.0.2}/dbdocs/__main__.py +0 -0
  15. {dbdocs-1.0.0 → dbdocs-1.0.2}/dbdocs/cli/__init__.py +0 -0
  16. {dbdocs-1.0.0 → dbdocs-1.0.2}/dbdocs/cli/main.py +0 -0
  17. {dbdocs-1.0.0 → dbdocs-1.0.2}/dbdocs/core/__init__.py +0 -0
  18. {dbdocs-1.0.0 → dbdocs-1.0.2}/dbdocs/core/artifacts.py +0 -0
  19. {dbdocs-1.0.0 → dbdocs-1.0.2}/dbdocs/core/exceptions.py +0 -0
  20. {dbdocs-1.0.0 → dbdocs-1.0.2}/dbdocs/core/log.py +0 -0
  21. {dbdocs-1.0.0 → dbdocs-1.0.2}/dbdocs/extract/__init__.py +0 -0
  22. {dbdocs-1.0.0 → dbdocs-1.0.2}/dbdocs/extract/_sqlglot_lineage.py +0 -0
  23. {dbdocs-1.0.0 → dbdocs-1.0.2}/dbdocs/extract/column_lineage.py +0 -0
  24. {dbdocs-1.0.0 → dbdocs-1.0.2}/dbdocs/extract/erd.py +0 -0
  25. {dbdocs-1.0.0 → dbdocs-1.0.2}/dbdocs/extract/erd_json.py +0 -0
  26. {dbdocs-1.0.0 → dbdocs-1.0.2}/dbdocs/extract/graph.py +0 -0
  27. {dbdocs-1.0.0 → dbdocs-1.0.2}/dbdocs/main.py +0 -0
  28. {dbdocs-1.0.0 → dbdocs-1.0.2}/dbdocs/site/__init__.py +0 -0
  29. {dbdocs-1.0.0 → dbdocs-1.0.2}/dbdocs/site/bundle/assets/favicon.svg +0 -0
  30. {dbdocs-1.0.0 → dbdocs-1.0.2}/dbdocs/site/bundle/assets/graph/index.css +0 -0
  31. {dbdocs-1.0.0 → dbdocs-1.0.2}/dbdocs/site/bundle/assets/graph/index.js +0 -0
  32. {dbdocs-1.0.0 → dbdocs-1.0.2}/dbdocs/site/bundle/assets/vendor/marked.min.js +0 -0
  33. {dbdocs-1.0.0 → dbdocs-1.0.2}/dbdocs/site/bundle/assets/vendor/minisearch.min.js +0 -0
  34. {dbdocs-1.0.0 → dbdocs-1.0.2}/pyproject.toml +0 -0
@@ -166,3 +166,4 @@ pnpm-debug.log*
166
166
 
167
167
  # dbdocs DEBUG file log
168
168
  logs/
169
+ .DS_Store
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dbdocs
3
- Version: 1.0.0
3
+ Version: 1.0.2
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
@@ -8,6 +8,23 @@ from dbdocs.core.exceptions import DbDocsConfigError
8
8
  DEFAULT_CONFIG_FILENAME = "dbdocs.yml"
9
9
 
10
10
 
11
+ def _resolve_within_cwd(value: str, field_name: str) -> Path:
12
+ """Resolve *value* against cwd; reject a relative path that escapes the cwd.
13
+
14
+ An absolute *value* is resolved as-is (the caller's deliberate choice — this
15
+ is documented behaviour and must not be rejected). A relative *value* is
16
+ resolved against ``Path.cwd()`` and then checked: if the result is not
17
+ inside (or equal to) the cwd, ``DbDocsConfigError`` is raised.
18
+ """
19
+ base = Path.cwd().resolve()
20
+ candidate = Path(value)
21
+ resolved = candidate if candidate.is_absolute() else (base / candidate)
22
+ resolved = resolved.resolve()
23
+ if not candidate.is_absolute() and resolved != base and base not in resolved.parents:
24
+ raise DbDocsConfigError(f"{field_name} {value!r} escapes the project directory ({base}).")
25
+ return resolved
26
+
27
+
11
28
  @dataclass
12
29
  class DbDocsConfig:
13
30
  """Site configuration for a dbdocs build.
@@ -103,8 +120,9 @@ class DbDocsConfig:
103
120
  directory **at access time** — this is intentional and must stay aligned
104
121
  with dbterd's ``DbtErd``, which also reads artifacts from ``./target``
105
122
  relative to the cwd. An absolute ``target_dir`` is returned unchanged.
123
+ A relative path that would escape the cwd raises ``DbDocsConfigError``.
106
124
  """
107
- return str(Path.cwd() / self.target_dir)
125
+ return str(_resolve_within_cwd(self.target_dir, "target_dir"))
108
126
 
109
127
  @property
110
128
  def output_path(self) -> str:
@@ -113,5 +131,6 @@ class DbDocsConfig:
113
131
  Resolved against the cwd at access time, mirroring ``target_path`` — a
114
132
  relative ``output_dir`` follows the working directory, an absolute one is
115
133
  returned unchanged.
134
+ A relative path that would escape the cwd raises ``DbDocsConfigError``.
116
135
  """
117
- return str(Path.cwd() / self.output_dir)
136
+ return str(_resolve_within_cwd(self.output_dir, "output_dir"))
@@ -12,27 +12,52 @@ from typing import Any
12
12
  from dbdocs.core.artifacts import NODE_PREFIXES, db_schema, node_name
13
13
 
14
14
 
15
+ def _column_entry(name: str, manifest_column: Any, col_type: str) -> dict:
16
+ """One column record. Newlines in the description become ``<br>`` for HTML."""
17
+ description = getattr(manifest_column, "description", "") or "" if manifest_column else ""
18
+ return {
19
+ "name": name,
20
+ "type": col_type or "",
21
+ "tags": (getattr(manifest_column, "tags", []) or []) if manifest_column else [],
22
+ "description": description.replace("\n", "<br>"),
23
+ }
24
+
25
+
15
26
  def _columns(model: Any, catalog_node: Any) -> list:
16
- """Merge manifest column metadata (description/tags) with catalog types.
27
+ """Build a node's columns: the manifest is the source of truth, the catalog
28
+ enriches it.
29
+
30
+ The **manifest** decides which columns are documented and carries their
31
+ metadata (description, tags, ``data_type``); the **catalog** *enriches* — it
32
+ supplies the warehouse-confirmed type and any columns the manifest didn't
33
+ document. It never replaces the manifest, so a model absent from a
34
+ stale/partial ``catalog.json`` still shows every documented column.
17
35
 
18
- Iterates the catalog's columns (the warehouse truth for which columns exist
19
- and their types) and layers on the manifest description/tags when present.
20
- Newlines in descriptions become ``<br>`` so they survive HTML rendering.
36
+ Manifest columns come first, in manifest order, each with its type enriched
37
+ from the catalog when present (falling back to the manifest ``data_type``).
38
+ The catalog keys columns by the warehouse's casing (Snowflake upper-cases
39
+ them) while the manifest keeps the modeled casing, so the catalog type lookup
40
+ is case-insensitive. Catalog-only columns are appended afterwards.
21
41
  """
22
42
  manifest_columns = getattr(model, "columns", {}) or {}
23
43
  catalog_columns = getattr(catalog_node, "columns", {}) or {} if catalog_node else {}
44
+ catalog_by_lower = {str(name).lower(): col for name, col in catalog_columns.items()}
45
+
24
46
  columns = []
25
- for name in catalog_columns:
26
- manifest_column = manifest_columns.get(name)
27
- description = getattr(manifest_column, "description", "") or "" if manifest_column else ""
28
- columns.append(
29
- {
30
- "name": name,
31
- "type": catalog_columns[name].type,
32
- "tags": (getattr(manifest_column, "tags", []) or []) if manifest_column else [],
33
- "description": description.replace("\n", "<br>"),
34
- }
47
+ seen_lower = set()
48
+ for name, manifest_column in manifest_columns.items():
49
+ lower = str(name).lower()
50
+ seen_lower.add(lower)
51
+ catalog_column = catalog_by_lower.get(lower)
52
+ col_type = getattr(catalog_column, "type", None) or getattr(
53
+ manifest_column, "data_type", None
35
54
  )
55
+ columns.append(_column_entry(name, manifest_column, col_type or ""))
56
+
57
+ for name, catalog_column in catalog_columns.items():
58
+ if str(name).lower() in seen_lower:
59
+ continue
60
+ columns.append(_column_entry(name, None, getattr(catalog_column, "type", "") or ""))
36
61
  return columns
37
62
 
38
63
 
@@ -9,11 +9,12 @@ data → write ``index.html`` + a debug ``dbdocs-data.json``.
9
9
  import json
10
10
  from datetime import datetime
11
11
  from pathlib import Path
12
- from shutil import copytree
12
+ from shutil import copytree, rmtree
13
13
  from typing import Any
14
14
 
15
15
  from dbdocs.core.artifacts import adapter_type, load_artifacts
16
- from dbdocs.core.config import DbDocsConfig
16
+ from dbdocs.core.config import DbDocsConfig, _resolve_within_cwd
17
+ from dbdocs.core.exceptions import DbDocsConfigError
17
18
  from dbdocs.core.log import logger
18
19
  from dbdocs.extract.column_lineage import ColumnLineageExtractor
19
20
  from dbdocs.extract.erd import build_erd, build_erd_data
@@ -66,12 +67,16 @@ class ReportBuilder:
66
67
  def _read_readme(self) -> str:
67
68
  """The project README markdown (rendered on the overview), or ``""``.
68
69
 
69
- ``config.readme`` is a path relative to the cwd; a missing file or an
70
- empty config value just yields no README section.
70
+ ``config.readme`` is a path relative to the cwd; a missing file, an
71
+ empty config value, or a relative path that escapes the cwd all yield
72
+ no README section (fail-soft — a bad README must never sink generate).
71
73
  """
72
74
  if not self.config.readme:
73
75
  return ""
74
- path = Path(self.config.readme)
76
+ try:
77
+ path = _resolve_within_cwd(self.config.readme, "readme")
78
+ except DbDocsConfigError:
79
+ return ""
75
80
  try:
76
81
  return path.read_text(encoding="utf-8")
77
82
  except OSError:
@@ -109,7 +114,9 @@ class ReportBuilder:
109
114
  def generate(self, output_dir: "str | None" = None) -> str:
110
115
  """Render the site into ``output_dir`` (or config's). Returns its path."""
111
116
  out = Path(output_dir) if output_dir else Path(self.config.output_path)
112
- out.mkdir(parents=True, exist_ok=True)
117
+ if out.exists():
118
+ rmtree(out)
119
+ out.mkdir(parents=True)
113
120
  copytree(src=BUNDLE_DIR, dst=out, dirs_exist_ok=True)
114
121
 
115
122
  data = self.build_data()
@@ -117,7 +124,7 @@ class ReportBuilder:
117
124
  index.write_text(inject(index.read_text(encoding="utf-8"), data), encoding="utf-8")
118
125
  # Compact, not indented — keeps the debug dump cheap on large projects.
119
126
  (out / "dbdocs-data.json").write_text(
120
- json.dumps(data, separators=(",", ":"), default=self._json_default),
127
+ json.dumps(data, separators=(",", ":"), sort_keys=True, default=self._json_default),
121
128
  encoding="utf-8",
122
129
  )
123
130
 
@@ -120,12 +120,23 @@
120
120
  el("span", {}, ["Generated by "]),
121
121
  el("a", { href: "https://github.com/datnguye/dbt-docs", target: "_blank", rel: "noopener" }, ["dbdocs"]),
122
122
  ]);
123
+ var meta = footerMeta();
124
+ if (meta) left.appendChild(el("span", { class: "cf-meta" }, [" · " + meta]));
123
125
  var right = el("a", { class: "cf-top", href: "#", onclick: function (e) { e.preventDefault(); app.scrollTop = 0; } }, ["↑ Back to top"]);
124
126
  foot.appendChild(left);
125
127
  foot.appendChild(right);
126
128
  return foot;
127
129
  }
128
130
 
131
+ /* "snowflake · N column-lineage edges" — the build metadata shown in the
132
+ content footer. The "Generated X" part lives in the topbar (see boot). */
133
+ function footerMeta() {
134
+ var parts = [];
135
+ if (META.adapter_type) parts.push(META.adapter_type);
136
+ parts.push(Object.keys(COLLIN).length + " column-lineage edges");
137
+ return parts.join(" · ");
138
+ }
139
+
129
140
  var RTYPE_ORDER = { model: 0, snapshot: 1, seed: 2, source: 3 };
130
141
 
131
142
  function buildNav() {
@@ -456,17 +467,6 @@
456
467
  link.hidden = false;
457
468
  }
458
469
 
459
- function initTopbarMeta() {
460
- var box = document.getElementById("topbar-meta");
461
- if (!box) return;
462
- var parts = [];
463
- if (META.generated_at) parts.push("Generated " + META.generated_at);
464
- if (META.adapter_type) parts.push(META.adapter_type);
465
- parts.push(Object.keys(COLLIN).length + " column-lineage edges");
466
- box.textContent = parts.join(" · ");
467
- box.hidden = false;
468
- }
469
-
470
470
  function initFooter() {
471
471
  var footer = document.getElementById("site-footer");
472
472
  if (!footer) return;
@@ -486,8 +486,12 @@
486
486
  function boot() {
487
487
  document.getElementById("brand-name").textContent = META.site_name || "dbt docs";
488
488
  document.title = META.site_name || "dbt docs";
489
+ if (META.generated_at) {
490
+ var gen = document.getElementById("brand-generated");
491
+ gen.textContent = "Generated " + META.generated_at;
492
+ gen.hidden = false;
493
+ }
489
494
  initRepo();
490
- initTopbarMeta();
491
495
  initFooter();
492
496
  initTheme();
493
497
  buildNav();
@@ -52,8 +52,9 @@ code, pre { font-family: "SF Mono", "Roboto Mono", Menlo, Consolas, monospace; }
52
52
  background: var(--bg-elev); border-bottom: 1px solid var(--border);
53
53
  position: sticky; top: 0; z-index: 30;
54
54
  }
55
- .brand { display: flex; align-items: center; gap: 8px; font-weight: 600; min-width: 160px; }
56
- .brand-mark { display: block; width: 24px; height: 24px; border-radius: 6px; }
55
+ .brand { display: flex; align-items: baseline; gap: 8px; font-weight: 600; min-width: 160px; }
56
+ .brand-mark { display: block; width: 24px; height: 24px; border-radius: 6px; align-self: center; }
57
+ .brand-generated { color: var(--text-soft); font-size: 12px; font-weight: 400; white-space: nowrap; }
57
58
  .search-wrap { position: relative; flex: 1; max-width: 520px; }
58
59
  #search {
59
60
  width: 100%; padding: 7px 12px; border-radius: var(--radius);
@@ -118,21 +119,16 @@ code, pre { font-family: "SF Mono", "Roboto Mono", Menlo, Consolas, monospace; }
118
119
  }
119
120
  .footer-copy { color: var(--text-soft); font-size: 12px; }
120
121
 
121
- /* Build metadata in the header (generated / adapter / edges) */
122
- .topbar-meta {
123
- color: var(--text-soft); font-size: 11.5px; white-space: nowrap;
124
- padding-right: 6px; flex-shrink: 0;
125
- }
126
- @media (max-width: 1100px) { .topbar-meta { display: none; } }
127
-
128
122
  /* End-of-page content footer */
129
123
  .content-foot {
130
124
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
131
125
  margin-top: 40px; padding-top: 16px; border-top: 1px solid var(--border);
132
126
  color: var(--text-soft); font-size: 12px;
133
127
  }
134
- .content-foot .cf-left { display: inline-flex; align-items: center; gap: 6px; }
128
+ .content-foot .cf-left { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; }
135
129
  .content-foot .cf-left .ic { color: var(--text-soft); }
130
+ /* Build metadata (generated / adapter / edges) appended to the footer's left */
131
+ .content-foot .cf-meta { color: var(--text-soft); }
136
132
  .content-foot .cf-top { color: var(--text-soft); }
137
133
  .content-foot .cf-top:hover { color: var(--accent); }
138
134
  .bmc {
@@ -14,8 +14,8 @@
14
14
  <div class="brand">
15
15
  <img class="brand-mark" src="assets/favicon.svg" alt="" width="24" height="24" />
16
16
  <span class="brand-name" id="brand-name">dbt docs</span>
17
+ <span class="brand-generated" id="brand-generated" hidden></span>
17
18
  </div>
18
- <div class="topbar-meta" id="topbar-meta" hidden></div>
19
19
  <div class="search-wrap">
20
20
  <input id="search" type="search" placeholder="Search models, sources, columns…" autocomplete="off" />
21
21
  <div id="search-results" class="search-results" hidden></div>
@@ -14,6 +14,7 @@ the output dir onto a ``gh-pages`` branch and pushes it.
14
14
  """
15
15
 
16
16
  import json
17
+ import re
17
18
  import subprocess
18
19
  from pathlib import Path
19
20
  from shutil import copytree, rmtree
@@ -25,6 +26,17 @@ from dbdocs.site.builder import ReportBuilder
25
26
 
26
27
  VERSIONS_FILE = "versions.json"
27
28
 
29
+ #: Allows only alphanumerics, dots, underscores, and hyphens — a safe single path segment.
30
+ _SAFE_SEGMENT = re.compile(r"^[A-Za-z0-9._-]+$")
31
+
32
+
33
+ def _validate_segment(value: str, kind: str) -> None:
34
+ """Raise :class:`DeployError` if *value* is not a safe single path segment."""
35
+ if not _SAFE_SEGMENT.match(value) or value in {".", ".."}:
36
+ raise DeployError(
37
+ f"Invalid {kind} {value!r}: must match ^[A-Za-z0-9._-]+$ and not be '.' or '..'."
38
+ )
39
+
28
40
 
29
41
  def _read_versions(output_root: Path) -> list:
30
42
  path = output_root / VERSIONS_FILE
@@ -57,6 +69,9 @@ def deploy(
57
69
  title: "str | None" = None,
58
70
  ) -> str:
59
71
  """Generate ``version`` into the output root and update the version index."""
72
+ _validate_segment(version, "version")
73
+ if alias is not None:
74
+ _validate_segment(alias, "alias")
60
75
  output_root = Path(config.output_path)
61
76
  version_dir = output_root / version
62
77
  if version_dir.exists():
@@ -85,6 +100,7 @@ def delete(config: DbDocsConfig, version: str, push: bool = False) -> None:
85
100
 
86
101
  Raises :class:`DeployError` if the version isn't deployed.
87
102
  """
103
+ _validate_segment(version, "version")
88
104
  output_root = Path(config.output_path)
89
105
  versions = _read_versions(output_root)
90
106
  entry = next((v for v in versions if v.get("version") == version), None)
@@ -95,6 +111,7 @@ def delete(config: DbDocsConfig, version: str, push: bool = False) -> None:
95
111
  if version_dir.exists():
96
112
  rmtree(version_dir)
97
113
  for alias in entry.get("aliases", []):
114
+ _validate_segment(alias, "alias")
98
115
  alias_dir = output_root / alias
99
116
  if alias_dir.exists():
100
117
  rmtree(alias_dir)
@@ -16,9 +16,9 @@ INJECT_MARKER = "<!-- DBDOCS_DATA -->"
16
16
 
17
17
  def data_script(data: dict) -> str:
18
18
  """The ``<script>`` tag that sets ``window.dbdocsData`` from ``data``."""
19
- payload = base64.b64encode(json.dumps(data, separators=(",", ":")).encode("utf-8")).decode(
20
- "ascii"
21
- )
19
+ payload = base64.b64encode(
20
+ json.dumps(data, separators=(",", ":"), sort_keys=True).encode("utf-8")
21
+ ).decode("ascii")
22
22
  return f'<script>window.dbdocsData = JSON.parse(atob("{payload}"));</script>'
23
23
 
24
24
 
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