figpack 0.2.3__py3-none-any.whl → 0.2.5__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,8 @@
2
2
  figpack - A Python package for creating shareable, interactive visualizations in the browser
3
3
  """
4
4
 
5
- __version__ = "0.2.3"
5
+ __version__ = "0.2.5"
6
+
7
+ from .cli import view_figure
8
+
9
+ __all__ = ["view_figure"]
figpack/cli.py CHANGED
@@ -5,21 +5,19 @@ Command-line interface for figpack
5
5
  import argparse
6
6
  import json
7
7
  import pathlib
8
- import socket
9
8
  import sys
10
9
  import tarfile
11
10
  import tempfile
12
11
  import threading
13
- import webbrowser
14
12
  from concurrent.futures import ThreadPoolExecutor, as_completed
15
- from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
16
- from typing import Dict, Tuple, Union
13
+ from typing import Dict, Tuple
17
14
  from urllib.parse import urljoin
18
15
 
19
16
  import requests
20
17
 
21
18
  from . import __version__
22
19
  from .core._server_manager import CORSRequestHandler
20
+ from .core._view_figure import serve_files, view_figure
23
21
 
24
22
  MAX_WORKERS_FOR_DOWNLOAD = 16
25
23
 
@@ -216,120 +214,6 @@ def download_figure(figure_url: str, dest_path: str) -> None:
216
214
  print(f"Archive saved to: {dest_path}")
217
215
 
218
216
 
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
-
276
- def view_figure(archive_path: str, port: Union[int, None] = None) -> None:
277
- """
278
- Extract and serve a figure archive locally
279
-
280
- Args:
281
- archive_path: Path to the tar.gz archive
282
- port: Optional port number to serve on
283
- """
284
- archive_pathlib = pathlib.Path(archive_path)
285
-
286
- if not archive_pathlib.exists():
287
- print(f"Error: Archive file not found: {archive_path}")
288
- sys.exit(1)
289
-
290
- if not archive_pathlib.suffix.lower() in [".gz", ".tgz"] or not str(
291
- archive_pathlib
292
- ).endswith(".tar.gz"):
293
- print(f"Error: Expected a .tar.gz file, got: {archive_path}")
294
- sys.exit(1)
295
-
296
- print(f"Extracting figure archive: {archive_path}")
297
-
298
- # Create temporary directory and extract files
299
- with tempfile.TemporaryDirectory(prefix="figpack_view_") as temp_dir:
300
- temp_path = pathlib.Path(temp_dir)
301
-
302
- try:
303
- with tarfile.open(archive_path, "r:gz") as tar:
304
- tar.extractall(temp_path)
305
-
306
- # Count extracted files
307
- extracted_files = list(temp_path.rglob("*"))
308
- file_count = len([f for f in extracted_files if f.is_file()])
309
- print(f"Extracted {file_count} files")
310
-
311
- # Check if index.html exists
312
- index_html = temp_path / "index.html"
313
- if not index_html.exists():
314
- print("Warning: No index.html found in archive")
315
- print("Available files:")
316
- for f in sorted(extracted_files):
317
- if f.is_file():
318
- print(f" {f.relative_to(temp_path)}")
319
-
320
- # Serve the files
321
- serve_files(
322
- str(temp_path),
323
- port=port,
324
- open_in_browser=True,
325
- allow_origin=None,
326
- )
327
-
328
- except tarfile.TarError as e:
329
- print(f"Error: Failed to extract archive: {e}")
330
- sys.exit(1)
331
-
332
-
333
217
  def main():
334
218
  """Main CLI entry point"""
335
219
  parser = argparse.ArgumentParser(
@@ -9,7 +9,7 @@ thisdir = pathlib.Path(__file__).parent.resolve()
9
9
 
10
10
 
11
11
  def prepare_figure_bundle(
12
- view: FigpackView, tmpdir: str, *, title: str = None, description: str = None
12
+ view: FigpackView, tmpdir: str, *, title: str, description: str = None
13
13
  ) -> None:
14
14
  """
