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
@@ -28,13 +28,120 @@ class CORSRequestHandler(SimpleHTTPRequestHandler):
28
28
  "Access-Control-Expose-Headers",
29
29
  "Accept-Ranges, Content-Encoding, Content-Length, Content-Range",
30
30
  )
31
+
32
+ # Always send Accept-Ranges header to indicate byte-range support
33
+ self.send_header("Accept-Ranges", "bytes")
34
+
35
+ # Prevent browser caching - important for when we are editing figures in place
36
+ # This ensures the browser always fetches the latest version of files
37
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
38
+ self.send_header("Pragma", "no-cache")
39
+ self.send_header("Expires", "0")
40
+
31
41
  super().end_headers()
32
42
 
33
43
  def do_OPTIONS(self):
34
44
  self.send_response(204, "No Content")
35
45
  self.end_headers()
36
46
 
37
- def log_message(self, fmt, *args):
47
+ def do_PUT(self):
48
+ """Reject PUT requests when file upload is not enabled."""
49
+ self.send_error(405, "Method Not Allowed")
50
+
51
+ def do_GET(self):
52
+ """Handle GET requests with support for Range requests."""
53
+ # Translate path and check if file exists
54
+ path = self.translate_path(self.path)
55
+
56
+ # Check if path is a file
57
+ if not os.path.isfile(path):
58
+ # Let parent class handle directories and 404s
59
+ return super().do_GET()
60
+
61
+ # Check for Range header
62
+ range_header = self.headers.get("Range")
63
+
64
+ if range_header is None:
65
+ # No range request, use parent's implementation
66
+ return super().do_GET()
67
+
68
+ # Parse range header
69
+ try:
70
+ # Range header format: "bytes=start-end"
71
+ if not range_header.startswith("bytes="):
72
+ # Invalid range format, ignore and serve full file
73
+ return super().do_GET()
74
+
75
+ range_spec = range_header[6:] # Remove "bytes=" prefix
76
+
77
+ # Get file size
78
+ file_size = os.path.getsize(path)
79
+
80
+ # Parse range specification
81
+ if "-" not in range_spec:
82
+ # Invalid format
83
+ self.send_error(400, "Invalid Range header")
84
+ return
85
+
86
+ range_parts = range_spec.split("-", 1)
87
+
88
+ # Determine start and end positions
89
+ if range_parts[0]: # Start position specified
90
+ start = int(range_parts[0])
91
+ if range_parts[1]: # End position also specified
92
+ end = int(range_parts[1])
93
+ else: # Open-ended range (e.g., "1024-")
94
+ end = file_size - 1
95
+ else: # Suffix range (e.g., "-500" means last 500 bytes)
96
+ if not range_parts[1]:
97
+ self.send_error(400, "Invalid Range header")
98
+ return
99
+ suffix_length = int(range_parts[1])
100
+ start = max(0, file_size - suffix_length)
101
+ end = file_size - 1
102
+
103
+ # Validate range
104
+ if start < 0 or end >= file_size or start > end:
105
+ self.send_response(416, "Range Not Satisfiable")
106
+ self.send_header("Content-Range", f"bytes */{file_size}")
107
+ self.end_headers()
108
+ return
109
+
110
+ # Calculate content length
111
+ content_length = end - start + 1
112
+
113
+ # Guess content type
114
+ import mimetypes
115
+
116
+ content_type = mimetypes.guess_type(path)[0] or "application/octet-stream"
117
+
118
+ # Send 206 Partial Content response
119
+ self.send_response(206, "Partial Content")
120
+ self.send_header("Content-Type", content_type)
121
+ self.send_header("Content-Length", str(content_length))
122
+ self.send_header("Content-Range", f"bytes {start}-{end}/{file_size}")
123
+ self.end_headers()
124
+
125
+ # Send the requested byte range
126
+ with open(path, "rb") as f:
127
+ f.seek(start)
128
+ remaining = content_length
129
+ while remaining > 0:
130
+ chunk_size = min(8192, remaining)
131
+ chunk = f.read(chunk_size)
132
+ if not chunk:
133
+ break
134
+ self.wfile.write(chunk)
135
+ remaining -= len(chunk)
136
+
137
+ except ValueError:
138
+ # Invalid range values
139
+ self.send_error(400, "Invalid Range header")
140
+ except Exception as e:
141
+ # Log error and return 500
142
+ self.send_error(500, f"Internal Server Error: {str(e)}")
143
+
144
+ def log_message(self, format, *args):
38
145
  pass
39
146
 
40
147
 
@@ -153,11 +260,21 @@ class ProcessServerManager:
153
260
  return figure_dir
154
261
 
155
262
  def start_server(
156
- self, port: Optional[int] = None, allow_origin: Optional[str] = None
263
+ self,
264
+ port: Optional[int] = None,
265
+ allow_origin: Optional[str] = None,
266
+ enable_file_upload: bool = False,
267
+ max_file_size: int = 10 * 1024 * 1024,
157
268
  ) -> tuple[str, int]:
158
269
  """
159
270
  Start the server if not already running, or return existing server info.
160
271
 
272
+ Args:
273
+ port: Port to bind to (auto-selected if None)
274
+ allow_origin: CORS origin to allow (None for no CORS)
275
+ enable_file_upload: Whether to enable PUT requests for file uploads
276
+ max_file_size: Maximum file size in bytes for uploads (default 10MB)
277
+
161
278
  Returns:
162
279
  tuple: (base_url, port)
163
280
  """
@@ -168,6 +285,7 @@ class ProcessServerManager:
168
285
  and self._server_thread.is_alive()
169
286
  and (allow_origin is None or self._allow_origin == allow_origin)
170
287
  ):
288
+ assert self._port is not None
171
289
  return f"http://localhost:{self._port}", self._port
172
290
 
173
291
  # Stop existing server if settings are incompatible
@@ -184,13 +302,34 @@ class ProcessServerManager:
184
302
 
185
303
  temp_dir = self.get_temp_dir()
186
304
 
