diff --git a/pyproject.toml b/pyproject.toml index 451b243029eabfd308441f5161eac2e9d7069a41..f12b50844f60dffea2a78b27441a5710ab3be8a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,8 @@ classifiers = [ ] dependencies = [ "platformdirs>=2.2", - "typing-extensions>=4; python_version<'3.11'", + # for dataclass_transform with frozen_default + "typing-extensions>=4; python_version<'3.13'", "siphash24>=1.6", ] diff --git a/pytools/tag.py b/pytools/tag.py index ed337a6d3cdb71166aca846c013f49a6d66f7df2..3fa32940007a758afefdb5c5e3ae614b6bdc7314 100644 --- a/pytools/tag.py +++ b/pytools/tag.py @@ -7,7 +7,6 @@ Tag Interface .. autoclass:: Taggable .. autoclass:: Tag .. autoclass:: UniqueTag -.. autoclass:: IgnoredForEqualityTag Supporting Functionality ------------------------ @@ -22,14 +21,13 @@ Internal stuff that is only here because the documentation tool wants it .. class:: TagT A type variable with lower bound :class:`Tag`. - -.. class:: _Self_Taggable - - A type variable with lower bound :class:`Taggable`. """ +from __future__ import annotations + from dataclasses import dataclass from typing import ( + TYPE_CHECKING, Any, FrozenSet, Iterable, @@ -40,6 +38,8 @@ from typing import ( Union, ) +from typing_extensions import Self, dataclass_transform + from pytools import memoize, memoize_method @@ -100,7 +100,7 @@ class DottedName: self.name_parts = name_parts @classmethod - def from_class(cls, argcls: Any) -> "DottedName": + def from_class(cls, argcls: Any) -> DottedName: name_parts = tuple( [str(part) for part in argcls.__module__.split(".")] + [str(argcls.__name__)]) @@ -124,7 +124,12 @@ class DottedName: # {{{ tag -tag_dataclass = dataclass(init=True, eq=True, frozen=True, repr=True) +T = TypeVar("T") + + +@dataclass_transform(eq_default=True, frozen_default=True) +def tag_dataclass(cls: type[T]) -> type[T]: + return dataclass(init=True, frozen=True, eq=True, repr=True)(cls) @tag_dataclass @@ -155,6 +160,7 @@ class Tag: # {{{ unique tag +@tag_dataclass class UniqueTag(Tag): """ A superclass for tags that are unique on each :class:`Taggable`. @@ -164,25 +170,22 @@ class UniqueTag(Tag): set of `tags`. Multiple `UniqueTag` instances of different (immediate) subclasses are allowed. """ - pass # }}} ToTagSetConvertible = Union[Iterable[Tag], Tag, None] TagT = TypeVar("TagT", bound="Tag") -# FIXME: Replace by Self type -_Self_Taggable = TypeVar("_Self_Taggable", bound="Taggable") # {{{ UniqueTag rules checking @memoize -def _immediate_unique_tag_descendants(cls): +def _immediate_unique_tag_descendants(cls: type[Tag]) -> FrozenSet[type[Tag]]: if UniqueTag in cls.__bases__: return frozenset([cls]) else: - result = frozenset() + result: FrozenSet[type[Tag]] = frozenset() for base in cls.__bases__: result = result | _immediate_unique_tag_descendants(base) return result @@ -197,14 +200,14 @@ class NonUniqueTagError(ValueError): pass -def check_tag_uniqueness(tags: FrozenSet[Tag]): +def check_tag_uniqueness(tags: FrozenSet[Tag]) -> FrozenSet[Tag]: """Ensure that *tags* obeys the rules set forth in :class:`UniqueTag`. If not, raise :exc:`NonUniqueTagError`. If any *tags* are not subclasses of :class:`Tag`, a :exc:`TypeError` will be raised. :returns: *tags* """ - unique_tag_descendants: Set[Tag] = set() + unique_tag_descendants: Set[type[Tag]] = set() for tag in tags: if not isinstance(tag, Tag): raise TypeError(f"'{tag}' is not an instance of pytools.tag.Tag") @@ -239,9 +242,7 @@ class Taggable: """ Parent class for objects with a `tags` attribute. - .. attribute:: tags - - A :class:`frozenset` of :class:`Tag` instances + .. autoattribute:: tags .. automethod:: __init__ @@ -257,37 +258,20 @@ class Taggable: # ReST references in docstrings must be fully qualified, as docstrings may # be inherited and appear in different contexts. - def __init__(self, tags: FrozenSet[Tag] = frozenset()): - """ - Constructor for all objects that possess a `tags` attribute. - - :arg tags: a :class:`frozenset` of :class:`~pytools.tag.Tag` objects. - Tags can be modified via the :meth:`~pytools.tag.Taggable.tagged` and - :meth:`~pytools.tag.Taggable.without_tags` routines. Input checking - of *tags* should be performed before creating a - :class:`~pytools.tag.Taggable` instance, using - :func:`~pytools.tag.check_tag_uniqueness`. - """ - self.tags = tags + # type-checking only so that self.tags = ... in subclasses still works + if TYPE_CHECKING: + @property + def tags(self) -> FrozenSet[Tag]: + ... - def _with_new_tags(self: _Self_Taggable, tags: FrozenSet[Tag]) -> _Self_Taggable: + def _with_new_tags(self, tags: FrozenSet[Tag]) -> Self: """ Returns a copy of *self* with the specified tags. This method should be overridden by subclasses. """ - from warnings import warn - warn(f"_with_new_tags() for {self.__class__} fell back " - "to using copy(). This is deprecated and will stop working in " - "July of 2022. Instead, override _with_new_tags to specify " - "how tags should be applied to an instance.", - DeprecationWarning, stacklevel=2) - - # mypy is right: we're not promising this attribute is defined. - # Once this deprecation expires, this will go back to being an - # abstract method. - return self.copy(tags=tags) # type: ignore[attr-defined] # pylint: disable=no-member + raise NotImplementedError - def tagged(self: _Self_Taggable, tags: ToTagSetConvertible) -> _Self_Taggable: + def tagged(self, tags: ToTagSetConvertible) -> Self: """ Return a copy of *self* with the specified tag or tags added to the set of tags. If the resulting set of @@ -300,9 +284,9 @@ class Taggable: return self._with_new_tags( tags=check_tag_uniqueness(normalize_tags(tags) | self.tags)) - def without_tags(self: _Self_Taggable, + def without_tags(self, tags: ToTagSetConvertible, verify_existence: bool = True - ) -> _Self_Taggable: + ) -> Self: """ Return a copy of *self* without the specified tags. @@ -340,32 +324,12 @@ class Taggable: def __eq__(self, other: object) -> bool: if isinstance(other, Taggable): - return (self.tags_not_of_type(IgnoredForEqualityTag) - == other.tags_not_of_type(IgnoredForEqualityTag)) + return self.tags == other.tags else: return super().__eq__(other) def __hash__(self) -> int: - return hash(self.tags_not_of_type(IgnoredForEqualityTag)) - - -# }}} - - -# {{{ IgnoredForEqualityTag - -class IgnoredForEqualityTag(Tag): - """ - A superclass for tags that are ignored when testing equality of instances of - :class:`Taggable`. - - When testing equality of two instances of :class:`Taggable`, the equality - of the ``tags`` of both instances is tested after removing all - instances of :class:`IgnoredForEqualityTag`. Instances of - :class:`IgnoredForEqualityTag` are removed for hashing instances of - :class:`Taggable`. - """ - pass + return hash(self.tags) # }}} @@ -385,7 +349,7 @@ _depr_name_to_replacement_and_obj = { } -def __getattr__(name): +def __getattr__(name: str) -> Any: replacement_and_obj = _depr_name_to_replacement_and_obj.get(name, None) if replacement_and_obj is not None: replacement, obj, year = replacement_and_obj diff --git a/pytools/test/test_persistent_dict.py b/pytools/test/test_persistent_dict.py index 5239884bf32505116b37bacfc77e5bd9c2ccd20f..151ac0303055ddf748df8fbca70497084d481991 100644 --- a/pytools/test/test_persistent_dict.py +++ b/pytools/test/test_persistent_dict.py @@ -82,7 +82,7 @@ def test_persistent_dict_storage_and_lookup() -> None: keys = [ (randrange(2000)-1000, rand_str(), None, - SomeTag(rand_str()), # type: ignore[call-arg] + SomeTag(rand_str()), frozenset({"abc", 123})) for i in range(20)] values = [randrange(2000) for i in range(20)] @@ -555,7 +555,7 @@ def test_class_hashing() -> None: class TagClass3(Tag): s: str - assert (keyb(TagClass3("foo")) == "cf1a33652cc75b9c") # type: ignore[call-arg] + assert (keyb(TagClass3("foo")) == "cf1a33652cc75b9c") def test_dataclass_hashing() -> None: diff --git a/pytools/test/test_pytools.py b/pytools/test/test_pytools.py index e6d95d68fe7bb6b8f5fceb6c2937fa26008cbfbf..07b59a9ce1b2996ee94d791630ac8bb04aaacbdf 100644 --- a/pytools/test/test_pytools.py +++ b/pytools/test/test_pytools.py @@ -23,11 +23,13 @@ THE SOFTWARE. import logging import sys +from dataclasses import dataclass from typing import FrozenSet import pytest from pytools import Record +from pytools.tag import tag_dataclass logger = logging.getLogger(__name__) @@ -173,7 +175,6 @@ def test_memoize_keyfunc(): def test_memoize_frozen() -> None: - from dataclasses import dataclass from pytools import memoize_method @@ -506,7 +507,9 @@ def test_tag(): ) # Need a subclass that defines the copy function in order to test. + @tag_dataclass class TaggableWithCopy(Taggable): + tags: FrozenSet[Tag] def _with_new_tags(self, tags): return TaggableWithCopy(tags) @@ -690,40 +693,6 @@ def test_unique_name_gen_conflicting_ok(): ung.add_names({"a", "b", "c"}, conflicting_ok=True) -def test_ignoredforequalitytag(): - from pytools.tag import IgnoredForEqualityTag, Tag, Taggable - - # Need a subclass that defines _with_new_tags in order to test. - class TaggableWithNewTags(Taggable): - - def _with_new_tags(self, tags: FrozenSet[Tag]): - return TaggableWithNewTags(tags) - - class Eq1(IgnoredForEqualityTag): - pass - - class Eq2(IgnoredForEqualityTag): - pass - - class Eq3(Tag): - pass - - eq1 = TaggableWithNewTags(frozenset([Eq1()])) - eq2 = TaggableWithNewTags(frozenset([Eq2()])) - eq12 = TaggableWithNewTags(frozenset([Eq1(), Eq2()])) - eq3 = TaggableWithNewTags(frozenset([Eq1(), Eq3()])) - - assert eq1 == eq2 == eq12 - assert eq1 != eq3 - - assert eq1.without_tags(Eq1()) - with pytest.raises(ValueError): - eq3.without_tags(Eq2()) - - assert hash(eq1) == hash(eq2) == hash(eq12) - assert hash(eq1) != hash(eq3) - - def test_strtobool(): from pytools import strtobool assert strtobool("true") is True diff --git a/run-mypy.sh b/run-mypy.sh index ae0edc90e6028ad7ac4ae8b7f491ee60b8205c41..244d6cc47f454df60e888a80e216a4dd9bf594dc 100755 --- a/run-mypy.sh +++ b/run-mypy.sh @@ -4,4 +4,7 @@ set -ex mypy --show-error-codes pytools -mypy --strict --follow-imports=silent pytools/datatable.py pytools/persistent_dict.py +mypy --strict --follow-imports=silent \ + pytools/tag.py \ + pytools/datatable.py \ + pytools/persistent_dict.py