weaverstack 0.1.1__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 (127) hide show
  1. weaver/__init__.py +59 -0
  2. weaver/build_bundle/__init__.py +109 -0
  3. weaver/build_bundle/aliases.py +325 -0
  4. weaver/build_bundle/bundle.py +359 -0
  5. weaver/build_bundle/catalogue_actions.py +275 -0
  6. weaver/build_bundle/changes.py +186 -0
  7. weaver/build_bundle/endpoints.py +83 -0
  8. weaver/build_bundle/executors/__init__.py +69 -0
  9. weaver/build_bundle/executors/alias.py +202 -0
  10. weaver/build_bundle/executors/base.py +132 -0
  11. weaver/build_bundle/executors/folder.py +71 -0
  12. weaver/build_bundle/executors/load_file.py +205 -0
  13. weaver/build_bundle/executors/spark_case.py +26 -0
  14. weaver/build_bundle/executors/spark_schema.py +60 -0
  15. weaver/build_bundle/executors/spark_sql.py +59 -0
  16. weaver/build_bundle/executors/spark_sql_batch.py +57 -0
  17. weaver/build_bundle/executors/spark_table.py +213 -0
  18. weaver/build_bundle/executors/sql_endpoint_refresh.py +34 -0
  19. weaver/build_bundle/executors/tsql.py +81 -0
  20. weaver/build_bundle/incremental.py +288 -0
  21. weaver/build_bundle/installer.py +384 -0
  22. weaver/build_bundle/models.py +288 -0
  23. weaver/build_bundle/payloads.py +34 -0
  24. weaver/build_bundle/physical.py +625 -0
  25. weaver/build_bundle/planner.py +389 -0
  26. weaver/build_bundle/prune.py +620 -0
  27. weaver/build_bundle/report.py +108 -0
  28. weaver/build_bundle/stages.py +196 -0
  29. weaver/build_bundle/targets.py +272 -0
  30. weaver/build_bundle/workflow.py +585 -0
  31. weaver/catalogue/__init__.py +73 -0
  32. weaver/catalogue/builtin.py +238 -0
  33. weaver/catalogue/claims.py +121 -0
  34. weaver/catalogue/projection.py +437 -0
  35. weaver/catalogue/reader.py +152 -0
  36. weaver/catalogue/reconcile.py +231 -0
  37. weaver/catalogue/render.py +410 -0
  38. weaver/catalogue/state.py +660 -0
  39. weaver/catalogue/tables.py +648 -0
  40. weaver/config.py +178 -0
  41. weaver/declaration/__init__.py +171 -0
  42. weaver/declaration/columns.py +223 -0
  43. weaver/declaration/ddl.py +266 -0
  44. weaver/declaration/dependencies.py +544 -0
  45. weaver/declaration/graph.py +240 -0
  46. weaver/declaration/item_dependencies.py +292 -0
  47. weaver/declaration/load.py +191 -0
  48. weaver/declaration/metadata.py +1405 -0
  49. weaver/declaration/model.py +448 -0
  50. weaver/declaration/references.py +294 -0
  51. weaver/declaration/repository.py +959 -0
  52. weaver/declaration/schemas.py +135 -0
  53. weaver/declaration/source.py +674 -0
  54. weaver/declaration/spark_load.py +759 -0
  55. weaver/declaration/sql_shaping.py +591 -0
  56. weaver/declaration/templates/ddl/declared_create_table.sql +64 -0
  57. weaver/declaration/templates/ddl/infer_create_table.sql +97 -0
  58. weaver/declaration/templates/ddl/metadata_column_validation.sql +30 -0
  59. weaver/declaration/templates/load/column_metadata.sql +40 -0
  60. weaver/declaration/templates/load/full_replace_body.sql +21 -0
  61. weaver/declaration/templates/load/install_load_procedure.sql +27 -0
  62. weaver/declaration/templates/load/load_procedure.sql +48 -0
  63. weaver/declaration/templates/load/primary_key_body.sql +113 -0
  64. weaver/declaration/tsql_ddl.py +468 -0
  65. weaver/declaration/tsql_load.py +417 -0
  66. weaver/declaration/warehouse_type_mapping.yml +93 -0
  67. weaver/diagnostics.py +247 -0
  68. weaver/errors.py +61 -0
  69. weaver/etl.py +469 -0
  70. weaver/fabric/__init__.py +107 -0
  71. weaver/fabric/auth.py +137 -0
  72. weaver/fabric/capacity.py +143 -0
  73. weaver/fabric/client.py +147 -0
  74. weaver/fabric/environment.py +460 -0
  75. weaver/fabric/livy.py +478 -0
  76. weaver/fabric/notebooks.py +201 -0
  77. weaver/fabric/onelake.py +263 -0
  78. weaver/fabric/resolution.py +344 -0
  79. weaver/fabric/resources.py +245 -0
  80. weaver/fabric/session.py +148 -0
  81. weaver/fabric/shortcuts.py +120 -0
  82. weaver/fabric/sql.py +118 -0
  83. weaver/fabric/store.py +198 -0
  84. weaver/initialise.py +209 -0
  85. weaver/lakehouse.py +386 -0
  86. weaver/load.py +474 -0
  87. weaver/load_execution.py +483 -0
  88. weaver/load_plan.py +912 -0
  89. weaver/load_report.py +330 -0
  90. weaver/load_resolution.py +386 -0
  91. weaver/locations.py +164 -0
  92. weaver/objects.py +392 -0
  93. weaver/operations.py +757 -0
  94. weaver/physical_wipe.py +369 -0
  95. weaver/push.py +76 -0
  96. weaver/resolution.py +292 -0
  97. weaver/runtime/__init__.py +30 -0
  98. weaver/runtime/folder_load.py +402 -0
  99. weaver/runtime/load_contract.py +245 -0
  100. weaver/runtime/load_result.py +104 -0
  101. weaver/runtime/spark_load.py +152 -0
  102. weaver/runtime/table_load.py +497 -0
  103. weaver/spark/__init__.py +49 -0
  104. weaver/spark/catalogue.py +245 -0
  105. weaver/spark/destination.py +195 -0
  106. weaver/spark/session.py +84 -0
  107. weaver/spark/tokens.py +138 -0
  108. weaver/sql/__init__.py +40 -0
  109. weaver/sql/authentication.py +38 -0
  110. weaver/sql/connection.py +90 -0
  111. weaver/sql/errors.py +25 -0
  112. weaver/sql/execution.py +123 -0
  113. weaver/sql/pool.py +174 -0
  114. weaver/sql/wipe.py +156 -0
  115. weaver/store.py +209 -0
  116. weaver/targets.py +257 -0
  117. weaver/task_logging.py +215 -0
  118. weaver/unbind.py +74 -0
  119. weaver/workspaces.py +175 -0
  120. weaver_cli/__init__.py +12 -0
  121. weaver_cli/__main__.py +7 -0
  122. weaver_cli/main.py +626 -0
  123. weaverstack-0.1.1.dist-info/METADATA +113 -0
  124. weaverstack-0.1.1.dist-info/RECORD +127 -0
  125. weaverstack-0.1.1.dist-info/WHEEL +4 -0
  126. weaverstack-0.1.1.dist-info/entry_points.txt +2 -0
  127. weaverstack-0.1.1.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,544 @@
1
+ """Static dependency extraction — the names a source file refers to.
2
+
3
+ Extraction only. Nothing here decides whether a name resolves: a two-part name
4
+ may be an object in this repository, a shortcut declared elsewhere, or a typo,
5
+ and telling those apart needs the external-dependency configuration supplied at
6
+ build. This module's whole job is to report, accurately, what the file says.
7
+
8
+ **Python** declares a dependency by importing the other object's module. The
9
+ marker is structural — one ``__`` in an absolute import name::
10
+
11
+ from Sales__Order import Sales__Order -> Sales.Order
12
+ from weaver import Table -> not a reference
13
+ from ._helpers.dates import parse -> not a reference
14
+
15
+ **SQL** declares them by relation position — after ``from``, ``join``,
16
+ ``apply`` or ``using``. Names are returned with their delimiters removed and
17
+ their part count intact:
18
+
19
+ =================================== ==========================================
20
+ ``Schema.Object`` two parts — Weaver's namespace
21
+ ``Catalogue.Schema.Object`` three parts — a physical thing, named by
22
+ the author
23
+ ``Server.Catalogue.Schema.Object`` four parts — likewise
24
+ =================================== ==========================================
25
+
26
+ Single-part names are never relations. A CTE, a temp view, a temp table and a
27
+ table alias are all single-part, so requiring two parts excludes every one of
28
+ them without tracking scope.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import re
34
+ from dataclasses import dataclass
35
+
36
+ from .metadata import ObjectId
37
+
38
+ _TOKENS = None
39
+
40
+
41
+ def _tokens():
42
+ global _TOKENS
43
+ if _TOKENS is None:
44
+ from sqlparse import tokens as _t
45
+
46
+ _TOKENS = _t
47
+ return _TOKENS
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class RelationReference:
52
+ """One name a source file refers to, with its parts as written."""
53
+
54
+ parts: tuple[str, ...]
55
+ #: True when the name is immediately followed by ``(`` — a table-valued
56
+ #: function call, not a managed object. ``cross apply Sales.SplitLines(…)``
57
+ #: reads like a two-part relation but resolves to a function, so strict
58
+ #: two-part validation exempts it the way it exempts CTEs and temp tables.
59
+ call: bool = False
60
+
61
+ @property
62
+ def object_id(self) -> ObjectId | None:
63
+ """The two-part identity, or None for a call or a qualified name.
64
+
65
+ A function call is not a repository object, so it yields no object
66
+ identity even though it has two parts.
67
+ """
68
+
69
+ if len(self.parts) != 2 or self.call:
70
+ return None
71
+ return ObjectId(schema=self.parts[0], object=self.parts[1])
72
+
73
+ @property
74
+ def is_qualified(self) -> bool:
75
+ """True when the author named a physical target rather than an object."""
76
+
77
+ return len(self.parts) > 2
78
+
79
+ def __str__(self) -> str:
80
+ return ".".join(self.parts)
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class PythonImport:
85
+ """One Python import with its relative level preserved for item resolution."""
86
+
87
+ module: str | None
88
+ level: int = 0
89
+ names: tuple[str, ...] = ()
90
+
91
+ def __str__(self) -> str:
92
+ prefix = "." * self.level
93
+ return prefix + (self.module or "")
94
+
95
+
96
+ # --- Python -----------------------------------------------------------------
97
+
98
+
99
+ def extract_python_references(imported_modules: tuple[str, ...]) -> tuple[RelationReference, ...]:
100
+ """Object references among a module's absolute imports.
101
+
102
+ Structural: exactly one ``__``, with both sides present and neither
103
+ beginning with an underscore. ``weaver`` has no ``__`` and is not a
104
+ reference; a helper reached as ``_helpers.dates`` contributes its package
105
+ name, which likewise is not one.
106
+ """
107
+
108
+ references: list[RelationReference] = []
109
+ seen: set[tuple[str, ...]] = set()
110
+ for name in imported_modules:
111
+ if name.startswith("_"):
112
+ continue
113
+ parts = name.split("__")
114
+ if len(parts) != 2:
115
+ continue
116
+ if not all(part and not part.startswith("_") for part in parts):
117
+ continue
118
+ key = tuple(parts)
119
+ if key not in seen:
120
+ seen.add(key)
121
+ references.append(RelationReference(parts=key))
122
+ return tuple(references)
123
+
124
+
125
+ # --- SQL --------------------------------------------------------------------
126
+
127
+ _FROM_BOUNDARY_KEYWORDS = {
128
+ "FOR",
129
+ "GO",
130
+ "GROUP",
131
+ "HAVING",
132
+ "OPTION",
133
+ "ORDER",
134
+ "UNION",
135
+ "EXCEPT",
136
+ "INTERSECT",
137
+ "WHERE",
138
+ "LATERAL",
139
+ "PIVOT",
140
+ "UNPIVOT",
141
+ "WINDOW",
142
+ "QUALIFY",
143
+ "CLUSTER",
144
+ "DISTRIBUTE",
145
+ "SORT",
146
+ "LIMIT",
147
+ }
148
+
149
+ #: ``trim(chars from value)`` is not a relation position.
150
+ _FROM_FUNCTIONS = {"TRIM", "SUBSTRING", "EXTRACT", "OVERLAY", "POSITION"}
151
+
152
+ _STATEMENT_START_KEYWORDS = {
153
+ "ALTER",
154
+ "CREATE",
155
+ "DELETE",
156
+ "DROP",
157
+ "INSERT",
158
+ "MERGE",
159
+ "SELECT",
160
+ "SET",
161
+ "TRUNCATE",
162
+ "UPDATE",
163
+ "USE",
164
+ }
165
+
166
+ #: Spark reads a path as ``delta.`abfss://…```. The prefix is a format, not a
167
+ #: schema, so the pair is not an object reference.
168
+ _PATH_FORMATS = {"delta", "parquet", "csv", "json", "orc", "avro", "text", "binaryfile"}
169
+
170
+
171
+ @dataclass(frozen=True)
172
+ class _FlatToken:
173
+ value: str
174
+ normalized: str
175
+ ttype: object
176
+ start: int
177
+ depth: int
178
+
179
+
180
+ @dataclass(frozen=True)
181
+ class LocatedReference:
182
+ """One relation reference, and where in the text it was written.
183
+
184
+ Extraction reports what a file says; a *span* additionally lets a caller
185
+ rewrite it. Build needs that: a two-part name in a view body resolves through
186
+ whatever catalogue the session is currently pointed at, so the planner
187
+ replaces each managed reference with a name that says which Lakehouse it
188
+ means (see :mod:`weaver.spark.tokens`).
189
+ """
190
+
191
+ reference: RelationReference
192
+ start: int
193
+ end: int
194
+
195
+
196
+ def extract_sql_references(sql_text: str) -> tuple[RelationReference, ...]:
197
+ """Ordered, de-duplicated relation references from a SQL body."""
198
+
199
+ references: list[RelationReference] = []
200
+ seen: set[tuple[str, ...]] = set()
201
+ for located in locate_sql_references(sql_text):
202
+ if located.reference.parts in seen:
203
+ continue
204
+ seen.add(located.reference.parts)
205
+ references.append(located.reference)
206
+ return tuple(references)
207
+
208
+
209
+ def rewrite_sql_references(sql_text: str, rewrite) -> str:
210
+ """One body with each relation reference the caller claims replaced.
211
+
212
+ ``rewrite`` receives a :class:`RelationReference` and returns the text to put
213
+ in its place, or None to leave it exactly as written. Everything else in the
214
+ body — whitespace, comments, casing, the author's own delimiters — is
215
+ untouched, because a build must not quietly reformat a query it is going to
216
+ freeze and execute.
217
+
218
+ Replacements are applied last-first so an earlier span's offsets stay valid.
219
+ """
220
+
221
+ replacements = []
222
+ for located in locate_sql_references(sql_text):
223
+ replacement = rewrite(located.reference)
224
+ if replacement is not None:
225
+ replacements.append((located.start, located.end, replacement))
226
+
227
+ for start, end, replacement in sorted(replacements, reverse=True):
228
+ sql_text = sql_text[:start] + replacement + sql_text[end:]
229
+ return sql_text
230
+
231
+
232
+ def locate_sql_references(sql_text: str) -> tuple[LocatedReference, ...]:
233
+ """Every relation reference in a SQL body, in order, with its span.
234
+
235
+ Not de-duplicated: a name written three times is three places to rewrite.
236
+ """
237
+
238
+ from sqlparse.exceptions import SQLParseError
239
+
240
+ try:
241
+ tokens = _flatten(sql_text)
242
+ except (SQLParseError, RecursionError):
243
+ return _fallback(sql_text)
244
+
245
+ references: list[LocatedReference] = []
246
+ seen: set[int] = set()
247
+
248
+ for index, token in enumerate(tokens):
249
+ if not _is_keyword(token):
250
+ continue
251
+ head = _keyword_head(token)
252
+ words = set(token.normalized.split())
253
+ if head == "FROM":
254
+ if _enclosing_function(tokens, index) in _FROM_FUNCTIONS:
255
+ continue
256
+ for reference in _from_relations(sql_text, tokens, index):
257
+ _add(references, seen, reference)
258
+ elif head in {"APPLY", "USING"} or "JOIN" in words or "APPLY" in words:
259
+ following = _next_significant(tokens, index + 1)
260
+ if following is not None:
261
+ reference = _relation_at(sql_text, tokens[following].start)
262
+ if reference is not None:
263
+ _add(references, seen, reference)
264
+ elif head in {"MERGE", "INSERT", "UPDATE", "DELETE"}:
265
+ # A DML target is a relation too. Weaver does not restrict what an
266
+ # author writes; it only has to read it accurately.
267
+ reference = _dml_target(sql_text, tokens, index)
268
+ if reference is not None:
269
+ _add(references, seen, reference)
270
+ elif head in {"CROSS", "OUTER"}:
271
+ # sqlparse keywords `cross` but not `apply`, so `cross apply Schema.Fn(…)`
272
+ # arrives as two tokens and the relation sits after the second.
273
+ following = _next_significant(tokens, index + 1)
274
+ if following is not None and tokens[following].value.lower() == "apply":
275
+ after = _next_significant(tokens, following + 1)
276
+ if after is not None:
277
+ reference = _relation_at(sql_text, tokens[after].start)
278
+ if reference is not None:
279
+ _add(references, seen, reference)
280
+
281
+ return tuple(references)
282
+
283
+
284
+ def _dml_target(
285
+ sql_text: str, tokens: list[_FlatToken], index: int
286
+ ) -> LocatedReference | None:
287
+ """The relation a DML statement writes to.
288
+
289
+ ``insert into``, ``merge into`` and ``delete from`` may arrive as one
290
+ keyword token or two, depending on the dialect and on sqlparse, so an
291
+ intervening ``into``/``from`` is skipped when present.
292
+ """
293
+
294
+ following = _next_significant(tokens, index + 1)
295
+ if following is None:
296
+ return None
297
+ if tokens[following].normalized.strip() in {"INTO", "FROM"}:
298
+ following = _next_significant(tokens, following + 1)
299
+ if following is None:
300
+ return None
301
+ return _relation_at(sql_text, tokens[following].start)
302
+
303
+
304
+ def _fallback(sql_text: str) -> tuple[LocatedReference, ...]:
305
+ """Scanner for bodies sqlparse cannot tokenise."""
306
+
307
+ references: list[LocatedReference] = []
308
+ seen: set[int] = set()
309
+ keyword = re.compile(r"\b(from|join|apply|using)\b", flags=re.IGNORECASE)
310
+ for match in keyword.finditer(sql_text):
311
+ located = _relation_at(sql_text, match.end())
312
+ if located is not None:
313
+ _add(references, seen, located)
314
+ return tuple(references)
315
+
316
+
317
+ def _add(
318
+ references: list[LocatedReference],
319
+ seen: set[int],
320
+ located: LocatedReference,
321
+ ) -> None:
322
+ """Record one occurrence, by position.
323
+
324
+ Position, not name: extraction de-duplicates by name afterwards, but a
325
+ rewrite needs every place the name was written — and two rules can reach the
326
+ same place, which is the one duplicate to drop here.
327
+ """
328
+
329
+ if located.start in seen:
330
+ return
331
+ seen.add(located.start)
332
+ references.append(located)
333
+
334
+
335
+ def _from_relations(
336
+ sql_text: str, tokens: list[_FlatToken], from_index: int
337
+ ) -> list[LocatedReference]:
338
+ """Every relation in one ``from`` list, including comma-separated ones."""
339
+
340
+ depth = tokens[from_index].depth
341
+ first = _next_significant(tokens, from_index + 1)
342
+ if first is None:
343
+ return []
344
+
345
+ relations: list[LocatedReference] = []
346
+ reference = _relation_at(sql_text, tokens[first].start)
347
+ if reference is not None:
348
+ relations.append(reference)
349
+
350
+ for index in range(first + 1, len(tokens)):
351
+ token = tokens[index]
352
+ if token.depth < depth:
353
+ break
354
+ if token.depth != depth:
355
+ continue
356
+ if _is_from_boundary(token):
357
+ break
358
+ if token.value != ",":
359
+ continue
360
+ following = _next_significant(tokens, index + 1)
361
+ if following is None or tokens[following].depth != depth:
362
+ continue
363
+ reference = _relation_at(sql_text, tokens[following].start)
364
+ if reference is not None:
365
+ relations.append(reference)
366
+
367
+ return relations
368
+
369
+
370
+ def _is_from_boundary(token: _FlatToken) -> bool:
371
+ if token.value == ";":
372
+ return True
373
+ if not _is_keyword(token):
374
+ return False
375
+ head = _keyword_head(token)
376
+ if head in _FROM_BOUNDARY_KEYWORDS:
377
+ return True
378
+ return head in _STATEMENT_START_KEYWORDS and head != "SELECT"
379
+
380
+
381
+ def _relation_at(sql_text: str, start: int) -> LocatedReference | None:
382
+ """A relation reference at ``start``, tagged if it is a function call."""
383
+
384
+ parsed = _parse_name(sql_text, start)
385
+ if parsed is None:
386
+ return None
387
+ parts, begin, position = parsed
388
+ # A ``(`` directly abutting the name is a call, ``Sales.SplitLines(…)``.
389
+ # A table hint is ``… with (nolock)`` — a keyword and a space intervene —
390
+ # so requiring the paren to abut avoids mistaking a hinted table for one.
391
+ call = position < len(sql_text) and sql_text[position] == "("
392
+ return LocatedReference(
393
+ reference=RelationReference(parts=parts, call=call), start=begin, end=position
394
+ )
395
+
396
+
397
+ def _parse_name(sql_text: str, start: int) -> tuple[tuple[str, ...], int, int] | None:
398
+ """The parts of a relation name, where it begins, and where it ends.
399
+
400
+ The end offset is where the name stops — before any trailing whitespace — so
401
+ a caller can tell an abutting ``(`` (a function call) from a spaced one. The
402
+ begin offset is what makes the name replaceable.
403
+ """
404
+
405
+ position = _skip_space(sql_text, start)
406
+ begin = position
407
+ parts: list[str] = []
408
+ end = position
409
+
410
+ while position < len(sql_text):
411
+ parsed = _parse_identifier_part(sql_text, position)
412
+ if parsed is None:
413
+ break
414
+ part, position = parsed
415
+ parts.append(part)
416
+ end = position
417
+ after_space = _skip_space(sql_text, position)
418
+ if after_space >= len(sql_text) or sql_text[after_space] != ".":
419
+ break
420
+ position = _skip_space(sql_text, after_space + 1)
421
+ if len(parts) >= 4:
422
+ break
423
+
424
+ if len(parts) < 2 or len(parts) > 4:
425
+ return None
426
+ if any(not part or part.startswith(("#", "@")) for part in parts):
427
+ return None
428
+ if len(parts) == 2 and parts[0].lower() in _PATH_FORMATS:
429
+ # delta.`abfss://…` — a format and a path, not schema and object.
430
+ return None
431
+ return tuple(parts), begin, end
432
+
433
+
434
+ def _parse_identifier_part(sql_text: str, start: int) -> tuple[str, int] | None:
435
+ if start >= len(sql_text):
436
+ return None
437
+ character = sql_text[start]
438
+ if character == "[":
439
+ return _parse_delimited(sql_text, start, "]")
440
+ if character == '"':
441
+ return _parse_delimited(sql_text, start, '"')
442
+ if character == "`":
443
+ return _parse_delimited(sql_text, start, "`")
444
+ match = re.match(r"[A-Za-z_@#][A-Za-z0-9_@$#]*", sql_text[start:])
445
+ if not match:
446
+ return None
447
+ return match.group(0), start + match.end()
448
+
449
+
450
+ def _parse_delimited(sql_text: str, start: int, closer: str) -> tuple[str, int] | None:
451
+ position = start + 1
452
+ characters: list[str] = []
453
+ while position < len(sql_text):
454
+ character = sql_text[position]
455
+ if character == closer:
456
+ if position + 1 < len(sql_text) and sql_text[position + 1] == closer:
457
+ characters.append(closer)
458
+ position += 2
459
+ continue
460
+ return "".join(characters), position + 1
461
+ characters.append(character)
462
+ position += 1
463
+ return None
464
+
465
+
466
+ def _skip_space(sql_text: str, start: int) -> int:
467
+ position = start
468
+ while position < len(sql_text) and sql_text[position] in " \t\r\n":
469
+ position += 1
470
+ return position
471
+
472
+
473
+ def _flatten(sql_text: str) -> list[_FlatToken]:
474
+ import sqlparse
475
+
476
+ flat: list[_FlatToken] = []
477
+ offset = 0
478
+ depth = 0
479
+ for statement in sqlparse.parse(sql_text):
480
+ for token in statement.flatten():
481
+ value = token.value
482
+ token_depth = depth
483
+ if value == ")":
484
+ depth = max(0, depth - 1)
485
+ token_depth = depth
486
+ flat.append(
487
+ _FlatToken(
488
+ value=value,
489
+ normalized=token.normalized.upper(),
490
+ ttype=token.ttype,
491
+ start=offset,
492
+ depth=token_depth,
493
+ )
494
+ )
495
+ offset += len(value)
496
+ if value == "(":
497
+ depth += 1
498
+ return flat
499
+
500
+
501
+ def _next_significant(tokens: list[_FlatToken], index: int) -> int | None:
502
+ for candidate in range(index, len(tokens)):
503
+ if not _is_trivia(tokens[candidate]):
504
+ return candidate
505
+ return None
506
+
507
+
508
+ def _previous_significant(tokens: list[_FlatToken], index: int) -> int | None:
509
+ for candidate in range(index - 1, -1, -1):
510
+ if not _is_trivia(tokens[candidate]):
511
+ return candidate
512
+ return None
513
+
514
+
515
+ def _enclosing_function(tokens: list[_FlatToken], index: int) -> str | None:
516
+ """The function keyword of the parenthesis enclosing ``index``, if any."""
517
+
518
+ depth = 0
519
+ for candidate in range(index - 1, -1, -1):
520
+ value = tokens[candidate].value
521
+ if value == ")":
522
+ depth += 1
523
+ elif value == "(":
524
+ if depth == 0:
525
+ previous = _previous_significant(tokens, candidate)
526
+ if previous is None:
527
+ return None
528
+ return _keyword_head(tokens[previous])
529
+ depth -= 1
530
+ return None
531
+
532
+
533
+ def _is_trivia(token: _FlatToken) -> bool:
534
+ tokens = _tokens()
535
+ return token.ttype in tokens.Whitespace or token.ttype in tokens.Comment
536
+
537
+
538
+ def _is_keyword(token: _FlatToken) -> bool:
539
+ return token.ttype in _tokens().Keyword
540
+
541
+
542
+ def _keyword_head(token: _FlatToken) -> str:
543
+ parts = token.normalized.split(maxsplit=1)
544
+ return parts[0] if parts else ""