figfoundry 0.1.0__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.
- figfoundry/__init__.py +7 -0
- figfoundry/__main__.py +4 -0
- figfoundry/app.py +378 -0
- figfoundry/canvas.py +711 -0
- figfoundry/cli.py +141 -0
- figfoundry/data_io.py +194 -0
- figfoundry/datasets/sample.npz +0 -0
- figfoundry/static/canvas.css +111 -0
- figfoundry/static/canvas.html +144 -0
- figfoundry/static/canvas.js +1355 -0
- figfoundry/static/index.html +113 -0
- figfoundry/static/style.css +338 -0
- figfoundry-0.1.0.dist-info/METADATA +125 -0
- figfoundry-0.1.0.dist-info/RECORD +17 -0
- figfoundry-0.1.0.dist-info/WHEEL +4 -0
- figfoundry-0.1.0.dist-info/entry_points.txt +2 -0
- figfoundry-0.1.0.dist-info/licenses/LICENSE +21 -0
figfoundry/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Fig Foundry -- a WYSIWYG matplotlib figure composer.
|
|
2
|
+
|
|
3
|
+
Load a dataset (.npz / .pkl), drag signals onto a canvas to make plots, arrange
|
|
4
|
+
and style them freely, and export clean, reproducible Python. The preview renders
|
|
5
|
+
by executing the generated code, so what you see is exactly what the .py produces.
|
|
6
|
+
"""
|
|
7
|
+
__version__ = "0.1.0"
|
figfoundry/__main__.py
ADDED
figfoundry/app.py
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""Fig Foundry -- FastAPI backend.
|
|
2
|
+
|
|
3
|
+
The spec (a scene graph of the figure) is the single source of truth. The browser
|
|
4
|
+
manipulates it; the backend renders it by EXECUTING the generated code (so the
|
|
5
|
+
preview is byte-identical to the export); and it can serialize the spec to a
|
|
6
|
+
standalone .py that references the dataset.
|
|
7
|
+
"""
|
|
8
|
+
import contextlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from fastapi import FastAPI, File, HTTPException, UploadFile
|
|
16
|
+
from fastapi.responses import FileResponse, JSONResponse, Response
|
|
17
|
+
from fastapi.staticfiles import StaticFiles
|
|
18
|
+
from pydantic import BaseModel
|
|
19
|
+
|
|
20
|
+
from . import canvas, data_io
|
|
21
|
+
|
|
22
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
23
|
+
USER_DIR = data_io.USER_DIR
|
|
24
|
+
CANVAS_DIR = os.path.join(USER_DIR, "canvases")
|
|
25
|
+
OUT_DIR = os.path.join(USER_DIR, "out")
|
|
26
|
+
os.makedirs(CANVAS_DIR, exist_ok=True)
|
|
27
|
+
os.makedirs(OUT_DIR, exist_ok=True)
|
|
28
|
+
|
|
29
|
+
app = FastAPI(title="Fig Foundry")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.middleware("http")
|
|
33
|
+
async def no_cache(request, call_next):
|
|
34
|
+
"""Local tool: never let the browser serve stale JS/HTML/CSS after an update."""
|
|
35
|
+
resp = await call_next(request)
|
|
36
|
+
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
|
37
|
+
resp.headers["Pragma"] = "no-cache"
|
|
38
|
+
resp.headers["Expires"] = "0"
|
|
39
|
+
return resp
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CanvasBody(BaseModel):
|
|
43
|
+
spec: dict | None = None
|
|
44
|
+
draft: bool = False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class NewCanvasBody(BaseModel):
|
|
48
|
+
mode: str = "blank"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class UpdateCanvasBody(BaseModel):
|
|
52
|
+
name: str | None = None
|
|
53
|
+
spec: dict | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _cpath(slug):
|
|
57
|
+
return os.path.join(CANVAS_DIR, f"{slug}.json")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _check_slug(slug):
|
|
61
|
+
# Slugs are generated as <timestamp>-<hex>; reject anything with path separators
|
|
62
|
+
# (including %-decoded ones, e.g. "..%2F..%2Fetc") so a crafted slug can't
|
|
63
|
+
# traverse out of the canvases dir.
|
|
64
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]+", slug):
|
|
65
|
+
raise HTTPException(404, "canvas not found")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _read(slug):
|
|
69
|
+
_check_slug(slug)
|
|
70
|
+
p = _cpath(slug)
|
|
71
|
+
if not os.path.exists(p):
|
|
72
|
+
raise HTTPException(404, "canvas not found")
|
|
73
|
+
try:
|
|
74
|
+
with open(p) as f:
|
|
75
|
+
doc = json.load(f)
|
|
76
|
+
except (OSError, ValueError) as e: # ValueError covers json.JSONDecodeError
|
|
77
|
+
raise HTTPException(500, "canvas file is corrupt") from e
|
|
78
|
+
# Every endpoint below does doc["spec"] / doc["spec"].get(...); a partial or
|
|
79
|
+
# hand-edited file without a dict spec would be a raw 500 KeyError/AttributeError.
|
|
80
|
+
if not isinstance(doc, dict) or not isinstance(doc.get("spec"), dict):
|
|
81
|
+
raise HTTPException(500, "canvas file is corrupt: missing spec")
|
|
82
|
+
return doc
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _check_dataset_name(name):
|
|
86
|
+
if os.path.basename(name) != name or name in (".", ".."):
|
|
87
|
+
raise HTTPException(400, "invalid dataset name")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
_write_lock = threading.Lock()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _write(slug, doc):
|
|
94
|
+
# Serialize writes (threadpool) and make each atomic (temp + rename) so a reader
|
|
95
|
+
# never sees a partial/corrupt file. On Windows a concurrent reader's open handle
|
|
96
|
+
# can briefly refuse the rename, so retry it; clean up the temp on any failure.
|
|
97
|
+
with _write_lock:
|
|
98
|
+
tmp = _cpath(slug) + ".tmp"
|
|
99
|
+
try:
|
|
100
|
+
with open(tmp, "w") as f:
|
|
101
|
+
json.dump(doc, f, indent=2)
|
|
102
|
+
last = None
|
|
103
|
+
for _ in range(20):
|
|
104
|
+
try:
|
|
105
|
+
os.replace(tmp, _cpath(slug))
|
|
106
|
+
last = None
|
|
107
|
+
break
|
|
108
|
+
except PermissionError as e:
|
|
109
|
+
last = e
|
|
110
|
+
time.sleep(0.02)
|
|
111
|
+
if last is not None:
|
|
112
|
+
raise last
|
|
113
|
+
except BaseException:
|
|
114
|
+
if os.path.exists(tmp):
|
|
115
|
+
os.remove(tmp)
|
|
116
|
+
raise
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _list_canvases():
|
|
120
|
+
out = []
|
|
121
|
+
for f in sorted(os.listdir(CANVAS_DIR), reverse=True):
|
|
122
|
+
if not f.endswith(".json"):
|
|
123
|
+
continue
|
|
124
|
+
try:
|
|
125
|
+
with open(os.path.join(CANVAS_DIR, f)) as fh:
|
|
126
|
+
doc = json.load(fh)
|
|
127
|
+
out.append({"slug": f[:-5], "name": doc.get("name", "Untitled"),
|
|
128
|
+
"plots": len(doc.get("spec", {}).get("plots", [])), "updated": doc.get("updated")})
|
|
129
|
+
except Exception:
|
|
130
|
+
continue
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# --------------------------------------------------------------------------- pages
|
|
135
|
+
@app.get("/")
|
|
136
|
+
def index():
|
|
137
|
+
return FileResponse(os.path.join(HERE, "static", "index.html"))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.get("/canvas")
|
|
141
|
+
def canvas_page():
|
|
142
|
+
return FileResponse(os.path.join(HERE, "static", "canvas.html"))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# --------------------------------------------------------------------------- datasets
|
|
146
|
+
# The picker calls this on every open; re-parsing (unpickling!) each dataset file
|
|
147
|
+
# every time is pure waste for large files. Cache per-file summaries keyed by
|
|
148
|
+
# (mtime, size) -- a re-upload of the same name changes both, so it invalidates.
|
|
149
|
+
_ds_list_cache = {}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@app.get("/api/datasets")
|
|
153
|
+
def list_datasets():
|
|
154
|
+
out = []
|
|
155
|
+
for f in sorted(os.listdir(data_io.DATA_DIR)):
|
|
156
|
+
if f.endswith((".npz", ".pkl", ".pickle", ".csv")):
|
|
157
|
+
p = os.path.join(data_io.DATA_DIR, f)
|
|
158
|
+
try:
|
|
159
|
+
st = os.stat(p)
|
|
160
|
+
except OSError:
|
|
161
|
+
continue
|
|
162
|
+
hit = _ds_list_cache.get(f)
|
|
163
|
+
if hit is not None and hit[0] == st.st_mtime and hit[1] == st.st_size:
|
|
164
|
+
out.append(hit[2])
|
|
165
|
+
continue
|
|
166
|
+
try:
|
|
167
|
+
arrays, kind = data_io.load_arrays(p)
|
|
168
|
+
info = {"name": f, "kind": kind, "n_signals": len(arrays)}
|
|
169
|
+
except Exception as e:
|
|
170
|
+
info = {"name": f, "kind": "?", "n_signals": 0, "error": str(e)}
|
|
171
|
+
_ds_list_cache[f] = (st.st_mtime, st.st_size, info)
|
|
172
|
+
out.append(info)
|
|
173
|
+
return {"datasets": out}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@app.get("/api/datasets/{name}/signals")
|
|
177
|
+
def dataset_signals(name):
|
|
178
|
+
"""The signals in a specific dataset file (independent of any canvas)."""
|
|
179
|
+
_check_dataset_name(name)
|
|
180
|
+
path = os.path.join(data_io.DATA_DIR, name)
|
|
181
|
+
if not os.path.exists(path):
|
|
182
|
+
raise HTTPException(404, "dataset not found")
|
|
183
|
+
try:
|
|
184
|
+
arrays, kind = data_io.load_arrays(path)
|
|
185
|
+
except Exception as e:
|
|
186
|
+
raise HTTPException(400, f"could not load dataset: {e}") from e
|
|
187
|
+
return {"dataset": name, "kind": kind, "signals": data_io.list_signals(arrays)}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@app.post("/api/datasets")
|
|
191
|
+
async def upload_dataset(file: UploadFile = File(...)):
|
|
192
|
+
"""Upload a .npz / .pkl / .csv dataset. pkl goes through the whitelist
|
|
193
|
+
SafeUnpickler. The payload is validated before it is written, so a bad upload
|
|
194
|
+
can never clobber an existing dataset of the same name."""
|
|
195
|
+
name = os.path.basename(file.filename or "upload")
|
|
196
|
+
if not name.endswith((".npz", ".pkl", ".pickle", ".csv")):
|
|
197
|
+
raise HTTPException(400, "only .npz / .pkl / .csv datasets are supported")
|
|
198
|
+
payload = await file.read()
|
|
199
|
+
try:
|
|
200
|
+
arrays, kind = data_io.load_arrays_bytes(payload, name)
|
|
201
|
+
except Exception as e:
|
|
202
|
+
raise HTTPException(400, f"could not load dataset: {e}") from e
|
|
203
|
+
try:
|
|
204
|
+
_write_atomic_bytes(os.path.join(data_io.DATA_DIR, name), payload)
|
|
205
|
+
except OSError as e: # e.g. a filename character the OS refuses (newline on Windows)
|
|
206
|
+
raise HTTPException(400, f"could not save dataset: {e}") from e
|
|
207
|
+
# Belt and braces: canvas.get_dataset_arrays already invalidates by
|
|
208
|
+
# (mtime, size) on read, but dropping the entry here too guarantees the next
|
|
209
|
+
# signal-list call re-stats and re-loads the freshly uploaded file.
|
|
210
|
+
canvas._ds_cache.pop(name, None)
|
|
211
|
+
return {"name": name, "kind": kind, "n_signals": len(arrays), "signals": data_io.list_signals(arrays)}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _write_atomic_bytes(path, payload):
|
|
215
|
+
"""Atomic byte write (unique temp + rename) so a reader never sees a partial
|
|
216
|
+
file and two concurrent uploads of the same name can't clobber each other's
|
|
217
|
+
temp. The temp name is unique per thread: a shared "<path>.tmp" would let one
|
|
218
|
+
thread's os.replace grab the other's in-progress write. On Windows a
|
|
219
|
+
concurrent reader's handle can briefly refuse the rename, so retry it."""
|
|
220
|
+
tmp = f"{path}.{threading.get_ident()}.tmp"
|
|
221
|
+
try:
|
|
222
|
+
with open(tmp, "wb") as f:
|
|
223
|
+
f.write(payload)
|
|
224
|
+
last = None
|
|
225
|
+
for _ in range(20):
|
|
226
|
+
try:
|
|
227
|
+
os.replace(tmp, path)
|
|
228
|
+
last = None
|
|
229
|
+
break
|
|
230
|
+
except PermissionError as e:
|
|
231
|
+
last = e
|
|
232
|
+
time.sleep(0.02)
|
|
233
|
+
if last is not None:
|
|
234
|
+
raise last
|
|
235
|
+
except BaseException:
|
|
236
|
+
with contextlib.suppress(OSError):
|
|
237
|
+
os.remove(tmp)
|
|
238
|
+
raise
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# --------------------------------------------------------------------------- canvases
|
|
242
|
+
@app.get("/api/canvases")
|
|
243
|
+
def list_canvases():
|
|
244
|
+
return {"canvases": _list_canvases()}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@app.post("/api/canvases")
|
|
248
|
+
def new_canvas(body: NewCanvasBody):
|
|
249
|
+
slug = time.strftime("%Y%m%d-%H%M%S") + "-" + os.urandom(2).hex()
|
|
250
|
+
spec = canvas.blank_spec() # always blank; load a dataset to get signals
|
|
251
|
+
_write(slug, {"name": "Untitled", "spec": spec, "updated": time.time()})
|
|
252
|
+
return {"slug": slug, "name": "Untitled"}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@app.get("/api/canvases/{slug}")
|
|
256
|
+
def get_canvas(slug):
|
|
257
|
+
doc = _read(slug)
|
|
258
|
+
return {"slug": slug, "name": doc.get("name", "Untitled"), "spec": doc["spec"]}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@app.put("/api/canvases/{slug}")
|
|
262
|
+
def update_canvas(slug, body: UpdateCanvasBody):
|
|
263
|
+
doc = _read(slug)
|
|
264
|
+
if body.name is not None:
|
|
265
|
+
doc["name"] = body.name
|
|
266
|
+
if body.spec is not None:
|
|
267
|
+
doc["spec"] = body.spec
|
|
268
|
+
doc["updated"] = time.time()
|
|
269
|
+
_write(slug, doc)
|
|
270
|
+
return {"ok": True}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@app.delete("/api/canvases/{slug}")
|
|
274
|
+
def delete_canvas(slug):
|
|
275
|
+
_check_slug(slug)
|
|
276
|
+
p = _cpath(slug)
|
|
277
|
+
if os.path.exists(p):
|
|
278
|
+
os.remove(p)
|
|
279
|
+
return {"ok": True}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@app.post("/api/canvases/{slug}/duplicate")
|
|
283
|
+
def duplicate_canvas(slug):
|
|
284
|
+
doc = _read(slug)
|
|
285
|
+
newslug = time.strftime("%Y%m%d-%H%M%S") + "-" + os.urandom(2).hex()
|
|
286
|
+
name = str(doc.get("name") or "Untitled") # a hand-edited file may hold a non-str
|
|
287
|
+
newdoc = {"name": name + " copy", "spec": json.loads(json.dumps(doc["spec"])), "updated": time.time()}
|
|
288
|
+
_write(newslug, newdoc)
|
|
289
|
+
return {"slug": newslug, "name": newdoc["name"]}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@app.get("/api/canvases/{slug}/signals")
|
|
293
|
+
def canvas_signals(slug):
|
|
294
|
+
"""The signal library for a canvas = the signals in its dataset (empty if none)."""
|
|
295
|
+
doc = _read(slug)
|
|
296
|
+
ds = doc["spec"].get("dataset")
|
|
297
|
+
if not ds:
|
|
298
|
+
return {"dataset": None, "signals": []}
|
|
299
|
+
try:
|
|
300
|
+
arrays = canvas.get_dataset_arrays(ds)
|
|
301
|
+
except HTTPException:
|
|
302
|
+
raise
|
|
303
|
+
except Exception as e: # dataset deleted from disk, or a non-str name in a hand-edited spec
|
|
304
|
+
raise HTTPException(400, f"could not load dataset: {e}") from e
|
|
305
|
+
return {"dataset": ds, "signals": data_io.list_signals(arrays)}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@app.post("/api/canvases/{slug}/render")
|
|
309
|
+
def render_canvas(slug, body: CanvasBody | None = None):
|
|
310
|
+
spec = body.spec if (body is not None and body.spec is not None) else _read(slug)["spec"]
|
|
311
|
+
draft = body.draft if body is not None else False
|
|
312
|
+
try:
|
|
313
|
+
return canvas.render_to_png(spec, draft=draft)
|
|
314
|
+
except Exception as e:
|
|
315
|
+
raise HTTPException(400, f"render failed: {e}") from e
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@app.get("/api/canvases/{slug}/code")
|
|
319
|
+
def code_canvas(slug):
|
|
320
|
+
try:
|
|
321
|
+
code = canvas.codegen_canvas(_read(slug)["spec"])
|
|
322
|
+
except HTTPException:
|
|
323
|
+
raise # _read's 404/500 must not be re-wrapped as a codegen failure
|
|
324
|
+
except Exception as e:
|
|
325
|
+
raise HTTPException(400, f"codegen failed: {e}") from e
|
|
326
|
+
return JSONResponse({"code": code})
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@app.get("/api/canvases/{slug}/download")
|
|
330
|
+
def download_canvas(slug):
|
|
331
|
+
doc = _read(slug)
|
|
332
|
+
try:
|
|
333
|
+
code = canvas.codegen_canvas(doc["spec"])
|
|
334
|
+
except HTTPException:
|
|
335
|
+
raise
|
|
336
|
+
except Exception as e:
|
|
337
|
+
raise HTTPException(400, f"codegen failed: {e}") from e
|
|
338
|
+
# Same filename-safe character set as export_figure: the name is client-
|
|
339
|
+
# controlled, and FileResponse's Content-Disposition handling of it has
|
|
340
|
+
# changed across starlette versions (older ones quoted it raw).
|
|
341
|
+
base = str(doc.get("name") or "canvas").strip().replace(" ", "_") or "canvas"
|
|
342
|
+
fname = re.sub(r"[^A-Za-z0-9._-]", "_", base) + ".py"
|
|
343
|
+
path = os.path.join(OUT_DIR, f"canvas_{slug}.py")
|
|
344
|
+
with open(path, "w") as f:
|
|
345
|
+
f.write(code)
|
|
346
|
+
return FileResponse(path, filename=fname, media_type="text/x-python")
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
_FIG_CT = {"png": "image/png", "svg": "image/svg+xml", "pdf": "application/pdf"}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@app.get("/api/canvases/{slug}/figure")
|
|
353
|
+
def export_figure(slug, format: str = "png"):
|
|
354
|
+
"""Render the figure (by executing the code) and return it as png / svg / pdf.
|
|
355
|
+
svg and pdf are vector, suitable for publication."""
|
|
356
|
+
if format not in _FIG_CT:
|
|
357
|
+
raise HTTPException(400, "format must be png, svg, or pdf")
|
|
358
|
+
doc = _read(slug)
|
|
359
|
+
try:
|
|
360
|
+
data = canvas.render_to_bytes(doc["spec"], fmt=format)
|
|
361
|
+
except HTTPException:
|
|
362
|
+
raise
|
|
363
|
+
except Exception as e:
|
|
364
|
+
raise HTTPException(400, f"render failed: {e}") from e
|
|
365
|
+
base = str(doc.get("name") or "figure").strip().replace(" ", "_") or "figure"
|
|
366
|
+
# The canvas name is client-controlled; keep the hand-built header value to
|
|
367
|
+
# filename-safe characters so a quote/newline can't break Content-Disposition.
|
|
368
|
+
fname = re.sub(r"[^A-Za-z0-9._-]", "_", base) + "." + format
|
|
369
|
+
return Response(content=data, media_type=_FIG_CT[format],
|
|
370
|
+
headers={"Content-Disposition": f'attachment; filename="{fname}"'})
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@app.get("/api/palettes")
|
|
374
|
+
def palettes():
|
|
375
|
+
return {"palettes": canvas.get_palettes()}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
app.mount("/static", StaticFiles(directory=os.path.join(HERE, "static")), name="static")
|