Last active
March 31, 2022 15:14
-
-
Save schamp/0e4e5c67bdcad1c97b4d1f129b93442e to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# adapted the following from: | |
# https://stackoverflow.com/questions/14141170/how-can-i-just-list-undocumented-members-with-sphinx-autodoc#14238449 | |
# set up the types of members to check that are documented | |
members_to_watch = [ | |
'module', | |
'exception', | |
'function', | |
'class', | |
'method', | |
'attribute', | |
] | |
members_to_ignore = [ | |
'property', | |
] | |
from collections import defaultdict | |
from functools import partial | |
import inspect | |
import logging | |
import re | |
items_to_document = defaultdict(int) | |
items_documented = defaultdict(int) | |
def makeRecord(self, name, level, fn, lno, msg, args, exc_info, func=None, extra=None, sinfo=None): | |
""" | |
A factory method which can be overridden in subclasses to create | |
specialized LogRecords. | |
""" | |
rv = logging.LogRecord(name, level, fn, lno, msg, args, exc_info, func, sinfo) | |
if extra is not None: | |
if not isinstance(extra, str): | |
rv.__dict__.update(extra) | |
return rv | |
def warn_undocumented_members(app, what, name, obj, options, lines): | |
""" | |
A Sphinx hook to be attached to auto-process-docstring, which will identify which items need to be documented | |
(storing them in the global ``items_to_document`` map) and which have been documented (storing them in the global | |
``items_documented`` map). Emits warnings for each item that needs to be documented or has been, but doesn't exist | |
(e.g., parameters that are named incorrectly). | |
:param app: | |
:param what: | |
:param name: | |
:param obj: | |
:param options: | |
:param lines: | |
:return: None | |
""" | |
try: | |
logging.basicConfig( | |
stream=sys.stdout, | |
level=logging.DEBUG, | |
format='%(pathname)s:%(lineno)s: %(levelname)s: %(message)s' | |
) | |
logger = logging.getLogger(__name__) | |
# monkey patch our logger so we can override the location in the `extra` args | |
logger.makeRecord = partial(makeRecord, logger) | |
items_to_document[what] += 1 | |
# FIXME: find a way upstream to do this | |
# skip test files | |
if 'test' in name.lower(): | |
return | |
if what in members_to_watch: | |
if what == 'function': | |
filename = obj.__code__.co_filename | |
lineno = obj.__code__.co_firstlineno | |
items_to_document['return_value'] += 1 | |
else: | |
if what == 'method': | |
items_to_document['return_value'] += 1 | |
try: | |
filename = inspect.getfile(obj) | |
try: | |
lineno = inspect.getsourcelines(obj)[1] | |
except OSError: | |
lineno = '0' | |
except TypeError: | |
# for class attributes, it looks like the 'obj' is the value assigned to the attribute. | |
# in cases like 'False', this can't be passed to ``inspect.getfile(...)``. | |
filename = name | |
lineno = '?' | |
# since we have the full path and filename, we only want the lastmost name segment | |
def remove_common_prefix(pathname, objname): | |
""" | |
pathnames are /full/prefix/importroot/path/to/file.py | |
objnames are importroot.path.to.file.Class.method (etc) | |
""" | |
if pathname.endswith('.py'): | |
pathname = pathname[:-3] | |
pathname = pathname.replace('/', '.') | |
pathname_segments = pathname.split('.') | |
objname_segments = objname.split('.') | |
#print('looking for commonality in:') | |
#print(' ', pathname) | |
#print(' ', objname) | |
split_index = len(objname_segments) - 1 | |
# work backwards, removing objname segments until we get something that matches the end of the path | |
for i, o in reversed(list(enumerate(objname_segments))): | |
#print(' comparing', o, 'to', pathname_segments[-1]) | |
if o == pathname_segments[-1]: | |
#print(' ...found at', i) | |
split_index = i | |
break | |
shortname_segments = objname_segments[split_index+1:] | |
return '.'.join(shortname_segments) | |
if what == 'module': | |
shortname = name.split('.')[-1] | |
else: | |
shortname = remove_common_prefix(pathname=filename, objname=name) | |
if len(lines) == 0: | |
logger.warning( | |
'{name}: {what} is undocumented.'.format( | |
name=shortname, | |
what=what, | |
), | |
extra=dict(pathname=filename, lineno=lineno) | |
) | |
# modify the docstring so the rendered output highlights the omission | |
lines.append(".. Warning:: {} '{}' undocumented.".format(what, shortname)) | |
else: | |
items_documented[what] += 1 | |
if what in ['function', 'method']: | |
# for functions that are wrapped with decorators, we want the innermost function: | |
while '__wrapped__' in dir(obj): | |
obj = obj.__wrapped__ | |
# get the parameters and make sure there's a one-to-one and onto mapping to documented parameters | |
# to defined parameters | |
# capture *pargs and **kwargs as regular names so we can look for them in the documented list | |
argspec = inspect.getfullargspec(obj) | |
varargs = [argspec.varargs] if argspec.varargs else [] | |
varkw = [argspec.varkw] if argspec.varkw else [] | |
kwonly = argspec.kwonlyargs if argspec.kwonlyargs else [] | |
defined_params = argspec.args + varargs + varkw + kwonly | |
# to figure out if it's static, first we need to get the class it's defined in | |
try: | |
cls = getattr(inspect.getmodule(obj), | |
obj.__qualname__.split('.<locals>', 1)[0].rsplit('.', 1)[0]) \ | |
if inspect.isfunction(obj) and what == 'method' \ | |
else None | |
except AttributeError as e: | |
print('obj', obj) | |
raise e | |
# then we check to see if the type of the object pulled out of the class dict is 'staticmethod' | |
# see: https://stackoverflow.com/questions/14187973/python3-check-if-method-is-static | |
objname = obj.__qualname__.split('.')[-1] | |
objtype = type(cls.__dict__[objname]) if cls and objname in cls.__dict__ else None | |
isstatic = cls and 'staticmethod' in str(objtype) | |
# okay, now we can actually wrap it up. | |
# for methods, don't require that the first param (self, or cls) be documented, whatever it may be called. | |
# except for static methods, which don't have that first item (which is why we had to figure out whether | |
# it's static) | |
defined_params = defined_params[ | |
1: | |
] if defined_params and what == 'method' and not isstatic else defined_params | |
defined_params = set(defined_params) | |
documented_params = set(re.findall(':param ([^:]+):\s*[^:]+', ''.join(lines))) | |
return_documented = re.search(':return(s?):\s*[^:]+', ''.join(lines)) | |
defined_but_not_documented_params = defined_params - documented_params | |
documented_but_not_defined_params = documented_params - defined_params | |
documented_defined_params = documented_params & defined_params | |
items_to_document['argument'] += len(defined_params) | |
items_documented['argument'] += len(documented_defined_params) | |
for param in defined_but_not_documented_params: | |
logger.warning( | |
f"{shortname}: '{param}' argument is undocumented.", | |
extra=dict(pathname=filename, lineno=lineno) | |
) | |
lines.append(f".. Warning:: argument '{param}' is undocumented.") | |
for param in documented_but_not_defined_params: | |
logger.warning( | |
f"{shortname}: '{param}' argument is documented but not defined.", | |
extra=dict(pathname=filename, lineno=lineno) | |
) | |
lines.append(f".. Warning:: argument '{param}' is documented but not defined.") | |
if not return_documented: | |
logger.warning( | |
f'{shortname}: return value is undocumented', | |
extra=dict(pathname=filename, lineno=lineno) | |
) | |
lines.append(f'.. Warning:: Return value is undocumented.') | |
else: | |
items_documented['return_value'] += 1 | |
elif what not in members_to_ignore: | |
print(f'checking {name} (which is {what}): {obj}') | |
except Exception as e: | |
import traceback | |
print('something went wrong:', e) | |
traceback.print_exc() | |
def emit_documentation_coverage(app, exception): | |
""" | |
After all documentation counts have been done, emit totals and coverage to the log. | |
:param app: | |
:param exception: | |
:return: | |
""" | |
logger = logging.getLogger(__name__) | |
for what in items_to_document: | |
to_document = items_to_document[what] | |
documented = items_documented[what] | |
coverage_percent = (documented / to_document) * 100.0 if documented else 0 | |
logger.info( | |
f'Total {what} coverage: {documented} out of {to_document} ({coverage_percent:2.0f}%)' | |
) | |
total_documented = sum(items_documented.values()) | |
total_to_document = sum(items_to_document.values()) | |
total_coverage_percent = (total_documented / total_to_document) * 100.0 if total_to_document else 0 | |
logger.info( | |
f'Total documentation coverage: {total_documented} out of {total_to_document} ({total_coverage_percent:2.0f}%)' | |
) | |
# This is the expected signature of the handler for this event, cf doc | |
def autodoc_skip_member_handler(app, what, name, obj, skip, options): | |
# Basic approach; you might want a regex instead | |
return 'test' in name.lower() | |
# connection the above function to the event | |
def setup(app): | |
#app.connect('autodoc-skip-member', autodoc_skip_member_handler) | |
app.connect('autodoc-process-docstring', warn_undocumented_members) | |
app.connect('build-finished', emit_documentation_coverage) | |
pass | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment