figpack 0.3.0__tar.gz → 0.3.2__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.
Files changed (77) hide show
  1. {figpack-0.3.0/figpack.egg-info → figpack-0.3.2}/PKG-INFO +2 -2
  2. {figpack-0.3.0 → figpack-0.3.2}/README.md +1 -1
  3. {figpack-0.3.0 → figpack-0.3.2}/figpack/__init__.py +1 -1
  4. {figpack-0.3.0 → figpack-0.3.2}/figpack/cli.py +2 -152
  5. {figpack-0.3.0 → figpack-0.3.2}/figpack/core/_upload_bundle.py +23 -47
  6. figpack-0.3.2/figpack-serve/worker/container/app.py +249 -0
  7. {figpack-0.3.0 → figpack-0.3.2/figpack.egg-info}/PKG-INFO +2 -2
  8. {figpack-0.3.0 → figpack-0.3.2}/figpack.egg-info/SOURCES.txt +1 -0
  9. {figpack-0.3.0 → figpack-0.3.2}/figpack.egg-info/top_level.txt +1 -0
  10. {figpack-0.3.0 → figpack-0.3.2}/pyproject.toml +1 -1
  11. {figpack-0.3.0 → figpack-0.3.2}/tests/test_upload_bundle.py +0 -3
  12. {figpack-0.3.0 → figpack-0.3.2}/LICENSE +0 -0
  13. {figpack-0.3.0 → figpack-0.3.2}/MANIFEST.in +0 -0
  14. {figpack-0.3.0 → figpack-0.3.2}/figpack/core/__init__.py +0 -0
  15. {figpack-0.3.0 → figpack-0.3.2}/figpack/core/_bundle_utils.py +0 -0
  16. {figpack-0.3.0 → figpack-0.3.2}/figpack/core/_file_handler.py +0 -0
  17. {figpack-0.3.0 → figpack-0.3.2}/figpack/core/_save_figure.py +0 -0
  18. {figpack-0.3.0 → figpack-0.3.2}/figpack/core/_server_manager.py +0 -0
  19. {figpack-0.3.0 → figpack-0.3.2}/figpack/core/_show_view.py +0 -0
  20. {figpack-0.3.0 → figpack-0.3.2}/figpack/core/_view_figure.py +0 -0
  21. {figpack-0.3.0 → figpack-0.3.2}/figpack/core/_zarr_consolidate.py +0 -0
  22. {figpack-0.3.0 → figpack-0.3.2}/figpack/core/config.py +0 -0
  23. {figpack-0.3.0 → figpack-0.3.2}/figpack/core/extension_view.py +0 -0
  24. {figpack-0.3.0 → figpack-0.3.2}/figpack/core/figpack_extension.py +0 -0
  25. {figpack-0.3.0 → figpack-0.3.2}/figpack/core/figpack_view.py +0 -0
  26. {figpack-0.3.0 → figpack-0.3.2}/figpack/core/zarr.py +0 -0
  27. {figpack-0.3.0 → figpack-0.3.2}/figpack/extensions.py +0 -0
  28. {figpack-0.3.0 → figpack-0.3.2}/figpack/figpack-figure-dist/assets/index-ST_DU17U.js +0 -0
  29. {figpack-0.3.0 → figpack-0.3.2}/figpack/figpack-figure-dist/assets/index-V5m_wCvw.css +0 -0
  30. {figpack-0.3.0 → figpack-0.3.2}/figpack/figpack-figure-dist/assets/neurosift-logo-CLsuwLMO.png +0 -0
  31. {figpack-0.3.0 → figpack-0.3.2}/figpack/figpack-figure-dist/index.html +0 -0
  32. {figpack-0.3.0 → figpack-0.3.2}/figpack/views/Box.py +0 -0
  33. {figpack-0.3.0 → figpack-0.3.2}/figpack/views/CaptionedView.py +0 -0
  34. {figpack-0.3.0 → figpack-0.3.2}/figpack/views/DataFrame.py +0 -0
  35. {figpack-0.3.0 → figpack-0.3.2}/figpack/views/Gallery.py +0 -0
  36. {figpack-0.3.0 → figpack-0.3.2}/figpack/views/GalleryItem.py +0 -0
  37. {figpack-0.3.0 → figpack-0.3.2}/figpack/views/Iframe.py +0 -0
  38. {figpack-0.3.0 → figpack-0.3.2}/figpack/views/Image.py +0 -0
  39. {figpack-0.3.0 → figpack-0.3.2}/figpack/views/LayoutItem.py +0 -0
  40. {figpack-0.3.0 → figpack-0.3.2}/figpack/views/Markdown.py +0 -0
  41. {figpack-0.3.0 → figpack-0.3.2}/figpack/views/MatplotlibFigure.py +0 -0
  42. {figpack-0.3.0 → figpack-0.3.2}/figpack/views/MountainLayout.py +0 -0
  43. {figpack-0.3.0 → figpack-0.3.2}/figpack/views/MountainLayoutItem.py +0 -0
  44. {figpack-0.3.0 → figpack-0.3.2}/figpack/views/MultiChannelTimeseries.py +0 -0
  45. {figpack-0.3.0 → figpack-0.3.2}/figpack/views/PlotlyExtension/PlotlyExtension.py +0 -0
  46. {figpack-0.3.0 → figpack-0.3.2}/figpack/views/PlotlyExtension/__init__.py +0 -0
  47. {figpack-0.3.0 → figpack-0.3.2}/figpack/views/PlotlyExtension/_plotly_extension.py +0 -0
  48. {figpack-0.3.0 → figpack-0.3.2}/figpack/views/PlotlyExtension/plotly_view.js +0 -0
  49. {figpack-0.3.0 → figpack-0.3.2}/figpack/views/Spectrogram.py +0 -0
  50. {figpack-0.3.0 → figpack-0.3.2}/figpack/views/Splitter.py +0 -0
  51. {figpack-0.3.0 → figpack-0.3.2}/figpack/views/TabLayout.py +0 -0
  52. {figpack-0.3.0 → figpack-0.3.2}/figpack/views/TabLayoutItem.py +0 -0
  53. {figpack-0.3.0 → figpack-0.3.2}/figpack/views/TimeseriesGraph.py +0 -0
  54. {figpack-0.3.0 → figpack-0.3.2}/figpack/views/__init__.py +0 -0
  55. {figpack-0.3.0 → figpack-0.3.2}/figpack.egg-info/dependency_links.txt +0 -0
  56. {figpack-0.3.0 → figpack-0.3.2}/figpack.egg-info/entry_points.txt +0 -0
  57. {figpack-0.3.0 → figpack-0.3.2}/figpack.egg-info/requires.txt +0 -0
  58. {figpack-0.3.0 → figpack-0.3.2}/setup.cfg +0 -0
  59. {figpack-0.3.0 → figpack-0.3.2}/tests/test_box.py +0 -0
  60. {figpack-0.3.0 → figpack-0.3.2}/tests/test_cli.py +0 -0
  61. {figpack-0.3.0 → figpack-0.3.2}/tests/test_core.py +0 -0
  62. {figpack-0.3.0 → figpack-0.3.2}/tests/test_dataframe.py +0 -0
  63. {figpack-0.3.0 → figpack-0.3.2}/tests/test_extension_system.py +0 -0
  64. {figpack-0.3.0 → figpack-0.3.2}/tests/test_figpack_view.py +0 -0
  65. {figpack-0.3.0 → figpack-0.3.2}/tests/test_file_handler.py +0 -0
  66. {figpack-0.3.0 → figpack-0.3.2}/tests/test_gallery.py +0 -0
  67. {figpack-0.3.0 → figpack-0.3.2}/tests/test_image.py +0 -0
  68. {figpack-0.3.0 → figpack-0.3.2}/tests/test_markdown.py +0 -0
  69. {figpack-0.3.0 → figpack-0.3.2}/tests/test_matplotlib_figure.py +0 -0
  70. {figpack-0.3.0 → figpack-0.3.2}/tests/test_multichannel_timeseries.py +0 -0
  71. {figpack-0.3.0 → figpack-0.3.2}/tests/test_plotly_figure.py +0 -0
  72. {figpack-0.3.0 → figpack-0.3.2}/tests/test_server_manager.py +0 -0
  73. {figpack-0.3.0 → figpack-0.3.2}/tests/test_spectrogram.py +0 -0
  74. {figpack-0.3.0 → figpack-0.3.2}/tests/test_splitter.py +0 -0
  75. {figpack-0.3.0 → figpack-0.3.2}/tests/test_tablayout.py +0 -0
  76. {figpack-0.3.0 → figpack-0.3.2}/tests/test_timeseries_graph.py +0 -0
  77. {figpack-0.3.0 → figpack-0.3.2}/tests/test_view_figure.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: figpack
