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.

Files changed (74) hide show
  1. {figpack-0.2.1/figpack.egg-info → figpack-0.2.3}/PKG-INFO +3 -2
  2. {figpack-0.2.1 → figpack-0.2.3}/figpack/__init__.py +1 -1
  3. {figpack-0.2.1 → figpack-0.2.3}/figpack/cli.py +60 -3
  4. figpack-0.2.3/figpack/core/_server_manager.py +304 -0
  5. figpack-0.2.3/figpack/core/_show_view.py +168 -0
  6. {figpack-0.2.1 → figpack-0.2.3}/figpack/core/_upload_bundle.py +19 -9
  7. {figpack-0.2.1 → figpack-0.2.3}/figpack/core/figpack_view.py +34 -2
  8. figpack-0.2.3/figpack/figpack-gui-dist/assets/index-DUR9Dmwh.js +847 -0
  9. {figpack-0.2.1 → figpack-0.2.3}/figpack/figpack-gui-dist/index.html +1 -1
  10. figpack-0.2.3/figpack/franklab/__init__.py +5 -0
  11. figpack-0.2.3/figpack/franklab/views/TrackAnimation.py +153 -0
  12. figpack-0.2.3/figpack/franklab/views/__init__.py +9 -0
  13. {figpack-0.2.1 → figpack-0.2.3}/figpack/spike_sorting/views/AutocorrelogramItem.py +0 -10
  14. {figpack-0.2.1 → figpack-0.2.3}/figpack/spike_sorting/views/Autocorrelograms.py +2 -0
  15. figpack-0.2.3/figpack/spike_sorting/views/AverageWaveforms.py +147 -0
  16. {figpack-0.2.1 → figpack-0.2.3}/figpack/spike_sorting/views/CrossCorrelogramItem.py +0 -11
  17. {figpack-0.2.1 → figpack-0.2.3}/figpack/spike_sorting/views/CrossCorrelograms.py +2 -0
  18. figpack-0.2.3/figpack/spike_sorting/views/SpikeAmplitudes.py +89 -0
  19. figpack-0.2.3/figpack/spike_sorting/views/SpikeAmplitudesItem.py +38 -0
  20. {figpack-0.2.1 → figpack-0.2.3}/figpack/spike_sorting/views/__init__.py +6 -0
  21. {figpack-0.2.1 → figpack-0.2.3/figpack.egg-info}/PKG-INFO +3 -2
  22. {figpack-0.2.1 → figpack-0.2.3}/figpack.egg-info/SOURCES.txt +10 -1
  23. {figpack-0.2.1 → figpack-0.2.3}/figpack.egg-info/requires.txt +2 -1
  24. {figpack-0.2.1 → figpack-0.2.3}/pyproject.toml +4 -3
  25. figpack-0.2.3/tests/test_average_waveforms.py +102 -0
  26. {figpack-0.2.1 → figpack-0.2.3}/tests/test_cli.py +0 -56
  27. figpack-0.2.3/tests/test_figpack_view.py +51 -0
  28. figpack-0.2.3/tests/test_show_view.py +0 -0
  29. figpack-0.2.3/tests/test_track_animation.py +230 -0
  30. figpack-0.2.1/figpack/core/_show_view.py +0 -132
  31. figpack-0.2.1/figpack/figpack-gui-dist/assets/index-BqYF6BN-.js +0 -846
  32. figpack-0.2.1/tests/test_figpack_view.py +0 -106
  33. figpack-0.2.1/tests/test_show_view.py +0 -521
  34. {figpack-0.2.1 → figpack-0.2.3}/LICENSE +0 -0
  35. {figpack-0.2.1 → figpack-0.2.3}/MANIFEST.in +0 -0
  36. {figpack-0.2.1 → figpack-0.2.3}/README.md +0 -0
  37. {figpack-0.2.1 → figpack-0.2.3}/figpack/core/__init__.py +0 -0
  38. {figpack-0.2.1 → figpack-0.2.3}/figpack/core/_bundle_utils.py +0 -0
  39. {figpack-0.2.1 → figpack-0.2.3}/figpack/core/config.py +0 -0
  40. {figpack-0.2.1 → figpack-0.2.3}/figpack/figpack-gui-dist/assets/index-Cmae55E4.css +0 -0
  41. {figpack-0.2.1 → figpack-0.2.3}/figpack/figpack-gui-dist/assets/neurosift-logo-CLsuwLMO.png +0 -0
  42. {figpack-0.2.1 → figpack-0.2.3}/figpack/spike_sorting/__init__.py +0 -0
  43. {figpack-0.2.1 → figpack-0.2.3}/figpack/spike_sorting/views/UnitSimilarityScore.py +0 -0
  44. {figpack-0.2.1 → figpack-0.2.3}/figpack/spike_sorting/views/UnitsTable.py +0 -0
  45. {figpack-0.2.1 → figpack-0.2.3}/figpack/spike_sorting/views/UnitsTableColumn.py +0 -0
  46. {figpack-0.2.1 → figpack-0.2.3}/figpack/spike_sorting/views/UnitsTableRow.py +0 -0
  47. {figpack-0.2.1 → figpack-0.2.3}/figpack/views/Box.py +0 -0
  48. {figpack-0.2.1 → figpack-0.2.3}/figpack/views/Image.py +0 -0
  49. {figpack-0.2.1 → figpack-0.2.3}/figpack/views/LayoutItem.py +0 -0
  50. {figpack-0.2.1 → figpack-0.2.3}/figpack/views/Markdown.py +0 -0
  51. {figpack-0.2.1 → figpack-0.2.3}/figpack/views/MatplotlibFigure.py +0 -0
  52. {figpack-0.2.1 → figpack-0.2.3}/figpack/views/MultiChannelTimeseries.py +0 -0
  53. {figpack-0.2.1 → figpack-0.2.3}/figpack/views/PlotlyFigure.py +0 -0
  54. {figpack-0.2.1 → figpack-0.2.3}/figpack/views/Splitter.py +0 -0
  55. {figpack-0.2.1 → figpack-0.2.3}/figpack/views/TabLayout.py +0 -0
  56. {figpack-0.2.1 → figpack-0.2.3}/figpack/views/TabLayoutItem.py +0 -0
  57. {figpack-0.2.1 → figpack-0.2.3}/figpack/views/TimeseriesGraph.py +0 -0
  58. {figpack-0.2.1 → figpack-0.2.3}/figpack/views/__init__.py +0 -0
  59. {figpack-0.2.1 → figpack-0.2.3}/figpack.egg-info/dependency_links.txt +0 -0
  60. {figpack-0.2.1 → figpack-0.2.3}/figpack.egg-info/entry_points.txt +0 -0
  61. {figpack-0.2.1 → figpack-0.2.3}/figpack.egg-info/top_level.txt +0 -0
  62. {figpack-0.2.1 → figpack-0.2.3}/setup.cfg +0 -0
  63. {figpack-0.2.1 → figpack-0.2.3}/tests/test_box.py +0 -0
  64. {figpack-0.2.1 → figpack-0.2.3}/tests/test_core.py +0 -0
  65. {figpack-0.2.1 → figpack-0.2.3}/tests/test_image.py +0 -0
  66. {figpack-0.2.1 → figpack-0.2.3}/tests/test_markdown.py +0 -0
  67. {figpack-0.2.1 → figpack-0.2.3}/tests/test_matplotlib_figure.py +0 -0
  68. {figpack-0.2.1 → figpack-0.2.3}/tests/test_multichannel_timeseries.py +0 -0
  69. {figpack-0.2.1 → figpack-0.2.3}/tests/test_plotly_figure.py +0 -0
  70. {figpack-0.2.1 → figpack-0.2.3}/tests/test_spike_sorting_correlograms.py +0 -0
  71. {figpack-0.2.1 → figpack-0.2.3}/tests/test_splitter.py +0 -0
  72. {figpack-0.2.1 → figpack-0.2.3}/tests/test_tablayout.py +0 -0
  73. {figpack-0.2.1 → figpack-0.2.3}/tests/test_timeseries_graph.py +0 -0
  74. {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.1
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"
@@ -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.2.1"
5
+ __version__ = "0.2.3"
@@ -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,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(tmpdir: str, api_key: str, title: str = None) -> str:
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 at: {figure_url}")
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