Package: onionizer
onionizerTable of ContentsIntroductionMotivationInstallationMiddlewares Composition[Support For Context Managers](#Support For Context Managers)[Advanced Usage](#Advanced Usage)[Onionizer vs raw decorators](#Onionizer vs raw decorators)GotchasLicenseIntroductionOnionizer is a small and focused library that makes decorators easier to read, write and chain.
To understand its benefits, let's make a short detour and review the anatomy of a classical decorator:importfunctoolsdefmy_decorator(func):# yes, a function that takes a function and returns a function@functools.wraps(func)# to preserve the signature and the docstringdefwrapper(*args,**kwargs):# ouch, let's define a new function# A] write some stuff hereresult=func(*args,**kwargs)# B] write some stuff therereturnresultreturnwrapper# and return the function (don't forget this line)Now compare it to the anatomy of a pytest fixture:importpytest@pytest.fixturedefa_pytest_fixture():# A] write some stuff hereyield'something'# B] write some stuff thereLess visual noise, isn't it? The usage ofyieldis a nice trick to make the code more readable.
This pattern is alsothe preferred way to write context managers.
If you believe that flat is better than nested, you might like this yield trickery.With onionizer, you can write your decorators in a similar way:importonionizer@onionizer.as_decoratordefmy_decorator(*args,**kwargs):# A] write some stuff hereresult=yield# we obtain the result of the wrapped function# B] write some stuff therereturnresultThen you can use it as usual:@my_decoratordefmy_function(*args,**kwargs):return'something'Onionizer decorator-like are called middlewares and sometimes referred to as onion layers.Features include:middlewares composition (accepting a list of onionizer middlewares and context managers)possibility to mutate the arguments given to the wrapped function in a readable waysupport for context managers and callable objectsMotivationOnionizer is inspired by the onion model of middlewares in web frameworks such as Django, Flask and FastAPI.If you did a bit of web developement, you certainly found this pattern very convenient as you plug middlewares to your application to add features such as authentication, logging, etc.Why not generalize this pattern to any function ? That's what Onionizer does.Hopefully, it could nudge communities share code more easily when they are using extensively the same specific API. Yes, I am looking at youopenai.ChatCompletion.create.InstallationpipinstallonionizerNo extra dependencies required.Middlewares compositiononionizer.as_decoratorwas introduced in the introduction.
Another way to use onionizer is to wrap a function with a list of middlewares usingonionizer.wrap:importonionizerdeffunc(x,y):returnx+ydefmiddleware1(x,y):result=yieldx+1,y+1# yield the new arguments and keyword arguments ; obtain the resultreturnresult# Do nothing with the resultdefmiddleware2(x,y):result=yieldx,y# arguments are not preprocessed by this middlewarereturnresult*2# double the resultwrapped_func=onionizer.wrap(func,[middleware1,middleware2])result=wrapped_func(0,0)print(result)# 2Tracing the execution layers by layers :middleware1is called with arguments(0, 0); it yields the new arguments(1, 1)and keyword arguments{}middleware2is called with arguments(1, 1); it yields the new arguments(1, 1)and keyword arguments{}(unchanged)wrapped_funccallsfuncwith arguments(1, 1)which returns2middleware2returns4middleware1returns4(unchanged)Alternatively, you can use the decorator syntax :@onionizer.decorate([middleware1,middleware2])deffunc(x,y):returnx+ySupport For Context Managerscontext managers are de facto supported by onionizer.importonionizerdeffunc(x,y):withexception_catcher():returnx/y@contextlib.contextmanagerdefexception_catcher():try:yieldexceptExceptionase:raiseRuntimeError("Exception caught")fromewrapped_func=onionizer.wrap(func,[exception_catcher()])# notice the parenthesis, onionizer needs an instance of the context managerwrapped_func(x=1,y=0)# raises RuntimeError("Exception caught")Do use context manager if you need to do some cleanup after the wrapped function has been called or if you want to catch exceptions.
Indeed, thetry/exceptaround the yield statement will not work for onionizer middlewares.Advanced UsageEasy way to pass mutated arguments to the wrapped functionThe default way of using the yield statement is to pass either a tuple of positional arguments or a dict of keyword arguments.importonionizerdeffunc(x,y):returnx+ydefmiddleware1(x:int,y:int):result=yield{'x':x,'y':y+1}# keyword arguments onlyreturnresultdefmiddleware2(x:int,y:int):result=yieldx+1,y# positional arguments onlyreturnresultdefmiddleware3(x:int,y:int):result=yield# no mutationreturnresult+1wrapped_func=onionizer.wrap(func,[middleware1,middleware2,middleware3])print(wrapped_func(x=0,y=0))# 3MixedArgsIn case you really need to pass both positional and keyword arguments, you can useonionizer.MixedArgs:importonionizerdefmiddleware1(x:int,y:int):result=yieldonionizer.MixedArgs(args=(x+1,),kwargs={'y':y+1})# pass a tuple of positional arguments and a dict of keyword argumentsreturnresultEarly return to skip the next onion layers and the wrapped functionLet's say you need a caching or validation middleware, you can return a value to skip the wrapped function or any remaining onion layers.importonionizerdeffunc(x,y):returnx+ydefmiddleware1(x:int,y:int):ifx==0:return0else:result=yieldreturnresultwrapped_func=onionizer.wrap(func,[middleware1])print(wrapped_func(x=0,y=0))# 0On an early return, the next onion layers are skipped and the wrapped function won't be called.
However, to play nicely with the middlewares already in play, all the previous onion layers will be called on the way back.importonionizerdeffunc(x,y):print("FUNC CALLED")returnx+ydefmiddleware1(x:int,y:int):ifx==0:print("EARLY RETURN")return0else:result=yieldreturnresultdefpolite_middleware(x:int,y:int):print("Hello")result=yieldprint("Goodbye")returnresultwrapped_func=onionizer.wrap(func,[polite_middleware,middleware1])# polite_middleware will be called on the way backprint(wrapped_func(x=0,y=0))# Hello# EARLY RETURN# GoodbyeBy using theHARD_BYPASScontainer, it's possible to skip all remaining onion layers and return a value without calling the wrapped function.
This means not playing nicely with the other middlewares that are already contacted.
This is discouraged and should be used as a last resort only.importonionizerdeffunc(x,y):print("FUNC CALLED")returnx+ydefmiddleware1(x:int,y:int):ifx==0:returnonionizer.HARD_BYPASS(0)else:result=yieldreturnresultdefpolite_middleware(x:int,y:int):print("Hello")result=yieldprint("Goodbye")returnresultwrapped_func=onionizer.wrap(func,[polite_middleware,middleware1])# polite_middleware won't be called on the way backprint(wrapped_func(x=0,y=0))# Hello# 0Typingonionizer let you type nicely your middlewares so that it's made apparent what arguments they expect and what they return.
The return value might be harder to type as your middleware is in fact a generator.
We provide aonionizer.Outtype to help you with that and let type checkers work their magic.importonionizerdeffunc(x:int,y:int)->int:returnx+ydefmiddleware1(x:int,y:int)->onionizer.Out[int]:result=yield{'x':x,'y':y+1}# keyword arguments onlyreturnresultThe proximity of the middleware signature with the wrapped function signature makes it easier to read and write
and value the fact that onionizer is a composition tool that cares about the domain model of the wrapped function (cf next section)Middlewares with stateMiddlewares can be instances of classes that implement the__call__method, which is a practical way to store some state between calls.importonionizerclassMiddWare:def__init__(self):self.call_count=0def__call__(self,*args,**kwargs):self.call_count+=1r=yieldreturnrmiddware=MiddWare()wrapped_func=onionizer.wrap(lambdax:x,[middware])wrapped_func(None)wrapped_func(None)print(middware.call_count)# 2Onionizer vs raw decoratorspros and consLet's discuss the pros and cons of using onionizer vs raw decorators.pros for onionizer middlewares:easier to read and writefeatures that eases the creation of your onion modelcons for onionizer middlewares:extra library to depend on (or extra code if you copy and paste the code from onionizer.py in your utils.py, which is fine if you ask me)some time required to get used to the API (but not much, it's really simple)I believe middlewares are a great pattern to build software by composition but also to share code between projects that revolves around the same API.
Generally, decorators are more thought as a way to handle cross-cutting concerns (logging, caching, etc.) and not as a way to share code between projects.
Middlewares, on the other hand, are a great way to share code between projects that revolves around the same API (cf this2022 pycon talkwhere the author explain and demonstrates how the WSGI spec which defines the signature of python web applications allows to share code between frameworks when using middlewares.conclusionWhen the very same API is used by many projects, I think it's a good idea to provide a framework to help code authors (yourself included) to build their own middlewares without having to write raw decorators.
Onionizer lets you bootstrap this framework.For cross-cutting concerns, I think it's better to use raw decorators as they will be usable everywhere and not only in the context of your project that uses onionizer.Gotchasas stated earlier, sandwiching youryieldstatement with atry-exceptblock won't work in a middleware. Use a context manager instead.only sync functions can be wrapped by onionizer at the moment.Roadmap/Ideasextend the support for other types of functions: methods, generators async functions..(?) provide ports for other Middleware frameworks (e.g@onionizer.as_wsgi_middleware)Licenseonionizeris distributed under the terms of theMITlicense.