execsql2 2.1.2__py3-none-any.whl → 2.4.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.
Files changed (94) hide show
  1. execsql/cli/__init__.py +436 -0
  2. execsql/cli/dsn.py +86 -0
  3. execsql/cli/help.py +140 -0
  4. execsql/{cli.py → cli/run.py} +14 -589
  5. execsql/config.py +65 -1
  6. execsql/db/access.py +27 -15
  7. execsql/db/base.py +328 -215
  8. execsql/db/dsn.py +10 -5
  9. execsql/db/duckdb.py +6 -2
  10. execsql/db/factory.py +21 -0
  11. execsql/db/firebird.py +27 -19
  12. execsql/db/mysql.py +12 -7
  13. execsql/db/oracle.py +15 -11
  14. execsql/db/postgres.py +31 -16
  15. execsql/db/sqlite.py +15 -11
  16. execsql/db/sqlserver.py +16 -5
  17. execsql/exceptions.py +25 -7
  18. execsql/exporters/base.py +12 -1
  19. execsql/exporters/delimited.py +80 -35
  20. execsql/exporters/duckdb.py +6 -2
  21. execsql/exporters/feather.py +10 -6
  22. execsql/exporters/html.py +89 -69
  23. execsql/exporters/json.py +52 -45
  24. execsql/exporters/latex.py +37 -27
  25. execsql/exporters/ods.py +32 -11
  26. execsql/exporters/parquet.py +5 -2
  27. execsql/exporters/pretty.py +16 -9
  28. execsql/exporters/raw.py +22 -16
  29. execsql/exporters/sqlite.py +6 -2
  30. execsql/exporters/templates.py +39 -21
  31. execsql/exporters/values.py +26 -20
  32. execsql/exporters/xls.py +30 -11
  33. execsql/exporters/xml.py +31 -13
  34. execsql/exporters/zip.py +15 -0
  35. execsql/importers/base.py +6 -4
  36. execsql/importers/csv.py +8 -6
  37. execsql/importers/feather.py +6 -4
  38. execsql/importers/ods.py +6 -4
  39. execsql/importers/xls.py +6 -4
  40. execsql/metacommands/__init__.py +208 -1548
  41. execsql/metacommands/conditions.py +101 -27
  42. execsql/metacommands/control.py +8 -4
  43. execsql/metacommands/data.py +6 -6
  44. execsql/metacommands/debug.py +6 -2
  45. execsql/metacommands/dispatch.py +2011 -0
  46. execsql/metacommands/io.py +67 -1310
  47. execsql/metacommands/io_export.py +442 -0
  48. execsql/metacommands/io_fileops.py +287 -0
  49. execsql/metacommands/io_import.py +398 -0
  50. execsql/metacommands/io_write.py +248 -0
  51. execsql/metacommands/prompt.py +22 -66
  52. execsql/metacommands/system.py +7 -2
  53. execsql/models.py +7 -0
  54. execsql/parser.py +10 -0
  55. execsql/py.typed +0 -0
  56. execsql/script/__init__.py +95 -0
  57. execsql/script/control.py +162 -0
  58. execsql/{script.py → script/engine.py} +184 -402
  59. execsql/script/variables.py +281 -0
  60. execsql/types.py +49 -20
  61. execsql/utils/auth.py +2 -0
  62. execsql/utils/crypto.py +4 -6
  63. execsql/utils/datetime.py +1 -0
  64. execsql/utils/errors.py +11 -0
  65. execsql/utils/fileio.py +33 -8
  66. execsql/utils/gui.py +46 -0
  67. execsql/utils/mail.py +7 -17
  68. execsql/utils/numeric.py +2 -0
  69. execsql/utils/regex.py +9 -0
  70. execsql/utils/strings.py +16 -0
  71. execsql/utils/timer.py +2 -0
  72. execsql2-2.4.0.data/data/execsql2_extras/README.md +65 -0
  73. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/execsql.conf +1 -1
  74. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/METADATA +13 -6
  75. execsql2-2.4.0.dist-info/RECORD +108 -0
  76. execsql2-2.1.2.data/data/execsql2_extras/READ_ME.rst +0 -127
  77. execsql2-2.1.2.dist-info/RECORD +0 -96
  78. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  79. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  80. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  81. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  82. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  83. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  84. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  85. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  86. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  87. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/script_template.sql +0 -0
  88. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  89. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  90. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  91. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/WHEEL +0 -0
  92. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/entry_points.txt +0 -0
  93. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/licenses/LICENSE.txt +0 -0
  94. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/licenses/NOTICE +0 -0
