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
1 change: 0 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,3 @@ repos:
rev: v0.942
hooks:
- id: mypy
additional_dependencies: [attrs]
7 changes: 1 addition & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ classifiers = [
keywords = ["sphinx","extension", "toc"]
requires-python = "~=3.7"
dependencies = [
"attrs>=20.3,<22",
"click>=7.1,<9",
"pyyaml",
"sphinx>=3,<5",
Expand Down Expand Up @@ -69,11 +68,7 @@ no_implicit_optional = true
strict_equality = true

[[tool.mypy.overrides]]
module = ["docutils.*"]
ignore_missing_imports = true

[[tool.mypy.overrides]]
module = ["yaml.*"]
module = ["docutils.*", "yaml.*"]
ignore_missing_imports = true

[tool.isort]
Expand Down
138 changes: 138 additions & 0 deletions sphinx_external_toc/_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""Compatibility for using dataclasses instead of attrs."""
from __future__ import annotations

import dataclasses as dc
import re
import sys
from typing import Any, Callable, Pattern, Type

if sys.version_info >= (3, 10):
DC_SLOTS: dict = {"slots": True}
else:
DC_SLOTS: dict = {}


def field(**kwargs: Any):
if sys.version_info < (3, 10):
kwargs.pop("kw_only", None)
if "validator" in kwargs:
kwargs.setdefault("metadata", {})["validator"] = kwargs.pop("validator")
return dc.field(**kwargs)


field.__doc__ = dc.field.__doc__


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 matches_re(regex: str | Pattern, flags: int = 0) -> ValidatorType:
r"""
A validator that raises `ValueError` if the initializer is called
with a string that doesn't match *regex*.

:param regex: a regex string or precompiled pattern to match against
:param flags: flags that will be passed to the underlying re function (default 0)

"""
fullmatch = getattr(re, "fullmatch", None)

if isinstance(regex, Pattern):
if flags:
raise TypeError(
"'flags' can only be used with a string pattern; "
"pass flags to re.compile() instead"
)
pattern = regex
else:
pattern = re.compile(regex, flags)

if fullmatch:
match_func = pattern.fullmatch
else: # Python 2 fullmatch emulation (https://bugs.python.org/issue16203)
pattern = re.compile(r"(?:{})\Z".format(pattern.pattern), pattern.flags)
match_func = pattern.match

def _validator(inst, attr, value):
if not match_func(value):
raise ValueError(
f"'{attr.name}' must match regex {pattern!r} ({value!r} doesn't)"
)

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 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
84 changes: 53 additions & 31 deletions sphinx_external_toc/api.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
"""Defines the `SiteMap` object, for storing the parsed ToC."""
from collections.abc import MutableMapping
from dataclasses import asdict, dataclass
from typing import Any, Dict, Iterator, List, Optional, Set, Union

import attr
from attr.validators import deep_iterable, instance_of, matches_re, optional
from ._compat import (
DC_SLOTS,
deep_iterable,
field,
instance_of,
matches_re,
optional,
validate_fields,
)

#: Pattern used to match URL items.
URL_PATTERN: str = r".+://.*"
Expand All @@ -21,35 +29,41 @@ class GlobItem(str):
"""A document glob in a toctree list."""


@attr.s(slots=True)
@dataclass(**DC_SLOTS)
class UrlItem:
"""A URL in a toctree."""

# regex should match sphinx.util.url_re
url: str = attr.ib(validator=[instance_of(str), matches_re(URL_PATTERN)])
title: Optional[str] = attr.ib(None, validator=optional(instance_of(str)))
url: str = field(validator=[instance_of(str), matches_re(URL_PATTERN)])
title: Optional[str] = field(default=None, validator=optional(instance_of(str)))

def __post_init__(self):
validate_fields(self)

@attr.s(slots=True)

@dataclass(**DC_SLOTS)
class TocTree:
"""An individual toctree within a document."""

# TODO validate uniqueness of docnames (at least one item)
items: List[Union[GlobItem, FileItem, UrlItem]] = attr.ib(
items: List[Union[GlobItem, FileItem, UrlItem]] = field(
validator=deep_iterable(
instance_of((GlobItem, FileItem, UrlItem)), instance_of(list)
)
)
caption: Optional[str] = attr.ib(
caption: Optional[str] = field(
default=None, kw_only=True, validator=optional(instance_of(str))
)
hidden: bool = attr.ib(default=True, kw_only=True, validator=instance_of(bool))
maxdepth: int = attr.ib(default=-1, kw_only=True, validator=instance_of(int))
numbered: Union[bool, int] = attr.ib(
hidden: bool = field(default=True, kw_only=True, validator=instance_of(bool))
maxdepth: int = field(default=-1, kw_only=True, validator=instance_of(int))
numbered: Union[bool, int] = field(
default=False, kw_only=True, validator=instance_of((bool, int))
)
reversed: bool = attr.ib(default=False, kw_only=True, validator=instance_of(bool))
titlesonly: bool = attr.ib(default=False, kw_only=True, validator=instance_of(bool))
reversed: bool = field(default=False, kw_only=True, validator=instance_of(bool))
titlesonly: bool = field(default=False, kw_only=True, validator=instance_of(bool))

def __post_init__(self):
validate_fields(self)

def files(self) -> List[str]:
"""Returns a list of file items included in this ToC tree.
Expand All @@ -66,17 +80,20 @@ def globs(self) -> List[str]:
return [str(item) for item in self.items if isinstance(item, GlobItem)]


@attr.s(slots=True)
@dataclass(**DC_SLOTS)
class Document:
"""A document in the site map."""

# TODO validate uniqueness of docnames across all parts (and none should be the docname)
docname: str = attr.ib(validator=instance_of(str))
subtrees: List[TocTree] = attr.ib(
factory=list,
docname: str = field(validator=instance_of(str))
subtrees: List[TocTree] = field(
default_factory=list,
validator=deep_iterable(instance_of(TocTree), instance_of(list)),
)
title: Optional[str] = attr.ib(default=None, validator=optional(instance_of(str)))
title: Optional[str] = field(default=None, validator=optional(instance_of(str)))

def __post_init__(self):
validate_fields(self)

def child_files(self) -> List[str]:
"""Return all children files.
Expand Down Expand Up @@ -183,24 +200,29 @@ def __len__(self) -> int:
"""
return len(self._docs)

@staticmethod
def _serializer(inst: Any, field: attr.Attribute, value: Any) -> Any:
"""Serialize to JSON compatible value.

(parsed to ``attr.asdict``)
"""
if isinstance(value, (GlobItem, FileItem)):
return str(value)
return value

def as_json(self) -> Dict[str, Any]:
"""Return JSON serialized site-map representation."""
doc_dict = {
k: attr.asdict(self._docs[k], value_serializer=self._serializer)
if self._docs[k]
else self._docs[k]
k: asdict(self._docs[k]) if self._docs[k] else self._docs[k]
for k in sorted(self._docs)
}

def _replace_items(d: Dict[str, Any]) -> Dict[str, Any]:
for k, v in d.items():
if isinstance(v, dict):
d[k] = _replace_items(v)
elif isinstance(v, (list, tuple)):
d[k] = [
_replace_items(i)
if isinstance(i, dict)
else (str(i) if isinstance(i, str) else i)
for i in v
]
elif isinstance(v, str):
d[k] = str(v)
return d

doc_dict = _replace_items(doc_dict)
data = {
"root": self.root.docname,
"documents": doc_dict,
Expand Down
20 changes: 11 additions & 9 deletions sphinx_external_toc/parsing.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"""Parse the ToC to a `SiteMap` object."""
from collections.abc import Mapping
from dataclasses import dataclass, fields
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple, Union

import attr
import yaml

from ._compat import DC_SLOTS, field
from .api import Document, FileItem, GlobItem, SiteMap, TocTree, UrlItem

DEFAULT_SUBTREES_KEY = "subtrees"
Expand All @@ -25,15 +26,15 @@
)


@attr.s(slots=True)
@dataclass(**DC_SLOTS)
class FileFormat:
"""Mapping of keys for subtrees and items, dependant on depth in the ToC."""

toc_defaults: Dict[str, Any] = attr.ib(factory=dict)
subtrees_keys: Sequence[str] = attr.ib(default=())
items_keys: Sequence[str] = attr.ib(default=())
default_subtrees_key: str = attr.ib(default=DEFAULT_SUBTREES_KEY)
default_items_key: str = attr.ib(default=DEFAULT_ITEMS_KEY)
toc_defaults: Dict[str, Any] = field(default_factory=dict)
subtrees_keys: Sequence[str] = ()
items_keys: Sequence[str] = ()
default_subtrees_key: str = DEFAULT_SUBTREES_KEY
default_items_key: str = DEFAULT_ITEMS_KEY

def get_subtrees_key(self, depth: int) -> str:
"""Get the subtrees key name for this depth in the ToC.
Expand Down Expand Up @@ -421,13 +422,14 @@ def _parse_item(item):
raise TypeError(item)

data[subtrees_key] = []
fields = attr.fields_dict(TocTree)
# TODO handle default_factory
_defaults = {f.name: f.default for f in fields(TocTree)}
for toctree in doc_item.subtrees:
# only add these keys if their value is not the default
toctree_data = {
key: getattr(toctree, key)
for key in TOCTREE_OPTIONS
if (not skip_defaults) or getattr(toctree, key) != fields[key].default
if (not skip_defaults) or getattr(toctree, key) != _defaults[key]
}
toctree_data[items_key] = [_parse_item(s) for s in toctree.items]
data[subtrees_key].append(toctree_data)
Expand Down