Skip to content

Commit

Permalink
docs: Adds Vega-Altair Theme Test (#3630)
Browse files Browse the repository at this point in the history
  • Loading branch information
dangotbanned authored Oct 17, 2024
1 parent 796649e commit 0245e0f
Show file tree
Hide file tree
Showing 9 changed files with 952 additions and 88 deletions.
1 change: 1 addition & 0 deletions doc/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"sphinxext_altair.altairplot",
"sphinxext.altairgallery",
"sphinxext.schematable",
"sphinxext.code_ref",
"sphinx_copybutton",
"sphinx_design",
]
Expand Down
18 changes: 15 additions & 3 deletions doc/user_guide/customization.rst
Original file line number Diff line number Diff line change
Expand Up @@ -787,10 +787,16 @@ If you would like to use any theme just for a single chart, you can use the
with alt.themes.enable('default'):
spec = chart.to_json()

Built-in Themes
~~~~~~~~~~~~~~~
Currently Altair does not offer many built-in themes, but we plan to add
more options in the future.

See `Vega Theme Test`_ for an interactive demo of themes inherited from `Vega Themes`_.
You can get a feel for the themes inherited from `Vega Themes`_ via *Vega-Altair Theme Test* below:

.. altair-theme:: tests.altair_theme_test.alt_theme_test
:fold:
:summary: Show Vega-Altair Theme Test

Defining a Custom Theme
~~~~~~~~~~~~~~~~~~~~~~~
Expand Down Expand Up @@ -843,6 +849,13 @@ If you want to restore the default theme, use:

alt.themes.enable('default')

When experimenting with your theme, you can use the code below to see how
it translates across a range of charts/marks:

.. altair-code-ref:: tests.altair_theme_test.alt_theme_test
:fold:
:summary: Show Vega-Altair Theme Test code


For more ideas on themes, see the `Vega Themes`_ repository.

Expand Down Expand Up @@ -889,5 +902,4 @@ The configured localization settings persist upon saving.
alt.renderers.set_embed_options(format_locale="en-US", time_format_locale="en-US")

.. _Vega Themes: https://github.com/vega/vega-themes/
.. _`D3's localization support`: https://d3-wiki.readthedocs.io/zh-cn/master/Localization/
.. _Vega Theme Test: https://vega.github.io/vega-themes/?renderer=canvas
.. _`D3's localization support`: https://d3-wiki.readthedocs.io/zh-cn/master/Localization/
330 changes: 330 additions & 0 deletions sphinxext/code_ref.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,330 @@
"""Sphinx extension providing formatted code blocks, referencing some function."""

from __future__ import annotations

from typing import TYPE_CHECKING, Literal, get_args

from docutils import nodes
from docutils.parsers.rst import directives
from sphinx.util.docutils import SphinxDirective
from sphinx.util.parsing import nested_parse_to_nodes

from altair.vegalite.v5.schema._typing import VegaThemes
from tools.codemod import extract_func_def, extract_func_def_embed

if TYPE_CHECKING:
import sys
from typing import (
Any,
Callable,
ClassVar,
Iterable,
Iterator,
Mapping,
Sequence,
TypeVar,
Union,
)

from docutils.parsers.rst.states import RSTState, RSTStateMachine
from docutils.statemachine import StringList
from sphinx.application import Sphinx

if sys.version_info >= (3, 12):
from typing import TypeAliasType
else:
from typing_extensions import TypeAliasType
if sys.version_info >= (3, 10):
from typing import TypeAlias
else:
from typing_extensions import TypeAlias

T = TypeVar("T")
OneOrIter = TypeAliasType("OneOrIter", Union[T, Iterable[T]], type_params=(T,))

_OutputShort: TypeAlias = Literal["code", "plot"]
_OutputLong: TypeAlias = Literal["code-block", "altair-plot"]
_OUTPUT_REMAP: Mapping[_OutputShort, _OutputLong] = {
"code": "code-block",
"plot": "altair-plot",
}
_Option: TypeAlias = Literal["output", "fold", "summary"]

_PYSCRIPT_URL_FMT = "https://pyscript.net/releases/{0}/core.js"
_PYSCRIPT_VERSION = "2024.10.1"
_PYSCRIPT_URL = _PYSCRIPT_URL_FMT.format(_PYSCRIPT_VERSION)


