"""
This tests module docstrings to ensure
* all the config options are documented
* correct default values are given
* config parameters listed in alphabetical order
Specific modules/config parameters are excluded but this should be discouraged.
"""
import ast
import re
from collections import OrderedDict
from pathlib import Path
from py3status.docstrings import core_module_docstrings
IGNORE_MODULE = []
ILLEGAL_CONFIG_OPTIONS = ["min_width", "separator", "separator_block_width", "align"]
IGNORE_ILLEGAL_CONFIG_OPTIONS = [("group", "align")]
IGNORE_ITEM = []
OBSOLETE_PARAM = []
RE_PARAM = re.compile(r"^\n- `(?P<name>[^`]*)`.*?(\*\(default (?P<value>(.*))\)\*)?$")
AST_LITERAL_TYPES = {
"Num": "n",
"Str": "s",
"NameConstant": "value",
"Constant": "value",
}
MODULE_PATH = Path(__file__).resolve().parent.parent / "py3status" / "modules"
def docstring_params(docstring):
"""
Crudely parse the docstring and get parameters and their defaults
"""
def find_line(text):
for index, line in enumerate(docstring):
if line == text:
return index + 1
def find_params(start):
"""
find documented parameters and add them to params dict
"""
params = OrderedDict()
if start is None:
return params
lines = []
for part in docstring[start:]:
if part == "\n":
break
if part.startswith("\n- "):
lines.append(part[:-1])
continue
lines[-1] += part[:-1]
for line in lines:
match = RE_PARAM.match(line)
if match:
if match.group("value"):
try:
value = eval(match.group("value"))
try:
value = value.replace("&", "&")
value = value.replace("<", "<")
value = value.replace(">", ">")
except AttributeError:
pass
value = (value,)
except (SyntaxError, NameError):
value = "?Unknown?"
else:
value = None
params[match.group("name")] = value
return params
params = find_params(find_line("Configuration parameters:\n"))
obsolete = find_params(find_line("Obsolete configuration parameters:\n"))
return params, obsolete
def get_module_attributes(path):
"""
Get the attributes from the module from the ast. We use ast because this
way we can examine modules even if we do not have their required python
modules installed.
"""
names = {}
attributes = OrderedDict()
def get_values(source, dest, extra=None):
"""
Get a list of the configuration parameters and their defaults.
"""
def get_value(value):
"""
Get the actual value from the ast allowing recursion
"""
class_name = value.__class__.__name__
value_type = AST_LITERAL_TYPES.get(class_name)
if value_type:
attr_value = getattr(value, value_type)
else:
if class_name == "Dict":
attr_value = {
get_value(k): get_value(v)
for k, v in zip(value.keys, value.values)
}
elif class_name == "List":
attr_value = [get_value(e) for e in value.elts]
elif class_name == "Name":
attr_value = extra.get(value.id)
elif class_name == "Tuple":
attr_value = tuple(get_value(e) for e in value.elts)
elif class_name == "UnaryOp":
op = value.op.__class__.__name__
attr_value = get_value(value.operand)
if op == "USub":
attr_value = -attr_value
else:
attr_value = f"UNKNOWN {class_name} UnaryOp {op}"
elif class_name == "BinOp":
left = get_value(value.left)
right = get_value(value.right)
op = value.op.__class__.__name__
try:
if op == "Add":
attr_value = left + right
elif op == "Mult":
attr_value = left * right
else:
attr_value = f"UNKNOWN {class_name} BinOp {op}"
except Exception:
attr_value = f"UNKNOWN {class_name} BinOp error"
else:
attr_value = f"UNKNOWN {class_name}"
return attr_value
if extra is None:
extra = {}
for line in source:
if isinstance(line, ast.Assign):
if len(line.targets) == 1 and isinstance(line.targets[0], ast.Name):
attr = line.targets[0].id
value = line.value
dest[attr] = get_value(value)
with path.open() as f:
tree = ast.parse(f.read(), "")
get_values(tree.body, names)
for statement in tree.body:
if isinstance(statement, ast.ClassDef) and statement.name == "Py3status":
get_values(statement.body, attributes, names)
return attributes
def _gen_diff(source, target, source_label="Source", target_label="Target"):
blank = object()
max_len = max(len(source), len(target))
for lst in (source, target):
lst.extend([blank] * (max_len - len(lst)))
max_elem_len = max(len(str(elem)) for elem in source)
padding = " "
format_str = padding + f"{{:{max_elem_len}}} {{}} {{}}"
middle_orig = " "
out = [
format_str.format(source_label, middle_orig, target_label),
padding + ("-" * (2 * max_elem_len + len(middle_orig) + 2)),
]
for (have, want) in zip(source, target):
middle = middle_orig
if have == blank:
middle = "<<"
have = ""
elif want == blank:
middle = ">>"
want = ""
elif have != want:
middle = "!!"
out.append(format_str.format(str(have), middle, str(want)))
return "\n".join(out) + "\n"
def check_docstrings():
all_errors = []
docstrings = core_module_docstrings()
for module_name in sorted(docstrings):
errors = []
if module_name in IGNORE_MODULE:
continue
path = MODULE_PATH / f"{module_name}.py"
mod_config = get_module_attributes(path)
params, obsolete = docstring_params(docstrings[module_name])
if list(params) != sorted(params):
keys = list(params)
msg = "config params not in alphabetical order should be\n{keys}"
errors.append(msg.format(keys=_gen_diff(keys, sorted(keys))))
if list(obsolete) != sorted(obsolete):
keys = list(obsolete)
msg = "obsolete params not in alphabetical order should be\n{keys}"
errors.append(msg.format(keys=_gen_diff(keys, sorted(keys))))
params.update(obsolete)
allowed_bad_config_params = [
x[1] for x in IGNORE_ILLEGAL_CONFIG_OPTIONS if x[0] == module_name
]
bad_config_params = set(ILLEGAL_CONFIG_OPTIONS) & set(params)
bad_config_params -= set(allowed_bad_config_params)
if bad_config_params:
msg = "The following config parameters are reserved and"
msg += "should not be used by modules:\n {}"
errors.append(msg.format(", ".join(bad_config_params)))
keys = list(mod_config)
for item in IGNORE_ITEM:
if item[0] == module_name and item[1] in keys:
keys.remove(item[1])
for item in OBSOLETE_PARAM:
if item[0] == module_name and item[1] in keys:
keys.remove(item[1])
if keys != sorted(keys):
errors.append("Attributes not in alphabetical order, should be")
errors.append(_gen_diff(keys, sorted(keys)))
for param in sorted(mod_config):
if (module_name, param) in IGNORE_ITEM:
continue
default = mod_config[param]
if param not in params:
msg = "{}: (default {!r}) not in module docstring"
errors.append(msg.format(param, default))
continue
default_doc = params[param]
if default_doc is None:
msg = "`{}` default not in docstring add (default {!r})"
errors.append(msg.format(param, default))
del params[param]
continue
if default_doc[0] != default:
msg = "`{}` (default {!r}) does not match docstring {!r}"
errors.append(msg.format(param, default, default_doc[0]))
del params[param]
continue
del params[param]
for param in params:
if (module_name, param) in IGNORE_ITEM:
continue
errors.append(f"{param} in module docstring but not module")
if errors:
all_errors += ["=" * 30, module_name, "=" * 30] + errors + ["\n"]
return all_errors
def test_docstrings():
errors = check_docstrings()
if errors:
print("Docstring error(s) detected!\n\n")
print(
"The tests may give incorrect errors for some configuration "
"parameters that the tests do not understand. "
"Such parameters can be excluded from the test by adding them "
"to the IGNORE_ITEM list in tests/test_module_doc.py\n\n"
)
for error in errors:
print(error)
assert False