Skip to content

Commit b46d5f8

Browse files
CopilotBorda
andcommitted
Fix: Handle variadic arguments (*args, **kwargs) in cache key generation
- Modified _convert_args_kwargs() to properly handle VAR_POSITIONAL and VAR_KEYWORD parameters - Variadic positional args are now expanded as individual entries with __varargs_N__ keys - Variadic keyword args are stored with their original keys - Added comprehensive test suite for variadic arguments - All existing tests pass including custom hash function tests Co-authored-by: Borda <6035284+Borda@users.noreply.github.com>
1 parent 8fa303f commit b46d5f8

2 files changed

Lines changed: 269 additions & 10 deletions

File tree

src/cachier/core.py

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -79,20 +79,74 @@ def _convert_args_kwargs(
7979
args = func.args + args
8080
kwds.update({k: v for k, v in func.keywords.items() if k not in kwds})
8181
func = func.func
82-
func_params = list(inspect.signature(func).parameters)
83-
args_as_kw = dict(
84-
zip(func_params[1:], args[1:])
85-
if _is_method
86-
else zip(func_params, args)
87-
)
88-
# init with default values
82+
83+
sig = inspect.signature(func)
84+
func_params = list(sig.parameters)
85+
86+
# Separate regular parameters from VAR_POSITIONAL and VAR_KEYWORD
87+
regular_params = []
88+
var_positional_name = None
89+
var_keyword_name = None
90+
91+
for param_name in func_params:
92+
param = sig.parameters[param_name]
93+
if param.kind == inspect.Parameter.VAR_POSITIONAL:
94+
var_positional_name = param_name
95+
elif param.kind == inspect.Parameter.VAR_KEYWORD:
96+
var_keyword_name = param_name
97+
else:
98+
regular_params.append(param_name)
99+
100+
# Map positional arguments to regular parameters
101+
if _is_method:
102+
# Skip 'self' for methods
103+
args_to_map = args[1:]
104+
params_to_use = regular_params[1:]
105+
else:
106+
args_to_map = args
107+
params_to_use = regular_params
108+
109+
# Map as many args as possible to regular parameters
110+
num_regular = len(params_to_use)
111+
args_as_kw = dict(zip(params_to_use, args_to_map[:num_regular]))
112+
113+
# Handle variadic positional arguments
114+
# Store them with indexed keys like __varargs_0__, __varargs_1__, etc.
115+
if var_positional_name and len(args_to_map) > num_regular:
116+
var_args = args_to_map[num_regular:]
117+
for i, arg in enumerate(var_args):
118+
args_as_kw[f"__varargs_{i}__"] = arg
119+
120+
# Init with default values
89121
kwargs = {
90122
k: v.default
91-
for k, v in inspect.signature(func).parameters.items()
123+
for k, v in sig.parameters.items()
92124
if v.default is not inspect.Parameter.empty
93125
}
94-
# merge args expanded as kwargs and the original kwds
95-
kwargs.update(dict(**args_as_kw, **kwds))
126+
127+
# Merge args expanded as kwargs and the original kwds
128+
kwargs.update(args_as_kw)
129+
130+
# Handle variadic keyword arguments
131+
if var_keyword_name:
132+
# Separate kwds that match known parameters from those that don't
133+
known_param_kwds = {}
134+
extra_kwds = {}
135+
for k, v in kwds.items():
136+
if k in sig.parameters:
137+
param = sig.parameters[k]
138+
if param.kind != inspect.Parameter.VAR_KEYWORD:
139+
known_param_kwds[k] = v
140+
else:
141+
extra_kwds[k] = v
142+
else:
143+
extra_kwds[k] = v
144+
kwargs.update(known_param_kwds)
145+
# Store extra kwargs with their original keys
146+
kwargs.update(extra_kwds)
147+
else:
148+
kwargs.update(kwds)
149+
96150
return OrderedDict(sorted(kwargs.items()))
97151

98152

tests/test_varargs.py

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
"""Test for variadic arguments (*args) handling in cachier."""
2+
3+
from datetime import timedelta
4+
5+
import pytest
6+
7+
from cachier import cachier
8+
9+
10+
@pytest.mark.pickle
11+
def test_varargs_different_cache_keys():
12+
"""Test that functions with *args get unique cache keys for different arguments."""
13+
call_count = 0
14+
15+
@cachier(stale_after=timedelta(seconds=500))
16+
def get_data(*args):
17+
"""Test function that accepts variadic arguments."""
18+
nonlocal call_count
19+
call_count += 1
20+
return f"Result for args: {args}, call #{call_count}"
21+
22+
# Clear any existing cache
23+
get_data.clear_cache()
24+
call_count = 0
25+
26+
# Test 1: Call with different arguments should produce different cache entries
27+
result1 = get_data("print", "domains")
28+
assert call_count == 1
29+
assert "('print', 'domains')" in result1
30+
31+
result2 = get_data("print", "users", "allfields")
32+
assert call_count == 2, "Function should be called again with different args"
33+
assert "('print', 'users', 'allfields')" in result2
34+
assert result1 != result2, "Different args should produce different results"
35+
36+
# Test 2: Calling with the same arguments should use cache
37+
result3 = get_data("print", "domains")
38+
assert call_count == 2, "Function should not be called again (cache hit)"
39+
assert result3 == result1
40+
41+
result4 = get_data("print", "users", "allfields")
42+
assert call_count == 2, "Function should not be called again (cache hit)"
43+
assert result4 == result2
44+
45+
get_data.clear_cache()
46+
47+
48+
@pytest.mark.pickle
49+
def test_varargs_empty():
50+
"""Test that functions with *args work with no arguments."""
51+
call_count = 0
52+
53+
@cachier(stale_after=timedelta(seconds=500))
54+
def get_data(*args):
55+
"""Test function that accepts variadic arguments."""
56+
nonlocal call_count
57+
call_count += 1
58+
return f"Result for args: {args}, call #{call_count}"
59+
60+
get_data.clear_cache()
61+
call_count = 0
62+
63+
# Call with no arguments
64+
result1 = get_data()
65+
assert call_count == 1
66+
assert "()" in result1
67+
68+
# Second call should use cache
69+
result2 = get_data()
70+
assert call_count == 1
71+
assert result2 == result1
72+
73+
get_data.clear_cache()
74+
75+
76+
@pytest.mark.pickle
77+
def test_varargs_with_regular_args():
78+
"""Test that functions with both regular and variadic arguments work correctly."""
79+
call_count = 0
80+
81+
@cachier(stale_after=timedelta(seconds=500))
82+
def get_data(command, *args):
83+
"""Test function with regular and variadic arguments."""
84+
nonlocal call_count
85+
call_count += 1
86+
return f"Command: {command}, args: {args}, call #{call_count}"
87+
88+
get_data.clear_cache()
89+
call_count = 0
90+
91+
# Test different calls
92+
result1 = get_data("print", "domains")
93+
assert call_count == 1
94+
95+
result2 = get_data("print", "users", "allfields")
96+
assert call_count == 2
97+
assert result1 != result2
98+
99+
result3 = get_data("list")
100+
assert call_count == 3
101+
assert result3 != result1
102+
assert result3 != result2
103+
104+
# Test cache hits
105+
result4 = get_data("print", "domains")
106+
assert call_count == 3
107+
assert result4 == result1
108+
109+
get_data.clear_cache()
110+
111+
112+
@pytest.mark.pickle
113+
def test_varkwargs_different_cache_keys():
114+
"""Test that functions with **kwargs get unique cache keys for different arguments."""
115+
call_count = 0
116+
117+
@cachier(stale_after=timedelta(seconds=500))
118+
def get_data(**kwargs):
119+
"""Test function that accepts keyword variadic arguments."""
120+
nonlocal call_count
121+
call_count += 1
122+
return f"Result for kwargs: {kwargs}, call #{call_count}"
123+
124+
get_data.clear_cache()
125+
call_count = 0
126+
127+
# Test with different kwargs
128+
result1 = get_data(type="domains", action="print")
129+
assert call_count == 1
130+
131+
result2 = get_data(type="users", action="print", fields="allfields")
132+
assert call_count == 2
133+
assert result1 != result2
134+
135+
# Test cache hits
136+
result3 = get_data(type="domains", action="print")
137+
assert call_count == 2
138+
assert result3 == result1
139+
140+
get_data.clear_cache()
141+
142+
143+
@pytest.mark.pickle
144+
def test_varargs_and_varkwargs():
145+
"""Test that functions with both *args and **kwargs work correctly."""
146+
call_count = 0
147+
148+
@cachier(stale_after=timedelta(seconds=500))
149+
def get_data(*args, **kwargs):
150+
"""Test function with both variadic arguments."""
151+
nonlocal call_count
152+
call_count += 1
153+
return f"args: {args}, kwargs: {kwargs}, call #{call_count}"
154+
155+
get_data.clear_cache()
156+
call_count = 0
157+
158+
# Test different combinations
159+
result1 = get_data("print", "domains")
160+
assert call_count == 1
161+
162+
result2 = get_data("print", "users", action="list")
163+
assert call_count == 2
164+
assert result1 != result2
165+
166+
result3 = get_data(action="list", resource="domains")
167+
assert call_count == 3
168+
assert result3 != result1
169+
assert result3 != result2
170+
171+
# Test cache hits
172+
result4 = get_data("print", "domains")
173+
assert call_count == 3
174+
assert result4 == result1
175+
176+
get_data.clear_cache()
177+
178+
179+
@pytest.mark.memory
180+
def test_varargs_memory_backend():
181+
"""Test that variadic arguments work with memory backend."""
182+
call_count = 0
183+
184+
@cachier(backend="memory", stale_after=timedelta(seconds=500))
185+
def get_data(*args):
186+
"""Test function that accepts variadic arguments."""
187+
nonlocal call_count
188+
call_count += 1
189+
return f"Result: {args}, call #{call_count}"
190+
191+
get_data.clear_cache()
192+
call_count = 0
193+
194+
result1 = get_data("a", "b")
195+
assert call_count == 1
196+
197+
result2 = get_data("a", "b", "c")
198+
assert call_count == 2
199+
assert result1 != result2
200+
201+
result3 = get_data("a", "b")
202+
assert call_count == 2
203+
assert result3 == result1
204+
205+
get_data.clear_cache()

0 commit comments

Comments
 (0)