From 756b37f892362120ebe09a89ee5b69c66c7da8ff Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Wed, 7 Aug 2024 16:29:13 +0100 Subject: [PATCH 1/5] feat: Adds `@register_theme` decorator Resolves one item in https://github.com/vega/altair/issues/3519 --- altair/vegalite/v5/theme.py | 92 +++++++++++++++++++++++++++++++++++-- 1 file changed, 89 insertions(+), 3 deletions(-) diff --git a/altair/vegalite/v5/theme.py b/altair/vegalite/v5/theme.py index a3c621658..d65f7ea80 100644 --- a/altair/vegalite/v5/theme.py +++ b/altair/vegalite/v5/theme.py @@ -2,13 +2,23 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Final, Literal +import sys +from functools import wraps +from typing import TYPE_CHECKING, Any, Callable, Dict, Final, Literal, TypeVar + +if sys.version_info >= (3, 10): + from typing import ParamSpec +else: + from typing_extensions import ParamSpec + from altair.utils.theme import ThemeRegistry if TYPE_CHECKING: - import sys - + if sys.version_info >= (3, 11): + from typing import LiteralString + else: + from typing_extensions import LiteralString if sys.version_info >= (3, 10): from typing import TypeAlias else: @@ -53,6 +63,10 @@ ] +P = ParamSpec("P") +R = TypeVar("R", bound=Dict[str, Any]) + + class VegaTheme: """Implementation of a builtin vega theme.""" @@ -94,3 +108,75 @@ def __repr__(self) -> str: themes.register(theme, VegaTheme(theme)) themes.enable("default") + + +def register_theme( + name: LiteralString, *, enable: bool +) -> Callable[[Callable[P, R]], Callable[P, R]]: + """ + Decorator for registering a theme function. + + Parameters + ---------- + name + Unique name assigned in ``alt.themes``. + enable + Auto-enable the wrapped theme. + + Examples + -------- + Register and enable a theme:: + + from __future__ import annotations + + from typing import Any + import altair as alt + + + @alt.register_theme("param_font_size", enable=True) + def custom_theme() -> dict[str, Any]: + sizes = 12, 14, 16, 18, 20 + return { + "autosize": {"contains": "content", "resize": True}, + "background": "#F3F2F1", + "config": { + "axisX": {"labelFontSize": sizes[1], "titleFontSize": sizes[1]}, + "axisY": {"labelFontSize": sizes[1], "titleFontSize": sizes[1]}, + "font": "'Lato', 'Segoe UI', Tahoma, Verdana, sans-serif", + "headerColumn": {"labelFontSize": sizes[1]}, + "headerFacet": {"labelFontSize": sizes[1]}, + "headerRow": {"labelFontSize": sizes[1]}, + "legend": {"labelFontSize": sizes[0], "titleFontSize": sizes[1]}, + "text": {"fontSize": sizes[0]}, + "title": {"fontSize": sizes[-1]}, + }, + "height": {"step": 28}, + "width": 350, + } + + Until another theme has been enabled, all charts will use defaults set in ``custom_theme``:: + + from vega_datasets import data + + source = data.stocks() + lines = ( + alt.Chart(source, title=alt.Title("Stocks")) + .mark_line() + .encode(x="date:T", y="price:Q", color="symbol:N") + ) + lines.interactive(bind_y=False) + + """ + + def decorate(func: Callable[P, R], /) -> Callable[P, R]: + themes.register(name, func) + if enable: + themes.enable(name) + + @wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + return func(*args, **kwargs) + + return wrapper + + return decorate From 3a4f276db6f3ab0c19174dfc87c3ab3ca27cb20b Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Wed, 7 Aug 2024 16:34:02 +0100 Subject: [PATCH 2/5] build: run `update-init-file` Adds `@register_theme` to top-level --- altair/__init__.py | 1 + altair/vegalite/v5/__init__.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/altair/__init__.py b/altair/__init__.py index d6c03f48a..2ea47599f 100644 --- a/altair/__init__.py +++ b/altair/__init__.py @@ -620,6 +620,7 @@ "mixins", "param", "parse_shorthand", + "register_theme", "renderers", "repeat", "sample", diff --git a/altair/vegalite/v5/__init__.py b/altair/vegalite/v5/__init__.py index bc0703ec6..a18be6e11 100644 --- a/altair/vegalite/v5/__init__.py +++ b/altair/vegalite/v5/__init__.py @@ -21,4 +21,4 @@ renderers, ) from .schema import * -from .theme import themes +from .theme import register_theme, themes From 14e9c743d843fe221aff6472a31ce77ba89f4a67 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Thu, 8 Aug 2024 15:00:51 +0100 Subject: [PATCH 3/5] test: Adds `test_register_theme_decorator` --- tests/vegalite/v5/test_theme.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/tests/vegalite/v5/test_theme.py b/tests/vegalite/v5/test_theme.py index 0eab5546d..fa6be95ac 100644 --- a/tests/vegalite/v5/test_theme.py +++ b/tests/vegalite/v5/test_theme.py @@ -1,7 +1,9 @@ +from __future__ import annotations + import pytest import altair.vegalite.v5 as alt -from altair.vegalite.v5.theme import VEGA_THEMES +from altair.vegalite.v5.theme import VEGA_THEMES, register_theme, themes @pytest.fixture @@ -9,7 +11,7 @@ def chart(): return alt.Chart("data.csv").mark_bar().encode(x="x:Q") -def test_vega_themes(chart): +def test_vega_themes(chart) -> None: for theme in VEGA_THEMES: with alt.themes.enable(theme): dct = chart.to_dict() @@ -17,3 +19,14 @@ def test_vega_themes(chart): assert dct["config"] == { "view": {"continuousWidth": 300, "continuousHeight": 300} } + + +def test_register_theme_decorator() -> None: + @register_theme("unique name", enable=True) + def custom_theme() -> dict[str, int]: + return {"height": 400, "width": 700} + + assert themes.active == "unique name" + registered = themes.get() + assert registered is not None + assert registered() == {"height": 400, "width": 700} == custom_theme() From b5798ab54a7d991ab1aafefa4f1b1cf8e8cd83fc Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Thu, 8 Aug 2024 15:27:37 +0100 Subject: [PATCH 4/5] refactor(typing): Specify `dict[str, Any]` instead of `dict[Any, Any]` The latter may give false-positives for json-incompatible dicts --- altair/utils/plugin_registry.py | 2 +- altair/utils/theme.py | 8 ++++---- altair/vegalite/v5/theme.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/altair/utils/plugin_registry.py b/altair/utils/plugin_registry.py index 996c6623e..b2723396a 100644 --- a/altair/utils/plugin_registry.py +++ b/altair/utils/plugin_registry.py @@ -115,7 +115,7 @@ def __init__( self.entry_point_group: str = entry_point_group self.plugin_type: IsPlugin if plugin_type is not callable and isinstance(plugin_type, type): - msg = ( + msg: Any = ( f"Pass a callable `TypeIs` function to `plugin_type` instead.\n" f"{type(self).__name__!r}(plugin_type)\n\n" f"See also:\n" diff --git a/altair/utils/theme.py b/altair/utils/theme.py index 51929a5fc..b3ee08b1f 100644 --- a/altair/utils/theme.py +++ b/altair/utils/theme.py @@ -3,9 +3,9 @@ from __future__ import annotations import sys -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING, Any, Dict -from .plugin_registry import PluginRegistry +from .plugin_registry import Plugin, PluginRegistry if sys.version_info >= (3, 11): from typing import LiteralString @@ -16,10 +16,10 @@ from altair.utils.plugin_registry import PluginEnabler from altair.vegalite.v5.theme import _ThemeName -ThemeType = Callable[..., dict] +ThemeType = Plugin[Dict[str, Any]] -class ThemeRegistry(PluginRegistry[ThemeType, dict]): +class ThemeRegistry(PluginRegistry[ThemeType, Dict[str, Any]]): def enable( self, name: LiteralString | _ThemeName | None = None, **options ) -> PluginEnabler: diff --git a/altair/vegalite/v5/theme.py b/altair/vegalite/v5/theme.py index d65f7ea80..8aa883ffb 100644 --- a/altair/vegalite/v5/theme.py +++ b/altair/vegalite/v5/theme.py @@ -45,7 +45,7 @@ ] # If you add a theme here, also add it in `_ThemeName` above. -VEGA_THEMES = [ +VEGA_THEMES: list[LiteralString] = [ "carbonwhite", "carbong10", "carbong90", From 9ce798c15ec01c57d2d810d9215815a3a75bda92 Mon Sep 17 00:00:00 2001 From: dangotbanned <125183946+dangotbanned@users.noreply.github.com> Date: Thu, 5 Sep 2024 10:09:24 +0100 Subject: [PATCH 5/5] docs: Adds comments re `LiteralString` https://github.com/vega/altair/pull/3526#pullrequestreview-2281646031 --- altair/utils/theme.py | 2 ++ altair/vegalite/v5/theme.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/altair/utils/theme.py b/altair/utils/theme.py index 65c1d36c6..47e5da6ad 100644 --- a/altair/utils/theme.py +++ b/altair/utils/theme.py @@ -19,6 +19,8 @@ ThemeType = Plugin[Dict[str, Any]] +# HACK: See for `LiteralString` requirement in `name` +# https://github.com/vega/altair/pull/3526#discussion_r1743350127 class ThemeRegistry(PluginRegistry[ThemeType, Dict[str, Any]]): def enable( self, name: LiteralString | AltairThemes | VegaThemes | None = None, **options diff --git a/altair/vegalite/v5/theme.py b/altair/vegalite/v5/theme.py index c9a3107a5..2e438679b 100644 --- a/altair/vegalite/v5/theme.py +++ b/altair/vegalite/v5/theme.py @@ -74,6 +74,8 @@ def __repr__(self) -> str: themes.enable("default") +# HACK: See for `LiteralString` requirement in `name` +# https://github.com/vega/altair/pull/3526#discussion_r1743350127 def register_theme( name: LiteralString, *, enable: bool ) -> Callable[[Callable[P, R]], Callable[P, R]]: