import optparse import tokenize import warnings # Polyfill stdin loading/reading lines # https://gitlab.com/pycqa/flake8-polyfill/blob/1.0.1/src/flake8_polyfill/stdin.py#L52-57 try: from flake8.engine import pep8 stdin_get_value = pep8.stdin_get_value readlines = pep8.readlines except ImportError: from flake8 import utils import pycodestyle stdin_get_value = utils.stdin_get_value readlines = pycodestyle.readlines from flake8_quotes.__about__ import __version__ from flake8_quotes.docstring_detection import get_docstring_tokens class QuoteChecker(object): name = __name__ version = __version__ INLINE_QUOTES = { # When user wants only single quotes '\'': { 'good_single': '\'', 'bad_single': '"', }, # When user wants only double quotes '"': { 'good_single': '"', 'bad_single': '\'', }, } # Provide aliases for Windows CLI support # https://github.com/zheller/flake8-quotes/issues/49 INLINE_QUOTES['single'] = INLINE_QUOTES['\''] INLINE_QUOTES['double'] = INLINE_QUOTES['"'] MULTILINE_QUOTES = { '\'': { 'good_multiline': '\'\'\'', 'bad_multiline': '"""', }, '"': { 'good_multiline': '"""', 'bad_multiline': '\'\'\'', }, } # Provide Windows CLI and multi-quote aliases MULTILINE_QUOTES['single'] = MULTILINE_QUOTES['\''] MULTILINE_QUOTES['double'] = MULTILINE_QUOTES['"'] MULTILINE_QUOTES['\'\'\''] = MULTILINE_QUOTES['\''] MULTILINE_QUOTES['"""'] = MULTILINE_QUOTES['"'] DOCSTRING_QUOTES = { '\'': { 'good_docstring': '\'\'\'', 'bad_docstring': '"""', }, '"': { 'good_docstring': '"""', 'bad_docstring': '\'\'\'', }, } # Provide Windows CLI and docstring-quote aliases DOCSTRING_QUOTES['single'] = DOCSTRING_QUOTES['\''] DOCSTRING_QUOTES['double'] = DOCSTRING_QUOTES['"'] DOCSTRING_QUOTES['\'\'\''] = DOCSTRING_QUOTES['\''] DOCSTRING_QUOTES['"""'] = DOCSTRING_QUOTES['"'] def __init__(self, tree, filename='(none)'): self.filename = filename @staticmethod def _register_opt(parser, *args, **kwargs): """ Handler to register an option for both Flake8 3.x and 2.x. This is based on: https://github.com/PyCQA/flake8/blob/3.0.0b2/docs/source/plugin-development/cross-compatibility.rst#option-handling-on-flake8-2-and-3 It only supports `parse_from_config` from the original function and it uses the `Option` object returned to get the string. """ try: # Flake8 3.x registration parser.add_option(*args, **kwargs) except (optparse.OptionError, TypeError): # Flake8 2.x registration parse_from_config = kwargs.pop('parse_from_config', False) option = parser.add_option(*args, **kwargs) if parse_from_config: parser.config_options.append(option.get_opt_string().lstrip('-')) @classmethod def add_options(cls, parser): cls._register_opt(parser, '--quotes', action='store', parse_from_config=True, type='choice', choices=sorted(cls.INLINE_QUOTES.keys()), help='Deprecated alias for `--inline-quotes`') cls._register_opt(parser, '--inline-quotes', default='\'', action='store', parse_from_config=True, type='choice', choices=sorted(cls.INLINE_QUOTES.keys()), help='Quote to expect in all files (default: \')') cls._register_opt(parser, '--multiline-quotes', default=None, action='store', parse_from_config=True, type='choice', choices=sorted(cls.MULTILINE_QUOTES.keys()), help='Quote to expect in all files (default: """)') cls._register_opt(parser, '--docstring-quotes', default=None, action='store', parse_from_config=True, type='choice', choices=sorted(cls.DOCSTRING_QUOTES.keys()), help='Quote to expect in all files (default: """)') @classmethod def parse_options(cls, options): # Define our default config # cls.config = {good_single: ', good_multiline: ''', bad_single: ", bad_multiline: """} cls.config = {} cls.config.update(cls.INLINE_QUOTES['\'']) cls.config.update(cls.MULTILINE_QUOTES['"""']) cls.config.update(cls.DOCSTRING_QUOTES['"""']) # If `options.quotes` was specified, then use it if hasattr(options, 'quotes') and options.quotes is not None: # https://docs.python.org/2/library/warnings.html#warnings.warn warnings.warn('flake8-quotes has deprecated `quotes` in favor of `inline-quotes`. ' 'Please update your configuration') cls.config.update(cls.INLINE_QUOTES[options.quotes]) # Otherwise, use the supported `inline_quotes` else: # cls.config = {good_single: ', good_multiline: """, bad_single: ", bad_multiline: '''} # -> {good_single: ", good_multiline: """, bad_single: ', bad_multiline: '''} cls.config.update(cls.INLINE_QUOTES[options.inline_quotes]) # If multiline quotes was specified, overload our config with those options if hasattr(options, 'multiline_quotes') and options.multiline_quotes is not None: # cls.config = {good_single: ', good_multiline: """, bad_single: ", bad_multiline: '''} # -> {good_single: ', good_multiline: ''', bad_single: ", bad_multiline: """} cls.config.update(cls.MULTILINE_QUOTES[options.multiline_quotes]) # If docstring quotes was specified, overload our config with those options if hasattr(options, 'docstring_quotes') and options.docstring_quotes is not None: cls.config.update(cls.DOCSTRING_QUOTES[options.docstring_quotes]) def get_file_contents(self): if self.filename in ('stdin', '-', None): return stdin_get_value().splitlines(True) else: return readlines(self.filename) def run(self): file_contents = self.get_file_contents() noqa_line_numbers = self.get_noqa_lines(file_contents) errors = self.get_quotes_errors(file_contents) for error in errors: if error.get('line') not in noqa_line_numbers: yield (error.get('line'), error.get('col'), error.get('message'), type(self)) def get_noqa_lines(self, file_contents): tokens = [Token(t) for t in tokenize.generate_tokens(lambda L=iter(file_contents): next(L))] return [token.start_row for token in tokens if token.type == tokenize.COMMENT and token.string.endswith('noqa')] def get_quotes_errors(self, file_contents): tokens = [Token(t) for t in tokenize.generate_tokens(lambda L=iter(file_contents): next(L))] docstring_tokens = get_docstring_tokens(tokens) for token in tokens: if token.type != tokenize.STRING: # ignore non strings continue # Remove any prefixes in strings like `u` from `u"foo"` # DEV: `last_quote_char` is 1 character, even for multiline strings # `"foo"` -> `"foo"` # `b"foo"` -> `"foo"` # `br"foo"` -> `"foo"` # `b"""foo"""` -> `"""foo"""` last_quote_char = token.string[-1] first_quote_index = token.string.index(last_quote_char) unprefixed_string = token.string[first_quote_index:] # Determine if our string is multiline-based # "foo"[0] * 3 = " * 3 = """ # "foo"[0:3] = "fo # """foo"""[0:3] = """ is_docstring = token in docstring_tokens is_multiline_string = unprefixed_string[0] * 3 == unprefixed_string[0:3] start_row, start_col = token.start # If our string is a docstring # DEV: Docstring quotes must come before multiline quotes as it can as a multiline quote if is_docstring: if self.config['good_docstring'] in unprefixed_string: continue yield { 'message': 'Q002 Remove bad quotes from docstring', 'line': start_row, 'col': start_col, } # Otherwise if our string is multiline elif is_multiline_string: # If our string is or containing a known good string, then ignore it # (""")foo""" -> good (continue) # '''foo(""")''' -> good (continue) # (''')foo''' -> possibly bad if self.config['good_multiline'] in unprefixed_string: continue # Output our error yield { 'message': 'Q001 Remove bad quotes from multiline string', 'line': start_row, 'col': start_col, } # Otherwise (string is inline quote) else: # If our string is a known good string, then ignore it # (')foo' -> good (continue) # "it(')s" -> good (continue) # (")foo" -> possibly bad if self.config['good_single'] in unprefixed_string: continue # Output our error yield { 'message': 'Q000 Remove bad quotes', 'line': start_row, 'col': start_col, } class Token: """Python 2 and 3 compatible token""" def __init__(self, token): self.token = token @property def type(self): return self.token[0] @property def string(self): return self.token[1] @property def start(self): return self.token[2] @property def start_row(self): return self.token[2][0] @property def start_col(self): return self.token[2][1]