figpack 0.2.17__tar.gz → 0.2.19__tar.gz

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.

Files changed (72) hide show
  1. {figpack-0.2.17/figpack.egg-info → figpack-0.2.19}/PKG-INFO +2 -1
  2. {figpack-0.2.17 → figpack-0.2.19}/figpack/__init__.py +1 -1
  3. {figpack-0.2.17 → figpack-0.2.19}/figpack/cli.py +74 -0
  4. {figpack-0.2.17 → figpack-0.2.19}/figpack/core/_bundle_utils.py +29 -0
  5. figpack-0.2.19/figpack/core/_file_handler.py +192 -0
  6. {figpack-0.2.17 → figpack-0.2.19}/figpack/core/_server_manager.py +42 -6
  7. {figpack-0.2.17 → figpack-0.2.19}/figpack/core/_show_view.py +1 -1
  8. {figpack-0.2.17 → figpack-0.2.19}/figpack/core/_view_figure.py +43 -12
  9. figpack-0.2.19/figpack/extensions.py +356 -0
  10. figpack-0.2.17/figpack/figpack-figure-dist/assets/index-DHWczh-Q.css → figpack-0.2.19/figpack/figpack-figure-dist/assets/index-BJUFDPIM.css +1 -1
  11. figpack-0.2.19/figpack/figpack-figure-dist/assets/index-Co16MLPb.js +91 -0
  12. {figpack-0.2.17 → figpack-0.2.19}/figpack/figpack-figure-dist/index.html +2 -2
  13. {figpack-0.2.17 → figpack-0.2.19/figpack.egg-info}/PKG-INFO +2 -1
  14. {figpack-0.2.17 → figpack-0.2.19}/figpack.egg-info/SOURCES.txt +5 -2
  15. {figpack-0.2.17 → figpack-0.2.19}/figpack.egg-info/requires.txt +1 -0
  16. {figpack-0.2.17 → figpack-0.2.19}/pyproject.toml +3 -2
  17. figpack-0.2.19/tests/test_file_handler.py +331 -0
  18. figpack-0.2.17/figpack/figpack-figure-dist/assets/index-DBwmtEpB.js +0 -91
  19. {figpack-0.2.17 → figpack-0.2.19}/LICENSE +0 -0
  20. {figpack-0.2.17 → figpack-0.2.19}/MANIFEST.in +0 -0
  21. {figpack-0.2.17 → figpack-0.2.19}/README.md +0 -0
  22. {figpack-0.2.17 → figpack-0.2.19}/figpack/core/__init__.py +0 -0
  23. {figpack-0.2.17 → figpack-0.2.19}/figpack/core/_save_figure.py +0 -0
  24. {figpack-0.2.17 → figpack-0.2.19}/figpack/core/_upload_bundle.py +0 -0
  25. {figpack-0.2.17 → figpack-0.2.19}/figpack/core/config.py +0 -0
  26. {figpack-0.2.17 → figpack-0.2.19}/figpack/core/extension_view.py +0 -0
  27. {figpack-0.2.17 → figpack-0.2.19}/figpack/core/figpack_extension.py +0 -0
  28. {figpack-0.2.17 → figpack-0.2.19}/figpack/core/figpack_view.py +0 -0
  29. {figpack-0.2.17 → figpack-0.2.19}/figpack/core/zarr.py +0 -0
  30. {figpack-0.2.17 → figpack-0.2.19}/figpack/figpack-figure-dist/assets/neurosift-logo-CLsuwLMO.png +0 -0
  31. {figpack-0.2.17 → figpack-0.2.19}/figpack/views/Box.py +0 -0
  32. {figpack-0.2.17 → figpack-0.2.19}/figpack/views/DataFrame.py +0 -0
  33. {figpack-0.2.17 → figpack-0.2.19}/figpack/views/Gallery.py +0 -0
  34. {figpack-0.2.17 → figpack-0.2.19}/figpack/views/GalleryItem.py +0 -0
  35. {figpack-0.2.17 → figpack-0.2.19}/figpack/views/Image.py +0 -0
  36. {figpack-0.2.17 → figpack-0.2.19}/figpack/views/LayoutItem.py +0 -0
  37. {figpack-0.2.17 → figpack-0.2.19}/figpack/views/Markdown.py +0 -0
  38. {figpack-0.2.17 → figpack-0.2.19}/figpack/views/MatplotlibFigure.py +0 -0
  39. {figpack-0.2.17 → figpack-0.2.19}/figpack/views/MultiChannelTimeseries.py +0 -0
  40. {figpack-0.2.17 → figpack-0.2.19}/figpack/views/PlotlyExtension/PlotlyExtension.py +0 -0
  41. {figpack-0.2.17 → figpack-0.2.19}/figpack/views/PlotlyExtension/__init__.py +0 -0
  42. {figpack-0.2.17 → figpack-0.2.19}/figpack/views/PlotlyExtension/_plotly_extension.py +0 -0
  43. {figpack-0.2.17 → figpack-0.2.19}/figpack/views/PlotlyExtension/plotly_view.js +0 -0
  44. {figpack-0.2.17 → figpack-0.2.19}/figpack/views/Spectrogram.py +0 -0
  45. {figpack-0.2.17 → figpack-0.2.19}/figpack/views/Splitter.py +0 -0
  46. {figpack-0.2.17 → figpack-0.2.19}/figpack/views/TabLayout.py +0 -0
  47. {figpack-0.2.17 → figpack-0.2.19}/figpack/views/TabLayoutItem.py +0 -0
  48. {figpack-0.2.17 → figpack-0.2.19}/figpack/views/TimeseriesGraph.py +0 -0
  49. {figpack-0.2.17 → figpack-0.2.19}/figpack/views/__init__.py +0 -0
  50. {figpack-0.2.17 → figpack-0.2.19}/figpack.egg-info/dependency_links.txt +0 -0
  51. {figpack-0.2.17 → figpack-0.2.19}/figpack.egg-info/entry_points.txt +0 -0
  52. {figpack-0.2.17 → figpack-0.2.19}/figpack.egg-info/top_level.txt +0 -0
  53. {figpack-0.2.17 → figpack-0.2.19}/setup.cfg +0 -0
  54. {figpack-0.2.17 → figpack-0.2.19}/tests/test_box.py +0 -0
  55. {figpack-0.2.17 → figpack-0.2.19}/tests/test_cli.py +0 -0
  56. {figpack-0.2.17 → figpack-0.2.19}/tests/test_core.py +0 -0
  57. {figpack-0.2.17 → figpack-0.2.19}/tests/test_dataframe.py +0 -0
  58. {figpack-0.2.17 → figpack-0.2.19}/tests/test_extension_system.py +0 -0
  59. {figpack-0.2.17 → figpack-0.2.19}/tests/test_figpack_view.py +0 -0
  60. {figpack-0.2.17 → figpack-0.2.19}/tests/test_gallery.py +0 -0
  61. {figpack-0.2.17 → figpack-0.2.19}/tests/test_image.py +0 -0
  62. {figpack-0.2.17 → figpack-0.2.19}/tests/test_markdown.py +0 -0
  63. {figpack-0.2.17 → figpack-0.2.19}/tests/test_matplotlib_figure.py +0 -0
  64. {figpack-0.2.17 → figpack-0.2.19}/tests/test_multichannel_timeseries.py +0 -0
  65. {figpack-0.2.17 → figpack-0.2.19}/tests/test_plotly_figure.py +0 -0
  66. {figpack-0.2.17 → figpack-0.2.19}/tests/test_server_manager.py +0 -0
  67. {figpack-0.2.17 → figpack-0.2.19}/tests/test_spectrogram.py +0 -0
  68. {figpack-0.2.17 → figpack-0.2.19}/tests/test_splitter.py +0 -0
  69. {figpack-0.2.17 → figpack-0.2.19}/tests/test_tablayout.py +0 -0
  70. {figpack-0.2.17 → figpack-0.2.19}/tests/test_timeseries_graph.py +0 -0
  71. {figpack-0.2.17 → figpack-0.2.19}/tests/test_upload_bundle.py +0 -0
  72. {figpack-0.2.17 → figpack-0.2.19}/tests/test_view_figure.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: figpack
