Repository: oppia/oppia

Issue:
Add lint check to ensure that there is a newline above args in python doc string
As per https://github.com/oppia/oppia/pull/7813#discussion_r339459963, we should be adding a newline above the Python doc string. 
Add lint check to ensure that there is a newline above args in python doc string
As per https://github.com/oppia/oppia/pull/7813#discussion_r339459963, we should be adding a newline above the Python doc string. 


Discussion:
@kevinlee12 I would like to work on this issue.
go for it!
@kevinlee12 I would like to work on this issue.
go for it!

Resolving patch:
diff --git a/scripts/pylint_extensions.py b/scripts/pylint_extensions.py
--- a/scripts/pylint_extensions.py
+++ b/scripts/pylint_extensions.py
@@ -639,7 +639,6 @@ def check_single_constructor_params(self, class_doc, init_doc, class_node):
         """Checks whether a class and corresponding  init() method are
         documented. If both of them are documented, it adds an error message.
 
-
         Args:
             class_doc: Docstring. Pylint docstring class instance representing
                 a class's docstring.
@@ -658,6 +657,7 @@ def check_single_constructor_params(self, class_doc, init_doc, class_node):
     def _handle_no_raise_doc(self, excs, node):
         """Checks whether the raised exception in a function has been
         documented, add a message otherwise.
+
         Args:
             excs: list(str). A list of exception types.
             node: astroid.scoped_nodes.Function. Node to access module content.
@@ -1010,7 +1010,6 @@ def process_module(self, node):
 
             if file_content[line_num] == b'\n':
                 blank_line_counter += 1
-
             else:
                 blank_line_counter = 0
 
@@ -1020,6 +1019,83 @@ def process_module(self, node):
                     self.add_message('excessive-new-lines', line=line_num + 1)
 
 
+class SingleNewlineAboveArgsChecker(checkers.BaseChecker):
+    """Checker for single space above args in python doc string."""
+
+    __implements__ = interfaces.IRawChecker
+    name = 'single-space-above-args-raises-returns'
+    priority = -1
+    msgs = {
+        'C0012': (
+            'Files must have a single newline above args in doc string.',
+            'single-space-above-args',
+            'Please enter a single newline above args in doc string.'
+        ),
+        'C0013': (
+            'Files must have a single newline above returns in doc string.',
+            'single-space-above-returns',
+            'Please enter a single newline above returns in doc string.'
+        ),
+        'C0014': (
+            'Files must have a single newline above raises in doc string.',
+            'single-space-above-raises',
+            'Please enter a single newline above raises in doc string.'
+        )
+    }
+
+    def process_module(self, node):
+        """Process a module to ensure that there is a single newline above args,
+        raises, returns in python doc string.
+
+        Args:
+            node: astroid.scoped_nodes.Function. Node to access module content.
+        """
+
+        in_multi_line_comment = False
+        multi_line_indicator = b'"""'
+        file_content = read_from_node(node)
+        file_length = len(file_content)
+        blank_line_counter = 0
+
+        for line_num in python_utils.RANGE(file_length):
+            line = file_content[line_num].strip()
+
+            # Single multi-line comment, ignore it.
+            if line.count(multi_line_indicator) == 2:
+                continue
+
+            # Flip multi-line boolean depending on whether or not we see
+            # the multi-line indicator. Possible for multiline comment to
+            # be somewhere other than the start of a line (e.g. func arg),
+            # so we can't look at start of or end of a line, which is why
+            # the case where two indicators in a single line is handled
+            # separately (i.e. one line comment with multi-line strings).
+            if multi_line_indicator in line:
+                in_multi_line_comment = not in_multi_line_comment
+
+            # Ignore anything inside a multi-line comment.
+            if in_multi_line_comment:
+                continue
+
+            if file_content[line_num] == b'\n':
+                blank_line_counter += 1
+            else:
+                blank_line_counter = 0
+
+            if (line_num + 1 < file_length and (
+                    blank_line_counter == 0 or blank_line_counter > 1)):
+                line = file_content[line_num + 1].strip()
+                if line == b'Args:':
+                    self.add_message(
+                        'single-space-above-args', line=line_num + 1)
+                elif line == b'Returns:':
+                    self.add_message(
+                        'single-space-above-returns', line=line_num + 1)
+                elif line == b'Raises:':
+                    self.add_message(
+                        'single-space-above-raises', line=line_num + 1)
+
+
 def register(linter):
     """Registers the checker with pylint.
 
@@ -1036,3 +1112,4 @@ def register(linter):
     linter.register_checker(SingleCharAndNewlineAtEOFChecker(linter))
     linter.register_checker(SingleSpaceAfterYieldChecker(linter))
     linter.register_checker(ExcessiveEmptyLinesChecker(linter))
+    linter.register_checker(SingleNewlineAboveArgsChecker(linter))


Test patch:
diff --git a/scripts/pylint_extensions_test.py b/scripts/pylint_extensions_test.py
--- a/scripts/pylint_extensions_test.py
+++ b/scripts/pylint_extensions_test.py
@@ -1143,3 +1143,329 @@ def func3():
 
         with checker_test_object.assertNoMessages():
             temp_file.close()
+
+
+class SingleNewlineAboveArgsCheckerTests(unittest.TestCase):
+
+    def setUp(self):
+        super(SingleNewlineAboveArgsCheckerTests, self).setUp()
+        self.checker_test_object = testutils.CheckerTestCase()
+        self.checker_test_object.CHECKER_CLASS = (
+            pylint_extensions.SingleNewlineAboveArgsChecker)
+        self.checker_test_object.setup_method()
+
+    def test_no_newline_above_args(self):
+        node_single_newline_above_args = astroid.scoped_nodes.Module(
+            name='test',
+            doc='Custom test')
+        temp_file = tempfile.NamedTemporaryFile()
+        filename = temp_file.name
+
+        with python_utils.open_file(filename, 'w') as tmp:
+            tmp.write(
+                u"""def func(arg):
+                        '''Do something.
+                        Args:
+                            arg: argument
+                        '''
+                        do something
+                """)
+        node_single_newline_above_args.file = filename
+        node_single_newline_above_args.path = filename
+
+        self.checker_test_object.checker.process_module(
+            node_single_newline_above_args)
+
+        with self.checker_test_object.assertAddsMessages(
+            testutils.Message(
+                msg_id='single-space-above-args',
+                line=2
+            ),
+        ):
+            temp_file.close()
+
+    def test_no_newline_above_raises(self):
+        node_single_newline_above_raises = astroid.scoped_nodes.Module(
+            name='test',
+            doc='Custom test')
+        temp_file = tempfile.NamedTemporaryFile()
+        filename = temp_file.name
+
+        with python_utils.open_file(filename, 'w') as tmp:
+            tmp.write(
+                u"""def func():
+                        '''Raises exception.
+                        Raises:
+                            raises_exception
+                        '''
+                        raises_exception
+                """)
+        node_single_newline_above_raises.file = filename
+        node_single_newline_above_raises.path = filename
+
+        self.checker_test_object.checker.process_module(
+            node_single_newline_above_raises)
+
+        with self.checker_test_object.assertAddsMessages(
+            testutils.Message(
+                msg_id='single-space-above-raises',
+                line=2
+            ),
+        ):
+            temp_file.close()
+
+    def test_no_newline_above_return(self):
+        node_with_no_space_above_return = astroid.scoped_nodes.Module(
+            name='test',
+            doc='Custom test')
+        temp_file = tempfile.NamedTemporaryFile()
+        filename = temp_file.name
+
+        with python_utils.open_file(filename, 'w') as tmp:
+            tmp.write(
+                u"""def func():
+                    '''Returns something.
+                    Returns:
+                        returns_something
+                    '''
+                    returns_something
+                """)
+        node_with_no_space_above_return.file = filename
+        node_with_no_space_above_return.path = filename
+
+        self.checker_test_object.checker.process_module(
+            node_with_no_space_above_return)
+
+        with self.checker_test_object.assertAddsMessages(
+            testutils.Message(
+                msg_id='single-space-above-returns',
+                line=2
+            ),
+        ):
+            temp_file.close()
+
+    def test_varying_combination_of_newline_above_args(self):
+        node_newline_above_args_raises = astroid.scoped_nodes.Module(
+            name='test',
+            doc='Custom test')
+        temp_file = tempfile.NamedTemporaryFile()
+        filename = temp_file.name
+
+        with python_utils.open_file(filename, 'w') as tmp:
+            tmp.write(
+                u"""def func(arg):
+                    '''Raises exception.
+
+                    Args:
+                        arg: argument
+                    Raises:
+                        raises_something
+                    '''
+                    raises_exception
+                """)
+        node_newline_above_args_raises.file = filename
+        node_newline_above_args_raises.path = filename
+
+        self.checker_test_object.checker.process_module(
+            node_newline_above_args_raises)
+
+        with self.checker_test_object.assertAddsMessages(
+            testutils.Message(
+                msg_id='single-space-above-raises',
+                line=5
+            ),
+        ):
+            temp_file.close()
+
+        node_newline_above_args_returns = astroid.scoped_nodes.Module(
+            name='test',
+            doc='Custom test')
+        temp_file = tempfile.NamedTemporaryFile()
+        filename = temp_file.name
+
+        with python_utils.open_file(filename, 'w') as tmp:
+            tmp.write(
+                u"""def func(arg):
+                    '''Returns Something.
+
+                    Args:
+                        arg: argument
+                    Returns:
+                        returns_something
+                    '''
+                    returns_something
+                """)
+        node_newline_above_args_returns.file = filename
+        node_newline_above_args_returns.path = filename
+
+        self.checker_test_object.checker.process_module(
+            node_newline_above_args_returns)
+
+        with self.checker_test_object.assertAddsMessages(
+            testutils.Message(
+                msg_id='single-space-above-returns',
+                line=5
+            ),
+        ):
+            temp_file.close()
+
+        node_newline_above_returns_raises = astroid.scoped_nodes.Module(
+            name='test',
+            doc='Custom test')
+        temp_file = tempfile.NamedTemporaryFile()
+        filename = temp_file.name
+
+        with python_utils.open_file(filename, 'w') as tmp:
+            tmp.write(
+                u"""def func():
+                    '''Do something.
+
+
+
+                    Raises:
+                        raises_exception
+
+                    Returns:
+                        returns_something
+                    '''
+                    raises_exception
+                    returns_something
+                """)
+        node_newline_above_returns_raises.file = filename
+        node_newline_above_returns_raises.path = filename
+
+        self.checker_test_object.checker.process_module(
+            node_newline_above_returns_raises)
+
+        with self.checker_test_object.assertAddsMessages(
+            testutils.Message(
+                msg_id='single-space-above-raises',
+                line=5
+            ),
+        ):
+            temp_file.close()
+
+    def test_excessive_newline_above_args(self):
+        node_with_two_newline = astroid.scoped_nodes.Module(
+            name='test',
+            doc='Custom test')
+
+        temp_file = tempfile.NamedTemporaryFile()
+        filename = temp_file.name
+        with python_utils.open_file(filename, 'w') as tmp:
+            tmp.write(
+                u"""def func(arg):
+                        '''Returns something.
+
+
+                        Args:
+                            arg: argument
+
+
+                        Returns:
+                            returns_something
+                        '''
+                        returns something
+                """)
+        node_with_two_newline.file = filename
+        node_with_two_newline.path = filename
+
+        self.checker_test_object.checker.process_module(
+            node_with_two_newline)
+
+        with self.checker_test_object.assertAddsMessages(
+            testutils.Message(
+                msg_id='single-space-above-args',
+                line=4
+            ),
+            testutils.Message(
+                msg_id='single-space-above-returns',
+                line=8
+            ),
+        ):
+            temp_file.close()
+
+    def test_return_in_comment(self):
+        node_with_return_in_comment = astroid.scoped_nodes.Module(
+            name='test',
+            doc='Custom test')
+        temp_file = tempfile.NamedTemporaryFile()
+        filename = temp_file.name
+
+        with python_utils.open_file(filename, 'w') as tmp:
+            tmp.write(
+                u"""def func(arg):
+                        '''Returns something.
+
+                        Args:
+                            arg: argument
+
+                        Returns:
+                            returns_something
+                        '''
+                        "Returns: something"
+                        returns_something
+                """)
+        node_with_return_in_comment.file = filename
+        node_with_return_in_comment.path = filename
+
+        self.checker_test_object.checker.process_module(
+            node_with_return_in_comment)
+
+        with self.checker_test_object.assertNoMessages():
+            temp_file.close()
+
+    def test_function_with_no_args(self):
+        node_with_no_args = astroid.scoped_nodes.Module(
+            name='test',
+            doc='Custom test')
+        temp_file = tempfile.NamedTemporaryFile()
+        filename = temp_file.name
+        with python_utils.open_file(filename, 'w') as tmp:
+            tmp.write(
+                u"""def func():
+                    '''Do something.'''
+
+                    do something
+                """)
+        node_with_no_args.file = filename
+        node_with_no_args.path = filename
+
+        self.checker_test_object.checker.process_module(
+            node_with_no_args)
+
+        with self.checker_test_object.assertNoMessages():
+            temp_file.close()
+
+    def test_well_placed_newline(self):
+        node_with_no_error_message = astroid.scoped_nodes.Module(
+            name='test',
+            doc='Custom test')
+
+        temp_file = tempfile.NamedTemporaryFile()
+        filename = temp_file.name
+        with python_utils.open_file(filename, 'w') as tmp:
+            tmp.write(
+                u"""def func(arg):
+                        '''Returns something.
+
+                        Args:
+                            arg: argument
+
+                        Returns:
+                            returns_something
+
+                        Raises:
+                            raises something
+                        '''
+                        raises_something
+                        returns_something
+                """)
+        node_with_no_error_message.file = filename
+        node_with_no_error_message.path = filename
+
+        self.checker_test_object.checker.process_module(
+            node_with_no_error_message)
+
+        with self.checker_test_object.assertNoMessages():
+            temp_file.close()