@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 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
- "bucket": "local",
83
- "object_path": object_path,
84
- "file_url": target.as_uri(),
85
- "content_type": _content_type(filename, content_type),
86
- "size": len(content),
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
- "bucket": "local",
100
- "object_path": object_path,
101
- "file_url": self._target(object_path).as_uri(),
102
- "upload_url": None,
103
- "content_type": _content_type(filename, content_type),
104
- "size": size,
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
- session = await ctx.storage.create_resumable_upload(
763
- "video.mp4",
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.
@@ -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")
@@ -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 review on ${env.url}`)
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
@@ -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}`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palettelab/cli",
3
- "version": "0.3.44",
3
+ "version": "0.3.46",
4
4
  "description": "Developer CLI for building Palette platform plugins — no platform source access required.",
5
5
  "bin": {
6
6
  "pltt": "bin/pltt.js"