3
- Version: 0.2.17
3
+ Version: 0.2.19
4
4
  Summary: A Python package for creating shareable, interactive visualizations in the browser
5
5
  Author-email: Jeremy Magland <jmagland@flatironinstitute.org>
6
6
  License: Apache-2.0
@@ -69,6 +69,7 @@ Requires-Dist: sphinx-rtd-theme>=2.0; extra == "docs"
69
69
  Requires-Dist: sphinx-autobuild>=2021.3.14; extra == "docs"
70
70
  Requires-Dist: linkify-it-py>=2.0; extra == "docs"
71
71
  Requires-Dist: sphinx-copybutton>=0.5; extra == "docs"
72
+ Requires-Dist: lindi; extra == "docs"
72
73
  Dynamic: license-file
73
74
 
74
75
  # figpack
@@ -2,7 +2,7 @@
2
2
  figpack - A Python package for creating shareable, interactive visualizations in the browser
3
3
  """
4
4
 
5
- __version__ = "0.2.17"
5
+ __version__ = "0.2.19"
6
6
 
7
7
  from .cli import view_figure
8
8
  from .core import FigpackView, FigpackExtension, ExtensionView
@@ -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 .extensions import ExtensionManager
21
22
 
22
23
  MAX_WORKERS_FOR_DOWNLOAD = 16
23
24
 
@@ -214,6 +215,40 @@ def download_figure(figure_url: str, dest_path: str) -> None:
214
215
  print(f"Archive saved to: {dest_path}")
215
216
 
216
217
 
218
+ def handle_extensions_command(args):
219
+ """Handle extensions subcommands"""
220
+ extension_manager = ExtensionManager()
221
+
222
+ if args.extensions_command == "list":
223
+ extension_manager.list_extensions()
224
+ elif args.extensions_command == "install":
225
+ if not args.extensions and not args.all:
226
+ print("Error: No extensions specified. Use extension names or --all flag.")
227
+ print("Example: figpack extensions install figpack_3d")
228
+ print(" figpack extensions install --all")
229
+ sys.exit(1)
230
+
231
+ success = extension_manager.install_extensions(
232
+ extensions=args.extensions, upgrade=args.upgrade, install_all=args.all
233
+ )
234
+
235
+ if not success:
236
+ sys.exit(1)
237
+
238
+ elif args.extensions_command == "uninstall":
239
+ success = extension_manager.uninstall_extensions(args.extensions)
240
+
241
+ if not success:
242
+ sys.exit(1)
243
+ else:
244
+ print("Available extension commands:")
245
+ print(" list - List available extensions and their status")
246
+ print(" install - Install or upgrade extension packages")
247
+ print(" uninstall - Uninstall extension packages")
248
+ print()
249
+ print("Use 'figpack extensions <command> --help' for more information.")
250
+
251
+
217
252
  def main():
218
253
  """Main CLI entry point"""
219
254
  parser = argparse.ArgumentParser(
@@ -240,12 +275,51 @@ def main():
240
275
  "--port", type=int, help="Port number to serve on (default: auto-select)"
241
276
  )
242
277
 
278
+ # Extensions command
279
+ extensions_parser = subparsers.add_parser(
280
+ "extensions", help="Manage figpack extension packages"
281
+ )
282
+ extensions_subparsers = extensions_parser.add_subparsers(
283
+ dest="extensions_command", help="Extension management commands"
284
+ )
285
+
286
+ # Extensions list subcommand
287
+ extensions_list_parser = extensions_subparsers.add_parser(
288
+ "list", help="List available extensions and their status"
289
+ )
290
+
291
+ # Extensions install subcommand
292
+ extensions_install_parser = extensions_subparsers.add_parser(
293
+ "install", help="Install or upgrade extension packages"
294
+ )
295
+ extensions_install_parser.add_argument(
296
+ "extensions",
297
+ nargs="*",
298
+ help="Extension package names to install (e.g., figpack_3d figpack_spike_sorting)",
299
+ )
300
+ extensions_install_parser.add_argument(
301
+ "--all", action="store_true", help="Install all available extensions"
302
+ )
303
+ extensions_install_parser.add_argument(
304
+ "--upgrade", action="store_true", help="Upgrade packages if already installed"
305
+ )
306
+
307
+ # Extensions uninstall subcommand
308
+ extensions_uninstall_parser = extensions_subparsers.add_parser(
309
+ "uninstall", help="Uninstall extension packages"
310
+ )
311
+ extensions_uninstall_parser.add_argument(
312
+ "extensions", nargs="+", help="Extension package names to uninstall"
313
+ )
314
+
243
315
  args = parser.parse_args()
244
316
 
245
317
  if args.command == "download":
246
318
  download_figure(args.figure_url, args.dest)
247
319
  elif args.command == "view":
248
320
  view_figure(args.archive, port=args.port)
321
+ elif args.command == "extensions":
322
+ handle_extensions_command(args)
249
323
  else:
250
324
  parser.print_help()
251
325
 
@@ -73,11 +73,40 @@ def prepare_figure_bundle(
73
73
  _write_extension_manifest(required_extensions, tmpdir)
74
74
 
75
75
  zarr.consolidate_metadata(zarr_group._zarr_group.store)
76
+
77
+ # It's important that we remove all the metadata files except for the
78
+ # consolidated one, because otherwise we may get inconstencies
79
+ # once we start editing the zarr data from the browser.
80
+ _remove_metadata_files_except_consolidated(pathlib.Path(tmpdir) / "data.zarr")
76
81
  finally:
77
82
  if _check_zarr_version() == 3:
78
83
  zarr.config.set({"default_zarr_format": old_default_zarr_format})
79
84
 
80
85
 
86
+ def _remove_metadata_files_except_consolidated(zarr_dir: pathlib.Path) -> None:
87
+ """
88
+ Remove all zarr metadata files except for the consolidated one.
89
+
90
+ Args:
91
+ zarr_dir: Path to the zarr directory
92
+ """
93
+ if not zarr_dir.is_dir():
94
+ raise ValueError(f"Expected a directory, got: {zarr_dir}")
95
+
96
+ for root, dirs, files in os.walk(zarr_dir):
97
+ for file in files:
98
+ if (
99
+ file.endswith(".zarray")
100
+ or file.endswith(".zgroup")
101
+ or file.endswith(".zattrs")
102
+ ):
103
+ file_path = pathlib.Path(root) / file
104
+ try:
105
+ file_path.unlink()
106
+ except Exception as e:
107
+ print(f"Warning: could not remove file {file_path}: {e}")
108
+
109
+
81
110
  def _discover_required_extensions(view: FigpackView) -> Set[str]:
82
111
  """
