Skip to content

Commit 0e43766

Browse files
author
Peter Gaultney
authored
Merge pull request #4 from xoeye/feature/strip-attrs-defaults
1.5.0: Allow stripping of attrs defaults on unstructure
2 parents b9224d2 + 0336673 commit 0e43766

9 files changed

+269
-9
lines changed

CHANGES.md

+10
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
## 1.5.0
2+
3+
New converter which will strip (recursively) all attributes which are
4+
equal to their attrs defaults, except for attributes annotated as
5+
`Literal`, which are assumed to be required.
6+
7+
Can be called using `unstruc_strip_defaults(your_attrs_obj)`, or on a
8+
Cat type using the new boolean keyword argument on `unstruc`,
9+
`your_cats_obj.unstruc(strip_defaults=True)`.
10+
111
### 1.4.1
212

313
No longer assume that the `dict` methods will not be overlaid on a

README.md

+17
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,23 @@ features. The 3 core features are:
199199
a Wildcat but its underlying `dict` would be overlaid and
200200
inaccessible as a Wildcat.
201201

202+
4. ### Strip `attrs` defaults on unstructure
203+
204+
All attributes where the value matches the default, except for
205+
attributes annotated as `Literal`, can have their defaults stripped
206+
recursively during unstructure.
207+
208+
This is accomplished via a new built-in Converter instance, and
209+
does not require use of features 1-3; in fact it will work with
210+
pure `attrs` classes.
211+
212+
This is new as of 1.5.0, is not the default behavior, and is
213+
fully backwards-compatible. It is enabled by a specific call to
214+
`unstruc_strip_defaults` or via a boolean keyword-only argument on
215+
the mixin method, `obj.unstruc(strip_defaults=True)`.
216+
217+
218+
202219

203220
## Notes on intent, compatibility, and dependencies
204221

tests/test_strip_defaults.py

+107
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
from typing import Dict, Any
2+
from typing_extensions import Literal
3+
4+
import attr
5+
from attr import Factory as fac
6+
7+
from typecats import Cat, unstruc_strip_defaults
8+
9+
10+
def test_clean_cats_basic():
11+
@Cat
12+
class Clean:
13+
s: str = ""
14+
i: int = 8
15+
lst: list = attr.Factory(list)
16+
17+
a = Clean()
18+
assert unstruc_strip_defaults(a) == dict()
19+
20+
b = Clean(i=4, lst=[1])
21+
22+
assert unstruc_strip_defaults(b) == dict(lst=[1], i=4)
23+
24+
25+
def test_method_works_also():
26+
@Cat
27+
class Clean:
28+
s: str = ""
29+
i: int = 8
30+
lst: list = attr.Factory(list)
31+
32+
a = Clean()
33+
assert a.unstruc(strip_defaults=True) == dict()
34+
35+
b = Clean(i=4, lst=[1])
36+
37+
assert b.unstruc(strip_defaults=True) == dict(lst=[1], i=4)
38+
39+
40+
def test_clean_wildcat():
41+
@Cat
42+
class WC(dict):
43+
s: str = ""
44+
i: int = 8
45+
lst: list = attr.Factory(list)
46+
47+
a = WC()
48+
assert unstruc_strip_defaults(a) == dict()
49+
50+
b = WC(i=4, lst=[1])
51+
b["f"] = 2
52+
53+
assert unstruc_strip_defaults(b) == dict(lst=[1], i=4, f=2)
54+
55+
56+
def test_clean_literal():
57+
@Cat
58+
class CleanLit:
59+
g: int
60+
s: str = ""
61+
a: Literal["a"] = "a"
62+
63+
cl = CleanLit(g=12)
64+
assert unstruc_strip_defaults(cl) == dict(a="a", g=12)
65+
66+
67+
@Cat
68+
class WC(dict):
69+
has_s: str = ""
70+
has_b: bool = False
71+
72+
73+
@Cat
74+
class Org(dict):
75+
id: str
76+
all: Literal[1] = 1
77+
strip_me: Dict[str, Any] = fac(dict)
78+
userClaims: WC = fac(WC)
79+
80+
81+
def test_clean_with_wildcat_underneath():
82+
83+
# this wildcat has a non-default value for a known key
84+
od = dict(id="id1", userClaims=dict(has_s="ssss", random="a string 1"))
85+
assert unstruc_strip_defaults(Org.struc(od)) == dict(od, all=1)
86+
87+
# this wildcat has no no-default values for known keys, but still
88+
# has wildcat data and should not be stripped.
89+
od = dict(id="id2", userClaims=dict(random="a string"))
90+
assert unstruc_strip_defaults(Org.struc(od)) == dict(od, all=1)
91+
92+
93+
def test_works_with_pure_attrs_obj():
94+
"""don't need a Cat annotation to take advantage of this!"""
95+
96+
@attr.s(auto_attribs=True)
97+
class Strippable:
98+
s: str = ""
99+
i: int = 8
100+
lst: list = attr.Factory(list)
101+
102+
a = Strippable()
103+
assert unstruc_strip_defaults(a) == dict()
104+
105+
b = Strippable(i=4, lst=[1])
106+
107+
assert unstruc_strip_defaults(b) == dict(lst=[1], i=4)

