diff --git a/atom/api.py b/atom/api.py index a8aea145..b6d45f02 100644 --- a/atom/api.py +++ b/atom/api.py @@ -40,6 +40,7 @@ MissingMemberWarning, add_member, clone_if_needed, + member, observe, set_default, ) @@ -119,6 +120,7 @@ "cached_property", "clone_if_needed", "defaultatomdict", + "member", "observe", "set_default", ] diff --git a/atom/catom.pyi b/atom/catom.pyi index 436880d6..5c7a9098 100644 --- a/atom/catom.pyi +++ b/atom/catom.pyi @@ -5,6 +5,7 @@ # # The full license is in the file LICENSE, distributed with this software. # -------------------------------------------------------------------------------------- +import sys from enum import IntEnum, IntFlag from typing import ( Any, @@ -22,7 +23,10 @@ from typing import ( overload, ) -from typing_extensions import Self +if sys.version_info < (3, 11): + from typing_extensions import Self +else: + from typing import Self from .atom import Atom from .property import Property diff --git a/atom/meta/__init__.py b/atom/meta/__init__.py index 0ea53187..313bbe15 100644 --- a/atom/meta/__init__.py +++ b/atom/meta/__init__.py @@ -8,7 +8,7 @@ """Atom metaclass and tools used to create atom subclasses.""" from .atom_meta import AtomMeta, MissingMemberWarning, add_member, clone_if_needed -from .member_modifiers import set_default +from .member_modifiers import member, set_default from .observation import observe __all__ = [ @@ -16,6 +16,8 @@ "MissingMemberWarning", "add_member", "clone_if_needed", + "member", "observe", "set_default", + "set_default", ] diff --git a/atom/meta/annotation_utils.py b/atom/meta/annotation_utils.py index 576d4193..c341744a 100644 --- a/atom/meta/annotation_utils.py +++ b/atom/meta/annotation_utils.py @@ -10,17 +10,28 @@ from typing import Any, ClassVar, Literal, MutableMapping, Type from ..catom import Member +from ..coerced import Coerced 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 +from ..scalars import ( + Bool, + Bytes, + Callable as ACallable, + Constant, + Float, + Int, + ReadOnly, + Str, + Value, +) from ..set import Set as ASet from ..subclass import Subclass from ..tuple import FixedTuple, Tuple as ATuple from ..typed import Typed from ..typing_utils import extract_types, get_args, get_origin, is_optional -from .member_modifiers import set_default +from .member_modifiers import _SENTINEL, member _NO_DEFAULT = object() @@ -38,8 +49,11 @@ collections.abc.Callable: ACallable, } +# XXX handle member as annotation +# XXX handle str as annotation with default -def generate_member_from_type_or_generic( + +def generate_member_from_type_or_generic( # noqa C901 type_generic: Any, default: Any, annotate_type_containers: int ) -> Member: """Generate a member from a type or generic alias.""" @@ -51,16 +65,30 @@ def generate_member_from_type_or_generic( types = extract_types(type_generic) parameters = get_args(type_generic) - m_kwargs = {} + m_kwargs: dict[str, Any] = {} m_cls: Type[Member] if any( isinstance(t, type) and issubclass(t, Member) for t in types - ) and not isinstance(default, Member): + ) and not isinstance(default, (Member, member)): raise ValueError( "Member subclasses cannot be used as annotations without " "specifying a default value for the attribute." ) + 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 = () @@ -124,22 +152,41 @@ def generate_member_from_type_or_generic( parameters = (filtered_types,) m_kwargs["optional"] = opt - if opt and default not in (_NO_DEFAULT, None): + if ( + opt + and not isinstance(default, member) + and default not in (_NO_DEFAULT, None) + ): raise ValueError( "Members requiring Instance(optional=True) cannot have " "a non-None default value." ) - elif not opt and default is not _NO_DEFAULT: + elif not opt and not isinstance(default, member) and default is not _NO_DEFAULT: raise ValueError("Members requiring Instance cannot have a default value.") # Instance does not have a default keyword so turn a None default into the # equivalent no default. - default = _NO_DEFAULT + if default is None: + default = _NO_DEFAULT - if default is not _NO_DEFAULT: + if isinstance(default, member): + if default.default_value is not _SENTINEL: + m_kwargs["default"] = default.default_value + elif default.default_factory is not None: + m_kwargs["factory"] = default.default_factory + if default.default_args is not None: + m_kwargs["args"] = default.default_args + if default.default_kwargs is not None: + m_kwargs["kwargs"] = default.default_kwargs + + elif default is not _NO_DEFAULT: m_kwargs["default"] = default - return m_cls(*parameters, **m_kwargs) + new = m_cls(*parameters, **m_kwargs) + if isinstance(default, member) and default.metadata: + new.metadata = default.metadata + + return new def generate_members_from_cls_namespace( @@ -153,7 +200,7 @@ def generate_members_from_cls_namespace( # We skip field for which a member was already provided or annotations # corresponding to class variables. - if isinstance(default, (Member, set_default)): + if isinstance(default, Member): # Allow string annotations for members if isinstance(ann, str): continue diff --git a/atom/meta/atom_meta.py b/atom/meta/atom_meta.py index 24455a5e..62badbf1 100644 --- a/atom/meta/atom_meta.py +++ b/atom/meta/atom_meta.py @@ -8,6 +8,7 @@ """Metaclass implementing atom members customization.""" import copyreg +import sys import warnings from types import FunctionType from typing import ( @@ -22,10 +23,16 @@ Sequence, Set, Tuple, + Type, TypeVar, Union, ) +if sys.version_info < (3, 11): + from typing_extensions import dataclass_transform +else: + from typing import dataclass_transform + from ..catom import ( CAtom, DefaultValue, @@ -36,8 +43,33 @@ PostValidate, Validate, ) +from ..coerced import Coerced +from ..containerlist import ContainerList +from ..dict import DefaultDict, Dict as MDict +from ..enum import Enum +from ..event import Event +from ..instance import Instance +from ..list import List as MList +from ..property import Property +from ..scalars import ( + Bool, + Bytes, + Callable as MCallable, + Constant, + Float, + FloatRange, + Int, + Range, + ReadOnly, + Str, + Value, +) +from ..set import Set as MSet +from ..signal import Signal +from ..tuple import Tuple as MTuple +from ..typed import Typed from .annotation_utils import generate_members_from_cls_namespace -from .member_modifiers import set_default +from .member_modifiers import _SENTINEL, member from .observation import ExtendedObserver, ObserveHandler OBSERVE_PREFIX = "_observe_" @@ -48,7 +80,7 @@ POST_VALIDATE_PREFIX = "_post_validate_" GETSTATE_PREFIX = "_getstate_" - +T = TypeVar("T") M = TypeVar("M", bound=Member) @@ -141,7 +173,7 @@ def _compute_mro(bases: Sequence[type]) -> List[type]: def _clone_if_needed( member: M, - members: Dict[str, Member], + members: MutableMapping[str, Member], specific_members: Set[str], owned_members: Set[Member], ) -> M: @@ -177,8 +209,8 @@ class _AtomMetaHelper: #: The set of seen @observe decorators seen_decorated: Set[ObserveHandler] - #: set_default() sentinel - set_defaults: List[set_default] + #: member() sentinel + member_modifiers: List[member] #: Static observer methods: _observe_* observes: List[str] @@ -205,11 +237,15 @@ class _AtomMetaHelper: "bases", "dct", "decorated", + "decorated", + "defaults", "defaults", "getstates", + "member_modifiers", "members", "name", "observes", + "observes", "owned_members", "post_getattrs", "post_setattrs", @@ -217,6 +253,8 @@ class _AtomMetaHelper: "seen_decorated", "set_defaults", "specific_members", + "specific_members", + "validates", "validates", ) @@ -231,7 +269,7 @@ def __init__(self, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> N self.defaults = [] self.validates = [] self.decorated = [] - self.set_defaults = [] + self.member_modifiers = [] self.post_getattrs = [] self.post_setattrs = [] self.post_validates = [] @@ -248,11 +286,11 @@ def scan_and_clear_namespace(self) -> None: dct = self.dct seen_sentinels = set() # The set of seen sentinels for key, value in dct.items(): - if isinstance(value, set_default): + if isinstance(value, member): if value in seen_sentinels: value = value.clone() value.name = key - self.set_defaults.append(value) + self.member_modifiers.append(value) seen_sentinels.add(value) continue if isinstance(value, ObserveHandler): @@ -314,13 +352,13 @@ def assign_members_indexes(self) -> None: # with multiple inheritance where the indices of multiple base # classes will overlap. When this happens, the members which # conflict must be cloned in order to occupy a unique index. - conflicts = [] + conflicts: List[Member] = [] occupied = set() - for member in members.values(): - if member.index in occupied: - conflicts.append(member) + for m in members.values(): + if m.index in occupied: + conflicts.append(m) else: - occupied.add(member.index) + occupied.add(m.index) # Track the first available index i = 0 @@ -336,8 +374,8 @@ def get_first_free_index() -> int: # Do not blow away an overridden item on the current class. owned_members = self.owned_members cloned = {} - for member in conflicts: - clone = member.clone() + for m in conflicts: + clone = m.clone() clone.set_index(get_first_free_index()) owned_members.add(clone) members[clone.name] = clone @@ -382,20 +420,42 @@ def apply_members_static_behaviors(self) -> None: """ members = self.members - def clone_if_needed(m): + def clone_if_needed(m: Member) -> Member: m = _clone_if_needed(m, members, self.specific_members, self.owned_members) self.dct[m.name] = m return m - # set_default() sentinels - for sd in self.set_defaults: + # member() sentinels + for sd in self.member_modifiers: assert sd.name # At this point the name has been set if sd.name not in members: msg = "Invalid call to set_default(). '%s' is not a member " msg += "on the '%s' class." raise TypeError(msg % (sd.name, self.name)) member = clone_if_needed(members[sd.name]) - member.set_default_value_mode(DefaultValue.Static, sd.value) + if sd.default_value is not _SENTINEL: + member.set_default_value_mode(DefaultValue.Static, sd.default_value) + elif sd.default_factory is not None: + member.set_default_value_mode( + DefaultValue.CallObject, sd.default_factory + ) + elif sd.default_args is not None or sd.default_kwargs is not None: + if not isinstance(member, (Typed, Instance, Coerced)): + raise TypeError( + "Can specify default args and kwargs only for " + f"Typed, Instance and Coerced member, got {member}." + ) + t = member.validate_mode[1] + if isinstance(t, tuple): + t = t[0] + member.set_default_value_mode( + DefaultValue.CallObject, + lambda: t(*sd.default_args, **sd.default_kwargs), + ) + if sd.metadata: + if member.metadata is None: + member.metadata = {} + member.metadata.update(sd.metadata) # _default_* methods for prefix, method_names, mode_setter in [ @@ -474,7 +534,7 @@ def clone_if_needed(m): self.name, name, members, "observe decorated" ) - def create_class(self, meta: type) -> type: + def create_class(self, meta: Type[T], freeze: bool) -> T: """Create the class after adding class variables.""" # Put a reference to the members dict on the class. This is used @@ -488,11 +548,14 @@ def create_class(self, meta: type) -> type: m for m in self.specific_members ) + # Store wether or not to freeze the new instance after initialization + self.dct["__atom_freeze__"] = freeze + # Create the class object. # We do it once everything else has been setup so that if users wants # to use __init__subclass__ they have access to fully initialized # Atom type. - cls: type = type.__new__(meta, self.name, self.bases, self.dct) + cls: T = type.__new__(meta, self.name, self.bases, self.dct) # Generate slotnames cache # (using a private function that mypy does not know about). @@ -501,6 +564,39 @@ def create_class(self, meta: type) -> type: return cls +@dataclass_transform( + eq_default=False, + order_default=False, + kw_only_default=True, + field_specifiers=( + member, + Member, + Coerced, + ContainerList, + DefaultDict, + MDict, + Enum, + Event, + Instance, + MList, + Property, + Bool, + Bytes, + MCallable, + Constant, + Float, + FloatRange, + Int, + Range, + ReadOnly, + Str, + Value, + MSet, + Signal, + MTuple, + Typed, + ), +) class AtomMeta(type): """The metaclass for classes derived from Atom. @@ -517,16 +613,18 @@ class so that the CAtom class can allocate exactly enough space for __atom_members__: Mapping[str, Member] __atom_specific_members__: FrozenSet[str] + __atom_freeze__: bool def __new__( - meta, + meta: Type[T], name: str, bases: Tuple[type, ...], dct: Dict[str, Any], enable_weakrefs: bool = False, use_annotations: bool = True, type_containers: int = 1, - ): + freeze: bool = False, + ) -> T: # Ensure there is no weird mro calculation and that we can use our # re-implementation of C3 assert meta.mro is type.mro, "Custom MRO calculation are not supported" @@ -554,4 +652,12 @@ def __new__( # Customize the members based on the specified static modifiers helper.apply_members_static_behaviors() - return helper.create_class(meta) + return helper.create_class(meta, freeze) + + def __call__(self, *args: Any, **kwds: Any) -> Any: + new = super().__call__(*args, **kwds) + if self.__atom_freeze__: + # We get a Atom instance here, so freeze exists + new.freeze() # type: ignore + + return new diff --git a/atom/meta/member_modifiers.py b/atom/meta/member_modifiers.py index 78c49b25..989e8957 100644 --- a/atom/meta/member_modifiers.py +++ b/atom/meta/member_modifiers.py @@ -7,31 +7,118 @@ # -------------------------------------------------------------------------------------- """Custom marker objects used to modify the default settings of a member.""" -from typing import Any, Optional +from typing import Any, Callable, Dict, Optional +from typing_extensions import Self -class set_default(object): - """An object used to set the default value of a base class member.""" +_SENTINEL = object() - __slots__ = ("name", "value") + +class member(object): + """An object used to configure a member defined by a type annotation.""" + + __slots__ = ( + "_coercer", + "_constant", + "_read_only", + "default_args", + "default_factory", + "default_kwargs", + "default_value", + "metadata", + "name", + ) #: Name of the member for which a new default value should be set. Used by #: the metaclass. name: Optional[str] - #: New default value to be set. - value: Any + #: Default value. + default_value: Any + + #: Default value factory. + default_factory: Optional[Callable[[], Any]] + + #: Tuple of argument to create a default value. + default_args: Optional[tuple] - def __init__(self, value: Any) -> None: - self.value = value + #: Keyword arguments to create a default value. + default_kwargs: Optional[dict] + + #: Metadata to set on the member + metadata: Dict[str, Any] + + def __init__( + self, + default_value: Any = _SENTINEL, + default_factory: Optional[Callable[[], Any]] = None, + default_args: Optional[tuple] = None, + default_kwargs: Optional[dict] = None, + ) -> None: + if default_value is not _SENTINEL: + if ( + default_factory is not None + or default_args is not None + or default_kwargs is not None + ): + raise ValueError( + "Cannot specify a default value and a factory or args or kwargs" + ) + elif default_factory is not None: + if default_args is not None or default_kwargs is not None: + raise ValueError("Cannot specify a factory and args or kwargs") self.name = None # storage for the metaclass + self.default_value = default_value + self.default_factory = default_factory + self.default_args = default_args + self.default_kwargs = default_kwargs + self._coercer = None + self._read_only = False + self._constant = False + self.metadata = {} - def clone(self) -> "set_default": + def clone(self) -> Self: """Create a clone of the sentinel.""" - return type(self)(self.value) + new = type(self)( + self.default_value, + self.default_factory, + self.default_args, + self.default_kwargs, + ) + 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 -# XXX add more sentinels here to allow customizing members without using the -# members themselves: -# - tag -# + 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.update(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/atom/src/validatebehavior.cpp b/atom/src/validatebehavior.cpp index 7d47d609..2cc46e5a 100644 --- a/atom/src/validatebehavior.cpp +++ b/atom/src/validatebehavior.cpp @@ -494,11 +494,6 @@ fixed_tuple_handler( Member* member, CAtom* atom, PyObject* oldvalue, PyObject* // Create a copy in which to store the validated values Py_ssize_t size = PyTuple_GET_SIZE( newvalue ); - cppy::ptr tuplecopy = PyTuple_New( size ); - if( !tuplecopy ) - { - return 0; - } // Check the size match the expected size Py_ssize_t expected_size = PyTuple_GET_SIZE( member->validate_context ); @@ -516,6 +511,13 @@ fixed_tuple_handler( Member* member, CAtom* atom, PyObject* oldvalue, PyObject* return 0; } + // Create a new tuple to store the validate values + cppy::ptr tuplecopy = PyTuple_New( size ); + if( !tuplecopy ) + { + return 0; + } + // Validate each single item for( Py_ssize_t i = 0; i < size; ++i ) { diff --git a/atom/tuple.pyi b/atom/tuple.pyi index e18326b9..01d849ea 100644 --- a/atom/tuple.pyi +++ b/atom/tuple.pyi @@ -5,6 +5,7 @@ # # The full license is in the file LICENSE, distributed with this software. # -------------------------------------------------------------------------------------- +import sys from typing import ( Any, Optional, @@ -15,7 +16,10 @@ from typing import ( overload, ) -from typing_extensions import Unpack +if sys.version_info < (3, 11): + from typing_extensions import Unpack +else: + from typing import Unpack from .catom import Member 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/examples/api/observe_hints.py b/examples/api/observe_hints.py index 487d0846..b1451980 100644 --- a/examples/api/observe_hints.py +++ b/examples/api/observe_hints.py @@ -23,7 +23,7 @@ class Person(Atom): age: int - dog: Optional[Dog] + dog: Optional[Dog] = None def _observe_age(self, change: ChangeDict) -> None: print("Age changed: {0}".format(change["value"])) diff --git a/examples/api/property.py b/examples/api/property.py index e6e04fa4..cf6bed32 100644 --- a/examples/api/property.py +++ b/examples/api/property.py @@ -13,7 +13,8 @@ class Person(Atom): """A simple class representing a person object.""" - first_name = Str() + # Attribute that can be used as argument must have an annotation + first_name: Str = Str() # Static type checker cannot infer from the magic method that the property # is read/write so a type annotation helps. @@ -29,7 +30,7 @@ def _set_age(self, age: int) -> None: if __name__ == "__main__": - bob = Person(first_name="Bob") + bob = Person(first_name="Bob") # type: ignore print(bob.age) bob.age = -10 print(bob.age) diff --git a/examples/typehints/static_type_checking.py b/examples/typehints/static_type_checking.py index 17a11b49..c912a9eb 100644 --- a/examples/typehints/static_type_checking.py +++ b/examples/typehints/static_type_checking.py @@ -17,7 +17,7 @@ class MyAtom(Atom): s: str = "Hello" lst: List[int] = [1, 2, 3] # On Python >= 3.9 list[int] can be used - num: Optional[float] + num: Optional[float] = None n = Int() diff --git a/tests/test_atom_from_annotations.py b/tests/test_atom_from_annotations.py index 8ee8e19c..86348ab2 100644 --- a/tests/test_atom_from_annotations.py +++ b/tests/test_atom_from_annotations.py @@ -35,6 +35,8 @@ Bool, Bytes, Callable, + Coerced, + Constant, DefaultDict, Dict, Enum, @@ -44,14 +46,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(): @@ -92,6 +102,7 @@ class A(Atom, use_annotations=True): assert A().b == [1, 2, 3] +@pytest.mark.xfail @pytest.mark.skipif( sys.version_info < (3, 9), reason="Subscription of Members requires Python 3.9+" ) @@ -105,6 +116,7 @@ class B(A, use_annotations=True): assert B().a == 1 +@pytest.mark.xfail def test_ignore_str_annotated_set_default(): class A(Atom, use_annotations=True): a = Value() @@ -132,16 +144,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", [ @@ -279,7 +281,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), @@ -303,19 +305,35 @@ 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), + (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()), ], ) -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 Enum: + 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 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 + 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(): @@ -335,3 +353,10 @@ def test_annotations_invalid_default_for_literal(): class A(Atom, use_annotations=True): a: Literal["a", "b"] = "c" + + +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 6de6fbd4..b22c0ed0 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 }}"