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
execsql/gui/desktop.py CHANGED
@@ -849,7 +849,7 @@ class ActionDialog:
849
849
 
850
850
 
851
851
  # ---------------------------------------------------------------------------
852
- # MapDialog — shows tabular data (no interactive map without tkintermapview)
852
+ # MapDialog — interactive map via tkintermapview (tabular fallback if missing)
853
853
  # ---------------------------------------------------------------------------
854
854
 
855
855
 
@@ -870,22 +870,31 @@ class MapDialog:
870
870
  anchor="w",
871
871
  pady=(0, 4),
872
872
  )
873
- ttk.Label(frame, text="(Interactive map requires tkintermapview; showing tabular data)").pack(
874
- anchor="w",
875
- pady=(0, 8),
876
- )
877
873
 
878
874
  headers = args.get("headers", [])
879
875
  rows = args.get("rows", [])
880
- if headers and rows:
881
- tree_frame = ttk.Frame(frame)
882
- tree_frame.pack(fill=tk.BOTH, expand=True)
883
- tree = ttk.Treeview(tree_frame, height=min(12, len(rows)))
884
- _populate_treeview(tree, headers, rows)
885
- vsb = ttk.Scrollbar(tree_frame, orient=tk.VERTICAL, command=tree.yview)
886
- tree.configure(yscrollcommand=vsb.set)
887
- tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
888
- vsb.pack(side=tk.RIGHT, fill=tk.Y)
876
+ rendered_map = self._try_render_map(frame, args, headers, rows)
877
+ if not rendered_map:
878
+ ttk.Label(
879
+ frame,
880
+ text=(
881
+ "(Interactive map requires the tkintermapview package — "
882
+ "`pip install execsql2[map]` — showing tabular data)"
883
+ ),
884
+ wraplength=580,
885
+ justify="left",
886
+ ).pack(anchor="w", pady=(0, 8))
887
+ if headers and rows:
888
+ tree_frame = ttk.Frame(frame)
889
+ tree_frame.pack(fill=tk.BOTH, expand=True)
890
+ tree = ttk.Treeview(tree_frame, height=min(12, len(rows)))
891
+ _populate_treeview(tree, headers, rows)
892
+ vsb = ttk.Scrollbar(tree_frame, orient=tk.VERTICAL, command=tree.yview)
893
+ tree.configure(yscrollcommand=vsb.set)
894
+ tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
895
+ vsb.pack(side=tk.RIGHT, fill=tk.Y)
896
+
897
+ if rows:
889
898
  ttk.Label(frame, text=_row_count_text(len(rows))).pack(anchor="w")
890
899
 
891
900
  button_list = args.get("button_list", [("Continue", 1, "<Return>")])
@@ -893,15 +902,120 @@ class MapDialog:
893
902
  btn_frame.pack(pady=8, anchor="e")
894
903
  _add_buttons(btn_frame, button_list, lambda v: self._close(win, v))
895
904
 
896
- _center_window(win, 600, 450)
905
+ _center_window(win, 750, 600)
897
906
  root.wait_window(win)
898
907
 
