Skip to content

Commit de236d5

Browse files
authored
Merge branch 'main' into patch-2
2 parents c30f9bc + ad2377e commit de236d5

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
@@ -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
@@ -2611,3 +2664,115 @@ def test_format_does_not_open_state_connection(
26112664
result = runner.invoke(cli, ["--paths", str(tmp_path), "format"])
26122665
assert result.exit_code == 0, f"Format failed: {result.output}\nException: {result.exception}"
26132666
mock.assert_not_called()
2667+
2668+
2669+
def test_test_local_runs_project_unit_tests(runner: CliRunner, tmp_path: Path, mocker) -> None:
2670+
"""A real unit test from the project's YAML runs under `--local` without touching state."""
2671+
create_example_project(tmp_path)
2672+
mock = _patch_state_access(mocker)
2673+
2674+
result = runner.invoke(cli, ["--paths", str(tmp_path), "test", "--local"])
2675+
2676+
assert result.exit_code == 0, f"Test failed: {result.output}\nException: {result.exception}"
2677+
assert "Successfully Ran 1 tests" in " ".join(result.output.split())
2678+
mock.assert_not_called()
2679+
2680+
2681+
def test_test_local_does_not_open_state_connection(
2682+
runner: CliRunner, tmp_path: Path, mocker, monkeypatch
2683+
) -> None:
2684+
"""`test --local` must not open a configured remote Postgres state connection."""
2685+
pytest.importorskip("psycopg2")
2686+
2687+
for var in ("PG_HOST", "PG_USER", "PG_PASSWORD", "PG_DATABASE"):
2688+
monkeypatch.delenv(var, raising=False)
2689+
2690+
create_example_project(tmp_path)
2691+
(tmp_path / "config.yaml").write_text(
2692+
"""project: cli_test
2693+
2694+
gateways:
2695+
prod:
2696+
state_connection:
2697+
type: postgres
2698+
host: "{{ env_var('PG_HOST', 'postgres.internal.example.com') }}"
2699+
port: 5432
2700+
user: "{{ env_var('PG_USER') }}"
2701+
password: "{{ env_var('PG_PASSWORD') }}"
2702+
database: "{{ env_var('PG_DATABASE', 'sqlmesh_state') }}"
2703+
connection:
2704+
type: duckdb
2705+
database: "warehouse.db"
2706+
2707+
default_gateway: prod
2708+
2709+
model_defaults:
2710+
dialect: duckdb
2711+
""",
2712+
encoding="utf-8",
2713+
)
2714+
2715+
mock = _patch_state_access(mocker)
2716+
2717+
result = runner.invoke(cli, ["--paths", str(tmp_path), "test", "--local"])
2718+
assert result.exit_code == 0, f"Test failed: {result.output}\nException: {result.exception}"
2719+
mock.assert_not_called()
2720+
2721+
2722+
def test_test_local_multi_repo_partial(runner: CliRunner, copy_to_temp_path, mocker) -> None:
2723+
"""Run tests for one repo of a multi-repo project whose upstream models live only in state.
2724+
2725+
Pins the behavioral difference against `lint --local`: a model that isn't loaded produces a
2726+
warning and its test is skipped, rather than turning into an error.
2727+
"""
2728+
repo_2 = copy_to_temp_path("examples/multi")[0] / "repo_2"
2729+
2730+
# silver.c lives in repo_2 and its upstream bronze.a is supplied as a test input.
2731+
(repo_2 / "tests" / "test_c.yaml").write_text(
2732+
"""test_silver_c:
2733+
model: silver.c
2734+
inputs:
2735+
bronze.a:
2736+
rows:
2737+
- col_a: 1
2738+
- col_a: 1
2739+
- col_a: 2
2740+
outputs:
2741+
query:
2742+
rows:
2743+
- col_a: 1
2744+
- col_a: 2
2745+
""",
2746+
encoding="utf-8",
2747+
)
2748+
# bronze.a itself is defined in repo_1, so it is not loaded when only repo_2 is given.
2749+
(repo_2 / "tests" / "test_a.yaml").write_text(
2750+
"""test_bronze_a:
2751+
model: bronze.a
2752+
outputs:
2753+
query:
2754+
rows:
2755+
- col_a: 1
2756+
""",
2757+
encoding="utf-8",
2758+
)
2759+
2760+
mock = _patch_state_access(mocker)
2761+
args = ["--gateway", "memory", "--paths", str(repo_2), "test"]
2762+
2763+
# Without --local the same run reaches the state backend.
2764+
runner.invoke(cli, args)
2765+
assert mock.called, "state-sync was never accessed during `test`"
2766+
2767+
mock.reset_mock()
2768+
2769+
result = runner.invoke(cli, [*args, "--local"])
2770+
2771+
assert result.exit_code == 0, f"Test failed: {result.output}\nException: {result.exception}"
2772+
# Console output wraps, so compare against whitespace-normalized text.
2773+
output = " ".join(result.output.split())
2774+
assert 'Model \'"memory"."bronze"."a"\' was not found' in output, (
2775+
"the unloaded model should warn rather than fail"
2776+
)
2777+
assert "Successfully Ran 1 tests" in output, "the repo_2 test should still run"
2778+
mock.assert_not_called()

0 commit comments

Comments
 (0)