figpack 0.2.27__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.
figpack/__init__.py CHANGED
@@ -2,7 +2,7 @@
2
2
  figpack - A Python package for creating shareable, interactive visualizations in the browser
3
3
  """
4
4
 
5
- __version__ = "0.2.27"
5
+ __version__ = "0.2.40"
6
6
 
7
7
  from .cli import view_figure
8
8
  from .core import FigpackView, FigpackExtension, ExtensionView
figpack/cli.py CHANGED
@@ -18,6 +18,7 @@ import requests
18
18
  from . import __version__
19
19
  from .core._server_manager import CORSRequestHandler
20
20
  from .core._view_figure import serve_files, view_figure
21
+ from .core._upload_bundle import _upload_bundle, get_figure_by_source_url
21
22
  from .extensions import ExtensionManager
22
23
 
23
24
  MAX_WORKERS_FOR_DOWNLOAD = 16
@@ -249,6 +250,188 @@ def handle_extensions_command(args):
249
250
  print("Use 'figpack extensions <command> --help' for more information.")
250
251
 
251
252
 
253
+ def handle_upload_from_source_url(args) -> None:
254
+ """
255
+ Handle the upload-from-source-url command
256
+
257
+ Downloads a tar.gz/tgz file from a URL, extracts it, and uploads it as a new figure
258
+ with the source URL set.
259
+ """
260
+ import os
261
+
262
+ source_url = args.source_url
263
+ title = args.title if hasattr(args, "title") else None
264
+
265
+ # Get API key from environment variable
266
+ api_key = os.environ.get("FIGPACK_API_KEY")
267
+ if not api_key:
268
+ print(
269
+ "Error: FIGPACK_API_KEY environment variable must be set to upload figures."
270
+ )
271
+ sys.exit(1)
272
+
273
+ # Validate URL format
274
+ if not (source_url.endswith(".tar.gz") or source_url.endswith(".tgz")):
275
+ print(f"Error: Source URL must point to a .tar.gz or .tgz file: {source_url}")
276
+ sys.exit(1)
277
+
278
+ print(f"Downloading archive from: {source_url}")
279
+
280
+ try:
281
+ # Download the archive
282
+ response = requests.get(source_url, timeout=120, stream=True)
283
+ response.raise_for_status()
284
+
285
+ # Create temporary file for the archive
286
+ with tempfile.NamedTemporaryFile(
287
+ suffix=".tar.gz", delete=False
288
+ ) as temp_archive:
289
+ archive_path = temp_archive.name
290
+
291
+ # Download with progress indication
292
+ total_size = int(response.headers.get("content-length", 0))
293
+ downloaded_size = 0
294
+
295
+ for chunk in response.iter_content(chunk_size=8192):
296
+ if chunk:
297
+ temp_archive.write(chunk)
298
+ downloaded_size += len(chunk)
299
+ if total_size > 0:
300
+ progress = (downloaded_size / total_size) * 100
301
+ print(
302
+ f"Downloaded: {downloaded_size / (1024*1024):.2f} MB ({progress:.1f}%)",
303
+ end="\r",
304
+ )
305
+
306
+ if total_size > 0:
307
+ print() # New line after progress
308
+ print(f"Download complete: {downloaded_size / (1024*1024):.2f} MB")
309
+
310
+ # Extract archive to temporary directory
311
+ print("Extracting archive...")
312
+ with tempfile.TemporaryDirectory() as extract_dir:
313
+ extract_path = pathlib.Path(extract_dir)
314
+
315
+ try:
316
+ with tarfile.open(archive_path, "r:gz") as tar:
317
+ tar.extractall(path=extract_path)
318
+ print(f"Extracted to temporary directory")
319
+ except Exception as e:
320
+ print(f"Error: Failed to extract archive: {e}")
321
+ sys.exit(1)
322
+ finally:
323
+ # Clean up downloaded archive
324
+ import os
325
+
326
+ try:
327
+ os.unlink(archive_path)
328
+ except Exception:
329
+ pass
330
+
331
+ # Upload the extracted files
332
+ print(f"Uploading figure with source URL: {source_url}")
333
+ try:
334
+ figure_url = _upload_bundle(
335
+ str(extract_path),
336
+ api_key=api_key,
337
+ title=title,
338
+ source_url=source_url,
339
+ )
340
+ print(f"\nFigure uploaded successfully!")
341
+ print(f"Figure URL: {figure_url}")
342
+ except Exception as e:
343
+ print(f"Error: Failed to upload figure: {e}")
344
+ sys.exit(1)
345
+
346
+ except requests.exceptions.RequestException as e:
347
+ print(f"Error: Failed to download archive from {source_url}: {e}")
348
+ sys.exit(1)
349
+ except Exception as e:
350
+ print(f"Error: {e}")
351
+ sys.exit(1)
352
+
353
+
354
+ def handle_find_by_source_url(args) -> None:
355
+ """
356
+ Handle the find-by-source-url command
357
+
358
+ Queries the API for a figure URL by its source URL.
359
+ """
360
+ source_url = args.source_url
361
+
362
+ print(f"Querying for figure with source URL: {source_url}")
363
+
364
+ try:
365
+ figure_url = get_figure_by_source_url(source_url)
366
+
367
+ if figure_url:
368
+ print(f"Figure found: {figure_url}")
369
+ else:
370
+ print(f"No figure found with source URL: {source_url}")
371
+ sys.exit(1)
372
+ except Exception as e:
373
+ print(f"Error: {e}")
374
+ sys.exit(1)
375
+
376
+
377
+ def download_and_view_archive(url: str, port: int = None) -> None:
378
+ """
379
+ Download a tar.gz/tgz archive from a URL and view it
380
+
381
+ Args:
382
+ url: URL to the tar.gz or tgz file
383
+ port: Optional port number to serve on
384
+ """
385
+ if not (url.endswith(".tar.gz") or url.endswith(".tgz")):
386
+ print(f"Error: URL must point to a .tar.gz or .tgz file: {url}")
387
+ sys.exit(1)
388
+
389
+ print(f"Downloading archive from: {url}")
390
+
391
+ try:
392
+ response = requests.get(url, timeout=60, stream=True)
393
+ response.raise_for_status()
394
+
395
+ # Create a temporary file to store the downloaded archive
396
+ with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as temp_file:
397
+ temp_path = temp_file.name
398
+
399
+ # Download with progress indication
400
+ total_size = int(response.headers.get("content-length", 0))
401
+ downloaded_size = 0
402
+
403
+ for chunk in response.iter_content(chunk_size=8192):
404
+ if chunk:
405
+ temp_file.write(chunk)
406
+ downloaded_size += len(chunk)
407
+ if total_size > 0:
408
+ progress = (downloaded_size / total_size) * 100
409
+ print(
410
+ f"Downloaded: {downloaded_size / (1024*1024):.2f} MB ({progress:.1f}%)",
411
+ end="\r",
412
+ )
413
+
414
+ if total_size > 0:
415
+ print() # New line after progress
416
+ print(f"Download complete: {downloaded_size / (1024*1024):.2f} MB")
417
+
418
+ # Now view the downloaded file
419
+ try:
420
+ view_figure(temp_path, port=port)
421
+ finally:
422
+ # Clean up the temporary file after viewing
423
+ import os
424
+
425
+ try:
426
+ os.unlink(temp_path)
427
+ except Exception:
428
+ pass
429
+
430
+ except requests.exceptions.RequestException as e:
431
+ print(f"Error: Failed to download archive from {url}: {e}")
432
+ sys.exit(1)
433
+
434
+
252
435
  def main():
253
436
  """Main CLI entry point"""
254
437
  parser = argparse.ArgumentParser(
@@ -270,7 +453,7 @@ def main():
270
453
  view_parser = subparsers.add_parser(
271
454
  "view", help="Extract and serve a figure archive locally"
272
455
  )
273
- view_parser.add_argument("archive", help="Path to the tar.gz archive file")
456
+ view_parser.add_argument("archive", help="Path or URL to the tar.gz archive file")
274
457
  view_parser.add_argument(
275
458
  "--port", type=int, help="Port number to serve on (default: auto-select)"
276
459
  )
@@ -312,14 +495,43 @@ def main():
312
495
  "extensions", nargs="+", help="Extension package names to uninstall"
313
496
  )
314
497
 
498
+ # Upload from URL command
499
+ upload_from_source_url_parser = subparsers.add_parser(
500
+ "upload-from-source-url",
501
+ help="Download a tar.gz/tgz file and upload it as a new figure",
502
+ )
503
+ upload_from_source_url_parser.add_argument(
504
+ "source_url",
505
+ help="URL to the tar.gz or tgz file (will be set as the figure's source URL)",
506
+ )
507
+ upload_from_source_url_parser.add_argument(
508
+ "--title", help="Optional title for the figure"
509
+ )
510
+
511
+ # Find by source URL command
512
+ find_by_source_url_parser = subparsers.add_parser(
513
+ "find-by-source-url", help="Get the figure URL for a given source URL"
514
+ )
515
+ find_by_source_url_parser.add_argument(
516
+ "source_url", help="The source URL to search for"
517
+ )
518
+
315
519
  args = parser.parse_args()
316
520
 
317
521
  if args.command == "download":
318
522
  download_figure(args.figure_url, args.dest)
319
523
  elif args.command == "view":
320
- view_figure(args.archive, port=args.port)
524
+ # Check if archive argument is a URL
525
+ if args.archive.startswith("http://") or args.archive.startswith("https://"):
526
+ download_and_view_archive(args.archive, port=args.port)
527
+ else:
528
+ view_figure(args.archive, port=args.port)
321
529
  elif args.command == "extensions":
322
530
  handle_extensions_command(args)
531
+ elif args.command == "upload-from-source-url":
532
+ handle_upload_from_source_url(args)
533
+ elif args.command == "find-by-source-url":
534
+ handle_find_by_source_url(args)
323
535
  else:
324
536
  parser.print_help()
325
537
 
@@ -1,7 +1,7 @@
1
1
  import os
2
2
  import pathlib
3
3
  import json
4
- from typing import Set
4
+ from typing import Optional, List
5
5
 
6
6
  import zarr
7
7
 
@@ -9,12 +9,13 @@ from .figpack_view import FigpackView
9
9
  from .figpack_extension import FigpackExtension
10
10
  from .extension_view import ExtensionView
11
11
  from .zarr import Group, _check_zarr_version
12
+ from ._zarr_consolidate import consolidate_zarr_chunks
12
13
 
13
14
  thisdir = pathlib.Path(__file__).parent.resolve()
14
15
 
15
16
 
16
17
  def prepare_figure_bundle(
17
- view: FigpackView, tmpdir: str, *, title: str, description: str = None
18
+ view: FigpackView, tmpdir: str, *, title: str, description: Optional[str] = None
18
19
  ) -> None:
19
20
  """