908
+ def _try_render_map(self, frame: ttk.Frame, args: dict, headers: list, rows: list) -> bool:
909
+ import warnings
910
+
911
+ # tkintermapview pulls in `geocoder`, which has a Python 3.12+ SyntaxWarning
912
+ # from an unescaped \d in a regex string literal. It's harmless but noisy.
913
+ with warnings.catch_warnings():
914
+ warnings.filterwarnings("ignore", category=SyntaxWarning, module=r"geocoder.*")
915
+ try:
916
+ from tkintermapview import TkinterMapView
917
+ except ImportError:
918
+ return False
919
+
920
+ lat_col = args.get("lat_col")
921
+ lon_col = args.get("lon_col")
922
+ if not (lat_col and lon_col and headers and rows):
923
+ return False
924
+
925
+ hdrs_lower = [str(h).lower() for h in headers]
926
+
927
+ def _idx(col: str | None) -> int | None:
928
+ if not col:
929
+ return None
930
+ try:
931
+ return hdrs_lower.index(col.lower())
932
+ except ValueError:
933
+ return None
934
+
935
+ lat_idx = _idx(lat_col)
936
+ lon_idx = _idx(lon_col)
937
+ if lat_idx is None or lon_idx is None:
938
+ return False
939
+
940
+ label_idx = _idx(args.get("label_col"))
941
+ color_idx = _idx(args.get("color_col"))
942
+
943
+ markers: list[tuple[float, float, str | None, str | None]] = []
944
+ for row in rows:
945
+ try:
946
+ lat = float(row[lat_idx])
947
+ lon = float(row[lon_idx])
948
+ except (TypeError, ValueError, IndexError):
949
+ continue
950
+ label = str(row[label_idx]) if label_idx is not None and row[label_idx] is not None else None
951
+ color = str(row[color_idx]) if color_idx is not None and row[color_idx] is not None else None
952
+ markers.append((lat, lon, label, color))
953
+
954
+ if not markers:
955
+ return False
956
+
957
+ try:
958
+ map_widget = TkinterMapView(frame, width=700, height=480, corner_radius=0)
959
+ except Exception:
960
+ # Headless display, no Tk root, or any other map-widget failure —
961
+ # fall back to tabular rather than crashing the dialog.
962
+ return False
963
+ map_widget.pack(fill=tk.BOTH, expand=True, pady=(0, 8))
964
+ # Force the geometry manager to assign the real packed size to the widget
965
+ # so its tile-box math uses the actual on-screen dimensions, not the
966
+ # constructor values. Without this the first paint can fetch tiles for
967
+ # the wrong viewport and the basemap appears blank.
968
+ map_widget.update_idletasks()
969
+
970
+ avg_lat = sum(m[0] for m in markers) / len(markers)
971
+ avg_lon = sum(m[1] for m in markers) / len(markers)
972
+ zoom = _pick_zoom(markers)
973
+
974
+ def _populate() -> None:
975
+ map_widget.set_position(avg_lat, avg_lon)
976
+ map_widget.set_zoom(zoom)
977
+ for lat, lon, label, color in markers:
978
+ kwargs: dict = {}
979
+ if label:
980
+ kwargs["text"] = label
981
+ # Setting only `marker_color_circle` (not `marker_color_outside`)
982
+ # renders a small colored dot instead of the chunky full drop-pin.
983
+ if color:
984
+ kwargs["marker_color_circle"] = color
985
+ map_widget.set_marker(lat, lon, **kwargs)
986
+
987
+ # Defer marker / position setup until after the widget's first <Configure>
988
+ # event so tile loading happens against the realized dimensions.
989
+ map_widget.after_idle(_populate)
990
+ return True
991
+
899
992
  def _close(self, win: tk.Toplevel, value: int | None) -> None:
900
993
  self.result = {"button": value}
901
994
  if win.winfo_exists():
902
995
  win.destroy()
903
996
 
904
997
 
998
+ def _pick_zoom(markers: list[tuple[float, float, str | None, str | None]]) -> int:
999
+ if len(markers) <= 1:
1000
+ return 10
1001
+ lat_span = max(m[0] for m in markers) - min(m[0] for m in markers)
1002
+ lon_span = max(m[1] for m in markers) - min(m[1] for m in markers)
1003
+ span = max(lat_span, lon_span)
1004
+ if span > 60:
1005
+ return 2
1006
+ if span > 30:
1007
+ return 3
1008
+ if span > 15:
1009
+ return 4
1010
+ if span > 5:
1011
+ return 5
1012
+ if span > 2:
1013
+ return 7
1014
+ if span > 0.5:
1015
+ return 9
1016
+ return 11
1017
+
1018
+
905
1019
  # ---------------------------------------------------------------------------
906
1020
  # CredentialsDialog
907
1021
  # ---------------------------------------------------------------------------
