figpack 0.1.5__py3-none-any.whl → 0.1.7__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 CHANGED
@@ -2,4 +2,4 @@
2
2
  figpack - A Python package for creating shareable, interactive visualizations in the browser
3
3
  """
4
4
 
5
- __version__ = "0.1.5"
5
+ __version__ = "0.1.7"
@@ -28,15 +28,15 @@ def _show_view(
28
28
 
29
29
  if upload:
30
30
  # Check for required environment variable
31
- passcode = os.environ.get("FIGPACK_UPLOAD_PASSCODE")
32
- if not passcode:
31
+ api_key = os.environ.get("FIGPACK_API_KEY")
32
+ if not api_key:
33
33
  raise EnvironmentError(
34
- "FIGPACK_UPLOAD_PASSCODE environment variable must be set to upload views."
34
+ "FIGPACK_API_KEY environment variable must be set to upload views."
35
35
  )
36
36
 
37
37
  # Upload the bundle
38
38
  print("Starting upload...")
39
- figure_url = _upload_bundle(tmpdir, passcode)
39
+ figure_url = _upload_bundle(tmpdir, api_key, title=title)
40
40
 
41
41
  if open_in_browser:
42
42
  webbrowser.open(figure_url)
@@ -11,29 +11,61 @@ import requests
11
11
 
12
12
  from .. import __version__
13
13
 
14
- thisdir = pathlib.Path(__file__).parent.resolve()
14
+ from .config import FIGPACK_API_BASE_URL
15
15
 
16
- FIGPACK_API_BASE_URL = "https://figpack-api.vercel.app"
17
- FIGPACK_FIGURES_BASE_URL = "https://figures.figpack.org/figures/default"
16
+ thisdir = pathlib.Path(__file__).parent.resolve()
18
17
 
19
18
 
20
19
  def _upload_single_file(
21
- figure_id: str, relative_path: str, file_path: pathlib.Path, passcode: str
20
+ figure_url: str, relative_path: str, file_path: pathlib.Path, api_key: str
22
21
  ) -> str:
23
22
  """
24
- Worker function to upload a single file
23
+ Worker function to upload a single file using signed URL
25
24
 
26
25
  Returns:
27
26
  str: The relative path of the uploaded file
28
27
  """
29
- file_type = _determine_file_type(relative_path)
28
+ file_size = file_path.stat().st_size
30
29
 
31
- if file_type == "small":
32
- with open(file_path, "r", encoding="utf-8") as f:
33
- content = f.read()
34
- _upload_small_file(figure_id, relative_path, content, passcode)
35
- else: # large file
36
- _upload_large_file(figure_id, relative_path, file_path, passcode)
30
+ # Get signed URL
31
+ payload = {
32
+ "figureUrl": figure_url,
33
+ "relativePath": relative_path,
34
+ "apiKey": api_key,
35
+ "size": file_size,
36
+ }
37
+
38
+ response = requests.post(f"{FIGPACK_API_BASE_URL}/api/upload", json=payload)
39
+
40
+ if not response.ok:
41
+ try:
42
+ error_data = response.json()
43
+ error_msg = error_data.get("message", "Unknown error")
44
+ except:
45
+ error_msg = f"HTTP {response.status_code}"
46
+ raise Exception(f"Failed to get signed URL for {relative_path}: {error_msg}")
47
+
48
+ response_data = response.json()
49
+ if not response_data.get("success"):
50
+ raise Exception(
51
+ f"Failed to get signed URL for {relative_path}: {response_data.get('message', 'Unknown error')}"
52
+ )
53
+
54
+ signed_url = response_data.get("signedUrl")
55
+ if not signed_url:
56
+ raise Exception(f"No signed URL returned for {relative_path}")
57
+
58
+ # Upload file to signed URL
59
+ content_type = _determine_content_type(relative_path)
60
+ with open(file_path, "rb") as f:
61
+ upload_response = requests.put(
62
+ signed_url, data=f, headers={"Content-Type": content_type}
63
+ )
64
+
65
+ if not upload_response.ok:
66
+ raise Exception(
67
+ f"Failed to upload {relative_path} to signed URL: HTTP {upload_response.status_code}"
68
+ )
37
69
 
38
70
  return relative_path
39
71
 
@@ -41,7 +73,7 @@ def _upload_single_file(
41
73
  MAX_WORKERS_FOR_UPLOAD = 16
42
74
 
43
75
 
44
- def _compute_deterministic_figure_id(tmpdir_path: pathlib.Path) -> str:
76
+ def _compute_deterministic_figure_hash(tmpdir_path: pathlib.Path) -> str:
45
77
  """
