Skip to content

Commit 70cf79b

Browse files
authored
♻️ Central declarative SdConfig dataclass (#274)
All global configuration is declared once on a dataclass with typed, TOML-compatible fields, per-field validators and help text (modelled on myst-parser's MdParserConfig). Flat sd_* confvals remain the public interface; modules read via a typed get_sd_config accessor; invalid values warn under design.config and fall back to defaults, with per-entry pruning for sd_custom_directives. Also guards the myst-parametrized tests from #271 so the no-myst tox env passes.
1 parent 834eebe commit 70cf79b

7 files changed

Lines changed: 498 additions & 41 deletions

File tree

docs/conf.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
"""Configuration file for the Sphinx documentation builder."""
22

3+
import dataclasses as dc
34
import os
45

6+
from sphinx_design.config import SdConfig
7+
58
project = "Sphinx Design"
69
copyright = "2021, Executable Book Project"
710
author = "Executable Book Project"
@@ -118,7 +121,31 @@
118121
"html_image",
119122
]
120123

124+
125+
def _sd_config_options_table() -> str:
126+
"""Generate a Markdown table of all sphinx-design configuration options,
127+
from the ``SdConfig`` dataclass fields.
128+
"""
129+
rows = [
130+
"| Name | Type | Default | Description |",
131+
"| ---- | ---- | ------- | ----------- |",
132+
]
133+
for field in dc.fields(SdConfig):
134+
default = (
135+
field.default_factory()
136+
if field.default_factory is not dc.MISSING
137+
else field.default
138+
)
139+
type_str = field.metadata.get("doc_type", field.type)
140+
rows.append(
141+
f"| `sd_{field.name}` | `{type_str}` | `{default!r}` "
142+
f"| {field.metadata.get('help', '')} |"
143+
)
144+
return "\n".join(rows)
145+
146+
121147
myst_substitutions = {
148+
"sd_config_options": _sd_config_options_table(),
122149
"loremipsum": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. "
123150
"Sed iaculis arcu vitae odio gravida congue. Donec porttitor ac risus et condimentum. "
124151
"Phasellus bibendum ac risus a sollicitudin. "
@@ -129,5 +156,5 @@
129156
"Aliquam sed lectus ac nisl sollicitudin ultricies id at neque. "
130157
"Aliquam fringilla odio vitae lorem ornare, sit amet scelerisque orci fringilla. "
131158
"Nam sed arcu dignissim, ultrices quam sit amet, commodo ipsum. "
132-
"Etiam quis nunc at ligula tincidunt eleifend."
159+
"Etiam quis nunc at ligula tincidunt eleifend.",
133160
}

docs/get_started.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ The MyST Markdown examples in this documentation assume that certain optional [M
3434

3535
## Configuration
3636

37+
### Global options
38+
39+
All global configuration options are prefixed with `sd_`, and can be set in your `conf.py`.
40+
Values are always simple, TOML-compatible, data types:
41+
42+
{{ sd_config_options }}
43+
44+
### Hiding the page title
45+
3746
To hide the title header of a page, add to the top of the page:
3847

3948
::::{tab-set}

sphinx_design/config.py

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
"""Central declarative configuration for sphinx-design.
2+
3+
All global configuration is declared on the :class:`SdConfig` dataclass:
4+
every option is declared once, with its type, default, validator and help text.
5+
6+
Every value is plain, TOML-compatible, data
7+
(``str``/``bool``/``int``/``list``/``dict`` of primitives),
8+
so that the configuration could also be read from a TOML file,
9+
or be understood by non-Python implementations.
10+
11+
The values are registered with Sphinx as flat, ``sd_`` prefixed,
12+
configuration values (e.g. ``fontawesome_latex`` -> ``sd_fontawesome_latex``),
13+
which remain the public interface.
14+
Modules should read the validated configuration via :func:`get_sd_config`,
15+
rather than accessing ``config.sd_*`` attributes directly.
16+
17+
The field validators mirror the approach of
18+
https://github.com/python-attrs/attrs validators:
19+
they take ``(inst, field, value)`` and raise on invalid values.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import dataclasses as dc
25+
from typing import TYPE_CHECKING, Any, Protocol
26+
27+
from sphinx.util.logging import getLogger
28+
29+
if TYPE_CHECKING:
30+
from sphinx.application import Sphinx
31+
from sphinx.config import Config
32+
from sphinx.environment import BuildEnvironment
33+
34+
LOGGER = getLogger(__name__)
35+
36+
WARNING_TYPE = "design"
37+
"""Type of warnings emitted by sphinx-design (i.e. ``design.<subtype>``)."""
38+
39+
40+
class ValidatorType(Protocol):
41+
"""Protocol for a dataclass field validator."""
42+
43+
def __call__(
44+
self, inst: Any, field: dc.Field[Any], value: Any, suffix: str = ""
45+
) -> None:
46+
"""Validate the value of a dataclass field, raising if invalid.
47+
48+
:param inst: The dataclass instance (or None if not yet created).
49+
:param field: The dataclass field.
50+
:param value: The value to validate.
51+
:param suffix: Suffix to append to the field name in error messages.
52+
:raises TypeError | ValueError: If the value is invalid.
53+
"""
54+
55+
56+
def validate_field(inst: Any, field: dc.Field[Any], value: Any) -> None:
57+
"""Validate the field of a dataclass,
58+
according to a ``validator`` function set in the field metadata.
59+
60+
:param inst: The dataclass instance (or None if not yet created).
61+
:param field: The dataclass field.
62+
:param value: The value to validate.
63+
:raises TypeError | ValueError: If the value is invalid.
64+
"""
65+
if "validator" in field.metadata:
66+
field.metadata["validator"](inst, field, value)
67+
68+
69+
def validate_fields(inst: Any) -> None:
70+
"""Validate the fields of a dataclass instance,
71+
according to ``validator`` functions set in the field metadata.
72+
73+
This function should be called in the ``__post_init__`` of the dataclass.
74+
75+
:param inst: The dataclass instance.
76+
:raises TypeError | ValueError: If any value is invalid.
77+
"""
78+
for field in dc.fields(inst):
79+
validate_field(inst, field, getattr(inst, field.name))
80+
81+
82+
def instance_of(type_: type[Any] | tuple[type[Any], ...]) -> ValidatorType:
83+
"""Create a validator that raises a ``TypeError``
84+
if the value is not an instance of the given type(s).
85+
86+
:param type_: The type(s) to check for.
87+
"""
88+
89+
def _validator(
90+
inst: Any, field: dc.Field[Any], value: Any, suffix: str = ""
91+
) -> None:
92+
if not isinstance(value, type_):
93+
raise TypeError(
94+
f"'{field.name}{suffix}' must be of type {type_!r} "
95+
f"(got {value!r} that is a {value.__class__!r})."
96+
)
97+
98+
return _validator
99+
100+
101+
def validate_custom_directive(field: dc.Field[Any], name: Any, data: Any) -> None:
102+
"""Validate the shape of a single custom directive (name -> data) entry.
103+
104+
Note, whether ``data["inherit"]`` refers to a known sphinx-design directive,
105+
and whether the option names are known for that directive,
106+
can only be checked at registration time
107+
(see ``sphinx_design.shared.setup_custom_directives``).
108+
109+
:param field: The dataclass field the entry belongs to.
110+
:param name: The name of the new directive.
111+
:param data: The directive data, expected shape
112+
``{inherit: str, argument: str, options: {str: str}}``.
113+
:raises TypeError | ValueError: If the entry is invalid.
114+
"""
115+
if not isinstance(name, str):
116+
raise TypeError(f"key must be a string: {name!r}")
117+
if not isinstance(data, dict):
118+
raise TypeError(f"{name!r} value must be a dictionary")
119+
if "inherit" not in data:
120+
raise ValueError(f"{name!r} value must have an 'inherit' key")
121+
if not isinstance(data["inherit"], str):
122+
raise TypeError(f"'{name}.inherit' value must be a string")
123+
if "argument" in data and not isinstance(data["argument"], str):
124+
raise TypeError(f"'{name}.argument' value must be a string")
125+
if "options" in data:
126+
if not isinstance(data["options"], dict):
127+
raise TypeError(f"'{name}.options' value must be a dictionary")
128+
for key, value in data["options"].items():
129+
if not isinstance(key, str):
130+
raise TypeError(f"'{name}.options' key must be a string: {key!r}")
131+
if not isinstance(value, str):
132+
raise TypeError(f"'{name}.options.{key}' value must be a string")
133+
134+
135+
def validate_custom_directives(
136+
inst: Any, field: dc.Field[Any], value: Any, suffix: str = ""
137+
) -> None:
138+
"""Validate the custom directives mapping, raising on the first invalid entry.
139+
140+
:param inst: The dataclass instance (or None if not yet created).
141+
:param field: The dataclass field.
142+
:param value: The value to validate.
143+
:param suffix: Suffix to append to the field name in error messages.
144+
:raises TypeError | ValueError: If the value is invalid.
145+
"""
146+
if not isinstance(value, dict):
147+
raise TypeError(f"'{field.name}{suffix}' must be a dictionary (got {value!r})")
148+
for name, data in value.items():
149+
validate_custom_directive(field, name, data)
150+
151+
152+
@dc.dataclass
153+
class SdConfig:
154+
"""Global configuration for sphinx-design (all values TOML-compatible).
155+
156+
In the sphinx configuration, these option names are prepended with ``sd_``.
157+
"""
158+
159+
custom_directives: dict[str, Any] = dc.field(
160+
default_factory=dict,
161+
metadata={
162+
"validator": validate_custom_directives,
163+
"entry_validator": validate_custom_directive,
164+
"help": "Custom directives, inheriting from sphinx-design ones",
165+
"doc_type": "dict[str, dict]",
166+
},
167+
)
168+
fontawesome_latex: bool = dc.field(
169+
default=False,
170+
metadata={
171+
"validator": instance_of(bool),
172+
"help": "Render fontawesome icons in LaTeX output",
173+
},
174+
)
175+
176+
def __post_init__(self) -> None:
177+
validate_fields(self)
178+
179+
@classmethod
180+
def from_sphinx(cls, config: Config) -> SdConfig:
181+
"""Create a validated instance from the flat ``sd_`` prefixed
182+
Sphinx configuration values.
183+
184+
Note, the values are expected to have already been sanitized by
185+
the ``config-inited`` event (which replaces invalid values with defaults),
186+
otherwise this may raise.
187+
188+
:param config: The Sphinx configuration.
189+
:raises TypeError | ValueError: If any value is invalid.
190+
"""
191+
return cls(**{f.name: getattr(config, f"sd_{f.name}") for f in dc.fields(cls)})
192+
193+
194+
def _field_default(field: dc.Field[Any]) -> Any:
195+
"""Return the default value for a dataclass field."""
196+
if field.default_factory is not dc.MISSING:
197+
return field.default_factory()
198+
return field.default
199+
200+
201+
def setup_sd_config(app: Sphinx) -> None:
202+
"""Set up the sphinx-design configuration handling.
203+
204+
Each field of :class:`SdConfig` is registered as a flat ``sd_<name>``
205+
Sphinx configuration value (the public, backwards-compatible, interface).
206+
207+
:param app: The Sphinx application object.
208+
"""
209+
for field in dc.fields(SdConfig):
210+
app.add_config_value(f"sd_{field.name}", _field_default(field), "env")
211+
# low priority, so that the values are validated
212+
# before any other `config-inited` listener reads them
213+
app.connect("config-inited", _validate_config_values, priority=400)
214+
app.connect("builder-inited", _attach_env_config)
215+
216+
217+
def get_sd_config(env: BuildEnvironment) -> SdConfig:
218+
"""Get the validated sphinx-design configuration for a build environment.
219+
220+
:param env: The Sphinx build environment.
221+
"""
222+
try:
223+
return env.sd_config # type: ignore[attr-defined]
224+
except AttributeError:
225+
sd_config = SdConfig.from_sphinx(env.config)
226+
env.sd_config = sd_config # type: ignore[attr-defined]
227+
return sd_config
228+
229+
230+
def _validate_config_values(app: Sphinx, config: Config) -> None:
231+
"""Validate the flat ``sd_`` prefixed configuration values
232+
(on the ``config-inited`` event).
233+
234+
Invalid values are replaced by the field default, with a warning,
235+
so that :class:`SdConfig` instances can subsequently always be created.
236+
For mapping fields with an ``entry_validator``,
237+
only the invalid entries are discarded.
238+
"""
239+
240+
def _warn(msg: str) -> None:
241+
LOGGER.warning(msg, type=WARNING_TYPE, subtype="config")
242+
243+
for field in dc.fields(SdConfig):
244+
name = f"sd_{field.name}"
245+
value = getattr(config, name)
246+
if entry_validator := field.metadata.get("entry_validator"):
247+
# validate mapping values per entry, discarding invalid entries,
248+
# so that one invalid entry does not invalidate the whole mapping
249+
if not isinstance(value, dict):
250+
_warn(f"{name}: must be a dictionary")
251+
value = _field_default(field)
252+
else:
253+
valid = {}
254+
for key, entry in value.items():
255+
try:
256+
entry_validator(field, key, entry)
257+
except (TypeError, ValueError) as exc:
258+
_warn(f"{name}: {exc}")
259+
else:
260+
valid[key] = entry
261+
value = valid
262+
else:
263+
try:
264+
validate_field(None, field, value)
265+
except (TypeError, ValueError) as exc:
266+
value = _field_default(field)
267+
_warn(f"{name}: {exc} Reverting to default: {value!r}")
268+
setattr(config, name, value)
269+
270+
271+
def _attach_env_config(app: Sphinx) -> None:
272+
"""Attach the validated configuration to the build environment
273+
(on the ``builder-inited`` event).
274+
275+
This is re-created on every build,
276+
so that changes to the configuration are always picked up.
277+
"""
278+
app.env.sd_config = SdConfig.from_sphinx(app.config) # type: ignore[attr-defined]

sphinx_design/extension.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from .article_info import setup_article_info
1616
from .badges_buttons import setup_badges_and_buttons
1717
from .cards import setup_cards
18+
from .config import setup_sd_config
1819
from .dropdown import setup_dropdown
1920
from .grids import setup_grids
2021
from .icons import setup_icons
@@ -29,6 +30,7 @@
2930

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

58-
app.add_config_value("sd_custom_directives", {}, "env")
5960
app.connect(
6061
"config-inited", partial(setup_custom_directives, directive_map=directive_map)
6162
)

0 commit comments

Comments
 (0)