Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
29 changes: 28 additions & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
"""Configuration file for the Sphinx documentation builder."""

import dataclasses as dc
import os

from sphinx_design.config import SdConfig

project = "Sphinx Design"
copyright = "2021, Executable Book Project"
author = "Executable Book Project"
Expand Down Expand Up @@ -118,7 +121,31 @@
"html_image",
]


def _sd_config_options_table() -> str:
"""Generate a Markdown table of all sphinx-design configuration options,
from the ``SdConfig`` dataclass fields.
"""
rows = [
"| Name | Type | Default | Description |",
"| ---- | ---- | ------- | ----------- |",
]
for field in dc.fields(SdConfig):
default = (
field.default_factory()
if field.default_factory is not dc.MISSING
else field.default
)
type_str = field.metadata.get("doc_type", field.type)
rows.append(
f"| `sd_{field.name}` | `{type_str}` | `{default!r}` "
f"| {field.metadata.get('help', '')} |"
)
return "\n".join(rows)


myst_substitutions = {
"sd_config_options": _sd_config_options_table(),
"loremipsum": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. "
"Sed iaculis arcu vitae odio gravida congue. Donec porttitor ac risus et condimentum. "
"Phasellus bibendum ac risus a sollicitudin. "
Expand All @@ -129,5 +156,5 @@
"Aliquam sed lectus ac nisl sollicitudin ultricies id at neque. "
"Aliquam fringilla odio vitae lorem ornare, sit amet scelerisque orci fringilla. "
"Nam sed arcu dignissim, ultrices quam sit amet, commodo ipsum. "
"Etiam quis nunc at ligula tincidunt eleifend."
"Etiam quis nunc at ligula tincidunt eleifend.",
}
9 changes: 9 additions & 0 deletions docs/get_started.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ The MyST Markdown examples in this documentation assume that certain optional [M

## Configuration

### Global options

All global configuration options are prefixed with `sd_`, and can be set in your `conf.py`.
Values are always simple, TOML-compatible, data types:

{{ sd_config_options }}

### Hiding the page title

To hide the title header of a page, add to the top of the page:

::::{tab-set}
Expand Down
278 changes: 278 additions & 0 deletions sphinx_design/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
"""Central declarative configuration for sphinx-design.

All global configuration is declared on the :class:`SdConfig` dataclass:
every option is declared once, with its type, default, validator and help text.

Every value is plain, TOML-compatible, data
(``str``/``bool``/``int``/``list``/``dict`` of primitives),
so that the configuration could also be read from a TOML file,
or be understood by non-Python implementations.

The values are registered with Sphinx as flat, ``sd_`` prefixed,
configuration values (e.g. ``fontawesome_latex`` -> ``sd_fontawesome_latex``),
which remain the public interface.
Modules should read the validated configuration via :func:`get_sd_config`,
rather than accessing ``config.sd_*`` attributes directly.

The field validators mirror the approach of
https://github.com/python-attrs/attrs validators:
they take ``(inst, field, value)`` and raise on invalid values.
"""

from __future__ import annotations

import dataclasses as dc
from typing import TYPE_CHECKING, Any, Protocol

from sphinx.util.logging import getLogger

if TYPE_CHECKING:
from sphinx.application import Sphinx
from sphinx.config import Config
from sphinx.environment import BuildEnvironment

LOGGER = getLogger(__name__)

WARNING_TYPE = "design"
"""Type of warnings emitted by sphinx-design (i.e. ``design.<subtype>``)."""


class ValidatorType(Protocol):
"""Protocol for a dataclass field validator."""

def __call__(
self, inst: Any, field: dc.Field[Any], value: Any, suffix: str = ""
) -> None:
"""Validate the value of a dataclass field, raising if invalid.

:param inst: The dataclass instance (or None if not yet created).
:param field: The dataclass field.
:param value: The value to validate.
:param suffix: Suffix to append to the field name in error messages.
:raises TypeError | ValueError: If the value is invalid.
"""


def validate_field(inst: Any, field: dc.Field[Any], value: Any) -> None:
"""Validate the field of a dataclass,
according to a ``validator`` function set in the field metadata.

:param inst: The dataclass instance (or None if not yet created).
:param field: The dataclass field.
:param value: The value to validate.
:raises TypeError | ValueError: If the value is invalid.
"""
if "validator" in field.metadata:
field.metadata["validator"](inst, field, value)


def validate_fields(inst: Any) -> None:
"""Validate the fields of a dataclass instance,
according to ``validator`` functions set in the field metadata.

This function should be called in the ``__post_init__`` of the dataclass.

:param inst: The dataclass instance.
:raises TypeError | ValueError: If any value is invalid.
"""
for field in dc.fields(inst):
validate_field(inst, field, getattr(inst, field.name))


def instance_of(type_: type[Any] | tuple[type[Any], ...]) -> ValidatorType:
"""Create a validator that raises a ``TypeError``
if the value is not an instance of the given type(s).

:param type_: The type(s) to check for.
"""

def _validator(
inst: Any, field: dc.Field[Any], value: Any, suffix: str = ""
) -> None:
if not isinstance(value, type_):
raise TypeError(
f"'{field.name}{suffix}' must be of type {type_!r} "
f"(got {value!r} that is a {value.__class__!r})."
)

return _validator


def validate_custom_directive(field: dc.Field[Any], name: Any, data: Any) -> None:
"""Validate the shape of a single custom directive (name -> data) entry.

Note, whether ``data["inherit"]`` refers to a known sphinx-design directive,
and whether the option names are known for that directive,
can only be checked at registration time
(see ``sphinx_design.shared.setup_custom_directives``).

:param field: The dataclass field the entry belongs to.
:param name: The name of the new directive.
:param data: The directive data, expected shape
``{inherit: str, argument: str, options: {str: str}}``.
:raises TypeError | ValueError: If the entry is invalid.
"""
if not isinstance(name, str):
raise TypeError(f"key must be a string: {name!r}")
if not isinstance(data, dict):
raise TypeError(f"{name!r} value must be a dictionary")
if "inherit" not in data:
raise ValueError(f"{name!r} value must have an 'inherit' key")
if not isinstance(data["inherit"], str):
raise TypeError(f"'{name}.inherit' value must be a string")
if "argument" in data and not isinstance(data["argument"], str):
raise TypeError(f"'{name}.argument' value must be a string")
if "options" in data:
if not isinstance(data["options"], dict):
raise TypeError(f"'{name}.options' value must be a dictionary")
for key, value in data["options"].items():
if not isinstance(key, str):
raise TypeError(f"'{name}.options' key must be a string: {key!r}")
if not isinstance(value, str):
raise TypeError(f"'{name}.options.{key}' value must be a string")


def validate_custom_directives(
inst: Any, field: dc.Field[Any], value: Any, suffix: str = ""
) -> None:
"""Validate the custom directives mapping, raising on the first invalid entry.

:param inst: The dataclass instance (or None if not yet created).
:param field: The dataclass field.
:param value: The value to validate.
:param suffix: Suffix to append to the field name in error messages.
:raises TypeError | ValueError: If the value is invalid.
"""
if not isinstance(value, dict):
raise TypeError(f"'{field.name}{suffix}' must be a dictionary (got {value!r})")
for name, data in value.items():
validate_custom_directive(field, name, data)


@dc.dataclass
class SdConfig:
"""Global configuration for sphinx-design (all values TOML-compatible).

In the sphinx configuration, these option names are prepended with ``sd_``.
"""

custom_directives: dict[str, Any] = dc.field(
default_factory=dict,
metadata={
"validator": validate_custom_directives,
"entry_validator": validate_custom_directive,
"help": "Custom directives, inheriting from sphinx-design ones",
"doc_type": "dict[str, dict]",
},
)
fontawesome_latex: bool = dc.field(
default=False,
metadata={
"validator": instance_of(bool),
"help": "Render fontawesome icons in LaTeX output",
},
)

def __post_init__(self) -> None:
validate_fields(self)

@classmethod
def from_sphinx(cls, config: Config) -> SdConfig:
"""Create a validated instance from the flat ``sd_`` prefixed
Sphinx configuration values.

Note, the values are expected to have already been sanitized by
the ``config-inited`` event (which replaces invalid values with defaults),
otherwise this may raise.

:param config: The Sphinx configuration.
:raises TypeError | ValueError: If any value is invalid.
"""
return cls(**{f.name: getattr(config, f"sd_{f.name}") for f in dc.fields(cls)})


def _field_default(field: dc.Field[Any]) -> Any:
"""Return the default value for a dataclass field."""
if field.default_factory is not dc.MISSING:
return field.default_factory()
return field.default


def setup_sd_config(app: Sphinx) -> None:
"""Set up the sphinx-design configuration handling.

Each field of :class:`SdConfig` is registered as a flat ``sd_<name>``
Sphinx configuration value (the public, backwards-compatible, interface).

:param app: The Sphinx application object.
"""
for field in dc.fields(SdConfig):
app.add_config_value(f"sd_{field.name}", _field_default(field), "env")
# low priority, so that the values are validated
# before any other `config-inited` listener reads them
app.connect("config-inited", _validate_config_values, priority=400)
app.connect("builder-inited", _attach_env_config)


def get_sd_config(env: BuildEnvironment) -> SdConfig:
"""Get the validated sphinx-design configuration for a build environment.

:param env: The Sphinx build environment.
"""
try:
return env.sd_config # type: ignore[attr-defined]
except AttributeError:
sd_config = SdConfig.from_sphinx(env.config)
env.sd_config = sd_config # type: ignore[attr-defined]
return sd_config


def _validate_config_values(app: Sphinx, config: Config) -> None:
"""Validate the flat ``sd_`` prefixed configuration values
(on the ``config-inited`` event).

Invalid values are replaced by the field default, with a warning,
so that :class:`SdConfig` instances can subsequently always be created.
For mapping fields with an ``entry_validator``,
only the invalid entries are discarded.
"""

def _warn(msg: str) -> None:
LOGGER.warning(msg, type=WARNING_TYPE, subtype="config")

for field in dc.fields(SdConfig):
name = f"sd_{field.name}"
value = getattr(config, name)
if entry_validator := field.metadata.get("entry_validator"):
# validate mapping values per entry, discarding invalid entries,
# so that one invalid entry does not invalidate the whole mapping
if not isinstance(value, dict):
_warn(f"{name}: must be a dictionary")
value = _field_default(field)
else:
valid = {}
for key, entry in value.items():
try:
entry_validator(field, key, entry)
except (TypeError, ValueError) as exc:
_warn(f"{name}: {exc}")
else:
valid[key] = entry
value = valid
else:
try:
validate_field(None, field, value)
except (TypeError, ValueError) as exc:
value = _field_default(field)
_warn(f"{name}: {exc} Reverting to default: {value!r}")
setattr(config, name, value)


def _attach_env_config(app: Sphinx) -> None:
"""Attach the validated configuration to the build environment
(on the ``builder-inited`` event).

This is re-created on every build,
so that changes to the configuration are always picked up.
"""
app.env.sd_config = SdConfig.from_sphinx(app.config) # type: ignore[attr-defined]
3 changes: 2 additions & 1 deletion sphinx_design/extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from .article_info import setup_article_info
from .badges_buttons import setup_badges_and_buttons
from .cards import setup_cards
from .config import setup_sd_config
from .dropdown import setup_dropdown
from .grids import setup_grids
from .icons import setup_icons
Expand All @@ -29,6 +30,7 @@

def setup_extension(app: Sphinx) -> None:
"""Set up the sphinx extension."""
setup_sd_config(app)
app.connect("builder-inited", update_css_js)
app.connect("env-updated", update_css_links)
# we override container html visitors, to stop the default behaviour
Expand All @@ -55,7 +57,6 @@ def setup_extension(app: Sphinx) -> None:
setup_tabs(app)
setup_article_info(app)

app.add_config_value("sd_custom_directives", {}, "env")
app.connect(
"config-inited", partial(setup_custom_directives, directive_map=directive_map)
)
Expand Down
Loading
Loading