Skip to content

Commit 3d18fee

Browse files
authored
♻️ REFACTOR: Replace attrs with dataclasses (#76)
Removes the external dependency
1 parent bbf855a commit 3d18fee

File tree

5 files changed

+203
-47
lines changed

5 files changed

+203
-47
lines changed

.pre-commit-config.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,3 @@ repos:
3838
rev: v0.942
3939
hooks:
4040
- id: mypy
41-
additional_dependencies: [attrs]

pyproject.toml

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ classifiers = [
2626
keywords = ["sphinx","extension", "toc"]
2727
requires-python = "~=3.7"
2828
dependencies = [
29-
"attrs>=20.3,<22",
3029
"click>=7.1,<9",
3130
"pyyaml",
3231
"sphinx>=3,<5",
@@ -69,11 +68,7 @@ no_implicit_optional = true
6968
strict_equality = true
7069

7170
[[tool.mypy.overrides]]
72-
module = ["docutils.*"]
73-
ignore_missing_imports = true
74-
75-
[[tool.mypy.overrides]]
76-
module = ["yaml.*"]
71+
module = ["docutils.*", "yaml.*"]
7772
ignore_missing_imports = true
7873

7974
[tool.isort]

sphinx_external_toc/_compat.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""Compatibility for using dataclasses instead of attrs."""
2+
from __future__ import annotations
3+
4+
import dataclasses as dc
5+
import re
6+
import sys
7+
from typing import Any, Callable, Pattern, Type
8+
9+
if sys.version_info >= (3, 10):
10+
DC_SLOTS: dict = {"slots": True}
11+
else:
12+
DC_SLOTS: dict = {}
13+
14+
15+
def field(**kwargs: Any):
16+
if sys.version_info < (3, 10):
17+
kwargs.pop("kw_only", None)
18+
if "validator" in kwargs:
19+
kwargs.setdefault("metadata", {})["validator"] = kwargs.pop("validator")
20+
return dc.field(**kwargs)
21+
22+
23+
field.__doc__ = dc.field.__doc__
24+
25+
26+
def validate_fields(inst):
27+
"""Validate the fields of a dataclass,
28+
according to `validator` functions set in the field metadata.
29+
30+
This function should be called in the `__post_init__` of the dataclass.
31+
32+
The validator function should take as input (inst, field, value) and
33+
raise an exception if the value is invalid.
34+
"""
35+
for field in dc.fields(inst):
36+
if "validator" not in field.metadata:
37+
continue
38+
if isinstance(field.metadata["validator"], list):
39+
for validator in field.metadata["validator"]:
40+
validator(inst, field, getattr(inst, field.name))
41+
else:
42+
field.metadata["validator"](inst, field, getattr(inst, field.name))
43+
44+
45+
ValidatorType = Callable[[Any, dc.Field, Any], None]
46+
47+
48+
def instance_of(type: Type[Any] | tuple[Type[Any], ...]) -> ValidatorType:
49+
"""
50+
A validator that raises a `TypeError` if the initializer is called
51+
with a wrong type for this particular attribute (checks are performed using
52+
`isinstance` therefore it's also valid to pass a tuple of types).
53+
54+
:param type: The type to check for.
55+
"""
56+
57+
def _validator(inst, attr, value):
58+
"""
59+
We use a callable class to be able to change the ``__repr__``.
60+
"""
61+
if not isinstance(value, type):
62+
raise TypeError(
63+
f"'{attr.name}' must be {type!r} (got {value!r} that is a {value.__class__!r})."
64+
)
65+
66+
return _validator
67+
68+
69+
def matches_re(regex: str | Pattern, flags: int = 0) -> ValidatorType:
70+
r"""
71+
A validator that raises `ValueError` if the initializer is called
72+
with a string that doesn't match *regex*.
73+
74+
:param regex: a regex string or precompiled pattern to match against
75+
:param flags: flags that will be passed to the underlying re function (default 0)
76+
77+
"""
78+
fullmatch = getattr(re, "fullmatch", None)
79+
80+
if isinstance(regex, Pattern):
81+
if flags:
82+
raise TypeError(
83+
"'flags' can only be used with a string pattern; "
84+
"pass flags to re.compile() instead"
85+
)
86+
pattern = regex
87+
else:
88+
pattern = re.compile(regex, flags)
89+
90+
if fullmatch:
91+
match_func = pattern.fullmatch
92+
else: # Python 2 fullmatch emulation (https://bugs.python.org/issue16203)
93+
pattern = re.compile(r"(?:{})\Z".format(pattern.pattern), pattern.flags)
94+
match_func = pattern.match
95+
96+
def _validator(inst, attr, value):
97+
if not match_func(value):
98+
raise ValueError(
99+
f"'{attr.name}' must match regex {pattern!r} ({value!r} doesn't)"
100+
)
101+
102+
return _validator
103+
104+
105+
def optional(validator: ValidatorType) -> ValidatorType:
106+
"""
107+
A validator that makes an attribute optional. An optional attribute is one
108+
which can be set to ``None`` in addition to satisfying the requirements of
109+
the sub-validator.
110+
"""
111+
112+
def _validator(inst, attr, value):
113+
if value is None:
114+
return
115+
116+
validator(inst, attr, value)
117+
118+
return _validator
119+
120+
121+
def deep_iterable(
122+
member_validator: ValidatorType, iterable_validator: ValidatorType | None = None
123+
) -> ValidatorType:
124+
"""
125+
A validator that performs deep validation of an iterable.
126+
127+
:param member_validator: Validator to apply to iterable members
128+
:param iterable_validator: Validator to apply to iterable itself
129+
"""
130+
131+
def _validator(inst, attr, value):
132+
if iterable_validator is not None:
133+
iterable_validator(inst, attr, value)
134+
135+
for member in value:
136+
member_validator(inst, attr, member)
137+
138+
return _validator

sphinx_external_toc/api.py

Lines changed: 53 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
"""Defines the `SiteMap` object, for storing the parsed ToC."""
22
from collections.abc import MutableMapping
3+
from dataclasses import asdict, dataclass
34
from typing import Any, Dict, Iterator, List, Optional, Set, Union
45

5-
import attr
6-
from attr.validators import deep_iterable, instance_of, matches_re, optional
6+
from ._compat import (
7+
DC_SLOTS,
8+
deep_iterable,
9+
field,
10+
instance_of,
11+
matches_re,
12+
optional,
13+
validate_fields,
14+
)
715

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

2331

24-
@attr.s(slots=True)
32+
@dataclass(**DC_SLOTS)
2533
class UrlItem:
2634
"""A URL in a toctree."""
2735

2836
# regex should match sphinx.util.url_re
29-
url: str = attr.ib(validator=[instance_of(str), matches_re(URL_PATTERN)])
30-
title: Optional[str] = attr.ib(None, validator=optional(instance_of(str)))
37+
url: str = field(validator=[instance_of(str), matches_re(URL_PATTERN)])
38+
title: Optional[str] = field(default=None, validator=optional(instance_of(str)))
3139

40+
def __post_init__(self):
41+
validate_fields(self)
3242

33-
@attr.s(slots=True)
43+
44+
@dataclass(**DC_SLOTS)
3445
class TocTree:
3546
"""An individual toctree within a document."""
3647

3748
# TODO validate uniqueness of docnames (at least one item)
38-
items: List[Union[GlobItem, FileItem, UrlItem]] = attr.ib(
49+
items: List[Union[GlobItem, FileItem, UrlItem]] = field(
3950
validator=deep_iterable(
4051
instance_of((GlobItem, FileItem, UrlItem)), instance_of(list)
4152
)
4253
)
43-
caption: Optional[str] = attr.ib(
54+
caption: Optional[str] = field(
4455
default=None, kw_only=True, validator=optional(instance_of(str))
4556
)
46-
hidden: bool = attr.ib(default=True, kw_only=True, validator=instance_of(bool))
47-
maxdepth: int = attr.ib(default=-1, kw_only=True, validator=instance_of(int))
48-
numbered: Union[bool, int] = attr.ib(
57+
hidden: bool = field(default=True, kw_only=True, validator=instance_of(bool))
58+
maxdepth: int = field(default=-1, kw_only=True, validator=instance_of(int))
59+
numbered: Union[bool, int] = field(
4960
default=False, kw_only=True, validator=instance_of((bool, int))
5061
)
51-
reversed: bool = attr.ib(default=False, kw_only=True, validator=instance_of(bool))
52-
titlesonly: bool = attr.ib(default=False, kw_only=True, validator=instance_of(bool))
62+
reversed: bool = field(default=False, kw_only=True, validator=instance_of(bool))
63+
titlesonly: bool = field(default=False, kw_only=True, validator=instance_of(bool))
64+
65+
def __post_init__(self):
66+
validate_fields(self)
5367

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

6882

69-
@attr.s(slots=True)
83+
@dataclass(**DC_SLOTS)
7084
class Document:
7185
"""A document in the site map."""
7286

7387
# TODO validate uniqueness of docnames across all parts (and none should be the docname)
74-
docname: str = attr.ib(validator=instance_of(str))
75-
subtrees: List[TocTree] = attr.ib(
76-
factory=list,
88+
docname: str = field(validator=instance_of(str))
89+
subtrees: List[TocTree] = field(
90+
default_factory=list,
7791
validator=deep_iterable(instance_of(TocTree), instance_of(list)),
7892
)
79-
title: Optional[str] = attr.ib(default=None, validator=optional(instance_of(str)))
93+
title: Optional[str] = field(default=None, validator=optional(instance_of(str)))
94+
95+
def __post_init__(self):
96+
validate_fields(self)
8097

8198
def child_files(self) -> List[str]:
8299
"""Return all children files.
@@ -183,24 +200,29 @@ def __len__(self) -> int:
183200
"""
184201
return len(self._docs)
185202

186-
@staticmethod
187-
def _serializer(inst: Any, field: attr.Attribute, value: Any) -> Any:
188-
"""Serialize to JSON compatible value.
189-
190-
(parsed to ``attr.asdict``)
191-
"""
192-
if isinstance(value, (GlobItem, FileItem)):
193-
return str(value)
194-
return value
195-
196203
def as_json(self) -> Dict[str, Any]:
197204
"""Return JSON serialized site-map representation."""
198205
doc_dict = {
199-
k: attr.asdict(self._docs[k], value_serializer=self._serializer)
200-
if self._docs[k]
201-
else self._docs[k]
206+
k: asdict(self._docs[k]) if self._docs[k] else self._docs[k]
202207
for k in sorted(self._docs)
203208
}
209+
210+
def _replace_items(d: Dict[str, Any]) -> Dict[str, Any]:
211+
for k, v in d.items():
212+
if isinstance(v, dict):
213+
d[k] = _replace_items(v)
214+
elif isinstance(v, (list, tuple)):
215+
d[k] = [
216+
_replace_items(i)
217+
if isinstance(i, dict)
218+
else (str(i) if isinstance(i, str) else i)
219+
for i in v
220+
]
221+
elif isinstance(v, str):
222+
d[k] = str(v)
223+
return d
224+
225+
doc_dict = _replace_items(doc_dict)
204226
data = {
205227
"root": self.root.docname,
206228
"documents": doc_dict,

sphinx_external_toc/parsing.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"""Parse the ToC to a `SiteMap` object."""
22
from collections.abc import Mapping
3+
from dataclasses import dataclass, fields
34
from pathlib import Path
45
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple, Union
56

6-
import attr
77
import yaml
88

9+
from ._compat import DC_SLOTS, field
910
from .api import Document, FileItem, GlobItem, SiteMap, TocTree, UrlItem
1011

1112
DEFAULT_SUBTREES_KEY = "subtrees"
@@ -25,15 +26,15 @@
2526
)
2627

2728

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

32-
toc_defaults: Dict[str, Any] = attr.ib(factory=dict)
33-
subtrees_keys: Sequence[str] = attr.ib(default=())
34-
items_keys: Sequence[str] = attr.ib(default=())
35-
default_subtrees_key: str = attr.ib(default=DEFAULT_SUBTREES_KEY)
36-
default_items_key: str = attr.ib(default=DEFAULT_ITEMS_KEY)
33+
toc_defaults: Dict[str, Any] = field(default_factory=dict)
34+
subtrees_keys: Sequence[str] = ()
35+
items_keys: Sequence[str] = ()
36+
default_subtrees_key: str = DEFAULT_SUBTREES_KEY
37+
default_items_key: str = DEFAULT_ITEMS_KEY
3738

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

423424
data[subtrees_key] = []
424-
fields = attr.fields_dict(TocTree)
425+
# TODO handle default_factory
426+
_defaults = {f.name: f.default for f in fields(TocTree)}
425427
for toctree in doc_item.subtrees:
426428
# only add these keys if their value is not the default
427429
toctree_data = {
428430
key: getattr(toctree, key)
429431
for key in TOCTREE_OPTIONS
430-
if (not skip_defaults) or getattr(toctree, key) != fields[key].default
432+
if (not skip_defaults) or getattr(toctree, key) != _defaults[key]
431433
}
432434
toctree_data[items_key] = [_parse_item(s) for s in toctree.items]
433435
data[subtrees_key].append(toctree_data)

0 commit comments

Comments
 (0)