Skip to content

Instantly share code, notes, and snippets.

@softwaredoug
Last active Jan 23, 2022
Embed
What would you like to do?
Python default parameters work in unexpected ways. Here we allow a per-call default, where objects are constructed on call, not function creation.
from inspect import signature
import random
from copy import deepcopy
def argue(fn):
"""Copies defaults _per call_ to give more expected python default parameter functionality.
Usage
-----
@argue
def append_length(l: list = []):
l.append(len(l))
return l
then calling, you should get the expected behavior, despite multiple
calls
assert append_length() == [1]
assert append_length() == [1]
Known Issues
------------
Use at your own risk, this a weekend fun hack thing, not thoroughly tested
* deepcopy likely to be expensive, need to allow people to define a lambda on how to construct the default
* performance has to be analyzed veeeery thoroughly and minimized here given it might be applied to every function call
* testing in more scenarios
"""
sig = signature(fn)
# save defaults
default_params = {}
for param_name, param in sig.parameters.items():
default_params[param_name] = deepcopy(param.default)
def redefault(*args, **kwargs):
"""Overwrite defaults with saved params."""
nonlocal default_params
sig = signature(fn)
bound = sig.bind(*args, **kwargs)
# If not provided by caller, add defaults
for param_name, param_default in default_params.items():
if param_name not in bound.arguments:
bound.arguments[param_name] = deepcopy(param_default)
args = bound.args
kwargs = bound.kwargs
return fn(*args, **kwargs)
return redefault
input_arg = None
input_other_arg = None
@argue
def foo(other_arg: list=[], arg: list=[]):
"""Test function using decorator."""
global input_arg
global input_other_arg
print("CALLED!")
input_arg = deepcopy(arg) # for testing purposes
input_other_arg = deepcopy(other_arg) # for testing purposes
print(arg, other_arg)
arg.append(len(arg))
other_arg.append(len(other_arg))
print(arg, other_arg)
print("------")
return arg, other_arg
# Some basic tests
if __name__ == "__main__":
random.seed(0)
last_arg = None; last_other_arg = None
# Confirm input args always the same
for i in range(0, 50):
print(foo())
if last_arg is not None:
assert last_arg == input_arg
if last_other_arg is not None:
assert last_other_arg == input_other_arg
last_arg = input_arg
last_other_arg = input_other_arg
print(last_arg, last_other_arg)
# Shuffle the order these are called
# as they should be stateless and order shouldn't matter
for i in range(0, 50):
test_num = random.randint(0,4)
if test_num == 0:
assert foo(arg=[1234]) == ([1234, 1], [0])
elif test_num == 1:
assert foo(arg=[1234]) == foo(arg=[1234])
elif test_num == 2:
assert foo([1234]) == foo([1234])
elif test_num == 3:
assert foo(other_arg=[1234]) == foo(other_arg=[1234])
elif test_num == 4:
assert foo() == foo()
@softwaredoug
Copy link
Author

softwaredoug commented Jan 23, 2022

See relevant thread on hacker news

Specifically, there I complained about a Python pet peeve of mine, where default arguments don't work as people expect. Most people expect the arguments to be bound to defaults at call time, when the reality is they are bound at function instantiation time. So there's a lot of boilerplate for call-time bound arguments in a lot of python code, and its sometimes not clear what the default actually is.

I'm curious if anyones found a good solution to Python default args?
For example, this doesn't do what people expect:
    def foo(bar=Bar())
        ...
Because this creates exactly one Bar at function definition time, not a Bar per function call.
Instead what people usually want is:
    def foo(bar=None)
        if bar is None:
            bar = Bar()
        ...
I hate having to have a block of `if bar is None` at the top of my functions.
I wonder if there's a library that helps make creating functions with default args easier? Or any tips people have?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment