execsql2 2.24.0__py3-none-any.whl → 2.25.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 (72) hide show
  1. execsql/api.py +169 -160
  2. execsql/cli/__init__.py +2 -0
  3. execsql/cli/commands/lint.py +2 -10
  4. execsql/cli/commands/run.py +35 -27
  5. execsql/cli/dsn.py +11 -4
  6. execsql/cli/lint.py +351 -48
  7. execsql/cli/options.py +1 -1
  8. execsql/cli/run.py +56 -19
  9. execsql/config.py +5 -19
  10. execsql/data/execsql.conf.template +14 -4
  11. execsql/db/access.py +29 -8
  12. execsql/db/base.py +40 -8
  13. execsql/db/dsn.py +13 -4
  14. execsql/db/duckdb.py +3 -1
  15. execsql/db/factory.py +11 -2
  16. execsql/db/firebird.py +21 -7
  17. execsql/db/mysql.py +37 -16
  18. execsql/db/oracle.py +49 -16
  19. execsql/db/postgres.py +30 -6
  20. execsql/db/sqlite.py +7 -9
  21. execsql/db/sqlserver.py +25 -8
  22. execsql/exporters/delimited.py +87 -28
  23. execsql/exporters/xlsx.py +24 -9
  24. execsql/exporters/zip.py +1 -2
  25. execsql/format.py +29 -2
  26. execsql/gui/desktop.py +1 -3
  27. execsql/gui/tui.py +2 -2
  28. execsql/importers/base.py +35 -1
  29. execsql/importers/csv.py +3 -1
  30. execsql/manifest.py +33 -22
  31. execsql/metacommands/conditions.py +6 -7
  32. execsql/metacommands/data.py +4 -1
  33. execsql/metacommands/debug.py +2 -4
  34. execsql/metacommands/io_fileops.py +62 -37
  35. execsql/metacommands/io_import.py +0 -1
  36. execsql/metacommands/prompt.py +2 -0
  37. execsql/metacommands/upsert.py +5 -2
  38. execsql/models.py +3 -2
  39. execsql/parser.py +14 -5
  40. execsql/plugins.py +24 -0
  41. execsql/script/engine.py +40 -11
  42. execsql/script/executor.py +9 -5
  43. execsql/script/parser.py +167 -118
  44. execsql/state.py +23 -9
  45. execsql/state.pyi +8 -0
  46. execsql/types.py +22 -13
  47. execsql/utils/auth.py +169 -24
  48. execsql/utils/datetime.py +55 -17
  49. execsql/utils/errors.py +71 -9
  50. execsql/utils/fileio.py +404 -117
  51. execsql/utils/gui.py +3 -0
  52. execsql/utils/mail.py +37 -12
  53. {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/METADATA +18 -12
  54. {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/RECORD +72 -72
  55. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/README.md +0 -0
  56. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  57. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  58. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/make_config_db.sql +0 -0
  59. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/md_compare.sql +0 -0
  60. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/md_glossary.sql +0 -0
  61. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/md_upsert.sql +0 -0
  62. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/pg_compare.sql +0 -0
  63. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  64. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  65. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/script_template.sql +0 -0
  66. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/ss_compare.sql +0 -0
  67. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  68. {execsql2-2.24.0.data → execsql2-2.25.1.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  69. {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/WHEEL +0 -0
  70. {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/entry_points.txt +0 -0
  71. {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/licenses/LICENSE.txt +0 -0
  72. {execsql2-2.24.0.dist-info → execsql2-2.25.1.dist-info}/licenses/NOTICE +0 -0
execsql/api.py CHANGED
@@ -15,7 +15,7 @@ Usage::
15
15
  result = run(sql="CREATE TABLE t (id INT); INSERT INTO t VALUES (1);",
16
16
  dsn="sqlite:///my.db")
17
17
 
18
- # With substitution variables
18
+ # With substitution variables (referenced as !!$SCHEMA!! and !!$DATE!!)
19
19
  result = run(script="etl.sql",
20
20
  dsn="postgresql://user:pass@host/db",
21
21
  variables={"SCHEMA": "public", "DATE": "2026-01-01"})
@@ -29,7 +29,6 @@ Usage::
29
29
 
30
30
  from __future__ import annotations
31
31
 
32
- import atexit
33
32
  import dataclasses
34
33
  import datetime
35
34
  import io
@@ -57,7 +56,9 @@ class ScriptError:
57
56
 
58
57
  Attributes:
59
58
  message: Human-readable error description.
60
- source: Script file path or ``"<inline>"``.
59
+ source: Script file path or ``"<inline>"`` for an error in the
60
+ script; ``"<config>"``, ``"<connect>"`` or ``"<output>"`` for a
61
+ config file, connection or output file that failed.
61
62
  line: Source line number, or ``None`` if unknown.
62
63
  sql: The SQL statement that caused the error, if applicable.
63
64
  """
@@ -77,10 +78,10 @@ class ScriptResult:
77
78
  commands_run: Number of SQL statements and metacommands executed.
78
79
  elapsed: Wall-clock execution time in seconds.
79
80
  errors: List of errors encountered (empty on success).
80
- variables: Final state of substitution variables, keyed by name
81
- without any sigil. Includes user-defined variables (no prefix)
82
- and system variables (the leading ``$`` is stripped, so
83
- ``$DATE_TAG`` appears as ``"DATE_TAG"``). Environment (``&``),
81
+ variables: Final state of substitution variables, keyed by
82
+ lower-case name without any sigil. Includes user-defined
83
+ variables (no prefix) and system variables (the leading ``$`` is
84
+ stripped, so ``$DATE_TAG`` appears as ``"date_tag"``). Environment (``&``),
84
85
  column-data (``@``), script-local (``~``), and script-parameter
85
86
  (``#``) variables are excluded.
86
87
  """
@@ -217,6 +218,8 @@ class _LibraryConfig:
217
218
  self.smtp_password: str | None = None
218
219
  self.smtp_ssl = False
219
220
  self.smtp_tls = False
221
+ self.smtp_verify_certificate = True
222
+ self.smtp_ca_file: str | None = None
220
223
  self.email_format = "plain"
221
224
  self.email_css: str | None = None
222
225
 
@@ -352,7 +355,8 @@ def run(
352
355
  connection: A pre-existing :class:`~execsql.db.base.Database`
353
356
  instance. ``run()`` will NOT close this connection on exit.
354
357
  variables: Substitution variables as ``{"NAME": "value"}``.
355
- Keys without a ``$`` prefix get one added automatically.
358
+ Keys without a ``$`` prefix get one added automatically, so
359
+ ``{"SCHEMA": "public"}`` is referenced as ``!!$SCHEMA!!``.
356
360
  config_file: Optional execsql configuration file to load.
357
361
  encoding: Script file encoding (default ``"utf-8"``).
358
362
  halt_on_error: If ``True`` (default), stop on the first SQL
@@ -373,7 +377,9 @@ def run(
373
377
 
374
378
  Returns:
375
379
  A :class:`ScriptResult` with execution outcome, timing, errors,
376
- and final variable state.
380
+ and final variable state. A script that cannot be read, a bad
381
+ *config_file*, and a *dsn* that cannot be parsed or opened all
382
+ return ``success=False`` rather than raising.
377
383
 
378
384
  Raises:
379
385
  ValueError: If the argument combination is invalid (e.g. both
@@ -404,14 +410,8 @@ def run(
404
410
  else:
405
411
  assert sql is not None
406
412
  tree = parse_string(sql, source_name="<inline>")
407
- except ErrInfo as exc:
408
- return ScriptResult(
409
- success=False,
410
- commands_run=0,
411
- elapsed=0.0,
412
- errors=[ScriptError(message=exc.errmsg(), source=str(script) if script else "<inline>")],
413
- variables={},
414
- )
413
+ except (ErrInfo, OSError) as exc:
414
+ return _failed(_error_text(exc), str(script) if script else "<inline>")
415
415
 
416
416
  # ------------------------------------------------------------------
417
417
  # Build an isolated RuntimeContext
@@ -425,9 +425,14 @@ def run(
425
425
  # Load a real ConfigData with the explicit config file
426
426
  from execsql.script.variables import SubVarSet
427
427
 
428
+ if not Path(config_file).is_file():
429
+ return _failed(f"Config file {str(config_file)!r} does not exist.", "<config>")
428
430
  temp_subvars = SubVarSet()
429
431
  script_dir = str(Path(script).resolve().parent) if script else os.getcwd()
430
- conf = ConfigData(script_dir, temp_subvars, config_file=str(config_file))
432
+ try:
433
+ conf = ConfigData(script_dir, temp_subvars, config_file=str(config_file))
434
+ except Exception as exc:
435
+ return _failed(_error_text(exc), "<config>")
431
436
  # Apply any overrides
432
437
  for k, v in conf_overrides.items():
433
438
  setattr(conf, k, v)
@@ -492,89 +497,103 @@ def run(
492
497
  # Connect to database
493
498
  # ------------------------------------------------------------------
494
499
  owns_connection = connection is None
495
- if dsn is not None:
496
- db = _connect_from_dsn(dsn, new_db=new_db)
497
- else:
498
- db = connection
499
-
500
- assert db is not None
501
- assert ctx.dbs is not None
502
- ctx.dbs.add("initial", db)
503
- ctx.subvars.add_substitution("$CURRENT_DBMS", db.type.dbms_id)
504
- ctx.subvars.add_substitution("$CURRENT_DATABASE", db.name())
505
- ctx.subvars.add_substitution("$SYSTEM_CMD_EXIT_STATUS", "0")
506
-
507
- # ------------------------------------------------------------------
508
- # Execute
509
- # ------------------------------------------------------------------
510
- from execsql.script.executor import execute
511
-
512
- errors: list[ScriptError] = []
513
- t0 = time.perf_counter()
514
-
515
- # WRITE ... TO <file> and TEE are handled by a FileWriter subprocess,
516
- # and fileio drops every write when none is running. Start one here so
517
- # the API behaves like the CLI.
518
- _ensure_filewriter(conf)
519
-
500
+ # Everything from here on runs inside try/finally so an owned
501
+ # connection is closed and temp files removed however run() exits,
502
+ # including KeyboardInterrupt in a notebook.
520
503
  try:
521
- execute(tree, ctx=ctx)
522
- except SystemExit:
523
- # exit_now() calls sys.exit() — catch and convert to error
524
- _capture_errors(ctx, errors)
525
- except ErrInfo as exc:
526
- errors.append(
527
- ScriptError(
528
- message=exc.errmsg(),
529
- source=_last_source(ctx),
530
- line=_last_line(ctx),
531
- sql=getattr(exc, "command_text", None),
532
- ),
533
- )
534
- except Exception as exc:
535
- errors.append(ScriptError(message=str(exc), source="<runtime>"))
536
-
537
- # Non-halting errors (halt_on_error=False, METACOMMAND_ERROR_HALT OFF)
538
- # are recorded in status.error_history as execution continues; a
539
- # halting error never reaches the history, so there is no overlap
540
- # with the exception paths above.
541
- if ctx.status is not None:
542
- for err_source, err_line, err_cmd, err_msg in ctx.status.error_history:
543
- errors.append(ScriptError(message=err_msg, source=err_source, line=err_line, sql=err_cmd))
544
-
545
- elapsed = time.perf_counter() - t0
504
+ if dsn is not None:
505
+ try:
506
+ db = _connect_from_dsn(dsn, new_db=new_db)
507
+ except Exception as exc:
508
+ return _failed(_error_text(exc), "<connect>")
509
+ else:
510
+ db = connection
511
+
512
+ assert db is not None
513
+ assert ctx.dbs is not None
514
+ ctx.dbs.add("initial", db)
515
+ ctx.subvars.add_substitution("$CURRENT_DBMS", db.type.dbms_id)
516
+ ctx.subvars.add_substitution("$CURRENT_DATABASE", db.name())
517
+ ctx.subvars.add_substitution("$SYSTEM_CMD_EXIT_STATUS", "0")
518
+
519
+ # ------------------------------------------------------------------
520
+ # Execute
521
+ # ------------------------------------------------------------------
522
+ from execsql.script.executor import execute
523
+
524
+ errors: list[ScriptError] = []
525
+ t0 = time.perf_counter()
526
+
527
+ # WRITE ... TO <file> and TEE hand their output to a FileWriter, and
528
+ # fileio drops every write when none is running. This run gets its
529
+ # own, so concurrent runs never share files or a halt.
530
+ _start_filewriter(ctx, conf)
546
531
 
547
- # Flush and close every file the script wrote, so the caller can read
548
- # them the moment run() returns. The subprocess itself is left running
549
- # for reuse by a later run() and is reaped by the atexit handler; a
550
- # writer the caller started is theirs and is never shut down here.
551
- from execsql.utils.fileio import filewriter_close_all_after_write
552
-
553
- try:
554
- filewriter_close_all_after_write()
555
- except Exception:
556
- pass # Best-effort: a failed flush must not mask a script error.
532
+ try:
533
+ execute(tree, ctx=ctx)
534
+ except SystemExit as exc:
535
+ # exit_now() calls sys.exit() — catch and convert to error
536
+ _capture_errors(ctx, errors, exc.code)
537
+ except ErrInfo as exc:
538
+ errors.append(
539
+ ScriptError(
540
+ message=exc.errmsg(),
541
+ source=_last_source(ctx),
542
+ line=_last_line(ctx),
543
+ sql=getattr(exc, "command_text", None),
544
+ ),
545
+ )
546
+ except Exception as exc:
547
+ errors.append(ScriptError(message=str(exc), source="<runtime>"))
548
+
549
+ # Non-halting SQL errors (halt_on_error=False) are recorded in
550
+ # status.error_history as execution continues; a halting error never
551
+ # reaches the history, so there is no overlap with the exception
552
+ # paths above. They happened first, so they are listed first.
553
+ if ctx.status is not None:
554
+ errors[:0] = [
555
+ ScriptError(message=err_msg, source=err_source, line=err_line, sql=err_cmd)
556
+ for err_source, err_line, err_cmd, err_msg in ctx.status.error_history
557
+ ]
558
+
559
+ elapsed = time.perf_counter() - t0
560
+
561
+ # Flush and close every file the script wrote, so the caller can read
562
+ # them the moment run() returns. Output to a file that could not be
563
+ # opened is reported as an error. The writer is stopped below.
564
+ from execsql.utils.fileio import filewriter_close_all_after_write
557
565
 
558
- # ------------------------------------------------------------------
559
- # Collect results
560
- # ------------------------------------------------------------------
561
- final_vars = {}
562
- if ctx.subvars is not None:
563
- for name, value in ctx.subvars.substitutions:
564
- # Include user vars and $-prefixed system vars
565
- # Skip environment (&), column (@), local (~), parameter (#) vars
566
- if not name or name[0] in ("&", "@", "~", "#"):
567
- continue
568
- key = name.lstrip("$")
569
- final_vars[key] = str(value) if value is not None else ""
570
-
571
- # Close connection if we own it
572
- if owns_connection:
573
566
  try:
574
- assert ctx.dbs is not None
575
- ctx.dbs.closeall()
567
+ filewriter_close_all_after_write()
568
+ except ErrInfo as exc:
569
+ errors.append(ScriptError(message=exc.errmsg(), source="<output>"))
576
570
  except Exception:
577
- pass
571
+ pass # Best-effort: a failed flush must not mask a script error.
572
+
573
+ # ------------------------------------------------------------------
574
+ # Collect results
575
+ # ------------------------------------------------------------------
576
+ final_vars = {}
577
+ if ctx.subvars is not None:
578
+ for name, value in ctx.subvars.substitutions:
579
+ # Include user vars and $-prefixed system vars
580
+ # Skip environment (&), column (@), local (~), parameter (#) vars
581
+ if not name or name[0] in ("&", "@", "~", "#"):
582
+ continue
583
+ key = name.lstrip("$")
584
+ final_vars[key] = str(value) if value is not None else ""
585
+ finally:
586
+ if ctx.filewriter is not None:
587
+ from execsql.utils.fileio import filewriter_end
588
+
589
+ filewriter_end(ctx.filewriter)
590
+ if owns_connection and ctx.dbs is not None:
591
+ try:
592
+ ctx.dbs.closeall()
593
+ except Exception:
594
+ pass
595
+ if ctx.tempfiles is not None:
596
+ ctx.tempfiles.remove_all()
578
597
 
579
598
  return ScriptResult(
580
599
  success=len(errors) == 0,
@@ -588,80 +607,70 @@ def run(
588
607
  # ---------------------------------------------------------------------------
589
608
  # Helpers
590
609
  # ---------------------------------------------------------------------------
591
- def _ensure_filewriter(conf: Any) -> bool:
592
- """Start the FileWriter subprocess if one is not already running.
610
+ def _start_filewriter(ctx: RuntimeContext, conf: Any) -> None:
611
+ """Give this run its own FileWriter thread, held on *ctx*.
612
+
613
+ ``WRITE ... TO <file>`` and ``TEE`` hand their output to the active run's
614
+ writer, and every entry point in :mod:`execsql.utils.fileio` drops the
615
+ write when none is running. A writer per run keeps concurrent runs from
616
+ sharing open files or reply queues, and a halt in one run (which stops
617
+ its writer) from discarding another run's output. :func:`run` stops the
618
+ writer before it returns.
619
+ """
620
+ from execsql.utils.fileio import FileWriter
593
621
 
594
- ``WRITE ... TO <file>`` and ``TEE`` hand their output to a FileWriter
595
- subprocess, and every entry point in :mod:`execsql.utils.fileio` guards on
596
- the subprocess being alive — dropping the write when it is not, so that a
597
- dead writer cannot deadlock the caller on a full pipe. The CLI starts one
598
- during startup; :func:`run` did not, so every file write under the API was
599
- silently discarded and the run still reported success.
622
+ ctx.filewriter = FileWriter(
623
+ file_encoding=conf.output_encoding,
624
+ open_timeout=getattr(conf, "outfile_open_timeout", 10),
625
+ )
626
+ ctx.filewriter.start()
600
627
 
601
- A writer started here outlives the call and is reused by a later
602
- :func:`run`, exactly as the CLI keeps one for the life of the process; it
603
- is reaped by an ``atexit`` handler. A writer the caller started themselves
604
- is left alone.
605
628
 
606
- Returns:
607
- ``True`` if this call started the writer, ``False`` if one was already
608
- running.
609
- """
610
- import execsql.state as _state
611
- import execsql.utils.fileio as _fileio
612
- from execsql.utils.fileio import FileWriter, filewriter_end
613
-
614
- if _fileio.filewriter is not None and _fileio.filewriter.is_alive():
615
- return False
616
-
617
- # Drain stale messages so a previously-dead subprocess cannot leak
618
- # responses or unconsumed commands into the new one. On macOS (`spawn`)
619
- # the OS pipe buffer is small enough that retained entries would deadlock
620
- # the next put().
621
- for q in (_fileio.fw_input, _fileio.fw_output):
622
- try:
623
- while True:
624
- q.get_nowait()
625
- except Exception:
626
- pass
629
+ def _capture_errors(ctx: RuntimeContext, errors: list[ScriptError], exit_code: Any) -> None:
630
+ """Record the error that halted the run, after ``exit_now()`` raised ``SystemExit``.
627
631
 
628
- try:
629
- _fileio.filewriter = _state.filewriter = FileWriter(
630
- _fileio.fw_input,
631
- _fileio.fw_output,
632
- file_encoding=conf.output_encoding,
633
- open_timeout=getattr(conf, "outfile_open_timeout", 10),
634
- )
635
- _state.filewriter.start()
636
- except Exception:
637
- # Under the `spawn` start method (macOS, Windows) the subprocess
638
- # re-imports the parent's __main__, which fails from a REPL, a
639
- # notebook, or `python -c`. Leave the writer unset: fileio's guards
640
- # then warn on the first dropped write rather than deadlocking.
641
- _fileio.filewriter = _state.filewriter = None
642
- return False
643
- atexit.register(filewriter_end)
644
- return True
645
-
646
-
647
- def _capture_errors(ctx: RuntimeContext, errors: list[ScriptError]) -> None:
648
- """Extract error info from the current context after a SystemExit."""
649
- last_error = None
650
- error_msg = None
651
- if ctx.subvars is not None:
652
- subs = dict(ctx.subvars.substitutions)
653
- last_error = subs.get("$LAST_ERROR")
654
- error_msg = subs.get("$ERROR_MESSAGE")
655
-
656
- msg = error_msg or last_error or "Script execution failed"
632
+ ``exit_now`` leaves the halting :class:`ErrInfo` on the context, and any
633
+ file output lost at the halt as a second error. A failed
634
+ SQL statement's text is in ``$LAST_ERROR``, which the executor sets just
635
+ before halting; it is reported only when the halt came from SQL, so a
636
+ metacommand error is never paired with an earlier statement. A ``HALT``
637
+ carries no error, only its exit status.
638
+ """
639
+ err = ctx.halt_error
640
+ if err is None:
641
+ msg = f"Script halted by HALT with exit status {exit_code}."
642
+ else:
643
+ msg = err.errmsg()
644
+ sql = None
645
+ lc = ctx.last_command
646
+ if err is not None and lc is not None and lc.command_type == "sql" and ctx.subvars is not None:
647
+ sql = ctx.subvars.varvalue("$LAST_ERROR") or None
657
648
  errors.append(
658
649
  ScriptError(
659
- message=str(msg),
650
+ message=msg,
660
651
  source=_last_source(ctx),
661
652
  line=_last_line(ctx),
662
- sql=str(last_error) if last_error else None,
653
+ sql=sql,
663
654
  ),
664
655
  )
656
+ if ctx.halt_output_error is not None:
657
+ errors.append(ScriptError(message=ctx.halt_output_error.errmsg(), source="<output>"))
658
+
659
+
660
+ def _failed(message: str, source: str) -> ScriptResult:
661
+ """A result for a run that failed before any of the script ran."""
662
+ return ScriptResult(
663
+ success=False,
664
+ commands_run=0,
665
+ elapsed=0.0,
666
+ errors=[ScriptError(message=message, source=source)],
667
+ variables={},
668
+ )
669
+
670
+
671
+ def _error_text(exc: BaseException) -> str:
672
+ """The message of *exc*: the full text for an :class:`ErrInfo`, else ``str()``."""
673
+ return exc.errmsg() if isinstance(exc, ErrInfo) else str(exc)
665
674
 
666
675
 
667
676
  def _last_source(ctx: RuntimeContext) -> str:
execsql/cli/__init__.py CHANGED
@@ -26,6 +26,7 @@ from execsql.cli.commands.run import main
26
26
  from execsql.cli.dsn import _parse_connection_string, _SCHEME_TO_DBTYPE # noqa: F401 — re-export
27
27
  from execsql.cli.help import _console, _err_console, _init_config, _print_encodings, _print_metacommands # noqa: F401 — re-export
28
28
  from execsql.cli.run import _connect_initial_db, _run # noqa: F401 — re-export
29
+ from execsql.cli.run import _end_run_on_sigterm
29
30
  from execsql.exceptions import ConfigError, ConfigFileError, ErrInfo
30
31
 
31
32
  __all__ = [
@@ -51,6 +52,7 @@ __all__ = [
51
52
 
52
53
  def _legacy_main() -> None:
53
54
  """Entry point that wraps the Typer app for use as a console_scripts target."""
55
+ _end_run_on_sigterm()
54
56
  try:
55
57
  app()
56
58
  except SystemExit as exc:
@@ -68,8 +68,7 @@ def lint_paths(
68
68
  _issue,
69
69
  exit_code,
70
70
  filter_issues,
71
- lint as _lint_script,
72
- parse_error,
71
+ lint_source,
73
72
  print_concise,
74
73
  render_github,
75
74
  print_statistics,
@@ -77,8 +76,6 @@ def lint_paths(
77
76
  render_json,
78
77
  rule_counts,
79
78
  )
80
- from execsql.exceptions import ErrInfo
81
- from execsql.script.parser import parse_string
82
79
 
83
80
  paths = sql_files(targets)
84
81
  if not paths:
@@ -96,12 +93,7 @@ def lint_paths(
96
93
  except OSError as exc:
97
94
  found = [_issue("P001", label, 0, f"cannot read: {exc.strerror or exc}")]
98
95
  else:
99
- try:
100
- tree = parse_string(source, label)
101
- except ErrInfo as exc:
102
- found = [parse_error(label, exc)]
103
- else:
104
- found = _lint_script(tree, script_path=None if stdin else label)
96
+ found = lint_source(source, label, script_path=None if stdin else label)
105
97
  per_file.append((label, sorted(filter_issues(found, select, ignore), key=lambda i: (i.line, i.code))))
106
98
 
107
99
  reported = [issue for _, issues in per_file for issue in issues]
@@ -58,7 +58,7 @@ def main(
58
58
  False,
59
59
  "-n",
60
60
  "--new-db",
61
- help="Create a new SQLite or Postgres database if it does not exist.",
61
+ help="Create a new SQLite, DuckDB or PostgreSQL database if it does not exist.",
62
62
  ),
63
63
  # -- Encoding ----------------------------------------------------------
64
64
  database_encoding: str | None = typer.Option(
@@ -93,21 +93,21 @@ def main(
93
93
  "-s",
94
94
  "--scan-lines",
95
95
  metavar="N",
96
- help="Lines to scan for IMPORT format detection. 0 = scan entire file.",
96
+ help="Lines to scan for IMPORT format detection. Default: 100; 0 = scan entire file.",
97
97
  ),
98
98
  boolean_int: str | None = typer.Option(
99
99
  None,
100
100
  "-b",
101
101
  "--boolean-int",
102
102
  metavar="{0,1,t,f,y,n}",
103
- help="Treat integers 0 and 1 as boolean values.",
103
+ help="Treat columns of only 0 and 1 as Boolean (y, default) or integer (n).",
104
104
  ),
105
105
  make_dirs: str | None = typer.Option(
106
106
  None,
107
107
  "-d",
108
108
  "--directories",
109
109
  metavar="{0,1,t,f,y,n}",
110
- help="Auto-create directories for EXPORT metacommand. n=no (default), y=yes",
110
+ help="Create missing output directories for EXPORT and WRITE. n=no (default), y=yes",
111
111
  ),
112
112
  output_dir: str | None = typer.Option(
113
113
  None,
@@ -189,7 +189,7 @@ def main(
189
189
  metavar="{0,1,2,3}",
190
190
  help=(
191
191
  "GUI level: 0=none (default), 1=GUI for password/pause, "
192
- "2=GUI for password/pause + DB selection, 3=full GUI console."
192
+ "2=also HALT messages and initial DB selection, 3=full GUI console."
193
193
  ),
194
194
  ),
195
195
  gui_framework: str | None = typer.Option(
@@ -230,7 +230,7 @@ def main(
230
230
  False,
231
231
  "-l",
232
232
  "--user-logfile",
233
- help="Write a log file to ~/execsql.log.",
233
+ help="Write the log to ~/execsql.log instead of ./execsql.log.",
234
234
  ),
235
235
  # -- Hidden aliases of `execsql list ...`; -m and -y are upstream flags.
236
236
  metacommands: bool = typer.Option(False, "-m", "--metacommands", hidden=True), # list metacommands
@@ -317,28 +317,10 @@ def main(
317
317
  _err_console.print(f"[bold red]Error:[/bold red] Cannot write --manifest {manifest_path}: {problem}")
318
318
  raise typer.Exit(code=2)
319
319
 
320
- positional = args or []
321
- if command is not None:
322
- script_name = None # inline mode — no script file
323
- elif ping:
324
- # --ping does not require a script file; positional args are still
325
- # available for server/db arguments if --dsn is not used.
326
- script_name = None
327
- else:
328
- if not positional:
329
- _err_console.print(
330
- "[bold red]Error:[/bold red] No SQL script file specified. Use -c to run an inline script.",
331
- )
332
- raise typer.Exit(code=1)
333
- script_name = positional[0]
334
- if not Path(script_name).exists():
335
- _err_console.print(
336
- f'[bold red]Error:[/bold red] SQL script file "{script_name}" does not exist.',
337
- )
338
- raise typer.Exit(code=1)
339
-
340
320
  # ------------------------------------------------------------------
341
- # Validate positional args and db_type choice
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.
342
324
  # ------------------------------------------------------------------
343
325
 
344
326
  if db_type and db_type not in ("a", "d", "p", "s", "l", "m", "k", "o", "f"):
@@ -365,6 +347,32 @@ def main(
365
347
  )
366
348
  raise typer.Exit(code=2)
367
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
+
368
376
  # ------------------------------------------------------------------
369
377
  # Parse tree: parse script into AST and print tree structure
370
378
  # ------------------------------------------------------------------
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("//"):