figpack 0.2.1__tar.gz → 0.2.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of figpack might be problematic. Click here for more details.
- {figpack-0.2.1/figpack.egg-info → figpack-0.2.3}/PKG-INFO +3 -2
- {figpack-0.2.1 → figpack-0.2.3}/figpack/__init__.py +1 -1
- {figpack-0.2.1 → figpack-0.2.3}/figpack/cli.py +60 -3
- figpack-0.2.3/figpack/core/_server_manager.py +304 -0
- figpack-0.2.3/figpack/core/_show_view.py +168 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack/core/_upload_bundle.py +19 -9
- {figpack-0.2.1 → figpack-0.2.3}/figpack/core/figpack_view.py +34 -2
- figpack-0.2.3/figpack/figpack-gui-dist/assets/index-DUR9Dmwh.js +847 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack/figpack-gui-dist/index.html +1 -1
- figpack-0.2.3/figpack/franklab/__init__.py +5 -0
- figpack-0.2.3/figpack/franklab/views/TrackAnimation.py +153 -0
- figpack-0.2.3/figpack/franklab/views/__init__.py +9 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack/spike_sorting/views/AutocorrelogramItem.py +0 -10
- {figpack-0.2.1 → figpack-0.2.3}/figpack/spike_sorting/views/Autocorrelograms.py +2 -0
- figpack-0.2.3/figpack/spike_sorting/views/AverageWaveforms.py +147 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack/spike_sorting/views/CrossCorrelogramItem.py +0 -11
- {figpack-0.2.1 → figpack-0.2.3}/figpack/spike_sorting/views/CrossCorrelograms.py +2 -0
- figpack-0.2.3/figpack/spike_sorting/views/SpikeAmplitudes.py +89 -0
- figpack-0.2.3/figpack/spike_sorting/views/SpikeAmplitudesItem.py +38 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack/spike_sorting/views/__init__.py +6 -0
- {figpack-0.2.1 → figpack-0.2.3/figpack.egg-info}/PKG-INFO +3 -2
- {figpack-0.2.1 → figpack-0.2.3}/figpack.egg-info/SOURCES.txt +10 -1
- {figpack-0.2.1 → figpack-0.2.3}/figpack.egg-info/requires.txt +2 -1
- {figpack-0.2.1 → figpack-0.2.3}/pyproject.toml +4 -3
- figpack-0.2.3/tests/test_average_waveforms.py +102 -0
- {figpack-0.2.1 → figpack-0.2.3}/tests/test_cli.py +0 -56
- figpack-0.2.3/tests/test_figpack_view.py +51 -0
- figpack-0.2.3/tests/test_show_view.py +0 -0
- figpack-0.2.3/tests/test_track_animation.py +230 -0
- figpack-0.2.1/figpack/core/_show_view.py +0 -132
- figpack-0.2.1/figpack/figpack-gui-dist/assets/index-BqYF6BN-.js +0 -846
- figpack-0.2.1/tests/test_figpack_view.py +0 -106
- figpack-0.2.1/tests/test_show_view.py +0 -521
- {figpack-0.2.1 → figpack-0.2.3}/LICENSE +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/MANIFEST.in +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/README.md +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack/core/__init__.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack/core/_bundle_utils.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack/core/config.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack/figpack-gui-dist/assets/index-Cmae55E4.css +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack/figpack-gui-dist/assets/neurosift-logo-CLsuwLMO.png +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack/spike_sorting/__init__.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack/spike_sorting/views/UnitSimilarityScore.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack/spike_sorting/views/UnitsTable.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack/spike_sorting/views/UnitsTableColumn.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack/spike_sorting/views/UnitsTableRow.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack/views/Box.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack/views/Image.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack/views/LayoutItem.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack/views/Markdown.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack/views/MatplotlibFigure.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack/views/MultiChannelTimeseries.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack/views/PlotlyFigure.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack/views/Splitter.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack/views/TabLayout.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack/views/TabLayoutItem.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack/views/TimeseriesGraph.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack/views/__init__.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack.egg-info/dependency_links.txt +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack.egg-info/entry_points.txt +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/figpack.egg-info/top_level.txt +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/setup.cfg +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/tests/test_box.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/tests/test_core.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/tests/test_image.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/tests/test_markdown.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/tests/test_matplotlib_figure.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/tests/test_multichannel_timeseries.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/tests/test_plotly_figure.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/tests/test_spike_sorting_correlograms.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/tests/test_splitter.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/tests/test_tablayout.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/tests/test_timeseries_graph.py +0 -0
- {figpack-0.2.1 → figpack-0.2.3}/tests/test_units_table.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: figpack
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: A Python package for creating shareable, interactive visualizations in the browser
|
|
5
5
|
Author-email: Jeremy Magland <jmagland@flatironinstitute.org>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -25,8 +25,9 @@ Requires-Python: >=3.8
|
|
|
25
25
|
Description-Content-Type: text/markdown
|
|
26
26
|
License-File: LICENSE
|
|
27
27
|
Requires-Dist: numpy
|
|
28
|
-
Requires-Dist: zarr
|
|
28
|
+
Requires-Dist: zarr<3
|
|
29
29
|
Requires-Dist: requests
|
|
30
|
+
Requires-Dist: psutil
|
|
30
31
|
Provides-Extra: test
|
|
31
32
|
Requires-Dist: pytest>=7.0; extra == "test"
|
|
32
33
|
Requires-Dist: pytest-cov>=4.0; extra == "test"
|
|
@@ -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
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import pathlib
|
|
3
|
+
import tempfile
|
|
4
|
+
import webbrowser
|
|
5
|
+
from typing import Union
|
|
6
|
+
|
|
7
|
+
from ._bundle_utils import prepare_figure_bundle
|
|
8
|
+
from ._server_manager import ProcessServerManager
|
|
9
|
+
from ._upload_bundle import _upload_bundle
|
|
10
|
+
from .figpack_view import FigpackView
|
|
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
|
+
|
|
81
|
+
thisdir = pathlib.Path(__file__).parent.resolve()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _show_view(
|
|
85
|
+
view: FigpackView,
|
|
86
|
+
*,
|
|
87
|
+
open_in_browser: bool = False,
|
|
88
|
+
port: Union[int, None] = None,
|
|
89
|
+
allow_origin: Union[str, None] = None,
|
|
90
|
+
upload: bool = False,
|
|
91
|
+
ephemeral: bool = False,
|
|
92
|
+
title: Union[str, None] = None,
|
|
93
|
+
description: Union[str, None] = None,
|
|
94
|
+
inline: Union[bool, None] = None,
|
|
95
|
+
inline_height: int = 600,
|
|
96
|
+
):
|
|
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
|
|
109
|
+
api_key = os.environ.get("FIGPACK_API_KEY")
|
|
110
|
+
if not ephemeral and not api_key:
|
|
111
|
+
raise EnvironmentError(
|
|
112
|
+
"FIGPACK_API_KEY environment variable must be set to upload views."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Upload the bundle
|
|
116
|
+
figure_url = _upload_bundle(
|
|
117
|
+
tmpdir, api_key, title=title, ephemeral=ephemeral
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if use_inline:
|
|
121
|
+
# For uploaded figures, display the remote URL inline and continue
|
|
122
|
+
_display_inline_iframe(figure_url, inline_height)
|
|
123
|
+
else:
|
|
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...")
|
|
132
|
+
|
|
133
|
+
return figure_url
|
|
134
|
+
else:
|
|
135
|
+
# Local server behavior: use process-level server manager
|
|
136
|
+
server_manager = ProcessServerManager.get_instance()
|
|
137
|
+
|
|
138
|
+
# Create figure subdirectory in process temp directory
|
|
139
|
+
figure_dir = server_manager.create_figure_subdir()
|
|
140
|
+
|
|
141
|
+
# Prepare the figure bundle in the subdirectory
|
|
142
|
+
prepare_figure_bundle(
|
|
143
|
+
view, str(figure_dir), title=title, description=description
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Start or get existing server
|
|
147
|
+
base_url, server_port = server_manager.start_server(
|
|
148
|
+
port=port, allow_origin=allow_origin
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Construct URL to the specific figure subdirectory
|
|
152
|
+
figure_subdir_name = figure_dir.name
|
|
153
|
+
figure_url = f"{base_url}/{figure_subdir_name}"
|
|
154
|
+
|
|
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...")
|
|
167
|
+
|
|
168
|
+
return figure_url
|
|
@@ -133,33 +133,45 @@ def _create_or_get_figure(
|
|
|
133
133
|
total_files: int = None,
|
|
134
134
|
total_size: int = None,
|
|
135
135
|
title: str = None,
|
|
136
|
+
ephemeral: bool = False,
|
|
136
137
|
) -> dict:
|
|
137
138
|
"""
|
|
138
139
|
Create a new figure or get existing figure information
|
|
139
140
|
|
|
140
141
|
Args:
|
|
141
142
|
figure_hash: The hash of the figure
|
|
142
|
-
api_key: The API key for authentication
|
|
143
|
+
api_key: The API key for authentication (required for non-ephemeral)
|
|
143
144
|
total_files: Optional total number of files
|
|
144
145
|
total_size: Optional total size of files
|
|
145
146
|
title: Optional title for the figure
|
|
147
|
+
ephemeral: Whether to create an ephemeral figure
|
|
146
148
|
|
|
147
149
|
Returns:
|
|
148
150
|
dict: Figure information from the API
|
|
149
151
|
"""
|
|
152
|
+
# Validate API key requirement
|
|
153
|
+
if not ephemeral and api_key is None:
|
|
154
|
+
raise ValueError("API key is required for non-ephemeral figures")
|
|
155
|
+
|
|
150
156
|
payload = {
|
|
151
157
|
"figureHash": figure_hash,
|
|
152
|
-
"apiKey": api_key,
|
|
153
158
|
"figpackVersion": __version__,
|
|
154
159
|
}
|
|
155
160
|
|
|
161
|
+
# API key is optional for ephemeral figures
|
|
162
|
+
if api_key is not None:
|
|
163
|
+
payload["apiKey"] = api_key
|
|
164
|
+
|
|
156
165
|
if total_files is not None:
|
|
157
166
|
payload["totalFiles"] = total_files
|
|
158
167
|
if total_size is not None:
|
|
159
168
|
payload["totalSize"] = total_size
|
|
160
169
|
if title is not None:
|
|
161
170
|
payload["title"] = title
|
|
171
|
+
if ephemeral:
|
|
172
|
+
payload["ephemeral"] = True
|
|
162
173
|
|
|
174
|
+
# Use the same endpoint for both regular and ephemeral figures
|
|
163
175
|
response = requests.post(f"{FIGPACK_API_BASE_URL}/api/figures/create", json=payload)
|
|
164
176
|
|
|
165
177
|
if not response.ok:
|
|
@@ -212,16 +224,16 @@ def _finalize_figure(figure_url: str, api_key: str) -> dict:
|
|
|
212
224
|
return response_data
|
|
213
225
|
|
|
214
226
|
|
|
215
|
-
def _upload_bundle(
|
|
227
|
+
def _upload_bundle(
|
|
228
|
+
tmpdir: str, api_key: str, title: str = None, ephemeral: bool = False
|
|
229
|
+
) -> str:
|
|
216
230
|
"""
|
|
217
231
|
Upload the prepared bundle to the cloud using the new database-driven approach
|
|
218
232
|
"""
|
|
219
233
|
tmpdir_path = pathlib.Path(tmpdir)
|
|
220
234
|
|
|
221
235
|
# Compute deterministic figure ID based on file contents
|
|
222
|
-
print("Computing deterministic figure ID...")
|
|
223
236
|
figure_hash = _compute_deterministic_figure_hash(tmpdir_path)
|
|
224
|
-
print(f"Figure hash: {figure_hash}")
|
|
225
237
|
|
|
226
238
|
# Collect all files to upload
|
|
227
239
|
all_files = []
|
|
@@ -239,17 +251,15 @@ def _upload_bundle(tmpdir: str, api_key: str, title: str = None) -> str:
|
|
|
239
251
|
|
|
240
252
|
# Find available figure ID and create/get figure in database with metadata
|
|
241
253
|
result = _create_or_get_figure(
|
|
242
|
-
figure_hash, api_key, total_files, total_size, title=title
|
|
254
|
+
figure_hash, api_key, total_files, total_size, title=title, ephemeral=ephemeral
|
|
243
255
|
)
|
|
244
256
|
figure_info = result.get("figure", {})
|
|
245
257
|
figure_url = figure_info.get("figureUrl")
|
|
246
258
|
|
|
247
259
|
if figure_info["status"] == "completed":
|
|
248
|
-
print(f"Figure already exists
|
|
260
|
+
print(f"Figure already exists. No upload needed.")
|
|
249
261
|
return figure_url
|
|
250
262
|
|
|
251
|
-
print(f"Using figure URL: {figure_url}")
|
|
252
|
-
|
|
253
263
|
files_to_upload = all_files
|
|
254
264
|
total_files_to_upload = len(files_to_upload)
|
|
255
265
|
|