├── test ├── __init__.py ├── data │ ├── doubles_noqa.py │ ├── singles_noqa.py │ ├── no_qa.py │ ├── doubles_wrapped.py │ ├── singles_wrapped.py │ ├── docstring_doubles_module_singleline.py │ ├── docstring_singles_module_singleline.py │ ├── doubles.py │ ├── multiline_string.py │ ├── singles.py │ ├── docstring_doubles_module_multiline.py │ ├── docstring_singles_module_multiline.py │ ├── doubles_multiline_string.py │ ├── singles_multiline_string.py │ ├── doubles_escaped.py │ ├── singles_escaped.py │ ├── docstring_doubles_class.py │ ├── docstring_singles_class.py │ ├── docstring_doubles_function.py │ ├── docstring_singles_function.py │ ├── docstring_not_docstrings.py │ ├── docstring_doubles.py │ └── docstring_singles.py ├── test_docstring_detection.py ├── test_checks.py └── test_docstring_checks.py ├── flake8_quotes ├── __about__.py ├── docstring_detection.py └── __init__.py ├── requirements-dev.txt ├── .gitignore ├── MANIFEST.in ├── setup.cfg ├── test.sh ├── .travis.yml ├── tox.ini ├── .github └── workflows │ └── run_quality_assurance.yml ├── release.sh ├── LICENSE ├── setup.py └── README.rst /test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /flake8_quotes/__about__.py: -------------------------------------------------------------------------------- 1 | __version__ = '3.4.0' 2 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | flake8<7.0,>=3.5 2 | setuptools 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg 2 | *.egg-info 3 | *.py[cod] 4 | .tox 5 | dist 6 | build 7 | -------------------------------------------------------------------------------- /test/data/doubles_noqa.py: -------------------------------------------------------------------------------- 1 | this_should_not_be_linted = "double quote string" # noqa 2 | -------------------------------------------------------------------------------- /test/data/singles_noqa.py: -------------------------------------------------------------------------------- 1 | this_should_not_be_linted = 'single quote string' # noqa 2 | -------------------------------------------------------------------------------- /test/data/no_qa.py: -------------------------------------------------------------------------------- 1 | some_code = 2 2 | this_should_not_be_checked = 1 # noqa 3 | some_code = 3 4 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | 3 | recursive-include flake8_quotes *.py 4 | recursive-include test *.py 5 | -------------------------------------------------------------------------------- /test/data/doubles_wrapped.py: -------------------------------------------------------------------------------- 1 | s = 'double "quotes" wrapped in singles are ignored' 2 | s = "single 'quotes' wrapped in doubles are ignored" 3 | -------------------------------------------------------------------------------- /test/data/singles_wrapped.py: -------------------------------------------------------------------------------- 1 | s = "single 'quotes' wrapped in doubles are ignored" 2 | s = 'double "quotes" wrapped in singles are ignored' 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | # Force jobs to 1 as a workaround to avoid the PicklingError in Flake8 3.x 4 | # see https://gitlab.com/pycqa/flake8/issues/164 5 | jobs=1 6 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Exit on first error and echo commands 3 | set -e 4 | set -x 5 | 6 | # Run our linter and tests 7 | flake8 *.py flake8_quotes/ test/*.py 8 | python setup.py test $* 9 | -------------------------------------------------------------------------------- /test/data/docstring_doubles_module_singleline.py: -------------------------------------------------------------------------------- 1 | """ Double quotes singleline module docstring """ 2 | """ this is not a docstring """ 3 | 4 | def foo(): 5 | pass 6 | """ this is not a docstring """ 7 | -------------------------------------------------------------------------------- /test/data/docstring_singles_module_singleline.py: -------------------------------------------------------------------------------- 1 | ''' Double quotes singleline module docstring ''' 2 | ''' this is not a docstring ''' 3 | 4 | def foo(): 5 | pass 6 | ''' this is not a docstring ''' 7 | -------------------------------------------------------------------------------- /test/data/doubles.py: -------------------------------------------------------------------------------- 1 | this_should_be_linted = "double quote string" 2 | this_should_be_linted = u"double quote string" 3 | this_should_be_linted = br"double quote string" # use b instead of u, as ur is invalid in Py3 4 | -------------------------------------------------------------------------------- /test/data/multiline_string.py: -------------------------------------------------------------------------------- 1 | s = """ abc 2 | def 3 | ghi """ 4 | 5 | s = """ abc 6 | def ''' 7 | ghi 8 | """ 9 | 10 | s = ''' abc 11 | def 12 | ghi ''' 13 | 14 | s = ''' abc 15 | def """ 16 | ghi 17 | ''' 18 | -------------------------------------------------------------------------------- /test/data/singles.py: -------------------------------------------------------------------------------- 1 | this_should_be_linted = 'single quote string' 2 | this_should_be_linted = u'double quote string' 3 | this_should_be_linted = br'double quote string' # use b instead of u, as ur is invalid in Py3 4 | -------------------------------------------------------------------------------- /test/data/docstring_doubles_module_multiline.py: -------------------------------------------------------------------------------- 1 | """ 2 | Double quotes multiline module docstring 3 | """ 4 | """ 5 | this is not a docstring 6 | """ 7 | def foo(): 8 | pass 9 | """ 10 | this is not a docstring 11 | """ 12 | -------------------------------------------------------------------------------- /test/data/docstring_singles_module_multiline.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Double quotes multiline module docstring 3 | ''' 4 | ''' 5 | this is not a docstring 6 | ''' 7 | def foo(): 8 | pass 9 | ''' 10 | this is not a docstring 11 | ''' 12 | -------------------------------------------------------------------------------- /test/data/doubles_multiline_string.py: -------------------------------------------------------------------------------- 1 | s = """ This "should" 2 | be 3 | "linted" """ 4 | 5 | s = ''' This "should" 6 | "not" be 7 | "linted" ''' 8 | 9 | s = """'This should not be linted due to having would-be quadruple end quote'""" 10 | -------------------------------------------------------------------------------- /test/data/singles_multiline_string.py: -------------------------------------------------------------------------------- 1 | s = ''' This 'should' 2 | be 3 | 'linted' ''' 4 | 5 | s = """ This 'should' 6 | 'not' be 7 | 'linted' """ 8 | 9 | s = '''"This should not be linted due to having would-be quadruple end quote"''' 10 | -------------------------------------------------------------------------------- /test/data/doubles_escaped.py: -------------------------------------------------------------------------------- 1 | this_should_raise_Q003 = 'This is a \'string\'' 2 | this_is_fine = '"This" is a \'string\'' 3 | this_is_fine = "This is a 'string'" 4 | this_is_fine = "\"This\" is a 'string'" 5 | this_is_fine = r'This is a \'string\'' 6 | this_is_fine = br'This is a \'string\'' 7 | -------------------------------------------------------------------------------- /test/data/singles_escaped.py: -------------------------------------------------------------------------------- 1 | this_should_raise_Q003 = "This is a \"string\"" 2 | this_is_fine = "'This' is a \"string\"" 3 | this_is_fine = 'This is a "string"' 4 | this_is_fine = '\'This\' is a "string"' 5 | this_is_fine = r"This is a \"string\"" 6 | this_is_fine = br"This is a \"string\"" 7 | -------------------------------------------------------------------------------- /test/data/docstring_doubles_class.py: -------------------------------------------------------------------------------- 1 | class SingleLineDocstrings(): 2 | """ Double quotes single line class docstring """ 3 | """ Not a docstring """ 4 | 5 | def foo(self, bar="""not a docstring"""): 6 | """ Double quotes single line method docstring""" 7 | pass 8 | 9 | class Nested(foo()[:]): """ inline docstring """; pass 10 | -------------------------------------------------------------------------------- /test/data/docstring_singles_class.py: -------------------------------------------------------------------------------- 1 | class SingleLineDocstrings(): 2 | ''' Double quotes single line class docstring ''' 3 | ''' Not a docstring ''' 4 | 5 | def foo(self, bar='''not a docstring'''): 6 | ''' Double quotes single line method docstring''' 7 | pass 8 | 9 | class Nested(foo()[:]): ''' inline docstring '''; pass 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | dist: xenial # is required for python3.7+ 3 | language: python 4 | python: 5 | - "3.8" 6 | - "3.9" 7 | - "3.10" 8 | - "3.11" 9 | - "3.12" 10 | 11 | install: 12 | # Install our dependencies 13 | - pip install -r requirements-dev.txt 14 | 15 | # Install `flake8-quotes` 16 | - python setup.py develop 17 | 18 | script: 19 | # Run our tests 20 | - ./test.sh 21 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # run like so: 2 | # 1. install pyenv 3 | # 2. pyenv install -s 3.7.17 3.8.18 3.9.18 3.10.13 3.11.6 3.12.0 4 | # 3. pyenv local 3.7.17 3.8.18 3.9.18 3.10.13 3.11.6 3.12.0 5 | # 4. pip install tox 6 | # 5. tox 7 | 8 | [tox] 9 | envlist = py37,py38,py39,py310,py311,py312 10 | isolated_build = True 11 | 12 | [testenv] 13 | extras = flake8,setuptools 14 | commands = ./test.sh 15 | allowlist_externals = ./test.sh 16 | -------------------------------------------------------------------------------- /test/data/docstring_doubles_function.py: -------------------------------------------------------------------------------- 1 | def foo(): 2 | """function without params, single line docstring""" 3 | """ not a docstring""" 4 | return 5 | 6 | 7 | def foo2(): 8 | """ 9 | function without params, multiline docstring 10 | """ 11 | """ not a docstring""" 12 | return 13 | 14 | 15 | def fun_with_params_no_docstring(a, b=""" 16 | not a 17 | """ """docstring"""): 18 | pass 19 | 20 | def fun_with_params_no_docstring2(a, b=c[foo():], c=\ 21 | """ not a docstring """): 22 | pass 23 | 24 | -------------------------------------------------------------------------------- /test/data/docstring_singles_function.py: -------------------------------------------------------------------------------- 1 | def foo(): 2 | '''function without params, single line docstring''' 3 | ''' not a docstring''' 4 | return 5 | 6 | 7 | def foo2(): 8 | ''' 9 | function without params, multiline docstring 10 | ''' 11 | ''' not a docstring''' 12 | return 13 | 14 | 15 | def fun_with_params_no_docstring(a, b=''' 16 | not a 17 | ''' '''docstring'''): 18 | pass 19 | 20 | def fun_with_params_no_docstring2(a, b=c[foo():], c=\ 21 | ''' not a docstring '''): 22 | pass 23 | 24 | -------------------------------------------------------------------------------- /.github/workflows/run_quality_assurance.yml: -------------------------------------------------------------------------------- 1 | name: Run Quality Assurance 2 | 3 | on: push 4 | 5 | jobs: 6 | run-quality-assurance: 7 | name: Run Quality Assurance 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 12 | steps: 13 | - name: Set up Python 14 | uses: actions/setup-python@v2 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - name: Checkout Code 18 | uses: actions/checkout@v2 19 | - name: Installation 20 | run: | 21 | python -m pip install --upgrade pip 22 | python -m pip install -r requirements-dev.txt 23 | - name: Test Script 24 | run: ./test.sh 25 | 26 | -------------------------------------------------------------------------------- /test/data/docstring_not_docstrings.py: -------------------------------------------------------------------------------- 1 | 2 | var0 = True 3 | l = [] 4 | 5 | if var0: 6 | """ not a docstring""" 7 | pass 8 | 9 | while(var0 < 0 or "def" in l[:] ): 10 | """ also not a docstring """ 11 | with open(l["def":]) as f: 12 | """ not a docstring """ 13 | pass 14 | 15 | if var0 < 10: 16 | """ 17 | not a multiline docstring 18 | """ 19 | pass 20 | 21 | 22 | if var0: 23 | ''' not a docstring''' 24 | pass 25 | 26 | while(var0 < 0 or "def" in l[:] ): 27 | ''' also not a docstring ''' 28 | with open(l["def":]) as f: 29 | ''' not a docstring ''' 30 | pass 31 | 32 | if var0 < 10: 33 | ''' 34 | not a multiline docstring 35 | ''' 36 | pass 37 | 38 | # https://github.com/zheller/flake8-quotes/issues/97 39 | def test(): 40 | {}["a"] 41 | 42 | 43 | class test: 44 | {}["a"] 45 | -------------------------------------------------------------------------------- /test/data/docstring_doubles.py: -------------------------------------------------------------------------------- 1 | """ 2 | Double quotes multiline module docstring 3 | """ 4 | 5 | """ 6 | this is not a docstring 7 | """ 8 | 9 | l = [] 10 | 11 | class Cls: 12 | """ 13 | Double quotes multiline class docstring 14 | """ 15 | 16 | """ 17 | this is not a docstring 18 | """ 19 | 20 | # The colon in the list indexing below is an edge case for the docstring scanner 21 | def f(self, bar=""" 22 | definitely not a docstring""", 23 | val=l[Cls():3]): 24 | """ 25 | Double quotes multiline function docstring 26 | """ 27 | 28 | some_expression = 'hello world' 29 | 30 | """ 31 | this is not a docstring 32 | """ 33 | 34 | if l: 35 | """ 36 | Looks like a docstring, but in reality it isn't - only modules, classes and functions 37 | """ 38 | pass 39 | -------------------------------------------------------------------------------- /test/data/docstring_singles.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Single quotes multiline module docstring 3 | ''' 4 | 5 | ''' 6 | this is not a docstring 7 | ''' 8 | 9 | l = [] 10 | 11 | class Cls(MakeKlass(''' 12 | class params \t not a docstring 13 | ''')): 14 | ''' 15 | Single quotes multiline class docstring 16 | ''' 17 | 18 | ''' 19 | this is not a docstring 20 | ''' 21 | 22 | # The colon in the list indexing below is an edge case for the docstring scanner 23 | def f(self, bar=''' 24 | definitely not a docstring''', 25 | val=l[Cls():3]): 26 | ''' 27 | Single quotes multiline function docstring 28 | ''' 29 | 30 | some_expression = 'hello world' 31 | 32 | ''' 33 | this is not a docstring 34 | ''' 35 | 36 | if l: 37 | ''' 38 | Looks like a docstring, but in reality it isn't - only modules, classes and functions 39 | ''' 40 | pass 41 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Exit on first error 3 | set -e 4 | 5 | # Parse our CLI arguments 6 | version="$1" 7 | if test "$version" = ""; then 8 | echo "Expected a version to be provided to \`release.sh\` but none was provided." 1>&2 9 | echo "Usage: $0 [version] # (e.g. $0 1.0.0)" 1>&2 10 | exit 1 11 | fi 12 | 13 | # Bump the version via regexp 14 | sed -E "s/^(__version__ = ')[0-9]+\.[0-9]+\.[0-9]+(')$/\1$version\2/" flake8_quotes/__about__.py --in-place 15 | 16 | # Verify our version made it into the file 17 | if ! grep "$version" flake8_quotes/__about__.py &> /dev/null; then 18 | echo "Expected \`__version__\` to update via \`sed\` but it didn't" 1>&2 19 | exit 1 20 | fi 21 | 22 | # Commit the change 23 | git add flake8_quotes/__about__.py 24 | git commit -a -m "Release $version" 25 | 26 | # Tag the release 27 | git tag "$version" 28 | 29 | # Publish the release to GitHub 30 | git push 31 | git push --tags 32 | 33 | # Publish the release to PyPI 34 | python setup.py sdist --formats=gztar 35 | twine upload "dist/flake8-quotes-$version.tar.gz" 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a copy 2 | of this software and associated documentation files (the "Software"), to deal 3 | in the Software without restriction, including without limitation the rights 4 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 5 | copies of the Software, and to permit persons to whom the Software is 6 | furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in 9 | all copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 17 | THE SOFTWARE. 18 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | from setuptools import setup 4 | 5 | __dir__ = os.path.dirname(__file__) 6 | 7 | 8 | def read(*filenames, **kwargs): 9 | encoding = kwargs.get('encoding', 'utf-8') 10 | sep = kwargs.get('sep', '\n') 11 | buf = [] 12 | for filename in filenames: 13 | with io.open(filename, encoding=encoding) as f: 14 | buf.append(f.read()) 15 | return sep.join(buf) 16 | 17 | 18 | LONG_DESCRIPTION = read(os.path.join(__dir__, 'README.rst')) 19 | 20 | about = {} 21 | with open(os.path.join(__dir__, 'flake8_quotes', '__about__.py')) as file: 22 | exec(file.read(), about) 23 | 24 | 25 | setup( 26 | name='flake8-quotes', 27 | author='Zachary Wright Heller', 28 | author_email='zheller@gmail.com', 29 | version=about['__version__'], 30 | install_requires=[ 31 | 'flake8', 32 | 'setuptools', 33 | ], 34 | url='http://github.com/zheller/flake8-quotes/', 35 | long_description=LONG_DESCRIPTION, 36 | description='Flake8 lint for quotes.', 37 | packages=['flake8_quotes'], 38 | test_suite='test', 39 | include_package_data=True, 40 | entry_points={ 41 | 'flake8.extension': [ 42 | 'Q0 = flake8_quotes:QuoteChecker', 43 | ], 44 | }, 45 | license='MIT', 46 | zip_safe=True, 47 | keywords='flake8 lint quotes', 48 | classifiers=[ 49 | 'Development Status :: 5 - Production/Stable', 50 | 'Environment :: Console', 51 | 'Framework :: Flake8', 52 | 'Intended Audience :: Developers', 53 | 'Operating System :: OS Independent', 54 | 'License :: OSI Approved :: MIT License', 55 | 'Programming Language :: Python', 56 | 'Programming Language :: Python :: 3', 57 | 'Programming Language :: Python :: 3.8', 58 | 'Programming Language :: Python :: 3.9', 59 | 'Programming Language :: Python :: 3.10', 60 | 'Programming Language :: Python :: 3.11', 61 | 'Programming Language :: Python :: 3.12', 62 | 'Topic :: Software Development :: Libraries :: Python Modules', 63 | 'Topic :: Software Development :: Quality Assurance', 64 | ] 65 | ) 66 | -------------------------------------------------------------------------------- /test/test_docstring_detection.py: -------------------------------------------------------------------------------- 1 | import tokenize 2 | from unittest import TestCase 3 | 4 | from flake8_quotes import Token, get_docstring_tokens 5 | from test.test_checks import get_absolute_path 6 | 7 | 8 | class GetDocstringTokensTests(TestCase): 9 | def _get_docstring_tokens(self, filename): 10 | with open(get_absolute_path(filename), 'r') as f: 11 | tokens = [Token(t) for t in tokenize.generate_tokens(f.readline)] 12 | return get_docstring_tokens(tokens) 13 | 14 | def test_get_docstring_tokens_absent(self): 15 | self.assertEqual(self._get_docstring_tokens('data/doubles.py'), set()) 16 | self.assertEqual(self._get_docstring_tokens('data/doubles_multiline_string.py'), set()) 17 | self.assertEqual(self._get_docstring_tokens('data/doubles_noqa.py'), set()) 18 | self.assertEqual(self._get_docstring_tokens('data/doubles_wrapped.py'), set()) 19 | self.assertEqual(self._get_docstring_tokens('data/multiline_string.py'), set()) 20 | self.assertEqual(self._get_docstring_tokens('data/no_qa.py'), set()) 21 | self.assertEqual(self._get_docstring_tokens('data/singles.py'), set()) 22 | self.assertEqual(self._get_docstring_tokens('data/singles_multiline_string.py'), set()) 23 | self.assertEqual(self._get_docstring_tokens('data/singles_noqa.py'), set()) 24 | self.assertEqual(self._get_docstring_tokens('data/singles_wrapped.py'), set()) 25 | self.assertEqual(self._get_docstring_tokens('data/docstring_not_docstrings.py'), set()) 26 | 27 | def test_get_docstring_tokens_doubles(self): 28 | with open(get_absolute_path('data/docstring_doubles.py'), 'r') as f: 29 | tokens = [Token(t) for t in tokenize.generate_tokens(f.readline)] 30 | docstring_tokens = {t.string for t in get_docstring_tokens(tokens)} 31 | self.assertEqual(docstring_tokens, { 32 | '"""\nDouble quotes multiline module docstring\n"""', 33 | '"""\n Double quotes multiline class docstring\n """', 34 | '"""\n Double quotes multiline function docstring\n """', 35 | }) 36 | 37 | def test_get_docstring_tokens_singles(self): 38 | with open(get_absolute_path('data/docstring_singles.py'), 'r') as f: 39 | tokens = [Token(t) for t in tokenize.generate_tokens(f.readline)] 40 | docstring_tokens = {t.string for t in get_docstring_tokens(tokens)} 41 | self.assertEqual(docstring_tokens, { 42 | "'''\nSingle quotes multiline module docstring\n'''", 43 | "'''\n Single quotes multiline class docstring\n '''", 44 | "'''\n Single quotes multiline function docstring\n '''", 45 | }) 46 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Flake8 Extension to lint for quotes. 2 | =========================================== 3 | 4 | .. image:: https://travis-ci.org/zheller/flake8-quotes.svg?branch=master 5 | :target: https://travis-ci.org/zheller/flake8-quotes 6 | :alt: Build Status 7 | 8 | Major update in 2.0.0 9 | --------------------- 10 | We automatically encourage avoiding escaping quotes as per `PEP 8 `_. To disable this, use ``--no-avoid-escape`` (can be used in configuration file via ``avoid-escape``). 11 | 12 | Deprecation notice in 0.3.0 13 | --------------------------- 14 | To anticipate multiline support, we are renaming ``--quotes`` to ``--inline-quotes``. Please adjust your configurations appropriately. 15 | 16 | Usage 17 | ----- 18 | 19 | If you are using flake8 it's as easy as: 20 | 21 | .. code:: shell 22 | 23 | pip install flake8-quotes 24 | 25 | Now you don't need to worry about people like @sectioneight constantly 26 | complaining that you are using double-quotes and not single-quotes. 27 | 28 | Warnings 29 | -------- 30 | 31 | This package adds flake8 warnings with the prefix ``Q0``. You might want to 32 | enable this warning inside your flake8 configuration file. Typically that 33 | will be ``.flake8`` inside the root folder of your project. 34 | 35 | .. code:: ini 36 | 37 | select = Q0 38 | 39 | The current set of warnings is: 40 | 41 | ==== ========================================================================= 42 | Code Description 43 | ---- ------------------------------------------------------------------------- 44 | Q000 Remove bad quotes 45 | Q001 Remove bad quotes from multiline string 46 | Q002 Remove bad quotes from docstring 47 | Q003 Change outer quotes to avoid escaping inner quotes 48 | ==== ========================================================================= 49 | 50 | Configuration 51 | ------------- 52 | 53 | By default, we expect single quotes (') and look for unwanted double quotes ("). To expect double quotes (") and find unwanted single quotes ('), use the CLI option: 54 | 55 | .. code:: shell 56 | 57 | flake8 --inline-quotes '"' 58 | # We also support "double" and "single" 59 | # flake8 --inline-quotes 'double' 60 | # 61 | # We also support configuration for multiline quotes 62 | # flake8 --inline-quotes '"' --multiline-quotes "'" 63 | # We also support "'''" 64 | # flake8 --inline-quotes '"' --multiline-quotes "'''" 65 | # 66 | # We also support docstring quotes similarly 67 | # flake8 --inline-quotes '"' --docstring-quotes "'" 68 | # flake8 --inline-quotes '"' --docstring-quotes "'''" 69 | 70 | # We also support disabling escaping quotes 71 | # flake8 --no-avoid-escape 72 | 73 | or configuration option in `tox.ini`/`setup.cfg`. 74 | 75 | .. code:: ini 76 | 77 | [flake8] 78 | inline-quotes = " 79 | # We also support "double" and "single" 80 | # inline-quotes = double 81 | # 82 | # We also support configuration for multiline quotes 83 | # multiline-quotes = ' 84 | # We also support "'''" 85 | # multiline-quotes = ''' 86 | # 87 | # We also support docstring quotes similarly 88 | # docstring-quotes = ' 89 | # docstring-quotes = ''' 90 | # 91 | # We also support disabling escaping quotes 92 | # avoid-escape = False 93 | 94 | Caveats 95 | ------- 96 | 97 | We follow the `PEP8 conventions `_ to avoid backslashes in the string. So, no matter what configuration you are using (single or double quotes) these are always valid strings 98 | 99 | .. code:: python 100 | 101 | s = 'double "quotes" wrapped in singles are ignored' 102 | s = "single 'quotes' wrapped in doubles are ignored" 103 | -------------------------------------------------------------------------------- /flake8_quotes/docstring_detection.py: -------------------------------------------------------------------------------- 1 | import tokenize 2 | 3 | # I don't think this is a minimized state machine, but it's clearer this 4 | # way. Namely, the class vs. function states can be merged 5 | 6 | # In the start of the module when we're expecting possibly a string that gets marked as a docstring 7 | STATE_EXPECT_MODULE_DOCSTRING = 0 8 | # After seeing the class keyword, we're waiting for the block colon (and do bracket counting) 9 | STATE_EXPECT_CLASS_COLON = 1 10 | # After seeing the colon in a class definition we're expecting possibly a docstring 11 | STATE_EXPECT_CLASS_DOCSTRING = 2 12 | # Same as EXPECT_CLASS_COLON, but for function definitions 13 | STATE_EXPECT_FUNCTION_COLON = 3 14 | # Same as EXPECT_CLASS_DOCSTRING, but for function definitions 15 | STATE_EXPECT_FUNCTION_DOCSTRING = 4 16 | # Just skipping tokens until we observe a class or a def. 17 | STATE_OTHER = 5 18 | 19 | # These tokens don't matter here - they don't get in the way of docstrings 20 | TOKENS_TO_IGNORE = [ 21 | tokenize.NEWLINE, 22 | tokenize.INDENT, 23 | tokenize.DEDENT, 24 | tokenize.NL, 25 | tokenize.COMMENT, 26 | ] 27 | 28 | 29 | def get_docstring_tokens(tokens): 30 | state = STATE_EXPECT_MODULE_DOCSTRING 31 | # The number of currently open parentheses, square brackets, etc. 32 | # This doesn't check if they're properly balanced, i.e. there isn't ([)], but we shouldn't 33 | # need to - if they aren't, it shouldn't parse at all, so we ignore the bracket type 34 | bracket_count = 0 35 | docstring_tokens = set() 36 | 37 | for token in tokens: 38 | if token.type in TOKENS_TO_IGNORE: 39 | continue 40 | if token.type == tokenize.STRING: 41 | if state in [STATE_EXPECT_MODULE_DOCSTRING, STATE_EXPECT_CLASS_DOCSTRING, 42 | STATE_EXPECT_FUNCTION_DOCSTRING]: 43 | docstring_tokens.add(token) 44 | state = STATE_OTHER 45 | # A class means we'll expect the class token 46 | elif token.type == tokenize.NAME and token.string == 'class': 47 | state = STATE_EXPECT_CLASS_COLON 48 | # Just in case - they should be balanced normally 49 | bracket_count = 0 50 | # A def means we'll expect a colon after that 51 | elif token.type == tokenize.NAME and token.string == 'def': 52 | state = STATE_EXPECT_FUNCTION_COLON 53 | # Just in case - they should be balanced normally 54 | bracket_count = 0 55 | # If we get a colon and we're expecting it, move to the next state 56 | elif token.type == tokenize.OP and token.string == ':': 57 | # If there are still left brackets open, it must be something other than the block start 58 | if bracket_count == 0: 59 | if state == STATE_EXPECT_CLASS_COLON: 60 | state = STATE_EXPECT_CLASS_DOCSTRING 61 | elif state == STATE_EXPECT_FUNCTION_COLON: 62 | state = STATE_EXPECT_FUNCTION_DOCSTRING 63 | # Count opening and closing brackets in bracket_count 64 | elif token.type == tokenize.OP and token.string in ['(', '[', '{']: 65 | bracket_count += 1 66 | if state in [STATE_EXPECT_MODULE_DOCSTRING, STATE_EXPECT_CLASS_DOCSTRING, 67 | STATE_EXPECT_FUNCTION_DOCSTRING]: 68 | state = STATE_OTHER 69 | elif token.type == tokenize.OP and token.string in [')', ']', '}']: 70 | bracket_count -= 1 71 | if state in [STATE_EXPECT_MODULE_DOCSTRING, STATE_EXPECT_CLASS_DOCSTRING, 72 | STATE_EXPECT_FUNCTION_DOCSTRING]: 73 | state = STATE_OTHER 74 | # The token is not one of the recognized types. If we're expecting a colon, then all good, 75 | # but if we're expecting a docstring, it would no longer be a docstring 76 | elif state in [STATE_EXPECT_MODULE_DOCSTRING, STATE_EXPECT_CLASS_DOCSTRING, 77 | STATE_EXPECT_FUNCTION_DOCSTRING]: 78 | state = STATE_OTHER 79 | 80 | return docstring_tokens 81 | -------------------------------------------------------------------------------- /test/test_checks.py: -------------------------------------------------------------------------------- 1 | from flake8_quotes import QuoteChecker 2 | import os 3 | import subprocess 4 | from unittest import TestCase 5 | 6 | 7 | class TestChecks(TestCase): 8 | def test_get_noqa_lines(self): 9 | checker = QuoteChecker(None, filename=get_absolute_path('data/no_qa.py')) 10 | self.assertEqual(checker.get_noqa_lines(checker.get_file_contents()), [2]) 11 | 12 | 13 | class TestFlake8Stdin(TestCase): 14 | def test_stdin(self): 15 | """Test using stdin.""" 16 | filepath = get_absolute_path('data/doubles.py') 17 | with open(filepath, 'rb') as f: 18 | p = subprocess.Popen(['flake8', '--select=Q', '-'], stdin=f, 19 | stdout=subprocess.PIPE, stderr=subprocess.PIPE) 20 | stdout, stderr = p.communicate() 21 | 22 | stdout_lines = stdout.splitlines() 23 | self.assertEqual(stderr, b'') 24 | self.assertEqual(len(stdout_lines), 3) 25 | self.assertRegex( 26 | stdout_lines[0], 27 | b'stdin:1:(24|25): Q000 Double quotes found but single quotes preferred') 28 | self.assertRegex( 29 | stdout_lines[1], 30 | b'stdin:2:(24|25): Q000 Double quotes found but single quotes preferred') 31 | self.assertRegex( 32 | stdout_lines[2], 33 | b'stdin:3:(24|25): Q000 Double quotes found but single quotes preferred') 34 | 35 | 36 | class DoublesTestChecks(TestCase): 37 | def setUp(self): 38 | class DoublesOptions(): 39 | inline_quotes = "'" 40 | multiline_quotes = "'" 41 | QuoteChecker.parse_options(DoublesOptions) 42 | 43 | def test_multiline_string(self): 44 | doubles_checker = QuoteChecker(None, filename=get_absolute_path('data/doubles_multiline_string.py')) 45 | self.assertEqual(list(doubles_checker.get_quotes_errors(doubles_checker.get_file_contents())), [ 46 | {'col': 4, 'line': 1, 'message': 'Q001 Double quote multiline found but single quotes preferred'}, 47 | ]) 48 | 49 | def test_multiline_string_using_lines(self): 50 | with open(get_absolute_path('data/doubles_multiline_string.py')) as f: 51 | lines = f.readlines() 52 | doubles_checker = QuoteChecker(None, lines=lines) 53 | self.assertEqual(list(doubles_checker.get_quotes_errors(doubles_checker.get_file_contents())), [ 54 | {'col': 4, 'line': 1, 'message': 'Q001 Double quote multiline found but single quotes preferred'}, 55 | ]) 56 | 57 | def test_wrapped(self): 58 | doubles_checker = QuoteChecker(None, filename=get_absolute_path('data/doubles_wrapped.py')) 59 | self.assertEqual(list(doubles_checker.get_quotes_errors(doubles_checker.get_file_contents())), []) 60 | 61 | def test_doubles(self): 62 | doubles_checker = QuoteChecker(None, filename=get_absolute_path('data/doubles.py')) 63 | self.assertEqual(list(doubles_checker.get_quotes_errors(doubles_checker.get_file_contents())), [ 64 | {'col': 24, 'line': 1, 'message': 'Q000 Double quotes found but single quotes preferred'}, 65 | {'col': 24, 'line': 2, 'message': 'Q000 Double quotes found but single quotes preferred'}, 66 | {'col': 24, 'line': 3, 'message': 'Q000 Double quotes found but single quotes preferred'}, 67 | ]) 68 | 69 | def test_noqa_doubles(self): 70 | checker = QuoteChecker(None, get_absolute_path('data/doubles_noqa.py')) 71 | self.assertEqual(list(checker.run()), []) 72 | 73 | def test_escapes(self): 74 | doubles_checker = QuoteChecker(None, filename=get_absolute_path('data/doubles_escaped.py')) 75 | self.assertEqual(list(doubles_checker.get_quotes_errors(doubles_checker.get_file_contents())), [ 76 | {'col': 25, 'line': 1, 'message': 'Q003 Change outer quotes to avoid escaping inner quotes'}, 77 | ]) 78 | 79 | def test_escapes_allowed(self): 80 | class Options(): 81 | inline_quotes = "'" 82 | avoid_escape = False 83 | QuoteChecker.parse_options(Options) 84 | 85 | doubles_checker = QuoteChecker(None, filename=get_absolute_path('data/doubles_escaped.py')) 86 | self.assertEqual(list(doubles_checker.get_quotes_errors(doubles_checker.get_file_contents())), []) 87 | 88 | 89 | class DoublesAliasTestChecks(TestCase): 90 | def setUp(self): 91 | class DoublesAliasOptions(): 92 | inline_quotes = 'single' 93 | multiline_quotes = 'single' 94 | QuoteChecker.parse_options(DoublesAliasOptions) 95 | 96 | def test_doubles(self): 97 | doubles_checker = QuoteChecker(None, filename=get_absolute_path('data/doubles_wrapped.py')) 98 | self.assertEqual(list(doubles_checker.get_quotes_errors(doubles_checker.get_file_contents())), []) 99 | 100 | doubles_checker = QuoteChecker(None, filename=get_absolute_path('data/doubles.py')) 101 | self.assertEqual(list(doubles_checker.get_quotes_errors(doubles_checker.get_file_contents())), [ 102 | {'col': 24, 'line': 1, 'message': 'Q000 Double quotes found but single quotes preferred'}, 103 | {'col': 24, 'line': 2, 'message': 'Q000 Double quotes found but single quotes preferred'}, 104 | {'col': 24, 'line': 3, 'message': 'Q000 Double quotes found but single quotes preferred'}, 105 | ]) 106 | 107 | 108 | class SinglesTestChecks(TestCase): 109 | def setUp(self): 110 | class SinglesOptions(): 111 | inline_quotes = '"' 112 | multiline_quotes = '"' 113 | QuoteChecker.parse_options(SinglesOptions) 114 | 115 | def test_multiline_string(self): 116 | singles_checker = QuoteChecker(None, filename=get_absolute_path('data/singles_multiline_string.py')) 117 | self.assertEqual(list(singles_checker.get_quotes_errors(singles_checker.get_file_contents())), [ 118 | {'col': 4, 'line': 1, 'message': 'Q001 Single quote multiline found but double quotes preferred'}, 119 | ]) 120 | 121 | def test_wrapped(self): 122 | singles_checker = QuoteChecker(None, filename=get_absolute_path('data/singles_wrapped.py')) 123 | self.assertEqual(list(singles_checker.get_quotes_errors(singles_checker.get_file_contents())), []) 124 | 125 | def test_singles(self): 126 | singles_checker = QuoteChecker(None, filename=get_absolute_path('data/singles.py')) 127 | self.assertEqual(list(singles_checker.get_quotes_errors(singles_checker.get_file_contents())), [ 128 | {'col': 24, 'line': 1, 'message': 'Q000 Single quotes found but double quotes preferred'}, 129 | {'col': 24, 'line': 2, 'message': 'Q000 Single quotes found but double quotes preferred'}, 130 | {'col': 24, 'line': 3, 'message': 'Q000 Single quotes found but double quotes preferred'}, 131 | ]) 132 | 133 | def test_noqa_singles(self): 134 | checker = QuoteChecker(None, get_absolute_path('data/singles_noqa.py')) 135 | self.assertEqual(list(checker.run()), []) 136 | 137 | def test_escapes(self): 138 | singles_checker = QuoteChecker(None, filename=get_absolute_path('data/singles_escaped.py')) 139 | self.assertEqual(list(singles_checker.get_quotes_errors(singles_checker.get_file_contents())), [ 140 | {'col': 25, 'line': 1, 'message': 'Q003 Change outer quotes to avoid escaping inner quotes'}, 141 | ]) 142 | 143 | def test_escapes_allowed(self): 144 | class Options(): 145 | inline_quotes = '"' 146 | avoid_escape = False 147 | QuoteChecker.parse_options(Options) 148 | 149 | singles_checker = QuoteChecker(None, filename=get_absolute_path('data/singles_escaped.py')) 150 | self.assertEqual(list(singles_checker.get_quotes_errors(singles_checker.get_file_contents())), []) 151 | 152 | 153 | class SinglesAliasTestChecks(TestCase): 154 | def setUp(self): 155 | class SinglesAliasOptions(): 156 | inline_quotes = 'double' 157 | multiline_quotes = 'double' 158 | QuoteChecker.parse_options(SinglesAliasOptions) 159 | 160 | def test_singles(self): 161 | singles_checker = QuoteChecker(None, filename=get_absolute_path('data/singles_wrapped.py')) 162 | self.assertEqual(list(singles_checker.get_quotes_errors(singles_checker.get_file_contents())), []) 163 | 164 | singles_checker = QuoteChecker(None, filename=get_absolute_path('data/singles.py')) 165 | self.assertEqual(list(singles_checker.get_quotes_errors(singles_checker.get_file_contents())), [ 166 | {'col': 24, 'line': 1, 'message': 'Q000 Single quotes found but double quotes preferred'}, 167 | {'col': 24, 'line': 2, 'message': 'Q000 Single quotes found but double quotes preferred'}, 168 | {'col': 24, 'line': 3, 'message': 'Q000 Single quotes found but double quotes preferred'}, 169 | ]) 170 | 171 | 172 | class MultilineTestChecks(TestCase): 173 | def test_singles(self): 174 | class Options(): 175 | inline_quotes = "'" 176 | multiline_quotes = '"' 177 | QuoteChecker.parse_options(Options) 178 | 179 | multiline_checker = QuoteChecker(None, filename=get_absolute_path('data/multiline_string.py')) 180 | self.assertEqual(list(multiline_checker.get_quotes_errors(multiline_checker.get_file_contents())), [ 181 | {'col': 4, 'line': 10, 'message': 'Q001 Single quote multiline found but double quotes preferred'}, 182 | ]) 183 | 184 | def test_singles_alias(self): 185 | class Options(): 186 | inline_quotes = 'single' 187 | multiline_quotes = 'double' 188 | QuoteChecker.parse_options(Options) 189 | 190 | multiline_checker = QuoteChecker(None, filename=get_absolute_path('data/multiline_string.py')) 191 | self.assertEqual(list(multiline_checker.get_quotes_errors(multiline_checker.get_file_contents())), [ 192 | {'col': 4, 'line': 10, 'message': 'Q001 Single quote multiline found but double quotes preferred'}, 193 | ]) 194 | 195 | def test_doubles(self): 196 | class Options(): 197 | inline_quotes = '"' 198 | multiline_quotes = "'" 199 | QuoteChecker.parse_options(Options) 200 | 201 | multiline_checker = QuoteChecker(None, filename=get_absolute_path('data/multiline_string.py')) 202 | self.assertEqual(list(multiline_checker.get_quotes_errors(multiline_checker.get_file_contents())), [ 203 | {'col': 4, 'line': 1, 'message': 'Q001 Double quote multiline found but single quotes preferred'}, 204 | ]) 205 | 206 | def test_doubles_alias(self): 207 | class Options(): 208 | inline_quotes = 'double' 209 | multiline_quotes = 'single' 210 | QuoteChecker.parse_options(Options) 211 | 212 | multiline_checker = QuoteChecker(None, filename=get_absolute_path('data/multiline_string.py')) 213 | self.assertEqual(list(multiline_checker.get_quotes_errors(multiline_checker.get_file_contents())), [ 214 | {'col': 4, 'line': 1, 'message': 'Q001 Double quote multiline found but single quotes preferred'}, 215 | ]) 216 | 217 | 218 | def get_absolute_path(filepath): 219 | return os.path.join(os.path.dirname(__file__), filepath) 220 | -------------------------------------------------------------------------------- /test/test_docstring_checks.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from flake8_quotes import QuoteChecker 4 | from test.test_checks import get_absolute_path 5 | 6 | 7 | class DocstringTestChecks(TestCase): 8 | def test_require_double_docstring_double_present(self): 9 | class Options(): 10 | inline_quotes = 'single' 11 | multiline_quotes = 'single' 12 | docstring_quotes = 'double' 13 | QuoteChecker.parse_options(Options) 14 | 15 | multiline_checker = QuoteChecker( 16 | None, 17 | filename=get_absolute_path('data/docstring_doubles.py') 18 | ) 19 | self.assertEqual(list(multiline_checker.get_quotes_errors(multiline_checker.get_file_contents())), [ 20 | {'col': 0, 'line': 5, 'message': 'Q001 Double quote multiline found but single quotes preferred'}, 21 | {'col': 4, 'line': 16, 'message': 'Q001 Double quote multiline found but single quotes preferred'}, 22 | {'col': 20, 'line': 21, 'message': 'Q001 Double quote multiline found but single quotes preferred'}, 23 | {'col': 8, 'line': 30, 'message': 'Q001 Double quote multiline found but single quotes preferred'}, 24 | {'col': 12, 'line': 35, 'message': 'Q001 Double quote multiline found but single quotes preferred'}, 25 | ]) 26 | 27 | multiline_checker = QuoteChecker( 28 | None, 29 | filename=get_absolute_path('data/docstring_doubles_module_multiline.py') 30 | ) 31 | self.assertEqual(list(multiline_checker.get_quotes_errors(multiline_checker.get_file_contents())), [ 32 | {'col': 0, 'line': 4, 'message': 'Q001 Double quote multiline found but single quotes preferred'}, 33 | {'col': 0, 'line': 9, 'message': 'Q001 Double quote multiline found but single quotes preferred'}, 34 | ]) 35 | 36 | multiline_checker = QuoteChecker( 37 | None, 38 | filename=get_absolute_path('data/docstring_doubles_module_singleline.py') 39 | ) 40 | self.assertEqual(list(multiline_checker.get_quotes_errors(multiline_checker.get_file_contents())), [ 41 | {'col': 0, 'line': 2, 'message': 'Q001 Double quote multiline found but single quotes preferred'}, 42 | {'col': 0, 'line': 6, 'message': 'Q001 Double quote multiline found but single quotes preferred'}, 43 | ]) 44 | 45 | multiline_checker = QuoteChecker( 46 | None, 47 | filename=get_absolute_path('data/docstring_doubles_class.py') 48 | ) 49 | self.assertEqual(list(multiline_checker.get_quotes_errors(multiline_checker.get_file_contents())), [ 50 | {'col': 4, 'line': 3, 'message': 'Q001 Double quote multiline found but single quotes preferred'}, 51 | {'col': 22, 'line': 5, 'message': 'Q001 Double quote multiline found but single quotes preferred'}, 52 | ]) 53 | 54 | multiline_checker = QuoteChecker( 55 | None, 56 | filename=get_absolute_path('data/docstring_doubles_function.py') 57 | ) 58 | self.assertEqual(list(multiline_checker.get_quotes_errors(multiline_checker.get_file_contents())), [ 59 | {'col': 4, 'line': 3, 'message': 'Q001 Double quote multiline found but single quotes preferred'}, 60 | {'col': 4, 'line': 11, 'message': 'Q001 Double quote multiline found but single quotes preferred'}, 61 | {'col': 38, 'line': 15, 'message': 'Q001 Double quote multiline found but single quotes preferred'}, 62 | {'col': 4, 'line': 17, 'message': 'Q001 Double quote multiline found but single quotes preferred'}, 63 | {'col': 4, 'line': 21, 'message': 'Q001 Double quote multiline found but single quotes preferred'}, 64 | ]) 65 | 66 | def test_require_single_docstring_double_present(self): 67 | class Options(): 68 | inline_quotes = 'single' 69 | multiline_quotes = 'double' 70 | docstring_quotes = 'single' 71 | QuoteChecker.parse_options(Options) 72 | 73 | multiline_checker = QuoteChecker( 74 | None, 75 | filename=get_absolute_path('data/docstring_doubles.py') 76 | ) 77 | self.assertEqual(list(multiline_checker.get_quotes_errors(multiline_checker.get_file_contents())), [ 78 | {'col': 0, 'line': 1, 'message': 'Q002 Double quote docstring found but single quotes preferred'}, 79 | {'col': 4, 'line': 12, 'message': 'Q002 Double quote docstring found but single quotes preferred'}, 80 | {'col': 8, 'line': 24, 'message': 'Q002 Double quote docstring found but single quotes preferred'}, 81 | ]) 82 | 83 | multiline_checker = QuoteChecker( 84 | None, 85 | filename=get_absolute_path('data/docstring_doubles_module_multiline.py') 86 | ) 87 | self.assertEqual(list(multiline_checker.get_quotes_errors(multiline_checker.get_file_contents())), [ 88 | {'col': 0, 'line': 1, 'message': 'Q002 Double quote docstring found but single quotes preferred'}, 89 | ]) 90 | 91 | multiline_checker = QuoteChecker( 92 | None, 93 | filename=get_absolute_path('data/docstring_doubles_module_singleline.py') 94 | ) 95 | self.assertEqual(list(multiline_checker.get_quotes_errors(multiline_checker.get_file_contents())), [ 96 | {'col': 0, 'line': 1, 'message': 'Q002 Double quote docstring found but single quotes preferred'}, 97 | ]) 98 | 99 | multiline_checker = QuoteChecker( 100 | None, 101 | filename=get_absolute_path('data/docstring_doubles_class.py') 102 | ) 103 | self.assertEqual(list(multiline_checker.get_quotes_errors(multiline_checker.get_file_contents())), [ 104 | {'col': 4, 'line': 2, 'message': 'Q002 Double quote docstring found but single quotes preferred'}, 105 | {'col': 8, 'line': 6, 'message': 'Q002 Double quote docstring found but single quotes preferred'}, 106 | {'col': 28, 'line': 9, 'message': 'Q002 Double quote docstring found but single quotes preferred'}, 107 | ]) 108 | 109 | multiline_checker = QuoteChecker( 110 | None, 111 | filename=get_absolute_path('data/docstring_doubles_function.py') 112 | ) 113 | self.assertEqual(list(multiline_checker.get_quotes_errors(multiline_checker.get_file_contents())), [ 114 | {'col': 4, 'line': 2, 'message': 'Q002 Double quote docstring found but single quotes preferred'}, 115 | {'col': 4, 'line': 8, 'message': 'Q002 Double quote docstring found but single quotes preferred'}, 116 | ]) 117 | 118 | def test_require_double_docstring_single_present(self): 119 | class Options(): 120 | inline_quotes = 'single' 121 | multiline_quotes = 'single' 122 | docstring_quotes = 'double' 123 | QuoteChecker.parse_options(Options) 124 | 125 | multiline_checker = QuoteChecker( 126 | None, 127 | filename=get_absolute_path('data/docstring_singles.py') 128 | ) 129 | self.assertEqual(list(multiline_checker.get_quotes_errors(multiline_checker.get_file_contents())), [ 130 | {'col': 0, 'line': 1, 'message': 'Q002 Single quote docstring found but double quotes preferred'}, 131 | {'col': 4, 'line': 14, 'message': 'Q002 Single quote docstring found but double quotes preferred'}, 132 | {'col': 8, 'line': 26, 'message': 'Q002 Single quote docstring found but double quotes preferred'}, 133 | ]) 134 | 135 | multiline_checker = QuoteChecker( 136 | None, 137 | filename=get_absolute_path('data/docstring_singles_module_multiline.py') 138 | ) 139 | self.assertEqual(list(multiline_checker.get_quotes_errors(multiline_checker.get_file_contents())), [ 140 | {'col': 0, 'line': 1, 'message': 'Q002 Single quote docstring found but double quotes preferred'}, 141 | ]) 142 | 143 | multiline_checker = QuoteChecker( 144 | None, 145 | filename=get_absolute_path('data/docstring_singles_module_singleline.py') 146 | ) 147 | self.assertEqual(list(multiline_checker.get_quotes_errors(multiline_checker.get_file_contents())), [ 148 | {'col': 0, 'line': 1, 'message': 'Q002 Single quote docstring found but double quotes preferred'}, 149 | ]) 150 | 151 | multiline_checker = QuoteChecker( 152 | None, 153 | filename=get_absolute_path('data/docstring_singles_class.py') 154 | ) 155 | self.assertEqual(list(multiline_checker.get_quotes_errors(multiline_checker.get_file_contents())), [ 156 | {'col': 4, 'line': 2, 'message': 'Q002 Single quote docstring found but double quotes preferred'}, 157 | {'col': 8, 'line': 6, 'message': 'Q002 Single quote docstring found but double quotes preferred'}, 158 | {'col': 28, 'line': 9, 'message': 'Q002 Single quote docstring found but double quotes preferred'}, 159 | ]) 160 | 161 | multiline_checker = QuoteChecker( 162 | None, 163 | filename=get_absolute_path('data/docstring_singles_function.py') 164 | ) 165 | self.assertEqual(list(multiline_checker.get_quotes_errors(multiline_checker.get_file_contents())), [ 166 | {'col': 4, 'line': 2, 'message': 'Q002 Single quote docstring found but double quotes preferred'}, 167 | {'col': 4, 'line': 8, 'message': 'Q002 Single quote docstring found but double quotes preferred'}, 168 | ]) 169 | 170 | def test_require_single_docstring_single_present(self): 171 | class Options(): 172 | inline_quotes = 'single' 173 | multiline_quotes = 'double' 174 | docstring_quotes = 'single' 175 | QuoteChecker.parse_options(Options) 176 | 177 | multiline_checker = QuoteChecker(None, filename=get_absolute_path('data/docstring_singles.py')) 178 | self.assertEqual(list(multiline_checker.get_quotes_errors(multiline_checker.get_file_contents())), [ 179 | {'col': 0, 'line': 5, 'message': 'Q001 Single quote multiline found but double quotes preferred'}, 180 | {'col': 20, 'line': 11, 'message': 'Q001 Single quote multiline found but double quotes preferred'}, 181 | {'col': 4, 'line': 18, 'message': 'Q001 Single quote multiline found but double quotes preferred'}, 182 | {'col': 20, 'line': 23, 'message': 'Q001 Single quote multiline found but double quotes preferred'}, 183 | {'col': 8, 'line': 32, 'message': 'Q001 Single quote multiline found but double quotes preferred'}, 184 | {'col': 12, 'line': 37, 'message': 'Q001 Single quote multiline found but double quotes preferred'}, 185 | ]) 186 | 187 | multiline_checker = QuoteChecker( 188 | None, 189 | filename=get_absolute_path('data/docstring_singles_module_multiline.py') 190 | ) 191 | self.assertEqual(list(multiline_checker.get_quotes_errors(multiline_checker.get_file_contents())), [ 192 | {'col': 0, 'line': 4, 'message': 'Q001 Single quote multiline found but double quotes preferred'}, 193 | {'col': 0, 'line': 9, 'message': 'Q001 Single quote multiline found but double quotes preferred'}, 194 | ]) 195 | 196 | multiline_checker = QuoteChecker( 197 | None, 198 | filename=get_absolute_path('data/docstring_singles_module_singleline.py') 199 | ) 200 | self.assertEqual(list(multiline_checker.get_quotes_errors(multiline_checker.get_file_contents())), [ 201 | {'col': 0, 'line': 2, 'message': 'Q001 Single quote multiline found but double quotes preferred'}, 202 | {'col': 0, 'line': 6, 'message': 'Q001 Single quote multiline found but double quotes preferred'}, 203 | ]) 204 | 205 | multiline_checker = QuoteChecker( 206 | None, 207 | filename=get_absolute_path('data/docstring_singles_class.py') 208 | ) 209 | self.assertEqual(list(multiline_checker.get_quotes_errors(multiline_checker.get_file_contents())), [ 210 | {'col': 4, 'line': 3, 'message': 'Q001 Single quote multiline found but double quotes preferred'}, 211 | {'col': 22, 'line': 5, 'message': 'Q001 Single quote multiline found but double quotes preferred'}, 212 | ]) 213 | 214 | multiline_checker = QuoteChecker( 215 | None, 216 | filename=get_absolute_path('data/docstring_singles_function.py') 217 | ) 218 | self.assertEqual(list(multiline_checker.get_quotes_errors(multiline_checker.get_file_contents())), [ 219 | {'col': 4, 'line': 3, 'message': 'Q001 Single quote multiline found but double quotes preferred'}, 220 | {'col': 4, 'line': 11, 'message': 'Q001 Single quote multiline found but double quotes preferred'}, 221 | {'col': 38, 'line': 15, 'message': 'Q001 Single quote multiline found but double quotes preferred'}, 222 | {'col': 4, 'line': 17, 'message': 'Q001 Single quote multiline found but double quotes preferred'}, 223 | {'col': 4, 'line': 21, 'message': 'Q001 Single quote multiline found but double quotes preferred'}, 224 | ]) 225 | -------------------------------------------------------------------------------- /flake8_quotes/__init__.py: -------------------------------------------------------------------------------- 1 | import optparse 2 | import sys 3 | import tokenize 4 | import warnings 5 | 6 | # Polyfill stdin loading/reading lines 7 | # https://gitlab.com/pycqa/flake8-polyfill/blob/1.0.1/src/flake8_polyfill/stdin.py#L52-57 8 | try: 9 | from flake8.engine import pep8 10 | stdin_get_value = pep8.stdin_get_value 11 | readlines = pep8.readlines 12 | except ImportError: 13 | from flake8 import utils 14 | import pycodestyle 15 | stdin_get_value = utils.stdin_get_value 16 | readlines = pycodestyle.readlines 17 | 18 | from flake8_quotes.__about__ import __version__ 19 | from flake8_quotes.docstring_detection import get_docstring_tokens 20 | 21 | 22 | _IS_PEP701 = sys.version_info[:2] >= (3, 12) 23 | 24 | 25 | class QuoteChecker(object): 26 | name = __name__ 27 | version = __version__ 28 | 29 | INLINE_QUOTES = { 30 | # When user wants only single quotes 31 | "'": { 32 | 'good_single': "'", 33 | 'bad_single': '"', 34 | 'single_error_message': 'Double quotes found but single quotes preferred', 35 | }, 36 | # When user wants only double quotes 37 | '"': { 38 | 'good_single': '"', 39 | 'bad_single': "'", 40 | 'single_error_message': 'Single quotes found but double quotes preferred', 41 | }, 42 | } 43 | # Provide aliases for Windows CLI support 44 | # https://github.com/zheller/flake8-quotes/issues/49 45 | INLINE_QUOTES['single'] = INLINE_QUOTES["'"] 46 | INLINE_QUOTES['double'] = INLINE_QUOTES['"'] 47 | 48 | MULTILINE_QUOTES = { 49 | "'": { 50 | 'good_multiline': "'''", 51 | 'good_multiline_ending': '\'"""', 52 | 'bad_multiline': '"""', 53 | 'multiline_error_message': 'Double quote multiline found but single quotes preferred', 54 | }, 55 | '"': { 56 | 'good_multiline': '"""', 57 | 'good_multiline_ending': '"\'\'\'', 58 | 'bad_multiline': "'''", 59 | 'multiline_error_message': 'Single quote multiline found but double quotes preferred', 60 | }, 61 | } 62 | # Provide Windows CLI and multi-quote aliases 63 | MULTILINE_QUOTES['single'] = MULTILINE_QUOTES["'"] 64 | MULTILINE_QUOTES['double'] = MULTILINE_QUOTES['"'] 65 | MULTILINE_QUOTES["'''"] = MULTILINE_QUOTES["'"] 66 | MULTILINE_QUOTES['"""'] = MULTILINE_QUOTES['"'] 67 | 68 | DOCSTRING_QUOTES = { 69 | "'": { 70 | 'good_docstring': "'''", 71 | 'bad_docstring': '"""', 72 | 'docstring_error_message': 'Double quote docstring found but single quotes preferred', 73 | }, 74 | '"': { 75 | 'good_docstring': '"""', 76 | 'bad_docstring': "'''", 77 | 'docstring_error_message': 'Single quote docstring found but double quotes preferred', 78 | }, 79 | } 80 | # Provide Windows CLI and docstring-quote aliases 81 | DOCSTRING_QUOTES['single'] = DOCSTRING_QUOTES["'"] 82 | DOCSTRING_QUOTES['double'] = DOCSTRING_QUOTES['"'] 83 | DOCSTRING_QUOTES["'''"] = DOCSTRING_QUOTES["'"] 84 | DOCSTRING_QUOTES['"""'] = DOCSTRING_QUOTES['"'] 85 | 86 | def __init__(self, tree, lines=None, filename='(none)'): 87 | self.filename = filename 88 | self.lines = lines 89 | 90 | @staticmethod 91 | def _register_opt(parser, *args, **kwargs): 92 | """ 93 | Handler to register an option for both Flake8 3.x and 2.x. 94 | 95 | This is based on: 96 | https://github.com/PyCQA/flake8/blob/3.0.0b2/docs/source/plugin-development/cross-compatibility.rst#option-handling-on-flake8-2-and-3 97 | 98 | It only supports `parse_from_config` from the original function and it 99 | uses the `Option` object returned to get the string. 100 | """ 101 | try: 102 | # Flake8 3.x registration 103 | parser.add_option(*args, **kwargs) 104 | except (optparse.OptionError, TypeError): 105 | # Flake8 2.x registration 106 | parse_from_config = kwargs.pop('parse_from_config', False) 107 | option = parser.add_option(*args, **kwargs) 108 | if parse_from_config: 109 | parser.config_options.append(option.get_opt_string().lstrip('-')) 110 | 111 | @classmethod 112 | def add_options(cls, parser): 113 | cls._register_opt(parser, '--quotes', action='store', 114 | parse_from_config=True, 115 | choices=sorted(cls.INLINE_QUOTES.keys()), 116 | help='Deprecated alias for `--inline-quotes`') 117 | cls._register_opt(parser, '--inline-quotes', default="'", 118 | action='store', parse_from_config=True, 119 | choices=sorted(cls.INLINE_QUOTES.keys()), 120 | help="Quote to expect in all files (default: ')") 121 | cls._register_opt(parser, '--multiline-quotes', default=None, action='store', 122 | parse_from_config=True, 123 | choices=sorted(cls.MULTILINE_QUOTES.keys()), 124 | help='Quote to expect in all files (default: """)') 125 | cls._register_opt(parser, '--docstring-quotes', default=None, action='store', 126 | parse_from_config=True, 127 | choices=sorted(cls.DOCSTRING_QUOTES.keys()), 128 | help='Quote to expect in all files (default: """)') 129 | cls._register_opt(parser, '--avoid-escape', default=None, action='store_true', 130 | parse_from_config=True, 131 | help='Avoiding escaping same quotes in inline strings (enabled by default)') 132 | cls._register_opt(parser, '--no-avoid-escape', dest='avoid_escape', default=None, action='store_false', 133 | parse_from_config=False, 134 | help='Disable avoiding escaping same quotes in inline strings') 135 | cls._register_opt(parser, '--check-inside-f-strings', 136 | dest='check_inside_f_strings', default=False, action='store_true', 137 | parse_from_config=True, 138 | help='Check strings inside f-strings, when PEP701 is active (Python 3.12+)') 139 | 140 | @classmethod 141 | def parse_options(cls, options): 142 | # Define our default config 143 | # cls.config = {good_single: ', good_multiline: ''', bad_single: ", bad_multiline: """} 144 | cls.config = {} 145 | cls.config.update(cls.INLINE_QUOTES["'"]) 146 | cls.config.update(cls.MULTILINE_QUOTES['"""']) 147 | cls.config.update(cls.DOCSTRING_QUOTES['"""']) 148 | 149 | # If `options.quotes` was specified, then use it 150 | if hasattr(options, 'quotes') and options.quotes is not None: 151 | # https://docs.python.org/2/library/warnings.html#warnings.warn 152 | warnings.warn('flake8-quotes has deprecated `quotes` in favor of `inline-quotes`. ' 153 | 'Please update your configuration') 154 | cls.config.update(cls.INLINE_QUOTES[options.quotes]) 155 | # Otherwise, use the supported `inline_quotes` 156 | else: 157 | # cls.config = {good_single: ', good_multiline: """, bad_single: ", bad_multiline: '''} 158 | # -> {good_single: ", good_multiline: """, bad_single: ', bad_multiline: '''} 159 | cls.config.update(cls.INLINE_QUOTES[options.inline_quotes]) 160 | 161 | # If multiline quotes was specified, overload our config with those options 162 | if hasattr(options, 'multiline_quotes') and options.multiline_quotes is not None: 163 | # cls.config = {good_single: ', good_multiline: """, bad_single: ", bad_multiline: '''} 164 | # -> {good_single: ', good_multiline: ''', bad_single: ", bad_multiline: """} 165 | cls.config.update(cls.MULTILINE_QUOTES[options.multiline_quotes]) 166 | 167 | # If docstring quotes was specified, overload our config with those options 168 | if hasattr(options, 'docstring_quotes') and options.docstring_quotes is not None: 169 | cls.config.update(cls.DOCSTRING_QUOTES[options.docstring_quotes]) 170 | 171 | # If avoid escaped specified, add to config 172 | if hasattr(options, 'avoid_escape') and options.avoid_escape is not None: 173 | cls.config.update({'avoid_escape': options.avoid_escape}) 174 | else: 175 | cls.config.update({'avoid_escape': True}) 176 | 177 | # If check inside f-strings specified, add to config 178 | if hasattr(options, 'check_inside_f_strings') and options.check_inside_f_strings is not None: 179 | cls.config.update({'check_inside_f_strings': options.check_inside_f_strings}) 180 | else: 181 | cls.config.update({'check_inside_f_strings': False}) 182 | 183 | def get_file_contents(self): 184 | if self.filename in ('stdin', '-', None): 185 | return stdin_get_value().splitlines(True) 186 | else: 187 | if self.lines: 188 | return self.lines 189 | else: 190 | return readlines(self.filename) 191 | 192 | def run(self): 193 | file_contents = self.get_file_contents() 194 | 195 | noqa_line_numbers = self.get_noqa_lines(file_contents) 196 | errors = self.get_quotes_errors(file_contents) 197 | 198 | for error in errors: 199 | if error.get('line') not in noqa_line_numbers: 200 | yield (error.get('line'), error.get('col'), error.get('message'), type(self)) 201 | 202 | def get_noqa_lines(self, file_contents): 203 | tokens = [Token(t) for t in tokenize.generate_tokens(lambda L=iter(file_contents): next(L))] 204 | return [token.start_row 205 | for token in tokens 206 | if token.type == tokenize.COMMENT and token.string.endswith('noqa')] 207 | 208 | def get_quotes_errors(self, file_contents): 209 | tokens = [Token(t) for t in tokenize.generate_tokens(lambda L=iter(file_contents): next(L))] 210 | docstring_tokens = get_docstring_tokens(tokens) 211 | # when PEP701 is enabled, we track when the token stream 212 | # is passing over an f-string 213 | 214 | # the start of the current f-string (row, col) 215 | fstring_start = None 216 | 217 | # > 0 when we are inside an f-string token stream, since 218 | # f-string can be arbitrarily nested, we need a counter 219 | fstring_nesting = 0 220 | 221 | # the token.string part of all tokens inside the current 222 | # f-string 223 | fstring_buffer = [] 224 | 225 | for token in tokens: 226 | is_docstring = token in docstring_tokens 227 | 228 | # non PEP701, we only check for STRING tokens 229 | if not _IS_PEP701: 230 | if token.type == tokenize.STRING: 231 | yield from self._check_string(token.string, token.start, is_docstring) 232 | 233 | continue 234 | 235 | # otherwise, we track all tokens for the current f-string 236 | if token.type == tokenize.FSTRING_START: 237 | if fstring_nesting == 0: 238 | fstring_start = token.start 239 | 240 | fstring_nesting += 1 241 | fstring_buffer.append(token.string) 242 | elif token.type == tokenize.FSTRING_END: 243 | fstring_nesting -= 1 244 | fstring_buffer.append(token.string) 245 | elif fstring_nesting > 0: 246 | fstring_buffer.append(token.string) 247 | 248 | # if we have reached the end of a top-level f-string, we check 249 | # it as if it was a single string (pre PEP701 semantics) when 250 | # check_inside_f_strings is false 251 | if token.type == tokenize.FSTRING_END and fstring_nesting == 0: 252 | token_string = ''.join(fstring_buffer) 253 | fstring_buffer[:] = [] 254 | 255 | if not self.config['check_inside_f_strings']: 256 | yield from self._check_string(token_string, fstring_start, is_docstring) 257 | continue 258 | 259 | # otherwise, we check nested strings and f-strings, we don't 260 | # check FSTRING_END since it should be legal if tokenize.FSTRING_START succeeded 261 | if token.type in (tokenize.STRING, tokenize.FSTRING_START,): 262 | if fstring_nesting > 0: 263 | if self.config['check_inside_f_strings']: 264 | yield from self._check_string(token.string, token.start, is_docstring) 265 | else: 266 | yield from self._check_string(token.string, token.start, is_docstring) 267 | 268 | def _check_string(self, token_string, token_start, is_docstring): 269 | # Remove any prefixes in strings like `u` from `u"foo"` 270 | # DEV: `last_quote_char` is 1 character, even for multiline strings 271 | # `"foo"` -> `"foo"` 272 | # `b"foo"` -> `"foo"` 273 | # `br"foo"` -> `"foo"` 274 | # `b"""foo"""` -> `"""foo"""` 275 | last_quote_char = token_string[-1] 276 | first_quote_index = token_string.index(last_quote_char) 277 | prefix = token_string[:first_quote_index].lower() 278 | unprefixed_string = token_string[first_quote_index:] 279 | 280 | # Determine if our string is multiline-based 281 | # "foo"[0] * 3 = " * 3 = """ 282 | # "foo"[0:3] = "fo 283 | # """foo"""[0:3] = """ 284 | is_multiline_string = unprefixed_string[0] * 3 == unprefixed_string[0:3] 285 | start_row, start_col = token_start 286 | 287 | # If our string is a docstring 288 | # DEV: Docstring quotes must come before multiline quotes as it can as a multiline quote 289 | if is_docstring: 290 | if self.config['good_docstring'] in unprefixed_string: 291 | return 292 | 293 | yield { 294 | 'message': 'Q002 ' + self.config['docstring_error_message'], 295 | 'line': start_row, 296 | 'col': start_col, 297 | } 298 | # Otherwise if our string is multiline 299 | elif is_multiline_string: 300 | # If our string is or containing a known good string, then ignore it 301 | # (""")foo""" -> good (continue) 302 | # '''foo(""")''' -> good (continue) 303 | # (''')foo''' -> possibly bad 304 | if self.config['good_multiline'] in unprefixed_string: 305 | return 306 | 307 | # If our string ends with a known good ending, then ignore it 308 | # '''foo("''') -> good (continue) 309 | # Opposite, """foo"""", would break our parser (cannot handle """" ending) 310 | if unprefixed_string.endswith(self.config['good_multiline_ending']): 311 | return 312 | 313 | # Output our error 314 | yield { 315 | 'message': 'Q001 ' + self.config['multiline_error_message'], 316 | 'line': start_row, 317 | 'col': start_col, 318 | } 319 | # Otherwise (string is inline quote) 320 | else: 321 | # 'This is a string' -> Good 322 | # 'This is a "string"' -> Good 323 | # 'This is a \"string\"' -> Good 324 | # 'This is a \'string\'' -> Bad (Q003) Escaped inner quotes 325 | # '"This" is a \'string\'' -> Good Changing outer quotes would not avoid escaping 326 | # "This is a string" -> Bad (Q000) 327 | # "This is a 'string'" -> Good Avoids escaped inner quotes 328 | # "This is a \"string\"" -> Bad (Q000) 329 | # "\"This\" is a 'string'" -> Good 330 | 331 | string_contents = unprefixed_string[1:-1] 332 | # If string preferred type, check for escapes 333 | if last_quote_char == self.config['good_single']: 334 | if not self.config['avoid_escape'] or 'r' in prefix: 335 | return 336 | if (self.config['good_single'] in string_contents and 337 | not self.config['bad_single'] in string_contents): 338 | yield { 339 | 'message': 'Q003 Change outer quotes to avoid escaping inner quotes', 340 | 'line': start_row, 341 | 'col': start_col, 342 | } 343 | return 344 | 345 | # If not preferred type, only allow use to avoid escapes. 346 | if self.config['good_single'] not in string_contents: 347 | yield { 348 | 'message': 'Q000 ' + self.config['single_error_message'], 349 | 'line': start_row, 350 | 'col': start_col, 351 | } 352 | 353 | 354 | class Token: 355 | """Python 2 and 3 compatible token""" 356 | def __init__(self, token): 357 | self.token = token 358 | 359 | @property 360 | def type(self): 361 | return self.token[0] 362 | 363 | @property 364 | def string(self): 365 | return self.token[1] 366 | 367 | @property 368 | def start(self): 369 | return self.token[2] 370 | 371 | @property 372 | def start_row(self): 373 | return self.token[2][0] 374 | 375 | @property 376 | def start_col(self): 377 | return self.token[2][1] 378 | --------------------------------------------------------------------------------