quickdbd-cli 0.4.3__py3-none-any.whl → 0.6.0__py3-none-any.whl

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.
quickdbd_cli/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.4.3"
1
+ __version__ = "0.6.0"
@@ -79,6 +79,7 @@ Declare a relationship once, in either direction.
79
79
  - Tags give tables a role and a header colour: `# @tag: gold, pii` above a table. Names like `dim_`, `fct_`, `stg_`, `int_`, `raw_` are tagged automatically.
80
80
  - `# @color gold #b8860b` sets a tag's colour (anywhere in any file).
81
81
  - Columns take tags too, on or above the column line: `discount decimal # @tag: added`. The row is tinted in the tag's colour; `added` is green, `removed` is red and struck through. Use them to mark proposed column changes for review.
82
+ - `# @dbt: ../my_dbt_project` (anywhere in one file; the path is relative to that file) links a dbt project. Tables matching a model, seed, snapshot or source (`"source_name.table"`) by name get its yml description (shown before the comment), tags and `data_type` (for columns with no type). Where the schema and the yml disagree, `qdbd check` warns (`--strict` fails). `# @dbt: none` above a table skips it.
82
83
 
83
84
  ## Settings and layout sections
84
85
 
@@ -128,7 +129,7 @@ Ref: order_items.(order_id, region) > orders.(id, region)
128
129
  - Drawn: tables (`schema.table`, `public.users` = `users`), columns and types, `pk`, `null`, `unique`, `increment`, `default:`, indexes, `note`/`Note`, every `Ref` form (short, long, inline, composite, with `?` for an optional side), `TableGroup`, `headercolor`, `TablePartial`.
129
130
  - Accepted but not drawn: `Project`, `Enum` (the column shows the enum name as its type), `checks`, `records`, sticky notes, ref `delete:`/`update:`, `TableGroup` colours and notes.
130
131
  - `use` imports aren't followed: every file you give qdbd is read into one namespace.
131
- - `// @tag:` and `// @color` comments work; plain `//` comments are not documentation (use `note`).
132
+ - `// @tag:`, `// @color` and `// @dbt:` comments work; plain `//` comments are not documentation (use `note`).
132
133
 
133
134
  # Notes
134
135
 
quickdbd_cli/cli.py CHANGED
@@ -32,11 +32,12 @@ def _open(args) -> Project:
32
32
  return Project.open([Path(p) for p in args.paths], Path(args.layout) if getattr(args, "layout", None) else None)
33
33
 
34
34
 
35
+ def _name(p: Path) -> str:
36
+ return p.stem if p.is_file() else (p.resolve().name or "diagram")
37
+
38
+
35
39
  def _title(project: Project) -> str:
36
- if len(project.inputs) == 1:
37
- p = project.inputs[0]
38
- return p.stem if p.is_file() else (p.resolve().name or "diagram")
39
- return "diagram"
40
+ return _name(project.inputs[0]) if len(project.inputs) == 1 else "diagram"
40
41
 
41
42
 
42
43
  def _github_annotation(d: Diagnostic, level: str) -> str:
@@ -57,14 +58,14 @@ def _symbols(schema: Schema) -> dict:
57
58
  tables = []
58
59
  for t in schema.tables.values():
59
60
  cols = [
60
- {"name": c.name, "line": c.line, "type": c.type, "pk": c.pk, "fk": c.fk}
61
+ {"name": c.name, "line": c.line, "type": c.shown_type(), "pk": c.pk, "fk": c.fk}
61
62
  for c in t.columns.values()
62
63
  if not c.virtual
63
64
  ]
64
65
  end = t.end_line or max([t.line + 1] + [c["line"] for c in cols])
65
66
  table = {"name": t.name, "aliases": t.aliases, "file": t.path, "line": t.line, "endLine": end, "columns": cols}
66
- if t.comments:
67
- table["doc"] = "\n".join(t.comments)
67
+ if t.doc():
68
+ table["doc"] = t.doc()
68
69
  tables.append(table)
69
70
 
70
71
  def end(e) -> dict:
@@ -76,7 +77,30 @@ def _symbols(schema: Schema) -> dict:
76
77
  return {"tables": tables, "rels": rels}
77
78
 
78
79
 
80
+ def _check_each(args) -> int:
81
+ """Check each PATH on its own, as ``build --site`` builds them; folders with no schema files are skipped."""
82
+ from .project import discover
83
+
84
+ if args.format == "json" or args.stdin_file:
85
+ print("error: --each can't be used with --format json or --stdin-file", file=sys.stderr)
86
+ return 2
87
+ code, checked = 0, 0
88
+ for p in args.paths:
89
+ if Path(p).is_dir() and not discover([Path(p)]):
90
+ print(f"Skipped {p}: no .txt or .dbml schema files", file=sys.stderr)
91
+ continue
92
+ print(f"{p}:")
93
+ code = max(code, cmd_check(argparse.Namespace(**{**vars(args), "paths": [p], "each": False})))
94
+ checked += 1
95
+ if not checked:
96
+ print("error: no schema files found in: " + ", ".join(args.paths), file=sys.stderr)
97
+ return 2
98
+ return code
99
+
100
+
79
101
  def cmd_check(args) -> int:
102
+ if args.each:
103
+ return _check_each(args)
80
104
  project = _open(args)
81
105
  overrides = None
82
106
  if args.stdin_file:
@@ -143,7 +167,52 @@ def _built_page(args) -> tuple[str, dict] | int:
143
167
  return render_page(model, pos, _title(project)), model
144
168
 
145
169
 
170
+ def _build_site(args) -> int:
171
+ """One page per PATH plus an index linking them, in the folder ``args.site``."""
172
+ from urllib.parse import quote
173
+
174
+ from .html import render_index
175
+ from .project import discover
176
+
177
+ if args.layout:
178
+ print("error: --layout can't be used with --site: each PATH uses its own layout file", file=sys.stderr)
179
+ return 2
180
+ pages, seen, failed = [], {}, 0
181
+ for p in map(Path, args.paths):
182
+ if p.is_dir() and not discover([p]):
183
+ print(f"Skipped {p}: no .txt or .dbml schema files", file=sys.stderr)
184
+ continue
185
+ name = _name(p)
186
+ if name.lower() in seen or name.lower() == "index":
187
+ other = seen.get(name.lower(), "the index page")
188
+ print(f"error: {p} and {other} would both be written to {name}.html", file=sys.stderr)
189
+ return 2
190
+ seen[name.lower()] = p
191
+ res = _built_page(argparse.Namespace(**{**vars(args), "paths": [str(p)]}))
192
+ if isinstance(res, int):
193
+ failed = max(failed, res)
194
+ continue
195
+ pages.append((name, *res))
196
+ if failed: # nothing is written, so a failed build never leaves a half-updated site
197
+ return failed
198
+ if not pages:
199
+ print("error: no schema files found in: " + ", ".join(args.paths), file=sys.stderr)
200
+ return 2
201
+ out = Path(args.site)
202
+ out.mkdir(parents=True, exist_ok=True)
203
+ index = []
204
+ for name, page, model in pages:
205
+ (out / f"{name}.html").write_text(page, encoding="utf-8")
206
+ index.append((name, quote(f"{name}.html"), len(model["tables"]), len(model["rels"])))
207
+ print(f"Wrote {out / f'{name}.html'} ({len(model['tables'])} tables, {len(model['rels'])} relationships)")
208
+ (out / "index.html").write_text(render_index(index), encoding="utf-8")
209
+ print(f"Wrote {out / 'index.html'} ({len(pages)} diagrams)")
210
+ return 0
211
+
212
+
146
213
  def cmd_build(args) -> int:
214
+ if args.site:
215
+ return _build_site(args)
147
216
  res = _built_page(args)
148
217
  if isinstance(res, int):
149
218
  return res
