figpack 0.1.7__tar.gz → 0.2.2__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.

Files changed (75) hide show
  1. {figpack-0.1.7/figpack.egg-info → figpack-0.2.2}/PKG-INFO +3 -2
  2. {figpack-0.1.7 → figpack-0.2.2}/figpack/__init__.py +1 -1
  3. {figpack-0.1.7 → figpack-0.2.2}/figpack/cli.py +60 -3
  4. figpack-0.2.2/figpack/core/_server_manager.py +304 -0
  5. figpack-0.2.2/figpack/core/_show_view.py +153 -0
  6. {figpack-0.1.7 → figpack-0.2.2}/figpack/core/_upload_bundle.py +113 -73
  7. {figpack-0.1.7 → figpack-0.2.2}/figpack/core/figpack_view.py +6 -0
  8. figpack-0.2.2/figpack/figpack-gui-dist/assets/index-Cmae55E4.css +1 -0
  9. figpack-0.2.2/figpack/figpack-gui-dist/assets/index-DUR9Dmwh.js +847 -0
  10. {figpack-0.1.7 → figpack-0.2.2}/figpack/figpack-gui-dist/index.html +2 -2
  11. figpack-0.2.2/figpack/franklab/__init__.py +5 -0
  12. figpack-0.2.2/figpack/franklab/views/TrackAnimation.py +153 -0
  13. figpack-0.2.2/figpack/franklab/views/__init__.py +9 -0
  14. {figpack-0.1.7 → figpack-0.2.2}/figpack/spike_sorting/views/AutocorrelogramItem.py +0 -10
  15. {figpack-0.1.7 → figpack-0.2.2}/figpack/spike_sorting/views/Autocorrelograms.py +2 -0
  16. figpack-0.2.2/figpack/spike_sorting/views/AverageWaveforms.py +147 -0
  17. {figpack-0.1.7 → figpack-0.2.2}/figpack/spike_sorting/views/CrossCorrelogramItem.py +0 -11
  18. {figpack-0.1.7 → figpack-0.2.2}/figpack/spike_sorting/views/CrossCorrelograms.py +2 -0
  19. figpack-0.2.2/figpack/spike_sorting/views/SpikeAmplitudes.py +89 -0
  20. figpack-0.2.2/figpack/spike_sorting/views/SpikeAmplitudesItem.py +38 -0
  21. {figpack-0.1.7 → figpack-0.2.2}/figpack/spike_sorting/views/__init__.py +6 -0
  22. {figpack-0.1.7 → figpack-0.2.2/figpack.egg-info}/PKG-INFO +3 -2
  23. {figpack-0.1.7 → figpack-0.2.2}/figpack.egg-info/SOURCES.txt +12 -4
  24. {figpack-0.1.7 → figpack-0.2.2}/figpack.egg-info/requires.txt +2 -1
  25. {figpack-0.1.7 → figpack-0.2.2}/pyproject.toml +4 -3
  26. figpack-0.2.2/tests/test_average_waveforms.py +102 -0
  27. {figpack-0.1.7 → figpack-0.2.2}/tests/test_cli.py +0 -56
  28. figpack-0.2.2/tests/test_show_view.py +0 -0
  29. figpack-0.2.2/tests/test_track_animation.py +230 -0
  30. figpack-0.1.7/figpack/core/_show_view.py +0 -132
  31. figpack-0.1.7/figpack/figpack-gui-dist/assets/index-BDa2iJW9.css +0 -1
  32. figpack-0.1.7/figpack/figpack-gui-dist/assets/index-DaeClgi6.js +0 -846
  33. figpack-0.1.7/tests/test_show_view.py +0 -521
  34. figpack-0.1.7/tests/test_upload_bundle.py +0 -679
  35. {figpack-0.1.7 → figpack-0.2.2}/LICENSE +0 -0
  36. {figpack-0.1.7 → figpack-0.2.2}/MANIFEST.in +0 -0
  37. {figpack-0.1.7 → figpack-0.2.2}/README.md +0 -0
  38. {figpack-0.1.7 → figpack-0.2.2}/figpack/core/__init__.py +0 -0
  39. {figpack-0.1.7 → figpack-0.2.2}/figpack/core/_bundle_utils.py +0 -0
  40. {figpack-0.1.7 → figpack-0.2.2}/figpack/core/config.py +0 -0
  41. {figpack-0.1.7 → figpack-0.2.2}/figpack/figpack-gui-dist/assets/neurosift-logo-CLsuwLMO.png +0 -0
  42. {figpack-0.1.7 → figpack-0.2.2}/figpack/spike_sorting/__init__.py +0 -0
  43. {figpack-0.1.7 → figpack-0.2.2}/figpack/spike_sorting/views/UnitSimilarityScore.py +0 -0
  44. {figpack-0.1.7 → figpack-0.2.2}/figpack/spike_sorting/views/UnitsTable.py +0 -0
  45. {figpack-0.1.7 → figpack-0.2.2}/figpack/spike_sorting/views/UnitsTableColumn.py +0 -0
  46. {figpack-0.1.7 → figpack-0.2.2}/figpack/spike_sorting/views/UnitsTableRow.py +0 -0
  47. {figpack-0.1.7 → figpack-0.2.2}/figpack/views/Box.py +0 -0
  48. {figpack-0.1.7 → figpack-0.2.2}/figpack/views/Image.py +0 -0
  49. {figpack-0.1.7 → figpack-0.2.2}/figpack/views/LayoutItem.py +0 -0
  50. {figpack-0.1.7 → figpack-0.2.2}/figpack/views/Markdown.py +0 -0
  51. {figpack-0.1.7 → figpack-0.2.2}/figpack/views/MatplotlibFigure.py +0 -0
  52. {figpack-0.1.7 → figpack-0.2.2}/figpack/views/MultiChannelTimeseries.py +0 -0
  53. {figpack-0.1.7 → figpack-0.2.2}/figpack/views/PlotlyFigure.py +0 -0
  54. {figpack-0.1.7 → figpack-0.2.2}/figpack/views/Splitter.py +0 -0
  55. {figpack-0.1.7 → figpack-0.2.2}/figpack/views/TabLayout.py +0 -0
  56. {figpack-0.1.7 → figpack-0.2.2}/figpack/views/TabLayoutItem.py +0 -0
  57. {figpack-0.1.7 → figpack-0.2.2}/figpack/views/TimeseriesGraph.py +0 -0
  58. {figpack-0.1.7 → figpack-0.2.2}/figpack/views/__init__.py +0 -0
  59. {figpack-0.1.7 → figpack-0.2.2}/figpack.egg-info/dependency_links.txt +0 -0
  60. {figpack-0.1.7 → figpack-0.2.2}/figpack.egg-info/entry_points.txt +0 -0
  61. {figpack-0.1.7 → figpack-0.2.2}/figpack.egg-info/top_level.txt +0 -0
  62. {figpack-0.1.7 → figpack-0.2.2}/setup.cfg +0 -0
  63. {figpack-0.1.7 → figpack-0.2.2}/tests/test_box.py +0 -0
  64. {figpack-0.1.7 → figpack-0.2.2}/tests/test_core.py +0 -0
  65. {figpack-0.1.7 → figpack-0.2.2}/tests/test_figpack_view.py +0 -0
  66. {figpack-0.1.7 → figpack-0.2.2}/tests/test_image.py +0 -0
  67. {figpack-0.1.7 → figpack-0.2.2}/tests/test_markdown.py +0 -0
  68. {figpack-0.1.7 → figpack-0.2.2}/tests/test_matplotlib_figure.py +0 -0
  69. {figpack-0.1.7 → figpack-0.2.2}/tests/test_multichannel_timeseries.py +0 -0
  70. {figpack-0.1.7 → figpack-0.2.2}/tests/test_plotly_figure.py +0 -0
  71. {figpack-0.1.7 → figpack-0.2.2}/tests/test_spike_sorting_correlograms.py +0 -0
  72. {figpack-0.1.7 → figpack-0.2.2}/tests/test_splitter.py +0 -0
  73. {figpack-0.1.7 → figpack-0.2.2}/tests/test_tablayout.py +0 -0
  74. {figpack-0.1.7 → figpack-0.2.2}/tests/test_timeseries_graph.py +0 -0
  75. {figpack-0.1.7 → figpack-0.2.2}/tests/test_units_table.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: figpack