20
21
  Prepare a figure bundle in the specified temporary directory.
@@ -51,8 +52,8 @@ def prepare_figure_bundle(
51
52
  # because we only support version 2 on the frontend right now.
52
53
 
53
54
  if _check_zarr_version() == 3:
54
- old_default_zarr_format = zarr.config.get("default_zarr_format")
55
- zarr.config.set({"default_zarr_format": 2})
55
+ old_default_zarr_format = zarr.config.get("default_zarr_format") # type: ignore
56
+ zarr.config.set({"default_zarr_format": 2}) # type: ignore
56
57
 
57
58
  try:
58
59
  # Write the view data to the Zarr group
@@ -72,15 +73,18 @@ def prepare_figure_bundle(
72
73
  # Generate extension manifest
73
74
  _write_extension_manifest(required_extensions, tmpdir)
74
75
 
76
+ # Create the .zmetadata file
75
77
  zarr.consolidate_metadata(zarr_group._zarr_group.store)
76
78
 
77
79
  # It's important that we remove all the metadata files except for the
78
- # consolidated one, because otherwise we may get inconstencies
79
- # once we start editing the zarr data from the browser.
80
+ # consolidated one so there is a single source of truth.
80
81
  _remove_metadata_files_except_consolidated(pathlib.Path(tmpdir) / "data.zarr")
82
+
83
+ # Consolidate zarr chunks into larger files to reduce upload count
84
+ consolidate_zarr_chunks(pathlib.Path(tmpdir) / "data.zarr")
81
85
  finally:
82
86
  if _check_zarr_version() == 3:
83
- zarr.config.set({"default_zarr_format": old_default_zarr_format})
87
+ zarr.config.set({"default_zarr_format": old_default_zarr_format}) # type: ignore
84
88
 
85
89
 
86
90
  def _remove_metadata_files_except_consolidated(zarr_dir: pathlib.Path) -> None:
@@ -107,7 +111,7 @@ def _remove_metadata_files_except_consolidated(zarr_dir: pathlib.Path) -> None:
107
111
  print(f"Warning: could not remove file {file_path}: {e}")
108
112
 
109
113
 
110
- def _discover_required_extensions(view: FigpackView) -> Set[str]:
114
+ def _discover_required_extensions(view: FigpackView) -> List[str]:
111
115
  """
112
116
  Recursively discover all extensions required by a view and its children
113
117
 
@@ -42,6 +42,9 @@ class FileUploadCORSRequestHandler(CORSRequestHandler):
42
42
  "Accept-Ranges, Content-Encoding, Content-Length, Content-Range",
43
43
  )
44
44
 
45
+ # Always send Accept-Ranges header to indicate byte-range support
46
+ self.send_header("Accept-Ranges", "bytes")
47
+
45
48
  # Prevent browser caching - important for when we are editing figures in place
46
49
  # This ensures the browser always fetches the latest version of files
47
50
  self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
@@ -187,6 +190,6 @@ class FileUploadCORSRequestHandler(CORSRequestHandler):
187
190
  self.send_error(500, f"Internal Server Error: {str(e)}")
188
191
  return False
189
192
 
190
- def log_message(self, fmt, *args):
193
+ def log_message(self, format, *args):
191
194
  """Override to suppress default logging (same as parent class)."""
192
195
  pass
@@ -5,7 +5,9 @@ from ._bundle_utils import prepare_figure_bundle
5
5
  from .figpack_view import FigpackView
6
6
 
7
7
 
8
- def _save_figure(view: FigpackView, output_path: str, *, title: str):
8
+ def _save_figure(
9
+ view: FigpackView, output_path: str, *, title: str, description: str = ""
10
+ ) -> None:
9
11
  """
10
12
  Save the figure to a folder or a .tar.gz file
11
13
 
@@ -13,19 +15,21 @@ def _save_figure(view: FigpackView, output_path: str, *, title: str):
13
15
  view: FigpackView instance to save
14
16
  output_path: Output path (destination folder or .tar.gz file path)
15
17
  """
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"
18
+ output_path_2 = pathlib.Path(output_path)
19
+ if (output_path_2.suffix == ".gz" and output_path_2.suffixes[-2] == ".tar") or (
20
+ output_path_2.suffix == ".tgz"
19
21
  ):
20
22
  # It's a .tar.gz file
21
23
  with tempfile.TemporaryDirectory(prefix="figpack_save_") as tmpdir:
22
- prepare_figure_bundle(view, tmpdir, title=title)
24
+ prepare_figure_bundle(view, tmpdir, title=title, description=description)
23
25
  # Create tar.gz file
24
26
  import tarfile
25
27
 
26
- with tarfile.open(output_path, "w:gz") as tar:
28
+ with tarfile.open(output_path_2, "w:gz") as tar:
27
29
  tar.add(tmpdir, arcname=".")
28
30
  else:
29
31
  # It's a folder
30
- output_path.mkdir(parents=True, exist_ok=True)
31
- prepare_figure_bundle(view, str(output_path), title=title)
32
+ output_path_2.mkdir(parents=True, exist_ok=True)
33
+ prepare_figure_bundle(
34
+ view, str(output_path_2), title=title, description=description
35
+ )
@@ -29,6 +29,9 @@ class CORSRequestHandler(SimpleHTTPRequestHandler):
29
29
  "Accept-Ranges, Content-Encoding, Content-Length, Content-Range",
30
30
  )
31
31
 
32
+ # Always send Accept-Ranges header to indicate byte-range support
33
+ self.send_header("Accept-Ranges", "bytes")
34
+
32
35
  # Prevent browser caching - important for when we are editing figures in place
33
36
  # This ensures the browser always fetches the latest version of files
34
37
  self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
@@ -45,7 +48,100 @@ class CORSRequestHandler(SimpleHTTPRequestHandler):
45
48
  """Reject PUT requests when file upload is not enabled."""
46
49
  self.send_error(405, "Method Not Allowed")
47
50
 
48
- def log_message(self, fmt, *args):
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):
49
145
  pass
50
146
 
51
147
 
@@ -189,6 +285,7 @@ class ProcessServerManager:
189
285
  and self._server_thread.is_alive()
190
286
  and (allow_origin is None or self._allow_origin == allow_origin)
191
287
  ):
288
+ assert self._port is not None
192
289
  return f"http://localhost:{self._port}", self._port
193
290
 
194
291
  # Stop existing server if settings are incompatible
@@ -209,7 +306,7 @@ class ProcessServerManager:
209
306
  if enable_file_upload:
210
307
  from ._file_handler import FileUploadCORSRequestHandler
211
308
 
212
- def handler_factory(*args, **kwargs):
309
+ def handler_factory_enable_upload(*args, **kwargs):
213
310
  return FileUploadCORSRequestHandler(
214
311
  *args,
215
312
  directory=str(temp_dir),
@@ -219,6 +316,11 @@ class ProcessServerManager:
219
316
  **kwargs,
220
317
  )
221
318
 
319
+ assert port is not None
320
+ self._server = ThreadingHTTPServer(
321
+ ("0.0.0.0", port), handler_factory_enable_upload
322
+ )
323
+
222
324
  else:
223
325
 
224
326
  def handler_factory(*args, **kwargs):
@@ -226,7 +328,8 @@ class ProcessServerManager:
226
328
  *args, directory=str(temp_dir), allow_origin=allow_origin, **kwargs
227
329
  )
228
330
 
229
- self._server = ThreadingHTTPServer(("0.0.0.0", port), handler_factory)
331
+ assert port is not None
332
+ self._server = ThreadingHTTPServer(("0.0.0.0", port), handler_factory)
230
333
  self._port = port
231
334
  self._allow_origin = allow_origin
232
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: