grimoireplot 0.0.1__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.
grimoireplot/main.py ADDED
@@ -0,0 +1,284 @@
1
+ # SPDX-FileCopyrightText: Copyright © 2026 Idiap Research Institute <contact@idiap.ch>
2
+ # SPDX-FileContributor: William Droz <william.droz@idiap.ch>
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """
6
+ Main CLI for GrimoirePlot application.
7
+ """
8
+
9
+ import argparse
10
+ from grimoireplot.common import get_grimoire_secret, get_grimoire_server
11
+
12
+
13
+ def serve_command(args):
14
+ """Run the GrimoirePlot server."""
15
+ from grimoireplot.server import my_app
16
+
17
+ my_app(host=args.host, port=args.port)
18
+
19
+
20
+ def live_test_command(args):
21
+ """Run a live test that adds one datapoint every 0.2 seconds to two plots."""
22
+ import time
23
+ import math
24
+ import plotly.graph_objects as go
25
+ from grimoireplot.client import push_plot_sync
26
+
27
+ server_url = f"http://{args.host}:{args.port}"
28
+ grimoire_name = args.grimoire_name
29
+ chapter_name = "Live Test"
30
+
31
+ print(f"Starting live test to {server_url}")
32
+ print(f"Grimoire: {grimoire_name}")
33
+ print(f"Adding one datapoint every {args.interval} seconds to 2 plots")
34
+ print("Press Ctrl+C to stop")
35
+ print("-" * 40)
36
+
37
+ # Data for plot 1 (sine wave)
38
+ x_data_1 = []
39
+ y_data_1 = []
40
+ # Data for plot 2 (cosine wave)
41
+ x_data_2 = []
42
+ y_data_2 = []
43
+ step = 0
44
+
45
+ try:
46
+ while args.max_points == 0 or step < args.max_points:
47
+ # Generate new datapoints
48
+ x_data_1.append(step)
49
+ y_data_1.append(math.sin(step * 0.1) + (step * 0.01))
50
+ x_data_2.append(step)
51
+ y_data_2.append(math.cos(step * 0.1) + (step * 0.01))
52
+
53
+ # Create figure 1 (sine wave)
54
+ fig1 = go.Figure()
55
+ fig1.add_trace(
56
+ go.Scatter(
57
+ x=x_data_1,
58
+ y=y_data_1,
59
+ mode="lines+markers",
60
+ name="Sine Wave",
61
+ line=dict(color="blue"),
62
+ marker=dict(size=6),
63
+ )
64
+ )
65
+ fig1.update_layout(
66
+ title=f"Sine Wave (Point {step + 1})",
67
+ xaxis_title="Step",
68
+ yaxis_title="Value",
69
+ )
70
+
71
+ # Create figure 2 (cosine wave)
72
+ fig2 = go.Figure()
73
+ fig2.add_trace(
74
+ go.Scatter(
75
+ x=x_data_2,
76
+ y=y_data_2,
77
+ mode="lines+markers",
78
+ name="Cosine Wave",
79
+ line=dict(color="red"),
80
+ marker=dict(size=6),
81
+ )
82
+ )
83
+ fig2.update_layout(
84
+ title=f"Cosine Wave (Point {step + 1})",
85
+ xaxis_title="Step",
86
+ yaxis_title="Value",
87
+ )
88
+
89
+ # Push both plots to server
90
+ try:
91
+ push_plot_sync(
92
+ grimoire_name=grimoire_name,
93
+ chapter_name=chapter_name,
94
+ plot_name="Sine Wave",
95
+ fig=fig1,
96
+ grimoire_secret=args.secret,
97
+ grimoire_server=server_url,
98
+ )
99
+ push_plot_sync(
100
+ grimoire_name=grimoire_name,
101
+ chapter_name=chapter_name,
102
+ plot_name="Cosine Wave",
103
+ fig=fig2,
104
+ grimoire_secret=args.secret,
105
+ grimoire_server=server_url,
106
+ )
107
+ print(
108
+ f" Point {step + 1}: sin={y_data_1[-1]:.4f}, cos={y_data_2[-1]:.4f}"
109
+ )
110
+ except Exception as e:
111
+ print(f" [ERROR] Failed to push point {step + 1}: {e}")
112
+
113
+ step += 1
114
+ time.sleep(args.interval)
115
+
116
+ except KeyboardInterrupt:
117
+ print("\n" + "-" * 40)
118
+ print(f"Stopped after {step} points")
119
+
120
+ print("Done!")
121
+
122
+
123
+ def push_samples_command(args):
124
+ """Push sample plots to test the server."""
125
+ from grimoireplot.client import push_plot_sync
126
+ from grimoireplot.create_some_plots import (
127
+ create_sample_line_plot,
128
+ create_sample_bar_plot,
129
+ create_sample_scatter_plot,
130
+ create_sample_histogram,
131
+ create_sample_pie_chart,
132
+ create_sample_box_plot,
133
+ create_sample_heatmap,
134
+ create_sample_area_plot,
135
+ )
136
+
137
+ server_url = f"http://{args.host}:{args.port}"
138
+ grimoire_name = args.grimoire_name
139
+
140
+ samples = [
141
+ ("Basic Plots", "Line Plot", create_sample_line_plot),
142
+ ("Basic Plots", "Bar Plot", create_sample_bar_plot),
143
+ ("Basic Plots", "Area Plot", create_sample_area_plot),
144
+ ("Distributions", "Scatter Plot", create_sample_scatter_plot),
145
+ ("Distributions", "Histogram", create_sample_histogram),
146
+ ("Distributions", "Box Plot", create_sample_box_plot),
147
+ ("Categories", "Pie Chart", create_sample_pie_chart),
148
+ ("Categories", "Heatmap", create_sample_heatmap),
149
+ ]
150
+
151
+ print(f"Pushing {len(samples)} sample plots to {server_url}")
152
+ print(f"Grimoire: {grimoire_name}")
153
+ print("-" * 40)
154
+
155
+ for chapter, plot_name, create_func in samples:
156
+ try:
157
+ fig = create_func()
158
+ _ = push_plot_sync(
159
+ grimoire_name=grimoire_name,
160
+ chapter_name=chapter,
161
+ plot_name=plot_name,
162
+ fig=fig,
163
+ grimoire_secret=args.secret,
164
+ grimoire_server=server_url,
165
+ )
166
+ print(f" [OK] {chapter}/{plot_name}")
167
+ except Exception as e:
168
+ print(f" [FAIL] {chapter}/{plot_name}: {e}")
169
+
170
+ print("-" * 40)
171
+ print("Done!")
172
+
173
+
174
+ def main():
175
+ default_server = get_grimoire_server()
176
+ default_host = default_server.split("://")[-1].split(":")[0]
177
+ default_port = int(default_server.split(":")[-1])
178
+ default_secret = get_grimoire_secret()
179
+
180
+ parser = argparse.ArgumentParser(
181
+ prog="grimoireplot",
182
+ description="GrimoirePlot - Live dashboard for Plotly-compatible plots",
183
+ )
184
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
185
+
186
+ # Serve command
187
+ serve_parser = subparsers.add_parser("serve", help="Start the GrimoirePlot server")
188
+ serve_parser.add_argument(
189
+ "--host",
190
+ type=str,
191
+ default=default_host,
192
+ help=f"Host to bind the server (default: {default_host})",
193
+ )
194
+ serve_parser.add_argument(
195
+ "--port",
196
+ type=int,
197
+ default=default_port,
198
+ help=f"Port to bind the server (default: {default_port})",
199
+ )
200
+ serve_parser.set_defaults(func=serve_command)
201
+
202
+ # Push samples command
203
+ push_parser = subparsers.add_parser(
204
+ "push-samples", help="Push sample plots to test the server"
205
+ )
206
+ push_parser.add_argument(
207
+ "--host",
208
+ type=str,
209
+ default=default_host,
210
+ help=f"Server host (default: {default_host})",
211
+ )
212
+ push_parser.add_argument(
213
+ "--port",
214
+ type=int,
215
+ default=default_port,
216
+ help=f"Server port (default: {default_port})",
217
+ )
218
+ push_parser.add_argument(
219
+ "--secret",
220
+ type=str,
221
+ default=default_secret,
222
+ help="Grimoire secret for authentication",
223
+ )
224
+ push_parser.add_argument(
225
+ "--grimoire-name",
226
+ type=str,
227
+ default="test_grimoire",
228
+ help="Name of the grimoire to create (default: test_grimoire)",
229
+ )
230
+ push_parser.set_defaults(func=push_samples_command)
231
+
232
+ # Live test command
233
+ live_parser = subparsers.add_parser(
234
+ "live-test", help="Test live plot updates by adding datapoints over time"
235
+ )
236
+ live_parser.add_argument(
237
+ "--host",
238
+ type=str,
239
+ default=default_host,
240
+ help=f"Server host (default: {default_host})",
241
+ )
242
+ live_parser.add_argument(
243
+ "--port",
244
+ type=int,
245
+ default=default_port,
246
+ help=f"Server port (default: {default_port})",
247
+ )
248
+ live_parser.add_argument(
249
+ "--secret",
250
+ type=str,
251
+ default=default_secret,
252
+ help="Grimoire secret for authentication",
253
+ )
254
+ live_parser.add_argument(
255
+ "--grimoire-name",
256
+ type=str,
257
+ default="live_test",
258
+ help="Name of the grimoire to create (default: live_test)",
259
+ )
260
+ live_parser.add_argument(
261
+ "--interval",
262
+ type=float,
263
+ default=0.2,
264
+ help="Interval between datapoints in seconds (default: 0.2)",
265
+ )
266
+ live_parser.add_argument(
267
+ "--max-points",
268
+ type=int,
269
+ default=0,
270
+ help="Maximum number of points to add (0 = unlimited, default: 0)",
271
+ )
272
+ live_parser.set_defaults(func=live_test_command)
273
+
274
+ args = parser.parse_args()
275
+
276
+ if args.command is None:
277
+ parser.print_help()
278
+ return
279
+
280
+ args.func(args)
281
+
282
+
283
+ if __name__ in {"__main__", "__mp_main__"}:
284
+ main()
grimoireplot/models.py ADDED
@@ -0,0 +1,210 @@
1
+ # SPDX-FileCopyrightText: Copyright © 2026 Idiap Research Institute <contact@idiap.ch>
2
+ # SPDX-FileContributor: William Droz <william.droz@idiap.ch>
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ import os
6
+ from typing import Optional
7
+ from datetime import datetime
8
+ from sqlmodel import Field, Relationship, SQLModel, create_engine, Session
9
+ from sqlalchemy import ForeignKeyConstraint
10
+ from dotenv import load_dotenv
11
+
12
+ load_dotenv()
13
+
14
+
15
+ class AddPlotRequest(SQLModel):
16
+ grimoire_name: str
17
+ chapter_name: str
18
+ plot_name: str
19
+ json_data: str
20
+
21
+
22
+ class Grimoire(SQLModel, table=True):
23
+ name: str = Field(primary_key=True)
24
+
25
+ chapters: list["Chapter"] = Relationship(
26
+ back_populates="grimoire", cascade_delete=True
27
+ )
28
+
29
+
30
+ class Chapter(SQLModel, table=True):
31
+ name: str = Field(primary_key=True)
32
+ grimoire_name: str = Field(primary_key=True, foreign_key="grimoire.name")
33
+
34
+ grimoire: Grimoire = Relationship(back_populates="chapters")
35
+
36
+ plots: list["Plot"] = Relationship(back_populates="chapter", cascade_delete=True)
37
+
38
+
39
+ class Plot(SQLModel, table=True):
40
+ __table_args__ = (
41
+ ForeignKeyConstraint(
42
+ ["chapter_name", "grimoire_name"], ["chapter.name", "chapter.grimoire_name"]
43
+ ),
44
+ )
45
+
46
+ name: str = Field(primary_key=True)
47
+ chapter_name: str = Field(primary_key=True)
48
+ grimoire_name: str = Field(primary_key=True)
49
+ json_data: str
50
+ created_at: datetime = Field(default_factory=datetime.now)
51
+
52
+ chapter: Chapter = Relationship(back_populates="plots")
53
+
54
+
55
+ def get_engine():
56
+ if os.getenv("GRIMOIRE_TEST", "").lower() in ("1", "true", "yes"):
57
+ sqlite_file_name = "database-deleteme.db"
58
+ grimoire_db = f"sqlite:///{sqlite_file_name}"
59
+ elif (grimoire_db := os.getenv("GRIMOIRE_DB")) is None:
60
+ sqlite_file_name = "database.db"
61
+ grimoire_db = f"sqlite:///{sqlite_file_name}"
62
+ return create_engine(grimoire_db)
63
+
64
+
65
+ def create_db_and_tables():
66
+ engine = get_engine()
67
+ SQLModel.metadata.create_all(engine)
68
+
69
+
70
+ def get_all_grimoires() -> list[Grimoire]:
71
+ """Get all grimoires with their chapters and plots."""
72
+ engine = get_engine()
73
+ with Session(engine) as session:
74
+ from sqlmodel import select
75
+
76
+ statement = select(Grimoire)
77
+ grimoires = session.exec(statement).all()
78
+ # Ensure relationships are loaded
79
+ result = []
80
+ for grimoire in grimoires:
81
+ # Access chapters to load them
82
+ _ = grimoire.chapters
83
+ for chapter in grimoire.chapters:
84
+ # Access plots to load them
85
+ _ = chapter.plots
86
+ result.append(grimoire)
87
+ return result
88
+
89
+
90
+ def get_grimoire_with_data(grimoire_name: str) -> Optional[Grimoire]:
91
+ """Get a specific grimoire with all its chapters and plots."""
92
+ engine = get_engine()
93
+ with Session(engine) as session:
94
+ grimoire = session.get(Grimoire, grimoire_name)
95
+ if grimoire:
96
+ # Load relationships
97
+ _ = grimoire.chapters
98
+ for chapter in grimoire.chapters:
99
+ _ = chapter.plots
100
+ return grimoire
101
+
102
+
103
+ def add_plot(
104
+ grimoire_name: str, chapter_name: str, plot_name: str, json_data: str
105
+ ) -> Plot:
106
+ """Add a plot to a grimoire/chapter, creating them if they don't exist.
107
+
108
+ If the grimoire exists, use it, else create it.
109
+ If the chapter exists, use it, else create it.
110
+ If the plot exists, replace it, else add it.
111
+
112
+ TODO: check if json_data is valid plotly, if not raise exception.
113
+ """
114
+
115
+ engine = get_engine()
116
+
117
+ with Session(engine) as session:
118
+ # Get or create Grimoire
119
+ grimoire = session.get(Grimoire, grimoire_name)
120
+ if grimoire is None:
121
+ grimoire = Grimoire(name=grimoire_name)
122
+ session.add(grimoire)
123
+ session.commit()
124
+ session.refresh(grimoire)
125
+
126
+ # Get or create Chapter
127
+ chapter = session.get(Chapter, (chapter_name, grimoire_name))
128
+ if chapter is None:
129
+ chapter = Chapter(name=chapter_name, grimoire_name=grimoire_name)
130
+ session.add(chapter)
131
+ session.commit()
132
+ session.refresh(chapter)
133
+
134
+ # Get or create/replace Plot
135
+ plot = session.get(Plot, (plot_name, chapter_name, grimoire_name))
136
+ if plot is None:
137
+ plot = Plot(
138
+ name=plot_name,
139
+ chapter_name=chapter_name,
140
+ grimoire_name=grimoire_name,
141
+ json_data=json_data,
142
+ )
143
+ session.add(plot)
144
+ else:
145
+ plot.json_data = json_data
146
+ session.add(plot)
147
+
148
+ session.commit()
149
+ session.refresh(plot)
150
+
151
+ return plot
152
+
153
+
154
+ def delete_plot(grimoire_name: str, chapter_name: str, plot_name: str) -> bool:
155
+ """Delete a plot by composite key. Returns True if deleted, False if not found."""
156
+ engine = get_engine()
157
+ with Session(engine) as session:
158
+ plot = session.get(Plot, (plot_name, chapter_name, grimoire_name))
159
+ if plot is None:
160
+ return False
161
+ session.delete(plot)
162
+ session.commit()
163
+ return True
164
+
165
+
166
+ def delete_chapter(grimoire_name: str, chapter_name: str) -> bool:
167
+ """Delete a chapter and all its plots. Returns True if deleted, False if not found."""
168
+ engine = get_engine()
169
+ with Session(engine) as session:
170
+ chapter = session.get(Chapter, (chapter_name, grimoire_name))
171
+ if chapter is None:
172
+ return False
173
+ session.delete(chapter)
174
+ session.commit()
175
+ return True
176
+
177
+
178
+ def delete_grimoire(grimoire_name: str) -> bool:
179
+ """Delete a grimoire and all its chapters and plots. Returns True if deleted, False if not found."""
180
+ engine = get_engine()
181
+ with Session(engine) as session:
182
+ grimoire = session.get(Grimoire, grimoire_name)
183
+ if grimoire is None:
184
+ return False
185
+ session.delete(grimoire)
186
+ session.commit()
187
+ return True
188
+
189
+
190
+ def get_chapter_with_plots(grimoire_name: str, chapter_name: str) -> Optional[Chapter]:
191
+ """Get a specific chapter with all its plots."""
192
+ engine = get_engine()
193
+ with Session(engine) as session:
194
+ chapter = session.get(Chapter, (chapter_name, grimoire_name))
195
+ if chapter:
196
+ # Load relationships
197
+ _ = chapter.plots
198
+ return chapter
199
+
200
+
201
+ def get_plots_for_chapter(grimoire_name: str, chapter_name: str) -> list[Plot]:
202
+ """Get all plots for a specific chapter."""
203
+ engine = get_engine()
204
+ with Session(engine) as session:
205
+ from sqlmodel import select
206
+
207
+ statement = select(Plot).where(
208
+ Plot.chapter_name == chapter_name, Plot.grimoire_name == grimoire_name
209
+ )
210
+ return list(session.exec(statement).all())
grimoireplot/server.py ADDED
@@ -0,0 +1,90 @@
1
+ # SPDX-FileCopyrightText: Copyright © 2026 Idiap Research Institute <contact@idiap.ch>
2
+ # SPDX-FileContributor: William Droz <william.droz@idiap.ch>
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ from fastapi import HTTPException, Request
6
+ from nicegui import app, ui
7
+ from grimoireplot.common import get_grimoire_secret
8
+ from grimoireplot.models import (
9
+ AddPlotRequest,
10
+ create_db_and_tables,
11
+ add_plot,
12
+ delete_plot,
13
+ delete_chapter,
14
+ delete_grimoire,
15
+ )
16
+
17
+ from grimoireplot.ui import dashboard_ui, refresh_chapter_plots
18
+ from grimoireplot.ui_elements import setup_theme
19
+
20
+ _GRIMOIRE_SECRET = get_grimoire_secret()
21
+
22
+
23
+ def verify_secret(request: Request):
24
+ if (secret := request.headers.get("grimoire-secret")) is None:
25
+ raise HTTPException(status_code=401, detail="grimoire-secret not found")
26
+ if secret != _GRIMOIRE_SECRET:
27
+ raise HTTPException(status_code=403, detail="invalid grimoire-secret")
28
+
29
+
30
+ def my_app(host: str = "localhost", port: int = 8080):
31
+ create_db_and_tables()
32
+
33
+ @app.post("/add_plot")
34
+ def add_plot_endpoint(add_plot_request: AddPlotRequest, request: Request):
35
+ verify_secret(request)
36
+ plot = add_plot(
37
+ grimoire_name=add_plot_request.grimoire_name,
38
+ chapter_name=add_plot_request.chapter_name,
39
+ plot_name=add_plot_request.plot_name,
40
+ json_data=add_plot_request.json_data,
41
+ )
42
+ # Try to refresh only the specific chapter's plots
43
+ # If the chapter doesn't exist in UI yet (new grimoire/chapter), refresh the whole dashboard
44
+ if not refresh_chapter_plots(
45
+ add_plot_request.grimoire_name, add_plot_request.chapter_name
46
+ ):
47
+ dashboard_ui.refresh()
48
+ return {"status": "success", "plot_name": plot.name}
49
+
50
+ @app.delete("/grimoire/{grimoire_name}/chapter/{chapter_name}/plot/{plot_name}")
51
+ def delete_plot_endpoint(
52
+ grimoire_name: str, chapter_name: str, plot_name: str, request: Request
53
+ ):
54
+ verify_secret(request)
55
+ if not delete_plot(grimoire_name, chapter_name, plot_name):
56
+ raise HTTPException(status_code=404, detail="Plot not found")
57
+ dashboard_ui.refresh()
58
+ return {"status": "success", "deleted": plot_name}
59
+
60
+ @app.delete("/grimoire/{grimoire_name}/chapter/{chapter_name}")
61
+ def delete_chapter_endpoint(
62
+ grimoire_name: str, chapter_name: str, request: Request
63
+ ):
64
+ verify_secret(request)
65
+ if not delete_chapter(grimoire_name, chapter_name):
66
+ raise HTTPException(status_code=404, detail="Chapter not found")
67
+ dashboard_ui.refresh()
68
+ return {"status": "success", "deleted": chapter_name}
69
+
70
+ @app.delete("/grimoire/{grimoire_name}")
71
+ def delete_grimoire_endpoint(grimoire_name: str, request: Request):
72
+ verify_secret(request)
73
+ if not delete_grimoire(grimoire_name):
74
+ raise HTTPException(status_code=404, detail="Grimoire not found")
75
+ dashboard_ui.refresh()
76
+ return {"status": "success", "deleted": grimoire_name}
77
+
78
+ @ui.page("/")
79
+ def page():
80
+ setup_theme()
81
+ dashboard_ui()
82
+
83
+ ui.run(
84
+ host=host,
85
+ port=port,
86
+ dark=True,
87
+ title="GrimoirePlot - Data Visualization Dashboard",
88
+ favicon="🔮",
89
+ reload=False,
90
+ )