15
15
  Prepare a figure bundle in the specified temporary directory.
@@ -22,7 +22,7 @@ def prepare_figure_bundle(
22
22
  Args:
23
23
  view: The figpack view to prepare
24
24
  tmpdir: The temporary directory to prepare the bundle in
25
- title: Optional title for the figure
25
+ title: Title for the figure (required)
26
26
  description: Optional description for the figure (markdown supported)
27
27
  """
28
28
  html_dir = thisdir / ".." / "figpack-gui-dist"
@@ -50,8 +50,7 @@ def prepare_figure_bundle(
50
50
  view._write_to_zarr_group(zarr_group)
51
51
 
52
52
  # Add title and description as attributes on the top-level zarr group
53
- if title is not None:
54
- zarr_group.attrs["title"] = title
53
+ zarr_group.attrs["title"] = title
55
54
  if description is not None:
56
55
  zarr_group.attrs["description"] = description
57
56
 
@@ -0,0 +1,31 @@
1
+ import pathlib
2
+ import tempfile
3
+
4
+ from ._bundle_utils import prepare_figure_bundle
5
+ from .figpack_view import FigpackView
6
+
7
+
8
+ def _save_figure(view: FigpackView, output_path: str):
9
+ """
10
+ Save the figure to a folder or a .tar.gz file
11
+
12
+ Args:
13
+ view: FigpackView instance to save
14
+ output_path: Output path (destination folder or .tar.gz file path)
15
+ """
16
+ output_path = pathlib.Path(output_path)
17
+ if (output_path.suffix == ".gz" and output_path.suffixes[-2] == ".tar") or (
18
+ output_path.suffix == ".tgz"
19
+ ):
20
+ # It's a .tar.gz file
21
+ with tempfile.TemporaryDirectory(prefix="figpack_save_") as tmpdir:
22
+ prepare_figure_bundle(view, tmpdir)
23
+ # Create tar.gz file
24
+ import tarfile
25
+
26
+ with tarfile.open(output_path, "w:gz") as tar:
27
+ tar.add(tmpdir, arcname=".")
28
+ else:
29
+ # It's a folder
30
+ output_path.mkdir(parents=True, exist_ok=True)
31
+ prepare_figure_bundle(view, str(output_path))
@@ -138,11 +138,17 @@ class ProcessServerManager:
138
138
  self._create_process_info_file()
139
139
  return self._temp_dir
140
140
 
141
- def create_figure_subdir(self) -> pathlib.Path:
141
+ def create_figure_subdir(
142
+ self, *, _local_figure_name: Optional[str] = None
143
+ ) -> pathlib.Path:
142
144
  """Create a unique subdirectory for a figure within the process temp dir."""
143
145
  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
+ local_figure_name = (
147
+ "figure_" + str(uuid.uuid4())[:8]
148
+ if _local_figure_name is None
149
+ else _local_figure_name
150
+ )
151
+ figure_dir = temp_dir / f"{local_figure_name}"
146
152
  figure_dir.mkdir(exist_ok=True)
147
153
  return figure_dir
148
154
 
@@ -200,8 +206,6 @@ class ProcessServerManager:
200
206
  # Start directory monitoring thread
201
207
  self._start_directory_monitor()
202
208
 
203
- print(f"Started figpack server at http://localhost:{port} serving {temp_dir}")
204
-
205
209
  return f"http://localhost:{port}", port
206
210
 
207
211
  def _stop_server(self):
@@ -84,22 +84,18 @@ thisdir = pathlib.Path(__file__).parent.resolve()
84
84
  def _show_view(
85
85
  view: FigpackView,
86
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,
87
+ open_in_browser: bool,
88
+ port: Union[int, None],
89
+ allow_origin: Union[str, None],
90
+ upload: bool,
91
+ ephemeral: bool,
92
+ title: str,
93
+ description: Union[str, None],
94
+ inline: bool,
95
+ inline_height: int,
96
+ wait_for_input: bool,
97
+ _local_figure_name: Union[str, None],
96
98
  ):
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
99
  if upload:
104
100
  # Upload behavior: create temporary directory for this upload only
105
101
  with tempfile.TemporaryDirectory(prefix="figpack_upload_") as tmpdir:
@@ -117,7 +113,7 @@ def _show_view(
117
113
  tmpdir, api_key, title=title, ephemeral=ephemeral
118
114
  )
119
115
 
120
- if use_inline:
116
+ if inline:
121
117
  # For uploaded figures, display the remote URL inline and continue
122
118
  _display_inline_iframe(figure_url, inline_height)
123
119
  else:
@@ -128,15 +124,18 @@ def _show_view(
128
124
  else:
129
125
  print(f"View the figure at: {figure_url}")
130
126
  # Wait until user presses Enter
131
- input("Press Enter to continue...")
132
127
 
128
+ if wait_for_input:
129
+ input("Press Enter to continue...")
133
130
  return figure_url
134
131
  else:
135
132
  # Local server behavior: use process-level server manager
136
133
  server_manager = ProcessServerManager.get_instance()
137
134
 
138
135
  # Create figure subdirectory in process temp directory
139
- figure_dir = server_manager.create_figure_subdir()
136
+ figure_dir = server_manager.create_figure_subdir(
137
+ _local_figure_name=_local_figure_name
138
+ )
140
139
 
141
140
  # Prepare the figure bundle in the subdirectory
142
141
  prepare_figure_bundle(
@@ -152,7 +151,7 @@ def _show_view(
152
151
  figure_subdir_name = figure_dir.name
153
152
  figure_url = f"{base_url}/{figure_subdir_name}"
154
153
 
155
- if use_inline:
154
+ if inline:
156
155
  # Display inline and continue (don't block)
157
156
  _display_inline_iframe(figure_url, inline_height)
158
157
  else:
@@ -162,7 +161,7 @@ def _show_view(
162
161
  print(f"Opening {figure_url} in browser.")
163
162
  else:
164
163
  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
164
 
165
+ if wait_for_input:
166
+ input("Press Enter to continue...")
168
167
  return figure_url
@@ -11,7 +11,7 @@ import requests
11
11
 
12
12
  from .. import __version__
13
13
 
14
- from .config import FIGPACK_API_BASE_URL
14
+ from .config import FIGPACK_API_BASE_URL, FIGPACK_BUCKET
15
15
 
16
16
  thisdir = pathlib.Path(__file__).parent.resolve()
17
17
 
@@ -69,27 +69,54 @@ def _get_batch_signed_urls(figure_url: str, files_batch: list, api_key: str) ->
69
69
 
70
70
 
71
71
  def _upload_single_file_with_signed_url(
72
- relative_path: str, file_path: pathlib.Path, signed_url: str
72
+ relative_path: str, file_path: pathlib.Path, signed_url: str, num_retries: int = 4
73
73
  ) -> str:
74
74
  """
75
- Upload a single file using a pre-obtained signed URL
75
+ Upload a single file using a pre-obtained signed URL with exponential backoff retries
76
+
77
+ Args:
78
+ relative_path: The relative path of the file
79
+ file_path: The path to the file to upload
80
+ signed_url: The signed URL to upload to
81
+ num_retries: Number of retries on failure with exponential backoff (default: 4)
76
82
 
77
83
  Returns:
78
84
  str: The relative path of the uploaded file
85
+
86
+ Raises:
87
+ Exception: If upload fails after all retries are exhausted
79
88
  """
80
- # Upload file to signed URL
81
89
  content_type = _determine_content_type(relative_path)
82
- with open(file_path, "rb") as f:
83
- upload_response = requests.put(
84
- signed_url, data=f, headers={"Content-Type": content_type}
85
- )
90
+ retries_remaining = num_retries
91
+ last_exception = None
86
92
 
87
- if not upload_response.ok:
88
- raise Exception(
89
- f"Failed to upload {relative_path} to signed URL: HTTP {upload_response.status_code}"
90
- )
93
+ while retries_remaining >= 0:
94
+ try:
95
+ with open(file_path, "rb") as f:
96
+ upload_response = requests.put(
97
+ signed_url, data=f, headers={"Content-Type": content_type}
98
+ )
91
99
 
92
- return relative_path
100
+ if upload_response.ok:
101
+ return relative_path
102
+
103
+ last_exception = Exception(
104
+ f"Failed to upload {relative_path} to signed URL: HTTP {upload_response.status_code}"
105
+ )
106
+ except Exception as e:
107
+ last_exception = e
108
+
109
+ if retries_remaining > 0:
110
+ backoff_seconds = 2 ** (num_retries - retries_remaining)
111
+ print(
112
+ f"Upload failed for {relative_path}, retrying in {backoff_seconds} seconds..."
113
+ )
114
+ time.sleep(backoff_seconds)
115
+ retries_remaining -= 1
116
+ else:
117
+ break
118
+
119
+ raise last_exception
93
120
 
94
121
 
95
122
  MAX_WORKERS_FOR_UPLOAD = 16
@@ -156,6 +183,7 @@ def _create_or_get_figure(
156
183
  payload = {
157
184
  "figureHash": figure_hash,
158
185
  "figpackVersion": __version__,
186
+ "bucket": FIGPACK_BUCKET,
159
187
  }
160
188
 
161
189
  # API key is optional for ephemeral figures
@@ -363,17 +391,13 @@ def _upload_bundle(
363
391
  if "manifest.json" not in signed_urls_map:
364
392
  raise Exception("No signed URL returned for manifest.json")
365
393
 
366
- # Upload manifest using signed URL
367
- upload_response = requests.put(
394
+ # Upload manifest using the same retry function
395
+ _upload_single_file_with_signed_url(
396
+ "manifest.json",
397
+ temp_file_path,
368
398
  signed_urls_map["manifest.json"],
369
- data=manifest_content,
370
- headers={"Content-Type": "application/json"},
399
+ num_retries=4,
371
400
  )
372
-
373
- if not upload_response.ok:
374
- raise Exception(
375
- f"Failed to upload manifest.json to signed URL: HTTP {upload_response.status_code}"
376
- )
377
401
  finally:
378
402
  # Clean up temporary file
379
403
  temp_file_path.unlink(missing_ok=True)
@@ -0,0 +1,138 @@
1
+ """
2
+ Core functionality for viewing figures locally
3
+ """
4
+
5
+ import pathlib
6
+ import socket
7
+ import sys
8
+ import tarfile
9
+ import tempfile
10
+ import threading
11
+ import webbrowser
12
+ from typing import Union
13
+
14
+ from ._server_manager import CORSRequestHandler, ThreadingHTTPServer
15
+
16
+
17
+ def serve_files(
18
+ tmpdir: str,
19
+ *,
20
+ port: Union[int, None],
21
+ open_in_browser: bool = False,
22
+ allow_origin: Union[str, None] = None,
23
+ ):
24
+ """
25
+ Serve files from a directory using a simple HTTP server.
26
+
27
+ Args:
28
+ tmpdir: Directory to serve
29
+ port: Port number for local server
30
+ open_in_browser: Whether to open in browser automatically
31
+ allow_origin: CORS allow origin header
32
+ """
33
+ # if port is None, find a free port
34
+ if port is None:
35
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
36
+ s.bind(("", 0))
37
+ port = s.getsockname()[1]
38
+
39
+ tmpdir = pathlib.Path(tmpdir)
40
+ tmpdir = tmpdir.resolve()
41
+ if not tmpdir.exists() or not tmpdir.is_dir():
42
+ raise SystemExit(f"Directory not found: {tmpdir}")
43
+
44
+ # Configure handler with directory and allow_origin
45
+ def handler_factory(*args, **kwargs):
46
+ return CORSRequestHandler(
47
+ *args, directory=str(tmpdir), allow_origin=allow_origin, **kwargs
48
+ )
49
+
50
+ httpd = ThreadingHTTPServer(("0.0.0.0", port), handler_factory)
51
+ print(f"Serving {tmpdir} at http://localhost:{port} (CORS → {allow_origin})")
52
+ thread = threading.Thread(target=httpd.serve_forever, daemon=True)
53
+ thread.start()
54
+
55
+ if open_in_browser:
56
+ webbrowser.open(f"http://localhost:{port}")
57
+ print(f"Opening http://localhost:{port} in your browser.")
58
+ else:
59
+ print(
60
+ f"Open http://localhost:{port} in your browser to view the visualization."
61
+ )
62
+
63
+ try:
64
+ input("Press Enter to stop...\n")
65
+ except (KeyboardInterrupt, EOFError):
66
+ pass
67
+ finally:
68
+ print("Shutting down server...")
69
+ httpd.shutdown()
70
+ httpd.server_close()
71
+ thread.join()
72
+
73
+
74
+ def view_figure(figure_path: str, port: Union[int, None] = None) -> None:
75
+ """
76
+ Extract and serve a figure archive locally
77
+
78
+ Args:
79
+ figure_path: Path to a .tar.gz archive file or a directory
80
+ port: Optional port number to serve on
81
+ """
82
+ figure_pathlib = pathlib.Path(figure_path)
83
+
84
+ if not figure_pathlib.exists():
85
+ print(f"Error: Archive file not found: {figure_path}")
86
+ sys.exit(1)
87
+
88
+ if figure_pathlib.is_dir():
89
+ # We assume it's a directory
90
+ serve_files(
91
+ str(figure_pathlib),
92
+ port=port,
93
+ open_in_browser=True,
94
+ allow_origin=None,
95
+ )
96
+ return
97
+
98
+ if not figure_pathlib.suffix.lower() in [".gz", ".tgz"] or not str(
99
+ figure_pathlib
100
+ ).endswith(".tar.gz"):
101
+ print(f"Error: Archive file must be a .tar.gz file: {figure_path}")
102
+ sys.exit(1)
103
+
104
+ print(f"Extracting figure archive: {figure_path}")
105
+
106
+ # Create temporary directory and extract files
107
+ with tempfile.TemporaryDirectory(prefix="figpack_view_") as temp_dir:
108
+ temp_path = pathlib.Path(temp_dir)
109
+
110
+ try:
111
+ with tarfile.open(figure_path, "r:gz") as tar:
112
+ tar.extractall(temp_path)
113
+
114
+ # Count extracted files
115
+ extracted_files = list(temp_path.rglob("*"))
116
+ file_count = len([f for f in extracted_files if f.is_file()])
117
+ print(f"Extracted {file_count} files")
118
+
119
+ # Check if index.html exists
120
+ index_html = temp_path / "index.html"
121
+ if not index_html.exists():
122
+ print("Warning: No index.html found in archive")
123
+ print("Available files:")
124
+ for f in sorted(extracted_files):
125
+ if f.is_file():
126
+ print(f" {f.relative_to(temp_path)}")
127
+
128
+ # Serve the files
129
+ serve_files(
130
+ str(temp_path),
131
+ port=port,
132
+ open_in_browser=True,
133
+ allow_origin=None,
134
+ )
135
+
136
+ except tarfile.TarError as e:
137
+ print(f"Error: Failed to extract archive: {e}")
138
+ sys.exit(1)
figpack/core/config.py CHANGED
@@ -3,3 +3,5 @@ import os
3
3
  FIGPACK_API_BASE_URL = os.getenv(
4
4
  "FIGPACK_API_BASE_URL", "https://figpack-api.vercel.app"
5
5
  )
6
+
7
+ FIGPACK_BUCKET = os.getenv("FIGPACK_BUCKET", "figpack-figures")