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
trendify/cli.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`trendify` CLI, built on Typer. It is a thin argument-parsing layer over `TrendifyPipeline`:
|
|
3
|
+
every command resolves its arguments then calls straight into the same pipeline class a Python
|
|
4
|
+
caller would use directly, so the CLI and the Python API can never drift apart.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import glob
|
|
10
|
+
import importlib
|
|
11
|
+
import importlib.util
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from types import ModuleType
|
|
17
|
+
from typing import cast
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
from rich.align import Align
|
|
21
|
+
from rich.console import Console
|
|
22
|
+
from rich.panel import Panel
|
|
23
|
+
from rich.text import Text
|
|
24
|
+
|
|
25
|
+
from trendify.__about__ import __version__
|
|
26
|
+
from trendify.base.record import RecordGenerator
|
|
27
|
+
from trendify.color import Color
|
|
28
|
+
from trendify.examples import make_example_data
|
|
29
|
+
from trendify.log import setup_logger
|
|
30
|
+
from trendify.pipeline import TrendifyPipeline
|
|
31
|
+
from trendify.store.record_store import RecordStore
|
|
32
|
+
|
|
33
|
+
__all__ = ["app"]
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
console = Console()
|
|
37
|
+
|
|
38
|
+
_LOGO_LINES = [
|
|
39
|
+
"████████╗██████╗ ███████╗███╗ ██╗██████╗ ██╗███████╗██╗ ██╗",
|
|
40
|
+
"╚══██╔══╝██╔══██╗██╔════╝████╗ ██║██╔══██╗██║██╔════╝╚██╗ ██╔╝",
|
|
41
|
+
" ██║ ██████╔╝█████╗ ██╔██╗ ██║██║ ██║██║█████╗ ╚████╔╝ ",
|
|
42
|
+
" ██║ ██╔══██╗██╔══╝ ██║╚██╗██║██║ ██║██║██╔══╝ ╚██╔╝ ",
|
|
43
|
+
" ██║ ██║ ██║███████╗██║ ╚████║██████╔╝██║██║ ██║ ",
|
|
44
|
+
" ╚═╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═══╝╚═════╝ ╚═╝╚═╝ ╚═╝ ",
|
|
45
|
+
]
|
|
46
|
+
_SHADES = [
|
|
47
|
+
Color.ROSE_300,
|
|
48
|
+
Color.ROSE_400,
|
|
49
|
+
Color.ROSE_500,
|
|
50
|
+
Color.ROSE_600,
|
|
51
|
+
Color.ROSE_700,
|
|
52
|
+
Color.ROSE_800,
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def print_logo() -> None:
|
|
57
|
+
body = Text()
|
|
58
|
+
for i, (line, shade) in enumerate(zip(_LOGO_LINES, _SHADES)):
|
|
59
|
+
body.append(line, style=f"bold {shade}")
|
|
60
|
+
if i != len(_LOGO_LINES) - 1:
|
|
61
|
+
body.append("\n")
|
|
62
|
+
|
|
63
|
+
console.print(
|
|
64
|
+
Panel(
|
|
65
|
+
Align.center(body),
|
|
66
|
+
expand=False,
|
|
67
|
+
border_style="indian_red1",
|
|
68
|
+
subtitle=f"[dim]v{__version__}[/dim]",
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
app = typer.Typer(
|
|
74
|
+
name="trendify",
|
|
75
|
+
help="Generate visual records and static assets from raw data.",
|
|
76
|
+
rich_markup_mode="rich",
|
|
77
|
+
no_args_is_help=True,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def version_callback(value: bool) -> None:
|
|
82
|
+
if value:
|
|
83
|
+
console.print(
|
|
84
|
+
f"[bold indian_red1]trendify[/bold indian_red1] [bold white]{__version__}"
|
|
85
|
+
)
|
|
86
|
+
raise typer.Exit()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@app.callback(
|
|
90
|
+
epilog="Check out the [link=https://github.com/Hydrowelder/trendify/]full documentation[/link] for more info."
|
|
91
|
+
)
|
|
92
|
+
def main(
|
|
93
|
+
ctx: typer.Context,
|
|
94
|
+
version: bool = typer.Option(
|
|
95
|
+
False,
|
|
96
|
+
"--version",
|
|
97
|
+
callback=version_callback,
|
|
98
|
+
is_eager=True,
|
|
99
|
+
help="Show version and exit.",
|
|
100
|
+
),
|
|
101
|
+
) -> None:
|
|
102
|
+
"""
|
|
103
|
+
[bold indian_red1]Trendify:[/bold indian_red1] Generate visual records and static assets from raw data.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
InputDirectoriesOption = typer.Option(
|
|
108
|
+
...,
|
|
109
|
+
"-i",
|
|
110
|
+
"--input-directories",
|
|
111
|
+
help="Raw data directories (or glob patterns) to map the record generator over.",
|
|
112
|
+
)
|
|
113
|
+
RecordGeneratorOption = typer.Option(
|
|
114
|
+
...,
|
|
115
|
+
"-g",
|
|
116
|
+
"--record-generator",
|
|
117
|
+
help=(
|
|
118
|
+
"RecordGenerator to map over input directories, given as 'module:function', "
|
|
119
|
+
"'module:Class.method', or '/path/to/file.py:function'."
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
OutputDirectoryOption = typer.Option(
|
|
123
|
+
...,
|
|
124
|
+
"-o",
|
|
125
|
+
"--output-directory",
|
|
126
|
+
help="Directory the pipeline reads/writes under.",
|
|
127
|
+
)
|
|
128
|
+
NProcsOption = typer.Option(
|
|
129
|
+
1,
|
|
130
|
+
"-n",
|
|
131
|
+
"--n-procs",
|
|
132
|
+
help="Number of parallel worker processes.",
|
|
133
|
+
min=1,
|
|
134
|
+
max=os.cpu_count(),
|
|
135
|
+
)
|
|
136
|
+
VerboseOption = typer.Option(
|
|
137
|
+
0, "-v", "--verbose", count=True, help="Increase log verbosity."
|
|
138
|
+
)
|
|
139
|
+
QuietOption = typer.Option(
|
|
140
|
+
0, "--quiet", "-q", count=True, help="Decrease log verbosity."
|
|
141
|
+
)
|
|
142
|
+
SkipTablesOption = typer.Option(
|
|
143
|
+
False, "--skip-tables", help="Suppress TableEntry CSV output."
|
|
144
|
+
)
|
|
145
|
+
SkipXyPlotsOption = typer.Option(
|
|
146
|
+
False,
|
|
147
|
+
"--skip-xy-plots",
|
|
148
|
+
help="Suppress Point2D/Scatter2D/Trace2D/AxLine plot output.",
|
|
149
|
+
)
|
|
150
|
+
SkipHistogramsOption = typer.Option(
|
|
151
|
+
False,
|
|
152
|
+
"--skip-histograms",
|
|
153
|
+
help="Suppress HistogramEntry plot output.",
|
|
154
|
+
)
|
|
155
|
+
DbPathArgument = typer.Argument(
|
|
156
|
+
...,
|
|
157
|
+
help="Path to a trendify.db file.",
|
|
158
|
+
)
|
|
159
|
+
ExampleWorkdirOption = typer.Option(
|
|
160
|
+
...,
|
|
161
|
+
"-w",
|
|
162
|
+
"--workdir",
|
|
163
|
+
help="Directory in which to generate sample data.",
|
|
164
|
+
)
|
|
165
|
+
ExampleNFoldersOption = typer.Option(
|
|
166
|
+
10,
|
|
167
|
+
"-n",
|
|
168
|
+
"--n-folders",
|
|
169
|
+
help="Number of sample data sets (subfolders) to generate.",
|
|
170
|
+
)
|
|
171
|
+
ExampleNColsOption = typer.Option(
|
|
172
|
+
3,
|
|
173
|
+
"--n-cols",
|
|
174
|
+
help=(
|
|
175
|
+
"Data columns per results.csv. The first 3 are the plotted waveforms; any beyond that "
|
|
176
|
+
"are unused filler that only make reading each file more expensive."
|
|
177
|
+
),
|
|
178
|
+
min=3,
|
|
179
|
+
)
|
|
180
|
+
HostOption = typer.Option(
|
|
181
|
+
"127.0.0.1",
|
|
182
|
+
"--host",
|
|
183
|
+
help="Interface to bind the dashboard server to.",
|
|
184
|
+
)
|
|
185
|
+
PortOption = typer.Option(
|
|
186
|
+
8000,
|
|
187
|
+
"--port",
|
|
188
|
+
help="Port to bind the dashboard server to.",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _configure_logging(verbose: int, quiet: int, logo: bool = True) -> logging.Logger:
|
|
193
|
+
"""
|
|
194
|
+
Configures the root logger via `trendify.log.setup_logger` (Rich console output plus a
|
|
195
|
+
rotating `trendify.log` file) rather than a one-off `logging.basicConfig`. This way a CLI
|
|
196
|
+
run gets the same beautiful, concurrent-safe logging setup a Python caller gets by calling
|
|
197
|
+
`setup_logger()` themselves, and `generate_records`'s multiprocess path picks up these
|
|
198
|
+
same handlers via its `QueueListener` (see `trendify.generator.generate`).
|
|
199
|
+
"""
|
|
200
|
+
if logo:
|
|
201
|
+
print_logo()
|
|
202
|
+
level = logging.INFO - (verbose * 10) + (quiet * 10)
|
|
203
|
+
return setup_logger(level=level)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _resolve_input_directories(patterns: list[str], ctx: typer.Context) -> list[Path]:
|
|
207
|
+
"""
|
|
208
|
+
Expands glob patterns (and plain paths) into a list of directories. A match on a file
|
|
209
|
+
resolves to its parent directory.
|
|
210
|
+
|
|
211
|
+
`ctx.args` picks up bare paths that Click couldn't bind to `-i`/`--input-directories`
|
|
212
|
+
directly. Git Bash/MSYS2 glob-expands wildcard arguments to *native* (non-MSYS)
|
|
213
|
+
executables at process-spawn time, even when the argument was quoted in bash (bash
|
|
214
|
+
itself correctly leaves a quoted `*` alone, but MSYS's runtime re-expands it before the
|
|
215
|
+
native `trendify` executable ever sees argv). That turns `-i "data/*"` into
|
|
216
|
+
`-i data/1 data/2 data/3 ...`, but `-i` only consumes the one value immediately
|
|
217
|
+
following it, so `data/2`/`data/3`/etc. would otherwise be rejected as unexpected extra
|
|
218
|
+
arguments (the commands using this opt into that via
|
|
219
|
+
`context_settings={"allow_extra_args": True}`).
|
|
220
|
+
"""
|
|
221
|
+
dirs: list[Path] = []
|
|
222
|
+
for pattern in [*patterns, *ctx.args]:
|
|
223
|
+
for match in glob.glob(pattern, root_dir=Path.cwd(), recursive=True):
|
|
224
|
+
path = Path(match)
|
|
225
|
+
dirs.append((path.parent if path.is_file() else path).resolve())
|
|
226
|
+
return dirs
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _import_from_path(module_name: str, file_path: Path) -> ModuleType:
|
|
230
|
+
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
|
231
|
+
if spec is None or spec.loader is None:
|
|
232
|
+
raise typer.BadParameter(f"Could not import {file_path} as a Python module")
|
|
233
|
+
module = importlib.util.module_from_spec(spec)
|
|
234
|
+
sys.modules[module_name] = module
|
|
235
|
+
spec.loader.exec_module(module)
|
|
236
|
+
return module
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _resolve_record_generator(spec: str) -> RecordGenerator:
|
|
240
|
+
"""
|
|
241
|
+
Resolves a `"module:function"` / `"module:Class.method"` / `"/path/to/file.py:function"`
|
|
242
|
+
spec string into a callable.
|
|
243
|
+
"""
|
|
244
|
+
# `rpartition` (not `partition`): a Windows absolute path's drive letter is itself a
|
|
245
|
+
# colon (`C:\...\gen.py:generate`), so splitting on the *first* colon mistakes the
|
|
246
|
+
# drive letter for the module/function separator. The attr path never contains a
|
|
247
|
+
# colon, so the *last* colon is always the real separator.
|
|
248
|
+
module_path, _, attr_path = spec.rpartition(":")
|
|
249
|
+
if not attr_path:
|
|
250
|
+
raise typer.BadParameter(
|
|
251
|
+
f"{spec!r} must be in the form 'module:function' or 'path/to/file.py:function'"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
if Path(module_path).exists():
|
|
255
|
+
file_path = Path(module_path).resolve()
|
|
256
|
+
# `n_procs > 1` sends `record_generator` to worker processes via pickle, which
|
|
257
|
+
# serializes a module-level function as a `(module_name, qualname)` reference, not
|
|
258
|
+
# the code itself. Worker processes (forkserver/spawn) re-import that module by name
|
|
259
|
+
# using the `sys.path` snapshotted when the process pool starts, so the file's parent
|
|
260
|
+
# directory must be on `sys.path` *before* that happens, not just registered in this
|
|
261
|
+
# process's `sys.modules` (which `_import_from_path` alone would do).
|
|
262
|
+
parent_dir = str(file_path.parent)
|
|
263
|
+
if parent_dir not in sys.path:
|
|
264
|
+
sys.path.insert(0, parent_dir)
|
|
265
|
+
module = _import_from_path(file_path.stem, file_path)
|
|
266
|
+
else:
|
|
267
|
+
module = importlib.import_module(module_path)
|
|
268
|
+
|
|
269
|
+
obj: object = module
|
|
270
|
+
for part in attr_path.split("."):
|
|
271
|
+
obj = getattr(obj, part)
|
|
272
|
+
return cast(RecordGenerator, obj)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@app.command(name="generate", context_settings={"allow_extra_args": True})
|
|
276
|
+
def generate(
|
|
277
|
+
ctx: typer.Context,
|
|
278
|
+
input_directories: list[str] = InputDirectoriesOption,
|
|
279
|
+
record_generator: str = RecordGeneratorOption,
|
|
280
|
+
output_directory: Path = OutputDirectoryOption,
|
|
281
|
+
n_procs: int = NProcsOption,
|
|
282
|
+
verbose: int = VerboseOption,
|
|
283
|
+
quiet: int = QuietOption,
|
|
284
|
+
) -> None:
|
|
285
|
+
"""Generate tagged records from raw data directories."""
|
|
286
|
+
_configure_logging(verbose, quiet)
|
|
287
|
+
pipeline = TrendifyPipeline(output_dir=output_directory, n_procs=n_procs)
|
|
288
|
+
_total = pipeline.generate(
|
|
289
|
+
record_generator=_resolve_record_generator(record_generator),
|
|
290
|
+
data_dirs=_resolve_input_directories(input_directories, ctx),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@app.command(name="render")
|
|
295
|
+
def render(
|
|
296
|
+
ctx: typer.Context,
|
|
297
|
+
output_directory: Path = OutputDirectoryOption,
|
|
298
|
+
verbose: int = VerboseOption,
|
|
299
|
+
quiet: int = QuietOption,
|
|
300
|
+
) -> None:
|
|
301
|
+
"""Render CSV tables and matplotlib figures from already-generated records."""
|
|
302
|
+
_configure_logging(verbose, quiet)
|
|
303
|
+
pipeline = TrendifyPipeline(output_dir=output_directory)
|
|
304
|
+
pipeline.render()
|
|
305
|
+
typer.echo(f"Rendered assets to {pipeline.assets_dir}")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@app.command(name="run", context_settings={"allow_extra_args": True})
|
|
309
|
+
def run(
|
|
310
|
+
ctx: typer.Context,
|
|
311
|
+
input_directories: list[str] = InputDirectoriesOption,
|
|
312
|
+
record_generator: str = RecordGeneratorOption,
|
|
313
|
+
output_directory: Path = OutputDirectoryOption,
|
|
314
|
+
n_procs: int = NProcsOption,
|
|
315
|
+
verbose: int = VerboseOption,
|
|
316
|
+
quiet: int = QuietOption,
|
|
317
|
+
) -> None:
|
|
318
|
+
"""Generate records and render assets in one step."""
|
|
319
|
+
logger = _configure_logging(verbose, quiet)
|
|
320
|
+
pipeline = TrendifyPipeline(output_dir=output_directory, n_procs=n_procs)
|
|
321
|
+
total = pipeline.run(
|
|
322
|
+
record_generator=_resolve_record_generator(record_generator),
|
|
323
|
+
data_dirs=_resolve_input_directories(input_directories, ctx),
|
|
324
|
+
)
|
|
325
|
+
logger.info(f"Wrote {total} records; assets under {pipeline.assets_dir}")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@app.command(name="example-data")
|
|
329
|
+
def example_data(
|
|
330
|
+
ctx: typer.Context,
|
|
331
|
+
workdir: Path = ExampleWorkdirOption,
|
|
332
|
+
n_folders: int = ExampleNFoldersOption,
|
|
333
|
+
n_cols: int = ExampleNColsOption,
|
|
334
|
+
verbose: int = VerboseOption,
|
|
335
|
+
quiet: int = QuietOption,
|
|
336
|
+
) -> None:
|
|
337
|
+
"""Generate sample raw-data directories for exercising the trendify pipeline."""
|
|
338
|
+
logger = _configure_logging(verbose, quiet)
|
|
339
|
+
make_example_data(workdir=workdir, n_folders=n_folders, n_cols=n_cols)
|
|
340
|
+
logger.info(f"Wrote {n_folders} sample data folders under {workdir / 'models'}")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def get_local_ip() -> str:
|
|
344
|
+
"""Returns the actual local IP address of this machine."""
|
|
345
|
+
import socket
|
|
346
|
+
|
|
347
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
348
|
+
try:
|
|
349
|
+
# Does not actually need to connect to 8.8.8.8 to work
|
|
350
|
+
s.connect(("8.8.8.8", 1))
|
|
351
|
+
ip = s.getsockname()[0]
|
|
352
|
+
except Exception:
|
|
353
|
+
ip = "127.0.0.1"
|
|
354
|
+
finally:
|
|
355
|
+
s.close()
|
|
356
|
+
return ip
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@app.command(name="viewer")
|
|
360
|
+
def viewer(
|
|
361
|
+
ctx: typer.Context,
|
|
362
|
+
db_path: Path = DbPathArgument,
|
|
363
|
+
host: str = HostOption,
|
|
364
|
+
port: int = PortOption,
|
|
365
|
+
verbose: int = VerboseOption,
|
|
366
|
+
quiet: int = QuietOption,
|
|
367
|
+
) -> None:
|
|
368
|
+
"""Launch a local web dashboard for browsing a trendify.db's records."""
|
|
369
|
+
_configure_logging(verbose, quiet, logo=False)
|
|
370
|
+
db_path = db_path.resolve()
|
|
371
|
+
if not db_path.exists():
|
|
372
|
+
raise typer.BadParameter(f"No such database file: {db_path}")
|
|
373
|
+
|
|
374
|
+
# Fail fast on a bad/corrupt db before uvicorn even starts, rather than a 500 on first request.
|
|
375
|
+
with RecordStore.open(db_path, readonly=True):
|
|
376
|
+
pass
|
|
377
|
+
|
|
378
|
+
# Deferred import: keeps `trendify.cli` import light for callers of generate/render/run
|
|
379
|
+
# who never touch the dashboard (FastAPI/Jinja2/uvicorn stay unimported until needed).
|
|
380
|
+
from trendify.viewer.app import create_app, make_server
|
|
381
|
+
|
|
382
|
+
connection_info = (
|
|
383
|
+
f"Local: [bold indian_red1 u]http://127.0.0.1:{port}[/bold indian_red1 u]"
|
|
384
|
+
)
|
|
385
|
+
if host == "0.0.0.0":
|
|
386
|
+
connection_info += f"\nMobile: [bold indian_red1 u]http://{get_local_ip()}:{port}[/bold indian_red1 u]"
|
|
387
|
+
else:
|
|
388
|
+
connection_info += "\n\n[dim]Tip: To view on other devices (and to make pages shareable), run with[/dim] [yellow]--host 0.0.0.0[/yellow]"
|
|
389
|
+
|
|
390
|
+
console.print(
|
|
391
|
+
Panel(
|
|
392
|
+
f"""\n{connection_info}\n\n[yellow]Press CTRL+C to stop[/yellow]""",
|
|
393
|
+
border_style="indian_red1",
|
|
394
|
+
expand=False,
|
|
395
|
+
title="Trendify Viewer is Live!",
|
|
396
|
+
)
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
make_server(create_app(db_path), host=host, port=port).run()
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
if __name__ == "__main__":
|
|
403
|
+
app()
|
trendify/color.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`Color`: the Tailwind CSS color palette as a `StrEnum` of hex values, for consistent styling
|
|
3
|
+
defaults across `Pen`/`Marker`/`HistogramStyle` and friends.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
__all__ = ["Color"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Color(StrEnum):
|
|
15
|
+
"""Contains color aliases and converters to go between the various color formats. The color values are defined by the standard [Tailwind CSS](https://tailwindcss.com/docs/colors) color theme."""
|
|
16
|
+
|
|
17
|
+
BLACK = "#000000"
|
|
18
|
+
WHITE = "#FFFFFF"
|
|
19
|
+
SLATE_50 = "#F8FAFC"
|
|
20
|
+
SLATE_100 = "#F1F5F9"
|
|
21
|
+
SLATE_200 = "#E2E8F0"
|
|
22
|
+
SLATE_300 = "#CBD5E1"
|
|
23
|
+
SLATE_400 = "#94A3B8"
|
|
24
|
+
SLATE_500 = "#64748B"
|
|
25
|
+
SLATE_600 = "#475569"
|
|
26
|
+
SLATE_700 = "#334155"
|
|
27
|
+
SLATE_800 = "#1E293B"
|
|
28
|
+
SLATE_900 = "#0F172A"
|
|
29
|
+
SLATE_950 = "#020617"
|
|
30
|
+
GRAY_50 = "#F9FAFB"
|
|
31
|
+
GRAY_100 = "#F3F4F6"
|
|
32
|
+
GRAY_200 = "#E5E7EB"
|
|
33
|
+
GRAY_300 = "#D1D5DB"
|
|
34
|
+
GRAY_400 = "#9CA3AF"
|
|
35
|
+
GRAY_500 = "#6B7280"
|
|
36
|
+
GRAY_600 = "#4B5563"
|
|
37
|
+
GRAY_700 = "#374151"
|
|
38
|
+
GRAY_800 = "#1F2937"
|
|
39
|
+
GRAY_900 = "#111827"
|
|
40
|
+
GRAY_950 = "#030712"
|
|
41
|
+
ZINC_50 = "#FAFAFA"
|
|
42
|
+
ZINC_100 = "#F4F4F5"
|
|
43
|
+
ZINC_200 = "#E4E4E7"
|
|
44
|
+
ZINC_300 = "#D4D4D8"
|
|
45
|
+
ZINC_400 = "#A1A1AA"
|
|
46
|
+
ZINC_500 = "#71717A"
|
|
47
|
+
ZINC_600 = "#52525B"
|
|
48
|
+
ZINC_700 = "#3F3F46"
|
|
49
|
+
ZINC_800 = "#27272A"
|
|
50
|
+
ZINC_900 = "#18181B"
|
|
51
|
+
ZINC_950 = "#09090B"
|
|
52
|
+
NEUTRAL_50 = "#FAFAFA"
|
|
53
|
+
NEUTRAL_100 = "#F5F5F5"
|
|
54
|
+
NEUTRAL_200 = "#E5E5E5"
|
|
55
|
+
NEUTRAL_300 = "#D4D4D4"
|
|
56
|
+
NEUTRAL_400 = "#A3A3A3"
|
|
57
|
+
NEUTRAL_500 = "#737373"
|
|
58
|
+
NEUTRAL_600 = "#525252"
|
|
59
|
+
NEUTRAL_700 = "#404040"
|
|
60
|
+
NEUTRAL_800 = "#262626"
|
|
61
|
+
NEUTRAL_900 = "#171717"
|
|
62
|
+
NEUTRAL_950 = "#0A0A0A"
|
|
63
|
+
STONE_50 = "#FAFAF9"
|
|
64
|
+
STONE_100 = "#F5F5F4"
|
|
65
|
+
STONE_200 = "#E7E5E4"
|
|
66
|
+
STONE_300 = "#D6D3D1"
|
|
67
|
+
STONE_400 = "#A8A29E"
|
|
68
|
+
STONE_500 = "#78716C"
|
|
69
|
+
STONE_600 = "#57534E"
|
|
70
|
+
STONE_700 = "#44403C"
|
|
71
|
+
STONE_800 = "#292524"
|
|
72
|
+
STONE_900 = "#1C1917"
|
|
73
|
+
STONE_950 = "#0C0A09"
|
|
74
|
+
RED_50 = "#FEF2F2"
|
|
75
|
+
RED_100 = "#FEE2E2"
|
|
76
|
+
RED_200 = "#FECACA"
|
|
77
|
+
RED_300 = "#FCA5A5"
|
|
78
|
+
RED_400 = "#F87171"
|
|
79
|
+
RED_500 = "#EF4444"
|
|
80
|
+
RED_600 = "#DC2626"
|
|
81
|
+
RED_700 = "#B91C1C"
|
|
82
|
+
RED_800 = "#991B1B"
|
|
83
|
+
RED_900 = "#7F1D1D"
|
|
84
|
+
RED_950 = "#450A0A"
|
|
85
|
+
ORANGE_50 = "#FFF7ED"
|
|
86
|
+
ORANGE_100 = "#FFEDD5"
|
|
87
|
+
ORANGE_200 = "#FED7AA"
|
|
88
|
+
ORANGE_300 = "#FDBA74"
|
|
89
|
+
ORANGE_400 = "#FB923C"
|
|
90
|
+
ORANGE_500 = "#F97316"
|
|
91
|
+
ORANGE_600 = "#EA580C"
|
|
92
|
+
ORANGE_700 = "#C2410C"
|
|
93
|
+
ORANGE_800 = "#9A3412"
|
|
94
|
+
ORANGE_900 = "#7C2D12"
|
|
95
|
+
ORANGE_950 = "#431407"
|
|
96
|
+
AMBER_50 = "#FFFBEB"
|
|
97
|
+
AMBER_100 = "#FEF3C7"
|
|
98
|
+
AMBER_200 = "#FDE68A"
|
|
99
|
+
AMBER_300 = "#FCD34D"
|
|
100
|
+
AMBER_400 = "#FBBF24"
|
|
101
|
+
AMBER_500 = "#F59E0B"
|
|
102
|
+
AMBER_600 = "#D97706"
|
|
103
|
+
AMBER_700 = "#B45309"
|
|
104
|
+
AMBER_800 = "#92400E"
|
|
105
|
+
AMBER_900 = "#78350F"
|
|
106
|
+
AMBER_950 = "#451A03"
|
|
107
|
+
YELLOW_50 = "#FEFCE8"
|
|
108
|
+
YELLOW_100 = "#FEF9C3"
|
|
109
|
+
YELLOW_200 = "#FEF08A"
|
|
110
|
+
YELLOW_300 = "#FDE047"
|
|
111
|
+
YELLOW_400 = "#FACC15"
|
|
112
|
+
YELLOW_500 = "#EAB308"
|
|
113
|
+
YELLOW_600 = "#CA8A04"
|
|
114
|
+
YELLOW_700 = "#A16207"
|
|
115
|
+
YELLOW_800 = "#854D0E"
|
|
116
|
+
YELLOW_900 = "#713F12"
|
|
117
|
+
YELLOW_950 = "#422006"
|
|
118
|
+
LIME_50 = "#F7FEE7"
|
|
119
|
+
LIME_100 = "#ECFCCB"
|
|
120
|
+
LIME_200 = "#D9F99D"
|
|
121
|
+
LIME_300 = "#BEF264"
|
|
122
|
+
LIME_400 = "#A3E635"
|
|
123
|
+
LIME_500 = "#84CC16"
|
|
124
|
+
LIME_600 = "#65A30D"
|
|
125
|
+
LIME_700 = "#4D7C0F"
|
|
126
|
+
LIME_800 = "#3F6212"
|
|
127
|
+
LIME_900 = "#365314"
|
|
128
|
+
LIME_950 = "#1A2E05"
|
|
129
|
+
GREEN_50 = "#F0FDF4"
|
|
130
|
+
GREEN_100 = "#DCFCE7"
|
|
131
|
+
GREEN_200 = "#BBF7D0"
|
|
132
|
+
GREEN_300 = "#86EFAC"
|
|
133
|
+
GREEN_400 = "#4ADE80"
|
|
134
|
+
GREEN_500 = "#22C55E"
|
|
135
|
+
GREEN_600 = "#16A34A"
|
|
136
|
+
GREEN_700 = "#15803D"
|
|
137
|
+
GREEN_800 = "#166534"
|
|
138
|
+
GREEN_900 = "#14532D"
|
|
139
|
+
GREEN_950 = "#052E16"
|
|
140
|
+
EMERALD_50 = "#ECFDF5"
|
|
141
|
+
EMERALD_100 = "#D1FAE5"
|
|
142
|
+
EMERALD_200 = "#A7F3D0"
|
|
143
|
+
EMERALD_300 = "#6EE7B7"
|
|
144
|
+
EMERALD_400 = "#34D399"
|
|
145
|
+
EMERALD_500 = "#10B981"
|
|
146
|
+
EMERALD_600 = "#059669"
|
|
147
|
+
EMERALD_700 = "#047857"
|
|
148
|
+
EMERALD_800 = "#065F46"
|
|
149
|
+
EMERALD_900 = "#064E3B"
|
|
150
|
+
EMERALD_950 = "#022C22"
|
|
151
|
+
TEAL_50 = "#F0FDFA"
|
|
152
|
+
TEAL_100 = "#CCFBF1"
|
|
153
|
+
TEAL_200 = "#99F6E4"
|
|
154
|
+
TEAL_300 = "#5EEAD4"
|
|
155
|
+
TEAL_400 = "#2DD4BF"
|
|
156
|
+
TEAL_500 = "#14B8A6"
|
|
157
|
+
TEAL_600 = "#0D9488"
|
|
158
|
+
TEAL_700 = "#0F766E"
|
|
159
|
+
TEAL_800 = "#115E59"
|
|
160
|
+
TEAL_900 = "#134E4A"
|
|
161
|
+
TEAL_950 = "#042F2E"
|
|
162
|
+
CYAN_50 = "#ECFEFF"
|
|
163
|
+
CYAN_100 = "#CFFAFE"
|
|
164
|
+
CYAN_200 = "#A5F3FC"
|
|
165
|
+
CYAN_300 = "#67E8F9"
|
|
166
|
+
CYAN_400 = "#22D3EE"
|
|
167
|
+
CYAN_500 = "#06B6D4"
|
|
168
|
+
CYAN_600 = "#0891B2"
|
|
169
|
+
CYAN_700 = "#0E7490"
|
|
170
|
+
CYAN_800 = "#155E75"
|
|
171
|
+
CYAN_900 = "#164E63"
|
|
172
|
+
CYAN_950 = "#083344"
|
|
173
|
+
SKY_50 = "#F0F9FF"
|
|
174
|
+
SKY_100 = "#E0F2FE"
|
|
175
|
+
SKY_200 = "#BAE6FD"
|
|
176
|
+
SKY_300 = "#7DD3FC"
|
|
177
|
+
SKY_400 = "#38BDF8"
|
|
178
|
+
SKY_500 = "#0EA5E9"
|
|
179
|
+
SKY_600 = "#0284C7"
|
|
180
|
+
SKY_700 = "#0369A1"
|
|
181
|
+
SKY_800 = "#075985"
|
|
182
|
+
SKY_900 = "#0C4A6E"
|
|
183
|
+
SKY_950 = "#082F49"
|
|
184
|
+
BLUE_50 = "#EFF6FF"
|
|
185
|
+
BLUE_100 = "#DBEAFE"
|
|
186
|
+
BLUE_200 = "#BFDBFE"
|
|
187
|
+
BLUE_300 = "#93C5FD"
|
|
188
|
+
BLUE_400 = "#60A5FA"
|
|
189
|
+
BLUE_500 = "#3B82F6"
|
|
190
|
+
BLUE_600 = "#2563EB"
|
|
191
|
+
BLUE_700 = "#1D4ED8"
|
|
192
|
+
BLUE_800 = "#1E40AF"
|
|
193
|
+
BLUE_900 = "#1E3A8A"
|
|
194
|
+
BLUE_950 = "#172554"
|
|
195
|
+
INDIGO_50 = "#EEF2FF"
|
|
196
|
+
INDIGO_100 = "#E0E7FF"
|
|
197
|
+
INDIGO_200 = "#C7D2FE"
|
|
198
|
+
INDIGO_300 = "#A5B4FC"
|
|
199
|
+
INDIGO_400 = "#818CF8"
|
|
200
|
+
INDIGO_500 = "#6366F1"
|
|
201
|
+
INDIGO_600 = "#4F46E5"
|
|
202
|
+
INDIGO_700 = "#4338CA"
|
|
203
|
+
INDIGO_800 = "#3730A3"
|
|
204
|
+
INDIGO_900 = "#312E81"
|
|
205
|
+
INDIGO_950 = "#1E1B4B"
|
|
206
|
+
VIOLET_50 = "#F5F3FF"
|
|
207
|
+
VIOLET_100 = "#EDE9FE"
|
|
208
|
+
VIOLET_200 = "#DDD6FE"
|
|
209
|
+
VIOLET_300 = "#C4B5FD"
|
|
210
|
+
VIOLET_400 = "#A78BFA"
|
|
211
|
+
VIOLET_500 = "#8B5CF6"
|
|
212
|
+
VIOLET_600 = "#7C3AED"
|
|
213
|
+
VIOLET_700 = "#6D28D9"
|
|
214
|
+
VIOLET_800 = "#5B21B6"
|
|
215
|
+
VIOLET_900 = "#4C1D95"
|
|
216
|
+
VIOLET_950 = "#2E1065"
|
|
217
|
+
PURPLE_50 = "#FAF5FF"
|
|
218
|
+
PURPLE_100 = "#F3E8FF"
|
|
219
|
+
PURPLE_200 = "#E9D5FF"
|
|
220
|
+
PURPLE_300 = "#D8B4FE"
|
|
221
|
+
PURPLE_400 = "#C084FC"
|
|
222
|
+
PURPLE_500 = "#A855F7"
|
|
223
|
+
PURPLE_600 = "#9333EA"
|
|
224
|
+
PURPLE_700 = "#7E22CE"
|
|
225
|
+
PURPLE_800 = "#6B21A8"
|
|
226
|
+
PURPLE_900 = "#581C87"
|
|
227
|
+
PURPLE_950 = "#3B0764"
|
|
228
|
+
FUCHSIA_50 = "#FDF4FF"
|
|
229
|
+
FUCHSIA_100 = "#FAE8FF"
|
|
230
|
+
FUCHSIA_200 = "#F5D0FE"
|
|
231
|
+
FUCHSIA_300 = "#F0ABFC"
|
|
232
|
+
FUCHSIA_400 = "#E879F9"
|
|
233
|
+
FUCHSIA_500 = "#D946EF"
|
|
234
|
+
FUCHSIA_600 = "#C026D3"
|
|
235
|
+
FUCHSIA_700 = "#A21CAF"
|
|
236
|
+
FUCHSIA_800 = "#86198F"
|
|
237
|
+
FUCHSIA_900 = "#701A75"
|
|
238
|
+
FUCHSIA_950 = "#4A044E"
|
|
239
|
+
PINK_50 = "#FDF2F8"
|
|
240
|
+
PINK_100 = "#FCE7F3"
|
|
241
|
+
PINK_200 = "#FBCFE8"
|
|
242
|
+
PINK_300 = "#F9A8D4"
|
|
243
|
+
PINK_400 = "#F472B6"
|
|
244
|
+
PINK_500 = "#EC4899"
|
|
245
|
+
PINK_600 = "#DB2777"
|
|
246
|
+
PINK_700 = "#BE185D"
|
|
247
|
+
PINK_800 = "#9D174D"
|
|
248
|
+
PINK_900 = "#831843"
|
|
249
|
+
PINK_950 = "#500724"
|
|
250
|
+
ROSE_50 = "#FFF1F2"
|
|
251
|
+
ROSE_100 = "#FFE4E6"
|
|
252
|
+
ROSE_200 = "#FECDD3"
|
|
253
|
+
ROSE_300 = "#FDA4AF"
|
|
254
|
+
ROSE_400 = "#FB7185"
|
|
255
|
+
ROSE_500 = "#F43F5E"
|
|
256
|
+
ROSE_600 = "#E11D48"
|
|
257
|
+
ROSE_700 = "#BE123C"
|
|
258
|
+
ROSE_800 = "#9F1239"
|
|
259
|
+
ROSE_900 = "#881337"
|
|
260
|
+
ROSE_950 = "#4C0519"
|