Skip to content

Instantly share code, notes, and snippets.

@outofmbufs
Created August 19, 2024 22:27
Show Gist options
  • Save outofmbufs/ac3bdc0ac049b51450880307f598e3f5 to your computer and use it in GitHub Desktop.
Save outofmbufs/ac3bdc0ac049b51450880307f598e3f5 to your computer and use it in GitHub Desktop.
DecoClass: base class for implementing decorators as class objects in python
# MIT License
#
# Copyright (c) 2024 Neil Webber
#
# 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 functools
import types
# A DecoClass abstracts away a few details that are important to get correct
# when implementing decorators as a class, with callable wrapper objects.
#
# To implement a decorator this way:
#
# class MyDecorator(DecoClass):
# def __init__(self, *args, kw1="foo", kw2="bar", **kwargs):
# super().__init__(*args, **kwargs)
# self.kw1 = kw1 # args as needed per application
# self.kw2 = kw2 # ...
#
# def __call__(self, *args, **kwargs):
# # -- decorator-specific stuff goes here --
# rv = super().__call__(*args, **kwargs)
# # -- decorator-specific stuff goes here --
# return rv
#
class DecoClass:
"""Base class for implementing a decorator as a class object."""
# NOTE ON PARAMETERIZED decorators and NAKED decorators
#
# If there are no arguments for the DECORATOR ITSELF, it will
# usually be invoked like this:
#
# @DecoClass
# def foo(): pass
#
# which ultimately becomes a call to the class constructor with
# one argument, the decoration target:
#
# DecoClass(foo)
#
# If there are KEYWORD arguments for the DECORATOR ITSELF, that
# looks like this:
#
# @DecoClass(clown='bozo')
# def foo(): pass
#
# And the class constructor is called with the keyword arguments
# given, and is expected to return a callable object 'D' that THEN is
# invoked with the function as an argument:
#
# @DecoClass(clown='bozo') --returns--> D
# D(foo)
#
# In the nested-function implementation of decorators this is handled
# by yet a third level of function nesting (see any number of python
# writeups about this topic for examples of that).
#
# In this implementation, the parameterized form returns a different
# object (via __new__ magic) that in turn will be called to return
# the decorated function object.
def __new__(cls, f_or_nothing=None, *args, **kwargs):
if f_or_nothing is None:
return cls.__Deferred(cls, *args, **kwargs) # PARAMETERIZED case
else:
return super().__new__(cls) # NAKED case
def __init__(self, f, /, **kwargs):
"""subclasses should provide their own and super() this."""
self.func = f
functools.wraps(f)(self)
# the callable object used for PARAMETERIZED decorators
class __Deferred:
def __init__(self, cls, *args, **kwargs):
self.cls = cls
self.args = args
self.kwargs = kwargs
def __call__(self, f):
return self.cls(f, *self.args, **self.kwargs)
def __call__(self, *args, **kwargs):
"""Call the wrapped function."""
return self.func(*args, **kwargs)
# A decorator implemented as a callable object method will not work
# for decorating bound methods unless the decorator object is a
# non-data descriptor (i.e., defines a __get__() method). This does that.
def __get__(self, instance, owner):
if instance is None:
return self
return types.MethodType(self, instance)
if __name__ == "__main__":
import unittest
# This is also an example of how to write a DecoClass decorator.
# This decorator counts the nesting level of the decorated function.
class RecursionCounter(DecoClass):
def __init__(self, *args, depthlimit=None, **kwargs):
super().__init__(*args, **kwargs)
self.depthlimit = depthlimit
self._count = 0
self.deepest = 0
def __call__(self, *args, **kwargs):
if self.depthlimit is not None and self._count >= self.depthlimit:
raise RecursionError(f"recursion limit exceeded")
self._count += 1
self.deepest = max(self._count, self.deepest)
try:
rv = super().__call__(*args, **kwargs)
finally:
self._count -= 1
return rv
@RecursionCounter
def nakedfoo(n):
if n > 1:
nakedfoo(n-1)
return nakedfoo.deepest
@RecursionCounter()
def nakedparmfoo(n):
if n > 1:
nakedparmfoo(n-1)
return nakedparmfoo.deepest
foolimit = 5
@RecursionCounter(depthlimit=foolimit)
def limitedfoo(n):
if n > 1:
limitedfoo(n-1)
return limitedfoo.deepest
class TestMethods(unittest.TestCase):
def test_naked_deco(self):
self.assertEqual(nakedfoo(17), 17)
def test_nakedparm_deco(self):
self.assertEqual(nakedparmfoo(17), 17)
def test_limited_deco(self):
self.assertEqual(limitedfoo(foolimit), foolimit)
with self.assertRaises(RecursionError):
limitedfoo(foolimit+1)
# this one is a method
class Foo:
@RecursionCounter
def foo(self, a, b=1, /, *, clown='bozo'):
return (a, b, clown)
# commenting out __get__ in DecoClass will demonstrate what this tests
def test_methodfoo(self):
f = self.Foo()
self.assertEqual(f.foo(17), (17, 1, 'bozo'))
self.assertEqual(f.foo(3, 17, clown='krusty'), (3, 17, 'krusty'))
unittest.main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment