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.
- {figpack-0.3.0/figpack.egg-info → figpack-0.3.2}/PKG-INFO +2 -2
- {figpack-0.3.0 → figpack-0.3.2}/README.md +1 -1
- {figpack-0.3.0 → figpack-0.3.2}/figpack/__init__.py +1 -1
- {figpack-0.3.0 → figpack-0.3.2}/figpack/cli.py +2 -152
- {figpack-0.3.0 → figpack-0.3.2}/figpack/core/_upload_bundle.py +23 -47
- figpack-0.3.2/figpack-serve/worker/container/app.py +249 -0
- {figpack-0.3.0 → figpack-0.3.2/figpack.egg-info}/PKG-INFO +2 -2
- {figpack-0.3.0 → figpack-0.3.2}/figpack.egg-info/SOURCES.txt +1 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack.egg-info/top_level.txt +1 -0
- {figpack-0.3.0 → figpack-0.3.2}/pyproject.toml +1 -1
- {figpack-0.3.0 → figpack-0.3.2}/tests/test_upload_bundle.py +0 -3
- {figpack-0.3.0 → figpack-0.3.2}/LICENSE +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/MANIFEST.in +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/core/__init__.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/core/_bundle_utils.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/core/_file_handler.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/core/_save_figure.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/core/_server_manager.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/core/_show_view.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/core/_view_figure.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/core/_zarr_consolidate.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/core/config.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/core/extension_view.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/core/figpack_extension.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/core/figpack_view.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/core/zarr.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/extensions.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/figpack-figure-dist/assets/index-ST_DU17U.js +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/figpack-figure-dist/assets/index-V5m_wCvw.css +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/figpack-figure-dist/assets/neurosift-logo-CLsuwLMO.png +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/figpack-figure-dist/index.html +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/views/Box.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/views/CaptionedView.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/views/DataFrame.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/views/Gallery.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/views/GalleryItem.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/views/Iframe.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/views/Image.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/views/LayoutItem.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/views/Markdown.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/views/MatplotlibFigure.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/views/MountainLayout.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/views/MountainLayoutItem.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/views/MultiChannelTimeseries.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/views/PlotlyExtension/PlotlyExtension.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/views/PlotlyExtension/__init__.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/views/PlotlyExtension/_plotly_extension.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/views/PlotlyExtension/plotly_view.js +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/views/Spectrogram.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/views/Splitter.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/views/TabLayout.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/views/TabLayoutItem.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/views/TimeseriesGraph.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack/views/__init__.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack.egg-info/dependency_links.txt +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack.egg-info/entry_points.txt +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/figpack.egg-info/requires.txt +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/setup.cfg +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/tests/test_box.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/tests/test_cli.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/tests/test_core.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/tests/test_dataframe.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/tests/test_extension_system.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/tests/test_figpack_view.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/tests/test_file_handler.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/tests/test_gallery.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/tests/test_image.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/tests/test_markdown.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/tests/test_matplotlib_figure.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/tests/test_multichannel_timeseries.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/tests/test_plotly_figure.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/tests/test_server_manager.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/tests/test_spectrogram.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/tests/test_splitter.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/tests/test_tablayout.py +0 -0
- {figpack-0.3.0 → figpack-0.3.2}/tests/test_timeseries_graph.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
|
@@ -16,9 +16,8 @@ from urllib.parse import urljoin
|
|
|
16
16
|
import requests
|
|
17
17
|
|
|
18
18
|
from . import __version__
|
|
19
|
-
from .core.
|
|
20
|
-
from .core.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
|
@@ -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
|
{figpack-0.3.0 → figpack-0.3.2}/figpack/figpack-figure-dist/assets/neurosift-logo-CLsuwLMO.png
RENAMED
|
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
|
|
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
|
|
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
|