execsql2 2.0.1__py3-none-any.whl → 2.1.1__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 (90) hide show
  1. execsql/cli.py +322 -108
  2. execsql/config.py +134 -114
  3. execsql/db/access.py +89 -65
  4. execsql/db/base.py +97 -68
  5. execsql/db/dsn.py +45 -29
  6. execsql/db/duckdb.py +4 -5
  7. execsql/db/factory.py +27 -27
  8. execsql/db/firebird.py +30 -18
  9. execsql/db/mysql.py +38 -14
  10. execsql/db/oracle.py +58 -33
  11. execsql/db/postgres.py +68 -28
  12. execsql/db/sqlite.py +36 -27
  13. execsql/db/sqlserver.py +45 -30
  14. execsql/exceptions.py +68 -64
  15. execsql/exporters/__init__.py +1 -1
  16. execsql/exporters/base.py +42 -17
  17. execsql/exporters/delimited.py +60 -59
  18. execsql/exporters/duckdb.py +8 -12
  19. execsql/exporters/feather.py +32 -24
  20. execsql/exporters/html.py +33 -30
  21. execsql/exporters/json.py +18 -17
  22. execsql/exporters/latex.py +11 -13
  23. execsql/exporters/ods.py +50 -46
  24. execsql/exporters/parquet.py +32 -0
  25. execsql/exporters/pretty.py +16 -15
  26. execsql/exporters/raw.py +9 -11
  27. execsql/exporters/sqlite.py +38 -38
  28. execsql/exporters/templates.py +15 -72
  29. execsql/exporters/values.py +13 -12
  30. execsql/exporters/xls.py +26 -26
  31. execsql/exporters/xml.py +12 -12
  32. execsql/exporters/zip.py +0 -3
  33. execsql/gui/__init__.py +2 -2
  34. execsql/gui/console.py +0 -1
  35. execsql/gui/desktop.py +6 -7
  36. execsql/gui/tui.py +8 -14
  37. execsql/importers/base.py +6 -9
  38. execsql/importers/csv.py +10 -17
  39. execsql/importers/feather.py +16 -22
  40. execsql/importers/ods.py +3 -4
  41. execsql/importers/xls.py +5 -6
  42. execsql/metacommands/__init__.py +8 -8
  43. execsql/metacommands/conditions.py +41 -33
  44. execsql/metacommands/connect.py +113 -99
  45. execsql/metacommands/control.py +38 -26
  46. execsql/metacommands/data.py +35 -33
  47. execsql/metacommands/debug.py +13 -9
  48. execsql/metacommands/io.py +288 -229
  49. execsql/metacommands/prompt.py +179 -157
  50. execsql/metacommands/script_ext.py +11 -9
  51. execsql/metacommands/system.py +44 -25
  52. execsql/models.py +9 -16
  53. execsql/parser.py +10 -10
  54. execsql/script.py +183 -157
  55. execsql/state.py +170 -208
  56. execsql/types.py +46 -81
  57. execsql/utils/auth.py +114 -14
  58. execsql/utils/crypto.py +31 -4
  59. execsql/utils/datetime.py +7 -7
  60. execsql/utils/errors.py +34 -29
  61. execsql/utils/fileio.py +90 -55
  62. execsql/utils/gui.py +22 -23
  63. execsql/utils/mail.py +15 -17
  64. execsql/utils/numeric.py +2 -3
  65. execsql/utils/regex.py +9 -12
  66. execsql/utils/strings.py +10 -12
  67. execsql/utils/timer.py +0 -2
  68. {execsql2-2.0.1.data → execsql2-2.1.1.data}/data/execsql2_extras/execsql.conf +1 -1
  69. execsql2-2.1.1.dist-info/METADATA +295 -0
  70. execsql2-2.1.1.dist-info/RECORD +96 -0
  71. execsql2-2.0.1.dist-info/METADATA +0 -406
  72. execsql2-2.0.1.dist-info/RECORD +0 -95
  73. {execsql2-2.0.1.data → execsql2-2.1.1.data}/data/execsql2_extras/READ_ME.rst +0 -0
  74. {execsql2-2.0.1.data → execsql2-2.1.1.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  75. {execsql2-2.0.1.data → execsql2-2.1.1.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  76. {execsql2-2.0.1.data → execsql2-2.1.1.data}/data/execsql2_extras/make_config_db.sql +0 -0
  77. {execsql2-2.0.1.data → execsql2-2.1.1.data}/data/execsql2_extras/md_compare.sql +0 -0
  78. {execsql2-2.0.1.data → execsql2-2.1.1.data}/data/execsql2_extras/md_glossary.sql +0 -0
  79. {execsql2-2.0.1.data → execsql2-2.1.1.data}/data/execsql2_extras/md_upsert.sql +0 -0
  80. {execsql2-2.0.1.data → execsql2-2.1.1.data}/data/execsql2_extras/pg_compare.sql +0 -0
  81. {execsql2-2.0.1.data → execsql2-2.1.1.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  82. {execsql2-2.0.1.data → execsql2-2.1.1.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  83. {execsql2-2.0.1.data → execsql2-2.1.1.data}/data/execsql2_extras/script_template.sql +0 -0
  84. {execsql2-2.0.1.data → execsql2-2.1.1.data}/data/execsql2_extras/ss_compare.sql +0 -0
  85. {execsql2-2.0.1.data → execsql2-2.1.1.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  86. {execsql2-2.0.1.data → execsql2-2.1.1.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  87. {execsql2-2.0.1.dist-info → execsql2-2.1.1.dist-info}/WHEEL +0 -0
  88. {execsql2-2.0.1.dist-info → execsql2-2.1.1.dist-info}/entry_points.txt +0 -0
  89. {execsql2-2.0.1.dist-info → execsql2-2.1.1.dist-info}/licenses/LICENSE.txt +0 -0
  90. {execsql2-2.0.1.dist-info → execsql2-2.1.1.dist-info}/licenses/NOTICE +0 -0
execsql/cli.py CHANGED
@@ -12,17 +12,19 @@ import getpass
12
12
  import os
13
13
  import sys
14
14
  import traceback
15
+ from pathlib import Path
15
16
  from encodings.aliases import aliases as codec_dict
16
- from typing import List, Optional
17
17
 
18
18
  import typer
19
19
  from rich.console import Console
20
20
  from rich.table import Table
21
- from rich.text import Text
22
21
 
23
22
  from execsql import __version__
24
23
  from execsql.config import ConfigData, StatObj
25
24
  from execsql.exceptions import ConfigError, ErrInfo
25
+ from execsql.script import SubVarSet, current_script_line, read_sqlfile, read_sqlstring, runscripts
26
+ from execsql.utils.fileio import FileWriter, Logger, filewriter_end
27
+ from execsql.utils.gui import gui_connect, gui_console_isrunning, gui_console_off, gui_console_on, gui_console_wait_user
26
28
 
27
29
  _console = Console()
28
30
  _err_console = Console(stderr=True)
@@ -193,7 +195,7 @@ def _version_callback(value: bool) -> None:
193
195
  def main(
194
196
  ctx: typer.Context,
195
197
  # Positional args collected manually (script + optional server/db/file)
196
- args: Optional[List[str]] = typer.Argument(
198
+ args: list[str] | None = typer.Argument(
197
199
  None,
198
200
  metavar="SQL_SCRIPT [SERVER DATABASE | DATABASE_FILE]",
199
201
  help=(
@@ -202,46 +204,46 @@ def main(
202
204
  ),
203
205
  ),
204
206
  # Named options — grouped to mirror the original argparse interface
205
- sub_vars: Optional[List[str]] = typer.Option(
207
+ sub_vars: list[str] | None = typer.Option(
206
208
  None,
207
209
  "-a",
208
210
  "--assign-arg",
209
211
  metavar="VALUE",
210
212
  help="Define the replacement string for a substitution variable [cyan]\\$ARG_x[/cyan].",
211
213
  ),
212
- boolean_int: Optional[str] = typer.Option(
214
+ boolean_int: str | None = typer.Option(
213
215
  None,
214
216
  "-b",
215
217
  "--boolean-int",
216
218
  metavar="{0,1,t,f,y,n}",
217
219
  help="Treat integers 0 and 1 as boolean values.",
218
220
  ),
219
- make_dirs: Optional[str] = typer.Option(
221
+ make_dirs: str | None = typer.Option(
220
222
  None,
221
223
  "-d",
222
224
  "--directories",
223
225
  metavar="{0,1,t,f,y,n}",
224
226
  help="Auto-create directories for EXPORT metacommand. [dim]n=no (default), y=yes[/dim]",
225
227
  ),
226
- database_encoding: Optional[str] = typer.Option(
228
+ database_encoding: str | None = typer.Option(
227
229
  None,
228
230
  "-e",
229
231
  "--database-encoding",
230
232
  help="Character encoding used in the database.",
231
233
  ),
232
- script_encoding: Optional[str] = typer.Option(
234
+ script_encoding: str | None = typer.Option(
233
235
  None,
234
236
  "-f",
235
237
  "--script-encoding",
236
238
  help="Character encoding of the script file. [dim]Default: UTF-8[/dim]",
237
239
  ),
238
- output_encoding: Optional[str] = typer.Option(
240
+ output_encoding: str | None = typer.Option(
239
241
  None,
240
242
  "-g",
241
243
  "--output-encoding",
242
244
  help="Encoding for WRITE and EXPORT output.",
243
245
  ),
244
- import_encoding: Optional[str] = typer.Option(
246
+ import_encoding: str | None = typer.Option(
245
247
  None,
246
248
  "-i",
247
249
  "--import-encoding",
@@ -271,20 +273,20 @@ def main(
271
273
  "--online-help",
272
274
  help="Open the online documentation in the default browser.",
273
275
  ),
274
- port: Optional[int] = typer.Option(
276
+ port: int | None = typer.Option(
275
277
  None,
276
278
  "-p",
277
279
  "--port",
278
280
  help="Database server port.",
279
281
  ),
280
- scanlines: Optional[int] = typer.Option(
282
+ scanlines: int | None = typer.Option(
281
283
  None,
282
284
  "-s",
283
285
  "--scan-lines",
284
286
  metavar="N",
285
287
  help="Lines to scan for IMPORT format detection. [dim]0 = scan entire file.[/dim]",
286
288
  ),
287
- db_type: Optional[str] = typer.Option(
289
+ db_type: str | None = typer.Option(
288
290
  None,
289
291
  "-t",
290
292
  "--type",
@@ -296,24 +298,28 @@ def main(
296
298
  "[bold]d[/bold]=DSN."
297
299
  ),
298
300
  ),
299
- user: Optional[str] = typer.Option(
301
+ user: str | None = typer.Option(
300
302
  None,
301
303
  "-u",
302
304
  "--user",
303
305
  help="Database user name.",
304
306
  ),
305
- use_gui: Optional[str] = typer.Option(
307
+ use_gui: str | None = typer.Option(
306
308
  None,
307
309
  "-v",
308
310
  "--visible-prompts",
309
311
  metavar="{0,1,2,3}",
310
312
  help=(
311
313
  "GUI level: [bold]0[/bold]=none (default), [bold]1[/bold]=GUI for password/pause, "
312
- "[bold]2[/bold]=GUI for password/pause + DB selection, [bold]3[/bold]=full GUI console. "
313
- "Set [cyan]gui_framework[/cyan] in the config [interface] section to choose "
314
- "'tkinter' (default) or 'textual'."
314
+ "[bold]2[/bold]=GUI for password/pause + DB selection, [bold]3[/bold]=full GUI console."
315
315
  ),
316
316
  ),
317
+ gui_framework: str | None = typer.Option(
318
+ None,
319
+ "--gui-framework",
320
+ metavar="{tkinter,textual}",
321
+ help="GUI framework to use with [cyan]--visible-prompts[/cyan]. [dim]Default: tkinter[/dim]",
322
+ ),
317
323
  no_passwd: bool = typer.Option(
318
324
  False,
319
325
  "-w",
@@ -326,14 +332,50 @@ def main(
326
332
  "--encodings",
327
333
  help="List available encoding names and exit.",
328
334
  ),
329
- import_buffer: Optional[int] = typer.Option(
335
+ import_buffer: int | None = typer.Option(
330
336
  None,
331
337
  "-z",
332
338
  "--import-buffer",
333
339
  metavar="KB",
334
340
  help="Import buffer size in KB. [dim]Default: 32[/dim]",
335
341
  ),
336
- version: Optional[bool] = typer.Option(
342
+ command: str | None = typer.Option(
343
+ None,
344
+ "-c",
345
+ "--command",
346
+ metavar="SCRIPT",
347
+ help=(
348
+ "Execute an inline SQL/metacommand script string instead of a script file. "
349
+ "Use shell [cyan]$'line1\\nline2'[/cyan] syntax for multi-line scripts."
350
+ ),
351
+ ),
352
+ dry_run: bool = typer.Option(
353
+ False,
354
+ "--dry-run",
355
+ help=("Parse the script and print the command list without connecting to a database or executing anything."),
356
+ ),
357
+ dsn: str | None = typer.Option(
358
+ None,
359
+ "--dsn",
360
+ "--connection-string",
361
+ metavar="URL",
362
+ help=(
363
+ "Database connection URL, e.g. [cyan]postgresql://user:pass@host:5432/db[/cyan]. "
364
+ "Supported schemes: postgresql, mysql, mssql, oracle, firebird, sqlite, duckdb. "
365
+ "Overrides [cyan]-t[/cyan]/[cyan]-u[/cyan]/[cyan]-p[/cyan] and positional server/db args."
366
+ ),
367
+ ),
368
+ output_dir: str | None = typer.Option(
369
+ None,
370
+ "--output-dir",
371
+ metavar="DIR",
372
+ help=(
373
+ "Default base directory for EXPORT output files. "
374
+ "Relative paths in EXPORT metacommands are joined to this directory. "
375
+ "Absolute paths and [cyan]stdout[/cyan] are unaffected."
376
+ ),
377
+ ),
378
+ version: bool | None = typer.Option(
337
379
  None,
338
380
  "--version",
339
381
  callback=_version_callback,
@@ -365,20 +407,28 @@ def main(
365
407
  if online_help:
366
408
  import webbrowser
367
409
 
368
- webbrowser.open("https://execsql.readthedocs.io/en/latest/", new=2, autoraise=True)
410
+ webbrowser.open("https://execsql2.readthedocs.io/en/latest/", new=2, autoraise=True)
411
+ raise typer.Exit()
369
412
 
370
413
  positional = args or []
371
- if not positional:
372
- _err_console.print("[bold red]Error:[/bold red] No SQL script file specified.")
373
- raise typer.Exit(code=1)
414
+ if command is not None:
415
+ script_name = None # inline mode — no script file
416
+ else:
417
+ if not positional:
418
+ _err_console.print(
419
+ "[bold red]Error:[/bold red] No SQL script file specified. Use [cyan]-c[/cyan] to run an inline script.",
420
+ )
421
+ raise typer.Exit(code=1)
422
+ script_name = positional[0]
423
+ if not Path(script_name).exists():
424
+ _err_console.print(
425
+ f'[bold red]Error:[/bold red] SQL script file [cyan]"{script_name}"[/cyan] does not exist.',
426
+ )
427
+ raise typer.Exit(code=1)
374
428
 
375
429
  # ------------------------------------------------------------------
376
430
  # Validate positional args and db_type choice
377
431
  # ------------------------------------------------------------------
378
- script_name = positional[0]
379
- if not os.path.exists(script_name):
380
- _err_console.print(f'[bold red]Error:[/bold red] SQL script file [cyan]"{script_name}"[/cyan] does not exist.')
381
- raise typer.Exit(code=1)
382
432
 
383
433
  if db_type and db_type not in ("a", "d", "p", "s", "l", "m", "k", "o", "f"):
384
434
  _err_console.print(
@@ -393,6 +443,12 @@ def main(
393
443
  )
394
444
  raise typer.Exit(code=2)
395
445
 
446
+ if gui_framework and gui_framework.lower() not in ("tkinter", "textual"):
447
+ _err_console.print(
448
+ f"[bold red]Error:[/bold red] Invalid GUI framework [cyan]{gui_framework!r}[/cyan]. Choose from: tkinter, textual",
449
+ )
450
+ raise typer.Exit(code=2)
451
+
396
452
  if boolean_int and boolean_int.lower() not in ("0", "1", "t", "f", "y", "n"):
397
453
  _err_console.print(
398
454
  f"[bold red]Error:[/bold red] Invalid --boolean-int value [cyan]{boolean_int!r}[/cyan].",
@@ -418,12 +474,122 @@ def main(
418
474
  db_type=db_type,
419
475
  user=user,
420
476
  use_gui=use_gui,
477
+ gui_framework=gui_framework,
421
478
  no_passwd=no_passwd,
422
479
  import_buffer=import_buffer,
423
480
  script_name=script_name,
481
+ command=command,
482
+ dry_run=dry_run,
483
+ dsn=dsn,
484
+ output_dir=output_dir,
424
485
  )
425
486
 
426
487
 
488
+ # ---------------------------------------------------------------------------
489
+ # Connection-string parser
490
+ # ---------------------------------------------------------------------------
491
+
492
+ #: Mapping from URL scheme → execsql db_type code
493
+ _SCHEME_TO_DBTYPE: dict[str, str] = {
494
+ "postgresql": "p",
495
+ "postgres": "p",
496
+ "mysql": "m",
497
+ "mariadb": "m",
498
+ "mssql": "s",
499
+ "sqlserver": "s",
500
+ "oracle": "o",
501
+ "oracle+cx_oracle": "o",
502
+ "firebird": "f",
503
+ "sqlite": "l",
504
+ "duckdb": "k",
505
+ }
506
+
507
+
508
+ def _parse_connection_string(dsn: str) -> dict:
509
+ """Parse a database URL into a dict of connection parameters.
510
+
511
+ Supports the common form::
512
+
513
+ scheme://[user[:password]@][host[:port]]/database
514
+
515
+ For file-based databases (SQLite, DuckDB) the path after ``//`` is
516
+ treated as the database file path::
517
+
518
+ sqlite:///path/to/file.db → db_file = /path/to/file.db
519
+ duckdb:///path/to/file.db → db_file = /path/to/file.db
520
+
521
+ Returns a dict with keys: ``db_type``, ``server``, ``db``, ``db_file``,
522
+ ``user``, ``password``, ``port``. Absent components are ``None``.
523
+
524
+ Raises :class:`~execsql.exceptions.ConfigError` for an unrecognised
525
+ URL scheme or a completely un-parseable string.
526
+ """
527
+ from urllib.parse import urlparse
528
+
529
+ parsed = urlparse(dsn)
530
+ scheme = parsed.scheme.lower()
531
+ if not scheme:
532
+ raise ConfigError(f"Cannot parse connection string (no scheme): {dsn!r}")
533
+ if scheme not in _SCHEME_TO_DBTYPE:
534
+ raise ConfigError(
535
+ f"Unrecognised connection-string scheme {scheme!r}. "
536
+ f"Supported schemes: {', '.join(sorted(_SCHEME_TO_DBTYPE))}",
537
+ )
538
+
539
+ db_type = _SCHEME_TO_DBTYPE[scheme]
540
+ port: int | None = parsed.port
541
+ server: str | None = parsed.hostname or None
542
+ user: str | None = parsed.username or None
543
+ password: str | None = parsed.password or None
544
+
545
+ # Database / file path
546
+ # urlparse puts the path in parsed.path. For three-slash URIs like
547
+ # sqlite:///foo.db the path starts with "/"; strip exactly one leading
548
+ # slash for relative paths (sqlite:///foo.db → foo.db) and leave
549
+ # absolute paths intact (sqlite:////abs/path → /abs/path).
550
+ raw_path = parsed.path
551
+ if db_type in ("l", "k", "a"):
552
+ # File-based: no server component
553
+ if raw_path.startswith("/") and not raw_path.startswith("//"):
554
+ db_file: str | None = raw_path[1:] or None
555
+ else:
556
+ db_file = raw_path or None
557
+ db: str | None = None
558
+ else:
559
+ db_file = None
560
+ # Remove leading "/"
561
+ db = raw_path.lstrip("/") or None
562
+
563
+ return {
564
+ "db_type": db_type,
565
+ "server": server,
566
+ "db": db,
567
+ "db_file": db_file,
568
+ "user": user,
569
+ "password": password,
570
+ "port": port,
571
+ }
572
+
573
+
574
+ # ---------------------------------------------------------------------------
575
+ # Dry-run helper
576
+ # ---------------------------------------------------------------------------
577
+
578
+
579
+ def _print_dry_run(cmdlist: object) -> None:
580
+ """Print the parsed command list for --dry-run mode."""
581
+ if cmdlist is None or not cmdlist.cmdlist:
582
+ _console.print("[yellow]No commands found in script.[/yellow]")
583
+ return
584
+ n = len(cmdlist.cmdlist)
585
+ _console.print(f"[bold cyan]Dry Run[/bold cyan] — [dim]{n} command(s) parsed[/dim]")
586
+ _console.print()
587
+ for i, cmd in enumerate(cmdlist.cmdlist, 1):
588
+ ctype = "SQL " if cmd.command_type == "sql" else "METACMD"
589
+ source_info = f"[dim]{cmd.source}:{cmd.line_no}[/dim]"
590
+ _console.print(f" [dim]{i:>4}[/dim] [bold green]{ctype}[/bold green] {source_info} {cmd.commandline()}")
591
+
592
+
427
593
  # ---------------------------------------------------------------------------
428
594
  # Core execution (split from argument parsing for testability)
429
595
  # ---------------------------------------------------------------------------
@@ -431,44 +597,56 @@ def main(
431
597
 
432
598
  def _run(
433
599
  positional: list,
434
- sub_vars: Optional[List[str]],
435
- boolean_int: Optional[str],
436
- make_dirs: Optional[str],
437
- database_encoding: Optional[str],
438
- script_encoding: Optional[str],
439
- output_encoding: Optional[str],
440
- import_encoding: Optional[str],
600
+ sub_vars: list[str] | None,
601
+ boolean_int: str | None,
602
+ make_dirs: str | None,
603
+ database_encoding: str | None,
604
+ script_encoding: str | None,
605
+ output_encoding: str | None,
606
+ import_encoding: str | None,
441
607
  user_logfile: bool,
442
608
  new_db: bool,
443
- port: Optional[int],
444
- scanlines: Optional[int],
445
- db_type: Optional[str],
446
- user: Optional[str],
447
- use_gui: Optional[str],
448
- no_passwd: bool,
449
- import_buffer: Optional[int],
450
- script_name: str,
609
+ port: int | None,
610
+ scanlines: int | None,
611
+ db_type: str | None,
612
+ user: str | None,
613
+ use_gui: str | None,
614
+ gui_framework: str | None = None,
615
+ no_passwd: bool = False,
616
+ import_buffer: int | None = None,
617
+ script_name: str | None = None,
618
+ command: str | None = None,
619
+ dry_run: bool = False,
620
+ dsn: str | None = None,
621
+ output_dir: str | None = None,
451
622
  ) -> None:
452
- """Core execution logic, separated from argument parsing."""
623
+ """Initialise state, connect to the database, load the script, and run it.
624
+
625
+ Separated from argument parsing so it can be called directly in tests
626
+ without going through the Typer CLI layer. All parameters mirror the
627
+ corresponding CLI options; see [Syntax & Options](../syntax.md) for
628
+ descriptions.
629
+ """
453
630
  import execsql.state as _state
454
631
 
455
632
  # ------------------------------------------------------------------
456
633
  # Early setup: substitution variables seeded before arg parsing
457
634
  # ------------------------------------------------------------------
458
- _state.subvars = _state.SubVarSet()
635
+ _state.subvars = SubVarSet()
459
636
 
637
+ # Security note: ALL environment variables are exposed as &-prefixed
638
+ # substitution variables. Sensitive values (API keys, tokens) in the
639
+ # process environment will be accessible to scripts. See the
640
+ # "Environment Variables" section in docs/substitution_vars.md.
460
641
  for k in os.environ:
461
642
  try:
462
643
  _state.subvars.add_substitution("&" + k, os.environ[k])
463
644
  except Exception:
464
- pass
645
+ pass # Skip env vars with names that can't be substitution keys.
465
646
  _state.subvars.add_substitution("$LAST_ROWCOUNT", None)
466
647
 
467
648
  dt_now = datetime.datetime.now()
468
- if sys.version_info < (3, 12):
469
- dt_now_utc = datetime.datetime.utcnow()
470
- else:
471
- dt_now_utc = datetime.datetime.now(tz=datetime.timezone.utc)
649
+ dt_now_utc = datetime.datetime.now(tz=datetime.timezone.utc)
472
650
 
473
651
  _state.subvars.add_substitution("$SCRIPT_START_TIME", dt_now.strftime("%Y-%m-%d %H:%M"))
474
652
  _state.subvars.add_substitution("$SCRIPT_START_TIME_UTC", dt_now_utc.strftime("%Y-%m-%d %H:%M"))
@@ -492,9 +670,36 @@ def _run(
492
670
  # ------------------------------------------------------------------
493
671
  # Read configuration file
494
672
  # ------------------------------------------------------------------
495
- _state.conf = ConfigData(os.path.dirname(os.path.abspath(script_name)), _state.subvars)
673
+ script_path = str(Path(script_name).resolve().parent) if script_name else os.getcwd()
674
+ _state.conf = ConfigData(script_path, _state.subvars)
496
675
  conf = _state.conf
497
676
 
677
+ # ------------------------------------------------------------------
678
+ # Connection string (--dsn / --connection-string): overrides -t/-u/-p
679
+ # and positional server/db args when provided.
680
+ # ------------------------------------------------------------------
681
+ if dsn:
682
+ try:
683
+ parsed_dsn = _parse_connection_string(dsn)
684
+ except ConfigError as exc:
685
+ _err_console.print(f"[bold red]Error:[/bold red] {exc}")
686
+ raise SystemExit(1)
687
+ db_type = db_type or parsed_dsn["db_type"]
688
+ conf.db_type = db_type
689
+ if parsed_dsn["server"] and not conf.server:
690
+ conf.server = parsed_dsn["server"]
691
+ if parsed_dsn["db"] and not conf.db:
692
+ conf.db = parsed_dsn["db"]
693
+ if parsed_dsn["db_file"] and not conf.db_file:
694
+ conf.db_file = parsed_dsn["db_file"]
695
+ if parsed_dsn["user"] and not user:
696
+ user = parsed_dsn["user"]
697
+ if parsed_dsn["password"]:
698
+ conf.db_password = parsed_dsn["password"]
699
+ conf.passwd_prompt = False
700
+ if parsed_dsn["port"] and not port:
701
+ port = parsed_dsn["port"]
702
+
498
703
  # Apply CLI options over config-file values
499
704
  if user:
500
705
  conf.username = user
@@ -530,6 +735,8 @@ def _run(
530
735
  conf.gui_level = 0
531
736
  elif conf.gui_level not in range(4):
532
737
  raise ConfigError(f"Invalid GUI level specification: {conf.gui_level}")
738
+ if gui_framework:
739
+ conf.gui_framework = gui_framework.lower()
533
740
  if db_type:
534
741
  conf.db_type = db_type
535
742
  if conf.db_type is None:
@@ -542,22 +749,27 @@ def _run(
542
749
  conf.access_username = user
543
750
  if new_db:
544
751
  conf.new_db = True
545
-
546
- # Positional arguments after the script name
547
- if len(positional) == 2:
752
+ if output_dir:
753
+ conf.export_output_dir = str(Path(output_dir).resolve())
754
+
755
+ # Positional arguments after the script name (or all positionals in inline mode)
756
+ # off=1: script file occupies positional[0]; connection args start at [1]
757
+ # off=0: no script file; all positionals are connection args
758
+ off = 0 if command is not None else 1
759
+ if len(positional) == off + 1:
548
760
  if conf.db_type in ("a", "l", "k"):
549
- conf.db_file = positional[1]
761
+ conf.db_file = positional[off]
550
762
  elif conf.db_type == "d":
551
- conf.db = positional[1]
763
+ conf.db = positional[off]
552
764
  else:
553
765
  if conf.server and not conf.db:
554
- conf.db = positional[1]
766
+ conf.db = positional[off]
555
767
  else:
556
- conf.server = positional[1]
557
- elif len(positional) == 3:
558
- conf.server = positional[1]
559
- conf.db = positional[2]
560
- elif len(positional) > 3:
768
+ conf.server = positional[off]
769
+ elif len(positional) == off + 2:
770
+ conf.server = positional[off]
771
+ conf.db = positional[off + 1]
772
+ elif len(positional) > off + 2:
561
773
  from execsql.utils.errors import fatal_error
562
774
 
563
775
  fatal_error("Incorrect number of command-line arguments.")
@@ -567,20 +779,25 @@ def _run(
567
779
  # ------------------------------------------------------------------
568
780
  from execsql.utils.errors import file_size_date
569
781
 
570
- _state.subvars.add_substitution("$STARTING_SCRIPT", script_name)
571
- _state.subvars.add_substitution("$STARTING_SCRIPT_NAME", os.path.basename(script_name))
572
- _state.subvars.add_substitution("$STARTING_SCRIPT_REVTIME", file_size_date(script_name)[1])
782
+ if script_name is not None:
783
+ _state.subvars.add_substitution("$STARTING_SCRIPT", script_name)
784
+ _state.subvars.add_substitution("$STARTING_SCRIPT_NAME", Path(script_name).name)
785
+ _state.subvars.add_substitution("$STARTING_SCRIPT_REVTIME", file_size_date(script_name)[1])
786
+ else:
787
+ _state.subvars.add_substitution("$STARTING_SCRIPT", "<inline>")
788
+ _state.subvars.add_substitution("$STARTING_SCRIPT_NAME", "<inline>")
789
+ _state.subvars.add_substitution("$STARTING_SCRIPT_REVTIME", "")
573
790
 
574
791
  # ------------------------------------------------------------------
575
792
  # Initialise state objects
576
793
  # ------------------------------------------------------------------
577
- _state.if_stack = _state.IfLevels()
578
- _state.counters = _state.CounterVars()
579
- _state.timer = _state.Timer()
794
+ from execsql.metacommands import DISPATCH_TABLE
795
+ from execsql.metacommands.conditions import CONDITIONAL_TABLE
796
+
797
+ _state.initialize(conf, DISPATCH_TABLE, CONDITIONAL_TABLE)
798
+
799
+ # Local-only objects that require CLI-specific args or class definitions
580
800
  _state.status = StatObj()
581
- _state.dbs = _state.DatabasePool()
582
- _state.tempfiles = _state.TempFileMgr()
583
- _state.export_metadata = _state.ExportMetadata()
584
801
 
585
802
  from execsql.config import WriteHooks
586
803
 
@@ -589,22 +806,14 @@ def _run(
589
806
  import execsql.utils.fileio as _fileio
590
807
 
591
808
  if _state.filewriter is None or not _state.filewriter.is_alive():
592
- _state.filewriter = _state.FileWriter(
809
+ _fileio.filewriter = _state.filewriter = FileWriter(
593
810
  _fileio.fw_input,
594
811
  _fileio.fw_output,
595
812
  file_encoding=conf.output_encoding,
596
813
  open_timeout=getattr(conf, "outfile_open_timeout", 10),
597
814
  )
598
815
  _state.filewriter.start()
599
- atexit.register(_state.filewriter_end)
600
-
601
- from execsql.metacommands import DISPATCH_TABLE
602
-
603
- _state.metacommandlist = DISPATCH_TABLE
604
-
605
- from execsql.metacommands.conditions import CONDITIONAL_TABLE
606
-
607
- _state.conditionallist = CONDITIONAL_TABLE
816
+ atexit.register(filewriter_end)
608
817
 
609
818
  # ------------------------------------------------------------------
610
819
  # Logging
@@ -631,15 +840,15 @@ def _run(
631
840
  }.items()
632
841
  if v
633
842
  }
634
- _state.exec_log = _state.Logger(
635
- script_name,
843
+ _state.exec_log = Logger(
844
+ script_name or "<inline>",
636
845
  conf.db,
637
846
  conf.server,
638
847
  opts_dict,
639
848
  conf.user_logfile,
640
849
  )
641
850
  _state.exec_log.log_status_info(
642
- "Python version %d.%d.%d %s" % sys.version_info[:4],
851
+ f"Python version {sys.version_info[0]}.{sys.version_info[1]}.{sys.version_info[2]} {sys.version_info[3]}",
643
852
  )
644
853
  _state.exec_log.log_status_info(f"execsql version {__version__}")
645
854
  _state.exec_log.log_status_info(f"System user: {getpass.getuser()}")
@@ -662,20 +871,30 @@ def _run(
662
871
  # ------------------------------------------------------------------
663
872
  # Load the SQL script
664
873
  # ------------------------------------------------------------------
665
- _state.read_sqlfile(script_name)
874
+ if command is not None:
875
+ read_sqlstring(command.replace("\\n", "\n").replace("\\t", "\t"), "<inline>")
876
+ else:
877
+ read_sqlfile(script_name)
878
+
879
+ # ------------------------------------------------------------------
880
+ # Dry-run: print command list and exit without connecting to DB
881
+ # ------------------------------------------------------------------
882
+ if dry_run:
883
+ _print_dry_run(_state.commandliststack[-1] if _state.commandliststack else None)
884
+ raise SystemExit(0)
666
885
 
667
886
  # ------------------------------------------------------------------
668
887
  # Start GUI console if requested
669
888
  # ------------------------------------------------------------------
670
889
  if conf.gui_level > 2:
671
- _state.gui_console_on()
890
+ gui_console_on()
672
891
 
673
892
  # ------------------------------------------------------------------
674
893
  # Establish database connection
675
894
  # ------------------------------------------------------------------
676
895
  if conf.server is None and conf.db is None and conf.db_file is None:
677
896
  if conf.gui_level > 1:
678
- _state.gui_connect("initial", f"Select the database to use with {script_name}.")
897
+ gui_connect("initial", f"Select the database to use with {script_name or '<inline>'}.")
679
898
  db = _state.dbs.current()
680
899
  else:
681
900
  from execsql.utils.errors import fatal_error
@@ -717,7 +936,7 @@ def _execute_script_textual_console(conf: ConfigData) -> None:
717
936
  _state.gui_manager_queue = dialog_queue
718
937
 
719
938
  app = ConsoleApp(
720
- script_runner=_state.runscripts,
939
+ script_runner=runscripts,
721
940
  dialog_queue=dialog_queue,
722
941
  wait_on_exit=conf.gui_wait_on_exit,
723
942
  )
@@ -745,8 +964,8 @@ def _execute_script_textual_console(conf: ConfigData) -> None:
745
964
  else:
746
965
  strace = traceback.extract_tb(exc.__traceback__)[-1:]
747
966
  lno = strace[0][1] if strace else "?"
748
- msg = f"{os.path.basename(sys.argv[0])}: Uncaught exception {type(exc)} ({exc}) on line {lno}"
749
- script, slno = _state.current_script_line()
967
+ msg = f"{Path(sys.argv[0]).name}: Uncaught exception {type(exc)} ({exc}) on line {lno}"
968
+ script, slno = current_script_line()
750
969
  if script is not None:
751
970
  msg += f" in script {script}, line {slno}"
752
971
  from execsql.utils.errors import exit_now
@@ -775,14 +994,14 @@ def _execute_script_direct(conf: ConfigData) -> None:
775
994
  pass
776
995
 
777
996
  try:
778
- _state.runscripts()
997
+ runscripts()
779
998
  except SystemExit as exc:
780
- if _state.gui_console_isrunning() and conf.gui_wait_on_exit:
781
- _state.gui_console_wait_user(
999
+ if gui_console_isrunning() and conf.gui_wait_on_exit:
1000
+ gui_console_wait_user(
782
1001
  "Script complete; close the console window to exit execsql.",
783
1002
  )
784
- if _state.gui_console_isrunning():
785
- _state.gui_console_off()
1003
+ if gui_console_isrunning():
1004
+ gui_console_off()
786
1005
  _state.exec_log.log_status_info(f"{_state.cmds_run} commands run")
787
1006
  sys.exit(exc.code)
788
1007
  except ConfigError:
@@ -794,11 +1013,8 @@ def _execute_script_direct(conf: ConfigData) -> None:
794
1013
  except Exception:
795
1014
  strace = traceback.extract_tb(sys.exc_info()[2])[-1:]
796
1015
  lno = strace[0][1]
797
- msg = (
798
- f"{os.path.basename(sys.argv[0])}: Uncaught exception "
799
- f"{sys.exc_info()[0]} ({sys.exc_info()[1]}) on line {lno}"
800
- )
801
- script, slno = _state.current_script_line()
1016
+ msg = f"{Path(sys.argv[0]).name}: Uncaught exception {sys.exc_info()[0]} ({sys.exc_info()[1]}) on line {lno}"
1017
+ script, slno = current_script_line()
802
1018
  if script is not None:
803
1019
  msg += f" in script {script}, line {slno}"
804
1020
  from execsql.utils.errors import exit_now
@@ -806,12 +1022,12 @@ def _execute_script_direct(conf: ConfigData) -> None:
806
1022
  exit_now(1, ErrInfo("exception", exception_msg=msg))
807
1023
 
808
1024
  _state.dbs.do_rollback = False
809
- if _state.gui_console_isrunning() and conf.gui_wait_on_exit:
810
- _state.gui_console_wait_user(
1025
+ if gui_console_isrunning() and conf.gui_wait_on_exit:
1026
+ gui_console_wait_user(
811
1027
  "Script complete; close the console window to exit execsql.",
812
1028
  )
813
- if _state.gui_console_isrunning():
814
- _state.gui_console_off()
1029
+ if gui_console_isrunning():
1030
+ gui_console_off()
815
1031
  _state.exec_log.log_status_info(f"{_state.cmds_run} commands run")
816
1032
  _state.exec_log.log_exit_end()
817
1033
 
@@ -855,6 +1071,7 @@ def _connect_initial_db(conf: ConfigData):
855
1071
  port=conf.port,
856
1072
  encoding=conf.db_encoding,
857
1073
  new_db=conf.new_db,
1074
+ password=getattr(conf, "db_password", None),
858
1075
  )
859
1076
  elif conf.db_type == "s":
860
1077
  return db_SqlServer(
@@ -939,10 +1156,7 @@ def _legacy_main() -> None:
939
1156
  except Exception:
940
1157
  strace = traceback.extract_tb(sys.exc_info()[2])[-1:]
941
1158
  lno = strace[0][1]
942
- msg = (
943
- f"{os.path.basename(sys.argv[0])}: Uncaught exception "
944
- f"{sys.exc_info()[0]} ({sys.exc_info()[1]}) on line {lno}"
945
- )
1159
+ msg = f"{Path(sys.argv[0]).name}: Uncaught exception {sys.exc_info()[0]} ({sys.exc_info()[1]}) on line {lno}"
946
1160
  from execsql.utils.errors import exit_now
947
1161
 
948
1162
  exit_now(1, ErrInfo("exception", exception_msg=msg))