quickdbd-cli 0.1.8__tar.gz → 0.1.9__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 (58) hide show
  1. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/PKG-INFO +26 -6
  2. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/README.md +25 -5
  3. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/pyproject.toml +1 -1
  4. quickdbd_cli-0.1.9/src/quickdbd_cli/__init__.py +1 -0
  5. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/cli.py +50 -3
  6. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/html.py +22 -6
  7. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/serve.py +27 -11
  8. quickdbd_cli-0.1.9/src/quickdbd_cli/share.py +109 -0
  9. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/tests/test_serve.py +36 -0
  10. quickdbd_cli-0.1.9/tests/test_share.py +116 -0
  11. quickdbd_cli-0.1.8/src/quickdbd_cli/__init__.py +0 -1
  12. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/.gitignore +0 -0
  13. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/LICENSE +0 -0
  14. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/NOTICE +0 -0
  15. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/examples/quickdbd-sample.txt +0 -0
  16. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/examples/synthetic/finance.txt +0 -0
  17. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/examples/synthetic/hr.txt +0 -0
  18. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/examples/synthetic/inventory.txt +0 -0
  19. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/examples/synthetic/marketing.txt +0 -0
  20. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/examples/synthetic/qdbd.layout.json +0 -0
  21. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/examples/synthetic/sales.txt +0 -0
  22. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/scripts/gen_font_metrics.py +0 -0
  23. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/scripts/gen_schema.py +0 -0
  24. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/scripts/release.sh +0 -0
  25. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/__main__.py +0 -0
  26. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/Inter-Bold.ttf +0 -0
  27. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/Inter-Bold.woff2 +0 -0
  28. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/Inter-LICENSE.txt +0 -0
  29. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/Inter-Regular.ttf +0 -0
  30. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/Inter-Regular.woff2 +0 -0
  31. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/editor.js +0 -0
  32. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/elk-LICENSE.md +0 -0
  33. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/elk.bundled.js +0 -0
  34. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/font-metrics.json +0 -0
  35. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/render.js +0 -0
  36. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/style.css +0 -0
  37. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/assets/viewer.js +0 -0
  38. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/dbt.py +0 -0
  39. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/diff.py +0 -0
  40. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/export.py +0 -0
  41. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/jsrt.py +0 -0
  42. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/layout.py +0 -0
  43. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/measure.py +0 -0
  44. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/model.py +0 -0
  45. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/parser.py +0 -0
  46. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/project.py +0 -0
  47. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/src/quickdbd_cli/tags.py +0 -0
  48. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/tests/fixtures/jaffle/LICENSE +0 -0
  49. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/tests/fixtures/jaffle/README.md +0 -0
  50. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/tests/fixtures/jaffle/catalog.json +0 -0
  51. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/tests/fixtures/jaffle/manifest.json +0 -0
  52. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/tests/test_dbt.py +0 -0
  53. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/tests/test_diff.py +0 -0
  54. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/tests/test_parser.py +0 -0
  55. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/tests/test_path.py +0 -0
  56. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/tests/test_pipeline.py +0 -0
  57. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/tests/test_select.py +0 -0
  58. {quickdbd_cli-0.1.8 → quickdbd_cli-0.1.9}/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.8
3
+ Version: 0.1.9
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
  ![Path from Invoice to Campaign](https://raw.githubusercontent.com/MarkPhamm/quickdbd-cli/main/docs/assets/img/path.png)
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.8
390
+ - uses: MarkPhamm/quickdbd-cli@v0.1.9
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.8
413
+ - uses: MarkPhamm/quickdbd-cli@v0.1.9
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.8
428
+ rev: v0.1.9
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] # live editor + drag to arrange
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
  ![Path from Invoice to Campaign](https://raw.githubusercontent.com/MarkPhamm/quickdbd-cli/main/docs/assets/img/path.png)
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.8
362
+ - uses: MarkPhamm/quickdbd-cli@v0.1.9
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.8
385
+ - uses: MarkPhamm/quickdbd-cli@v0.1.9
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.8
400
+ rev: v0.1.9
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] # live editor + drag to arrange
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.8"
7
+ version = "0.1.9"
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.9"
@@ -47,7 +47,8 @@ def cmd_check(args) -> int:
47
47
  return 0 if ok else 1
48
48
 
49
49
 
50
- def cmd_build(args) -> int:
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(render_page(model, pos, _title(project)), encoding="utf-8")
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,7 @@ def cmd_serve(args) -> int:
289
318
  from .serve import serve
290
319
 
291
320
  project = _open(args)
292
- return serve(project, port=args.port, open_browser=not args.no_open)
321
+ return serve(project, port=args.port, open_browser=not args.no_open, read_only=args.read_only)
293
322
 
294
323
 
295
324
  def build_parser() -> argparse.ArgumentParser:
@@ -328,6 +357,19 @@ def build_parser() -> argparse.ArgumentParser:
328
357
  sp.add_argument("--strict", action="store_true")
329
358
  sp.set_defaults(func=cmd_build)
330
359
 
360
+ sp = sub.add_parser("share", help="publish the diagram as a GitHub gist and print a link (needs the gh CLI)")
361
+ paths(sp)
362
+ selectors(sp)
363
+ sp.add_argument(
364
+ "--diff-base", metavar="GIT_REF", help="colour tables/columns changed since this git ref (e.g. origin/main)"
365
+ )
366
+ sp.add_argument("--by-group", action="store_true", help=BY_GROUP_HELP)
367
+ sp.add_argument("--layout", help="layout sidecar file (default: next to the schema)")
368
+ sp.add_argument("--strict", action="store_true")
369
+ sp.add_argument("--public", action="store_true", help="list the gist publicly (default: secret, link-only)")
370
+ sp.add_argument("--new", action="store_true", help="make a new gist instead of updating the last one")
371
+ sp.set_defaults(func=cmd_share)
372
+
331
373
  sp = sub.add_parser("export", help="write a PNG, SVG or draw.io file")
332
374
  paths(sp)
333
375
  sp.add_argument("-o", "--output", required=True, help="out.png, out.svg or out.drawio")
@@ -398,6 +440,11 @@ def build_parser() -> argparse.ArgumentParser:
398
440
  sp.add_argument("--port", type=int, default=8765)
399
441
  sp.add_argument("--layout")
400
442
  sp.add_argument("--no-open", action="store_true", help="don't open a browser")
443
+ sp.add_argument(
444
+ "--read-only",
445
+ action="store_true",
446
+ help="live view only: no editor, nothing saved; safe to share through a tunnel",
447
+ )
401
448
  sp.set_defaults(func=cmd_serve)
402
449
  return p
403
450
 
@@ -92,13 +92,27 @@ FILTER_HELP = (
92
92
  )
93
93
 
94
94
 
