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
Original file line number Diff line number Diff line change
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.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
filters ``exclude`` and ``include`` now support to pass literal name of attribtes in ``str`` format
lqhuang marked this conversation as resolved.
Show resolved Hide resolved
28 changes: 25 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 @@ -228,7 +228,7 @@ For the common case where you want to [`include`](attrs.filters.include) or [`ex

>>> asdict(
... User("jane", "s33kred", 42),
... filter=filters.exclude(fields(User).password, int))
... filter=filters.exclude(fields(User).password, "password", int))
lqhuang marked this conversation as resolved.
Show resolved Hide resolved
{'login': 'jane'}

>>> @define
Expand All @@ -238,10 +238,32 @@ For the common case where you want to [`include`](attrs.filters.include) or [`ex
... z: int

>>> asdict(C("foo", "2", 3),
... filter=filters.include(int, fields(C).x))
... filter=filters.include(int, fields(C).x, "x"))
lqhuang marked this conversation as resolved.
Show resolved Hide resolved
{'x': 'foo', 'z': 3}
```

:::{note}
Through using string names directly is convenient, it's not safe in typo issues.
lqhuang marked this conversation as resolved.
Show resolved Hide resolved
`fields()` will raise an `AttributeError` when the field doesn't exist while literal string names won't.
lqhuang marked this conversation as resolved.
Show resolved Hide resolved
Use `fields()` to get attributes is worth being recommended in most cases.
lqhuang marked this conversation as resolved.
Show resolved Hide resolved

```{doctest}
>>> asdict(
... User("jane", "s33kred", 42),
... filter=filters.exclude("passwd")
... )
{'login': 'jane', 'password': 's33kred', '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
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:: 22.2.0 Accept field name string as input argument
lqhuang marked this conversation as resolved.
Show resolved Hide resolved
"""
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:: 22.2.0 Accept field name string as input argument
lqhuang marked this conversation as resolved.
Show resolved Hide resolved
"""
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