83
112
  Recursively discover all extensions required by a view and its children
@@ -0,0 +1,192 @@
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
+ # Prevent browser caching - important for when we are editing figures in place
46
+ # This ensures the browser always fetches the latest version of files
47
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
48
+ self.send_header("Pragma", "no-cache")
49
+ self.send_header("Expires", "0")
50
+
51
+ super(SimpleHTTPRequestHandler, self).end_headers()
52
+
53
+ def do_PUT(self):
54
+ """Handle PUT requests for file uploads."""
55
+ if not self.enable_file_upload:
56
+ self.send_error(405, "Method Not Allowed")
57
+ return
58
+
59
+ try:
60
+ # Parse and validate the path
61
+ file_path = self._get_safe_file_path()
62
+ if file_path is None:
63
+ return # Error already sent
64
+
65
+ # Check content length
66
+ content_length = self._get_content_length()
67
+ if content_length is None:
68
+ return # Error already sent
69
+
70
+ # Determine if this will be a create or update
71
+ is_new_file = not file_path.exists()
72
+
73
+ # Read and write the file
74
+ if self._write_file_content(file_path, content_length):
75
+ # Send appropriate status code
76
+ status_code = 201 if is_new_file else 200
77
+ self.send_response(status_code)
78
+ self.send_header("Content-Type", "application/json")
79
+ self.end_headers()
80
+
81
+ response_data = f'{{"status": "success", "path": "{file_path.relative_to(pathlib.Path(self.directory))}"}}'
82
+ self.wfile.write(response_data.encode("utf-8"))
83
+
84
+ except Exception as e:
85
+ self.log_error(f"Error in PUT request: {e}")
86
+ self.send_error(500, f"Internal Server Error: {str(e)}")
87
+
88
+ def _get_safe_file_path(self) -> Optional[pathlib.Path]:
89
+ """
90
+ Parse and validate the requested file path.
91
+ Returns None if the path is invalid or unsafe.
92
+ """
93
+ # Parse the URL path
94
+ parsed_path = urllib.parse.urlparse(self.path).path
95
+
96
+ # Remove leading slash and decode URL encoding
97
+ relative_path = urllib.parse.unquote(parsed_path.lstrip("/"))
98
+
99
+ # Prevent empty paths
100
+ if not relative_path:
101
+ self.send_error(400, "Bad Request: Empty file path")
102
+ return None
103
+
104
+ # Get the served directory
105
+ served_dir = pathlib.Path(self.directory).resolve()
106
+
107
+ # Construct the target file path
108
+ target_path = served_dir / relative_path
109
+
110
+ try:
111
+ # Resolve the path to handle any .. or . components
112
+ resolved_path = target_path.resolve()
113
+
114
+ # Ensure the resolved path is within the served directory
115
+ if not str(resolved_path).startswith(str(served_dir)):
116
+ self.send_error(403, "Forbidden: Path outside served directory")
117
+ return None
118
+
119
+ except (OSError, ValueError) as e:
120
+ self.send_error(400, f"Bad Request: Invalid path - {str(e)}")
121
+ return None
122
+
123
+ return resolved_path
124
+
125
+ def _get_content_length(self) -> Optional[int]:
126
+ """
127
+ Get and validate the content length from headers.
128
+ Returns None if invalid or too large.
129
+ """
130
+ content_length_header = self.headers.get("Content-Length")
131
+ if not content_length_header:
132
+ self.send_error(400, "Bad Request: Content-Length header required")
133
+ return None
134
+
135
+ try:
136
+ content_length = int(content_length_header)
137
+ except ValueError:
138
+ self.send_error(400, "Bad Request: Invalid Content-Length")
139
+ return None
140
+
141
+ if content_length < 0:
142
+ self.send_error(400, "Bad Request: Negative Content-Length")
143
+ return None
144
+
145
+ if content_length > self.max_file_size:
146
+ self.send_error(
147
+ 413,
148
+ f"Payload Too Large: Maximum file size is {self.max_file_size} bytes",
149
+ )
150
+ return None
151
+
152
+ return content_length
153
+
154
+ def _write_file_content(self, file_path: pathlib.Path, content_length: int) -> bool:
155
+ """
156
+ Write the request body content to the specified file.
157
+ Returns True on success, False on failure (error already sent).
158
+ """
159
+ try:
160
+ # Create parent directories if they don't exist
161
+ file_path.parent.mkdir(parents=True, exist_ok=True)
162
+
163
+ # Write the file content
164
+ with open(file_path, "wb") as f:
165
+ remaining = content_length
166
+ while remaining > 0:
167
+ # Read in chunks to handle large files efficiently
168
+ chunk_size = min(8192, remaining)
169
+ chunk = self.rfile.read(chunk_size)
170
+
171
+ if not chunk:
172
+ # Unexpected end of data
173
+ self.send_error(400, "Bad Request: Incomplete data")
174
+ return False
175
+
176
+ f.write(chunk)
177
+ remaining -= len(chunk)
178
+
179
+ return True
180
+
181
+ except OSError as e:
182
+ self.send_error(
183
+ 500, f"Internal Server Error: Could not write file - {str(e)}"
184
+ )
185
+ return False
186
+ except Exception as e:
187
+ self.send_error(500, f"Internal Server Error: {str(e)}")
188
+ return False
189
+
190
+ def log_message(self, fmt, *args):
191
+ """Override to suppress default logging (same as parent class)."""
192
+ pass
@@ -28,12 +28,23 @@ class CORSRequestHandler(SimpleHTTPRequestHandler):
28
28
  "Access-Control-Expose-Headers",
