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.
- weaver/__init__.py +59 -0
- weaver/build_bundle/__init__.py +109 -0
- weaver/build_bundle/aliases.py +325 -0
- weaver/build_bundle/bundle.py +359 -0
- weaver/build_bundle/catalogue_actions.py +275 -0
- weaver/build_bundle/changes.py +186 -0
- weaver/build_bundle/endpoints.py +83 -0
- weaver/build_bundle/executors/__init__.py +69 -0
- weaver/build_bundle/executors/alias.py +202 -0
- weaver/build_bundle/executors/base.py +132 -0
- weaver/build_bundle/executors/folder.py +71 -0
- weaver/build_bundle/executors/load_file.py +205 -0
- weaver/build_bundle/executors/spark_case.py +26 -0
- weaver/build_bundle/executors/spark_schema.py +60 -0
- weaver/build_bundle/executors/spark_sql.py +59 -0
- weaver/build_bundle/executors/spark_sql_batch.py +57 -0
- weaver/build_bundle/executors/spark_table.py +213 -0
- weaver/build_bundle/executors/sql_endpoint_refresh.py +34 -0
- weaver/build_bundle/executors/tsql.py +81 -0
- weaver/build_bundle/incremental.py +288 -0
- weaver/build_bundle/installer.py +384 -0
- weaver/build_bundle/models.py +288 -0
- weaver/build_bundle/payloads.py +34 -0
- weaver/build_bundle/physical.py +625 -0
- weaver/build_bundle/planner.py +389 -0
- weaver/build_bundle/prune.py +620 -0
- weaver/build_bundle/report.py +108 -0
- weaver/build_bundle/stages.py +196 -0
- weaver/build_bundle/targets.py +272 -0
- weaver/build_bundle/workflow.py +585 -0
- weaver/catalogue/__init__.py +73 -0
- weaver/catalogue/builtin.py +238 -0
- weaver/catalogue/claims.py +121 -0
- weaver/catalogue/projection.py +437 -0
- weaver/catalogue/reader.py +152 -0
- weaver/catalogue/reconcile.py +231 -0
- weaver/catalogue/render.py +410 -0
- weaver/catalogue/state.py +660 -0
- weaver/catalogue/tables.py +648 -0
- weaver/config.py +178 -0
- weaver/declaration/__init__.py +171 -0
- weaver/declaration/columns.py +223 -0
- weaver/declaration/ddl.py +266 -0
- weaver/declaration/dependencies.py +544 -0
- weaver/declaration/graph.py +240 -0
- weaver/declaration/item_dependencies.py +292 -0
- weaver/declaration/load.py +191 -0
- weaver/declaration/metadata.py +1405 -0
- weaver/declaration/model.py +448 -0
- weaver/declaration/references.py +294 -0
- weaver/declaration/repository.py +959 -0
- weaver/declaration/schemas.py +135 -0
- weaver/declaration/source.py +674 -0
- weaver/declaration/spark_load.py +759 -0
- weaver/declaration/sql_shaping.py +591 -0
- weaver/declaration/templates/ddl/declared_create_table.sql +64 -0
- weaver/declaration/templates/ddl/infer_create_table.sql +97 -0
- weaver/declaration/templates/ddl/metadata_column_validation.sql +30 -0
- weaver/declaration/templates/load/column_metadata.sql +40 -0
- weaver/declaration/templates/load/full_replace_body.sql +21 -0
- weaver/declaration/templates/load/install_load_procedure.sql +27 -0
- weaver/declaration/templates/load/load_procedure.sql +48 -0
- weaver/declaration/templates/load/primary_key_body.sql +113 -0
- weaver/declaration/tsql_ddl.py +468 -0
- weaver/declaration/tsql_load.py +417 -0
- weaver/declaration/warehouse_type_mapping.yml +93 -0
- weaver/diagnostics.py +247 -0
- weaver/errors.py +61 -0
- weaver/etl.py +469 -0
- weaver/fabric/__init__.py +107 -0
- weaver/fabric/auth.py +137 -0
- weaver/fabric/capacity.py +143 -0
- weaver/fabric/client.py +147 -0
- weaver/fabric/environment.py +460 -0
- weaver/fabric/livy.py +478 -0
- weaver/fabric/notebooks.py +201 -0
- weaver/fabric/onelake.py +263 -0
- weaver/fabric/resolution.py +344 -0
- weaver/fabric/resources.py +245 -0
- weaver/fabric/session.py +148 -0
- weaver/fabric/shortcuts.py +120 -0
- weaver/fabric/sql.py +118 -0
- weaver/fabric/store.py +198 -0
- weaver/initialise.py +209 -0
- weaver/lakehouse.py +386 -0
- weaver/load.py +474 -0
- weaver/load_execution.py +483 -0
- weaver/load_plan.py +912 -0
- weaver/load_report.py +330 -0
- weaver/load_resolution.py +386 -0
- weaver/locations.py +164 -0
- weaver/objects.py +392 -0
- weaver/operations.py +757 -0
- weaver/physical_wipe.py +369 -0
- weaver/push.py +76 -0
- weaver/resolution.py +292 -0
- weaver/runtime/__init__.py +30 -0
- weaver/runtime/folder_load.py +402 -0
- weaver/runtime/load_contract.py +245 -0
- weaver/runtime/load_result.py +104 -0
- weaver/runtime/spark_load.py +152 -0
- weaver/runtime/table_load.py +497 -0
- weaver/spark/__init__.py +49 -0
- weaver/spark/catalogue.py +245 -0
- weaver/spark/destination.py +195 -0
- weaver/spark/session.py +84 -0
- weaver/spark/tokens.py +138 -0
- weaver/sql/__init__.py +40 -0
- weaver/sql/authentication.py +38 -0
- weaver/sql/connection.py +90 -0
- weaver/sql/errors.py +25 -0
- weaver/sql/execution.py +123 -0
- weaver/sql/pool.py +174 -0
- weaver/sql/wipe.py +156 -0
- weaver/store.py +209 -0
- weaver/targets.py +257 -0
- weaver/task_logging.py +215 -0
- weaver/unbind.py +74 -0
- weaver/workspaces.py +175 -0
- weaver_cli/__init__.py +12 -0
- weaver_cli/__main__.py +7 -0
- weaver_cli/main.py +626 -0
- weaverstack-0.1.1.dist-info/METADATA +113 -0
- weaverstack-0.1.1.dist-info/RECORD +127 -0
- weaverstack-0.1.1.dist-info/WHEEL +4 -0
- weaverstack-0.1.1.dist-info/entry_points.txt +2 -0
- 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
|