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.
- sqlfluff_templater_sqlmesh/__init__.py +10 -0
- sqlfluff_templater_sqlmesh/templater.py +1005 -0
- sqlfluff_templater_sqlmesh-4.5.0.dist-info/METADATA +67 -0
- sqlfluff_templater_sqlmesh-4.5.0.dist-info/RECORD +8 -0
- sqlfluff_templater_sqlmesh-4.5.0.dist-info/WHEEL +5 -0
- sqlfluff_templater_sqlmesh-4.5.0.dist-info/entry_points.txt +2 -0
- sqlfluff_templater_sqlmesh-4.5.0.dist-info/licenses/LICENSE.md +21 -0
- sqlfluff_templater_sqlmesh-4.5.0.dist-info/top_level.txt +1 -0
|
@@ -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,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
|