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,195 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Draws `Point2D`/`Trace2D`/`AxLine` records onto a matplotlib figure. Fed directly by
|
|
3
|
+
`RecordStore` query results, so there's no directory-loading state to manage.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from matplotlib.axes import Axes
|
|
12
|
+
from matplotlib.collections import LineCollection
|
|
13
|
+
from matplotlib.colors import to_rgba
|
|
14
|
+
from matplotlib.lines import Line2D
|
|
15
|
+
from matplotlib.transforms import Transform
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
|
|
18
|
+
from trendify.base.helpers import Tag
|
|
19
|
+
from trendify.base.pen import Pen
|
|
20
|
+
from trendify.plotting.axline import AxLine, LineOrientation
|
|
21
|
+
from trendify.plotting.figure import SingleAxisFigure
|
|
22
|
+
from trendify.plotting.point import Point2D
|
|
23
|
+
from trendify.plotting.scatter import Scatter2D
|
|
24
|
+
from trendify.plotting.trace import Trace2D
|
|
25
|
+
|
|
26
|
+
__all__ = ["XYDataPlotter"]
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _nan_separated(segments: list[np.ndarray]) -> np.ndarray:
|
|
32
|
+
"""
|
|
33
|
+
Stacks `(N, 2)` vertex arrays into one array with a NaN row after each, so a single
|
|
34
|
+
`Line2D` can stand in for many separate polylines.
|
|
35
|
+
"""
|
|
36
|
+
gap = np.full((1, 2), np.nan)
|
|
37
|
+
return np.concatenate([part for segment in segments for part in (segment, gap)])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _add_batched_lines(
|
|
41
|
+
ax: Axes,
|
|
42
|
+
segments: list[np.ndarray],
|
|
43
|
+
pens: list[Pen],
|
|
44
|
+
transform: Transform | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Draws many lines as one `LineCollection` per z-order instead of one `Line2D` each. Every
|
|
48
|
+
artist costs matplotlib transform, clipping and draw work, which dominates when a tag
|
|
49
|
+
holds thousands of lines. Lines keep their given order within a z-order, so overlap is
|
|
50
|
+
unchanged. Each line keeps its own color, width, linestyle and alpha.
|
|
51
|
+
|
|
52
|
+
A collection is not a `Line2D`, so two things `Line2D`s used to provide are restored here:
|
|
53
|
+
- **Legend entries**: one empty, labeled `Line2D` per distinct label (the last pen with that
|
|
54
|
+
label wins, as when handles were de-duplicated by label).
|
|
55
|
+
- **Data limits and `loc="best"` legend placement**: both are computed from `Line2D`
|
|
56
|
+
geometry, and matplotlib ignores `LineCollection`s there. One invisible `Line2D` tracing
|
|
57
|
+
every segment carries the geometry, so the axes autoscale and the legend avoids the data
|
|
58
|
+
exactly as before. It is never drawn.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
ax (Axes): axes to draw onto
|
|
62
|
+
segments (list[np.ndarray]): one `(N, 2)` array of vertices per line
|
|
63
|
+
pens (list[Pen]): style of each line, parallel to `segments`
|
|
64
|
+
transform (Transform | None): transform for the vertices; `None` means data coordinates
|
|
65
|
+
|
|
66
|
+
"""
|
|
67
|
+
by_zorder: dict[float, list[int]] = {}
|
|
68
|
+
for i, pen in enumerate(pens):
|
|
69
|
+
by_zorder.setdefault(pen.zorder, []).append(i)
|
|
70
|
+
for zorder, indices in by_zorder.items():
|
|
71
|
+
collection = LineCollection(
|
|
72
|
+
[segments[i] for i in indices],
|
|
73
|
+
colors=[to_rgba(pens[i].color, pens[i].alpha) for i in indices],
|
|
74
|
+
linewidths=[pens[i].size for i in indices],
|
|
75
|
+
linestyles=[pens[i].linestyle for i in indices],
|
|
76
|
+
zorder=zorder,
|
|
77
|
+
)
|
|
78
|
+
if transform is not None:
|
|
79
|
+
collection.set_transform(transform)
|
|
80
|
+
ax.add_collection(collection, autolim=False)
|
|
81
|
+
|
|
82
|
+
for pen in {pen.label: pen for pen in pens if pen.label is not None}.values():
|
|
83
|
+
ax.plot([], [], **pen.as_scatter_plot_kwargs())
|
|
84
|
+
|
|
85
|
+
vertices = _nan_separated(segments)
|
|
86
|
+
stand_in = Line2D(vertices[:, 0], vertices[:, 1], visible=False, label="_nolegend_")
|
|
87
|
+
if transform is not None:
|
|
88
|
+
stand_in.set_transform(transform)
|
|
89
|
+
ax.add_line(stand_in)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _plot_traces(ax: Axes, traces: list[Trace2D]) -> None:
|
|
93
|
+
"""
|
|
94
|
+
Plots traces, batching the plain ones (no marker, visible line) with `_add_batched_lines`.
|
|
95
|
+
Traces with markers or no line are drawn individually afterward, so at an equal z-order
|
|
96
|
+
they paint over the batched lines.
|
|
97
|
+
"""
|
|
98
|
+
plain = [t for t in traces if t.marker is None and t.pen.linestyle is not None]
|
|
99
|
+
if plain:
|
|
100
|
+
_add_batched_lines(
|
|
101
|
+
ax, [np.column_stack([t.x, t.y]) for t in plain], [t.pen for t in plain]
|
|
102
|
+
)
|
|
103
|
+
for trace in traces:
|
|
104
|
+
if trace.marker is not None or trace.pen.linestyle is None:
|
|
105
|
+
trace.plot_to_ax(ax)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _plot_axlines(ax: Axes, axlines: list[AxLine]) -> None:
|
|
109
|
+
"""
|
|
110
|
+
Plots axis lines, batching each orientation with `_add_batched_lines`. Vertices use the
|
|
111
|
+
same blended transform `Axes.axhline`/`axvline` use (axes fraction along the line, data
|
|
112
|
+
value across it), so the lines span the full axes and only the across-axis limit autoscales.
|
|
113
|
+
"""
|
|
114
|
+
plain = [a for a in axlines if a.pen.linestyle is not None]
|
|
115
|
+
horizontal = [a for a in plain if a.orientation == LineOrientation.HORIZONTAL]
|
|
116
|
+
vertical = [a for a in plain if a.orientation == LineOrientation.VERTICAL]
|
|
117
|
+
if horizontal:
|
|
118
|
+
_add_batched_lines(
|
|
119
|
+
ax,
|
|
120
|
+
[np.array([[0.0, a.value], [1.0, a.value]]) for a in horizontal],
|
|
121
|
+
[a.pen for a in horizontal],
|
|
122
|
+
ax.get_yaxis_transform(which="grid"),
|
|
123
|
+
)
|
|
124
|
+
if vertical:
|
|
125
|
+
_add_batched_lines(
|
|
126
|
+
ax,
|
|
127
|
+
[np.array([[a.value, 0.0], [a.value, 1.0]]) for a in vertical],
|
|
128
|
+
[a.pen for a in vertical],
|
|
129
|
+
ax.get_xaxis_transform(which="grid"),
|
|
130
|
+
)
|
|
131
|
+
for axline in axlines:
|
|
132
|
+
if axline.pen.linestyle is None:
|
|
133
|
+
axline.plot_to_ax(ax)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class XYDataPlotter(BaseModel):
|
|
137
|
+
"""
|
|
138
|
+
Draws `Point2D`/`Scatter2D`/`Trace2D`/`AxLine` records sharing a tag onto a matplotlib
|
|
139
|
+
axes.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def handle_points_and_traces(
|
|
144
|
+
cls,
|
|
145
|
+
tag: Tag,
|
|
146
|
+
points: list[Point2D],
|
|
147
|
+
traces: list[Trace2D],
|
|
148
|
+
axlines: list[AxLine],
|
|
149
|
+
scatters: list[Scatter2D],
|
|
150
|
+
saf: SingleAxisFigure | None = None,
|
|
151
|
+
) -> SingleAxisFigure:
|
|
152
|
+
"""
|
|
153
|
+
Plots points (scattered, grouped by marker), scatters, traces, and axlines onto `saf`.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
tag (Tag): tag these records belong to (used only if a new figure is created)
|
|
157
|
+
points (list[Point2D]): points to scatter, grouped by `Marker` (each distinct
|
|
158
|
+
marker becomes one `ax.scatter` call/series)
|
|
159
|
+
traces (list[Trace2D]): traces to plot
|
|
160
|
+
axlines (list[AxLine]): axis lines to plot
|
|
161
|
+
scatters (list[Scatter2D]): bulk scatter arrays to plot, each already carrying
|
|
162
|
+
its own single shared `Marker`
|
|
163
|
+
saf (SingleAxisFigure | None): figure to draw onto; a new one is created if `None`
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
(SingleAxisFigure): the figure drawn onto
|
|
167
|
+
|
|
168
|
+
"""
|
|
169
|
+
if saf is None:
|
|
170
|
+
saf = SingleAxisFigure.new(tag=tag)
|
|
171
|
+
|
|
172
|
+
logger.debug(
|
|
173
|
+
f"Plotting {len(points)} point(s), {len(traces)} trace(s), {len(axlines)} "
|
|
174
|
+
f"axline(s), {len(scatters)} scatter(s) for {tag = }"
|
|
175
|
+
)
|
|
176
|
+
if points:
|
|
177
|
+
markers = set(p.marker for p in points)
|
|
178
|
+
for marker in markers:
|
|
179
|
+
matching_points = [p for p in points if p.marker == marker]
|
|
180
|
+
x = [p.x for p in matching_points]
|
|
181
|
+
y = [p.y for p in matching_points]
|
|
182
|
+
if x and y:
|
|
183
|
+
if marker is not None:
|
|
184
|
+
saf.ax.scatter(x, y, **marker.as_scatter_plot_kwargs())
|
|
185
|
+
else:
|
|
186
|
+
saf.ax.scatter(x, y)
|
|
187
|
+
|
|
188
|
+
for scatter in scatters:
|
|
189
|
+
scatter.plot_to_ax(saf.ax)
|
|
190
|
+
|
|
191
|
+
_plot_traces(saf.ax, traces)
|
|
192
|
+
_plot_axlines(saf.ax, axlines)
|
|
193
|
+
saf.ax.autoscale_view()
|
|
194
|
+
|
|
195
|
+
return saf
|
trendify/log.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Trendify's logging setup: a Rich-formatted console handler plus a rotating log file
|
|
3
|
+
(`setup_logger`), a multiprocessing-safe queue/listener pair for routing worker-process log
|
|
4
|
+
records back through those same handlers (`create_queue_listener`/`worker_init`), and
|
|
5
|
+
`set_log_level` for a Python caller who just wants to control verbosity.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import multiprocessing
|
|
12
|
+
from logging.handlers import QueueHandler, QueueListener, RotatingFileHandler
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.logging import RichHandler
|
|
18
|
+
from rich.theme import Theme
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"create_queue_listener",
|
|
22
|
+
"get_logger",
|
|
23
|
+
"set_log_level",
|
|
24
|
+
"setup_logger",
|
|
25
|
+
"worker_init",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
# Library-standard courtesy: don't let logging print "No handlers could be found for logger
|
|
29
|
+
# trendify.*" if the consuming application never configures logging at all. This only affects
|
|
30
|
+
# the "nothing configured anywhere" case. An application that calls `logging.basicConfig()`
|
|
31
|
+
# or `setup_logger()` still sees everything, since a `NullHandler` doesn't stop propagation.
|
|
32
|
+
logging.getLogger("trendify").addHandler(logging.NullHandler())
|
|
33
|
+
|
|
34
|
+
theme = Theme(
|
|
35
|
+
{
|
|
36
|
+
"logging.level.debug": "bold dodger_blue1",
|
|
37
|
+
"logging.level.info": "bold green",
|
|
38
|
+
"logging.level.warning": "bold yellow",
|
|
39
|
+
"logging.level.error": "bold red",
|
|
40
|
+
"path": "dim white",
|
|
41
|
+
"message": "white",
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TerminalFilter(logging.Filter):
|
|
47
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
48
|
+
# Block if 'file_only' is True
|
|
49
|
+
return not getattr(record, "file_only", False)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class FileFilter(logging.Filter):
|
|
53
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
54
|
+
# Block if 'terminal_only' is True
|
|
55
|
+
return not getattr(record, "terminal_only", False)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_logger(name: str) -> logging.Logger:
|
|
59
|
+
logger = logging.getLogger(name)
|
|
60
|
+
|
|
61
|
+
if not logger.hasHandlers():
|
|
62
|
+
logger.addHandler(logging.NullHandler())
|
|
63
|
+
return logger
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def setup_logger(
|
|
67
|
+
level=logging.INFO,
|
|
68
|
+
terminal: bool = True,
|
|
69
|
+
log_file: Path | str | None = Path("trendify.log"),
|
|
70
|
+
max_bytes: int = 10 * 1024 * 1024, # 10MB
|
|
71
|
+
backup_count: int = 5,
|
|
72
|
+
) -> logging.Logger:
|
|
73
|
+
"""Shortcut to a sensible trendify logger using Rich for the terminal."""
|
|
74
|
+
logger = logging.getLogger()
|
|
75
|
+
logger.setLevel(level)
|
|
76
|
+
|
|
77
|
+
# avoid double logging by preventing logs from being sent to the root logger
|
|
78
|
+
logger.propagate = False
|
|
79
|
+
|
|
80
|
+
if not terminal and log_file is None:
|
|
81
|
+
return logger
|
|
82
|
+
|
|
83
|
+
# clear existing handlers
|
|
84
|
+
if logger.hasHandlers():
|
|
85
|
+
logger.handlers.clear()
|
|
86
|
+
|
|
87
|
+
# --- RICH HANDLER FOR TERMINAL ---
|
|
88
|
+
if terminal:
|
|
89
|
+
console = Console(theme=theme)
|
|
90
|
+
rich_handler = RichHandler(
|
|
91
|
+
console=console,
|
|
92
|
+
show_time=True,
|
|
93
|
+
log_time_format="[%Y-%m-%d %H:%M:%S]",
|
|
94
|
+
show_path=True,
|
|
95
|
+
enable_link_path=False,
|
|
96
|
+
# highlighter=None,
|
|
97
|
+
markup=True, # allows color in the log message itself currently disabled in case an array is logged. Square brackets will break logging.
|
|
98
|
+
rich_tracebacks=True,
|
|
99
|
+
)
|
|
100
|
+
rich_handler.addFilter(TerminalFilter())
|
|
101
|
+
logger.addHandler(rich_handler)
|
|
102
|
+
|
|
103
|
+
# --- PLAIN TEXT HANDLER ---
|
|
104
|
+
if log_file is not None:
|
|
105
|
+
log_file = Path(log_file)
|
|
106
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
|
|
108
|
+
file_format = (
|
|
109
|
+
"%(asctime)s [%(levelname)s] %(pathname)s:%(lineno)d - %(message)s"
|
|
110
|
+
)
|
|
111
|
+
file_handler = RotatingFileHandler(
|
|
112
|
+
log_file, maxBytes=max_bytes, backupCount=backup_count
|
|
113
|
+
)
|
|
114
|
+
file_handler.setFormatter(logging.Formatter(file_format))
|
|
115
|
+
file_handler.addFilter(FileFilter())
|
|
116
|
+
logger.addHandler(file_handler)
|
|
117
|
+
|
|
118
|
+
return logger
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def create_queue_listener(
|
|
122
|
+
*handlers: logging.Handler,
|
|
123
|
+
) -> tuple[multiprocessing.Queue[Any], QueueListener]:
|
|
124
|
+
"""
|
|
125
|
+
Creates a multiprocessing-safe log queue plus a `QueueListener` that drains it in the
|
|
126
|
+
calling process and dispatches records to `handlers`.
|
|
127
|
+
|
|
128
|
+
This is what makes multiprocess logging safe, including on Windows: pair it with
|
|
129
|
+
`worker_init` in each worker process, so that no worker ever opens the real console/file
|
|
130
|
+
handlers directly. Only the listener, running in the parent process, does that. On Windows,
|
|
131
|
+
the default `spawn` start method means workers don't inherit the parent's configured
|
|
132
|
+
handlers at all; on POSIX with `fork`, workers *do* inherit them, which is its own hazard
|
|
133
|
+
(multiple processes writing to the same file concurrently). Routing everything through one
|
|
134
|
+
queue and one listener avoids both failure modes uniformly.
|
|
135
|
+
|
|
136
|
+
Caller owns the listener's lifecycle: call `.start()` before spinning up worker processes
|
|
137
|
+
and `.stop()` once they've finished (a `try`/`finally` around a `ProcessPoolExecutor` block
|
|
138
|
+
is the natural place, since `.stop()` blocks until every already-queued record has been
|
|
139
|
+
dispatched, so no log messages are lost when the pool shuts down).
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
*handlers (logging.Handler): the real handlers worker log records should ultimately
|
|
143
|
+
reach (e.g. the handlers already attached to the root logger by `setup_logger`)
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
(tuple[multiprocessing.Queue, QueueListener]): the queue to hand to worker processes
|
|
147
|
+
(via `ProcessPoolExecutor`'s `initializer`/`initargs`, calling `worker_init` in
|
|
148
|
+
each) and the not-yet-started listener draining it
|
|
149
|
+
|
|
150
|
+
"""
|
|
151
|
+
log_queue: multiprocessing.Queue[Any] = multiprocessing.Queue(-1)
|
|
152
|
+
listener = QueueListener(log_queue, *handlers, respect_handler_level=True)
|
|
153
|
+
return log_queue, listener
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def worker_init(queue: multiprocessing.Queue[Any], level: int) -> None:
|
|
157
|
+
"""
|
|
158
|
+
Initializes a worker process's root logger to ship every record through `queue` instead of
|
|
159
|
+
touching a real handler directly. Pair with `create_queue_listener`, called once in the
|
|
160
|
+
parent process before workers start.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
queue (multiprocessing.Queue): queue created by `create_queue_listener`
|
|
164
|
+
level (int): log level to apply to this worker's root logger
|
|
165
|
+
|
|
166
|
+
"""
|
|
167
|
+
root = logging.getLogger()
|
|
168
|
+
|
|
169
|
+
# Clear any handlers inherited via fork (POSIX default) so this worker never writes
|
|
170
|
+
# directly to a file/console shared with the parent or sibling workers. Only the
|
|
171
|
+
# QueueHandler below is installed, so everything funnels through the parent's
|
|
172
|
+
# QueueListener.
|
|
173
|
+
root.handlers.clear()
|
|
174
|
+
|
|
175
|
+
handler = QueueHandler(queue)
|
|
176
|
+
root.addHandler(handler)
|
|
177
|
+
|
|
178
|
+
root.setLevel(level)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def set_log_level(level: int | str, logger_name: str = "trendify") -> None:
|
|
182
|
+
"""
|
|
183
|
+
Sets the log level for `logger_name` (defaults to the top-level `"trendify"` logger, i.e.
|
|
184
|
+
every `trendify.*` module logger, since none of them set an explicit level of their own
|
|
185
|
+
and so inherit whatever their nearest ancestor has set). This is the supported way for
|
|
186
|
+
a Python caller to control trendify's log verbosity without needing to call `setup_logger`
|
|
187
|
+
or otherwise touch logging internals directly.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
level (int | str): a `logging` level (e.g. `logging.DEBUG`) or level name (e.g.
|
|
191
|
+
`"DEBUG"`)
|
|
192
|
+
logger_name (str): logger to set the level on; defaults to `"trendify"`
|
|
193
|
+
|
|
194
|
+
"""
|
|
195
|
+
logging.getLogger(logger_name).setLevel(level)
|
trendify/pipeline.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Top-level pipeline API: generate -> render -> include files, backed by one `RecordStore`
|
|
3
|
+
`.db` file per output directory. `TrendifyPipeline` is the single implementation both the
|
|
4
|
+
`trendify` CLI (`trendify.cli`) and direct Python callers use. The CLI is a thin
|
|
5
|
+
argument-parsing layer over this class, not a separate code path.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
from trendify.base.record import RecordGenerator
|
|
16
|
+
from trendify.generator.generate import generate_records
|
|
17
|
+
from trendify.generator.render import render_assets
|
|
18
|
+
from trendify.progress import ProgressCallback
|
|
19
|
+
|
|
20
|
+
__all__ = ["TrendifyPipeline"]
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TrendifyPipeline(BaseModel):
|
|
26
|
+
"""
|
|
27
|
+
Runs the trendify pipeline (generate -> render -> include files) against one output
|
|
28
|
+
directory, backed by a single `RecordStore` `.db` file.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
output_dir: Path
|
|
32
|
+
"""Directory the pipeline reads/writes under. Holds `trendify.db` (the `RecordStore`) and
|
|
33
|
+
an `assets/` subdirectory (rendered CSVs/figures)."""
|
|
34
|
+
|
|
35
|
+
n_procs: int = Field(default=1, ge=1)
|
|
36
|
+
"""Number of worker processes used by `generate`."""
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def db_path(self) -> Path:
|
|
40
|
+
return self.output_dir / "trendify.db"
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def assets_dir(self) -> Path:
|
|
44
|
+
return self.output_dir / "assets"
|
|
45
|
+
|
|
46
|
+
def generate(
|
|
47
|
+
self,
|
|
48
|
+
record_generator: RecordGenerator,
|
|
49
|
+
data_dirs: list[Path],
|
|
50
|
+
on_progress: ProgressCallback | None = None,
|
|
51
|
+
) -> int:
|
|
52
|
+
"""
|
|
53
|
+
Maps `record_generator` over `data_dirs`, writing records directly to this
|
|
54
|
+
pipeline's `RecordStore`. See `trendify.generator.generate.generate_records`.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
record_generator (RecordGenerator): callable mapped over `data_dirs`.
|
|
58
|
+
data_dirs (list[Path]): directories to map `record_generator` over.
|
|
59
|
+
on_progress (ProgressCallback | None): called once per directory finished, e.g.
|
|
60
|
+
to report status from a containerized deployment back to an end user.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
(int): total number of records written
|
|
64
|
+
|
|
65
|
+
"""
|
|
66
|
+
logger.info(
|
|
67
|
+
f"Generating records for {len(data_dirs)} directory(ies) into {self.db_path} "
|
|
68
|
+
f"({self.n_procs} worker process{'es' if self.n_procs > 1 else ''})"
|
|
69
|
+
)
|
|
70
|
+
total = generate_records(
|
|
71
|
+
record_generator=record_generator,
|
|
72
|
+
data_dirs=data_dirs,
|
|
73
|
+
db_path=self.db_path,
|
|
74
|
+
n_procs=self.n_procs,
|
|
75
|
+
on_progress=on_progress,
|
|
76
|
+
)
|
|
77
|
+
logger.info(f"Generated {total} record(s)")
|
|
78
|
+
return total
|
|
79
|
+
|
|
80
|
+
def render(self, on_progress: ProgressCallback | None = None) -> None:
|
|
81
|
+
"""
|
|
82
|
+
Renders CSV tables and matplotlib figures for every tag into `self.assets_dir`. See
|
|
83
|
+
`trendify.generator.render.render_assets`.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
on_progress (ProgressCallback | None): called once per tag finished, e.g. to
|
|
87
|
+
report status from a containerized deployment back to an end user.
|
|
88
|
+
|
|
89
|
+
"""
|
|
90
|
+
logger.info(
|
|
91
|
+
f"Rendering assets from '{self.db_path}' into '{self.assets_dir}' "
|
|
92
|
+
f"({self.n_procs} worker process{'es' if self.n_procs > 1 else ''})"
|
|
93
|
+
)
|
|
94
|
+
render_assets(
|
|
95
|
+
self.db_path,
|
|
96
|
+
self.assets_dir,
|
|
97
|
+
n_procs=self.n_procs,
|
|
98
|
+
on_progress=on_progress,
|
|
99
|
+
)
|
|
100
|
+
logger.info(f"Finished rendering assets into '{self.assets_dir}'")
|
|
101
|
+
|
|
102
|
+
def run(
|
|
103
|
+
self,
|
|
104
|
+
record_generator: RecordGenerator,
|
|
105
|
+
data_dirs: list[Path],
|
|
106
|
+
on_progress: ProgressCallback | None = None,
|
|
107
|
+
) -> int:
|
|
108
|
+
"""
|
|
109
|
+
Runs the full pipeline: generate, then render (unless all three asset kinds are
|
|
110
|
+
suppressed).
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
record_generator (RecordGenerator): callable mapped over `data_dirs`.
|
|
114
|
+
data_dirs (list[Path]): directories to map `record_generator` over.
|
|
115
|
+
on_progress (ProgressCallback | None): called once per unit of work finished in
|
|
116
|
+
*both* stages -- `ProgressEvent.stage` distinguishes "generate" from "render"
|
|
117
|
+
for a single handler covering the whole run.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
(int): total number of records written by `generate`
|
|
121
|
+
|
|
122
|
+
"""
|
|
123
|
+
logger.info(
|
|
124
|
+
f"Running full pipeline (generate -> render) for '{self.output_dir}'"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
total = self.generate(
|
|
128
|
+
record_generator=record_generator,
|
|
129
|
+
data_dirs=data_dirs,
|
|
130
|
+
on_progress=on_progress,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
self.render(on_progress=on_progress)
|
|
134
|
+
|
|
135
|
+
logger.info(f"Finished full pipeline for '{self.output_dir}'")
|
|
136
|
+
return total
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Plottable record types (points, traces, scatters, axis lines, histogram entries) and figure wrappers."""
|
|
2
|
+
|
|
3
|
+
from trendify.plotting import axline
|
|
4
|
+
from trendify.plotting import figure
|
|
5
|
+
from trendify.plotting import histogram
|
|
6
|
+
from trendify.plotting import point
|
|
7
|
+
from trendify.plotting import scatter
|
|
8
|
+
from trendify.plotting import trace
|
|
9
|
+
|
|
10
|
+
from trendify.plotting.axline import (
|
|
11
|
+
AxLine,
|
|
12
|
+
LineOrientation,
|
|
13
|
+
)
|
|
14
|
+
from trendify.plotting.figure import (
|
|
15
|
+
PlotlyFigure,
|
|
16
|
+
SingleAxisFigure,
|
|
17
|
+
)
|
|
18
|
+
from trendify.plotting.histogram import (
|
|
19
|
+
HistogramEntry,
|
|
20
|
+
HistogramStyle,
|
|
21
|
+
)
|
|
22
|
+
from trendify.plotting.point import (
|
|
23
|
+
Point2D,
|
|
24
|
+
)
|
|
25
|
+
from trendify.plotting.scatter import (
|
|
26
|
+
Scatter2D,
|
|
27
|
+
)
|
|
28
|
+
from trendify.plotting.trace import (
|
|
29
|
+
Trace2D,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"AxLine",
|
|
34
|
+
"HistogramEntry",
|
|
35
|
+
"HistogramStyle",
|
|
36
|
+
"LineOrientation",
|
|
37
|
+
"PlotlyFigure",
|
|
38
|
+
"Point2D",
|
|
39
|
+
"Scatter2D",
|
|
40
|
+
"SingleAxisFigure",
|
|
41
|
+
"Trace2D",
|
|
42
|
+
"axline",
|
|
43
|
+
"figure",
|
|
44
|
+
"histogram",
|
|
45
|
+
"point",
|
|
46
|
+
"scatter",
|
|
47
|
+
"trace",
|
|
48
|
+
]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`AxLine`: a horizontal or vertical reference line drawn on a plot, styled with a `Pen`.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
|
|
10
|
+
from matplotlib.axes import Axes
|
|
11
|
+
from pydantic import ConfigDict
|
|
12
|
+
|
|
13
|
+
from trendify.base.pen import Pen
|
|
14
|
+
from trendify.formats.format2d import PlottableData2D
|
|
15
|
+
from trendify.plotting.figure import PlotlyFigure
|
|
16
|
+
|
|
17
|
+
__all__ = ["AxLine", "LineOrientation"]
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LineOrientation(StrEnum):
|
|
23
|
+
"""
|
|
24
|
+
Defines orientation for axis lines.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
HORIZONTAL = "horizontal"
|
|
28
|
+
"""Horizontal line"""
|
|
29
|
+
|
|
30
|
+
VERTICAL = "vertical"
|
|
31
|
+
"""Vertical line"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AxLine(PlottableData2D):
|
|
35
|
+
"""
|
|
36
|
+
Defines a horizontal or vertical line to be drawn on a plot.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
value: float
|
|
40
|
+
"""Value at which to draw the line (x-value for vertical, y-value for horizontal)."""
|
|
41
|
+
|
|
42
|
+
orientation: LineOrientation
|
|
43
|
+
"""Whether line should be horizontal or vertical."""
|
|
44
|
+
|
|
45
|
+
pen: Pen = Pen()
|
|
46
|
+
"""Style and label information for drawing to matplotlib axes."""
|
|
47
|
+
|
|
48
|
+
model_config = ConfigDict(extra="forbid")
|
|
49
|
+
|
|
50
|
+
def plot_to_ax(self, ax: Axes) -> None:
|
|
51
|
+
"""
|
|
52
|
+
Plots line to matplotlib axes object.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
ax (Axes): axes to which line should be plotted
|
|
56
|
+
|
|
57
|
+
"""
|
|
58
|
+
match self.orientation:
|
|
59
|
+
case LineOrientation.HORIZONTAL:
|
|
60
|
+
ax.axhline(y=self.value, **self.pen.as_scatter_plot_kwargs())
|
|
61
|
+
case LineOrientation.VERTICAL:
|
|
62
|
+
ax.axvline(x=self.value, **self.pen.as_scatter_plot_kwargs())
|
|
63
|
+
case _:
|
|
64
|
+
logger.error(f"Unrecognized line orientation {self.orientation}")
|
|
65
|
+
|
|
66
|
+
def add_to_plotly(self, plotly_figure: PlotlyFigure) -> PlotlyFigure:
|
|
67
|
+
"""Add axis line to plotly figure without showing it in the legend"""
|
|
68
|
+
match self.orientation:
|
|
69
|
+
case LineOrientation.HORIZONTAL:
|
|
70
|
+
plotly_figure.fig.add_hline(
|
|
71
|
+
y=self.value,
|
|
72
|
+
line=dict(
|
|
73
|
+
color=self.pen.rgba if self.pen else None,
|
|
74
|
+
width=self.pen.size if self.pen else None,
|
|
75
|
+
dash=(
|
|
76
|
+
self.pen._convert_linestyle_to_plotly()
|
|
77
|
+
if self.pen
|
|
78
|
+
else None
|
|
79
|
+
),
|
|
80
|
+
),
|
|
81
|
+
showlegend=False, # Do not show in the legend
|
|
82
|
+
opacity=self.pen.alpha if self.pen else None,
|
|
83
|
+
)
|
|
84
|
+
plotly_figure.fig.add_annotation(
|
|
85
|
+
x=1.0,
|
|
86
|
+
y=self.value,
|
|
87
|
+
xref="paper", # Reference to the entire plotting area
|
|
88
|
+
yref="y",
|
|
89
|
+
text=self.pen.label,
|
|
90
|
+
showarrow=False, # Hide the arrow if not needed
|
|
91
|
+
xanchor="left",
|
|
92
|
+
yanchor="middle", # Center the text horizontally with the vline
|
|
93
|
+
)
|
|
94
|
+
case LineOrientation.VERTICAL:
|
|
95
|
+
plotly_figure.fig.add_vline(
|
|
96
|
+
x=self.value,
|
|
97
|
+
line=dict(
|
|
98
|
+
color=self.pen.rgba if self.pen else None,
|
|
99
|
+
width=self.pen.size if self.pen else None,
|
|
100
|
+
dash=(
|
|
101
|
+
self.pen._convert_linestyle_to_plotly()
|
|
102
|
+
if self.pen
|
|
103
|
+
else None
|
|
104
|
+
),
|
|
105
|
+
),
|
|
106
|
+
showlegend=False, # Do not show in the legend
|
|
107
|
+
opacity=self.pen.alpha if self.pen else None,
|
|
108
|
+
)
|
|
109
|
+
plotly_figure.fig.add_annotation(
|
|
110
|
+
x=self.value,
|
|
111
|
+
y=1.0, # Position slightly above the plot area
|
|
112
|
+
yref="paper", # Reference to the entire plotting area
|
|
113
|
+
text=self.pen.label,
|
|
114
|
+
showarrow=False, # Hide the arrow if not needed
|
|
115
|
+
xanchor="center", # Center the text horizontally with the vline
|
|
116
|
+
yanchor="bottom",
|
|
117
|
+
)
|
|
118
|
+
case _:
|
|
119
|
+
logger.error(f"Unrecognized line orientation {self.orientation}")
|
|
120
|
+
return plotly_figure
|
|
121
|
+
|
|
122
|
+
return plotly_figure
|