Repository: beartype/plum

Issue:
`parametric` conflicts with certain usages of customized `__init_subclass__`
Hi!

I'm using JAX, and also using `plum` -- in my library, I've define a mixin class called `Pytree` which automatically implements the Pytree interface for classes which mix it in.

It's quite simple:

```python
class Pytree:
    def __init_subclass__(cls, **kwargs):
        jtu.register_pytree_node(
            cls,
            cls.flatten,
            cls.unflatten,
        )

```

If I wish to use this mixin, and `parametric` -- I'm in for problems, I get duplicate registration:

```
ERROR ... ValueError: Duplicate custom PyTreeDef type registration for <class...>
```

I'm not exactly sure why this occurs, but I'm hoping to find a fix -- because I'd like to use parametric classes to guide some of the dispatch in my library functions.

Discussion:
wesselb: @femtomc, would you be able to check if this PR fixes the covariance issues you're experiencing?
coveralls: ## Pull Request Test Coverage Report for [Build 6282521614](https://coveralls.io/builds/62852262)

* **7** of **7**   **(100.0%)**  changed or added relevant lines in **1** file are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage remained the same at **100.0%**

---



|  Totals | [![Coverage Status](https://coveralls.io/builds/62852262/badge)](https://coveralls.io/builds/62852262) |
| :-- | --: |
| Change from base [Build 6282511977](https://coveralls.io/builds/62852254): |  0.0% |
| Covered Lines: | 942 |
| Relevant Lines: | 942 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 59df964..bb6f738 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,8 +1,11 @@
 name: CI
 
 on:
-  - push
-  - pull_request
+  pull_request:
+
+  push:
+    branches:
+      - master
 
 jobs:
   test:
@@ -25,24 +28,23 @@ jobs:
       - name: Test linter assertions
         run: |
           python check_linter_assertions.py tests/typechecked
-      - name: Test
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          COVERALLS_FLAG_NAME: ${{ matrix.python-version }}
-          COVERALLS_PARALLEL: true
+      - name: Test py${{ matrix.python-version }}
         run: |
           PRAGMA_VERSION=`python -c "import sys; print('.'.join(map(str, sys.version_info[:2])))"` \
             pytest -v --cov=plum --cov-report term-missing
-          coveralls --service=github
-  coveralls:
+      - name: Coveralls Parallel
+        uses: coverallsapp/github-action@v2
+        with:
+          flag-name: run-${{ join(matrix.*, '-') }}
+          parallel: true
+
+  finish:
     name: Finish coverage
     needs: test
+    if: ${{ always() }}
     runs-on: ubuntu-latest
-    container: python:3-slim
     steps:
-    - name: Finished
-      run: |
-        pip3 install --upgrade coveralls
-        coveralls --finish
-      env:
-        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+    - name: Coveralls Finished
+      uses: coverallsapp/github-action@v2
+      with:
+        parallel-finished: true
diff --git a/.github/workflows/formatting_check.yml b/.github/workflows/formatting_check.yml
index 5be7b36..2415945 100644
--- a/.github/workflows/formatting_check.yml
+++ b/.github/workflows/formatting_check.yml
@@ -1,7 +1,11 @@
 name: Check Formatting
 on:
-  - push
-  - pull_request
+  pull_request:
+
+  push:
+    branches:
+      - master
+
 
 jobs:
   check:
diff --git a/plum/dispatcher.py b/plum/dispatcher.py
index bf018c3..0f2b5ad 100644
--- a/plum/dispatcher.py
+++ b/plum/dispatcher.py
@@ -1,4 +1,4 @@
-from typing import Any, Callable, Dict, Optional, Tuple, TypeVar, Union
+from typing import Any, Dict, Optional, Tuple, TypeVar, Union
 
 from .function import Function
 from .overload import get_overloads
diff --git a/plum/function.py b/plum/function.py
index 15b4b9c..df87cc4 100644
--- a/plum/function.py
+++ b/plum/function.py
@@ -1,5 +1,6 @@
 import os
 import textwrap
+from copy import copy
 from functools import wraps
 from types import MethodType
 from typing import Any, Callable, List, Optional, Tuple, TypeVar, Union
@@ -281,7 +282,7 @@ class Function(metaclass=_FunctionMeta):
             else:
                 # Ensure that the implementation is `f`, but make a copy before
                 # mutating.
-                signature = signature.__copy__()
+                signature = copy(signature)
                 signature.implementation = f
 
             # Ensure that the implementation has the right name, because this name
@@ -343,9 +344,12 @@ class Function(metaclass=_FunctionMeta):
             return_type = signature.return_type
 
         except AmbiguousLookupError as e:
-            raise self._enhance_exception(e)  # Specify this function.
+            __tracebackhide__ = True
+            raise self._enhance_exception(e) from None  # Specify this function.
 
         except NotFoundLookupError as e:
+            __tracebackhide__ = True
+
             e = self._enhance_exception(e)  # Specify this function.
             method, return_type = self._handle_not_found_lookup_error(e)
 
@@ -356,7 +360,7 @@ class Function(metaclass=_FunctionMeta):
     ) -> Tuple[Callable, TypeHint]:
         if not self.owner:
             # Not in a class. Nothing we can do.
-            raise ex
+            raise ex from None
 
         # In a class. Walk through the classes in the class's MRO, except for this
         # class, and try to get the method.
@@ -391,10 +395,11 @@ class Function(metaclass=_FunctionMeta):
         if not method:
             # If no method has been found after walking through the MRO, raise the
             # original exception.
-            raise ex
+            raise ex from None
         return method, return_type
 
     def __call__(self, *args, **kw_args):
+        __tracebackhide__ = True
         method, return_type = self._resolve_method_with_cache(args=args)
         return _convert(method(*args, **kw_args), return_type)
 
@@ -420,6 +425,8 @@ class Function(metaclass=_FunctionMeta):
         try:
             return self._cache[types]
         except KeyError:
+            __tracebackhide__ = True
+
             if args is None:
                 args = Signature(*(resolve_type_hint(t) for t in types))
 
diff --git a/plum/parametric.py b/plum/parametric.py
index 12aafa3..4592249 100644
--- a/plum/parametric.py
+++ b/plum/parametric.py
@@ -144,6 +144,18 @@ class CovariantMeta(ParametricTypeMeta):
         # Default behaviour to `type`s subclass check.
         return type.__subclasscheck__(cls, subclass)
 
+    def __instancecheck__(cls, instance):
+        # If `A` is a parametric type, then `A[T1]` and `A[T2]` are subclasses of
+        # `A`. With the implementation of `__subclasscheck__` above, we have that
+        # `issubclass(A[T1], A[T2])` whenever `issubclass(T1, T2)`. _However_,
+        # `isinstance(A[T1](), A[T2])` will fall back to `type.__ininstance__`, which
+        # will conclude that `A[T1]` is not a subclass of `A[T2]` because it bypasses
+        # the above implementation of `__subclasscheck__`. We therefore implement
+        # `__instancecheck__` to ensure that `isinstance(A[T1](), A[T2])` whenever
+        # `issubclass(T1, T2)`. In any case, we do first try `type.__instancecheck__`,
+        # since it is fast and only gives true positives.
+        return type.__instancecheck__(cls, instance) or issubclass(type(instance), cls)
+
     def __le_type_parameter__(cls, p_left, p_right):
         # Check that there are an equal number of parameters.
         if len(p_left) != len(p_right):
@@ -196,7 +208,26 @@ def parametric(original_class=None):
         cls = cls.__concrete_class__(*args, **kw_args)
         return original_meta.__call__(cls, *args, **kw_args)
 
-    meta = type(name, bases, {"__call__": __call__})
+    def __instancecheck__(cls, instance):
+        # An implementation of `__instancecheck__` is necessary to ensure that
+        # `isinstance(A[SubType](), A[Type])`. `CovariantMeta` comes first in the MRO,
+        # but the implementation of `__instancecheck__` should be taken from
+        # `original_meta` if it exists. The implementation of `CovariantMeta` should be
+        # used as a fallback. Note that `original_meta.__instancecheck__` always exists.
+        # We check that it is not equal to the default `type.__instancecheck__`.
+        if original_meta.__instancecheck__ != type.__instancecheck__:
+            return original_meta.__instancecheck__(cls, instance)
+        else:
+            return CovariantMeta.__instancecheck__(cls, instance)
+
+    meta = type(
+        name,
+        bases,
+        {
+            "__call__": __call__,
+            "__instancecheck__": __instancecheck__,
+        },
+    )
 
     subclasses = {}
 
diff --git a/plum/promotion.py b/plum/promotion.py
index c34f8ae..58df9c1 100644
--- a/plum/promotion.py
+++ b/plum/promotion.py
@@ -126,7 +126,7 @@ def add_promotion_rule(type1, type2, type_to):
     # If the types are the same, the method will get overwritten.
 
     @_promotion_rule.dispatch
-    def rule(t1: type2, t2: type1):
+    def rule(t1: type2, t2: type1):  # noqa: F811
         return type_to
 
 
@@ -161,11 +161,11 @@ def promote(obj1, obj2, *objs):
 
 
 @_dispatch
-def promote(obj: object):
+def promote(obj: object):  # noqa: F811
     # Promote should always return a tuple to avoid edge cases.
     return (obj,)
 
 
 @_dispatch
-def promote():
+def promote():  # noqa: F811
     return ()
diff --git a/plum/signature.py b/plum/signature.py
index 5d291e3..e651318 100644
--- a/plum/signature.py
+++ b/plum/signature.py
@@ -1,6 +1,7 @@
 import inspect
 import operator
 import typing
+from copy import copy
 from typing import Callable, List, Optional, Tuple
 
 import beartype.door
@@ -20,7 +21,7 @@ class Signature(Comparable):
         *types (tuple[type, ...]): Types of the arguments.
         varargs (type, optional): Type of the variable arguments.
         return_type (type, optional): Type of the return value. Defaults to `Any`.
-        precedence (int, optional): Precedence. Default to `0`.
+        precedence (int, optional): Precedence. Defaults to `0`.
         implementation (function, optional): Implementation.
 
     Attributes:
@@ -286,15 +287,15 @@ def append_default_args(signature: Signature, f: Callable) -> List[Signature]:
         if p.kind == p.VAR_POSITIONAL:
             continue
 
-        copy = signatures[-1].__copy__()
+        signature_copy = copy(signatures[-1])
 
         # As specified over, these additional signatures should never have variable
         # arguments.
-        copy.varargs = Missing
+        signature_copy.varargs = Missing
 
         # Remove the last positional argument.
-        copy.types = copy.types[:-1]
+        signature_copy.types = signature_copy.types[:-1]
 
-        signatures.append(copy)
+        signatures.append(signature_copy)
 
     return signatures
diff --git a/plum/util.py b/plum/util.py
index fbce29c..4c9629a 100644
--- a/plum/util.py
+++ b/plum/util.py
@@ -1,7 +1,7 @@
 import abc
 import sys
 import typing
-from typing import Callable, List
+from typing import List
 
 if sys.version_info.minor <= 8:  # pragma: specific no cover 3.9 3.10 3.11
     from typing import Callable
diff --git a/pyproject.toml b/pyproject.toml
index c6728a0..a0247c9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -38,7 +38,7 @@ dev = [
     "tox",
     "jupyter-book",
     "mypy",
-    "pyright",
+    "pyright>=1.1.323",
 ]
 
 [project.urls]


Test patch:
diff --git a/tests/test_parametric.py b/tests/test_parametric.py
index 535819d..35618ab 100644
--- a/tests/test_parametric.py
+++ b/tests/test_parametric.py
@@ -159,22 +159,68 @@ def test_parametric_covariance():
 
     # Test covariance.
     assert issubclass(A[int], A[Number])
+    assert isinstance(A[int](), A[Number])
     assert not issubclass(A[int], A[float])
+    assert not isinstance(A[int](), A[float])
 
     # Check that the number of arguments must be right.
     assert not issubclass(A[int], A[Number, Number])
+    assert not isinstance(A[int](), A[Number, Number])
     assert issubclass(A[int, int], A[Number, Number])
+    assert isinstance(A[int, int](), A[Number, Number])
     assert not issubclass(A[int, int, int], A[Number, Number])
+    assert not isinstance(A[int, int, int](), A[Number, Number])
 
     # Test that type parameters are resolved.
     assert issubclass(
         A[ModuleType("builtins", "int")],
         A[ModuleType("numbers", "Number")],
     )
+    assert isinstance(
+        A[ModuleType("builtins", "int")](),
+        A[ModuleType("numbers", "Number")],
+    )
 
     # Check a mix between equatable objects and types.
     assert issubclass(A[1, int], A[1, Number])
+    assert isinstance(A[1, int](), A[1, Number])
     assert not issubclass(A[2, int], A[1, Number])
+    assert not isinstance(A[2, int](), A[1, Number])
+
+
+def test_parametric_covariance_test_case():
+    @parametric
+    class A:
+        def __init__(self, x):
+            self.x = x
+
+    dispatch = Dispatcher()
+
+    @dispatch
+    def f(a: object):
+        return "fallback"
+
+    @dispatch
+    def f(a: A):
+        return "A"
+
+    @dispatch
+    def f(a: A[Number]):
+        return "Number"
+
+    @dispatch
+    def f(a: A[int]):
+        return "int"
+
+    @dispatch
+    def f(a: A[float]):
+        return "int"
+
+    assert f(1j) == "fallback"
+    assert f(A(object())) == "A"
+    assert f(A(1j)) == "Number"
+    assert f(A(1)) == "int"
+    assert f(A(1.0)) == "int"
 
 
 def test_parametric_constructor():