Adding a New Metacommand¶
This guide walks through every step required to add a new metacommand to execsql. The process involves four files in a fixed sequence: write the handler, register the regex, export the handler, and add tests.
Plugin alternative
If your metacommand is self-contained and could benefit from being distributed
as a separate package, consider creating it as a plugin instead of adding it
to the core codebase. Plugins use Python entry points to register metacommands
at startup — no changes to execsql source required. See
extras/plugin-template/ for a ready-to-use starting point, or run
execsql --list-plugins to see installed plugins.
Background: How Metacommands Work¶
The dispatch table¶
build_dispatch_table() in src/execsql/metacommands/dispatch.py populates a MetaCommandList with mcl.add() calls for every metacommand. __init__.py calls it at import time and assigns the result to the module-level DISPATCH_TABLE, which is then consumed at runtime via _state.metacommandlist.
MetaCommandList / MetaCommand¶
MetaCommandList (in src/execsql/script/engine.py) is an ordered list of MetaCommand objects with a keyword index for fast dispatch. Each MetaCommand holds one compiled regex and one handler. When MetaCommandList.eval() is called on a script line:
- It extracts the leading keyword and narrows candidates via the keyword index, falling back to the full list if no keyword matches.
- It calls the handler, passing all named regex groups plus
"metacommandline"as keyword arguments.
Conditional branching (IF / ELSEIF / ELSE / ENDIF) is structural under the AST executor — the parser turns those metacommands into IfBlock AST nodes and the executor only invokes handlers for the branch it actually entered. Handlers therefore do not need to consult any IF state; the run_when_false and run_in_batch flags that used to gate dispatch under the flat command-list engine were removed in v2.18.1.
Handler naming conventions¶
| Prefix | Purpose |
|---|---|
x_* |
Metacommand handler — registered in build_dispatch_table() |
xf_* |
Conditional test predicate — registered in conditions.py's build_conditional_table() and used by IF/ELSEIF expressions |
Step-by-step: Adding a Metacommand¶
Step 1 — Write the handler function¶
Add the handler to whichever sibling module fits best:
| Module | Handles |
|---|---|
connect.py |
Database connections, USE, DISCONNECT |
control.py |
Control flow: IF, LOOP, BREAK, HALT, batch |
data.py |
Variable manipulation: SUB, SUBDATA, counters |
io.py |
Re-export façade only -- imports and re-exports from the modules below |
io_export.py |
EXPORT handlers |
io_import.py |
IMPORT handlers |
io_write.py |
WRITE and WRITESCRIPT handlers |
io_fileops.py |
INCLUDE, ZIP, CD, SERVE, and other file operations |
prompt.py |
User interaction: PROMPT, ASK, PAUSE, MSG |
system.py |
OS interaction: SYSTEM_CMD, LOG, EMAIL, console |
debug.py |
Debug output |
script_ext.py |
Script extensions |
If none of these fit, create a new module and follow the same structure.
Handler signature: all handlers accept only **kwargs. The keys are the named groups from the matching regex, plus "metacommandline" (the original unmodified command line).
# src/execsql/metacommands/data.py
from typing import Any
import execsql.state as _state
def x_my_command(**kwargs: Any) -> None:
target = kwargs["target"] # from (?P<target>...) in the regex
value = kwargs["value"] # from (?P<value>...) in the regex
# Optional groups are None when absent — check before using.
option = kwargs.get("option")
# Interact with global state as needed.
_state.subvars.add_substitution(target, value)
return None
Key points:
- Return
Noneunless there is a specific reason to return a value (very rare). - Optional regex groups will be
Nonewhen the group did not participate in the match. Always guard withif option:orkwargs.get("option"). - Raise
ErrInfofor expected failure conditions rather than using a bareraiseorsys.exit.
from execsql.exceptions import ErrInfo
def x_my_command(**kwargs: Any) -> None:
if not some_condition:
raise ErrInfo("cmd", command_text=kwargs["metacommandline"],
other_msg="MY_COMMAND: something went wrong")
Step 2 — Register the regex in the dispatch table¶
Open src/execsql/metacommands/dispatch.py and add a mcl.add() call inside build_dispatch_table(). (__init__.py calls this function at import time and assigns the result to DISPATCH_TABLE; you don't edit __init__.py for new registrations.) Order matters: more specific patterns should appear before catch-all patterns. The list is traversed front-to-back and the first match wins.
mcl.add(
r"^\s*MY_COMMAND\s+(?P<target>\w+)\s+(?P<value>.+)$",
x_my_command,
description="MY_COMMAND",
category="action",
)
MetaCommandList.add() accepts these parameters:
| Parameter | Type | Default | Purpose |
|---|---|---|---|
matching_regexes |
str or tuple[str, ...] |
required | One regex string, or a tuple of strings all mapped to the same handler |
exec_func |
callable | required | The handler function |
description |
str \| None |
None |
Human-readable keyword name (shown in DEBUG WRITE METACOMMANDLIST and --dump-keywords) |
set_error_flag |
bool |
True |
Update _state.status.metacommand_error on success/failure |
category |
str \| None |
None |
Keyword category for --dump-keywords and VS Code grammar generation (e.g., "action", "control", "config") |
All regexes are compiled with re.I (case-insensitive) automatically.
Passing multiple regex variants is common when a value may be quoted or unquoted:
mcl.add(
(
r'^\s*MY_COMMAND\s+(?P<target>\w+)\s+"(?P<value>[^"]+)"\s*$',
r"^\s*MY_COMMAND\s+(?P<target>\w+)\s+(?P<value>\S+)\s*$",
),
x_my_command,
description="MY_COMMAND",
)
Using regex composition helpers (from src/execsql/utils/regex.py) avoids duplicating patterns for all quoting styles used by file-name arguments:
from execsql.utils.regex import ins_fn_rxs, ins_table_rxs
# ins_fn_rxs(prefix, suffix) generates variants for bare, quoted, and
# bracket-quoted filenames captured as (?P<filename>...).
mcl.add(
ins_fn_rxs(r"^\s*MY_COMMAND\s+TO\s+", r"\s*$"),
x_my_command,
)
Step 3 — Import the handler in dispatch.py¶
Add the function to the relevant import block at the top of src/execsql/metacommands/dispatch.py so the mcl.add() call in Step 2 can reference it:
Step 4 — Write tests¶
Integration tests (preferred)¶
Integration tests exercise the full CLI pipeline against a temporary SQLite database. Add a new test class to tests/test_metacommands.py (or the appropriate test_metacommands_*.py file):
class TestMyCommand:
"""MY_COMMAND metacommand."""
def test_basic(self, script_runner):
result, db = script_runner("""
-- !x! my_command greeting hello
create table result (val text);
insert into result values ('!!greeting!!');
""")
assert result.exit_code == 0, result.output
assert qdb(db, "SELECT val FROM result") == [("hello",)]
def test_error_case(self, script_runner):
result, db = script_runner("""
-- !x! my_command
""")
# Bad syntax should not crash; exit code depends on HALT_ON_METACOMMAND_ERR
assert "MY_COMMAND" in result.output or result.exit_code != 0
The script_runner fixture:
- Writes the script to a temp
.sqlfile. - Invokes
execsql <script> <db> -t l -nviatyper.testing.CliRunner. - Waits for the
FileWritersubprocess to flush all pending writes. - Returns
(result, db_path).
Use qdb(db_path, sql) to query the resulting SQLite database for assertions.
Unit tests¶
For handlers that are complex enough to warrant isolated testing, use direct function calls with mocked state. See tests/test_metacommands_connect.py for examples:
from unittest.mock import patch, MagicMock
from execsql.metacommands.data import x_my_command
def test_x_my_command_sets_subvar(minimal_conf, tmp_path):
# Set up the minimal state that the handler touches.
from tests.conftest import _setup_subvars
sv = _setup_subvars()
x_my_command(
target="greeting",
value="hello",
option=None,
metacommandline="MY_COMMAND greeting hello",
)
assert sv.get_substitution("greeting") == "hello"
Checklist¶
- Handler function added to the appropriate
src/execsql/metacommands/*.pymodule - Handler imported in
src/execsql/metacommands/dispatch.py -
mcl.add(...)call added inbuild_dispatch_table()withdescription=andcategory= - Run
just install-vscodeto regenerate the VS Code grammar - Integration test added to
tests/test_metacommands.py(or relevant file) -
pytestpasses locally (includingtests/test_registry.pykeyword consistency checks)
Adding a Conditional Test Predicate (xf_*)¶
If your new functionality needs to be usable in IF/ELSEIF expressions (e.g., IF MY_TEST foo bar), add an xf_* function instead. These live in src/execsql/metacommands/conditions.py and are registered in a separate build_conditional_table() function in the same file.
def xf_my_test(**kwargs: Any) -> bool:
"""Return True if the condition is met."""
return kwargs["value1"] == kwargs["value2"]
Then register it in build_conditional_table() (the same MetaCommandList-typed local is conventionally named mcl here too):
mcl.add(
r"^\s*MY_TEST\s+(?P<value1>\S+)\s+(?P<value2>\S+)\s*$",
xf_my_test,
description="MY_TEST",
category="condition",
)
The result is exposed as CONDITIONAL_TABLE and consumed at runtime via _state.conditionallist.