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/debug/repl.py CHANGED
@@ -1,49 +1,34 @@
1
1
  from __future__ import annotations
2
2
 
3
- """Interactive debug REPL metacommand handler for execsql.
3
+ """The debug REPL a ``BREAKPOINT`` (or ``run --debug``) opens.
4
4
 
5
- Implements ``x_breakpoint`` — the ``BREAKPOINT`` metacommand — which pauses
6
- script execution and drops into an interactive read-eval-print loop.
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
- The REPL allows the user to:
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
- - Inspect and print substitution variables.
11
- - Run ad-hoc SQL queries against the current database.
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 os.environ.get("NO_COLOR") is not None or os.environ.get("EXECSQL_NO_COLOR") is not None:
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 partial multi-line SQL buffer"),
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 — multi-line accepted, terminate with ';' to execute"),
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 commands from stdin until the user types ``.continue`` or ``.abort``,
200
- or until EOF / KeyboardInterrupt.
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
- sql_buffer: list[str] = []
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
- if not line:
257
- continue
258
-
259
- try:
260
- if line.startswith("."):
261
- cmd = line[1:].strip().lower()
262
- if cmd == "cancel":
263
- if sql_buffer:
264
- sql_buffer.clear()
265
- _write(" (input discarded)\n")
266
- continue
267
- _handle_dot_command(line)
268
- if cmd in ("continue", "c"):
269
- return
270
- if cmd in ("abort", "q", "quit"):
271
- return
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
- elif cmd in ("help", "h"):
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 cmd in ("vars", "v"):
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 cmd == "stack":
248
+ elif command == "stack":
323
249
  _print_stack()
324
- elif cmd in ("next", "n"):
325
- _enable_step_mode()
326
- elif cmd.startswith("set ") or cmd == "set":
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`` (a :class:`ScriptCmd`) and displays the
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 _run_sql(sql: str) -> None:
535
- """Execute ad-hoc SQL and pretty-print results or affected rowcount."""
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
 
@@ -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__ = ["LineDelimiter", "CsvFile", "CsvWriter", "DelimitedWriter", "write_delimited_file"]
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 elements in csv_reader:
657
- if len(elements) == 0:
658
- break
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
- line_no = 0
674
- try:
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
- except:
682
- raise
683
- if len(elements) > 0:
684
- if conf.del_empty_cols and len(self.blank_cols) > 0:
685
- blanks = copy.copy(self.blank_cols)
686
- while len(blanks) > 0:
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 writer(self, append: bool = False) -> CsvWriter:
696
- """Return a :class:`CsvWriter` configured with this file's format settings."""
697
- return CsvWriter(self.filename, self.encoding, self.delimiter, self.quotechar, self.escapechar, append)
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("The openpyxl library is needed to write Excel (.xlsx) spreadsheets (install execsql2[excel]).")
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.cell(row=1, column=col_idx, value=hdr)
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.cell(row=1, column=col_idx, value=str(hdr))
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.cell(row=row_idx, column=col_idx, value=_cell_value(item))
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.cell(row=next_row, column=col_idx, value=value)
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.cell(row=1, column=col_idx, value=hdr)
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.cell(row=1, column=col_idx, value=str(hdr))
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.cell(row=row_idx, column=col_idx, value=_cell_value(item))
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.cell(row=next_row, column=col_idx, value=value)
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
- if sys.version_info.major >= 3 and sys.version_info.minor >= 7:
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"):