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,369 @@
1
+ """Internal mechanics for clearing a physical target.
2
+
3
+ Wipe is per target, because the three are different places with different
4
+ mechanics. It is also the bluntest thing Weaver does, so it reports what it
5
+ would remove before it removes anything.
6
+
7
+ **Delta.** Weaver addresses Delta tables by explicit path and never registers
8
+ them in a metastore, so there is no catalogue to consult and none to leave
9
+ dangling — a table is a directory, and wiping is removing it. That is the same
10
+ property that lets a Fabric notebook write to a Lakehouse it is not attached
11
+ to, showing up again as a simplification. On Fabric the Lakehouse auto-discovers
12
+ what appears under ``Tables/``, so removing the directory is expected to
13
+ de-register it; that is worth confirming against a real workspace before relying
14
+ on it.
15
+
16
+ **Shortcuts, first.** A shortcut is the one thing in a Lakehouse that is not the
17
+ Lakehouse's own data: it is a name this item holds for data another item owns. So
18
+ it cannot be removed by deleting a directory, and *must not be* — a recursive
19
+ delete of a directory holding one would be reaching through the pointer at the
20
+ producer's data, which is the one outcome a wipe of *this* Lakehouse must never
21
+ have. Shortcuts are therefore removed through the workspace, and removed before
22
+ any storage is swept, so the sweep can never meet one.
23
+
24
+ The local emulator materialises an alias as a symbolic link, and
25
+ :meth:`weaver.store.LocalStore.delete` unlinks a link rather than following it —
26
+ so the emulator reaches the same guarantee by a different mechanism, and neither
27
+ needs the other's.
28
+
29
+ **Folders.** The Files area is kept and its contents removed, so the target
30
+ survives and only what it held goes. Shortcuts under ``Files/`` go first, for the
31
+ same reason.
32
+
33
+ **Warehouse.** One dynamic statement enumerates and removes user objects in
34
+ dependency-safe order while preserving the Warehouse item and system schemas.
35
+
36
+ Nothing here is scoped to Weaver-managed objects: a wipe clears the target. That
37
+ suits a development loop, and makes the function something a CLI must gate
38
+ rather than something safe by default.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ from dataclasses import dataclass
44
+ from typing import Iterable
45
+
46
+ from .errors import CommandError
47
+ from .workspaces import Workspace, LocalWorkspace
48
+ from .locations import Location
49
+ from .resolution import TABLES_AREA, LocalResolver, resolver_for, store_for
50
+ from .store import LocalStore, Store
51
+ from .targets import FILES_AREA, DeltaTarget, FolderTarget, ItemRef, WarehouseTarget
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class WipeReport:
56
+ """What a wipe removed, or would remove."""
57
+
58
+ target: str
59
+ location: Location
60
+ removed: tuple[str, ...]
61
+ dry_run: bool = False
62
+
63
+ @property
64
+ def count(self) -> int:
65
+ return len(self.removed)
66
+
67
+ def __str__(self) -> str:
68
+ verb = "would remove" if self.dry_run else "removed"
69
+ return f"{self.target}: {verb} {self.count} from {self.location}"
70
+
71
+
72
+ #: Schemas a wipe empties but does not remove. A schema-enabled Fabric Lakehouse
73
+ #: is created holding ``dbo``; Fabric owns it, Weaver never manages it, and
74
+ #: nothing recreates it once its directory is gone — the Lakehouse is simply left
75
+ #: unable to resolve a schema it is supposed to have. Deleting it is therefore
76
+ #: not "clearing the target" but damaging it, which is a different act and not
77
+ #: one a wipe is asking for.
78
+ #:
79
+ #: This is the same judgement :data:`weaver.build_bundle.prune._RESERVED_SCHEMAS`
80
+ #: already makes for prune. The two agreeing is the point: an operator should not
81
+ #: have to know which of Weaver's destructive paths respects the default schema.
82
+ _KEPT_SCHEMAS = ("dbo",)
83
+
84
+
85
+ def _guard(location: Location, root: Location) -> None:
86
+ """Never remove anything outside the workspace root.
87
+
88
+ Locations are derived rather than supplied, so this should be unreachable —
89
+ which is exactly why it is worth having, since the failure it prevents is
90
+ unrecoverable.
91
+ """
92
+
93
+ inside = location.value == root.value or location.value.startswith(
94
+ root.value.rstrip("/") + "/"
95
+ )
96
+ if not inside:
97
+ raise CommandError(
98
+ f"refusing to wipe {location.value!r}: outside the workspace root {root.value!r}"
99
+ )
100
+
101
+
102
+ def _clear(
103
+ store: Store, location: Location, root: Location, *, dry_run: bool, keep=()
104
+ ) -> tuple[str, ...]:
105
+ """Remove the contents of a location, keeping the location itself.
106
+
107
+ ``keep`` names entries the wipe passes over. It is not a scoping of what a
108
+ wipe is *for* — a wipe still clears the target — but a recognition that not
109
+ everything under an area belongs to the target. See :data:`_KEPT_SCHEMAS`.
110
+ """
111
+
112
+ _guard(location, root)
113
+ if not store.exists(location):
114
+ return ()
115
+ kept = {name.casefold() for name in keep}
116
+ entries = [
117
+ entry for entry in store.list(location) if entry.location.name.casefold() not in kept
118
+ ]
119
+ removed = tuple(sorted(entry.location.name for entry in entries))
120
+ if not dry_run:
121
+ for entry in entries:
122
+ _guard(entry.location, root)
123
+ store.delete(entry.location, recursive=entry.is_directory)
124
+ return removed
125
+
126
+
127
+ def _remove_shortcuts(
128
+ resolver, lakehouse: ItemRef, *, prefix: str, dry_run: bool
129
+ ) -> tuple[str, ...]:
130
+ """Take away this Lakehouse's shortcuts beneath ``prefix``, before storage is swept.
131
+
132
+ Scoped by path prefix, so a wipe of one area leaves the other's pointers
133
+ alone — a Files wipe must not take away a shortcut under ``Tables/``.
134
+
135
+ Reported as ``shortcut:<path>/<name>`` so a dry run distinguishes a pointer
136
+ being taken away from a directory being deleted — they are not the same act,
137
+ and only one of them destroys data.
138
+
139
+ An environment with no shortcuts to remove offers no capability and this
140
+ answers nothing: the emulator's links are removed by the storage sweep, which
141
+ unlinks rather than follows.
142
+ """
143
+
144
+ enumerate_shortcuts = getattr(resolver, "onelake_shortcuts", None)
145
+ remove = getattr(resolver, "remove_onelake_shortcut", None)
146
+ if enumerate_shortcuts is None or remove is None:
147
+ return ()
148
+
149
+ within = prefix.strip("/").casefold()
150
+ shortcuts = tuple(
151
+ shortcut
152
+ for shortcut in enumerate_shortcuts(lakehouse)
153
+ if shortcut.path.casefold() == within
154
+ or shortcut.path.casefold().startswith(within + "/")
155
+ )
156
+ if not dry_run:
157
+ for shortcut in shortcuts:
158
+ remove(lakehouse, path=shortcut.path, name=shortcut.name)
159
+ return tuple(f"shortcut:{shortcut.qualified}" for shortcut in shortcuts)
160
+
161
+
162
+ def wipe_folder_target(
163
+ target: FolderTarget,
164
+ workspace: Workspace,
165
+ *,
166
+ store: Store | None = None,
167
+ dry_run: bool = False,
168
+ ) -> WipeReport:
169
+ """Empty a folder target, keeping the Files area itself."""
170
+
171
+ store = store or store_for(workspace)
172
+ resolver = resolver_for(workspace)
173
+ location = resolver.folder_root(target)
174
+ shortcuts = _remove_shortcuts(
175
+ resolver, target.lakehouse, prefix=FILES_AREA, dry_run=dry_run
176
+ )
177
+ return WipeReport(
178
+ target=f"folder:{target}",
179
+ location=location,
180
+ removed=shortcuts + _clear(store, location, resolver.root, dry_run=dry_run),
181
+ dry_run=dry_run,
182
+ )
183
+
184
+
185
+ def wipe_delta_target(
186
+ target: DeltaTarget,
187
+ workspace: Workspace,
188
+ *,
189
+ store: Store | None = None,
190
+ dry_run: bool = False,
191
+ ) -> WipeReport:
192
+ """Remove every Delta table in a Lakehouse, keeping the Tables area.
193
+
194
+ A table is a directory. There is no catalogue to enumerate from and none to
195
+ leave behind, because Weaver never registered one — with one exception, and it
196
+ is the reason shortcuts go first: a table shortcut is a directory whose bytes
197
+ belong to another item.
198
+ """
199
+
200
+ store = store or store_for(workspace)
201
+ resolver = resolver_for(workspace)
202
+ location = resolver.tables_root(target.lakehouse)
203
+ shortcuts = _remove_shortcuts(
204
+ resolver, target.lakehouse, prefix=TABLES_AREA, dry_run=dry_run
205
+ )
206
+ return WipeReport(
207
+ target=f"delta:{target}",
208
+ location=location,
209
+ removed=shortcuts
210
+ + _clear(
211
+ store, location, resolver.root, dry_run=dry_run, keep=_KEPT_SCHEMAS
212
+ ),
213
+ dry_run=dry_run,
214
+ )
215
+
216
+
217
+ def wipe_sql_target(
218
+ target: WarehouseTarget,
219
+ workspace: Workspace,
220
+ *,
221
+ sql=None,
222
+ ) -> None:
223
+ """Clear a Warehouse through the common SQL capability.
224
+
225
+ The default is deliberately Fabric-native. A desktop caller crossing into
226
+ Fabric constructs and injects ``desktop_sql_executor`` explicitly.
227
+ """
228
+
229
+ from .sql import SqlError, SqlExecutionError, generate_warehouse_wipe_sql
230
+
231
+ owns_sql = sql is None
232
+ if sql is None:
233
+ from .fabric.sql import fabric_sql_executor
234
+
235
+ sql = fabric_sql_executor(target, workspace)
236
+ try:
237
+ sql.execute_script(generate_warehouse_wipe_sql())
238
+ except SqlError as exc:
239
+ raise SqlExecutionError(
240
+ f"failed to wipe Warehouse {target.warehouse.name!r}: {exc}"
241
+ ) from exc
242
+ except Exception as exc:
243
+ raise SqlExecutionError(
244
+ f"failed to wipe Warehouse {target.warehouse.name!r}: {exc}"
245
+ ) from exc
246
+ finally:
247
+ if owns_sql and hasattr(sql, "close"):
248
+ sql.close()
249
+
250
+
251
+ def wipe(
252
+ workspace: Workspace,
253
+ *,
254
+ folder_target: FolderTarget | None = None,
255
+ delta_target: DeltaTarget | None = None,
256
+ sql_target: WarehouseTarget | None = None,
257
+ store: Store | None = None,
258
+ sql=None,
259
+ dry_run: bool = False,
260
+ ) -> tuple[WipeReport, ...]:
261
+ """Wipe each supplied target. At least one is required.
262
+
263
+ Targets are independently optional, so a development loop can clear the
264
+ Delta tables while leaving downloaded source files alone.
265
+ """
266
+
267
+ if not any((folder_target, delta_target, sql_target)):
268
+ raise CommandError("wipe needs at least one target")
269
+
270
+ reports: list[WipeReport] = []
271
+ storage = store
272
+ if folder_target is not None:
273
+ storage = storage or store_for(workspace)
274
+ reports.append(
275
+ wipe_folder_target(folder_target, workspace, store=storage, dry_run=dry_run)
276
+ )
277
+ if delta_target is not None:
278
+ storage = storage or store_for(workspace)
279
+ reports.append(
280
+ wipe_delta_target(delta_target, workspace, store=storage, dry_run=dry_run)
281
+ )
282
+ if sql_target is not None:
283
+ if dry_run:
284
+ raise CommandError("Warehouse wipe does not support dry_run")
285
+ wipe_sql_target(sql_target, workspace, sql=sql)
286
+ return tuple(reports)
287
+
288
+
289
+ def wipe_lakehouse(
290
+ lakehouse: ItemRef,
291
+ workspace: Workspace,
292
+ *,
293
+ store: Store | None = None,
294
+ dry_run: bool = False,
295
+ ) -> tuple[WipeReport, ...]:
296
+ """Clear both areas of a Lakehouse — its Files and its Tables.
297
+
298
+ The item is resolved *as a Lakehouse*, so there is no untyped "what is this
299
+ name?" discovery: a Warehouse of the same name resolves elsewhere and is not
300
+ reached here. A destructive operation must not depend on name inference.
301
+ """
302
+
303
+ store = store or store_for(workspace)
304
+ resolver = resolver_for(workspace)
305
+ if not _lakehouse_exists(resolver, lakehouse):
306
+ raise CommandError(
307
+ f"no Lakehouse named {lakehouse.name!r} on this workspace — nothing to wipe"
308
+ )
309
+ return (
310
+ wipe_folder_target(
311
+ FolderTarget(lakehouse=lakehouse), workspace, store=store, dry_run=dry_run
312
+ ),
313
+ wipe_delta_target(
314
+ DeltaTarget(lakehouse=lakehouse), workspace, store=store, dry_run=dry_run
315
+ ),
316
+ )
317
+
318
+
319
+ def _lakehouse_exists(resolver, lakehouse: ItemRef) -> bool:
320
+ """Whether the Lakehouse is there, resolved as a Lakehouse.
321
+
322
+ Locally that is a directory check; on Fabric, resolving it as a Lakehouse
323
+ both proves it exists and refuses a same-named Warehouse.
324
+ """
325
+
326
+ if hasattr(resolver, "lakehouse_exists"):
327
+ return resolver.lakehouse_exists(lakehouse)
328
+ from .errors import CommandError as _CommandError
329
+
330
+ try:
331
+ resolver.lakehouse(lakehouse)
332
+ return True
333
+ except _CommandError:
334
+ return False
335
+
336
+
337
+ def wipe_selection(
338
+ selection: Iterable[str],
339
+ workspace: Workspace,
340
+ *,
341
+ store: Store | None = None,
342
+ dry_run: bool = False,
343
+ ) -> tuple[WipeReport, ...]:
344
+ """Wipe each named target, taking its type from its shape.
345
+
346
+ ``Sales_LH`` is a **Lakehouse** and clears both its areas.
347
+ ``Sales_LH/Files`` is that Lakehouse's Files area and clears only that. A bare
348
+ name is always a Lakehouse — a Warehouse must be wiped through a
349
+ :class:`~weaver.targets.WarehouseTarget`, never inferred from a name.
350
+ """
351
+
352
+ names = list(selection)
353
+ if not names:
354
+ raise CommandError("wipe needs at least one target")
355
+
356
+ store = store or store_for(workspace)
357
+ reports: list[WipeReport] = []
358
+ for name in names:
359
+ if "/" in name:
360
+ reports.append(
361
+ wipe_folder_target(
362
+ FolderTarget.parse(name), workspace, store=store, dry_run=dry_run
363
+ )
364
+ )
365
+ else:
366
+ reports.extend(
367
+ wipe_lakehouse(ItemRef.parse(name), workspace, store=store, dry_run=dry_run)
368
+ )
369
+ return tuple(reports)
weaver/push.py ADDED
@@ -0,0 +1,76 @@
1
+ """Validate and replace the authored repository in a Weaver Lakehouse."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from .declaration.repository import (
8
+ IGNORED_DIRECTORIES,
9
+ IGNORED_FILENAMES,
10
+ IGNORED_SUFFIXES,
11
+ parse_item_repository,
12
+ )
13
+ from .errors import CommandError
14
+ from .locations import Location
15
+ from .store import LocalStore, Store
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class PushResult:
20
+ source: str
21
+ destination: str
22
+ repository_signature: str
23
+ files: tuple[str, ...]
24
+
25
+ def to_mapping(self) -> dict[str, object]:
26
+ return {
27
+ "source": self.source,
28
+ "destination": self.destination,
29
+ "repository_signature": self.repository_signature,
30
+ "files": list(self.files),
31
+ }
32
+
33
+
34
+ def push_item_repository(
35
+ source: Location,
36
+ destination: Location,
37
+ *,
38
+ destination_store: Store,
39
+ source_store: Store | None = None,
40
+ ) -> PushResult:
41
+ """Validate ``source`` completely, then replace ``destination`` with it."""
42
+
43
+ source_store = source_store or LocalStore()
44
+ repository = parse_item_repository(source, store=source_store)
45
+ if source_store is destination_store and source == destination:
46
+ raise CommandError("push source and destination are the same repository")
47
+
48
+ prefix = source.value.rstrip("/") + "/"
49
+ files: list[tuple[str, bytes]] = []
50
+ for entry in source_store.list(source, recursive=True):
51
+ if entry.is_directory:
52
+ continue
53
+ relative = entry.location.value[len(prefix) :]
54
+ parts = relative.split("/")
55
+ if (
56
+ "_ignore" in parts
57
+ or any(part in IGNORED_DIRECTORIES for part in parts)
58
+ or parts[-1] in IGNORED_FILENAMES
59
+ or parts[-1].endswith(IGNORED_SUFFIXES)
60
+ ):
61
+ continue
62
+ files.append((relative, source_store.read(entry.location)))
63
+
64
+ if destination_store.exists(destination):
65
+ destination_store.delete(destination, recursive=True)
66
+ destination_store.make_directory(destination)
67
+ for relative, content in sorted(files):
68
+ destination_store.write(
69
+ destination.join(*relative.split("/")), content
70
+ )
71
+ return PushResult(
72
+ source=source.value,
73
+ destination=destination.value,
74
+ repository_signature=repository.signature,
75
+ files=tuple(relative for relative, _content in sorted(files)),
76
+ )