Skip to content

Commit e2e34b1

Browse files
authored
Merge branch 'main' into lint-model-paths
2 parents 0f782af + ad2377e commit e2e34b1

13 files changed

Lines changed: 999 additions & 450 deletions

File tree

.github/workflows/pr.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,10 @@ jobs:
470470
path: vscode/extension/playwright-report/
471471
retention-days: 30
472472
test-dbt-versions:
473+
needs: changes
474+
if:
475+
needs.changes.outputs.python == 'true' || needs.changes.outputs.ci ==
476+
'true' || github.ref == 'refs/heads/main'
473477
runs-on: ubuntu-latest
474478
strategy:
475479
fail-fast: false

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ docs
3030
examples
3131
posts
3232
.circleci
33+
.github/
3334
README.md
3435
mkdocs.yml
3536
.readthedocs.yaml

CONTRIBUTING.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,12 @@ See [docs/development.md](docs/development.md) for full setup instructions. Key
6565
python -m venv .venv
6666
source .venv/bin/activate
6767
make install-dev
68-
make style # Run before submitting
68+
make style # Run before submitting
6969
make fast-test # Quick test suite
7070
```
7171

72+
Optionally, `make install-pre-commit` installs git hooks so ruff and mypy run on `git commit`. Hooks do not replace `make style`: they run on staged files, while CI runs `make style` across the tree.
73+
7274
## Coding Standards
7375

7476
- Run `make style` before submitting a pull request

docs/concepts/tests.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,23 @@ 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.
473+
474+
In multi-repository setups, or when running tests for only a subset of projects, models that exist only in remote state are not loaded under `--local`. Unlike [`sqlmesh lint --local`](../guides/linter.md), which reports additional errors in that situation, a test whose model is missing is **skipped with a warning and the run still succeeds**:
475+
476+
```
477+
[WARNING] Model '"memory"."bronze"."a"' was not found at tests/test_a.yaml
478+
.**Successfully Ran `1` Tests Against `duckdb`**
479+
```
480+
481+
So a passing exit code alone does not mean every test you expected actually ran. Watch the output for these warnings, and keep in mind that a hook using `--local` will not fail on them.
482+
466483
### Testing using notebooks
467484

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

docs/development.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ Once you have activated your virtual environment, you can install the dependenci
4242
make install-dev
4343
```
4444

45-
Optionally, you can use pre-commit to automatically run linters/formatters:
45+
Optionally, `make install-pre-commit` installs git hooks so ruff and mypy run on `git commit`. Hooks do not replace `make style`: they run on staged files, while CI runs `make style` across the tree.
4646

4747
```bash
4848
make install-pre-commit

docs/reference/cli.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,9 @@ 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. Tests whose model is not loaded
635+
are skipped with a warning rather than failing.
633636
--help Show this message and exit.
634637
```
635638

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
},
66
"scripts": {
77
"ci": "pnpm run lint && pnpm run -r ci",
8-
"fmt": "prettier --write .",
9-
"fmt:check": "prettier --check .",
8+
"fmt": "prettier --write vscode web/client web/common",
9+
"fmt:check": "prettier --check vscode web/client web/common",
1010
"lint": "pnpm run fmt:check && pnpm run -r lint",
1111
"lint:fix": "pnpm run fmt && pnpm run -r lint:fix"
1212
},

pnpm-lock.yaml

Lines changed: 777 additions & 440 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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. Tests whose model is not loaded are skipped with a warning rather than failing.",
825+
)
814826
@click.argument("tests", nargs=-1)
815827
@click.pass_obj
816828
@error_handler

tests/cli/test_cli.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2657,6 +2657,59 @@ def test_lint_local_runs_without_state(runner: CliRunner, tmp_path: Path, mocker
26572657
mock.assert_not_called()
26582658

26592659

2660+
def test_test_still_loads_state(runner: CliRunner, tmp_path: Path, mocker):
2661+
"""Guard that `test` explicitly passes `load_state=True` and still reaches state sync."""
2662+
mock = _setup_local_only_project(tmp_path, mocker)
2663+
init_spy = mocker.spy(Context, "__init__")
2664+
2665+
runner.invoke(cli, ["--paths", str(tmp_path), "test"])
2666+
2667+
assert init_spy.called, "Context was never constructed"
2668+
for call in init_spy.call_args_list:
2669+
assert "load_state" in call.kwargs, (
2670+
"CLI didn't pass load_state= explicitly; missing kwarg defaults to True silently"
2671+
)
2672+
assert call.kwargs["load_state"] is True, (
2673+
f"Context was constructed with load_state={call.kwargs['load_state']} for `test`"
2674+
)
2675+
assert mock.called, "state-sync was never accessed during `test`"
2676+
2677+
2678+
def test_test_local_runs_without_state(runner: CliRunner, tmp_path: Path, mocker):
2679+
mock = _setup_local_only_project(tmp_path, mocker)
2680+
init_spy = mocker.spy(Context, "__init__")
2681+
2682+
result = runner.invoke(cli, ["--paths", str(tmp_path), "test", "--local"])
2683+
2684+
assert result.exit_code == 0, f"Test failed: {result.output}\nException: {result.exception}"
2685+
assert init_spy.called, "Context was never constructed"
2686+
for call in init_spy.call_args_list:
2687+
assert "load_state" in call.kwargs, (
2688+
"CLI didn't pass load_state= explicitly; missing kwarg defaults to True silently"
2689+
)
2690+
assert call.kwargs["load_state"] is False, (
2691+
f"Context was constructed with load_state={call.kwargs['load_state']} for `test --local`"
2692+
)
2693+
mock.assert_not_called()
2694+
2695+
2696+
def test_test_local_runs_without_state_multiple_paths(
2697+
runner: CliRunner, tmp_path: Path, mocker
2698+
) -> None:
2699+
"""`--local` gating must hold for any number of --paths, matching `lint --local`."""
2700+
project_a = tmp_path / "a"
2701+
project_b = tmp_path / "b"
2702+
_create_local_only_project(project_a, "proj_a")
2703+
_create_local_only_project(project_b, "proj_b")
2704+
mock = _patch_state_access(mocker)
2705+
2706+
result = runner.invoke(
2707+
cli, ["--paths", str(project_a), "--paths", str(project_b), "test", "--local"]
2708+
)
2709+
assert result.exit_code == 0, f"Test failed: {result.output}\nException: {result.exception}"
2710+
mock.assert_not_called()
2711+
2712+
26602713
@pytest.mark.parametrize("command", ["format"])
26612714
def test_local_only_commands_skip_state_multiple_paths(
26622715
runner: CliRunner, tmp_path: Path, mocker, command: str
@@ -2738,3 +2791,115 @@ def test_format_does_not_open_state_connection(
27382791
result = runner.invoke(cli, ["--paths", str(tmp_path), "format"])
27392792
assert result.exit_code == 0, f"Format failed: {result.output}\nException: {result.exception}"
27402793
mock.assert_not_called()
2794+
2795+
2796+
def test_test_local_runs_project_unit_tests(runner: CliRunner, tmp_path: Path, mocker) -> None:
2797+
"""A real unit test from the project's YAML runs under `--local` without touching state."""
2798+
create_example_project(tmp_path)
2799+
mock = _patch_state_access(mocker)
2800+
2801+
result = runner.invoke(cli, ["--paths", str(tmp_path), "test", "--local"])
2802+
2803+
assert result.exit_code == 0, f"Test failed: {result.output}\nException: {result.exception}"
2804+
assert "Successfully Ran 1 tests" in " ".join(result.output.split())
2805+
mock.assert_not_called()
2806+
2807+
2808+
def test_test_local_does_not_open_state_connection(
2809+
runner: CliRunner, tmp_path: Path, mocker, monkeypatch
2810+
) -> None:
2811+
"""`test --local` must not open a configured remote Postgres state connection."""
2812+
pytest.importorskip("psycopg2")
2813+
2814+
for var in ("PG_HOST", "PG_USER", "PG_PASSWORD", "PG_DATABASE"):
2815+
monkeypatch.delenv(var, raising=False)
2816+
2817+
create_example_project(tmp_path)
2818+
(tmp_path / "config.yaml").write_text(
2819+
"""project: cli_test
2820+
2821+
gateways:
2822+
prod:
2823+
state_connection:
2824+
type: postgres
2825+
host: "{{ env_var('PG_HOST', 'postgres.internal.example.com') }}"
2826+
port: 5432
2827+
user: "{{ env_var('PG_USER') }}"
2828+
password: "{{ env_var('PG_PASSWORD') }}"
2829+
database: "{{ env_var('PG_DATABASE', 'sqlmesh_state') }}"
2830+
connection:
2831+
type: duckdb
2832+
database: "warehouse.db"
2833+
2834+
default_gateway: prod
2835+
2836+
model_defaults:
2837+
dialect: duckdb
2838+
""",
2839+
encoding="utf-8",
2840+
)
2841+
2842+
mock = _patch_state_access(mocker)
2843+
2844+
result = runner.invoke(cli, ["--paths", str(tmp_path), "test", "--local"])
2845+
assert result.exit_code == 0, f"Test failed: {result.output}\nException: {result.exception}"
2846+
mock.assert_not_called()
2847+
2848+
2849+
def test_test_local_multi_repo_partial(runner: CliRunner, copy_to_temp_path, mocker) -> None:
2850+
"""Run tests for one repo of a multi-repo project whose upstream models live only in state.
2851+
2852+
Pins the behavioral difference against `lint --local`: a model that isn't loaded produces a
2853+
warning and its test is skipped, rather than turning into an error.
2854+
"""
2855+
repo_2 = copy_to_temp_path("examples/multi")[0] / "repo_2"
2856+
2857+
# silver.c lives in repo_2 and its upstream bronze.a is supplied as a test input.
2858+
(repo_2 / "tests" / "test_c.yaml").write_text(
2859+
"""test_silver_c:
2860+
model: silver.c
2861+
inputs:
2862+
bronze.a:
2863+
rows:
2864+
- col_a: 1
2865+
- col_a: 1
2866+
- col_a: 2
2867+
outputs:
2868+
query:
2869+
rows:
2870+
- col_a: 1
2871+
- col_a: 2
2872+
""",
2873+
encoding="utf-8",
2874+
)
2875+
# bronze.a itself is defined in repo_1, so it is not loaded when only repo_2 is given.
2876+
(repo_2 / "tests" / "test_a.yaml").write_text(
2877+
"""test_bronze_a:
2878+
model: bronze.a
2879+
outputs:
2880+
query:
2881+
rows:
2882+
- col_a: 1
2883+
""",
2884+
encoding="utf-8",
2885+
)
2886+
2887+
mock = _patch_state_access(mocker)
2888+
args = ["--gateway", "memory", "--paths", str(repo_2), "test"]
2889+
2890+
# Without --local the same run reaches the state backend.
2891+
runner.invoke(cli, args)
2892+
assert mock.called, "state-sync was never accessed during `test`"
2893+
2894+
mock.reset_mock()
2895+
2896+
result = runner.invoke(cli, [*args, "--local"])
2897+
2898+
assert result.exit_code == 0, f"Test failed: {result.output}\nException: {result.exception}"
2899+
# Console output wraps, so compare against whitespace-normalized text.
2900+
output = " ".join(result.output.split())
2901+
assert 'Model \'"memory"."bronze"."a"\' was not found' in output, (
2902+
"the unloaded model should warn rather than fail"
2903+
)
2904+
assert "Successfully Ran 1 tests" in output, "the repo_2 test should still run"
2905+
mock.assert_not_called()

0 commit comments

Comments
 (0)