figpack 0.2.4__py3-none-any.whl → 0.2.6__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 +5 -1
- figpack/cli.py +2 -118
- figpack/core/_bundle_utils.py +5 -6
- figpack/core/_save_figure.py +31 -0
- figpack/core/_server_manager.py +0 -2
- figpack/core/_show_view.py +22 -22
- figpack/core/_upload_bundle.py +61 -22
- figpack/core/_view_figure.py +138 -0
- figpack/core/figpack_view.py +74 -25
- figpack/{figpack-gui-dist/assets/index-CuFseOGX.js → figpack-figure-dist/assets/index-HXdk2TtM.js} +58 -58
- figpack/{figpack-gui-dist → figpack-figure-dist}/index.html +1 -1
- figpack/spike_sorting/views/Autocorrelograms.py +29 -19
- figpack/spike_sorting/views/CrossCorrelograms.py +29 -19
- figpack/spike_sorting/views/UnitsTable.py +27 -8
- figpack/views/Gallery.py +88 -0
- figpack/views/GalleryItem.py +47 -0
- figpack/views/Image.py +37 -0
- figpack/views/Markdown.py +12 -2
- figpack/views/MatplotlibFigure.py +26 -3
- figpack/views/PlotlyFigure.py +18 -2
- figpack/views/__init__.py +2 -0
- figpack-0.2.6.dist-info/METADATA +96 -0
- figpack-0.2.6.dist-info/RECORD +53 -0
- figpack-0.2.4.dist-info/METADATA +0 -168
- figpack-0.2.4.dist-info/RECORD +0 -49
- /figpack/{figpack-gui-dist → figpack-figure-dist}/assets/index-Cmae55E4.css +0 -0
- /figpack/{figpack-gui-dist → figpack-figure-dist}/assets/neurosift-logo-CLsuwLMO.png +0 -0
- {figpack-0.2.4.dist-info → figpack-0.2.6.dist-info}/WHEEL +0 -0
- {figpack-0.2.4.dist-info → figpack-0.2.6.dist-info}/entry_points.txt +0 -0
- {figpack-0.2.4.dist-info → figpack-0.2.6.dist-info}/licenses/LICENSE +0 -0
- {figpack-0.2.4.dist-info → figpack-0.2.6.dist-info}/top_level.txt +0 -0
figpack/__init__.py
CHANGED
figpack/cli.py
CHANGED
|
@@ -5,21 +5,19 @@ Command-line interface for figpack
|
|
|
5
5
|
import argparse
|
|
6
6
|
import json
|
|
7
7
|
import pathlib
|
|
8
|
-
import socket
|
|
9
8
|
import sys
|
|
10
9
|
import tarfile
|
|
11
10
|
import tempfile
|
|
12
11
|
import threading
|
|
13
|
-
import webbrowser
|
|
14
12
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
15
|
-
from
|
|
16
|
-
from typing import Dict, Tuple, Union
|
|
13
|
+
from typing import Dict, Tuple
|
|
17
14
|
from urllib.parse import urljoin
|
|
18
15
|
|
|
19
16
|
import requests
|
|
20
17
|
|
|
21
18
|
from . import __version__
|
|
22
19
|
from .core._server_manager import CORSRequestHandler
|
|
20
|
+
from .core._view_figure import serve_files, view_figure
|
|
23
21
|
|
|
24
22
|
MAX_WORKERS_FOR_DOWNLOAD = 16
|
|
25
23
|
|
|
@@ -216,120 +214,6 @@ def download_figure(figure_url: str, dest_path: str) -> None:
|
|
|
216
214
|
print(f"Archive saved to: {dest_path}")
|
|
217
215
|
|
|
218
216
|
|
|
219
|
-
def serve_files(
|
|
220
|
-
tmpdir: str,
|
|
221
|
-
*,
|
|
222
|
-
port: Union[int, None],
|
|
223
|
-
open_in_browser: bool = False,
|
|
224
|
-
allow_origin: Union[str, None] = None,
|
|
225
|
-
):
|
|
226
|
-
"""
|
|
227
|
-
Serve files from a directory using a simple HTTP server.
|
|
228
|
-
|
|
229
|
-
Args:
|
|
230
|
-
tmpdir: Directory to serve
|
|
231
|
-
port: Port number for local server
|
|
232
|
-
open_in_browser: Whether to open in browser automatically
|
|
233
|
-
allow_origin: CORS allow origin header
|
|
234
|
-
"""
|
|
235
|
-
# if port is None, find a free port
|
|
236
|
-
if port is None:
|
|
237
|
-
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
238
|
-
s.bind(("", 0))
|
|
239
|
-
port = s.getsockname()[1]
|
|
240
|
-
|
|
241
|
-
tmpdir = pathlib.Path(tmpdir)
|
|
242
|
-
tmpdir = tmpdir.resolve()
|
|
243
|
-
if not tmpdir.exists() or not tmpdir.is_dir():
|
|
244
|
-
raise SystemExit(f"Directory not found: {tmpdir}")
|
|
245
|
-
|
|
246
|
-
# Configure handler with directory and allow_origin
|
|
247
|
-
def handler_factory(*args, **kwargs):
|
|
248
|
-
return CORSRequestHandler(
|
|
249
|
-
*args, directory=str(tmpdir), allow_origin=allow_origin, **kwargs
|
|
250
|
-
)
|
|
251
|
-
|
|
252
|
-
httpd = ThreadingHTTPServer(("0.0.0.0", port), handler_factory)
|
|
253
|
-
print(f"Serving {tmpdir} at http://localhost:{port} (CORS → {allow_origin})")
|
|
254
|
-
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
|
255
|
-
thread.start()
|
|
256
|
-
|
|
257
|
-
if open_in_browser:
|
|
258
|
-
webbrowser.open(f"http://localhost:{port}")
|
|
259
|
-
print(f"Opening http://localhost:{port} in your browser.")
|
|
260
|
-
else:
|
|
261
|
-
print(
|
|
262
|
-
f"Open http://localhost:{port} in your browser to view the visualization."
|
|
263
|
-
)
|
|
264
|
-
|
|
265
|
-
try:
|
|
266
|
-
input("Press Enter to stop...\n")
|
|
267
|
-
except (KeyboardInterrupt, EOFError):
|
|
268
|
-
pass
|
|
269
|
-
finally:
|
|
270
|
-
print("Shutting down server...")
|
|
271
|
-
httpd.shutdown()
|
|
272
|
-
httpd.server_close()
|
|
273
|
-
thread.join()
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
def view_figure(archive_path: str, port: Union[int, None] = None) -> None:
|
|
277
|
-
"""
|
|
278
|
-
Extract and serve a figure archive locally
|
|
279
|
-
|
|
280
|
-
Args:
|
|
281
|
-
archive_path: Path to the tar.gz archive
|
|
282
|
-
port: Optional port number to serve on
|
|
283
|
-
"""
|
|
284
|
-
archive_pathlib = pathlib.Path(archive_path)
|
|
285
|
-
|
|
286
|
-
if not archive_pathlib.exists():
|
|
287
|
-
print(f"Error: Archive file not found: {archive_path}")
|
|
288
|
-
sys.exit(1)
|
|
289
|
-
|
|
290
|
-
if not archive_pathlib.suffix.lower() in [".gz", ".tgz"] or not str(
|
|
291
|
-
archive_pathlib
|
|
292
|
-
).endswith(".tar.gz"):
|
|
293
|
-
print(f"Error: Expected a .tar.gz file, got: {archive_path}")
|
|
294
|
-
sys.exit(1)
|
|
295
|
-
|
|
296
|
-
print(f"Extracting figure archive: {archive_path}")
|
|
297
|
-
|
|
298
|
-
# Create temporary directory and extract files
|
|
299
|
-
with tempfile.TemporaryDirectory(prefix="figpack_view_") as temp_dir:
|
|
300
|
-
temp_path = pathlib.Path(temp_dir)
|
|
301
|
-
|
|
302
|
-
try:
|
|
303
|
-
with tarfile.open(archive_path, "r:gz") as tar:
|
|
304
|
-
tar.extractall(temp_path)
|
|
305
|
-
|
|
306
|
-
# Count extracted files
|
|
307
|
-
extracted_files = list(temp_path.rglob("*"))
|
|
308
|
-
file_count = len([f for f in extracted_files if f.is_file()])
|
|
309
|
-
print(f"Extracted {file_count} files")
|
|
310
|
-
|
|
311
|
-
# Check if index.html exists
|
|
312
|
-
index_html = temp_path / "index.html"
|
|
313
|
-
if not index_html.exists():
|
|
314
|
-
print("Warning: No index.html found in archive")
|
|
315
|
-
print("Available files:")
|
|
316
|
-
for f in sorted(extracted_files):
|
|
317
|
-
if f.is_file():
|
|
318
|
-
print(f" {f.relative_to(temp_path)}")
|
|
319
|
-
|
|
320
|
-
# Serve the files
|
|
321
|
-
serve_files(
|
|
322
|
-
str(temp_path),
|
|
323
|
-
port=port,
|
|
324
|
-
open_in_browser=True,
|
|
325
|
-
allow_origin=None,
|
|
326
|
-
)
|
|
327
|
-
|
|
328
|
-
except tarfile.TarError as e:
|
|
329
|
-
print(f"Error: Failed to extract archive: {e}")
|
|
330
|
-
sys.exit(1)
|
|
331
|
-
|
|
332
|
-
|
|
333
217
|
def main():
|
|
334
218
|
"""Main CLI entry point"""
|
|
335
219
|
parser = argparse.ArgumentParser(
|
figpack/core/_bundle_utils.py
CHANGED
|
@@ -9,23 +9,23 @@ thisdir = pathlib.Path(__file__).parent.resolve()
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def prepare_figure_bundle(
|
|
12
|
-
view: FigpackView, tmpdir: str, *, title: str
|
|
12
|
+
view: FigpackView, tmpdir: str, *, title: str, description: str = None
|
|
13
13
|
) -> None:
|
|
14
14
|
"""
|
|
15
15
|
Prepare a figure bundle in the specified temporary directory.
|
|
16
16
|
|
|
17
17
|
This function:
|
|
18
|
-
1. Copies all files from the figpack-
|
|
18
|
+
1. Copies all files from the figpack-figure-dist directory to tmpdir
|
|
19
19
|
2. Writes the view data to a zarr group
|
|
20
20
|
3. Consolidates zarr metadata
|
|
21
21
|
|
|
22
22
|
Args:
|
|
23
23
|
view: The figpack view to prepare
|
|
24
24
|
tmpdir: The temporary directory to prepare the bundle in
|
|
25
|
-
title:
|
|
25
|
+
title: Title for the figure (required)
|
|
26
26
|
description: Optional description for the figure (markdown supported)
|
|
27
27
|
"""
|
|
28
|
-
html_dir = thisdir / ".." / "figpack-
|
|
28
|
+
html_dir = thisdir / ".." / "figpack-figure-dist"
|
|
29
29
|
if not os.path.exists(html_dir):
|
|
30
30
|
raise SystemExit(f"Error: directory not found: {html_dir}")
|
|
31
31
|
|
|
@@ -50,8 +50,7 @@ def prepare_figure_bundle(
|
|
|
50
50
|
view._write_to_zarr_group(zarr_group)
|
|
51
51
|
|
|
52
52
|
# Add title and description as attributes on the top-level zarr group
|
|
53
|
-
|
|
54
|
-
zarr_group.attrs["title"] = title
|
|
53
|
+
zarr_group.attrs["title"] = title
|
|
55
54
|
if description is not None:
|
|
56
55
|
zarr_group.attrs["description"] = description
|
|
57
56
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import pathlib
|
|
2
|
+
import tempfile
|
|
3
|
+
|
|
4
|
+
from ._bundle_utils import prepare_figure_bundle
|
|
5
|
+
from .figpack_view import FigpackView
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _save_figure(view: FigpackView, output_path: str, *, title: str):
|
|
9
|
+
"""
|
|
10
|
+
Save the figure to a folder or a .tar.gz file
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
view: FigpackView instance to save
|
|
14
|
+
output_path: Output path (destination folder or .tar.gz file path)
|
|
15
|
+
"""
|
|
16
|
+
output_path = pathlib.Path(output_path)
|
|
17
|
+
if (output_path.suffix == ".gz" and output_path.suffixes[-2] == ".tar") or (
|
|
18
|
+
output_path.suffix == ".tgz"
|
|
19
|
+
):
|
|
20
|
+
# It's a .tar.gz file
|
|
21
|
+
with tempfile.TemporaryDirectory(prefix="figpack_save_") as tmpdir:
|
|
22
|
+
prepare_figure_bundle(view, tmpdir, title=title)
|
|
23
|
+
# Create tar.gz file
|
|
24
|
+
import tarfile
|
|
25
|
+
|
|
26
|
+
with tarfile.open(output_path, "w:gz") as tar:
|
|
27
|
+
tar.add(tmpdir, arcname=".")
|
|
28
|
+
else:
|
|
29
|
+
# It's a folder
|
|
30
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
prepare_figure_bundle(view, str(output_path), title=title)
|
figpack/core/_server_manager.py
CHANGED
|
@@ -206,8 +206,6 @@ class ProcessServerManager:
|
|
|
206
206
|
# Start directory monitoring thread
|
|
207
207
|
self._start_directory_monitor()
|
|
208
208
|
|
|
209
|
-
print(f"Started figpack server at http://localhost:{port} serving {temp_dir}")
|
|
210
|
-
|
|
211
209
|
return f"http://localhost:{port}", port
|
|
212
210
|
|
|
213
211
|
def _stop_server(self):
|
figpack/core/_show_view.py
CHANGED
|
@@ -84,23 +84,18 @@ thisdir = pathlib.Path(__file__).parent.resolve()
|
|
|
84
84
|
def _show_view(
|
|
85
85
|
view: FigpackView,
|
|
86
86
|
*,
|
|
87
|
-
open_in_browser: bool
|
|
88
|
-
port: Union[int, None]
|
|
89
|
-
allow_origin: Union[str, None]
|
|
90
|
-
upload: bool
|
|
91
|
-
ephemeral: bool
|
|
92
|
-
title:
|
|
93
|
-
description: Union[str, None]
|
|
94
|
-
inline:
|
|
95
|
-
inline_height: int
|
|
96
|
-
|
|
87
|
+
open_in_browser: bool,
|
|
88
|
+
port: Union[int, None],
|
|
89
|
+
allow_origin: Union[str, None],
|
|
90
|
+
upload: bool,
|
|
91
|
+
ephemeral: bool,
|
|
92
|
+
title: str,
|
|
93
|
+
description: Union[str, None],
|
|
94
|
+
inline: bool,
|
|
95
|
+
inline_height: int,
|
|
96
|
+
wait_for_input: bool,
|
|
97
|
+
_local_figure_name: Union[str, None],
|
|
97
98
|
):
|
|
98
|
-
# Determine if we should use inline display
|
|
99
|
-
use_inline = inline
|
|
100
|
-
if inline is None:
|
|
101
|
-
# Auto-detect: use inline if we're in a notebook
|
|
102
|
-
use_inline = _is_in_notebook()
|
|
103
|
-
|
|
104
99
|
if upload:
|
|
105
100
|
# Upload behavior: create temporary directory for this upload only
|
|
106
101
|
with tempfile.TemporaryDirectory(prefix="figpack_upload_") as tmpdir:
|
|
@@ -115,10 +110,14 @@ def _show_view(
|
|
|
115
110
|
|
|
116
111
|
# Upload the bundle
|
|
117
112
|
figure_url = _upload_bundle(
|
|
118
|
-
tmpdir,
|
|
113
|
+
tmpdir,
|
|
114
|
+
api_key,
|
|
115
|
+
title=title,
|
|
116
|
+
ephemeral=ephemeral,
|
|
117
|
+
use_consolidated_metadata_only=True,
|
|
119
118
|
)
|
|
120
119
|
|
|
121
|
-
if
|
|
120
|
+
if inline:
|
|
122
121
|
# For uploaded figures, display the remote URL inline and continue
|
|
123
122
|
_display_inline_iframe(figure_url, inline_height)
|
|
124
123
|
else:
|
|
@@ -129,8 +128,9 @@ def _show_view(
|
|
|
129
128
|
else:
|
|
130
129
|
print(f"View the figure at: {figure_url}")
|
|
131
130
|
# Wait until user presses Enter
|
|
132
|
-
input("Press Enter to continue...")
|
|
133
131
|
|
|
132
|
+
if wait_for_input:
|
|
133
|
+
input("Press Enter to continue...")
|
|
134
134
|
return figure_url
|
|
135
135
|
else:
|
|
136
136
|
# Local server behavior: use process-level server manager
|
|
@@ -155,7 +155,7 @@ def _show_view(
|
|
|
155
155
|
figure_subdir_name = figure_dir.name
|
|
156
156
|
figure_url = f"{base_url}/{figure_subdir_name}"
|
|
157
157
|
|
|
158
|
-
if
|
|
158
|
+
if inline:
|
|
159
159
|
# Display inline and continue (don't block)
|
|
160
160
|
_display_inline_iframe(figure_url, inline_height)
|
|
161
161
|
else:
|
|
@@ -165,7 +165,7 @@ def _show_view(
|
|
|
165
165
|
print(f"Opening {figure_url} in browser.")
|
|
166
166
|
else:
|
|
167
167
|
print(f"Open {figure_url} in your browser to view the visualization.")
|
|
168
|
-
# Wait until user presses Enter
|
|
169
|
-
input("Press Enter to continue...")
|
|
170
168
|
|
|
169
|
+
if wait_for_input:
|
|
170
|
+
input("Press Enter to continue...")
|
|
171
171
|
return figure_url
|
figpack/core/_upload_bundle.py
CHANGED
|
@@ -69,27 +69,54 @@ def _get_batch_signed_urls(figure_url: str, files_batch: list, api_key: str) ->
|
|
|
69
69
|
|
|
70
70
|
|
|
71
71
|
def _upload_single_file_with_signed_url(
|
|
72
|
-
relative_path: str, file_path: pathlib.Path, signed_url: str
|
|
72
|
+
relative_path: str, file_path: pathlib.Path, signed_url: str, num_retries: int = 4
|
|
73
73
|
) -> str:
|
|
74
74
|
"""
|
|
75
|
-
Upload a single file using a pre-obtained signed URL
|
|
75
|
+
Upload a single file using a pre-obtained signed URL with exponential backoff retries
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
relative_path: The relative path of the file
|
|
79
|
+
file_path: The path to the file to upload
|
|
80
|
+
signed_url: The signed URL to upload to
|
|
81
|
+
num_retries: Number of retries on failure with exponential backoff (default: 4)
|
|
76
82
|
|
|
77
83
|
Returns:
|
|
78
84
|
str: The relative path of the uploaded file
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
Exception: If upload fails after all retries are exhausted
|
|
79
88
|
"""
|
|
80
|
-
# Upload file to signed URL
|
|
81
89
|
content_type = _determine_content_type(relative_path)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
signed_url, data=f, headers={"Content-Type": content_type}
|
|
85
|
-
)
|
|
90
|
+
retries_remaining = num_retries
|
|
91
|
+
last_exception = None
|
|
86
92
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
93
|
+
while retries_remaining >= 0:
|
|
94
|
+
try:
|
|
95
|
+
with open(file_path, "rb") as f:
|
|
96
|
+
upload_response = requests.put(
|
|
97
|
+
signed_url, data=f, headers={"Content-Type": content_type}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if upload_response.ok:
|
|
101
|
+
return relative_path
|
|
102
|
+
|
|
103
|
+
last_exception = Exception(
|
|
104
|
+
f"Failed to upload {relative_path} to signed URL: HTTP {upload_response.status_code}"
|
|
105
|
+
)
|
|
106
|
+
except Exception as e:
|
|
107
|
+
last_exception = e
|
|
108
|
+
|
|
109
|
+
if retries_remaining > 0:
|
|
110
|
+
backoff_seconds = 2 ** (num_retries - retries_remaining)
|
|
111
|
+
print(
|
|
112
|
+
f"Upload failed for {relative_path}, retrying in {backoff_seconds} seconds..."
|
|
113
|
+
)
|
|
114
|
+
time.sleep(backoff_seconds)
|
|
115
|
+
retries_remaining -= 1
|
|
116
|
+
else:
|
|
117
|
+
break
|
|
91
118
|
|
|
92
|
-
|
|
119
|
+
raise last_exception
|
|
93
120
|
|
|
94
121
|
|
|
95
122
|
MAX_WORKERS_FOR_UPLOAD = 16
|
|
@@ -226,10 +253,22 @@ def _finalize_figure(figure_url: str, api_key: str) -> dict:
|
|
|
226
253
|
|
|
227
254
|
|
|
228
255
|
def _upload_bundle(
|
|
229
|
-
tmpdir: str,
|
|
256
|
+
tmpdir: str,
|
|
257
|
+
api_key: str,
|
|
258
|
+
title: str = None,
|
|
259
|
+
ephemeral: bool = False,
|
|
260
|
+
use_consolidated_metadata_only: bool = False,
|
|
230
261
|
) -> str:
|
|
231
262
|
"""
|
|
232
263
|
Upload the prepared bundle to the cloud using the new database-driven approach
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
tmpdir: Path to the temporary directory containing the bundle
|
|
267
|
+
api_key: API key for authentication
|
|
268
|
+
title: Optional title for the figure
|
|
269
|
+
ephemeral: Whether to create an ephemeral figure
|
|
270
|
+
use_consolidated_metadata_only: If True, excludes individual zarr metadata files
|
|
271
|
+
(.zgroup, .zarray, .zattrs) since they are included in .zmetadata
|
|
233
272
|
"""
|
|
234
273
|
tmpdir_path = pathlib.Path(tmpdir)
|
|
235
274
|
|
|
@@ -241,6 +280,10 @@ def _upload_bundle(
|
|
|
241
280
|
for file_path in tmpdir_path.rglob("*"):
|
|
242
281
|
if file_path.is_file():
|
|
243
282
|
relative_path = file_path.relative_to(tmpdir_path)
|
|
283
|
+
# Skip individual zarr metadata files if using consolidated metadata only
|
|
284
|
+
if use_consolidated_metadata_only:
|
|
285
|
+
if str(relative_path).endswith((".zgroup", ".zarray", ".zattrs")):
|
|
286
|
+
continue
|
|
244
287
|
all_files.append((str(relative_path), file_path))
|
|
245
288
|
|
|
246
289
|
# Calculate total files and size for metadata
|
|
@@ -364,17 +407,13 @@ def _upload_bundle(
|
|
|
364
407
|
if "manifest.json" not in signed_urls_map:
|
|
365
408
|
raise Exception("No signed URL returned for manifest.json")
|
|
366
409
|
|
|
367
|
-
# Upload manifest using
|
|
368
|
-
|
|
410
|
+
# Upload manifest using the same retry function
|
|
411
|
+
_upload_single_file_with_signed_url(
|
|
412
|
+
"manifest.json",
|
|
413
|
+
temp_file_path,
|
|
369
414
|
signed_urls_map["manifest.json"],
|
|
370
|
-
|
|
371
|
-
headers={"Content-Type": "application/json"},
|
|
415
|
+
num_retries=4,
|
|
372
416
|
)
|
|
373
|
-
|
|
374
|
-
if not upload_response.ok:
|
|
375
|
-
raise Exception(
|
|
376
|
-
f"Failed to upload manifest.json to signed URL: HTTP {upload_response.status_code}"
|
|
377
|
-
)
|
|
378
417
|
finally:
|
|
379
418
|
# Clean up temporary file
|
|
380
419
|
temp_file_path.unlink(missing_ok=True)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core functionality for viewing figures locally
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import pathlib
|
|
6
|
+
import socket
|
|
7
|
+
import sys
|
|
8
|
+
import tarfile
|
|
9
|
+
import tempfile
|
|
10
|
+
import threading
|
|
11
|
+
import webbrowser
|
|
12
|
+
from typing import Union
|
|
13
|
+
|
|
14
|
+
from ._server_manager import CORSRequestHandler, ThreadingHTTPServer
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def serve_files(
|
|
18
|
+
tmpdir: str,
|
|
19
|
+
*,
|
|
20
|
+
port: Union[int, None],
|
|
21
|
+
open_in_browser: bool = False,
|
|
22
|
+
allow_origin: Union[str, None] = None,
|
|
23
|
+
):
|
|
24
|
+
"""
|
|
25
|
+
Serve files from a directory using a simple HTTP server.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
tmpdir: Directory to serve
|
|
29
|
+
port: Port number for local server
|
|
30
|
+
open_in_browser: Whether to open in browser automatically
|
|
31
|
+
allow_origin: CORS allow origin header
|
|
32
|
+
"""
|
|
33
|
+
# if port is None, find a free port
|
|
34
|
+
if port is None:
|
|
35
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
36
|
+
s.bind(("", 0))
|
|
37
|
+
port = s.getsockname()[1]
|
|
38
|
+
|
|
39
|
+
tmpdir = pathlib.Path(tmpdir)
|
|
40
|
+
tmpdir = tmpdir.resolve()
|
|
41
|
+
if not tmpdir.exists() or not tmpdir.is_dir():
|
|
42
|
+
raise SystemExit(f"Directory not found: {tmpdir}")
|
|
43
|
+
|
|
44
|
+
# Configure handler with directory and allow_origin
|
|
45
|
+
def handler_factory(*args, **kwargs):
|
|
46
|
+
return CORSRequestHandler(
|
|
47
|
+
*args, directory=str(tmpdir), allow_origin=allow_origin, **kwargs
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
httpd = ThreadingHTTPServer(("0.0.0.0", port), handler_factory)
|
|
51
|
+
print(f"Serving {tmpdir} at http://localhost:{port} (CORS → {allow_origin})")
|
|
52
|
+
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
|
53
|
+
thread.start()
|
|
54
|
+
|
|
55
|
+
if open_in_browser:
|
|
56
|
+
webbrowser.open(f"http://localhost:{port}")
|
|
57
|
+
print(f"Opening http://localhost:{port} in your browser.")
|
|
58
|
+
else:
|
|
59
|
+
print(
|
|
60
|
+
f"Open http://localhost:{port} in your browser to view the visualization."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
input("Press Enter to stop...\n")
|
|
65
|
+
except (KeyboardInterrupt, EOFError):
|
|
66
|
+
pass
|
|
67
|
+
finally:
|
|
68
|
+
print("Shutting down server...")
|
|
69
|
+
httpd.shutdown()
|
|
70
|
+
httpd.server_close()
|
|
71
|
+
thread.join()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def view_figure(figure_path: str, port: Union[int, None] = None) -> None:
|
|
75
|
+
"""
|
|
76
|
+
Extract and serve a figure archive locally
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
figure_path: Path to a .tar.gz archive file or a directory
|
|
80
|
+
port: Optional port number to serve on
|
|
81
|
+
"""
|
|
82
|
+
figure_pathlib = pathlib.Path(figure_path)
|
|
83
|
+
|
|
84
|
+
if not figure_pathlib.exists():
|
|
85
|
+
print(f"Error: Archive file not found: {figure_path}")
|
|
86
|
+
sys.exit(1)
|
|
87
|
+
|
|
88
|
+
if figure_pathlib.is_dir():
|
|
89
|
+
# We assume it's a directory
|
|
90
|
+
serve_files(
|
|
91
|
+
str(figure_pathlib),
|
|
92
|
+
port=port,
|
|
93
|
+
open_in_browser=True,
|
|
94
|
+
allow_origin=None,
|
|
95
|
+
)
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
if not figure_pathlib.suffix.lower() in [".gz", ".tgz"] or not str(
|
|
99
|
+
figure_pathlib
|
|
100
|
+
).endswith(".tar.gz"):
|
|
101
|
+
print(f"Error: Archive file must be a .tar.gz file: {figure_path}")
|
|
102
|
+
sys.exit(1)
|
|
103
|
+
|
|
104
|
+
print(f"Extracting figure archive: {figure_path}")
|
|
105
|
+
|
|
106
|
+
# Create temporary directory and extract files
|
|
107
|
+
with tempfile.TemporaryDirectory(prefix="figpack_view_") as temp_dir:
|
|
108
|
+
temp_path = pathlib.Path(temp_dir)
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
with tarfile.open(figure_path, "r:gz") as tar:
|
|
112
|
+
tar.extractall(temp_path)
|
|
113
|
+
|
|
114
|
+
# Count extracted files
|
|
115
|
+
extracted_files = list(temp_path.rglob("*"))
|
|
116
|
+
file_count = len([f for f in extracted_files if f.is_file()])
|
|
117
|
+
print(f"Extracted {file_count} files")
|
|
118
|
+
|
|
119
|
+
# Check if index.html exists
|
|
120
|
+
index_html = temp_path / "index.html"
|
|
121
|
+
if not index_html.exists():
|
|
122
|
+
print("Warning: No index.html found in archive")
|
|
123
|
+
print("Available files:")
|
|
124
|
+
for f in sorted(extracted_files):
|
|
125
|
+
if f.is_file():
|
|
126
|
+
print(f" {f.relative_to(temp_path)}")
|
|
127
|
+
|
|
128
|
+
# Serve the files
|
|
129
|
+
serve_files(
|
|
130
|
+
str(temp_path),
|
|
131
|
+
port=port,
|
|
132
|
+
open_in_browser=True,
|
|
133
|
+
allow_origin=None,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
except tarfile.TarError as e:
|
|
137
|
+
print(f"Error: Failed to extract archive: {e}")
|
|
138
|
+
sys.exit(1)
|