From 228bee95aa8d84740ebae1f479aec4f9a3a1eb4e Mon Sep 17 00:00:00 2001 From: MatthieuDartiailh Date: Tue, 16 Apr 2024 09:37:27 +0200 Subject: [PATCH] allow to mark a member as read_only, constant, or coerced using the member class --- atom/meta/annotation_utils.py | 35 ++++++++++++-- atom/meta/member_modifiers.py | 39 +++++++++++++--- docs/source/substitutions.sub | 2 + tests/test_atom_from_annotations.py | 59 ++++++++++++++++-------- tests/type_checking/test_annotations.yml | 2 +- 5 files changed, 106 insertions(+), 31 deletions(-) diff --git a/atom/meta/annotation_utils.py b/atom/meta/annotation_utils.py index dcb757e7..9482201d 100644 --- a/atom/meta/annotation_utils.py +++ b/atom/meta/annotation_utils.py @@ -14,7 +14,19 @@ from ..dict import DefaultDict, Dict as ADict from ..instance import Instance from ..list import List as AList -from ..scalars import Bool, Bytes, Callable as ACallable, Float, Int, Str, Value +from ..scalars import ( + Bool, + Bytes, + Callable as ACallable, + Float, + Int, + ReadOnly, + Str, + Value, + Constant, + Range, + FloatRange, +) from ..set import Set as ASet from ..subclass import Subclass from ..tuple import FixedTuple, Tuple as ATuple @@ -38,6 +50,9 @@ collections.abc.Callable: ACallable, } +# XXX handle member as annotation +# XXX handle str as annotation with default + def generate_member_from_type_or_generic( type_generic: Any, default: Any, annotate_type_containers: int @@ -56,10 +71,20 @@ def generate_member_from_type_or_generic( "Member subclasses cannot be used as annotations without " "specifying a default value for the attribute." ) - elif isinstance(default, member) and default.coercer is not None: - m_cls = Coerced - parameters = (types,) - m_kwargs["coercer"] = default.coercer + elif isinstance(default, member) and any( + (default._constant, default._coercer, default._read_only) + ): + if default._coercer is not None: + m_cls = Coerced + parameters = (types,) + m_kwargs["coercer"] = default._coercer + if default._read_only: + m_cls = ReadOnly + parameters = (types,) + if default._constant: + m_cls = Constant + m_kwargs["kind"] = types + elif object in types or Any in types: m_cls = Value parameters = () diff --git a/atom/meta/member_modifiers.py b/atom/meta/member_modifiers.py index 907cd072..9538ff94 100644 --- a/atom/meta/member_modifiers.py +++ b/atom/meta/member_modifiers.py @@ -22,7 +22,9 @@ class member(object): "default_factory", "default_args", "default_kwargs", - "coercer", + "_coercer", + "_read_only", + "_constant", ) #: Name of the member for which a new default value should be set. Used by @@ -41,9 +43,6 @@ class member(object): #: Keyword arguments to create a default value. default_kwargs: Optional[dict] - #: Coercing function to use. - coercer: Optional[Callable[[Any], Any]] - #: Metadata to set on the member metadata: dict[str, Any] @@ -53,7 +52,6 @@ def __init__( default_factory: Optional[Callable[[], Any]] = None, default_args: Optional[tuple] = None, default_kwargs: Optional[dict] = None, - coercer: Optional[Callable[[Any], Any]] = None, ) -> None: if default_value is not _SENTINEL: if ( @@ -72,7 +70,9 @@ def __init__( self.default_factory = default_factory self.default_args = default_args self.default_kwargs = default_kwargs - self.coercer = coercer + self._coercer = None + self._read_only = False + self._constant = False self.metadata = {} def clone(self) -> Self: @@ -82,16 +82,41 @@ def clone(self) -> Self: self.default_factory, self.default_args, self.default_kwargs, - self.coercer, ) + new._coercer = self._coercer + new._read_only = self._read_only + new._constant = self._constant new.metadata = self.metadata.copy() return new + def coerce(self, coercer: Callable[[Any], Any]) -> Self: + self._coercer = coercer + return self + + def read_only(self) -> Self: + self._read_only = True + return self + + def constant(self) -> Self: + self._constant = True + return self + def tag(self, **meta: Any) -> Self: """Add new metadata to the member.""" self.metadata |= meta return self + # --- Private API + + #: Coercing function to use. + _coercer: Optional[Callable[[Any], Any]] + + #: Should the member be read only. + _read_only: bool + + #: Should the member be constant + _constant: bool + def set_default(value: Any) -> member: return member(default_value=value) diff --git a/docs/source/substitutions.sub b/docs/source/substitutions.sub index 857d9253..c68fd339 100644 --- a/docs/source/substitutions.sub +++ b/docs/source/substitutions.sub @@ -83,6 +83,8 @@ .. |observe| replace:: :py:class:`~atom.atom.observe` +.. |member| replace:: :py:class:`~atom.atom.member` + .. |set_default| replace:: :py:class:`~atom.atom.set_default` .. |atomref| replace:: :py:class:`~atom.catom.atomref` diff --git a/tests/test_atom_from_annotations.py b/tests/test_atom_from_annotations.py index bc41a097..3c80c338 100644 --- a/tests/test_atom_from_annotations.py +++ b/tests/test_atom_from_annotations.py @@ -33,6 +33,8 @@ Bool, Bytes, Callable, + Coerced, + Constant, DefaultDict, Dict, FixedTuple, @@ -41,14 +43,22 @@ Int, List, Member, + ReadOnly, Set, Str, Subclass, Tuple, Typed, Value, + member, + set_default, ) -from atom.atom import set_default + + +class Dummy: + def __init__(self, a=1, b=2) -> None: + self.a = a + self.b = b def test_ignore_annotations(): @@ -129,16 +139,6 @@ class A(Atom, use_annotations=True): a: TList[int] = List(int, default=[1, 2, 3]) -def test_reject_non_member_annotated_set_default(): - class A(Atom, use_annotations=True): - a = Value() - - with pytest.raises(TypeError): - - class B(A, use_annotations=True): - a: int = set_default(1) - - @pytest.mark.parametrize( "annotation, member", [ @@ -275,7 +275,7 @@ class A(Atom, use_annotations=True, type_containers=depth): @pytest.mark.parametrize( - "annotation, member, default", + "annotation, member_cls, default", [ (bool, Bool, True), (int, Int, 1), @@ -293,6 +293,10 @@ class A(Atom, use_annotations=True, type_containers=depth): (TDefaultDict, DefaultDict, defaultdict(int, {1: 2})), (Optional[Iterable], Instance, None), (Type[int], Subclass, int), + (Dummy, Typed, member(default_args=(5,), default_kwargs={"b": 1})), + (Dummy, Coerced, member().coerce(lambda v: Dummy(v, 5))), + (Dummy, ReadOnly, member(1).read_only()), + (Dummy, Constant, member(1).constant()), ] + ( [ @@ -306,15 +310,27 @@ class A(Atom, use_annotations=True, type_containers=depth): else [] ), ) -def test_annotations_with_default(annotation, member, default): +def test_annotations_with_default(annotation, member_cls, default): class A(Atom, use_annotations=True): a: annotation = default - 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 not Instance: - assert A.a.default_value_mode == member(default=default).default_value_mode + assert isinstance(A.a, member_cls) + if member_cls is Subclass: + assert ( + A.a.default_value_mode + == member_cls(int, default=default).default_value_mode + ) + elif member_cls not in (Instance, Typed, Coerced): + d = default.default_value if isinstance(default, member) else default + assert A.a.default_value_mode == member_cls(default=d).default_value_mode + + if annotation is Dummy and default.default_args: + assert A().a.a == 5 + assert A().a.b == 1 + elif annotation is Dummy and default._coercer: + t = A() + t.a = 8 + assert t.a.a == 8 def test_annotations_no_default_for_instance(): @@ -327,3 +343,10 @@ class A(Atom, use_annotations=True): class B(Atom, use_annotations=True): a: Optional[Iterable] = [] + + +def test_setting_metadata(): + class A(Atom, use_annotations=True): + a: Iterable = member().tag(a=1) + + assert A.a.metadata == {"a": 1} diff --git a/tests/type_checking/test_annotations.yml b/tests/type_checking/test_annotations.yml index 12aba27f..db4d8bf4 100644 --- a/tests/type_checking/test_annotations.yml +++ b/tests/type_checking/test_annotations.yml @@ -21,4 +21,4 @@ m: {{ annotation }} = {{ member_instance }} reveal_type(A.m) # N: Revealed type is "{{ member_type }}" - reveal_type(A().m) # N: Revealed type is "{{ member_value_type }}" + reveal_type(A(m=[]).m) # N: Revealed type is "{{ member_value_type }}"