figfoundry 0.1.0__tar.gz
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-0.1.0/.gitattributes +4 -0
- figfoundry-0.1.0/.gitignore +19 -0
- figfoundry-0.1.0/LICENSE +21 -0
- figfoundry-0.1.0/PKG-INFO +125 -0
- figfoundry-0.1.0/README.md +91 -0
- figfoundry-0.1.0/figfoundry/__init__.py +7 -0
- figfoundry-0.1.0/figfoundry/__main__.py +4 -0
- figfoundry-0.1.0/figfoundry/app.py +378 -0
- figfoundry-0.1.0/figfoundry/canvas.py +711 -0
- figfoundry-0.1.0/figfoundry/cli.py +141 -0
- figfoundry-0.1.0/figfoundry/data_io.py +194 -0
- figfoundry-0.1.0/figfoundry/datasets/sample.npz +0 -0
- figfoundry-0.1.0/figfoundry/static/canvas.css +111 -0
- figfoundry-0.1.0/figfoundry/static/canvas.html +144 -0
- figfoundry-0.1.0/figfoundry/static/canvas.js +1355 -0
- figfoundry-0.1.0/figfoundry/static/index.html +113 -0
- figfoundry-0.1.0/figfoundry/static/style.css +338 -0
- figfoundry-0.1.0/pyproject.toml +64 -0
- figfoundry-0.1.0/uv.lock +1757 -0
figfoundry-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tai D. Nguyen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: figfoundry
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: WYSIWYG matplotlib figure composer: load data (.npz/.pkl), drag signals onto a canvas to build plots, and export clean reproducible code.
|
|
5
|
+
Project-URL: Homepage, https://github.com/ductai199x/figfoundry
|
|
6
|
+
Project-URL: Repository, https://github.com/ductai199x/figfoundry
|
|
7
|
+
Project-URL: Issues, https://github.com/ductai199x/figfoundry/issues
|
|
8
|
+
Author-email: Tai Duc Nguyen <taiducnguyen.drexel@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: data-visualization,figures,matplotlib,plotting,scientific-figures,wysiwyg
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Web Environment
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: fastapi>=0.110
|
|
25
|
+
Requires-Dist: matplotlib>=3.9
|
|
26
|
+
Requires-Dist: numpy>=1.24
|
|
27
|
+
Requires-Dist: pydantic>=2.0
|
|
28
|
+
Requires-Dist: python-multipart>=0.0.9
|
|
29
|
+
Requires-Dist: uvicorn>=0.29
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: httpx2>=2.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# Fig Foundry
|
|
36
|
+
|
|
37
|
+
A WYSIWYG matplotlib figure composer. Load your data (`.npz` / `.pkl` / `.csv`),
|
|
38
|
+
drag signals onto a free canvas to build plots, arrange and style them, and export
|
|
39
|
+
clean, reproducible Python.
|
|
40
|
+
|
|
41
|
+
**The preview renders by executing the generated code**, so what you see is
|
|
42
|
+
byte-for-byte what the exported `.py` produces. There is no second renderer to
|
|
43
|
+
drift.
|
|
44
|
+
|
|
45
|
+
Fig Foundry is a standalone local app with no dependency on any particular
|
|
46
|
+
project's data. It ships with a small neutral `sample.npz` (1-D signals plus a
|
|
47
|
+
2-D field) so a fresh install has something to open; load your own dataset to work
|
|
48
|
+
with real data.
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
uv tool install ./path/to/fig_editor # installs a `figfoundry` command
|
|
54
|
+
# or, from a git repo:
|
|
55
|
+
uv tool install figfoundry --from "git+https://github.com/<you>/<repo>.git"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Then run:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
figfoundry # starts the web app on http://127.0.0.1:8037 and opens the browser
|
|
62
|
+
figfoundry serve --port 9000 --no-browser
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Command line
|
|
66
|
+
|
|
67
|
+
The same engines are available as subcommands for scripting:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
figfoundry list # list saved figures
|
|
71
|
+
figfoundry new --name my-fig # create a blank figure, print its slug
|
|
72
|
+
figfoundry export SLUG [--out PATH] # write a figure's generated .py
|
|
73
|
+
figfoundry render SLUG --format svg|pdf|png # render a figure to a file
|
|
74
|
+
figfoundry datasets # list available datasets
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Data
|
|
78
|
+
|
|
79
|
+
A *dataset* is a named collection of signals (arrays). Supported formats:
|
|
80
|
+
|
|
81
|
+
- **`.npz`** — numpy zip of named arrays. Safe by construction.
|
|
82
|
+
- **`.pkl`** — Python pickle, loaded through a **whitelist unpickler** that only
|
|
83
|
+
permits numpy arrays and plain containers (blocks `os`, `subprocess`, `socket`,
|
|
84
|
+
`ctypes`, …). A malicious pickle cannot execute code.
|
|
85
|
+
- **`.csv`** — a table with a header row; each numeric column becomes a signal.
|
|
86
|
+
|
|
87
|
+
1-D and 2-D arrays are both supported (2-D for image/contour/vector plots). Load a
|
|
88
|
+
dataset with the **Load data…** button in the toolbar; each canvas is tied to one
|
|
89
|
+
dataset, and its signals populate the library.
|
|
90
|
+
|
|
91
|
+
Your figures, datasets, and exports live in `~/.figfoundry/`.
|
|
92
|
+
|
|
93
|
+
## What you can do
|
|
94
|
+
|
|
95
|
+
- Drag a signal onto the canvas to make a plot (or onto an existing plot to
|
|
96
|
+
overlay a line / σ-band / error bars / replace).
|
|
97
|
+
- **Plot types (23):** line, step, scatter, grouped scatter (clustering), bar,
|
|
98
|
+
bar (horizontal), histogram, box plot, violin plot, 2-D histogram, hexbin, image,
|
|
99
|
+
contour (filled / lines), pcolormesh, vector field, streamplot, decision boundary,
|
|
100
|
+
pie, polar, 3-D scatter, surface (3-D), wireframe (3-D).
|
|
101
|
+
- Move, resize (with snapping), and nudge (arrow keys) plots and elements.
|
|
102
|
+
- Multi-select (Shift+click) to nudge / move / delete several at once.
|
|
103
|
+
- Per-plot: data, color, line/marker, σ-band, error bars, ticks, labels, limits,
|
|
104
|
+
spines, title, colormap + colorbar, and artists (vline, hline, text, annotate,
|
|
105
|
+
arrow, box, circle, ellipse, polygon, span, legend).
|
|
106
|
+
- **Suggest accessible colors** — one click applies the colorblind-safe Okabe-Ito
|
|
107
|
+
palette to a plot's series.
|
|
108
|
+
- **LaTeX mathtext** in labels, titles, and text (e.g. `$x_0$`).
|
|
109
|
+
- Layout: grid, align, distribute, margins, sync axis ranges.
|
|
110
|
+
- Figure elements: axis labels, legend, suptitle, free text.
|
|
111
|
+
- Name / duplicate / delete figures. Export the `.py` (copy or download), and the
|
|
112
|
+
figure itself as **PNG / SVG / PDF** (SVG and PDF are vector, for publication).
|
|
113
|
+
|
|
114
|
+
Everything is also available through a small JSON API (`/api/canvases`,
|
|
115
|
+
`/api/datasets`, `/api/palettes`, `/api/canvases/{slug}/render|code|figure|download`).
|
|
116
|
+
|
|
117
|
+
## Testing
|
|
118
|
+
|
|
119
|
+
The test suite lives in `tests/` (outside the package, so it is never shipped).
|
|
120
|
+
It proves the WYSIWYG invariant (for every plot type), the pickle-safety
|
|
121
|
+
whitelist, and dataset I/O (npz/pkl/csv), each against a throwaway data dir:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
uv run --extra dev pytest -q
|
|
125
|
+
```
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Fig Foundry
|
|
2
|
+
|
|
3
|
+
A WYSIWYG matplotlib figure composer. Load your data (`.npz` / `.pkl` / `.csv`),
|
|
4
|
+
drag signals onto a free canvas to build plots, arrange and style them, and export
|
|
5
|
+
clean, reproducible Python.
|
|
6
|
+
|
|
7
|
+
**The preview renders by executing the generated code**, so what you see is
|
|
8
|
+
byte-for-byte what the exported `.py` produces. There is no second renderer to
|
|
9
|
+
drift.
|
|
10
|
+
|
|
11
|
+
Fig Foundry is a standalone local app with no dependency on any particular
|
|
12
|
+
project's data. It ships with a small neutral `sample.npz` (1-D signals plus a
|
|
13
|
+
2-D field) so a fresh install has something to open; load your own dataset to work
|
|
14
|
+
with real data.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uv tool install ./path/to/fig_editor # installs a `figfoundry` command
|
|
20
|
+
# or, from a git repo:
|
|
21
|
+
uv tool install figfoundry --from "git+https://github.com/<you>/<repo>.git"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Then run:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
figfoundry # starts the web app on http://127.0.0.1:8037 and opens the browser
|
|
28
|
+
figfoundry serve --port 9000 --no-browser
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Command line
|
|
32
|
+
|
|
33
|
+
The same engines are available as subcommands for scripting:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
figfoundry list # list saved figures
|
|
37
|
+
figfoundry new --name my-fig # create a blank figure, print its slug
|
|
38
|
+
figfoundry export SLUG [--out PATH] # write a figure's generated .py
|
|
39
|
+
figfoundry render SLUG --format svg|pdf|png # render a figure to a file
|
|
40
|
+
figfoundry datasets # list available datasets
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Data
|
|
44
|
+
|
|
45
|
+
A *dataset* is a named collection of signals (arrays). Supported formats:
|
|
46
|
+
|
|
47
|
+
- **`.npz`** — numpy zip of named arrays. Safe by construction.
|
|
48
|
+
- **`.pkl`** — Python pickle, loaded through a **whitelist unpickler** that only
|
|
49
|
+
permits numpy arrays and plain containers (blocks `os`, `subprocess`, `socket`,
|
|
50
|
+
`ctypes`, …). A malicious pickle cannot execute code.
|
|
51
|
+
- **`.csv`** — a table with a header row; each numeric column becomes a signal.
|
|
52
|
+
|
|
53
|
+
1-D and 2-D arrays are both supported (2-D for image/contour/vector plots). Load a
|
|
54
|
+
dataset with the **Load data…** button in the toolbar; each canvas is tied to one
|
|
55
|
+
dataset, and its signals populate the library.
|
|
56
|
+
|
|
57
|
+
Your figures, datasets, and exports live in `~/.figfoundry/`.
|
|
58
|
+
|
|
59
|
+
## What you can do
|
|
60
|
+
|
|
61
|
+
- Drag a signal onto the canvas to make a plot (or onto an existing plot to
|
|
62
|
+
overlay a line / σ-band / error bars / replace).
|
|
63
|
+
- **Plot types (23):** line, step, scatter, grouped scatter (clustering), bar,
|
|
64
|
+
bar (horizontal), histogram, box plot, violin plot, 2-D histogram, hexbin, image,
|
|
65
|
+
contour (filled / lines), pcolormesh, vector field, streamplot, decision boundary,
|
|
66
|
+
pie, polar, 3-D scatter, surface (3-D), wireframe (3-D).
|
|
67
|
+
- Move, resize (with snapping), and nudge (arrow keys) plots and elements.
|
|
68
|
+
- Multi-select (Shift+click) to nudge / move / delete several at once.
|
|
69
|
+
- Per-plot: data, color, line/marker, σ-band, error bars, ticks, labels, limits,
|
|
70
|
+
spines, title, colormap + colorbar, and artists (vline, hline, text, annotate,
|
|
71
|
+
arrow, box, circle, ellipse, polygon, span, legend).
|
|
72
|
+
- **Suggest accessible colors** — one click applies the colorblind-safe Okabe-Ito
|
|
73
|
+
palette to a plot's series.
|
|
74
|
+
- **LaTeX mathtext** in labels, titles, and text (e.g. `$x_0$`).
|
|
75
|
+
- Layout: grid, align, distribute, margins, sync axis ranges.
|
|
76
|
+
- Figure elements: axis labels, legend, suptitle, free text.
|
|
77
|
+
- Name / duplicate / delete figures. Export the `.py` (copy or download), and the
|
|
78
|
+
figure itself as **PNG / SVG / PDF** (SVG and PDF are vector, for publication).
|
|
79
|
+
|
|
80
|
+
Everything is also available through a small JSON API (`/api/canvases`,
|
|
81
|
+
`/api/datasets`, `/api/palettes`, `/api/canvases/{slug}/render|code|figure|download`).
|
|
82
|
+
|
|
83
|
+
## Testing
|
|
84
|
+
|
|
85
|
+
The test suite lives in `tests/` (outside the package, so it is never shipped).
|
|
86
|
+
It proves the WYSIWYG invariant (for every plot type), the pickle-safety
|
|
87
|
+
whitelist, and dataset I/O (npz/pkl/csv), each against a throwaway data dir:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
uv run --extra dev pytest -q
|
|
91
|
+
```
|
|
@@ -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"
|
|
@@ -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")
|