tests/test_wildcats.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,8 @@ class WithId(dict):
178178
def test_wildcat_repr_no_conflicts():
179179
@Cat
180180
class WithDictMethodAttrs(dict):
181-
items: ty.List[int]
182-
keys: ty.Set[str]
181+
items: ty.List[int] # type: ignore
182+
keys: ty.Set[str] # type: ignore
183183

184184
wdma = WithDictMethodAttrs([1, 2, 3], {"a", "b"})
185185

typecats/__about__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
"""typecats"""
2-
__version__ = "1.4.1"
2+
__version__ = "1.5.0"
33
__author__ = "Peter Gaultney"
44
__author_email__ = "pgaultney@xoi.io"

typecats/__init__.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
1-
from .tc import Cat, unstruc, struc, register_struc_hook, register_unstruc_hook # noqa
1+
from .tc import ( # noqa
2+
Cat,
3+
unstruc,
4+
struc,
5+
try_struc,
6+
register_struc_hook,
7+
register_unstruc_hook,
8+
)
29
from .wildcat import is_wildcat # noqa
310
from .patch import patch_converter_for_typecats # noqa
11+
from .strip_defaults import unstruc_strip_defaults # noqa
412

513
from .__about__ import __version__
614

typecats/cats_mypy_plugin.py

+15-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
Var,
88
Argument,
99
ARG_POS,
10+
ARG_NAMED_OPT,
1011
FuncDef,
1112
PassStmt,
1213
Block,
@@ -75,6 +76,7 @@ def add_struc_and_unstruc_to_classdefcontext(cls_def_ctx: ClassDefContext):
7576

7677
dict_type = cls_def_ctx.api.named_type("__builtins__.dict")
7778
str_type = cls_def_ctx.api.named_type("__builtins__.str")
79+
bool_type = cls_def_ctx.api.named_type("__builtins__.bool")
7880
api = cls_def_ctx.api
7981
implicit_any = AnyType(TypeOfAny.special_form)
8082
mapping = api.lookup_fully_qualified_or_none("typing.Mapping")
@@ -125,7 +127,19 @@ def add_struc_and_unstruc_to_classdefcontext(cls_def_ctx: ClassDefContext):
125127
maybe_cat_return_type,
126128
)
127129
if UNSTRUCTURE_NAME not in info.names:
128-
add_method(cls_def_ctx, UNSTRUCTURE_NAME, [], dict_type)
130+
add_method(
131+
cls_def_ctx,
132+
UNSTRUCTURE_NAME,
133+
[
134+
Argument(
135+
Var("strip_defaults", bool_type),
136+
bool_type,
137+
None,
138+
ARG_NAMED_OPT,
139+
)
140+
],
141+
dict_type,
142+
)
129143

130144
if fullname == CAT_PATH:
131145
return add_struc_and_unstruc_to_classdefcontext

typecats/strip_defaults.py

