Skip to content

Commit e947825

Browse files
committed
feat(test): add --local to run tests without loading state
`sqlmesh lint --local` skips loading remote state, but `test` had no equivalent, so a commit hook or an offline unit-test run still opened a connection to the state backend. Handle it the same way lint does: the flag is declared on the command with expose_value=False and the gating happens in the group callback, which is where Context is constructed before the subcommand runs. The two commands now share a single OPTIONAL_LOCAL_COMMANDS tuple rather than each special-casing its own name. As with lint, multi-repository projects that depend on models which exist only in remote state may see missing-reference errors under --local; this is documented alongside the same caveat for lint. Signed-off-by: Adegbite Ayoade <tripleaceme@gmail.com>
1 parent 80731de commit e947825

4 files changed

Lines changed: 81 additions & 2 deletions

File tree

‎docs/concepts/tests.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,14 @@ You can also run tests that match a pattern or substring using a glob pathname e
463463
$ sqlmesh test tests/test_*
464464
```
465465

466+
You can pass `--local` to run tests without loading state from the configured state connection:
467+
468+
``` bash
469+
$ sqlmesh test --local
470+
```
471+
472+
This keeps offline runs and commit hooks from opening a connection to the state backend. As with [`sqlmesh lint --local`](../guides/linter.md), in multi-repository setups, or when running tests for only a subset of projects, `--local` may cause errors because SQLMesh will not resolve references or schemas from models that exist only in remote state.
473+
466474
### Testing using notebooks
467475

468476
You can execute tests on demand using the `%run_test` notebook magic as follows:

‎docs/reference/cli.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,12 @@ Options:
630630
useful for debugging.
631631
--select-model TEXT Select specific models to run unit tests for. Can be
632632
specified multiple times.
633+
--local Run tests using only locally loaded project files
634+
without loading state. In multi-repository setups, or
635+
when running tests for only a subset of projects, this
636+
may cause errors because SQLMesh will not resolve
637+
references or schemas from models that exist only in
638+
remote state.
633639
--help Show this message and exit.
634640
```
635641

‎sqlmesh/cli/main.py‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
)
4343
SKIP_CONTEXT_COMMANDS = ("init", "ui")
4444
LOCAL_ONLY_COMMANDS = ("format",)
45+
# Commands that are local-only when they're passed --local.
46+
OPTIONAL_LOCAL_COMMANDS = ("lint", "test")
4547

4648

4749
class _SQLMeshGroup(click.Group):
@@ -129,8 +131,12 @@ def cli(
129131
load = True
130132
# Local-only gating must hold for any number of --paths, so it stays outside the block below.
131133
load_state = ctx.invoked_subcommand not in LOCAL_ONLY_COMMANDS
132-
# The parent callback constructs Context before Click invokes `lint`, so inspect its parsed args here.
133-
if ctx.invoked_subcommand == "lint" and "--local" in ctx.meta["subcommand_args"]:
134+
# The parent callback constructs Context before Click invokes the subcommand, so inspect its
135+
# parsed args here.
136+
if (
137+
ctx.invoked_subcommand in OPTIONAL_LOCAL_COMMANDS
138+
and "--local" in ctx.meta["subcommand_args"]
139+
):
134140
load_state = False
135141

136142
if len(paths) == 1:
@@ -811,6 +817,12 @@ def create_test(
811817
multiple=True,
812818
help="Select specific models to run unit tests for.",
813819
)
820+
@click.option(
821+
"--local",
822+
is_flag=True,
823+
expose_value=False,
824+
help="Run tests using only locally loaded project files without loading state.",
825+
)
814826
@click.argument("tests", nargs=-1)
815827
@click.pass_obj
816828
@error_handler

‎tests/cli/test_cli.py‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2530,6 +2530,59 @@ def test_lint_local_runs_without_state(runner: CliRunner, tmp_path: Path, mocker
25302530
mock.assert_not_called()
25312531

25322532

2533+
def test_test_still_loads_state(runner: CliRunner, tmp_path: Path, mocker):
2534+
"""Guard that `test` explicitly passes `load_state=True` and still reaches state sync."""
2535+
mock = _setup_local_only_project(tmp_path, mocker)
2536+
init_spy = mocker.spy(Context, "__init__")
2537+
2538+
runner.invoke(cli, ["--paths", str(tmp_path), "test"])
2539+
2540+
assert init_spy.called, "Context was never constructed"
2541+
for call in init_spy.call_args_list:
2542+
assert "load_state" in call.kwargs, (
2543+
"CLI didn't pass load_state= explicitly; missing kwarg defaults to True silently"
2544+
)
2545+
assert call.kwargs["load_state"] is True, (
2546+
f"Context was constructed with load_state={call.kwargs['load_state']} for `test`"
2547+
)
2548+
assert mock.called, "state-sync was never accessed during `test`"
2549+
2550+
2551+
def test_test_local_runs_without_state(runner: CliRunner, tmp_path: Path, mocker):
2552+
mock = _setup_local_only_project(tmp_path, mocker)
2553+
init_spy = mocker.spy(Context, "__init__")
2554+
2555+
result = runner.invoke(cli, ["--paths", str(tmp_path), "test", "--local"])
2556+
2557+
assert result.exit_code == 0, f"Test failed: {result.output}\nException: {result.exception}"
2558+
assert init_spy.called, "Context was never constructed"
2559+
for call in init_spy.call_args_list:
2560+
assert "load_state" in call.kwargs, (
2561+
"CLI didn't pass load_state= explicitly; missing kwarg defaults to True silently"
2562+
)
2563+
assert call.kwargs["load_state"] is False, (
2564+
f"Context was constructed with load_state={call.kwargs['load_state']} for `test --local`"
2565+
)
2566+
mock.assert_not_called()
2567+
2568+
2569+
def test_test_local_runs_without_state_multiple_paths(
2570+
runner: CliRunner, tmp_path: Path, mocker
2571+
) -> None:
2572+
"""`--local` gating must hold for any number of --paths, matching `lint --local`."""
2573+
project_a = tmp_path / "a"
2574+
project_b = tmp_path / "b"
2575+
_create_local_only_project(project_a, "proj_a")
2576+
_create_local_only_project(project_b, "proj_b")
2577+
mock = _patch_state_access(mocker)
2578+
2579+
result = runner.invoke(
2580+
cli, ["--paths", str(project_a), "--paths", str(project_b), "test", "--local"]
2581+
)
2582+
assert result.exit_code == 0, f"Test failed: {result.output}\nException: {result.exception}"
2583+
mock.assert_not_called()
2584+
2585+
25332586
@pytest.mark.parametrize("command", ["format"])
25342587
def test_local_only_commands_skip_state_multiple_paths(
25352588
runner: CliRunner, tmp_path: Path, mocker, command: str

0 commit comments

Comments
 (0)