@@ -5,5 +5,5 @@ Importer package for execsql.
5
5
 
6
6
  Each sub-module reads a specific file format and loads data into a target
7
7
  database table via the active :class:`~execsql.db.base.Database` connection.
8
- Sub-modules: ``base``, ``csv``, ``ods``, ``xls``, ``feather``.
8
+ Sub-modules: ``base``, ``csv``, ``json``, ``ods``, ``xls``, ``feather``.
9
9
  """
execsql/importers/ods.py CHANGED
@@ -6,7 +6,7 @@ ODS spreadsheet import for execsql.
6
6
  Provides :func:`ods_data` (row iterator over an ODS sheet) and
7
7
  :func:`importods` (imports an ODS sheet into a database table), used by
8
8
  the ``IMPORT … FORMAT ods`` metacommand. Requires ``odfpy``
9
- (``execsql2[ods]``).
9
+ (``execsql2[formats]``).
10
10
  """
11
11
 
12
12
  from typing import Any
execsql/importers/xls.py CHANGED
@@ -6,7 +6,7 @@ XLS and XLSX spreadsheet import for execsql.
6
6
  Provides :func:`xls_data` / :func:`importxls` (``xlrd``-based ``.xls``
7
7
  reader) and the XLSX equivalent using ``openpyxl``. Used by
8
8
  ``IMPORT … FORMAT xls`` and ``FORMAT xlsx``. Requires
9
- ``execsql2[excel]``.
9
+ ``execsql2[formats]``.
10
10
  """
11
11
 
12
12
  from typing import Any
@@ -1,11 +1,40 @@
1
1
  """execsql metacommand dispatch table.
2
2
 
3
- Importing this module populates a MetaCommandList (``DISPATCH_TABLE``) with
4
- every metacommand regex and its handler function. The dispatch table is
5
- consumed by script.MetacommandStmt.run() via ``_state.metacommandlist``.
3
+ Importing this module populates a ``MetaCommandList`` (``DISPATCH_TABLE``)
4
+ with every metacommand regex and its handler function. The dispatch
5
+ table is consumed by ``script.MetacommandStmt.run()`` via
6
+ ``_state.metacommandlist``.
6
7
 
7
- Handler functions live in the sibling modules:
8
- connect, conditions, control, data, debug, io, prompt, script_ext, system
8
+ The table itself is built by ``build_dispatch_table()`` in
9
+ :mod:`execsql.metacommands.dispatch`. Handler functions are organized
10
+ into sibling modules by topic:
11
+
12
+ - :mod:`~execsql.metacommands.connect` — CONNECT / USE / DISCONNECT
13
+ / AUTOCOMMIT / PG_VACUUM.
14
+ - :mod:`~execsql.metacommands.conditions` — ``xf_*`` predicates used
15
+ by IF / ELSEIF / ASSERT.
16
+ - :mod:`~execsql.metacommands.control` — IF / ELSEIF / ELSE / ENDIF /
17
+ ANDIF / ORIF, ASSERT, LOOP, BATCH, HALT, BREAK, WAIT_UNTIL,
18
+ error-halt directives.
19
+ - :mod:`~execsql.metacommands.data` — SUB family, counters, IMPORT
20
+ config metacommands.
21
+ - :mod:`~execsql.metacommands.debug` — DEBUG WRITE/LOG variants and
22
+ SHOW SCRIPTS.
23
+ - :mod:`~execsql.metacommands.io_export` — EXPORT and EXPORT QUERY.
24
+ - :mod:`~execsql.metacommands.io_import` — IMPORT and IMPORT_FILE.
25
+ - :mod:`~execsql.metacommands.io_write` — WRITE, WRITE SCRIPT,
26
+ WRITE CREATE_TABLE.
27
+ - :mod:`~execsql.metacommands.io_fileops` — INCLUDE, COPY, ZIP,
28
+ RM_FILE, CD, SERVE.
29
+ - :mod:`~execsql.metacommands.io` — re-export façade for the four
30
+ ``io_*`` modules above (kept for backward-compatible import paths).
31
+ - :mod:`~execsql.metacommands.prompt` — PROMPT family, ASK, PAUSE.
32
+ - :mod:`~execsql.metacommands.script_ext` — EXECUTE SCRIPT, RUN
33
+ SCRIPT, EXTEND SCRIPT.
34
+ - :mod:`~execsql.metacommands.system` — SYSTEM_CMD, LOG, EMAIL,
35
+ CONSOLE, ON ERROR_HALT / ON CANCEL_HALT directives, TIMER.
36
+ - :mod:`~execsql.metacommands.upsert` — PG_UPSERT (optional, requires
37
+ ``execsql2[upsert]``).
9
38
  """
10
39
 
11
40
  from __future__ import annotations
@@ -3,14 +3,26 @@ from __future__ import annotations
3
3
  """
