commit 1ce8864a60ec1736ddbcca92e2f43beda586612d Author: Timmy Welch Date: Fri Feb 2 18:30:05 2024 -0800 Initial commit diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..2d50508 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,34 @@ +name: CI + +on: + pull_request: + push: + branches: + - '**' + +jobs: + build-and-publish: + runs-on: ${{ matrix.os }} + strategy: + matrix: + python_version: ['3.9'] + os: [ubuntu-latest] + + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Set up Python ${{ matrix.python_version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python_version }} + + - name: Install tox + run: | + python -m pip install --upgrade --upgrade-strategy eager tox + + - name: Build and install wheel + run: | + tox run -m build + python -m pip install dist/*.whl diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..e2ada11 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,55 @@ +name: CI + +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+*" + +jobs: + build-and-publish: + runs-on: ubuntu-latest + # Specifying a GitHub environment is optional, but strongly encouraged + environment: release + permissions: + # IMPORTANT: this permission is mandatory for trusted publishing + id-token: write + contents: write + + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Set up Python 3.9 + uses: actions/setup-python@v2 + with: + python-version: 3.9 + + - name: Install tox + run: | + python -m pip install --upgrade --upgrade-strategy eager tox + + - name: Build and install wheel + run: | + tox run -m build + python -m pip install dist/*.whl + + - name: "Publish distribution 📦 to PyPI" + if: startsWith(github.ref, 'refs/tags/') + uses: pypa/gh-action-pypi-publish@release/v1 + + - name: Get release name + if: startsWith(github.ref, 'refs/tags/') + shell: bash + run: | + git fetch --depth=1 origin +refs/tags/*:refs/tags/* # github is dumb + echo "release_name=$(git tag -l --format "%(refname:strip=2): %(contents:lines=1)" ${{ github.ref_name }})" >> $GITHUB_ENV + + - name: Release + uses: softprops/action-gh-release@v1 + if: startsWith(github.ref, 'refs/tags/') + with: + name: "${{ env.release_name }}" + draft: false + files: | + dist/*.whl diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b42bc0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,88 @@ +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion + +*.iml + +## Directory-based project format: +.idea/ + +### Other editors +.*.swp +nbproject/ +.vscode + +*.exe +*.zip + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# ruff +.ruff_cache + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# for testing +temp/ +tmp/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..443f7a9 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,33 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: debug-statements + - id: double-quote-string-fixer + - id: name-tests-test +- repo: https://github.com/asottile/reorder-python-imports + rev: v3.12.0 + hooks: + - id: reorder-python-imports + args: [--py38-plus, --add-import, 'from __future__ import annotations'] +- repo: https://github.com/hhatto/autopep8 + rev: v2.0.4 + hooks: + - id: autopep8 +- repo: https://github.com/asottile/add-trailing-comma + rev: v3.1.0 + hooks: + - id: add-trailing-comma +- repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.1.14 + hooks: + - id: ruff + args: [ --fix ] +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.8.0 + hooks: + - id: mypy diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4a071fc --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2014 pre-commit dev team: Anthony Sottile, Ken Struys + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0fca136 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +[![build status](https://github.com/lordwelch/flake8-no-nested-comprehensions/actions/workflows/build.yaml/badge.svg)](https://github.com/lordwelch/flake8-no-nested-comprehensions/actions/workflows/build.yaml) +[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/lordwelch/flake8-no-nested-comprehensions/main.svg)](https://results.pre-commit.ci/latest/github/lordwelch/flake8-no-nested-comprehensions/main) + +flake8-no-nested-comprehensions +================ + +flake8 plugin which forbids nested comprehensions + +## installation + +```bash +pip install flake8-no-nested-comprehensions +``` + +## flake8 codes + +| Code | Description | +|--------|----------------------------------| +| CMP100 | do not use nested comprehensions | + +## rationale + +I don't like them. +If you need them for performance you can put a `# noqa: CMP100` and preferrably put a comment in explaining it. + +## as a pre-commit hook + +See [pre-commit](https://github.com/pre-commit/pre-commit) for instructions + +Sample `.pre-commit-config.yaml`: + +```yaml +- repo: https://github.com/pycqa/flake8 + rev: 3.8.1 + hooks: + - id: flake8 + additional_dependencies: [flake8-no-nested-comprehensions==1.0.0] +``` diff --git a/flake8_no_nested_comprehensions.py b/flake8_no_nested_comprehensions.py new file mode 100644 index 0000000..8142fb1 --- /dev/null +++ b/flake8_no_nested_comprehensions.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import ast +import importlib.metadata +from typing import Any +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: # pragma: no cover + import argparse + import flake8.options.manager + from collections.abc import Generator + + +class Visitor(ast.NodeVisitor): + def __init__(self, max_generators: int) -> None: + self.problems: list[tuple[int, int]] = [] + self.max_generators = max_generators + + self.visit_ListComp = self._visit_max_generators + self.visit_SetComp = self._visit_max_generators + self.visit_GeneratorExp = self._visit_max_generators + self.visit_DictComp = self._visit_max_generators + + def _visit_max_generators(self, node: ast.ListComp | ast.SetComp | ast.GeneratorExp | ast.DictComp) -> None: + if len(node.generators) > self.max_generators: + self.problems.append((node.lineno, node.col_offset)) + self.generic_visit(node) + + +class Plugin: + name = __name__ + version = importlib.metadata.version(__name__) + max_generators = 1 + + def __init__(self, tree: ast.AST) -> None: + self._tree = tree + + def add_options(manager: flake8.options.manager.OptionManager) -> None: # pragma: no cover + manager.add_option( + '--max-comprehensions', type=int, metavar='n', + default=1, parse_from_config=True, + help='Maximum allowed generators in a single comprehension. (Default: %(default)s)', + ) + + @staticmethod + def parse_options(options: argparse.Namespace) -> None: # pragma: no cover + Plugin.max_generators = options.max_comprehensions + + def run(self) -> Generator[tuple[int, int, str, type[Any]], None, None]: + visitor = Visitor(self.max_generators) + visitor.visit(self._tree) + for line, col in visitor.problems: + yield line, col, 'CMP100 no nested comprehension', type(self) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..567b4ca --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,114 @@ +[build-system] +requires = ["setuptools>=61.2", "wheel", "setuptools_scm[toml]>=3.4"] +build-backend = "setuptools.build_meta" + +[project] +name = "flake8_no_nested_comprehensions" +description = "A flake8 plugin to disallow nested comprehensions" +authors = [{name = "Timmy Welch", email = "timmy@narnian.us"}] +license = {text = "MIT"} +classifiers = [ + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +urls = {Homepage = "https://github.com/lordwelch/flake8_no_nested_comprehensions"} +requires-python = ">=3.9" +dependencies = ["flake8>4"] +dynamic = ["version"] + +[project.readme] +file = "README.md" +content-type = "text/markdown" + +[tool.setuptools_scm] +local_scheme = "no-local-version" + +[tool.setuptools] +py-modules = ["flake8_no_nested_comprehensions"] +include-package-data = true +license-files = ["LICENSE"] + +[project.entry-points."flake8.extension"] +CMP = "flake8_no_nested_comprehensions:Plugin" + +[tool.tox] +legacy_tox_ini = """ +[tox:tox] +envlist = py3.9 + +[testenv] +deps = -rrequirements-dev.txt +commands = + coverage erase + coverage run -m pytest {posargs:tests} + coverage report + +[testenv:wheel] +description = Generate wheel and tar.gz +labels = + release + build +skip_install = true +deps = + build +commands_pre = + -python -c 'import shutil,pathlib; \ + shutil.rmtree("./build/", ignore_errors=True); \ + shutil.rmtree("./dist/", ignore_errors=True)' +commands = + python -m build + +[testenv:pypi-upload] +description = Upload wheel to PyPi +platform = Linux +labels = + release +skip_install = true +depends = wheel +deps = + twine +passenv = + TWINE_* +setenv = + TWINE_NON_INTERACTIVE=true +commands = + python -m twine upload dist/*.whl dist/*.tar.gz +""" + + +[tool.pep8] +ignore = "E265,E501" +max_line_length = "120" + +[tool.autopep8] +max_line_length = 120 +ignore = "E265,E501" + +[tool.mypy] +check_untyped_defs = true +disallow_any_generics = true +warn_return_any = false +disallow_incomplete_defs = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +disable_error_code = ['method-assign'] + +[[tool.mypy.overrides]] +module = ["testing.*"] +warn_return_any = false +disallow_untyped_defs = false + +[[tool.mypy.overrides]] +module = ["tests.*"] +warn_return_any = false +disallow_untyped_defs = false + +[tool.ruff] +line-length = 120 +extend-safe-fixes = ["TCH"] +extend-select = ["COM812", "TCH"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..997bde9 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +build +covdefaults +coverage +pytest +tox diff --git a/tests/no_nested_comprehensions_test.py b/tests/no_nested_comprehensions_test.py new file mode 100644 index 0000000..dc1ea86 --- /dev/null +++ b/tests/no_nested_comprehensions_test.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import ast + +from flake8_no_nested_comprehensions import Plugin + + +def _results(s: str) -> set[str]: + tree = ast.parse(s) + plugin = Plugin(tree) + return {f'{line}:{col} {msg}' for line, col, msg, _ in plugin.run()} + + +def test_trivial_case() -> None: + assert _results('') == set() + + +def test_nested_comprehension() -> None: + ret = _results('[attr for style in styles for attr in {"testing", "set"}]') + assert ret == {'1:0 CMP100 no nested comprehension'} + + +def test_allowed_comprehension() -> None: + ret = _results('[style for style in {"testing", "set"}]') + assert ret == set()