quickdbd-cli 0.1.0__tar.gz → 0.1.1__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.
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/PKG-INFO +12 -1
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/README.md +11 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/examples/synthetic/qdbd.layout.json +14 -14
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/pyproject.toml +1 -1
- quickdbd_cli-0.1.1/scripts/release.sh +23 -0
- quickdbd_cli-0.1.1/src/quickdbd_cli/__init__.py +1 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/src/quickdbd_cli/assets/viewer.js +1 -1
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/src/quickdbd_cli/html.py +5 -2
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/src/quickdbd_cli/serve.py +21 -8
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/tests/test_pipeline.py +20 -0
- quickdbd_cli-0.1.1/tests/test_serve.py +160 -0
- quickdbd_cli-0.1.0/src/quickdbd_cli/__init__.py +0 -1
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/.gitignore +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/LICENSE +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/NOTICE +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/examples/quickdbd-sample.txt +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/examples/synthetic/finance.txt +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/examples/synthetic/hr.txt +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/examples/synthetic/inventory.txt +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/examples/synthetic/marketing.txt +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/examples/synthetic/sales.txt +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/scripts/gen_font_metrics.py +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/scripts/gen_schema.py +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/src/quickdbd_cli/__main__.py +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/src/quickdbd_cli/assets/Inter-Bold.ttf +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/src/quickdbd_cli/assets/Inter-Bold.woff2 +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/src/quickdbd_cli/assets/Inter-LICENSE.txt +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/src/quickdbd_cli/assets/Inter-Regular.ttf +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/src/quickdbd_cli/assets/Inter-Regular.woff2 +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/src/quickdbd_cli/assets/editor.js +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/src/quickdbd_cli/assets/elk-LICENSE.md +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/src/quickdbd_cli/assets/elk.bundled.js +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/src/quickdbd_cli/assets/font-metrics.json +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/src/quickdbd_cli/assets/render.js +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/src/quickdbd_cli/assets/style.css +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/src/quickdbd_cli/cli.py +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/src/quickdbd_cli/export.py +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/src/quickdbd_cli/jsrt.py +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/src/quickdbd_cli/layout.py +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/src/quickdbd_cli/measure.py +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/src/quickdbd_cli/model.py +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/src/quickdbd_cli/parser.py +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/src/quickdbd_cli/project.py +0 -0
- {quickdbd_cli-0.1.0 → quickdbd_cli-0.1.1}/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.
|
|
3
|
+
Version: 0.1.1
|
|
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
|
|
@@ -148,6 +148,17 @@ python scripts/gen_font_metrics.py # regenerate font width table
|
|
|
148
148
|
|
|
149
149
|
Design notes: `docs/superpowers/specs/2026-10-17-qdbd-design.md`.
|
|
150
150
|
|
|
151
|
+
### Releasing
|
|
152
|
+
|
|
153
|
+
Put PyPI credentials in `.env` (gitignored):
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
TWINE_USERNAME=__token__
|
|
157
|
+
TWINE_PASSWORD=pypi-...
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Bump `version` in `pyproject.toml` and `src/quickdbd_cli/__init__.py`, commit, then run `scripts/release.sh`. It builds, uploads to PyPI, tags `vX.Y.Z` and creates the GitHub release.
|
|
161
|
+
|
|
151
162
|
## License
|
|
152
163
|
|
|
153
164
|
Apache-2.0. Bundled third-party files are listed in [NOTICE](NOTICE).
|
|
@@ -123,6 +123,17 @@ python scripts/gen_font_metrics.py # regenerate font width table
|
|
|
123
123
|
|
|
124
124
|
Design notes: `docs/superpowers/specs/2026-10-17-qdbd-design.md`.
|
|
125
125
|
|
|
126
|
+
### Releasing
|
|
127
|
+
|
|
128
|
+
Put PyPI credentials in `.env` (gitignored):
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
TWINE_USERNAME=__token__
|
|
132
|
+
TWINE_PASSWORD=pypi-...
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Bump `version` in `pyproject.toml` and `src/quickdbd_cli/__init__.py`, commit, then run `scripts/release.sh`. It builds, uploads to PyPI, tags `vX.Y.Z` and creates the GitHub release.
|
|
136
|
+
|
|
126
137
|
## License
|
|
127
138
|
|
|
128
139
|
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":
|
|
33
|
-
"y":
|
|
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":
|
|
249
|
-
"y":
|
|
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":
|
|
329
|
-
"y":
|
|
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":
|
|
397
|
-
"y":
|
|
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":
|
|
577
|
-
"y":
|
|
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":
|
|
585
|
-
"y":
|
|
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":
|
|
765
|
-
"y":
|
|
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.
|
|
7
|
+
version = "0.1.1"
|
|
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,23 @@
|
|
|
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 scripts/release.sh
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
cd "$(dirname "$0")/.."
|
|
7
|
+
|
|
8
|
+
[ -f .env ] || { echo "error: .env with TWINE_USERNAME/TWINE_PASSWORD not found" >&2; exit 1; }
|
|
9
|
+
set -a; . ./.env; set +a
|
|
10
|
+
|
|
11
|
+
version=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
|
|
12
|
+
[ -z "$(git status --porcelain --untracked-files=no)" ] || { echo "error: uncommitted changes" >&2; exit 1; }
|
|
13
|
+
|
|
14
|
+
rm -rf dist
|
|
15
|
+
python3 -m pip install -q --upgrade build twine
|
|
16
|
+
python3 -m build
|
|
17
|
+
python3 -m twine check dist/*
|
|
18
|
+
python3 -m twine upload --non-interactive dist/*
|
|
19
|
+
|
|
20
|
+
git tag -a "v$version" -m "qdbd v$version"
|
|
21
|
+
git push origin "v$version"
|
|
22
|
+
gh release create "v$version" dist/* --title "qdbd v$version" --generate-notes
|
|
23
|
+
echo "Released v$version: https://pypi.org/project/quickdbd-cli/$version/"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.1"
|
|
@@ -288,7 +288,7 @@
|
|
|
288
288
|
}
|
|
289
289
|
function exportSvg() {
|
|
290
290
|
var only = S.focus ? Object.keys(neighbours(S.focus)) : null;
|
|
291
|
-
return Q.renderSvg(S.model, S.pos, { mode: S.mode, only: only, fontCss:
|
|
291
|
+
return Q.renderSvg(S.model, S.pos, { mode: S.mode, only: only, fontCss: document.getElementById("qdbd-font").textContent });
|
|
292
292
|
}
|
|
293
293
|
document.getElementById("xsvg").onclick = function () {
|
|
294
294
|
download((data.title || "diagram") + ".svg", new Blob([exportSvg()], { type: "image/svg+xml" }));
|
|
@@ -67,7 +67,7 @@ SIDE = """
|
|
|
67
67
|
|
|
68
68
|
|
|
69
69
|
def render_page(model: dict, positions: dict, title: str, serve: bool = False) -> str:
|
|
70
|
-
data = {"model": model, "positions": positions, "serve": serve, "title": title
|
|
70
|
+
data = {"model": model, "positions": positions, "serve": serve, "title": title}
|
|
71
71
|
hint = (
|
|
72
72
|
"Drag tables to move them (saved). Click a table to focus. Esc clears."
|
|
73
73
|
if serve
|
|
@@ -88,7 +88,10 @@ def render_page(model: dict, positions: dict, title: str, serve: bool = False) -
|
|
|
88
88
|
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
89
89
|
'<meta name="generator" content="qdbd">'
|
|
90
90
|
f"<title>{_escape(title)}</title>"
|
|
91
|
-
|
|
91
|
+
# The font CSS lives in its own <style> so the viewer can reuse it for SVG/PNG export
|
|
92
|
+
# instead of embedding the fonts a second time.
|
|
93
|
+
f'<style id="qdbd-font">{font_css()}</style>'
|
|
94
|
+
f"<style>{_asset('style.css')}</style>{head_extra}</head><body>"
|
|
92
95
|
f"{body}"
|
|
93
96
|
f'<script type="application/json" id="qdbd-data">{_json_for_script(data)}</script>'
|
|
94
97
|
f"<script>{_asset('render.js')}</script>"
|
|
@@ -178,13 +178,30 @@ def _handler(state: State, title: str):
|
|
|
178
178
|
return Handler
|
|
179
179
|
|
|
180
180
|
|
|
181
|
-
def
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
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
|