execsql2 2.16.18__py3-none-any.whl → 2.17.2__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 (74) hide show
  1. execsql/__init__.py +6 -2
  2. execsql/api.py +25 -6
  3. execsql/cli/__init__.py +5 -3
  4. execsql/cli/lint.py +30 -34
  5. execsql/cli/run.py +10 -0
  6. execsql/config.py +145 -92
  7. execsql/db/access.py +54 -40
  8. execsql/db/base.py +33 -6
  9. execsql/db/firebird.py +3 -1
  10. execsql/db/mysql.py +4 -3
  11. execsql/db/oracle.py +36 -14
  12. execsql/db/postgres.py +8 -6
  13. execsql/db/sqlite.py +5 -2
  14. execsql/db/sqlserver.py +8 -6
  15. execsql/debug/repl.py +59 -21
  16. execsql/exceptions.py +19 -4
  17. execsql/exporters/base.py +3 -2
  18. execsql/exporters/delimited.py +2 -3
  19. execsql/exporters/feather.py +3 -3
  20. execsql/exporters/ods.py +1 -1
  21. execsql/exporters/xls.py +12 -4
  22. execsql/exporters/xlsx.py +1 -1
  23. execsql/gui/desktop.py +129 -15
  24. execsql/importers/__init__.py +1 -1
  25. execsql/importers/ods.py +1 -1
  26. execsql/importers/xls.py +1 -1
  27. execsql/metacommands/__init__.py +34 -5
  28. execsql/metacommands/conditions.py +26 -14
  29. execsql/metacommands/connect.py +21 -14
  30. execsql/metacommands/control.py +55 -68
  31. execsql/metacommands/data.py +25 -9
  32. execsql/metacommands/debug.py +132 -77
  33. execsql/metacommands/io_export.py +14 -2
  34. execsql/metacommands/io_import.py +11 -2
  35. execsql/metacommands/io_write.py +113 -11
  36. execsql/metacommands/prompt.py +46 -32
  37. execsql/metacommands/script_ext.py +63 -34
  38. execsql/metacommands/system.py +4 -3
  39. execsql/metacommands/upsert.py +0 -29
  40. execsql/script/__init__.py +28 -37
  41. execsql/script/ast.py +7 -7
  42. execsql/script/control.py +4 -101
  43. execsql/script/engine.py +37 -251
  44. execsql/script/executor.py +193 -230
  45. execsql/script/parser.py +1 -3
  46. execsql/script/variables.py +8 -3
  47. execsql/state.py +125 -37
  48. execsql/utils/errors.py +0 -2
  49. execsql/utils/fileio.py +47 -3
  50. execsql/utils/mail.py +3 -2
  51. execsql/utils/strings.py +5 -5
  52. {execsql2-2.16.18.dist-info → execsql2-2.17.2.dist-info}/METADATA +42 -36
  53. execsql2-2.17.2.dist-info/RECORD +124 -0
  54. execsql2-2.17.2.dist-info/licenses/NOTICE +11 -0
  55. execsql2-2.16.18.dist-info/RECORD +0 -124
  56. execsql2-2.16.18.dist-info/licenses/NOTICE +0 -10
  57. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/README.md +0 -0
  58. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  59. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  60. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/execsql.conf +0 -0
  61. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/make_config_db.sql +0 -0
  62. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/md_compare.sql +0 -0
  63. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/md_glossary.sql +0 -0
  64. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/md_upsert.sql +0 -0
  65. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/pg_compare.sql +0 -0
  66. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  67. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  68. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/script_template.sql +0 -0
  69. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/ss_compare.sql +0 -0
  70. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  71. {execsql2-2.16.18.data → execsql2-2.17.2.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  72. {execsql2-2.16.18.dist-info → execsql2-2.17.2.dist-info}/WHEEL +0 -0
  73. {execsql2-2.16.18.dist-info → execsql2-2.17.2.dist-info}/entry_points.txt +0 -0
  74. {execsql2-2.16.18.dist-info → execsql2-2.17.2.dist-info}/licenses/LICENSE.txt +0 -0
@@ -3,12 +3,24 @@ from __future__ import annotations
3
3
  """
4
4
  Debug metacommand handlers for execsql.
5
5
 
6
- Provides ``x_debug_write_metacommands``, which implements the
7
- ``WRITE METACOMMANDS`` debug metacommand that prints the full registered
8
- metacommand list to the log/console for troubleshooting.
9
-
10
- Also provides ``x_show_scripts`` for runtime introspection of registered
11
- SCRIPT blocks.
6
+ Handlers for the ``DEBUG …`` family of introspection metacommands and
7
+ ``SHOW SCRIPTS`` script-block listing:
8
+
9
+ - ``x_debug_log_subvars`` / ``x_debug_write_subvars`` — dump substitution
10
+ variables to the log file or to terminal/file (``DEBUG LOG SUBVARS`` /
11
+ ``DEBUG WRITE SUBVARS``). Both accept optional ``LOCAL`` / ``USER``
12
+ qualifiers.
13
+ - ``x_debug_log_config`` / ``x_debug_write_config`` — dump the merged
14
+ configuration.
15
+ - ``x_debug_write_odbc_drivers`` — list installed ODBC drivers.
16
+ - ``x_debug_write_metacommands`` — dump the registered metacommand list
17
+ (``DEBUG WRITE METACOMMANDLIST TO <file>``).
18
+ - ``x_debug_commandliststack`` — dump the current execution stack
19
+ (``DEBUG WRITE COMMANDLISTSTACK``).
20
+ - ``x_debug_iflevels`` — dump the nested IF condition state
21
+ (``DEBUG WRITE IFLEVELS``).
22
+ - ``x_show_scripts`` — list registered ``BEGIN SCRIPT`` blocks
23
+ (``SHOW SCRIPTS [<name>]``), with optional name argument for detail.
12
24
  """
13
25
 
14
26
  from pathlib import Path
@@ -34,23 +46,65 @@ def x_debug_write_metacommands(**kwargs: Any) -> None:
34
46
 
35
47
 
36
48
  def x_debug_commandliststack(**kwargs: Any) -> None:
37
- _state.output.write("Command List Stack:\n")
38
- pfx = " "
39
- for cl in _state.commandliststack:
40
- _state.output.write(pfx + f"Name: {cl.listname}\n")
41
- _state.output.write(pfx + f"Parameters: {cl.paramnames}\n")
42
- _state.output.write(pfx + f"Command pointer: {cl.cmdptr}\n")
43
- pfx = pfx + " "
49
+ """Dump the unified AST execution stack.
50
+
51
+ Shows every nesting construct the executor is currently inside:
52
+ ``<main>`` script, ``EXECUTE SCRIPT`` calls, ``INCLUDE``'d files,
53
+ ``IF``/``ELSEIF``/``ELSE`` branches, ``LOOP`` iterations (with iteration
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.
59
+ """
60
+ stack = getattr(_state, "ast_exec_stack", None) or []
61
+ _state.output.write(f"Execution Stack (depth: {len(stack)}):\n")
62
+ if not stack:
63
+ _state.output.write(" (empty)\n")
64
+ return None
65
+
66
+ for depth, frame in enumerate(stack):
67
+ kind_label = frame.kind.upper().replace("LOOP_", "LOOP ")
68
+ # Right-hand description per kind
69
+ if frame.kind in ("if", "elseif"):
70
+ desc = f"{kind_label} {frame.label}"
71
+ elif frame.kind == "else":
72
+ desc = kind_label
73
+ elif frame.kind in ("loop_while", "loop_until"):
74
+ desc = f"{kind_label} {frame.label} iter={frame.iteration}"
75
+ elif frame.kind == "script":
76
+ params = ""
77
+ if frame.params:
78
+ params = "(" + ", ".join(f"{k}={v!r}" for k, v in frame.params.items()) + ")"
79
+ iter_suffix = f" iter={frame.iteration}" if frame.iteration else ""
80
+ desc = f"SCRIPT {frame.label}{params}{iter_suffix}"
81
+ elif frame.kind == "include":
82
+ desc = f"INCLUDE {frame.label}"
83
+ elif frame.kind == "batch":
84
+ desc = "BATCH"
85
+ elif frame.kind == "main":
86
+ desc = frame.label or "<main>"
87
+ else:
88
+ desc = f"{kind_label} {frame.label}"
89
+ src = f" {frame.source}:{frame.line}" if frame.source and frame.line else ""
90
+ _state.output.write(f" [{depth}] {desc}{src}\n")
44
91
  return None
45
92
 
46
93
 
47
94
  def x_debug_iflevels(**kwargs: Any) -> None:
48
- if len(_state.if_stack.if_levels) == 0:
49
- _state.output.write("If levels: None\n")
50
- else:
51
- _state.output.write(
52
- "If levels: [{}]\n".format(",".join([str(tf.tf_value) for tf in _state.if_stack.if_levels])),
53
- )
95
+ # Filter the unified AST exec stack to just IF/ELSEIF/ELSE frames.
96
+ stack = getattr(_state, "ast_exec_stack", None) or []
97
+ if_frames = [f for f in stack if f.kind in ("if", "elseif", "else")]
98
+ if not if_frames:
99
+ _state.output.write("If levels: (no active IF block)\n")
100
+ return None
101
+
102
+ _state.output.write(f"If levels (depth {len(if_frames)}):\n")
103
+ for depth, frame in enumerate(if_frames):
104
+ kind_label = frame.kind.upper()
105
+ label = f"{kind_label} {frame.label}".strip() if frame.label else kind_label
106
+ src = f" {frame.source}:{frame.line}" if frame.source and frame.line else ""
107
+ _state.output.write(f" [{depth}] {label}{src}\n")
54
108
  return None
55
109
 
56
110
 
@@ -61,13 +115,13 @@ def x_debug_write_odbc_drivers(**kwargs: Any) -> None:
61
115
  fatal_error("The pyodbc module is required.")
62
116
  output_dest = kwargs["filename"]
63
117
  append = kwargs["append"]
118
+ if output_dest is not None and output_dest != "stdout" and append is None:
119
+ filewriter_open_as_new(output_dest)
64
120
 
65
121
  def write(txt: str) -> None:
66
122
  if output_dest is None or output_dest == "stdout":
67
123
  _state.output.write(txt)
68
124
  else:
69
- if not append:
70
- filewriter_open_as_new(output_dest)
71
125
  filewriter_write(output_dest, txt)
72
126
 
73
127
  for d in pyodbc.drivers():
@@ -77,8 +131,9 @@ def x_debug_write_odbc_drivers(**kwargs: Any) -> None:
77
131
  def x_debug_log_subvars(**kwargs: Any) -> None:
78
132
  local = kwargs["local"]
79
133
  user = kwargs["user"]
80
- if _state.commandliststack:
81
- for s in _state.commandliststack[-1].localvars.substitutions:
134
+ localvars = _state.current_localvars()
135
+ if localvars is not None:
136
+ for s in localvars.substitutions:
82
137
  _state.exec_log.log_status_info(f"Substitution [{s[0]}] = [{s[1]}]")
83
138
  if local is None:
84
139
  for s in _state.subvars.substitutions:
@@ -86,51 +141,57 @@ def x_debug_log_subvars(**kwargs: Any) -> None:
86
141
  _state.exec_log.log_status_info(f"Substitution [{s[0]}] = [{s[1]}]")
87
142
 
88
143
 
89
- def x_debug_log_config(**kwargs: Any) -> None:
144
+ _SENSITIVE_ATTRS = frozenset({"smtp_password", "passwd", "password"})
145
+
146
+
147
+ def _config_lines() -> list[str]:
148
+ """Return the merged config as a list of ``"[<section>] <key> = <value>"`` lines.
149
+
150
+ Pulled directly from ``ConfigData._schema``, the registry that ``ConfigData``
151
+ populates each time it reads an option from the INI files. Adding a new
152
+ option to ``ConfigData.__init__`` automatically makes it appear here —
153
+ nothing in this file needs updating.
154
+
155
+ Lines are grouped by section in the order each section was first registered
156
+ so the dump matches the natural reading order of ``execsql.conf``.
157
+
158
+ Sensitive values (passwords) are redacted.
159
+ """
160
+ from execsql.config import ConfigData
161
+
90
162
  conf = _state.conf
91
- _state.exec_log.log_status_info(f"Config; Script encoding = {conf.script_encoding}")
92
- _state.exec_log.log_status_info(f"Config; Output encoding = {conf.output_encoding}")
93
- _state.exec_log.log_status_info(f"Config; Import encoding = {conf.import_encoding}")
94
- _state.exec_log.log_status_info(f"Config; Import common columns only = {conf.import_common_cols_only}")
95
- _state.exec_log.log_status_info(f"Config; Use numeric type for Access = {conf.access_use_numeric}")
96
- _state.exec_log.log_status_info(f"Config; Max int = {conf.max_int}")
97
- _state.exec_log.log_status_info(f"Config; Boolean int = {conf.boolean_int}")
98
- _state.exec_log.log_status_info(f"Config; Boolean words = {conf.boolean_words}")
99
- _state.exec_log.log_status_info(f"Config; Clean column headers {conf.clean_col_hdrs}")
100
- _state.exec_log.log_status_info(f"Config; Create column headers {conf.create_col_hdrs}")
101
- _state.exec_log.log_status_info(f"Config; Dedup column headers {conf.dedup_col_hdrs}")
102
- _state.exec_log.log_status_info(f"Config; Console wait when done {conf.gui_wait_on_exit}")
103
- _state.exec_log.log_status_info(f"Config; Console wait when error {conf.gui_wait_on_error_halt}")
104
- _state.exec_log.log_status_info(f"Config; Empty rows = {conf.empty_rows}")
105
- _state.exec_log.log_status_info(f"Config; Empty_strings = {conf.empty_strings}")
106
- _state.exec_log.log_status_info(f"Config: Trim_strings = {conf.trim_strings}")
107
- _state.exec_log.log_status_info(f"Config: Replace_newlines = {conf.replace_newlines}")
108
- _state.exec_log.log_status_info(f"Config; Only_strings = {conf.only_strings}")
109
- _state.exec_log.log_status_info(f"Config; Scan lines = {conf.scan_lines}")
110
- _state.exec_log.log_status_info(f"Config; Import row buffer size = {conf.import_row_buffer}")
111
- _state.exec_log.log_status_info(f"Config; Write warnings to console = {conf.write_warnings}")
112
- _state.exec_log.log_status_info(f"Config; Write prefix = {conf.write_prefix}")
113
- _state.exec_log.log_status_info(f"Config; Write suffix = {conf.write_suffix}")
114
- _state.exec_log.log_status_info(f"Config; Log write messages = {conf.tee_write_log}")
115
- _state.exec_log.log_status_info(f"Config; Log data variable assignments = {conf.log_datavars}")
116
- _state.exec_log.log_status_info(f"Config; GUI level = {conf.gui_level}")
117
- _state.exec_log.log_status_info(f"Config; CSS file for HTML export = {conf.css_file}")
118
- _state.exec_log.log_status_info(f"Config; CSS styles for HTML export = {conf.css_styles}")
119
- _state.exec_log.log_status_info(f"Config; Make export directories = {conf.make_export_dirs}")
120
- _state.exec_log.log_status_info(f"Config; Quote all text on export = {conf.quote_all_text}")
121
- _state.exec_log.log_status_info(f"Config; Export row buffer size = {conf.export_row_buffer}")
122
- _state.exec_log.log_status_info(f"Config; Text length for HDF5 export = {conf.hdf5_text_len}")
123
- _state.exec_log.log_status_info(f"Config; Template processor = {conf.template_processor}")
124
- _state.exec_log.log_status_info(f"Config; SMTP host = {conf.smtp_host}")
125
- _state.exec_log.log_status_info(f"Config; SMTP port = {conf.smtp_port}")
126
- _state.exec_log.log_status_info(f"Config; SMTP username = {conf.smtp_username}")
127
- _state.exec_log.log_status_info(f"Config; SMTP use SSL = {conf.smtp_ssl}")
128
- _state.exec_log.log_status_info(f"Config; SMTP use TLS = {conf.smtp_tls}")
129
- _state.exec_log.log_status_info(f"Config; Email format = {conf.email_format}")
130
- _state.exec_log.log_status_info(f"Config; Email CSS = {conf.email_css}")
131
- _state.exec_log.log_status_info(f"Config; Zip buffer size (Mb) = {conf.zip_buffer_mb}")
132
- _state.exec_log.log_status_info(f"Config; DAO flush delay (seconds) = {conf.dao_flush_delay_secs}")
133
- _state.exec_log.log_status_info(f"Config; Configuration files read = {', '.join(conf.files_read)}")
163
+ if conf is None:
164
+ return ["(no config loaded)"]
165
+
166
+ # Group attrs by section, preserving registration order within each section.
167
+ by_section: dict[str, list[tuple[str, str, str]]] = {}
168
+ section_order: list[str] = []
169
+ for attr, (section, ini_key, type_label) in ConfigData._schema.items():
170
+ if section not in by_section:
171
+ by_section[section] = []
172
+ section_order.append(section)
173
+ by_section[section].append((attr, ini_key, type_label))
174
+
175
+ lines: list[str] = []
176
+ for section in section_order:
177
+ lines.append(f"[{section}]")
178
+ for attr, ini_key, _type_label in by_section[section]:
179
+ value = getattr(conf, attr, "(unset)")
180
+ if attr in _SENSITIVE_ATTRS and value:
181
+ value = "***"
182
+ lines.append(f" {ini_key} = {value} (attr: {attr})")
183
+ lines.append("")
184
+
185
+ # Runtime-computed values that aren't read from the INI file.
186
+ lines.append("[runtime]")
187
+ files = getattr(conf, "files_read", None) or []
188
+ lines.append(f" files_read = {', '.join(files) if files else '(none)'}")
189
+ return lines
190
+
191
+
192
+ def x_debug_log_config(**kwargs: Any) -> None:
193
+ for line in _config_lines():
194
+ _state.exec_log.log_status_info(line)
134
195
 
135
196
 
136
197
  def x_debug_write_subvars(**kwargs: Any) -> None:
@@ -147,8 +208,9 @@ def x_debug_write_subvars(**kwargs: Any) -> None:
147
208
  else:
148
209
  filewriter_write(output_dest, txt)
149
210
 
150
- if _state.commandliststack:
151
- for s in _state.commandliststack[-1].localvars.substitutions:
211
+ localvars = _state.current_localvars()
212
+ if localvars is not None:
213
+ for s in localvars.substitutions:
152
214
  write(f"Substitution [{s[0]}] = [{s[1]}]\n")
153
215
  if local is None:
154
216
  for s in _state.subvars.substitutions:
@@ -159,13 +221,6 @@ def x_debug_write_subvars(**kwargs: Any) -> None:
159
221
  def x_debug_write_config(**kwargs: Any) -> None:
160
222
  output_dest = kwargs["filename"]
161
223
  append = kwargs["append"]
162
- conf = _state.conf
163
- lines = [
164
- f"Config; Script encoding = {conf.script_encoding}",
165
- f"Config; Output encoding = {conf.output_encoding}",
166
- f"Config; Import encoding = {conf.import_encoding}",
167
- f"Config; GUI level = {conf.gui_level}",
168
- ]
169
224
  if output_dest is not None and output_dest != "stdout" and append is None:
170
225
  filewriter_open_as_new(output_dest)
171
226
 
@@ -175,7 +230,7 @@ def x_debug_write_config(**kwargs: Any) -> None:
175
230
  else:
176
231
  filewriter_write(output_dest, txt)
177
232
 
178
- for line in lines:
233
+ for line in _config_lines():
179
234
  write(f"{line}\n")
180
235
 
181
236
 
@@ -1,7 +1,19 @@
1
1
  """Export metacommand handlers.
2
2
 
3
- Implements ``x_export``, ``x_export_query``, template-based exports,
4
- ODS multi-sheet export, and export metadata operations.
3
+ - ``x_export`` EXPORT (table → file in any supported format).
4
+ - ``x_export_query`` EXPORT QUERY (ad-hoc SELECT → file).
5
+ - ``x_export_with_template`` / ``x_export_query_with_template`` —
6
+ template-rendered EXPORT (Jinja or string.Template).
7
+ - ``x_export_ods_multiple`` / ``x_export_xlsx_multiple`` — multi-sheet
8
+ ODS / XLSX workbooks.
9
+ - ``x_export_metadata`` / ``x_export_metadata_table`` — EXPORT_METADATA
10
+ (dump the in-process export-record log).
11
+ - ``x_export_row_buffer`` — CONFIG EXPORT_ROW_BUFFER (set the per-export
12
+ row-buffer size).
13
+
14
+ Per-format dispatch happens inside ``x_export``'s
15
+ ``if filefmt in (...): ... elif ...`` chain, which calls the
16
+ appropriate writer in :mod:`execsql.exporters`.
5
17
  """
6
18
 
7
19
  from __future__ import annotations
@@ -1,7 +1,16 @@
1
1
  """Import metacommand handlers.
2
2
 
3
- Implements ``x_import``, ``x_import_file``, ODS/XLS/Parquet/Feather
4
- import handlers, and the import row buffer setting.
3
+ - ``x_import`` — IMPORT (delimited text / CSV; dispatches by extension
4
+ for .ods/.xls/.xlsx in the same handler).
5
+ - ``x_import_file`` — IMPORT_FILE (insert a binary file blob as a row).
6
+ - ``x_import_ods`` / ``x_import_ods_pattern`` — IMPORT … FROM ODS,
7
+ single-sheet and SHEETS MATCHING <pattern>.
8
+ - ``x_import_xls`` / ``x_import_xls_pattern`` — same for XLS/XLSX.
9
+ - ``x_import_parquet`` — IMPORT … FROM PARQUET (via polars).
10
+ - ``x_import_feather`` — IMPORT … FROM FEATHER (via polars).
11
+ - ``x_import_json`` — IMPORT … FROM JSON (array of objects or NDJSON).
12
+ - ``x_import_row_buffer`` — CONFIG IMPORT_ROW_BUFFER.
13
+ - ``x_show_progress`` — CONFIG SHOW_PROGRESS (toggle the import progress bar).
5
14
  """
6
15
 
7
16
  from __future__ import annotations
@@ -1,7 +1,14 @@
1
1
  """WRITE metacommand handlers.
2
2
 
3
- Implements ``x_write``, ``x_write_create_table`` (CSV, ODS, XLS, alias),
4
- ``x_write_prefix``, ``x_write_suffix``, and ``x_writescript``.
3
+ - ``x_write`` WRITE "<text>" [TEE TO <file>] (text output).
4
+ - ``x_writescript`` WRITE SCRIPT <name> [TO <file>] (dump a named
5
+ script block).
6
+ - ``x_write_create_table`` — WRITE CREATE_TABLE … (emit a CREATE TABLE
7
+ statement inferred from a data source); plus the format-specific
8
+ variants ``x_write_create_table_ods``, ``x_write_create_table_xls``,
9
+ ``x_write_create_table_alias``.
10
+ - ``x_write_prefix`` / ``x_write_suffix`` — CONFIG WRITE_PREFIX /
11
+ CONFIG WRITE_SUFFIX (set text prepended / appended to every WRITE).
5
12
  """
6
13
 
7
14
  from __future__ import annotations
@@ -222,11 +229,107 @@ def x_write_suffix(**kwargs: Any) -> None:
222
229
  return None
223
230
 
224
231
 
232
+ def _render_script_nodes(nodes: Any, emit: Any) -> None:
233
+ """Walk a list of AST nodes and emit reconstructed source lines.
234
+
235
+ Every node type that can legally appear in a ``ScriptBlock.body`` is
236
+ handled — nested ``IfBlock`` / ``LoopBlock`` / ``BatchBlock`` /
237
+ ``SqlBlock`` structures render with their delimiters; ``Comment`` and
238
+ ``IncludeDirective`` (``INCLUDE`` / ``EXECUTE SCRIPT``) are preserved.
239
+ Unknown node types raise ``ErrInfo`` rather than silently disappear so
240
+ future AST additions can't quietly cause data loss.
241
+ """
242
+ from execsql.exceptions import ErrInfo
243
+ from execsql.script.ast import (
244
+ BatchBlock,
245
+ Comment,
246
+ IfBlock,
247
+ IncludeDirective,
248
+ LoopBlock,
249
+ MetaCommandStatement,
250
+ ScriptBlock,
251
+ SqlBlock,
252
+ SqlStatement,
253
+ )
254
+
255
+ for node in nodes:
256
+ if isinstance(node, SqlStatement):
257
+ emit(f"{node.text}\n")
258
+ elif isinstance(node, MetaCommandStatement):
259
+ emit(f"-- !x! {node.command}\n")
260
+ elif isinstance(node, Comment):
261
+ # Preserve comment text verbatim; the parser stores it including
262
+ # the leading "--" / "/* ... */" delimiters.
263
+ emit(f"{node.text}\n")
264
+ elif isinstance(node, IncludeDirective):
265
+ kw = "EXECUTE SCRIPT" if node.is_execute_script else "INCLUDE"
266
+ if_exists = " IF EXISTS" if node.if_exists else ""
267
+ args = f" ({node.arguments})" if node.arguments else ""
268
+ loop = f" LOOP {node.loop_type} ({node.loop_condition})" if node.loop_type else ""
269
+ emit(f"-- !x! {kw}{if_exists} {node.target}{args}{loop}\n")
270
+ elif isinstance(node, IfBlock):
271
+ emit(f"-- !x! IF ({node.condition})\n")
272
+ for mod in node.condition_modifiers:
273
+ kw = "ANDIF" if mod.kind == "AND" else "ORIF"
274
+ emit(f"-- !x! {kw} ({mod.condition})\n")
275
+ _render_script_nodes(node.body, emit)
276
+ for clause in node.elseif_clauses:
277
+ emit(f"-- !x! ELSEIF ({clause.condition})\n")
278
+ for mod in clause.condition_modifiers:
279
+ kw = "ANDIF" if mod.kind == "AND" else "ORIF"
280
+ emit(f"-- !x! {kw} ({mod.condition})\n")
281
+ _render_script_nodes(clause.body, emit)
282
+ if node.else_body:
283
+ emit("-- !x! ELSE\n")
284
+ _render_script_nodes(node.else_body, emit)
285
+ emit("-- !x! ENDIF\n")
286
+ elif isinstance(node, LoopBlock):
287
+ emit(f"-- !x! LOOP {node.loop_type} ({node.condition})\n")
288
+ _render_script_nodes(node.body, emit)
289
+ emit("-- !x! END LOOP\n")
290
+ elif isinstance(node, BatchBlock):
291
+ emit("-- !x! BEGIN BATCH\n")
292
+ _render_script_nodes(node.body, emit)
293
+ emit("-- !x! END BATCH\n")
294
+ elif isinstance(node, SqlBlock):
295
+ emit("-- !x! BEGIN SQL\n")
296
+ _render_script_nodes(node.body, emit)
297
+ emit("-- !x! END SQL\n")
298
+ elif isinstance(node, ScriptBlock):
299
+ # Nested SCRIPT definition — emit recursively with its own
300
+ # BEGIN/END SCRIPT framing.
301
+ param_names = node.param_names
302
+ if param_names:
303
+ emit(f"-- !x! BEGIN SCRIPT {node.name} ({', '.join(param_names)})\n")
304
+ else:
305
+ emit(f"-- !x! BEGIN SCRIPT {node.name}\n")
306
+ _render_script_nodes(node.body, emit)
307
+ emit(f"-- !x! END SCRIPT {node.name}\n")
308
+ else:
309
+ raise ErrInfo(
310
+ type="cmd",
311
+ other_msg=f"WRITE SCRIPT cannot render AST node of type {type(node).__name__}.",
312
+ )
313
+
314
+
225
315
  def x_writescript(**kwargs: Any) -> None:
226
- script_id = kwargs["script_id"]
316
+ """Dump a registered SCRIPT block's source to stdout or a file.
317
+
318
+ Reads from the AST script registry (``ctx.ast_scripts``) and walks the
319
+ full block tree — including nested IF / LOOP / BATCH structures —
320
+ reconstructing source between ``BEGIN SCRIPT`` and ``END SCRIPT``
321
+ delimiters so the output is re-includable.
322
+ """
323
+ from execsql.exceptions import ErrInfo
324
+
325
+ script_id = kwargs["script_id"].lower()
227
326
  output_dest = kwargs["filename"]
228
327
  append = kwargs["append"]
229
328
 
329
+ block = _state.ast_scripts.get(script_id)
330
+ if block is None:
331
+ raise ErrInfo("cmd", other_msg=f"There is no SCRIPT named {script_id}.")
332
+
230
333
  def write(txt: str) -> None:
231
334
  if output_dest is None or output_dest == "stdout":
232
335
  _state.output.write(txt)
@@ -237,12 +340,11 @@ def x_writescript(**kwargs: Any) -> None:
237
340
  check_dir(output_dest)
238
341
  if not append:
239
342
  filewriter_open_as_new(output_dest)
240
- script = _state.savedscripts[script_id]
241
- if script.paramnames is not None and len(script.paramnames) > 0:
242
- write(f"BEGIN SCRIPT {script_id} ({', '.join(script.paramnames)})\n")
343
+
344
+ param_names = block.param_names
345
+ if param_names:
346
+ write(f"-- !x! BEGIN SCRIPT {script_id} ({', '.join(param_names)})\n")
243
347
  else:
244
- write(f"BEGIN SCRIPT {script_id}\n")
245
- lines = [c.commandline() for c in script.cmdlist]
246
- for line in lines:
247
- write(f"{line}\n")
248
- write(f"END SCRIPT {script_id}\n")
348
+ write(f"-- !x! BEGIN SCRIPT {script_id}\n")
349
+ _render_script_nodes(block.body, write)
350
+ write(f"-- !x! END SCRIPT {script_id}\n")