Skip to content

Commit 73f8dd6

Browse files
committed
Merge branch 'main' into format-skip-load
Resolves the conflicts with #6061, which landed the model-path test selectors. Both were additive rather than contradictory: - sqlmesh/core/context.py: each branch added a different name to the same `from sqlmesh.utils import` line — `str_to_bool` here for the formatting-flag coercion, `unique` there for deduplicating test selectors. Both are kept. - tests/cli/test_cli.py: each branch added its own tests at the same point in the file, so both blocks are kept. sqlmesh/cli/main.py and docs/reference/cli.md merged cleanly. Checked afterwards that both sides survive in context.py: the test-selection work (`_unknown_test_selector_error`, `_tests_by_absolute_model_path`, `raise_on_unknown_paths`) and the format work (`FormatTarget`, the two `_format_targets_for_*` generators, `str_to_bool`) are all present. Signed-off-by: Adegbite Ayoade <tripleaceme@gmail.com>
2 parents c08310b + 76225c6 commit 73f8dd6

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
@@ -627,6 +627,10 @@ Usage: sqlmesh test [OPTIONS] [TESTS]...
627627
628628
Run model unit tests.
629629
630+
TESTS are test files, `file.yaml::test_name` selectors, or model files, in
631+
which case the tests for those models are run. They are unioned, and a test
632+
selected more than once still only runs once.
633+
630634
Options:
631635
-k TEXT Only run tests that match the pattern of substring.
632636
-v, --verbose Verbose output.

sqlmesh/cli/main.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -841,7 +841,12 @@ def test(
841841
select_model: t.List[str],
842842
tests: t.List[str],
843843
) -> None:
844-
"""Run model unit tests."""
844+
"""Run model unit tests.
845+
846+
TESTS are test files, `file.yaml::test_name` selectors, or model files, in which case the
847+
tests for those models are run. They are unioned, and a test selected more than once still
848+
only runs once.
849+
"""
845850
model_names = (
846851
obj._new_selector().expand_model_selections(select_model) if select_model else None
847852
)
@@ -851,6 +856,7 @@ def test(
851856
verbosity=Verbosity(verbose),
852857
preserve_fixtures=preserve_fixtures,
853858
model_names=model_names,
859+
raise_on_unknown_paths=True,
854860
)
855861
if not result.wasSuccessful():
856862
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
@@ -121,7 +122,7 @@
121122
filter_tests_by_patterns,
122123
)
123124
from sqlmesh.core.user import User
124-
from sqlmesh.utils import CorrelationId, UniqueKeyDict, Verbosity, str_to_bool
125+
from sqlmesh.utils import CorrelationId, UniqueKeyDict, Verbosity, str_to_bool, unique
125126
from sqlmesh.utils.concurrency import concurrent_apply_to_values
126127
from sqlmesh.utils.dag import DAG
127128
from sqlmesh.utils.date import (
@@ -2492,17 +2493,26 @@ def test(
24922493
preserve_fixtures: bool = False,
24932494
stream: t.Optional[t.TextIO] = None,
24942495
model_names: t.Optional[t.Collection[str]] = None,
2496+
raise_on_unknown_paths: bool = False,
24952497
) -> ModelTextTestResult:
24962498
"""Discover and run model tests"""
24972499
if verbosity >= Verbosity.VERBOSE:
24982500
import pandas as pd
24992501

25002502
pd.set_option("display.max_columns", None)
25012503

2502-
baseline_meta = self.select_tests(tests=tests, patterns=match_patterns, model_names=None)
2504+
baseline_meta = self.select_tests(
2505+
tests=tests,
2506+
patterns=match_patterns,
2507+
model_names=None,
2508+
raise_on_unknown_paths=raise_on_unknown_paths,
2509+
)
25032510
if model_names is not None:
25042511
test_meta = self.select_tests(
2505-
tests=tests, patterns=match_patterns, model_names=model_names
2512+
tests=tests,
2513+
patterns=match_patterns,
2514+
model_names=model_names,
2515+
raise_on_unknown_paths=raise_on_unknown_paths,
25062516
)
25072517
tests_skipped = len(baseline_meta) - len(test_meta)
25082518
else:
@@ -3696,30 +3706,112 @@ def lint_models(
36963706

36973707
return all_violations
36983708

3709+
def _tests_by_absolute_model_path(self) -> t.Dict[str, t.List[ModelTestMetadata]]:
3710+
"""Map each model file to the tests that target the model(s) defined in it."""
3711+
tests_by_model_name: t.Dict[str, t.List[ModelTestMetadata]] = collections.defaultdict(list)
3712+
for metadata in self._model_test_metadata:
3713+
if metadata.model_name:
3714+
tests_by_model_name[
3715+
normalize_model_name(
3716+
metadata.model_name,
3717+
default_catalog=self.default_catalog,
3718+
dialect=self.default_dialect,
3719+
)
3720+
].append(metadata)
3721+
3722+
# A path is made absolute rather than resolved, so this costs no syscalls per model.
3723+
tests_by_path: t.Dict[str, t.List[ModelTestMetadata]] = {}
3724+
for fqn, model in self._models.items():
3725+
if model._path is not None:
3726+
tests_by_path.setdefault(os.path.abspath(model._path), []).extend(
3727+
tests_by_model_name.get(fqn, [])
3728+
)
3729+
3730+
return tests_by_path
3731+
3732+
def _select_tests_by_test_path(self, selector: str) -> t.Optional[t.List[ModelTestMetadata]]:
3733+
"""Resolve a selector against the test files, or return None if it matches none of them.
3734+
3735+
The selector is a test file path or a `path::test_name`. Paths are matched as given
3736+
first, so an unchanged selector never pays for normalization.
3737+
"""
3738+
if "::" in selector:
3739+
metadata = self._model_test_metadata_fully_qualified_name_index.get(selector)
3740+
if metadata is None:
3741+
path, _, test_name = selector.rpartition("::")
3742+
metadata = self._model_test_metadata_fully_qualified_name_index.get(
3743+
f"{os.path.abspath(path)}::{test_name}"
3744+
)
3745+
return [metadata] if metadata is not None else None
3746+
3747+
for candidate in (Path(selector), Path(os.path.abspath(selector))):
3748+
matched = self._model_test_metadata_path_index.get(candidate)
3749+
if matched is not None:
3750+
return list(matched)
3751+
3752+
return None
3753+
3754+
def _unknown_test_selector_error(self, selector: str) -> str:
3755+
"""Explains why a selector matched nothing.
3756+
3757+
A `path::test_name` whose file is a known test file failed on the test name, not the
3758+
path, so the message says so rather than claiming the file is unknown.
3759+
"""
3760+
if "::" in selector:
3761+
path, _, _ = selector.rpartition("::")
3762+
if any(
3763+
candidate in self._model_test_metadata_path_index
3764+
for candidate in (Path(path), Path(os.path.abspath(path)))
3765+
):
3766+
return f"'{selector}' is not a known test in '{path}'."
3767+
3768+
return f"'{selector}' is not a known model or test file."
3769+
36993770
def select_tests(
37003771
self,
37013772
tests: t.Optional[t.List[str]] = None,
37023773
patterns: t.Optional[t.List[str]] = None,
37033774
model_names: t.Optional[t.Collection[str]] = None,
3775+
raise_on_unknown_paths: bool = False,
37043776
) -> t.List[ModelTestMetadata]:
3705-
"""Filter pre-loaded test metadata based on tests and patterns."""
3777+
"""Filter pre-loaded test metadata based on tests and patterns.
3778+
3779+
Args:
3780+
tests: Test selectors. Each one is a test file path, a `path::test_name`, or the path
3781+
of a model file, in which case that model's tests are selected. Selectors are
3782+
unioned and the result is deduplicated, so a model file and a test file that
3783+
resolve to the same test run it once rather than twice.
3784+
patterns: Patterns matched against fully qualified test names.
3785+
model_names: If given, narrows the selection to tests targeting these models.
3786+
raise_on_unknown_paths: Whether to raise when a selector matches neither a known test
3787+
nor a known model file. Off by default so that callers which probe arbitrary
3788+
documents, such as the LSP, keep getting an empty result instead of an error.
3789+
"""
37063790

37073791
test_meta = self._model_test_metadata
37083792

37093793
if tests:
3710-
filtered_tests = []
3794+
filtered_tests: t.List[ModelTestMetadata] = []
3795+
# Built at most once, and only if a selector turns out not to be a test file.
3796+
tests_by_model_path: t.Optional[t.Dict[str, t.List[ModelTestMetadata]]] = None
3797+
37113798
for test in tests:
3712-
if "::" in test:
3713-
if test in self._model_test_metadata_fully_qualified_name_index:
3714-
filtered_tests.append(
3715-
self._model_test_metadata_fully_qualified_name_index[test]
3716-
)
3717-
else:
3718-
test_path = Path(test)
3719-
if test_path in self._model_test_metadata_path_index:
3720-
filtered_tests.extend(self._model_test_metadata_path_index[test_path])
3799+
matched = self._select_tests_by_test_path(test)
3800+
if matched is None and "::" not in test:
3801+
if tests_by_model_path is None:
3802+
tests_by_model_path = self._tests_by_absolute_model_path()
3803+
# A known model with no tests matches an empty list, which is not the same
3804+
# as a selector that resolves to nothing at all.
3805+
matched = tests_by_model_path.get(os.path.abspath(test))
3806+
if matched is None:
3807+
if raise_on_unknown_paths:
3808+
raise SQLMeshError(self._unknown_test_selector_error(test))
3809+
continue
3810+
filtered_tests.extend(matched)
37213811

3722-
test_meta = filtered_tests
3812+
# Selectors can overlap, e.g. a model file and the test file holding its tests, so
3813+
# the union is deduplicated to avoid running the same test more than once.
3814+
test_meta = unique(filtered_tests)
37233815

37243816
if patterns:
37253817
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
@@ -2690,6 +2690,27 @@ def test_format_without_paths_still_loads_project(
26902690
assert load_spy.called
26912691

26922692

2693+
def test_test_accepts_model_paths(runner: CliRunner, tmp_path: Path) -> None:
2694+
create_example_project(tmp_path)
2695+
2696+
result = runner.invoke(
2697+
cli, ["--paths", str(tmp_path), "test", str(tmp_path / "models" / "full_model.sql")]
2698+
)
2699+
assert result.exit_code == 0, f"Test failed: {result.output}\nException: {result.exception}"
2700+
assert "Ran 1 test" in result.output
2701+
2702+
2703+
def test_test_unknown_path_fails(runner: CliRunner, tmp_path: Path) -> None:
2704+
"""A staged file that resolves to nothing must fail rather than silently run no tests."""
2705+
create_example_project(tmp_path)
2706+
2707+
result = runner.invoke(
2708+
cli, ["--paths", str(tmp_path), "test", str(tmp_path / "models" / "nope.sql")]
2709+
)
2710+
assert result.exit_code != 0
2711+
assert "is not a known model or test file" in result.output
2712+
2713+
26932714
def test_test_local_runs_project_unit_tests(runner: CliRunner, tmp_path: Path, mocker) -> None:
26942715
"""A real unit test from the project's YAML runs under `--local` without touching state."""
26952716
create_example_project(tmp_path)
@@ -2800,3 +2821,77 @@ def test_test_local_multi_repo_partial(runner: CliRunner, copy_to_temp_path, moc
28002821
)
28012822
assert "Successfully Ran 1 tests" in output, "the repo_2 test should still run"
28022823
mock.assert_not_called()
2824+
2825+
2826+
def test_test_local_with_model_paths(runner: CliRunner, tmp_path: Path, mocker) -> None:
2827+
"""`--local` and model path selectors compose, which is the pre-commit hook case in #6020."""
2828+
create_example_project(tmp_path)
2829+
mock = _patch_state_access(mocker)
2830+
2831+
result = runner.invoke(
2832+
cli,
2833+
["--paths", str(tmp_path), "test", "--local", str(tmp_path / "models" / "full_model.sql")],
2834+
)
2835+
2836+
assert result.exit_code == 0, f"Test failed: {result.output}\nException: {result.exception}"
2837+
assert "Successfully Ran 1 tests" in " ".join(result.output.split())
2838+
mock.assert_not_called()
2839+
2840+
# An unresolvable path still fails loudly, without reaching state.
2841+
result = runner.invoke(
2842+
cli, ["--paths", str(tmp_path), "test", "--local", str(tmp_path / "models" / "nope.sql")]
2843+
)
2844+
assert result.exit_code != 0
2845+
assert "is not a known model or test file" in result.output
2846+
mock.assert_not_called()
2847+
2848+
2849+
def test_test_local_with_python_model_paths(runner: CliRunner, tmp_path: Path, mocker) -> None:
2850+
"""The `--local` + path-selector combination works for Python models too."""
2851+
create_example_project(tmp_path)
2852+
2853+
(tmp_path / "models" / "py_model.py").write_text(
2854+
"""
2855+
import pandas as pd # noqa: TID253
2856+
from sqlmesh import model, ExecutionContext
2857+
import typing as t
2858+
2859+
@model(
2860+
name="sqlmesh_example.py_model",
2861+
columns={"id": "int"},
2862+
)
2863+
def execute(context: ExecutionContext, **kwargs: t.Any) -> pd.DataFrame:
2864+
return pd.DataFrame([{"id": 1}])
2865+
""",
2866+
encoding="utf-8",
2867+
)
2868+
(tmp_path / "tests" / "test_py_model.yaml").write_text(
2869+
"""
2870+
test_py_model:
2871+
model: sqlmesh_example.py_model
2872+
outputs:
2873+
query:
2874+
rows:
2875+
- id: 1
2876+
""",
2877+
encoding="utf-8",
2878+
)
2879+
2880+
mock = _patch_state_access(mocker)
2881+
2882+
result = runner.invoke(
2883+
cli,
2884+
["--paths", str(tmp_path), "test", "--local", str(tmp_path / "models" / "py_model.py")],
2885+
)
2886+
2887+
assert result.exit_code == 0, f"Test failed: {result.output}\nException: {result.exception}"
2888+
assert "Successfully Ran 1 tests" in " ".join(result.output.split())
2889+
mock.assert_not_called()
2890+
2891+
# A Python file that is not a model is still an error rather than a silent no-op.
2892+
result = runner.invoke(
2893+
cli, ["--paths", str(tmp_path), "test", "--local", str(tmp_path / "models" / "nope.py")]
2894+
)
2895+
assert result.exit_code != 0
2896+
assert "is not a known model or test file" in result.output
2897+
mock.assert_not_called()

0 commit comments

Comments
 (0)