diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..40595e4 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,31 @@ +# This workflows will upload a Python Package using Twine when a release is created +# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries + +name: Upload Python Package + +on: + release: + types: [created] + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools twine + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python setup.py sdist + twine upload dist/* diff --git a/.gitignore b/.gitignore index ece6e41..d0ffaa2 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,8 @@ dist/ .pytest_cache/ .coverage +# mypy +.mypy_cache/ + # System .DS_Store diff --git a/README.md b/README.md index 9cd6e57..d5263e1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,11 @@ # flake8-use-fstring -[![Build Status](https://travis-ci.com/MichaelKim0407/flake8-use-fstring.svg?branch=master)](https://travis-ci.com/MichaelKim0407/flake8-use-fstring) -[![Coverage Status](https://coveralls.io/repos/github/MichaelKim0407/flake8-use-fstring/badge.svg?branch=master)](https://coveralls.io/github/MichaelKim0407/flake8-use-fstring?branch=master) +* `master` (release) + [![Build Status](https://travis-ci.com/MichaelKim0407/flake8-use-fstring.svg?branch=master)](https://travis-ci.com/MichaelKim0407/flake8-use-fstring) + [![Coverage Status](https://coveralls.io/repos/github/MichaelKim0407/flake8-use-fstring/badge.svg?branch=master)](https://coveralls.io/github/MichaelKim0407/flake8-use-fstring?branch=master) +* `develop` (main) + [![Build Status](https://travis-ci.com/MichaelKim0407/flake8-use-fstring.svg?branch=develop)](https://travis-ci.com/MichaelKim0407/flake8-use-fstring) + [![Coverage Status](https://coveralls.io/repos/github/MichaelKim0407/flake8-use-fstring/badge.svg?branch=develop)](https://coveralls.io/github/MichaelKim0407/flake8-use-fstring?branch=develop) Jump-start into modern Python by forcing yourself to use f-strings. @@ -19,9 +23,11 @@ pip install flake8-use-fstring * `FS002`: `.format` formatting is used. +* `FS003`: f-string missing prefix (ignored by default). + ## Available Configurations -### `--percent-greedy` and `--format-greedy` +### `--percent-greedy`, `--format-greedy`, and `--fstring-ignore-format` This plugin checks each python statement (logical line) and see if `%` or `.format` is used. @@ -45,5 +51,18 @@ Thus level 0 is the default level. However, for most projects it should be reasonable to use greedy level 2 with confidence. To set greedy levels, -set `--percent-greedy` and `--format-greedy` in the command line, -or set `percent-greedy` and `format-greedy` in the `.flake8` config file. +set `--percent-greedy=` and `--format-greedy=` in the command line, +or set `percent-greedy=` and `format-greedy=` in the `.flake8` config file. + +Optionally, this plugin can also check for strings that appear to be intended to be f-strings +but are missing the `f` prefix. +This check is meant to assist when converting code to use f-strings. +Due to the potential for false positives, this check (`FS003`) is disabled by default. +To enable this check, +add the `--enable-extensions=FS003` command line option, +or set `enable-extensions=FS003` in the `.flake8` config file. + +The missing prefix check normally ignores strings that are using `%` or `.format` formatting, +to check those strings as well, +add the `--fstring-ignore-format` command line option, +or set `fstring-ignore-format=True` in the `.flake8` config file. \ No newline at end of file diff --git a/flake8_use_fstring/__init__.py b/flake8_use_fstring/__init__.py index 7e49527..439eb0c 100644 --- a/flake8_use_fstring/__init__.py +++ b/flake8_use_fstring/__init__.py @@ -1 +1 @@ -__version__ = '1.0' +__version__ = '1.1' diff --git a/flake8_use_fstring/base.py b/flake8_use_fstring/base.py index b669743..f0aea02 100644 --- a/flake8_use_fstring/base.py +++ b/flake8_use_fstring/base.py @@ -8,6 +8,8 @@ OptionManager as _OptionManager, ) +Flake8Output = _typing.Tuple[_typing.Tuple[int, int], str] + class BaseLogicalLineChecker(object): def __init__( @@ -24,7 +26,15 @@ def __getitem__(self, i: int) -> bool: def __call__(self, i: int) -> str: raise NotImplementedError # pragma: no cover - def __iter__(self) -> _typing.Iterator[_typing.Tuple[int, str]]: + def __iter__(self) -> _typing.Iterator[Flake8Output]: + for i in range(len(self.tokens)): + if not self[i]: + continue + yield self.tokens[i].start, self(i) + + +class BaseGreedyLogicalLineChecker(BaseLogicalLineChecker): + def __iter__(self) -> _typing.Iterator[Flake8Output]: met_string = False for i in range(len(self.tokens)): diff --git a/flake8_use_fstring/format.py b/flake8_use_fstring/format.py index 79c3387..6a8c495 100644 --- a/flake8_use_fstring/format.py +++ b/flake8_use_fstring/format.py @@ -1,7 +1,7 @@ import token as _token from .base import ( - BaseLogicalLineChecker as _Base, + BaseGreedyLogicalLineChecker as _Base, ) diff --git a/flake8_use_fstring/percent.py b/flake8_use_fstring/percent.py index a5afa81..baf6c90 100644 --- a/flake8_use_fstring/percent.py +++ b/flake8_use_fstring/percent.py @@ -1,7 +1,7 @@ import token as _token from .base import ( - BaseLogicalLineChecker as _Base, + BaseGreedyLogicalLineChecker as _Base, ) diff --git a/flake8_use_fstring/prefix.py b/flake8_use_fstring/prefix.py new file mode 100644 index 0000000..66f592e --- /dev/null +++ b/flake8_use_fstring/prefix.py @@ -0,0 +1,70 @@ +import re as _re +import token as _token + +from flake8.options.manager import ( + OptionManager as _OptionManager, +) + +from .base import ( + BaseLogicalLineChecker as _Base, +) + +FSTRING_REGEX = _re.compile(r'^([a-zA-Z]*?[fF][a-zA-Z]*?){1}["\']') +NON_FSTRING_REGEX = _re.compile( + r'^[a-zA-Z]*(?:\'\'\'|\'|"""|")(.*?{.+?}.*)(?:\'|\'\'\'|"|""")$', +) + + +class MissingPrefixDetector(_Base): + name = 'use-fstring-prefix' + version = '1.0' + ignore_format = False + off_by_default = True + + def __getitem__(self, i: int) -> bool: + token = self.tokens[i] + if token.exact_type != _token.STRING: + return False + + if FSTRING_REGEX.search(token.string): # already is an f-string + return False + + if not self.ignore_format: + # look ahead for % or .format and skip if present + for next_i, next_token in enumerate(self.tokens[i + 1:], i + 1): + if next_token.exact_type == _token.STRING: + continue + if next_token.exact_type == _token.PERCENT: + return False + if next_token.exact_type == _token.DOT: + try: + next_token = self.tokens[next_i + 1] + if next_token.exact_type != _token.NAME: + break + if next_token.string == 'format': + return False + except IndexError: + pass + break + + value = token.string.replace('{{', '').replace('}}', '') + return NON_FSTRING_REGEX.search(value) is not None + + def __call__(self, i: int) -> str: + return 'FS003 f-string missing prefix' + + @classmethod + def add_options(cls, option_manager: _OptionManager): + option_manager.add_option( + f'--{cls.OPTION_NAME}', + action='store_true', + default=False, + parse_from_config=True, + ) + + @classmethod + def parse_options(cls, options): + option_var = cls.OPTION_NAME.replace('-', '_') + cls.ignore_format = vars(options)[option_var] + + OPTION_NAME = 'fstring-ignore-format' diff --git a/setup.py b/setup.py index 6957b9b..a0d430b 100644 --- a/setup.py +++ b/setup.py @@ -3,6 +3,7 @@ from flake8_use_fstring import __version__ extra_test = [ + 'coverage==4.*', 'pytest>=4', 'pytest-cov>=2', @@ -50,6 +51,7 @@ 'flake8.extension': [ 'FS001 = flake8_use_fstring.percent:PercentFormatDetector', 'FS002 = flake8_use_fstring.format:StrFormatDetector', + 'FS003 = flake8_use_fstring.prefix:MissingPrefixDetector', ], }, diff --git a/tests/conftest.py b/tests/conftest.py index 0b00959..c97f586 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,8 @@ class TestFlake8Cmd(object): def __init__(self): self.percent_greedy = 0 self.format_greedy = 0 + self.enable_prefix = False + self.ignore_format = False self.expected_output = None def test(self): @@ -19,6 +21,10 @@ def test(self): f'--percent-greedy={self.percent_greedy}', f'--format-greedy={self.format_greedy}', ] + if self.enable_prefix: + cmd.append('--enable-extensions=FS003') + if self.ignore_format: + cmd.append('--fstring-ignore-format') p = subprocess.run( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, diff --git a/tests/example.py b/tests/example.py index 8d9cdc5..410ea6a 100644 --- a/tests/example.py +++ b/tests/example.py @@ -15,7 +15,7 @@ f = 3 % 2 # match greedy level 0 -g = '{}'.format(123) +g = '{val}'.format(val=123) # match greedy level 1 h = ('x' + '{}').format(123) @@ -36,5 +36,18 @@ def format(self): # noqa: A003 # false positive greedy level 2 m = C().format() +# missing prefix false positive +n = (rf'{m}' + '{' + '}' + 'm' + '' + '{}' + '{{m}}') + +# match missing prefix +o = ('{n}' + '{{m}} {n}') + # no errors below; coverage ''.strip() diff --git a/tests/test_00.py b/tests/test_00.py index 24ea713..7b0d9d3 100644 --- a/tests/test_00.py +++ b/tests/test_00.py @@ -1,7 +1,7 @@ def test_greedy_0(test_flake8_cmd): test_flake8_cmd.expected_output = b"""\ tests/example.py:2:10: FS001 '%' operator used -tests/example.py:18:9: FS002 '.format' used +tests/example.py:18:12: FS002 '.format' used """ test_flake8_cmd.test() @@ -13,7 +13,7 @@ def test_greedy_1(test_flake8_cmd): tests/example.py:2:10: FS001 '%' operator used tests/example.py:5:18: FS001 '%' operator used tests/example.py:12:16: FS001 '%' operator used -tests/example.py:18:9: FS002 '.format' used +tests/example.py:18:12: FS002 '.format' used tests/example.py:21:17: FS002 '.format' used tests/example.py:34:13: FS002 '.format' used """ @@ -29,7 +29,7 @@ def test_greedy_2(test_flake8_cmd): tests/example.py:9:7: FS001 '%' operator used tests/example.py:12:16: FS001 '%' operator used tests/example.py:15:7: FS001 '%' operator used -tests/example.py:18:9: FS002 '.format' used +tests/example.py:18:12: FS002 '.format' used tests/example.py:21:17: FS002 '.format' used tests/example.py:25:6: FS002 '.format' used tests/example.py:34:13: FS002 '.format' used @@ -46,6 +46,30 @@ def test_greedy_different(test_flake8_cmd): tests/example.py:9:7: FS001 '%' operator used tests/example.py:12:16: FS001 '%' operator used tests/example.py:15:7: FS001 '%' operator used -tests/example.py:18:9: FS002 '.format' used +tests/example.py:18:12: FS002 '.format' used +""" + test_flake8_cmd.test() + + +def test_missing_prefix(test_flake8_cmd): + test_flake8_cmd.enable_prefix = True + test_flake8_cmd.expected_output = b"""\ +tests/example.py:2:10: FS001 '%' operator used +tests/example.py:18:12: FS002 '.format' used +tests/example.py:49:6: FS003 f-string missing prefix +tests/example.py:50:6: FS003 f-string missing prefix +""" + test_flake8_cmd.test() + + +def test_missing_prefix_ignore_format(test_flake8_cmd): + test_flake8_cmd.enable_prefix = True + test_flake8_cmd.ignore_format = True + test_flake8_cmd.expected_output = b"""\ +tests/example.py:2:10: FS001 '%' operator used +tests/example.py:18:5: FS003 f-string missing prefix +tests/example.py:18:12: FS002 '.format' used +tests/example.py:49:6: FS003 f-string missing prefix +tests/example.py:50:6: FS003 f-string missing prefix """ test_flake8_cmd.test()