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,674 @@
1
+ """One source file, read and checked without executing it.
2
+
3
+ A :class:`SourceDocument` wraps the validated
4
+ :class:`~weaver.ses.metadata.SesDocument` with everything else the file
5
+ yielded: its language, its content hash, and the parse — a Python AST or the
6
+ split SQL statements. Holding the parse here means later checkpoints read the
7
+ repository once rather than once per question.
8
+
9
+ The contract this enforces is *structural*: the object's declared ID, its
10
+ filename and (for Python) its class name must all agree, and the file must
11
+ present exactly one unit of work.
12
+
13
+ +------------+---------------------------------+------------------+
14
+ | Language | File | ID |
15
+ +============+=================================+==================+
16
+ | Python | ``Sales__Order.py`` | ``Sales.Order`` |
17
+ | SQL | ``Sales.Order.sql`` | ``Sales.Order`` |
18
+ +------------+---------------------------------+------------------+
19
+
20
+ Python uses ``__`` because a module name cannot contain a dot without breaking
21
+ imports; SQL files have no such constraint and use the dot directly. The Python
22
+ class carries the same full name as its file — ``class Sales__Order(Table)`` —
23
+ so the import at a call site is explicit about which object it names.
24
+
25
+ **The owning item chooses the SQL dialect.** A ``.sql`` file in a Lakehouse item
26
+ is Spark SQL; the same name in a Warehouse item is T-SQL.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import ast
32
+ import codecs
33
+ import hashlib
34
+ import re
35
+ from dataclasses import dataclass, field
36
+
37
+ from ..errors import DiscoveryError
38
+ from ..objects import BASE_CLASSES, BASE_CLASS_NAMES
39
+ from .dependencies import (
40
+ PythonImport,
41
+ RelationReference,
42
+ extract_python_references,
43
+ extract_sql_references,
44
+ )
45
+ from .metadata import (
46
+ FOLDER,
47
+ PYTHON,
48
+ SPARK_SQL,
49
+ SQL,
50
+ TABLE,
51
+ VIEW,
52
+ ObjectId,
53
+ SesDocument,
54
+ namespace_for_target,
55
+ target_kind_for,
56
+ parse_document,
57
+ extract_python_metadata,
58
+ extract_sql_metadata_and_body,
59
+ )
60
+ from .model import LAKEHOUSE, WeaverDocumentId
61
+
62
+ PYTHON_SUFFIX = ".py"
63
+ SQL_SUFFIX = ".sql"
64
+
65
+ #: Python cannot have a dot in a module name, so a schema separator is needed.
66
+ PYTHON_ID_SEPARATOR = "__"
67
+
68
+
69
+ def content_hash(data: bytes) -> str:
70
+ """A hash that is stable for the same content on any platform.
71
+
72
+ Line endings are normalised and a UTF-8 BOM dropped before hashing: a file
73
+ checked out with ``autocrlf`` is not a changed file, and the hash exists to
74
+ answer "has this changed since it was certified".
75
+ """
76
+
77
+ if data.startswith(codecs.BOM_UTF8):
78
+ data = data[len(codecs.BOM_UTF8):]
79
+ return hashlib.sha256(data.replace(b"\r\n", b"\n")).hexdigest()
80
+
81
+
82
+ def sql_dialect_for_item_type(item_type: str) -> str:
83
+ """The SQL a ``.sql`` file speaks inside an item of this type.
84
+
85
+ This is the whole reason a Weaver document needs no dialect suffix: the
86
+ containing item already decides. A Lakehouse materialises Delta through
87
+ Spark; a Warehouse materialises tables and views through T-SQL.
88
+ """
89
+
90
+ return SPARK_SQL if item_type == LAKEHOUSE else SQL
91
+
92
+
93
+ def language_for_filename(filename: str, item_type: str) -> str | None:
94
+ """The language a filename declares, or None if it is not an object file."""
95
+
96
+ if filename.endswith(PYTHON_SUFFIX):
97
+ return PYTHON
98
+ if filename.endswith(SQL_SUFFIX):
99
+ return sql_dialect_for_item_type(item_type)
100
+ return None
101
+
102
+
103
+ def _stem(filename: str) -> str:
104
+ """The filename with its object suffix removed."""
105
+
106
+ name = filename.rsplit("/", 1)[-1]
107
+ for suffix in (PYTHON_SUFFIX, SQL_SUFFIX):
108
+ if name.endswith(suffix):
109
+ return name[: -len(suffix)]
110
+ return name
111
+
112
+
113
+ def python_id_parts(stem: str) -> list[str]:
114
+ """Split ``Schema__Object`` where the schema may itself be underscores.
115
+
116
+ ``Sales__Order`` is unambiguous, but ``_`` is a real schema — it is where
117
+ generated infrastructure lives — and ``_`` + ``__`` + ``Load`` spells
118
+ ``___Load``, which an ordinary split reads as an empty schema. A run of
119
+ leading underscores is therefore read as what it is: the last two are the
120
+ separator and the rest are the schema, so ``___Load`` is ``_.Load``.
121
+
122
+ Only a schema made *entirely* of underscores needs this. ``_ETL__Load`` has
123
+ one leading underscore, splits once, and never reaches the branch.
124
+ """
125
+
126
+ leading = len(stem) - len(stem.lstrip("_"))
127
+ if leading >= len(PYTHON_ID_SEPARATOR) + 1:
128
+ return [stem[: leading - len(PYTHON_ID_SEPARATOR)], stem[leading:]]
129
+ return stem.split(PYTHON_ID_SEPARATOR)
130
+
131
+
132
+ #: Private alias, so the helper reads as an implementation detail at its one
133
+ #: internal call site while staying importable for the authoring surface.
134
+ _python_id_parts = python_id_parts
135
+
136
+
137
+ def object_id_for_filename(filename: str, language: str) -> ObjectId:
138
+ """The ID a filename claims, before the document is consulted."""
139
+
140
+ stem = _stem(filename)
141
+ if language == PYTHON:
142
+ if "." in stem:
143
+ raise DiscoveryError(
144
+ f"{filename}: a Python object file separates schema and object with "
145
+ f"{PYTHON_ID_SEPARATOR!r}, not '.', because a module name cannot "
146
+ "contain a dot — expected Schema__Object.py"
147
+ )
148
+ parts = _python_id_parts(stem)
149
+ else:
150
+ if PYTHON_ID_SEPARATOR in stem:
151
+ raise DiscoveryError(
152
+ f"{filename}: a SQL object file separates schema and object with '.', "
153
+ f"not {PYTHON_ID_SEPARATOR!r} — expected Schema.Object{SQL_SUFFIX}"
154
+ )
155
+ parts = stem.split(".")
156
+ parts = [part.strip() for part in parts]
157
+ if len(parts) != 2 or not all(parts):
158
+ raise DiscoveryError(
159
+ f"{filename}: an object filename must name Schema and Object, got {stem!r}"
160
+ )
161
+ return ObjectId(schema=parts[0], object=parts[1])
162
+
163
+
164
+ @dataclass(frozen=True)
165
+ class SqlAnalysis:
166
+ """What could be established about a SQL body without executing it."""
167
+
168
+ statement_count: int
169
+ result_set_count: int | None
170
+ #: Why the result-set count could not be established, when it could not.
171
+ undetermined_because: str | None = None
172
+ statements: tuple[str, ...] = ()
173
+ #: Statements that look like they create a permanent object. Recorded for a
174
+ #: later lint, not refused — see _permanent_ddl.
175
+ permanent_ddl: tuple[str, ...] = ()
176
+
177
+ @property
178
+ def determined(self) -> bool:
179
+ return self.result_set_count is not None
180
+
181
+
182
+ @dataclass(frozen=True)
183
+ class SourceDocument:
184
+ """One object's source file, parsed and structurally checked."""
185
+
186
+ relative_path: str
187
+ language: str
188
+ text: str
189
+ source_hash: str
190
+ document: SesDocument
191
+ #: The signature build compares with the installed Registry row. For a
192
+ #: Python document this also covers every in-item ``lib/`` module reachable
193
+ #: through static imports; for every other document it is ``source_hash``.
194
+ build_signature: str | None = None
195
+ class_name: str | None = None
196
+ imported_modules: tuple[str, ...] = ()
197
+ python_imports: tuple[PythonImport, ...] = ()
198
+ sql_body: str | None = None
199
+ sql_analysis: SqlAnalysis | None = None
200
+ #: Names this file refers to, as written. Whether each resolves is a build
201
+ #: concern — it needs the external-dependency configuration.
202
+ discovered_references: tuple[RelationReference, ...] = ()
203
+ python_ast: ast.Module | None = field(default=None, compare=False, repr=False)
204
+ #: Item-qualified logical identity, assigned by the reader once the owning
205
+ #: item is known. Unset only while a document is read in isolation.
206
+ logical_id: WeaverDocumentId | None = None
207
+
208
+ @property
209
+ def object_id(self) -> ObjectId:
210
+ return self.document.object_id
211
+
212
+ @property
213
+ def qualified(self) -> str:
214
+ return self.document.qualified
215
+
216
+ @property
217
+ def kind(self) -> str:
218
+ return self.document.kind
219
+
220
+ @property
221
+ def target_kind(self) -> str:
222
+ """Which physical destination this object materialises into."""
223
+
224
+ return target_kind_for(self.language, self.document.kind)
225
+
226
+ @property
227
+ def effective_signature(self) -> str:
228
+ """The exact authored implementation this physical object represents."""
229
+
230
+ return self.build_signature or self.source_hash
231
+
232
+ @property
233
+ def node_id(self) -> str:
234
+ """Identity within the repository: target and ID together.
235
+
236
+ The ID alone is not unique — the same Schema.Object may exist as a
237
+ folder, a Delta table and a Warehouse table simultaneously.
238
+ """
239
+
240
+ if self.logical_id is not None:
241
+ return str(self.logical_id)
242
+ return f"{self.target_kind}:{self.qualified}"
243
+
244
+ @property
245
+ def namespace(self) -> str:
246
+ """The execution namespace this object's references bind in."""
247
+
248
+ return namespace_for_target(self.target_kind)
249
+
250
+ @property
251
+ def warehouse_alias(self) -> ObjectId | None:
252
+ """This Lakehouse object's Warehouse-facing name, if it publishes one."""
253
+
254
+ return self.document.warehouse_alias
255
+
256
+ @property
257
+ def lakehouse_alias(self) -> ObjectId | None:
258
+ """This Warehouse object's Lakehouse-facing name, if it publishes one."""
259
+
260
+ return self.document.lakehouse_alias
261
+
262
+ @property
263
+ def referenced_object_ids(self) -> tuple[ObjectId, ...]:
264
+ """Two-part references — candidates for objects in this repository.
265
+
266
+ Function calls are excluded: ``Sales.SplitLines(…)`` is two parts but
267
+ names a function, not a managed object, so it yields no object identity.
268
+ """
269
+
270
+ return tuple(
271
+ reference.object_id
272
+ for reference in self.discovered_references
273
+ if reference.object_id is not None
274
+ )
275
+
276
+ @property
277
+ def qualified_references(self) -> tuple[RelationReference, ...]:
278
+ """Three- and four-part references — physical targets the author named."""
279
+
280
+ return tuple(
281
+ reference for reference in self.discovered_references if reference.is_qualified
282
+ )
283
+
284
+ @property
285
+ def call_references(self) -> tuple[RelationReference, ...]:
286
+ """Two-part function calls — named like relations, resolved as functions."""
287
+
288
+ return tuple(
289
+ reference
290
+ for reference in self.discovered_references
291
+ if reference.call and len(reference.parts) == 2
292
+ )
293
+
294
+ @property
295
+ def external_references(self) -> tuple[str, ...]:
296
+ """References that leave the repository: physical names and functions.
297
+
298
+ A valid repository resolves every ordinary two-part reference, so what
299
+ remains is deliberately outside: a physically-qualified name, or a
300
+ table-valued function. Recorded, never an error.
301
+ """
302
+
303
+ return tuple(
304
+ sorted(
305
+ str(reference)
306
+ for reference in self.qualified_references + self.call_references
307
+ )
308
+ )
309
+
310
+ @property
311
+ def declared_dependencies(self) -> tuple[ObjectId, ...]:
312
+ return self.document.dependencies
313
+
314
+ @property
315
+ def module_name(self) -> str | None:
316
+ """The importable module name, for Python objects."""
317
+
318
+ if self.language != PYTHON:
319
+ return None
320
+ return self.relative_path[: -len(PYTHON_SUFFIX)]
321
+
322
+ def create_ddl(self) -> "GeneratedDdl":
323
+ """The generated, installable create definition for this source.
324
+
325
+ Delegates to :mod:`weaver.ses.ddl`. The source owns this because it is
326
+ the only thing that knows its language, kind, ID and validated body;
327
+ a build planner calls it and never re-derives create syntax.
328
+ """
329
+
330
+ from .ddl import generate_ddl
331
+
332
+ return generate_ddl(self)
333
+
334
+ def create_load(self) -> "GeneratedLoad":
335
+ """The generated, installable load definition for this source.
336
+
337
+ The sibling of :meth:`create_ddl`, and owned here for the same reason:
338
+ the source alone knows its language, kind, ID and validated body. The
339
+ load artefact layer asks for this and carries what it gets rather than
340
+ rendering anything itself.
341
+ """
342
+
343
+ from .load import generate_load
344
+
345
+ return generate_load(self)
346
+
347
+
348
+ def read_source_document(
349
+ relative_path: str, data: bytes, item_type: str
350
+ ) -> SourceDocument:
351
+ """Parse and structurally validate one object file."""
352
+
353
+ language = language_for_filename(relative_path, item_type)
354
+ if language is None:
355
+ raise DiscoveryError(f"{relative_path}: not a Weaver object file")
356
+
357
+ try:
358
+ text = data.decode("utf-8-sig")
359
+ except UnicodeDecodeError as exc:
360
+ raise DiscoveryError(f"{relative_path}: must be UTF-8 text ({exc})") from exc
361
+
362
+ source_hash = content_hash(data)
363
+ filename_id = object_id_for_filename(relative_path, language)
364
+
365
+ if language == PYTHON:
366
+ return _read_python(relative_path, text, source_hash, filename_id)
367
+ return _read_sql(relative_path, text, source_hash, filename_id, language)
368
+
369
+
370
+ def _check_declared_id(relative_path: str, document: SesDocument, filename_id: ObjectId) -> None:
371
+ if document.object_id != filename_id:
372
+ raise DiscoveryError(
373
+ f"{relative_path}: declares {document.kind} ID "
374
+ f"{document.qualified!r} but the filename names "
375
+ f"{filename_id.qualified!r} — they must agree"
376
+ )
377
+
378
+
379
+ def _read_python(
380
+ relative_path: str, text: str, source_hash: str, filename_id: ObjectId
381
+ ) -> SourceDocument:
382
+ document = parse_document(extract_python_metadata(text), language=PYTHON)
383
+ _check_declared_id(relative_path, document, filename_id)
384
+
385
+ if document.kind == VIEW:
386
+ raise DiscoveryError(
387
+ f"{relative_path}: a View is declared in SQL, not Python — its query is "
388
+ "its definition"
389
+ )
390
+
391
+ module = ast.parse(text)
392
+ expected_class = _stem(relative_path)
393
+
394
+ # Ordinary helper classes may live alongside the object. What must be
395
+ # unique is the *Weaver* class — the one inheriting Folder, Table or View.
396
+ candidates = [
397
+ node
398
+ for node in module.body
399
+ if isinstance(node, ast.ClassDef)
400
+ and any(_base_name(base) in BASE_CLASS_NAMES for base in node.bases)
401
+ ]
402
+ if not candidates:
403
+ raise DiscoveryError(
404
+ f"{relative_path}: must define a class inheriting "
405
+ f"{BASE_CLASSES[document.kind].__name__} directly, and none does"
406
+ )
407
+ if len(candidates) > 1:
408
+ found = ", ".join(node.name for node in candidates)
409
+ raise DiscoveryError(
410
+ f"{relative_path}: defines more than one Weaver object class ({found}) — "
411
+ "one file declares one object"
412
+ )
413
+
414
+ declared = candidates[0]
415
+ if declared.name != expected_class:
416
+ raise DiscoveryError(
417
+ f"{relative_path}: defines class {declared.name!r} but the file names "
418
+ f"{expected_class!r} — the class, the file and the ID all carry the same name"
419
+ )
420
+
421
+ _check_base_class(relative_path, declared, document.kind)
422
+ _check_read_method(relative_path, declared)
423
+ imports = _imported_modules(module)
424
+ python_imports = _python_imports(module)
425
+
426
+ return SourceDocument(
427
+ relative_path=relative_path,
428
+ language=PYTHON,
429
+ text=text,
430
+ source_hash=source_hash,
431
+ document=document,
432
+ class_name=declared.name,
433
+ imported_modules=imports,
434
+ python_imports=python_imports,
435
+ discovered_references=extract_python_references(imports),
436
+ python_ast=module,
437
+ )
438
+
439
+
440
+ def _check_base_class(relative_path: str, declared: ast.ClassDef, kind: str) -> None:
441
+ expected = BASE_CLASSES[kind].__name__
442
+ bases = [_base_name(base) for base in declared.bases]
443
+ if expected not in bases:
444
+ found = ", ".join(name for name in bases if name) or "nothing"
445
+ raise DiscoveryError(
446
+ f"{relative_path}: declares {kind} ID, so class {declared.name!r} must "
447
+ f"inherit {expected}, but it inherits {found}"
448
+ )
449
+
450
+
451
+ def _base_name(node: ast.expr) -> str | None:
452
+ if isinstance(node, ast.Name):
453
+ return node.id
454
+ if isinstance(node, ast.Attribute):
455
+ return node.attr
456
+ return None
457
+
458
+
459
+ def _check_read_method(relative_path: str, declared: ast.ClassDef) -> None:
460
+ reads = [
461
+ node
462
+ for node in declared.body
463
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == "read"
464
+ ]
465
+ if not reads:
466
+ raise DiscoveryError(
467
+ f"{relative_path}: class {declared.name!r} must implement read()"
468
+ )
469
+ if len(reads) > 1:
470
+ raise DiscoveryError(
471
+ f"{relative_path}: class {declared.name!r} defines read() "
472
+ f"{len(reads)} times — the later one silently replaces the earlier"
473
+ )
474
+ if isinstance(reads[0], ast.AsyncFunctionDef):
475
+ raise DiscoveryError(f"{relative_path}: read() must not be async")
476
+
477
+
478
+ def _imported_modules(module: ast.Module) -> tuple[str, ...]:
479
+ """Top-level module names imported absolutely, in source order.
480
+
481
+ Relative imports are helper imports by construction and are excluded. What
482
+ is a dependency rather than a plain import is decided by the repository,
483
+ which knows every object's module name.
484
+ """
485
+
486
+ names: list[str] = []
487
+ for node in ast.walk(module):
488
+ if isinstance(node, ast.Import):
489
+ for alias in node.names:
490
+ names.append(alias.name.split(".")[0])
491
+ elif isinstance(node, ast.ImportFrom):
492
+ if node.level: # from . import x
493
+ continue
494
+ if node.module:
495
+ names.append(node.module.split(".")[0])
496
+ seen: list[str] = []
497
+ for name in names:
498
+ if name not in seen:
499
+ seen.append(name)
500
+ return tuple(seen)
501
+
502
+
503
+ def _python_imports(module: ast.Module) -> tuple[PythonImport, ...]:
504
+ """All imports needed for item-package dependency resolution."""
505
+
506
+ imports: list[PythonImport] = []
507
+ for node in ast.walk(module):
508
+ if isinstance(node, ast.ImportFrom):
509
+ imports.append(
510
+ PythonImport(
511
+ module=node.module,
512
+ level=node.level,
513
+ names=tuple(alias.name for alias in node.names),
514
+ )
515
+ )
516
+ elif isinstance(node, ast.Import):
517
+ imports.extend(
518
+ PythonImport(module=alias.name, names=(alias.name,))
519
+ for alias in node.names
520
+ )
521
+ return tuple(imports)
522
+
523
+
524
+ def _read_sql(
525
+ relative_path: str,
526
+ text: str,
527
+ source_hash: str,
528
+ filename_id: ObjectId,
529
+ language: str,
530
+ ) -> SourceDocument:
531
+ metadata_text, body = extract_sql_metadata_and_body(text)
532
+ document = parse_document(metadata_text, language=language)
533
+ _check_declared_id(relative_path, document, filename_id)
534
+
535
+ if document.kind == FOLDER:
536
+ raise DiscoveryError(
537
+ f"{relative_path}: a Folder is declared in Python — it stages files rather "
538
+ "than returning rows"
539
+ )
540
+
541
+ analysis = analyse_sql(body)
542
+
543
+ if document.kind == VIEW and analysis.statement_count > 1:
544
+ raise DiscoveryError(
545
+ f"{relative_path}: a View is one query — Weaver wraps it in the CREATE "
546
+ f"VIEW, and a view definition cannot carry preceding statements. Found "
547
+ f"{analysis.statement_count}."
548
+ )
549
+
550
+ if analysis.determined and analysis.result_set_count != 1:
551
+ raise DiscoveryError(
552
+ f"{relative_path}: a SQL object must produce exactly one result set, "
553
+ f"found {analysis.result_set_count}. Intermediate work is fine — only "
554
+ "one statement may return rows."
555
+ )
556
+
557
+ return SourceDocument(
558
+ relative_path=relative_path,
559
+ language=language,
560
+ text=text,
561
+ source_hash=source_hash,
562
+ document=document,
563
+ sql_body=body,
564
+ sql_analysis=analysis,
565
+ discovered_references=extract_sql_references(body),
566
+ )
567
+
568
+
569
+ #: Constructs that put the result-set count beyond static reach. Seeing one,
570
+ #: the check stands down rather than blocking a file it cannot read.
571
+ _DYNAMIC_SQL = ("exec ", "execute ", "sp_executesql")
572
+
573
+ #: Intermediate scratch — allowed, because it is working, not the object.
574
+ #: ``create temp view``, ``create temporary view``, ``create table #tmp``.
575
+ _SCRATCH_DDL = re.compile(
576
+ r"^\s*create\s+(or\s+replace\s+)?(temp|temporary|local\s+temporary)\b"
577
+ r"|^\s*create\s+table\s+#",
578
+ re.IGNORECASE,
579
+ )
580
+ _PERMANENT_DDL = re.compile(
581
+ r"^\s*create\s+(or\s+replace\s+)?(view|table)\b", re.IGNORECASE
582
+ )
583
+
584
+
585
+ def _permanent_ddl(statements: tuple[str, ...]) -> tuple[str, ...]:
586
+ """Statements that appear to create a permanent object.
587
+
588
+ Normally the author writes the query and Weaver writes the ``CREATE``, so
589
+ one of these usually means the wrapper has been written by hand. It is
590
+ *recorded*, not refused: this is fail-early validation, not critical-path,
591
+ and there may be a legitimate reason to create something durable inside a
592
+ body. Getting it wrong would block valid work in exchange for an error the
593
+ build would have produced anyway.
594
+ """
595
+
596
+ return tuple(
597
+ statement
598
+ for statement in statements
599
+ if _PERMANENT_DDL.match(statement) and not _SCRATCH_DDL.match(statement)
600
+ )
601
+
602
+
603
+ def analyse_sql(body: str) -> SqlAnalysis:
604
+ """Count result-producing statements, or report why that is unknowable.
605
+
606
+ Deliberately calibrated to abstain rather than guess: a wrong rejection
607
+ blocks a legitimate object, while a missed one merely fails at build the
608
+ way it does today.
609
+ """
610
+
611
+ import sqlparse
612
+
613
+ statements = [
614
+ statement
615
+ for statement in sqlparse.parse(body)
616
+ if str(statement).strip() and not _is_only_comments(statement)
617
+ ]
618
+
619
+ texts = tuple(str(statement).strip() for statement in statements)
620
+
621
+ lowered = body.lower()
622
+ for marker in _DYNAMIC_SQL:
623
+ if marker in lowered:
624
+ return SqlAnalysis(
625
+ statement_count=len(statements),
626
+ result_set_count=None,
627
+ undetermined_because=f"the body uses dynamic SQL ({marker.strip()})",
628
+ statements=texts,
629
+ permanent_ddl=_permanent_ddl(texts),
630
+ )
631
+
632
+ return SqlAnalysis(
633
+ statement_count=len(statements),
634
+ result_set_count=sum(1 for statement in statements if _returns_rows(statement)),
635
+ statements=texts,
636
+ permanent_ddl=_permanent_ddl(texts),
637
+ )
638
+
639
+
640
+ def _is_only_comments(statement) -> bool:
641
+ import sqlparse
642
+
643
+ return all(
644
+ token.ttype in sqlparse.tokens.Comment
645
+ or token.ttype in sqlparse.tokens.Whitespace
646
+ or token.ttype in sqlparse.tokens.Newline
647
+ for token in statement.flatten()
648
+ )
649
+
650
+
651
+ def _returns_rows(statement) -> bool:
652
+ """A statement returns rows when it selects and does not divert the result."""
653
+
654
+ if statement.get_type() != "SELECT":
655
+ return False
656
+ # T-SQL `select … into #tmp` materialises instead of returning; Spark SQL
657
+ # has no such form, so the check is harmless there.
658
+ return not _has_into(statement)
659
+
660
+
661
+ def _has_into(statement) -> bool:
662
+ import sqlparse
663
+
664
+ depth = 0
665
+ for token in statement.flatten():
666
+ value = token.value.lower()
667
+ if token.ttype in sqlparse.tokens.Punctuation:
668
+ if value == "(":
669
+ depth += 1
670
+ elif value == ")":
671
+ depth -= 1
672
+ elif depth == 0 and token.ttype in sqlparse.tokens.Keyword and value == "into":
673
+ return True
674
+ return False