Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

docs: Adds Vega-Altair Theme Test #3630

Merged
merged 40 commits into from
Oct 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
db3557d
docs: Adds `altair_theme_test.py`
dangotbanned Oct 3, 2024
bc60d23
chore: Vendor `alt.utils.html.STANDARD` template
dangotbanned Oct 4, 2024
ffc8345
refactor: Remove static conditionals in template
dangotbanned Oct 4, 2024
4b4d4fd
feat: Add theme select input
dangotbanned Oct 4, 2024
bad2bcf
chore: Add temporary `render_write` helper
dangotbanned Oct 4, 2024
f7aeef8
refactor: Move chart concat into function
dangotbanned Oct 4, 2024
0fae2ab
feat(DRAFT): Adapt more of `index.html` script
dangotbanned Oct 5, 2024
f6971de
fix: Pass theme name to correct property
dangotbanned Oct 5, 2024
d59ce76
docs: Ensure all charts have tooltips
dangotbanned Oct 5, 2024
23a2f91
docs: Adds static `vega-altair_theme_test.html`
dangotbanned Oct 5, 2024
8c6544c
ci: Temp pin `mypy`
dangotbanned Oct 5, 2024
0b07f27
revert: Remove temp `mypy` pin
dangotbanned Oct 5, 2024
3e72fe1
Merge branch 'main' into altair-theme-test
dangotbanned Oct 5, 2024
d1ea691
docs: Link to `vega-altair_theme_test.html` in `#chart-themes`
dangotbanned Oct 5, 2024
436a65d
refactor: Remove `polars` dependency
dangotbanned Oct 5, 2024
f466d8a
refactor: Move entire chart definition to `alt_theme_test`
dangotbanned Oct 5, 2024
85ba0b6
refactor: Move imports inline
dangotbanned Oct 5, 2024
1773498
feat: Adds `tools.codemod.py`
dangotbanned Oct 5, 2024
1945d2b
feat: Adds `.. altair-code-ref::` directive
dangotbanned Oct 6, 2024
8221d6c
docs: Add folding code block for `Vega-Altair Theme Test`
dangotbanned Oct 6, 2024
db14314
fix(typing): Remove unused type ignore
dangotbanned Oct 6, 2024
aa00dc1
fix(typing): Add patch for `vl-convert-python=1.7.0`
dangotbanned Oct 6, 2024
712c646
Merge remote-tracking branch 'upstream/main' into altair-theme-test
dangotbanned Oct 6, 2024
6e1ca94
refactor: Render html after `generate_api_docs`
dangotbanned Oct 6, 2024
3a5801e
style: Trim some whitespace
dangotbanned Oct 6, 2024
42491c7
Merge branch 'main' into altair-theme-test
dangotbanned Oct 12, 2024
b04dc02
Merge branch 'main' into altair-theme-test
dangotbanned Oct 13, 2024
6568cdf
refactor: Factor out `vega_datasets` dependency
dangotbanned Oct 13, 2024
85a2a4f
fix: Run `generate-schema-wrapper`
dangotbanned Oct 13, 2024
2fe68f8
fix(typing): Add type ignore
dangotbanned Oct 14, 2024
0694233
feat(DRAFT): Adds Functional `altair-pyscript` directive
dangotbanned Oct 14, 2024
6803368
revert: Remove `generate_static_docs`
dangotbanned Oct 14, 2024
99400d5
refactor: Rewrite as `altair-theme`
dangotbanned Oct 14, 2024
cbdf854
feat: Support `:summary:` in `altair-theme`
dangotbanned Oct 15, 2024
7cf627d
docs: Add warning for `ast.unparse` use
dangotbanned Oct 16, 2024
0615228
refactor: Adds `tools.codemod.Ruff`
dangotbanned Oct 16, 2024
567796e
docs: Redesign to fit User Guide template
dangotbanned Oct 17, 2024
c82ecbf
revert: Remove `THEME_TEST_TEMPLATE`, `render_theme_test`
dangotbanned Oct 17, 2024
092fe09
docs: Adds *Built-in Themes* section
dangotbanned Oct 17, 2024
1987542
revert: Remove `vega-altair_theme_test.html`
dangotbanned Oct 17, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"]'
dangotbanned marked this conversation as resolved.
Show resolved Hide resolved
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