From 21ef488db4eff2c47c67fab76e0ef9b2736dafe7 Mon Sep 17 00:00:00 2001 From: Dmitry Selyutin Date: Mon, 12 Jun 2023 23:23:55 +0300 Subject: [PATCH 1/1] initial implementation --- .gitignore | 3 ++ LICENSE | 9 ++++ README.md | 97 ++++++++++++++++++++++++++++++++++++++++++ src/mdis/__init__.py | 16 +++++++ src/mdis/core.py | 48 +++++++++++++++++++++ src/mdis/dispatcher.py | 37 ++++++++++++++++ src/mdis/visitor.py | 16 +++++++ src/mdis/walker.py | 26 +++++++++++ 8 files changed, 252 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 src/mdis/__init__.py create mode 100644 src/mdis/core.py create mode 100644 src/mdis/dispatcher.py create mode 100644 src/mdis/visitor.py create mode 100644 src/mdis/walker.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6e4266f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.py[cod] +*$py.class diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2144f2c --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +Copyright 2023 Dmitry Selyutin + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9fd6422 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# About this project + +The mdis module allows to quickly extend the classes with the required functionality without modifications inside these classes. +This is done via double dispatch pattern: the dispatcher describes the required action based on the the argument type. +Use cases mainly include visitors and iterators, but are not limited to them. + +# Dispatchers + +The key concept of this module is dispatcher. A dispatcher object is a callable object; the exact call logic depends on the type of the argument. +The main class, `mdis.dispatcher.Dispatcher`, can register per-type hooks, which modify the call logic depending on the argument. +The hook is registered on a dispatcher class level as a method; attempts to call a dispatcher determine the relevant hooks based on argument type. + +# Walkers + +Sometimes objects of different types need to be traversed, but either lack `__iter__` method, or its semantics differs from what the users want. +For example, let's consider a nested collection, consisting of tuples, dicts and other types. +`dict.__iter__` method yields just keys, and the values have to be got via `__getitem__` calls. +Also, in order to support nested collections, users will have to write some recursive code with `isinstance` checks. +With `mdis.Walker`, users can just install the handlers for the types of interest, and change the iteration logic. +A walker is just a particular example of dispatcher, where the call yields some objects in a class-dependent manner. +The default `mdis.Walker` already incorporates the logic to walk over some builtin Python objects. +The example below shows how to override the way the dicts are traversed so that keys and values are swapped. + + import mdis.core + import mdis.walker + + class CustomWalker(mdis.walker.Walker): + @mdis.core.hook(dict) + def walk_dict(self, instance): + for (key, value) in instance.items(): + yield (value, key) + yield from self((value, key)) + + collection = {"a": 1, "b": 2} + walker = CustomWalker() + for item in walker(collection): + print(item) + +The following output is expected: + + (1, 'a') + 1 + a + (2, 'b') + 2 + b + +# Visitors + +In `mdis`, a visitor is just another particular example of dispatcher. +Whenever the visitor is called, the call is dispatched based on type, and some per-class action is performed. +The primary scenario is to combine the visitor calls with context managers. +The example below shows how to execute some arbitrary code upon visiting an object. + + import contextlib + + import mdis.core + import mdis.visitor + + class CustomVisitor(mdis.visitor.Visitor): + @mdis.core.hook(int) + @contextlib.contextmanager + def visit_int(self, instance): + print("entering int") + yield (instance + 42) + print("leaving int") + + @mdis.core.hook(str) + @contextlib.contextmanager + def visit_str(self, instance): + print("entering str") + yield f"!!!{instance}!!!" + print("leaving str") + + @mdis.core.hook(object) + @contextlib.contextmanager + def visit_object(self, instance): + print("entering object") + yield instance + print("leaving object") + + visitor = CustomVisitor() + for item in (42, "cocojamboo", 1.5): + with visitor(item) as result: + print(result) + +The following output is expected: + + entering int + 84 + leaving int + entering str + !!!cocojamboo!!! + leaving str + entering object + 1.5 + leaving object diff --git a/src/mdis/__init__.py b/src/mdis/__init__.py new file mode 100644 index 0000000..3b2821e --- /dev/null +++ b/src/mdis/__init__.py @@ -0,0 +1,16 @@ +"""Python multi-dispatcher library""" + +__version__ = "0.1" + +__author__ = "Dmitry Selyutin" +__copyright__ = "Copyright 2023 Dmitry Selyutin" +__license__ = "BSD" +__maintainer__ = "Dmitry Selyutin" +__email__ = "ghostmansd@gmail.com" +__status__ = "Prototype" +__all__ = [ + "core", + "dispatcher", + "visitor", + "walker", +] diff --git a/src/mdis/core.py b/src/mdis/core.py new file mode 100644 index 0000000..d1847d8 --- /dev/null +++ b/src/mdis/core.py @@ -0,0 +1,48 @@ +class TypeidHook(object): + def __init__(self, *typeids, wrapper=lambda call: call): + for typeid in typeids: + if not isinstance(typeid, type): + raise ValueError(typeid) + self.__typeids = typeids + self.__wrapper = wrapper + return super().__init__() + + def __iter__(self): + yield from self.__typeids + + def __repr__(self): + return f"{self.__class__.__name__}({self.__typeids})" + + def __call__(self, call): + if not callable(call): + raise ValueError(call) + return CallHook(typeids=self, call=call, wrapper=self.__wrapper) + + +class CallHook(object): + def __init__(self, typeids, call, wrapper=lambda call: call): + if not isinstance(typeids, TypeidHook): + raise ValueError(typeids) + if not callable(call): + raise ValueError(call) + self.__typeids = typeids + self.__call = wrapper(call) + return super().__init__() + + def __repr__(self): + return f"{self.__class__.__name__}(call={self.call!r}, typeids={self.typeids!r})" + + @property + def typeids(self): + return self.__typeids + + @property + def call(self): + return self.__call + + def __call__(self, visitor, instance): + return self.__call(visitor, instance) + + +def hook(*typeids): + return TypeidHook(*typeids) diff --git a/src/mdis/dispatcher.py b/src/mdis/dispatcher.py new file mode 100644 index 0000000..a40a1c1 --- /dev/null +++ b/src/mdis/dispatcher.py @@ -0,0 +1,37 @@ +from . import core as _core + + +class DispatcherMeta(type): + @classmethod + def dispatch(metacls, typeid=object): + module = typeid.__module__ + qualname = typeid.__qualname__ + if module == "builtins": + return qualname + return f"{module}.{qualname}".replace(".", "_") + + def __new__(metacls, name, bases, ns): + hooks = {} + + for (key, value) in tuple(ns.items()): + if isinstance(value, _core.CallHook): + hook = ns.pop(key) + for typeid in hook.typeids: + site = metacls.dispatch(typeid) + hooks[typeid] = (hook, site) + + for (typeid, (hook, site)) in tuple(hooks.items()): + ns[site] = hook + + return super().__new__(metacls, name, bases, ns) + + +class Dispatcher(metaclass=DispatcherMeta): + def __call__(self, instance): + nil = object() + for typeid in instance.__class__.__mro__: + site = self.__class__.dispatch(typeid) + call = getattr(self, site, nil) + if call is not nil: + return call(self, instance) + return getattr(self, self.__class__.dispatch()) diff --git a/src/mdis/visitor.py b/src/mdis/visitor.py new file mode 100644 index 0000000..bbdc042 --- /dev/null +++ b/src/mdis/visitor.py @@ -0,0 +1,16 @@ +import contextlib as _contextlib + +from . import core as _core +from . import dispatcher as _dispatcher + + +class VisitorMeta(_dispatcher.DispatcherMeta): + @classmethod + def dispatch(metacls, typeid): + return ("visit_" + super().dispatch(typeid)) + + +class Visitor(_dispatcher.Dispatcher, metaclass=VisitorMeta): + @_core.hook(object) + def object(self, instance): + yield instance diff --git a/src/mdis/walker.py b/src/mdis/walker.py new file mode 100644 index 0000000..c60d71a --- /dev/null +++ b/src/mdis/walker.py @@ -0,0 +1,26 @@ +from . import core as _core +from . import dispatcher as _dispatcher + + +class WalkerMeta(_dispatcher.DispatcherMeta): + @classmethod + def dispatch(metacls, typeid): + return ("walk_" + super().dispatch(typeid)) + + +class Walker(_dispatcher.Dispatcher, metaclass=WalkerMeta): + @_core.hook(tuple, list, set, frozenset) + def sequence(self, instance): + for item in instance: + yield item + yield from self(item) + + @_core.hook(dict) + def mapping(self, instance): + for (key, value) in instance.items(): + yield (key, value) + yield from self((key, value)) + + @_core.hook(object) + def object(self, instance): + yield from () -- 2.30.2