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 CHANGED
@@ -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"
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, 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
@@ -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
- with tempfile.TemporaryDirectory(prefix="figpack_") as tmpdir:
27
- prepare_figure_bundle(view, tmpdir, title=title, description=description)
28
-
29
- if upload:
30
- # Check for required environment variable
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
- print("Starting upload...")
39
- figure_url = _upload_bundle(tmpdir, api_key, title=title)
116
+ figure_url = _upload_bundle(
117
+ tmpdir, api_key, title=title, ephemeral=ephemeral
118
+ )
40
119
 
41
- if open_in_browser:
42
- webbrowser.open(figure_url)
43
- print(f"Opening {figure_url} in browser.")
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
- print(f"View the figure at: {figure_url}")
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
- else:
51
- serve_files(
52
- tmpdir,
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
- def log_message(self, fmt, *args):
82
- pass
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
- def serve_files(
86
- tmpdir: str,
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
- httpd = ThreadingHTTPServer(("0.0.0.0", port), handler_factory)
112
- print(f"Serving {tmpdir} at http://localhost:{port} (CORS → {allow_origin})")
113
- thread = threading.Thread(target=httpd.serve_forever, daemon=True)
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
- if open_in_browser:
117
- webbrowser.open(f"http://localhost:{port}")
118
- print(f"Opening http://localhost:{port} in your browser.")
119
- else:
120
- print(
121
- f"Open http://localhost:{port} in your browser to view the visualization."
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
- try:
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