figpack 0.2.17__py3-none-any.whl → 0.2.40__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.
Files changed (44) hide show
  1. figpack/__init__.py +1 -1
  2. figpack/cli.py +288 -2
  3. figpack/core/_bundle_utils.py +40 -7
  4. figpack/core/_file_handler.py +195 -0
  5. figpack/core/_save_figure.py +12 -8
  6. figpack/core/_server_manager.py +146 -7
  7. figpack/core/_show_view.py +2 -2
  8. figpack/core/_upload_bundle.py +63 -53
  9. figpack/core/_view_figure.py +48 -12
  10. figpack/core/_zarr_consolidate.py +185 -0
  11. figpack/core/extension_view.py +9 -5
  12. figpack/core/figpack_extension.py +1 -1
  13. figpack/core/figpack_view.py +52 -21
  14. figpack/core/zarr.py +2 -2
  15. figpack/extensions.py +356 -0
  16. figpack/figpack-figure-dist/assets/index-ST_DU17U.js +95 -0
  17. figpack/figpack-figure-dist/assets/index-V5m_wCvw.css +1 -0
  18. figpack/figpack-figure-dist/index.html +6 -2
  19. figpack/views/Box.py +4 -4
  20. figpack/views/CaptionedView.py +64 -0
  21. figpack/views/DataFrame.py +1 -1
  22. figpack/views/Gallery.py +2 -2
  23. figpack/views/Iframe.py +43 -0
  24. figpack/views/Image.py +2 -3
  25. figpack/views/Markdown.py +8 -4
  26. figpack/views/MatplotlibFigure.py +1 -1
  27. figpack/views/MountainLayout.py +72 -0
  28. figpack/views/MountainLayoutItem.py +50 -0
  29. figpack/views/MultiChannelTimeseries.py +1 -1
  30. figpack/views/PlotlyExtension/PlotlyExtension.py +14 -14
  31. figpack/views/Spectrogram.py +3 -1
  32. figpack/views/Splitter.py +3 -3
  33. figpack/views/TabLayout.py +2 -2
  34. figpack/views/TimeseriesGraph.py +113 -20
  35. figpack/views/__init__.py +4 -0
  36. {figpack-0.2.17.dist-info → figpack-0.2.40.dist-info}/METADATA +25 -1
  37. figpack-0.2.40.dist-info/RECORD +50 -0
  38. figpack/figpack-figure-dist/assets/index-DBwmtEpB.js +0 -91
  39. figpack/figpack-figure-dist/assets/index-DHWczh-Q.css +0 -1
  40. figpack-0.2.17.dist-info/RECORD +0 -43
  41. {figpack-0.2.17.dist-info → figpack-0.2.40.dist-info}/WHEEL +0 -0
  42. {figpack-0.2.17.dist-info → figpack-0.2.40.dist-info}/entry_points.txt +0 -0
  43. {figpack-0.2.17.dist-info → figpack-0.2.40.dist-info}/licenses/LICENSE +0 -0
  44. {figpack-0.2.17.dist-info → figpack-0.2.40.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,185 @@
1
+ import os
2
+ import pathlib
3
+ import json
4
+ from typing import Dict, List, Tuple
5
+
6
+
7
+ def consolidate_zarr_chunks(
8
+ zarr_dir: pathlib.Path, max_file_size: int = 100_000_000
9
+ ) -> None:
10
+ """
11
+ Consolidate zarr chunk files into larger files to reduce the number of files
12
+ that need to be uploaded. Updates the .zmetadata file with refs mapping.
13
+
14
+ Args:
15
+ zarr_dir: Path to the zarr directory
16
+ max_file_size: Maximum size for each consolidated file in bytes (default: 100 MB)
17
+ """
18
+ if not zarr_dir.is_dir():
19
+ raise ValueError(f"Expected a directory, got: {zarr_dir}")
20
+
21
+ # Read the existing .zmetadata file
22
+ zmetadata_path = zarr_dir / ".zmetadata"
23
+ if not zmetadata_path.exists():
24
+ raise ValueError(f"No .zmetadata file found at {zmetadata_path}")
25
+
26
+ with open(zmetadata_path, "r") as f:
27
+ zmetadata = json.load(f)
28
+
29
+ # Collect all chunk files (non-metadata files)
30
+ chunk_files = _collect_chunk_files(zarr_dir)
31
+
32
+ if not chunk_files:
33
+ # No chunk files to consolidate
34
+ return
35
+
36
+ # Group chunk files into consolidated files
37
+ consolidated_groups = _group_files_by_size(chunk_files, max_file_size)
38
+
39
+ # Create consolidated files and build refs mapping
40
+ refs: Dict[str, List] = {}
41
+ for group_idx, file_group in enumerate(consolidated_groups):
42
+ consolidated_filename = f"_consolidated_{group_idx}.dat"
43
+ consolidated_path = zarr_dir / consolidated_filename
44
+
45
+ # Write the consolidated file and track byte offsets
46
+ current_offset = 0
47
+ with open(consolidated_path, "wb") as consolidated_file:
48
+ for file_path, relative_path in file_group:
49
+ # Read the chunk file
50
+ with open(file_path, "rb") as chunk_file:
51
+ chunk_data = chunk_file.read()
52
+
53
+ # Write to consolidated file
54
+ consolidated_file.write(chunk_data)
55
+
56
+ # Add to refs mapping
57
+ refs[relative_path] = [
58
+ consolidated_filename,
59
+ current_offset,
60
+ len(chunk_data),
61
+ ]
62
+
63
+ # Update offset
64
+ current_offset += len(chunk_data)
65
+
66
+ # Update .zmetadata with refs
67
+ zmetadata["refs"] = refs
68
+
69
+ # Write updated .zmetadata
70
+ with open(zmetadata_path, "w") as f:
71
+ json.dump(zmetadata, f, indent=2)
72
+
73
+ # Delete original chunk files
74
+ for file_path, _ in chunk_files:
75
+ try:
76
+ file_path.unlink()
77
+ except Exception as e:
78
+ print(f"Warning: could not remove file {file_path}: {e}")
79
+
80
+ # Clean up empty directories
81
+ _remove_empty_directories(zarr_dir)
82
+
83
+
84
+ def _collect_chunk_files(zarr_dir: pathlib.Path) -> List[Tuple[pathlib.Path, str]]:
85
+ """
86
+ Collect all chunk files in the zarr directory (excluding metadata files).
87
+
88
+ Args:
89
+ zarr_dir: Path to the zarr directory
90
+
91
+ Returns:
92
+ List of tuples (absolute_path, relative_path) for each chunk file
93
+ """
94
+ chunk_files = []
95
+ metadata_files = {".zmetadata", ".zarray", ".zgroup", ".zattrs"}
96
+
97
+ for root, dirs, files in os.walk(zarr_dir):
98
+ for file in files:
99
+ # Skip metadata files
100
+ if file in metadata_files or file.startswith("_consolidated_"):
101
+ continue
102
+
103
+ file_path = pathlib.Path(root) / file
104
+ # Get relative path from zarr_dir
105
+ relative_path = file_path.relative_to(zarr_dir).as_posix()
106
+
107
+ chunk_files.append((file_path, relative_path))
108
+
109
+ return chunk_files
110
+
111
+
112
+ def _group_files_by_size(
113
+ files: List[Tuple[pathlib.Path, str]], max_size: int
114
+ ) -> List[List[Tuple[pathlib.Path, str]]]:
115
+ """
116
+ Group files into bins where each bin's total size is <= max_size.
117
+
118
+ Uses a simple first-fit bin packing algorithm.
119
+
120
+ Args:
121
+ files: List of (file_path, relative_path) tuples
122
+ max_size: Maximum total size for each group in bytes
123
+
124
+ Returns:
125
+ List of groups, where each group is a list of (file_path, relative_path) tuples
126
+ """
127
+ # Get file sizes
128
+ files_with_sizes = []
129
+ for file_path, relative_path in files:
130
+ try:
131
+ size = file_path.stat().st_size
132
+ files_with_sizes.append((file_path, relative_path, size))
133
+ except Exception as e:
134
+ print(f"Warning: could not get size of {file_path}: {e}")
135
+ continue
136
+
137
+ # Sort by size (largest first) for better packing
138
+ files_with_sizes.sort(key=lambda x: x[2], reverse=True)
139
+
140
+ # First-fit bin packing
141
+ groups: List[List[Tuple[pathlib.Path, str]]] = []
142
+ group_sizes: List[int] = []
143
+
144
+ for file_path, relative_path, size in files_with_sizes:
145
+ # If file is larger than max_size, put it in its own group
146
+ if size > max_size:
147
+ groups.append([(file_path, relative_path)])
148
+ group_sizes.append(size)
149
+ continue
150
+
151
+ # Try to fit into existing group
152
+ placed = False
153
+ for i, group_size in enumerate(group_sizes):
154
+ if group_size + size <= max_size:
155
+ groups[i].append((file_path, relative_path))
156
+ group_sizes[i] += size
157
+ placed = True
158
+ break
159
+
160
+ # If doesn't fit anywhere, create new group
161
+ if not placed:
162
+ groups.append([(file_path, relative_path)])
163
+ group_sizes.append(size)
164
+
165
+ return groups
166
+
167
+
168
+ def _remove_empty_directories(zarr_dir: pathlib.Path) -> None:
169
+ """
170
+ Remove empty directories within the zarr directory.
171
+
172
+ Args:
173
+ zarr_dir: Path to the zarr directory
174
+ """
175
+ # Walk bottom-up so we can remove empty parent directories
176
+ for root, dirs, files in os.walk(zarr_dir, topdown=False):
177
+ for dir_name in dirs:
178
+ dir_path = pathlib.Path(root) / dir_name
179
+ try:
180
+ # Only remove if directory is empty
181
+ if not any(dir_path.iterdir()):
182
+ dir_path.rmdir()
183
+ except Exception:
184
+ # Directory not empty or other error, skip
185
+ pass
@@ -2,9 +2,13 @@
2
2
  Base class for views that use figpack extensions
3
3
  """
4
4
 
5
+ from typing import TYPE_CHECKING
6
+
5
7
  from .figpack_view import FigpackView
6
- from .figpack_extension import FigpackExtension
7
- from ..core.zarr import Group
8
+
9
+ if TYPE_CHECKING:
10
+ from .figpack_extension import FigpackExtension
11
+ from .zarr import Group
8
12
 
9
13
 
10
14
  class ExtensionView(FigpackView):
@@ -12,7 +16,7 @@ class ExtensionView(FigpackView):
12
16
  Base class for views that are rendered by figpack extensions
13
17
  """
14
18
 
15
- def __init__(self, *, extension: FigpackExtension, view_type: str):
19
+ def __init__(self, *, extension: "FigpackExtension", view_type: str) -> None:
16
20
  """
17
21
  Initialize an extension-based view
18
22
 
@@ -23,10 +27,10 @@ class ExtensionView(FigpackView):
23
27
  self.extension = extension
24
28
  self.view_type = view_type
25
29
 
26
- def _write_to_zarr_group(self, group: Group) -> None:
30
+ def write_to_zarr_group(self, group: "Group") -> None:
27
31
  """
28
32
  Write the extension view metadata to a Zarr group.
29
- Subclasses should call super()._write_to_zarr_group(group) first,
33
+ Subclasses should call super().write_to_zarr_group(group) first,
30
34
  then add their own data.
31
35
 
32
36
  Args:
@@ -17,7 +17,7 @@ class FigpackExtension:
17
17
  javascript_code: str,
18
18
  additional_files: Optional[Dict[str, str]] = None,
19
19
  version: str = "1.0.0",
20
- ):
20
+ ) -> None:
21
21
  """
22
22
  Initialize a figpack extension
23
23
 
@@ -5,7 +5,7 @@ Base view class for figpack visualization components
5
5
  import os
6
6
  import random
7
7
  import string
8
- from typing import Union
8
+ from typing import Optional
9
9
 
10
10
  from .zarr import Group
11
11
 
@@ -19,17 +19,17 @@ class FigpackView:
19
19
  self,
20
20
  *,
21
21
  title: str,
22
- description: Union[str, None] = None,
23
- port: Union[int, None] = None,
24
- open_in_browser: Union[bool, None] = None,
25
- upload: Union[bool, None] = None,
26
- inline: Union[bool, None] = None,
22
+ description: Optional[str] = None,
23
+ port: Optional[int] = None,
24
+ open_in_browser: Optional[bool] = None,
25
+ upload: Optional[bool] = None,
26
+ inline: Optional[bool] = None,
27
27
  inline_height: int = 600,
28
- ephemeral: Union[bool, None] = None,
29
- allow_origin: Union[str, None] = None,
30
- wait_for_input: Union[bool, None] = None,
31
- _dev: Union[bool, None] = None,
32
- ):
28
+ ephemeral: Optional[bool] = None,
29
+ allow_origin: Optional[str] = None,
30
+ wait_for_input: Optional[bool] = None,
31
+ _dev: Optional[bool] = None,
32
+ ) -> None:
33
33
  """
