mplbed 0.1.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.
mplbed-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.3
2
+ Name: mplbed
3
+ Version: 0.1.0
4
+ Summary: Integrate Matplotlib's WebAgg backend with Starlette for real-time plotting in web applications.
5
+ Requires-Dist: frozendict>=2.4.7
6
+ Requires-Dist: matplotlib>=3.10.9
7
+ Requires-Dist: starlette>=1.0.0
8
+ Requires-Dist: tornado>=6.5.5
9
+ Requires-Python: >=3.14
10
+ Description-Content-Type: text/markdown
11
+
12
+ # mplbed
13
+
14
+ ```
15
+ |[]mpl
16
+ |======|
17
+ | bed |
18
+ ```
19
+
20
+ `mplbed` is a library of support code for embedding interactive, server-side matplotlib figures in web applications.
21
+
22
+ Supporting, in principle any ASGI-compatible Python application, it provides convenient integration for a number of frameworks ones out of the box.
23
+ It aims to have "usually-works" defaults useful for quick demos, while providing a lot of flexibility and options as well as utilities to deal with advanced issues such as style isolation, and connection and resource management to enable some degree of scaling up to use-cases like internal dashboards.
24
+
25
+ It can be as easy as:
26
+
27
+ ```python
28
+ import mywebframework
29
+ from mplbed import mplbed_mywebframework
30
+
31
+ @mywebframework.route("/myplot")
32
+ @mplbed_mywebframework.figure_page
33
+ def my_plot_page(request):
34
+ fig, ax = plt.subplots()
35
+ ax.plot([1, 2, 3], [4, 5, 6])
36
+ return fig
37
+
38
+ app = mywebframework.App(...)
39
+ setup(app)
40
+ ```
41
+
42
+ ## Installation
43
+
44
+ Currently you can install this package from Github:
45
+
46
+ $ uv add git+https://github.com/frankier/mplbed
47
+
48
+ ## Documentation
49
+
50
+ The documentation includes [API docs](https://frankier.github.io/mplbed/api.html) and [examples](https://frankier.github.io/mplbed/examples.html).
51
+
52
+ ## Contributing
53
+
54
+ Contributions are welcome. Please discuss any larger changes in the issues
55
+ before making a pull request to avoid wasted work. You, the human contributor,
56
+ must personally have reviewed and understood any code submitted.
57
+
58
+ Set up a dev environment with [uv](https://docs.astral.sh/uv/):
59
+
60
+ $ uv sync --all-groups --all-extras
61
+
62
+ Run the tests (end-to-end tests need Playwright browsers: `uv run playwright install chromium`):
63
+
64
+ $ uv run pytest
65
+
66
+ Lint, format and typecheck:
67
+
68
+ $ uv run ruff check ./src
69
+ $ uv run ruff format ./src
70
+ $ uv run ty check ./src
71
+
72
+ Install the [prek](https://prek.j178.dev/) hook to run these automatically on commit:
73
+
74
+ $ prek install
mplbed-0.1.0/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # mplbed
2
+
3
+ ```
4
+ |[]mpl
5
+ |======|
6
+ | bed |
7
+ ```
8
+
9
+ `mplbed` is a library of support code for embedding interactive, server-side matplotlib figures in web applications.
10
+
11
+ Supporting, in principle any ASGI-compatible Python application, it provides convenient integration for a number of frameworks ones out of the box.
12
+ It aims to have "usually-works" defaults useful for quick demos, while providing a lot of flexibility and options as well as utilities to deal with advanced issues such as style isolation, and connection and resource management to enable some degree of scaling up to use-cases like internal dashboards.
13
+
14
+ It can be as easy as:
15
+
16
+ ```python
17
+ import mywebframework
18
+ from mplbed import mplbed_mywebframework
19
+
20
+ @mywebframework.route("/myplot")
21
+ @mplbed_mywebframework.figure_page
22
+ def my_plot_page(request):
23
+ fig, ax = plt.subplots()
24
+ ax.plot([1, 2, 3], [4, 5, 6])
25
+ return fig
26
+
27
+ app = mywebframework.App(...)
28
+ setup(app)
29
+ ```
30
+
31
+ ## Installation
32
+
33
+ Currently you can install this package from Github:
34
+
35
+ $ uv add git+https://github.com/frankier/mplbed
36
+
37
+ ## Documentation
38
+
39
+ The documentation includes [API docs](https://frankier.github.io/mplbed/api.html) and [examples](https://frankier.github.io/mplbed/examples.html).
40
+
41
+ ## Contributing
42
+
43
+ Contributions are welcome. Please discuss any larger changes in the issues
44
+ before making a pull request to avoid wasted work. You, the human contributor,
45
+ must personally have reviewed and understood any code submitted.
46
+
47
+ Set up a dev environment with [uv](https://docs.astral.sh/uv/):
48
+
49
+ $ uv sync --all-groups --all-extras
50
+
51
+ Run the tests (end-to-end tests need Playwright browsers: `uv run playwright install chromium`):
52
+
53
+ $ uv run pytest
54
+
55
+ Lint, format and typecheck:
56
+
57
+ $ uv run ruff check ./src
58
+ $ uv run ruff format ./src
59
+ $ uv run ty check ./src
60
+
61
+ Install the [prek](https://prek.j178.dev/) hook to run these automatically on commit:
62
+
63
+ $ prek install
@@ -0,0 +1,81 @@
1
+ [project]
2
+ name = "mplbed"
3
+ version = "0.1.0"
4
+ description = "Integrate Matplotlib's WebAgg backend with Starlette for real-time plotting in web applications."
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = [
8
+ "frozendict>=2.4.7",
9
+ "matplotlib>=3.10.9",
10
+ "starlette>=1.0.0",
11
+ "tornado>=6.5.5",
12
+ ]
13
+
14
+ [dependency-groups]
15
+ demo = [
16
+ "mne>=1.12.1",
17
+ "quart",
18
+ "django",
19
+ "daphne",
20
+ "questionary"
21
+ ]
22
+ docs = [
23
+ "sphinx>=8",
24
+ "myst-parser>=4",
25
+ "sphinx-autodoc-typehints>=2",
26
+ "furo>=2024",
27
+ "quart",
28
+ ]
29
+ test = [
30
+ "pytest>=8",
31
+ "pytest-playwright>=0.5",
32
+ "pillow>=10",
33
+ ]
34
+ qa = [
35
+ "ty",
36
+ ]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
40
+ markers = [
41
+ "e2e: end-to-end Playwright tests that drive a real browser",
42
+ ]
43
+
44
+ [tool.ruff]
45
+ line-length = 120
46
+
47
+ [tool.ruff.lint]
48
+ select = [
49
+ # pycodestyle
50
+ "E",
51
+ # Pyflakes
52
+ "F",
53
+ # pyupgrade
54
+ "UP",
55
+ # flake8-bugbear
56
+ "B",
57
+ # flake8-simplify
58
+ "SIM",
59
+ # isort
60
+ "I",
61
+ # pyupgrade
62
+ "UP",
63
+ # pydocstyle
64
+ "D",
65
+ ]
66
+
67
+ ignore = [
68
+ # Package/module-level docstrings
69
+ "D100",
70
+ "D104",
71
+ # Summary line seprate from body
72
+ "D205"
73
+ ]
74
+
75
+ [tool.ruff.lint.pydocstyle]
76
+ convention = "numpy"
77
+ ignore-decorators = ["mplbed._doc_helpers.doc", "mplbed.integration._common.setup_page_docstring"]
78
+
79
+ [build-system]
80
+ requires = ["uv_build >= 0.11.7, <0.12.0"]
81
+ build-backend = "uv_build"
@@ -0,0 +1,15 @@
1
+ from .asgi import MplbedMiddleware
2
+ from .html import raw as raw_html
3
+ from .html import safe as safe_html
4
+ from .integration import quart as mplbed_quart
5
+ from .integration import starlette as mplbed_starlette
6
+ from .server import mplbed_app_factory
7
+
8
+ __all__ = [
9
+ "mplbed_quart",
10
+ "mplbed_starlette",
11
+ "mplbed_app_factory",
12
+ "raw_html",
13
+ "safe_html",
14
+ "MplbedMiddleware",
15
+ ]
@@ -0,0 +1,52 @@
1
+ from typing import Any
2
+
3
+ from .consts import DEFAULT_PREFIX
4
+
5
+
6
+ def doc(s):
7
+ def decorator(func):
8
+ func.__doc__ = s
9
+ return func
10
+
11
+ return decorator
12
+
13
+
14
+ def fdf(s):
15
+ """_F_ix _D_ocstring _F_ragment for inclusion in a docstring template."""
16
+ from inspect import cleandoc
17
+ from textwrap import indent
18
+
19
+ return indent(cleandoc(s), " ").strip()
20
+
21
+
22
+ class DotAccessDict(dict[str, Any]):
23
+ __getattr__ = dict.get
24
+
25
+
26
+ PARAMS_DS: Any = DotAccessDict(
27
+ prefix=fdf(f"""
28
+ prefix : str, optional
29
+ The URL prefix for the routes handleded by `mplbed`. Default is '{DEFAULT_PREFIX}'.
30
+ """),
31
+ mplbed_starlette_app=lambda name="mplbed_starlette_app": fdf(f"""
32
+ {name} : Starlette, optional
33
+ The Starlette app to use for the `mplbed` routes, as returned by `mplbed_starlette_app_factory`.
34
+ If not provided, a new app will be created using the provided `mplbed_starlette_app_kwargs`.
35
+ """),
36
+ mplbed_starlette_app_kwargs=lambda name="mplbed_starlette_app_kwargs": fdf(f"""
37
+ {name} : dict, optional
38
+ Keyword arguments to pass to the Mplbed app factory if `mplbed_starlette_app` is not provided.
39
+ """),
40
+ manage_routing=fdf("""
41
+ manage_routing : bool, optional
42
+ Whether the ASGI middleware should manage routing. Default is True.
43
+ If you set this to False, you are responsible for routing requests to
44
+ the Mplbed app under the given `prefix`.
45
+ """),
46
+ native_app=fdf("""
47
+ native_app : Any, optional
48
+ The native app, e.g. the `Starlette` or `Quart` instance, which will
49
+ typically be saved in cased it is needed by the specific integration,
50
+ e.g. for rendering templates.
51
+ """),
52
+ )
@@ -0,0 +1,121 @@
1
+ from contextvars import ContextVar
2
+ from typing import Any
3
+
4
+ from starlette.applications import Starlette
5
+ from starlette.routing import Match, Mount
6
+
7
+ from mplbed._doc_helpers import PARAMS_DS as D
8
+ from mplbed._doc_helpers import doc
9
+
10
+ _native_app: ContextVar[Any] = ContextVar("_native_app", default=None)
11
+ _prefix_and_app: ContextVar[tuple[str, Starlette] | None] = ContextVar("_prefix_and_app", default=None)
12
+
13
+
14
+ class MplbedMiddleware:
15
+ """
16
+ ASGI middleware to sets up routing and ensures context the correct context is
17
+ available so that the integrations work correctly.
18
+ """
19
+
20
+ @doc(
21
+ f"""
22
+ Initialize the MplbedMiddleware.
23
+
24
+ Parameters
25
+ ----------
26
+ default_app : ASGI3Application
27
+ The default ASGI app to use (i.e. the main app, typically yours).
28
+ {D.prefix}
29
+ {D.mplbed_starlette_app("app")}
30
+ {D.mplbed_starlette_app_kwargs("app_kwargs")}
31
+ {D.manage_routing}
32
+ {D.native_app}
33
+ """
34
+ )
35
+ def __init__(
36
+ self,
37
+ default_app,
38
+ *,
39
+ prefix: str,
40
+ app=None,
41
+ app_kwargs=None,
42
+ manage_routing=True,
43
+ native_app=None,
44
+ ):
45
+ from mplbed.server import mplbed_app_factory
46
+
47
+ self.main_app = default_app
48
+ if app is None and not manage_routing:
49
+ raise ValueError(
50
+ "If manage_routing is False, you must construct and provide the app yourself "
51
+ "(otherwise how do you plan to route to it?)"
52
+ )
53
+ if app is not None:
54
+ self.mplbed_app = app
55
+ elif app_kwargs is not None:
56
+ self.mplbed_app = mplbed_app_factory(**app_kwargs)
57
+ else:
58
+ self.mplbed_app = mplbed_app_factory()
59
+ self.prefix = prefix
60
+ self.manage_routing = manage_routing
61
+ self.native_app = native_app
62
+ if manage_routing:
63
+ self.mount = Mount(prefix, self.mplbed_app)
64
+
65
+ async def __call__(self, scope, receive, send):
66
+ """ASGI entry point for the middleware."""
67
+ with (
68
+ _native_app.set(self.native_app),
69
+ _prefix_and_app.set((self.prefix, self.mplbed_app)),
70
+ ):
71
+ if self.manage_routing:
72
+ assert self.mount
73
+ match, child_scope = self.mount.matches(scope)
74
+ if match != Match.NONE:
75
+ scope.update(child_scope)
76
+ await self.mplbed_app(scope, receive, send)
77
+ return
78
+ await self.main_app(scope, receive, send)
79
+
80
+
81
+ def get_native_app():
82
+ """Get the native app from the current context.
83
+
84
+ Returns
85
+ -------
86
+ Any
87
+ The native app, or None if not set.
88
+
89
+ """
90
+ return _native_app.get()
91
+
92
+
93
+ def get_asgi_app():
94
+ """Get the ASGI app from the current context.
95
+
96
+ Returns
97
+ -------
98
+ ASGI3Application | None
99
+ The ASGI app, or None if not set.
100
+
101
+ """
102
+ val = _prefix_and_app.get()
103
+ if val is not None:
104
+ _, app = val
105
+ return app
106
+ return None
107
+
108
+
109
+ def url_path_for(name, **path_params):
110
+ """Get the URL path for a given route name and parameters."""
111
+ prefix_and_app = path_params.pop("_prefix_and_app", None)
112
+ if prefix_and_app is None:
113
+ prefix_and_app = _prefix_and_app.get()
114
+ if prefix_and_app is None:
115
+ raise RuntimeError(
116
+ "Missing current prefix_and_app in context! "
117
+ "Did you install the MlpbedMiddleware? (_prefix_and_app was not passed)"
118
+ )
119
+ prefix, app = prefix_and_app
120
+ path = app.url_path_for(name, **path_params)
121
+ return prefix + path
@@ -0,0 +1,2 @@
1
+ DEFAULT_PREFIX = "/webagg"
2
+ HEAD_TEMPLATE_VARIABLE_NAME = "mplbed_head"
File without changes
@@ -0,0 +1,149 @@
1
+ from mplbed.asgi import url_path_for
2
+
3
+
4
+ def figure_html_from_id(fig_id, *, target="inline", on_close="msg_discrete", prefix_and_app=None):
5
+ from json import dumps
6
+
7
+ ws_uri = url_path_for("websocket", fig_id=fig_id, _prefix_and_app=prefix_and_app)
8
+ ws_uri_str = dumps(ws_uri)
9
+ download_fig_uri = url_path_for("download_fig", fig_id=fig_id, fmt="{fmt}", _prefix_and_app=prefix_and_app)
10
+ download_fig_uri_str = dumps(download_fig_uri)
11
+ container = ""
12
+ setup_container = ""
13
+ if target == "inline":
14
+ container = "<div></div>"
15
+ target_js = "document.currentScript.previousElementSibling"
16
+ elif target == "body":
17
+ target_js = "document.body"
18
+ elif target == "modal":
19
+ container = """
20
+ <dialog closedby="any" style="padding: 1em; margin: 0 auto;"></dialog>
21
+ """.strip()
22
+ target_js = "document.currentScript.previousElementSibling"
23
+ setup_container = """
24
+ _mpl_webaggext.mk_modal(document.currentScript.previousElementSibling, fig);
25
+ """.strip()
26
+ else:
27
+ raise ValueError(f"Invalid target: {target}")
28
+ if on_close == "remove_dialog":
29
+ on_close = ["remove_parent", "dialog"]
30
+ on_close_js = dumps(on_close)
31
+ create_figure = f"""
32
+ let fig = _mpl_webaggext.new_fig(
33
+ {target_js},
34
+ {fig_id},
35
+ {ws_uri_str},
36
+ {download_fig_uri_str},
37
+ {on_close_js}
38
+ );
39
+ """.strip()
40
+ bits = (
41
+ container,
42
+ """
43
+ <script>
44
+ (function() {
45
+ """.strip(),
46
+ create_figure,
47
+ setup_container,
48
+ """
49
+ })();
50
+ </script>
51
+ """.strip(),
52
+ )
53
+ return "\n".join(bits)
54
+
55
+
56
+ def figure_html(figure, *retains, target="inline", on_close="msg_discrete"):
57
+ from matplotlib.pyplot import _get_backend_mod as get_backend_mod
58
+
59
+ from mplbed.server._impl import add_manager
60
+
61
+ manager = get_backend_mod().new_figure_manager_given_figure(id(figure), figure)
62
+ if hasattr(manager, "add_retains"):
63
+ manager.add_retains(*retains) # ty: ignore
64
+ else:
65
+ if not hasattr(figure, "_retains"):
66
+ figure._retains = []
67
+ figure._retains.extend(retains)
68
+ add_manager(manager)
69
+ return figure_html_from_id(manager.num, target=target, on_close=on_close)
70
+
71
+
72
+ def default_figure_page_template(*, head, fig, title):
73
+ return f"""<!DOCTYPE html>
74
+ <html lang="en">
75
+ <head>
76
+ {head}
77
+ <title>{title}</title>
78
+ </head>
79
+ <body>
80
+ {fig}
81
+ </body>
82
+ </html>
83
+ """
84
+
85
+
86
+ _template_preview = default_figure_page_template(head="{head}", fig="{fig}", title="{title}")
87
+ _indented = "\n".join(" " + line for line in _template_preview.splitlines())
88
+ default_figure_page_template.__doc__ = (
89
+ "Applies the following template to the given ``head``, ``fig`` and ``title``:\n\n"
90
+ ".. code-block:: html\n\n" + _indented + "\n"
91
+ )
92
+ del _template_preview, _indented
93
+
94
+
95
+ def head_content(*, core=False, prefix_and_app=None):
96
+ css_files = []
97
+ if not core:
98
+ css_files.extend(["page", "boilerplate", "fbm"])
99
+ css_files.append("mpl")
100
+ head_bits = []
101
+ for css_file in css_files:
102
+ static_url = url_path_for("static", path=f"css/{css_file}.css", _prefix_and_app=prefix_and_app)
103
+ head_bits.append(
104
+ f"""
105
+ <link rel="stylesheet" href="{static_url}" type="text/css">
106
+ """.strip()
107
+ )
108
+ mpl_js_uri = url_path_for("mpl_js", _prefix_and_app=prefix_and_app)
109
+ head_bits.append(
110
+ f"""
111
+ <script src="{mpl_js_uri}"></script>
112
+ """.strip()
113
+ )
114
+ webaggext_js_uri = url_path_for("webaggext_js", _prefix_and_app=prefix_and_app)
115
+ head_bits.append(
116
+ f"""
117
+ <script src="{webaggext_js_uri}"></script>
118
+ """.strip()
119
+ )
120
+ head_bits.append(
121
+ """
122
+ <style>
123
+ .mpl-toolbar {
124
+ position: relative;
125
+ padding-bottom: 2em;
126
+ }
127
+ .mpl-message {
128
+ position: absolute;
129
+ left: 0;
130
+ bottom: 0;
131
+ white-space: nowrap;
132
+ overflow-x: auto;
133
+ width: 100%;
134
+ }
135
+ .mpl-figure-root {
136
+ display: inline-flex !important;
137
+ flex-direction: column;
138
+ }
139
+ </style>
140
+ """.strip()
141
+ )
142
+ return "\n".join(head_bits)
143
+
144
+
145
+ def figure_page_html(fig, *, template=default_figure_page_template):
146
+ fig_html = figure_html(fig, target="body")
147
+ head = head_content(core=True)
148
+ resp_html = template(head=head, title="figure", fig=fig_html)
149
+ return resp_html
@@ -0,0 +1,15 @@
1
+ from mplbed.html._impl import (
2
+ default_figure_page_template,
3
+ figure_html,
4
+ figure_html_from_id,
5
+ figure_page_html,
6
+ head_content,
7
+ )
8
+
9
+ __all__ = [
10
+ "figure_html_from_id",
11
+ "figure_html",
12
+ "head_content",
13
+ "figure_page_html",
14
+ "default_figure_page_template",
15
+ ]
@@ -0,0 +1,22 @@
1
+ from mplbed.html import _impl
2
+
3
+
4
+ def _wrap_markupsafe(func):
5
+ def wrapper(*args, **kwargs):
6
+ from markupsafe import Markup
7
+
8
+ return Markup(func(*args, **kwargs))
9
+
10
+ return wrapper
11
+
12
+
13
+ figure_html_from_id = _wrap_markupsafe(_impl.figure_html_from_id)
14
+ head_content = _wrap_markupsafe(_impl.head_content)
15
+ figure_html = _wrap_markupsafe(_impl.figure_html)
16
+
17
+
18
+ __all__ = [
19
+ "figure_html_from_id",
20
+ "figure_html",
21
+ "head_content",
22
+ ]