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.
- sqlitexplorer/__init__.py +10 -0
- sqlitexplorer/__main__.py +6 -0
- sqlitexplorer/charts.py +203 -0
- sqlitexplorer/cli.py +857 -0
- sqlitexplorer/completion.py +22 -0
- sqlitexplorer/core.py +700 -0
- sqlitexplorer/render.py +508 -0
- sqlitexplorer/shell.py +254 -0
- sqlitexplorer-1.0.0.dist-info/METADATA +229 -0
- sqlitexplorer-1.0.0.dist-info/RECORD +14 -0
- sqlitexplorer-1.0.0.dist-info/WHEEL +5 -0
- sqlitexplorer-1.0.0.dist-info/entry_points.txt +2 -0
- sqlitexplorer-1.0.0.dist-info/licenses/LICENSE +21 -0
- sqlitexplorer-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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__"]
|
sqlitexplorer/charts.py
ADDED
|
@@ -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
|
+
)
|