3
- Version: 0.3.0
3
+ Version: 0.3.2
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
@@ -133,7 +133,7 @@ If you use figpack in your research, please cite it:
133
133
 
134
134
  Or in APA format:
135
135
 
136
- > Magland, J. (2025). figpack (Version 0.3.0) [Computer software]. Zenodo. https://doi.org/10.5281/zenodo.17419621
136
+ > Magland, J. (2025). figpack (Version 0.3.2) [Computer software]. Zenodo. https://doi.org/10.5281/zenodo.17419621
137
137
 
138
138
  ## Contributing
139
139
 
@@ -57,7 +57,7 @@ If you use figpack in your research, please cite it:
57
57
 
58
58
  Or in APA format:
59
59
 
60
- > Magland, J. (2025). figpack (Version 0.3.0) [Computer software]. Zenodo. https://doi.org/10.5281/zenodo.17419621
60
+ > Magland, J. (2025). figpack (Version 0.3.2) [Computer software]. Zenodo. https://doi.org/10.5281/zenodo.17419621
61
61
 
62
62
  ## Contributing
63
63
 
@@ -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.3.0"
5
+ __version__ = "0.3.2"
6
6
 
7
7
  from .cli import view_figure
8
8
  from .core import FigpackView, FigpackExtension, ExtensionView
