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/manifest.py ADDED
@@ -0,0 +1,200 @@
1
+ """The run manifest: a JSON record of one ``execsql run``, for ``--manifest FILE``.
2
+
3
+ The manifest says what a run did — the script, the database, how many
4
+ statements ran, the files it read, wrote and deleted, the connections it
5
+ opened, and how it ended — in a form an orchestrator or an audit can read
6
+ without parsing the log. It is written when the run ends, whether it
7
+ succeeded, failed or was cancelled.
8
+
9
+ Files are recorded as the executor runs each metacommand, after variable
10
+ substitution, so paths are the ones actually used. What a metacommand touches
11
+ is decided by :mod:`execsql.metacommands.effects`. Variable values and passwords are never written:
12
+ the run log hides ``-a`` and ``--var`` values because they can be secrets, and
13
+ the manifest lists only their names.
14
+
15
+ One recorder is active per run; :func:`current` returns it, or ``None`` when
16
+ no manifest was asked for, so the executor's hooks cost one check. Writing the
17
+ manifest ends the recording, so a later run in the same process starts clean.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import atexit
23
+ import datetime
24
+ import json
25
+ import os
26
+ import sys
27
+ import tempfile
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from execsql import __version__
32
+
33
+ __all__ = ["RunManifest", "check_writable", "current", "start"]
34
+
35
+
36
+ def current() -> RunManifest | None:
37
+ """The active run's manifest recorder, or ``None`` when ``--manifest`` was not given.
38
+
39
+ Held on the run's :class:`~execsql.state.RuntimeContext`, so a library
40
+ ``execsql.run()`` in the same process never records into a CLI manifest.
41
+ """
42
+ import execsql.state as _state
43
+
44
+ return _state.manifest
45
+
46
+
47
+ def check_writable(path: str) -> str | None:
48
+ """Why a manifest cannot be written to *path*, or ``None`` if it can.
49
+
50
+ Checked before a run starts, so a bad ``--manifest`` path fails before any
51
+ SQL runs rather than after.
52
+ """
53
+ target = Path(path)
54
+ if target.is_dir():
55
+ return f"{path} is a directory"
56
+ try:
57
+ target.parent.mkdir(parents=True, exist_ok=True)
58
+ fd, probe = tempfile.mkstemp(dir=target.parent, prefix=f".{target.name}.", suffix=".tmp")
59
+ os.close(fd)
60
+ os.unlink(probe)
61
+ except OSError as exc:
62
+ return exc.strerror or str(exc)
63
+ return None
64
+
65
+
66
+ def start(path: str, script: str | None, variables: list[str]) -> RunManifest:
67
+ """Begin recording a run; the manifest is written to *path* when it ends.
68
+
69
+ If nothing else writes it (``exit_now`` on an error, the end of a run), an
70
+ ``atexit`` fallback does.
71
+ """
72
+ import execsql.state as _state
73
+
74
+ manifest = RunManifest(path, script, variables)
75
+ _state.manifest = manifest
76
+ atexit.register(manifest.finish, None)
77
+ return manifest
78
+
79
+
80
+ def _now() -> datetime.datetime:
81
+ return datetime.datetime.now(tz=datetime.timezone.utc)
82
+
83
+
84
+ class RunManifest:
85
+ """Collects what one run does and writes it as JSON when the run ends."""
86
+
87
+ def __init__(self, path: str, script: str | None, variables: list[str]) -> None:
88
+ self.path = Path(path)
89
+ self.script = script or "<inline>"
90
+ self.variables = variables
91
+ self.started = _now()
92
+ self.config_files: list[str] = []
93
+ self.database: dict[str, Any] | None = None
94
+ self.sql_statements = 0
95
+ self.metacommands = 0
96
+ self.reads: list[dict[str, Any]] = []
97
+ self.writes: list[dict[str, Any]] = []
98
+ self.deletes: list[dict[str, Any]] = []
99
+ self.connections: list[dict[str, Any]] = []
100
+ self.errors: list[dict[str, Any]] = []
101
+ self.written = False
102
+
103
+ # -- recording -------------------------------------------------------------
104
+
105
+ def set_database(self, db: Any) -> None:
106
+ """Record the initial connection: DBMS, server, database or file, user — no password."""
107
+ self.database = {
108
+ "dbms": db.type.dbms_id if getattr(db, "type", None) else None,
109
+ "server": getattr(db, "server_name", None),
110
+ "database": getattr(db, "db_name", None),
111
+ "user": getattr(db, "user", None),
112
+ }
113
+
114
+ def record_sql(self) -> None:
115
+ self.sql_statements += 1
116
+
117
+ def record_metacommand(self, command: str, source: str, line: int, metacommandlist: Any) -> None:
118
+ """Count a metacommand that ran, and record the files and connections it touched.
119
+
120
+ *command* is the text after substitution, so paths are the ones used.
121
+ """
122
+ from execsql.metacommands.effects import effects
123
+
124
+ self.metacommands += 1
125
+ hit = metacommandlist.get_match(command)
126
+ if hit is None:
127
+ return
128
+ mc, m = hit
129
+ by = mc.description or command.split(None, 1)[0].upper()
130
+ for effect in effects(mc.exec_fn.__name__, m.groupdict()):
131
+ entry = {"source": source, "line": line, "by": by}
132
+ if effect.role in ("connect", "use"):
133
+ self.connections.append(
134
+ {**entry, "by": effect.role.upper(), "target": effect.target, "detail": effect.detail},
135
+ )
136
+ else:
137
+ {"read": self.reads, "write": self.writes, "delete": self.deletes}[effect.role].append(
138
+ {**entry, "path": effect.target},
139
+ )
140
+
141
+ def record_include(self, path: str, source: str, line: int) -> None:
142
+ self.reads.append({"source": source, "line": line, "by": "INCLUDE", "path": path})
143
+
144
+ # -- finishing -------------------------------------------------------------
145
+
146
+ def add_error(self, error: Any) -> None:
147
+ """Record *error* (an :class:`ErrInfo`) in the manifest's ``errors``."""
148
+ # str(ErrInfo) is its concise message; errmsg() is the multi-line
149
+ # console block with a timestamp, which a reader of JSON does not want.
150
+ self.errors.append(
151
+ {
152
+ "message": " ".join(str(error).split()),
153
+ "type": getattr(error, "type", None),
154
+ "source": getattr(error, "script_file", None),
155
+ "line": getattr(error, "script_line_no", None),
156
+ "command": getattr(error, "cmd", None) or getattr(error, "command", None),
157
+ },
158
+ )
159
+
160
+ def finish(self, exit_status: int | None, error: Any = None) -> None:
161
+ """Write the manifest once. Later calls (the atexit fallback) do nothing."""
162
+ if self.written:
163
+ return
164
+ if error is not None:
165
+ self.add_error(error)
166
+ finished = _now()
167
+ data = {
168
+ "execsql_version": __version__,
169
+ "script": self.script,
170
+ "started": self.started.isoformat(timespec="seconds"),
171
+ "finished": finished.isoformat(timespec="seconds"),
172
+ "duration_s": round((finished - self.started).total_seconds(), 3),
173
+ "exit_status": exit_status,
174
+ "config_files": self.config_files,
175
+ "database": self.database,
176
+ "variables": self.variables,
177
+ "statements": {"sql": self.sql_statements, "metacommands": self.metacommands},
178
+ "files_read": self.reads,
179
+ "files_written": self.writes,
180
+ "files_deleted": self.deletes,
181
+ "connections": self.connections,
182
+ "errors": self.errors,
183
+ }
184
+ self.written = True
185
+ import execsql.state as _state
186
+
187
+ if _state.manifest is self:
188
+ _state.manifest = None
189
+ atexit.unregister(self.finish)
190
+ try:
191
+ self.path.parent.mkdir(parents=True, exist_ok=True)
192
+ # Write beside the target and rename, so a reader never sees half a file.
193
+ fd, tmp = tempfile.mkstemp(dir=self.path.parent, prefix=f".{self.path.name}.", suffix=".tmp")
194
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
195
+ json.dump(data, f, indent=2, default=str)
196
+ f.write("\n")
197
+ os.replace(tmp, self.path)
198
+ except OSError as exc:
199
+ # The run itself is over; report the lost manifest without a traceback.
200
+ sys.stderr.write(f"execsql: could not write --manifest {self.path}: {exc.strerror or exc}\n")
@@ -22,7 +22,7 @@ registration time for quoted, unquoted, and bracketed arguments):
22
22
  ``xf_row_count_eq``, ``xf_row_count_lt``.
23
23
  - Runtime state: ``xf_sqlerror``, ``xf_dialogcanceled``, ``xf_dbms``.
24
24
 
25
- The full list is also surfaced by ``execsql --dump-keywords``.
25
+ The full list is also surfaced by ``execsql list keywords``.
26
26
  """
27
27
 
28
28
  import os
@@ -100,12 +100,11 @@ def xf_hasrows(**kwargs: Any) -> bool:
100
100
  return bool(nrows > 0)
101
101
 
102
102
 
103
- def _row_count(queryname: str, sql_context: str, metacommandline: str) -> int:
103
+ def _row_count(queryname: str, metacommandline: str) -> int:
104
104
  """Return the number of rows in *queryname*, raising ErrInfo on failure.
105
105
 
106
106
  Args:
107
107
  queryname: Table or view name to count rows in.
108
- sql_context: The SQL string to include in error messages.
109
108
  metacommandline: The full metacommand line for error context.
110
109
 
111
110
  Returns:
@@ -167,7 +166,7 @@ def xf_row_count_gt(**kwargs: Any) -> bool:
167
166
  queryname = kwargs["queryname"]
168
167
  mcl = kwargs["metacommandline"]
169
168
  n = _parse_row_count_n(kwargs["n"], mcl)
170
- return _row_count(queryname, f"select count(*) from {queryname};", mcl) > n
169
+ return _row_count(queryname, mcl) > n
171
170
 
