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,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