figpack 0.2.4__py3-none-any.whl → 0.2.6__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.

Files changed (31) hide show
  1. figpack/__init__.py +5 -1
  2. figpack/cli.py +2 -118
  3. figpack/core/_bundle_utils.py +5 -6
  4. figpack/core/_save_figure.py +31 -0
  5. figpack/core/_server_manager.py +0 -2
  6. figpack/core/_show_view.py +22 -22
  7. figpack/core/_upload_bundle.py +61 -22
  8. figpack/core/_view_figure.py +138 -0
  9. figpack/core/figpack_view.py +74 -25
  10. figpack/{figpack-gui-dist/assets/index-CuFseOGX.js → figpack-figure-dist/assets/index-HXdk2TtM.js} +58 -58
  11. figpack/{figpack-gui-dist → figpack-figure-dist}/index.html +1 -1
  12. figpack/spike_sorting/views/Autocorrelograms.py +29 -19
  13. figpack/spike_sorting/views/CrossCorrelograms.py +29 -19
  14. figpack/spike_sorting/views/UnitsTable.py +27 -8
  15. figpack/views/Gallery.py +88 -0
  16. figpack/views/GalleryItem.py +47 -0
  17. figpack/views/Image.py +37 -0
  18. figpack/views/Markdown.py +12 -2
  19. figpack/views/MatplotlibFigure.py +26 -3
  20. figpack/views/PlotlyFigure.py +18 -2
  21. figpack/views/__init__.py +2 -0
  22. figpack-0.2.6.dist-info/METADATA +96 -0
  23. figpack-0.2.6.dist-info/RECORD +53 -0
  24. figpack-0.2.4.dist-info/METADATA +0 -168
  25. figpack-0.2.4.dist-info/RECORD +0 -49
  26. /figpack/{figpack-gui-dist → figpack-figure-dist}/assets/index-Cmae55E4.css +0 -0
  27. /figpack/{figpack-gui-dist → figpack-figure-dist}/assets/neurosift-logo-CLsuwLMO.png +0 -0
  28. {figpack-0.2.4.dist-info → figpack-0.2.6.dist-info}/WHEEL +0 -0
  29. {figpack-0.2.4.dist-info → figpack-0.2.6.dist-info}/entry_points.txt +0 -0
  30. {figpack-0.2.4.dist-info → figpack-0.2.6.dist-info}/licenses/LICENSE +0 -0
  31. {figpack-0.2.4.dist-info → figpack-0.2.6.dist-info}/top_level.txt +0 -0
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.4"
5
+ __version__ = "0.2.6"
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,23 +9,23 @@ 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.
16
16
 
17
17
  This function:
18
- 1. Copies all files from the figpack-gui-dist directory to tmpdir
18
+ 1. Copies all files from the figpack-figure-dist directory to tmpdir
19
19
  2. Writes the view data to a zarr group
20
20
  3. Consolidates zarr metadata
21
21
 
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
- html_dir = thisdir / ".." / "figpack-gui-dist"
28
+ html_dir = thisdir / ".." / "figpack-figure-dist"
29
29
  if not os.path.exists(html_dir):
30
30
  raise SystemExit(f"Error: directory not found: {html_dir}")
31
31
 
@@ -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, *, title: 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, title=title)
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), title=title)
@@ -206,8 +206,6 @@ class ProcessServerManager:
206
206
  # Start directory monitoring thread
207
207
  self._start_directory_monitor()
208
208
 
209
- print(f"Started figpack server at http://localhost:{port} serving {temp_dir}")
210
-
211
209
  return f"http://localhost:{port}", port
212
210
 
213
211
  def _stop_server(self):
@@ -84,23 +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,
96
- _local_figure_name: Union[str, None] = None,
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],
97
98
  ):
98
- # Determine if we should use inline display
99
- use_inline = inline
100
- if inline is None:
101
- # Auto-detect: use inline if we're in a notebook
102
- use_inline = _is_in_notebook()
103
-
104
99
  if upload:
105
100
  # Upload behavior: create temporary directory for this upload only
106
101
  with tempfile.TemporaryDirectory(prefix="figpack_upload_") as tmpdir:
@@ -115,10 +110,14 @@ def _show_view(
115
110
 
116
111
  # Upload the bundle
117
112
  figure_url = _upload_bundle(
118
- tmpdir, api_key, title=title, ephemeral=ephemeral
113
+ tmpdir,
114
+ api_key,
115
+ title=title,
116
+ ephemeral=ephemeral,
117
+ use_consolidated_metadata_only=True,
119
118
  )
120
119
 
121
- if use_inline:
120
+ if inline:
122
121
  # For uploaded figures, display the remote URL inline and continue
123
122
  _display_inline_iframe(figure_url, inline_height)
124
123
  else:
@@ -129,8 +128,9 @@ def _show_view(
129
128
  else:
130
129
  print(f"View the figure at: {figure_url}")
131
130
  # Wait until user presses Enter
132
- input("Press Enter to continue...")
133
131
 
132
+ if wait_for_input:
133
+ input("Press Enter to continue...")
134
134
  return figure_url
135
135
  else:
136
136
  # Local server behavior: use process-level server manager
@@ -155,7 +155,7 @@ def _show_view(
155
155
  figure_subdir_name = figure_dir.name
156
156
  figure_url = f"{base_url}/{figure_subdir_name}"
157
157
 
158
- if use_inline:
158
+ if inline:
159
159
  # Display inline and continue (don't block)
160
160
  _display_inline_iframe(figure_url, inline_height)
161
161
  else:
@@ -165,7 +165,7 @@ def _show_view(
165
165
  print(f"Opening {figure_url} in browser.")
166
166
  else:
167
167
  print(f"Open {figure_url} in your browser to view the visualization.")
168
- # Wait until user presses Enter
169
- input("Press Enter to continue...")
170
168
 
169
+ if wait_for_input:
170
+ input("Press Enter to continue...")
171
171
  return figure_url
@@ -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
+ )
99
+
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
91
118
 
92
- return relative_path
119
+ raise last_exception
93
120
 
94
121
 
95
122
  MAX_WORKERS_FOR_UPLOAD = 16
@@ -226,10 +253,22 @@ def _finalize_figure(figure_url: str, api_key: str) -> dict:
226
253
 
227
254
 
228
255
  def _upload_bundle(
229
- tmpdir: str, api_key: str, title: str = None, ephemeral: bool = False
256
+ tmpdir: str,
257
+ api_key: str,
258
+ title: str = None,
259
+ ephemeral: bool = False,
260
+ use_consolidated_metadata_only: bool = False,
230
261
  ) -> str:
231
262
  """
232
263
  Upload the prepared bundle to the cloud using the new database-driven approach
264
+
265
+ Args:
266
+ tmpdir: Path to the temporary directory containing the bundle
267
+ api_key: API key for authentication
268
+ title: Optional title for the figure
269
+ ephemeral: Whether to create an ephemeral figure
270
+ use_consolidated_metadata_only: If True, excludes individual zarr metadata files
271
+ (.zgroup, .zarray, .zattrs) since they are included in .zmetadata
233
272
  """
234
273
  tmpdir_path = pathlib.Path(tmpdir)
235
274
 
@@ -241,6 +280,10 @@ def _upload_bundle(
241
280
  for file_path in tmpdir_path.rglob("*"):
242
281
  if file_path.is_file():
243
282
  relative_path = file_path.relative_to(tmpdir_path)
283
+ # Skip individual zarr metadata files if using consolidated metadata only
284
+ if use_consolidated_metadata_only:
285
+ if str(relative_path).endswith((".zgroup", ".zarray", ".zattrs")):
286
+ continue
244
287
  all_files.append((str(relative_path), file_path))
245
288
 
246
289
  # Calculate total files and size for metadata
@@ -364,17 +407,13 @@ def _upload_bundle(
364
407
  if "manifest.json" not in signed_urls_map:
365
408
  raise Exception("No signed URL returned for manifest.json")
366
409
 
367
- # Upload manifest using signed URL
368
- upload_response = requests.put(
410
+ # Upload manifest using the same retry function
411
+ _upload_single_file_with_signed_url(
412
+ "manifest.json",
413
+ temp_file_path,
369
414
  signed_urls_map["manifest.json"],
370
- data=manifest_content,
371
- headers={"Content-Type": "application/json"},
415
+ num_retries=4,
372
416
  )
373
-
374
- if not upload_response.ok:
375
- raise Exception(
376
- f"Failed to upload manifest.json to signed URL: HTTP {upload_response.status_code}"
377
- )
378
417
  finally:
379
418
  # Clean up temporary file
380
419
  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)