46
78
  Compute a deterministic figure ID based on SHA1 hashes of all files
47
79
 
@@ -73,101 +105,101 @@ def _compute_deterministic_figure_id(tmpdir_path: pathlib.Path) -> str:
73
105
  return combined_hash.hexdigest()
74
106
 
75
107
 
76
- def _check_existing_figure(figure_id: str) -> dict:
108
+ def _create_or_get_figure(
109
+ figure_hash: str,
110
+ api_key: str,
111
+ total_files: int = None,
112
+ total_size: int = None,
113
+ title: str = None,
114
+ ) -> dict:
77
115
  """
78
- Check if a figure already exists and return its status
116
+ Create a new figure or get existing figure information
117
+
118
+ Args:
119
+ figure_hash: The hash of the figure
120
+ api_key: The API key for authentication
121
+ total_files: Optional total number of files
122
+ total_size: Optional total size of files
123
+ title: Optional title for the figure
79
124
 
80
125
  Returns:
81
- dict: Contains 'exists' (bool) and 'status' (str) if exists
126
+ dict: Figure information from the API
82
127
  """
83
- figpack_url = f"{FIGPACK_FIGURES_BASE_URL}/{figure_id}/figpack.json"
128
+ payload = {
129
+ "figureHash": figure_hash,
130
+ "apiKey": api_key,
131
+ "figpackVersion": __version__,
132
+ }
133
+
134
+ if total_files is not None:
135
+ payload["totalFiles"] = total_files
136
+ if total_size is not None:
137
+ payload["totalSize"] = total_size
138
+ if title is not None:
139
+ payload["title"] = title
84
140
 
85
- try:
86
- response = requests.get(figpack_url, timeout=10)
87
- if response.ok:
88
- figpack_data = response.json()
89
- return {"exists": True, "status": figpack_data.get("status", "unknown")}
90
- else:
91
- return {"exists": False}
92
- except Exception:
93
- return {"exists": False}
141
+ response = requests.post(f"{FIGPACK_API_BASE_URL}/api/figures/create", json=payload)
94
142
 
143
+ if not response.ok:
144
+ try:
145
+ error_data = response.json()
146
+ error_msg = error_data.get("message", "Unknown error")
147
+ except:
148
+ error_msg = f"HTTP {response.status_code}"
149
+ raise Exception(f"Failed to create figure {figure_hash}: {error_msg}")
95
150
 
96
- def _find_available_figure_id(base_figure_id: str) -> tuple:
151
+ response_data = response.json()
152
+ if not response_data.get("success"):
153
+ raise Exception(
154
+ f"Failed to create figure {figure_hash}: {response_data.get('message', 'Unknown error')}"
155
+ )
156
+
157
+ return response_data
158
+
159
+
160
+ def _finalize_figure(figure_url: str, api_key: str) -> dict:
97
161
  """
98
- Find an available figure ID by checking base_figure_id, then base_figure_id-1, base_figure_id-2, etc.
162
+ Finalize a figure upload
99
163
 
100
164
  Returns:
101
- tuple: (figure_id_to_use, completed_figure_id) where:
102
- - figure_id_to_use is None if upload should be skipped
103
- - completed_figure_id is the ID of the completed figure if one exists
165
+ dict: Figure information from the API
104
166
  """