3
- Version: 0.1.7
3
+ Version: 0.2.2
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"
@@ -2,4 +2,4 @@
2
2
  figpack - A Python package for creating shareable, interactive visualizations in the browser
3
3
  """
4
4
 
5
- __version__ = "0.1.7"
5
+ __version__ = "0.2.2"
@@ -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, List, Optional, Tuple, Union
17
- from urllib.parse import urljoin, urlparse
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._show_view import serve_files
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,153 @@
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 _display_inline_iframe(url: str, height: int) -> None:
42
+ """
43
+ Display an iframe inline in a Jupyter notebook.
44
+
45
+ Args:
46
+ url: URL to display in the iframe
47
+ height: Height of the iframe in pixels
48
+ """
49
+ try:
50
+ from IPython.display import HTML, display
51
+
52
+ iframe_html = f"""
53
+ <iframe src="{url}"
54
+ width="100%"
55
+ height="{height}px"
56
+ frameborder="0"
57
+ style="border: 1px solid #ccc; border-radius: 4px;">
58
+ </iframe>
59
+ """
60
+
61
+ display(HTML(iframe_html))
62
+
63
+ except ImportError:
64
+ print(f"IPython not available. Please install IPython to use inline display.")
65
+ print(f"Alternatively, open {url} in your browser.")
66
+
67
+
68
+ thisdir = pathlib.Path(__file__).parent.resolve()
69
+
70
+
71
+ def _show_view(
72
+ view: FigpackView,
73
+ *,
74
+ open_in_browser: bool = False,
75
+ port: Union[int, None] = None,
76
+ allow_origin: Union[str, None] = None,
77
+ upload: bool = False,
78
+ title: Union[str, None] = None,
79
+ description: Union[str, None] = None,
80
+ inline: Union[bool, None] = None,
81
+ inline_height: int = 600,
82
+ ):
83
+ # Determine if we should use inline display
84
+ use_inline = inline
85
+ if inline is None:
86
+ # Auto-detect: use inline if we're in a notebook
87
+ use_inline = _is_in_notebook()
88
+
89
+ if upload:
90
+ # Upload behavior: create temporary directory for this upload only
91
+ with tempfile.TemporaryDirectory(prefix="figpack_upload_") as tmpdir:
92
+ prepare_figure_bundle(view, tmpdir, title=title, description=description)
93
+
94
+ # Check for required environment variable
95
+ api_key = os.environ.get("FIGPACK_API_KEY")
96
+ if not api_key:
97
+ raise EnvironmentError(
98
+ "FIGPACK_API_KEY environment variable must be set to upload views."
99
+ )
100
+
101
+ # Upload the bundle
102
+ print("Starting upload...")
103
+ figure_url = _upload_bundle(tmpdir, api_key, title=title)
104
+
105
+ if use_inline:
106
+ # For uploaded figures, display the remote URL inline and continue
107
+ _display_inline_iframe(figure_url, inline_height)
108
+ else:
109
+ # Not in notebook environment
110
+ if open_in_browser:
111
+ webbrowser.open(figure_url)
112
+ print(f"Opening {figure_url} in browser.")
113
+ else:
114
+ print(f"View the figure at: {figure_url}")
115
+ # Wait until user presses Enter
116
+ input("Press Enter to continue...")
117
+
118
+ return figure_url
119
+ else:
120
+ # Local server behavior: use process-level server manager
121
+ server_manager = ProcessServerManager.get_instance()
122
+
123
+ # Create figure subdirectory in process temp directory
124
+ figure_dir = server_manager.create_figure_subdir()
125
+
126
+ # Prepare the figure bundle in the subdirectory
127
+ prepare_figure_bundle(
128
+ view, str(figure_dir), title=title, description=description
129
+ )
130
+
131
+ # Start or get existing server
132
+ base_url, server_port = server_manager.start_server(
133
+ port=port, allow_origin=allow_origin
134
+ )
135
+
136
+ # Construct URL to the specific figure subdirectory
137
+ figure_subdir_name = figure_dir.name
138
+ figure_url = f"{base_url}/{figure_subdir_name}"
139
+
140
+ if use_inline:
141
+ # Display inline and continue (don't block)
142
+ _display_inline_iframe(figure_url, inline_height)
143
+ else:
144
+ # Not in notebook environment
145
+ if open_in_browser:
146
+ webbrowser.open(figure_url)
147
+ print(f"Opening {figure_url} in browser.")
148
+ else:
149
+ print(f"Open {figure_url} in your browser to view the visualization.")
150
+ # Wait until user presses Enter
151
+ input("Press Enter to continue...")
152
+
153
+ return figure_url