figpack 0.2.35__py3-none-any.whl → 0.2.37__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.
Potentially problematic release.
This version of figpack might be problematic. Click here for more details.
- figpack/__init__.py +1 -1
- figpack/cli.py +214 -2
- figpack/core/_bundle_utils.py +6 -6
- figpack/core/_file_handler.py +1 -1
- figpack/core/_save_figure.py +6 -6
- figpack/core/_server_manager.py +10 -3
- figpack/core/_show_view.py +1 -1
- figpack/core/_upload_bundle.py +63 -51
- figpack/core/_view_figure.py +15 -10
- figpack/core/extension_view.py +8 -4
- figpack/core/figpack_extension.py +1 -1
- figpack/core/figpack_view.py +22 -12
- figpack/core/zarr.py +2 -2
- figpack/figpack-figure-dist/assets/{index-DXkrNeMu.js → index-Bt8OPETP.js} +3 -3
- figpack/figpack-figure-dist/index.html +1 -1
- figpack/views/Box.py +2 -2
- figpack/views/Image.py +1 -2
- figpack/views/PlotlyExtension/PlotlyExtension.py +12 -12
- figpack/views/Spectrogram.py +2 -0
- figpack/views/TimeseriesGraph.py +17 -13
- {figpack-0.2.35.dist-info → figpack-0.2.37.dist-info}/METADATA +22 -1
- {figpack-0.2.35.dist-info → figpack-0.2.37.dist-info}/RECORD +26 -26
- {figpack-0.2.35.dist-info → figpack-0.2.37.dist-info}/WHEEL +0 -0
- {figpack-0.2.35.dist-info → figpack-0.2.37.dist-info}/entry_points.txt +0 -0
- {figpack-0.2.35.dist-info → figpack-0.2.37.dist-info}/licenses/LICENSE +0 -0
- {figpack-0.2.35.dist-info → figpack-0.2.37.dist-info}/top_level.txt +0 -0
figpack/__init__.py
CHANGED
figpack/cli.py
CHANGED
|
@@ -18,6 +18,7 @@ import requests
|
|
|
18
18
|
from . import __version__
|
|
19
19
|
from .core._server_manager import CORSRequestHandler
|
|
20
20
|
from .core._view_figure import serve_files, view_figure
|
|
21
|
+
from .core._upload_bundle import _upload_bundle, get_figure_by_source_url
|
|
21
22
|
from .extensions import ExtensionManager
|
|
22
23
|
|
|
23
24
|
MAX_WORKERS_FOR_DOWNLOAD = 16
|
|
@@ -249,6 +250,188 @@ def handle_extensions_command(args):
|
|
|
249
250
|
print("Use 'figpack extensions <command> --help' for more information.")
|
|
250
251
|
|
|
251
252
|
|
|
253
|
+
def handle_upload_from_source_url(args) -> None:
|
|
254
|
+
"""
|
|
255
|
+
Handle the upload-from-source-url command
|
|
256
|
+
|
|
257
|
+
Downloads a tar.gz/tgz file from a URL, extracts it, and uploads it as a new figure
|
|
258
|
+
with the source URL set.
|
|
259
|
+
"""
|
|
260
|
+
import os
|
|
261
|
+
|
|
262
|
+
source_url = args.source_url
|
|
263
|
+
title = args.title if hasattr(args, "title") else None
|
|
264
|
+
|
|
265
|
+
# Get API key from environment variable
|
|
266
|
+
api_key = os.environ.get("FIGPACK_API_KEY")
|
|
267
|
+
if not api_key:
|
|
268
|
+
print(
|
|
269
|
+
"Error: FIGPACK_API_KEY environment variable must be set to upload figures."
|
|
270
|
+
)
|
|
271
|
+
sys.exit(1)
|
|
272
|
+
|
|
273
|
+
# Validate URL format
|
|
274
|
+
if not (source_url.endswith(".tar.gz") or source_url.endswith(".tgz")):
|
|
275
|
+
print(f"Error: Source URL must point to a .tar.gz or .tgz file: {source_url}")
|
|
276
|
+
sys.exit(1)
|
|
277
|
+
|
|
278
|
+
print(f"Downloading archive from: {source_url}")
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
# Download the archive
|
|
282
|
+
response = requests.get(source_url, timeout=120, stream=True)
|
|
283
|
+
response.raise_for_status()
|
|
284
|
+
|
|
285
|
+
# Create temporary file for the archive
|
|
286
|
+
with tempfile.NamedTemporaryFile(
|
|
287
|
+
suffix=".tar.gz", delete=False
|
|
288
|
+
) as temp_archive:
|
|
289
|
+
archive_path = temp_archive.name
|
|
290
|
+
|
|
291
|
+
# Download with progress indication
|
|
292
|
+
total_size = int(response.headers.get("content-length", 0))
|
|
293
|
+
downloaded_size = 0
|
|
294
|
+
|
|
295
|
+
for chunk in response.iter_content(chunk_size=8192):
|
|
296
|
+
if chunk:
|
|
297
|
+
temp_archive.write(chunk)
|
|
298
|
+
downloaded_size += len(chunk)
|
|
299
|
+
if total_size > 0:
|
|
300
|
+
progress = (downloaded_size / total_size) * 100
|
|
301
|
+
print(
|
|
302
|
+
f"Downloaded: {downloaded_size / (1024*1024):.2f} MB ({progress:.1f}%)",
|
|
303
|
+
end="\r",
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
if total_size > 0:
|
|
307
|
+
print() # New line after progress
|
|
308
|
+
print(f"Download complete: {downloaded_size / (1024*1024):.2f} MB")
|
|
309
|
+
|
|
310
|
+
# Extract archive to temporary directory
|
|
311
|
+
print("Extracting archive...")
|
|
312
|
+
with tempfile.TemporaryDirectory() as extract_dir:
|
|
313
|
+
extract_path = pathlib.Path(extract_dir)
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
with tarfile.open(archive_path, "r:gz") as tar:
|
|
317
|
+
tar.extractall(path=extract_path)
|
|
318
|
+
print(f"Extracted to temporary directory")
|
|
319
|
+
except Exception as e:
|
|
320
|
+
print(f"Error: Failed to extract archive: {e}")
|
|
321
|
+
sys.exit(1)
|
|
322
|
+
finally:
|
|
323
|
+
# Clean up downloaded archive
|
|
324
|
+
import os
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
os.unlink(archive_path)
|
|
328
|
+
except Exception:
|
|
329
|
+
pass
|
|
330
|
+
|
|
331
|
+
# Upload the extracted files
|
|
332
|
+
print(f"Uploading figure with source URL: {source_url}")
|
|
333
|
+
try:
|
|
334
|
+
figure_url = _upload_bundle(
|
|
335
|
+
str(extract_path),
|
|
336
|
+
api_key=api_key,
|
|
337
|
+
title=title,
|
|
338
|
+
source_url=source_url,
|
|
339
|
+
)
|
|
340
|
+
print(f"\nFigure uploaded successfully!")
|
|
341
|
+
print(f"Figure URL: {figure_url}")
|
|
342
|
+
except Exception as e:
|
|
343
|
+
print(f"Error: Failed to upload figure: {e}")
|
|
344
|
+
sys.exit(1)
|
|
345
|
+
|
|
346
|
+
except requests.exceptions.RequestException as e:
|
|
347
|
+
print(f"Error: Failed to download archive from {source_url}: {e}")
|
|
348
|
+
sys.exit(1)
|
|
349
|
+
except Exception as e:
|
|
350
|
+
print(f"Error: {e}")
|
|
351
|
+
sys.exit(1)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def handle_find_by_source_url(args) -> None:
|
|
355
|
+
"""
|
|
356
|
+
Handle the find-by-source-url command
|
|
357
|
+
|
|
358
|
+
Queries the API for a figure URL by its source URL.
|
|
359
|
+
"""
|
|
360
|
+
source_url = args.source_url
|
|
361
|
+
|
|
362
|
+
print(f"Querying for figure with source URL: {source_url}")
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
figure_url = get_figure_by_source_url(source_url)
|
|
366
|
+
|
|
367
|
+
if figure_url:
|
|
368
|
+
print(f"Figure found: {figure_url}")
|
|
369
|
+
else:
|
|
370
|
+
print(f"No figure found with source URL: {source_url}")
|
|
371
|
+
sys.exit(1)
|
|
372
|
+
except Exception as e:
|
|
373
|
+
print(f"Error: {e}")
|
|
374
|
+
sys.exit(1)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def download_and_view_archive(url: str, port: int = None) -> None:
|
|
378
|
+
"""
|
|
379
|
+
Download a tar.gz/tgz archive from a URL and view it
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
url: URL to the tar.gz or tgz file
|
|
383
|
+
port: Optional port number to serve on
|
|
384
|
+
"""
|
|
385
|
+
if not (url.endswith(".tar.gz") or url.endswith(".tgz")):
|
|
386
|
+
print(f"Error: URL must point to a .tar.gz or .tgz file: {url}")
|
|
387
|
+
sys.exit(1)
|
|
388
|
+
|
|
389
|
+
print(f"Downloading archive from: {url}")
|
|
390
|
+
|
|
391
|
+
try:
|
|
392
|
+
response = requests.get(url, timeout=60, stream=True)
|
|
393
|
+
response.raise_for_status()
|
|
394
|
+
|
|
395
|
+
# Create a temporary file to store the downloaded archive
|
|
396
|
+
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as temp_file:
|
|
397
|
+
temp_path = temp_file.name
|
|
398
|
+
|
|
399
|
+
# Download with progress indication
|
|
400
|
+
total_size = int(response.headers.get("content-length", 0))
|
|
401
|
+
downloaded_size = 0
|
|
402
|
+
|
|
403
|
+
for chunk in response.iter_content(chunk_size=8192):
|
|
404
|
+
if chunk:
|
|
405
|
+
temp_file.write(chunk)
|
|
406
|
+
downloaded_size += len(chunk)
|
|
407
|
+
if total_size > 0:
|
|
408
|
+
progress = (downloaded_size / total_size) * 100
|
|
409
|
+
print(
|
|
410
|
+
f"Downloaded: {downloaded_size / (1024*1024):.2f} MB ({progress:.1f}%)",
|
|
411
|
+
end="\r",
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
if total_size > 0:
|
|
415
|
+
print() # New line after progress
|
|
416
|
+
print(f"Download complete: {downloaded_size / (1024*1024):.2f} MB")
|
|
417
|
+
|
|
418
|
+
# Now view the downloaded file
|
|
419
|
+
try:
|
|
420
|
+
view_figure(temp_path, port=port)
|
|
421
|
+
finally:
|
|
422
|
+
# Clean up the temporary file after viewing
|
|
423
|
+
import os
|
|
424
|
+
|
|
425
|
+
try:
|
|
426
|
+
os.unlink(temp_path)
|
|
427
|
+
except Exception:
|
|
428
|
+
pass
|
|
429
|
+
|
|
430
|
+
except requests.exceptions.RequestException as e:
|
|
431
|
+
print(f"Error: Failed to download archive from {url}: {e}")
|
|
432
|
+
sys.exit(1)
|
|
433
|
+
|
|
434
|
+
|
|
252
435
|
def main():
|
|
253
436
|
"""Main CLI entry point"""
|
|
254
437
|
parser = argparse.ArgumentParser(
|
|
@@ -270,7 +453,7 @@ def main():
|
|
|
270
453
|
view_parser = subparsers.add_parser(
|
|
271
454
|
"view", help="Extract and serve a figure archive locally"
|
|
272
455
|
)
|
|
273
|
-
view_parser.add_argument("archive", help="Path to the tar.gz archive file")
|
|
456
|
+
view_parser.add_argument("archive", help="Path or URL to the tar.gz archive file")
|
|
274
457
|
view_parser.add_argument(
|
|
275
458
|
"--port", type=int, help="Port number to serve on (default: auto-select)"
|
|
276
459
|
)
|
|
@@ -312,14 +495,43 @@ def main():
|
|
|
312
495
|
"extensions", nargs="+", help="Extension package names to uninstall"
|
|
313
496
|
)
|
|
314
497
|
|
|
498
|
+
# Upload from URL command
|
|
499
|
+
upload_from_source_url_parser = subparsers.add_parser(
|
|
500
|
+
"upload-from-source-url",
|
|
501
|
+
help="Download a tar.gz/tgz file and upload it as a new figure",
|
|
502
|
+
)
|
|
503
|
+
upload_from_source_url_parser.add_argument(
|
|
504
|
+
"source_url",
|
|
505
|
+
help="URL to the tar.gz or tgz file (will be set as the figure's source URL)",
|
|
506
|
+
)
|
|
507
|
+
upload_from_source_url_parser.add_argument(
|
|
508
|
+
"--title", help="Optional title for the figure"
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
# Find by source URL command
|
|
512
|
+
find_by_source_url_parser = subparsers.add_parser(
|
|
513
|
+
"find-by-source-url", help="Get the figure URL for a given source URL"
|
|
514
|
+
)
|
|
515
|
+
find_by_source_url_parser.add_argument(
|
|
516
|
+
"source_url", help="The source URL to search for"
|
|
517
|
+
)
|
|
518
|
+
|
|
315
519
|
args = parser.parse_args()
|
|
316
520
|
|
|
317
521
|
if args.command == "download":
|
|
318
522
|
download_figure(args.figure_url, args.dest)
|
|
319
523
|
elif args.command == "view":
|
|
320
|
-
|
|
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
|
|
figpack/core/_bundle_utils.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import pathlib
|
|
3
3
|
import json
|
|
4
|
-
from typing import
|
|
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) ->
|
|
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
|
|
figpack/core/_file_handler.py
CHANGED
|
@@ -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,
|
|
190
|
+
def log_message(self, format, *args):
|
|
191
191
|
"""Override to suppress default logging (same as parent class)."""
|
|
192
192
|
pass
|
figpack/core/_save_figure.py
CHANGED
|
@@ -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
|
-
|
|
19
|
-
if (
|
|
20
|
-
|
|
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(
|
|
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
|
-
|
|
32
|
+
output_path_2.mkdir(parents=True, exist_ok=True)
|
|
33
33
|
prepare_figure_bundle(
|
|
34
|
-
view, str(
|
|
34
|
+
view, str(output_path_2), title=title, description=description
|
|
35
35
|
)
|
figpack/core/_server_manager.py
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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
|
|
figpack/core/_show_view.py
CHANGED
figpack/core/_upload_bundle.py
CHANGED
|
@@ -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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
figpack/core/_view_figure.py
CHANGED
|
@@ -34,10 +34,10 @@ def serve_files(
|
|
|
34
34
|
enable_file_upload: Whether to enable PUT requests for file uploads
|
|
35
35
|
max_file_size: Maximum file size in bytes for uploads (default 10MB)
|
|
36
36
|
"""
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if not
|
|
40
|
-
raise SystemExit(f"Directory not found: {
|
|
37
|
+
tmpdir_2 = pathlib.Path(tmpdir)
|
|
38
|
+
tmpdir_2 = tmpdir_2.resolve()
|
|
39
|
+
if not tmpdir_2.exists() or not tmpdir_2.is_dir():
|
|
40
|
+
raise SystemExit(f"Directory not found: {tmpdir_2}")
|
|
41
41
|
|
|
42
42
|
# Create a temporary server manager instance for this specific directory
|
|
43
43
|
# Note: We can't use the singleton ProcessServerManager here because it serves
|
|
@@ -56,29 +56,34 @@ def serve_files(
|
|
|
56
56
|
# Choose handler based on file upload requirement
|
|
57
57
|
if enable_file_upload:
|
|
58
58
|
|
|
59
|
-
def
|
|
59
|
+
def handler_factory_upload_enabled(*args, **kwargs):
|
|
60
60
|
return FileUploadCORSRequestHandler(
|
|
61
61
|
*args,
|
|
62
|
-
directory=str(
|
|
62
|
+
directory=str(tmpdir_2),
|
|
63
63
|
allow_origin=allow_origin,
|
|
64
64
|
enable_file_upload=True,
|
|
65
65
|
max_file_size=max_file_size,
|
|
66
66
|
**kwargs,
|
|
67
67
|
)
|
|
68
68
|
|
|
69
|
-
upload_status =
|
|
69
|
+
upload_status = (
|
|
70
|
+
" (file upload enabled)" if handler_factory_upload_enabled else ""
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
httpd = ThreadingHTTPServer(("0.0.0.0", port), handler_factory_upload_enabled) # type: ignore
|
|
70
74
|
else:
|
|
71
75
|
|
|
72
76
|
def handler_factory(*args, **kwargs):
|
|
73
77
|
return CORSRequestHandler(
|
|
74
|
-
*args, directory=str(
|
|
78
|
+
*args, directory=str(tmpdir_2), allow_origin=allow_origin, **kwargs
|
|
75
79
|
)
|
|
76
80
|
|
|
77
81
|
upload_status = ""
|
|
78
82
|
|
|
79
|
-
|
|
83
|
+
httpd = ThreadingHTTPServer(("0.0.0.0", port), handler_factory) # type: ignore
|
|
84
|
+
|
|
80
85
|
print(
|
|
81
|
-
f"Serving {
|
|
86
|
+
f"Serving {tmpdir_2} at http://localhost:{port} (CORS → {allow_origin}){upload_status}"
|
|
82
87
|
)
|
|
83
88
|
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
|
84
89
|
thread.start()
|