figpack 0.2.3__py3-none-any.whl → 0.2.5__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 +3 -4
- figpack/core/_save_figure.py +31 -0
- figpack/core/_server_manager.py +9 -5
- figpack/core/_show_view.py +20 -21
- figpack/core/_upload_bundle.py +46 -22
- figpack/core/_view_figure.py +138 -0
- figpack/core/config.py +2 -0
- figpack/core/figpack_view.py +79 -22
- figpack/figpack-gui-dist/assets/{index-DUR9Dmwh.js → index-CrYQmIda.js} +70 -70
- figpack/figpack-gui-dist/index.html +1 -1
- figpack/spike_sorting/views/RasterPlot.py +77 -0
- figpack/spike_sorting/views/RasterPlotItem.py +28 -0
- figpack/spike_sorting/views/__init__.py +4 -0
- figpack/views/Gallery.py +88 -0
- figpack/views/GalleryItem.py +47 -0
- figpack/views/Image.py +37 -0
- figpack/views/__init__.py +2 -0
- figpack-0.2.5.dist-info/METADATA +96 -0
- {figpack-0.2.3.dist-info → figpack-0.2.5.dist-info}/RECORD +25 -19
- figpack-0.2.3.dist-info/METADATA +0 -168
- {figpack-0.2.3.dist-info → figpack-0.2.5.dist-info}/WHEEL +0 -0
- {figpack-0.2.3.dist-info → figpack-0.2.5.dist-info}/entry_points.txt +0 -0
- {figpack-0.2.3.dist-info → figpack-0.2.5.dist-info}/licenses/LICENSE +0 -0
- {figpack-0.2.3.dist-info → figpack-0.2.5.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,7 +9,7 @@ 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.
|
|
@@ -22,7 +22,7 @@ def prepare_figure_bundle(
|
|
|
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
28
|
html_dir = thisdir / ".." / "figpack-gui-dist"
|
|
@@ -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):
|
|
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)
|
|
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))
|
figpack/core/_server_manager.py
CHANGED
|
@@ -138,11 +138,17 @@ class ProcessServerManager:
|
|
|
138
138
|
self._create_process_info_file()
|
|
139
139
|
return self._temp_dir
|
|
140
140
|
|
|
141
|
-
def create_figure_subdir(
|
|
141
|
+
def create_figure_subdir(
|
|
142
|
+
self, *, _local_figure_name: Optional[str] = None
|
|
143
|
+
) -> pathlib.Path:
|
|
142
144
|
"""Create a unique subdirectory for a figure within the process temp dir."""
|
|
143
145
|
temp_dir = self.get_temp_dir()
|
|
144
|
-
|
|
145
|
-
|
|
146
|
+
local_figure_name = (
|
|
147
|
+
"figure_" + str(uuid.uuid4())[:8]
|
|
148
|
+
if _local_figure_name is None
|
|
149
|
+
else _local_figure_name
|
|
150
|
+
)
|
|
151
|
+
figure_dir = temp_dir / f"{local_figure_name}"
|
|
146
152
|
figure_dir.mkdir(exist_ok=True)
|
|
147
153
|
return figure_dir
|
|
148
154
|
|
|
@@ -200,8 +206,6 @@ class ProcessServerManager:
|
|
|
200
206
|
# Start directory monitoring thread
|
|
201
207
|
self._start_directory_monitor()
|
|
202
208
|
|
|
203
|
-
print(f"Started figpack server at http://localhost:{port} serving {temp_dir}")
|
|
204
|
-
|
|
205
209
|
return f"http://localhost:{port}", port
|
|
206
210
|
|
|
207
211
|
def _stop_server(self):
|
figpack/core/_show_view.py
CHANGED
|
@@ -84,22 +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
|
|
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],
|
|
96
98
|
):
|
|
97
|
-
# Determine if we should use inline display
|
|
98
|
-
use_inline = inline
|
|
99
|
-
if inline is None:
|
|
100
|
-
# Auto-detect: use inline if we're in a notebook
|
|
101
|
-
use_inline = _is_in_notebook()
|
|
102
|
-
|
|
103
99
|
if upload:
|
|
104
100
|
# Upload behavior: create temporary directory for this upload only
|
|
105
101
|
with tempfile.TemporaryDirectory(prefix="figpack_upload_") as tmpdir:
|
|
@@ -117,7 +113,7 @@ def _show_view(
|
|
|
117
113
|
tmpdir, api_key, title=title, ephemeral=ephemeral
|
|
118
114
|
)
|
|
119
115
|
|
|
120
|
-
if
|
|
116
|
+
if inline:
|
|
121
117
|
# For uploaded figures, display the remote URL inline and continue
|
|
122
118
|
_display_inline_iframe(figure_url, inline_height)
|
|
123
119
|
else:
|
|
@@ -128,15 +124,18 @@ def _show_view(
|
|
|
128
124
|
else:
|
|
129
125
|
print(f"View the figure at: {figure_url}")
|
|
130
126
|
# Wait until user presses Enter
|
|
131
|
-
input("Press Enter to continue...")
|
|
132
127
|
|
|
128
|
+
if wait_for_input:
|
|
129
|
+
input("Press Enter to continue...")
|
|
133
130
|
return figure_url
|
|
134
131
|
else:
|
|
135
132
|
# Local server behavior: use process-level server manager
|
|
136
133
|
server_manager = ProcessServerManager.get_instance()
|
|
137
134
|
|
|
138
135
|
# Create figure subdirectory in process temp directory
|
|
139
|
-
figure_dir = server_manager.create_figure_subdir(
|
|
136
|
+
figure_dir = server_manager.create_figure_subdir(
|
|
137
|
+
_local_figure_name=_local_figure_name
|
|
138
|
+
)
|
|
140
139
|
|
|
141
140
|
# Prepare the figure bundle in the subdirectory
|
|
142
141
|
prepare_figure_bundle(
|
|
@@ -152,7 +151,7 @@ def _show_view(
|
|
|
152
151
|
figure_subdir_name = figure_dir.name
|
|
153
152
|
figure_url = f"{base_url}/{figure_subdir_name}"
|
|
154
153
|
|
|
155
|
-
if
|
|
154
|
+
if inline:
|
|
156
155
|
# Display inline and continue (don't block)
|
|
157
156
|
_display_inline_iframe(figure_url, inline_height)
|
|
158
157
|
else:
|
|
@@ -162,7 +161,7 @@ def _show_view(
|
|
|
162
161
|
print(f"Opening {figure_url} in browser.")
|
|
163
162
|
else:
|
|
164
163
|
print(f"Open {figure_url} in your browser to view the visualization.")
|
|
165
|
-
# Wait until user presses Enter
|
|
166
|
-
input("Press Enter to continue...")
|
|
167
164
|
|
|
165
|
+
if wait_for_input:
|
|
166
|
+
input("Press Enter to continue...")
|
|
168
167
|
return figure_url
|
figpack/core/_upload_bundle.py
CHANGED
|
@@ -11,7 +11,7 @@ import requests
|
|
|
11
11
|
|
|
12
12
|
from .. import __version__
|
|
13
13
|
|
|
14
|
-
from .config import FIGPACK_API_BASE_URL
|
|
14
|
+
from .config import FIGPACK_API_BASE_URL, FIGPACK_BUCKET
|
|
15
15
|
|
|
16
16
|
thisdir = pathlib.Path(__file__).parent.resolve()
|
|
17
17
|
|
|
@@ -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
|
+
)
|
|
91
99
|
|
|
92
|
-
|
|
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
|
|
118
|
+
|
|
119
|
+
raise last_exception
|
|
93
120
|
|
|
94
121
|
|
|
95
122
|
MAX_WORKERS_FOR_UPLOAD = 16
|
|
@@ -156,6 +183,7 @@ def _create_or_get_figure(
|
|
|
156
183
|
payload = {
|
|
157
184
|
"figureHash": figure_hash,
|
|
158
185
|
"figpackVersion": __version__,
|
|
186
|
+
"bucket": FIGPACK_BUCKET,
|
|
159
187
|
}
|
|
160
188
|
|
|
161
189
|
# API key is optional for ephemeral figures
|
|
@@ -363,17 +391,13 @@ def _upload_bundle(
|
|
|
363
391
|
if "manifest.json" not in signed_urls_map:
|
|
364
392
|
raise Exception("No signed URL returned for manifest.json")
|
|
365
393
|
|
|
366
|
-
# Upload manifest using
|
|
367
|
-
|
|
394
|
+
# Upload manifest using the same retry function
|
|
395
|
+
_upload_single_file_with_signed_url(
|
|
396
|
+
"manifest.json",
|
|
397
|
+
temp_file_path,
|
|
368
398
|
signed_urls_map["manifest.json"],
|
|
369
|
-
|
|
370
|
-
headers={"Content-Type": "application/json"},
|
|
399
|
+
num_retries=4,
|
|
371
400
|
)
|
|
372
|
-
|
|
373
|
-
if not upload_response.ok:
|
|
374
|
-
raise Exception(
|
|
375
|
-
f"Failed to upload manifest.json to signed URL: HTTP {upload_response.status_code}"
|
|
376
|
-
)
|
|
377
401
|
finally:
|
|
378
402
|
# Clean up temporary file
|
|
379
403
|
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)
|