execsql2 2.23.1__py3-none-any.whl → 2.25.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 (92) hide show
  1. execsql/api.py +169 -160
  2. execsql/cli/__init__.py +19 -586
  3. execsql/cli/application.py +309 -0
  4. execsql/cli/commands/__init__.py +13 -0
  5. execsql/cli/commands/config.py +205 -0
  6. execsql/cli/commands/format.py +100 -0
  7. execsql/cli/commands/init.py +135 -0
  8. execsql/cli/commands/lint.py +225 -0
  9. execsql/cli/commands/listing.py +81 -0
  10. execsql/cli/commands/run.py +459 -0
  11. execsql/cli/dispatch.py +66 -0
  12. execsql/cli/dsn.py +11 -4
  13. execsql/cli/help.py +175 -24
  14. execsql/cli/lint.py +780 -100
  15. execsql/cli/options.py +108 -0
  16. execsql/cli/run.py +96 -32
  17. execsql/config.py +256 -173
  18. execsql/config_validation.py +282 -0
  19. execsql/data/execsql.conf.template +38 -5
  20. execsql/data/script_header.sql +23 -0
  21. execsql/db/access.py +29 -8
  22. execsql/db/base.py +40 -8
  23. execsql/db/dsn.py +13 -4
  24. execsql/db/duckdb.py +3 -1
  25. execsql/db/factory.py +13 -4
  26. execsql/db/firebird.py +21 -7
  27. execsql/db/mysql.py +37 -16
  28. execsql/db/oracle.py +49 -16
  29. execsql/db/postgres.py +30 -6
  30. execsql/db/sqlite.py +7 -9
  31. execsql/db/sqlserver.py +25 -8
  32. execsql/debug/repl.py +51 -193
  33. execsql/exceptions.py +9 -0
  34. execsql/exporters/delimited.py +87 -28
  35. execsql/exporters/xlsx.py +24 -9
  36. execsql/exporters/zip.py +1 -2
  37. execsql/format.py +115 -76
  38. execsql/gui/desktop.py +1 -3
  39. execsql/gui/tui.py +2 -2
  40. execsql/importers/base.py +35 -1
  41. execsql/importers/csv.py +3 -1
  42. execsql/interactive.py +232 -0
  43. execsql/manifest.py +200 -0
  44. execsql/metacommands/conditions.py +7 -8
  45. execsql/metacommands/control.py +2 -3
  46. execsql/metacommands/data.py +4 -1
  47. execsql/metacommands/debug.py +2 -4
  48. execsql/metacommands/effects.py +124 -0
  49. execsql/metacommands/io_fileops.py +62 -37
  50. execsql/metacommands/io_import.py +0 -1
  51. execsql/metacommands/prompt.py +2 -0
  52. execsql/metacommands/upsert.py +5 -2
  53. execsql/models.py +3 -2
  54. execsql/parser.py +14 -5
  55. execsql/plugins.py +24 -0
  56. execsql/script/__init__.py +0 -9
  57. execsql/script/ast.py +1 -1
  58. execsql/script/engine.py +46 -104
  59. execsql/script/executor.py +162 -39
  60. execsql/script/parser.py +167 -118
  61. execsql/state.py +34 -11
  62. execsql/state.pyi +11 -0
  63. execsql/types.py +22 -13
  64. execsql/utils/auth.py +169 -24
  65. execsql/utils/color.py +18 -0
  66. execsql/utils/datetime.py +55 -17
  67. execsql/utils/errors.py +82 -10
  68. execsql/utils/fileio.py +404 -117
  69. execsql/utils/gui.py +3 -0
  70. execsql/utils/mail.py +37 -12
  71. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/README.md +1 -1
  72. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/METADATA +122 -63
  73. execsql2-2.25.0.dist-info/RECORD +140 -0
  74. execsql2-2.25.0.dist-info/entry_points.txt +2 -0
  75. execsql2-2.23.1.dist-info/RECORD +0 -124
  76. execsql2-2.23.1.dist-info/entry_points.txt +0 -3
  77. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  78. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  79. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  80. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  81. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  82. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  83. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  84. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  85. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  86. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/script_template.sql +0 -0
  87. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  88. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  89. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  90. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/WHEEL +0 -0
  91. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/licenses/LICENSE.txt +0 -0
  92. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/licenses/NOTICE +0 -0
