figpack 0.2.17__py3-none-any.whl → 0.2.40__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- figpack/__init__.py +1 -1
- figpack/cli.py +288 -2
- figpack/core/_bundle_utils.py +40 -7
- figpack/core/_file_handler.py +195 -0
- figpack/core/_save_figure.py +12 -8
- figpack/core/_server_manager.py +146 -7
- figpack/core/_show_view.py +2 -2
- figpack/core/_upload_bundle.py +63 -53
- figpack/core/_view_figure.py +48 -12
- figpack/core/_zarr_consolidate.py +185 -0
- figpack/core/extension_view.py +9 -5
- figpack/core/figpack_extension.py +1 -1
- figpack/core/figpack_view.py +52 -21
- figpack/core/zarr.py +2 -2
- figpack/extensions.py +356 -0
- figpack/figpack-figure-dist/assets/index-ST_DU17U.js +95 -0
- figpack/figpack-figure-dist/assets/index-V5m_wCvw.css +1 -0
- figpack/figpack-figure-dist/index.html +6 -2
- figpack/views/Box.py +4 -4
- figpack/views/CaptionedView.py +64 -0
- figpack/views/DataFrame.py +1 -1
- figpack/views/Gallery.py +2 -2
- figpack/views/Iframe.py +43 -0
- figpack/views/Image.py +2 -3
- figpack/views/Markdown.py +8 -4
- figpack/views/MatplotlibFigure.py +1 -1
- figpack/views/MountainLayout.py +72 -0
- figpack/views/MountainLayoutItem.py +50 -0
- figpack/views/MultiChannelTimeseries.py +1 -1
- figpack/views/PlotlyExtension/PlotlyExtension.py +14 -14
- figpack/views/Spectrogram.py +3 -1
- figpack/views/Splitter.py +3 -3
- figpack/views/TabLayout.py +2 -2
- figpack/views/TimeseriesGraph.py +113 -20
- figpack/views/__init__.py +4 -0
- {figpack-0.2.17.dist-info → figpack-0.2.40.dist-info}/METADATA +25 -1
- figpack-0.2.40.dist-info/RECORD +50 -0
- figpack/figpack-figure-dist/assets/index-DBwmtEpB.js +0 -91
- figpack/figpack-figure-dist/assets/index-DHWczh-Q.css +0 -1
- figpack-0.2.17.dist-info/RECORD +0 -43
- {figpack-0.2.17.dist-info → figpack-0.2.40.dist-info}/WHEEL +0 -0
- {figpack-0.2.17.dist-info → figpack-0.2.40.dist-info}/entry_points.txt +0 -0
- {figpack-0.2.17.dist-info → figpack-0.2.40.dist-info}/licenses/LICENSE +0 -0
- {figpack-0.2.17.dist-info → figpack-0.2.40.dist-info}/top_level.txt +0 -0
figpack/__init__.py
CHANGED
figpack/cli.py
CHANGED
|
@@ -18,6 +18,8 @@ import requests
|
|
|
18
18
|
from . import __version__
|
|
19
19
|
from .core._server_manager import CORSRequestHandler
|
|
20
20
|
from .core._view_figure import serve_files, view_figure
|
|
21
|
+
from .core._upload_bundle import _upload_bundle, get_figure_by_source_url
|
|
22
|
+
from .extensions import ExtensionManager
|
|
21
23
|
|
|
22
24
|
MAX_WORKERS_FOR_DOWNLOAD = 16
|
|
23
25
|
|
|
@@ -214,6 +216,222 @@ def download_figure(figure_url: str, dest_path: str) -> None:
|
|
|
214
216
|
print(f"Archive saved to: {dest_path}")
|
|
215
217
|
|
|
216
218
|
|
|
219
|
+
def handle_extensions_command(args):
|
|
220
|
+
"""Handle extensions subcommands"""
|
|
221
|
+
extension_manager = ExtensionManager()
|
|
222
|
+
|
|
223
|
+
if args.extensions_command == "list":
|
|
224
|
+
extension_manager.list_extensions()
|
|
225
|
+
elif args.extensions_command == "install":
|
|
226
|
+
if not args.extensions and not args.all:
|
|
227
|
+
print("Error: No extensions specified. Use extension names or --all flag.")
|
|
228
|
+
print("Example: figpack extensions install figpack_3d")
|
|
229
|
+
print(" figpack extensions install --all")
|
|
230
|
+
sys.exit(1)
|
|
231
|
+
|
|
232
|
+
success = extension_manager.install_extensions(
|
|
233
|
+
extensions=args.extensions, upgrade=args.upgrade, install_all=args.all
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
if not success:
|
|
237
|
+
sys.exit(1)
|
|
238
|
+
|
|
239
|
+
elif args.extensions_command == "uninstall":
|
|
240
|
+
success = extension_manager.uninstall_extensions(args.extensions)
|
|
241
|
+
|
|
242
|
+
if not success:
|
|
243
|
+
sys.exit(1)
|
|
244
|
+
else:
|
|
245
|
+
print("Available extension commands:")
|
|
246
|
+
print(" list - List available extensions and their status")
|
|
247
|
+
print(" install - Install or upgrade extension packages")
|
|
248
|
+
print(" uninstall - Uninstall extension packages")
|
|
249
|
+
print()
|
|
250
|
+
print("Use 'figpack extensions <command> --help' for more information.")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def handle_upload_from_source_url(args) -> None:
|
|
254
|
+
"""
|
|
255
|
+
Handle the upload-from-source-url command
|
|
256
|
+
|
|
257
|
+
Downloads a tar.gz/tgz file from a URL, extracts it, and uploads it as a new figure
|
|
258
|
+
with the source URL set.
|
|
259
|
+
"""
|
|
260
|
+
import os
|
|
261
|
+
|
|
262
|
+
source_url = args.source_url
|
|
263
|
+
title = args.title if hasattr(args, "title") else None
|
|
264
|
+
|
|
265
|
+
# Get API key from environment variable
|
|
266
|
+
api_key = os.environ.get("FIGPACK_API_KEY")
|
|
267
|
+
if not api_key:
|
|
268
|
+
print(
|
|
269
|
+
"Error: FIGPACK_API_KEY environment variable must be set to upload figures."
|
|
270
|
+
)
|
|
271
|
+
sys.exit(1)
|
|
272
|
+
|
|
273
|
+
# Validate URL format
|
|
274
|
+
if not (source_url.endswith(".tar.gz") or source_url.endswith(".tgz")):
|
|
275
|
+
print(f"Error: Source URL must point to a .tar.gz or .tgz file: {source_url}")
|
|
276
|
+
sys.exit(1)
|
|
277
|
+
|
|
278
|
+
print(f"Downloading archive from: {source_url}")
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
# Download the archive
|
|
282
|
+
response = requests.get(source_url, timeout=120, stream=True)
|
|
283
|
+
response.raise_for_status()
|
|
284
|
+
|
|
285
|
+
# Create temporary file for the archive
|
|
286
|
+
with tempfile.NamedTemporaryFile(
|
|
287
|
+
suffix=".tar.gz", delete=False
|
|
288
|
+
) as temp_archive:
|
|
289
|
+
archive_path = temp_archive.name
|
|
290
|
+
|
|
291
|
+
# Download with progress indication
|
|
292
|
+
total_size = int(response.headers.get("content-length", 0))
|
|
293
|
+
downloaded_size = 0
|
|
294
|
+
|
|
295
|
+
for chunk in response.iter_content(chunk_size=8192):
|
|
296
|
+
if chunk:
|
|
297
|
+
temp_archive.write(chunk)
|
|
298
|
+
downloaded_size += len(chunk)
|
|
299
|
+
if total_size > 0:
|
|
300
|
+
progress = (downloaded_size / total_size) * 100
|
|
301
|
+
print(
|
|
302
|
+
f"Downloaded: {downloaded_size / (1024*1024):.2f} MB ({progress:.1f}%)",
|
|
303
|
+
end="\r",
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
if total_size > 0:
|
|
307
|
+
print() # New line after progress
|
|
308
|
+
print(f"Download complete: {downloaded_size / (1024*1024):.2f} MB")
|
|
309
|
+
|
|
310
|
+
# Extract archive to temporary directory
|
|
311
|
+
print("Extracting archive...")
|
|
312
|
+
with tempfile.TemporaryDirectory() as extract_dir:
|
|
313
|
+
extract_path = pathlib.Path(extract_dir)
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
with tarfile.open(archive_path, "r:gz") as tar:
|
|
317
|
+
tar.extractall(path=extract_path)
|
|
318
|
+
print(f"Extracted to temporary directory")
|
|
319
|
+
except Exception as e:
|
|
320
|
+
print(f"Error: Failed to extract archive: {e}")
|
|
321
|
+
sys.exit(1)
|
|
322
|
+
finally:
|
|
323
|
+
# Clean up downloaded archive
|
|
324
|
+
import os
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
os.unlink(archive_path)
|
|
328
|
+
except Exception:
|
|
329
|
+
pass
|
|
330
|
+
|
|
331
|
+
# Upload the extracted files
|
|
332
|
+
print(f"Uploading figure with source URL: {source_url}")
|
|
333
|
+
try:
|
|
334
|
+
figure_url = _upload_bundle(
|
|
335
|
+
str(extract_path),
|
|
336
|
+
api_key=api_key,
|
|
337
|
+
title=title,
|
|
338
|
+
source_url=source_url,
|
|
339
|
+
)
|
|
340
|
+
print(f"\nFigure uploaded successfully!")
|
|
341
|
+
print(f"Figure URL: {figure_url}")
|
|
342
|
+
except Exception as e:
|
|
343
|
+
print(f"Error: Failed to upload figure: {e}")
|
|
344
|
+
sys.exit(1)
|
|
345
|
+
|
|
346
|
+
except requests.exceptions.RequestException as e:
|
|
347
|
+
print(f"Error: Failed to download archive from {source_url}: {e}")
|
|
348
|
+
sys.exit(1)
|
|
349
|
+
except Exception as e:
|
|
350
|
+
print(f"Error: {e}")
|
|
351
|
+
sys.exit(1)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def handle_find_by_source_url(args) -> None:
|
|
355
|
+
"""
|
|
356
|
+
Handle the find-by-source-url command
|
|
357
|
+
|
|
358
|
+
Queries the API for a figure URL by its source URL.
|
|
359
|
+
"""
|
|
360
|
+
source_url = args.source_url
|
|
361
|
+
|
|
362
|
+
print(f"Querying for figure with source URL: {source_url}")
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
figure_url = get_figure_by_source_url(source_url)
|
|
366
|
+
|
|
367
|
+
if figure_url:
|
|
368
|
+
print(f"Figure found: {figure_url}")
|
|
369
|
+
else:
|
|
370
|
+
print(f"No figure found with source URL: {source_url}")
|
|
371
|
+
sys.exit(1)
|
|
372
|
+
except Exception as e:
|
|
373
|
+
print(f"Error: {e}")
|
|
374
|
+
sys.exit(1)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def download_and_view_archive(url: str, port: int = None) -> None:
|
|
378
|
+
"""
|
|
379
|
+
Download a tar.gz/tgz archive from a URL and view it
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
url: URL to the tar.gz or tgz file
|
|
383
|
+
port: Optional port number to serve on
|
|
384
|
+
"""
|
|
385
|
+
if not (url.endswith(".tar.gz") or url.endswith(".tgz")):
|
|
386
|
+
print(f"Error: URL must point to a .tar.gz or .tgz file: {url}")
|
|
387
|
+
sys.exit(1)
|
|
388
|
+
|
|
389
|
+
print(f"Downloading archive from: {url}")
|
|
390
|
+
|
|
391
|
+
try:
|
|
392
|
+
response = requests.get(url, timeout=60, stream=True)
|
|
393
|
+
response.raise_for_status()
|
|
394
|
+
|
|
395
|
+
# Create a temporary file to store the downloaded archive
|
|
396
|
+
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as temp_file:
|
|
397
|
+
temp_path = temp_file.name
|
|
398
|
+
|
|
399
|
+
# Download with progress indication
|
|
400
|
+
total_size = int(response.headers.get("content-length", 0))
|
|
401
|
+
downloaded_size = 0
|
|
402
|
+
|
|
403
|
+
for chunk in response.iter_content(chunk_size=8192):
|
|
404
|
+
if chunk:
|
|
405
|
+
temp_file.write(chunk)
|
|
406
|
+
downloaded_size += len(chunk)
|
|
407
|
+
if total_size > 0:
|
|
408
|
+
progress = (downloaded_size / total_size) * 100
|
|
409
|
+
print(
|
|
410
|
+
f"Downloaded: {downloaded_size / (1024*1024):.2f} MB ({progress:.1f}%)",
|
|
411
|
+
end="\r",
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
if total_size > 0:
|
|
415
|
+
print() # New line after progress
|
|
416
|
+
print(f"Download complete: {downloaded_size / (1024*1024):.2f} MB")
|
|
417
|
+
|
|
418
|
+
# Now view the downloaded file
|
|
419
|
+
try:
|
|
420
|
+
view_figure(temp_path, port=port)
|
|
421
|
+
finally:
|
|
422
|
+
# Clean up the temporary file after viewing
|
|
423
|
+
import os
|
|
424
|
+
|
|
425
|
+
try:
|
|
426
|
+
os.unlink(temp_path)
|
|
427
|
+
except Exception:
|
|
428
|
+
pass
|
|
429
|
+
|
|
430
|
+
except requests.exceptions.RequestException as e:
|
|
431
|
+
print(f"Error: Failed to download archive from {url}: {e}")
|
|
432
|
+
sys.exit(1)
|
|
433
|
+
|
|
434
|
+
|
|
217
435
|
def main():
|
|
218
436
|
"""Main CLI entry point"""
|
|
219
437
|
parser = argparse.ArgumentParser(
|
|
@@ -235,17 +453,85 @@ def main():
|
|
|
235
453
|
view_parser = subparsers.add_parser(
|
|
236
454
|
"view", help="Extract and serve a figure archive locally"
|
|
237
455
|
)
|
|
238
|
-
view_parser.add_argument("archive", help="Path to the tar.gz archive file")
|
|
456
|
+
view_parser.add_argument("archive", help="Path or URL to the tar.gz archive file")
|
|
239
457
|
view_parser.add_argument(
|
|
240
458
|
"--port", type=int, help="Port number to serve on (default: auto-select)"
|
|
241
459
|
)
|
|
242
460
|
|
|
461
|
+
# Extensions command
|
|
462
|
+
extensions_parser = subparsers.add_parser(
|
|
463
|
+
"extensions", help="Manage figpack extension packages"
|
|
464
|
+
)
|
|
465
|
+
extensions_subparsers = extensions_parser.add_subparsers(
|
|
466
|
+
dest="extensions_command", help="Extension management commands"
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
# Extensions list subcommand
|
|
470
|
+
extensions_list_parser = extensions_subparsers.add_parser(
|
|
471
|
+
"list", help="List available extensions and their status"
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
# Extensions install subcommand
|
|
475
|
+
extensions_install_parser = extensions_subparsers.add_parser(
|
|
476
|
+
"install", help="Install or upgrade extension packages"
|
|
477
|
+
)
|
|
478
|
+
extensions_install_parser.add_argument(
|
|
479
|
+
"extensions",
|
|
480
|
+
nargs="*",
|
|
481
|
+
help="Extension package names to install (e.g., figpack_3d figpack_spike_sorting)",
|
|
482
|
+
)
|
|
483
|
+
extensions_install_parser.add_argument(
|
|
484
|
+
"--all", action="store_true", help="Install all available extensions"
|
|
485
|
+
)
|
|
486
|
+
extensions_install_parser.add_argument(
|
|
487
|
+
"--upgrade", action="store_true", help="Upgrade packages if already installed"
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
# Extensions uninstall subcommand
|
|
491
|
+
extensions_uninstall_parser = extensions_subparsers.add_parser(
|
|
492
|
+
"uninstall", help="Uninstall extension packages"
|
|
493
|
+
)
|
|
494
|
+
extensions_uninstall_parser.add_argument(
|
|
495
|
+
"extensions", nargs="+", help="Extension package names to uninstall"
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
# Upload from URL command
|
|
499
|
+
upload_from_source_url_parser = subparsers.add_parser(
|
|
500
|
+
"upload-from-source-url",
|
|
501
|
+
help="Download a tar.gz/tgz file and upload it as a new figure",
|
|
502
|
+
)
|
|
503
|
+
upload_from_source_url_parser.add_argument(
|
|
504
|
+
"source_url",
|
|
505
|
+
help="URL to the tar.gz or tgz file (will be set as the figure's source URL)",
|
|
506
|
+
)
|
|
507
|
+
upload_from_source_url_parser.add_argument(
|
|
508
|
+
"--title", help="Optional title for the figure"
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
# Find by source URL command
|
|
512
|
+
find_by_source_url_parser = subparsers.add_parser(
|
|
513
|
+
"find-by-source-url", help="Get the figure URL for a given source URL"
|
|
514
|
+
)
|
|
515
|
+
find_by_source_url_parser.add_argument(
|
|
516
|
+
"source_url", help="The source URL to search for"
|
|
517
|
+
)
|
|
518
|
+
|
|
243
519
|
args = parser.parse_args()
|
|
244
520
|
|
|
245
521
|
if args.command == "download":
|
|
246
522
|
download_figure(args.figure_url, args.dest)
|
|
247
523
|
elif args.command == "view":
|
|
248
|
-
|
|
524
|
+
# Check if archive argument is a URL
|
|
525
|
+
if args.archive.startswith("http://") or args.archive.startswith("https://"):
|
|
526
|
+
download_and_view_archive(args.archive, port=args.port)
|
|
527
|
+
else:
|
|
528
|
+
view_figure(args.archive, port=args.port)
|
|
529
|
+
elif args.command == "extensions":
|
|
530
|
+
handle_extensions_command(args)
|
|
531
|
+
elif args.command == "upload-from-source-url":
|
|
532
|
+
handle_upload_from_source_url(args)
|
|
533
|
+
elif args.command == "find-by-source-url":
|
|
534
|
+
handle_find_by_source_url(args)
|
|
249
535
|
else:
|
|
250
536
|
parser.print_help()
|
|
251
537
|
|
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
|
|
|
@@ -9,12 +9,13 @@ from .figpack_view import FigpackView
|
|
|
9
9
|
from .figpack_extension import FigpackExtension
|
|
10
10
|
from .extension_view import ExtensionView
|
|
11
11
|
from .zarr import Group, _check_zarr_version
|
|
12
|
+
from ._zarr_consolidate import consolidate_zarr_chunks
|
|
12
13
|
|
|
13
14
|
thisdir = pathlib.Path(__file__).parent.resolve()
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
def prepare_figure_bundle(
|
|
17
|
-
view: FigpackView, tmpdir: str, *, title: str, description: str = None
|
|
18
|
+
view: FigpackView, tmpdir: str, *, title: str, description: Optional[str] = None
|
|
18
19
|
) -> None:
|
|
19
20
|
"""
|
|
20
21
|
Prepare a figure bundle in the specified temporary directory.
|
|
@@ -51,14 +52,14 @@ def prepare_figure_bundle(
|
|
|
51
52
|
# because we only support version 2 on the frontend right now.
|
|
52
53
|
|
|
53
54
|
if _check_zarr_version() == 3:
|
|
54
|
-
old_default_zarr_format = zarr.config.get("default_zarr_format")
|
|
55
|
-
zarr.config.set({"default_zarr_format": 2})
|
|
55
|
+
old_default_zarr_format = zarr.config.get("default_zarr_format") # type: ignore
|
|
56
|
+
zarr.config.set({"default_zarr_format": 2}) # type: ignore
|
|
56
57
|
|
|
57
58
|
try:
|
|
58
59
|
# Write the view data to the Zarr group
|
|
59
60
|
zarr_group = zarr.open_group(pathlib.Path(tmpdir) / "data.zarr", mode="w")
|
|
60
61
|
zarr_group = Group(zarr_group)
|
|
61
|
-
view.
|
|
62
|
+
view.write_to_zarr_group(zarr_group)
|
|
62
63
|
|
|
63
64
|
# Add title and description as attributes on the top-level zarr group
|
|
64
65
|
zarr_group.attrs["title"] = title
|
|
@@ -72,13 +73,45 @@ def prepare_figure_bundle(
|
|
|
72
73
|
# Generate extension manifest
|
|
73
74
|
_write_extension_manifest(required_extensions, tmpdir)
|
|
74
75
|
|
|
76
|
+
# Create the .zmetadata file
|
|
75
77
|
zarr.consolidate_metadata(zarr_group._zarr_group.store)
|
|
78
|
+
|
|
79
|
+
# It's important that we remove all the metadata files except for the
|
|
80
|
+
# consolidated one so there is a single source of truth.
|
|
81
|
+
_remove_metadata_files_except_consolidated(pathlib.Path(tmpdir) / "data.zarr")
|
|
82
|
+
|
|
83
|
+
# Consolidate zarr chunks into larger files to reduce upload count
|
|
84
|
+
consolidate_zarr_chunks(pathlib.Path(tmpdir) / "data.zarr")
|
|
76
85
|
finally:
|
|
77
86
|
if _check_zarr_version() == 3:
|
|
78
|
-
zarr.config.set({"default_zarr_format": old_default_zarr_format})
|
|
87
|
+
zarr.config.set({"default_zarr_format": old_default_zarr_format}) # type: ignore
|
|
88
|
+
|
|
79
89
|
|
|
90
|
+
def _remove_metadata_files_except_consolidated(zarr_dir: pathlib.Path) -> None:
|
|
91
|
+
"""
|
|
92
|
+
Remove all zarr metadata files except for the consolidated one.
|
|
80
93
|
|
|
81
|
-
|
|
94
|
+
Args:
|
|
95
|
+
zarr_dir: Path to the zarr directory
|
|
96
|
+
"""
|
|
97
|
+
if not zarr_dir.is_dir():
|
|
98
|
+
raise ValueError(f"Expected a directory, got: {zarr_dir}")
|
|
99
|
+
|
|
100
|
+
for root, dirs, files in os.walk(zarr_dir):
|
|
101
|
+
for file in files:
|
|
102
|
+
if (
|
|
103
|
+
file.endswith(".zarray")
|
|
104
|
+
or file.endswith(".zgroup")
|
|
105
|
+
or file.endswith(".zattrs")
|
|
106
|
+
):
|
|
107
|
+
file_path = pathlib.Path(root) / file
|
|
108
|
+
try:
|
|
109
|
+
file_path.unlink()
|
|
110
|
+
except Exception as e:
|
|
111
|
+
print(f"Warning: could not remove file {file_path}: {e}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _discover_required_extensions(view: FigpackView) -> List[str]:
|
|
82
115
|
"""
|
|
83
116
|
Recursively discover all extensions required by a view and its children
|
|
84
117
|
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import pathlib
|
|
3
|
+
import urllib.parse
|
|
4
|
+
from http.server import SimpleHTTPRequestHandler
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ._server_manager import CORSRequestHandler
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FileUploadCORSRequestHandler(CORSRequestHandler):
|
|
11
|
+
"""
|
|
12
|
+
Extended CORS request handler that supports PUT requests for file uploads.
|
|
13
|
+
Only allows file operations within the served directory.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
*args,
|
|
19
|
+
allow_origin=None,
|
|
20
|
+
enable_file_upload=False,
|
|
21
|
+
max_file_size=10 * 1024 * 1024,
|
|
22
|
+
**kwargs,
|
|
23
|
+
):
|
|
24
|
+
self.enable_file_upload = enable_file_upload
|
|
25
|
+
self.max_file_size = max_file_size # Default 10MB
|
|
26
|
+
super().__init__(*args, allow_origin=allow_origin, **kwargs)
|
|
27
|
+
|
|
28
|
+
def end_headers(self):
|
|
29
|
+
if self.allow_origin is not None:
|
|
30
|
+
self.send_header("Access-Control-Allow-Origin", self.allow_origin)
|
|
31
|
+
self.send_header("Vary", "Origin")
|
|
32
|
+
# Add PUT to allowed methods if file upload is enabled
|
|
33
|
+
methods = "GET, HEAD, OPTIONS"
|
|
34
|
+
if self.enable_file_upload:
|
|
35
|
+
methods += ", PUT"
|
|
36
|
+
self.send_header("Access-Control-Allow-Methods", methods)
|
|
37
|
+
self.send_header(
|
|
38
|
+
"Access-Control-Allow-Headers", "Content-Type, Range, Content-Length"
|
|
39
|
+
)
|
|
40
|
+
self.send_header(
|
|
41
|
+
"Access-Control-Expose-Headers",
|
|
42
|
+
"Accept-Ranges, Content-Encoding, Content-Length, Content-Range",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Always send Accept-Ranges header to indicate byte-range support
|
|
46
|
+
self.send_header("Accept-Ranges", "bytes")
|
|
47
|
+
|
|
48
|
+
# Prevent browser caching - important for when we are editing figures in place
|
|
49
|
+
# This ensures the browser always fetches the latest version of files
|
|
50
|
+
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
51
|
+
self.send_header("Pragma", "no-cache")
|
|
52
|
+
self.send_header("Expires", "0")
|
|
53
|
+
|
|
54
|
+
super(SimpleHTTPRequestHandler, self).end_headers()
|
|
55
|
+
|
|
56
|
+
def do_PUT(self):
|
|
57
|
+
"""Handle PUT requests for file uploads."""
|
|
58
|
+
if not self.enable_file_upload:
|
|
59
|
+
self.send_error(405, "Method Not Allowed")
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
# Parse and validate the path
|
|
64
|
+
file_path = self._get_safe_file_path()
|
|
65
|
+
if file_path is None:
|
|
66
|
+
return # Error already sent
|
|
67
|
+
|
|
68
|
+
# Check content length
|
|
69
|
+
content_length = self._get_content_length()
|
|
70
|
+
if content_length is None:
|
|
71
|
+
return # Error already sent
|
|
72
|
+
|
|
73
|
+
# Determine if this will be a create or update
|
|
74
|
+
is_new_file = not file_path.exists()
|
|
75
|
+
|
|
76
|
+
# Read and write the file
|
|
77
|
+
if self._write_file_content(file_path, content_length):
|
|
78
|
+
# Send appropriate status code
|
|
79
|
+
status_code = 201 if is_new_file else 200
|
|
80
|
+
self.send_response(status_code)
|
|
81
|
+
self.send_header("Content-Type", "application/json")
|
|
82
|
+
self.end_headers()
|
|
83
|
+
|
|
84
|
+
response_data = f'{{"status": "success", "path": "{file_path.relative_to(pathlib.Path(self.directory))}"}}'
|
|
85
|
+
self.wfile.write(response_data.encode("utf-8"))
|
|
86
|
+
|
|
87
|
+
except Exception as e:
|
|
88
|
+
self.log_error(f"Error in PUT request: {e}")
|
|
89
|
+
self.send_error(500, f"Internal Server Error: {str(e)}")
|
|
90
|
+
|
|
91
|
+
def _get_safe_file_path(self) -> Optional[pathlib.Path]:
|
|
92
|
+
"""
|
|
93
|
+
Parse and validate the requested file path.
|
|
94
|
+
Returns None if the path is invalid or unsafe.
|
|
95
|
+
"""
|
|
96
|
+
# Parse the URL path
|
|
97
|
+
parsed_path = urllib.parse.urlparse(self.path).path
|
|
98
|
+
|
|
99
|
+
# Remove leading slash and decode URL encoding
|
|
100
|
+
relative_path = urllib.parse.unquote(parsed_path.lstrip("/"))
|
|
101
|
+
|
|
102
|
+
# Prevent empty paths
|
|
103
|
+
if not relative_path:
|
|
104
|
+
self.send_error(400, "Bad Request: Empty file path")
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
# Get the served directory
|
|
108
|
+
served_dir = pathlib.Path(self.directory).resolve()
|
|
109
|
+
|
|
110
|
+
# Construct the target file path
|
|
111
|
+
target_path = served_dir / relative_path
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
# Resolve the path to handle any .. or . components
|
|
115
|
+
resolved_path = target_path.resolve()
|
|
116
|
+
|
|
117
|
+
# Ensure the resolved path is within the served directory
|
|
118
|
+
if not str(resolved_path).startswith(str(served_dir)):
|
|
119
|
+
self.send_error(403, "Forbidden: Path outside served directory")
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
except (OSError, ValueError) as e:
|
|
123
|
+
self.send_error(400, f"Bad Request: Invalid path - {str(e)}")
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
return resolved_path
|
|
127
|
+
|
|
128
|
+
def _get_content_length(self) -> Optional[int]:
|
|
129
|
+
"""
|
|
130
|
+
Get and validate the content length from headers.
|
|
131
|
+
Returns None if invalid or too large.
|
|
132
|
+
"""
|
|
133
|
+
content_length_header = self.headers.get("Content-Length")
|
|
134
|
+
if not content_length_header:
|
|
135
|
+
self.send_error(400, "Bad Request: Content-Length header required")
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
content_length = int(content_length_header)
|
|
140
|
+
except ValueError:
|
|
141
|
+
self.send_error(400, "Bad Request: Invalid Content-Length")
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
if content_length < 0:
|
|
145
|
+
self.send_error(400, "Bad Request: Negative Content-Length")
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
if content_length > self.max_file_size:
|
|
149
|
+
self.send_error(
|
|
150
|
+
413,
|
|
151
|
+
f"Payload Too Large: Maximum file size is {self.max_file_size} bytes",
|
|
152
|
+
)
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
return content_length
|
|
156
|
+
|
|
157
|
+
def _write_file_content(self, file_path: pathlib.Path, content_length: int) -> bool:
|
|
158
|
+
"""
|
|
159
|
+
Write the request body content to the specified file.
|
|
160
|
+
Returns True on success, False on failure (error already sent).
|
|
161
|
+
"""
|
|
162
|
+
try:
|
|
163
|
+
# Create parent directories if they don't exist
|
|
164
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
|
|
166
|
+
# Write the file content
|
|
167
|
+
with open(file_path, "wb") as f:
|
|
168
|
+
remaining = content_length
|
|
169
|
+
while remaining > 0:
|
|
170
|
+
# Read in chunks to handle large files efficiently
|
|
171
|
+
chunk_size = min(8192, remaining)
|
|
172
|
+
chunk = self.rfile.read(chunk_size)
|
|
173
|
+
|
|
174
|
+
if not chunk:
|
|
175
|
+
# Unexpected end of data
|
|
176
|
+
self.send_error(400, "Bad Request: Incomplete data")
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
f.write(chunk)
|
|
180
|
+
remaining -= len(chunk)
|
|
181
|
+
|
|
182
|
+
return True
|
|
183
|
+
|
|
184
|
+
except OSError as e:
|
|
185
|
+
self.send_error(
|
|
186
|
+
500, f"Internal Server Error: Could not write file - {str(e)}"
|
|
187
|
+
)
|
|
188
|
+
return False
|
|
189
|
+
except Exception as e:
|
|
190
|
+
self.send_error(500, f"Internal Server Error: {str(e)}")
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
def log_message(self, format, *args):
|
|
194
|
+
"""Override to suppress default logging (same as parent class)."""
|
|
195
|
+
pass
|
figpack/core/_save_figure.py
CHANGED
|
@@ -5,7 +5,9 @@ from ._bundle_utils import prepare_figure_bundle
|
|
|
5
5
|
from .figpack_view import FigpackView
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
def _save_figure(
|
|
8
|
+
def _save_figure(
|
|
9
|
+
view: FigpackView, output_path: str, *, title: str, description: str = ""
|
|
10
|
+
) -> None:
|
|
9
11
|
"""
|
|
10
12
|
Save the figure to a folder or a .tar.gz file
|
|
11
13
|
|
|
@@ -13,19 +15,21 @@ def _save_figure(view: FigpackView, output_path: str, *, title: str):
|
|
|
13
15
|
view: FigpackView instance to save
|
|
14
16
|
output_path: Output path (destination folder or .tar.gz file path)
|
|
15
17
|
"""
|
|
16
|
-
|
|
17
|
-
if (
|
|
18
|
-
|
|
18
|
+
output_path_2 = pathlib.Path(output_path)
|
|
19
|
+
if (output_path_2.suffix == ".gz" and output_path_2.suffixes[-2] == ".tar") or (
|
|
20
|
+
output_path_2.suffix == ".tgz"
|
|
19
21
|
):
|
|
20
22
|
# It's a .tar.gz file
|
|
21
23
|
with tempfile.TemporaryDirectory(prefix="figpack_save_") as tmpdir:
|
|
22
|
-
prepare_figure_bundle(view, tmpdir, title=title)
|
|
24
|
+
prepare_figure_bundle(view, tmpdir, title=title, description=description)
|
|
23
25
|
# Create tar.gz file
|
|
24
26
|
import tarfile
|
|
25
27
|
|
|
26
|
-
with tarfile.open(
|
|
28
|
+
with tarfile.open(output_path_2, "w:gz") as tar:
|
|
27
29
|
tar.add(tmpdir, arcname=".")
|
|
28
30
|
else:
|
|
29
31
|
# It's a folder
|
|
30
|
-
|
|
31
|
-
prepare_figure_bundle(
|
|
32
|
+
output_path_2.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
prepare_figure_bundle(
|
|
34
|
+
view, str(output_path_2), title=title, description=description
|
|
35
|
+
)
|