diff --git a/darglint/config.py b/darglint/config.py index 1f5b22c..67bc262 100644 --- a/darglint/config.py +++ b/darglint/config.py @@ -31,19 +31,22 @@ class Configuration(object): - def __init__(self, ignore, message_template, style): - # type: (List[str], Optional[str], DocstringStyle) -> None + def __init__(self, ignore, message_template, style, raise_on_missing_docstrings): + # type: (List[str], Optional[str], DocstringStyle, bool) -> None """Initialize the configuration object. Args: ignore: A list of error codes to ignore. message_template: the template with which to format the errors. style: The style of docstring. + raise_on_missing_docstrings: Report an error if a public function or method + is missing a docstring. """ self.ignore = ignore self.message_template = message_template self.style = style + self.raise_on_missing_docstrings = raise_on_missing_docstrings def load_config_file(filename): # type: (str) -> Configuration @@ -64,6 +67,7 @@ def load_config_file(filename): # type: (str) -> Configuration ignore = list() message_template = None style = DocstringStyle.GOOGLE + raise_on_missing_docstrings = False if 'darglint' in config.sections(): if 'ignore' in config['darglint']: errors = config['darglint']['ignore'] @@ -83,10 +87,15 @@ def load_config_file(filename): # type: (str) -> Configuration [x.name for x in DocstringStyle] ) ) + if 'raise_on_missing_docstrings' in config['darglint']: + raise_on_missing_docstrings = config.getboolean( + 'darglint', 'raise_on_missing_docstrings') + return Configuration( ignore=ignore, message_template=message_template, - style=style + style=style, + raise_on_missing_docstrings=raise_on_missing_docstrings ) @@ -167,7 +176,8 @@ def get_config(): # type: () -> Configuration return Configuration( ignore=list(), message_template=None, - style=DocstringStyle.GOOGLE + style=DocstringStyle.GOOGLE, + raise_on_missing_docstrings=False ) return load_config_file(filename) diff --git a/darglint/driver.py b/darglint/driver.py index 6232874..bf2d9e5 100644 --- a/darglint/driver.py +++ b/darglint/driver.py @@ -92,6 +92,14 @@ 'only google or sphinx styles are supported.' ) ) +parser.add_argument( + '--raise-on-missing-docstrings', + action='store_true', + help=( + 'Missing docstrings for public functions or methods are considered an ' + 'error.' + ) +) # ---------------------- MAIN SCRIPT --------------------------------- @@ -138,6 +146,8 @@ def get_error_report(filename, def print_error_list(): print('\n'.join([ + 'I002: Missing docstring for public method.' + 'I003: Missing docstring for public function.' 'I101: The docstring is missing a parameter in the definition.', 'I102: The docstring contains a parameter not in function.', 'I103: The docstring parameter type doesn\'t match function.', @@ -181,6 +191,10 @@ def main(): config.style = DocstringStyle.SPHINX elif args.docstring_style == 'google': config.style = DocstringStyle.GOOGLE + + if args.raise_on_missing_docstrings: + config.raise_on_missing_docstrings = True + raise_errors_for_syntax = args.raise_syntax or False for filename in args.files: error_report = get_error_report( diff --git a/darglint/errors.py b/darglint/errors.py index 8842e85..1a81365 100644 --- a/darglint/errors.py +++ b/darglint/errors.py @@ -8,6 +8,7 @@ I Interface S Style + 000 Missing docstrings 100 Args 200 Returns 300 Yields @@ -151,6 +152,50 @@ def __init__(self, function, message, line_numbers=None): ) +class MissingDocstringForPublicMethod(DarglintError): + """Describes when a docstring is missing for a public method.""" + + error_code = 'I002' + + def __init__(self, function, line_numbers=None): + # type: (Union[ast.FunctionDef, ast.AsyncFunctionDef], Tuple[int, int]) -> None + """Instantiate the error's message. + + Args: + function: An ast node for the function. + line_numbers: The line numbers where this error occurs. + + """ + self.general_message = 'Missing docstring for public method' + self.terse_message = function.name + super(MissingDocstringForPublicMethod, self).__init__( + function, + line_numbers=line_numbers, + ) + + +class MissingDocstringForPublicFunction(DarglintError): + """Describes when a docstring is missing for a public function.""" + + error_code = 'I003' + + def __init__(self, function, line_numbers=None): + # type: (Union[ast.FunctionDef, ast.AsyncFunctionDef], Tuple[int, int]) -> None + """Instantiate the error's message. + + Args: + function: An ast node for the function. + line_numbers: The line numbers where this error occurs. + + """ + self.general_message = 'Missing docstring for public function' + self.terse_message = function.name + super(MissingDocstringForPublicFunction, self).__init__( + function, + line_numbers=line_numbers, + ) + + class MissingParameterError(DarglintError): """Describes when a docstring is missing a parameter in the definition.""" diff --git a/darglint/integrity_checker.py b/darglint/integrity_checker.py index 1d24c0f..2ebf6e9 100644 --- a/darglint/integrity_checker.py +++ b/darglint/integrity_checker.py @@ -34,6 +34,8 @@ ExcessReturnError, ExcessVariableError, ExcessYieldError, + MissingDocstringForPublicMethod, + MissingDocstringForPublicFunction, MissingParameterError, MissingRaiseError, MissingReturnError, @@ -64,7 +66,8 @@ def __init__(self, config=Configuration( ignore=[], message_template=None, - style=DocstringStyle.GOOGLE + style=DocstringStyle.GOOGLE, + raise_on_missing_docstrings=False ), raise_errors=False ): @@ -93,8 +96,8 @@ def run_checks(self, function): """ self.function = function - if function.docstring is not None: - try: + try: + if function.docstring is not None: if self.config.style == DocstringStyle.GOOGLE: self.docstring = Docstring.from_google( function.docstring, @@ -113,24 +116,35 @@ def run_checks(self, function): self._check_yield() self._check_raises() self._sorted = False - except ParserException as exception: - # If a syntax exception was raised, we may still - # want to ignore it, if we place a noqa statement. - if ( - SYNTAX_NOQA.search(function.docstring) - or EXPLICIT_GLOBAL_NOQA.search(function.docstring) - or BARE_NOQA.search(function.docstring) - ): - return - if self.raise_errors: - raise + # If not docstring is given, and raise on missing is true, and its not + # a private function, add an error. + elif self.config.raise_on_missing_docstrings and not function.name.startswith("_"): + cls = (MissingDocstringForPublicMethod + if function.is_method else MissingDocstringForPublicFunction) self.errors.append( - exception.style_error( + cls( self.function.function, - message=str(exception), - line_numbers=exception.line_numbers, - ), + line_numbers=(-1, -1), + ) ) + except ParserException as exception: + # If a syntax exception was raised, we may still + # want to ignore it, if we place a noqa statement. + if ( + SYNTAX_NOQA.search(function.docstring) + or EXPLICIT_GLOBAL_NOQA.search(function.docstring) + or BARE_NOQA.search(function.docstring) + ): + return + if self.raise_errors: + raise + self.errors.append( + exception.style_error( + self.function.function, + message=str(exception), + line_numbers=exception.line_numbers, + ), + ) def _check_parameter_types(self): # type: () -> None diff --git a/tests/test_integrity_checker.py b/tests/test_integrity_checker.py index 3d2b895..78a6fd4 100644 --- a/tests/test_integrity_checker.py +++ b/tests/test_integrity_checker.py @@ -18,6 +18,8 @@ MissingRaiseError, MissingReturnError, MissingYieldError, + MissingDocstringForPublicFunction, + MissingDocstringForPublicMethod, ParameterTypeMismatchError, ReturnTypeMismatchError, ) @@ -30,7 +32,8 @@ def setUp(self): self.config = Configuration( ignore=[], message_template=None, - style=DocstringStyle.SPHINX + style=DocstringStyle.SPHINX, + raise_on_missing_docstrings=False ) def test_missing_parameter(self): @@ -644,3 +647,80 @@ def test_global_noqa_works_for_syntax_errors(self): ]) for variant in ['# noqa: *', '# noqa: S001', '# noqa']: self.has_no_errors(program.format(variant)) + + self.config = Configuration( + ignore=[], + message_template=None, + style=DocstringStyle.SPHINX, + raise_on_missing_docstrings=False + ) + + def test_check_for_missing_docstrings(self): + """Make sure we capture missing parameters.""" + program = '\n'.join([ + # Function without a docstring. + 'def foo():', + ' pass', + # Function with a docstring. + 'def bar():', + ' """"docstring"""', + ' pass', + # private function without a docstring. + 'def _foo():', + ' pass', + 'class FooBar:', + # Method with a docstring. + ' def bar_method(self):', + ' """docstring"""', + ' pass', + # Method without a docstring. + ' def foo_method(self):', + ' pass', + # Private method without a docstring. + ' def _foo_method(self):', + ' pass', + ]) + tree = ast.parse(program) + functions = get_function_descriptions(tree) + + # No errors if raise_on_missing_docstrings is False. + checker = IntegrityChecker( + Configuration( + ignore=[], + message_template=None, + style=DocstringStyle.GOOGLE, + raise_on_missing_docstrings=False + ) + ) + for f in functions: + checker.run_checks(f) + errors = checker.errors + self.assertEqual(len(errors), 0) + + # But two errors if True. + checker = IntegrityChecker( + Configuration( + ignore=[], + message_template=None, + style=DocstringStyle.GOOGLE, + raise_on_missing_docstrings=True + ) + ) + for f in functions: + checker.run_checks(f) + errors = checker.errors + self.assertEqual(len(errors), 2) + + # Somehow the functions are parsed methods -> functions. + self.assertTrue(isinstance( + errors[0], MissingDocstringForPublicMethod)) + self.assertTrue(isinstance( + errors[1], MissingDocstringForPublicFunction)) + self.assertEqual(errors[0].general_message, + 'Missing docstring for public method') + self.assertEqual(errors[0].terse_message, + 'foo_method') + self.assertEqual(errors[1].general_message, + 'Missing docstring for public function') + self.assertEqual(errors[1].terse_message, + 'foo')