34
34
  Display a figpack view component with intelligent environment detection and flexible display options.
35
35
  See https://flatironinstitute.github.io/figpack/show_function.html for complete documentation.
@@ -60,13 +60,23 @@ class FigpackView:
60
60
 
61
61
  # determine upload
62
62
  if upload is None:
63
- upload = os.environ.get("FIGPACK_UPLOAD") == "1"
64
- else:
65
- if upload is True and ephemeral is True:
63
+ if os.environ.get("FIGPACK_UPLOAD") == "1":
64
+ upload = True
65
+ elif os.environ.get("FIGPACK_UPLOAD") == "0":
66
+ upload = False
67
+
68
+ if upload is True:
69
+ if ephemeral is True:
66
70
  # ephemeral is reserved for the case where we don't specify upload
67
71
  # and we are in a notebook in a remote environment such as
68
72
  # colab or jupyterhub
69
- raise ValueError("ephemeral cannot be set if upload is set")
73
+ raise ValueError(
74
+ "ephemeral cannot be set to True if upload is set to True"
75
+ )
76
+ else:
77
+ ephemeral = (
78
+ False # if we excplicitly set upload=True, force ephemeral=False
79
+ )
70
80
 
71
81
  # determine inline
72
82
  if inline is None:
@@ -76,15 +86,22 @@ class FigpackView:
76
86
  inline = False
77
87
  elif _is_in_notebook() and not upload:
78
88
  inline = True
89
+ else:
90
+ inline = False
79
91
 
80
92
  # determine open_in_browser
81
93
  if open_in_browser is None:
82
94
  open_in_browser = os.environ.get("FIGPACK_OPEN_IN_BROWSER") == "1"
83
95
 
84
96
  # determine ephemeral
85
- if ephemeral is None:
97
+ if ephemeral is None and not upload:
86
98
  ephemeral = False # default to False
87
- if _is_in_notebook():
99
+ if os.environ.get("FIGPACK_REMOTE_ENV") == "1":
100
+ ephemeral = True
101
+ upload = True
102
+ elif os.environ.get("FIGPACK_REMOTE_ENV") == "0":
103
+ ephemeral = False
104
+ elif _is_in_notebook():
88
105
  if _is_in_colab():
89
106
  # if we are in a notebook and in colab, we should show as uploaded ephemeral
90
107
  print("Detected Google Colab notebook environment.")
@@ -96,10 +113,22 @@ class FigpackView:
96
113
  upload = True
97
114
  ephemeral = True
98
115
 
116
+ if ephemeral is None:
117
+ ephemeral = False
118
+
119
+ if upload is None:
120
+ upload = False
121
+
99
122
  # determine _dev
100
123
  if _dev is None:
101
124
  _dev = os.environ.get("FIGPACK_DEV") == "1"
102
125
 
126
+ if port is None and os.environ.get("FIGPACK_PORT"):
127
+ try:
128
+ port = int(os.environ.get("FIGPACK_PORT", ""))
129
+ except Exception:
130
+ pass
131
+
103
132
  # determine wait_for_input
104
133
  if wait_for_input is None:
105
134
  wait_for_input = not _is_in_notebook()
@@ -108,6 +137,8 @@ class FigpackView:
108
137
  if ephemeral and not upload:
109
138
  raise ValueError("ephemeral=True requires upload=True to be set")
110
139
 
140
+ _local_figure_name: Optional[str] = None
141
+
111
142
  if _dev:
112
143
  if open_in_browser:
113
144
  print("** Note: In dev mode, open_in_browser is forced to False **")
@@ -145,7 +176,7 @@ class FigpackView:
145
176
  _local_figure_name=_local_figure_name if _dev else None,
146
177
  )
147
178
 
148
- def save(self, output_path: str, *, title: str) -> None:
179
+ def save(self, output_path: str, *, title: str, description: str = "") -> None:
149
180
  """
150
181
  Save as figure either to a folder or to a .tar.gz file
151
182
  Args:
@@ -153,13 +184,13 @@ class FigpackView:
153
184
  """
154
185
  from ._save_figure import _save_figure
155
186
 
156
- _save_figure(self, output_path, title=title)
187
+ _save_figure(self, output_path, title=title, description=description)
157
188
 
158
- def _write_to_zarr_group(self, group: Group) -> None:
189
+ def write_to_zarr_group(self, group: Group) -> None:
159
190
  """
160
191
  Write the view data to a Zarr group. Must be implemented by subclasses.
161
192
 
162
193
  Args:
163
194
  group: Zarr group to write data into
164
195
  """
165
- raise NotImplementedError("Subclasses must implement _write_to_zarr_group")
196
+ raise NotImplementedError("Subclasses must implement write_to_zarr_group")
figpack/core/zarr.py CHANGED
@@ -33,13 +33,13 @@ class Group:
33
33
  if _check_zarr_version() == 2:
34
34
  self._zarr_group.create_dataset(name, **kwargs)
35
35
  elif _check_zarr_version() == 3:
36
- self._zarr_group.create_array(name, **kwargs)
36
+ self._zarr_group.create_array(name, **kwargs) # type: ignore
37
37
  else:
38
38
  raise RuntimeError("Unsupported Zarr version")
39
39
 
40
40
  @property
41
41
  def attrs(self) -> Dict[str, Any]:
42
- return self._zarr_group.attrs
42
+ return self._zarr_group.attrs # type: ignore
43
43
 
44
44
  def __getitem__(self, key: str) -> Any:
45
45
  return self._zarr_group[key]