def validate_output(output: Any) -> _OutputLong:
output = output.strip().lower()
if output not in {"plot", "code"}:
msg = f":output: option must be one of {get_args(_OutputShort)!r}"
raise TypeError(msg)
else:
short: _OutputShort = output
return _OUTPUT_REMAP[short]


def validate_packages(packages: Any) -> str:
if packages is None:
return '["altair"]'
else:
split = [pkg.strip() for pkg in packages.split(",")]
if len(split) == 1:
return f'["{split[0]}"]'
else:
return f'[{",".join(split)}]'


def raw_html(text: str, /) -> nodes.raw:
return nodes.raw("", text, format="html")


def maybe_details(
parsed: Iterable[nodes.Node], options: dict[_Option, Any], *, default_summary: str
) -> Sequence[nodes.Node]:
"""
Wrap ``parsed`` in a folding `details`_ block if requested.
Parameters
----------
parsed
Target nodes that have been processed.
options
Optional arguments provided to ``.. altair-code-ref::``.
.. note::
If no relevant options are specified,
``parsed`` is returned unchanged.
default_summary
Label text used when **only** specifying ``:fold:``.
.. _details:
https://developer.mozilla.org/en-US/docs/Web/HTML/Element/details
"""

def gen() -> Iterator[nodes.Node]:
if {"fold", "summary"}.isdisjoint(options.keys()):
yield from parsed
else:
summary = options.get("summary", default_summary)
yield raw_html(f"<p><details><summary><a>{summary}</a></summary>")
yield from parsed
yield raw_html("</details></p>")

return list(gen())


def theme_names() -> tuple[Sequence[str], Sequence[str]]:
names: set[VegaThemes] = set(get_args(VegaThemes))
carbon = {nm for nm in names if nm.startswith("carbon")}
return ["default", *sorted(names - carbon)], sorted(carbon)


def option(label: str, value: str | None = None, /) -> nodes.raw:
s = f"<option value={value!r}>" if value else "<option>"
return raw_html(f"{s}{label}</option>\n")


def optgroup(label: str, *options: OneOrIter[nodes.raw]) -> Iterator[nodes.raw]:
yield raw_html(f"<optgroup label={label!r}>\n")
for opt in options:
if isinstance(opt, nodes.raw):
yield opt
else:
yield from opt
yield raw_html("</optgroup>\n")


def dropdown(
id: str, label: str | None, extra_select: str, *options: OneOrIter[nodes.raw]
) -> Iterator[nodes.raw]:
if label:
yield raw_html(f"<label for={id!r}>{label}</label>\n")
select_text = f"<select id={id!r}"
if extra_select:
select_text = f"{select_text} {extra_select}"
yield raw_html(f"{select_text}>\n")
for opt in options:
if isinstance(opt, nodes.raw):
yield opt
else:
yield from opt
yield raw_html("</select>\n")


def pyscript(
packages: str, target_div_id: str, loading_label: str, py_code: str
) -> Iterator[nodes.raw]:
PY = "py"
LB, RB = "{", "}"
packages = f""""packages":{packages}"""
yield raw_html(f"<div id={target_div_id!r}>{loading_label}</div>\n")
yield raw_html(f"<script type={PY!r} config='{LB}{packages}{RB}'>\n")
yield raw_html(py_code)
yield raw_html("</script>\n")


def _before_code(refresh_name: str, select_id: str, target_div_id: str) -> str:
INDENT = " " * 4
return (
f"from js import document\n"
f"from pyscript import display\n"
f"import altair as alt\n\n"
f"def {refresh_name}(*args):\n"
f"{INDENT}selected = document.getElementById({select_id!r}).value\n"
f"{INDENT}alt.renderers.set_embed_options(theme=selected)\n"
f"{INDENT}display(chart, append=False, target={target_div_id!r})\n"
)


class ThemeDirective(SphinxDirective):
"""
Theme preview directive.
Similar to ``CodeRefDirective``, but uses `PyScript`_ to access the browser.
.. _PyScript:
https://pyscript.net/
"""

