|
17 | 17 | import control.flatsys
|
18 | 18 |
|
19 | 19 | # List of functions that we can skip testing (special cases)
|
20 |
| -skiplist = [ |
| 20 | +function_skiplist = [ |
21 | 21 | control.ControlPlot.reshape, # needed for legacy interface
|
| 22 | + control.phase_plot, # legacy function |
22 | 23 | ]
|
23 | 24 |
|
| 25 | +# List of keywords that we can skip testing (special cases) |
| 26 | +keyword_skiplist = { |
| 27 | + control.input_output_response: ['method'], |
| 28 | + control.nyquist_plot: ['color'], # checked separately |
| 29 | + control.optimal.solve_ocp: ['method'], # deprecated |
| 30 | + control.sisotool: ['kvect'], # deprecated |
| 31 | +} |
| 32 | + |
| 33 | +# Decide on the level of verbosity (use -rP when running pytest) |
| 34 | +verbose = 1 |
| 35 | + |
24 | 36 | @pytest.mark.parametrize("module, prefix", [
|
25 | 37 | (control, ""), (control.flatsys, "flatsys."),
|
26 | 38 | (control.optimal, "optimal."), (control.phaseplot, "phaseplot.")
|
27 | 39 | ])
|
28 | 40 | def test_docstrings(module, prefix):
|
29 | 41 | # Look through every object in the package
|
30 |
| - print(f"Checking module {module}") |
| 42 | + if verbose > 1: |
| 43 | + print(f"Checking module {module}") |
31 | 44 | for name, obj in inspect.getmembers(module):
|
32 | 45 | # Skip anything that is outside of this module
|
33 |
| - if inspect.getmodule(obj) is not None and \ |
34 |
| - not inspect.getmodule(obj).__name__.startswith('control'): |
| 46 | + if inspect.getmodule(obj) is not None and ( |
| 47 | + not inspect.getmodule(obj).__name__.startswith('control') |
| 48 | + or prefix != "" and inspect.getmodule(obj) != module): |
35 | 49 | # Skip anything that isn't part of the control package
|
36 | 50 | continue
|
37 | 51 |
|
38 | 52 | if inspect.isclass(obj):
|
39 |
| - print(f" Checking class {name}") |
| 53 | + if verbose > 1: |
| 54 | + print(f" Checking class {name}") |
40 | 55 | # Check member functions within the class
|
41 |
| - test_docstrings(obj, prefix + obj.__name__ + '.') |
| 56 | + test_docstrings(obj, prefix + name + '.') |
42 | 57 |
|
43 | 58 | if inspect.isfunction(obj):
|
44 |
| - # Skip anything that is inherited or hidden |
45 |
| - if inspect.isclass(module) and obj.__name__ not in module.__dict__ \ |
46 |
| - or obj.__name__.startswith('_') or obj in skiplist: |
| 59 | + # Skip anything that is inherited, hidden, or deprecated |
| 60 | + if inspect.isclass(module) and name not in module.__dict__ \ |
| 61 | + or name.startswith('_') or obj in function_skiplist: |
47 | 62 | continue
|
48 | 63 |
|
49 |
| - # Make sure there is a docstring |
50 |
| - print(f" Checking function {name}") |
| 64 | + # Get the docstring (skip w/ warning if there isn't one) |
| 65 | + if verbose > 1: |
| 66 | + print(f" Checking function {name}") |
51 | 67 | if obj.__doc__ is None:
|
52 | 68 | warnings.warn(
|
53 |
| - f"{module.__name__}.{obj.__name__} is missing docstring") |
| 69 | + f"{module.__name__}.{name} is missing docstring") |
| 70 | + continue |
| 71 | + else: |
| 72 | + docstring = inspect.getdoc(obj) |
| 73 | + source = inspect.getsource(obj) |
| 74 | + |
| 75 | + # Skip deprecated functions |
| 76 | + if f"{name} is deprecated" in docstring or \ |
| 77 | + "function is deprecated" in docstring or \ |
| 78 | + ".. deprecated::" in docstring: |
| 79 | + if verbose > 1: |
| 80 | + print(" [deprecated]") |
54 | 81 | continue
|
55 |
| - |
| 82 | + |
| 83 | + elif f"{name} is deprecated" in source: |
| 84 | + if verbose: |
| 85 | + print(f" {name} is deprecated, but not documented") |
| 86 | + warnings.warn(f"{name} deprecated, but not documented") |
| 87 | + continue |
| 88 | + |
56 | 89 | # Get the signature for the function
|
57 | 90 | sig = inspect.signature(obj)
|
58 | 91 |
|
59 | 92 | # Go through each parameter and make sure it is in the docstring
|
60 | 93 | for argname, par in sig.parameters.items():
|
61 |
| - if argname == 'self' or argname[0] == '_': |
| 94 | + |
| 95 | + # Look for arguments that we can skip |
| 96 | + if argname == 'self' or argname[0] == '_' or \ |
| 97 | + obj in keyword_skiplist and argname in keyword_skiplist[obj]: |
62 | 98 | continue
|
63 |
| - |
64 |
| - if par.kind == inspect.Parameter.VAR_KEYWORD: |
65 |
| - # Found a keyword argument; look at code for parsing |
66 |
| - warnings.warn("keyword argument checks not yet implemented") |
| 99 | + |
| 100 | + # Check for positional arguments |
| 101 | + if par.kind == inspect.Parameter.VAR_POSITIONAL: |
| 102 | + # Too complicated to check |
| 103 | + if f"*{argname}" not in docstring and verbose: |
| 104 | + print(f" {name} has positional arguments; " |
| 105 | + "check manually") |
| 106 | + continue |
| 107 | + |
| 108 | + # Check for keyword arguments (then look at code for parsing) |
| 109 | + elif par.kind == inspect.Parameter.VAR_KEYWORD: |
| 110 | + # See if we documented the keyward argumnt directly |
| 111 | + if f"**{argname}" in docstring: |
| 112 | + continue |
| 113 | + |
| 114 | + # Look for direct kwargs argument access |
| 115 | + kwargnames = set() |
| 116 | + for _, kwargname in re.findall( |
| 117 | + argname + r"(\[|\.pop\(|\.get\()'([\w]+)'", |
| 118 | + source): |
| 119 | + if verbose > 2: |
| 120 | + print(" Found direct keyword argument", |
| 121 | + kwargname) |
| 122 | + kwargnames.add(kwargname) |
| 123 | + |
| 124 | + # Look for kwargs access via _process_legacy_keyword |
| 125 | + for kwargname in re.findall( |
| 126 | + r"_process_legacy_keyword\([\s]*" + argname + |
| 127 | + r",[\s]*'[\w]+',[\s]*'([\w]+)'", source): |
| 128 | + if verbose > 2: |
| 129 | + print(" Found legacy keyword argument", |
| 130 | + {kwargname}) |
| 131 | + kwargnames.add(kwargname) |
| 132 | + |
| 133 | + for kwargname in kwargnames: |
| 134 | + if obj in keyword_skiplist and \ |
| 135 | + kwargname in keyword_skiplist[obj]: |
| 136 | + continue |
| 137 | + if verbose > 3: |
| 138 | + print(f" Checking keyword argument {kwargname}") |
| 139 | + assert _check_docstring( |
| 140 | + name, kwargname, inspect.getdoc(obj), |
| 141 | + prefix=prefix) |
67 | 142 |
|
68 | 143 | # Make sure this argument is documented properly in docstring
|
69 | 144 | else:
|
70 |
| - assert _check_docstring(obj.__name__, argname, obj.__doc__) |
| 145 | + if verbose > 3: |
| 146 | + print(f" Checking argument {argname}") |
| 147 | + assert _check_docstring( |
| 148 | + name, argname, docstring, prefix=prefix) |
71 | 149 |
|
72 | 150 |
|
73 | 151 | # Utility function to check for an argument in a docstring
|
74 |
| -def _check_docstring(funcname, argname, docstring): |
75 |
| - if re.search(f" ([ \\w]+, )*{argname}(,[ \\w]+)*[^ ]:", docstring): |
| 152 | +def _check_docstring(funcname, argname, docstring, prefix=""): |
| 153 | + funcname = prefix + funcname |
| 154 | + if re.search( |
| 155 | + "\n" + r"((\w+|\.{3}), )*" + argname + r"(, (\w+|\.{3}))*:", |
| 156 | + docstring): |
76 | 157 | # Found the string, but not in numpydoc form
|
| 158 | + if verbose: |
| 159 | + print(f" {funcname}: {argname} docstring missing space") |
77 | 160 | warnings.warn(f"{funcname} '{argname}' docstring missing space")
|
78 | 161 | return True
|
79 |
| - |
80 |
| - elif not re.search(f" ([ \\w]+, )*{argname}(,[ \\w]+)* :", docstring): |
| 162 | + |
| 163 | + elif not re.search( |
| 164 | + "\n" + r"((\w+|\.{3}), )*" + argname + r"(, (\w+|\.{3}))* :", |
| 165 | + docstring): |
81 | 166 | # return False
|
82 | 167 | #
|
83 | 168 | # Just issue a warning for now
|
| 169 | + if verbose: |
| 170 | + print(f" {funcname}: {argname} not documented") |
84 | 171 | warnings.warn(f"{funcname} '{argname}' not documented")
|
85 | 172 | return True
|
86 |
| - |
| 173 | + |
87 | 174 | return True
|
0 commit comments