@palettelab/cli 0.3.44 → 0.3.45
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.
- package/README.md +12 -0
- package/backend-sdk/palette_sdk/storage.py +140 -1
- package/docs/python-backend-sdk.md +19 -3
- package/lib/commands/dev.js +1 -1
- package/lib/commands/publish.js +25 -3
- package/lib/commands/status.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -342,6 +342,18 @@ async def save_report(ctx: PluginContext = Depends(get_plugin_context)):
|
|
|
342
342
|
key="reports/summary.json",
|
|
343
343
|
)
|
|
344
344
|
return saved
|
|
345
|
+
|
|
346
|
+
@router.post("/exports", dependencies=[require_permission("reports:write")])
|
|
347
|
+
async def save_large_export(ctx: PluginContext = Depends(get_plugin_context)):
|
|
348
|
+
progress = []
|
|
349
|
+
saved = await ctx.storage.upload_file_path(
|
|
350
|
+
"/tmp/export.zip",
|
|
351
|
+
"application/zip",
|
|
352
|
+
key="exports/export.zip",
|
|
353
|
+
chunk_size=8 * 1024 * 1024,
|
|
354
|
+
on_progress=lambda event: progress.append(event),
|
|
355
|
+
)
|
|
356
|
+
return {"saved": saved, "progress": progress[-1:]}
|
|
345
357
|
```
|
|
346
358
|
|
|
347
359
|
Frontend apps can use `createPaletteClient(platform).storage.upload(file, {
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import inspect
|
|
3
4
|
import mimetypes
|
|
5
|
+
import os
|
|
4
6
|
import re
|
|
5
7
|
import uuid
|
|
6
8
|
from dataclasses import dataclass
|
|
7
9
|
from pathlib import Path
|
|
8
|
-
from typing import Any
|
|
10
|
+
from typing import Any, BinaryIO, Callable
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
def _slug_segment(value: str | None, fallback: str) -> str:
|
|
@@ -31,6 +33,48 @@ def _validate_relative_key(key: str) -> str:
|
|
|
31
33
|
return "/".join(_sanitize_filename(part) for part in parts)
|
|
32
34
|
|
|
33
35
|
|
|
36
|
+
MIN_CHUNK_SIZE = 256 * 1024
|
|
37
|
+
DEFAULT_CHUNK_SIZE = 8 * 1024 * 1024
|
|
38
|
+
|
|
39
|
+
ProgressCallback = Callable[[dict[str, Any]], Any]
|
|
40
|
+
|
|
41
|
+
def _normalize_chunk_size(value: int | None) -> int:
|
|
42
|
+
raw = max(value or DEFAULT_CHUNK_SIZE, MIN_CHUNK_SIZE)
|
|
43
|
+
return ((raw + MIN_CHUNK_SIZE - 1) // MIN_CHUNK_SIZE) * MIN_CHUNK_SIZE
|
|
44
|
+
|
|
45
|
+
def _infer_stream_size(stream: BinaryIO) -> int | None:
|
|
46
|
+
try:
|
|
47
|
+
current = stream.tell()
|
|
48
|
+
stream.seek(0, os.SEEK_END)
|
|
49
|
+
end = stream.tell()
|
|
50
|
+
stream.seek(current, os.SEEK_SET)
|
|
51
|
+
return max(0, end - current)
|
|
52
|
+
except Exception:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
async def _maybe_report_progress(
|
|
56
|
+
callback: ProgressCallback | None,
|
|
57
|
+
*,
|
|
58
|
+
loaded: int,
|
|
59
|
+
total: int,
|
|
60
|
+
chunk_index: int,
|
|
61
|
+
chunk_count: int,
|
|
62
|
+
state: str,
|
|
63
|
+
) -> None:
|
|
64
|
+
if callback is None:
|
|
65
|
+
return
|
|
66
|
+
payload = {
|
|
67
|
+
"loaded": loaded,
|
|
68
|
+
"total": total,
|
|
69
|
+
"percentage": min(100, round((loaded / total) * 10000) / 100) if total > 0 else 100,
|
|
70
|
+
"chunk_index": chunk_index,
|
|
71
|
+
"chunk_count": chunk_count,
|
|
72
|
+
"state": state,
|
|
73
|
+
}
|
|
74
|
+
result = callback(payload)
|
|
75
|
+
if inspect.isawaitable(result):
|
|
76
|
+
await result
|
|
77
|
+
|
|
34
78
|
def _content_type(filename: str | None, content_type: str | None) -> str:
|
|
35
79
|
browser_ct = content_type or "application/octet-stream"
|
|
36
80
|
if browser_ct == "application/octet-stream":
|
|
@@ -86,6 +130,101 @@ class LocalStorageService:
|
|
|
86
130
|
"size": len(content),
|
|
87
131
|
}
|
|
88
132
|
|
|
133
|
+
async def upload_file_stream(
|
|
134
|
+
self,
|
|
135
|
+
filename: str,
|
|
136
|
+
stream: BinaryIO,
|
|
137
|
+
content_type: str | None = None,
|
|
138
|
+
*,
|
|
139
|
+
size: int | None = None,
|
|
140
|
+
key: str | None = None,
|
|
141
|
+
chunk_size: int | None = None,
|
|
142
|
+
on_progress: ProgressCallback | None = None,
|
|
143
|
+
) -> dict[str, Any]:
|
|
144
|
+
total = size if size is not None else _infer_stream_size(stream)
|
|
145
|
+
if total is None:
|
|
146
|
+
raise ValueError("size is required for non-seekable storage streams")
|
|
147
|
+
chunk_bytes = _normalize_chunk_size(chunk_size)
|
|
148
|
+
chunk_count = max(1, (total + chunk_bytes - 1) // chunk_bytes)
|
|
149
|
+
object_path = self.object_path(filename, key=key)
|
|
150
|
+
target = self._target(object_path)
|
|
151
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
loaded = 0
|
|
153
|
+
await _maybe_report_progress(
|
|
154
|
+
on_progress,
|
|
155
|
+
loaded=0,
|
|
156
|
+
total=total,
|
|
157
|
+
chunk_index=0,
|
|
158
|
+
chunk_count=chunk_count,
|
|
159
|
+
state="starting",
|
|
160
|
+
)
|
|
161
|
+
if total == 0:
|
|
162
|
+
target.write_bytes(b"")
|
|
163
|
+
await _maybe_report_progress(
|
|
164
|
+
on_progress,
|
|
165
|
+
loaded=0,
|
|
166
|
+
total=0,
|
|
167
|
+
chunk_index=0,
|
|
168
|
+
chunk_count=chunk_count,
|
|
169
|
+
state="complete",
|
|
170
|
+
)
|
|
171
|
+
return {
|
|
172
|
+
"bucket": "local",
|
|
173
|
+
"object_path": object_path,
|
|
174
|
+
"file_url": target.as_uri(),
|
|
175
|
+
"content_type": _content_type(filename, content_type),
|
|
176
|
+
"size": 0,
|
|
177
|
+
}
|
|
178
|
+
with target.open("wb") as out:
|
|
179
|
+
chunk_index = 0
|
|
180
|
+
while True:
|
|
181
|
+
chunk = stream.read(chunk_bytes)
|
|
182
|
+
if not chunk:
|
|
183
|
+
break
|
|
184
|
+
chunk_index += 1
|
|
185
|
+
out.write(chunk)
|
|
186
|
+
loaded += len(chunk)
|
|
187
|
+
await _maybe_report_progress(
|
|
188
|
+
on_progress,
|
|
189
|
+
loaded=loaded,
|
|
190
|
+
total=total,
|
|
191
|
+
chunk_index=chunk_index,
|
|
192
|
+
chunk_count=chunk_count,
|
|
193
|
+
state="complete" if loaded >= total else "uploading",
|
|
194
|
+
)
|
|
195
|
+
if loaded != total:
|
|
196
|
+
raise RuntimeError(f"storage upload incomplete: uploaded {loaded} of {total} bytes")
|
|
197
|
+
return {
|
|
198
|
+
"bucket": "local",
|
|
199
|
+
"object_path": object_path,
|
|
200
|
+
"file_url": target.as_uri(),
|
|
201
|
+
"content_type": _content_type(filename, content_type),
|
|
202
|
+
"size": loaded,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async def upload_file_path(
|
|
206
|
+
self,
|
|
207
|
+
path: str | Path,
|
|
208
|
+
content_type: str | None = None,
|
|
209
|
+
*,
|
|
210
|
+
filename: str | None = None,
|
|
211
|
+
key: str | None = None,
|
|
212
|
+
chunk_size: int | None = None,
|
|
213
|
+
on_progress: ProgressCallback | None = None,
|
|
214
|
+
) -> dict[str, Any]:
|
|
215
|
+
source = Path(path)
|
|
216
|
+
resolved_filename = filename or source.name
|
|
217
|
+
with source.open("rb") as stream:
|
|
218
|
+
return await self.upload_file_stream(
|
|
219
|
+
resolved_filename,
|
|
220
|
+
stream,
|
|
221
|
+
content_type,
|
|
222
|
+
size=source.stat().st_size,
|
|
223
|
+
key=key,
|
|
224
|
+
chunk_size=chunk_size,
|
|
225
|
+
on_progress=on_progress,
|
|
226
|
+
)
|
|
227
|
+
|
|
89
228
|
async def create_resumable_upload(
|
|
90
229
|
self,
|
|
91
230
|
filename: str,
|
|
@@ -759,12 +759,28 @@ saved = await ctx.storage.upload_file(
|
|
|
759
759
|
key="reports/summary.json",
|
|
760
760
|
)
|
|
761
761
|
|
|
762
|
-
|
|
763
|
-
|
|
762
|
+
# Large backend-generated files should stream from disk. This avoids loading
|
|
763
|
+
# GB-sized files into memory and reports chunk progress.
|
|
764
|
+
progress = []
|
|
765
|
+
saved = await ctx.storage.upload_file_path(
|
|
766
|
+
"/tmp/video.mp4",
|
|
764
767
|
"video/mp4",
|
|
765
|
-
size=video_size,
|
|
766
768
|
key="videos/video.mp4",
|
|
769
|
+
chunk_size=8 * 1024 * 1024,
|
|
770
|
+
on_progress=lambda event: progress.append(event),
|
|
767
771
|
)
|
|
772
|
+
|
|
773
|
+
# If you already have an open binary stream, use upload_file_stream(...).
|
|
774
|
+
# Non-seekable streams must pass size=.
|
|
775
|
+
with open("/tmp/video.mp4", "rb") as stream:
|
|
776
|
+
saved = await ctx.storage.upload_file_stream(
|
|
777
|
+
"video.mp4",
|
|
778
|
+
stream,
|
|
779
|
+
"video/mp4",
|
|
780
|
+
size=video_size,
|
|
781
|
+
key="videos/video.mp4",
|
|
782
|
+
on_progress=lambda event: progress.append(event),
|
|
783
|
+
)
|
|
768
784
|
```
|
|
769
785
|
|
|
770
786
|
Frontend apps can use the browser SDK for chunked/resumable upload progress:
|
package/lib/commands/dev.js
CHANGED
|
@@ -83,7 +83,7 @@ async function run(args, { cwd }) {
|
|
|
83
83
|
loadLocalEnv(cwd)
|
|
84
84
|
if (cloud) {
|
|
85
85
|
const json = args.includes("--json")
|
|
86
|
-
const publishArgs = []
|
|
86
|
+
const publishArgs = ["--publish-type", "preview"]
|
|
87
87
|
if (flags.env) publishArgs.push("--env", flags.env)
|
|
88
88
|
if (flags.yes) publishArgs.push("--yes")
|
|
89
89
|
if (args.includes("--json")) publishArgs.push("--json")
|
package/lib/commands/publish.js
CHANGED
|
@@ -165,6 +165,20 @@ function runPreflight(cwd, json) {
|
|
|
165
165
|
process.exit(res.status || 1)
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
|
|
169
|
+
function parsePublishType(argv) {
|
|
170
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
171
|
+
const arg = argv[i]
|
|
172
|
+
if (arg === "--preview") return "preview"
|
|
173
|
+
if (arg === "--release") return "release"
|
|
174
|
+
if (arg === "--publish-type") return argv[i + 1] === "preview" ? "preview" : "release"
|
|
175
|
+
if (arg.startsWith("--publish-type=")) {
|
|
176
|
+
return arg.slice("--publish-type=".length) === "preview" ? "preview" : "release"
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return "release"
|
|
180
|
+
}
|
|
181
|
+
|
|
168
182
|
function makeApi(env) {
|
|
169
183
|
return async function api(pathname, { method = "GET", body, headers = {} } = {}) {
|
|
170
184
|
const url = `${env.url}${pathname}`
|
|
@@ -266,6 +280,7 @@ async function run(argv, { cwd }) {
|
|
|
266
280
|
}
|
|
267
281
|
|
|
268
282
|
const manifest = loadManifest(cwd)
|
|
283
|
+
const publishType = parsePublishType(argv)
|
|
269
284
|
loadLocalEnv(cwd)
|
|
270
285
|
const errors = validateManifest(manifest)
|
|
271
286
|
if (errors.length) {
|
|
@@ -310,6 +325,7 @@ async function run(argv, { cwd }) {
|
|
|
310
325
|
plugin_id: manifest.id,
|
|
311
326
|
version: manifest.version,
|
|
312
327
|
bundle_sha256: backendSha,
|
|
328
|
+
publish_type: publishType,
|
|
313
329
|
},
|
|
314
330
|
})
|
|
315
331
|
|
|
@@ -334,6 +350,7 @@ async function run(argv, { cwd }) {
|
|
|
334
350
|
bundle_path: signed.bundle_path,
|
|
335
351
|
bundle_sha256: backendSha,
|
|
336
352
|
manifest,
|
|
353
|
+
publish_type: publishType,
|
|
337
354
|
environment: env.name,
|
|
338
355
|
}
|
|
339
356
|
if (Object.keys(pluginSecrets).length) publishBody.plugin_secrets = pluginSecrets
|
|
@@ -357,6 +374,7 @@ async function run(argv, { cwd }) {
|
|
|
357
374
|
version: record.version,
|
|
358
375
|
env: env.name,
|
|
359
376
|
url: env.url,
|
|
377
|
+
publish_type: record.publish_type || publishType,
|
|
360
378
|
preview_url: record.preview_url,
|
|
361
379
|
preview_expires_at: record.preview_expires_at,
|
|
362
380
|
published_at: new Date().toISOString(),
|
|
@@ -375,12 +393,16 @@ async function run(argv, { cwd }) {
|
|
|
375
393
|
}
|
|
376
394
|
|
|
377
395
|
console.log(
|
|
378
|
-
`[pltt] published ${record.plugin_id}@${record.version} (status=${record.status})`,
|
|
396
|
+
`[pltt] ${publishType === "preview" ? "previewed" : "published"} ${record.plugin_id}@${record.version} (status=${record.status})`,
|
|
379
397
|
)
|
|
380
398
|
if (record.status === "active") {
|
|
381
399
|
console.log(`[pltt] active on ${env.url}`)
|
|
400
|
+
} else if (record.status === "preview_ready") {
|
|
401
|
+
console.log(`[pltt] preview ready on ${env.url}`)
|
|
402
|
+
} else if (record.status === "preview_pending") {
|
|
403
|
+
console.log(`[pltt] awaiting superadmin preview approval on ${env.url}`)
|
|
382
404
|
} else {
|
|
383
|
-
console.log(`[pltt] awaiting superadmin
|
|
405
|
+
console.log(`[pltt] awaiting superadmin publish approval on ${env.url}`)
|
|
384
406
|
}
|
|
385
407
|
if (record.review_url && record.status !== "active") {
|
|
386
408
|
console.log(`[pltt] review queue: ${record.review_url}`)
|
|
@@ -393,7 +415,7 @@ async function run(argv, { cwd }) {
|
|
|
393
415
|
}
|
|
394
416
|
if (record.status === "active") {
|
|
395
417
|
console.log(`[pltt] live at ${env.url}${record.catalog_url}`)
|
|
396
|
-
} else {
|
|
418
|
+
} else if (publishType === "release") {
|
|
397
419
|
console.log(`[pltt] once approved, live at ${env.url}${record.catalog_url}`)
|
|
398
420
|
}
|
|
399
421
|
return record
|
package/lib/commands/status.js
CHANGED
|
@@ -63,6 +63,7 @@ async function run(argv, { cwd }) {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
console.log(`[pltt] ${data.plugin_id}@${data.version}`)
|
|
66
|
+
if (data.publish_type) console.log(` type: ${data.publish_type}`)
|
|
66
67
|
console.log(` status: ${data.status}`)
|
|
67
68
|
if (data.review_decision) console.log(` decision: ${data.review_decision}`)
|
|
68
69
|
if (data.review_reason) console.log(` reason: ${data.review_reason}`)
|