+96
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import typing as ty
2+
from functools import lru_cache
3+
from typing_extensions import Literal
4+
5+
import attr
6+
from cattr.converters import _is_attrs_class, Converter
7+
8+
from .patch import TypecatsCattrPatch
9+
10+
C = ty.TypeVar("C")
11+
12+
13+
_MISSING = object()
14+
15+
16+
@lru_cache(128)
17+
def _get_factory_default(_attr):
18+
return _attr.default.factory()
19+
20+
21+
def _get_attr_default_value(attribute) -> ty.Any:
22+
if not isinstance(attribute.default, attr.Factory): # type: ignore
23+
return attribute.default
24+
return _get_factory_default(attribute)
25+
26+
27+
def _strip_attr_defaults(
28+
attrs_type: ty.Type, m: ty.Mapping[str, ty.Any]
29+
) -> ty.Dict[str, ty.Any]:
30+
"""The idea here is that when you are using pure dicts, a key can be
31+
missing to indicate absence. But if you're dealing with typed
32+
objects, that's not possible since all keys are always present. So
33+
the only 'reasonable' way to determine what a 'union' means in a
34+
class-based world is to prefer non-default values to default values at
35+
all times, which attrs can tell us about.
36+
"""
37+
attr_defaults_by_name = {
38+
attribute.name: _get_attr_default_value(attribute)
39+
for attribute in attrs_type.__attrs_attrs__
40+
}
41+
return {
42+
k: v
43+
for k, v in m.items()
44+
if k not in attr_defaults_by_name or v != attr_defaults_by_name[k]
45+
}
46+
47+
48+
def _get_names_of_defaulted_attrs(
49+
unstructured: ty.Dict[str, ty.Any], attrs_obj: ty.Any
50+
) -> ty.Set[str]:
51+
"""We must provide the partially unstructured version of the object in
52+
case this is a wildcat with keys that aren't defined as part of
53+
the Attr type.
54+
"""
55+
res: ty.Set[str] = set()
56+
for _attr in attrs_obj.__attrs_attrs__:
57+
if getattr(_attr.type, "__origin__", None) is Literal:
58+
# don't strip attributes annotated as Literals - they're requirements, not "defaults"
59+
continue
60+
if unstructured.get(_attr.name, _MISSING) == _get_attr_default_value(_attr):
61+
res.add(_attr.name)
62+
return res
63+
64+
65+
def _strip_attrs_defaults(
66+
unstructured_but_unclean: ty.Any, obj_to_unstructure: ty.Any
67+
) -> ty.Any:
68+
if _is_attrs_class(obj_to_unstructure.__class__):
69+
keys_to_strip = _get_names_of_defaulted_attrs(
70+
unstructured_but_unclean, obj_to_unstructure
71+
)
72+
return {
73+
k: v for k, v in unstructured_but_unclean.items() if k not in keys_to_strip
74+
}
75+
return unstructured_but_unclean
76+
77+
78+
class StripAttrsDefaultsOnUnstructurePatch(TypecatsCattrPatch):
79+
def unstructure_patch(
80+
self, original_handler: ty.Callable, obj_to_unstructure: ty.Any
81+
) -> ty.Any:
82+
rv = super().unstructure_patch(original_handler, obj_to_unstructure)
83+
return _strip_attrs_defaults(rv, obj_to_unstructure)
84+
85+
86+
_STRIP_DEFAULTS_CONVERTER = Converter() # create converter
87+
__PATCH = StripAttrsDefaultsOnUnstructurePatch(_STRIP_DEFAULTS_CONVERTER) # patch it
88+
89+
90+
def get_stripping_converter() -> Converter:
91+
return _STRIP_DEFAULTS_CONVERTER
92+
93+
94+
def unstruc_strip_defaults(obj: ty.Any) -> ty.Any:
95+
"""This is the only thing anyone outside needs to worry about"""
96+
return _STRIP_DEFAULTS_CONVERTER.unstructure(obj)

typecats/tc.py

+12-4
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
)
1414
from .types import C, StrucInput, UnstrucOutput, CommonStructuringExceptions
1515
from .patch import patch_converter_for_typecats, log_structure_exception
16+
from .strip_defaults import get_stripping_converter
1617

1718

1819
class TypeCat:
@@ -55,9 +56,14 @@ def _struc(cl: ty.Type[C], obj: StrucInput) -> C:
5556
return _struc
5657

5758

58-
def make_unstruc(converter: cattr.Converter):
59-
def _unstruc(obj: ty.Any) -> ty.Any:
59+
def make_unstruc(
60+
converter: cattr.Converter,
61+
stripping_converter: cattr.Converter = get_stripping_converter(),
62+
):
63+
def _unstruc(obj: ty.Any, *, strip_defaults: bool = False) -> ty.Any:
6064
"""A wrapper for cattrs unstructure using the internal converter"""
65+
if strip_defaults:
66+
return stripping_converter.unstructure(obj)
6167
return converter.unstructure(obj)
6268

6369
return _unstruc
@@ -199,13 +205,15 @@ def try_struc_cat(d: ty.Optional[StrucInput]) -> ty.Optional[C]:
199205

200206

201207
def set_unstruc_converter(
202-
cls: ty.Type[C], converter: cattr.Converter = _TYPECATS_DEFAULT_CONVERTER
208+
cls: ty.Type[C],
209+
converter: cattr.Converter = _TYPECATS_DEFAULT_CONVERTER,
210+
strip_defaults_converter: cattr.Converter = get_stripping_converter(),
203211
):
204212
"""If you want to change your mind about the built-in Converter that
205213
is meant to run when you call the object method YourCatObj.unstruc(), you
206214
can reset it here. By default, it is defined by the converter
207215
keyword argument on the Cat decorator.
208216
209217
"""
210-
_unstruc = make_unstruc(converter)
218+
_unstruc = make_unstruc(converter, strip_defaults_converter)
211219
setattr(cls, UNSTRUCTURE_NAME, _unstruc)

0 commit comments

Comments
 (0)