diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000..f6947b3 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,2 @@ +ignore: + - tests/input/**/*.py diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..c08b6d4 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# Add `.pre-commit-config.yaml` + `pre-commit run -a` +85d7e422b36fb86e22990ede8c92f7ec95ac43ec diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index d0fce9b..2130f9e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -12,7 +12,7 @@ A clear and concise description of what the bug is. **To Reproduce** Package versions -- pylint +- pylint - pytest - pylint-pytest diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml new file mode 100644 index 0000000..54c30db --- /dev/null +++ b/.github/workflows/checks.yaml @@ -0,0 +1,116 @@ +name: Checks + +on: + push: + branches: + - master + pull_request: + branches: + - master + +env: + CACHE_VERSION: 1 + KEY_PREFIX: base-venv + DEFAULT_PYTHON: "3.11" + PRE_COMMIT_CACHE: ~/.cache/pre-commit + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + prepare-base: + name: Prepare base dependencies + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + python-key: ${{ steps.generate-python-key.outputs.key }} + pre-commit-key: ${{ steps.generate-pre-commit-key.outputs.key }} + steps: + - name: Check out code from GitHub + uses: actions/checkout@v4.1.0 + - name: Set up Python ${{ env.DEFAULT_PYTHON }} + id: python + uses: actions/setup-python@v4.7.1 + with: + python-version: ${{ env.DEFAULT_PYTHON }} + check-latest: true + - name: Generate partial Python venv restore key + id: generate-python-key + run: >- + echo "key=${{ env.KEY_PREFIX }}-${{ env.CACHE_VERSION }}-${{ + hashFiles('pyproject.toml', 'requirements_test.txt', + 'requirements_test_min.txt', 'requirements_test_pre_commit.txt') }}" >> + $GITHUB_OUTPUT + - name: Restore Python virtual environment + id: cache-venv + uses: actions/cache@v3.3.2 + with: + path: venv + key: >- + ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + steps.generate-python-key.outputs.key }} + - name: Create Python virtual environment + if: steps.cache-venv.outputs.cache-hit != 'true' + run: | + python -m venv venv + . venv/bin/activate + python -m pip install -U pip setuptools wheel + pip install . + pip install pre-commit + - name: Generate pre-commit restore key + id: generate-pre-commit-key + run: >- + echo "key=pre-commit-${{ env.CACHE_VERSION }}-${{ + hashFiles('.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT + - name: Restore pre-commit environment + id: cache-precommit + uses: actions/cache@v3.3.2 + with: + path: ${{ env.PRE_COMMIT_CACHE }} + key: >- + ${{ runner.os }}-${{ steps.generate-pre-commit-key.outputs.key }} + - name: Install pre-commit dependencies + if: steps.cache-precommit.outputs.cache-hit != 'true' + run: | + . venv/bin/activate + pre-commit install --install-hooks + + pylint: + name: pylint + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: prepare-base + steps: + - name: Check out code from GitHub + uses: actions/checkout@v4.1.0 + - name: Set up Python ${{ env.DEFAULT_PYTHON }} + id: python + uses: actions/setup-python@v4.7.1 + with: + python-version: ${{ env.DEFAULT_PYTHON }} + check-latest: true + - name: Restore Python virtual environment + id: cache-venv + uses: actions/cache@v3.3.2 + with: + path: venv + fail-on-cache-miss: true + key: + ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + needs.prepare-base.outputs.python-key }} + - name: Restore pre-commit environment + id: cache-precommit + uses: actions/cache@v3.3.2 + with: + path: ${{ env.PRE_COMMIT_CACHE }} + fail-on-cache-miss: true + key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} + - name: Run pylint checks + run: | + . venv/bin/activate + pip install . + pre-commit run pylint --all-files diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5fb9cbe --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,45 @@ +name: Release + +on: + release: + types: + - published + +env: + DEFAULT_PYTHON: "3.11" + +permissions: + contents: read + +jobs: + release-pypi: + name: Upload release to PyPI + runs-on: ubuntu-latest + environment: + name: PyPI + url: https://pypi.org/project/pylint-pytest/ + steps: + - name: Check out code from Github + uses: actions/checkout@v4.1.0 + - name: Set up Python ${{ env.DEFAULT_PYTHON }} + id: python + uses: actions/setup-python@v4.7.1 + with: + python-version: ${{ env.DEFAULT_PYTHON }} + check-latest: true + - name: Install requirements + run: | + # Remove dist, build, and pylint.egg-info + # when building locally for testing! + python -m pip install twine build + - name: Build distributions + run: | + python -m build + - name: Upload to PyPI + if: github.event_name == 'release' && startsWith(github.ref, 'refs/tags') + env: + TWINE_REPOSITORY: pypi + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: | + twine upload --verbose dist/* diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml new file mode 100644 index 0000000..d44d33e --- /dev/null +++ b/.github/workflows/run-tests.yaml @@ -0,0 +1,78 @@ +name: Testing + +on: + push: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + - macos-latest + python-version: + - '3.6' + - '3.7' + - '3.8' + - '3.9' + - '3.10' + - '3.11' + # - '3.12' # FixMe: https://github.com/pylint-dev/pylint-pytest/issues/3 + # Python 3.6 is not available in `ubuntu-latest`. + exclude: + - python-version: '3.6' + os: ubuntu-latest + include: + - python-version: '3.6' + os: ubuntu-20.04 + + defaults: + run: + shell: ${{ matrix.os == 'windows-latest' && 'pwsh' || '/bin/bash --noprofile --norc -Eeuxo pipefail {0}' }} + + steps: + - uses: actions/checkout@v3 + + - name: Slugify GITHUB_REPOSITORY + run: echo "GITHUB_REPOSITORY_SLUG=${GITHUB_REPOSITORY////_}" >> $GITHUB_ENV + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: setup.py + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install tox + + - name: Test with tox + env: + FORCE_COLOR: 1 + PYTEST_CI_ARGS: --cov-report=xml --cov-report=html --junitxml=test_artifacts/test_report.xml --color=yes + run: tox ${{ matrix.python-version == '3.6' && '--skip-missing-interpreters=true' || '' }} + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: ${{ matrix.os }},${{ matrix.python-version }} + fail_ci_if_error: true + files: test_artifacts/cobertura.xml + + - name: Create artifacts + uses: actions/upload-artifact@v3 + if: ${{ !cancelled() }} + with: + name: ${{ env.GITHUB_REPOSITORY_SLUG }}_test-artifacts_${{ github.event_name }}_${{ github.event.pull_request.number || github.sha }}_${{ matrix.os }}_py${{ matrix.python-version }} + path: test_artifacts/ diff --git a/.gitignore b/.gitignore index e99db73..4012e01 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,169 @@ -*.pyc +sandbox/**/*.py +/test_artifacts/ + +## Mostly complete version from https://github.com/github/gitignore/blob/e5323759e387ba347a9d50f8b0ddd16502eb71d4/Python.gitignore + +# Byte-compiled / optimized / DLL files __pycache__/ -.tox/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python build/ -sandbox/**/*.py -Pipfile* +develop-eggs/ dist/ -Makefile +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# 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/ +cache/* + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# VS code +.vscode/launch.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..50be1c3 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,56 @@ +ci: + skip: [pylint] + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: end-of-file-fixer + exclude: ^.idea/ + - id: trailing-whitespace + - id: pretty-format-json + args: [ "--no-sort-keys", "--autofix", "--indent=4" ] + exclude: ^.vscode/ + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.0.292 + hooks: + - id: ruff + args: ["--fix"] + exclude: "tests/input/" + - repo: https://github.com/psf/black + rev: 23.9.1 + hooks: + - id: black + - repo: https://github.com/asottile/blacken-docs + rev: 1.16.0 + hooks: + - id: blacken-docs + additional_dependencies: + - black==23.9.1 + - repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.10.0 + hooks: + - id: python-use-type-annotations + - id: python-check-blanket-noqa + - id: python-check-blanket-type-ignore + - id: python-check-mock-methods + - id: python-no-eval + - id: python-no-log-warn + - id: python-use-type-annotations + - id: rst-backticks + - id: rst-directive-colons + - id: rst-inline-touching-normal + - id: text-unicode-replacement-char + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.6.0 + hooks: + - id: mypy + exclude: "tests/input/" + - repo: local + hooks: + - id: pylint + name: pylint + entry: bash -c 'test -d .venv && . .venv/bin/activate ; pylint "$@"' - + language: system + types: [ python ] + args: ["-sn", "-rn"] diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index d08855f..0000000 --- a/.travis.yml +++ /dev/null @@ -1,33 +0,0 @@ -language: python - -matrix: - include: - - python: 3.6 - env: TOX_ENV=py36 - - python: 3.7 - env: TOX_ENV=py37 - - python: 3.8 - env: TOX_ENV=py38 - - python: 3.9 - env: TOX_ENV=py39 - -install: - - pip install tox - -script: - - tox -e $TOX_ENV - -before_cache: - - rm -rf $HOME/.cache/pip/log - -cache: - directories: - - $HOME/.cache/pip - -deploy: - provider: pypi - user: __token__ - on: - tags: true - condition: "$TOX_ENV = py38" - distributions: bdist_wheel diff --git a/CHANGELOG.md b/CHANGELOG.md index 59758b8..6a62d64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ ## [Unreleased] +## [1.1.3] - 2023-10-23 + +This is the first release after maintenance was assumed by https://github.com/stdedos. + +The focus of this release was to improve automation: +* Fix the continuous integration, +* Run tests as part of branches and PRs, +* Use `.pre-commit-config.yaml` file to upkeep the code quality, and +* Automate the release process + +There should be no functional changes in this release, although there are changes in the source code. + +A heartfelt thank you to https://github.com/Pierre-Sassoulas for his invaluable contributions to the continued maintenance of this project! + +### Fixed + +- The continuous integration was fixed, as a new maintenance team was assembled. + +### Added + +- Added an extensive `.pre-commit-config.yaml` file to upkeep the code quality. + It includes, among others, `black`, `mypy` (in non-strict mode yet), `ruff`, and `pylint`. +- Added an automated release process + +### Changed + +- Redirected all repository URLs to the https://github.com/pylint-dev/pylint-pytest. + ## [1.1.2] - 2021-04-19 ### Fixed - Fix #18 plugin crash when test case is marked with a non-pytest.mark decorator diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..cbd2698 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +prune tests/ diff --git a/README.md b/README.md index 2cfcc6c..8e57bce 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,18 @@ # pylint-pytest -[![PyPI version fury.io](https://badge.fury.io/py/pylint-pytest.svg)](https://pypi.python.org/pypi/pylint-pytest/) -[![Travis CI](https://travis-ci.org/reverbc/pylint-pytest.svg?branch=master)](https://travis-ci.org/reverbc/pylint-pytest) -[![AppVeyor](https://ci.appveyor.com/api/projects/status/github/reverbc/pylint-pytest?branch=master&svg=true)](https://ci.appveyor.com/project/reverbc/pylint-pytest) +![PyPI - Version](https://img.shields.io/pypi/v/pylint-pytest) +![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pylint-pytest) +![PyPI - Downloads](https://img.shields.io/pypi/dd/pylint-pytest) +![PyPI - License](https://img.shields.io/pypi/l/pylint-pytest) + +[![Github - Testing](https://github.com/pylint-dev/pylint-pytest/actions/workflows/run-tests.yaml/badge.svg)](https://github.com/pylint-dev/pylint-pytest/actions/workflows/run-tests.yaml) +[![codecov](https://codecov.io/gh/pylint-dev/pylint-pytest/graph/badge.svg?token=NhZDLKmomd)](https://codecov.io/gh/pylint-dev/pylint-pytest) + +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) +[![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/) + +[![Say Thanks!](https://img.shields.io/badge/Say%20Thanks-!-1EAEDB.svg)](https://saythanks.io/to/stdedos) A Pylint plugin to suppress pytest-related false positives. @@ -50,7 +60,10 @@ def test_something(conftest_fixture): # <- Unused argument 'conftest_fixture' FP when an imported fixture is used in an applicable function, e.g. ```python -from fixture_collections import imported_fixture # <- Unused imported_fixture imported from fixture_collections +from fixture_collections import ( + imported_fixture, +) # <- Unused imported_fixture imported from fixture_collections + def test_something(imported_fixture): ... @@ -63,7 +76,10 @@ FP when an imported/declared fixture is used in an applicable function, e.g. ```python from fixture_collections import imported_fixture -def test_something(imported_fixture): # <- Redefining name 'imported_fixture' from outer scope (line 1) + +def test_something( + imported_fixture, +): # <- Redefining name 'imported_fixture' from outer scope (line 1) ... ``` @@ -74,15 +90,18 @@ FP when class attributes are defined in setup fixtures ```python import pytest + class TestClass(object): @staticmethod - @pytest.fixture(scope='class', autouse=True) + @pytest.fixture(scope="class", autouse=True) def setup_class(request): cls = request.cls cls.defined_in_setup_class = True def test_foo(self): - assert self.defined_in_setup_class # <- Instance of 'TestClass' has no 'defined_in_setup_class' member + assert ( + self.defined_in_setup_class + ) # <- Instance of 'TestClass' has no 'defined_in_setup_class' member ``` ## Raise new warning(s) @@ -94,6 +113,7 @@ Raise when using deprecated `@pytest.yield_fixture` decorator ([ref](https://doc ```python import pytest + @pytest.yield_fixture # <- Using a deprecated @pytest.yield_fixture decorator def yield_fixture(): yield @@ -106,12 +126,16 @@ Raise when using every `@pytest.mark.*` for the fixture ([ref](https://docs.pyte ```python import pytest + @pytest.fixture def awesome_fixture(): ... + @pytest.fixture -@pytest.mark.usefixtures("awesome_fixture") # <- Using useless `@pytest.mark.*` decorator for fixtures +@pytest.mark.usefixtures( + "awesome_fixture" +) # <- Using useless `@pytest.mark.*` decorator for fixtures def another_awesome_fixture(): ... ``` @@ -123,6 +147,7 @@ Raise when using deprecated positional arguments for fixture decorator ([ref](ht ```python import pytest + @pytest.fixture("module") # <- Using a deprecated positional arguments for fixture def awesome_fixture(): ... diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index 7b1a8b3..0000000 --- a/appveyor.yml +++ /dev/null @@ -1,36 +0,0 @@ -# What Python version is installed where: -# http://www.appveyor.com/docs/installed-software#python - -image: Visual Studio 2019 - -environment: - matrix: - - PYTHON: "C:\\Python36" - TOX_ENV: "py36" - - - PYTHON: "C:\\Python37" - TOX_ENV: "py37" - - - PYTHON: "C:\\Python38" - TOX_ENV: "py38" - - - PYTHON: "C:\\Python39" - TOX_ENV: "py39" - -init: - - "%PYTHON%/python -V" - - "%PYTHON%/python -c \"import struct;print( 8 * struct.calcsize(\'P\'))\"" - -install: - - "%PYTHON%/Scripts/easy_install -U pip" - - "%PYTHON%/Scripts/pip install tox" - - "%PYTHON%/Scripts/pip install wheel" - - "%PYTHON%/Scripts/pip install psutil" - -build: false # Not a C# project, build stuff at the test step instead. - -test_script: - - "%PYTHON%/Scripts/tox -e %TOX_ENV%" - -#on_success: -# - TODO: upload the content of dist/*.whl to a public wheelhouse diff --git a/pylint_pytest/__init__.py b/pylint_pytest/__init__.py index 983d575..93e4690 100644 --- a/pylint_pytest/__init__.py +++ b/pylint_pytest/__init__.py @@ -1,29 +1,30 @@ -import os -import inspect -import importlib import glob +import importlib +import inspect +import os from .checkers import BasePytestChecker -# pylint: disable=protected-access def register(linter): - '''auto discover pylint checker classes''' + """auto discover pylint checker classes""" dirname = os.path.dirname(__file__) - for module in glob.glob(os.path.join(dirname, 'checkers', '*.py')): + for module in glob.glob(os.path.join(dirname, "checkers", "*.py")): # trim file extension module = os.path.splitext(module)[0] # use relative path only - module = module.replace(dirname, '', 1) + module = module.replace(dirname, "", 1) # translate file path into module import path - module = module.replace(os.sep, '.') + module = module.replace(os.sep, ".") checker = importlib.import_module(module, package=os.path.basename(dirname)) for attr_name in dir(checker): attr_val = getattr(checker, attr_name) - if attr_val != BasePytestChecker and \ - inspect.isclass(attr_val) and \ - issubclass(attr_val, BasePytestChecker): + if ( + attr_val != BasePytestChecker + and inspect.isclass(attr_val) + and issubclass(attr_val, BasePytestChecker) + ): linter.register_checker(attr_val(linter)) diff --git a/pylint_pytest/checkers/__init__.py b/pylint_pytest/checkers/__init__.py index 543eb8e..d74c770 100644 --- a/pylint_pytest/checkers/__init__.py +++ b/pylint_pytest/checkers/__init__.py @@ -2,4 +2,4 @@ class BasePytestChecker(BaseChecker): - name = 'pylint-pytest' + name = "pylint-pytest" diff --git a/pylint_pytest/checkers/class_attr_loader.py b/pylint_pytest/checkers/class_attr_loader.py index 1cfda6a..6c9b4e9 100644 --- a/pylint_pytest/checkers/class_attr_loader.py +++ b/pylint_pytest/checkers/class_attr_loader.py @@ -1,19 +1,22 @@ -import astroid +from typing import Optional, Set + +from astroid import Assign, Attribute, ClassDef, Name from pylint.interfaces import IAstroidChecker + from ..utils import _can_use_fixture, _is_class_autouse_fixture from . import BasePytestChecker class ClassAttrLoader(BasePytestChecker): __implements__ = IAstroidChecker - msgs = {'E6400': ('', 'pytest-class-attr-loader', '')} + msgs = {"E6400": ("", "pytest-class-attr-loader", "")} in_setup = False - request_cls = set() - class_node = None + request_cls: Set[str] = set() + class_node: Optional[ClassDef] = None def visit_functiondef(self, node): - '''determine if a method is a class setup method''' + """determine if a method is a class setup method""" self.in_setup = False self.request_cls = set() self.class_node = None @@ -22,26 +25,34 @@ def visit_functiondef(self, node): self.in_setup = True self.class_node = node.parent - def visit_assign(self, node): - '''store the aliases for `cls`''' - if self.in_setup and isinstance(node.value, astroid.Attribute) and \ - node.value.attrname == 'cls' and \ - node.value.expr.name == 'request': + def visit_assign(self, node: Assign): + """store the aliases for `cls`""" + if ( + self.in_setup + and isinstance(node.value, Attribute) + and node.value.attrname == "cls" + and isinstance(node.value.expr, Name) + and node.value.expr.name == "request" + ): # storing the aliases for cls from request.cls - self.request_cls = set(map(lambda t: t.name, node.targets)) + self.request_cls = set(t.name for t in node.targets) def visit_assignattr(self, node): - if self.in_setup and isinstance(node.expr, astroid.Name) and \ - node.expr.name in self.request_cls and \ - node.attrname not in self.class_node.locals: + if ( + self.in_setup + and isinstance(node.expr, Name) + and node.expr.name in self.request_cls + and self.class_node is not None + and node.attrname not in self.class_node.locals + ): try: # find Assign node which contains the source "value" assign_node = node - while not isinstance(assign_node, astroid.Assign): + while not isinstance(assign_node, Assign): assign_node = assign_node.parent # hack class locals self.class_node.locals[node.attrname] = [assign_node.value] - except: # pylint: disable=bare-except + except Exception: # pylint: disable=broad-except # cannot find valid assign expr, skipping the entire attribute pass diff --git a/pylint_pytest/checkers/fixture.py b/pylint_pytest/checkers/fixture.py index c2a00a0..79b9fb8 100644 --- a/pylint_pytest/checkers/fixture.py +++ b/pylint_pytest/checkers/fixture.py @@ -1,29 +1,36 @@ -import os +import fnmatch +import io import sys from pathlib import Path -import fnmatch +from typing import Set, Tuple import astroid import pylint +import pytest from pylint.checkers.variables import VariablesChecker from pylint.interfaces import IAstroidChecker -import pytest + from ..utils import ( _can_use_fixture, + _is_pytest_fixture, _is_pytest_mark, _is_pytest_mark_usefixtures, - _is_pytest_fixture, _is_same_module, ) from . import BasePytestChecker +from .types import FixtureDict, replacement_add_message # TODO: support pytest python_files configuration -FILE_NAME_PATTERNS = ('test_*.py', '*_test.py') +FILE_NAME_PATTERNS: Tuple[str, ...] = ("test_*.py", "*_test.py") +ARGUMENT_ARE_KEYWORD_ONLY = ( + "https://docs.pytest.org/en/stable/deprecations.html#pytest-fixture-arguments-are-keyword-only" +) class FixtureCollector: - fixtures = {} - errors = set() + # Same as ``_pytest.fixtures.FixtureManager._arg2fixturedefs``. + fixtures: FixtureDict = {} + errors: Set[pytest.CollectReport] = set() def pytest_sessionfinish(self, session): # pylint: disable=protected-access @@ -37,41 +44,45 @@ def pytest_collectreport(self, report): class FixtureChecker(BasePytestChecker): __implements__ = IAstroidChecker msgs = { - 'W6401': ( - 'Using a deprecated @pytest.yield_fixture decorator', - 'deprecated-pytest-yield-fixture', - 'Used when using a deprecated pytest decorator that has been deprecated in pytest-3.0' + "W6401": ( + "Using a deprecated @pytest.yield_fixture decorator", + "deprecated-pytest-yield-fixture", + "Used when using a deprecated pytest decorator that has been deprecated in pytest-3.0", ), - 'W6402': ( - 'Using useless `@pytest.mark.*` decorator for fixtures', - 'useless-pytest-mark-decorator', + "W6402": ( + "Using useless `@pytest.mark.*` decorator for fixtures", + "useless-pytest-mark-decorator", ( - '@pytest.mark.* decorators can\'t by applied to fixtures. ' - 'Take a look at: https://docs.pytest.org/en/stable/reference.html#marks' + "@pytest.mark.* decorators can't by applied to fixtures. " + "Take a look at: https://docs.pytest.org/en/stable/reference.html#marks" ), ), - 'W6403': ( - 'Using a deprecated positional arguments for fixture', - 'deprecated-positional-argument-for-pytest-fixture', + "W6403": ( + "Using a deprecated positional arguments for fixture", + "deprecated-positional-argument-for-pytest-fixture", ( - 'Pass scope as a kwarg, not positional arg, which is deprecated in future pytest. ' - 'Take a look at: https://docs.pytest.org/en/stable/deprecations.html#pytest-fixture-arguments-are-keyword-only' + "Pass scope as a kwarg, not positional arg, which is deprecated in future pytest. " + f"Take a look at: {ARGUMENT_ARE_KEYWORD_ONLY}" ), ), - 'F6401': ( + "F6401": ( ( - 'pylint-pytest plugin cannot enumerate and collect pytest fixtures. ' - 'Please run `pytest --fixtures --collect-only path/to/current/module.py` and resolve any potential syntax error or package dependency issues' + "pylint-pytest plugin cannot enumerate and collect pytest fixtures. " + "Please run `pytest --fixtures --collect-only %s` and resolve " + "any potential syntax error or package dependency issues. stdout: %s. stderr: %s." ), - 'cannot-enumerate-pytest-fixtures', - 'Used when pylint-pytest has been unable to enumerate and collect pytest fixtures.', + "cannot-enumerate-pytest-fixtures", + "Used when pylint-pytest has been unable to enumerate and collect pytest fixtures.", ), } - _pytest_fixtures = {} - _invoked_with_func_args = set() - _invoked_with_usefixtures = set() - _original_add_message = callable + # Store all fixtures discovered by pytest session + _pytest_fixtures: FixtureDict = {} + # Stores all used function arguments + _invoked_with_func_args: Set[str] = set() + # Stores all invoked fixtures through @pytest.mark.usefixture(...) + _invoked_with_usefixtures: Set[str] = set() + _original_add_message = replacement_add_message def open(self): # patch VariablesChecker.add_message @@ -79,10 +90,10 @@ def open(self): VariablesChecker.add_message = FixtureChecker.patch_add_message def close(self): - '''restore & reset class attr for testing''' + """restore & reset class attr for testing""" # restore add_message VariablesChecker.add_message = FixtureChecker._original_add_message - FixtureChecker._original_add_message = callable + FixtureChecker._original_add_message = replacement_add_message # reset fixture info storage FixtureChecker._pytest_fixtures = {} @@ -90,19 +101,14 @@ def close(self): FixtureChecker._invoked_with_usefixtures = set() def visit_module(self, node): - ''' + """ - only run once per module - invoke pytest session to collect available fixtures - create containers for the module to store args and fixtures - ''' - # storing all fixtures discovered by pytest session - FixtureChecker._pytest_fixtures = {} # Dict[List[_pytest.fixtures.FixtureDef]] - - # storing all used function arguments - FixtureChecker._invoked_with_func_args = set() # Set[str] - - # storing all invoked fixtures through @pytest.mark.usefixture(...) - FixtureChecker._invoked_with_usefixtures = set() # Set[str] + """ + FixtureChecker._pytest_fixtures = {} + FixtureChecker._invoked_with_func_args = set() + FixtureChecker._invoked_with_usefixtures = set() is_test_module = False for pattern in FILE_NAME_PATTERNS: @@ -110,11 +116,12 @@ def visit_module(self, node): is_test_module = True break + stdout, stderr = sys.stdout, sys.stderr try: - with open(os.devnull, 'w') as devnull: + with io.StringIO() as captured_stdout, io.StringIO() as captured_stderr: # suppress any future output from pytest - stdout, stderr = sys.stdout, sys.stderr - sys.stderr = sys.stdout = devnull + sys.stderr = captured_stderr + sys.stdout = captured_stdout # run pytest session with customized plugin to collect fixtures fixture_collector = FixtureCollector() @@ -124,8 +131,10 @@ def visit_module(self, node): ret = pytest.main( [ - node.file, '--fixtures', '--collect-only', - '--pythonwarnings=ignore:Module already imported:pytest.PytestWarning', + node.file, + "--fixtures", + "--collect-only", + "--pythonwarnings=ignore:Module already imported:pytest.PytestWarning", ], plugins=[fixture_collector], ) @@ -135,8 +144,32 @@ def visit_module(self, node): FixtureChecker._pytest_fixtures = fixture_collector.fixtures - if (ret != pytest.ExitCode.OK or fixture_collector.errors) and is_test_module: - self.add_message('cannot-enumerate-pytest-fixtures', node=node) + legitimate_failure_paths = set( + collection_report.nodeid + for collection_report in fixture_collector.errors + if any( + fnmatch.fnmatch( + Path(collection_report.nodeid).name, + pattern, + ) + for pattern in FILE_NAME_PATTERNS + ) + ) + if (ret != pytest.ExitCode.OK or legitimate_failure_paths) and is_test_module: + files_to_report = { + str(Path(x).absolute().relative_to(Path.cwd())) + for x in legitimate_failure_paths | {node.file} + } + + self.add_message( + "cannot-enumerate-pytest-fixtures", + args=( + " ".join(files_to_report), + captured_stdout.getvalue(), + captured_stderr.getvalue(), + ), + node=node, + ) finally: # restore output devices sys.stdout, sys.stderr = stdout, stderr @@ -164,9 +197,14 @@ def visit_decorators(self, node): uses_fixture_deco, uses_mark_deco = False, False for decorator in node.nodes: try: - if _is_pytest_fixture(decorator) and isinstance(decorator, astroid.Call) and decorator.args: + if ( + _is_pytest_fixture(decorator) + and isinstance(decorator, astroid.Call) + and decorator.args + ): self.add_message( - 'deprecated-positional-argument-for-pytest-fixture', node=decorator + "deprecated-positional-argument-for-pytest-fixture", + node=decorator, ) uses_fixture_deco |= _is_pytest_fixture(decorator) uses_mark_deco |= _is_pytest_mark(decorator) @@ -177,10 +215,10 @@ def visit_decorators(self, node): self.add_message("useless-pytest-mark-decorator", node=node) def visit_functiondef(self, node): - ''' + """ - save invoked fixtures for later use - save used function arguments for later use - ''' + """ if _can_use_fixture(node): if node.decorators: # check all decorators @@ -189,72 +227,88 @@ def visit_functiondef(self, node): # save all visited fixtures for arg in decorator.args: self._invoked_with_usefixtures.add(arg.value) - if int(pytest.__version__.split('.')[0]) >= 3 and \ - _is_pytest_fixture(decorator, fixture=False): + if int(pytest.__version__.split(".")[0]) >= 3 and _is_pytest_fixture( + decorator, fixture=False + ): # raise deprecated warning for @pytest.yield_fixture - self.add_message('deprecated-pytest-yield-fixture', node=node) + self.add_message("deprecated-pytest-yield-fixture", node=node) for arg in node.args.args: self._invoked_with_func_args.add(arg.name) - # pylint: disable=protected-access,bad-staticmethod-argument + # pylint: disable=bad-staticmethod-argument @staticmethod - def patch_add_message(self, msgid, line=None, node=None, args=None, - confidence=None, col_offset=None): - ''' + def patch_add_message( + self, msgid, line=None, node=None, args=None, confidence=None, col_offset=None + ): + """ - intercept and discard unwanted warning messages - ''' + """ # check W0611 unused-import - if msgid == 'unused-import': + if msgid == "unused-import": # actual attribute name is not passed as arg so...dirty hack # message is usually in the form of '%s imported from %s (as %)' message_tokens = args.split() fixture_name = message_tokens[0] # ignoring 'import %s' message - if message_tokens[0] == 'import' and len(message_tokens) == 2: + if message_tokens[0] == "import" and len(message_tokens) == 2: pass # fixture is defined in other modules and being imported to # conftest for pytest magic - elif isinstance(node.parent, astroid.Module) \ - and node.parent.name.split('.')[-1] == 'conftest' \ - and fixture_name in FixtureChecker._pytest_fixtures: + elif ( + isinstance(node.parent, astroid.Module) + and node.parent.name.split(".")[-1] == "conftest" + and fixture_name in FixtureChecker._pytest_fixtures + ): return # imported fixture is referenced in test/fixture func - elif fixture_name in FixtureChecker._invoked_with_func_args \ - and fixture_name in FixtureChecker._pytest_fixtures: - if _is_same_module(fixtures=FixtureChecker._pytest_fixtures, - import_node=node, - fixture_name=fixture_name): + elif ( + fixture_name in FixtureChecker._invoked_with_func_args + and fixture_name in FixtureChecker._pytest_fixtures + ): + if _is_same_module( + fixtures=FixtureChecker._pytest_fixtures, + import_node=node, + fixture_name=fixture_name, + ): return # fixture is referenced in @pytest.mark.usefixtures - elif fixture_name in FixtureChecker._invoked_with_usefixtures \ - and fixture_name in FixtureChecker._pytest_fixtures: - if _is_same_module(fixtures=FixtureChecker._pytest_fixtures, - import_node=node, - fixture_name=fixture_name): + elif ( + fixture_name in FixtureChecker._invoked_with_usefixtures + and fixture_name in FixtureChecker._pytest_fixtures + ): + if _is_same_module( + fixtures=FixtureChecker._pytest_fixtures, + import_node=node, + fixture_name=fixture_name, + ): return # check W0613 unused-argument - if msgid == 'unused-argument' and \ - _can_use_fixture(node.parent.parent) and \ - isinstance(node.parent, astroid.Arguments) and \ - node.name in FixtureChecker._pytest_fixtures: + if ( + msgid == "unused-argument" + and _can_use_fixture(node.parent.parent) + and isinstance(node.parent, astroid.Arguments) + and node.name in FixtureChecker._pytest_fixtures + ): return # check W0621 redefined-outer-name - if msgid == 'redefined-outer-name' and \ - _can_use_fixture(node.parent.parent) and \ - isinstance(node.parent, astroid.Arguments) and \ - node.name in FixtureChecker._pytest_fixtures: + if ( + msgid == "redefined-outer-name" + and _can_use_fixture(node.parent.parent) + and isinstance(node.parent, astroid.Arguments) + and node.name in FixtureChecker._pytest_fixtures + ): return - if int(pylint.__version__.split('.')[0]) >= 2: + if int(pylint.__version__.split(".")[0]) >= 2: FixtureChecker._original_add_message( - self, msgid, line, node, args, confidence, col_offset) + self, msgid, line, node, args, confidence, col_offset + ) else: # python2 + pylint1.9 backward compatibility - FixtureChecker._original_add_message( - self, msgid, line, node, args, confidence) + FixtureChecker._original_add_message(self, msgid, line, node, args, confidence) diff --git a/pylint_pytest/checkers/types.py b/pylint_pytest/checkers/types.py new file mode 100644 index 0000000..c4c43e8 --- /dev/null +++ b/pylint_pytest/checkers/types.py @@ -0,0 +1,13 @@ +import sys +from pprint import pprint +from typing import Any, Dict, List + +from _pytest.fixtures import FixtureDef + +FixtureDict = Dict[str, List[FixtureDef[Any]]] + + +def replacement_add_message(*args, **kwargs): + print("Called un-initialized _original_add_message with:", file=sys.stderr) + pprint(args, sys.stderr) + pprint(kwargs, sys.stderr) diff --git a/pylint_pytest/utils.py b/pylint_pytest/utils.py index 7dac65f..c9fa02f 100644 --- a/pylint_pytest/utils.py +++ b/pylint_pytest/utils.py @@ -1,14 +1,17 @@ import inspect + import astroid def _is_pytest_mark_usefixtures(decorator): # expecting @pytest.mark.usefixture(...) try: - if isinstance(decorator, astroid.Call) and \ - decorator.func.attrname == 'usefixtures' and \ - decorator.func.expr.attrname == 'mark' and \ - decorator.func.expr.expr.name == 'pytest': + if ( + isinstance(decorator, astroid.Call) + and decorator.func.attrname == "usefixtures" + and decorator.func.expr.attrname == "mark" + and decorator.func.expr.expr.name == "pytest" + ): return True except AttributeError: pass @@ -20,7 +23,7 @@ def _is_pytest_mark(decorator): deco = decorator # as attribute `@pytest.mark.trylast` if isinstance(decorator, astroid.Call): deco = decorator.func # as function `@pytest.mark.skipif(...)` - if deco.expr.attrname == 'mark' and deco.expr.expr.name == 'pytest': + if deco.expr.attrname == "mark" and deco.expr.expr.name == "pytest": return True except AttributeError: pass @@ -28,27 +31,46 @@ def _is_pytest_mark(decorator): def _is_pytest_fixture(decorator, fixture=True, yield_fixture=True): - attr = None to_check = set() if fixture: - to_check.add('fixture') + to_check.add("fixture") if yield_fixture: - to_check.add('yield_fixture') + to_check.add("yield_fixture") + + def _check_attribute(attr): + """ + handle astroid.Attribute, i.e., when the fixture function is + used by importing the pytest module + """ + return attr.attrname in to_check and attr.expr.name == "pytest" + + def _check_name(name_): + """ + handle astroid.Name, i.e., when the fixture function is + directly imported + """ + function_name = name_.name + module = decorator.root().globals.get(function_name, [None])[0] + module_name = module.modname if module else None + return function_name in to_check and module_name == "pytest" try: + if isinstance(decorator, astroid.Name): + # expecting @fixture + return _check_name(decorator) if isinstance(decorator, astroid.Attribute): # expecting @pytest.fixture - attr = decorator - + return _check_attribute(decorator) if isinstance(decorator, astroid.Call): + func = decorator.func + if isinstance(func, astroid.Name): + # expecting @fixture(scope=...) + return _check_name(func) # expecting @pytest.fixture(scope=...) - attr = decorator.func + return _check_attribute(func) - if attr and attr.attrname in to_check \ - and attr.expr.name == 'pytest': - return True except AttributeError: pass @@ -61,15 +83,17 @@ def _is_class_autouse_fixture(function): if isinstance(decorator, astroid.Call): func = decorator.func - if func and func.attrname in ('fixture', 'yield_fixture') \ - and func.expr.name == 'pytest': - + if ( + func + and func.attrname in ("fixture", "yield_fixture") + and func.expr.name == "pytest" + ): is_class = is_autouse = False for kwarg in decorator.keywords or []: - if kwarg.arg == 'scope' and kwarg.value.value == 'class': + if kwarg.arg == "scope" and kwarg.value.value == "class": is_class = True - if kwarg.arg == 'autouse' and kwarg.value.value is True: + if kwarg.arg == "autouse" and kwarg.value.value is True: is_autouse = True if is_class and is_autouse: @@ -82,9 +106,8 @@ def _is_class_autouse_fixture(function): def _can_use_fixture(function): if isinstance(function, astroid.FunctionDef): - # test_*, *_test - if function.name.startswith('test_') or function.name.endswith('_test'): + if function.name.startswith("test_") or function.name.endswith("_test"): return True if function.decorators: @@ -101,15 +124,16 @@ def _can_use_fixture(function): def _is_same_module(fixtures, import_node, fixture_name): - '''Comparing pytest fixture node with astroid.ImportFrom''' + """Comparing pytest fixture node with astroid.ImportFrom""" try: for fixture in fixtures[fixture_name]: for import_from in import_node.root().globals[fixture_name]: - if inspect.getmodule(fixture.func).__file__ == \ - import_from.parent.import_module(import_from.modname, - False, - import_from.level).file: + module = inspect.getmodule(fixture.func) + parent_import = import_from.parent.import_module( + import_from.modname, False, import_from.level + ) + if module is not None and module.__file__ == parent_import.file: return True - except: # pylint: disable=bare-except + except Exception: # pylint: disable=broad-except pass return False diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9f4abc8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,177 @@ +# Only a configuration storage, for now + +[tool.black] +line-length = 100 + +[tool.coverage] +run.branch = true +run.data_file = "test_artifacts/.coverage" +xml.output = "test_artifacts/cobertura.xml" +html.directory = "test_artifacts/htmlcov" +report.exclude_lines = [ + # Have to re-enable the standard pragma + 'pragma: no cover', + # Don't complain about missing debug-only code: + 'def __repr__', + 'if self\.debug', + 'if settings.DEBUG', + # Don't complain if tests don't hit defensive assertion code: + 'raise AssertionError', + 'raise NotImplementedError', + # Don't complain if non-runnable code isn't run: + 'if 0:', + 'if __name__ == .__main__.:', + # Don't complain about abstract methods, they aren't run: + '@(abc\.)?abstractmethod', + 'class .*\bProtocol\):', + ## Defaults must be re-listed; we cannot `extend_exclude_lines` + + # Ignore type-checking blocks + 'if TYPE_CHECKING:', + # Defensive programming does not need to be covered + 'raise UnreachableCodeException', +] +paths.source = [ + "pylint_pytest/", +] + +[tool.mypy] +python_version = "3.7" +check_untyped_defs = true +explicit_package_bases = true +namespace_packages = true +show_error_codes = true +strict_optional = true +warn_redundant_casts = true +warn_unreachable = true +warn_unused_configs = true +warn_unused_ignores = true +exclude = [ + "^.venv", # Ignore installed packages + "^.tox", # Ignore tox virtualenvs + "^.cache", # Ignore CI-defined .cache + "^tests/input/" # Ignore test inputs +] + +[[tool.mypy.overrides]] +module = [ + "astroid", + "pylint.*", + "setuptools", +] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = [ + "tests.*", +] +check_untyped_defs = true + +[tool.pytest.ini_options] +addopts = "--verbose --cov-config=pyproject.toml" + +[tool.ruff] +# ruff is less lenient than pylint and does not make any exceptions +# (for docstrings, strings and comments in particular). +line-length = 100 + +select = [ + "E", # pycodestyle + "F", # pyflakes + "W", # pycodestyle + "B", # bugbear + "I", # isort + "RUF", # ruff + "UP", # pyupgrade +] + +ignore = [ + "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` +] + +# py36, but ruff does not support it :/ +target-version = "py37" + +[tool.ruff.pydocstyle] +convention = "google" + +[tool.ruff.extend-per-file-ignores] +"tests/**/test_*.py" = [ + "S101", # pytest works with `assert`s + "FBT", # Don't care about booleans as positional arguments in tests, e.g. via @pytest.mark.parametrize() + # The below are debateable + "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes + "PLR2004", # Magic value used in comparison +] +"tests/**/*_test.py" = [ + "S101", # pytest works with `assert`s + "FBT", # Don't care about booleans as positional arguments in tests, e.g. via @pytest.mark.parametrize() + # The below are debateable + "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes + "PLR2004", # Magic value used in comparison +] + +[tool.pylint] + +py-version = "3.6" + +ignore-paths="tests/input" # Ignore test inputs + +load-plugins= [ + "pylint_pytest", + "pylint.extensions.bad_builtin", + "pylint.extensions.broad_try_clause", + "pylint.extensions.check_elif", + "pylint.extensions.code_style", + "pylint.extensions.comparetozero", + "pylint.extensions.comparison_placement", + "pylint.extensions.confusing_elif", + # "pylint.extensions.consider_ternary_expression", # Not a pretty refactoring + "pylint.extensions.docparams", + "pylint.extensions.docstyle", + "pylint.extensions.emptystring", + "pylint.extensions.eq_without_hash", + "pylint.extensions.for_any_all", + "pylint.extensions.mccabe", + "pylint.extensions.no_self_use", + "pylint.extensions.overlapping_exceptions", + "pylint.extensions.redefined_loop_name", + "pylint.extensions.redefined_variable_type", + "pylint.extensions.typing", + # "pylint.extensions.while_used", # highly opinionated + "pylint.extensions.dict_init_mutate", + "pylint.extensions.dunder", + "pylint.extensions.typing", + # "pylint.extensions.magic_value", # highly opinionated +] +disable=[ + "docstring-first-line-empty", # C0199; not-an-issue + + # Temporary disables + "cannot-enumerate-pytest-fixtures", # ToDo: Our own message, fix first + "fixme", # needs-work, and probably regex + "attribute-defined-outside-init", + "confusing-consecutive-elif", + "duplicate-code", + "missing-docstring", + "redefined-loop-name", + "too-complex", + "too-many-arguments", + "too-many-nested-blocks", + "too-many-try-statements", + "unspecified-encoding", + "use-maxsplit-arg", + "used-before-assignment", +] + +[tool.pylint.design] +max-args = 7 + +[tool.pylint.reports] +output-format = "colorized" + +[tool.pylint.variables] +ignored-argument-names = "_.*" + +[tool.pylint."messages control"] +enable = ["useless-suppression"] diff --git a/setup.cfg b/setup.cfg index 2a2ef5b..31ad82b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,9 +1,2 @@ [aliases] test = pytest - -[tool:pytest] -addopts = --verbose -python_files = tests/test_*.py - -[bdist_wheel] -universal = 1 diff --git a/setup.py b/setup.py index ae9e869..f30541f 100644 --- a/setup.py +++ b/setup.py @@ -1,48 +1,54 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- from os import path -from setuptools import setup, find_packages +from setuptools import find_packages, setup here = path.abspath(path.dirname(__file__)) -with open(path.join(here, 'README.md')) as fin: +with open(path.join(here, "README.md")) as fin: long_description = fin.read() setup( - name='pylint-pytest', - version='1.1.2', - author='Reverb Chu', - author_email='pylint-pytest@reverbc.tw', - maintainer='Reverb Chu', - maintainer_email='pylint-pytest@reverbc.tw', - license='MIT', - url='https://github.com/reverbc/pylint-pytest', - description='A Pylint plugin to suppress pytest-related false positives.', + name="pylint-pytest", + version="1.1.3", + author="Stavros Ntentos", + author_email="133706+stdedos@users.noreply.github.com", + license="MIT", + url="https://github.com/pylint-dev/pylint-pytest", + project_urls={ + "Changelog": "https://github.com/pylint-dev/pylint-pytest/blob/master/CHANGELOG.md", + "Documentation": "https://github.com/pylint-dev/pylint-pytest#readme", + "Say Thanks!": "https://saythanks.io/to/stdedos", + "Source": "https://github.com/pylint-dev/pylint-pytest", + "Tracker": "https://github.com/pylint-dev/pylint-pytest/issues", + }, + description="A Pylint plugin to suppress pytest-related false positives.", long_description=long_description, - long_description_content_type='text/markdown', - packages=find_packages(exclude=['tests', 'sandbox']), + long_description_content_type="text/markdown", + packages=find_packages(exclude=["tests*", "sandbox"]), install_requires=[ - 'pylint', - 'pytest>=4.6', + "pylint<3", + "pytest>=4.6", ], - python_requires='>=3.6', + python_requires=">=3.6", classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Intended Audience :: Developers', - 'Topic :: Software Development :: Testing', - 'Topic :: Software Development :: Quality Assurance', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: Implementation :: CPython', - 'Operating System :: OS Independent', - 'License :: OSI Approved :: MIT License', + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Topic :: Software Development :: Testing", + "Topic :: Software Development :: Quality Assurance", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "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 :: Implementation :: CPython", + "Operating System :: OS Independent", + "License :: OSI Approved :: MIT License", ], - tests_require=['pytest', 'pylint'], - keywords=['pylint', 'pytest', 'plugin'], + tests_require=["pytest", "pytest-cov", "pylint"], + keywords=["pylint", "pytest", "plugin"], ) diff --git a/tests/base_tester.py b/tests/base_tester.py index cf8c178..12d2e63 100644 --- a/tests/base_tester.py +++ b/tests/base_tester.py @@ -1,59 +1,68 @@ -import sys import os +import sys +from abc import ABC from pprint import pprint +from typing import Any, Dict, List import astroid -from pylint.testutils import UnittestLinter +from pylint.testutils import MessageTest, UnittestLinter + try: from pylint.utils import ASTWalker except ImportError: # for pylint 1.9 from pylint.utils import PyLintASTWalker as ASTWalker + from pylint.checkers import BaseChecker import pylint_pytest.checkers.fixture -# XXX: allow all file name -pylint_pytest.checkers.fixture.FILE_NAME_PATTERNS = ('*', ) +# XXX: allow all file names +pylint_pytest.checkers.fixture.FILE_NAME_PATTERNS = ("*",) -class BasePytestTester(object): +class BasePytestTester(ABC): CHECKER_CLASS = BaseChecker - IMPACTED_CHECKER_CLASSES = [] - MSG_ID = None - MESSAGES = None - CONFIG = {} + IMPACTED_CHECKER_CLASSES: List[BaseChecker] = [] + MSG_ID: str + msgs: List[MessageTest] = [] + CONFIG: Dict[str, Any] = {} + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + if not hasattr(cls, "MSG_ID") or not isinstance(cls.MSG_ID, str) or not cls.MSG_ID: + raise TypeError("Subclasses must define a non-empty MSG_ID of type str") enable_plugin = True - def run_linter(self, enable_plugin, file_path=None): + def run_linter(self, enable_plugin): self.enable_plugin = enable_plugin - # pylint: disable=protected-access - if file_path is None: - module = sys._getframe(1).f_code.co_name.replace('test_', '', 1) - file_path = os.path.join( - os.getcwd(), 'tests', 'input', self.MSG_ID, module + '.py') + # pylint: disable-next=protected-access + target_test_file = sys._getframe(1).f_code.co_name.replace("test_", "", 1) + file_path = os.path.join( + os.getcwd(), "tests", "input", self.MSG_ID, target_test_file + ".py" + ) with open(file_path) as fin: content = fin.read() - module = astroid.parse(content, module_name=module) + module = astroid.parse(content, module_name=target_test_file) module.file = fin.name self.walk(module) # run all checkers - self.MESSAGES = self.linter.release_messages() + self.msgs = self.linter.release_messages() def verify_messages(self, msg_count, msg_id=None): msg_id = msg_id or self.MSG_ID matched_count = 0 - for message in self.MESSAGES: + for message in self.msgs: # only care about ID and count, not the content if message.msg_id == msg_id: matched_count += 1 - pprint(self.MESSAGES) - assert matched_count == msg_count, f'expecting {msg_count}, actual {matched_count}' + pprint(self.msgs) + assert matched_count == msg_count, f"expecting {msg_count}, actual {matched_count}" def setup_method(self): self.linter = UnittestLinter() diff --git a/tests/base_tester_test.py b/tests/base_tester_test.py new file mode 100644 index 0000000..4cf43ff --- /dev/null +++ b/tests/base_tester_test.py @@ -0,0 +1,28 @@ +import pytest +from base_tester import BasePytestTester + +# pylint: disable=unused-variable + + +def test_init_subclass_valid_msg_id(): + some_string = "some_string" + + class ValidSubclass(BasePytestTester): + MSG_ID = some_string + + assert ValidSubclass.MSG_ID == some_string + + +def test_init_subclass_no_msg_id(): + with pytest.raises(TypeError): + + class NoMsgIDSubclass(BasePytestTester): + pass + + +@pytest.mark.parametrize("msg_id", [123, None, ""], ids=lambda x: f"msg_id={x}") +def test_init_subclass_invalid_msg_id_type(msg_id): + with pytest.raises(TypeError): + + class Subclass(BasePytestTester): + MSG_ID = msg_id diff --git a/tests/input/cannot-enumerate-pytest-fixtures/no_such_package.py b/tests/input/cannot-enumerate-pytest-fixtures/no_such_package.py index da18ef0..23cbe82 100644 --- a/tests/input/cannot-enumerate-pytest-fixtures/no_such_package.py +++ b/tests/input/cannot-enumerate-pytest-fixtures/no_such_package.py @@ -6,5 +6,6 @@ def fixture(): pass + def test_something(fixture): pass diff --git a/tests/input/conftest.py b/tests/input/conftest.py index 0965d19..6824751 100644 --- a/tests/input/conftest.py +++ b/tests/input/conftest.py @@ -6,6 +6,6 @@ def conftest_fixture_attr(): return True -@pytest.fixture(scope='function') +@pytest.fixture(scope="function") def conftest_fixture_func(): return True diff --git a/tests/input/deprecated-positional-argument-for-pytest-fixture/with_args_scope.py b/tests/input/deprecated-positional-argument-for-pytest-fixture/with_args_scope.py index 239a960..95fa0b6 100644 --- a/tests/input/deprecated-positional-argument-for-pytest-fixture/with_args_scope.py +++ b/tests/input/deprecated-positional-argument-for-pytest-fixture/with_args_scope.py @@ -1,6 +1,6 @@ import pytest -@pytest.fixture('function') +@pytest.fixture("function") def some_fixture(): - return 'ok' + return "ok" diff --git a/tests/input/deprecated-positional-argument-for-pytest-fixture/with_kwargs_scope.py b/tests/input/deprecated-positional-argument-for-pytest-fixture/with_kwargs_scope.py index 57a510b..1ec1f5c 100644 --- a/tests/input/deprecated-positional-argument-for-pytest-fixture/with_kwargs_scope.py +++ b/tests/input/deprecated-positional-argument-for-pytest-fixture/with_kwargs_scope.py @@ -1,6 +1,6 @@ import pytest -@pytest.fixture(scope='function') +@pytest.fixture(scope="function") def some_fixture(): - return 'ok' + return "ok" diff --git a/tests/input/deprecated-positional-argument-for-pytest-fixture/without_scope.py b/tests/input/deprecated-positional-argument-for-pytest-fixture/without_scope.py index 82187f0..60d3841 100644 --- a/tests/input/deprecated-positional-argument-for-pytest-fixture/without_scope.py +++ b/tests/input/deprecated-positional-argument-for-pytest-fixture/without_scope.py @@ -3,4 +3,4 @@ @pytest.fixture def some_fixture(): - return 'ok' + return "ok" diff --git a/tests/input/deprecated-pytest-yield-fixture/func.py b/tests/input/deprecated-pytest-yield-fixture/func.py index 8f5867d..9d853bc 100644 --- a/tests/input/deprecated-pytest-yield-fixture/func.py +++ b/tests/input/deprecated-pytest-yield-fixture/func.py @@ -6,6 +6,6 @@ def yield_fixture(): yield -@pytest.yield_fixture(scope='session') +@pytest.yield_fixture(scope="session") def yield_fixture_session(): yield diff --git a/tests/input/no-member/assign_attr_of_attr.py b/tests/input/no-member/assign_attr_of_attr.py index 9b2ddc0..79c54fb 100644 --- a/tests/input/no-member/assign_attr_of_attr.py +++ b/tests/input/no-member/assign_attr_of_attr.py @@ -1,9 +1,9 @@ import pytest -class TestClass(object): +class TestClass: @staticmethod - @pytest.fixture(scope='class', autouse=True) + @pytest.fixture(scope="class", autouse=True) def setup_class(request): cls = request.cls cls.defined_in_setup_class = object() diff --git a/tests/input/no-member/fixture.py b/tests/input/no-member/fixture.py index 4293624..76fe6b1 100644 --- a/tests/input/no-member/fixture.py +++ b/tests/input/no-member/fixture.py @@ -1,9 +1,9 @@ import pytest -class TestClass(object): +class TestClass: @staticmethod - @pytest.fixture(scope='class', autouse=True) + @pytest.fixture(scope="class", autouse=True) def setup_class(request): cls = request.cls cls.defined_in_setup_class = 123 diff --git a/tests/input/no-member/from_unpack.py b/tests/input/no-member/from_unpack.py index 6132fbd..e88c635 100644 --- a/tests/input/no-member/from_unpack.py +++ b/tests/input/no-member/from_unpack.py @@ -5,9 +5,9 @@ def meh(): return True, False -class TestClass(object): +class TestClass: @staticmethod - @pytest.fixture(scope='class', autouse=True) + @pytest.fixture(scope="class", autouse=True) def setup_class(request): cls = request.cls cls.defined_in_setup_class, _ = meh() diff --git a/tests/input/no-member/inheritance.py b/tests/input/no-member/inheritance.py index e4355f1..9e86916 100644 --- a/tests/input/no-member/inheritance.py +++ b/tests/input/no-member/inheritance.py @@ -1,9 +1,9 @@ import pytest -class TestClass(object): +class TestClass: @staticmethod - @pytest.fixture(scope='class', autouse=True) + @pytest.fixture(scope="class", autouse=True) def setup_class(request): cls = request.cls cls.defined_in_setup_class = 123 diff --git a/tests/input/no-member/not_using_cls.py b/tests/input/no-member/not_using_cls.py index b2ad9e6..95438af 100644 --- a/tests/input/no-member/not_using_cls.py +++ b/tests/input/no-member/not_using_cls.py @@ -1,9 +1,9 @@ import pytest -class TestClass(object): +class TestClass: @staticmethod - @pytest.fixture(scope='class', autouse=True) + @pytest.fixture(scope="class", autouse=True) def setup_class(request): clls = request.cls clls.defined_in_setup_class = 123 diff --git a/tests/input/no-member/yield_fixture.py b/tests/input/no-member/yield_fixture.py index 3bc5deb..c2f823b 100644 --- a/tests/input/no-member/yield_fixture.py +++ b/tests/input/no-member/yield_fixture.py @@ -1,9 +1,9 @@ import pytest -class TestClass(object): +class TestClass: @staticmethod - @pytest.yield_fixture(scope='class', autouse=True) + @pytest.yield_fixture(scope="class", autouse=True) def setup_class(request): cls = request.cls cls.defined_in_setup_class = 123 diff --git a/tests/input/redefined-outer-name/direct_import.py b/tests/input/redefined-outer-name/direct_import.py new file mode 100644 index 0000000..d926094 --- /dev/null +++ b/tests/input/redefined-outer-name/direct_import.py @@ -0,0 +1,28 @@ +from pytest import fixture + + +@fixture +def simple_decorator(): + """the fixture using the decorator without executing it""" + + +def test_simple_fixture(simple_decorator): + assert True + + +@fixture() +def un_configured_decorated(): + """the decorated is called without argument, like scope""" + + +def test_un_configured_decorated(un_configured_decorated): + assert True + + +@fixture(scope="function") +def configured_decorated(): + """the decorated is called with argument, like scope""" + + +def test_un_configured_decorated(configured_decorated): + assert True diff --git a/tests/input/unused-import/same_name_decorator.py b/tests/input/unused-import/same_name_decorator.py index 322dc08..cc32ed6 100644 --- a/tests/input/unused-import/same_name_decorator.py +++ b/tests/input/unused-import/same_name_decorator.py @@ -1,8 +1,9 @@ import pytest + # an actual unused import, just happened to have the same name as fixture from _same_name_module import conftest_fixture_attr -@pytest.mark.usefixtures('conftest_fixture_attr') +@pytest.mark.usefixtures("conftest_fixture_attr") def test_conftest_fixture_attr(): assert True diff --git a/tests/input/useless-pytest-mark-decorator/not_pytest_marker.py b/tests/input/useless-pytest-mark-decorator/not_pytest_marker.py index 0efad86..595601d 100644 --- a/tests/input/useless-pytest-mark-decorator/not_pytest_marker.py +++ b/tests/input/useless-pytest-mark-decorator/not_pytest_marker.py @@ -6,14 +6,11 @@ def noop(func): @functools.wraps(func) def wrapper_noop(*args, **kwargs): return func(*args, **kwargs) + return wrapper_noop -PYTEST = SimpleNamespace( - MARK=SimpleNamespace( - noop=noop - ) -) +PYTEST = SimpleNamespace(MARK=SimpleNamespace(noop=noop)) @noop diff --git a/tests/input/useless-pytest-mark-decorator/other_marks_using_for_fixture.py b/tests/input/useless-pytest-mark-decorator/other_marks_using_for_fixture.py index b269f61..96e9ed1 100644 --- a/tests/input/useless-pytest-mark-decorator/other_marks_using_for_fixture.py +++ b/tests/input/useless-pytest-mark-decorator/other_marks_using_for_fixture.py @@ -1,4 +1,5 @@ import os + import pytest @@ -11,7 +12,7 @@ def fixture(): @pytest.mark.parametrize("id", range(2)) @pytest.fixture def fixture_with_params(id): - return "{} not OK".format(id) + return f"{id} not OK" @pytest.mark.custom_mark diff --git a/tests/test_cannot_enumerate_fixtures.py b/tests/test_cannot_enumerate_fixtures.py index c931359..362ab2a 100644 --- a/tests/test_cannot_enumerate_fixtures.py +++ b/tests/test_cannot_enumerate_fixtures.py @@ -1,19 +1,53 @@ +import re + import pytest -from pylint.checkers.variables import VariablesChecker from base_tester import BasePytestTester + from pylint_pytest.checkers.fixture import FixtureChecker class TestCannotEnumerateFixtures(BasePytestTester): CHECKER_CLASS = FixtureChecker - MSG_ID = 'cannot-enumerate-pytest-fixtures' + MSG_ID = "cannot-enumerate-pytest-fixtures" - @pytest.mark.parametrize('enable_plugin', [True, False]) + @pytest.mark.parametrize("enable_plugin", [True, False]) def test_no_such_package(self, enable_plugin): self.run_linter(enable_plugin) self.verify_messages(1 if enable_plugin else 0) - @pytest.mark.parametrize('enable_plugin', [True, False]) + if enable_plugin: + msg = self.msgs[0] + + # Asserts/Fixes duplicate filenames in output: + # https://github.com/reverbc/pylint-pytest/pull/22/files#r698204470 + filename_arg = msg.args[0] + assert len(re.findall(r"\.py", filename_arg)) == 1 + + # Asserts that path is relative (usually to the root of the repository). + assert filename_arg[0] != "/" + + # Assert `stdout` is non-empty. + assert msg.args[1] + # Assert `stderr` is empty (pytest runs stably, even though fixture collection fails). + assert not msg.args[2] + + @pytest.mark.parametrize("enable_plugin", [True, False]) def test_import_corrupted_module(self, enable_plugin): self.run_linter(enable_plugin) self.verify_messages(1 if enable_plugin else 0) + + if enable_plugin: + msg = self.msgs[0] + + # ... somehow, since `import_corrupted_module.py` imports `no_such_package.py` + # both of their names are returned in the message. + filename_arg = msg.args[0] + assert len(re.findall(r"\.py", filename_arg)) == 2 + + # Asserts that paths are relative (usually to the root of the repository). + assert not [x for x in filename_arg.split(" ") if x[0] == "/"] + + # Assert `stdout` is non-empty. + assert msg.args[1] + # Assert `stderr` is empty (pytest runs stably, even though fixture collection fails). + assert not msg.args[2] diff --git a/tests/test_no_member.py b/tests/test_no_member.py index 4c7bad7..9d9019f 100644 --- a/tests/test_no_member.py +++ b/tests/test_no_member.py @@ -1,40 +1,41 @@ import pytest +from base_tester import BasePytestTester from pylint.checkers.typecheck import TypeChecker + from pylint_pytest.checkers.class_attr_loader import ClassAttrLoader -from base_tester import BasePytestTester class TestNoMember(BasePytestTester): CHECKER_CLASS = ClassAttrLoader IMPACTED_CHECKER_CLASSES = [TypeChecker] - MSG_ID = 'no-member' + MSG_ID = "no-member" - @pytest.mark.parametrize('enable_plugin', [True, False]) + @pytest.mark.parametrize("enable_plugin", [True, False]) def test_fixture(self, enable_plugin): self.run_linter(enable_plugin) self.verify_messages(0 if enable_plugin else 1) - @pytest.mark.parametrize('enable_plugin', [True, False]) + @pytest.mark.parametrize("enable_plugin", [True, False]) def test_yield_fixture(self, enable_plugin): self.run_linter(enable_plugin) self.verify_messages(0 if enable_plugin else 1) - @pytest.mark.parametrize('enable_plugin', [True, False]) + @pytest.mark.parametrize("enable_plugin", [True, False]) def test_not_using_cls(self, enable_plugin): self.run_linter(enable_plugin) self.verify_messages(0 if enable_plugin else 1) - @pytest.mark.parametrize('enable_plugin', [True, False]) + @pytest.mark.parametrize("enable_plugin", [True, False]) def test_inheritance(self, enable_plugin): self.run_linter(enable_plugin) self.verify_messages(0 if enable_plugin else 1) - @pytest.mark.parametrize('enable_plugin', [True, False]) + @pytest.mark.parametrize("enable_plugin", [True, False]) def test_from_unpack(self, enable_plugin): self.run_linter(enable_plugin) self.verify_messages(0 if enable_plugin else 1) - @pytest.mark.parametrize('enable_plugin', [True, False]) + @pytest.mark.parametrize("enable_plugin", [True, False]) def test_assign_attr_of_attr(self, enable_plugin): self.run_linter(enable_plugin) self.verify_messages(0 if enable_plugin else 1) diff --git a/tests/test_pytest_fixture_positional_arguments.py b/tests/test_pytest_fixture_positional_arguments.py index 6208d9f..3913c9d 100644 --- a/tests/test_pytest_fixture_positional_arguments.py +++ b/tests/test_pytest_fixture_positional_arguments.py @@ -1,10 +1,11 @@ from base_tester import BasePytestTester + from pylint_pytest.checkers.fixture import FixtureChecker class TestDeprecatedPytestFixtureScopeAsPositionalParam(BasePytestTester): CHECKER_CLASS = FixtureChecker - MSG_ID = 'deprecated-positional-argument-for-pytest-fixture' + MSG_ID = "deprecated-positional-argument-for-pytest-fixture" def test_with_args_scope(self): self.run_linter(enable_plugin=True) diff --git a/tests/test_pytest_mark_for_fixtures.py b/tests/test_pytest_mark_for_fixtures.py index 2854126..df5f747 100644 --- a/tests/test_pytest_mark_for_fixtures.py +++ b/tests/test_pytest_mark_for_fixtures.py @@ -1,10 +1,11 @@ from base_tester import BasePytestTester + from pylint_pytest.checkers.fixture import FixtureChecker class TestPytestMarkUsefixtures(BasePytestTester): CHECKER_CLASS = FixtureChecker - MSG_ID = 'useless-pytest-mark-decorator' + MSG_ID = "useless-pytest-mark-decorator" def test_mark_usefixture_using_for_test(self): self.run_linter(enable_plugin=True) diff --git a/tests/test_pytest_yield_fixture.py b/tests/test_pytest_yield_fixture.py index 0102b89..359dfb8 100644 --- a/tests/test_pytest_yield_fixture.py +++ b/tests/test_pytest_yield_fixture.py @@ -1,11 +1,11 @@ from base_tester import BasePytestTester + from pylint_pytest.checkers.fixture import FixtureChecker class TestDeprecatedPytestYieldFixture(BasePytestTester): CHECKER_CLASS = FixtureChecker - IMPACTED_CHECKER_CLASSES = [] - MSG_ID = 'deprecated-pytest-yield-fixture' + MSG_ID = "deprecated-pytest-yield-fixture" def test_smoke(self): self.run_linter(enable_plugin=True) diff --git a/tests/test_redefined_outer_name.py b/tests/test_redefined_outer_name.py index 2de88b4..ec0beef 100644 --- a/tests/test_redefined_outer_name.py +++ b/tests/test_redefined_outer_name.py @@ -1,30 +1,37 @@ import pytest -from pylint.checkers.variables import VariablesChecker from base_tester import BasePytestTester +from pylint.checkers.variables import VariablesChecker + from pylint_pytest.checkers.fixture import FixtureChecker class TestRedefinedOuterName(BasePytestTester): CHECKER_CLASS = FixtureChecker IMPACTED_CHECKER_CLASSES = [VariablesChecker] - MSG_ID = 'redefined-outer-name' + MSG_ID = "redefined-outer-name" - @pytest.mark.parametrize('enable_plugin', [True, False]) + @pytest.mark.parametrize("enable_plugin", [True, False]) def test_smoke(self, enable_plugin): self.run_linter(enable_plugin) self.verify_messages(0 if enable_plugin else 1) - @pytest.mark.parametrize('enable_plugin', [True, False]) + @pytest.mark.parametrize("enable_plugin", [True, False]) def test_caller_yield_fixture(self, enable_plugin): self.run_linter(enable_plugin) self.verify_messages(0 if enable_plugin else 1) - @pytest.mark.parametrize('enable_plugin', [True, False]) + @pytest.mark.parametrize("enable_plugin", [True, False]) def test_caller_not_a_test_func(self, enable_plugin): self.run_linter(enable_plugin) self.verify_messages(1) - @pytest.mark.parametrize('enable_plugin', [True, False]) + @pytest.mark.parametrize("enable_plugin", [True, False]) def test_args_and_kwargs(self, enable_plugin): self.run_linter(enable_plugin) self.verify_messages(2) + + @pytest.mark.parametrize("enable_plugin", [True, False]) + def test_direct_import(self, enable_plugin): + """the fixture method is directly imported""" + self.run_linter(enable_plugin) + self.verify_messages(0 if enable_plugin else 3) diff --git a/tests/test_regression.py b/tests/test_regression.py index 2cc9233..e53f999 100644 --- a/tests/test_regression.py +++ b/tests/test_regression.py @@ -1,24 +1,26 @@ import pylint import pytest -from pylint.checkers.variables import VariablesChecker from base_tester import BasePytestTester +from pylint.checkers.variables import VariablesChecker + from pylint_pytest.checkers.fixture import FixtureChecker class TestRegression(BasePytestTester): - '''Covering some behaviors that shouldn't get impacted by the plugin''' + """Covering some behaviors that shouldn't get impacted by the plugin""" + CHECKER_CLASS = FixtureChecker IMPACTED_CHECKER_CLASSES = [VariablesChecker] - MSG_ID = 'regression' + MSG_ID = "regression" - @pytest.mark.parametrize('enable_plugin', [True, False]) + @pytest.mark.parametrize("enable_plugin", [True, False]) def test_import_twice(self, enable_plugin): - '''catch a coding error when using fixture + if + inline import''' + """catch a coding error when using fixture + if + inline import""" self.run_linter(enable_plugin) - if int(pylint.__version__.split('.')[0]) < 2: + if int(pylint.__version__.split(".")[0]) < 2: # for some reason pylint 1.9.5 does not raise unused-import for inline import - self.verify_messages(1, msg_id='unused-import') + self.verify_messages(1, msg_id="unused-import") else: - self.verify_messages(2, msg_id='unused-import') - self.verify_messages(1, msg_id='redefined-outer-name') + self.verify_messages(2, msg_id="unused-import") + self.verify_messages(1, msg_id="redefined-outer-name") diff --git a/tests/test_unused_argument.py b/tests/test_unused_argument.py index 7ab2747..7ca2307 100644 --- a/tests/test_unused_argument.py +++ b/tests/test_unused_argument.py @@ -1,30 +1,31 @@ import pytest -from pylint.checkers.variables import VariablesChecker from base_tester import BasePytestTester +from pylint.checkers.variables import VariablesChecker + from pylint_pytest.checkers.fixture import FixtureChecker class TestUnusedArgument(BasePytestTester): CHECKER_CLASS = FixtureChecker IMPACTED_CHECKER_CLASSES = [VariablesChecker] - MSG_ID = 'unused-argument' + MSG_ID = "unused-argument" - @pytest.mark.parametrize('enable_plugin', [True, False]) + @pytest.mark.parametrize("enable_plugin", [True, False]) def test_smoke(self, enable_plugin): self.run_linter(enable_plugin) self.verify_messages(0 if enable_plugin else 2) - @pytest.mark.parametrize('enable_plugin', [True, False]) + @pytest.mark.parametrize("enable_plugin", [True, False]) def test_caller_yield_fixture(self, enable_plugin): self.run_linter(enable_plugin) self.verify_messages(0 if enable_plugin else 1) - @pytest.mark.parametrize('enable_plugin', [True, False]) + @pytest.mark.parametrize("enable_plugin", [True, False]) def test_caller_not_a_test_func(self, enable_plugin): self.run_linter(enable_plugin) self.verify_messages(1) - @pytest.mark.parametrize('enable_plugin', [True, False]) + @pytest.mark.parametrize("enable_plugin", [True, False]) def test_args_and_kwargs(self, enable_plugin): self.run_linter(enable_plugin) self.verify_messages(2) diff --git a/tests/test_unused_import.py b/tests/test_unused_import.py index 888e432..1f2bd4b 100644 --- a/tests/test_unused_import.py +++ b/tests/test_unused_import.py @@ -1,41 +1,42 @@ import pytest -from pylint.checkers.variables import VariablesChecker from base_tester import BasePytestTester +from pylint.checkers.variables import VariablesChecker + from pylint_pytest.checkers.fixture import FixtureChecker class TestUnusedImport(BasePytestTester): CHECKER_CLASS = FixtureChecker IMPACTED_CHECKER_CLASSES = [VariablesChecker] - MSG_ID = 'unused-import' + MSG_ID = "unused-import" - @pytest.mark.parametrize('enable_plugin', [True, False]) + @pytest.mark.parametrize("enable_plugin", [True, False]) def test_smoke(self, enable_plugin): self.run_linter(enable_plugin) self.verify_messages(0 if enable_plugin else 1) - @pytest.mark.parametrize('enable_plugin', [True, False]) + @pytest.mark.parametrize("enable_plugin", [True, False]) def test_caller_yield_fixture(self, enable_plugin): self.run_linter(enable_plugin) self.verify_messages(0 if enable_plugin else 1) - @pytest.mark.parametrize('enable_plugin', [True, False]) + @pytest.mark.parametrize("enable_plugin", [True, False]) def test_same_name_arg(self, enable_plugin): - '''an unused import (not a fixture) just happened to have the same - name as fixture - should still raise unused-import warning''' + """an unused import (not a fixture) just happened to have the same + name as fixture - should still raise unused-import warning""" self.run_linter(enable_plugin) self.verify_messages(1) - @pytest.mark.parametrize('enable_plugin', [True, False]) + @pytest.mark.parametrize("enable_plugin", [True, False]) def test_same_name_decorator(self, enable_plugin): - '''an unused import (not a fixture) just happened to have the same - name as fixture - should still raise unused-import warning''' + """an unused import (not a fixture) just happened to have the same + name as fixture - should still raise unused-import warning""" self.run_linter(enable_plugin) self.verify_messages(1) - @pytest.mark.parametrize('enable_plugin', [True, False]) + @pytest.mark.parametrize("enable_plugin", [True, False]) def test_conftest(self, enable_plugin): - '''fixtures are defined in different modules and imported to conftest - for pytest to do its magic''' + """fixtures are defined in different modules and imported to conftest + for pytest to do its magic""" self.run_linter(enable_plugin) self.verify_messages(0 if enable_plugin else 1) diff --git a/tox.ini b/tox.ini index a7cfb5d..09dd02e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,13 @@ [tox] -envlist = py36,py37,py38,py39 +envlist = py36,py37,py38,py39,py310,py311 skipsdist = True +passenv = + FORCE_COLOR [testenv] +deps = + pytest + pytest-cov commands = - pip install ./ --upgrade - pytest {posargs:tests} + pip install --upgrade --editable . + pytest --cov --cov-append {env:PYTEST_CI_ARGS:} {tty:--color=yes} {posargs:tests}