105
- # First check the base figure ID
106
- result = _check_existing_figure(base_figure_id)
107
- if not result["exists"]:
108
- return (base_figure_id, None)
109
- elif result["status"] == "completed":
110
- print(
111
- f"Figure {base_figure_id} already exists and is completed. Skipping upload."
167
+ payload = {
168
+ "figureUrl": figure_url,
169
+ "apiKey": api_key,
170
+ }
171
+
172
+ response = requests.post(
173
+ f"{FIGPACK_API_BASE_URL}/api/figures/finalize", json=payload
174
+ )
175
+
176
+ if not response.ok:
177
+ try:
178
+ error_data = response.json()
179
+ error_msg = error_data.get("message", "Unknown error")
180
+ except:
181
+ error_msg = f"HTTP {response.status_code}"
182
+ raise Exception(f"Failed to finalize figure {figure_url}: {error_msg}")
183
+
184
+ response_data = response.json()
185
+ if not response_data.get("success"):
186
+ raise Exception(
187
+ f"Failed to finalize figure {figure_url}: {response_data.get('message', 'Unknown error')}"
112
188
  )
113
- return (None, base_figure_id) # Signal to skip upload, return completed ID
114
-
115
- # If exists but not completed, try with suffixes
116
- suffix = 1
117
- while True:
118
- candidate_id = f"{base_figure_id}-{suffix}"
119
- result = _check_existing_figure(candidate_id)
120
-
121
- if not result["exists"]:
122
- print(f"Using figure ID: {candidate_id}")
123
- return (candidate_id, None)
124
- elif result["status"] == "completed":
125
- print(
126
- f"Figure {candidate_id} already exists and is completed. Skipping upload."
127
- )
128
- return (None, candidate_id) # Signal to skip upload, return completed ID
129
-
130
- suffix += 1
131
- if suffix > 100: # Safety limit
132
- raise Exception(
133
- "Too many existing figure variants, unable to find available ID"
134
- )
135
-
136
-
137
- def _upload_bundle(tmpdir: str, passcode: str) -> None:
189
+
190
+ return response_data
191
+
192
+
193
+ def _upload_bundle(tmpdir: str, api_key: str, title: str = None) -> str:
138
194
  """
139
- Upload the prepared bundle to the cloud using parallel uploads
195
+ Upload the prepared bundle to the cloud using the new database-driven approach
140
196
  """
141
197
  tmpdir_path = pathlib.Path(tmpdir)
142
198
 
143
199
  # Compute deterministic figure ID based on file contents
144
200
  print("Computing deterministic figure ID...")
145
- base_figure_id = _compute_deterministic_figure_id(tmpdir_path)
146
- print(f"Base figure ID: {base_figure_id}")
147
-
148
- # Find available figure ID (check for existing uploads)
149
- figure_id, completed_figure_id = _find_available_figure_id(base_figure_id)
150
-
151
- # If figure_id is None, it means we found a completed upload and should skip
152
- if figure_id is None:
153
- figure_url = f"{FIGPACK_FIGURES_BASE_URL}/{completed_figure_id}/index.html"
154
- print(f"Figure already exists at: {figure_url}")
155
- return figure_url
156
-
157
- print(f"Using figure ID: {figure_id}")
158
-
159
- # First, upload initial figpack.json with "uploading" status
160
- print("Uploading initial status...")
161
- figpack_json = {
162
- "status": "uploading",
163
- "upload_started": datetime.now(timezone.utc).isoformat(),
164
- "upload_updated": datetime.now(timezone.utc).isoformat(),
165
- "figure_id": figure_id,
166
- "figpack_version": __version__,
167
- }
168
- _upload_small_file(
169
- figure_id, "figpack.json", json.dumps(figpack_json, indent=2), passcode
170
- )
201
+ figure_hash = _compute_deterministic_figure_hash(tmpdir_path)
202
+ print(f"Figure hash: {figure_hash}")
171
203
 
172
204
  # Collect all files to upload
173
205
  all_files = []
@@ -176,34 +208,46 @@ def _upload_bundle(tmpdir: str, passcode: str) -> None:
176
208
  relative_path = file_path.relative_to(tmpdir_path)
177
209
  all_files.append((str(relative_path), file_path))
178
210
 
179
- print(f"Found {len(all_files)} files to upload")
211
+ # Calculate total files and size for metadata
212
+ total_files = len(all_files)
213
+ total_size = sum(file_path.stat().st_size for _, file_path in all_files)
214
+ print(
215
+ f"Found {total_files} files to upload, total size: {total_size / (1024 * 1024):.2f} MB"
216
+ )
217
+
218
+ # Find available figure ID and create/get figure in database with metadata
219
+ result = _create_or_get_figure(
220
+ figure_hash, api_key, total_files, total_size, title=title
221
+ )
222
+ figure_info = result.get("figure", {})
223
+ figure_url = figure_info.get("figureUrl")
224
+
225
+ if figure_info["status"] == "completed":
226
+ print(f"Figure already exists at: {figure_url}")
227
+ return figure_url
228
+
229
+ print(f"Using figure URL: {figure_url}")
180
230
 
181
- # Filter out figpack.json since we already uploaded the initial version
182
- files_to_upload = [
183
- (rel_path, file_path)
184
- for rel_path, file_path in all_files
185
- if rel_path != "figpack.json"
186
- ]
231
+ files_to_upload = all_files
187
232
  total_files_to_upload = len(files_to_upload)
188
233
 
189
234
  if total_files_to_upload == 0:
190
- print("No additional files to upload")
235
+ print("No files to upload")
191
236
  else:
192
237
  print(
193
- f"Uploading {total_files_to_upload} files with up to 8 concurrent uploads..."
238
+ f"Uploading {total_files_to_upload} files with up to {MAX_WORKERS_FOR_UPLOAD} concurrent uploads..."
194
239
  )
195
240
 
196
241
  # Thread-safe progress tracking
197
242
  uploaded_count = 0
198
243
  count_lock = threading.Lock()
199
- timer = time.time()
200
244
 
201
245
  # Upload files in parallel with concurrent uploads
202
246
  with ThreadPoolExecutor(max_workers=MAX_WORKERS_FOR_UPLOAD) as executor:
203
247
  # Submit all upload tasks
204
248
  future_to_file = {
205
249
  executor.submit(
206
- _upload_single_file, figure_id, rel_path, file_path, passcode
250
+ _upload_single_file, figure_url, rel_path, file_path, api_key
207
251
  ): rel_path
208
252
  for rel_path, file_path in files_to_upload
209
253
  }
@@ -221,36 +265,14 @@ def _upload_bundle(tmpdir: str, passcode: str) -> None:
221
265
  f"Uploaded {uploaded_count}/{total_files_to_upload}: {relative_path}"
222
266
  )
223
267
 
224
- # Update progress every 60 seconds
225
- elapsed_time = time.time() - timer
226
- if elapsed_time > 60:
227
- figpack_json = {
228
- **figpack_json,
229
- "status": "uploading",
230
- "upload_progress": f"{uploaded_count}/{total_files_to_upload}",
231
- "upload_updated": datetime.now(
232
- timezone.utc
233
- ).isoformat(),
234
- }
235
- _upload_small_file(
236
- figure_id,
237
- "figpack.json",
238
- json.dumps(figpack_json, indent=2),
239
- passcode,
240
- )
241
- print(
242
- f"Updated figpack.json with progress: {uploaded_count}/{total_files_to_upload}"
243
- )
244
- timer = time.time()
245
-
246
268
  except Exception as e:
247
269
  print(f"Failed to upload {relative_path}: {e}")
248
270
  raise # Re-raise the exception to stop the upload process
249
271
 
250
- # Create and upload manifest.json
251
- print("Creating manifest.json...")
272
+ # Create manifest for finalization
273
+ print("Creating manifest...")
252
274
  manifest = {
253
- "timestamp": datetime.now(timezone.utc).isoformat(),
275
+ "timestamp": time.time(),
254
276
  "files": [],
255
277
  "total_size": 0,
256
278
  "total_files": len(files_to_upload),
@@ -261,175 +283,58 @@ def _upload_bundle(tmpdir: str, passcode: str) -> None:
261
283
  manifest["files"].append({"path": rel_path, "size": file_size})
262
284
  manifest["total_size"] += file_size
263
285
 
264
- _upload_small_file(
265
- figure_id, "manifest.json", json.dumps(manifest, indent=2), passcode
266
- )
267
- print("Uploaded manifest.json")
268
286
  print(f"Total size: {manifest['total_size'] / (1024 * 1024):.2f} MB")
269
287
 
270
- # Finally, upload completion status
271
- figpack_json = {
272
- **figpack_json,
273
- "status": "completed",
274
- "upload_completed": datetime.now(timezone.utc).isoformat(),
275
- "expiration": (datetime.now(timezone.utc) + timedelta(days=1)).isoformat(),
276
- "figure_id": figure_id,
277
- "total_files": len(all_files),
278
- "total_size": manifest["total_size"],
279
- "figpack_version": __version__,
280
- }
281
- _upload_small_file(
282
- figure_id, "figpack.json", json.dumps(figpack_json, indent=2), passcode
283
- )
284
- print("Upload completed successfully")
285
-
286
- figure_url = f"{FIGPACK_FIGURES_BASE_URL}/{figure_id}/index.html"
287
- return figure_url
288
-
289
-
290
- def _determine_file_type(file_path: str) -> str:
291
- """
292
- Determine if a file should be uploaded as small or large
293
- Based on the validation logic in the API
294
- """
295
- # Check exact matches first
296
- if file_path == "figpack.json" or file_path == "index.html":
297
- return "small"
298
-
299
- # Check zarr metadata files
300
- if (
301
- file_path.endswith(".zattrs")
302
- or file_path.endswith(".zgroup")
303
- or file_path.endswith(".zarray")
304
- or file_path.endswith(".zmetadata")
305
- ):
306
- return "small"
307
-
308
- # Check HTML files
309
- if file_path.endswith(".html"):
310
- return "small"
311
-
312
- # Check data.zarr directory
313
- if file_path.startswith("data.zarr/"):
314
- file_name = file_path[len("data.zarr/") :]
315
- # Check if it's a zarr chunk (numeric like 0.0.1)
316
- if _is_zarr_chunk(file_name):
317
- return "large"
318
- # Check for zarr metadata files in subdirectories
319
- if (
320
- file_name.endswith(".zattrs")
321
- or file_name.endswith(".zgroup")
322
- or file_name.endswith(".zarray")
323
- or file_name.endswith(".zmetadata")
324
- ):
325
- return "small"
326
-
327
- # Check assets directory
328
- if file_path.startswith("assets/"):
329
- file_name = file_path[len("assets/") :]
330
- if file_name.endswith(".js") or file_name.endswith(".css"):
331
- return "large"
332
-
333
- # Default to large file
334
- return "large"
335
-
336
-
337
- def _is_zarr_chunk(file_name: str) -> bool:
338
- """
339
- Check if filename consists only of numbers and dots (zarr chunk pattern)
340
- """
341
- for char in file_name:
342
- if char != "." and not char.isdigit():
343
- return False
344
- return (
345
- len(file_name) > 0
346
- and not file_name.startswith(".")
347
- and not file_name.endswith(".")
348
- )
349
-
350
-
351
- def _upload_small_file(
352
- figure_id: str, file_path: str, content: str, passcode: str
353
- ) -> None:
354
- """
355
- Upload a small file by sending content directly
356
- """
357
- destination_url = f"{FIGPACK_FIGURES_BASE_URL}/{figure_id}/{file_path}"
288
+ # Upload manifest.json
289
+ print("Uploading manifest.json...")
290
+ manifest_content = json.dumps(manifest, indent=2)
291
+ manifest_size = len(manifest_content.encode("utf-8"))
358
292
 
359
- try:
360
- content.encode("utf-8")
361
- except Exception as e:
362
- raise Exception(f"Content for {file_path} is not UTF-8 encodable: {e}")
363
- payload = {
364
- "destinationUrl": destination_url,
365
- "passcode": passcode,
366
- "content": content,
293
+ manifest_payload = {
294
+ "figureUrl": figure_url,
295
+ "relativePath": "manifest.json",
296
+ "apiKey": api_key,
297
+ "size": manifest_size,
367
298
  }
368
- # check that payload is json serializable
369
- try:
370
- json.dumps(payload)
371
- except Exception as e:
372
- raise Exception(f"Payload for {file_path} is not JSON serializable: {e}")
373
-
374
- response = requests.post(f"{FIGPACK_API_BASE_URL}/api/upload", json=payload)
375
-
376
- if not response.ok:
377
- try:
378
- error_data = response.json()
379
- error_msg = error_data.get("message", "Unknown error")
380
- except:
381
- error_msg = f"HTTP {response.status_code}"
382
- raise Exception(f"Failed to upload {file_path}: {error_msg}")
383
-
384
-
385
- def _upload_large_file(
386
- figure_id: str, file_path: str, local_file_path: pathlib.Path, passcode: str
387
- ) -> None:
388
- """
389
- Upload a large file using signed URL
390
- """
391
- destination_url = f"{FIGPACK_FIGURES_BASE_URL}/{figure_id}/{file_path}"
392
- file_size = local_file_path.stat().st_size
393
-
394
- # Get signed URL
395
- payload = {
396
- "destinationUrl": destination_url,
397
- "passcode": passcode,
398
- "size": file_size,
399
- }
400
-
401
- response = requests.post(f"{FIGPACK_API_BASE_URL}/api/upload", json=payload)
402
299
 
300
+ response = requests.post(
301
+ f"{FIGPACK_API_BASE_URL}/api/upload", json=manifest_payload
302
+ )
403
303
  if not response.ok:
404
304
  try:
405
305
  error_data = response.json()
406
306
  error_msg = error_data.get("message", "Unknown error")
407
307
  except:
408
308
  error_msg = f"HTTP {response.status_code}"
409
- raise Exception(f"Failed to get signed URL for {file_path}: {error_msg}")
309
+ raise Exception(f"Failed to get signed URL for manifest.json: {error_msg}")
410
310
 
411
311
  response_data = response.json()
412
312
  if not response_data.get("success"):
413
313
  raise Exception(
414
- f"Failed to get signed URL for {file_path}: {response_data.get('message', 'Unknown error')}"
314
+ f"Failed to get signed URL for manifest.json: {response_data.get('message', 'Unknown error')}"
415
315
  )
416
316
 
417
317
  signed_url = response_data.get("signedUrl")
418
318
  if not signed_url:
419
- raise Exception(f"No signed URL returned for {file_path}")
319
+ raise Exception("No signed URL returned for manifest.json")
420
320
 
421
- # Upload file to signed URL
422
- content_type = _determine_content_type(file_path)
423
- with open(local_file_path, "rb") as f:
424
- upload_response = requests.put(
425
- signed_url, data=f, headers={"Content-Type": content_type}
426
- )
321
+ # Upload manifest using signed URL
322
+ upload_response = requests.put(
323
+ signed_url, data=manifest_content, headers={"Content-Type": "application/json"}
324
+ )
427
325
 
428
326
  if not upload_response.ok:
429
327
  raise Exception(
430
- f"Failed to upload {file_path} to signed URL: HTTP {upload_response.status_code}"
328
+ f"Failed to upload manifest.json to signed URL: HTTP {upload_response.status_code}"
431
329
  )
432
330
 
331
+ # Finalize the figure upload
332
+ print("Finalizing figure...")
333
+ _finalize_figure(figure_url, api_key)
334
+ print("Upload completed successfully")
335
+
336
+ return figure_url
337
+
433
338
 
434
339
  def _determine_content_type(file_path: str) -> str:
435
340
  """
figpack/core/config.py ADDED
@@ -0,0 +1,5 @@
1
+ import os
2
+
3
+ FIGPACK_API_BASE_URL = os.getenv(
4
+ "FIGPACK_API_BASE_URL", "https://figpack-api.vercel.app"
5
+ )