172
171
 
173
172
  def xf_row_count_gte(**kwargs: Any) -> bool:
@@ -183,7 +182,7 @@ def xf_row_count_gte(**kwargs: Any) -> bool:
183
182
  queryname = kwargs["queryname"]
184
183
  mcl = kwargs["metacommandline"]
185
184
  n = _parse_row_count_n(kwargs["n"], mcl)
186
- return _row_count(queryname, f"select count(*) from {queryname};", mcl) >= n
185
+ return _row_count(queryname, mcl) >= n
187
186
 
188
187
 
189
188
  def xf_row_count_eq(**kwargs: Any) -> bool:
@@ -199,7 +198,7 @@ def xf_row_count_eq(**kwargs: Any) -> bool:
199
198
  queryname = kwargs["queryname"]
200
199
  mcl = kwargs["metacommandline"]
201
200
  n = _parse_row_count_n(kwargs["n"], mcl)
202
- return _row_count(queryname, f"select count(*) from {queryname};", mcl) == n
201
+ return _row_count(queryname, mcl) == n
203
202
 
204
203
 
205
204
  def xf_row_count_lt(**kwargs: Any) -> bool:
@@ -215,7 +214,7 @@ def xf_row_count_lt(**kwargs: Any) -> bool:
215
214
  queryname = kwargs["queryname"]
216
215
  mcl = kwargs["metacommandline"]
217
216
  n = _parse_row_count_n(kwargs["n"], mcl)
218
- return _row_count(queryname, f"select count(*) from {queryname};", mcl) < n
217
+ return _row_count(queryname, mcl) < n
219
218
 
220
219
 
221
220
  def xf_sqlerror(**kwargs: Any) -> bool:
@@ -428,7 +427,7 @@ def xf_newer_date(**kwargs: Any) -> bool:
428
427
  datestr = unquoted(kwargs["datestr"])
429
428
  if not Path(file1).exists():
430
429
  raise ErrInfo(type="cmd", other_msg=f"File {file1} does not exist.")
431
- dt_value = parse_datetime(datestr)
430
+ dt_value = parse_datetime(datestr, strict=False)
432
431
  if not dt_value:
433
432
  raise ErrInfo(type="cmd", other_msg=f"{datestr} can't be interpreted as a date/time.")
434
433
  return os.stat(file1).st_mtime > time.mktime(dt_value.timetuple())
@@ -77,9 +77,8 @@ def x_assert(**kwargs: Any) -> None:
77
77
  def _ast_only_stub(name: str):
78
78
  """Return an ErrInfo for a metacommand the AST executor handles structurally.
79
79
 
80
- These handlers stay registered in the dispatch table so ``--dump-keywords``,
81
- the VS Code grammar generator, and ``--list-keywords`` still see the
82
- keyword. The AST parser converts the source form (``IF`` / ``ENDIF`` /
80
+ These handlers stay registered in the dispatch table so ``execsql list
81
+ keywords`` and the VS Code grammar generator still see the keyword. The AST parser converts the source form (``IF`` / ``ENDIF`` /
83
82
  ``LOOP`` / ``BEGIN BATCH`` / ``BREAK`` / ``ELSE`` / ``ELSEIF`` / ``ANDIF``
84
83
  / ``ORIF``) into structural AST nodes that the executor walks directly —
85
84
  none of these dispatch handlers fire for parsed scripts. Reaching one
@@ -36,6 +36,7 @@ from execsql.parser import NumericParser
36
36
  from execsql.script import current_script_line
37
37
  from execsql.utils.crypto import Encrypt
38
38
  from execsql.utils.errors import exception_desc, exit_now
39
+ from execsql.utils.fileio import register_secret
39
40
  from execsql.utils.gui import GUI_SELECTSUB, GuiSpec, enable_gui
40
41
  from execsql.utils.strings import get_subvarset, unquoted
41
42
 
@@ -126,7 +127,9 @@ def x_sub_encrypt(**kwargs: Any) -> None:
126
127
  def x_sub_decrypt(**kwargs: Any) -> None:
127
128
  varname = kwargs["match"]
128
129
  subvarset, varname = get_subvarset(varname, kwargs["metacommandline"])
129
- subvarset.add_substitution(varname, Encrypt().decrypt(kwargs["crypttext"]))
130
+ plaintext = Encrypt().decrypt(kwargs["crypttext"])
131
+ register_secret(plaintext)
132
+ subvarset.add_substitution(varname, plaintext)
130
133
  return None
131
134
 
132
135
 
@@ -52,10 +52,8 @@ def x_debug_commandliststack(**kwargs: Any) -> None:
52
52
  ``<main>`` script, ``EXECUTE SCRIPT`` calls, ``INCLUDE``'d files,
53
53
  ``IF``/``ELSEIF``/``ELSE`` branches, ``LOOP`` iterations (with iteration
54
54
  count), and ``BATCH`` blocks. Each frame shows source file and line.
55
-
56
- The legacy ``commandliststack`` is reported separately as a secondary view
57
- because it only records SCRIPT call frames; the unified ``ast_exec_stack``
58
- is the authoritative debugger view.
55
+ The metacommand keeps its upstream name; what it prints is the AST
56
+ executor's ``ast_exec_stack``.
59
57
  """
60
58
  stack = getattr(_state, "ast_exec_stack", None) or []
61
59
  _state.output.write(f"Execution Stack (depth: {len(stack)}):\n")