95
- def render_page(model: dict, positions: dict, title: str, serve: bool = False) -> str:
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
- hint = (
98
- "Drag tables to arrange them (saved). Click a table for details. Esc clears."
99
- if serve
100
- else "Drag tables to rearrange (not saved). Click a table for details. Scroll to zoom, drag empty space to pan."
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,7 +3,8 @@
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
@@ -22,8 +23,9 @@ from .project import Project, discover, display_path
22
23
 
23
24
 
24
25
  class State:
25
- def __init__(self, project: Project):
26
+ def __init__(self, project: Project, read_only: bool = False):
26
27
  self.project = project
28
+ self.read_only = read_only # never write to disk: no editor saves, no layout file
27
29
  self.lock = threading.Condition()
28
30
  self.version = 0
29
31
  self.model: dict = {"style": {}, "tables": [], "rels": [], "zoom": None, "position": None}
@@ -61,7 +63,8 @@ class State:
61
63
  pos = layout.resolve_positions(model, base, schema.layout)
62
64
  self.model = model
63
65
  self.positions = {k: [v[0], v[1]] for k, v in pos.items()}
64
- self.project.save_positions(model, self.positions)
66
+ if not self.read_only:
67
+ self.project.save_positions(model, self.positions)
65
68
  self.version += 1
66
69
  self.lock.notify_all()
67
70
  if not initial:
@@ -76,6 +79,8 @@ class State:
76
79
 
77
80
  def snapshot(self) -> dict:
78
81
  with self.lock:
82
+ if self.read_only: # the model only: no file text, no diagnostics
83
+ return {"version": self.version, "model": self.model, "positions": self.positions}
79
84
  files = []
80
85
  for f in self.files:
81
86
  p = display_path(f)
@@ -134,7 +139,10 @@ def _handler(state: State, title: str):
134
139
  def do_GET(self) -> None:
135
140
  if self.path == "/":
136
141
  with state.lock:
137
- page = render_page(state.model, state.positions, title, serve=True)
142
+ if state.read_only:
143
+ page = render_page(state.model, state.positions, title, live=True)
144
+ else:
145
+ page = render_page(state.model, state.positions, title, serve=True)
138
146
  self._send(200, page.encode(), "text/html; charset=utf-8")
139
147
  elif self.path == "/state":
140
148
  self._json(200, state.snapshot())
@@ -161,14 +169,18 @@ def _handler(state: State, title: str):
161
169
  self._send(404, b"not found", "text/plain")
162
170
 
163
171
  def do_POST(self) -> None:
164
- if self.path == "/layout":
172
+ if state.read_only:
173
+ self._json(403, {"error": "read-only"})
174
+ elif self.path == "/layout":
165
175
  state.save_layout(self._body())
166
176
  self._json(200, {"ok": True})
167
177
  else:
168
178
  self._send(404, b"not found", "text/plain")
169
179
 
170
180
  def do_PUT(self) -> None:
171
- if self.path == "/file":
181
+ if state.read_only:
182
+ self._json(403, {"error": "read-only"})
183
+ elif self.path == "/file":
172
184
  b = self._body()
173
185
  code, obj = state.write_file(b.get("path", ""), b.get("text", ""), b.get("mtime"))
174
186
  self._json(code, obj)
@@ -178,9 +190,9 @@ def _handler(state: State, title: str):
178
190
  return Handler
179
191
 
180
192
 
181
- def make_server(project: Project, port: int = 8765) -> tuple[ThreadingHTTPServer, State]:
193
+ def make_server(project: Project, port: int = 8765, read_only: bool = False) -> tuple[ThreadingHTTPServer, State]:
182
194
  """Bind the HTTP server (port 0 picks a free port) without starting it."""
183
- state = State(project)
195
+ state = State(project, read_only=read_only)
184
196
  title = project.inputs[0].stem if len(project.inputs) == 1 else "diagram"
185
197
  server = ThreadingHTTPServer(("127.0.0.1", port), _handler(state, title))
186
198
  server.daemon_threads = True
@@ -198,12 +210,16 @@ def poll_once(state: State) -> bool:
198
210
  return True
199
211
 
200
212
 
201
- def serve(project: Project, port: int = 8765, open_browser: bool = True) -> int:
202
- server, state = make_server(project, port)
213
+ def serve(project: Project, port: int = 8765, open_browser: bool = True, read_only: bool = False) -> int:
214
+ server, state = make_server(project, port, read_only=read_only)
203
215
  for d in state.diagnostics:
204
216
  print(d["text"], file=sys.stderr)
205
217
  url = f"http://127.0.0.1:{server.server_address[1]}/"
206
- print(f"[qdbd] serving {len(state.files)} file(s) at {url} (layout: {display_path(project.sidecar)})")
218
+ if read_only:
219
+ print(f"[qdbd] serving {len(state.files)} file(s) read-only at {url}")
220
+ print(f"[qdbd] nothing is written to disk. To share it: cloudflared tunnel --url {url}")
221
+ else:
222
+ print(f"[qdbd] serving {len(state.files)} file(s) at {url} (layout: {display_path(project.sidecar)})")
207
223
  print("[qdbd] Ctrl+C to stop", flush=True)
208
224
 
209
225
  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,39 @@ 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()
@@ -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