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/core/_server_manager.py
CHANGED
|
@@ -28,13 +28,120 @@ class CORSRequestHandler(SimpleHTTPRequestHandler):
|
|
|
28
28
|
"Access-Control-Expose-Headers",
|
|
29
29
|
"Accept-Ranges, Content-Encoding, Content-Length, Content-Range",
|
|
30
30
|
)
|
|
31
|
+
|
|
32
|
+
# Always send Accept-Ranges header to indicate byte-range support
|
|
33
|
+
self.send_header("Accept-Ranges", "bytes")
|
|
34
|
+
|
|
35
|
+
# Prevent browser caching - important for when we are editing figures in place
|
|
36
|
+
# This ensures the browser always fetches the latest version of files
|
|
37
|
+
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
38
|
+
self.send_header("Pragma", "no-cache")
|
|
39
|
+
self.send_header("Expires", "0")
|
|
40
|
+
|
|
31
41
|
super().end_headers()
|
|
32
42
|
|
|
33
43
|
def do_OPTIONS(self):
|
|
34
44
|
self.send_response(204, "No Content")
|
|
35
45
|
self.end_headers()
|
|
36
46
|
|
|
37
|
-
def
|
|
47
|
+
def do_PUT(self):
|
|
48
|
+
"""Reject PUT requests when file upload is not enabled."""
|
|
49
|
+
self.send_error(405, "Method Not Allowed")
|
|
50
|
+
|
|
51
|
+
def do_GET(self):
|
|
52
|
+
"""Handle GET requests with support for Range requests."""
|
|
53
|
+
# Translate path and check if file exists
|
|
54
|
+
path = self.translate_path(self.path)
|
|
55
|
+
|
|
56
|
+
# Check if path is a file
|
|
57
|
+
if not os.path.isfile(path):
|
|
58
|
+
# Let parent class handle directories and 404s
|
|
59
|
+
return super().do_GET()
|
|
60
|
+
|
|
61
|
+
# Check for Range header
|
|
62
|
+
range_header = self.headers.get("Range")
|
|
63
|
+
|
|
64
|
+
if range_header is None:
|
|
65
|
+
# No range request, use parent's implementation
|
|
66
|
+
return super().do_GET()
|
|
67
|
+
|
|
68
|
+
# Parse range header
|
|
69
|
+
try:
|
|
70
|
+
# Range header format: "bytes=start-end"
|
|
71
|
+
if not range_header.startswith("bytes="):
|
|
72
|
+
# Invalid range format, ignore and serve full file
|
|
73
|
+
return super().do_GET()
|
|
74
|
+
|
|
75
|
+
range_spec = range_header[6:] # Remove "bytes=" prefix
|
|
76
|
+
|
|
77
|
+
# Get file size
|
|
78
|
+
file_size = os.path.getsize(path)
|
|
79
|
+
|
|
80
|
+
# Parse range specification
|
|
81
|
+
if "-" not in range_spec:
|
|
82
|
+
# Invalid format
|
|
83
|
+
self.send_error(400, "Invalid Range header")
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
range_parts = range_spec.split("-", 1)
|
|
87
|
+
|
|
88
|
+
# Determine start and end positions
|
|
89
|
+
if range_parts[0]: # Start position specified
|
|
90
|
+
start = int(range_parts[0])
|
|
91
|
+
if range_parts[1]: # End position also specified
|
|
92
|
+
end = int(range_parts[1])
|
|
93
|
+
else: # Open-ended range (e.g., "1024-")
|
|
94
|
+
end = file_size - 1
|
|
95
|
+
else: # Suffix range (e.g., "-500" means last 500 bytes)
|
|
96
|
+
if not range_parts[1]:
|
|
97
|
+
self.send_error(400, "Invalid Range header")
|
|
98
|
+
return
|
|
99
|
+
suffix_length = int(range_parts[1])
|
|
100
|
+
start = max(0, file_size - suffix_length)
|
|
101
|
+
end = file_size - 1
|
|
102
|
+
|
|
103
|
+
# Validate range
|
|
104
|
+
if start < 0 or end >= file_size or start > end:
|
|
105
|
+
self.send_response(416, "Range Not Satisfiable")
|
|
106
|
+
self.send_header("Content-Range", f"bytes */{file_size}")
|
|
107
|
+
self.end_headers()
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
# Calculate content length
|
|
111
|
+
content_length = end - start + 1
|
|
112
|
+
|
|
113
|
+
# Guess content type
|
|
114
|
+
import mimetypes
|
|
115
|
+
|
|
116
|
+
content_type = mimetypes.guess_type(path)[0] or "application/octet-stream"
|
|
117
|
+
|
|
118
|
+
# Send 206 Partial Content response
|
|
119
|
+
self.send_response(206, "Partial Content")
|
|
120
|
+
self.send_header("Content-Type", content_type)
|
|
121
|
+
self.send_header("Content-Length", str(content_length))
|
|
122
|
+
self.send_header("Content-Range", f"bytes {start}-{end}/{file_size}")
|
|
123
|
+
self.end_headers()
|
|
124
|
+
|
|
125
|
+
# Send the requested byte range
|
|
126
|
+
with open(path, "rb") as f:
|
|
127
|
+
f.seek(start)
|
|
128
|
+
remaining = content_length
|
|
129
|
+
while remaining > 0:
|
|
130
|
+
chunk_size = min(8192, remaining)
|
|
131
|
+
chunk = f.read(chunk_size)
|
|
132
|
+
if not chunk:
|
|
133
|
+
break
|
|
134
|
+
self.wfile.write(chunk)
|
|
135
|
+
remaining -= len(chunk)
|
|
136
|
+
|
|
137
|
+
except ValueError:
|
|
138
|
+
# Invalid range values
|
|
139
|
+
self.send_error(400, "Invalid Range header")
|
|
140
|
+
except Exception as e:
|
|
141
|
+
# Log error and return 500
|
|
142
|
+
self.send_error(500, f"Internal Server Error: {str(e)}")
|
|
143
|
+
|
|
144
|
+
def log_message(self, format, *args):
|
|
38
145
|
pass
|
|
39
146
|
|
|
40
147
|
|
|
@@ -153,11 +260,21 @@ class ProcessServerManager:
|
|
|
153
260
|
return figure_dir
|
|
154
261
|
|
|
155
262
|
def start_server(
|
|
156
|
-
self,
|
|
263
|
+
self,
|
|
264
|
+
port: Optional[int] = None,
|
|
265
|
+
allow_origin: Optional[str] = None,
|
|
266
|
+
enable_file_upload: bool = False,
|
|
267
|
+
max_file_size: int = 10 * 1024 * 1024,
|
|
157
268
|
) -> tuple[str, int]:
|
|
158
269
|
"""
|
|
159
270
|
Start the server if not already running, or return existing server info.
|
|
160
271
|
|
|
272
|
+
Args:
|
|
273
|
+
port: Port to bind to (auto-selected if None)
|
|
274
|
+
allow_origin: CORS origin to allow (None for no CORS)
|
|
275
|
+
enable_file_upload: Whether to enable PUT requests for file uploads
|
|
276
|
+
max_file_size: Maximum file size in bytes for uploads (default 10MB)
|
|
277
|
+
|
|
161
278
|
Returns:
|
|
162
279
|
tuple: (base_url, port)
|
|
163
280
|
"""
|
|
@@ -168,6 +285,7 @@ class ProcessServerManager:
|
|
|
168
285
|
and self._server_thread.is_alive()
|
|
169
286
|
and (allow_origin is None or self._allow_origin == allow_origin)
|
|
170
287
|
):
|
|
288
|
+
assert self._port is not None
|
|
171
289
|
return f"http://localhost:{self._port}", self._port
|
|
172
290
|
|
|
173
291
|
# Stop existing server if settings are incompatible
|
|
@@ -184,13 +302,34 @@ class ProcessServerManager:
|
|
|
184
302
|
|
|
185
303
|
temp_dir = self.get_temp_dir()
|
|
186
304
|
|
|
187
|
-
#
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
305
|
+
# Choose handler based on file upload requirement
|
|
306
|
+
if enable_file_upload:
|
|
307
|
+
from ._file_handler import FileUploadCORSRequestHandler
|
|
308
|
+
|
|
309
|
+
def handler_factory_enable_upload(*args, **kwargs):
|
|
310
|
+
return FileUploadCORSRequestHandler(
|
|
311
|
+
*args,
|
|
312
|
+
directory=str(temp_dir),
|
|
313
|
+
allow_origin=allow_origin,
|
|
314
|
+
enable_file_upload=True,
|
|
315
|
+
max_file_size=max_file_size,
|
|
316
|
+
**kwargs,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
assert port is not None
|
|
320
|
+
self._server = ThreadingHTTPServer(
|
|
321
|
+
("0.0.0.0", port), handler_factory_enable_upload
|
|
191
322
|
)
|
|
192
323
|
|
|
193
|
-
|
|
324
|
+
else:
|
|
325
|
+
|
|
326
|
+
def handler_factory(*args, **kwargs):
|
|
327
|
+
return CORSRequestHandler(
|
|
328
|
+
*args, directory=str(temp_dir), allow_origin=allow_origin, **kwargs
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
assert port is not None
|
|
332
|
+
self._server = ThreadingHTTPServer(("0.0.0.0", port), handler_factory)
|
|
194
333
|
self._port = port
|
|
195
334
|
self._allow_origin = allow_origin
|
|
196
335
|
|
figpack/core/_show_view.py
CHANGED
|
@@ -19,7 +19,7 @@ def _is_in_notebook() -> bool:
|
|
|
19
19
|
"""
|
|
20
20
|
try:
|
|
21
21
|
# Check if IPython is available and we're in a notebook
|
|
22
|
-
from IPython import get_ipython
|
|
22
|
+
from IPython import get_ipython # type: ignore
|
|
23
23
|
|
|
24
24
|
ipython = get_ipython()
|
|
25
25
|
if ipython is None:
|
|
@@ -148,7 +148,7 @@ def _show_view(
|
|
|
148
148
|
|
|
149
149
|
# Start or get existing server
|
|
150
150
|
base_url, server_port = server_manager.start_server(
|
|
151
|
-
port=port, allow_origin=allow_origin
|
|
151
|
+
port=port, allow_origin=allow_origin, enable_file_upload=True
|
|
152
152
|
)
|
|
153
153
|
|
|
154
154
|
# Construct URL to the specific figure subdirectory
|
figpack/core/_upload_bundle.py
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
+
from typing import Optional, Union
|
|
1
2
|
import hashlib
|
|
2
3
|
import json
|
|
3
4
|
import pathlib
|
|
4
5
|
import threading
|
|
5
6
|
import time
|
|
6
|
-
import uuid
|
|
7
7
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
8
|
-
from datetime import datetime, timedelta, timezone
|
|
9
8
|
|
|
10
9
|
import requests
|
|
11
10
|
|
|
@@ -116,62 +115,31 @@ def _upload_single_file_with_signed_url(
|
|
|
116
115
|
else:
|
|
117
116
|
break
|
|
118
117
|
|
|
118
|
+
assert last_exception is not None
|
|
119
119
|
raise last_exception
|
|
120
120
|
|
|
121
121
|
|
|
122
122
|
MAX_WORKERS_FOR_UPLOAD = 16
|
|
123
123
|
|
|
124
124
|
|
|
125
|
-
def _compute_deterministic_figure_hash(tmpdir_path: pathlib.Path) -> str:
|
|
126
|
-
"""
|
|
127
|
-
Compute a deterministic figure ID based on SHA1 hashes of all files
|
|
128
|
-
|
|
129
|
-
Returns:
|
|
130
|
-
str: 40-character SHA1 hash representing the content of all files
|
|
131
|
-
"""
|
|
132
|
-
file_hashes = []
|
|
133
|
-
|
|
134
|
-
# Collect all files and their hashes
|
|
135
|
-
for file_path in sorted(tmpdir_path.rglob("*")):
|
|
136
|
-
if file_path.is_file():
|
|
137
|
-
relative_path = file_path.relative_to(tmpdir_path)
|
|
138
|
-
|
|
139
|
-
# Compute SHA1 hash of file content
|
|
140
|
-
sha1_hash = hashlib.sha1()
|
|
141
|
-
with open(file_path, "rb") as f:
|
|
142
|
-
for chunk in iter(lambda: f.read(4096), b""):
|
|
143
|
-
sha1_hash.update(chunk)
|
|
144
|
-
|
|
145
|
-
# Include both the relative path and content hash to ensure uniqueness
|
|
146
|
-
file_info = f"{relative_path}:{sha1_hash.hexdigest()}"
|
|
147
|
-
file_hashes.append(file_info)
|
|
148
|
-
|
|
149
|
-
# Create final hash from all file hashes
|
|
150
|
-
combined_hash = hashlib.sha1()
|
|
151
|
-
for file_hash in file_hashes:
|
|
152
|
-
combined_hash.update(file_hash.encode("utf-8"))
|
|
153
|
-
|
|
154
|
-
return combined_hash.hexdigest()
|
|
155
|
-
|
|
156
|
-
|
|
157
125
|
def _create_or_get_figure(
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
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,
|
|
163
130
|
ephemeral: bool = False,
|
|
131
|
+
source_url: Optional[str] = None,
|
|
164
132
|
) -> dict:
|
|
165
133
|
"""
|
|
166
134
|
Create a new figure or get existing figure information
|
|
167
135
|
|
|
168
136
|
Args:
|
|
169
|
-
figure_hash: The hash of the figure
|
|
170
137
|
api_key: The API key for authentication (required for non-ephemeral)
|
|
171
138
|
total_files: Optional total number of files
|
|
172
139
|
total_size: Optional total size of files
|
|
173
140
|
title: Optional title for the figure
|
|
174
141
|
ephemeral: Whether to create an ephemeral figure
|
|
142
|
+
source_url: Optional source URL for the figure (must be unique)
|
|
175
143
|
|
|
176
144
|
Returns:
|
|
177
145
|
dict: Figure information from the API
|
|
@@ -180,8 +148,7 @@ def _create_or_get_figure(
|
|
|
180
148
|
if not ephemeral and api_key is None:
|
|
181
149
|
raise ValueError("API key is required for non-ephemeral figures")
|
|
182
150
|
|
|
183
|
-
payload = {
|
|
184
|
-
"figureHash": figure_hash,
|
|
151
|
+
payload: dict[str, Union[str, int]] = {
|
|
185
152
|
"figpackVersion": __version__,
|
|
186
153
|
"bucket": FIGPACK_BUCKET,
|
|
187
154
|
}
|
|
@@ -198,6 +165,8 @@ def _create_or_get_figure(
|
|
|
198
165
|
payload["title"] = title
|
|
199
166
|
if ephemeral:
|
|
200
167
|
payload["ephemeral"] = True
|
|
168
|
+
if source_url is not None:
|
|
169
|
+
payload["sourceUrl"] = source_url
|
|
201
170
|
|
|
202
171
|
# Use the same endpoint for both regular and ephemeral figures
|
|
203
172
|
response = requests.post(f"{FIGPACK_API_BASE_URL}/api/figures/create", json=payload)
|
|
@@ -208,12 +177,12 @@ def _create_or_get_figure(
|
|
|
208
177
|
error_msg = error_data.get("message", "Unknown error")
|
|
209
178
|
except:
|
|
210
179
|
error_msg = f"HTTP {response.status_code}"
|
|
211
|
-
raise Exception(f"Failed to create figure
|
|
180
|
+
raise Exception(f"Failed to create figure: {error_msg}")
|
|
212
181
|
|
|
213
182
|
response_data = response.json()
|
|
214
183
|
if not response_data.get("success"):
|
|
215
184
|
raise Exception(
|
|
216
|
-
f"Failed to create figure
|
|
185
|
+
f"Failed to create figure: {response_data.get('message', 'Unknown error')}"
|
|
217
186
|
)
|
|
218
187
|
|
|
219
188
|
return response_data
|
|
@@ -254,10 +223,11 @@ def _finalize_figure(figure_url: str, api_key: str) -> dict:
|
|
|
254
223
|
|
|
255
224
|
def _upload_bundle(
|
|
256
225
|
tmpdir: str,
|
|
257
|
-
api_key: str,
|
|
258
|
-
title: str = None,
|
|
226
|
+
api_key: Optional[str],
|
|
227
|
+
title: Optional[str] = None,
|
|
259
228
|
ephemeral: bool = False,
|
|
260
229
|
use_consolidated_metadata_only: bool = False,
|
|
230
|
+
source_url: Optional[str] = None,
|
|
261
231
|
) -> str:
|
|
262
232
|
"""
|
|
263
233
|
Upload the prepared bundle to the cloud using the new database-driven approach
|
|
@@ -269,12 +239,10 @@ def _upload_bundle(
|
|
|
269
239
|
ephemeral: Whether to create an ephemeral figure
|
|
270
240
|
use_consolidated_metadata_only: If True, excludes individual zarr metadata files
|
|
271
241
|
(.zgroup, .zarray, .zattrs) since they are included in .zmetadata
|
|
242
|
+
source_url: Optional source URL for the figure (must be unique)
|
|
272
243
|
"""
|
|
273
244
|
tmpdir_path = pathlib.Path(tmpdir)
|
|
274
245
|
|
|
275
|
-
# Compute deterministic figure ID based on file contents
|
|
276
|
-
figure_hash = _compute_deterministic_figure_hash(tmpdir_path)
|
|
277
|
-
|
|
278
246
|
# Collect all files to upload
|
|
279
247
|
all_files = []
|
|
280
248
|
for file_path in tmpdir_path.rglob("*"):
|
|
@@ -295,7 +263,12 @@ def _upload_bundle(
|
|
|
295
263
|
|
|
296
264
|
# Find available figure ID and create/get figure in database with metadata
|
|
297
265
|
result = _create_or_get_figure(
|
|
298
|
-
|
|
266
|
+
api_key,
|
|
267
|
+
total_files,
|
|
268
|
+
total_size,
|
|
269
|
+
title=title,
|
|
270
|
+
ephemeral=ephemeral,
|
|
271
|
+
source_url=source_url,
|
|
299
272
|
)
|
|
300
273
|
figure_info = result.get("figure", {})
|
|
301
274
|
figure_url = figure_info.get("figureUrl")
|
|
@@ -331,7 +304,9 @@ def _upload_bundle(
|
|
|
331
304
|
|
|
332
305
|
# Get signed URLs for this batch
|
|
333
306
|
try:
|
|
334
|
-
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
|
+
)
|
|
335
310
|
except Exception as e:
|
|
336
311
|
print(f"Failed to get signed URLs for batch {batch_num}: {e}")
|
|
337
312
|
raise
|
|
@@ -402,7 +377,9 @@ def _upload_bundle(
|
|
|
402
377
|
try:
|
|
403
378
|
# Use batch API for manifest
|
|
404
379
|
manifest_batch = [("manifest.json", temp_file_path)]
|
|
405
|
-
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
|
+
)
|
|
406
383
|
|
|
407
384
|
if "manifest.json" not in signed_urls_map:
|
|
408
385
|
raise Exception("No signed URL returned for manifest.json")
|
|
@@ -420,12 +397,45 @@ def _upload_bundle(
|
|
|
420
397
|
|
|
421
398
|
# Finalize the figure upload
|
|
422
399
|
print("Finalizing figure...")
|
|
423
|
-
_finalize_figure(figure_url, api_key)
|
|
400
|
+
_finalize_figure(figure_url, api_key if api_key else "")
|
|
424
401
|
print("Upload completed successfully")
|
|
425
402
|
|
|
426
403
|
return figure_url
|
|
427
404
|
|
|
428
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
|
+
|
|
429
439
|
def _determine_content_type(file_path: str) -> str:
|
|
430
440
|
"""
|
|
431
441
|
Determine content type for upload based on file extension
|
figpack/core/_view_figure.py
CHANGED
|
@@ -11,7 +11,7 @@ import threading
|
|
|
11
11
|
import webbrowser
|
|
12
12
|
from typing import Union
|
|
13
13
|
|
|
14
|
-
from ._server_manager import
|
|
14
|
+
from ._server_manager import ProcessServerManager
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
def serve_files(
|
|
@@ -20,35 +20,71 @@ def serve_files(
|
|
|
20
20
|
port: Union[int, None],
|
|
21
21
|
open_in_browser: bool = False,
|
|
22
22
|
allow_origin: Union[str, None] = None,
|
|
23
|
+
enable_file_upload: bool = False,
|
|
24
|
+
max_file_size: int = 10 * 1024 * 1024,
|
|
23
25
|
):
|
|
24
26
|
"""
|
|
25
|
-
Serve files from a directory using
|
|
27
|
+
Serve files from a directory using the ProcessServerManager.
|
|
26
28
|
|
|
27
29
|
Args:
|
|
28
30
|
tmpdir: Directory to serve
|
|
29
31
|
port: Port number for local server
|
|
30
32
|
open_in_browser: Whether to open in browser automatically
|
|
31
33
|
allow_origin: CORS allow origin header
|
|
34
|
+
enable_file_upload: Whether to enable PUT requests for file uploads
|
|
35
|
+
max_file_size: Maximum file size in bytes for uploads (default 10MB)
|
|
32
36
|
"""
|
|
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
|
+
|
|
42
|
+
# Create a temporary server manager instance for this specific directory
|
|
43
|
+
# Note: We can't use the singleton ProcessServerManager here because it serves
|
|
44
|
+
# from its own temp directory, but we need to serve from the specified tmpdir
|
|
45
|
+
|
|
46
|
+
# Import the required classes for direct server creation
|
|
47
|
+
from ._server_manager import CORSRequestHandler, ThreadingHTTPServer
|
|
48
|
+
from ._file_handler import FileUploadCORSRequestHandler
|
|
49
|
+
|
|
33
50
|
# if port is None, find a free port
|
|
34
51
|
if port is None:
|
|
35
52
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
36
53
|
s.bind(("", 0))
|
|
37
54
|
port = s.getsockname()[1]
|
|
38
55
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
56
|
+
# Choose handler based on file upload requirement
|
|
57
|
+
if enable_file_upload:
|
|
58
|
+
|
|
59
|
+
def handler_factory_upload_enabled(*args, **kwargs):
|
|
60
|
+
return FileUploadCORSRequestHandler(
|
|
61
|
+
*args,
|
|
62
|
+
directory=str(tmpdir_2),
|
|
63
|
+
allow_origin=allow_origin,
|
|
64
|
+
enable_file_upload=True,
|
|
65
|
+
max_file_size=max_file_size,
|
|
66
|
+
**kwargs,
|
|
67
|
+
)
|
|
43
68
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
return CORSRequestHandler(
|
|
47
|
-
*args, directory=str(tmpdir), allow_origin=allow_origin, **kwargs
|
|
69
|
+
upload_status = (
|
|
70
|
+
" (file upload enabled)" if handler_factory_upload_enabled else ""
|
|
48
71
|
)
|
|
49
72
|
|
|
50
|
-
|
|
51
|
-
|
|
73
|
+
httpd = ThreadingHTTPServer(("0.0.0.0", port), handler_factory_upload_enabled) # type: ignore
|
|
74
|
+
else:
|
|
75
|
+
|
|
76
|
+
def handler_factory(*args, **kwargs):
|
|
77
|
+
return CORSRequestHandler(
|
|
78
|
+
*args, directory=str(tmpdir_2), allow_origin=allow_origin, **kwargs
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
upload_status = ""
|
|
82
|
+
|
|
83
|
+
httpd = ThreadingHTTPServer(("0.0.0.0", port), handler_factory) # type: ignore
|
|
84
|
+
|
|
85
|
+
print(
|
|
86
|
+
f"Serving {tmpdir_2} at http://localhost:{port} (CORS → {allow_origin}){upload_status}"
|
|
87
|
+
)
|
|
52
88
|
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
|
53
89
|
thread.start()
|
|
54
90
|
|