29
29
  "Accept-Ranges, Content-Encoding, Content-Length, Content-Range",
30
30
  )
31
+
32
+ # Prevent browser caching - important for when we are editing figures in place
33
+ # This ensures the browser always fetches the latest version of files
34
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
35
+ self.send_header("Pragma", "no-cache")
36
+ self.send_header("Expires", "0")
37
+
31
38
  super().end_headers()
32
39
 
33
40
  def do_OPTIONS(self):
34
41
  self.send_response(204, "No Content")
35
42
  self.end_headers()
36
43
 
44
+ def do_PUT(self):
45
+ """Reject PUT requests when file upload is not enabled."""
46
+ self.send_error(405, "Method Not Allowed")
47
+
37
48
  def log_message(self, fmt, *args):
38
49
  pass
39
50
 
@@ -153,11 +164,21 @@ class ProcessServerManager:
153
164
  return figure_dir
154
165
 
155
166
  def start_server(
156
- self, port: Optional[int] = None, allow_origin: Optional[str] = None
167
+ self,
168
+ port: Optional[int] = None,
169
+ allow_origin: Optional[str] = None,
170
+ enable_file_upload: bool = False,
171
+ max_file_size: int = 10 * 1024 * 1024,
157
172
  ) -> tuple[str, int]:
