sqlitexplorer 1.0.0__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.
@@ -0,0 +1,10 @@
1
+ """Explore SQLite databases from the terminal."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("sqlitexplorer")
7
+ except PackageNotFoundError: # pragma: no cover - source checkout that was never installed
8
+ __version__ = "0.0.0+unknown"
9
+
10
+ __all__ = ["__version__"]
@@ -0,0 +1,6 @@
1
+ """Support ``python -m sqlitexplorer``."""
2
+
3
+ from sqlitexplorer.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,203 @@
1
+ """Charts drawn with plotille from a :class:`ResultSet`.
2
+
3
+ The first column of the result is the X axis (numbers or ISO dates) and every
4
+ other column is a numeric series. Histograms use the first column only.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from collections.abc import Iterator, Sequence
11
+ from contextlib import contextmanager
12
+ from dataclasses import dataclass
13
+ from datetime import datetime
14
+ from enum import Enum
15
+
16
+ import plotille
17
+
18
+ from sqlitexplorer.core import ExplorerError, ResultSet
19
+
20
+ __all__ = [
21
+ "ChartKind",
22
+ "Series",
23
+ "histogram_values",
24
+ "render_chart",
25
+ "render_histogram",
26
+ "series_from_result",
27
+ ]
28
+
29
+ PALETTE = ("red", "green", "yellow", "blue", "magenta", "cyan")
30
+ # plotille reserves this many characters for the Y axis label.
31
+ AXIS_LABEL_WIDTH = 8
32
+ # Characters taken by the Y axis (ticks, label and separator) next to the canvas.
33
+ AXIS_WIDTH = 12
34
+
35
+
36
+ class ChartKind(str, Enum):
37
+ LINE = "line"
38
+ SCATTER = "scatter"
39
+ HIST = "hist"
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Series:
44
+ label: str
45
+ x: list[float | datetime]
46
+ y: list[float]
47
+
48
+
49
+ def _number(value: object) -> float | None:
50
+ if isinstance(value, bool | int | float):
51
+ return float(value)
52
+ if isinstance(value, str):
53
+ try:
54
+ return float(value)
55
+ except ValueError:
56
+ return None
57
+ return None
58
+
59
+
60
+ def _x_value(value: object) -> float | datetime | None:
61
+ number = _number(value)
62
+ if number is not None:
63
+ return number
64
+ if isinstance(value, str):
65
+ try:
66
+ return datetime.fromisoformat(value)
67
+ except ValueError:
68
+ return None
69
+ return None
70
+
71
+
72
+ def series_from_result(result: ResultSet) -> tuple[list[Series], int]:
73
+ """Split *result* into one series per numeric column after the first.
74
+
75
+ Rows with a NULL in the X column or in any series are skipped; the second
76
+ item of the returned tuple counts them.
77
+ """
78
+ if len(result.columns) < 2:
79
+ raise ExplorerError("need an X column and at least one numeric column")
80
+ x_name, y_names = result.columns[0], result.columns[1:]
81
+ xs: list[float | datetime] = []
82
+ ys: list[list[float]] = [[] for _ in y_names]
83
+ x_type: type | None = None
84
+ skipped = 0
85
+ for row in result.rows:
86
+ if any(value is None for value in row):
87
+ skipped += 1
88
+ continue
89
+ x = _x_value(row[0])
90
+ if x is None:
91
+ raise ExplorerError(f"column {x_name} is neither numeric nor an ISO date: {row[0]!r}")
92
+ if x_type is None:
93
+ x_type = type(x)
94
+ elif not isinstance(x, x_type):
95
+ raise ExplorerError(f"column {x_name} mixes numbers and dates")
96
+ numbers = []
97
+ for name, value in zip(y_names, row[1:], strict=True):
98
+ number = _number(value)
99
+ if number is None:
100
+ raise ExplorerError(f"column {name} is not numeric: {value!r}")
101
+ numbers.append(number)
102
+ xs.append(x)
103
+ for bucket, number in zip(ys, numbers, strict=True):
104
+ bucket.append(number)
105
+ if not xs:
106
+ raise ExplorerError("no rows to plot")
107
+ return [
108
+ Series(label=name, x=xs, y=bucket) for name, bucket in zip(y_names, ys, strict=True)
109
+ ], skipped
110
+
111
+
112
+ def histogram_values(result: ResultSet) -> tuple[list[float], int]:
113
+ """Numeric values of the first column of *result*, and how many NULLs were skipped."""
114
+ if not result.columns:
115
+ raise ExplorerError("no rows to plot")
116
+ name = result.columns[0]
117
+ values: list[float] = []
118
+ skipped = 0
119
+ for row in result.rows:
120
+ if row[0] is None:
121
+ skipped += 1
122
+ continue
123
+ number = _number(row[0])
124
+ if number is None:
125
+ raise ExplorerError(f"column {name} is not numeric: {row[0]!r}")
126
+ values.append(number)
127
+ if not values:
128
+ raise ExplorerError("no rows to plot")
129
+ return values, skipped
130
+
131
+
132
+ @contextmanager
133
+ def _color_environment(enabled: bool) -> Iterator[None]:
134
+ """plotille checks FORCE_COLOR, NO_COLOR and isatty itself; make it follow *enabled*."""
135
+ saved = {name: os.environ.get(name) for name in ("FORCE_COLOR", "NO_COLOR")}
136
+ if enabled:
137
+ os.environ["FORCE_COLOR"] = "1"
138
+ os.environ.pop("NO_COLOR", None)
139
+ else:
140
+ os.environ["NO_COLOR"] = "1"
141
+ try:
142
+ yield
143
+ finally:
144
+ for name, value in saved.items():
145
+ if value is None:
146
+ os.environ.pop(name, None)
147
+ else:
148
+ os.environ[name] = value
149
+
150
+
151
+ def _canvas_width(width: int) -> int:
152
+ return max(10, width - AXIS_WIDTH)
153
+
154
+
155
+ def render_chart(
156
+ series: Sequence[Series],
157
+ *,
158
+ kind: ChartKind,
159
+ width: int,
160
+ height: int,
161
+ color: bool,
162
+ x_label: str,
163
+ y_label: str,
164
+ ) -> str:
165
+ """Draw *series* as a line chart or scatter plot, with a legend when there are several."""
166
+ figure = plotille.Figure()
167
+ figure.width = _canvas_width(width)
168
+ figure.height = max(3, height)
169
+ figure.with_colors = color
170
+ figure.color_mode = "names"
171
+ figure.x_label = x_label
172
+ figure.y_label = y_label[:AXIS_LABEL_WIDTH]
173
+ for index, item in enumerate(series):
174
+ line_color = PALETTE[index % len(PALETTE)] if color else None
175
+ if kind is ChartKind.SCATTER:
176
+ figure.scatter(item.x, item.y, lc=line_color, label=item.label)
177
+ else:
178
+ figure.plot(item.x, item.y, lc=line_color, label=item.label)
179
+ with _color_environment(color):
180
+ return figure.show(legend=len(series) > 1)
181
+
182
+
183
+ def render_histogram(
184
+ values: Sequence[float],
185
+ *,
186
+ bins: int,
187
+ width: int,
188
+ height: int,
189
+ color: bool,
190
+ x_label: str,
191
+ y_label: str,
192
+ ) -> str:
193
+ """Draw the distribution of *values*."""
194
+ with _color_environment(color):
195
+ return plotille.histogram(
196
+ list(values),
197
+ bins=bins,
198
+ width=_canvas_width(width),
199
+ height=max(3, height),
200
+ X_label=x_label,
201
+ Y_label=y_label[:AXIS_LABEL_WIDTH],
202
+ lc=PALETTE[0] if color else None,
203
+ )