Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add literal string support to include and exclude filters #1068

Merged
merged 13 commits into from Apr 14, 2023
1 change: 1 addition & 0 deletions .gitignore
Expand Up @@ -10,6 +10,7 @@
.pytest_cache
.tox
.vscode
.venv*
lqhuang marked this conversation as resolved.
Show resolved Hide resolved
build
dist
docs/_build
Expand Down
1 change: 1 addition & 0 deletions changelog.d/1068.change.md
@@ -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
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.org", 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.org", 42),
... filter=filters.exclude("passwd")
... )
{'login': 'jane', 'password': 's33kred', 'email': 'jane@example.org', 'id': 42}

>>> asdict(
... User("jane", "s33kred", 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
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
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
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
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