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
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.17"
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,8 @@ 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
22
+ from .extensions import ExtensionManager
21
23
 
22
24
  MAX_WORKERS_FOR_DOWNLOAD = 16
23
25
 
@@ -214,6 +216,222 @@ def download_figure(figure_url: str, dest_path: str) -> None:
214
216
  print(f"Archive saved to: {dest_path}")
215
217
 
216
218
 
219
+ def handle_extensions_command(args):
220
+ """Handle extensions subcommands"""
221
+ extension_manager = ExtensionManager()
222
+
223
+ if args.extensions_command == "list":
224
+ extension_manager.list_extensions()
225
+ elif args.extensions_command == "install":
226
+ if not args.extensions and not args.all:
227
+ print("Error: No extensions specified. Use extension names or --all flag.")
228
+ print("Example: figpack extensions install figpack_3d")
229
+ print(" figpack extensions install --all")
230
+ sys.exit(1)
231
+
232
+ success = extension_manager.install_extensions(
233
+ extensions=args.extensions, upgrade=args.upgrade, install_all=args.all
234
+ )
235
+
236
+ if not success:
237
+ sys.exit(1)
238
+
239
+ elif args.extensions_command == "uninstall":
240
+ success = extension_manager.uninstall_extensions(args.extensions)
241
+
242
+ if not success:
243
+ sys.exit(1)
244
+ else:
245
+ print("Available extension commands:")
246
+ print(" list - List available extensions and their status")
247
+ print(" install - Install or upgrade extension packages")
248
+ print(" uninstall - Uninstall extension packages")
249
+ print()
250
+ print("Use 'figpack extensions <command> --help' for more information.")
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
+
217
435
  def main():
218
436
  """Main CLI entry point"""
219
437
  parser = argparse.ArgumentParser(
@@ -235,17 +453,85 @@ def main():
235
453
  view_parser = subparsers.add_parser(
236
454
  "view", help="Extract and serve a figure archive locally"
237
455
  )
238
- 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")
239
457
  view_parser.add_argument(
240
458
  "--port", type=int, help="Port number to serve on (default: auto-select)"
241
459
  )
242
460
 
461
+ # Extensions command
462
+ extensions_parser = subparsers.add_parser(
463
+ "extensions", help="Manage figpack extension packages"
464
+ )
465
+ extensions_subparsers = extensions_parser.add_subparsers(
466
+ dest="extensions_command", help="Extension management commands"
467
+ )
468
+
469
+ # Extensions list subcommand
470
+ extensions_list_parser = extensions_subparsers.add_parser(
471
+ "list", help="List available extensions and their status"
472
+ )
473
+
474
+ # Extensions install subcommand
475
+ extensions_install_parser = extensions_subparsers.add_parser(
476
+ "install", help="Install or upgrade extension packages"
477
+ )
478
+ extensions_install_parser.add_argument(
479
+ "extensions",
480
+ nargs="*",
481
+ help="Extension package names to install (e.g., figpack_3d figpack_spike_sorting)",
482
+ )
483
+ extensions_install_parser.add_argument(
484
+ "--all", action="store_true", help="Install all available extensions"
485
+ )
486
+ extensions_install_parser.add_argument(
487
+ "--upgrade", action="store_true", help="Upgrade packages if already installed"
488
+ )
489
+
490
+ # Extensions uninstall subcommand
491
+ extensions_uninstall_parser = extensions_subparsers.add_parser(
492
+ "uninstall", help="Uninstall extension packages"
493
+ )
494
+ extensions_uninstall_parser.add_argument(
495
+ "extensions", nargs="+", help="Extension package names to uninstall"
496
+ )
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
+
243
519
  args = parser.parse_args()
244
520
 
245
521
  if args.command == "download":
246
522
  download_figure(args.figure_url, args.dest)
247
523
  elif args.command == "view":
248
- 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)
529
+ elif args.command == "extensions":
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)
249
535
  else:
250
536
  parser.print_help()
251
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,14 +52,14 @@ 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
59
60
  zarr_group = zarr.open_group(pathlib.Path(tmpdir) / "data.zarr", mode="w")
60
61
  zarr_group = Group(zarr_group)
61
- view._write_to_zarr_group(zarr_group)
62
+ view.write_to_zarr_group(zarr_group)
62
63
 
63
64
  # Add title and description as attributes on the top-level zarr group
64
65
  zarr_group.attrs["title"] = title
