Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions src/awkward/_backends/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -21,7 +21,7 @@
KernelType: TypeAlias = "Callable[..., KernelError | None]"


class Backend(PublicSingleton, ABC):
class Backend(PublicSingleton, ABC, metaclass=NominalMeta):
name: str

@property
Expand Down
3 changes: 2 additions & 1 deletion src/awkward/_nplikes/array_like.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
Any,
DType,
EllipsisType,
NominalMeta,
Protocol,
Self,
SupportsIndex,
Expand Down Expand Up @@ -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: ...

Expand Down
13 changes: 11 additions & 2 deletions src/awkward/_nplikes/array_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions src/awkward/_nplikes/typetracer.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
EllipsisType,
Final,
Literal,
NominalMeta,
Self,
SupportsIndex,
TypeGuard,
Expand Down Expand Up @@ -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, ...]

Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions src/awkward/_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__
121 changes: 121 additions & 0 deletions tests/test_4339_nominal_isinstance.py
Original file line number Diff line number Diff line change
@@ -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
Loading