litestar-vite 0.1.1__py3-none-any.whl → 0.15.0__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 (169) hide show
  1. litestar_vite/__init__.py +54 -4
  2. litestar_vite/__metadata__.py +12 -7
  3. litestar_vite/cli.py +1048 -10
  4. litestar_vite/codegen/__init__.py +48 -0
  5. litestar_vite/codegen/_export.py +229 -0
  6. litestar_vite/codegen/_inertia.py +619 -0
  7. litestar_vite/codegen/_openapi.py +280 -0
  8. litestar_vite/codegen/_routes.py +720 -0
  9. litestar_vite/codegen/_ts.py +235 -0
  10. litestar_vite/codegen/_utils.py +141 -0
  11. litestar_vite/commands.py +73 -0
  12. litestar_vite/config/__init__.py +997 -0
  13. litestar_vite/config/_constants.py +97 -0
  14. litestar_vite/config/_deploy.py +70 -0
  15. litestar_vite/config/_inertia.py +241 -0
  16. litestar_vite/config/_paths.py +63 -0
  17. litestar_vite/config/_runtime.py +235 -0
  18. litestar_vite/config/_spa.py +93 -0
  19. litestar_vite/config/_types.py +94 -0
  20. litestar_vite/deploy.py +366 -0
  21. litestar_vite/doctor.py +1181 -0
  22. litestar_vite/exceptions.py +78 -0
  23. litestar_vite/executor.py +360 -0
  24. litestar_vite/handler/__init__.py +9 -0
  25. litestar_vite/handler/_app.py +612 -0
  26. litestar_vite/handler/_routing.py +130 -0
  27. litestar_vite/html_transform.py +569 -0
  28. litestar_vite/inertia/__init__.py +77 -0
  29. litestar_vite/inertia/_utils.py +119 -0
  30. litestar_vite/inertia/exception_handler.py +178 -0
  31. litestar_vite/inertia/helpers.py +1571 -0
  32. litestar_vite/inertia/middleware.py +54 -0
  33. litestar_vite/inertia/plugin.py +199 -0
  34. litestar_vite/inertia/precognition.py +274 -0
  35. litestar_vite/inertia/request.py +334 -0
  36. litestar_vite/inertia/response.py +802 -0
  37. litestar_vite/inertia/types.py +335 -0
  38. litestar_vite/loader.py +464 -123
  39. litestar_vite/plugin/__init__.py +687 -0
  40. litestar_vite/plugin/_process.py +185 -0
  41. litestar_vite/plugin/_proxy.py +689 -0
  42. litestar_vite/plugin/_proxy_headers.py +244 -0
  43. litestar_vite/plugin/_static.py +37 -0
  44. litestar_vite/plugin/_utils.py +489 -0
  45. litestar_vite/py.typed +0 -0
  46. litestar_vite/scaffolding/__init__.py +20 -0
  47. litestar_vite/scaffolding/generator.py +270 -0
  48. litestar_vite/scaffolding/templates.py +437 -0
  49. litestar_vite/templates/__init__.py +0 -0
  50. litestar_vite/templates/addons/tailwindcss/tailwind.css.j2 +1 -0
  51. litestar_vite/templates/angular/index.html.j2 +12 -0
  52. litestar_vite/templates/angular/openapi-ts.config.ts.j2 +18 -0
  53. litestar_vite/templates/angular/package.json.j2 +36 -0
  54. litestar_vite/templates/angular/src/app/app.component.css.j2 +3 -0
  55. litestar_vite/templates/angular/src/app/app.component.html.j2 +1 -0
  56. litestar_vite/templates/angular/src/app/app.component.ts.j2 +9 -0
  57. litestar_vite/templates/angular/src/app/app.config.ts.j2 +5 -0
  58. litestar_vite/templates/angular/src/main.ts.j2 +9 -0
  59. litestar_vite/templates/angular/src/styles.css.j2 +9 -0
  60. litestar_vite/templates/angular/tsconfig.app.json.j2 +34 -0
  61. litestar_vite/templates/angular/tsconfig.json.j2 +20 -0
  62. litestar_vite/templates/angular/vite.config.ts.j2 +21 -0
  63. litestar_vite/templates/angular-cli/.postcssrc.json.j2 +5 -0
  64. litestar_vite/templates/angular-cli/angular.json.j2 +36 -0
  65. litestar_vite/templates/angular-cli/openapi-ts.config.ts.j2 +18 -0
  66. litestar_vite/templates/angular-cli/package.json.j2 +28 -0
  67. litestar_vite/templates/angular-cli/proxy.conf.json.j2 +18 -0
  68. litestar_vite/templates/angular-cli/src/app/app.component.css.j2 +3 -0
  69. litestar_vite/templates/angular-cli/src/app/app.component.html.j2 +1 -0
  70. litestar_vite/templates/angular-cli/src/app/app.component.ts.j2 +9 -0
  71. litestar_vite/templates/angular-cli/src/app/app.config.ts.j2 +5 -0
  72. litestar_vite/templates/angular-cli/src/index.html.j2 +13 -0
  73. litestar_vite/templates/angular-cli/src/main.ts.j2 +6 -0
  74. litestar_vite/templates/angular-cli/src/styles.css.j2 +10 -0
  75. litestar_vite/templates/angular-cli/tailwind.config.js.j2 +4 -0
  76. litestar_vite/templates/angular-cli/tsconfig.app.json.j2 +16 -0
  77. litestar_vite/templates/angular-cli/tsconfig.json.j2 +26 -0
  78. litestar_vite/templates/angular-cli/tsconfig.spec.json.j2 +9 -0
  79. litestar_vite/templates/astro/astro.config.mjs.j2 +28 -0
  80. litestar_vite/templates/astro/openapi-ts.config.ts.j2 +15 -0
  81. litestar_vite/templates/astro/src/layouts/Layout.astro.j2 +63 -0
  82. litestar_vite/templates/astro/src/pages/index.astro.j2 +36 -0
  83. litestar_vite/templates/astro/src/styles/global.css.j2 +1 -0
  84. litestar_vite/templates/base/.gitignore.j2 +42 -0
  85. litestar_vite/templates/base/openapi-ts.config.ts.j2 +15 -0
  86. litestar_vite/templates/base/package.json.j2 +39 -0
  87. litestar_vite/templates/base/resources/vite-env.d.ts.j2 +1 -0
  88. litestar_vite/templates/base/tsconfig.json.j2 +37 -0
  89. litestar_vite/templates/htmx/src/main.js.j2 +8 -0
  90. litestar_vite/templates/htmx/templates/base.html.j2.j2 +56 -0
  91. litestar_vite/templates/htmx/templates/index.html.j2.j2 +13 -0
  92. litestar_vite/templates/htmx/vite.config.ts.j2 +40 -0
  93. litestar_vite/templates/nuxt/app.vue.j2 +29 -0
  94. litestar_vite/templates/nuxt/composables/useApi.ts.j2 +33 -0
  95. litestar_vite/templates/nuxt/nuxt.config.ts.j2 +31 -0
  96. litestar_vite/templates/nuxt/openapi-ts.config.ts.j2 +15 -0
  97. litestar_vite/templates/nuxt/pages/index.vue.j2 +54 -0
  98. litestar_vite/templates/react/index.html.j2 +13 -0
  99. litestar_vite/templates/react/src/App.css.j2 +56 -0
  100. litestar_vite/templates/react/src/App.tsx.j2 +19 -0
  101. litestar_vite/templates/react/src/main.tsx.j2 +10 -0
  102. litestar_vite/templates/react/vite.config.ts.j2 +39 -0
  103. litestar_vite/templates/react-inertia/index.html.j2 +14 -0
  104. litestar_vite/templates/react-inertia/package.json.j2 +47 -0
  105. litestar_vite/templates/react-inertia/resources/App.css.j2 +68 -0
  106. litestar_vite/templates/react-inertia/resources/main.tsx.j2 +17 -0
  107. litestar_vite/templates/react-inertia/resources/pages/Home.tsx.j2 +18 -0
  108. litestar_vite/templates/react-inertia/resources/ssr.tsx.j2 +19 -0
  109. litestar_vite/templates/react-inertia/vite.config.ts.j2 +59 -0
  110. litestar_vite/templates/react-router/index.html.j2 +12 -0
  111. litestar_vite/templates/react-router/src/App.css.j2 +17 -0
  112. litestar_vite/templates/react-router/src/App.tsx.j2 +7 -0
  113. litestar_vite/templates/react-router/src/main.tsx.j2 +10 -0
  114. litestar_vite/templates/react-router/vite.config.ts.j2 +39 -0
  115. litestar_vite/templates/react-tanstack/index.html.j2 +12 -0
  116. litestar_vite/templates/react-tanstack/openapi-ts.config.ts.j2 +18 -0
  117. litestar_vite/templates/react-tanstack/src/App.css.j2 +17 -0
  118. litestar_vite/templates/react-tanstack/src/main.tsx.j2 +21 -0
  119. litestar_vite/templates/react-tanstack/src/routeTree.gen.ts.j2 +7 -0
  120. litestar_vite/templates/react-tanstack/src/routes/__root.tsx.j2 +9 -0
  121. litestar_vite/templates/react-tanstack/src/routes/books.tsx.j2 +9 -0
  122. litestar_vite/templates/react-tanstack/src/routes/index.tsx.j2 +9 -0
  123. litestar_vite/templates/react-tanstack/vite.config.ts.j2 +39 -0
  124. litestar_vite/templates/svelte/index.html.j2 +13 -0
  125. litestar_vite/templates/svelte/src/App.svelte.j2 +30 -0
  126. litestar_vite/templates/svelte/src/app.css.j2 +45 -0
  127. litestar_vite/templates/svelte/src/main.ts.j2 +8 -0
  128. litestar_vite/templates/svelte/src/vite-env.d.ts.j2 +2 -0
  129. litestar_vite/templates/svelte/svelte.config.js.j2 +5 -0
  130. litestar_vite/templates/svelte/vite.config.ts.j2 +39 -0
  131. litestar_vite/templates/svelte-inertia/index.html.j2 +14 -0
  132. litestar_vite/templates/svelte-inertia/resources/app.css.j2 +21 -0
  133. litestar_vite/templates/svelte-inertia/resources/main.ts.j2 +11 -0
  134. litestar_vite/templates/svelte-inertia/resources/pages/Home.svelte.j2 +43 -0
  135. litestar_vite/templates/svelte-inertia/resources/vite-env.d.ts.j2 +2 -0
  136. litestar_vite/templates/svelte-inertia/svelte.config.js.j2 +5 -0
  137. litestar_vite/templates/svelte-inertia/vite.config.ts.j2 +37 -0
  138. litestar_vite/templates/sveltekit/openapi-ts.config.ts.j2 +15 -0
  139. litestar_vite/templates/sveltekit/src/app.css.j2 +40 -0
  140. litestar_vite/templates/sveltekit/src/app.html.j2 +12 -0
  141. litestar_vite/templates/sveltekit/src/hooks.server.ts.j2 +55 -0
  142. litestar_vite/templates/sveltekit/src/routes/+layout.svelte.j2 +12 -0
  143. litestar_vite/templates/sveltekit/src/routes/+page.svelte.j2 +34 -0
  144. litestar_vite/templates/sveltekit/svelte.config.js.j2 +12 -0
  145. litestar_vite/templates/sveltekit/tsconfig.json.j2 +14 -0
  146. litestar_vite/templates/sveltekit/vite.config.ts.j2 +31 -0
  147. litestar_vite/templates/vue/env.d.ts.j2 +7 -0
  148. litestar_vite/templates/vue/index.html.j2 +13 -0
  149. litestar_vite/templates/vue/src/App.vue.j2 +28 -0
  150. litestar_vite/templates/vue/src/main.ts.j2 +5 -0
  151. litestar_vite/templates/vue/src/style.css.j2 +45 -0
  152. litestar_vite/templates/vue/vite.config.ts.j2 +39 -0
  153. litestar_vite/templates/vue-inertia/env.d.ts.j2 +7 -0
  154. litestar_vite/templates/vue-inertia/index.html.j2 +14 -0
  155. litestar_vite/templates/vue-inertia/package.json.j2 +50 -0
  156. litestar_vite/templates/vue-inertia/resources/main.ts.j2 +18 -0
  157. litestar_vite/templates/vue-inertia/resources/pages/Home.vue.j2 +22 -0
  158. litestar_vite/templates/vue-inertia/resources/ssr.ts.j2 +21 -0
  159. litestar_vite/templates/vue-inertia/resources/style.css.j2 +21 -0
  160. litestar_vite/templates/vue-inertia/vite.config.ts.j2 +59 -0
  161. litestar_vite-0.15.0.dist-info/METADATA +230 -0
  162. litestar_vite-0.15.0.dist-info/RECORD +164 -0
  163. {litestar_vite-0.1.1.dist-info → litestar_vite-0.15.0.dist-info}/WHEEL +1 -1
  164. litestar_vite/config.py +0 -100
  165. litestar_vite/plugin.py +0 -45
  166. litestar_vite/template_engine.py +0 -103
  167. litestar_vite-0.1.1.dist-info/METADATA +0 -68
  168. litestar_vite-0.1.1.dist-info/RECORD +0 -11
  169. {litestar_vite-0.1.1.dist-info → litestar_vite-0.15.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,366 @@
1
+ """Vite CDN deployment utilities.
2
+
3
+ Provides a deployer for publishing built Vite assets to any fsspec backend.
4
+ DeployConfig is defined in litestar_vite.config and passed into ViteDeployer.
5
+ """
6
+
7
+ # pyright: reportUnknownVariableType=false, reportUnknownMemberType=false, reportMissingTypeStubs=false
8
+
9
+ from collections.abc import Callable, Iterable
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any, cast
13
+
14
+ from litestar.exceptions import SerializationException
15
+ from litestar.serialization import decode_json
16
+
17
+ from litestar_vite.config import FSSPEC_INSTALLED
18
+ from litestar_vite.config import DeployConfig as _DeployConfig
19
+ from litestar_vite.exceptions import MissingDependencyError
20
+
21
+ __all__ = ("FileInfo", "SyncPlan", "SyncResult", "ViteDeployer", "format_bytes")
22
+
23
+ AbstractFileSystem = Any
24
+
25
+
26
+ def _suggest_install_extra(storage_backend: "str | None") -> str:
27
+ """Suggest an install target based on backend scheme.
28
+
29
+ Args:
30
+ storage_backend: The storage backend URL.
31
+
32
+ Returns:
33
+ Suggested package to install.
34
+ """
35
+ if not storage_backend:
36
+ return "fsspec"
37
+ scheme = storage_backend.split("://", 1)[0]
38
+ mapping = {"gcs": "gcsfs", "s3": "s3fs", "abfs": "adlfs", "az": "adlfs", "sftp": "fsspec", "ftp": "fsspec"}
39
+ return mapping.get(scheme, "fsspec")
40
+
41
+
42
+ def _import_fsspec(storage_backend: "str | None") -> tuple[Any, Callable[..., tuple[Any, Any]]]:
43
+ """Import fsspec lazily with a helpful error when missing.
44
+
45
+ Args:
46
+ storage_backend: The storage backend URL for error messaging.
47
+
48
+ Returns:
49
+ Tuple of fsspec module and url_to_fs function.
50
+
51
+ Raises:
52
+ MissingDependencyError: If fsspec is not installed.
53
+ """
54
+ if not FSSPEC_INSTALLED:
55
+ msg = "fsspec"
56
+ raise MissingDependencyError(msg, install_package=_suggest_install_extra(storage_backend))
57
+
58
+ import fsspec # pyright: ignore
59
+ from fsspec.core import url_to_fs # pyright: ignore
60
+
61
+ return fsspec, url_to_fs
62
+
63
+
64
+ @dataclass
65
+ class FileInfo:
66
+ """Lightweight file metadata used for sync planning."""
67
+
68
+ path: str
69
+ size: int
70
+ mtime: float
71
+
72
+
73
+ @dataclass
74
+ class SyncPlan:
75
+ """Diff plan for deployment."""
76
+
77
+ to_upload: list[str]
78
+ to_delete: list[str]
79
+
80
+
81
+ @dataclass
82
+ class SyncResult:
83
+ """Deployment result summary."""
84
+
85
+ uploaded: list[str]
86
+ deleted: list[str]
87
+ uploaded_bytes: int
88
+ deleted_bytes: int
89
+ dry_run: bool
90
+
91
+
92
+ class ViteDeployer:
93
+ """Deploy built Vite assets to a remote fsspec backend."""
94
+
95
+ def __init__(
96
+ self,
97
+ *,
98
+ bundle_dir: Path,
99
+ manifest_name: str,
100
+ deploy_config: _DeployConfig,
101
+ fs: "AbstractFileSystem | None" = None,
102
+ remote_path: str | None = None,
103
+ ) -> None:
104
+ self._fsspec, self._url_to_fs = _import_fsspec(deploy_config.storage_backend)
105
+ if not deploy_config.enabled:
106
+ msg = "Deployment is disabled. Enable DeployConfig.enabled to proceed."
107
+ raise ValueError(msg)
108
+ if not deploy_config.storage_backend:
109
+ msg = "DeployConfig.storage_backend is required (e.g. gcs://bucket/assets)."
110
+ raise ValueError(msg)
111
+
112
+ self.bundle_dir = bundle_dir
113
+ manifest_rel = Path(manifest_name)
114
+ manifest_path = bundle_dir / manifest_rel
115
+ if (
116
+ not manifest_path.exists()
117
+ and not manifest_rel.is_absolute()
118
+ and (not manifest_rel.parts or manifest_rel.parts[0] != ".vite")
119
+ ):
120
+ vite_manifest = bundle_dir / ".vite" / manifest_rel
121
+ if vite_manifest.exists():
122
+ manifest_path = vite_manifest
123
+
124
+ self.manifest_path = manifest_path
125
+ self.config = deploy_config
126
+ self._fs, self.remote_path = self._init_filesystem(fs, remote_path)
127
+
128
+ @property
129
+ def fs(self) -> "AbstractFileSystem":
130
+ """Filesystem for deployment operations.
131
+
132
+ Returns:
133
+ The filesystem used for deployment operations.
134
+ """
135
+ return self._fs
136
+
137
+ def collect_local_files(self) -> dict[str, FileInfo]:
138
+ """Collect local files to publish.
139
+
140
+ Returns:
141
+ Mapping of relative paths to file metadata.
142
+ """
143
+
144
+ manifest_paths: set[str] = (
145
+ self._paths_from_manifest(self.manifest_path) if self.manifest_path.exists() else set[str]()
146
+ )
147
+
148
+ include_manifest = self.config.include_manifest and self.manifest_path.exists()
149
+ files: dict[str, FileInfo] = {}
150
+
151
+ if manifest_paths:
152
+ candidate_paths: list[Path] = [self.bundle_dir / p for p in manifest_paths]
153
+ if include_manifest:
154
+ candidate_paths.append(self.manifest_path)
155
+ candidates: Iterable[Path] = candidate_paths
156
+ else:
157
+ candidates = self.bundle_dir.rglob("*")
158
+
159
+ for path in candidates:
160
+ if path.is_dir():
161
+ continue
162
+ if not path.exists():
163
+ continue
164
+ rel_path = path.relative_to(self.bundle_dir).as_posix()
165
+ stat = path.stat()
166
+ files[rel_path] = FileInfo(path=rel_path, size=stat.st_size, mtime=stat.st_mtime)
167
+
168
+ index_html = self.bundle_dir / "index.html"
169
+ if index_html.exists():
170
+ stat = index_html.stat()
171
+ files.setdefault("index.html", FileInfo(path="index.html", size=stat.st_size, mtime=stat.st_mtime))
172
+
173
+ return files
174
+
175
+ def collect_remote_files(self) -> dict[str, FileInfo]:
176
+ """Collect remote files from the target storage.
177
+
178
+ Returns:
179
+ Mapping of relative remote paths to file metadata.
180
+ """
181
+
182
+ try:
183
+ entries = cast("list[dict[str, Any]]", self.fs.ls(self.remote_path, detail=True))
184
+ except (FileNotFoundError, OSError):
185
+ return {}
186
+
187
+ remote_files: dict[str, FileInfo] = {}
188
+ base = self.remote_path.rstrip("/")
189
+ for entry in entries:
190
+ name = entry.get("name")
191
+ if name is None:
192
+ continue
193
+ if entry.get("type") == "directory":
194
+ continue
195
+ rel_path = self._relative_remote_path(name, base)
196
+ remote_files[rel_path] = FileInfo(
197
+ path=rel_path, size=int(entry.get("size", 0)), mtime=float(entry.get("mtime", 0.0))
198
+ )
199
+ return remote_files
200
+
201
+ @staticmethod
202
+ def compute_diff(local: dict[str, FileInfo], remote: dict[str, FileInfo], delete_orphaned: bool) -> SyncPlan:
203
+ """Compute which files to upload or delete.
204
+
205
+ Args:
206
+ local: Local files keyed by relative path.
207
+ remote: Remote files keyed by relative path.
208
+ delete_orphaned: Whether to remove remote-only files.
209
+
210
+ Returns:
211
+ SyncPlan listing upload and delete actions.
212
+ """
213
+
214
+ to_upload: list[str] = []
215
+ for path, info in local.items():
216
+ remote_info = remote.get(path)
217
+ if remote_info is None or remote_info.size != info.size:
218
+ to_upload.append(path)
219
+
220
+ to_delete: list[str] = [path for path in remote if path not in local] if delete_orphaned else []
221
+
222
+ return SyncPlan(to_upload=to_upload, to_delete=to_delete)
223
+
224
+ def sync(self, *, dry_run: bool = False, on_progress: Callable[[str, str], None] | None = None) -> SyncResult:
225
+ """Sync local bundle to remote storage.
226
+
227
+ Args:
228
+ dry_run: When True, compute the plan without uploading or deleting.
229
+ on_progress: Optional callback receiving an action and path for each step.
230
+
231
+ Returns:
232
+ SyncResult summarizing the deployment.
233
+ """
234
+
235
+ local_files = self.collect_local_files()
236
+ remote_files = self.collect_remote_files()
237
+ plan = self.compute_diff(local_files, remote_files, delete_orphaned=self.config.delete_orphaned)
238
+
239
+ uploaded: list[str] = []
240
+ deleted: list[str] = []
241
+ uploaded_bytes = 0
242
+ deleted_bytes = 0
243
+
244
+ if dry_run:
245
+ return SyncResult(
246
+ uploaded=plan.to_upload,
247
+ deleted=plan.to_delete,
248
+ uploaded_bytes=sum(local_files[p].size for p in plan.to_upload),
249
+ deleted_bytes=sum(remote_files[p].size for p in plan.to_delete),
250
+ dry_run=True,
251
+ )
252
+
253
+ for path in plan.to_upload:
254
+ local_path = self.bundle_dir / path
255
+ remote_path = self._join_remote(path)
256
+ content_type: str | None = self.config.content_types.get(Path(path).suffix)
257
+ if content_type:
258
+ self.fs.put(local_path.as_posix(), remote_path, content_type=content_type)
259
+ else:
260
+ self.fs.put(local_path.as_posix(), remote_path)
261
+ uploaded.append(path)
262
+ uploaded_bytes += local_files[path].size
263
+ if on_progress:
264
+ on_progress("upload", path)
265
+
266
+ for path in plan.to_delete:
267
+ remote_path = self._join_remote(path)
268
+ self.fs.rm(remote_path)
269
+ deleted.append(path)
270
+ deleted_bytes += remote_files[path].size
271
+ if on_progress:
272
+ on_progress("delete", path)
273
+
274
+ return SyncResult(
275
+ uploaded=uploaded,
276
+ deleted=deleted,
277
+ uploaded_bytes=uploaded_bytes,
278
+ deleted_bytes=deleted_bytes,
279
+ dry_run=False,
280
+ )
281
+
282
+ def _init_filesystem(
283
+ self, fs: "AbstractFileSystem | None", remote_path: str | None
284
+ ) -> "tuple[AbstractFileSystem, str]":
285
+ if fs is not None and remote_path is not None:
286
+ return fs, remote_path
287
+
288
+ if fs is not None:
289
+ _, resolved_path = self._url_to_fs(self.config.storage_backend or "", **self.config.storage_options)
290
+ resolved_str = str(resolved_path)
291
+ return fs, remote_path or resolved_str
292
+
293
+ filesystem, resolved_path = self._url_to_fs(self.config.storage_backend or "", **self.config.storage_options)
294
+ resolved_str = str(resolved_path)
295
+ return filesystem, remote_path or resolved_str
296
+
297
+ def _paths_from_manifest(self, manifest_path: Path) -> set[str]:
298
+ """Extract file paths referenced by manifest.json.
299
+
300
+ Returns:
301
+ Set of file paths.
302
+ """
303
+
304
+ try:
305
+ manifest_data: Any = decode_json(manifest_path.read_text(encoding="utf-8"))
306
+ except (OSError, UnicodeDecodeError, SerializationException):
307
+ return set[str]()
308
+
309
+ paths: set[str] = set()
310
+ if isinstance(manifest_data, dict):
311
+ for value in manifest_data.values():
312
+ if not isinstance(value, dict):
313
+ continue
314
+ file_path = value.get("file")
315
+ if isinstance(file_path, str):
316
+ paths.add(file_path)
317
+ for field in ("css", "assets"):
318
+ for item in value.get(field, []) or []:
319
+ if isinstance(item, str):
320
+ paths.add(item)
321
+ return paths
322
+
323
+ def _relative_remote_path(self, full_path: str, base: str) -> str:
324
+ """Compute remote path relative to deployment root.
325
+
326
+ Returns:
327
+ The remote path relative to the deployment root.
328
+ """
329
+
330
+ if "://" in full_path:
331
+ full_path = full_path.split("://", 1)[1]
332
+ if "://" in base:
333
+ base = base.split("://", 1)[1]
334
+ full_path = full_path.lstrip("/")
335
+ base = base.lstrip("/")
336
+ if not base:
337
+ return full_path.lstrip("/")
338
+ cleaned = full_path.removeprefix(base)
339
+ return cleaned.lstrip("/")
340
+
341
+ def _join_remote(self, relative_path: str) -> str:
342
+ """Join remote base and relative path.
343
+
344
+ Returns:
345
+ The full remote path.
346
+ """
347
+
348
+ if not self.remote_path:
349
+ return relative_path
350
+ return f"{self.remote_path.rstrip('/')}/{relative_path.lstrip('/')}"
351
+
352
+
353
+ def format_bytes(size: int) -> str:
354
+ """Human friendly byte formatting.
355
+
356
+ Returns:
357
+ The formatted byte size string.
358
+ """
359
+
360
+ units = ["B", "KB", "MB", "GB", "TB"]
361
+ value = float(size)
362
+ for unit in units:
363
+ if value < 1024 or unit == "TB":
364
+ return f"{value:.1f} {unit}"
365
+ value /= 1024
366
+ return f"{value:.1f} TB"