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