diff --git a/.appveyor.yml b/.appveyor.yml deleted file mode 100644 index 232acf5..0000000 --- a/.appveyor.yml +++ /dev/null @@ -1,53 +0,0 @@ -# Copyright (c) 2020 Hong Xu - -# This file is part of flake8-executable. - -# flake8-executable is free software: you can redistribute it and/or modify it -# under the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or (at your -# option) any later version. - -# flake8-executable is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License -# for more details. - -# You should have received a copy of the GNU Lesser General Public License -# along with flake8-executable. If not, see . - - -environment: - matrix: - - TOXENV: lint - PYTHON_VERSION: 3.10 - APPVEYOR_BUILD_WORKER_IMAGE: Ubuntu2004 - - TOXENV: py39 - PYTHON_VERSION: 3.9 - APPVEYOR_BUILD_WORKER_IMAGE: "Visual Studio 2019" - - TOXENV: py37 - PYTHON_VERSION: 3.7 - APPVEYOR_BUILD_WORKER_IMAGE: macOS - - TOXENV: py38 - PYTHON_VERSION: 3.8 - APPVEYOR_BUILD_WORKER_IMAGE: Ubuntu1804 - - TOXENV: py39 - PYTHON_VERSION: 3.9 - APPVEYOR_BUILD_WORKER_IMAGE: Ubuntu2004 - - TOXENV: py310 - PYTHON_VERSION: 3.10 - APPVEYOR_BUILD_WORKER_IMAGE: Ubuntu2004 - -build: false - -install: - # AppVeyor requires us to manually source a virtualenv for non-Windows tests. This line is allowed to fail (and should - # fail) on Windows images. - - source ~/venv${PYTHON_VERSION}/bin/activate || (exit 0) - - pip install -U tox - -test_script: - - tox -vv - -branches: - only: - - master diff --git a/.bandit b/.bandit deleted file mode 100644 index eed5f85..0000000 --- a/.bandit +++ /dev/null @@ -1,2 +0,0 @@ -[bandit] -exclude: /.eggs,/tests,/.tox diff --git a/.coveragerc.in b/.coveragerc.in deleted file mode 100644 index 7c7d57c..0000000 --- a/.coveragerc.in +++ /dev/null @@ -1,8 +0,0 @@ -[coverage:run] -source = flake8_executable - -[coverage:report] -fail_under = 100 -show_missing = True -exclude_lines = - pragma: no cover {platform} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7733cd4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +name: CI + +on: [ push, pull_request ] + +jobs: + lint: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [ "3.9" ] + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - uses: actions/cache@v3 + name: Configure pip caching + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-pip- + - name: Install dependencies + run: | + pip install -e '.[dev]' + - name: Run lint + run: | + pre-commit run --all-files --show-diff-on-failure + + test: + strategy: + matrix: + python-version: [ "3.7", "3.8", "3.9", "3.10", "3.11" ] + os: [ ubuntu-latest, macos-latest, windows-latest ] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - uses: actions/cache@v3 + name: Configure pip caching + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-pip- + - name: Install dependencies + run: | + pip install -e '.[dev]' + - name: Run tests + run: | + pytest diff --git a/.github/workflows/renovate-lint.yml b/.github/workflows/renovate-lint.yml index 8e10589..f10fb1a 100644 --- a/.github/workflows/renovate-lint.yml +++ b/.github/workflows/renovate-lint.yml @@ -30,7 +30,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-node@v2 with: - node-version: '12' + node-version: '16' - name: Install Dependencies run: npm install -g renovate - name: Test diff --git a/.gitignore b/.gitignore index f09f8cb..bc4a0ef 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ __pycache__/ *.egg-info /build /dist -/flake8_executable/_version.py +/tests/.pytest_cache/ diff --git a/.mypy.ini b/.mypy.ini deleted file mode 100644 index f44ffee..0000000 --- a/.mypy.ini +++ /dev/null @@ -1,3 +0,0 @@ -[mypy] -disallow_untyped_defs = True -show_error_codes = True diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..068c469 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,41 @@ +repos: + - repo: https://github.com/PyCQA/flake8 + rev: 6.0.0 + hooks: + - id: flake8 + additional_dependencies: + - flake8-executable + - bandit + - flake8-bugbear + - flake8-comprehensions + - flake8-simplify + args: [ "--max-line-length", "120" ] + exclude: "tests/to-be-tested/" + - repo: https://github.com/pre-commit/mirrors-mypy + rev: 'v0.991' + hooks: + - id: mypy + args: ["--disallow-untyped-defs", "--show-error-codes"] + exclude: "tests/" + - repo: https://github.com/psf/black + rev: 22.12.0 + hooks: + - id: black + exclude: "tests/to-be-tested/" + - repo: https://github.com/pycqa/isort + rev: 5.11.4 + hooks: + - id: isort + args: [ "--profile", "black", "-w", "88" ] + exclude: "tests/to-be-tested/" + - repo: https://github.com/asottile/yesqa + rev: v1.4.0 + hooks: + - id: yesqa + exclude: "tests/to-be-tested/" + - repo: https://github.com/asottile/pyupgrade + rev: v3.3.1 + hooks: + - id: pyupgrade + args: [ "--py37-plus" ] + exclude: "tests/to-be-tested/" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9afdaa1..2a0c4e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,18 +1,14 @@ +# Contributing + To start developing, first create a virtual environment. Inside the virtual environment, install all development dependencies: - pip install -U -r requirements-dev.txt - -Alternatively, install `tox` first. Then, let tox create the virtual environment for you: - - pip install -U --user tox - tox -e dev - . .tox/dev/bin/activate # activate the virtual environment + pip install -U .[dev] To run lint: - tox -e lint + pre-commit run --all-files To run runtime tests: - tox --skip-missing-interpreters + pytest diff --git a/README.md b/README.md index 2b1f1d5..9327509 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,11 @@ [![Pyversions](https://img.shields.io/pypi/pyversions/flake8-executable.svg?style=flat-square)](https://pypi.python.org/pypi/flake8-executable) ![PyPI](https://img.shields.io/pypi/v/flake8-executable.svg) ![PyPI - Downloads](https://img.shields.io/pypi/dm/flake8-executable) -[![Build Status](https://ci.appveyor.com/api/projects/status/h6mucl894w6dx7d0?svg=true)](https://ci.appveyor.com/project/xuhdev/flake8-executable) +[![Build Status](https://github.com/sbrugman/flake8-executable/actions/workflows/ci.yml/badge.svg)](https://github.com/sbrugman/flake8-executable/actions/workflows/ci.yml) Very often, developers mess up the executable permissions and shebangs of Python files. For example, -sometimes the executable permission was accidentally granted, sometimes it is forgotten. +sometimes the executable permission was accidentally granted, sometimes it is forgotten. Moreover, +this should be consistent with [Top-level code environment][] checks. This is a [Flake8][] plugin that ensures the executable permissions and shebangs of Python files are correctly set. Specifically, it checks the following errors: @@ -16,6 +17,23 @@ correctly set. Specifically, it checks the following errors: - EXE003: Shebang is present but does not contain "python". - EXE004: There is whitespace before shebang. - EXE005: There are blank or comment lines before shebang. +- EXE006: Found shebang, but no \_\_name\_\_ == '\_\_main\_\_' or \_\_main\_\_.py. +- EXE007: The file is executable, but no \_\_name\_\_ == '\_\_main\_\_' or \_\_main\_\_.py. + +## Error codes overview + +| Shebang | Executable* | \_\_main\_\_ | Error code | +|---------|-------------|--------------|-------------------------------------| +| ✅ | ✅ | ✅ | Complete, no issues | +| ✅ | ✅ | ❌ | EXE007 | +| ✅ | ❌ | ✅ | EXE001 | +| ✅ | ❌ | ❌ | EXE001, EXE006 | +| ❌ | ✅ | ✅ | EXE002 | +| ❌ | ✅ | ❌ | EXE002, EXE007 | +| ❌ | ❌ | ✅ | Shebang and executable are optional | +| ❌ | ❌ | ❌ | No issue | + +(*) Executable bit is ignored on Windows ## Installation @@ -23,6 +41,18 @@ Run: pip install flake8-executable +Or through `pre-commit` with the `.pre-commit-config.yaml`: + +```yaml +repos: +- repo: https://github.com/PyCQA/flake8 + rev: 6.0.0 # replace with the latest flake8 release + hooks: + - id: flake8 + additional_dependencies: + - flake8-executable +``` + ## Usage Normally, after flake8-executable is installed, invoking flake8 will also run this plugin. For more @@ -30,7 +60,7 @@ details, check out the [Flake8 plugin page][]. ## Copyright and License -Copyright (c) 2019 Hong Xu +Copyright (c) 2019 Hong Xu , 2023 Simon Brugman flake8-executable is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation, either version 3 of @@ -47,3 +77,4 @@ flake8-executable. If not, see . [Flake8]: https://flake8.pycqa.org/ [Flake8 plugin page]: https://flake8.pycqa.org/en/latest/user/using-plugins.html +[Top-level code environment]: https://docs.python.org/3/library/__main__.html diff --git a/flake8_executable/__init__.py b/flake8_executable/__init__.py index 98d840f..5572ae0 100644 --- a/flake8_executable/__init__.py +++ b/flake8_executable/__init__.py @@ -1,4 +1,5 @@ # Copyright (c) 2019 Hong Xu +# Copyright (c) 2023 Simon Brugman # This file is part of flake8-executable. @@ -15,22 +16,16 @@ # You should have received a copy of the GNU Lesser General Public License # along with flake8-executable. If not, see . -from abc import ABC +import ast import os import re -from typing import Any, Iterable, List, Tuple, Optional, Union +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Any, Iterable, List, Optional, Tuple, Union -from ._version import version as __version__ +from ._version import __version__ -__all__ = ('__version__', - 'ExcutableChecker', - # Error classes - 'Error', - 'EXE001', - 'EXE002', - 'EXE003', - 'EXE004', - 'EXE005') +SHEBANG_REGEX = re.compile(r"(\s*)#!") class Error(ABC): @@ -43,110 +38,257 @@ class Error(ABC): :param kwargs: Ignored. This is for the convenience of inheriting and calling super().__init__(). """ - def __init__(self, line_number: int, offset: int, error_code: str, message: str, **kwargs: Any) -> None: + @abstractmethod + def __init__( + self, + line_number: int, + offset: int, + error_code: str, + message: str, + **kwargs: Any, + ) -> None: self.line_number = line_number self.offset = offset self.error_code = error_code self.message = message @staticmethod - def format_flake8(line_number: int, offset: int, error_code: str, message: str) -> Tuple[int, int, str, str]: - "Return a format of that Flake8 accepts." - return line_number, offset, '{} {}'.format(error_code, message), '' + def format_flake8( + line_number: int, offset: int, error_code: str, message: str + ) -> Tuple[int, int, str, str]: + """Return a format of that Flake8 accepts.""" + return line_number, offset, f"{error_code} {message}", "" def __call__(self) -> Tuple[int, int, str, str]: - return self.__class__.format_flake8(self.line_number, self.offset, self.error_code, self.message) + return self.__class__.format_flake8( + self.line_number, self.offset, self.error_code, self.message + ) @classmethod - def should_check(cls, **kwargs: Any) -> bool: + def should_check(cls, *args: Any, **kwargs: Any) -> bool: """Whether this error should be checked. The base class currently will always return True but this can change, so you should always check the return value of this function when overriding.""" return True -class EXE001(Error): - def __init__(self, line_number: int, **kwargs: Any) -> None: - super().__init__(line_number, 0, 'EXE001', 'Shebang is present but the file is not executable.', **kwargs) - +class UnixError(Error, ABC): @classmethod - def should_check(cls, filename: Union[os.PathLike, str], **kwargs: Any) -> bool: # type: ignore[override] + def should_check(cls, filename: Union[os.PathLike, str], **kwargs: Any) -> bool: # Do not check on Windows or the input is not a file in the filesystem. - return (os.name != 'nt' and filename is not None and filename not in ('-', 'stdin') and - super().should_check(filename=filename, **kwargs)) + return ( + os.name != "nt" + and filename is not None + and str(filename) not in ("-", "stdin") + and super().should_check(filename=filename, **kwargs) + ) -class EXE002(Error): - def __init__(self, **kwargs: Any) -> None: - super().__init__(0, 0, 'EXE002', 'The file is executable but no shebang is present.', **kwargs) +class EXE001(UnixError): + def __init__(self, line_number: int, **kwargs: Any) -> None: + super().__init__( + line_number, + 0, + "EXE001", + "Shebang is present but the file is not executable.", + **kwargs, + ) - @classmethod - def should_check(cls, filename: Union[os.PathLike, str], **kwargs: Any) -> bool: # type: ignore[override] - # Do not check on Windows or the input is not a file in the filesystem. - return (os.name != 'nt' and filename is not None and filename not in ('-', 'stdin') and - super().should_check(filename=filename, **kwargs)) + +class EXE002(UnixError): + def __init__(self, **kwargs: Any) -> None: + super().__init__( + 0, + 0, + "EXE002", + "The file is executable but no shebang is present.", + **kwargs, + ) class EXE003(Error): def __init__(self, line_number: int, shebang: str, **kwargs: Any) -> None: - super().__init__(line_number, 0, 'EXE003', 'Shebang is present but does not contain "python": ' + shebang, - **kwargs) + super().__init__( + line_number, + 0, + "EXE003", + 'Shebang is present but does not contain "python": ' + shebang, + **kwargs, + ) class EXE004(Error): def __init__(self, line_number: int, offset: int, **kwargs: Any): - super().__init__(line_number, offset, 'EXE004', 'There is whitespace before shebang.', **kwargs) + super().__init__( + line_number, + offset, + "EXE004", + "There is whitespace before shebang.", + **kwargs, + ) class EXE005(Error): def __init__(self, line_number: int, **kwargs: Any): - super().__init__(line_number, 0, 'EXE005', 'There are blank or comment lines before shebang.', **kwargs) + super().__init__( + line_number, + 0, + "EXE005", + "There are blank or comment lines before shebang.", + **kwargs, + ) + + +class EXE006(Error): + def __init__(self, **kwargs: Any): + super().__init__( + 0, + 0, + "EXE006", + "Found shebang, but no __name__ == '__main__'.", + **kwargs, + ) + + +class EXE007(UnixError): + def __init__(self, **kwargs: Any): + super().__init__( + 0, + 0, + "EXE007", + "The file is executable, but no __name__ == '__main__'.", + **kwargs, + ) + + +class MainAnalyzer(ast.NodeVisitor): + """Detect __name__ == '__main__'""" + + has_main = False + + def visit_If(self, node: ast.If) -> None: + if isinstance(node.test, ast.Compare): + c = node.test + if ( + (len(c.ops) == 1 and isinstance(c.ops[0], ast.Eq)) + and len(c.comparators) == 1 + and ( + isinstance(c.left, ast.Name) + and c.left.id == "__name__" + and ( + isinstance(c.comparators[0], ast.Constant) + and c.comparators[0].value == "__main__" + ) + or + # py <= 3.7 + ( + isinstance(c.comparators[0], ast.Str) + and c.comparators[0].s == "__main__" + ) + ) + or ( + (isinstance(c.left, ast.Str) and c.left.s == "__main__") + or (isinstance(c.left, ast.Constant) and c.left.value == "__main__") + and isinstance(c.comparators[0], ast.Name) + and c.comparators[0].id == "__name__" + ) + ): + self.has_main = True + else: + print(ast.dump(c)) + self.generic_visit(node) + + def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.FunctionDef: + """Prevent functions from being evaluated""" + # self.generic_visit(node) + return node class ExecutableChecker: - name = 'flake8-executable' + name = "flake8-executable" version = __version__ - def __init__(self, - tree: None = None, # This is for flake8 - filename: Union[os.PathLike, str] = '', - lines: Optional[List[str]] = None) -> None: - self.filename = filename - self.lines = lines - - def run(self) -> Optional[Iterable[Error]]: - # Get lines if its not already read - if self.lines is None: - with open(self.filename) as f: - self.lines = f.readlines() - - shebang_lineno = None + def __init__( + self, + tree: ast.AST, + filename: Union[os.PathLike, str] = "", + lines: Optional[List[str]] = None, + ) -> None: + self.tree = tree + self.filename = Path(filename) + + # Get lines if it is not already read + if lines is None: + self.lines = self.filename.read_text().splitlines() + else: + self.lines = lines + + def _check_main(self) -> bool: + ma = MainAnalyzer() + ma.visit(self.tree) + return ma.has_main or self.filename.name == "__main__.py" + + def _check_shebang(self) -> Optional[Tuple[int, str, int]]: + shebang = None for i, line in enumerate(self.lines, 1): - m = re.match(r'(\s*)#!', line) - if m: # shebang found - shebang_lineno = i - shebang_line = line - if m.group(1): - if EXE004.should_check(): - yield EXE004(line_number=shebang_lineno, offset=len(m.group(1)))() + m = SHEBANG_REGEX.match(line) + if m: + # shebang found + shebang = (i, line, len(m.group(1))) break line = line.strip() - if len(line) != 0 and not line.startswith('#'): # neither blank or comment line + if len(line) != 0 and not line.startswith("#"): + # neither blank nor comment line break - - is_executable = os.access(self.filename, os.X_OK) - if shebang_lineno is not None: - if not is_executable: # pragma: no cover windows. No execution of this branch on Windows - if EXE001.should_check(filename=self.filename): - yield EXE001(line_number=shebang_lineno)() - if 'python' not in shebang_line: - if EXE003.should_check(): - yield EXE003(line_number=shebang_lineno, shebang=shebang_line.strip())() - if shebang_lineno > 1: - if EXE005.should_check(): - yield EXE005(line_number=shebang_lineno)() - elif is_executable: # pragma: no cover windows. No execution of this branch on Windows + return shebang + + def _check_executable(self) -> bool: + return os.access(self.filename, os.X_OK) + + def get_flake8_codes( + self, main: bool, shebang: Optional[Tuple[int, str, int]], is_executable: bool + ) -> Iterable[Tuple[int, int, str, str]]: + if shebang is not None: + if shebang[2] > 0 and EXE004.should_check(): + yield EXE004(line_number=shebang[0], offset=shebang[2])() + + if not is_executable and EXE001.should_check( + filename=self.filename + ): # pragma: no cover windows. No execution of this branch on Windows + yield EXE001(line_number=shebang[0])() + if "python" not in shebang[1] and EXE003.should_check(): + yield EXE003(line_number=shebang[0], shebang=shebang[1].strip())() + if shebang[0] > 1 and EXE005.should_check(): + yield EXE005(line_number=shebang[0])() + if not main and EXE006.should_check(filename=self.filename): + yield EXE006()() + elif ( + is_executable + ): # pragma: no cover windows. No execution of this branch on Windows # In principle, this error may also be yielded on empty # files, but flake8 seems to always skip empty files. if EXE002.should_check(filename=self.filename): yield EXE002()() + if not main and EXE007.should_check(filename=self.filename): + yield EXE007()() + + def run(self) -> Optional[Iterable[Error]]: + main = self._check_main() + shebang = self._check_shebang() + is_executable = self._check_executable() + + yield from self.get_flake8_codes(main, shebang, is_executable) + + +__all__ = ( + "__version__", + "ExecutableChecker", + "Error", + "EXE001", + "EXE002", + "EXE003", + "EXE004", + "EXE005", + "EXE006", + "EXE007", +) diff --git a/flake8_executable/_version.py b/flake8_executable/_version.py new file mode 100644 index 0000000..19d2115 --- /dev/null +++ b/flake8_executable/_version.py @@ -0,0 +1 @@ +__version__ = version = "3.0.0" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..82dbfed --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,64 @@ +[build-system] +build-backend = "setuptools.build_meta" +requires = ["setuptools>=61.2"] + +[project] +name = "flake8-executable" +description="A Flake8 plugin for checking executable permissions and shebangs." +readme="README.md" +keywords=["flake8", "linter","qa", "shebang"] +authors = [ + {name = "Hong Xu", email = "hong@topbug.net"}, + {name = "Simon Brugman", email = "sfbbrugman@gmail.com"}, +] +license={text = 'LGPL v3+'} +requires-python=">=3.7" +classifiers=[ + "Environment :: Console", + "Framework :: Flake8", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Software Development :: Quality Assurance", +] +dependencies = [ + "flake8", +] +dynamic = ["version"] + +[project.urls] +url="https://github.com/xuhdev/flake8-executable" + +[tool.setuptools.packages] +find = {} + +[project.optional-dependencies] +dev = [ + "flake8-executable[test]", + "flake8-executable[lint]", +] +lint = [ + "pre-commit", +] +test = [ + "pytest", + "pytest-cov", +] + +[project.entry-points] +"flake8.extension" = {EXE00 = "flake8_executable:ExecutableChecker"} + +[tool.setuptools.dynamic] +version = {attr = "flake8_executable.__version__"} + +[tool.bandit] +exclude_dirs = ["/.eggs","/tests"] diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index 2c22600..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,3 +0,0 @@ -tox --r requirements/lint.txt --r requirements/test.txt diff --git a/requirements/lint.txt b/requirements/lint.txt deleted file mode 100644 index 7a738a2..0000000 --- a/requirements/lint.txt +++ /dev/null @@ -1,5 +0,0 @@ -bandit==1.6.2 -flake8==3.9.2 -flake8-bugbear==21.4.3 -flake8-comprehensions==3.3.1 -mypy==0.812 diff --git a/requirements/test.txt b/requirements/test.txt deleted file mode 100644 index dea2b2c..0000000 --- a/requirements/test.txt +++ /dev/null @@ -1,2 +0,0 @@ -coverage==5.5 -pytest==6.2.5 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index e77318d..0000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[sdist] -formats=gztar diff --git a/setup.py b/setup.py deleted file mode 100644 index 9059369..0000000 --- a/setup.py +++ /dev/null @@ -1,58 +0,0 @@ -# Copyright (c) 2019 Hong Xu - -# This file is part of flake8-executable. - -# flake8-executable is free software: you can redistribute it and/or modify it -# under the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or (at your -# option) any later version. - -# flake8-executable is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License -# for more details. - -# You should have received a copy of the GNU Lesser General Public License -# along with flake8-executable. If not, see . - -import pathlib - -from setuptools import setup - - -setup( - name="flake8-executable", - description="A Flake8 plugin for checking executable permissions and shebangs.", - long_description=pathlib.Path('README.md').read_text(), - long_description_content_type="text/markdown", - keywords="flake8 linter qa", - author="Hong Xu", - author_email="hong@topbug.net", - url="https://github.com/xuhdev/flake8-executable", - license='LGPL v3+', - packages=["flake8_executable"], - data_files=[("", ["COPYING", "COPYING.GPL"])], - python_requires=">=3.6", - install_requires=["flake8 >= 3.0.0"], - classifiers=[ - "Environment :: Console", - "Framework :: Flake8", - "Intended Audience :: Developers", - "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3 :: Only", - "Topic :: Software Development :: Libraries :: Python Modules", - "Topic :: Software Development :: Quality Assurance", - ], - entry_points={ - "flake8.extension": ["EXE00 = flake8_executable:ExecutableChecker"] - }, - use_scm_version={'write_to': 'flake8_executable/_version.py'}, - setup_requires=['setuptools_scm'] -) diff --git a/tests/test_flake8_executable.py b/tests/test_flake8_executable.py index effb06e..199e7bc 100644 --- a/tests/test_flake8_executable.py +++ b/tests/test_flake8_executable.py @@ -1,4 +1,5 @@ # Copyright (c) 2019 Hong Xu +# Copyright (c) 2023 Simon Brugman # This file is part of flake8-executable. @@ -16,115 +17,226 @@ # along with flake8-executable. If not, see . -from pathlib import Path +import ast import sys +from pathlib import Path +from typing import List, Optional, Tuple import pytest -from flake8_executable import ExecutableChecker, EXE001, EXE002, EXE003, EXE004, EXE005 +from flake8_executable import ( + EXE001, + EXE002, + EXE003, + EXE004, + EXE005, + EXE006, + EXE007, + Error, + ExecutableChecker, +) WIN32 = sys.platform.startswith("win") class TestFlake8Executable: - - _python_files_folder = Path(__file__).absolute().parent / 'to-be-tested' - - @classmethod - def _get_pos_filename(cls, error_code): - "Get the filename for which an error of error_code should be emitted (on POSIX, Windows might be different)." - return cls._python_files_folder / (error_code + '_pos.py') - - @classmethod - def _get_neg_filename(cls, error_code): - """Get the filename for which an error of error_code should not be emitted (on POSIX, Windows might be - different).""" - return cls._python_files_folder / (error_code + '_neg.py') - - @pytest.mark.parametrize("error, error_code", [ - pytest.param(EXE001(line_number=1), 'exe001', - marks=pytest.mark.skipif(WIN32, reason="Windows doesn't support EXE001")), - pytest.param(EXE002(), 'exe002', marks=pytest.mark.skipif(WIN32, reason="Windows doesn't support EXE002")), - (EXE003(line_number=1, shebang='#!/bin/bash'), 'exe003'), - (EXE004(line_number=1, offset=4), 'exe004'), - (EXE005(line_number=3), 'exe005')]) - def test_exe_positive(self, error, error_code): - "Test cases in which an error should be reported." - filename = __class__._get_pos_filename(error_code) - ec = ExecutableChecker(filename=str(filename)) - errors = tuple(ec.run()) - assert errors == (error(),) - - @pytest.mark.skipif(not WIN32, reason="Windows-only test.") - @pytest.mark.parametrize("error_code", [ - 'exe001', - 'exe002']) - def test_exe_positive_others_but_negative_windows(self, error_code): - "Test cases in which an error should not be reported on Windows, while they are reported on Linux." - filename = __class__._get_pos_filename(error_code) - ec = ExecutableChecker(filename=str(filename)) - errors = tuple(ec.run()) - assert not errors # errors should be empty - - @pytest.mark.parametrize("error_code", [ - 'exe001', - 'exe002', - 'exe003', - 'exe004', - 'exe005']) - def test_exe_negative(self, error_code): - "Test cases in which no error should be reported." - filename = __class__._get_neg_filename(error_code) - ec = ExecutableChecker(filename=str(filename)) - errors = tuple(ec.run()) - assert not errors # errors should be empty - - @staticmethod - def _run_checker_stdin_from_file(filename): - with open(filename) as f: - lines = f.readlines() - ec = ExecutableChecker(filename='-', lines=lines) - return tuple(ec.run()) - - @pytest.mark.parametrize("error, error_code", [ - (EXE003(line_number=1, shebang='#!/bin/bash'), 'exe003'), - (EXE004(line_number=1, offset=4), 'exe004'), - (EXE005(line_number=3), 'exe005')]) - def test_stdin_positive(self, error, error_code): - "Test case in which an error should be reported (input is stdin)." - filename = __class__._get_pos_filename(error_code) - errors = __class__._run_checker_stdin_from_file(filename) - assert errors == (error(),) - - @pytest.mark.parametrize("error_code", [ - 'exe001', - 'exe002', - 'exe003', - 'exe004', - 'exe005']) - def test_stdin_negative(self, error_code): - "Test cases in which no error should be reported (input is stdin)." - filename = __class__._get_neg_filename(error_code) - errors = __class__._run_checker_stdin_from_file(filename) - assert not errors # errors should be empty - - @pytest.mark.parametrize("error_code", [ - 'exe001', - 'exe002']) - def test_stdin_negative_otherwise_positive(self, error_code): - "Test errors that should not be emitted when the input is from stdin, even if they should be otherwise emitted." - filename = __class__._get_pos_filename(error_code) - errors = __class__._run_checker_stdin_from_file(filename) - assert not errors # errors should be empty - - @pytest.mark.parametrize("filename", ['-', 'stdin']) - def test_stdin_negative_empty(self, filename): - "Test empty input from stdin." - ec = ExecutableChecker(filename=filename, lines=[]) - assert len(tuple(ec.run())) == 0 + _python_files_folder = Path(__file__).absolute().parent / "to-be-tested" + + def _get_filename(self, error_code: str) -> Path: + """Get the filename for the test file (on POSIX, Windows might be different).""" + return self._python_files_folder / (error_code + ".py") + + @pytest.mark.parametrize( + "test_name,main,executable,shebang,errors", + [ + ("empty", False, WIN32, None, []), + ("__main__", True, WIN32, None, []), + ("main_off", False, WIN32, None, []), + pytest.param( + "exe001_pos", + True, + False, + (1, "#!/usr/bin/python", 0), + [EXE001(line_number=1)()], + marks=pytest.mark.skipif( + WIN32, reason="Windows doesn't support EXE001" + ), + ), + pytest.param( + "exe001_pos", + True, + True, + (1, "#!/usr/bin/python", 0), + [], + marks=pytest.mark.skipif( + not WIN32, reason="Windows doesn't support EXE001" + ), + ), + pytest.param( + "shebang_only", + False, + False, + (1, "#!/usr/bin/python", 0), + [EXE001(line_number=1)(), EXE006()()], + marks=pytest.mark.skipif( + WIN32, reason="Windows doesn't support EXE001" + ), + ), + pytest.param( + "shebang_only", + False, + True, + (1, "#!/usr/bin/python", 0), + [EXE006()()], + marks=pytest.mark.skipif( + not WIN32, reason="Windows doesn't support EXE001" + ), + ), + pytest.param( + "exe002_pos", + True, + True, + None, + [EXE002()()], + marks=pytest.mark.skipif( + WIN32, reason="Windows doesn't support EXE001" + ), + ), + pytest.param( + "exe002_pos", + True, + True, + None, + [], + marks=pytest.mark.skipif( + not WIN32, reason="Windows doesn't support EXE001" + ), + ), + ( + "exe003_pos", + True, + True, + (1, "#!/bin/bash", 0), + [EXE003(line_number=1, shebang="#!/bin/bash")()], + ), + ( + "exe004_pos", + True, + True, + (1, " #!/usr/bin/python3", 4), + [EXE004(line_number=1, offset=4)()], + ), + ( + "exe005_pos", + True, + True, + (3, "#!/usr/bin/python3", 0), + [EXE005(line_number=3)()], + ), + ("exe006_pos", False, True, (1, "#!/usr/bin/python3", 0), [EXE006()()]), + ("exe006_2_pos", False, True, (1, "#!/usr/bin/python3", 0), [EXE006()()]), + pytest.param( + "exe007_pos", + False, + True, + None, + [EXE002()(), EXE007()()], + marks=pytest.mark.skipif( + WIN32, reason="Windows doesn't support EXE001" + ), + ), + pytest.param( + "exe007_pos", + False, + True, + None, + [], + marks=pytest.mark.skipif( + not WIN32, reason="Windows doesn't support EXE001" + ), + ), + ("exe001_neg", True, WIN32, None, []), + ("exe002_neg", True, WIN32, None, []), + ("exe003_neg", True, True, (1, "#!/usr/bin/python3", 0), []), + ("exe004_neg", True, True, (1, "#!/usr/bin/python3", 0), []), + ("exe005_neg", True, True, (1, "#!/usr/bin/python3", 0), []), + ("exe006_neg", True, True, (1, "#!/usr/bin/python3", 0), []), + ("exe006_2_neg", True, True, (1, "#!/usr/bin/python3", 0), []), + ], + ) + def test_checker( + self, + test_name: str, + main: bool, + executable: bool, + shebang: Optional[Tuple[int, str, int]], + errors: List[Error], + ): + file_name = self._get_filename(test_name) + tree = ast.parse(file_name.read_text()) + + ec = ExecutableChecker(tree=tree, filename=str(file_name)) + assert ec._check_main() == main + assert ec._check_shebang() == shebang + assert ec._check_executable() == executable + + codes = list(ec.get_flake8_codes(main, shebang, executable)) + assert codes == errors + + @pytest.mark.parametrize( + "test_name,main,shebang,errors", + [ + ("empty", False, None, []), + ("__main__", False, None, []), + ("main_off", False, None, []), + ("exe001_pos", True, (1, "#!/usr/bin/python", 0), []), + ("shebang_only", False, (1, "#!/usr/bin/python", 0), [EXE006()()]), + ("exe002_pos", True, None, []), + ( + "exe003_pos", + True, + (1, "#!/bin/bash", 0), + [EXE003(line_number=1, shebang="#!/bin/bash")()], + ), + ( + "exe004_pos", + True, + (1, " #!/usr/bin/python3", 4), + [EXE004(line_number=1, offset=4)()], + ), + ( + "exe005_pos", + True, + (3, "#!/usr/bin/python3", 0), + [EXE005(line_number=3)()], + ), + ("exe006_pos", False, (1, "#!/usr/bin/python3", 0), [EXE006()()]), + ("exe006_2_pos", False, (1, "#!/usr/bin/python3", 0), [EXE006()()]), + ("exe007_pos", False, None, []), + ("exe001_neg", True, None, []), + ("exe002_neg", True, None, []), + ("exe003_neg", True, (1, "#!/usr/bin/python3", 0), []), + ("exe004_neg", True, (1, "#!/usr/bin/python3", 0), []), + ("exe005_neg", True, (1, "#!/usr/bin/python3", 0), []), + ("exe006_neg", True, (1, "#!/usr/bin/python3", 0), []), + ("exe006_2_neg", True, (1, "#!/usr/bin/python3", 0), []), + ], + ) + def test_stdin(self, test_name, main, shebang, errors): + file_name = self._get_filename(test_name) + text = file_name.read_text() + lines = text.splitlines() + tree = ast.parse(text) + + ec = ExecutableChecker(tree=tree, filename="-", lines=lines) + assert ec._check_main() == main + assert ec._check_shebang() == shebang + + codes = list(ec.get_flake8_codes(main, shebang, False)) + assert codes == errors def test_cli(self): - "Test the flake8 CLI interface and ensure there's no crash." + """Test the flake8 CLI interface and ensure there's no crash.""" import flake8.main.application # The following line must not raise any exception diff --git a/tests/to-be-tested/__main__.py b/tests/to-be-tested/__main__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/to-be-tested/empty.py b/tests/to-be-tested/empty.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/to-be-tested/exe001_neg.py b/tests/to-be-tested/exe001_neg.py index 21c3d83..b8a80b9 100644 --- a/tests/to-be-tested/exe001_neg.py +++ b/tests/to-be-tested/exe001_neg.py @@ -1,2 +1,2 @@ -if __name__ == '__main__': - print('I should be executable.') +if __name__ == "__main__": + print("I should be executable.") diff --git a/tests/to-be-tested/exe001_pos.py b/tests/to-be-tested/exe001_pos.py index 6e9bbae..30fe180 100644 --- a/tests/to-be-tested/exe001_pos.py +++ b/tests/to-be-tested/exe001_pos.py @@ -1,4 +1,4 @@ #!/usr/bin/python -if __name__ == '__main__': - print('I should be executable.') +if __name__ == "__main__": + print("I should be executable.") diff --git a/tests/to-be-tested/exe002_neg.py b/tests/to-be-tested/exe002_neg.py index 4a6f268..9951d37 100644 --- a/tests/to-be-tested/exe002_neg.py +++ b/tests/to-be-tested/exe002_neg.py @@ -1,2 +1,6 @@ def a_lib_function(): print("I shouldn't be executable.") + + +if __name__ == "__main__": + a_lib_function() diff --git a/tests/to-be-tested/exe002_pos.py b/tests/to-be-tested/exe002_pos.py index 4a6f268..9951d37 100755 --- a/tests/to-be-tested/exe002_pos.py +++ b/tests/to-be-tested/exe002_pos.py @@ -1,2 +1,6 @@ def a_lib_function(): print("I shouldn't be executable.") + + +if __name__ == "__main__": + a_lib_function() diff --git a/tests/to-be-tested/exe003_neg.py b/tests/to-be-tested/exe003_neg.py index 828c921..3bfcc43 100755 --- a/tests/to-be-tested/exe003_neg.py +++ b/tests/to-be-tested/exe003_neg.py @@ -1,4 +1,4 @@ #!/usr/bin/python3 -if __name__ == '__main__': - print('Wrong shebang.') +if __name__ == "__main__": + print("Wrong shebang.") diff --git a/tests/to-be-tested/exe003_pos.py b/tests/to-be-tested/exe003_pos.py index 1e5c63c..14aadd3 100755 --- a/tests/to-be-tested/exe003_pos.py +++ b/tests/to-be-tested/exe003_pos.py @@ -1,4 +1,4 @@ #!/bin/bash -if __name__ == '__main__': - print('Wrong shebang.') +if __name__ == "__main__": + print("Wrong shebang.") diff --git a/tests/to-be-tested/exe004_neg.py b/tests/to-be-tested/exe004_neg.py index 1dbcbf0..ed39434 100755 --- a/tests/to-be-tested/exe004_neg.py +++ b/tests/to-be-tested/exe004_neg.py @@ -1,4 +1,4 @@ #!/usr/bin/python3 -if __name__ == '__main__': - print('I do not have whitespace before shebang.') +if __name__ == "__main__": + print("I do not have whitespace before shebang.") diff --git a/tests/to-be-tested/exe004_pos.py b/tests/to-be-tested/exe004_pos.py index 9ec967a..05608b6 100755 --- a/tests/to-be-tested/exe004_pos.py +++ b/tests/to-be-tested/exe004_pos.py @@ -1,4 +1,4 @@ #!/usr/bin/python3 -if __name__ == '__main__': - print('I have whitespace before shebang.') +if __name__ == "__main__": + print("I have whitespace before shebang.") diff --git a/tests/to-be-tested/exe005_neg.py b/tests/to-be-tested/exe005_neg.py index 839fdfe..34d2f13 100755 --- a/tests/to-be-tested/exe005_neg.py +++ b/tests/to-be-tested/exe005_neg.py @@ -1,4 +1,4 @@ #!/usr/bin/python3 -if __name__ == '__main__': - print('I do not have any blank or comment lines before shebang.') +if __name__ == "__main__": + print("I do not have any blank or comment lines before shebang.") diff --git a/tests/to-be-tested/exe005_pos.py b/tests/to-be-tested/exe005_pos.py index 98e61da..a387d8f 100755 --- a/tests/to-be-tested/exe005_pos.py +++ b/tests/to-be-tested/exe005_pos.py @@ -2,5 +2,5 @@ # #!/usr/bin/python3 -if __name__ == '__main__': - print('I have blank and comment lines before shebang.') +if __name__ == "__main__": + print("I have blank and comment lines before shebang.") diff --git a/tests/to-be-tested/exe006_2_neg.py b/tests/to-be-tested/exe006_2_neg.py new file mode 100755 index 0000000..fb10d30 --- /dev/null +++ b/tests/to-be-tested/exe006_2_neg.py @@ -0,0 +1,4 @@ +#!/usr/bin/python3 + +if "__main__" == __name__: + print("I do have a main") diff --git a/tests/to-be-tested/exe006_2_pos.py b/tests/to-be-tested/exe006_2_pos.py new file mode 100755 index 0000000..5046d01 --- /dev/null +++ b/tests/to-be-tested/exe006_2_pos.py @@ -0,0 +1,12 @@ +#!/usr/bin/python3 + +def wrapped_in_another(): + if __name__ == "__main__": + print("I do not have a main") + + +class Wrapper: + @classmethod + def func(cls): + if __name__ == "__main": + print("I'm not in the global namespace") \ No newline at end of file diff --git a/tests/to-be-tested/exe006_neg.py b/tests/to-be-tested/exe006_neg.py new file mode 100755 index 0000000..c30dc28 --- /dev/null +++ b/tests/to-be-tested/exe006_neg.py @@ -0,0 +1,4 @@ +#!/usr/bin/python3 + +if __name__ == "__main__": + print("I do have a main") diff --git a/tests/to-be-tested/exe006_pos.py b/tests/to-be-tested/exe006_pos.py new file mode 100755 index 0000000..6b76a39 --- /dev/null +++ b/tests/to-be-tested/exe006_pos.py @@ -0,0 +1,3 @@ +#!/usr/bin/python3 + +print("I do not have a main") diff --git a/tests/to-be-tested/exe007_neg.py b/tests/to-be-tested/exe007_neg.py new file mode 100644 index 0000000..1bc9543 --- /dev/null +++ b/tests/to-be-tested/exe007_neg.py @@ -0,0 +1,2 @@ +if __name__ == "__main__": + print("Executable with main") diff --git a/tests/to-be-tested/exe007_pos.py b/tests/to-be-tested/exe007_pos.py new file mode 100755 index 0000000..2b72875 --- /dev/null +++ b/tests/to-be-tested/exe007_pos.py @@ -0,0 +1,2 @@ + +print("Executable without main") diff --git a/tests/to-be-tested/main_off.py b/tests/to-be-tested/main_off.py new file mode 100644 index 0000000..60cfd10 --- /dev/null +++ b/tests/to-be-tested/main_off.py @@ -0,0 +1,2 @@ +if __name__ == "another_module": + print("No __main__") diff --git a/tests/to-be-tested/shebang_only.py b/tests/to-be-tested/shebang_only.py new file mode 100644 index 0000000..013e4b7 --- /dev/null +++ b/tests/to-be-tested/shebang_only.py @@ -0,0 +1 @@ +#!/usr/bin/python diff --git a/tox.ini b/tox.ini deleted file mode 100644 index ce17727..0000000 --- a/tox.ini +++ /dev/null @@ -1,59 +0,0 @@ -# Copyright (c) 2020 Hong Xu - -# This file is part of flake8-executable. - -# flake8-executable is free software: you can redistribute it and/or modify it -# under the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or (at your -# option) any later version. - -# flake8-executable is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License -# for more details. - -# You should have received a copy of the GNU Lesser General Public License -# along with flake8-executable. If not, see . - -[tox] -minversion = 3.20 -envlist = py{36,37,38,39}-{linux,macos,windows} -skipsdist = True - -[testenv] -download = True -usedevelop = True -platform = linux: linux - macos: darwin - windows: win32 -setenv = - linux: PLATFORM = linux - macos: PLATFORM = macos - windows: PLATFORM = windows - -[testenv:py{36,37,38,39}-{linux,macos,windows}] -deps = -rrequirements/test.txt -setenv = - {[testenv]setenv} - COVERAGE_RCFILE = {envtmpdir}/coveragerc -commands_pre = - {envpython} -c 'from pathlib import Path; Path(r"{env:COVERAGE_RCFILE}").write_text(Path(".coveragerc.in").read_text().format(platform="{env:PLATFORM}"))' -commands = - coverage run -m pytest -v - coverage report - -[testenv:lint] -deps = -rrequirements/lint.txt -commands = - flake8 . - bandit -r . - mypy flake8_executable - -[testenv:dev] -description = development environment with all deps at {envdir} -deps = -rrequirements-dev.txt -commands = python -c "print(r'{envpython}')" - -[flake8] -max-line-length = 120 -exclude = .eggs,.git,__pycache__,to-be-tested,.tox