Context
A SQLMesh context encapsulates a SQLMesh environment. When you create a new context, it will discover and
load your project's models, macros, and audits. Afterwards, you can use the context to create and apply
plans, visualize your model's lineage, run your audits and model tests, and perform various other tasks.
For more information regarding what a context can do, see Context.
Examples:
Creating and applying a plan against the staging environment.
from sqlmesh.core.context import Context
context = Context(paths="example", config="local_config")
plan = context.plan("staging")
context.apply(plan)
Running audits on your data.
from sqlmesh.core.context import Context
context = Context(paths="example", config="local_config")
context.audit("yesterday", "now")
Running tests on your models.
from sqlmesh.core.context import Context
context = Context(paths="example")
context.test()
1""" 2# Context 3 4A SQLMesh context encapsulates a SQLMesh environment. When you create a new context, it will discover and 5load your project's models, macros, and audits. Afterwards, you can use the context to create and apply 6plans, visualize your model's lineage, run your audits and model tests, and perform various other tasks. 7For more information regarding what a context can do, see `sqlmesh.core.context.Context`. 8 9# Examples: 10 11Creating and applying a plan against the staging environment. 12```python 13from sqlmesh.core.context import Context 14context = Context(paths="example", config="local_config") 15plan = context.plan("staging") 16context.apply(plan) 17``` 18 19Running audits on your data. 20```python 21from sqlmesh.core.context import Context 22context = Context(paths="example", config="local_config") 23context.audit("yesterday", "now") 24``` 25 26Running tests on your models. 27```python 28from sqlmesh.core.context import Context 29context = Context(paths="example") 30context.test() 31``` 32""" 33 34from __future__ import annotations 35 36import abc 37import collections 38import logging 39import sys 40import time 41import traceback 42import typing as t 43from functools import cached_property 44from io import StringIO 45from itertools import chain 46from pathlib import Path 47from shutil import rmtree 48from types import MappingProxyType 49from datetime import datetime 50 51from sqlglot import Dialect, exp 52from sqlglot.helper import first 53from sqlglot.lineage import GraphHTML 54 55from sqlmesh.core import analytics 56from sqlmesh.core import constants as c 57from sqlmesh.core.analytics import python_api_analytics 58from sqlmesh.core.audit import Audit, ModelAudit, StandaloneAudit 59from sqlmesh.core.config import ( 60 CategorizerConfig, 61 Config, 62 load_configs, 63) 64from sqlmesh.core.config.connection import ConnectionConfig 65from sqlmesh.core.config.loader import C 66from sqlmesh.core.config.root import RegexKeyDict 67from sqlmesh.core.console import get_console 68from sqlmesh.core.context_diff import ContextDiff 69from sqlmesh.core.dialect import ( 70 format_model_expressions, 71 is_meta_expression, 72 normalize_model_name, 73 pandas_to_sql, 74 parse, 75 parse_one, 76) 77from sqlmesh.core.engine_adapter import EngineAdapter 78from sqlmesh.core.environment import Environment, EnvironmentNamingInfo, EnvironmentStatements 79from sqlmesh.core.loader import Loader 80from sqlmesh.core.linter.definition import AnnotatedRuleViolation, Linter 81from sqlmesh.core.linter.rules import BUILTIN_RULES 82from sqlmesh.core.macros import ExecutableOrMacro, macro 83from sqlmesh.core.metric import Metric, rewrite 84from sqlmesh.core.model import Model, update_model_schemas 85from sqlmesh.core.config.model import ModelDefaultsConfig 86from sqlmesh.core.notification_target import ( 87 NotificationEvent, 88 NotificationTarget, 89 NotificationTargetManager, 90) 91from sqlmesh.core.plan import Plan, PlanBuilder, SnapshotIntervals, PlanExplainer 92from sqlmesh.core.plan.definition import UserProvidedFlags 93from sqlmesh.core.reference import ReferenceGraph 94from sqlmesh.core.scheduler import Scheduler, CompletionStatus 95from sqlmesh.core.schema_loader import create_external_models_file 96from sqlmesh.core.selector import Selector, NativeSelector 97from sqlmesh.core.snapshot import ( 98 DeployabilityIndex, 99 Snapshot, 100 SnapshotEvaluator, 101 SnapshotFingerprint, 102 missing_intervals, 103 to_table_mapping, 104) 105from sqlmesh.core.snapshot.definition import get_next_model_interval_start 106from sqlmesh.core.state_sync import ( 107 CachingStateSync, 108 StateReader, 109 StateSync, 110) 111from sqlmesh.core.janitor import cleanup_expired_views, delete_expired_snapshots 112from sqlmesh.core.table_diff import TableDiff 113from sqlmesh.core.test import ( 114 ModelTextTestResult, 115 ModelTestMetadata, 116 generate_test, 117 run_tests, 118 filter_tests_by_patterns, 119) 120from sqlmesh.core.user import User 121from sqlmesh.utils import UniqueKeyDict, Verbosity 122from sqlmesh.utils.concurrency import concurrent_apply_to_values 123from sqlmesh.utils.dag import DAG 124from sqlmesh.utils.date import ( 125 TimeLike, 126 to_timestamp, 127 format_tz_datetime, 128 now_timestamp, 129 now, 130 to_datetime, 131 make_exclusive, 132) 133from sqlmesh.utils.errors import ( 134 CircuitBreakerError, 135 ConfigError, 136 PlanError, 137 SQLMeshError, 138 UncategorizedPlanError, 139 LinterError, 140) 141from sqlmesh.utils.config import print_config 142from sqlmesh.utils.jinja import JinjaMacroRegistry 143from sqlmesh.utils.windows import IS_WINDOWS, fix_windows_path 144 145if t.TYPE_CHECKING: 146 import pandas as pd 147 from typing_extensions import Literal 148 149 from sqlmesh.core.engine_adapter._typing import ( 150 BigframeSession, 151 DF, 152 PySparkDataFrame, 153 PySparkSession, 154 SnowparkSession, 155 ) 156 from sqlmesh.core.snapshot import Node 157 158 from sqlmesh.core.snapshot.definition import Intervals 159 160 ModelOrSnapshot = t.Union[str, Model, Snapshot] 161 NodeOrSnapshot = t.Union[str, Model, StandaloneAudit, Snapshot] 162 163logger = logging.getLogger(__name__) 164 165 166class BaseContext(abc.ABC): 167 """The base context which defines methods to execute a model.""" 168 169 @property 170 @abc.abstractmethod 171 def default_dialect(self) -> t.Optional[str]: 172 """Returns the default dialect.""" 173 174 @property 175 @abc.abstractmethod 176 def _model_tables(self) -> t.Dict[str, str]: 177 """Returns a mapping of model names to tables.""" 178 179 @property 180 @abc.abstractmethod 181 def engine_adapter(self) -> EngineAdapter: 182 """Returns an engine adapter.""" 183 184 @property 185 def spark(self) -> t.Optional[PySparkSession]: 186 """Returns the spark session if it exists.""" 187 return self.engine_adapter.spark 188 189 @property 190 def snowpark(self) -> t.Optional[SnowparkSession]: 191 """Returns the snowpark session if it exists.""" 192 return self.engine_adapter.snowpark 193 194 @property 195 def bigframe(self) -> t.Optional[BigframeSession]: 196 """Returns the bigframe session if it exists.""" 197 return self.engine_adapter.bigframe 198 199 @property 200 def default_catalog(self) -> t.Optional[str]: 201 raise NotImplementedError 202 203 def table(self, model_name: str) -> str: 204 get_console().log_warning( 205 "The SQLMesh context's `table` method is deprecated and will be removed " 206 "in a future release. Please use the `resolve_table` method instead." 207 ) 208 return self.resolve_table(model_name) 209 210 def resolve_table(self, model_name: str) -> str: 211 """Gets the physical table name for a given model. 212 213 Args: 214 model_name: The model name. 215 216 Returns: 217 The physical table name. 218 """ 219 model_name = normalize_model_name(model_name, self.default_catalog, self.default_dialect) 220 221 if model_name not in self._model_tables: 222 model_name_list = "\n".join(list(self._model_tables)) 223 logger.debug( 224 f"'{model_name}' not found in model to table mapping. Available model names: \n{model_name_list}" 225 ) 226 raise SQLMeshError( 227 f"Unable to find a table mapping for model '{model_name}'. Has it been spelled correctly?" 228 ) 229 230 # We generate SQL for the default dialect because the table name may be used in a 231 # fetchdf call and so the quotes need to be correct (eg. backticks for bigquery) 232 return parse_one(self._model_tables[model_name]).sql( 233 dialect=self.default_dialect, identify=True 234 ) 235 236 def fetchdf( 237 self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False 238 ) -> pd.DataFrame: 239 """Fetches a dataframe given a sql string or sqlglot expression. 240 241 Args: 242 query: SQL string or sqlglot expression. 243 quote_identifiers: Whether to quote all identifiers in the query. 244 245 Returns: 246 The default dataframe is Pandas, but for Spark a PySpark dataframe is returned. 247 """ 248 return self.engine_adapter.fetchdf(query, quote_identifiers=quote_identifiers) 249 250 def fetch_pyspark_df( 251 self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False 252 ) -> PySparkDataFrame: 253 """Fetches a PySpark dataframe given a sql string or sqlglot expression. 254 255 Args: 256 query: SQL string or sqlglot expression. 257 quote_identifiers: Whether to quote all identifiers in the query. 258 259 Returns: 260 A PySpark dataframe. 261 """ 262 return self.engine_adapter.fetch_pyspark_df(query, quote_identifiers=quote_identifiers) 263 264 265class ExecutionContext(BaseContext): 266 """The minimal context needed to execute a model. 267 268 Args: 269 engine_adapter: The engine adapter to execute queries against. 270 snapshots: All upstream snapshots (by model name) to use for expansion and mapping of physical locations. 271 deployability_index: Determines snapshots that are deployable in the context of this evaluation. 272 """ 273 274 def __init__( 275 self, 276 engine_adapter: EngineAdapter, 277 snapshots: t.Dict[str, Snapshot], 278 deployability_index: t.Optional[DeployabilityIndex] = None, 279 default_dialect: t.Optional[str] = None, 280 default_catalog: t.Optional[str] = None, 281 is_restatement: t.Optional[bool] = None, 282 parent_intervals: t.Optional[Intervals] = None, 283 variables: t.Optional[t.Dict[str, t.Any]] = None, 284 blueprint_variables: t.Optional[t.Dict[str, t.Any]] = None, 285 ): 286 self.snapshots = snapshots 287 self.deployability_index = deployability_index 288 self._engine_adapter = engine_adapter 289 self._default_catalog = default_catalog 290 self._default_dialect = default_dialect 291 self._variables = variables or {} 292 self._blueprint_variables = blueprint_variables or {} 293 self._is_restatement = is_restatement 294 self._parent_intervals = parent_intervals 295 296 @property 297 def default_dialect(self) -> t.Optional[str]: 298 return self._default_dialect 299 300 @property 301 def engine_adapter(self) -> EngineAdapter: 302 """Returns an engine adapter.""" 303 return self._engine_adapter 304 305 @cached_property 306 def _model_tables(self) -> t.Dict[str, str]: 307 """Returns a mapping of model names to tables.""" 308 return to_table_mapping(self.snapshots.values(), self.deployability_index) 309 310 @property 311 def default_catalog(self) -> t.Optional[str]: 312 return self._default_catalog 313 314 @property 315 def gateway(self) -> t.Optional[str]: 316 """Returns the gateway name.""" 317 return self.var(c.GATEWAY) 318 319 @property 320 def is_restatement(self) -> t.Optional[bool]: 321 return self._is_restatement 322 323 @property 324 def parent_intervals(self) -> t.Optional[Intervals]: 325 return self._parent_intervals 326 327 def var(self, var_name: str, default: t.Optional[t.Any] = None) -> t.Optional[t.Any]: 328 """Returns a variable value.""" 329 return self._variables.get(var_name.lower(), default) 330 331 def blueprint_var(self, var_name: str, default: t.Optional[t.Any] = None) -> t.Optional[t.Any]: 332 """Returns a blueprint variable value.""" 333 return self._blueprint_variables.get(var_name.lower(), default) 334 335 def with_variables( 336 self, 337 variables: t.Dict[str, t.Any], 338 blueprint_variables: t.Optional[t.Dict[str, t.Any]] = None, 339 ) -> ExecutionContext: 340 """Returns a new ExecutionContext with additional variables.""" 341 return ExecutionContext( 342 self._engine_adapter, 343 self.snapshots, 344 self.deployability_index, 345 self._default_dialect, 346 self._default_catalog, 347 self._is_restatement, 348 variables=variables, 349 blueprint_variables=blueprint_variables, 350 ) 351 352 353class GenericContext(BaseContext, t.Generic[C]): 354 """Encapsulates a SQLMesh environment supplying convenient functions to perform various tasks. 355 356 Args: 357 notification_targets: The notification target to use. Defaults to what is defined in config. 358 paths: The directories containing SQLMesh files. 359 config: A Config object or the name of a Config object in config.py. 360 connection: The name of the connection. If not specified the first connection as it appears 361 in configuration will be used. 362 test_connection: The name of the connection to use for tests. If not specified the first 363 connection as it appears in configuration will be used. 364 concurrent_tasks: The maximum number of tasks that can use the connection concurrently. 365 load: Whether or not to automatically load all models and macros (default True). 366 load_state: Whether to merge remote state into the local project during load (default True). 367 Only intended for local-only operations like format; plan/apply in multi-repo projects 368 require it to see models owned by other projects. 369 console: The rich instance used for printing out CLI command results. 370 users: A list of users to make known to SQLMesh. 371 """ 372 373 CONFIG_TYPE: t.Type[C] 374 """The type of config object to use (default: Config).""" 375 376 PLAN_BUILDER_TYPE = PlanBuilder 377 """The type of plan builder object to use (default: PlanBuilder).""" 378 379 def __init__( 380 self, 381 notification_targets: t.Optional[t.List[NotificationTarget]] = None, 382 state_sync: t.Optional[StateSync] = None, 383 paths: t.Union[str | Path, t.Iterable[str | Path]] = "", 384 config: t.Optional[t.Union[C, str, t.Dict[Path, C]]] = None, 385 gateway: t.Optional[str] = None, 386 concurrent_tasks: t.Optional[int] = None, 387 loader: t.Optional[t.Type[Loader]] = None, 388 load: bool = True, 389 users: t.Optional[t.List[User]] = None, 390 config_loader_kwargs: t.Optional[t.Dict[str, t.Any]] = None, 391 selector: t.Optional[t.Type[Selector]] = None, 392 load_state: bool = True, 393 ): 394 self.configs = ( 395 config 396 if isinstance(config, dict) 397 else load_configs(config, self.CONFIG_TYPE, paths, **(config_loader_kwargs or {})) 398 ) 399 self._projects = {config.project for config in self.configs.values()} 400 self.dag: DAG[str] = DAG() 401 self._models: UniqueKeyDict[str, Model] = UniqueKeyDict("models") 402 self._audits: UniqueKeyDict[str, ModelAudit] = UniqueKeyDict("audits") 403 self._standalone_audits: UniqueKeyDict[str, StandaloneAudit] = UniqueKeyDict( 404 "standaloneaudits" 405 ) 406 self._model_test_metadata: t.List[ModelTestMetadata] = [] 407 self._model_test_metadata_path_index: t.Dict[Path, t.List[ModelTestMetadata]] = {} 408 self._model_test_metadata_fully_qualified_name_index: t.Dict[str, ModelTestMetadata] = {} 409 self._models_with_tests: t.Set[str] = set() 410 411 self._macros: UniqueKeyDict[str, ExecutableOrMacro] = UniqueKeyDict("macros") 412 self._metrics: UniqueKeyDict[str, Metric] = UniqueKeyDict("metrics") 413 self._jinja_macros = JinjaMacroRegistry() 414 self._requirements: t.Dict[str, str] = {} 415 self._environment_statements: t.List[EnvironmentStatements] = [] 416 self._excluded_requirements: t.Set[str] = set() 417 self._engine_adapter: t.Optional[EngineAdapter] = None 418 self._linters: t.Dict[str, Linter] = {} 419 self._loaded: bool = False 420 self._load_state: bool = load_state 421 self._selector_cls = selector or NativeSelector 422 423 self.path, self.config = t.cast(t.Tuple[Path, C], next(iter(self.configs.items()))) 424 425 self._all_dialects: t.Set[str] = {self.config.dialect or ""} 426 427 if self.config.disable_anonymized_analytics: 428 analytics.disable_analytics() 429 430 self.gateway = gateway 431 self._scheduler = self.config.get_scheduler(self.gateway) 432 self.environment_ttl = self.config.environment_ttl 433 self.pinned_environments = Environment.sanitize_names(self.config.pinned_environments) 434 self.auto_categorize_changes = self.config.plan.auto_categorize_changes 435 self.selected_gateway = (gateway or self.config.default_gateway_name).lower() 436 437 gw_model_defaults = self.config.get_gateway(self.selected_gateway).model_defaults 438 if gw_model_defaults: 439 # Merge global model defaults with the selected gateway's, if it's overriden 440 global_defaults = self.config.model_defaults.model_dump(exclude_unset=True) 441 gateway_defaults = gw_model_defaults.model_dump(exclude_unset=True) 442 443 self.config.model_defaults = ModelDefaultsConfig( 444 **{**global_defaults, **gateway_defaults} 445 ) 446 447 # This allows overriding the default dialect's normalization strategy, so for example 448 # one can do `dialect="duckdb,normalization_strategy=lowercase"` and this will be 449 # applied to the DuckDB dialect globally 450 if "normalization_strategy" in str(self.config.dialect): 451 dialect = Dialect.get_or_raise(self.config.dialect) 452 type(dialect).NORMALIZATION_STRATEGY = dialect.normalization_strategy 453 454 self._loaders = [ 455 (loader or config.loader)(self, path, **config.loader_kwargs) 456 for path, config in self.configs.items() 457 ] 458 459 self._concurrent_tasks = concurrent_tasks 460 self._state_connection_config = ( 461 self.config.get_state_connection(self.gateway) or self.connection_config 462 ) 463 464 self._snapshot_evaluator: t.Optional[SnapshotEvaluator] = None 465 466 self.console = get_console() 467 setattr(self.console, "dialect", self.config.dialect) 468 469 self._provided_state_sync: t.Optional[StateSync] = state_sync 470 self._state_sync: t.Optional[StateSync] = None 471 472 # Should we dedupe notification_targets? If so how? 473 self.notification_targets = (notification_targets or []) + self.config.notification_targets 474 self.users = (users or []) + self.config.users 475 self.users = list({user.username: user for user in self.users}.values()) 476 self._register_notification_targets() 477 478 if load: 479 self.load() 480 481 @property 482 def default_dialect(self) -> t.Optional[str]: 483 return self.config.dialect 484 485 @property 486 def engine_adapter(self) -> EngineAdapter: 487 """Returns the default engine adapter.""" 488 if self._engine_adapter is None: 489 self._engine_adapter = self.connection_config.create_engine_adapter() 490 return self._engine_adapter 491 492 @property 493 def snapshot_evaluator(self) -> SnapshotEvaluator: 494 if not self._snapshot_evaluator: 495 self._ensure_virtual_catalog_injection() 496 self._snapshot_evaluator = SnapshotEvaluator( 497 { 498 gateway: adapter.with_settings(execute_log_level=logging.INFO) 499 for gateway, adapter in self.engine_adapters.items() 500 }, 501 ddl_concurrent_tasks=self.concurrent_tasks, 502 selected_gateway=self.selected_gateway, 503 ) 504 return self._snapshot_evaluator 505 506 def _ensure_virtual_catalog_injection(self) -> None: 507 """Ensure virtual catalog injection has run before adapters are cloned for SnapshotEvaluator. 508 509 Injection is a side effect of get_default_catalog_per_gateway. In normal usage it fires 510 earlier (default_catalog is accessed during model loading), but this guard covers the edge 511 case where snapshot_evaluator is accessed directly on a fresh context before any model ops. 512 """ 513 _ = self.default_catalog_per_gateway 514 515 def execution_context( 516 self, 517 deployability_index: t.Optional[DeployabilityIndex] = None, 518 engine_adapter: t.Optional[EngineAdapter] = None, 519 snapshots: t.Optional[t.Dict[str, Snapshot]] = None, 520 ) -> ExecutionContext: 521 """Returns an execution context.""" 522 return ExecutionContext( 523 engine_adapter=engine_adapter or self.engine_adapter, 524 snapshots=snapshots or self.snapshots, 525 deployability_index=deployability_index, 526 default_dialect=self.default_dialect, 527 default_catalog=self.default_catalog, 528 ) 529 530 @python_api_analytics 531 def upsert_model(self, model: t.Union[str, Model], **kwargs: t.Any) -> Model: 532 """Update or insert a model. 533 534 The context's models dictionary will be updated to include these changes. 535 536 Args: 537 model: Model name or instance to update. 538 kwargs: The kwargs to update the model with. 539 540 Returns: 541 A new instance of the updated or inserted model. 542 """ 543 model = self.get_model(model, raise_if_missing=True) 544 if not model.enabled: 545 raise SQLMeshError(f"The disabled model '{model.name}' cannot be upserted") 546 path = model._path 547 548 model = model.copy(update=kwargs) 549 model._path = path 550 551 self.dag.add(model.fqn, model.depends_on) 552 553 self._models.update( 554 { 555 model.fqn: model, 556 # bust the fingerprint cache for all downstream models 557 **{fqn: self._models[fqn].copy() for fqn in self.dag.downstream(model.fqn)}, 558 } 559 ) 560 561 update_model_schemas( 562 self.dag, 563 models=self._models, 564 cache_dir=self.cache_dir, 565 ) 566 567 if model.dialect: 568 self._all_dialects.add(model.dialect) 569 570 model.validate_definition() 571 572 return model 573 574 def scheduler( 575 self, 576 environment: t.Optional[str] = None, 577 snapshot_evaluator: t.Optional[SnapshotEvaluator] = None, 578 ) -> Scheduler: 579 """Returns the built-in scheduler. 580 581 Args: 582 environment: The target environment to source model snapshots from, or None 583 if snapshots should be sourced from the currently loaded local state. 584 585 Returns: 586 The built-in scheduler instance. 587 """ 588 snapshots: t.Iterable[Snapshot] 589 if environment is not None: 590 stored_environment = self.state_sync.get_environment(environment) 591 if stored_environment is None: 592 raise ConfigError(f"Environment '{environment}' was not found.") 593 snapshots = self.state_sync.get_snapshots(stored_environment.snapshots).values() 594 else: 595 snapshots = self.snapshots.values() 596 597 if not snapshots: 598 raise ConfigError("No models were found") 599 600 return self.create_scheduler(snapshots, snapshot_evaluator or self.snapshot_evaluator) 601 602 def create_scheduler( 603 self, snapshots: t.Iterable[Snapshot], snapshot_evaluator: SnapshotEvaluator 604 ) -> Scheduler: 605 """Creates the built-in scheduler. 606 607 Args: 608 snapshots: The snapshots to schedule. 609 610 Returns: 611 The built-in scheduler instance. 612 """ 613 return Scheduler( 614 snapshots, 615 snapshot_evaluator, 616 self.state_sync, 617 default_catalog=self.default_catalog, 618 max_workers=self.concurrent_tasks, 619 console=self.console, 620 notification_target_manager=self.notification_target_manager, 621 ) 622 623 @property 624 def state_sync(self) -> StateSync: 625 if not self._state_sync: 626 self._state_sync = self._new_state_sync() 627 628 if self._state_sync.get_versions(validate=False).schema_version == 0: 629 self.console.log_status_update("Initializing new project state...") 630 self._state_sync.migrate() 631 self._state_sync.get_versions() 632 self._state_sync = CachingStateSync(self._state_sync) # type: ignore 633 return self._state_sync 634 635 @property 636 def state_reader(self) -> StateReader: 637 return self.state_sync 638 639 def refresh(self) -> None: 640 """Refresh all models that have been updated.""" 641 if any(loader.reload_needed() for loader in self._loaders): 642 self.load() 643 644 def load(self, update_schemas: bool = True) -> GenericContext[C]: 645 """Load all files in the context's path.""" 646 load_start_ts = time.perf_counter() 647 648 loaded_projects = [loader.load() for loader in self._loaders] 649 650 self.dag = DAG() 651 self._standalone_audits.clear() 652 self._audits.clear() 653 self._macros.clear() 654 self._models.clear() 655 self._metrics.clear() 656 self._requirements.clear() 657 self._excluded_requirements.clear() 658 self._linters.clear() 659 self._environment_statements = [] 660 self._model_test_metadata.clear() 661 self._model_test_metadata_path_index.clear() 662 self._model_test_metadata_fully_qualified_name_index.clear() 663 self._models_with_tests.clear() 664 665 for loader, project in zip(self._loaders, loaded_projects): 666 self._jinja_macros = self._jinja_macros.merge(project.jinja_macros) 667 self._macros.update(project.macros) 668 self._models.update(project.models) 669 self._metrics.update(project.metrics) 670 self._audits.update(project.audits) 671 self._standalone_audits.update(project.standalone_audits) 672 self._requirements.update(project.requirements) 673 self._excluded_requirements.update(project.excluded_requirements) 674 self._environment_statements.extend(project.environment_statements) 675 676 self._model_test_metadata.extend(project.model_test_metadata) 677 for metadata in project.model_test_metadata: 678 if metadata.path not in self._model_test_metadata_path_index: 679 self._model_test_metadata_path_index[metadata.path] = [] 680 self._model_test_metadata_path_index[metadata.path].append(metadata) 681 self._model_test_metadata_fully_qualified_name_index[ 682 metadata.fully_qualified_test_name 683 ] = metadata 684 self._models_with_tests.add(metadata.model_name) 685 686 config = loader.config 687 self._linters[config.project] = Linter.from_rules( 688 BUILTIN_RULES.union(project.user_rules), config.linter 689 ) 690 691 # Load environment statements from state for projects not in current load 692 if self._load_state and any(self._projects): 693 prod = self.state_reader.get_environment(c.PROD) 694 if prod: 695 existing_statements = self.state_reader.get_environment_statements(c.PROD) 696 for stmt in existing_statements: 697 if stmt.project and stmt.project not in self._projects: 698 self._environment_statements.append(stmt) 699 700 uncached = set() 701 702 if self._load_state and any(self._projects): 703 prod = self.state_reader.get_environment(c.PROD) 704 705 if prod: 706 for snapshot in self.state_reader.get_snapshots(prod.snapshots).values(): 707 if snapshot.node.project in self._projects: 708 uncached.add(snapshot.name) 709 else: 710 local_store = self._standalone_audits if snapshot.is_audit else self._models 711 if snapshot.name in local_store: 712 uncached.add(snapshot.name) 713 else: 714 local_store[snapshot.name] = snapshot.node # type: ignore 715 716 for model in self._models.values(): 717 self.dag.add(model.fqn, model.depends_on) 718 719 if update_schemas: 720 for fqn in self.dag: 721 model = self._models.get(fqn) # type: ignore 722 723 if not model or fqn in uncached: 724 continue 725 726 # make a copy of remote models that depend on local models or in the downstream chain 727 # without this, a SELECT * FROM local will not propogate properly because the downstream 728 # model will get mutated (schema changes) but the object is the same as the remote cache 729 if any(dep in uncached for dep in model.depends_on): 730 uncached.add(fqn) 731 self._models.update({fqn: model.copy(update={"mapping_schema": {}})}) 732 continue 733 734 update_model_schemas( 735 self.dag, 736 models=self._models, 737 cache_dir=self.cache_dir, 738 ) 739 740 models = self.models.values() 741 for model in models: 742 # The model definition can be validated correctly only after the schema is set. 743 model.validate_definition() 744 745 duplicates = set(self._models) & set(self._standalone_audits) 746 if duplicates: 747 raise ConfigError( 748 f"Models and Standalone audits cannot have the same name: {duplicates}" 749 ) 750 751 self._all_dialects = {m.dialect for m in self._models.values() if m.dialect} | { 752 self.default_dialect or "" 753 } 754 755 analytics.collector.on_project_loaded( 756 project_type=self._project_type, 757 models_count=len(self._models), 758 audits_count=len(self._audits), 759 standalone_audits_count=len(self._standalone_audits), 760 macros_count=len(self._macros), 761 jinja_macros_count=len(self._jinja_macros.root_macros), 762 load_time_sec=time.perf_counter() - load_start_ts, 763 state_sync_fingerprint=self._scheduler.state_sync_fingerprint(self), 764 project_name=self.config.project, 765 ) 766 767 self._loaded = True 768 return self 769 770 @python_api_analytics 771 def run( 772 self, 773 environment: t.Optional[str] = None, 774 *, 775 start: t.Optional[TimeLike] = None, 776 end: t.Optional[TimeLike] = None, 777 execution_time: t.Optional[TimeLike] = None, 778 skip_janitor: bool = False, 779 ignore_cron: bool = False, 780 select_models: t.Optional[t.Collection[str]] = None, 781 exit_on_env_update: t.Optional[int] = None, 782 no_auto_upstream: bool = False, 783 ) -> CompletionStatus: 784 """Run the entire dag through the scheduler. 785 786 Args: 787 environment: The target environment to source model snapshots from and virtually update. Default: prod. 788 start: The start of the interval to render. 789 end: The end of the interval to render. 790 execution_time: The date/time time reference to use for execution time. Defaults to now. 791 skip_janitor: Whether to skip the janitor task. 792 ignore_cron: Whether to ignore the model's cron schedule and run all available missing intervals. 793 select_models: A list of model selection expressions to filter models that should run. Note that 794 upstream dependencies of selected models will also be evaluated. 795 exit_on_env_update: If set, exits with the provided code if the run is interrupted by an update 796 to the target environment. 797 no_auto_upstream: Whether to not force upstream models to run. Only applicable when using `select_models`. 798 799 Returns: 800 True if the run was successful, False otherwise. 801 """ 802 environment = environment or self.config.default_target_environment 803 environment = Environment.sanitize_name(environment) 804 if not skip_janitor and environment.lower() == c.PROD: 805 self._run_janitor() 806 807 self.notification_target_manager.notify( 808 NotificationEvent.RUN_START, environment=environment 809 ) 810 analytics_run_id = analytics.collector.on_run_start( 811 engine_type=self.snapshot_evaluator.adapter.dialect, 812 state_sync_type=self.state_sync.state_type(), 813 ) 814 self._load_materializations() 815 816 env_check_attempts_num = max( 817 1, 818 self.config.run.environment_check_max_wait 819 // self.config.run.environment_check_interval, 820 ) 821 822 def _block_until_finalized() -> str: 823 for _ in range(env_check_attempts_num): 824 assert environment is not None # mypy 825 environment_state = self.state_sync.get_environment(environment) 826 if not environment_state: 827 raise SQLMeshError(f"Environment '{environment}' was not found.") 828 if environment_state.finalized_ts: 829 return environment_state.plan_id 830 self.console.log_warning( 831 f"Environment '{environment}' is being updated by plan '{environment_state.plan_id}'. " 832 f"Retrying in {self.config.run.environment_check_interval} seconds..." 833 ) 834 time.sleep(self.config.run.environment_check_interval) 835 raise SQLMeshError( 836 f"Exceeded the maximum wait time for environment '{environment}' to be ready. " 837 "This means that the environment either failed to update or the update is taking longer than expected. " 838 "See https://sqlmesh.readthedocs.io/en/stable/reference/configuration/#run to adjust the timeout settings." 839 ) 840 841 success = False 842 interrupted = False 843 done = False 844 while not done: 845 plan_id_at_start = _block_until_finalized() 846 847 def _has_environment_changed() -> bool: 848 assert environment is not None # mypy 849 current_environment_state = self.state_sync.get_environment(environment) 850 return ( 851 not current_environment_state 852 or current_environment_state.plan_id != plan_id_at_start 853 or not current_environment_state.finalized_ts 854 ) 855 856 try: 857 completion_status = self._run( 858 environment, 859 start=start, 860 end=end, 861 execution_time=execution_time, 862 ignore_cron=ignore_cron, 863 select_models=select_models, 864 circuit_breaker=_has_environment_changed, 865 no_auto_upstream=no_auto_upstream, 866 ) 867 done = True 868 except CircuitBreakerError: 869 self.console.log_warning( 870 f"Environment '{environment}' modified while running. Restarting the run..." 871 ) 872 if exit_on_env_update: 873 interrupted = True 874 done = True 875 except Exception as e: 876 self.notification_target_manager.notify( 877 NotificationEvent.RUN_FAILURE, traceback.format_exc() 878 ) 879 logger.info("Run failed.", exc_info=e) 880 analytics.collector.on_run_end( 881 run_id=analytics_run_id, succeeded=False, interrupted=False, error=e 882 ) 883 raise e 884 885 if completion_status.is_success or interrupted: 886 self.notification_target_manager.notify( 887 NotificationEvent.RUN_END, environment=environment 888 ) 889 self.console.log_success(f"Run finished for environment '{environment}'") 890 elif completion_status.is_failure: 891 self.notification_target_manager.notify( 892 NotificationEvent.RUN_FAILURE, "See console logs for details." 893 ) 894 895 analytics.collector.on_run_end( 896 run_id=analytics_run_id, succeeded=success, interrupted=interrupted 897 ) 898 899 if interrupted and exit_on_env_update is not None: 900 sys.exit(exit_on_env_update) 901 902 return completion_status 903 904 @python_api_analytics 905 def run_janitor( 906 self, 907 ignore_ttl: bool, 908 force_delete: bool = False, 909 environment: t.Optional[str] = None, 910 ) -> bool: 911 if environment is not None: 912 environment = Environment.sanitize_name(environment) 913 914 success = False 915 916 if self.console.start_cleanup(ignore_ttl): 917 try: 918 self._run_janitor(ignore_ttl, force_delete=force_delete, environment=environment) 919 success = True 920 finally: 921 self.console.stop_cleanup(success=success) 922 923 return success 924 925 @python_api_analytics 926 def destroy(self) -> bool: 927 success = False 928 929 # Collect resources to be deleted 930 environments = self.state_reader.get_environments() 931 schemas_to_delete = set() 932 tables_to_delete = set() 933 views_to_delete = set() 934 all_snapshot_infos = set() 935 936 # For each environment find schemas and tables 937 for environment in environments: 938 all_snapshot_infos.update(environment.snapshots) 939 snapshots = self.state_reader.get_snapshots(environment.snapshots).values() 940 for snapshot in snapshots: 941 if snapshot.is_model and not snapshot.is_symbolic: 942 # Get the appropriate adapter 943 if environment.gateway_managed and snapshot.model_gateway: 944 adapter = self.engine_adapters.get( 945 snapshot.model_gateway, self.engine_adapter 946 ) 947 else: 948 adapter = self.engine_adapter 949 950 if environment.suffix_target.is_schema or environment.suffix_target.is_catalog: 951 schema = snapshot.qualified_view_name.schema_for_environment( 952 environment.naming_info, dialect=adapter.dialect 953 ) 954 catalog = snapshot.qualified_view_name.catalog_for_environment( 955 environment.naming_info, dialect=adapter.dialect 956 ) 957 if catalog: 958 schemas_to_delete.add(f"{catalog}.{schema}") 959 else: 960 schemas_to_delete.add(schema) 961 962 if environment.suffix_target.is_table: 963 view_name = snapshot.qualified_view_name.for_environment( 964 environment.naming_info, dialect=adapter.dialect 965 ) 966 views_to_delete.add(view_name) 967 968 # Add snapshot tables 969 table_name = snapshot.table_name() 970 tables_to_delete.add(table_name) 971 972 if self.console.start_destroy(schemas_to_delete, views_to_delete, tables_to_delete): 973 try: 974 success = self._destroy() 975 finally: 976 self.console.stop_destroy(success=success) 977 978 return success 979 980 @t.overload 981 def get_model( 982 self, model_or_snapshot: ModelOrSnapshot, raise_if_missing: Literal[True] = True 983 ) -> Model: ... 984 985 @t.overload 986 def get_model( 987 self, 988 model_or_snapshot: ModelOrSnapshot, 989 raise_if_missing: Literal[False] = False, 990 ) -> t.Optional[Model]: ... 991 992 def get_model( 993 self, model_or_snapshot: ModelOrSnapshot, raise_if_missing: bool = False 994 ) -> t.Optional[Model]: 995 """Returns a model with the given name or None if a model with such name doesn't exist. 996 997 Args: 998 model_or_snapshot: A model name, model, or snapshot. 999 raise_if_missing: Raises an error if a model is not found. 1000 1001 Returns: 1002 The expected model. 1003 """ 1004 if isinstance(model_or_snapshot, Snapshot): 1005 return model_or_snapshot.model 1006 if not isinstance(model_or_snapshot, str): 1007 return model_or_snapshot 1008 1009 try: 1010 # We should try all dialects referenced in the project for cases when models use mixed dialects. 1011 for dialect in self._all_dialects: 1012 normalized_name = normalize_model_name( 1013 model_or_snapshot, 1014 dialect=dialect, 1015 default_catalog=self.default_catalog, 1016 ) 1017 if normalized_name in self._models: 1018 return self._models[normalized_name] 1019 except: 1020 pass 1021 1022 if raise_if_missing: 1023 if model_or_snapshot.endswith((".sql", ".py")): 1024 msg = "Resolving models by path is not supported, please pass in the model name instead." 1025 else: 1026 msg = f"Cannot find model with name '{model_or_snapshot}'" 1027 1028 raise SQLMeshError(msg) 1029 1030 return None 1031 1032 @t.overload 1033 def get_snapshot(self, node_or_snapshot: NodeOrSnapshot) -> t.Optional[Snapshot]: ... 1034 1035 @t.overload 1036 def get_snapshot( 1037 self, node_or_snapshot: NodeOrSnapshot, raise_if_missing: Literal[True] 1038 ) -> Snapshot: ... 1039 1040 @t.overload 1041 def get_snapshot( 1042 self, node_or_snapshot: NodeOrSnapshot, raise_if_missing: Literal[False] 1043 ) -> t.Optional[Snapshot]: ... 1044 1045 def get_snapshot( 1046 self, node_or_snapshot: NodeOrSnapshot, raise_if_missing: bool = False 1047 ) -> t.Optional[Snapshot]: 1048 """Returns a snapshot with the given name or None if a snapshot with such name doesn't exist. 1049 1050 Args: 1051 node_or_snapshot: A node name, node, or snapshot. 1052 raise_if_missing: Raises an error if a snapshot is not found. 1053 1054 Returns: 1055 The expected snapshot. 1056 """ 1057 if isinstance(node_or_snapshot, Snapshot): 1058 return node_or_snapshot 1059 fqn = self._node_or_snapshot_to_fqn(node_or_snapshot) 1060 snapshot = self.snapshots.get(fqn) 1061 1062 if raise_if_missing and not snapshot: 1063 raise SQLMeshError(f"Cannot find snapshot for '{fqn}'") 1064 1065 return snapshot 1066 1067 def config_for_path(self, path: Path) -> t.Tuple[Config, Path]: 1068 """Returns the config and path of the said project for a given file path.""" 1069 for config_path, config in self.configs.items(): 1070 try: 1071 path.relative_to(config_path) 1072 return config, config_path 1073 except ValueError: 1074 pass 1075 return self.config, self.path 1076 1077 def config_for_node(self, node: Model | Audit) -> Config: 1078 path = node._path 1079 if path is None: 1080 return self.config 1081 return self.config_for_path(path)[0] # type: ignore 1082 1083 @property 1084 def models(self) -> MappingProxyType[str, Model]: 1085 """Returns all registered models in this context.""" 1086 return MappingProxyType(self._models) 1087 1088 @property 1089 def metrics(self) -> MappingProxyType[str, Metric]: 1090 """Returns all registered metrics in this context.""" 1091 return MappingProxyType(self._metrics) 1092 1093 @property 1094 def standalone_audits(self) -> MappingProxyType[str, StandaloneAudit]: 1095 """Returns all registered standalone audits in this context.""" 1096 return MappingProxyType(self._standalone_audits) 1097 1098 @property 1099 def models_with_tests(self) -> t.Set[str]: 1100 """Returns all models with tests in this context.""" 1101 return self._models_with_tests 1102 1103 @property 1104 def snapshots(self) -> t.Dict[str, Snapshot]: 1105 """Generates and returns snapshots based on models registered in this context. 1106 1107 If one of the snapshots has been previously stored in the persisted state, the stored 1108 instance will be returned. 1109 """ 1110 return self._snapshots() 1111 1112 @property 1113 def requirements(self) -> t.Dict[str, str]: 1114 """Returns the Python dependencies of the project loaded in this context.""" 1115 return self._requirements.copy() 1116 1117 @cached_property 1118 def default_catalog(self) -> t.Optional[str]: 1119 return self.default_catalog_per_gateway.get(self.selected_gateway) 1120 1121 @python_api_analytics 1122 def render( 1123 self, 1124 model_or_snapshot: ModelOrSnapshot, 1125 *, 1126 start: t.Optional[TimeLike] = None, 1127 end: t.Optional[TimeLike] = None, 1128 execution_time: t.Optional[TimeLike] = None, 1129 expand: t.Union[bool, t.Iterable[str]] = False, 1130 **kwargs: t.Any, 1131 ) -> exp.Expr: 1132 """Renders a model's query, expanding macros with provided kwargs, and optionally expanding referenced models. 1133 1134 Args: 1135 model_or_snapshot: The model, model name, or snapshot to render. 1136 start: The start of the interval to render. 1137 end: The end of the interval to render. 1138 execution_time: The date/time time reference to use for execution time. Defaults to now. 1139 expand: Whether or not to use expand materialized models, defaults to False. 1140 If True, all referenced models are expanded as raw queries. 1141 If a list, only referenced models are expanded as raw queries. 1142 1143 Returns: 1144 The rendered expression. 1145 """ 1146 execution_time = execution_time or now() 1147 1148 model = self.get_model(model_or_snapshot, raise_if_missing=True) 1149 1150 if expand and not isinstance(expand, bool): 1151 expand = { 1152 normalize_model_name( 1153 x, default_catalog=self.default_catalog, dialect=self.default_dialect 1154 ) 1155 for x in expand 1156 } 1157 1158 expand = self.dag.upstream(model.fqn) if expand is True else expand or [] 1159 1160 if model.is_seed: 1161 import pandas as pd 1162 1163 df = next( 1164 model.render( 1165 context=self.execution_context( 1166 engine_adapter=self._get_engine_adapter(model.gateway) 1167 ), 1168 start=start, 1169 end=end, 1170 execution_time=execution_time, 1171 **kwargs, 1172 ) 1173 ) 1174 return next(pandas_to_sql(t.cast(pd.DataFrame, df), model.columns_to_types)) 1175 1176 snapshots = self.snapshots 1177 deployability_index = DeployabilityIndex.create(snapshots.values(), start=start) 1178 1179 return model.render_query_or_raise( 1180 start=start, 1181 end=end, 1182 execution_time=execution_time, 1183 snapshots=snapshots, 1184 expand=expand, 1185 deployability_index=deployability_index, 1186 engine_adapter=self._get_engine_adapter(model.gateway), 1187 **kwargs, 1188 ) 1189 1190 @python_api_analytics 1191 def evaluate( 1192 self, 1193 model_or_snapshot: ModelOrSnapshot, 1194 start: TimeLike, 1195 end: TimeLike, 1196 execution_time: TimeLike, 1197 limit: t.Optional[int] = None, 1198 **kwargs: t.Any, 1199 ) -> DF: 1200 """Evaluate a model or snapshot (running its query against a DB/Engine). 1201 1202 This method is used to test or iterate on models without side effects. 1203 1204 Args: 1205 model_or_snapshot: The model, model name, or snapshot to render. 1206 start: The start of the interval to evaluate. 1207 end: The end of the interval to evaluate. 1208 execution_time: The date/time time reference to use for execution time. 1209 limit: A limit applied to the model. 1210 """ 1211 snapshots = self.snapshots 1212 fqn = self._node_or_snapshot_to_fqn(model_or_snapshot) 1213 if fqn not in snapshots: 1214 raise SQLMeshError(f"Cannot find snapshot for '{fqn}'") 1215 snapshot = snapshots[fqn] 1216 1217 # Expand all uncategorized parents since physical tables don't exist for them yet 1218 expand = [ 1219 parent 1220 for parent in self.dag.upstream(snapshot.model.fqn) 1221 if (parent_snapshot := snapshots.get(parent)) 1222 and parent_snapshot.is_model 1223 and parent_snapshot.model.is_sql 1224 and not parent_snapshot.categorized 1225 ] 1226 1227 df = self.snapshot_evaluator.evaluate_and_fetch( 1228 snapshot, 1229 start=start, 1230 end=end, 1231 execution_time=execution_time, 1232 snapshots=self.snapshots, 1233 limit=limit or c.DEFAULT_MAX_LIMIT, 1234 expand=expand, 1235 ) 1236 1237 if df is None: 1238 raise RuntimeError(f"Error evaluating {snapshot.name}") 1239 1240 return df 1241 1242 @python_api_analytics 1243 def format( 1244 self, 1245 transpile: t.Optional[str] = None, 1246 rewrite_casts: t.Optional[bool] = None, 1247 append_newline: t.Optional[bool] = None, 1248 *, 1249 check: t.Optional[bool] = None, 1250 paths: t.Optional[t.Tuple[t.Union[str, Path], ...]] = None, 1251 **kwargs: t.Any, 1252 ) -> bool: 1253 """Format all SQL models and audits.""" 1254 filtered_targets = [ 1255 target 1256 for target in chain(self._models.values(), self._audits.values()) 1257 if target._path is not None 1258 and target._path.suffix == ".sql" 1259 and (not paths or any(target._path.samefile(p) for p in paths)) 1260 ] 1261 unformatted_file_paths = [] 1262 1263 for target in filtered_targets: 1264 if ( 1265 target._path is None or target.formatting is False 1266 ): # introduced to satisfy type checker as still want to pull filter out as many targets as possible before loop 1267 continue 1268 1269 with open(target._path, "r+", encoding="utf-8") as file: 1270 before = file.read() 1271 1272 after = self._format( 1273 target, 1274 before, 1275 transpile=transpile, 1276 rewrite_casts=rewrite_casts, 1277 append_newline=append_newline, 1278 **kwargs, 1279 ) 1280 1281 if not check: 1282 file.seek(0) 1283 file.write(after) 1284 file.truncate() 1285 elif before != after: 1286 unformatted_file_paths.append(target._path) 1287 1288 if unformatted_file_paths: 1289 for path in unformatted_file_paths: 1290 self.console.log_status_update(f"{path} needs reformatting.") 1291 self.console.log_status_update( 1292 f"\n{len(unformatted_file_paths)} file(s) need reformatting." 1293 ) 1294 return False 1295 1296 return True 1297 1298 def _format( 1299 self, 1300 target: Model | Audit, 1301 before: str, 1302 *, 1303 transpile: t.Optional[str] = None, 1304 rewrite_casts: t.Optional[bool] = None, 1305 append_newline: t.Optional[bool] = None, 1306 **kwargs: t.Any, 1307 ) -> str: 1308 expressions = parse(before, default_dialect=self.config_for_node(target).dialect) 1309 if transpile and is_meta_expression(expressions[0]): 1310 for prop in expressions[0].expressions: 1311 if prop.name.lower() == "dialect": 1312 prop.replace( 1313 exp.Property( 1314 this="dialect", 1315 value=exp.Literal.string(transpile or target.dialect), 1316 ) 1317 ) 1318 1319 format_config = self.config_for_node(target).format 1320 after = format_model_expressions( 1321 expressions, 1322 transpile or target.dialect, 1323 rewrite_casts=( 1324 rewrite_casts if rewrite_casts is not None else not format_config.no_rewrite_casts 1325 ), 1326 **{**format_config.generator_options, **kwargs}, 1327 ) 1328 1329 if append_newline is None: 1330 append_newline = format_config.append_newline 1331 if append_newline: 1332 after += "\n" 1333 1334 return after 1335 1336 @python_api_analytics 1337 def plan( 1338 self, 1339 environment: t.Optional[str] = None, 1340 *, 1341 start: t.Optional[TimeLike] = None, 1342 end: t.Optional[TimeLike] = None, 1343 execution_time: t.Optional[TimeLike] = None, 1344 create_from: t.Optional[str] = None, 1345 skip_tests: t.Optional[bool] = None, 1346 restate_models: t.Optional[t.Iterable[str]] = None, 1347 no_gaps: t.Optional[bool] = None, 1348 skip_backfill: t.Optional[bool] = None, 1349 empty_backfill: t.Optional[bool] = None, 1350 forward_only: t.Optional[bool] = None, 1351 allow_destructive_models: t.Optional[t.Collection[str]] = None, 1352 allow_additive_models: t.Optional[t.Collection[str]] = None, 1353 no_prompts: t.Optional[bool] = None, 1354 auto_apply: t.Optional[bool] = None, 1355 no_auto_categorization: t.Optional[bool] = None, 1356 effective_from: t.Optional[TimeLike] = None, 1357 include_unmodified: t.Optional[bool] = None, 1358 select_models: t.Optional[t.Collection[str]] = None, 1359 backfill_models: t.Optional[t.Collection[str]] = None, 1360 categorizer_config: t.Optional[CategorizerConfig] = None, 1361 enable_preview: t.Optional[bool] = None, 1362 no_diff: t.Optional[bool] = None, 1363 run: t.Optional[bool] = None, 1364 diff_rendered: t.Optional[bool] = None, 1365 skip_linter: t.Optional[bool] = None, 1366 explain: t.Optional[bool] = None, 1367 ignore_cron: t.Optional[bool] = None, 1368 min_intervals: t.Optional[int] = None, 1369 ) -> Plan: 1370 """Interactively creates a plan. 1371 1372 This method compares the current context with the target environment. It then presents 1373 the differences and asks whether to backfill each modified model. 1374 1375 Args: 1376 environment: The environment to diff and plan against. 1377 start: The start date of the backfill if there is one. 1378 end: The end date of the backfill if there is one. 1379 execution_time: The date/time reference to use for execution time. Defaults to now. 1380 create_from: The environment to create the target environment from if it 1381 doesn't exist. If not specified, the "prod" environment will be used. 1382 skip_tests: Unit tests are run by default so this will skip them if enabled 1383 restate_models: A list of either internal or external models, or tags, that need to be restated 1384 for the given plan interval. If the target environment is a production environment, 1385 ALL snapshots that depended on these upstream tables will have their intervals deleted 1386 (even ones not in this current environment). Only the snapshots in this environment will 1387 be backfilled whereas others need to be recovered on a future plan application. For development 1388 environments only snapshots that are part of this plan will be affected. 1389 no_gaps: Whether to ensure that new snapshots for models that are already a 1390 part of the target environment have no data gaps when compared against previous 1391 snapshots for same models. 1392 skip_backfill: Whether to skip the backfill step. Default: False. 1393 empty_backfill: Like skip_backfill, but also records processed intervals. 1394 forward_only: Whether the purpose of the plan is to make forward only changes. 1395 allow_destructive_models: Models whose forward-only changes are allowed to be destructive. 1396 allow_additive_models: Models whose forward-only changes are allowed to be additive. 1397 no_prompts: Whether to disable interactive prompts for the backfill time range. Please note that 1398 if this flag is set to true and there are uncategorized changes the plan creation will 1399 fail. Default: False. 1400 auto_apply: Whether to automatically apply the new plan after creation. Default: False. 1401 no_auto_categorization: Indicates whether to disable automatic categorization of model 1402 changes (breaking / non-breaking). If not provided, then the corresponding configuration 1403 option determines the behavior. 1404 categorizer_config: The configuration for the categorizer. Uses the categorizer configuration defined in the 1405 project config by default. 1406 effective_from: The effective date from which to apply forward-only changes on production. 1407 include_unmodified: Indicates whether to include unmodified models in the target development environment. 1408 select_models: A list of model selection strings to filter the models that should be included into this plan. 1409 backfill_models: A list of model selection strings to filter the models for which the data should be backfilled. 1410 enable_preview: Indicates whether to enable preview for forward-only models in development environments. 1411 no_diff: Hide text differences for changed models. 1412 run: Whether to run latest intervals as part of the plan application. 1413 diff_rendered: Whether the diff should compare raw vs rendered models 1414 skip_linter: Linter runs by default so this will skip it if enabled 1415 explain: Whether to explain the plan instead of applying it. 1416 min_intervals: Adjust the plan start date on a per-model basis in order to ensure at least this many intervals are covered 1417 on every model when checking for missing intervals 1418 1419 Returns: 1420 The populated Plan object. 1421 """ 1422 plan_builder = self.plan_builder( 1423 environment, 1424 start=start, 1425 end=end, 1426 execution_time=execution_time, 1427 create_from=create_from, 1428 skip_tests=skip_tests, 1429 restate_models=restate_models, 1430 no_gaps=no_gaps, 1431 skip_backfill=skip_backfill, 1432 empty_backfill=empty_backfill, 1433 forward_only=forward_only, 1434 allow_destructive_models=allow_destructive_models, 1435 allow_additive_models=allow_additive_models, 1436 no_auto_categorization=no_auto_categorization, 1437 effective_from=effective_from, 1438 include_unmodified=include_unmodified, 1439 select_models=select_models, 1440 backfill_models=backfill_models, 1441 categorizer_config=categorizer_config, 1442 enable_preview=enable_preview, 1443 run=run, 1444 diff_rendered=diff_rendered, 1445 skip_linter=skip_linter, 1446 explain=explain, 1447 ignore_cron=ignore_cron, 1448 min_intervals=min_intervals, 1449 ) 1450 1451 plan = plan_builder.build() 1452 1453 self._warn_if_virtual_catalog_rematerialization(plan) 1454 1455 if no_auto_categorization or plan.uncategorized: 1456 # Prompts are required if the auto categorization is disabled 1457 # or if there are any uncategorized snapshots in the plan 1458 no_prompts = False 1459 1460 if explain: 1461 auto_apply = True 1462 1463 self.console.plan( 1464 plan_builder, 1465 auto_apply if auto_apply is not None else self.config.plan.auto_apply, 1466 self.default_catalog, 1467 no_diff=no_diff if no_diff is not None else self.config.plan.no_diff, 1468 no_prompts=no_prompts if no_prompts is not None else self.config.plan.no_prompts, 1469 ) 1470 1471 return plan 1472 1473 @python_api_analytics 1474 def plan_builder( 1475 self, 1476 environment: t.Optional[str] = None, 1477 *, 1478 start: t.Optional[TimeLike] = None, 1479 end: t.Optional[TimeLike] = None, 1480 execution_time: t.Optional[TimeLike] = None, 1481 create_from: t.Optional[str] = None, 1482 skip_tests: t.Optional[bool] = None, 1483 restate_models: t.Optional[t.Iterable[str]] = None, 1484 no_gaps: t.Optional[bool] = None, 1485 skip_backfill: t.Optional[bool] = None, 1486 empty_backfill: t.Optional[bool] = None, 1487 forward_only: t.Optional[bool] = None, 1488 allow_destructive_models: t.Optional[t.Collection[str]] = None, 1489 allow_additive_models: t.Optional[t.Collection[str]] = None, 1490 no_auto_categorization: t.Optional[bool] = None, 1491 effective_from: t.Optional[TimeLike] = None, 1492 include_unmodified: t.Optional[bool] = None, 1493 select_models: t.Optional[t.Collection[str]] = None, 1494 backfill_models: t.Optional[t.Collection[str]] = None, 1495 categorizer_config: t.Optional[CategorizerConfig] = None, 1496 enable_preview: t.Optional[bool] = None, 1497 preview_start: t.Optional[TimeLike] = None, 1498 preview_min_intervals: t.Optional[int] = None, 1499 run: t.Optional[bool] = None, 1500 diff_rendered: t.Optional[bool] = None, 1501 skip_linter: t.Optional[bool] = None, 1502 explain: t.Optional[bool] = None, 1503 ignore_cron: t.Optional[bool] = None, 1504 min_intervals: t.Optional[int] = None, 1505 always_include_local_changes: t.Optional[bool] = None, 1506 ) -> PlanBuilder: 1507 """Creates a plan builder. 1508 1509 Args: 1510 environment: The environment to diff and plan against. 1511 start: The start date of the backfill if there is one. 1512 end: The end date of the backfill if there is one. 1513 execution_time: The date/time reference to use for execution time. Defaults to now. 1514 create_from: The environment to create the target environment from if it 1515 doesn't exist. If not specified, the "prod" environment will be used. 1516 skip_tests: Unit tests are run by default so this will skip them if enabled 1517 restate_models: A list of either internal or external models, or tags, that need to be restated 1518 for the given plan interval. If the target environment is a production environment, 1519 ALL snapshots that depended on these upstream tables will have their intervals deleted 1520 (even ones not in this current environment). Only the snapshots in this environment will 1521 be backfilled whereas others need to be recovered on a future plan application. For development 1522 environments only snapshots that are part of this plan will be affected. 1523 no_gaps: Whether to ensure that new snapshots for models that are already a 1524 part of the target environment have no data gaps when compared against previous 1525 snapshots for same models. 1526 skip_backfill: Whether to skip the backfill step. Default: False. 1527 empty_backfill: Like skip_backfill, but also records processed intervals. 1528 forward_only: Whether the purpose of the plan is to make forward only changes. 1529 allow_destructive_models: Models whose forward-only changes are allowed to be destructive. 1530 no_auto_categorization: Indicates whether to disable automatic categorization of model 1531 changes (breaking / non-breaking). If not provided, then the corresponding configuration 1532 option determines the behavior. 1533 categorizer_config: The configuration for the categorizer. Uses the categorizer configuration defined in the 1534 project config by default. 1535 effective_from: The effective date from which to apply forward-only changes on production. 1536 include_unmodified: Indicates whether to include unmodified models in the target development environment. 1537 select_models: A list of model selection strings to filter the models that should be included into this plan. 1538 backfill_models: A list of model selection strings to filter the models for which the data should be backfilled. 1539 enable_preview: Indicates whether to enable preview for forward-only models in development environments. 1540 preview_start: The start date for forward-only previews. 1541 preview_min_intervals: The minimum number of intervals to preview for each forward-only preview snapshot. 1542 run: Whether to run latest intervals as part of the plan application. 1543 diff_rendered: Whether the diff should compare raw vs rendered models 1544 min_intervals: Adjust the plan start date on a per-model basis in order to ensure at least this many intervals are covered 1545 on every model when checking for missing intervals 1546 always_include_local_changes: Usually when restatements are present, local changes in the filesystem are ignored. 1547 However, it can be desirable to deploy changes + restatements in the same plan, so this flag overrides the default behaviour. 1548 1549 Returns: 1550 The plan builder. 1551 """ 1552 kwargs: t.Dict[str, t.Optional[UserProvidedFlags]] = { 1553 "start": start, 1554 "end": end, 1555 "execution_time": execution_time, 1556 "create_from": create_from, 1557 "skip_tests": skip_tests, 1558 "restate_models": list(restate_models) if restate_models is not None else None, 1559 "no_gaps": no_gaps, 1560 "skip_backfill": skip_backfill, 1561 "empty_backfill": empty_backfill, 1562 "forward_only": forward_only, 1563 "allow_destructive_models": list(allow_destructive_models) 1564 if allow_destructive_models is not None 1565 else None, 1566 "allow_additive_models": list(allow_additive_models) 1567 if allow_additive_models is not None 1568 else None, 1569 "no_auto_categorization": no_auto_categorization, 1570 "effective_from": effective_from, 1571 "include_unmodified": include_unmodified, 1572 "select_models": list(select_models) if select_models is not None else None, 1573 "backfill_models": list(backfill_models) if backfill_models is not None else None, 1574 "enable_preview": enable_preview, 1575 "preview_start": preview_start, 1576 "preview_min_intervals": preview_min_intervals, 1577 "run": run, 1578 "diff_rendered": diff_rendered, 1579 "skip_linter": skip_linter, 1580 "min_intervals": min_intervals, 1581 } 1582 user_provided_flags: t.Dict[str, UserProvidedFlags] = { 1583 k: v for k, v in kwargs.items() if v is not None 1584 } 1585 1586 skip_tests = explain or skip_tests or False 1587 no_gaps = no_gaps or False 1588 skip_backfill = skip_backfill or False 1589 empty_backfill = empty_backfill or False 1590 run = run or False 1591 diff_rendered = diff_rendered or False 1592 skip_linter = skip_linter or False 1593 min_intervals = min_intervals or 0 1594 1595 environment = environment or self.config.default_target_environment 1596 environment = Environment.sanitize_name(environment) 1597 is_dev = environment != c.PROD 1598 1599 if include_unmodified is None: 1600 include_unmodified = self.config.plan.include_unmodified 1601 1602 if skip_backfill and not no_gaps and not is_dev: 1603 # note: we deliberately don't mention the --no-gaps flag in case the plan came from the sqlmesh_dbt command 1604 # todo: perhaps we could have better error messages if we check sys.argv[0] for which cli is running? 1605 self.console.log_warning( 1606 "Skipping the backfill stage for production can lead to unexpected results, such as tables being empty or incremental data with non-contiguous time ranges being made available.\n" 1607 "If you are doing this deliberately to create an empty version of a table to test a change, please consider using Virtual Data Environments instead." 1608 ) 1609 1610 if not skip_linter: 1611 self.lint_models() 1612 1613 self._run_plan_tests(skip_tests=skip_tests) 1614 1615 environment_ttl = ( 1616 self.environment_ttl if environment not in self.pinned_environments else None 1617 ) 1618 1619 model_selector = self._new_selector() 1620 1621 if allow_destructive_models: 1622 expanded_destructive_models = model_selector.expand_model_selections( 1623 allow_destructive_models 1624 ) 1625 else: 1626 expanded_destructive_models = None 1627 1628 if allow_additive_models: 1629 expanded_additive_models = model_selector.expand_model_selections(allow_additive_models) 1630 else: 1631 expanded_additive_models = None 1632 1633 if backfill_models: 1634 backfill_models = model_selector.expand_model_selections(backfill_models) 1635 else: 1636 backfill_models = None 1637 1638 models_override: t.Optional[UniqueKeyDict[str, Model]] = None 1639 selected_fqns: t.Set[str] = set() 1640 selected_deletion_fqns: t.Set[str] = set() 1641 if select_models: 1642 try: 1643 models_override, selected_fqns = model_selector.select_models( 1644 select_models, 1645 environment, 1646 fallback_env_name=create_from or c.PROD, 1647 ensure_finalized_snapshots=self.config.plan.use_finalized_state, 1648 ) 1649 except SQLMeshError as e: 1650 logger.exception(e) # ensure the full stack trace is logged 1651 raise PlanError( 1652 f"{e}\nCheck the SQLMesh log file for the full stack trace.\nIf the model has been fixed locally, please ensure that the --select-model expression includes it." 1653 ) 1654 if not backfill_models: 1655 # Only backfill selected models unless explicitly specified. 1656 backfill_models = model_selector.expand_model_selections(select_models) 1657 1658 if not backfill_models: 1659 # The selection matched nothing locally. Check whether it matched models 1660 # in the deployed environment that were deleted locally. 1661 selected_deletion_fqns = selected_fqns - set(self._models) 1662 1663 expanded_restate_models = None 1664 if restate_models is not None: 1665 expanded_restate_models = model_selector.expand_model_selections(restate_models) 1666 1667 if (restate_models is not None and not expanded_restate_models) or ( 1668 backfill_models is not None and not backfill_models and not selected_deletion_fqns 1669 ): 1670 raise PlanError( 1671 "Selector did not return any models. Please check your model selection and try again." 1672 ) 1673 1674 if always_include_local_changes is None: 1675 # default behaviour - if restatements are detected; we operate entirely out of state and ignore local changes 1676 force_no_diff = restate_models is not None or ( 1677 backfill_models is not None and not backfill_models and not selected_deletion_fqns 1678 ) 1679 else: 1680 force_no_diff = not always_include_local_changes 1681 1682 snapshots = self._snapshots(models_override) 1683 context_diff = self._context_diff( 1684 environment or c.PROD, 1685 snapshots=snapshots, 1686 create_from=create_from, 1687 force_no_diff=force_no_diff, 1688 ensure_finalized_snapshots=self.config.plan.use_finalized_state, 1689 diff_rendered=diff_rendered, 1690 always_recreate_environment=self.config.plan.always_recreate_environment, 1691 ) 1692 modified_model_names = { 1693 *context_diff.modified_snapshots, 1694 *[s.name for s in context_diff.added], 1695 } 1696 1697 if ( 1698 is_dev 1699 and not include_unmodified 1700 and backfill_models is None 1701 and expanded_restate_models is None 1702 ): 1703 # Only backfill modified and added models. 1704 # This ensures that no models outside the impacted sub-DAG(s) will be backfilled unexpectedly. 1705 backfill_models = modified_model_names or None 1706 1707 max_interval_end_per_model = None 1708 default_start, default_end = None, None 1709 if not run: 1710 ignore_cron = False 1711 max_interval_end_per_model = self._get_max_interval_end_per_model( 1712 snapshots, backfill_models 1713 ) 1714 # If no end date is specified, use the max interval end from prod 1715 # to prevent unintended evaluation of the entire DAG. 1716 default_start, default_end = self._get_plan_default_start_end( 1717 snapshots, 1718 max_interval_end_per_model, 1719 backfill_models, 1720 modified_model_names, 1721 execution_time or now(), 1722 ) 1723 1724 # Refresh snapshot intervals to ensure that they are up to date with values reflected in the max_interval_end_per_model. 1725 self.state_sync.refresh_snapshot_intervals(context_diff.snapshots.values()) 1726 1727 start_override_per_model = self._calculate_start_override_per_model( 1728 min_intervals, 1729 start or default_start, 1730 end or default_end, 1731 execution_time or now(), 1732 backfill_models, 1733 snapshots, 1734 max_interval_end_per_model, 1735 ) 1736 1737 if not self.config.virtual_environment_mode.is_full: 1738 forward_only = True 1739 elif forward_only is None: 1740 forward_only = self.config.plan.forward_only 1741 1742 # When handling prod restatements, only clear intervals from other model versions if we are using full virtual environments 1743 # If we are not, then there is no point, because none of the data in dev environments can be promoted by definition 1744 restate_all_snapshots = ( 1745 expanded_restate_models is not None 1746 and not is_dev 1747 and self.config.virtual_environment_mode.is_full 1748 ) 1749 1750 return self.PLAN_BUILDER_TYPE( 1751 context_diff=context_diff, 1752 start=start, 1753 end=end, 1754 execution_time=execution_time, 1755 apply=self.apply, 1756 restate_models=expanded_restate_models, 1757 restate_all_snapshots=restate_all_snapshots, 1758 backfill_models=backfill_models, 1759 no_gaps=no_gaps, 1760 skip_backfill=skip_backfill, 1761 empty_backfill=empty_backfill, 1762 is_dev=is_dev, 1763 forward_only=forward_only, 1764 allow_destructive_models=expanded_destructive_models, 1765 allow_additive_models=expanded_additive_models, 1766 environment_ttl=environment_ttl, 1767 environment_suffix_target=self.config.environment_suffix_target, 1768 environment_catalog_mapping=self.environment_catalog_mapping, 1769 categorizer_config=categorizer_config or self.auto_categorize_changes, 1770 auto_categorization_enabled=not no_auto_categorization, 1771 effective_from=effective_from, 1772 include_unmodified=include_unmodified, 1773 default_start=default_start, 1774 default_end=default_end, 1775 enable_preview=( 1776 enable_preview if enable_preview is not None else self._plan_preview_enabled 1777 ), 1778 preview_start=preview_start, 1779 preview_min_intervals=preview_min_intervals or 0, 1780 end_bounded=not run, 1781 ensure_finalized_snapshots=self.config.plan.use_finalized_state, 1782 start_override_per_model=start_override_per_model, 1783 end_override_per_model=max_interval_end_per_model, 1784 console=self.console, 1785 user_provided_flags=user_provided_flags, 1786 selected_models={ 1787 dbt_unique_id 1788 for model in model_selector.expand_model_selections(select_models or "*") 1789 if (dbt_unique_id := snapshots[model].node.dbt_unique_id) 1790 }, 1791 explain=explain or False, 1792 ignore_cron=ignore_cron or False, 1793 ) 1794 1795 def apply( 1796 self, 1797 plan: Plan, 1798 circuit_breaker: t.Optional[t.Callable[[], bool]] = None, 1799 ) -> None: 1800 """Applies a plan by pushing snapshots and backfilling data. 1801 1802 Given a plan, it pushes snapshots into the state sync and then uses the scheduler 1803 to backfill all models. 1804 1805 Args: 1806 plan: The plan to apply. 1807 circuit_breaker: An optional handler which checks if the apply should be aborted. 1808 """ 1809 if ( 1810 not plan.context_diff.has_changes 1811 and not plan.requires_backfill 1812 and not plan.has_unmodified_unpromoted 1813 ): 1814 return 1815 if plan.uncategorized: 1816 raise UncategorizedPlanError("Can't apply a plan with uncategorized changes.") 1817 1818 if plan.explain: 1819 explainer = PlanExplainer( 1820 state_reader=self.state_reader, 1821 default_catalog=self.default_catalog, 1822 console=self.console, 1823 ) 1824 explainer.evaluate(plan.to_evaluatable()) 1825 return 1826 1827 self.notification_target_manager.notify( 1828 NotificationEvent.APPLY_START, 1829 environment=plan.environment_naming_info.name, 1830 plan_id=plan.plan_id, 1831 ) 1832 try: 1833 self._apply(plan, circuit_breaker) 1834 except Exception as e: 1835 self.notification_target_manager.notify( 1836 NotificationEvent.APPLY_FAILURE, 1837 environment=plan.environment_naming_info.name, 1838 plan_id=plan.plan_id, 1839 exc=traceback.format_exc(), 1840 ) 1841 logger.info("Plan application failed.", exc_info=e) 1842 raise e 1843 self.notification_target_manager.notify( 1844 NotificationEvent.APPLY_END, 1845 environment=plan.environment_naming_info.name, 1846 plan_id=plan.plan_id, 1847 ) 1848 1849 @python_api_analytics 1850 def invalidate_environment(self, name: str, sync: bool = False) -> None: 1851 """Invalidates the target environment by setting its expiration timestamp to now. 1852 1853 Args: 1854 name: The name of the environment to invalidate. 1855 sync: If True, the call blocks until the environment is deleted. Otherwise, the environment will 1856 be deleted asynchronously by the janitor process. 1857 """ 1858 name = Environment.sanitize_name(name) 1859 self.state_sync.invalidate_environment(name) 1860 if sync: 1861 self._cleanup_environments(name=name) 1862 self.console.log_success(f"Environment '{name}' deleted.") 1863 else: 1864 self.console.log_success(f"Environment '{name}' invalidated.") 1865 1866 @python_api_analytics 1867 def diff(self, environment: t.Optional[str] = None, detailed: bool = False) -> bool: 1868 """Show a diff of the current context with a given environment. 1869 1870 Args: 1871 environment: The environment to diff against. 1872 detailed: Show the actual SQL differences if True. 1873 1874 Returns: 1875 True if there are changes, False otherwise. 1876 """ 1877 environment = environment or self.config.default_target_environment 1878 environment = Environment.sanitize_name(environment) 1879 context_diff = self._context_diff(environment) 1880 self.console.show_environment_difference_summary( 1881 context_diff, 1882 no_diff=not detailed, 1883 ) 1884 if context_diff.has_changes: 1885 self.console.show_model_difference_summary( 1886 context_diff, 1887 EnvironmentNamingInfo.from_environment_catalog_mapping( 1888 self.environment_catalog_mapping, 1889 name=environment, 1890 suffix_target=self.config.environment_suffix_target, 1891 normalize_name=context_diff.normalize_environment_name, 1892 ), 1893 self.default_catalog, 1894 no_diff=not detailed, 1895 ) 1896 return context_diff.has_changes 1897 1898 @python_api_analytics 1899 def table_diff( 1900 self, 1901 source: str, 1902 target: str, 1903 on: t.Optional[t.List[str] | exp.Expr] = None, 1904 skip_columns: t.Optional[t.List[str]] = None, 1905 select_models: t.Optional[t.Collection[str]] = None, 1906 where: t.Optional[str | exp.Expr] = None, 1907 limit: int = 20, 1908 show: bool = True, 1909 show_sample: bool = True, 1910 decimals: int = 3, 1911 skip_grain_check: bool = False, 1912 warn_grain_check: bool = False, 1913 temp_schema: t.Optional[str] = None, 1914 schema_diff_ignore_case: bool = False, 1915 **kwargs: t.Any, # catch-all to prevent an 'unexpected keyword argument' error if an table_diff extension passes in some extra arguments 1916 ) -> t.List[TableDiff]: 1917 """Show a diff between two tables. 1918 1919 Args: 1920 source: The source environment or table. 1921 target: The target environment or table. 1922 on: The join condition, table aliases must be "s" and "t" for source and target. 1923 If omitted, the table's grain will be used. 1924 skip_columns: The columns to skip when computing the table diff. 1925 select_models: The models or snapshots to use when environments are passed in. 1926 where: An optional where statement to filter results. 1927 limit: The limit of the sample dataframe. 1928 show: Show the table diff output in the console. 1929 show_sample: Show the sample dataframe in the console. Requires show=True. 1930 decimals: The number of decimal places to keep when comparing floating point columns. 1931 skip_grain_check: Skip check for rows that contain null or duplicate grains. 1932 temp_schema: The schema to use for temporary tables. 1933 1934 Returns: 1935 The list of TableDiff objects containing schema and summary differences. 1936 """ 1937 1938 if "|" in source or "|" in target: 1939 raise ConfigError( 1940 "Cross-database table diffing is available in Tobiko Cloud. Read more here: " 1941 "https://sqlmesh.readthedocs.io/en/stable/guides/tablediff/#diffing-tables-or-views-across-gateways" 1942 ) 1943 1944 table_diffs: t.List[TableDiff] = [] 1945 1946 # Diffs multiple or a single model across two environments 1947 if select_models: 1948 source_env = self.state_reader.get_environment(source) 1949 target_env = self.state_reader.get_environment(target) 1950 if not source_env: 1951 raise SQLMeshError(f"Could not find environment '{source}'") 1952 if not target_env: 1953 raise SQLMeshError(f"Could not find environment '{target}'") 1954 criteria = ", ".join(f"'{c}'" for c in select_models) 1955 try: 1956 selected_models = self._new_selector().expand_model_selections(select_models) 1957 if not selected_models: 1958 self.console.log_status_update( 1959 f"No models matched the selection criteria: {criteria}" 1960 ) 1961 except Exception as e: 1962 raise SQLMeshError(e) 1963 1964 models_to_diff: t.List[ 1965 t.Tuple[Model, EngineAdapter, str, str, t.Optional[t.List[str] | exp.Expr]] 1966 ] = [] 1967 models_without_grain: t.List[Model] = [] 1968 source_snapshots_to_name = { 1969 snapshot.name: snapshot for snapshot in source_env.snapshots 1970 } 1971 target_snapshots_to_name = { 1972 snapshot.name: snapshot for snapshot in target_env.snapshots 1973 } 1974 1975 for model_fqn in selected_models: 1976 model = self._models[model_fqn] 1977 adapter = self._get_engine_adapter(model.gateway) 1978 source_snapshot = source_snapshots_to_name.get(model.fqn) 1979 target_snapshot = target_snapshots_to_name.get(model.fqn) 1980 1981 if target_snapshot and source_snapshot: 1982 if (source_snapshot.fingerprint != target_snapshot.fingerprint) and ( 1983 (source_snapshot.version != target_snapshot.version) 1984 or source_snapshot.is_forward_only 1985 ): 1986 # Compare the virtual layer instead of the physical layer because the virtual layer is guaranteed to point 1987 # to the correct/active snapshot for the model in the specified environment, taking into account things like dev previews 1988 source = source_snapshot.qualified_view_name.for_environment( 1989 source_env.naming_info, adapter.dialect 1990 ) 1991 target = target_snapshot.qualified_view_name.for_environment( 1992 target_env.naming_info, adapter.dialect 1993 ) 1994 model_on = on or model.on 1995 if not model_on: 1996 models_without_grain.append(model) 1997 else: 1998 models_to_diff.append((model, adapter, source, target, model_on)) 1999 2000 if models_without_grain: 2001 model_names = "\n".join( 2002 f"─ {model.name} \n at '{model._path}'" for model in models_without_grain 2003 ) 2004 message = ( 2005 "SQLMesh doesn't know how to join the tables for the following models:\n" 2006 f"{model_names}\n\n" 2007 "Please specify a `grain` in each model definition. It must be unique and not null." 2008 ) 2009 if warn_grain_check: 2010 self.console.log_warning(message) 2011 else: 2012 raise SQLMeshError(message) 2013 2014 if models_to_diff: 2015 self.console.show_table_diff_details( 2016 [model[0].name for model in models_to_diff], 2017 ) 2018 2019 self.console.start_table_diff_progress(len(models_to_diff)) 2020 try: 2021 tasks_num = min(len(models_to_diff), self.concurrent_tasks) 2022 table_diffs = concurrent_apply_to_values( 2023 list(models_to_diff), 2024 lambda model_info: self._model_diff( 2025 model=model_info[0], 2026 adapter=model_info[1], 2027 source=model_info[2], 2028 target=model_info[3], 2029 on=model_info[4], 2030 source_alias=source_env.name, 2031 target_alias=target_env.name, 2032 limit=limit, 2033 decimals=decimals, 2034 skip_columns=skip_columns, 2035 where=where, 2036 show=show, 2037 temp_schema=temp_schema, 2038 skip_grain_check=skip_grain_check, 2039 schema_diff_ignore_case=schema_diff_ignore_case, 2040 ), 2041 tasks_num=tasks_num, 2042 ) 2043 self.console.stop_table_diff_progress(success=True) 2044 except: 2045 self.console.stop_table_diff_progress(success=False) 2046 raise 2047 elif selected_models: 2048 self.console.log_status_update( 2049 f"No models contain differences with the selection criteria: {criteria}" 2050 ) 2051 2052 else: 2053 table_diffs = [ 2054 self._table_diff( 2055 source=source, 2056 target=target, 2057 source_alias=source, 2058 target_alias=target, 2059 limit=limit, 2060 decimals=decimals, 2061 adapter=self.engine_adapter, 2062 on=on, 2063 skip_columns=skip_columns, 2064 where=where, 2065 schema_diff_ignore_case=schema_diff_ignore_case, 2066 ) 2067 ] 2068 2069 if show: 2070 self.console.show_table_diff(table_diffs, show_sample, skip_grain_check, temp_schema) 2071 2072 return table_diffs 2073 2074 def _model_diff( 2075 self, 2076 model: Model, 2077 adapter: EngineAdapter, 2078 source: str, 2079 target: str, 2080 source_alias: str, 2081 target_alias: str, 2082 limit: int, 2083 decimals: int, 2084 on: t.Optional[t.List[str] | exp.Expr] = None, 2085 skip_columns: t.Optional[t.List[str]] = None, 2086 where: t.Optional[str | exp.Expr] = None, 2087 show: bool = True, 2088 temp_schema: t.Optional[str] = None, 2089 skip_grain_check: bool = False, 2090 schema_diff_ignore_case: bool = False, 2091 ) -> TableDiff: 2092 self.console.start_table_diff_model_progress(model.name) 2093 2094 table_diff = self._table_diff( 2095 on=on, 2096 skip_columns=skip_columns, 2097 where=where, 2098 limit=limit, 2099 decimals=decimals, 2100 model=model, 2101 adapter=adapter, 2102 source=source, 2103 target=target, 2104 source_alias=source_alias, 2105 target_alias=target_alias, 2106 schema_diff_ignore_case=schema_diff_ignore_case, 2107 ) 2108 2109 if show: 2110 # Trigger row_diff in parallel execution so it's available for ordered display later 2111 table_diff.row_diff(temp_schema=temp_schema, skip_grain_check=skip_grain_check) 2112 2113 self.console.update_table_diff_progress(model.name) 2114 2115 return table_diff 2116 2117 def _table_diff( 2118 self, 2119 source: str, 2120 target: str, 2121 source_alias: str, 2122 target_alias: str, 2123 limit: int, 2124 decimals: int, 2125 adapter: EngineAdapter, 2126 on: t.Optional[t.List[str] | exp.Expr] = None, 2127 model: t.Optional[Model] = None, 2128 skip_columns: t.Optional[t.List[str]] = None, 2129 where: t.Optional[str | exp.Expr] = None, 2130 schema_diff_ignore_case: bool = False, 2131 ) -> TableDiff: 2132 if not on: 2133 raise SQLMeshError( 2134 "SQLMesh doesn't know how to join the two tables. Specify the `grains` in each model definition or pass join column names in separate `-o` flags." 2135 ) 2136 2137 return TableDiff( 2138 adapter=adapter.with_settings(execute_log_level=logger.getEffectiveLevel()), 2139 source=source, 2140 target=target, 2141 on=on, 2142 skip_columns=skip_columns, 2143 where=where, 2144 source_alias=source_alias, 2145 target_alias=target_alias, 2146 limit=limit, 2147 decimals=decimals, 2148 model_name=model.name if model else None, 2149 model_dialect=model.dialect if model else None, 2150 schema_diff_ignore_case=schema_diff_ignore_case, 2151 ) 2152 2153 @python_api_analytics 2154 def get_dag( 2155 self, select_models: t.Optional[t.Collection[str]] = None, **options: t.Any 2156 ) -> GraphHTML: 2157 """Gets an HTML object representation of the DAG. 2158 2159 Args: 2160 select_models: A list of model selection strings that should be included in the dag. 2161 Returns: 2162 An html object that renders the dag. 2163 """ 2164 dag = ( 2165 self.dag.prune(*self._new_selector().expand_model_selections(select_models)) 2166 if select_models 2167 else self.dag 2168 ) 2169 2170 nodes = {} 2171 edges: t.List[t.Dict] = [] 2172 2173 for node, deps in dag.graph.items(): 2174 nodes[node] = { 2175 "id": node, 2176 "label": node.split(".")[-1], 2177 "title": f"<span>{node}</span>", 2178 } 2179 edges.extend({"from": d, "to": node} for d in deps) 2180 2181 return GraphHTML( 2182 nodes, 2183 edges, 2184 options={ 2185 "height": "100%", 2186 "width": "100%", 2187 "interaction": {}, 2188 "layout": { 2189 "hierarchical": { 2190 "enabled": True, 2191 "nodeSpacing": 200, 2192 "sortMethod": "directed", 2193 }, 2194 }, 2195 "nodes": { 2196 "shape": "box", 2197 }, 2198 **options, 2199 }, 2200 ) 2201 2202 @python_api_analytics 2203 def render_dag(self, path: str, select_models: t.Optional[t.Collection[str]] = None) -> None: 2204 """Render the dag as HTML and save it to a file. 2205 2206 Args: 2207 path: filename to save the dag html to 2208 select_models: A list of model selection strings that should be included in the dag. 2209 """ 2210 file_path = Path(path) 2211 suffix = file_path.suffix 2212 if suffix != ".html": 2213 if suffix: 2214 get_console().log_warning( 2215 f"The extension {suffix} does not designate an html file. A file with a `.html` extension will be created instead." 2216 ) 2217 path = str(file_path.with_suffix(".html")) 2218 2219 with open(path, "w", encoding="utf-8") as file: 2220 file.write(str(self.get_dag(select_models))) 2221 2222 @python_api_analytics 2223 def create_test( 2224 self, 2225 model: str, 2226 input_queries: t.Dict[str, str], 2227 overwrite: bool = False, 2228 variables: t.Optional[t.Dict[str, str]] = None, 2229 path: t.Optional[str] = None, 2230 name: t.Optional[str] = None, 2231 include_ctes: bool = False, 2232 ) -> None: 2233 """Generate a unit test fixture for a given model. 2234 2235 Args: 2236 model: The model to test. 2237 input_queries: Mapping of model names to queries. Each model included in this mapping 2238 will be populated in the test based on the results of the corresponding query. 2239 overwrite: Whether to overwrite the existing test in case of a file path collision. 2240 When set to False, an error will be raised if there is such a collision. 2241 variables: Key-value pairs that will define variables needed by the model. 2242 path: The file path corresponding to the fixture, relative to the test directory. 2243 By default, the fixture will be created under the test directory and the file name 2244 will be inferred from the test's name. 2245 name: The name of the test. This is inferred from the model name by default. 2246 include_ctes: When true, CTE fixtures will also be generated. 2247 """ 2248 input_queries = { 2249 # The get_model here has two purposes: return normalized names & check for missing deps 2250 self.get_model(dep, raise_if_missing=True).fqn: query 2251 for dep, query in input_queries.items() 2252 } 2253 2254 try: 2255 model_to_test = self.get_model(model, raise_if_missing=True) 2256 test_adapter = self.test_connection_config.create_engine_adapter( 2257 register_comments_override=False 2258 ) 2259 2260 generate_test( 2261 model=model_to_test, 2262 input_queries=input_queries, 2263 models=self._models, 2264 engine_adapter=self._get_engine_adapter(model_to_test.gateway), 2265 test_engine_adapter=test_adapter, 2266 project_path=self.path, 2267 overwrite=overwrite, 2268 variables=variables, 2269 path=path, 2270 name=name, 2271 include_ctes=include_ctes, 2272 ) 2273 finally: 2274 if test_adapter: 2275 test_adapter.close() 2276 2277 @python_api_analytics 2278 def test( 2279 self, 2280 match_patterns: t.Optional[t.List[str]] = None, 2281 tests: t.Optional[t.List[str]] = None, 2282 verbosity: Verbosity = Verbosity.DEFAULT, 2283 preserve_fixtures: bool = False, 2284 stream: t.Optional[t.TextIO] = None, 2285 ) -> ModelTextTestResult: 2286 """Discover and run model tests""" 2287 if verbosity >= Verbosity.VERBOSE: 2288 import pandas as pd 2289 2290 pd.set_option("display.max_columns", None) 2291 2292 test_meta = self.select_tests(tests=tests, patterns=match_patterns) 2293 2294 result = run_tests( 2295 model_test_metadata=test_meta, 2296 models=self._models, 2297 config=self.config, 2298 selected_gateway=self.selected_gateway, 2299 dialect=self.default_dialect, 2300 verbosity=verbosity, 2301 preserve_fixtures=preserve_fixtures, 2302 stream=stream, 2303 default_catalog=self.default_catalog, 2304 default_catalog_dialect=self.config.dialect or "", 2305 ) 2306 2307 self.console.log_test_results( 2308 result, 2309 self.test_connection_config._engine_adapter.DIALECT, 2310 ) 2311 2312 return result 2313 2314 @python_api_analytics 2315 def audit( 2316 self, 2317 start: TimeLike, 2318 end: TimeLike, 2319 *, 2320 models: t.Optional[t.Iterator[str]] = None, 2321 execution_time: t.Optional[TimeLike] = None, 2322 ) -> bool: 2323 """Audit models. 2324 2325 Args: 2326 start: The start of the interval to audit. 2327 end: The end of the interval to audit. 2328 models: The models to audit. All models will be audited if not specified. 2329 execution_time: The date/time time reference to use for execution time. Defaults to now. 2330 2331 Returns: 2332 False if any of the audits failed, True otherwise. 2333 """ 2334 2335 snapshots = ( 2336 [self.get_snapshot(model, raise_if_missing=True) for model in models] 2337 if models 2338 else self.snapshots.values() 2339 ) 2340 2341 num_audits = sum(len(snapshot.node.audits_with_args) for snapshot in snapshots) 2342 self.console.log_status_update(f"Found {num_audits} audit(s).") 2343 2344 errors = [] 2345 skipped_count = 0 2346 for snapshot in snapshots: 2347 for audit_result in self.snapshot_evaluator.audit( 2348 snapshot=snapshot, 2349 start=start, 2350 end=end, 2351 execution_time=execution_time, 2352 snapshots=self.snapshots, 2353 ): 2354 audit_id = f"{audit_result.audit.name}" 2355 if audit_result.model: 2356 audit_id += f" on model {audit_result.model.name}" 2357 2358 if audit_result.skipped: 2359 self.console.log_status_update(f"{audit_id} ⏸️ SKIPPED.") 2360 skipped_count += 1 2361 elif audit_result.count: 2362 errors.append(audit_result) 2363 self.console.log_status_update( 2364 f"{audit_id} ❌ [red]FAIL [{audit_result.count}][/red]." 2365 ) 2366 else: 2367 self.console.log_status_update(f"{audit_id} ✅ [green]PASS[/green].") 2368 2369 self.console.log_status_update( 2370 f"\nFinished with {len(errors)} audit error{'' if len(errors) == 1 else 's'} " 2371 f"and {skipped_count} audit{'' if skipped_count == 1 else 's'} skipped." 2372 ) 2373 for error in errors: 2374 self.console.log_status_update( 2375 f"\nFailure in audit {error.audit.name} ({error.audit._path})." 2376 ) 2377 self.console.log_status_update(f"Got {error.count} results, expected 0.") 2378 if error.query: 2379 self.console.show_sql( 2380 f"{error.query.sql(dialect=self.snapshot_evaluator.adapter.dialect)}" 2381 ) 2382 2383 self.console.log_status_update("Done.") 2384 return not errors 2385 2386 @python_api_analytics 2387 def rewrite(self, sql: str, dialect: str = "") -> exp.Expr: 2388 """Rewrite a sql expression with semantic references into an executable query. 2389 2390 https://sqlmesh.readthedocs.io/en/latest/concepts/metrics/overview/ 2391 2392 Args: 2393 sql: The sql string to rewrite. 2394 dialect: The dialect of the sql string, defaults to the project dialect. 2395 2396 Returns: 2397 A SQLGlot expression with semantic references expanded. 2398 """ 2399 return rewrite( 2400 sql, 2401 graph=ReferenceGraph(self.models.values()), 2402 metrics=self._metrics, 2403 dialect=dialect or self.default_dialect, 2404 ) 2405 2406 @python_api_analytics 2407 def check_intervals( 2408 self, 2409 environment: t.Optional[str], 2410 no_signals: bool, 2411 select_models: t.Collection[str], 2412 start: t.Optional[TimeLike] = None, 2413 end: t.Optional[TimeLike] = None, 2414 ) -> t.Dict[Snapshot, SnapshotIntervals]: 2415 """Check intervals for a given environment. 2416 2417 Args: 2418 environment: The environment or prod if None. 2419 select_models: A list of model selection strings to show intervals for. 2420 start: The start of the intervals to check. 2421 end: The end of the intervals to check. 2422 """ 2423 2424 environment = environment or c.PROD 2425 env = self.state_reader.get_environment(environment) 2426 if not env: 2427 raise SQLMeshError(f"Environment '{environment}' was not found.") 2428 2429 snapshots = {k.name: v for k, v in self.state_sync.get_snapshots(env.snapshots).items()} 2430 2431 missing = { 2432 k.name: v 2433 for k, v in missing_intervals( 2434 snapshots.values(), start=start, end=end, execution_time=end 2435 ).items() 2436 } 2437 2438 if select_models: 2439 selected: t.Collection[str] = self._select_models_for_run( 2440 select_models, True, snapshots.values() 2441 ) 2442 else: 2443 selected = snapshots.keys() 2444 2445 results = {} 2446 execution_context = self.execution_context(snapshots=snapshots) 2447 2448 for fqn in selected: 2449 snapshot = snapshots[fqn] 2450 intervals = missing.get(fqn) or [] 2451 2452 results[snapshot] = SnapshotIntervals( 2453 snapshot.snapshot_id, 2454 intervals 2455 if no_signals 2456 else snapshot.check_ready_intervals(intervals, execution_context), 2457 ) 2458 2459 return results 2460 2461 @python_api_analytics 2462 def migrate(self) -> None: 2463 """Migrates SQLMesh to the current running version. 2464 2465 Please contact your SQLMesh administrator before doing this. 2466 """ 2467 self.notification_target_manager.notify(NotificationEvent.MIGRATION_START) 2468 self._load_materializations() 2469 try: 2470 self._new_state_sync().migrate( 2471 promoted_snapshots_only=self.config.migration.promoted_snapshots_only, 2472 ) 2473 except Exception as e: 2474 self.notification_target_manager.notify( 2475 NotificationEvent.MIGRATION_FAILURE, traceback.format_exc() 2476 ) 2477 raise e 2478 self.notification_target_manager.notify(NotificationEvent.MIGRATION_END) 2479 2480 @python_api_analytics 2481 def rollback(self) -> None: 2482 """Rolls back SQLMesh to the previous migration. 2483 2484 Please contact your SQLMesh administrator before doing this. This action cannot be undone. 2485 """ 2486 self._new_state_sync().rollback() 2487 2488 @python_api_analytics 2489 def create_external_models(self, strict: bool = False) -> None: 2490 """Create a file to document the schema of external models. 2491 2492 The external models file contains all columns and types of external models, allowing for more 2493 robust lineage, validation, and optimizations. 2494 2495 Args: 2496 strict: If True, raise an error if the external model is missing in the database. 2497 """ 2498 if not self._models: 2499 self.load(update_schemas=False) 2500 2501 for path, config in self.configs.items(): 2502 deprecated_yaml = path / c.EXTERNAL_MODELS_DEPRECATED_YAML 2503 2504 external_models_yaml = ( 2505 path / c.EXTERNAL_MODELS_YAML if not deprecated_yaml.exists() else deprecated_yaml 2506 ) 2507 2508 external_models_gateway: t.Optional[str] = self.gateway or self.config.default_gateway 2509 if not external_models_gateway: 2510 # can happen if there was no --gateway defined and the default_gateway is '' 2511 # which means that the single gateway syntax is being used which means there is 2512 # no named gateway which means we should not stamp `gateway:` on the external models 2513 external_models_gateway = None 2514 2515 create_external_models_file( 2516 path=external_models_yaml, 2517 models=UniqueKeyDict( 2518 "models", 2519 { 2520 fqn: model 2521 for fqn, model in self._models.items() 2522 if self.config_for_node(model) is config 2523 }, 2524 ), 2525 adapter=self.engine_adapter, 2526 state_reader=self.state_reader, 2527 dialect=config.model_defaults.dialect, 2528 gateway=external_models_gateway, 2529 max_workers=self.concurrent_tasks, 2530 strict=strict, 2531 all_models=self._models, 2532 ) 2533 2534 @python_api_analytics 2535 def print_info( 2536 self, skip_connection: bool = False, verbosity: Verbosity = Verbosity.DEFAULT 2537 ) -> None: 2538 """Prints information about connections, models, macros, etc. to the console.""" 2539 self.console.log_status_update(f"Models: {len(self.models)}") 2540 self.console.log_status_update(f"Macros: {len(self._macros) - len(macro.get_registry())}") 2541 2542 if skip_connection: 2543 return 2544 2545 if verbosity >= Verbosity.VERBOSE: 2546 self.console.log_status_update("") 2547 print_config(self.config.get_connection(self.gateway), self.console, "Connection") 2548 print_config( 2549 self.config.get_test_connection(self.gateway), self.console, "Test Connection" 2550 ) 2551 print_config( 2552 self.config.get_state_connection(self.gateway), self.console, "State Connection" 2553 ) 2554 2555 self._try_connection("data warehouse", self.engine_adapter.ping) 2556 state_connection = self.config.get_state_connection(self.gateway) 2557 if state_connection: 2558 self._try_connection("state backend", state_connection.connection_validator()) 2559 2560 @python_api_analytics 2561 def print_environment_names(self) -> None: 2562 """Prints all environment names along with expiry datetime.""" 2563 result = self._new_state_sync().get_environments_summary() 2564 if not result: 2565 raise SQLMeshError( 2566 "This project has no environments. Create an environment using the `sqlmesh plan` command." 2567 ) 2568 self.console.print_environments(result) 2569 2570 def close(self) -> None: 2571 """Releases all resources allocated by this context.""" 2572 if self._snapshot_evaluator: 2573 self._snapshot_evaluator.close() 2574 2575 if self._state_sync: 2576 self._state_sync.close() 2577 2578 def _run( 2579 self, 2580 environment: str, 2581 *, 2582 start: t.Optional[TimeLike], 2583 end: t.Optional[TimeLike], 2584 execution_time: t.Optional[TimeLike], 2585 ignore_cron: bool, 2586 select_models: t.Optional[t.Collection[str]], 2587 circuit_breaker: t.Optional[t.Callable[[], bool]], 2588 no_auto_upstream: bool, 2589 ) -> CompletionStatus: 2590 scheduler = self.scheduler(environment=environment) 2591 snapshots = scheduler.snapshots 2592 2593 if select_models is not None: 2594 select_models = self._select_models_for_run( 2595 select_models, no_auto_upstream, snapshots.values() 2596 ) 2597 2598 completion_status = scheduler.run( 2599 environment, 2600 start=start, 2601 end=end, 2602 execution_time=execution_time, 2603 ignore_cron=ignore_cron, 2604 circuit_breaker=circuit_breaker, 2605 selected_snapshots=select_models, 2606 auto_restatement_enabled=environment.lower() == c.PROD, 2607 run_environment_statements=True, 2608 ) 2609 2610 if completion_status.is_nothing_to_do: 2611 next_run_ready_msg = "" 2612 2613 next_ready_interval_start = get_next_model_interval_start(snapshots.values()) 2614 if next_ready_interval_start: 2615 utc_time = format_tz_datetime(next_ready_interval_start) 2616 local_time = format_tz_datetime(next_ready_interval_start, use_local_timezone=True) 2617 time_msg = local_time if local_time == utc_time else f"{local_time} ({utc_time})" 2618 next_run_ready_msg = f"\n\nNext run will be ready at {time_msg}." 2619 2620 self.console.log_status_update( 2621 f"No models are ready to run. Please wait until a model `cron` interval has elapsed.{next_run_ready_msg}" 2622 ) 2623 2624 return completion_status 2625 2626 def _apply(self, plan: Plan, circuit_breaker: t.Optional[t.Callable[[], bool]]) -> None: 2627 self._scheduler.create_plan_evaluator(self).evaluate( 2628 plan.to_evaluatable(), circuit_breaker=circuit_breaker 2629 ) 2630 2631 @python_api_analytics 2632 def table_name( 2633 self, model_name: str, environment: t.Optional[str] = None, prod: bool = False 2634 ) -> str: 2635 """Returns the name of the pysical table for the given model name in the target environment. 2636 2637 Args: 2638 model_name: The name of the model. 2639 environment: The environment to source the model version from. 2640 prod: If True, return the name of the physical table that will be used in production for the model version 2641 promoted in the target environment. 2642 2643 Returns: 2644 The name of the physical table. 2645 """ 2646 environment = environment or self.config.default_target_environment 2647 fqn = self._node_or_snapshot_to_fqn(model_name) 2648 target_env = self.state_reader.get_environment(environment) 2649 if not target_env: 2650 raise SQLMeshError(f"Environment '{environment}' was not found.") 2651 2652 snapshot_info = None 2653 for s in target_env.snapshots: 2654 if s.name == fqn: 2655 snapshot_info = s 2656 break 2657 if not snapshot_info: 2658 raise SQLMeshError( 2659 f"Model '{model_name}' was not found in environment '{environment}'." 2660 ) 2661 2662 if target_env.name == c.PROD or prod: 2663 return snapshot_info.table_name() 2664 2665 snapshots = self.state_reader.get_snapshots(target_env.snapshots) 2666 deployability_index = DeployabilityIndex.create(snapshots) 2667 2668 return snapshot_info.table_name( 2669 is_deployable=deployability_index.is_deployable(snapshot_info.snapshot_id) 2670 ) 2671 2672 def clear_caches(self) -> None: 2673 paths_to_remove = [path / c.CACHE for path in self.configs] 2674 paths_to_remove.append(self.cache_dir) 2675 2676 if IS_WINDOWS: 2677 paths_to_remove = [fix_windows_path(path) for path in paths_to_remove] 2678 2679 for path in paths_to_remove: 2680 if path.exists(): 2681 rmtree(path) 2682 2683 if isinstance(self._state_sync, CachingStateSync): 2684 self._state_sync.clear_cache() 2685 2686 def export_state( 2687 self, 2688 output_file: Path, 2689 environment_names: t.Optional[t.List[str]] = None, 2690 local_only: bool = False, 2691 confirm: bool = True, 2692 ) -> None: 2693 from sqlmesh.core.state_sync.export_import import export_state 2694 2695 # trigger a connection to the StateSync so we can fail early if there is a problem 2696 # note we still need to do this even if we are doing a local export so we know what 'versions' to write 2697 self.state_sync.get_versions(validate=True) 2698 2699 local_snapshots = self.snapshots if local_only else None 2700 2701 if self.console.start_state_export( 2702 output_file=output_file, 2703 gateway=self.selected_gateway, 2704 state_connection_config=self._state_connection_config, 2705 environment_names=environment_names, 2706 local_only=local_only, 2707 confirm=confirm, 2708 ): 2709 try: 2710 export_state( 2711 state_sync=self.state_sync, 2712 output_file=output_file, 2713 local_snapshots=local_snapshots, 2714 environment_names=environment_names, 2715 console=self.console, 2716 ) 2717 self.console.stop_state_export(success=True, output_file=output_file) 2718 except: 2719 self.console.stop_state_export(success=False, output_file=output_file) 2720 raise 2721 2722 def import_state(self, input_file: Path, clear: bool = False, confirm: bool = True) -> None: 2723 from sqlmesh.core.state_sync.export_import import import_state 2724 2725 if self.console.start_state_import( 2726 input_file=input_file, 2727 gateway=self.selected_gateway, 2728 state_connection_config=self._state_connection_config, 2729 clear=clear, 2730 confirm=confirm, 2731 ): 2732 try: 2733 import_state( 2734 state_sync=self.state_sync, 2735 input_file=input_file, 2736 clear=clear, 2737 console=self.console, 2738 ) 2739 self.console.stop_state_import(success=True, input_file=input_file) 2740 except: 2741 self.console.stop_state_import(success=False, input_file=input_file) 2742 raise 2743 2744 def _run_tests( 2745 self, verbosity: Verbosity = Verbosity.DEFAULT 2746 ) -> t.Tuple[ModelTextTestResult, str]: 2747 test_output_io = StringIO() 2748 result = self.test(stream=test_output_io, verbosity=verbosity) 2749 return result, test_output_io.getvalue() 2750 2751 def _run_plan_tests(self, skip_tests: bool = False) -> t.Optional[ModelTextTestResult]: 2752 if not skip_tests: 2753 result = self.test() 2754 if not result.wasSuccessful(): 2755 raise PlanError( 2756 "Cannot generate plan due to failing test(s). Fix test(s) and run again." 2757 ) 2758 return result 2759 return None 2760 2761 def _warn_if_virtual_catalog_rematerialization(self, plan: "Plan") -> None: 2762 """Warn when ClickHouse models appear as new snapshots solely because a virtual catalog 2763 prefix was added to their FQNs after a catalog-aware gateway joined the project. 2764 2765 This situation causes every previously-applied ClickHouse model to be treated as brand-new 2766 by SQLMesh, triggering full re-materialization and historical backfills. Emitting a warning 2767 before the plan is displayed gives users a chance to understand the cost before applying. 2768 """ 2769 from sqlglot import exp 2770 2771 # Collect the set of old 2-level snapshot names from the current environment so we can 2772 # detect which new 3-level names are renames rather than genuinely new models. 2773 old_names: t.Set[str] = set() 2774 for s_id in plan.context_diff.removed_snapshots: 2775 old_names.add(s_id.name) 2776 for name in plan.context_diff.snapshots_by_name: 2777 old_names.add(name) 2778 2779 affected: t.List[t.Tuple[str, str]] = [] # (new_3level_name, old_2level_name) 2780 2781 for gateway, adapter in self.engine_adapters.items(): 2782 if not adapter.supports_virtual_catalog() or not adapter._default_catalog: 2783 continue 2784 virtual_catalog = adapter._default_catalog 2785 2786 for snapshot in plan.new_snapshots: 2787 table = exp.to_table(snapshot.name) 2788 if table.catalog != virtual_catalog: 2789 continue 2790 # Reconstruct the 2-level name that would have been used before injection. 2791 old_name = f"{table.db}.{table.name}" 2792 if old_name in old_names: 2793 affected.append((snapshot.name, old_name)) 2794 2795 if not affected: 2796 return 2797 2798 max_display = 10 2799 model_lines = "\n".join( 2800 f" - {new_name} (was: {old_name})" for new_name, old_name in affected[:max_display] 2801 ) 2802 if len(affected) > max_display: 2803 model_lines += f"\n ... and {len(affected) - max_display} more" 2804 2805 self.console.log_warning( 2806 "ClickHouse models are being re-materialized due to virtual catalog FQN change.\n\n" 2807 "The following ClickHouse models appear as new because their fully-qualified\n" 2808 "names changed from 2-level (db.table) to 3-level (__gateway__.db.table):\n\n" 2809 f"{model_lines}\n\n" 2810 "FULL models will be recreated once. INCREMENTAL_BY_TIME_RANGE models will\n" 2811 "require a full historical backfill from their configured start date.\n\n" 2812 "This is a one-time cost when first adding a catalog-aware gateway to an\n" 2813 "existing ClickHouse project. To proceed, run `sqlmesh apply`." 2814 ) 2815 2816 @property 2817 def _model_tables(self) -> t.Dict[str, str]: 2818 """Mapping of model name to physical table name. 2819 2820 If a snapshot has not been versioned yet, its view name will be returned. 2821 """ 2822 return { 2823 fqn: ( 2824 snapshot.table_name() 2825 if snapshot.version 2826 else snapshot.qualified_view_name.for_environment( 2827 EnvironmentNamingInfo.from_environment_catalog_mapping( 2828 self.environment_catalog_mapping, 2829 name=c.PROD, 2830 suffix_target=self.config.environment_suffix_target, 2831 ) 2832 ) 2833 ) 2834 for fqn, snapshot in self.snapshots.items() 2835 } 2836 2837 @cached_property 2838 def cache_dir(self) -> Path: 2839 if self.config.cache_dir: 2840 cache_path = Path(self.config.cache_dir) 2841 if cache_path.is_absolute(): 2842 return cache_path 2843 return self.path / cache_path 2844 2845 # Default to .cache directory in the project path 2846 return self.path / c.CACHE 2847 2848 @cached_property 2849 def engine_adapters(self) -> t.Dict[str, EngineAdapter]: 2850 """Returns all the engine adapters for the gateways defined in the configurations.""" 2851 adapters: t.Dict[str, EngineAdapter] = {self.selected_gateway: self.engine_adapter} 2852 for config in self.configs.values(): 2853 for gateway_name in config.gateways: 2854 if gateway_name not in adapters: 2855 connection = config.get_connection(gateway_name) 2856 adapter = connection.create_engine_adapter( 2857 concurrent_tasks=self.concurrent_tasks, 2858 ) 2859 adapters[gateway_name] = adapter 2860 return adapters 2861 2862 @cached_property 2863 def default_catalog_per_gateway(self) -> t.Dict[str, str]: 2864 """Returns the default catalogs for each engine adapter.""" 2865 return self._scheduler.get_default_catalog_per_gateway(self) 2866 2867 @property 2868 def concurrent_tasks(self) -> int: 2869 if self._concurrent_tasks is None: 2870 self._concurrent_tasks = self.connection_config.concurrent_tasks 2871 return self._concurrent_tasks 2872 2873 @cached_property 2874 def connection_config(self) -> ConnectionConfig: 2875 return self.config.get_connection(self.selected_gateway) 2876 2877 @cached_property 2878 def test_connection_config(self) -> ConnectionConfig: 2879 return self.config.get_test_connection( 2880 self.gateway, 2881 self.default_catalog, 2882 default_catalog_dialect=self.config.dialect, 2883 ) 2884 2885 @cached_property 2886 def environment_catalog_mapping(self) -> RegexKeyDict: 2887 engine_adapter = None 2888 try: 2889 engine_adapter = self.engine_adapter 2890 except Exception: 2891 pass 2892 2893 if ( 2894 self.config.environment_catalog_mapping 2895 and engine_adapter 2896 and not self.engine_adapter.catalog_support.is_multi_catalog_supported 2897 ): 2898 raise SQLMeshError( 2899 "Environment catalog mapping is only supported for engine adapters that support multiple catalogs" 2900 ) 2901 return self.config.environment_catalog_mapping 2902 2903 def _get_engine_adapter(self, gateway: t.Optional[str] = None) -> EngineAdapter: 2904 if gateway: 2905 if adapter := self.engine_adapters.get(gateway): 2906 return adapter 2907 raise SQLMeshError(f"Gateway '{gateway}' not found in the available engine adapters.") 2908 return self.engine_adapter 2909 2910 def _snapshots( 2911 self, models_override: t.Optional[UniqueKeyDict[str, Model]] = None 2912 ) -> t.Dict[str, Snapshot]: 2913 nodes = {**(models_override or self._models), **self._standalone_audits} 2914 snapshots = self._nodes_to_snapshots(nodes) 2915 stored_snapshots = self.state_reader.get_snapshots(snapshots.values()) 2916 2917 unrestorable_snapshots = { 2918 snapshot 2919 for snapshot in stored_snapshots.values() 2920 if snapshot.name in nodes and snapshot.unrestorable 2921 } 2922 if unrestorable_snapshots: 2923 for snapshot in unrestorable_snapshots: 2924 logger.info( 2925 "Found a unrestorable snapshot %s. Restamping the model...", snapshot.name 2926 ) 2927 node = nodes[snapshot.name] 2928 nodes[snapshot.name] = node.copy( 2929 update={"stamp": f"revert to {snapshot.identifier}"} 2930 ) 2931 snapshots = self._nodes_to_snapshots(nodes) 2932 stored_snapshots = self.state_reader.get_snapshots(snapshots.values()) 2933 2934 for snapshot in stored_snapshots.values(): 2935 # Keep the original model instance to preserve the query cache. 2936 snapshot.node = snapshots[snapshot.name].node 2937 2938 return {name: stored_snapshots.get(s.snapshot_id, s) for name, s in snapshots.items()} 2939 2940 def _context_diff( 2941 self, 2942 environment: str, 2943 snapshots: t.Optional[t.Dict[str, Snapshot]] = None, 2944 create_from: t.Optional[str] = None, 2945 force_no_diff: bool = False, 2946 ensure_finalized_snapshots: bool = False, 2947 diff_rendered: bool = False, 2948 always_recreate_environment: bool = False, 2949 ) -> ContextDiff: 2950 environment = Environment.sanitize_name(environment) 2951 if force_no_diff: 2952 return ContextDiff.create_no_diff(environment, self.state_reader) 2953 2954 return ContextDiff.create( 2955 environment, 2956 snapshots=snapshots or self.snapshots, 2957 create_from=create_from or c.PROD, 2958 state_reader=self.state_reader, 2959 provided_requirements=self._requirements, 2960 excluded_requirements=self._excluded_requirements, 2961 ensure_finalized_snapshots=ensure_finalized_snapshots, 2962 diff_rendered=diff_rendered, 2963 environment_statements=self._environment_statements, 2964 gateway_managed_virtual_layer=self.config.gateway_managed_virtual_layer, 2965 infer_python_dependencies=self.config.infer_python_dependencies, 2966 always_recreate_environment=always_recreate_environment, 2967 ) 2968 2969 def _destroy(self) -> bool: 2970 # Invalidate all environments, including prod 2971 for environment in self.state_reader.get_environments(): 2972 self.state_sync.invalidate_environment(name=environment.name, protect_prod=False) 2973 self.console.log_success(f"Environment '{environment.name}' invalidated.") 2974 2975 # Run janitor to clean up all objects 2976 self._run_janitor(ignore_ttl=True) 2977 2978 # Remove state tables, including backup tables 2979 self.state_sync.remove_state(including_backup=True) 2980 self.console.log_status_update("State tables removed.") 2981 2982 # Finally clear caches 2983 self.clear_caches() 2984 2985 return True 2986 2987 def _run_janitor( 2988 self, 2989 ignore_ttl: bool = False, 2990 force_delete: bool = False, 2991 environment: t.Optional[str] = None, 2992 ) -> None: 2993 current_ts = now_timestamp() 2994 failures: t.List[str] = [] 2995 2996 # Clean up expired environments by removing their views and schemas 2997 failures.extend( 2998 self._cleanup_environments( 2999 current_ts=current_ts, force_delete=force_delete, name=environment 3000 ) 3001 ) 3002 3003 if environment is None: 3004 failures.extend( 3005 delete_expired_snapshots( 3006 self.state_sync, 3007 self.snapshot_evaluator, 3008 current_ts=current_ts, 3009 ignore_ttl=ignore_ttl, 3010 force_delete=force_delete, 3011 console=self.console, 3012 batch_size=self.config.janitor.expired_snapshots_batch_size, 3013 ) 3014 ) 3015 self.state_sync.compact_intervals() 3016 3017 if failures: 3018 failure_string = "\n - ".join(failures) 3019 summary = f"Janitor completed with failures:\n {failure_string}" 3020 if force_delete: 3021 summary += "\nState records have been deleted, but the underlying objects may still exist in the database.\nPlease investigate and clean up manually the above if necessary." 3022 if self.config.janitor.warn_on_delete_failure: 3023 self.console.log_warning(summary) 3024 else: 3025 raise SQLMeshError(summary) 3026 3027 def _cleanup_environments( 3028 self, 3029 current_ts: t.Optional[int] = None, 3030 force_delete: bool = False, 3031 name: t.Optional[str] = None, 3032 ) -> t.List[str]: 3033 current_ts = current_ts or now_timestamp() 3034 failures: t.List[str] = [] 3035 3036 expired_environments_summaries = self.state_sync.get_expired_environments( 3037 current_ts=current_ts, name=name 3038 ) 3039 3040 if name is not None and not expired_environments_summaries: 3041 self.console.log_warning( 3042 f"Environment '{name}' is not expired or does not exist. Nothing to clean up." 3043 ) 3044 3045 for expired_env_summary in expired_environments_summaries: 3046 expired_env = self.state_reader.get_environment(expired_env_summary.name) 3047 3048 if expired_env: 3049 failures.extend( 3050 cleanup_expired_views( 3051 default_adapter=self.engine_adapter, 3052 engine_adapters=self.engine_adapters, 3053 environments=[expired_env], 3054 console=self.console, 3055 ) 3056 ) 3057 3058 # we want to retry on the next janitor pass if drops failed, unless 3059 # force_delete is set in which case we purge state records regardless 3060 if not failures or force_delete: 3061 self.state_sync.delete_expired_environments(current_ts=current_ts, name=name) 3062 return failures 3063 3064 def _try_connection(self, connection_name: str, validator: t.Callable[[], None]) -> None: 3065 connection_name = connection_name.capitalize() 3066 try: 3067 validator() 3068 self.console.log_status_update(f"{connection_name} connection [green]succeeded[/green]") 3069 except Exception as ex: 3070 self.console.log_error(f"{connection_name} connection failed. {ex}") 3071 3072 def _new_state_sync(self) -> StateSync: 3073 return self._provided_state_sync or self._scheduler.create_state_sync(self) 3074 3075 def _new_selector( 3076 self, models: t.Optional[UniqueKeyDict[str, Model]] = None, dag: t.Optional[DAG[str]] = None 3077 ) -> Selector: 3078 return self._selector_cls( 3079 self.state_reader, 3080 models=models or self._models, 3081 context_path=self.path, 3082 dag=dag, 3083 default_catalog=self.default_catalog, 3084 dialect=self.default_dialect, 3085 cache_dir=self.cache_dir, 3086 ) 3087 3088 def _register_notification_targets(self) -> None: 3089 event_notifications = collections.defaultdict(set) 3090 for target in self.notification_targets: 3091 if target.is_configured: 3092 for event in target.notify_on: 3093 event_notifications[event].add(target) 3094 user_notification_targets = { 3095 user.username: set( 3096 target for target in user.notification_targets if target.is_configured 3097 ) 3098 for user in self.users 3099 } 3100 self.notification_target_manager = NotificationTargetManager( 3101 event_notifications, user_notification_targets, username=self.config.username 3102 ) 3103 3104 def _load_materializations(self) -> None: 3105 if not self._loaded: 3106 for loader in self._loaders: 3107 loader.load_materializations() 3108 3109 def _select_models_for_run( 3110 self, 3111 select_models: t.Collection[str], 3112 no_auto_upstream: bool, 3113 snapshots: t.Collection[Snapshot], 3114 ) -> t.Set[str]: 3115 models: UniqueKeyDict[str, Model] = UniqueKeyDict( 3116 "models", **{s.name: s.model for s in snapshots if s.is_model} 3117 ) 3118 dag: DAG[str] = DAG() 3119 for fqn, model in models.items(): 3120 dag.add(fqn, model.depends_on) 3121 model_selector = self._new_selector(models=models, dag=dag) 3122 result = set(model_selector.expand_model_selections(select_models)) 3123 if not no_auto_upstream: 3124 result = set(dag.subdag(*result)) 3125 return result 3126 3127 @cached_property 3128 def _project_type(self) -> str: 3129 project_types = { 3130 c.DBT if loader.__class__.__name__.lower().startswith(c.DBT) else c.NATIVE 3131 for loader in self._loaders 3132 } 3133 return c.HYBRID if len(project_types) > 1 else first(project_types) 3134 3135 def _nodes_to_snapshots(self, nodes: t.Dict[str, Node]) -> t.Dict[str, Snapshot]: 3136 snapshots: t.Dict[str, Snapshot] = {} 3137 fingerprint_cache: t.Dict[str, SnapshotFingerprint] = {} 3138 3139 for node in nodes.values(): 3140 kwargs: t.Dict[str, t.Any] = {} 3141 if node.project in self._projects: 3142 config = self.config_for_node(node) 3143 kwargs["ttl"] = config.snapshot_ttl 3144 kwargs["table_naming_convention"] = config.physical_table_naming_convention 3145 3146 snapshot = Snapshot.from_node( 3147 node, 3148 nodes=nodes, 3149 cache=fingerprint_cache, 3150 **kwargs, 3151 ) 3152 snapshots[snapshot.name] = snapshot 3153 return snapshots 3154 3155 def _node_or_snapshot_to_fqn(self, node_or_snapshot: NodeOrSnapshot) -> str: 3156 if isinstance(node_or_snapshot, Snapshot): 3157 return node_or_snapshot.name 3158 if isinstance(node_or_snapshot, str) and not self.standalone_audits.get(node_or_snapshot): 3159 return normalize_model_name( 3160 node_or_snapshot, 3161 dialect=self.default_dialect, 3162 default_catalog=self.default_catalog, 3163 ) 3164 if not isinstance(node_or_snapshot, str): 3165 return node_or_snapshot.fqn 3166 return node_or_snapshot 3167 3168 @property 3169 def _plan_preview_enabled(self) -> bool: 3170 if self.config.plan.enable_preview is not None: 3171 return self.config.plan.enable_preview 3172 # It is dangerous to enable preview by default for dbt projects that rely on engines that don't support cloning. 3173 # Enabling previews in such cases can result in unintended full refreshes because dbt incremental models rely on 3174 # the maximum timestamp value in the target table. 3175 return self._project_type == c.NATIVE or self.engine_adapter.SUPPORTS_CLONING 3176 3177 def _get_plan_default_start_end( 3178 self, 3179 snapshots: t.Dict[str, Snapshot], 3180 max_interval_end_per_model: t.Dict[str, datetime], 3181 backfill_models: t.Optional[t.Set[str]], 3182 modified_model_names: t.Set[str], 3183 execution_time: t.Optional[TimeLike] = None, 3184 ) -> t.Tuple[t.Optional[int], t.Optional[int]]: 3185 # exclude seeds so their stale interval ends does not become the default plan end date 3186 # when they're the only ones that contain intervals in this plan 3187 non_seed_interval_ends = { 3188 model_fqn: end 3189 for model_fqn, end in max_interval_end_per_model.items() 3190 if model_fqn not in snapshots or not snapshots[model_fqn].is_seed 3191 } 3192 if not non_seed_interval_ends: 3193 return None, None 3194 3195 default_end = to_timestamp(max(non_seed_interval_ends.values())) 3196 default_start: t.Optional[int] = None 3197 # Infer the default start by finding the smallest interval start that corresponds to the default end. 3198 for model_name in backfill_models or modified_model_names or max_interval_end_per_model: 3199 if model_name not in snapshots: 3200 continue 3201 node = snapshots[model_name].node 3202 interval_unit = node.interval_unit 3203 default_start = min( 3204 default_start or sys.maxsize, 3205 to_timestamp( 3206 interval_unit.cron_prev( 3207 interval_unit.cron_floor( 3208 max_interval_end_per_model.get( 3209 model_name, node.cron_floor(default_end) 3210 ), 3211 ), 3212 estimate=True, 3213 ) 3214 ), 3215 ) 3216 3217 if execution_time and to_timestamp(default_end) > to_timestamp(execution_time): 3218 # the end date can't be in the future, which can happen if a specific `execution_time` is set and prod intervals 3219 # are newer than it 3220 default_end = to_timestamp(execution_time) 3221 3222 return default_start, default_end 3223 3224 def _calculate_start_override_per_model( 3225 self, 3226 min_intervals: t.Optional[int], 3227 plan_start: t.Optional[TimeLike], 3228 plan_end: t.Optional[TimeLike], 3229 plan_execution_time: TimeLike, 3230 backfill_model_fqns: t.Optional[t.Set[str]], 3231 snapshots_by_model_fqn: t.Dict[str, Snapshot], 3232 end_override_per_model: t.Optional[t.Dict[str, datetime]], 3233 ) -> t.Dict[str, datetime]: 3234 if not min_intervals or not backfill_model_fqns or not plan_start: 3235 # If there are no models to backfill, there are no intervals to consider for backfill, so we dont need to consider a minimum number 3236 # If the plan doesnt have a start date, all intervals are considered already so we dont need to consider a minimum number 3237 # If we dont have a minimum number of intervals to consider, then we dont need to adjust the start date on a per-model basis 3238 return {} 3239 3240 start_overrides: t.Dict[str, datetime] = {} 3241 end_override_per_model = end_override_per_model or {} 3242 3243 plan_execution_time_dt = to_datetime(plan_execution_time) 3244 plan_start_dt = to_datetime(plan_start, relative_base=plan_execution_time_dt) 3245 plan_end_dt = to_datetime( 3246 plan_end or plan_execution_time_dt, relative_base=plan_execution_time_dt 3247 ) 3248 3249 # we need to take the DAG into account so that parent models can be expanded to cover at least as much as their children 3250 # for example, A(hourly) <- B(daily) 3251 # if min_intervals=1, A would have 1 hour and B would have 1 day 3252 # but B depends on A so in order for B to have 1 valid day, A needs to be expanded to 24 hours 3253 backfill_dag: DAG[str] = DAG() 3254 for fqn in backfill_model_fqns: 3255 backfill_dag.add( 3256 fqn, 3257 [ 3258 p.name 3259 for p in snapshots_by_model_fqn[fqn].parents 3260 if p.name in backfill_model_fqns 3261 ], 3262 ) 3263 3264 # start from the leaf nodes and work back towards the root because the min_start at the root node is determined by the calculated starts in the leaf nodes 3265 reversed_dag = backfill_dag.reversed 3266 graph = reversed_dag.graph 3267 3268 for model_fqn in reversed_dag: 3269 # Get the earliest start from all immediate children of this snapshot 3270 # this works because topological ordering guarantees that they've already been visited 3271 # and we always set a start override 3272 min_child_start = min( 3273 [start_overrides[immediate_child_fqn] for immediate_child_fqn in graph[model_fqn]], 3274 default=plan_start_dt, 3275 ) 3276 3277 snapshot = snapshots_by_model_fqn.get(model_fqn) 3278 3279 if not snapshot: 3280 continue 3281 3282 starting_point = end_override_per_model.get(model_fqn, plan_end_dt) 3283 if node_end := snapshot.node.end: 3284 # if we dont do this, if the node end is a *date* (as opposed to a timestamp) 3285 # we end up incorrectly winding back an extra day 3286 node_end_dt = make_exclusive(node_end) 3287 3288 if node_end_dt < plan_end_dt: 3289 # if the model has an end date that has already elapsed, use that as a starting point for calculating min_intervals 3290 # instead of the plan end. If we use the plan end, we will return intervals in the future which are invalid 3291 starting_point = node_end_dt 3292 3293 snapshot_start = snapshot.node.cron_floor(starting_point) 3294 3295 for _ in range(min_intervals): 3296 # wind back the starting point by :min_intervals intervals to arrive at the minimum snapshot start date 3297 snapshot_start = snapshot.node.cron_prev(snapshot_start) 3298 3299 start_overrides[model_fqn] = min(min_child_start, snapshot_start) 3300 3301 return start_overrides 3302 3303 def _get_max_interval_end_per_model( 3304 self, snapshots: t.Dict[str, Snapshot], backfill_models: t.Optional[t.Set[str]] 3305 ) -> t.Dict[str, datetime]: 3306 models_for_interval_end = ( 3307 self._get_models_for_interval_end(snapshots, backfill_models) 3308 if backfill_models is not None 3309 else None 3310 ) 3311 return { 3312 model_fqn: to_datetime(ts) 3313 for model_fqn, ts in self.state_sync.max_interval_end_per_model( 3314 c.PROD, 3315 models=models_for_interval_end, 3316 ensure_finalized_snapshots=self.config.plan.use_finalized_state, 3317 ).items() 3318 } 3319 3320 @staticmethod 3321 def _get_models_for_interval_end( 3322 snapshots: t.Dict[str, Snapshot], backfill_models: t.Set[str] 3323 ) -> t.Set[str]: 3324 models_for_interval_end = set() 3325 models_stack = list(backfill_models) 3326 while models_stack: 3327 next_model = models_stack.pop() 3328 if next_model not in snapshots: 3329 continue 3330 models_for_interval_end.add(next_model) 3331 models_stack.extend( 3332 s.name 3333 for s in snapshots[next_model].parents 3334 if s.name not in models_for_interval_end 3335 ) 3336 return models_for_interval_end 3337 3338 def lint_models( 3339 self, 3340 models: t.Optional[t.Iterable[t.Union[str, Model]]] = None, 3341 raise_on_error: bool = True, 3342 ) -> t.List[AnnotatedRuleViolation]: 3343 found_error = False 3344 3345 model_list = ( 3346 list(self.get_model(model, raise_if_missing=True) for model in models) 3347 if models 3348 else self.models.values() 3349 ) 3350 all_violations = [] 3351 for model in model_list: 3352 # Linter may be `None` if the context is not loaded yet 3353 if linter := self._linters.get(model.project): 3354 lint_violation, violations = ( 3355 linter.lint_model(model, self, console=self.console) or found_error 3356 ) 3357 if lint_violation: 3358 found_error = True 3359 all_violations.extend(violations) 3360 3361 if raise_on_error and found_error: 3362 raise LinterError( 3363 "Linter detected errors in the code. Please fix them before proceeding." 3364 ) 3365 3366 return all_violations 3367 3368 def select_tests( 3369 self, 3370 tests: t.Optional[t.List[str]] = None, 3371 patterns: t.Optional[t.List[str]] = None, 3372 ) -> t.List[ModelTestMetadata]: 3373 """Filter pre-loaded test metadata based on tests and patterns.""" 3374 3375 test_meta = self._model_test_metadata 3376 3377 if tests: 3378 filtered_tests = [] 3379 for test in tests: 3380 if "::" in test: 3381 if test in self._model_test_metadata_fully_qualified_name_index: 3382 filtered_tests.append( 3383 self._model_test_metadata_fully_qualified_name_index[test] 3384 ) 3385 else: 3386 test_path = Path(test) 3387 if test_path in self._model_test_metadata_path_index: 3388 filtered_tests.extend(self._model_test_metadata_path_index[test_path]) 3389 3390 test_meta = filtered_tests 3391 3392 if patterns: 3393 test_meta = filter_tests_by_patterns(test_meta, patterns) 3394 3395 return test_meta 3396 3397 3398class Context(GenericContext[Config]): 3399 CONFIG_TYPE = Config
167class BaseContext(abc.ABC): 168 """The base context which defines methods to execute a model.""" 169 170 @property 171 @abc.abstractmethod 172 def default_dialect(self) -> t.Optional[str]: 173 """Returns the default dialect.""" 174 175 @property 176 @abc.abstractmethod 177 def _model_tables(self) -> t.Dict[str, str]: 178 """Returns a mapping of model names to tables.""" 179 180 @property 181 @abc.abstractmethod 182 def engine_adapter(self) -> EngineAdapter: 183 """Returns an engine adapter.""" 184 185 @property 186 def spark(self) -> t.Optional[PySparkSession]: 187 """Returns the spark session if it exists.""" 188 return self.engine_adapter.spark 189 190 @property 191 def snowpark(self) -> t.Optional[SnowparkSession]: 192 """Returns the snowpark session if it exists.""" 193 return self.engine_adapter.snowpark 194 195 @property 196 def bigframe(self) -> t.Optional[BigframeSession]: 197 """Returns the bigframe session if it exists.""" 198 return self.engine_adapter.bigframe 199 200 @property 201 def default_catalog(self) -> t.Optional[str]: 202 raise NotImplementedError 203 204 def table(self, model_name: str) -> str: 205 get_console().log_warning( 206 "The SQLMesh context's `table` method is deprecated and will be removed " 207 "in a future release. Please use the `resolve_table` method instead." 208 ) 209 return self.resolve_table(model_name) 210 211 def resolve_table(self, model_name: str) -> str: 212 """Gets the physical table name for a given model. 213 214 Args: 215 model_name: The model name. 216 217 Returns: 218 The physical table name. 219 """ 220 model_name = normalize_model_name(model_name, self.default_catalog, self.default_dialect) 221 222 if model_name not in self._model_tables: 223 model_name_list = "\n".join(list(self._model_tables)) 224 logger.debug( 225 f"'{model_name}' not found in model to table mapping. Available model names: \n{model_name_list}" 226 ) 227 raise SQLMeshError( 228 f"Unable to find a table mapping for model '{model_name}'. Has it been spelled correctly?" 229 ) 230 231 # We generate SQL for the default dialect because the table name may be used in a 232 # fetchdf call and so the quotes need to be correct (eg. backticks for bigquery) 233 return parse_one(self._model_tables[model_name]).sql( 234 dialect=self.default_dialect, identify=True 235 ) 236 237 def fetchdf( 238 self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False 239 ) -> pd.DataFrame: 240 """Fetches a dataframe given a sql string or sqlglot expression. 241 242 Args: 243 query: SQL string or sqlglot expression. 244 quote_identifiers: Whether to quote all identifiers in the query. 245 246 Returns: 247 The default dataframe is Pandas, but for Spark a PySpark dataframe is returned. 248 """ 249 return self.engine_adapter.fetchdf(query, quote_identifiers=quote_identifiers) 250 251 def fetch_pyspark_df( 252 self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False 253 ) -> PySparkDataFrame: 254 """Fetches a PySpark dataframe given a sql string or sqlglot expression. 255 256 Args: 257 query: SQL string or sqlglot expression. 258 quote_identifiers: Whether to quote all identifiers in the query. 259 260 Returns: 261 A PySpark dataframe. 262 """ 263 return self.engine_adapter.fetch_pyspark_df(query, quote_identifiers=quote_identifiers)
The base context which defines methods to execute a model.
170 @property 171 @abc.abstractmethod 172 def default_dialect(self) -> t.Optional[str]: 173 """Returns the default dialect."""
Returns the default dialect.
180 @property 181 @abc.abstractmethod 182 def engine_adapter(self) -> EngineAdapter: 183 """Returns an engine adapter."""
Returns an engine adapter.
185 @property 186 def spark(self) -> t.Optional[PySparkSession]: 187 """Returns the spark session if it exists.""" 188 return self.engine_adapter.spark
Returns the spark session if it exists.
190 @property 191 def snowpark(self) -> t.Optional[SnowparkSession]: 192 """Returns the snowpark session if it exists.""" 193 return self.engine_adapter.snowpark
Returns the snowpark session if it exists.
195 @property 196 def bigframe(self) -> t.Optional[BigframeSession]: 197 """Returns the bigframe session if it exists.""" 198 return self.engine_adapter.bigframe
Returns the bigframe session if it exists.
211 def resolve_table(self, model_name: str) -> str: 212 """Gets the physical table name for a given model. 213 214 Args: 215 model_name: The model name. 216 217 Returns: 218 The physical table name. 219 """ 220 model_name = normalize_model_name(model_name, self.default_catalog, self.default_dialect) 221 222 if model_name not in self._model_tables: 223 model_name_list = "\n".join(list(self._model_tables)) 224 logger.debug( 225 f"'{model_name}' not found in model to table mapping. Available model names: \n{model_name_list}" 226 ) 227 raise SQLMeshError( 228 f"Unable to find a table mapping for model '{model_name}'. Has it been spelled correctly?" 229 ) 230 231 # We generate SQL for the default dialect because the table name may be used in a 232 # fetchdf call and so the quotes need to be correct (eg. backticks for bigquery) 233 return parse_one(self._model_tables[model_name]).sql( 234 dialect=self.default_dialect, identify=True 235 )
Gets the physical table name for a given model.
Arguments:
- model_name: The model name.
Returns:
The physical table name.
237 def fetchdf( 238 self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False 239 ) -> pd.DataFrame: 240 """Fetches a dataframe given a sql string or sqlglot expression. 241 242 Args: 243 query: SQL string or sqlglot expression. 244 quote_identifiers: Whether to quote all identifiers in the query. 245 246 Returns: 247 The default dataframe is Pandas, but for Spark a PySpark dataframe is returned. 248 """ 249 return self.engine_adapter.fetchdf(query, quote_identifiers=quote_identifiers)
Fetches a dataframe given a sql string or sqlglot expression.
Arguments:
- query: SQL string or sqlglot expression.
- quote_identifiers: Whether to quote all identifiers in the query.
Returns:
The default dataframe is Pandas, but for Spark a PySpark dataframe is returned.
251 def fetch_pyspark_df( 252 self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False 253 ) -> PySparkDataFrame: 254 """Fetches a PySpark dataframe given a sql string or sqlglot expression. 255 256 Args: 257 query: SQL string or sqlglot expression. 258 quote_identifiers: Whether to quote all identifiers in the query. 259 260 Returns: 261 A PySpark dataframe. 262 """ 263 return self.engine_adapter.fetch_pyspark_df(query, quote_identifiers=quote_identifiers)
Fetches a PySpark dataframe given a sql string or sqlglot expression.
Arguments:
- query: SQL string or sqlglot expression.
- quote_identifiers: Whether to quote all identifiers in the query.
Returns:
A PySpark dataframe.
266class ExecutionContext(BaseContext): 267 """The minimal context needed to execute a model. 268 269 Args: 270 engine_adapter: The engine adapter to execute queries against. 271 snapshots: All upstream snapshots (by model name) to use for expansion and mapping of physical locations. 272 deployability_index: Determines snapshots that are deployable in the context of this evaluation. 273 """ 274 275 def __init__( 276 self, 277 engine_adapter: EngineAdapter, 278 snapshots: t.Dict[str, Snapshot], 279 deployability_index: t.Optional[DeployabilityIndex] = None, 280 default_dialect: t.Optional[str] = None, 281 default_catalog: t.Optional[str] = None, 282 is_restatement: t.Optional[bool] = None, 283 parent_intervals: t.Optional[Intervals] = None, 284 variables: t.Optional[t.Dict[str, t.Any]] = None, 285 blueprint_variables: t.Optional[t.Dict[str, t.Any]] = None, 286 ): 287 self.snapshots = snapshots 288 self.deployability_index = deployability_index 289 self._engine_adapter = engine_adapter 290 self._default_catalog = default_catalog 291 self._default_dialect = default_dialect 292 self._variables = variables or {} 293 self._blueprint_variables = blueprint_variables or {} 294 self._is_restatement = is_restatement 295 self._parent_intervals = parent_intervals 296 297 @property 298 def default_dialect(self) -> t.Optional[str]: 299 return self._default_dialect 300 301 @property 302 def engine_adapter(self) -> EngineAdapter: 303 """Returns an engine adapter.""" 304 return self._engine_adapter 305 306 @cached_property 307 def _model_tables(self) -> t.Dict[str, str]: 308 """Returns a mapping of model names to tables.""" 309 return to_table_mapping(self.snapshots.values(), self.deployability_index) 310 311 @property 312 def default_catalog(self) -> t.Optional[str]: 313 return self._default_catalog 314 315 @property 316 def gateway(self) -> t.Optional[str]: 317 """Returns the gateway name.""" 318 return self.var(c.GATEWAY) 319 320 @property 321 def is_restatement(self) -> t.Optional[bool]: 322 return self._is_restatement 323 324 @property 325 def parent_intervals(self) -> t.Optional[Intervals]: 326 return self._parent_intervals 327 328 def var(self, var_name: str, default: t.Optional[t.Any] = None) -> t.Optional[t.Any]: 329 """Returns a variable value.""" 330 return self._variables.get(var_name.lower(), default) 331 332 def blueprint_var(self, var_name: str, default: t.Optional[t.Any] = None) -> t.Optional[t.Any]: 333 """Returns a blueprint variable value.""" 334 return self._blueprint_variables.get(var_name.lower(), default) 335 336 def with_variables( 337 self, 338 variables: t.Dict[str, t.Any], 339 blueprint_variables: t.Optional[t.Dict[str, t.Any]] = None, 340 ) -> ExecutionContext: 341 """Returns a new ExecutionContext with additional variables.""" 342 return ExecutionContext( 343 self._engine_adapter, 344 self.snapshots, 345 self.deployability_index, 346 self._default_dialect, 347 self._default_catalog, 348 self._is_restatement, 349 variables=variables, 350 blueprint_variables=blueprint_variables, 351 )
The minimal context needed to execute a model.
Arguments:
- engine_adapter: The engine adapter to execute queries against.
- snapshots: All upstream snapshots (by model name) to use for expansion and mapping of physical locations.
- deployability_index: Determines snapshots that are deployable in the context of this evaluation.
275 def __init__( 276 self, 277 engine_adapter: EngineAdapter, 278 snapshots: t.Dict[str, Snapshot], 279 deployability_index: t.Optional[DeployabilityIndex] = None, 280 default_dialect: t.Optional[str] = None, 281 default_catalog: t.Optional[str] = None, 282 is_restatement: t.Optional[bool] = None, 283 parent_intervals: t.Optional[Intervals] = None, 284 variables: t.Optional[t.Dict[str, t.Any]] = None, 285 blueprint_variables: t.Optional[t.Dict[str, t.Any]] = None, 286 ): 287 self.snapshots = snapshots 288 self.deployability_index = deployability_index 289 self._engine_adapter = engine_adapter 290 self._default_catalog = default_catalog 291 self._default_dialect = default_dialect 292 self._variables = variables or {} 293 self._blueprint_variables = blueprint_variables or {} 294 self._is_restatement = is_restatement 295 self._parent_intervals = parent_intervals
301 @property 302 def engine_adapter(self) -> EngineAdapter: 303 """Returns an engine adapter.""" 304 return self._engine_adapter
Returns an engine adapter.
315 @property 316 def gateway(self) -> t.Optional[str]: 317 """Returns the gateway name.""" 318 return self.var(c.GATEWAY)
Returns the gateway name.
328 def var(self, var_name: str, default: t.Optional[t.Any] = None) -> t.Optional[t.Any]: 329 """Returns a variable value.""" 330 return self._variables.get(var_name.lower(), default)
Returns a variable value.
332 def blueprint_var(self, var_name: str, default: t.Optional[t.Any] = None) -> t.Optional[t.Any]: 333 """Returns a blueprint variable value.""" 334 return self._blueprint_variables.get(var_name.lower(), default)
Returns a blueprint variable value.
336 def with_variables( 337 self, 338 variables: t.Dict[str, t.Any], 339 blueprint_variables: t.Optional[t.Dict[str, t.Any]] = None, 340 ) -> ExecutionContext: 341 """Returns a new ExecutionContext with additional variables.""" 342 return ExecutionContext( 343 self._engine_adapter, 344 self.snapshots, 345 self.deployability_index, 346 self._default_dialect, 347 self._default_catalog, 348 self._is_restatement, 349 variables=variables, 350 blueprint_variables=blueprint_variables, 351 )
Returns a new ExecutionContext with additional variables.
Inherited Members
354class GenericContext(BaseContext, t.Generic[C]): 355 """Encapsulates a SQLMesh environment supplying convenient functions to perform various tasks. 356 357 Args: 358 notification_targets: The notification target to use. Defaults to what is defined in config. 359 paths: The directories containing SQLMesh files. 360 config: A Config object or the name of a Config object in config.py. 361 connection: The name of the connection. If not specified the first connection as it appears 362 in configuration will be used. 363 test_connection: The name of the connection to use for tests. If not specified the first 364 connection as it appears in configuration will be used. 365 concurrent_tasks: The maximum number of tasks that can use the connection concurrently. 366 load: Whether or not to automatically load all models and macros (default True). 367 load_state: Whether to merge remote state into the local project during load (default True). 368 Only intended for local-only operations like format; plan/apply in multi-repo projects 369 require it to see models owned by other projects. 370 console: The rich instance used for printing out CLI command results. 371 users: A list of users to make known to SQLMesh. 372 """ 373 374 CONFIG_TYPE: t.Type[C] 375 """The type of config object to use (default: Config).""" 376 377 PLAN_BUILDER_TYPE = PlanBuilder 378 """The type of plan builder object to use (default: PlanBuilder).""" 379 380 def __init__( 381 self, 382 notification_targets: t.Optional[t.List[NotificationTarget]] = None, 383 state_sync: t.Optional[StateSync] = None, 384 paths: t.Union[str | Path, t.Iterable[str | Path]] = "", 385 config: t.Optional[t.Union[C, str, t.Dict[Path, C]]] = None, 386 gateway: t.Optional[str] = None, 387 concurrent_tasks: t.Optional[int] = None, 388 loader: t.Optional[t.Type[Loader]] = None, 389 load: bool = True, 390 users: t.Optional[t.List[User]] = None, 391 config_loader_kwargs: t.Optional[t.Dict[str, t.Any]] = None, 392 selector: t.Optional[t.Type[Selector]] = None, 393 load_state: bool = True, 394 ): 395 self.configs = ( 396 config 397 if isinstance(config, dict) 398 else load_configs(config, self.CONFIG_TYPE, paths, **(config_loader_kwargs or {})) 399 ) 400 self._projects = {config.project for config in self.configs.values()} 401 self.dag: DAG[str] = DAG() 402 self._models: UniqueKeyDict[str, Model] = UniqueKeyDict("models") 403 self._audits: UniqueKeyDict[str, ModelAudit] = UniqueKeyDict("audits") 404 self._standalone_audits: UniqueKeyDict[str, StandaloneAudit] = UniqueKeyDict( 405 "standaloneaudits" 406 ) 407 self._model_test_metadata: t.List[ModelTestMetadata] = [] 408 self._model_test_metadata_path_index: t.Dict[Path, t.List[ModelTestMetadata]] = {} 409 self._model_test_metadata_fully_qualified_name_index: t.Dict[str, ModelTestMetadata] = {} 410 self._models_with_tests: t.Set[str] = set() 411 412 self._macros: UniqueKeyDict[str, ExecutableOrMacro] = UniqueKeyDict("macros") 413 self._metrics: UniqueKeyDict[str, Metric] = UniqueKeyDict("metrics") 414 self._jinja_macros = JinjaMacroRegistry() 415 self._requirements: t.Dict[str, str] = {} 416 self._environment_statements: t.List[EnvironmentStatements] = [] 417 self._excluded_requirements: t.Set[str] = set() 418 self._engine_adapter: t.Optional[EngineAdapter] = None 419 self._linters: t.Dict[str, Linter] = {} 420 self._loaded: bool = False 421 self._load_state: bool = load_state 422 self._selector_cls = selector or NativeSelector 423 424 self.path, self.config = t.cast(t.Tuple[Path, C], next(iter(self.configs.items()))) 425 426 self._all_dialects: t.Set[str] = {self.config.dialect or ""} 427 428 if self.config.disable_anonymized_analytics: 429 analytics.disable_analytics() 430 431 self.gateway = gateway 432 self._scheduler = self.config.get_scheduler(self.gateway) 433 self.environment_ttl = self.config.environment_ttl 434 self.pinned_environments = Environment.sanitize_names(self.config.pinned_environments) 435 self.auto_categorize_changes = self.config.plan.auto_categorize_changes 436 self.selected_gateway = (gateway or self.config.default_gateway_name).lower() 437 438 gw_model_defaults = self.config.get_gateway(self.selected_gateway).model_defaults 439 if gw_model_defaults: 440 # Merge global model defaults with the selected gateway's, if it's overriden 441 global_defaults = self.config.model_defaults.model_dump(exclude_unset=True) 442 gateway_defaults = gw_model_defaults.model_dump(exclude_unset=True) 443 444 self.config.model_defaults = ModelDefaultsConfig( 445 **{**global_defaults, **gateway_defaults} 446 ) 447 448 # This allows overriding the default dialect's normalization strategy, so for example 449 # one can do `dialect="duckdb,normalization_strategy=lowercase"` and this will be 450 # applied to the DuckDB dialect globally 451 if "normalization_strategy" in str(self.config.dialect): 452 dialect = Dialect.get_or_raise(self.config.dialect) 453 type(dialect).NORMALIZATION_STRATEGY = dialect.normalization_strategy 454 455 self._loaders = [ 456 (loader or config.loader)(self, path, **config.loader_kwargs) 457 for path, config in self.configs.items() 458 ] 459 460 self._concurrent_tasks = concurrent_tasks 461 self._state_connection_config = ( 462 self.config.get_state_connection(self.gateway) or self.connection_config 463 ) 464 465 self._snapshot_evaluator: t.Optional[SnapshotEvaluator] = None 466 467 self.console = get_console() 468 setattr(self.console, "dialect", self.config.dialect) 469 470 self._provided_state_sync: t.Optional[StateSync] = state_sync 471 self._state_sync: t.Optional[StateSync] = None 472 473 # Should we dedupe notification_targets? If so how? 474 self.notification_targets = (notification_targets or []) + self.config.notification_targets 475 self.users = (users or []) + self.config.users 476 self.users = list({user.username: user for user in self.users}.values()) 477 self._register_notification_targets() 478 479 if load: 480 self.load() 481 482 @property 483 def default_dialect(self) -> t.Optional[str]: 484 return self.config.dialect 485 486 @property 487 def engine_adapter(self) -> EngineAdapter: 488 """Returns the default engine adapter.""" 489 if self._engine_adapter is None: 490 self._engine_adapter = self.connection_config.create_engine_adapter() 491 return self._engine_adapter 492 493 @property 494 def snapshot_evaluator(self) -> SnapshotEvaluator: 495 if not self._snapshot_evaluator: 496 self._ensure_virtual_catalog_injection() 497 self._snapshot_evaluator = SnapshotEvaluator( 498 { 499 gateway: adapter.with_settings(execute_log_level=logging.INFO) 500 for gateway, adapter in self.engine_adapters.items() 501 }, 502 ddl_concurrent_tasks=self.concurrent_tasks, 503 selected_gateway=self.selected_gateway, 504 ) 505 return self._snapshot_evaluator 506 507 def _ensure_virtual_catalog_injection(self) -> None: 508 """Ensure virtual catalog injection has run before adapters are cloned for SnapshotEvaluator. 509 510 Injection is a side effect of get_default_catalog_per_gateway. In normal usage it fires 511 earlier (default_catalog is accessed during model loading), but this guard covers the edge 512 case where snapshot_evaluator is accessed directly on a fresh context before any model ops. 513 """ 514 _ = self.default_catalog_per_gateway 515 516 def execution_context( 517 self, 518 deployability_index: t.Optional[DeployabilityIndex] = None, 519 engine_adapter: t.Optional[EngineAdapter] = None, 520 snapshots: t.Optional[t.Dict[str, Snapshot]] = None, 521 ) -> ExecutionContext: 522 """Returns an execution context.""" 523 return ExecutionContext( 524 engine_adapter=engine_adapter or self.engine_adapter, 525 snapshots=snapshots or self.snapshots, 526 deployability_index=deployability_index, 527 default_dialect=self.default_dialect, 528 default_catalog=self.default_catalog, 529 ) 530 531 @python_api_analytics 532 def upsert_model(self, model: t.Union[str, Model], **kwargs: t.Any) -> Model: 533 """Update or insert a model. 534 535 The context's models dictionary will be updated to include these changes. 536 537 Args: 538 model: Model name or instance to update. 539 kwargs: The kwargs to update the model with. 540 541 Returns: 542 A new instance of the updated or inserted model. 543 """ 544 model = self.get_model(model, raise_if_missing=True) 545 if not model.enabled: 546 raise SQLMeshError(f"The disabled model '{model.name}' cannot be upserted") 547 path = model._path 548 549 model = model.copy(update=kwargs) 550 model._path = path 551 552 self.dag.add(model.fqn, model.depends_on) 553 554 self._models.update( 555 { 556 model.fqn: model, 557 # bust the fingerprint cache for all downstream models 558 **{fqn: self._models[fqn].copy() for fqn in self.dag.downstream(model.fqn)}, 559 } 560 ) 561 562 update_model_schemas( 563 self.dag, 564 models=self._models, 565 cache_dir=self.cache_dir, 566 ) 567 568 if model.dialect: 569 self._all_dialects.add(model.dialect) 570 571 model.validate_definition() 572 573 return model 574 575 def scheduler( 576 self, 577 environment: t.Optional[str] = None, 578 snapshot_evaluator: t.Optional[SnapshotEvaluator] = None, 579 ) -> Scheduler: 580 """Returns the built-in scheduler. 581 582 Args: 583 environment: The target environment to source model snapshots from, or None 584 if snapshots should be sourced from the currently loaded local state. 585 586 Returns: 587 The built-in scheduler instance. 588 """ 589 snapshots: t.Iterable[Snapshot] 590 if environment is not None: 591 stored_environment = self.state_sync.get_environment(environment) 592 if stored_environment is None: 593 raise ConfigError(f"Environment '{environment}' was not found.") 594 snapshots = self.state_sync.get_snapshots(stored_environment.snapshots).values() 595 else: 596 snapshots = self.snapshots.values() 597 598 if not snapshots: 599 raise ConfigError("No models were found") 600 601 return self.create_scheduler(snapshots, snapshot_evaluator or self.snapshot_evaluator) 602 603 def create_scheduler( 604 self, snapshots: t.Iterable[Snapshot], snapshot_evaluator: SnapshotEvaluator 605 ) -> Scheduler: 606 """Creates the built-in scheduler. 607 608 Args: 609 snapshots: The snapshots to schedule. 610 611 Returns: 612 The built-in scheduler instance. 613 """ 614 return Scheduler( 615 snapshots, 616 snapshot_evaluator, 617 self.state_sync, 618 default_catalog=self.default_catalog, 619 max_workers=self.concurrent_tasks, 620 console=self.console, 621 notification_target_manager=self.notification_target_manager, 622 ) 623 624 @property 625 def state_sync(self) -> StateSync: 626 if not self._state_sync: 627 self._state_sync = self._new_state_sync() 628 629 if self._state_sync.get_versions(validate=False).schema_version == 0: 630 self.console.log_status_update("Initializing new project state...") 631 self._state_sync.migrate() 632 self._state_sync.get_versions() 633 self._state_sync = CachingStateSync(self._state_sync) # type: ignore 634 return self._state_sync 635 636 @property 637 def state_reader(self) -> StateReader: 638 return self.state_sync 639 640 def refresh(self) -> None: 641 """Refresh all models that have been updated.""" 642 if any(loader.reload_needed() for loader in self._loaders): 643 self.load() 644 645 def load(self, update_schemas: bool = True) -> GenericContext[C]: 646 """Load all files in the context's path.""" 647 load_start_ts = time.perf_counter() 648 649 loaded_projects = [loader.load() for loader in self._loaders] 650 651 self.dag = DAG() 652 self._standalone_audits.clear() 653 self._audits.clear() 654 self._macros.clear() 655 self._models.clear() 656 self._metrics.clear() 657 self._requirements.clear() 658 self._excluded_requirements.clear() 659 self._linters.clear() 660 self._environment_statements = [] 661 self._model_test_metadata.clear() 662 self._model_test_metadata_path_index.clear() 663 self._model_test_metadata_fully_qualified_name_index.clear() 664 self._models_with_tests.clear() 665 666 for loader, project in zip(self._loaders, loaded_projects): 667 self._jinja_macros = self._jinja_macros.merge(project.jinja_macros) 668 self._macros.update(project.macros) 669 self._models.update(project.models) 670 self._metrics.update(project.metrics) 671 self._audits.update(project.audits) 672 self._standalone_audits.update(project.standalone_audits) 673 self._requirements.update(project.requirements) 674 self._excluded_requirements.update(project.excluded_requirements) 675 self._environment_statements.extend(project.environment_statements) 676 677 self._model_test_metadata.extend(project.model_test_metadata) 678 for metadata in project.model_test_metadata: 679 if metadata.path not in self._model_test_metadata_path_index: 680 self._model_test_metadata_path_index[metadata.path] = [] 681 self._model_test_metadata_path_index[metadata.path].append(metadata) 682 self._model_test_metadata_fully_qualified_name_index[ 683 metadata.fully_qualified_test_name 684 ] = metadata 685 self._models_with_tests.add(metadata.model_name) 686 687 config = loader.config 688 self._linters[config.project] = Linter.from_rules( 689 BUILTIN_RULES.union(project.user_rules), config.linter 690 ) 691 692 # Load environment statements from state for projects not in current load 693 if self._load_state and any(self._projects): 694 prod = self.state_reader.get_environment(c.PROD) 695 if prod: 696 existing_statements = self.state_reader.get_environment_statements(c.PROD) 697 for stmt in existing_statements: 698 if stmt.project and stmt.project not in self._projects: 699 self._environment_statements.append(stmt) 700 701 uncached = set() 702 703 if self._load_state and any(self._projects): 704 prod = self.state_reader.get_environment(c.PROD) 705 706 if prod: 707 for snapshot in self.state_reader.get_snapshots(prod.snapshots).values(): 708 if snapshot.node.project in self._projects: 709 uncached.add(snapshot.name) 710 else: 711 local_store = self._standalone_audits if snapshot.is_audit else self._models 712 if snapshot.name in local_store: 713 uncached.add(snapshot.name) 714 else: 715 local_store[snapshot.name] = snapshot.node # type: ignore 716 717 for model in self._models.values(): 718 self.dag.add(model.fqn, model.depends_on) 719 720 if update_schemas: 721 for fqn in self.dag: 722 model = self._models.get(fqn) # type: ignore 723 724 if not model or fqn in uncached: 725 continue 726 727 # make a copy of remote models that depend on local models or in the downstream chain 728 # without this, a SELECT * FROM local will not propogate properly because the downstream 729 # model will get mutated (schema changes) but the object is the same as the remote cache 730 if any(dep in uncached for dep in model.depends_on): 731 uncached.add(fqn) 732 self._models.update({fqn: model.copy(update={"mapping_schema": {}})}) 733 continue 734 735 update_model_schemas( 736 self.dag, 737 models=self._models, 738 cache_dir=self.cache_dir, 739 ) 740 741 models = self.models.values() 742 for model in models: 743 # The model definition can be validated correctly only after the schema is set. 744 model.validate_definition() 745 746 duplicates = set(self._models) & set(self._standalone_audits) 747 if duplicates: 748 raise ConfigError( 749 f"Models and Standalone audits cannot have the same name: {duplicates}" 750 ) 751 752 self._all_dialects = {m.dialect for m in self._models.values() if m.dialect} | { 753 self.default_dialect or "" 754 } 755 756 analytics.collector.on_project_loaded( 757 project_type=self._project_type, 758 models_count=len(self._models), 759 audits_count=len(self._audits), 760 standalone_audits_count=len(self._standalone_audits), 761 macros_count=len(self._macros), 762 jinja_macros_count=len(self._jinja_macros.root_macros), 763 load_time_sec=time.perf_counter() - load_start_ts, 764 state_sync_fingerprint=self._scheduler.state_sync_fingerprint(self), 765 project_name=self.config.project, 766 ) 767 768 self._loaded = True 769 return self 770 771 @python_api_analytics 772 def run( 773 self, 774 environment: t.Optional[str] = None, 775 *, 776 start: t.Optional[TimeLike] = None, 777 end: t.Optional[TimeLike] = None, 778 execution_time: t.Optional[TimeLike] = None, 779 skip_janitor: bool = False, 780 ignore_cron: bool = False, 781 select_models: t.Optional[t.Collection[str]] = None, 782 exit_on_env_update: t.Optional[int] = None, 783 no_auto_upstream: bool = False, 784 ) -> CompletionStatus: 785 """Run the entire dag through the scheduler. 786 787 Args: 788 environment: The target environment to source model snapshots from and virtually update. Default: prod. 789 start: The start of the interval to render. 790 end: The end of the interval to render. 791 execution_time: The date/time time reference to use for execution time. Defaults to now. 792 skip_janitor: Whether to skip the janitor task. 793 ignore_cron: Whether to ignore the model's cron schedule and run all available missing intervals. 794 select_models: A list of model selection expressions to filter models that should run. Note that 795 upstream dependencies of selected models will also be evaluated. 796 exit_on_env_update: If set, exits with the provided code if the run is interrupted by an update 797 to the target environment. 798 no_auto_upstream: Whether to not force upstream models to run. Only applicable when using `select_models`. 799 800 Returns: 801 True if the run was successful, False otherwise. 802 """ 803 environment = environment or self.config.default_target_environment 804 environment = Environment.sanitize_name(environment) 805 if not skip_janitor and environment.lower() == c.PROD: 806 self._run_janitor() 807 808 self.notification_target_manager.notify( 809 NotificationEvent.RUN_START, environment=environment 810 ) 811 analytics_run_id = analytics.collector.on_run_start( 812 engine_type=self.snapshot_evaluator.adapter.dialect, 813 state_sync_type=self.state_sync.state_type(), 814 ) 815 self._load_materializations() 816 817 env_check_attempts_num = max( 818 1, 819 self.config.run.environment_check_max_wait 820 // self.config.run.environment_check_interval, 821 ) 822 823 def _block_until_finalized() -> str: 824 for _ in range(env_check_attempts_num): 825 assert environment is not None # mypy 826 environment_state = self.state_sync.get_environment(environment) 827 if not environment_state: 828 raise SQLMeshError(f"Environment '{environment}' was not found.") 829 if environment_state.finalized_ts: 830 return environment_state.plan_id 831 self.console.log_warning( 832 f"Environment '{environment}' is being updated by plan '{environment_state.plan_id}'. " 833 f"Retrying in {self.config.run.environment_check_interval} seconds..." 834 ) 835 time.sleep(self.config.run.environment_check_interval) 836 raise SQLMeshError( 837 f"Exceeded the maximum wait time for environment '{environment}' to be ready. " 838 "This means that the environment either failed to update or the update is taking longer than expected. " 839 "See https://sqlmesh.readthedocs.io/en/stable/reference/configuration/#run to adjust the timeout settings." 840 ) 841 842 success = False 843 interrupted = False 844 done = False 845 while not done: 846 plan_id_at_start = _block_until_finalized() 847 848 def _has_environment_changed() -> bool: 849 assert environment is not None # mypy 850 current_environment_state = self.state_sync.get_environment(environment) 851 return ( 852 not current_environment_state 853 or current_environment_state.plan_id != plan_id_at_start 854 or not current_environment_state.finalized_ts 855 ) 856 857 try: 858 completion_status = self._run( 859 environment, 860 start=start, 861 end=end, 862 execution_time=execution_time, 863 ignore_cron=ignore_cron, 864 select_models=select_models, 865 circuit_breaker=_has_environment_changed, 866 no_auto_upstream=no_auto_upstream, 867 ) 868 done = True 869 except CircuitBreakerError: 870 self.console.log_warning( 871 f"Environment '{environment}' modified while running. Restarting the run..." 872 ) 873 if exit_on_env_update: 874 interrupted = True 875 done = True 876 except Exception as e: 877 self.notification_target_manager.notify( 878 NotificationEvent.RUN_FAILURE, traceback.format_exc() 879 ) 880 logger.info("Run failed.", exc_info=e) 881 analytics.collector.on_run_end( 882 run_id=analytics_run_id, succeeded=False, interrupted=False, error=e 883 ) 884 raise e 885 886 if completion_status.is_success or interrupted: 887 self.notification_target_manager.notify( 888 NotificationEvent.RUN_END, environment=environment 889 ) 890 self.console.log_success(f"Run finished for environment '{environment}'") 891 elif completion_status.is_failure: 892 self.notification_target_manager.notify( 893 NotificationEvent.RUN_FAILURE, "See console logs for details." 894 ) 895 896 analytics.collector.on_run_end( 897 run_id=analytics_run_id, succeeded=success, interrupted=interrupted 898 ) 899 900 if interrupted and exit_on_env_update is not None: 901 sys.exit(exit_on_env_update) 902 903 return completion_status 904 905 @python_api_analytics 906 def run_janitor( 907 self, 908 ignore_ttl: bool, 909 force_delete: bool = False, 910 environment: t.Optional[str] = None, 911 ) -> bool: 912 if environment is not None: 913 environment = Environment.sanitize_name(environment) 914 915 success = False 916 917 if self.console.start_cleanup(ignore_ttl): 918 try: 919 self._run_janitor(ignore_ttl, force_delete=force_delete, environment=environment) 920 success = True 921 finally: 922 self.console.stop_cleanup(success=success) 923 924 return success 925 926 @python_api_analytics 927 def destroy(self) -> bool: 928 success = False 929 930 # Collect resources to be deleted 931 environments = self.state_reader.get_environments() 932 schemas_to_delete = set() 933 tables_to_delete = set() 934 views_to_delete = set() 935 all_snapshot_infos = set() 936 937 # For each environment find schemas and tables 938 for environment in environments: 939 all_snapshot_infos.update(environment.snapshots) 940 snapshots = self.state_reader.get_snapshots(environment.snapshots).values() 941 for snapshot in snapshots: 942 if snapshot.is_model and not snapshot.is_symbolic: 943 # Get the appropriate adapter 944 if environment.gateway_managed and snapshot.model_gateway: 945 adapter = self.engine_adapters.get( 946 snapshot.model_gateway, self.engine_adapter 947 ) 948 else: 949 adapter = self.engine_adapter 950 951 if environment.suffix_target.is_schema or environment.suffix_target.is_catalog: 952 schema = snapshot.qualified_view_name.schema_for_environment( 953 environment.naming_info, dialect=adapter.dialect 954 ) 955 catalog = snapshot.qualified_view_name.catalog_for_environment( 956 environment.naming_info, dialect=adapter.dialect 957 ) 958 if catalog: 959 schemas_to_delete.add(f"{catalog}.{schema}") 960 else: 961 schemas_to_delete.add(schema) 962 963 if environment.suffix_target.is_table: 964 view_name = snapshot.qualified_view_name.for_environment( 965 environment.naming_info, dialect=adapter.dialect 966 ) 967 views_to_delete.add(view_name) 968 969 # Add snapshot tables 970 table_name = snapshot.table_name() 971 tables_to_delete.add(table_name) 972 973 if self.console.start_destroy(schemas_to_delete, views_to_delete, tables_to_delete): 974 try: 975 success = self._destroy() 976 finally: 977 self.console.stop_destroy(success=success) 978 979 return success 980 981 @t.overload 982 def get_model( 983 self, model_or_snapshot: ModelOrSnapshot, raise_if_missing: Literal[True] = True 984 ) -> Model: ... 985 986 @t.overload 987 def get_model( 988 self, 989 model_or_snapshot: ModelOrSnapshot, 990 raise_if_missing: Literal[False] = False, 991 ) -> t.Optional[Model]: ... 992 993 def get_model( 994 self, model_or_snapshot: ModelOrSnapshot, raise_if_missing: bool = False 995 ) -> t.Optional[Model]: 996 """Returns a model with the given name or None if a model with such name doesn't exist. 997 998 Args: 999 model_or_snapshot: A model name, model, or snapshot. 1000 raise_if_missing: Raises an error if a model is not found. 1001 1002 Returns: 1003 The expected model. 1004 """ 1005 if isinstance(model_or_snapshot, Snapshot): 1006 return model_or_snapshot.model 1007 if not isinstance(model_or_snapshot, str): 1008 return model_or_snapshot 1009 1010 try: 1011 # We should try all dialects referenced in the project for cases when models use mixed dialects. 1012 for dialect in self._all_dialects: 1013 normalized_name = normalize_model_name( 1014 model_or_snapshot, 1015 dialect=dialect, 1016 default_catalog=self.default_catalog, 1017 ) 1018 if normalized_name in self._models: 1019 return self._models[normalized_name] 1020 except: 1021 pass 1022 1023 if raise_if_missing: 1024 if model_or_snapshot.endswith((".sql", ".py")): 1025 msg = "Resolving models by path is not supported, please pass in the model name instead." 1026 else: 1027 msg = f"Cannot find model with name '{model_or_snapshot}'" 1028 1029 raise SQLMeshError(msg) 1030 1031 return None 1032 1033 @t.overload 1034 def get_snapshot(self, node_or_snapshot: NodeOrSnapshot) -> t.Optional[Snapshot]: ... 1035 1036 @t.overload 1037 def get_snapshot( 1038 self, node_or_snapshot: NodeOrSnapshot, raise_if_missing: Literal[True] 1039 ) -> Snapshot: ... 1040 1041 @t.overload 1042 def get_snapshot( 1043 self, node_or_snapshot: NodeOrSnapshot, raise_if_missing: Literal[False] 1044 ) -> t.Optional[Snapshot]: ... 1045 1046 def get_snapshot( 1047 self, node_or_snapshot: NodeOrSnapshot, raise_if_missing: bool = False 1048 ) -> t.Optional[Snapshot]: 1049 """Returns a snapshot with the given name or None if a snapshot with such name doesn't exist. 1050 1051 Args: 1052 node_or_snapshot: A node name, node, or snapshot. 1053 raise_if_missing: Raises an error if a snapshot is not found. 1054 1055 Returns: 1056 The expected snapshot. 1057 """ 1058 if isinstance(node_or_snapshot, Snapshot): 1059 return node_or_snapshot 1060 fqn = self._node_or_snapshot_to_fqn(node_or_snapshot) 1061 snapshot = self.snapshots.get(fqn) 1062 1063 if raise_if_missing and not snapshot: 1064 raise SQLMeshError(f"Cannot find snapshot for '{fqn}'") 1065 1066 return snapshot 1067 1068 def config_for_path(self, path: Path) -> t.Tuple[Config, Path]: 1069 """Returns the config and path of the said project for a given file path.""" 1070 for config_path, config in self.configs.items(): 1071 try: 1072 path.relative_to(config_path) 1073 return config, config_path 1074 except ValueError: 1075 pass 1076 return self.config, self.path 1077 1078 def config_for_node(self, node: Model | Audit) -> Config: 1079 path = node._path 1080 if path is None: 1081 return self.config 1082 return self.config_for_path(path)[0] # type: ignore 1083 1084 @property 1085 def models(self) -> MappingProxyType[str, Model]: 1086 """Returns all registered models in this context.""" 1087 return MappingProxyType(self._models) 1088 1089 @property 1090 def metrics(self) -> MappingProxyType[str, Metric]: 1091 """Returns all registered metrics in this context.""" 1092 return MappingProxyType(self._metrics) 1093 1094 @property 1095 def standalone_audits(self) -> MappingProxyType[str, StandaloneAudit]: 1096 """Returns all registered standalone audits in this context.""" 1097 return MappingProxyType(self._standalone_audits) 1098 1099 @property 1100 def models_with_tests(self) -> t.Set[str]: 1101 """Returns all models with tests in this context.""" 1102 return self._models_with_tests 1103 1104 @property 1105 def snapshots(self) -> t.Dict[str, Snapshot]: 1106 """Generates and returns snapshots based on models registered in this context. 1107 1108 If one of the snapshots has been previously stored in the persisted state, the stored 1109 instance will be returned. 1110 """ 1111 return self._snapshots() 1112 1113 @property 1114 def requirements(self) -> t.Dict[str, str]: 1115 """Returns the Python dependencies of the project loaded in this context.""" 1116 return self._requirements.copy() 1117 1118 @cached_property 1119 def default_catalog(self) -> t.Optional[str]: 1120 return self.default_catalog_per_gateway.get(self.selected_gateway) 1121 1122 @python_api_analytics 1123 def render( 1124 self, 1125 model_or_snapshot: ModelOrSnapshot, 1126 *, 1127 start: t.Optional[TimeLike] = None, 1128 end: t.Optional[TimeLike] = None, 1129 execution_time: t.Optional[TimeLike] = None, 1130 expand: t.Union[bool, t.Iterable[str]] = False, 1131 **kwargs: t.Any, 1132 ) -> exp.Expr: 1133 """Renders a model's query, expanding macros with provided kwargs, and optionally expanding referenced models. 1134 1135 Args: 1136 model_or_snapshot: The model, model name, or snapshot to render. 1137 start: The start of the interval to render. 1138 end: The end of the interval to render. 1139 execution_time: The date/time time reference to use for execution time. Defaults to now. 1140 expand: Whether or not to use expand materialized models, defaults to False. 1141 If True, all referenced models are expanded as raw queries. 1142 If a list, only referenced models are expanded as raw queries. 1143 1144 Returns: 1145 The rendered expression. 1146 """ 1147 execution_time = execution_time or now() 1148 1149 model = self.get_model(model_or_snapshot, raise_if_missing=True) 1150 1151 if expand and not isinstance(expand, bool): 1152 expand = { 1153 normalize_model_name( 1154 x, default_catalog=self.default_catalog, dialect=self.default_dialect 1155 ) 1156 for x in expand 1157 } 1158 1159 expand = self.dag.upstream(model.fqn) if expand is True else expand or [] 1160 1161 if model.is_seed: 1162 import pandas as pd 1163 1164 df = next( 1165 model.render( 1166 context=self.execution_context( 1167 engine_adapter=self._get_engine_adapter(model.gateway) 1168 ), 1169 start=start, 1170 end=end, 1171 execution_time=execution_time, 1172 **kwargs, 1173 ) 1174 ) 1175 return next(pandas_to_sql(t.cast(pd.DataFrame, df), model.columns_to_types)) 1176 1177 snapshots = self.snapshots 1178 deployability_index = DeployabilityIndex.create(snapshots.values(), start=start) 1179 1180 return model.render_query_or_raise( 1181 start=start, 1182 end=end, 1183 execution_time=execution_time, 1184 snapshots=snapshots, 1185 expand=expand, 1186 deployability_index=deployability_index, 1187 engine_adapter=self._get_engine_adapter(model.gateway), 1188 **kwargs, 1189 ) 1190 1191 @python_api_analytics 1192 def evaluate( 1193 self, 1194 model_or_snapshot: ModelOrSnapshot, 1195 start: TimeLike, 1196 end: TimeLike, 1197 execution_time: TimeLike, 1198 limit: t.Optional[int] = None, 1199 **kwargs: t.Any, 1200 ) -> DF: 1201 """Evaluate a model or snapshot (running its query against a DB/Engine). 1202 1203 This method is used to test or iterate on models without side effects. 1204 1205 Args: 1206 model_or_snapshot: The model, model name, or snapshot to render. 1207 start: The start of the interval to evaluate. 1208 end: The end of the interval to evaluate. 1209 execution_time: The date/time time reference to use for execution time. 1210 limit: A limit applied to the model. 1211 """ 1212 snapshots = self.snapshots 1213 fqn = self._node_or_snapshot_to_fqn(model_or_snapshot) 1214 if fqn not in snapshots: 1215 raise SQLMeshError(f"Cannot find snapshot for '{fqn}'") 1216 snapshot = snapshots[fqn] 1217 1218 # Expand all uncategorized parents since physical tables don't exist for them yet 1219 expand = [ 1220 parent 1221 for parent in self.dag.upstream(snapshot.model.fqn) 1222 if (parent_snapshot := snapshots.get(parent)) 1223 and parent_snapshot.is_model 1224 and parent_snapshot.model.is_sql 1225 and not parent_snapshot.categorized 1226 ] 1227 1228 df = self.snapshot_evaluator.evaluate_and_fetch( 1229 snapshot, 1230 start=start, 1231 end=end, 1232 execution_time=execution_time, 1233 snapshots=self.snapshots, 1234 limit=limit or c.DEFAULT_MAX_LIMIT, 1235 expand=expand, 1236 ) 1237 1238 if df is None: 1239 raise RuntimeError(f"Error evaluating {snapshot.name}") 1240 1241 return df 1242 1243 @python_api_analytics 1244 def format( 1245 self, 1246 transpile: t.Optional[str] = None, 1247 rewrite_casts: t.Optional[bool] = None, 1248 append_newline: t.Optional[bool] = None, 1249 *, 1250 check: t.Optional[bool] = None, 1251 paths: t.Optional[t.Tuple[t.Union[str, Path], ...]] = None, 1252 **kwargs: t.Any, 1253 ) -> bool: 1254 """Format all SQL models and audits.""" 1255 filtered_targets = [ 1256 target 1257 for target in chain(self._models.values(), self._audits.values()) 1258 if target._path is not None 1259 and target._path.suffix == ".sql" 1260 and (not paths or any(target._path.samefile(p) for p in paths)) 1261 ] 1262 unformatted_file_paths = [] 1263 1264 for target in filtered_targets: 1265 if ( 1266 target._path is None or target.formatting is False 1267 ): # introduced to satisfy type checker as still want to pull filter out as many targets as possible before loop 1268 continue 1269 1270 with open(target._path, "r+", encoding="utf-8") as file: 1271 before = file.read() 1272 1273 after = self._format( 1274 target, 1275 before, 1276 transpile=transpile, 1277 rewrite_casts=rewrite_casts, 1278 append_newline=append_newline, 1279 **kwargs, 1280 ) 1281 1282 if not check: 1283 file.seek(0) 1284 file.write(after) 1285 file.truncate() 1286 elif before != after: 1287 unformatted_file_paths.append(target._path) 1288 1289 if unformatted_file_paths: 1290 for path in unformatted_file_paths: 1291 self.console.log_status_update(f"{path} needs reformatting.") 1292 self.console.log_status_update( 1293 f"\n{len(unformatted_file_paths)} file(s) need reformatting." 1294 ) 1295 return False 1296 1297 return True 1298 1299 def _format( 1300 self, 1301 target: Model | Audit, 1302 before: str, 1303 *, 1304 transpile: t.Optional[str] = None, 1305 rewrite_casts: t.Optional[bool] = None, 1306 append_newline: t.Optional[bool] = None, 1307 **kwargs: t.Any, 1308 ) -> str: 1309 expressions = parse(before, default_dialect=self.config_for_node(target).dialect) 1310 if transpile and is_meta_expression(expressions[0]): 1311 for prop in expressions[0].expressions: 1312 if prop.name.lower() == "dialect": 1313 prop.replace( 1314 exp.Property( 1315 this="dialect", 1316 value=exp.Literal.string(transpile or target.dialect), 1317 ) 1318 ) 1319 1320 format_config = self.config_for_node(target).format 1321 after = format_model_expressions( 1322 expressions, 1323 transpile or target.dialect, 1324 rewrite_casts=( 1325 rewrite_casts if rewrite_casts is not None else not format_config.no_rewrite_casts 1326 ), 1327 **{**format_config.generator_options, **kwargs}, 1328 ) 1329 1330 if append_newline is None: 1331 append_newline = format_config.append_newline 1332 if append_newline: 1333 after += "\n" 1334 1335 return after 1336 1337 @python_api_analytics 1338 def plan( 1339 self, 1340 environment: t.Optional[str] = None, 1341 *, 1342 start: t.Optional[TimeLike] = None, 1343 end: t.Optional[TimeLike] = None, 1344 execution_time: t.Optional[TimeLike] = None, 1345 create_from: t.Optional[str] = None, 1346 skip_tests: t.Optional[bool] = None, 1347 restate_models: t.Optional[t.Iterable[str]] = None, 1348 no_gaps: t.Optional[bool] = None, 1349 skip_backfill: t.Optional[bool] = None, 1350 empty_backfill: t.Optional[bool] = None, 1351 forward_only: t.Optional[bool] = None, 1352 allow_destructive_models: t.Optional[t.Collection[str]] = None, 1353 allow_additive_models: t.Optional[t.Collection[str]] = None, 1354 no_prompts: t.Optional[bool] = None, 1355 auto_apply: t.Optional[bool] = None, 1356 no_auto_categorization: t.Optional[bool] = None, 1357 effective_from: t.Optional[TimeLike] = None, 1358 include_unmodified: t.Optional[bool] = None, 1359 select_models: t.Optional[t.Collection[str]] = None, 1360 backfill_models: t.Optional[t.Collection[str]] = None, 1361 categorizer_config: t.Optional[CategorizerConfig] = None, 1362 enable_preview: t.Optional[bool] = None, 1363 no_diff: t.Optional[bool] = None, 1364 run: t.Optional[bool] = None, 1365 diff_rendered: t.Optional[bool] = None, 1366 skip_linter: t.Optional[bool] = None, 1367 explain: t.Optional[bool] = None, 1368 ignore_cron: t.Optional[bool] = None, 1369 min_intervals: t.Optional[int] = None, 1370 ) -> Plan: 1371 """Interactively creates a plan. 1372 1373 This method compares the current context with the target environment. It then presents 1374 the differences and asks whether to backfill each modified model. 1375 1376 Args: 1377 environment: The environment to diff and plan against. 1378 start: The start date of the backfill if there is one. 1379 end: The end date of the backfill if there is one. 1380 execution_time: The date/time reference to use for execution time. Defaults to now. 1381 create_from: The environment to create the target environment from if it 1382 doesn't exist. If not specified, the "prod" environment will be used. 1383 skip_tests: Unit tests are run by default so this will skip them if enabled 1384 restate_models: A list of either internal or external models, or tags, that need to be restated 1385 for the given plan interval. If the target environment is a production environment, 1386 ALL snapshots that depended on these upstream tables will have their intervals deleted 1387 (even ones not in this current environment). Only the snapshots in this environment will 1388 be backfilled whereas others need to be recovered on a future plan application. For development 1389 environments only snapshots that are part of this plan will be affected. 1390 no_gaps: Whether to ensure that new snapshots for models that are already a 1391 part of the target environment have no data gaps when compared against previous 1392 snapshots for same models. 1393 skip_backfill: Whether to skip the backfill step. Default: False. 1394 empty_backfill: Like skip_backfill, but also records processed intervals. 1395 forward_only: Whether the purpose of the plan is to make forward only changes. 1396 allow_destructive_models: Models whose forward-only changes are allowed to be destructive. 1397 allow_additive_models: Models whose forward-only changes are allowed to be additive. 1398 no_prompts: Whether to disable interactive prompts for the backfill time range. Please note that 1399 if this flag is set to true and there are uncategorized changes the plan creation will 1400 fail. Default: False. 1401 auto_apply: Whether to automatically apply the new plan after creation. Default: False. 1402 no_auto_categorization: Indicates whether to disable automatic categorization of model 1403 changes (breaking / non-breaking). If not provided, then the corresponding configuration 1404 option determines the behavior. 1405 categorizer_config: The configuration for the categorizer. Uses the categorizer configuration defined in the 1406 project config by default. 1407 effective_from: The effective date from which to apply forward-only changes on production. 1408 include_unmodified: Indicates whether to include unmodified models in the target development environment. 1409 select_models: A list of model selection strings to filter the models that should be included into this plan. 1410 backfill_models: A list of model selection strings to filter the models for which the data should be backfilled. 1411 enable_preview: Indicates whether to enable preview for forward-only models in development environments. 1412 no_diff: Hide text differences for changed models. 1413 run: Whether to run latest intervals as part of the plan application. 1414 diff_rendered: Whether the diff should compare raw vs rendered models 1415 skip_linter: Linter runs by default so this will skip it if enabled 1416 explain: Whether to explain the plan instead of applying it. 1417 min_intervals: Adjust the plan start date on a per-model basis in order to ensure at least this many intervals are covered 1418 on every model when checking for missing intervals 1419 1420 Returns: 1421 The populated Plan object. 1422 """ 1423 plan_builder = self.plan_builder( 1424 environment, 1425 start=start, 1426 end=end, 1427 execution_time=execution_time, 1428 create_from=create_from, 1429 skip_tests=skip_tests, 1430 restate_models=restate_models, 1431 no_gaps=no_gaps, 1432 skip_backfill=skip_backfill, 1433 empty_backfill=empty_backfill, 1434 forward_only=forward_only, 1435 allow_destructive_models=allow_destructive_models, 1436 allow_additive_models=allow_additive_models, 1437 no_auto_categorization=no_auto_categorization, 1438 effective_from=effective_from, 1439 include_unmodified=include_unmodified, 1440 select_models=select_models, 1441 backfill_models=backfill_models, 1442 categorizer_config=categorizer_config, 1443 enable_preview=enable_preview, 1444 run=run, 1445 diff_rendered=diff_rendered, 1446 skip_linter=skip_linter, 1447 explain=explain, 1448 ignore_cron=ignore_cron, 1449 min_intervals=min_intervals, 1450 ) 1451 1452 plan = plan_builder.build() 1453 1454 self._warn_if_virtual_catalog_rematerialization(plan) 1455 1456 if no_auto_categorization or plan.uncategorized: 1457 # Prompts are required if the auto categorization is disabled 1458 # or if there are any uncategorized snapshots in the plan 1459 no_prompts = False 1460 1461 if explain: 1462 auto_apply = True 1463 1464 self.console.plan( 1465 plan_builder, 1466 auto_apply if auto_apply is not None else self.config.plan.auto_apply, 1467 self.default_catalog, 1468 no_diff=no_diff if no_diff is not None else self.config.plan.no_diff, 1469 no_prompts=no_prompts if no_prompts is not None else self.config.plan.no_prompts, 1470 ) 1471 1472 return plan 1473 1474 @python_api_analytics 1475 def plan_builder( 1476 self, 1477 environment: t.Optional[str] = None, 1478 *, 1479 start: t.Optional[TimeLike] = None, 1480 end: t.Optional[TimeLike] = None, 1481 execution_time: t.Optional[TimeLike] = None, 1482 create_from: t.Optional[str] = None, 1483 skip_tests: t.Optional[bool] = None, 1484 restate_models: t.Optional[t.Iterable[str]] = None, 1485 no_gaps: t.Optional[bool] = None, 1486 skip_backfill: t.Optional[bool] = None, 1487 empty_backfill: t.Optional[bool] = None, 1488 forward_only: t.Optional[bool] = None, 1489 allow_destructive_models: t.Optional[t.Collection[str]] = None, 1490 allow_additive_models: t.Optional[t.Collection[str]] = None, 1491 no_auto_categorization: t.Optional[bool] = None, 1492 effective_from: t.Optional[TimeLike] = None, 1493 include_unmodified: t.Optional[bool] = None, 1494 select_models: t.Optional[t.Collection[str]] = None, 1495 backfill_models: t.Optional[t.Collection[str]] = None, 1496 categorizer_config: t.Optional[CategorizerConfig] = None, 1497 enable_preview: t.Optional[bool] = None, 1498 preview_start: t.Optional[TimeLike] = None, 1499 preview_min_intervals: t.Optional[int] = None, 1500 run: t.Optional[bool] = None, 1501 diff_rendered: t.Optional[bool] = None, 1502 skip_linter: t.Optional[bool] = None, 1503 explain: t.Optional[bool] = None, 1504 ignore_cron: t.Optional[bool] = None, 1505 min_intervals: t.Optional[int] = None, 1506 always_include_local_changes: t.Optional[bool] = None, 1507 ) -> PlanBuilder: 1508 """Creates a plan builder. 1509 1510 Args: 1511 environment: The environment to diff and plan against. 1512 start: The start date of the backfill if there is one. 1513 end: The end date of the backfill if there is one. 1514 execution_time: The date/time reference to use for execution time. Defaults to now. 1515 create_from: The environment to create the target environment from if it 1516 doesn't exist. If not specified, the "prod" environment will be used. 1517 skip_tests: Unit tests are run by default so this will skip them if enabled 1518 restate_models: A list of either internal or external models, or tags, that need to be restated 1519 for the given plan interval. If the target environment is a production environment, 1520 ALL snapshots that depended on these upstream tables will have their intervals deleted 1521 (even ones not in this current environment). Only the snapshots in this environment will 1522 be backfilled whereas others need to be recovered on a future plan application. For development 1523 environments only snapshots that are part of this plan will be affected. 1524 no_gaps: Whether to ensure that new snapshots for models that are already a 1525 part of the target environment have no data gaps when compared against previous 1526 snapshots for same models. 1527 skip_backfill: Whether to skip the backfill step. Default: False. 1528 empty_backfill: Like skip_backfill, but also records processed intervals. 1529 forward_only: Whether the purpose of the plan is to make forward only changes. 1530 allow_destructive_models: Models whose forward-only changes are allowed to be destructive. 1531 no_auto_categorization: Indicates whether to disable automatic categorization of model 1532 changes (breaking / non-breaking). If not provided, then the corresponding configuration 1533 option determines the behavior. 1534 categorizer_config: The configuration for the categorizer. Uses the categorizer configuration defined in the 1535 project config by default. 1536 effective_from: The effective date from which to apply forward-only changes on production. 1537 include_unmodified: Indicates whether to include unmodified models in the target development environment. 1538 select_models: A list of model selection strings to filter the models that should be included into this plan. 1539 backfill_models: A list of model selection strings to filter the models for which the data should be backfilled. 1540 enable_preview: Indicates whether to enable preview for forward-only models in development environments. 1541 preview_start: The start date for forward-only previews. 1542 preview_min_intervals: The minimum number of intervals to preview for each forward-only preview snapshot. 1543 run: Whether to run latest intervals as part of the plan application. 1544 diff_rendered: Whether the diff should compare raw vs rendered models 1545 min_intervals: Adjust the plan start date on a per-model basis in order to ensure at least this many intervals are covered 1546 on every model when checking for missing intervals 1547 always_include_local_changes: Usually when restatements are present, local changes in the filesystem are ignored. 1548 However, it can be desirable to deploy changes + restatements in the same plan, so this flag overrides the default behaviour. 1549 1550 Returns: 1551 The plan builder. 1552 """ 1553 kwargs: t.Dict[str, t.Optional[UserProvidedFlags]] = { 1554 "start": start, 1555 "end": end, 1556 "execution_time": execution_time, 1557 "create_from": create_from, 1558 "skip_tests": skip_tests, 1559 "restate_models": list(restate_models) if restate_models is not None else None, 1560 "no_gaps": no_gaps, 1561 "skip_backfill": skip_backfill, 1562 "empty_backfill": empty_backfill, 1563 "forward_only": forward_only, 1564 "allow_destructive_models": list(allow_destructive_models) 1565 if allow_destructive_models is not None 1566 else None, 1567 "allow_additive_models": list(allow_additive_models) 1568 if allow_additive_models is not None 1569 else None, 1570 "no_auto_categorization": no_auto_categorization, 1571 "effective_from": effective_from, 1572 "include_unmodified": include_unmodified, 1573 "select_models": list(select_models) if select_models is not None else None, 1574 "backfill_models": list(backfill_models) if backfill_models is not None else None, 1575 "enable_preview": enable_preview, 1576 "preview_start": preview_start, 1577 "preview_min_intervals": preview_min_intervals, 1578 "run": run, 1579 "diff_rendered": diff_rendered, 1580 "skip_linter": skip_linter, 1581 "min_intervals": min_intervals, 1582 } 1583 user_provided_flags: t.Dict[str, UserProvidedFlags] = { 1584 k: v for k, v in kwargs.items() if v is not None 1585 } 1586 1587 skip_tests = explain or skip_tests or False 1588 no_gaps = no_gaps or False 1589 skip_backfill = skip_backfill or False 1590 empty_backfill = empty_backfill or False 1591 run = run or False 1592 diff_rendered = diff_rendered or False 1593 skip_linter = skip_linter or False 1594 min_intervals = min_intervals or 0 1595 1596 environment = environment or self.config.default_target_environment 1597 environment = Environment.sanitize_name(environment) 1598 is_dev = environment != c.PROD 1599 1600 if include_unmodified is None: 1601 include_unmodified = self.config.plan.include_unmodified 1602 1603 if skip_backfill and not no_gaps and not is_dev: 1604 # note: we deliberately don't mention the --no-gaps flag in case the plan came from the sqlmesh_dbt command 1605 # todo: perhaps we could have better error messages if we check sys.argv[0] for which cli is running? 1606 self.console.log_warning( 1607 "Skipping the backfill stage for production can lead to unexpected results, such as tables being empty or incremental data with non-contiguous time ranges being made available.\n" 1608 "If you are doing this deliberately to create an empty version of a table to test a change, please consider using Virtual Data Environments instead." 1609 ) 1610 1611 if not skip_linter: 1612 self.lint_models() 1613 1614 self._run_plan_tests(skip_tests=skip_tests) 1615 1616 environment_ttl = ( 1617 self.environment_ttl if environment not in self.pinned_environments else None 1618 ) 1619 1620 model_selector = self._new_selector() 1621 1622 if allow_destructive_models: 1623 expanded_destructive_models = model_selector.expand_model_selections( 1624 allow_destructive_models 1625 ) 1626 else: 1627 expanded_destructive_models = None 1628 1629 if allow_additive_models: 1630 expanded_additive_models = model_selector.expand_model_selections(allow_additive_models) 1631 else: 1632 expanded_additive_models = None 1633 1634 if backfill_models: 1635 backfill_models = model_selector.expand_model_selections(backfill_models) 1636 else: 1637 backfill_models = None 1638 1639 models_override: t.Optional[UniqueKeyDict[str, Model]] = None 1640 selected_fqns: t.Set[str] = set() 1641 selected_deletion_fqns: t.Set[str] = set() 1642 if select_models: 1643 try: 1644 models_override, selected_fqns = model_selector.select_models( 1645 select_models, 1646 environment, 1647 fallback_env_name=create_from or c.PROD, 1648 ensure_finalized_snapshots=self.config.plan.use_finalized_state, 1649 ) 1650 except SQLMeshError as e: 1651 logger.exception(e) # ensure the full stack trace is logged 1652 raise PlanError( 1653 f"{e}\nCheck the SQLMesh log file for the full stack trace.\nIf the model has been fixed locally, please ensure that the --select-model expression includes it." 1654 ) 1655 if not backfill_models: 1656 # Only backfill selected models unless explicitly specified. 1657 backfill_models = model_selector.expand_model_selections(select_models) 1658 1659 if not backfill_models: 1660 # The selection matched nothing locally. Check whether it matched models 1661 # in the deployed environment that were deleted locally. 1662 selected_deletion_fqns = selected_fqns - set(self._models) 1663 1664 expanded_restate_models = None 1665 if restate_models is not None: 1666 expanded_restate_models = model_selector.expand_model_selections(restate_models) 1667 1668 if (restate_models is not None and not expanded_restate_models) or ( 1669 backfill_models is not None and not backfill_models and not selected_deletion_fqns 1670 ): 1671 raise PlanError( 1672 "Selector did not return any models. Please check your model selection and try again." 1673 ) 1674 1675 if always_include_local_changes is None: 1676 # default behaviour - if restatements are detected; we operate entirely out of state and ignore local changes 1677 force_no_diff = restate_models is not None or ( 1678 backfill_models is not None and not backfill_models and not selected_deletion_fqns 1679 ) 1680 else: 1681 force_no_diff = not always_include_local_changes 1682 1683 snapshots = self._snapshots(models_override) 1684 context_diff = self._context_diff( 1685 environment or c.PROD, 1686 snapshots=snapshots, 1687 create_from=create_from, 1688 force_no_diff=force_no_diff, 1689 ensure_finalized_snapshots=self.config.plan.use_finalized_state, 1690 diff_rendered=diff_rendered, 1691 always_recreate_environment=self.config.plan.always_recreate_environment, 1692 ) 1693 modified_model_names = { 1694 *context_diff.modified_snapshots, 1695 *[s.name for s in context_diff.added], 1696 } 1697 1698 if ( 1699 is_dev 1700 and not include_unmodified 1701 and backfill_models is None 1702 and expanded_restate_models is None 1703 ): 1704 # Only backfill modified and added models. 1705 # This ensures that no models outside the impacted sub-DAG(s) will be backfilled unexpectedly. 1706 backfill_models = modified_model_names or None 1707 1708 max_interval_end_per_model = None 1709 default_start, default_end = None, None 1710 if not run: 1711 ignore_cron = False 1712 max_interval_end_per_model = self._get_max_interval_end_per_model( 1713 snapshots, backfill_models 1714 ) 1715 # If no end date is specified, use the max interval end from prod 1716 # to prevent unintended evaluation of the entire DAG. 1717 default_start, default_end = self._get_plan_default_start_end( 1718 snapshots, 1719 max_interval_end_per_model, 1720 backfill_models, 1721 modified_model_names, 1722 execution_time or now(), 1723 ) 1724 1725 # Refresh snapshot intervals to ensure that they are up to date with values reflected in the max_interval_end_per_model. 1726 self.state_sync.refresh_snapshot_intervals(context_diff.snapshots.values()) 1727 1728 start_override_per_model = self._calculate_start_override_per_model( 1729 min_intervals, 1730 start or default_start, 1731 end or default_end, 1732 execution_time or now(), 1733 backfill_models, 1734 snapshots, 1735 max_interval_end_per_model, 1736 ) 1737 1738 if not self.config.virtual_environment_mode.is_full: 1739 forward_only = True 1740 elif forward_only is None: 1741 forward_only = self.config.plan.forward_only 1742 1743 # When handling prod restatements, only clear intervals from other model versions if we are using full virtual environments 1744 # If we are not, then there is no point, because none of the data in dev environments can be promoted by definition 1745 restate_all_snapshots = ( 1746 expanded_restate_models is not None 1747 and not is_dev 1748 and self.config.virtual_environment_mode.is_full 1749 ) 1750 1751 return self.PLAN_BUILDER_TYPE( 1752 context_diff=context_diff, 1753 start=start, 1754 end=end, 1755 execution_time=execution_time, 1756 apply=self.apply, 1757 restate_models=expanded_restate_models, 1758 restate_all_snapshots=restate_all_snapshots, 1759 backfill_models=backfill_models, 1760 no_gaps=no_gaps, 1761 skip_backfill=skip_backfill, 1762 empty_backfill=empty_backfill, 1763 is_dev=is_dev, 1764 forward_only=forward_only, 1765 allow_destructive_models=expanded_destructive_models, 1766 allow_additive_models=expanded_additive_models, 1767 environment_ttl=environment_ttl, 1768 environment_suffix_target=self.config.environment_suffix_target, 1769 environment_catalog_mapping=self.environment_catalog_mapping, 1770 categorizer_config=categorizer_config or self.auto_categorize_changes, 1771 auto_categorization_enabled=not no_auto_categorization, 1772 effective_from=effective_from, 1773 include_unmodified=include_unmodified, 1774 default_start=default_start, 1775 default_end=default_end, 1776 enable_preview=( 1777 enable_preview if enable_preview is not None else self._plan_preview_enabled 1778 ), 1779 preview_start=preview_start, 1780 preview_min_intervals=preview_min_intervals or 0, 1781 end_bounded=not run, 1782 ensure_finalized_snapshots=self.config.plan.use_finalized_state, 1783 start_override_per_model=start_override_per_model, 1784 end_override_per_model=max_interval_end_per_model, 1785 console=self.console, 1786 user_provided_flags=user_provided_flags, 1787 selected_models={ 1788 dbt_unique_id 1789 for model in model_selector.expand_model_selections(select_models or "*") 1790 if (dbt_unique_id := snapshots[model].node.dbt_unique_id) 1791 }, 1792 explain=explain or False, 1793 ignore_cron=ignore_cron or False, 1794 ) 1795 1796 def apply( 1797 self, 1798 plan: Plan, 1799 circuit_breaker: t.Optional[t.Callable[[], bool]] = None, 1800 ) -> None: 1801 """Applies a plan by pushing snapshots and backfilling data. 1802 1803 Given a plan, it pushes snapshots into the state sync and then uses the scheduler 1804 to backfill all models. 1805 1806 Args: 1807 plan: The plan to apply. 1808 circuit_breaker: An optional handler which checks if the apply should be aborted. 1809 """ 1810 if ( 1811 not plan.context_diff.has_changes 1812 and not plan.requires_backfill 1813 and not plan.has_unmodified_unpromoted 1814 ): 1815 return 1816 if plan.uncategorized: 1817 raise UncategorizedPlanError("Can't apply a plan with uncategorized changes.") 1818 1819 if plan.explain: 1820 explainer = PlanExplainer( 1821 state_reader=self.state_reader, 1822 default_catalog=self.default_catalog, 1823 console=self.console, 1824 ) 1825 explainer.evaluate(plan.to_evaluatable()) 1826 return 1827 1828 self.notification_target_manager.notify( 1829 NotificationEvent.APPLY_START, 1830 environment=plan.environment_naming_info.name, 1831 plan_id=plan.plan_id, 1832 ) 1833 try: 1834 self._apply(plan, circuit_breaker) 1835 except Exception as e: 1836 self.notification_target_manager.notify( 1837 NotificationEvent.APPLY_FAILURE, 1838 environment=plan.environment_naming_info.name, 1839 plan_id=plan.plan_id, 1840 exc=traceback.format_exc(), 1841 ) 1842 logger.info("Plan application failed.", exc_info=e) 1843 raise e 1844 self.notification_target_manager.notify( 1845 NotificationEvent.APPLY_END, 1846 environment=plan.environment_naming_info.name, 1847 plan_id=plan.plan_id, 1848 ) 1849 1850 @python_api_analytics 1851 def invalidate_environment(self, name: str, sync: bool = False) -> None: 1852 """Invalidates the target environment by setting its expiration timestamp to now. 1853 1854 Args: 1855 name: The name of the environment to invalidate. 1856 sync: If True, the call blocks until the environment is deleted. Otherwise, the environment will 1857 be deleted asynchronously by the janitor process. 1858 """ 1859 name = Environment.sanitize_name(name) 1860 self.state_sync.invalidate_environment(name) 1861 if sync: 1862 self._cleanup_environments(name=name) 1863 self.console.log_success(f"Environment '{name}' deleted.") 1864 else: 1865 self.console.log_success(f"Environment '{name}' invalidated.") 1866 1867 @python_api_analytics 1868 def diff(self, environment: t.Optional[str] = None, detailed: bool = False) -> bool: 1869 """Show a diff of the current context with a given environment. 1870 1871 Args: 1872 environment: The environment to diff against. 1873 detailed: Show the actual SQL differences if True. 1874 1875 Returns: 1876 True if there are changes, False otherwise. 1877 """ 1878 environment = environment or self.config.default_target_environment 1879 environment = Environment.sanitize_name(environment) 1880 context_diff = self._context_diff(environment) 1881 self.console.show_environment_difference_summary( 1882 context_diff, 1883 no_diff=not detailed, 1884 ) 1885 if context_diff.has_changes: 1886 self.console.show_model_difference_summary( 1887 context_diff, 1888 EnvironmentNamingInfo.from_environment_catalog_mapping( 1889 self.environment_catalog_mapping, 1890 name=environment, 1891 suffix_target=self.config.environment_suffix_target, 1892 normalize_name=context_diff.normalize_environment_name, 1893 ), 1894 self.default_catalog, 1895 no_diff=not detailed, 1896 ) 1897 return context_diff.has_changes 1898 1899 @python_api_analytics 1900 def table_diff( 1901 self, 1902 source: str, 1903 target: str, 1904 on: t.Optional[t.List[str] | exp.Expr] = None, 1905 skip_columns: t.Optional[t.List[str]] = None, 1906 select_models: t.Optional[t.Collection[str]] = None, 1907 where: t.Optional[str | exp.Expr] = None, 1908 limit: int = 20, 1909 show: bool = True, 1910 show_sample: bool = True, 1911 decimals: int = 3, 1912 skip_grain_check: bool = False, 1913 warn_grain_check: bool = False, 1914 temp_schema: t.Optional[str] = None, 1915 schema_diff_ignore_case: bool = False, 1916 **kwargs: t.Any, # catch-all to prevent an 'unexpected keyword argument' error if an table_diff extension passes in some extra arguments 1917 ) -> t.List[TableDiff]: 1918 """Show a diff between two tables. 1919 1920 Args: 1921 source: The source environment or table. 1922 target: The target environment or table. 1923 on: The join condition, table aliases must be "s" and "t" for source and target. 1924 If omitted, the table's grain will be used. 1925 skip_columns: The columns to skip when computing the table diff. 1926 select_models: The models or snapshots to use when environments are passed in. 1927 where: An optional where statement to filter results. 1928 limit: The limit of the sample dataframe. 1929 show: Show the table diff output in the console. 1930 show_sample: Show the sample dataframe in the console. Requires show=True. 1931 decimals: The number of decimal places to keep when comparing floating point columns. 1932 skip_grain_check: Skip check for rows that contain null or duplicate grains. 1933 temp_schema: The schema to use for temporary tables. 1934 1935 Returns: 1936 The list of TableDiff objects containing schema and summary differences. 1937 """ 1938 1939 if "|" in source or "|" in target: 1940 raise ConfigError( 1941 "Cross-database table diffing is available in Tobiko Cloud. Read more here: " 1942 "https://sqlmesh.readthedocs.io/en/stable/guides/tablediff/#diffing-tables-or-views-across-gateways" 1943 ) 1944 1945 table_diffs: t.List[TableDiff] = [] 1946 1947 # Diffs multiple or a single model across two environments 1948 if select_models: 1949 source_env = self.state_reader.get_environment(source) 1950 target_env = self.state_reader.get_environment(target) 1951 if not source_env: 1952 raise SQLMeshError(f"Could not find environment '{source}'") 1953 if not target_env: 1954 raise SQLMeshError(f"Could not find environment '{target}'") 1955 criteria = ", ".join(f"'{c}'" for c in select_models) 1956 try: 1957 selected_models = self._new_selector().expand_model_selections(select_models) 1958 if not selected_models: 1959 self.console.log_status_update( 1960 f"No models matched the selection criteria: {criteria}" 1961 ) 1962 except Exception as e: 1963 raise SQLMeshError(e) 1964 1965 models_to_diff: t.List[ 1966 t.Tuple[Model, EngineAdapter, str, str, t.Optional[t.List[str] | exp.Expr]] 1967 ] = [] 1968 models_without_grain: t.List[Model] = [] 1969 source_snapshots_to_name = { 1970 snapshot.name: snapshot for snapshot in source_env.snapshots 1971 } 1972 target_snapshots_to_name = { 1973 snapshot.name: snapshot for snapshot in target_env.snapshots 1974 } 1975 1976 for model_fqn in selected_models: 1977 model = self._models[model_fqn] 1978 adapter = self._get_engine_adapter(model.gateway) 1979 source_snapshot = source_snapshots_to_name.get(model.fqn) 1980 target_snapshot = target_snapshots_to_name.get(model.fqn) 1981 1982 if target_snapshot and source_snapshot: 1983 if (source_snapshot.fingerprint != target_snapshot.fingerprint) and ( 1984 (source_snapshot.version != target_snapshot.version) 1985 or source_snapshot.is_forward_only 1986 ): 1987 # Compare the virtual layer instead of the physical layer because the virtual layer is guaranteed to point 1988 # to the correct/active snapshot for the model in the specified environment, taking into account things like dev previews 1989 source = source_snapshot.qualified_view_name.for_environment( 1990 source_env.naming_info, adapter.dialect 1991 ) 1992 target = target_snapshot.qualified_view_name.for_environment( 1993 target_env.naming_info, adapter.dialect 1994 ) 1995 model_on = on or model.on 1996 if not model_on: 1997 models_without_grain.append(model) 1998 else: 1999 models_to_diff.append((model, adapter, source, target, model_on)) 2000 2001 if models_without_grain: 2002 model_names = "\n".join( 2003 f"─ {model.name} \n at '{model._path}'" for model in models_without_grain 2004 ) 2005 message = ( 2006 "SQLMesh doesn't know how to join the tables for the following models:\n" 2007 f"{model_names}\n\n" 2008 "Please specify a `grain` in each model definition. It must be unique and not null." 2009 ) 2010 if warn_grain_check: 2011 self.console.log_warning(message) 2012 else: 2013 raise SQLMeshError(message) 2014 2015 if models_to_diff: 2016 self.console.show_table_diff_details( 2017 [model[0].name for model in models_to_diff], 2018 ) 2019 2020 self.console.start_table_diff_progress(len(models_to_diff)) 2021 try: 2022 tasks_num = min(len(models_to_diff), self.concurrent_tasks) 2023 table_diffs = concurrent_apply_to_values( 2024 list(models_to_diff), 2025 lambda model_info: self._model_diff( 2026 model=model_info[0], 2027 adapter=model_info[1], 2028 source=model_info[2], 2029 target=model_info[3], 2030 on=model_info[4], 2031 source_alias=source_env.name, 2032 target_alias=target_env.name, 2033 limit=limit, 2034 decimals=decimals, 2035 skip_columns=skip_columns, 2036 where=where, 2037 show=show, 2038 temp_schema=temp_schema, 2039 skip_grain_check=skip_grain_check, 2040 schema_diff_ignore_case=schema_diff_ignore_case, 2041 ), 2042 tasks_num=tasks_num, 2043 ) 2044 self.console.stop_table_diff_progress(success=True) 2045 except: 2046 self.console.stop_table_diff_progress(success=False) 2047 raise 2048 elif selected_models: 2049 self.console.log_status_update( 2050 f"No models contain differences with the selection criteria: {criteria}" 2051 ) 2052 2053 else: 2054 table_diffs = [ 2055 self._table_diff( 2056 source=source, 2057 target=target, 2058 source_alias=source, 2059 target_alias=target, 2060 limit=limit, 2061 decimals=decimals, 2062 adapter=self.engine_adapter, 2063 on=on, 2064 skip_columns=skip_columns, 2065 where=where, 2066 schema_diff_ignore_case=schema_diff_ignore_case, 2067 ) 2068 ] 2069 2070 if show: 2071 self.console.show_table_diff(table_diffs, show_sample, skip_grain_check, temp_schema) 2072 2073 return table_diffs 2074 2075 def _model_diff( 2076 self, 2077 model: Model, 2078 adapter: EngineAdapter, 2079 source: str, 2080 target: str, 2081 source_alias: str, 2082 target_alias: str, 2083 limit: int, 2084 decimals: int, 2085 on: t.Optional[t.List[str] | exp.Expr] = None, 2086 skip_columns: t.Optional[t.List[str]] = None, 2087 where: t.Optional[str | exp.Expr] = None, 2088 show: bool = True, 2089 temp_schema: t.Optional[str] = None, 2090 skip_grain_check: bool = False, 2091 schema_diff_ignore_case: bool = False, 2092 ) -> TableDiff: 2093 self.console.start_table_diff_model_progress(model.name) 2094 2095 table_diff = self._table_diff( 2096 on=on, 2097 skip_columns=skip_columns, 2098 where=where, 2099 limit=limit, 2100 decimals=decimals, 2101 model=model, 2102 adapter=adapter, 2103 source=source, 2104 target=target, 2105 source_alias=source_alias, 2106 target_alias=target_alias, 2107 schema_diff_ignore_case=schema_diff_ignore_case, 2108 ) 2109 2110 if show: 2111 # Trigger row_diff in parallel execution so it's available for ordered display later 2112 table_diff.row_diff(temp_schema=temp_schema, skip_grain_check=skip_grain_check) 2113 2114 self.console.update_table_diff_progress(model.name) 2115 2116 return table_diff 2117 2118 def _table_diff( 2119 self, 2120 source: str, 2121 target: str, 2122 source_alias: str, 2123 target_alias: str, 2124 limit: int, 2125 decimals: int, 2126 adapter: EngineAdapter, 2127 on: t.Optional[t.List[str] | exp.Expr] = None, 2128 model: t.Optional[Model] = None, 2129 skip_columns: t.Optional[t.List[str]] = None, 2130 where: t.Optional[str | exp.Expr] = None, 2131 schema_diff_ignore_case: bool = False, 2132 ) -> TableDiff: 2133 if not on: 2134 raise SQLMeshError( 2135 "SQLMesh doesn't know how to join the two tables. Specify the `grains` in each model definition or pass join column names in separate `-o` flags." 2136 ) 2137 2138 return TableDiff( 2139 adapter=adapter.with_settings(execute_log_level=logger.getEffectiveLevel()), 2140 source=source, 2141 target=target, 2142 on=on, 2143 skip_columns=skip_columns, 2144 where=where, 2145 source_alias=source_alias, 2146 target_alias=target_alias, 2147 limit=limit, 2148 decimals=decimals, 2149 model_name=model.name if model else None, 2150 model_dialect=model.dialect if model else None, 2151 schema_diff_ignore_case=schema_diff_ignore_case, 2152 ) 2153 2154 @python_api_analytics 2155 def get_dag( 2156 self, select_models: t.Optional[t.Collection[str]] = None, **options: t.Any 2157 ) -> GraphHTML: 2158 """Gets an HTML object representation of the DAG. 2159 2160 Args: 2161 select_models: A list of model selection strings that should be included in the dag. 2162 Returns: 2163 An html object that renders the dag. 2164 """ 2165 dag = ( 2166 self.dag.prune(*self._new_selector().expand_model_selections(select_models)) 2167 if select_models 2168 else self.dag 2169 ) 2170 2171 nodes = {} 2172 edges: t.List[t.Dict] = [] 2173 2174 for node, deps in dag.graph.items(): 2175 nodes[node] = { 2176 "id": node, 2177 "label": node.split(".")[-1], 2178 "title": f"<span>{node}</span>", 2179 } 2180 edges.extend({"from": d, "to": node} for d in deps) 2181 2182 return GraphHTML( 2183 nodes, 2184 edges, 2185 options={ 2186 "height": "100%", 2187 "width": "100%", 2188 "interaction": {}, 2189 "layout": { 2190 "hierarchical": { 2191 "enabled": True, 2192 "nodeSpacing": 200, 2193 "sortMethod": "directed", 2194 }, 2195 }, 2196 "nodes": { 2197 "shape": "box", 2198 }, 2199 **options, 2200 }, 2201 ) 2202 2203 @python_api_analytics 2204 def render_dag(self, path: str, select_models: t.Optional[t.Collection[str]] = None) -> None: 2205 """Render the dag as HTML and save it to a file. 2206 2207 Args: 2208 path: filename to save the dag html to 2209 select_models: A list of model selection strings that should be included in the dag. 2210 """ 2211 file_path = Path(path) 2212 suffix = file_path.suffix 2213 if suffix != ".html": 2214 if suffix: 2215 get_console().log_warning( 2216 f"The extension {suffix} does not designate an html file. A file with a `.html` extension will be created instead." 2217 ) 2218 path = str(file_path.with_suffix(".html")) 2219 2220 with open(path, "w", encoding="utf-8") as file: 2221 file.write(str(self.get_dag(select_models))) 2222 2223 @python_api_analytics 2224 def create_test( 2225 self, 2226 model: str, 2227 input_queries: t.Dict[str, str], 2228 overwrite: bool = False, 2229 variables: t.Optional[t.Dict[str, str]] = None, 2230 path: t.Optional[str] = None, 2231 name: t.Optional[str] = None, 2232 include_ctes: bool = False, 2233 ) -> None: 2234 """Generate a unit test fixture for a given model. 2235 2236 Args: 2237 model: The model to test. 2238 input_queries: Mapping of model names to queries. Each model included in this mapping 2239 will be populated in the test based on the results of the corresponding query. 2240 overwrite: Whether to overwrite the existing test in case of a file path collision. 2241 When set to False, an error will be raised if there is such a collision. 2242 variables: Key-value pairs that will define variables needed by the model. 2243 path: The file path corresponding to the fixture, relative to the test directory. 2244 By default, the fixture will be created under the test directory and the file name 2245 will be inferred from the test's name. 2246 name: The name of the test. This is inferred from the model name by default. 2247 include_ctes: When true, CTE fixtures will also be generated. 2248 """ 2249 input_queries = { 2250 # The get_model here has two purposes: return normalized names & check for missing deps 2251 self.get_model(dep, raise_if_missing=True).fqn: query 2252 for dep, query in input_queries.items() 2253 } 2254 2255 try: 2256 model_to_test = self.get_model(model, raise_if_missing=True) 2257 test_adapter = self.test_connection_config.create_engine_adapter( 2258 register_comments_override=False 2259 ) 2260 2261 generate_test( 2262 model=model_to_test, 2263 input_queries=input_queries, 2264 models=self._models, 2265 engine_adapter=self._get_engine_adapter(model_to_test.gateway), 2266 test_engine_adapter=test_adapter, 2267 project_path=self.path, 2268 overwrite=overwrite, 2269 variables=variables, 2270 path=path, 2271 name=name, 2272 include_ctes=include_ctes, 2273 ) 2274 finally: 2275 if test_adapter: 2276 test_adapter.close() 2277 2278 @python_api_analytics 2279 def test( 2280 self, 2281 match_patterns: t.Optional[t.List[str]] = None, 2282 tests: t.Optional[t.List[str]] = None, 2283 verbosity: Verbosity = Verbosity.DEFAULT, 2284 preserve_fixtures: bool = False, 2285 stream: t.Optional[t.TextIO] = None, 2286 ) -> ModelTextTestResult: 2287 """Discover and run model tests""" 2288 if verbosity >= Verbosity.VERBOSE: 2289 import pandas as pd 2290 2291 pd.set_option("display.max_columns", None) 2292 2293 test_meta = self.select_tests(tests=tests, patterns=match_patterns) 2294 2295 result = run_tests( 2296 model_test_metadata=test_meta, 2297 models=self._models, 2298 config=self.config, 2299 selected_gateway=self.selected_gateway, 2300 dialect=self.default_dialect, 2301 verbosity=verbosity, 2302 preserve_fixtures=preserve_fixtures, 2303 stream=stream, 2304 default_catalog=self.default_catalog, 2305 default_catalog_dialect=self.config.dialect or "", 2306 ) 2307 2308 self.console.log_test_results( 2309 result, 2310 self.test_connection_config._engine_adapter.DIALECT, 2311 ) 2312 2313 return result 2314 2315 @python_api_analytics 2316 def audit( 2317 self, 2318 start: TimeLike, 2319 end: TimeLike, 2320 *, 2321 models: t.Optional[t.Iterator[str]] = None, 2322 execution_time: t.Optional[TimeLike] = None, 2323 ) -> bool: 2324 """Audit models. 2325 2326 Args: 2327 start: The start of the interval to audit. 2328 end: The end of the interval to audit. 2329 models: The models to audit. All models will be audited if not specified. 2330 execution_time: The date/time time reference to use for execution time. Defaults to now. 2331 2332 Returns: 2333 False if any of the audits failed, True otherwise. 2334 """ 2335 2336 snapshots = ( 2337 [self.get_snapshot(model, raise_if_missing=True) for model in models] 2338 if models 2339 else self.snapshots.values() 2340 ) 2341 2342 num_audits = sum(len(snapshot.node.audits_with_args) for snapshot in snapshots) 2343 self.console.log_status_update(f"Found {num_audits} audit(s).") 2344 2345 errors = [] 2346 skipped_count = 0 2347 for snapshot in snapshots: 2348 for audit_result in self.snapshot_evaluator.audit( 2349 snapshot=snapshot, 2350 start=start, 2351 end=end, 2352 execution_time=execution_time, 2353 snapshots=self.snapshots, 2354 ): 2355 audit_id = f"{audit_result.audit.name}" 2356 if audit_result.model: 2357 audit_id += f" on model {audit_result.model.name}" 2358 2359 if audit_result.skipped: 2360 self.console.log_status_update(f"{audit_id} ⏸️ SKIPPED.") 2361 skipped_count += 1 2362 elif audit_result.count: 2363 errors.append(audit_result) 2364 self.console.log_status_update( 2365 f"{audit_id} ❌ [red]FAIL [{audit_result.count}][/red]." 2366 ) 2367 else: 2368 self.console.log_status_update(f"{audit_id} ✅ [green]PASS[/green].") 2369 2370 self.console.log_status_update( 2371 f"\nFinished with {len(errors)} audit error{'' if len(errors) == 1 else 's'} " 2372 f"and {skipped_count} audit{'' if skipped_count == 1 else 's'} skipped." 2373 ) 2374 for error in errors: 2375 self.console.log_status_update( 2376 f"\nFailure in audit {error.audit.name} ({error.audit._path})." 2377 ) 2378 self.console.log_status_update(f"Got {error.count} results, expected 0.") 2379 if error.query: 2380 self.console.show_sql( 2381 f"{error.query.sql(dialect=self.snapshot_evaluator.adapter.dialect)}" 2382 ) 2383 2384 self.console.log_status_update("Done.") 2385 return not errors 2386 2387 @python_api_analytics 2388 def rewrite(self, sql: str, dialect: str = "") -> exp.Expr: 2389 """Rewrite a sql expression with semantic references into an executable query. 2390 2391 https://sqlmesh.readthedocs.io/en/latest/concepts/metrics/overview/ 2392 2393 Args: 2394 sql: The sql string to rewrite. 2395 dialect: The dialect of the sql string, defaults to the project dialect. 2396 2397 Returns: 2398 A SQLGlot expression with semantic references expanded. 2399 """ 2400 return rewrite( 2401 sql, 2402 graph=ReferenceGraph(self.models.values()), 2403 metrics=self._metrics, 2404 dialect=dialect or self.default_dialect, 2405 ) 2406 2407 @python_api_analytics 2408 def check_intervals( 2409 self, 2410 environment: t.Optional[str], 2411 no_signals: bool, 2412 select_models: t.Collection[str], 2413 start: t.Optional[TimeLike] = None, 2414 end: t.Optional[TimeLike] = None, 2415 ) -> t.Dict[Snapshot, SnapshotIntervals]: 2416 """Check intervals for a given environment. 2417 2418 Args: 2419 environment: The environment or prod if None. 2420 select_models: A list of model selection strings to show intervals for. 2421 start: The start of the intervals to check. 2422 end: The end of the intervals to check. 2423 """ 2424 2425 environment = environment or c.PROD 2426 env = self.state_reader.get_environment(environment) 2427 if not env: 2428 raise SQLMeshError(f"Environment '{environment}' was not found.") 2429 2430 snapshots = {k.name: v for k, v in self.state_sync.get_snapshots(env.snapshots).items()} 2431 2432 missing = { 2433 k.name: v 2434 for k, v in missing_intervals( 2435 snapshots.values(), start=start, end=end, execution_time=end 2436 ).items() 2437 } 2438 2439 if select_models: 2440 selected: t.Collection[str] = self._select_models_for_run( 2441 select_models, True, snapshots.values() 2442 ) 2443 else: 2444 selected = snapshots.keys() 2445 2446 results = {} 2447 execution_context = self.execution_context(snapshots=snapshots) 2448 2449 for fqn in selected: 2450 snapshot = snapshots[fqn] 2451 intervals = missing.get(fqn) or [] 2452 2453 results[snapshot] = SnapshotIntervals( 2454 snapshot.snapshot_id, 2455 intervals 2456 if no_signals 2457 else snapshot.check_ready_intervals(intervals, execution_context), 2458 ) 2459 2460 return results 2461 2462 @python_api_analytics 2463 def migrate(self) -> None: 2464 """Migrates SQLMesh to the current running version. 2465 2466 Please contact your SQLMesh administrator before doing this. 2467 """ 2468 self.notification_target_manager.notify(NotificationEvent.MIGRATION_START) 2469 self._load_materializations() 2470 try: 2471 self._new_state_sync().migrate( 2472 promoted_snapshots_only=self.config.migration.promoted_snapshots_only, 2473 ) 2474 except Exception as e: 2475 self.notification_target_manager.notify( 2476 NotificationEvent.MIGRATION_FAILURE, traceback.format_exc() 2477 ) 2478 raise e 2479 self.notification_target_manager.notify(NotificationEvent.MIGRATION_END) 2480 2481 @python_api_analytics 2482 def rollback(self) -> None: 2483 """Rolls back SQLMesh to the previous migration. 2484 2485 Please contact your SQLMesh administrator before doing this. This action cannot be undone. 2486 """ 2487 self._new_state_sync().rollback() 2488 2489 @python_api_analytics 2490 def create_external_models(self, strict: bool = False) -> None: 2491 """Create a file to document the schema of external models. 2492 2493 The external models file contains all columns and types of external models, allowing for more 2494 robust lineage, validation, and optimizations. 2495 2496 Args: 2497 strict: If True, raise an error if the external model is missing in the database. 2498 """ 2499 if not self._models: 2500 self.load(update_schemas=False) 2501 2502 for path, config in self.configs.items(): 2503 deprecated_yaml = path / c.EXTERNAL_MODELS_DEPRECATED_YAML 2504 2505 external_models_yaml = ( 2506 path / c.EXTERNAL_MODELS_YAML if not deprecated_yaml.exists() else deprecated_yaml 2507 ) 2508 2509 external_models_gateway: t.Optional[str] = self.gateway or self.config.default_gateway 2510 if not external_models_gateway: 2511 # can happen if there was no --gateway defined and the default_gateway is '' 2512 # which means that the single gateway syntax is being used which means there is 2513 # no named gateway which means we should not stamp `gateway:` on the external models 2514 external_models_gateway = None 2515 2516 create_external_models_file( 2517 path=external_models_yaml, 2518 models=UniqueKeyDict( 2519 "models", 2520 { 2521 fqn: model 2522 for fqn, model in self._models.items() 2523 if self.config_for_node(model) is config 2524 }, 2525 ), 2526 adapter=self.engine_adapter, 2527 state_reader=self.state_reader, 2528 dialect=config.model_defaults.dialect, 2529 gateway=external_models_gateway, 2530 max_workers=self.concurrent_tasks, 2531 strict=strict, 2532 all_models=self._models, 2533 ) 2534 2535 @python_api_analytics 2536 def print_info( 2537 self, skip_connection: bool = False, verbosity: Verbosity = Verbosity.DEFAULT 2538 ) -> None: 2539 """Prints information about connections, models, macros, etc. to the console.""" 2540 self.console.log_status_update(f"Models: {len(self.models)}") 2541 self.console.log_status_update(f"Macros: {len(self._macros) - len(macro.get_registry())}") 2542 2543 if skip_connection: 2544 return 2545 2546 if verbosity >= Verbosity.VERBOSE: 2547 self.console.log_status_update("") 2548 print_config(self.config.get_connection(self.gateway), self.console, "Connection") 2549 print_config( 2550 self.config.get_test_connection(self.gateway), self.console, "Test Connection" 2551 ) 2552 print_config( 2553 self.config.get_state_connection(self.gateway), self.console, "State Connection" 2554 ) 2555 2556 self._try_connection("data warehouse", self.engine_adapter.ping) 2557 state_connection = self.config.get_state_connection(self.gateway) 2558 if state_connection: 2559 self._try_connection("state backend", state_connection.connection_validator()) 2560 2561 @python_api_analytics 2562 def print_environment_names(self) -> None: 2563 """Prints all environment names along with expiry datetime.""" 2564 result = self._new_state_sync().get_environments_summary() 2565 if not result: 2566 raise SQLMeshError( 2567 "This project has no environments. Create an environment using the `sqlmesh plan` command." 2568 ) 2569 self.console.print_environments(result) 2570 2571 def close(self) -> None: 2572 """Releases all resources allocated by this context.""" 2573 if self._snapshot_evaluator: 2574 self._snapshot_evaluator.close() 2575 2576 if self._state_sync: 2577 self._state_sync.close() 2578 2579 def _run( 2580 self, 2581 environment: str, 2582 *, 2583 start: t.Optional[TimeLike], 2584 end: t.Optional[TimeLike], 2585 execution_time: t.Optional[TimeLike], 2586 ignore_cron: bool, 2587 select_models: t.Optional[t.Collection[str]], 2588 circuit_breaker: t.Optional[t.Callable[[], bool]], 2589 no_auto_upstream: bool, 2590 ) -> CompletionStatus: 2591 scheduler = self.scheduler(environment=environment) 2592 snapshots = scheduler.snapshots 2593 2594 if select_models is not None: 2595 select_models = self._select_models_for_run( 2596 select_models, no_auto_upstream, snapshots.values() 2597 ) 2598 2599 completion_status = scheduler.run( 2600 environment, 2601 start=start, 2602 end=end, 2603 execution_time=execution_time, 2604 ignore_cron=ignore_cron, 2605 circuit_breaker=circuit_breaker, 2606 selected_snapshots=select_models, 2607 auto_restatement_enabled=environment.lower() == c.PROD, 2608 run_environment_statements=True, 2609 ) 2610 2611 if completion_status.is_nothing_to_do: 2612 next_run_ready_msg = "" 2613 2614 next_ready_interval_start = get_next_model_interval_start(snapshots.values()) 2615 if next_ready_interval_start: 2616 utc_time = format_tz_datetime(next_ready_interval_start) 2617 local_time = format_tz_datetime(next_ready_interval_start, use_local_timezone=True) 2618 time_msg = local_time if local_time == utc_time else f"{local_time} ({utc_time})" 2619 next_run_ready_msg = f"\n\nNext run will be ready at {time_msg}." 2620 2621 self.console.log_status_update( 2622 f"No models are ready to run. Please wait until a model `cron` interval has elapsed.{next_run_ready_msg}" 2623 ) 2624 2625 return completion_status 2626 2627 def _apply(self, plan: Plan, circuit_breaker: t.Optional[t.Callable[[], bool]]) -> None: 2628 self._scheduler.create_plan_evaluator(self).evaluate( 2629 plan.to_evaluatable(), circuit_breaker=circuit_breaker 2630 ) 2631 2632 @python_api_analytics 2633 def table_name( 2634 self, model_name: str, environment: t.Optional[str] = None, prod: bool = False 2635 ) -> str: 2636 """Returns the name of the pysical table for the given model name in the target environment. 2637 2638 Args: 2639 model_name: The name of the model. 2640 environment: The environment to source the model version from. 2641 prod: If True, return the name of the physical table that will be used in production for the model version 2642 promoted in the target environment. 2643 2644 Returns: 2645 The name of the physical table. 2646 """ 2647 environment = environment or self.config.default_target_environment 2648 fqn = self._node_or_snapshot_to_fqn(model_name) 2649 target_env = self.state_reader.get_environment(environment) 2650 if not target_env: 2651 raise SQLMeshError(f"Environment '{environment}' was not found.") 2652 2653 snapshot_info = None 2654 for s in target_env.snapshots: 2655 if s.name == fqn: 2656 snapshot_info = s 2657 break 2658 if not snapshot_info: 2659 raise SQLMeshError( 2660 f"Model '{model_name}' was not found in environment '{environment}'." 2661 ) 2662 2663 if target_env.name == c.PROD or prod: 2664 return snapshot_info.table_name() 2665 2666 snapshots = self.state_reader.get_snapshots(target_env.snapshots) 2667 deployability_index = DeployabilityIndex.create(snapshots) 2668 2669 return snapshot_info.table_name( 2670 is_deployable=deployability_index.is_deployable(snapshot_info.snapshot_id) 2671 ) 2672 2673 def clear_caches(self) -> None: 2674 paths_to_remove = [path / c.CACHE for path in self.configs] 2675 paths_to_remove.append(self.cache_dir) 2676 2677 if IS_WINDOWS: 2678 paths_to_remove = [fix_windows_path(path) for path in paths_to_remove] 2679 2680 for path in paths_to_remove: 2681 if path.exists(): 2682 rmtree(path) 2683 2684 if isinstance(self._state_sync, CachingStateSync): 2685 self._state_sync.clear_cache() 2686 2687 def export_state( 2688 self, 2689 output_file: Path, 2690 environment_names: t.Optional[t.List[str]] = None, 2691 local_only: bool = False, 2692 confirm: bool = True, 2693 ) -> None: 2694 from sqlmesh.core.state_sync.export_import import export_state 2695 2696 # trigger a connection to the StateSync so we can fail early if there is a problem 2697 # note we still need to do this even if we are doing a local export so we know what 'versions' to write 2698 self.state_sync.get_versions(validate=True) 2699 2700 local_snapshots = self.snapshots if local_only else None 2701 2702 if self.console.start_state_export( 2703 output_file=output_file, 2704 gateway=self.selected_gateway, 2705 state_connection_config=self._state_connection_config, 2706 environment_names=environment_names, 2707 local_only=local_only, 2708 confirm=confirm, 2709 ): 2710 try: 2711 export_state( 2712 state_sync=self.state_sync, 2713 output_file=output_file, 2714 local_snapshots=local_snapshots, 2715 environment_names=environment_names, 2716 console=self.console, 2717 ) 2718 self.console.stop_state_export(success=True, output_file=output_file) 2719 except: 2720 self.console.stop_state_export(success=False, output_file=output_file) 2721 raise 2722 2723 def import_state(self, input_file: Path, clear: bool = False, confirm: bool = True) -> None: 2724 from sqlmesh.core.state_sync.export_import import import_state 2725 2726 if self.console.start_state_import( 2727 input_file=input_file, 2728 gateway=self.selected_gateway, 2729 state_connection_config=self._state_connection_config, 2730 clear=clear, 2731 confirm=confirm, 2732 ): 2733 try: 2734 import_state( 2735 state_sync=self.state_sync, 2736 input_file=input_file, 2737 clear=clear, 2738 console=self.console, 2739 ) 2740 self.console.stop_state_import(success=True, input_file=input_file) 2741 except: 2742 self.console.stop_state_import(success=False, input_file=input_file) 2743 raise 2744 2745 def _run_tests( 2746 self, verbosity: Verbosity = Verbosity.DEFAULT 2747 ) -> t.Tuple[ModelTextTestResult, str]: 2748 test_output_io = StringIO() 2749 result = self.test(stream=test_output_io, verbosity=verbosity) 2750 return result, test_output_io.getvalue() 2751 2752 def _run_plan_tests(self, skip_tests: bool = False) -> t.Optional[ModelTextTestResult]: 2753 if not skip_tests: 2754 result = self.test() 2755 if not result.wasSuccessful(): 2756 raise PlanError( 2757 "Cannot generate plan due to failing test(s). Fix test(s) and run again." 2758 ) 2759 return result 2760 return None 2761 2762 def _warn_if_virtual_catalog_rematerialization(self, plan: "Plan") -> None: 2763 """Warn when ClickHouse models appear as new snapshots solely because a virtual catalog 2764 prefix was added to their FQNs after a catalog-aware gateway joined the project. 2765 2766 This situation causes every previously-applied ClickHouse model to be treated as brand-new 2767 by SQLMesh, triggering full re-materialization and historical backfills. Emitting a warning 2768 before the plan is displayed gives users a chance to understand the cost before applying. 2769 """ 2770 from sqlglot import exp 2771 2772 # Collect the set of old 2-level snapshot names from the current environment so we can 2773 # detect which new 3-level names are renames rather than genuinely new models. 2774 old_names: t.Set[str] = set() 2775 for s_id in plan.context_diff.removed_snapshots: 2776 old_names.add(s_id.name) 2777 for name in plan.context_diff.snapshots_by_name: 2778 old_names.add(name) 2779 2780 affected: t.List[t.Tuple[str, str]] = [] # (new_3level_name, old_2level_name) 2781 2782 for gateway, adapter in self.engine_adapters.items(): 2783 if not adapter.supports_virtual_catalog() or not adapter._default_catalog: 2784 continue 2785 virtual_catalog = adapter._default_catalog 2786 2787 for snapshot in plan.new_snapshots: 2788 table = exp.to_table(snapshot.name) 2789 if table.catalog != virtual_catalog: 2790 continue 2791 # Reconstruct the 2-level name that would have been used before injection. 2792 old_name = f"{table.db}.{table.name}" 2793 if old_name in old_names: 2794 affected.append((snapshot.name, old_name)) 2795 2796 if not affected: 2797 return 2798 2799 max_display = 10 2800 model_lines = "\n".join( 2801 f" - {new_name} (was: {old_name})" for new_name, old_name in affected[:max_display] 2802 ) 2803 if len(affected) > max_display: 2804 model_lines += f"\n ... and {len(affected) - max_display} more" 2805 2806 self.console.log_warning( 2807 "ClickHouse models are being re-materialized due to virtual catalog FQN change.\n\n" 2808 "The following ClickHouse models appear as new because their fully-qualified\n" 2809 "names changed from 2-level (db.table) to 3-level (__gateway__.db.table):\n\n" 2810 f"{model_lines}\n\n" 2811 "FULL models will be recreated once. INCREMENTAL_BY_TIME_RANGE models will\n" 2812 "require a full historical backfill from their configured start date.\n\n" 2813 "This is a one-time cost when first adding a catalog-aware gateway to an\n" 2814 "existing ClickHouse project. To proceed, run `sqlmesh apply`." 2815 ) 2816 2817 @property 2818 def _model_tables(self) -> t.Dict[str, str]: 2819 """Mapping of model name to physical table name. 2820 2821 If a snapshot has not been versioned yet, its view name will be returned. 2822 """ 2823 return { 2824 fqn: ( 2825 snapshot.table_name() 2826 if snapshot.version 2827 else snapshot.qualified_view_name.for_environment( 2828 EnvironmentNamingInfo.from_environment_catalog_mapping( 2829 self.environment_catalog_mapping, 2830 name=c.PROD, 2831 suffix_target=self.config.environment_suffix_target, 2832 ) 2833 ) 2834 ) 2835 for fqn, snapshot in self.snapshots.items() 2836 } 2837 2838 @cached_property 2839 def cache_dir(self) -> Path: 2840 if self.config.cache_dir: 2841 cache_path = Path(self.config.cache_dir) 2842 if cache_path.is_absolute(): 2843 return cache_path 2844 return self.path / cache_path 2845 2846 # Default to .cache directory in the project path 2847 return self.path / c.CACHE 2848 2849 @cached_property 2850 def engine_adapters(self) -> t.Dict[str, EngineAdapter]: 2851 """Returns all the engine adapters for the gateways defined in the configurations.""" 2852 adapters: t.Dict[str, EngineAdapter] = {self.selected_gateway: self.engine_adapter} 2853 for config in self.configs.values(): 2854 for gateway_name in config.gateways: 2855 if gateway_name not in adapters: 2856 connection = config.get_connection(gateway_name) 2857 adapter = connection.create_engine_adapter( 2858 concurrent_tasks=self.concurrent_tasks, 2859 ) 2860 adapters[gateway_name] = adapter 2861 return adapters 2862 2863 @cached_property 2864 def default_catalog_per_gateway(self) -> t.Dict[str, str]: 2865 """Returns the default catalogs for each engine adapter.""" 2866 return self._scheduler.get_default_catalog_per_gateway(self) 2867 2868 @property 2869 def concurrent_tasks(self) -> int: 2870 if self._concurrent_tasks is None: 2871 self._concurrent_tasks = self.connection_config.concurrent_tasks 2872 return self._concurrent_tasks 2873 2874 @cached_property 2875 def connection_config(self) -> ConnectionConfig: 2876 return self.config.get_connection(self.selected_gateway) 2877 2878 @cached_property 2879 def test_connection_config(self) -> ConnectionConfig: 2880 return self.config.get_test_connection( 2881 self.gateway, 2882 self.default_catalog, 2883 default_catalog_dialect=self.config.dialect, 2884 ) 2885 2886 @cached_property 2887 def environment_catalog_mapping(self) -> RegexKeyDict: 2888 engine_adapter = None 2889 try: 2890 engine_adapter = self.engine_adapter 2891 except Exception: 2892 pass 2893 2894 if ( 2895 self.config.environment_catalog_mapping 2896 and engine_adapter 2897 and not self.engine_adapter.catalog_support.is_multi_catalog_supported 2898 ): 2899 raise SQLMeshError( 2900 "Environment catalog mapping is only supported for engine adapters that support multiple catalogs" 2901 ) 2902 return self.config.environment_catalog_mapping 2903 2904 def _get_engine_adapter(self, gateway: t.Optional[str] = None) -> EngineAdapter: 2905 if gateway: 2906 if adapter := self.engine_adapters.get(gateway): 2907 return adapter 2908 raise SQLMeshError(f"Gateway '{gateway}' not found in the available engine adapters.") 2909 return self.engine_adapter 2910 2911 def _snapshots( 2912 self, models_override: t.Optional[UniqueKeyDict[str, Model]] = None 2913 ) -> t.Dict[str, Snapshot]: 2914 nodes = {**(models_override or self._models), **self._standalone_audits} 2915 snapshots = self._nodes_to_snapshots(nodes) 2916 stored_snapshots = self.state_reader.get_snapshots(snapshots.values()) 2917 2918 unrestorable_snapshots = { 2919 snapshot 2920 for snapshot in stored_snapshots.values() 2921 if snapshot.name in nodes and snapshot.unrestorable 2922 } 2923 if unrestorable_snapshots: 2924 for snapshot in unrestorable_snapshots: 2925 logger.info( 2926 "Found a unrestorable snapshot %s. Restamping the model...", snapshot.name 2927 ) 2928 node = nodes[snapshot.name] 2929 nodes[snapshot.name] = node.copy( 2930 update={"stamp": f"revert to {snapshot.identifier}"} 2931 ) 2932 snapshots = self._nodes_to_snapshots(nodes) 2933 stored_snapshots = self.state_reader.get_snapshots(snapshots.values()) 2934 2935 for snapshot in stored_snapshots.values(): 2936 # Keep the original model instance to preserve the query cache. 2937 snapshot.node = snapshots[snapshot.name].node 2938 2939 return {name: stored_snapshots.get(s.snapshot_id, s) for name, s in snapshots.items()} 2940 2941 def _context_diff( 2942 self, 2943 environment: str, 2944 snapshots: t.Optional[t.Dict[str, Snapshot]] = None, 2945 create_from: t.Optional[str] = None, 2946 force_no_diff: bool = False, 2947 ensure_finalized_snapshots: bool = False, 2948 diff_rendered: bool = False, 2949 always_recreate_environment: bool = False, 2950 ) -> ContextDiff: 2951 environment = Environment.sanitize_name(environment) 2952 if force_no_diff: 2953 return ContextDiff.create_no_diff(environment, self.state_reader) 2954 2955 return ContextDiff.create( 2956 environment, 2957 snapshots=snapshots or self.snapshots, 2958 create_from=create_from or c.PROD, 2959 state_reader=self.state_reader, 2960 provided_requirements=self._requirements, 2961 excluded_requirements=self._excluded_requirements, 2962 ensure_finalized_snapshots=ensure_finalized_snapshots, 2963 diff_rendered=diff_rendered, 2964 environment_statements=self._environment_statements, 2965 gateway_managed_virtual_layer=self.config.gateway_managed_virtual_layer, 2966 infer_python_dependencies=self.config.infer_python_dependencies, 2967 always_recreate_environment=always_recreate_environment, 2968 ) 2969 2970 def _destroy(self) -> bool: 2971 # Invalidate all environments, including prod 2972 for environment in self.state_reader.get_environments(): 2973 self.state_sync.invalidate_environment(name=environment.name, protect_prod=False) 2974 self.console.log_success(f"Environment '{environment.name}' invalidated.") 2975 2976 # Run janitor to clean up all objects 2977 self._run_janitor(ignore_ttl=True) 2978 2979 # Remove state tables, including backup tables 2980 self.state_sync.remove_state(including_backup=True) 2981 self.console.log_status_update("State tables removed.") 2982 2983 # Finally clear caches 2984 self.clear_caches() 2985 2986 return True 2987 2988 def _run_janitor( 2989 self, 2990 ignore_ttl: bool = False, 2991 force_delete: bool = False, 2992 environment: t.Optional[str] = None, 2993 ) -> None: 2994 current_ts = now_timestamp() 2995 failures: t.List[str] = [] 2996 2997 # Clean up expired environments by removing their views and schemas 2998 failures.extend( 2999 self._cleanup_environments( 3000 current_ts=current_ts, force_delete=force_delete, name=environment 3001 ) 3002 ) 3003 3004 if environment is None: 3005 failures.extend( 3006 delete_expired_snapshots( 3007 self.state_sync, 3008 self.snapshot_evaluator, 3009 current_ts=current_ts, 3010 ignore_ttl=ignore_ttl, 3011 force_delete=force_delete, 3012 console=self.console, 3013 batch_size=self.config.janitor.expired_snapshots_batch_size, 3014 ) 3015 ) 3016 self.state_sync.compact_intervals() 3017 3018 if failures: 3019 failure_string = "\n - ".join(failures) 3020 summary = f"Janitor completed with failures:\n {failure_string}" 3021 if force_delete: 3022 summary += "\nState records have been deleted, but the underlying objects may still exist in the database.\nPlease investigate and clean up manually the above if necessary." 3023 if self.config.janitor.warn_on_delete_failure: 3024 self.console.log_warning(summary) 3025 else: 3026 raise SQLMeshError(summary) 3027 3028 def _cleanup_environments( 3029 self, 3030 current_ts: t.Optional[int] = None, 3031 force_delete: bool = False, 3032 name: t.Optional[str] = None, 3033 ) -> t.List[str]: 3034 current_ts = current_ts or now_timestamp() 3035 failures: t.List[str] = [] 3036 3037 expired_environments_summaries = self.state_sync.get_expired_environments( 3038 current_ts=current_ts, name=name 3039 ) 3040 3041 if name is not None and not expired_environments_summaries: 3042 self.console.log_warning( 3043 f"Environment '{name}' is not expired or does not exist. Nothing to clean up." 3044 ) 3045 3046 for expired_env_summary in expired_environments_summaries: 3047 expired_env = self.state_reader.get_environment(expired_env_summary.name) 3048 3049 if expired_env: 3050 failures.extend( 3051 cleanup_expired_views( 3052 default_adapter=self.engine_adapter, 3053 engine_adapters=self.engine_adapters, 3054 environments=[expired_env], 3055 console=self.console, 3056 ) 3057 ) 3058 3059 # we want to retry on the next janitor pass if drops failed, unless 3060 # force_delete is set in which case we purge state records regardless 3061 if not failures or force_delete: 3062 self.state_sync.delete_expired_environments(current_ts=current_ts, name=name) 3063 return failures 3064 3065 def _try_connection(self, connection_name: str, validator: t.Callable[[], None]) -> None: 3066 connection_name = connection_name.capitalize() 3067 try: 3068 validator() 3069 self.console.log_status_update(f"{connection_name} connection [green]succeeded[/green]") 3070 except Exception as ex: 3071 self.console.log_error(f"{connection_name} connection failed. {ex}") 3072 3073 def _new_state_sync(self) -> StateSync: 3074 return self._provided_state_sync or self._scheduler.create_state_sync(self) 3075 3076 def _new_selector( 3077 self, models: t.Optional[UniqueKeyDict[str, Model]] = None, dag: t.Optional[DAG[str]] = None 3078 ) -> Selector: 3079 return self._selector_cls( 3080 self.state_reader, 3081 models=models or self._models, 3082 context_path=self.path, 3083 dag=dag, 3084 default_catalog=self.default_catalog, 3085 dialect=self.default_dialect, 3086 cache_dir=self.cache_dir, 3087 ) 3088 3089 def _register_notification_targets(self) -> None: 3090 event_notifications = collections.defaultdict(set) 3091 for target in self.notification_targets: 3092 if target.is_configured: 3093 for event in target.notify_on: 3094 event_notifications[event].add(target) 3095 user_notification_targets = { 3096 user.username: set( 3097 target for target in user.notification_targets if target.is_configured 3098 ) 3099 for user in self.users 3100 } 3101 self.notification_target_manager = NotificationTargetManager( 3102 event_notifications, user_notification_targets, username=self.config.username 3103 ) 3104 3105 def _load_materializations(self) -> None: 3106 if not self._loaded: 3107 for loader in self._loaders: 3108 loader.load_materializations() 3109 3110 def _select_models_for_run( 3111 self, 3112 select_models: t.Collection[str], 3113 no_auto_upstream: bool, 3114 snapshots: t.Collection[Snapshot], 3115 ) -> t.Set[str]: 3116 models: UniqueKeyDict[str, Model] = UniqueKeyDict( 3117 "models", **{s.name: s.model for s in snapshots if s.is_model} 3118 ) 3119 dag: DAG[str] = DAG() 3120 for fqn, model in models.items(): 3121 dag.add(fqn, model.depends_on) 3122 model_selector = self._new_selector(models=models, dag=dag) 3123 result = set(model_selector.expand_model_selections(select_models)) 3124 if not no_auto_upstream: 3125 result = set(dag.subdag(*result)) 3126 return result 3127 3128 @cached_property 3129 def _project_type(self) -> str: 3130 project_types = { 3131 c.DBT if loader.__class__.__name__.lower().startswith(c.DBT) else c.NATIVE 3132 for loader in self._loaders 3133 } 3134 return c.HYBRID if len(project_types) > 1 else first(project_types) 3135 3136 def _nodes_to_snapshots(self, nodes: t.Dict[str, Node]) -> t.Dict[str, Snapshot]: 3137 snapshots: t.Dict[str, Snapshot] = {} 3138 fingerprint_cache: t.Dict[str, SnapshotFingerprint] = {} 3139 3140 for node in nodes.values(): 3141 kwargs: t.Dict[str, t.Any] = {} 3142 if node.project in self._projects: 3143 config = self.config_for_node(node) 3144 kwargs["ttl"] = config.snapshot_ttl 3145 kwargs["table_naming_convention"] = config.physical_table_naming_convention 3146 3147 snapshot = Snapshot.from_node( 3148 node, 3149 nodes=nodes, 3150 cache=fingerprint_cache, 3151 **kwargs, 3152 ) 3153 snapshots[snapshot.name] = snapshot 3154 return snapshots 3155 3156 def _node_or_snapshot_to_fqn(self, node_or_snapshot: NodeOrSnapshot) -> str: 3157 if isinstance(node_or_snapshot, Snapshot): 3158 return node_or_snapshot.name 3159 if isinstance(node_or_snapshot, str) and not self.standalone_audits.get(node_or_snapshot): 3160 return normalize_model_name( 3161 node_or_snapshot, 3162 dialect=self.default_dialect, 3163 default_catalog=self.default_catalog, 3164 ) 3165 if not isinstance(node_or_snapshot, str): 3166 return node_or_snapshot.fqn 3167 return node_or_snapshot 3168 3169 @property 3170 def _plan_preview_enabled(self) -> bool: 3171 if self.config.plan.enable_preview is not None: 3172 return self.config.plan.enable_preview 3173 # It is dangerous to enable preview by default for dbt projects that rely on engines that don't support cloning. 3174 # Enabling previews in such cases can result in unintended full refreshes because dbt incremental models rely on 3175 # the maximum timestamp value in the target table. 3176 return self._project_type == c.NATIVE or self.engine_adapter.SUPPORTS_CLONING 3177 3178 def _get_plan_default_start_end( 3179 self, 3180 snapshots: t.Dict[str, Snapshot], 3181 max_interval_end_per_model: t.Dict[str, datetime], 3182 backfill_models: t.Optional[t.Set[str]], 3183 modified_model_names: t.Set[str], 3184 execution_time: t.Optional[TimeLike] = None, 3185 ) -> t.Tuple[t.Optional[int], t.Optional[int]]: 3186 # exclude seeds so their stale interval ends does not become the default plan end date 3187 # when they're the only ones that contain intervals in this plan 3188 non_seed_interval_ends = { 3189 model_fqn: end 3190 for model_fqn, end in max_interval_end_per_model.items() 3191 if model_fqn not in snapshots or not snapshots[model_fqn].is_seed 3192 } 3193 if not non_seed_interval_ends: 3194 return None, None 3195 3196 default_end = to_timestamp(max(non_seed_interval_ends.values())) 3197 default_start: t.Optional[int] = None 3198 # Infer the default start by finding the smallest interval start that corresponds to the default end. 3199 for model_name in backfill_models or modified_model_names or max_interval_end_per_model: 3200 if model_name not in snapshots: 3201 continue 3202 node = snapshots[model_name].node 3203 interval_unit = node.interval_unit 3204 default_start = min( 3205 default_start or sys.maxsize, 3206 to_timestamp( 3207 interval_unit.cron_prev( 3208 interval_unit.cron_floor( 3209 max_interval_end_per_model.get( 3210 model_name, node.cron_floor(default_end) 3211 ), 3212 ), 3213 estimate=True, 3214 ) 3215 ), 3216 ) 3217 3218 if execution_time and to_timestamp(default_end) > to_timestamp(execution_time): 3219 # the end date can't be in the future, which can happen if a specific `execution_time` is set and prod intervals 3220 # are newer than it 3221 default_end = to_timestamp(execution_time) 3222 3223 return default_start, default_end 3224 3225 def _calculate_start_override_per_model( 3226 self, 3227 min_intervals: t.Optional[int], 3228 plan_start: t.Optional[TimeLike], 3229 plan_end: t.Optional[TimeLike], 3230 plan_execution_time: TimeLike, 3231 backfill_model_fqns: t.Optional[t.Set[str]], 3232 snapshots_by_model_fqn: t.Dict[str, Snapshot], 3233 end_override_per_model: t.Optional[t.Dict[str, datetime]], 3234 ) -> t.Dict[str, datetime]: 3235 if not min_intervals or not backfill_model_fqns or not plan_start: 3236 # If there are no models to backfill, there are no intervals to consider for backfill, so we dont need to consider a minimum number 3237 # If the plan doesnt have a start date, all intervals are considered already so we dont need to consider a minimum number 3238 # If we dont have a minimum number of intervals to consider, then we dont need to adjust the start date on a per-model basis 3239 return {} 3240 3241 start_overrides: t.Dict[str, datetime] = {} 3242 end_override_per_model = end_override_per_model or {} 3243 3244 plan_execution_time_dt = to_datetime(plan_execution_time) 3245 plan_start_dt = to_datetime(plan_start, relative_base=plan_execution_time_dt) 3246 plan_end_dt = to_datetime( 3247 plan_end or plan_execution_time_dt, relative_base=plan_execution_time_dt 3248 ) 3249 3250 # we need to take the DAG into account so that parent models can be expanded to cover at least as much as their children 3251 # for example, A(hourly) <- B(daily) 3252 # if min_intervals=1, A would have 1 hour and B would have 1 day 3253 # but B depends on A so in order for B to have 1 valid day, A needs to be expanded to 24 hours 3254 backfill_dag: DAG[str] = DAG() 3255 for fqn in backfill_model_fqns: 3256 backfill_dag.add( 3257 fqn, 3258 [ 3259 p.name 3260 for p in snapshots_by_model_fqn[fqn].parents 3261 if p.name in backfill_model_fqns 3262 ], 3263 ) 3264 3265 # start from the leaf nodes and work back towards the root because the min_start at the root node is determined by the calculated starts in the leaf nodes 3266 reversed_dag = backfill_dag.reversed 3267 graph = reversed_dag.graph 3268 3269 for model_fqn in reversed_dag: 3270 # Get the earliest start from all immediate children of this snapshot 3271 # this works because topological ordering guarantees that they've already been visited 3272 # and we always set a start override 3273 min_child_start = min( 3274 [start_overrides[immediate_child_fqn] for immediate_child_fqn in graph[model_fqn]], 3275 default=plan_start_dt, 3276 ) 3277 3278 snapshot = snapshots_by_model_fqn.get(model_fqn) 3279 3280 if not snapshot: 3281 continue 3282 3283 starting_point = end_override_per_model.get(model_fqn, plan_end_dt) 3284 if node_end := snapshot.node.end: 3285 # if we dont do this, if the node end is a *date* (as opposed to a timestamp) 3286 # we end up incorrectly winding back an extra day 3287 node_end_dt = make_exclusive(node_end) 3288 3289 if node_end_dt < plan_end_dt: 3290 # if the model has an end date that has already elapsed, use that as a starting point for calculating min_intervals 3291 # instead of the plan end. If we use the plan end, we will return intervals in the future which are invalid 3292 starting_point = node_end_dt 3293 3294 snapshot_start = snapshot.node.cron_floor(starting_point) 3295 3296 for _ in range(min_intervals): 3297 # wind back the starting point by :min_intervals intervals to arrive at the minimum snapshot start date 3298 snapshot_start = snapshot.node.cron_prev(snapshot_start) 3299 3300 start_overrides[model_fqn] = min(min_child_start, snapshot_start) 3301 3302 return start_overrides 3303 3304 def _get_max_interval_end_per_model( 3305 self, snapshots: t.Dict[str, Snapshot], backfill_models: t.Optional[t.Set[str]] 3306 ) -> t.Dict[str, datetime]: 3307 models_for_interval_end = ( 3308 self._get_models_for_interval_end(snapshots, backfill_models) 3309 if backfill_models is not None 3310 else None 3311 ) 3312 return { 3313 model_fqn: to_datetime(ts) 3314 for model_fqn, ts in self.state_sync.max_interval_end_per_model( 3315 c.PROD, 3316 models=models_for_interval_end, 3317 ensure_finalized_snapshots=self.config.plan.use_finalized_state, 3318 ).items() 3319 } 3320 3321 @staticmethod 3322 def _get_models_for_interval_end( 3323 snapshots: t.Dict[str, Snapshot], backfill_models: t.Set[str] 3324 ) -> t.Set[str]: 3325 models_for_interval_end = set() 3326 models_stack = list(backfill_models) 3327 while models_stack: 3328 next_model = models_stack.pop() 3329 if next_model not in snapshots: 3330 continue 3331 models_for_interval_end.add(next_model) 3332 models_stack.extend( 3333 s.name 3334 for s in snapshots[next_model].parents 3335 if s.name not in models_for_interval_end 3336 ) 3337 return models_for_interval_end 3338 3339 def lint_models( 3340 self, 3341 models: t.Optional[t.Iterable[t.Union[str, Model]]] = None, 3342 raise_on_error: bool = True, 3343 ) -> t.List[AnnotatedRuleViolation]: 3344 found_error = False 3345 3346 model_list = ( 3347 list(self.get_model(model, raise_if_missing=True) for model in models) 3348 if models 3349 else self.models.values() 3350 ) 3351 all_violations = [] 3352 for model in model_list: 3353 # Linter may be `None` if the context is not loaded yet 3354 if linter := self._linters.get(model.project): 3355 lint_violation, violations = ( 3356 linter.lint_model(model, self, console=self.console) or found_error 3357 ) 3358 if lint_violation: 3359 found_error = True 3360 all_violations.extend(violations) 3361 3362 if raise_on_error and found_error: 3363 raise LinterError( 3364 "Linter detected errors in the code. Please fix them before proceeding." 3365 ) 3366 3367 return all_violations 3368 3369 def select_tests( 3370 self, 3371 tests: t.Optional[t.List[str]] = None, 3372 patterns: t.Optional[t.List[str]] = None, 3373 ) -> t.List[ModelTestMetadata]: 3374 """Filter pre-loaded test metadata based on tests and patterns.""" 3375 3376 test_meta = self._model_test_metadata 3377 3378 if tests: 3379 filtered_tests = [] 3380 for test in tests: 3381 if "::" in test: 3382 if test in self._model_test_metadata_fully_qualified_name_index: 3383 filtered_tests.append( 3384 self._model_test_metadata_fully_qualified_name_index[test] 3385 ) 3386 else: 3387 test_path = Path(test) 3388 if test_path in self._model_test_metadata_path_index: 3389 filtered_tests.extend(self._model_test_metadata_path_index[test_path]) 3390 3391 test_meta = filtered_tests 3392 3393 if patterns: 3394 test_meta = filter_tests_by_patterns(test_meta, patterns) 3395 3396 return test_meta
Encapsulates a SQLMesh environment supplying convenient functions to perform various tasks.
Arguments:
- notification_targets: The notification target to use. Defaults to what is defined in config.
- paths: The directories containing SQLMesh files.
- config: A Config object or the name of a Config object in config.py.
- connection: The name of the connection. If not specified the first connection as it appears in configuration will be used.
- test_connection: The name of the connection to use for tests. If not specified the first connection as it appears in configuration will be used.
- concurrent_tasks: The maximum number of tasks that can use the connection concurrently.
- load: Whether or not to automatically load all models and macros (default True).
- load_state: Whether to merge remote state into the local project during load (default True). Only intended for local-only operations like format; plan/apply in multi-repo projects require it to see models owned by other projects.
- console: The rich instance used for printing out CLI command results.
- users: A list of users to make known to SQLMesh.
380 def __init__( 381 self, 382 notification_targets: t.Optional[t.List[NotificationTarget]] = None, 383 state_sync: t.Optional[StateSync] = None, 384 paths: t.Union[str | Path, t.Iterable[str | Path]] = "", 385 config: t.Optional[t.Union[C, str, t.Dict[Path, C]]] = None, 386 gateway: t.Optional[str] = None, 387 concurrent_tasks: t.Optional[int] = None, 388 loader: t.Optional[t.Type[Loader]] = None, 389 load: bool = True, 390 users: t.Optional[t.List[User]] = None, 391 config_loader_kwargs: t.Optional[t.Dict[str, t.Any]] = None, 392 selector: t.Optional[t.Type[Selector]] = None, 393 load_state: bool = True, 394 ): 395 self.configs = ( 396 config 397 if isinstance(config, dict) 398 else load_configs(config, self.CONFIG_TYPE, paths, **(config_loader_kwargs or {})) 399 ) 400 self._projects = {config.project for config in self.configs.values()} 401 self.dag: DAG[str] = DAG() 402 self._models: UniqueKeyDict[str, Model] = UniqueKeyDict("models") 403 self._audits: UniqueKeyDict[str, ModelAudit] = UniqueKeyDict("audits") 404 self._standalone_audits: UniqueKeyDict[str, StandaloneAudit] = UniqueKeyDict( 405 "standaloneaudits" 406 ) 407 self._model_test_metadata: t.List[ModelTestMetadata] = [] 408 self._model_test_metadata_path_index: t.Dict[Path, t.List[ModelTestMetadata]] = {} 409 self._model_test_metadata_fully_qualified_name_index: t.Dict[str, ModelTestMetadata] = {} 410 self._models_with_tests: t.Set[str] = set() 411 412 self._macros: UniqueKeyDict[str, ExecutableOrMacro] = UniqueKeyDict("macros") 413 self._metrics: UniqueKeyDict[str, Metric] = UniqueKeyDict("metrics") 414 self._jinja_macros = JinjaMacroRegistry() 415 self._requirements: t.Dict[str, str] = {} 416 self._environment_statements: t.List[EnvironmentStatements] = [] 417 self._excluded_requirements: t.Set[str] = set() 418 self._engine_adapter: t.Optional[EngineAdapter] = None 419 self._linters: t.Dict[str, Linter] = {} 420 self._loaded: bool = False 421 self._load_state: bool = load_state 422 self._selector_cls = selector or NativeSelector 423 424 self.path, self.config = t.cast(t.Tuple[Path, C], next(iter(self.configs.items()))) 425 426 self._all_dialects: t.Set[str] = {self.config.dialect or ""} 427 428 if self.config.disable_anonymized_analytics: 429 analytics.disable_analytics() 430 431 self.gateway = gateway 432 self._scheduler = self.config.get_scheduler(self.gateway) 433 self.environment_ttl = self.config.environment_ttl 434 self.pinned_environments = Environment.sanitize_names(self.config.pinned_environments) 435 self.auto_categorize_changes = self.config.plan.auto_categorize_changes 436 self.selected_gateway = (gateway or self.config.default_gateway_name).lower() 437 438 gw_model_defaults = self.config.get_gateway(self.selected_gateway).model_defaults 439 if gw_model_defaults: 440 # Merge global model defaults with the selected gateway's, if it's overriden 441 global_defaults = self.config.model_defaults.model_dump(exclude_unset=True) 442 gateway_defaults = gw_model_defaults.model_dump(exclude_unset=True) 443 444 self.config.model_defaults = ModelDefaultsConfig( 445 **{**global_defaults, **gateway_defaults} 446 ) 447 448 # This allows overriding the default dialect's normalization strategy, so for example 449 # one can do `dialect="duckdb,normalization_strategy=lowercase"` and this will be 450 # applied to the DuckDB dialect globally 451 if "normalization_strategy" in str(self.config.dialect): 452 dialect = Dialect.get_or_raise(self.config.dialect) 453 type(dialect).NORMALIZATION_STRATEGY = dialect.normalization_strategy 454 455 self._loaders = [ 456 (loader or config.loader)(self, path, **config.loader_kwargs) 457 for path, config in self.configs.items() 458 ] 459 460 self._concurrent_tasks = concurrent_tasks 461 self._state_connection_config = ( 462 self.config.get_state_connection(self.gateway) or self.connection_config 463 ) 464 465 self._snapshot_evaluator: t.Optional[SnapshotEvaluator] = None 466 467 self.console = get_console() 468 setattr(self.console, "dialect", self.config.dialect) 469 470 self._provided_state_sync: t.Optional[StateSync] = state_sync 471 self._state_sync: t.Optional[StateSync] = None 472 473 # Should we dedupe notification_targets? If so how? 474 self.notification_targets = (notification_targets or []) + self.config.notification_targets 475 self.users = (users or []) + self.config.users 476 self.users = list({user.username: user for user in self.users}.values()) 477 self._register_notification_targets() 478 479 if load: 480 self.load()
The type of plan builder object to use (default: PlanBuilder).
493 @property 494 def snapshot_evaluator(self) -> SnapshotEvaluator: 495 if not self._snapshot_evaluator: 496 self._ensure_virtual_catalog_injection() 497 self._snapshot_evaluator = SnapshotEvaluator( 498 { 499 gateway: adapter.with_settings(execute_log_level=logging.INFO) 500 for gateway, adapter in self.engine_adapters.items() 501 }, 502 ddl_concurrent_tasks=self.concurrent_tasks, 503 selected_gateway=self.selected_gateway, 504 ) 505 return self._snapshot_evaluator
516 def execution_context( 517 self, 518 deployability_index: t.Optional[DeployabilityIndex] = None, 519 engine_adapter: t.Optional[EngineAdapter] = None, 520 snapshots: t.Optional[t.Dict[str, Snapshot]] = None, 521 ) -> ExecutionContext: 522 """Returns an execution context.""" 523 return ExecutionContext( 524 engine_adapter=engine_adapter or self.engine_adapter, 525 snapshots=snapshots or self.snapshots, 526 deployability_index=deployability_index, 527 default_dialect=self.default_dialect, 528 default_catalog=self.default_catalog, 529 )
Returns an execution context.
531 @python_api_analytics 532 def upsert_model(self, model: t.Union[str, Model], **kwargs: t.Any) -> Model: 533 """Update or insert a model. 534 535 The context's models dictionary will be updated to include these changes. 536 537 Args: 538 model: Model name or instance to update. 539 kwargs: The kwargs to update the model with. 540 541 Returns: 542 A new instance of the updated or inserted model. 543 """ 544 model = self.get_model(model, raise_if_missing=True) 545 if not model.enabled: 546 raise SQLMeshError(f"The disabled model '{model.name}' cannot be upserted") 547 path = model._path 548 549 model = model.copy(update=kwargs) 550 model._path = path 551 552 self.dag.add(model.fqn, model.depends_on) 553 554 self._models.update( 555 { 556 model.fqn: model, 557 # bust the fingerprint cache for all downstream models 558 **{fqn: self._models[fqn].copy() for fqn in self.dag.downstream(model.fqn)}, 559 } 560 ) 561 562 update_model_schemas( 563 self.dag, 564 models=self._models, 565 cache_dir=self.cache_dir, 566 ) 567 568 if model.dialect: 569 self._all_dialects.add(model.dialect) 570 571 model.validate_definition() 572 573 return model
Update or insert a model.
The context's models dictionary will be updated to include these changes.
Arguments:
- model: Model name or instance to update.
- kwargs: The kwargs to update the model with.
Returns:
A new instance of the updated or inserted model.
575 def scheduler( 576 self, 577 environment: t.Optional[str] = None, 578 snapshot_evaluator: t.Optional[SnapshotEvaluator] = None, 579 ) -> Scheduler: 580 """Returns the built-in scheduler. 581 582 Args: 583 environment: The target environment to source model snapshots from, or None 584 if snapshots should be sourced from the currently loaded local state. 585 586 Returns: 587 The built-in scheduler instance. 588 """ 589 snapshots: t.Iterable[Snapshot] 590 if environment is not None: 591 stored_environment = self.state_sync.get_environment(environment) 592 if stored_environment is None: 593 raise ConfigError(f"Environment '{environment}' was not found.") 594 snapshots = self.state_sync.get_snapshots(stored_environment.snapshots).values() 595 else: 596 snapshots = self.snapshots.values() 597 598 if not snapshots: 599 raise ConfigError("No models were found") 600 601 return self.create_scheduler(snapshots, snapshot_evaluator or self.snapshot_evaluator)
Returns the built-in scheduler.
Arguments:
- environment: The target environment to source model snapshots from, or None if snapshots should be sourced from the currently loaded local state.
Returns:
The built-in scheduler instance.
603 def create_scheduler( 604 self, snapshots: t.Iterable[Snapshot], snapshot_evaluator: SnapshotEvaluator 605 ) -> Scheduler: 606 """Creates the built-in scheduler. 607 608 Args: 609 snapshots: The snapshots to schedule. 610 611 Returns: 612 The built-in scheduler instance. 613 """ 614 return Scheduler( 615 snapshots, 616 snapshot_evaluator, 617 self.state_sync, 618 default_catalog=self.default_catalog, 619 max_workers=self.concurrent_tasks, 620 console=self.console, 621 notification_target_manager=self.notification_target_manager, 622 )
Creates the built-in scheduler.
Arguments:
- snapshots: The snapshots to schedule.
Returns:
The built-in scheduler instance.
624 @property 625 def state_sync(self) -> StateSync: 626 if not self._state_sync: 627 self._state_sync = self._new_state_sync() 628 629 if self._state_sync.get_versions(validate=False).schema_version == 0: 630 self.console.log_status_update("Initializing new project state...") 631 self._state_sync.migrate() 632 self._state_sync.get_versions() 633 self._state_sync = CachingStateSync(self._state_sync) # type: ignore 634 return self._state_sync
640 def refresh(self) -> None: 641 """Refresh all models that have been updated.""" 642 if any(loader.reload_needed() for loader in self._loaders): 643 self.load()
Refresh all models that have been updated.
645 def load(self, update_schemas: bool = True) -> GenericContext[C]: 646 """Load all files in the context's path.""" 647 load_start_ts = time.perf_counter() 648 649 loaded_projects = [loader.load() for loader in self._loaders] 650 651 self.dag = DAG() 652 self._standalone_audits.clear() 653 self._audits.clear() 654 self._macros.clear() 655 self._models.clear() 656 self._metrics.clear() 657 self._requirements.clear() 658 self._excluded_requirements.clear() 659 self._linters.clear() 660 self._environment_statements = [] 661 self._model_test_metadata.clear() 662 self._model_test_metadata_path_index.clear() 663 self._model_test_metadata_fully_qualified_name_index.clear() 664 self._models_with_tests.clear() 665 666 for loader, project in zip(self._loaders, loaded_projects): 667 self._jinja_macros = self._jinja_macros.merge(project.jinja_macros) 668 self._macros.update(project.macros) 669 self._models.update(project.models) 670 self._metrics.update(project.metrics) 671 self._audits.update(project.audits) 672 self._standalone_audits.update(project.standalone_audits) 673 self._requirements.update(project.requirements) 674 self._excluded_requirements.update(project.excluded_requirements) 675 self._environment_statements.extend(project.environment_statements) 676 677 self._model_test_metadata.extend(project.model_test_metadata) 678 for metadata in project.model_test_metadata: 679 if metadata.path not in self._model_test_metadata_path_index: 680 self._model_test_metadata_path_index[metadata.path] = [] 681 self._model_test_metadata_path_index[metadata.path].append(metadata) 682 self._model_test_metadata_fully_qualified_name_index[ 683 metadata.fully_qualified_test_name 684 ] = metadata 685 self._models_with_tests.add(metadata.model_name) 686 687 config = loader.config 688 self._linters[config.project] = Linter.from_rules( 689 BUILTIN_RULES.union(project.user_rules), config.linter 690 ) 691 692 # Load environment statements from state for projects not in current load 693 if self._load_state and any(self._projects): 694 prod = self.state_reader.get_environment(c.PROD) 695 if prod: 696 existing_statements = self.state_reader.get_environment_statements(c.PROD) 697 for stmt in existing_statements: 698 if stmt.project and stmt.project not in self._projects: 699 self._environment_statements.append(stmt) 700 701 uncached = set() 702 703 if self._load_state and any(self._projects): 704 prod = self.state_reader.get_environment(c.PROD) 705 706 if prod: 707 for snapshot in self.state_reader.get_snapshots(prod.snapshots).values(): 708 if snapshot.node.project in self._projects: 709 uncached.add(snapshot.name) 710 else: 711 local_store = self._standalone_audits if snapshot.is_audit else self._models 712 if snapshot.name in local_store: 713 uncached.add(snapshot.name) 714 else: 715 local_store[snapshot.name] = snapshot.node # type: ignore 716 717 for model in self._models.values(): 718 self.dag.add(model.fqn, model.depends_on) 719 720 if update_schemas: 721 for fqn in self.dag: 722 model = self._models.get(fqn) # type: ignore 723 724 if not model or fqn in uncached: 725 continue 726 727 # make a copy of remote models that depend on local models or in the downstream chain 728 # without this, a SELECT * FROM local will not propogate properly because the downstream 729 # model will get mutated (schema changes) but the object is the same as the remote cache 730 if any(dep in uncached for dep in model.depends_on): 731 uncached.add(fqn) 732 self._models.update({fqn: model.copy(update={"mapping_schema": {}})}) 733 continue 734 735 update_model_schemas( 736 self.dag, 737 models=self._models, 738 cache_dir=self.cache_dir, 739 ) 740 741 models = self.models.values() 742 for model in models: 743 # The model definition can be validated correctly only after the schema is set. 744 model.validate_definition() 745 746 duplicates = set(self._models) & set(self._standalone_audits) 747 if duplicates: 748 raise ConfigError( 749 f"Models and Standalone audits cannot have the same name: {duplicates}" 750 ) 751 752 self._all_dialects = {m.dialect for m in self._models.values() if m.dialect} | { 753 self.default_dialect or "" 754 } 755 756 analytics.collector.on_project_loaded( 757 project_type=self._project_type, 758 models_count=len(self._models), 759 audits_count=len(self._audits), 760 standalone_audits_count=len(self._standalone_audits), 761 macros_count=len(self._macros), 762 jinja_macros_count=len(self._jinja_macros.root_macros), 763 load_time_sec=time.perf_counter() - load_start_ts, 764 state_sync_fingerprint=self._scheduler.state_sync_fingerprint(self), 765 project_name=self.config.project, 766 ) 767 768 self._loaded = True 769 return self
Load all files in the context's path.
771 @python_api_analytics 772 def run( 773 self, 774 environment: t.Optional[str] = None, 775 *, 776 start: t.Optional[TimeLike] = None, 777 end: t.Optional[TimeLike] = None, 778 execution_time: t.Optional[TimeLike] = None, 779 skip_janitor: bool = False, 780 ignore_cron: bool = False, 781 select_models: t.Optional[t.Collection[str]] = None, 782 exit_on_env_update: t.Optional[int] = None, 783 no_auto_upstream: bool = False, 784 ) -> CompletionStatus: 785 """Run the entire dag through the scheduler. 786 787 Args: 788 environment: The target environment to source model snapshots from and virtually update. Default: prod. 789 start: The start of the interval to render. 790 end: The end of the interval to render. 791 execution_time: The date/time time reference to use for execution time. Defaults to now. 792 skip_janitor: Whether to skip the janitor task. 793 ignore_cron: Whether to ignore the model's cron schedule and run all available missing intervals. 794 select_models: A list of model selection expressions to filter models that should run. Note that 795 upstream dependencies of selected models will also be evaluated. 796 exit_on_env_update: If set, exits with the provided code if the run is interrupted by an update 797 to the target environment. 798 no_auto_upstream: Whether to not force upstream models to run. Only applicable when using `select_models`. 799 800 Returns: 801 True if the run was successful, False otherwise. 802 """ 803 environment = environment or self.config.default_target_environment 804 environment = Environment.sanitize_name(environment) 805 if not skip_janitor and environment.lower() == c.PROD: 806 self._run_janitor() 807 808 self.notification_target_manager.notify( 809 NotificationEvent.RUN_START, environment=environment 810 ) 811 analytics_run_id = analytics.collector.on_run_start( 812 engine_type=self.snapshot_evaluator.adapter.dialect, 813 state_sync_type=self.state_sync.state_type(), 814 ) 815 self._load_materializations() 816 817 env_check_attempts_num = max( 818 1, 819 self.config.run.environment_check_max_wait 820 // self.config.run.environment_check_interval, 821 ) 822 823 def _block_until_finalized() -> str: 824 for _ in range(env_check_attempts_num): 825 assert environment is not None # mypy 826 environment_state = self.state_sync.get_environment(environment) 827 if not environment_state: 828 raise SQLMeshError(f"Environment '{environment}' was not found.") 829 if environment_state.finalized_ts: 830 return environment_state.plan_id 831 self.console.log_warning( 832 f"Environment '{environment}' is being updated by plan '{environment_state.plan_id}'. " 833 f"Retrying in {self.config.run.environment_check_interval} seconds..." 834 ) 835 time.sleep(self.config.run.environment_check_interval) 836 raise SQLMeshError( 837 f"Exceeded the maximum wait time for environment '{environment}' to be ready. " 838 "This means that the environment either failed to update or the update is taking longer than expected. " 839 "See https://sqlmesh.readthedocs.io/en/stable/reference/configuration/#run to adjust the timeout settings." 840 ) 841 842 success = False 843 interrupted = False 844 done = False 845 while not done: 846 plan_id_at_start = _block_until_finalized() 847 848 def _has_environment_changed() -> bool: 849 assert environment is not None # mypy 850 current_environment_state = self.state_sync.get_environment(environment) 851 return ( 852 not current_environment_state 853 or current_environment_state.plan_id != plan_id_at_start 854 or not current_environment_state.finalized_ts 855 ) 856 857 try: 858 completion_status = self._run( 859 environment, 860 start=start, 861 end=end, 862 execution_time=execution_time, 863 ignore_cron=ignore_cron, 864 select_models=select_models, 865 circuit_breaker=_has_environment_changed, 866 no_auto_upstream=no_auto_upstream, 867 ) 868 done = True 869 except CircuitBreakerError: 870 self.console.log_warning( 871 f"Environment '{environment}' modified while running. Restarting the run..." 872 ) 873 if exit_on_env_update: 874 interrupted = True 875 done = True 876 except Exception as e: 877 self.notification_target_manager.notify( 878 NotificationEvent.RUN_FAILURE, traceback.format_exc() 879 ) 880 logger.info("Run failed.", exc_info=e) 881 analytics.collector.on_run_end( 882 run_id=analytics_run_id, succeeded=False, interrupted=False, error=e 883 ) 884 raise e 885 886 if completion_status.is_success or interrupted: 887 self.notification_target_manager.notify( 888 NotificationEvent.RUN_END, environment=environment 889 ) 890 self.console.log_success(f"Run finished for environment '{environment}'") 891 elif completion_status.is_failure: 892 self.notification_target_manager.notify( 893 NotificationEvent.RUN_FAILURE, "See console logs for details." 894 ) 895 896 analytics.collector.on_run_end( 897 run_id=analytics_run_id, succeeded=success, interrupted=interrupted 898 ) 899 900 if interrupted and exit_on_env_update is not None: 901 sys.exit(exit_on_env_update) 902 903 return completion_status
Run the entire dag through the scheduler.
Arguments:
- environment: The target environment to source model snapshots from and virtually update. Default: prod.
- start: The start of the interval to render.
- end: The end of the interval to render.
- execution_time: The date/time time reference to use for execution time. Defaults to now.
- skip_janitor: Whether to skip the janitor task.
- ignore_cron: Whether to ignore the model's cron schedule and run all available missing intervals.
- select_models: A list of model selection expressions to filter models that should run. Note that upstream dependencies of selected models will also be evaluated.
- exit_on_env_update: If set, exits with the provided code if the run is interrupted by an update to the target environment.
- no_auto_upstream: Whether to not force upstream models to run. Only applicable when using
select_models.
Returns:
True if the run was successful, False otherwise.
905 @python_api_analytics 906 def run_janitor( 907 self, 908 ignore_ttl: bool, 909 force_delete: bool = False, 910 environment: t.Optional[str] = None, 911 ) -> bool: 912 if environment is not None: 913 environment = Environment.sanitize_name(environment) 914 915 success = False 916 917 if self.console.start_cleanup(ignore_ttl): 918 try: 919 self._run_janitor(ignore_ttl, force_delete=force_delete, environment=environment) 920 success = True 921 finally: 922 self.console.stop_cleanup(success=success) 923 924 return success
926 @python_api_analytics 927 def destroy(self) -> bool: 928 success = False 929 930 # Collect resources to be deleted 931 environments = self.state_reader.get_environments() 932 schemas_to_delete = set() 933 tables_to_delete = set() 934 views_to_delete = set() 935 all_snapshot_infos = set() 936 937 # For each environment find schemas and tables 938 for environment in environments: 939 all_snapshot_infos.update(environment.snapshots) 940 snapshots = self.state_reader.get_snapshots(environment.snapshots).values() 941 for snapshot in snapshots: 942 if snapshot.is_model and not snapshot.is_symbolic: 943 # Get the appropriate adapter 944 if environment.gateway_managed and snapshot.model_gateway: 945 adapter = self.engine_adapters.get( 946 snapshot.model_gateway, self.engine_adapter 947 ) 948 else: 949 adapter = self.engine_adapter 950 951 if environment.suffix_target.is_schema or environment.suffix_target.is_catalog: 952 schema = snapshot.qualified_view_name.schema_for_environment( 953 environment.naming_info, dialect=adapter.dialect 954 ) 955 catalog = snapshot.qualified_view_name.catalog_for_environment( 956 environment.naming_info, dialect=adapter.dialect 957 ) 958 if catalog: 959 schemas_to_delete.add(f"{catalog}.{schema}") 960 else: 961 schemas_to_delete.add(schema) 962 963 if environment.suffix_target.is_table: 964 view_name = snapshot.qualified_view_name.for_environment( 965 environment.naming_info, dialect=adapter.dialect 966 ) 967 views_to_delete.add(view_name) 968 969 # Add snapshot tables 970 table_name = snapshot.table_name() 971 tables_to_delete.add(table_name) 972 973 if self.console.start_destroy(schemas_to_delete, views_to_delete, tables_to_delete): 974 try: 975 success = self._destroy() 976 finally: 977 self.console.stop_destroy(success=success) 978 979 return success
993 def get_model( 994 self, model_or_snapshot: ModelOrSnapshot, raise_if_missing: bool = False 995 ) -> t.Optional[Model]: 996 """Returns a model with the given name or None if a model with such name doesn't exist. 997 998 Args: 999 model_or_snapshot: A model name, model, or snapshot. 1000 raise_if_missing: Raises an error if a model is not found. 1001 1002 Returns: 1003 The expected model. 1004 """ 1005 if isinstance(model_or_snapshot, Snapshot): 1006 return model_or_snapshot.model 1007 if not isinstance(model_or_snapshot, str): 1008 return model_or_snapshot 1009 1010 try: 1011 # We should try all dialects referenced in the project for cases when models use mixed dialects. 1012 for dialect in self._all_dialects: 1013 normalized_name = normalize_model_name( 1014 model_or_snapshot, 1015 dialect=dialect, 1016 default_catalog=self.default_catalog, 1017 ) 1018 if normalized_name in self._models: 1019 return self._models[normalized_name] 1020 except: 1021 pass 1022 1023 if raise_if_missing: 1024 if model_or_snapshot.endswith((".sql", ".py")): 1025 msg = "Resolving models by path is not supported, please pass in the model name instead." 1026 else: 1027 msg = f"Cannot find model with name '{model_or_snapshot}'" 1028 1029 raise SQLMeshError(msg) 1030 1031 return None
Returns a model with the given name or None if a model with such name doesn't exist.
Arguments:
- model_or_snapshot: A model name, model, or snapshot.
- raise_if_missing: Raises an error if a model is not found.
Returns:
The expected model.
1046 def get_snapshot( 1047 self, node_or_snapshot: NodeOrSnapshot, raise_if_missing: bool = False 1048 ) -> t.Optional[Snapshot]: 1049 """Returns a snapshot with the given name or None if a snapshot with such name doesn't exist. 1050 1051 Args: 1052 node_or_snapshot: A node name, node, or snapshot. 1053 raise_if_missing: Raises an error if a snapshot is not found. 1054 1055 Returns: 1056 The expected snapshot. 1057 """ 1058 if isinstance(node_or_snapshot, Snapshot): 1059 return node_or_snapshot 1060 fqn = self._node_or_snapshot_to_fqn(node_or_snapshot) 1061 snapshot = self.snapshots.get(fqn) 1062 1063 if raise_if_missing and not snapshot: 1064 raise SQLMeshError(f"Cannot find snapshot for '{fqn}'") 1065 1066 return snapshot
Returns a snapshot with the given name or None if a snapshot with such name doesn't exist.
Arguments:
- node_or_snapshot: A node name, node, or snapshot.
- raise_if_missing: Raises an error if a snapshot is not found.
Returns:
The expected snapshot.
1068 def config_for_path(self, path: Path) -> t.Tuple[Config, Path]: 1069 """Returns the config and path of the said project for a given file path.""" 1070 for config_path, config in self.configs.items(): 1071 try: 1072 path.relative_to(config_path) 1073 return config, config_path 1074 except ValueError: 1075 pass 1076 return self.config, self.path
Returns the config and path of the said project for a given file path.
1084 @property 1085 def models(self) -> MappingProxyType[str, Model]: 1086 """Returns all registered models in this context.""" 1087 return MappingProxyType(self._models)
Returns all registered models in this context.
1089 @property 1090 def metrics(self) -> MappingProxyType[str, Metric]: 1091 """Returns all registered metrics in this context.""" 1092 return MappingProxyType(self._metrics)
Returns all registered metrics in this context.
1094 @property 1095 def standalone_audits(self) -> MappingProxyType[str, StandaloneAudit]: 1096 """Returns all registered standalone audits in this context.""" 1097 return MappingProxyType(self._standalone_audits)
Returns all registered standalone audits in this context.
1099 @property 1100 def models_with_tests(self) -> t.Set[str]: 1101 """Returns all models with tests in this context.""" 1102 return self._models_with_tests
Returns all models with tests in this context.
1104 @property 1105 def snapshots(self) -> t.Dict[str, Snapshot]: 1106 """Generates and returns snapshots based on models registered in this context. 1107 1108 If one of the snapshots has been previously stored in the persisted state, the stored 1109 instance will be returned. 1110 """ 1111 return self._snapshots()
Generates and returns snapshots based on models registered in this context.
If one of the snapshots has been previously stored in the persisted state, the stored instance will be returned.
1113 @property 1114 def requirements(self) -> t.Dict[str, str]: 1115 """Returns the Python dependencies of the project loaded in this context.""" 1116 return self._requirements.copy()
Returns the Python dependencies of the project loaded in this context.
1122 @python_api_analytics 1123 def render( 1124 self, 1125 model_or_snapshot: ModelOrSnapshot, 1126 *, 1127 start: t.Optional[TimeLike] = None, 1128 end: t.Optional[TimeLike] = None, 1129 execution_time: t.Optional[TimeLike] = None, 1130 expand: t.Union[bool, t.Iterable[str]] = False, 1131 **kwargs: t.Any, 1132 ) -> exp.Expr: 1133 """Renders a model's query, expanding macros with provided kwargs, and optionally expanding referenced models. 1134 1135 Args: 1136 model_or_snapshot: The model, model name, or snapshot to render. 1137 start: The start of the interval to render. 1138 end: The end of the interval to render. 1139 execution_time: The date/time time reference to use for execution time. Defaults to now. 1140 expand: Whether or not to use expand materialized models, defaults to False. 1141 If True, all referenced models are expanded as raw queries. 1142 If a list, only referenced models are expanded as raw queries. 1143 1144 Returns: 1145 The rendered expression. 1146 """ 1147 execution_time = execution_time or now() 1148 1149 model = self.get_model(model_or_snapshot, raise_if_missing=True) 1150 1151 if expand and not isinstance(expand, bool): 1152 expand = { 1153 normalize_model_name( 1154 x, default_catalog=self.default_catalog, dialect=self.default_dialect 1155 ) 1156 for x in expand 1157 } 1158 1159 expand = self.dag.upstream(model.fqn) if expand is True else expand or [] 1160 1161 if model.is_seed: 1162 import pandas as pd 1163 1164 df = next( 1165 model.render( 1166 context=self.execution_context( 1167 engine_adapter=self._get_engine_adapter(model.gateway) 1168 ), 1169 start=start, 1170 end=end, 1171 execution_time=execution_time, 1172 **kwargs, 1173 ) 1174 ) 1175 return next(pandas_to_sql(t.cast(pd.DataFrame, df), model.columns_to_types)) 1176 1177 snapshots = self.snapshots 1178 deployability_index = DeployabilityIndex.create(snapshots.values(), start=start) 1179 1180 return model.render_query_or_raise( 1181 start=start, 1182 end=end, 1183 execution_time=execution_time, 1184 snapshots=snapshots, 1185 expand=expand, 1186 deployability_index=deployability_index, 1187 engine_adapter=self._get_engine_adapter(model.gateway), 1188 **kwargs, 1189 )
Renders a model's query, expanding macros with provided kwargs, and optionally expanding referenced models.
Arguments:
- model_or_snapshot: The model, model name, or snapshot to render.
- start: The start of the interval to render.
- end: The end of the interval to render.
- execution_time: The date/time time reference to use for execution time. Defaults to now.
- expand: Whether or not to use expand materialized models, defaults to False. If True, all referenced models are expanded as raw queries. If a list, only referenced models are expanded as raw queries.
Returns:
The rendered expression.
1191 @python_api_analytics 1192 def evaluate( 1193 self, 1194 model_or_snapshot: ModelOrSnapshot, 1195 start: TimeLike, 1196 end: TimeLike, 1197 execution_time: TimeLike, 1198 limit: t.Optional[int] = None, 1199 **kwargs: t.Any, 1200 ) -> DF: 1201 """Evaluate a model or snapshot (running its query against a DB/Engine). 1202 1203 This method is used to test or iterate on models without side effects. 1204 1205 Args: 1206 model_or_snapshot: The model, model name, or snapshot to render. 1207 start: The start of the interval to evaluate. 1208 end: The end of the interval to evaluate. 1209 execution_time: The date/time time reference to use for execution time. 1210 limit: A limit applied to the model. 1211 """ 1212 snapshots = self.snapshots 1213 fqn = self._node_or_snapshot_to_fqn(model_or_snapshot) 1214 if fqn not in snapshots: 1215 raise SQLMeshError(f"Cannot find snapshot for '{fqn}'") 1216 snapshot = snapshots[fqn] 1217 1218 # Expand all uncategorized parents since physical tables don't exist for them yet 1219 expand = [ 1220 parent 1221 for parent in self.dag.upstream(snapshot.model.fqn) 1222 if (parent_snapshot := snapshots.get(parent)) 1223 and parent_snapshot.is_model 1224 and parent_snapshot.model.is_sql 1225 and not parent_snapshot.categorized 1226 ] 1227 1228 df = self.snapshot_evaluator.evaluate_and_fetch( 1229 snapshot, 1230 start=start, 1231 end=end, 1232 execution_time=execution_time, 1233 snapshots=self.snapshots, 1234 limit=limit or c.DEFAULT_MAX_LIMIT, 1235 expand=expand, 1236 ) 1237 1238 if df is None: 1239 raise RuntimeError(f"Error evaluating {snapshot.name}") 1240 1241 return df
Evaluate a model or snapshot (running its query against a DB/Engine).
This method is used to test or iterate on models without side effects.
Arguments:
- model_or_snapshot: The model, model name, or snapshot to render.
- start: The start of the interval to evaluate.
- end: The end of the interval to evaluate.
- execution_time: The date/time time reference to use for execution time.
- limit: A limit applied to the model.
1243 @python_api_analytics 1244 def format( 1245 self, 1246 transpile: t.Optional[str] = None, 1247 rewrite_casts: t.Optional[bool] = None, 1248 append_newline: t.Optional[bool] = None, 1249 *, 1250 check: t.Optional[bool] = None, 1251 paths: t.Optional[t.Tuple[t.Union[str, Path], ...]] = None, 1252 **kwargs: t.Any, 1253 ) -> bool: 1254 """Format all SQL models and audits.""" 1255 filtered_targets = [ 1256 target 1257 for target in chain(self._models.values(), self._audits.values()) 1258 if target._path is not None 1259 and target._path.suffix == ".sql" 1260 and (not paths or any(target._path.samefile(p) for p in paths)) 1261 ] 1262 unformatted_file_paths = [] 1263 1264 for target in filtered_targets: 1265 if ( 1266 target._path is None or target.formatting is False 1267 ): # introduced to satisfy type checker as still want to pull filter out as many targets as possible before loop 1268 continue 1269 1270 with open(target._path, "r+", encoding="utf-8") as file: 1271 before = file.read() 1272 1273 after = self._format( 1274 target, 1275 before, 1276 transpile=transpile, 1277 rewrite_casts=rewrite_casts, 1278 append_newline=append_newline, 1279 **kwargs, 1280 ) 1281 1282 if not check: 1283 file.seek(0) 1284 file.write(after) 1285 file.truncate() 1286 elif before != after: 1287 unformatted_file_paths.append(target._path) 1288 1289 if unformatted_file_paths: 1290 for path in unformatted_file_paths: 1291 self.console.log_status_update(f"{path} needs reformatting.") 1292 self.console.log_status_update( 1293 f"\n{len(unformatted_file_paths)} file(s) need reformatting." 1294 ) 1295 return False 1296 1297 return True
Format all SQL models and audits.
1337 @python_api_analytics 1338 def plan( 1339 self, 1340 environment: t.Optional[str] = None, 1341 *, 1342 start: t.Optional[TimeLike] = None, 1343 end: t.Optional[TimeLike] = None, 1344 execution_time: t.Optional[TimeLike] = None, 1345 create_from: t.Optional[str] = None, 1346 skip_tests: t.Optional[bool] = None, 1347 restate_models: t.Optional[t.Iterable[str]] = None, 1348 no_gaps: t.Optional[bool] = None, 1349 skip_backfill: t.Optional[bool] = None, 1350 empty_backfill: t.Optional[bool] = None, 1351 forward_only: t.Optional[bool] = None, 1352 allow_destructive_models: t.Optional[t.Collection[str]] = None, 1353 allow_additive_models: t.Optional[t.Collection[str]] = None, 1354 no_prompts: t.Optional[bool] = None, 1355 auto_apply: t.Optional[bool] = None, 1356 no_auto_categorization: t.Optional[bool] = None, 1357 effective_from: t.Optional[TimeLike] = None, 1358 include_unmodified: t.Optional[bool] = None, 1359 select_models: t.Optional[t.Collection[str]] = None, 1360 backfill_models: t.Optional[t.Collection[str]] = None, 1361 categorizer_config: t.Optional[CategorizerConfig] = None, 1362 enable_preview: t.Optional[bool] = None, 1363 no_diff: t.Optional[bool] = None, 1364 run: t.Optional[bool] = None, 1365 diff_rendered: t.Optional[bool] = None, 1366 skip_linter: t.Optional[bool] = None, 1367 explain: t.Optional[bool] = None, 1368 ignore_cron: t.Optional[bool] = None, 1369 min_intervals: t.Optional[int] = None, 1370 ) -> Plan: 1371 """Interactively creates a plan. 1372 1373 This method compares the current context with the target environment. It then presents 1374 the differences and asks whether to backfill each modified model. 1375 1376 Args: 1377 environment: The environment to diff and plan against. 1378 start: The start date of the backfill if there is one. 1379 end: The end date of the backfill if there is one. 1380 execution_time: The date/time reference to use for execution time. Defaults to now. 1381 create_from: The environment to create the target environment from if it 1382 doesn't exist. If not specified, the "prod" environment will be used. 1383 skip_tests: Unit tests are run by default so this will skip them if enabled 1384 restate_models: A list of either internal or external models, or tags, that need to be restated 1385 for the given plan interval. If the target environment is a production environment, 1386 ALL snapshots that depended on these upstream tables will have their intervals deleted 1387 (even ones not in this current environment). Only the snapshots in this environment will 1388 be backfilled whereas others need to be recovered on a future plan application. For development 1389 environments only snapshots that are part of this plan will be affected. 1390 no_gaps: Whether to ensure that new snapshots for models that are already a 1391 part of the target environment have no data gaps when compared against previous 1392 snapshots for same models. 1393 skip_backfill: Whether to skip the backfill step. Default: False. 1394 empty_backfill: Like skip_backfill, but also records processed intervals. 1395 forward_only: Whether the purpose of the plan is to make forward only changes. 1396 allow_destructive_models: Models whose forward-only changes are allowed to be destructive. 1397 allow_additive_models: Models whose forward-only changes are allowed to be additive. 1398 no_prompts: Whether to disable interactive prompts for the backfill time range. Please note that 1399 if this flag is set to true and there are uncategorized changes the plan creation will 1400 fail. Default: False. 1401 auto_apply: Whether to automatically apply the new plan after creation. Default: False. 1402 no_auto_categorization: Indicates whether to disable automatic categorization of model 1403 changes (breaking / non-breaking). If not provided, then the corresponding configuration 1404 option determines the behavior. 1405 categorizer_config: The configuration for the categorizer. Uses the categorizer configuration defined in the 1406 project config by default. 1407 effective_from: The effective date from which to apply forward-only changes on production. 1408 include_unmodified: Indicates whether to include unmodified models in the target development environment. 1409 select_models: A list of model selection strings to filter the models that should be included into this plan. 1410 backfill_models: A list of model selection strings to filter the models for which the data should be backfilled. 1411 enable_preview: Indicates whether to enable preview for forward-only models in development environments. 1412 no_diff: Hide text differences for changed models. 1413 run: Whether to run latest intervals as part of the plan application. 1414 diff_rendered: Whether the diff should compare raw vs rendered models 1415 skip_linter: Linter runs by default so this will skip it if enabled 1416 explain: Whether to explain the plan instead of applying it. 1417 min_intervals: Adjust the plan start date on a per-model basis in order to ensure at least this many intervals are covered 1418 on every model when checking for missing intervals 1419 1420 Returns: 1421 The populated Plan object. 1422 """ 1423 plan_builder = self.plan_builder( 1424 environment, 1425 start=start, 1426 end=end, 1427 execution_time=execution_time, 1428 create_from=create_from, 1429 skip_tests=skip_tests, 1430 restate_models=restate_models, 1431 no_gaps=no_gaps, 1432 skip_backfill=skip_backfill, 1433 empty_backfill=empty_backfill, 1434 forward_only=forward_only, 1435 allow_destructive_models=allow_destructive_models, 1436 allow_additive_models=allow_additive_models, 1437 no_auto_categorization=no_auto_categorization, 1438 effective_from=effective_from, 1439 include_unmodified=include_unmodified, 1440 select_models=select_models, 1441 backfill_models=backfill_models, 1442 categorizer_config=categorizer_config, 1443 enable_preview=enable_preview, 1444 run=run, 1445 diff_rendered=diff_rendered, 1446 skip_linter=skip_linter, 1447 explain=explain, 1448 ignore_cron=ignore_cron, 1449 min_intervals=min_intervals, 1450 ) 1451 1452 plan = plan_builder.build() 1453 1454 self._warn_if_virtual_catalog_rematerialization(plan) 1455 1456 if no_auto_categorization or plan.uncategorized: 1457 # Prompts are required if the auto categorization is disabled 1458 # or if there are any uncategorized snapshots in the plan 1459 no_prompts = False 1460 1461 if explain: 1462 auto_apply = True 1463 1464 self.console.plan( 1465 plan_builder, 1466 auto_apply if auto_apply is not None else self.config.plan.auto_apply, 1467 self.default_catalog, 1468 no_diff=no_diff if no_diff is not None else self.config.plan.no_diff, 1469 no_prompts=no_prompts if no_prompts is not None else self.config.plan.no_prompts, 1470 ) 1471 1472 return plan
Interactively creates a plan.
This method compares the current context with the target environment. It then presents the differences and asks whether to backfill each modified model.
Arguments:
- environment: The environment to diff and plan against.
- start: The start date of the backfill if there is one.
- end: The end date of the backfill if there is one.
- execution_time: The date/time reference to use for execution time. Defaults to now.
- create_from: The environment to create the target environment from if it doesn't exist. If not specified, the "prod" environment will be used.
- skip_tests: Unit tests are run by default so this will skip them if enabled
- restate_models: A list of either internal or external models, or tags, that need to be restated for the given plan interval. If the target environment is a production environment, ALL snapshots that depended on these upstream tables will have their intervals deleted (even ones not in this current environment). Only the snapshots in this environment will be backfilled whereas others need to be recovered on a future plan application. For development environments only snapshots that are part of this plan will be affected.
- no_gaps: Whether to ensure that new snapshots for models that are already a part of the target environment have no data gaps when compared against previous snapshots for same models.
- skip_backfill: Whether to skip the backfill step. Default: False.
- empty_backfill: Like skip_backfill, but also records processed intervals.
- forward_only: Whether the purpose of the plan is to make forward only changes.
- allow_destructive_models: Models whose forward-only changes are allowed to be destructive.
- allow_additive_models: Models whose forward-only changes are allowed to be additive.
- no_prompts: Whether to disable interactive prompts for the backfill time range. Please note that if this flag is set to true and there are uncategorized changes the plan creation will fail. Default: False.
- auto_apply: Whether to automatically apply the new plan after creation. Default: False.
- no_auto_categorization: Indicates whether to disable automatic categorization of model changes (breaking / non-breaking). If not provided, then the corresponding configuration option determines the behavior.
- categorizer_config: The configuration for the categorizer. Uses the categorizer configuration defined in the project config by default.
- effective_from: The effective date from which to apply forward-only changes on production.
- include_unmodified: Indicates whether to include unmodified models in the target development environment.
- select_models: A list of model selection strings to filter the models that should be included into this plan.
- backfill_models: A list of model selection strings to filter the models for which the data should be backfilled.
- enable_preview: Indicates whether to enable preview for forward-only models in development environments.
- no_diff: Hide text differences for changed models.
- run: Whether to run latest intervals as part of the plan application.
- diff_rendered: Whether the diff should compare raw vs rendered models
- skip_linter: Linter runs by default so this will skip it if enabled
- explain: Whether to explain the plan instead of applying it.
- min_intervals: Adjust the plan start date on a per-model basis in order to ensure at least this many intervals are covered on every model when checking for missing intervals
Returns:
The populated Plan object.
1474 @python_api_analytics 1475 def plan_builder( 1476 self, 1477 environment: t.Optional[str] = None, 1478 *, 1479 start: t.Optional[TimeLike] = None, 1480 end: t.Optional[TimeLike] = None, 1481 execution_time: t.Optional[TimeLike] = None, 1482 create_from: t.Optional[str] = None, 1483 skip_tests: t.Optional[bool] = None, 1484 restate_models: t.Optional[t.Iterable[str]] = None, 1485 no_gaps: t.Optional[bool] = None, 1486 skip_backfill: t.Optional[bool] = None, 1487 empty_backfill: t.Optional[bool] = None, 1488 forward_only: t.Optional[bool] = None, 1489 allow_destructive_models: t.Optional[t.Collection[str]] = None, 1490 allow_additive_models: t.Optional[t.Collection[str]] = None, 1491 no_auto_categorization: t.Optional[bool] = None, 1492 effective_from: t.Optional[TimeLike] = None, 1493 include_unmodified: t.Optional[bool] = None, 1494 select_models: t.Optional[t.Collection[str]] = None, 1495 backfill_models: t.Optional[t.Collection[str]] = None, 1496 categorizer_config: t.Optional[CategorizerConfig] = None, 1497 enable_preview: t.Optional[bool] = None, 1498 preview_start: t.Optional[TimeLike] = None, 1499 preview_min_intervals: t.Optional[int] = None, 1500 run: t.Optional[bool] = None, 1501 diff_rendered: t.Optional[bool] = None, 1502 skip_linter: t.Optional[bool] = None, 1503 explain: t.Optional[bool] = None, 1504 ignore_cron: t.Optional[bool] = None, 1505 min_intervals: t.Optional[int] = None, 1506 always_include_local_changes: t.Optional[bool] = None, 1507 ) -> PlanBuilder: 1508 """Creates a plan builder. 1509 1510 Args: 1511 environment: The environment to diff and plan against. 1512 start: The start date of the backfill if there is one. 1513 end: The end date of the backfill if there is one. 1514 execution_time: The date/time reference to use for execution time. Defaults to now. 1515 create_from: The environment to create the target environment from if it 1516 doesn't exist. If not specified, the "prod" environment will be used. 1517 skip_tests: Unit tests are run by default so this will skip them if enabled 1518 restate_models: A list of either internal or external models, or tags, that need to be restated 1519 for the given plan interval. If the target environment is a production environment, 1520 ALL snapshots that depended on these upstream tables will have their intervals deleted 1521 (even ones not in this current environment). Only the snapshots in this environment will 1522 be backfilled whereas others need to be recovered on a future plan application. For development 1523 environments only snapshots that are part of this plan will be affected. 1524 no_gaps: Whether to ensure that new snapshots for models that are already a 1525 part of the target environment have no data gaps when compared against previous 1526 snapshots for same models. 1527 skip_backfill: Whether to skip the backfill step. Default: False. 1528 empty_backfill: Like skip_backfill, but also records processed intervals. 1529 forward_only: Whether the purpose of the plan is to make forward only changes. 1530 allow_destructive_models: Models whose forward-only changes are allowed to be destructive. 1531 no_auto_categorization: Indicates whether to disable automatic categorization of model 1532 changes (breaking / non-breaking). If not provided, then the corresponding configuration 1533 option determines the behavior. 1534 categorizer_config: The configuration for the categorizer. Uses the categorizer configuration defined in the 1535 project config by default. 1536 effective_from: The effective date from which to apply forward-only changes on production. 1537 include_unmodified: Indicates whether to include unmodified models in the target development environment. 1538 select_models: A list of model selection strings to filter the models that should be included into this plan. 1539 backfill_models: A list of model selection strings to filter the models for which the data should be backfilled. 1540 enable_preview: Indicates whether to enable preview for forward-only models in development environments. 1541 preview_start: The start date for forward-only previews. 1542 preview_min_intervals: The minimum number of intervals to preview for each forward-only preview snapshot. 1543 run: Whether to run latest intervals as part of the plan application. 1544 diff_rendered: Whether the diff should compare raw vs rendered models 1545 min_intervals: Adjust the plan start date on a per-model basis in order to ensure at least this many intervals are covered 1546 on every model when checking for missing intervals 1547 always_include_local_changes: Usually when restatements are present, local changes in the filesystem are ignored. 1548 However, it can be desirable to deploy changes + restatements in the same plan, so this flag overrides the default behaviour. 1549 1550 Returns: 1551 The plan builder. 1552 """ 1553 kwargs: t.Dict[str, t.Optional[UserProvidedFlags]] = { 1554 "start": start, 1555 "end": end, 1556 "execution_time": execution_time, 1557 "create_from": create_from, 1558 "skip_tests": skip_tests, 1559 "restate_models": list(restate_models) if restate_models is not None else None, 1560 "no_gaps": no_gaps, 1561 "skip_backfill": skip_backfill, 1562 "empty_backfill": empty_backfill, 1563 "forward_only": forward_only, 1564 "allow_destructive_models": list(allow_destructive_models) 1565 if allow_destructive_models is not None 1566 else None, 1567 "allow_additive_models": list(allow_additive_models) 1568 if allow_additive_models is not None 1569 else None, 1570 "no_auto_categorization": no_auto_categorization, 1571 "effective_from": effective_from, 1572 "include_unmodified": include_unmodified, 1573 "select_models": list(select_models) if select_models is not None else None, 1574 "backfill_models": list(backfill_models) if backfill_models is not None else None, 1575 "enable_preview": enable_preview, 1576 "preview_start": preview_start, 1577 "preview_min_intervals": preview_min_intervals, 1578 "run": run, 1579 "diff_rendered": diff_rendered, 1580 "skip_linter": skip_linter, 1581 "min_intervals": min_intervals, 1582 } 1583 user_provided_flags: t.Dict[str, UserProvidedFlags] = { 1584 k: v for k, v in kwargs.items() if v is not None 1585 } 1586 1587 skip_tests = explain or skip_tests or False 1588 no_gaps = no_gaps or False 1589 skip_backfill = skip_backfill or False 1590 empty_backfill = empty_backfill or False 1591 run = run or False 1592 diff_rendered = diff_rendered or False 1593 skip_linter = skip_linter or False 1594 min_intervals = min_intervals or 0 1595 1596 environment = environment or self.config.default_target_environment 1597 environment = Environment.sanitize_name(environment) 1598 is_dev = environment != c.PROD 1599 1600 if include_unmodified is None: 1601 include_unmodified = self.config.plan.include_unmodified 1602 1603 if skip_backfill and not no_gaps and not is_dev: 1604 # note: we deliberately don't mention the --no-gaps flag in case the plan came from the sqlmesh_dbt command 1605 # todo: perhaps we could have better error messages if we check sys.argv[0] for which cli is running? 1606 self.console.log_warning( 1607 "Skipping the backfill stage for production can lead to unexpected results, such as tables being empty or incremental data with non-contiguous time ranges being made available.\n" 1608 "If you are doing this deliberately to create an empty version of a table to test a change, please consider using Virtual Data Environments instead." 1609 ) 1610 1611 if not skip_linter: 1612 self.lint_models() 1613 1614 self._run_plan_tests(skip_tests=skip_tests) 1615 1616 environment_ttl = ( 1617 self.environment_ttl if environment not in self.pinned_environments else None 1618 ) 1619 1620 model_selector = self._new_selector() 1621 1622 if allow_destructive_models: 1623 expanded_destructive_models = model_selector.expand_model_selections( 1624 allow_destructive_models 1625 ) 1626 else: 1627 expanded_destructive_models = None 1628 1629 if allow_additive_models: 1630 expanded_additive_models = model_selector.expand_model_selections(allow_additive_models) 1631 else: 1632 expanded_additive_models = None 1633 1634 if backfill_models: 1635 backfill_models = model_selector.expand_model_selections(backfill_models) 1636 else: 1637 backfill_models = None 1638 1639 models_override: t.Optional[UniqueKeyDict[str, Model]] = None 1640 selected_fqns: t.Set[str] = set() 1641 selected_deletion_fqns: t.Set[str] = set() 1642 if select_models: 1643 try: 1644 models_override, selected_fqns = model_selector.select_models( 1645 select_models, 1646 environment, 1647 fallback_env_name=create_from or c.PROD, 1648 ensure_finalized_snapshots=self.config.plan.use_finalized_state, 1649 ) 1650 except SQLMeshError as e: 1651 logger.exception(e) # ensure the full stack trace is logged 1652 raise PlanError( 1653 f"{e}\nCheck the SQLMesh log file for the full stack trace.\nIf the model has been fixed locally, please ensure that the --select-model expression includes it." 1654 ) 1655 if not backfill_models: 1656 # Only backfill selected models unless explicitly specified. 1657 backfill_models = model_selector.expand_model_selections(select_models) 1658 1659 if not backfill_models: 1660 # The selection matched nothing locally. Check whether it matched models 1661 # in the deployed environment that were deleted locally. 1662 selected_deletion_fqns = selected_fqns - set(self._models) 1663 1664 expanded_restate_models = None 1665 if restate_models is not None: 1666 expanded_restate_models = model_selector.expand_model_selections(restate_models) 1667 1668 if (restate_models is not None and not expanded_restate_models) or ( 1669 backfill_models is not None and not backfill_models and not selected_deletion_fqns 1670 ): 1671 raise PlanError( 1672 "Selector did not return any models. Please check your model selection and try again." 1673 ) 1674 1675 if always_include_local_changes is None: 1676 # default behaviour - if restatements are detected; we operate entirely out of state and ignore local changes 1677 force_no_diff = restate_models is not None or ( 1678 backfill_models is not None and not backfill_models and not selected_deletion_fqns 1679 ) 1680 else: 1681 force_no_diff = not always_include_local_changes 1682 1683 snapshots = self._snapshots(models_override) 1684 context_diff = self._context_diff( 1685 environment or c.PROD, 1686 snapshots=snapshots, 1687 create_from=create_from, 1688 force_no_diff=force_no_diff, 1689 ensure_finalized_snapshots=self.config.plan.use_finalized_state, 1690 diff_rendered=diff_rendered, 1691 always_recreate_environment=self.config.plan.always_recreate_environment, 1692 ) 1693 modified_model_names = { 1694 *context_diff.modified_snapshots, 1695 *[s.name for s in context_diff.added], 1696 } 1697 1698 if ( 1699 is_dev 1700 and not include_unmodified 1701 and backfill_models is None 1702 and expanded_restate_models is None 1703 ): 1704 # Only backfill modified and added models. 1705 # This ensures that no models outside the impacted sub-DAG(s) will be backfilled unexpectedly. 1706 backfill_models = modified_model_names or None 1707 1708 max_interval_end_per_model = None 1709 default_start, default_end = None, None 1710 if not run: 1711 ignore_cron = False 1712 max_interval_end_per_model = self._get_max_interval_end_per_model( 1713 snapshots, backfill_models 1714 ) 1715 # If no end date is specified, use the max interval end from prod 1716 # to prevent unintended evaluation of the entire DAG. 1717 default_start, default_end = self._get_plan_default_start_end( 1718 snapshots, 1719 max_interval_end_per_model, 1720 backfill_models, 1721 modified_model_names, 1722 execution_time or now(), 1723 ) 1724 1725 # Refresh snapshot intervals to ensure that they are up to date with values reflected in the max_interval_end_per_model. 1726 self.state_sync.refresh_snapshot_intervals(context_diff.snapshots.values()) 1727 1728 start_override_per_model = self._calculate_start_override_per_model( 1729 min_intervals, 1730 start or default_start, 1731 end or default_end, 1732 execution_time or now(), 1733 backfill_models, 1734 snapshots, 1735 max_interval_end_per_model, 1736 ) 1737 1738 if not self.config.virtual_environment_mode.is_full: 1739 forward_only = True 1740 elif forward_only is None: 1741 forward_only = self.config.plan.forward_only 1742 1743 # When handling prod restatements, only clear intervals from other model versions if we are using full virtual environments 1744 # If we are not, then there is no point, because none of the data in dev environments can be promoted by definition 1745 restate_all_snapshots = ( 1746 expanded_restate_models is not None 1747 and not is_dev 1748 and self.config.virtual_environment_mode.is_full 1749 ) 1750 1751 return self.PLAN_BUILDER_TYPE( 1752 context_diff=context_diff, 1753 start=start, 1754 end=end, 1755 execution_time=execution_time, 1756 apply=self.apply, 1757 restate_models=expanded_restate_models, 1758 restate_all_snapshots=restate_all_snapshots, 1759 backfill_models=backfill_models, 1760 no_gaps=no_gaps, 1761 skip_backfill=skip_backfill, 1762 empty_backfill=empty_backfill, 1763 is_dev=is_dev, 1764 forward_only=forward_only, 1765 allow_destructive_models=expanded_destructive_models, 1766 allow_additive_models=expanded_additive_models, 1767 environment_ttl=environment_ttl, 1768 environment_suffix_target=self.config.environment_suffix_target, 1769 environment_catalog_mapping=self.environment_catalog_mapping, 1770 categorizer_config=categorizer_config or self.auto_categorize_changes, 1771 auto_categorization_enabled=not no_auto_categorization, 1772 effective_from=effective_from, 1773 include_unmodified=include_unmodified, 1774 default_start=default_start, 1775 default_end=default_end, 1776 enable_preview=( 1777 enable_preview if enable_preview is not None else self._plan_preview_enabled 1778 ), 1779 preview_start=preview_start, 1780 preview_min_intervals=preview_min_intervals or 0, 1781 end_bounded=not run, 1782 ensure_finalized_snapshots=self.config.plan.use_finalized_state, 1783 start_override_per_model=start_override_per_model, 1784 end_override_per_model=max_interval_end_per_model, 1785 console=self.console, 1786 user_provided_flags=user_provided_flags, 1787 selected_models={ 1788 dbt_unique_id 1789 for model in model_selector.expand_model_selections(select_models or "*") 1790 if (dbt_unique_id := snapshots[model].node.dbt_unique_id) 1791 }, 1792 explain=explain or False, 1793 ignore_cron=ignore_cron or False, 1794 )
Creates a plan builder.
Arguments:
- environment: The environment to diff and plan against.
- start: The start date of the backfill if there is one.
- end: The end date of the backfill if there is one.
- execution_time: The date/time reference to use for execution time. Defaults to now.
- create_from: The environment to create the target environment from if it doesn't exist. If not specified, the "prod" environment will be used.
- skip_tests: Unit tests are run by default so this will skip them if enabled
- restate_models: A list of either internal or external models, or tags, that need to be restated for the given plan interval. If the target environment is a production environment, ALL snapshots that depended on these upstream tables will have their intervals deleted (even ones not in this current environment). Only the snapshots in this environment will be backfilled whereas others need to be recovered on a future plan application. For development environments only snapshots that are part of this plan will be affected.
- no_gaps: Whether to ensure that new snapshots for models that are already a part of the target environment have no data gaps when compared against previous snapshots for same models.
- skip_backfill: Whether to skip the backfill step. Default: False.
- empty_backfill: Like skip_backfill, but also records processed intervals.
- forward_only: Whether the purpose of the plan is to make forward only changes.
- allow_destructive_models: Models whose forward-only changes are allowed to be destructive.
- no_auto_categorization: Indicates whether to disable automatic categorization of model changes (breaking / non-breaking). If not provided, then the corresponding configuration option determines the behavior.
- categorizer_config: The configuration for the categorizer. Uses the categorizer configuration defined in the project config by default.
- effective_from: The effective date from which to apply forward-only changes on production.
- include_unmodified: Indicates whether to include unmodified models in the target development environment.
- select_models: A list of model selection strings to filter the models that should be included into this plan.
- backfill_models: A list of model selection strings to filter the models for which the data should be backfilled.
- enable_preview: Indicates whether to enable preview for forward-only models in development environments.
- preview_start: The start date for forward-only previews.
- preview_min_intervals: The minimum number of intervals to preview for each forward-only preview snapshot.
- run: Whether to run latest intervals as part of the plan application.
- diff_rendered: Whether the diff should compare raw vs rendered models
- min_intervals: Adjust the plan start date on a per-model basis in order to ensure at least this many intervals are covered on every model when checking for missing intervals
- always_include_local_changes: Usually when restatements are present, local changes in the filesystem are ignored. However, it can be desirable to deploy changes + restatements in the same plan, so this flag overrides the default behaviour.
Returns:
The plan builder.
1796 def apply( 1797 self, 1798 plan: Plan, 1799 circuit_breaker: t.Optional[t.Callable[[], bool]] = None, 1800 ) -> None: 1801 """Applies a plan by pushing snapshots and backfilling data. 1802 1803 Given a plan, it pushes snapshots into the state sync and then uses the scheduler 1804 to backfill all models. 1805 1806 Args: 1807 plan: The plan to apply. 1808 circuit_breaker: An optional handler which checks if the apply should be aborted. 1809 """ 1810 if ( 1811 not plan.context_diff.has_changes 1812 and not plan.requires_backfill 1813 and not plan.has_unmodified_unpromoted 1814 ): 1815 return 1816 if plan.uncategorized: 1817 raise UncategorizedPlanError("Can't apply a plan with uncategorized changes.") 1818 1819 if plan.explain: 1820 explainer = PlanExplainer( 1821 state_reader=self.state_reader, 1822 default_catalog=self.default_catalog, 1823 console=self.console, 1824 ) 1825 explainer.evaluate(plan.to_evaluatable()) 1826 return 1827 1828 self.notification_target_manager.notify( 1829 NotificationEvent.APPLY_START, 1830 environment=plan.environment_naming_info.name, 1831 plan_id=plan.plan_id, 1832 ) 1833 try: 1834 self._apply(plan, circuit_breaker) 1835 except Exception as e: 1836 self.notification_target_manager.notify( 1837 NotificationEvent.APPLY_FAILURE, 1838 environment=plan.environment_naming_info.name, 1839 plan_id=plan.plan_id, 1840 exc=traceback.format_exc(), 1841 ) 1842 logger.info("Plan application failed.", exc_info=e) 1843 raise e 1844 self.notification_target_manager.notify( 1845 NotificationEvent.APPLY_END, 1846 environment=plan.environment_naming_info.name, 1847 plan_id=plan.plan_id, 1848 )
Applies a plan by pushing snapshots and backfilling data.
Given a plan, it pushes snapshots into the state sync and then uses the scheduler to backfill all models.
Arguments:
- plan: The plan to apply.
- circuit_breaker: An optional handler which checks if the apply should be aborted.
1850 @python_api_analytics 1851 def invalidate_environment(self, name: str, sync: bool = False) -> None: 1852 """Invalidates the target environment by setting its expiration timestamp to now. 1853 1854 Args: 1855 name: The name of the environment to invalidate. 1856 sync: If True, the call blocks until the environment is deleted. Otherwise, the environment will 1857 be deleted asynchronously by the janitor process. 1858 """ 1859 name = Environment.sanitize_name(name) 1860 self.state_sync.invalidate_environment(name) 1861 if sync: 1862 self._cleanup_environments(name=name) 1863 self.console.log_success(f"Environment '{name}' deleted.") 1864 else: 1865 self.console.log_success(f"Environment '{name}' invalidated.")
Invalidates the target environment by setting its expiration timestamp to now.
Arguments:
- name: The name of the environment to invalidate.
- sync: If True, the call blocks until the environment is deleted. Otherwise, the environment will be deleted asynchronously by the janitor process.
1867 @python_api_analytics 1868 def diff(self, environment: t.Optional[str] = None, detailed: bool = False) -> bool: 1869 """Show a diff of the current context with a given environment. 1870 1871 Args: 1872 environment: The environment to diff against. 1873 detailed: Show the actual SQL differences if True. 1874 1875 Returns: 1876 True if there are changes, False otherwise. 1877 """ 1878 environment = environment or self.config.default_target_environment 1879 environment = Environment.sanitize_name(environment) 1880 context_diff = self._context_diff(environment) 1881 self.console.show_environment_difference_summary( 1882 context_diff, 1883 no_diff=not detailed, 1884 ) 1885 if context_diff.has_changes: 1886 self.console.show_model_difference_summary( 1887 context_diff, 1888 EnvironmentNamingInfo.from_environment_catalog_mapping( 1889 self.environment_catalog_mapping, 1890 name=environment, 1891 suffix_target=self.config.environment_suffix_target, 1892 normalize_name=context_diff.normalize_environment_name, 1893 ), 1894 self.default_catalog, 1895 no_diff=not detailed, 1896 ) 1897 return context_diff.has_changes
Show a diff of the current context with a given environment.
Arguments:
- environment: The environment to diff against.
- detailed: Show the actual SQL differences if True.
Returns:
True if there are changes, False otherwise.
1899 @python_api_analytics 1900 def table_diff( 1901 self, 1902 source: str, 1903 target: str, 1904 on: t.Optional[t.List[str] | exp.Expr] = None, 1905 skip_columns: t.Optional[t.List[str]] = None, 1906 select_models: t.Optional[t.Collection[str]] = None, 1907 where: t.Optional[str | exp.Expr] = None, 1908 limit: int = 20, 1909 show: bool = True, 1910 show_sample: bool = True, 1911 decimals: int = 3, 1912 skip_grain_check: bool = False, 1913 warn_grain_check: bool = False, 1914 temp_schema: t.Optional[str] = None, 1915 schema_diff_ignore_case: bool = False, 1916 **kwargs: t.Any, # catch-all to prevent an 'unexpected keyword argument' error if an table_diff extension passes in some extra arguments 1917 ) -> t.List[TableDiff]: 1918 """Show a diff between two tables. 1919 1920 Args: 1921 source: The source environment or table. 1922 target: The target environment or table. 1923 on: The join condition, table aliases must be "s" and "t" for source and target. 1924 If omitted, the table's grain will be used. 1925 skip_columns: The columns to skip when computing the table diff. 1926 select_models: The models or snapshots to use when environments are passed in. 1927 where: An optional where statement to filter results. 1928 limit: The limit of the sample dataframe. 1929 show: Show the table diff output in the console. 1930 show_sample: Show the sample dataframe in the console. Requires show=True. 1931 decimals: The number of decimal places to keep when comparing floating point columns. 1932 skip_grain_check: Skip check for rows that contain null or duplicate grains. 1933 temp_schema: The schema to use for temporary tables. 1934 1935 Returns: 1936 The list of TableDiff objects containing schema and summary differences. 1937 """ 1938 1939 if "|" in source or "|" in target: 1940 raise ConfigError( 1941 "Cross-database table diffing is available in Tobiko Cloud. Read more here: " 1942 "https://sqlmesh.readthedocs.io/en/stable/guides/tablediff/#diffing-tables-or-views-across-gateways" 1943 ) 1944 1945 table_diffs: t.List[TableDiff] = [] 1946 1947 # Diffs multiple or a single model across two environments 1948 if select_models: 1949 source_env = self.state_reader.get_environment(source) 1950 target_env = self.state_reader.get_environment(target) 1951 if not source_env: 1952 raise SQLMeshError(f"Could not find environment '{source}'") 1953 if not target_env: 1954 raise SQLMeshError(f"Could not find environment '{target}'") 1955 criteria = ", ".join(f"'{c}'" for c in select_models) 1956 try: 1957 selected_models = self._new_selector().expand_model_selections(select_models) 1958 if not selected_models: 1959 self.console.log_status_update( 1960 f"No models matched the selection criteria: {criteria}" 1961 ) 1962 except Exception as e: 1963 raise SQLMeshError(e) 1964 1965 models_to_diff: t.List[ 1966 t.Tuple[Model, EngineAdapter, str, str, t.Optional[t.List[str] | exp.Expr]] 1967 ] = [] 1968 models_without_grain: t.List[Model] = [] 1969 source_snapshots_to_name = { 1970 snapshot.name: snapshot for snapshot in source_env.snapshots 1971 } 1972 target_snapshots_to_name = { 1973 snapshot.name: snapshot for snapshot in target_env.snapshots 1974 } 1975 1976 for model_fqn in selected_models: 1977 model = self._models[model_fqn] 1978 adapter = self._get_engine_adapter(model.gateway) 1979 source_snapshot = source_snapshots_to_name.get(model.fqn) 1980 target_snapshot = target_snapshots_to_name.get(model.fqn) 1981 1982 if target_snapshot and source_snapshot: 1983 if (source_snapshot.fingerprint != target_snapshot.fingerprint) and ( 1984 (source_snapshot.version != target_snapshot.version) 1985 or source_snapshot.is_forward_only 1986 ): 1987 # Compare the virtual layer instead of the physical layer because the virtual layer is guaranteed to point 1988 # to the correct/active snapshot for the model in the specified environment, taking into account things like dev previews 1989 source = source_snapshot.qualified_view_name.for_environment( 1990 source_env.naming_info, adapter.dialect 1991 ) 1992 target = target_snapshot.qualified_view_name.for_environment( 1993 target_env.naming_info, adapter.dialect 1994 ) 1995 model_on = on or model.on 1996 if not model_on: 1997 models_without_grain.append(model) 1998 else: 1999 models_to_diff.append((model, adapter, source, target, model_on)) 2000 2001 if models_without_grain: 2002 model_names = "\n".join( 2003 f"─ {model.name} \n at '{model._path}'" for model in models_without_grain 2004 ) 2005 message = ( 2006 "SQLMesh doesn't know how to join the tables for the following models:\n" 2007 f"{model_names}\n\n" 2008 "Please specify a `grain` in each model definition. It must be unique and not null." 2009 ) 2010 if warn_grain_check: 2011 self.console.log_warning(message) 2012 else: 2013 raise SQLMeshError(message) 2014 2015 if models_to_diff: 2016 self.console.show_table_diff_details( 2017 [model[0].name for model in models_to_diff], 2018 ) 2019 2020 self.console.start_table_diff_progress(len(models_to_diff)) 2021 try: 2022 tasks_num = min(len(models_to_diff), self.concurrent_tasks) 2023 table_diffs = concurrent_apply_to_values( 2024 list(models_to_diff), 2025 lambda model_info: self._model_diff( 2026 model=model_info[0], 2027 adapter=model_info[1], 2028 source=model_info[2], 2029 target=model_info[3], 2030 on=model_info[4], 2031 source_alias=source_env.name, 2032 target_alias=target_env.name, 2033 limit=limit, 2034 decimals=decimals, 2035 skip_columns=skip_columns, 2036 where=where, 2037 show=show, 2038 temp_schema=temp_schema, 2039 skip_grain_check=skip_grain_check, 2040 schema_diff_ignore_case=schema_diff_ignore_case, 2041 ), 2042 tasks_num=tasks_num, 2043 ) 2044 self.console.stop_table_diff_progress(success=True) 2045 except: 2046 self.console.stop_table_diff_progress(success=False) 2047 raise 2048 elif selected_models: 2049 self.console.log_status_update( 2050 f"No models contain differences with the selection criteria: {criteria}" 2051 ) 2052 2053 else: 2054 table_diffs = [ 2055 self._table_diff( 2056 source=source, 2057 target=target, 2058 source_alias=source, 2059 target_alias=target, 2060 limit=limit, 2061 decimals=decimals, 2062 adapter=self.engine_adapter, 2063 on=on, 2064 skip_columns=skip_columns, 2065 where=where, 2066 schema_diff_ignore_case=schema_diff_ignore_case, 2067 ) 2068 ] 2069 2070 if show: 2071 self.console.show_table_diff(table_diffs, show_sample, skip_grain_check, temp_schema) 2072 2073 return table_diffs
Show a diff between two tables.
Arguments:
- source: The source environment or table.
- target: The target environment or table.
- on: The join condition, table aliases must be "s" and "t" for source and target. If omitted, the table's grain will be used.
- skip_columns: The columns to skip when computing the table diff.
- select_models: The models or snapshots to use when environments are passed in.
- where: An optional where statement to filter results.
- limit: The limit of the sample dataframe.
- show: Show the table diff output in the console.
- show_sample: Show the sample dataframe in the console. Requires show=True.
- decimals: The number of decimal places to keep when comparing floating point columns.
- skip_grain_check: Skip check for rows that contain null or duplicate grains.
- temp_schema: The schema to use for temporary tables.
Returns:
The list of TableDiff objects containing schema and summary differences.
2154 @python_api_analytics 2155 def get_dag( 2156 self, select_models: t.Optional[t.Collection[str]] = None, **options: t.Any 2157 ) -> GraphHTML: 2158 """Gets an HTML object representation of the DAG. 2159 2160 Args: 2161 select_models: A list of model selection strings that should be included in the dag. 2162 Returns: 2163 An html object that renders the dag. 2164 """ 2165 dag = ( 2166 self.dag.prune(*self._new_selector().expand_model_selections(select_models)) 2167 if select_models 2168 else self.dag 2169 ) 2170 2171 nodes = {} 2172 edges: t.List[t.Dict] = [] 2173 2174 for node, deps in dag.graph.items(): 2175 nodes[node] = { 2176 "id": node, 2177 "label": node.split(".")[-1], 2178 "title": f"<span>{node}</span>", 2179 } 2180 edges.extend({"from": d, "to": node} for d in deps) 2181 2182 return GraphHTML( 2183 nodes, 2184 edges, 2185 options={ 2186 "height": "100%", 2187 "width": "100%", 2188 "interaction": {}, 2189 "layout": { 2190 "hierarchical": { 2191 "enabled": True, 2192 "nodeSpacing": 200, 2193 "sortMethod": "directed", 2194 }, 2195 }, 2196 "nodes": { 2197 "shape": "box", 2198 }, 2199 **options, 2200 }, 2201 )
Gets an HTML object representation of the DAG.
Arguments:
- select_models: A list of model selection strings that should be included in the dag.
Returns:
An html object that renders the dag.
2203 @python_api_analytics 2204 def render_dag(self, path: str, select_models: t.Optional[t.Collection[str]] = None) -> None: 2205 """Render the dag as HTML and save it to a file. 2206 2207 Args: 2208 path: filename to save the dag html to 2209 select_models: A list of model selection strings that should be included in the dag. 2210 """ 2211 file_path = Path(path) 2212 suffix = file_path.suffix 2213 if suffix != ".html": 2214 if suffix: 2215 get_console().log_warning( 2216 f"The extension {suffix} does not designate an html file. A file with a `.html` extension will be created instead." 2217 ) 2218 path = str(file_path.with_suffix(".html")) 2219 2220 with open(path, "w", encoding="utf-8") as file: 2221 file.write(str(self.get_dag(select_models)))
Render the dag as HTML and save it to a file.
Arguments:
- path: filename to save the dag html to
- select_models: A list of model selection strings that should be included in the dag.
2223 @python_api_analytics 2224 def create_test( 2225 self, 2226 model: str, 2227 input_queries: t.Dict[str, str], 2228 overwrite: bool = False, 2229 variables: t.Optional[t.Dict[str, str]] = None, 2230 path: t.Optional[str] = None, 2231 name: t.Optional[str] = None, 2232 include_ctes: bool = False, 2233 ) -> None: 2234 """Generate a unit test fixture for a given model. 2235 2236 Args: 2237 model: The model to test. 2238 input_queries: Mapping of model names to queries. Each model included in this mapping 2239 will be populated in the test based on the results of the corresponding query. 2240 overwrite: Whether to overwrite the existing test in case of a file path collision. 2241 When set to False, an error will be raised if there is such a collision. 2242 variables: Key-value pairs that will define variables needed by the model. 2243 path: The file path corresponding to the fixture, relative to the test directory. 2244 By default, the fixture will be created under the test directory and the file name 2245 will be inferred from the test's name. 2246 name: The name of the test. This is inferred from the model name by default. 2247 include_ctes: When true, CTE fixtures will also be generated. 2248 """ 2249 input_queries = { 2250 # The get_model here has two purposes: return normalized names & check for missing deps 2251 self.get_model(dep, raise_if_missing=True).fqn: query 2252 for dep, query in input_queries.items() 2253 } 2254 2255 try: 2256 model_to_test = self.get_model(model, raise_if_missing=True) 2257 test_adapter = self.test_connection_config.create_engine_adapter( 2258 register_comments_override=False 2259 ) 2260 2261 generate_test( 2262 model=model_to_test, 2263 input_queries=input_queries, 2264 models=self._models, 2265 engine_adapter=self._get_engine_adapter(model_to_test.gateway), 2266 test_engine_adapter=test_adapter, 2267 project_path=self.path, 2268 overwrite=overwrite, 2269 variables=variables, 2270 path=path, 2271 name=name, 2272 include_ctes=include_ctes, 2273 ) 2274 finally: 2275 if test_adapter: 2276 test_adapter.close()
Generate a unit test fixture for a given model.
Arguments:
- model: The model to test.
- input_queries: Mapping of model names to queries. Each model included in this mapping will be populated in the test based on the results of the corresponding query.
- overwrite: Whether to overwrite the existing test in case of a file path collision. When set to False, an error will be raised if there is such a collision.
- variables: Key-value pairs that will define variables needed by the model.
- path: The file path corresponding to the fixture, relative to the test directory. By default, the fixture will be created under the test directory and the file name will be inferred from the test's name.
- name: The name of the test. This is inferred from the model name by default.
- include_ctes: When true, CTE fixtures will also be generated.
2278 @python_api_analytics 2279 def test( 2280 self, 2281 match_patterns: t.Optional[t.List[str]] = None, 2282 tests: t.Optional[t.List[str]] = None, 2283 verbosity: Verbosity = Verbosity.DEFAULT, 2284 preserve_fixtures: bool = False, 2285 stream: t.Optional[t.TextIO] = None, 2286 ) -> ModelTextTestResult: 2287 """Discover and run model tests""" 2288 if verbosity >= Verbosity.VERBOSE: 2289 import pandas as pd 2290 2291 pd.set_option("display.max_columns", None) 2292 2293 test_meta = self.select_tests(tests=tests, patterns=match_patterns) 2294 2295 result = run_tests( 2296 model_test_metadata=test_meta, 2297 models=self._models, 2298 config=self.config, 2299 selected_gateway=self.selected_gateway, 2300 dialect=self.default_dialect, 2301 verbosity=verbosity, 2302 preserve_fixtures=preserve_fixtures, 2303 stream=stream, 2304 default_catalog=self.default_catalog, 2305 default_catalog_dialect=self.config.dialect or "", 2306 ) 2307 2308 self.console.log_test_results( 2309 result, 2310 self.test_connection_config._engine_adapter.DIALECT, 2311 ) 2312 2313 return result
Discover and run model tests
2315 @python_api_analytics 2316 def audit( 2317 self, 2318 start: TimeLike, 2319 end: TimeLike, 2320 *, 2321 models: t.Optional[t.Iterator[str]] = None, 2322 execution_time: t.Optional[TimeLike] = None, 2323 ) -> bool: 2324 """Audit models. 2325 2326 Args: 2327 start: The start of the interval to audit. 2328 end: The end of the interval to audit. 2329 models: The models to audit. All models will be audited if not specified. 2330 execution_time: The date/time time reference to use for execution time. Defaults to now. 2331 2332 Returns: 2333 False if any of the audits failed, True otherwise. 2334 """ 2335 2336 snapshots = ( 2337 [self.get_snapshot(model, raise_if_missing=True) for model in models] 2338 if models 2339 else self.snapshots.values() 2340 ) 2341 2342 num_audits = sum(len(snapshot.node.audits_with_args) for snapshot in snapshots) 2343 self.console.log_status_update(f"Found {num_audits} audit(s).") 2344 2345 errors = [] 2346 skipped_count = 0 2347 for snapshot in snapshots: 2348 for audit_result in self.snapshot_evaluator.audit( 2349 snapshot=snapshot, 2350 start=start, 2351 end=end, 2352 execution_time=execution_time, 2353 snapshots=self.snapshots, 2354 ): 2355 audit_id = f"{audit_result.audit.name}" 2356 if audit_result.model: 2357 audit_id += f" on model {audit_result.model.name}" 2358 2359 if audit_result.skipped: 2360 self.console.log_status_update(f"{audit_id} ⏸️ SKIPPED.") 2361 skipped_count += 1 2362 elif audit_result.count: 2363 errors.append(audit_result) 2364 self.console.log_status_update( 2365 f"{audit_id} ❌ [red]FAIL [{audit_result.count}][/red]." 2366 ) 2367 else: 2368 self.console.log_status_update(f"{audit_id} ✅ [green]PASS[/green].") 2369 2370 self.console.log_status_update( 2371 f"\nFinished with {len(errors)} audit error{'' if len(errors) == 1 else 's'} " 2372 f"and {skipped_count} audit{'' if skipped_count == 1 else 's'} skipped." 2373 ) 2374 for error in errors: 2375 self.console.log_status_update( 2376 f"\nFailure in audit {error.audit.name} ({error.audit._path})." 2377 ) 2378 self.console.log_status_update(f"Got {error.count} results, expected 0.") 2379 if error.query: 2380 self.console.show_sql( 2381 f"{error.query.sql(dialect=self.snapshot_evaluator.adapter.dialect)}" 2382 ) 2383 2384 self.console.log_status_update("Done.") 2385 return not errors
Audit models.
Arguments:
- start: The start of the interval to audit.
- end: The end of the interval to audit.
- models: The models to audit. All models will be audited if not specified.
- execution_time: The date/time time reference to use for execution time. Defaults to now.
Returns:
False if any of the audits failed, True otherwise.
2387 @python_api_analytics 2388 def rewrite(self, sql: str, dialect: str = "") -> exp.Expr: 2389 """Rewrite a sql expression with semantic references into an executable query. 2390 2391 https://sqlmesh.readthedocs.io/en/latest/concepts/metrics/overview/ 2392 2393 Args: 2394 sql: The sql string to rewrite. 2395 dialect: The dialect of the sql string, defaults to the project dialect. 2396 2397 Returns: 2398 A SQLGlot expression with semantic references expanded. 2399 """ 2400 return rewrite( 2401 sql, 2402 graph=ReferenceGraph(self.models.values()), 2403 metrics=self._metrics, 2404 dialect=dialect or self.default_dialect, 2405 )
Rewrite a sql expression with semantic references into an executable query.
https://sqlmesh.readthedocs.io/en/latest/concepts/metrics/overview/
Arguments:
- sql: The sql string to rewrite.
- dialect: The dialect of the sql string, defaults to the project dialect.
Returns:
A SQLGlot expression with semantic references expanded.
2407 @python_api_analytics 2408 def check_intervals( 2409 self, 2410 environment: t.Optional[str], 2411 no_signals: bool, 2412 select_models: t.Collection[str], 2413 start: t.Optional[TimeLike] = None, 2414 end: t.Optional[TimeLike] = None, 2415 ) -> t.Dict[Snapshot, SnapshotIntervals]: 2416 """Check intervals for a given environment. 2417 2418 Args: 2419 environment: The environment or prod if None. 2420 select_models: A list of model selection strings to show intervals for. 2421 start: The start of the intervals to check. 2422 end: The end of the intervals to check. 2423 """ 2424 2425 environment = environment or c.PROD 2426 env = self.state_reader.get_environment(environment) 2427 if not env: 2428 raise SQLMeshError(f"Environment '{environment}' was not found.") 2429 2430 snapshots = {k.name: v for k, v in self.state_sync.get_snapshots(env.snapshots).items()} 2431 2432 missing = { 2433 k.name: v 2434 for k, v in missing_intervals( 2435 snapshots.values(), start=start, end=end, execution_time=end 2436 ).items() 2437 } 2438 2439 if select_models: 2440 selected: t.Collection[str] = self._select_models_for_run( 2441 select_models, True, snapshots.values() 2442 ) 2443 else: 2444 selected = snapshots.keys() 2445 2446 results = {} 2447 execution_context = self.execution_context(snapshots=snapshots) 2448 2449 for fqn in selected: 2450 snapshot = snapshots[fqn] 2451 intervals = missing.get(fqn) or [] 2452 2453 results[snapshot] = SnapshotIntervals( 2454 snapshot.snapshot_id, 2455 intervals 2456 if no_signals 2457 else snapshot.check_ready_intervals(intervals, execution_context), 2458 ) 2459 2460 return results
Check intervals for a given environment.
Arguments:
- environment: The environment or prod if None.
- select_models: A list of model selection strings to show intervals for.
- start: The start of the intervals to check.
- end: The end of the intervals to check.
2462 @python_api_analytics 2463 def migrate(self) -> None: 2464 """Migrates SQLMesh to the current running version. 2465 2466 Please contact your SQLMesh administrator before doing this. 2467 """ 2468 self.notification_target_manager.notify(NotificationEvent.MIGRATION_START) 2469 self._load_materializations() 2470 try: 2471 self._new_state_sync().migrate( 2472 promoted_snapshots_only=self.config.migration.promoted_snapshots_only, 2473 ) 2474 except Exception as e: 2475 self.notification_target_manager.notify( 2476 NotificationEvent.MIGRATION_FAILURE, traceback.format_exc() 2477 ) 2478 raise e 2479 self.notification_target_manager.notify(NotificationEvent.MIGRATION_END)
Migrates SQLMesh to the current running version.
Please contact your SQLMesh administrator before doing this.
2481 @python_api_analytics 2482 def rollback(self) -> None: 2483 """Rolls back SQLMesh to the previous migration. 2484 2485 Please contact your SQLMesh administrator before doing this. This action cannot be undone. 2486 """ 2487 self._new_state_sync().rollback()
Rolls back SQLMesh to the previous migration.
Please contact your SQLMesh administrator before doing this. This action cannot be undone.
2489 @python_api_analytics 2490 def create_external_models(self, strict: bool = False) -> None: 2491 """Create a file to document the schema of external models. 2492 2493 The external models file contains all columns and types of external models, allowing for more 2494 robust lineage, validation, and optimizations. 2495 2496 Args: 2497 strict: If True, raise an error if the external model is missing in the database. 2498 """ 2499 if not self._models: 2500 self.load(update_schemas=False) 2501 2502 for path, config in self.configs.items(): 2503 deprecated_yaml = path / c.EXTERNAL_MODELS_DEPRECATED_YAML 2504 2505 external_models_yaml = ( 2506 path / c.EXTERNAL_MODELS_YAML if not deprecated_yaml.exists() else deprecated_yaml 2507 ) 2508 2509 external_models_gateway: t.Optional[str] = self.gateway or self.config.default_gateway 2510 if not external_models_gateway: 2511 # can happen if there was no --gateway defined and the default_gateway is '' 2512 # which means that the single gateway syntax is being used which means there is 2513 # no named gateway which means we should not stamp `gateway:` on the external models 2514 external_models_gateway = None 2515 2516 create_external_models_file( 2517 path=external_models_yaml, 2518 models=UniqueKeyDict( 2519 "models", 2520 { 2521 fqn: model 2522 for fqn, model in self._models.items() 2523 if self.config_for_node(model) is config 2524 }, 2525 ), 2526 adapter=self.engine_adapter, 2527 state_reader=self.state_reader, 2528 dialect=config.model_defaults.dialect, 2529 gateway=external_models_gateway, 2530 max_workers=self.concurrent_tasks, 2531 strict=strict, 2532 all_models=self._models, 2533 )
Create a file to document the schema of external models.
The external models file contains all columns and types of external models, allowing for more robust lineage, validation, and optimizations.
Arguments:
- strict: If True, raise an error if the external model is missing in the database.
2535 @python_api_analytics 2536 def print_info( 2537 self, skip_connection: bool = False, verbosity: Verbosity = Verbosity.DEFAULT 2538 ) -> None: 2539 """Prints information about connections, models, macros, etc. to the console.""" 2540 self.console.log_status_update(f"Models: {len(self.models)}") 2541 self.console.log_status_update(f"Macros: {len(self._macros) - len(macro.get_registry())}") 2542 2543 if skip_connection: 2544 return 2545 2546 if verbosity >= Verbosity.VERBOSE: 2547 self.console.log_status_update("") 2548 print_config(self.config.get_connection(self.gateway), self.console, "Connection") 2549 print_config( 2550 self.config.get_test_connection(self.gateway), self.console, "Test Connection" 2551 ) 2552 print_config( 2553 self.config.get_state_connection(self.gateway), self.console, "State Connection" 2554 ) 2555 2556 self._try_connection("data warehouse", self.engine_adapter.ping) 2557 state_connection = self.config.get_state_connection(self.gateway) 2558 if state_connection: 2559 self._try_connection("state backend", state_connection.connection_validator())
Prints information about connections, models, macros, etc. to the console.
2561 @python_api_analytics 2562 def print_environment_names(self) -> None: 2563 """Prints all environment names along with expiry datetime.""" 2564 result = self._new_state_sync().get_environments_summary() 2565 if not result: 2566 raise SQLMeshError( 2567 "This project has no environments. Create an environment using the `sqlmesh plan` command." 2568 ) 2569 self.console.print_environments(result)
Prints all environment names along with expiry datetime.
2571 def close(self) -> None: 2572 """Releases all resources allocated by this context.""" 2573 if self._snapshot_evaluator: 2574 self._snapshot_evaluator.close() 2575 2576 if self._state_sync: 2577 self._state_sync.close()
Releases all resources allocated by this context.
2632 @python_api_analytics 2633 def table_name( 2634 self, model_name: str, environment: t.Optional[str] = None, prod: bool = False 2635 ) -> str: 2636 """Returns the name of the pysical table for the given model name in the target environment. 2637 2638 Args: 2639 model_name: The name of the model. 2640 environment: The environment to source the model version from. 2641 prod: If True, return the name of the physical table that will be used in production for the model version 2642 promoted in the target environment. 2643 2644 Returns: 2645 The name of the physical table. 2646 """ 2647 environment = environment or self.config.default_target_environment 2648 fqn = self._node_or_snapshot_to_fqn(model_name) 2649 target_env = self.state_reader.get_environment(environment) 2650 if not target_env: 2651 raise SQLMeshError(f"Environment '{environment}' was not found.") 2652 2653 snapshot_info = None 2654 for s in target_env.snapshots: 2655 if s.name == fqn: 2656 snapshot_info = s 2657 break 2658 if not snapshot_info: 2659 raise SQLMeshError( 2660 f"Model '{model_name}' was not found in environment '{environment}'." 2661 ) 2662 2663 if target_env.name == c.PROD or prod: 2664 return snapshot_info.table_name() 2665 2666 snapshots = self.state_reader.get_snapshots(target_env.snapshots) 2667 deployability_index = DeployabilityIndex.create(snapshots) 2668 2669 return snapshot_info.table_name( 2670 is_deployable=deployability_index.is_deployable(snapshot_info.snapshot_id) 2671 )
Returns the name of the pysical table for the given model name in the target environment.
Arguments:
- model_name: The name of the model.
- environment: The environment to source the model version from.
- prod: If True, return the name of the physical table that will be used in production for the model version promoted in the target environment.
Returns:
The name of the physical table.
2673 def clear_caches(self) -> None: 2674 paths_to_remove = [path / c.CACHE for path in self.configs] 2675 paths_to_remove.append(self.cache_dir) 2676 2677 if IS_WINDOWS: 2678 paths_to_remove = [fix_windows_path(path) for path in paths_to_remove] 2679 2680 for path in paths_to_remove: 2681 if path.exists(): 2682 rmtree(path) 2683 2684 if isinstance(self._state_sync, CachingStateSync): 2685 self._state_sync.clear_cache()
2687 def export_state( 2688 self, 2689 output_file: Path, 2690 environment_names: t.Optional[t.List[str]] = None, 2691 local_only: bool = False, 2692 confirm: bool = True, 2693 ) -> None: 2694 from sqlmesh.core.state_sync.export_import import export_state 2695 2696 # trigger a connection to the StateSync so we can fail early if there is a problem 2697 # note we still need to do this even if we are doing a local export so we know what 'versions' to write 2698 self.state_sync.get_versions(validate=True) 2699 2700 local_snapshots = self.snapshots if local_only else None 2701 2702 if self.console.start_state_export( 2703 output_file=output_file, 2704 gateway=self.selected_gateway, 2705 state_connection_config=self._state_connection_config, 2706 environment_names=environment_names, 2707 local_only=local_only, 2708 confirm=confirm, 2709 ): 2710 try: 2711 export_state( 2712 state_sync=self.state_sync, 2713 output_file=output_file, 2714 local_snapshots=local_snapshots, 2715 environment_names=environment_names, 2716 console=self.console, 2717 ) 2718 self.console.stop_state_export(success=True, output_file=output_file) 2719 except: 2720 self.console.stop_state_export(success=False, output_file=output_file) 2721 raise
2723 def import_state(self, input_file: Path, clear: bool = False, confirm: bool = True) -> None: 2724 from sqlmesh.core.state_sync.export_import import import_state 2725 2726 if self.console.start_state_import( 2727 input_file=input_file, 2728 gateway=self.selected_gateway, 2729 state_connection_config=self._state_connection_config, 2730 clear=clear, 2731 confirm=confirm, 2732 ): 2733 try: 2734 import_state( 2735 state_sync=self.state_sync, 2736 input_file=input_file, 2737 clear=clear, 2738 console=self.console, 2739 ) 2740 self.console.stop_state_import(success=True, input_file=input_file) 2741 except: 2742 self.console.stop_state_import(success=False, input_file=input_file) 2743 raise
2838 @cached_property 2839 def cache_dir(self) -> Path: 2840 if self.config.cache_dir: 2841 cache_path = Path(self.config.cache_dir) 2842 if cache_path.is_absolute(): 2843 return cache_path 2844 return self.path / cache_path 2845 2846 # Default to .cache directory in the project path 2847 return self.path / c.CACHE
2849 @cached_property 2850 def engine_adapters(self) -> t.Dict[str, EngineAdapter]: 2851 """Returns all the engine adapters for the gateways defined in the configurations.""" 2852 adapters: t.Dict[str, EngineAdapter] = {self.selected_gateway: self.engine_adapter} 2853 for config in self.configs.values(): 2854 for gateway_name in config.gateways: 2855 if gateway_name not in adapters: 2856 connection = config.get_connection(gateway_name) 2857 adapter = connection.create_engine_adapter( 2858 concurrent_tasks=self.concurrent_tasks, 2859 ) 2860 adapters[gateway_name] = adapter 2861 return adapters
Returns all the engine adapters for the gateways defined in the configurations.
2863 @cached_property 2864 def default_catalog_per_gateway(self) -> t.Dict[str, str]: 2865 """Returns the default catalogs for each engine adapter.""" 2866 return self._scheduler.get_default_catalog_per_gateway(self)
Returns the default catalogs for each engine adapter.
2886 @cached_property 2887 def environment_catalog_mapping(self) -> RegexKeyDict: 2888 engine_adapter = None 2889 try: 2890 engine_adapter = self.engine_adapter 2891 except Exception: 2892 pass 2893 2894 if ( 2895 self.config.environment_catalog_mapping 2896 and engine_adapter 2897 and not self.engine_adapter.catalog_support.is_multi_catalog_supported 2898 ): 2899 raise SQLMeshError( 2900 "Environment catalog mapping is only supported for engine adapters that support multiple catalogs" 2901 ) 2902 return self.config.environment_catalog_mapping
3339 def lint_models( 3340 self, 3341 models: t.Optional[t.Iterable[t.Union[str, Model]]] = None, 3342 raise_on_error: bool = True, 3343 ) -> t.List[AnnotatedRuleViolation]: 3344 found_error = False 3345 3346 model_list = ( 3347 list(self.get_model(model, raise_if_missing=True) for model in models) 3348 if models 3349 else self.models.values() 3350 ) 3351 all_violations = [] 3352 for model in model_list: 3353 # Linter may be `None` if the context is not loaded yet 3354 if linter := self._linters.get(model.project): 3355 lint_violation, violations = ( 3356 linter.lint_model(model, self, console=self.console) or found_error 3357 ) 3358 if lint_violation: 3359 found_error = True 3360 all_violations.extend(violations) 3361 3362 if raise_on_error and found_error: 3363 raise LinterError( 3364 "Linter detected errors in the code. Please fix them before proceeding." 3365 ) 3366 3367 return all_violations
3369 def select_tests( 3370 self, 3371 tests: t.Optional[t.List[str]] = None, 3372 patterns: t.Optional[t.List[str]] = None, 3373 ) -> t.List[ModelTestMetadata]: 3374 """Filter pre-loaded test metadata based on tests and patterns.""" 3375 3376 test_meta = self._model_test_metadata 3377 3378 if tests: 3379 filtered_tests = [] 3380 for test in tests: 3381 if "::" in test: 3382 if test in self._model_test_metadata_fully_qualified_name_index: 3383 filtered_tests.append( 3384 self._model_test_metadata_fully_qualified_name_index[test] 3385 ) 3386 else: 3387 test_path = Path(test) 3388 if test_path in self._model_test_metadata_path_index: 3389 filtered_tests.extend(self._model_test_metadata_path_index[test_path]) 3390 3391 test_meta = filtered_tests 3392 3393 if patterns: 3394 test_meta = filter_tests_by_patterns(test_meta, patterns) 3395 3396 return test_meta
Filter pre-loaded test metadata based on tests and patterns.
Encapsulates a SQLMesh environment supplying convenient functions to perform various tasks.
Arguments:
- notification_targets: The notification target to use. Defaults to what is defined in config.
- paths: The directories containing SQLMesh files.
- config: A Config object or the name of a Config object in config.py.
- connection: The name of the connection. If not specified the first connection as it appears in configuration will be used.
- test_connection: The name of the connection to use for tests. If not specified the first connection as it appears in configuration will be used.
- concurrent_tasks: The maximum number of tasks that can use the connection concurrently.
- load: Whether or not to automatically load all models and macros (default True).
- load_state: Whether to merge remote state into the local project during load (default True). Only intended for local-only operations like format; plan/apply in multi-repo projects require it to see models owned by other projects.
- console: The rich instance used for printing out CLI command results.
- users: A list of users to make known to SQLMesh.
The type of config object to use (default: Config).
Inherited Members
- GenericContext
- GenericContext
- PLAN_BUILDER_TYPE
- configs
- dag
- gateway
- environment_ttl
- pinned_environments
- auto_categorize_changes
- selected_gateway
- console
- notification_targets
- users
- default_dialect
- engine_adapter
- snapshot_evaluator
- execution_context
- upsert_model
- scheduler
- create_scheduler
- state_sync
- state_reader
- refresh
- load
- run
- run_janitor
- destroy
- get_model
- get_snapshot
- config_for_path
- config_for_node
- models
- metrics
- standalone_audits
- models_with_tests
- snapshots
- requirements
- default_catalog
- render
- evaluate
- format
- plan
- plan_builder
- apply
- invalidate_environment
- diff
- table_diff
- get_dag
- render_dag
- create_test
- test
- audit
- rewrite
- check_intervals
- migrate
- rollback
- create_external_models
- print_info
- print_environment_names
- close
- table_name
- clear_caches
- export_state
- import_state
- cache_dir
- engine_adapters
- default_catalog_per_gateway
- concurrent_tasks
- connection_config
- test_connection_config
- environment_catalog_mapping
- lint_models
- select_tests