Skip to content

Commit 88dffff

Browse files
committed
Make Python versions known statically
Type checkers are unable to interpret `sys.version_info` aliases and generate unions between Python version branches. If either branch depends on packages which are not installed the union might be reduced to an unknown or any type.
1 parent b25a258 commit 88dffff

12 files changed

+174
-178
lines changed

src/cattrs/_compat.py

+144-163
Original file line numberDiff line numberDiff line change
@@ -30,25 +30,10 @@
3030
except ImportError:
3131
ExtensionsTypedDictMeta = None
3232

33-
__all__ = [
34-
"ExtensionsTypedDict",
35-
"is_py37",
36-
"is_py38",
37-
"is_py39_plus",
38-
"is_py310_plus",
39-
"is_py311_plus",
40-
"is_typeddict",
41-
"TypedDict",
42-
]
43-
44-
version_info = sys.version_info[0:3]
45-
is_py37 = version_info[:2] == (3, 7)
46-
is_py38 = version_info[:2] == (3, 8)
47-
is_py39_plus = version_info[:2] >= (3, 9)
48-
is_py310_plus = version_info[:2] >= (3, 10)
49-
is_py311_plus = version_info[:2] >= (3, 11)
50-
51-
if is_py37:
33+
if sys.version_info >= (3, 8):
34+
from typing import Final, Protocol, get_args, get_origin
35+
36+
else:
5237

5338
def get_args(cl):
5439
return cl.__args__
@@ -58,10 +43,7 @@ def get_origin(cl):
5843

5944
from typing_extensions import Final, Protocol
6045

61-
else:
62-
from typing import Final, Protocol, get_args, get_origin
63-
64-
if is_py311_plus:
46+
if sys.version_info >= (3, 11):
6547
ExceptionGroup = ExceptionGroup
6648
else:
6749
from exceptiongroup import ExceptionGroup as ExceptionGroup # noqa: PLC0414
@@ -157,144 +139,7 @@ def get_final_base(type) -> Optional[type]:
157139
OriginAbstractSet = AbcSet
158140
OriginMutableSet = AbcMutableSet
159141

160-
if is_py37 or is_py38:
161-
Set = TypingSet
162-
AbstractSet = TypingAbstractSet
163-
MutableSet = TypingMutableSet
164-
165-
Sequence = TypingSequence
166-
MutableSequence = TypingMutableSequence
167-
MutableMapping = TypingMutableMapping
168-
Mapping = TypingMapping
169-
FrozenSetSubscriptable = FrozenSet
170-
TupleSubscriptable = Tuple
171-
172-
from collections import Counter as ColCounter
173-
from typing import Counter, Union, _GenericAlias
174-
175-
from typing_extensions import Annotated, NotRequired, Required
176-
from typing_extensions import get_origin as te_get_origin
177-
178-
if is_py38:
179-
from typing import TypedDict, _TypedDictMeta
180-
else:
181-
_TypedDictMeta = None
182-
TypedDict = ExtensionsTypedDict
183-
184-
def is_annotated(type) -> bool:
185-
return te_get_origin(type) is Annotated
186-
187-
def is_tuple(type):
188-
return type in (Tuple, tuple) or (
189-
type.__class__ is _GenericAlias and issubclass(type.__origin__, Tuple)
190-
)
191-
192-
def is_union_type(obj):
193-
return (
194-
obj is Union or isinstance(obj, _GenericAlias) and obj.__origin__ is Union
195-
)
196-
197-
def get_newtype_base(typ: Any) -> Optional[type]:
198-
supertype = getattr(typ, "__supertype__", None)
199-
if (
200-
supertype is not None
201-
and getattr(typ, "__qualname__", "") == "NewType.<locals>.new_type"
202-
and typ.__module__ in ("typing", "typing_extensions")
203-
):
204-
return supertype
205-
return None
206-
207-
def is_sequence(type: Any) -> bool:
208-
return type in (List, list, Tuple, tuple) or (
209-
type.__class__ is _GenericAlias
210-
and (
211-
type.__origin__ not in (Union, Tuple, tuple)
212-
and issubclass(type.__origin__, TypingSequence)
213-
)
214-
or (type.__origin__ in (Tuple, tuple) and type.__args__[1] is ...)
215-
)
216-
217-
def is_deque(type: Any) -> bool:
218-
return (
219-
type in (deque, Deque)
220-
or (type.__class__ is _GenericAlias and issubclass(type.__origin__, deque))
221-
or type.__origin__ is deque
222-
)
223-
224-
def is_mutable_set(type):
225-
return type is set or (
226-
type.__class__ is _GenericAlias and issubclass(type.__origin__, MutableSet)
227-
)
228-
229-
def is_frozenset(type):
230-
return type is frozenset or (
231-
type.__class__ is _GenericAlias and issubclass(type.__origin__, FrozenSet)
232-
)
233-
234-
def is_mapping(type):
235-
return type in (TypingMapping, dict) or (
236-
type.__class__ is _GenericAlias
237-
and issubclass(type.__origin__, TypingMapping)
238-
)
239-
240-
bare_generic_args = {
241-
List.__args__,
242-
TypingSequence.__args__,
243-
TypingMapping.__args__,
244-
Dict.__args__,
245-
TypingMutableSequence.__args__,
246-
Tuple.__args__,
247-
None, # non-parametrized containers do not have `__args__ attribute in py3.7-8
248-
}
249-
250-
def is_bare(type):
251-
return getattr(type, "__args__", None) in bare_generic_args
252-
253-
def is_counter(type):
254-
return (
255-
type in (Counter, ColCounter)
256-
or getattr(type, "__origin__", None) is ColCounter
257-
)
258-
259-
if is_py38:
260-
from typing import Literal
261-
262-
def is_literal(type) -> bool:
263-
return type.__class__ is _GenericAlias and type.__origin__ is Literal
264-
265-
else:
266-
# No literals in 3.7.
267-
def is_literal(_) -> bool:
268-
return False
269-
270-
def is_generic(obj):
271-
return isinstance(obj, _GenericAlias)
272-
273-
def copy_with(type, args):
274-
"""Replace a generic type's arguments."""
275-
return type.copy_with(args)
276-
277-
def is_typeddict(cls) -> bool:
278-
return (
279-
cls.__class__ is _TypedDictMeta
280-
or (is_generic(cls) and (cls.__origin__.__class__ is _TypedDictMeta))
281-
or (
282-
ExtensionsTypedDictMeta is not None
283-
and cls.__class__ is ExtensionsTypedDictMeta
284-
or (
285-
is_generic(cls)
286-
and (cls.__origin__.__class__ is ExtensionsTypedDictMeta)
287-
)
288-
)
289-
)
290-
291-
def get_notrequired_base(type) -> "Union[Any, Literal[NOTHING]]":
292-
if get_origin(type) in (NotRequired, Required):
293-
return get_args(type)[0]
294-
return NOTHING
295-
296-
else:
297-
# 3.9+
142+
if sys.version_info >= (3, 9):
298143
from collections import Counter
299144
from collections.abc import Mapping as AbcMapping
300145
from collections.abc import MutableMapping as AbcMutableMapping
@@ -348,7 +193,7 @@ def is_tuple(type):
348193
or (getattr(type, "__origin__", None) is tuple)
349194
)
350195

351-
if is_py310_plus:
196+
if sys.version_info >= (3, 10):
352197

353198
def is_union_type(obj):
354199
from types import UnionType
@@ -364,7 +209,7 @@ def get_newtype_base(typ: Any) -> Optional[type]:
364209
return typ.__supertype__
365210
return None
366211

367-
if is_py311_plus:
212+
if sys.version_info >= (3, 11):
368213
from typing import NotRequired, Required
369214
else:
370215
from typing_extensions import NotRequired, Required
@@ -500,6 +345,142 @@ def copy_with(type, args):
500345
return Annotated[args]
501346
return type.__origin__[args]
502347

348+
else:
349+
Set = TypingSet
350+
AbstractSet = TypingAbstractSet
351+
MutableSet = TypingMutableSet
352+
353+
Sequence = TypingSequence
354+
MutableSequence = TypingMutableSequence
355+
MutableMapping = TypingMutableMapping
356+
Mapping = TypingMapping
357+
FrozenSetSubscriptable = FrozenSet
358+
TupleSubscriptable = Tuple
359+
360+
from collections import Counter as ColCounter
361+
from typing import Counter, Union, _GenericAlias
362+
363+
from typing_extensions import Annotated, NotRequired, Required
364+
from typing_extensions import get_origin as te_get_origin
365+
366+
if sys.version_info >= (3, 8):
367+
from typing import TypedDict, _TypedDictMeta
368+
else:
369+
_TypedDictMeta = None
370+
TypedDict = ExtensionsTypedDict
371+
372+
def is_annotated(type) -> bool:
373+
return te_get_origin(type) is Annotated
374+
375+
def is_tuple(type):
376+
return type in (Tuple, tuple) or (
377+
type.__class__ is _GenericAlias and issubclass(type.__origin__, Tuple)
378+
)
379+
380+
def is_union_type(obj):
381+
return (
382+
obj is Union or isinstance(obj, _GenericAlias) and obj.__origin__ is Union
383+
)
384+
385+
def get_newtype_base(typ: Any) -> Optional[type]:
386+
supertype = getattr(typ, "__supertype__", None)
387+
if (
388+
supertype is not None
389+
and getattr(typ, "__qualname__", "") == "NewType.<locals>.new_type"
390+
and typ.__module__ in ("typing", "typing_extensions")
391+
):
392+
return supertype
393+
return None
394+
395+
def is_sequence(type: Any) -> bool:
396+
return type in (List, list, Tuple, tuple) or (
397+
type.__class__ is _GenericAlias
398+
and (
399+
type.__origin__ not in (Union, Tuple, tuple)
400+
and issubclass(type.__origin__, TypingSequence)
401+
)
402+
or (type.__origin__ in (Tuple, tuple) and type.__args__[1] is ...)
403+
)
404+
405+
def is_deque(type: Any) -> bool:
406+
return (
407+
type in (deque, Deque)
408+
or (type.__class__ is _GenericAlias and issubclass(type.__origin__, deque))
409+
or type.__origin__ is deque
410+
)
411+
412+
def is_mutable_set(type):
413+
return type is set or (
414+
type.__class__ is _GenericAlias and issubclass(type.__origin__, MutableSet)
415+
)
416+
417+
def is_frozenset(type):
418+
return type is frozenset or (
419+
type.__class__ is _GenericAlias and issubclass(type.__origin__, FrozenSet)
420+
)
421+
422+
def is_mapping(type):
423+
return type in (TypingMapping, dict) or (
424+
type.__class__ is _GenericAlias
425+
and issubclass(type.__origin__, TypingMapping)
426+
)
427+
428+
bare_generic_args = {
429+
List.__args__,
430+
TypingSequence.__args__,
431+
TypingMapping.__args__,
432+
Dict.__args__,
433+
TypingMutableSequence.__args__,
434+
Tuple.__args__,
435+
None, # non-parametrized containers do not have `__args__ attribute in py3.7-8
436+
}
437+
438+
def is_bare(type):
439+
return getattr(type, "__args__", None) in bare_generic_args
440+
441+
def is_counter(type):
442+
return (
443+
type in (Counter, ColCounter)
444+
or getattr(type, "__origin__", None) is ColCounter
445+
)
446+
447+
if sys.version_info >= (3, 8):
448+
from typing import Literal
449+
450+
def is_literal(type) -> bool:
451+
return type.__class__ is _GenericAlias and type.__origin__ is Literal
452+
453+
else:
454+
# No literals in 3.7.
455+
def is_literal(_) -> bool:
456+
return False
457+
458+
def is_generic(obj):
459+
return isinstance(obj, _GenericAlias)
460+
461+
def copy_with(type, args):
462+
"""Replace a generic type's arguments."""
463+
return type.copy_with(args)
464+
465+
def is_typeddict(cls) -> bool:
466+
return (
467+
cls.__class__ is _TypedDictMeta
468+
or (is_generic(cls) and (cls.__origin__.__class__ is _TypedDictMeta))
469+
or (
470+
ExtensionsTypedDictMeta is not None
471+
and cls.__class__ is ExtensionsTypedDictMeta
472+
or (
473+
is_generic(cls)
474+
and (cls.__origin__.__class__ is ExtensionsTypedDictMeta)
475+
)
476+
)
477+
)
478+
479+
def get_notrequired_base(type) -> "Union[Any, Literal[NOTHING]]":
480+
if get_origin(type) in (NotRequired, Required):
481+
return get_args(type)[0]
482+
return NOTHING
483+
503484

504485
def is_generic_attrs(type):
505486
return is_generic(type) and has(type.__origin__)

src/cattrs/gen/typeddicts.py

+3-4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import linecache
44
import re
5+
import sys
56
from typing import TYPE_CHECKING, Any, Callable, TypeVar
67

78
from attr import NOTHING, Attribute
@@ -34,8 +35,6 @@ def get_annots(cl):
3435
is_annotated,
3536
is_bare,
3637
is_generic,
37-
is_py39_plus,
38-
is_py311_plus,
3938
)
4039
from .._generics import deep_copy_with
4140
from ..errors import (
@@ -533,12 +532,12 @@ def _is_extensions_typeddict(cls) -> bool:
533532
)
534533

535534

536-
if is_py311_plus:
535+
if sys.version_info >= (3, 11):
537536

538537
def _required_keys(cls: type) -> set[str]:
539538
return cls.__required_keys__
540539

541-
elif is_py39_plus:
540+
elif sys.version_info >= (3, 9):
542541
from typing_extensions import Annotated, NotRequired, Required, get_args
543542

544543
def _required_keys(cls: type) -> set[str]:

tests/_compat.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
from cattrs._compat import is_py37, is_py38
1+
import sys
2+
3+
is_py37 = sys.version_info[:2] == (3, 7)
4+
is_py38 = sys.version_info[:2] == (3, 8)
5+
is_py39_plus = sys.version_info >= (3, 9)
6+
is_py310_plus = sys.version_info >= (3, 10)
7+
is_py311_plus = sys.version_info >= (3, 11)
28

39
if is_py37 or is_py38:
410
from typing import Dict, List

0 commit comments

Comments
 (0)