The Problem
I have a module with several sibling classes that share a method with a similar name. These methods receive the same basic arguments, however, for a number of the sibling classes the method in question can take different arguments that are irrelevant to the methods defined in its siblings. For example:
class ElectricKeyboard(Instrument):
def __init__ (self):
Instrument.__init__(self)
def play (sheet_music: list, foot_pedal: bool = True):
# play some music
class ElectricGuitar(Instrument):
def __init__ (self):
Instrument.__init__(self)
def play (sheet_music: list, foot_pedal: bool = True, guitar_pick: bool = True):
# play some music
class Kazoo(Instrument):
def __init__ (self):
Instrument.__init__(self)
def play (sheet_music: list):
# play some music
Above, we have three children of the Instrument
class that are siblings. They all have a method, play
, that takes a similar argument, however, ElectricKeyboard.play
and ElectricGuitar.play
take different additional keyword arguments relative to Kazoo.play
, which takes none.
Now imagine there exists a separate module where we have some calling context where the guitar_pick
keyword argument happens to be defined. Something like this:
# Import the Instrument parent class
from instruments import ElectricGuitar, ElectricKeyboard, Kazoo
# We have some external config file that encapsulates a concert object
import concert_config
# We have some local variables that could be useful
guitar_pick, foot_pedal = True, False
# We initialize all the instruments for the concert and store them in a list
instruments = [Instrument.get_child(instrument)() for instrument in concert_config["instruments"]]
# We then play the music (ignoring that this would play the same bar for each instrument sequentially rather than all at once)
for bar in concert_config["sheet_music"]:
for instrument in instruments:
# Either of these three scenarios could occur in the body of the for loop
instrument.play(bar) # We'd like to do this for the Kazoo
instrument.play(bar, foot_pedal = foot_pedal) # Or this for the ElectricKeyboard
instrument.play(bar, foot_pedal = foot_pedal, guitar_pick = guitar_pick) # Or this for the ElectricGuitar
The key here is that in the calling context, the instrument is considered generic; it could be an electric guitar, a keyboard, or a kazoo. We don't care what instrument we're playing, we just want to play it. However, to play it correctly, we want to give as much detail as possible – we would like to provide the values for foot_pedal
and guitar_pick
, when appropriate. In contrast, if we were to instead have the Kazoo
class as our instrument
we would not want to pass any additional arguments because they would not be useful or make sense for that instrument.
A Possible Solution
In thinking about how to manage this and preserve the existing architecture (this example is highly contrived relative to the actual application) I thought it might be useful to create a decorator that 'absorbs' the variable scope of the method's calling context (this could be the scope just outside of the method or the global scope).
This is how it would work: Prior to executing the decorated function (i.e. the play
method for the instrument), the decorator would 1. retrieve a specified context (e.g. locals()
), 2. inspect the function signature to identify its parameters and 3. search the specified context for variables that have the same name as the function parameters and, if found, pass them to the decorated function if they exist. Here's a decorator that does this:
import inspect
from typing import Callable, Any
class AbsorbContext ():
"""
A decorator that searches a specified context (e.g. locals() or globals())
for undefined arguments and passes them to the decorated function from the
local contest if they are defined there (i.e. 'absorbs' them).
"""
def __init__ (self,
context: dict = globals(),
positional_only: bool = True,
positional_or_keyword: bool = True,
keyword_only: bool = True
):
self.positional_only = positional_only
self.positional_or_keyword = positional_or_keyword
self.keyword_only = keyword_only
self.context = context
def __call__ (self, func: Callable[..., Any]):
def absorb (*args, **kwargs):
params = inspect.signature(func).parameters.values()
if self.positional_only:
absorbed_pos_args = ()
pos_only = [param.name for param in params if param.kind is inspect.Parameter.POSITIONAL_ONLY]
args = tuple(self.context[arg] for arg in pos_only if arg in self.context)
if self.positional_or_keyword:
absorbed_pos_or_kwd_args = {}
pos_or_kwd = [param.name for param in params if param.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD]
kwargs = dict(kwargs, **{arg: self.context[arg] for arg in pos_or_kwd if arg in self.context})
if self.keyword_only:
absorbed_kwd_args = {}
kwd_only = [param.name for param in params if param.kind is inspect.Parameter.KEYWORD_ONLY]
kwargs = dict(kwargs, **{arg: self.context[arg] for arg in kwd_only if arg in self.context})
return func(*args, **kwargs)
return absorb
This works and seems to achieved the desired behavior. Below is an example usage (if you're unfamiliar with the /
and *
syntax seen in function signature below, see this answer).
Example Usage
# Some Arguments
a = 2
b = 3
c = 4
d = 5
@AbsorbContext(context = locals())
def func (a: int, b: int, /, c: int = 0, *, d: int = 1):
return (a * b + c) * d
func(a) # Returns 50
func(a, b) # Returns 50
func() # We can pass nothing and it will still evaluate correctly; returns 50
# ...
As long as we maintain the correct ordering for args a
and b
(the positional only arguments), the method will always return the correct value, 50
, given the parameters that are available for input in the calling context. This can also be thought of as defining the default argument values for a function in the calling context rather than in the function signature.
func(1, 2, 3, 4) # All new arguments, returns 20
func(1) # Modify a POSITION_ONLY argument, returns 35
func(d = 10) # Modify a KEYWORD_ONLY argument, returns 100
func(2, 3, c = 14) # Modify a POSITION_OR_KEYWORD argument, returns 100
func(2, 3, 14) # Modify a POSITION_OR_KEYWORD argument, returns 100
Note that unlike in the example shown here, in general the definition of the function (e.g. func
) will exist in a separate module from the one where the arguments are defined and the function is called.
The Question
This feels like a hack. While this seems to work I have some questions:
Is there a way to get a similar behavior that does not require a decorator of this sort? It seems like this could be a weakness with the architecture I have chosen – are there any architectures that have been designed to solve this problem?
Is there any obvious way this behavior could be exploited by a bad actor? Note that this software is not designed to run or be called over a network; it is reasonable to assume that all arguments will be defined by the user at runtime.
In its current form, the context should be called in the same module that the decorated function is defined. Is there a way to make this more flexible? I think we might be able to solve this problem by calling
globals()
, but that seems inelegant.