diff --git a/pyproject.toml b/pyproject.toml index ce52ddf34a..d7d01375ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -182,6 +182,7 @@ disable = [ "import-outside-toplevel", "inconsistent-return-statements", "invalid-class-object", + "invalid-metaclass", "invalid-name", "invalid-unary-operand-type", "keyword-arg-before-vararg", diff --git a/src/awkward/_backends/backend.py b/src/awkward/_backends/backend.py index 7763953ed1..a7da8102bb 100644 --- a/src/awkward/_backends/backend.py +++ b/src/awkward/_backends/backend.py @@ -10,7 +10,7 @@ from awkward._nplikes.numpy import Numpy from awkward._nplikes.numpy_like import NumpyLike, NumpyMetadata from awkward._singleton import PublicSingleton -from awkward._typing import Callable, TypeAlias, TypeVar +from awkward._typing import Callable, NominalMeta, TypeAlias, TypeVar np = NumpyMetadata.instance() numpy = Numpy.instance() @@ -21,7 +21,7 @@ KernelType: TypeAlias = "Callable[..., KernelError | None]" -class Backend(PublicSingleton, ABC): +class Backend(PublicSingleton, ABC, metaclass=NominalMeta): name: str @property diff --git a/src/awkward/_nplikes/array_like.py b/src/awkward/_nplikes/array_like.py index 609ae84aeb..857350faee 100644 --- a/src/awkward/_nplikes/array_like.py +++ b/src/awkward/_nplikes/array_like.py @@ -10,6 +10,7 @@ Any, DType, EllipsisType, + NominalMeta, Protocol, Self, SupportsIndex, @@ -140,7 +141,7 @@ def __or__(self, other: int | bool | Self) -> Self: ... def __invert__(self) -> Self: ... -class MaterializableArray(ArrayLike): +class MaterializableArray(ArrayLike, metaclass=NominalMeta): @abstractmethod def materialize(self) -> ArrayLike: ... diff --git a/src/awkward/_nplikes/array_module.py b/src/awkward/_nplikes/array_module.py index a6eae53195..ba19813dda 100644 --- a/src/awkward/_nplikes/array_module.py +++ b/src/awkward/_nplikes/array_module.py @@ -18,7 +18,16 @@ from awkward._nplikes.placeholder import PlaceholderArray from awkward._nplikes.shape import ShapeItem, unknown_length from awkward._nplikes.virtual import VirtualNDArray -from awkward._typing import TYPE_CHECKING, Any, DType, Final, Literal, TypeVar, cast +from awkward._typing import ( + TYPE_CHECKING, + Any, + DType, + Final, + Literal, + NominalMeta, + TypeVar, + cast, +) if TYPE_CHECKING: from numpy.typing import DTypeLike @@ -56,7 +65,7 @@ def _nplike_unique_has_equal_nan(module: Any) -> bool: ArrayLikeT = TypeVar("ArrayLikeT", bound=ArrayLike) -class ArrayModuleNumpyLike(NumpyLike[ArrayLikeT]): +class ArrayModuleNumpyLike(NumpyLike[ArrayLikeT], metaclass=NominalMeta): """ Some methods maintain virtualness while others are required to materialize the array. The `maybe_materialize` function is used for that purpose. diff --git a/src/awkward/_nplikes/typetracer.py b/src/awkward/_nplikes/typetracer.py index 792324872e..cfb0855887 100644 --- a/src/awkward/_nplikes/typetracer.py +++ b/src/awkward/_nplikes/typetracer.py @@ -29,6 +29,7 @@ EllipsisType, Final, Literal, + NominalMeta, Self, SupportsIndex, TypeGuard, @@ -236,7 +237,7 @@ def data_touched_in(self, node_ids: Collection[str]) -> list[str]: return list(out) -class TypeTracerArray(NDArrayOperatorsMixin, ArrayLike): +class TypeTracerArray(NDArrayOperatorsMixin, ArrayLike, metaclass=NominalMeta): _dtype: numpy.dtype _shape: tuple[ShapeItem, ...] @@ -602,7 +603,7 @@ def try_touch_shape(array: Any): @register_nplike -class TypeTracer(NumpyLike[TypeTracerArray]): +class TypeTracer(NumpyLike[TypeTracerArray], metaclass=NominalMeta): known_data: Final = False is_eager: Final = True supports_structured_dtypes: Final = True diff --git a/src/awkward/_typing.py b/src/awkward/_typing.py index 230a83e2cb..b5336baa53 100644 --- a/src/awkward/_typing.py +++ b/src/awkward/_typing.py @@ -79,3 +79,18 @@ JSONMapping: TypeAlias = "dict[str, JSONSerializable]" DType: TypeAlias = numpy.dtype + + +class NominalMeta(type(Protocol)): + """Metaclass restoring the interpreter's ``isinstance`` fast path. + + Concrete classes that descend from a ``Protocol`` inherit + ``typing._ProtocolMeta``, whose Python-level ``__instancecheck__`` costs + roughly four times a plain class check even though, for a non-protocol + subclass, it only performs the ordinary nominal test. Classes using this + metaclass are checked nominally and cannot be given virtual subclasses + through ``abc``'s ``register()``. + """ + + __instancecheck__ = type.__instancecheck__ + __subclasscheck__ = type.__subclasscheck__ diff --git a/tests/test_4339_nominal_isinstance.py b/tests/test_4339_nominal_isinstance.py new file mode 100644 index 0000000000..2d93a7932a --- /dev/null +++ b/tests/test_4339_nominal_isinstance.py @@ -0,0 +1,121 @@ +# BSD 3-Clause License; see https://github.com/scikit-hep/awkward/blob/main/LICENSE + +from __future__ import annotations + +import numpy as np +import pytest + +import awkward as ak +from awkward._backends.backend import Backend +from awkward._backends.numpy import NumpyBackend +from awkward._backends.typetracer import TypeTracerBackend +from awkward._nplikes.array_like import ArrayLike, MaterializableArray +from awkward._nplikes.array_module import ArrayModuleNumpyLike +from awkward._nplikes.numpy import Numpy +from awkward._nplikes.placeholder import PlaceholderArray +from awkward._nplikes.shape import unknown_length +from awkward._nplikes.typetracer import TypeTracer, TypeTracerArray +from awkward._nplikes.virtual import VirtualNDArray +from awkward._typing import NominalMeta + +# Every class that opts into the nominal metaclass. +NOMINAL_CLASSES = [ + Backend, + MaterializableArray, + ArrayModuleNumpyLike, + TypeTracer, + TypeTracerArray, +] + + +@pytest.mark.parametrize("cls", NOMINAL_CLASSES, ids=lambda cls: cls.__name__) +def test_uses_nominal_metaclass(cls): + assert type(cls) is NominalMeta + + +def test_real_implementations_are_still_instances(): + # nplikes + assert isinstance(Numpy.instance(), ArrayModuleNumpyLike) + assert issubclass(Numpy, ArrayModuleNumpyLike) + assert isinstance(TypeTracer.instance(), TypeTracer) + + # backends + assert isinstance(NumpyBackend.instance(), Backend) + assert isinstance(TypeTracerBackend.instance(), Backend) + + # arrays + placeholder = PlaceholderArray(Numpy.instance(), (3,), np.dtype(np.int64)) + assert isinstance(placeholder, MaterializableArray) + virtual = VirtualNDArray( + Numpy.instance(), (3,), np.dtype(np.int64), lambda: np.arange(3) + ) + assert isinstance(virtual, MaterializableArray) + + layout = ak.Array([[1, 2, 3], [], [4, 5]]).layout.to_typetracer(forget_length=True) + assert isinstance(layout.content.data, TypeTracerArray) + assert isinstance(layout.backend, Backend) + assert isinstance(layout.backend.nplike, TypeTracer) + + # the ArrayLike protocol is still part of the concrete classes' interface + assert ArrayLike in MaterializableArray.__mro__ + assert ArrayLike in TypeTracerArray.__mro__ + + +@pytest.mark.parametrize("cls", NOMINAL_CLASSES, ids=lambda cls: cls.__name__) +def test_duck_types_are_not_instances(cls): + """A structurally compatible duck type is *not* an instance. + + This is the behaviour change: these classes are checked nominally, so + neither structural compatibility nor an explicit ``abc`` ``register()`` + makes an unrelated class an instance. Only real subclasses count. + """ + + class Duck: + known_data = True + dtype = np.dtype(np.int64) + ndim = 1 + shape = (3,) + strides = (8,) + name = "duck" + + def materialize(self): + return np.arange(3) + + assert not isinstance(Duck(), cls) + cls.register(Duck) + assert not isinstance(Duck(), cls) + assert not issubclass(Duck, cls) + + +def test_explicit_subclasses_are_still_instances(): + class MyPlaceholder(PlaceholderArray): + pass + + assert isinstance( + MyPlaceholder(Numpy.instance(), (unknown_length,), np.dtype(np.int64)), + MaterializableArray, + ) + + class MyBackend(NumpyBackend): + pass + + assert issubclass(MyBackend, Backend) + assert isinstance(MyBackend.instance(), Backend) + + class MyNumpy(Numpy): + pass + + assert issubclass(MyNumpy, ArrayModuleNumpyLike) + + +def test_operations_are_unaffected(): + array = ak.Array([[1, 2, 3], [], [4, 5]]) + assert ak.num(array).to_list() == [3, 0, 2] + assert (array * 2).to_list() == [[2, 4, 6], [], [8, 10]] + assert array[1:].to_list() == [[], [4, 5]] + assert ak.sum(array, axis=-1).to_list() == [6, 0, 9] + + tracer = ak.Array(array.layout.to_typetracer(forget_length=True)) + assert ak.num(tracer).layout.dtype == np.dtype(np.int64) + assert (tracer * 2).layout.form == (array * 2).layout.form + assert ak.sum(tracer, axis=-1).layout.form == ak.sum(array, axis=-1).layout.form