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 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
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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")