From d86350c477c62f0231201873821a452f777d94fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Nesveda?= Date: Wed, 6 Sep 2023 15:37:37 +0200 Subject: [PATCH] Initial commit --- .editorconfig | 15 +++ .flake8 | 29 ++++++ .github/workflows/checks.yaml | 15 +++ .github/workflows/lint_and_type_checks.yaml | 30 ++++++ .github/workflows/release.yaml | 108 ++++++++++++++++++++ .github/workflows/unit_tests.yaml | 28 +++++ .gitignore | 16 +++ .isort.cfg | 7 ++ .pre-commit-config.yaml | 26 +++++ CHANGELOG.md | 7 ++ CONTRIBUTING.md | 46 +++++++++ LICENSE | 19 ++++ Makefile | 36 +++++++ README.md | 34 ++++++ mypy.ini | 16 +++ pyproject.toml | 71 +++++++++++++ scripts/check_version_in_changelog.py | 22 ++++ scripts/print_current_package_version.py | 7 ++ scripts/update_version_for_prerelease.py | 60 +++++++++++ scripts/utils.py | 38 +++++++ src/flake8_no_pytest_mark_only/__init__.py | 7 ++ src/flake8_no_pytest_mark_only/plugin.py | 74 ++++++++++++++ src/flake8_no_pytest_mark_only/py.typed | 0 tests/__init__.py | 0 tests/test_PNO001.py | 73 +++++++++++++ 25 files changed, 784 insertions(+) create mode 100644 .editorconfig create mode 100644 .flake8 create mode 100644 .github/workflows/checks.yaml create mode 100644 .github/workflows/lint_and_type_checks.yaml create mode 100644 .github/workflows/release.yaml create mode 100644 .github/workflows/unit_tests.yaml create mode 100644 .gitignore create mode 100644 .isort.cfg create mode 100644 .pre-commit-config.yaml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 mypy.ini create mode 100644 pyproject.toml create mode 100644 scripts/check_version_in_changelog.py create mode 100644 scripts/print_current_package_version.py create mode 100644 scripts/update_version_for_prerelease.py create mode 100644 scripts/utils.py create mode 100755 src/flake8_no_pytest_mark_only/__init__.py create mode 100755 src/flake8_no_pytest_mark_only/plugin.py create mode 100755 src/flake8_no_pytest_mark_only/py.typed create mode 100755 tests/__init__.py create mode 100755 tests/test_PNO001.py 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])