bideux 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. bideux/__init__.py +157 -0
  2. bideux/_guard.py +62 -0
  3. bideux/authoring.py +12 -0
  4. bideux/cli.py +118 -0
  5. bideux/core.py +12 -0
  6. bideux/corpus.py +19 -0
  7. bideux/demo.py +200 -0
  8. bideux/doctor.py +249 -0
  9. bideux/engine.py +14 -0
  10. bideux/fastapi/__init__.py +62 -0
  11. bideux/fastapi/authoring.py +472 -0
  12. bideux/fastapi/dashboards.py +381 -0
  13. bideux/fastapi/mount.py +354 -0
  14. bideux/fastapi/reader.py +227 -0
  15. bideux/fastapi/reports.py +95 -0
  16. bideux/hosting.py +387 -0
  17. bideux/init.py +109 -0
  18. bideux/py.typed +0 -0
  19. bideux/quickstart.py +154 -0
  20. bideux/report.py +12 -0
  21. bideux/server.py +12 -0
  22. bideux/spec.py +13 -0
  23. bideux/store.py +12 -0
  24. bideux-0.1.2.dist-info/METADATA +157 -0
  25. bideux-0.1.2.dist-info/RECORD +122 -0
  26. bideux-0.1.2.dist-info/WHEEL +4 -0
  27. bideux-0.1.2.dist-info/entry_points.txt +2 -0
  28. bideux-0.1.2.dist-info/licenses/LICENSE +202 -0
  29. dashboard_authoring/__init__.py +223 -0
  30. dashboard_authoring/checks.py +604 -0
  31. dashboard_authoring/drafts.py +621 -0
  32. dashboard_authoring/grade.py +427 -0
  33. dashboard_authoring/grounding.py +257 -0
  34. dashboard_authoring/history.py +233 -0
  35. dashboard_authoring/parsing.py +48 -0
  36. dashboard_authoring/ports.py +412 -0
  37. dashboard_authoring/prompts.py +313 -0
  38. dashboard_authoring/py.typed +0 -0
  39. dashboard_authoring/render.py +809 -0
  40. dashboard_authoring/results.py +129 -0
  41. dashboard_authoring/service.py +1510 -0
  42. dashboard_authoring/suite.py +394 -0
  43. dashboard_authoring/tools.py +252 -0
  44. dashboard_authoring/warehouse.py +208 -0
  45. dashboard_core/__init__.py +128 -0
  46. dashboard_core/cron.py +127 -0
  47. dashboard_core/pivot.py +1259 -0
  48. dashboard_core/py.typed +0 -0
  49. dashboard_core/schedule_filters.py +205 -0
  50. dashboard_core/schedule_timing.py +184 -0
  51. dashboard_core/tokens.py +76 -0
  52. dashboard_engine/__init__.py +198 -0
  53. dashboard_engine/diagnostics.py +664 -0
  54. dashboard_engine/errors.py +48 -0
  55. dashboard_engine/execution.py +1146 -0
  56. dashboard_engine/expressions.py +725 -0
  57. dashboard_engine/periods.py +320 -0
  58. dashboard_engine/planning.py +665 -0
  59. dashboard_engine/ports.py +161 -0
  60. dashboard_engine/py.typed +0 -0
  61. dashboard_engine/reference.py +634 -0
  62. dashboard_engine/results.py +38 -0
  63. dashboard_engine/sql.py +490 -0
  64. dashboard_engine/tables.py +322 -0
  65. dashboard_engine/testing.py +245 -0
  66. dashboard_engine/widgets.py +717 -0
  67. dashboard_report/__init__.py +91 -0
  68. dashboard_report/bands.py +474 -0
  69. dashboard_report/charts.py +568 -0
  70. dashboard_report/constants.py +328 -0
  71. dashboard_report/csv_report.py +110 -0
  72. dashboard_report/deck.py +2554 -0
  73. dashboard_report/logos.py +104 -0
  74. dashboard_report/pages.py +1279 -0
  75. dashboard_report/pictures.py +55 -0
  76. dashboard_report/pivots.py +223 -0
  77. dashboard_report/plots.py +277 -0
  78. dashboard_report/ports.py +170 -0
  79. dashboard_report/py.typed +0 -0
  80. dashboard_report/report.py +464 -0
  81. dashboard_report/sections.py +516 -0
  82. dashboard_report/service.py +165 -0
  83. dashboard_report/theme.py +142 -0
  84. dashboard_report/values.py +235 -0
  85. dashboard_report/workbook.py +1087 -0
  86. dashboard_server/__init__.py +187 -0
  87. dashboard_server/access.py +201 -0
  88. dashboard_server/documents.py +373 -0
  89. dashboard_server/errors.py +104 -0
  90. dashboard_server/memory.py +660 -0
  91. dashboard_server/ports.py +264 -0
  92. dashboard_server/principals.py +93 -0
  93. dashboard_server/py.typed +0 -0
  94. dashboard_server/records.py +193 -0
  95. dashboard_server/service.py +1441 -0
  96. dashboard_spec/__init__.py +72 -0
  97. dashboard_spec/diagnostics.py +22 -0
  98. dashboard_spec/migrations.py +59 -0
  99. dashboard_spec/model_schema.py +44 -0
  100. dashboard_spec/models.py +988 -0
  101. dashboard_spec/parsing.py +185 -0
  102. dashboard_spec/py.typed +0 -0
  103. dashboard_spec/round_trip.py +37 -0
  104. dashboard_spec/schema.py +60 -0
  105. dashboard_spec/schema_aliases.py +43 -0
  106. dashboard_spec/slugs.py +43 -0
  107. dashboard_spec/widget_schema.py +570 -0
  108. dashboard_store/__init__.py +133 -0
  109. dashboard_store/authoring/__init__.py +1 -0
  110. dashboard_store/authoring/history.py +314 -0
  111. dashboard_store/binding.py +217 -0
  112. dashboard_store/config.py +404 -0
  113. dashboard_store/creation.py +145 -0
  114. dashboard_store/diagnostics.py +318 -0
  115. dashboard_store/inspection.py +599 -0
  116. dashboard_store/manifest.py +416 -0
  117. dashboard_store/model_repository.py +226 -0
  118. dashboard_store/py.typed +0 -0
  119. dashboard_store/repository.py +585 -0
  120. dashboard_store/rows.py +453 -0
  121. dashboard_store/schedule_repository.py +310 -0
  122. dashboard_store/share_repository.py +200 -0
