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,217 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`Legend` (matplotlib/Plotly legend styling: location, title, opacity, border) and the
|
|
3
|
+
`LegendLocation` enum of matplotlib-recognized location strings it validates against.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from trendify.base.helpers import HashableBase
|
|
12
|
+
|
|
13
|
+
__all__ = ["Legend", "LegendLocation"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LegendLocation(StrEnum):
|
|
17
|
+
"""
|
|
18
|
+
Matplotlib-recognized legend anchor locations.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
BEST = "best"
|
|
22
|
+
"""Matplotlib chooses the location that overlaps plotted data the least."""
|
|
23
|
+
|
|
24
|
+
UPPER_RIGHT = "upper right"
|
|
25
|
+
"""Upper right corner of the axes."""
|
|
26
|
+
|
|
27
|
+
UPPER_LEFT = "upper left"
|
|
28
|
+
"""Upper left corner of the axes."""
|
|
29
|
+
|
|
30
|
+
LOWER_LEFT = "lower left"
|
|
31
|
+
"""Lower left corner of the axes."""
|
|
32
|
+
|
|
33
|
+
LOWER_RIGHT = "lower right"
|
|
34
|
+
"""Lower right corner of the axes."""
|
|
35
|
+
|
|
36
|
+
RIGHT = "right"
|
|
37
|
+
"""Right edge of the axes, vertically centered."""
|
|
38
|
+
|
|
39
|
+
CENTER_LEFT = "center left"
|
|
40
|
+
"""Left edge of the axes, vertically centered."""
|
|
41
|
+
|
|
42
|
+
CENTER_RIGHT = "center right"
|
|
43
|
+
"""Right edge of the axes, vertically centered."""
|
|
44
|
+
|
|
45
|
+
LOWER_CENTER = "lower center"
|
|
46
|
+
"""Bottom edge of the axes, horizontally centered."""
|
|
47
|
+
|
|
48
|
+
UPPER_CENTER = "upper center"
|
|
49
|
+
"""Top edge of the axes, horizontally centered."""
|
|
50
|
+
|
|
51
|
+
CENTER = "center"
|
|
52
|
+
"""Center of the axes."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Legend(HashableBase):
|
|
56
|
+
"""
|
|
57
|
+
Configuration container for Matplotlib legend styling and placement.
|
|
58
|
+
|
|
59
|
+
Placement is governed by a combination of the `loc` and `bbox_to_anchor`
|
|
60
|
+
parameters, mirroring Matplotlib's `Axes.legend()`.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
visible: bool = True
|
|
64
|
+
"""Whether the legend should be displayed."""
|
|
65
|
+
|
|
66
|
+
title: str | None = None
|
|
67
|
+
"""Title displayed above the legend entries."""
|
|
68
|
+
|
|
69
|
+
framealpha: float = 1
|
|
70
|
+
"""Opacity of the legend background. 1 is fully opaque, 0 is fully transparent."""
|
|
71
|
+
|
|
72
|
+
loc: LegendLocation = LegendLocation.BEST
|
|
73
|
+
"""Anchor point for the legend (e.g., upper right, lower left). See `LegendLocation`
|
|
74
|
+
enum for options."""
|
|
75
|
+
|
|
76
|
+
ncol: int = 1
|
|
77
|
+
"""Number of columns to arrange legend entries into."""
|
|
78
|
+
|
|
79
|
+
fancybox: bool = True
|
|
80
|
+
"""Whether to draw a rounded (True) or square (False) legend frame."""
|
|
81
|
+
|
|
82
|
+
edgecolor: str = "black"
|
|
83
|
+
"""Color of the legend frame border."""
|
|
84
|
+
|
|
85
|
+
zorder: int = 1000
|
|
86
|
+
"""Prioritization of the legend relative to plotted data."""
|
|
87
|
+
|
|
88
|
+
bbox_to_anchor: tuple[float, float] | None = None
|
|
89
|
+
"""Offset position of the legend in figure or axes coordinates. If `None`, the legend
|
|
90
|
+
is placed inside the axes using `loc`."""
|
|
91
|
+
|
|
92
|
+
def to_kwargs(self) -> dict[str, Any]:
|
|
93
|
+
return {
|
|
94
|
+
"title": self.title,
|
|
95
|
+
"framealpha": self.framealpha,
|
|
96
|
+
"loc": self.loc,
|
|
97
|
+
"ncol": self.ncol,
|
|
98
|
+
"fancybox": self.fancybox,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def plotly_location(self) -> dict:
|
|
103
|
+
"""
|
|
104
|
+
Convert matplotlib legend location to Plotly legend position parameters.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
dict: Dictionary containing Plotly legend position parameters (x, y, xanchor, yanchor)
|
|
108
|
+
|
|
109
|
+
"""
|
|
110
|
+
# Default position mappings for standard locations
|
|
111
|
+
location_map = {
|
|
112
|
+
LegendLocation.BEST: {
|
|
113
|
+
"x": 1.02,
|
|
114
|
+
"y": 1,
|
|
115
|
+
"xanchor": "left",
|
|
116
|
+
"yanchor": "top",
|
|
117
|
+
},
|
|
118
|
+
LegendLocation.UPPER_RIGHT: {
|
|
119
|
+
"x": 0.98,
|
|
120
|
+
"y": 0.98,
|
|
121
|
+
"xanchor": "right",
|
|
122
|
+
"yanchor": "top",
|
|
123
|
+
},
|
|
124
|
+
LegendLocation.UPPER_LEFT: {
|
|
125
|
+
"x": 0.02,
|
|
126
|
+
"y": 0.98,
|
|
127
|
+
"xanchor": "left",
|
|
128
|
+
"yanchor": "top",
|
|
129
|
+
},
|
|
130
|
+
LegendLocation.LOWER_LEFT: {
|
|
131
|
+
"x": 0.02,
|
|
132
|
+
"y": 0.02,
|
|
133
|
+
"xanchor": "left",
|
|
134
|
+
"yanchor": "bottom",
|
|
135
|
+
},
|
|
136
|
+
LegendLocation.LOWER_RIGHT: {
|
|
137
|
+
"x": 0.98,
|
|
138
|
+
"y": 0.02,
|
|
139
|
+
"xanchor": "right",
|
|
140
|
+
"yanchor": "bottom",
|
|
141
|
+
},
|
|
142
|
+
LegendLocation.RIGHT: {
|
|
143
|
+
"x": 1.02,
|
|
144
|
+
"y": 0.5,
|
|
145
|
+
"xanchor": "left",
|
|
146
|
+
"yanchor": "middle",
|
|
147
|
+
},
|
|
148
|
+
LegendLocation.CENTER_LEFT: {
|
|
149
|
+
"x": -0.02,
|
|
150
|
+
"y": 0.5,
|
|
151
|
+
"xanchor": "right",
|
|
152
|
+
"yanchor": "middle",
|
|
153
|
+
},
|
|
154
|
+
LegendLocation.CENTER_RIGHT: {
|
|
155
|
+
"x": 1.02,
|
|
156
|
+
"y": 0.5,
|
|
157
|
+
"xanchor": "left",
|
|
158
|
+
"yanchor": "middle",
|
|
159
|
+
},
|
|
160
|
+
LegendLocation.LOWER_CENTER: {
|
|
161
|
+
"x": 0.5,
|
|
162
|
+
"y": 0.02,
|
|
163
|
+
"xanchor": "center",
|
|
164
|
+
"yanchor": "bottom",
|
|
165
|
+
},
|
|
166
|
+
LegendLocation.UPPER_CENTER: {
|
|
167
|
+
"x": 0.5,
|
|
168
|
+
"y": 0.98,
|
|
169
|
+
"xanchor": "center",
|
|
170
|
+
"yanchor": "top",
|
|
171
|
+
},
|
|
172
|
+
LegendLocation.CENTER: {
|
|
173
|
+
"x": 0.5,
|
|
174
|
+
"y": 0.5,
|
|
175
|
+
"xanchor": "center",
|
|
176
|
+
"yanchor": "middle",
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
# If bbox_to_anchor is provided, use it to override the position
|
|
181
|
+
if self.bbox_to_anchor is not None:
|
|
182
|
+
x, y = self.bbox_to_anchor
|
|
183
|
+
# Determine anchors based on location
|
|
184
|
+
if self.loc in [
|
|
185
|
+
LegendLocation.CENTER_LEFT,
|
|
186
|
+
LegendLocation.UPPER_LEFT,
|
|
187
|
+
LegendLocation.LOWER_LEFT,
|
|
188
|
+
]:
|
|
189
|
+
xanchor = "right"
|
|
190
|
+
elif self.loc in [
|
|
191
|
+
LegendLocation.CENTER_RIGHT,
|
|
192
|
+
LegendLocation.UPPER_RIGHT,
|
|
193
|
+
LegendLocation.LOWER_RIGHT,
|
|
194
|
+
]:
|
|
195
|
+
xanchor = "left"
|
|
196
|
+
else:
|
|
197
|
+
xanchor = "center"
|
|
198
|
+
|
|
199
|
+
if self.loc in [
|
|
200
|
+
LegendLocation.UPPER_RIGHT,
|
|
201
|
+
LegendLocation.UPPER_LEFT,
|
|
202
|
+
LegendLocation.UPPER_CENTER,
|
|
203
|
+
]:
|
|
204
|
+
yanchor = "top"
|
|
205
|
+
elif self.loc in [
|
|
206
|
+
LegendLocation.LOWER_RIGHT,
|
|
207
|
+
LegendLocation.LOWER_LEFT,
|
|
208
|
+
LegendLocation.LOWER_CENTER,
|
|
209
|
+
]:
|
|
210
|
+
yanchor = "bottom"
|
|
211
|
+
else:
|
|
212
|
+
yanchor = "middle"
|
|
213
|
+
|
|
214
|
+
return {"x": x, "y": y, "xanchor": xanchor, "yanchor": yanchor}
|
|
215
|
+
|
|
216
|
+
# Use predefined mapping if no bbox_to_anchor
|
|
217
|
+
return location_map[self.loc]
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`Marker`: scatter-point styling (color, size, alpha, symbol) for `Point2D`, including the
|
|
3
|
+
`from_pen` conversion used when a `Trace2D`'s `Pen` needs to render as scattered markers.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from matplotlib.colors import to_rgba
|
|
12
|
+
from pydantic import ConfigDict
|
|
13
|
+
|
|
14
|
+
from trendify.base.helpers import HashableBase
|
|
15
|
+
from trendify.base.pen import Pen
|
|
16
|
+
|
|
17
|
+
__all__ = ["Marker"]
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Marker(HashableBase):
|
|
23
|
+
"""
|
|
24
|
+
Defines marker for scattering to matplotlib
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
color: tuple[float, float, float] | tuple[float, float, float, float] | str = "k"
|
|
28
|
+
"""Color of line"""
|
|
29
|
+
|
|
30
|
+
size: float = 5
|
|
31
|
+
"""Line width"""
|
|
32
|
+
|
|
33
|
+
alpha: float = 1
|
|
34
|
+
"""Opacity from 0 to 1 (inclusive)"""
|
|
35
|
+
|
|
36
|
+
zorder: float = 0
|
|
37
|
+
"""Prioritization"""
|
|
38
|
+
|
|
39
|
+
label: str | None = None
|
|
40
|
+
"""Legend label"""
|
|
41
|
+
|
|
42
|
+
symbol: str = "."
|
|
43
|
+
"""Matplotlib symbol string"""
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_pen(
|
|
47
|
+
cls,
|
|
48
|
+
pen: Pen,
|
|
49
|
+
symbol: str = ".",
|
|
50
|
+
):
|
|
51
|
+
"""
|
|
52
|
+
Converts Pen to marker with the option to specify a symbol.
|
|
53
|
+
|
|
54
|
+
`Pen` has one field (`linestyle`) that `Marker` doesn't, so its dumped dict can't be
|
|
55
|
+
splatted directly as `**kwargs` into `Marker`. This drops that one field and splats
|
|
56
|
+
what's left (color/size/alpha/zorder/label, which line up 1:1 with `Marker`'s fields).
|
|
57
|
+
"""
|
|
58
|
+
data = pen.model_dump()
|
|
59
|
+
data.pop("linestyle", None)
|
|
60
|
+
return cls(symbol=symbol, **data)
|
|
61
|
+
|
|
62
|
+
model_config = ConfigDict(extra="forbid")
|
|
63
|
+
|
|
64
|
+
def as_scatter_plot_kwargs(self) -> dict[str, Any]:
|
|
65
|
+
"""
|
|
66
|
+
Returns:
|
|
67
|
+
(dict): dictionary of `kwargs` for [matplotlib scatter][matplotlib.axes.Axes.scatter]
|
|
68
|
+
|
|
69
|
+
"""
|
|
70
|
+
return {
|
|
71
|
+
"marker": self.symbol,
|
|
72
|
+
"color": self.color,
|
|
73
|
+
"s": self.size,
|
|
74
|
+
"alpha": self.alpha,
|
|
75
|
+
"zorder": self.zorder,
|
|
76
|
+
"label": self.label,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def plotly_symbol(self) -> str:
|
|
81
|
+
"""Convert matplotlib marker symbol to plotly symbol"""
|
|
82
|
+
symbol_map = {
|
|
83
|
+
".": "circle",
|
|
84
|
+
"o": "circle",
|
|
85
|
+
"v": "triangle-down",
|
|
86
|
+
"^": "triangle-up",
|
|
87
|
+
"<": "triangle-left",
|
|
88
|
+
">": "triangle-right",
|
|
89
|
+
"s": "square",
|
|
90
|
+
"p": "pentagon",
|
|
91
|
+
"*": "star",
|
|
92
|
+
"h": "hexagon",
|
|
93
|
+
"+": "cross",
|
|
94
|
+
"x": "x",
|
|
95
|
+
"D": "diamond",
|
|
96
|
+
}
|
|
97
|
+
return symbol_map.get(self.symbol, "circle")
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def rgba(self) -> str:
|
|
101
|
+
"""
|
|
102
|
+
Convert the pen's color to rgba string format.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
str: Color in 'rgba(r,g,b,a)' format where r,g,b are 0-255 and a is 0-1
|
|
106
|
+
|
|
107
|
+
"""
|
|
108
|
+
# Handle different color input formats
|
|
109
|
+
if isinstance(self.color, tuple):
|
|
110
|
+
if len(self.color) == 3: # RGB tuple
|
|
111
|
+
r, g, b = self.color
|
|
112
|
+
a = self.alpha
|
|
113
|
+
else: # RGBA tuple
|
|
114
|
+
r, g, b, a = self.color
|
|
115
|
+
# Convert 0-1 range to 0-255 for RGB
|
|
116
|
+
r, g, b = int(r * 255), int(g * 255), int(b * 255)
|
|
117
|
+
else: # String color (name or hex)
|
|
118
|
+
# Use matplotlib's color converter
|
|
119
|
+
rgba_vals = to_rgba(self.color, self.alpha)
|
|
120
|
+
# Convert 0-1 range to 0-255 for RGB
|
|
121
|
+
r, g, b = [int(x * 255) for x in rgba_vals[:3]]
|
|
122
|
+
a = rgba_vals[3]
|
|
123
|
+
|
|
124
|
+
return f"rgba({r}, {g}, {b}, {a})"
|
|
125
|
+
|
|
126
|
+
def get_contrast_color(self, background_luminance: float = 1.0) -> str:
|
|
127
|
+
"""
|
|
128
|
+
Returns 'white' or 'black' to provide the best contrast against the pen's color,
|
|
129
|
+
taking into account the alpha (transparency) value of the line.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
background_luminance (float): The luminance of the background (default is 1.0 for white).
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
str: 'white' or 'black'
|
|
136
|
+
|
|
137
|
+
"""
|
|
138
|
+
# Convert the pen's color to RGB (0-255 range) and get alpha
|
|
139
|
+
if isinstance(self.color, tuple):
|
|
140
|
+
if len(self.color) == 3: # RGB tuple
|
|
141
|
+
r, g, b = self.color
|
|
142
|
+
a = self.alpha
|
|
143
|
+
else: # RGBA tuple
|
|
144
|
+
r, g, b, a = self.color
|
|
145
|
+
r, g, b = int(r * 255), int(g * 255), int(b * 255)
|
|
146
|
+
else: # String color (name or hex)
|
|
147
|
+
rgba_vals = to_rgba(self.color, self.alpha)
|
|
148
|
+
r, g, b = [int(x * 255) for x in rgba_vals[:3]]
|
|
149
|
+
a = rgba_vals[3]
|
|
150
|
+
|
|
151
|
+
# Calculate relative luminance of the pen's color
|
|
152
|
+
def luminance(channel: float) -> float:
|
|
153
|
+
channel /= 255.0
|
|
154
|
+
return (
|
|
155
|
+
channel / 12.92
|
|
156
|
+
if channel <= 0.03928
|
|
157
|
+
else ((channel + 0.055) / 1.055) ** 2.4
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
color_luminance = (
|
|
161
|
+
0.2126 * luminance(r) + 0.7152 * luminance(g) + 0.0722 * luminance(b)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Blend the color luminance with the background luminance based on alpha
|
|
165
|
+
blended_luminance = (1 - a) * background_luminance + a * color_luminance
|
|
166
|
+
|
|
167
|
+
# Return white for dark blended colors, black for light blended colors
|
|
168
|
+
return "white" if blended_luminance < 0.5 else "black"
|
trendify/typing.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared numeric array type aliases (`VecN`, `MatN`) used across `trendify.plotting`/
|
|
3
|
+
`trendify.styling` for fields like `Trace2D.x`/`Trace2D.y` and `HistogramStyle.bins`. Backed by
|
|
4
|
+
`numpydantic.NDArray` for runtime pydantic validation, with a plain `TypeAlias` swapped in under
|
|
5
|
+
`TYPE_CHECKING` so static type checkers see an ordinary array/list/tuple union instead of
|
|
6
|
+
`numpydantic`'s shape-annotated generic.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING, Annotated
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from numpydantic import NDArray, Shape
|
|
13
|
+
|
|
14
|
+
__all__ = ["MatN", "VecN"]
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
type _VecBase = np.ndarray | list[float | int] | tuple[float | int, ...]
|
|
18
|
+
|
|
19
|
+
type VecN = _VecBase
|
|
20
|
+
"""An N-element numeric array of arbitrary length."""
|
|
21
|
+
|
|
22
|
+
type MatN = _VecBase
|
|
23
|
+
"""An NxN numeric matrix of arbitrary length."""
|
|
24
|
+
else:
|
|
25
|
+
VecN = Annotated[NDArray[Shape["*"], float | int], ...] # noqa: F722
|
|
26
|
+
MatN = Annotated[NDArray[Shape["*, *"], float | int], ...] # noqa: F722
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""FastAPI dashboard for browsing a `trendify.db` in the browser."""
|
|
2
|
+
|
|
3
|
+
from trendify.viewer import app as viewer_app
|
|
4
|
+
from trendify.viewer import plot_config, routes, tag_tree
|
|
5
|
+
from trendify.viewer.app import (
|
|
6
|
+
create_app,
|
|
7
|
+
create_app_from_env,
|
|
8
|
+
)
|
|
9
|
+
from trendify.viewer.plot_config import (
|
|
10
|
+
HoverMode,
|
|
11
|
+
InterpMode,
|
|
12
|
+
LineMode,
|
|
13
|
+
PlotConfig,
|
|
14
|
+
camel_case_dict,
|
|
15
|
+
)
|
|
16
|
+
from trendify.viewer.routes import (
|
|
17
|
+
api,
|
|
18
|
+
pages,
|
|
19
|
+
)
|
|
20
|
+
from trendify.viewer.tag_tree import (
|
|
21
|
+
TagNode,
|
|
22
|
+
build_tag_tree,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"HoverMode",
|
|
27
|
+
"InterpMode",
|
|
28
|
+
"LineMode",
|
|
29
|
+
"PlotConfig",
|
|
30
|
+
"TagNode",
|
|
31
|
+
"api",
|
|
32
|
+
"build_tag_tree",
|
|
33
|
+
"camel_case_dict",
|
|
34
|
+
"create_app",
|
|
35
|
+
"create_app_from_env",
|
|
36
|
+
"pages",
|
|
37
|
+
"plot_config",
|
|
38
|
+
"routes",
|
|
39
|
+
"tag_tree",
|
|
40
|
+
"viewer_app",
|
|
41
|
+
]
|
trendify/viewer/app.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI app factory for the trendify dashboard. See `trendify.cli`'s `serve` command for how
|
|
3
|
+
this gets launched.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import datetime
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from collections import OrderedDict
|
|
12
|
+
from collections.abc import AsyncGenerator, Sequence
|
|
13
|
+
from contextlib import asynccontextmanager
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
16
|
+
|
|
17
|
+
from fastapi import FastAPI
|
|
18
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
19
|
+
from fastapi.staticfiles import StaticFiles
|
|
20
|
+
from fastapi.templating import Jinja2Templates
|
|
21
|
+
|
|
22
|
+
from trendify.__about__ import __version__
|
|
23
|
+
from trendify.store.record_store import RecordStore
|
|
24
|
+
from trendify.viewer.hydration import HydrationRunner
|
|
25
|
+
from trendify.viewer.routes import api, pages
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
import socket
|
|
29
|
+
|
|
30
|
+
import uvicorn
|
|
31
|
+
|
|
32
|
+
__all__ = ["close_runners", "create_app", "create_app_from_env", "make_server"]
|
|
33
|
+
|
|
34
|
+
_DB_PATH_ENV_VAR = "TRENDIFY_DB_PATH"
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
_VIEWER_DIR = Path(__file__).parent
|
|
39
|
+
_TEMPLATES_DIR = _VIEWER_DIR / "templates"
|
|
40
|
+
_STATIC_DIR = _TEMPLATES_DIR / "static"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def create_app(
|
|
44
|
+
db_path: Path | str,
|
|
45
|
+
*,
|
|
46
|
+
show_db_path: bool = True,
|
|
47
|
+
cors_origins: Sequence[str] = (),
|
|
48
|
+
) -> FastAPI:
|
|
49
|
+
"""
|
|
50
|
+
Builds the dashboard FastAPI app for `db_path`.
|
|
51
|
+
|
|
52
|
+
The app runs on its own or mounted inside another FastAPI/Starlette app, either at a path
|
|
53
|
+
prefix (`host.mount("/viz", create_app(db_path))`) or on its own hostname, and needs nothing
|
|
54
|
+
from the host: no lifespan, no shared state. Pages and scripts build their URLs from the
|
|
55
|
+
prefix the app is mounted under, so they work wherever it is served.
|
|
56
|
+
|
|
57
|
+
All database and plot-building work runs on two dedicated worker threads
|
|
58
|
+
(`app.state.runner` for real requests and `app.state.hydration_runner` for background
|
|
59
|
+
prefetch, see `routes.api`), each with its own read-only `RecordStore` connection opened on
|
|
60
|
+
first use. A slow request therefore never blocks the event loop, which matters when that loop
|
|
61
|
+
is shared with a host site's other endpoints, and a prefetch never queues ahead of a click.
|
|
62
|
+
A SQLite connection belongs to the thread that opens it, which is why each one lives
|
|
63
|
+
entirely on its worker thread. The lifespan only closes those connections at shutdown; a
|
|
64
|
+
mounted app doesn't get one, and its connections then close with the interpreter.
|
|
65
|
+
|
|
66
|
+
This app is meant to run as a single process (each process would keep its own caches).
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
db_path (Path | str): path to the `.db` file to serve. Must already exist.
|
|
70
|
+
show_db_path (bool): show the server-side path of `db_path` in the page header. Turn
|
|
71
|
+
off when the viewer is exposed to people who shouldn't see the server's file layout.
|
|
72
|
+
cors_origins (Sequence[str]): origins whose browser JavaScript may call this app's API
|
|
73
|
+
from another origin (an exact list; no wildcard is added for you). Empty by
|
|
74
|
+
default, which adds no CORS headers. Not needed to show the viewer in an `<iframe>`:
|
|
75
|
+
the page then calls its own origin. Only needed if the host page's own scripts fetch
|
|
76
|
+
from the viewer's API.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
(FastAPI): the configured app, ready for `uvicorn.run`, `host.mount`, or a `TestClient`.
|
|
80
|
+
|
|
81
|
+
"""
|
|
82
|
+
db_path = Path(db_path)
|
|
83
|
+
RecordStore.open(
|
|
84
|
+
db_path, readonly=True
|
|
85
|
+
).close() # fail now, not on the first request
|
|
86
|
+
|
|
87
|
+
@asynccontextmanager
|
|
88
|
+
async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
|
|
89
|
+
try:
|
|
90
|
+
yield
|
|
91
|
+
finally:
|
|
92
|
+
close_runners(app)
|
|
93
|
+
|
|
94
|
+
app = FastAPI(title="trendify", version=__version__, lifespan=lifespan)
|
|
95
|
+
app.state.runner = HydrationRunner(db_path)
|
|
96
|
+
app.state.hydration_runner = HydrationRunner(db_path)
|
|
97
|
+
app.state.response_cache = OrderedDict()
|
|
98
|
+
app.state.db_path = db_path
|
|
99
|
+
app.state.db_mtime = None
|
|
100
|
+
|
|
101
|
+
if cors_origins:
|
|
102
|
+
app.add_middleware(
|
|
103
|
+
CORSMiddleware,
|
|
104
|
+
allow_origins=list(cors_origins),
|
|
105
|
+
allow_methods=["GET"],
|
|
106
|
+
allow_headers=["x-trendify-hydrate"],
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
templates = Jinja2Templates(directory=_TEMPLATES_DIR)
|
|
110
|
+
# Jinja's `Environment.globals` is inferred from its default namespace (range, dict, ...),
|
|
111
|
+
# so it isn't typed as the open `dict[str, Any]` it behaves as.
|
|
112
|
+
env_globals = cast("dict[str, Any]", templates.env.globals)
|
|
113
|
+
env_globals["current_year"] = datetime.datetime.now().year
|
|
114
|
+
env_globals["app_version"] = __version__
|
|
115
|
+
env_globals["db_path"] = str(db_path) if show_db_path else ""
|
|
116
|
+
app.state.templates = templates
|
|
117
|
+
|
|
118
|
+
app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static")
|
|
119
|
+
app.include_router(pages.router)
|
|
120
|
+
app.include_router(api.router, prefix="/api")
|
|
121
|
+
|
|
122
|
+
return app
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def create_app_from_env() -> FastAPI:
|
|
126
|
+
"""
|
|
127
|
+
`create_app`, reading `db_path` from the `TRENDIFY_DB_PATH` environment variable instead of
|
|
128
|
+
a direct argument.
|
|
129
|
+
|
|
130
|
+
This exists solely as a uvicorn ASGI factory target (`uvicorn.run(..., factory=True)`) for
|
|
131
|
+
`trendify serve --reload`: uvicorn's reload mode re-imports the app in a fresh subprocess on
|
|
132
|
+
every file change, so it needs an importable `module:attribute` string rather than a live app
|
|
133
|
+
instance; there is no other way to hand it a runtime value like `db_path` across that
|
|
134
|
+
re-import boundary.
|
|
135
|
+
"""
|
|
136
|
+
db_path = os.environ[_DB_PATH_ENV_VAR]
|
|
137
|
+
return create_app(Path(db_path))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def close_runners(app: FastAPI) -> None:
|
|
141
|
+
"""
|
|
142
|
+
Stops a viewer app's worker threads and releases every request still waiting on them (those
|
|
143
|
+
answer 503), without waiting for a plot that is mid-build. The app calls this itself at
|
|
144
|
+
shutdown; a server that wants to stop quickly calls it as soon as shutdown begins (see
|
|
145
|
+
`make_server`). Safe to call more than once.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
app (FastAPI): an app made by `create_app`.
|
|
149
|
+
|
|
150
|
+
"""
|
|
151
|
+
app.state.runner.close()
|
|
152
|
+
app.state.hydration_runner.close()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def make_server(
|
|
156
|
+
app: FastAPI,
|
|
157
|
+
*,
|
|
158
|
+
host: str,
|
|
159
|
+
port: int,
|
|
160
|
+
log_level: str = "critical",
|
|
161
|
+
viewers: Sequence[FastAPI] | None = None,
|
|
162
|
+
) -> uvicorn.Server:
|
|
163
|
+
"""
|
|
164
|
+
Builds a uvicorn server for `app` that stops promptly and quietly on Ctrl+C.
|
|
165
|
+
|
|
166
|
+
The page prefetches every tag in the background, so when Ctrl+C arrives there can be many
|
|
167
|
+
requests in flight, and plain uvicorn waits for all of them before it exits (or, after a
|
|
168
|
+
timeout, cancels them with a traceback each). This server releases the viewer's waiting
|
|
169
|
+
requests the moment shutdown begins, so they finish at once with a 503.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
app (FastAPI): the app to serve.
|
|
173
|
+
host (str): interface to bind.
|
|
174
|
+
port (int): port to bind.
|
|
175
|
+
log_level (str): uvicorn log level.
|
|
176
|
+
viewers (Sequence[FastAPI] | None): viewer apps (from `create_app`) to release at
|
|
177
|
+
shutdown. Defaults to `app` itself; pass the mounted viewers when `app` is a host
|
|
178
|
+
site that mounts them.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
(uvicorn.Server): call `.run()` on it.
|
|
182
|
+
|
|
183
|
+
"""
|
|
184
|
+
import uvicorn
|
|
185
|
+
|
|
186
|
+
targets = (app,) if viewers is None else tuple(viewers)
|
|
187
|
+
|
|
188
|
+
class ViewerServer(uvicorn.Server):
|
|
189
|
+
async def shutdown(self, sockets: list[socket.socket] | None = None) -> None:
|
|
190
|
+
for viewer in targets:
|
|
191
|
+
close_runners(viewer)
|
|
192
|
+
await super().shutdown(sockets)
|
|
193
|
+
|
|
194
|
+
def run(self, sockets: list[socket.socket] | None = None) -> None:
|
|
195
|
+
try:
|
|
196
|
+
super().run(sockets)
|
|
197
|
+
except KeyboardInterrupt:
|
|
198
|
+
pass # uvicorn re-raises Ctrl+C once it has shut down cleanly; that's the end
|
|
199
|
+
|
|
200
|
+
return ViewerServer(
|
|
201
|
+
uvicorn.Config(
|
|
202
|
+
app,
|
|
203
|
+
host=host,
|
|
204
|
+
port=port,
|
|
205
|
+
log_level=log_level,
|
|
206
|
+
timeout_graceful_shutdown=3, # a safety net; requests are already released
|
|
207
|
+
)
|
|
208
|
+
)
|