Skip to content

Commit c60d65d

Browse files
authored
Merge branch 'main' into fix/lsp-restart-storm
2 parents 13105e0 + 76225c6 commit c60d65d

6 files changed

Lines changed: 378 additions & 16 deletions

File tree

docs/concepts/tests.md

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

466+
Passing the path of a model file runs the tests for that model, which is useful for commit hooks and other tools that work with changed files rather than test names:
467+
468+
```
469+
$ sqlmesh test models/full_model.sql
470+
```
471+
472+
Model files and test files can be mixed, and the results are unioned. A test selected by more than one argument still runs only once, so the following runs each of `full_model`'s tests a single time even though both arguments cover them:
473+
474+
```
475+
$ sqlmesh test models/full_model.sql tests/test_full_model.yaml
476+
```
477+
478+
An argument that is neither a known model file nor a known test file is an error, so a mistyped or stale path fails instead of quietly running no tests. A model that simply has no tests is not an error.
479+
466480
You can pass `--local` to run tests without loading state from the configured state connection:
467481

468482
``` bash

docs/reference/cli.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,10 @@ Usage: sqlmesh test [OPTIONS] [TESTS]...
623623
624624
Run model unit tests.
625625
626+
TESTS are test files, `file.yaml::test_name` selectors, or model files, in
627+
which case the tests for those models are run. They are unioned, and a test
628+
selected more than once still only runs once.
629+
626630
Options:
627631
-k TEXT Only run tests that match the pattern of substring.
628632
-v, --verbose Verbose output.

sqlmesh/cli/main.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -835,7 +835,12 @@ def test(
835835
select_model: t.List[str],
836836
tests: t.List[str],
837837
) -> None:
838-
"""Run model unit tests."""
838+
"""Run model unit tests.
839+
840+
TESTS are test files, `file.yaml::test_name` selectors, or model files, in which case the
841+
tests for those models are run. They are unioned, and a test selected more than once still
842+
only runs once.
843+
"""
839844
model_names = (
840845
obj._new_selector().expand_model_selections(select_model) if select_model else None
841846
)
@@ -845,6 +850,7 @@ def test(
845850
verbosity=Verbosity(verbose),
846851
preserve_fixtures=preserve_fixtures,
847852
model_names=model_names,
853+
raise_on_unknown_paths=True,
848854
)
849855
if not result.wasSuccessful():
850856
exit(1)

sqlmesh/core/context.py

Lines changed: 107 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import abc
3737
import collections
3838
import logging
39+
import os.path
3940
import sys
4041
import time
4142
import traceback
@@ -119,7 +120,7 @@
119120
filter_tests_by_patterns,
120121
)
121122
from sqlmesh.core.user import User
122-
from sqlmesh.utils import CorrelationId, UniqueKeyDict, Verbosity
123+
from sqlmesh.utils import CorrelationId, UniqueKeyDict, Verbosity, unique
123124
from sqlmesh.utils.concurrency import concurrent_apply_to_values
124125
from sqlmesh.utils.dag import DAG
125126
from sqlmesh.utils.date import (
@@ -2407,17 +2408,26 @@ def test(
24072408
preserve_fixtures: bool = False,
24082409
stream: t.Optional[t.TextIO] = None,
24092410
model_names: t.Optional[t.Collection[str]] = None,
2411+
raise_on_unknown_paths: bool = False,
24102412
) -> ModelTextTestResult:
24112413
"""Discover and run model tests"""
24122414
if verbosity >= Verbosity.VERBOSE:
24132415
import pandas as pd
24142416

24152417
pd.set_option("display.max_columns", None)
24162418

2417-
baseline_meta = self.select_tests(tests=tests, patterns=match_patterns, model_names=None)
2419+
baseline_meta = self.select_tests(
2420+
tests=tests,
2421+
patterns=match_patterns,
2422+
model_names=None,
2423+
raise_on_unknown_paths=raise_on_unknown_paths,
2424+
)
24182425
if model_names is not None:
24192426
test_meta = self.select_tests(
2420-
tests=tests, patterns=match_patterns, model_names=model_names
2427+
tests=tests,
2428+
patterns=match_patterns,
2429+
model_names=model_names,
2430+
raise_on_unknown_paths=raise_on_unknown_paths,
24212431
)
24222432
tests_skipped = len(baseline_meta) - len(test_meta)
24232433
else:
@@ -3611,30 +3621,112 @@ def lint_models(
36113621

36123622
return all_violations
36133623

3624+
def _tests_by_absolute_model_path(self) -> t.Dict[str, t.List[ModelTestMetadata]]:
3625+
"""Map each model file to the tests that target the model(s) defined in it."""
3626+
tests_by_model_name: t.Dict[str, t.List[ModelTestMetadata]] = collections.defaultdict(list)
3627+
for metadata in self._model_test_metadata:
3628+
if metadata.model_name:
3629+
tests_by_model_name[
3630+
normalize_model_name(
3631+
metadata.model_name,
3632+
default_catalog=self.default_catalog,
3633+
dialect=self.default_dialect,
3634+
)
3635+
].append(metadata)
3636+
3637+
# A path is made absolute rather than resolved, so this costs no syscalls per model.
3638+
tests_by_path: t.Dict[str, t.List[ModelTestMetadata]] = {}
3639+
for fqn, model in self._models.items():
3640+
if model._path is not None:
3641+
tests_by_path.setdefault(os.path.abspath(model._path), []).extend(
3642+
tests_by_model_name.get(fqn, [])
3643+
)
3644+
3645+
return tests_by_path
3646+
3647+
def _select_tests_by_test_path(self, selector: str) -> t.Optional[t.List[ModelTestMetadata]]:
3648+
"""Resolve a selector against the test files, or return None if it matches none of them.
3649+
3650+
The selector is a test file path or a `path::test_name`. Paths are matched as given
3651+
first, so an unchanged selector never pays for normalization.
3652+
"""
3653+
if "::" in selector:
3654+
metadata = self._model_test_metadata_fully_qualified_name_index.get(selector)
3655+
if metadata is None:
3656+
path, _, test_name = selector.rpartition("::")
3657+
metadata = self._model_test_metadata_fully_qualified_name_index.get(
3658+
f"{os.path.abspath(path)}::{test_name}"
3659+
)
3660+
return [metadata] if metadata is not None else None
3661+
3662+
for candidate in (Path(selector), Path(os.path.abspath(selector))):
3663+
matched = self._model_test_metadata_path_index.get(candidate)
3664+
if matched is not None:
3665+
return list(matched)
3666+
3667+
return None
3668+
3669+
def _unknown_test_selector_error(self, selector: str) -> str:
3670+
"""Explains why a selector matched nothing.
3671+
3672+
A `path::test_name` whose file is a known test file failed on the test name, not the
3673+
path, so the message says so rather than claiming the file is unknown.
3674+
"""
3675+
if "::" in selector:
3676+
path, _, _ = selector.rpartition("::")
3677+
if any(
3678+
candidate in self._model_test_metadata_path_index
3679+
for candidate in (Path(path), Path(os.path.abspath(path)))
3680+
):
3681+
return f"'{selector}' is not a known test in '{path}'."
3682+
3683+
return f"'{selector}' is not a known model or test file."
3684+
36143685
def select_tests(
36153686
self,
36163687
tests: t.Optional[t.List[str]] = None,
36173688
patterns: t.Optional[t.List[str]] = None,
36183689
model_names: t.Optional[t.Collection[str]] = None,
3690+
raise_on_unknown_paths: bool = False,
36193691
) -> t.List[ModelTestMetadata]:
3620-
"""Filter pre-loaded test metadata based on tests and patterns."""
3692+
"""Filter pre-loaded test metadata based on tests and patterns.
3693+
3694+
Args:
3695+
tests: Test selectors. Each one is a test file path, a `path::test_name`, or the path
3696+
of a model file, in which case that model's tests are selected. Selectors are
3697+
unioned and the result is deduplicated, so a model file and a test file that
3698+
resolve to the same test run it once rather than twice.
3699+
patterns: Patterns matched against fully qualified test names.
3700+
model_names: If given, narrows the selection to tests targeting these models.
3701+
raise_on_unknown_paths: Whether to raise when a selector matches neither a known test
3702+
nor a known model file. Off by default so that callers which probe arbitrary
3703+
documents, such as the LSP, keep getting an empty result instead of an error.
3704+
"""
36213705

36223706
test_meta = self._model_test_metadata
36233707

36243708
if tests:
3625-
filtered_tests = []
3709+
filtered_tests: t.List[ModelTestMetadata] = []
3710+
# Built at most once, and only if a selector turns out not to be a test file.
3711+
tests_by_model_path: t.Optional[t.Dict[str, t.List[ModelTestMetadata]]] = None
3712+
36263713
for test in tests:
3627-
if "::" in test:
3628-
if test in self._model_test_metadata_fully_qualified_name_index:
3629-
filtered_tests.append(
3630-
self._model_test_metadata_fully_qualified_name_index[test]
3631-
)
3632-
else:
3633-
test_path = Path(test)
3634-
if test_path in self._model_test_metadata_path_index:
3635-
filtered_tests.extend(self._model_test_metadata_path_index[test_path])
3714+
matched = self._select_tests_by_test_path(test)
3715+
if matched is None and "::" not in test:
3716+
if tests_by_model_path is None:
3717+
tests_by_model_path = self._tests_by_absolute_model_path()
3718+
# A known model with no tests matches an empty list, which is not the same
3719+
# as a selector that resolves to nothing at all.
3720+
matched = tests_by_model_path.get(os.path.abspath(test))
3721+
if matched is None:
3722+
if raise_on_unknown_paths:
3723+
raise SQLMeshError(self._unknown_test_selector_error(test))
3724+
continue
3725+
filtered_tests.extend(matched)
36363726

3637-
test_meta = filtered_tests
3727+
# Selectors can overlap, e.g. a model file and the test file holding its tests, so
3728+
# the union is deduplicated to avoid running the same test more than once.
3729+
test_meta = unique(filtered_tests)
36383730

36393731
if patterns:
36403732
test_meta = filter_tests_by_patterns(test_meta, patterns)

tests/cli/test_cli.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2666,6 +2666,27 @@ def test_format_does_not_open_state_connection(
26662666
mock.assert_not_called()
26672667

26682668

2669+
def test_test_accepts_model_paths(runner: CliRunner, tmp_path: Path) -> None:
2670+
create_example_project(tmp_path)
2671+
2672+
result = runner.invoke(
2673+
cli, ["--paths", str(tmp_path), "test", str(tmp_path / "models" / "full_model.sql")]
2674+
)
2675+
assert result.exit_code == 0, f"Test failed: {result.output}\nException: {result.exception}"
2676+
assert "Ran 1 test" in result.output
2677+
2678+
2679+
def test_test_unknown_path_fails(runner: CliRunner, tmp_path: Path) -> None:
2680+
"""A staged file that resolves to nothing must fail rather than silently run no tests."""
2681+
create_example_project(tmp_path)
2682+
2683+
result = runner.invoke(
2684+
cli, ["--paths", str(tmp_path), "test", str(tmp_path / "models" / "nope.sql")]
2685+
)
2686+
assert result.exit_code != 0
2687+
assert "is not a known model or test file" in result.output
2688+
2689+
26692690
def test_test_local_runs_project_unit_tests(runner: CliRunner, tmp_path: Path, mocker) -> None:
26702691
"""A real unit test from the project's YAML runs under `--local` without touching state."""
26712692
create_example_project(tmp_path)
@@ -2776,3 +2797,77 @@ def test_test_local_multi_repo_partial(runner: CliRunner, copy_to_temp_path, moc
27762797
)
27772798
assert "Successfully Ran 1 tests" in output, "the repo_2 test should still run"
27782799
mock.assert_not_called()
2800+
2801+
2802+
def test_test_local_with_model_paths(runner: CliRunner, tmp_path: Path, mocker) -> None:
2803+
"""`--local` and model path selectors compose, which is the pre-commit hook case in #6020."""
2804+
create_example_project(tmp_path)
2805+
mock = _patch_state_access(mocker)
2806+
2807+
result = runner.invoke(
2808+
cli,
2809+
["--paths", str(tmp_path), "test", "--local", str(tmp_path / "models" / "full_model.sql")],
2810+
)
2811+
2812+
assert result.exit_code == 0, f"Test failed: {result.output}\nException: {result.exception}"
2813+
assert "Successfully Ran 1 tests" in " ".join(result.output.split())
2814+
mock.assert_not_called()
2815+
2816+
# An unresolvable path still fails loudly, without reaching state.
2817+
result = runner.invoke(
2818+
cli, ["--paths", str(tmp_path), "test", "--local", str(tmp_path / "models" / "nope.sql")]
2819+
)
2820+
assert result.exit_code != 0
2821+
assert "is not a known model or test file" in result.output
2822+
mock.assert_not_called()
2823+
2824+
2825+
def test_test_local_with_python_model_paths(runner: CliRunner, tmp_path: Path, mocker) -> None:
2826+
"""The `--local` + path-selector combination works for Python models too."""
2827+
create_example_project(tmp_path)
2828+
2829+
(tmp_path / "models" / "py_model.py").write_text(
2830+
"""
2831+
import pandas as pd # noqa: TID253
2832+
from sqlmesh import model, ExecutionContext
2833+
import typing as t
2834+
2835+
@model(
2836+
name="sqlmesh_example.py_model",
2837+
columns={"id": "int"},
2838+
)
2839+
def execute(context: ExecutionContext, **kwargs: t.Any) -> pd.DataFrame:
2840+
return pd.DataFrame([{"id": 1}])
2841+
""",
2842+
encoding="utf-8",
2843+
)
2844+
(tmp_path / "tests" / "test_py_model.yaml").write_text(
2845+
"""
2846+
test_py_model:
2847+
model: sqlmesh_example.py_model
2848+
outputs:
2849+
query:
2850+
rows:
2851+
- id: 1
2852+
""",
2853+
encoding="utf-8",
2854+
)
2855+
2856+
mock = _patch_state_access(mocker)
2857+
2858+
result = runner.invoke(
2859+
cli,
2860+
["--paths", str(tmp_path), "test", "--local", str(tmp_path / "models" / "py_model.py")],
2861+
)
2862+
2863+
assert result.exit_code == 0, f"Test failed: {result.output}\nException: {result.exception}"
2864+
assert "Successfully Ran 1 tests" in " ".join(result.output.split())
2865+
mock.assert_not_called()
2866+
2867+
# A Python file that is not a model is still an error rather than a silent no-op.
2868+
result = runner.invoke(
2869+
cli, ["--paths", str(tmp_path), "test", "--local", str(tmp_path / "models" / "nope.py")]
2870+
)
2871+
assert result.exit_code != 0
2872+
assert "is not a known model or test file" in result.output
2873+
mock.assert_not_called()

0 commit comments

Comments
 (0)