/
githubmirror
/
oppia
Обзор
Документация
Войти
/
githubmirror
/
oppia
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
scripts/linters/pylint_extensions.py
3 078 строк
121 KB
Sean Lip
Fully remove the Docker installation. (#23936)
01 дек 2025, 08:01
Не верифицирован
01 дек 2025, 08:01
2288eb9
Код
Авторство
О чём код?
# coding: utf-8 # # Copyright 2018 The Oppia Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS-IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Implements additional custom Pylint checkers to be used as part of presubmit checks. Next message id would be C0042. """ from __future__ import annotations import copy import fnmatch import linecache import re import tokenize from core import handler_schema_constants from pylint import lint from pylint import utils as pylint_utils from typing import ( Dict, Final, Generator, List, Optional, Pattern, Set, Tuple, TypedDict, ) from . import docstrings_checker # List of punctuation symbols that can be used at the end of # comments and docstrings. ALLOWED_TERMINATING_PUNCTUATIONS: Final = ['.', '?', '}', ']', ')'] # If any of these phrases are found inside a docstring or comment, # the punctuation and capital letter checks will be skipped for that # comment or docstring. EXCLUDED_PHRASES: Final = [ 'coding:', 'pylint:', 'http://', 'https://', 'scripts/', 'extract_node', ] ALLOWED_PRAGMAS_FOR_INLINE_COMMENTS: Final = [ 'pylint:', 'isort:', 'type: ignore', 'pragma:', 'https:', ] ALLOWED_LINES_OF_GAP_IN_COMMENT: Final = 15 import astroid from pylint import checkers, interfaces from pylint.checkers import utils as checker_utils from pylint.extensions import _check_docs_utils def read_from_node(node: astroid.scoped_nodes.Module) -> List[str]: """Returns the data read from the ast node in unicode form. Args: node: astroid.scoped_nodes.Module. Node to access module content. Returns: list(str). The data read from the ast node. """ # Readlines returns bytes, thus we need to decode them to string. return [line.decode('utf-8') for line in node.stream().readlines()] # TODO(#16567): Here we use MyPy ignore because the incomplete typing of # pylint library and absences of stubs in pylint, forces MyPy to # assume that BaseChecker class has attributes of type Any. # Thus to avoid MyPy's error # (Class cannot subclass 'BaseChecker' (has type 'Any')), # we added an ignore here. class HangingIndentChecker(checkers.BaseChecker): # type: ignore[misc] """Custom pylint checker which checks for break after parenthesis in case of hanging indentation. """ __implements__ = interfaces.ITokenChecker name = 'hanging-indent' priority = -1 msgs = { 'C0002': ( ( 'There should be a break after parenthesis when content within ' 'parenthesis spans multiple lines.' ), 'no-break-after-hanging-indent', ( 'If something within parenthesis extends along multiple lines, ' 'break after opening parenthesis.' ), ), } def process_tokens(self, tokens: List[tokenize.TokenInfo]) -> None: """Process tokens to check if there is a line break after the bracket. Args: tokens: List[TokenInfo]. Object to process tokens. """ escape_character_indicator = '\\' string_indicator = '\'' excluded = False for token_type, token, (line_num, _), _, line in tokens: # Check if token type is an operator and is either a # left parenthesis '(' or a right parenthesis ')'. if token_type == tokenize.OP and token in ('(', ')'): line = line.strip() # Exclude 'if', 'elif', 'while' statements. if line.startswith(('if ', 'while ', 'elif ')): excluded = True # Skip check if there is a comment at the end of line. if excluded: split_line = line.split() if '#' in split_line: comment_index = split_line.index('#') if split_line[comment_index - 1].endswith( ':' ) or split_line[comment_index - 1].endswith('):'): excluded = False elif line.endswith(':') or line.endswith('):'): excluded = False if excluded: continue bracket_count = 0 line_length = len(line) escape_character_found = False in_string = False for char_num in range(line_length): char = line[char_num] if in_string and ( char == escape_character_indicator or escape_character_found ): escape_character_found = not escape_character_found continue # Check if we found the string indicator and flip the # in_string boolean. if char == string_indicator: in_string = not in_string # Ignore anything inside a string. if in_string: continue if char == '(': if bracket_count == 0: position = char_num bracket_count += 1 elif char == ')' and bracket_count > 0: bracket_count -= 1 if bracket_count > 0 and position + 1 < line_length: # Allow the use of '[', ']', '{', '}' after the parenthesis. separators = set('[{( ') if line[line_length - 1] in separators: continue content = line[position + 1 :] # Skip check if there is nothing after the bracket. split_content = content.split() # Skip check if there is a comment at the end of line. if '#' in split_content: comment_index = split_content.index('#') if comment_index == 0: continue last_content_before_comment = split_content[ comment_index - 1 ] if last_content_before_comment.endswith( ('(', '[', '{') ): continue self.add_message( 'no-break-after-hanging-indent', line=line_num ) # The following class was derived from # https://github.com/PyCQA/pylint/blob/377cc42f9e3116ff97cddd4567d53e9a3e24ebf9/pylint/extensions/docparams.py#L26 # TODO(#16567): Here we use MyPy ignore because the incomplete typing of # pylint library and absences of stubs in pylint, forces MyPy to # assume that BaseChecker class has attributes of type Any. # Thus to avoid MyPy's error # (Class cannot subclass 'BaseChecker' (has type 'Any')), # we added an ignore here. class DocstringParameterChecker(checkers.BaseChecker): # type: ignore[misc] """Checker for Sphinx, Google, or Numpy style docstrings * Check that all function, method and constructor parameters are mentioned in the params and types part of the docstring. Constructor parameters can be documented in either the class docstring or ``__init__`` docstring, but not both. * Check that there are no naming inconsistencies between the signature and the documentation, i.e. also report documented parameters that are missing in the signature. This is important to find cases where parameters are renamed only in the code, not in the documentation. * Check that all explicitly raised exceptions in a function are documented in the function docstring. Caught exceptions are ignored. Args: linter: Pylinter. The linter object. """ __implements__ = interfaces.IAstroidChecker name = 'parameter_documentation' msgs = { 'W9005': ( '"%s" has constructor parameters ' 'documented in class and __init__', 'multiple-constructor-doc', 'Please remove parameter declarations ' 'in the class or constructor.', ), 'W9006': ( '"%s" not documented as being raised', 'missing-raises-doc', 'Please document exceptions for all raised exception types.', ), 'W9008': ( 'Redundant returns documentation', 'redundant-returns-doc', 'Please remove the return/rtype documentation from this method.', ), 'W9010': ( 'Redundant yields documentation', 'redundant-yields-doc', 'Please remove the yields documentation from this method.', ), 'W9011': ( 'Missing return documentation', 'missing-return-doc', 'Please add documentation about what this method returns.', {'old_names': [('W9007', 'missing-returns-doc')]}, ), 'W9012': ( 'Missing return type documentation', 'missing-return-type-doc', 'Please document the type returned by this method.', # We can't use the same old_name for two different warnings # {'old_names': [('W9007', 'missing-returns-doc')]}. ), 'W9013': ( 'Missing yield documentation', 'missing-yield-doc', 'Please add documentation about what this generator yields.', {'old_names': [('W9009', 'missing-yields-doc')]}, ), 'W9014': ( 'Missing yield type documentation', 'missing-yield-type-doc', 'Please document the type yielded by this method.', ), 'W9015': ( '"%s" missing in parameter documentation', 'missing-param-doc', 'Please add parameter declarations for all parameters.', {'old_names': [('W9003', 'old-missing-param-doc')]}, ), 'W9016': ( '"%s" missing in parameter type documentation', 'missing-type-doc', 'Please add parameter type declarations for all parameters.', ), 'W9017': ( '"%s" differing in parameter documentation', 'differing-param-doc', 'Please check parameter names in declarations.', ), 'W9018': ( '"%s" differing in parameter type documentation', 'differing-type-doc', 'Please check parameter names in type declarations.', ), 'W9019': ( 'Line starting with "%s" requires 4 space indentation relative to' ' args line indentation', '4-space-indentation-for-arg-parameters-doc', 'Please use 4 space indentation in parameter definitions relative' ' to the args line indentation.', ), 'W9020': ( 'Line starting with "%s" requires 8 space indentation relative to' ' args line indentation', '8-space-indentation-for-arg-in-descriptions-doc', 'Please indent wrap-around descriptions by 8 relative to the args' ' line indentation.', ), 'W9021': ( 'Args: indentation is incorrect, must be at the outermost' ' indentation level.', 'incorrect-indentation-for-arg-header-doc', 'Please indent args line to the outermost indentation level.', ), 'W9022': ( '4 space indentation in docstring.', '4-space-indentation-in-docstring', 'Please use 4 space indentation for parameters relative to section' ' headers.', ), 'W9023': ( '8 space indentation in docstring.', '8-space-indentation-in-docstring', 'Please use 8 space indentation in wrap around messages' ' relative to section headers.', ), 'W9024': ( 'Raises section should be the following form: Exception_name. ' 'Description.', 'malformed-raises-section', 'The parameter is incorrectly formatted.', ), 'W9025': ( 'Period is not used at the end of the docstring.', 'no-period-used', 'Please use a period at the end of the docstring,', ), 'W9026': ( 'Multiline docstring should end with a new line.', 'no-newline-used-at-end', 'Please end multiline docstring with a new line.', ), 'W9027': ( 'Single line docstring should not span two lines.', 'single-line-docstring-span-two-lines', 'Please do not use two lines for a single line docstring. ' 'If line length exceeds 80 characters, ' 'convert the single line docstring to a multiline docstring.', ), 'W9028': ( 'Empty line before the end of multi-line docstring.', 'empty-line-before-end', 'Please do not use empty line before ' 'the end of the multi-line docstring.', ), 'W9029': ( 'Space after """ in docstring.', 'space-after-triple-quote', 'Please do not use space after """ in docstring.', ), 'W9030': ( 'Missing single newline below class docstring.', 'newline-below-class-docstring', 'Please add a single newline below class docstring.', ), 'W9031': ( '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.', ), 'W9032': ( '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.', ), 'W9033': ( '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.', ), 'W9034': ( 'Files must have a single newline above yield in doc string.', 'single-space-above-yield', 'Please enter a single newline above yield in doc string.', ), 'W9035': ( 'Arguments should be in following form: variable_name: typeinfo. ' 'Description.', 'malformed-args-section', 'The parameter is incorrectly formatted.', ), 'W9036': ( 'Returns should be in the following form: typeinfo. Description.', 'malformed-returns-section', 'The parameter is incorrectly formatted.', ), 'W9037': ( 'Yields should be in the following form: typeinfo. Description.', 'malformed-yields-section', 'The parameter is incorrectly formatted.', ), 'W9038': ( 'Arguments starting with *args should be formatted in the following' ' form: *args: list(*). Description.', 'malformed-args-argument', 'The parameter is incorrectly formatted.', ), } options = ( ( 'accept-no-param-doc', { 'default': True, 'type': 'yn', 'metavar': '<y or n>', 'help': 'Whether to accept totally missing parameter ' 'documentation in the docstring of a ' 'function that has parameters.', }, ), ( 'accept-no-raise-doc', { 'default': True, 'type': 'yn', 'metavar': '<y or n>', 'help': 'Whether to accept totally missing raises ' 'documentation in the docstring of a function that ' 'raises an exception.', }, ), ( 'accept-no-return-doc', { 'default': True, 'type': 'yn', 'metavar': '<y or n>', 'help': 'Whether to accept totally missing return ' 'documentation in the docstring of a function that ' 'returns a statement.', }, ), ( 'accept-no-yields-doc', { 'default': True, 'type': 'yn', 'metavar': '<y or n>', 'help': 'Whether to accept totally missing yields ' 'documentation in the docstring of a generator.', }, ), ) priority = -2 constructor_names = {'__init__', '__new__'} not_needed_param_in_docstring = {'self', 'cls', 'mcs'} docstring_sections = {'Raises:', 'Returns:', 'Yields:'} # Docstring section headers split up into arguments, returns, yields # and raises sections signifying that we are currently parsing the # corresponding section of that docstring. DOCSTRING_SECTION_RETURNS = 'returns' DOCSTRING_SECTION_YIELDS = 'yields' DOCSTRING_SECTION_RAISES = 'raises' def visit_classdef(self, node: astroid.nodes.ClassDef) -> None: """Visit each class definition in a module and check if there is a single new line below each class docstring. Args: node: astroid.nodes.ClassDef. Node for a class definition in the AST. """ # Check if the given node has docstring. if node.doc is None: return line_number = node.fromlineno # Iterate till the start of docstring. while True: line = linecache.getline(node.root().file, line_number).strip() if line.startswith(('"""', '\'\'\'', '\'', '"')): break line_number += 1 doc_length = len(node.doc.split('\n')) line_number += doc_length first_line_after_doc = linecache.getline( node.root().file, line_number ).strip() second_line_after_doc = linecache.getline( node.root().file, line_number + 1 ).strip() if first_line_after_doc != '': self.add_message('newline-below-class-docstring', node=node) elif second_line_after_doc == '': self.add_message('newline-below-class-docstring', node=node) def visit_functiondef(self, node: astroid.nodes.FunctionDef) -> None: """Called for function and method definitions (def). Args: node: astroid.scoped_nodes.FunctionDef. Node for a function or method definition in the AST. """ node_doc = docstrings_checker.docstringify(node.doc_node) self.check_functiondef_params(node, node_doc) self.check_functiondef_returns(node, node_doc) self.check_functiondef_yields(node, node_doc) self.check_docstring_style(node) self.check_docstring_section_indentation(node) self.check_typeinfo(node, node_doc) def check_typeinfo( self, node: astroid.nodes.FunctionDef, node_doc: _check_docs_utils.Docstring, ) -> None: """Checks whether all parameters in a function definition are properly formatted. Args: node: astroid.node.FunctionDef. Node for a function or method definition in the AST. node_doc: Docstring. Pylint Docstring class instance representing a node's docstring. """ # The regexes are taken from the pylint codebase and are modified # according to our needs. Link: https://github.com/PyCQA/pylint/blob/ # e89c361668aeead9fd192d5289c186611ef779ca/pylint/extensions/ # _check_docs_utils.py#L428. re_param_line = re.compile( r""" \s* \*{{0,2}}(\w+) # identifier potentially with asterisks \s* ( [:] \s* ({type}|\S*|[\s\S]*) (?:,\s+optional)? [.]+\s )+ \s* \s* [A-Z0-9](.*)[.\]}}\)]+$ # beginning of optional description """.format( type=_check_docs_utils.GoogleDocstring.re_multiple_type, ), flags=re.X | re.S | re.M, ) re_returns_line = re.compile( r""" \s* (({type}|\S*|[\s\S]*).[.]+\s)+ # identifier \s* [A-Z0-9](.*)[.\]}}\)]+$ # beginning of description """.format( type=_check_docs_utils.GoogleDocstring.re_multiple_type, ), flags=re.X | re.S | re.M, ) re_yields_line = re_returns_line re_raise_line = re.compile( r""" \s* ({type}[.])+ # identifier \s* [A-Z0-9](.*)[.\]}}\)]+$ # beginning of description """.format( type=_check_docs_utils.GoogleDocstring.re_multiple_type, ), flags=re.X | re.S | re.M, ) # We need to extract the information from the given section for that # we need to use _parse_section as this will extract all the arguments # from the Args section, as this is a private method hence we need to # use the pylint pragma to escape the pylint warning. if node_doc.has_params(): entries = ( node_doc._parse_section( # pylint: disable=protected-access _check_docs_utils.GoogleDocstring.re_param_section ) ) for entry in entries: if entry.lstrip().startswith('*args') and not ( entry.lstrip().startswith('*args: list(*)') ): self.add_message('malformed-args-argument', node=node) match = re_param_line.match(entry) if not match: self.add_message('malformed-args-section', node=node) # We need to extract the information from the given section for that # we need to use _parse_section as this will extract all the returns # from the Returns section, as this is a private method hence we need to # use the pylint pragma to escape the pylint warning. if node_doc.has_returns(): entries = ( node_doc._parse_section( # pylint: disable=protected-access _check_docs_utils.GoogleDocstring.re_returns_section ) ) entries = [''.join(entries)] for entry in entries: match = re_returns_line.match(entry) if not match: self.add_message('malformed-returns-section', node=node) # We need to extract the information from the given section for that # we need to use _parse_section as this will extract all the yields # from the Yields section, as this is a private method hence we need to # use the pylint pragma to escape the pylint warning. if node_doc.has_yields(): entries = ( node_doc._parse_section( # pylint: disable=protected-access _check_docs_utils.GoogleDocstring.re_yields_section ) ) entries = [''.join(entries)] for entry in entries: match = re_yields_line.match(entry) if not match: self.add_message('malformed-yields-section', node=node) # We need to extract the information from the given section for that # we need to use _parse_section as this will extract all the exceptions # from the Raises section, as this is a private method hence we need to # use the pylint pragma to escape the pylint warning. if node_doc.exceptions(): entries = ( node_doc._parse_section( # pylint: disable=protected-access _check_docs_utils.GoogleDocstring.re_raise_section ) ) for entry in entries: match = re_raise_line.match(entry) if not match: self.add_message('malformed-raises-section', node=node) def check_functiondef_params( self, node: astroid.nodes.FunctionDef, node_doc: _check_docs_utils.Docstring, ) -> None: """Checks whether all parameters in a function definition are documented. Args: node: astroid.scoped_nodes.FunctionDef. Node for a function or method definition in the AST. node_doc: Docstring. Pylint Docstring class instance representing a node's docstring. """ node_allow_no_param = None if node.name in self.constructor_names: class_node = checker_utils.node_frame_class(node) if class_node is not None: class_doc = docstrings_checker.docstringify(class_node.doc_node) self.check_single_constructor_params( class_doc, node_doc, class_node ) # __init__ or class docstrings can have no parameters documented # as long as the other documents them. node_allow_no_param = ( class_doc.has_params() or class_doc.params_documented_elsewhere() or None ) class_allow_no_param = ( node_doc.has_params() or node_doc.params_documented_elsewhere() or None ) self.check_arguments_in_docstring( class_doc, node.args, class_node, accept_no_param_doc=class_allow_no_param, ) self.check_arguments_in_docstring( node_doc, node.args, node, accept_no_param_doc=node_allow_no_param ) def check_docstring_style(self, node: astroid.nodes.FunctionDef) -> None: """It fetches a function node and extract the class node from function node if it is inside a class body and passes it to check_docstring_structure which checks whether the docstring has a space at the beginning and a period at the end. Args: node: astroid.scoped_nodes.FunctionDef. Node for a function or method definition in the AST. """ if node.name in self.constructor_names: class_node = checker_utils.node_frame_class(node) if class_node is not None: self.check_docstring_structure(class_node) self.check_docstring_structure(node) def check_newline_above_args( self, node: astroid.nodes.FunctionDef, docstring: List[str] ) -> None: """Checks to ensure that there is a single space above the argument parameters in the docstring. Args: node: astroid.node.FunctionDef. Node for a function or method definition in the AST. docstring: list(str). Function docstring in splitted by newlines. """ blank_line_counter = 0 for line in docstring: line = line.strip() if line == '': blank_line_counter += 1 if blank_line_counter == 0 or blank_line_counter > 1: if line == 'Args:': self.add_message('single-space-above-args', node=node) elif line == 'Returns:': self.add_message('single-space-above-returns', node=node) elif line == 'Raises:': self.add_message('single-space-above-raises', node=node) elif line == 'Yields:': self.add_message('single-space-above-yield', node=node) if line != '': blank_line_counter = 0 def check_docstring_structure(self, node: astroid.NodeNG) -> None: """Checks whether the docstring has the correct structure i.e. do not have space at the beginning and have a period at the end of docstring. Args: node: astroid.NodeNG. Node for a function or method definition in the AST. """ if node.doc: docstring = node.doc.splitlines() # Check for space after """ in docstring. if len(docstring[0]) > 0 and docstring[0][0] == ' ': self.add_message('space-after-triple-quote', node=node) # Check if single line docstring span two lines. if len(docstring) == 2 and docstring[-1].strip() == '': self.add_message( 'single-line-docstring-span-two-lines', node=node ) # Check for punctuation at end of a single line docstring. elif ( len(docstring) == 1 and docstring[-1][-1] not in ALLOWED_TERMINATING_PUNCTUATIONS ): self.add_message('no-period-used', node=node) # Check for punctuation at the end of a multiline docstring. elif len(docstring) > 1: if docstring[-2].strip() == '': self.add_message('empty-line-before-end', node=node) elif docstring[-1].strip() != '': self.add_message('no-newline-used-at-end', node=node) elif docstring[-2][ -1 ] not in ALLOWED_TERMINATING_PUNCTUATIONS and not any( word in docstring[-2] for word in EXCLUDED_PHRASES ): self.add_message('no-period-used', node=node) def check_docstring_section_indentation( self, node: astroid.nodes.FunctionDef ) -> None: """Checks whether the function argument definitions ("Args": section, "Returns": section, "Yield": section, "Raises: section) are indented properly. Parameters should be indented by 4 relative to the 'Args:' 'Return:', 'Raises:', 'Yield:' line and any wrap-around descriptions should be indented by 8. Args: node: astroid.nodes.FunctionDef. Node for a function or method definition in the AST. """ arguments_node = node.args expected_argument_names = set( ( None if (arg.name in self.not_needed_param_in_docstring) else (arg.name + ':') ) for arg in arguments_node.args + arguments_node.kwonlyargs ) currently_in_args_section = False # When we are in the args section and a line ends in a colon, # we can ignore the indentation styling in the next section of # description, hence a freeform section. currently_in_freeform_section = False args_indentation = 0 if node.doc: current_docstring_section = None in_description = False args_indentation_in_spaces = 0 docstring = node.doc.splitlines() self.check_newline_above_args(node, docstring) for line in docstring: stripped_line = line.lstrip() current_line_indentation = len(line) - len(stripped_line) parameter = re.search('^[^:]+:', stripped_line) # Check for empty lines and ignore them. if len(line.strip()) == 0: continue # If line starts with Returns: , it is the header of a Returns # subsection. if stripped_line.startswith('Returns:'): current_docstring_section = self.DOCSTRING_SECTION_RETURNS in_freeform_section = False in_description = False args_indentation_in_spaces = current_line_indentation # If line starts with Raises: , it is the header of a Raises # subsection. elif stripped_line.startswith('Raises:'): current_docstring_section = self.DOCSTRING_SECTION_RAISES in_freeform_section = False in_description = False args_indentation_in_spaces = current_line_indentation # If line starts with Yields: , it is the header of a Yields # subsection. elif stripped_line.startswith('Yields:'): current_docstring_section = self.DOCSTRING_SECTION_YIELDS in_freeform_section = False in_description = False args_indentation_in_spaces = current_line_indentation # Check if we are in a docstring raises section. elif ( current_docstring_section and current_docstring_section == self.DOCSTRING_SECTION_RAISES ): # In the raises section, if we see this regex expression, we # can assume it's the start of a new parameter definition. # We check the indentation of the parameter definition. if re.search(r'^[a-zA-Z0-9_\.\*]+[.] ', stripped_line): if current_line_indentation != ( args_indentation_in_spaces + 4 ): self.add_message( '4-space-indentation-in-docstring', node=node ) in_description = True # In a description line that is wrapped around (doesn't # start off with the parameter name), we need to make sure # the indentation is 8. elif in_description: if current_line_indentation != ( args_indentation_in_spaces + 8 ): self.add_message( '8-space-indentation-in-docstring', node=node ) # Check if we are in a docstring returns or yields section. # NOTE: Each function should only have one yield or return # object. If a tuple is returned, wrap both in a tuple parameter # section. elif ( current_docstring_section and current_docstring_section in ( self.DOCSTRING_SECTION_RETURNS, self.DOCSTRING_SECTION_YIELDS, ) ): # Check for the start of a new parameter definition in the # format "type (elaboration)." and check the indentation. if ( re.search(r'^[a-zA-Z_() -:,\*]+\.', stripped_line) and not in_description ): if current_line_indentation != ( args_indentation_in_spaces + 4 ): self.add_message( '4-space-indentation-in-docstring', node=node ) # If the line ends with a colon, we can assume the rest # of the section is free form. if re.search(r':$', stripped_line): in_freeform_section = True in_description = True # In a description line of a returns or yields, we keep the # indentation the same as the definition line. elif in_description: if ( current_line_indentation != args_indentation_in_spaces + 4 and not in_freeform_section ): self.add_message( '4-space-indentation-in-docstring', node=node ) # If the description line ends with a colon, we can # assume the rest of the section is free form. if re.search(r':$', stripped_line): in_freeform_section = True # Check for the start of an Args: section and check the correct # indentation. elif stripped_line.startswith('Args:'): args_indentation = current_line_indentation # The current args indentation is incorrect. if current_line_indentation % 4 != 0: self.add_message( 'incorrect-indentation-for-arg-header-doc', node=node, ) # Since other checks are based on relative indentation, # we need to fix this indentation first. break currently_in_args_section = True # Check for parameter section header by checking that the # parameter is in the function arguments set. We also check for # arguments that start with * which means it's autofill and will # not appear in the node args list so we handle those too. elif ( currently_in_args_section and parameter and ( parameter.group(0).strip('*') in expected_argument_names or re.search(r'\*[^ ]+: ', stripped_line) ) ): words_in_line = stripped_line.split(' ') currently_in_freeform_section = False # Check if the current parameter section indentation is # correct. if current_line_indentation != args_indentation + 4: # Use the first word in the line to identify the error. beginning_of_line = ( words_in_line[0] if words_in_line else None ) self.add_message( '4-space-indentation-for-arg-parameters-doc', node=node, args=beginning_of_line, ) # If the line ends with a colon, that means # the next subsection of description is free form. if line.endswith(':'): currently_in_freeform_section = True # All other lines can be treated as description. elif currently_in_args_section: # If it is not a freeform section, we check the indentation. words_in_line = stripped_line.split(' ') if ( not currently_in_freeform_section and current_line_indentation != args_indentation + 8 ): # Use the first word in the line to identify the error. beginning_of_line = ( words_in_line[0] if words_in_line else None ) self.add_message( '8-space-indentation-for-arg-in-descriptions-doc', node=node, args=beginning_of_line, ) # If the line ends with a colon, that # means the next subsection of description is free form. if line.endswith(':'): currently_in_freeform_section = True def check_functiondef_returns( self, node: astroid.nodes.FunctionDef, node_doc: _check_docs_utils.Docstring, ) -> None: """Checks whether a function documented with a return value actually has a return statement in its definition. Args: node: astroid.nodes.FunctionDef. Node for a function or method definition in the AST. node_doc: Docstring. Pylint Docstring class instance representing a node's docstring. """ if not node_doc.supports_yields and node.is_generator(): return return_nodes = node.nodes_of_class(astroid.Return) if (node_doc.has_returns() or node_doc.has_rtype()) and not any( docstrings_checker.returns_something(ret_node) for ret_node in return_nodes ): self.add_message('redundant-returns-doc', node=node) def check_functiondef_yields( self, node: astroid.nodes.FunctionDef, node_doc: _check_docs_utils.Docstring, ) -> None: """Checks whether a function documented with a yield value actually has a yield statement in its definition. Args: node: astroid.scoped_nodes.Function. Node for a function or method definition in the AST. node_doc: Docstring. Pylint Docstring class instance representing a node's docstring. """ if not node_doc.supports_yields: return if ( node_doc.has_yields() or node_doc.has_yields_type() ) and not node.is_generator(): self.add_message('redundant-yields-doc', node=node) def visit_raise(self, node: astroid.nodes.FunctionDef) -> None: """Visits a function node that raises an exception and verifies that all exceptions raised in the function definition are documented. Args: node: astroid.scoped_nodes.Function. Node for a function or method definition in the AST. """ func_node = node.frame() if not isinstance(func_node, astroid.FunctionDef): return expected_excs = docstrings_checker.possible_exc_types(node) if not expected_excs: return if not func_node.doc: # If this is a property setter, # the property should have the docstring instead. setters_property = docstrings_checker.get_setters_property( func_node ) if setters_property: func_node = setters_property doc = docstrings_checker.docstringify(func_node.doc_node) if doc.matching_sections() == 0: if doc.doc: self._handle_no_raise_doc(expected_excs, func_node) return found_excs = doc.exceptions() missing_excs = expected_excs - found_excs self._add_raise_message(missing_excs, func_node) def visit_return(self, node: astroid.nodes.FunctionDef) -> None: """Visits a function node that contains a return statement and verifies that the return value and the return type are documented. Args: node: astroid.scoped_nodes.FunctionDef. Node for a function or method definition in the AST. """ if not docstrings_checker.returns_something(node): return func_node = node.frame() doc = docstrings_checker.docstringify(func_node.doc_node) if doc.matching_sections() == 0 and self.config.accept_no_return_doc: return is_property = checker_utils.decorated_with_property(func_node) if not ( doc.has_returns() or (doc.has_property_returns() and is_property) ): self.add_message('missing-return-doc', node=func_node) if not (doc.has_rtype() or (doc.has_property_type() and is_property)): self.add_message('missing-return-type-doc', node=func_node) def visit_yield(self, node: astroid.nodes.FunctionDef) -> None: """Visits a function node that contains a yield statement and verifies that the yield value and the yield type are documented. Args: node: astroid.scoped_nodes.FunctionDef. Node for a function or method definition in the AST. """ func_node = node.frame() doc = docstrings_checker.docstringify(func_node.doc_node) if doc.matching_sections() == 0 and self.config.accept_no_yields_doc: return doc_has_yields = doc.has_yields() doc_has_yields_type = doc.has_yields_type() if not doc_has_yields: self.add_message('missing-yield-doc', node=func_node) if not doc_has_yields_type: self.add_message('missing-yield-type-doc', node=func_node) def visit_yieldfrom(self, node: astroid.nodes.FunctionDef) -> None: """Visits a function node that contains a yield from statement and verifies that the yield from value and the yield from type are documented. Args: node: astroid.nodes.FunctionDef. Node to access module content. """ self.visit_yield(node) def check_arguments_in_docstring( self, doc: _check_docs_utils.Docstring, arguments_node: astroid.nodes.Arguments, warning_node: astroid.nodes.NodeNG, accept_no_param_doc: Optional[bool] = None, ) -> None: """Check that all parameters in a function, method or class constructor on the one hand and the parameters mentioned in the parameter documentation (e.g. the Sphinx tags 'param' and 'type') on the other hand are consistent with each other. * Undocumented parameters except 'self' are noticed. * Undocumented parameter types except for 'self' and the ``*<args>`` and ``**<kwargs>`` parameters are noticed. * Parameters mentioned in the parameter documentation that don't or no longer exist in the function parameter list are noticed. * If the text "For the parameters, see" or "For the other parameters, see" (ignoring additional whitespace) is mentioned in the docstring, missing parameter documentation is tolerated. * If there's no Sphinx style, Google style or NumPy style parameter documentation at all, i.e. ``:param`` is never mentioned etc., the checker assumes that the parameters are documented in another format and the absence is tolerated. Args: doc: str. Docstring for the function, method or class. arguments_node: astroid.scoped_nodes.Arguments. Arguments node for the function, method or class constructor. warning_node: astroid.scoped_nodes.Node. The node to assign the warnings to. accept_no_param_doc: bool|None. Whether or not to allow no parameters to be documented. If None then this value is read from the configuration. """ # Tolerate missing param or type declarations if there is a link to # another method carrying the same name. if not doc.doc: return if accept_no_param_doc is None: accept_no_param_doc = self.config.accept_no_param_doc tolerate_missing_params = doc.params_documented_elsewhere() # Collect the function arguments. expected_argument_names = set(arg.name for arg in arguments_node.args) expected_argument_names.update( arg.name for arg in arguments_node.kwonlyargs ) not_needed_type_in_docstring = self.not_needed_param_in_docstring.copy() if arguments_node.vararg is not None: expected_argument_names.add(arguments_node.vararg) not_needed_type_in_docstring.add(arguments_node.vararg) if arguments_node.kwarg is not None: expected_argument_names.add(arguments_node.kwarg) not_needed_type_in_docstring.add(arguments_node.kwarg) params_with_doc, params_with_type = doc.match_param_docs() # Tolerate no parameter documentation at all. if not params_with_doc and not params_with_type and accept_no_param_doc: tolerate_missing_params = True def _compare_missing_args( found_argument_names: Set[str], message_id: str, not_needed_names: Set[str], ) -> None: """Compare the found argument names with the expected ones and generate a message if there are arguments missing. Args: found_argument_names: set(str). Argument names found in the docstring. message_id: str. Pylint message id. not_needed_names: set(str). Names that may be omitted. """ if not tolerate_missing_params: missing_argument_names = ( expected_argument_names - found_argument_names ) - not_needed_names if missing_argument_names: self.add_message( message_id, args=(', '.join(sorted(missing_argument_names)),), node=warning_node, ) def _compare_different_args( found_argument_names: Set[str], message_id: str, not_needed_names: Set[str], ) -> None: """Compare the found argument names with the expected ones and generate a message if there are extra arguments found. Args: found_argument_names: set(str). Argument names found in the docstring. message_id: str. Pylint message id. not_needed_names: set(str). Names that may be omitted. """ differing_argument_names = ( (expected_argument_names ^ found_argument_names) - not_needed_names - expected_argument_names ) if differing_argument_names: self.add_message( message_id, args=(', '.join(sorted(differing_argument_names)),), node=warning_node, ) _compare_missing_args( params_with_doc, 'missing-param-doc', self.not_needed_param_in_docstring, ) _compare_missing_args( params_with_type, 'missing-type-doc', not_needed_type_in_docstring ) _compare_different_args( params_with_doc, 'differing-param-doc', self.not_needed_param_in_docstring, ) _compare_different_args( params_with_type, 'differing-type-doc', not_needed_type_in_docstring ) def check_single_constructor_params( self, class_doc: _check_docs_utils.Docstring, init_doc: _check_docs_utils.Docstring, class_node: astroid.nodes.ClassDef, ) -> None: """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. init_doc: Docstring. Pylint docstring class instance representing a method's docstring, the method here is the constructor method for the above class. class_node: astroid.nodes.ClassDef. Node for class definition in AST. """ if class_doc.has_params() and init_doc.has_params(): self.add_message( 'multiple-constructor-doc', args=(class_node.name,), node=class_node, ) def _handle_no_raise_doc( self, excs: Set[str], node: astroid.nodes.FunctionDef ) -> None: """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.nodes.FunctionDef. Node to access module content. """ if self.config.accept_no_raise_doc: return self._add_raise_message(excs, node) def _add_raise_message( self, missing_excs: Set[str], node: astroid.nodes.NodeNG ) -> None: """Adds a message on :param:`node` for the missing exception type. Args: missing_excs: list(str). A list of missing exception types. node: astroid.node_classes.NodeNG. The node show the message on. """ if not missing_excs: return self.add_message( 'missing-raises-doc', args=(', '.join(sorted(missing_excs)),), node=node, ) # TODO(#16567): Here we use MyPy ignore because the incomplete typing of # pylint library and absences of stubs in pylint, forces MyPy to # assume that BaseChecker class has attributes of type Any. # Thus to avoid MyPy's error # (Class cannot subclass 'BaseChecker' (has type 'Any')), # we added an ignore here. class ImportOnlyModulesChecker(checkers.BaseChecker): # type: ignore[misc] """Checker for import-from statements. It checks that modules are only imported. """ __implements__ = interfaces.IAstroidChecker name = 'import-only-modules' priority = -1 msgs = { 'C0003': ( 'Import \"%s\" from \"%s\" is not a module.', 'import-only-modules', 'Modules should only be imported.', ), } # If import from any of these is made, it may not be a module. EXCLUDED_IMPORT_MODULES = [ '__future__', 'types', 'typing', 'mypy_imports', ] # TODO(#16567): Here we use MyPy ignore because the incomplete typing of # pylint library and absences of stubs in pylint, forces MyPy to # assume that checker_utils.check_messages function # is untyped. Thus to avoid MyPy's error # (Untyped decorator makes function "visit_importfrom" untyped), # we added an ignore here. @checker_utils.check_messages('import-only-modules') # type: ignore[misc] def visit_importfrom(self, node: astroid.nodes.ImportFrom) -> None: """Visits all import-from statements in a python file and checks that modules are imported. It then adds a message accordingly. Args: node: astroid.node_classes.ImportFrom. Node for a import-from statement in the AST. """ try: imported_module = node.do_import_module(node.modname) except astroid.AstroidBuildingException: return if node.modname in self.EXCLUDED_IMPORT_MODULES: return modname = node.modname for name, _ in node.names: try: imported_module.import_module(name, True) except astroid.AstroidImportError: self.add_message( 'import-only-modules', node=node, args=(name, modname), ) # TODO(#16567): Here we use MyPy ignore because the incomplete typing of # pylint library and absences of stubs in pylint, forces MyPy to # assume that BaseChecker class has attributes of type Any. # Thus to avoid MyPy's error # (Class cannot subclass 'BaseChecker' (has type 'Any')), # we added an ignore here. class BackslashContinuationChecker(checkers.BaseChecker): # type: ignore[misc] """Custom pylint checker which checks that backslash is not used for continuation. """ __implements__ = interfaces.IRawChecker name = 'backslash-continuation' priority = -1 msgs = { 'C0004': ( ( 'Backslash should not be used to break continuation lines. ' 'Use braces to break long lines.' ), 'backslash-continuation', 'Use braces to break long lines instead of backslash.', ), } def process_module(self, node: astroid.nodes.Module) -> None: """Process a module. Args: node: astroid.scoped_nodes.Module. Node to access module content. """ file_content = read_from_node(node) for line_num, line in enumerate(file_content): if line.rstrip('\r\n').endswith('\\'): self.add_message('backslash-continuation', line=line_num + 1) # TODO(#16567): Here we use MyPy ignore because the incomplete typing of # pylint library and absences of stubs in pylint, forces MyPy to # assume that BaseChecker class has attributes of type Any. # Thus to avoid MyPy's error # (Class cannot subclass 'BaseChecker' (has type 'Any')), # we added an ignore here. class FunctionArgsOrderChecker(checkers.BaseChecker): # type: ignore[misc] """Custom pylint checker which checks the order of arguments in function definition. """ __implements__ = interfaces.IAstroidChecker name = 'function-args-order' priority = -1 msgs = { 'C0005': ( 'Wrong order of arguments in function definition ' '\'self\' should come first.', 'function-args-order-self', '\'self\' should come first', ), 'C0006': ( 'Wrong order of arguments in function definition ' '\'cls\' should come first.', 'function-args-order-cls', '\'cls\' should come first', ), } def visit_functiondef(self, node: astroid.nodes.FunctionDef) -> None: """Visits every function definition in the python file and check the function arguments order. It then adds a message accordingly. Args: node: astroid.nodes.FunctionDef. Node for a function or method definition in the AST. """ args_list = [args.name for args in node.args.args] if 'self' in args_list and args_list[0] != 'self': self.add_message('function-args-order-self', node=node) elif 'cls' in args_list and args_list[0] != 'cls': self.add_message('function-args-order-cls', node=node) # TODO(#16567): Here we use MyPy ignore because the incomplete typing of # pylint library and absences of stubs in pylint, forces MyPy to # assume that BaseChecker class has attributes of type Any. # Thus to avoid MyPy's error # (Class cannot subclass 'BaseChecker' (has type 'Any')), # we added an ignore here. class RestrictedImportChecker(checkers.BaseChecker): # type: ignore[misc] """Custom pylint checker which checks layers importing modules from their respective restricted layers. """ __implements__ = interfaces.IAstroidChecker name = 'invalid-import' priority = -1 msgs = { 'C0009': ( 'Importing any file from module %s in module "%s" is prohibited.', 'invalid-import', 'Some modules cannot be imported in other modules.', ), 'C0010': ( 'Importing file named "%s" from module "%s" ' 'in module "%s" is prohibited.', 'invalid-import-from', 'Some modules cannot be imported in other modules.', ), } options = ( ( 'forbidden-imports', { 'default': [], 'type': 'csv', 'metavar': '<comma separated list>', 'help': ( 'List of disallowed imports. The items start with ' 'the module name where the imports are forbidden, the path ' 'needs to be absolute with the root module name included ' '(e.g. \'oppia.core.domain\'), then comes ' 'the \':\' separator, and after that a list of the imports ' 'that are forbidden separated by \'|\', these imports are ' 'relative to the root module (e.g. \'core.domain\').' ), }, ), ) def __init__(self, linter: Optional[lint.PyLinter] = None) -> None: super().__init__(linter=linter) self._module_to_forbidden_imports: List[ Tuple[str, List[Tuple[str, Optional[str]]]] ] = [] def open(self) -> None: """Parse the forbidden imports.""" module_to_forbidden_imports: List[Tuple[str, str]] = [ forbidden_import.strip().split(':') for forbidden_import in self.config.forbidden_imports ] self._module_to_forbidden_imports = [] for module_regex, forbidden_imports in module_to_forbidden_imports: processed_forbidden_imports: List[Tuple[str, Optional[str]]] = [] for forbidden_import in forbidden_imports.split('|'): stripped_forbidden_import = forbidden_import.strip() if stripped_forbidden_import.startswith('from'): from_part, import_part = stripped_forbidden_import[ 4: ].split(' import ') processed_forbidden_imports.append( (from_part.strip(), import_part.strip()) ) else: processed_forbidden_imports.append( (stripped_forbidden_import[7:].strip(), None) ) self._module_to_forbidden_imports.append( (module_regex.strip(), processed_forbidden_imports) ) def _iterate_forbidden_imports( self, node: astroid.nodes.Import ) -> Generator[Tuple[str, Tuple[str, Optional[str]]], None, None]: """Yields pairs of module name and forbidden imports. Args: node: astroid.node_classes.Import. Node for a import statement in the AST. Yields: tuple(str, tuple(str, None)). Yields pair of module name and forbidden import. """ modnode = node.root() for module_name, forbidden_imports in self._module_to_forbidden_imports: for forbidden_import in forbidden_imports: if ( fnmatch.fnmatch(modnode.name, module_name) and not '_test' in modnode.name ): yield module_name, forbidden_import def _add_invalid_import_message( self, node: astroid.nodes.Import, module_name: str, forbidden_import_names: Tuple[str, Optional[str]], ) -> None: """Adds pylint message about the invalid import. Args: node: astroid.node_classes.Import. Node for a import statement in the AST. module_name: str. The module that was checked. forbidden_import_names: tuple(str, str|None). The import that was invalid. """ if forbidden_import_names[1] is None: self.add_message( 'invalid-import', node=node, args=(forbidden_import_names[0], module_name), ) else: self.add_message( 'invalid-import-from', node=node, args=( forbidden_import_names[1], forbidden_import_names[0], module_name, ), ) def visit_import(self, node: astroid.nodes.Import) -> None: """Visits every import statement in the file. Args: node: astroid.node_classes.Import. Node for a import statement in the AST. """ names = [name for name, _ in node.names] forbidden_imports = self._iterate_forbidden_imports(node) for module_name, forbidden_import_names in forbidden_imports: if forbidden_import_names[1] is not None: import_to_check = '%s.%s' % ( forbidden_import_names[0], forbidden_import_names[1], ) else: import_to_check = forbidden_import_names[0] if any(fnmatch.fnmatch(name, import_to_check) for name in names): self._add_invalid_import_message( node, module_name, forbidden_import_names ) def visit_importfrom(self, node: astroid.Import) -> None: """Visits all import-from statements in a python file and checks that modules are imported. It then adds a message accordingly. Args: node: astroid.node_classes.ImportFrom. Node for a import-from statement in the AST. """ forbidden_imports = self._iterate_forbidden_imports(node) for module_name, forbidden_import_names in forbidden_imports: if fnmatch.fnmatch(node.modname, forbidden_import_names[0]): if forbidden_import_names[1] is None: self._add_invalid_import_message( node, module_name, forbidden_import_names ) elif any( fnmatch.fnmatch(name[0], forbidden_import_names[1]) for name in node.names ): self._add_invalid_import_message( node, module_name, forbidden_import_names ) # TODO(#16567): Here we use MyPy ignore because the incomplete typing of # pylint library and absences of stubs in pylint, forces MyPy to # assume that BaseChecker class has attributes of type Any. # Thus to avoid MyPy's error # (Class cannot subclass 'BaseChecker' (has type 'Any')), # we added an ignore here. class SingleCharAndNewlineAtEOFChecker(checkers.BaseChecker): # type: ignore[misc] """Checker for single character files and newline at EOF.""" __implements__ = interfaces.IRawChecker name = 'newline-at-eof' priority = -1 msgs = { 'C0007': ( 'Files should end in a single newline character.', 'newline-at-eof', 'Please enter a single newline at the end of the file.', ), 'C0008': ( 'Only one character in file', 'only-one-character', 'Files with only one character are not allowed.', ), } def process_module(self, node: astroid.Module) -> None: """Process a module. Args: node: astroid.scoped_nodes.Function. Node to access module content. """ file_content = read_from_node(node) file_length = len(file_content) if file_length == 1 and len(file_content[0]) == 1: self.add_message('only-one-character', line=file_length) if file_length >= 2 and not re.search(r'[^\n]\n', file_content[-1]): self.add_message('newline-at-eof', line=file_length) # TODO(#16567): Here we use MyPy ignore because the incomplete typing of # pylint library and absences of stubs in pylint, forces MyPy to # assume that BaseChecker class has attributes of type Any. # Thus to avoid MyPy's error # (Class cannot subclass 'BaseChecker' (has type 'Any')), # we added an ignore here. class SingleLineCommentChecker(checkers.BaseChecker): # type: ignore[misc] """Checks if comments follow correct style.""" __implements__ = interfaces.ITokenChecker name = 'incorrectly_styled_comment' priority = -1 msgs = { 'C0016': ( 'Invalid punctuation is used.', 'invalid-punctuation-used', 'Please use valid punctuation.', ), 'C0017': ( 'Please use single space at beginning of comment.', 'no-space-at-beginning', 'Please use single space at the beginning of comment.', ), 'C0018': ( 'Please use a capital letter at the beginning of comment.', 'no-capital-letter-at-beginning', 'Please use capital letter to begin the content of comment.', ), 'C0040': ( 'This inline comment does not start with any allowed pragma. Please' ' put this comment in a new line.', 'no-allowed-inline-pragma', 'Inline comments should always start with an allowed inline pragma.', ), } options = ( ( 'allowed-comment-prefixes', { 'default': ('int', 'str', 'float', 'bool', 'v'), 'type': 'csv', 'metavar': '<comma separated list>', 'help': 'List of allowed prefixes in a comment.', }, ), ) def _check_space_at_beginning_of_comments( self, line: str, line_num: int ) -> None: """Checks if the comment starts with a space. Args: line: str. The current line of comment. line_num: int. Line number of the current comment. """ if re.search(r'^#[^\s].*$', line) and not line.startswith('#!'): self.add_message('no-space-at-beginning', line=line_num) def _check_comment_starts_with_capital_letter( self, line: str, line_num: int ) -> None: """Checks if the comment starts with a capital letter. Comments may include a lowercase character at the beginning only if they start with version info or a data type or a variable name e.g. "# next_line is of string type." or "# v2 version does not have ExplorationStats Model." or "# int. The file size, in bytes.". Args: line: str. The current line of comment. line_num: int. Line number of the current comment. """ # Check if variable name is used. if line[1:].startswith(' '): starts_with_underscore = '_' in line.split()[1] else: starts_with_underscore = '_' in line.split()[0] # Check if allowed prefix is used. allowed_prefix_is_present = any( line[2:].startswith(word) for word in self.config.allowed_comment_prefixes ) # Check if comment contains any excluded phrase. excluded_phrase_is_present = any( line[1:].strip().startswith(word) for word in EXCLUDED_PHRASES ) if re.search(r'^# [a-z].*', line) and not ( excluded_phrase_is_present or starts_with_underscore or allowed_prefix_is_present ): self.add_message('no-capital-letter-at-beginning', line=line_num) def _check_punctuation(self, line: str, line_num: int) -> None: """Checks if the comment starts with a correct punctuation. Args: line: str. The current line of comment. line_num: int. Line number of the current comment. """ excluded_phrase_is_present_at_end = any( word in line for word in EXCLUDED_PHRASES ) # Comments must end with the proper punctuation. last_char_is_invalid = line[-1] not in ( ALLOWED_TERMINATING_PUNCTUATIONS ) excluded_phrase_at_beginning_of_line = any( line[1:].startswith(word) for word in EXCLUDED_PHRASES ) if last_char_is_invalid and not ( excluded_phrase_is_present_at_end or excluded_phrase_at_beginning_of_line ): self.add_message('invalid-punctuation-used', line=line_num) def _check_trailing_comment_starts_with_allowed_pragma( self, line: str, line_num: int ) -> None: """Checks if the trailing inline comment starts with a valid and allowed pragma. Args: line: str. The current line of comment. line_num: int. Line number of the current comment. """ comment_start_index = -1 for pos, char in enumerate(line): if char == '#': comment_start_index = pos line = line[comment_start_index:] self._check_space_at_beginning_of_comments(line, line_num) allowed_inline_pragma_present = any( line[2:].startswith(word) for word in ALLOWED_PRAGMAS_FOR_INLINE_COMMENTS ) if allowed_inline_pragma_present: return self.add_message('no-allowed-inline-pragma', line=line_num) def process_tokens(self, tokens: List[tokenize.TokenInfo]) -> None: """Custom pylint checker to ensure that comments follow correct style. Args: tokens: list(TokenInfo). Object to access all tokens of a module. """ prev_line_num = -1 comments_group_list: List[List[Tuple[str, int]]] = [] comments_index = -1 for token_type, _, (line_num, _), _, line in tokens: if token_type == tokenize.COMMENT: line = line.strip() if line.startswith('#'): self._check_space_at_beginning_of_comments(line, line_num) if prev_line_num + 1 == line_num: comments_group_list[comments_index].append( (line, line_num) ) else: comments_group_list.append([(line, line_num)]) comments_index += 1 prev_line_num = line_num else: self._check_trailing_comment_starts_with_allowed_pragma( line, line_num ) for comments in comments_group_list: # Checks first line of comment. self._check_comment_starts_with_capital_letter(*comments[0]) # Checks last line of comment. self._check_punctuation(*comments[-1]) # TODO(#16567): Here we use MyPy ignore because the incomplete typing of # pylint library and absences of stubs in pylint, forces MyPy to # assume that BaseChecker class has attributes of type Any. # Thus to avoid MyPy's error # (Class cannot subclass 'BaseChecker' (has type 'Any')), # we added an ignore here. class BlankLineBelowFileOverviewChecker(checkers.BaseChecker): # type: ignore[misc] """Checks if there is a single empty line below the fileoverview docstring. Note: The check assumes that all files have a file overview. This assumption is justified because Pylint has an inbuilt check (missing-docstring) for missing file overviews. """ __implements__ = interfaces.IAstroidChecker name = 'space_between_imports_and_file-overview' priority = -1 msgs = { 'C0024': ( 'Please add an empty line below the fileoverview docstring.', 'no-empty-line-provided-below-fileoverview', 'please provide an empty line below the fileoverview.', ), 'C0025': ( 'Single empty line should be provided below the fileoverview.', 'only-a-single-empty-line-should-be-provided', 'please provide an empty line below the fileoverview.', ), } def visit_module(self, node: astroid.Module) -> None: """Visit a module to ensure that there is a blank line below file overview docstring. Args: node: astroid.scoped_nodes.Function. Node to access module content. """ # Check if the given node has docstring. if node.doc is None: return line_number = node.fromlineno # Iterate till the start of docstring. while True: line = linecache.getline(node.root().file, line_number).strip() if line.startswith(('\'', '"')): break line_number += 1 doc_length = len(node.doc.split('\n')) line_number += doc_length first_line_after_doc = linecache.getline( node.root().file, line_number ).strip() second_line_after_doc = linecache.getline( node.root().file, line_number + 1 ).strip() if first_line_after_doc != '': self.add_message( 'no-empty-line-provided-below-fileoverview', node=node ) elif second_line_after_doc == '': self.add_message( 'only-a-single-empty-line-should-be-provided', node=node ) # TODO(#16567): Here we use MyPy ignore because the incomplete typing of # pylint library and absences of stubs in pylint, forces MyPy to # assume that BaseChecker class has attributes of type Any. # Thus to avoid MyPy's error # (Class cannot subclass 'BaseChecker' (has type 'Any')), # we added an ignore here. class SingleLinePragmaChecker(checkers.BaseChecker): # type: ignore[misc] """Custom pylint checker which checks if pylint pragma is used to disable a rule for a single line only. """ __implements__ = interfaces.ITokenChecker name = 'single-line-pragma' priority = -1 msgs = { 'C0028': ( 'Pylint pragmas should be used to disable a rule ' 'for a single line only', 'single-line-pragma', 'Please use pylint pragmas to disable a rule for a single line only', ) } def process_tokens(self, tokens: List[tokenize.TokenInfo]) -> None: """Custom pylint checker which allows paramas to disable a rule for a single line only. Args: tokens: List[TokenInfo]. Object to access all tokens of a module. """ for token_type, _, (line_num, _), _, line in tokens: if token_type == tokenize.COMMENT: line = line.lstrip() # Ignore line that is enabling this check. # Example: # # pylint: disable=import-only-modules, single-line-pragma # def func(a, b): # # pylint: enable=import-only-modules, single-line-pragma # Now if do not ignore the line with 'enable' statement # pylint will raise the error of single-line-pragma because # from here on all this lint check is enabled. So we need to # ignore this line. if re.search(r'^(#\s*pylint:)', line): if 'enable' in line and 'single-line-pragma' in line: continue self.add_message('single-line-pragma', line=line_num) # TODO(#16567): Here we use MyPy ignore because the incomplete typing of # pylint library and absences of stubs in pylint, forces MyPy to # assume that BaseChecker class has attributes of type Any. # Thus to avoid MyPy's error # (Class cannot subclass 'BaseChecker' (has type 'Any')), # we added an ignore here. class TypeIgnoreCommentChecker(checkers.BaseChecker): # type: ignore[misc] """Custom pylint checker which checks if MyPy's type ignores are properly documented or not. """ __implements__ = interfaces.IAstroidChecker name = 'type-ignore-comment' priority = -1 msgs = { 'C0045': ( 'Please try to avoid the use of \'type: ignore\' if possible.' ' If \'type: ignore\' is really necessary, then add a proper' ' comment with clear justification. The format of the comment' ' should be -> Here we use MyPy ignore because ...', 'mypy-ignore-used', 'MyPy ignores should be accompanied by proper comments. The format ' ' of comments should be -> Here we use MyPy ignore because ...', ), 'C0046': ( 'Extra comment is present for MyPy type: ignore. Please' ' remove it.', 'redundant-type-comment', 'No corresponding \'type: ignore\' is found for the comment.', ), 'C0050': ( 'Please avoid the usage of \'type: ignore[%s]\' as it is' ' not allowed in the codebase. Instead try to fix the code' ' implementation so that the MyPy error is suppressed. For' ' more information, visit :' ' https://github.com/oppia/oppia/wiki/Backend-Type-Annotations', 'prohibited-type-ignore-used', 'Only a limited number of type ignores are allowed in the codebase.', ), 'C0051': ( 'Usage of generic MyPy type ignores is prohibited. ' 'MyPy type ignores can only be used with specific ' 'error codes: type: ignore[<error-code>]', 'generic-mypy-ignore-used', 'Generic type ignore can be ambiguous while reading and could be ' 'dangerous for python static typing. So, only error code specific ' 'type ignores are allowed.', ), } options = ( ( 'allowed-type-ignore-error-codes', { 'default': [], 'type': 'csv', 'metavar': '<comma separated list>', 'help': 'List of allowed MyPy type ignore error codes.', }, ), ) def visit_module(self, node: astroid.Module) -> None: """Visit a module to ensure that there is a comment for each MyPy type ignore. Args: node: astroid.scoped_nodes.Module. Node to access module content. """ tokens = pylint_utils.tokenize_module(node) self._process_module_tokens(tokens, node) def _process_module_tokens( self, tokens: List[tokenize.TokenInfo], node: astroid.Module ) -> None: """Checks if the MyPy type ignores present in a module are properly documented by a code comment or not. Also, checks for unnecessary code comments for which no corresponding type: ignore is found. Args: tokens: List[TokenInfo]. Object to access all tokens of a module. node: astroid.scoped_nodes.Module. Node to access module content. """ expected_type_ignore_comment_substring = ( r'Here we use MyPy ignore because' ) type_ignore_comment_present = False no_of_type_ignore_comments = 0 previous_comment_line_number = 0 comment_line_number = 0 for token_type, _, (line_num, _), _, line in tokens: if token_type == tokenize.COMMENT: line = line.lstrip() if expected_type_ignore_comment_substring in line: type_ignore_comment_present = True no_of_type_ignore_comments += 1 if no_of_type_ignore_comments > 1: previous_comment_line_number = comment_line_number self.add_message( 'redundant-type-comment', line=previous_comment_line_number, node=node, ) comment_line_number = line_num specific_type_ignore_matches = re.search( r'(\s*type:\s*ignore)\[([a-z-\s\,]*)\]', line ) if specific_type_ignore_matches: error_codes = specific_type_ignore_matches.group(2) encountered_error_codes = [] encountered_prohibited_error_codes = [] for error_code in error_codes.split(','): error_code = error_code.strip() if ( error_code not in self.config.allowed_type_ignore_error_codes ): encountered_prohibited_error_codes.append( error_code ) encountered_error_codes.append(error_code) if encountered_prohibited_error_codes: self.add_message( 'prohibited-type-ignore-used', line=line_num, args=tuple(encountered_prohibited_error_codes), node=node, ) if type_ignore_comment_present and line_num <= ( comment_line_number + ALLOWED_LINES_OF_GAP_IN_COMMENT ): type_ignore_comment_present = False no_of_type_ignore_comments = 0 elif not encountered_prohibited_error_codes: self.add_message( 'mypy-ignore-used', line=line_num, node=node ) elif re.search(r'(\s*type:\s*ignore)', line): self.add_message( 'generic-mypy-ignore-used', line=line_num, node=node ) if type_ignore_comment_present: self.add_message( 'redundant-type-comment', line=comment_line_number, node=node ) # TODO(#16567): Here we use MyPy ignore because the incomplete typing of # pylint library and absences of stubs in pylint, forces MyPy to # assume that BaseChecker class has attributes of type Any. # Thus to avoid MyPy's error # (Class cannot subclass 'BaseChecker' (has type 'Any')), # we added an ignore here. class SingleSpaceAfterKeyWordChecker(checkers.BaseChecker): # type: ignore[misc] """Custom pylint checker which checks that there is a single space after keywords like `if`, `elif`, `while`, and `yield`. """ __implements__ = interfaces.ITokenChecker name = 'single-space-after-keyword' priority = -1 msgs = { 'C0029': ( 'Please add a single space after `%s` statement.', 'single-space-after-keyword', 'A single space should be added after a keyword.', ), } keywords = set(['if', 'elif', 'while', 'yield']) def process_tokens(self, tokens: List[tokenize.TokenInfo]) -> None: """Custom pylint checker which makes sure that every keyword is followed by a single space. Args: tokens: List[TokenInfo]. Object to access all tokens of a module. """ for token_type, token, (line_num, _), _, line in tokens: if token_type == tokenize.NAME and token in self.keywords: line = line.strip() # Regex evaluates to True if the line is of the form "if #" or # "... if #" where # is not a space. if not re.search(r'(\s|^)' + token + r'(\s[^\s]|$)', line): self.add_message( 'single-space-after-keyword', args=(token), line=line_num, ) class ImportStatusDict(TypedDict): """This dictionary which contains the variables that tracks the module's import status.""" single_line_import: bool inside_multi_line_import_scope: bool import_line_num: int class TypeStatusDict(TypedDict): """The dict containing all the information about the exceptional type that was passed to this method.""" type_comment_pending: bool type_comment_line_num: int outside_function_signature_block: bool outside_args_section: bool type_present_inside_arg_section: bool type_present_inside_return_section: bool type_present_in_function_signature: bool args_section_end_line_num: int func_def_start_line: int # TODO(#16567): Here we use MyPy ignore because the incomplete typing of # pylint library and absences of stubs in pylint, forces MyPy to # assume that BaseChecker class has attributes of type Any. # Thus to avoid MyPy's error # (Class cannot subclass 'BaseChecker' (has type 'Any')), # we added an ignore here. class ExceptionalTypesCommentChecker(checkers.BaseChecker): # type: ignore[misc] """Custom pylint checker which checks that there is always a comment for exceptional types in the backend type annotations. """ EXCEPTIONAL_TYPE_STATUS_DICT: TypeStatusDict = { 'type_comment_pending': False, 'type_comment_line_num': 0, 'outside_function_signature_block': True, 'outside_args_section': True, 'type_present_inside_arg_section': False, 'type_present_inside_return_section': False, 'type_present_in_function_signature': False, 'args_section_end_line_num': 0, 'func_def_start_line': 0, } __implements__ = interfaces.IAstroidChecker name = 'comment-for-exceptional-types' priority = -1 msgs = { 'C0047': ( 'Any type is used. If the Any type is really needed, then please' ' add a proper comment with clear justification why other specific' ' types cannot be used. The format of the comment should be' ' -> Here we use type Any because ...', 'any-type-used', 'Annotations with Any type should only be done for exceptional' ' cases with proper explanation in the code comment.', ), 'C0048': ( 'cast function is used. If the cast is really needed, then please' ' add a proper comment with clear justification why cast function' ' is needed. The format of the comment should be -> Here use cast' ' because ...', 'cast-func-used', 'Casting of any value should be done with a proper explanation in' ' the code comment.', ), 'C0049': ( 'object class is used. If the object class is really needed, then' ' please add a proper comment with clear justification why other' ' specific types cannot be used. The format of the comment should' ' be -> Here we use object because ...', 'object-class-used', 'Annotations with object should only be done for exceptional' ' cases with proper explanation in the code comment.', ), } def visit_module(self, node: astroid.Module) -> None: """Visit a module to ensure that there is a comment for each exceptional type (cast, Any and object). Args: node: astroid.scoped_nodes.Module. Node to access module content. """ tokens = pylint_utils.tokenize_module(node) self._process_module_tokens(tokens, node) def _process_module_tokens( self, tokens: List[tokenize.TokenInfo], node: astroid.Module ) -> None: """Checks whether an exceptional type in backend type annotations is documented. If exceptional type is not documented, then it adds a message accordingly. Args: tokens: List[TokenInfo]. Object to access all tokens of a module. node: astroid.scoped_nodes.Module. Node to access module content. """ self.check_comment_is_present_with_any_type(tokens, node) self.check_comment_is_present_with_cast_method(tokens, node) self.check_comment_is_present_with_object_class(tokens, node) def _check_import_status( self, import_status_dict: ImportStatusDict, token_type: int, token: str, line_num: int, ) -> None: """Checks whether the single-line import or multi-line import is present inside the module. If multi-line import is present then it checks whether the linters are currently inside multi-line import's scope or not. Args: import_status_dict: dict. This dictionary contains the variables that tracks the module's import status, where: 1st element of dict: Indicates whether single line import is encountered or not. 2nd element of dict: Indicates the line number if import is encountered, otherwise it is zero. 3rd element of dict: Indicates whether the multi-line import is encountered and linters are in it's scope or not. import( << multi-line import's scope >> ) token_type: int. The kind of token that pylint provided. token: str. The token of module the pylint provided. line_num: int. The line number of given token. """ # Checking if single-line import is present. # Eg: from typing import Any. if token_type == tokenize.NAME: if token == 'import': import_status_dict['single_line_import'] = True import_status_dict['import_line_num'] = line_num # Checking if multi-line import is present. # Eg: from typing import ( # Any, Callable, Dict, FrozenSet, Iterator, List, Set, # Tuple, Type, cast # ) if token_type == tokenize.OP: if import_status_dict['single_line_import'] and token == '(': import_status_dict['inside_multi_line_import_scope'] = True import_status_dict['single_line_import'] = False if ( import_status_dict['inside_multi_line_import_scope'] and token == ')' ): import_status_dict['inside_multi_line_import_scope'] = False def _check_exceptional_type_is_documented( self, type_status_dict: TypeStatusDict, import_status_dict: Optional[ImportStatusDict], token_type: int, token: str, line: str, line_num: int, exceptional_type: str, node: astroid.Module, ) -> None: """Checks whether the given exceptional type in a module has been documented or not. If the exceptional type is not documented then adds an error message. Args: type_status_dict: dict. The dict containing all the information about the exceptional type that was passed to this method. import_status_dict: Optional[Dict]. This dictionary contains the variables that tracks the module's import status, whether a multi-line import or single-line import is present, or None if the given exceptional_type is not imported in the module. token_type: int. The kind of token that pylint provided. token: str. The token of module the pylint provided. line: str. The line of the module where current token is present. line_num: int. The line number of given token. exceptional_type: str. The exceptional type for which this method is called, Possible values can be 'Any' or 'object'. node: astroid.scoped_nodes.Module. Node to access module content. """ # Checking if linters are in argument-section, return-section or # outside of the function signature. # Eg: # <outside function signature block> # def func(<argument-section>) -> <return-section>: # <outside function signature block>. if token_type == tokenize.NAME: if token == 'def': type_status_dict['outside_function_signature_block'] = False type_status_dict['func_def_start_line'] = line_num type_status_dict['outside_args_section'] = False if token_type == tokenize.OP: if token == '->': type_status_dict['outside_args_section'] = True type_status_dict['args_section_end_line_num'] = line_num if type_status_dict['outside_args_section'] and token == ':': type_status_dict['outside_function_signature_block'] = True # Checking if exceptional_type is present in function definition or not. if token_type == tokenize.NAME and token == exceptional_type: if not type_status_dict['outside_args_section']: type_status_dict['type_present_inside_arg_section'] = True elif ( type_status_dict['outside_args_section'] and type_status_dict['args_section_end_line_num'] == line_num ): type_status_dict['type_present_inside_return_section'] = True if ( type_status_dict['type_present_inside_arg_section'] or type_status_dict['type_present_inside_return_section'] ): type_status_dict['type_present_in_function_signature'] = True if type_status_dict['outside_function_signature_block']: if type_status_dict['type_present_in_function_signature']: if type_status_dict[ 'type_comment_pending' ] and type_status_dict['func_def_start_line'] <= ( type_status_dict['type_comment_line_num'] + ALLOWED_LINES_OF_GAP_IN_COMMENT ): type_status_dict['type_comment_pending'] = False else: self._add_exceptional_type_error_message( exceptional_type, type_status_dict['func_def_start_line'], node, ) type_status_dict['type_present_in_function_signature'] = False type_status_dict['type_present_inside_arg_section'] = False type_status_dict['type_present_inside_return_section'] = False if token_type == tokenize.NAME and token == exceptional_type: if exceptional_type == 'object': # Excluding the case when object is called: # Eg: var = object() if 'object()' in line: return # Excluding mock.patch.object usage: # Eg: mock.patch.object(...) if 'patch.object' in line: return # Passing those cases where Any is imported. if exceptional_type == 'Any' and import_status_dict: if ( import_status_dict['single_line_import'] and import_status_dict['import_line_num'] == line_num ): return elif import_status_dict['inside_multi_line_import_scope']: return # Checking if comment for exceptional_type is present and it's # with in the range (minimum 15 line of gaps). if type_status_dict['type_comment_pending'] and line_num <= ( type_status_dict['type_comment_line_num'] + ALLOWED_LINES_OF_GAP_IN_COMMENT ): type_status_dict['type_comment_pending'] = False else: self._add_exceptional_type_error_message( exceptional_type, line_num, node ) def _add_exceptional_type_error_message( self, exceptional_type: str, line_num: int, node: astroid.Module ) -> None: """This method should be called only when an exceptional type error is encountered. If the exceptional type is Any then 'any-type-used' error message is added, for object 'object-class-used' is added and for cast 'cast-func-used' is added. Args: exceptional_type: str. The exceptional type for which this method is called, Possible values can be 'Any', 'object' and 'cast'. line_num: int. The line number where error is encountered. node: astroid.scoped_nodes.Module. Node to access module content. """ if exceptional_type == 'Any': self.add_message('any-type-used', line=line_num, node=node) if exceptional_type == 'object': self.add_message('object-class-used', line=line_num, node=node) if exceptional_type == 'cast': self.add_message('cast-func-used', line=line_num, node=node) def check_comment_is_present_with_object_class( self, tokens: List[tokenize.TokenInfo], node: astroid.Module ) -> None: """Checks whether the object class in a module has been documented or not. If the object class is not documented then adds an error message. Args: tokens: List[TokenInfo]. Object to access all tokens of a module. node: astroid.Module. Node to access module content. """ object_class_status_dict: TypeStatusDict = copy.deepcopy( self.EXCEPTIONAL_TYPE_STATUS_DICT ) expected_object_class_comment_substring = r'Here we use object because' for token_type, token, (line_num, _), _, line in tokens: line = line.strip() if token_type == tokenize.COMMENT: if expected_object_class_comment_substring in line: object_class_status_dict['type_comment_pending'] = True object_class_status_dict['type_comment_line_num'] = line_num self._check_exceptional_type_is_documented( object_class_status_dict, None, token_type, token, line, line_num, 'object', node, ) def check_comment_is_present_with_cast_method( self, tokens: List[tokenize.TokenInfo], node: astroid.Module ) -> None: """Checks whether the cast method in a module has been documented or not. If the cast method is not documented then adds an error message. Args: tokens: List[TokenInfo]. Object to access all tokens of a module. node: astroid.scoped_nodes.Module. Node to access module content. """ expected_cast_method_comment_substring = r'Here we use cast because' cast_comment_present = False cast_comment_line_num = 0 import_status_dict: ImportStatusDict = { 'single_line_import': False, 'import_line_num': 0, 'inside_multi_line_import_scope': False, } for token_type, token, (line_num, _), _, line in tokens: line = line.strip() if token_type == tokenize.COMMENT: if expected_cast_method_comment_substring in line: cast_comment_present = True cast_comment_line_num = line_num self._check_import_status( import_status_dict, token_type, token, line_num ) if token_type == tokenize.NAME and token == 'cast': # Passing those cases where cast is imported. if ( import_status_dict['single_line_import'] and import_status_dict['import_line_num'] == line_num ): pass elif import_status_dict['inside_multi_line_import_scope']: pass # Throwing an error when cast is encountered but there is no # corresponding comment exist. elif cast_comment_present and line_num <= ( cast_comment_line_num + ALLOWED_LINES_OF_GAP_IN_COMMENT ): cast_comment_present = False else: self._add_exceptional_type_error_message( 'cast', line_num, node ) def check_comment_is_present_with_any_type( self, tokens: List[tokenize.TokenInfo], node: astroid.Module ) -> None: """Checks whether the Any type in a module has been documented or not. If the Any type is not documented then adds an error message. Args: tokens: List[TokenInfo]. Object to access all tokens of a module. node: astroid.Module. Node to access module content. """ import_status_dict: ImportStatusDict = { 'single_line_import': False, 'import_line_num': 0, 'inside_multi_line_import_scope': False, } any_type_status_dict: TypeStatusDict = copy.deepcopy( self.EXCEPTIONAL_TYPE_STATUS_DICT ) expected_any_type_comment_substring = r'Here we use type Any because' for token_type, token, (line_num, _), _, line in tokens: line = line.strip() if token_type == tokenize.COMMENT: if expected_any_type_comment_substring in line: any_type_status_dict['type_comment_pending'] = True any_type_status_dict['type_comment_line_num'] = line_num self._check_import_status( import_status_dict, token_type, token, line_num ) self._check_exceptional_type_is_documented( any_type_status_dict, import_status_dict, token_type, token, line, line_num, 'Any', node, ) # TODO(#16567): Here we use MyPy ignore because the incomplete typing of # pylint library and absences of stubs in pylint, forces MyPy to # assume that BaseChecker class has attributes of type Any. # Thus to avoid MyPy's error # (Class cannot subclass 'BaseChecker' (has type 'Any')), # we added an ignore here. class InequalityWithNoneChecker(checkers.BaseChecker): # type: ignore[misc] """Custom pylint checker prohibiting use of "if x != None" and enforcing use of "if x is not None" instead. """ __implements__ = interfaces.IAstroidChecker name = 'inequality-with-none' priority = -1 msgs = { 'C0030': ( 'Please refrain from using "x != None" ' 'and use "x is not None" instead.', 'inequality-with-none', 'Use "is" to assert equality or inequality against None.', ) } def visit_compare(self, node: astroid.Compare) -> None: """Called for comparisons (a != b). Args: node: astroid.Compare. A node indicating comparison. """ ops = node.ops for operator, operand in ops: if operator != '!=': continue # Check if value field is in operand node, since # not all righthand side nodes will have this field. if 'value' in vars(operand) and operand.value is None: self.add_message('inequality-with-none', node=node) # TODO(#16567): Here we use MyPy ignore because the incomplete typing of # pylint library and absences of stubs in pylint, forces MyPy to # assume that BaseChecker class has attributes of type Any. # Thus to avoid MyPy's error # (Class cannot subclass 'BaseChecker' (has type 'Any')), # we added an ignore here. class NonTestFilesFunctionNameChecker(checkers.BaseChecker): # type: ignore[misc] """Custom pylint checker prohibiting use of "test_only" prefix in function names of non-test files. """ __implements__ = interfaces.IAstroidChecker name = 'non-test-files-function-name-checker' priority = -1 msgs = { 'C0031': ( 'Please change the name of the function so that it does not use ' '"test_only" as its prefix in non-test files.', 'non-test-files-function-name-checker', 'Prohibit use of "test_only" prefix in function names of non-test ' 'files.', ) } def visit_functiondef(self, node: astroid.FunctionDef) -> None: """Visit every function definition and ensure their name doesn't have test_only as its prefix. Args: node: astroid.FunctionDef. A node for a function or method definition in the AST. """ modnode = node.root() if modnode.name.endswith('_test'): return function_name = node.name if function_name.startswith('test_only'): self.add_message('non-test-files-function-name-checker', node=node) # TODO(#16567): Here we use MyPy ignore because the incomplete typing of # pylint library and absences of stubs in pylint, forces MyPy to # assume that BaseChecker class has attributes of type Any. # Thus to avoid MyPy's error # (Class cannot subclass 'BaseChecker' (has type 'Any')), # we added an ignore here. class DisallowedFunctionsChecker(checkers.BaseChecker): # type: ignore[misc] """Custom pylint checker for language specific general purpose regex checks of functions calls to be removed or replaced. """ __implements__ = interfaces.IAstroidChecker name = 'disallowed-function-calls' priority = -1 msgs = { 'C0032': ( 'Please remove the call to %s.', 'remove-disallowed-function-calls', ( 'Disallows usage of black-listed functions that ' 'should be removed.' ), ), 'C0033': ( 'Please replace the call to %s with %s.', 'replace-disallowed-function-calls', ( 'Disallows usage of black-listed functions that ' 'should be replaced by allowed alternatives.' ), ), } options = ( ( 'disallowed-functions-and-replacements-str', { 'default': (), 'type': 'csv', 'metavar': '<comma separated list>', 'help': ( 'List of strings of disallowed function names. ' 'Strings should be either in the format (1) "A=>B", ' 'where A is the disallowed function and B is the ' 'replacement, or (2) in the format "A", which signifies ' 'that A should just be removed.' ), }, ), ( 'disallowed-functions-and-replacements-regex', { 'default': (), 'type': 'csv', 'metavar': '<comma separated list>', 'help': ( 'List of strings of regex to find disallowed function ' 'names. Strings should be either in the format "A=>B", ' 'where A is a regex for the disallowed function and B ' 'is the replacement or in the format "A", which ' ' signifies that A should just be removed. ' 'An example regex entry is: ".*func=>other", which ' 'suggests "somefunc" be replaced by "other".' ), }, ), ) def __init__(self, linter: Optional[lint.PyLinter] = None) -> None: super().__init__(linter=linter) self.funcs_to_replace_str: Dict[str, str] = {} self.funcs_to_remove_str: Set[str] = set() self.funcs_to_replace_regex: List[Tuple[Pattern[str], str]] = [] self.funcs_to_remove_regex: Optional[Pattern[str]] = None def open(self) -> None: self._populate_disallowed_functions_and_replacements_str() self._populate_disallowed_functions_and_replacements_regex() def _populate_disallowed_functions_and_replacements_str(self) -> None: """Parse pylint config entries for replacements of disallowed functions represented by strings. """ for entry in self.config.disallowed_functions_and_replacements_str: splits = [s.strip() for s in entry.split('=>')] assert len(splits) in (1, 2) if len(splits) == 1: self.funcs_to_remove_str.add(splits[0]) else: self.funcs_to_replace_str[splits[0]] = splits[1] def _populate_disallowed_functions_and_replacements_regex(self) -> None: """Parse pylint config entries for replacements of disallowed functions represented by regex. """ remove_regexes = [] for entry in self.config.disallowed_functions_and_replacements_regex: splits = [s.strip() for s in entry.split('=>')] assert len(splits) in (1, 2) if len(splits) == 1: remove_regexes.append(splits[0]) else: rgx = re.compile(r'{}'.format(splits[0])) self.funcs_to_replace_regex.append((rgx, splits[1])) # Store removal regexes as one large regex, concatenated by "|". if len(remove_regexes) > 0: self.funcs_to_remove_regex = re.compile( r'{}'.format('|'.join(remove_regexes)) ) def visit_call(self, node: astroid.Call) -> None: """Visit a function call to ensure that the call is not using any disallowed functions. Args: node: astroid.Call. Node to access call content. """ func = node.func.as_string() if func in self.funcs_to_replace_str: self.add_message( 'replace-disallowed-function-calls', node=node, args=(func, self.funcs_to_replace_str[func]), ) elif func in self.funcs_to_remove_str or ( self.funcs_to_remove_regex is not None and self.funcs_to_remove_regex.match(func) is not None ): self.add_message( 'remove-disallowed-function-calls', node=node, args=func ) else: # Search through list of replacement regexes entries # (tuple(rgx, replacement)). If a match is found, return the # corresponding replacement. for rgx, replacement in self.funcs_to_replace_regex: if rgx.match(func) is not None: self.add_message( 'replace-disallowed-function-calls', node=node, args=(func, replacement), ) break # TODO(#16567): Here we use MyPy ignore because the incomplete typing of # pylint library and absences of stubs in pylint, forces MyPy to # assume that BaseChecker class has attributes of type Any. # Thus to avoid MyPy's error # (Class cannot subclass 'BaseChecker' (has type 'Any')), # we added an ignore here. class DisallowHandlerWithoutSchema(checkers.BaseChecker): # type: ignore[misc] """Custom pylint checker prohibiting handlers which do not have schema defined within the class. """ __implements__ = interfaces.IAstroidChecker name = 'disallow-handlers-without-schema' priority = -1 msgs = { 'C0035': ( 'Please add schema in URL_ARGS_PATH_SCHEMA for %s class. \nVisit ' 'https://github.com/oppia/oppia/wiki/Writing-schema-for-' 'handler-args' 'to learn how to write schema for handlers.', 'no-schema-for-url-path-elements', 'Enforce writing schema for url path arguments of handler class.', ), 'C0036': ( 'Please add schema in HANDLER_ARGS_SCHEMA for %s class. \nVisit ' 'https://github.com/oppia/oppia/wiki/Writing-schema-for-' 'handler-args' 'to learn how to write schema for handlers.', 'no-schema-for-handler-args', 'Enforce writing schema for request arguments of handler class.', ), 'C0037': ( 'URL_PATH_ARGS_SCHEMAS for %s class must be dict.', 'url-path-args-schemas-must-be-dict', 'Enforce URL_ARGS_PATH_SCHEMAS to be of dict type.', ), 'C0038': ( 'HANDLER_ARGS_SCHEMAS for %s class must be dict.', 'handler-args-schemas-must-be-dict', 'Enforce HANDLER_ARGS_SCHEMAS to be of dict type.', ), } def check_given_variable_is_a_dict( self, node: astroid.ClassDef, variable_name: str ) -> bool: """Checks whether schema variable of a handlers class is of dict type. Args: node: astroid.nodes.ClassDef. Node for a class definition in the AST. variable_name: str. Name of the variable which contains schemas. Returns: bool. Whether schema variable of a class is of dict type. """ generator_object_for_value_of_schemas = node.locals[variable_name][ 0 ].assigned_stmts() for value_of_schemas in generator_object_for_value_of_schemas: if value_of_schemas.name != 'dict': return False return True def check_parent_class_is_basehandler(self, node: astroid.ClassDef) -> bool: """Checks whether the parent class of given class is BaseHandler. Args: node: astroid.nodes.ClassDef. Node for a class definition in the AST. Returns: bool. Whether the parent class of given class is BaseHandler. """ for ancestor_node in node.ancestors(): if ancestor_node.name == 'BaseHandler': return True return False def visit_classdef(self, node: astroid.nodes.ClassDef) -> None: """Visit each class definition in controllers layer module and check if it contains schema or not. Args: node: astroid.nodes.ClassDef. Node for a class definition in the AST. """ if not self.check_parent_class_is_basehandler(node): return if ( node.name in handler_schema_constants.HANDLER_CLASS_NAMES_WITH_NO_SCHEMA ): return if 'URL_PATH_ARGS_SCHEMAS' not in node.locals: self.add_message( 'no-schema-for-url-path-elements', node=node, args=node.name ) elif not self.check_given_variable_is_a_dict( node, 'URL_PATH_ARGS_SCHEMAS' ): self.add_message( 'url-path-args-schemas-must-be-dict', node=node, args=node.name ) if 'HANDLER_ARGS_SCHEMAS' not in node.locals: self.add_message( 'no-schema-for-handler-args', node=node, args=node.name ) elif not self.check_given_variable_is_a_dict( node, 'HANDLER_ARGS_SCHEMAS' ): self.add_message( 'handler-args-schemas-must-be-dict', node=node, args=node.name ) # TODO(#16567): Here we use MyPy ignore because the incomplete typing of # pylint library and absences of stubs in pylint, forces MyPy to # assume that BaseChecker class has attributes of type Any. # Thus to avoid MyPy's error # (Class cannot subclass 'BaseChecker' (has type 'Any')), # we added an ignore here. class DisallowedImportsChecker(checkers.BaseChecker): # type: ignore[misc] """Check that disallowed imports are not made.""" __implements__ = interfaces.IAstroidChecker name = 'disallowed-imports' priority = -1 msgs = { 'C0039': ( 'Please use str instead of Text', 'disallowed-text-import', 'Disallow import of Text from typing module', ), } def visit_importfrom(self, node: astroid.nodes.ImportFrom) -> None: """Visits all import-from statements in a python file and ensures that only allowed imports are made. Args: node: astroid.node_classes.ImportFrom. Node for a import-from statement in the AST. """ if node.modname != 'typing': return for name, _ in node.names: if name == 'Text': self.add_message('disallowed-text-import', node=node) # TODO(#16567): Here we use MyPy ignore because the incomplete typing of # pylint library and absences of stubs in pylint, forces MyPy to # assume that BaseChecker class has attributes of type Any. # Thus to avoid MyPy's error # (Class cannot subclass 'BaseChecker' (has type 'Any')), # we added an ignore here. class PreventStringConcatenationChecker(checkers.BaseChecker): # type: ignore[misc] """Checks for string concactenation and encourages string interpolation.""" __implements__ = interfaces.IAstroidChecker name = 'use-string-interpolation' priority = -1 msgs = { 'C0041': ( 'Please use string interpolation instead of string concatenation', 'use-string-interpolation', 'Used when string concatenation is detected.', ), } def visit_binop(self, node: astroid.BinOp) -> None: """Visits a binary operation node in the AST then checks if it's a string concatenation operation. Args: node: astroid.BinOp The binary operation node to check. """ if isinstance(node, astroid.BinOp) and node.op == '+': try: left_inferred = next(node.left.infer()) right_inferred = next(node.right.infer()) except astroid.exceptions.InferenceError: return # Ignore operation if either side is inferred to be a datetime obj. if any( isinstance(inferred, (astroid.Instance, astroid.Const)) and isinstance(inferred.pytype(), str) and 'datetime.datetime' in inferred.pytype() for inferred in [left_inferred, right_inferred] ): return if ( isinstance(left_inferred, astroid.Const) and isinstance(right_inferred, astroid.Const) and isinstance(left_inferred.value, str) and isinstance(right_inferred.value, str) ): self.add_message('use-string-interpolation', node=node) def register(linter: lint.PyLinter) -> None: """Registers the checker with pylint. Args: linter: Pylinter. The Pylinter object. """ linter.register_checker(HangingIndentChecker(linter)) linter.register_checker(DocstringParameterChecker(linter)) linter.register_checker(ImportOnlyModulesChecker(linter)) linter.register_checker(BackslashContinuationChecker(linter)) linter.register_checker(FunctionArgsOrderChecker(linter)) linter.register_checker(RestrictedImportChecker(linter)) linter.register_checker(SingleCharAndNewlineAtEOFChecker(linter)) linter.register_checker(SingleLineCommentChecker(linter)) linter.register_checker(BlankLineBelowFileOverviewChecker(linter)) linter.register_checker(SingleLinePragmaChecker(linter)) linter.register_checker(TypeIgnoreCommentChecker(linter)) linter.register_checker(SingleSpaceAfterKeyWordChecker(linter)) linter.register_checker(ExceptionalTypesCommentChecker(linter)) linter.register_checker(InequalityWithNoneChecker(linter)) linter.register_checker(NonTestFilesFunctionNameChecker(linter)) linter.register_checker(DisallowedFunctionsChecker(linter)) linter.register_checker(DisallowHandlerWithoutSchema(linter)) linter.register_checker(DisallowedImportsChecker(linter)) linter.register_checker(PreventStringConcatenationChecker(linter))