@@ -0,0 +1,124 @@
1
+ """What a matched metacommand touches: files it reads, writes or deletes, and connections.
2
+
3
+ Used by ``run --manifest``, which records the metacommands a run executed.
4
+ It classifies from the handler a metacommand dispatched to and the groups its
5
+ regex captured, so any other reader of a script (a static inspector) can share
6
+ the same answer.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ __all__ = ["Effect", "effects"]
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Effect:
18
+ """One thing a metacommand touches.
19
+
20
+ Attributes:
21
+ role: ``"read"``, ``"write"`` or ``"delete"`` for a file;
22
+ ``"connect"`` or ``"use"`` for a database.
23
+ target: The file path, or for a connection ``alias: server/db`` or
24
+ ``alias: file``.
25
+ detail: For a connection, the DBMS and user. Never a password.
26
+ """
27
+
28
+ role: str
29
+ target: str
30
+ detail: str = ""
31
+
32
+
33
+ _IMPORTS = (
34
+ "x_import",
35
+ "x_import_file",
36
+ "x_import_feather",
37
+ "x_import_json",
38
+ "x_import_ods",
39
+ "x_import_ods_pattern",
40
+ "x_import_parquet",
41
+ "x_import_xls",
42
+ "x_import_xls_pattern",
43
+ "x_sub_ini",
44
+ "x_serve",
45
+ )
46
+ _EXPORTS = (
47
+ "x_export",
48
+ "x_export_query",
49
+ "x_export_metadata",
50
+ "x_export_with_template",
51
+ "x_export_query_with_template",
52
+ "x_export_ods_multiple",
53
+ "x_export_xlsx_multiple",
54
+ )
55
+ _WRITES = (
56
+ "x_write",
57
+ "x_writescript",
58
+ "x_consolesave",
59
+ "x_cancel_halt_write",
60
+ "x_error_halt_write",
61
+ "x_debug_write_config",
62
+ "x_debug_write_metacommands",
63
+ "x_debug_write_odbc_drivers",
64
+ "x_debug_write_subvars",
65
+ )
66
+
67
+ #: Handler name → {regex group: role}. Groups not listed (passwords,
68
+ #: encodings, prompt paths) are never reported.
69
+ FILE_ROLES: dict[str, dict[str, str]] = {
70
+ **{name: {"filename": "read"} for name in _IMPORTS},
71
+ **{name: {"filename": "write", "zipfilename": "write", "template": "read"} for name in _EXPORTS},
72
+ **{name: {"filename": "write"} for name in _WRITES},
73
+ **{
74
+ name: {"filename": "read", "outfile": "write"}
75
+ for name in ("x_write_create_table", "x_write_create_table_ods", "x_write_create_table_xls")
76
+ },
77
+ "x_write_create_table_alias": {"filename": "read"},
78
+ **{
79
+ name: {"att_file": "read", "msg_file": "read"}
80
+ for name in ("x_email", "x_cancel_halt_email", "x_error_halt_email")
81
+ },
82
+ "x_zip": {"filename": "read", "zipfilename": "write"},
83
+ "x_rm_file": {"filename": "delete"},
84
+ }
85
+
86
+ DBMS = {
87
+ "pg": "PostgreSQL",
88
+ "mysql": "MySQL",
89
+ "ssvr": "SQL Server",
90
+ "ora": "Oracle",
91
+ "fb": "Firebird",
92
+ "sqlite": "SQLite",
93
+ "duckdb": "DuckDB",
94
+ "access": "MS Access",
95
+ "dsn": "ODBC DSN",
96
+ }
97
+
98
+
99
+ def _connection(handler: str, groups: dict[str, str | None]) -> Effect | None:
100
+ if handler == "x_use":
101
+ return Effect("use", groups.get("db_alias") or "")
102
+ if not handler.startswith("x_connect_"):
103
+ return None
104
+ kind = handler.removeprefix("x_connect_").removeprefix("user_")
105
+ target = groups.get("filename") or groups.get("dsn") or ""
106
+ if groups.get("server"):
107
+ target = f"{groups['server']}/{groups.get('db_name') or ''}"
108
+ detail = DBMS.get(kind, kind)
109
+ if groups.get("user"):
110
+ detail += f", user {groups['user']}"
111
+ return Effect("connect", f"{groups.get('db_alias') or ''}: {target}".strip(), detail)
112
+
113
+
114
+ def effects(handler: str, groups: dict[str, str | None]) -> list[Effect]:
115
+ """Everything a metacommand dispatched to *handler* with *groups* touches."""
116
+ connection = _connection(handler, groups)
117
+ if connection is not None:
118
+ return [connection]
119
+ found = []
120
+ for group, role in FILE_ROLES.get(handler, {}).items():
121
+ value = groups.get(group)
122
+ if value:
123
+ found.append(Effect(role, value.strip().strip('"')))
124
+ return found
@@ -8,14 +8,18 @@ Implements ``x_include``, ``x_copy``, ``x_copy_query``, ``x_zip``,
8
8
  from __future__ import annotations
9
9
 
10
10
  import os
11
+ import pickle
12
+ import struct
11
13
  import sys
12
- from itertools import tee
14
+ import tempfile
15
+ from collections.abc import Iterator
13
16
  from pathlib import Path
14
17
  from shutil import copyfileobj
15
- from typing import Any, Literal, cast
18
+ from typing import Any, Literal
16
19
 
17
20
  import execsql.state as _state
18
21
  from execsql.exceptions import ErrInfo
22
+ from execsql.importers.base import refuse_existing_table
19
23
  from execsql.models import DataTable
20
24
  from execsql.script import current_script_line
21
25
  from execsql.utils.errors import exception_desc
@@ -23,6 +27,39 @@ from execsql.utils.fileio import filewriter_close
23
27
  from execsql.utils.strings import unquoted
24
28
 
25
29
 
30
+ class _RowSpool:
31
+ """Pass rows through while writing each to a temporary file, to be read back once more.
32
+
33
+ ``COPY ... TO NEW`` needs every row twice, once to describe the new table
34
+ and once to load it, but runs the source query once. The rows are kept
35
+ in memory up to *max_size* bytes and on disk beyond that. Each row is
36
+ pickled on its own, so neither pass keeps a reference to earlier rows.
37
+ """
38
+
39
+ _LENGTH = struct.Struct("<Q")
40
+
41
+ def __init__(self, rows: Any, max_size: int = 8 * 1024 * 1024) -> None:
42
+ self._rows = rows
43
+ self._file = tempfile.SpooledTemporaryFile(max_size=max_size) # noqa: SIM115 - closed by close()
44
+
45
+ def __iter__(self) -> Iterator[Any]:
46
+ for row in self._rows:
47
+ data = pickle.dumps(row, protocol=pickle.HIGHEST_PROTOCOL)
48
+ self._file.write(self._LENGTH.pack(len(data)))
49
+ self._file.write(data)
50
+ yield row
51
+
52
+ def replay(self) -> Iterator[Any]:
53
+ """Yield the rows passed through, in order."""
54
+ self._file.seek(0)
55
+ while header := self._file.read(self._LENGTH.size):
56
+ (size,) = self._LENGTH.unpack(header)
57
+ yield pickle.loads(self._file.read(size))
58
+
59
+ def close(self) -> None:
60
+ self._file.close()
61
+
62
+
26
63
  def _close_rowsource(rows: Any) -> None:
27
64
  close = getattr(rows, "close", None)
28
65
  if close:
@@ -63,29 +100,16 @@ def x_copy(**kwargs: Any) -> None:
63
100
  db2 = _state.dbs.aliased_as(alias2)
64
101
  tbl1 = db1.schema_qualified_table_name(schema1, table1)
65
102
  tbl2 = db2.schema_qualified_table_name(schema2, table2)
66
- try:
67
- if not db1.table_exists(table1, schema1):
68
- raise ErrInfo(
69
- type="cmd",
70
- command_text=kwargs["metacommandline"],
71
- other_msg=f"Table {tbl1} does not exist",
72
- )
73
- except Exception:
74
- pass # Best-effort check; some adapters lack information_schema.
103
+ # No check that the source exists: the database may fold an unquoted name
104
+ # (PostgreSQL reads Orders as orders), and the SELECT below names a missing
105
+ # source in the database's own words.
75
106
  if new_tbl2 and new_tbl2 == "new":
76
- try:
77
- if db2.table_exists(table2, schema2):
78
- raise ErrInfo(
79
- type="cmd",
80
- command_text=kwargs["metacommandline"],
81
- other_msg=f"Table {tbl2} already exists",
82
- )
83
- except Exception:
84
- pass # Best-effort check; some adapters lack information_schema.
107
+ refuse_existing_table(db2, schema2, table2)
85
108
  select_stmt = f"select * from {tbl1};"
86
109
 
87
110
  get_ts_tablespec = None
88
111
  rows_to_close = None
112
+ spool: _RowSpool | None = None
89
113
  rows_for_insert: Any
90
114
 
91
115
  if new_tbl2:
@@ -96,9 +120,10 @@ def x_copy(**kwargs: Any) -> None:
96
120
  except Exception as e:
97
121
  raise ErrInfo("db", select_stmt, exception_msg=exception_desc()) from e
98
122
  rows_to_close = rows
99
- rows = cast(Any, rows)
100
- rows_for_schema, rows_for_insert = tee(rows)
101
- get_ts_tablespec = DataTable(hdrs, rows_for_schema)
123
+ # Values come from a database cursor, so a text value stays text.
124
+ spool = _RowSpool(rows)
125
+ get_ts_tablespec = DataTable(hdrs, spool, infer_strings=False)
126
+ rows_for_insert = spool.replay()
102
127
  tbl_desc = get_ts_tablespec
103
128
  create_tbl = tbl_desc.create_table(db2.type, schema2, table2)
104
129
  if new_tbl2 == "replacement":
@@ -129,7 +154,7 @@ def x_copy(**kwargs: Any) -> None:
129
154
  nonlocal get_ts_tablespec
130
155
  if get_ts_tablespec is None:
131
156
  ts_hdrs, ts_rows = db1.select_rowsource(select_stmt)
132
- get_ts_tablespec = DataTable(ts_hdrs, ts_rows)
157
+ get_ts_tablespec = DataTable(ts_hdrs, ts_rows, infer_strings=False)
133
158
  return get_ts_tablespec
134
159
 
135
160
  try:
@@ -138,6 +163,9 @@ def x_copy(**kwargs: Any) -> None:
138
163
  except BaseException:
139
164
  _close_rowsource(rows_to_close)
140
165
  raise
166
+ finally:
167
+ if spool is not None:
168
+ spool.close()
141
169
 
142
170
 
143
171
  def x_copy_query(**kwargs: Any) -> None:
@@ -164,18 +192,11 @@ def x_copy_query(**kwargs: Any) -> None:
164
192
  db2 = _state.dbs.aliased_as(alias2)
165
193
  tbl2 = db2.schema_qualified_table_name(schema2, table2)
166
194
  if new_tbl2 and new_tbl2 == "new":
167
- try:
168
- if db2.table_exists(table2, schema2):
169
- raise ErrInfo(
170
- type="cmd",
171
- command_text=kwargs["metacommandline"],
172
- other_msg=f"Table {tbl2} already exists",
173
- )
174
- except Exception:
175
- pass # Best-effort check; some adapters lack information_schema.
195
+ refuse_existing_table(db2, schema2, table2)
176
196
 
177
197
  get_ts_tablespec = None
178
198
  rows_to_close = None
199
+ spool: _RowSpool | None = None
179
200
  rows_for_insert: Any
180
201
 
181
202
  if new_tbl2:
@@ -186,9 +207,10 @@ def x_copy_query(**kwargs: Any) -> None:
186
207
  except Exception as e:
187
208
  raise ErrInfo("db", select_stmt, exception_msg=exception_desc()) from e
188
209
  rows_to_close = rows
189
- rows = cast(Any, rows)
190
- rows_for_schema, rows_for_insert = tee(rows)
191
- get_ts_tablespec = DataTable(hdrs, rows_for_schema)
210
+ # Values come from a database cursor, so a text value stays text.
211
+ spool = _RowSpool(rows)
212
+ get_ts_tablespec = DataTable(hdrs, spool, infer_strings=False)
213
+ rows_for_insert = spool.replay()
192
214
  tbl_desc = get_ts_tablespec
193
215
  create_tbl = tbl_desc.create_table(db2.type, schema2, table2)
194
216
  if new_tbl2 == "replacement":
@@ -219,7 +241,7 @@ def x_copy_query(**kwargs: Any) -> None:
219
241
  nonlocal get_ts_tablespec
220
242
  if get_ts_tablespec is None:
221
243
  ts_hdrs, ts_rows = db1.select_rowsource(select_stmt)
222
- get_ts_tablespec = DataTable(ts_hdrs, ts_rows)
244
+ get_ts_tablespec = DataTable(ts_hdrs, ts_rows, infer_strings=False)
223
245
  return get_ts_tablespec
224
246
 
225
247
  try:
@@ -228,6 +250,9 @@ def x_copy_query(**kwargs: Any) -> None:
228
250
  except BaseException:
229
251
  _close_rowsource(rows_to_close)
230
252
  raise
253
+ finally:
254
+ if spool is not None:
255
+ spool.close()
231
256
 
232
257
 
233
258
  def x_zip(**kwargs: Any) -> None:
@@ -74,7 +74,6 @@ def x_import(**kwargs: Any) -> None:
74
74
  tablename,
75
75
  filename,
76
76
  is_new,
77
- skip_header_line=True,
78
77
  quotechar=quotechar,
79
78
  delimchar=delimchar,
80
79
  encoding=enc,
@@ -67,6 +67,7 @@ from execsql.utils.gui import (
67
67
  pause,
68
68
  pause_win,
69
69
  )
70
+ from execsql.utils.fileio import register_secret
70
71
  from execsql.utils.strings import unquoted
71
72
 
72
73
 
@@ -144,6 +145,7 @@ def x_prompt_enter(**kwargs: Any) -> None:
144
145
  subvarset.add_substitution(sub_var, txtval)
145
146
  script_name, lno = current_script_line()
146
147
  if as_pw:
148
+ register_secret(txtval)
147
149
  _state.exec_log.log_status_info(f"Password assigned to variable {{{sub_var}}} on line {lno}.")
148
150
  else:
149
151
  _state.exec_log.log_status_info(f"Variable {{{sub_var}}} set to {{{txtval}}} on line {lno}.")
@@ -4,7 +4,7 @@ Integrates pg-upsert (https://pg-upsert.readthedocs.io/) as an optional
4
4
  dependency, providing QA-checked, FK-dependency-ordered upserts from a
5
5
  staging schema to a base schema on PostgreSQL.
6
6
 
7
- Requires: ``pip install execsql2[upsert]``
7
+ Requires the ``upsert`` extra (``execsql2[upsert]``).
8
8
  """
9
9
 
10
10
  from __future__ import annotations
@@ -213,7 +213,10 @@ def _require_pg_upsert() -> None:
213
213
  except ImportError as exc:
214
214
  raise ErrInfo(
215
215
  "exception",
216
- other_msg=("PG_UPSERT requires the pg-upsert package. Install it with: pip install execsql2[upsert]"),
216
+ other_msg=(
217
+ 'PG_UPSERT requires the pg-upsert package. Install the upsert extra: uv tool install "execsql2[upsert]"'
218
+ ' (or pip install "execsql2[upsert]").'
219
+ ),
217
220
  ) from exc
218
221
 
219
222