|
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 |
|
119 | 120 | filter_tests_by_patterns, |
120 | 121 | ) |
121 | 122 | from sqlmesh.core.user import User |
122 | | -from sqlmesh.utils import CorrelationId, UniqueKeyDict, Verbosity |
| 123 | +from sqlmesh.utils import CorrelationId, UniqueKeyDict, Verbosity, unique |
123 | 124 | from sqlmesh.utils.concurrency import concurrent_apply_to_values |
124 | 125 | from sqlmesh.utils.dag import DAG |
125 | 126 | from sqlmesh.utils.date import ( |
@@ -2407,17 +2408,26 @@ def test( |
2407 | 2408 | preserve_fixtures: bool = False, |
2408 | 2409 | stream: t.Optional[t.TextIO] = None, |
2409 | 2410 | model_names: t.Optional[t.Collection[str]] = None, |
| 2411 | + raise_on_unknown_paths: bool = False, |
2410 | 2412 | ) -> ModelTextTestResult: |
2411 | 2413 | """Discover and run model tests""" |
2412 | 2414 | if verbosity >= Verbosity.VERBOSE: |
2413 | 2415 | import pandas as pd |
2414 | 2416 |
|
2415 | 2417 | pd.set_option("display.max_columns", None) |
2416 | 2418 |
|
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 | + ) |
2418 | 2425 | if model_names is not None: |
2419 | 2426 | 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, |
2421 | 2431 | ) |
2422 | 2432 | tests_skipped = len(baseline_meta) - len(test_meta) |
2423 | 2433 | else: |
@@ -3611,30 +3621,112 @@ def lint_models( |
3611 | 3621 |
|
3612 | 3622 | return all_violations |
3613 | 3623 |
|
| 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 | + |
3614 | 3685 | def select_tests( |
3615 | 3686 | self, |
3616 | 3687 | tests: t.Optional[t.List[str]] = None, |
3617 | 3688 | patterns: t.Optional[t.List[str]] = None, |
3618 | 3689 | model_names: t.Optional[t.Collection[str]] = None, |
| 3690 | + raise_on_unknown_paths: bool = False, |
3619 | 3691 | ) -> 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 | + """ |
3621 | 3705 |
|
3622 | 3706 | test_meta = self._model_test_metadata |
3623 | 3707 |
|
3624 | 3708 | 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 | + |
3626 | 3713 | 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) |
3636 | 3726 |
|
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) |
3638 | 3730 |
|
3639 | 3731 | if patterns: |
3640 | 3732 | test_meta = filter_tests_by_patterns(test_meta, patterns) |
|
0 commit comments