figpack 0.2.35__tar.gz → 0.2.37__tar.gz

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 (75) hide show
  1. {figpack-0.2.35/figpack.egg-info → figpack-0.2.37}/PKG-INFO +22 -1
  2. {figpack-0.2.35 → figpack-0.2.37}/README.md +21 -0
  3. {figpack-0.2.35 → figpack-0.2.37}/figpack/__init__.py +1 -1
  4. {figpack-0.2.35 → figpack-0.2.37}/figpack/cli.py +214 -2
  5. {figpack-0.2.35 → figpack-0.2.37}/figpack/core/_bundle_utils.py +6 -6
  6. {figpack-0.2.35 → figpack-0.2.37}/figpack/core/_file_handler.py +1 -1
  7. {figpack-0.2.35 → figpack-0.2.37}/figpack/core/_save_figure.py +6 -6
  8. {figpack-0.2.35 → figpack-0.2.37}/figpack/core/_server_manager.py +10 -3
  9. {figpack-0.2.35 → figpack-0.2.37}/figpack/core/_show_view.py +1 -1
  10. {figpack-0.2.35 → figpack-0.2.37}/figpack/core/_upload_bundle.py +63 -51
  11. {figpack-0.2.35 → figpack-0.2.37}/figpack/core/_view_figure.py +15 -10
  12. {figpack-0.2.35 → figpack-0.2.37}/figpack/core/extension_view.py +8 -4
  13. {figpack-0.2.35 → figpack-0.2.37}/figpack/core/figpack_extension.py +1 -1
  14. {figpack-0.2.35 → figpack-0.2.37}/figpack/core/figpack_view.py +22 -12
  15. {figpack-0.2.35 → figpack-0.2.37}/figpack/core/zarr.py +2 -2
  16. figpack-0.2.35/figpack/figpack-figure-dist/assets/index-DXkrNeMu.js → figpack-0.2.37/figpack/figpack-figure-dist/assets/index-Bt8OPETP.js +3 -3
  17. {figpack-0.2.35 → figpack-0.2.37}/figpack/figpack-figure-dist/index.html +1 -1
  18. {figpack-0.2.35 → figpack-0.2.37}/figpack/views/Box.py +2 -2
  19. {figpack-0.2.35 → figpack-0.2.37}/figpack/views/Image.py +1 -2
  20. {figpack-0.2.35 → figpack-0.2.37}/figpack/views/PlotlyExtension/PlotlyExtension.py +12 -12
  21. {figpack-0.2.35 → figpack-0.2.37}/figpack/views/Spectrogram.py +2 -0
  22. {figpack-0.2.35 → figpack-0.2.37}/figpack/views/TimeseriesGraph.py +17 -13
  23. {figpack-0.2.35 → figpack-0.2.37/figpack.egg-info}/PKG-INFO +22 -1
  24. {figpack-0.2.35 → figpack-0.2.37}/figpack.egg-info/SOURCES.txt +1 -1
  25. {figpack-0.2.35 → figpack-0.2.37}/pyproject.toml +1 -1
  26. {figpack-0.2.35 → figpack-0.2.37}/tests/test_upload_bundle.py +1 -25
  27. {figpack-0.2.35 → figpack-0.2.37}/LICENSE +0 -0
  28. {figpack-0.2.35 → figpack-0.2.37}/MANIFEST.in +0 -0
  29. {figpack-0.2.35 → figpack-0.2.37}/figpack/core/__init__.py +0 -0
  30. {figpack-0.2.35 → figpack-0.2.37}/figpack/core/config.py +0 -0
  31. {figpack-0.2.35 → figpack-0.2.37}/figpack/extensions.py +0 -0
  32. {figpack-0.2.35 → figpack-0.2.37}/figpack/figpack-figure-dist/assets/index-V5m_wCvw.css +0 -0
  33. {figpack-0.2.35 → figpack-0.2.37}/figpack/figpack-figure-dist/assets/neurosift-logo-CLsuwLMO.png +0 -0
  34. {figpack-0.2.35 → figpack-0.2.37}/figpack/views/CaptionedView.py +0 -0
  35. {figpack-0.2.35 → figpack-0.2.37}/figpack/views/DataFrame.py +0 -0
  36. {figpack-0.2.35 → figpack-0.2.37}/figpack/views/Gallery.py +0 -0
  37. {figpack-0.2.35 → figpack-0.2.37}/figpack/views/GalleryItem.py +0 -0
  38. {figpack-0.2.35 → figpack-0.2.37}/figpack/views/Iframe.py +0 -0
  39. {figpack-0.2.35 → figpack-0.2.37}/figpack/views/LayoutItem.py +0 -0
  40. {figpack-0.2.35 → figpack-0.2.37}/figpack/views/Markdown.py +0 -0
  41. {figpack-0.2.35 → figpack-0.2.37}/figpack/views/MatplotlibFigure.py +0 -0
  42. {figpack-0.2.35 → figpack-0.2.37}/figpack/views/MountainLayout.py +0 -0
  43. {figpack-0.2.35 → figpack-0.2.37}/figpack/views/MountainLayoutItem.py +0 -0
  44. {figpack-0.2.35 → figpack-0.2.37}/figpack/views/MultiChannelTimeseries.py +0 -0
  45. {figpack-0.2.35 → figpack-0.2.37}/figpack/views/PlotlyExtension/__init__.py +0 -0
  46. {figpack-0.2.35 → figpack-0.2.37}/figpack/views/PlotlyExtension/_plotly_extension.py +0 -0
  47. {figpack-0.2.35 → figpack-0.2.37}/figpack/views/PlotlyExtension/plotly_view.js +0 -0
  48. {figpack-0.2.35 → figpack-0.2.37}/figpack/views/Splitter.py +0 -0
  49. {figpack-0.2.35 → figpack-0.2.37}/figpack/views/TabLayout.py +0 -0
  50. {figpack-0.2.35 → figpack-0.2.37}/figpack/views/TabLayoutItem.py +0 -0
  51. {figpack-0.2.35 → figpack-0.2.37}/figpack/views/__init__.py +0 -0
  52. {figpack-0.2.35 → figpack-0.2.37}/figpack.egg-info/dependency_links.txt +0 -0
  53. {figpack-0.2.35 → figpack-0.2.37}/figpack.egg-info/entry_points.txt +0 -0
  54. {figpack-0.2.35 → figpack-0.2.37}/figpack.egg-info/requires.txt +0 -0
  55. {figpack-0.2.35 → figpack-0.2.37}/figpack.egg-info/top_level.txt +0 -0
  56. {figpack-0.2.35 → figpack-0.2.37}/setup.cfg +0 -0
  57. {figpack-0.2.35 → figpack-0.2.37}/tests/test_box.py +0 -0
  58. {figpack-0.2.35 → figpack-0.2.37}/tests/test_cli.py +0 -0
  59. {figpack-0.2.35 → figpack-0.2.37}/tests/test_core.py +0 -0
  60. {figpack-0.2.35 → figpack-0.2.37}/tests/test_dataframe.py +0 -0
  61. {figpack-0.2.35 → figpack-0.2.37}/tests/test_extension_system.py +0 -0
  62. {figpack-0.2.35 → figpack-0.2.37}/tests/test_figpack_view.py +0 -0
  63. {figpack-0.2.35 → figpack-0.2.37}/tests/test_file_handler.py +0 -0
  64. {figpack-0.2.35 → figpack-0.2.37}/tests/test_gallery.py +0 -0
  65. {figpack-0.2.35 → figpack-0.2.37}/tests/test_image.py +0 -0
  66. {figpack-0.2.35 → figpack-0.2.37}/tests/test_markdown.py +0 -0
  67. {figpack-0.2.35 → figpack-0.2.37}/tests/test_matplotlib_figure.py +0 -0
  68. {figpack-0.2.35 → figpack-0.2.37}/tests/test_multichannel_timeseries.py +0 -0
  69. {figpack-0.2.35 → figpack-0.2.37}/tests/test_plotly_figure.py +0 -0
  70. {figpack-0.2.35 → figpack-0.2.37}/tests/test_server_manager.py +0 -0
  71. {figpack-0.2.35 → figpack-0.2.37}/tests/test_spectrogram.py +0 -0
  72. {figpack-0.2.35 → figpack-0.2.37}/tests/test_splitter.py +0 -0
  73. {figpack-0.2.35 → figpack-0.2.37}/tests/test_tablayout.py +0 -0
  74. {figpack-0.2.35 → figpack-0.2.37}/tests/test_timeseries_graph.py +0 -0
  75. {figpack-0.2.35 → figpack-0.2.37}/tests/test_view_figure.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: figpack
