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.
Files changed (76) hide show
  1. trendify/__about__.py +14 -0
  2. trendify/__init__.py +250 -0
  3. trendify/base/__init__.py +36 -0
  4. trendify/base/helpers.py +76 -0
  5. trendify/base/pen.py +161 -0
  6. trendify/base/record.py +148 -0
  7. trendify/cli.py +403 -0
  8. trendify/color.py +260 -0
  9. trendify/examples.py +474 -0
  10. trendify/formats/__init__.py +28 -0
  11. trendify/formats/format2d.py +128 -0
  12. trendify/formats/table.py +55 -0
  13. trendify/generator/__init__.py +38 -0
  14. trendify/generator/generate.py +167 -0
  15. trendify/generator/histogrammer.py +62 -0
  16. trendify/generator/render.py +195 -0
  17. trendify/generator/table_builder.py +194 -0
  18. trendify/generator/xy_data_plotter.py +195 -0
  19. trendify/log.py +195 -0
  20. trendify/pipeline.py +136 -0
  21. trendify/plotting/__init__.py +48 -0
  22. trendify/plotting/axline.py +122 -0
  23. trendify/plotting/figure.py +389 -0
  24. trendify/plotting/histogram.py +282 -0
  25. trendify/plotting/point.py +89 -0
  26. trendify/plotting/scatter.py +100 -0
  27. trendify/plotting/trace.py +129 -0
  28. trendify/progress.py +43 -0
  29. trendify/store/__init__.py +30 -0
  30. trendify/store/db.py +119 -0
  31. trendify/store/record_store.py +448 -0
  32. trendify/store/tags.py +65 -0
  33. trendify/styling/__init__.py +30 -0
  34. trendify/styling/grid.py +125 -0
  35. trendify/styling/legend.py +217 -0
  36. trendify/styling/marker.py +168 -0
  37. trendify/typing.py +26 -0
  38. trendify/viewer/__init__.py +41 -0
  39. trendify/viewer/app.py +208 -0
  40. trendify/viewer/hydration.py +181 -0
  41. trendify/viewer/plot_config.py +102 -0
  42. trendify/viewer/routes/__init__.py +6 -0
  43. trendify/viewer/routes/api.py +360 -0
  44. trendify/viewer/routes/pages.py +39 -0
  45. trendify/viewer/tag_tree.py +148 -0
  46. trendify/viewer/templates/base.html +63 -0
  47. trendify/viewer/templates/index.html +131 -0
  48. trendify/viewer/templates/partials/__init__.py +1 -0
  49. trendify/viewer/templates/partials/footer.html +15 -0
  50. trendify/viewer/templates/partials/nav.html +181 -0
  51. trendify/viewer/templates/partials/plot_view.html +221 -0
  52. trendify/viewer/templates/partials/sidebar.html +163 -0
  53. trendify/viewer/templates/partials/table_view.html +43 -0
  54. trendify/viewer/templates/static/__init__.py +1 -0
  55. trendify/viewer/templates/static/assets/__init__.py +1 -0
  56. trendify/viewer/templates/static/assets/main-logo-white-bg.svg +75 -0
  57. trendify/viewer/templates/static/assets/main-logo.svg +66 -0
  58. trendify/viewer/templates/static/css/app.css +75 -0
  59. trendify/viewer/templates/static/js/main.js +2 -0
  60. trendify/viewer/templates/static/vendored/__init__.py +1 -0
  61. trendify/viewer/templates/static/vendored/alpine-3.15.12.min.js +5 -0
  62. trendify/viewer/templates/static/vendored/alpine-LICENSE.md +21 -0
  63. trendify/viewer/templates/static/vendored/dataTables-LICENSE.txt +3 -0
  64. trendify/viewer/templates/static/vendored/dataTables.dataTables.min.css +1 -0
  65. trendify/viewer/templates/static/vendored/dataTables.min.js +4 -0
  66. trendify/viewer/templates/static/vendored/jquery-3.7.1.min.js +2 -0
  67. trendify/viewer/templates/static/vendored/jquery-LICENSE.txt +3 -0
  68. trendify/viewer/templates/static/vendored/plotly-3.6.0.min.js +3882 -0
  69. trendify/viewer/templates/static/vendored/plotly-LICENSE.txt +21 -0
  70. trendify/viewer/templates/static/vendored/tailwind-4.3.2.global.js +947 -0
  71. trendify/viewer/templates/static/vendored/tailwind-LICENSE +21 -0
  72. trendify2-2.0.4.dist-info/METADATA +107 -0
  73. trendify2-2.0.4.dist-info/RECORD +76 -0
  74. trendify2-2.0.4.dist-info/WHEEL +4 -0
  75. trendify2-2.0.4.dist-info/entry_points.txt +2 -0
  76. 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
+ )