4
4
  Conditional test handler functions for execsql.
5
5
 
6
- Implements all ``xf_*`` functions the conditional test predicates used
7
- by IF/ELSEIF expressions and the ``x_if``, ``x_elseif``, ``x_else``,
8
- and ``x_endif`` imperative handlers that manage the IF-nesting stack.
9
-
10
- Examples of conditional tests defined here: ``xf_tableexists``,
11
- ``xf_fileexists``, ``xf_equals``, ``xf_contains``, ``xf_startswith``,
12
- ``xf_greaterthan``, etc., along with all their quoting variants generated
13
- at registration time.
6
+ Implements the ``xf_*`` predicates used by IF / ELSEIF / ANDIF / ORIF
7
+ / ASSERT / WAIT_UNTIL expressions. The IF-nesting *imperative*
8
+ handlers (``x_if``, ``x_if_elseif``, ``x_if_else``, ``x_if_end``,
9
+ etc.) live in :mod:`execsql.metacommands.control`.
10
+
11
+ Predicates registered here (with regex variants generated at
12
+ registration time for quoted, unquoted, and bracketed arguments):
13
+
14
+ - Object existence: ``xf_fileexists``, ``xf_direxists``,
15
+ ``xf_schemaexists``, ``xf_tableexists``, ``xf_roleexists``,
16
+ ``xf_script_exists``, ``xf_sub_defined``.
17
+ - Value tests: ``xf_equals``, ``xf_identical``, ``xf_contains``,
18
+ ``xf_startswith``, ``xf_endswith``, ``xf_isnull``, ``xf_iszero``,
19
+ ``xf_istrue``, ``xf_boolliteral``, ``xf_sub_empty``.
20
+ - Numeric comparison: ``xf_isgt``, ``xf_isgte``.
21
+ - Row counts: ``xf_hasrows``, ``xf_row_count_gt``, ``xf_row_count_gte``,
22
+ ``xf_row_count_eq``, ``xf_row_count_lt``.
23
+ - Runtime state: ``xf_sqlerror``, ``xf_dialogcanceled``, ``xf_dbms``.
24
+
25
+ The full list is also surfaced by ``execsql --dump-keywords``.
14
26
  """
15
27
 
16
28
  import os
@@ -245,9 +257,9 @@ def xf_sub_defined(**kwargs: Any) -> bool:
245
257
  if varname[0] not in ("~", "#"):
246
258
  subvarset = _state.subvars
247
259
  elif varname[0] == "~":
248
- subvarset = _state.commandliststack[-1].localvars
260
+ subvarset = _state.current_localvars()
249
261
  else:
250
- subvarset = _state.commandliststack[-1].paramvals
262
+ subvarset = _state.current_paramvals()
251
263
  return subvarset.sub_exists(varname) if subvarset else False
252
264
 
253
265
 
@@ -256,10 +268,10 @@ def xf_sub_empty(**kwargs: Any) -> bool:
256
268
  if varname[0] not in ("~", "#"):
257
269
  subvarset = _state.subvars
258
270
  elif varname[0] == "~":
259
- subvarset = _state.commandliststack[-1].localvars
271
+ subvarset = _state.current_localvars()
260
272
  else:
261
- subvarset = _state.commandliststack[-1].paramvals
262
- if not subvarset.sub_exists(varname):
273
+ subvarset = _state.current_paramvals()
274
+ if subvarset is None or not subvarset.sub_exists(varname):
263
275
  raise ErrInfo(
264
276
  type="cmd",
265
277
  command_text=kwargs["metacommandline"],
@@ -270,7 +282,7 @@ def xf_sub_empty(**kwargs: Any) -> bool:
270
282
 
271
283
  def xf_script_exists(**kwargs: Any) -> bool:
272
284
  script_id = kwargs["script_id"].lower()
273
- return script_id in _state.savedscripts
285
+ return script_id in _state.ast_scripts
274
286
 
275
287
 
276
288
  def xf_equals(**kwargs: Any) -> bool:
@@ -3,20 +3,27 @@ from __future__ import annotations
3
3
  """
