Skip to content

Commit

Permalink
Add literal string support to include and exclude filters (#1068)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
3 people authored Apr 14, 2023
1 parent e4c9f27 commit c4c6fdd
Show file tree
Hide file tree
Showing 7 changed files with 75 additions and 12 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
.pytest_cache
.tox
.vscode
.venv*
build
dist
docs/_build
Expand Down
1 change: 1 addition & 0 deletions changelog.d/1068.change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`attrs.filters.exclude()` and `attrs.filters.include()` now support the passing of attribute names as strings.
33 changes: 30 additions & 3 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ For that, {func}`attrs.asdict` offers a callback that decides whether an attribu
{'users': [{'email': 'jane@doe.invalid'}, {'email': 'joe@doe.invalid'}]}
```

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:
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:

```{doctest}
>>> from attrs import asdict, filters, fields
Expand All @@ -224,11 +224,12 @@ For the common case where you want to [`include`](attrs.filters.include) or [`ex
... class User:
... login: str
... password: str
... email: str
... id: int
>>> asdict(
... User("jane", "s33kred", 42),
... filter=filters.exclude(fields(User).password, int))
... User("jane", "s33kred", "jane@example.com", 42),
... filter=filters.exclude(fields(User).password, "email", int))
{'login': 'jane'}
>>> @define
Expand All @@ -240,8 +241,34 @@ For the common case where you want to [`include`](attrs.filters.include) or [`ex
>>> asdict(C("foo", "2", 3),
... filter=filters.include(int, fields(C).x))
{'x': 'foo', 'z': 3}
>>> asdict(C("foo", "2", 3),
... filter=filters.include(fields(C).x, "z"))
{'x': 'foo', 'z': 3}
```

:::{note}
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.
{func}`attrs.fields()` will raise an `AttributeError` when the field doesn't exist while literal string names won't.
Using {func}`attrs.fields()` to get attributes is worth being recommended in most cases.

```{doctest}
>>> asdict(
... User("jane", "s33kred", "jane@example.com", 42),
... filter=filters.exclude("passwd")
... )
{'login': 'jane', 'password': 's33kred', 'email': 'jane@example.com', 'id': 42}
>>> asdict(
... User("jane", "s33kred", "jane@example.com", 42),
... filter=fields(User).passwd
... )
Traceback (most recent call last):
...
AttributeError: 'UserAttributes' object has no attribute 'passwd'. Did you mean: 'password'?
```
:::

Other times, all you want is a tuple and *attrs* won't let you down:

```{doctest}
Expand Down
27 changes: 21 additions & 6 deletions src/attr/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ def _split_what(what):
"""
return (
frozenset(cls for cls in what if isinstance(cls, type)),
frozenset(cls for cls in what if isinstance(cls, str)),
frozenset(cls for cls in what if isinstance(cls, Attribute)),
)

Expand All @@ -22,14 +23,21 @@ def include(*what):
Include *what*.
:param what: What to include.
:type what: `list` of `type` or `attrs.Attribute`\\ s
:type what: `list` of classes `type`, field names `str` or
`attrs.Attribute`\\ s
:rtype: `callable`
.. versionchanged:: 23.1.0 Accept strings with field names.
"""
cls, attrs = _split_what(what)
cls, names, attrs = _split_what(what)

def include_(attribute, value):
return value.__class__ in cls or attribute in attrs
return (
value.__class__ in cls
or attribute.name in names
or attribute in attrs
)

return include_

Expand All @@ -39,13 +47,20 @@ def exclude(*what):
Exclude *what*.
:param what: What to exclude.
:type what: `list` of classes or `attrs.Attribute`\\ s.
:type what: `list` of classes `type`, field names `str` or
`attrs.Attribute`\\ s.
:rtype: `callable`
.. versionchanged:: 23.3.0 Accept field name string as input argument
"""
cls, attrs = _split_what(what)
cls, names, attrs = _split_what(what)

def exclude_(attribute, value):
return value.__class__ not in cls and attribute not in attrs
return not (
value.__class__ in cls
or attribute.name in names
or attribute in attrs
)

return exclude_
4 changes: 2 additions & 2 deletions src/attr/filters.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ from typing import Any, Union

from . import Attribute, _FilterType

def include(*what: Union[type, Attribute[Any]]) -> _FilterType[Any]: ...
def exclude(*what: Union[type, Attribute[Any]]) -> _FilterType[Any]: ...
def include(*what: Union[type, str, Attribute[Any]]) -> _FilterType[Any]: ...
def exclude(*what: Union[type, str, Attribute[Any]]) -> _FilterType[Any]: ...
19 changes: 18 additions & 1 deletion tests/test_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ def test_splits(self):
"""
assert (
frozenset((int, str)),
frozenset(("abcd", "123")),
frozenset((fields(C).a,)),
) == _split_what((str, fields(C).a, int))
) == _split_what((str, "123", fields(C).a, int, "abcd"))


class TestInclude:
Expand All @@ -46,6 +47,10 @@ class TestInclude:
((str,), "hello"),
((str, fields(C).a), 42),
((str, fields(C).b), "hello"),
(("a",), 42),
(("a",), "hello"),
(("a", str), 42),
(("a", fields(C).b), "hello"),
],
)
def test_allow(self, incl, value):
Expand All @@ -62,6 +67,10 @@ def test_allow(self, incl, value):
((int,), "hello"),
((str, fields(C).b), 42),
((int, fields(C).b), "hello"),
(("b",), 42),
(("b",), "hello"),
(("b", str), 42),
(("b", fields(C).b), "hello"),
],
)
def test_drop_class(self, incl, value):
Expand All @@ -84,6 +93,10 @@ class TestExclude:
((int,), "hello"),
((str, fields(C).b), 42),
((int, fields(C).b), "hello"),
(("b",), 42),
(("b",), "hello"),
(("b", str), 42),
(("b", fields(C).b), "hello"),
],
)
def test_allow(self, excl, value):
Expand All @@ -100,6 +113,10 @@ def test_allow(self, excl, value):
((str,), "hello"),
((str, fields(C).a), 42),
((str, fields(C).b), "hello"),
(("a",), 42),
(("a",), "hello"),
(("a", str), 42),
(("a", fields(C).b), "hello"),
],
)
def test_drop_class(self, excl, value):
Expand Down
2 changes: 2 additions & 0 deletions tests/typing_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,7 @@ def accessing_from_attr() -> None:
attr.converters.optional
attr.exceptions.FrozenError
attr.filters.include
attr.filters.exclude
attr.setters.frozen
attr.validators.and_
attr.cmp_using
Expand All @@ -453,6 +454,7 @@ def accessing_from_attrs() -> None:
attrs.converters.optional
attrs.exceptions.FrozenError
attrs.filters.include
attrs.filters.exclude
attrs.setters.frozen
attrs.validators.and_
attrs.cmp_using
Expand Down

0 comments on commit c4c6fdd

Please sign in to comment.