From 607b7aa25554b8aa68c04acfa91151a8b192e001 Mon Sep 17 00:00:00 2001 From: MatthieuDartiailh Date: Tue, 7 Jan 2025 19:29:15 +0100 Subject: [PATCH 1/4] =?UTF-8?q?=EF=BB=BFallow=20the=20use=20of=20Literal?= =?UTF-8?q?=20in=20type=20annotation=20and=20use=20Enum=20in=20those=20cas?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- atom/meta/annotation_utils.py | 14 ++++-- atom/typing_utils.py | 31 ++++-------- docs/source/basis/typing.rst | 11 ++-- tests/test_atom_from_annotations.py | 78 ++++++++++++++--------------- tests/test_typing_utils.py | 8 +++ 5 files changed, 74 insertions(+), 68 deletions(-) diff --git a/atom/meta/annotation_utils.py b/atom/meta/annotation_utils.py index 8958e589..4f20c404 100644 --- a/atom/meta/annotation_utils.py +++ b/atom/meta/annotation_utils.py @@ -7,10 +7,11 @@ # -------------------------------------------------------------------------------------- import collections.abc from collections import defaultdict -from typing import Any, ClassVar, MutableMapping, Type +from typing import Any, ClassVar, Literal, MutableMapping, Type from ..catom import Member from ..dict import DefaultDict, Dict as ADict +from ..enum import Enum from ..instance import Instance from ..list import List as AList from ..scalars import Bool, Bytes, Callable as ACallable, Float, Int, Str, Value @@ -18,7 +19,7 @@ from ..subclass import Subclass from ..tuple import FixedTuple, Tuple as ATuple from ..typed import Typed -from ..typing_utils import extract_types, get_args, is_optional +from ..typing_utils import extract_types, get_args, get_origin, is_optional from .member_modifiers import set_default _NO_DEFAULT = object() @@ -42,7 +43,11 @@ def generate_member_from_type_or_generic( type_generic: Any, default: Any, annotate_type_containers: int ) -> Member: """Generate a member from a type or generic alias.""" - types = extract_types(type_generic) + # Here we special case Literal to generate an Enum member. + if get_origin(type_generic) is Literal: + types = () + else: + types = extract_types(type_generic) parameters = get_args(type_generic) m_kwargs = {} @@ -58,6 +63,9 @@ def generate_member_from_type_or_generic( elif object in types or Any in types: m_cls = Value parameters = () + # We are dealing with a Literal, so use an Enum member + elif not types: + m_cls = Enum # Int, Float, Str, Bytes, List, Dict, Set, Tuple, Bool, Callable elif len(types) == 1 and types[0] in _TYPE_TO_MEMBER: t = types[0] diff --git a/atom/typing_utils.py b/atom/typing_utils.py index 53274a7f..a7c37dc2 100644 --- a/atom/typing_utils.py +++ b/atom/typing_utils.py @@ -5,8 +5,8 @@ # # The full license is in the file LICENSE, distributed with this software. # -------------------------------------------------------------------------------------- -import sys from itertools import chain +from types import GenericAlias, UnionType from typing import ( TYPE_CHECKING, Any, @@ -22,25 +22,9 @@ get_origin, ) -# In Python 3.9+, List is a _SpecialGenericAlias and does not inherit from -# _GenericAlias which is the type of List[int] for example -if sys.version_info >= (3, 9): - from types import GenericAlias - - GENERICS = (type(List), type(List[int]), GenericAlias) -else: - GENERICS = (type(List), type(List[int])) - -if sys.version_info >= (3, 10): - from types import UnionType +GENERICS = (type(List), type(List[int]), GenericAlias) - UNION = (UnionType,) -else: - UNION = () - -# Type checker consider that typing.List and typing.List[int] are types even though -# there are not at runtime. -from types import GenericAlias, UnionType +UNION = (UnionType,) TypeLike = Union[type, TypeVar, UnionType, GenericAlias, NewType] @@ -134,7 +118,14 @@ def _extract_types(kind: TypeLike) -> Tuple[type, ...]: elif t is Any: extracted.append(object) else: - assert isinstance(t, type) + if not isinstance(t, type): + raise TypeError( + f"Failed to extract types from {kind}. " + f"The extraction yielded {t} which is not a type. " + "One case in which this can occur is when using unions of " + "Literal, and the issues can be worked around by using a " + "single literal containing all the values." + ) extracted.append(t) return tuple(extracted) diff --git a/docs/source/basis/typing.rst b/docs/source/basis/typing.rst index f2f6355d..cad1116d 100644 --- a/docs/source/basis/typing.rst +++ b/docs/source/basis/typing.rst @@ -68,13 +68,9 @@ a runtime ``TypeError`` exception. class MyAtom(Atom): s = Str("Hello") lst = List(Int(), default=[1, 2, 3]) - num = Instance(float) + num = Typed(float) n = Int() - One can note that when inferring members from annotations, |Instance| will - always be preferred over |Typed| since the object to check may define a - custom instance check. - .. note:: By default, atom will generate runtime checks for the content of list, dict @@ -100,4 +96,9 @@ a runtime ``TypeError`` exception. s = Str("Hello") lst = List(default=[1, 2, 3]) +.. versionadded:: 0.12.0 + ``Literal`` are now supported and represented using the |Enum| member. + However ``Literal`` cannot appear in union since it is not a "real" type. If + your union is only composed of ``Literal``, you can use a single ``Literal`` + in an equivalent manner. diff --git a/tests/test_atom_from_annotations.py b/tests/test_atom_from_annotations.py index 81d041bd..6ab40336 100644 --- a/tests/test_atom_from_annotations.py +++ b/tests/test_atom_from_annotations.py @@ -18,12 +18,14 @@ Dict as TDict, Iterable, List as TList, + Literal, Optional, Set as TSet, Tuple as TTuple, Type, TypeVar, Union, + get_args, ) import pytest @@ -35,6 +37,7 @@ Callable, DefaultDict, Dict, + Enum, FixedTuple, Float, Instance, @@ -155,6 +158,7 @@ class B(A, use_annotations=True): (Iterable, Instance), (Type[int], Subclass), (Type[TypeVar("T")], Subclass), + (Literal[1, 2, "a"], Enum), ], ) def test_annotation_use(annotation, member): @@ -167,10 +171,12 @@ class A(Atom, use_annotations=True): elif member is Instance: assert A.a.validate_mode[1] == (annotation.__origin__,) elif member is Subclass: - if isinstance(annotation.__args__[0], TypeVar): + if isinstance(a_args := get_args(annotation)[0], TypeVar): assert A.a.validate_mode[1] is object else: - assert A.a.validate_mode[1] == annotation.__args__[0] + assert A.a.validate_mode[1] == a_args + elif member is Enum: + assert A.a.validate_mode[1] == get_args(annotation) else: assert A.a.default_value_mode == member().default_value_mode @@ -180,6 +186,7 @@ class A(Atom, use_annotations=True): [ (Atom, Typed, Atom), (Union[int, str], Instance, (int, str)), + (int | str, Instance, (int, str)), ], ) def test_union_in_annotation(annotation, member, validate_mode): @@ -195,6 +202,7 @@ class A(Atom, use_annotations=True): [ (TList[int], List(), 0), (TList[int], List(Int()), 1), + (TList[Literal[1, 2, 3]], List(Enum(1, 2, 3)), 1), (TList[TList[int]], List(List()), 1), (TList[TList[int]], List(List(Int())), -1), (TSet[int], Set(), 0), @@ -214,32 +222,26 @@ class A(Atom, use_annotations=True): FixedTuple(FixedTuple(Int(), Int()), Int()), -1, ), - ] - + ( - [ - (list[int], List(), 0), - (list[int], List(Int()), 1), - (list[list[int]], List(List()), 1), - (list[list[int]], List(List(Int())), -1), - (set[int], Set(), 0), - (set[int], Set(Int()), 1), - (dict[int, int], Dict(), 0), - (dict[int, int], Dict(Int(), Int()), 1), - (defaultdict[int, int], DefaultDict(Int(), Int()), 1), - (tuple[int], Tuple(), 0), - (tuple[int], FixedTuple(Int()), 1), - (tuple[int, ...], Tuple(Int()), 1), - (tuple[int, float], FixedTuple(Int(), Float()), 1), - (tuple[tuple, int], FixedTuple(Tuple(), Int()), 1), - ( - tuple[tuple[int, int], int], - FixedTuple(FixedTuple(Int(), Int()), Int()), - -1, - ), - ] - if sys.version_info >= (3, 9) - else [] - ), + (list[int], List(), 0), + (list[int], List(Int()), 1), + (list[list[int]], List(List()), 1), + (list[list[int]], List(List(Int())), -1), + (set[int], Set(), 0), + (set[int], Set(Int()), 1), + (dict[int, int], Dict(), 0), + (dict[int, int], Dict(Int(), Int()), 1), + (defaultdict[int, int], DefaultDict(Int(), Int()), 1), + (tuple[int], Tuple(), 0), + (tuple[int], FixedTuple(Int()), 1), + (tuple[int, ...], Tuple(Int()), 1), + (tuple[int, float], FixedTuple(Int(), Float()), 1), + (tuple[tuple, int], FixedTuple(Tuple(), Int()), 1), + ( + tuple[tuple[int, int], int], + FixedTuple(FixedTuple(Int(), Int()), Int()), + -1, + ), + ], ) def test_annotated_containers_no_default(annotation, member, depth): class A(Atom, use_annotations=True, type_containers=depth): @@ -295,18 +297,14 @@ class A(Atom, use_annotations=True, type_containers=depth): (TDefaultDict, DefaultDict, defaultdict(int, {1: 2})), (Optional[Iterable], Instance, None), (Type[int], Subclass, int), - ] - + ( - [ - (tuple[int], FixedTuple, (1,)), - (list, List, [1]), - (set, Set, {1}), - (dict, Dict, {1: 2}), - (defaultdict, DefaultDict, defaultdict(int, {1: 2})), - ] - if sys.version_info >= (3, 9) - else [] - ), + (tuple[int], FixedTuple, (1,)), + (list, List, [1]), + (set, Set, {1}), + (dict, Dict, {1: 2}), + (defaultdict, DefaultDict, defaultdict(int, {1: 2})), + (Literal[1, 2, "a"], Enum, 2), + + ], ) def test_annotations_with_default(annotation, member, default): class A(Atom, use_annotations=True): diff --git a/tests/test_typing_utils.py b/tests/test_typing_utils.py index b12db65e..0db90b67 100644 --- a/tests/test_typing_utils.py +++ b/tests/test_typing_utils.py @@ -8,7 +8,11 @@ """Test typing utilities.""" from collections.abc import Iterable +<<<<<<< HEAD from typing import Dict, List, NewType, Optional, Set, Tuple, TypeVar, Union +======= +from typing import Dict, List, Literal, Optional, Set, Tuple, TypeVar, Union +>>>>>>> 38528ca (allow the use of Literal in type annotation and use Enum in those cases) import pytest @@ -74,3 +78,7 @@ def test_is_optional(ty, outputs): def test_reject_str_annotations(): with pytest.raises(TypeError): extract_types("int") + +def test_reject_literal(): + with pytest.raises(TypeError): + extract_types(Literal[1]) From 6979dedca0a00b6b29b2a587d11fb28cd934d6f0 Mon Sep 17 00:00:00 2001 From: MatthieuDartiailh Date: Tue, 7 Jan 2025 19:35:46 +0100 Subject: [PATCH 2/4] fix linting issues --- atom/meta/annotation_utils.py | 1 + tests/test_atom_from_annotations.py | 1 - tests/test_typing_utils.py | 1 + 3 files changed, 2 insertions(+), 1 deletion(-) diff --git a/atom/meta/annotation_utils.py b/atom/meta/annotation_utils.py index 4f20c404..969cb9e4 100644 --- a/atom/meta/annotation_utils.py +++ b/atom/meta/annotation_utils.py @@ -44,6 +44,7 @@ def generate_member_from_type_or_generic( ) -> Member: """Generate a member from a type or generic alias.""" # Here we special case Literal to generate an Enum member. + types: tuple[type, ...] if get_origin(type_generic) is Literal: types = () else: diff --git a/tests/test_atom_from_annotations.py b/tests/test_atom_from_annotations.py index 6ab40336..4e3aafa6 100644 --- a/tests/test_atom_from_annotations.py +++ b/tests/test_atom_from_annotations.py @@ -303,7 +303,6 @@ class A(Atom, use_annotations=True, type_containers=depth): (dict, Dict, {1: 2}), (defaultdict, DefaultDict, defaultdict(int, {1: 2})), (Literal[1, 2, "a"], Enum, 2), - ], ) def test_annotations_with_default(annotation, member, default): diff --git a/tests/test_typing_utils.py b/tests/test_typing_utils.py index 0db90b67..dfc81d3b 100644 --- a/tests/test_typing_utils.py +++ b/tests/test_typing_utils.py @@ -79,6 +79,7 @@ def test_reject_str_annotations(): with pytest.raises(TypeError): extract_types("int") + def test_reject_literal(): with pytest.raises(TypeError): extract_types(Literal[1]) From 2098dd72847d3e149645ff33fce5885742f057e7 Mon Sep 17 00:00:00 2001 From: MatthieuDartiailh Date: Tue, 7 Jan 2025 22:39:55 +0100 Subject: [PATCH 3/4] fix handling of Enum default value --- atom/meta/annotation_utils.py | 8 ++++++++ tests/test_atom_from_annotations.py | 2 ++ 2 files changed, 10 insertions(+) diff --git a/atom/meta/annotation_utils.py b/atom/meta/annotation_utils.py index 969cb9e4..c9a22c67 100644 --- a/atom/meta/annotation_utils.py +++ b/atom/meta/annotation_utils.py @@ -67,6 +67,14 @@ def generate_member_from_type_or_generic( # We are dealing with a Literal, so use an Enum member elif not types: m_cls = Enum + if default is not _NO_DEFAULT: + if default not in parameters: + raise ValueError("Default value does not appear in Literal") + # Make the default value the first in the enum arguments. + p = list(parameters) + p.pop(p.index(default)) + parameters = (default, *p) + default = _NO_DEFAULT # Int, Float, Str, Bytes, List, Dict, Set, Tuple, Bool, Callable elif len(types) == 1 and types[0] in _TYPE_TO_MEMBER: t = types[0] diff --git a/tests/test_atom_from_annotations.py b/tests/test_atom_from_annotations.py index 4e3aafa6..8e595fe3 100644 --- a/tests/test_atom_from_annotations.py +++ b/tests/test_atom_from_annotations.py @@ -312,6 +312,8 @@ class A(Atom, use_annotations=True): assert isinstance(A.a, member) if member is Subclass: assert A.a.default_value_mode == member(int, default=default).default_value_mode + elif member is Enum: + assert A.a.default_value_mode[1] == default elif member is not Instance: assert A.a.default_value_mode == member(default=default).default_value_mode From 0f6f4b7b5df4f69bc6ec0bc06ebacddc7ef5b4d0 Mon Sep 17 00:00:00 2001 From: MatthieuDartiailh Date: Mon, 20 Jan 2025 09:20:58 +0100 Subject: [PATCH 4/4] tests: add test for invalid default for literal --- .github/workflows/ci.yml | 2 +- atom/meta/annotation_utils.py | 4 +++- tests/test_atom_from_annotations.py | 7 +++++++ tests/test_typing_utils.py | 6 +----- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6911e42..2e61e253 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,7 +94,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.10', '3.11', '3.12', '3.13-dev'] + python-version: ['3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v4 - name: Get history and tags for SCM versioning to work diff --git a/atom/meta/annotation_utils.py b/atom/meta/annotation_utils.py index c9a22c67..576d4193 100644 --- a/atom/meta/annotation_utils.py +++ b/atom/meta/annotation_utils.py @@ -69,7 +69,9 @@ def generate_member_from_type_or_generic( m_cls = Enum if default is not _NO_DEFAULT: if default not in parameters: - raise ValueError("Default value does not appear in Literal") + raise ValueError( + f"Default value {default} does not appear in Literal: {parameters}" + ) # Make the default value the first in the enum arguments. p = list(parameters) p.pop(p.index(default)) diff --git a/tests/test_atom_from_annotations.py b/tests/test_atom_from_annotations.py index 8e595fe3..8ee8e19c 100644 --- a/tests/test_atom_from_annotations.py +++ b/tests/test_atom_from_annotations.py @@ -328,3 +328,10 @@ class A(Atom, use_annotations=True): class B(Atom, use_annotations=True): a: Optional[Iterable] = [] + + +def test_annotations_invalid_default_for_literal(): + with pytest.raises(ValueError): + + class A(Atom, use_annotations=True): + a: Literal["a", "b"] = "c" diff --git a/tests/test_typing_utils.py b/tests/test_typing_utils.py index dfc81d3b..a6537cb9 100644 --- a/tests/test_typing_utils.py +++ b/tests/test_typing_utils.py @@ -8,11 +8,7 @@ """Test typing utilities.""" from collections.abc import Iterable -<<<<<<< HEAD -from typing import Dict, List, NewType, Optional, Set, Tuple, TypeVar, Union -======= -from typing import Dict, List, Literal, Optional, Set, Tuple, TypeVar, Union ->>>>>>> 38528ca (allow the use of Literal in type annotation and use Enum in those cases) +from typing import Dict, List, Literal, NewType, Optional, Set, Tuple, TypeVar, Union import pytest