diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..31447bf --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +end_of_line = lf + +[Makefile] +indent_style = tab + +[{*.yaml, *.yml}] +indent_size = 2 diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..c0b7bdb --- /dev/null +++ b/.flake8 @@ -0,0 +1,29 @@ +[flake8] +filename = + ./scripts/*.py, + ./src/*.py, + ./tests/*.py +per-file-ignores = + scripts/*: D + tests/*: D + +# Google docstring convention + D204 & D401 +docstring-convention = all +ignore = + D100 + D104 + D203 + D213 + D215 + D406 + D407 + D408 + D409 + D413 + U101 + +max_line_length = 150 +unused-arguments-ignore-overload-functions = True +unused-arguments-ignore-stub-functions = True +pytest-fixture-no-parentheses = True +pytest-mark-no-parentheses = True diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml new file mode 100644 index 0000000..ce99339 --- /dev/null +++ b/.github/workflows/checks.yaml @@ -0,0 +1,15 @@ +name: Code quality checks + +on: + pull_request: + +jobs: + lint_and_type_checks: + name: Run lint and type checks + uses: ./.github/workflows/lint_and_type_checks.yaml + + unit_tests: + name: Run unit tests + needs: [lint_and_type_checks] + uses: ./.github/workflows/unit_tests.yaml + diff --git a/.github/workflows/lint_and_type_checks.yaml b/.github/workflows/lint_and_type_checks.yaml new file mode 100644 index 0000000..c2cc2e1 --- /dev/null +++ b/.github/workflows/lint_and_type_checks.yaml @@ -0,0 +1,30 @@ +name: Lint and type checks + +on: + workflow_call: + +jobs: + lint_and_type_checks: + name: Lint and type checks + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11"] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: make install-dev + + - name: Run lint + run: make lint + + - name: Run type checks + run: make type-check diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..7be7d35 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,108 @@ +name: Check & Release + +on: + # Push to master will publish a beta version + push: + branches: + - master + tags-ignore: + - "**" + # A release via GitHub releases will publish a stable version + release: + types: [published] + # Workflow dispatch will publish whatever you choose + workflow_dispatch: + inputs: + release_type: + description: Release type + required: true + type: choice + default: alpha + options: + - alpha + - beta + - final + +jobs: + lint_and_type_checks: + name: Run lint and type checks + uses: ./.github/workflows/lint_and_type_checks.yaml + + unit_tests: + name: Run unit tests + uses: ./.github/workflows/unit_tests.yaml + + publish_to_pypi: + name: Publish to PyPI + needs: [lint_and_type_checks, unit_tests] + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + environment: + name: pypi + url: https://pypi.org/p/flake8-no-pytest-mark-only + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: 3.8 + + - name: Install dependencies + run: make install-dev + + - # Determine if this is a prerelease or latest release + name: Determine release type + id: get-release-type + run: | + if [ ${{ github.event_name }} = release ]; then + release_type="final" + elif [ ${{ github.event_name }} = push ]; then + release_type="beta" + elif [ ${{ github.event_name }} = workflow_dispatch ]; then + release_type=${{ github.event.inputs.release_type }} + fi + + echo "release_type=${release_type}" >> $GITHUB_OUTPUT + + - # Check whether the released version is listed in CHANGELOG.md + name: Check whether the released version is listed in the changelog + if: steps.get-release-type.outputs.release_type != 'alpha' + run: make check-changelog-entry + + - # Check version consistency and increment pre-release version number for prereleases (must be the last step before build) + name: Bump pre-release version + if: steps.get-release-type.outputs.release_type != 'final' + run: python ./scripts/update_version_for_prerelease.py ${{ steps.get-release-type.outputs.release_type }} + + - # Build a source distribution and a python3-only wheel + name: Build distribution files + run: make build + + - # Check whether the package description will render correctly on PyPI + name: Check package rendering on PyPI + run: make twine-check + + - # Publish package to PyPI using their official GitHub action + name: Publish package to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + - # Tag the current commit with the version tag if this is not made from the release event (releases are tagged with the release process) + name: Tag Version + if: github.event_name != 'release' + run: | + git_tag=v`python ./scripts/print_current_package_version.py` + git tag $git_tag + git push origin $git_tag + + - # Upload the build artifacts to the release + name: Upload the build artifacts to release + if: github.event_name == 'release' + run: gh release upload ${{ github.ref_name }} dist/* + env: + GH_TOKEN: ${{ github.token }} + diff --git a/.github/workflows/unit_tests.yaml b/.github/workflows/unit_tests.yaml new file mode 100644 index 0000000..016b524 --- /dev/null +++ b/.github/workflows/unit_tests.yaml @@ -0,0 +1,28 @@ +name: Unit tests + +on: + workflow_call: + +jobs: + unit_tests: + name: Run unit tests + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ["3.8", "3.9", "3.10", "3.11"] + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: make install-dev + + - name: Run unit tests + run: make unit-tests diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb7a073 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +__pycache__ +.mypy_cache +.pytest_cache + +.venv +.direnv +.envrc +.python-version + +*.egg-info/ +*.egg +dist/ +build/ + +.vscode +.DS_Store diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..d984d16 --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,7 @@ +[isort] +include_trailing_comma = True +line_length = 150 +use_parentheses = True +multi_line_output = 3 +sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER +known_first_party = flake8_no_pytest_mark_only diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..4caf9f6 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,26 @@ +repos: + - repo: local + hooks: + - id: lint + name: Lint codebase + entry: make lint + language: system + pass_filenames: false + + - id: type-check + name: Type-check codebase + entry: make type-check + language: system + pass_filenames: false + + - id: unit-tests + name: Run unit tests + entry: make unit-tests + language: system + pass_filenames: false + + - id: check-changelog + name: Check whether current version is mentioned in changelog + entry: make check-changelog-entry + language: system + pass_filenames: false diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c4ece12 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +Changelog +========= + +[1.0.0](../../releases/tag/v1.0.0) - 2023-06-09 +----------------------------------------------- + +Initial release of the package. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a9e2112 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,46 @@ +# Development + +## Environment + +For local development, it is required to have Python 3.8 installed. + +It is recommended to set up a virtual environment while developing this package to isolate your development environment, +however, due to the many varied ways Python can be installed and virtual environments can be set up, +this is left up to the developers to do themselves. + +One recommended way is with the built-in `venv` module: + +```bash +python3 -m venv .venv +source .venv/bin/activate +``` + +To improve on the experience, you can use [pyenv](https://github.com/pyenv/pyenv) to have an environment with a pinned Python version, +and [direnv](https://github.com/direnv/direnv) to automatically activate/deactivate the environment when you enter/exit the project folder. + +## Dependencies + +To install this package and its development dependencies, run `make install-dev` + +## Formatting + +We use `autopep8` and `isort` to automatically format the code to a common format. To run the formatting, just run `make format`. + +## Linting, type-checking and unit testing + +We use `flake8` for linting, `mypy` for type checking and `pytest` for unit testing. To run these tools, just run `make check-code`. + +## Release process + +Publishing new versions to [PyPI](https://pypi.org/project/flake8-no-pytest-mark-only) happens automatically through GitHub Actions. + +On each commit to the `master` branch, a new beta release is published, taking the version number from `pyproject.toml` +and automatically incrementing the beta version suffix by 1 from the last beta release published to PyPI. + +A stable version is published when a new release is created using GitHub Releases, again taking the version number from `pyproject.toml`. +The built package assets are automatically uploaded to the GitHub release. + +If there is already a stable version with the same version number as in `pyproject.toml` published to PyPI, the publish process fails, +so don't forget to update the version number before releasing a new version. +The release process also fails when the released version is not described in `CHANGELOG.md`, +so don't forget to describe the changes in the new version there. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..80798d7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2023 František Nesveda + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..35828ae --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +.PHONY: clean install-dev build publish twine-check lint unit-tests type-check check-code format check-changelog-entry + +clean: + rm -rf build dist .mypy_cache .pytest_cache src/*.egg-info __pycache__ + +install-dev: + python -m pip install --upgrade pip setuptools + pip install --no-cache-dir -e ".[dev]" + pre-commit install + +build: + python -m build + +publish: + python -m twine upload dist/* + +twine-check: + python -m twine check dist/* + +lint: + python3 -m flake8 + +unit-tests: + python3 -m pytest -ra tests + +type-check: + python3 -m mypy + +check-code: lint type-check unit-tests + +format: + python3 -m isort scripts src tests + python3 -m autopep8 --in-place --recursive scripts src tests + +check-changelog-entry: + python3 scripts/check_version_in_changelog.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..23a326a --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +`flake8-no-pytest-mark-only` +============================ + +A [Flake8](https://flake8.pycqa.org/) plugin to check for the usage of the `@pytest.mark.only` decorator. + +Rationale +--------- + +The `@pytest.mark.only` decorator, coming from the [`pytest-only`](https://pypi.org/project/pytest-only/) plugin, is great. +It's super useful when developing your tests, so that you can run just the test (or set of tests) that you're working on, +without having to run your whole test suite. + +The only problem with it is that when you forget to remove it after you've finished developing your tests, +and you accidentally commit it to your repository, +it's hard to notice that only a subset of tests are running in CI instead of the whole test suite. + +This Flake8 plugin prevents that, by raising a lint error on usages of the `@pytest.mark.only` decorator, +so that you notice that you've committed changes with the decorator in them. + +Usage +----- + +Just install the `flake8-no-pytest-mark-only` package, and when you run Flake8, +it will automatically find the plugin and use it. + +If you want to override the reporting from this plugin, you can filter for the error code `PNO001`: + +```bash +# Turn on the errors from this plugin +flake8 --select PNO001 ... + +# Turn off the errors from this plugin +flake8 --extend-ignore PNO001 ... +``` diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..c57367f --- /dev/null +++ b/mypy.ini @@ -0,0 +1,16 @@ +[mypy] +python_version = 3.8 +files = + scripts, + src, + tests +check_untyped_defs = True +disallow_incomplete_defs = True +disallow_untyped_calls = True +disallow_untyped_decorators = True +disallow_untyped_defs = True +no_implicit_optional = True +warn_redundant_casts = True +warn_return_any = True +warn_unreachable = True +warn_unused_ignores = True diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bde046c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,71 @@ +[project] +name = "flake8-no-pytest-mark-only" +version = "1.0.0" +description = "Flake8 plugin to disallow the use of the pytest.mark.only decorator." +readme = "README.md" +license = {text = "Apache Software License"} +authors = [ + { name = "František Nesveda", email = "frantisek.nesveda@gmail.com" }, +] +keywords = ["flake8", "pytest", "mark", "only"] + +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Software Development :: Libraries", +] + +requires-python = ">=3.8" +dependencies = [ +] + +[project.optional-dependencies] +dev = [ + "autopep8 ~= 2.0.4", + "build ~= 1.0.0", + "flake8 ~= 6.1.0", + "flake8-bugbear ~= 23.7.10", + "flake8-commas ~= 2.1.0", + "flake8-comprehensions ~= 3.14.0", + "flake8-docstrings ~= 1.7.0", + "flake8-isort ~= 6.0.0", + "flake8-noqa ~= 1.3.2", + "flake8-pytest-style ~= 1.7.2", + "flake8-quotes ~= 3.3.2", + "flake8-simplify ~= 0.20.0", + "flake8-unused-arguments ~= 0.0.13", + "isort ~= 5.12.0", + "mypy ~= 1.5.1", + "pep8-naming ~= 0.13.3", + "pre-commit ~= 3.4.0", + "pytest ~= 7.4.1", + "pytest-only ~= 2.0.0", + "twine ~= 4.0.2", +] + +[project.entry-points."flake8.extension"] +PNO = "flake8_no_pytest_mark_only.plugin:Flake8NoPytestMarkOnly" + +[project.urls] +"Homepage" = "https://www.nesveda/com/projects/flake8-no-pytest-mark-only" +"Documentation" = "https://github.com/fnesveda/flake8-no-pytest-mark-only" +"Source" = "https://github.com/fnesveda/flake8-no-pytest-mark-only" +"Issue tracker" = "https://github.com/fnesveda/flake8-no-pytest-mark-only/issues" +"Changelog" = "https://github.com/fnesveda/flake8-no-pytest-mark-only/blob/master/CHANGELOG.md" + +[build-system] +requires = ["setuptools>=68.0.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["src"] +include = ["flake8_no_pytest_mark_only*"] + +[tool.setuptools.package-data] +flake8_no_pytest_mark_only = ["py.typed"] diff --git a/scripts/check_version_in_changelog.py b/scripts/check_version_in_changelog.py new file mode 100644 index 0000000..f72bee8 --- /dev/null +++ b/scripts/check_version_in_changelog.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 + +import re + +from utils import REPO_ROOT, get_current_package_version + +CHANGELOG_PATH = REPO_ROOT / 'CHANGELOG.md' + +# Checks whether the current package version has an entry in the CHANGELOG.md file +if __name__ == '__main__': + current_package_version = get_current_package_version() + + if not CHANGELOG_PATH.is_file(): + raise RuntimeError('Unable to find CHANGELOG.md file') + + with open(CHANGELOG_PATH, encoding='utf-8') as changelog_file: + for line in changelog_file: + # The heading for the changelog entry for the given version can start with either the version number, or the version number in a link + if re.match(fr'\[?{current_package_version}([\] ]|$)', line): + break + else: + raise RuntimeError(f'There is no entry in the changelog for the current package version ({current_package_version})') diff --git a/scripts/print_current_package_version.py b/scripts/print_current_package_version.py new file mode 100644 index 0000000..3c78a5b --- /dev/null +++ b/scripts/print_current_package_version.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python3 + +from utils import get_current_package_version + +# Print the current package version from the pyproject.toml file to stdout +if __name__ == '__main__': + print(get_current_package_version(), end='') diff --git a/scripts/update_version_for_prerelease.py b/scripts/update_version_for_prerelease.py new file mode 100644 index 0000000..d29fc31 --- /dev/null +++ b/scripts/update_version_for_prerelease.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 + +import json +import re +import sys +import urllib.request + +from utils import PACKAGE_NAME, get_current_package_version, set_current_package_version + +# Checks whether the current package version number was not already used in a published release, +# and if not, modifies the package version number in pyproject.toml +# from a stable release version (X.Y.Z) to a prerelease version (X.Y.ZbN or X.Y.Z.aN or X.Y.Z.rcN) +if __name__ == '__main__': + if len(sys.argv) != 2: + raise RuntimeError('You must pass the prerelease type as an argument to this script!') + + prerelease_type = sys.argv[1] + if prerelease_type not in ['alpha', 'beta', 'rc']: + raise RuntimeError(f'The prerelease type must be one of "alpha", "beta" or "rc", got "{prerelease_type}"!') + + if prerelease_type == 'alpha': + prerelease_prefix = 'a' + elif prerelease_type == 'beta': + prerelease_prefix = 'b' + elif prerelease_type == 'rc': + prerelease_prefix = 'rc' + + current_version = get_current_package_version() + + # We can only transform a stable release version (X.Y.Z) to a prerelease version (X.Y.ZxxxN) + if not re.match(r'^\d+\.\d+\.\d+$', current_version): + raise RuntimeError(f'The current version {current_version} does not match the proper semver format for stable releases (X.Y.Z)') + + # Load the version numbers of the currently published versions from PyPI + # If the URL returns 404, it means the package has no releases yet (which is okay in our case) + package_info_url = f'https://pypi.org/pypi/{PACKAGE_NAME}/json' + try: + conn = urllib.request.urlopen(package_info_url) + package_data = json.load(urllib.request.urlopen(package_info_url)) + published_versions = list(package_data['releases'].keys()) + except urllib.error.HTTPError as e: + if e.code != 404: + raise e + published_versions = [] + + # We don't want to publish a prerelease version with the same version number as an already released stable version + if current_version in published_versions: + raise RuntimeError(f'The current version {current_version} was already released!') + + # Find the highest prerelease version number that was already published + latest_prerelease = 0 + for version in published_versions: + if version.startswith(f'{current_version}{prerelease_prefix}'): + prerelease_version = int(version.split(prerelease_prefix)[1]) + if prerelease_version > latest_prerelease: + latest_prerelease = prerelease_version + + # Write the latest prerelease version number to pyproject.toml + new_prerelease_version_number = f'{current_version}{prerelease_prefix}{latest_prerelease + 1}' + set_current_package_version(new_prerelease_version_number) diff --git a/scripts/utils.py b/scripts/utils.py new file mode 100644 index 0000000..1dfce81 --- /dev/null +++ b/scripts/utils.py @@ -0,0 +1,38 @@ +import pathlib + +PACKAGE_NAME = 'flake8_no_pytest_mark_only' +REPO_ROOT = pathlib.Path(__file__).parent.resolve() / '..' +PYPROJECT_TOML_FILE_PATH = REPO_ROOT / 'pyproject.toml' + + +# Load the current version number from pyproject.toml +# It is on a line in the format `version = "1.2.3"` +def get_current_package_version() -> str: + with open(PYPROJECT_TOML_FILE_PATH, 'r', encoding='utf-8') as pyproject_toml_file: + for line in pyproject_toml_file: + if line.startswith('version = '): + delim = '"' if '"' in line else "'" + version = line.split(delim)[1] + return version + else: + raise RuntimeError('Unable to find version string.') + + +# Write the given version number from pyproject.toml +# It replaces the version number on the line with the format `version = "1.2.3"` +def set_current_package_version(version: str) -> None: + with open(PYPROJECT_TOML_FILE_PATH, 'r+', encoding='utf-8') as pyproject_toml_file: + updated_pyproject_toml_file_lines = [] + version_string_found = False + for line in pyproject_toml_file: + if line.startswith('version = '): + version_string_found = True + line = f'version = "{version}"' + updated_pyproject_toml_file_lines.append(line) + + if not version_string_found: + raise RuntimeError('Unable to find version string.') + + pyproject_toml_file.seek(0) + pyproject_toml_file.write('\n'.join(updated_pyproject_toml_file_lines)) + pyproject_toml_file.truncate() diff --git a/src/flake8_no_pytest_mark_only/__init__.py b/src/flake8_no_pytest_mark_only/__init__.py new file mode 100755 index 0000000..c571bc1 --- /dev/null +++ b/src/flake8_no_pytest_mark_only/__init__.py @@ -0,0 +1,7 @@ +from importlib import metadata + +from .plugin import Flake8NoPytestMarkOnly + +__all__ = ['Flake8NoPytestMarkOnly'] + +__version__ = metadata.version('flake8-no-pytest-mark-only') diff --git a/src/flake8_no_pytest_mark_only/plugin.py b/src/flake8_no_pytest_mark_only/plugin.py new file mode 100755 index 0000000..ed5c460 --- /dev/null +++ b/src/flake8_no_pytest_mark_only/plugin.py @@ -0,0 +1,74 @@ +import ast +from typing import Iterable, List, NamedTuple, Optional, Tuple, Union + +Flake8Error = Tuple[int, int, str, 'Flake8NoPytestMarkOnly'] + + +class _CodePosition(NamedTuple): + line: int + column: int + + +def _get_qualname(node: ast.AST) -> Optional[str]: + parts: List[str] = [] + while True: + if isinstance(node, ast.Attribute): + parts.insert(0, node.attr) + node = node.value + elif isinstance(node, ast.Name): + parts.insert(0, node.id) + break + else: + return None + return '.'.join(parts) + + +class _MarksVisitor(ast.NodeVisitor): + pytest_mark_only_positions: List[_CodePosition] + + def __init__(self) -> None: + self.pytest_mark_only_positions = [] + + def _check_node(self, node: Union[ast.ClassDef, ast.AsyncFunctionDef, ast.FunctionDef]) -> None: + for decorator in node.decorator_list: + if isinstance(decorator, ast.Call): + decorator = decorator.func + if not isinstance(decorator, ast.Attribute): + continue + + if _get_qualname(decorator) == 'pytest.mark.only': + self.pytest_mark_only_positions.append(_CodePosition(decorator.lineno, decorator.col_offset + len('pytest.mark.'))) + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: # noqa: N802 + self._check_node(node) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: # noqa: N802 + self._check_node(node) + + def visit_ClassDef(self, node: ast.ClassDef) -> None: # noqa: N802 + # Check decorators on the class itself + self._check_node(node) + + # Visit all children of the class + self.generic_visit(node) + + +class Flake8NoPytestMarkOnly: + """Flake8 plugin to check for `@pytest.mark.only` decorators.""" + + def __init__(self, tree: ast.AST) -> None: + """Initialize the plugin.""" + self._tree: ast.AST = tree + + def run(self) -> Iterable[Flake8Error]: + """Run the plugin.""" + visitor = _MarksVisitor() + visitor.visit(self._tree) + + for position in visitor.pytest_mark_only_positions: + yield ( + position.line, + position.column, + 'PNO001 do not commit @pytest.mark.only', + self, + ) diff --git a/src/flake8_no_pytest_mark_only/py.typed b/src/flake8_no_pytest_mark_only/py.typed new file mode 100755 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/tests/test_PNO001.py b/tests/test_PNO001.py new file mode 100755 index 0000000..9d6f866 --- /dev/null +++ b/tests/test_PNO001.py @@ -0,0 +1,73 @@ +import ast +import textwrap + +from flake8_no_pytest_mark_only import Flake8NoPytestMarkOnly + + +def test_class_with_decorator() -> None: + """Test a class with a decorator.""" + src = """ + import pytest + + @pytest.mark.only + class TestClassWithDecorator: + @pytest.mark.only + class TestNestedClass: + pass + + @pytest.mark.only + def test_function_with_decorator(): + pass + + @pytest.mark.only + async def test_async_function_with_decorator(): + pass + + def test_function_without_decorator(): + pass + + class TestClassWithoutDecorator: + @pytest.mark.only + def test_function_with_decorator(): + pass + + def test_function_without_decorator(): + pass + + @pytest.mark.only + def test_function_with_decorator(): + pass + + @pytest.mark.only() + def test_function_with_decorator_as_function_call(): + pass + + @pytest.mark.other_mark + def test_function_with_different_decorator(): + pass + + def test_function_without_decorator(): + pass + + # test that non-decorator use is not detected + print(pytest.mark.only) + """ + src = textwrap.dedent(src) + tree = ast.parse(src) + plugin = Flake8NoPytestMarkOnly(tree) + errors = list(plugin.run()) + + # Find all positions of @pytest.mark.only decorators in the source + expected_errors = [] + for lineno, line in enumerate(src.splitlines(), start=1): + if '@pytest.mark.only' in line: + expected_errors.append((lineno, line.index('@pytest.mark.only') + len('@pytest.mark.'), 'PNO001 do not commit @pytest.mark.only', plugin)) + + # Check that the expected errors were found + assert len(errors) == len(expected_errors) + for error, expected_error in zip(errors, expected_errors): + try: + assert error == expected_error + finally: + print(error) + print(src.splitlines()[error[0] + 1])