158
173
  """
159
174
  Start the server if not already running, or return existing server info.
160
175
 
176
+ Args:
177
+ port: Port to bind to (auto-selected if None)
178
+ allow_origin: CORS origin to allow (None for no CORS)
179
+ enable_file_upload: Whether to enable PUT requests for file uploads
180
+ max_file_size: Maximum file size in bytes for uploads (default 10MB)
181
+
161
182
  Returns:
162
183
  tuple: (base_url, port)
163
184
  """
@@ -184,11 +205,26 @@ class ProcessServerManager:
184
205
 
185
206
  temp_dir = self.get_temp_dir()
186
207
 
187
- # Configure handler with directory and allow_origin
188
- def handler_factory(*args, **kwargs):
189
- return CORSRequestHandler(
190
- *args, directory=str(temp_dir), allow_origin=allow_origin, **kwargs
191
- )
208
+ # Choose handler based on file upload requirement
209
+ if enable_file_upload:
210
+ from ._file_handler import FileUploadCORSRequestHandler
211
+
212
+ def handler_factory(*args, **kwargs):
213
+ return FileUploadCORSRequestHandler(
214
+ *args,
215
+ directory=str(temp_dir),
216
+ allow_origin=allow_origin,
217
+ enable_file_upload=True,
218
+ max_file_size=max_file_size,
219
+ **kwargs,
220
+ )
221
+
222
+ else:
223
+
224
+ def handler_factory(*args, **kwargs):
225
+ return CORSRequestHandler(
226
+ *args, directory=str(temp_dir), allow_origin=allow_origin, **kwargs
227
+ )
192
228
 
193
229
  self._server = ThreadingHTTPServer(("0.0.0.0", port), handler_factory)
194
230
  self._port = port
@@ -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
@@ -11,7 +11,7 @@ import threading
11
11
  import webbrowser
12
12
  from typing import Union
13
13
 
14
- from ._server_manager import CORSRequestHandler, ThreadingHTTPServer
14
+ from ._server_manager import ProcessServerManager
15
15
 
16
16
 
17
17
  def serve_files(
@@ -20,35 +20,66 @@ 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 a simple HTTP server.
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 = pathlib.Path(tmpdir)
38
+ tmpdir = tmpdir.resolve()
39
+ if not tmpdir.exists() or not tmpdir.is_dir():
40
+ raise SystemExit(f"Directory not found: {tmpdir}")
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
- tmpdir = pathlib.Path(tmpdir)
40
- tmpdir = tmpdir.resolve()
41
- if not tmpdir.exists() or not tmpdir.is_dir():
42
- raise SystemExit(f"Directory not found: {tmpdir}")
56
+ # Choose handler based on file upload requirement
57
+ if enable_file_upload:
58
+
59
+ def handler_factory(*args, **kwargs):
60
+ return FileUploadCORSRequestHandler(
61
+ *args,
62
+ directory=str(tmpdir),
63
+ allow_origin=allow_origin,
64
+ enable_file_upload=True,
65
+ max_file_size=max_file_size,
66
+ **kwargs,
67
+ )
43
68
 
44
- # Configure handler with directory and allow_origin
45
- def handler_factory(*args, **kwargs):
46
- return CORSRequestHandler(
47
- *args, directory=str(tmpdir), allow_origin=allow_origin, **kwargs
48
- )
69
+ upload_status = " (file upload enabled)" if enable_file_upload else ""
70
+ else:
71
+
72
+ def handler_factory(*args, **kwargs):
73
+ return CORSRequestHandler(
74
+ *args, directory=str(tmpdir), allow_origin=allow_origin, **kwargs
75
+ )
76
+
77
+ upload_status = ""
49
78
 
50
79
  httpd = ThreadingHTTPServer(("0.0.0.0", port), handler_factory)
51
- print(f"Serving {tmpdir} at http://localhost:{port} (CORS → {allow_origin})")
80
+ print(
81
+ f"Serving {tmpdir} at http://localhost:{port} (CORS → {allow_origin}){upload_status}"
82
+ )
52
83
  thread = threading.Thread(target=httpd.serve_forever, daemon=True)
53
84
  thread.start()
54
85