Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added multidispatch singleton decorator, and multidispatch_final #42

Merged
merged 2 commits into from
Sep 22, 2023
Merged
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
26 changes: 11 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,22 +97,20 @@ print( Person("Bad", interests=['a', 1]) )
Runtype dispatches according to the most specific type match -

```python
from runtype import Dispatch
dp = Dispatch()
from runtype import multidispatch as md

@dp
def mul(a: Any, b: Any):
return a * b
@dp
@md
def mul(a: list, b: list):
return [mul(i, j) for i, j in zip(a, b, strict=True)]
@md
def mul(a: list, b: Any):
return [ai*b for ai in a]
@dp
@md
def mul(a: Any, b: list):
return [bi*b for bi in b]
@dp
def mul(a: list, b: list):
return [mul(i, j) for i, j in zip(a, b, strict=True)]

@md
def mul(a: Any, b: Any):
return a * b

assert mul("a", 4) == "aaaa" # Any, Any
assert mul([1, 2, 3], 2) == [2, 4, 6] # list, Any
Expand All @@ -123,18 +121,16 @@ assert mul([1, 2], [3, 4]) == [3, 8] # list, list
Dispatch can also be used for extending the dataclass builtin `__init__`:

```python
dp = Dispatch()

@dataclass(frozen=False)
class Point:
x: int = 0
y: int = 0

@dp
@md
def __init__(self, points: list | tuple):
self.x, self.y = points

@dp
@md
def __init__(self, points: dict):
self.x = points['x']
self.y = points['y']
Expand Down
68 changes: 33 additions & 35 deletions docs/dispatch.rst
Original file line number Diff line number Diff line change
Expand Up @@ -62,34 +62,25 @@ Ideally, every project will instanciate Dispatch only once, in a module such as
Basic Use
---------

First, users must instanciate the `Dispatch` class, to create a dispatch group:
Multidispatch groups functions by their name. Functions of different names will never collide with each other.

::

from runtype import Dispatch
dp = Dispatch()

Then, the group can be used as a decorator for any number of functions.

Dispatch maintains the original name of every function. So, functions of different names will never collide with each other.

The order in which you define functions doesn't matter.
The order in which you define functions doesn't matter to runtype, but it's recommended to order functions from most specific to least specific.

Example:
::

dp = Dispatch()
from runtype import multidispatch as md

@dataclass(frozen=False)
class Point:
x: int = 0
y: int = 0

@dp
@md
def __init__(self, points: list | tuple):
self.x, self.y = points

@dp
@md
def __init__(self, points: dict):
self.x = points['x']
self.y = points['y']
Expand All @@ -102,6 +93,19 @@ Example:
assert p0 == Point({"x": 0, "y": 0}) # User constructor


A different dispatch object is created for each module, so collisions between different modules are impossible.

Users who want to define a dispatch across several modules, or to have more granular control, can use the Dispatch class:

::

from runtype import Dispatch
dp = Dispatch()

Then, the group can be used as a decorator for any number of functions, in any module.

Functions will still be grouped by name.


Specificity
-----------
Expand All @@ -117,11 +121,11 @@ Example:

from typing import Union

@dp
@md
def f(a: int, b: int):
return a + b

@dp
@md
def f(a: Union[int, str], b: int):
return (a, b)

Expand All @@ -147,9 +151,9 @@ Ambiguity can result from two situations:
Example:
::

>>> @dp
>>> @md
... def f(a, b: int): pass
>>> @dp
>>> @md
... def f(a: int, b): pass
>>> f(1, 1)
Traceback (most recent call last):
Expand All @@ -161,14 +165,11 @@ Dispatch is designed to always throw an error when the right choice isn't obviou
Another example:
::

from runtype import Dispatch
dp = Dispatch()

@dp
@md
def join(seq, sep: str = ''):
return sep.join(str(s) for s in seq)

@dp
@md
def join(seq, sep: list):
return join(join(sep, str(s)) for s in seq)
...
Expand All @@ -191,39 +192,36 @@ Another example:

Dispatch chooses the right function based on the idea specificity, which means that `class MyStr(str)` is more specific than `str`, and so on: `MyStr(str) < str < Union[int, str] < object`.

MyPy support (@overload)
MyPy support
------------------------

Dispatch can be made to work with the overload decorator, aiding in granular type resolution.
multidispatch works with mypy by employing the typing.overload decorator, aiding in granular type resolution.

However, due to the limited design of the overload decorator, there are several rules that need to be followed, and limitations that should be considered.

1. The overload decorator must be placed above the dispatch decorator.
1. For MyPy's benefit, more specific functions should be placed above less specific functions.

1. The last dispatched function of each function group, must be written without type declarations, and without the overload decorator. It is recommended to use this function for error handling.
2. The last dispatched function of each function group, must be written without type declarations (making it the least specific), and use the multidispatch_final decorator. It is recommended to use this function for error handling and default functionality.

3. Mypy doesn't support all of the functionality of Runtype's dispatch, such as full specificity resolution. Therefore, some valid dispatch constructs will produce an error in mypy.
Note: Mypy doesn't support all of the functionality of Runtype's dispatch, such as full specificity resolution. Therefore, some valid dispatch constructs will produce an error in mypy.


Example usage:

::

from runtype import Dispatch
from runtype import multidispatch as md, multidispatch_final as md_final
from typing import overload
dp = Dispatch()

@overload
@dp
@md
def join(seq, sep: str = ''):
return sep.join(str(s) for s in seq)

@overload
@dp
@md
def join(seq, sep: list):
return join(join(sep, str(s)) for s in seq)

@dp
@md_final
def join(seq, sep):
raise NotImplementedError()

Expand Down
63 changes: 63 additions & 0 deletions runtype/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Callable, TYPE_CHECKING

from .dataclass import dataclass
from .dispatch import DispatchError, MultiDispatch
from .validation import (PythonTyping, TypeSystem, TypeMismatchError,
Expand Down Expand Up @@ -54,3 +56,64 @@ def Dispatch(typesystem: TypeSystem = PythonTyping()):
return MultiDispatch(typesystem)



typesystem: TypeSystem = PythonTyping()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider avoiding global mutable state. It's typically better to encapsulate it within classes or use it at a function scope.


class PythonDispatch:
def __init__(self):
self.by_module = {}

def decorate(self, f: Callable) -> Callable:
"""A decorator that enables multiple-dispatch for the given function.

The dispatch namespace is unique for each module, so there can be no name
collisions for functions defined across different modules.
Users that wish to share a dispatch across modules, should use the
`Dispatch` class.

Parameters:
f (Callable): Function to enable multiple-dispatch for

Returns:
the decorated function

Example:
::

>>> from runtype import multidispatch as md

>>> @md
... def add1(i: Optional[int]):
... return i + 1

>>> @md
... def add1(s: Optional[str]):
... return s + "1"

>>> @md
... def add1(a): # accepts any type (least-specific)
... return (a, 1)

>>> add1(1)
2

>>> add1("1")
11

>>> add1(1.0)
(1.0, 1)


"""
module = f.__module__
if module not in self.by_module:
self.by_module[module] = MultiDispatch(typesystem)
return self.by_module[module](f)

python_dispatch = PythonDispatch()

multidispatch_final = python_dispatch.decorate
if TYPE_CHECKING:
from typing import overload as multidispatch
else:
multidispatch = python_dispatch.decorate
19 changes: 18 additions & 1 deletion tests/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import logging
logging.basicConfig(level=logging.INFO)

from runtype import Dispatch, DispatchError, dataclass, isa, is_subtype, issubclass, assert_isa, String, Int, validate_func, cv_type_checking
from runtype import Dispatch, DispatchError, dataclass, isa, is_subtype, issubclass, assert_isa, String, Int, validate_func, cv_type_checking, multidispatch
from runtype.dispatch import MultiDispatch
from runtype.dataclass import Configuration

Expand Down Expand Up @@ -626,7 +626,24 @@ def test_callable(self):
def test_match(self):
pass

def test_dispatch_singleton(self):
def f(a: int):
return 'a'
f.__module__ = 'a'
f1 = multidispatch(f)

def f(a: int):
return 'a'
f.__module__ = 'b'
f2 = multidispatch(f)

assert f1(1) == 'a'
assert f2(1) == 'a'

def f(a: int):
return 'a'
f.__module__ = 'a'
self.assertRaises(ValueError, multidispatch, f)

class TestDataclass(TestCase):
def setUp(self):
Expand Down
Loading