figpack 0.1.1__py3-none-any.whl → 0.1.3__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.
Potentially problematic release.
This version of figpack might be problematic. Click here for more details.
- figpack/__init__.py +3 -1
- figpack/cli.py +312 -0
- figpack/core/_bundle_utils.py +11 -1
- figpack/core/_show_view.py +34 -8
- figpack/core/{_upload_view.py → _upload_bundle.py} +140 -49
- figpack/core/figpack_view.py +32 -24
- figpack/figpack-gui-dist/assets/index-BDa2iJW9.css +1 -0
- figpack/figpack-gui-dist/assets/index-ByLxmrzp.js +846 -0
- figpack/figpack-gui-dist/assets/neurosift-logo-CLsuwLMO.png +0 -0
- figpack/figpack-gui-dist/index.html +4 -4
- figpack/spike_sorting/__init__.py +5 -0
- figpack/spike_sorting/views/AutocorrelogramItem.py +41 -0
- figpack/spike_sorting/views/Autocorrelograms.py +76 -0
- figpack/spike_sorting/views/CrossCorrelogramItem.py +45 -0
- figpack/spike_sorting/views/CrossCorrelograms.py +82 -0
- figpack/spike_sorting/views/UnitSimilarityScore.py +40 -0
- figpack/spike_sorting/views/UnitsTable.py +68 -0
- figpack/spike_sorting/views/UnitsTableColumn.py +40 -0
- figpack/spike_sorting/views/UnitsTableRow.py +36 -0
- figpack/spike_sorting/views/__init__.py +23 -0
- figpack/views/Image.py +82 -0
- figpack/views/Markdown.py +34 -0
- figpack/views/MatplotlibFigure.py +65 -0
- figpack/views/PlotlyFigure.py +58 -0
- figpack/views/__init__.py +4 -0
- figpack-0.1.3.dist-info/METADATA +126 -0
- figpack-0.1.3.dist-info/RECORD +38 -0
- figpack-0.1.3.dist-info/entry_points.txt +2 -0
- figpack-0.1.3.dist-info/top_level.txt +1 -0
- figpack/figpack-gui-dist/assets/index-BW-ONVCL.js +0 -65
- figpack/figpack-gui-dist/assets/index-CeWL3OeJ.css +0 -1
- figpack-0.1.1.dist-info/METADATA +0 -33
- figpack-0.1.1.dist-info/RECORD +0 -22
- figpack-0.1.1.dist-info/top_level.txt +0 -2
- figpack-gui/node_modules/flatted/python/flatted.py +0 -149
- {figpack-0.1.1.dist-info → figpack-0.1.3.dist-info}/WHEEL +0 -0
- {figpack-0.1.1.dist-info → figpack-0.1.3.dist-info}/licenses/LICENSE +0 -0
figpack/__init__.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"""
|
|
2
|
-
figpack - A Python package for creating interactive visualizations
|
|
2
|
+
figpack - A Python package for creating shareable, interactive visualizations in the browser
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
__version__ = "0.1.0"
|
|
6
6
|
__author__ = "Jeremy Magland"
|
|
7
7
|
__email__ = "jmagland@flatironinstitute.org"
|
|
8
|
+
|
|
9
|
+
from . import spike_sorting
|
figpack/cli.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line interface for figpack
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
import pathlib
|
|
8
|
+
import tempfile
|
|
9
|
+
import tarfile
|
|
10
|
+
import json
|
|
11
|
+
import requests
|
|
12
|
+
import threading
|
|
13
|
+
import webbrowser
|
|
14
|
+
import socket
|
|
15
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
16
|
+
from urllib.parse import urlparse, urljoin
|
|
17
|
+
from typing import Dict, List, Tuple, Optional, Union
|
|
18
|
+
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
19
|
+
|
|
20
|
+
from . import __version__
|
|
21
|
+
from .core._show_view import serve_files
|
|
22
|
+
|
|
23
|
+
MAX_WORKERS_FOR_DOWNLOAD = 16
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_figure_base_url(figure_url: str) -> str:
|
|
27
|
+
"""
|
|
28
|
+
Get the base URL from any figpack URL
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
figure_url: Any figpack URL (may or may not end with /index.html)
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
str: The base URL for the figure directory
|
|
35
|
+
"""
|
|
36
|
+
# Handle URLs that end with /index.html
|
|
37
|
+
if figure_url.endswith("/index.html"):
|
|
38
|
+
base_url = figure_url[:-11] # Remove "/index.html"
|
|
39
|
+
elif figure_url.endswith("/"):
|
|
40
|
+
base_url = figure_url[:-1] # Remove trailing slash
|
|
41
|
+
else:
|
|
42
|
+
# Assume it's already a directory URL
|
|
43
|
+
base_url = figure_url
|
|
44
|
+
|
|
45
|
+
# Ensure it ends with a slash for urljoin to work properly
|
|
46
|
+
if not base_url.endswith("/"):
|
|
47
|
+
base_url += "/"
|
|
48
|
+
|
|
49
|
+
return base_url
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def download_file(
|
|
53
|
+
base_url: str, file_info: Dict, temp_dir: pathlib.Path
|
|
54
|
+
) -> Tuple[str, bool]:
|
|
55
|
+
"""
|
|
56
|
+
Download a single file from the figure
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
base_url: The base URL for the figure
|
|
60
|
+
file_info: Dictionary with 'path' and 'size' keys
|
|
61
|
+
temp_dir: Temporary directory to download to
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Tuple of (file_path, success)
|
|
65
|
+
"""
|
|
66
|
+
file_path = file_info["path"]
|
|
67
|
+
file_url = urljoin(base_url, file_path)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
response = requests.get(file_url, timeout=30)
|
|
71
|
+
response.raise_for_status()
|
|
72
|
+
|
|
73
|
+
# Create directory structure if needed
|
|
74
|
+
local_file_path = temp_dir / file_path
|
|
75
|
+
local_file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
|
|
77
|
+
# Write file content
|
|
78
|
+
if file_path.endswith(
|
|
79
|
+
(
|
|
80
|
+
".json",
|
|
81
|
+
".html",
|
|
82
|
+
".css",
|
|
83
|
+
".js",
|
|
84
|
+
".zattrs",
|
|
85
|
+
".zgroup",
|
|
86
|
+
".zarray",
|
|
87
|
+
".zmetadata",
|
|
88
|
+
)
|
|
89
|
+
):
|
|
90
|
+
# Text files
|
|
91
|
+
local_file_path.write_text(response.text, encoding="utf-8")
|
|
92
|
+
else:
|
|
93
|
+
# Binary files
|
|
94
|
+
local_file_path.write_bytes(response.content)
|
|
95
|
+
|
|
96
|
+
return file_path, True
|
|
97
|
+
|
|
98
|
+
except Exception as e:
|
|
99
|
+
print(f"Failed to download {file_path}: {e}")
|
|
100
|
+
return file_path, False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def download_figure(figure_url: str, dest_path: str) -> None:
|
|
104
|
+
"""
|
|
105
|
+
Download a figure from a figpack URL and save as tar.gz
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
figure_url: The figpack URL
|
|
109
|
+
dest_path: Destination path for the tar.gz file
|
|
110
|
+
"""
|
|
111
|
+
print(f"Downloading figure from: {figure_url}")
|
|
112
|
+
|
|
113
|
+
# Get base URL
|
|
114
|
+
base_url = get_figure_base_url(figure_url)
|
|
115
|
+
print(f"Base URL: {base_url}")
|
|
116
|
+
|
|
117
|
+
# Check if manifest.json exists
|
|
118
|
+
manifest_url = urljoin(base_url, "manifest.json")
|
|
119
|
+
print("Checking for manifest.json...")
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
response = requests.get(manifest_url, timeout=10)
|
|
123
|
+
response.raise_for_status()
|
|
124
|
+
manifest = response.json()
|
|
125
|
+
print(f"Found manifest with {len(manifest['files'])} files")
|
|
126
|
+
except requests.exceptions.RequestException as e:
|
|
127
|
+
print(f"Error: Could not retrieve manifest.json from {manifest_url}: {e}")
|
|
128
|
+
print("Make sure the URL points to a valid figpack figure with a manifest.")
|
|
129
|
+
sys.exit(1)
|
|
130
|
+
except json.JSONDecodeError as e:
|
|
131
|
+
print(f"Error: Invalid manifest.json format: {e}")
|
|
132
|
+
sys.exit(1)
|
|
133
|
+
|
|
134
|
+
# Create temporary directory for downloads
|
|
135
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
136
|
+
temp_path = pathlib.Path(temp_dir)
|
|
137
|
+
|
|
138
|
+
# Download all files in parallel
|
|
139
|
+
print(
|
|
140
|
+
f"Downloading {len(manifest['files'])} files with up to {MAX_WORKERS_FOR_DOWNLOAD} concurrent downloads..."
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
downloaded_count = 0
|
|
144
|
+
failed_files = []
|
|
145
|
+
count_lock = threading.Lock()
|
|
146
|
+
|
|
147
|
+
with ThreadPoolExecutor(max_workers=MAX_WORKERS_FOR_DOWNLOAD) as executor:
|
|
148
|
+
# Submit all download tasks
|
|
149
|
+
future_to_file = {
|
|
150
|
+
executor.submit(
|
|
151
|
+
download_file, base_url, file_info, temp_path
|
|
152
|
+
): file_info["path"]
|
|
153
|
+
for file_info in manifest["files"]
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
# Process completed downloads
|
|
157
|
+
for future in as_completed(future_to_file):
|
|
158
|
+
file_path = future_to_file[future]
|
|
159
|
+
try:
|
|
160
|
+
downloaded_path, success = future.result()
|
|
161
|
+
|
|
162
|
+
with count_lock:
|
|
163
|
+
if success:
|
|
164
|
+
downloaded_count += 1
|
|
165
|
+
print(
|
|
166
|
+
f"Downloaded {downloaded_count}/{len(manifest['files'])}: {downloaded_path}"
|
|
167
|
+
)
|
|
168
|
+
else:
|
|
169
|
+
failed_files.append(downloaded_path)
|
|
170
|
+
|
|
171
|
+
except Exception as e:
|
|
172
|
+
with count_lock:
|
|
173
|
+
failed_files.append(file_path)
|
|
174
|
+
print(f"Failed to download {file_path}: {e}")
|
|
175
|
+
|
|
176
|
+
if failed_files:
|
|
177
|
+
print(f"Warning: Failed to download {len(failed_files)} files:")
|
|
178
|
+
for failed_file in failed_files:
|
|
179
|
+
print(f" - {failed_file}")
|
|
180
|
+
|
|
181
|
+
if len(failed_files) == len(manifest["files"]):
|
|
182
|
+
print("Error: Failed to download any files. Aborting.")
|
|
183
|
+
sys.exit(1)
|
|
184
|
+
|
|
185
|
+
# Save manifest.json to temp directory
|
|
186
|
+
manifest_path = temp_path / "manifest.json"
|
|
187
|
+
manifest_path.write_text(json.dumps(manifest, indent=2))
|
|
188
|
+
print("Added manifest.json to bundle")
|
|
189
|
+
|
|
190
|
+
# Create tar.gz file
|
|
191
|
+
print(f"Creating tar.gz archive: {dest_path}")
|
|
192
|
+
dest_pathlib = pathlib.Path(dest_path)
|
|
193
|
+
dest_pathlib.parent.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
|
|
195
|
+
with tarfile.open(dest_path, "w:gz") as tar:
|
|
196
|
+
# Add all downloaded files (excluding figpack.json if it exists)
|
|
197
|
+
for file_path in temp_path.rglob("*"):
|
|
198
|
+
if file_path.is_file():
|
|
199
|
+
arcname = file_path.relative_to(temp_path)
|
|
200
|
+
# Skip figpack.json as requested
|
|
201
|
+
if str(arcname) != "figpack.json":
|
|
202
|
+
tar.add(file_path, arcname=arcname)
|
|
203
|
+
|
|
204
|
+
# Count files in archive (excluding directories)
|
|
205
|
+
archive_files = [
|
|
206
|
+
f for f in temp_path.rglob("*") if f.is_file() and f.name != "figpack.json"
|
|
207
|
+
]
|
|
208
|
+
total_size = sum(f.stat().st_size for f in archive_files)
|
|
209
|
+
|
|
210
|
+
print(f"Archive created successfully!")
|
|
211
|
+
print(
|
|
212
|
+
f"Total files: {len(archive_files)} (including manifest.json, excluding figpack.json)"
|
|
213
|
+
)
|
|
214
|
+
print(f"Total size: {total_size / (1024 * 1024):.2f} MB")
|
|
215
|
+
print(f"Archive saved to: {dest_path}")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def view_figure(archive_path: str, port: Union[int, None] = None) -> None:
|
|
219
|
+
"""
|
|
220
|
+
Extract and serve a figure archive locally
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
archive_path: Path to the tar.gz archive
|
|
224
|
+
port: Optional port number to serve on
|
|
225
|
+
"""
|
|
226
|
+
archive_pathlib = pathlib.Path(archive_path)
|
|
227
|
+
|
|
228
|
+
if not archive_pathlib.exists():
|
|
229
|
+
print(f"Error: Archive file not found: {archive_path}")
|
|
230
|
+
sys.exit(1)
|
|
231
|
+
|
|
232
|
+
if not archive_pathlib.suffix.lower() in [".gz", ".tgz"] or not str(
|
|
233
|
+
archive_pathlib
|
|
234
|
+
).endswith(".tar.gz"):
|
|
235
|
+
print(f"Error: Expected a .tar.gz file, got: {archive_path}")
|
|
236
|
+
sys.exit(1)
|
|
237
|
+
|
|
238
|
+
print(f"Extracting figure archive: {archive_path}")
|
|
239
|
+
|
|
240
|
+
# Create temporary directory and extract files
|
|
241
|
+
with tempfile.TemporaryDirectory(prefix="figpack_view_") as temp_dir:
|
|
242
|
+
temp_path = pathlib.Path(temp_dir)
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
with tarfile.open(archive_path, "r:gz") as tar:
|
|
246
|
+
tar.extractall(temp_path)
|
|
247
|
+
|
|
248
|
+
# Count extracted files
|
|
249
|
+
extracted_files = list(temp_path.rglob("*"))
|
|
250
|
+
file_count = len([f for f in extracted_files if f.is_file()])
|
|
251
|
+
print(f"Extracted {file_count} files")
|
|
252
|
+
|
|
253
|
+
# Check if index.html exists
|
|
254
|
+
index_html = temp_path / "index.html"
|
|
255
|
+
if not index_html.exists():
|
|
256
|
+
print("Warning: No index.html found in archive")
|
|
257
|
+
print("Available files:")
|
|
258
|
+
for f in sorted(extracted_files):
|
|
259
|
+
if f.is_file():
|
|
260
|
+
print(f" {f.relative_to(temp_path)}")
|
|
261
|
+
|
|
262
|
+
# Serve the files
|
|
263
|
+
serve_files(
|
|
264
|
+
str(temp_path),
|
|
265
|
+
port=port,
|
|
266
|
+
open_in_browser=True,
|
|
267
|
+
allow_origin=None,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
except tarfile.TarError as e:
|
|
271
|
+
print(f"Error: Failed to extract archive: {e}")
|
|
272
|
+
sys.exit(1)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def main():
|
|
276
|
+
"""Main CLI entry point"""
|
|
277
|
+
parser = argparse.ArgumentParser(
|
|
278
|
+
description="figpack - A Python package for creating shareable, interactive visualizations",
|
|
279
|
+
prog="figpack",
|
|
280
|
+
)
|
|
281
|
+
parser.add_argument("--version", action="version", version=f"figpack {__version__}")
|
|
282
|
+
|
|
283
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
284
|
+
|
|
285
|
+
# Download command
|
|
286
|
+
download_parser = subparsers.add_parser(
|
|
287
|
+
"download", help="Download a figure from a figpack URL"
|
|
288
|
+
)
|
|
289
|
+
download_parser.add_argument("figure_url", help="The figpack URL to download")
|
|
290
|
+
download_parser.add_argument("dest", help="Destination path for the tar.gz file")
|
|
291
|
+
|
|
292
|
+
# View command
|
|
293
|
+
view_parser = subparsers.add_parser(
|
|
294
|
+
"view", help="Extract and serve a figure archive locally"
|
|
295
|
+
)
|
|
296
|
+
view_parser.add_argument("archive", help="Path to the tar.gz archive file")
|
|
297
|
+
view_parser.add_argument(
|
|
298
|
+
"--port", type=int, help="Port number to serve on (default: auto-select)"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
args = parser.parse_args()
|
|
302
|
+
|
|
303
|
+
if args.command == "download":
|
|
304
|
+
download_figure(args.figure_url, args.dest)
|
|
305
|
+
elif args.command == "view":
|
|
306
|
+
view_figure(args.archive, port=args.port)
|
|
307
|
+
else:
|
|
308
|
+
parser.print_help()
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
if __name__ == "__main__":
|
|
312
|
+
main()
|
figpack/core/_bundle_utils.py
CHANGED
|
@@ -6,7 +6,9 @@ from .figpack_view import FigpackView
|
|
|
6
6
|
thisdir = pathlib.Path(__file__).parent.resolve()
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
def prepare_figure_bundle(
|
|
9
|
+
def prepare_figure_bundle(
|
|
10
|
+
view: FigpackView, tmpdir: str, *, title: str = None, description: str = None
|
|
11
|
+
) -> None:
|
|
10
12
|
"""
|
|
11
13
|
Prepare a figure bundle in the specified temporary directory.
|
|
12
14
|
|
|
@@ -18,6 +20,8 @@ def prepare_figure_bundle(view: FigpackView, tmpdir: str) -> None:
|
|
|
18
20
|
Args:
|
|
19
21
|
view: The figpack view to prepare
|
|
20
22
|
tmpdir: The temporary directory to prepare the bundle in
|
|
23
|
+
title: Optional title for the figure
|
|
24
|
+
description: Optional description for the figure (markdown supported)
|
|
21
25
|
"""
|
|
22
26
|
html_dir = thisdir / ".." / "figpack-gui-dist"
|
|
23
27
|
if not os.path.exists(html_dir):
|
|
@@ -43,4 +47,10 @@ def prepare_figure_bundle(view: FigpackView, tmpdir: str) -> None:
|
|
|
43
47
|
)
|
|
44
48
|
view._write_to_zarr_group(zarr_group)
|
|
45
49
|
|
|
50
|
+
# Add title and description as attributes on the top-level zarr group
|
|
51
|
+
if title is not None:
|
|
52
|
+
zarr_group.attrs["title"] = title
|
|
53
|
+
if description is not None:
|
|
54
|
+
zarr_group.attrs["description"] = description
|
|
55
|
+
|
|
46
56
|
zarr.consolidate_metadata(zarr_group.store)
|
figpack/core/_show_view.py
CHANGED
|
@@ -12,6 +12,7 @@ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
|
12
12
|
|
|
13
13
|
from .figpack_view import FigpackView
|
|
14
14
|
from ._bundle_utils import prepare_figure_bundle
|
|
15
|
+
from ._upload_bundle import _upload_bundle
|
|
15
16
|
|
|
16
17
|
thisdir = pathlib.Path(__file__).parent.resolve()
|
|
17
18
|
|
|
@@ -22,16 +23,41 @@ def _show_view(
|
|
|
22
23
|
open_in_browser: bool = False,
|
|
23
24
|
port: Union[int, None] = None,
|
|
24
25
|
allow_origin: Union[str, None] = None,
|
|
26
|
+
upload: bool = False,
|
|
27
|
+
title: Union[str, None] = None,
|
|
28
|
+
description: Union[str, None] = None,
|
|
25
29
|
):
|
|
26
30
|
with tempfile.TemporaryDirectory(prefix="figpack_") as tmpdir:
|
|
27
|
-
prepare_figure_bundle(view, tmpdir)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
prepare_figure_bundle(view, tmpdir, title=title, description=description)
|
|
32
|
+
|
|
33
|
+
if upload:
|
|
34
|
+
# Check for required environment variable
|
|
35
|
+
passcode = os.environ.get("FIGPACK_UPLOAD_PASSCODE")
|
|
36
|
+
if not passcode:
|
|
37
|
+
raise EnvironmentError(
|
|
38
|
+
"FIGPACK_UPLOAD_PASSCODE environment variable must be set to upload views."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Upload the bundle
|
|
42
|
+
print("Starting upload...")
|
|
43
|
+
figure_url = _upload_bundle(tmpdir, passcode)
|
|
44
|
+
|
|
45
|
+
if open_in_browser:
|
|
46
|
+
webbrowser.open(figure_url)
|
|
47
|
+
print(f"Opening {figure_url} in browser.")
|
|
48
|
+
# wait until user presses Enter
|
|
49
|
+
input("Press Enter to continue...")
|
|
50
|
+
else:
|
|
51
|
+
print(f"View the figure at: {figure_url}")
|
|
52
|
+
|
|
53
|
+
return figure_url
|
|
54
|
+
else:
|
|
55
|
+
serve_files(
|
|
56
|
+
tmpdir,
|
|
57
|
+
port=port,
|
|
58
|
+
open_in_browser=open_in_browser,
|
|
59
|
+
allow_origin=allow_origin,
|
|
60
|
+
)
|
|
35
61
|
|
|
36
62
|
|
|
37
63
|
class CORSRequestHandler(SimpleHTTPRequestHandler):
|
|
@@ -1,62 +1,18 @@
|
|
|
1
|
-
import os
|
|
2
1
|
import time
|
|
3
2
|
import json
|
|
4
3
|
import uuid
|
|
5
|
-
import tempfile
|
|
6
4
|
import pathlib
|
|
7
5
|
import requests
|
|
8
6
|
import threading
|
|
7
|
+
import hashlib
|
|
8
|
+
from .. import __version__
|
|
9
9
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
10
10
|
from datetime import datetime, timedelta, timezone
|
|
11
11
|
|
|
12
|
-
from .figpack_view import FigpackView
|
|
13
|
-
from ._bundle_utils import prepare_figure_bundle
|
|
14
|
-
|
|
15
12
|
thisdir = pathlib.Path(__file__).parent.resolve()
|
|
16
13
|
|
|
17
14
|
FIGPACK_API_BASE_URL = "https://figpack-api.vercel.app"
|
|
18
|
-
TEMPORY_BASE_URL = "https://tempory.net/figpack/figures"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def _upload_view(view: FigpackView) -> str:
|
|
22
|
-
"""
|
|
23
|
-
Upload a figpack view to the cloud
|
|
24
|
-
|
|
25
|
-
Args:
|
|
26
|
-
view: The figpack view to upload
|
|
27
|
-
|
|
28
|
-
Returns:
|
|
29
|
-
str: URL where the uploaded figure can be viewed
|
|
30
|
-
|
|
31
|
-
Raises:
|
|
32
|
-
EnvironmentError: If FIGPACK_UPLOAD_PASSCODE is not set
|
|
33
|
-
Exception: If upload fails
|
|
34
|
-
"""
|
|
35
|
-
# Check for required environment variable
|
|
36
|
-
passcode = os.environ.get("FIGPACK_UPLOAD_PASSCODE")
|
|
37
|
-
if not passcode:
|
|
38
|
-
raise EnvironmentError(
|
|
39
|
-
"FIGPACK_UPLOAD_PASSCODE environment variable must be set"
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
# Generate random figure ID
|
|
43
|
-
figure_id = str(uuid.uuid4())
|
|
44
|
-
print(f"Generated figure ID: {figure_id}")
|
|
45
|
-
|
|
46
|
-
with tempfile.TemporaryDirectory(prefix="figpack_upload_") as tmpdir:
|
|
47
|
-
# Prepare the figure bundle (reuse logic from _show_view)
|
|
48
|
-
print("Preparing figure bundle...")
|
|
49
|
-
prepare_figure_bundle(view, tmpdir)
|
|
50
|
-
|
|
51
|
-
# Upload the bundle
|
|
52
|
-
print("Starting upload...")
|
|
53
|
-
_upload_bundle(tmpdir, figure_id, passcode)
|
|
54
|
-
|
|
55
|
-
# Return the final URL
|
|
56
|
-
figure_url = f"{TEMPORY_BASE_URL}/{figure_id}/index.html"
|
|
57
|
-
print(f"Upload completed successfully!")
|
|
58
|
-
print(f"Figure available at: {figure_url}")
|
|
59
|
-
return figure_url
|
|
15
|
+
TEMPORY_BASE_URL = "https://tempory.net/figpack/default/figures"
|
|
60
16
|
|
|
61
17
|
|
|
62
18
|
def _upload_single_file(
|
|
@@ -83,12 +39,121 @@ def _upload_single_file(
|
|
|
83
39
|
MAX_WORKERS_FOR_UPLOAD = 16
|
|
84
40
|
|
|
85
41
|
|
|
86
|
-
def
|
|
42
|
+
def _compute_deterministic_figure_id(tmpdir_path: pathlib.Path) -> str:
|
|
43
|
+
"""
|
|
44
|
+
Compute a deterministic figure ID based on SHA1 hashes of all files
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
str: 40-character SHA1 hash representing the content of all files
|
|
48
|
+
"""
|
|
49
|
+
file_hashes = []
|
|
50
|
+
|
|
51
|
+
# Collect all files and their hashes
|
|
52
|
+
for file_path in sorted(tmpdir_path.rglob("*")):
|
|
53
|
+
if file_path.is_file():
|
|
54
|
+
relative_path = file_path.relative_to(tmpdir_path)
|
|
55
|
+
|
|
56
|
+
# Compute SHA1 hash of file content
|
|
57
|
+
sha1_hash = hashlib.sha1()
|
|
58
|
+
with open(file_path, "rb") as f:
|
|
59
|
+
for chunk in iter(lambda: f.read(4096), b""):
|
|
60
|
+
sha1_hash.update(chunk)
|
|
61
|
+
|
|
62
|
+
# Include both the relative path and content hash to ensure uniqueness
|
|
63
|
+
file_info = f"{relative_path}:{sha1_hash.hexdigest()}"
|
|
64
|
+
file_hashes.append(file_info)
|
|
65
|
+
|
|
66
|
+
# Create final hash from all file hashes
|
|
67
|
+
combined_hash = hashlib.sha1()
|
|
68
|
+
for file_hash in file_hashes:
|
|
69
|
+
combined_hash.update(file_hash.encode("utf-8"))
|
|
70
|
+
|
|
71
|
+
return combined_hash.hexdigest()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _check_existing_figure(figure_id: str) -> dict:
|
|
75
|
+
"""
|
|
76
|
+
Check if a figure already exists and return its status
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
dict: Contains 'exists' (bool) and 'status' (str) if exists
|
|
80
|
+
"""
|
|
81
|
+
figpack_url = f"{TEMPORY_BASE_URL}/{figure_id}/figpack.json"
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
response = requests.get(figpack_url, timeout=10)
|
|
85
|
+
if response.ok:
|
|
86
|
+
figpack_data = response.json()
|
|
87
|
+
return {"exists": True, "status": figpack_data.get("status", "unknown")}
|
|
88
|
+
else:
|
|
89
|
+
return {"exists": False}
|
|
90
|
+
except Exception:
|
|
91
|
+
return {"exists": False}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _find_available_figure_id(base_figure_id: str) -> tuple:
|
|
95
|
+
"""
|
|
96
|
+
Find an available figure ID by checking base_figure_id, then base_figure_id-1, base_figure_id-2, etc.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
tuple: (figure_id_to_use, completed_figure_id) where:
|
|
100
|
+
- figure_id_to_use is None if upload should be skipped
|
|
101
|
+
- completed_figure_id is the ID of the completed figure if one exists
|
|
102
|
+
"""
|
|
103
|
+
# First check the base figure ID
|
|
104
|
+
result = _check_existing_figure(base_figure_id)
|
|
105
|
+
if not result["exists"]:
|
|
106
|
+
return (base_figure_id, None)
|
|
107
|
+
elif result["status"] == "completed":
|
|
108
|
+
print(
|
|
109
|
+
f"Figure {base_figure_id} already exists and is completed. Skipping upload."
|
|
110
|
+
)
|
|
111
|
+
return (None, base_figure_id) # Signal to skip upload, return completed ID
|
|
112
|
+
|
|
113
|
+
# If exists but not completed, try with suffixes
|
|
114
|
+
suffix = 1
|
|
115
|
+
while True:
|
|
116
|
+
candidate_id = f"{base_figure_id}-{suffix}"
|
|
117
|
+
result = _check_existing_figure(candidate_id)
|
|
118
|
+
|
|
119
|
+
if not result["exists"]:
|
|
120
|
+
print(f"Using figure ID: {candidate_id}")
|
|
121
|
+
return (candidate_id, None)
|
|
122
|
+
elif result["status"] == "completed":
|
|
123
|
+
print(
|
|
124
|
+
f"Figure {candidate_id} already exists and is completed. Skipping upload."
|
|
125
|
+
)
|
|
126
|
+
return (None, candidate_id) # Signal to skip upload, return completed ID
|
|
127
|
+
|
|
128
|
+
suffix += 1
|
|
129
|
+
if suffix > 100: # Safety limit
|
|
130
|
+
raise Exception(
|
|
131
|
+
"Too many existing figure variants, unable to find available ID"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _upload_bundle(tmpdir: str, passcode: str) -> None:
|
|
87
136
|
"""
|
|
88
137
|
Upload the prepared bundle to the cloud using parallel uploads
|
|
89
138
|
"""
|
|
90
139
|
tmpdir_path = pathlib.Path(tmpdir)
|
|
91
140
|
|
|
141
|
+
# Compute deterministic figure ID based on file contents
|
|
142
|
+
print("Computing deterministic figure ID...")
|
|
143
|
+
base_figure_id = _compute_deterministic_figure_id(tmpdir_path)
|
|
144
|
+
print(f"Base figure ID: {base_figure_id}")
|
|
145
|
+
|
|
146
|
+
# Find available figure ID (check for existing uploads)
|
|
147
|
+
figure_id, completed_figure_id = _find_available_figure_id(base_figure_id)
|
|
148
|
+
|
|
149
|
+
# If figure_id is None, it means we found a completed upload and should skip
|
|
150
|
+
if figure_id is None:
|
|
151
|
+
figure_url = f"{TEMPORY_BASE_URL}/{completed_figure_id}/index.html"
|
|
152
|
+
print(f"Figure already exists at: {figure_url}")
|
|
153
|
+
return figure_url
|
|
154
|
+
|
|
155
|
+
print(f"Using figure ID: {figure_id}")
|
|
156
|
+
|
|
92
157
|
# First, upload initial figpack.json with "uploading" status
|
|
93
158
|
print("Uploading initial status...")
|
|
94
159
|
figpack_json = {
|
|
@@ -96,6 +161,7 @@ def _upload_bundle(tmpdir: str, figure_id: str, passcode: str) -> None:
|
|
|
96
161
|
"upload_started": datetime.now(timezone.utc).isoformat(),
|
|
97
162
|
"upload_updated": datetime.now(timezone.utc).isoformat(),
|
|
98
163
|
"figure_id": figure_id,
|
|
164
|
+
"figpack_version": __version__,
|
|
99
165
|
}
|
|
100
166
|
_upload_small_file(
|
|
101
167
|
figure_id, "figpack.json", json.dumps(figpack_json, indent=2), passcode
|
|
@@ -179,8 +245,27 @@ def _upload_bundle(tmpdir: str, figure_id: str, passcode: str) -> None:
|
|
|
179
245
|
print(f"Failed to upload {relative_path}: {e}")
|
|
180
246
|
raise # Re-raise the exception to stop the upload process
|
|
181
247
|
|
|
248
|
+
# Create and upload manifest.json
|
|
249
|
+
print("Creating manifest.json...")
|
|
250
|
+
manifest = {
|
|
251
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
252
|
+
"files": [],
|
|
253
|
+
"total_size": 0,
|
|
254
|
+
"total_files": len(files_to_upload),
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
for rel_path, file_path in files_to_upload:
|
|
258
|
+
file_size = file_path.stat().st_size
|
|
259
|
+
manifest["files"].append({"path": rel_path, "size": file_size})
|
|
260
|
+
manifest["total_size"] += file_size
|
|
261
|
+
|
|
262
|
+
_upload_small_file(
|
|
263
|
+
figure_id, "manifest.json", json.dumps(manifest, indent=2), passcode
|
|
264
|
+
)
|
|
265
|
+
print("Uploaded manifest.json")
|
|
266
|
+
print(f"Total size: {manifest['total_size'] / (1024 * 1024):.2f} MB")
|
|
267
|
+
|
|
182
268
|
# Finally, upload completion status
|
|
183
|
-
print("Uploading completion status...")
|
|
184
269
|
figpack_json = {
|
|
185
270
|
**figpack_json,
|
|
186
271
|
"status": "completed",
|
|
@@ -188,10 +273,16 @@ def _upload_bundle(tmpdir: str, figure_id: str, passcode: str) -> None:
|
|
|
188
273
|
"expiration": (datetime.now(timezone.utc) + timedelta(days=1)).isoformat(),
|
|
189
274
|
"figure_id": figure_id,
|
|
190
275
|
"total_files": len(all_files),
|
|
276
|
+
"total_size": manifest["total_size"],
|
|
277
|
+
"figpack_version": __version__,
|
|
191
278
|
}
|
|
192
279
|
_upload_small_file(
|
|
193
280
|
figure_id, "figpack.json", json.dumps(figpack_json, indent=2), passcode
|
|
194
281
|
)
|
|
282
|
+
print("Upload completed successfully")
|
|
283
|
+
|
|
284
|
+
figure_url = f"{TEMPORY_BASE_URL}/{figure_id}/index.html"
|
|
285
|
+
return figure_url
|
|
195
286
|
|
|
196
287
|
|
|
197
288
|
def _determine_file_type(file_path: str) -> str:
|