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.
- execsql/api.py +169 -160
- execsql/cli/__init__.py +19 -586
- execsql/cli/application.py +309 -0
- execsql/cli/commands/__init__.py +13 -0
- execsql/cli/commands/config.py +205 -0
- execsql/cli/commands/format.py +100 -0
- execsql/cli/commands/init.py +135 -0
- execsql/cli/commands/lint.py +225 -0
- execsql/cli/commands/listing.py +81 -0
- execsql/cli/commands/run.py +459 -0
- execsql/cli/dispatch.py +66 -0
- execsql/cli/dsn.py +11 -4
- execsql/cli/help.py +175 -24
- execsql/cli/lint.py +780 -100
- execsql/cli/options.py +108 -0
- execsql/cli/run.py +96 -32
- execsql/config.py +256 -173
- execsql/config_validation.py +282 -0
- execsql/data/execsql.conf.template +38 -5
- execsql/data/script_header.sql +23 -0
- execsql/db/access.py +29 -8
- execsql/db/base.py +40 -8
- execsql/db/dsn.py +13 -4
- execsql/db/duckdb.py +3 -1
- execsql/db/factory.py +13 -4
- execsql/db/firebird.py +21 -7
- execsql/db/mysql.py +37 -16
- execsql/db/oracle.py +49 -16
- execsql/db/postgres.py +30 -6
- execsql/db/sqlite.py +7 -9
- execsql/db/sqlserver.py +25 -8
- execsql/debug/repl.py +51 -193
- execsql/exceptions.py +9 -0
- execsql/exporters/delimited.py +87 -28
- execsql/exporters/xlsx.py +24 -9
- execsql/exporters/zip.py +1 -2
- execsql/format.py +115 -76
- execsql/gui/desktop.py +1 -3
- execsql/gui/tui.py +2 -2
- execsql/importers/base.py +35 -1
- execsql/importers/csv.py +3 -1
- execsql/interactive.py +232 -0
- execsql/manifest.py +200 -0
- execsql/metacommands/conditions.py +7 -8
- execsql/metacommands/control.py +2 -3
- execsql/metacommands/data.py +4 -1
- execsql/metacommands/debug.py +2 -4
- execsql/metacommands/effects.py +124 -0
- execsql/metacommands/io_fileops.py +62 -37
- execsql/metacommands/io_import.py +0 -1
- execsql/metacommands/prompt.py +2 -0
- execsql/metacommands/upsert.py +5 -2
- execsql/models.py +3 -2
- execsql/parser.py +14 -5
- execsql/plugins.py +24 -0
- execsql/script/__init__.py +0 -9
- execsql/script/ast.py +1 -1
- execsql/script/engine.py +46 -104
- execsql/script/executor.py +162 -39
- execsql/script/parser.py +167 -118
- execsql/state.py +34 -11
- execsql/state.pyi +11 -0
- execsql/types.py +22 -13
- execsql/utils/auth.py +169 -24
- execsql/utils/color.py +18 -0
- execsql/utils/datetime.py +55 -17
- execsql/utils/errors.py +82 -10
- execsql/utils/fileio.py +404 -117
- execsql/utils/gui.py +3 -0
- execsql/utils/mail.py +37 -12
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/README.md +1 -1
- {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/METADATA +122 -63
- execsql2-2.25.0.dist-info/RECORD +140 -0
- execsql2-2.25.0.dist-info/entry_points.txt +2 -0
- execsql2-2.23.1.dist-info/RECORD +0 -124
- execsql2-2.23.1.dist-info/entry_points.txt +0 -3
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/WHEEL +0 -0
- {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/licenses/NOTICE +0 -0
execsql/debug/repl.py
CHANGED
|
@@ -1,49 +1,34 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
"""
|
|
3
|
+
"""The debug REPL a ``BREAKPOINT`` (or ``run --debug``) opens.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
Input runs as if it were written where the script paused, through
|
|
6
|
+
:mod:`execsql.interactive`: SQL (ending with ``;``, results shown as tables),
|
|
7
|
+
metacommands and blocks, in the paused script's variable scope and
|
|
8
|
+
transaction. A ``SUB`` typed here is still set
|
|
9
|
+
when the script resumes; a ``BREAK`` inside a LOOP leaves the loop; ``HALT``
|
|
10
|
+
ends the run. An error ends only the input that caused it.
|
|
7
11
|
|
|
8
|
-
|
|
12
|
+
Commands to the REPL itself start with ``.``: ``.continue`` and ``.next``
|
|
13
|
+
resume the script, ``.where`` and ``.stack`` show where it paused, ``.quit``
|
|
14
|
+
halts it; ``.vars``, ``.set``, ``.scripts`` and ``.cancel`` inspect and
|
|
15
|
+
change the run.
|
|
9
16
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
- Step through the script one statement at a time.
|
|
13
|
-
- Resume or abort execution.
|
|
14
|
-
|
|
15
|
-
Dispatch is two-way (psql-style): input starting with ``.`` is a REPL
|
|
16
|
-
command (``.continue``, ``.vars [VAR]``, ``.next``, etc.), everything
|
|
17
|
-
else is SQL. Multi-line SQL is supported: any non-``.`` input opens a
|
|
18
|
-
buffer whose continuation prompt is `` ... > ``, accumulating
|
|
19
|
-
until a line ends with ``;``. ``.cancel`` (or Ctrl-C / EOF) discards
|
|
20
|
-
the partial buffer.
|
|
21
|
-
|
|
22
|
-
Variable lookup is explicit — ``.vars LOGFILE`` prints one variable;
|
|
23
|
-
``.vars`` lists them all. There is no bare-identifier lookup, so any
|
|
24
|
-
SQL keyword you type starts a buffer the moment you press Enter.
|
|
25
|
-
|
|
26
|
-
The trailing ``;`` is the SQL terminator both within one line and across
|
|
27
|
-
multiple lines — the REPL has no read-only mode and DDL on most adapters
|
|
28
|
-
is irreversible, so requiring ``;`` is a small intent gate against
|
|
29
|
-
accidental DML/DDL on mistyped input. Use SQL ``BEGIN; … ROLLBACK;`` to
|
|
30
|
-
bracket exploratory DML if you need recoverability.
|
|
31
|
-
|
|
32
|
-
Errors raised by any REPL helper — bad SQL, malformed dot-commands, etc. —
|
|
33
|
-
are caught at the loop level so the session re-prompts instead of escaping
|
|
34
|
-
through ``x_breakpoint`` and being stamped as a "Metacommand error".
|
|
17
|
+
This module also holds the REPL's display helpers (colors, tables, variable
|
|
18
|
+
listings).
|
|
35
19
|
|
|
36
20
|
In non-interactive environments (CI, piped input, ``sys.stdin.isatty()`` is
|
|
37
21
|
``False``) the metacommand is silently skipped so automated pipelines are not
|
|
38
22
|
blocked.
|
|
39
23
|
"""
|
|
40
24
|
|
|
41
|
-
import os
|
|
42
25
|
import sys
|
|
43
26
|
from pathlib import Path
|
|
44
27
|
from typing import Any
|
|
45
28
|
|
|
46
29
|
import execsql.state as _state
|
|
30
|
+
from execsql.interactive import Prompt, common_dot_command, read_eval_loop, unknown_dot_command
|
|
31
|
+
from execsql.utils.color import color_disabled_by_env
|
|
47
32
|
|
|
48
33
|
__all__ = ["x_breakpoint"]
|
|
49
34
|
|
|
@@ -77,7 +62,7 @@ def _use_color() -> bool:
|
|
|
77
62
|
global _color_cache # noqa: PLW0603
|
|
78
63
|
if _color_cache is not None:
|
|
79
64
|
return _color_cache
|
|
80
|
-
if
|
|
65
|
+
if color_disabled_by_env():
|
|
81
66
|
_color_cache = False
|
|
82
67
|
else:
|
|
83
68
|
output = _state.output
|
|
@@ -123,18 +108,20 @@ _HELP_COMMANDS = [
|
|
|
123
108
|
(".quit", ".q", "Halt the script (exit 1)"),
|
|
124
109
|
(".vars", ".v", "List all execsql substitution variables"),
|
|
125
110
|
(".vars VAR", ".v VAR", "Print the value of a single variable (e.g. .vars logfile)"),
|
|
111
|
+
(".vars all", "", "Also list environment (&) variables"),
|
|
126
112
|
(".next", ".n", "Execute the next statement then pause again (step mode)"),
|
|
127
113
|
(".where", ".w", "Show the current script location and upcoming statement"),
|
|
128
114
|
(".stack", "", "Show the command-list stack (script name, line, depth)"),
|
|
129
115
|
(".set VAR VAL", ".s", "Set or update a substitution variable"),
|
|
130
116
|
(".scripts", "", "List all registered SCRIPT definitions"),
|
|
131
117
|
(".scripts NAME", "", "Show detail for a specific SCRIPT"),
|
|
132
|
-
(".cancel", "", "Discard a
|
|
118
|
+
(".cancel", "", "Discard a statement or block you are typing"),
|
|
133
119
|
(".help", ".h", "Show this help text"),
|
|
134
120
|
]
|
|
135
121
|
|
|
136
122
|
_HELP_OTHER = [
|
|
137
|
-
("SELECT ...;", "Run SQL
|
|
123
|
+
("SELECT ...;", "Run SQL, as the script would here; it may span lines and ends with ';'"),
|
|
124
|
+
("-- !x! SUB ...", "Run a metacommand (!x! SUB ... works too); blocks stay open until closed"),
|
|
138
125
|
]
|
|
139
126
|
|
|
140
127
|
_HELP_CMD_WIDTH = 13 # width of the command column
|
|
@@ -196,8 +183,8 @@ def _write_rule(label: str) -> None:
|
|
|
196
183
|
def _debug_repl(*, step: bool = False) -> None:
|
|
197
184
|
"""Interactive read-eval-print loop for script debugging.
|
|
198
185
|
|
|
199
|
-
Reads
|
|
200
|
-
|
|
186
|
+
Reads input until ``.continue``, ``.next`` or ``.quit``, or until Ctrl-D /
|
|
187
|
+
Ctrl-C on an empty line, which resume the script.
|
|
201
188
|
|
|
202
189
|
Args:
|
|
203
190
|
step: When ``True``, the entry banner says "Step" instead of
|
|
@@ -232,125 +219,37 @@ def _debug_repl(*, step: bool = False) -> None:
|
|
|
232
219
|
_hint_c = _c(_DIM, "'.c'")
|
|
233
220
|
_write(f" Type {_hint_help} for commands, {_hint_c} to resume.\n\n")
|
|
234
221
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
while True:
|
|
238
|
-
prompt = " ... > " if sql_buffer else "execsql debug> "
|
|
239
|
-
try:
|
|
240
|
-
line = input(prompt).strip()
|
|
241
|
-
except EOFError:
|
|
242
|
-
if sql_buffer:
|
|
243
|
-
sql_buffer.clear()
|
|
244
|
-
_write("\n (input discarded)\n")
|
|
245
|
-
continue
|
|
246
|
-
_write("\n")
|
|
247
|
-
return
|
|
248
|
-
except KeyboardInterrupt:
|
|
249
|
-
if sql_buffer:
|
|
250
|
-
sql_buffer.clear()
|
|
251
|
-
_write("\n (input discarded)\n")
|
|
252
|
-
continue
|
|
253
|
-
_write("\n")
|
|
254
|
-
return
|
|
222
|
+
read_eval_loop(_DebugPrompt())
|
|
255
223
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
if cmd in ("next", "n"):
|
|
273
|
-
return
|
|
274
|
-
continue
|
|
275
|
-
|
|
276
|
-
if sql_buffer:
|
|
277
|
-
sql_buffer.append(line)
|
|
278
|
-
joined = " ".join(sql_buffer)
|
|
279
|
-
if joined.rstrip().endswith(";"):
|
|
280
|
-
_run_sql(joined)
|
|
281
|
-
sql_buffer.clear()
|
|
282
|
-
continue
|
|
283
|
-
|
|
284
|
-
if line.rstrip().endswith(";"):
|
|
285
|
-
_run_sql(line)
|
|
286
|
-
continue
|
|
287
|
-
|
|
288
|
-
sql_buffer.append(line)
|
|
289
|
-
except SystemExit:
|
|
290
|
-
raise
|
|
291
|
-
except KeyboardInterrupt:
|
|
292
|
-
sql_buffer.clear()
|
|
293
|
-
_write("\n (interrupted)\n")
|
|
294
|
-
continue
|
|
295
|
-
except Exception as exc:
|
|
296
|
-
sql_buffer.clear()
|
|
297
|
-
_write(f" {_c(_RED, 'Error:')} {exc}\n")
|
|
298
|
-
continue
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
def _handle_dot_command(line: str) -> None:
|
|
302
|
-
"""Dispatch a dot-prefixed REPL command."""
|
|
303
|
-
# Strip the leading dot and normalize
|
|
304
|
-
cmd = line[1:].strip().lower()
|
|
305
|
-
|
|
306
|
-
if cmd in ("continue", "c"):
|
|
307
|
-
return # caller checks and returns from _debug_repl
|
|
308
|
-
elif cmd in ("abort", "q", "quit"):
|
|
224
|
+
|
|
225
|
+
class _DebugPrompt(Prompt):
|
|
226
|
+
name = "execsql debug"
|
|
227
|
+
source = "<breakpoint>"
|
|
228
|
+
|
|
229
|
+
def dot_command(self, command: str, argument: str, line: str) -> bool:
|
|
230
|
+
return _handle_dot_command(line)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _handle_dot_command(line: str) -> bool:
|
|
234
|
+
"""Dispatch a ``.`` command; return ``False`` when the script should resume."""
|
|
235
|
+
command, _, argument = line.strip()[1:].partition(" ")
|
|
236
|
+
command = command.lower()
|
|
237
|
+
if command in ("continue", "c"):
|
|
238
|
+
return False
|
|
239
|
+
if command in ("abort", "q", "quit"):
|
|
309
240
|
raise SystemExit(1)
|
|
310
|
-
|
|
241
|
+
if command in ("next", "n"):
|
|
242
|
+
_enable_step_mode()
|
|
243
|
+
return False
|
|
244
|
+
if command in ("help", "h"):
|
|
311
245
|
_write(_format_help())
|
|
312
|
-
elif
|
|
313
|
-
_print_all_vars()
|
|
314
|
-
elif cmd.startswith("vars ") or cmd.startswith("v "):
|
|
315
|
-
rest = cmd.split(None, 1)[1].strip() if " " in cmd else ""
|
|
316
|
-
if rest:
|
|
317
|
-
_print_var(rest)
|
|
318
|
-
else:
|
|
319
|
-
_print_all_vars()
|
|
320
|
-
elif cmd in ("where", "w"):
|
|
246
|
+
elif command in ("where", "w"):
|
|
321
247
|
_print_where()
|
|
322
|
-
elif
|
|
248
|
+
elif command == "stack":
|
|
323
249
|
_print_stack()
|
|
324
|
-
elif
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
# .set VAR VAL — set or update a substitution variable
|
|
328
|
-
rest = cmd[4:].strip() if cmd.startswith("set ") else ""
|
|
329
|
-
if not rest:
|
|
330
|
-
_write(" Usage: .set VAR VALUE\n")
|
|
331
|
-
else:
|
|
332
|
-
parts = rest.split(None, 1)
|
|
333
|
-
varname = parts[0]
|
|
334
|
-
value = parts[1] if len(parts) > 1 else ""
|
|
335
|
-
_set_var(varname, value)
|
|
336
|
-
elif cmd.startswith("s ") or cmd == "s":
|
|
337
|
-
# .s VAR VAL — shorthand for .set
|
|
338
|
-
rest = cmd[2:].strip() if cmd.startswith("s ") else ""
|
|
339
|
-
if not rest:
|
|
340
|
-
_write(" Usage: .s VAR VALUE\n")
|
|
341
|
-
else:
|
|
342
|
-
parts = rest.split(None, 1)
|
|
343
|
-
varname = parts[0]
|
|
344
|
-
value = parts[1] if len(parts) > 1 else ""
|
|
345
|
-
_set_var(varname, value)
|
|
346
|
-
elif cmd.startswith("scripts"):
|
|
347
|
-
rest = cmd[7:].strip()
|
|
348
|
-
if rest:
|
|
349
|
-
_print_script_detail(rest)
|
|
350
|
-
else:
|
|
351
|
-
_print_scripts()
|
|
352
|
-
else:
|
|
353
|
-
_write(f" {_c(_RED, 'Unknown command:')} {line!r}. Type '.help' for available commands.\n")
|
|
250
|
+
elif not common_dot_command(command, argument.strip(), line):
|
|
251
|
+
unknown_dot_command(line)
|
|
252
|
+
return True
|
|
354
253
|
|
|
355
254
|
|
|
356
255
|
# ---------------------------------------------------------------------------
|
|
@@ -371,7 +270,7 @@ def _write(text: str) -> None:
|
|
|
371
270
|
def _print_where() -> None:
|
|
372
271
|
"""Print the current script location and the upcoming statement.
|
|
373
272
|
|
|
374
|
-
Reads ``_state.last_command``
|
|
273
|
+
Reads ``_state.last_command`` and displays the
|
|
375
274
|
filename, line number, command type, and (truncated) statement text.
|
|
376
275
|
If ``last_command`` is ``None``, reports that the position is unknown.
|
|
377
276
|
"""
|
|
@@ -531,49 +430,8 @@ def _print_stack() -> None:
|
|
|
531
430
|
_write("\n")
|
|
532
431
|
|
|
533
432
|
|
|
534
|
-
def
|
|
535
|
-
"""
|
|
536
|
-
dbs = _state.dbs
|
|
537
|
-
if dbs is None:
|
|
538
|
-
_write(" (no database connection is active)\n")
|
|
539
|
-
return
|
|
540
|
-
db = dbs.current()
|
|
541
|
-
if db is None:
|
|
542
|
-
_write(" (no database connection is active)\n")
|
|
543
|
-
return
|
|
544
|
-
|
|
545
|
-
try:
|
|
546
|
-
with db._cursor() as curs:
|
|
547
|
-
try:
|
|
548
|
-
curs.execute(sql)
|
|
549
|
-
except Exception as exc:
|
|
550
|
-
try:
|
|
551
|
-
db.rollback()
|
|
552
|
-
except Exception:
|
|
553
|
-
pass
|
|
554
|
-
_write(f" {_c(_RED, 'SQL error:')} {exc}\n")
|
|
555
|
-
return
|
|
556
|
-
|
|
557
|
-
try:
|
|
558
|
-
_state.subvars.add_substitution("$LAST_ROWCOUNT", curs.rowcount)
|
|
559
|
-
except Exception:
|
|
560
|
-
pass
|
|
561
|
-
|
|
562
|
-
if curs.description is None:
|
|
563
|
-
rowcount = curs.rowcount if curs.rowcount is not None else -1
|
|
564
|
-
if rowcount >= 0:
|
|
565
|
-
row_word = "row" if rowcount == 1 else "rows"
|
|
566
|
-
_write(f" {_c(_DIM, f'({rowcount} {row_word} affected)')}\n")
|
|
567
|
-
else:
|
|
568
|
-
_write(f" {_c(_DIM, '(statement executed)')}\n")
|
|
569
|
-
return
|
|
570
|
-
|
|
571
|
-
colnames = [d[0] for d in curs.description]
|
|
572
|
-
rows = curs.fetchall()
|
|
573
|
-
except Exception as exc:
|
|
574
|
-
_write(f" {_c(_RED, 'SQL error:')} {exc}\n")
|
|
575
|
-
return
|
|
576
|
-
|
|
433
|
+
def _print_table(colnames: list[str], rows: list[Any]) -> None:
|
|
434
|
+
"""Print query results as a boxed text table, NULLs dimmed."""
|
|
577
435
|
if not colnames:
|
|
578
436
|
_write(" (query returned no columns)\n")
|
|
579
437
|
return
|
execsql/exceptions.py
CHANGED
|
@@ -35,6 +35,7 @@ Note:
|
|
|
35
35
|
__all__ = [
|
|
36
36
|
"ExecSqlError",
|
|
37
37
|
"ConfigError",
|
|
38
|
+
"ConfigFileError",
|
|
38
39
|
"ExecSqlTimeoutError",
|
|
39
40
|
"ErrInfo",
|
|
40
41
|
"DataTypeError",
|
|
@@ -74,6 +75,14 @@ class ConfigError(ExecSqlError):
|
|
|
74
75
|
"""Raised for invalid or missing execsql configuration values."""
|
|
75
76
|
|
|
76
77
|
|
|
78
|
+
class ConfigFileError(ConfigError):
|
|
79
|
+
"""A :class:`ConfigError` found while reading one config file.
|
|
80
|
+
|
|
81
|
+
The message starts with the file's path. ``execsql config --validate``
|
|
82
|
+
reports every problem in the file, with line numbers.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
|
|
77
86
|
class ExecSqlTimeoutError(ExecSqlError):
|
|
78
87
|
"""Timeout during alarm-timer operations.
|
|
79
88
|
|
execsql/exporters/delimited.py
CHANGED
|
@@ -28,7 +28,14 @@ from execsql.utils.errors import exception_desc
|
|
|
28
28
|
from execsql.utils.fileio import filewriter_close
|
|
29
29
|
from execsql.utils.strings import clean_words, dedup_words, fold_words
|
|
30
30
|
|
|
31
|
-
__all__ = [
|
|
31
|
+
__all__ = [
|
|
32
|
+
"LineDelimiter",
|
|
33
|
+
"CsvFile",
|
|
34
|
+
"CsvWriter",
|
|
35
|
+
"DelimitedWriter",
|
|
36
|
+
"blank_lines_as_empty_rows",
|
|
37
|
+
"write_delimited_file",
|
|
38
|
+
]
|
|
32
39
|
|
|
33
40
|
_SPACE_DELIM_RX = re.compile(r" +")
|
|
34
41
|
|
|
@@ -618,6 +625,9 @@ class CsvFile(EncodedFile):
|
|
|
618
625
|
prev_state = state
|
|
619
626
|
state = exec_vector[state]()
|
|
620
627
|
end()
|
|
628
|
+
# An empty result is either a blank line or the end of the file; the
|
|
629
|
+
# reader needs to know which.
|
|
630
|
+
self._at_eof = c == ""
|
|
621
631
|
if len(self.parse_errors) > 0:
|
|
622
632
|
raise ErrInfo("error", other_msg=", ".join(self.parse_errors))
|
|
623
633
|
return elements
|
|
@@ -653,48 +663,63 @@ class CsvFile(EncodedFile):
|
|
|
653
663
|
doublequote=True,
|
|
654
664
|
strict=False,
|
|
655
665
|
)
|
|
656
|
-
for
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
# Normalize empty strings to None for parity with the slow reader.
|
|
660
|
-
normalized_elements = [e if e != "" else None for e in elements]
|
|
661
|
-
if conf.del_empty_cols and len(self.blank_cols) > 0:
|
|
662
|
-
blanks = copy.copy(self.blank_cols)
|
|
663
|
-
while len(blanks) > 0:
|
|
664
|
-
b = blanks.pop()
|
|
665
|
-
del normalized_elements[b]
|
|
666
|
-
yield normalized_elements
|
|
666
|
+
# Normalize empty strings to None for parity with the slow reader.
|
|
667
|
+
raw = ([e if e != "" else None for e in elements] for elements in csv_reader)
|
|
668
|
+
yield from self._rows(raw, conf)
|
|
667
669
|
finally:
|
|
668
670
|
f.close()
|
|
669
671
|
|
|
670
672
|
def _slow_reader(self, conf: Any) -> Any:
|
|
671
673
|
"""Read using the character-at-a-time state machine (fallback for non-standard formats)."""
|
|
672
674
|
f = self.openclean("rt")
|
|
673
|
-
|
|
674
|
-
|
|
675
|
+
|
|
676
|
+
def raw() -> Any:
|
|
677
|
+
line_no = 0
|
|
675
678
|
while True:
|
|
676
679
|
line_no += 1
|
|
677
680
|
try:
|
|
678
681
|
elements = self.read_and_parse_line(f)
|
|
679
682
|
except ErrInfo as e:
|
|
680
683
|
raise ErrInfo("error", other_msg=f"{e.other} on line {line_no}.") from e
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
b = blanks.pop()
|
|
688
|
-
del elements[b]
|
|
689
|
-
yield elements
|
|
690
|
-
else:
|
|
691
|
-
break
|
|
684
|
+
if not elements and self._at_eof:
|
|
685
|
+
return
|
|
686
|
+
yield elements
|
|
687
|
+
|
|
688
|
+
try:
|
|
689
|
+
yield from self._rows(raw(), conf)
|
|
692
690
|
finally:
|
|
693
691
|
f.close()
|
|
694
692
|
|
|
695
|
-
def
|
|
696
|
-
"""
|
|
697
|
-
|
|
693
|
+
def _rows(self, raw: Any, conf: Any) -> Any:
|
|
694
|
+
"""Yield parsed rows, turning a blank line inside the data into an empty row.
|
|
695
|
+
|
|
696
|
+
A blank line (no fields at all) is an all-``None`` row as wide as the
|
|
697
|
+
header, so ``CONFIG EMPTY_ROWS`` decides whether it is loaded, as it
|
|
698
|
+
does for a row of empty fields. Blank lines with no data after them
|
|
699
|
+
end the file, as they always have, so a trailing newline does not add
|
|
700
|
+
an empty row.
|
|
701
|
+
"""
|
|
702
|
+
width = None
|
|
703
|
+
pending_blank = 0
|
|
704
|
+
for elements in raw:
|
|
705
|
+
if not elements:
|
|
706
|
+
if width is not None:
|
|
707
|
+
pending_blank += 1
|
|
708
|
+
continue
|
|
709
|
+
if width is None:
|
|
710
|
+
width = len(elements)
|
|
711
|
+
for _ in range(pending_blank):
|
|
712
|
+
yield self._without_blank_cols([None] * width, conf)
|
|
713
|
+
pending_blank = 0
|
|
714
|
+
yield self._without_blank_cols(elements, conf)
|
|
715
|
+
|
|
716
|
+
def _without_blank_cols(self, elements: list, conf: Any) -> list:
|
|
717
|
+
if conf.del_empty_cols and len(self.blank_cols) > 0:
|
|
718
|
+
blanks = copy.copy(self.blank_cols)
|
|
719
|
+
while len(blanks) > 0:
|
|
720
|
+
b = blanks.pop()
|
|
721
|
+
del elements[b]
|
|
722
|
+
return elements
|
|
698
723
|
|
|
699
724
|
def _colhdrs(self, inf: Any) -> list[str]:
|
|
700
725
|
conf = _state.conf
|
|
@@ -764,6 +789,40 @@ class CsvFile(EncodedFile):
|
|
|
764
789
|
return self.table_data.create_table(database_type, schemaname, tablename, pretty)
|
|
765
790
|
|
|
766
791
|
|
|
792
|
+
def blank_lines_as_empty_rows(f: Any, delimiter: str, quotechar: str | None, columns: int, chunk_size: int) -> Any:
|
|
793
|
+
"""Yield the text of *f* in chunks of about *chunk_size*, with each blank line inside the data made an empty row.
|
|
794
|
+
|
|
795
|
+
For a reader of the raw file, such as PostgreSQL's COPY, which rejects a
|
|
796
|
+
blank line. The line becomes *columns* empty fields (``,,`` for three),
|
|
797
|
+
matching what the row-by-row reader loads; blank lines at the end of the
|
|
798
|
+
file are dropped, as there. A blank line inside a quoted value is part
|
|
799
|
+
of that value and is kept: quote characters are counted, and a doubled
|
|
800
|
+
quote (the only escape a raw CSV reader is given) counts twice.
|
|
801
|
+
"""
|
|
802
|
+
empty_row = delimiter * (columns - 1) + "\n"
|
|
803
|
+
in_quotes = False
|
|
804
|
+
pending_blank = 0
|
|
805
|
+
buf: list[str] = []
|
|
806
|
+
size = 0
|
|
807
|
+
for line in f:
|
|
808
|
+
if not in_quotes and line in ("\n", "\r\n"):
|
|
809
|
+
pending_blank += 1
|
|
810
|
+
continue
|
|
811
|
+
if pending_blank:
|
|
812
|
+
buf.append(empty_row * pending_blank)
|
|
813
|
+
size += len(empty_row) * pending_blank
|
|
814
|
+
pending_blank = 0
|
|
815
|
+
if quotechar and line.count(quotechar) % 2:
|
|
816
|
+
in_quotes = not in_quotes
|
|
817
|
+
buf.append(line)
|
|
818
|
+
size += len(line)
|
|
819
|
+
if size >= chunk_size:
|
|
820
|
+
yield "".join(buf)
|
|
821
|
+
buf, size = [], 0
|
|
822
|
+
if buf:
|
|
823
|
+
yield "".join(buf)
|
|
824
|
+
|
|
825
|
+
|
|
767
826
|
def write_delimited_file(
|
|
768
827
|
outfile: str,
|
|
769
828
|
filefmt: str,
|
execsql/exporters/xlsx.py
CHANGED
|
@@ -38,7 +38,9 @@ def _require_openpyxl() -> Any:
|
|
|
38
38
|
|
|
39
39
|
return openpyxl
|
|
40
40
|
except ImportError:
|
|
41
|
-
fatal_error(
|
|
41
|
+
fatal_error(
|
|
42
|
+
"The openpyxl library is needed to write Excel (.xlsx) spreadsheets (install the formats extra, execsql2[formats]).",
|
|
43
|
+
)
|
|
42
44
|
|
|
43
45
|
|
|
44
46
|
def _cell_value(item: Any) -> Any:
|
|
@@ -72,6 +74,19 @@ def _cell_value(item: Any) -> Any:
|
|
|
72
74
|
return str(item)
|
|
73
75
|
|
|
74
76
|
|
|
77
|
+
def _write_cell(ws: Any, row: int, column: int, value: Any) -> Any:
|
|
78
|
+
"""Write *value* to a cell as data, never as a formula, and return the cell.
|
|
79
|
+
|
|
80
|
+
openpyxl stores any string that starts with ``=`` as a formula, so text
|
|
81
|
+
from the database such as ``=1+1`` or ``=HYPERLINK(...)`` would be
|
|
82
|
+
evaluated when the workbook is opened. Such cells are stored as text.
|
|
83
|
+
"""
|
|
84
|
+
cell = ws.cell(row=row, column=column, value=value)
|
|
85
|
+
if cell.data_type == "f":
|
|
86
|
+
cell.data_type = "s"
|
|
87
|
+
return cell
|
|
88
|
+
|
|
89
|
+
|
|
75
90
|
# ---------------------------------------------------------------------------
|
|
76
91
|
# Public API
|
|
77
92
|
# ---------------------------------------------------------------------------
|
|
@@ -145,7 +160,7 @@ def write_query_to_xlsx(
|
|
|
145
160
|
("datasheet_name", "created_on", "created_by", "description", "source"),
|
|
146
161
|
start=1,
|
|
147
162
|
):
|
|
148
|
-
cell = inv_ws
|
|
163
|
+
cell = _write_cell(inv_ws, 1, col_idx, hdr)
|
|
149
164
|
cell.font = bold_font
|
|
150
165
|
else:
|
|
151
166
|
inv_ws = wb[inventory_name]
|
|
@@ -158,13 +173,13 @@ def write_query_to_xlsx(
|
|
|
158
173
|
|
|
159
174
|
# Header row
|
|
160
175
|
for col_idx, hdr in enumerate(hdrs, start=1):
|
|
161
|
-
cell = ws
|
|
176
|
+
cell = _write_cell(ws, 1, col_idx, str(hdr))
|
|
162
177
|
cell.font = bold_font
|
|
163
178
|
|
|
164
179
|
# Data rows
|
|
165
180
|
for row_idx, row in enumerate(rows, start=2):
|
|
166
181
|
for col_idx, item in enumerate(row, start=1):
|
|
167
|
-
ws
|
|
182
|
+
_write_cell(ws, row_idx, col_idx, _cell_value(item))
|
|
168
183
|
|
|
169
184
|
# ------------------------------------------------------------------
|
|
170
185
|
# Update inventory sheet
|
|
@@ -185,7 +200,7 @@ def write_query_to_xlsx(
|
|
|
185
200
|
),
|
|
186
201
|
start=1,
|
|
187
202
|
):
|
|
188
|
-
inv_ws
|
|
203
|
+
_write_cell(inv_ws, next_row, col_idx, value)
|
|
189
204
|
|
|
190
205
|
wb.save(outfile)
|
|
191
206
|
wb.close()
|
|
@@ -256,7 +271,7 @@ def write_queries_to_xlsx(
|
|
|
256
271
|
("datasheet_name", "created_on", "created_by", "description", "source"),
|
|
257
272
|
start=1,
|
|
258
273
|
):
|
|
259
|
-
cell = inv_ws
|
|
274
|
+
cell = _write_cell(inv_ws, 1, col_idx, hdr)
|
|
260
275
|
cell.font = bold_font
|
|
261
276
|
else:
|
|
262
277
|
inv_ws = wb[inventory_name]
|
|
@@ -297,12 +312,12 @@ def write_queries_to_xlsx(
|
|
|
297
312
|
ws = wb.create_sheet(sheet_name)
|
|
298
313
|
|
|
299
314
|
for col_idx, hdr in enumerate(hdrs, start=1):
|
|
300
|
-
cell = ws
|
|
315
|
+
cell = _write_cell(ws, 1, col_idx, str(hdr))
|
|
301
316
|
cell.font = bold_font
|
|
302
317
|
|
|
303
318
|
for row_idx, row in enumerate(rows, start=2):
|
|
304
319
|
for col_idx, item in enumerate(row, start=1):
|
|
305
|
-
ws
|
|
320
|
+
_write_cell(ws, row_idx, col_idx, _cell_value(item))
|
|
306
321
|
|
|
307
322
|
# Determine per-sheet description.
|
|
308
323
|
if desc is None:
|
|
@@ -326,7 +341,7 @@ def write_queries_to_xlsx(
|
|
|
326
341
|
),
|
|
327
342
|
start=1,
|
|
328
343
|
):
|
|
329
|
-
inv_ws
|
|
344
|
+
_write_cell(inv_ws, next_row, col_idx, value)
|
|
330
345
|
|
|
331
346
|
if tee and outfile.lower() != "stdout":
|
|
332
347
|
prettyprint_query(select_stmt, db, "stdout", False, desc=d)
|
execsql/exporters/zip.py
CHANGED
|
@@ -54,8 +54,7 @@ class WriteableZipfile:
|
|
|
54
54
|
date_time=time.localtime(time.time())[:6],
|
|
55
55
|
)
|
|
56
56
|
self.current_zinfo.compress_type = self.zf.compression
|
|
57
|
-
|
|
58
|
-
self.current_zinfo._compresslevel = self.zf.compresslevel # type: ignore[attr-defined]
|
|
57
|
+
self.current_zinfo._compresslevel = self.zf.compresslevel # type: ignore[attr-defined]
|
|
59
58
|
# See https://stackoverflow.com/questions/434641/how-do-i-set-permissions-attributes-on-a-file-in-a-zip-file-using-pythons-zip
|
|
60
59
|
self.current_zinfo.external_attr = 0o100755 << 16 # ?rw-rw-rw-
|
|
61
60
|
if sys.platform.startswith("win"):
|