Repository: TobikoData/sqlmesh
Pull request #4588 (2025-05-30 04:36:40)

Chore: improve error message when no MODEL block
Old
```
The MODEL statement is required as the first statement in the definition, unless model name inference is enabled.
```

New
```
Please add a MODEL block at the top of the file. Example:

MODEL (
  name sqlmesh_example.full_model, --model name
  kind VIEW, --materialization
  cron '@daily', --schedule
);

Learn more at https://sqlmesh.readthedocs.io/en/stable/concepts/models/overview
```


Patch:
diff --git a/docs/integrations/engines/duckdb.md b/docs/integrations/engines/duckdb.md
index 0b593ce4..33751cda 100644
--- a/docs/integrations/engines/duckdb.md
+++ b/docs/integrations/engines/duckdb.md
@@ -64,6 +64,53 @@ SQLMesh will place models with the explicit catalog "ephemeral", such as `epheme
     )
     ```
 
+#### DuckLake Catalog Example
+
+=== "YAML"
+
+    ```yaml linenums="1"
+    gateways:
+      my_gateway:
+        connection:
+          type: duckdb
+          catalogs:
+            ducklake:
+              type: ducklake
+              path: 'catalog.ducklake'
+              data_path: data/ducklake
+              encrypted: True
+    ```
+    
+=== "Python"
+
+    ```python linenums="1"
+    from sqlmesh.core.config import (
+        Config,
+        ModelDefaultsConfig,
+        GatewayConfig,
+        DuckDBConnectionConfig
+    )
+    from sqlmesh.core.config.connection import DuckDBAttachOptions
+
+    config = Config(
+        model_defaults=ModelDefaultsConfig(dialect=<dialect>),
+        gateways={
+            "my_gateway": GatewayConfig(
+                connection=DuckDBConnectionConfig(
+                    catalogs={
+                        "ducklake": DuckDBAttachOptions(
+                            type="ducklake",
+                            path="catalog.ducklake",
+                            data_path="data/ducklake",
+                            encrypted=True
+                        ),
+                    }
+                )
+            ),
+        }
+    )
+    ```
+
 #### Other Connection Catalogs Example
 
 Catalogs can also be defined to connect to anything that [DuckDB can be attached to](https://duckdb.org/docs/sql/statements/attach.html).
diff --git a/sqlmesh/core/config/connection.py b/sqlmesh/core/config/connection.py
index 9cc322d9..b932e5d3 100644
--- a/sqlmesh/core/config/connection.py
+++ b/sqlmesh/core/config/connection.py
@@ -221,6 +221,10 @@ class DuckDBAttachOptions(BaseConfig):
     path: str
     read_only: bool = False
 
+    # DuckLake specific options
+    data_path: t.Optional[str] = None
+    encrypted: bool = False
+
     def to_sql(self, alias: str) -> str:
         options = []
         # 'duckdb' is actually not a supported type, but we'd like to allow it for
@@ -229,6 +233,14 @@ class DuckDBAttachOptions(BaseConfig):
             options.append(f"TYPE {self.type.upper()}")
         if self.read_only:
             options.append("READ_ONLY")
+
+        # DuckLake specific options
+        if self.type == "ducklake":
+            if self.data_path:
+                options.append(f"DATA_PATH '{self.data_path}'")
+            if self.encrypted:
+                options.append("ENCRYPTED")
+
         options_sql = f" ({', '.join(options)})" if options else ""
         alias_sql = ""
         # TODO: Add support for Postgres schema. Currently adding it blocks access to the information_schema
diff --git a/sqlmesh/core/loader.py b/sqlmesh/core/loader.py
index 5965e6c1..ee0af270 100644
--- a/sqlmesh/core/loader.py
+++ b/sqlmesh/core/loader.py
@@ -452,10 +452,12 @@ class Loader(abc.ABC):
         self._path_mtimes[path] = path.stat().st_mtime
 
     def _failed_to_load_model_error(self, path: Path, error: t.Union[str, Exception]) -> str:
-        base_message = f"Failed to load model definition at '{path}':"
+        base_message = f"Failed to load model from file '{path}':"
         if isinstance(error, ValidationError):
             return validation_error_message(error, base_message)
-        return f"{base_message}\n  {error}"
+        # indent all lines of error message
+        error_message = str(error).replace("\n", "\n  ")
+        return f"{base_message}\n\n  {error_message}"
 
 
 class SqlMeshLoader(Loader):
diff --git a/sqlmesh/core/model/definition.py b/sqlmesh/core/model/definition.py
index a0d52bc5..4ca86566 100644
--- a/sqlmesh/core/model/definition.py
+++ b/sqlmesh/core/model/definition.py
@@ -2036,18 +2036,25 @@ def load_sql_based_model(
         variables: The variables to pass to the model.
         kwargs: Additional kwargs to pass to the loader.
     """
+    missing_model_msg = f"""Please add a MODEL block at the top of the file. Example:
+
+MODEL (
+  name sqlmesh_example.full_model, --model name
+  kind FULL, --materialization
+  cron '@daily', --schedule
+);
+
+Learn more at https://sqlmesh.readthedocs.io/en/stable/concepts/models/overview
+"""
+
     if not expressions:
-        raise_config_error("Incomplete model definition, missing MODEL statement", path)
+        raise_config_error(missing_model_msg)
 
     dialect = dialect or ""
     meta = expressions[0]
     if not isinstance(meta, d.Model):
         if not infer_names:
-            raise_config_error(
-                "The MODEL statement is required as the first statement in the definition, "
-                "unless model name inference is enabled.",
-                path,
-            )
+            raise_config_error(missing_model_msg)
         meta = d.Model(expressions=[])  # Dummy meta node
         expressions.insert(0, meta)
 
diff --git a/sqlmesh/lsp/context.py b/sqlmesh/lsp/context.py
index 89ca2365..6fd0ddd5 100644
--- a/sqlmesh/lsp/context.py
+++ b/sqlmesh/lsp/context.py
@@ -4,6 +4,7 @@ from sqlmesh.core.context import Context
 import typing as t
 
 from sqlmesh.core.model.definition import SqlModel
+from sqlmesh.core.linter.definition import AnnotatedRuleViolation
 from sqlmesh.lsp.custom import RenderModelEntry
 from sqlmesh.lsp.uri import URI
 
@@ -28,8 +29,14 @@ class LSPContext:
     model names and standalone audit names.
     """
 
+    map: t.Dict[Path, t.Union[ModelTarget, AuditTarget]]
+    _render_cache: t.Dict[Path, t.List[RenderModelEntry]]
+    _lint_cache: t.Dict[Path, t.List[AnnotatedRuleViolation]]
+
     def __init__(self, context: Context) -> None:
         self.context = context
+        self._render_cache = {}
+        self._lint_cache = {}
 
         # Add models to the map
         model_map: t.Dict[Path, ModelTarget] = {}
@@ -54,36 +61,90 @@ class LSPContext:
             **audit_map,
         }
 
+    def render_model(self, uri: URI) -> t.List[RenderModelEntry]:
+        """Get rendered models for a file, using cache when available.
 
-def render_model(context: LSPContext, uri: URI) -> t.Iterator[RenderModelEntry]:
-    target = context.map[uri.to_path()]
-    if isinstance(target, AuditTarget):
-        audit = context.context.standalone_audits[target.name]
-        definition = audit.render_definition(
-            include_python=False,
-            render_query=True,
-        )
-        rendered_query = [render.sql(dialect=audit.dialect, pretty=True) for render in definition]
-        yield RenderModelEntry(
-            name=audit.name,
-            fqn=audit.fqn,
-            description=audit.description,
-            rendered_query="\n\n".join(rendered_query),
-        )
-    if isinstance(target, ModelTarget):
-        for name in target.names:
-            model = context.context.get_model(name)
-            if isinstance(model, SqlModel):
-                rendered_query = [
-                    render.sql(dialect=model.dialect, pretty=True)
-                    for render in model.render_definition(
-                        include_python=False,
-                        render_query=True,
+        Args:
+            uri: The URI of the file to render.
+
+        Returns:
+            List of rendered model entries.
+        """
+        path = uri.to_path()
+
+        # Check cache first
+        if path in self._render_cache:
+            return self._render_cache[path]
+
+        # If not cached, render and cache
+        entries: t.List[RenderModelEntry] = []
+        target = self.map.get(path)
+
+        if isinstance(target, AuditTarget):
+            audit = self.context.standalone_audits[target.name]
+            definition = audit.render_definition(
+                include_python=False,
+                render_query=True,
+            )
+            rendered_query = [
+                render.sql(dialect=audit.dialect, pretty=True) for render in definition
+            ]
+            entry = RenderModelEntry(
+                name=audit.name,
+                fqn=audit.fqn,
+                description=audit.description,
+                rendered_query="\n\n".join(rendered_query),
+            )
+            entries.append(entry)
+
+        elif isinstance(target, ModelTarget):
+            for name in target.names:
+                model = self.context.get_model(name)
+                if isinstance(model, SqlModel):
+                    rendered_query = [
+                        render.sql(dialect=model.dialect, pretty=True)
+                        for render in model.render_definition(
+                            include_python=False,
+                            render_query=True,
+                        )
+                    ]
+                    entry = RenderModelEntry(
+                        name=model.name,
+                        fqn=model.fqn,
+                        description=model.description,
+                        rendered_query="\n\n".join(rendered_query),
                     )
-                ]
-                yield RenderModelEntry(
-                    name=model.name,
-                    fqn=model.fqn,
-                    description=model.description,
-                    rendered_query="\n\n".join(rendered_query),
-                )
+                    entries.append(entry)
+
+        # Store in cache
+        self._render_cache[path] = entries
+        return entries
+
+    def lint_model(self, uri: URI) -> t.List[AnnotatedRuleViolation]:
+        """Get lint diagnostics for a model, using cache when available.
+
+        Args:
+            uri: The URI of the file to lint.
+
+        Returns:
+            List of annotated rule violations.
+        """
+        path = uri.to_path()
+
+        # Check cache first
+        if path in self._lint_cache:
+            return self._lint_cache[path]
+
+        # If not cached, lint and cache
+        target = self.map.get(path)
+        if target is None or not isinstance(target, ModelTarget):
+            return []
+
+        diagnostics = self.context.lint_models(
+            target.names,
+            raise_on_error=False,
+        )
+
+        # Store in cache
+        self._lint_cache[path] = diagnostics
+        return diagnostics
diff --git a/sqlmesh/lsp/main.py b/sqlmesh/lsp/main.py
index 3d04f0d5..203a208b 100644
--- a/sqlmesh/lsp/main.py
+++ b/sqlmesh/lsp/main.py
@@ -24,7 +24,6 @@ from sqlmesh.lsp.completions import get_sql_completions
 from sqlmesh.lsp.context import (
     LSPContext,
     ModelTarget,
-    render_model as render_model_context,
 )
 from sqlmesh.lsp.custom import (
     ALL_MODELS_FEATURE,
@@ -58,10 +57,6 @@ class SQLMeshLanguageServer:
         self.context_class = context_class
         self.lsp_context: t.Optional[LSPContext] = None
 
-        # Cache stores tuples of (diagnostics, diagnostic_version)
-        self.lint_cache: t.Dict[URI, t.Tuple[t.List[AnnotatedRuleViolation], int]] = {}
-        self._diagnostic_version_counter: int = 0
-
         self.client_supports_pull_diagnostics = False
         # Register LSP features (e.g., formatting, hover, etc.)
         self._register_features()
@@ -120,7 +115,7 @@ class SQLMeshLanguageServer:
         def render_model(ls: LanguageServer, params: RenderModelRequest) -> RenderModelResponse:
             uri = URI(params.textDocumentUri)
             context = self._context_get_or_load(uri)
-            return RenderModelResponse(models=list(render_model_context(context, uri)))
+            return RenderModelResponse(models=context.render_model(uri))
 
         @self.server.feature(API_FEATURE)
         def api(ls: LanguageServer, request: ApiRequest) -> t.Dict[str, t.Any]:
@@ -173,17 +168,11 @@ class SQLMeshLanguageServer:
             if models is None or not isinstance(models, ModelTarget):
                 return
 
-            if self.lint_cache.get(uri) is None:
-                diagnostics = context.context.lint_models(
-                    models.names,
-                    raise_on_error=False,
-                )
-                self._diagnostic_version_counter += 1
-                self.lint_cache[uri] = (diagnostics, self._diagnostic_version_counter)
+            # Get diagnostics from context (which handles caching)
+            diagnostics = context.lint_model(uri)
 
             # Only publish diagnostics if client doesn't support pull diagnostics
             if not self.client_supports_pull_diagnostics:
-                diagnostics, _ = self.lint_cache[uri]
                 ls.publish_diagnostics(
                     params.text_document.uri,
                     SQLMeshLanguageServer._diagnostics_to_lsp_diagnostics(diagnostics),
@@ -197,13 +186,8 @@ class SQLMeshLanguageServer:
             if models is None or not isinstance(models, ModelTarget):
                 return
 
-            # Always update the cache
-            diagnostics = context.context.lint_models(
-                models.names,
-                raise_on_error=False,
-            )
-            self._diagnostic_version_counter += 1
-            self.lint_cache[uri] = (diagnostics, self._diagnostic_version_counter)
+            # Get diagnostics from context (which handles caching)
+            diagnostics = context.lint_model(uri)
 
             # Only publish diagnostics if client doesn't support pull diagnostics
             if not self.client_supports_pull_diagnostics:
@@ -220,13 +204,8 @@ class SQLMeshLanguageServer:
             if models is None or not isinstance(models, ModelTarget):
                 return
 
-            # Always update the cache
-            diagnostics = context.context.lint_models(
-                models.names,
-                raise_on_error=False,
-            )
-            self._diagnostic_version_counter += 1
-            self.lint_cache[uri] = (diagnostics, self._diagnostic_version_counter)
+            # Get diagnostics from context (which handles caching)
+            diagnostics = context.lint_model(uri)
 
             # Only publish diagnostics if client doesn't support pull diagnostics
             if not self.client_supports_pull_diagnostics:
@@ -445,31 +424,16 @@ class SQLMeshLanguageServer:
                 return types.WorkspaceDiagnosticReport(items=[])
 
     def _get_diagnostics_for_uri(self, uri: URI) -> t.Tuple[t.List[types.Diagnostic], int]:
-        """Get diagnostics for a specific URI, returning (diagnostics, result_id)."""
-        # Check if we have cached diagnostics
-        if uri in self.lint_cache:
-            diagnostics, result_id = self.lint_cache[uri]
-            return SQLMeshLanguageServer._diagnostics_to_lsp_diagnostics(diagnostics), result_id
+        """Get diagnostics for a specific URI, returning (diagnostics, result_id).
 
-        # Try to get diagnostics by loading context and linting
+        Since we no longer track version numbers, we always return 0 as the result_id.
+        This means pull diagnostics will always fetch fresh results.
+        """
         try:
             context = self._context_get_or_load(uri)
-            models = context.map[uri.to_path()]
-            if models is None or not isinstance(models, ModelTarget):
-                return [], 0
-
-            # Lint the models and cache the results
-            diagnostics = context.context.lint_models(
-                models.names,
-                raise_on_error=False,
-            )
-            self._diagnostic_version_counter += 1
-            self.lint_cache[uri] = (diagnostics, self._diagnostic_version_counter)
-            return SQLMeshLanguageServer._diagnostics_to_lsp_diagnostics(
-                diagnostics
-            ), self._diagnostic_version_counter
+            diagnostics = context.lint_model(uri)
+            return SQLMeshLanguageServer._diagnostics_to_lsp_diagnostics(diagnostics), 0
         except Exception:
-            # If we can't get diagnostics, return empty list with no result ID
             return [], 0
 
     def _context_get_or_load(self, document_uri: URI) -> LSPContext:
@@ -523,7 +487,7 @@ class SQLMeshLanguageServer:
                         created_context = self.context_class(paths=[path])
                         self.lsp_context = LSPContext(created_context)
                         loaded = True
-                        # Re-check context for document now that it's loaded
+                        # Re-check context for the document now that it's loaded
                         return self._ensure_context_for_document(document_uri)
                     except Exception as e:
                         self.server.show_message(
diff --git a/sqlmesh/magics.py b/sqlmesh/magics.py
index 6929cc81..d77120c7 100644
--- a/sqlmesh/magics.py
+++ b/sqlmesh/magics.py
@@ -32,7 +32,7 @@ from sqlmesh.core.context import Context
 from sqlmesh.core.dialect import format_model_expressions, parse
 from sqlmesh.core.model import load_sql_based_model
 from sqlmesh.core.test import ModelTestMetadata
-from sqlmesh.utils import sqlglot_dialects, yaml, Verbosity
+from sqlmesh.utils import sqlglot_dialects, yaml, Verbosity, optional_import
 from sqlmesh.utils.errors import MagicError, MissingContextException, SQLMeshError
 
 logger = logging.getLogger(__name__)
@@ -548,6 +548,8 @@ class SQLMeshMagics(Magics):
     def evaluate(self, context: Context, line: str) -> None:
         """Evaluate a model query and fetches a dataframe."""
         context.refresh()
+
+        snowpark = optional_import("snowflake.snowpark")
         args = parse_argstring(self.evaluate, line)
 
         df = context.evaluate(
@@ -557,6 +559,10 @@ class SQLMeshMagics(Magics):
             execution_time=args.execution_time,
             limit=args.limit,
         )
+
+        if snowpark and isinstance(df, snowpark.DataFrame):
+            df = df.limit(args.limit or 100).to_pandas()
+
         self.display(df)
 
     @magic_arguments()


Test patch:
diff --git a/tests/core/test_connection_config.py b/tests/core/test_connection_config.py
index f30aba11..9f91ddd5 100644
--- a/tests/core/test_connection_config.py
+++ b/tests/core/test_connection_config.py
@@ -602,6 +602,30 @@ def test_duckdb_attach_catalog(make_config):
     assert not config.is_recommended_for_state_sync
 
 
+def test_duckdb_attach_ducklake_catalog(make_config):
+    config = make_config(
+        type="duckdb",
+        catalogs={
+            "ducklake": DuckDBAttachOptions(
+                type="ducklake",
+                path="catalog.ducklake",
+                data_path="/tmp/ducklake_data",
+                encrypted=True,
+            ),
+        },
+    )
+    assert isinstance(config, DuckDBConnectionConfig)
+    ducklake_catalog = config.catalogs.get("ducklake")
+    assert ducklake_catalog is not None
+    assert ducklake_catalog.type == "ducklake"
+    assert ducklake_catalog.path == "catalog.ducklake"
+    assert ducklake_catalog.data_path == "/tmp/ducklake_data"
+    assert ducklake_catalog.encrypted is True
+    # Check that the generated SQL includes DATA_PATH
+    assert "DATA_PATH '/tmp/ducklake_data'" in ducklake_catalog.to_sql("ducklake")
+    assert "ENCRYPTED" in ducklake_catalog.to_sql("ducklake")
+
+
 def test_duckdb_attach_options():
     options = DuckDBAttachOptions(
         type="postgres", path="dbname=postgres user=postgres host=127.0.0.1", read_only=True
diff --git a/tests/core/test_loader.py b/tests/core/test_loader.py
index 6ee9a440..b194139d 100644
--- a/tests/core/test_loader.py
+++ b/tests/core/test_loader.py
@@ -159,7 +159,7 @@ def execute(
 
     with pytest.raises(
         ConfigError,
-        match=r"Failed to load model definition at '.*'.\n  Duplicate name: 'test_schema.test_model'.",
+        match=r"Failed to load model from file '.*'.\n\n  Duplicate name: 'test_schema.test_model'.",
     ):
         Context(paths=tmp_path, config=config)
 
diff --git a/tests/core/test_model.py b/tests/core/test_model.py
index 4efa45f6..b04aabf1 100644
--- a/tests/core/test_model.py
+++ b/tests/core/test_model.py
@@ -576,7 +576,7 @@ def test_no_model_statement(tmp_path: Path):
     expressions = d.parse("SELECT 1 AS x")
     with pytest.raises(
         ConfigError,
-        match="The MODEL statement is required as the first statement in the definition, unless model name inference is enabled. at '.'",
+        match="Please add a MODEL block at the top of the file. Example:",
     ):
         load_sql_based_model(expressions)
 
@@ -613,7 +613,7 @@ def test_unordered_model_statements():
 
     with pytest.raises(ConfigError) as ex:
         load_sql_based_model(expressions)
-    assert "MODEL statement is required" in str(ex.value)
+    assert "Please add a MODEL block at the top of the file. Example:" in str(ex.value)
 
 
 def test_no_query():
diff --git a/vscode/extension/tests/utils.ts b/vscode/extension/tests/utils.ts
index 10e36887..af4e0bb7 100644
--- a/vscode/extension/tests/utils.ts
+++ b/vscode/extension/tests/utils.ts
@@ -32,6 +32,7 @@ export const startVSCode = async (workspaceDir: string): Promise<{
       `--extensionDevelopmentPath=${EXT_PATH}`,
       '--disable-workspace-trust',
       '--disable-telemetry',
+      '--install-extension=ms-python.python',
       `--user-data-dir=${userDataDir}`,
       workspaceDir,
     ];