check for and fix mutable keyword args · python-control/python-control@4968cb3
@@ -38,6 +38,10 @@ def test_kwarg_search(module, prefix):
3838# Skip anything that isn't part of the control package
3939continue
404041+# Look for classes and then check member functions
42+if inspect.isclass(obj):
43+test_kwarg_search(obj, prefix + obj.__name__ + '.')
44+4145# Only look for functions with keyword arguments
4246if not inspect.isfunction(obj):
4347continue
@@ -70,10 +74,6 @@ def test_kwarg_search(module, prefix):
7074f"'unrecognized keyword' not found in unit test "
7175f"for {name}")
727673-# Look for classes and then check member functions
74-if inspect.isclass(obj):
75-test_kwarg_search(obj, prefix + obj.__name__ + '.')
76-77777878@pytest.mark.parametrize(
7979 "function, nsssys, ntfsys, moreargs, kwargs",
@@ -201,3 +201,66 @@ def test_matplotlib_kwargs(function, nsysargs, moreargs, kwargs, mplcleanup):
201201'TimeResponseData.__call__': trdata_test.test_response_copy,
202202'TransferFunction.__init__': test_unrecognized_kwargs,
203203}
204+205+#
206+# Look for keywords with mutable defaults
207+#
208+# This test goes through every function and looks for signatures that have a
209+# default value for a keyword that is mutable. An error is generated unless
210+# the function is listed in the `mutable_ok` set (which should only be used
211+# for cases were the code has been explicitly checked to make sure that the
212+# value of the mutable is not modified in the code).
213+#
214+mutable_ok = { # initial and date
215+control.flatsys.SystemTrajectory.__init__, # RMM, 18 Nov 2022
216+control.freqplot._add_arrows_to_line2D, # RMM, 18 Nov 2022
217+control.namedio._process_dt_keyword, # RMM, 13 Nov 2022
218+control.namedio._process_namedio_keywords, # RMM, 18 Nov 2022
219+control.optimal.OptimalControlProblem.__init__, # RMM, 18 Nov 2022
220+control.optimal.solve_ocp, # RMM, 18 Nov 2022
221+control.optimal.create_mpc_iosystem, # RMM, 18 Nov 2022
222+}
223+224+@pytest.mark.parametrize("module", [control, control.flatsys])
225+def test_mutable_defaults(module, recurse=True):
226+# Look through every object in the package
227+for name, obj in inspect.getmembers(module):
228+# Skip anything that is outside of this module
229+if inspect.getmodule(obj) is not None and \
230+not inspect.getmodule(obj).__name__.startswith('control'):
231+# Skip anything that isn't part of the control package
232+continue
233+234+# Look for classes and then check member functions
235+if inspect.isclass(obj):
236+test_mutable_defaults(obj, True)
237+238+# Look for modules and check for internal functions (w/ no recursion)
239+if inspect.ismodule(obj) and recurse:
240+test_mutable_defaults(obj, False)
241+242+# Only look at functions and skip any that are marked as OK
243+if not inspect.isfunction(obj) or obj in mutable_ok:
244+continue
245+246+# Get the signature for the function
247+sig = inspect.signature(obj)
248+249+# Skip anything that is inherited
250+if inspect.isclass(module) and obj.__name__ not in module.__dict__:
251+continue
252+253+# See if there is a variable keyword argument
254+for argname, par in sig.parameters.items():
255+if par.default is inspect._empty or \
256+not par.kind == inspect.Parameter.KEYWORD_ONLY and \
257+not par.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD:
258+continue
259+260+# Check to see if the default value is mutable
261+if par.default is not None and not \
262+isinstance(par.default, (bool, int, float, tuple, str)):
263+pytest.fail(
264+f"function '{obj.__name__}' in module '{module.__name__}'"
265+f" has mutable default for keyword '{par.name}'")
266+