@@ -337,13 +406,13 @@ def cmd_convert(args) -> int:
337
406
  root = next((p for p in inputs if p.is_dir() and f.resolve().is_relative_to(p.resolve())), None)
338
407
  rel = f.resolve().relative_to(root.resolve()).as_posix() if root else f.name
339
408
  sources.append((str(f), rel, f.read_text(encoding="utf-8")))
340
- schema = compile_sources([(d, t) for d, _, t in sources])
409
+ schema = compile_sources([(d, t) for d, _, t in sources], dbt=False)
341
410
  if not _print_diagnostics(schema, False):
342
411
  return 1
343
412
  res = convert.convert(sources, args.to, split_groups=to_dir)
344
413
  for w in res.warnings:
345
414
  print(w.format(), file=sys.stderr)
346
- again = compile_sources(list(res.files.items()))
415
+ again = compile_sources(list(res.files.items()), dbt=False)
347
416
  if again.errors or convert.canonical(again, res.lost) != convert.canonical(schema, res.lost):
348
417
  for d in again.errors:
349
418
  print(d.format(), file=sys.stderr)
@@ -575,11 +644,20 @@ def build_parser() -> argparse.ArgumentParser:
575
644
  metavar="PATH",
576
645
  help="read this schema file's text from stdin instead of disk (an editor's unsaved buffer)",
577
646
  )
647
+ sp.add_argument(
648
+ "--each",
649
+ action="store_true",
650
+ help="check each PATH as its own schema, as build --site does (folders with no schema files are skipped)",
651
+ )
578
652
  sp.set_defaults(func=cmd_check)
579
653
 
580
654
  sp = sub.add_parser("build", help="write an interactive HTML diagram")
581
655
  paths(sp)
582
- sp.add_argument("-o", "--output", default="diagram.html")
656
+ out = sp.add_mutually_exclusive_group()
657
+ out.add_argument("-o", "--output", default="diagram.html")
658
+ out.add_argument(
659
+ "--site", metavar="DIR", help="one page per PATH (named after it) plus an index.html linking them, in DIR"
660
+ )
583
661
  selectors(sp)
584
662
  sp.add_argument(
585
663
  "--diff-base", metavar="GIT_REF", help="colour tables/columns changed since this git ref (e.g. origin/main)"
quickdbd_cli/convert.py CHANGED
@@ -38,7 +38,7 @@ class ConvertResult:
38
38
 
39
39
  def convert(sources: list[tuple[str, str, str]], to: str, split_groups: bool) -> ConvertResult:
40
40
  """``sources``: ``(display path, output-relative path, text)``. Files already in ``to`` are copied as is."""
41
- schema, parses = compile_files([(d, t) for d, _, t in sources])
41
+ schema, parses = compile_files([(d, t) for d, _, t in sources], dbt=False)
42
42
  res = ConvertResult()
43
43
  for t in schema.tables.values():
44
44
  if t.id in schema.layout:
@@ -88,7 +88,10 @@ def canonical(schema: Schema, lost: set[tuple]) -> dict:
88
88
  if ("col", t.name, c.name, k) in lost:
89
89
  del col[k]
90
90
  cols[c.name] = col
91
- table = {"cols": cols, "doc": _doc(t.comments), "aliases": t.aliases, "tags": t.tags, "dbt_tags": t.dbt_tags}
91
+ table = {
92
+ "cols": cols, "doc": _doc(t.comments), "aliases": t.aliases, "tags": t.tags, "dbt_tags": t.dbt_tags,
93
+ "dbt_skip": t.dbt_skip,
94
+ } # fmt: skip
92
95
  for k in list(table):
93
96
  if ("table", t.name, k) in lost:
94
97
  del table[k]
@@ -153,7 +156,9 @@ class _DbmlWriter:
153
156
  self.warn(line, f"settings: [settings] {k.capitalize()} has no DBML equivalent; dropped")
154
157
  for tag, (color, _) in sorted(fp.colors.items()):
155
158
  lines.append(f"// @color {tag} {color}")
156
- if fp.colors:
159
+ if fp.dbt: # shortcut: kept as written, so it breaks if -o puts the file in another folder
160
+ lines.append(f"// @dbt: {fp.dbt[0]}")
161
+ if fp.colors or fp.dbt:
157
162
  lines.append("")
158
163
  for t in (t for t in self.schema.tables.values() if t.path == fp.path):
159
164
  lines += self.table(t)
@@ -170,6 +175,8 @@ class _DbmlWriter:
170
175
  out.append(f"// @tag: {', '.join(t.tags)}")
171
176
  if t.dbt_tags:
172
177
  out.append(f"// @dbt-tags: {', '.join(t.dbt_tags)}")
178
+ if t.dbt_skip:
179
+ out.append("// @dbt: none")
173
180
  head = f"Table {_dbml_name(t.name)}"
174
181
  if t.aliases:
175
182
  head += f" as {_dbml_name(t.aliases[0])}"
@@ -313,6 +320,8 @@ class _QuickdbdWriter:
313
320
  header.append(f"# enum {name}: {', '.join(values)}")
314
321
  for tag, (color, _) in sorted(fp.colors.items()):
315
322
  header.append(f"# @color {tag} {color}")
323
+ if fp.dbt: # shortcut: kept as written, so it breaks if -o puts the file in another folder
324
+ header.append(f"# @dbt: {fp.dbt[0]}")
316
325
  if not self.split:
317
326
  for group, line in fp.groups.items():
318
327
  self.warn(line, f"TableGroup {group}: QuickDBD groups tables by file (use -o DIR to split); dropped")
@@ -347,6 +356,8 @@ class _QuickdbdWriter:
347
356
  out.append(f"# @tag: {', '.join(t.tags)}")
348
357
  if t.dbt_tags:
349
358
  out.append(f"# @dbt-tags: {', '.join(t.dbt_tags)}")
359
+ if t.dbt_skip:
360
+ out.append("# @dbt: none")
350
361
  out.append(_qdbd_name(t.name) + (f" as {', '.join(t.aliases)}" if t.aliases else ""))
351
362
  out.append("-")
352
363
  for c in t.columns.values():
quickdbd_cli/dbml.py CHANGED
@@ -16,7 +16,18 @@ import unicodedata
16
16
  from dataclasses import dataclass, field
17
17
 
18
18
  from .model import Column, Diagnostic, Table
19
- from .parser import COLOR_RE, HEX_RE, TAG_NAME_RE, TAG_RE, FileParse, PendingRel, encode, split_tags
19
+ from .parser import (
20
+ COLOR_RE,
21
+ DBT_RE,
22
+ HEX_RE,
23
+ TAG_NAME_RE,
24
+ TAG_RE,
25
+ FileParse,
26
+ PendingRel,
27
+ encode,
28
+ is_dbt_none,
29
+ split_tags,
30
+ )
20
31
 
21
32
  # Relationship operator -> (cardinality of the left end, of the right end). A `?` makes its own side optional.
22
33
  _BASE = {"-": ("one", "one"), "<>": ("many", "many"), ">": ("many", "one"), "<": ("one", "many")}
@@ -1287,7 +1298,9 @@ class _Interpreter:
1287
1298
  line = t.line - 1
1288
1299
  while line in self.comments and line not in self.code_lines:
1289
1300
  m = TAG_RE.match(self.comments[line])
1290
- if m:
1301
+ if is_dbt_none(self.comments[line]):
1302
+ t.dbt_skip = True
1303
+ elif m:
1291
1304
  target = t.dbt_tags if m.group(1).lower().startswith("dbt") else t.tags
1292
1305
  target[:0] = [x for x in split_tags(m.group(2)) if x not in target]
1293
1306
  line -= 1
@@ -1306,10 +1319,17 @@ class _Interpreter:
1306
1319
 
1307
1320
  def colors(self) -> None:
1308
1321
  first_code = min(self.code_lines, default=0)
1322
+
1323
+ def directive(text: str) -> bool:
1324
+ return bool(COLOR_RE.match(text) or (DBT_RE.match(text) and not is_dbt_none(text)))
1325
+
1309
1326
  self.fp.general_comments = [
1310
- text for line, text in sorted(self.comments.items()) if line < first_code and not COLOR_RE.match(text)
1327
+ text for line, text in sorted(self.comments.items()) if line < first_code and not directive(text)
1311
1328
  ]
1312
1329
  for line, text in sorted(self.comments.items()):
1330
+ d = DBT_RE.match(text)
1331
+ if d and not is_dbt_none(text):
1332
+ self.fp.set_dbt(d.group(1), line)
1313
1333
  m = COLOR_RE.match(text)
1314
1334
  if not m:
1315
1335
  continue
quickdbd_cli/dbt.py CHANGED
@@ -174,10 +174,12 @@ def _comment_lines(text: str) -> list[str]:
174
174
  return out
175
175
 
176
176
 
177
- def _node_tags(n: dict) -> list[str]:
177
+ def node_tags(n: dict) -> list[str]:
178
178
  """dbt tags (node-level and config), lowercased, in order, usable as qdbd tag names."""
179
179
  out: list[str] = []
180
- raw = list(n.get("tags") or []) + list((n.get("config") or {}).get("tags") or [])
180
+ raw: list = []
181
+ for v in (n.get("tags"), (n.get("config") or {}).get("tags")):
182
+ raw += [v] if isinstance(v, str) else list(v or []) # yml allows `tags: pii`
181
183
  for tag in raw:
182
184
  tag = re.sub(r"[^a-z0-9_.-]+", "-", str(tag).lower()).strip("-.")
183
185
  if tag and tag not in out:
@@ -243,7 +245,7 @@ class _Importer:
243
245
  name = self._base_name(n)
244
246
  if names[name] > 1: # same name in two packages
245
247
  name = f"{n.get('package_name')}.{name}"
246
- t = Tbl(n["unique_id"], name, self._group(n), n.get("description") or "", tags=_node_tags(n))
248
+ t = Tbl(n["unique_id"], name, self._group(n), n.get("description") or "", tags=node_tags(n))
247
249
  self._columns(t, n)
248
250
  self.tables[t.uid] = t
249
251
  self.by_name.setdefault(n.get("name", "").lower(), []).append(t.uid)
quickdbd_cli/dbtyml.py ADDED
@@ -0,0 +1,275 @@
1
+ """`# @dbt: PATH`: descriptions, tags and types from a dbt project's yml, for hand-written schema files.
2
+
3
+ No manifest.json is needed (dbt Cloud projects have none locally). qdbd reads ``dbt_project.yml`` and the yml, md
4
+ and sql files under its resource paths (the root project only; ``dbt_packages/`` and ``target/`` are skipped).
5
+
6
+ - Tables match models, seeds, snapshots and sources (``source_name.table_name``) by name, ignoring case;
7
+ columns match by name inside the matched table. ``# @dbt: none`` above a table opts it out.
8
+ - The dbt description is shown before the schema file's own comment; ``{{ doc('x') }}`` is filled from
9
+ ``{% docs x %}`` blocks. Model and column tags rank below ``# @tag:`` and name prefixes. ``data_type`` fills
10
+ columns that have no type.
11
+ - Drift is reported as warnings (``--strict`` fails on them): a table dbt doesn't have, a column only one side has,
12
+ a type that differs (by base type: ``varchar(50)`` and ``string`` agree).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import re
19
+ from collections.abc import Callable
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+
23
+ import yaml
24
+
25
+ from .dbt import node_tags, norm_type
26
+ from .model import DbtInfo, Schema
27
+
28
+ SKIP_DIRS = {"dbt_packages", "dbt_modules", "target", "logs", "node_modules"}
29
+ WATCHED = (".yml", ".yaml", ".md", ".sql", ".py", ".csv")
30
+ _DOC_REF = re.compile(r"""\{\{\s*doc\(\s*(?:['"][^'"]*['"]\s*,\s*)?['"]([^'"]+)['"]\s*\)\s*\}\}""")
31
+ _DOCS_BLOCK = re.compile(r"\{%-?\s*docs\s+([\w.-]+)\s*-?%\}(.*?)\{%-?\s*enddocs\s*-?%\}", re.S)
32
+ _SNAPSHOT = re.compile(r"\{%-?\s*snapshot\s+(\w+)\s*-?%\}")
33
+
34
+ # Base types that mean the same thing across warehouses; sizes and precision are ignored.
35
+ _FAMILIES = {
36
+ "int": "int integer int2 int4 int8 int16 int32 int64 smallint bigint tinyint byteint mediumint hugeint long short",
37
+ "decimal": "decimal numeric number dec bignumeric",
38
+ "float": "float float4 float8 float32 float64 double double_precision real",
39
+ "string": "string text varchar char character nvarchar nchar bpchar varchar2 nvarchar2 citext clob",
40
+ "bool": "bool boolean",
41
+ "timestamp": "timestamp timestamptz datetime datetime2 smalldatetime datetimeoffset timestamp_ntz timestamp_ltz "
42
+ "timestamp_tz",
43
+ "time": "time timetz",
44
+ "json": "json jsonb variant object super",
45
+ "binary": "binary varbinary bytes bytea blob",
46
+ "uuid": "uuid uniqueidentifier",
47
+ "array": "array list",
48
+ }
49
+ _BASE = {name: family for family, names in _FAMILIES.items() for name in names.split()}
50
+
51
+
52
+ class DbtError(Exception):
53
+ pass
54
+
55
+
56
+ @dataclass
57
+ class DbtColumn:
58
+ name: str
59
+ description: str
60
+ data_type: str | None
61
+ tags: list[str]
62
+ line: int
63
+
64
+
65
+ @dataclass
66
+ class DbtModel:
67
+ name: str # model / seed / snapshot name, or `source_name.table_name`
68
+ description: str
69
+ tags: list[str]
70
+ columns: dict[str, DbtColumn] # by lowercased name
71
+ path: str # yml file, relative to the project root
72
+ line: int
73
+
74
+
75
+ @dataclass
76
+ class DbtProject:
77
+ root: Path
78
+ models: dict[str, DbtModel] = field(default_factory=dict) # by lowercased name
79
+ known: set[str] = field(default_factory=set) # lowercased names that exist as files, documented or not
80
+ ambiguous: set[str] = field(default_factory=set)
81
+ docs: dict[str, str] = field(default_factory=dict) # `{% docs name %}` blocks
82
+
83
+
84
+ class _Loader(getattr(yaml, "CSafeLoader", yaml.SafeLoader)): # type: ignore[misc]
85
+ """Records each mapping's line as `__line__`, so drift messages can point into the yml."""
86
+
87
+ def construct_mapping(self, node, deep=False):
88
+ m = super().construct_mapping(node, deep=deep)
89
+ m["__line__"] = node.start_mark.line + 1
90
+ return m
91
+
92
+
93
+ # (path, parser) -> (mtime_ns, parsed): keeps `qdbd serve` recompiles cheap
94
+ _cache: dict[tuple[Path, Callable], tuple[int, object]] = {}
95
+
96
+
97
+ def _cached(path: Path, parse: Callable[[str], object]) -> object:
98
+ mtime = path.stat().st_mtime_ns
99
+ hit = _cache.get((path, parse))
100
+ if hit is None or hit[0] != mtime:
101
+ hit = _cache[(path, parse)] = (mtime, parse(path.read_text(encoding="utf-8")))
102
+ return hit[1]
103
+
104
+
105
+ def _load_yaml(text: str) -> object:
106
+ return yaml.load(text, Loader=_Loader) # a SafeLoader: no arbitrary Python objects
107
+
108
+
109
+ def _yaml(path: Path, root: Path) -> object:
110
+ try:
111
+ return _cached(path, _load_yaml)
112
+ except yaml.YAMLError as e:
113
+ mark = getattr(e, "problem_mark", None)
114
+ where = f"{path.relative_to(root).as_posix()}:{mark.line + 1}" if mark else path.relative_to(root).as_posix()
115
+ raise DbtError(f"{where}: invalid YAML ({getattr(e, 'problem', None) or e})") from None
116
+
117
+
118
+ def find_root(path: Path) -> Path:
119
+ """The dbt project a `# @dbt:` path names: its folder, or its dbt_project.yml."""
120
+ root = path.parent if path.name == "dbt_project.yml" else path
121
+ if not (root / "dbt_project.yml").is_file():
122
+ raise DbtError(f"no dbt_project.yml in {root}")
123
+ return root
124
+
125
+
126
+ def _paths(root: Path) -> tuple[dict[str, list[Path]], list[Path]]:
127
+ """(resource kind -> folders, docs folders), from dbt_project.yml with dbt's defaults."""
128
+ cfg = _yaml(root / "dbt_project.yml", root)
129
+ cfg = cfg if isinstance(cfg, dict) else {}
130
+
131
+ def get(key: str, old: str | None = None, default: str | None = None) -> list[Path]:
132
+ v = cfg.get(key) or (cfg.get(old) if old else None) or ([default] if default else [])
133
+ out = []
134
+ for p in [v] if isinstance(v, str) else v:
135
+ # dbt resolves these inside the project; anything else (`/`, `../..`) would walk the whole disk
136
+ if not (root / str(p)).resolve().is_relative_to(root.resolve()):
137
+ raise DbtError(f"dbt_project.yml: {key} '{p}' is outside the project")
138
+ out.append(root / str(p))
139
+ return out
140
+
141
+ kinds = {
142
+ "model": get("model-paths", "source-paths", "models"),
143
+ "seed": get("seed-paths", "data-paths", "seeds"),
144
+ "snapshot": get("snapshot-paths", None, "snapshots"),
145
+ }
146
+ doc_dirs = get("docs-paths")
147
+ if not doc_dirs: # dbt's default: every resource path
148
+ doc_dirs = [d for ds in kinds.values() for d in ds]
149
+ doc_dirs += get("analysis-paths", None, "analyses") + get("macro-paths", None, "macros")
150
+ return kinds, doc_dirs
151
+
152
+
153
+ def _walk(dirs: list[Path], suffixes: tuple[str, ...]) -> list[Path]:
154
+ out: dict[Path, None] = {}
155
+ for d in dirs:
156
+ for dirpath, dirnames, filenames in os.walk(d):
157
+ dirnames[:] = sorted(n for n in dirnames if not n.startswith(".") and n not in SKIP_DIRS)
158
+ for n in sorted(filenames):
159
+ if n.lower().endswith(suffixes):
160
+ out[Path(dirpath, n)] = None
161
+ return list(out)
162
+
163
+
164
+ def watched_files(root: Path) -> list[Path]:
165
+ """Every file `load_project` reads (or may read once it exists), for `qdbd serve`'s change polling."""
166
+ kinds, doc_dirs = _paths(root)
167
+ return [root / "dbt_project.yml", *_walk([d for ds in kinds.values() for d in ds] + doc_dirs, WATCHED)]
168
+
169
+
170
+ def _text(v: object) -> str:
171
+ return str(v).strip() if v is not None else ""
172
+
173
+
174
+ def _column_name(v: object) -> str:
175
+ return _text(v).strip('"`')
176
+
177
+
178
+ def load_project(root: Path) -> DbtProject:
179
+ kinds, doc_dirs = _paths(root)
180
+ project = DbtProject(root)
181
+ for p in _walk(kinds["model"], (".sql", ".py")):
182
+ project.known.add(p.stem.lower())
183
+ for p in _walk(kinds["seed"], (".csv",)):
184
+ project.known.add(p.stem.lower())
185
+ for p in _walk(kinds["snapshot"], (".sql",)):
186
+ project.known.update(m.lower() for m in _cached(p, _SNAPSHOT.findall)) # type: ignore[union-attr]
187
+ for p in _walk(doc_dirs, (".md",)):
188
+ for name, body in _cached(p, _DOCS_BLOCK.findall): # type: ignore[union-attr]
189
+ project.docs[name] = body.strip()
190
+ for p in _walk([d for ds in kinds.values() for d in ds], (".yml", ".yaml")):
191
+ data = _yaml(p, root)
192
+ if not isinstance(data, dict):
193
+ continue
194
+ rel = p.relative_to(root).as_posix()
195
+ for key in ("models", "seeds", "snapshots"):
196
+ for n in data.get(key) or []:
197
+ if isinstance(n, dict) and n.get("name"):
198
+ _add(project, _text(n["name"]), n, rel)
199
+ for src in data.get("sources") or []:
200
+ if not isinstance(src, dict) or not src.get("name"):
201
+ continue
202
+ for n in src.get("tables") or []:
203
+ if isinstance(n, dict) and n.get("name"):
204
+ _add(project, f"{_text(src['name'])}.{_text(n['name'])}", n, rel)
205
+ return project
206
+
207
+
208
+ def _add(project: DbtProject, name: str, n: dict, rel: str) -> None:
209
+ key = name.lower()
210
+ if key in project.models:
211
+ project.ambiguous.add(key)
212
+ return
213
+ cols: dict[str, DbtColumn] = {}
214
+ for c in n.get("columns") or []:
215
+ if isinstance(c, dict) and _column_name(c.get("name")):
216
+ cname = _column_name(c["name"])
217
+ dtype = _text(c.get("data_type")) or None
218
+ cols[cname.lower()] = DbtColumn(cname, _text(c.get("description")), dtype, node_tags(c), c["__line__"])
219
+ project.models[key] = DbtModel(name, _text(n.get("description")), node_tags(n), cols, rel, n["__line__"])
220
+ project.known.add(key)
221
+
222
+
223
+ def base_type(t: str) -> str:
224
+ """`varchar(255)` -> `string`, `INTEGER` -> `int`: what two types must share to count as the same."""
225
+ s = re.split(r"[(<\[]", (norm_type(t) or "").lower())[0].strip("_ ")
226
+ return _BASE.get(s, s)
227
+
228
+
229
+ Report = Callable[[str, int, str, str], None] # (path, line, message, severity)
230
+
231
+
232
+ def apply(schema: Schema, project: DbtProject, report: Report) -> None:
233
+ """Attach dbt descriptions, tags and types to the matching tables and columns, and report drift."""
234
+
235
+ def doc(text: str, path: str, line: int) -> str:
236
+ def sub(m: re.Match) -> str:
237
+ if m.group(1) in project.docs:
238
+ return project.docs[m.group(1)]
239
+ report(path, line, f"dbt: no docs block named '{m.group(1)}'", "warning")
240
+ return m.group(0)
241
+
242
+ return _DOC_REF.sub(sub, text)
243
+
244
+ for t in schema.tables.values():
245
+ key = t.name.lower()
246
+ if t.dbt_skip or key in project.ambiguous:
247
+ continue
248
+ m = project.models.get(key)
249
+ if m is None:
250
+ if key not in project.known:
251
+ report(
252
+ t.path,
253
+ t.line,
254
+ f"dbt: no model, seed, snapshot or source named '{t.name}' "
255
+ "(put '# @dbt: none' above the table if it isn't one)",
256
+ "warning",
257
+ )
258
+ continue
259
+ t.dbt = DbtInfo(doc(m.description, t.path, t.line), list(m.tags))
260
+ cols = {c.name.lower(): c for c in t.columns.values() if not c.virtual}
261
+ for ckey, dc in m.columns.items():
262
+ c = cols.get(ckey)
263
+ if c is None:
264
+ msg = f"dbt: column '{dc.name}' ({m.path}:{dc.line}) is missing from '{t.name}'"
265
+ report(t.path, t.line, msg, "warning")
266
+ continue
267
+ c.dbt = DbtInfo(doc(dc.description, t.path, c.line), list(dc.tags), norm_type(dc.data_type))
268
+ if dc.data_type and c.type and not c.type_default and base_type(c.type) != base_type(dc.data_type):
269
+ msg = f"dbt: type '{c.type}' differs from data_type '{dc.data_type}' ({m.path}:{dc.line})"
270
+ report(t.path, c.line, msg, "warning")
271
+ if m.columns:
272
+ for ckey, c in cols.items():
273
+ if ckey not in m.columns:
274
+ msg = f"dbt: column '{c.name}' is not in the yml for '{m.name}' ({m.path}:{m.line})"
275
+ report(t.path, c.line, msg, "warning")
quickdbd_cli/diff.py CHANGED
@@ -175,7 +175,7 @@ def sources_at_ref(ref: str, inputs: list[Path]) -> list[tuple[str, str]]:
175
175
 
176
176
 
177
177
  def schema_at_ref(ref: str, inputs: list[Path]) -> Schema:
178
- return compile_sources(sources_at_ref(ref, inputs))
178
+ return compile_sources(sources_at_ref(ref, inputs), dbt=False)
179
179
 
180
180
 
181
181
  # --------------------------------------------------------------------------- output
quickdbd_cli/html.py CHANGED
@@ -190,3 +190,28 @@ def render_page(
190
190
  f"<script>{_asset('viewer.js')}</script>"
191
191
  f"{scripts}</body></html>\n"
192
192
  )
193
+
194
+
195
+ INDEX_CSS = (
196
+ ":root{color-scheme:light dark;--fg:#1f2328;--muted:#656d76;--bg:#fff;--line:#d0d7de}"
197
+ "@media (prefers-color-scheme:dark){:root{--fg:#e6edf3;--muted:#8d96a0;--bg:#0d1117;--line:#30363d}}"
198
+ "body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,sans-serif}"
199
+ "main{max-width:640px;margin:0 auto;padding:32px 16px}h1{font-size:20px;margin:0 0 16px}"
200
+ "ul{list-style:none;margin:0;padding:0}li{border-top:1px solid var(--line);padding:10px 0}"
201
+ "a{color:inherit;font-weight:600}span{color:var(--muted);margin-left:8px}"
202
+ )
203
+
204
+
205
+ def render_index(pages: list[tuple[str, str, int, int]], title: str = "Diagrams") -> str:
206
+ """``qdbd build --site``'s index: a link per (name, href, tables, relationships)."""
207
+ items = "".join(
208
+ f'<li><a href="{_escape(href)}">{_escape(name)}</a><span>{t} tables, {r} relationships</span></li>'
209
+ for name, href, t, r in pages
210
+ )
211
+ return (
212
+ '<!doctype html>\n<html lang="en"><head><meta charset="utf-8">'
213
+ '<meta name="viewport" content="width=device-width, initial-scale=1">'
214
+ '<meta name="generator" content="qdbd">'
215
+ f"{favicon()}<title>{_escape(title)}</title><style>{INDEX_CSS}</style></head>"
216
+ f"<body><main><h1>{_escape(title)}</h1><ul>{items}</ul></main></body></html>\n"
217
+ )
quickdbd_cli/measure.py CHANGED
@@ -61,11 +61,12 @@ def _column_json(col: Column) -> dict:
61
61
  bold = col.pk or col.fk
62
62
  name_w = text_width(col.name, s["rowSize"], bold)
63
63
  bs = [{"t": b, "w": round(text_width(b, s["badgeSize"]) + 2 * s["badgePad"], 2)} for b in badges(col)]
64
- type_w = text_width(col.type or "", s["rowSize"])
64
+ typ = col.shown_type() or ""
65
+ type_w = text_width(typ, s["rowSize"])
65
66
  out = {
66
67
  "id": col.id,
67
68
  "name": col.name,
68
- "type": col.type or "",
69
+ "type": typ,
69
70
  "pk": col.pk,
70
71
  "fk": col.fk,
71
72
  "nullable": col.nullable,
@@ -77,10 +78,11 @@ def _column_json(col: Column) -> dict:
77
78
  "typeW": type_w,
78
79
  "badges": bs,
79
80
  }
80
- if col.comments:
81
- out["doc"] = "\n".join(col.comments)
82
- if col.tags:
83
- out["tags"] = list(col.tags)
81
+ if col.doc():
82
+ out["doc"] = col.doc()
83
+ tags = col.tags or (col.dbt.tags if col.dbt else [])
84
+ if tags:
85
+ out["tags"] = list(tags)
84
86
  return out
85
87
 
86
88
 
@@ -98,8 +100,8 @@ def table_json(table: Table) -> dict:
98
100
  w = max(round(w), s["minW"])
99
101
  h = s["headerH"] + len(cols) * s["rowH"] + s["bottomPad"]
100
102
  out = {"id": table.id, "name": table.name, "aliases": table.aliases, "w": w, "h": h, "nameW": name_w, "cols": cols}
101
- if table.comments:
102
- out["doc"] = "\n".join(table.comments)
103
+ if table.doc():
104
+ out["doc"] = table.doc()
103
105
  return out
104
106
 
105
107
 
quickdbd_cli/model.py CHANGED
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from dataclasses import dataclass, field
6
+ from pathlib import Path
6
7
 
7
8
  VIRTUAL_FIELD = "qdbdvirtualfield"
8
9
 
@@ -31,6 +32,25 @@ class Diagnostic:
31
32
  return f"{self.path}:{self.line}: {self.severity}: {self.message}"
32
33
 
33
34
 
35
+ @dataclass
36
+ class DbtInfo:
37
+ """What a dbt project's yml says about a table or column. Kept apart from comments/type/tags, so
38
+ `qdbd convert` never writes it into schema files."""
39
+
40
+ doc: str = ""
41
+ tags: list[str] = field(default_factory=list)
42
+ type: str | None = None
43
+
44
+
45
+ def merged_doc(dbt: DbtInfo | None, comments: list[str]) -> str:
46
+ """The dbt description first, then the schema file's comments (once, if they say the same)."""
47
+ own = "\n".join(comments)
48
+ theirs = dbt.doc if dbt else ""
49
+ if not theirs or " ".join(theirs.split()) == " ".join(own.split()):
50
+ return own
51
+ return theirs + ("\n" + own if own else "")
52
+
53
+
34
54
  @dataclass
35
55
  class Column:
36
56
  id: str
@@ -48,6 +68,16 @@ class Column:
48
68
  virtual: bool = False
49
69
  comments: list[str] = field(default_factory=list)
50
70
  tags: list[str] = field(default_factory=list) # `# @tag: ...` comments
71
+ type_default: bool = False # type came from the file's DefaultFieldType setting
72
+ dbt: DbtInfo | None = None # from the dbt project's yml (`# @dbt: PATH`)
73
+
74
+ def shown_type(self) -> str | None:
75
+ if self.dbt and self.dbt.type and (self.type is None or self.type_default):
76
+ return self.dbt.type
77
+ return self.type
78
+
79
+ def doc(self) -> str:
80
+ return merged_doc(self.dbt, self.comments)
51
81
 
52
82
 
53
83
  @dataclass
@@ -64,6 +94,11 @@ class Table:
64
94
  group: str | None = None # DBML TableGroup; else the explorer groups by file
65
95
  color: str | None = None # DBML headercolor; beats tag colours
66
96
  end_line: int | None = None # DBML: the closing `}` line
97
+ dbt_skip: bool = False # `# @dbt: none` above the table
98
+ dbt: DbtInfo | None = None # from the dbt project's yml (`# @dbt: PATH`)
99
+
100
+ def doc(self) -> str:
101
+ return merged_doc(self.dbt, self.comments)
67
102
 
68
103
 
69
104
  @dataclass
@@ -91,6 +126,7 @@ class Schema:
91
126
  diagnostics: list[Diagnostic] = field(default_factory=list)
92
127
  general_comments: list[str] = field(default_factory=list)
93
128
  tag_colors: dict[str, str] = field(default_factory=dict) # `# @color TAG #hex`
129
+ dbt_root: Path | None = None # the dbt project named by `# @dbt: PATH`
94
130
 
95
131
  @property
96
132
  def errors(self) -> list[Diagnostic]:
quickdbd_cli/parser.py CHANGED
@@ -14,7 +14,9 @@ from __future__ import annotations
14
14
 
15
15
  import re
16
16
  from dataclasses import dataclass, field
17
+ from pathlib import Path
17
18
 
19
+ from . import dbtyml
18
20
  from .model import REL_TYPES, VIRTUAL_FIELD, Column, Diagnostic, End, Relationship, Schema, Table
19
21
 
20
22
  SECTIONS = ["tables", "settings", "layout"]
@@ -25,10 +27,16 @@ REJECT = "QuickDBD would reject this: "
25
27
  # qdbd directives live in comments, so files stay valid QuickDBD.
26
28
  TAG_RE = re.compile(r"^@(tags?|dbt-tags?)\s*:?\s*(.*)$", re.I)
27
29
  COLOR_RE = re.compile(r"^@colou?r\b\s*:?\s*(.*)$", re.I)
30
+ DBT_RE = re.compile(r"^@dbt\s*:\s*(.*?)\s*$", re.I) # `@dbt: PATH` (file) or `@dbt: none` (above a table)
28
31
  TAG_NAME_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.-]*$")
29
32
  HEX_RE = re.compile(r"^#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$")
30
33
 
31
34
 
35
+ def is_dbt_none(comment: str) -> bool:
36
+ m = DBT_RE.match(comment)
37
+ return bool(m) and m.group(1).lower() == "none"
38
+
39
+
32
40
  def split_tags(text: str) -> list[str]:
33
41
  out: list[str] = []
34
42
  for t in re.split(r"[\s,]+", text.strip()):
@@ -219,6 +227,13 @@ class FileParse:
219
227
  unmodelled: list[tuple[int, str]] = field(default_factory=list) # DBML: (line, what) parsed, not drawn
220
228
  enums: dict[str, list[str]] = field(default_factory=dict) # DBML: enum name -> values
221
229
  groups: dict[str, int] = field(default_factory=dict) # DBML: TableGroup name -> line
230
+ dbt: tuple[str, int] | None = None # `# @dbt: PATH`: (path as written, line)
231
+
232
+ def set_dbt(self, value: str, line: int) -> None:
233
+ if self.dbt and self.dbt[0] != value:
234
+ self.error(line, f"@dbt differs from line {self.dbt[1]} ('{self.dbt[0]}')")
235
+ elif not self.dbt:
236
+ self.dbt = (value, line)
222
237
 
223
238
  def error(self, line: int, msg: str) -> None:
224
239
  self.diagnostics.append(Diagnostic(self.path, line, msg, "error"))
@@ -254,6 +269,10 @@ class _FileParser:
254
269
  full = re.search(r"#\s*(@colou?r\b.*)$", raw, re.I)
255
270
  self._color(full.group(1).strip() if full else comment, lineno)
256
271
  comment = None
272
+ m = DBT_RE.match(comment) if comment else None
273
+ if m and not is_dbt_none(comment):
274
+ self.fp.set_dbt(m.group(1), lineno)
275
+ comment = None
257
276
  if comment:
258
277
  pending.append(comment)
259
278
  elif blank and not seen_data:
@@ -410,7 +429,9 @@ class _FileParser:
410
429
  table = Table(ident, caption, fp.path, e.line, aliases, {})
411
430
  for c in e.comments:
412
431
  m = TAG_RE.match(c)
413
- if not m:
432
+ if is_dbt_none(c):
433
+ table.dbt_skip = True
434
+ elif not m:
414
435
  table.comments.append(c)
415
436
  elif m.group(1).lower().startswith("dbt"):
416
437
  table.dbt_tags += [t for t in split_tags(m.group(2)) if t not in table.dbt_tags]
@@ -567,6 +588,7 @@ class _FileParser:
567
588
  for c in t.columns.values():
