blob: 94c7532459621e7e5697b9ce3ea1e1471952da60 [file] [log] [blame]
# Copyright 2013 The ChromiumOS Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
# This module is not automatically loaded by the `cros` helper. The filename
# would need a "cros_" prefix to make that happen. It lives here so that it
# is alongside the cros_lint.py file.
#
# For msg namespaces, the 9xxx should generally be reserved for our own use.
"""Additional lint modules loaded by pylint.
This is loaded by pylint directly via its pylintrc file:
load-plugins=chromite.cli.cros.lint
Then pylint will import the register function and call it. So we can have
as many/few checkers as we want in this one module.
"""
import collections
import itertools
import os
import re
import sys
import tokenize
from typing import Optional, Tuple
import astroid
# pylint: enable=unused-import
import pylint.checkers
from pylint.config import ConfigurationMixIn
import pylint.interfaces
from chromite.utils import memoize
# pylint: disable=too-few-public-methods
class DocStringSectionDetails(object):
"""Object to hold details about a docstring section.
e.g. This holds the Args: or Returns: data.
"""
def __init__(self, name=None, header=None, lines=None, lineno=None):
"""Initialize.
Args:
name: The name of this section, e.g. "Args".
header: The raw header of this section, e.g. " Args:".
lines: The raw lines making up the section.
lineno: The first line of the section in the overall docstring.
This counts from one and includes the section header line.
"""
self.name = name
self.header = header
self.lines = [] if lines is None else lines
self.lineno = lineno
def __str__(self):
"""A human readable string for this object."""
return "DocStringSectionDetails(%r, %r)" % (self.name, self.lineno)
def __repr__(self):
"""A string to quickly identify this object."""
return "DocStringSectionDetails(%r, %r, %r, %r)" % (
self.name,
self.header,
self.lines,
self.lineno,
)
def __eq__(self, other):
"""Test whether two DocStringSectionDetails objects are equivalent"""
return (
self.name == other.name
and self.header == other.header
and self.lines == other.lines
and self.lineno == other.lineno
)
def _PylintrcConfig(config_file, section, opts):
"""Read specific pylintrc settings.
This is a bit hacky. The pylint framework doesn't allow us to access options
outside a Checker's own namespace (self.name), and multiple linters may not
have the same name/options values (since they get globally registered). So
we have to re-read the registered config file and pull out the value we
want.
The other option would be to force people to duplicate settings in the
config files and that's worse. e.g.
[format]
indent-string = ' '
[doc_string_checker]
indent-string = ' '
Args:
config_file: Path to the pylintrc file to read.
section: The section to read.
opts: The specific settings to return.
Returns:
A pylint configuration object. Use option_value('...') to read.
"""
class ConfigReader(ConfigurationMixIn):
"""Dynamic config file reader."""
name = section
options = opts
def set_current_module(self, config_file):
"""Ignore - read_config_file() invokes this for stats."""
cfg = ConfigReader(config_file=config_file)
cfg.read_config_file()
cfg.load_config_file()
return cfg
class _EncodingExtractError(Exception):
"""When we can't extract real values."""
class EncodingChecker(pylint.checkers.BaseChecker):
"""Various encoding enforcements."""
__implements__ = pylint.interfaces.IAstroidChecker
# pylint: disable=class-missing-docstring,multiple-statements
class _MessageR9150(object):
pass
# pylint: enable=class-missing-docstring,multiple-statements
name = "encoding_checker"
priority = -1
msgs = {
"R9150": (
"Missing explicit encoding='utf-8'",
("encoding-missing"),
_MessageR9150,
),
}
options = ()
def _extract_mode_encoding(
self,
node: astroid.Call,
encoding_idx: int,
mode_idx: Optional[int] = None,
) -> Tuple[Optional[str], Optional[str]]:
"""Extract the mode & encoding settings in the call."""
mode = "r"
encoding = None
def _extract_value(arg):
if isinstance(arg, astroid.Name):
raise _EncodingExtractError()
return arg.value
if len(node.args) >= encoding_idx + 1:
encoding = _extract_value(node.args[encoding_idx])
if mode_idx is not None and len(node.args) >= mode_idx + 1:
mode = _extract_value(node.args[mode_idx])
if node.keywords:
for kw in node.keywords:
if kw.arg == "mode":
mode = _extract_value(kw.value)
elif kw.arg == "encoding":
encoding = _extract_value(kw.value)
# If the caller explicitly set mode=None, ignore it -- it's an error,
# but other lints (e.g. bad-open-mode) handle it for us.
if mode is None:
raise _EncodingExtractError()
return (mode, encoding)
def _check_call(
self,
node: astroid.Call,
encoding_idx: int,
mode_idx: Optional[int] = None,
) -> None:
"""Check |node| call that has an encoding & mode argument."""
try:
mode, encoding = self._extract_mode_encoding(
node, encoding_idx, mode_idx
)
except _EncodingExtractError:
# If we couldn't reliably probe the values, give up.
return
if "b" not in mode and encoding != "utf-8":
self.add_message("R9150", node=node, line=node.fromlineno)
def visit_call(self, node: astroid.Call) -> None:
"""Check |node| call."""
# Check open() calls.
# open(file, mode='r', buffering=-1, encoding=None, ...
if isinstance(node.func, astroid.Name) and node.func.name == "open":
self._check_call(node, 3, 1)
# Check pathlib APIs.
pathlib_calls = {
# Path.open(mode='r', buffering=-1, encoding=None, ...
"open": (2, 0),
# Path.read_text(encoding=None, ...
"read_text": (0, None),
# Path.write_text(data, encoding=None, ...
"write_text": (1, None),
}
if (
isinstance(node.func, astroid.Attribute)
and node.func.attrname in pathlib_calls
):
encoding_idx, mode_idx = pathlib_calls[node.func.attrname]
try:
for inode in node.func.infer():
if (
isinstance(inode, astroid.bases.BoundMethod)
and inode.bound.pytype() == "pathlib.Path"
):
self._check_call(node, encoding_idx, mode_idx)
except astroid.exceptions.InferenceError:
# If astroid couldn't guess the type, it will often throw errors
# instead of returning None. We only care about code we can
# guess at with high confidence.
pass
# Check gzip.open() calls.
# NB: The default mode is binary, not text, e.g. "r" == "rb".
# gzip.open(file, mode='r', compresslevel=0, encoding=None, ...
if (
isinstance(node.func, astroid.Attribute)
and node.func.attrname == "open"
and isinstance(node.func.expr, astroid.Name)
and node.func.expr.name == "gzip"
):
try:
mode, encoding = self._extract_mode_encoding(node, 3, 1)
except _EncodingExtractError:
return
if "t" in mode and encoding != "utf-8":
self.add_message("R9150", node=node, line=node.fromlineno)
class DocStringChecker(pylint.checkers.BaseChecker):
"""PyLint AST based checker to verify PEP 257 compliance
See our style guide for more info:
https://dev.chromium.org/chromium-os/python-style-guidelines#TOC-Describing-arguments-in-docstrings
"""
# TODO: See about merging with the pep257 project:
# https://github.com/GreenSteam/pep257
__implements__ = pylint.interfaces.IAstroidChecker
# pylint: disable=class-missing-docstring,multiple-statements
class _MessageCP001(object):
pass
class _MessageCP002(object):
pass
class _MessageCP003(object):
pass
class _MessageCP004(object):
pass
class _MessageCP005(object):
pass
class _MessageCP006(object):
pass
class _MessageCP007(object):
pass
class _MessageCP008(object):
pass
class _MessageCP009(object):
pass
class _MessageCP010(object):
pass
class _MessageCP011(object):
pass
class _MessageCP012(object):
pass
class _MessageCP013(object):
pass
class _MessageCP014(object):
pass
class _MessageCP015(object):
pass
class _MessageCP016(object):
pass
class _MessageCP017(object):
pass
class _MessageCP018(object):
pass
class _MessageCP019(object):
pass
# pylint: enable=class-missing-docstring,multiple-statements
# All the sections we recognize (and in this order).
VALID_FUNC_SECTIONS = ("Examples", "Args", "Returns", "Yields", "Raises")
VALID_CLASS_SECTIONS = ("Examples", "Attributes")
ALL_VALID_SECTIONS = set(VALID_FUNC_SECTIONS + VALID_CLASS_SECTIONS)
# This is the section name in the pylintrc file.
name = "doc_string_checker"
# Any pylintrc config options we accept.
options = ()
priority = -1
MSG_ARGS = "offset:%(offset)i: {%(line)s}"
msgs = {
"C9001": (
"Modules should have docstrings (even a one liner)",
("module-missing-docstring"),
_MessageCP001,
),
"C9002": (
"Classes should have docstrings (even a one liner)",
("class-missing-docstring"),
_MessageCP002,
),
"C9003": (
"Trailing whitespace in docstring: " + MSG_ARGS,
("docstring-trailing-whitespace"),
_MessageCP003,
),
"C9004": (
"Leading whitespace in docstring (excess or missing)"
": " + MSG_ARGS,
("docstring-leading-whitespace"),
_MessageCP004,
),
"C9005": (
"Closing triple quotes should not be cuddled",
("docstring-cuddled-quotes"),
_MessageCP005,
),
"C9006": (
"Section names should be preceded by one blank line"
": " + MSG_ARGS,
("docstring-section-newline"),
_MessageCP006,
),
"C9007": (
'Section names should be "%(section)s": ' + MSG_ARGS,
("docstring-section-name"),
_MessageCP007,
),
"C9008": (
"Sections should be in the order: %(sections)s",
("docstring-section-order"),
_MessageCP008,
),
"C9009": (
"First line should be a short summary",
("docstring-first-line"),
_MessageCP009,
),
"C9010": (
"Not all args mentioned in doc string: |%(arg)s|",
("docstring-missing-args"),
_MessageCP010,
),
"C9011": (
"Variable args/keywords are named *args/**kwargs, not %(arg)s",
("docstring-misnamed-args"),
_MessageCP011,
),
"C9012": (
"Incorrectly formatted Args section: %(arg)s",
("docstring-arg-spacing"),
_MessageCP012,
),
"C9013": (
"Too many blank lines in a row: " + MSG_ARGS,
("docstring-too-many-newlines"),
_MessageCP013,
),
"C9014": (
"Second line should be blank",
("docstring-second-line-blank"),
_MessageCP014,
),
"C9015": (
"Section indentation should be %(want_indent)s spaces, not "
"%(curr_indent)s spaces: " + MSG_ARGS,
("docstring-section-indent"),
_MessageCP015,
),
"C9016": (
"Closing triple quotes should be indented with %(want_indent)s "
"spaces, not %(curr_indent)s",
("docstring-trailing-quotes"),
_MessageCP016,
),
"C9017": (
"Section %(section)s shows up more than once; previous at "
"%(line_old)i",
("docstring-duplicate-section"),
_MessageCP017,
),
"C9018": (
"Docstrings must start with exactly three quotes",
("docstring-extra-quotes"),
_MessageCP018,
),
"C9019": (
"Use typing module instead of docstring annotations",
("docstring-deprecated-annotations"),
_MessageCP019,
),
"C9020": (
"Duplicate arg documentation found: |%(arg)s|",
("docstring-duplicate-argument"),
_MessageCP019,
),
"C9021": (
"Unknown arg documentation found: |%(arg)s|",
("docstring-unknown-argument"),
_MessageCP019,
),
}
def __init__(self, *args, **kwargs):
pylint.checkers.BaseChecker.__init__(self, *args, **kwargs)
if self.linter is None:
# Unit tests don't set this up.
self._indent_string = " "
else:
cfg = _PylintrcConfig(
self.linter.config_file,
"format",
(("indent-string", {"default": " ", "type": "string"}),),
)
self._indent_string = cfg.option_value("indent-string")
self._indent_len = len(self._indent_string)
def visit_functiondef(self, node):
"""Verify function docstrings"""
if node.doc:
lines = node.doc.split("\n")
self._check_common(node, lines)
sections = self._parse_docstring_sections(node, lines)
self._check_section_lines(
node, lines, sections, self.VALID_FUNC_SECTIONS
)
self._check_all_args_in_doc(node, lines, sections)
self._check_func_signature(node)
else:
# This is what C0111 already does for us, so ignore.
pass
def visit_module(self, node):
"""Verify module docstrings"""
if node.doc:
self._check_common(node)
else:
# Ignore stub __init__.py files.
if os.path.basename(node.file) == "__init__.py":
return
self.add_message("C9001", node=node)
def visit_classdef(self, node):
"""Verify class docstrings"""
if node.doc:
lines = node.doc.split("\n")
self._check_common(node, lines)
sections = self._parse_docstring_sections(node, lines)
self._check_section_lines(
node, lines, sections, self.VALID_CLASS_SECTIONS
)
else:
self.add_message("C9002", node=node, line=node.fromlineno)
def _docstring_indent(self, node):
"""How much a |node|'s docstring should be indented"""
if node.display_type() == "Module":
return 0
else:
return node.col_offset + self._indent_len
def _check_common(self, node, lines=None):
"""Common checks we enforce on all docstrings"""
if lines is None:
lines = node.doc.split("\n")
funcs = (
self._check_first_line,
self._check_second_line_blank,
self._check_whitespace,
self._check_last_line,
)
for f in funcs:
f(node, lines)
def _check_first_line(self, node, lines):
"""Make sure first line is a short summary by itself"""
if lines[0] == "":
self.add_message("C9009", node=node, line=node.fromlineno)
# We only check the first line for extra quotes because the grammar
# halts when it sees the next set of triple quotes (which means extra
# trailing quotes are not part of the docstring).
if lines[0].startswith('"'):
self.add_message("C9018", node=node, line=node.fromlineno)
def _check_second_line_blank(self, node, lines):
"""Make sure the second line is blank"""
if len(lines) > 1 and lines[1] != "":
self.add_message("C9014", node=node, line=node.fromlineno)
def _check_whitespace(self, node, lines):
"""Verify whitespace is correct"""
# Make sure first line doesn't have leading whitespace.
if lines[0].lstrip() != lines[0]:
margs = {"offset": 0, "line": lines[0]}
self.add_message(
"C9004", node=node, line=node.fromlineno, args=margs
)
# Verify no trailing whitespace.
# We skip the last line since it's supposed to be pure whitespace.
#
# Also check for multiple blank lines in a row.
last_blank = False
for i, l in enumerate(lines[:-1]):
margs = {"offset": i, "line": l}
if l.rstrip() != l:
self.add_message(
"C9003", node=node, line=node.fromlineno, args=margs
)
curr_blank = l == ""
if last_blank and curr_blank:
self.add_message(
"C9013", node=node, line=node.fromlineno, args=margs
)
last_blank = curr_blank
# Now specially handle the last line.
l = lines[-1]
if l.strip() != "" and l.rstrip() != l:
margs = {"offset": len(lines), "line": l}
self.add_message(
"C9003", node=node, line=node.fromlineno, args=margs
)
def _check_last_line(self, node, lines):
"""Make sure last line is all by itself"""
if len(lines) > 1:
indent = self._docstring_indent(node)
if lines[-1].strip() != "":
self.add_message("C9005", node=node, line=node.fromlineno)
elif lines[-1] != " " * indent:
# The -1 line holds the """ itself and that should be indented.
margs = {
"offset": len(lines) - 1,
"line": lines[-1],
"want_indent": indent,
"curr_indent": len(lines[-1]),
}
self.add_message(
"C9016", node=node, line=node.fromlineno, args=margs
)
# The last line should not be blank.
if lines[-2] == "":
margs = {"offset": len(lines) - 2, "line": lines[-2]}
self.add_message(
"C9003", node=node, line=node.fromlineno, args=margs
)
@memoize.MemoizedSingleCall
def _invalid_sections_map(self):
"""Get a mapping from common invalid section names to the valid name."""
invalid_sections_sets = {
# Handle common misnamings.
"Examples": {
"example",
"exaple",
"exaples",
"usage",
"example usage",
},
"Attributes": {
"attr",
"attrs",
"attribute",
"prop",
"props",
"properties",
"member",
"members",
},
"Args": {
"arg",
"argument",
"arguments",
"param",
"params",
"parameter",
"parameters",
},
"Returns": {
"ret",
"rets",
"return",
"retrun",
"retruns",
"result",
"results",
},
"Yields": {"yield", "yeild", "yeilds"},
"Raises": {"raise", "riase", "riases", "throw", "throws"},
}
invalid_sections_map = {}
for key, value in invalid_sections_sets.items():
invalid_sections_map.update((v, key) for v in value)
return invalid_sections_map
def _parse_docstring_sections(self, node, lines):
"""Find all the sections and return them
Args:
node: The python object we're checking.
lines: Parsed docstring lines.
Returns:
An ordered dict of sections and their (start, end) line numbers.
The start line does not include the section header itself.
{'Args': [start_line_number, end_line_number], ...}
"""
sections = collections.OrderedDict()
invalid_sections_map = self._invalid_sections_map()
indent_len = self._docstring_indent(node)
in_args_section = False
last_section = None
for lineno, line in enumerate(lines[1:], start=2):
line_indent_len = len(line) - len(line.lstrip(" "))
margs = {
"offset": lineno,
"line": line,
}
l = line.strip()
# Catch semi-common javadoc style.
if l.startswith("@param"):
margs["section"] = "Args"
self.add_message(
"C9007", node=node, line=node.fromlineno, args=margs
)
if l.startswith("@return"):
margs["section"] = "Returns"
self.add_message(
"C9007", node=node, line=node.fromlineno, args=margs
)
# See if we can detect incorrect behavior.
section = l.split(":", 1)[0]
# Remember whether we're currently in the Args: section so we don't
# treat named arguments as sections (e.g. a function has a "returns"
# arg). Use the indentation level to detect the start of the next
# section.
if in_args_section:
in_args_section = indent_len < line_indent_len
if not in_args_section:
# We only parse known invalid & valid sections here. This
# avoids picking up things that look like sections but aren't
# (e.g. "Note:" lines), and avoids running checks on sections we
# don't yet support.
if section.lower() in invalid_sections_map:
margs["section"] = invalid_sections_map[section.lower()]
self.add_message(
"C9007", node=node, line=node.fromlineno, args=margs
)
elif section in self.ALL_VALID_SECTIONS:
if section in sections:
# We got the same section more than once?
margs_copy = margs.copy()
margs_copy.update(
{
"line_old": sections[section].lineno,
"section": section,
}
)
self.add_message(
"C9017",
node=node,
line=node.fromlineno,
args=margs_copy,
)
else:
# Gather the order of the sections.
sections[
section
] = last_section = DocStringSectionDetails(
name=section, header=line, lineno=lineno
)
# Detect whether we're in the Args section once we've processed
# the Args section itself.
in_args_section = section in ("Args", "Attributes")
if l == "" and last_section:
last_section.lines = lines[last_section.lineno : lineno - 1]
last_section = None
return sections
def _check_section_lines(self, node, lines, sections, valid_sections):
"""Verify each section (e.g. Args/Returns/etc...) is correct"""
indent_len = self._docstring_indent(node)
# Make sure the sections are in the right order.
found_sections = [x for x in valid_sections if x in sections]
if found_sections != list(sections):
margs = {"sections": ", ".join(valid_sections)}
self.add_message(
"C9008", node=node, line=node.fromlineno, args=margs
)
for section in sections.values():
# We're going to check the section line itself.
lineno = section.lineno
line = section.header
line_indent_len = len(line) - len(line.lstrip(" "))
margs = {
"offset": lineno,
"line": line,
"want_indent": indent_len,
"curr_indent": line_indent_len,
}
# Make sure it has some number of leading whitespace.
if not line.startswith(" "):
self.add_message(
"C9004", node=node, line=node.fromlineno, args=margs
)
# Make sure it has a single trailing colon.
if line.strip() != "%s:" % section.name:
margs["section"] = section.name
self.add_message(
"C9007", node=node, line=node.fromlineno, args=margs
)
# Verify blank line before it. We use -2 because lineno counts from
# one, but lines is a zero-based list.
if lines[lineno - 2] != "":
self.add_message(
"C9006", node=node, line=node.fromlineno, args=margs
)
# Check the indentation level on the section header (e.g. Args:).
if line_indent_len != indent_len:
self.add_message(
"C9015", node=node, line=node.fromlineno, args=margs
)
# Now check the indentation of subtext in each section.
saw_exact = False
want_indent = indent_len + self._indent_len
margs["want_indent"] = want_indent
first_item_margs = None
for i, line in enumerate(section.lines, start=1):
# Every line should be indented at least the minimum.
# Always update margs so that if we drop through below, it has
# reasonable values when generating the message.
line_indent_len = len(line) - len(line.lstrip(" "))
if first_item_margs is None:
first_item_margs = {
**margs,
"line": line,
"offset": lineno + i,
"curr_indent": line_indent_len,
}
if line_indent_len == want_indent:
saw_exact = True
elif line_indent_len < want_indent:
self.add_message(
"C9015",
node=node,
line=node.fromlineno,
args={
**margs,
"line": line,
"offset": lineno + i,
"curr_indent": line_indent_len,
},
)
# If none of the lines were indented at the exact level, then
# something is amiss like they're all incorrectly offset.
if first_item_margs and not saw_exact:
self.add_message(
"C9015",
node=node,
line=node.fromlineno,
args=first_item_margs,
)
def _check_all_args_in_doc(self, node, _lines, sections):
"""All function arguments are mentioned in doc"""
if not hasattr(node, "args"):
return
# If they don't have an Args section, then give it a pass.
section = sections.get("Args")
if section is None:
return
# Now verify all args exist.
# TODO: Should we verify arg order matches doc order ?
# TODO: Should we check indentation of wrapped docs ?
missing_args = []
found_args = set()
for arg in itertools.chain(node.args.args, node.args.kwonlyargs):
# Ignore class related args.
if arg.name in ("cls", "self"):
continue
# Ignore ignored args.
if arg.name.startswith("_"):
continue
# Valid arguments may look like `<arg>:` or `<arg> (<type>):`.
arg_re = re.compile(r"%s( \([^)]+\))?:" % re.escape(arg.name))
for l in section.lines:
aline = l.lstrip()
m = arg_re.match(aline)
if m:
if arg.name in found_args:
margs = {"arg": l}
self.add_message(
"C9020", node=node, line=node.fromlineno, args=margs
)
found_args.add(arg.name)
if m.group(1) is not None:
margs = {"arg": l}
self.add_message(
"C9019", node=node, line=node.fromlineno, args=margs
)
amsg = aline[m.end() :]
if amsg and len(amsg) - len(amsg.lstrip()) != 1:
margs = {"arg": l}
self.add_message(
"C9012", node=node, line=node.fromlineno, args=margs
)
if arg.name not in found_args:
missing_args.append(arg.name)
if missing_args:
margs = {"arg": "|, |".join(missing_args)}
self.add_message(
"C9010", node=node, line=node.fromlineno, args=margs
)
# Check special named arguments.
args_exp = node.args.vararg == "args"
args_re = re.compile(r"(\*?args):")
args_found = None
kwargs_exp = node.args.kwarg == "kwargs"
kwargs_re = re.compile(r"((\*\*)?kwargs):")
kwargs_found = None
for l in section.lines:
aline = l.lstrip()
m = args_re.match(aline)
if m:
if args_found:
margs = {"arg": l}
self.add_message(
"C9020", node=node, line=node.fromlineno, args=margs
)
args_found = m.group(1)
m = kwargs_re.match(aline)
if m:
if kwargs_found:
margs = {"arg": l}
self.add_message(
"C9020", node=node, line=node.fromlineno, args=margs
)
kwargs_found = m.group(1)
# args/kwargs shouldn't be documented if the func doesn't use them.
if args_found == "*args" and not args_exp:
margs = {"arg": args_found}
self.add_message(
"C9021", node=node, line=node.fromlineno, args=margs
)
if kwargs_found == "**kwargs" and not kwargs_exp:
margs = {"arg": kwargs_found}
self.add_message(
"C9021", node=node, line=node.fromlineno, args=margs
)
# Only accept "*args" & "**kwargs".
if args_exp and args_found and args_found != "*args":
margs = {"arg": args_found}
self.add_message(
"C9011", node=node, line=node.fromlineno, args=margs
)
if kwargs_exp and kwargs_found and kwargs_found != "**kwargs":
margs = {"arg": kwargs_found}
self.add_message(
"C9011", node=node, line=node.fromlineno, args=margs
)
def _check_func_signature(self, node):
"""Require *args to be named args, and **kwargs kwargs"""
vararg = node.args.vararg
if vararg and vararg != "args" and vararg != "_args":
margs = {"arg": vararg}
self.add_message(
"C9011", node=node, line=node.fromlineno, args=margs
)
kwarg = node.args.kwarg
if kwarg and kwarg != "kwargs" and kwarg != "_kwargs":
margs = {"arg": kwarg}
self.add_message(
"C9011", node=node, line=node.fromlineno, args=margs
)
class SourceChecker(pylint.checkers.BaseChecker):
"""Make sure we enforce rules on the source."""
__implements__ = pylint.interfaces.IAstroidChecker
# pylint: disable=class-missing-docstring,multiple-statements
class _MessageR9200(object):
pass
class _MessageR9201(object):
pass
class _MessageR9202(object):
pass
class _MessageR9203(object):
pass
class _MessageR9204(object):
pass
class _MessageR9205(object):
pass
class _MessageR9206(object):
pass
# pylint: enable=class-missing-docstring,multiple-statements
name = "source_checker"
priority = -1
MSG_ARGS = "offset:%(offset)i: {%(line)s}"
msgs = {
"R9200": (
'Shebang should be "#!/usr/bin/env python3" or '
'"#!/usr/bin/env python"',
("bad-shebang"),
_MessageR9200,
),
"R9201": (
"Shebang is missing, but file is executable (chmod -x to fix)",
("missing-shebang"),
_MessageR9201,
),
"R9202": (
"Shebang is set, but file is not executable (chmod +x to fix)",
("spurious-shebang"),
_MessageR9202,
),
"R9203": (
"Unittest not named xxx_unittest.py",
("unittest-misnamed"),
_MessageR9203,
),
"R9204": (
"File encoding missing (the first line after the shebang"
' should be "# -*- coding: utf-8 -*-")',
("missing-file-encoding"),
_MessageR9204,
),
"R9205": (
'File encoding should be "utf-8"',
("old-bad-file-encoding"),
_MessageR9205,
),
"R9206": (
"Use parens for long line wrapping, not backslashes",
("parens-not-backslashes"),
_MessageR9206,
),
}
options = ()
# Taken from PEP-263.
_ENCODING_RE = re.compile(
rb"^[ \t\v]*#.*?coding[:=][ \t]*([-_.a-zA-Z0-9]+)"
)
def visit_module(self, node):
"""Called when the whole file has been read"""
with node.stream() as stream:
st = os.fstat(stream.fileno())
self._check_shebang(node, stream, st)
self._check_encoding(node, stream, st)
self._check_module_name(node)
self._check_backslashes(node, stream)
def _check_shebang(self, _node, stream, st):
"""Verify the shebang is version specific"""
stream.seek(0)
mode = st.st_mode
executable = bool(mode & 0o0111)
shebang = stream.readline()
if shebang[0:2] != b"#!":
if executable:
self.add_message("R9201")
return
elif not executable:
self.add_message("R9202")
if shebang.strip() not in (
b"#!/usr/bin/env python2",
b"#!/usr/bin/env python3",
b"#!/usr/bin/env python",
b"#!/usr/bin/env vpython",
b"#!/usr/bin/env vpython3",
):
self.add_message("R9200")
def _check_encoding(self, _node, stream, st):
"""Verify the file has an encoding set
See PEP-263 for more details.
https://www.python.org/dev/peps/pep-0263/
"""
# Only allow empty files to have no encoding (e.g. __init__.py).
if not st.st_size:
return
stream.seek(0)
encoding = stream.readline()
# If the first line is the shebang, then the encoding is the second
# line.
if encoding[0:2] == b"#!":
encoding = stream.readline()
# See if the encoding matches the standard.
m = self._ENCODING_RE.match(encoding)
if m:
if m.group(1) != b"utf-8":
self.add_message("R9205")
else:
# Only enforce this on Python 2 files.
if sys.version_info.major < 3:
self.add_message("R9204")
def _check_module_name(self, node):
"""Make sure the module name is correct"""
# Catch various typos.
name = node.name.rsplit(".", 2)[-1]
if name.rsplit("_", 2)[-1] in ("unittests",):
self.add_message("R9203")
def _check_backslashes(self, _node, stream):
"""Make sure we use () for line continuations, not backslashes"""
stream.seek(0)
# This is a rough heuristic by nature: try and flag common uses, but not
# all \ usage as there are a few that are legitimate.
matcher = re.compile(
rb"^\s*([^#]*(and|or|=| [-%+|*/])|assert.*) *\\\n$"
)
for lineno, line in enumerate(stream, start=1):
if matcher.search(line):
s = line.decode("utf8")
self.add_message("R9206", line=lineno, col_offset=len(s) - 1)
class CommentChecker(pylint.checkers.BaseTokenChecker):
"""Enforce our arbitrary rules on comments."""
__implements__ = pylint.interfaces.ITokenChecker
# pylint: disable=class-missing-docstring,multiple-statements
class _MessageR9250(object):
pass
# pylint: enable=class-missing-docstring,multiple-statements
name = "comment_checker"
priority = -1
MSG_ARGS = "offset:%(offset)i: {%(line)s}"
msgs = {
"R9250": (
"One space needed at start of comment: %(comment)s",
("comment-missing-leading-space"),
_MessageR9250,
),
}
options = ()
def _visit_comment(self, lineno, comment):
"""Process |comment| at |lineno|."""
if comment == "#":
# Ignore standalone comments for spacing.
return
if lineno == 1 and comment.startswith("#!"):
# Ignore shebangs.
return
# We remove multiple leading # to support runs like ###.
if not comment.lstrip("#").startswith(" "):
self.add_message("R9250", line=lineno, args={"comment": comment})
def process_tokens(self, tokens):
"""Process tokens and look for comments."""
for tok_type, token, (start_row, _), _, _ in tokens:
if tok_type == tokenize.COMMENT:
self._visit_comment(start_row, token)
class FormatStringChecker(pylint.checkers.BaseChecker):
"""Check string formatting."""
__implements__ = pylint.interfaces.IAstroidChecker
# pylint: disable=class-missing-docstring,multiple-statements
class _MessageR9100(object):
pass
# pylint: enable=class-missing-docstring,multiple-statements
name = "format_string_checker"
priority = -1
MSG_ARGS = "offset:%(offset)i: {%(line)s}"
msgs = {
"R9100": (
"Use f-strings or % interpolation, never .format()",
("banned-string-format-function"),
_MessageR9100,
),
}
options = ()
def visit_const(self, node: astroid.nodes.Const) -> None:
"""Process a constant string node."""
if (
node.pytype() == "builtins.str"
and not isinstance(node.parent, astroid.nodes.JoinedStr)
and isinstance(node.parent, astroid.nodes.Attribute)
and node.parent.attrname == "format"
and isinstance(node.parent.parent, astroid.nodes.Call)
):
self.add_message(
"R9100", node=node, line=node.lineno, col_offset=node.col_offset
)
def register(linter):
"""pylint will call this func to register all our checkers"""
# Walk all the classes in this module and register ours.
this_module = sys.modules[__name__]
for member in dir(this_module):
if not member.endswith("Checker"):
continue
cls = getattr(this_module, member)
linter.register_checker(cls(linter))