3636import abc
3737import collections
3838import logging
39+ import os .path
3940import sys
4041import time
4142import traceback
107108 CachingStateSync ,
108109 StateReader ,
109110 StateSync ,
111+ Versions ,
110112)
111113from sqlmesh .core .janitor import cleanup_expired_views , delete_expired_snapshots
112114from sqlmesh .core .table_diff import TableDiff
119121 filter_tests_by_patterns ,
120122)
121123from sqlmesh .core .user import User
122- from sqlmesh .utils import CorrelationId , UniqueKeyDict , Verbosity
124+ from sqlmesh .utils import CorrelationId , UniqueKeyDict , Verbosity , unique
123125from sqlmesh .utils .concurrency import concurrent_apply_to_values
124126from sqlmesh .utils .dag import DAG
125127from sqlmesh .utils .date import (
@@ -2435,17 +2437,26 @@ def test(
24352437 preserve_fixtures : bool = False ,
24362438 stream : t .Optional [t .TextIO ] = None ,
24372439 model_names : t .Optional [t .Collection [str ]] = None ,
2440+ raise_on_unknown_paths : bool = False ,
24382441 ) -> ModelTextTestResult :
24392442 """Discover and run model tests"""
24402443 if verbosity >= Verbosity .VERBOSE :
24412444 import pandas as pd
24422445
24432446 pd .set_option ("display.max_columns" , None )
24442447
2445- baseline_meta = self .select_tests (tests = tests , patterns = match_patterns , model_names = None )
2448+ baseline_meta = self .select_tests (
2449+ tests = tests ,
2450+ patterns = match_patterns ,
2451+ model_names = None ,
2452+ raise_on_unknown_paths = raise_on_unknown_paths ,
2453+ )
24462454 if model_names is not None :
24472455 test_meta = self .select_tests (
2448- tests = tests , patterns = match_patterns , model_names = model_names
2456+ tests = tests ,
2457+ patterns = match_patterns ,
2458+ model_names = model_names ,
2459+ raise_on_unknown_paths = raise_on_unknown_paths ,
24492460 )
24502461 tests_skipped = len (baseline_meta ) - len (test_meta )
24512462 else :
@@ -2628,15 +2639,18 @@ def migrate(self) -> None:
26282639 """
26292640 self .notification_target_manager .notify (NotificationEvent .MIGRATION_START )
26302641 self ._load_materializations ()
2642+ state_sync = self ._new_state_sync ()
2643+ previous_versions = self ._state_versions (state_sync )
26312644 try :
2632- self . _new_state_sync () .migrate (
2645+ state_sync .migrate (
26332646 promoted_snapshots_only = self .config .migration .promoted_snapshots_only ,
26342647 )
26352648 except Exception as e :
26362649 self .notification_target_manager .notify (
26372650 NotificationEvent .MIGRATION_FAILURE , traceback .format_exc ()
26382651 )
26392652 raise e
2653+ self ._print_state_versions (self ._state_versions (state_sync ), previous_versions )
26402654 self .notification_target_manager .notify (NotificationEvent .MIGRATION_END )
26412655
26422656 @python_api_analytics
@@ -2645,7 +2659,10 @@ def rollback(self) -> None:
26452659
26462660 Please contact your SQLMesh administrator before doing this. This action cannot be undone.
26472661 """
2648- self ._new_state_sync ().rollback ()
2662+ state_sync = self ._new_state_sync ()
2663+ previous_versions = self ._state_versions (state_sync )
2664+ state_sync .rollback ()
2665+ self ._print_state_versions (self ._state_versions (state_sync ), previous_versions )
26492666
26502667 @python_api_analytics
26512668 def create_external_models (self , strict : bool = False ) -> None :
@@ -2719,6 +2736,12 @@ def print_info(
27192736 if state_connection :
27202737 self ._try_connection ("state backend" , state_connection .connection_validator ())
27212738
2739+ if verbosity >= Verbosity .VERBOSE :
2740+ try :
2741+ self ._print_state_versions (self ._state_versions ())
2742+ except Exception as ex :
2743+ self .console .log_error (f"Failed to fetch the state backend versions. { ex } " )
2744+
27222745 @python_api_analytics
27232746 def print_environment_names (self ) -> None :
27242747 """Prints all environment names along with expiry datetime."""
@@ -3308,6 +3331,25 @@ def _try_connection(self, connection_name: str, validator: t.Callable[[], None])
33083331 except Exception as ex :
33093332 self .console .log_error (f"{ connection_name } connection failed. { ex } " )
33103333
3334+ def _state_versions (self , state_sync : t .Optional [StateSync ] = None ) -> Versions :
3335+ """Returns the versions recorded in the state backend without validating them."""
3336+ return (state_sync or self ._new_state_sync ()).get_versions (validate = False )
3337+
3338+ def _print_state_versions (
3339+ self , versions : Versions , previous_versions : t .Optional [Versions ] = None
3340+ ) -> None :
3341+ """Prints the state backend versions, optionally alongside the ones they replaced."""
3342+ self .console .log_status_update ("\n State backend versions:" )
3343+ for label , attribute in (
3344+ ("Schema version" , "schema_version" ),
3345+ ("SQLGlot version" , "sqlglot_version" ),
3346+ ("SQLMesh version" , "sqlmesh_version" ),
3347+ ):
3348+ version = getattr (versions , attribute )
3349+ if previous_versions is not None :
3350+ version = f"{ getattr (previous_versions , attribute )} -> { version } "
3351+ self .console .log_status_update (f"{ label } : { version } " )
3352+
33113353 def _new_state_sync (self ) -> StateSync :
33123354 return self ._provided_state_sync or self ._scheduler .create_state_sync (self )
33133355
@@ -3704,30 +3746,112 @@ def lint_models(
37043746
37053747 return all_violations
37063748
3749+ def _tests_by_absolute_model_path (self ) -> t .Dict [str , t .List [ModelTestMetadata ]]:
3750+ """Map each model file to the tests that target the model(s) defined in it."""
3751+ tests_by_model_name : t .Dict [str , t .List [ModelTestMetadata ]] = collections .defaultdict (list )
3752+ for metadata in self ._model_test_metadata :
3753+ if metadata .model_name :
3754+ tests_by_model_name [
3755+ normalize_model_name (
3756+ metadata .model_name ,
3757+ default_catalog = self .default_catalog ,
3758+ dialect = self .default_dialect ,
3759+ )
3760+ ].append (metadata )
3761+
3762+ # A path is made absolute rather than resolved, so this costs no syscalls per model.
3763+ tests_by_path : t .Dict [str , t .List [ModelTestMetadata ]] = {}
3764+ for fqn , model in self ._models .items ():
3765+ if model ._path is not None :
3766+ tests_by_path .setdefault (os .path .abspath (model ._path ), []).extend (
3767+ tests_by_model_name .get (fqn , [])
3768+ )
3769+
3770+ return tests_by_path
3771+
3772+ def _select_tests_by_test_path (self , selector : str ) -> t .Optional [t .List [ModelTestMetadata ]]:
3773+ """Resolve a selector against the test files, or return None if it matches none of them.
3774+
3775+ The selector is a test file path or a `path::test_name`. Paths are matched as given
3776+ first, so an unchanged selector never pays for normalization.
3777+ """
3778+ if "::" in selector :
3779+ metadata = self ._model_test_metadata_fully_qualified_name_index .get (selector )
3780+ if metadata is None :
3781+ path , _ , test_name = selector .rpartition ("::" )
3782+ metadata = self ._model_test_metadata_fully_qualified_name_index .get (
3783+ f"{ os .path .abspath (path )} ::{ test_name } "
3784+ )
3785+ return [metadata ] if metadata is not None else None
3786+
3787+ for candidate in (Path (selector ), Path (os .path .abspath (selector ))):
3788+ matched = self ._model_test_metadata_path_index .get (candidate )
3789+ if matched is not None :
3790+ return list (matched )
3791+
3792+ return None
3793+
3794+ def _unknown_test_selector_error (self , selector : str ) -> str :
3795+ """Explains why a selector matched nothing.
3796+
3797+ A `path::test_name` whose file is a known test file failed on the test name, not the
3798+ path, so the message says so rather than claiming the file is unknown.
3799+ """
3800+ if "::" in selector :
3801+ path , _ , _ = selector .rpartition ("::" )
3802+ if any (
3803+ candidate in self ._model_test_metadata_path_index
3804+ for candidate in (Path (path ), Path (os .path .abspath (path )))
3805+ ):
3806+ return f"'{ selector } ' is not a known test in '{ path } '."
3807+
3808+ return f"'{ selector } ' is not a known model or test file."
3809+
37073810 def select_tests (
37083811 self ,
37093812 tests : t .Optional [t .List [str ]] = None ,
37103813 patterns : t .Optional [t .List [str ]] = None ,
37113814 model_names : t .Optional [t .Collection [str ]] = None ,
3815+ raise_on_unknown_paths : bool = False ,
37123816 ) -> t .List [ModelTestMetadata ]:
3713- """Filter pre-loaded test metadata based on tests and patterns."""
3817+ """Filter pre-loaded test metadata based on tests and patterns.
3818+
3819+ Args:
3820+ tests: Test selectors. Each one is a test file path, a `path::test_name`, or the path
3821+ of a model file, in which case that model's tests are selected. Selectors are
3822+ unioned and the result is deduplicated, so a model file and a test file that
3823+ resolve to the same test run it once rather than twice.
3824+ patterns: Patterns matched against fully qualified test names.
3825+ model_names: If given, narrows the selection to tests targeting these models.
3826+ raise_on_unknown_paths: Whether to raise when a selector matches neither a known test
3827+ nor a known model file. Off by default so that callers which probe arbitrary
3828+ documents, such as the LSP, keep getting an empty result instead of an error.
3829+ """
37143830
37153831 test_meta = self ._model_test_metadata
37163832
37173833 if tests :
3718- filtered_tests = []
3834+ filtered_tests : t .List [ModelTestMetadata ] = []
3835+ # Built at most once, and only if a selector turns out not to be a test file.
3836+ tests_by_model_path : t .Optional [t .Dict [str , t .List [ModelTestMetadata ]]] = None
3837+
37193838 for test in tests :
3720- if "::" in test :
3721- if test in self ._model_test_metadata_fully_qualified_name_index :
3722- filtered_tests .append (
3723- self ._model_test_metadata_fully_qualified_name_index [test ]
3724- )
3725- else :
3726- test_path = Path (test )
3727- if test_path in self ._model_test_metadata_path_index :
3728- filtered_tests .extend (self ._model_test_metadata_path_index [test_path ])
3839+ matched = self ._select_tests_by_test_path (test )
3840+ if matched is None and "::" not in test :
3841+ if tests_by_model_path is None :
3842+ tests_by_model_path = self ._tests_by_absolute_model_path ()
3843+ # A known model with no tests matches an empty list, which is not the same
3844+ # as a selector that resolves to nothing at all.
3845+ matched = tests_by_model_path .get (os .path .abspath (test ))
3846+ if matched is None :
3847+ if raise_on_unknown_paths :
3848+ raise SQLMeshError (self ._unknown_test_selector_error (test ))
3849+ continue
3850+ filtered_tests .extend (matched )
37293851
3730- test_meta = filtered_tests
3852+ # Selectors can overlap, e.g. a model file and the test file holding its tests, so
3853+ # the union is deduplicated to avoid running the same test more than once.
3854+ test_meta = unique (filtered_tests )
37313855
37323856 if patterns :
37333857 test_meta = filter_tests_by_patterns (test_meta , patterns )
0 commit comments