@palettelab/cli 0.3.44 → 0.3.46
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 +14 -0
- package/backend-sdk/palette_sdk/storage.py +196 -16
- package/docs/python-backend-sdk.md +21 -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
|
@@ -328,6 +328,8 @@ async def sync_invoices(ctx: PluginContext = Depends(get_plugin_context)):
|
|
|
328
328
|
return {"room": room, "folder": folder, "bytes": len(content or b"")}
|
|
329
329
|
```
|
|
330
330
|
|
|
331
|
+
Storage upload responses include both snake_case and camelCase aliases for object metadata (`object_path`/`objectPath`, `file_url`/`fileUrl`, `content_type`/`contentType`) so local simulator and hosted OS responses can be consumed with the same app code.
|
|
332
|
+
|
|
331
333
|
App storage is different from Data Room uploads. Use `ctx.storage` or `palette.storage` for app-owned files written directly to the OS-configured storage backend, currently GCS in hosted environments. Use `ctx.data_rooms` / `palette.dataRooms` only when the file should be managed as a Data Room document.
|
|
332
334
|
|
|
333
335
|
Python backend app-storage example:
|
|
@@ -342,6 +344,18 @@ async def save_report(ctx: PluginContext = Depends(get_plugin_context)):
|
|
|
342
344
|
key="reports/summary.json",
|
|
343
345
|
)
|
|
344
346
|
return saved
|
|
347
|
+
|
|
348
|
+
@router.post("/exports", dependencies=[require_permission("reports:write")])
|
|
349
|
+
async def save_large_export(ctx: PluginContext = Depends(get_plugin_context)):
|
|
350
|
+
progress = []
|
|
351
|
+
saved = await ctx.storage.upload_file_path(
|
|
352
|
+
"/tmp/export.zip",
|
|
353
|
+
"application/zip",
|
|
354
|
+
key="exports/export.zip",
|
|
355
|
+
chunk_size=8 * 1024 * 1024,
|
|
356
|
+
on_progress=lambda event: progress.append(event),
|
|
357
|
+
)
|
|
358
|
+
return {"saved": saved, "progress": progress[-1:]}
|
|
345
359
|
```
|
|
346
360
|
|
|
347
361
|
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":
|
|
@@ -39,6 +83,47 @@ def _content_type(filename: str | None, content_type: str | None) -> str:
|
|
|
39
83
|
return browser_ct
|
|
40
84
|
|
|
41
85
|
|
|
86
|
+
def _storage_metadata(
|
|
87
|
+
*,
|
|
88
|
+
bucket: str,
|
|
89
|
+
object_path: str,
|
|
90
|
+
file_url: str,
|
|
91
|
+
content_type: str,
|
|
92
|
+
size: int | None,
|
|
93
|
+
) -> dict[str, Any]:
|
|
94
|
+
return {
|
|
95
|
+
"bucket": bucket,
|
|
96
|
+
"object_path": object_path,
|
|
97
|
+
"objectPath": object_path,
|
|
98
|
+
"file_url": file_url,
|
|
99
|
+
"fileUrl": file_url,
|
|
100
|
+
"content_type": content_type,
|
|
101
|
+
"contentType": content_type,
|
|
102
|
+
"size": size,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _resumable_metadata(
|
|
107
|
+
*,
|
|
108
|
+
bucket: str,
|
|
109
|
+
object_path: str,
|
|
110
|
+
file_url: str,
|
|
111
|
+
upload_url: str | None,
|
|
112
|
+
content_type: str,
|
|
113
|
+
size: int | None,
|
|
114
|
+
) -> dict[str, Any]:
|
|
115
|
+
payload = _storage_metadata(
|
|
116
|
+
bucket=bucket,
|
|
117
|
+
object_path=object_path,
|
|
118
|
+
file_url=file_url,
|
|
119
|
+
content_type=content_type,
|
|
120
|
+
size=size,
|
|
121
|
+
)
|
|
122
|
+
payload["upload_url"] = upload_url
|
|
123
|
+
payload["uploadUrl"] = upload_url
|
|
124
|
+
return payload
|
|
125
|
+
|
|
126
|
+
|
|
42
127
|
@dataclass
|
|
43
128
|
class LocalStorageService:
|
|
44
129
|
"""Filesystem-backed ctx.storage implementation used by local SDK dev."""
|
|
@@ -78,13 +163,108 @@ class LocalStorageService:
|
|
|
78
163
|
target = self._target(object_path)
|
|
79
164
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
80
165
|
target.write_bytes(content)
|
|
81
|
-
return
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
166
|
+
return _storage_metadata(
|
|
167
|
+
bucket="local",
|
|
168
|
+
object_path=object_path,
|
|
169
|
+
file_url=target.as_uri(),
|
|
170
|
+
content_type=_content_type(filename, content_type),
|
|
171
|
+
size=len(content),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
async def upload_file_stream(
|
|
175
|
+
self,
|
|
176
|
+
filename: str,
|
|
177
|
+
stream: BinaryIO,
|
|
178
|
+
content_type: str | None = None,
|
|
179
|
+
*,
|
|
180
|
+
size: int | None = None,
|
|
181
|
+
key: str | None = None,
|
|
182
|
+
chunk_size: int | None = None,
|
|
183
|
+
on_progress: ProgressCallback | None = None,
|
|
184
|
+
) -> dict[str, Any]:
|
|
185
|
+
total = size if size is not None else _infer_stream_size(stream)
|
|
186
|
+
if total is None:
|
|
187
|
+
raise ValueError("size is required for non-seekable storage streams")
|
|
188
|
+
chunk_bytes = _normalize_chunk_size(chunk_size)
|
|
189
|
+
chunk_count = max(1, (total + chunk_bytes - 1) // chunk_bytes)
|
|
190
|
+
object_path = self.object_path(filename, key=key)
|
|
191
|
+
target = self._target(object_path)
|
|
192
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
loaded = 0
|
|
194
|
+
await _maybe_report_progress(
|
|
195
|
+
on_progress,
|
|
196
|
+
loaded=0,
|
|
197
|
+
total=total,
|
|
198
|
+
chunk_index=0,
|
|
199
|
+
chunk_count=chunk_count,
|
|
200
|
+
state="starting",
|
|
201
|
+
)
|
|
202
|
+
if total == 0:
|
|
203
|
+
target.write_bytes(b"")
|
|
204
|
+
await _maybe_report_progress(
|
|
205
|
+
on_progress,
|
|
206
|
+
loaded=0,
|
|
207
|
+
total=0,
|
|
208
|
+
chunk_index=0,
|
|
209
|
+
chunk_count=chunk_count,
|
|
210
|
+
state="complete",
|
|
211
|
+
)
|
|
212
|
+
return _storage_metadata(
|
|
213
|
+
bucket="local",
|
|
214
|
+
object_path=object_path,
|
|
215
|
+
file_url=target.as_uri(),
|
|
216
|
+
content_type=_content_type(filename, content_type),
|
|
217
|
+
size=0,
|
|
218
|
+
)
|
|
219
|
+
with target.open("wb") as out:
|
|
220
|
+
chunk_index = 0
|
|
221
|
+
while True:
|
|
222
|
+
chunk = stream.read(chunk_bytes)
|
|
223
|
+
if not chunk:
|
|
224
|
+
break
|
|
225
|
+
chunk_index += 1
|
|
226
|
+
out.write(chunk)
|
|
227
|
+
loaded += len(chunk)
|
|
228
|
+
await _maybe_report_progress(
|
|
229
|
+
on_progress,
|
|
230
|
+
loaded=loaded,
|
|
231
|
+
total=total,
|
|
232
|
+
chunk_index=chunk_index,
|
|
233
|
+
chunk_count=chunk_count,
|
|
234
|
+
state="complete" if loaded >= total else "uploading",
|
|
235
|
+
)
|
|
236
|
+
if loaded != total:
|
|
237
|
+
raise RuntimeError(f"storage upload incomplete: uploaded {loaded} of {total} bytes")
|
|
238
|
+
return _storage_metadata(
|
|
239
|
+
bucket="local",
|
|
240
|
+
object_path=object_path,
|
|
241
|
+
file_url=target.as_uri(),
|
|
242
|
+
content_type=_content_type(filename, content_type),
|
|
243
|
+
size=loaded,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
async def upload_file_path(
|
|
247
|
+
self,
|
|
248
|
+
path: str | Path,
|
|
249
|
+
content_type: str | None = None,
|
|
250
|
+
*,
|
|
251
|
+
filename: str | None = None,
|
|
252
|
+
key: str | None = None,
|
|
253
|
+
chunk_size: int | None = None,
|
|
254
|
+
on_progress: ProgressCallback | None = None,
|
|
255
|
+
) -> dict[str, Any]:
|
|
256
|
+
source = Path(path)
|
|
257
|
+
resolved_filename = filename or source.name
|
|
258
|
+
with source.open("rb") as stream:
|
|
259
|
+
return await self.upload_file_stream(
|
|
260
|
+
resolved_filename,
|
|
261
|
+
stream,
|
|
262
|
+
content_type,
|
|
263
|
+
size=source.stat().st_size,
|
|
264
|
+
key=key,
|
|
265
|
+
chunk_size=chunk_size,
|
|
266
|
+
on_progress=on_progress,
|
|
267
|
+
)
|
|
88
268
|
|
|
89
269
|
async def create_resumable_upload(
|
|
90
270
|
self,
|
|
@@ -95,11 +275,11 @@ class LocalStorageService:
|
|
|
95
275
|
key: str | None = None,
|
|
96
276
|
) -> dict[str, Any]:
|
|
97
277
|
object_path = self.object_path(filename, key=key)
|
|
98
|
-
return
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
278
|
+
return _resumable_metadata(
|
|
279
|
+
bucket="local",
|
|
280
|
+
object_path=object_path,
|
|
281
|
+
file_url=self._target(object_path).as_uri(),
|
|
282
|
+
upload_url=None,
|
|
283
|
+
content_type=_content_type(filename, content_type),
|
|
284
|
+
size=size,
|
|
285
|
+
)
|
|
@@ -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:
|
|
@@ -1028,3 +1044,5 @@ async def create_note(
|
|
|
1028
1044
|
|
|
1029
1045
|
This route stores app-owned data in the app database and writes a related file
|
|
1030
1046
|
into Palette Data Rooms from the Python backend.
|
|
1047
|
+
|
|
1048
|
+
Backend storage responses include both snake_case and camelCase aliases for object metadata (`object_path`/`objectPath`, `file_url`/`fileUrl`, `content_type`/`contentType`). The concrete values differ by environment, but the key contract is stable across local dev and hosted OS.
|
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}`)
|