figpack 0.2.1__py3-none-any.whl → 0.2.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 +1 -1
- figpack/cli.py +60 -3
- figpack/core/_server_manager.py +304 -0
- figpack/core/_show_view.py +128 -92
- figpack/core/_upload_bundle.py +19 -9
- figpack/core/figpack_view.py +34 -2
- figpack/figpack-gui-dist/assets/index-DUR9Dmwh.js +847 -0
- figpack/figpack-gui-dist/index.html +1 -1
- figpack/franklab/__init__.py +5 -0
- figpack/franklab/views/TrackAnimation.py +153 -0
- figpack/franklab/views/__init__.py +9 -0
- figpack/spike_sorting/views/AutocorrelogramItem.py +0 -10
- figpack/spike_sorting/views/Autocorrelograms.py +2 -0
- figpack/spike_sorting/views/AverageWaveforms.py +147 -0
- figpack/spike_sorting/views/CrossCorrelogramItem.py +0 -11
- figpack/spike_sorting/views/CrossCorrelograms.py +2 -0
- figpack/spike_sorting/views/SpikeAmplitudes.py +89 -0
- figpack/spike_sorting/views/SpikeAmplitudesItem.py +38 -0
- figpack/spike_sorting/views/__init__.py +6 -0
- {figpack-0.2.1.dist-info → figpack-0.2.3.dist-info}/METADATA +3 -2
- figpack-0.2.3.dist-info/RECORD +47 -0
- figpack/figpack-gui-dist/assets/index-BqYF6BN-.js +0 -846
- figpack-0.2.1.dist-info/RECORD +0 -40
- {figpack-0.2.1.dist-info → figpack-0.2.3.dist-info}/WHEEL +0 -0
- {figpack-0.2.1.dist-info → figpack-0.2.3.dist-info}/entry_points.txt +0 -0
- {figpack-0.2.1.dist-info → figpack-0.2.3.dist-info}/licenses/LICENSE +0 -0
- {figpack-0.2.1.dist-info → figpack-0.2.3.dist-info}/top_level.txt +0 -0
figpack/__init__.py
CHANGED
figpack/cli.py
CHANGED
|
@@ -13,13 +13,13 @@ import threading
|
|
|
13
13
|
import webbrowser
|
|
14
14
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
15
15
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
16
|
-
from typing import Dict,
|
|
17
|
-
from urllib.parse import urljoin
|
|
16
|
+
from typing import Dict, Tuple, Union
|
|
17
|
+
from urllib.parse import urljoin
|
|
18
18
|
|
|
19
19
|
import requests
|
|
20
20
|
|
|
21
21
|
from . import __version__
|
|
22
|
-
from .core.
|
|
22
|
+
from .core._server_manager import CORSRequestHandler
|
|
23
23
|
|
|
24
24
|
MAX_WORKERS_FOR_DOWNLOAD = 16
|
|
25
25
|
|
|
@@ -216,6 +216,63 @@ def download_figure(figure_url: str, dest_path: str) -> None:
|
|
|
216
216
|
print(f"Archive saved to: {dest_path}")
|
|
217
217
|
|
|
218
218
|
|
|
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
|
+
|
|
219
276
|
def view_figure(archive_path: str, port: Union[int, None] = None) -> None:
|
|
220
277
|
"""
|
|
221
278
|
Extract and serve a figure archive locally
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import atexit
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import pathlib
|
|
5
|
+
import psutil
|
|
6
|
+
import shutil
|
|
7
|
+
import socket
|
|
8
|
+
import tempfile
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
13
|
+
from typing import Optional, Union
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CORSRequestHandler(SimpleHTTPRequestHandler):
|
|
17
|
+
def __init__(self, *args, allow_origin=None, **kwargs):
|
|
18
|
+
self.allow_origin = allow_origin
|
|
19
|
+
super().__init__(*args, **kwargs)
|
|
20
|
+
|
|
21
|
+
def end_headers(self):
|
|
22
|
+
if self.allow_origin is not None:
|
|
23
|
+
self.send_header("Access-Control-Allow-Origin", self.allow_origin)
|
|
24
|
+
self.send_header("Vary", "Origin")
|
|
25
|
+
self.send_header("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
|
|
26
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type, Range")
|
|
27
|
+
self.send_header(
|
|
28
|
+
"Access-Control-Expose-Headers",
|
|
29
|
+
"Accept-Ranges, Content-Encoding, Content-Length, Content-Range",
|
|
30
|
+
)
|
|
31
|
+
super().end_headers()
|
|
32
|
+
|
|
33
|
+
def do_OPTIONS(self):
|
|
34
|
+
self.send_response(204, "No Content")
|
|
35
|
+
self.end_headers()
|
|
36
|
+
|
|
37
|
+
def log_message(self, fmt, *args):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _is_process_alive(pid: int) -> bool:
|
|
42
|
+
"""Check if a process with the given PID is still alive."""
|
|
43
|
+
try:
|
|
44
|
+
return psutil.pid_exists(pid)
|
|
45
|
+
except Exception:
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _is_port_in_use(port: int) -> bool:
|
|
50
|
+
"""Check if a port is currently in use."""
|
|
51
|
+
try:
|
|
52
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
53
|
+
s.settimeout(1)
|
|
54
|
+
result = s.connect_ex(("localhost", port))
|
|
55
|
+
return result == 0
|
|
56
|
+
except Exception:
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _cleanup_orphaned_directories():
|
|
61
|
+
"""Clean up orphaned figpack process directories."""
|
|
62
|
+
temp_root = pathlib.Path(tempfile.gettempdir())
|
|
63
|
+
|
|
64
|
+
for item in temp_root.iterdir():
|
|
65
|
+
if item.is_dir() and item.name.startswith("figpack_process_"):
|
|
66
|
+
process_info_file = item / "process_info.json"
|
|
67
|
+
|
|
68
|
+
if process_info_file.exists():
|
|
69
|
+
try:
|
|
70
|
+
with open(process_info_file, "r") as f:
|
|
71
|
+
info = json.load(f)
|
|
72
|
+
|
|
73
|
+
pid = info.get("pid")
|
|
74
|
+
port = info.get("port")
|
|
75
|
+
|
|
76
|
+
# Check if process is dead or port is not in use
|
|
77
|
+
process_dead = pid is None or not _is_process_alive(pid)
|
|
78
|
+
port_free = port is None or not _is_port_in_use(port)
|
|
79
|
+
|
|
80
|
+
if process_dead or port_free:
|
|
81
|
+
print(f"Cleaning up orphaned directory: {item}")
|
|
82
|
+
shutil.rmtree(item)
|
|
83
|
+
|
|
84
|
+
except Exception as e:
|
|
85
|
+
# If we can't read the process info, assume it's orphaned
|
|
86
|
+
print(f"Cleaning up unreadable directory: {item} (error: {e})")
|
|
87
|
+
try:
|
|
88
|
+
shutil.rmtree(item)
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
else:
|
|
92
|
+
# No process info file, likely orphaned
|
|
93
|
+
print(f"Cleaning up directory without process info: {item}")
|
|
94
|
+
try:
|
|
95
|
+
shutil.rmtree(item)
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ProcessServerManager:
|
|
101
|
+
"""
|
|
102
|
+
Manages a single server and temporary directory per process.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
_instance: Optional["ProcessServerManager"] = None
|
|
106
|
+
_lock = threading.Lock()
|
|
107
|
+
|
|
108
|
+
def __init__(self):
|
|
109
|
+
self._temp_dir: Optional[pathlib.Path] = None
|
|
110
|
+
self._server: Optional[ThreadingHTTPServer] = None
|
|
111
|
+
self._server_thread: Optional[threading.Thread] = None
|
|
112
|
+
self._port: Optional[int] = None
|
|
113
|
+
self._allow_origin: Optional[str] = None
|
|
114
|
+
self._monitor_thread: Optional[threading.Thread] = None
|
|
115
|
+
self._stop_monitoring = threading.Event()
|
|
116
|
+
|
|
117
|
+
# Register cleanup on process exit
|
|
118
|
+
atexit.register(self._cleanup)
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def get_instance(cls) -> "ProcessServerManager":
|
|
122
|
+
"""Get the singleton instance of the server manager."""
|
|
123
|
+
if cls._instance is None:
|
|
124
|
+
with cls._lock:
|
|
125
|
+
if cls._instance is None:
|
|
126
|
+
cls._instance = cls()
|
|
127
|
+
return cls._instance
|
|
128
|
+
|
|
129
|
+
def get_temp_dir(self) -> pathlib.Path:
|
|
130
|
+
"""Get or create the process-level temporary directory."""
|
|
131
|
+
if self._temp_dir is None:
|
|
132
|
+
# Clean up orphaned directories before creating new one
|
|
133
|
+
_cleanup_orphaned_directories()
|
|
134
|
+
|
|
135
|
+
self._temp_dir = pathlib.Path(tempfile.mkdtemp(prefix="figpack_process_"))
|
|
136
|
+
|
|
137
|
+
# Create process info file
|
|
138
|
+
self._create_process_info_file()
|
|
139
|
+
return self._temp_dir
|
|
140
|
+
|
|
141
|
+
def create_figure_subdir(self) -> pathlib.Path:
|
|
142
|
+
"""Create a unique subdirectory for a figure within the process temp dir."""
|
|
143
|
+
temp_dir = self.get_temp_dir()
|
|
144
|
+
figure_id = str(uuid.uuid4())[:8] # Short unique ID
|
|
145
|
+
figure_dir = temp_dir / f"figure_{figure_id}"
|
|
146
|
+
figure_dir.mkdir(exist_ok=True)
|
|
147
|
+
return figure_dir
|
|
148
|
+
|
|
149
|
+
def start_server(
|
|
150
|
+
self, port: Optional[int] = None, allow_origin: Optional[str] = None
|
|
151
|
+
) -> tuple[str, int]:
|
|
152
|
+
"""
|
|
153
|
+
Start the server if not already running, or return existing server info.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
tuple: (base_url, port)
|
|
157
|
+
"""
|
|
158
|
+
# If server is already running with compatible settings, return existing info
|
|
159
|
+
if (
|
|
160
|
+
self._server is not None
|
|
161
|
+
and self._server_thread is not None
|
|
162
|
+
and self._server_thread.is_alive()
|
|
163
|
+
and (allow_origin is None or self._allow_origin == allow_origin)
|
|
164
|
+
):
|
|
165
|
+
return f"http://localhost:{self._port}", self._port
|
|
166
|
+
|
|
167
|
+
# Stop existing server if settings are incompatible
|
|
168
|
+
if self._server is not None:
|
|
169
|
+
self._stop_server()
|
|
170
|
+
|
|
171
|
+
# Find available port if not specified
|
|
172
|
+
if port is None:
|
|
173
|
+
import socket
|
|
174
|
+
|
|
175
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
176
|
+
s.bind(("", 0))
|
|
177
|
+
port = s.getsockname()[1]
|
|
178
|
+
|
|
179
|
+
temp_dir = self.get_temp_dir()
|
|
180
|
+
|
|
181
|
+
# Configure handler with directory and allow_origin
|
|
182
|
+
def handler_factory(*args, **kwargs):
|
|
183
|
+
return CORSRequestHandler(
|
|
184
|
+
*args, directory=str(temp_dir), allow_origin=allow_origin, **kwargs
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
self._server = ThreadingHTTPServer(("0.0.0.0", port), handler_factory)
|
|
188
|
+
self._port = port
|
|
189
|
+
self._allow_origin = allow_origin
|
|
190
|
+
|
|
191
|
+
# Start server in daemon thread
|
|
192
|
+
self._server_thread = threading.Thread(
|
|
193
|
+
target=self._server.serve_forever, daemon=True
|
|
194
|
+
)
|
|
195
|
+
self._server_thread.start()
|
|
196
|
+
|
|
197
|
+
# Update process info file with port information
|
|
198
|
+
self._update_process_info_file()
|
|
199
|
+
|
|
200
|
+
# Start directory monitoring thread
|
|
201
|
+
self._start_directory_monitor()
|
|
202
|
+
|
|
203
|
+
print(f"Started figpack server at http://localhost:{port} serving {temp_dir}")
|
|
204
|
+
|
|
205
|
+
return f"http://localhost:{port}", port
|
|
206
|
+
|
|
207
|
+
def _stop_server(self):
|
|
208
|
+
"""Stop the current server."""
|
|
209
|
+
if self._server is not None:
|
|
210
|
+
self._server.shutdown()
|
|
211
|
+
self._server.server_close()
|
|
212
|
+
if self._server_thread is not None:
|
|
213
|
+
self._server_thread.join(timeout=1.0)
|
|
214
|
+
self._server = None
|
|
215
|
+
self._server_thread = None
|
|
216
|
+
self._port = None
|
|
217
|
+
self._allow_origin = None
|
|
218
|
+
|
|
219
|
+
def _create_process_info_file(self):
|
|
220
|
+
"""Create the process info file in the temporary directory."""
|
|
221
|
+
if self._temp_dir is not None:
|
|
222
|
+
process_info = {
|
|
223
|
+
"pid": os.getpid(),
|
|
224
|
+
"port": self._port,
|
|
225
|
+
"created_at": time.time(),
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
process_info_file = self._temp_dir / "process_info.json"
|
|
229
|
+
try:
|
|
230
|
+
with open(process_info_file, "w") as f:
|
|
231
|
+
json.dump(process_info, f, indent=2)
|
|
232
|
+
except Exception as e:
|
|
233
|
+
print(f"Warning: Failed to create process info file: {e}")
|
|
234
|
+
|
|
235
|
+
def _update_process_info_file(self):
|
|
236
|
+
"""Update the process info file with current port information."""
|
|
237
|
+
if self._temp_dir is not None:
|
|
238
|
+
process_info_file = self._temp_dir / "process_info.json"
|
|
239
|
+
try:
|
|
240
|
+
# Read existing info
|
|
241
|
+
if process_info_file.exists():
|
|
242
|
+
with open(process_info_file, "r") as f:
|
|
243
|
+
process_info = json.load(f)
|
|
244
|
+
else:
|
|
245
|
+
process_info = {"pid": os.getpid(), "created_at": time.time()}
|
|
246
|
+
|
|
247
|
+
# Update with current port
|
|
248
|
+
process_info["port"] = self._port
|
|
249
|
+
process_info["updated_at"] = time.time()
|
|
250
|
+
|
|
251
|
+
# Write back
|
|
252
|
+
with open(process_info_file, "w") as f:
|
|
253
|
+
json.dump(process_info, f, indent=2)
|
|
254
|
+
except Exception as e:
|
|
255
|
+
print(f"Warning: Failed to update process info file: {e}")
|
|
256
|
+
|
|
257
|
+
def _start_directory_monitor(self):
|
|
258
|
+
"""Start monitoring thread to detect if directory is deleted."""
|
|
259
|
+
if self._monitor_thread is None or not self._monitor_thread.is_alive():
|
|
260
|
+
self._stop_monitoring.clear()
|
|
261
|
+
self._monitor_thread = threading.Thread(
|
|
262
|
+
target=self._monitor_directory, daemon=True
|
|
263
|
+
)
|
|
264
|
+
self._monitor_thread.start()
|
|
265
|
+
|
|
266
|
+
def _monitor_directory(self):
|
|
267
|
+
"""Monitor the temporary directory and stop server if it's deleted."""
|
|
268
|
+
while not self._stop_monitoring.is_set():
|
|
269
|
+
try:
|
|
270
|
+
if self._temp_dir is not None and not self._temp_dir.exists():
|
|
271
|
+
print(
|
|
272
|
+
f"Temporary directory {self._temp_dir} was deleted, stopping server"
|
|
273
|
+
)
|
|
274
|
+
self._stop_server()
|
|
275
|
+
self._stop_monitoring.set()
|
|
276
|
+
break
|
|
277
|
+
|
|
278
|
+
# Check every 5 seconds
|
|
279
|
+
self._stop_monitoring.wait(5.0)
|
|
280
|
+
|
|
281
|
+
except Exception as e:
|
|
282
|
+
print(f"Warning: Error in directory monitor: {e}")
|
|
283
|
+
break
|
|
284
|
+
|
|
285
|
+
def _cleanup(self):
|
|
286
|
+
"""Cleanup server and temporary directory on process exit."""
|
|
287
|
+
# Stop monitoring
|
|
288
|
+
self._stop_monitoring.set()
|
|
289
|
+
if self._monitor_thread is not None:
|
|
290
|
+
self._monitor_thread.join(timeout=1.0)
|
|
291
|
+
|
|
292
|
+
# Stop server
|
|
293
|
+
self._stop_server()
|
|
294
|
+
|
|
295
|
+
# Remove temporary directory
|
|
296
|
+
if self._temp_dir is not None and self._temp_dir.exists():
|
|
297
|
+
try:
|
|
298
|
+
shutil.rmtree(self._temp_dir)
|
|
299
|
+
except Exception as e:
|
|
300
|
+
# Don't raise exceptions during cleanup
|
|
301
|
+
print(
|
|
302
|
+
f"Warning: Failed to cleanup temporary directory {self._temp_dir}: {e}"
|
|
303
|
+
)
|
|
304
|
+
self._temp_dir = None
|
figpack/core/_show_view.py
CHANGED
|
@@ -1,15 +1,83 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import pathlib
|
|
3
3
|
import tempfile
|
|
4
|
-
import threading
|
|
5
4
|
import webbrowser
|
|
6
|
-
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
7
5
|
from typing import Union
|
|
8
6
|
|
|
9
7
|
from ._bundle_utils import prepare_figure_bundle
|
|
8
|
+
from ._server_manager import ProcessServerManager
|
|
10
9
|
from ._upload_bundle import _upload_bundle
|
|
11
10
|
from .figpack_view import FigpackView
|
|
12
11
|
|
|
12
|
+
|
|
13
|
+
def _is_in_notebook() -> bool:
|
|
14
|
+
"""
|
|
15
|
+
Detect if we are running in a Jupyter notebook environment.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
bool: True if running in a notebook, False otherwise
|
|
19
|
+
"""
|
|
20
|
+
try:
|
|
21
|
+
# Check if IPython is available and we're in a notebook
|
|
22
|
+
from IPython import get_ipython
|
|
23
|
+
|
|
24
|
+
ipython = get_ipython()
|
|
25
|
+
if ipython is None:
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
# Check if we're in a notebook environment
|
|
29
|
+
if hasattr(ipython, "kernel"):
|
|
30
|
+
return True
|
|
31
|
+
|
|
32
|
+
# Additional check for notebook-specific attributes
|
|
33
|
+
if "ipykernel" in str(type(ipython)):
|
|
34
|
+
return True
|
|
35
|
+
|
|
36
|
+
return False
|
|
37
|
+
except ImportError:
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _is_in_colab():
|
|
42
|
+
try:
|
|
43
|
+
import google.colab # type: ignore
|
|
44
|
+
|
|
45
|
+
return True
|
|
46
|
+
except ImportError:
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_in_jupyterhub():
|
|
51
|
+
return "JUPYTERHUB_USER" in os.environ
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _display_inline_iframe(url: str, height: int) -> None:
|
|
55
|
+
"""
|
|
56
|
+
Display an iframe inline in a Jupyter notebook.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
url: URL to display in the iframe
|
|
60
|
+
height: Height of the iframe in pixels
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
from IPython.display import HTML, display
|
|
64
|
+
|
|
65
|
+
iframe_html = f"""
|
|
66
|
+
<iframe src="{url}"
|
|
67
|
+
width="100%"
|
|
68
|
+
height="{height}px"
|
|
69
|
+
frameborder="0"
|
|
70
|
+
style="border: 1px solid #ccc; border-radius: 4px;">
|
|
71
|
+
</iframe>
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
display(HTML(iframe_html))
|
|
75
|
+
|
|
76
|
+
except ImportError:
|
|
77
|
+
print(f"IPython not available. Please install IPython to use inline display.")
|
|
78
|
+
print(f"Alternatively, open {url} in your browser.")
|
|
79
|
+
|
|
80
|
+
|
|
13
81
|
thisdir = pathlib.Path(__file__).parent.resolve()
|
|
14
82
|
|
|
15
83
|
|
|
@@ -20,113 +88,81 @@ def _show_view(
|
|
|
20
88
|
port: Union[int, None] = None,
|
|
21
89
|
allow_origin: Union[str, None] = None,
|
|
22
90
|
upload: bool = False,
|
|
91
|
+
ephemeral: bool = False,
|
|
23
92
|
title: Union[str, None] = None,
|
|
24
93
|
description: Union[str, None] = None,
|
|
94
|
+
inline: Union[bool, None] = None,
|
|
95
|
+
inline_height: int = 600,
|
|
25
96
|
):
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if
|
|
30
|
-
|
|
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
|
+
if upload:
|
|
104
|
+
# Upload behavior: create temporary directory for this upload only
|
|
105
|
+
with tempfile.TemporaryDirectory(prefix="figpack_upload_") as tmpdir:
|
|
106
|
+
prepare_figure_bundle(view, tmpdir, title=title, description=description)
|
|
107
|
+
|
|
108
|
+
# Check for API key - required for regular uploads, optional for ephemeral
|
|
31
109
|
api_key = os.environ.get("FIGPACK_API_KEY")
|
|
32
|
-
if not api_key:
|
|
110
|
+
if not ephemeral and not api_key:
|
|
33
111
|
raise EnvironmentError(
|
|
34
112
|
"FIGPACK_API_KEY environment variable must be set to upload views."
|
|
35
113
|
)
|
|
36
114
|
|
|
37
115
|
# Upload the bundle
|
|
38
|
-
|
|
39
|
-
|
|
116
|
+
figure_url = _upload_bundle(
|
|
117
|
+
tmpdir, api_key, title=title, ephemeral=ephemeral
|
|
118
|
+
)
|
|
40
119
|
|
|
41
|
-
if
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
# wait until user presses Enter
|
|
45
|
-
input("Press Enter to continue...")
|
|
120
|
+
if use_inline:
|
|
121
|
+
# For uploaded figures, display the remote URL inline and continue
|
|
122
|
+
_display_inline_iframe(figure_url, inline_height)
|
|
46
123
|
else:
|
|
47
|
-
|
|
124
|
+
# Not in notebook environment
|
|
125
|
+
if open_in_browser:
|
|
126
|
+
webbrowser.open(figure_url)
|
|
127
|
+
print(f"Opening {figure_url} in browser.")
|
|
128
|
+
else:
|
|
129
|
+
print(f"View the figure at: {figure_url}")
|
|
130
|
+
# Wait until user presses Enter
|
|
131
|
+
input("Press Enter to continue...")
|
|
48
132
|
|
|
49
133
|
return figure_url
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
port=port,
|
|
54
|
-
open_in_browser=open_in_browser,
|
|
55
|
-
allow_origin=allow_origin,
|
|
56
|
-
)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
class CORSRequestHandler(SimpleHTTPRequestHandler):
|
|
60
|
-
def __init__(self, *args, allow_origin=None, **kwargs):
|
|
61
|
-
self.allow_origin = allow_origin
|
|
62
|
-
super().__init__(*args, **kwargs)
|
|
63
|
-
|
|
64
|
-
# Serve only GET/HEAD/OPTIONS; add CORS headers on every response
|
|
65
|
-
def end_headers(self):
|
|
66
|
-
if self.allow_origin is not None:
|
|
67
|
-
self.send_header("Access-Control-Allow-Origin", self.allow_origin)
|
|
68
|
-
self.send_header("Vary", "Origin")
|
|
69
|
-
self.send_header("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
|
|
70
|
-
self.send_header("Access-Control-Allow-Headers", "Content-Type, Range")
|
|
71
|
-
self.send_header(
|
|
72
|
-
"Access-Control-Expose-Headers",
|
|
73
|
-
"Accept-Ranges, Content-Encoding, Content-Length, Content-Range",
|
|
74
|
-
)
|
|
75
|
-
super().end_headers()
|
|
76
|
-
|
|
77
|
-
def do_OPTIONS(self):
|
|
78
|
-
self.send_response(204, "No Content")
|
|
79
|
-
self.end_headers()
|
|
134
|
+
else:
|
|
135
|
+
# Local server behavior: use process-level server manager
|
|
136
|
+
server_manager = ProcessServerManager.get_instance()
|
|
80
137
|
|
|
81
|
-
|
|
82
|
-
|
|
138
|
+
# Create figure subdirectory in process temp directory
|
|
139
|
+
figure_dir = server_manager.create_figure_subdir()
|
|
83
140
|
|
|
141
|
+
# Prepare the figure bundle in the subdirectory
|
|
142
|
+
prepare_figure_bundle(
|
|
143
|
+
view, str(figure_dir), title=title, description=description
|
|
144
|
+
)
|
|
84
145
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
port: Union[int, None],
|
|
89
|
-
open_in_browser: bool = False,
|
|
90
|
-
allow_origin: Union[str, None] = None,
|
|
91
|
-
):
|
|
92
|
-
# if port is None, find a free port
|
|
93
|
-
if port is None:
|
|
94
|
-
import socket
|
|
95
|
-
|
|
96
|
-
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
97
|
-
s.bind(("", 0))
|
|
98
|
-
port = s.getsockname()[1]
|
|
99
|
-
|
|
100
|
-
tmpdir = pathlib.Path(tmpdir)
|
|
101
|
-
tmpdir = tmpdir.resolve()
|
|
102
|
-
if not tmpdir.exists() or not tmpdir.is_dir():
|
|
103
|
-
raise SystemExit(f"Directory not found: {tmpdir}")
|
|
104
|
-
|
|
105
|
-
# Configure handler with directory and allow_origin
|
|
106
|
-
def handler_factory(*args, **kwargs):
|
|
107
|
-
return CORSRequestHandler(
|
|
108
|
-
*args, directory=str(tmpdir), allow_origin=allow_origin, **kwargs
|
|
146
|
+
# Start or get existing server
|
|
147
|
+
base_url, server_port = server_manager.start_server(
|
|
148
|
+
port=port, allow_origin=allow_origin
|
|
109
149
|
)
|
|
110
150
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
thread.start()
|
|
151
|
+
# Construct URL to the specific figure subdirectory
|
|
152
|
+
figure_subdir_name = figure_dir.name
|
|
153
|
+
figure_url = f"{base_url}/{figure_subdir_name}"
|
|
115
154
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
155
|
+
if use_inline:
|
|
156
|
+
# Display inline and continue (don't block)
|
|
157
|
+
_display_inline_iframe(figure_url, inline_height)
|
|
158
|
+
else:
|
|
159
|
+
# Not in notebook environment
|
|
160
|
+
if open_in_browser:
|
|
161
|
+
webbrowser.open(figure_url)
|
|
162
|
+
print(f"Opening {figure_url} in browser.")
|
|
163
|
+
else:
|
|
164
|
+
print(f"Open {figure_url} in your browser to view the visualization.")
|
|
165
|
+
# Wait until user presses Enter
|
|
166
|
+
input("Press Enter to continue...")
|
|
123
167
|
|
|
124
|
-
|
|
125
|
-
input("Press Enter to stop...\n")
|
|
126
|
-
except (KeyboardInterrupt, EOFError):
|
|
127
|
-
pass
|
|
128
|
-
finally:
|
|
129
|
-
print("Shutting down server...")
|
|
130
|
-
httpd.shutdown()
|
|
131
|
-
httpd.server_close()
|
|
132
|
-
thread.join()
|
|
168
|
+
return figure_url
|