3
- Version: 0.2.35
3
+ Version: 0.2.37
4
4
  Summary: A Python package for creating shareable, interactive visualizations in the browser
5
5
  Author-email: Jeremy Magland <jmagland@flatironinstitute.org>
6
6
  License: Apache-2.0
@@ -114,6 +114,27 @@ graph.show(open_in_browser=True, title="Quick Start Example")
114
114
 
115
115
  Apache-2.0
116
116
 
117
+ ## Citation
118
+
119
+ If you use figpack in your research, please cite it:
120
+
121
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.17419621.svg)](https://doi.org/10.5281/zenodo.17419621)
122
+
123
+ ```bibtex
124
+ @software{magland_figpack_2025,
125
+ author = {Magland, Jeremy},
126
+ title = {figpack},
127
+ year = 2025,
128
+ publisher = {Zenodo},
129
+ doi = {10.5281/zenodo.17419621},
130
+ url = {https://doi.org/10.5281/zenodo.17419621}
131
+ }
132
+ ```
133
+
134
+ Or in APA format:
135
+
136
+ > Magland, J. (2025). figpack (Version 0.2.37) [Computer software]. Zenodo. https://doi.org/10.5281/zenodo.17419621
137
+
117
138
  ## Contributing
118
139
 
119
140
  Visit the [GitHub repository](https://github.com/flatironinstitute/figpack) for issues, contributions, and the latest updates.
@@ -38,6 +38,27 @@ graph.show(open_in_browser=True, title="Quick Start Example")
38
38
 
39
39
  Apache-2.0
40
40
 
41
+ ## Citation
42
+
43
+ If you use figpack in your research, please cite it:
44
+
45
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.17419621.svg)](https://doi.org/10.5281/zenodo.17419621)
46
+
47
+ ```bibtex
48
+ @software{magland_figpack_2025,
49
+ author = {Magland, Jeremy},
50
+ title = {figpack},
51
+ year = 2025,
52
+ publisher = {Zenodo},
53
+ doi = {10.5281/zenodo.17419621},
54
+ url = {https://doi.org/10.5281/zenodo.17419621}
55
+ }
56
+ ```
57
+
58
+ Or in APA format:
59
+
60
+ > Magland, J. (2025). figpack (Version 0.2.37) [Computer software]. Zenodo. https://doi.org/10.5281/zenodo.17419621
61
+
41
62
  ## Contributing
42
63
 
43
64
  Visit the [GitHub repository](https://github.com/flatironinstitute/figpack) for issues, contributions, and the latest updates.
@@ -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.35"
5
+ __version__ = "0.2.37"
6
6
 
7
7
  from .cli import view_figure
8
8
  from .core import FigpackView, FigpackExtension, ExtensionView
@@ -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
 
@@ -14,7 +14,7 @@ thisdir = pathlib.Path(__file__).parent.resolve()
14
14
 
15
15
 
16
16
  def prepare_figure_bundle(
17
- view: FigpackView, tmpdir: str, *, title: str, description: str = None
17
+ view: FigpackView, tmpdir: str, *, title: str, description: Optional[str] = None
18
18
  ) -> None:
19
19
  """
20
20
  Prepare a figure bundle in the specified temporary directory.
@@ -51,8 +51,8 @@ def prepare_figure_bundle(
51
51
  # because we only support version 2 on the frontend right now.
52
52
 
53
53
  if _check_zarr_version() == 3:
54
- old_default_zarr_format = zarr.config.get("default_zarr_format")
55
- zarr.config.set({"default_zarr_format": 2})
54
+ old_default_zarr_format = zarr.config.get("default_zarr_format") # type: ignore
55
+ zarr.config.set({"default_zarr_format": 2}) # type: ignore
56
56
 
57
57
  try:
58
58
  # Write the view data to the Zarr group
@@ -80,7 +80,7 @@ def prepare_figure_bundle(
80
80
  _remove_metadata_files_except_consolidated(pathlib.Path(tmpdir) / "data.zarr")
81
81
  finally:
82
82
  if _check_zarr_version() == 3:
83
- zarr.config.set({"default_zarr_format": old_default_zarr_format})
83
+ zarr.config.set({"default_zarr_format": old_default_zarr_format}) # type: ignore
84
84
 
85
85
 
86
86
  def _remove_metadata_files_except_consolidated(zarr_dir: pathlib.Path) -> None:
@@ -107,7 +107,7 @@ def _remove_metadata_files_except_consolidated(zarr_dir: pathlib.Path) -> None:
107
107
  print(f"Warning: could not remove file {file_path}: {e}")
108
108
 
109
109
 
110
- def _discover_required_extensions(view: FigpackView) -> Set[str]:
110
+ def _discover_required_extensions(view: FigpackView) -> List[str]:
111
111
  """
112
112
  Recursively discover all extensions required by a view and its children
113
113
 
@@ -187,6 +187,6 @@ class FileUploadCORSRequestHandler(CORSRequestHandler):
187
187
  self.send_error(500, f"Internal Server Error: {str(e)}")
188
188
  return False
189
189
 
190
- def log_message(self, fmt, *args):
190
+ def log_message(self, format, *args):
191
191
  """Override to suppress default logging (same as parent class)."""
192
192
  pass
@@ -15,9 +15,9 @@ def _save_figure(
15
15
  view: FigpackView instance to save
16
16
  output_path: Output path (destination folder or .tar.gz file path)
17
17
  """
18
- output_path = pathlib.Path(output_path)
19
- if (output_path.suffix == ".gz" and output_path.suffixes[-2] == ".tar") or (
20
- 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"
21
21
  ):
22
22
  # It's a .tar.gz file
23
23
  with tempfile.TemporaryDirectory(prefix="figpack_save_") as tmpdir:
@@ -25,11 +25,11 @@ def _save_figure(
25
25
  # Create tar.gz file
26
26
  import tarfile
27
27
 
28
- with tarfile.open(output_path, "w:gz") as tar:
28
+ with tarfile.open(output_path_2, "w:gz") as tar:
29
29
  tar.add(tmpdir, arcname=".")
30
30
  else:
31
31
  # It's a folder
32
- output_path.mkdir(parents=True, exist_ok=True)
32
+ output_path_2.mkdir(parents=True, exist_ok=True)
33
33
  prepare_figure_bundle(
34
- view, str(output_path), title=title, description=description
34
+ view, str(output_path_2), title=title, description=description
35
35
  )
@@ -45,7 +45,7 @@ class CORSRequestHandler(SimpleHTTPRequestHandler):
45
45
  """Reject PUT requests when file upload is not enabled."""
46
46
  self.send_error(405, "Method Not Allowed")
47
47
 
48
- def log_message(self, fmt, *args):
48
+ def log_message(self, format, *args):
49
49
  pass
50
50
 
51
51
 
@@ -189,6 +189,7 @@ class ProcessServerManager:
189
189
  and self._server_thread.is_alive()
190
190
  and (allow_origin is None or self._allow_origin == allow_origin)
191
191
  ):
192
+ assert self._port is not None
192
193
  return f"http://localhost:{self._port}", self._port
193
194
 
194
195
  # Stop existing server if settings are incompatible
@@ -209,7 +210,7 @@ class ProcessServerManager:
209
210
  if enable_file_upload:
210
211
  from ._file_handler import FileUploadCORSRequestHandler
211
212
 
212
- def handler_factory(*args, **kwargs):
213
+ def handler_factory_enable_upload(*args, **kwargs):
213
214
  return FileUploadCORSRequestHandler(
214
215
  *args,
215
216
  directory=str(temp_dir),
@@ -219,6 +220,11 @@ class ProcessServerManager:
219
220
  **kwargs,
220
221
  )
221
222
 
223
+ assert port is not None
224
+ self._server = ThreadingHTTPServer(
225
+ ("0.0.0.0", port), handler_factory_enable_upload
226
+ )
227
+
222
228
  else:
223
229
 
224
230
  def handler_factory(*args, **kwargs):
@@ -226,7 +232,8 @@ class ProcessServerManager:
226
232
  *args, directory=str(temp_dir), allow_origin=allow_origin, **kwargs
227
233
  )
228
234
 
229
- self._server = ThreadingHTTPServer(("0.0.0.0", port), handler_factory)
235
+ assert port is not None
236
+ self._server = ThreadingHTTPServer(("0.0.0.0", port), handler_factory)
230
237
  self._port = port
231
238
  self._allow_origin = allow_origin
232
239
 
@@ -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:
@@ -1,3 +1,4 @@
1
+ from typing import Optional, Union
1
2
  import hashlib
2
3
  import json
3
4
  import pathlib
@@ -114,62 +115,31 @@ def _upload_single_file_with_signed_url(
114
115
  else:
115
116
  break
116
117
 
118
+ assert last_exception is not None
117
119
  raise last_exception
118
120
 
119
121
 
120
122
  MAX_WORKERS_FOR_UPLOAD = 16
121
123
 
122
124
 
123
- def _compute_deterministic_figure_hash(tmpdir_path: pathlib.Path) -> str:
124
- """
125
- Compute a deterministic figure ID based on SHA1 hashes of all files
126
-
127
- Returns:
128
- str: 40-character SHA1 hash representing the content of all files
129
- """
130
- file_hashes = []
131
-
132
- # Collect all files and their hashes
133
- for file_path in sorted(tmpdir_path.rglob("*")):
134
- if file_path.is_file():
135
- relative_path = file_path.relative_to(tmpdir_path)
136
-
137
- # Compute SHA1 hash of file content
138
- sha1_hash = hashlib.sha1()
139
- with open(file_path, "rb") as f:
140
- for chunk in iter(lambda: f.read(4096), b""):
141
- sha1_hash.update(chunk)
142
-
143
- # Include both the relative path and content hash to ensure uniqueness
144
- file_info = f"{relative_path}:{sha1_hash.hexdigest()}"
145
- file_hashes.append(file_info)
146
-
147
- # Create final hash from all file hashes
148
- combined_hash = hashlib.sha1()
149
- for file_hash in file_hashes:
150
- combined_hash.update(file_hash.encode("utf-8"))
151
-
152
- return combined_hash.hexdigest()
153
-
154
-
155
125
  def _create_or_get_figure(
156
- figure_hash: str,
157
- api_key: str,
158
- total_files: int = None,
159
- total_size: int = None,
160
- 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,
161
130
  ephemeral: bool = False,
131
+ source_url: Optional[str] = None,
162
132
  ) -> dict:
163
133
  """
164
134
  Create a new figure or get existing figure information
165
135
 
166
136
  Args:
167
- figure_hash: The hash of the figure
168
137
  api_key: The API key for authentication (required for non-ephemeral)
169
138
  total_files: Optional total number of files
170
139
  total_size: Optional total size of files
171
140
  title: Optional title for the figure
172
141
  ephemeral: Whether to create an ephemeral figure
142
+ source_url: Optional source URL for the figure (must be unique)
173
143
 
174
144
  Returns:
175
145
  dict: Figure information from the API
@@ -178,8 +148,7 @@ def _create_or_get_figure(
178
148
  if not ephemeral and api_key is None:
179
149
  raise ValueError("API key is required for non-ephemeral figures")
180
150
 
181
- payload = {
182
- "figureHash": figure_hash,
151
+ payload: dict[str, Union[str, int]] = {
183
152
  "figpackVersion": __version__,
184
153
  "bucket": FIGPACK_BUCKET,
185
154
  }
@@ -196,6 +165,8 @@ def _create_or_get_figure(
196
165
  payload["title"] = title
197
166
  if ephemeral:
198
167
  payload["ephemeral"] = True
168
+ if source_url is not None:
169
+ payload["sourceUrl"] = source_url
199
170
 
200
171
  # Use the same endpoint for both regular and ephemeral figures
201
172
  response = requests.post(f"{FIGPACK_API_BASE_URL}/api/figures/create", json=payload)
@@ -206,12 +177,12 @@ def _create_or_get_figure(
206
177
  error_msg = error_data.get("message", "Unknown error")
207
178
  except:
208
179
  error_msg = f"HTTP {response.status_code}"
209
- raise Exception(f"Failed to create figure {figure_hash}: {error_msg}")
180
+ raise Exception(f"Failed to create figure: {error_msg}")
210
181
 
211
182
  response_data = response.json()
212
183
  if not response_data.get("success"):
213
184
  raise Exception(
214
- 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')}"
215
186
  )
216
187
 
217
188
  return response_data
@@ -252,10 +223,11 @@ def _finalize_figure(figure_url: str, api_key: str) -> dict:
252
223
 
253
224
  def _upload_bundle(
254
225
  tmpdir: str,
255
- api_key: str,
256
- title: str = None,
226
+ api_key: Optional[str],
227
+ title: Optional[str] = None,
257
228
  ephemeral: bool = False,
258
229
  use_consolidated_metadata_only: bool = False,
230
+ source_url: Optional[str] = None,
259
231
  ) -> str:
260
232
  """
261
233
  Upload the prepared bundle to the cloud using the new database-driven approach
@@ -267,12 +239,10 @@ def _upload_bundle(
267
239
  ephemeral: Whether to create an ephemeral figure
268
240
  use_consolidated_metadata_only: If True, excludes individual zarr metadata files
269
241
  (.zgroup, .zarray, .zattrs) since they are included in .zmetadata
242
+ source_url: Optional source URL for the figure (must be unique)
270
243
  """
271
244
  tmpdir_path = pathlib.Path(tmpdir)
272
245
 
273
- # Compute deterministic figure ID based on file contents
274
- figure_hash = _compute_deterministic_figure_hash(tmpdir_path)
275
-
276
246
  # Collect all files to upload
277
247
  all_files = []
278
248
  for file_path in tmpdir_path.rglob("*"):
@@ -293,7 +263,12 @@ def _upload_bundle(
293
263
 
294
264
  # Find available figure ID and create/get figure in database with metadata
295
265
  result = _create_or_get_figure(
296
- 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,
297
272
  )
298
273
  figure_info = result.get("figure", {})
299
274
  figure_url = figure_info.get("figureUrl")
@@ -329,7 +304,9 @@ def _upload_bundle(
329
304
 
330
305
  # Get signed URLs for this batch
331
306
  try:
332
- 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
+ )
333
310
  except Exception as e:
334
311
  print(f"Failed to get signed URLs for batch {batch_num}: {e}")
335
312
  raise
@@ -400,7 +377,9 @@ def _upload_bundle(
400
377
  try:
401
378
  # Use batch API for manifest
402
379
  manifest_batch = [("manifest.json", temp_file_path)]
403
- 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
+ )
404
383
 
405
384
  if "manifest.json" not in signed_urls_map:
406
385
  raise Exception("No signed URL returned for manifest.json")
@@ -418,12 +397,45 @@ def _upload_bundle(
418
397
 
419
398
  # Finalize the figure upload
420
399
  print("Finalizing figure...")
421
- _finalize_figure(figure_url, api_key)
400
+ _finalize_figure(figure_url, api_key if api_key else "")
422
401
  print("Upload completed successfully")
423
402
 
424
403
  return figure_url
425
404
 
426
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
+
427
439
  def _determine_content_type(file_path: str) -> str:
428
440
  """
429
441
  Determine content type for upload based on file extension