@@ -0,0 +1,459 @@
1
+ """The ``run`` command: execute one script against a database."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from execsql.cli.application import ExecsqlCommand, _online_help_callback, _version_callback, app
10
+ from execsql.cli.help import (
11
+ _console,
12
+ _err_console,
13
+ _init_config,
14
+ _print_encodings,
15
+ _print_keywords_json,
16
+ _print_metacommands,
17
+ _print_plugins,
18
+ )
19
+ from execsql.cli.options import (
20
+ ConfigFileOpt,
21
+ DbTypeOpt,
22
+ DsnOpt,
23
+ NoPasswdOpt,
24
+ PortOpt,
25
+ ScriptEncodingOpt,
26
+ UserOpt,
27
+ )
28
+ from execsql.cli.run import _run
29
+ from execsql.exceptions import ErrInfo
30
+
31
+ __all__ = ["main"]
32
+
33
+
34
+ @app.command(
35
+ cls=ExecsqlCommand,
36
+ name="run",
37
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
38
+ )
39
+ def main(
40
+ ctx: typer.Context,
41
+ # Positional args collected manually (script + optional server/db/file)
42
+ args: list[str] | None = typer.Argument(
43
+ None,
44
+ metavar="[SQL_SCRIPT [SERVER DATABASE | DATABASE_FILE]]",
45
+ help=(
46
+ "SQL script file to execute. Optionally followed by server and database "
47
+ "name (client-server DBs) or a database file path (file-based DBs). "
48
+ "Not needed with -c or --ping."
49
+ ),
50
+ ),
51
+ # -- Connection --------------------------------------------------------
52
+ db_type: DbTypeOpt = None,
53
+ dsn: DsnOpt = None,
54
+ user: UserOpt = None,
55
+ port: PortOpt = None,
56
+ no_passwd: NoPasswdOpt = False,
57
+ new_db: bool = typer.Option(
58
+ False,
59
+ "-n",
60
+ "--new-db",
61
+ help="Create a new SQLite, DuckDB or PostgreSQL database if it does not exist.",
62
+ ),
63
+ # -- Encoding ----------------------------------------------------------
64
+ database_encoding: str | None = typer.Option(
65
+ None,
66
+ "-e",
67
+ "--database-encoding",
68
+ help="Character encoding used in the database.",
69
+ ),
70
+ script_encoding: ScriptEncodingOpt = None,
71
+ output_encoding: str | None = typer.Option(
72
+ None,
73
+ "-g",
74
+ "--output-encoding",
75
+ help="Encoding for WRITE and EXPORT output.",
76
+ ),
77
+ import_encoding: str | None = typer.Option(
78
+ None,
79
+ "-i",
80
+ "--import-encoding",
81
+ help="Encoding for data files used with IMPORT.",
82
+ ),
83
+ # -- Import/Export -----------------------------------------------------
84
+ import_buffer: int | None = typer.Option(
85
+ None,
86
+ "-z",
87
+ "--import-buffer",
88
+ metavar="KB",
89
+ help="Import buffer size in KB. Default: 32",
90
+ ),
91
+ scanlines: int | None = typer.Option(
92
+ None,
93
+ "-s",
94
+ "--scan-lines",
95
+ metavar="N",
96
+ help="Lines to scan for IMPORT format detection. Default: 100; 0 = scan entire file.",
97
+ ),
98
+ boolean_int: str | None = typer.Option(
99
+ None,
100
+ "-b",
101
+ "--boolean-int",
102
+ metavar="{0,1,t,f,y,n}",
103
+ help="Treat columns of only 0 and 1 as Boolean (y, default) or integer (n).",
104
+ ),
105
+ make_dirs: str | None = typer.Option(
106
+ None,
107
+ "-d",
108
+ "--directories",
109
+ metavar="{0,1,t,f,y,n}",
110
+ help="Create missing output directories for EXPORT and WRITE. n=no (default), y=yes",
111
+ ),
112
+ output_dir: str | None = typer.Option(
113
+ None,
114
+ "--output-dir",
115
+ metavar="DIR",
116
+ help=(
117
+ "Default base directory for EXPORT output files. "
118
+ "Relative paths in EXPORT metacommands are joined to this directory. "
119
+ "Absolute paths and stdout are unaffected."
120
+ ),
121
+ ),
122
+ progress: bool = typer.Option(
123
+ False,
124
+ "--progress",
125
+ help="Show a progress bar for long-running IMPORT operations.",
126
+ ),
127
+ # -- Execution ---------------------------------------------------------
128
+ command: str | None = typer.Option(
129
+ None,
130
+ "-c",
131
+ "--command",
132
+ metavar="SCRIPT",
133
+ help=(
134
+ "Execute an inline SQL/metacommand script string instead of a script file. "
135
+ "Use shell $'line1\\nline2' syntax for multi-line scripts."
136
+ ),
137
+ ),
138
+ dry_run: bool = typer.Option(
139
+ False,
140
+ "--dry-run",
141
+ help="Parse the script and print the command list without connecting to a database or executing anything.",
142
+ ),
143
+ # Removed: `execsql lint` replaces it. Still declared so the flag is
144
+ # refused with a pointer instead of Click's generic "no such option".
145
+ lint: bool = typer.Option(False, "--lint", hidden=True),
146
+ parse_tree: bool = typer.Option(
147
+ False,
148
+ "--parse-tree",
149
+ help=(
150
+ "Parse the script into an abstract syntax tree and print the tree structure. "
151
+ "Does not connect to a database or execute anything."
152
+ ),
153
+ ),
154
+ debug: bool = typer.Option(
155
+ False,
156
+ "--debug",
157
+ help="Start in step-through debug mode. The debug REPL pauses before each statement.",
158
+ ),
159
+ no_system_cmd: bool = typer.Option(
160
+ False,
161
+ "--no-system-cmd",
162
+ help="Disable the SYSTEM_CMD (SHELL) metacommand. Scripts that use SHELL will fail with an error.",
163
+ ),
164
+ no_rm_file: bool = typer.Option(
165
+ False,
166
+ "--no-rm-file",
167
+ help="Disable the RM_FILE metacommand. Scripts that try to delete files will fail with an error.",
168
+ ),
169
+ no_serve: bool = typer.Option(
170
+ False,
171
+ "--no-serve",
172
+ help="Disable the SERVE metacommand. Scripts that try to stream a file to stdout will fail with an error.",
173
+ ),
174
+ profile: bool = typer.Option(
175
+ False,
176
+ "--profile",
177
+ help="Record per-statement execution times and print a timing summary after the script completes.",
178
+ ),
179
+ profile_limit: int = typer.Option(
180
+ 20,
181
+ "--profile-limit",
182
+ help="Number of top statements to show in the --profile timing summary (default: 20).",
183
+ ),
184
+ # -- GUI ---------------------------------------------------------------
185
+ use_gui: str | None = typer.Option(
186
+ None,
187
+ "-v",
188
+ "--visible-prompts",
189
+ metavar="{0,1,2,3}",
190
+ help=(
191
+ "GUI level: 0=none (default), 1=GUI for password/pause, "
192
+ "2=also HALT messages and initial DB selection, 3=full GUI console."
193
+ ),
194
+ ),
195
+ gui_framework: str | None = typer.Option(
196
+ None,
197
+ "--gui-framework",
198
+ metavar="{tkinter,textual}",
199
+ help="GUI framework to use with --visible-prompts. Default: tkinter",
200
+ ),
201
+ # -- Configuration -----------------------------------------------------
202
+ config_file: ConfigFileOpt = None,
203
+ init_config: bool = typer.Option(False, "--init-config", hidden=True), # alias: execsql config --init
204
+ sub_vars: list[str] | None = typer.Option(
205
+ None,
206
+ "-a",
207
+ "--assign-arg",
208
+ metavar="VALUE",
209
+ help="Define the replacement string for a substitution variable $ARG_x.",
210
+ ),
211
+ named_vars: list[str] | None = typer.Option(
212
+ None,
213
+ "--var",
214
+ metavar="NAME=VALUE",
215
+ help=(
216
+ "Set the substitution variable !!NAME!!, as a [variables] entry in a config file would; "
217
+ "it wins over config files, and a SUB in the script can reassign it. Repeatable."
218
+ ),
219
+ ),
220
+ manifest_path: str | None = typer.Option(
221
+ None,
222
+ "--manifest",
223
+ metavar="FILE",
224
+ help=(
225
+ "Write a JSON record of the run to FILE when it ends, even if it fails: script, database, "
226
+ "statement counts, files read, written and deleted, connections, errors. No values or passwords."
227
+ ),
228
+ ),
229
+ user_logfile: bool = typer.Option(
230
+ False,
231
+ "-l",
232
+ "--user-logfile",
233
+ help="Write the log to ~/execsql.log instead of ./execsql.log.",
234
+ ),
235
+ # -- Hidden aliases of `execsql list ...`; -m and -y are upstream flags.
236
+ metacommands: bool = typer.Option(False, "-m", "--metacommands", hidden=True), # list metacommands
237
+ encodings: bool = typer.Option(False, "-y", "--encodings", hidden=True), # list encodings
238
+ dump_keywords: bool = typer.Option(False, "--dump-keywords", hidden=True), # list keywords --output-format json
239
+ list_plugins: bool = typer.Option(False, "--list-plugins", hidden=True), # list plugins
240
+ ping: bool = typer.Option(
241
+ False,
242
+ "--ping",
243
+ help=(
244
+ "Test database connectivity and exit. Prints connection details and the server version on success "
245
+ "(exit 0), or the error message on failure (exit 1). No script file is required."
246
+ ),
247
+ ),
248
+ # The global options again, hidden. Upstream's optparse accepted them
249
+ # anywhere, so `execsql script.sql db --version` still has to print the
250
+ # version rather than read "--version" as a database name.
251
+ _online_help: bool = typer.Option(
252
+ False,
253
+ "-o",
254
+ "--online-help",
255
+ callback=_online_help_callback,
256
+ is_eager=True,
257
+ hidden=True,
258
+ ),
259
+ _version: bool | None = typer.Option(
260
+ None,
261
+ "--version",
262
+ callback=_version_callback,
263
+ is_eager=True,
264
+ hidden=True,
265
+ ),
266
+ ) -> None:
267
+ """Run a script against a database.
268
+
269
+ \b
270
+ Client-server databases: execsql run script.sql SERVER DATABASE
271
+ File-based databases: execsql run script.sql DATABASE_FILE
272
+ An inline script: execsql run -c "select 1;" DATABASE_FILE
273
+ A connection test: execsql run --ping DATABASE_FILE
274
+
275
+ Running a script without run (execsql script.sql ...) is deprecated and
276
+ stops working in execsql2 3.0.
277
+ """
278
+ if lint:
279
+ _err_console.print("[bold red]Error:[/bold red] run --lint was removed; use execsql lint")
280
+ raise typer.Exit(code=2)
281
+
282
+ # ------------------------------------------------------------------
283
+ # Information flags and the --init-config alias (no script file needed)
284
+ # ------------------------------------------------------------------
285
+ if metacommands:
286
+ _print_metacommands()
287
+ raise typer.Exit()
288
+
289
+ if encodings:
290
+ _print_encodings()
291
+ raise typer.Exit()
292
+
293
+ if init_config:
294
+ _init_config()
295
+ raise typer.Exit()
296
+
297
+ if dump_keywords:
298
+ _print_keywords_json()
299
+ raise typer.Exit()
300
+
301
+ if list_plugins:
302
+ _print_plugins()
303
+ raise typer.Exit()
304
+
305
+ parsed_vars = _parse_named_vars(named_vars)
306
+
307
+ if config_file and not Path(config_file).is_file():
308
+ _err_console.print(
309
+ f"[bold red]Error:[/bold red] Config file {config_file!r} does not exist.",
310
+ )
311
+ raise typer.Exit(code=2)
312
+
313
+ if manifest_path and not dry_run:
314
+ from execsql.manifest import check_writable
315
+
316
+ if (problem := check_writable(manifest_path)) is not None:
317
+ _err_console.print(f"[bold red]Error:[/bold red] Cannot write --manifest {manifest_path}: {problem}")
318
+ raise typer.Exit(code=2)
319
+
320
+ # ------------------------------------------------------------------
321
+ # Validate option values, before the script is looked for: a value that
322
+ # is really the script path (`-d script.sql`) is reported as a bad value,
323
+ # as upstream's option parser did.
324
+ # ------------------------------------------------------------------
325
+
326
+ if db_type and db_type not in ("a", "d", "p", "s", "l", "m", "k", "o", "f"):
327
+ _err_console.print(
328
+ f"[bold red]Error:[/bold red] Invalid database type {db_type!r}. Choose from: a, d, p, s, l, m, k, o, f",
329
+ )
330
+ raise typer.Exit(code=2)
331
+
332
+ if use_gui and use_gui not in ("0", "1", "2", "3"):
333
+ _err_console.print(
334
+ f"[bold red]Error:[/bold red] Invalid GUI level {use_gui!r}. Choose from: 0, 1, 2, 3",
335
+ )
336
+ raise typer.Exit(code=2)
337
+
338
+ if gui_framework and gui_framework.lower() not in ("tkinter", "textual"):
339
+ _err_console.print(
340
+ f"[bold red]Error:[/bold red] Invalid GUI framework {gui_framework!r}. Choose from: tkinter, textual",
341
+ )
342
+ raise typer.Exit(code=2)
343
+
344
+ if boolean_int and boolean_int.lower() not in ("0", "1", "t", "f", "y", "n"):
345
+ _err_console.print(
346
+ f"[bold red]Error:[/bold red] Invalid --boolean-int value {boolean_int!r}.",
347
+ )
348
+ raise typer.Exit(code=2)
349
+
350
+ if make_dirs and make_dirs.lower() not in ("0", "1", "t", "f", "y", "n"):
351
+ _err_console.print(
352
+ f"[bold red]Error:[/bold red] Invalid --directories value {make_dirs!r}. Choose from: 0, 1, t, f, y, n",
353
+ )
354
+ raise typer.Exit(code=2)
355
+
356
+ positional = args or []
357
+ if command is not None:
358
+ script_name = None # inline mode — no script file
359
+ elif ping:
360
+ # --ping does not require a script file; positional args are still
361
+ # available for server/db arguments if --dsn is not used.
362
+ script_name = None
363
+ else:
364
+ if not positional:
365
+ _err_console.print(
366
+ "[bold red]Error:[/bold red] No SQL script file specified. Use -c to run an inline script.",
367
+ )
368
+ raise typer.Exit(code=1)
369
+ script_name = positional[0]
370
+ if not Path(script_name).exists():
371
+ _err_console.print(
372
+ f'[bold red]Error:[/bold red] SQL script file "{script_name}" does not exist.',
373
+ )
374
+ raise typer.Exit(code=1)
375
+
376
+ # ------------------------------------------------------------------
377
+ # Parse tree: parse script into AST and print tree structure
378
+ # ------------------------------------------------------------------
379
+ if parse_tree:
380
+ from execsql.script.ast import format_tree
381
+ from execsql.script.parser import parse_script, parse_string
382
+
383
+ try:
384
+ if command is not None:
385
+ tree = parse_string(command.replace("\\n", "\n").replace("\\t", "\t"), "<inline>")
386
+ elif script_name is not None:
387
+ encoding = script_encoding or "utf-8"
388
+ tree = parse_script(script_name, encoding=encoding)
389
+ else:
390
+ _err_console.print(
391
+ "[bold red]Error:[/bold red] --parse-tree requires a script file or -c command.",
392
+ )
393
+ raise typer.Exit(code=1)
394
+ except ErrInfo as exc:
395
+ _err_console.print(f"[bold red]Parse error:[/bold red] {exc.errmsg()}")
396
+ raise typer.Exit(code=1) from exc
397
+
398
+ _console.print(format_tree(tree))
399
+ raise typer.Exit()
400
+
401
+ # ------------------------------------------------------------------
402
+ # Delegate to the real main implementation
403
+ # ------------------------------------------------------------------
404
+ _run(
405
+ positional=positional,
406
+ sub_vars=sub_vars,
407
+ boolean_int=boolean_int,
408
+ make_dirs=make_dirs,
409
+ database_encoding=database_encoding,
410
+ script_encoding=script_encoding,
411
+ output_encoding=output_encoding,
412
+ import_encoding=import_encoding,
413
+ user_logfile=user_logfile,
414
+ new_db=new_db,
415
+ port=port,
416
+ scanlines=scanlines,
417
+ db_type=db_type,
418
+ user=user,
419
+ use_gui=use_gui,
420
+ gui_framework=gui_framework,
421
+ no_passwd=no_passwd,
422
+ import_buffer=import_buffer,
423
+ script_name=script_name,
424
+ command=command,
425
+ dry_run=dry_run,
426
+ dsn=dsn,
427
+ output_dir=output_dir,
428
+ progress=progress,
429
+ profile=profile,
430
+ profile_limit=profile_limit,
431
+ ping=ping,
432
+ debug=debug,
433
+ no_system_cmd=no_system_cmd,
434
+ no_rm_file=no_rm_file,
435
+ no_serve=no_serve,
436
+ config_file=config_file,
437
+ named_vars=parsed_vars,
438
+ manifest_path=manifest_path,
439
+ )
440
+
441
+
442
+ def _parse_named_vars(values: list[str] | None) -> list[tuple[str, str]]:
443
+ """``--var NAME=VALUE`` pairs, in order. Rejects a missing ``=`` or a name execsql reserves."""
444
+ import re
445
+
446
+ pairs: list[tuple[str, str]] = []
447
+ for raw in values or ():
448
+ name, sep, value = raw.partition("=")
449
+ name = name.strip()
450
+ if not sep:
451
+ raise typer.BadParameter(f"{raw!r} has no '='; write NAME=VALUE.", param_hint="'--var'")
452
+ if not re.fullmatch(r"\w+", name):
453
+ raise typer.BadParameter(
454
+ f"{name!r} is not a variable name: use letters, digits and _ only. "
455
+ "Names starting with $, & or @ are execsql's own (system, environment and column variables).",
456
+ param_hint="'--var'",
457
+ )
458
+ pairs.append((name, value))
459
+ return pairs
@@ -0,0 +1,66 @@
1
+ """Command dispatch for the ``execsql`` console script.
2
+
3
+ execsql is six commands behind one program — ``run``, ``format``, ``lint``,
4
+ ``config``, ``list`` and ``init`` — each a real :class:`typer.Typer` command,
5
+ so ``execsql --help`` lists them the way any multi-command CLI does.
6
+
7
+ What this module adds on top is one rule, applied before the parser sees
8
+ anything: **an argument list with no command in it means** ``run``.
9
+ ``execsql script.sql server db`` has been the invocation since upstream
10
+ v1.130.1 — it is in shell scripts and cron entries — so :func:`normalize`
11
+ still inserts the verb. That form is deprecated: the app warns when it
12
+ inserts ``run``, and the form stops working in execsql2 3.0.
13
+
14
+ Two things must not have ``run`` inserted: a command name, and an option the
15
+ app declares itself — ``--help``, ``--version``, ``--online-help``.
16
+ Everything else, including ``-m`` and the other hidden aliases declared on
17
+ ``run``, is a run.
18
+
19
+ A command name is always a command, whatever files the working directory
20
+ holds: a linter must never be able to execute a script because a file named
21
+ ``lint`` happens to be present. A script named exactly like a command, with
22
+ no extension, runs with ``execsql run NAME``. That is a
23
+ recorded, maintainer-approved break from upstream (docs/about/divergence.md).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+
29
+ __all__ = ["COMMANDS", "GLOBAL_FLAGS", "dispatch", "normalize"]
30
+
31
+ #: Tokens that name a command.
32
+ COMMANDS = ("run", "format", "lint", "config", "list", "init")
33
+
34
+ #: Options declared on the app rather than on a command, which must reach
35
+ #: the parser without ``run`` in front of them. ``-m``, ``--encodings`` and
36
+ #: the other early-exit options are declared on ``run``, so they are not
37
+ #: here — they get the verb inserted like any other run invocation.
38
+ GLOBAL_FLAGS = ("--help", "-h", "--version", "-o", "--online-help")
39
+
40
+
41
+ def normalize(argv: list[str]) -> list[str]:
42
+ """Insert ``run`` when *argv* carries no command.
43
+
44
+ Takes and returns the arguments after the program name. *argv* itself is
45
+ returned when nothing is inserted, so a caller can tell the deprecated
46
+ bare form by identity.
47
+ """
48
+ if not argv:
49
+ return argv
50
+ head = argv[0]
51
+ if head in COMMANDS or head in GLOBAL_FLAGS:
52
+ return argv
53
+ return ["run", *argv]
54
+
55
+
56
+ def dispatch() -> None:
57
+ """Console-script entry point.
58
+
59
+ :func:`normalize` is applied by the app's command group, so every caller —
60
+ this entry point, ``python -m execsql``, and a test driving ``app``
61
+ directly — gets the same treatment. This is a thin alias kept because
62
+ pyproject names it.
63
+ """
64
+ from execsql.cli import _legacy_main
65
+
66
+ _legacy_main()
execsql/cli/dsn.py CHANGED
@@ -35,13 +35,17 @@ def _parse_connection_string(dsn: str) -> dict:
35
35
  sqlite:///path/to/file.db -> db_file = /path/to/file.db
