Skip to content

Instantly share code, notes, and snippets.

@schamp
Last active March 31, 2022 15:14
Show Gist options
  • Save schamp/0e4e5c67bdcad1c97b4d1f129b93442e to your computer and use it in GitHub Desktop.
Save schamp/0e4e5c67bdcad1c97b4d1f129b93442e to your computer and use it in GitHub Desktop.
# 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