has_content: ClassVar[Literal[False]] = False
required_arguments: ClassVar[Literal[1]] = 1
option_spec = {
"packages": validate_packages,
"dropdown-label": directives.unchanged,
"loading-label": directives.unchanged,
"fold": directives.flag,
"summary": directives.unchanged_required,
}

def run(self) -> Sequence[nodes.Node]:
results: list[nodes.Node] = []
SELECT_ID = "embed_theme"
REFRESH_NAME = "apply_embed_input"
TARGET_DIV_ID = "render_altair"
standard_names, carbon_names = theme_names()

qual_name = self.arguments[0]
module_name, func_name = qual_name.rsplit(".", 1)
dropdown_label = self.options.get("dropdown-label", "Select theme:")
loading_label = self.options.get("loading-label", "loading...")
packages: str = self.options.get("packages", validate_packages(None))

results.append(raw_html("<div><p>\n"))
results.extend(
dropdown(
SELECT_ID,
dropdown_label,
f"py-input={REFRESH_NAME!r}",
(option(nm) for nm in standard_names),
optgroup("Carbon", (option(nm) for nm in carbon_names)),
)
)
py_code = extract_func_def_embed(
module_name,
func_name,
before=_before_code(REFRESH_NAME, SELECT_ID, TARGET_DIV_ID),
after=f"{REFRESH_NAME}()",
assign_to="chart",
indent=4,
)
results.extend(
pyscript(packages, TARGET_DIV_ID, loading_label, py_code=py_code)
)
results.append(raw_html("</div></p>\n"))
return maybe_details(
results, self.options, default_summary="Show Vega-Altair Theme Test"
)


class PyScriptDirective(SphinxDirective):
"""Placeholder for non-theme related directive."""

has_content: ClassVar[Literal[False]] = False
option_spec = {"packages": directives.unchanged}

def run(self) -> Sequence[nodes.Node]:
raise NotImplementedError


class CodeRefDirective(SphinxDirective):
"""
Formatted code block, referencing the contents of a function definition.
Options:
.. altair-code-ref::
:output: [code, plot]
:fold: flag
:summary: str
Examples
--------
Reference a function, generating a code block:
.. altair-code-ref:: package.module.function
Wrap the code block in a collapsible `details`_ tag:
.. altair-code-ref:: package.module.function
:fold:
Override default ``"Show code"`` `details`_ summary:
.. altair-code-ref:: package.module.function
:fold:
:summary: Look here!
Use `altair-plot`_ instead of a code block:
.. altair-code-ref:: package.module.function
:output: plot
.. note::
Using `altair-plot`_ currently ignores the other options.
.. _details:
https://developer.mozilla.org/en-US/docs/Web/HTML/Element/details
.. _altair-plot:
https://github.com/vega/sphinxext-altair
"""

has_content: ClassVar[Literal[False]] = False
required_arguments: ClassVar[Literal[1]] = 1
option_spec: ClassVar[dict[_Option, Callable[[str], Any]]] = {
"output": validate_output,
"fold": directives.flag,
"summary": directives.unchanged_required,
}

def __init__(
self,
name: str,
arguments: list[str],
options: dict[_Option, Any],
content: StringList,
lineno: int,
content_offset: int,
block_text: str,
state: RSTState,
state_machine: RSTStateMachine,
) -> None:
super().__init__(name, arguments, options, content, lineno, content_offset, block_text, state, state_machine) # fmt: skip
self.options: dict[_Option, Any]

def run(self) -> Sequence[nodes.Node]:
qual_name = self.arguments[0]
module_name, func_name = qual_name.rsplit(".", 1)
output: _OutputLong = self.options.get("output", "code-block")
content = extract_func_def(module_name, func_name, output=output)
parsed = nested_parse_to_nodes(self.state, content)
return maybe_details(parsed, self.options, default_summary="Show code")


def setup(app: Sphinx) -> None:
app.add_directive_to_domain("py", "altair-code-ref", CodeRefDirective)
app.add_js_file(_PYSCRIPT_URL, loading_method="defer", type="module")
# app.add_directive("altair-pyscript", PyScriptDirective)
app.add_directive("altair-theme", ThemeDirective)
Loading

0 comments on commit 0245e0f

Please sign in to comment.