├── .gitignore ├── .travis.yml ├── LICENSE ├── README.rst ├── setup.py ├── src └── singledispatchmethod.py ├── tests ├── test_singledispatchmethod.py └── test_singledispatchmethod_py3_only.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Backup files 2 | *.~ 3 | 4 | # Byte-compiles / optimizied 5 | __pycache__/ 6 | *.py[cod] 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | bin/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # Installer logs 29 | pip-log.txt 30 | pip-delete-this-directory.txt 31 | 32 | # Unit test / coverage reports 33 | .tox/ 34 | .coverage 35 | .coverage.* 36 | .cache 37 | .pytest_cache 38 | nosetests.xml 39 | coverage.xml 40 | 41 | # Translations 42 | *.mo 43 | 44 | # Ignore sublime's project (for fans) 45 | .ropeproject/ 46 | *.sublime-project 47 | *.sublime-workspace 48 | 49 | # Ignore virtualenvs (who places it near) 50 | .venv/ 51 | 52 | # Various shit from the OS itself 53 | .DS_Store 54 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: python 3 | python: "3.7" 4 | cache: pip 5 | 6 | install: 7 | - python -m pip install tox 8 | 9 | script: 10 | - tox 11 | 12 | stages: 13 | - name: lint 14 | - name: test 15 | - name: pypi 16 | if: tag IS present 17 | 18 | jobs: 19 | include: 20 | - stage: lint 21 | env: TOXENV=flake8 22 | python: "3.7" 23 | 24 | - stage: lint 25 | env: TOXENV=black 26 | python: "3.7" 27 | 28 | - stage: lint 29 | env: TOXENV=metadata 30 | python: "3.7" 31 | 32 | - stage: test 33 | env: TOXENV=py27 34 | python: "2.7" 35 | script: tox -- --strict --ignore-glob "*_py3_only.py" 36 | 37 | - stage: test 38 | env: TOXENV=py35 39 | python: "3.5" 40 | 41 | - stage: test 42 | env: TOXENV=py36 43 | python: "3.6" 44 | 45 | - stage: test 46 | env: TOXENV=py37 47 | python: "3.7" 48 | 49 | - stage: test 50 | env: TOXENV=py38 51 | python: "3.8-dev" 52 | 53 | - stage: test 54 | env: TOXENV=pypy 55 | python: "pypy" 56 | script: tox -- --strict --ignore-glob "*_py3_only.py" 57 | 58 | - stage: test 59 | env: TOXENV=pypy3 60 | python: "pypy3" 61 | 62 | - stage: pypi 63 | deploy: 64 | - provider: pypi 65 | user: $PYPI_USERNAME 66 | password: $PYPI_PASSWORD 67 | on: 68 | tags: true 69 | distributions: sdist bdist_wheel --universal 70 | install: skip 71 | script: skip 72 | 73 | notifications: 74 | on_success: change 75 | on_failure: always 76 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 2 | -------------------------------------------- 3 | 4 | 1. This LICENSE AGREEMENT is between the Python Software Foundation 5 | ("PSF"), and the Individual or Organization ("Licensee") accessing and 6 | otherwise using this software ("Python") in source or binary form and 7 | its associated documentation. 8 | 9 | 2. Subject to the terms and conditions of this License Agreement, PSF 10 | hereby grants Licensee a nonexclusive, royalty-free, world-wide license 11 | to reproduce, analyze, test, perform and/or display publicly, prepare 12 | derivative works, distribute, and otherwise use Python alone or in any 13 | derivative version, provided, however, that PSF's License Agreement and 14 | PSF's notice of copyright, i.e., "Copyright (c) 2001, 2002, 2003, 2004, 15 | 2005, 2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016, 16 | 2017, 2018, 2019 Python Software Foundation; All Rights Reserved" are 17 | retained in Python alone or in any derivative version prepared by 18 | Licensee. 19 | 20 | 3. In the event Licensee prepares a derivative work that is based on or 21 | incorporates Python or any part thereof, and wants to make the 22 | derivative work available to others as provided herein, then Licensee 23 | hereby agrees to include in any such work a brief summary of the changes 24 | made to Python. 25 | 26 | 4. PSF is making Python available to Licensee on an "AS IS" basis. PSF 27 | MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED. BY WAY OF 28 | EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND DISCLAIMS ANY 29 | REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS FOR ANY 30 | PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT INFRINGE ANY THIRD 31 | PARTY RIGHTS. 32 | 33 | 5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON FOR 34 | ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS A RESULT OF 35 | MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, OR ANY DERIVATIVE 36 | THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. 37 | 38 | 6. This License Agreement will automatically terminate upon a material 39 | breach of its terms and conditions. 40 | 41 | 7. Nothing in this License Agreement shall be deemed to create any 42 | relationship of agency, partnership, or joint venture between PSF and 43 | Licensee. This License Agreement does not grant permission to use PSF 44 | trademarks or trade name in a trademark sense to endorse or promote 45 | products or services of Licensee, or any third party. 46 | 47 | 8. By copying, installing or otherwise using Python, Licensee agrees to 48 | be bound by the terms and conditions of this License Agreement. 49 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | singledispatchmethod 2 | ==================== 3 | 4 | Backport of ``@functools.singledispatchmethod`` decorator [1]_ from 5 | Python 3.8 to Python 2.7-3.7. These are merely ~30 lines of code, but 6 | why bother yourself with copypasta? 7 | 8 | .. code:: bash 9 | 10 | $ pip install singledispatchmethod 11 | 12 | The decorator transforms a method into a single-dispatch [2]_ generic 13 | function [3]_. Note that since the dispatch happens on the type of the 14 | first non-self or non-cls argument, you have to create your function 15 | accordingly: 16 | 17 | .. code:: python 18 | 19 | from singledispatchmethod import singledispatchmethod 20 | 21 | class Negator: 22 | 23 | @singledispatchmethod 24 | def neg(self, arg): 25 | raise NotImplementedError("Cannot negate a") 26 | 27 | @neg.register 28 | def _(self, arg: int): 29 | return -arg 30 | 31 | @neg.register 32 | def _(self, arg: bool): 33 | return not arg 34 | 35 | ``@singledispatchmethod`` supports nesting with other decorators such as 36 | ``@classmethod``. However, in order to expose ``dispatcher.register``, 37 | ``@singledispatchmethod`` must be the *outer most* decorator. Here is 38 | the ``Negator`` class with the ``neg`` methods being class bound: 39 | 40 | .. code:: python 41 | 42 | from singledispatchmethod import singledispatchmethod 43 | 44 | class Negator: 45 | 46 | @singledispatchmethod 47 | @classmethod 48 | def neg(cls, arg): 49 | raise NotImplementedError("Cannot negate a") 50 | 51 | @neg.register 52 | @classmethod 53 | def _(cls, arg: int): 54 | return -arg 55 | 56 | @neg.register 57 | @classmethod 58 | def _(cls, arg: bool): 59 | return not arg 60 | 61 | The same pattern can be used for other similar decorators, such as 62 | ``@staticmethod`` or ``@abstractmethod``. Please note, since 63 | ``@singledispatchmethod`` decorator is based on 64 | ``@functools.singledispatch``, type annotations are supported by 65 | ``dispatcher.register`` only since Python 3.7. 66 | 67 | .. [1] https://docs.python.org/3.8/library/functools.html#functools.singledispatchmethod 68 | .. [2] https://docs.python.org/3.8/glossary.html#term-single-dispatch 69 | .. [3] https://docs.python.org/3.8/glossary.html#term-generic-function 70 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Backport of @functools.singledispatchmethod to Python 2.7-3.7.""" 2 | 3 | import os 4 | import io 5 | import setuptools 6 | 7 | 8 | here = os.path.dirname(__file__) 9 | 10 | with io.open(os.path.join(here, "README.rst"), "r", encoding="UTF-8") as f: 11 | long_description = f.read() 12 | 13 | 14 | setuptools.setup( 15 | name="singledispatchmethod", 16 | description=__doc__, 17 | long_description=long_description, 18 | long_description_content_type="text/x-rst", 19 | url="https://github.com/ikalnytskyi/singledispatchmethod", 20 | license="MIT", 21 | author="Ihor Kalnytskyi", 22 | author_email="ihor@kalnytskyi.com", 23 | py_modules=["singledispatchmethod"], 24 | package_dir={"": "src"}, 25 | use_scm_version={"root": here}, 26 | setup_requires=["setuptools_scm"], 27 | install_requires=['singledispatch; python_version < "3.4"'], 28 | project_urls={ 29 | "Documentation": ( 30 | "https://docs.python.org/3.8/library/functools.html" 31 | "#functools.singledispatchmethod" 32 | ), 33 | "Source": "https://github.com/ikalnytskyi/singledispatchmethod", 34 | "Bugs": "https://github.com/ikalnytskyi/singledispatchmethod/issues", 35 | }, 36 | classifiers=[ 37 | "Development Status :: 5 - Production/Stable", 38 | "Intended Audience :: Developers", 39 | "License :: OSI Approved :: Python Software Foundation License", 40 | "Operating System :: OS Independent", 41 | "Programming Language :: Python", 42 | "Programming Language :: Python :: 2", 43 | "Programming Language :: Python :: 2.7", 44 | "Programming Language :: Python :: 3", 45 | "Programming Language :: Python :: 3.5", 46 | "Programming Language :: Python :: 3.6", 47 | "Programming Language :: Python :: 3.7", 48 | "Programming Language :: Python :: 3.8", 49 | "Programming Language :: Python :: Implementation :: CPython", 50 | "Programming Language :: Python :: Implementation :: PyPy", 51 | "Topic :: Software Development :: Libraries", 52 | ], 53 | ) 54 | -------------------------------------------------------------------------------- /src/singledispatchmethod.py: -------------------------------------------------------------------------------- 1 | """Backport of @functools.singledispatchmethod to Python 2.7-3.7.""" 2 | 3 | import functools 4 | import sys 5 | 6 | 7 | if sys.version_info[0] > 2: 8 | update_wrapper = functools.update_wrapper 9 | singledispatch = functools.singledispatch 10 | else: 11 | import singledispatch as _singledispatch 12 | 13 | def update_wrapper( 14 | wrapper, 15 | wrapped, 16 | assigned=functools.WRAPPER_ASSIGNMENTS, 17 | updated=functools.WRAPPER_UPDATES, 18 | ): 19 | """Backport of Python 3's `functools.update_wrapper`.""" 20 | 21 | for attr in assigned: 22 | try: 23 | value = getattr(wrapped, attr) 24 | except AttributeError: 25 | pass 26 | else: 27 | setattr(wrapper, attr, value) 28 | for attr in updated: 29 | getattr(wrapper, attr).update(getattr(wrapped, attr, {})) 30 | wrapper.__wrapped__ = wrapped 31 | return wrapper 32 | 33 | @functools.wraps(_singledispatch.singledispatch) 34 | def singledispatch(*args, **kwargs): 35 | """Singledispatch that works with @classmethod and @staticmethod.""" 36 | 37 | try: 38 | rv = _singledispatch.singledispatch(*args, **kwargs) 39 | except AttributeError: 40 | # Due to imperfection in Python 2, functools.update_wrapper 41 | # may raise an AttributeError exception when applied to 42 | # @classmethod or @staticmethod. If that happened, the best 43 | # we can do is try one more time using a 44 | # functools.update_wrapper from Python 3 where this issue 45 | # does not exist anymore. 46 | _update_wrapper = _singledispatch.update_wrapper 47 | _singledispatch.update_wrapper = update_wrapper 48 | rv = _singledispatch.singledispatch(*args, **kwargs) 49 | _singledispatch.update_wrapper = _update_wrapper 50 | return rv 51 | 52 | 53 | class singledispatchmethod(object): 54 | """Single-dispatch generic method descriptor.""" 55 | 56 | def __init__(self, func): 57 | if not callable(func) and not hasattr(func, "__get__"): 58 | raise TypeError("{!r} is not callable or a descriptor".format(func)) 59 | 60 | self.dispatcher = singledispatch(func) 61 | self.func = func 62 | 63 | def register(self, cls, method=None): 64 | return self.dispatcher.register(cls, func=method) 65 | 66 | def __get__(self, obj, cls): 67 | def _method(*args, **kwargs): 68 | method = self.dispatcher.dispatch(args[0].__class__) 69 | return method.__get__(obj, cls)(*args, **kwargs) 70 | 71 | _method.__isabstractmethod__ = self.__isabstractmethod__ 72 | _method.register = self.register 73 | update_wrapper(_method, self.func) 74 | return _method 75 | 76 | @property 77 | def __isabstractmethod__(self): 78 | return getattr(self.func, "__isabstractmethod__", False) 79 | -------------------------------------------------------------------------------- /tests/test_singledispatchmethod.py: -------------------------------------------------------------------------------- 1 | """Test @singledispatchmethod decorator.""" 2 | 3 | import abc 4 | import unittest 5 | 6 | import pytest 7 | 8 | from singledispatchmethod import singledispatchmethod 9 | 10 | 11 | class TestSingleDispatchMethod(unittest.TestCase): 12 | """Backported tests from CPython source tree.""" 13 | 14 | def test_method_register(self): 15 | class A(object): 16 | @singledispatchmethod 17 | def t(self, arg): 18 | self.arg = "base" 19 | 20 | @t.register(int) 21 | def _(self, arg): 22 | self.arg = "int" 23 | 24 | @t.register(str) 25 | def _(self, arg): 26 | self.arg = "str" 27 | 28 | a = A() 29 | 30 | a.t(0) 31 | self.assertEqual(a.arg, "int") 32 | aa = A() 33 | self.assertFalse(hasattr(aa, "arg")) 34 | a.t("") 35 | self.assertEqual(a.arg, "str") 36 | aa = A() 37 | self.assertFalse(hasattr(aa, "arg")) 38 | a.t(0.0) 39 | self.assertEqual(a.arg, "base") 40 | aa = A() 41 | self.assertFalse(hasattr(aa, "arg")) 42 | 43 | def test_staticmethod_register(self): 44 | class A(object): 45 | @singledispatchmethod 46 | @staticmethod 47 | def t(arg): 48 | return arg 49 | 50 | @t.register(int) 51 | @staticmethod 52 | def _(arg): 53 | return isinstance(arg, int) 54 | 55 | @t.register(str) 56 | @staticmethod 57 | def _(arg): 58 | return isinstance(arg, str) 59 | 60 | self.assertTrue(A.t(0)) 61 | self.assertTrue(A.t("")) 62 | self.assertEqual(A.t(0.0), 0.0) 63 | 64 | def test_classmethod_register(self): 65 | class A(object): 66 | def __init__(self, arg): 67 | self.arg = arg 68 | 69 | @singledispatchmethod 70 | @classmethod 71 | def t(cls, arg): 72 | return cls("base") 73 | 74 | @t.register(int) 75 | @classmethod 76 | def _(cls, arg): 77 | return cls("int") 78 | 79 | @t.register(str) 80 | @classmethod 81 | def _(cls, arg): 82 | return cls("str") 83 | 84 | self.assertEqual(A.t(0).arg, "int") 85 | self.assertEqual(A.t("").arg, "str") 86 | self.assertEqual(A.t(0.0).arg, "base") 87 | 88 | def test_callable_register(self): 89 | class A(object): 90 | def __init__(self, arg): 91 | self.arg = arg 92 | 93 | @singledispatchmethod 94 | @classmethod 95 | def t(cls, arg): 96 | return cls("base") 97 | 98 | @A.t.register(int) 99 | @classmethod 100 | def _(cls, arg): 101 | return cls("int") 102 | 103 | @A.t.register(str) 104 | @classmethod 105 | def _(cls, arg): 106 | return cls("str") 107 | 108 | self.assertEqual(A.t(0).arg, "int") 109 | self.assertEqual(A.t("").arg, "str") 110 | self.assertEqual(A.t(0.0).arg, "base") 111 | 112 | def test_abstractmethod_register(self): 113 | class Abstract(abc.ABCMeta): 114 | @singledispatchmethod 115 | @abc.abstractmethod 116 | def add(self, x, y): 117 | pass 118 | 119 | self.assertTrue(Abstract.add.__isabstractmethod__) 120 | 121 | 122 | def test_type_not_callable_not_descriptor(): 123 | with pytest.raises(TypeError) as excinfo: 124 | singledispatchmethod(42) 125 | assert str(excinfo.value) == "42 is not callable or a descriptor" 126 | 127 | with pytest.raises(TypeError) as excinfo: 128 | singledispatchmethod(True) 129 | assert str(excinfo.value) == "True is not callable or a descriptor" 130 | -------------------------------------------------------------------------------- /tests/test_singledispatchmethod_py3_only.py: -------------------------------------------------------------------------------- 1 | """Test py3-only features of @singledispatchmethod decorator.""" 2 | 3 | import sys 4 | import unittest 5 | 6 | import pytest 7 | 8 | from singledispatchmethod import singledispatchmethod 9 | 10 | 11 | class TestSingleDispatchMethod(unittest.TestCase): 12 | """Backported tests from CPython source tree.""" 13 | 14 | @pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7+") 15 | def test_type_ann_register(self): 16 | class A(object): 17 | @singledispatchmethod 18 | def t(self, arg): 19 | return "base" 20 | 21 | @t.register 22 | def _(self, arg: int): 23 | return "int" 24 | 25 | @t.register 26 | def _(self, arg: str): 27 | return "str" 28 | 29 | a = A() 30 | 31 | self.assertEqual(a.t(0), "int") 32 | self.assertEqual(a.t(""), "str") 33 | self.assertEqual(a.t(0.0), "base") 34 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = flake8, black, py3, py2, metadata 3 | 4 | [testenv] 5 | deps = pytest 6 | commands = 7 | {envpython} -m pytest -vv --strict {posargs:} 8 | 9 | [testenv:metadata] 10 | deps = twine 11 | commands = 12 | {envpython} setup.py sdist bdist_wheel --universal 13 | {envpython} -m twine check dist/* 14 | 15 | [testenv:flake8] 16 | deps = flake8 17 | skip_install = true 18 | commands = 19 | {envpython} -m flake8 --max-line-length=88 {posargs:} 20 | 21 | [testenv:black] 22 | deps = black 23 | skip_install = true 24 | commands = 25 | {envpython} -m black --check --diff . 26 | --------------------------------------------------------------------------------