@palettelab/cli 0.3.43 → 0.3.44
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 +2 -0
- package/docs/python-backend-sdk.md +2 -0
- package/lib/dev-simulator.js +112 -3
- 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
|
+
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
|
+
|
|
331
333
|
Python backend app-storage example:
|
|
332
334
|
|
|
333
335
|
```python
|
|
@@ -740,6 +740,8 @@ Local mocks live in `.palette/app-services.local.json`:
|
|
|
740
740
|
}
|
|
741
741
|
```
|
|
742
742
|
|
|
743
|
+
App storage is separate from Data Rooms. Use `ctx.storage` and `palette.storage` for app-owned files that go directly to the OS-configured storage backend, currently GCS in hosted environments. Use `ctx.data_rooms` or `palette.dataRooms` only when the file should be visible and governed as a Data Room document.
|
|
744
|
+
|
|
743
745
|
Palette scopes storage the same way. Files written through `ctx.storage` or the
|
|
744
746
|
frontend storage client live under:
|
|
745
747
|
|
package/lib/dev-simulator.js
CHANGED
|
@@ -145,7 +145,7 @@ function ensurePythonEnv(cwd, devDir, manifest) {
|
|
|
145
145
|
return venvPython
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
function writeBackendRunner(cwd, devDir, manifest, backendEntry) {
|
|
148
|
+
function writeBackendRunner(cwd, devDir, manifest, backendEntry, backendPort) {
|
|
149
149
|
const runner = path.join(devDir, "backend_runner.py")
|
|
150
150
|
const sdkPath = localBackendSdkPath()
|
|
151
151
|
const databasePath = path.join(devDir, `${manifest.id}.sqlite3`)
|
|
@@ -159,10 +159,12 @@ import importlib.util
|
|
|
159
159
|
import json
|
|
160
160
|
import os
|
|
161
161
|
import pathlib
|
|
162
|
+
import re
|
|
162
163
|
import sys
|
|
164
|
+
import uuid
|
|
163
165
|
from types import SimpleNamespace
|
|
164
166
|
|
|
165
|
-
from fastapi import FastAPI, Request
|
|
167
|
+
from fastapi import FastAPI, HTTPException, Request, Response
|
|
166
168
|
from fastapi.middleware.cors import CORSMiddleware
|
|
167
169
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
168
170
|
|
|
@@ -175,6 +177,7 @@ DATABASE_URL = os.environ.get("PALETTE_DEV_DATABASE_URL", "sqlite+aiosqlite:///$
|
|
|
175
177
|
DEV_SECRETS = json.loads(${JSON.stringify(JSON.stringify(devSecrets))})
|
|
176
178
|
DEV_CONNECTIONS = json.loads(${JSON.stringify(JSON.stringify(devConnections))})
|
|
177
179
|
DEV_APP_MOCKS = json.loads(${JSON.stringify(JSON.stringify(devAppMocks))})
|
|
180
|
+
BACKEND_BASE = "http://127.0.0.1:${backendPort}"
|
|
178
181
|
|
|
179
182
|
def _service_enabled(name: str) -> bool:
|
|
180
183
|
services = MANIFEST.get("platform_services") or []
|
|
@@ -209,6 +212,34 @@ if _service_enabled("storage"):
|
|
|
209
212
|
organization_name="Palette Dev",
|
|
210
213
|
)
|
|
211
214
|
|
|
215
|
+
LOCAL_UPLOADS = {}
|
|
216
|
+
CONTENT_RANGE_RE = re.compile(r"^bytes (?P<start>\\d+)-(?P<end>\\d+)/(?P<total>\\d+)$")
|
|
217
|
+
|
|
218
|
+
def _local_storage_enabled():
|
|
219
|
+
return _service_enabled("storage") and DEV_STORAGE is not None
|
|
220
|
+
|
|
221
|
+
def _parse_content_range(value: str | None):
|
|
222
|
+
if not value:
|
|
223
|
+
raise HTTPException(status_code=411, detail="Content-Range header is required")
|
|
224
|
+
match = CONTENT_RANGE_RE.match(value)
|
|
225
|
+
if not match:
|
|
226
|
+
raise HTTPException(status_code=400, detail="Invalid Content-Range header")
|
|
227
|
+
start = int(match.group("start"))
|
|
228
|
+
end = int(match.group("end"))
|
|
229
|
+
total = int(match.group("total"))
|
|
230
|
+
if end < start or total < 0 or end >= total:
|
|
231
|
+
raise HTTPException(status_code=400, detail="Invalid Content-Range byte range")
|
|
232
|
+
return start, end, total
|
|
233
|
+
|
|
234
|
+
def _require_local_storage(plugin_id: str):
|
|
235
|
+
if plugin_id != MANIFEST.get("id", ""):
|
|
236
|
+
raise HTTPException(status_code=404, detail="App not found")
|
|
237
|
+
if not _local_storage_enabled():
|
|
238
|
+
raise HTTPException(status_code=403, detail='App must declare platform_services: ["storage"]')
|
|
239
|
+
|
|
240
|
+
def _api_url(path: str):
|
|
241
|
+
return f"{BACKEND_BASE}/api/v1{path}"
|
|
242
|
+
|
|
212
243
|
class LocalAppInteropService:
|
|
213
244
|
def service(self, service_id: str):
|
|
214
245
|
return LocalAppServiceClient(service_id)
|
|
@@ -310,6 +341,84 @@ app.add_middleware(
|
|
|
310
341
|
app.add_middleware(DevPluginContextMiddleware)
|
|
311
342
|
app.include_router(router, prefix=f"/api/v1/plugins/{MANIFEST['id']}")
|
|
312
343
|
|
|
344
|
+
@app.post("/api/v1/app-storage/{plugin_id}/uploads")
|
|
345
|
+
async def create_local_app_storage_upload(plugin_id: str, request: Request):
|
|
346
|
+
_require_local_storage(plugin_id)
|
|
347
|
+
body = await request.json()
|
|
348
|
+
filename = body.get("filename") or "upload"
|
|
349
|
+
content_type = body.get("content_type") or "application/octet-stream"
|
|
350
|
+
size = int(body.get("size") or 0)
|
|
351
|
+
key = body.get("key")
|
|
352
|
+
chunk_size = int(body.get("chunk_size") or 8 * 1024 * 1024)
|
|
353
|
+
upload_id = uuid.uuid4().hex
|
|
354
|
+
object_path = DEV_STORAGE.object_path(filename, key=key)
|
|
355
|
+
temp_path = DEV_STORAGE._target(f".tmp/app-storage/{upload_id}.part")
|
|
356
|
+
temp_path.parent.mkdir(parents=True, exist_ok=True)
|
|
357
|
+
temp_path.write_bytes(b"")
|
|
358
|
+
LOCAL_UPLOADS[upload_id] = {
|
|
359
|
+
"plugin_id": plugin_id,
|
|
360
|
+
"object_path": object_path,
|
|
361
|
+
"content_type": content_type,
|
|
362
|
+
"size": size,
|
|
363
|
+
"uploaded_bytes": 0,
|
|
364
|
+
"complete": False,
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
"upload_id": upload_id,
|
|
368
|
+
"mode": "local_resumable",
|
|
369
|
+
"bucket": "local",
|
|
370
|
+
"object_path": object_path,
|
|
371
|
+
"file_url": DEV_STORAGE._target(object_path).as_uri(),
|
|
372
|
+
"upload_url": _api_url(f"/app-storage/{plugin_id}/uploads/{upload_id}/chunks"),
|
|
373
|
+
"status_url": _api_url(f"/app-storage/{plugin_id}/uploads/{upload_id}/status"),
|
|
374
|
+
"content_type": content_type,
|
|
375
|
+
"size": size,
|
|
376
|
+
"chunk_size": chunk_size,
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
@app.get("/api/v1/app-storage/{plugin_id}/uploads/{upload_id}/status")
|
|
380
|
+
async def local_app_storage_upload_status(plugin_id: str, upload_id: str):
|
|
381
|
+
_require_local_storage(plugin_id)
|
|
382
|
+
session = LOCAL_UPLOADS.get(upload_id)
|
|
383
|
+
if not session or session["plugin_id"] != plugin_id:
|
|
384
|
+
raise HTTPException(status_code=404, detail="Upload session not found")
|
|
385
|
+
return {
|
|
386
|
+
"upload_id": upload_id,
|
|
387
|
+
"object_path": session["object_path"],
|
|
388
|
+
"uploaded_bytes": session["uploaded_bytes"],
|
|
389
|
+
"size": session["size"],
|
|
390
|
+
"complete": session["complete"],
|
|
391
|
+
"file_url": DEV_STORAGE._target(session["object_path"]).as_uri(),
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
@app.put("/api/v1/app-storage/{plugin_id}/uploads/{upload_id}/chunks")
|
|
395
|
+
async def upload_local_app_storage_chunk(plugin_id: str, upload_id: str, request: Request):
|
|
396
|
+
_require_local_storage(plugin_id)
|
|
397
|
+
session = LOCAL_UPLOADS.get(upload_id)
|
|
398
|
+
if not session or session["plugin_id"] != plugin_id:
|
|
399
|
+
raise HTTPException(status_code=404, detail="Upload session not found")
|
|
400
|
+
if session["complete"]:
|
|
401
|
+
return Response(status_code=204)
|
|
402
|
+
start, end, total = _parse_content_range(request.headers.get("content-range"))
|
|
403
|
+
if total != session["size"]:
|
|
404
|
+
raise HTTPException(status_code=400, detail="Chunk total does not match upload size")
|
|
405
|
+
if start != session["uploaded_bytes"]:
|
|
406
|
+
raise HTTPException(status_code=409, detail=f"Expected chunk to start at byte {session['uploaded_bytes']}")
|
|
407
|
+
payload = await request.body()
|
|
408
|
+
if len(payload) != end - start + 1:
|
|
409
|
+
raise HTTPException(status_code=400, detail="Chunk size does not match Content-Range")
|
|
410
|
+
temp_path = DEV_STORAGE._target(f".tmp/app-storage/{upload_id}.part")
|
|
411
|
+
with temp_path.open("ab") as fh:
|
|
412
|
+
fh.write(payload)
|
|
413
|
+
session["uploaded_bytes"] = end + 1
|
|
414
|
+
if session["uploaded_bytes"] >= session["size"]:
|
|
415
|
+
final_path = DEV_STORAGE._target(session["object_path"])
|
|
416
|
+
final_path.parent.mkdir(parents=True, exist_ok=True)
|
|
417
|
+
temp_path.replace(final_path)
|
|
418
|
+
session["complete"] = True
|
|
419
|
+
return Response(status_code=201)
|
|
420
|
+
return Response(status_code=308, headers={"Range": f"bytes=0-{session['uploaded_bytes'] - 1}"})
|
|
421
|
+
|
|
313
422
|
@app.on_event("startup")
|
|
314
423
|
async def create_local_database_tables():
|
|
315
424
|
if engine is None:
|
|
@@ -328,7 +437,7 @@ function startBackend(cwd, devDir, manifest, backendPort) {
|
|
|
328
437
|
if (!fs.existsSync(absEntry)) throw new Error(`backend entry not found: ${backendEntry}`)
|
|
329
438
|
|
|
330
439
|
const python = ensurePythonEnv(cwd, devDir, manifest)
|
|
331
|
-
const runner = writeBackendRunner(cwd, devDir, manifest, backendEntry)
|
|
440
|
+
const runner = writeBackendRunner(cwd, devDir, manifest, backendEntry, backendPort)
|
|
332
441
|
const sdkPath = localBackendSdkPath()
|
|
333
442
|
const env = { ...process.env }
|
|
334
443
|
if (sdkPath) {
|