@@ -16,9 +16,8 @@ from urllib.parse import urljoin
16
16
  import requests
17
17
 
18
18
  from . import __version__
19
- from .core._server_manager import CORSRequestHandler
20
- from .core._view_figure import serve_files, view_figure
21
- from .core._upload_bundle import _upload_bundle, get_figure_by_source_url
19
+ from .core._view_figure import view_figure
20
+ from .core._upload_bundle import _upload_bundle
22
21
  from .extensions import ExtensionManager
23
22
 
24
23
  MAX_WORKERS_FOR_DOWNLOAD = 16
@@ -250,130 +249,6 @@ def handle_extensions_command(args):
250
249
  print("Use 'figpack extensions <command> --help' for more information.")
251
250
 
252
251
 
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
252
  def download_and_view_archive(url: str, port: int = None) -> None:
378
253
  """
379
254
  Download a tar.gz/tgz archive from a URL and view it
@@ -495,27 +370,6 @@ def main():
495
370
  "extensions", nargs="+", help="Extension package names to uninstall"
496
371
  )
497
372
 
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
-
519
373
  args = parser.parse_args()
520
374
 
521
375
  if args.command == "download":
@@ -528,10 +382,6 @@ def main():
528
382
  view_figure(args.archive, port=args.port)
529
383
  elif args.command == "extensions":
530
384
  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)
535
385
  else:
536
386
  parser.print_help()
537
387
 
@@ -36,10 +36,16 @@ def _get_batch_signed_urls(figure_url: str, files_batch: list, api_key: str) ->
36
36
  payload = {
37
37
  "figureUrl": figure_url,
38
38
  "files": files_data,
39
- "apiKey": api_key,
40
39
  }
41
40
 
42
- response = requests.post(f"{FIGPACK_API_BASE_URL}/upload", json=payload)
41
+ headers = {
42
+ "Content-Type": "application/json",
43
+ "x-api-key": api_key if api_key else "",
44
+ }
45
+
46
+ response = requests.post(
47
+ f"{FIGPACK_API_BASE_URL}/upload", json=payload, headers=headers
48
+ )
43
49
 
44
50
  if not response.ok:
45
51
  try:
@@ -128,7 +134,6 @@ def _create_or_get_figure(
128
134
  total_size: Optional[int] = None,
129
135
  title: Optional[str] = None,
130
136
  ephemeral: bool = False,
131
- source_url: Optional[str] = None,
132
137
  ) -> dict:
133
138
  """
134
139
  Create a new figure or get existing figure information
