figpack 0.1.0__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 ADDED
@@ -0,0 +1,7 @@
1
+ """
2
+ figpack - A Python package for creating interactive visualizations
3
+ """
4
+
5
+ __version__ = "0.1.0"
6
+ __author__ = "Jeremy Magland"
7
+ __email__ = "jmagland@flatironinstitute.org"
figpack/_show_view.py ADDED
@@ -0,0 +1,133 @@
1
+ import os
2
+
3
+ from typing import Union
4
+ import zarr
5
+ import tempfile
6
+
7
+ import webbrowser
8
+
9
+ import pathlib
10
+
11
+ import threading
12
+ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
13
+
14
+ from .views import TimeseriesGraph
15
+
16
+ thisdir = pathlib.Path(__file__).parent.resolve()
17
+
18
+
19
+ def _show_view(
20
+ view: TimeseriesGraph,
21
+ *,
22
+ open_in_browser: bool = False,
23
+ port: Union[int, None] = None,
24
+ allow_origin: Union[str, None] = None,
25
+ ):
26
+ with tempfile.TemporaryDirectory(prefix="figpack_") as tmpdir:
27
+ html_dir = thisdir / "figpack-gui-dist"
28
+ if not os.path.exists(html_dir):
29
+ raise SystemExit(f"Error: directory not found: {html_dir}")
30
+ # copy all files in html_dir recursively to tmpdir
31
+ for item in html_dir.iterdir():
32
+ if item.is_file():
33
+ target = pathlib.Path(tmpdir) / item.name
34
+ target.write_bytes(item.read_bytes())
35
+ elif item.is_dir():
36
+ target = pathlib.Path(tmpdir) / item.name
37
+ target.mkdir(exist_ok=True)
38
+ for subitem in item.iterdir():
39
+ target_sub = target / subitem.name
40
+ target_sub.write_bytes(subitem.read_bytes())
41
+
42
+ # Write the graph data to the Zarr group
43
+ zarr_group = zarr.open_group(
44
+ pathlib.Path(tmpdir) / "data.zarr",
45
+ mode="w",
46
+ synchronizer=zarr.ThreadSynchronizer(),
47
+ )
48
+ view._write_to_zarr_group(zarr_group)
49
+
50
+ zarr.consolidate_metadata(zarr_group.store)
51
+
52
+ serve_files(
53
+ tmpdir,
54
+ port=port,
55
+ open_in_browser=open_in_browser,
56
+ allow_origin=allow_origin,
57
+ )
58
+
59
+
60
+ class CORSRequestHandler(SimpleHTTPRequestHandler):
61
+ def __init__(self, *args, allow_origin=None, **kwargs):
62
+ self.allow_origin = allow_origin
63
+ super().__init__(*args, **kwargs)
64
+
65
+ # Serve only GET/HEAD/OPTIONS; add CORS headers on every response
66
+ def end_headers(self):
67
+ if self.allow_origin is not None:
68
+ self.send_header("Access-Control-Allow-Origin", self.allow_origin)
69
+ self.send_header("Vary", "Origin")
70
+ self.send_header("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
71
+ self.send_header("Access-Control-Allow-Headers", "Content-Type, Range")
72
+ self.send_header(
73
+ "Access-Control-Expose-Headers",
74
+ "Accept-Ranges, Content-Encoding, Content-Length, Content-Range",
75
+ )
76
+ super().end_headers()
77
+
78
+ def do_OPTIONS(self):
79
+ self.send_response(204, "No Content")
80
+ self.end_headers()
81
+
82
+ def log_message(self, fmt, *args):
83
+ pass
84
+
85
+
86
+ def serve_files(
87
+ tmpdir: str,
88
+ *,
89
+ port: Union[int, None],
90
+ open_in_browser: bool = False,
91
+ allow_origin: Union[str, None] = None,
92
+ ):
93
+ # if port is None, find a free port
94
+ if port is None:
95
+ import socket
96
+
97
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
98
+ s.bind(("", 0))
99
+ port = s.getsockname()[1]
100
+
101
+ tmpdir = pathlib.Path(tmpdir)
102
+ tmpdir = tmpdir.resolve()
103
+ if not tmpdir.exists() or not tmpdir.is_dir():
104
+ raise SystemExit(f"Directory not found: {tmpdir}")
105
+
106
+ # Configure handler with directory and allow_origin
107
+ def handler_factory(*args, **kwargs):
108
+ return CORSRequestHandler(
109
+ *args, directory=str(tmpdir), allow_origin=allow_origin, **kwargs
110
+ )
111
+
112
+ httpd = ThreadingHTTPServer(("0.0.0.0", port), handler_factory)
113
+ print(f"Serving {tmpdir} at http://localhost:{port} (CORS → {allow_origin})")
114
+ thread = threading.Thread(target=httpd.serve_forever, daemon=True)
115
+ thread.start()
116
+
117
+ if open_in_browser:
118
+ webbrowser.open(f"http://localhost:{port}")
119
+ print(f"Opening http://localhost:{port} in your browser.")
120
+ else:
121
+ print(
122
+ f"Open http://localhost:{port} in your browser to view the visualization."
123
+ )
124
+
125
+ try:
126
+ input("Press Enter to stop...\n")
127
+ except (KeyboardInterrupt, EOFError):
128
+ pass
129
+ finally:
130
+ print("Shutting down server...")
131
+ httpd.shutdown()
132
+ httpd.server_close()
133
+ thread.join()
@@ -0,0 +1,334 @@
1
+ import os
2
+ import time
3
+ import json
4
+ import uuid
5
+ import tempfile
6
+ import pathlib
7
+ import requests
8
+ from datetime import datetime, timedelta, timezone
9
+
10
+ import zarr
11
+
12
+ from .views import TimeseriesGraph
13
+
14
+ thisdir = pathlib.Path(__file__).parent.resolve()
15
+
16
+ FIGPACK_API_BASE_URL = "https://figpack-api.vercel.app"
17
+ TEMPORY_BASE_URL = "https://tempory.net/figpack/figures"
18
+
19
+
20
+ def _upload_view(view: TimeseriesGraph) -> str:
21
+ """
22
+ Upload a figpack view to the cloud
23
+
24
+ Args:
25
+ view: The figpack view to upload
26
+
27
+ Returns:
28
+ str: URL where the uploaded figure can be viewed
29
+
30
+ Raises:
31
+ EnvironmentError: If FIGPACK_UPLOAD_PASSCODE is not set
32
+ Exception: If upload fails
33
+ """
34
+ # Check for required environment variable
35
+ passcode = os.environ.get("FIGPACK_UPLOAD_PASSCODE")
36
+ if not passcode:
37
+ raise EnvironmentError(
38
+ "FIGPACK_UPLOAD_PASSCODE environment variable must be set"
39
+ )
40
+
41
+ # Generate random figure ID
42
+ figure_id = str(uuid.uuid4())
43
+ print(f"Generated figure ID: {figure_id}")
44
+
45
+ with tempfile.TemporaryDirectory(prefix="figpack_upload_") as tmpdir:
46
+ # Prepare the figure bundle (reuse logic from _show_view)
47
+ print("Preparing figure bundle...")
48
+ _prepare_figure_bundle(view, tmpdir)
49
+
50
+ # Upload the bundle
51
+ print("Starting upload...")
52
+ _upload_bundle(tmpdir, figure_id, passcode)
53
+
54
+ # Return the final URL
55
+ figure_url = f"{TEMPORY_BASE_URL}/{figure_id}/index.html"
56
+ print(f"Upload completed successfully!")
57
+ print(f"Figure available at: {figure_url}")
58
+ return figure_url
59
+
60
+
61
+ def _prepare_figure_bundle(view: TimeseriesGraph, tmpdir: str) -> None:
62
+ """
63
+ Prepare the figure bundle in the temporary directory
64
+ This reuses the same logic as _show_view
65
+ """
66
+ html_dir = thisdir / "figpack-gui-dist"
67
+ if not os.path.exists(html_dir):
68
+ raise SystemExit(f"Error: directory not found: {html_dir}")
69
+
70
+ # Copy all files in html_dir recursively to tmpdir
71
+ for item in html_dir.iterdir():
72
+ if item.is_file():
73
+ target = pathlib.Path(tmpdir) / item.name
74
+ target.write_bytes(item.read_bytes())
75
+ elif item.is_dir():
76
+ target = pathlib.Path(tmpdir) / item.name
77
+ target.mkdir(exist_ok=True)
78
+ for subitem in item.iterdir():
79
+ target_sub = target / subitem.name
80
+ target_sub.write_bytes(subitem.read_bytes())
81
+
82
+ # Write the graph data to the Zarr group
83
+ zarr_group = zarr.open_group(
84
+ pathlib.Path(tmpdir) / "data.zarr",
85
+ mode="w",
86
+ synchronizer=zarr.ThreadSynchronizer(),
87
+ )
88
+ view._write_to_zarr_group(zarr_group)
89
+ zarr.consolidate_metadata(zarr_group.store)
90
+
91
+
92
+ def _upload_bundle(tmpdir: str, figure_id: str, passcode: str) -> None:
93
+ """
94
+ Upload the prepared bundle to the cloud
95
+ """
96
+ tmpdir_path = pathlib.Path(tmpdir)
97
+
98
+ # First, upload initial figpack.json with "uploading" status
99
+ print("Uploading initial status...")
100
+ figpack_json = {
101
+ "status": "uploading",
102
+ "upload_started": datetime.now(timezone.utc).isoformat(),
103
+ "upload_updated": datetime.now(timezone.utc).isoformat(),
104
+ "figure_id": figure_id,
105
+ }
106
+ _upload_small_file(
107
+ figure_id, "figpack.json", json.dumps(figpack_json, indent=2), passcode
108
+ )
109
+
110
+ # Collect all files to upload
111
+ all_files = []
112
+ for file_path in tmpdir_path.rglob("*"):
113
+ if file_path.is_file():
114
+ relative_path = file_path.relative_to(tmpdir_path)
115
+ all_files.append((str(relative_path), file_path))
116
+
117
+ print(f"Found {len(all_files)} files to upload")
118
+
119
+ # Upload files
120
+ uploaded_count = 0
121
+ timer = time.time()
122
+ for relative_path, file_path in all_files:
123
+ # Skip the figpack.json since we already uploaded the initial version
124
+ if relative_path == "figpack.json":
125
+ continue
126
+ file_type = _determine_file_type(relative_path)
127
+
128
+ if file_type == "small":
129
+ with open(file_path, "r", encoding="utf-8") as f:
130
+ content = f.read()
131
+ _upload_small_file(figure_id, relative_path, content, passcode)
132
+ else: # large file
133
+ _upload_large_file(figure_id, relative_path, file_path, passcode)
134
+
135
+ uploaded_count += 1
136
+ print(f"Uploaded {uploaded_count}/{len(all_files)-1}: {relative_path}")
137
+ elapsed_time = time.time() - timer
138
+ if elapsed_time > 60:
139
+ figpack_json = {
140
+ **figpack_json,
141
+ "status": "uploading",
142
+ "upload_progress": f"{uploaded_count}/{len(all_files)-1}",
143
+ "upload_updated": datetime.now(timezone.utc).isoformat(),
144
+ }
145
+ _upload_small_file(
146
+ figure_id,
147
+ "figpack.json",
148
+ json.dumps(figpack_json, indent=2),
149
+ passcode,
150
+ )
151
+ print(
152
+ f"Updated figpack.json with progress: {uploaded_count}/{len(all_files)-1}"
153
+ )
154
+ timer = time.time()
155
+
156
+ # Finally, upload completion status
157
+ print("Uploading completion status...")
158
+ figpack_json = {
159
+ **figpack_json,
160
+ "status": "completed",
161
+ "upload_completed": datetime.now(timezone.utc).isoformat(),
162
+ "expiration": (datetime.now(timezone.utc) + timedelta(days=1)).isoformat(),
163
+ "figure_id": figure_id,
164
+ "total_files": len(all_files),
165
+ }
166
+ _upload_small_file(
167
+ figure_id, "figpack.json", json.dumps(figpack_json, indent=2), passcode
168
+ )
169
+
170
+
171
+ def _determine_file_type(file_path: str) -> str:
172
+ """
173
+ Determine if a file should be uploaded as small or large
174
+ Based on the validation logic in the API
175
+ """
176
+ # Check exact matches first
177
+ if file_path == "figpack.json" or file_path == "index.html":
178
+ return "small"
179
+
180
+ # Check zarr metadata files
181
+ if (
182
+ file_path.endswith(".zattrs")
183
+ or file_path.endswith(".zgroup")
184
+ or file_path.endswith(".zarray")
185
+ or file_path.endswith(".zmetadata")
186
+ ):
187
+ return "small"
188
+
189
+ # Check HTML files
190
+ if file_path.endswith(".html"):
191
+ return "small"
192
+
193
+ # Check data.zarr directory
194
+ if file_path.startswith("data.zarr/"):
195
+ file_name = file_path[len("data.zarr/") :]
196
+ # Check if it's a zarr chunk (numeric like 0.0.1)
197
+ if _is_zarr_chunk(file_name):
198
+ return "large"
199
+ # Check for zarr metadata files in subdirectories
200
+ if (
201
+ file_name.endswith(".zattrs")
202
+ or file_name.endswith(".zgroup")
203
+ or file_name.endswith(".zarray")
204
+ or file_name.endswith(".zmetadata")
205
+ ):
206
+ return "small"
207
+
208
+ # Check assets directory
209
+ if file_path.startswith("assets/"):
210
+ file_name = file_path[len("assets/") :]
211
+ if file_name.endswith(".js") or file_name.endswith(".css"):
212
+ return "large"
213
+
214
+ # Default to large file
215
+ return "large"
216
+
217
+
218
+ def _is_zarr_chunk(file_name: str) -> bool:
219
+ """
220
+ Check if filename consists only of numbers and dots (zarr chunk pattern)
221
+ """
222
+ for char in file_name:
223
+ if char != "." and not char.isdigit():
224
+ return False
225
+ return (
226
+ len(file_name) > 0
227
+ and not file_name.startswith(".")
228
+ and not file_name.endswith(".")
229
+ )
230
+
231
+
232
+ def _upload_small_file(
233
+ figure_id: str, file_path: str, content: str, passcode: str
234
+ ) -> None:
235
+ """
236
+ Upload a small file by sending content directly
237
+ """
238
+ destination_url = f"{TEMPORY_BASE_URL}/{figure_id}/{file_path}"
239
+
240
+ try:
241
+ content.encode("utf-8")
242
+ except Exception as e:
243
+ raise Exception(f"Content for {file_path} is not UTF-8 encodable: {e}")
244
+ payload = {
245
+ "destinationUrl": destination_url,
246
+ "passcode": passcode,
247
+ "content": content,
248
+ }
249
+ # check that payload is json serializable
250
+ try:
251
+ json.dumps(payload)
252
+ except Exception as e:
253
+ raise Exception(f"Payload for {file_path} is not JSON serializable: {e}")
254
+
255
+ response = requests.post(f"{FIGPACK_API_BASE_URL}/api/upload", json=payload)
256
+
257
+ if not response.ok:
258
+ try:
259
+ error_data = response.json()
260
+ error_msg = error_data.get("message", "Unknown error")
261
+ except:
262
+ error_msg = f"HTTP {response.status_code}"
263
+ raise Exception(f"Failed to upload {file_path}: {error_msg}")
264
+
265
+
266
+ def _upload_large_file(
267
+ figure_id: str, file_path: str, local_file_path: pathlib.Path, passcode: str
268
+ ) -> None:
269
+ """
270
+ Upload a large file using signed URL
271
+ """
272
+ destination_url = f"{TEMPORY_BASE_URL}/{figure_id}/{file_path}"
273
+ file_size = local_file_path.stat().st_size
274
+
275
+ # Get signed URL
276
+ payload = {
277
+ "destinationUrl": destination_url,
278
+ "passcode": passcode,
279
+ "size": file_size,
280
+ }
281
+
282
+ response = requests.post(f"{FIGPACK_API_BASE_URL}/api/upload", json=payload)
283
+
284
+ if not response.ok:
285
+ try:
286
+ error_data = response.json()
287
+ error_msg = error_data.get("message", "Unknown error")
288
+ except:
289
+ error_msg = f"HTTP {response.status_code}"
290
+ raise Exception(f"Failed to get signed URL for {file_path}: {error_msg}")
291
+
292
+ response_data = response.json()
293
+ if not response_data.get("success"):
294
+ raise Exception(
295
+ f"Failed to get signed URL for {file_path}: {response_data.get('message', 'Unknown error')}"
296
+ )
297
+
298
+ signed_url = response_data.get("signedUrl")
299
+ if not signed_url:
300
+ raise Exception(f"No signed URL returned for {file_path}")
301
+
302
+ # Upload file to signed URL
303
+ content_type = _determine_content_type(file_path)
304
+ with open(local_file_path, "rb") as f:
305
+ upload_response = requests.put(
306
+ signed_url, data=f, headers={"Content-Type": content_type}
307
+ )
308
+
309
+ if not upload_response.ok:
310
+ raise Exception(
311
+ f"Failed to upload {file_path} to signed URL: HTTP {upload_response.status_code}"
312
+ )
313
+
314
+
315
+ def _determine_content_type(file_path: str) -> str:
316
+ """
317
+ Determine content type for upload based on file extension
318
+ """
319
+ file_name = file_path.split("/")[-1]
320
+ extension = file_name.split(".")[-1] if "." in file_name else ""
321
+
322
+ content_type_map = {
323
+ "json": "application/json",
324
+ "html": "text/html",
325
+ "css": "text/css",
326
+ "js": "application/javascript",
327
+ "png": "image/png",
328
+ "zattrs": "application/json",
329
+ "zgroup": "application/json",
330
+ "zarray": "application/json",
331
+ "zmetadata": "application/json",
332
+ }
333
+
334
+ return content_type_map.get(extension, "application/octet-stream")