4
4
  Database connection metacommand handlers for execsql.
5
5
 
6
- Implements all ``x_connect_*`` handler functions that open or switch
7
- database connections at script runtime:
8
-
9
- - ``x_connect_pg`` — ``CONNECT POSTGRESQL …``
10
- - ``x_connect_mysql`` ``CONNECT MYSQL …``
11
- - ``x_connect_oracle`` ``CONNECT ORACLE …``
12
- - ``x_connect_sqlite`` ``CONNECT SQLITE …``
13
- - ``x_connect_duckdb`` ``CONNECT DUCKDB …``
14
- - ``x_connect_firebird`` ``CONNECT FIREBIRD …``
15
- - ``x_connect_sqlserver`` ``CONNECT SQLSERVER …``
16
- - ``x_connect_access`` — ``CONNECT ACCESS …``
17
- - ``x_connect_dsn`` — ``CONNECT DSN …``
18
- - ``x_use_db`` ``USE DATABASE <alias>``
19
- - ``x_close_db`` — ``CLOSE DATABASE <alias>``
6
+ Per-DBMS ``CONNECT`` handlers. Each DBMS has two variants — the bare
7
+ form (credentials taken from regex captures / config) and the
8
+ ``_user_`` form (interactive password prompt):
9
+
10
+ - PostgreSQL: ``x_connect_pg``, ``x_connect_user_pg``
11
+ - SQL Server: ``x_connect_ssvr``, ``x_connect_user_ssvr``
12
+ - MySQL / MariaDB: ``x_connect_mysql``, ``x_connect_user_mysql``
13
+ - Oracle: ``x_connect_ora``, ``x_connect_user_ora``
14
+ - Firebird: ``x_connect_fb``, ``x_connect_user_fb``
15
+ - MS Access: ``x_connect_access``
16
+ - DuckDB: ``x_connect_duckdb``
17
+ - SQLite: ``x_connect_sqlite``
18
+ - ODBC DSN: ``x_connect_dsn``
19
+
20
+ Plus the connection-management handlers:
21
+
22
+ - ``x_use`` — ``USE <alias>`` (switch the active database).
23
+ - ``x_disconnect`` — ``DISCONNECT [<alias>]`` (close a registered connection).
24
+ - ``x_autocommit_on`` / ``x_autocommit_off`` — ``AUTOCOMMIT ON|OFF``.
25
+ - ``x_pg_vacuum`` — ``PG_VACUUM`` (run VACUUM against the current Postgres connection).
26
+ - ``x_daoflushdelay`` — ``CONFIG DAO_FLUSH_DELAY_SECS`` (MS Access only).
20
27
  """
21
28
 
22
29
  from pathlib import Path
@@ -3,17 +3,28 @@ from __future__ import annotations
3
3
  """
4
4
  Control-flow metacommand handlers for execsql.
5
5
 