bideux/__init__.py ADDED
@@ -0,0 +1,157 @@
1
+ """Bideux dashboards in one install.
2
+
3
+ This distribution carries ``dashboard_spec``, ``dashboard_core``,
4
+ ``dashboard_engine``, ``dashboard_store``, ``dashboard_server``,
5
+ ``dashboard_report`` and ``dashboard_authoring`` under their own names, so
6
+ every import that works against the granular ``bideux-dashboard-*`` wheels
7
+ works unchanged here. This module is the short path: the objects a host
8
+ touches when it wires a dashboard service, runs the engine, or renders a
9
+ report, in one namespace.
10
+
11
+ Scheduling is not here on purpose. A host owns its schedules, cron and
12
+ delivery and calls :class:`DashboardReportService` for the bytes.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dashboard_authoring import DashboardAuthoringService
18
+ from dashboard_core import slugify
19
+ from dashboard_engine import (
20
+ DashboardEngine,
21
+ DashboardEngineError,
22
+ EngineOptions,
23
+ ScopeContext,
24
+ WarehousePort,
25
+ )
26
+ from dashboard_report import (
27
+ BrandingPort,
28
+ BuiltReport,
29
+ CapturePort,
30
+ DashboardLinkPort,
31
+ DashboardReportPort,
32
+ DashboardReportRequest,
33
+ DashboardReportService,
34
+ ReportBranding,
35
+ ReportFormat,
36
+ )
37
+ from dashboard_server import (
38
+ AccessPolicy,
39
+ AuditEvent,
40
+ AuditPort,
41
+ Caller,
42
+ DashboardAccessError,
43
+ DashboardLayoutConflictError,
44
+ DashboardListing,
45
+ DashboardNotFoundError,
46
+ DashboardPermission,
47
+ DashboardRef,
48
+ DashboardRender,
49
+ DashboardRepositoryPort,
50
+ DashboardService,
51
+ DashboardServiceError,
52
+ DefaultAccessPolicy,
53
+ EngineFactoryPort,
54
+ InvalidDocumentError,
55
+ ModelRepositoryPort,
56
+ Principal,
57
+ PrincipalDirectoryPort,
58
+ RenderCachePort,
59
+ Repositories,
60
+ ServiceOptions,
61
+ ShareRepositoryPort,
62
+ SharingDisabledError,
63
+ SlugTakenError,
64
+ SlugUnavailableError,
65
+ UnitOfWorkPort,
66
+ WidgetNotFoundError,
67
+ WidgetRepositoryPort,
68
+ )
69
+ from dashboard_spec import (
70
+ DashboardSpec,
71
+ SemanticModel,
72
+ WidgetSpec,
73
+ parse_dashboard_yaml,
74
+ parse_semantic_model_yaml,
75
+ parse_widget_yaml,
76
+ )
77
+ from dashboard_store import (
78
+ DashboardModelRepository,
79
+ ModelRepository,
80
+ ShareRepository,
81
+ WidgetRepository,
82
+ )
83
+
84
+ from bideux._guard import (
85
+ GRANULAR_DISTRIBUTIONS,
86
+ BideuxInstallConflict,
87
+ installed_granular_distributions,
88
+ warn_on_conflict,
89
+ )
90
+
91
+ __version__ = "0.1.2"
92
+
93
+ warn_on_conflict()
94
+
95
+ __all__ = [
96
+ "GRANULAR_DISTRIBUTIONS",
97
+ "AccessPolicy",
98
+ "AuditEvent",
99
+ "AuditPort",
100
+ "BideuxInstallConflict",
101
+ "BrandingPort",
102
+ "BuiltReport",
103
+ "Caller",
104
+ "CapturePort",
105
+ "DashboardAccessError",
106
+ "DashboardAuthoringService",
107
+ "DashboardEngine",
108
+ "DashboardEngineError",
109
+ "DashboardLayoutConflictError",
110
+ "DashboardLinkPort",
111
+ "DashboardListing",
112
+ "DashboardModelRepository",
113
+ "DashboardNotFoundError",
114
+ "DashboardPermission",
115
+ "DashboardRef",
116
+ "DashboardRender",
117
+ "DashboardReportPort",
118
+ "DashboardReportRequest",
119
+ "DashboardReportService",
120
+ "DashboardRepositoryPort",
121
+ "DashboardService",
122
+ "DashboardServiceError",
123
+ "DashboardSpec",
124
+ "DefaultAccessPolicy",
125
+ "EngineFactoryPort",
126
+ "EngineOptions",
127
+ "InvalidDocumentError",
128
+ "ModelRepository",
129
+ "ModelRepositoryPort",
130
+ "Principal",
131
+ "PrincipalDirectoryPort",
132
+ "RenderCachePort",
133
+ "ReportBranding",
134
+ "ReportFormat",
135
+ "Repositories",
136
+ "ScopeContext",
137
+ "SemanticModel",
138
+ "ServiceOptions",
139
+ "ShareRepository",
140
+ "ShareRepositoryPort",
141
+ "SharingDisabledError",
142
+ "SlugTakenError",
143
+ "SlugUnavailableError",
144
+ "UnitOfWorkPort",
145
+ "WarehousePort",
146
+ "WidgetNotFoundError",
147
+ "WidgetRepository",
148
+ "WidgetRepositoryPort",
149
+ "WidgetSpec",
150
+ "__version__",
151
+ "installed_granular_distributions",
152
+ "parse_dashboard_yaml",
153
+ "parse_semantic_model_yaml",
154
+ "parse_widget_yaml",
155
+ "slugify",
156
+ "warn_on_conflict",
157
+ ]
bideux/_guard.py ADDED
@@ -0,0 +1,62 @@
1
+ """Refuse to run next to the granular distributions this wheel already contains.
2
+
3
+ ``bideux`` and the ``bideux-dashboard-*`` wheels install the same import
4
+ packages (``dashboard_spec``, ``dashboard_server`` and the rest), so having
5
+ both means whichever was installed last silently wins. Python metadata has no
6
+ conflicts field, so the check is done here, once, at import.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import warnings
12
+ from importlib import metadata
13
+
14
+ GRANULAR_DISTRIBUTIONS: tuple[str, ...] = (
15
+ "bideux-dashboard-spec",
16
+ "bideux-dashboard-core",
17
+ "bideux-dashboard-engine",
18
+ "bideux-dashboard-store",
19
+ "bideux-dashboard-server",
20
+ "bideux-dashboard-report",
21
+ "bideux-dashboard-authoring",
22
+ "bideux-dashboard-schedule",
23
+ )
24
+
25
+
26
+ class BideuxInstallConflict(RuntimeWarning):
27
+ """Both the bundle and one or more granular distributions are installed."""
28
+
29
+
30
+ def installed_granular_distributions() -> tuple[str, ...]:
31
+ """Return the ``bideux-dashboard-*`` distributions present in this environment."""
32
+ found: list[str] = []
33
+ for name in GRANULAR_DISTRIBUTIONS:
34
+ try:
35
+ metadata.version(name)
36
+ except metadata.PackageNotFoundError:
37
+ continue
38
+ found.append(name)
39
+ return tuple(found)
40
+
41
+
42
+ def warn_on_conflict() -> tuple[str, ...]:
43
+ """Warn once when granular distributions share the environment with ``bideux``."""
44
+ found = installed_granular_distributions()
45
+ if found:
46
+ warnings.warn(
47
+ "bideux is installed alongside "
48
+ + ", ".join(found)
49
+ + ". Both provide the same dashboard_* modules; uninstall one family. "
50
+ "Hosts pick either `bideux` or the `bideux-dashboard-*` packages, never both.",
51
+ BideuxInstallConflict,
52
+ stacklevel=3,
53
+ )
54
+ return found
55
+
56
+
57
+ __all__ = [
58
+ "GRANULAR_DISTRIBUTIONS",
59
+ "BideuxInstallConflict",
60
+ "installed_granular_distributions",
61
+ "warn_on_conflict",
62
+ ]
bideux/authoring.py ADDED
@@ -0,0 +1,12 @@
1
+ """AI assisted authoring of models, widgets and dashboards over host provided ports.
2
+
3
+ Every public name of ``dashboard_authoring`` re-exported under the ``bideux`` namespace, so a
4
+ host that installs the single ``bideux`` wheel never imports a granular module name.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dashboard_authoring import * # noqa: F403
10
+ from dashboard_authoring import __all__ as _base
11
+
12
+ __all__ = [*_base]
bideux/cli.py ADDED
@@ -0,0 +1,118 @@
1
+ """The ``bideux`` command: ``demo``, ``init`` and ``doctor``.
2
+
3
+ ``demo`` and ``init`` need the ``fastapi`` extra (and ``demo`` the ``demo``
4
+ extra for uvicorn); ``doctor`` needs nothing beyond the base install, which is
5
+ why the demo module is loaded only when that subcommand runs.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+ from importlib import import_module
13
+ from importlib.util import find_spec
14
+ from pathlib import Path
15
+
16
+ from bideux import doctor, init
17
+ from bideux.corpus import find_fixtures
18
+
19
+ DEMO_PORT = 5477
20
+
21
+
22
+ def _demo(args: argparse.Namespace) -> int:
23
+ missing = [name for name in ("fastapi", "aiosqlite", "uvicorn") if find_spec(name) is None]
24
+ if missing:
25
+ print(
26
+ "bideux demo needs " + ", ".join(missing) + ": uv add 'bideux[demo]'",
27
+ file=sys.stderr,
28
+ )
29
+ return 2
30
+ fixtures = Path(args.fixtures) if args.fixtures else find_fixtures(Path.cwd())
31
+ if fixtures is None:
32
+ print(
33
+ "no conformance/fixtures directory at or above the working directory; "
34
+ "pass --fixtures PATH (the corpus lives in the bideux repository)",
35
+ file=sys.stderr,
36
+ )
37
+ return 2
38
+ element_dir = Path(args.element_dir) if args.element_dir else None
39
+ demo = import_module("bideux.demo")
40
+ uvicorn = import_module("uvicorn")
41
+ app = demo.create_demo_app(
42
+ fixtures=fixtures,
43
+ database_url=args.database_url,
44
+ prefix=args.prefix,
45
+ element_dir=element_dir,
46
+ )
47
+ print(f"Bideux demo at http://{args.host}:{args.port}/ (API under {args.prefix})")
48
+ uvicorn.run(app, host=args.host, port=args.port, log_level="info")
49
+ return 0
50
+
51
+
52
+ def _init(args: argparse.Namespace) -> int:
53
+ for written in init.scaffold(
54
+ Path(args.target), prefix=args.prefix, port=args.port, force=args.force
55
+ ):
56
+ verb = "wrote" if written.created else "kept"
57
+ print(f"{verb} {written.path}")
58
+ print(
59
+ "next: uv add 'bideux[fastapi,sqlite]' uvicorn, fill in MyHost, then "
60
+ f"uvicorn bideux_host:app --port {args.port} and open dashboard.html"
61
+ )
62
+ return 0
63
+
64
+
65
+ def _doctor(args: argparse.Namespace) -> int:
66
+ findings = doctor.run(Path(args.project))
67
+ for finding in findings:
68
+ print(finding.line())
69
+ return 1 if doctor.worst(findings) == "fail" else 0
70
+
71
+
72
+ def build_parser() -> argparse.ArgumentParser:
73
+ parser = argparse.ArgumentParser(prog="bideux", description="Bideux dashboards in one install.")
74
+ commands = parser.add_subparsers(dest="command", required=True)
75
+
76
+ demo = commands.add_parser("demo", help="run a seeded dashboard API and page on one port")
77
+ demo.add_argument("--host", default="127.0.0.1")
78
+ demo.add_argument("--port", type=int, default=DEMO_PORT)
79
+ demo.add_argument("--prefix", default="/api/dashboards")
80
+ demo.add_argument(
81
+ "--database-url",
82
+ default="sqlite+aiosqlite:///bideux-demo.sqlite3",
83
+ help="where the store lives; delete the file to reseed",
84
+ )
85
+ demo.add_argument(
86
+ "--fixtures", help="path to conformance/fixtures; found automatically in the repo"
87
+ )
88
+ demo.add_argument(
89
+ "--element-dir",
90
+ help="a built packages/bideux/dist to serve instead of loading @dlh.io/bideux from unpkg",
91
+ )
92
+ demo.set_defaults(run=_demo)
93
+
94
+ scaffold = commands.add_parser(
95
+ "init", help="write bideux_host.py and dashboard.html to start from"
96
+ )
97
+ scaffold.add_argument("target", nargs="?", default=".")
98
+ scaffold.add_argument("--prefix", default="/api/dashboards")
99
+ scaffold.add_argument("--port", type=int, default=DEMO_PORT)
100
+ scaffold.add_argument("--force", action="store_true", help="overwrite files that exist")
101
+ scaffold.set_defaults(run=_init)
102
+
103
+ check = commands.add_parser(
104
+ "doctor", help="check the Python and Node install for known problems"
105
+ )
106
+ check.add_argument("project", nargs="?", default=".", help="directory holding package.json")
107
+ check.set_defaults(run=_doctor)
108
+ return parser
109
+
110
+
111
+ def main(argv: list[str] | None = None) -> int:
112
+ args = build_parser().parse_args(argv)
113
+ result = args.run(args)
114
+ return result if isinstance(result, int) else 0
115
+
116
+
117
+ if __name__ == "__main__":
118
+ raise SystemExit(main())
bideux/core.py ADDED
@@ -0,0 +1,12 @@
1
+ """Headless dashboard logic: layout, filters, pivots and tokens.
2
+
3
+ Every public name of ``dashboard_core`` re-exported under the ``bideux`` namespace, so a
4
+ host that installs the single ``bideux`` wheel never imports a granular module name.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dashboard_core import * # noqa: F403
10
+ from dashboard_core import __all__ as _base
11
+
12
+ __all__ = [*_base]
bideux/corpus.py ADDED
@@ -0,0 +1,19 @@
1
+ """Where the conformance corpus is, for the commands that read it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ FIXTURE_DIRECTORY = Path("conformance") / "fixtures"
8
+
9
+
10
+ def find_fixtures(start: Path) -> Path | None:
11
+ """The corpus directory at or above ``start``, when running inside the repo."""
12
+ for candidate in (start, *start.parents):
13
+ found = candidate / FIXTURE_DIRECTORY
14
+ if (found / "dashboards").is_dir():
15
+ return found
16
+ return None
17
+
18
+
19
+ __all__ = ["FIXTURE_DIRECTORY", "find_fixtures"]
bideux/demo.py ADDED
@@ -0,0 +1,200 @@
1
+ """``bideux demo``: a working dashboard API and page in one command.
2
+
3
+ The demo is :func:`bideux.fastapi.mount_bideux` over a sqlite file with a
4
+ :class:`HostAdapter` whose warehouse is the in-memory reference warehouse
5
+ evaluating the corpus rows, seeded with the ``retail_overview`` dashboard from
6
+ ``conformance/fixtures``. The page at ``/`` is the custom element from
7
+ ``@dlh.io/bideux/element``, loaded from a CDN or from a local ``dist``
8
+ directory. Nothing in it is meant for production; it shows the shape a host
9
+ copies with ``bideux init``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from collections.abc import AsyncIterator, Mapping, Sequence
16
+ from contextlib import asynccontextmanager
17
+ from dataclasses import dataclass
18
+ from importlib.metadata import PackageNotFoundError, version
19
+ from pathlib import Path
20
+
21
+ from dashboard_engine import ResolvedLocations, WarehousePort
22
+ from dashboard_engine.reference import ReferenceWarehouse, reference_tables
23
+ from dashboard_engine.results import DataStoreDescriptor, Row
24
+ from dashboard_server import Caller
25
+ from fastapi import FastAPI, Request
26
+ from fastapi.responses import HTMLResponse
27
+ from fastapi.staticfiles import StaticFiles
28
+
29
+ from bideux.corpus import find_fixtures
30
+ from bideux.fastapi.mount import BideuxMount, mount_bideux
31
+ from bideux.hosting import HostAdapter
32
+ from bideux.quickstart import local_caller
33
+
34
+ DISPLAY_NAMES = {"10001": "Downtown", "10002": "Riverside", "10003": "Airport"}
35
+ DEMO_SLUG = "retail-overview"
36
+
37
+
38
+ def load_tables(fixtures: Path) -> dict[str, tuple[Row, ...]]:
39
+ payload = json.loads((fixtures / "data" / "warehouse_rows.json").read_text())
40
+ tables: Mapping[str, Mapping[str, Sequence[Row]]] = payload["tables"]
41
+ rows: dict[str, Sequence[Row]] = {
42
+ name: [dict(row) for row in table["rows"]] for name, table in tables.items()
43
+ }
44
+ return reference_tables(rows)
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class DemoDocuments:
49
+ model_yaml: str
50
+ dashboard_yaml: str
51
+ widget_yamls: dict[str, str]
52
+
53
+
54
+ def load_documents(fixtures: Path) -> DemoDocuments:
55
+ widgets = {
56
+ path.name.removesuffix(".widget.yaml"): path.read_text()
57
+ for path in sorted((fixtures / "widgets").glob("*.widget.yaml"))
58
+ }
59
+ return DemoDocuments(
60
+ model_yaml=(fixtures / "model" / "retail.model.yaml").read_text(),
61
+ dashboard_yaml=(fixtures / "dashboards" / "retail_overview.dashboard.yaml").read_text(),
62
+ widget_yamls=widgets,
63
+ )
64
+
65
+
66
+ class DemoHost(HostAdapter):
67
+ """Everyone is ``?person=`` (default ``demo``), an admin; rows come from the corpus."""
68
+
69
+ model_slug = "retail"
70
+
71
+ def __init__(self, fixtures: Path) -> None:
72
+ self._warehouse = ReferenceWarehouse(
73
+ tables=load_tables(fixtures),
74
+ descriptor=DataStoreDescriptor("reference", "snowflake", "MART"),
75
+ locations=ResolvedLocations(None, None),
76
+ display_names=DISPLAY_NAMES,
77
+ )
78
+
79
+ async def caller(self, request: Request) -> Caller:
80
+ return local_caller(request.query_params.get("person", "demo"))
81
+
82
+ def warehouse(self, caller: Caller) -> WarehousePort:
83
+ del caller
84
+ return self._warehouse
85
+
86
+
87
+ def element_script_url(element_dir: Path | None) -> str:
88
+ if element_dir is not None:
89
+ return "/element/element/element.js"
90
+ try:
91
+ release = version("bideux")
92
+ except PackageNotFoundError:
93
+ release = "latest"
94
+ return f"https://unpkg.com/@dlh.io/bideux@{release}/dist/element/element.js"
95
+
96
+
97
+ def stylesheet_urls(element_dir: Path | None) -> tuple[str, str]:
98
+ if element_dir is not None:
99
+ return ("/element/styles.css", "/element/theme.css")
100
+ base = element_script_url(None).removesuffix("/element/element.js")
101
+ return (f"{base}/styles.css", f"{base}/theme.css")
102
+
103
+
104
+ def demo_page(*, prefix: str, element_dir: Path | None) -> str:
105
+ styles, theme = stylesheet_urls(element_dir)
106
+ script = element_script_url(element_dir)
107
+ return f"""<!doctype html>
108
+ <html lang="en">
109
+ <head>
110
+ <meta charset="UTF-8" />
111
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
112
+ <title>Bideux demo</title>
113
+ <link rel="stylesheet" href="{styles}" />
114
+ <link rel="stylesheet" href="{theme}" />
115
+ <script type="module" src="{script}"></script>
116
+ <style>
117
+ body {{ margin: 0; font-family: system-ui, sans-serif; }}
118
+ </style>
119
+ </head>
120
+ <body>
121
+ <bideux-dashboard-list id="list" base-url="{prefix}"></bideux-dashboard-list>
122
+ <bideux-dashboard id="page" base-url="{prefix}" can-edit-model hidden></bideux-dashboard>
123
+ <script type="module">
124
+ const list = document.getElementById("list");
125
+ const page = document.getElementById("page");
126
+ const open = (dashboardId, editing) => {{
127
+ page.setAttribute("dashboard-id", dashboardId);
128
+ page.toggleAttribute("start-in-edit-mode", editing);
129
+ page.hidden = false;
130
+ list.hidden = true;
131
+ }};
132
+ list.addEventListener("open", (event) =>
133
+ open(event.detail.dashboardId, event.detail.editing),
134
+ );
135
+ page.addEventListener("opendashboard", (event) => open(event.detail.dashboardId, false));
136
+ page.addEventListener("back", () => {{
137
+ page.hidden = true;
138
+ list.hidden = false;
139
+ }});
140
+ const wanted = new URL(location.href).searchParams.get("dashboard");
141
+ if (wanted) open(wanted, false);
142
+ </script>
143
+ </body>
144
+ </html>
145
+ """
146
+
147
+
148
+ async def seed(mount: BideuxMount, documents: DemoDocuments) -> None:
149
+ """Write the corpus model and dashboard once; a second start finds them and stops."""
150
+ async with mount.service(local_caller("demo")) as service:
151
+ if await service.dashboard_exists(DEMO_SLUG):
152
+ return
153
+ await service.update_global_model(documents.model_yaml, note="bideux demo seed")
154
+ await service.upsert_from_spec(
155
+ documents.dashboard_yaml,
156
+ documents.widget_yamls,
157
+ visibility="org",
158
+ )
159
+
160
+
161
+ def create_demo_app(
162
+ *,
163
+ fixtures: Path,
164
+ database_url: str,
165
+ prefix: str = "/api/dashboards",
166
+ element_dir: Path | None = None,
167
+ ) -> FastAPI:
168
+ """The demo app: routes at ``prefix``, the page at ``/``, seeded on start."""
169
+ documents = load_documents(fixtures)
170
+
171
+ @asynccontextmanager
172
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
173
+ del app
174
+ await seed(mount, documents)
175
+ yield
176
+
177
+ app = FastAPI(title="Bideux demo", lifespan=lifespan)
178
+ mount = mount_bideux(app, host=DemoHost(fixtures), database_url=database_url, prefix=prefix)
179
+ if element_dir is not None:
180
+ app.mount("/element", StaticFiles(directory=element_dir), name="element")
181
+
182
+ page = demo_page(prefix=prefix, element_dir=element_dir)
183
+
184
+ @app.get("/", response_class=HTMLResponse, include_in_schema=False)
185
+ async def index() -> str:
186
+ return page
187
+
188
+ return app
189
+
190
+
191
+ __all__ = [
192
+ "DemoDocuments",
193
+ "DemoHost",
194
+ "create_demo_app",
195
+ "demo_page",
196
+ "find_fixtures",
197
+ "load_documents",
198
+ "load_tables",
199
+ "seed",
200
+ ]