figpack 0.3.20__tar.gz → 0.4.0__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.
- {figpack-0.3.20/figpack.egg-info → figpack-0.4.0}/PKG-INFO +3 -1
- {figpack-0.3.20 → figpack-0.4.0}/README.md +2 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/__init__.py +1 -1
- {figpack-0.3.20 → figpack-0.4.0}/figpack/cli.py +19 -2
- {figpack-0.3.20 → figpack-0.4.0}/figpack/core/_bundle_utils.py +21 -2
- figpack-0.4.0/figpack/core/_figure_spec.py +281 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/core/_file_handler.py +1 -1
- {figpack-0.3.20 → figpack-0.4.0}/figpack/core/_patch_figure.py +85 -4
- figpack-0.4.0/figpack/core/_remix_figure.py +338 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/core/_revert_patch_figure.py +1 -1
- {figpack-0.3.20 → figpack-0.4.0}/figpack/core/_upload_bundle.py +4 -3
- figpack-0.3.20/figpack/figpack-figure-dist/assets/index-CSMGUquF.js → figpack-0.4.0/figpack/figpack-figure-dist/assets/index-BKMp8GZO.js +70 -70
- {figpack-0.3.20 → figpack-0.4.0}/figpack/figpack-figure-dist/index.html +1 -1
- {figpack-0.3.20 → figpack-0.4.0}/figpack/views/Box.py +24 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/views/CaptionedView.py +11 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/views/Gallery.py +20 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/views/MountainLayout.py +15 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/views/MultiChannelTimeseries.py +6 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/views/Splitter.py +30 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/views/TabLayout.py +22 -1
- {figpack-0.3.20 → figpack-0.4.0}/figpack/views/TimeseriesGraph.py +68 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/views/VerticalLayout.py +19 -0
- {figpack-0.3.20 → figpack-0.4.0/figpack.egg-info}/PKG-INFO +3 -1
- {figpack-0.3.20 → figpack-0.4.0}/figpack.egg-info/SOURCES.txt +5 -2
- figpack-0.4.0/figpack.egg-info/top_level.txt +1 -0
- {figpack-0.3.20 → figpack-0.4.0}/pyproject.toml +1 -1
- figpack-0.4.0/tests/test_figure_spec.py +130 -0
- figpack-0.4.0/tests/test_patch_pages.py +55 -0
- {figpack-0.3.20 → figpack-0.4.0}/tests/test_tablayout.py +1 -1
- figpack-0.3.20/figpack-serve/worker/container/app.py +0 -249
- figpack-0.3.20/figpack.egg-info/top_level.txt +0 -3
- {figpack-0.3.20 → figpack-0.4.0}/LICENSE +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/MANIFEST.in +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/core/__init__.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/core/_figure_utils.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/core/_save_figure.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/core/_server_manager.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/core/_show_view.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/core/_view_figure.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/core/_zarr_consolidate.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/core/config.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/core/extension_view.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/core/figpack_extension.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/core/figpack_view.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/core/zarr.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/extensions.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/figpack-figure-dist/assets/index-GPjx4QpG.css +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/figpack-figure-dist/assets/neurosift-logo-CLsuwLMO.png +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/utils/__init__.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/views/DataFrame.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/views/GalleryItem.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/views/Iframe.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/views/Image.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/views/LayoutItem.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/views/Markdown.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/views/MatplotlibFigure.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/views/MountainLayoutItem.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/views/PlotlyExtension/PlotlyExtension.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/views/PlotlyExtension/__init__.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/views/PlotlyExtension/_plotly_extension.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/views/PlotlyExtension/plotly_view.js +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/views/Spectrogram.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/views/TabLayoutItem.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/views/VerticalLayoutItem.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack/views/__init__.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack.egg-info/dependency_links.txt +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack.egg-info/entry_points.txt +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/figpack.egg-info/requires.txt +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/setup.cfg +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/tests/test_box.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/tests/test_cli.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/tests/test_core.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/tests/test_dataframe.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/tests/test_extension_system.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/tests/test_figpack_view.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/tests/test_file_handler.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/tests/test_gallery.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/tests/test_image.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/tests/test_markdown.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/tests/test_matplotlib_figure.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/tests/test_multichannel_timeseries.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/tests/test_plotly_figure.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/tests/test_server_manager.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/tests/test_spectrogram.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/tests/test_splitter.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/tests/test_timeseries_graph.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/tests/test_upload_bundle.py +0 -0
- {figpack-0.3.20 → figpack-0.4.0}/tests/test_view_figure.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: figpack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
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
|
|
@@ -86,6 +86,8 @@ A Python package for creating shareable, interactive visualizations in the brows
|
|
|
86
86
|
|
|
87
87
|
For detailed guidance, tutorials, and API reference, visit our **[documentation](https://flatironinstitute.github.io/figpack)**.
|
|
88
88
|
|
|
89
|
+
[Figpack for the Curious](https://jeremy.magland.org/posts/2026-10-02-figpack-for-the-curious/) (blog post)
|
|
90
|
+
|
|
89
91
|
## Quick Start
|
|
90
92
|
|
|
91
93
|
Want to jump right in? Here's how to get started:
|
|
@@ -10,6 +10,8 @@ A Python package for creating shareable, interactive visualizations in the brows
|
|
|
10
10
|
|
|
11
11
|
For detailed guidance, tutorials, and API reference, visit our **[documentation](https://flatironinstitute.github.io/figpack)**.
|
|
12
12
|
|
|
13
|
+
[Figpack for the Curious](https://jeremy.magland.org/posts/2026-10-02-figpack-for-the-curious/) (blog post)
|
|
14
|
+
|
|
13
15
|
## Quick Start
|
|
14
16
|
|
|
15
17
|
Want to jump right in? Here's how to get started:
|
|
@@ -18,6 +18,7 @@ import requests
|
|
|
18
18
|
|
|
19
19
|
from . import __version__
|
|
20
20
|
from .core._view_figure import view_figure
|
|
21
|
+
from .core._remix_figure import remix_figure
|
|
21
22
|
from .core._patch_figure import patch_figure as core_patch_figure
|
|
22
23
|
from .core._revert_patch_figure import revert_patch_figure as core_revert_patch_figure
|
|
23
24
|
from .core._figure_utils import get_figure_base_url, download_file
|
|
@@ -430,7 +431,7 @@ def upload_figure(dir_path: str, title: str, description: str = None) -> None:
|
|
|
430
431
|
|
|
431
432
|
for file_path in dir_pathlib.rglob("*"):
|
|
432
433
|
if file_path.is_file():
|
|
433
|
-
rel_path =
|
|
434
|
+
rel_path = file_path.relative_to(dir_pathlib).as_posix()
|
|
434
435
|
file_size = file_path.stat().st_size
|
|
435
436
|
manifest["files"].append({"path": rel_path, "size": file_size})
|
|
436
437
|
manifest["total_size"] += file_size
|
|
@@ -553,7 +554,7 @@ def _old_revert_figure(
|
|
|
553
554
|
for item in backup_subdir.rglob("*"):
|
|
554
555
|
if item.is_file() and item.name != "backup_info.json":
|
|
555
556
|
rel_path = item.relative_to(backup_subdir)
|
|
556
|
-
backed_up_files.append(
|
|
557
|
+
backed_up_files.append(rel_path.as_posix())
|
|
557
558
|
else:
|
|
558
559
|
with open(backup_info_path) as f:
|
|
559
560
|
backup_info = json.load(f)
|
|
@@ -715,6 +716,20 @@ def main():
|
|
|
715
716
|
"--port", type=int, help="Port number to serve on (default: auto-select)"
|
|
716
717
|
)
|
|
717
718
|
|
|
719
|
+
# Remix command
|
|
720
|
+
remix_parser = subparsers.add_parser(
|
|
721
|
+
"remix",
|
|
722
|
+
help="Download a figure's index.html and serve it locally for remixing",
|
|
723
|
+
)
|
|
724
|
+
remix_parser.add_argument("figure_url", help="The figpack URL to remix")
|
|
725
|
+
remix_parser.add_argument(
|
|
726
|
+
"--dir",
|
|
727
|
+
help="Local directory for the remix (default: ~/.figpack/remixes/<figure id>)",
|
|
728
|
+
)
|
|
729
|
+
remix_parser.add_argument(
|
|
730
|
+
"--port", type=int, help="Port number to serve on (default: auto-select)"
|
|
731
|
+
)
|
|
732
|
+
|
|
718
733
|
# Patch figure command
|
|
719
734
|
patch_figure_parser = subparsers.add_parser(
|
|
720
735
|
"patch-figure", help="Update a figure's rendering code to the latest version"
|
|
@@ -797,6 +812,8 @@ def main():
|
|
|
797
812
|
download_and_view_archive(args.archive, port=args.port)
|
|
798
813
|
else:
|
|
799
814
|
view_figure(args.archive, port=args.port)
|
|
815
|
+
elif args.command == "remix":
|
|
816
|
+
remix_figure(args.figure_url, dir_path=args.dir, port=args.port)
|
|
800
817
|
elif args.command == "patch-figure":
|
|
801
818
|
patch_figure(args.figure_url, admin_override=args.admin_override)
|
|
802
819
|
elif args.command == "revert-patch-figure":
|
|
@@ -10,6 +10,11 @@ from .figpack_extension import FigpackExtension
|
|
|
10
10
|
from .extension_view import ExtensionView
|
|
11
11
|
from .zarr import Group, _check_zarr_version
|
|
12
12
|
from ._zarr_consolidate import consolidate_zarr_chunks
|
|
13
|
+
from ._figure_spec import (
|
|
14
|
+
build_figure_spec,
|
|
15
|
+
build_figure_spec_docs,
|
|
16
|
+
insert_figure_spec_into_html,
|
|
17
|
+
)
|
|
13
18
|
|
|
14
19
|
thisdir = pathlib.Path(__file__).parent.resolve()
|
|
15
20
|
|
|
@@ -28,8 +33,9 @@ def prepare_figure_bundle(
|
|
|
28
33
|
This function:
|
|
29
34
|
1. Copies all files from the figpack-figure-dist directory to tmpdir
|
|
30
35
|
2. Writes the view data to a zarr group
|
|
31
|
-
3.
|
|
32
|
-
4.
|
|
36
|
+
3. Writes the figure spec (layout and settings) into index.html
|
|
37
|
+
4. Discovers and writes extension JavaScript files
|
|
38
|
+
5. Consolidates zarr metadata
|
|
33
39
|
|
|
34
40
|
Args:
|
|
35
41
|
view: The figpack view to prepare
|
|
@@ -74,6 +80,19 @@ def prepare_figure_bundle(
|
|
|
74
80
|
if script:
|
|
75
81
|
zarr_group.attrs["script"] = script
|
|
76
82
|
|
|
83
|
+
# Write the figure spec (layout and settings) into index.html
|
|
84
|
+
index_html_path = pathlib.Path(tmpdir) / "index.html"
|
|
85
|
+
figure_spec = build_figure_spec(zarr_group._zarr_group)
|
|
86
|
+
figure_spec_docs = build_figure_spec_docs(zarr_group._zarr_group)
|
|
87
|
+
index_html_path.write_text(
|
|
88
|
+
insert_figure_spec_into_html(
|
|
89
|
+
index_html_path.read_text(encoding="utf-8"),
|
|
90
|
+
figure_spec,
|
|
91
|
+
figure_spec_docs,
|
|
92
|
+
),
|
|
93
|
+
encoding="utf-8",
|
|
94
|
+
)
|
|
95
|
+
|
|
77
96
|
# Discover and write extension JavaScript files
|
|
78
97
|
required_extensions = _discover_required_extensions(view)
|
|
79
98
|
_write_extension_files(required_extensions, tmpdir)
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Figure spec written into index.html
|
|
3
|
+
|
|
4
|
+
The spec lists the layout and the user-facing settings of a figure, on top of
|
|
5
|
+
the data in data.zarr. Each node's props are merged over the attributes of the
|
|
6
|
+
matching zarr group by the frontend (figpack-figure/src/figureSpec.ts), so
|
|
7
|
+
editing a prop value in index.html changes the figure.
|
|
8
|
+
|
|
9
|
+
Which attributes appear is decided by per-view schemas (SpecSchema), declared
|
|
10
|
+
as the `spec_schema` class attribute of view classes. Attributes that describe
|
|
11
|
+
the stored data (sizes, sampling rates, downsampling factors, ...) are left
|
|
12
|
+
out, and so are views without a schema. A short reference for the view types
|
|
13
|
+
used in the figure is written into index.html next to the spec, so the figure
|
|
14
|
+
stays self-contained.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any, Dict, List, Literal, Optional
|
|
20
|
+
|
|
21
|
+
FIGURE_SPEC_VERSION = 1
|
|
22
|
+
|
|
23
|
+
# Warn when the spec gets large, since it is meant to be read and edited (often
|
|
24
|
+
# by an AI agent, where it costs input tokens)
|
|
25
|
+
SPEC_SIZE_WARNING_CHARS = 20000
|
|
26
|
+
|
|
27
|
+
# Line width used when formatting the spec JSON
|
|
28
|
+
_SPEC_LINE_WIDTH = 120
|
|
29
|
+
|
|
30
|
+
# Props of the root node that belong to the figure rather than its root view
|
|
31
|
+
_FIGURE_PROPS = {"title"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class SpecProp:
|
|
36
|
+
"""A user-facing attribute of a view (or of a child group of a view)"""
|
|
37
|
+
|
|
38
|
+
name: str
|
|
39
|
+
type: str
|
|
40
|
+
description: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class SpecSchema:
|
|
45
|
+
"""
|
|
46
|
+
Describes which zarr attributes of a view are exposed in the figure spec
|
|
47
|
+
|
|
48
|
+
children:
|
|
49
|
+
"none": the view has no configurable child groups
|
|
50
|
+
"views": child groups are views (e.g. layouts); each uses its own schema
|
|
51
|
+
"groups": child groups are configurable parts of this view (e.g. the
|
|
52
|
+
series of a TimeseriesGraph), with props given by child_props
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
view_type: str
|
|
56
|
+
description: str
|
|
57
|
+
props: List[SpecProp] = field(default_factory=list)
|
|
58
|
+
children: Literal["none", "views", "groups"] = "none"
|
|
59
|
+
children_description: str = ""
|
|
60
|
+
child_props: List[SpecProp] = field(default_factory=list)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_spec_schemas() -> Dict[str, SpecSchema]:
|
|
64
|
+
"""Collect the spec schemas of all imported view classes, keyed by view type"""
|
|
65
|
+
from .figpack_view import FigpackView
|
|
66
|
+
|
|
67
|
+
schemas: Dict[str, SpecSchema] = {}
|
|
68
|
+
stack = list(FigpackView.__subclasses__())
|
|
69
|
+
seen = set()
|
|
70
|
+
while stack:
|
|
71
|
+
cls = stack.pop()
|
|
72
|
+
if cls in seen:
|
|
73
|
+
continue
|
|
74
|
+
seen.add(cls)
|
|
75
|
+
stack.extend(cls.__subclasses__())
|
|
76
|
+
schema = cls.__dict__.get("spec_schema")
|
|
77
|
+
if isinstance(schema, SpecSchema):
|
|
78
|
+
schemas[schema.view_type] = schema
|
|
79
|
+
return schemas
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def build_figure_spec(
|
|
83
|
+
zarr_group, schemas: Optional[Dict[str, SpecSchema]] = None
|
|
84
|
+
) -> Dict[str, Any]:
|
|
85
|
+
"""
|
|
86
|
+
Build the figure spec from the (raw) root zarr group of a figure
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
zarr_group: The root zarr group of the figure
|
|
90
|
+
schemas: Spec schemas by view type (default: get_spec_schemas())
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
The spec as a JSON-serializable dict
|
|
94
|
+
"""
|
|
95
|
+
if schemas is None:
|
|
96
|
+
schemas = get_spec_schemas()
|
|
97
|
+
node = _build_view_node(zarr_group, schemas)
|
|
98
|
+
figure_props = {
|
|
99
|
+
k: zarr_group.attrs[k] for k in sorted(_FIGURE_PROPS) if k in zarr_group.attrs
|
|
100
|
+
}
|
|
101
|
+
if figure_props:
|
|
102
|
+
node["props"] = {**figure_props, **node.get("props", {})}
|
|
103
|
+
return node
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _select_props(attrs, props: List[SpecProp]) -> Dict[str, Any]:
|
|
107
|
+
return {p.name: attrs[p.name] for p in props if p.name in attrs}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _build_view_node(group, schemas: Dict[str, SpecSchema]) -> Dict[str, Any]:
|
|
111
|
+
schema = schemas.get(group.attrs.get("view_type", ""))
|
|
112
|
+
if schema is None:
|
|
113
|
+
return {}
|
|
114
|
+
node: Dict[str, Any] = {}
|
|
115
|
+
props = _select_props(group.attrs, schema.props)
|
|
116
|
+
if props:
|
|
117
|
+
node["props"] = props
|
|
118
|
+
children = {}
|
|
119
|
+
for name, subgroup in group.groups():
|
|
120
|
+
if schema.children == "views":
|
|
121
|
+
child = _build_view_node(subgroup, schemas)
|
|
122
|
+
elif schema.children == "groups":
|
|
123
|
+
child_props = _select_props(subgroup.attrs, schema.child_props)
|
|
124
|
+
child = {"props": child_props} if child_props else {}
|
|
125
|
+
else:
|
|
126
|
+
child = {}
|
|
127
|
+
if child:
|
|
128
|
+
children[name] = child
|
|
129
|
+
if children:
|
|
130
|
+
node["children"] = children
|
|
131
|
+
return node
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _collect_view_types(zarr_group) -> List[str]:
|
|
135
|
+
view_types = []
|
|
136
|
+
|
|
137
|
+
def visit(group):
|
|
138
|
+
vt = group.attrs.get("view_type")
|
|
139
|
+
if vt and vt not in view_types:
|
|
140
|
+
view_types.append(vt)
|
|
141
|
+
for _, subgroup in group.groups():
|
|
142
|
+
visit(subgroup)
|
|
143
|
+
|
|
144
|
+
visit(zarr_group)
|
|
145
|
+
return view_types
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def build_figure_spec_docs(
|
|
149
|
+
zarr_group, schemas: Optional[Dict[str, SpecSchema]] = None
|
|
150
|
+
) -> str:
|
|
151
|
+
"""
|
|
152
|
+
Build a short markdown reference for the spec, covering the view types
|
|
153
|
+
used in the figure
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
zarr_group: The root zarr group of the figure
|
|
157
|
+
schemas: Spec schemas by view type (default: get_spec_schemas())
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
The reference as markdown text
|
|
161
|
+
"""
|
|
162
|
+
if schemas is None:
|
|
163
|
+
schemas = get_spec_schemas()
|
|
164
|
+
lines = [
|
|
165
|
+
f"# Figure spec reference (spec version {FIGURE_SPEC_VERSION})",
|
|
166
|
+
"",
|
|
167
|
+
"The figpack.render(spec) script below defines this figure's layout and settings on top of the data in data.zarr.",
|
|
168
|
+
"A spec node is {data?, view_type?, props?, children?}:",
|
|
169
|
+
"- props: values that replace the attributes of the node's zarr group (whole values are replaced; objects and lists are not merged).",
|
|
170
|
+
"- children: child nodes by name, matched to the subgroups of the node's zarr group.",
|
|
171
|
+
'- data: path of a group in data.zarr for this node (e.g. "/item_0"), or null for a node defined only by the spec (then view_type is required). If omitted, the node uses its counterpart in the original figure: the root group, or the parent\'s subgroup with the same name.',
|
|
172
|
+
"- view_type: the view type of the node.",
|
|
173
|
+
"Only the props documented here are listed in the spec. Other attributes in data.zarr describe the stored data and should not be changed.",
|
|
174
|
+
"Root node: props.title (string) is the figure title.",
|
|
175
|
+
]
|
|
176
|
+
view_types = _collect_view_types(zarr_group)
|
|
177
|
+
documented = [vt for vt in view_types if vt in schemas]
|
|
178
|
+
for vt in documented:
|
|
179
|
+
schema = schemas[vt]
|
|
180
|
+
lines += ["", f"## {vt}", "", schema.description]
|
|
181
|
+
if schema.props:
|
|
182
|
+
lines.append("Props:")
|
|
183
|
+
lines += [f"- {p.name} ({p.type}): {p.description}" for p in schema.props]
|
|
184
|
+
else:
|
|
185
|
+
lines.append("No configurable props.")
|
|
186
|
+
if schema.children_description:
|
|
187
|
+
lines.append(f"Children: {schema.children_description}")
|
|
188
|
+
if schema.child_props:
|
|
189
|
+
lines.append("Child props:")
|
|
190
|
+
lines += [
|
|
191
|
+
f"- {p.name} ({p.type}): {p.description}" for p in schema.child_props
|
|
192
|
+
]
|
|
193
|
+
undocumented = [vt for vt in view_types if vt not in schemas]
|
|
194
|
+
if undocumented:
|
|
195
|
+
lines += [
|
|
196
|
+
"",
|
|
197
|
+
"View types without documented props (they are rendered from data.zarr as is): "
|
|
198
|
+
+ ", ".join(undocumented),
|
|
199
|
+
]
|
|
200
|
+
return "\n".join(lines) + "\n"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def format_spec_json(value: Any, *, indent: int = 0, prefix_len: int = 0) -> str:
|
|
204
|
+
"""
|
|
205
|
+
Format JSON with one entry per line, except that values short enough to
|
|
206
|
+
fit on the current line are written inline
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
value: JSON-serializable value
|
|
210
|
+
indent: Current indentation (spaces)
|
|
211
|
+
prefix_len: Length of the text before the value on its first line
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
The formatted JSON
|
|
215
|
+
"""
|
|
216
|
+
compact = json.dumps(value, ensure_ascii=False)
|
|
217
|
+
if (
|
|
218
|
+
not isinstance(value, (dict, list))
|
|
219
|
+
or not value
|
|
220
|
+
or indent + prefix_len + len(compact) <= _SPEC_LINE_WIDTH
|
|
221
|
+
):
|
|
222
|
+
return compact
|
|
223
|
+
pad = " " * (indent + 2)
|
|
224
|
+
if isinstance(value, dict):
|
|
225
|
+
entries = []
|
|
226
|
+
for k, v in value.items():
|
|
227
|
+
key = json.dumps(k, ensure_ascii=False) + ": "
|
|
228
|
+
entries.append(
|
|
229
|
+
pad + key + format_spec_json(v, indent=indent + 2, prefix_len=len(key))
|
|
230
|
+
)
|
|
231
|
+
return "{\n" + ",\n".join(entries) + "\n" + " " * indent + "}"
|
|
232
|
+
entries = [pad + format_spec_json(v, indent=indent + 2) for v in value]
|
|
233
|
+
return "[\n" + ",\n".join(entries) + "\n" + " " * indent + "]"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _escape_for_script(text: str) -> str:
|
|
237
|
+
# Make text safe to embed in a <script> element
|
|
238
|
+
return (
|
|
239
|
+
text.replace("</", "<\\/")
|
|
240
|
+
.replace("\u2028", "\\u2028")
|
|
241
|
+
.replace("\u2029", "\\u2029")
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def insert_figure_spec_into_html(
|
|
246
|
+
html: str, spec: Dict[str, Any], docs: Optional[str] = None
|
|
247
|
+
) -> str:
|
|
248
|
+
"""
|
|
249
|
+
Insert the spec reference and a figpack.render(spec) script at the end of
|
|
250
|
+
the body of index.html
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
html: The contents of index.html
|
|
254
|
+
spec: The figure spec
|
|
255
|
+
docs: Optional markdown reference for the spec
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
The updated html
|
|
259
|
+
"""
|
|
260
|
+
spec_json = _escape_for_script(format_spec_json(spec, indent=4))
|
|
261
|
+
if len(spec_json) > SPEC_SIZE_WARNING_CHARS:
|
|
262
|
+
print(
|
|
263
|
+
f"Warning: the figure spec in index.html is {len(spec_json)} characters "
|
|
264
|
+
f"(more than {SPEC_SIZE_WARNING_CHARS})"
|
|
265
|
+
)
|
|
266
|
+
script = ""
|
|
267
|
+
if docs:
|
|
268
|
+
script += (
|
|
269
|
+
' <script type="text/markdown" data-figpack-spec-docs>\n'
|
|
270
|
+
+ _escape_for_script(docs)
|
|
271
|
+
+ " </script>\n"
|
|
272
|
+
)
|
|
273
|
+
script += (
|
|
274
|
+
' <script type="module" data-figpack-spec>\n'
|
|
275
|
+
+ f" figpack.render({spec_json}, {{ specVersion: {FIGURE_SPEC_VERSION} }});\n"
|
|
276
|
+
+ " </script>\n"
|
|
277
|
+
)
|
|
278
|
+
idx = html.rfind("</body>")
|
|
279
|
+
if idx < 0:
|
|
280
|
+
return html + script
|
|
281
|
+
return html[:idx] + script + html[idx:]
|
|
@@ -81,7 +81,7 @@ class FileUploadCORSRequestHandler(CORSRequestHandler):
|
|
|
81
81
|
self.send_header("Content-Type", "application/json")
|
|
82
82
|
self.end_headers()
|
|
83
83
|
|
|
84
|
-
response_data = f'{{"status": "success", "path": "{file_path.relative_to(pathlib.Path(self.directory))}"}}'
|
|
84
|
+
response_data = f'{{"status": "success", "path": "{file_path.relative_to(pathlib.Path(self.directory)).as_posix()}"}}'
|
|
85
85
|
self.wfile.write(response_data.encode("utf-8"))
|
|
86
86
|
|
|
87
87
|
except Exception as e:
|
|
@@ -9,7 +9,10 @@ Core functionality for patching figpack figures
|
|
|
9
9
|
import json
|
|
10
10
|
import os
|
|
11
11
|
import pathlib
|
|
12
|
+
import re
|
|
13
|
+
import tempfile
|
|
12
14
|
import time
|
|
15
|
+
from typing import Optional, Tuple
|
|
13
16
|
from urllib.parse import urljoin
|
|
14
17
|
|
|
15
18
|
import requests
|
|
@@ -18,6 +21,58 @@ from .. import __version__
|
|
|
18
21
|
from .config import FIGPACK_API_BASE_URL
|
|
19
22
|
from ._figure_utils import get_figure_base_url
|
|
20
23
|
|
|
24
|
+
# A reference to a hashed bundle asset in a src/href attribute, e.g.
|
|
25
|
+
# src="./assets/index-CSMGUquF.js" (Vite names assets <name>-<8-char hash>.<ext>)
|
|
26
|
+
_ASSET_REF_RE = re.compile(
|
|
27
|
+
r"""((?:src|href)=["'])((?:\./)?assets/)([A-Za-z0-9_.-]+)-([A-Za-z0-9_-]{8})\.([A-Za-z0-9]+)(["'])"""
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def swap_bundle_references(page_html: str, new_index_html: str) -> Tuple[str, int]:
|
|
32
|
+
"""
|
|
33
|
+
Point the bundle asset references of a figure page to the assets of a new
|
|
34
|
+
figpack-figure bundle, leaving the rest of the page (such as the figure
|
|
35
|
+
spec, its reference docs, and any edits) unchanged
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
page_html: The html of the figure page to patch
|
|
39
|
+
new_index_html: The index.html of the new figpack-figure bundle
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
The patched html and the number of references replaced
|
|
43
|
+
"""
|
|
44
|
+
new_refs = {}
|
|
45
|
+
for m in _ASSET_REF_RE.finditer(new_index_html):
|
|
46
|
+
new_refs[(m.group(3), m.group(5))] = m.group(3) + "-" + m.group(4)
|
|
47
|
+
|
|
48
|
+
count = 0
|
|
49
|
+
|
|
50
|
+
def replace(m):
|
|
51
|
+
nonlocal count
|
|
52
|
+
key = (m.group(3), m.group(5))
|
|
53
|
+
if key not in new_refs:
|
|
54
|
+
return m.group(0)
|
|
55
|
+
count += 1
|
|
56
|
+
return m.group(1) + m.group(2) + new_refs[key] + "." + m.group(5) + m.group(6)
|
|
57
|
+
|
|
58
|
+
return _ASSET_REF_RE.sub(replace, page_html), count
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _patched_page_html(path: str, page_html: str, new_index_html: str) -> Optional[str]:
|
|
62
|
+
"""
|
|
63
|
+
The patched contents of a figure page, or None if it cannot be patched
|
|
64
|
+
|
|
65
|
+
An index.html without a figure spec (from figpack versions before figure
|
|
66
|
+
specs) is replaced by the new bundle's index.html, as before. Other pages
|
|
67
|
+
keep their contents, with the bundle references swapped.
|
|
68
|
+
"""
|
|
69
|
+
if path == "index.html" and "data-figpack-spec" not in page_html:
|
|
70
|
+
return new_index_html
|
|
71
|
+
patched, count = swap_bundle_references(page_html, new_index_html)
|
|
72
|
+
if count == 0:
|
|
73
|
+
return None
|
|
74
|
+
return patched
|
|
75
|
+
|
|
21
76
|
|
|
22
77
|
def patch_figure(
|
|
23
78
|
figure_url: str,
|
|
@@ -93,7 +148,7 @@ def patch_figure(
|
|
|
93
148
|
if admin_override and figure_owner:
|
|
94
149
|
print(f"⚠️ ADMIN OVERRIDE: Modifying figure owned by {figure_owner}")
|
|
95
150
|
print(
|
|
96
|
-
f"This will update the rendering code (
|
|
151
|
+
f"This will update the rendering code (assets/*, and the bundle references in the figure's .html pages)"
|
|
97
152
|
)
|
|
98
153
|
print(f"to figpack version {__version__}")
|
|
99
154
|
print(
|
|
@@ -128,7 +183,7 @@ def patch_figure(
|
|
|
128
183
|
file_path = file_info["path"]
|
|
129
184
|
# Only backup rendering files
|
|
130
185
|
if (
|
|
131
|
-
file_path
|
|
186
|
+
file_path.endswith(".html")
|
|
132
187
|
or file_path.startswith("assets/")
|
|
133
188
|
or file_path.startswith("extension-")
|
|
134
189
|
or file_path == "extension_manifest.json"
|
|
@@ -205,8 +260,36 @@ def patch_figure(
|
|
|
205
260
|
# Collect all files to upload
|
|
206
261
|
files_to_upload = []
|
|
207
262
|
|
|
263
|
+
new_index_html = (html_dir / "index.html").read_text(encoding="utf-8")
|
|
264
|
+
patched_pages_dir = pathlib.Path(tempfile.mkdtemp(prefix="figpack_patch_"))
|
|
265
|
+
page_paths = [
|
|
266
|
+
f["path"]
|
|
267
|
+
for f in rendering_files
|
|
268
|
+
if f["path"].endswith(".html") and f["path"] not in failed_backups
|
|
269
|
+
]
|
|
270
|
+
if "index.html" not in page_paths:
|
|
271
|
+
page_paths.append("index.html")
|
|
272
|
+
for page_path in page_paths:
|
|
273
|
+
backup_page = backup_subdir / page_path
|
|
274
|
+
page_html = (
|
|
275
|
+
backup_page.read_text(encoding="utf-8") if backup_page.exists() else ""
|
|
276
|
+
)
|
|
277
|
+
patched = _patched_page_html(page_path, page_html, new_index_html)
|
|
278
|
+
if patched is None:
|
|
279
|
+
if verbose:
|
|
280
|
+
print(
|
|
281
|
+
f"Warning: {page_path} has no bundle references to update; leaving it unchanged"
|
|
282
|
+
)
|
|
283
|
+
continue
|
|
284
|
+
patched_page = patched_pages_dir / page_path
|
|
285
|
+
patched_page.parent.mkdir(parents=True, exist_ok=True)
|
|
286
|
+
patched_page.write_text(patched, encoding="utf-8")
|
|
287
|
+
files_to_upload.append((page_path, patched_page))
|
|
288
|
+
|
|
208
289
|
for item in html_dir.iterdir():
|
|
209
290
|
if item.is_file():
|
|
291
|
+
if item.name.endswith(".html"):
|
|
292
|
+
continue # pages are patched above
|
|
210
293
|
files_to_upload.append((str(item.name), item))
|
|
211
294
|
elif item.is_dir() and item.name == "assets":
|
|
212
295
|
for subitem in item.iterdir():
|
|
@@ -339,8 +422,6 @@ def patch_figure(
|
|
|
339
422
|
manifest["total_size"] = sum(f.get("size", 0) for f in updated_files)
|
|
340
423
|
|
|
341
424
|
# Upload manifest.json
|
|
342
|
-
import tempfile
|
|
343
|
-
|
|
344
425
|
manifest_content = json.dumps(manifest, indent=2)
|
|
345
426
|
|
|
346
427
|
with tempfile.NamedTemporaryFile(
|