Last active
April 10, 2025 07:53
-
-
Save softwaredoug/bbfa2541d48c685908ebd6a6a78e6829 to your computer and use it in GitHub Desktop.
Python default parameters work in unexpected ways. Here we allow a per-call default, where objects are constructed on call, not function creation.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.