568
589
  if c.type is None and not c.virtual:
569
590
  c.type = default_type
591
+ c.type_default = True
570
592
  self.fp.layout_entries = sections["layout"]
571
593
  return self.fp
572
594
 
@@ -628,8 +650,9 @@ def parse_file(text: str, path: str = "<input>") -> FileParse:
628
650
 
629
651
 
630
652
  class _Resolver:
631
- def __init__(self, files: list[FileParse]):
653
+ def __init__(self, files: list[FileParse], dbt: bool = True):
632
654
  self.files = files
655
+ self.dbt = dbt
633
656
  self.schema = Schema()
634
657
  self.aliases: dict[str, str] = {}
635
658
  self._diag: dict[str, list[Diagnostic]] = {f.path: f.diagnostics for f in files}
@@ -803,10 +826,29 @@ class _Resolver:
803
826
  continue
804
827
  s.layout[t.id] = (float(xs), float(ys))
805
828
 
829
+ def apply_dbt(self) -> None:
830
+ """`# @dbt: PATH`: descriptions, tags and types from that dbt project's yml, plus drift warnings."""
831
+ named = [(f, Path(f.path).parent / f.dbt[0], f.dbt[1]) for f in self.files if f.dbt]
832
+ if not named:
833
+ return
834
+ first, path, line = named[0]
835
+ for f, other, ln in named[1:]:
836
+ if other.resolve() != path.resolve():
837
+ self.error(f.path, ln, f"@dbt names a different dbt project than {first.path}:{line}")
838
+ try:
839
+ self.schema.dbt_root = dbtyml.find_root(path) # set first: `qdbd serve` watches it even while broken
840
+ project = dbtyml.load_project(self.schema.dbt_root)
841
+ except (dbtyml.DbtError, OSError) as e:
842
+ self.error(first.path, line, f"dbt: {e}")
843
+ return
844
+ dbtyml.apply(self.schema, project, self.error)
845
+
806
846
  def run(self) -> Schema:
807
847
  self.merge()
808
848
  self.resolve_rels()
809
849
  self.parse_layout()
850
+ if self.dbt:
851
+ self.apply_dbt()
810
852
  diags = [d for f in self.files for d in self._diag.get(f.path, [])]
811
853
  order = {f.path: i for i, f in enumerate(self.files)}
812
854
  diags.sort(key=lambda d: (order.get(d.path, 0), d.line))
@@ -820,15 +862,17 @@ def _same_rel(r: Relationship, a: End, b: End) -> bool:
820
862
  )
821
863
 
822
864
 
823
- def compile_files(sources: list[tuple[str, str]]) -> tuple[Schema, list[FileParse]]:
865
+ def compile_files(sources: list[tuple[str, str]], dbt: bool = True) -> tuple[Schema, list[FileParse]]:
824
866
  """Like ``compile_sources``, also returning each file's own parse (comments, settings, DBML extras)."""
825
867
  files = [parse_file(text, path) for path, text in sources]
826
- return _Resolver(files).run(), files
868
+ return _Resolver(files, dbt).run(), files
869
+
827
870
 
871
+ def compile_sources(sources: list[tuple[str, str]], dbt: bool = True) -> Schema:
872
+ """Compile ``[(path, text), ...]`` into one Schema (tables share one namespace).
828
873
 
829
- def compile_sources(sources: list[tuple[str, str]]) -> Schema:
830
- """Compile ``[(path, text), ...]`` into one Schema (tables share one namespace)."""
831
- return compile_files(sources)[0]
874
+ ``dbt=False`` skips `# @dbt:` (the yml is read from disk, which a converted or git-ref copy can't rely on)."""
875
+ return compile_files(sources, dbt)[0]
832
876
 
833
877
 
834
878
  def compile_text(text: str, path: str = "<input>") -> Schema:
