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,448 @@
1
+ """Pure logical repository, item and document identities.
2
+
3
+ These values describe authored Weaver structure. They deliberately know
4
+ nothing about Fabric item names, workspaces, stores or build execution: those are
5
+ physical bindings applied later.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ from dataclasses import dataclass, field
12
+ from typing import TYPE_CHECKING, Mapping
13
+
14
+ from ..errors import DiscoveryError, IdentityError
15
+ from ..locations import Location
16
+ from .metadata import ObjectId
17
+
18
+ if TYPE_CHECKING:
19
+ from .schemas import SchemaSes
20
+ from .source import SourceDocument
21
+
22
+ LAKEHOUSE = "Lakehouse"
23
+ WAREHOUSE = "Warehouse"
24
+ ITEM_TYPES = frozenset({LAKEHOUSE, WAREHOUSE})
25
+ FILES = "Files"
26
+
27
+ #: What an identity's two parts *mean*, which is what decides how they are
28
+ #: validated and spelled. Weaver has one identity — a schema and an object
29
+ #: within an item — and three kinds of target wear it differently:
30
+ #:
31
+ #: ``OBJECT`` ``Sales`` + ``Customer``: a table, view or folder.
32
+ #: ``FILE`` ``_/Load/lib`` + ``dates.py``: the containing path is the
33
+ #: schema and the complete leaf filename is the object.
34
+ #: ``PROCEDURE`` ``_`` + ``Load Sales.Customer``: an ordinary schema, and an
35
+ #: object name that carries the dot and space of the object it
36
+ #: loads.
37
+ #:
38
+ #: Validation branches on this rather than assuming table-style naming
39
+ #: everywhere, and the Registry stores the real logical target name rather than
40
+ #: something encoded to fit one validator.
41
+ OBJECT_SHAPE = "object"
42
+ FILE_SHAPE = "file"
43
+ PROCEDURE_SHAPE = "procedure"
44
+ SHAPES = (OBJECT_SHAPE, FILE_SHAPE, PROCEDURE_SHAPE)
45
+
46
+ #: How a non-object shape marks itself in the one-line spelling. A file's schema
47
+ #: is a path and its object carries an extension, so ``Schema.Object`` cannot
48
+ #: tell the two halves apart without being told which shape it is reading.
49
+ _SHAPE_MARKERS = {FILE_SHAPE: "file:", PROCEDURE_SHAPE: "procedure:"}
50
+
51
+
52
+ def _logical_name(value: object, *, what: str) -> str:
53
+ if not isinstance(value, str):
54
+ raise IdentityError(f"{what} must be a string, got {type(value).__name__}")
55
+ if not value or value != value.strip():
56
+ raise IdentityError(f"{what} must be a non-empty name without surrounding whitespace")
57
+ if any(character in value for character in ("/", "\\", ".", ":")):
58
+ raise IdentityError(f"{what} must be one logical name, got {value!r}")
59
+ return value
60
+
61
+
62
+ def _relative_path(value: object, *, what: str) -> str:
63
+ """One relative, canonical path — a file identity's schema half.
64
+
65
+ A file's schema is where it sits, so it may contain ``/``. Everything that
66
+ would make it ambiguous or let it escape its root may not: an absolute path,
67
+ a backslash, an empty component, or a ``.``/``..`` component. The result is
68
+ a path that joins onto a root exactly once and means the same thing however
69
+ it is read back.
70
+ """
71
+
72
+ if not isinstance(value, str):
73
+ raise IdentityError(f"{what} must be a string, got {type(value).__name__}")
74
+ if not value or value != value.strip():
75
+ raise IdentityError(f"{what} must be a non-empty path without surrounding whitespace")
76
+ if "\\" in value:
77
+ raise IdentityError(f"{what} must use '/' between components, got {value!r}")
78
+ components = value.split("/")
79
+ if any(not component for component in components):
80
+ raise IdentityError(f"{what} must be relative and canonical, got {value!r}")
81
+ if any(component in (".", "..") for component in components):
82
+ raise IdentityError(f"{what} must not contain '.' or '..', got {value!r}")
83
+ return value
84
+
85
+
86
+ def _file_name(value: object, *, what: str) -> str:
87
+ """One complete leaf filename, extension included — a file identity's object."""
88
+
89
+ if not isinstance(value, str):
90
+ raise IdentityError(f"{what} must be a string, got {type(value).__name__}")
91
+ if not value or value != value.strip():
92
+ raise IdentityError(f"{what} must be a non-empty name without surrounding whitespace")
93
+ if "/" in value or "\\" in value:
94
+ raise IdentityError(f"{what} must be one filename, got {value!r}")
95
+ if value in (".", ".."):
96
+ raise IdentityError(f"{what} must name a file, got {value!r}")
97
+ return value
98
+
99
+
100
+ def _procedure_name(value: object, *, what: str) -> str:
101
+ """One procedure name, which carries the identity of what it loads.
102
+
103
+ ``Load Sales.Customer`` is the real name of the real object, so the dot and
104
+ the space are part of it rather than something to encode away. It is still
105
+ one name in one schema, so a path separator is refused.
106
+ """
107
+
108
+ if not isinstance(value, str):
109
+ raise IdentityError(f"{what} must be a string, got {type(value).__name__}")
110
+ if not value or value != value.strip():
111
+ raise IdentityError(f"{what} must be a non-empty name without surrounding whitespace")
112
+ if any(character in value for character in ("/", "\\")):
113
+ raise IdentityError(f"{what} must be one object name, got {value!r}")
114
+ return value
115
+
116
+
117
+ def _item_type(value: object) -> str:
118
+ if not isinstance(value, str) or value not in ITEM_TYPES:
119
+ expected = ", ".join(sorted(ITEM_TYPES))
120
+ raise IdentityError(f"item type must be exactly one of {expected}, got {value!r}")
121
+ return value
122
+
123
+
124
+ def _split(text: object, *, what: str) -> tuple[str, ...]:
125
+ if not isinstance(text, str):
126
+ raise IdentityError(f"{what} must be a string, got {type(text).__name__}")
127
+ if not text or text != text.strip():
128
+ raise IdentityError(f"{what} must not be empty or padded with whitespace")
129
+ return tuple(text.split("/"))
130
+
131
+
132
+ def _object_id(text: str) -> ObjectId:
133
+ if text.count(".") != 1:
134
+ raise IdentityError(f"object identity must be Schema.Object, got {text!r}")
135
+ schema, object_name = text.split(".")
136
+ return ObjectId(
137
+ schema=_logical_name(schema, what="schema name"),
138
+ object=_logical_name(object_name, what="object name"),
139
+ )
140
+
141
+
142
+ @dataclass(frozen=True, order=True)
143
+ class WeaverItemId:
144
+ """An exact-case logical item identity: ``ItemType/ItemName``."""
145
+
146
+ item_type: str
147
+ item_name: str
148
+
149
+ def __post_init__(self) -> None:
150
+ object.__setattr__(self, "item_type", _item_type(self.item_type))
151
+ object.__setattr__(
152
+ self, "item_name", _logical_name(self.item_name, what="item name")
153
+ )
154
+
155
+ @classmethod
156
+ def parse(cls, text: str) -> "WeaverItemId":
157
+ parts = _split(text, what="item identity")
158
+ if len(parts) != 2:
159
+ raise IdentityError(
160
+ f"item identity must be ItemType/ItemName, got {text!r}"
161
+ )
162
+ return cls(parts[0], parts[1])
163
+
164
+ def __str__(self) -> str:
165
+ return f"{self.item_type}/{self.item_name}"
166
+
167
+
168
+ @dataclass(frozen=True, order=True)
169
+ class WeaverSchemaId:
170
+ """An item-owned schema identity."""
171
+
172
+ item: WeaverItemId
173
+ schema: str
174
+
175
+ def __post_init__(self) -> None:
176
+ object.__setattr__(self, "schema", _logical_name(self.schema, what="schema name"))
177
+
178
+ @classmethod
179
+ def parse(cls, text: str) -> "WeaverSchemaId":
180
+ parts = _split(text, what="schema identity")
181
+ if len(parts) != 3:
182
+ raise IdentityError(
183
+ f"schema identity must be ItemType/ItemName/Schema, got {text!r}"
184
+ )
185
+ return cls(WeaverItemId(parts[0], parts[1]), parts[2])
186
+
187
+ def __str__(self) -> str:
188
+ return f"{self.item}/{self.schema}"
189
+
190
+
191
+ @dataclass(frozen=True, order=True)
192
+ class WeaverDocumentId:
193
+ """An item-qualified target identity: one schema and one object, in one item.
194
+
195
+ One identity model for everything Weaver builds. A table, a deployed Python
196
+ module and a generated stored procedure are all *a schema and an object
197
+ inside an item* — what differs is only the shape of those two parts, which
198
+ :data:`SHAPES` names and which decides both how they are validated and how
199
+ they are spelled on one line. The Registry stores the two real parts, so
200
+ nothing is encoded to fit a validator and nothing has to be decoded to be
201
+ used.
202
+ """
203
+
204
+ item: WeaverItemId
205
+ object_id: ObjectId
206
+ is_files: bool = False
207
+ shape: str = OBJECT_SHAPE
208
+
209
+ def __post_init__(self) -> None:
210
+ if self.shape not in SHAPES:
211
+ expected = ", ".join(SHAPES)
212
+ raise IdentityError(
213
+ f"identity shape must be one of {expected}, got {self.shape!r}"
214
+ )
215
+ if self.shape == FILE_SHAPE:
216
+ schema = _relative_path(self.object_id.schema, what="file path")
217
+ name = _file_name(self.object_id.object, what="file name")
218
+ elif self.shape == PROCEDURE_SHAPE:
219
+ schema = _logical_name(self.object_id.schema, what="schema name")
220
+ name = _procedure_name(self.object_id.object, what="object name")
221
+ else:
222
+ schema = _logical_name(self.object_id.schema, what="schema name")
223
+ name = _logical_name(self.object_id.object, what="object name")
224
+ object.__setattr__(self, "object_id", ObjectId(schema=schema, object=name))
225
+ if self.is_files and self.shape != OBJECT_SHAPE:
226
+ raise IdentityError(
227
+ "the Files/ prefix belongs to a Folder document; a "
228
+ f"{self.shape} identity carries its own location"
229
+ )
230
+ if self.is_files and self.item.item_type != LAKEHOUSE:
231
+ raise IdentityError("Files documents may only belong to a Lakehouse item")
232
+ if self.shape == FILE_SHAPE and self.item.item_type != LAKEHOUSE:
233
+ raise IdentityError("a file identity may only belong to a Lakehouse item")
234
+ if self.shape == PROCEDURE_SHAPE and self.item.item_type != WAREHOUSE:
235
+ raise IdentityError(
236
+ "a stored procedure identity may only belong to a Warehouse item"
237
+ )
238
+
239
+ @classmethod
240
+ def parse(cls, text: str) -> "WeaverDocumentId":
241
+ parts = _split(text, what="document identity")
242
+ if len(parts) >= 4:
243
+ marker = _SHAPE_MARKERS[FILE_SHAPE]
244
+ if parts[2].startswith(marker):
245
+ # ``file:<path>/<name>`` — the last component is the filename and
246
+ # everything before it, marker stripped, is the containing path.
247
+ head = (parts[2][len(marker) :],) + parts[3:-1]
248
+ return cls(
249
+ WeaverItemId(parts[0], parts[1]),
250
+ ObjectId(schema="/".join(head), object=parts[-1]),
251
+ shape=FILE_SHAPE,
252
+ )
253
+ if len(parts) == 4:
254
+ marker = _SHAPE_MARKERS[PROCEDURE_SHAPE]
255
+ if parts[2].startswith(marker):
256
+ return cls(
257
+ WeaverItemId(parts[0], parts[1]),
258
+ ObjectId(schema=parts[2][len(marker) :], object=parts[3]),
259
+ shape=PROCEDURE_SHAPE,
260
+ )
261
+ if parts[2] == FILES:
262
+ return cls(
263
+ WeaverItemId(parts[0], parts[1]),
264
+ _object_id(parts[3]),
265
+ is_files=True,
266
+ )
267
+ if len(parts) == 3:
268
+ return cls(WeaverItemId(parts[0], parts[1]), _object_id(parts[2]))
269
+ raise IdentityError(
270
+ "document identity must be ItemType/ItemName/Schema.Object, "
271
+ "Lakehouse/ItemName/Files/Schema.Object, "
272
+ "Lakehouse/ItemName/file:Path/Name.ext or "
273
+ f"Warehouse/ItemName/procedure:Schema/Object, got {text!r}"
274
+ )
275
+
276
+ @classmethod
277
+ def parse_local(cls, item: "WeaverItemId", text: str) -> "WeaverDocumentId":
278
+ """Parse the item-relative spelling — the inverse of :attr:`relative`.
279
+
280
+ Used where the item is already known from context, such as an item's own
281
+ ``alias.yml``, so the declaration does not repeat it.
282
+ """
283
+
284
+ parts = _split(text, what="document identity")
285
+ if len(parts) == 1:
286
+ return cls(item, _object_id(parts[0]))
287
+ if len(parts) == 2 and parts[0] == FILES:
288
+ return cls(item, _object_id(parts[1]), is_files=True)
289
+ raise IdentityError(
290
+ "an item-relative document identity must be Schema.Object or "
291
+ f"Files/Schema.Object, got {text!r}"
292
+ )
293
+
294
+ @property
295
+ def relative(self) -> str:
296
+ if self.shape == FILE_SHAPE:
297
+ marker = _SHAPE_MARKERS[FILE_SHAPE]
298
+ return f"{marker}{self.object_id.schema}/{self.object_id.object}"
299
+ if self.shape == PROCEDURE_SHAPE:
300
+ marker = _SHAPE_MARKERS[PROCEDURE_SHAPE]
301
+ return f"{marker}{self.object_id.schema}/{self.object_id.object}"
302
+ prefix = f"{FILES}/" if self.is_files else ""
303
+ return f"{prefix}{self.object_id.qualified}"
304
+
305
+ @property
306
+ def is_load_artefact(self) -> bool:
307
+ """Whether this identity names something a load layer produces.
308
+
309
+ The two load shapes against the one structural shape. Asked wherever a
310
+ selection has to be partitioned, so that the question is answered from
311
+ the identity rather than by each caller keeping its own set.
312
+ """
313
+
314
+ return self.shape in (FILE_SHAPE, PROCEDURE_SHAPE)
315
+
316
+ def __str__(self) -> str:
317
+ return f"{self.item}/{self.relative}"
318
+
319
+
320
+ @dataclass(frozen=True, order=True)
321
+ class RepositoryAlias:
322
+ """One destination-keyed logical alias from ``alias.yml``."""
323
+
324
+ destination: WeaverDocumentId
325
+ source: WeaverDocumentId
326
+
327
+ @property
328
+ def signature(self) -> str:
329
+ """What this alias *is*, hashed — and nothing about what it points to.
330
+
331
+ An alias declares one thing: this destination stands for that source. So
332
+ its signature is the pair, and only the pair. The source document's own
333
+ content is deliberately absent: a rebuilt source does not redefine the
334
+ alias, and treating it as a change would replace every downstream
335
+ shortcut whenever a table was reloaded.
336
+
337
+ A source that was rebuilt is still a reason to remake the alias — but
338
+ that is freshness, answered by comparing build epochs in the Registry,
339
+ not by this signature. Keeping the two apart is what lets an unchanged
340
+ alias over an unchanged source be left alone.
341
+ """
342
+
343
+ declaration = f"{self.destination}\0{self.source}".encode("utf-8")
344
+ return hashlib.sha256(declaration).hexdigest()
345
+
346
+ def __str__(self) -> str:
347
+ return f"{self.destination}: {self.source}"
348
+
349
+
350
+ @dataclass(frozen=True, order=True)
351
+ class ItemDependency:
352
+ """One consumer-owned dependency declaration and its logical resolution."""
353
+
354
+ consumer: WeaverDocumentId
355
+ reference: str
356
+ producer: WeaverDocumentId | None = None
357
+ resolution_kind: str = "native" # native | alias | physical
358
+ is_within_item: bool = False
359
+
360
+ @property
361
+ def uses_alias(self) -> bool:
362
+ return self.resolution_kind == "alias"
363
+
364
+ @property
365
+ def is_physical(self) -> bool:
366
+ return self.resolution_kind == "physical"
367
+
368
+
369
+ def _reject_duplicates(values: tuple[object, ...], *, what: str) -> None:
370
+ exact: set[str] = set()
371
+ folded: dict[str, str] = {}
372
+ for value in values:
373
+ rendered = str(value)
374
+ if rendered in exact:
375
+ raise DiscoveryError(f"{what} is declared more than once: {rendered}")
376
+ prior = folded.get(rendered.casefold())
377
+ if prior is not None and prior != rendered:
378
+ raise DiscoveryError(
379
+ f"{rendered} and {prior} differ only by case and cannot coexist"
380
+ )
381
+ exact.add(rendered)
382
+ folded[rendered.casefold()] = rendered
383
+
384
+
385
+ @dataclass(frozen=True)
386
+ class WeaverItem:
387
+ """The pure identity-level contents owned by one logical item."""
388
+
389
+ identity: WeaverItemId
390
+ schemas: tuple[WeaverSchemaId, ...] = ()
391
+ documents: tuple[WeaverDocumentId, ...] = ()
392
+ signature: str = ""
393
+
394
+ def __post_init__(self) -> None:
395
+ if any(schema.item != self.identity for schema in self.schemas):
396
+ raise DiscoveryError(f"every schema must belong to item {self.identity}")
397
+ if any(document.item != self.identity for document in self.documents):
398
+ raise DiscoveryError(f"every document must belong to item {self.identity}")
399
+ _reject_duplicates(self.schemas, what="schema")
400
+ _reject_duplicates(self.documents, what="document")
401
+
402
+ def __getitem__(self, relative: str) -> WeaverDocumentId:
403
+ for document in self.documents:
404
+ if document.relative == relative:
405
+ return document
406
+ raise DiscoveryError(f"{relative!r} is not a document in item {self.identity}")
407
+
408
+
409
+ @dataclass(frozen=True)
410
+ class WeaverRepository:
411
+ """One exact-case logical repository containing typed Weaver items."""
412
+
413
+ name: str
414
+ items: tuple[WeaverItem, ...]
415
+ root: Location | None = None
416
+ source_documents: Mapping[WeaverDocumentId, "SourceDocument"] = field(
417
+ default_factory=dict
418
+ )
419
+ schema_documents: Mapping[WeaverSchemaId, "SchemaSes"] = field(default_factory=dict)
420
+ support_files: tuple[str, ...] = ()
421
+ #: The bytes of the support files a build has to *carry*, by the same
422
+ #: repository-relative path. A ``lib/`` module is authored source that no
423
+ #: Weaver document declares, and the load layer deploys it — so its content
424
+ #: has to reach signature derivation and the bundle without either of them
425
+ #: reopening the repository. Files nothing deploys, such as ``alias.yml``,
426
+ #: are listed in :attr:`support_files` and not held here.
427
+ support_file_contents: Mapping[str, bytes] = field(default_factory=dict)
428
+ signature: str = ""
429
+ aliases: tuple[RepositoryAlias, ...] = ()
430
+ dependency_edges: tuple[ItemDependency, ...] = ()
431
+ dependency_graph: object | None = None
432
+ #: The item-level graph over :attr:`items`, and its topological layers.
433
+ #: The document graph orders work *inside* an item; this orders the items
434
+ #: themselves, and is the outer structure a build is planned against. It is
435
+ #: derived once, here, so no later stage reconstructs an ordering of its own.
436
+ item_graph: object | None = None
437
+ item_layers: tuple[tuple[WeaverItemId, ...], ...] = ()
438
+ generated_files: Mapping[str, bytes] = field(default_factory=dict)
439
+
440
+ def __post_init__(self) -> None:
441
+ object.__setattr__(self, "name", _logical_name(self.name, what="repository name"))
442
+ _reject_duplicates(tuple(item.identity for item in self.items), what="item")
443
+
444
+ def __getitem__(self, identity: str) -> WeaverItem:
445
+ for item in self.items:
446
+ if str(item.identity) == identity:
447
+ return item
448
+ raise DiscoveryError(f"{identity!r} is not an item in repository {self.name!r}")