diff --git a/docs/conf.py b/docs/conf.py index 26da6195..34f5034c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -156,7 +156,7 @@ def run_apidoc(app): # # 'inherited-members': True # } autodoc_member_order = "bysource" - +nitpicky = True nitpick_ignore = [ ("py:class", "docutils.nodes.document"), ("py:class", "docutils.nodes.docinfo"), diff --git a/myst_parser/dc_validators.py b/myst_parser/dc_validators.py new file mode 100644 index 00000000..c0c69ef2 --- /dev/null +++ b/myst_parser/dc_validators.py @@ -0,0 +1,142 @@ +"""Validators for dataclasses, mirroring those of https://github.com/python-attrs/attrs.""" +from __future__ import annotations + +import dataclasses as dc +from typing import Any, Callable, Sequence, Type + + +def validate_fields(inst): + """Validate the fields of a dataclass, + according to `validator` functions set in the field metadata. + + This function should be called in the `__post_init__` of the dataclass. + + The validator function should take as input (inst, field, value) and + raise an exception if the value is invalid. + """ + for field in dc.fields(inst): + if "validator" not in field.metadata: + continue + if isinstance(field.metadata["validator"], list): + for validator in field.metadata["validator"]: + validator(inst, field, getattr(inst, field.name)) + else: + field.metadata["validator"](inst, field, getattr(inst, field.name)) + + +ValidatorType = Callable[[Any, dc.Field, Any], None] + + +def instance_of(type: Type[Any] | tuple[Type[Any], ...]) -> ValidatorType: + """ + A validator that raises a `TypeError` if the initializer is called + with a wrong type for this particular attribute (checks are performed using + `isinstance` therefore it's also valid to pass a tuple of types). + + :param type: The type to check for. + """ + + def _validator(inst, attr, value): + """ + We use a callable class to be able to change the ``__repr__``. + """ + if not isinstance(value, type): + raise TypeError( + f"'{attr.name}' must be {type!r} (got {value!r} that is a {value.__class__!r})." + ) + + return _validator + + +def optional(validator: ValidatorType) -> ValidatorType: + """ + A validator that makes an attribute optional. An optional attribute is one + which can be set to ``None`` in addition to satisfying the requirements of + the sub-validator. + """ + + def _validator(inst, attr, value): + if value is None: + return + + validator(inst, attr, value) + + return _validator + + +def is_callable(inst, attr, value): + """ + A validator that raises a `TypeError` if the + initializer is called with a value for this particular attribute + that is not callable. + """ + if not callable(value): + raise TypeError( + f"'{attr.name}' must be callable " + f"(got {value!r} that is a {value.__class__!r})." + ) + + +def in_(options: Sequence) -> ValidatorType: + """ + A validator that raises a `ValueError` if the initializer is called + with a value that does not belong in the options provided. The check is + performed using ``value in options``. + + :param options: Allowed options. + """ + + def _validator(inst, attr, value): + try: + in_options = value in options + except TypeError: # e.g. `1 in "abc"` + in_options = False + + if not in_options: + raise ValueError(f"'{attr.name}' must be in {options!r} (got {value!r})") + + return _validator + + +def deep_iterable( + member_validator: ValidatorType, iterable_validator: ValidatorType | None = None +) -> ValidatorType: + """ + A validator that performs deep validation of an iterable. + + :param member_validator: Validator to apply to iterable members + :param iterable_validator: Validator to apply to iterable itself + """ + + def _validator(inst, attr, value): + if iterable_validator is not None: + iterable_validator(inst, attr, value) + + for member in value: + member_validator(inst, attr, member) + + return _validator + + +def deep_mapping( + key_validator: ValidatorType, + value_validator: ValidatorType, + mapping_validator: ValidatorType | None = None, +) -> ValidatorType: + """ + A validator that performs deep validation of a dictionary. + + :param key_validator: Validator to apply to dictionary keys + :param value_validator: Validator to apply to dictionary values + :param mapping_validator: Validator to apply to top-level mapping attribute (optional) + """ + + def _validator(inst, attr, value): + if mapping_validator is not None: + mapping_validator(inst, attr, value) + + for key in value: + key_validator(inst, attr, key) + value_validator(inst, attr, value[key]) + + return _validator diff --git a/myst_parser/docutils_.py b/myst_parser/docutils_.py index 49502cd7..f0a7001f 100644 --- a/myst_parser/docutils_.py +++ b/myst_parser/docutils_.py @@ -3,9 +3,9 @@ .. include:: path/to/file.md :parser: myst_parser.docutils_ """ +from dataclasses import Field from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple, Union -from attr import Attribute from docutils import frontend, nodes from docutils.core import default_description, publish_cmdline from docutils.parsers.rst import Parser as RstParser @@ -69,8 +69,8 @@ def __repr__(self): """Names of settings that cannot be set in docutils.conf.""" -def _attr_to_optparse_option(at: Attribute, default: Any) -> Tuple[dict, str]: - """Convert an ``attrs.Attribute`` into a Docutils optparse options dict.""" +def _attr_to_optparse_option(at: Field, default: Any) -> Tuple[dict, str]: + """Convert a field into a Docutils optparse options dict.""" if at.type is int: return {"metavar": "", "validator": _validate_int}, f"(default: {default})" if at.type is bool: @@ -118,7 +118,7 @@ def _attr_to_optparse_option(at: Attribute, default: Any) -> Tuple[dict, str]: def attr_to_optparse_option( - attribute: Attribute, default: Any, prefix: str = "myst_" + attribute: Field, default: Any, prefix: str = "myst_" ) -> Tuple[str, List[str], Dict[str, Any]]: """Convert an ``MdParserConfig`` attribute into a Docutils setting tuple. diff --git a/myst_parser/main.py b/myst_parser/main.py index c26c7edd..982fe851 100644 --- a/myst_parser/main.py +++ b/myst_parser/main.py @@ -1,17 +1,9 @@ """This module holds the global configuration for the parser ``MdParserConfig``, and the ``create_md_parser`` function, which creates a parser from the config. """ +import dataclasses as dc from typing import Any, Callable, Dict, Iterable, Optional, Sequence, Tuple, Union, cast -import attr -from attr.validators import ( - deep_iterable, - deep_mapping, - in_, - instance_of, - is_callable, - optional, -) from markdown_it import MarkdownIt from markdown_it.renderer import RendererHTML, RendererProtocol from mdit_py_plugins.amsmath import amsmath_plugin @@ -29,205 +21,265 @@ from mdit_py_plugins.wordcount import wordcount_plugin from . import __version__ # noqa: F401 +from .dc_validators import ( + deep_iterable, + deep_mapping, + in_, + instance_of, + is_callable, + optional, + validate_fields, +) -@attr.s() +def check_extensions(_, __, value): + if not isinstance(value, Iterable): + raise TypeError(f"myst_enable_extensions not iterable: {value}") + diff = set(value).difference( + [ + "dollarmath", + "amsmath", + "deflist", + "fieldlist", + "html_admonition", + "html_image", + "colon_fence", + "smartquotes", + "replacements", + "linkify", + "strikethrough", + "substitution", + "tasklist", + ] + ) + if diff: + raise ValueError(f"myst_enable_extensions not recognised: {diff}") + + +def check_sub_delimiters(_, __, value): + if (not isinstance(value, (tuple, list))) or len(value) != 2: + raise TypeError(f"myst_sub_delimiters is not a tuple of length 2: {value}") + for delim in value: + if (not isinstance(delim, str)) or len(delim) != 1: + raise TypeError( + f"myst_sub_delimiters does not contain strings of length 1: {value}" + ) + + +@dc.dataclass() class MdParserConfig: """Configuration options for the Markdown Parser. Note in the sphinx configuration these option names are prepended with ``myst_`` """ - commonmark_only: bool = attr.ib( + commonmark_only: bool = dc.field( default=False, - validator=instance_of(bool), - metadata={"help": "Use strict CommonMark parser"}, + metadata={ + "validator": instance_of(bool), + "help": "Use strict CommonMark parser", + }, ) - gfm_only: bool = attr.ib( + gfm_only: bool = dc.field( default=False, - validator=instance_of(bool), - metadata={"help": "Use strict Github Flavoured Markdown parser"}, + metadata={ + "validator": instance_of(bool), + "help": "Use strict Github Flavoured Markdown parser", + }, ) - enable_extensions: Sequence[str] = attr.ib( - factory=list, metadata={"help": "Enable extensions"} + + enable_extensions: Sequence[str] = dc.field( + default_factory=list, + metadata={"validator": check_extensions, "help": "Enable extensions"}, ) - linkify_fuzzy_links: bool = attr.ib( + linkify_fuzzy_links: bool = dc.field( default=True, - validator=instance_of(bool), - metadata={"help": "linkify: recognise URLs without schema prefixes"}, + metadata={ + "validator": instance_of(bool), + "help": "linkify: recognise URLs without schema prefixes", + }, ) - dmath_allow_labels: bool = attr.ib( + dmath_allow_labels: bool = dc.field( default=True, - validator=instance_of(bool), - metadata={"help": "Parse `$$...$$ (label)`"}, + metadata={"validator": instance_of(bool), "help": "Parse `$$...$$ (label)`"}, ) - dmath_allow_space: bool = attr.ib( + dmath_allow_space: bool = dc.field( default=True, - validator=instance_of(bool), - metadata={"help": "dollarmath: allow initial/final spaces in `$ ... $`"}, + metadata={ + "validator": instance_of(bool), + "help": "dollarmath: allow initial/final spaces in `$ ... $`", + }, ) - dmath_allow_digits: bool = attr.ib( + dmath_allow_digits: bool = dc.field( default=True, - validator=instance_of(bool), - metadata={"help": "dollarmath: allow initial/final digits `1$ ...$2`"}, + metadata={ + "validator": instance_of(bool), + "help": "dollarmath: allow initial/final digits `1$ ...$2`", + }, ) - dmath_double_inline: bool = attr.ib( + dmath_double_inline: bool = dc.field( default=False, - validator=instance_of(bool), - metadata={"help": "dollarmath: parse inline `$$ ... $$`"}, + metadata={ + "validator": instance_of(bool), + "help": "dollarmath: parse inline `$$ ... $$`", + }, ) - update_mathjax: bool = attr.ib( + update_mathjax: bool = dc.field( default=True, - validator=instance_of(bool), - metadata={"help": "Update sphinx.ext.mathjax configuration"}, + metadata={ + "validator": instance_of(bool), + "help": "Update sphinx.ext.mathjax configuration", + }, ) - mathjax_classes: str = attr.ib( + mathjax_classes: str = dc.field( default="tex2jax_process|mathjax_process|math|output_area", - validator=instance_of(str), - metadata={"help": "MathJax classes to add to math HTML"}, + metadata={ + "validator": instance_of(str), + "help": "MathJax classes to add to math HTML", + }, ) - @enable_extensions.validator - def check_extensions(self, attribute, value): - if not isinstance(value, Iterable): - raise TypeError(f"myst_enable_extensions not iterable: {value}") - diff = set(value).difference( - [ - "dollarmath", - "amsmath", - "deflist", - "fieldlist", - "html_admonition", - "html_image", - "colon_fence", - "smartquotes", - "replacements", - "linkify", - "strikethrough", - "substitution", - "tasklist", - ] - ) - if diff: - raise ValueError(f"myst_enable_extensions not recognised: {diff}") - - disable_syntax: Iterable[str] = attr.ib( - factory=list, - validator=deep_iterable(instance_of(str), instance_of((list, tuple))), - metadata={"help": "Disable syntax elements"}, + disable_syntax: Iterable[str] = dc.field( + default_factory=list, + metadata={ + "validator": deep_iterable(instance_of(str), instance_of((list, tuple))), + "help": "Disable syntax elements", + }, ) - all_links_external: bool = attr.ib( + all_links_external: bool = dc.field( default=False, - validator=instance_of(bool), - metadata={"help": "Parse all links as simple hyperlinks"}, + metadata={ + "validator": instance_of(bool), + "help": "Parse all links as simple hyperlinks", + }, ) # see https://en.wikipedia.org/wiki/List_of_URI_schemes - url_schemes: Optional[Iterable[str]] = attr.ib( + url_schemes: Optional[Iterable[str]] = dc.field( default=cast(Optional[Iterable[str]], ("http", "https", "mailto", "ftp")), - validator=optional(deep_iterable(instance_of(str), instance_of((list, tuple)))), - metadata={"help": "URL scheme prefixes identified as external links"}, + metadata={ + "validator": optional( + deep_iterable(instance_of(str), instance_of((list, tuple))) + ), + "help": "URL scheme prefixes identified as external links", + }, ) - ref_domains: Optional[Iterable[str]] = attr.ib( + ref_domains: Optional[Iterable[str]] = dc.field( default=None, - validator=optional(deep_iterable(instance_of(str), instance_of((list, tuple)))), - metadata={"help": "Sphinx domain names to search in for references"}, + metadata={ + "validator": optional( + deep_iterable(instance_of(str), instance_of((list, tuple))) + ), + "help": "Sphinx domain names to search in for references", + }, ) - highlight_code_blocks: bool = attr.ib( + highlight_code_blocks: bool = dc.field( default=True, - validator=instance_of(bool), metadata={ + "validator": instance_of(bool), "help": "Syntax highlight code blocks with pygments", "docutils_only": True, }, ) - number_code_blocks: Sequence[str] = attr.ib( - default=(), - validator=deep_iterable(instance_of(str)), - metadata={"help": "Add line numbers to code blocks with these languages"}, + number_code_blocks: Sequence[str] = dc.field( + default_factory=list, + metadata={ + "validator": deep_iterable(instance_of(str), instance_of((list, tuple))), + "help": "Add line numbers to code blocks with these languages", + }, ) - title_to_header: bool = attr.ib( + title_to_header: bool = dc.field( default=False, - validator=instance_of(bool), - metadata={"help": "Convert a `title` field in the top-matter to a H1 header"}, + metadata={ + "validator": instance_of(bool), + "help": "Convert a `title` field in the top-matter to a H1 header", + }, ) - heading_anchors: Optional[int] = attr.ib( + heading_anchors: Optional[int] = dc.field( default=None, - validator=optional(in_([1, 2, 3, 4, 5, 6, 7])), - metadata={"help": "Heading level depth to assign HTML anchors"}, + metadata={ + "validator": optional(in_([1, 2, 3, 4, 5, 6, 7])), + "help": "Heading level depth to assign HTML anchors", + }, ) - heading_slug_func: Optional[Callable[[str], str]] = attr.ib( + heading_slug_func: Optional[Callable[[str], str]] = dc.field( default=None, - validator=optional(is_callable()), - metadata={"help": "Function for creating heading anchors"}, + metadata={ + "validator": optional(is_callable), + "help": "Function for creating heading anchors", + }, ) - html_meta: Dict[str, str] = attr.ib( - factory=dict, - validator=deep_mapping(instance_of(str), instance_of(str), instance_of(dict)), - repr=lambda v: str(list(v)), - metadata={"help": "HTML meta tags"}, + html_meta: Dict[str, str] = dc.field( + default_factory=dict, + repr=False, + metadata={ + "validator": deep_mapping( + instance_of(str), instance_of(str), instance_of(dict) + ), + "help": "HTML meta tags", + }, ) - footnote_transition: bool = attr.ib( + footnote_transition: bool = dc.field( default=True, - validator=instance_of(bool), - metadata={"help": "Place a transition before any footnotes"}, + metadata={ + "validator": instance_of(bool), + "help": "Place a transition before any footnotes", + }, ) - substitutions: Dict[str, Union[str, int, float]] = attr.ib( - factory=dict, - validator=deep_mapping( - instance_of(str), instance_of((str, int, float)), instance_of(dict) - ), - repr=lambda v: str(list(v)), - metadata={"help": "Substitutions"}, + substitutions: Dict[str, Union[str, int, float]] = dc.field( + default_factory=dict, + repr=False, + metadata={ + "validator": deep_mapping( + instance_of(str), instance_of((str, int, float)), instance_of(dict) + ), + "help": "Substitutions", + }, ) - sub_delimiters: Tuple[str, str] = attr.ib( - default=("{", "}"), metadata={"help": "Substitution delimiters"} + sub_delimiters: Tuple[str, str] = dc.field( + default=("{", "}"), + metadata={"validator": check_sub_delimiters, "help": "Substitution delimiters"}, ) - words_per_minute: int = attr.ib( + words_per_minute: int = dc.field( default=200, - validator=instance_of(int), - metadata={"help": "For reading speed calculations"}, + metadata={ + "validator": instance_of(int), + "help": "For reading speed calculations", + }, ) - @sub_delimiters.validator - def check_sub_delimiters(self, attribute, value): - if (not isinstance(value, (tuple, list))) or len(value) != 2: - raise TypeError(f"myst_sub_delimiters is not a tuple of length 2: {value}") - for delim in value: - if (not isinstance(delim, str)) or len(delim) != 1: - raise TypeError( - f"myst_sub_delimiters does not contain strings of length 1: {value}" - ) + def __post_init__(self): + validate_fields(self) @classmethod - def get_fields(cls) -> Tuple[attr.Attribute, ...]: + def get_fields(cls) -> Tuple[dc.Field, ...]: """Return all attribute fields in this class.""" - return attr.fields(cls) + return dc.fields(cls) def as_dict(self, dict_factory=dict) -> dict: """Return a dictionary of field name -> value.""" - return attr.asdict(self, dict_factory=dict_factory) + return dc.asdict(self, dict_factory=dict_factory) - def as_triple(self) -> Iterable[Tuple[str, Any, attr.Attribute]]: + def as_triple(self) -> Iterable[Tuple[str, Any, dc.Field]]: """Yield triples of (name, value, field).""" - fields = attr.fields_dict(self.__class__) - for name, value in attr.asdict(self).items(): + fields = {f.name: f for f in dc.fields(self.__class__)} + for name, value in dc.asdict(self).items(): yield name, value, fields[name] diff --git a/pyproject.toml b/pyproject.toml index b9d19ef8..5a36d88b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,6 @@ keywords = [ ] requires-python = ">=3.7" dependencies = [ - "attrs>=19", "docutils>=0.15,<0.18", "jinja2", # required for substitutions, but let sphinx choose version "markdown-it-py>=1.0.0,<3.0.0", diff --git a/tests/test_docutils.py b/tests/test_docutils.py index 46d10462..b206958c 100644 --- a/tests/test_docutils.py +++ b/tests/test_docutils.py @@ -1,7 +1,7 @@ import io +from dataclasses import dataclass, field, fields from textwrap import dedent -import attr import pytest from docutils import VersionInfo, __version_info__ from typing_extensions import Literal @@ -19,11 +19,11 @@ def test_attr_to_optparse_option(): - @attr.s + @dataclass class Config: - name: Literal["a"] = attr.ib(default="default") + name: Literal["a"] = field(default="default") - output = attr_to_optparse_option(attr.fields(Config).name, "default") + output = attr_to_optparse_option(fields(Config)[0], "default") assert len(output) == 3