|
36 | 36 | import abc |
37 | 37 | import collections |
38 | 38 | import logging |
| 39 | +import os.path |
39 | 40 | import sys |
40 | 41 | import time |
41 | 42 | import traceback |
|
121 | 122 | filter_tests_by_patterns, |
122 | 123 | ) |
123 | 124 | 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 |
125 | 126 | from sqlmesh.utils.concurrency import concurrent_apply_to_values |
126 | 127 | from sqlmesh.utils.dag import DAG |
127 | 128 | from sqlmesh.utils.date import ( |
@@ -2492,17 +2493,26 @@ def test( |
2492 | 2493 | preserve_fixtures: bool = False, |
2493 | 2494 | stream: t.Optional[t.TextIO] = None, |
2494 | 2495 | model_names: t.Optional[t.Collection[str]] = None, |
| 2496 | + raise_on_unknown_paths: bool = False, |
2495 | 2497 | ) -> ModelTextTestResult: |
2496 | 2498 | """Discover and run model tests""" |
2497 | 2499 | if verbosity >= Verbosity.VERBOSE: |
2498 | 2500 | import pandas as pd |
2499 | 2501 |
|
2500 | 2502 | pd.set_option("display.max_columns", None) |
2501 | 2503 |
|
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 | + ) |
2503 | 2510 | if model_names is not None: |
2504 | 2511 | 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, |
2506 | 2516 | ) |
2507 | 2517 | tests_skipped = len(baseline_meta) - len(test_meta) |
2508 | 2518 | else: |
@@ -3696,30 +3706,112 @@ def lint_models( |
3696 | 3706 |
|
3697 | 3707 | return all_violations |
3698 | 3708 |
|
| 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 | + |
3699 | 3770 | def select_tests( |
3700 | 3771 | self, |
3701 | 3772 | tests: t.Optional[t.List[str]] = None, |
3702 | 3773 | patterns: t.Optional[t.List[str]] = None, |
3703 | 3774 | model_names: t.Optional[t.Collection[str]] = None, |
| 3775 | + raise_on_unknown_paths: bool = False, |
3704 | 3776 | ) -> 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 | + """ |
3706 | 3790 |
|
3707 | 3791 | test_meta = self._model_test_metadata |
3708 | 3792 |
|
3709 | 3793 | 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 | + |
3711 | 3798 | 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) |
3721 | 3811 |
|
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) |
3723 | 3815 |
|
3724 | 3816 | if patterns: |
3725 | 3817 | test_meta = filter_tests_by_patterns(test_meta, patterns) |
|
0 commit comments