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/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()