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
sqlitexplorer/cli.py
ADDED
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
"""Command-line interface of sqlitexplorer, built with Typer.
|
|
2
|
+
|
|
3
|
+
This module only declares commands and options; the SQL lives in
|
|
4
|
+
:mod:`sqlitexplorer.core`, the output formats in :mod:`sqlitexplorer.render`,
|
|
5
|
+
the charts in :mod:`sqlitexplorer.charts` and the REPL in
|
|
6
|
+
:mod:`sqlitexplorer.shell`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import difflib
|
|
12
|
+
import functools
|
|
13
|
+
import inspect
|
|
14
|
+
import shutil
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
18
|
+
from contextlib import contextmanager
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Annotated, NoReturn
|
|
21
|
+
|
|
22
|
+
import typer
|
|
23
|
+
|
|
24
|
+
from sqlitexplorer import __version__
|
|
25
|
+
from sqlitexplorer.charts import (
|
|
26
|
+
ChartKind,
|
|
27
|
+
histogram_values,
|
|
28
|
+
render_chart,
|
|
29
|
+
render_histogram,
|
|
30
|
+
series_from_result,
|
|
31
|
+
)
|
|
32
|
+
from sqlitexplorer.completion import complete_table
|
|
33
|
+
from sqlitexplorer.core import (
|
|
34
|
+
Explorer,
|
|
35
|
+
ExplorerError,
|
|
36
|
+
Page,
|
|
37
|
+
ReadOnlyError,
|
|
38
|
+
open_database,
|
|
39
|
+
split_statements,
|
|
40
|
+
)
|
|
41
|
+
from sqlitexplorer.render import (
|
|
42
|
+
OutputFormat,
|
|
43
|
+
OutputOptions,
|
|
44
|
+
coerce_rows,
|
|
45
|
+
default_page_size,
|
|
46
|
+
emit,
|
|
47
|
+
emit_stream,
|
|
48
|
+
infer_types,
|
|
49
|
+
ok_message,
|
|
50
|
+
parse_number,
|
|
51
|
+
parse_rows,
|
|
52
|
+
resolve_color,
|
|
53
|
+
stdout_is_tty,
|
|
54
|
+
write_rows,
|
|
55
|
+
)
|
|
56
|
+
from sqlitexplorer.shell import run_shell
|
|
57
|
+
|
|
58
|
+
app = typer.Typer(
|
|
59
|
+
help="Explore SQLite databases from the terminal.",
|
|
60
|
+
no_args_is_help=True,
|
|
61
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
62
|
+
pretty_exceptions_show_locals=False,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# --- Parameters shared by several commands ------------------------------------
|
|
66
|
+
|
|
67
|
+
DatabaseArg = Annotated[
|
|
68
|
+
Path,
|
|
69
|
+
typer.Argument(
|
|
70
|
+
exists=True,
|
|
71
|
+
dir_okay=False,
|
|
72
|
+
readable=True,
|
|
73
|
+
show_default=False,
|
|
74
|
+
help="Path to the SQLite database file.",
|
|
75
|
+
),
|
|
76
|
+
]
|
|
77
|
+
TableArg = Annotated[
|
|
78
|
+
str,
|
|
79
|
+
typer.Argument(
|
|
80
|
+
show_default=False, help="Name of a table or view.", autocompletion=complete_table
|
|
81
|
+
),
|
|
82
|
+
]
|
|
83
|
+
OptionalTableArg = Annotated[
|
|
84
|
+
str | None,
|
|
85
|
+
typer.Argument(
|
|
86
|
+
show_default=False,
|
|
87
|
+
help="Name of a table (default: every table).",
|
|
88
|
+
autocompletion=complete_table,
|
|
89
|
+
),
|
|
90
|
+
]
|
|
91
|
+
AllOption = Annotated[
|
|
92
|
+
bool,
|
|
93
|
+
typer.Option("--all", "-a", help="Include SQLite's internal objects (sqlite_*)."),
|
|
94
|
+
]
|
|
95
|
+
WriteOption = Annotated[
|
|
96
|
+
bool,
|
|
97
|
+
typer.Option("--write", "-w", help="Open the database read-write and commit the changes."),
|
|
98
|
+
]
|
|
99
|
+
ParamOption = Annotated[
|
|
100
|
+
list[str] | None,
|
|
101
|
+
typer.Option("--param", "-p", help="Bind a named parameter, as NAME=VALUE. Repeatable."),
|
|
102
|
+
]
|
|
103
|
+
AttachOption = Annotated[
|
|
104
|
+
list[str] | None,
|
|
105
|
+
typer.Option("--attach", help="Attach another database, as ALIAS=PATH. Repeatable."),
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
# Output options. Every command that prints a result set takes all of them.
|
|
109
|
+
FormatOption = Annotated[
|
|
110
|
+
OutputFormat,
|
|
111
|
+
typer.Option("--format", "-f", case_sensitive=False, help="Output format."),
|
|
112
|
+
]
|
|
113
|
+
NullOption = Annotated[
|
|
114
|
+
str,
|
|
115
|
+
typer.Option("--null", help="Text shown for NULL values (table, csv, tsv and markdown)."),
|
|
116
|
+
]
|
|
117
|
+
TruncateOption = Annotated[
|
|
118
|
+
int | None,
|
|
119
|
+
typer.Option("--truncate", min=1, help="Truncate values longer than N characters."),
|
|
120
|
+
]
|
|
121
|
+
PageOption = Annotated[
|
|
122
|
+
int | None,
|
|
123
|
+
typer.Option("--page", min=1, help="Print only page N of the rows."),
|
|
124
|
+
]
|
|
125
|
+
PageSizeOption = Annotated[
|
|
126
|
+
int | None,
|
|
127
|
+
typer.Option("--page-size", min=1, help="Rows per page (default: what fits on the screen)."),
|
|
128
|
+
]
|
|
129
|
+
PagerOption = Annotated[
|
|
130
|
+
bool,
|
|
131
|
+
typer.Option("--pager", help="Send the output to $PAGER (default: less -R) on a terminal."),
|
|
132
|
+
]
|
|
133
|
+
ColorOption = Annotated[
|
|
134
|
+
bool | None,
|
|
135
|
+
typer.Option(
|
|
136
|
+
"--color/--no-color",
|
|
137
|
+
help="Force or disable ANSI colors. By default they are used only on a terminal "
|
|
138
|
+
"and disabled when NO_COLOR is set.",
|
|
139
|
+
),
|
|
140
|
+
]
|
|
141
|
+
WidthOption = Annotated[
|
|
142
|
+
int | None,
|
|
143
|
+
typer.Option(
|
|
144
|
+
"--width",
|
|
145
|
+
"-W",
|
|
146
|
+
min=1,
|
|
147
|
+
help="Width in columns to fit the output into. Defaults to the terminal width.",
|
|
148
|
+
),
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
_EXTENSIONS = {
|
|
152
|
+
OutputFormat.TABLE: "txt",
|
|
153
|
+
OutputFormat.CSV: "csv",
|
|
154
|
+
OutputFormat.TSV: "tsv",
|
|
155
|
+
OutputFormat.JSON: "json",
|
|
156
|
+
OutputFormat.MARKDOWN: "md",
|
|
157
|
+
}
|
|
158
|
+
_FORMAT_BY_SUFFIX = {".csv": OutputFormat.CSV, ".tsv": OutputFormat.TSV, ".json": OutputFormat.JSON}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# --- Helpers ------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _version_callback(value: bool) -> None:
|
|
165
|
+
if value:
|
|
166
|
+
typer.echo(f"sqlitexplorer {__version__}")
|
|
167
|
+
raise typer.Exit()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@app.callback()
|
|
171
|
+
def _main(
|
|
172
|
+
version: Annotated[
|
|
173
|
+
bool,
|
|
174
|
+
typer.Option(
|
|
175
|
+
"--version",
|
|
176
|
+
"-V",
|
|
177
|
+
callback=_version_callback,
|
|
178
|
+
is_eager=True,
|
|
179
|
+
help="Show the version and exit.",
|
|
180
|
+
),
|
|
181
|
+
] = False,
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Explore SQLite databases from the terminal."""
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _error_message(error: ExplorerError) -> str:
|
|
187
|
+
if isinstance(error, ReadOnlyError):
|
|
188
|
+
return f"{error}. Pass --write to allow changes."
|
|
189
|
+
return str(error)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _report(error: ExplorerError) -> None:
|
|
193
|
+
typer.secho(f"Error: {_error_message(error)}", err=True, fg=typer.colors.RED)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _fail(message: str) -> NoReturn:
|
|
197
|
+
typer.secho(f"Error: {message}", err=True, fg=typer.colors.RED)
|
|
198
|
+
raise typer.Exit(code=1)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@contextmanager
|
|
202
|
+
def _reporting_errors() -> Iterator[None]:
|
|
203
|
+
"""Turn ExplorerError and OSError into a message on stderr and exit status 1."""
|
|
204
|
+
try:
|
|
205
|
+
yield
|
|
206
|
+
except ExplorerError as error:
|
|
207
|
+
_fail(_error_message(error))
|
|
208
|
+
except OSError as error:
|
|
209
|
+
_fail(str(error))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
OUTPUT_PARAMETERS: dict[str, tuple[object, object]] = {
|
|
213
|
+
"output_format": (FormatOption, OutputFormat.TABLE),
|
|
214
|
+
"null": (NullOption, "NULL"),
|
|
215
|
+
"truncate": (TruncateOption, None),
|
|
216
|
+
"page": (PageOption, None),
|
|
217
|
+
"page_size": (PageSizeOption, None),
|
|
218
|
+
"pager": (PagerOption, False),
|
|
219
|
+
"color": (ColorOption, None),
|
|
220
|
+
"width": (WidthOption, None),
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def with_output_options(command: Callable[..., None]) -> Callable[..., None]:
|
|
225
|
+
"""Expose the shared output options on *command* and hand them over as ``options``.
|
|
226
|
+
|
|
227
|
+
Typer has no option groups, so instead of repeating the eight output
|
|
228
|
+
parameters in every command this decorator appends them to the signature
|
|
229
|
+
Typer inspects, and packs the values it receives into an
|
|
230
|
+
:class:`OutputOptions` passed to the command as its ``options`` argument.
|
|
231
|
+
"""
|
|
232
|
+
signature = inspect.signature(command, eval_str=True)
|
|
233
|
+
own = [parameter for parameter in signature.parameters.values() if parameter.name != "options"]
|
|
234
|
+
shared = [
|
|
235
|
+
inspect.Parameter(
|
|
236
|
+
name, inspect.Parameter.KEYWORD_ONLY, default=default, annotation=annotation
|
|
237
|
+
)
|
|
238
|
+
for name, (annotation, default) in OUTPUT_PARAMETERS.items()
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
@functools.wraps(command)
|
|
242
|
+
def wrapper(**arguments: object) -> None:
|
|
243
|
+
values = {name: arguments.pop(name) for name in OUTPUT_PARAMETERS}
|
|
244
|
+
values["format"] = values.pop("output_format")
|
|
245
|
+
command(options=OutputOptions(**values), **arguments)
|
|
246
|
+
|
|
247
|
+
wrapper.__signature__ = signature.replace(parameters=[*own, *shared]) # type: ignore[attr-defined]
|
|
248
|
+
wrapper.__annotations__ = {
|
|
249
|
+
parameter.name: parameter.annotation for parameter in [*own, *shared]
|
|
250
|
+
}
|
|
251
|
+
return wrapper
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _pairs(values: Sequence[str] | None, *, option: str) -> list[tuple[str, str]]:
|
|
255
|
+
pairs = []
|
|
256
|
+
for value in values or []:
|
|
257
|
+
name, separator, rest = value.partition("=")
|
|
258
|
+
if not separator or not name.strip():
|
|
259
|
+
_fail(f"{option} expects NAME=VALUE, got {value!r}")
|
|
260
|
+
pairs.append((name.strip(), rest))
|
|
261
|
+
return pairs
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _parameters(values: Sequence[str] | None) -> dict[str, object]:
|
|
265
|
+
return {name.lstrip(":@$"): parse_number(raw) for name, raw in _pairs(values, option="--param")}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _attach_all(db: Explorer, values: Sequence[str] | None, *, write: bool) -> None:
|
|
269
|
+
for alias, raw_path in _pairs(values, option="--attach"):
|
|
270
|
+
path = Path(raw_path)
|
|
271
|
+
if not path.is_file():
|
|
272
|
+
_fail(f"--attach: no such file: {path}")
|
|
273
|
+
db.attach(alias, path, write=write)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _read_sql(sql: str | None, file: Path | None) -> list[str]:
|
|
277
|
+
if (sql is None) == (file is None):
|
|
278
|
+
_fail("give either an SQL statement or --file, not both")
|
|
279
|
+
if file is not None:
|
|
280
|
+
text = file.read_text(encoding="utf-8")
|
|
281
|
+
elif sql == "-":
|
|
282
|
+
text = sys.stdin.read()
|
|
283
|
+
else:
|
|
284
|
+
text = sql or ""
|
|
285
|
+
statements = split_statements(text)
|
|
286
|
+
if not statements:
|
|
287
|
+
_fail("no SQL statement given")
|
|
288
|
+
return statements
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _page_window(options: OutputOptions) -> Page | None:
|
|
292
|
+
"""The page requested with --page/--page-size, or None to stream everything."""
|
|
293
|
+
if options.page is None and options.page_size is None:
|
|
294
|
+
return None
|
|
295
|
+
return Page(options.page or 1, options.page_size or default_page_size())
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _split_list(value: str | None) -> list[str] | None:
|
|
299
|
+
if value is None:
|
|
300
|
+
return None
|
|
301
|
+
return [item.strip() for item in value.split(",") if item.strip()]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# --- Exploration --------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@app.command()
|
|
308
|
+
@with_output_options
|
|
309
|
+
def tables(
|
|
310
|
+
database: DatabaseArg,
|
|
311
|
+
include_internal: AllOption = False,
|
|
312
|
+
count: Annotated[
|
|
313
|
+
bool,
|
|
314
|
+
typer.Option(
|
|
315
|
+
"--count/--no-count",
|
|
316
|
+
help="Count the rows of every table and view (--no-count is faster on big ones).",
|
|
317
|
+
),
|
|
318
|
+
] = True,
|
|
319
|
+
*,
|
|
320
|
+
options: OutputOptions,
|
|
321
|
+
) -> None:
|
|
322
|
+
"""List the tables and views of the database with their row counts."""
|
|
323
|
+
with _reporting_errors(), open_database(database) as db:
|
|
324
|
+
result = db.tables(include_internal=include_internal, count=count)
|
|
325
|
+
emit(result, options, empty="(no tables)")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@app.command()
|
|
329
|
+
def schema(
|
|
330
|
+
database: DatabaseArg,
|
|
331
|
+
name: Annotated[
|
|
332
|
+
str | None,
|
|
333
|
+
typer.Argument(
|
|
334
|
+
show_default=False,
|
|
335
|
+
help="Show only this table or view, with its indexes and triggers.",
|
|
336
|
+
autocompletion=complete_table,
|
|
337
|
+
),
|
|
338
|
+
] = None,
|
|
339
|
+
include_internal: AllOption = False,
|
|
340
|
+
) -> None:
|
|
341
|
+
"""Print the CREATE statements stored in the database."""
|
|
342
|
+
with _reporting_errors(), open_database(database) as db:
|
|
343
|
+
statements = db.schema(name, include_internal=include_internal)
|
|
344
|
+
if not statements:
|
|
345
|
+
typer.echo("(empty schema)")
|
|
346
|
+
return
|
|
347
|
+
typer.echo("\n\n".join(f"{statement};" for statement in statements))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@app.command()
|
|
351
|
+
@with_output_options
|
|
352
|
+
def describe(
|
|
353
|
+
database: DatabaseArg,
|
|
354
|
+
table: TableArg,
|
|
355
|
+
*,
|
|
356
|
+
options: OutputOptions,
|
|
357
|
+
) -> None:
|
|
358
|
+
"""Show the columns of a table or view: type, NOT NULL, default and primary key."""
|
|
359
|
+
with _reporting_errors(), open_database(database) as db:
|
|
360
|
+
emit(db.columns(table), options)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@app.command()
|
|
364
|
+
@with_output_options
|
|
365
|
+
def indexes(
|
|
366
|
+
database: DatabaseArg,
|
|
367
|
+
table: OptionalTableArg = None,
|
|
368
|
+
*,
|
|
369
|
+
options: OutputOptions,
|
|
370
|
+
) -> None:
|
|
371
|
+
"""List the indexes of a table, or of every table, with the columns they cover."""
|
|
372
|
+
with _reporting_errors(), open_database(database) as db:
|
|
373
|
+
emit(db.indexes(table), options, empty="(no indexes)")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@app.command()
|
|
377
|
+
@with_output_options
|
|
378
|
+
def foreign_keys(
|
|
379
|
+
database: DatabaseArg,
|
|
380
|
+
table: OptionalTableArg = None,
|
|
381
|
+
*,
|
|
382
|
+
options: OutputOptions,
|
|
383
|
+
) -> None:
|
|
384
|
+
"""List the foreign keys declared by a table, or by every table."""
|
|
385
|
+
with _reporting_errors(), open_database(database) as db:
|
|
386
|
+
emit(db.foreign_keys(table), options, empty="(no foreign keys)")
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@app.command()
|
|
390
|
+
@with_output_options
|
|
391
|
+
def info(
|
|
392
|
+
database: DatabaseArg,
|
|
393
|
+
check: Annotated[
|
|
394
|
+
bool, typer.Option("--check", help="Run PRAGMA integrity_check (slow on big files).")
|
|
395
|
+
] = False,
|
|
396
|
+
*,
|
|
397
|
+
options: OutputOptions,
|
|
398
|
+
) -> None:
|
|
399
|
+
"""Show facts about the database file: size, pragmas and object counts."""
|
|
400
|
+
with _reporting_errors(), open_database(database) as db:
|
|
401
|
+
emit(db.info(check=check), options)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@app.command()
|
|
405
|
+
@with_output_options
|
|
406
|
+
def show(
|
|
407
|
+
database: DatabaseArg,
|
|
408
|
+
table: TableArg,
|
|
409
|
+
columns: Annotated[
|
|
410
|
+
str | None,
|
|
411
|
+
typer.Option("--columns", "-c", help="Comma-separated list of columns to print."),
|
|
412
|
+
] = None,
|
|
413
|
+
where: Annotated[
|
|
414
|
+
str | None,
|
|
415
|
+
typer.Option("--where", help="Condition to filter the rows (raw SQL)."),
|
|
416
|
+
] = None,
|
|
417
|
+
order_by: Annotated[str | None, typer.Option("--order-by", help="Column to sort by.")] = None,
|
|
418
|
+
descending: Annotated[bool, typer.Option("--desc", help="Sort in descending order.")] = False,
|
|
419
|
+
limit: Annotated[
|
|
420
|
+
int | None,
|
|
421
|
+
typer.Option("--limit", "-n", min=0, help="Maximum number of rows to print."),
|
|
422
|
+
] = None,
|
|
423
|
+
offset: Annotated[int, typer.Option("--offset", min=0, help="Number of rows to skip.")] = 0,
|
|
424
|
+
*,
|
|
425
|
+
options: OutputOptions,
|
|
426
|
+
) -> None:
|
|
427
|
+
"""Print the rows of a table or view."""
|
|
428
|
+
selection = {
|
|
429
|
+
"columns": _split_list(columns),
|
|
430
|
+
"where": where,
|
|
431
|
+
"order_by": order_by,
|
|
432
|
+
"descending": descending,
|
|
433
|
+
"limit": limit,
|
|
434
|
+
"offset": offset,
|
|
435
|
+
}
|
|
436
|
+
with _reporting_errors(), open_database(database) as db:
|
|
437
|
+
window = _page_window(options)
|
|
438
|
+
if window is None:
|
|
439
|
+
emit_stream(db.stream_rows(table, **selection), options)
|
|
440
|
+
else:
|
|
441
|
+
emit(db.rows(table, **selection, page=window), options)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@app.command()
|
|
445
|
+
@with_output_options
|
|
446
|
+
def stats(
|
|
447
|
+
database: DatabaseArg,
|
|
448
|
+
table: TableArg,
|
|
449
|
+
top: Annotated[
|
|
450
|
+
int,
|
|
451
|
+
typer.Option(
|
|
452
|
+
"--top",
|
|
453
|
+
min=0,
|
|
454
|
+
help="How many frequent values to list per column; 0 skips them, which is much "
|
|
455
|
+
"faster on big tables.",
|
|
456
|
+
),
|
|
457
|
+
] = 3,
|
|
458
|
+
columns: Annotated[
|
|
459
|
+
str | None,
|
|
460
|
+
typer.Option("--columns", "-c", help="Comma-separated list of columns to analyse."),
|
|
461
|
+
] = None,
|
|
462
|
+
sample: Annotated[
|
|
463
|
+
int | None,
|
|
464
|
+
typer.Option(
|
|
465
|
+
"--sample",
|
|
466
|
+
min=1,
|
|
467
|
+
help="Compute on a random sample of N rows instead of the whole table.",
|
|
468
|
+
),
|
|
469
|
+
] = None,
|
|
470
|
+
*,
|
|
471
|
+
options: OutputOptions,
|
|
472
|
+
) -> None:
|
|
473
|
+
"""Per-column statistics: nulls, distinct values, min, max and most frequent values."""
|
|
474
|
+
with _reporting_errors(), open_database(database) as db:
|
|
475
|
+
report = db.stats(table, top=top, columns=_split_list(columns) or None, sample=sample)
|
|
476
|
+
emit(report.result, options)
|
|
477
|
+
if report.sampled:
|
|
478
|
+
plural = "" if report.rows == 1 else "s"
|
|
479
|
+
typer.echo(f"computed on a random sample of {report.rows} row{plural}", err=True)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
@app.command()
|
|
483
|
+
@with_output_options
|
|
484
|
+
def search(
|
|
485
|
+
database: DatabaseArg,
|
|
486
|
+
text: Annotated[
|
|
487
|
+
str, typer.Argument(show_default=False, help="Text to look for (case-insensitive).")
|
|
488
|
+
],
|
|
489
|
+
table: Annotated[
|
|
490
|
+
list[str] | None,
|
|
491
|
+
typer.Option(
|
|
492
|
+
"--table",
|
|
493
|
+
"-t",
|
|
494
|
+
help="Only search these tables. Repeatable.",
|
|
495
|
+
autocompletion=complete_table,
|
|
496
|
+
),
|
|
497
|
+
] = None,
|
|
498
|
+
limit: Annotated[
|
|
499
|
+
int | None, typer.Option("--limit", "-n", min=1, help="Stop after N matches.")
|
|
500
|
+
] = None,
|
|
501
|
+
*,
|
|
502
|
+
options: OutputOptions,
|
|
503
|
+
) -> None:
|
|
504
|
+
"""Find a text in every column of every table."""
|
|
505
|
+
with _reporting_errors(), open_database(database) as db:
|
|
506
|
+
emit(db.search(text, tables=table, limit=limit), options, empty="(no matches)")
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
# --- Queries ------------------------------------------------------------------
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
@app.command()
|
|
513
|
+
@with_output_options
|
|
514
|
+
def query(
|
|
515
|
+
database: DatabaseArg,
|
|
516
|
+
sql: Annotated[
|
|
517
|
+
str | None,
|
|
518
|
+
typer.Argument(show_default=False, help="SQL to run, or - to read it from stdin."),
|
|
519
|
+
] = None,
|
|
520
|
+
file: Annotated[
|
|
521
|
+
Path | None,
|
|
522
|
+
typer.Option(
|
|
523
|
+
"--file",
|
|
524
|
+
"-F",
|
|
525
|
+
exists=True,
|
|
526
|
+
dir_okay=False,
|
|
527
|
+
readable=True,
|
|
528
|
+
help="Read the SQL from a file.",
|
|
529
|
+
),
|
|
530
|
+
] = None,
|
|
531
|
+
write: WriteOption = False,
|
|
532
|
+
explain: Annotated[
|
|
533
|
+
bool, typer.Option("--explain", help="Show the query plan instead of running it.")
|
|
534
|
+
] = False,
|
|
535
|
+
time_it: Annotated[
|
|
536
|
+
bool, typer.Option("--time", help="Report rows and elapsed time on stderr.")
|
|
537
|
+
] = False,
|
|
538
|
+
params: ParamOption = None,
|
|
539
|
+
attach: AttachOption = None,
|
|
540
|
+
watch: Annotated[
|
|
541
|
+
float | None,
|
|
542
|
+
typer.Option("--watch", min=0.1, help="Re-run every N seconds until Ctrl-C."),
|
|
543
|
+
] = None,
|
|
544
|
+
*,
|
|
545
|
+
options: OutputOptions,
|
|
546
|
+
) -> None:
|
|
547
|
+
"""Run SQL statements and print their results."""
|
|
548
|
+
statements = _read_sql(sql, file)
|
|
549
|
+
if watch is not None and (options.pager or write):
|
|
550
|
+
_fail("--watch cannot be combined with --pager or --write")
|
|
551
|
+
parameters = _parameters(params)
|
|
552
|
+
with _reporting_errors(), open_database(database, write=write) as db:
|
|
553
|
+
_attach_all(db, attach, write=write)
|
|
554
|
+
try:
|
|
555
|
+
first = True
|
|
556
|
+
while True:
|
|
557
|
+
if watch is not None:
|
|
558
|
+
if stdout_is_tty():
|
|
559
|
+
typer.echo("\x1b[2J\x1b[H", nl=False)
|
|
560
|
+
elif not first:
|
|
561
|
+
typer.echo("")
|
|
562
|
+
for statement in statements:
|
|
563
|
+
_run_statement(
|
|
564
|
+
db, statement, parameters, options=options, explain=explain, time_it=time_it
|
|
565
|
+
)
|
|
566
|
+
if watch is None:
|
|
567
|
+
return
|
|
568
|
+
first = False
|
|
569
|
+
time.sleep(watch)
|
|
570
|
+
except KeyboardInterrupt:
|
|
571
|
+
if watch is None:
|
|
572
|
+
raise
|
|
573
|
+
typer.echo("", err=True)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _run_statement(
|
|
577
|
+
db: Explorer,
|
|
578
|
+
statement: str,
|
|
579
|
+
parameters: dict[str, object],
|
|
580
|
+
*,
|
|
581
|
+
options: OutputOptions,
|
|
582
|
+
explain: bool,
|
|
583
|
+
time_it: bool,
|
|
584
|
+
) -> None:
|
|
585
|
+
started = time.perf_counter()
|
|
586
|
+
bound = parameters if parameters else ()
|
|
587
|
+
window = _page_window(options)
|
|
588
|
+
if explain:
|
|
589
|
+
result = db.explain(statement, bound)
|
|
590
|
+
emit(result, options)
|
|
591
|
+
count = len(result.rows)
|
|
592
|
+
elif window is None:
|
|
593
|
+
stream = db.stream(statement, bound)
|
|
594
|
+
if stream.returns_rows:
|
|
595
|
+
count = emit_stream(stream, options)
|
|
596
|
+
else:
|
|
597
|
+
typer.echo(ok_message(stream))
|
|
598
|
+
count = 0
|
|
599
|
+
else:
|
|
600
|
+
result = db.execute(statement, bound, page=window)
|
|
601
|
+
if result.returns_rows:
|
|
602
|
+
emit(result, options)
|
|
603
|
+
count = result.total if result.total is not None else len(result.rows)
|
|
604
|
+
else:
|
|
605
|
+
typer.echo(ok_message(result))
|
|
606
|
+
count = 0
|
|
607
|
+
elapsed = (time.perf_counter() - started) * 1000
|
|
608
|
+
if time_it:
|
|
609
|
+
plural = "" if count == 1 else "s"
|
|
610
|
+
typer.echo(f"{count} row{plural} in {elapsed:.1f} ms", err=True)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
@app.command()
|
|
614
|
+
def chart(
|
|
615
|
+
database: DatabaseArg,
|
|
616
|
+
sql: Annotated[
|
|
617
|
+
str,
|
|
618
|
+
typer.Argument(
|
|
619
|
+
show_default=False,
|
|
620
|
+
help="Query whose first column is X and the other numeric columns are series.",
|
|
621
|
+
),
|
|
622
|
+
],
|
|
623
|
+
kind: Annotated[
|
|
624
|
+
ChartKind, typer.Option("--kind", "-k", case_sensitive=False, help="Kind of chart.")
|
|
625
|
+
] = ChartKind.LINE,
|
|
626
|
+
height: Annotated[int, typer.Option("--height", min=3, help="Height in rows.")] = 15,
|
|
627
|
+
bins: Annotated[int, typer.Option("--bins", min=1, help="Bins of a histogram.")] = 10,
|
|
628
|
+
x_label: Annotated[str | None, typer.Option("--x-label", help="Label of the X axis.")] = None,
|
|
629
|
+
y_label: Annotated[str | None, typer.Option("--y-label", help="Label of the Y axis.")] = None,
|
|
630
|
+
params: ParamOption = None,
|
|
631
|
+
width: WidthOption = None,
|
|
632
|
+
color: ColorOption = None,
|
|
633
|
+
) -> None:
|
|
634
|
+
"""Draw the result of a query as a line chart, scatter plot or histogram."""
|
|
635
|
+
parameters = _parameters(params)
|
|
636
|
+
with _reporting_errors(), open_database(database) as db:
|
|
637
|
+
text = sys.stdin.read() if sql == "-" else sql
|
|
638
|
+
result = db.execute(text, parameters if parameters else ())
|
|
639
|
+
if not result.returns_rows:
|
|
640
|
+
_fail("the statement returned no rows")
|
|
641
|
+
use_color = resolve_color(color)
|
|
642
|
+
screen = width if width is not None else shutil.get_terminal_size().columns
|
|
643
|
+
if kind is ChartKind.HIST:
|
|
644
|
+
values, skipped = histogram_values(result)
|
|
645
|
+
drawing = render_histogram(
|
|
646
|
+
values,
|
|
647
|
+
bins=bins,
|
|
648
|
+
width=screen,
|
|
649
|
+
height=height,
|
|
650
|
+
color=use_color,
|
|
651
|
+
x_label=x_label or result.columns[0],
|
|
652
|
+
y_label=y_label or "count",
|
|
653
|
+
)
|
|
654
|
+
else:
|
|
655
|
+
series, skipped = series_from_result(result)
|
|
656
|
+
default_y = series[0].label if len(series) == 1 else "value"
|
|
657
|
+
drawing = render_chart(
|
|
658
|
+
series,
|
|
659
|
+
kind=kind,
|
|
660
|
+
width=screen,
|
|
661
|
+
height=height,
|
|
662
|
+
color=use_color,
|
|
663
|
+
x_label=x_label or result.columns[0],
|
|
664
|
+
y_label=y_label or default_y,
|
|
665
|
+
)
|
|
666
|
+
if skipped:
|
|
667
|
+
plural = "" if skipped == 1 else "s"
|
|
668
|
+
typer.echo(f"skipped {skipped} row{plural} with NULL values", err=True)
|
|
669
|
+
typer.echo(drawing, color=use_color)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
# --- Export / import ----------------------------------------------------------
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
@app.command()
|
|
676
|
+
def dump(
|
|
677
|
+
database: DatabaseArg,
|
|
678
|
+
output: Annotated[
|
|
679
|
+
Path | None,
|
|
680
|
+
typer.Option(
|
|
681
|
+
"--output", "-o", dir_okay=False, help="Write to this file instead of stdout."
|
|
682
|
+
),
|
|
683
|
+
] = None,
|
|
684
|
+
) -> None:
|
|
685
|
+
"""Print the whole database as SQL, like the .dump command of the sqlite3 shell."""
|
|
686
|
+
with _reporting_errors(), open_database(database) as db:
|
|
687
|
+
if output is None:
|
|
688
|
+
for line in db.dump():
|
|
689
|
+
typer.echo(line)
|
|
690
|
+
else:
|
|
691
|
+
with output.open("w", encoding="utf-8") as handle:
|
|
692
|
+
for line in db.dump():
|
|
693
|
+
handle.write(line + "\n")
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
@app.command()
|
|
697
|
+
def export(
|
|
698
|
+
database: DatabaseArg,
|
|
699
|
+
table: OptionalTableArg = None,
|
|
700
|
+
every: Annotated[
|
|
701
|
+
bool,
|
|
702
|
+
typer.Option(
|
|
703
|
+
"--all", "-a", help="Export every table and view, one file each, into --output."
|
|
704
|
+
),
|
|
705
|
+
] = False,
|
|
706
|
+
output_format: FormatOption = OutputFormat.CSV,
|
|
707
|
+
output: Annotated[
|
|
708
|
+
Path | None,
|
|
709
|
+
typer.Option(
|
|
710
|
+
"--output", "-o", help="File to write, or directory with --all. Default: stdout."
|
|
711
|
+
),
|
|
712
|
+
] = None,
|
|
713
|
+
null: NullOption = "NULL",
|
|
714
|
+
truncate: TruncateOption = None,
|
|
715
|
+
) -> None:
|
|
716
|
+
"""Write the rows of a table (or of every table) to a file."""
|
|
717
|
+
if (table is None) == (not every):
|
|
718
|
+
_fail("give a table name or --all")
|
|
719
|
+
if every and output is None:
|
|
720
|
+
_fail("--all needs --output DIRECTORY")
|
|
721
|
+
options = OutputOptions(format=output_format, null=null, truncate=truncate, color=False)
|
|
722
|
+
with _reporting_errors(), open_database(database) as db:
|
|
723
|
+
if every:
|
|
724
|
+
assert output is not None
|
|
725
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
726
|
+
for name in db.names():
|
|
727
|
+
target = output / f"{name}.{_EXTENSIONS[output_format]}"
|
|
728
|
+
with target.open("w", encoding="utf-8") as handle:
|
|
729
|
+
write_rows(db.stream_rows(name), options, handle)
|
|
730
|
+
return
|
|
731
|
+
assert table is not None
|
|
732
|
+
if output is None:
|
|
733
|
+
write_rows(db.stream_rows(table), options, sys.stdout)
|
|
734
|
+
else:
|
|
735
|
+
with output.open("w", encoding="utf-8") as handle:
|
|
736
|
+
write_rows(db.stream_rows(table), options, handle)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
@app.command("import")
|
|
740
|
+
def import_(
|
|
741
|
+
database: DatabaseArg,
|
|
742
|
+
table: TableArg,
|
|
743
|
+
file: Annotated[
|
|
744
|
+
Path,
|
|
745
|
+
typer.Argument(
|
|
746
|
+
exists=True,
|
|
747
|
+
dir_okay=False,
|
|
748
|
+
readable=True,
|
|
749
|
+
show_default=False,
|
|
750
|
+
help="CSV, TSV or JSON file to load.",
|
|
751
|
+
),
|
|
752
|
+
],
|
|
753
|
+
output_format: Annotated[
|
|
754
|
+
OutputFormat | None,
|
|
755
|
+
typer.Option(
|
|
756
|
+
"--format",
|
|
757
|
+
"-f",
|
|
758
|
+
case_sensitive=False,
|
|
759
|
+
help="Input format (default: from the file extension).",
|
|
760
|
+
),
|
|
761
|
+
] = None,
|
|
762
|
+
delimiter: Annotated[
|
|
763
|
+
str | None, typer.Option("--delimiter", help="Field delimiter for CSV/TSV.")
|
|
764
|
+
] = None,
|
|
765
|
+
) -> None:
|
|
766
|
+
"""Load a CSV, TSV or JSON file into a table, creating it if needed."""
|
|
767
|
+
input_format = output_format or _FORMAT_BY_SUFFIX.get(file.suffix.lower())
|
|
768
|
+
if input_format is None:
|
|
769
|
+
_fail(f"cannot tell the format of {file.name}; pass --format")
|
|
770
|
+
with _reporting_errors(), open_database(database, write=True) as db:
|
|
771
|
+
headers, raw_rows = parse_rows(
|
|
772
|
+
file.read_text(encoding="utf-8-sig"), input_format, delimiter=delimiter
|
|
773
|
+
)
|
|
774
|
+
types = infer_types(raw_rows, len(headers))
|
|
775
|
+
count = db.import_rows(
|
|
776
|
+
table, list(zip(headers, types, strict=True)), coerce_rows(raw_rows, types)
|
|
777
|
+
)
|
|
778
|
+
plural = "" if count == 1 else "s"
|
|
779
|
+
typer.echo(f"OK ({count} row{plural} imported into {table})")
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
@app.command()
|
|
783
|
+
def diff(
|
|
784
|
+
left: DatabaseArg,
|
|
785
|
+
right: Annotated[
|
|
786
|
+
Path,
|
|
787
|
+
typer.Argument(
|
|
788
|
+
exists=True,
|
|
789
|
+
dir_okay=False,
|
|
790
|
+
readable=True,
|
|
791
|
+
show_default=False,
|
|
792
|
+
help="Database to compare against.",
|
|
793
|
+
),
|
|
794
|
+
],
|
|
795
|
+
include_internal: AllOption = False,
|
|
796
|
+
) -> None:
|
|
797
|
+
"""Compare the schemas of two databases. Exit status 1 when they differ."""
|
|
798
|
+
with _reporting_errors():
|
|
799
|
+
with open_database(left) as db:
|
|
800
|
+
before = {(k, n): s for k, n, s in db.objects(include_internal=include_internal)}
|
|
801
|
+
with open_database(right) as db:
|
|
802
|
+
after = {(k, n): s for k, n, s in db.objects(include_internal=include_internal)}
|
|
803
|
+
lines: list[str] = []
|
|
804
|
+
for key in sorted(set(before) | set(after)):
|
|
805
|
+
kind, name = key
|
|
806
|
+
if key not in after:
|
|
807
|
+
lines.append(f"- {kind} {name}")
|
|
808
|
+
elif key not in before:
|
|
809
|
+
lines.append(f"+ {kind} {name}")
|
|
810
|
+
elif before[key] != after[key]:
|
|
811
|
+
lines.append(f"~ {kind} {name}")
|
|
812
|
+
lines.extend(
|
|
813
|
+
difflib.unified_diff(
|
|
814
|
+
before[key].splitlines(),
|
|
815
|
+
after[key].splitlines(),
|
|
816
|
+
fromfile=f"{left}:{name}",
|
|
817
|
+
tofile=f"{right}:{name}",
|
|
818
|
+
lineterm="",
|
|
819
|
+
)
|
|
820
|
+
)
|
|
821
|
+
if not lines:
|
|
822
|
+
typer.echo("(no differences)")
|
|
823
|
+
return
|
|
824
|
+
typer.echo("\n".join(lines))
|
|
825
|
+
raise typer.Exit(code=1)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
# --- Shell --------------------------------------------------------------------
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
@app.command()
|
|
832
|
+
def shell(
|
|
833
|
+
database: DatabaseArg,
|
|
834
|
+
write: WriteOption = False,
|
|
835
|
+
attach: AttachOption = None,
|
|
836
|
+
output_format: FormatOption = OutputFormat.TABLE,
|
|
837
|
+
null: NullOption = "NULL",
|
|
838
|
+
truncate: TruncateOption = None,
|
|
839
|
+
color: ColorOption = None,
|
|
840
|
+
width: WidthOption = None,
|
|
841
|
+
) -> None:
|
|
842
|
+
"""Open an interactive shell: SQL statements, dot-commands, history and completion."""
|
|
843
|
+
options = OutputOptions(
|
|
844
|
+
format=output_format, null=null, truncate=truncate, color=color, width=width
|
|
845
|
+
)
|
|
846
|
+
try:
|
|
847
|
+
interactive = sys.stdin.isatty()
|
|
848
|
+
except (AttributeError, ValueError):
|
|
849
|
+
interactive = False
|
|
850
|
+
with _reporting_errors(), open_database(database, write=write) as db:
|
|
851
|
+
_attach_all(db, attach, write=write)
|
|
852
|
+
run_shell(db, options=options, write=write, interactive=interactive, report_error=_report)
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def main() -> None:
|
|
856
|
+
"""Entry point of the ``sqlitexplorer`` console script."""
|
|
857
|
+
app()
|