quickdbd-cli 0.1.0__tar.gz → 0.1.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 (44) hide show
  1. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/PKG-INFO +13 -1
  2. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/README.md +12 -0
  3. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/examples/synthetic/qdbd.layout.json +14 -14
  4. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/pyproject.toml +1 -1
  5. quickdbd_cli-0.1.2/scripts/release.sh +28 -0
  6. quickdbd_cli-0.1.2/src/quickdbd_cli/__init__.py +1 -0
  7. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/assets/render.js +15 -4
  8. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/assets/style.css +9 -0
  9. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/assets/viewer.js +46 -3
  10. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/html.py +6 -2
  11. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/measure.py +10 -3
  12. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/serve.py +21 -8
  13. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/tests/test_pipeline.py +51 -0
  14. quickdbd_cli-0.1.2/tests/test_serve.py +160 -0
  15. quickdbd_cli-0.1.0/src/quickdbd_cli/__init__.py +0 -1
  16. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/.gitignore +0 -0
  17. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/LICENSE +0 -0
  18. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/NOTICE +0 -0
  19. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/examples/quickdbd-sample.txt +0 -0
  20. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/examples/synthetic/finance.txt +0 -0
  21. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/examples/synthetic/hr.txt +0 -0
  22. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/examples/synthetic/inventory.txt +0 -0
  23. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/examples/synthetic/marketing.txt +0 -0
  24. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/examples/synthetic/sales.txt +0 -0
  25. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/scripts/gen_font_metrics.py +0 -0
  26. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/scripts/gen_schema.py +0 -0
  27. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/__main__.py +0 -0
  28. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/assets/Inter-Bold.ttf +0 -0
  29. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/assets/Inter-Bold.woff2 +0 -0
  30. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/assets/Inter-LICENSE.txt +0 -0
  31. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/assets/Inter-Regular.ttf +0 -0
  32. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/assets/Inter-Regular.woff2 +0 -0
  33. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/assets/editor.js +0 -0
  34. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/assets/elk-LICENSE.md +0 -0
  35. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/assets/elk.bundled.js +0 -0
  36. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/assets/font-metrics.json +0 -0
  37. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/cli.py +0 -0
  38. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/export.py +0 -0
  39. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/jsrt.py +0 -0
  40. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/layout.py +0 -0
  41. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/model.py +0 -0
  42. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/parser.py +0 -0
  43. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/src/quickdbd_cli/project.py +0 -0
  44. {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.2}/tests/test_parser.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: quickdbd-cli
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Compile QuickDBD-syntax schema files into interactive HTML ER diagrams, with no table limit.
5
5
  Project-URL: Homepage, https://github.com/MarkPhamm/quickdbd-cli
6
6
  Author: Minh (Mark) Pham
@@ -55,6 +55,7 @@ Not affiliated with QuickDBD. It reads the same text syntax, so schemas move fre
55
55
  - click a table to focus on it and its neighbours
56
56
  - collapse tables: full / keys only / names only
57
57
  - hover a column to highlight its relationship
58
+ - hover a dotted-underlined table or column to read its `#` doc comments
58
59
  - PNG/SVG export buttons
59
60
  - **Live editing:** `qdbd serve` reloads when you save, has a browser editor pane with autosave, and remembers where you drag tables.
60
61
  - **Many files, one diagram:** point it at a folder and foreign keys can cross files.
@@ -148,6 +149,17 @@ python scripts/gen_font_metrics.py # regenerate font width table
148
149
 
149
150
  Design notes: `docs/superpowers/specs/2026-10-17-qdbd-design.md`.
150
151
 
152
+ ### Releasing
153
+
154
+ Put PyPI credentials in `.env` (gitignored):
155
+
156
+ ```
157
+ TWINE_USERNAME=__token__
158
+ TWINE_PASSWORD=pypi-...
159
+ ```
160
+
161
+ Bump `version` in `pyproject.toml` and `src/quickdbd_cli/__init__.py`, commit, then run `PATH="$PWD/.venv/bin:$PATH" scripts/release.sh`. It builds, uploads to PyPI, tags `vX.Y.Z` and creates the GitHub release.
162
+
151
163
  ## License
152
164
 
153
165
  Apache-2.0. Bundled third-party files are listed in [NOTICE](NOTICE).
@@ -30,6 +30,7 @@ Not affiliated with QuickDBD. It reads the same text syntax, so schemas move fre
30
30
  - click a table to focus on it and its neighbours
31
31
  - collapse tables: full / keys only / names only
