Repository: TobikoData/sqlmesh
Pull request #4288 (2025-04-30 21:55:34)

feat(vscode): better errors for missing sqlmesh_lsp
Handle the cases where 

- `sqlmesh_lsp` is not present 
- `pygls` and `lsprotocol` are not present, recommend installing `sqlmesh[lsp]`

![Screenshot 2025-04-30 at 22 47 06](https://github.com/user-attachments/assets/10b4f76c-83bc-4d1b-ac6f-af142e38a748)



Patch:
diff --git a/docs/concepts/macros/jinja_macros.md b/docs/concepts/macros/jinja_macros.md
index 7018ecc9..49b5f819 100644
--- a/docs/concepts/macros/jinja_macros.md
+++ b/docs/concepts/macros/jinja_macros.md
@@ -122,6 +122,31 @@ Access gateway variables in models using the same methods as [global variables](
 
 Gateway-specific variable values take precedence over variables with the same name specified in the configuration file's root `variables` key.
 
+### Blueprint variables
+
+Blueprint variables are defined as a property of the `MODEL` statement, and serve as a mechanism for [creating model templates](../models/sql_models.md):
+
+```sql linenums="1"
+MODEL (
+  name @customer.some_table,
+  kind FULL,
+  blueprints (
+    (customer := customer1, field_a := x, field_b := y),
+    (customer := customer2, field_a := z)
+  )
+);
+
+JINJA_QUERY_BEGIN;
+SELECT
+  {{ blueprint_var('field_a') }}
+  {{ blueprint_var('field_b', 'default_b') }} AS field_b
+FROM {{ blueprint_var('customer') }}.some_source
+JINJA_END;
+```
+
+Blueprint variables can be accessed using the `{{ blueprint_var() }}` macro function, which also supports specifying default values in case the variable is undefined (similar to `{{ var() }}`).
+
+
 ### Local variables
 
 Define your own variables with the Jinja statement `{% set ... %}`. For example, we could specify the name of the `num_orders` column in the `sqlmesh_example.full_model` like this:
diff --git a/sqlmesh/core/constants.py b/sqlmesh/core/constants.py
index 131d3a99..60c6a3ee 100644
--- a/sqlmesh/core/constants.py
+++ b/sqlmesh/core/constants.py
@@ -79,6 +79,7 @@ SQLMESH_VARS = "__sqlmesh__vars__"
 SQLMESH_BLUEPRINT_VARS = "__sqlmesh__blueprint__vars__"
 
 VAR = "var"
+BLUEPRINT_VAR = "blueprint_var"
 GATEWAY = "gateway"
 
 SQLMESH_MACRO = "__sqlmesh__macro__"
diff --git a/sqlmesh/utils/jinja.py b/sqlmesh/utils/jinja.py
index 403b8cab..dcb09296 100644
--- a/sqlmesh/utils/jinja.py
+++ b/sqlmesh/utils/jinja.py
@@ -15,6 +15,7 @@ from sqlmesh.core import constants as c
 from sqlmesh.core import dialect as d
 from sqlmesh.utils import AttributeDict
 from sqlmesh.utils.pydantic import PRIVATE_FIELDS, PydanticModel, field_serializer, field_validator
+from sqlmesh.utils.metaprogramming import SqlValue
 
 
 if t.TYPE_CHECKING:
@@ -593,7 +594,10 @@ def jinja_call_arg_name(node: nodes.Node) -> str:
 
 def create_var(variables: t.Dict[str, t.Any]) -> t.Callable:
     def _var(var_name: str, default: t.Optional[t.Any] = None) -> t.Optional[t.Any]:
-        return variables.get(var_name.lower(), default)
+        value = variables.get(var_name.lower(), default)
+        if isinstance(value, SqlValue):
+            return value.sql
+        return value
 
     return _var
 
@@ -603,10 +607,12 @@ def create_builtin_globals(
 ) -> t.Dict[str, t.Any]:
     global_vars.pop(c.GATEWAY, None)
     variables = global_vars.pop(c.SQLMESH_VARS, None) or {}
+    blueprint_variables = global_vars.pop(c.SQLMESH_BLUEPRINT_VARS, None) or {}
     return {
+        **global_vars,
         c.VAR: create_var(variables),
         c.GATEWAY: lambda: variables.get(c.GATEWAY, None),
-        **global_vars,
+        c.BLUEPRINT_VAR: create_var(blueprint_variables),
     }
 
 
diff --git a/vscode/extension/eslint.config.mjs b/vscode/extension/eslint.config.mjs
index 651221da..be80cd9a 100644
--- a/vscode/extension/eslint.config.mjs
+++ b/vscode/extension/eslint.config.mjs
@@ -16,6 +16,7 @@ export default tseslint.config(
   },
   {
     rules: {
+      'no-fallthrough': 'error',
       '@typescript-eslint/switch-exhaustiveness-check': 'error',
       '@typescript-eslint/no-unsafe-assignment': 'off',
       '@typescript-eslint/no-explicit-any': 'off',
diff --git a/vscode/extension/src/commands/format.ts b/vscode/extension/src/commands/format.ts
index a340fcee..59d4c221 100644
--- a/vscode/extension/src/commands/format.ts
+++ b/vscode/extension/src/commands/format.ts
@@ -3,22 +3,35 @@ import { execSync } from 'child_process'
 import { sqlmesh_exec } from '../utilities/sqlmesh/sqlmesh'
 import { err, isErr, ok, Result } from '../utilities/functional/result'
 import * as vscode from 'vscode'
-import { ErrorType, handleNotSginedInError } from '../utilities/errors'
+import {
+  ErrorType,
+  handleNotSginedInError,
+  handleSqlmeshLspNotFoundError,
+  handleSqlmeshLspDependenciesMissingError,
+} from '../utilities/errors'
 import { AuthenticationProviderTobikoCloud } from '../auth/auth'
 
 export const format =
-  (authProvider: AuthenticationProviderTobikoCloud) => async () => {
+  (authProvider: AuthenticationProviderTobikoCloud) =>
+  async (): Promise<void> => {
     traceLog('Calling format')
     const out = await internalFormat()
     if (isErr(out)) {
-      if (out.error.type === 'not_signed_in') {
-        await handleNotSginedInError(authProvider)
-        return
-      } else {
-        vscode.window.showErrorMessage(
-          `Project format failed: ${out.error.message}`,
-        )
-        return
+      switch (out.error.type) {
+        case 'not_signed_in':
+          await handleNotSginedInError(authProvider)
+          return
+        case 'sqlmesh_lsp_not_found':
+          await handleSqlmeshLspNotFoundError()
+          return
+        case 'sqlmesh_lsp_dependencies_missing':
+          await handleSqlmeshLspDependenciesMissingError(out.error)
+          return
+        case 'generic':
+          await vscode.window.showErrorMessage(
+            `Project format failed: ${out.error.message}`,
+          )
+          return
       }
     }
     vscode.window.showInformationMessage('Project formatted successfully')
diff --git a/vscode/extension/src/extension.ts b/vscode/extension/src/extension.ts
index ef449057..cf41d2c0 100644
--- a/vscode/extension/src/extension.ts
+++ b/vscode/extension/src/extension.ts
@@ -13,7 +13,11 @@ import { signOut } from './commands/signout'
 import { signIn } from './commands/signin'
 import { signInSpecifyFlow } from './commands/signinSpecifyFlow'
 import { isErr } from './utilities/functional/result'
-import { handleNotSginedInError } from './utilities/errors'
+import {
+  handleNotSginedInError,
+  handleSqlmeshLspNotFoundError,
+  handleSqlmeshLspDependenciesMissingError,
+} from './utilities/errors'
 
 let lspClient: LSPClient | undefined
 
@@ -58,17 +62,49 @@ export async function activate(context: vscode.ExtensionContext) {
   lspClient = new LSPClient()
   const result = await lspClient.start()
   if (isErr(result)) {
-    await handleNotSginedInError(authProvider)
+    switch (result.error.type) {
+      case 'not_signed_in':
+        await handleNotSginedInError(authProvider)
+        break
+      case 'sqlmesh_lsp_not_found':
+        await handleSqlmeshLspNotFoundError()
+        break
+      case 'sqlmesh_lsp_dependencies_missing':
+        await handleSqlmeshLspDependenciesMissingError(result.error)
+        break
+      case 'generic':
+        await vscode.window.showErrorMessage(
+          `Failed to start LSP: ${result.error.message}`,
+        )
+        break
+    }
+  } else {
+    context.subscriptions.push(lspClient)
   }
-  context.subscriptions.push(lspClient)
 
   const restart = async () => {
     if (lspClient) {
       traceVerbose('Restarting LSP client')
       const restartResult = await lspClient.restart()
       if (isErr(restartResult)) {
-        await handleNotSginedInError(authProvider)
+        switch (restartResult.error.type) {
+          case 'not_signed_in':
+            await handleNotSginedInError(authProvider)
+            return
+          case 'sqlmesh_lsp_not_found':
+            await handleSqlmeshLspNotFoundError()
+            return
+          case 'sqlmesh_lsp_dependencies_missing':
+            await handleSqlmeshLspDependenciesMissingError(restartResult.error)
+            return
+          case 'generic':
+            await vscode.window.showErrorMessage(
+              `Failed to restart LSP: ${restartResult.error.message}`,
+            )
+            return
+        }
       }
+      context.subscriptions.push(lspClient)
     }
   }
 
diff --git a/vscode/extension/src/utilities/errors.ts b/vscode/extension/src/utilities/errors.ts
index a1f9ba3e..9a1d02c1 100644
--- a/vscode/extension/src/utilities/errors.ts
+++ b/vscode/extension/src/utilities/errors.ts
@@ -9,6 +9,16 @@ import { traceInfo } from './common/log'
 export type ErrorType =
   | { type: 'generic'; message: string }
   | { type: 'not_signed_in' }
+  | { type: 'sqlmesh_lsp_not_found' }
+  // sqlmesh_lsp_dependencies_missing is used when the sqlmesh_lsp is found but the lsp extras are missing.
+  | SqlmeshLspDependenciesMissingError
+
+interface SqlmeshLspDependenciesMissingError {
+  type: 'sqlmesh_lsp_dependencies_missing'
+  is_missing_pygls: boolean
+  is_missing_lsprotocol: boolean
+  is_tobiko_cloud: boolean
+}
 
 /**
  * Handles the case where the user is not signed in to Tobiko Cloud.
@@ -26,3 +36,40 @@ export const handleNotSginedInError = async (
     await signIn(authProvider)()
   }
 }
+
+/**
+ * Handles the case where the sqlmesh_lsp is not found.
+ */
+export const handleSqlmeshLspNotFoundError = async (): Promise<void> => {
+  traceInfo('handleSqlmeshLspNotFoundError')
+  await window.showErrorMessage(
+    'SQLMesh LSP not found, please check installation',
+  )
+}
+
+/**
+ * Handles the case where the sqlmesh_lsp is found but the lsp extras are missing.
+ */
+export const handleSqlmeshLspDependenciesMissingError = async (
+  error: SqlmeshLspDependenciesMissingError,
+): Promise<void> => {
+  traceInfo('handleSqlmeshLspDependenciesMissingError')
+  if (error.is_tobiko_cloud) {
+    await window.showErrorMessage(
+      'LSP dependencies missing, make sure to include `lsp` in the `extras` section of your `tcloud.yaml` file.',
+    )
+  } else {
+    const install = await window.showErrorMessage(
+      'LSP dependencies missing, make sure to install `sqlmesh[lsp]`.',
+      'Install',
+    )
+    if (install === 'Install') {
+      const terminal = window.createTerminal({
+        name: 'SQLMesh LSP Install',
+        hideFromUser: false,
+      })
+      terminal.show()
+      terminal.sendText("pip install 'sqlmesh[lsp]'", false)
+    }
+  }
+}
diff --git a/vscode/extension/src/utilities/sqlmesh/sqlmesh.ts b/vscode/extension/src/utilities/sqlmesh/sqlmesh.ts
index 40b01e76..05bb66a0 100644
--- a/vscode/extension/src/utilities/sqlmesh/sqlmesh.ts
+++ b/vscode/extension/src/utilities/sqlmesh/sqlmesh.ts
@@ -231,6 +231,46 @@ export const sqlmesh_exec = async (): Promise<
   }
 }
 
+/**
+ * Ensure that the sqlmesh_lsp dependencies are installed.
+ *
+ * @returns A Result indicating whether the sqlmesh_lsp dependencies were installed.
+ */
+export const ensureSqlmeshLspDependenciesInstalled = async (): Promise<
+  Result<undefined, ErrorType>
+> => {
+  const isPyglsInstalled = await isPythonModuleInstalled('pygls')
+  if (isErr(isPyglsInstalled)) {
+    return err({
+      type: 'generic',
+      message: isPyglsInstalled.error,
+    })
+  }
+  const isLsprotocolInstalled = await isPythonModuleInstalled('lsprotocol')
+  if (isErr(isLsprotocolInstalled)) {
+    return err({
+      type: 'generic',
+      message: isLsprotocolInstalled.error,
+    })
+  }
+  const isTobikoCloudInstalled = await isTcloudProject()
+  if (isErr(isTobikoCloudInstalled)) {
+    return err({
+      type: 'generic',
+      message: isTobikoCloudInstalled.error,
+    })
+  }
+  if (!isPyglsInstalled.value || !isLsprotocolInstalled.value) {
+    return err({
+      type: 'sqlmesh_lsp_dependencies_missing',
+      is_missing_pygls: !isPyglsInstalled.value,
+      is_missing_lsprotocol: !isLsprotocolInstalled.value,
+      is_tobiko_cloud: isTobikoCloudInstalled.value,
+    })
+  }
+  return ok(undefined)
+}
+
 /**
  * Get the sqlmesh_lsp executable for the current workspace.
  *
@@ -282,8 +322,17 @@ export const sqlmesh_lsp_exec = async (): Promise<
         })
       }
     }
+    const ensuredDependencies = await ensureSqlmeshLspDependenciesInstalled()
+    if (isErr(ensuredDependencies)) {
+      return ensuredDependencies
+    }
     const binPath = path.join(interpreterDetails.binPath!, 'sqlmesh_lsp')
     traceLog(`Bin path: ${binPath}`)
+    if (!fs.existsSync(binPath)) {
+      return err({
+        type: 'sqlmesh_lsp_not_found',
+      })
+    }
     return ok({
       bin: binPath,
       workspacePath,


Test patch:
diff --git a/tests/core/test_model.py b/tests/core/test_model.py
index 647ae8d7..722cc006 100644
--- a/tests/core/test_model.py
+++ b/tests/core/test_model.py
@@ -8662,6 +8662,55 @@ def test_blueprint_variable_precedence_sql(tmp_path: Path, assert_exp_eq: t.Call
     )
 
 
+def test_blueprint_variable_jinja(tmp_path: Path, assert_exp_eq: t.Callable) -> None:
+    init_example_project(tmp_path, dialect="duckdb", template=ProjectTemplate.EMPTY)
+
+    blueprint_variables = tmp_path / "models/blueprint_variables.sql"
+    blueprint_variables.parent.mkdir(parents=True, exist_ok=True)
+    blueprint_variables.write_text(
+        """
+        MODEL (
+          name s.@{bp_name},
+          blueprints (
+            (bp_name := m1, var1 := 'v1', var2 := v2),
+            (bp_name := m2, var1 := 'v3'),
+          ),
+        );
+
+        @DEF(bp_name, override);
+
+        JINJA_QUERY_BEGIN;
+        SELECT
+            {{ blueprint_var('var1') }} AS var1,
+            '{{ blueprint_var('var2') }}' AS var2,
+            '{{ blueprint_var('var2', 'var2_default') }}' AS var2_default,
+            '{{ blueprint_var('bp_name') }}' AS bp_name
+        FROM s.{{ blueprint_var('bp_name') }}_source;
+        JINJA_END;
+        """
+    )
+    ctx = Context(
+        config=Config(
+            model_defaults=ModelDefaultsConfig(dialect="duckdb"),
+            variables={"var2": "1"},
+        ),
+        paths=tmp_path,
+    )
+    assert len(ctx.models) == 2
+
+    m1 = ctx.get_model("s.m1", raise_if_missing=True)
+    m2 = ctx.get_model("s.m2", raise_if_missing=True)
+
+    assert_exp_eq(
+        m1.render_query(),
+        """SELECT 'v1' AS "var1", 'v2' AS "var2", 'v2' AS "var2_default", 'm1' AS "bp_name" FROM "memory"."s"."m1_source" AS "m1_source" """,
+    )
+    assert_exp_eq(
+        m2.render_query(),
+        """SELECT 'v3' AS "var1", 'None' AS "var2", 'var2_default' AS "var2_default", 'm2' AS "bp_name" FROM "memory"."s"."m2_source" AS "m2_source" """,
+    )
+
+
 def test_blueprint_variable_precedence_python(tmp_path: Path, mocker: MockerFixture) -> None:
     init_example_project(tmp_path, dialect="duckdb", template=ProjectTemplate.EMPTY)