187
- # Configure handler with directory and allow_origin
188
- def handler_factory(*args, **kwargs):
189
- return CORSRequestHandler(
190
- *args, directory=str(temp_dir), allow_origin=allow_origin, **kwargs
305
+ # Choose handler based on file upload requirement
306
+ if enable_file_upload:
307
+ from ._file_handler import FileUploadCORSRequestHandler
308
+
309
+ def handler_factory_enable_upload(*args, **kwargs):
310
+ return FileUploadCORSRequestHandler(
311
+ *args,
312
+ directory=str(temp_dir),
313
+ allow_origin=allow_origin,
314
+ enable_file_upload=True,
315
+ max_file_size=max_file_size,
316
+ **kwargs,
317
+ )
318
+
319
+ assert port is not None
320
+ self._server = ThreadingHTTPServer(
321
+ ("0.0.0.0", port), handler_factory_enable_upload
191
322
  )
192
323
 
193
- self._server = ThreadingHTTPServer(("0.0.0.0", port), handler_factory)
324
+ else:
325
+
326
+ def handler_factory(*args, **kwargs):
327
+ return CORSRequestHandler(
328
+ *args, directory=str(temp_dir), allow_origin=allow_origin, **kwargs
329
+ )
330
+
331
+ assert port is not None
332
+ self._server = ThreadingHTTPServer(("0.0.0.0", port), handler_factory)
194
333
  self._port = port
195
334
  self._allow_origin = allow_origin
196
335
 
@@ -19,7 +19,7 @@ def _is_in_notebook() -> bool:
19
19
  """
20
20
  try:
21
21
  # Check if IPython is available and we're in a notebook
22
- from IPython import get_ipython
22
+ from IPython import get_ipython # type: ignore
23
23
 
24
24
  ipython = get_ipython()
25
25
  if ipython is None:
@@ -148,7 +148,7 @@ def _show_view(
148
148
 
149
149
  # Start or get existing server
150
150
  base_url, server_port = server_manager.start_server(
151
- port=port, allow_origin=allow_origin
151
+ port=port, allow_origin=allow_origin, enable_file_upload=True
152
152
  )
153
153
 
154
154
  # Construct URL to the specific figure subdirectory
@@ -1,11 +1,10 @@
1
+ from typing import Optional, Union
1
2
  import hashlib
2
3
  import json
3
4
  import pathlib
4
5
  import threading
5
6
  import time
6
- import uuid
7
7
  from concurrent.futures import ThreadPoolExecutor, as_completed
8
- from datetime import datetime, timedelta, timezone
9
8
 
10
9
  import requests
11
10
 
@@ -116,62 +115,31 @@ def _upload_single_file_with_signed_url(
116
115
  else:
117
116
  break
118
117
 
118
+ assert last_exception is not None
119
119
  raise last_exception
120
120
 
121
121
 
122
122
  MAX_WORKERS_FOR_UPLOAD = 16
123
123
 
124
124
 
125
- def _compute_deterministic_figure_hash(tmpdir_path: pathlib.Path) -> str:
126
- """
127
- Compute a deterministic figure ID based on SHA1 hashes of all files
128
-
129
- Returns:
130
- str: 40-character SHA1 hash representing the content of all files
131
- """
132
- file_hashes = []
133
-
134
- # Collect all files and their hashes
135
- for file_path in sorted(tmpdir_path.rglob("*")):
136
- if file_path.is_file():
137
- relative_path = file_path.relative_to(tmpdir_path)
138
-
139
- # Compute SHA1 hash of file content
140
- sha1_hash = hashlib.sha1()
141
- with open(file_path, "rb") as f:
142
- for chunk in iter(lambda: f.read(4096), b""):
143
- sha1_hash.update(chunk)
144
-
145
- # Include both the relative path and content hash to ensure uniqueness
146
- file_info = f"{relative_path}:{sha1_hash.hexdigest()}"
147
- file_hashes.append(file_info)
148
-
149
- # Create final hash from all file hashes
150
- combined_hash = hashlib.sha1()
151
- for file_hash in file_hashes:
152
- combined_hash.update(file_hash.encode("utf-8"))
153
-
154
- return combined_hash.hexdigest()
155
-
156
-
157
125
  def _create_or_get_figure(
158
- figure_hash: str,
159
- api_key: str,
160
- total_files: int = None,
161
- total_size: int = None,
162
- title: str = None,
126
+ api_key: Optional[str],
127
+ total_files: Optional[int] = None,
128
+ total_size: Optional[int] = None,
129
+ title: Optional[str] = None,
163
130
  ephemeral: bool = False,
131
+ source_url: Optional[str] = None,
164
132
  ) -> dict:
165
133
  """
166
134
  Create a new figure or get existing figure information
167
135
 
168
136
  Args:
169
- figure_hash: The hash of the figure
170
137
  api_key: The API key for authentication (required for non-ephemeral)
171
138
  total_files: Optional total number of files
172
139
  total_size: Optional total size of files
173
140
  title: Optional title for the figure
174
141
  ephemeral: Whether to create an ephemeral figure
142
+ source_url: Optional source URL for the figure (must be unique)
175
143
 
176
144
  Returns:
177
145
  dict: Figure information from the API
@@ -180,8 +148,7 @@ def _create_or_get_figure(
180
148
  if not ephemeral and api_key is None:
181
149
  raise ValueError("API key is required for non-ephemeral figures")
182
150
 
183
- payload = {
184
- "figureHash": figure_hash,
151
+ payload: dict[str, Union[str, int]] = {
185
152
  "figpackVersion": __version__,
186
153
  "bucket": FIGPACK_BUCKET,
187
154
  }
@@ -198,6 +165,8 @@ def _create_or_get_figure(
198
165
  payload["title"] = title
199
166
  if ephemeral:
200
167
  payload["ephemeral"] = True
168
+ if source_url is not None:
169
+ payload["sourceUrl"] = source_url
201
170
 
202
171
  # Use the same endpoint for both regular and ephemeral figures
203
172
  response = requests.post(f"{FIGPACK_API_BASE_URL}/api/figures/create", json=payload)
@@ -208,12 +177,12 @@ def _create_or_get_figure(
208
177
  error_msg = error_data.get("message", "Unknown error")
209
178
  except:
210
179
  error_msg = f"HTTP {response.status_code}"
211
- raise Exception(f"Failed to create figure {figure_hash}: {error_msg}")
180
+ raise Exception(f"Failed to create figure: {error_msg}")
212
181
 
213
182
  response_data = response.json()
214
183
  if not response_data.get("success"):
215
184
  raise Exception(
216
- f"Failed to create figure {figure_hash}: {response_data.get('message', 'Unknown error')}"
185
+ f"Failed to create figure: {response_data.get('message', 'Unknown error')}"
217
186
  )
218
187
 
219
188
  return response_data
@@ -254,10 +223,11 @@ def _finalize_figure(figure_url: str, api_key: str) -> dict:
254
223
 
255
224
  def _upload_bundle(
256
225
  tmpdir: str,
257
- api_key: str,
258
- title: str = None,
226
+ api_key: Optional[str],
227
+ title: Optional[str] = None,
259
228
  ephemeral: bool = False,
260
229
  use_consolidated_metadata_only: bool = False,
230
+ source_url: Optional[str] = None,
261
231
  ) -> str:
262
232
  """
263
233
  Upload the prepared bundle to the cloud using the new database-driven approach
@@ -269,12 +239,10 @@ def _upload_bundle(
269
239
  ephemeral: Whether to create an ephemeral figure
270
240
  use_consolidated_metadata_only: If True, excludes individual zarr metadata files
271
241
  (.zgroup, .zarray, .zattrs) since they are included in .zmetadata
242
+ source_url: Optional source URL for the figure (must be unique)
272
243
  """
273
244
  tmpdir_path = pathlib.Path(tmpdir)
274
245
 
275
- # Compute deterministic figure ID based on file contents
276
- figure_hash = _compute_deterministic_figure_hash(tmpdir_path)
277
-
278
246
  # Collect all files to upload
279
247
  all_files = []
280
248
  for file_path in tmpdir_path.rglob("*"):
@@ -295,7 +263,12 @@ def _upload_bundle(
295
263
 
296
264
  # Find available figure ID and create/get figure in database with metadata
297
265
  result = _create_or_get_figure(
298
- figure_hash, api_key, total_files, total_size, title=title, ephemeral=ephemeral
266
+ api_key,
267
+ total_files,
268
+ total_size,
269
+ title=title,
270
+ ephemeral=ephemeral,
271
+ source_url=source_url,
299
272
  )
300
273
  figure_info = result.get("figure", {})
301
274
  figure_url = figure_info.get("figureUrl")
@@ -331,7 +304,9 @@ def _upload_bundle(
331
304
 
332
305
  # Get signed URLs for this batch
333
306
  try:
334
- signed_urls_map = _get_batch_signed_urls(figure_url, batch, api_key)
307
+ signed_urls_map = _get_batch_signed_urls(
308
+ figure_url, batch, api_key if api_key else ""
309
+ )
335
310
  except Exception as e:
336
311
  print(f"Failed to get signed URLs for batch {batch_num}: {e}")
337
312
  raise
@@ -402,7 +377,9 @@ def _upload_bundle(
402
377
  try:
403
378
  # Use batch API for manifest
404
379
  manifest_batch = [("manifest.json", temp_file_path)]
405
- signed_urls_map = _get_batch_signed_urls(figure_url, manifest_batch, api_key)
380
+ signed_urls_map = _get_batch_signed_urls(
381
+ figure_url, manifest_batch, api_key if api_key else ""
382
+ )
406
383
 
407
384
  if "manifest.json" not in signed_urls_map:
408
385
  raise Exception("No signed URL returned for manifest.json")
@@ -420,12 +397,45 @@ def _upload_bundle(
420
397
 
421
398
  # Finalize the figure upload
422
399
  print("Finalizing figure...")
423
- _finalize_figure(figure_url, api_key)
400
+ _finalize_figure(figure_url, api_key if api_key else "")
424
401
  print("Upload completed successfully")
425
402
 
426
403
  return figure_url
427
404
 
428
405
 
406
+ def get_figure_by_source_url(source_url: str) -> Optional[str]:
407
+ """
408
+ Query the API for a figure URL by its source URL
409
+
410
+ Args:
411
+ source_url: The source URL to search for
412
+
413
+ Returns:
414
+ Optional[str]: The figure URL if found, None otherwise
415
+ """
416
+ payload = {"sourceUrl": source_url}
417
+
418
+ response = requests.post(
419
+ f"{FIGPACK_API_BASE_URL}/api/figures/find-by-source-url", json=payload
420
+ )
421
+
422
+ if not response.ok:
423
+ if response.status_code == 404:
424
+ return None
425
+ try:
426
+ error_data = response.json()
427
+ error_msg = error_data.get("message", "Unknown error")
428
+ except:
429
+ error_msg = f"HTTP {response.status_code}"
430
+ raise Exception(f"Failed to query figure by source URL: {error_msg}")
431
+
432
+ response_data = response.json()
433
+ if not response_data.get("success"):
434
+ return None
435
+
436
+ return response_data.get("figureUrl")
437
+
438
+
429
439
  def _determine_content_type(file_path: str) -> str:
430
440
  """
431
441
  Determine content type for upload based on file extension
@@ -11,7 +11,7 @@ import threading
11
11
  import webbrowser
12
12
  from typing import Union
13
13
 
14
- from ._server_manager import CORSRequestHandler, ThreadingHTTPServer
14
+ from ._server_manager import ProcessServerManager
15
15
 
16
16
 
17
17
  def serve_files(
@@ -20,35 +20,71 @@ def serve_files(
20
20
  port: Union[int, None],
21
21
  open_in_browser: bool = False,
22
22
  allow_origin: Union[str, None] = None,
23
+ enable_file_upload: bool = False,
24
+ max_file_size: int = 10 * 1024 * 1024,
23
25
  ):
24
26
  """
25
- Serve files from a directory using a simple HTTP server.
27
+ Serve files from a directory using the ProcessServerManager.
26
28
 
27
29
  Args:
28
30
  tmpdir: Directory to serve
29
31
  port: Port number for local server
30
32
  open_in_browser: Whether to open in browser automatically
31
33
  allow_origin: CORS allow origin header
34
+ enable_file_upload: Whether to enable PUT requests for file uploads
35
+ max_file_size: Maximum file size in bytes for uploads (default 10MB)
32
36
  """
37
+ tmpdir_2 = pathlib.Path(tmpdir)
38
+ tmpdir_2 = tmpdir_2.resolve()
39
+ if not tmpdir_2.exists() or not tmpdir_2.is_dir():
40
+ raise SystemExit(f"Directory not found: {tmpdir_2}")
41
+
42
+ # Create a temporary server manager instance for this specific directory
43
+ # Note: We can't use the singleton ProcessServerManager here because it serves
44
+ # from its own temp directory, but we need to serve from the specified tmpdir
45
+
46
+ # Import the required classes for direct server creation
47
+ from ._server_manager import CORSRequestHandler, ThreadingHTTPServer
48
+ from ._file_handler import FileUploadCORSRequestHandler
49
+
33
50
  # if port is None, find a free port
34
51
  if port is None:
35
52
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
36
53
  s.bind(("", 0))
37
54
  port = s.getsockname()[1]
38
55
 
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}")
56
+ # Choose handler based on file upload requirement
57
+ if enable_file_upload:
58
+
59
+ def handler_factory_upload_enabled(*args, **kwargs):
60
+ return FileUploadCORSRequestHandler(
61
+ *args,
62
+ directory=str(tmpdir_2),
63
+ allow_origin=allow_origin,
64
+ enable_file_upload=True,
65
+ max_file_size=max_file_size,
66
+ **kwargs,
67
+ )
43
68
 
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
69
+ upload_status = (
70
+ " (file upload enabled)" if handler_factory_upload_enabled else ""
48
71
  )
49
72
 
50
- httpd = ThreadingHTTPServer(("0.0.0.0", port), handler_factory)
51
- print(f"Serving {tmpdir} at http://localhost:{port} (CORS → {allow_origin})")
73
+ httpd = ThreadingHTTPServer(("0.0.0.0", port), handler_factory_upload_enabled) # type: ignore
74
+ else:
75
+
76
+ def handler_factory(*args, **kwargs):
77
+ return CORSRequestHandler(
78
+ *args, directory=str(tmpdir_2), allow_origin=allow_origin, **kwargs
79
+ )
80
+
81
+ upload_status = ""
82
+
83
+ httpd = ThreadingHTTPServer(("0.0.0.0", port), handler_factory) # type: ignore
84
+
85
+ print(
86
+ f"Serving {tmpdir_2} at http://localhost:{port} (CORS → {allow_origin}){upload_status}"
87
+ )
52
88
  thread = threading.Thread(target=httpd.serve_forever, daemon=True)
53
89
  thread.start()
54
90