6
- Implements the imperative ``x_*`` functions for script flow control:
7
-
8
- - Loop management: ``x_loop`` (LOOP END LOOP), ``x_while_loop``
9
- (WHILE END LOOP), ``x_until_loop`` (UNTIL … END LOOP)
10
- - Batch control: ``x_begin_batch``, ``x_end_batch``, ``x_commit``,
11
- ``x_rollback``
12
- - Script include/execute: ``x_include``, ``x_execute_script``
13
- - Named scripts: ``x_begin_script``, ``x_end_script``, ``x_run_script``
14
- - Error/halt control: ``x_halt``, ``x_on_error``, ``x_on_cancel``
15
- - Counter operations: ``x_set_counter``, ``x_increment_counter``
16
- - Substitution variable assignment: ``x_set``
6
+ Implements the ``x_*`` functions for script flow control:
7
+
8
+ - Conditional execution: ``x_if`` and its companions ``x_if_andif``,
9
+ ``x_if_orif``, ``x_if_elseif``, ``x_if_else``, ``x_if_end``,
10
+ ``x_if_block`` (compound IF / ELSEIF / ELSE / ENDIF handling).
11
+ - Assertion: ``x_assert`` (ASSERT condition with optional message).
12
+ - Loop management: ``x_loop`` (LOOP … END LOOP, including WHILE/UNTIL
13
+ variants matched by the same regex).
14
+ - Batch control: ``x_begin_batch``, ``x_end_batch``, ``x_rollback``.
15
+ - Error/halt control: ``x_halt``, ``x_halt_msg``, ``x_error_halt``,
16
+ ``x_metacommand_error_halt``.
17
+ - Flow modifiers: ``x_break`` (exit a LOOP), ``x_wait_until``.
18
+
19
+ Handlers for related areas live in sibling modules:
20
+
21
+ - INCLUDE / EXECUTE SCRIPT / RUN / named-script registration —
22
+ :mod:`execsql.metacommands.script_ext` (and the AST executor).
23
+ - Counter operations (RESET COUNTER, SET COUNTER) and substitution
24
+ variable assignment (SUB, SUB_LOCAL, etc.) —
25
+ :mod:`execsql.metacommands.data`.
26
+ - ON ERROR_HALT / ON CANCEL_HALT directives —
27
+ :mod:`execsql.metacommands.system`.
17
28
  """
18
29
 
19
30
  import time
@@ -22,13 +33,8 @@ from execsql.exceptions import ErrInfo
22
33
  from typing import Any
23
34
 
24
35
  import execsql.state as _state
25
- from execsql.script import (
26
- CommandList,
27
- MetacommandStmt,
28
- ScriptCmd,
29
- current_script_line,
30
- )
31
- from execsql.utils.errors import exit_now, write_warning
36
+ from execsql.script import current_script_line
37
+ from execsql.utils.errors import exit_now
32
38
  from execsql.utils.fileio import EncodedFile, check_dir
33
39
  from execsql.utils.gui import GUI_HALT, GuiSpec, enable_gui, gui_console_isrunning
34
40
 
@@ -67,67 +73,55 @@ def x_assert(**kwargs: Any) -> None:
67
73
  raise ErrInfo(type="assert", other_msg=message)
68
74
 
69
75
 
76
+ def _ast_only_stub(name: str):
77
+ """Return an ErrInfo for a metacommand the AST executor handles structurally.
78
+
79
+ These handlers stay registered in the dispatch table so ``--dump-keywords``,
80
+ the VS Code grammar generator, and ``--list-keywords`` still see the
81
+ keyword. The AST parser converts the source form (``IF`` / ``ENDIF`` /
82
+ ``LOOP`` / ``BEGIN BATCH`` / ``BREAK`` / ``ELSE`` / ``ELSEIF`` / ``ANDIF``
83
+ / ``ORIF``) into structural AST nodes that the executor walks directly —
84
+ none of these dispatch handlers fire for parsed scripts. Reaching one
85
+ means the AST parser failed to recognise the keyword, which is a bug.
86
+ """
87
+ from execsql.exceptions import ErrInfo
88
+
89
+ return ErrInfo(
90
+ type="cmd",
91
+ other_msg=f"{name} should be handled by the AST executor, not the dispatch table.",
92
+ )
93
+
94
+
70
95
  def x_if(**kwargs: Any) -> None:
71
- tf_value = _state.xcmd_test(kwargs["condtest"])
72
- if tf_value:
73
- src, line_no = current_script_line()
74
- metacmd = MetacommandStmt(kwargs["condcmd"])
75
- script_cmd = ScriptCmd(src, line_no, "cmd", metacmd)
76
- cmdlist = CommandList([script_cmd], f"{src}_{line_no}")
77
- _state.commandliststack.append(cmdlist)
78
- return None
96
+ raise _ast_only_stub("IF")
79
97
 
80
98
 
81
99
  def x_if_orif(**kwargs: Any) -> None:
82
- if _state.if_stack.all_true():
83
- return None # Short-circuit evaluation
84
- if _state.if_stack.only_current_false():
85
- _state.if_stack.replace(_state.xcmd_test(kwargs["condtest"]))
86
- return None
100
+ raise _ast_only_stub("ORIF")
87
101
 
88
102
 
89
103
  def x_if_andif(**kwargs: Any) -> None:
90
- if _state.if_stack.all_true():
91
- _state.if_stack.replace(_state.if_stack.current() and _state.xcmd_test(kwargs["condtest"]))
92
- return None
104
+ raise _ast_only_stub("ANDIF")
93
105
 
94
106
 
95
107
  def x_if_elseif(**kwargs: Any) -> None:
96
- if _state.if_stack.only_current_false():
97
- _state.if_stack.replace(_state.xcmd_test(kwargs["condtest"]))
98
- else:
99
- _state.if_stack.replace(False)
100
- return None
108
+ raise _ast_only_stub("ELSEIF")
101
109
 
102
110
 
103
111
  def x_if_else(**kwargs: Any) -> None:
104
- if _state.if_stack.all_true() or _state.if_stack.only_current_false():
105
- _state.if_stack.invert()
106
- return None
112
+ raise _ast_only_stub("ELSE")
107
113
 
108
114
 
109
115
  def x_if_block(**kwargs: Any) -> None:
110
- if _state.if_stack.all_true():
111
- _state.if_stack.nest(_state.xcmd_test(kwargs["condtest"]))
112
- else:
113
- _state.if_stack.nest(False)
114
- return None
116
+ raise _ast_only_stub("IF")
115
117
 
116
118
 
117
119
  def x_if_end(**kwargs: Any) -> None:
118
- _state.if_stack.unnest()
119
- return None
120
+ raise _ast_only_stub("ENDIF")
120
121
 
121
122
 
122
123
  def x_loop(**kwargs: Any) -> None:
123
- # LOOP is now handled natively by the AST executor (_execute_loop).
124
- # This handler exists only for dispatch table registration compatibility.
125
- from execsql.exceptions import ErrInfo
126
-
127
- raise ErrInfo(
128
- type="cmd",
129
- other_msg="LOOP should be handled by the AST executor, not the dispatch table.",
130
- )
124
+ raise _ast_only_stub("LOOP")
131
125
 
132
126
 
133
127
  def x_halt(**kwargs: Any) -> None:
@@ -186,27 +180,20 @@ def x_metacommand_error_halt(**kwargs: Any) -> None:
186
180
 
187
181
 
188
182
  def x_begin_batch(**kwargs: Any) -> None:
189
- _state.status.batch.new_batch()
190
- return None
183
+ raise _ast_only_stub("BEGIN BATCH")
191
184
 
192
185
 
193
186
  def x_end_batch(**kwargs: Any) -> None:
194
- _state.status.batch.end_batch()
195
- return None
187
+ raise _ast_only_stub("END BATCH")
196
188
 
197
189
 
198
190
  def x_rollback(**kwargs: Any) -> None:
191
+ """Roll back all DBs registered in the innermost batch level."""
199
192
  _state.status.batch.rollback_batch()
200
193
 
201
194
 
202
195
  def x_break(**kwargs: Any) -> None:
203
- if len(_state.commandliststack) == 1:
204
- src, line_no = current_script_line()
205
- write_warning(f"BREAK metacommand with no command nesting on line {line_no} of {src}")
206
- else:
207
- _state.if_stack.if_levels = _state.if_stack.if_levels[: _state.commandliststack[-1].init_if_level]
208
- _state.commandliststack.pop()
209
- return None
196
+ raise _ast_only_stub("BREAK")
210
197
 
211
198
 
212
199
  def x_wait_until(**kwargs: Any) -> None:
@@ -2,13 +2,29 @@ from __future__ import annotations
2
2
  from execsql.exceptions import ErrInfo
3
3
 
4
4
  """
