quickdbd-cli 0.1.8__tar.gz → 0.1.10__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.8 → quickdbd_cli-0.1.10}/PKG-INFO +26 -6
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/README.md +25 -5
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/pyproject.toml +1 -1
- quickdbd_cli-0.1.10/src/quickdbd_cli/__init__.py +1 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/src/quickdbd_cli/cli.py +53 -4
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/src/quickdbd_cli/html.py +22 -6
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/src/quickdbd_cli/serve.py +54 -13
- quickdbd_cli-0.1.10/src/quickdbd_cli/share.py +109 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/tests/test_serve.py +69 -0
- quickdbd_cli-0.1.10/tests/test_share.py +116 -0
- quickdbd_cli-0.1.8/src/quickdbd_cli/__init__.py +0 -1
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/.gitignore +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/LICENSE +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/NOTICE +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/examples/quickdbd-sample.txt +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/examples/synthetic/finance.txt +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/examples/synthetic/hr.txt +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/examples/synthetic/inventory.txt +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/examples/synthetic/marketing.txt +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/examples/synthetic/qdbd.layout.json +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/examples/synthetic/sales.txt +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/scripts/gen_font_metrics.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/scripts/gen_schema.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/scripts/release.sh +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/src/quickdbd_cli/__main__.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/src/quickdbd_cli/assets/Inter-Bold.ttf +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/src/quickdbd_cli/assets/Inter-Bold.woff2 +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/src/quickdbd_cli/assets/Inter-LICENSE.txt +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/src/quickdbd_cli/assets/Inter-Regular.ttf +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/src/quickdbd_cli/assets/Inter-Regular.woff2 +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/src/quickdbd_cli/assets/editor.js +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/src/quickdbd_cli/assets/elk-LICENSE.md +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/src/quickdbd_cli/assets/elk.bundled.js +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/src/quickdbd_cli/assets/font-metrics.json +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/src/quickdbd_cli/assets/render.js +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/src/quickdbd_cli/assets/style.css +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/src/quickdbd_cli/assets/viewer.js +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/src/quickdbd_cli/dbt.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/src/quickdbd_cli/diff.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/src/quickdbd_cli/export.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/src/quickdbd_cli/jsrt.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/src/quickdbd_cli/layout.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/src/quickdbd_cli/measure.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/src/quickdbd_cli/model.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/src/quickdbd_cli/parser.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/src/quickdbd_cli/project.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/src/quickdbd_cli/tags.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/tests/fixtures/jaffle/LICENSE +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/tests/fixtures/jaffle/README.md +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/tests/fixtures/jaffle/catalog.json +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/tests/fixtures/jaffle/manifest.json +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/tests/test_dbt.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/tests/test_diff.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/tests/test_parser.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/tests/test_path.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/tests/test_pipeline.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/tests/test_select.py +0 -0
- {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.10}/tests/test_tags.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.10
|
|
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
|
|
@@ -121,7 +121,7 @@ A browser tab opens with the diagram and an editor pane.
|
|
|
121
121
|
qdbd build schema.txt -o schema.html
|
|
122
122
|
```
|
|
123
123
|
|
|
124
|
-
`schema.html` is a single self-contained file. Open it locally, email it, attach it to a ticket, or publish it on GitHub Pages ([7.1](#71-github-action)).
|
|
124
|
+
`schema.html` is a single self-contained file. Open it locally, email it, attach it to a ticket, or publish it on GitHub Pages ([7.1](#71-github-action)). To send a link instead of the file, see [4.4](#44-share-a-link).
|
|
125
125
|
|
|
126
126
|
### 2.5 Export an image
|
|
127
127
|
|
|
@@ -290,6 +290,25 @@ qdbd path schema/ --from Invoice --to Campaign
|
|
|
290
290
|
|
|
291
291
|

|
|
292
292
|
|
|
293
|
+
### 4.4 Share a link
|
|
294
|
+
|
|
295
|
+
`qdbd serve`'s address only works on your machine. To send someone a **link**:
|
|
296
|
+
|
|
297
|
+
```bash
|
|
298
|
+
qdbd share schema.txt # needs the GitHub CLI: brew install gh && gh auth login
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
```text
|
|
302
|
+
Shared 12 tables (secret gist https://gist.github.com/you/1a2b3c…)
|
|
303
|
+
Link: https://gistpreview.github.io/?1a2b3c…
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
- The link opens the full interactive page. Running `qdbd share` again updates the same link.
|
|
307
|
+
- Gists are secret (link-only) by default; `--public` lists it.
|
|
308
|
+
- For a **live** view while you edit, run `qdbd serve schema.txt --read-only` (no editor, nothing written to disk) behind a tunnel such as `cloudflared tunnel --url http://127.0.0.1:8765`. Never tunnel a plain `qdbd serve`: viewers could edit your files.
|
|
309
|
+
|
|
310
|
+
More: [Sharing a Link](https://markphamm.github.io/quickdbd-cli/sharing/).
|
|
311
|
+
|
|
293
312
|
## 5. dbt projects
|
|
294
313
|
|
|
295
314
|
### 5.1 Generate the schema from dbt
|
|
@@ -368,7 +387,7 @@ jobs:
|
|
|
368
387
|
permissions: { contents: read, pull-requests: write }
|
|
369
388
|
steps:
|
|
370
389
|
- uses: actions/checkout@v4
|
|
371
|
-
- uses: MarkPhamm/quickdbd-cli@v0.1.
|
|
390
|
+
- uses: MarkPhamm/quickdbd-cli@v0.1.10
|
|
372
391
|
with:
|
|
373
392
|
path: erd/ # schema file(s) or folder(s)
|
|
374
393
|
output: site/index.html
|
|
@@ -391,7 +410,7 @@ Run your dbt setup first, then let the action generate the schema:
|
|
|
391
410
|
|
|
392
411
|
```yaml
|
|
393
412
|
# … dbt deps && dbt build && dbt docs generate
|
|
394
|
-
- uses: MarkPhamm/quickdbd-cli@v0.1.
|
|
413
|
+
- uses: MarkPhamm/quickdbd-cli@v0.1.10
|
|
395
414
|
with:
|
|
396
415
|
dbt-manifest: target/
|
|
397
416
|
dbt-args: --infer-fks
|
|
@@ -406,7 +425,7 @@ Commit `erd/` so pull requests can be compared with `main`. Set `dbt-check: true
|
|
|
406
425
|
```yaml
|
|
407
426
|
repos:
|
|
408
427
|
- repo: https://github.com/MarkPhamm/quickdbd-cli
|
|
409
|
-
rev: v0.1.
|
|
428
|
+
rev: v0.1.10
|
|
410
429
|
hooks:
|
|
411
430
|
- id: qdbd-check
|
|
412
431
|
files: ^erd/.*\.txt$
|
|
@@ -431,8 +450,9 @@ Builds are deterministic (no timestamps or randomness), so the output only chang
|
|
|
431
450
|
|
|
432
451
|
```bash
|
|
433
452
|
qdbd check PATH... [--strict] # validate
|
|
434
|
-
qdbd serve PATH... [--port 8765]
|
|
453
|
+
qdbd serve PATH... [--port 8765] [--read-only] # live editor + drag to arrange
|
|
435
454
|
qdbd build PATH... -o diagram.html # interactive HTML
|
|
455
|
+
qdbd share PATH... [--public] # publish as a gist, print a link
|
|
436
456
|
qdbd export PATH... -o out.png|.svg|.drawio [--focus T --depth 1]
|
|
437
457
|
qdbd layout PATH... [--reset] # (re)write the layout file
|
|
438
458
|
qdbd path PATH... --from A --to B # shortest join path + SQL JOIN
|
|
@@ -93,7 +93,7 @@ A browser tab opens with the diagram and an editor pane.
|
|
|
93
93
|
qdbd build schema.txt -o schema.html
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
-
`schema.html` is a single self-contained file. Open it locally, email it, attach it to a ticket, or publish it on GitHub Pages ([7.1](#71-github-action)).
|
|
96
|
+
`schema.html` is a single self-contained file. Open it locally, email it, attach it to a ticket, or publish it on GitHub Pages ([7.1](#71-github-action)). To send a link instead of the file, see [4.4](#44-share-a-link).
|
|
97
97
|
|
|
98
98
|
### 2.5 Export an image
|
|
99
99
|
|
|
@@ -262,6 +262,25 @@ qdbd path schema/ --from Invoice --to Campaign
|
|
|
262
262
|
|
|
263
263
|

|
|
264
264
|
|
|
265
|
+
### 4.4 Share a link
|
|
266
|
+
|
|
267
|
+
`qdbd serve`'s address only works on your machine. To send someone a **link**:
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
qdbd share schema.txt # needs the GitHub CLI: brew install gh && gh auth login
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
```text
|
|
274
|
+
Shared 12 tables (secret gist https://gist.github.com/you/1a2b3c…)
|
|
275
|
+
Link: https://gistpreview.github.io/?1a2b3c…
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
- The link opens the full interactive page. Running `qdbd share` again updates the same link.
|
|
279
|
+
- Gists are secret (link-only) by default; `--public` lists it.
|
|
280
|
+
- For a **live** view while you edit, run `qdbd serve schema.txt --read-only` (no editor, nothing written to disk) behind a tunnel such as `cloudflared tunnel --url http://127.0.0.1:8765`. Never tunnel a plain `qdbd serve`: viewers could edit your files.
|
|
281
|
+
|
|
282
|
+
More: [Sharing a Link](https://markphamm.github.io/quickdbd-cli/sharing/).
|
|
283
|
+
|
|
265
284
|
## 5. dbt projects
|
|
266
285
|
|
|
267
286
|
### 5.1 Generate the schema from dbt
|
|
@@ -340,7 +359,7 @@ jobs:
|
|
|
340
359
|
permissions: { contents: read, pull-requests: write }
|
|
341
360
|
steps:
|
|
342
361
|
- uses: actions/checkout@v4
|
|
343
|
-
- uses: MarkPhamm/quickdbd-cli@v0.1.
|
|
362
|
+
- uses: MarkPhamm/quickdbd-cli@v0.1.10
|
|
344
363
|
with:
|
|
345
364
|
path: erd/ # schema file(s) or folder(s)
|
|
346
365
|
output: site/index.html
|
|
@@ -363,7 +382,7 @@ Run your dbt setup first, then let the action generate the schema:
|
|
|
363
382
|
|
|
364
383
|
```yaml
|
|
365
384
|
# … dbt deps && dbt build && dbt docs generate
|
|
366
|
-
- uses: MarkPhamm/quickdbd-cli@v0.1.
|
|
385
|
+
- uses: MarkPhamm/quickdbd-cli@v0.1.10
|
|
367
386
|
with:
|
|
368
387
|
dbt-manifest: target/
|
|
369
388
|
dbt-args: --infer-fks
|
|
@@ -378,7 +397,7 @@ Commit `erd/` so pull requests can be compared with `main`. Set `dbt-check: true
|
|
|
378
397
|
```yaml
|
|
379
398
|
repos:
|
|
380
399
|
- repo: https://github.com/MarkPhamm/quickdbd-cli
|
|
381
|
-
rev: v0.1.
|
|
400
|
+
rev: v0.1.10
|
|
382
401
|
hooks:
|
|
383
402
|
- id: qdbd-check
|
|
384
403
|
files: ^erd/.*\.txt$
|
|
@@ -403,8 +422,9 @@ Builds are deterministic (no timestamps or randomness), so the output only chang
|
|
|
403
422
|
|
|
404
423
|
```bash
|
|
405
424
|
qdbd check PATH... [--strict] # validate
|
|
406
|
-
qdbd serve PATH... [--port 8765]
|
|
425
|
+
qdbd serve PATH... [--port 8765] [--read-only] # live editor + drag to arrange
|
|
407
426
|
qdbd build PATH... -o diagram.html # interactive HTML
|
|
427
|
+
qdbd share PATH... [--public] # publish as a gist, print a link
|
|
408
428
|
qdbd export PATH... -o out.png|.svg|.drawio [--focus T --depth 1]
|
|
409
429
|
qdbd layout PATH... [--reset] # (re)write the layout file
|
|
410
430
|
qdbd path PATH... --from A --to B # shortest join path + SQL JOIN
|
|
@@ -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.10"
|
|
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 @@
|
|
|
1
|
+
__version__ = "0.1.10"
|
|
@@ -47,7 +47,8 @@ def cmd_check(args) -> int:
|
|
|
47
47
|
return 0 if ok else 1
|
|
48
48
|
|
|
49
49
|
|
|
50
|
-
def
|
|
50
|
+
def _built_page(args) -> tuple[str, dict] | int:
|
|
51
|
+
"""Compile, select and lay out like `build`; returns (html, model) or an exit code."""
|
|
51
52
|
from .html import render_page
|
|
52
53
|
|
|
53
54
|
project = _open(args)
|
|
@@ -80,13 +81,41 @@ def cmd_build(args) -> int:
|
|
|
80
81
|
print(f"error: {e}", file=sys.stderr)
|
|
81
82
|
return 2
|
|
82
83
|
raise
|
|
84
|
+
return render_page(model, pos, _title(project)), model
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def cmd_build(args) -> int:
|
|
88
|
+
res = _built_page(args)
|
|
89
|
+
if isinstance(res, int):
|
|
90
|
+
return res
|
|
91
|
+
page, model = res
|
|
83
92
|
out = Path(args.output)
|
|
84
93
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
-
out.write_text(
|
|
94
|
+
out.write_text(page, encoding="utf-8")
|
|
86
95
|
print(f"Wrote {out} ({len(model['tables'])} tables, {len(model['rels'])} relationships)")
|
|
87
96
|
return 0
|
|
88
97
|
|
|
89
98
|
|
|
99
|
+
def cmd_share(args) -> int:
|
|
100
|
+
from . import share
|
|
101
|
+
|
|
102
|
+
res = _built_page(args)
|
|
103
|
+
if isinstance(res, int):
|
|
104
|
+
return res
|
|
105
|
+
page, model = res
|
|
106
|
+
key = share.share_key(args.paths, selector_expr(args.select, args.exclude))
|
|
107
|
+
try:
|
|
108
|
+
r = share.publish(page, _title(_open(args)), key, public=args.public, new=args.new)
|
|
109
|
+
except share.ShareError as e:
|
|
110
|
+
print(f"error: {e}", file=sys.stderr)
|
|
111
|
+
return 2
|
|
112
|
+
verb = "Updated" if r.updated else "Shared"
|
|
113
|
+
print(f"{verb} {len(model['tables'])} tables ({'public' if r.public else 'secret'} gist {r.gist_url})")
|
|
114
|
+
print(f"Link: {r.link}")
|
|
115
|
+
print(f" (if that doesn't load: {r.alt_link})")
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
90
119
|
def cmd_export(args) -> int:
|
|
91
120
|
from . import export
|
|
92
121
|
|
|
@@ -289,7 +318,9 @@ def cmd_serve(args) -> int:
|
|
|
289
318
|
from .serve import serve
|
|
290
319
|
|
|
291
320
|
project = _open(args)
|
|
292
|
-
|
|
321
|
+
# Without --port, a busy 8765 (e.g. a second `qdbd serve`) moves on to the next free port.
|
|
322
|
+
port, tries = (args.port, 1) if args.port is not None else (8765, 20)
|
|
323
|
+
return serve(project, port=port, open_browser=not args.no_open, read_only=args.read_only, tries=tries)
|
|
293
324
|
|
|
294
325
|
|
|
295
326
|
def build_parser() -> argparse.ArgumentParser:
|
|
@@ -328,6 +359,19 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
328
359
|
sp.add_argument("--strict", action="store_true")
|
|
329
360
|
sp.set_defaults(func=cmd_build)
|
|
330
361
|
|
|
362
|
+
sp = sub.add_parser("share", help="publish the diagram as a GitHub gist and print a link (needs the gh CLI)")
|
|
363
|
+
paths(sp)
|
|
364
|
+
selectors(sp)
|
|
365
|
+
sp.add_argument(
|
|
366
|
+
"--diff-base", metavar="GIT_REF", help="colour tables/columns changed since this git ref (e.g. origin/main)"
|
|
367
|
+
)
|
|
368
|
+
sp.add_argument("--by-group", action="store_true", help=BY_GROUP_HELP)
|
|
369
|
+
sp.add_argument("--layout", help="layout sidecar file (default: next to the schema)")
|
|
370
|
+
sp.add_argument("--strict", action="store_true")
|
|
371
|
+
sp.add_argument("--public", action="store_true", help="list the gist publicly (default: secret, link-only)")
|
|
372
|
+
sp.add_argument("--new", action="store_true", help="make a new gist instead of updating the last one")
|
|
373
|
+
sp.set_defaults(func=cmd_share)
|
|
374
|
+
|
|
331
375
|
sp = sub.add_parser("export", help="write a PNG, SVG or draw.io file")
|
|
332
376
|
paths(sp)
|
|
333
377
|
sp.add_argument("-o", "--output", required=True, help="out.png, out.svg or out.drawio")
|
|
@@ -395,9 +439,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
395
439
|
|
|
396
440
|
sp = sub.add_parser("serve", help="live editor: reload on save, drag tables to arrange")
|
|
397
441
|
paths(sp)
|
|
398
|
-
sp.add_argument("--port", type=int, default
|
|
442
|
+
sp.add_argument("--port", type=int, help="port to listen on (default: 8765, or the next free one)")
|
|
399
443
|
sp.add_argument("--layout")
|
|
400
444
|
sp.add_argument("--no-open", action="store_true", help="don't open a browser")
|
|
445
|
+
sp.add_argument(
|
|
446
|
+
"--read-only",
|
|
447
|
+
action="store_true",
|
|
448
|
+
help="live view only: no editor, nothing saved; safe to share through a tunnel",
|
|
449
|
+
)
|
|
401
450
|
sp.set_defaults(func=cmd_serve)
|
|
402
451
|
return p
|
|
403
452
|
|
|
@@ -92,13 +92,27 @@ FILTER_HELP = (
|
|
|
92
92
|
)
|
|
93
93
|
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
# Read-only live view: refetch the model whenever the server's version changes. Relative URLs, so it
|
|
96
|
+
# also works behind a tunnel or a path prefix.
|
|
97
|
+
LIVE_JS = (
|
|
98
|
+
"(function(){var v=null;new EventSource('events').onmessage=function(e){"
|
|
99
|
+
"if(v!==null&&e.data!==v)fetch('state').then(function(r){return r.json();})"
|
|
100
|
+
".then(function(s){window.qdbdViewer.setData(s.model,s.positions);});v=e.data;};})();"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def render_page(model: dict, positions: dict, title: str, serve: bool = False, live: bool = False) -> str:
|
|
105
|
+
"""The self-contained page. ``serve``: editor pane and saved dragging. ``live``: read-only, but reloads
|
|
106
|
+
when the server's schema changes (``qdbd serve --read-only``)."""
|
|
96
107
|
data = {"model": model, "positions": positions, "serve": serve, "title": title}
|
|
97
|
-
|
|
98
|
-
"Drag tables to arrange them (saved). Click a table for details. Esc clears."
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
108
|
+
if serve:
|
|
109
|
+
hint = "Drag tables to arrange them (saved). Click a table for details. Esc clears."
|
|
110
|
+
elif live:
|
|
111
|
+
hint = "Live, read-only view: updates when the schema changes. Drag tables to rearrange (not saved)."
|
|
112
|
+
else:
|
|
113
|
+
hint = (
|
|
114
|
+
"Drag tables to rearrange (not saved). Click a table for details. Scroll to zoom, drag empty space to pan."
|
|
115
|
+
)
|
|
102
116
|
body = BODY.format(side=SIDE if serve else "", count=len(model["tables"]), hint=hint, filter_help=FILTER_HELP)
|
|
103
117
|
head_extra = ""
|
|
104
118
|
scripts = ""
|
|
@@ -109,6 +123,8 @@ def render_page(model: dict, positions: dict, title: str, serve: bool = False) -
|
|
|
109
123
|
f'<script src="{CODEMIRROR}/addon/mode/simple.min.js"></script>'
|
|
110
124
|
f"<script>{_asset('editor.js')}</script>"
|
|
111
125
|
)
|
|
126
|
+
elif live:
|
|
127
|
+
scripts = f"<script>{LIVE_JS}</script>"
|
|
112
128
|
return (
|
|
113
129
|
'<!doctype html>\n<html lang="en"><head><meta charset="utf-8">'
|
|
114
130
|
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
Watches the schema files (mtime polling), recompiles on change and pushes a reload event to the
|
|
4
4
|
page over Server-Sent Events. Dragged table positions are saved to the layout sidecar; the
|
|
5
5
|
browser editor pane writes the .txt files, refusing to overwrite a file changed on disk since
|
|
6
|
-
the browser last loaded it.
|
|
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.
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
from __future__ import annotations
|
|
10
11
|
|
|
12
|
+
import errno
|
|
11
13
|
import json
|
|
12
14
|
import sys
|
|
13
15
|
import threading
|
|
@@ -22,8 +24,9 @@ from .project import Project, discover, display_path
|
|
|
22
24
|
|
|
23
25
|
|
|
24
26
|
class State:
|
|
25
|
-
def __init__(self, project: Project):
|
|
27
|
+
def __init__(self, project: Project, read_only: bool = False):
|
|
26
28
|
self.project = project
|
|
29
|
+
self.read_only = read_only # never write to disk: no editor saves, no layout file
|
|
27
30
|
self.lock = threading.Condition()
|
|
28
31
|
self.version = 0
|
|
29
32
|
self.model: dict = {"style": {}, "tables": [], "rels": [], "zoom": None, "position": None}
|
|
@@ -61,7 +64,8 @@ class State:
|
|
|
61
64
|
pos = layout.resolve_positions(model, base, schema.layout)
|
|
62
65
|
self.model = model
|
|
63
66
|
self.positions = {k: [v[0], v[1]] for k, v in pos.items()}
|
|
64
|
-
|
|
67
|
+
if not self.read_only:
|
|
68
|
+
self.project.save_positions(model, self.positions)
|
|
65
69
|
self.version += 1
|
|
66
70
|
self.lock.notify_all()
|
|
67
71
|
if not initial:
|
|
@@ -76,6 +80,8 @@ class State:
|
|
|
76
80
|
|
|
77
81
|
def snapshot(self) -> dict:
|
|
78
82
|
with self.lock:
|
|
83
|
+
if self.read_only: # the model only: no file text, no diagnostics
|
|
84
|
+
return {"version": self.version, "model": self.model, "positions": self.positions}
|
|
79
85
|
files = []
|
|
80
86
|
for f in self.files:
|
|
81
87
|
p = display_path(f)
|
|
@@ -134,7 +140,10 @@ def _handler(state: State, title: str):
|
|
|
134
140
|
def do_GET(self) -> None:
|
|
135
141
|
if self.path == "/":
|
|
136
142
|
with state.lock:
|
|
137
|
-
|
|
143
|
+
if state.read_only:
|
|
144
|
+
page = render_page(state.model, state.positions, title, live=True)
|
|
145
|
+
else:
|
|
146
|
+
page = render_page(state.model, state.positions, title, serve=True)
|
|
138
147
|
self._send(200, page.encode(), "text/html; charset=utf-8")
|
|
139
148
|
elif self.path == "/state":
|
|
140
149
|
self._json(200, state.snapshot())
|
|
@@ -161,14 +170,18 @@ def _handler(state: State, title: str):
|
|
|
161
170
|
self._send(404, b"not found", "text/plain")
|
|
162
171
|
|
|
163
172
|
def do_POST(self) -> None:
|
|
164
|
-
if
|
|
173
|
+
if state.read_only:
|
|
174
|
+
self._json(403, {"error": "read-only"})
|
|
175
|
+
elif self.path == "/layout":
|
|
165
176
|
state.save_layout(self._body())
|
|
166
177
|
self._json(200, {"ok": True})
|
|
167
178
|
else:
|
|
168
179
|
self._send(404, b"not found", "text/plain")
|
|
169
180
|
|
|
170
181
|
def do_PUT(self) -> None:
|
|
171
|
-
if
|
|
182
|
+
if state.read_only:
|
|
183
|
+
self._json(403, {"error": "read-only"})
|
|
184
|
+
elif self.path == "/file":
|
|
172
185
|
b = self._body()
|
|
173
186
|
code, obj = state.write_file(b.get("path", ""), b.get("text", ""), b.get("mtime"))
|
|
174
187
|
self._json(code, obj)
|
|
@@ -178,11 +191,29 @@ def _handler(state: State, title: str):
|
|
|
178
191
|
return Handler
|
|
179
192
|
|
|
180
193
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
194
|
+
class PortInUse(OSError):
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def make_server(
|
|
199
|
+
project: Project, port: int = 8765, read_only: bool = False, tries: int = 1
|
|
200
|
+
) -> tuple[ThreadingHTTPServer, State]:
|
|
201
|
+
"""Bind the HTTP server without starting it. Port 0 picks a free port; with ``tries`` > 1, a busy port
|
|
202
|
+
moves on to the next one (port, port+1, ...)."""
|
|
203
|
+
state = State(project, read_only=read_only)
|
|
184
204
|
title = project.inputs[0].stem if len(project.inputs) == 1 else "diagram"
|
|
185
|
-
server =
|
|
205
|
+
server = None
|
|
206
|
+
for p in range(port, port + max(tries, 1)) if port else [0]:
|
|
207
|
+
try:
|
|
208
|
+
server = ThreadingHTTPServer(("127.0.0.1", p), _handler(state, title))
|
|
209
|
+
break
|
|
210
|
+
except OSError as e:
|
|
211
|
+
if e.errno != errno.EADDRINUSE:
|
|
212
|
+
raise
|
|
213
|
+
if server is None:
|
|
214
|
+
last = port + max(tries, 1) - 1
|
|
215
|
+
span = f"ports {port}-{last} are" if last > port else f"port {port} is"
|
|
216
|
+
raise PortInUse(f"{span} already in use (another qdbd serve?); pick one with --port")
|
|
186
217
|
server.daemon_threads = True
|
|
187
218
|
return server, state
|
|
188
219
|
|
|
@@ -198,12 +229,22 @@ def poll_once(state: State) -> bool:
|
|
|
198
229
|
return True
|
|
199
230
|
|
|
200
231
|
|
|
201
|
-
def serve(
|
|
202
|
-
|
|
232
|
+
def serve(
|
|
233
|
+
project: Project, port: int = 8765, open_browser: bool = True, read_only: bool = False, tries: int = 1
|
|
234
|
+
) -> int:
|
|
235
|
+
try:
|
|
236
|
+
server, state = make_server(project, port, read_only=read_only, tries=tries)
|
|
237
|
+
except PortInUse as e:
|
|
238
|
+
print(f"error: {e}", file=sys.stderr)
|
|
239
|
+
return 2
|
|
203
240
|
for d in state.diagnostics:
|
|
204
241
|
print(d["text"], file=sys.stderr)
|
|
205
242
|
url = f"http://127.0.0.1:{server.server_address[1]}/"
|
|
206
|
-
|
|
243
|
+
if read_only:
|
|
244
|
+
print(f"[qdbd] serving {len(state.files)} file(s) read-only at {url}")
|
|
245
|
+
print(f"[qdbd] nothing is written to disk. To share it: cloudflared tunnel --url {url}")
|
|
246
|
+
else:
|
|
247
|
+
print(f"[qdbd] serving {len(state.files)} file(s) at {url} (layout: {display_path(project.sidecar)})")
|
|
207
248
|
print("[qdbd] Ctrl+C to stop", flush=True)
|
|
208
249
|
|
|
209
250
|
def watch() -> None:
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""`qdbd share`: publish the built page as a GitHub gist and return a link to view it.
|
|
2
|
+
|
|
3
|
+
Uses the GitHub CLI (``gh api``), so it inherits the user's login. Gists are secret (unlisted) unless
|
|
4
|
+
``public``. The gist made for each input/selection is remembered in the user's cache directory, so
|
|
5
|
+
sharing again updates the same gist and the link stays the same.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ShareError(Exception):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Shared:
|
|
24
|
+
link: str # gistpreview: renders the gist's HTML, keeps the #view part of the URL
|
|
25
|
+
alt_link: str # githack: serves the raw file as text/html (after a one-click confirmation page)
|
|
26
|
+
gist_url: str
|
|
27
|
+
public: bool
|
|
28
|
+
updated: bool
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def share_key(paths: list[str], select: str = "") -> str:
|
|
32
|
+
return "|".join(sorted(str(Path(p).resolve()) for p in paths)) + "#" + select
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cache_file() -> Path:
|
|
36
|
+
if os.environ.get("QDBD_CACHE_DIR"):
|
|
37
|
+
base = Path(os.environ["QDBD_CACHE_DIR"])
|
|
38
|
+
else:
|
|
39
|
+
base = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "qdbd"
|
|
40
|
+
return base / "shares.json"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _load() -> dict:
|
|
44
|
+
try:
|
|
45
|
+
return json.loads(cache_file().read_text(encoding="utf-8"))
|
|
46
|
+
except (FileNotFoundError, ValueError):
|
|
47
|
+
return {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _save(data: dict) -> None:
|
|
51
|
+
f = cache_file()
|
|
52
|
+
f.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
f.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _gh(method: str, endpoint: str, payload: dict) -> dict:
|
|
57
|
+
try:
|
|
58
|
+
r = subprocess.run(
|
|
59
|
+
["gh", "api", "--method", method, endpoint, "--input", "-"],
|
|
60
|
+
input=json.dumps(payload),
|
|
61
|
+
capture_output=True,
|
|
62
|
+
text=True,
|
|
63
|
+
check=False,
|
|
64
|
+
)
|
|
65
|
+
except FileNotFoundError:
|
|
66
|
+
raise ShareError(
|
|
67
|
+
"qdbd share needs the GitHub CLI: install it (brew install gh), then run gh auth login"
|
|
68
|
+
) from None
|
|
69
|
+
if r.returncode != 0:
|
|
70
|
+
msg = (r.stderr or r.stdout).strip()
|
|
71
|
+
if "auth login" in msg or "not logged" in msg.lower():
|
|
72
|
+
raise ShareError("not logged in to GitHub: run gh auth login")
|
|
73
|
+
raise ShareError(f"gh api {method} {endpoint} failed: {msg}")
|
|
74
|
+
return json.loads(r.stdout)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _filename(title: str) -> str:
|
|
78
|
+
slug = re.sub(r"[^A-Za-z0-9._-]+", "-", title).strip("-.") or "diagram"
|
|
79
|
+
return f"{slug}.html"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def publish(page: str, title: str, key: str, public: bool = False, new: bool = False) -> Shared:
|
|
83
|
+
cache = _load()
|
|
84
|
+
entry = None if new else cache.get(key)
|
|
85
|
+
desc = f"qdbd ERD: {title}"
|
|
86
|
+
gist = None
|
|
87
|
+
if entry:
|
|
88
|
+
try:
|
|
89
|
+
gist = _gh(
|
|
90
|
+
"PATCH", f"/gists/{entry['id']}", {"description": desc, "files": {entry["file"]: {"content": page}}}
|
|
91
|
+
)
|
|
92
|
+
except ShareError as e:
|
|
93
|
+
if "404" not in str(e) and "Not Found" not in str(e):
|
|
94
|
+
raise
|
|
95
|
+
entry = None # the gist was deleted: make a new one
|
|
96
|
+
updated = gist is not None
|
|
97
|
+
fname = entry["file"] if entry else _filename(title)
|
|
98
|
+
if gist is None:
|
|
99
|
+
gist = _gh("POST", "/gists", {"description": desc, "public": public, "files": {fname: {"content": page}}})
|
|
100
|
+
owner = (gist.get("owner") or {}).get("login") or ""
|
|
101
|
+
cache[key] = {"id": gist["id"], "owner": owner, "file": fname}
|
|
102
|
+
_save(cache)
|
|
103
|
+
return Shared(
|
|
104
|
+
link=f"https://gistpreview.github.io/?{gist['id']}",
|
|
105
|
+
alt_link=f"https://gist.githack.com/{owner}/{gist['id']}/raw/{fname}",
|
|
106
|
+
gist_url=gist.get("html_url", ""),
|
|
107
|
+
public=bool(gist.get("public")),
|
|
108
|
+
updated=updated,
|
|
109
|
+
)
|
|
@@ -158,3 +158,72 @@ def test_events_stream_announces_versions(srv):
|
|
|
158
158
|
poll_once(state)
|
|
159
159
|
t.join(timeout=10)
|
|
160
160
|
assert len(got) == 2 and got[1] > got[0]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ------------------------------------------------------------------ --read-only
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@pytest.fixture
|
|
167
|
+
def ro(tmp_path):
|
|
168
|
+
f = tmp_path / "shop.txt"
|
|
169
|
+
f.write_text(SCHEMA)
|
|
170
|
+
server, state = make_server(Project.open([f]), port=0, read_only=True)
|
|
171
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
172
|
+
yield f"http://127.0.0.1:{server.server_address[1]}", state, f
|
|
173
|
+
server.shutdown()
|
|
174
|
+
server.server_close()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_read_only_page_has_no_editor_and_reloads_live(ro):
|
|
178
|
+
base, _, _ = ro
|
|
179
|
+
code, page = call(base, "/")
|
|
180
|
+
assert code == 200 and b'id="side"' not in page and b'"serve":false' in page
|
|
181
|
+
assert b"codemirror.min.js" not in page and b"EventSource('events')" in page
|
|
182
|
+
code, st = call(base, "/state")
|
|
183
|
+
assert code == 200 and set(st) == {"version", "model", "positions"} # no file text
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_read_only_refuses_writes_and_never_touches_disk(ro):
|
|
187
|
+
base, state, f = ro
|
|
188
|
+
assert not f.with_name("shop.layout.json").exists()
|
|
189
|
+
assert call(base, "/file", "PUT", {"path": display_path(f), "text": "x", "mtime": None})[0] == 403
|
|
190
|
+
assert call(base, "/layout", "POST", {"Customer": [1, 2]})[0] == 403
|
|
191
|
+
assert f.read_text() == SCHEMA
|
|
192
|
+
f.write_text(SCHEMA + "\nProduct\n-\nProductId PK int\n")
|
|
193
|
+
bump_mtime(f)
|
|
194
|
+
assert poll_once(state)
|
|
195
|
+
assert [t["name"] for t in call(base, "/state")[1]["model"]["tables"]] == ["Customer", "Order", "Product"]
|
|
196
|
+
assert not f.with_name("shop.layout.json").exists()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ------------------------------------------------------------------ busy ports
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_busy_port_moves_to_the_next_free_one(tmp_path):
|
|
203
|
+
from quickdbd_cli.serve import PortInUse
|
|
204
|
+
|
|
205
|
+
f = tmp_path / "shop.txt"
|
|
206
|
+
f.write_text(SCHEMA)
|
|
207
|
+
first, _ = make_server(Project.open([f]), port=0)
|
|
208
|
+
busy = first.server_address[1]
|
|
209
|
+
try:
|
|
210
|
+
with pytest.raises(PortInUse, match=f"port {busy} is already in use"):
|
|
211
|
+
make_server(Project.open([f]), port=busy)
|
|
212
|
+
second, _ = make_server(Project.open([f]), port=busy, tries=20)
|
|
213
|
+
assert second.server_address[1] > busy
|
|
214
|
+
second.server_close()
|
|
215
|
+
finally:
|
|
216
|
+
first.server_close()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_cli_explicit_busy_port_is_a_clean_error(tmp_path, capsys):
|
|
220
|
+
from quickdbd_cli.cli import main
|
|
221
|
+
|
|
222
|
+
f = tmp_path / "shop.txt"
|
|
223
|
+
f.write_text(SCHEMA)
|
|
224
|
+
first, _ = make_server(Project.open([f]), port=0)
|
|
225
|
+
try:
|
|
226
|
+
assert main(["serve", str(f), "--no-open", "--port", str(first.server_address[1])]) == 2
|
|
227
|
+
assert "already in use" in capsys.readouterr().err
|
|
228
|
+
finally:
|
|
229
|
+
first.server_close()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""`qdbd share`, with `gh api` faked (no network)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from quickdbd_cli import share
|
|
9
|
+
from quickdbd_cli.cli import main
|
|
10
|
+
|
|
11
|
+
SCHEMA = "Customer\n-\nId PK int\n\nOrder\n-\nId PK int\nCustomerId int FK >- Customer.Id\n"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FakeGh:
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.calls = []
|
|
17
|
+
self.gists = {}
|
|
18
|
+
self.fail = None
|
|
19
|
+
|
|
20
|
+
def __call__(self, cmd, input=None, **kw):
|
|
21
|
+
assert cmd[:3] == ["gh", "api", "--method"] and cmd[-2:] == ["--input", "-"]
|
|
22
|
+
method, endpoint, payload = cmd[3], cmd[4], json.loads(input)
|
|
23
|
+
self.calls.append((method, endpoint, payload))
|
|
24
|
+
if self.fail:
|
|
25
|
+
return subprocess.CompletedProcess(cmd, 1, "", self.fail)
|
|
26
|
+
if method == "POST":
|
|
27
|
+
gid = f"g{len(self.gists) + 1}"
|
|
28
|
+
self.gists[gid] = payload
|
|
29
|
+
body = {"id": gid, "html_url": f"https://gist.github.com/me/{gid}", "public": payload["public"]}
|
|
30
|
+
else:
|
|
31
|
+
gid = endpoint.rsplit("/", 1)[1]
|
|
32
|
+
if gid not in self.gists:
|
|
33
|
+
return subprocess.CompletedProcess(cmd, 1, "", "gh: Not Found (HTTP 404)")
|
|
34
|
+
self.gists[gid]["files"].update(payload["files"])
|
|
35
|
+
body = {"id": gid, "html_url": f"https://gist.github.com/me/{gid}", "public": self.gists[gid]["public"]}
|
|
36
|
+
body["owner"] = {"login": "me"}
|
|
37
|
+
return subprocess.CompletedProcess(cmd, 0, json.dumps(body), "")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.fixture
|
|
41
|
+
def gh(monkeypatch, tmp_path):
|
|
42
|
+
fake = FakeGh()
|
|
43
|
+
monkeypatch.setattr(share.subprocess, "run", fake)
|
|
44
|
+
monkeypatch.setenv("QDBD_CACHE_DIR", str(tmp_path / "cache"))
|
|
45
|
+
return fake
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.fixture
|
|
49
|
+
def schema(tmp_path):
|
|
50
|
+
f = tmp_path / "shop.txt"
|
|
51
|
+
f.write_text(SCHEMA)
|
|
52
|
+
return f
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_share_creates_a_secret_gist_and_prints_links(gh, schema, capsys):
|
|
56
|
+
assert main(["share", str(schema)]) == 0
|
|
57
|
+
out = capsys.readouterr().out
|
|
58
|
+
method, endpoint, payload = gh.calls[0]
|
|
59
|
+
assert (method, endpoint, payload["public"]) == ("POST", "/gists", False)
|
|
60
|
+
assert list(payload["files"]) == ["shop.html"]
|
|
61
|
+
assert payload["files"]["shop.html"]["content"].startswith("<!doctype html>")
|
|
62
|
+
assert "Shared 2 tables (secret gist https://gist.github.com/me/g1)" in out
|
|
63
|
+
assert "Link: https://gistpreview.github.io/?g1" in out
|
|
64
|
+
assert "https://gist.githack.com/me/g1/raw/shop.html" in out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_sharing_again_updates_the_same_gist(gh, schema, capsys):
|
|
68
|
+
main(["share", str(schema)])
|
|
69
|
+
schema.write_text(SCHEMA + "\nProduct\n-\nId PK int\n")
|
|
70
|
+
assert main(["share", str(schema)]) == 0
|
|
71
|
+
assert [c[0] for c in gh.calls] == ["POST", "PATCH"]
|
|
72
|
+
assert gh.calls[1][1] == "/gists/g1"
|
|
73
|
+
assert "Product" in gh.gists["g1"]["files"]["shop.html"]["content"]
|
|
74
|
+
assert "Updated 3 tables" in capsys.readouterr().out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_new_and_selection_make_separate_gists(gh, schema):
|
|
78
|
+
main(["share", str(schema)])
|
|
79
|
+
main(["share", str(schema), "--new"])
|
|
80
|
+
main(["share", str(schema), "-s", "Order+"])
|
|
81
|
+
assert [c[0] for c in gh.calls] == ["POST", "POST", "POST"]
|
|
82
|
+
main(["share", str(schema), "-s", "Order+"])
|
|
83
|
+
assert gh.calls[-1][:2] == ("PATCH", "/gists/g3")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_deleted_gist_is_recreated(gh, schema):
|
|
87
|
+
main(["share", str(schema)])
|
|
88
|
+
gh.gists.clear()
|
|
89
|
+
assert main(["share", str(schema)]) == 0
|
|
90
|
+
assert [c[0] for c in gh.calls] == ["POST", "PATCH", "POST"]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_public_flag(gh, schema, capsys):
|
|
94
|
+
main(["share", str(schema), "--public"])
|
|
95
|
+
assert gh.calls[0][2]["public"] is True
|
|
96
|
+
assert "(public gist" in capsys.readouterr().out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_errors(gh, schema, capsys, monkeypatch):
|
|
100
|
+
gh.fail = "To get started with GitHub CLI, please run: gh auth login"
|
|
101
|
+
assert main(["share", str(schema)]) == 2
|
|
102
|
+
assert "run gh auth login" in capsys.readouterr().err
|
|
103
|
+
|
|
104
|
+
def missing(*a, **k):
|
|
105
|
+
raise FileNotFoundError
|
|
106
|
+
|
|
107
|
+
monkeypatch.setattr(share.subprocess, "run", missing)
|
|
108
|
+
assert main(["share", str(schema)]) == 2
|
|
109
|
+
assert "needs the GitHub CLI" in capsys.readouterr().err
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_schema_errors_stop_before_uploading(gh, tmp_path):
|
|
113
|
+
f = tmp_path / "bad.txt"
|
|
114
|
+
f.write_text("A\n-\nId int FK >- Nope.Id\n")
|
|
115
|
+
assert main(["share", str(f)]) == 1
|
|
116
|
+
assert gh.calls == []
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.8"
|
|
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
|
|
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
|