@@ -72,13 +73,45 @@ 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)
78
+
79
+ # It's important that we remove all the metadata files except for the
80
+ # consolidated one so there is a single source of truth.
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")
76
85
  finally:
77
86
  if _check_zarr_version() == 3:
78
- zarr.config.set({"default_zarr_format": old_default_zarr_format})
87
+ zarr.config.set({"default_zarr_format": old_default_zarr_format}) # type: ignore
88
+
79
89
 
90
+ def _remove_metadata_files_except_consolidated(zarr_dir: pathlib.Path) -> None:
91
+ """
92
+ Remove all zarr metadata files except for the consolidated one.
80
93
 
81
- def _discover_required_extensions(view: FigpackView) -> Set[str]:
94
+ Args:
95
+ zarr_dir: Path to the zarr directory
96
+ """
97
+ if not zarr_dir.is_dir():
98
+ raise ValueError(f"Expected a directory, got: {zarr_dir}")
99
+
100
+ for root, dirs, files in os.walk(zarr_dir):
101
+ for file in files:
102
+ if (
103
+ file.endswith(".zarray")
104
+ or file.endswith(".zgroup")
105
+ or file.endswith(".zattrs")
106
+ ):
107
+ file_path = pathlib.Path(root) / file
108
+ try:
109
+ file_path.unlink()
110
+ except Exception as e:
111
+ print(f"Warning: could not remove file {file_path}: {e}")
112
+
113
+
114
+ def _discover_required_extensions(view: FigpackView) -> List[str]:
82
115
  """
83
116
  Recursively discover all extensions required by a view and its children
84
117
 
@@ -0,0 +1,195 @@
1
+ import os
2
+ import pathlib
3
+ import urllib.parse
4
+ from http.server import SimpleHTTPRequestHandler
5
+ from typing import Optional
6
+
7
+ from ._server_manager import CORSRequestHandler
8
+
9
+
10
+ class FileUploadCORSRequestHandler(CORSRequestHandler):
11
+ """
12
+ Extended CORS request handler that supports PUT requests for file uploads.
13
+ Only allows file operations within the served directory.
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ *args,
19
+ allow_origin=None,
20
+ enable_file_upload=False,
21
+ max_file_size=10 * 1024 * 1024,
22
+ **kwargs,
23
+ ):
24
+ self.enable_file_upload = enable_file_upload
25
+ self.max_file_size = max_file_size # Default 10MB
26
+ super().__init__(*args, allow_origin=allow_origin, **kwargs)
27
+
28
+ def end_headers(self):
29
+ if self.allow_origin is not None:
30
+ self.send_header("Access-Control-Allow-Origin", self.allow_origin)
31
+ self.send_header("Vary", "Origin")
32
+ # Add PUT to allowed methods if file upload is enabled
33
+ methods = "GET, HEAD, OPTIONS"
34
+ if self.enable_file_upload:
35
+ methods += ", PUT"
36
+ self.send_header("Access-Control-Allow-Methods", methods)
37
+ self.send_header(
38
+ "Access-Control-Allow-Headers", "Content-Type, Range, Content-Length"
39
+ )
40
+ self.send_header(
41
+ "Access-Control-Expose-Headers",
42
+ "Accept-Ranges, Content-Encoding, Content-Length, Content-Range",
43
+ )
44
+
45
+ # Always send Accept-Ranges header to indicate byte-range support
46
+ self.send_header("Accept-Ranges", "bytes")
47
+
48
+ # Prevent browser caching - important for when we are editing figures in place
49
+ # This ensures the browser always fetches the latest version of files
50
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
51
+ self.send_header("Pragma", "no-cache")
52
+ self.send_header("Expires", "0")
53
+
54
+ super(SimpleHTTPRequestHandler, self).end_headers()
55
+
56
+ def do_PUT(self):
57
+ """Handle PUT requests for file uploads."""
58
+ if not self.enable_file_upload:
59
+ self.send_error(405, "Method Not Allowed")
60
+ return
61
+
62
+ try:
63
+ # Parse and validate the path
64
+ file_path = self._get_safe_file_path()
65
+ if file_path is None:
66
+ return # Error already sent
67
+
68
+ # Check content length
69
+ content_length = self._get_content_length()
70
+ if content_length is None:
71
+ return # Error already sent
72
+
73
+ # Determine if this will be a create or update
74
+ is_new_file = not file_path.exists()
75
+
76
+ # Read and write the file
77
+ if self._write_file_content(file_path, content_length):
78
+ # Send appropriate status code
79
+ status_code = 201 if is_new_file else 200
80
+ self.send_response(status_code)
81
+ self.send_header("Content-Type", "application/json")
82
+ self.end_headers()
83
+
84
+ response_data = f'{{"status": "success", "path": "{file_path.relative_to(pathlib.Path(self.directory))}"}}'
85
+ self.wfile.write(response_data.encode("utf-8"))
86
+
87
+ except Exception as e:
88
+ self.log_error(f"Error in PUT request: {e}")
89
+ self.send_error(500, f"Internal Server Error: {str(e)}")
90
+
91
+ def _get_safe_file_path(self) -> Optional[pathlib.Path]:
92
+ """
93
+ Parse and validate the requested file path.
94
+ Returns None if the path is invalid or unsafe.
95
+ """
96
+ # Parse the URL path
97
+ parsed_path = urllib.parse.urlparse(self.path).path
98
+
99
+ # Remove leading slash and decode URL encoding
100
+ relative_path = urllib.parse.unquote(parsed_path.lstrip("/"))
101
+
102
+ # Prevent empty paths
103
+ if not relative_path:
104
+ self.send_error(400, "Bad Request: Empty file path")
105
+ return None
106
+
107
+ # Get the served directory
108
+ served_dir = pathlib.Path(self.directory).resolve()
109
+
110
+ # Construct the target file path
111
+ target_path = served_dir / relative_path
112
+
113
+ try:
114
+ # Resolve the path to handle any .. or . components
115
+ resolved_path = target_path.resolve()
116
+
117
+ # Ensure the resolved path is within the served directory
118
+ if not str(resolved_path).startswith(str(served_dir)):
119
+ self.send_error(403, "Forbidden: Path outside served directory")
120
+ return None
121
+
122
+ except (OSError, ValueError) as e:
123
+ self.send_error(400, f"Bad Request: Invalid path - {str(e)}")
124
+ return None
125
+
126
+ return resolved_path
127
+
128
+ def _get_content_length(self) -> Optional[int]:
129
+ """
130
+ Get and validate the content length from headers.
131
+ Returns None if invalid or too large.
132
+ """
133
+ content_length_header = self.headers.get("Content-Length")
134
+ if not content_length_header:
135
+ self.send_error(400, "Bad Request: Content-Length header required")
136
+ return None
137
+
138
+ try:
139
+ content_length = int(content_length_header)
140
+ except ValueError:
141
+ self.send_error(400, "Bad Request: Invalid Content-Length")
142
+ return None
143
+
144
+ if content_length < 0:
145
+ self.send_error(400, "Bad Request: Negative Content-Length")
146
+ return None
147
+
148
+ if content_length > self.max_file_size:
149
+ self.send_error(
150
+ 413,
151
+ f"Payload Too Large: Maximum file size is {self.max_file_size} bytes",
152
+ )
153
+ return None
154
+
155
+ return content_length
156
+
157
+ def _write_file_content(self, file_path: pathlib.Path, content_length: int) -> bool:
158
+ """
159
+ Write the request body content to the specified file.
160
+ Returns True on success, False on failure (error already sent).
161
+ """
162
+ try:
163
+ # Create parent directories if they don't exist
164
+ file_path.parent.mkdir(parents=True, exist_ok=True)
165
+
166
+ # Write the file content
167
+ with open(file_path, "wb") as f:
168
+ remaining = content_length
169
+ while remaining > 0:
170
+ # Read in chunks to handle large files efficiently
171
+ chunk_size = min(8192, remaining)
172
+ chunk = self.rfile.read(chunk_size)
173
+
174
+ if not chunk:
175
+ # Unexpected end of data
176
+ self.send_error(400, "Bad Request: Incomplete data")
177
+ return False
178
+
179
+ f.write(chunk)
180
+ remaining -= len(chunk)
181
+
182
+ return True
183
+
184
+ except OSError as e:
185
+ self.send_error(
186
+ 500, f"Internal Server Error: Could not write file - {str(e)}"
187
+ )
188
+ return False
189
+ except Exception as e:
190
+ self.send_error(500, f"Internal Server Error: {str(e)}")
191
+ return False
192
+
193
+ def log_message(self, format, *args):
194
+ """Override to suppress default logging (same as parent class)."""
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
+ )