32
32
  - hover a column to highlight its relationship
33
+ - hover a dotted-underlined table or column to read its `#` doc comments
33
34
  - PNG/SVG export buttons
34
35
  - **Live editing:** `qdbd serve` reloads when you save, has a browser editor pane with autosave, and remembers where you drag tables.
35
36
  - **Many files, one diagram:** point it at a folder and foreign keys can cross files.
@@ -123,6 +124,17 @@ python scripts/gen_font_metrics.py # regenerate font width table
123
124
 
124
125
  Design notes: `docs/superpowers/specs/2026-10-17-qdbd-design.md`.
125
126
 
127
+ ### Releasing
128
+
129
+ Put PyPI credentials in `.env` (gitignored):
130
+
131
+ ```
132
+ TWINE_USERNAME=__token__
133
+ TWINE_PASSWORD=pypi-...
134
+ ```
135
+
136
+ Bump `version` in `pyproject.toml` and `src/quickdbd_cli/__init__.py`, commit, then run `PATH="$PWD/.venv/bin:$PATH" scripts/release.sh`. It builds, uploads to PyPI, tags `vX.Y.Z` and creates the GitHub release.
137
+
126
138
  ## License
127
139
 
128
140
  Apache-2.0. Bundled third-party files are listed in [NOTICE](NOTICE).
@@ -29,8 +29,8 @@
29
29
  "y": 5795
30
30
  },
31
31
  "Agency": {
32
- "x": 573,
33
- "y": 3287
32
+ "x": 603,
33
+ "y": 3752
34
34
  },
35
35
  "Allocation": {
36
36
  "x": 1655,
@@ -245,8 +245,8 @@
245
245
  "y": 5393
246
246
  },
247
247
  "EmailOpen": {
248
- "x": 3473,
249
- "y": 2776
248
+ "x": 3395,
249
+ "y": 2767
250
250
  },
251
251
  "EmailSend": {
252
252
  "x": 1927,
@@ -325,8 +325,8 @@
325
325
  "y": 4608
326
326
  },
327
327
  "Holiday": {
328
- "x": 3334,
329
- "y": 3050
328
+ "x": 3280,
329
+ "y": 3035
330
330
  },
331
331
  "Impression": {
332
332
  "x": 2544,
@@ -393,8 +393,8 @@
393
393
  "y": 6657
394
394
  },
395
395
  "Ledger": {
396
- "x": 3583,
397
- "y": 2235
396
+ "x": 3541,
397
+ "y": 2427
398
398
  },
399
399
  "LedgerEntry": {
400
400
  "x": 2512,
@@ -573,16 +573,16 @@
573
573
  "y": 3695
574
574
  },
575
575
  "Reseller": {
576
- "x": 3051,
577
- "y": 2797
576
+ "x": 3043,
577
+ "y": 2791
578
578
  },
579
579
  "Reservation": {
580
580
  "x": 3614,
581
581
  "y": 1303
582
582
  },
583
583
  "Return": {
584
- "x": 1231,
585
- "y": 3991
584
+ "x": 1113,
585
+ "y": 3858
586
586
  },
587
587
  "Review": {
588
588
  "x": 604,
@@ -761,8 +761,8 @@
761
761
  "y": 5153
762
762
  },
763
763
  "hr.Training": {
764
- "x": 1115,
765
- "y": 4126
764
+ "x": 1357,
765
+ "y": 3813
766
766
  },
767
767
  "inventory.Container": {
768
768
  "x": 2503,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "quickdbd-cli"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "Compile QuickDBD-syntax schema files into interactive HTML ER diagrams, with no table limit."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env bash
2
+ # Build and upload the current version to PyPI using credentials from .env,
3
+ # then tag and create the GitHub release.
4
+ # Usage: bump `version` in pyproject.toml and src/quickdbd_cli/__init__.py, commit, then run
5
+ # PATH="$PWD/.venv/bin:$PATH" scripts/release.sh
6
+ set -euo pipefail
7
+ cd "$(dirname "$0")/.."
8
+
9
+ [ -f .env ] || { echo "error: .env with TWINE_USERNAME/TWINE_PASSWORD not found" >&2; exit 1; }
10
+ set -a; . ./.env; set +a
11
+
12
+ version=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
13
+ [ -z "$(git status --porcelain --untracked-files=no)" ] || { echo "error: uncommitted changes" >&2; exit 1; }
14
+
15
+ rm -rf dist
16
+ # uv-created venvs have no pip; only install the tools when they're missing.
17
+ python3 -c "import build, twine" 2>/dev/null || {
18
+ if command -v uv >/dev/null; then uv pip install -q --python "$(command -v python3)" build twine
19
+ else python3 -m pip install -q --upgrade build twine; fi
20
+ }
21
+ python3 -m build
22
+ python3 -m twine check dist/*
23
+ python3 -m twine upload --non-interactive dist/*
24
+
25
+ git tag -a "v$version" -m "qdbd v$version"
26
+ git push origin "v$version"
27
+ gh release create "v$version" dist/* --title "qdbd v$version" --generate-notes
28
+ echo "Released v$version: https://pypi.org/project/quickdbd-cli/$version/"
@@ -0,0 +1 @@
1
+ __version__ = "0.1.2"
@@ -66,13 +66,22 @@
66
66
  '<path d="M' + n(x + 6.5) + " " + n(cy) + "h7m-2 0v3m-2.5-3v2" + '"/></g>';
67
67
  }
68
68
 
69
- function renderTable(model, t, g, p) {
69
+ /* Dotted underline marking text that has a doc comment (interactive viewer only). */
70
+ function docMark(x, y, w, color) {
71
+ return '<line class="dm" x1="' + n(x) + '" y1="' + n(y) + '" x2="' + n(x + w) + '" y2="' + n(y) +
72
+ '" stroke="' + color + '" stroke-width="1" stroke-dasharray="1.5 2"/>';
73
+ }
74
+
75
+ function renderTable(model, t, g, p, docMarks) {
70
76
  var st = model.style, w = g.w, h = g.h, out = [];
71
77
  out.push('<g class="t" data-id="' + esc(t.id) + '" transform="translate(' + n(p[0]) + "," + n(p[1]) + ')">');
72
78
  out.push('<rect class="tb" width="' + w + '" height="' + h + '" rx="2" fill="' + C.body + '" stroke="' + C.border + '"/>');
73
79
  out.push('<path class="th" d="M0 2a2 2 0 0 1 2-2h' + (w - 4) + "a2 2 0 0 1 2 2v" + (st.headerH - 2) + "h-" + w + 'z" fill="' + C.header + '"/>');
74
80
  out.push('<text x="' + st.padX + '" y="' + n(st.headerH / 2 + st.headerSize * 0.36) + '" fill="' + C.headerText +
75
81
  '" font-family="' + FONT + '" font-size="' + st.headerSize + '">' + esc(t.name) + "</text>");
82
+ if (docMarks && t.doc) {
83
+ out.push(docMark(st.padX, st.headerH / 2 + st.headerSize * 0.36 + 3, t.nameW, "rgba(255,255,255,.7)"));
84
+ }
76
85
  g.cols.forEach(function (c, i) {
77
86
  var y = st.headerH + i * st.rowH, cy = y + st.rowH / 2, base = n(cy + st.rowSize * 0.36);
78
87
  var weight = c.pk || c.fk ? ' font-weight="bold"' : "";
@@ -81,6 +90,7 @@
81
90
  out.push('<rect class="rb" y="' + y + '" width="' + w + '" height="' + st.rowH + '" fill="' + C.body + '" fill-opacity="0"/>');
82
91
  out.push('<text x="' + st.padX + '" y="' + base + '" fill="' + color + '" font-family="' + FONT + '" font-size="' +
83
92
  st.rowSize + '"' + weight + ">" + esc(c.name) + "</text>");
93
+ if (docMarks && c.doc) out.push(docMark(st.padX, cy + st.rowSize * 0.36 + 2.5, c.nameW, color));
84
94
  var bx = st.padX + c.nameW + 6;
85
95
  c.badges.forEach(function (b) {
86
96
  var bh = st.badgeSize + 5;
@@ -164,9 +174,10 @@
164
174
  return model.rels.map(function (r) { return renderEdge(model, geo, pos, r); }).join("");
165
175
  }
166
176
 
167
- function renderInner(model, pos, mode) {
168
- var geo = geometry(model, mode);
169
- var tables = model.tables.map(function (t) { return pos[t.id] ? renderTable(model, t, geo[t.id], pos[t.id]) : ""; }).join("");
177
+ /* opts.docMarks: underline tables/columns that have doc comments (viewer only, not exports). */
178
+ function renderInner(model, pos, mode, opts) {
179
+ var geo = geometry(model, mode), marks = !!(opts && opts.docMarks);
180
+ var tables = model.tables.map(function (t) { return pos[t.id] ? renderTable(model, t, geo[t.id], pos[t.id], marks) : ""; }).join("");
170
181
  return '<g class="edges">' + renderEdges(model, pos, mode, geo) + '</g><g class="tables">' + tables + "</g>";
171
182
  }
172
183
 
@@ -53,6 +53,15 @@ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink);
53
53
  .minimap svg { width: 100%; height: 100%; display: block; }
54
54
  .minimap .vpr { fill: rgba(43,87,151,.12); stroke: var(--blue); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
55
55
 
56
+ /* doc-comment tooltip */
57
+ .tip { position: absolute; display: none; max-width: 340px; background: #23272b; color: #eee; border-radius: 6px;
58
+ padding: 8px 10px; font-size: 12px; line-height: 1.45; pointer-events: none; box-shadow: 0 4px 14px rgba(0,0,0,.25);
59
+ z-index: 5; }
60
+ .tip.open { display: block; }
61
+ .tip .tt { font-weight: bold; margin-bottom: 3px; }
62
+ .tip .tt span { font-weight: normal; color: #9aa4ad; margin-left: 8px; }
63
+ .tip .td { white-space: pre-wrap; color: #d8dde2; }
64
+
56
65
  .hint { position: absolute; left: 16px; bottom: 14px; color: var(--muted); font-size: 12px; }
57
66
 
58
67
  /* serve: editor pane */
@@ -24,7 +24,7 @@
24
24
 
25
25
  /* ------------------------------------------------------------- render */
26
26
  function render() {
27
- vp.innerHTML = Q.renderInner(S.model, S.pos, S.mode);
27
+ vp.innerHTML = Q.renderInner(S.model, S.pos, S.mode, { docMarks: true });
28
28
  applyFocus();
29
29
  drawMinimap();
30
30
  }
@@ -124,7 +124,50 @@
124
124
  if (t) t.classList.add("hl");
125
125
  });
126
126
  });
127
- vp.addEventListener("mouseleave", clearHover);
127
+ vp.addEventListener("mouseleave", function () { clearHover(); hideTip(); });
128
+
129
+ /* ------------------------------------------------------------- doc-comment tooltips */
130
+ var tip = document.getElementById("tip");
131
+ function hideTip() { tip.classList.remove("open"); }
132
+ function showTip(ev, title, sub, doc) {
133
+ tip.innerHTML = "";
134
+ var h = document.createElement("div");
135
+ h.className = "tt";
136
+ h.textContent = title;
137
+ if (sub) {
138
+ var s = document.createElement("span");
139
+ s.textContent = sub;
140
+ h.appendChild(s);
141
+ }
142
+ var d = document.createElement("div");
143
+ d.className = "td";
144
+ d.textContent = doc;
145
+ tip.appendChild(h);
146
+ tip.appendChild(d);
147
+ tip.classList.add("open");
148
+ moveTip(ev);
149
+ }
150
+ function moveTip(ev) {
151
+ var r = svg.getBoundingClientRect(), x = ev.clientX - r.left + 14, y = ev.clientY - r.top + 14;
152
+ var w = tip.offsetWidth, h = tip.offsetHeight;
153
+ if (x + w > r.width - 8) x = ev.clientX - r.left - w - 14;
154
+ if (y + h > r.height - 8) y = ev.clientY - r.top - h - 14;
155
+ tip.style.left = Math.max(8, x) + "px";
156
+ tip.style.top = Math.max(8, y) + "px";
157
+ }
158
+ vp.addEventListener("mousemove", function (ev) {
159
+ if (drag && drag.moved) { hideTip(); return; }
160
+ var tEl = ev.target.closest && ev.target.closest(".t");
161
+ if (!tEl) { hideTip(); return; }
162
+ var t = byId[tEl.getAttribute("data-id")], row = ev.target.closest(".r");
163
+ if (row) {
164
+ var cid = row.getAttribute("data-c"), c = t.cols.filter(function (x) { return x.id === cid; })[0];
165
+ if (c && c.doc) showTip(ev, t.name + "." + c.name, c.type, c.doc);
166
+ else hideTip();
167
+ } else if (t.doc && ev.target.closest(".th, text")) {
168
+ showTip(ev, t.name, t.aliases.length ? "as " + t.aliases.join(", ") : "", t.doc);
169
+ } else hideTip();
170
+ });
128
171
 
129
172
  /* ------------------------------------------------------------- pointer: pan, click, drag */
130
173
  var drag = null;
@@ -288,7 +331,7 @@
288
331
  }
289
332
  function exportSvg() {
290
333
  var only = S.focus ? Object.keys(neighbours(S.focus)) : null;
291
- return Q.renderSvg(S.model, S.pos, { mode: S.mode, only: only, fontCss: data.fontCss });
334
+ return Q.renderSvg(S.model, S.pos, { mode: S.mode, only: only, fontCss: document.getElementById("qdbd-font").textContent });
292
335
  }
293
336
  document.getElementById("xsvg").onclick = function () {
294
337
  download((data.title || "diagram") + ".svg", new Blob([exportSvg()], { type: "image/svg+xml" }));
@@ -50,6 +50,7 @@ BODY = """
50
50
  <div class="minimap">
51
51
  <svg id="mini" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"></svg>
52
52
  </div>
53
+ <div class="tip" id="tip" role="tooltip"></div>
53
54
  <div class="hint">{hint}</div>
54
55
  </div>
55
56
  </div>
@@ -67,7 +68,7 @@ SIDE = """
67
68
 
68
69
 
69
70
  def render_page(model: dict, positions: dict, title: str, serve: bool = False) -> str:
70
- data = {"model": model, "positions": positions, "serve": serve, "title": title, "fontCss": font_css()}
71
+ data = {"model": model, "positions": positions, "serve": serve, "title": title}
71
72
  hint = (
72
73
  "Drag tables to move them (saved). Click a table to focus. Esc clears."
73
74
  if serve
@@ -88,7 +89,10 @@ def render_page(model: dict, positions: dict, title: str, serve: bool = False) -
88
89
  '<meta name="viewport" content="width=device-width, initial-scale=1">'
89
90
  '<meta name="generator" content="qdbd">'
90
91
  f"<title>{_escape(title)}</title>"
91
- f"<style>{font_css()}{_asset('style.css')}</style>{head_extra}</head><body>"
92
+ # The font CSS lives in its own <style> so the viewer can reuse it for SVG/PNG export
93
+ # instead of embedding the fonts a second time.
94
+ f'<style id="qdbd-font">{font_css()}</style>'
95
+ f"<style>{_asset('style.css')}</style>{head_extra}</head><body>"
92
96
  f"{body}"
93
97
  f'<script type="application/json" id="qdbd-data">{_json_for_script(data)}</script>'
94
98
  f"<script>{_asset('render.js')}</script>"
@@ -60,7 +60,7 @@ def _column_json(col: Column) -> dict:
60
60
  name_w = text_width(col.name, s["rowSize"], bold)
61
61
  bs = [{"t": b, "w": round(text_width(b, s["badgeSize"]) + 2 * s["badgePad"], 2)} for b in badges(col)]
62
62
  type_w = text_width(col.type or "", s["rowSize"])
63
- return {
63
+ out = {
64
64
  "id": col.id,
65
65
  "name": col.name,
66
66
  "type": col.type or "",
@@ -75,12 +75,16 @@ def _column_json(col: Column) -> dict:
75
75
  "typeW": type_w,
76
76
  "badges": bs,
77
77
  }
78
+ if col.comments:
79
+ out["doc"] = "\n".join(col.comments)
80
+ return out
78
81
 
79
82
 
80
83
  def table_json(table: Table) -> dict:
81
84
  s = STYLE
82
85
  cols = [_column_json(c) for c in table.columns.values() if not c.virtual]
83
- w = text_width(table.name, s["headerSize"]) + 2 * s["padX"]
86
+ name_w = text_width(table.name, s["headerSize"])
87
+ w = name_w + 2 * s["padX"]
84
88
  for c in cols:
85
89
  row = c["nameW"] + c["typeW"] + 2 * s["padX"] + s["gap"]
86
90
  row += sum(b["w"] + s["badgeGap"] for b in c["badges"])
@@ -89,7 +93,10 @@ def table_json(table: Table) -> dict:
89
93
  w = max(w, row)
90
94
  w = max(round(w), s["minW"])
91
95
  h = s["headerH"] + len(cols) * s["rowH"] + s["bottomPad"]
92
- return {"id": table.id, "name": table.name, "aliases": table.aliases, "w": w, "h": h, "cols": cols}
96
+ out = {"id": table.id, "name": table.name, "aliases": table.aliases, "w": w, "h": h, "nameW": name_w, "cols": cols}
97
+ if table.comments:
98
+ out["doc"] = "\n".join(table.comments)
99
+ return out
93
100
 
94
101
 
95
102
  def model_json(schema: Schema) -> dict:
@@ -178,13 +178,30 @@ def _handler(state: State, title: str):
178
178
  return Handler
179
179
 
180
180
 
181
- def serve(project: Project, port: int = 8765, open_browser: bool = True) -> int:
181
+ def make_server(project: Project, port: int = 8765) -> tuple[ThreadingHTTPServer, State]:
182
+ """Bind the HTTP server (port 0 picks a free port) without starting it."""
182
183
  state = State(project)
183
- for d in state.diagnostics:
184
- print(d["text"], file=sys.stderr)
185
184
  title = project.inputs[0].stem if len(project.inputs) == 1 else "diagram"
186
185
  server = ThreadingHTTPServer(("127.0.0.1", port), _handler(state, title))
187
186
  server.daemon_threads = True
187
+ return server, state
188
+
189
+
190
+ def poll_once(state: State) -> bool:
191
+ """Reload if any schema file changed; returns True when a reload happened."""
192
+ if not state.changed():
193
+ return False
194
+ try:
195
+ state.reload()
196
+ except Exception as e: # keep serving on unexpected errors
197
+ print(f"[qdbd] reload failed: {e}", file=sys.stderr, flush=True)
198
+ return True
199
+
200
+
201
+ def serve(project: Project, port: int = 8765, open_browser: bool = True) -> int:
202
+ server, state = make_server(project, port)
203
+ for d in state.diagnostics:
204
+ print(d["text"], file=sys.stderr)
188
205
  url = f"http://127.0.0.1:{server.server_address[1]}/"
189
206
  print(f"[qdbd] serving {len(state.files)} file(s) at {url} (layout: {display_path(project.sidecar)})")
190
207
  print("[qdbd] Ctrl+C to stop", flush=True)
@@ -192,11 +209,7 @@ def serve(project: Project, port: int = 8765, open_browser: bool = True) -> int:
192
209
  def watch() -> None:
193
210
  while True:
194
211
  time.sleep(0.3)
195
- if state.changed():
196
- try:
197
- state.reload()
198
- except Exception as e: # keep serving on unexpected errors
199
- print(f"[qdbd] reload failed: {e}", file=sys.stderr, flush=True)
212
+ poll_once(state)
200
213
 
201
214
  threading.Thread(target=watch, daemon=True).start()
202
215
  if open_browser:
@@ -8,6 +8,7 @@ import pytest
8
8
 
9
9
  from quickdbd_cli import export, layout
10
10
  from quickdbd_cli.cli import main
11
+ from quickdbd_cli.html import render_page
11
12
  from quickdbd_cli.measure import model_json, text_width
12
13
  from quickdbd_cli.parser import compile_text
13
14
  from quickdbd_cli.project import Project
@@ -138,6 +139,7 @@ def test_check_strict_warnings(tmp_path):
138
139
  def test_build_is_deterministic_and_does_not_write_sidecar(tmp_path):
139
140
  work = tmp_path / "s"
140
141
  shutil.copytree(SYNTH, work)
142
+ (work / "qdbd.layout.json").unlink(missing_ok=True)
141
143
  a, b = tmp_path / "a.html", tmp_path / "b.html"
142
144
  assert main(["build", str(work), "-o", str(a)]) == 0
143
145
  assert main(["build", str(work), "-o", str(b)]) == 0
@@ -159,6 +161,24 @@ def test_layout_command_writes_sidecar_and_reset(tmp_path):
159
161
  assert json.loads(side.read_text())["tables"]["Customer"] != {"x": 9999, "y": 9999}
160
162
 
161
163
 
164
+ def test_build_uses_but_never_rewrites_committed_sidecar(tmp_path):
165
+ work = tmp_path / "s"
166
+ shutil.copytree(SYNTH, work)
167
+ side = work / "qdbd.layout.json"
168
+ before = side.read_bytes()
169
+ assert main(["build", str(work), "-o", str(tmp_path / "d.html")]) == 0
170
+ assert side.read_bytes() == before
171
+ html = (tmp_path / "d.html").read_text()
172
+ customer = layout.read_sidecar(side)["Customer"]
173
+ assert f'"Customer":[{customer[0]},{customer[1]}]' in html
174
+
175
+
176
+ def test_html_embeds_font_once():
177
+ html = render_page(model_json(compile_text(SMALL)), {"Customer": [0, 0], "Order": [300, 0]}, "t")
178
+ assert html.count("@font-face") == 2 # regular + bold, once each
179
+ assert 'id="qdbd-font"' in html
180
+
181
+
162
182
  def test_missing_path(capsys):
163
183
  assert main(["check", "nope/does-not-exist.txt"]) == 2
164
184
 
@@ -166,3 +186,34 @@ def test_missing_path(capsys):
166
186
  @pytest.mark.parametrize("bad_suffix", ["out.jpg"])
167
187
  def test_export_rejects_unknown_format(tmp_path, bad_suffix):
168
188
  assert main(["export", str(SAMPLE), "-o", str(tmp_path / bad_suffix)]) == 2
189
+
190
+
191
+ DOCS = """# Customers who placed at least one order
192
+ Customer # one row per person
193
+ -
194
+ CustomerId PK int
195
+ # Contact address
196
+ Email string # verified
197
+ Name string
198
+ """
199
+
200
+
201
+ def test_doc_comments_in_model():
202
+ m = model_json(compile_text(DOCS))
203
+ t = m["tables"][0]
204
+ assert t["doc"] == "Customers who placed at least one order\none row per person"
205
+ cols = {c["id"]: c for c in t["cols"]}
206
+ assert cols["Email"]["doc"] == "Contact address\nverified"
207
+ assert "doc" not in cols["Name"]
208
+
209
+
210
+ def test_doc_marks_only_in_viewer_not_exports():
211
+ from quickdbd_cli.jsrt import runtime
212
+
213
+ m = model_json(compile_text(DOCS))
214
+ pos = {"Customer": [0, 0]}
215
+ rt = runtime()
216
+ rt.load("render.js")
217
+ viewer = rt.call("qdbd.renderInner", m, pos, "full", {"docMarks": True})
218
+ assert viewer.count('class="dm"') == 2 # table header + Email
219
+ assert 'class="dm"' not in export.render_svg(m, pos, embed_font=False)
@@ -0,0 +1,160 @@
1
+ """`qdbd serve` HTTP endpoints, driven without a browser."""
2
+
3
+ import json
4
+ import os
5
+ import threading
6
+ import time
7
+ import urllib.error
8
+ import urllib.request
9
+
10
+ import pytest
11
+
12
+ from quickdbd_cli import layout
13
+ from quickdbd_cli.project import Project, display_path
14
+ from quickdbd_cli.serve import make_server, poll_once
15
+
16
+ SCHEMA = """Customer
17
+ -
18
+ CustomerId PK int
19
+ Name string
20
+
21
+ Order
22
+ -
23
+ OrderId PK int
24
+ CustomerId int FK >- Customer.CustomerId
25
+ """
26
+
27
+
28
+ @pytest.fixture
29
+ def srv(tmp_path):
30
+ f = tmp_path / "shop.txt"
31
+ f.write_text(SCHEMA)
32
+ server, state = make_server(Project.open([f]), port=0)
33
+ t = threading.Thread(target=server.serve_forever, daemon=True)
34
+ t.start()
35
+ base = f"http://127.0.0.1:{server.server_address[1]}"
36
+ yield base, state, f
37
+ server.shutdown()
38
+ server.server_close()
39
+
40
+
41
+ def call(base, path, method="GET", body=None):
42
+ data = json.dumps(body).encode() if body is not None else None
43
+ req = urllib.request.Request(base + path, data=data, method=method, headers={"Content-Type": "application/json"})
44
+ try:
45
+ with urllib.request.urlopen(req, timeout=10) as r:
46
+ raw = r.read()
47
+ return r.status, (json.loads(raw) if r.headers.get_content_type() == "application/json" else raw)
48
+ except urllib.error.HTTPError as e:
49
+ return e.code, json.loads(e.read())
50
+
51
+
52
+ def bump_mtime(path):
53
+ st = path.stat()
54
+ os.utime(path, ns=(st.st_atime_ns, st.st_mtime_ns + 1_000_000_000))
55
+
56
+
57
+ def test_page_and_state(srv):
58
+ base, _, f = srv
59
+ code, page = call(base, "/")
60
+ assert code == 200 and b'id="side"' in page and b'"serve":true' in page
61
+ code, st = call(base, "/state")
62
+ assert code == 200
63
+ assert [t["name"] for t in st["model"]["tables"]] == ["Customer", "Order"]
64
+ assert st["files"] == [{"path": display_path(f), "text": SCHEMA, "mtime": st["files"][0]["mtime"]}]
65
+ assert isinstance(st["files"][0]["mtime"], str) # ns mtimes overflow JS numbers
66
+ assert st["diagnostics"] == []
67
+
68
+
69
+ def test_initial_layout_written_to_sidecar(srv):
70
+ _, state, f = srv
71
+ side = layout.read_sidecar(f.with_name("shop.layout.json"))
72
+ assert set(side) == {"Customer", "Order"}
73
+
74
+
75
+ def test_put_file_saves_and_returns_new_mtime(srv):
76
+ base, _, f = srv
77
+ _, st = call(base, "/state")
78
+ path, mtime = st["files"][0]["path"], st["files"][0]["mtime"]
79
+ new = SCHEMA + "\nProduct\n-\nProductId PK int\n"
80
+ code, res = call(base, "/file", "PUT", {"path": path, "text": new, "mtime": mtime})
81
+ assert code == 200 and res["mtime"] != mtime
82
+ assert f.read_text() == new
83
+
84
+
85
+ def test_put_file_refuses_stale_write(srv):
86
+ base, _, f = srv
87
+ _, st = call(base, "/state")
88
+ path, mtime = st["files"][0]["path"], st["files"][0]["mtime"]
89
+ f.write_text(SCHEMA + "\n# edited elsewhere\n")
90
+ bump_mtime(f)
91
+ code, res = call(base, "/file", "PUT", {"path": path, "text": "clobber", "mtime": mtime})
92
+ assert code == 409
93
+ assert "# edited elsewhere" in res["text"]
94
+ assert "# edited elsewhere" in f.read_text() # disk untouched
95
+
96
+
97
+ def test_put_unknown_file(srv):
98
+ base, _, _ = srv
99
+ code, _ = call(base, "/file", "PUT", {"path": "nope.txt", "text": "x", "mtime": None})
100
+ assert code == 404
101
+
102
+
103
+ def test_post_layout_updates_sidecar(srv):
104
+ base, state, f = srv
105
+ code, _ = call(base, "/layout", "POST", {"Customer": [1234, 567], "Unknown": [1, 1]})
106
+ assert code == 200
107
+ side = layout.read_sidecar(f.with_name("shop.layout.json"))
108
+ assert side["Customer"] == (1234, 567)
109
+ assert "Unknown" not in side
110
+ _, st = call(base, "/state")
111
+ assert st["positions"]["Customer"] == [1234, 567]
112
+
113
+
114
+ def test_external_edit_reloads_and_places_new_table(srv):
115
+ base, state, f = srv
116
+ _, before = call(base, "/state")
117
+ f.write_text(SCHEMA + "\nLine\n-\nLineId PK int\nOrderId int FK >- Order.OrderId\n")
118
+ bump_mtime(f)
119
+ assert poll_once(state)
120
+ _, after = call(base, "/state")
121
+ assert after["version"] > before["version"]
122
+ assert "Line" in after["positions"]
123
+ # existing tables did not move
124
+ for t in ("Customer", "Order"):
125
+ assert after["positions"][t] == before["positions"][t]
126
+ assert not poll_once(state) # nothing changed since
127
+
128
+
129
+ def test_errors_keep_last_good_model(srv):
130
+ base, state, f = srv
131
+ f.write_text(SCHEMA + "\nBad\n-\nX int FK >- Nope.Id\n")
132
+ bump_mtime(f)
133
+ poll_once(state)
134
+ _, st = call(base, "/state")
135
+ assert [d["severity"] for d in st["diagnostics"]] == ["error"]
136
+ assert "Invalid relationship, the table 'Nope' does not exist" in st["diagnostics"][0]["text"]
137
+ assert [t["name"] for t in st["model"]["tables"]] == ["Customer", "Order"]
138
+
139
+
140
+ def test_events_stream_announces_versions(srv):
141
+ base, state, f = srv
142
+ got = []
143
+
144
+ def listen():
145
+ with urllib.request.urlopen(base + "/events", timeout=10) as r:
146
+ for line in r:
147
+ line = line.decode().strip()
148
+ if line.startswith("data:"):
149
+ got.append(int(line[5:]))
150
+ if len(got) == 2:
151
+ return
152
+
153
+ t = threading.Thread(target=listen, daemon=True)
154
+ t.start()
155
+ time.sleep(0.3)
156
+ f.write_text(SCHEMA + "\n# touch\n")
157
+ bump_mtime(f)
158
+ poll_once(state)
159
+ t.join(timeout=10)
160
+ assert len(got) == 2 and got[1] > got[0]
@@ -1 +0,0 @@
1
- __version__ = "0.1.0"
File without changes
File without changes
File without changes