figpack 0.2.16__tar.gz → 0.2.18__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.
- {figpack-0.2.16/figpack.egg-info → figpack-0.2.18}/PKG-INFO +1 -1
- {figpack-0.2.16 → figpack-0.2.18}/figpack/__init__.py +2 -3
- {figpack-0.2.16 → figpack-0.2.18}/figpack/cli.py +74 -0
- figpack-0.2.18/figpack/core/__init__.py +5 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack/core/_bundle_utils.py +85 -18
- figpack-0.2.18/figpack/core/_file_handler.py +192 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack/core/_server_manager.py +42 -6
- {figpack-0.2.16 → figpack-0.2.18}/figpack/core/_show_view.py +1 -1
- {figpack-0.2.16 → figpack-0.2.18}/figpack/core/_view_figure.py +43 -12
- figpack-0.2.18/figpack/core/extension_view.py +41 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack/core/figpack_extension.py +0 -71
- figpack-0.2.18/figpack/extensions.py +356 -0
- figpack-0.2.16/figpack/figpack-figure-dist/assets/index-D9a3K6eW.css → figpack-0.2.18/figpack/figpack-figure-dist/assets/index-BJUFDPIM.css +1 -1
- figpack-0.2.18/figpack/figpack-figure-dist/assets/index-nBpxgXXT.js +91 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack/figpack-figure-dist/index.html +2 -2
- {figpack-0.2.16 → figpack-0.2.18}/figpack/views/PlotlyExtension/PlotlyExtension.py +4 -50
- figpack-0.2.18/figpack/views/PlotlyExtension/_plotly_extension.py +46 -0
- figpack-0.2.18/figpack/views/PlotlyExtension/plotly_view.js +110 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack/views/__init__.py +1 -0
- {figpack-0.2.16 → figpack-0.2.18/figpack.egg-info}/PKG-INFO +1 -1
- {figpack-0.2.16 → figpack-0.2.18}/figpack.egg-info/SOURCES.txt +6 -27
- {figpack-0.2.16 → figpack-0.2.18}/pyproject.toml +1 -1
- {figpack-0.2.16 → figpack-0.2.18}/tests/test_extension_system.py +7 -84
- figpack-0.2.18/tests/test_file_handler.py +331 -0
- {figpack-0.2.16 → figpack-0.2.18}/tests/test_plotly_figure.py +1 -1
- figpack-0.2.16/figpack/core/__init__.py +0 -5
- figpack-0.2.16/figpack/core/extension_view.py +0 -59
- figpack-0.2.16/figpack/figpack-figure-dist/assets/index-DtOnN02w.js +0 -846
- figpack-0.2.16/figpack/franklab/__init__.py +0 -5
- figpack-0.2.16/figpack/franklab/views/TrackAnimation.py +0 -154
- figpack-0.2.16/figpack/franklab/views/__init__.py +0 -9
- figpack-0.2.16/figpack/spike_sorting/__init__.py +0 -5
- figpack-0.2.16/figpack/spike_sorting/views/AutocorrelogramItem.py +0 -32
- figpack-0.2.16/figpack/spike_sorting/views/Autocorrelograms.py +0 -116
- figpack-0.2.16/figpack/spike_sorting/views/AverageWaveforms.py +0 -146
- figpack-0.2.16/figpack/spike_sorting/views/CrossCorrelogramItem.py +0 -35
- figpack-0.2.16/figpack/spike_sorting/views/CrossCorrelograms.py +0 -131
- figpack-0.2.16/figpack/spike_sorting/views/RasterPlot.py +0 -284
- figpack-0.2.16/figpack/spike_sorting/views/RasterPlotItem.py +0 -28
- figpack-0.2.16/figpack/spike_sorting/views/SpikeAmplitudes.py +0 -364
- figpack-0.2.16/figpack/spike_sorting/views/SpikeAmplitudesItem.py +0 -38
- figpack-0.2.16/figpack/spike_sorting/views/UnitMetricsGraph.py +0 -127
- figpack-0.2.16/figpack/spike_sorting/views/UnitSimilarityScore.py +0 -40
- figpack-0.2.16/figpack/spike_sorting/views/UnitsTable.py +0 -82
- figpack-0.2.16/figpack/spike_sorting/views/UnitsTableColumn.py +0 -40
- figpack-0.2.16/figpack/spike_sorting/views/UnitsTableRow.py +0 -36
- figpack-0.2.16/figpack/spike_sorting/views/__init__.py +0 -41
- figpack-0.2.16/figpack/views/PlotlyExtension/plotly_view.js +0 -106
- figpack-0.2.16/tests/test_average_waveforms.py +0 -105
- figpack-0.2.16/tests/test_raster_plot.py +0 -63
- figpack-0.2.16/tests/test_spike_amplitudes.py +0 -126
- figpack-0.2.16/tests/test_spike_sorting_correlograms.py +0 -169
- figpack-0.2.16/tests/test_track_animation.py +0 -231
- figpack-0.2.16/tests/test_units_table.py +0 -209
- {figpack-0.2.16 → figpack-0.2.18}/LICENSE +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/MANIFEST.in +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/README.md +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack/core/_save_figure.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack/core/_upload_bundle.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack/core/config.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack/core/figpack_view.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack/core/zarr.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack/figpack-figure-dist/assets/neurosift-logo-CLsuwLMO.png +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack/views/Box.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack/views/DataFrame.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack/views/Gallery.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack/views/GalleryItem.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack/views/Image.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack/views/LayoutItem.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack/views/Markdown.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack/views/MatplotlibFigure.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack/views/MultiChannelTimeseries.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack/views/PlotlyExtension/__init__.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack/views/Spectrogram.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack/views/Splitter.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack/views/TabLayout.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack/views/TabLayoutItem.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack/views/TimeseriesGraph.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack.egg-info/dependency_links.txt +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack.egg-info/entry_points.txt +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack.egg-info/requires.txt +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/figpack.egg-info/top_level.txt +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/setup.cfg +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/tests/test_box.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/tests/test_cli.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/tests/test_core.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/tests/test_dataframe.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/tests/test_figpack_view.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/tests/test_gallery.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/tests/test_image.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/tests/test_markdown.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/tests/test_matplotlib_figure.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/tests/test_multichannel_timeseries.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/tests/test_server_manager.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/tests/test_spectrogram.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/tests/test_splitter.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/tests/test_tablayout.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/tests/test_timeseries_graph.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/tests/test_upload_bundle.py +0 -0
- {figpack-0.2.16 → figpack-0.2.18}/tests/test_view_figure.py +0 -0
|
@@ -2,17 +2,16 @@
|
|
|
2
2
|
figpack - A Python package for creating shareable, interactive visualizations in the browser
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
__version__ = "0.2.
|
|
5
|
+
__version__ = "0.2.18"
|
|
6
6
|
|
|
7
7
|
from .cli import view_figure
|
|
8
|
-
from .core import FigpackView, FigpackExtension,
|
|
8
|
+
from .core import FigpackView, FigpackExtension, ExtensionView
|
|
9
9
|
from .core.zarr import Group
|
|
10
10
|
|
|
11
11
|
__all__ = [
|
|
12
12
|
"view_figure",
|
|
13
13
|
"FigpackView",
|
|
14
14
|
"FigpackExtension",
|
|
15
|
-
"ExtensionRegistry",
|
|
16
15
|
"ExtensionView",
|
|
17
16
|
"Group",
|
|
18
17
|
]
|
|
@@ -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
|
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import pathlib
|
|
3
|
+
import json
|
|
3
4
|
from typing import Set
|
|
4
5
|
|
|
5
6
|
import zarr
|
|
6
7
|
|
|
7
8
|
from .figpack_view import FigpackView
|
|
8
|
-
from .figpack_extension import
|
|
9
|
+
from .figpack_extension import FigpackExtension
|
|
9
10
|
from .extension_view import ExtensionView
|
|
10
11
|
from .zarr import Group, _check_zarr_version
|
|
11
12
|
|
|
@@ -68,12 +69,44 @@ def prepare_figure_bundle(
|
|
|
68
69
|
required_extensions = _discover_required_extensions(view)
|
|
69
70
|
_write_extension_files(required_extensions, tmpdir)
|
|
70
71
|
|
|
72
|
+
# Generate extension manifest
|
|
73
|
+
_write_extension_manifest(required_extensions, tmpdir)
|
|
74
|
+
|
|
71
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")
|
|
72
81
|
finally:
|
|
73
82
|
if _check_zarr_version() == 3:
|
|
74
83
|
zarr.config.set({"default_zarr_format": old_default_zarr_format})
|
|
75
84
|
|
|
76
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
|
+
|
|
77
110
|
def _discover_required_extensions(view: FigpackView) -> Set[str]:
|
|
78
111
|
"""
|
|
79
112
|
Recursively discover all extensions required by a view and its children
|
|
@@ -84,7 +117,8 @@ def _discover_required_extensions(view: FigpackView) -> Set[str]:
|
|
|
84
117
|
Returns:
|
|
85
118
|
Set of extension names required by this view hierarchy
|
|
86
119
|
"""
|
|
87
|
-
|
|
120
|
+
extension_names_discovered = set()
|
|
121
|
+
extensions_discovered = []
|
|
88
122
|
visited = set() # Prevent infinite recursion
|
|
89
123
|
|
|
90
124
|
def _collect_extensions(v: FigpackView):
|
|
@@ -95,7 +129,9 @@ def _discover_required_extensions(view: FigpackView) -> Set[str]:
|
|
|
95
129
|
|
|
96
130
|
# Check if this view is an extension view
|
|
97
131
|
if isinstance(v, ExtensionView):
|
|
98
|
-
|
|
132
|
+
if v.extension.name not in extension_names_discovered:
|
|
133
|
+
extension_names_discovered.add(v.extension.name)
|
|
134
|
+
extensions_discovered.append(v.extension)
|
|
99
135
|
|
|
100
136
|
# Recursively check all attributes that might contain child views
|
|
101
137
|
for attr_name in dir(v):
|
|
@@ -130,10 +166,10 @@ def _discover_required_extensions(view: FigpackView) -> Set[str]:
|
|
|
130
166
|
continue
|
|
131
167
|
|
|
132
168
|
_collect_extensions(view)
|
|
133
|
-
return
|
|
169
|
+
return extensions_discovered
|
|
134
170
|
|
|
135
171
|
|
|
136
|
-
def _write_extension_files(
|
|
172
|
+
def _write_extension_files(extensions, tmpdir: str) -> None:
|
|
137
173
|
"""
|
|
138
174
|
Write JavaScript files for the required extensions
|
|
139
175
|
|
|
@@ -141,20 +177,11 @@ def _write_extension_files(extension_names: Set[str], tmpdir: str) -> None:
|
|
|
141
177
|
extension_names: Set of extension names to write
|
|
142
178
|
tmpdir: Directory to write extension files to
|
|
143
179
|
"""
|
|
144
|
-
if not extension_names:
|
|
145
|
-
return
|
|
146
|
-
|
|
147
|
-
registry = ExtensionRegistry.get_instance()
|
|
148
180
|
tmpdir_path = pathlib.Path(tmpdir)
|
|
149
181
|
|
|
150
|
-
for
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
raise RuntimeError(
|
|
154
|
-
f"Extension '{extension_name}' is required but not registered"
|
|
155
|
-
)
|
|
156
|
-
|
|
157
|
-
# Write the main JavaScript file
|
|
182
|
+
for extension in extensions:
|
|
183
|
+
if not isinstance(extension, FigpackExtension):
|
|
184
|
+
raise ValueError("Expected a FigpackExtension instance")
|
|
158
185
|
js_filename = extension.get_javascript_filename()
|
|
159
186
|
js_path = tmpdir_path / js_filename
|
|
160
187
|
|
|
@@ -164,7 +191,7 @@ def _write_extension_files(extension_names: Set[str], tmpdir: str) -> None:
|
|
|
164
191
|
* Version: {extension.version}
|
|
165
192
|
* Generated automatically - do not edit
|
|
166
193
|
*/
|
|
167
|
-
|
|
194
|
+
|
|
168
195
|
{extension.javascript_code}
|
|
169
196
|
"""
|
|
170
197
|
|
|
@@ -187,3 +214,43 @@ def _write_extension_files(extension_names: Set[str], tmpdir: str) -> None:
|
|
|
187
214
|
"""
|
|
188
215
|
|
|
189
216
|
additional_path.write_text(additional_js_content, encoding="utf-8")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _write_extension_manifest(extensions, tmpdir: str) -> None:
|
|
220
|
+
"""
|
|
221
|
+
Write the extension manifest file that lists all extensions and their files
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
extensions: List of FigpackExtension instances
|
|
225
|
+
tmpdir: Directory to write the manifest file to
|
|
226
|
+
"""
|
|
227
|
+
tmpdir_path = pathlib.Path(tmpdir)
|
|
228
|
+
manifest_path = tmpdir_path / "extension_manifest.json"
|
|
229
|
+
|
|
230
|
+
# Build the manifest data
|
|
231
|
+
manifest_data = {"extensions": []}
|
|
232
|
+
|
|
233
|
+
for extension in extensions:
|
|
234
|
+
if not isinstance(extension, FigpackExtension):
|
|
235
|
+
raise ValueError("Expected a FigpackExtension instance")
|
|
236
|
+
|
|
237
|
+
# Get the main script filename
|
|
238
|
+
main_script = extension.get_javascript_filename()
|
|
239
|
+
|
|
240
|
+
# Get additional script filenames
|
|
241
|
+
additional_filenames = extension.get_additional_filenames()
|
|
242
|
+
additional_scripts = list(additional_filenames.values())
|
|
243
|
+
|
|
244
|
+
extension_entry = {
|
|
245
|
+
"name": extension.name,
|
|
246
|
+
"mainScript": main_script,
|
|
247
|
+
"additionalScripts": additional_scripts,
|
|
248
|
+
"version": extension.version,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
manifest_data["extensions"].append(extension_entry)
|
|
252
|
+
|
|
253
|
+
# Write the manifest file
|
|
254
|
+
manifest_path.write_text(
|
|
255
|
+
json.dumps(manifest_data, indent=2, ensure_ascii=False), encoding="utf-8"
|
|
256
|
+
)
|
|
@@ -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,
|
|
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
|
-
#
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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(
|
|
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
|
|