ripple-sql 0.1.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.
Files changed (72) hide show
  1. ripple/__init__.py +31 -0
  2. ripple/answer.py +473 -0
  3. ripple/answer_page.py +214 -0
  4. ripple/cache.py +80 -0
  5. ripple/ci.py +422 -0
  6. ripple/ci_signature.py +374 -0
  7. ripple/cli.py +733 -0
  8. ripple/doctor.py +225 -0
  9. ripple/engine/__init__.py +111 -0
  10. ripple/engine/budget.py +86 -0
  11. ripple/engine/column_lineage.py +112 -0
  12. ripple/engine/column_ref.py +818 -0
  13. ripple/engine/cte_tracing.py +1309 -0
  14. ripple/engine/dependencies.py +466 -0
  15. ripple/engine/dialect.py +132 -0
  16. ripple/engine/dispatch.py +12 -0
  17. ripple/engine/extraction.py +27 -0
  18. ripple/engine/jinja.py +282 -0
  19. ripple/engine/json_sources.py +241 -0
  20. ripple/engine/macro_source.py +127 -0
  21. ripple/engine/pipeline.py +265 -0
  22. ripple/engine/preprocess.py +174 -0
  23. ripple/engine/safe_gen.py +21 -0
  24. ripple/engine/schema_qualification.py +151 -0
  25. ripple/engine/scope.py +488 -0
  26. ripple/engine/select_sources.py +1038 -0
  27. ripple/engine/sql_script.py +729 -0
  28. ripple/engine/statement.py +449 -0
  29. ripple/engine/tech_debt.py +169 -0
  30. ripple/engine/tsql_catalog.py +83 -0
  31. ripple/engine/tsql_scalar_vars.py +248 -0
  32. ripple/engine/tsql_tvf.py +653 -0
  33. ripple/engine/tsql_xml.py +97 -0
  34. ripple/engine/types.py +167 -0
  35. ripple/engine/unused_deps.py +555 -0
  36. ripple/engine/validation.py +158 -0
  37. ripple/graph.py +1499 -0
  38. ripple/home.py +232 -0
  39. ripple/loaders/__init__.py +7 -0
  40. ripple/loaders/dbt.py +359 -0
  41. ripple/loaders/dbt_config.py +339 -0
  42. ripple/loaders/identity.py +328 -0
  43. ripple/loaders/sidecar.py +65 -0
  44. ripple/loaders/sqldir.py +262 -0
  45. ripple/loaders/types.py +197 -0
  46. ripple/lookml.py +163 -0
  47. ripple/mcp_server.py +600 -0
  48. ripple/names.py +40 -0
  49. ripple/project.py +167 -0
  50. ripple/py.typed +0 -0
  51. ripple/render.py +426 -0
  52. ripple/render_shims.py +209 -0
  53. ripple/schemas.py +155 -0
  54. ripple/semantic.py +232 -0
  55. ripple/server.py +184 -0
  56. ripple/sourcefiles.py +64 -0
  57. ripple/star_resolution.py +100 -0
  58. ripple/static/answer.css +146 -0
  59. ripple/static/answer.html +358 -0
  60. ripple/static/answer_twin.js +299 -0
  61. ripple/static/explore.js +133 -0
  62. ripple/usage/__init__.py +18 -0
  63. ripple/usage/cli.py +78 -0
  64. ripple/usage/collect.py +315 -0
  65. ripple/usage/discover.py +190 -0
  66. ripple/usage/ingest.py +414 -0
  67. ripple/usage/report.py +131 -0
  68. ripple_sql-0.1.0.dist-info/METADATA +285 -0
  69. ripple_sql-0.1.0.dist-info/RECORD +72 -0
  70. ripple_sql-0.1.0.dist-info/WHEEL +4 -0
  71. ripple_sql-0.1.0.dist-info/entry_points.txt +3 -0
  72. ripple_sql-0.1.0.dist-info/licenses/LICENSE +202 -0
ripple/project.py ADDED
@@ -0,0 +1,167 @@
1
+ """Project discovery and loading.
2
+
3
+ Finds what kind of SQL project we're in and yields models with parseable SQL.
4
+ Three modes, best evidence first:
5
+
6
+ 1. dbt with a compiled manifest (target/manifest.json): exact compiled SQL,
7
+ explicit dependency edges, adapter-declared dialect.
8
+ 2. dbt raw (no manifest): models/**/*.sql with lightweight Jinja conversion.
9
+ ref('x') becomes x, source('a','b') becomes a__b.
10
+ 3. Plain SQL directory: every .sql file is a model named by its stem.
11
+
12
+ Models from mode 2 and 3 carry lower baseline trust than mode 1; the graph
13
+ layer folds that into per-edge confidence.
14
+
15
+ This module is the public API and the mode dispatcher; the loaders package
16
+ does the work.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from pathlib import Path
22
+
23
+ from ripple.loaders.dbt import _load_one_dbt, resolve_dbt_dialect
24
+ from ripple.loaders.dbt_config import _dbt_owned_dirs
25
+ from ripple.loaders.identity import _apply_ingested_schemas, _qualify_collisions
26
+ from ripple.loaders.sqldir import _load_sql_dir
27
+ from ripple.loaders.types import (
28
+ ADAPTER_TO_DIALECT,
29
+ DEFAULT_DIALECT,
30
+ IGNORE_DIRS,
31
+ Model,
32
+ Project,
33
+ _find_dbt_projects,
34
+ find_project_root,
35
+ relative_posix,
36
+ )
37
+
38
+ __all__ = [
39
+ "ADAPTER_TO_DIALECT",
40
+ "DEFAULT_DIALECT",
41
+ "IGNORE_DIRS",
42
+ "Model",
43
+ "Project",
44
+ "find_project_root",
45
+ "load_project",
46
+ "resolve_dbt_dialect",
47
+ ]
48
+
49
+
50
+ def load_project(path: str | Path = ".", dialect: str | None = None) -> Project:
51
+ start = Path(path).resolve()
52
+ root = find_project_root(start)
53
+ # If we're standing above several dbt projects (a monorepo), gather them
54
+ # all. find_project_root only walks UP to the nearest one, so scan DOWN.
55
+ scan_root = root if (root / "dbt_project.yml").exists() else start
56
+ dbt_dirs = _find_dbt_projects(scan_root)
57
+
58
+ if len(dbt_dirs) > 1:
59
+ project = _load_monorepo(scan_root, dbt_dirs, dialect)
60
+ elif len(dbt_dirs) == 1:
61
+ project = _load_one_dbt(dbt_dirs[0], dialect)
62
+ elif (root / "dbt_project.yml").exists():
63
+ project = _load_one_dbt(root, dialect)
64
+ else:
65
+ project = _load_sql_dir(start, dialect)
66
+ if not project.models and dbt_dirs:
67
+ project = _sql_beside_dbt(start, dbt_dirs, dialect) or project
68
+ elif project.models and dbt_dirs:
69
+ # a repo can hold raw build SQL beside its dbt project(s), even
70
+ # inside a project dir, beside its models/ (nyc cbbr, holdout
71
+ # round 4). Only the directories dbt actually owns are pruned;
72
+ # pruning whole project dirs made cbbr/sql invisible.
73
+ owned = [d for pdir in dbt_dirs for d in _dbt_owned_dirs(pdir)]
74
+ # the dbt project's declared warehouse is the best evidence for the
75
+ # SQL beside it; sniffing those files alone reported "Assuming
76
+ # postgres" inside Snowflake's own tasty_bytes demo
77
+ inherited = None if project.dialect_assumed else project.dialect
78
+ stray = _load_sql_dir(scan_root, dialect or inherited, exclude=owned)
79
+ if stray.models:
80
+ project.models.extend(stray.models)
81
+ project.warnings.extend(w for w in stray.warnings if not w.startswith("No .sql files"))
82
+ project.warnings.append(
83
+ f"{len(stray.models)} plain-SQL models outside the dbt "
84
+ f"project{'s' if len(dbt_dirs) != 1 else ''} were loaded as well."
85
+ )
86
+ _qualify_collisions(project)
87
+ _apply_ingested_schemas(project, extra_roots=(scan_root, start))
88
+ from ripple.lookml import load_lookml_downstream
89
+ from ripple.semantic import load_downstream
90
+
91
+ downstream = []
92
+ if project.mode != "sql-dir":
93
+ downstream += load_downstream(project.root, {m.name for m in project.models})
94
+ downstream += load_lookml_downstream(project.root) # Looker, where the extra is installed
95
+ project.downstream = downstream
96
+ return project
97
+
98
+
99
+ def _sql_beside_dbt(start: Path, dbt_dirs: list[Path], dialect: str | None) -> Project | None:
100
+ """The repo's other SQL, when the dbt projects define no models.
101
+
102
+ dagster-open-platform ships a dbt project of macros, tests and snapshots
103
+ with no models/ directory, and fifteen plain .sql files beside it. Reporting
104
+ zero there was true of the dbt project and useless about the repo.
105
+
106
+ Deliberately narrow. It runs only when the dbt side found nothing, so a repo
107
+ that already resolves cannot start counting its migrations as models. The
108
+ dbt directories are excluded, since their macros are Jinja fragments rather
109
+ than models.
110
+ """
111
+ project = _load_sql_dir(start, dialect, exclude=dbt_dirs)
112
+ if not project.models:
113
+ return None
114
+ project.warnings.insert(
115
+ 0,
116
+ "The dbt project here defines no models, so this is the rest of the repo's SQL instead.",
117
+ )
118
+ return project
119
+
120
+
121
+ def _load_monorepo(root: Path, project_dirs: list[Path], dialect: str | None) -> Project:
122
+ """Union of several dbt projects, each parsed in its own dialect.
123
+
124
+ Cross-project references stay unresolved and degrade to review_required in
125
+ the graph layer, which is honest: one warehouse rarely reads another's
126
+ tables directly, and where it does we don't pretend to know the columns.
127
+ """
128
+ from collections import Counter
129
+
130
+ all_models: list[Model] = []
131
+ all_sources: list[Model] = []
132
+ macro_sources: list = []
133
+ inner_warnings: list[str] = []
134
+ island_dialects: dict[str, str] = {}
135
+ merged_vars: dict = {}
136
+
137
+ for pdir in project_dirs:
138
+ sub = _load_one_dbt(pdir, dialect)
139
+ for model in sub.models:
140
+ model.dialect = sub.dialect
141
+ model.jinja_vars = sub.jinja_vars
142
+ all_models.extend(sub.models)
143
+ all_sources.extend(sub.sources)
144
+ macro_sources.extend(sub.macro_sources)
145
+ inner_warnings.extend(sub.warnings)
146
+ merged_vars.update(sub.jinja_vars)
147
+ label = relative_posix(pdir, root) or pdir.name
148
+ island_dialects[label] = sub.dialect
149
+
150
+ dialect_counts = Counter(m.dialect for m in all_models if m.dialect)
151
+ primary = dialect or (dialect_counts.most_common(1)[0][0] if dialect_counts else "snowflake")
152
+ listing = ", ".join(f"{p} ({d})" for p, d in sorted(island_dialects.items()))
153
+ warnings = [
154
+ f"Found {len(project_dirs)} dbt projects, each parsed in its own dialect: {listing}."
155
+ ] + inner_warnings
156
+
157
+ return Project(
158
+ root=root,
159
+ dialect=primary,
160
+ mode="dbt-monorepo",
161
+ models=all_models,
162
+ sources=all_sources,
163
+ warnings=warnings,
164
+ macro_sources=macro_sources,
165
+ package_name=next((pkg for pkg, _ in macro_sources if pkg), None),
166
+ jinja_vars=merged_vars,
167
+ )
ripple/py.typed ADDED
File without changes
ripple/render.py ADDED
@@ -0,0 +1,426 @@
1
+ """Tier-b Jinja: really render dbt models instead of regex-stripping them.
2
+
3
+ Three tiers of evidence for a model's SQL (best first):
4
+ a. compiled SQL from target/manifest.json (exact, handled in project.py)
5
+ b. jinja2 render with a real macro registry (this module): control flow
6
+ executes, the project's own macros expand, package-namespaced calls
7
+ like {{ ga4.unnest_key(...) }} resolve, and adapter.dispatch finds
8
+ default__/<dialect>__ implementations
9
+ c. the regex converter in engine/jinja.py (last resort)
10
+
11
+ A render that fails falls through to tier c. A model that still won't parse
12
+ is reported honestly, never guessed at.
13
+
14
+ The registry design mirrors sqlmesh's dbt loader: macros are registered as
15
+ late-bound wrappers before anything compiles, so compile order never
16
+ matters; unknown macros degrade to NULL (parseable, no fake lineage).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import datetime as _datetime
22
+ import itertools as _itertools
23
+ import re
24
+ import re as _re_mod
25
+
26
+ from jinja2 import BaseLoader, ChainableUndefined, Environment
27
+ from jinja2.utils import missing as _missing
28
+
29
+ from ripple.render_shims import BUILTIN_PACKAGE_MACROS as _BUILTIN_PACKAGE_MACROS
30
+
31
+ _IDENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z")
32
+
33
+
34
+ class _Anything(ChainableUndefined):
35
+ """Undefined that survives calls, attribute access, and iteration.
36
+
37
+ Unknown macros render as NULL: '{{ unknown_macro(x) }} as col' becomes
38
+ 'NULL as col', which parses, keeps the column, and carries no fake
39
+ lineage. The engine's honesty labels take it from there.
40
+
41
+ A pure undefined name/attribute chain that was never called
42
+ ({{params.dataset_name_raw}}, Airflow-templated dbt) is different: it
43
+ names something outside the render's knowledge, and the round-trip
44
+ convention cites it as written. Such a chain stringifies back to its
45
+ own {{...}} spelling; the relation-position rewrite in preprocess
46
+ carries it into the emitted table name. Calls, and attribute misses on
47
+ DEFINED objects, still render NULL.
48
+ """
49
+
50
+ __slots__ = ("_chain",)
51
+
52
+ def __init__(self, *args, **kwargs):
53
+ super().__init__(*args, **kwargs)
54
+ name = self._undefined_name
55
+ self._chain = (
56
+ name
57
+ if self._undefined_obj is _missing and isinstance(name, str) and _IDENT.match(name)
58
+ else None
59
+ )
60
+
61
+ def __getattr__(self, name):
62
+ if name.startswith("_"):
63
+ raise AttributeError(name)
64
+ if self._chain and _IDENT.match(name):
65
+ out = _Anything(name=name)
66
+ out._chain = f"{self._chain}.{name}"
67
+ return out
68
+ return self
69
+
70
+ def __getitem__(self, key):
71
+ return self.__getattr__(key) if isinstance(key, str) else self
72
+
73
+ def __call__(self, *args, **kwargs):
74
+ called = _Anything(name=self._undefined_name)
75
+ called._chain = None
76
+ return called
77
+
78
+ def __iter__(self):
79
+ return iter(())
80
+
81
+ def __str__(self):
82
+ if self._chain:
83
+ return "{{" + self._chain + "}}"
84
+ return "NULL"
85
+
86
+
87
+ def _ref(*args, **kwargs) -> str:
88
+ # ref('model') or ref('package', 'model')
89
+ return str(args[-1]) if args else "unknown_ref"
90
+
91
+
92
+ def _source(source_name: str, table_name: str, **kwargs) -> str:
93
+ return f"{source_name}__{table_name}"
94
+
95
+
96
+ class _VarValue(str):
97
+ """Missing-var stub: renders as the quoted var name, survives range()/int()."""
98
+
99
+ __slots__ = ()
100
+
101
+ def __index__(self):
102
+ return 0
103
+
104
+ def __int__(self):
105
+ return 0
106
+
107
+ def __getitem__(self, item):
108
+ return _VarValue(self.strip("'"))
109
+
110
+
111
+ _MISSING = object()
112
+
113
+
114
+ def _make_var(declared: dict | None = None):
115
+ """dbt precedence minus CLI --vars: project vars, call default, name stub."""
116
+ declared = declared or {}
117
+
118
+ def _var(name: str, default=_MISSING):
119
+ if name in declared:
120
+ return declared[name]
121
+ # var('x', none) must return None (dbt semantics): no-default is a sentinel
122
+ return default if default is not _MISSING else _VarValue(f"'{name}'")
123
+
124
+ return _var
125
+
126
+
127
+ def _config(*args, **kwargs) -> str:
128
+ return ""
129
+
130
+
131
+ class _MacroReturn(Exception):
132
+ """dbt's return(): control flow out of a macro, caught at invocation."""
133
+
134
+ def __init__(self, value=None):
135
+ super().__init__()
136
+ self.value = value
137
+
138
+
139
+ def _return(value=None):
140
+ raise _MacroReturn(value)
141
+
142
+
143
+ def _returnable(macro):
144
+ """A macro call evaluates to its return() value; body output otherwise."""
145
+
146
+ def call(*args, **kwargs):
147
+ try:
148
+ return macro(*args, **kwargs)
149
+ except _MacroReturn as ret:
150
+ return ret.value
151
+
152
+ return call
153
+
154
+
155
+ class _MacroRegistry:
156
+ """(package, name) -> compiled jinja macro. Shared and mutable: late binding."""
157
+
158
+ def __init__(self, root_package: str | None, dialect: str):
159
+ self.root_package = root_package
160
+ self.dialect = dialect
161
+ self.macros: dict[tuple[str | None, str], object] = {}
162
+ self.package_order: list[str | None] = []
163
+
164
+ def register(self, package: str | None, name: str, macro) -> None:
165
+ self.macros[(package, name)] = macro
166
+ if package not in self.package_order:
167
+ self.package_order.append(package)
168
+
169
+ def lookup(self, name: str, package: str | None = None):
170
+ """dbt search order: explicit package, else root project, then others."""
171
+ if package is not None:
172
+ return self.macros.get((package, name))
173
+ for pkg in [self.root_package] + [p for p in self.package_order if p != self.root_package]:
174
+ hit = self.macros.get((pkg, name))
175
+ if hit is not None:
176
+ return hit
177
+ return None
178
+
179
+ def dispatch(self, macro_name: str, macro_namespace: str | None = None):
180
+ for candidate in (f"{self.dialect}__{macro_name}", f"default__{macro_name}"):
181
+ hit = self.lookup(candidate, None)
182
+ if hit is None and macro_namespace:
183
+ hit = self.lookup(candidate, macro_namespace)
184
+ if hit is not None:
185
+ return hit
186
+ return _Anything(name=macro_name)
187
+
188
+
189
+ class _LateBound:
190
+ """Callable placeholder registered before compile; resolves at call time."""
191
+
192
+ __slots__ = ("_registry", "_package", "_name")
193
+
194
+ def __init__(self, registry: _MacroRegistry, package: str | None, name: str):
195
+ self._registry = registry
196
+ self._package = package
197
+ self._name = name
198
+
199
+ def __call__(self, *args, **kwargs):
200
+ macro = self._registry.lookup(self._name, self._package)
201
+ if macro is None:
202
+ return _Anything(name=self._name)
203
+ return macro(*args, **kwargs)
204
+
205
+
206
+ class _MacroNamespace:
207
+ """{{ pkg.macro(...) }} resolves through the registry; unknown attrs -> NULL."""
208
+
209
+ def __init__(self, registry: _MacroRegistry | None, package: str, var_fn=None):
210
+ self._registry = registry
211
+ self._package = package
212
+ self._var_fn = var_fn
213
+
214
+ def __getattr__(self, name: str):
215
+ if name.startswith("__"):
216
+ raise AttributeError(name)
217
+ if self._registry is not None and self._registry.lookup(name, self._package) is not None:
218
+ return _LateBound(self._registry, self._package, name)
219
+ builtin = _BUILTIN_PACKAGE_MACROS.get((self._package, name))
220
+ if builtin is not None:
221
+ if getattr(builtin, "wants_var", False) and self._var_fn is not None:
222
+ import functools
223
+
224
+ return functools.partial(builtin, _var=self._var_fn)
225
+ return builtin
226
+ return _Anything(name=f"{self._package}.{name}")
227
+
228
+
229
+ class _AdapterNoOp:
230
+ """Unmodeled adapter surface: calls return '', attribute chains keep
231
+ no-opping, and str() is empty so expression position never leaks a
232
+ function repr into SQL."""
233
+
234
+ __slots__ = ()
235
+
236
+ def __call__(self, *args, **kwargs):
237
+ return ""
238
+
239
+ def __str__(self):
240
+ return ""
241
+
242
+ def __getattr__(self, name):
243
+ if name.startswith("__"):
244
+ raise AttributeError(name)
245
+ return self
246
+
247
+
248
+ _ADAPTER_NOOP = _AdapterNoOp()
249
+
250
+
251
+ class _Adapter:
252
+ """adapter.dispatch resolves against the registry; everything else no-ops."""
253
+
254
+ def __init__(self, registry: _MacroRegistry | None = None):
255
+ self._registry = registry
256
+
257
+ def dispatch(self, macro_name, macro_namespace=None):
258
+ if self._registry is not None:
259
+ return self._registry.dispatch(macro_name, macro_namespace)
260
+ return _Anything(name=str(macro_name))
261
+
262
+ def quote(self, name):
263
+ # identity, not '""': ground truth cites unquoted spellings, and a
264
+ # no-op '' poisons comparisons like elementary's edr_quote_column
265
+ # (cycle-11 review, F5)
266
+ return name
267
+
268
+ def __getattr__(self, name):
269
+ return _ADAPTER_NOOP
270
+
271
+
272
+ class _Modules:
273
+ datetime = _datetime
274
+ re = _re_mod
275
+ itertools = _itertools
276
+
277
+
278
+ _SET_BLOCK_RE = re.compile(r"{%-?\s*(call|materialization|test)\b.*?{%-?\s*end\1\s*-?%}", re.S)
279
+ _MACRO_DEF_RE = re.compile(r"{%-?\s*macro\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(")
280
+ _MACRO_DEF_SUB_RE = re.compile(r"({%-?\s*macro\s+)([A-Za-z_][A-Za-z0-9_]*)(\s*\()")
281
+ # Definition names are mangled before compile so no call site can reach the
282
+ # raw jinja macro: a same-file sibling (or recursive) call would bypass the
283
+ # registry wrapper and its _MacroReturn would unwind through the CALLER's
284
+ # remainder into the caller's wrapper, returning the inner macro's value from
285
+ # the outer macro (cycle-11 review, F1: shopify max_columns came out 24, not
286
+ # 9966). With the rename every call resolves late through the registry, so
287
+ # each invocation catches only its own return(). Must not start with "_":
288
+ # jinja does not export underscore names from template modules.
289
+ _MACRO_DEF_PREFIX = "ripple_macro_def__"
290
+
291
+
292
+ def build_environment(
293
+ model_name: str,
294
+ registry: _MacroRegistry | None = None,
295
+ project_vars: dict | None = None,
296
+ ) -> Environment:
297
+ env = Environment(
298
+ loader=BaseLoader(),
299
+ undefined=_Anything,
300
+ extensions=["jinja2.ext.do", "jinja2.ext.loopcontrols"],
301
+ )
302
+ dialect = registry.dialect if registry else "snowflake"
303
+ env.globals.update(
304
+ {
305
+ "ref": _ref,
306
+ "source": _source,
307
+ "var": _make_var(project_vars),
308
+ "config": _config,
309
+ "this": model_name,
310
+ "target": {"name": "prod", "schema": "analytics", "database": "prod", "type": dialect},
311
+ # True keeps the incremental WHERE, which proves columns that a
312
+ # SELECT * chain from an undeclared source would otherwise hide
313
+ "is_incremental": lambda: True,
314
+ "should_full_refresh": lambda: False,
315
+ "execute": False,
316
+ "adapter": _Adapter(registry),
317
+ "return": _return,
318
+ "run_query": lambda *a, **k: [],
319
+ "log": lambda *a, **k: "",
320
+ "exceptions": _Anything(name="exceptions"),
321
+ "modules": _Modules(),
322
+ }
323
+ )
324
+ # vendored macros still win: the namespace checks the registry first
325
+ for pkg in {p for p, _ in _BUILTIN_PACKAGE_MACROS}:
326
+ env.globals.setdefault(pkg, _MacroNamespace(registry, pkg, var_fn=_make_var(project_vars)))
327
+ return env
328
+
329
+
330
+ def load_project_macros(env: Environment, macro_pairs, registry: _MacroRegistry) -> int:
331
+ """Register a dbt project's macros, package-attributed and late-bound."""
332
+ # pass 0: learn every macro name; wire wrappers + namespaces before compiling
333
+ names_by_pkg: dict[str | None, set[str]] = {}
334
+ for pkg, source in macro_pairs:
335
+ for name in _MACRO_DEF_RE.findall(source):
336
+ names_by_pkg.setdefault(pkg, set()).add(name)
337
+ for pkg, names in names_by_pkg.items():
338
+ if pkg is not None:
339
+ env.globals.setdefault(pkg, _MacroNamespace(registry, pkg))
340
+ for name in names:
341
+ env.globals.setdefault(name, _LateBound(registry, None, name))
342
+
343
+ # pass 1: compile once; real macros land in the shared registry.
344
+ # Sibling names resolve through module vars bound to the OWN package
345
+ # first (dbt's search order), then the bare-name globals from pass 0.
346
+ loaded = 0
347
+ for pkg, source in macro_pairs:
348
+ if "{% macro" not in source and "{%- macro" not in source:
349
+ continue
350
+ mangled = _MACRO_DEF_SUB_RE.sub(rf"\g<1>{_MACRO_DEF_PREFIX}\g<2>\g<3>", source)
351
+ try:
352
+ template = env.from_string(_SET_BLOCK_RE.sub("", mangled))
353
+ module = template.make_module(
354
+ vars={name: _LateBound(registry, pkg, name) for name in names_by_pkg.get(pkg, ())}
355
+ )
356
+ except Exception:
357
+ continue
358
+ for name in dir(module):
359
+ if name.startswith(_MACRO_DEF_PREFIX):
360
+ real_name = name[len(_MACRO_DEF_PREFIX) :]
361
+ elif name.startswith("_"):
362
+ continue
363
+ else:
364
+ real_name = name
365
+ attr = getattr(module, name)
366
+ if callable(attr):
367
+ registry.register(pkg, real_name, _returnable(attr))
368
+ loaded += 1
369
+ return loaded
370
+
371
+
372
+ def build_project_environment(
373
+ macro_sources=None,
374
+ root_package: str | None = None,
375
+ dialect: str = "snowflake",
376
+ project_vars: dict | None = None,
377
+ ) -> Environment:
378
+ """Environment with the project's macros loaded.
379
+
380
+ macro_sources: list of (package_name | None, source_text). Bare strings
381
+ are accepted and treated as root-package.
382
+ """
383
+ registry = _MacroRegistry(root_package, dialect)
384
+ env = build_environment(model_name="this_model", registry=registry, project_vars=project_vars)
385
+ pairs = []
386
+ for item in macro_sources or []:
387
+ if isinstance(item, str):
388
+ pairs.append((root_package, item))
389
+ else:
390
+ pairs.append(item)
391
+ if pairs:
392
+ load_project_macros(env, pairs, registry)
393
+ return env
394
+
395
+
396
+ def render_dbt_sql(
397
+ sql: str,
398
+ model_name: str,
399
+ env: Environment | None = None,
400
+ jinja_vars: dict | None = None,
401
+ ) -> str:
402
+ """Render a dbt model's Jinja to plain SQL. Raises on failure."""
403
+ trimmed = _SET_BLOCK_RE.sub("", sql)
404
+ if env is None:
405
+ env = build_environment(model_name, project_vars=jinja_vars)
406
+ context = {"this": model_name}
407
+ if jinja_vars:
408
+ # monorepo shares one env; each island's models still see their own vars
409
+ context["var"] = _make_var(jinja_vars)
410
+ rendered = env.from_string(trimmed).render(**context)
411
+ # a var value can itself be jinja ("{{ ref('sessions') }}" in
412
+ # dbt_project.yml); re-render bounded, then give up honestly
413
+ for _ in range(3):
414
+ if "{{" not in rendered and "{%" not in rendered:
415
+ break
416
+ try:
417
+ again = env.from_string(rendered).render(**context)
418
+ except Exception:
419
+ break
420
+ if again == rendered:
421
+ break
422
+ rendered = again
423
+ # a render that eats the whole model is a failure, not a success
424
+ if not re.search(r"\bselect\b", rendered, re.I):
425
+ raise ValueError("render produced no SELECT")
426
+ return rendered