trendify2 2.0.4__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.
- trendify/__about__.py +14 -0
- trendify/__init__.py +250 -0
- trendify/base/__init__.py +36 -0
- trendify/base/helpers.py +76 -0
- trendify/base/pen.py +161 -0
- trendify/base/record.py +148 -0
- trendify/cli.py +403 -0
- trendify/color.py +260 -0
- trendify/examples.py +474 -0
- trendify/formats/__init__.py +28 -0
- trendify/formats/format2d.py +128 -0
- trendify/formats/table.py +55 -0
- trendify/generator/__init__.py +38 -0
- trendify/generator/generate.py +167 -0
- trendify/generator/histogrammer.py +62 -0
- trendify/generator/render.py +195 -0
- trendify/generator/table_builder.py +194 -0
- trendify/generator/xy_data_plotter.py +195 -0
- trendify/log.py +195 -0
- trendify/pipeline.py +136 -0
- trendify/plotting/__init__.py +48 -0
- trendify/plotting/axline.py +122 -0
- trendify/plotting/figure.py +389 -0
- trendify/plotting/histogram.py +282 -0
- trendify/plotting/point.py +89 -0
- trendify/plotting/scatter.py +100 -0
- trendify/plotting/trace.py +129 -0
- trendify/progress.py +43 -0
- trendify/store/__init__.py +30 -0
- trendify/store/db.py +119 -0
- trendify/store/record_store.py +448 -0
- trendify/store/tags.py +65 -0
- trendify/styling/__init__.py +30 -0
- trendify/styling/grid.py +125 -0
- trendify/styling/legend.py +217 -0
- trendify/styling/marker.py +168 -0
- trendify/typing.py +26 -0
- trendify/viewer/__init__.py +41 -0
- trendify/viewer/app.py +208 -0
- trendify/viewer/hydration.py +181 -0
- trendify/viewer/plot_config.py +102 -0
- trendify/viewer/routes/__init__.py +6 -0
- trendify/viewer/routes/api.py +360 -0
- trendify/viewer/routes/pages.py +39 -0
- trendify/viewer/tag_tree.py +148 -0
- trendify/viewer/templates/base.html +63 -0
- trendify/viewer/templates/index.html +131 -0
- trendify/viewer/templates/partials/__init__.py +1 -0
- trendify/viewer/templates/partials/footer.html +15 -0
- trendify/viewer/templates/partials/nav.html +181 -0
- trendify/viewer/templates/partials/plot_view.html +221 -0
- trendify/viewer/templates/partials/sidebar.html +163 -0
- trendify/viewer/templates/partials/table_view.html +43 -0
- trendify/viewer/templates/static/__init__.py +1 -0
- trendify/viewer/templates/static/assets/__init__.py +1 -0
- trendify/viewer/templates/static/assets/main-logo-white-bg.svg +75 -0
- trendify/viewer/templates/static/assets/main-logo.svg +66 -0
- trendify/viewer/templates/static/css/app.css +75 -0
- trendify/viewer/templates/static/js/main.js +2 -0
- trendify/viewer/templates/static/vendored/__init__.py +1 -0
- trendify/viewer/templates/static/vendored/alpine-3.15.12.min.js +5 -0
- trendify/viewer/templates/static/vendored/alpine-LICENSE.md +21 -0
- trendify/viewer/templates/static/vendored/dataTables-LICENSE.txt +3 -0
- trendify/viewer/templates/static/vendored/dataTables.dataTables.min.css +1 -0
- trendify/viewer/templates/static/vendored/dataTables.min.js +4 -0
- trendify/viewer/templates/static/vendored/jquery-3.7.1.min.js +2 -0
- trendify/viewer/templates/static/vendored/jquery-LICENSE.txt +3 -0
- trendify/viewer/templates/static/vendored/plotly-3.6.0.min.js +3882 -0
- trendify/viewer/templates/static/vendored/plotly-LICENSE.txt +21 -0
- trendify/viewer/templates/static/vendored/tailwind-4.3.2.global.js +947 -0
- trendify/viewer/templates/static/vendored/tailwind-LICENSE +21 -0
- trendify2-2.0.4.dist-info/METADATA +107 -0
- trendify2-2.0.4.dist-info/RECORD +76 -0
- trendify2-2.0.4.dist-info/WHEEL +4 -0
- trendify2-2.0.4.dist-info/entry_points.txt +2 -0
- trendify2-2.0.4.dist-info/licenses/LICENSE.txt +21 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`Point2D`: a single scattered (x, y) point, styled with a `Marker`.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
import plotly.graph_objects as go
|
|
10
|
+
from pydantic import ConfigDict
|
|
11
|
+
|
|
12
|
+
from trendify.formats.format2d import XYData
|
|
13
|
+
from trendify.plotting.figure import PlotlyFigure
|
|
14
|
+
from trendify.styling.marker import Marker
|
|
15
|
+
|
|
16
|
+
__all__ = ["Point2D"]
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Point2D(XYData):
|
|
22
|
+
"""
|
|
23
|
+
Defines a point to be scattered onto xy plot.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
x: float | str
|
|
27
|
+
"""X value for the point."""
|
|
28
|
+
|
|
29
|
+
y: float | str
|
|
30
|
+
"""Y value for the point."""
|
|
31
|
+
|
|
32
|
+
marker: Marker | None = Marker()
|
|
33
|
+
"""Style and label information for scattering points to matplotlib axes. Only the label
|
|
34
|
+
information is used in Grafana. Eventually style information will be used in Grafana."""
|
|
35
|
+
|
|
36
|
+
model_config = ConfigDict(extra="forbid")
|
|
37
|
+
|
|
38
|
+
def add_to_plotly(self, plotly_figure: PlotlyFigure) -> PlotlyFigure:
|
|
39
|
+
"""Add point to plotly figure with legendgroup support"""
|
|
40
|
+
legend_key = (
|
|
41
|
+
f"{self.marker.label}_{self.marker.color}_{self.marker.symbol}"
|
|
42
|
+
if self.marker
|
|
43
|
+
else None
|
|
44
|
+
)
|
|
45
|
+
# Prepare metadata for the tooltip
|
|
46
|
+
metadata_html = (
|
|
47
|
+
"<br>".join([f"{key}: {value}" for key, value in self.metadata.items()])
|
|
48
|
+
if self.metadata
|
|
49
|
+
else ""
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Define hovertemplate for the tooltip
|
|
53
|
+
hovertemplate = (
|
|
54
|
+
f"<b>{self.marker.label if self.marker else ''}</b><br>"
|
|
55
|
+
"x: %{x}<br>"
|
|
56
|
+
"y: %{y}<br>"
|
|
57
|
+
f"{metadata_html}<extra></extra>"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
plotly_figure.fig.add_trace(
|
|
61
|
+
go.Scatter(
|
|
62
|
+
x=[self.x],
|
|
63
|
+
y=[self.y],
|
|
64
|
+
name=self.marker.label if self.marker else None,
|
|
65
|
+
mode="markers",
|
|
66
|
+
marker=dict(
|
|
67
|
+
color=self.marker.rgba if self.marker else None,
|
|
68
|
+
size=self.marker.size if self.marker else None,
|
|
69
|
+
symbol=self.marker.plotly_symbol if self.marker else None,
|
|
70
|
+
),
|
|
71
|
+
zorder=int(self.marker.zorder) if self.marker else None,
|
|
72
|
+
legendgroup=legend_key,
|
|
73
|
+
hovertemplate=hovertemplate,
|
|
74
|
+
hoverlabel=dict(
|
|
75
|
+
bgcolor=(self.marker.rgba if self.marker else None),
|
|
76
|
+
font=dict(
|
|
77
|
+
color=self.marker.get_contrast_color() if self.marker else None
|
|
78
|
+
),
|
|
79
|
+
),
|
|
80
|
+
showlegend=(
|
|
81
|
+
True if legend_key not in plotly_figure.legend_groups else False
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if legend_key and legend_key not in plotly_figure.legend_groups:
|
|
87
|
+
plotly_figure.legend_groups.add(legend_key)
|
|
88
|
+
|
|
89
|
+
return plotly_figure
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`Scatter2D`: a bulk collection of (x, y) points sharing one `Marker`, drawn as unconnected
|
|
3
|
+
scatter points.
|
|
4
|
+
|
|
5
|
+
Use this instead of many individual `Point2D` records when a single generation call
|
|
6
|
+
produces a large array of points that all share one style and don't need distinct per-point
|
|
7
|
+
tags/metadata (e.g. a raw scatter of measurements from one run). `Point2D` remains the right
|
|
8
|
+
choice when each point is its own taggable/hoverable entity, such as one point summarizing
|
|
9
|
+
one run, aggregated across many runs that share a tag.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
import plotly.graph_objects as go
|
|
18
|
+
from pydantic import ConfigDict
|
|
19
|
+
|
|
20
|
+
from trendify.formats.format2d import XYData
|
|
21
|
+
from trendify.plotting.figure import PlotlyFigure
|
|
22
|
+
from trendify.styling.marker import Marker
|
|
23
|
+
from trendify.typing import VecN
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from matplotlib.axes import Axes
|
|
27
|
+
|
|
28
|
+
__all__ = ["Scatter2D"]
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Scatter2D(XYData):
|
|
34
|
+
"""
|
|
35
|
+
A collection of unconnected scattered points sharing one `Marker`.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
model_config = ConfigDict(extra="forbid")
|
|
39
|
+
|
|
40
|
+
x: VecN
|
|
41
|
+
"""x values"""
|
|
42
|
+
|
|
43
|
+
y: VecN
|
|
44
|
+
"""y values"""
|
|
45
|
+
|
|
46
|
+
marker: Marker = Marker()
|
|
47
|
+
"""Style and label information shared by every point in this scatter."""
|
|
48
|
+
|
|
49
|
+
def plot_to_ax(self, ax: Axes) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Scatters xy data to a matplotlib axes object.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
ax (Axes): axes to which xy data should be plotted
|
|
55
|
+
|
|
56
|
+
"""
|
|
57
|
+
ax.scatter(self.x, self.y, **self.marker.as_scatter_plot_kwargs())
|
|
58
|
+
|
|
59
|
+
def add_to_plotly(self, plotly_figure: PlotlyFigure) -> PlotlyFigure:
|
|
60
|
+
legend_key = f"{self.marker.label}_{self.marker.color}_{self.marker.symbol}"
|
|
61
|
+
|
|
62
|
+
metadata_html = (
|
|
63
|
+
"<br>".join(f"{key}: {value}" for key, value in self.metadata.items())
|
|
64
|
+
if self.metadata
|
|
65
|
+
else ""
|
|
66
|
+
)
|
|
67
|
+
hovertemplate = (
|
|
68
|
+
f"<b>{self.marker.label or ''}</b><br>"
|
|
69
|
+
"x: %{x}<br>"
|
|
70
|
+
"y: %{y}<br>"
|
|
71
|
+
f"{metadata_html}<extra></extra>"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
plotly_figure.fig.add_trace(
|
|
75
|
+
go.Scatter(
|
|
76
|
+
x=self.x,
|
|
77
|
+
y=self.y,
|
|
78
|
+
name=self.marker.label,
|
|
79
|
+
mode="markers",
|
|
80
|
+
marker=dict(
|
|
81
|
+
color=self.marker.rgba,
|
|
82
|
+
size=self.marker.size,
|
|
83
|
+
symbol=self.marker.plotly_symbol,
|
|
84
|
+
),
|
|
85
|
+
zorder=int(self.marker.zorder),
|
|
86
|
+
legendgroup=legend_key,
|
|
87
|
+
hovertemplate=hovertemplate,
|
|
88
|
+
hoverlabel=dict(
|
|
89
|
+
bgcolor=self.marker.rgba,
|
|
90
|
+
font=dict(color=self.marker.get_contrast_color()),
|
|
91
|
+
),
|
|
92
|
+
showlegend=(
|
|
93
|
+
True if legend_key not in plotly_figure.legend_groups else False
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if legend_key not in plotly_figure.legend_groups:
|
|
99
|
+
plotly_figure.legend_groups.add(legend_key)
|
|
100
|
+
return plotly_figure
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`Trace2D`: an xy line built from flat `x`/`y` arrays, styled with a `Pen`, and optionally
|
|
3
|
+
marked at intervals with a single shared `Marker` (mirroring matplotlib's native
|
|
4
|
+
`markevery`).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import plotly.graph_objects as go
|
|
13
|
+
from pydantic import ConfigDict
|
|
14
|
+
|
|
15
|
+
from trendify.base.pen import Pen
|
|
16
|
+
from trendify.formats.format2d import XYData
|
|
17
|
+
from trendify.plotting.figure import PlotlyFigure
|
|
18
|
+
from trendify.styling.marker import Marker
|
|
19
|
+
from trendify.typing import VecN
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from matplotlib.axes import Axes
|
|
23
|
+
|
|
24
|
+
__all__ = ["Trace2D"]
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Trace2D(XYData):
|
|
30
|
+
"""An xy line, optionally marked at intervals."""
|
|
31
|
+
|
|
32
|
+
model_config = ConfigDict(extra="forbid")
|
|
33
|
+
|
|
34
|
+
x: VecN
|
|
35
|
+
"""x values"""
|
|
36
|
+
|
|
37
|
+
y: VecN
|
|
38
|
+
"""y values"""
|
|
39
|
+
|
|
40
|
+
pen: Pen = Pen()
|
|
41
|
+
"""Style and label information for drawing to matplotlib axes. Only the label information
|
|
42
|
+
is used in Grafana. Eventually style information will be used in Grafana."""
|
|
43
|
+
|
|
44
|
+
marker: Marker | None = None
|
|
45
|
+
"""Shared marker style drawn along the line (e.g. one marker every `markevery` points).
|
|
46
|
+
`None` draws a plain line with no markers."""
|
|
47
|
+
|
|
48
|
+
markevery: int | None = None
|
|
49
|
+
"""Draw a marker every Nth point when `marker` is set (passed straight through to
|
|
50
|
+
matplotlib's `Axes.plot(markevery=...)`); `None` marks every point."""
|
|
51
|
+
|
|
52
|
+
def plot_to_ax(self, ax: Axes) -> None:
|
|
53
|
+
"""
|
|
54
|
+
Plots xy data from trace to a matplotlib axes object.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
ax (Axes): axes to which xy data should be plotted
|
|
58
|
+
|
|
59
|
+
"""
|
|
60
|
+
kwargs = self.pen.as_scatter_plot_kwargs()
|
|
61
|
+
if self.marker is not None:
|
|
62
|
+
kwargs["marker"] = self.marker.symbol
|
|
63
|
+
kwargs["markersize"] = self.marker.size
|
|
64
|
+
kwargs["markerfacecolor"] = self.marker.color
|
|
65
|
+
kwargs["markeredgecolor"] = self.marker.color
|
|
66
|
+
kwargs["markevery"] = self.markevery if self.markevery is not None else 1
|
|
67
|
+
ax.plot(self.x, self.y, **kwargs)
|
|
68
|
+
|
|
69
|
+
def add_to_plotly(self, plotly_figure: PlotlyFigure) -> PlotlyFigure:
|
|
70
|
+
legend_key = (
|
|
71
|
+
f"{self.pen.label}_{self.pen.color}_{self.pen._convert_linestyle_to_plotly()}"
|
|
72
|
+
if self.pen
|
|
73
|
+
else None
|
|
74
|
+
)
|
|
75
|
+
# Prepare metadata for the tooltip
|
|
76
|
+
metadata_html = (
|
|
77
|
+
"<br>".join([f"{key}: {value}" for key, value in self.metadata.items()])
|
|
78
|
+
if self.metadata
|
|
79
|
+
else ""
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Define hovertemplate for the tooltip
|
|
83
|
+
hovertemplate = (
|
|
84
|
+
f"<b>{self.pen.label if self.pen else ''}</b><br>"
|
|
85
|
+
"x: %{x}<br>"
|
|
86
|
+
"y: %{y}<br>"
|
|
87
|
+
f"{metadata_html}<extra></extra>"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# `markevery` (subsampling markers along the line) has no direct Plotly equivalent
|
|
91
|
+
# for a single trace, so Plotly renders a marker at every point when `marker` is set.
|
|
92
|
+
mode_parts = []
|
|
93
|
+
if self.pen.linestyle is not None:
|
|
94
|
+
mode_parts.append("lines")
|
|
95
|
+
if self.marker is not None:
|
|
96
|
+
mode_parts.append("markers")
|
|
97
|
+
|
|
98
|
+
plotly_figure.fig.add_trace(
|
|
99
|
+
go.Scatter(
|
|
100
|
+
x=self.x,
|
|
101
|
+
y=self.y,
|
|
102
|
+
name=self.pen.label if self.pen else None,
|
|
103
|
+
mode="+".join(mode_parts) if mode_parts else "lines",
|
|
104
|
+
line=dict(
|
|
105
|
+
color=self.pen.rgba if self.pen else None,
|
|
106
|
+
width=self.pen.size if self.pen else None,
|
|
107
|
+
dash=self.pen._convert_linestyle_to_plotly() if self.pen else None,
|
|
108
|
+
),
|
|
109
|
+
marker=dict(
|
|
110
|
+
color=self.marker.rgba if self.marker else self.pen.rgba,
|
|
111
|
+
size=self.marker.size if self.marker else None,
|
|
112
|
+
symbol=self.marker.plotly_symbol if self.marker else None,
|
|
113
|
+
),
|
|
114
|
+
zorder=int(self.pen.zorder),
|
|
115
|
+
hovertemplate=hovertemplate,
|
|
116
|
+
hoverlabel=dict(
|
|
117
|
+
bgcolor=(self.pen.rgba if self.pen else None),
|
|
118
|
+
font=dict(color=self.pen.get_contrast_color()),
|
|
119
|
+
),
|
|
120
|
+
legendgroup=legend_key,
|
|
121
|
+
showlegend=(
|
|
122
|
+
True if legend_key not in plotly_figure.legend_groups else False
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if legend_key and legend_key not in plotly_figure.legend_groups:
|
|
128
|
+
plotly_figure.legend_groups.add(legend_key)
|
|
129
|
+
return plotly_figure
|
trendify/progress.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Progress reporting for the generate/render pipeline: a lightweight event type plus a callback
|
|
3
|
+
type alias, so a caller embedding trendify (e.g. running it inside a container and feeding
|
|
4
|
+
status back to their own UI/API) can observe progress without needing logging configured or
|
|
5
|
+
parsing log lines.
|
|
6
|
+
|
|
7
|
+
`on_progress` is always invoked from the main process, never a worker: `generate_records`
|
|
8
|
+
reports after each `write_run` (the parent process is the only one that ever writes) and
|
|
9
|
+
`render_assets` reports after each tag's `future.result()` (or, sequentially, after each
|
|
10
|
+
`_render_tag_assets` call) -- so the callback itself never needs to be picklable/sent across a
|
|
11
|
+
process boundary, unlike `record_generator` itself.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Literal
|
|
19
|
+
|
|
20
|
+
__all__ = ["ProgressCallback", "ProgressEvent"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ProgressEvent:
|
|
25
|
+
"""One unit of work finishing during `generate` or `render`."""
|
|
26
|
+
|
|
27
|
+
stage: Literal["generate", "render"]
|
|
28
|
+
"""Which pipeline stage this event is from."""
|
|
29
|
+
|
|
30
|
+
completed: int
|
|
31
|
+
"""Number of units finished so far this stage, including this one."""
|
|
32
|
+
|
|
33
|
+
total: int
|
|
34
|
+
"""Total number of units this stage will process."""
|
|
35
|
+
|
|
36
|
+
detail: str
|
|
37
|
+
"""Human-readable identifier for the unit that just finished: a run directory's path for
|
|
38
|
+
`generate`, a tag's display string for `render`."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
ProgressCallback = Callable[[ProgressEvent], None]
|
|
42
|
+
"""A callback invoked once per completed unit of work. Left to raise: a callback that fails is
|
|
43
|
+
almost certainly a bug in the caller's own integration, not something to silently swallow."""
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""SQLite-backed storage and querying of records."""
|
|
2
|
+
|
|
3
|
+
from trendify.store import db
|
|
4
|
+
from trendify.store import record_store
|
|
5
|
+
from trendify.store import tags
|
|
6
|
+
|
|
7
|
+
from trendify.store.db import (
|
|
8
|
+
SCHEMA_VERSION,
|
|
9
|
+
connect,
|
|
10
|
+
)
|
|
11
|
+
from trendify.store.record_store import (
|
|
12
|
+
RecordStore,
|
|
13
|
+
)
|
|
14
|
+
from trendify.store.tags import (
|
|
15
|
+
decode_tag,
|
|
16
|
+
encode_tag,
|
|
17
|
+
tag_to_path_parts,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"SCHEMA_VERSION",
|
|
22
|
+
"RecordStore",
|
|
23
|
+
"connect",
|
|
24
|
+
"db",
|
|
25
|
+
"decode_tag",
|
|
26
|
+
"encode_tag",
|
|
27
|
+
"record_store",
|
|
28
|
+
"tag_to_path_parts",
|
|
29
|
+
"tags",
|
|
30
|
+
]
|
trendify/store/db.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQLite connection factory, pragmas, and schema management for the trendify store.
|
|
3
|
+
|
|
4
|
+
- One `.db` file per trendify output directory, opened by every worker process independently.
|
|
5
|
+
- WAL mode gives concurrent readers plus one writer without the readers blocking on the
|
|
6
|
+
writer; `busy_timeout` handles the writer-vs-writer case without any external file locking.
|
|
7
|
+
- Schema versioning via `PRAGMA user_version`, checked/applied on every `connect()` call so
|
|
8
|
+
opening a fresh `.db` file bootstraps it and opening an existing one verifies compatibility.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import sqlite3
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
__all__ = ["SCHEMA_VERSION", "connect"]
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
SCHEMA_VERSION = 1
|
|
22
|
+
|
|
23
|
+
_SCHEMA_DDL = """
|
|
24
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
25
|
+
id INTEGER PRIMARY KEY,
|
|
26
|
+
path TEXT NOT NULL UNIQUE,
|
|
27
|
+
generated_at TEXT NOT NULL
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
CREATE TABLE IF NOT EXISTS records (
|
|
31
|
+
id INTEGER PRIMARY KEY,
|
|
32
|
+
run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
|
|
33
|
+
record_type TEXT NOT NULL,
|
|
34
|
+
payload TEXT NOT NULL,
|
|
35
|
+
created_at TEXT NOT NULL
|
|
36
|
+
);
|
|
37
|
+
CREATE INDEX IF NOT EXISTS idx_records_run ON records(run_id);
|
|
38
|
+
|
|
39
|
+
CREATE TABLE IF NOT EXISTS record_tags (
|
|
40
|
+
record_id INTEGER NOT NULL REFERENCES records(id) ON DELETE CASCADE,
|
|
41
|
+
tag_key TEXT NOT NULL,
|
|
42
|
+
record_type TEXT NOT NULL,
|
|
43
|
+
PRIMARY KEY (record_id, tag_key)
|
|
44
|
+
);
|
|
45
|
+
CREATE INDEX IF NOT EXISTS idx_record_tags_lookup ON record_tags(tag_key, record_type);
|
|
46
|
+
|
|
47
|
+
CREATE TABLE IF NOT EXISTS table_entries (
|
|
48
|
+
id INTEGER PRIMARY KEY,
|
|
49
|
+
record_id INTEGER NOT NULL REFERENCES records(id) ON DELETE CASCADE,
|
|
50
|
+
tag_key TEXT NOT NULL,
|
|
51
|
+
row_key TEXT NOT NULL,
|
|
52
|
+
col_key TEXT NOT NULL,
|
|
53
|
+
value_num REAL,
|
|
54
|
+
value_text TEXT,
|
|
55
|
+
value_bool INTEGER,
|
|
56
|
+
unit TEXT
|
|
57
|
+
);
|
|
58
|
+
CREATE INDEX IF NOT EXISTS idx_table_entries_tag ON table_entries(tag_key);
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_table_entries_record ON table_entries(record_id);
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def connect(db_path: Path, *, readonly: bool = False) -> sqlite3.Connection:
|
|
64
|
+
"""
|
|
65
|
+
Opens a WAL-mode SQLite connection to `db_path`, bootstrapping the schema on first use.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
db_path (Path): path to the trendify output directory's `.db` file
|
|
69
|
+
readonly (bool): open in read-only mode (`mode=ro` URI), for render workers that only
|
|
70
|
+
ever query and never write, so they can safely run concurrently with an
|
|
71
|
+
in-progress writer under WAL.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
(sqlite3.Connection): a configured connection, with row access by column name.
|
|
75
|
+
|
|
76
|
+
"""
|
|
77
|
+
logger.debug(f"Connecting to {db_path = } ({readonly = })")
|
|
78
|
+
if readonly:
|
|
79
|
+
uri = f"file:{db_path}?mode=ro"
|
|
80
|
+
conn = sqlite3.connect(uri, uri=True)
|
|
81
|
+
else:
|
|
82
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
conn = sqlite3.connect(db_path)
|
|
84
|
+
|
|
85
|
+
conn.row_factory = sqlite3.Row
|
|
86
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
87
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
88
|
+
conn.execute("PRAGMA busy_timeout=30000")
|
|
89
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
90
|
+
|
|
91
|
+
if not readonly:
|
|
92
|
+
_ensure_schema(conn)
|
|
93
|
+
|
|
94
|
+
return conn
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _ensure_schema(conn: sqlite3.Connection) -> None:
|
|
98
|
+
"""
|
|
99
|
+
Bootstraps the schema on a fresh database, or verifies the on-disk schema version matches
|
|
100
|
+
`SCHEMA_VERSION` on an existing one. There is deliberately no migration logic yet, since
|
|
101
|
+
there has only ever been one schema version; this is the hook future migrations attach to.
|
|
102
|
+
"""
|
|
103
|
+
(version,) = conn.execute("PRAGMA user_version").fetchone()
|
|
104
|
+
if version == 0:
|
|
105
|
+
logger.info(f"Bootstrapping fresh schema (version {SCHEMA_VERSION})")
|
|
106
|
+
conn.executescript(_SCHEMA_DDL)
|
|
107
|
+
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
|
|
108
|
+
conn.commit()
|
|
109
|
+
elif version != SCHEMA_VERSION:
|
|
110
|
+
logger.error(
|
|
111
|
+
f"Database schema version {version} does not match expected "
|
|
112
|
+
f"{SCHEMA_VERSION}, and no migration path exists yet."
|
|
113
|
+
)
|
|
114
|
+
raise RuntimeError(
|
|
115
|
+
f"Database schema version {version} does not match expected "
|
|
116
|
+
f"{SCHEMA_VERSION}, and no migration path exists yet."
|
|
117
|
+
)
|
|
118
|
+
else:
|
|
119
|
+
logger.debug(f"Schema version {version} verified")
|