@@ -139,7 +144,6 @@ def _create_or_get_figure(
139
144
  total_size: Optional total size of files
140
145
  title: Optional title for the figure
141
146
  ephemeral: Whether to create an ephemeral figure
142
- source_url: Optional source URL for the figure (must be unique)
143
147
 
144
148
  Returns:
145
149
  dict: Figure information from the API
@@ -154,8 +158,8 @@ def _create_or_get_figure(
154
158
  }
155
159
 
156
160
  # API key is optional for ephemeral figures
157
- if api_key is not None:
158
- payload["apiKey"] = api_key
161
+ # if api_key is not None:
162
+ # payload["apiKey"] = api_key
159
163
 
160
164
  if total_files is not None:
161
165
  payload["totalFiles"] = total_files
@@ -165,12 +169,14 @@ def _create_or_get_figure(
165
169
  payload["title"] = title
166
170
  if ephemeral:
167
171
  payload["ephemeral"] = True
168
- if source_url is not None:
169
- payload["sourceUrl"] = source_url
170
172
 
171
173
  # Use the same endpoint for both regular and ephemeral figures
172
174
  url = f"{FIGPACK_API_BASE_URL}/figures/create"
173
- response = requests.post(url, json=payload)
175
+ headers = {
176
+ "Content-Type": "application/json",
177
+ "x-api-key": api_key if api_key else "",
178
+ }
179
+ response = requests.post(url, json=payload, headers=headers)
174
180
 
175
181
  if not response.ok:
176
182
  try:
@@ -198,10 +204,16 @@ def _finalize_figure(figure_url: str, api_key: str) -> dict:
198
204
  """
199
205
  payload = {
200
206
  "figureUrl": figure_url,
201
- "apiKey": api_key,
202
207
  }
203
208
 
204
- response = requests.post(f"{FIGPACK_API_BASE_URL}/figures/finalize", json=payload)
209
+ headers = {
210
+ "Content-Type": "application/json",
211
+ "x-api-key": api_key if api_key else "",
212
+ }
213
+
214
+ response = requests.post(
215
+ f"{FIGPACK_API_BASE_URL}/figures/finalize", json=payload, headers=headers
216
+ )
205
217
 
206
218
  if not response.ok:
207
219
  try:
@@ -226,7 +238,6 @@ def _upload_bundle(
226
238
  title: Optional[str] = None,
227
239
  ephemeral: bool = False,
228
240
  use_consolidated_metadata_only: bool = False,
229
- source_url: Optional[str] = None,
230
241
  ) -> str:
231
242
  """
232
243
  Upload the prepared bundle to the cloud using the new database-driven approach
@@ -238,7 +249,6 @@ def _upload_bundle(
238
249
  ephemeral: Whether to create an ephemeral figure
239
250
  use_consolidated_metadata_only: If True, excludes individual zarr metadata files
240
251
  (.zgroup, .zarray, .zattrs) since they are included in .zmetadata
241
- source_url: Optional source URL for the figure (must be unique)
242
252
  """
243
253
  tmpdir_path = pathlib.Path(tmpdir)
244
254
 
@@ -267,7 +277,6 @@ def _upload_bundle(
267
277
  total_size,
268
278
  title=title,
269
279
  ephemeral=ephemeral,
270
- source_url=source_url,
271
280
  )
272
281
  figure_info = result.get("figure", {})
273
282
  figure_url = figure_info.get("figureUrl")
@@ -402,39 +411,6 @@ def _upload_bundle(
402
411
  return figure_url
403
412
 
404
413
 
405
- def get_figure_by_source_url(source_url: str) -> Optional[str]:
406
- """
407
- Query the API for a figure URL by its source URL
408
-
409
- Args:
410
- source_url: The source URL to search for
411
-
412
- Returns:
413
- Optional[str]: The figure URL if found, None otherwise
414
- """
415
- payload = {"sourceUrl": source_url}
416
-
417
- response = requests.post(
418
- f"{FIGPACK_API_BASE_URL}/figures/find-by-source-url", json=payload
419
- )
420
-
421
- if not response.ok:
422
- if response.status_code == 404:
423
- return None
424
- try:
425
- error_data = response.json()
426
- error_msg = error_data.get("message", "Unknown error")
427
- except:
428
- error_msg = f"HTTP {response.status_code}"
429
- raise Exception(f"Failed to query figure by source URL: {error_msg}")
430
-
431
- response_data = response.json()
432
- if not response_data.get("success"):
433
- return None
434
-
435
- return response_data.get("figureUrl")
436
-
437
-
438
414
  def _determine_content_type(file_path: str) -> str:
439
415
  """
440
416
  Determine content type for upload based on file extension
@@ -0,0 +1,249 @@
1
+ import os
2
+ import json
3
+ import tempfile
4
+ import tarfile
5
+ import shutil
6
+ from pathlib import Path
7
+ from http.server import BaseHTTPRequestHandler, HTTPServer
8
+ import sys
9
+ from io import StringIO
10
+ import boto3
11
+ import requests
12
+
13
+ PORT = int(os.environ.get("PORT", "8080"))
14
+
15
+ bucket_name = os.environ.get("R2_BUCKET_NAME")
16
+ if not bucket_name:
17
+ raise ValueError("R2_BUCKET_NAME environment variable is not set")
18
+
19
+
20
+ def get_r2_client():
21
+ """Create and return an S3 client configured for Cloudflare R2."""
22
+ print("Creating R2 client...")
23
+ account_id = os.environ.get("R2_ACCOUNT_ID")
24
+ access_key_id = os.environ.get("R2_ACCESS_KEY_ID")
25
+ secret_access_key = os.environ.get("R2_SECRET_ACCESS_KEY")
26
+
27
+ if not all([account_id, access_key_id, secret_access_key]):
28
+ raise ValueError("Missing R2 credentials in environment variables")
29
+
30
+ endpoint_url = f"https://{account_id}.r2.cloudflarestorage.com"
31
+ print(f"R2 endpoint URL: {endpoint_url}")
32
+
33
+ s3_client = boto3.client(
34
+ "s3",
35
+ endpoint_url=endpoint_url,
36
+ aws_access_key_id=access_key_id,
37
+ aws_secret_access_key=secret_access_key,
38
+ region_name="auto",
39
+ )
40
+
41
+ print("R2 client created successfully")
42
+ return s3_client
43
+
44
+
45
+ def upload_directory_to_r2(s3_client, bucket_name, local_dir, bucket_prefix):
46
+ """
47
+ Upload all files from local_dir to R2 bucket with the given prefix.
48
+ Returns the number of files uploaded.
49
+ """
50
+ print(
51
+ f"Starting upload from {local_dir} to bucket {bucket_name} with prefix {bucket_prefix}"
52
+ )
53
+ uploaded_count = 0
54
+ local_path = Path(local_dir)
55
+
56
+ for file_path in local_path.rglob("*"):
57
+ if file_path.is_file():
58
+ # Get relative path from local_dir
59
+ relative_path = file_path.relative_to(local_path)
60
+ # Create S3 key with bucket_prefix
61
+ s3_key = f"{bucket_prefix}/{relative_path}"
62
+
63
+ # Determine content type
64
+ content_type = "application/octet-stream"
65
+ suffix = file_path.suffix.lower()
66
+ if suffix == ".html":
67
+ content_type = "text/html"
68
+ elif suffix == ".css":
69
+ content_type = "text/css"
70
+ elif suffix == ".js":
71
+ content_type = "application/javascript"
72
+ elif suffix == ".json":
73
+ content_type = "application/json"
74
+ elif suffix == ".png":
75
+ content_type = "image/png"
76
+ elif suffix == ".jpg" or suffix == ".jpeg":
77
+ content_type = "image/jpeg"
78
+ elif suffix == ".svg":
79
+ content_type = "image/svg+xml"
80
+ elif suffix == ".txt":
81
+ content_type = "text/plain"
82
+
83
+ # Upload file
84
+ print(f"Uploading file: {s3_key} (Content-Type: {content_type})")
85
+ with open(file_path, "rb") as f:
86
+ s3_client.put_object(
87
+ Bucket=bucket_name, Key=s3_key, Body=f, ContentType=content_type
88
+ )
89
+
90
+ uploaded_count += 1
91
+
92
+ print(f"Upload complete. Total files uploaded: {uploaded_count}")
93
+ return uploaded_count
94
+
95
+
96
+ class Handler(BaseHTTPRequestHandler):
97
+ def _send(self, code: int, payload: dict):
98
+ data = json.dumps(payload).encode("utf-8")
99
+ self.send_response(code)
100
+ self.send_header("Content-Type", "application/json")
101
+ self.send_header("Content-Length", str(len(data)))
102
+ self.end_headers()
103
+ self.wfile.write(data)
104
+
105
+ def do_POST(self):
106
+ print(f"Received POST request to {self.path}")
107
+ if self.path != "/run":
108
+ print(f"Invalid path: {self.path}")
109
+ self._send(404, {"error": "not found"})
110
+ return
111
+
112
+ print("Parsing request body...")
113
+ try:
114
+ length = int(self.headers.get("Content-Length", "0"))
115
+ raw = self.rfile.read(length)
116
+ body = json.loads(raw or "{}")
117
+ except Exception as e:
118
+ print(f"Error parsing JSON: {e}")
119
+ self._send(400, {"error": f"invalid json: {e}"})
120
+ return
121
+
122
+ source_url = (body.get("source_url") or "").strip()
123
+ print(f"Received source_url: {source_url}")
124
+
125
+ if not source_url:
126
+ print("Error: source_url is missing")
127
+ self._send(400, {"error": "missing source_url"})
128
+ return
129
+
130
+ try:
131
+ assert source_url.endswith(".tgz") or source_url.endswith(
132
+ ".tar.gz"
133
+ ), "source_url must point to a .tgz or .tar.gz file"
134
+ except AssertionError as e:
135
+ print(f"Error: Invalid source_url format - {e}")
136
+ self._send(400, {"error": str(e)})
137
+ return
138
+
139
+ # Extract bucket key from source_url
140
+ # e.g., https://sandbox.zenodo.org/records/391408/files/hello_figpack.tgz
141
+ # becomes sandbox.zenodo.org/records/391408/files/hello_figpack.tgz
142
+ bucket_key = source_url.split("://")[1]
143
+ print(f"Bucket key: {bucket_key}")
144
+
145
+ temp_dir = None
146
+
147
+ # Capture stdout
148
+ stdout_capture = StringIO()
149
+ old_stdout = sys.stdout
150
+ sys.stdout = stdout_capture
151
+
152
+ try:
153
+ # Create temporary directory for download and extraction
154
+ temp_dir = tempfile.mkdtemp()
155
+ print(f"Created temporary directory: {temp_dir}")
156
+ download_path = os.path.join(temp_dir, "archive.tar.gz")
157
+ extract_dir = os.path.join(temp_dir, "extracted")
158
+
159
+ # Download the archive
160
+ print(f"Downloading archive from {source_url}...")
161
+ response = requests.get(source_url, stream=True, timeout=300)
162
+ response.raise_for_status()
163
+ print(f"Download response status: {response.status_code}")
164
+
165
+ bytes_downloaded = 0
166
+ with open(download_path, "wb") as f:
167
+ for chunk in response.iter_content(chunk_size=8192):
168
+ f.write(chunk)
169
+ bytes_downloaded += len(chunk)
170
+ print(f"Download complete. Total bytes: {bytes_downloaded}")
171
+
172
+ # Extract the archive
173
+ print(f"Extracting archive to {extract_dir}...")
174
+ os.makedirs(extract_dir, exist_ok=True)
175
+ with tarfile.open(download_path, "r:*") as tar:
176
+ tar.extractall(path=extract_dir)
177
+ print("Extraction complete")
178
+
179
+ # Get R2 client and upload files
180
+ s3_client = get_r2_client()
181
+
182
+ uploaded_count = upload_directory_to_r2(
183
+ s3_client, bucket_name, extract_dir, bucket_key
184
+ )
185
+
186
+ # Construct the URL to the uploaded index.html
187
+ url = f"https://serve-bucket.figpack.org/{bucket_key}/index.html"
188
+ print(f"Successfully processed request. URL: {url}")
189
+
190
+ # Restore stdout and get captured output
191
+ sys.stdout = old_stdout
192
+ stdout = stdout_capture.getvalue()
193
+
194
+ self._send(
195
+ 200,
196
+ {
197
+ "ok": True,
198
+ "url": url,
199
+ "files_uploaded": uploaded_count,
200
+ "stdout": stdout,
201
+ },
202
+ )
203
+
204
+ except requests.exceptions.RequestException as e:
205
+ # Restore stdout on error
206
+ sys.stdout = old_stdout
207
+ stdout = stdout_capture.getvalue()
208
+ print(f"ERROR: Failed to download source_url: {e}")
209
+ self._send(
210
+ 500, {"error": f"failed to download source_url: {e}", "stdout": stdout}
211
+ )
212
+ return
213
+ except tarfile.TarError as e:
214
+ # Restore stdout on error
215
+ sys.stdout = old_stdout
216
+ stdout = stdout_capture.getvalue()
217
+ print(f"ERROR: Failed to extract archive: {e}")
218
+ self._send(
219
+ 500, {"error": f"failed to extract archive: {e}", "stdout": stdout}
220
+ )
221
+ return
222
+ except Exception as e:
223
+ # Restore stdout on error
224
+ sys.stdout = old_stdout
225
+ stdout = stdout_capture.getvalue()
226
+ print(f"ERROR: Failed to process source_url: {e}")
227
+ self._send(
228
+ 500, {"error": f"failed to process source_url: {e}", "stdout": stdout}
229
+ )
230
+ return
231
+ finally:
232
+ # Ensure stdout is always restored
233
+ sys.stdout = old_stdout
234
+ # Clean up temporary directory
235
+ if temp_dir and os.path.exists(temp_dir):
236
+ print(f"Cleaning up temporary directory: {temp_dir}")
237
+ shutil.rmtree(temp_dir, ignore_errors=True)
238
+
239
+
240
+ def main():
241
+ print(f"Starting HTTP server on 0.0.0.0:{PORT}")
242
+ print(f"Using R2 bucket: {bucket_name}")
243
+ server = HTTPServer(("0.0.0.0", PORT), Handler)
244
+ print("Server ready, waiting for requests...")
245
+ server.serve_forever()
246
+
247
+
248
+ if __name__ == "__main__":
249
+ main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: figpack
3
- Version: 0.3.0
3
+ Version: 0.3.2
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
@@ -133,7 +133,7 @@ If you use figpack in your research, please cite it:
133
133
 
134
134
  Or in APA format:
135
135
 
136
- > Magland, J. (2025). figpack (Version 0.3.0) [Computer software]. Zenodo. https://doi.org/10.5281/zenodo.17419621
136
+ > Magland, J. (2025). figpack (Version 0.3.2) [Computer software]. Zenodo. https://doi.org/10.5281/zenodo.17419621
137
137
 
138
138
  ## Contributing
139
139
 
@@ -5,6 +5,7 @@ pyproject.toml
5
5
  figpack/__init__.py
6
6
  figpack/cli.py
7
7
  figpack/extensions.py
8
+ figpack-serve/worker/container/app.py
8
9
  figpack.egg-info/PKG-INFO
9
10
  figpack.egg-info/SOURCES.txt
10
11
  figpack.egg-info/dependency_links.txt
@@ -1,2 +1,3 @@
1
1
  figpack
2
2
  figpack-documents
3
+ figpack-serve
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "figpack"
7
- version = "0.3.0"
7
+ version = "0.3.2"
8
8
  description = "A Python package for creating shareable, interactive visualizations in the browser"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -49,7 +49,6 @@ def test_get_batch_signed_urls(tmp_path):
49
49
  called_payload = mock_post.call_args[1]["json"]
50
50
  assert "upload" in called_url
51
51
  assert called_payload["figureUrl"] == figure_url
52
- assert called_payload["apiKey"] == api_key
53
52
  assert len(called_payload["files"]) == 2
54
53
 
55
54
 
@@ -138,7 +137,6 @@ def test_create_or_get_figure():
138
137
  assert result["figure"]["status"] == "pending"
139
138
 
140
139
  mock_post.assert_called_once()
141
- assert mock_post.call_args[1]["json"]["apiKey"] == api_key
142
140
 
143
141
 
144
142
  def test_create_or_get_figure_error():
@@ -170,7 +168,6 @@ def test_finalize_figure():
170
168
 
171
169
  mock_post.assert_called_once()
172
170
  assert mock_post.call_args[1]["json"]["figureUrl"] == figure_url
173
- assert mock_post.call_args[1]["json"]["apiKey"] == api_key
174
171
 
175
172
 
176
173
  def test_determine_content_type():
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes