From 608f97f22cb57c6f96efa9c19e27e400cb6b289e Mon Sep 17 00:00:00 2001 From: ?ukasz Langa Date: Sat, 25 May 2013 23:22:05 +0200 Subject: initial commit --- .travis.yml | 6 + MANIFEST.in | 1 + README.rst | 167 +++++++++++++++++++++++ setup.py | 78 +++++++++++ singledispatch.py | 117 ++++++++++++++++ test_singledispatch.py | 354 +++++++++++++++++++++++++++++++++++++++++++++++++ tox.ini | 12 ++ 7 files changed, 735 insertions(+) create mode 100644 .travis.yml create mode 100644 MANIFEST.in create mode 100644 README.rst create mode 100644 setup.py create mode 100644 singledispatch.py create mode 100644 test_singledispatch.py create mode 100644 tox.ini diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..b6e1860 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,6 @@ +language: python + +before_install: + - pip install tox + +script: tox diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..9561fb1 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include README.rst diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..802ab5e --- /dev/null +++ b/README.rst @@ -0,0 +1,167 @@ +============== +singledispatch +============== + +`PEP 443 `_ proposed to expose +a mechanism in the ``functools`` standard library module in Python 3.4 +that provides a simple form of generic programming known as +single-dispatch generic functions. + +This library is a backport of this functionality to Python 2.6 - 3.3. + +To define a generic function, decorate it with the ``@singledispatch`` +decorator. Note that the dispatch happens on the type of the first +argument, create your function accordingly:: + + >>> from functools import singledispatch + >>> @singledispatch + ... def fun(arg, verbose=False): + ... if verbose: + ... print("Let me just say,", end=" ") + ... print(arg) + +To add overloaded implementations to the function, use the +``register()`` attribute of the generic function. It takes a type +parameter:: + + >>> @fun.register(int) + ... def _(arg, verbose=False): + ... if verbose: + ... print("Strength in numbers, eh?", end=" ") + ... print(arg) + ... + >>> @fun.register(list) + ... def _(arg, verbose=False): + ... if verbose: + ... print("Enumerate this:") + ... for i, elem in enumerate(arg): + ... print(i, elem) + +To enable registering lambdas and pre-existing functions, the +``register()`` attribute can be used in a functional form:: + + >>> def nothing(arg, verbose=False): + ... print("Nothing.") + ... + >>> fun.register(type(None), nothing) + +The ``register()`` attribute returns the undecorated function which +enables decorator stacking, pickling, as well as creating unit tests for +each variant independently:: + + >>> @fun.register(float) + ... @fun.register(Decimal) + ... def fun_num(arg, verbose=False): + ... if verbose: + ... print("Half of your number:", end=" ") + ... print(arg / 2) + ... + >>> fun_num is fun + False + +When called, the generic function dispatches on the first argument:: + + >>> fun("Hello, world.") + Hello, world. + >>> fun("test.", verbose=True) + Let me just say, test. + >>> fun(42, verbose=True) + Strength in numbers, eh? 42 + >>> fun(['spam', 'spam', 'eggs', 'spam'], verbose=True) + Enumerate this: + 0 spam + 1 spam + 2 eggs + 3 spam + >>> fun(None) + Nothing. + >>> fun(1.23) + 0.615 + +To get the implementation for a specific type, use the ``dispatch()`` +attribute:: + + >>> fun.dispatch(float) + + >>> fun.dispatch(dict) + + +To access all registered overloads, use the read-only ``registry`` +attribute:: + + >>> fun.registry.keys() + dict_keys([, , , + , , + ]) + >>> fun.registry[float] + + >>> fun.registry[object] + + +The vanilla documentation is available at +http://docs.python.org/3/library/functools.html#functools.singledispatch. + + +Versioning +---------- + +This backport is intended to keep 100% compatibility with the vanilla +release in Python 3.4+. To help maintaining a version you want and +expect, a versioning scheme is used where: + +* the first three numbers indicate the version of Python 3.x from which the + backport is done + +* a backport release number is provided after the last dot + +For example, ``3.4.0.0`` is the **first** release of ``singledispatch`` +compatible with the library found in Python **3.4.0**. + +A single exception from the 100% compatibility principle is that bugs +fixed before releasing another minor Python 3.x.y version **will be +included** in the backport releases done in the mean time. This rule +applies to bugs only. + + +Maintenance +----------- + +This backport is maintained on BitBucket by Łukasz Langa, one of the +members of the core CPython team: + +* `singledispatch Mercurial repository `_ + +* `singledispatch issue tracker `_ + + +Change Log +---------- + +3.4.0.0 +~~~~~~~ + +* the first public release compatible with 3.4.0 + + +Conversion Process +------------------ + +This section is technical and should bother you only if you are +wondering how this backport is produced. If the implementation details +of this backport are not important for you, feel free to ignore the +following content. + +``singledispatch`` is converted using `six +`_ so that a single codebase can be +used for all compatible Python versions. Because a fully automatic +conversion was not doable, I took the following branching approach: + +* the ``upstream`` branch holds unchanged files synchronized from the + upstream CPython repository. The synchronization is currently done by + manually copying the required code parts and stating from which + CPython changeset they come from. The tests should pass on Python 3.4 + on this branch. + +* the ``default`` branch holds the manually translated version and this + is where all tests are run for all supported Python versions using + Tox. diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..834e9f1 --- /dev/null +++ b/setup.py @@ -0,0 +1,78 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +"""This library brings functools.singledispatch from Python 3.4 to Python 2.6-3.3.""" + +# Copyright (C) 2013 by Łukasz Langa +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import os +import sys +import codecs +from setuptools import setup, find_packages + +PY3 = sys.version_info[0] == 3 + +if not PY3: + reload(sys) + sys.setdefaultencoding('utf8') + +with codecs.open( + os.path.join(os.path.dirname(__file__), 'README.rst'), 'r', 'utf8', +) as ld_file: + long_description = ld_file.read() +# We let it die a horrible tracebacking death if reading the file fails. +# We couldn't sensibly recover anyway: we need the long description. + +setup ( + name = 'singledispatch', + version = '3.4.0.0', + author = 'Łukasz Langa', + author_email = 'lukasz@langa.pl', + description = __doc__, + long_description = long_description, + url = 'http://docs.python.org/3/library/functools.html' + '#functools.singledispatch', + keywords = 'single dispatch generic functions singledispatch ' + 'genericfunctions decorator backport', + platforms = ['any'], + license = 'MIT', + py_modules = ('singledispatch',), + zip_safe = True, + install_requires = [ + 'six', + ], + classifiers = [ + 'Development Status :: 5 - Production/Stable', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Natural Language :: English', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.6', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.2', + 'Programming Language :: Python :: 3.3', + 'Topic :: Software Development :: Libraries', + 'Topic :: Software Development :: Libraries :: Python Modules', + ] +) diff --git a/singledispatch.py b/singledispatch.py new file mode 100644 index 0000000..e0de4ca --- /dev/null +++ b/singledispatch.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +__all__ = ['singledispatch'] + +from abc import get_cache_token +from functools import update_wrapper +from types import MappingProxyType +from weakref import WeakKeyDictionary + + +################################################################################ +### singledispatch() - single-dispatch generic function decorator +################################################################################ + +def _compose_mro(cls, haystack): + """Calculates the MRO for a given class `cls`, including relevant abstract + base classes from `haystack`.""" + bases = set(cls.__mro__) + mro = list(cls.__mro__) + for regcls in haystack: + if regcls in bases or not issubclass(cls, regcls): + continue # either present in the __mro__ already or unrelated + for index, base in enumerate(mro): + if not issubclass(base, regcls): + break + if base in bases and not issubclass(regcls, base): + # Conflict resolution: put classes present in __mro__ and their + # subclasses first. See test_mro_conflicts() in test_functools.py + # for examples. + index += 1 + mro.insert(index, regcls) + return mro + +def singledispatch(func): + """Single-dispatch generic function decorator. + + Transforms a function into a generic function, which can have different + behaviours depending upon the type of its first argument. The decorated + function acts as the default implementation, and additional + implementations can be registered using the 'register()' attribute of + the generic function. + + """ + registry = {} + dispatch_cache = WeakKeyDictionary() + cache_token = None + + def dispatch(cls): + """generic_func.dispatch(type) -> + + Runs the dispatch algorithm to return the best available implementation + for the given `type` registered on `generic_func`. + + """ + if cache_token is not None: + mro = _compose_mro(cls, registry.keys()) + match = None + for t in mro: + if not match: + if t in registry: + match = t + continue + if (t in registry and not issubclass(match, t) + and match not in cls.__mro__): + # `match` is an ABC but there is another unrelated, equally + # matching ABC. Refuse the temptation to guess. + raise RuntimeError("Ambiguous dispatch: {} or {}".format( + match, t)) + return registry[match] + else: + for t in cls.__mro__: + if t in registry: + return registry[t] + return func + + def wrapper(*args, **kw): + nonlocal cache_token + if cache_token is not None: + current_token = get_cache_token() + if cache_token != current_token: + dispatch_cache.clear() + cache_token = current_token + cls = args[0].__class__ + try: + impl = dispatch_cache[cls] + except KeyError: + impl = dispatch_cache[cls] = dispatch(cls) + return impl(*args, **kw) + + def register(typ, func=None): + """generic_func.register(type, func) -> func + + Registers a new overload for the given `type` on a `generic_func`. + + """ + nonlocal cache_token + if func is None: + return lambda f: register(typ, f) + registry[typ] = func + if cache_token is None and hasattr(typ, '__abstractmethods__'): + cache_token = get_cache_token() + dispatch_cache.clear() + return func + + registry[object] = func + wrapper.register = register + wrapper.dispatch = dispatch + wrapper.registry = MappingProxyType(registry) + update_wrapper(wrapper, func) + return wrapper + diff --git a/test_singledispatch.py b/test_singledispatch.py new file mode 100644 index 0000000..2d562cd --- /dev/null +++ b/test_singledispatch.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +import collections +import decimal +from itertools import permutations +import singledispatch as functools +import unittest + + +class TestSingleDispatch(unittest.TestCase): + def test_simple_overloads(self): + @functools.singledispatch + def g(obj): + return "base" + def g_int(i): + return "integer" + g.register(int, g_int) + self.assertEqual(g("str"), "base") + self.assertEqual(g(1), "integer") + self.assertEqual(g([1,2,3]), "base") + + def test_mro(self): + @functools.singledispatch + def g(obj): + return "base" + class C: + pass + class D(C): + pass + def g_C(c): + return "C" + g.register(C, g_C) + self.assertEqual(g(C()), "C") + self.assertEqual(g(D()), "C") + + def test_classic_classes(self): + @functools.singledispatch + def g(obj): + return "base" + class C: + pass + class D(C): + pass + def g_C(c): + return "C" + g.register(C, g_C) + self.assertEqual(g(C()), "C") + self.assertEqual(g(D()), "C") + + def test_register_decorator(self): + @functools.singledispatch + def g(obj): + return "base" + @g.register(int) + def g_int(i): + return "int %s" % (i,) + self.assertEqual(g(""), "base") + self.assertEqual(g(12), "int 12") + self.assertIs(g.dispatch(int), g_int) + self.assertIs(g.dispatch(object), g.dispatch(str)) + # Note: in the assert above this is not g. + # @singledispatch returns the wrapper. + + def test_wrapping_attributes(self): + @functools.singledispatch + def g(obj): + "Simple test" + return "Test" + self.assertEqual(g.__name__, "g") + self.assertEqual(g.__doc__, "Simple test") + + @unittest.skipUnless(decimal, 'requires _decimal') + def test_c_classes(self): + @functools.singledispatch + def g(obj): + return "base" + @g.register(decimal.DecimalException) + def _(obj): + return obj.args + subn = decimal.Subnormal("Exponent < Emin") + rnd = decimal.Rounded("Number got rounded") + self.assertEqual(g(subn), ("Exponent < Emin",)) + self.assertEqual(g(rnd), ("Number got rounded",)) + @g.register(decimal.Subnormal) + def _(obj): + return "Too small to care." + self.assertEqual(g(subn), "Too small to care.") + self.assertEqual(g(rnd), ("Number got rounded",)) + + def test_compose_mro(self): + c = collections + mro = functools._compose_mro + bases = [c.Sequence, c.MutableMapping, c.Mapping, c.Set] + for haystack in permutations(bases): + m = mro(dict, haystack) + self.assertEqual(m, [dict, c.MutableMapping, c.Mapping, object]) + bases = [c.Container, c.Mapping, c.MutableMapping, c.OrderedDict] + for haystack in permutations(bases): + m = mro(c.ChainMap, haystack) + self.assertEqual(m, [c.ChainMap, c.MutableMapping, c.Mapping, + c.Sized, c.Iterable, c.Container, object]) + # Note: The MRO order below depends on haystack ordering. + m = mro(c.defaultdict, [c.Sized, c.Container, str]) + self.assertEqual(m, [c.defaultdict, dict, c.Container, c.Sized, object]) + m = mro(c.defaultdict, [c.Container, c.Sized, str]) + self.assertEqual(m, [c.defaultdict, dict, c.Sized, c.Container, object]) + + def test_register_abc(self): + c = collections + d = {"a": "b"} + l = [1, 2, 3] + s = {object(), None} + f = frozenset(s) + t = (1, 2, 3) + @functools.singledispatch + def g(obj): + return "base" + self.assertEqual(g(d), "base") + self.assertEqual(g(l), "base") + self.assertEqual(g(s), "base") + self.assertEqual(g(f), "base") + self.assertEqual(g(t), "base") + g.register(c.Sized, lambda obj: "sized") + self.assertEqual(g(d), "sized") + self.assertEqual(g(l), "sized") + self.assertEqual(g(s), "sized") + self.assertEqual(g(f), "sized") + self.assertEqual(g(t), "sized") + g.register(c.MutableMapping, lambda obj: "mutablemapping") + self.assertEqual(g(d), "mutablemapping") + self.assertEqual(g(l), "sized") + self.assertEqual(g(s), "sized") + self.assertEqual(g(f), "sized") + self.assertEqual(g(t), "sized") + g.register(c.ChainMap, lambda obj: "chainmap") + self.assertEqual(g(d), "mutablemapping") # irrelevant ABCs registered + self.assertEqual(g(l), "sized") + self.assertEqual(g(s), "sized") + self.assertEqual(g(f), "sized") + self.assertEqual(g(t), "sized") + g.register(c.MutableSequence, lambda obj: "mutablesequence") + self.assertEqual(g(d), "mutablemapping") + self.assertEqual(g(l), "mutablesequence") + self.assertEqual(g(s), "sized") + self.assertEqual(g(f), "sized") + self.assertEqual(g(t), "sized") + g.register(c.MutableSet, lambda obj: "mutableset") + self.assertEqual(g(d), "mutablemapping") + self.assertEqual(g(l), "mutablesequence") + self.assertEqual(g(s), "mutableset") + self.assertEqual(g(f), "sized") + self.assertEqual(g(t), "sized") + g.register(c.Mapping, lambda obj: "mapping") + self.assertEqual(g(d), "mutablemapping") # not specific enough + self.assertEqual(g(l), "mutablesequence") + self.assertEqual(g(s), "mutableset") + self.assertEqual(g(f), "sized") + self.assertEqual(g(t), "sized") + g.register(c.Sequence, lambda obj: "sequence") + self.assertEqual(g(d), "mutablemapping") + self.assertEqual(g(l), "mutablesequence") + self.assertEqual(g(s), "mutableset") + self.assertEqual(g(f), "sized") + self.assertEqual(g(t), "sequence") + g.register(c.Set, lambda obj: "set") + self.assertEqual(g(d), "mutablemapping") + self.assertEqual(g(l), "mutablesequence") + self.assertEqual(g(s), "mutableset") + self.assertEqual(g(f), "set") + self.assertEqual(g(t), "sequence") + g.register(dict, lambda obj: "dict") + self.assertEqual(g(d), "dict") + self.assertEqual(g(l), "mutablesequence") + self.assertEqual(g(s), "mutableset") + self.assertEqual(g(f), "set") + self.assertEqual(g(t), "sequence") + g.register(list, lambda obj: "list") + self.assertEqual(g(d), "dict") + self.assertEqual(g(l), "list") + self.assertEqual(g(s), "mutableset") + self.assertEqual(g(f), "set") + self.assertEqual(g(t), "sequence") + g.register(set, lambda obj: "concrete-set") + self.assertEqual(g(d), "dict") + self.assertEqual(g(l), "list") + self.assertEqual(g(s), "concrete-set") + self.assertEqual(g(f), "set") + self.assertEqual(g(t), "sequence") + g.register(frozenset, lambda obj: "frozen-set") + self.assertEqual(g(d), "dict") + self.assertEqual(g(l), "list") + self.assertEqual(g(s), "concrete-set") + self.assertEqual(g(f), "frozen-set") + self.assertEqual(g(t), "sequence") + g.register(tuple, lambda obj: "tuple") + self.assertEqual(g(d), "dict") + self.assertEqual(g(l), "list") + self.assertEqual(g(s), "concrete-set") + self.assertEqual(g(f), "frozen-set") + self.assertEqual(g(t), "tuple") + + def test_mro_conflicts(self): + c = collections + + @functools.singledispatch + def g(arg): + return "base" + + class O(c.Sized): + def __len__(self): + return 0 + + o = O() + self.assertEqual(g(o), "base") + g.register(c.Iterable, lambda arg: "iterable") + g.register(c.Container, lambda arg: "container") + g.register(c.Sized, lambda arg: "sized") + g.register(c.Set, lambda arg: "set") + self.assertEqual(g(o), "sized") + c.Iterable.register(O) + self.assertEqual(g(o), "sized") # because it's explicitly in __mro__ + c.Container.register(O) + self.assertEqual(g(o), "sized") # see above: Sized is in __mro__ + + class P: + pass + + p = P() + self.assertEqual(g(p), "base") + c.Iterable.register(P) + self.assertEqual(g(p), "iterable") + c.Container.register(P) + with self.assertRaises(RuntimeError) as re: + g(p) + self.assertEqual( + str(re), + ("Ambiguous dispatch: " + "or "), + ) + + class Q(c.Sized): + def __len__(self): + return 0 + + q = Q() + self.assertEqual(g(q), "sized") + c.Iterable.register(Q) + self.assertEqual(g(q), "sized") # because it's explicitly in __mro__ + c.Set.register(Q) + self.assertEqual(g(q), "set") # because c.Set is a subclass of + # c.Sized which is explicitly in + # __mro__ + + def test_cache_invalidation(self): + from collections import UserDict + class TracingDict(UserDict): + def __init__(self, *args, **kwargs): + super(TracingDict, self).__init__(*args, **kwargs) + self.set_ops = [] + self.get_ops = [] + def __getitem__(self, key): + result = self.data[key] + self.get_ops.append(key) + return result + def __setitem__(self, key, value): + self.set_ops.append(key) + self.data[key] = value + def clear(self): + self.data.clear() + _orig_wkd = functools.WeakKeyDictionary + td = TracingDict() + functools.WeakKeyDictionary = lambda: td + c = collections + @functools.singledispatch + def g(arg): + return "base" + d = {} + l = [] + self.assertEqual(len(td), 0) + self.assertEqual(g(d), "base") + self.assertEqual(len(td), 1) + self.assertEqual(td.get_ops, []) + self.assertEqual(td.set_ops, [dict]) + self.assertEqual(td.data[dict], g.registry[object]) + self.assertEqual(g(l), "base") + self.assertEqual(len(td), 2) + self.assertEqual(td.get_ops, []) + self.assertEqual(td.set_ops, [dict, list]) + self.assertEqual(td.data[dict], g.registry[object]) + self.assertEqual(td.data[list], g.registry[object]) + self.assertEqual(td.data[dict], td.data[list]) + self.assertEqual(g(l), "base") + self.assertEqual(g(d), "base") + self.assertEqual(td.get_ops, [list, dict]) + self.assertEqual(td.set_ops, [dict, list]) + g.register(list, lambda arg: "list") + self.assertEqual(td.get_ops, [list, dict]) + self.assertEqual(len(td), 0) + self.assertEqual(g(d), "base") + self.assertEqual(len(td), 1) + self.assertEqual(td.get_ops, [list, dict]) + self.assertEqual(td.set_ops, [dict, list, dict]) + self.assertEqual(td.data[dict], g.dispatch(dict)) + self.assertEqual(g(l), "list") + self.assertEqual(len(td), 2) + self.assertEqual(td.get_ops, [list, dict]) + self.assertEqual(td.set_ops, [dict, list, dict, list]) + self.assertEqual(td.data[list], g.dispatch(list)) + class X: + pass + c.MutableMapping.register(X) # Will not invalidate the cache, + # not using ABCs yet. + self.assertEqual(g(d), "base") + self.assertEqual(g(l), "list") + self.assertEqual(td.get_ops, [list, dict, dict, list]) + self.assertEqual(td.set_ops, [dict, list, dict, list]) + g.register(c.Sized, lambda arg: "sized") + self.assertEqual(len(td), 0) + self.assertEqual(g(d), "sized") + self.assertEqual(len(td), 1) + self.assertEqual(td.get_ops, [list, dict, dict, list]) + self.assertEqual(td.set_ops, [dict, list, dict, list, dict]) + self.assertEqual(g(l), "list") + self.assertEqual(len(td), 2) + self.assertEqual(td.get_ops, [list, dict, dict, list]) + self.assertEqual(td.set_ops, [dict, list, dict, list, dict, list]) + self.assertEqual(g(l), "list") + self.assertEqual(g(d), "sized") + self.assertEqual(td.get_ops, [list, dict, dict, list, list, dict]) + self.assertEqual(td.set_ops, [dict, list, dict, list, dict, list]) + c.MutableSet.register(X) # Will invalidate the cache. + self.assertEqual(len(td), 2) # Stale cache. + self.assertEqual(g(l), "list") + self.assertEqual(len(td), 1) + g.register(c.MutableMapping, lambda arg: "mutablemapping") + self.assertEqual(len(td), 0) + self.assertEqual(g(d), "mutablemapping") + self.assertEqual(len(td), 1) + self.assertEqual(g(l), "list") + self.assertEqual(len(td), 2) + g.register(dict, lambda arg: "dict") + self.assertEqual(g(d), "dict") + self.assertEqual(g(l), "list") + functools.WeakKeyDictionary = _orig_wkd + + +if __name__ == '__main__': + unittest.main() diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..c0fc179 --- /dev/null +++ b/tox.ini @@ -0,0 +1,12 @@ +[tox] +envlist = py26,py27,py32,py33 + +[testenv] +changedir = test +commands = + {envbindir}/python test_singledispatch.py + +[testenv:py26] +basepython = python2.6 +deps = + unittest2 -- cgit v1.2.1