Skip to content

Commit 4a1d4c4

Browse files
authored
Fix non-generic protocols (#436)
* Fix non-generic protocols * Fix generics on 3.8
1 parent 23b0fef commit 4a1d4c4

File tree

8 files changed

+64
-41
lines changed

8 files changed

+64
-41
lines changed

HISTORY.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@
5050
- _cattrs_ is now linted with [Ruff](https://beta.ruff.rs/docs/).
5151
- Remove some unused lines in the unstructuring code.
5252
([#416](https://github.com/python-attrs/cattrs/pull/416))
53+
- Fix handling classes inheriting from non-generic protocols.
54+
([#374](https://github.com/python-attrs/cattrs/issues/374))
5355

5456
## 23.1.2 (2023-06-02)
5557

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ clean-test: ## remove test and coverage artifacts
4949

5050
lint: ## check style with ruff and black
5151
pdm run ruff src/ tests
52+
pdm run isort -c src/ tests
5253
pdm run black --check src tests docs/conf.py
5354

5455
test: ## run tests quickly with the default Python

src/cattrs/_compat.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -316,8 +316,11 @@ def is_counter(type):
316316
)
317317

318318
def is_generic(obj) -> bool:
319-
return isinstance(obj, (_GenericAlias, GenericAlias)) or is_subclass(
320-
obj, Generic
319+
"""Whether obj is a generic type."""
320+
# Inheriting from protocol will inject `Generic` into the MRO
321+
# without `__orig_bases__`.
322+
return isinstance(obj, (_GenericAlias, GenericAlias)) or (
323+
is_subclass(obj, Generic) and hasattr(obj, "__orig_bases__")
321324
)
322325

323326
def copy_with(type, args):
@@ -343,7 +346,7 @@ def get_full_type_hints(obj, globalns=None, localns=None):
343346
TupleSubscriptable = Tuple
344347

345348
from collections import Counter as ColCounter
346-
from typing import Counter, TypedDict, Union, _GenericAlias
349+
from typing import Counter, Generic, TypedDict, Union, _GenericAlias
347350

348351
from typing_extensions import Annotated, NotRequired, Required
349352
from typing_extensions import get_origin as te_get_origin
@@ -429,7 +432,9 @@ def is_literal(type) -> bool:
429432
return type.__class__ is _GenericAlias and type.__origin__ is Literal
430433

431434
def is_generic(obj):
432-
return isinstance(obj, _GenericAlias)
435+
return isinstance(obj, _GenericAlias) or (
436+
is_subclass(obj, Generic) and hasattr(obj, "__orig_bases__")
437+
)
433438

434439
def copy_with(type, args):
435440
"""Replace a generic type's arguments."""

src/cattrs/preconf/orjson.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Preconfigured converters for orjson."""
22
from base64 import b85decode, b85encode
3-
from datetime import datetime, date
3+
from datetime import date, datetime
44
from enum import Enum
55
from typing import Any, Type, TypeVar, Union
66

tests/test_baseconverter.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
"""Test both structuring and unstructuring."""
22
from typing import Optional, Union
33

4-
import attr
54
import pytest
6-
from attr import define, fields, make_class
5+
from attrs import define, fields, make_class
76
from hypothesis import HealthCheck, assume, given, settings
87
from hypothesis.strategies import just, one_of
98

@@ -90,9 +89,9 @@ def test_union_field_roundtrip(cl_and_vals_a, cl_and_vals_b, strat):
9089
common_names = a_field_names & b_field_names
9190
assume(len(a_field_names) > len(common_names))
9291

93-
@attr.s
92+
@define
9493
class C:
95-
a = attr.ib(type=Union[cl_a, cl_b])
94+
a: Union[cl_a, cl_b]
9695

9796
inst = C(a=cl_a(*vals_a, **kwargs_a))
9897

@@ -161,9 +160,9 @@ def test_optional_field_roundtrip(cl_and_vals):
161160
converter = BaseConverter()
162161
cl, vals, kwargs = cl_and_vals
163162

164-
@attr.s
163+
@define
165164
class C:
166-
a = attr.ib(type=Optional[cl])
165+
a: Optional[cl]
167166

168167
inst = C(a=cl(*vals, **kwargs))
169168
assert inst == converter.structure(converter.unstructure(inst), C)

tests/test_gen.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import linecache
33
from traceback import format_exc
44

5-
from attr import define
5+
from attrs import define
66

77
from cattrs import Converter
88
from cattrs.gen import make_dict_structure_fn, make_dict_unstructure_fn

tests/test_generics.py

Lines changed: 44 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from typing import Deque, Dict, Generic, List, Optional, TypeVar, Union
33

44
import pytest
5-
from attr import asdict, attrs, define
5+
from attrs import asdict, define
66

77
from cattrs import BaseConverter, Converter
88
from cattrs._compat import Protocol
@@ -132,7 +132,7 @@ def test_able_to_structure_deeply_nested_generics_gen(converter):
132132

133133

134134
def test_structure_unions_of_generics(converter):
135-
@attrs(auto_attribs=True)
135+
@define
136136
class TClass2(Generic[T]):
137137
c: T
138138

@@ -142,7 +142,7 @@ class TClass2(Generic[T]):
142142

143143

144144
def test_structure_list_of_generic_unions(converter):
145-
@attrs(auto_attribs=True)
145+
@define
146146
class TClass2(Generic[T]):
147147
c: T
148148

@@ -154,7 +154,7 @@ class TClass2(Generic[T]):
154154

155155

156156
def test_structure_deque_of_generic_unions(converter):
157-
@attrs(auto_attribs=True)
157+
@define
158158
class TClass2(Generic[T]):
159159
c: T
160160

@@ -179,35 +179,31 @@ def test_raises_if_no_generic_params_supplied(
179179
assert exc.value.type_ is T
180180

181181

182-
def test_unstructure_generic_attrs():
183-
c = Converter()
184-
185-
@attrs(auto_attribs=True)
182+
def test_unstructure_generic_attrs(genconverter):
183+
@define
186184
class Inner(Generic[T]):
187185
a: T
188186

189-
@attrs(auto_attribs=True)
187+
@define
190188
class Outer:
191189
inner: Inner[int]
192190

193191
initial = Outer(Inner(1))
194-
raw = c.unstructure(initial)
192+
raw = genconverter.unstructure(initial)
195193

196194
assert raw == {"inner": {"a": 1}}
197195

198-
new = c.structure(raw, Outer)
196+
new = genconverter.structure(raw, Outer)
199197
assert initial == new
200198

201-
@attrs(auto_attribs=True)
199+
@define
202200
class OuterStr:
203201
inner: Inner[str]
204202

205-
assert c.structure(raw, OuterStr) == OuterStr(Inner("1"))
206-
203+
assert genconverter.structure(raw, OuterStr) == OuterStr(Inner("1"))
207204

208-
def test_unstructure_deeply_nested_generics():
209-
c = Converter()
210205

206+
def test_unstructure_deeply_nested_generics(genconverter):
211207
@define
212208
class Inner:
213209
a: int
@@ -217,16 +213,14 @@ class Outer(Generic[T]):
217213
inner: T
218214

219215
initial = Outer[Inner](Inner(1))
220-
raw = c.unstructure(initial, Outer[Inner])
216+
raw = genconverter.unstructure(initial, Outer[Inner])
221217
assert raw == {"inner": {"a": 1}}
222218

223-
raw = c.unstructure(initial)
219+
raw = genconverter.unstructure(initial)
224220
assert raw == {"inner": {"a": 1}}
225221

226222

227-
def test_unstructure_deeply_nested_generics_list():
228-
c = Converter()
229-
223+
def test_unstructure_deeply_nested_generics_list(genconverter):
230224
@define
231225
class Inner:
232226
a: int
@@ -236,16 +230,14 @@ class Outer(Generic[T]):
236230
inner: List[T]
237231

238232
initial = Outer[Inner]([Inner(1)])
239-
raw = c.unstructure(initial, Outer[Inner])
233+
raw = genconverter.unstructure(initial, Outer[Inner])
240234
assert raw == {"inner": [{"a": 1}]}
241235

242-
raw = c.unstructure(initial)
236+
raw = genconverter.unstructure(initial)
243237
assert raw == {"inner": [{"a": 1}]}
244238

245239

246-
def test_unstructure_protocol():
247-
c = Converter()
248-
240+
def test_unstructure_protocol(genconverter):
249241
class Proto(Protocol):
250242
a: int
251243

@@ -258,10 +250,10 @@ class Outer:
258250
inner: Proto
259251

260252
initial = Outer(Inner(1))
261-
raw = c.unstructure(initial, Outer)
253+
raw = genconverter.unstructure(initial, Outer)
262254
assert raw == {"inner": {"a": 1}}
263255

264-
raw = c.unstructure(initial)
256+
raw = genconverter.unstructure(initial)
265257
assert raw == {"inner": {"a": 1}}
266258

267259

@@ -306,3 +298,27 @@ class B(A[int]):
306298
pass
307299

308300
assert generate_mapping(B, {}) == {T.__name__: int}
301+
302+
303+
def test_nongeneric_protocols(converter):
304+
"""Non-generic protocols work."""
305+
306+
class NongenericProtocol(Protocol):
307+
...
308+
309+
@define
310+
class Entity(NongenericProtocol):
311+
...
312+
313+
assert generate_mapping(Entity) == {}
314+
315+
class GenericProtocol(Protocol[T]):
316+
...
317+
318+
@define
319+
class GenericEntity(GenericProtocol[int]):
320+
a: int
321+
322+
assert generate_mapping(GenericEntity) == {"T": int}
323+
324+
assert converter.structure({"a": 1}, GenericEntity) == GenericEntity(1)

tests/test_validation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"""Tests for the extended validation mode."""
2+
import pickle
23
from typing import Dict, FrozenSet, List, Set, Tuple
34

45
import pytest
5-
import pickle
66
from attrs import define, field
77
from attrs.validators import in_
88
from hypothesis import given

0 commit comments

Comments
 (0)