Skip to content

Commit 884e865

Browse files
committed
use numpydoc to capture function signature for var_positional args
1 parent b360c2a commit 884e865

5 files changed

Lines changed: 84 additions & 56 deletions

File tree

control/bdalg.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,8 @@
6969
'combine_tf', 'split_tf']
7070

7171

72-
def series(sys1, *sysn, **kwargs):
73-
r"""series(sys1, sys2, [..., sysn])
72+
def series(*sys, **kwargs):
73+
r"""series(sys1, sys2[, ..., sysn])
7474
7575
Return series connection (`sysn` \* ...\ \*) `sys2` \* `sys1`.
7676
@@ -136,13 +136,13 @@ def series(sys1, *sysn, **kwargs):
136136
(2, 1, 5)
137137
138138
"""
139-
sys = reduce(lambda x, y: y * x, sysn, sys1)
139+
sys = reduce(lambda x, y: y * x, sys[1:], sys[0])
140140
sys.update_names(**kwargs)
141141
return sys
142142

143143

144-
def parallel(sys1, *sysn, **kwargs):
145-
r"""parallel(sys1, sys2, [..., sysn])
144+
def parallel(*sys, **kwargs):
145+
r"""parallel(sys1, sys2[, ..., sysn])
146146
147147
Return parallel connection `sys1` + `sys2` (+ ...\ + `sysn`).
148148
@@ -206,7 +206,7 @@ def parallel(sys1, *sysn, **kwargs):
206206
(3, 4, 7)
207207
208208
"""
209-
sys = reduce(lambda x, y: x + y, sysn, sys1)
209+
sys = reduce(lambda x, y: x + y, sys[1:], sys[0])
210210
sys.update_names(**kwargs)
211211
return sys
212212

@@ -354,7 +354,7 @@ def feedback(sys1, sys2=1, sign=-1, **kwargs):
354354
return sys
355355

356356
def append(*sys, **kwargs):
357-
"""append(sys1, sys2, [..., sysn])
357+
"""append(sys1, sys2[, ..., sysn])
358358
359359
Group LTI models by appending their inputs and outputs.
360360

control/freqplot.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2172,7 +2172,9 @@ def gangof4_response(
21722172
def gangof4_plot(
21732173
*args, omega=None, omega_limits=None, omega_num=None,
21742174
Hz=False, **kwargs):
2175-
"""Plot response of "Gang of 4" transfer functions.
2175+
"""gangof4_plot(response) | gangof4_plot(P, C, omega)
2176+
2177+
Plot response of "Gang of 4" transfer functions.
21762178
21772179
Plots a 2x2 frequency response for the "Gang of 4" sensitivity
21782180
functions [T, PS; CS, S]. Can be called in one of two ways:

control/margins.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -507,11 +507,13 @@ def phase_crossover_frequencies(sys):
507507

508508

509509
def margin(*args):
510-
"""margin(sysdata)
510+
"""
511+
margin(sys) \
512+
margin(mag, phase, omega)
511513
512514
Gain and phase margins and associated crossover frequencies.
513515
514-
Can be called as ``margin(sys)`` where ``sys`` is a SISO LTI sytem or
516+
Can be called as ``margin(sys)`` where ``sys`` is a SISO LTI system or
515517
``margin(mag, phase, omega)``.
516518
517519
Parameters

control/stochsys.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,8 +213,8 @@ def dlqe(*args, **kwargs):
213213
214214
Parameters
215215
----------
216-
A, G : 2D array_like
217-
Dynamics and noise input matrices.
216+
A, G, C : 2D array_like
217+
Dynamics, process noise (disturbance), and output matrices.
218218
QN, RN : 2D array_like
219219
Process and sensor noise covariance matrices.
220220
NN : 2D array, optional

control/tests/docstrings_test.py

Lines changed: 68 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -36,23 +36,6 @@
3636
control.tf, # tested separately below
3737
]
3838

