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,1405 @@
1
+ """The Weaver document document contract.
2
+
3
+ This is the basic unit of work in Weaver: a Folder, a Delta table, or a
4
+ Warehouse table or view, declared as YAML at the top of its source file — a
5
+ Python module docstring, or the opening ``/* … */`` of a SQL file.
6
+
7
+ The contract is validated to exhaustion up front. Every key is known, every
8
+ column reference is checked against the declared schema where one exists, and
9
+ every contradiction is refused before anything physical happens. A mistyped
10
+ ``Primary Key`` must not parse as "no primary key" and silently become a full
11
+ replacement at load time.
12
+
13
+ Where validation cannot happen here it is recorded rather than skipped: a SQL
14
+ object infers its shape from its query, so its column references are parsed but
15
+ resolved at build. :attr:`SesDocument.defers_column_validation` says so.
16
+
17
+ Nothing here imports the module it describes, reads a file, or resolves a
18
+ reference to another object. Reference resolution needs sibling documents and
19
+ belongs with the repository reader.
20
+
21
+ **Layout convention.** Separate each subsection with a blank line. This is not
22
+ enforced — YAML does not care — but the header is the contract a reader meets
23
+ first, and a wall of keys is a worse contract than a legible one::
24
+
25
+ Table ID: Sales.Order
26
+
27
+ Description: One row per confirmed customer order.
28
+
29
+ Lineage: $Sales.OrderExport
30
+
31
+ Primary key: Order id
32
+
33
+ Schema:
34
+ Order id: string
35
+ Order date: date
36
+
37
+ Revision notes:
38
+ - 2026-07-23 Added the amount column.
39
+
40
+ Fixtures and examples follow it so the convention is learned by reading.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import ast
46
+ import re
47
+ from dataclasses import dataclass, field
48
+ from datetime import date, datetime
49
+ from typing import Any
50
+
51
+ import yaml
52
+
53
+ from ..errors import MetadataError
54
+
55
+ FOLDER = "Folder"
56
+ TABLE = "Table"
57
+ VIEW = "View"
58
+ OBJECT_KINDS = frozenset({FOLDER, TABLE, VIEW})
59
+
60
+ PYTHON = "python"
61
+ SQL = "sql"
62
+ SPARK_SQL = "spark_sql"
63
+ LANGUAGES = frozenset({PYTHON, SQL, SPARK_SQL})
64
+
65
+ #: Languages whose objects materialise as Delta rather than in a Warehouse.
66
+ #: They declare their shape up front and use the underscored audit spelling.
67
+ DELTA_LANGUAGES = frozenset({PYTHON, SPARK_SQL})
68
+
69
+ # The three physical destinations. An object ID is unique *within* one of these,
70
+ # not across them: Sales.Order may exist as a folder, as a Delta table and as a
71
+ # Warehouse table at the same time, because those are three different places.
72
+ FOLDER_TARGET = "folder"
73
+ DELTA_TARGET = "delta"
74
+ SQL_TARGET = "sql"
75
+ TARGET_KINDS = (FOLDER_TARGET, DELTA_TARGET, SQL_TARGET)
76
+
77
+ # The two execution namespaces a two-part reference may bind in. A Lakehouse
78
+ # object (Folder or Delta) resolves its references inside the Lakehouse; a
79
+ # Warehouse object (SQL) inside the Warehouse. The two are bridged only by an
80
+ # explicit alias, never by inference.
81
+ LAKEHOUSE_NAMESPACE = "lakehouse"
82
+ WAREHOUSE_NAMESPACE = "warehouse"
83
+ NAMESPACES = (LAKEHOUSE_NAMESPACE, WAREHOUSE_NAMESPACE)
84
+
85
+
86
+ def namespace_for_target(target_kind: str) -> str:
87
+ """The namespace a native object of this target binds its references in."""
88
+
89
+ return WAREHOUSE_NAMESPACE if target_kind == SQL_TARGET else LAKEHOUSE_NAMESPACE
90
+
91
+
92
+ def target_kind_for(language: str, kind: str) -> str:
93
+ """Where an object materialises, from its language and kind.
94
+
95
+ Routing is inferred, never configured — which is what removed the old
96
+ paired source-and-target build command.
97
+ """
98
+
99
+ if kind == FOLDER:
100
+ return FOLDER_TARGET
101
+ if language in DELTA_LANGUAGES:
102
+ return DELTA_TARGET
103
+ return SQL_TARGET
104
+
105
+ _ID_KEYS = {"Folder ID": FOLDER, "Table ID": TABLE, "View ID": VIEW}
106
+ _PLACEHOLDERS = {"not declared", "n/a", "tbd", "todo"}
107
+
108
+ # Cross-engine aliases. A Lakehouse object publishes into the Warehouse with a
109
+ # Warehouse alias; a Warehouse object publishes into the Lakehouse with a
110
+ # Lakehouse alias. Eligibility is by target, not just kind, so both keys are
111
+ # accepted here and refused in _parse_aliases when they sit on the wrong object.
112
+ WAREHOUSE_ALIAS = "Warehouse alias"
113
+ LAKEHOUSE_ALIAS = "Lakehouse alias"
114
+ _ALIAS_KEYS = {WAREHOUSE_ALIAS, LAKEHOUSE_ALIAS}
115
+
116
+ #: Stability thresholds — the guard against a load that is *technically* correct
117
+ #: and obviously wrong. A source that broke overnight and returned a tenth of its
118
+ #: rows produces a load Weaver would otherwise carry out faithfully.
119
+ #:
120
+ #: The percentages are of the target's row count *before* the load, and the row
121
+ #: threshold is the size below which neither applies: on a small table a single
122
+ #: row is a large percentage, and tripping on that would teach everyone to turn
123
+ #: the guard off.
124
+ DELETE_THRESHOLD = "Delete percentage threshold"
125
+ UPDATE_THRESHOLD = "Update percentage threshold"
126
+ STABILITY_ROWS = "Stability row threshold"
127
+
128
+ #: Deliberately not zero. A load that has never been run against a populated
129
+ #: table has nothing to compare with, and a first load inserts everything — so
130
+ #: the defaults protect an established table without standing in the way of one
131
+ #: being established.
132
+ DEFAULT_DELETE_THRESHOLD = 5
133
+ DEFAULT_UPDATE_THRESHOLD = 20
134
+ DEFAULT_STABILITY_ROWS = 1_000_000
135
+
136
+ # Keys accepted per kind. Anything else is a typo and is refused by name.
137
+ _COMMON_KEYS = {
138
+ "Description",
139
+ "Lineage",
140
+ "Notes",
141
+ "Revision notes",
142
+ "Dependencies",
143
+ "Static",
144
+ "Prohibit rebuild",
145
+ WAREHOUSE_ALIAS,
146
+ LAKEHOUSE_ALIAS,
147
+ }
148
+ _KIND_KEYS = {
149
+ FOLDER: {"File key", "Incremental"},
150
+ TABLE: {
151
+ "Schema",
152
+ "Column notes",
153
+ "Primary key",
154
+ "Unique keys",
155
+ "Foreign keys",
156
+ "Not null",
157
+ "Identity",
158
+ "Comparison columns",
159
+ "Incremental",
160
+ DELETE_THRESHOLD,
161
+ UPDATE_THRESHOLD,
162
+ STABILITY_ROWS,
163
+ },
164
+ # A view's keys are logical: it stores no rows, so they describe the shape of
165
+ # the result rather than constraining storage. They are declared so the model
166
+ # is complete and can be checked for quality; nothing physical follows.
167
+ VIEW: {"Column notes", "Primary key", "Unique keys", "Foreign keys"},
168
+ }
169
+
170
+ # Retired keys, refused with the migration rather than as "unknown".
171
+ _RETIRED_KEYS = {
172
+ "Auto delete": (
173
+ "Auto delete is no longer supported. Use Incremental with the inverse value:\n"
174
+ "Auto delete: false becomes Incremental: true.\n"
175
+ "Auto delete: true becomes Incremental: false."
176
+ ),
177
+ "Load mode": (
178
+ "Load mode is no longer supported. Behaviour follows from Incremental and "
179
+ "Primary key."
180
+ ),
181
+ }
182
+
183
+ # Multiple independent columns are a YAML list; a column *set* — one key or one
184
+ # comparison tuple — is comma-separated.
185
+ _LIST_KEYS = {"Not null"}
186
+ _SET_KEYS = {"Primary key", "Comparison columns"}
187
+
188
+ _REFERENCE = re.compile(r"^\$([^\[\]$]+?)(?:\[([^\[\]$]+)\])?$")
189
+
190
+ # A revision entry opens with a date. Which spelling is the developer's choice;
191
+ # holding to one spelling within a document is not, because a mixed list cannot
192
+ # be read in order at a glance. Day-first and month-first share a shape and are
193
+ # not told apart — Weaver checks the shape, not the reading.
194
+ _REVISION_DATE_SHAPES = (
195
+ ("YYYY-MM-DD", re.compile(r"^(\d{4})-(\d{1,2})-(\d{1,2})(?=\s|$)"), True),
196
+ ("YYYY/MM/DD", re.compile(r"^(\d{4})/(\d{1,2})/(\d{1,2})(?=\s|$)"), True),
197
+ ("DD/MM/YYYY", re.compile(r"^(\d{1,2})/(\d{1,2})/(\d{4})(?=\s|$)"), False),
198
+ ("DD-MM-YYYY", re.compile(r"^(\d{1,2})-(\d{1,2})-(\d{4})(?=\s|$)"), False),
199
+ ("DD.MM.YYYY", re.compile(r"^(\d{1,2})\.(\d{1,2})\.(\d{4})(?=\s|$)"), False),
200
+ )
201
+
202
+
203
+ # --- audit columns ---------------------------------------------------------
204
+
205
+ #: Logical audit columns, materialised on every table but never authored.
206
+ #: Physical spelling follows the representation: a Warehouse keeps the spaced
207
+ #: form already used by the SQL backend, Delta uses lower snake case because
208
+ #: spaces in Spark column names need quoting everywhere they appear and Delta's
209
+ #: own convention is snake case throughout.
210
+ AUDIT_INSERT = "Row insert datetime"
211
+ AUDIT_UPDATE = "Row update datetime"
212
+ AUDIT_DELETE = "Row delete datetime"
213
+ AUDIT_COLUMNS = (AUDIT_INSERT, AUDIT_UPDATE, AUDIT_DELETE)
214
+
215
+ _AUDIT_TYPES = {PYTHON: "timestamp", SPARK_SQL: "timestamp", SQL: "datetime2(6)"}
216
+
217
+ #: The delete datetime of a row that is still live. All three audit columns are
218
+ #: physically not null — there is no valid null state for any of them — so a live
219
+ #: row carries a sentinel maximum rather than an absence. That makes "as at" a
220
+ #: single range predicate instead of a null check, which is why the SQL Server
221
+ #: original chose it.
222
+ #:
223
+ #: Load will populate these columns for ordinary rows and is not yet written;
224
+ #: this constant exists now because the catalogue's own DML must satisfy the
225
+ #: not-null constraint today. It is the row convention, not a catalogue detail,
226
+ #: which is why it lives here.
227
+ AUDIT_LIVE_DELETE_DATETIME = "9999-12-31 23:59:59.999999"
228
+
229
+ #: The identity column is a surrogate the *engine* generates: build declares it
230
+ #: ``bigint identity not null`` and the Warehouse assigns a value to every
231
+ #: inserted row. It is Weaver's column, so it is not part of the declared
232
+ #: business schema or a query's output, and a load never inserts into it.
233
+ IDENTITY_TYPE = "bigint"
234
+
235
+ #: Which representations can carry an identity column, and it is the Warehouse
236
+ #: alone. Native identity is what makes the column trustworthy — a value Weaver
237
+ #: computed would have to be unique across concurrent writers, which is the
238
+ #: guarantee an engine's identity exists to provide, and neither Delta 3.2 (this
239
+ #: repository's floor) nor Fabric's Spark runtime offers one to generate it with.
240
+ #: So a Delta table declares no identity at all rather than carrying a column
241
+ #: whose contents Weaver could not honestly promise.
242
+ IDENTITY_LANGUAGES = frozenset({SQL})
243
+
244
+ _IDENTITY_UNSUPPORTED = (
245
+ "Identity is supported for Warehouse tables only. A Delta table has no "
246
+ "engine-generated identity to sit behind the column — Spark and Delta offer "
247
+ "none Weaver can rely on — so remove the Identity header and use the "
248
+ "business key, or declare the object in a Warehouse item."
249
+ )
250
+
251
+
252
+ def audit_column_name(logical: str, language: str) -> str:
253
+ """The physical spelling of one logical audit column for a representation.
254
+
255
+ Delta gets lower snake case (``row_insert_datetime``); a Warehouse keeps the
256
+ spaced form (``Row insert datetime``) the SQL backend has always used.
257
+ """
258
+
259
+ if language in DELTA_LANGUAGES:
260
+ return logical.replace(" ", "_").lower()
261
+ return logical
262
+
263
+
264
+ #: Every spelling of an audit column an author might reach for, folded for
265
+ #: comparison. All are reserved, including the retired ``Row_insert_datetime``
266
+ #: form, so a declaration can never collide with the columns Weaver adds.
267
+ _RESERVED_AUDIT_NAMES = frozenset(
268
+ spelling.lower()
269
+ for logical in AUDIT_COLUMNS
270
+ for spelling in (logical, logical.replace(" ", "_"))
271
+ )
272
+
273
+
274
+ def _audit_columns(language: str) -> tuple["Column", ...]:
275
+ return tuple(
276
+ Column(
277
+ name=audit_column_name(logical, language),
278
+ type=_AUDIT_TYPES[language],
279
+ # Weaver populates all three on every loaded row — insert and update
280
+ # datetimes, and a sentinel maximum delete datetime for a live row —
281
+ # so none has a valid null state and all are physically not null.
282
+ not_null=True,
283
+ is_audit=True,
284
+ )
285
+ for logical in AUDIT_COLUMNS
286
+ )
287
+
288
+
289
+ # --- values ----------------------------------------------------------------
290
+
291
+
292
+ @dataclass(frozen=True)
293
+ class ObjectId:
294
+ """Levels two and one — ``Schema.Object`` within a repository."""
295
+
296
+ schema: str
297
+ object: str
298
+
299
+ @property
300
+ def qualified(self) -> str:
301
+ return f"{self.schema}.{self.object}"
302
+
303
+ def __str__(self) -> str:
304
+ return self.qualified
305
+
306
+
307
+ @dataclass(frozen=True)
308
+ class Reference:
309
+ """An item-relative or item-qualified exact-case metadata reference."""
310
+
311
+ schema: str
312
+ object: str
313
+ column: str | None = None
314
+ item_type: str | None = None
315
+ item_name: str | None = None
316
+ is_files: bool = False
317
+
318
+ def __post_init__(self) -> None:
319
+ if (self.item_type is None) != (self.item_name is None):
320
+ raise MetadataError("a qualified reference needs both item type and item name")
321
+ if self.item_type is not None and self.item_type not in ("Lakehouse", "Warehouse"):
322
+ raise MetadataError(
323
+ f"reference item type must be Lakehouse or Warehouse, got {self.item_type!r}"
324
+ )
325
+ if self.is_files and self.item_type == "Warehouse":
326
+ raise MetadataError("Files references may only name a Lakehouse item")
327
+
328
+ @property
329
+ def object_id(self) -> ObjectId:
330
+ return ObjectId(schema=self.schema, object=self.object)
331
+
332
+ @property
333
+ def target(self) -> str:
334
+ within = f"Files/{self.schema}.{self.object}" if self.is_files else self.object_id.qualified
335
+ if self.item_type is None:
336
+ return within
337
+ return f"{self.item_type}/{self.item_name}/{within}"
338
+
339
+ @property
340
+ def is_item_qualified(self) -> bool:
341
+ return self.item_type is not None
342
+
343
+ def __str__(self) -> str:
344
+ target = f"${self.target}"
345
+ return f"{target}[{self.column}]" if self.column else target
346
+
347
+
348
+ @dataclass(frozen=True)
349
+ class MetadataText:
350
+ """Either literal prose or exactly one reference — never a mix.
351
+
352
+ ``See $Sales.Order`` is refused. Mixed content cannot be resolved
353
+ mechanically, and a contract that is only sometimes machine-readable is not
354
+ a contract. Write ``$$`` for a literal dollar sign.
355
+ """
356
+
357
+ literal: str | None = None
358
+ reference: Reference | None = None
359
+
360
+ @property
361
+ def is_reference(self) -> bool:
362
+ return self.reference is not None
363
+
364
+ def __str__(self) -> str:
365
+ return str(self.reference) if self.reference else (self.literal or "")
366
+
367
+
368
+ @dataclass(frozen=True)
369
+ class Revision:
370
+ """One dated entry in the object's revision history."""
371
+
372
+ date: str
373
+ note: str
374
+
375
+ def __str__(self) -> str:
376
+ return f"{self.date} {self.note}"
377
+
378
+
379
+ @dataclass(frozen=True)
380
+ class ForeignKey:
381
+ """One declared relationship to a parent object.
382
+
383
+ Semantic rather than physical — closer to an ER diagram than to a database
384
+ constraint. Nothing is enforced by the engine and no index follows; the
385
+ declaration records that these columns mean the parent's columns.
386
+
387
+ Consequently a key has no name, two objects may be related several times
388
+ over, and an object may reference itself (a parent-child hierarchy in one
389
+ table). The parent is a two-part ``Schema.Object``: it is a logical name in
390
+ the same repository, not a physical one.
391
+ """
392
+
393
+ columns: tuple[str, ...]
394
+ reference: ObjectId
395
+ reference_columns: tuple[str, ...]
396
+ logical_reference: Reference | None = None
397
+
398
+ def __str__(self) -> str:
399
+ child = ", ".join(self.columns)
400
+ parent = ", ".join(self.reference_columns)
401
+ target = (
402
+ self.logical_reference.target
403
+ if self.logical_reference is not None
404
+ else self.reference.qualified
405
+ )
406
+ return f"{child}: {target}[{parent}]"
407
+
408
+
409
+ @dataclass(frozen=True)
410
+ class Column:
411
+ """One column of a table or view."""
412
+
413
+ name: str
414
+ type: str | None = None
415
+ note: MetadataText | None = None
416
+ not_null: bool = False
417
+ is_audit: bool = False
418
+ is_identity: bool = False
419
+
420
+
421
+ # --- the document ----------------------------------------------------------
422
+
423
+
424
+ @dataclass(frozen=True)
425
+ class WeaverDocument:
426
+ """A fully validated Weaver document object declaration."""
427
+
428
+ kind: str
429
+ language: str
430
+ object_id: ObjectId
431
+ description: MetadataText
432
+ lineage: MetadataText
433
+ notes: str | None = None
434
+ dependencies: tuple[ObjectId, ...] = ()
435
+ #: True when the document wrote a ``Dependencies`` key at all, including an
436
+ #: empty list. An explicit none must suppress discovery the same way a
437
+ #: populated list replaces it — otherwise `Dependencies: []` would silently
438
+ #: mean "discover them for me".
439
+ declares_dependencies: bool = False
440
+ revision_notes: tuple[Revision, ...] = ()
441
+ revision_date_format: str | None = None
442
+ schema: tuple[Column, ...] = ()
443
+ primary_key: tuple[str, ...] = ()
444
+ unique_keys: tuple[tuple[str, ...], ...] = ()
445
+ foreign_keys: tuple[ForeignKey, ...] = ()
446
+ declared_not_null: tuple[str, ...] = ()
447
+ identity: str | None = None
448
+ declared_comparison_columns: tuple[str, ...] = ()
449
+ delete_threshold: int = DEFAULT_DELETE_THRESHOLD
450
+ update_threshold: int = DEFAULT_UPDATE_THRESHOLD
451
+ stability_rows: int = DEFAULT_STABILITY_ROWS
452
+ file_keys: tuple[str, ...] = ()
453
+ is_incremental: bool = False
454
+ prohibit_rebuild: bool = False
455
+ static: bool = False
456
+ warehouse_alias: ObjectId | None = None
457
+ lakehouse_alias: ObjectId | None = None
458
+ raw: dict[str, Any] = field(default_factory=dict)
459
+
460
+ @property
461
+ def qualified(self) -> str:
462
+ return self.object_id.qualified
463
+
464
+ @property
465
+ def has_primary_key(self) -> bool:
466
+ return bool(self.primary_key)
467
+
468
+ @property
469
+ def has_declared_schema(self) -> bool:
470
+ return bool(self.schema)
471
+
472
+ @property
473
+ def defers_column_validation(self) -> bool:
474
+ """True when column references cannot be checked until build.
475
+
476
+ A SQL object infers its shape from its query, so its `Primary key`,
477
+ `Not null`, `Identity`, `Comparison columns` and `Column notes` are
478
+ validated against the built table rather than here.
479
+ """
480
+
481
+ return self.kind in (TABLE, VIEW) and not self.has_declared_schema
482
+
483
+ @property
484
+ def audit_columns(self) -> tuple[Column, ...]:
485
+ """The architectural columns, spelled for this representation."""
486
+
487
+ return _audit_columns(self.language) if self.kind == TABLE else ()
488
+
489
+ @property
490
+ def identity_column(self) -> Column | None:
491
+ """The engine-generated surrogate column, when Identity names one.
492
+
493
+ A not-null ``bigint`` the Warehouse generates: build declares it
494
+ ``identity`` and every insert leaves it out so the engine assigns
495
+ it. It is Weaver's own column, so it stands outside the business schema
496
+ (declared or inferred); the primary key may name it when the surrogate is
497
+ the key. Only a Warehouse table has one — see :data:`IDENTITY_LANGUAGES`.
498
+ """
499
+
500
+ if self.identity is None or self.kind != TABLE:
501
+ return None
502
+ return Column(
503
+ name=self.identity, type=IDENTITY_TYPE, not_null=True, is_identity=True
504
+ )
505
+
506
+ @property
507
+ def effective_schema(self) -> tuple[Column, ...]:
508
+ """The full physical shape of a declared table: identity, business, audit.
509
+
510
+ ``schema`` stays exactly what the author wrote; this is what gets
511
+ materialised. The Weaver-managed identity column leads, the audit columns
512
+ trail. Both forms are available because either can be the one you need.
513
+ """
514
+
515
+ identity = (self.identity_column,) if self.identity_column else ()
516
+ return identity + self.schema + self.audit_columns
517
+
518
+ @property
519
+ def not_null(self) -> tuple[str, ...]:
520
+ """Declared not-null columns plus the primary key, which always is."""
521
+
522
+ return self.primary_key + self.declared_not_null
523
+
524
+ @property
525
+ def comparison_columns(self) -> tuple[str, ...]:
526
+ """Columns whose change drives an upsert.
527
+
528
+ Defaults to every declared non-key column. Naming a narrower set makes
529
+ the comparison cheaper when a watermark column already implies change.
530
+ """
531
+
532
+ if self.declared_comparison_columns:
533
+ return self.declared_comparison_columns
534
+ return tuple(
535
+ column.name
536
+ for column in self.schema
537
+ if column.name not in self.primary_key and not column.is_audit
538
+ )
539
+
540
+
541
+ # Transitional public spelling. R8 removes it after callers have migrated;
542
+ # keeping the alias here lets identity and discovery move independently.
543
+ SesDocument = WeaverDocument
544
+
545
+
546
+ # --- extraction ------------------------------------------------------------
547
+
548
+
549
+ class _UniqueKeyLoader(yaml.SafeLoader):
550
+ """YAML loader that refuses duplicate mapping keys."""
551
+
552
+
553
+ def _no_duplicate_keys(loader, node, deep=False):
554
+ loader.flatten_mapping(node)
555
+ mapping: dict[Any, Any] = {}
556
+ for key_node, value_node in node.value:
557
+ key = loader.construct_object(key_node, deep=deep)
558
+ if key in mapping:
559
+ raise MetadataError(f"duplicate metadata key: {key}")
560
+ mapping[key] = loader.construct_object(value_node, deep=deep)
561
+ return mapping
562
+
563
+
564
+ _UniqueKeyLoader.add_constructor(
565
+ yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
566
+ _no_duplicate_keys,
567
+ )
568
+
569
+
570
+ def extract_python_metadata(source: str) -> str:
571
+ """The metadata YAML from a Python object file's module docstring."""
572
+
573
+ try:
574
+ module = ast.parse(source)
575
+ except SyntaxError as exc:
576
+ raise MetadataError(f"python object file is not parseable: {exc}") from exc
577
+ doc = ast.get_docstring(module, clean=True)
578
+ if doc is None or not doc.strip():
579
+ raise MetadataError("python object file must begin with a docstring metadata block")
580
+ return doc
581
+
582
+
583
+ def extract_sql_metadata_and_body(source: str) -> tuple[str, str]:
584
+ """Split a SQL object file into (metadata text, executable body)."""
585
+
586
+ match = re.match(r"\s*/\*(.*?)\*/(.*)\Z", source, flags=re.DOTALL)
587
+ if not match:
588
+ raise MetadataError("Weaver document SQL must begin with a /* ... */ metadata block")
589
+ return match.group(1).strip("\n"), match.group(2).lstrip()
590
+
591
+
592
+ def parse_python_document(source: str) -> SesDocument:
593
+ return parse_document(extract_python_metadata(source), language=PYTHON)
594
+
595
+
596
+ def parse_sql_document(source: str) -> tuple[SesDocument, str]:
597
+ text, body = extract_sql_metadata_and_body(source)
598
+ return parse_document(text, language=SQL), body
599
+
600
+
601
+ # --- parsing ---------------------------------------------------------------
602
+
603
+
604
+ def parse_document(text: str, *, language: str) -> SesDocument:
605
+ """Parse and exhaustively validate one metadata block."""
606
+
607
+ if language not in LANGUAGES:
608
+ raise MetadataError(f"language must be one of {', '.join(sorted(LANGUAGES))}")
609
+
610
+ try:
611
+ loaded = yaml.load(text, Loader=_UniqueKeyLoader)
612
+ except MetadataError:
613
+ raise
614
+ except yaml.YAMLError as exc:
615
+ raise MetadataError(f"invalid metadata YAML: {exc}") from exc
616
+
617
+ if not isinstance(loaded, dict):
618
+ raise MetadataError("metadata must be a YAML mapping")
619
+
620
+ for retired, message in _RETIRED_KEYS.items():
621
+ if retired in loaded:
622
+ raise MetadataError(message)
623
+
624
+ kind, object_id = _parse_id(loaded)
625
+ _reject_unknown_keys(loaded, kind)
626
+
627
+ # A Warehouse (T-SQL) table may declare Schema or omit it: with a declaration
628
+ # the declared types are authoritative; without one the table takes its shape
629
+ # from its query, inferred at build (see how-does-build-work §2).
630
+
631
+ declares_dependencies = "Dependencies" in loaded
632
+ dependencies = _parse_dependencies(loaded.get("Dependencies"), object_id)
633
+ if language == SPARK_SQL and not declares_dependencies:
634
+ raise MetadataError(
635
+ "a Spark SQL object must declare Dependencies. Its query may read by "
636
+ "path, which cannot be resolved back to a managed object, so the graph "
637
+ "is declared rather than discovered. Write `Dependencies: []` if it "
638
+ "genuinely depends on nothing."
639
+ )
640
+
641
+ description = _parse_text(loaded, "Description")
642
+ lineage = _parse_text(loaded, "Lineage")
643
+ notes = _parse_notes(loaded.get("Notes"))
644
+ revisions, revision_format = _parse_revision_notes(loaded.get("Revision notes"))
645
+ static = _parse_bool(loaded.get("Static"), "Static")
646
+ prohibit_rebuild = _parse_flag_with_default(
647
+ loaded, "Prohibit rebuild", default=kind == FOLDER
648
+ )
649
+ file_keys = _parse_file_keys(loaded.get("File key"), kind=kind)
650
+
651
+ if kind == VIEW and "Incremental" in loaded:
652
+ raise MetadataError("Incremental is not supported for View objects")
653
+ is_incremental = _parse_flag_with_default(loaded, "Incremental", default=kind == FOLDER)
654
+
655
+ declared_columns = _parse_schema(loaded.get("Schema"))
656
+ if kind == TABLE and language == PYTHON and not declared_columns:
657
+ raise MetadataError(
658
+ "a Python-backed Delta table must declare Schema — it has no query to "
659
+ "infer a shape from, is created before it is loaded, and the declared "
660
+ "shape is what lets every column guard run up front"
661
+ )
662
+
663
+ primary_key = _parse_column_set(loaded.get("Primary key"), "Primary key")
664
+ unique_keys = _parse_unique_keys(loaded.get("Unique keys"), primary_key)
665
+ foreign_keys = _parse_foreign_keys(loaded.get("Foreign keys"), object_id)
666
+ declared_not_null = _parse_column_list(loaded.get("Not null"), "Not null")
667
+ delete_threshold = _parse_percentage(loaded, DELETE_THRESHOLD, DEFAULT_DELETE_THRESHOLD)
668
+ update_threshold = _parse_percentage(loaded, UPDATE_THRESHOLD, DEFAULT_UPDATE_THRESHOLD)
669
+ stability_rows = _parse_row_count(loaded, STABILITY_ROWS, DEFAULT_STABILITY_ROWS)
670
+ identity = _parse_identity(loaded.get("Identity"))
671
+ if identity is not None and language not in IDENTITY_LANGUAGES:
672
+ raise MetadataError(_IDENTITY_UNSUPPORTED)
673
+ comparison = _parse_column_set(loaded.get("Comparison columns"), "Comparison columns")
674
+ column_notes = _parse_column_notes(loaded.get("Column notes"))
675
+
676
+ _validate_columns(
677
+ kind=kind,
678
+ declared_columns=declared_columns,
679
+ primary_key=primary_key,
680
+ unique_keys=unique_keys,
681
+ foreign_keys=foreign_keys,
682
+ declared_not_null=declared_not_null,
683
+ identity=identity,
684
+ comparison=comparison,
685
+ notes=column_notes,
686
+ )
687
+
688
+ if kind == TABLE:
689
+ if is_incremental and not primary_key:
690
+ raise MetadataError("Incremental: true requires a Primary key")
691
+ if comparison and not primary_key:
692
+ raise MetadataError(
693
+ "Comparison columns require a Primary key — they drive upsert comparison, "
694
+ "which only happens when rows can be matched"
695
+ )
696
+ if static and is_incremental:
697
+ raise MetadataError(
698
+ "Static and Incremental: true contradict — a static object is loaded once, "
699
+ "so there is nothing to accumulate"
700
+ )
701
+
702
+ schema = _apply_column_details(declared_columns, column_notes, primary_key, declared_not_null)
703
+
704
+ warehouse_alias, lakehouse_alias = _parse_aliases(loaded, language, kind, object_id)
705
+
706
+ return SesDocument(
707
+ kind=kind,
708
+ language=language,
709
+ object_id=object_id,
710
+ description=description,
711
+ lineage=lineage,
712
+ notes=notes,
713
+ dependencies=dependencies,
714
+ declares_dependencies=declares_dependencies,
715
+ revision_notes=revisions,
716
+ revision_date_format=revision_format,
717
+ schema=schema,
718
+ primary_key=primary_key,
719
+ unique_keys=unique_keys,
720
+ foreign_keys=foreign_keys,
721
+ declared_not_null=declared_not_null,
722
+ identity=identity,
723
+ declared_comparison_columns=comparison,
724
+ delete_threshold=delete_threshold,
725
+ update_threshold=update_threshold,
726
+ stability_rows=stability_rows,
727
+ file_keys=file_keys,
728
+ is_incremental=is_incremental,
729
+ prohibit_rebuild=prohibit_rebuild,
730
+ static=static,
731
+ warehouse_alias=warehouse_alias,
732
+ lakehouse_alias=lakehouse_alias,
733
+ raw=dict(loaded),
734
+ )
735
+
736
+
737
+ def _parse_id(raw: dict[str, Any]) -> tuple[str, ObjectId]:
738
+ present = [key for key in _ID_KEYS if key in raw and raw[key] is not None]
739
+ if len(present) != 1:
740
+ raise MetadataError("metadata must include exactly one of Folder ID, Table ID, View ID")
741
+ key = present[0]
742
+ value = raw[key]
743
+ if not isinstance(value, str) or not value.strip():
744
+ raise MetadataError(f"{key} must be a non-empty Schema.Object string")
745
+ parts = [part.strip() for part in value.strip().split(".")]
746
+ if len(parts) != 2 or not all(parts):
747
+ raise MetadataError(f"{key} must be a two-part Schema.Object declaration, got {value!r}")
748
+ return _ID_KEYS[key], ObjectId(schema=parts[0], object=parts[1])
749
+
750
+
751
+ def _reject_unknown_keys(raw: dict[str, Any], kind: str) -> None:
752
+ allowed = _COMMON_KEYS | _KIND_KEYS[kind] | set(_ID_KEYS)
753
+ unknown = {str(key) for key in raw} - allowed
754
+ if unknown:
755
+ wrong_kind = {
756
+ key
757
+ for key in unknown
758
+ for other_kind, keys in _KIND_KEYS.items()
759
+ if key in keys and other_kind != kind
760
+ }
761
+ detail = ""
762
+ if wrong_kind:
763
+ detail = f" ({', '.join(sorted(wrong_kind))} belongs to another object kind)"
764
+ raise MetadataError(
765
+ f"unknown metadata key(s) for a {kind} object: "
766
+ + ", ".join(sorted(unknown))
767
+ + detail
768
+ )
769
+
770
+
771
+ def _parse_text(raw: dict[str, Any], key: str) -> MetadataText:
772
+ value = raw.get(key)
773
+ if not isinstance(value, str) or not value.strip():
774
+ raise MetadataError(f"{key} is required and must be non-empty text")
775
+ return _parse_text_value(value, key)
776
+
777
+
778
+ def _parse_text_value(value: str, key: str) -> MetadataText:
779
+ stripped = value.strip()
780
+ if "$" in stripped.replace("$$", ""):
781
+ match = _REFERENCE.match(stripped)
782
+ if not match:
783
+ raise MetadataError(
784
+ f"{key} must be either prose or exactly one $Schema.Object reference, "
785
+ f"not a mix of both: {stripped!r}. Write $$ for a literal dollar sign."
786
+ )
787
+ target, column = match.groups()
788
+ return MetadataText(
789
+ reference=_parse_logical_reference(
790
+ target.strip(), column=column.strip() if column else None, key=key
791
+ )
792
+ )
793
+ literal = stripped.replace("$$", "$")
794
+ if literal.lower() in _PLACEHOLDERS:
795
+ raise MetadataError(f"{key} must not be a placeholder value ({literal!r})")
796
+ return MetadataText(literal=literal)
797
+
798
+
799
+ def _parse_logical_reference(
800
+ target: str, *, column: str | None = None, key: str
801
+ ) -> Reference:
802
+ """Parse the shared short/canonical logical-reference grammar."""
803
+
804
+ parts = target.split("/")
805
+ item_type: str | None = None
806
+ item_name: str | None = None
807
+ is_files = False
808
+ object_text: str
809
+ if len(parts) == 1:
810
+ object_text = parts[0]
811
+ elif len(parts) == 2 and parts[0] == "Files":
812
+ is_files = True
813
+ object_text = parts[1]
814
+ elif len(parts) == 3:
815
+ item_type, item_name, object_text = parts
816
+ elif len(parts) == 4 and parts[2] == "Files":
817
+ item_type, item_name, _, object_text = parts
818
+ is_files = True
819
+ else:
820
+ raise MetadataError(
821
+ f"{key} reference must be Schema.Object, Files/Schema.Object or an "
822
+ f"item-qualified logical identity, got {target!r}"
823
+ )
824
+ if object_text.count(".") != 1:
825
+ raise MetadataError(f"{key} reference must end in Schema.Object, got {target!r}")
826
+ schema, object_name = object_text.split(".")
827
+ names = (schema, object_name, item_name) if item_name is not None else (schema, object_name)
828
+ if any(not name or name != name.strip() for name in names):
829
+ raise MetadataError(f"{key} reference contains an empty or padded logical name")
830
+ return Reference(
831
+ schema=schema,
832
+ object=object_name,
833
+ column=column,
834
+ item_type=item_type,
835
+ item_name=item_name,
836
+ is_files=is_files,
837
+ )
838
+
839
+
840
+ def _parse_dependencies(value: Any, object_id: ObjectId) -> tuple[ObjectId, ...]:
841
+ """Objects this one depends on, declared rather than discovered.
842
+
843
+ Additive: whatever discovery finds is added to these, never replaced by
844
+ them. A missing dependency is a wrong build order, which is silent data
845
+ corruption, so the declared set can only ever widen the graph.
846
+ """
847
+
848
+ if value is None:
849
+ return ()
850
+ if not isinstance(value, list):
851
+ raise MetadataError(
852
+ "Dependencies must be a YAML list of Schema.Object names:\n"
853
+ "Dependencies:\n - Sales.Customer"
854
+ )
855
+ if not value:
856
+ # `Dependencies: []` is a positive declaration of none, which is what the
857
+ # requirement below wants from a Spark SQL author: be explicit. A query
858
+ # built entirely from literals genuinely depends on nothing.
859
+ return ()
860
+ seen: list[ObjectId] = []
861
+ for entry in value:
862
+ if not isinstance(entry, str) or not entry.strip():
863
+ raise MetadataError("Dependencies entries must be non-empty Schema.Object names")
864
+ parts = [part.strip() for part in entry.strip().split(".")]
865
+ if len(parts) != 2 or not all(parts):
866
+ raise MetadataError(
867
+ f"a Dependencies entry must be a two-part Schema.Object name, got {entry!r}"
868
+ )
869
+ dependency = ObjectId(schema=parts[0], object=parts[1])
870
+ if dependency == object_id:
871
+ raise MetadataError(f"{object_id.qualified} cannot depend on itself")
872
+ if dependency in seen:
873
+ raise MetadataError(f"Dependencies repeats {dependency.qualified}")
874
+ seen.append(dependency)
875
+ return tuple(seen)
876
+
877
+
878
+ def _parse_aliases(
879
+ raw: dict[str, Any], language: str, kind: str, object_id: ObjectId
880
+ ) -> tuple[ObjectId | None, ObjectId | None]:
881
+ """The cross-engine aliases this object publishes, checked for eligibility.
882
+
883
+ A Lakehouse object (a Delta table or Spark view) may publish a
884
+ ``Warehouse alias``; a Warehouse object (a SQL table or view) may publish a
885
+ ``Lakehouse alias``. Neither belongs on a Folder, and neither belongs on the
886
+ opposite engine. The alias may name a different Schema.Object from the
887
+ native one — a Staging table can surface as Sales.Customer — so it is parsed
888
+ through the same two-part model rather than assumed equal.
889
+ """
890
+
891
+ target = target_kind_for(language, kind)
892
+ warehouse_alias = _parse_alias(raw.get(WAREHOUSE_ALIAS), WAREHOUSE_ALIAS)
893
+ lakehouse_alias = _parse_alias(raw.get(LAKEHOUSE_ALIAS), LAKEHOUSE_ALIAS)
894
+
895
+ if warehouse_alias is not None and target != DELTA_TARGET:
896
+ raise MetadataError(
897
+ f"{WAREHOUSE_ALIAS} publishes a Lakehouse object into the Warehouse, so it "
898
+ f"belongs on a Delta table or Spark view, not on {object_id.qualified} "
899
+ + (
900
+ "(a Warehouse object uses Lakehouse alias)"
901
+ if target == SQL_TARGET
902
+ else "(a Folder is not published across engines)"
903
+ )
904
+ )
905
+ if lakehouse_alias is not None and target != SQL_TARGET:
906
+ raise MetadataError(
907
+ f"{LAKEHOUSE_ALIAS} publishes a Warehouse object into the Lakehouse, so it "
908
+ f"belongs on a SQL table or view, not on {object_id.qualified} "
909
+ + (
910
+ "(a Lakehouse object uses Warehouse alias)"
911
+ if target == DELTA_TARGET
912
+ else "(a Folder is not published across engines)"
913
+ )
914
+ )
915
+ return warehouse_alias, lakehouse_alias
916
+
917
+
918
+ def _parse_alias(value: Any, key: str) -> ObjectId | None:
919
+ if value is None:
920
+ return None
921
+ if not isinstance(value, str) or not value.strip():
922
+ raise MetadataError(f"{key} must be a non-empty Schema.Object name")
923
+ parts = [part.strip() for part in value.strip().split(".")]
924
+ if len(parts) != 2 or not all(parts):
925
+ raise MetadataError(
926
+ f"{key} must be a two-part Schema.Object name, got {value!r}"
927
+ )
928
+ return ObjectId(schema=parts[0], object=parts[1])
929
+
930
+
931
+ def _parse_notes(value: Any) -> str | None:
932
+ """Free-range commentary. Deliberately unpoliced.
933
+
934
+ No reference parsing and no placeholder check: this is where an author
935
+ writes whatever helps, including a dollar sign.
936
+ """
937
+
938
+ if value is None:
939
+ return None
940
+ if not isinstance(value, str) or not value.strip():
941
+ raise MetadataError("Notes must be non-empty text when present")
942
+ return value.strip()
943
+
944
+
945
+ def _parse_revision_notes(value: Any) -> tuple[tuple[Revision, ...], str | None]:
946
+ if value is None:
947
+ return (), None
948
+ if not isinstance(value, list) or not value:
949
+ raise MetadataError(
950
+ "Revision notes must be a non-empty YAML list, each entry opening with a date:\n"
951
+ "Revision notes:\n - 2026-07-23 Added the amount column."
952
+ )
953
+
954
+ revisions: list[Revision] = []
955
+ shape: str | None = None
956
+ for entry in value:
957
+ if isinstance(entry, (date, datetime)):
958
+ # YAML resolves a bare `- 2026-07-23` to a date rather than text.
959
+ raise MetadataError(
960
+ f"Revision notes entry {entry} has a date but no note"
961
+ )
962
+ if not isinstance(entry, str) or not entry.strip():
963
+ raise MetadataError("Revision notes entries must be non-empty text")
964
+ text = entry.strip()
965
+ matched = _match_revision_date(text)
966
+ if matched is None:
967
+ raise MetadataError(
968
+ f"a Revision notes entry must open with a date, got {text!r}. "
969
+ "Any consistent spelling is accepted, such as 2026-07-23 or 23/07/2026."
970
+ )
971
+ entry_shape, date_text = matched
972
+ if shape is None:
973
+ shape = entry_shape
974
+ elif entry_shape != shape:
975
+ raise MetadataError(
976
+ f"Revision notes mix date formats — {shape} was used first, "
977
+ f"then {entry_shape} in {text!r}. Use one spelling throughout an object."
978
+ )
979
+ note = text[len(date_text):].strip()
980
+ if not note:
981
+ raise MetadataError(f"Revision notes entry {text!r} has a date but no note")
982
+ revisions.append(Revision(date=date_text, note=note))
983
+ return tuple(revisions), shape
984
+
985
+
986
+ def _match_revision_date(text: str) -> tuple[str, str] | None:
987
+ for shape, pattern, year_first in _REVISION_DATE_SHAPES:
988
+ match = pattern.match(text)
989
+ if match is None:
990
+ continue
991
+ first, second, third = (int(part) for part in match.groups())
992
+ if year_first:
993
+ month, day = second, third
994
+ plausible = 1 <= month <= 12 and 1 <= day <= 31
995
+ else:
996
+ # Day-first and month-first are indistinguishable, so accept either
997
+ # reading rather than pretend to know which was meant.
998
+ plausible = (
999
+ 1 <= first <= 31 and 1 <= second <= 31 and (first <= 12 or second <= 12)
1000
+ )
1001
+ if not plausible:
1002
+ raise MetadataError(f"Revision notes entry does not open with a real date: {text!r}")
1003
+ return shape, match.group(0)
1004
+ return None
1005
+
1006
+
1007
+ def _parse_bool(value: Any, key: str) -> bool:
1008
+ if value is None:
1009
+ return False
1010
+ if isinstance(value, bool):
1011
+ return value
1012
+ raise MetadataError(f"{key} must be a boolean (true/false)")
1013
+
1014
+
1015
+ def _parse_percentage(raw: dict[str, Any], key: str, default: int) -> int:
1016
+ """A whole percentage between 0 and 100.
1017
+
1018
+ 100 is permitted and means "never trip", which is a clearer way to disable
1019
+ one threshold than a separate flag would be.
1020
+ """
1021
+
1022
+ if key not in raw or raw[key] is None:
1023
+ return default
1024
+ value = raw[key]
1025
+ if isinstance(value, bool) or not isinstance(value, int):
1026
+ raise MetadataError(f"{key} must be a whole percentage, got {value!r}")
1027
+ if not 0 <= value <= 100:
1028
+ raise MetadataError(f"{key} must be between 0 and 100, got {value}")
1029
+ return value
1030
+
1031
+
1032
+ def _parse_row_count(raw: dict[str, Any], key: str, default: int) -> int:
1033
+ if key not in raw or raw[key] is None:
1034
+ return default
1035
+ value = raw[key]
1036
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
1037
+ raise MetadataError(f"{key} must be a whole number of rows, got {value!r}")
1038
+ return value
1039
+
1040
+
1041
+ def _parse_flag_with_default(raw: dict[str, Any], key: str, *, default: bool) -> bool:
1042
+ if key not in raw:
1043
+ return default
1044
+ return _parse_bool(raw[key], key)
1045
+
1046
+
1047
+ def _parse_column_set(value: Any, key: str) -> tuple[str, ...]:
1048
+ """A column *set* is comma-separated: one key, one comparison tuple."""
1049
+
1050
+ if value is None:
1051
+ return ()
1052
+ if isinstance(value, list):
1053
+ raise MetadataError(
1054
+ f"{key} is a column set and must be comma-separated text, not a YAML list"
1055
+ )
1056
+ if isinstance(value, bool) or not isinstance(value, (str, int)):
1057
+ raise MetadataError(f"{key} must be comma-separated text")
1058
+ columns = tuple(part.strip() for part in str(value).split(","))
1059
+ if any(not column for column in columns):
1060
+ raise MetadataError(f"{key} must not contain empty column names")
1061
+ if len(set(columns)) != len(columns):
1062
+ raise MetadataError(f"{key} must not repeat columns")
1063
+ return columns
1064
+
1065
+
1066
+ def _parse_unique_keys(
1067
+ value: Any, primary_key: tuple[str, ...]
1068
+ ) -> tuple[tuple[str, ...], ...]:
1069
+ """Alternate keys — a YAML list, one comma-separated column *set* per entry.
1070
+
1071
+ The two levels are deliberate and match ``Primary key``: independent things
1072
+ are a list, and one key's columns are a comma-separated set whose order is
1073
+ preserved. A key has no name, because nothing physical is created from it::
1074
+
1075
+ Unique keys:
1076
+ - Order number
1077
+ - Customer id, Order date
1078
+ """
1079
+
1080
+ if value is None:
1081
+ return ()
1082
+ if not isinstance(value, list) or not value:
1083
+ raise MetadataError(
1084
+ "Unique keys must be a non-empty YAML list, one comma-separated column "
1085
+ "set per key:\nUnique keys:\n - Order number\n - Customer id, Order date"
1086
+ )
1087
+
1088
+ keys: list[tuple[str, ...]] = []
1089
+ for entry in value:
1090
+ if isinstance(entry, list):
1091
+ raise MetadataError(
1092
+ "each Unique keys entry is one key and must be comma-separated text, "
1093
+ "not a nested YAML list"
1094
+ )
1095
+ columns = _parse_column_set(entry, "Unique keys")
1096
+ if not columns:
1097
+ raise MetadataError("Unique keys entries must name at least one column")
1098
+ if columns in keys:
1099
+ raise MetadataError(
1100
+ "Unique keys repeats the key " + ", ".join(columns)
1101
+ )
1102
+ if primary_key and columns == primary_key:
1103
+ raise MetadataError(
1104
+ "a Unique keys entry repeats the Primary key (" + ", ".join(columns)
1105
+ + ") — the primary key is already unique, so remove it from Unique keys"
1106
+ )
1107
+ keys.append(columns)
1108
+ return tuple(keys)
1109
+
1110
+
1111
+ #: A foreign key's parent: ``Schema.Object[Column, Column]``. Brackets are
1112
+ #: required — the parent columns are what make the relationship readable, and a
1113
+ #: bare parent name would leave them to be guessed.
1114
+ _FOREIGN_KEY_PARENT = re.compile(r"^([^\[\]]+)\[([^\[\]]+)\]$")
1115
+
1116
+
1117
+ def _parse_foreign_keys(value: Any, object_id: ObjectId) -> tuple[ForeignKey, ...]:
1118
+ """Declared relationships to parent objects, as an ER model rather than DDL.
1119
+
1120
+ Each entry is a one-entry mapping from this object's column set to the
1121
+ parent's::
1122
+
1123
+ Foreign keys:
1124
+ - Customer id: Sales.Customer[Customer id]
1125
+ - Region, Country: Sales.Territory[Region, Country]
1126
+ - Parent order id: Sales.Order[Order id]
1127
+
1128
+ Several entries may name the same parent, and the parent may be this object
1129
+ itself — a hierarchy in one table is an ordinary shape.
1130
+ """
1131
+
1132
+ if value is None:
1133
+ return ()
1134
+ if not isinstance(value, list) or not value:
1135
+ raise MetadataError(
1136
+ "Foreign keys must be a non-empty YAML list, one relationship per entry:\n"
1137
+ "Foreign keys:\n - Customer id: Sales.Customer[Customer id]"
1138
+ )
1139
+
1140
+ keys: list[ForeignKey] = []
1141
+ for entry in value:
1142
+ if not isinstance(entry, dict) or len(entry) != 1:
1143
+ raise MetadataError(
1144
+ "each Foreign keys entry maps one column set to one parent:\n"
1145
+ " - Customer id: Sales.Customer[Customer id]"
1146
+ )
1147
+ raw_columns, raw_parent = next(iter(entry.items()))
1148
+ columns = _parse_column_set(raw_columns, "Foreign keys")
1149
+ if not columns:
1150
+ raise MetadataError("a Foreign keys entry must name at least one column")
1151
+ if not isinstance(raw_parent, str) or not raw_parent.strip():
1152
+ raise MetadataError(
1153
+ f"the Foreign keys entry for {', '.join(columns)} must name a parent "
1154
+ "as Schema.Object[Column, Column]"
1155
+ )
1156
+ match = _FOREIGN_KEY_PARENT.match(raw_parent.strip())
1157
+ if match is None:
1158
+ raise MetadataError(
1159
+ f"the Foreign keys parent for {', '.join(columns)} must be "
1160
+ f"Schema.Object[Column, Column], got {raw_parent.strip()!r}"
1161
+ )
1162
+ raw_target, raw_parent_columns = match.groups()
1163
+ try:
1164
+ logical_reference = _parse_logical_reference(
1165
+ raw_target.strip(), key="Foreign keys"
1166
+ )
1167
+ except MetadataError as exc:
1168
+ raise MetadataError(
1169
+ f"the Foreign keys parent for {', '.join(columns)} must be "
1170
+ "Schema.Object[Column, Column] or an item-qualified logical "
1171
+ f"identity, got {raw_parent.strip()!r}"
1172
+ ) from exc
1173
+ parent_columns = _parse_column_set(raw_parent_columns, "Foreign keys parent")
1174
+ if len(parent_columns) != len(columns):
1175
+ raise MetadataError(
1176
+ f"the Foreign keys entry for {', '.join(columns)} references "
1177
+ f"{len(parent_columns)} parent column(s) — a relationship pairs its "
1178
+ "columns, so the two sets must be the same size"
1179
+ )
1180
+ key = ForeignKey(
1181
+ columns=columns,
1182
+ reference=logical_reference.object_id,
1183
+ reference_columns=parent_columns,
1184
+ logical_reference=(
1185
+ logical_reference
1186
+ if logical_reference.is_item_qualified or logical_reference.is_files
1187
+ else None
1188
+ ),
1189
+ )
1190
+ if not key.reference.schema or not key.reference.object:
1191
+ raise MetadataError(
1192
+ f"the Foreign keys parent for {', '.join(columns)} must be a two-part "
1193
+ f"Schema.Object name, got {raw_parent.strip()!r}"
1194
+ )
1195
+ if key in keys:
1196
+ raise MetadataError(f"Foreign keys repeats the relationship {key}")
1197
+ keys.append(key)
1198
+ return tuple(keys)
1199
+
1200
+
1201
+ def _parse_column_list(value: Any, key: str) -> tuple[str, ...]:
1202
+ """Independent columns are a YAML list."""
1203
+
1204
+ if value is None:
1205
+ return ()
1206
+ if not isinstance(value, list):
1207
+ raise MetadataError(
1208
+ f"{key} is a list of independent columns and must be a YAML list:\n"
1209
+ f"{key}:\n - Column one\n - Column two"
1210
+ )
1211
+ columns: list[str] = []
1212
+ for entry in value:
1213
+ if not isinstance(entry, str) or not entry.strip():
1214
+ raise MetadataError(f"{key} entries must be non-empty column names")
1215
+ columns.append(entry.strip())
1216
+ if len(set(columns)) != len(columns):
1217
+ raise MetadataError(f"{key} must not repeat columns")
1218
+ return tuple(columns)
1219
+
1220
+
1221
+ def _parse_file_keys(value: Any, *, kind: str) -> tuple[str, ...]:
1222
+ """The globs a Folder manages. Everything else in the folder is not ours."""
1223
+
1224
+ if kind != FOLDER:
1225
+ return ()
1226
+ if value is None:
1227
+ raise MetadataError(
1228
+ "a Folder must declare File key — it is the scope of what Weaver manages, "
1229
+ "and reconciliation deletes nothing outside it"
1230
+ )
1231
+
1232
+ values = [value] if isinstance(value, str) else value
1233
+ if not isinstance(values, list) or not values:
1234
+ raise MetadataError("File key must be a non-empty string or list of strings")
1235
+
1236
+ patterns: list[str] = []
1237
+ for pattern in values:
1238
+ if not isinstance(pattern, str) or not pattern.strip():
1239
+ raise MetadataError("File key patterns must be non-empty strings")
1240
+ normalised = pattern.strip().replace("\\", "/")
1241
+ if normalised.startswith("/") or ".." in normalised.split("/"):
1242
+ raise MetadataError(
1243
+ "File key patterns must be relative and must not traverse with '..'"
1244
+ )
1245
+ patterns.append(normalised)
1246
+ return tuple(patterns)
1247
+
1248
+
1249
+ def _parse_identity(value: Any) -> str | None:
1250
+ if value is None:
1251
+ return None
1252
+ if isinstance(value, list):
1253
+ raise MetadataError("Identity must be a single column")
1254
+ if isinstance(value, bool) or not isinstance(value, (str, int)):
1255
+ raise MetadataError("Identity must be a single column name")
1256
+ name = str(value).strip()
1257
+ if not name:
1258
+ raise MetadataError("Identity must be a non-empty column name")
1259
+ if "," in name:
1260
+ raise MetadataError("Identity must be a single column, not a list")
1261
+ return name
1262
+
1263
+
1264
+ def _parse_schema(value: Any) -> tuple[Column, ...]:
1265
+ if value is None:
1266
+ return ()
1267
+ if not isinstance(value, dict) or not value:
1268
+ raise MetadataError("Schema must be a non-empty mapping of column to type")
1269
+ columns: list[Column] = []
1270
+ for name, column_type in value.items():
1271
+ if not isinstance(name, str) or not name.strip():
1272
+ raise MetadataError("Schema column names must be non-empty strings")
1273
+ if not isinstance(column_type, str) or not column_type.strip():
1274
+ raise MetadataError(
1275
+ f"Schema column {name!r} must declare a non-empty type"
1276
+ )
1277
+ columns.append(Column(name=name.strip(), type=column_type.strip()))
1278
+ return tuple(columns)
1279
+
1280
+
1281
+ def _parse_column_notes(value: Any) -> dict[str, MetadataText]:
1282
+ if value is None:
1283
+ return {}
1284
+ if not isinstance(value, dict) or not value:
1285
+ raise MetadataError("Column notes must be a non-empty mapping of column to description")
1286
+ notes: dict[str, MetadataText] = {}
1287
+ for name, note in value.items():
1288
+ if not isinstance(name, str) or not name.strip():
1289
+ raise MetadataError("Column notes column names must be non-empty strings")
1290
+ if not isinstance(note, str) or not note.strip():
1291
+ raise MetadataError(f"Column notes for {name!r} must be non-empty text")
1292
+ notes[name.strip()] = _parse_text_value(note, f"Column notes[{name.strip()}]")
1293
+ return notes
1294
+
1295
+
1296
+ def _validate_columns(
1297
+ *,
1298
+ kind: str,
1299
+ declared_columns: tuple[Column, ...],
1300
+ primary_key: tuple[str, ...],
1301
+ unique_keys: tuple[tuple[str, ...], ...],
1302
+ foreign_keys: tuple[ForeignKey, ...],
1303
+ declared_not_null: tuple[str, ...],
1304
+ identity: str | None,
1305
+ comparison: tuple[str, ...],
1306
+ notes: dict[str, MetadataText],
1307
+ ) -> None:
1308
+ """Cross-field column guards, where a declared schema makes them possible."""
1309
+
1310
+ redundant = [column for column in declared_not_null if column in primary_key]
1311
+ if redundant:
1312
+ raise MetadataError(
1313
+ "primary key columns are already not null, so remove them from Not null: "
1314
+ + ", ".join(redundant)
1315
+ )
1316
+
1317
+ overlapping = [column for column in comparison if column in primary_key]
1318
+ if overlapping:
1319
+ raise MetadataError(
1320
+ "Comparison columns must not include primary key columns — a matched row "
1321
+ "has equal keys by definition: " + ", ".join(overlapping)
1322
+ )
1323
+
1324
+ colliding = [
1325
+ column.name
1326
+ for column in declared_columns
1327
+ if column.name.lower() in _RESERVED_AUDIT_NAMES
1328
+ ]
1329
+ if colliding:
1330
+ raise MetadataError(
1331
+ "these column names are reserved for Weaver's audit columns: "
1332
+ + ", ".join(colliding)
1333
+ )
1334
+ # Identity is a Weaver-managed surrogate column, so it must not clash with the
1335
+ # audit columns it sits beside.
1336
+ if identity is not None and identity.lower() in _RESERVED_AUDIT_NAMES:
1337
+ raise MetadataError(
1338
+ f"Identity {identity} collides with a Weaver audit column name"
1339
+ )
1340
+ # The primary key must not *be* the identity column, and the reason is a load
1341
+ # one rather than a modelling preference. The engine assigns the identity on
1342
+ # insert, so a source never produces it; a load matching on it could never
1343
+ # find an existing row, and every run would insert duplicates. Caught here
1344
+ # because the alternative is an "Invalid column name" from the engine at
1345
+ # install, which says nothing about what the declaration got wrong.
1346
+ if identity is not None and identity in primary_key:
1347
+ raise MetadataError(
1348
+ f"Primary key names the Identity column {identity!r}. The engine "
1349
+ "assigns the identity on insert, so a load can never match on it — "
1350
+ "key on the business column that identifies a row across loads, and "
1351
+ "let the identity be the surrogate beside it."
1352
+ )
1353
+
1354
+ if not declared_columns:
1355
+ # A SQL object takes its shape from its query; checked at build instead.
1356
+ return
1357
+
1358
+ # The identity column is Weaver's, not the author's, so it must not be
1359
+ # declared in Schema — but the primary key may name it when the surrogate is
1360
+ # the key, so it counts as a known column for the reference checks.
1361
+ if identity is not None and identity in {column.name for column in declared_columns}:
1362
+ raise MetadataError(
1363
+ f"Identity {identity} names a declared column; the identity column is "
1364
+ "Weaver-managed and must not appear in Schema"
1365
+ )
1366
+ known = {column.name for column in declared_columns}
1367
+ if identity is not None:
1368
+ known = known | {identity}
1369
+ unique_columns = tuple(
1370
+ column for unique_key in unique_keys for column in unique_key
1371
+ )
1372
+ foreign_key_columns = tuple(
1373
+ column for foreign_key in foreign_keys for column in foreign_key.columns
1374
+ )
1375
+ for key, columns in (
1376
+ ("Primary key", primary_key),
1377
+ ("Unique keys", unique_columns),
1378
+ ("Foreign keys", foreign_key_columns),
1379
+ ("Not null", declared_not_null),
1380
+ ("Comparison columns", comparison),
1381
+ ("Column notes", tuple(notes)),
1382
+ ):
1383
+ missing = [column for column in columns if column not in known]
1384
+ if missing:
1385
+ raise MetadataError(
1386
+ f"{key} names column(s) that are not in Schema: " + ", ".join(missing)
1387
+ )
1388
+
1389
+
1390
+ def _apply_column_details(
1391
+ declared: tuple[Column, ...],
1392
+ notes: dict[str, MetadataText],
1393
+ primary_key: tuple[str, ...],
1394
+ declared_not_null: tuple[str, ...],
1395
+ ) -> tuple[Column, ...]:
1396
+ not_null = set(primary_key) | set(declared_not_null)
1397
+ return tuple(
1398
+ Column(
1399
+ name=column.name,
1400
+ type=column.type,
1401
+ note=notes.get(column.name),
1402
+ not_null=column.name in not_null,
1403
+ )
1404
+ for column in declared
1405
+ )