Skip to content

Commit c4c6fdd

Browse files
lqhuanghynekchrysle
authored
Add literal string support to include and exclude filters (#1068)
* Add literal string support to includer and exclude filters * Add docs and changelog for new feature * Fix typo in `typing_example` * Add a note to document typo issues while using literal name strings as filter args * Add more docs * Add code mark for `AttributeError` * Fix grammar error and upgrade `versionchanged` info * Improve docs and examples from hynek's comments * Keep example cases the same * More examples * Apply suggestions from code review Co-authored-by: chrysle <fritzihab@posteo.de> --------- Co-authored-by: Hynek Schlawack <hs@ox.cx> Co-authored-by: chrysle <fritzihab@posteo.de>
1 parent e4c9f27 commit c4c6fdd

File tree

7 files changed

+75
-12
lines changed

7 files changed

+75
-12
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
.pytest_cache
1111
.tox
1212
.vscode
13+
.venv*
1314
build
1415
dist
1516
docs/_build

changelog.d/1068.change.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
`attrs.filters.exclude()` and `attrs.filters.include()` now support the passing of attribute names as strings.

docs/examples.md

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ For that, {func}`attrs.asdict` offers a callback that decides whether an attribu
215215
{'users': [{'email': 'jane@doe.invalid'}, {'email': 'joe@doe.invalid'}]}
216216
```
217217

218-
For the common case where you want to [`include`](attrs.filters.include) or [`exclude`](attrs.filters.exclude) certain types or attributes, *attrs* ships with a few helpers:
218+
For the common case where you want to [`include`](attrs.filters.include) or [`exclude`](attrs.filters.exclude) certain types, string name or attributes, *attrs* ships with a few helpers:
219219

220220
```{doctest}
221221
>>> from attrs import asdict, filters, fields
@@ -224,11 +224,12 @@ For the common case where you want to [`include`](attrs.filters.include) or [`ex
224224
... class User:
225225
... login: str
226226
... password: str
227+
... email: str
227228
... id: int
228229
229230
>>> asdict(
230-
... User("jane", "s33kred", 42),
231-
... filter=filters.exclude(fields(User).password, int))
231+
... User("jane", "s33kred", "jane@example.com", 42),
232+
... filter=filters.exclude(fields(User).password, "email", int))
232233
{'login': 'jane'}
233234
234235
>>> @define
@@ -240,8 +241,34 @@ For the common case where you want to [`include`](attrs.filters.include) or [`ex
240241
>>> asdict(C("foo", "2", 3),
241242
... filter=filters.include(int, fields(C).x))
242243
{'x': 'foo', 'z': 3}
244+
245+
>>> asdict(C("foo", "2", 3),
246+
... filter=filters.include(fields(C).x, "z"))
247+
{'x': 'foo', 'z': 3}
243248
```
244249

250+
:::{note}
251+
Though using string names directly is convenient, mistyping attribute names will silently do the wrong thing and neither Python nor your type checker can help you.
252+
{func}`attrs.fields()` will raise an `AttributeError` when the field doesn't exist while literal string names won't.
253+
Using {func}`attrs.fields()` to get attributes is worth being recommended in most cases.
254+
255+
```{doctest}
256+
>>> asdict(
257+
... User("jane", "s33kred", "jane@example.com", 42),
258+
... filter=filters.exclude("passwd")
259+
... )
260+
{'login': 'jane', 'password': 's33kred', 'email': 'jane@example.com', 'id': 42}
261+
262+
>>> asdict(
263+
... User("jane", "s33kred", "jane@example.com", 42),
264+
... filter=fields(User).passwd
265+
... )
266+
Traceback (most recent call last):
267+
...
268+
AttributeError: 'UserAttributes' object has no attribute 'passwd'. Did you mean: 'password'?
269+
```
270+
:::
271+
245272
Other times, all you want is a tuple and *attrs* won't let you down:
246273

247274
```{doctest}

src/attr/filters.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ def _split_what(what):
1313
"""
1414
return (
1515
frozenset(cls for cls in what if isinstance(cls, type)),
16+
frozenset(cls for cls in what if isinstance(cls, str)),
1617
frozenset(cls for cls in what if isinstance(cls, Attribute)),
1718
)
1819

@@ -22,14 +23,21 @@ def include(*what):
2223
Include *what*.
2324
2425
:param what: What to include.
25-
:type what: `list` of `type` or `attrs.Attribute`\\ s
26+
:type what: `list` of classes `type`, field names `str` or
27+
`attrs.Attribute`\\ s
2628
2729
:rtype: `callable`
30+
31+
.. versionchanged:: 23.1.0 Accept strings with field names.
2832
"""
29-
cls, attrs = _split_what(what)
33+
cls, names, attrs = _split_what(what)
3034

3135
def include_(attribute, value):
32-
return value.__class__ in cls or attribute in attrs
36+
return (
37+
value.__class__ in cls
38+
or attribute.name in names
39+
or attribute in attrs
40+
)
3341

3442
return include_
3543

@@ -39,13 +47,20 @@ def exclude(*what):
3947
Exclude *what*.
4048
4149
:param what: What to exclude.
42-
:type what: `list` of classes or `attrs.Attribute`\\ s.
50+
:type what: `list` of classes `type`, field names `str` or
51+
`attrs.Attribute`\\ s.
4352
4453
:rtype: `callable`
54+
55+
.. versionchanged:: 23.3.0 Accept field name string as input argument
4556
"""
46-
cls, attrs = _split_what(what)
57+
cls, names, attrs = _split_what(what)
4758

4859
def exclude_(attribute, value):
49-
return value.__class__ not in cls and attribute not in attrs
60+
return not (
61+
value.__class__ in cls
62+
or attribute.name in names
63+
or attribute in attrs
64+
)
5065

5166
return exclude_

src/attr/filters.pyi

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@ from typing import Any, Union
22

33
from . import Attribute, _FilterType
44

5-
def include(*what: Union[type, Attribute[Any]]) -> _FilterType[Any]: ...
6-
def exclude(*what: Union[type, Attribute[Any]]) -> _FilterType[Any]: ...
5+
def include(*what: Union[type, str, Attribute[Any]]) -> _FilterType[Any]: ...
6+
def exclude(*what: Union[type, str, Attribute[Any]]) -> _FilterType[Any]: ...

tests/test_filters.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@ def test_splits(self):
3030
"""
3131
assert (
3232
frozenset((int, str)),
33+
frozenset(("abcd", "123")),
3334
frozenset((fields(C).a,)),
34-
) == _split_what((str, fields(C).a, int))
35+
) == _split_what((str, "123", fields(C).a, int, "abcd"))
3536

3637

3738
class TestInclude:
@@ -46,6 +47,10 @@ class TestInclude:
4647
((str,), "hello"),
4748
((str, fields(C).a), 42),
4849
((str, fields(C).b), "hello"),
50+
(("a",), 42),
51+
(("a",), "hello"),
52+
(("a", str), 42),
53+
(("a", fields(C).b), "hello"),
4954
],
5055
)
5156
def test_allow(self, incl, value):
@@ -62,6 +67,10 @@ def test_allow(self, incl, value):
6267
((int,), "hello"),
6368
((str, fields(C).b), 42),
6469
((int, fields(C).b), "hello"),
70+
(("b",), 42),
71+
(("b",), "hello"),
72+
(("b", str), 42),
73+
(("b", fields(C).b), "hello"),
6574
],
6675
)
6776
def test_drop_class(self, incl, value):
@@ -84,6 +93,10 @@ class TestExclude:
8493
((int,), "hello"),
8594
((str, fields(C).b), 42),
8695
((int, fields(C).b), "hello"),
96+
(("b",), 42),
97+
(("b",), "hello"),
98+
(("b", str), 42),
99+
(("b", fields(C).b), "hello"),
87100
],
88101
)
89102
def test_allow(self, excl, value):
@@ -100,6 +113,10 @@ def test_allow(self, excl, value):
100113
((str,), "hello"),
101114
((str, fields(C).a), 42),
102115
((str, fields(C).b), "hello"),
116+
(("a",), 42),
117+
(("a",), "hello"),
118+
(("a", str), 42),
119+
(("a", fields(C).b), "hello"),
103120
],
104121
)
105122
def test_drop_class(self, excl, value):

tests/typing_example.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,7 @@ def accessing_from_attr() -> None:
441441
attr.converters.optional
442442
attr.exceptions.FrozenError
443443
attr.filters.include
444+
attr.filters.exclude
444445
attr.setters.frozen
445446
attr.validators.and_
446447
attr.cmp_using
@@ -453,6 +454,7 @@ def accessing_from_attrs() -> None:
453454
attrs.converters.optional
454455
attrs.exceptions.FrozenError
455456
attrs.filters.include
457+
attrs.filters.exclude
456458
attrs.setters.frozen
457459
attrs.validators.and_
458460
attrs.cmp_using

0 commit comments

Comments
 (0)