39-
# Checksums to use for checking whether a docstring has changed
40-
function_docstring_hash = {
41-
control.append: 'fad0bd0766754c3524e0ea27b06bf74a',
42-
control.describing_function_plot: '95f894706b1d3eeb3b854934596af09f',
43-
control.dlqe: 'e1e9479310e4e5a6f50f5459fb3d2dfb',
44-
control.dlqr: '56d7f3a452bc8d7a7256a52d9d1dcb37',
45-
control.lqe: '0447235d11b685b9dfaf485dd01fdb9a',
46-
control.lqr: 'a3e0a85f781fc9c0f69a4b7da4f0bd22',
47-
control.margin: 'f02b3034f5f1d44ce26f916cc3e51600',
48-
control.parallel: 'bfc470aef75dbb923f9c6fb8bf3c9b43',
49-
control.series: '9aede1459667738f05cf4fc46603a4f6',
50-
control.ss2tf: 'e779b8d70205bc1218cc2a4556a66e4b',
51-
control.tf2ss: '086a3692659b7321c2af126f79f4bc11',
52-
control.markov: 'a4199c54cb50f07c0163d3790739eafe',
53-
control.gangof4: 'f9673ae4c6d26c202060ed4b9ef54800',
54-
}
55-
5639
# List of keywords that we can skip testing (special cases)
5740
keyword_skiplist = {
5841
control.input_output_response: ['method'],
@@ -171,6 +154,9 @@ def test_parameter_docs(module, prefix):
171154
# Get the signature for the function
172155
sig = inspect.signature(obj)
173156

157+
# If first argument is *args, try to use docstring instead
158+
sig = _replace_var_positional_with_docstring(sig, doc)
159+
174160
# Go through each parameter and make sure it is in the docstring
175161
for argname, par in sig.parameters.items():
176162
# Look for arguments that we can skip
@@ -180,22 +166,11 @@ def test_parameter_docs(module, prefix):
180166

181167
# Check for positional arguments (*arg)
182168
if par.kind == inspect.Parameter.VAR_POSITIONAL:
183-
if obj in function_docstring_hash:
184-
import hashlib
185-
hash = hashlib.md5(
186-
(docstring + source).encode('utf-8')).hexdigest()
187-
if function_docstring_hash[obj] != hash:
188-
_fail(
189-
f"source/docstring for {objname}() modified; "
190-
f"recheck docstring and update hash to "
191-
f"{hash=}")
192-
continue
193-
194-
# Too complicated to check
195169
if f"*{argname}" not in docstring:
196-
_warn(
197-
f"{objname} {argname} has positional arguments; "
198-
"docstring not checked")
170+
_fail(
171+
f"{objname} has undocumented, unbound positional "
172+
f"argument '{argname}'; "
173+
"use docstring signature instead")
199174
continue
200175

201176
# Check for keyword arguments (then look at code for parsing)
@@ -621,7 +596,7 @@ def _check_numpydoc_style(obj, doc):
621596
name = ".".join([obj.__module__.removeprefix("control."), obj.__name__])
622597

623598
# Standard checks for all objects
624-
summary = "".join(doc["Summary"])
599+
summary = "\n".join(doc["Summary"])
625600
if len(doc["Summary"]) > 1:
626601
_warn(f"{name} summary is more than one line")
627602
if summary and summary[-1] != '.' and re.match(":$", summary) is None:
@@ -666,6 +641,55 @@ def _check_param(param, empty_ok=False, noname_ok=False):
666641
raise TypeError("unknown object type for {obj}")
667642

668643

644+
# Utility function to replace positional signature with docstring signature
645+
def _replace_var_positional_with_docstring(sig, doc):
646+
# If no documentation is available, there is nothing we can do...
647+
if doc is None:
648+
return sig
649+
650+
# Check to see if the first argument is positional
651+
parameter_items = iter(sig.parameters.items())
652+
try:
653+
argname, par = next(parameter_items)
654+
if par.kind != inspect.Parameter.VAR_POSITIONAL or \
655+
(signature := doc["Signature"]) == '':
656+
return sig
657+
except StopIteration:
658+
return sig
659+
660+
# Try parsing the docstring signature
661+
arg_list = []
662+
while (1):
663+
if (match_fcn := re.match(
664+
r"^([\s]*\|[\s]*)*[\w]+\(", signature)) is None:
665+
break
666+
arg_idx = match_fcn.span(0)[1]
667+
while (1):
668+
match_arg = re.match(
669+
r"[\s]*([\w]+)(,|,\[|\[,|\)|\]\))(,[\s]*|[\s]*[.]{3},[\s]*)*",
670+
signature[arg_idx:])
671+
if match_arg is None:
672+
break
673+
else:
674+
arg_idx += match_arg.span(0)[1]
675+
arg_list.append(match_arg.group(1))
676+
signature = signature[arg_idx:]
677+
if arg_list == []:
678+
return sig
679+
680+
# Create the new parameter list
681+
parameter_list = [
682+
inspect.Parameter(arg, inspect.Parameter.POSITIONAL_ONLY)
683+
for arg in arg_list]
684+
685+
# Add any remaining parameters that were in the original signature
686+
for argname, par in parameter_items:
687+
if argname not in arg_list:
688+
parameter_list.append(par)
689+
690+
# Return the new signature
691+
return sig.replace(parameters=parameter_list)
692+
669693
# Utility function to warn with verbose output
670694
def _info(str, level):
671695
if verbose > level:
@@ -693,17 +717,17 @@ def simple_function(arg1, arg2, opt1=None, **kwargs):
693717

694718
Failed = pytest.fail.Exception
695719

696-
doc_header = simple_class.simple_function.__doc__
720+
doc_header = simple_class.simple_function.__doc__ + "\n"
697721
doc_parameters = "\nParameters\n----------\n"
698-
doc_arg1 = "\narg1 : int\nArgument 1.\n"
699-
doc_arg2 = "\narg2 : int\nArgument 2.\n"
700-
doc_arg2_nospace = "\narg2: int\nArgument 2.\n"
701-
doc_arg3 = "\narg3 : int\nNon-existent argument 1.\n"
702-
doc_opt1 = "\nopt1 : int\nKeyword argument 1.\n"
703-
doc_test = "\ntest : int\nInternal keyword argument 1.\n"
722+
doc_arg1 = "arg1 : int\n Argument 1.\n"
723+
doc_arg2 = "arg2 : int\n Argument 2.\n"
724+
doc_arg2_nospace = "arg2: int\n Argument 2.\n"
725+
doc_arg3 = "arg3 : int\n Non-existent argument 1.\n"
726+
doc_opt1 = "opt1 : int\n Keyword argument 1.\n"
727+
doc_test = "test : int\n Internal keyword argument 1.\n"
704728
doc_returns = "\nReturns\n-------\n"
705-
doc_ret = "\nout : int\n"
706-
doc_ret_nospace = "\nout: int\n"
729+
doc_ret = "out : int\n"
730+
doc_ret_nospace = "out: int\n"
707731

708732
@pytest.mark.parametrize("docstring, exception, match", [
709733
(None, UserWarning, "missing docstring"),
@@ -721,8 +745,8 @@ def simple_function(arg1, arg2, opt1=None, **kwargs):
721745
doc_returns + doc_ret, Failed, "'test' not documented"),
722746
(doc_header + doc_parameters + doc_arg1 + doc_arg2_nospace + doc_opt1 +
723747
doc_test + doc_returns + doc_ret_nospace, UserWarning, "missing space"),
724-
(doc_header + doc_arg1 + doc_arg2_nospace + doc_opt1 + doc_test +
725-
doc_returns + doc_ret_nospace, Failed, "missing Parameters section"),
748+
(doc_header + doc_returns + doc_ret_nospace,
749+
Failed, "missing Parameters section"),
726750
(doc_header, None, ""),
727751
(doc_header + "\n.. deprecated::", None, ""),
728752
(doc_header + "\n\n simple_function() is deprecated",

0 commit comments

Comments
 (0)