quickdbd_cli/serve.py CHANGED
@@ -1,12 +1,13 @@
1
1
  """`qdbd serve`: local live editor.
2
2
 
3
- Watches the schema files (mtime polling), recompiles on change and pushes a reload event to the
4
- page over Server-Sent Events. Dragged table positions are saved to the layout sidecar; the
5
- browser editor pane writes the .txt/.dbml files, refusing to overwrite a file changed on disk since
6
- the browser last loaded it. With ``read_only`` the page is the plain viewer plus live reload, and
7
- the server never writes anything. With ``embed`` (the VS Code extension's preview) the page is the
8
- viewer plus live reload; dragging still saves the layout, but files are only edited in VS Code. With
9
- ``diff_base`` every reload is coloured by its changes since that git ref, like ``build --diff-base``.
3
+ Watches the schema files and the dbt project a `# @dbt:` line names (mtime polling), recompiles on
4
+ change and pushes a reload event to the page over Server-Sent Events. Dragged table positions are
5
+ saved to the layout sidecar; the browser editor pane writes the .txt/.dbml files, refusing to
6
+ overwrite a file changed on disk since the browser last loaded it. With ``read_only`` the page is
7
+ the plain viewer plus live reload, and the server never writes anything. With ``embed`` (the VS Code
8
+ extension's preview) the page is the viewer plus live reload; dragging still saves the layout, but
9
+ files are only edited in VS Code. With ``diff_base`` every reload is coloured by its changes since
10
+ that git ref, like ``build --diff-base``.
10
11
  """
11
12
 
12
13
  from __future__ import annotations
@@ -22,7 +23,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
22
23
  from pathlib import Path
23
24
  from urllib.parse import urlsplit
24
25
 
25
- from . import diff, layout
26
+ from . import dbtyml, diff, layout
26
27
  from .html import render_page
27
28
  from .parser import syntax_of
28
29
  from .project import Project, discover, display_path
@@ -43,16 +44,26 @@ class State:
43
44
  self.files: list[Path] = []
44
45
  # mtimes are strings: nanosecond integers exceed JavaScript's safe integer range.
45
46
  self.mtimes: dict[str, str] = {}
47
+ self.dbt_root: Path | None = None
46
48
  self.reload(initial=True)
47
49
 
48
50
  def _scan(self) -> dict[str, str]:
49
51
  self.files = discover(self.project.inputs)
50
52
  self.project.files = self.files
51
- return {display_path(f): str(f.stat().st_mtime_ns) for f in self.files}
53
+ out = {display_path(f): str(f.stat().st_mtime_ns) for f in self.files}
54
+ if self.dbt_root:
55
+ try:
56
+ out.update({f"dbt:{f}": str(f.stat().st_mtime_ns) for f in dbtyml.watched_files(self.dbt_root)})
57
+ except (dbtyml.DbtError, OSError):
58
+ out["dbt:"] = "unreadable"
59
+ return out
52
60
 
53
61
  def reload(self, initial: bool = False) -> None:
54
62
  mtimes = self._scan()
55
63
  schema = self.project.compile()
