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/cli/run.py CHANGED
@@ -514,7 +514,8 @@ def _setup_logging(
514
514
  opts_dict = {
515
515
  k: v
516
516
  for k, v in {
517
- "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,
518
519
  "boolean_int": boolean_int,
519
520
  "make_dirs": make_dirs,
520
521
  "database_encoding": database_encoding,
@@ -535,7 +536,7 @@ def _setup_logging(
535
536
  }
536
537
  logger = Logger(
537
538
  script_name or "<inline>",
538
- conf.db or "",
539
+ conf.db or conf.db_file or "",
539
540
  conf.server,
540
541
  opts_dict,
541
542
  conf.user_logfile,
@@ -552,6 +553,9 @@ def _setup_logging(
552
553
  )
553
554
 
554
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))
555
559
 
556
560
  if sub_vars:
557
561
  for n, repl in enumerate(sub_vars):
@@ -740,27 +744,13 @@ def _run(
740
744
  manifest = _manifest.start(manifest_path, script_name, variables)
741
745
  manifest.config_files = list(conf.files_read)
742
746
 
743
- import execsql.utils.fileio as _fileio
744
-
745
747
  if _state.filewriter is None or not _state.filewriter.is_alive():
746
- # Drain stale messages from the queues so a previously-dead subprocess
747
- # can't leak responses or unconsumed commands into the new one. On
748
- # macOS (`spawn`) the OS pipe buffer is small enough that retained
749
- # entries from a crashed writer would deadlock the next put().
750
- for q in (_fileio.fw_input, _fileio.fw_output):
751
- try:
752
- while True:
753
- q.get_nowait()
754
- except Exception:
755
- pass
756
- _fileio.filewriter = _state.filewriter = FileWriter(
757
- _fileio.fw_input,
758
- _fileio.fw_output,
748
+ _state.filewriter = FileWriter(
759
749
  file_encoding=conf.output_encoding,
760
750
  open_timeout=getattr(conf, "outfile_open_timeout", 10),
761
751
  )
762
752
  _state.filewriter.start()
763
- atexit.register(filewriter_end)
753
+ atexit.register(filewriter_end, _state.filewriter)
764
754
 
765
755
  # ------------------------------------------------------------------
766
756
  # Logging
@@ -856,6 +846,28 @@ def _run(
856
846
  # ---------------------------------------------------------------------------
857
847
 
858
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
+
859
871
  def _execute_script_ast(
860
872
  tree: Any,
861
873
  conf: ConfigData,
@@ -883,7 +895,8 @@ def _execute_script_ast(
883
895
  try:
884
896
  execute(tree)
885
897
  except SystemExit as exc:
886
- 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:
887
900
  gui_console_wait_user(
888
901
  "Script complete; close the console window to exit execsql.",
889
902
  )
@@ -895,6 +908,9 @@ def _execute_script_ast(
895
908
  sys.exit(exc.code)
896
909
  except ConfigError:
897
910
  raise
911
+ except KeyboardInterrupt:
912
+ _state.exec_log.log_exit_interrupted()
913
+ raise
898
914
  except ErrInfo as exc:
899
915
  from execsql.utils.errors import exit_now
900
916
 
@@ -908,6 +924,7 @@ def _execute_script_ast(
908
924
  exit_now(1, ErrInfo("exception", exception_msg=msg))
909
925
 
910
926
  _state.dbs.do_rollback = False
927
+ _flush_output_files()
911
928
  if gui_console_isrunning() and conf.gui_wait_on_exit:
912
929
  gui_console_wait_user(
913
930
  "Script complete; close the console window to exit execsql.",
@@ -970,10 +987,30 @@ def _execute_script_textual_console(tree: Any, conf: ConfigData) -> None:
970
987
  exit_now(1, ErrInfo("exception", exception_msg=msg))
971
988
 
972
989
  _state.dbs.do_rollback = False
990
+ _flush_output_files()
973
991
  _state.exec_log.log_status_info(f"{_state.cmds_run} commands run")
974
992
  _state.exec_log.log_exit_end()
975
993
 
976
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
+
977
1014
  # ---------------------------------------------------------------------------
978
1015
  # Utility: build the database connection for the initial/default database
979
1016
  # ---------------------------------------------------------------------------
execsql/config.py CHANGED
@@ -372,6 +372,8 @@ class ConfigData:
372
372
  self._get_str(cp, self._EMAIL_SECTION, "password", "smtp_password")
373
373
  self._get_bool(cp, self._EMAIL_SECTION, "use_ssl", "smtp_ssl")
374
374
  self._get_bool(cp, self._EMAIL_SECTION, "use_tls", "smtp_tls")
375
+ self._get_bool(cp, self._EMAIL_SECTION, "verify_certificate", "smtp_verify_certificate")
376
+ self._get_str(cp, self._EMAIL_SECTION, "ca_file", "smtp_ca_file")
375
377
  self._get_str(cp, self._EMAIL_SECTION, "message_css", "email_css")
376
378
  # --- [format] and [lint]: read by `execsql format` / `execsql lint`; a run ignores them ---
377
379
  self._get_int(cp, self._FORMAT_SECTION, "indent", "format_indent")
@@ -523,6 +525,8 @@ class ConfigData:
523
525
  self.smtp_password: str | None = None
524
526
  self.smtp_ssl = False
525
527
  self.smtp_tls = False
528
+ self.smtp_verify_certificate = True
529
+ self.smtp_ca_file: str | None = None
526
530
  self.email_format = "plain"
527
531
  self.email_css: str | None = None
528
532
  self.include_req: list = []
@@ -727,13 +731,12 @@ class WriteHooks:
727
731
  """
728
732
 
729
733
  def __repr__(self) -> str:
730
- return f"WriteHooks({self.write_func!r}, {self.err_func!r}, {self.status_func!r})"
734
+ return f"WriteHooks({self.write_func!r}, {self.err_func!r})"
731
735
 
732
736
  def __init__(
733
737
  self,
734
738
  standard_output_func: Callable[[str], object] | None = None,
735
739
  error_output_func: Callable[[str], object] | None = None,
736
- status_output_func: Callable[[str], object] | None = None,
737
740
  ) -> None:
738
741
  """Store optional hook callables; ``None`` means use the default stream.
739
742
 
@@ -742,8 +745,6 @@ class WriteHooks:
742
745
  ``None`` to use ``sys.stdout``.
743
746
  error_output_func: Callable to receive error-output text, or
744
747
  ``None`` to use ``sys.stderr``.
745
- status_output_func: Callable to receive status-line text, or
746
- ``None`` to suppress.
747
748
  """
748
749
  # Arguments should be functions that take a single string and
749
750
  # write it to the desired destination. Both stdout and stderr can be hooked.
@@ -752,7 +753,6 @@ class WriteHooks:
752
753
  # The purpose is to allow writing to be redirected to a GUI.
753
754
  self.write_func = standard_output_func
754
755
  self.err_func = error_output_func
755
- self.status_func = status_output_func
756
756
  self.tee_stderr = True
757
757
 
758
758
  def reset(self) -> None:
@@ -770,15 +770,6 @@ class WriteHooks:
770
770
  self.err_func = error_output_func
771
771
  self.tee_stderr = tee
772
772
 
773
- def redir(
774
- self,
775
- standard_output_func: Callable[[str], object],
776
- error_output_func: Callable[[str], object],
777
- ) -> None:
778
- """Redirect both stdout and stderr hooks in one call."""
779
- self.redir_stdout(standard_output_func)
780
- self.redir_stderr(error_output_func)
781
-
782
773
  def write(self, strval: str) -> None:
783
774
  """Write a string to the standard-output hook, or to sys.stdout if unset."""
784
775
  if self.write_func:
@@ -799,8 +790,3 @@ class WriteHooks:
799
790
  else:
800
791
  sys.stderr.write(strval)
801
792
  sys.stderr.flush()
802
-
803
- def write_status(self, strval: str) -> None:
804
- """Forward a status string to the status hook if one is registered."""
805
- if self.status_func:
806
- self.status_func(strval)
@@ -46,7 +46,7 @@
46
46
  #password_prompt=Yes
47
47
 
48
48
  # Whether to use the OS credential store (keyring) for password storage.
49
- # Requires: pip install execsql2[auth]
49
+ # Requires the auth extra: uv tool install "execsql2[auth]"
50
50
  # Values: Yes or No. Default: Yes.
51
51
  #use_keyring=Yes
52
52
 
@@ -71,7 +71,8 @@
71
71
  [input]
72
72
  # Settings for handling imported data.
73
73
 
74
- # Whether to convert numeric values to double precision when using MS Access.
74
+ # MS Access: create columns for decimal data as NUMERIC(precision,scale)
75
+ # (Yes) or as DOUBLE (No) when IMPORT or COPY creates a table.
75
76
  # Values: Yes or No. Default: No.
76
77
  #access_use_numeric=No
77
78
 
@@ -261,6 +262,15 @@
261
262
  # Values: Yes or No. Default: No.
262
263
  #use_tls=No
263
264
 
265
+ # Whether to verify the SMTP server's certificate when use_ssl or use_tls is on.
266
+ # Turn off only for a test relay; prefer ca_file for an internal certificate.
267
+ # Values: Yes or No. Default: Yes.
268
+ #verify_certificate=Yes
269
+
270
+ # CA certificate file (PEM) to trust, for a server with an internal or
271
+ # self-signed certificate. Default: the system's trusted CAs.
272
+ #ca_file=
273
+
264
274
  # Email body format.
265
275
  # Values: plain or html. Default: plain.
266
276
  #email_format=plain
@@ -297,8 +307,8 @@
297
307
  # Values: Yes or No. Default: Yes.
298
308
  #allow_rm_file=Yes
299
309
 
300
- # Whether to allow the SERVE metacommand (which opens an HTTP server on
301
- # a local port to serve a single file). Set to No to disable.
310
+ # Whether to allow the SERVE metacommand (which copies a file to stdout
311
+ # with HTTP headers, for CGI use). Set to No to disable.
302
312
  # Also controllable via --no-serve CLI flag.
303
313
  # Values: Yes or No. Default: Yes.
304
314
  #allow_serve=Yes
execsql/db/access.py CHANGED
@@ -18,7 +18,14 @@ from execsql.db.base import Database, _fetched
18
18
  from execsql.db.tiers import SupportTier
19
19
  from execsql.exceptions import ErrInfo
20
20
  from execsql.utils.errors import exception_desc, fatal_error
21
- from execsql.utils.auth import clear_stored_password, get_password, password_from_keyring
21
+ from execsql.utils.auth import (
22
+ begin_login,
23
+ clear_stored_password,
24
+ get_password,
25
+ is_login_failure,
26
+ password_from_keyring,
27
+ remember_password,
28
+ )
22
29
  import execsql.state as _state
23
30
 
24
31
  __all__ = ["AccessDatabase"]
@@ -76,7 +83,6 @@ class AccessDatabase(Database):
76
83
  self.password = password
77
84
  # Encoding is only applicable to Jet < 4.0: non-accdb databases.
78
85
  self.encoding = encoding or "windows-1252"
79
- self.encode_commands = True
80
86
  self.dao_conn: Any = None
81
87
  self.conn = None # ODBC connection
82
88
  self.paramstr = "?"
@@ -104,9 +110,12 @@ class AccessDatabase(Database):
104
110
  if self.conn is not None:
105
111
  self.conn.close()
106
112
  self.conn = None
113
+ begin_login()
107
114
  if self.need_passwd and self.user and self.password is None:
108
115
  self.password = get_password("MS-Access", self.db_name, self.user)
109
116
 
117
+ errors: list[BaseException] = []
118
+
110
119
  def _try_odbc_drivers():
111
120
  db_name = str(Path(self.db_name).resolve())
112
121
  for cs, jet4flag in self.connection_strings:
@@ -120,7 +129,8 @@ class AccessDatabase(Database):
120
129
  # the connect attempt at 30 s to match the other
121
130
  # adapters.
122
131
  self.conn = pyodbc.connect(connstr, timeout=30)
123
- except Exception:
132
+ except Exception as e:
133
+ errors.append(e)
124
134
  if _state.exec_log is not None:
125
135
  _state.exec_log.log_status_info(
126
136
  f"Could not connect via ODBC using: {re.sub(r'Pwd=[^;]*', 'Pwd=***', connstr)}",
@@ -134,7 +144,8 @@ class AccessDatabase(Database):
134
144
  return True
135
145
  return False
136
146
 
137
- if not _try_odbc_drivers() and password_from_keyring():
147
+ # Only a rejected password means the stored one is stale.
148
+ if not _try_odbc_drivers() and password_from_keyring() and any(is_login_failure(e) for e in errors):
138
149
  clear_stored_password("MS-Access", self.db_name, self.user)
139
150
  self.password = get_password(
140
151
  "MS-Access",
@@ -144,6 +155,8 @@ class AccessDatabase(Database):
144
155
  other_msg="(stored credential failed — enter current password)",
145
156
  )
146
157
  _try_odbc_drivers()
158
+ if self.conn and self.user:
159
+ remember_password("MS-Access", self.db_name, self.user)
147
160
 
148
161
  if not self.conn:
149
162
  raise ErrInfo(
@@ -158,10 +171,13 @@ class AccessDatabase(Database):
158
171
  if self.dao_conn is not None:
159
172
  self.dao_conn.Close()
160
173
  self.dao_conn = None
174
+ begin_login()
161
175
  if self.need_passwd and self.user and self.password is None:
162
176
  self.password = get_password("MS-Access", self.db_name, self.user)
163
177
  dao_engines = ("DAO.DBEngine.120", "DAO.DBEngine.36")
164
178
 
179
+ dao_errors: list[BaseException] = []
180
+
165
181
  def _try_dao_engines():
166
182
  for engine in dao_engines:
167
183
  try:
@@ -175,7 +191,8 @@ class AccessDatabase(Database):
175
191
  )
176
192
  else:
177
193
  self.dao_conn = daoEngine.OpenDatabase(self.db_name)
178
- except Exception:
194
+ except Exception as e:
195
+ dao_errors.append(e)
179
196
  if _state.exec_log is not None:
180
197
  _state.exec_log.log_status_info(f"Could not connect via DAO using: {engine}")
181
198
  else:
@@ -184,7 +201,7 @@ class AccessDatabase(Database):
184
201
  return True
185
202
  return False
186
203
 
187
- if not _try_dao_engines() and password_from_keyring():
204
+ if not _try_dao_engines() and password_from_keyring() and any(is_login_failure(e) for e in dao_errors):
188
205
  clear_stored_password("MS-Access", self.db_name, self.user)
189
206
  self.password = get_password(
190
207
  "MS-Access",
@@ -194,6 +211,8 @@ class AccessDatabase(Database):
194
211
  other_msg="(stored credential failed — enter current password)",
195
212
  )
196
213
  _try_dao_engines()
214
+ if self.dao_conn and self.user:
215
+ remember_password("MS-Access", self.db_name, self.user)
197
216
 
198
217
  if not self.dao_conn:
199
218
  raise ErrInfo(
@@ -339,6 +358,9 @@ class AccessDatabase(Database):
339
358
 
340
359
  return headers, iter(dict_row, None)
341
360
 
361
+ # table_exists() without a schema looks only where CREATE TABLE puts a table: one namespace.
362
+ unqualified_lookup_is_scoped = True
363
+
342
364
  def table_exists(self, table_name: str, schema_name: str | None = None) -> bool:
343
365
  """Return True if the named table exists in the Access database.
344
366
 
@@ -418,9 +440,8 @@ class AccessDatabase(Database):
418
440
  return False
419
441
 
420
442
  def drop_table(self, tablename: str) -> None:
421
- """Drop the named table from the Access database."""
443
+ """Drop *tablename*, quoted by the caller."""
422
444
  self.dao_flush_check()
423
- tablename = self.type.quoted(tablename)
424
445
  self.execute(f"drop table {tablename};")
425
446
 
426
447
  def as_datetime(self, val: Any) -> datetime.datetime | datetime.date | datetime.time | None:
execsql/db/base.py CHANGED
@@ -98,7 +98,6 @@ class Database(ABC):
98
98
  password: str | None
99
99
  port: int | None
100
100
  encoding: Any
101
- encode_commands: bool
102
101
  paramstr: str
103
102
  conn: Any
104
103
  autocommit: bool
@@ -143,7 +142,6 @@ class Database(ABC):
143
142
  self.password: str | None = None
144
143
  self.port = port
145
144
  self.encoding = encoding
146
- self.encode_commands = True
147
145
  self.paramstr = "?"
148
146
  self.conn = None
149
147
  self.autocommit = True
@@ -494,6 +492,21 @@ class Database(ABC):
494
492
  rows = curs.fetchall()
495
493
  return len(rows) > 0
496
494
 
495
+ #: Whether :meth:`table_exists` without a schema already looks only where
496
+ #: an unqualified ``CREATE TABLE`` puts a table (one namespace, or the
497
+ #: search path). When not, the check is narrowed to :meth:`current_schema`.
498
+ unqualified_lookup_is_scoped = False
499
+
500
+ def current_schema(self) -> str | None:
501
+ """The schema (MySQL: database) an unqualified table name is created in, or ``None`` if unknown."""
502
+ return None
503
+
504
+ def _query_current_schema(self, sql: str) -> str | None:
505
+ with self._cursor() as curs:
506
+ curs.execute(sql)
507
+ row = curs.fetchone()
508
+ return str(row[0]) if row and row[0] else None
509
+
497
510
  def table_exists(self, table_name: str, schema_name: str | None = None) -> bool:
498
511
  """Return ``True`` if *table_name* (optionally in *schema_name*) exists."""
499
512
  with self._cursor() as curs:
@@ -665,6 +678,17 @@ class Database(ABC):
665
678
  eof = False
666
679
  total_rows = 0
667
680
  self.before_statement(sql)
681
+ # The rows go in as several batches and are committed once, by the
682
+ # caller. Most drivers hold every batch in the transaction they open
683
+ # themselves; DuckDB's commits each statement unless one is open, so a
684
+ # failure would leave the batches before it behind.
685
+ self.begin_transaction()
686
+ if self.driver_autocommits:
687
+ self.warn_once(
688
+ "import",
689
+ f"{self.name()} commits each statement as it runs, so an IMPORT or COPY that fails part-way "
690
+ "leaves the rows already loaded.",
691
+ )
668
692
 
669
693
  # Optional rich progress bar for long-running imports.
670
694
  use_progress = getattr(_state.conf, "show_progress", False)
@@ -739,6 +763,8 @@ class Database(ABC):
739
763
  )
740
764
  if not _state.conf.empty_strings and line[i].strip() == "":
741
765
  line[i] = None
766
+ # Trailing values left off the line are NULL.
767
+ line = list(line) + [None] * (len(ts_colnames) - len(line))
742
768
  lt = [
743
769
  type_objs[i].from_data(val) if val is not None else None for i, val in enumerate(line)
744
770
  ]
@@ -777,13 +803,19 @@ class Database(ABC):
777
803
  break
778
804
  return total_rows
779
805
 
780
- with self._cursor() as curs:
781
- if use_progress and progress_ctx is not None:
782
- with progress_ctx:
783
- task_id = progress_ctx.add_task(sq_name, total=None)
806
+ try:
807
+ with self._cursor() as curs:
808
+ if use_progress and progress_ctx is not None:
809
+ with progress_ctx:
810
+ task_id = progress_ctx.add_task(sq_name, total=None)
811
+ _import_loop(curs)
812
+ else:
784
813
  _import_loop(curs)
785
- else:
786
- _import_loop(curs)
814
+ except BaseException:
815
+ # A bad value or short row stops the load as surely as a failed
816
+ # insert does; either way none of it is kept.
817
+ self.rollback()
818
+ raise
787
819
 
788
820
  if _state.exec_log:
789
821
  _state.exec_log.log_status_info(
execsql/db/dsn.py CHANGED
@@ -13,7 +13,14 @@ from execsql.db.base import Database
13
13
  from execsql.db.tiers import SupportTier
14
14
  from execsql.exceptions import ErrInfo
15
15
  from execsql.utils.errors import exception_desc, fatal_error
16
- from execsql.utils.auth import clear_stored_password, get_password, password_from_keyring
16
+ from execsql.utils.auth import (
17
+ begin_login,
18
+ clear_stored_password,
19
+ get_password,
20
+ is_login_failure,
21
+ password_from_keyring,
22
+ remember_password,
23
+ )
17
24
  import execsql.state as _state
18
25
 
19
26
  __all__ = ["DsnDatabase"]
@@ -56,7 +63,6 @@ class DsnDatabase(Database):
56
63
  self.type = dbt_dsn
57
64
  self.password = password
58
65
  self.encoding = encoding
59
- self.encode_commands = True
60
66
  self.paramstr = "?"
61
67
  self.open_db()
62
68
  self.password = None # Clear cleartext password after successful connection
@@ -72,6 +78,7 @@ class DsnDatabase(Database):
72
78
  if self.conn is not None:
73
79
  self.conn.close()
74
80
  self.conn = None
81
+ begin_login()
75
82
  if self.need_passwd and self.user and self.password is None:
76
83
  self.password = get_password("DSN", self.db_name, self.user)
77
84
 
@@ -126,8 +133,9 @@ class DsnDatabase(Database):
126
133
 
127
134
  try:
128
135
  _try_connect()
129
- except ErrInfo:
130
- if not password_from_keyring():
136
+ except ErrInfo as e:
137
+ # Only a rejected password means the stored one is stale.
138
+ if not (password_from_keyring() and is_login_failure(e)):
131
139
  raise
132
140
  clear_stored_password("DSN", self.db_name, self.user)
133
141
  self.password = get_password(
@@ -139,6 +147,7 @@ class DsnDatabase(Database):
139
147
  )
140
148
  self.conn = None
141
149
  _try_connect()
150
+ remember_password("DSN", self.db_name, self.user or "")
142
151
 
143
152
  def exec_cmd(self, querycommand: str) -> None:
144
153
  """Execute a stored procedure by name."""
execsql/db/duckdb.py CHANGED
@@ -74,9 +74,11 @@ class DuckDBDatabase(Database):
74
74
  )
75
75
  self.type = dbt_duckdb
76
76
  self.catalog_name = Path(DuckDB_fn).stem
77
- self.encode_commands = False
78
77
  self.open_db()
79
78
 
79
+ def current_schema(self) -> str | None:
80
+ return self._query_current_schema("select current_schema()")
81
+
80
82
  def __repr__(self) -> str:
81
83
  return f"DuckDBDatabase({self.db_name!r})"
82
84
 
execsql/db/factory.py CHANGED
@@ -64,7 +64,16 @@ def db_Postgres(
64
64
  password: str | None = None,
65
65
  ) -> PostgresDatabase:
66
66
  """Open a new PostgreSQL connection via psycopg (psycopg3)."""
67
- return PostgresDatabase(server_name, database_name, user, pw_needed, port, new_db=new_db, password=password)
67
+ return PostgresDatabase(
68
+ server_name,
69
+ database_name,
70
+ user,
71
+ pw_needed,
72
+ port,
73
+ new_db=new_db,
74
+ encoding=encoding,
75
+ password=password,
76
+ )
68
77
 
69
78
 
70
79
  def db_SQLite(
@@ -143,7 +152,7 @@ def db_Oracle(
143
152
  encoding: str | None = None,
144
153
  password: str | None = None,
145
154
  ) -> OracleDatabase:
146
- """Open an Oracle database connection via cx_Oracle (python-oracledb)."""
155
+ """Open an Oracle database connection via python-oracledb."""
147
156
  return OracleDatabase(server_name, database_name, user, pw_needed, port, encoding, password=password)
148
157
 
149
158
 
execsql/db/firebird.py CHANGED
@@ -12,7 +12,14 @@ from execsql.db.base import Database
12
12
  from execsql.db.tiers import SupportTier
13
13
  from execsql.exceptions import ErrInfo
14
14
  from execsql.utils.errors import exception_desc, fatal_error
15
- from execsql.utils.auth import clear_stored_password, get_password, password_from_keyring
15
+ from execsql.utils.auth import (
16
+ begin_login,
17
+ clear_stored_password,
18
+ get_password,
19
+ is_login_failure,
20
+ password_from_keyring,
21
+ remember_password,
22
+ )
16
23
  import execsql.state as _state
17
24
 
18
25
  __all__ = ["FirebirdDatabase"]
@@ -54,7 +61,6 @@ class FirebirdDatabase(Database):
54
61
  self.type = dbt_firebird
55
62
  self.password = password
56
63
  self.encoding = encoding or "latin1"
57
- self.encode_commands = True
58
64
  self.paramstr = "?"
59
65
  self.open_db()
60
66
  self.password = None # Clear cleartext password after successful connection
@@ -100,28 +106,34 @@ class FirebirdDatabase(Database):
100
106
 
101
107
  if self.conn is None:
102
108
  try:
109
+ begin_login()
103
110
  if self.user and self.need_passwd and not self.password:
104
111
  self.password = get_password(
105
112
  "Firebird",
106
113
  self.db_name,
107
114
  self.user,
108
115
  server_name=self.server_name,
116
+ port=self.port,
109
117
  )
110
118
  try:
111
119
  self.conn = db_conn()
112
- except Exception:
113
- if not password_from_keyring():
120
+ except Exception as e:
121
+ # Only a rejected password means the stored one is stale;
122
+ # a timeout or a missing database leaves it alone.
123
+ if not (password_from_keyring() and is_login_failure(e)):
114
124
  raise
115
- clear_stored_password("Firebird", self.db_name, self.user, self.server_name)
125
+ clear_stored_password("Firebird", self.db_name, self.user, self.server_name, port=self.port)
116
126
  self.password = get_password(
117
127
  "Firebird",
118
128
  self.db_name,
119
129
  self.user,
120
130
  server_name=self.server_name,
131
+ port=self.port,
121
132
  skip_keyring=True,
122
133
  other_msg="(stored credential failed — enter current password)",
123
134
  )
124
135
  self.conn = db_conn()
136
+ remember_password("Firebird", self.db_name, self.user, self.server_name, port=self.port)
125
137
  except SystemExit:
126
138
  # If the user canceled the password prompt.
127
139
  raise
@@ -143,6 +155,9 @@ class FirebirdDatabase(Database):
143
155
  raise
144
156
  _state.subvars.add_substitution("$LAST_ROWCOUNT", curs.rowcount)
145
157
 
158
+ # table_exists() without a schema looks only where CREATE TABLE puts a table: one namespace.
159
+ unqualified_lookup_is_scoped = True
160
+
146
161
  def table_exists(self, table_name: str, schema_name: str | None = None) -> bool:
147
162
  """Return True if the named table exists in the Firebird database."""
148
163
  sql = (
@@ -243,8 +258,7 @@ class FirebirdDatabase(Database):
243
258
  return len(rows) > 0
244
259
 
245
260
  def drop_table(self, tablename: str) -> None:
246
- """Drop the named table from the Firebird database."""
261
+ """Drop *tablename*, schema-qualified and quoted by the caller."""
247
262
  # Firebird will thrown an error if there are foreign keys into the table.
248
- tablename = self.type.quoted(tablename)
249
263
  self.execute(f"DROP TABLE {tablename};")
250
264
  self.conn.commit()