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
execsql/cli/options.py ADDED
@@ -0,0 +1,108 @@
1
+ """Options shared by more than one command, each defined once.
2
+
3
+ A command declares a shared option by annotating a parameter with one of the
4
+ types below, so ``run`` and ``ping`` cannot drift apart on a flag's spelling,
5
+ metavar or help text. Options that belong to a single command stay in that
6
+ command's module.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from enum import Enum
12
+ from typing import Annotated
13
+
14
+ import typer
15
+
16
+ __all__ = [
17
+ "ConfigFileOpt",
18
+ "DbTypeOpt",
19
+ "DsnOpt",
20
+ "NoPasswdOpt",
21
+ "OutputFormat",
22
+ "OutputFormatOpt",
23
+ "PortOpt",
24
+ "ScriptEncodingOpt",
25
+ "UserOpt",
26
+ ]
27
+
28
+ # -- Connection (run, ping) ----------------------------------------------------
29
+
30
+ DbTypeOpt = Annotated[
31
+ str | None,
32
+ typer.Option(
33
+ "-t",
34
+ "--type",
35
+ metavar="{a,d,p,s,l,m,k,o,f}",
36
+ help=(
37
+ "Database type: a=MS-Access, p=PostgreSQL, "
38
+ "s=SQL Server, l=SQLite, m=MySQL/MariaDB, "
39
+ "k=DuckDB, o=Oracle, f=Firebird, "
40
+ "d=DSN."
41
+ ),
42
+ ),
43
+ ]
44
+
45
+ DsnOpt = Annotated[
46
+ str | None,
47
+ typer.Option(
48
+ "--dsn",
49
+ "--connection-string",
50
+ metavar="URL",
51
+ help=(
52
+ "Database connection URL, e.g. postgresql://user:pass@host:5432/db. "
53
+ "Supported schemes: postgresql (postgres), mysql (mariadb), mssql (sqlserver), oracle, firebird, sqlite, duckdb. "
54
+ "Overrides -t/-u/-p and positional server/db args."
55
+ ),
56
+ ),
57
+ ]
58
+
59
+ UserOpt = Annotated[str | None, typer.Option("-u", "--user", help="Database user name.")]
60
+
61
+ PortOpt = Annotated[int | None, typer.Option("-p", "--port", help="Database server port.")]
62
+
63
+ NoPasswdOpt = Annotated[
64
+ bool,
65
+ typer.Option("-w", "--no-passwd", help="Skip password prompt when user is specified."),
66
+ ]
67
+
68
+ # -- Configuration (run, ping, config) -----------------------------------------
69
+
70
+ ConfigFileOpt = Annotated[
71
+ str | None,
72
+ typer.Option(
73
+ "--config",
74
+ metavar="FILE",
75
+ help=(
76
+ "Path to an execsql configuration file. "
77
+ "Loaded after the implicit search paths so its values take precedence. "
78
+ "The file may chain additional configs via its [config] section."
79
+ ),
80
+ ),
81
+ ]
82
+
83
+ # -- Script files (run, format, lint) ------------------------------------------
84
+
85
+ ScriptEncodingOpt = Annotated[
86
+ str | None,
87
+ typer.Option(
88
+ "-f",
89
+ "--script-encoding",
90
+ metavar="NAME",
91
+ help="Character encoding of the script file. Default: [encoding] script from a config file, else UTF-8.",
92
+ ),
93
+ ]
94
+
95
+ # -- Machine-readable output (ping, config, list) ------------------------------
96
+
97
+
98
+ class OutputFormat(str, Enum):
99
+ """Output formats for commands that report data rather than check files."""
100
+
101
+ text = "text"
102
+ json = "json"
103
+
104
+
105
+ OutputFormatOpt = Annotated[
106
+ OutputFormat,
107
+ typer.Option("--output-format", help="text for people; json for tools."),
108
+ ]
execsql/cli/run.py CHANGED
@@ -33,7 +33,6 @@ __all__ = ["_connect_initial_db", "_ping_db", "_print_dry_run", "_print_profile"
33
33
 
34
34
  # Lint helper — imported lazily inside _run() to keep start-up cost low, but
35
35
  # re-exported here so that tests and callers can reach it via cli.run.
36
- from execsql.cli.lint import _print_lint_results # noqa: F401 — re-export (used by cli/__init__.py)
37
36
 
38
37
 
39
38
  # ---------------------------------------------------------------------------
@@ -289,6 +288,21 @@ def _load_config(
289
288
  return ConfigData(script_path, subvars, config_file=config_file)
290
289
 
291
290
 
291
+ def _tool_config(config_file: str | None) -> ConfigData:
292
+ """The config ``execsql format`` and ``execsql lint`` read.
293
+
294
+ Read once per invocation, from the system, user and working-directory
295
+ locations plus *config_file* — not per script, so a script directory's
296
+ own ``execsql.conf`` applies only when passed with ``--config``.
297
+ """
298
+ return _load_config(None, _seed_early_subvars(), config_file)
299
+
300
+
301
+ def _configured_script_encoding(conf: ConfigData) -> str | None:
302
+ """``[encoding] script`` from *conf*, or ``None`` when no config file sets it."""
303
+ return conf.script_encoding if "script_encoding" in conf.sources else None
304
+
305
+
292
306
  def _seed_script_subvars(subvars: SubVarSet, script_name: str | None) -> None:
293
307
  """Add substitution variables that depend on the script path."""
294
308
  from execsql.utils.errors import file_size_date
@@ -492,14 +506,16 @@ def _setup_logging(
492
506
  use_gui: str | None,
493
507
  no_passwd: bool,
494
508
  import_buffer: int | None,
509
+ named_vars: list[tuple[str, str]] | None = None,
495
510
  ) -> Logger:
496
- """Create the execution logger, log initial info, and seed ``$RUN_ID``."""
511
+ """Create the execution logger, log initial info, and seed ``$RUN_ID``, ``-a`` and ``--var`` values."""
497
512
  from execsql.utils.errors import file_size_date
498
513
 
499
514
  opts_dict = {
500
515
  k: v
501
516
  for k, v in {
502
- "sub_vars": sub_vars,
517
+ # Count only: -a values are opaque and often secrets.
518
+ "sub_vars": f"{len(sub_vars)} value(s)" if sub_vars else None,
503
519
  "boolean_int": boolean_int,
504
520
  "make_dirs": make_dirs,
505
521
  "database_encoding": database_encoding,
@@ -520,7 +536,7 @@ def _setup_logging(
520
536
  }
521
537
  logger = Logger(
522
538
  script_name or "<inline>",
523
- conf.db or "",
539
+ conf.db or conf.db_file or "",
524
540
  conf.server,
525
541
  opts_dict,
526
542
  conf.user_logfile,
@@ -537,6 +553,9 @@ def _setup_logging(
537
553
  )
538
554
 
539
555
  subvars.add_substitution("$RUN_ID", logger.run_id)
556
+ # Passwords from the DSN or config files, known before the first connection.
557
+ logger.add_redaction_value(getattr(conf, "db_password", None))
558
+ logger.add_redaction_value(getattr(conf, "smtp_password", None))
540
559
 
541
560
  if sub_vars:
542
561
  for n, repl in enumerate(sub_vars):
@@ -550,6 +569,13 @@ def _setup_logging(
550
569
  logger.log_status_info(
551
570
  f"Command-line substitution variable assignment: {var} set to {{***}}",
552
571
  )
572
+ # --var values: named, set after config files so they win over
573
+ # [variables]; a SUB in the script can still reassign them. Values are
574
+ # redacted for the same reason as -a's.
575
+ for name, value in named_vars or ():
576
+ subvars.add_substitution(name, value)
577
+ logger.add_redaction_value(value)
578
+ logger.log_status_info(f"Command-line substitution variable assignment: {name} set to {{***}}")
553
579
 
554
580
  return logger
555
581
 
@@ -587,12 +613,13 @@ def _run(
587
613
  profile: bool = False,
588
614
  profile_limit: int = 20,
589
615
  ping: bool = False,
590
- lint: bool = False,
591
616
  debug: bool = False,
592
617
  no_system_cmd: bool = False,
593
618
  no_rm_file: bool = False,
594
619
  no_serve: bool = False,
595
620
  config_file: str | None = None,
621
+ named_vars: list[tuple[str, str]] | None = None,
622
+ manifest_path: str | None = None,
596
623
  ) -> None:
597
624
  """Initialise state, connect to the database, load the script, and run it.
598
625
 
@@ -605,12 +632,6 @@ def _run(
605
632
  connection details (DBMS name, server version, and location), and calls
606
633
  :func:`_ping_db` which raises ``SystemExit(0)``. No script is loaded or
607
634
  executed. *script_name* and *command* may both be ``None`` in ping mode.
608
-
609
- When *lint* is ``True``, the script is parsed and statically analysed for
610
- structural issues (unmatched IF/ENDIF/LOOP/BATCH blocks, potentially
611
- undefined variables, missing INCLUDE files, empty scripts) without
612
- connecting to a database or executing anything. Exits with code 0 if no
613
- errors were found, or code 1 if errors were found.
614
635
  """
615
636
  import execsql.state as _state
616
637
 
@@ -698,6 +719,8 @@ def _run(
698
719
  if sub_vars:
699
720
  for n, repl in enumerate(sub_vars):
700
721
  _state.subvars.add_substitution(f"$ARG_{n + 1}", repl)
722
+ for name, value in named_vars or ():
723
+ _state.subvars.add_substitution(name, value)
701
724
  _ast_tree = _load_script(command, script_name, conf.script_encoding)
702
725
  _print_dry_run(_ast_tree)
703
726
  raise SystemExit(0)
@@ -713,27 +736,21 @@ def _run(
713
736
  _state.dbs.add("initial", db)
714
737
  _ping_db(db) # raises SystemExit
715
738
 
716
- import execsql.utils.fileio as _fileio
739
+ manifest = None
740
+ if manifest_path:
741
+ from execsql import manifest as _manifest
742
+
743
+ variables = [f"$ARG_{n + 1}" for n in range(len(sub_vars or ()))] + [name for name, _ in named_vars or ()]
744
+ manifest = _manifest.start(manifest_path, script_name, variables)
745
+ manifest.config_files = list(conf.files_read)
717
746
 
718
747
  if _state.filewriter is None or not _state.filewriter.is_alive():
719
- # Drain stale messages from the queues so a previously-dead subprocess
720
- # can't leak responses or unconsumed commands into the new one. On
721
- # macOS (`spawn`) the OS pipe buffer is small enough that retained
722
- # entries from a crashed writer would deadlock the next put().
723
- for q in (_fileio.fw_input, _fileio.fw_output):
724
- try:
725
- while True:
726
- q.get_nowait()
727
- except Exception:
728
- pass
729
- _fileio.filewriter = _state.filewriter = FileWriter(
730
- _fileio.fw_input,
731
- _fileio.fw_output,
748
+ _state.filewriter = FileWriter(
732
749
  file_encoding=conf.output_encoding,
733
750
  open_timeout=getattr(conf, "outfile_open_timeout", 10),
734
751
  )
735
752
  _state.filewriter.start()
736
- atexit.register(filewriter_end)
753
+ atexit.register(filewriter_end, _state.filewriter)
737
754
 
738
755
  # ------------------------------------------------------------------
739
756
  # Logging
@@ -758,6 +775,7 @@ def _run(
758
775
  use_gui=use_gui,
759
776
  no_passwd=no_passwd,
760
777
  import_buffer=import_buffer,
778
+ named_vars=named_vars,
761
779
  )
762
780
 
763
781
  # ------------------------------------------------------------------
@@ -765,11 +783,6 @@ def _run(
765
783
  # ------------------------------------------------------------------
766
784
  _ast_tree = _load_script(command, script_name, conf.script_encoding)
767
785
 
768
- # ------------------------------------------------------------------
769
- # NOTE: --lint is handled as an early exit in cli/__init__.py (AST
770
- # linter) before _run() is called. No lint code path here.
771
- # ------------------------------------------------------------------
772
-
773
786
  # ------------------------------------------------------------------
774
787
  # Start GUI console if requested
775
788
  # ------------------------------------------------------------------
@@ -794,6 +807,8 @@ def _run(
794
807
  _state.dbs.add("initial", db)
795
808
 
796
809
  _state.exec_log.log_db_connect(db)
810
+ if manifest is not None:
811
+ manifest.set_database(db)
797
812
  _state.subvars.add_substitution("$CURRENT_DBMS", db.type.dbms_id)
798
813
  _state.subvars.add_substitution("$CURRENT_DATABASE", db.name())
799
814
  _state.subvars.add_substitution("$DB_SERVER", db.server_name)
@@ -822,6 +837,8 @@ def _run(
822
837
 
823
838
  if _ast_tree is not None:
824
839
  _execute_script_ast(_ast_tree, conf, profile=profile, profile_limit=profile_limit)
840
+ if manifest is not None:
841
+ manifest.finish(0)
825
842
 
826
843
 
827
844
  # ---------------------------------------------------------------------------
@@ -829,6 +846,28 @@ def _run(
829
846
  # ---------------------------------------------------------------------------
830
847
 
831
848
 
849
+ def _end_run_on_sigterm() -> None:
850
+ """Make SIGTERM end the process through ``SystemExit`` (exit status 143).
851
+
852
+ Python's default SIGTERM action stops the process without running
853
+ ``atexit`` handlers, so the run's log (buffered until close) was lost,
854
+ the open transaction was not rolled back explicitly, and a running
855
+ ``SYSTEM_CMD`` was left behind. ``timeout``, ``docker stop``, systemd
856
+ and CI cancellation all send SIGTERM. Installed by the console entry
857
+ point only: ``execsql.run()`` leaves the host program's signals alone.
858
+ """
859
+ import signal
860
+
861
+ def _terminate(signum: int, _frame: Any) -> None:
862
+ import execsql.state as _state
863
+
864
+ if _state.exec_log is not None:
865
+ _state.exec_log.log_exit_terminated()
866
+ raise SystemExit(128 + signum)
867
+
868
+ signal.signal(signal.SIGTERM, _terminate)
869
+
870
+
832
871
  def _execute_script_ast(
833
872
  tree: Any,
834
873
  conf: ConfigData,
@@ -856,7 +895,8 @@ def _execute_script_ast(
856
895
  try:
857
896
  execute(tree)
858
897
  except SystemExit as exc:
859
- if gui_console_isrunning() and conf.gui_wait_on_exit:
898
+ terminated = _state.exec_log is not None and _state.exec_log.exit_type == "terminated"
899
+ if gui_console_isrunning() and conf.gui_wait_on_exit and not terminated:
860
900
  gui_console_wait_user(
861
901
  "Script complete; close the console window to exit execsql.",
862
902
  )
@@ -868,6 +908,9 @@ def _execute_script_ast(
868
908
  sys.exit(exc.code)
869
909
  except ConfigError:
870
910
  raise
911
+ except KeyboardInterrupt:
912
+ _state.exec_log.log_exit_interrupted()
913
+ raise
871
914
  except ErrInfo as exc:
872
915
  from execsql.utils.errors import exit_now
873
916
 
@@ -881,6 +924,7 @@ def _execute_script_ast(
881
924
  exit_now(1, ErrInfo("exception", exception_msg=msg))
882
925
 
883
926
  _state.dbs.do_rollback = False
927
+ _flush_output_files()
884
928
  if gui_console_isrunning() and conf.gui_wait_on_exit:
885
929
  gui_console_wait_user(
886
930
  "Script complete; close the console window to exit execsql.",
@@ -943,10 +987,30 @@ def _execute_script_textual_console(tree: Any, conf: ConfigData) -> None:
943
987
  exit_now(1, ErrInfo("exception", exception_msg=msg))
944
988
 
945
989
  _state.dbs.do_rollback = False
990
+ _flush_output_files()
946
991
  _state.exec_log.log_status_info(f"{_state.cmds_run} commands run")
947
992
  _state.exec_log.log_exit_end()
948
993
 
949
994
 
995
+ def _flush_output_files() -> None:
996
+ """Flush file output at the normal end of a script; lost output fails the run.
997
+
998
+ A WRITE/EXPORT target that stays locked past ``outfile_open_timeout`` has
999
+ its output discarded. That must not end in a successful exit status.
1000
+ """
1001
+ from execsql.utils.fileio import filewriter_close_all_after_write
1002
+
1003
+ try:
1004
+ filewriter_close_all_after_write()
1005
+ except ErrInfo as exc:
1006
+ from execsql.utils.errors import exit_now
1007
+
1008
+ # The loss belongs to no particular statement; keep exit_now from
1009
+ # attributing it to whatever line happened to run last.
1010
+ exc.script_file = ""
1011
+ exit_now(1, exc)
1012
+
1013
+
950
1014
  # ---------------------------------------------------------------------------
951
1015
  # Utility: build the database connection for the initial/default database
952
1016
  # ---------------------------------------------------------------------------