Skip to content

Divergence from Upstream

execsql2 is a maintained fork of execsql v1.130.1 by R. Dreas Nielsen. This page documents all user-visible changes since the fork was created: new features, changed behavior, security fixes, and removed functionality.

For a chronological view, see the Change Log.


Added Features

CLI Options

Flag Description
--version Print version and exit (Rich-formatted).
-c / --command Execute an inline SQL or metacommand string instead of a script file.
--dsn / --connection-string Accept a standard database URL (e.g. postgresql://user:pass@host/db). Supports postgresql, mysql, mssql, oracle, firebird, sqlite, and duckdb schemes.
--output-dir Set a default base directory for export output files.
--progress Show a Rich progress bar during long-running IMPORT operations.
--dump-keywords Emit all metacommand keywords, conditionals, config options, and export formats as structured JSON.
--config FILE Load an explicit configuration file after the implicit search paths. Its values take precedence over system, user, script-dir, and working-dir config files; CLI arguments still override everything. The file may chain additional configs via its [config] section.
--gui-framework Select GUI backend: tkinter (default) or textual (terminal UI).
--debug Start in step-through debug mode. The debug REPL pauses before each statement, as if BREAKPOINT were at the top with .next always active.
--dry-run Parse the script and print the full command list without connecting to a database or executing anything. Substitution variables already populated at parse time (env vars, --assign-arg values, built-in start-time vars) are expanded in the output; execution-time variables ($DB_NAME, $CURRENT_TIME, etc.) remain unexpanded.
--profile Record wall-clock time for each SQL and metacommand statement. After the script completes, print a summary table sorted by elapsed time (descending), showing time, percentage of total, source location, command type, and a command preview.
--profile-limit N Number of top statements to display in the --profile summary (default: 20). Remaining statements are counted and noted in the output footer.
--ping Test database connectivity and exit. Connects using the supplied connection parameters, queries for the server version, and prints a one-line success message (exit 0) or the error (exit 1). No script file argument is required.
--lint Parse and statically check a script without connecting to a database. Reports unmatched block structures (errors) and undefined variables, missing INCLUDE targets, and unknown EXECUTE SCRIPT targets (warnings). Two-pass variable analysis follows EXECUTE SCRIPT / INCLUDE chains and reads SUB_INI files. Exits 0 / 1.
--parse-tree Parse the script into an Abstract Syntax Tree and print a visual tree showing block nesting (IF/LOOP/BATCH/SCRIPT), source line ranges, compound conditions (ANDIF/ORIF), and all metacommands. Does not connect to a database or execute anything. Useful for understanding script structure and verifying the parser handles a script correctly.
--list-plugins List all discovered plugins (metacommands, exporters, importers) from Python entry points and exit. Plugins extend execsql via execsql.metacommands, execsql.exporters, and execsql.importers entry point groups.
--no-system-cmd Disable the SYSTEM_CMD (SHELL) metacommand. Scripts that use SHELL will fail with a clear error. Also configurable via allow_system_cmd = No in execsql.conf [config] section, or allow_system_cmd=False in the library API.
--init-config Print a default execsql.conf template to stdout with all options commented out and documented. Redirect to a file to bootstrap a configuration: execsql --init-config > execsql.conf.

Export Formats

Format Description
PARQUET Export query or table results to Apache Parquet via polars.
FEATHER Export to Apache Feather/IPC via polars (upstream used pandas + pyarrow).
YAML Export query or table results as a YAML sequence of mappings via PyYAML.
MARKDOWN / MD Export query or table results as a GitHub-Flavored Markdown (GFM) pipe table. Pure Python, no optional dependencies.
XLSX Export query or table results to an Excel XLSX workbook via openpyxl (single or multi-sheet).

Metacommands

Metacommand Description
ASSERT Evaluate a condition and raise an error (halting the script) if it is false. Supports all IF conditions. Optional quoted failure message. Skipped in false IF blocks.
BREAKPOINT Pause script execution and drop into an interactive debug REPL. See Debugging below for full details.
CONFIG SHOW_PROGRESS Enable the Rich progress bar for IMPORT operations at runtime.
CONFIG LOG_SQL Enable SQL query audit logging — writes executed SQL to the log file.
PG_UPSERT QA-checked, FK-dependency-ordered upserts from staging to base schema on PostgreSQL via the optional pg-upsert dependency. Modes: PG_UPSERT (full pipeline), PG_UPSERT QA (checks only), PG_UPSERT CHECK (schema only). QA failure does not raise — the result is reported via $PG_UPSERT_QA_PASSED so the caller's IF / ASSERT decides control flow.
IMPORT … FROM JSON Import a JSON file (array of objects or JSON Lines) into a database table. Nested objects are flattened with dot-separated column names; arrays are stored as JSON strings.
SHOW SCRIPTS [<name>] Without a name, lists all registered SCRIPT definitions with parameter signatures and source locations. With a name, shows detail including parameters (with defaults), source file/line range, and docstring.

SCRIPT Enhancements

Feature Description
Default parameters BEGIN SCRIPT load(schema, table, batch=1000) — parameters with defaults can be omitted at call site. Required parameters must precede optional parameters.
Quoted defaults Default values may be quoted with single or double quotes to embed spaces, commas, or other special characters: BEGIN SCRIPT proc(msg="hello, world", path="/var/log/app.log"). Surrounding quotes are stripped at parse time so the bound substitution variable holds the value itself, matching the call-site quote-handling for passed arguments.
Docstrings Comments (-- or /* */) immediately following BEGIN SCRIPT are captured as documentation. A blank line terminates the docstring. Displayed by SHOW SCRIPTS <name> and .scripts <name> REPL command.

Bug Fixes

Fix Description
Variable EXECUTE SCRIPT targets EXECUTE SCRIPT !!#var!! now works — the parser accepts substitution variable patterns as script identifiers, and the executor resolves them at runtime.
Quoted parameter defaults BEGIN SCRIPT proc(name="value") previously stored the literal "value" (with surrounding quotes), causing substitutions like WRITE "!!#name!!" to expand to WRITE ""value"" and fail. Defaults now strip surrounding quotes at parse time, matching the wo_quotes handling already applied to passed arguments.

Conditional Tests

Conditional Description
ROW_COUNT_GT(table, N) True if the number of rows in table is strictly greater than N (integer). Queries SELECT count(*).
ROW_COUNT_GTE(table, N) True if the number of rows in table is greater than or equal to N.
ROW_COUNT_EQ(table, N) True if the number of rows in table is exactly equal to N.
ROW_COUNT_LT(table, N) True if the number of rows in table is strictly less than N.

Configuration Options

New options in execsql.conf:

Option Section Description
use_keyring [connect] Use the OS keyring for credential storage (default: yes).
show_progress [input] Enable Rich progress bar for IMPORT (default: no).
import_progress_interval [input] Log a status line every N rows during IMPORT (default: 0).
gui_framework [interface] GUI backend: tkinter (default) or textual (terminal UI).
log_sql [config] Enable SQL audit logging (default: no).
max_log_size_mb [config] Rotate the log file at this size in MB (default: 0 = disabled).
macos_config_file [config] Additional config file path, active only on macOS.
allow_rm_file [config] Disable the RM_FILE metacommand (default: yes). Symmetric with allow_system_cmd; also --no-rm-file on the CLI.
allow_serve [config] Disable the SERVE metacommand (default: yes). Symmetric with allow_system_cmd; also --no-serve on the CLI.
include_root [config] Path-containment root for INCLUDE / EXECUTE SCRIPT (default: none). When set, attempts to include files outside this root via ../, absolute paths, drive letters, or UNC paths are rejected with an error.
serve_root [config] Path-containment root for SERVE (default: none). Same semantics as include_root.
template_root [config] Path-containment root for Jinja2 / string.Template loaders (default: none). Same semantics as include_root.
max_substitution_bytes [config] Byte ceiling on a single substitution-variable expansion (default: 10485760 = 10 MB) to defeat exponential-expansion bombs.

Tools

Tool Description
execsql-format Standalone CLI for normalizing metacommand indentation and uppercasing SQL keywords. Supports --check, --in-place, --indent N (controls both metacommand and SQL indentation), and --leading-comma (commas at start of lines) modes. Also available as a pre-commit hook. SQL reformatting (the optional sqlglot pass) requires the [formatter] extra as of 2.19.0; --no-sql works without it.

GUI

Feature Description
Textual TUI backend Full terminal-UI backend via the textual library. Provides all dialog types (password, pause, message, entry, compare, action, etc.) in the terminal.
Console fallback Text-only backend that handles GUI calls in headless environments by printing to stdout.
Table row counts All GUI backends (Tkinter, Textual, Console) display a row count footer below every table widget (e.g. "3 rows", "1 row").
Help URL button Dialogs that accept the HELP keyword display a clickable Help button that opens the URL in the system browser.
Compare diff summary The compare dialog shows a one-line summary of matching, differing, and table-exclusive rows when key columns are specified.
Compare cell markers When diff highlighting is active, individual cells that differ within a changed row are prefixed with a bullet marker across all backends (Tkinter, Textual, console).
Form validation PROMPT ENTRY_FORM enforces validation_regex (on submit) and validation_key_regex (per-keystroke) across all backends. Required fields validated.

Authentication

Feature Description
OS keyring integration When the keyring package is installed, passwords are stored in and retrieved from the OS credential store (macOS Keychain, Windows Credential Manager, Linux SecretService).
Keyring retry on auth failure If a stored password is rejected, the stale entry is deleted, the user is re-prompted, and the new password is saved automatically.
Keyring auto-store in GUI mode When a password is entered via a GUI prompt (Tkinter or Textual), it is stored to the OS keyring automatically without an explicit confirmation prompt. CLI password prompts behave the same way.

Logging Enhancements

Feature Description
Per-event ISO 8601 timestamps status, connect, action, and user_msg log entries include a timestamp.
Run duration in exit record The exit log record includes elapsed wall-clock time.
Run ID millisecond precision Run identifier format changed from %Y%m%d_%H%M_%S to %Y%m%d_%H%M_%S_NNN.
SQL audit log record New sql record type containing DB name, line number, and query text.
Import progress log Periodic row-count status lines during IMPORT when import_progress_interval > 0.

Developer / Packaging

Feature Description
VS Code syntax highlighting Auto-generated tmLanguage.json grammar from the dispatch table.
py.typed marker PEP 561 marker enabling downstream static type checking.
Structured keyword registry --dump-keywords introspects the dispatch table and outputs JSON used by the grammar generator and test suite.
python-dateutil dependency Date/time parsing delegates to dateutil.parser.parse() instead of 231 hardcoded strptime format strings. Handles ISO 8601 T separators, microseconds, Z suffix, and named timezones.

Debugging

execsql2 adds an interactive debug REPL (no upstream equivalent), triggered either by the BREAKPOINT metacommand or the --debug CLI flag (step-through mode). The REPL prompts with execsql debug> and uses two-way dispatch (as of 2.19.0): input starting with . is a REPL command (.continue, .quit, .next, .vars, .vars VAR, .where, .stack, .set, .scripts, .cancel, .help); everything else is SQL, buffered across lines until terminated with ; and executed against the live connection (DML reports rows affected, DDL / transaction-control reports (statement executed), errors print inline without ending the session). BREAKPOINT is silently skipped when stdin is not a TTY so it does not hang CI. Full command list and behavior: Debugging guide.

Library API

execsql2 exposes execsql.run() for programmatic execution — no upstream equivalent.

from execsql import run

result = run(
    script="pipeline.sql",
    dsn="postgresql://user:pass@host/db",
    variables={"SCHEMA": "public"},
)
# result: ScriptResult(success, commands_run, elapsed, errors, variables)
result.raise_on_error()

Accepts dsn= or a pre-built connection= Database object. Each call runs in its own thread-local RuntimeContext, so concurrent calls from different threads are safe. halt_on_error=False collects errors instead of raising.


Changed Behavior

CLI Interface

The CLI framework changed from optparse to Typer with Rich-formatted help text. All original short flags (-a through -z) are preserved. The tool can be invoked as either execsql or execsql2.

Seven upstream long-form flags were renamed underscore → hyphen and the underscore forms are not accepted: --database-encoding, --script-encoding, --output-encoding, --import-encoding, --import-buffer, --user-logfile, --visible-prompts (upstream wrote these with underscores). Scripts and CI pipelines that invoke the long-form flags must update the spelling; the short letters (-e, -f, -g, -i, -l, -v, -z) are unchanged.

Default Database Type

The default database type (-t) changed from Access (a) to SQLite (l). Upstream defaulted to Access, which requires Windows and pyodbc. SQLite is cross-platform, ships with Python, and is the most common use case. Users targeting Access databases should pass -t a explicitly.

Configuration

  • linux_config_file — now only active on Linux (sys.platform == "linux"). Upstream applied it to all POSIX systems, including macOS. A new macos_config_file option handles macOS specifically.

Internal State Management

All 33 mutable runtime globals in state.py have been consolidated into a RuntimeContext object stored in threading.local(). The module uses a transparent proxy so existing code is unaffected. Each thread gets its own isolated context, enabling concurrent from execsql import run calls and future PARALLEL blocks.

Substitution Variables

  • $HOSTNAME — Network name of the machine running execsql (platform.node()). Useful for log messages and environment detection.
  • $SYSTEM_CMD_PID — New system variable set to the PID of the background process when SHELL … CONTINUE is used.
  • Cycle detectionsubstitute_vars() raises an error after 100 iterations to prevent infinite loops when variables reference each other cyclically. Upstream had no protection.
  • O(1) substitution — Variable substitution uses a single combined regex and dict lookup instead of O(V) per-variable regex passes. Behavior is identical; performance is improved.
  • Lazy $RANDOM/$UUID — These system variables are now computed on first access rather than generated unconditionally for every statement. Behavior is identical when referenced; scripts that never reference them skip the computation entirely.
  • Static/dynamic system var split — System substitution variables are split into static (set once per script and refreshed on CONNECT/CHDIR) and dynamic (refreshed per statement). Eliminates redundant Path.resolve() syscalls and database pool lookups per statement.

CSV Import

  • Fast-path CSV reader — Standard delimited imports (comma, tab, semicolon, pipe with doubled-quote escaping) now use Python's csv module. Non-standard formats (space-delimiter collapsing, escape characters) fall back to the original character-at-a-time parser.

Database Adapters

  • Database is an ABCopen_db() and exec_cmd() are abstract methods. Subclasses that omit them raise TypeError at instantiation instead of at call time.
  • Connection timeouts — PostgreSQL and SQLite adapters accept a connection timeout parameter (default 30 seconds).
  • DuckDB temporal typesTIMESTAMPTZ, TIMESTAMP, DATE, TIME now map to native DuckDB types instead of TEXT.
  • SQLite DT_Long mappingDT_Long maps to "hugeint" in the SQLite type table. SQLite does not have a native HUGEINT type; the value receives TEXT affinity. In practice this is harmless because SQLite's type affinity system handles large integers transparently, but the mapping name differs from upstream.
  • Database.auto_commits_ddl() not provided — 2.18.0 shipped this capability hook on the base Database class with True overrides on Oracle, MySQL, SQL Server, and MS Access (to signal that DDL on those drivers implicitly commits, so rollback() is a silent no-op across the DDL boundary). No call site ever consumed the hook, so 2.19.0 removed it. The asymmetry it described is still real — DDL inside a BEGIN BATCH … END BATCH block on Oracle / MySQL / SQL Server / MS Access cannot be rolled back — but execsql does not currently warn callers about it. (The companion hook Database.needs_explicit_commit_after_ddl(), used by the Firebird import path, remains.)

Execution Engine

The legacy flat command-list engine (_parse_script_lines / runscripts / CommandList.run_next()) has been replaced by an AST-based execution engine. Scripts are parsed into a tree of typed nodes, then the tree is walked for execution. This change is transparent to users — all metacommands, SQL, and control flow work identically.

  • Native INCLUDE handling — The AST executor parses INCLUDE'd files with the AST parser and executes them through the tree-walking executor. Control flow structures (IF/LOOP/BATCH/SCRIPT) in included files are fully tree-driven, with correct deferred variable handling and source-span error reporting.
  • Circular INCLUDE detection — The executor tracks the full include chain and detects circular references (e.g. A includes B includes A), reporting the full chain in the error message. Upstream has no such detection.
  • BREAK outside LOOP is an errorBREAK outside a loop block now raises an error (exit 1) instead of being silently ignored. This catches script bugs that upstream would not report.
  • Instance-scoped script registry — Named SCRIPT blocks are stored on the RuntimeContext instance instead of a module-level dict, preventing cross-execution contamination.
  • Script source directory resolutionScriptCmd resolves source_dir at construction time (when the command is parsed) rather than per-statement at execution time. This is functionally equivalent for all normal usage since the script file does not move between parse and execution.

Error Handling

  • Exception hierarchy — All custom exceptions inherit from ExecSqlError, enabling except ExecSqlError to catch any execsql-originated error.
  • Exception chaining — All raise statements inside except blocks preserve the original traceback via from.
  • ASSERT error typeASSERT failures now use a dedicated "assert" error type that produces **** Assertion failed. instead of **** Error in metacommand.. This distinguishes intentional script-level checks from actual metacommand errors. Upstream did not have ASSERT.

Security and Correctness Fixes

These are behavioral changes driven by security or correctness issues in the upstream code.

Injection Fixes

Area Fix
Database metadata queries schema_exists(), table_exists(), column_exists(), table_columns(), view_exists(), role_exists() across all 9 adapters now use parameterized queries. Upstream used string interpolation.
import_entire_file() Column names are quoted with quote_identifier() instead of interpolated into INSERT statements.
PostgreSQL CREATE DATABASE Database name and encoding are quoted. COPY delimiter and quote character are validated.
MySQL LOAD DATA INFILE File path, delimiter, and quotechar are now escaped with replace("'", "''") before interpolation into the SQL statement.
$SHEETS_TABLES_VALUES Sheet names from ODS/XLS imports are escaped before embedding in SQL.
HTTP Content-Disposition Filename is sanitized to prevent HTTP response splitting in SERVE.

Template and Export Safety

Area Fix
Jinja2 sandboxing Templates run in SandboxedEnvironment instead of the default jinja2.Template.
HTML export Column headers and cell values are escaped with html.escape() to prevent XSS.
XML export Values are escaped with xml.sax.saxutils.escape(). Invalid XML element name characters are replaced.
JSON export The description field and all column names use json.dumps() instead of string interpolation.
XLSX zip-bomb defence EXPORT … FORMAT xlsx, EXPORT … FORMAT xls, and (as of 2.19.0) IMPORT … FORMAT xlsx reject zip-based workbooks whose decompression ratio exceeds 100:1 per member or whose aggregate uncompressed size exceeds 500 MB. Helper at execsql.utils.fileio.check_zip_decompression_ratio. Legacy .xls (OLE-CDF, not zip) is unaffected — the helper no-ops on it.
ODS XML attack defence EXPORT … FORMAT ods / IMPORT … FORMAT ods defuse the stdlib XML parsers via defusedxml.defuse_stdlib() on first OdsFile construction, protecting odfpy from billion-laughs and external-entity attacks.

Credential and Logging Safety

Area Fix
ODBC password redaction Connection strings in log output have Pwd=*** substituted before logging.
enc_password documentation Prominent warnings that XOR encryption is obfuscation only — keys are hardcoded in source.

Bug Fixes

Area Fix
Oracle default port Corrected from 5432 (PostgreSQL) to 1521.
MySQL LOAD DATA INFILE encoding Python encoding names are now mapped to MySQL charset names.
dt_cast type converters Base Database class auto-populates 8 type converters that were previously left empty after the refactor.
FileWriter CPU busy-loop Uses blocking queue.get(timeout=0.1) instead of get_nowait() in a tight loop.
Substitution variable cycles 100-iteration limit prevents infinite loops on cyclic variable references.
Script location in error messages ErrInfo.script_file and script_line_no are now populated via stamp_errinfo() so error output includes "Line N of script foo.sql" context — restoring behavior present in the monolith.
$ERROR_MESSAGE not updated $ERROR_MESSAGE is now set on every error path: exit_now(), non-halting SQL errors, and non-halting metacommand errors. Previously it was initialized to "" and never changed.
Metacommand error message lost When halt_on_metacommand_err is ON, the original handler ErrInfo is now re-raised; the generic "Unknown metacommand" message no longer replaces the specific error from the handler.
Empty script name in error msg _execute_script_direct() and _execute_script_textual_console() no longer append "in script , line 0" to uncaught-exception messages when current_script_line() returns an empty string.
PROMPT COMPARE diff comparison Diff engine uses native Python equality instead of string comparison — numeric types, Decimals, and booleans compare correctly. None is distinguished from empty string. Columns are matched by name (not position), key columns are excluded from comparison, and duplicate PKs keep the first row.
win_config_file broken Checked os.name == "windows" which Python never returns (correct value is "nt"). Fixed to os.name == "nt".
ELSEIF + ANDIF/ORIF ANDIF/ORIF after an ELSEIF were silently attached to the parent IF condition instead of the ELSEIF clause. The compound condition was never evaluated for the ELSEIF branch. Fixed in the AST parser and executor.
Cursor leak in select_rowsource() Cursor was not closed on query execution failure. Row generators in EXPORT and COPY paths were not explicitly closed on error, relying on garbage collection for cleanup.
NumericParser right-associative Arithmetic operators were parsed right-to-left. 10 - 3 - 2 evaluated as 9 instead of 5. Fixed to left-associative parsing.
Empty-column check precedence DataTable and Database.populate_table() had an operator precedence bug in the extra-column emptiness check — a redundant and conf.del_empty_cols caused incorrect short-circuit evaluation.
SQLite import string processing SQLiteDatabase.populate_table() applied trim_strings, replace_newlines, and empty_strings after copying row data, so processing never reached the INSERT. Fixed to process before extraction.
$CURRENT_DATABASE/$CURRENT_DBMS stale after USE These variables were only set at startup and on CONNECT, not refreshed when USE switched the active database. Now set in set_static_system_vars() so they update on any connection change.
DT_Text.data_type_name wrong Was "character" (same as DT_Character), making error messages indistinguishable. Corrected to "text".
DT_Varchar non-string data unchecked _from_data() only enforced the 255-char limit for str inputs; non-string values passed through without conversion or length check.
WriteHooks.write_err() crash on empty string strval[-1] raised IndexError on empty input. Fixed to use str.endswith().
NumericParser division by zero NumericAstNode.eval() raised unhandled ZeroDivisionError. Now raises NumericParserError with a clear message.
CondAstNode.eval() could return None Missing fallthrough for unknown node types silently returned None. Now raises CondParserError.
DT_Timestamp claims time-only values dateutil.parser.parse() silently fills in today's date for bare time strings like "13:15:45", so DT_Timestamp matched before DT_Time in the inference order. parse_datetime() now rejects time-only strings.
CounterVars.substitute skipped pos 0–1 re.I was passed as the positional pos argument, skipping the first 2 characters of every string during counter variable expansion. Counter variables at the very start of a line were never matched.
exec_cmd (SQLite/DuckDB) always raised TypeError Passed bytes to curs.execute() via .encode(), which Python 3 sqlite3/duckdb reject. EXECUTE PROCEDURE metacommand was non-functional on these backends.
MySQL LOAD DATA INFILE injection File path, delimiter, and quotechar were interpolated without escaping. Now single-quotes are escaped consistent with the PostgreSQL COPY path.
Config file chain infinite loop The config_file directive could chain config files without limit. A circular reference (via symlinks or different relative paths) caused an infinite loop at startup. Now capped at 20 files.
Cursor leaks in database adapters ~15 methods across all adapters used curs = self.cursor() / curs.close() without try/finally. If the query raised, the cursor leaked. Converted to with self._cursor() as curs:.
JSON export malformed on special column names Column names containing " or \ produced invalid JSON. Now uses json.dumps() for all field names.
Temp file creation TOCTOU race TempFileMgr.new_temp_fn() discarded the NamedTemporaryFile handle, creating a race window. Now uses tempfile.mkstemp() for secure creation.
shlex.split on Windows incorrect mode Called without posix=False on Windows, mishandling backslash-heavy paths in SHELL commands.
AST executor ~/+ variable scoping broken The AST executor passed localvars through function parameters but never pushed CommandList frames onto commandliststack. Legacy metacommand handlers (x_sub, x_rm_sub, xf_sub_defined, SUB_LOCAL, prompt handlers, REPL) access commandliststack[-1] for ~ local and + outer-scope variables. This caused ~ vars to be invisible to SQL, SUB_DEFINED(~var) to always return false, and the REPL .vars/.stack to show empty state. Fixed by pushing/popping CommandList frames in execute() and _execute_script_native().
AST parser INCLUDE quoted paths broken The AST parser captured the full INCLUDE target including surrounding quotes ("path"), but the legacy dispatch regex stripped them. Quoted INCLUDE paths failed with "File does not exist" even when the file was present. Fixed by stripping matched quote pairs in the parser.
AST parser BEGIN SCRIPT name(params) rejected The regex required whitespace between the script name and parameter list. BEGIN SCRIPT foo(a,b) (no space before () silently failed to match, causing the matching END SCRIPT to raise "Unmatched END SCRIPT metacommand." Fixed by allowing optional whitespace before the parameter expression.
AST executor forward SCRIPT references broken The legacy engine registered all BEGIN SCRIPT blocks at parse time (two-pass), so EXECUTE SCRIPT foo could appear before the BEGIN SCRIPT foo definition. The AST executor walked the tree in a single pass, so forward references failed with "There is no SCRIPT named foo." Fixed by adding a pre-registration scan of all SCRIPT blocks before execution begins.

Removed Features

Feature Reason
Airspeed template processor The airspeed library (Velocity clone) is unmaintained since ~2018. Use FORMAT jinja instead. The airspeed value for template_processor in execsql.conf is no longer accepted.
Python 2 compatibility All Python 2 constructs (stringtypes, u"" literals, optparse, etc.) have been removed. execsql2 requires Python 3.10+.
FREE keyword on PROMPT DISPLAY The non-blocking display behavior was only implemented in the console backend; the Textual and Tkinter GUI backends ignored it. Removed rather than partially supported.
Legacy command-list execution engine The CommandList data class, IfLevels / IfItem classes, and the .run() methods on SqlStmt / MetacommandStmt / ScriptCmd were the flat command-list engine inherited from the monolith. The AST executor has been the sole engine since v2.16.0; everything dependent on the flat-engine bookkeeping (runscripts, check_iflevels, endloop, and the commandliststack / loopcommandstack / compiling_loop / loop_nest_level / if_stack / savedscripts slots on RuntimeContext) has been removed. Variable scoping migrated to the unified ast_exec_stack via the new ExecFrame data class. Advanced consumers that read _state.commandliststack directly should use _state.current_localvars() / current_paramvals() / outer_script_scopes() instead.
run_when_false / run_in_batch flags on MetaCommand The two boolean flags on MetaCommand (and the matching keyword arguments on MetaCommandList.add()) were the flat-engine's dispatch-time gates: run_when_false let ELSE / ENDIF handlers fire even when the IF stack was false, and run_in_batch permitted END BATCH / ROLLBACK to run inside a BEGIN BATCH block. Under the AST executor IF branching is structural, so handlers only execute on the chosen branch; the live BEGIN BATCH gate is BatchLevels.in_batch(). Neither flag was read by any live code path; both were removed in v2.18.1 along with the two tests that pinned their presence on every dispatch entry. Code that passed these kwargs to mcl.add() will raise TypeError.