5
- Data import and export metacommand handlers for execsql.
6
-
7
- Implements ``x_export`` (the EXPORT metacommand) and ``x_import`` (the
8
- IMPORT metacommand). These are among the most complex handlers: they
9
- parse the full metacommand argument syntax (format, file name, options)
10
- and delegate to the appropriate exporter or importer function in
11
- :mod:`execsql.exporters` and :mod:`execsql.importers`.
5
+ Substitution-variable, counter, and IMPORT/EXPORT configuration handlers.
6
+
7
+ Despite the module name, this file does **not** implement the EXPORT or
8
+ IMPORT metacommands themselves those live in
9
+ :mod:`execsql.metacommands.io_export` and :mod:`execsql.metacommands.io_import`.
10
+
11
+ What's actually here:
12
+
13
+ - Substitution-variable manipulation: ``x_sub``, ``x_sub_local``,
14
+ ``x_sub_add``, ``x_sub_append``, ``x_sub_empty``, ``x_rm_sub``,
15
+ ``x_sub_tempfile``, ``x_sub_ini``, ``x_sub_querystring``,
16
+ ``x_sub_encrypt``, ``x_sub_decrypt``, ``x_subdata``.
17
+ - Data-variable assignment from queries / prompts: ``x_selectsub``,
18
+ ``x_prompt_selectsub``.
19
+ - Counter operations: ``x_reset_counter``, ``x_reset_counters``,
20
+ ``x_set_counter``.
21
+ - IMPORT / scanning configuration metacommands (the ``CONFIG …`` form):
22
+ ``x_empty_strings``, ``x_trim_strings``, ``x_replace_newlines``,
23
+ ``x_empty_rows``, ``x_only_strings``, ``x_boolean_int``,
24
+ ``x_boolean_words``, ``x_fold_col_hdrs``, ``x_trim_col_hdrs``,
25
+ ``x_clean_col_hdrs``, ``x_del_empty_cols``, ``x_create_col_hdrs``,
26
+ ``x_dedup_col_hdrs``, ``x_import_common_cols_only``, ``x_max_int``.
27
+ - EXPORT configuration: ``x_quote_all_text``.
12
28
  """
13
29
 
14
30
  import math
@@ -54,7 +70,7 @@ def x_sub_empty(**kwargs: Any) -> None:
54
70
 
55
71
  def x_rm_sub(**kwargs: Any) -> None:
56
72
  varname = kwargs["match"]
57
- subvarset = _state.subvars if varname[0] != "~" else _state.commandliststack[-1].localvars
73
+ subvarset = _state.subvars if varname[0] != "~" else _state.current_localvars()
58
74
  subvarset.remove_substitution(varname)
59
75
  return None
60
76
 
@@ -63,7 +79,7 @@ def x_sub_local(**kwargs: Any) -> None:
63
79
  varname = kwargs["match"]
64
80
  if varname[0] != "~":
65
81
  varname = "~" + varname
66
- _state.commandliststack[-1].localvars.add_substitution(varname, kwargs["repl"])
82
+ _state.current_localvars().add_substitution(varname, kwargs["repl"])
67
83
  return None
68
84
 
69
85