@@ -0,0 +1,436 @@
1
+ """Command-line interface for execsql.
2
+
3
+ Parses arguments via Typer, then delegates to :func:`_run` for state
4
+ initialisation, database connection, and script execution.
5
+
6
+ Submodules:
7
+
8
+ - :mod:`execsql.cli.help` — Rich-formatted help output & console objects
9
+ - :mod:`execsql.cli.dsn` — Connection-string (DSN URL) parser
10
+ - :mod:`execsql.cli.run` — Core execution logic
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+ import traceback
17
+ from pathlib import Path
18
+
19
+ import typer
20
+
21
+ from execsql import __version__
22
+ from execsql.cli.dsn import _parse_connection_string, _SCHEME_TO_DBTYPE # noqa: F401 — re-export
23
+ from execsql.cli.help import _console, _err_console, _print_encodings, _print_metacommands # noqa: F401 — re-export
24
+ from execsql.cli.run import _connect_initial_db, _run # noqa: F401 — re-export
25
+ from execsql.exceptions import ConfigError, ErrInfo
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Typer app
30
+ # ---------------------------------------------------------------------------
31
+
32
+ app = typer.Typer(
33
+ name="execsql",
34
+ help="Run a SQL script against a database with metacommand support.",
35
+ add_completion=False,
36
+ rich_markup_mode="rich",
37
+ no_args_is_help=True,
38
+ )
39
+
40
+
41
+ def _version_callback(value: bool) -> None:
42
+ if value:
43
+ _console.print(f"execsql [bold cyan]{__version__}[/bold cyan]")
44
+ raise typer.Exit()
45
+
46
+
47
+ @app.command(
48
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
49
+ )
50
+ def main(
51
+ ctx: typer.Context,
52
+ # Positional args collected manually (script + optional server/db/file)
53
+ args: list[str] | None = typer.Argument(
54
+ None,
55
+ metavar="SQL_SCRIPT [SERVER DATABASE | DATABASE_FILE]",
56
+ help=(
57
+ "SQL script file to execute. Optionally followed by server and database "
58
+ "name (client-server DBs) or a database file path (file-based DBs)."
59
+ ),
60
+ ),
61
+ # Named options — grouped to mirror the original argparse interface
62
+ sub_vars: list[str] | None = typer.Option(
63
+ None,
64
+ "-a",
65
+ "--assign-arg",
66
+ metavar="VALUE",
67
+ help="Define the replacement string for a substitution variable [cyan]\\$ARG_x[/cyan].",
68
+ ),
69
+ boolean_int: str | None = typer.Option(
70
+ None,
71
+ "-b",
72
+ "--boolean-int",
73
+ metavar="{0,1,t,f,y,n}",
74
+ help="Treat integers 0 and 1 as boolean values.",
75
+ ),
76
+ make_dirs: str | None = typer.Option(
77
+ None,
78
+ "-d",
79
+ "--directories",
80
+ metavar="{0,1,t,f,y,n}",
81
+ help="Auto-create directories for EXPORT metacommand. [dim]n=no (default), y=yes[/dim]",
82
+ ),
83
+ database_encoding: str | None = typer.Option(
84
+ None,
85
+ "-e",
86
+ "--database-encoding",
87
+ help="Character encoding used in the database.",
88
+ ),
89
+ script_encoding: str | None = typer.Option(
90
+ None,
91
+ "-f",
92
+ "--script-encoding",
93
+ help="Character encoding of the script file. [dim]Default: UTF-8[/dim]",
94
+ ),
95
+ output_encoding: str | None = typer.Option(
96
+ None,
97
+ "-g",
98
+ "--output-encoding",
99
+ help="Encoding for WRITE and EXPORT output.",
100
+ ),
101
+ import_encoding: str | None = typer.Option(
102
+ None,
103
+ "-i",
104
+ "--import-encoding",
105
+ help="Encoding for data files used with IMPORT.",
106
+ ),
107
+ user_logfile: bool = typer.Option(
108
+ False,
109
+ "-l",
110
+ "--user-logfile",
111
+ help="Write a log file to [cyan]~/execsql.log[/cyan].",
112
+ ),
113
+ metacommands: bool = typer.Option(
114
+ False,
115
+ "-m",
116
+ "--metacommands",
117
+ help="List metacommands and exit.",
118
+ ),
119
+ new_db: bool = typer.Option(
120
+ False,
121
+ "-n",
122
+ "--new-db",
123
+ help="Create a new SQLite or Postgres database if it does not exist.",
124
+ ),
125
+ online_help: bool = typer.Option(
126
+ False,
127
+ "-o",
128
+ "--online-help",
129
+ help="Open the online documentation in the default browser.",
130
+ ),
131
+ port: int | None = typer.Option(
132
+ None,
133
+ "-p",
134
+ "--port",
135
+ help="Database server port.",
136
+ ),
137
+ scanlines: int | None = typer.Option(
138
+ None,
139
+ "-s",
140
+ "--scan-lines",
141
+ metavar="N",
142
+ help="Lines to scan for IMPORT format detection. [dim]0 = scan entire file.[/dim]",
143
+ ),
144
+ db_type: str | None = typer.Option(
145
+ None,
146
+ "-t",
147
+ "--type",
148
+ metavar="{a,d,p,s,l,m,k,o,f}",
149
+ help=(
150
+ "Database type: [bold]a[/bold]=MS-Access, [bold]p[/bold]=PostgreSQL, "
151
+ "[bold]s[/bold]=SQL Server, [bold]l[/bold]=SQLite, [bold]m[/bold]=MySQL/MariaDB, "
152
+ "[bold]k[/bold]=DuckDB, [bold]o[/bold]=Oracle, [bold]f[/bold]=Firebird, "
153
+ "[bold]d[/bold]=DSN."
154
+ ),
155
+ ),
156
+ user: str | None = typer.Option(
157
+ None,
158
+ "-u",
159
+ "--user",
160
+ help="Database user name.",
161
+ ),
162
+ use_gui: str | None = typer.Option(
163
+ None,
164
+ "-v",
165
+ "--visible-prompts",
166
+ metavar="{0,1,2,3}",
167
+ help=(
168
+ "GUI level: [bold]0[/bold]=none (default), [bold]1[/bold]=GUI for password/pause, "
169
+ "[bold]2[/bold]=GUI for password/pause + DB selection, [bold]3[/bold]=full GUI console."
170
+ ),
171
+ ),
172
+ gui_framework: str | None = typer.Option(
173
+ None,
174
+ "--gui-framework",
175
+ metavar="{tkinter,textual}",
176
+ help="GUI framework to use with [cyan]--visible-prompts[/cyan]. [dim]Default: tkinter[/dim]",
177
+ ),
178
+ no_passwd: bool = typer.Option(
179
+ False,
180
+ "-w",
181
+ "--no-passwd",
182
+ help="Skip password prompt when user is specified.",
183
+ ),
184
+ encodings: bool = typer.Option(
185
+ False,
186
+ "-y",
187
+ "--encodings",
188
+ help="List available encoding names and exit.",
189
+ ),
190
+ import_buffer: int | None = typer.Option(
191
+ None,
192
+ "-z",
193
+ "--import-buffer",
194
+ metavar="KB",
195
+ help="Import buffer size in KB. [dim]Default: 32[/dim]",
196
+ ),
197
+ command: str | None = typer.Option(
198
+ None,
199
+ "-c",
200
+ "--command",
201
+ metavar="SCRIPT",
202
+ help=(
203
+ "Execute an inline SQL/metacommand script string instead of a script file. "
204
+ "Use shell [cyan]$'line1\\nline2'[/cyan] syntax for multi-line scripts."
205
+ ),
206
+ ),
207
+ dry_run: bool = typer.Option(
208
+ False,
209
+ "--dry-run",
210
+ help=("Parse the script and print the command list without connecting to a database or executing anything."),
211
+ ),
212
+ dsn: str | None = typer.Option(
213
+ None,
214
+ "--dsn",
215
+ "--connection-string",
216
+ metavar="URL",
217
+ help=(
218
+ "Database connection URL, e.g. [cyan]postgresql://user:pass@host:5432/db[/cyan]. "
219
+ "Supported schemes: postgresql, mysql, mssql, oracle, firebird, sqlite, duckdb. "
220
+ "Overrides [cyan]-t[/cyan]/[cyan]-u[/cyan]/[cyan]-p[/cyan] and positional server/db args."
221
+ ),
222
+ ),
223
+ output_dir: str | None = typer.Option(
224
+ None,
225
+ "--output-dir",
226
+ metavar="DIR",
227
+ help=(
228
+ "Default base directory for EXPORT output files. "
229
+ "Relative paths in EXPORT metacommands are joined to this directory. "
230
+ "Absolute paths and [cyan]stdout[/cyan] are unaffected."
231
+ ),
232
+ ),
233
+ progress: bool = typer.Option(
234
+ False,
235
+ "--progress",
236
+ help="Show a progress bar for long-running IMPORT operations.",
237
+ ),
238
+ dump_keywords: bool = typer.Option(
239
+ False,
240
+ "--dump-keywords",
241
+ help="Dump all metacommand keywords as JSON and exit.",
242
+ ),
243
+ version: bool | None = typer.Option(
244
+ None,
245
+ "--version",
246
+ callback=_version_callback,
247
+ is_eager=True,
248
+ help="Show version and exit.",
249
+ ),
250
+ ) -> None:
251
+ """Run [bold]SQL_SCRIPT[/bold] against the specified database.
252
+
253
+ [dim]Positional arguments after the script file:[/dim]
254
+
255
+ [green]Client-server databases:[/green]
256
+ execsql script.sql [SERVER] [DATABASE]
257
+
258
+ [green]File-based databases (SQLite, DuckDB, Access):[/green]
259
+ execsql script.sql [DATABASE_FILE]
260
+ """
261
+ # ------------------------------------------------------------------
262
+ # Early exits (no script file needed)
263
+ # ------------------------------------------------------------------
264
+ if metacommands:
265
+ _print_metacommands()
266
+ raise typer.Exit()
267
+
268
+ if encodings:
269
+ _print_encodings()
270
+ raise typer.Exit()
271
+
272
+ if dump_keywords:
273
+ import json as _json
274
+
275
+ from execsql.metacommands import (
276
+ ALL_EXPORT_FORMATS,
277
+ DATABASE_TYPES,
278
+ DISPATCH_TABLE,
279
+ JSON_VARIANT_FORMATS,
280
+ METADATA_FORMATS,
281
+ QUERY_EXPORT_FORMATS,
282
+ SERVE_FORMATS,
283
+ TABLE_EXPORT_FORMATS,
284
+ )
285
+ from execsql.metacommands.conditions import CONDITIONAL_TABLE
286
+
287
+ mc_kw = DISPATCH_TABLE.keywords_by_category()
288
+ cond_kw = CONDITIONAL_TABLE.keywords_by_category()
289
+
290
+ data = {
291
+ "metacommands": {
292
+ "control": sorted(mc_kw.get("control", [])),
293
+ "block": sorted(
294
+ mc_kw.get("block", []) + ["BEGIN SCRIPT", "END SCRIPT", "BEGIN SQL", "END SQL"],
295
+ ),
296
+ "action": sorted(mc_kw.get("action", [])),
297
+ "config": sorted(mc_kw.get("config", [])),
298
+ "prompt": sorted(mc_kw.get("prompt", [])),
299
+ },
300
+ "conditions": sorted(cond_kw.get("condition", []) + ["IS_FALSE", "NOT", "OR"]),
301
+ "config_options": sorted(mc_kw.get("config_option", [])),
302
+ "export_formats": {
303
+ "query": sorted(QUERY_EXPORT_FORMATS),
304
+ "table": sorted(TABLE_EXPORT_FORMATS),
305
+ "serve": sorted(SERVE_FORMATS),
306
+ "metadata": sorted(METADATA_FORMATS),
307
+ "json_variants": sorted(JSON_VARIANT_FORMATS),
308
+ "all": sorted(ALL_EXPORT_FORMATS),
309
+ },
310
+ "database_types": sorted(DATABASE_TYPES),
311
+ "variable_patterns": {
312
+ "system": "!!$name!!",
313
+ "environment": "!!&name!!",
314
+ "parameter": "!!#name!!",
315
+ "column": "!!@name!!",
316
+ "local": "!!~name!!",
317
+ "local_alt": "!!+name!!",
318
+ "regular": "!!name!!",
319
+ "deferred": "!{name}!",
320
+ },
321
+ }
322
+ _console.print_json(_json.dumps(data, indent=2))
323
+ raise typer.Exit()
324
+
325
+ if online_help:
326
+ import webbrowser
327
+
328
+ webbrowser.open("https://execsql2.readthedocs.io/en/latest/", new=2, autoraise=True)
329
+ raise typer.Exit()
330
+
331
+ positional = args or []
332
+ if command is not None:
333
+ script_name = None # inline mode — no script file
334
+ else:
335
+ if not positional:
336
+ _err_console.print(
337
+ "[bold red]Error:[/bold red] No SQL script file specified. Use [cyan]-c[/cyan] to run an inline script.",
338
+ )
339
+ raise typer.Exit(code=1)
340
+ script_name = positional[0]
341
+ if not Path(script_name).exists():
342
+ _err_console.print(
343
+ f'[bold red]Error:[/bold red] SQL script file [cyan]"{script_name}"[/cyan] does not exist.',
344
+ )
345
+ raise typer.Exit(code=1)
346
+
347
+ # ------------------------------------------------------------------
348
+ # Validate positional args and db_type choice
349
+ # ------------------------------------------------------------------
350
+
351
+ if db_type and db_type not in ("a", "d", "p", "s", "l", "m", "k", "o", "f"):
352
+ _err_console.print(
353
+ f"[bold red]Error:[/bold red] Invalid database type [cyan]{db_type!r}[/cyan]. "
354
+ "Choose from: a, d, p, s, l, m, k, o, f",
355
+ )
356
+ raise typer.Exit(code=2)
357
+
358
+ if use_gui and use_gui not in ("0", "1", "2", "3"):
359
+ _err_console.print(
360
+ f"[bold red]Error:[/bold red] Invalid GUI level [cyan]{use_gui!r}[/cyan]. Choose from: 0, 1, 2, 3",
361
+ )
362
+ raise typer.Exit(code=2)
363
+
364
+ if gui_framework and gui_framework.lower() not in ("tkinter", "textual"):
365
+ _err_console.print(
366
+ f"[bold red]Error:[/bold red] Invalid GUI framework [cyan]{gui_framework!r}[/cyan]. Choose from: tkinter, textual",
367
+ )
368
+ raise typer.Exit(code=2)
369
+
370
+ if boolean_int and boolean_int.lower() not in ("0", "1", "t", "f", "y", "n"):
371
+ _err_console.print(
372
+ f"[bold red]Error:[/bold red] Invalid --boolean-int value [cyan]{boolean_int!r}[/cyan].",
373
+ )
374
+ raise typer.Exit(code=2)
375
+
376
+ # ------------------------------------------------------------------
377
+ # Delegate to the real main implementation
378
+ # ------------------------------------------------------------------
379
+ _run(
380
+ positional=positional,
381
+ sub_vars=sub_vars,
382
+ boolean_int=boolean_int,
383
+ make_dirs=make_dirs,
384
+ database_encoding=database_encoding,
385
+ script_encoding=script_encoding,
386
+ output_encoding=output_encoding,
387
+ import_encoding=import_encoding,
388
+ user_logfile=user_logfile,
389
+ new_db=new_db,
390
+ port=port,
391
+ scanlines=scanlines,
392
+ db_type=db_type,
393
+ user=user,
394
+ use_gui=use_gui,
395
+ gui_framework=gui_framework,
396
+ no_passwd=no_passwd,
397
+ import_buffer=import_buffer,
398
+ script_name=script_name,
399
+ command=command,
400
+ dry_run=dry_run,
401
+ dsn=dsn,
402
+ output_dir=output_dir,
403
+ progress=progress,
404
+ )
405
+
406
+
407
+ # ---------------------------------------------------------------------------
408
+ # Legacy entry point (kept for backwards compat with pyproject.toml script)
409
+ # ---------------------------------------------------------------------------
410
+
411
+
412
+ def _legacy_main() -> None:
413
+ """Entry point that wraps the Typer app for use as a console_scripts target."""
414
+ try:
415
+ app()
416
+ except SystemExit as exc:
417
+ raise exc from exc
418
+ except ErrInfo as exc:
419
+ from execsql.utils.errors import exit_now
420
+
421
+ exit_now(1, exc)
422
+ except ConfigError as exc:
423
+ strace = traceback.extract_tb(sys.exc_info()[2])[-1:]
424
+ lno = strace[0][1]
425
+ sys.exit(f"Configuration error on line {lno} of execsql: {exc}")
426
+ except Exception:
427
+ strace = traceback.extract_tb(sys.exc_info()[2])[-1:]
428
+ lno = strace[0][1]
429
+ msg = f"{Path(sys.argv[0]).name}: Uncaught exception {sys.exc_info()[0]} ({sys.exc_info()[1]}) on line {lno}"
430
+ from execsql.utils.errors import exit_now
431
+
432
+ exit_now(1, ErrInfo("exception", exception_msg=msg))
433
+
434
+
435
+ if __name__ == "__main__":
436
+ _legacy_main()
execsql/cli/dsn.py ADDED
@@ -0,0 +1,86 @@
1
+ """Connection-string (DSN URL) parser for the execsql CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from execsql.exceptions import ConfigError
6
+
7
+ #: Mapping from URL scheme to execsql db_type code.
8
+ _SCHEME_TO_DBTYPE: dict[str, str] = {
9
+ "postgresql": "p",
10
+ "postgres": "p",
11
+ "mysql": "m",
12
+ "mariadb": "m",
13
+ "mssql": "s",
14
+ "sqlserver": "s",
15
+ "oracle": "o",
16
+ "oracle+cx_oracle": "o",
17
+ "firebird": "f",
18
+ "sqlite": "l",
19
+ "duckdb": "k",
20
+ }
21
+
22
+
23
+ def _parse_connection_string(dsn: str) -> dict:
24
+ """Parse a database URL into a dict of connection parameters.
25
+
26
+ Supports the common form::
27
+
28
+ scheme://[user[:password]@][host[:port]]/database
29
+
30
+ For file-based databases (SQLite, DuckDB) the path after ``//`` is
31
+ treated as the database file path::
32
+
33
+ sqlite:///path/to/file.db -> db_file = /path/to/file.db
34
+ duckdb:///path/to/file.db -> db_file = /path/to/file.db
35
+
36
+ Returns a dict with keys: ``db_type``, ``server``, ``db``, ``db_file``,
37
+ ``user``, ``password``, ``port``. Absent components are ``None``.
38
+
39
+ Raises :class:`~execsql.exceptions.ConfigError` for an unrecognised
40
+ URL scheme or a completely un-parseable string.
41
+ """
42
+ from urllib.parse import urlparse
43
+
44
+ parsed = urlparse(dsn)
45
+ scheme = parsed.scheme.lower()
46
+ if not scheme:
47
+ raise ConfigError(f"Cannot parse connection string (no scheme): {dsn!r}")
48
+ if scheme not in _SCHEME_TO_DBTYPE:
49
+ raise ConfigError(
50
+ f"Unrecognised connection-string scheme {scheme!r}. "
51
+ f"Supported schemes: {', '.join(sorted(_SCHEME_TO_DBTYPE))}",
52
+ )
53
+
54
+ db_type = _SCHEME_TO_DBTYPE[scheme]
55
+ port: int | None = parsed.port
56
+ server: str | None = parsed.hostname or None
57
+ user: str | None = parsed.username or None
58
+ password: str | None = parsed.password or None
59
+
60
+ # Database / file path
61
+ # urlparse puts the path in parsed.path. For three-slash URIs like
62
+ # sqlite:///foo.db the path starts with "/"; strip exactly one leading
63
+ # slash for relative paths (sqlite:///foo.db -> foo.db) and leave
64
+ # absolute paths intact (sqlite:////abs/path -> /abs/path).
65
+ raw_path = parsed.path
66
+ if db_type in ("l", "k", "a"):
67
+ # File-based: no server component
68
+ if raw_path.startswith("/") and not raw_path.startswith("//"):
69
+ db_file: str | None = raw_path[1:] or None
70
+ else:
71
+ db_file = raw_path or None
72
+ db: str | None = None
73
+ else:
74
+ db_file = None
75
+ # Remove leading "/"
76
+ db = raw_path.lstrip("/") or None
77
+
78
+ return {
79
+ "db_type": db_type,
80
+ "server": server,
81
+ "db": db,
82
+ "db_file": db_file,
83
+ "user": user,
84
+ "password": password,
85
+ "port": port,
86
+ }
execsql/cli/help.py ADDED
@@ -0,0 +1,140 @@
1
+ """Rich-formatted help output for the execsql CLI.
2
+
3
+ Contains the metacommand reference table, encoding list, and the shared
4
+ ``Console`` instances used by the other CLI submodules.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from encodings.aliases import aliases as codec_dict
10
+
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ _console = Console()
15
+ _err_console = Console(stderr=True)
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Metacommand syntax hints — paired with keywords from the dispatch table.
19
+ # Keys must match the ``description`` values used in mcl.add() calls.
20
+ # Entries here are validated by tests/test_registry.py.
21
+ # ---------------------------------------------------------------------------
22
+
23
+ _SYNTAX: dict[str, tuple[str, str]] = {
24
+ # (display_name, syntax_hint)
25
+ "ASK": ("ASK", '"<question>" SUB <match_string>'),
26
+ "AUTOCOMMIT": ("AUTOCOMMIT", "ON|OFF"),
27
+ "BEGIN BATCH": ("BEGIN BATCH / END BATCH / ROLLBACK BATCH", ""),
28
+ "BEGIN SCRIPT": ("BEGIN SCRIPT / END SCRIPT", ""),
29
+ "BEGIN SQL": ("BEGIN SQL / END SQL", ""),
30
+ "CANCEL_HALT": ("CANCEL_HALT", "ON|OFF"),
31
+ "CD": ("CD", "<directory>"),
32
+ "CONNECT": ("CONNECT", "<alias> [AS <alias_name>]"),
33
+ "COPY": ("COPY", "<source_file> TO <dest_file>"),
34
+ "DEBUG": ("DEBUG", "ON|OFF"),
35
+ "SUB": ("DEFINE SUB", "<variable> [AS] <value>"),
36
+ "EXPORT QUERY": ("EXPORT QUERY", "<queryname> [AS <alias>] ..."),
37
+ "EXPORT": ("EXPORT", "<queryname> TO <format> <filename> ..."),
38
+ "HALT": ("HALT [ON]", "ERROR|CANCEL"),
39
+ "IF": ("IF <condition>", "/ ELSE / ENDIF"),
40
+ "IMPORT_FILE": ("IMPORT FILE", "<filename> [OPTIONS ...]"),
41
+ "IMPORT": ("IMPORT TABLE", "<tablename> FROM FILE <filename> [OPTIONS ...]"),
42
+ "LOOP": ("LOOP <n> TIMES | WHILE | UNTIL", "/ END LOOP"),
43
+ "CONFIG": ("CONFIG", "<option> <value>"),
44
+ "ON CANCEL_HALT": ("ON CANCEL_HALT", "..."),
45
+ "ON ERROR_HALT": ("ON ERROR_HALT", "..."),
46
+ "PAUSE": ("PAUSE", "[<text>]"),
47
+ "PROMPT ACTION": ("PROMPT ACTION", "..."),
48
+ "PROMPT ENTRY_FORM": ("PROMPT ENTRY_FORM", "..."),
49
+ "PROMPT OPENFILE": ("PROMPT OPENFILE", "..."),
50
+ "PROMPT SAVEFILE": ("PROMPT SAVEFILE", "..."),
51
+ "PROMPT DIRECTORY": ("PROMPT DIRECTORY", "..."),
52
+ "PROMPT MAP": ("PROMPT MAP", "..."),
53
+ "ROLLBACK BATCH": ("ROLLBACK", ""),
54
+ "SERVE": ("SERVE", "<queryname> ..."),
55
+ "SYSTEM_CMD": ("SYSTEM_CMD", "(<operating system command line>)"),
56
+ "TIMER": ("TIMER", "ON|OFF"),
57
+ "USE": ("USE", "<alias_name>"),
58
+ "WAIT_UNTIL": ("WAIT_UNTIL", "<Boolean_expression> <HALT|CONTINUE> AFTER <n> SECONDS"),
59
+ "WRITE": ("WRITE", '"<text>" [[TEE] TO <output>]'),
60
+ "WRITE CREATE_TABLE": ("WRITE CREATE_TABLE FROM", "<filename> [TO <output>]"),
61
+ "WRITE SCRIPT": ("WRITE SCRIPT", "<script_name> [[APPEND] TO <output_file>]"),
62
+ "ZIP": ("ZIP", "<filename> [APPEND] TO ZIPFILE <zipfilename>"),
63
+ "SUB_TEMPFILE": ("SUB_TEMPFILE", "<variable>"),
64
+ }
65
+
66
+ # Keys from _SYNTAX that should be skipped when auto-generating from dispatch
67
+ # table (they're variants covered by another entry).
68
+ _SKIP_FROM_DISPATCH = {
69
+ "END BATCH",
70
+ "END SCRIPT",
71
+ "END SQL",
72
+ "ROLLBACK BATCH",
73
+ "BEGIN SCRIPT",
74
+ "BEGIN SQL",
75
+ }
76
+
77
+
78
+ def _print_metacommands() -> None:
79
+ """Print the metacommands table using Rich.
80
+
81
+ Keyword list is derived from the dispatch table; syntax hints come from
82
+ the ``_SYNTAX`` dict above. Keywords not in ``_SYNTAX`` are shown without
83
+ a syntax column.
84
+ """
85
+ from execsql.metacommands import DISPATCH_TABLE
86
+
87
+ table = Table(
88
+ title="execsql Metacommands",
89
+ caption="Embed in SQL comment lines following the [bold]!x![/bold] token.",
90
+ show_header=True,
91
+ header_style="bold cyan",
92
+ border_style="dim",
93
+ expand=False,
94
+ )
95
+ table.add_column("Metacommand", style="bold green", no_wrap=True)
96
+ table.add_column("Syntax", style="white")
97
+
98
+ # Collect unique keyword names from the dispatch table.
99
+ seen: set[str] = set()
100
+ keywords: list[str] = []
101
+ for mc in DISPATCH_TABLE:
102
+ if mc.description and mc.description not in seen and mc.description not in _SKIP_FROM_DISPATCH:
103
+ seen.add(mc.description)
104
+ keywords.append(mc.description)
105
+ # Add parser-level keywords not in the dispatch table.
106
+ for extra in ("BEGIN BATCH", "BEGIN SCRIPT", "BEGIN SQL"):
107
+ if extra not in seen:
108
+ seen.add(extra)
109
+ keywords.append(extra)
110
+
111
+ for kw in sorted(keywords):
112
+ if kw in _SYNTAX:
113
+ name, syntax = _SYNTAX[kw]
114
+ table.add_row(name, syntax)
115
+ elif kw.startswith("CONFIG ") or kw.startswith("CONSOLE_") or "_" in kw:
116
+ continue # skip config options / internal entries
117
+ else:
118
+ table.add_row(kw, "")
119
+
120
+ _console.print(table)
121
+
122
+
123
+ def _print_encodings() -> None:
124
+ """Print available encodings using Rich."""
125
+ enc = sorted(codec_dict.keys())
126
+ table = Table(
127
+ title="Available Encodings",
128
+ show_header=False,
129
+ border_style="dim",
130
+ expand=True,
131
+ )
132
+ table.add_column("Encoding", style="cyan")
133
+ # 4 columns
134
+ cols = 4
135
+ for i in range(0, len(enc), cols):
136
+ row = enc[i : i + cols]
137
+ while len(row) < cols:
138
+ row.append("")
139
+ table.add_row(*row)
140
+ _console.print(table)