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,89 @@
1
+ """
2
+ `Point2D`: a single scattered (x, y) point, styled with a `Marker`.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+
9
+ import plotly.graph_objects as go
10
+ from pydantic import ConfigDict
11
+
12
+ from trendify.formats.format2d import XYData
13
+ from trendify.plotting.figure import PlotlyFigure
14
+ from trendify.styling.marker import Marker
15
+
16
+ __all__ = ["Point2D"]
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class Point2D(XYData):
22
+ """
23
+ Defines a point to be scattered onto xy plot.
24
+ """
25
+
26
+ x: float | str
27
+ """X value for the point."""
28
+
29
+ y: float | str
30
+ """Y value for the point."""
31
+
32
+ marker: Marker | None = Marker()
33
+ """Style and label information for scattering points to matplotlib axes. Only the label
34
+ information is used in Grafana. Eventually style information will be used in Grafana."""
35
+
36
+ model_config = ConfigDict(extra="forbid")
37
+
38
+ def add_to_plotly(self, plotly_figure: PlotlyFigure) -> PlotlyFigure:
39
+ """Add point to plotly figure with legendgroup support"""
40
+ legend_key = (
41
+ f"{self.marker.label}_{self.marker.color}_{self.marker.symbol}"
42
+ if self.marker
43
+ else None
44
+ )
45
+ # Prepare metadata for the tooltip
46
+ metadata_html = (
47
+ "<br>".join([f"{key}: {value}" for key, value in self.metadata.items()])
48
+ if self.metadata
49
+ else ""
50
+ )
51
+
52
+ # Define hovertemplate for the tooltip
53
+ hovertemplate = (
54
+ f"<b>{self.marker.label if self.marker else ''}</b><br>"
55
+ "x: %{x}<br>"
56
+ "y: %{y}<br>"
57
+ f"{metadata_html}<extra></extra>"
58
+ )
59
+
60
+ plotly_figure.fig.add_trace(
61
+ go.Scatter(
62
+ x=[self.x],
63
+ y=[self.y],
64
+ name=self.marker.label if self.marker else None,
65
+ mode="markers",
66
+ marker=dict(
67
+ color=self.marker.rgba if self.marker else None,
68
+ size=self.marker.size if self.marker else None,
69
+ symbol=self.marker.plotly_symbol if self.marker else None,
70
+ ),
71
+ zorder=int(self.marker.zorder) if self.marker else None,
72
+ legendgroup=legend_key,
73
+ hovertemplate=hovertemplate,
74
+ hoverlabel=dict(
75
+ bgcolor=(self.marker.rgba if self.marker else None),
76
+ font=dict(
77
+ color=self.marker.get_contrast_color() if self.marker else None
78
+ ),
79
+ ),
80
+ showlegend=(
81
+ True if legend_key not in plotly_figure.legend_groups else False
82
+ ),
83
+ )
84
+ )
85
+
86
+ if legend_key and legend_key not in plotly_figure.legend_groups:
87
+ plotly_figure.legend_groups.add(legend_key)
88
+
89
+ return plotly_figure
@@ -0,0 +1,100 @@
1
+ """
2
+ `Scatter2D`: a bulk collection of (x, y) points sharing one `Marker`, drawn as unconnected
3
+ scatter points.
4
+
5
+ Use this instead of many individual `Point2D` records when a single generation call
6
+ produces a large array of points that all share one style and don't need distinct per-point
7
+ tags/metadata (e.g. a raw scatter of measurements from one run). `Point2D` remains the right
8
+ choice when each point is its own taggable/hoverable entity, such as one point summarizing
9
+ one run, aggregated across many runs that share a tag.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from typing import TYPE_CHECKING
16
+
17
+ import plotly.graph_objects as go
18
+ from pydantic import ConfigDict
19
+
20
+ from trendify.formats.format2d import XYData
21
+ from trendify.plotting.figure import PlotlyFigure
22
+ from trendify.styling.marker import Marker
23
+ from trendify.typing import VecN
24
+
25
+ if TYPE_CHECKING:
26
+ from matplotlib.axes import Axes
27
+
28
+ __all__ = ["Scatter2D"]
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ class Scatter2D(XYData):
34
+ """
35
+ A collection of unconnected scattered points sharing one `Marker`.
36
+ """
37
+
38
+ model_config = ConfigDict(extra="forbid")
39
+
40
+ x: VecN
41
+ """x values"""
42
+
43
+ y: VecN
44
+ """y values"""
45
+
46
+ marker: Marker = Marker()
47
+ """Style and label information shared by every point in this scatter."""
48
+
49
+ def plot_to_ax(self, ax: Axes) -> None:
50
+ """
51
+ Scatters xy data to a matplotlib axes object.
52
+
53
+ Args:
54
+ ax (Axes): axes to which xy data should be plotted
55
+
56
+ """
57
+ ax.scatter(self.x, self.y, **self.marker.as_scatter_plot_kwargs())
58
+
59
+ def add_to_plotly(self, plotly_figure: PlotlyFigure) -> PlotlyFigure:
60
+ legend_key = f"{self.marker.label}_{self.marker.color}_{self.marker.symbol}"
61
+
62
+ metadata_html = (
63
+ "<br>".join(f"{key}: {value}" for key, value in self.metadata.items())
64
+ if self.metadata
65
+ else ""
66
+ )
67
+ hovertemplate = (
68
+ f"<b>{self.marker.label or ''}</b><br>"
69
+ "x: %{x}<br>"
70
+ "y: %{y}<br>"
71
+ f"{metadata_html}<extra></extra>"
72
+ )
73
+
74
+ plotly_figure.fig.add_trace(
75
+ go.Scatter(
76
+ x=self.x,
77
+ y=self.y,
78
+ name=self.marker.label,
79
+ mode="markers",
80
+ marker=dict(
81
+ color=self.marker.rgba,
82
+ size=self.marker.size,
83
+ symbol=self.marker.plotly_symbol,
84
+ ),
85
+ zorder=int(self.marker.zorder),
86
+ legendgroup=legend_key,
87
+ hovertemplate=hovertemplate,
88
+ hoverlabel=dict(
89
+ bgcolor=self.marker.rgba,
90
+ font=dict(color=self.marker.get_contrast_color()),
91
+ ),
92
+ showlegend=(
93
+ True if legend_key not in plotly_figure.legend_groups else False
94
+ ),
95
+ )
96
+ )
97
+
98
+ if legend_key not in plotly_figure.legend_groups:
99
+ plotly_figure.legend_groups.add(legend_key)
100
+ return plotly_figure
@@ -0,0 +1,129 @@
1
+ """
2
+ `Trace2D`: an xy line built from flat `x`/`y` arrays, styled with a `Pen`, and optionally
3
+ marked at intervals with a single shared `Marker` (mirroring matplotlib's native
4
+ `markevery`).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import TYPE_CHECKING
11
+
12
+ import plotly.graph_objects as go
13
+ from pydantic import ConfigDict
14
+
15
+ from trendify.base.pen import Pen
16
+ from trendify.formats.format2d import XYData
17
+ from trendify.plotting.figure import PlotlyFigure
18
+ from trendify.styling.marker import Marker
19
+ from trendify.typing import VecN
20
+
21
+ if TYPE_CHECKING:
22
+ from matplotlib.axes import Axes
23
+
24
+ __all__ = ["Trace2D"]
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class Trace2D(XYData):
30
+ """An xy line, optionally marked at intervals."""
31
+
32
+ model_config = ConfigDict(extra="forbid")
33
+
34
+ x: VecN
35
+ """x values"""
36
+
37
+ y: VecN
38
+ """y values"""
39
+
40
+ pen: Pen = Pen()
41
+ """Style and label information for drawing to matplotlib axes. Only the label information
42
+ is used in Grafana. Eventually style information will be used in Grafana."""
43
+
44
+ marker: Marker | None = None
45
+ """Shared marker style drawn along the line (e.g. one marker every `markevery` points).
46
+ `None` draws a plain line with no markers."""
47
+
48
+ markevery: int | None = None
49
+ """Draw a marker every Nth point when `marker` is set (passed straight through to
50
+ matplotlib's `Axes.plot(markevery=...)`); `None` marks every point."""
51
+
52
+ def plot_to_ax(self, ax: Axes) -> None:
53
+ """
54
+ Plots xy data from trace to a matplotlib axes object.
55
+
56
+ Args:
57
+ ax (Axes): axes to which xy data should be plotted
58
+
59
+ """
60
+ kwargs = self.pen.as_scatter_plot_kwargs()
61
+ if self.marker is not None:
62
+ kwargs["marker"] = self.marker.symbol
63
+ kwargs["markersize"] = self.marker.size
64
+ kwargs["markerfacecolor"] = self.marker.color
65
+ kwargs["markeredgecolor"] = self.marker.color
66
+ kwargs["markevery"] = self.markevery if self.markevery is not None else 1
67
+ ax.plot(self.x, self.y, **kwargs)
68
+
69
+ def add_to_plotly(self, plotly_figure: PlotlyFigure) -> PlotlyFigure:
70
+ legend_key = (
71
+ f"{self.pen.label}_{self.pen.color}_{self.pen._convert_linestyle_to_plotly()}"
72
+ if self.pen
73
+ else None
74
+ )
75
+ # Prepare metadata for the tooltip
76
+ metadata_html = (
77
+ "<br>".join([f"{key}: {value}" for key, value in self.metadata.items()])
78
+ if self.metadata
79
+ else ""
80
+ )
81
+
82
+ # Define hovertemplate for the tooltip
83
+ hovertemplate = (
84
+ f"<b>{self.pen.label if self.pen else ''}</b><br>"
85
+ "x: %{x}<br>"
86
+ "y: %{y}<br>"
87
+ f"{metadata_html}<extra></extra>"
88
+ )
89
+
90
+ # `markevery` (subsampling markers along the line) has no direct Plotly equivalent
91
+ # for a single trace, so Plotly renders a marker at every point when `marker` is set.
92
+ mode_parts = []
93
+ if self.pen.linestyle is not None:
94
+ mode_parts.append("lines")
95
+ if self.marker is not None:
96
+ mode_parts.append("markers")
97
+
98
+ plotly_figure.fig.add_trace(
99
+ go.Scatter(
100
+ x=self.x,
101
+ y=self.y,
102
+ name=self.pen.label if self.pen else None,
103
+ mode="+".join(mode_parts) if mode_parts else "lines",
104
+ line=dict(
105
+ color=self.pen.rgba if self.pen else None,
106
+ width=self.pen.size if self.pen else None,
107
+ dash=self.pen._convert_linestyle_to_plotly() if self.pen else None,
108
+ ),
109
+ marker=dict(
110
+ color=self.marker.rgba if self.marker else self.pen.rgba,
111
+ size=self.marker.size if self.marker else None,
112
+ symbol=self.marker.plotly_symbol if self.marker else None,
113
+ ),
114
+ zorder=int(self.pen.zorder),
115
+ hovertemplate=hovertemplate,
116
+ hoverlabel=dict(
117
+ bgcolor=(self.pen.rgba if self.pen else None),
118
+ font=dict(color=self.pen.get_contrast_color()),
119
+ ),
120
+ legendgroup=legend_key,
121
+ showlegend=(
122
+ True if legend_key not in plotly_figure.legend_groups else False
123
+ ),
124
+ )
125
+ )
126
+
127
+ if legend_key and legend_key not in plotly_figure.legend_groups:
128
+ plotly_figure.legend_groups.add(legend_key)
129
+ return plotly_figure
trendify/progress.py ADDED
@@ -0,0 +1,43 @@
1
+ """
2
+ Progress reporting for the generate/render pipeline: a lightweight event type plus a callback
3
+ type alias, so a caller embedding trendify (e.g. running it inside a container and feeding
4
+ status back to their own UI/API) can observe progress without needing logging configured or
5
+ parsing log lines.
6
+
7
+ `on_progress` is always invoked from the main process, never a worker: `generate_records`
8
+ reports after each `write_run` (the parent process is the only one that ever writes) and
9
+ `render_assets` reports after each tag's `future.result()` (or, sequentially, after each
10
+ `_render_tag_assets` call) -- so the callback itself never needs to be picklable/sent across a
11
+ process boundary, unlike `record_generator` itself.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass
18
+ from typing import Literal
19
+
20
+ __all__ = ["ProgressCallback", "ProgressEvent"]
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ProgressEvent:
25
+ """One unit of work finishing during `generate` or `render`."""
26
+
27
+ stage: Literal["generate", "render"]
28
+ """Which pipeline stage this event is from."""
29
+
30
+ completed: int
31
+ """Number of units finished so far this stage, including this one."""
32
+
33
+ total: int
34
+ """Total number of units this stage will process."""
35
+
36
+ detail: str
37
+ """Human-readable identifier for the unit that just finished: a run directory's path for
38
+ `generate`, a tag's display string for `render`."""
39
+
40
+
41
+ ProgressCallback = Callable[[ProgressEvent], None]
42
+ """A callback invoked once per completed unit of work. Left to raise: a callback that fails is
43
+ almost certainly a bug in the caller's own integration, not something to silently swallow."""
@@ -0,0 +1,30 @@
1
+ """SQLite-backed storage and querying of records."""
2
+
3
+ from trendify.store import db
4
+ from trendify.store import record_store
5
+ from trendify.store import tags
6
+
7
+ from trendify.store.db import (
8
+ SCHEMA_VERSION,
9
+ connect,
10
+ )
11
+ from trendify.store.record_store import (
12
+ RecordStore,
13
+ )
14
+ from trendify.store.tags import (
15
+ decode_tag,
16
+ encode_tag,
17
+ tag_to_path_parts,
18
+ )
19
+
20
+ __all__ = [
21
+ "SCHEMA_VERSION",
22
+ "RecordStore",
23
+ "connect",
24
+ "db",
25
+ "decode_tag",
26
+ "encode_tag",
27
+ "record_store",
28
+ "tag_to_path_parts",
29
+ "tags",
30
+ ]
trendify/store/db.py ADDED
@@ -0,0 +1,119 @@
1
+ """
2
+ SQLite connection factory, pragmas, and schema management for the trendify store.
3
+
4
+ - One `.db` file per trendify output directory, opened by every worker process independently.
5
+ - WAL mode gives concurrent readers plus one writer without the readers blocking on the
6
+ writer; `busy_timeout` handles the writer-vs-writer case without any external file locking.
7
+ - Schema versioning via `PRAGMA user_version`, checked/applied on every `connect()` call so
8
+ opening a fresh `.db` file bootstraps it and opening an existing one verifies compatibility.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import sqlite3
15
+ from pathlib import Path
16
+
17
+ __all__ = ["SCHEMA_VERSION", "connect"]
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ SCHEMA_VERSION = 1
22
+
23
+ _SCHEMA_DDL = """
24
+ CREATE TABLE IF NOT EXISTS runs (
25
+ id INTEGER PRIMARY KEY,
26
+ path TEXT NOT NULL UNIQUE,
27
+ generated_at TEXT NOT NULL
28
+ );
29
+
30
+ CREATE TABLE IF NOT EXISTS records (
31
+ id INTEGER PRIMARY KEY,
32
+ run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
33
+ record_type TEXT NOT NULL,
34
+ payload TEXT NOT NULL,
35
+ created_at TEXT NOT NULL
36
+ );
37
+ CREATE INDEX IF NOT EXISTS idx_records_run ON records(run_id);
38
+
39
+ CREATE TABLE IF NOT EXISTS record_tags (
40
+ record_id INTEGER NOT NULL REFERENCES records(id) ON DELETE CASCADE,
41
+ tag_key TEXT NOT NULL,
42
+ record_type TEXT NOT NULL,
43
+ PRIMARY KEY (record_id, tag_key)
44
+ );
45
+ CREATE INDEX IF NOT EXISTS idx_record_tags_lookup ON record_tags(tag_key, record_type);
46
+
47
+ CREATE TABLE IF NOT EXISTS table_entries (
48
+ id INTEGER PRIMARY KEY,
49
+ record_id INTEGER NOT NULL REFERENCES records(id) ON DELETE CASCADE,
50
+ tag_key TEXT NOT NULL,
51
+ row_key TEXT NOT NULL,
52
+ col_key TEXT NOT NULL,
53
+ value_num REAL,
54
+ value_text TEXT,
55
+ value_bool INTEGER,
56
+ unit TEXT
57
+ );
58
+ CREATE INDEX IF NOT EXISTS idx_table_entries_tag ON table_entries(tag_key);
59
+ CREATE INDEX IF NOT EXISTS idx_table_entries_record ON table_entries(record_id);
60
+ """
61
+
62
+
63
+ def connect(db_path: Path, *, readonly: bool = False) -> sqlite3.Connection:
64
+ """
65
+ Opens a WAL-mode SQLite connection to `db_path`, bootstrapping the schema on first use.
66
+
67
+ Args:
68
+ db_path (Path): path to the trendify output directory's `.db` file
69
+ readonly (bool): open in read-only mode (`mode=ro` URI), for render workers that only
70
+ ever query and never write, so they can safely run concurrently with an
71
+ in-progress writer under WAL.
72
+
73
+ Returns:
74
+ (sqlite3.Connection): a configured connection, with row access by column name.
75
+
76
+ """
77
+ logger.debug(f"Connecting to {db_path = } ({readonly = })")
78
+ if readonly:
79
+ uri = f"file:{db_path}?mode=ro"
80
+ conn = sqlite3.connect(uri, uri=True)
81
+ else:
82
+ db_path.parent.mkdir(parents=True, exist_ok=True)
83
+ conn = sqlite3.connect(db_path)
84
+
85
+ conn.row_factory = sqlite3.Row
86
+ conn.execute("PRAGMA journal_mode=WAL")
87
+ conn.execute("PRAGMA synchronous=NORMAL")
88
+ conn.execute("PRAGMA busy_timeout=30000")
89
+ conn.execute("PRAGMA foreign_keys=ON")
90
+
91
+ if not readonly:
92
+ _ensure_schema(conn)
93
+
94
+ return conn
95
+
96
+
97
+ def _ensure_schema(conn: sqlite3.Connection) -> None:
98
+ """
99
+ Bootstraps the schema on a fresh database, or verifies the on-disk schema version matches
100
+ `SCHEMA_VERSION` on an existing one. There is deliberately no migration logic yet, since
101
+ there has only ever been one schema version; this is the hook future migrations attach to.
102
+ """
103
+ (version,) = conn.execute("PRAGMA user_version").fetchone()
104
+ if version == 0:
105
+ logger.info(f"Bootstrapping fresh schema (version {SCHEMA_VERSION})")
106
+ conn.executescript(_SCHEMA_DDL)
107
+ conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
108
+ conn.commit()
109
+ elif version != SCHEMA_VERSION:
110
+ logger.error(
111
+ f"Database schema version {version} does not match expected "
112
+ f"{SCHEMA_VERSION}, and no migration path exists yet."
113
+ )
114
+ raise RuntimeError(
115
+ f"Database schema version {version} does not match expected "
116
+ f"{SCHEMA_VERSION}, and no migration path exists yet."
117
+ )
118
+ else:
119
+ logger.debug(f"Schema version {version} verified")