Skip to content

Commit f11fef1

Browse files
authored
feat: field_keys and docstrings (#5)
* feat: add sybil doctests * feat: field_keys and full docstrings * ci: fix pylint Signed-off-by: nstarman <nstarman@users.noreply.github.com>
1 parent 4d9c8ff commit f11fef1

File tree

5 files changed

+354
-28
lines changed

5 files changed

+354
-28
lines changed

conftest.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""Doctest configuration."""
2+
3+
from doctest import ELLIPSIS, NORMALIZE_WHITESPACE
4+
5+
from sybil import Sybil
6+
from sybil.parsers.rest import DocTestParser, PythonCodeBlockParser, SkipParser
7+
8+
pytest_collect_file = Sybil(
9+
parsers=[
10+
DocTestParser(optionflags=NORMALIZE_WHITESPACE | ELLIPSIS),
11+
PythonCodeBlockParser(),
12+
SkipParser(),
13+
],
14+
patterns=["*.rst", "*.py"],
15+
).pytest()

pyproject.toml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ dependencies = [
3737
test = [
3838
"pytest >=6",
3939
"pytest-cov >=3",
40+
"sybil",
4041
]
4142
dev = [
4243
"pytest >=6",
@@ -113,21 +114,26 @@ src = ["src"]
113114
extend-select = ["ALL"]
114115
ignore = [
115116
"ANN401", # Dynamically typed expressions are disallowed in `**kwargs`
117+
"COM812", # For ruff.format
116118
"D203", # 1 blank line required before class docstring
117119
"D213", # Multi-line docstring summary should start at the first line
120+
"F811", # Redefinition of unused '...' (for plum-dispatch)
121+
"FIX002", # Line contains TODO
122+
"ISC001", # Conflicts with formatter
118123
"PLR09", # Too many <...>
119124
"PLR2004", # Magic value used in comparison
120-
"ISC001", # Conflicts with formatter
121125
"TD002", # Missing author in TODO
122126
"TD003", # Missing issue link on the line following this TODO
123-
"FIX002", # Line contains TODO
124127
]
125128

126129
[tool.ruff.lint.per-file-ignores]
127130
"tests/**" = ["ANN", "S101", "T20"]
128131
"noxfile.py" = ["T20"]
129132
"docs/conf.py" = ["INP001"]
130133

134+
[tool.ruff.lint.isort]
135+
combine-as-imports = true
136+
131137

132138
[tool.pylint]
133139
py-version = "3.10"
@@ -137,6 +143,7 @@ similarities.ignore-imports = "yes"
137143
messages_control.disable = [
138144
"design",
139145
"fixme",
146+
"function-redefined", # for plum-dispatch
140147
"line-too-long",
141148
"missing-module-docstring",
142149
"missing-function-docstring",

src/dataclasstools/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"""
55

66
from ._core import DataclassInstance, asdict, astuple, fields, replace
7-
from ._ext import field_items, field_values
7+
from ._ext import field_items, field_keys, field_values
88
from ._version import version as __version__
99

1010
__all__ = [
@@ -16,6 +16,7 @@
1616
"asdict",
1717
"astuple",
1818
# ext
19+
"field_keys",
1920
"field_values",
2021
"field_items",
2122
]

src/dataclasstools/_core.py

Lines changed: 199 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22

33
__all__ = ["DataclassInstance", "replace", "fields", "asdict", "astuple"]
44

5-
from collections.abc import Callable
6-
from dataclasses import Field as _dataclass_Field
7-
from dataclasses import asdict as _dataclass_asdict
8-
from dataclasses import astuple as _dataclass_astuple
9-
from dataclasses import fields as _dataclass_fields
10-
from dataclasses import replace as _dataclass_replace
5+
from collections.abc import Callable, Hashable, Mapping
6+
from dataclasses import (
7+
Field,
8+
asdict as _dataclass_asdict,
9+
astuple as _dataclass_astuple,
10+
field,
11+
fields as _dataclass_fields,
12+
replace as _dataclass_replace,
13+
)
1114
from typing import Any, ClassVar, Protocol, runtime_checkable
1215

1316
from plum import dispatch
@@ -27,34 +30,215 @@ def __subclasshook__(cls: type, c: type) -> bool:
2730
return hasattr(c, "__dataclass_fields__")
2831

2932

30-
@dispatch # type: ignore[misc]
33+
# ===================================================================
34+
# Replace
35+
36+
37+
@dispatch
3138
def replace(obj: DataclassInstance, /, **kwargs: Any) -> DataclassInstance:
32-
"""Replace the fields of a dataclass instance."""
39+
"""Replace the fields of a dataclass instance.
40+
41+
Examples
42+
--------
43+
>>> from dataclasses import dataclass
44+
>>> from dataclasstools import replace
45+
46+
>>> @dataclass
47+
... class Point:
48+
... x: float
49+
... y: float
50+
51+
>>> p = Point(1.0, 2.0)
52+
>>> p
53+
Point(x=1.0, y=2.0)
54+
55+
>>> replace(p, x=3.0)
56+
Point(x=3.0, y=2.0)
57+
58+
"""
3359
return _dataclass_replace(obj, **kwargs)
3460

3561

36-
@dispatch # type: ignore[misc]
37-
def fields(obj: DataclassInstance) -> tuple[_dataclass_Field, ...]: # type: ignore[type-arg] # TODO: raise issue in beartype
38-
"""Return the fields of a dataclass instance."""
62+
@dispatch # type: ignore[no-redef]
63+
def replace(obj: Mapping[Hashable, Any], /, **kwargs: Any) -> Mapping[Hashable, Any]:
64+
"""Replace the fields of a mapping.
65+
66+
This operates similarly to `dict.update`, except that
67+
the kwargs are checked against the keys of the mapping.
68+
69+
Examples
70+
--------
71+
>>> from dataclasses import dataclass
72+
>>> from dataclasstools import replace
73+
74+
>>> p = {"a": 1, "b": 2, "c": 3}
75+
>>> p
76+
{'a': 1, 'b': 2, 'c': 3}
77+
78+
>>> replace(p, c=4.0)
79+
{'a': 1, 'b': 2, 'c': 4.0}
80+
81+
"""
82+
extra_keys = set(kwargs) - set(obj)
83+
if extra_keys:
84+
msg = f"invalid keys {extra_keys}."
85+
raise ValueError(msg)
86+
87+
return type(obj)(**{**obj, **kwargs})
88+
89+
90+
# ===================================================================
91+
# Fields
92+
93+
94+
@dispatch
95+
def fields(obj: DataclassInstance, /) -> tuple[Field, ...]: # type: ignore[type-arg] # TODO: raise issue in beartype
96+
"""Return the fields of a dataclass instance.
97+
98+
Examples
99+
--------
100+
>>> from dataclasses import dataclass
101+
>>> from dataclasstools import fields
102+
103+
>>> @dataclass
104+
... class Point:
105+
... x: float
106+
... y: float
107+
108+
>>> p = Point(1.0, 2.0)
109+
>>> fields(p)
110+
(Field(name='x',type=<class 'float'>,...),
111+
Field(name='y',type=<class 'float'>,...))
112+
113+
"""
39114
return _dataclass_fields(obj)
40115

41116

42-
@dispatch # type: ignore[misc]
117+
@dispatch # type: ignore[no-redef]
118+
def fields(obj: Mapping[str, Any], /) -> tuple[Field, ...]: # type: ignore[type-arg] # TODO: raise issue in beartype
119+
"""Return the mapping as a tuple of `dataclass.Field` objects.
120+
121+
Examples
122+
--------
123+
>>> from dataclasstools import fields
124+
125+
>>> p = {"a": 1, "b": 2.0, "c": "3"}
126+
>>> fields(p)
127+
(Field(name='a',type=<class 'int'>,...),
128+
Field(name='b',type=<class 'float'>,...),
129+
Field(name='c',type=<class 'str'>,...))
130+
131+
"""
132+
fs = tuple(field(kw_only=True) for _ in obj) # pylint: disable=invalid-field-call
133+
for f, (k, v) in zip(fs, obj.items(), strict=True):
134+
f.name = k
135+
f.type = type(v)
136+
return fs
137+
138+
139+
# ===================================================================
140+
# Asdict
141+
142+
143+
@dispatch
43144
def asdict(
44145
obj: DataclassInstance,
45146
/,
46147
*,
47148
dict_factory: Callable[[list[tuple[str, Any]]], dict[str, Any]] = dict,
48149
) -> dict[str, Any]:
49-
"""Return the fields of a dataclass instance as a dictionary."""
150+
"""Return the fields of a dataclass instance as a dictionary.
151+
152+
Examples
153+
--------
154+
>>> from dataclasses import dataclass
155+
>>> from dataclasstools import asdict
156+
157+
>>> @dataclass
158+
... class Point:
159+
... x: float
160+
... y: float
161+
162+
>>> p = Point(1.0, 2.0)
163+
>>> asdict(p)
164+
{'x': 1.0, 'y': 2.0}
165+
166+
"""
50167
return _dataclass_asdict(obj, dict_factory=dict_factory)
51168

52169

53-
@dispatch # type: ignore[misc]
170+
@dispatch # type: ignore[no-redef]
171+
def asdict(
172+
obj: Mapping[str, Any],
173+
/,
174+
*,
175+
dict_factory: Callable[[list[tuple[str, Any]]], dict[str, Any]] = dict,
176+
) -> dict[str, Any]:
177+
"""Return the fields of a mapping as a dictionary.
178+
179+
Following the `asdict` API, the dictionary may be copied if ``dict_factory``
180+
performs a copy when constructed from a :class:`~collections.abc.Mapping`.
181+
182+
Examples
183+
--------
184+
>>> from dataclasstools import asdict
185+
186+
>>> p = {"a": 1, "b": 2.0, "c": "3"}
187+
>>> asdict(p)
188+
{'a': 1, 'b': 2.0, 'c': '3'}
189+
190+
>>> asdict(p) is p
191+
False
192+
193+
"""
194+
return dict_factory(obj)
195+
196+
197+
# ===================================================================
198+
# Astuple
199+
200+
201+
@dispatch
54202
def astuple(
55203
obj: DataclassInstance,
56204
/,
57205
tuple_factory: Callable[[Any], tuple[Any, ...]] = tuple,
58206
) -> tuple[Any, ...]:
59-
"""Return the fields of a dataclass instance as a tuple."""
207+
"""Return the fields of a dataclass instance as a tuple.
208+
209+
Examples
210+
--------
211+
>>> from dataclasses import dataclass
212+
>>> from dataclasstools import astuple
213+
214+
>>> @dataclass
215+
... class Point:
216+
... x: float
217+
... y: float
218+
219+
>>> p = Point(1.0, 2.0)
220+
>>> astuple(p)
221+
(1.0, 2.0)
222+
223+
"""
60224
return _dataclass_astuple(obj, tuple_factory=tuple_factory)
225+
226+
227+
@dispatch # type: ignore[no-redef]
228+
def astuple(
229+
obj: Mapping[str, Any],
230+
/,
231+
tuple_factory: Callable[[Any], tuple[Any, ...]] = tuple,
232+
) -> tuple[Any, ...]:
233+
"""Return the fields of a mapping as a tuple.
234+
235+
Examples
236+
--------
237+
>>> from dataclasstools import astuple
238+
239+
>>> p = {"a": 1, "b": 2.0, "c": "3"}
240+
>>> astuple(p)
241+
(1, 2.0, '3')
242+
243+
"""
244+
return tuple_factory(obj.values())

0 commit comments

Comments
 (0)