36
36
  duckdb:///path/to/file.db -> db_file = /path/to/file.db
37
37
 
38
+ The user, password and database (or file path) are percent-decoded, so
39
+ a password containing ``@``, ``:``, ``/``, ``?``, ``#`` or ``%`` is
40
+ written ``%40``, ``%3A``, ``%2F``, ``%3F``, ``%23``, ``%25``.
41
+
38
42
  Returns a dict with keys: ``db_type``, ``server``, ``db``, ``db_file``,
39
43
  ``user``, ``password``, ``port``. Absent components are ``None``.
40
44
 
41
45
  Raises :class:`~execsql.exceptions.ConfigError` for an unrecognised
42
46
  URL scheme or a completely un-parseable string.
43
47
  """
44
- from urllib.parse import urlparse
48
+ from urllib.parse import unquote, urlparse
45
49
 
46
50
  parsed = urlparse(dsn)
47
51
  scheme = parsed.scheme.lower()
@@ -56,15 +60,18 @@ def _parse_connection_string(dsn: str) -> dict:
56
60
  db_type = _SCHEME_TO_DBTYPE[scheme]
57
61
  port: int | None = parsed.port
58
62
  server: str | None = parsed.hostname or None
59
- user: str | None = parsed.username or None
60
- password: str | None = parsed.password or None
63
+ # User, password and database are percent-encoded in a URL (a password
64
+ # containing @ : / ? # % is written %40 %3A %2F %3F %23 %25); urlparse
65
+ # leaves them encoded.
66
+ user: str | None = unquote(parsed.username) if parsed.username else None
67
+ password: str | None = unquote(parsed.password) if parsed.password else None
61
68
 
62
69
  # Database / file path
63
70
  # urlparse puts the path in parsed.path. For three-slash URIs like
64
71
  # sqlite:///foo.db the path starts with "/"; strip exactly one leading
65
72
  # slash for relative paths (sqlite:///foo.db -> foo.db) and leave
66
73
  # absolute paths intact (sqlite:////abs/path -> /abs/path).
67
- raw_path = parsed.path
74
+ raw_path = unquote(parsed.path)
68
75
  if db_type in ("l", "k", "a"):
69
76
  # File-based: no server component
70
77
  if raw_path.startswith("/") and not raw_path.startswith("//"):