sqlfluff-templater-sqlmesh 4.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ """Defines the hook endpoints for the SQLMesh templater plugin."""
2
+
3
+ from sqlfluff.core.plugin import hookimpl
4
+ from sqlfluff_templater_sqlmesh.templater import SQLMeshTemplater
5
+
6
+
7
+ @hookimpl
8
+ def get_templaters():
9
+ """Get templaters."""
10
+ return [SQLMeshTemplater]
@@ -0,0 +1,1005 @@
1
+ """Defines the SQLMesh templater.
2
+
3
+ NOTE: The SQLMesh python package adds a significant overhead to import.
4
+ This module is also loaded on every run of SQLFluff regardless of
5
+ whether the SQLMesh templater is selected in the configuration.
6
+
7
+ The templater is however only _instantiated_ when selected, and as
8
+ such, all imports of the SQLMesh libraries are contained within the
9
+ SQLMeshTemplater class and so are only imported when necessary.
10
+ """
11
+
12
+ import logging
13
+ import os
14
+ import os.path
15
+ import re
16
+ from functools import cached_property
17
+ from pathlib import Path
18
+ from typing import (
19
+ TYPE_CHECKING,
20
+ Any,
21
+ Callable,
22
+ Optional,
23
+ TypeVar,
24
+ )
25
+
26
+ from sqlfluff.core.errors import SQLTemplaterError
27
+ from sqlfluff.core.templaters.base import (
28
+ RawFileSlice,
29
+ TemplatedFile,
30
+ TemplatedFileSlice,
31
+ large_file_check,
32
+ )
33
+ from sqlfluff.core.templaters.jinja import JinjaTemplater
34
+
35
+ if TYPE_CHECKING: # pragma: no cover
36
+ from sqlfluff.cli.formatters import OutputStreamFormatter
37
+ from sqlfluff.core import FluffConfig
38
+
39
+ # Instantiate the templater logger
40
+ templater_logger = logging.getLogger("sqlfluff.templater")
41
+
42
+ ERROR_PREAMBLE = "Error received from SQLMesh during project compilation. "
43
+
44
+ # A source region description: (slice_type, src_start, src_end, tmpl_start, tmpl_end).
45
+ Region = tuple[str, int, int, int, int]
46
+
47
+
48
+ def is_sqlmesh_exception(exception: Optional[BaseException]) -> bool:
49
+ """Check whether this looks like a SQLMesh exception."""
50
+ # None is not a SQLMesh exception.
51
+ if not exception:
52
+ return False
53
+ return exception.__class__.__module__.startswith("sqlmesh")
54
+
55
+
56
+ def _extract_error_detail(exception: BaseException) -> str:
57
+ """Serialise an exception into a string for reuse in other messages."""
58
+ return (
59
+ f"{exception.__class__.__module__}.{exception.__class__.__name__}: {exception}"
60
+ )
61
+
62
+
63
+ T = TypeVar("T")
64
+
65
+
66
+ def handle_sqlmesh_errors(
67
+ error_class: type[Exception], preamble: str
68
+ ) -> Callable[[Callable[..., T]], Callable[..., T]]:
69
+ """A decorator to safely catch SQLMesh exceptions and raise native ones.
70
+
71
+ NOTE: This looks and behaves a lot like a context manager, but it's
72
+ important that it is *not* a context manager so that it can effectively
73
+ strip the context from handled exceptions. That is not reliably possible
74
+ within a context manager for this use case.
75
+
76
+ SQLMesh exceptions don't pickle nicely, and python exception context tries
77
+ very hard to make sure that the exception context of any new exceptions
78
+ is preserved. This means that if we want to remove the context from any
79
+ exceptions (so they can be pickled), we need to explicitly catch and
80
+ reraise outside of the context of whatever call made them.
81
+ """
82
+
83
+ def decorator(wrapped_method: Callable[..., T]) -> Callable[..., T]:
84
+ def wrapped_method_inner(*args, **kwargs) -> T:
85
+ new_error: Optional[Exception] = None
86
+ try:
87
+ return wrapped_method(*args, **kwargs)
88
+ except Exception as err:
89
+ # If this is a *direct* SQLMesh exception, convert it.
90
+ if is_sqlmesh_exception(err):
91
+ _detail = _extract_error_detail(err)
92
+ new_error = error_class(preamble + _detail)
93
+
94
+ # If this is a SQLTemplaterError that was raised "from" a
95
+ # SQLMesh exception, convert based on the underlying cause.
96
+ elif isinstance(err, SQLTemplaterError) and is_sqlmesh_exception(
97
+ err.__cause__
98
+ ):
99
+ _detail = _extract_error_detail(err.__cause__)
100
+ new_error = error_class(preamble + _detail)
101
+
102
+ # Otherwise, strip any SQLMesh exceptions from the context so
103
+ # the resulting exception can be safely pickled. Rather than
104
+ # mutating the caught exception in place (which can lead to
105
+ # unexpected behaviour), re-raise as a fresh instance of the
106
+ # same type so no SQLMesh context is attached.
107
+ elif is_sqlmesh_exception(err.__cause__) or is_sqlmesh_exception(
108
+ err.__context__
109
+ ):
110
+ new_error = type(err)(*err.args)
111
+
112
+ # If it's not a SQLMesh exception (or has been cleaned), just
113
+ # re-raise as is.
114
+ else:
115
+ raise
116
+
117
+ assert new_error is not None
118
+ raise new_error from None
119
+
120
+ return wrapped_method_inner
121
+
122
+ return decorator
123
+
124
+
125
+ class SQLMeshTemplater(JinjaTemplater):
126
+ """A templater using SQLMesh.
127
+
128
+ SQLMesh renders models through SQLGlot (AST expansion + re-serialisation)
129
+ rather than by Jinja-style substitution, so the rendered SQL has no
130
+ literal-slice relationship to the source. Rather than reconstruct a map
131
+ from a text diff (which classifies almost everything as "templated" and
132
+ hides violations), the templater dispatches across four tiers, cheapest
133
+ first, so that as much of the file as possible is mapped as real,
134
+ lintable ``literal`` SQL:
135
+
136
+ * **T0** - split the ``MODEL (...)`` header (source-only) from the SQL body.
137
+ * **T1** - a body with no ``@`` macros is linted verbatim, with exact
138
+ positions and no SQLMesh context loaded at all.
139
+ * **T2** - a body whose macros all resolve to a single inline expression is
140
+ substituted into the *verbatim* source, so non-macro SQL keeps exact
141
+ positions (this mirrors how the Jinja templater traces substitutions).
142
+ * **T3** - anything else (structural or unresolved macros) is rendered whole
143
+ and mapped as one coarse ``templated`` region. It is suppressed by
144
+ default but, crucially, never collapses positions to the start of file.
145
+ """
146
+
147
+ name = "sqlmesh"
148
+ templates_in_worker = False
149
+ # Inherited from JinjaTemplater: max consecutive templating failures before abort.
150
+ sequential_fail_limit = 3
151
+
152
+ def __init__(self, override_context: Optional[dict[str, Any]] = None):
153
+ self.sqlfluff_config = None
154
+ self.formatter = None
155
+ self.project_dir = None
156
+ self._context_key: Optional[tuple[Any, Any, Any]] = None
157
+ super().__init__(override_context=override_context)
158
+
159
+ def config_pairs(self) -> list[tuple[str, str]]:
160
+ """Returns info about the given templater for output by the cli."""
161
+ return [("templater", self.name), ("sqlmesh", self.sqlmesh_version)]
162
+
163
+ def _clear_cached_sqlmesh_context(self) -> None:
164
+ """Clear cached SQLMesh context when runtime configuration changes."""
165
+ self.__dict__.pop("sqlmesh_context", None)
166
+
167
+ def _sqlmesh_context_key(
168
+ self, config: Optional["FluffConfig"]
169
+ ) -> tuple[Any, Any, Any]:
170
+ """Inputs that determine the SQLMesh context: (project_dir, config, gateway).
171
+
172
+ The context depends only on these, not on the identity of the
173
+ ``FluffConfig`` object. SQLFluff hands each file in a directory lint its
174
+ own child config, so keying the cache on object identity would rebuild
175
+ the (expensive) SQLMesh context for every file.
176
+ """
177
+ project_dir = self._get_project_dir(config)
178
+ cfg_name = gateway = None
179
+ if config is not None:
180
+ cfg_name = config.get_section(
181
+ (self.templater_selector, self.name, "config")
182
+ )
183
+ gateway = config.get_section(
184
+ (self.templater_selector, self.name, "gateway")
185
+ )
186
+ return (project_dir, cfg_name, gateway)
187
+
188
+ def _update_runtime_context(
189
+ self,
190
+ *,
191
+ config: Optional["FluffConfig"],
192
+ formatter: Optional["OutputStreamFormatter"],
193
+ ) -> None:
194
+ """Update runtime state for this templating request.
195
+
196
+ The cached SQLMesh context is only rebuilt when the inputs that define
197
+ it (project dir, config name, gateway) actually change — not merely
198
+ because SQLFluff passed a different (per-file child) config object.
199
+ """
200
+ new_key = self._sqlmesh_context_key(config)
201
+ if new_key != self._context_key:
202
+ self._clear_cached_sqlmesh_context()
203
+ self._context_key = new_key
204
+ self.sqlfluff_config = config
205
+ self.formatter = formatter
206
+ self.project_dir = new_key[0]
207
+
208
+ def _get_project_dir(self, config: Optional["FluffConfig"] = None) -> str:
209
+ """Get the SQLMesh project directory from the configuration.
210
+
211
+ Defaults to the working directory.
212
+ """
213
+ resolved_config = config or self.sqlfluff_config
214
+ config_project_dir = None
215
+ if resolved_config is not None:
216
+ config_project_dir = resolved_config.get_section(
217
+ (self.templater_selector, self.name, "project_dir")
218
+ )
219
+ env_project_dir = os.getenv("SQLMESH_PROJECT_DIR")
220
+ cwd = os.getcwd()
221
+
222
+ sqlmesh_project_dir = os.path.abspath(
223
+ os.path.expanduser(config_project_dir or env_project_dir or cwd)
224
+ )
225
+ templater_logger.debug(
226
+ "Resolved SQLMesh project_dir to %s (config=%r, env=%r, cwd=%r)",
227
+ sqlmesh_project_dir,
228
+ config_project_dir,
229
+ env_project_dir,
230
+ cwd,
231
+ )
232
+
233
+ if not os.path.exists(sqlmesh_project_dir):
234
+ templater_logger.error(
235
+ f"sqlmesh_project_dir: {sqlmesh_project_dir} could not be accessed. "
236
+ "Check it exists."
237
+ )
238
+
239
+ return sqlmesh_project_dir
240
+
241
+ def _get_config_name(self) -> Optional[str]:
242
+ """Get the SQLMesh config name from the configuration."""
243
+ if self.sqlfluff_config is None:
244
+ return None
245
+ return self.sqlfluff_config.get_section(
246
+ (self.templater_selector, self.name, "config")
247
+ )
248
+
249
+ def _get_gateway_name(self) -> Optional[str]:
250
+ """Get the SQLMesh gateway name from the configuration."""
251
+ if self.sqlfluff_config is None:
252
+ return None
253
+ return self.sqlfluff_config.get_section(
254
+ (self.templater_selector, self.name, "gateway")
255
+ )
256
+
257
+ def _get_dialect(self) -> Optional[str]:
258
+ """Get the configured SQLFluff dialect (used to seed SQLGlot parsing)."""
259
+ if self.sqlfluff_config is None:
260
+ return None
261
+ try:
262
+ return self.sqlfluff_config.get("dialect")
263
+ except Exception: # pragma: no cover - defensive
264
+ return None
265
+
266
+ @cached_property
267
+ def sqlmesh_version(self):
268
+ """Gets the SQLMesh version."""
269
+ try:
270
+ import sqlmesh
271
+
272
+ return sqlmesh.__version__
273
+ except ImportError:
274
+ return "not installed"
275
+
276
+ @cached_property
277
+ def sqlmesh_context(self):
278
+ """Loads the SQLMesh context."""
279
+ if self.project_dir is None:
280
+ raise SQLTemplaterError(
281
+ "SQLMesh project_dir is not set. Call process() with a valid config first."
282
+ )
283
+ try:
284
+ from sqlmesh.core.context import Context as SQLMeshContext
285
+
286
+ templater_logger.info(
287
+ "Loading SQLMesh context from project: %s", self.project_dir
288
+ )
289
+ except ImportError:
290
+ raise SQLTemplaterError(
291
+ "SQLMesh is not installed. Please install SQLMesh to use the sqlmesh templater: "
292
+ "pip install sqlmesh"
293
+ )
294
+
295
+ try:
296
+ context = SQLMeshContext(
297
+ paths=self.project_dir,
298
+ config=self._get_config_name(),
299
+ gateway=self._get_gateway_name(),
300
+ )
301
+ templater_logger.info("Successfully created SQLMesh context")
302
+ return context
303
+ except Exception as err:
304
+ if is_sqlmesh_exception(err) or isinstance(
305
+ err, (OSError, TypeError, ValueError)
306
+ ):
307
+ raise SQLTemplaterError(
308
+ f"Failed to create SQLMesh context: {err}. "
309
+ "Check your SQLMesh project configuration."
310
+ ) from None
311
+ raise
312
+
313
+ @large_file_check
314
+ @handle_sqlmesh_errors(SQLTemplaterError, ERROR_PREAMBLE)
315
+ def process(
316
+ self,
317
+ *,
318
+ fname: str,
319
+ in_str: Optional[str] = None,
320
+ config: Optional["FluffConfig"] = None,
321
+ formatter: Optional["OutputStreamFormatter"] = None,
322
+ ) -> tuple[TemplatedFile, list[SQLTemplaterError]]:
323
+ """Compile a SQLMesh model and return the compiled SQL.
324
+
325
+ Side effects:
326
+ Updates cached runtime state used to initialize the SQLMesh context.
327
+
328
+ Args:
329
+ fname: Path to SQLMesh model(s)
330
+ in_str: fname contents using configured encoding
331
+ config: A specific config to use for this
332
+ formatter: The output stream formatter for this run
333
+ """
334
+ self._update_runtime_context(config=config, formatter=formatter)
335
+ fname_absolute_path = os.path.abspath(fname) if fname != "stdin" else fname
336
+
337
+ # NOTE: SQLMesh exceptions are caught and handled safely for pickling by the outer
338
+ # `handle_sqlmesh_errors` decorator.
339
+ return self._unsafe_process(fname_absolute_path, in_str, config)
340
+
341
+ def _unsafe_process(
342
+ self,
343
+ fname: str,
344
+ in_str: Optional[str] = None,
345
+ config: Optional["FluffConfig"] = None,
346
+ ) -> tuple[TemplatedFile, list[SQLTemplaterError]]:
347
+ """Process a file with SQLMesh, without error handling.
348
+
349
+ Implements the T0-T3 dispatch described on the class docstring.
350
+ """
351
+ if in_str is None:
352
+ with open(fname, "r", encoding="utf-8") as f:
353
+ in_str = f.read()
354
+
355
+ # T0: separate the MODEL(...) header (source-only) from the SQL body.
356
+ header_end = self._find_model_block_end(in_str)
357
+ body = in_str[header_end or 0 :]
358
+
359
+ # T1: a body with no macros and no Jinja is linted verbatim. This needs
360
+ # no SQLMesh context at all, so plain-SQL models (and stdin buffers)
361
+ # "just work". Jinja models are excluded here — their ``{{ }}`` / Jinja
362
+ # blocks aren't SQL, so they must be rendered rather than passed through.
363
+ macro_spans = self._find_macro_spans(body)
364
+ if not macro_spans and not self._has_jinja(body):
365
+ return self._passthrough_file(fname, in_str, header_end)
366
+
367
+ # Macros are present, so we need the model to resolve them against.
368
+ model = self._resolve_model(fname, self._get_model_name_from_path(fname))
369
+ if model is None:
370
+ # A non-model file (macros/, tests/, ...) that happens to contain an
371
+ # '@'. There is nothing to render it against, so pass it through.
372
+ templater_logger.debug(
373
+ "No SQLMesh model registered for %s; passing through literally.",
374
+ fname,
375
+ )
376
+ return self._passthrough_file(fname, in_str, header_end)
377
+
378
+ # Prefer authoritative segmentation: SQLMesh has already parsed this
379
+ # model, so its statements tell us exactly where the query is. This is
380
+ # more robust than the regex header split and, crucially, keeps
381
+ # non-query statements (the MODEL block, ``@DEF`` pre-statements, post
382
+ # statements) out of the SQL SQLFluff parses. Only the query is linted.
383
+ query_span = self._locate_query_span(in_str, model)
384
+ if query_span is not None:
385
+ return self._build_query_file(fname, in_str, model, query_span)
386
+
387
+ # Fallback (query text not locatable): use the regex header split and
388
+ # treat the whole body as the query.
389
+ templater_logger.debug(
390
+ "Statement segmentation unavailable for %s; using regex body split.",
391
+ fname,
392
+ )
393
+ replacements = self._resolve_macro_spans(model, body, macro_spans)
394
+ if replacements is not None:
395
+ return self._substitute_file(
396
+ fname, in_str, header_end, macro_spans, replacements
397
+ )
398
+ rendered_sql = self._render_body(model)
399
+ return self._coarse_templated_file(fname, in_str, header_end, rendered_sql)
400
+
401
+ def _locate_query_span(
402
+ self, source_str: str, model: Any
403
+ ) -> Optional[tuple[int, int]]:
404
+ """Locate the model's query in the source via SQLMesh's parsed statements.
405
+
406
+ Returns ``(start, end)`` source offsets covering the query and any
407
+ trailing whitespace up to the next statement (so file-level rules such
408
+ as the final-newline check still apply), or ``None`` if the file can't
409
+ be segmented this way (the caller then falls back to the regex split).
410
+
411
+ Only reachable in tiers 2/3, where the model has already parsed
412
+ successfully — so this never has to cope with a broken file.
413
+ """
414
+ query = getattr(model, "query", None)
415
+ query_meta = getattr(query, "meta", None) if query is not None else None
416
+ if not query_meta or not query_meta.get("sql"):
417
+ return None
418
+
419
+ pre = list(getattr(model, "pre_statements", []) or [])
420
+ post = list(getattr(model, "post_statements", []) or [])
421
+ ordered = [*pre, query, *post]
422
+
423
+ # Each statement's ``meta['sql']`` is its verbatim source text; recover
424
+ # offsets by locating them in order.
425
+ cursor = 0
426
+ located: list[tuple[int, int, bool]] = []
427
+ for node in ordered:
428
+ meta = getattr(node, "meta", None)
429
+ text = meta.get("sql") if meta else None
430
+ if not text:
431
+ return None
432
+ idx = source_str.find(text, cursor)
433
+ if idx < 0:
434
+ return None
435
+ end = idx + len(text)
436
+ located.append((idx, end, node is query))
437
+ cursor = end
438
+
439
+ for i, (start, _end, is_query) in enumerate(located):
440
+ if is_query:
441
+ next_start = (
442
+ located[i + 1][0] if i + 1 < len(located) else len(source_str)
443
+ )
444
+ return (start, next_start)
445
+ return None
446
+
447
+ def _build_query_file(
448
+ self, fname: str, source_str: str, model: Any, span: tuple[int, int]
449
+ ) -> tuple[TemplatedFile, list[SQLTemplaterError]]:
450
+ """Map a segmented file: only the query span is linted.
451
+
452
+ Everything before the query (MODEL header, ``@DEF`` pre-statements) and
453
+ after it (post statements) is emitted as a source-only, zero-length
454
+ templated region. The query itself is handled per tier: verbatim when
455
+ it has no macros, macro-substituted when they all resolve inline
456
+ (tier 2), or coarse-rendered otherwise (tier 3).
457
+ """
458
+ qs, qe = span
459
+ qtext = source_str[qs:qe]
460
+ macro_spans = self._find_macro_spans(qtext)
461
+
462
+ if self._has_jinja(qtext):
463
+ # A Jinja query (e.g. JINJA_QUERY_BEGIN ... {{ }} ... JINJA_END) has
464
+ # no literal-slice relationship to the source and can't be parsed as
465
+ # SQL, so render it whole and map it as one coarse templated region.
466
+ templater_logger.debug(
467
+ "Coarse templated mapping for the Jinja query of %s (tier 3).", fname
468
+ )
469
+ templated = self._render_body(model)
470
+ query_regions: list[Region] = [("templated", qs, qe, 0, len(templated))]
471
+ elif not macro_spans:
472
+ query_regions = [("literal", qs, qe, 0, len(qtext))]
473
+ templated = qtext
474
+ else:
475
+ replacements = self._resolve_macro_spans(model, qtext, macro_spans)
476
+ if replacements is not None:
477
+ templater_logger.debug(
478
+ "Substituting %d macro(s) in the query of %s (tier 2).",
479
+ len(macro_spans),
480
+ fname,
481
+ )
482
+ query_regions, templated = self._substitute_regions(
483
+ source_str, qs, qe, macro_spans, replacements
484
+ )
485
+ else:
486
+ templater_logger.debug(
487
+ "Coarse templated mapping for the query of %s (tier 3).", fname
488
+ )
489
+ templated = self._render_body(model)
490
+ query_regions = [("templated", qs, qe, 0, len(templated))]
491
+
492
+ regions: list[Region] = []
493
+ if qs > 0:
494
+ regions.append(("templated", 0, qs, 0, 0))
495
+ regions.extend(query_regions)
496
+ if qe < len(source_str):
497
+ regions.append(
498
+ ("templated", qe, len(source_str), len(templated), len(templated))
499
+ )
500
+ return self._make_file(fname, source_str, templated, regions)
501
+
502
+ # -- Model resolution -----------------------------------------------------
503
+
504
+ def _resolve_model(self, fname: str, model_name: Optional[str]) -> Any:
505
+ """Resolve the SQLMesh model for a file, or None if it isn't a model.
506
+
507
+ Resolution prefers matching the file path against the loaded models so
508
+ that non-model files (``macros/``, ``tests/``, ``seeds/``, ``audits/``,
509
+ ...) and models whose declared name differs from their path are handled
510
+ correctly. A name-based lookup is used as a fallback for cases where the
511
+ path does not map directly to a registered model file.
512
+ """
513
+ context = self.sqlmesh_context
514
+
515
+ try:
516
+ target = os.path.realpath(fname)
517
+ except (OSError, ValueError):
518
+ target = None
519
+
520
+ if target is not None:
521
+ for model in context.models.values():
522
+ model_path = getattr(model, "_path", None)
523
+ if not model_path:
524
+ continue
525
+ try:
526
+ if os.path.realpath(str(model_path)) == target:
527
+ return model
528
+ except (OSError, ValueError):
529
+ continue
530
+
531
+ if model_name:
532
+ return context.get_model(model_name, raise_if_missing=False)
533
+
534
+ return None
535
+
536
+ def _get_model_name_from_path(self, fname: str) -> Optional[str]:
537
+ """Extract the SQLMesh model name from a file path."""
538
+ if self.project_dir is None:
539
+ return None
540
+ try:
541
+ file_path = Path(fname)
542
+ rel_path = file_path.relative_to(self.project_dir)
543
+ except ValueError:
544
+ templater_logger.debug(
545
+ "Skipping SQLMesh model resolution for path outside project: %s",
546
+ fname,
547
+ )
548
+ return None
549
+
550
+ try:
551
+ # Remove models/ or audits/ prefix if present.
552
+ for prefix in ("models", "audits"):
553
+ try:
554
+ rel_path = rel_path.relative_to(prefix)
555
+ break
556
+ except ValueError:
557
+ continue
558
+
559
+ # Remove file extension and convert separators to dots.
560
+ model_name = ".".join(rel_path.with_suffix("").parts)
561
+ templater_logger.debug(
562
+ "Resolved SQLMesh model %s from path %s", model_name, fname
563
+ )
564
+ return model_name
565
+
566
+ except (OSError, ValueError) as err:
567
+ templater_logger.error(
568
+ "Failed to extract model name from %s: %s", fname, err
569
+ )
570
+ return None
571
+
572
+ # -- T0: MODEL header detection -------------------------------------------
573
+
574
+ @staticmethod
575
+ def _skip_leading_trivia(source: str) -> int:
576
+ """Return the index of the first non-whitespace, non-comment char."""
577
+ i, n = 0, len(source)
578
+ while i < n:
579
+ ch = source[i]
580
+ if ch in " \t\r\n":
581
+ i += 1
582
+ elif ch == "-" and source[i + 1 : i + 2] == "-":
583
+ nl = source.find("\n", i)
584
+ i = n if nl == -1 else nl + 1
585
+ elif ch == "/" and source[i + 1 : i + 2] == "*":
586
+ end = source.find("*/", i + 2)
587
+ i = n if end == -1 else end + 2
588
+ else:
589
+ break
590
+ return i
591
+
592
+ @staticmethod
593
+ def _find_model_block_end(source: str) -> Optional[int]:
594
+ """Find the end of the MODEL (...); block in a SQLMesh source file.
595
+
596
+ Handles leading comments before the ``MODEL`` DDL (SQLMesh allows them),
597
+ nested parentheses (e.g. INCREMENTAL_BY_TIME_RANGE(...)) and string
598
+ literals within the MODEL block.
599
+
600
+ Returns the index just past the MODEL block including any trailing
601
+ whitespace, or None if no MODEL block is found.
602
+ """
603
+ start = SQLMeshTemplater._skip_leading_trivia(source)
604
+ if not re.match(r"MODEL\s*\(", source[start:], re.IGNORECASE):
605
+ return None
606
+
607
+ depth = 0
608
+ in_string = False
609
+ string_char = None
610
+ i = start
611
+
612
+ while i < len(source):
613
+ ch = source[i]
614
+ if in_string:
615
+ if ch == string_char:
616
+ # Handle escaped quotes (e.g. '' inside a single-quoted string)
617
+ if i + 1 < len(source) and source[i + 1] == string_char:
618
+ i += 1 # Skip the escaped quote
619
+ else:
620
+ in_string = False
621
+ elif ch in ("'", '"'):
622
+ in_string = True
623
+ string_char = ch
624
+ elif ch == "(":
625
+ depth += 1
626
+ elif ch == ")":
627
+ depth -= 1
628
+ if depth == 0:
629
+ # Found the closing paren — consume the trailing semicolon.
630
+ rest = source[i + 1 :]
631
+ semi_match = re.match(r"\s*;", rest)
632
+ if semi_match:
633
+ end_idx = i + 1 + semi_match.end()
634
+ else:
635
+ end_idx = i + 1
636
+ # Consume trailing whitespace/newlines so the SQL body
637
+ # starts at real content.
638
+ while end_idx < len(source) and source[end_idx] in (
639
+ " ",
640
+ "\t",
641
+ "\n",
642
+ "\r",
643
+ ):
644
+ end_idx += 1
645
+ return end_idx
646
+ i += 1
647
+
648
+ return None
649
+
650
+ # -- Macro scanning -------------------------------------------------------
651
+
652
+ @staticmethod
653
+ def _skip_string(s: str, i: int) -> int:
654
+ """Return the index just past a string literal starting at ``i``."""
655
+ quote = s[i]
656
+ i += 1
657
+ n = len(s)
658
+ while i < n:
659
+ if s[i] == quote:
660
+ if i + 1 < n and s[i + 1] == quote: # '' / "" escape
661
+ i += 2
662
+ continue
663
+ return i + 1
664
+ i += 1
665
+ return n
666
+
667
+ @staticmethod
668
+ def _skip_balanced_parens(s: str, i: int) -> int:
669
+ """Return the index past the ``)`` matching the ``(`` at ``i``."""
670
+ depth = 0
671
+ n = len(s)
672
+ while i < n:
673
+ c = s[i]
674
+ if c in ("'", '"'):
675
+ i = SQLMeshTemplater._skip_string(s, i)
676
+ continue
677
+ if c == "(":
678
+ depth += 1
679
+ elif c == ")":
680
+ depth -= 1
681
+ if depth == 0:
682
+ return i + 1
683
+ i += 1
684
+ return n
685
+
686
+ @staticmethod
687
+ def _find_macro_spans(body: str) -> list[tuple[int, int]]:
688
+ """Locate SQLMesh ``@macro`` spans in a SQL body.
689
+
690
+ Skips string literals and comments so an ``@`` inside a string (e.g. an
691
+ email literal) is not mistaken for a macro. Recognises ``@name``,
692
+ ``@name(...)`` and ``@{ ... }``.
693
+ """
694
+ spans: list[tuple[int, int]] = []
695
+ i, n = 0, len(body)
696
+ while i < n:
697
+ ch = body[i]
698
+ if ch in ("'", '"'):
699
+ i = SQLMeshTemplater._skip_string(body, i)
700
+ elif ch == "-" and body[i + 1 : i + 2] == "-":
701
+ nl = body.find("\n", i)
702
+ i = n if nl == -1 else nl
703
+ elif ch == "/" and body[i + 1 : i + 2] == "*":
704
+ end = body.find("*/", i + 2)
705
+ i = n if end == -1 else end + 2
706
+ elif (
707
+ ch == "@"
708
+ and i + 1 < n
709
+ and (body[i + 1] == "{" or body[i + 1].isalpha() or body[i + 1] == "_")
710
+ ):
711
+ start = i
712
+ if body[i + 1] == "{":
713
+ close = body.find("}", i + 2)
714
+ i = n if close == -1 else close + 1
715
+ else:
716
+ j = i + 1
717
+ while j < n and (body[j].isalnum() or body[j] == "_"):
718
+ j += 1
719
+ if j < n and body[j] == "(": # macro call: consume args
720
+ j = SQLMeshTemplater._skip_balanced_parens(body, j)
721
+ i = j
722
+ spans.append((start, i))
723
+ else:
724
+ i += 1
725
+ return spans
726
+
727
+ @staticmethod
728
+ def _has_jinja(text: str) -> bool:
729
+ """True if the text contains Jinja (a SQLMesh Jinja model).
730
+
731
+ Looks for ``{{`` / ``{%`` outside string literals and comments, or the
732
+ ``JINJA_QUERY_BEGIN`` / ``JINJA_STATEMENT_BEGIN`` block markers. Such
733
+ queries aren't SQL, so they must be rendered rather than linted raw.
734
+ """
735
+ i, n = 0, len(text)
736
+ while i < n:
737
+ ch = text[i]
738
+ if ch in ("'", '"'):
739
+ i = SQLMeshTemplater._skip_string(text, i)
740
+ elif ch == "-" and text[i + 1 : i + 2] == "-":
741
+ nl = text.find("\n", i)
742
+ i = n if nl == -1 else nl
743
+ elif ch == "/" and text[i + 1 : i + 2] == "*":
744
+ end = text.find("*/", i + 2)
745
+ i = n if end == -1 else end + 2
746
+ elif ch == "{" and text[i + 1 : i + 2] in ("{", "%"):
747
+ return True
748
+ else:
749
+ i += 1
750
+ return "JINJA_QUERY_BEGIN" in text or "JINJA_STATEMENT_BEGIN" in text
751
+
752
+ # -- T2: positioned macro substitution ------------------------------------
753
+
754
+ def _build_macro_evaluator(self, model: Any):
755
+ """Build a MacroEvaluator seeded the way SQLMesh seeds it for a model.
756
+
757
+ Passing the model's ``python_env`` to the constructor initialises both
758
+ the macro registry (so project-defined Python macros like
759
+ ``@my_macro(...)`` resolve) *and* the local variables (user vars via
760
+ ``__sqlmesh__vars__``). Without it, a model using a Python macro would
761
+ fail to resolve and fall all the way back to tier 3. ``date_dict``
762
+ supplies the temporal macro variables (``@start_ds``, ``@end_ds``, ...);
763
+ the exact window is irrelevant to linting — only that the tokens resolve
764
+ to literals so the SQL parses and positions map.
765
+ """
766
+ from sqlmesh.core.macros import MacroEvaluator
767
+ from sqlmesh.utils.date import date_dict, to_datetime
768
+
769
+ python_env = dict(getattr(model, "python_env", {}) or {})
770
+ evaluator = MacroEvaluator(
771
+ dialect=self._get_dialect() or "", python_env=python_env
772
+ )
773
+ anchor = to_datetime(getattr(model, "start", None) or "1970-01-01")
774
+ evaluator.locals.update(date_dict(anchor, anchor, anchor))
775
+ return evaluator
776
+
777
+ def _resolve_macro_spans(
778
+ self, model: Any, body: str, spans: list[tuple[int, int]]
779
+ ) -> Optional[list[str]]:
780
+ """Resolve each macro span to a single inline SQL string.
781
+
782
+ Returns replacements aligned with ``spans``, or ``None`` if any span is
783
+ structural (expands to multiple expressions, e.g. ``@EACH``) or cannot
784
+ be resolved — in which case the caller safely defers to tier 3.
785
+ """
786
+ import sqlglot
787
+
788
+ try:
789
+ evaluator = self._build_macro_evaluator(model)
790
+ except Exception as err: # pragma: no cover - defensive
791
+ templater_logger.debug("Could not build a SQLMesh macro evaluator: %s", err)
792
+ return None
793
+
794
+ dialect = self._get_dialect()
795
+ replacements: list[str] = []
796
+ for start, end in spans:
797
+ text = body[start:end]
798
+ parsed = None
799
+ for candidate in (dialect, None):
800
+ try:
801
+ parsed = sqlglot.parse_one(text, dialect=candidate)
802
+ break
803
+ except Exception:
804
+ continue
805
+ if parsed is None:
806
+ templater_logger.debug("Macro %r could not be parsed.", text)
807
+ return None
808
+ try:
809
+ out = evaluator.transform(parsed)
810
+ except Exception as err:
811
+ templater_logger.debug("Macro %r did not resolve inline: %s", text, err)
812
+ return None
813
+ # A list/tuple means a structural macro that splices multiple
814
+ # expressions into its parent — not a single-span substitution.
815
+ if out is None or isinstance(out, (list, tuple)) or not hasattr(out, "sql"):
816
+ return None
817
+ replacements.append(out.sql(dialect=dialect) if dialect else out.sql())
818
+ return replacements
819
+
820
+ # -- T3: coarse fallback rendering ----------------------------------------
821
+
822
+ def _render_body(self, model: Any) -> str:
823
+ """Render a model's own query to a SQL string.
824
+
825
+ ``expand=False`` avoids inlining upstream models, whose SQL has no
826
+ source location in this file.
827
+ """
828
+ try:
829
+ rendered = self.sqlmesh_context.render(model, expand=False)
830
+ except Exception as err:
831
+ if is_sqlmesh_exception(err) or isinstance(
832
+ err, (AttributeError, TypeError, ValueError)
833
+ ):
834
+ raise SQLTemplaterError(
835
+ f"SQLMesh rendering failed for model '{model.name}': {err}. "
836
+ "Check your SQLMesh model syntax and project configuration."
837
+ ) from None
838
+ raise
839
+ return rendered.sql() if hasattr(rendered, "sql") else str(rendered)
840
+
841
+ # -- TemplatedFile assembly -----------------------------------------------
842
+
843
+ def _make_file(
844
+ self,
845
+ fname: str,
846
+ source_str: str,
847
+ templated_str: str,
848
+ regions: list[Region],
849
+ ) -> tuple[TemplatedFile, list[SQLTemplaterError]]:
850
+ """Assemble a TemplatedFile from contiguous source/templated regions."""
851
+ raw_sliced: list[RawFileSlice] = []
852
+ sliced_file: list[TemplatedFileSlice] = []
853
+ for stype, s0, s1, t0, t1 in regions:
854
+ raw_sliced.append(
855
+ RawFileSlice(
856
+ raw=source_str[s0:s1],
857
+ slice_type=stype,
858
+ source_idx=s0,
859
+ block_idx=0,
860
+ )
861
+ )
862
+ sliced_file.append(
863
+ TemplatedFileSlice(
864
+ slice_type=stype,
865
+ source_slice=slice(s0, s1),
866
+ templated_slice=slice(t0, t1),
867
+ )
868
+ )
869
+ return (
870
+ TemplatedFile(
871
+ source_str=source_str,
872
+ templated_str=templated_str,
873
+ fname=fname,
874
+ sliced_file=sliced_file,
875
+ raw_sliced=raw_sliced,
876
+ ),
877
+ [],
878
+ )
879
+
880
+ def _passthrough_file(
881
+ self, fname: str, source_str: str, header_end: Optional[int]
882
+ ) -> tuple[TemplatedFile, list[SQLTemplaterError]]:
883
+ """Tiers 0/1: MODEL header (if any) → zero-length templated; body → literal.
884
+
885
+ The templated string is the body only, so the DDL header is never parsed
886
+ as SQL and the body keeps exact source positions.
887
+ """
888
+ body_start = header_end or 0
889
+ body = source_str[body_start:]
890
+ regions: list[Region] = []
891
+ if header_end:
892
+ regions.append(("templated", 0, body_start, 0, 0))
893
+ regions.append(("literal", body_start, len(source_str), 0, len(body)))
894
+ return self._make_file(fname, source_str, body, regions)
895
+
896
+ def _substitute_regions(
897
+ self,
898
+ source_str: str,
899
+ start: int,
900
+ end: int,
901
+ spans: list[tuple[int, int]],
902
+ replacements: list[str],
903
+ ) -> tuple[list[Region], str]:
904
+ """Splice replacements into ``source_str[start:end]``.
905
+
906
+ ``spans`` are offsets relative to that slice. Returns the regions
907
+ (with absolute source offsets) and the resulting templated string.
908
+ Literal runs between macros keep their exact source positions.
909
+ """
910
+ segment = source_str[start:end]
911
+ regions: list[Region] = []
912
+ parts: list[str] = []
913
+ t = 0 # running offset within the templated string
914
+ cursor = 0 # running offset within the segment
915
+ for (s, e), text in zip(spans, replacements):
916
+ if s > cursor: # literal run preceding this macro
917
+ lit = segment[cursor:s]
918
+ regions.append(("literal", start + cursor, start + s, t, t + len(lit)))
919
+ parts.append(lit)
920
+ t += len(lit)
921
+ regions.append(("templated", start + s, start + e, t, t + len(text)))
922
+ parts.append(text)
923
+ t += len(text)
924
+ cursor = e
925
+ if cursor < len(segment): # trailing literal
926
+ lit = segment[cursor:]
927
+ regions.append(("literal", start + cursor, end, t, t + len(lit)))
928
+ parts.append(lit)
929
+ return regions, "".join(parts)
930
+
931
+ def _substitute_file(
932
+ self,
933
+ fname: str,
934
+ source_str: str,
935
+ header_end: Optional[int],
936
+ spans: list[tuple[int, int]],
937
+ replacements: list[str],
938
+ ) -> tuple[TemplatedFile, list[SQLTemplaterError]]:
939
+ """Tier 2: splice resolved macro text into the verbatim source body."""
940
+ body_start = header_end or 0
941
+ regions, templated = self._substitute_regions(
942
+ source_str, body_start, len(source_str), spans, replacements
943
+ )
944
+ if header_end:
945
+ regions.insert(0, ("templated", 0, body_start, 0, 0))
946
+ return self._make_file(fname, source_str, templated, regions)
947
+
948
+ def _coarse_templated_file(
949
+ self,
950
+ fname: str,
951
+ source_str: str,
952
+ header_end: Optional[int],
953
+ rendered_sql: str,
954
+ ) -> tuple[TemplatedFile, list[SQLTemplaterError]]:
955
+ """Tier 3: whole body → one templated region. Safe, never collapses."""
956
+ body_start = header_end or 0
957
+ regions: list[Region] = []
958
+ if header_end:
959
+ regions.append(("templated", 0, body_start, 0, 0))
960
+ regions.append(("templated", body_start, len(source_str), 0, len(rendered_sql)))
961
+ return self._make_file(fname, source_str, rendered_sql, regions)
962
+
963
+ def _create_literal_templated_file(
964
+ self,
965
+ fname: str,
966
+ templated_content: str,
967
+ source_content: Optional[str] = None,
968
+ *,
969
+ was_rendered: bool = False,
970
+ ) -> tuple[TemplatedFile, list[SQLTemplaterError]]:
971
+ """Create a TemplatedFile with literal (no templating) content."""
972
+ # Use source_content if provided, otherwise use templated_content for both
973
+ actual_source = (
974
+ source_content if source_content is not None else templated_content
975
+ )
976
+
977
+ _slice_type = "templated" if was_rendered else "literal"
978
+
979
+ sliced_file = [
980
+ TemplatedFileSlice(
981
+ slice_type=_slice_type,
982
+ source_slice=slice(0, len(actual_source)),
983
+ templated_slice=slice(0, len(templated_content)),
984
+ )
985
+ ]
986
+
987
+ raw_sliced = [
988
+ RawFileSlice(
989
+ raw=actual_source,
990
+ slice_type=_slice_type,
991
+ source_idx=0,
992
+ block_idx=0,
993
+ )
994
+ ]
995
+
996
+ return (
997
+ TemplatedFile(
998
+ source_str=actual_source,
999
+ templated_str=templated_content,
1000
+ fname=fname,
1001
+ sliced_file=sliced_file,
1002
+ raw_sliced=raw_sliced,
1003
+ ),
1004
+ [],
1005
+ )
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqlfluff-templater-sqlmesh
3
+ Version: 4.5.0
4
+ Summary: Lint your SQLMesh project SQL
5
+ Author-email: Alan Cruickshank <alan@designingoverload.com>, Danny Jones <danny.jones1994@live.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2018-2026 Alan Cruickshank
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://www.sqlfluff.com
29
+ Project-URL: Documentation, https://docs.sqlfluff.com
30
+ Project-URL: Source, https://github.com/sqlfluff/sqlfluff
31
+ Project-URL: Changes, https://github.com/sqlfluff/sqlfluff/blob/main/CHANGELOG.md
32
+ Project-URL: Issue Tracker, https://github.com/sqlfluff/sqlfluff/issues
33
+ Project-URL: Twitter, https://twitter.com/SQLFluff
34
+ Project-URL: Chat, https://github.com/sqlfluff/sqlfluff#sqlfluff-on-slack
35
+ Keywords: sqlfluff,sql,linter,formatter,bigquery,clickhouse,databricks,duckdb,mysql,postgres,redshift,snowflake,sparksql,sqlite,sqlmesh
36
+ Classifier: Development Status :: 4 - Beta
37
+ Classifier: Environment :: Console
38
+ Classifier: Intended Audience :: Developers
39
+ Classifier: License :: OSI Approved :: MIT License
40
+ Classifier: Operating System :: Unix
41
+ Classifier: Operating System :: POSIX
42
+ Classifier: Operating System :: MacOS
43
+ Classifier: Operating System :: Microsoft :: Windows
44
+ Classifier: Programming Language :: Python
45
+ Classifier: Programming Language :: Python :: 3
46
+ Classifier: Programming Language :: Python :: 3.10
47
+ Classifier: Programming Language :: Python :: 3.11
48
+ Classifier: Programming Language :: Python :: 3.12
49
+ Classifier: Programming Language :: Python :: 3.13
50
+ Classifier: Programming Language :: Python :: Implementation :: CPython
51
+ Classifier: Topic :: Utilities
52
+ Classifier: Topic :: Software Development :: Quality Assurance
53
+ Requires-Python: >=3.10
54
+ Description-Content-Type: text/markdown
55
+ License-File: LICENSE.md
56
+ Requires-Dist: sqlfluff==4.5.0
57
+ Requires-Dist: sqlmesh>=0.80.0
58
+ Dynamic: license-file
59
+
60
+ # SQLMesh plugin for SQLFluff
61
+
62
+ This plugin works with [SQLFluff](https://pypi.org/project/sqlfluff/), the
63
+ SQL linter for humans, to correctly parse and compile SQL projects using
64
+ [SQLMesh](https://pypi.org/project/sqlmesh/).
65
+
66
+ For more details on how to use this plugin,
67
+ see the [SQLFluff documentation](https://docs.sqlfluff.com/).
@@ -0,0 +1,8 @@
1
+ sqlfluff_templater_sqlmesh/__init__.py,sha256=bmzzNS-YojfPxenSFmkJ5FtgnB61lfkpVLYMb-4e4_w,266
2
+ sqlfluff_templater_sqlmesh/templater.py,sha256=IpFO6FXkhEbYAu6ouXiNNpM9SNQx0UXo9XAYpgLp6m4,39863
3
+ sqlfluff_templater_sqlmesh-4.5.0.dist-info/licenses/LICENSE.md,sha256=Itzi3DZj0pnMwmBiiWR5gtdX5hVzkwgeWXNqnbM1x38,1078
4
+ sqlfluff_templater_sqlmesh-4.5.0.dist-info/METADATA,sha256=KZiFQkZZpIa1C9HvgWkBeNebz6u2hjlguj_VPUPv3QA,3346
5
+ sqlfluff_templater_sqlmesh-4.5.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
6
+ sqlfluff_templater_sqlmesh-4.5.0.dist-info/entry_points.txt,sha256=_4mRGSv1uIvnUE4AYHUa4_mPreysG3TMS-bC4WsizKI,67
7
+ sqlfluff_templater_sqlmesh-4.5.0.dist-info/top_level.txt,sha256=B9m-AGYfWOW15xDPcY4DxBz7CRtN2gZWgH4_mUJdQeo,27
8
+ sqlfluff_templater_sqlmesh-4.5.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [sqlfluff]
2
+ sqlfluff_templater_sqlmesh = sqlfluff_templater_sqlmesh
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2018-2026 Alan Cruickshank
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ sqlfluff_templater_sqlmesh