64
+ if schema.dbt_root != self.dbt_root: # a `# @dbt:` line was added, removed or changed
65
+ self.dbt_root = schema.dbt_root
66
+ mtimes = self._scan()
56
67
  diags = [
57
68
  {"path": d.path, "line": d.line, "message": d.message, "severity": d.severity, "text": d.format()}
58
69
  for d in schema.diagnostics
quickdbd_cli/tags.py CHANGED
@@ -4,7 +4,7 @@ A table's tags come from the first source that has any:
4
4
 
5
5
  1. explicit ``# @tag: dim, pii`` comments;
6
6
  2. its name: ``dim_*`` / ``DimCustomer`` -> dim, ``fct_*`` -> fact, ``stg_*`` -> staging, ...;
7
- 3. ``# @dbt-tags: ...`` comments written by ``qdbd import dbt``.
7
+ 3. dbt tags: ``# @dbt-tags: ...`` comments written by ``qdbd import dbt``, and the yml's tags (``# @dbt: PATH``).
8
8
 
9
9
  The header takes the colour of the table's first tag. ``# @color TAG #hex`` overrides the defaults, and a DBML
10
10
  ``headercolor`` beats both.
@@ -70,7 +70,7 @@ def table_tags(t: Table) -> list[str]:
70
70
  tag = name_tag(t.name)
71
71
  if tag:
72
72
  return [tag]
73
- return list(t.dbt_tags)
73
+ return list(dict.fromkeys(t.dbt_tags + (t.dbt.tags if t.dbt else [])))
74
74
 
75
75
 
76
76
  def tag_colors(schema: Schema, tag_lists: Iterable[list[str]]) -> dict[str, str]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: quickdbd-cli
3
- Version: 0.4.3
3
+ Version: 0.6.0
4
4
  Summary: Compile QuickDBD-syntax schema files into interactive HTML ER diagrams, with no table limit.
5
5
  Project-URL: Homepage, https://qdbd.dev/
6
6
  Project-URL: Documentation, https://qdbd.dev/guide/
@@ -25,6 +25,7 @@ Classifier: Topic :: Database
25
25
  Classifier: Topic :: Software Development :: Documentation
26
26
  Requires-Python: >=3.11
27
27
  Requires-Dist: mini-racer>=0.14
28
+ Requires-Dist: pyyaml>=6
28
29
  Requires-Dist: resvg-py>=0.5
29
30
  Provides-Extra: dev
30
31
  Requires-Dist: fonttools>=4.50; extra == 'dev'
@@ -193,7 +194,7 @@ jobs:
193
194
  permissions: { contents: read, pull-requests: write }
194
195
  steps:
195
196
  - uses: actions/checkout@v4
196
- - uses: MarkPhamm/quickdbd-cli@v0.4.2
197
+ - uses: MarkPhamm/quickdbd-cli@v0.6.0
197
198
  with:
198
199
  path: erd/ # schema file(s) or folder(s)
199
200
  output: site/index.html
@@ -1,23 +1,24 @@
1
- quickdbd_cli/__init__.py,sha256=Nyg0pmk5ea9-SLCAFEIF96ByFx4-TJFtrqYPN-Zn6g4,22
1
+ quickdbd_cli/__init__.py,sha256=cID1jLnC_vj48GgMN6Yb1FA3JsQ95zNmCHmRYE8TFhY,22
2
2
  quickdbd_cli/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
3
3
  quickdbd_cli/agent.py,sha256=LEuReTkwWDlRLs-GfQQMpMosHDFWbin0EAXRo73j-dA,37202
4
- quickdbd_cli/cli.py,sha256=kphlsJHzp-agb01rlw4PD2_SnriOtr-yRZPuW3I5B_E,30634
5
- quickdbd_cli/convert.py,sha256=1AYFm4YmEav_wZp7xCAcL7TsD3qC17Y1I5g6V_fyAU0,16682
6
- quickdbd_cli/dbml.py,sha256=tHJOtMm1PHx4-yqs6rncPqdDfbwPcW8F54j_kKlWkx0,59413
7
- quickdbd_cli/dbt.py,sha256=AMHzERmc8WfL1tBLX4bJdPVghIHrqrfx8nMDbZMrueY,23100
8
- quickdbd_cli/diff.py,sha256=UBiCE3dk_cN-xSRZFQoLiIIFh_FAVZUpLwsdtHG9eBo,9986
4
+ quickdbd_cli/cli.py,sha256=Edv-kROpZOdHA1J1O_G7LrgzrqVJe5pxHJydNnYUs8M,33875
5
+ quickdbd_cli/convert.py,sha256=7jhkXg4csH-sFfJeTYrDtYL-A3HzGojgZtizPsckO0w,17200
6
+ quickdbd_cli/dbml.py,sha256=jCgGH5YH0JG1Necsjhru1PyE52wdlaJd8QAuqn5JIts,59828
7
+ quickdbd_cli/dbt.py,sha256=0S7x7DpV6EcBTR6LJ62elZGMw-Guq-r3jULV1JWU5vs,23184
8
+ quickdbd_cli/dbtyml.py,sha256=iFS1cxJUmJrKWfGd6zgFvoPV3C3in4eIF5sRgxUvhOw,11667
9
+ quickdbd_cli/diff.py,sha256=RpoEe7rAFGhittdsHoIQvbFDaRPnvthYXjUH0PCfTxY,9997
9
10
  quickdbd_cli/export.py,sha256=XbSvWqFmhQHPcBtPHPit1SmhJ6qXrPyGlHYt7FpIMyo,3539
10
- quickdbd_cli/html.py,sha256=eZSECAaP-vxC_qGXOy0T7cXT7WkJeL7RixYvzMCDCZQ,8309
11
+ quickdbd_cli/html.py,sha256=tcbxiUmKhKfpAb7Pke1AVSRPK5VWaw9znpOi_3mQkoQ,9634
11
12
  quickdbd_cli/jsrt.py,sha256=_sRkmUbgHDTwK272jeTNUwojfjUKAkv4UeD2E2u3H1A,3464
12
13
  quickdbd_cli/layout.py,sha256=2wrryJ-SSJbMlOdxmtCTzC2_zkTtvRVs-OuOBS1bGEQ,10856
13
14
  quickdbd_cli/mcp.py,sha256=ibUg1a50BBco-hZABdxTexGZi_gclFCdcBDalTvFar8,12350
14
- quickdbd_cli/measure.py,sha256=Nyk47FiJokPvTStKz23mgXkO0HnXKWOts90kp1gCjng,5969
15
- quickdbd_cli/model.py,sha256=lQcj89gDg2y4v_xhJX0_PWoWKGx0rg2emREVL_LA6uQ,3352
16
- quickdbd_cli/parser.py,sha256=MpWvYWNMIfl8PNKGDyVqVA_RUsuD21oZQqTT7yZd89g,33018
15
+ quickdbd_cli/measure.py,sha256=M1Rnf8iLGI1o2ZpbblfPUcAlXItsjHB3zKKA-yd2fQs,5995
16
+ quickdbd_cli/model.py,sha256=_TsWSjyF9N62xYuC-hJwk7VsQ2qGU40Af-_6oRrxhG0,4748
17
+ quickdbd_cli/parser.py,sha256=bdM1KyVGBJKeVY3SUbrKswQ-xqNkaijraHTBqj_qqv0,35066
17
18
  quickdbd_cli/project.py,sha256=Iu6aXJNxCbM9KDRbLaxTb7SfbVDv7PkajzKZX4-XfDk,4570
18
- quickdbd_cli/serve.py,sha256=NqYAcfT4lySRckAG2tkrZwmRbzYDjdNh18Yh4Er_hPo,12271
19
+ quickdbd_cli/serve.py,sha256=_3VHnHc2GRWVnzfowy-8rQNIULEr9DSQ-ylucEaPmtw,12801
19
20
  quickdbd_cli/share.py,sha256=njzF4HpQ-ymusLzzNb_ndvqWGlDaljx7TFP-mZj4lPU,4782
20
- quickdbd_cli/tags.py,sha256=fOMcz3N8YAIqh5uJJ3GuYMBCkfOViGRCVNb3Ufz63GM,4098
21
+ quickdbd_cli/tags.py,sha256=8SjC9K4g8oBYvlFYUXwqRa7EGfheY0T52aGCovJ3nHE,4194
21
22
  quickdbd_cli/targets.py,sha256=zvge0c2_XOC13gnv2C_XMf_ZmDcpOFPzld2PoyOtyxU,8740
22
23
  quickdbd_cli/assets/Inter-Bold.ttf,sha256=KIMWCZseCkekcW0VkJgAXu98AGaSHzTjIAOT29sBlH8,420428
23
24
  quickdbd_cli/assets/Inter-Bold.woff2,sha256=-oiBJ7baAVtlVp8DUfO1w5GtkokElR8cIOn4RiqNleo,114840
@@ -31,13 +32,13 @@ quickdbd_cli/assets/font-metrics.json,sha256=sXSiElxQSK8md8IEePLwh16TTdAqKt9rV14
31
32
  quickdbd_cli/assets/icon.svg,sha256=z1KKpKN3dJbESzbC1EDS3NYOj9aKQw1pJfzwXFPg_M0,338
32
33
  quickdbd_cli/assets/render.js,sha256=WzxX_sUgdz0O4GgiaupKl8J_LI4YCqF9PUxzRbwebwo,31891
33
34
  quickdbd_cli/assets/style.css,sha256=mTkpexDcMilBtds2WRk6SYiCrIA1DpZLueKmm3pRQw0,18877
34
- quickdbd_cli/assets/syntax-reference.md,sha256=WERcsWjyrfeHQPgxQriWKBsqFJnmWTQ2oRZjKYAHhKc,5322
35
+ quickdbd_cli/assets/syntax-reference.md,sha256=r-zMxIrtMLRhTa2QHANmlz4Z_t-8oN-5Rp66j4WzZU4,5743
35
36
  quickdbd_cli/assets/viewer.js,sha256=3rKXI80TNv9jF1dJNQa3Gpy0042yTcGfTz_8qg4h5WU,49676
36
- quickdbd_cli-0.4.3.dist-info/METADATA,sha256=Pqgb4PK2vhe2yV7UDNeeTxFdDcnSeZa_qeJXKEAdzEw,17704
37
- quickdbd_cli-0.4.3.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
38
- quickdbd_cli-0.4.3.dist-info/entry_points.txt,sha256=nRhiYYug-XOY95nWUmi4oanico3Zjkfqk2OHtIE6SQ0,47
39
- quickdbd_cli-0.4.3.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
40
- quickdbd_cli-0.4.3.dist-info/licenses/NOTICE,sha256=pyv3BfIYiGzKLU4w-GcBe7GXoXxHqZ97Mf6Zpx_3_i0,2067
41
- quickdbd_cli-0.4.3.dist-info/licenses/src/quickdbd_cli/assets/Inter-LICENSE.txt,sha256=JiSB6ERSGzJvXs0FPlm5jIstp4yO4b27boF0MF5Uk1o,4380
42
- quickdbd_cli-0.4.3.dist-info/licenses/src/quickdbd_cli/assets/elk-LICENSE.md,sha256=Y36B9KG2tAeVNcSZ_KBeI4z3YFwf9bdtYNfafOlnAMk,14443
43
- quickdbd_cli-0.4.3.dist-info/RECORD,,
37
+ quickdbd_cli-0.6.0.dist-info/METADATA,sha256=eziz_n7Q6_slikrg7LJnnjmNv2bmiESUG3eYw-G9Ai0,17729
38
+ quickdbd_cli-0.6.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
39
+ quickdbd_cli-0.6.0.dist-info/entry_points.txt,sha256=nRhiYYug-XOY95nWUmi4oanico3Zjkfqk2OHtIE6SQ0,47
40
+ quickdbd_cli-0.6.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
41
+ quickdbd_cli-0.6.0.dist-info/licenses/NOTICE,sha256=pyv3BfIYiGzKLU4w-GcBe7GXoXxHqZ97Mf6Zpx_3_i0,2067
42
+ quickdbd_cli-0.6.0.dist-info/licenses/src/quickdbd_cli/assets/Inter-LICENSE.txt,sha256=JiSB6ERSGzJvXs0FPlm5jIstp4yO4b27boF0MF5Uk1o,4380
43
+ quickdbd_cli-0.6.0.dist-info/licenses/src/quickdbd_cli/assets/elk-LICENSE.md,sha256=Y36B9KG2tAeVNcSZ_KBeI4z3YFwf9bdtYNfafOlnAMk,14443
44
+ quickdbd_cli-0.6.0.dist-info/RECORD,,