diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 48ab53c..cedabea 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7, 3.8, 3.9, "3.10", 3.11] + python-version: [3.8, 3.9, "3.10", 3.11, 3.12] steps: - uses: actions/checkout@v4 @@ -28,10 +28,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Set up Python 3.8 + - name: Set up Python 3.12 uses: actions/setup-python@v5 with: - python-version: '3.8' + python-version: '3.12' - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index 85fa6ad..0000000 --- a/.pylintrc +++ /dev/null @@ -1,76 +0,0 @@ -[MASTER] - -# A comma-separated list of package or module names from where C extensions may -# be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code -extension-pkg-whitelist=lxml,posix_ipc,spidev,netifaces - -# Add files or directories to the blacklist. They should be base names, not -# paths. -#ignore= - -# Add files or directories matching the regex patterns to the blacklist. The -# regex matches against base names, not paths. -#ignore-patterns= - -# List of plugins (as comma separated values of python module names) to load, -# usually to register additional checkers. -load-plugins= - pylint_strict_informational - - -[MESSAGES CONTROL] - -enable= - use-symbolic-message-instead, - useless-suppression - -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifiers separated by comma (,) or put this -# option multiple times (only on the command line, not in the configuration -# file where it should appear only once).You can also use "--disable=all" to -# disable everything first and then reenable specific checks. For example, if -# you want to run only the similarities checker, you can use "--disable=all -# --enable=similarities". If you want to run only the classes checker, but have -# no Warning level messages displayed, use"--disable=all --enable=classes -# --disable=W" -disable= - arguments-differ, - attribute-defined-outside-init, - cyclic-import, - duplicate-code, - fixme, - file-ignored, - import-outside-toplevel, - invalid-name, - locally-disabled, - missing-docstring, - no-member, - protected-access, - superfluous-parens, - too-few-public-methods, - too-many-ancestors, - too-many-arguments, - too-many-branches, - too-many-instance-attributes, - too-many-lines, - too-many-locals, - too-many-public-methods, - too-many-statements, - ungrouped-imports, - unused-argument, - useless-return, - wrong-import-order # we have a custom isort config which pylint can't grok - - -[REPORTS] - -# Template used to display messages. This is a python new-style format string -# used to format the message information. See doc for all details -msg-template="{path}:{line}: [{msg_id}({symbol}), {obj}] {msg}" - - -[FORMAT] - -# Maximum number of characters on a single line. -max-line-length=120 diff --git a/CHANGES.rst b/CHANGES.rst index 42216c4..847f484 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,13 @@ 1.0.0 (unreleased) ------------------ +- Add support of Python 3.12. + +- |backward-incompatible| Remove support of Git 2.18 and earlier + version. You must now have Git 2.19 (or a more recent version). + +- |backward-incompatible| Remove support of Python 3.7. + - |backward-incompatible| Remove ``--xunit-file`` argument from all commands. It can be replaced by a new ``--format=xunit`` argument and redirecting the standard output to a file, like this:: diff --git a/README.rst b/README.rst index 84e421a..c481fdf 100644 --- a/README.rst +++ b/README.rst @@ -63,9 +63,7 @@ could be extended to work with other code hosting platforms. Requirements and installation ============================= -You must have Python 3.7 or later, and a relatively recent version of -Git. Git 2.1.4 (shipped with Debian Jessie) is known to work. More -recent versions should work and are supported. +You must have Python 3.8 or later, and Git 2.19.0 or later. Install with ``pip``, preferably in a virtual environment: @@ -74,7 +72,7 @@ Install with ``pip``, preferably in a virtual environment: $ python3 -m venv /path/to/your/virtualenv $ source /path/to/your/virtualenv/bin/activate - $ pip install "check-oldies[toml]" + $ pip install check-oldies Features, configuration and more diff --git a/docs/installation.rst b/docs/installation.rst index da4bdee..e06e759 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -1,9 +1,7 @@ Installation ============ -You must have Python 3.7 or later, and a relatively recent version of -Git. Git 2.1.4 (shipped with Debian Jessie) is known to work. More -recent versions should work and are supported. +You must have Python 3.8 or later, and Git 2.19.0 or later. Install with ``pip``, preferably in a `virtual environment`_: @@ -11,22 +9,6 @@ Install with ``pip``, preferably in a `virtual environment`_: $ python3 -m venv /path/to/your/virtualenv $ source /path/to/your/virtualenv/bin/activate - $ pip install "check-oldies[toml]" - - -.. note:: - - If you do not need to read TOML configuration files, you may omit the - optional dependency and install like this: - - .. code-block:: bash - - $ pip install check-oldies - - Configuration files are not required, but some options are only - available through configuration files. If you are not sure, do not - use the latter command. Instead, use ``pip install check-oldies[toml]`` - as indicated above. - + $ pip install check-oldies .. _virtual environment: https://packaging.python.org/tutorials/installing-packages/#creating-virtual-environments diff --git a/pyproject.toml b/pyproject.toml index 675b0eb..984ea4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,3 +40,44 @@ known_tests = ["tests"] skip = [ "docs/conf.py", ] + + +[tool.pylint.MASTER] +load-plugins = [ + "pylint_strict_informational", +] + +[tool.pylint.FORMAT] +max-line-length = 120 + +[tool.pylint."MESSAGES CONTROL"] +enable = [ + "use-symbolic-message-instead", + "useless-suppression", +] + +disable = [ + "attribute-defined-outside-init", + "cyclic-import", + "duplicate-code", + "fixme", + "file-ignored", + "import-outside-toplevel", + "invalid-name", + "locally-disabled", + "missing-docstring", + "no-member", + "protected-access", + "too-few-public-methods", + "too-many-ancestors", + "too-many-arguments", + "too-many-branches", + "too-many-instance-attributes", + "too-many-lines", + "too-many-locals", + "too-many-public-methods", + "too-many-statements", + "ungrouped-imports", + "unused-argument", + "wrong-import-order", # we `isort` with a custom config +] diff --git a/requirements_dev.txt b/requirements_dev.txt index 97473f6..554dc76 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,4 +1,4 @@ --e .[toml] +-e . # Testing pytest @@ -10,7 +10,7 @@ sphinx_rtd_theme # Quality check-manifest -isort[pyproject] +isort pylint pylint_strict_informational twine diff --git a/setup.cfg b/setup.cfg index 071466f..7c09514 100644 --- a/setup.cfg +++ b/setup.cfg @@ -17,11 +17,11 @@ classifiers = Programming Language :: Python Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 keywords = fixme todo quality platform = any project_urls = @@ -30,7 +30,9 @@ project_urls = Bug Tracker = https://github.com/Polyconseil/check-oldies/issues [options] -python_requires = >=3.7 +python_requires = >=3.8 +install_requires = + toml; python_version<"3.11" package_dir= =src packages=find: @@ -42,9 +44,6 @@ console-scripts = check-future-tags = check_oldies.check_future_tags:main forget-me-not = check_oldies.forget_me_not:main -[options.extras_require] -toml = toml - [options.packages.find] where = src diff --git a/src/check_oldies/annotations.py b/src/check_oldies/annotations.py index fbf5f91..fa1530e 100644 --- a/src/check_oldies/annotations.py +++ b/src/check_oldies/annotations.py @@ -2,11 +2,8 @@ import dataclasses import datetime import re -import subprocess import typing -import pkg_resources - from . import commands from . import output @@ -230,66 +227,28 @@ def get_known_future_tags(directory, annotation_regex, future_tag_regex, whiteli return set(lines) -def git_supports_only_matching(): - out = subprocess.check_output(["git", "--version"]).decode("utf-8") - # output looks like "git version 2.26.0" - git_version = pkg_resources.parse_version(out.split()[2]) - # `git grep --only-matching` appeared in Git 2.19.0 - # https://github.com/git/git/blob/v2.19.0/Documentation/RelNotes/2.19.0.txt#L41 - minimal_version = pkg_resources.parse_version("2.19.0") - return git_version >= minimal_version - - def get_all_futures(directory, future_tag_regex, whitelist): """Get all occurrences of FUTURE tags.""" - # Old versions of Git (such as Git 2.1.4 that is shipped by Debian - # Jessie) do not support the `--only-matching` option. Pipe to - # `sed` instead. - if git_supports_only_matching(): - grep = [ - "git", - "grep", - "-I", # ignore binary files - "--line-number", - "--extended-regexp", - "--only-matching", - "-e", - future_tag_regex, - "--and", - "--not", - "-e", - IGNORE_PRAGMA, - ] - grep.extend([f":(exclude){glob}" for glob in whitelist]) - lines = commands.get_output( - grep, - cwd=directory, - valid_return_codes=(0, 1), # 0 if there are matches, 1 otherwise - ) - else: - grep = [ - "git", - "grep", - "-I", # ignore binary files - "--line-number", - "--extended-regexp", - "-e", - future_tag_regex, - "--and", - "--not", - "-e", - IGNORE_PRAGMA, - "--", # needed on old versions... - ".", # ... of git - ] - grep.extend([f":(exclude){glob}" for glob in whitelist]) - sed = f'sed --regexp-extended "s/(.*?):.*?({future_tag_regex}).*?/\\1:\\2/g"' - lines = commands.get_pipe_command_output( - grep, - piped_to=sed, - cwd=directory, - valid_return_codes=(0, 1), # 0 if there are matches, 1 otherwise - ) + grep = [ + "git", + "grep", + "-I", # ignore binary files + "--line-number", + "--extended-regexp", + "--only-matching", + "-e", + future_tag_regex, + "--and", + "--not", + "-e", + IGNORE_PRAGMA, + ] + grep.extend([f":(exclude){glob}" for glob in whitelist]) + lines = commands.get_output( + grep, + cwd=directory, + valid_return_codes=(0, 1), # 0 if there are matches, 1 otherwise + ) occurrences = collections.defaultdict(list) for line in lines: diff --git a/src/check_oldies/compat.py b/src/check_oldies/compat.py index fc5ecc1..dda0441 100644 --- a/src/check_oldies/compat.py +++ b/src/check_oldies/compat.py @@ -1,4 +1,5 @@ import enum +import pathlib __all__ = ["StrEnum"] @@ -36,3 +37,19 @@ def __new__(cls, *values): def _generate_next_value_(name, start, count, last_values): return name.lower() # fmt: on + + +try: + import tomllib + + def load_toml(path: pathlib.Path) -> dict: + with open(path, "rb") as fp: + return tomllib.load(fp) +except ImportError: # Python < 3.11 + try: + import toml + + def load_toml(path: pathlib.Path) -> dict: + return toml.load(path) + except ImportError: + load_toml = None diff --git a/src/check_oldies/configuration.py b/src/check_oldies/configuration.py index 088e662..02bfcbb 100644 --- a/src/check_oldies/configuration.py +++ b/src/check_oldies/configuration.py @@ -2,11 +2,7 @@ import subprocess import sys - -try: - import toml -except ImportError: # pragma: no cover - toml = None +from . import compat PYPROJECT_FILENAME = "pyproject.toml" @@ -34,13 +30,14 @@ def replace_dashes(options): def read_from_configuration_file(path, tool_name): - if not toml: # pragma: no cover + if not compat.load_toml: # pragma: no cover sys.exit( 'You must install with `pip install "check-oldies[toml]" ' - "to read from TOML configuration files." + "to read TOML configuration files." ) + try: - conf = toml.load(path) + conf = compat.load_toml(path) except Exception as exc: # pylint: disable=broad-except # pragma: no cover sys.exit(f"Error reading {path}: {exc.args[-1]}") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..596f1d3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1 @@ +pytest_plugins = ("tests.requests_mocker",) diff --git a/tests/data/project7/file1.py b/tests/data/project7/file1.py new file mode 100644 index 0000000..e6169b7 --- /dev/null +++ b/tests/data/project7/file1.py @@ -0,0 +1,2 @@ +# TIMEBOMB: FEWTURE-BOOM1 +# FEWTURE-DO-NOT-REPORT: do not report, it's not within an annotation diff --git a/tests/data/project7/file2.py b/tests/data/project7/file2.py new file mode 100644 index 0000000..83563d0 --- /dev/null +++ b/tests/data/project7/file2.py @@ -0,0 +1 @@ +# TIMEBOMB: FEWTURE-BOOM2 diff --git a/tests/requests_mocker.py b/tests/requests_mocker.py new file mode 100644 index 0000000..35df8db --- /dev/null +++ b/tests/requests_mocker.py @@ -0,0 +1,107 @@ +"""A mocker for ``urllib.request.urlopen``.""" + +import collections +import dataclasses +import json as jsonlib +import typing +import unittest.mock +import urllib.parse +import urllib.request + +import pytest + + +@pytest.fixture(name="requests_mocker") +def get_mock(): + """Return an instance of ``Mock`` to be used as a fixture. + + Example:: + + with get_mock() as mock: + mock.register("GET", "https://example.com", content="OK") + # call code that would make an HTTP request + """ + m = Mock() + with unittest.mock.patch("urllib.request.urlopen", m.urlopen): + yield m + + +@dataclasses.dataclass +class Request: + url: str + headers: typing.Dict[str, str] + data: bytes + params: dict + + +@dataclasses.dataclass +class Response: + content: bytes + status: int + + def read(self): + return self.content + + +@dataclasses.dataclass +class Call: + request: Request + response: Response + + +class Mock(): + """Intercept HTTP requests and mock their responses. + + An instance of ``Mock`` can be configured via its two methods: + + - ``get(url: str, json: object, status=200)`` allows you to mock + the response of a ``GET`` request to particular URL. + + - ``register(method: str, url: str, content: bytes, status=200)`` + is a more generic method. + """ + def __init__(self): + self.mocks = collections.defaultdict(dict) + self.calls = [] + + def register(self, method: str, url: str, content: bytes, status: int = 200): + method = method.lower() + self.mocks[url][method] = Response(content=content, status=status) + + def get(self, url: str, json: object, status: int = 200): + content = jsonlib.dumps(json) + self.register("get", url, content=content.encode("utf-8"), status=status) + + def urlopen(self, request: urllib.request.Request, **kwargs): + method = request.get_method().lower() + url = _strip_query_string(request.full_url) + response = self.mocks.get(url, {}).get(method) + if not response: + raise ValueError(f"No mock for method={method} and url={url}") + call = Call( + request=Request( + url=url, + headers=dict(request.headers), # MutableMapping -> dict + data=request.data or b'', # type: ignore [arg-type] + params=_extract_params(request), + ), + response=response, + ) + self.calls.append(call) + return response + + +def _strip_query_string(url: str) -> str: + parsed = urllib.parse.urlparse(url) + return parsed._replace(query="").geturl() + + +def _extract_params(request: urllib.request.Request) -> dict: + query = urllib.parse.urlparse(request.full_url).query + if not query: + return {} + params = urllib.parse.parse_qs(query) + for key, values in params.items(): + if len(values) == 1: + params[key] = values[0] # type: ignore [assignment] + return params diff --git a/tests/test_annotations.py b/tests/test_annotations.py index f916e32..01341b4 100644 --- a/tests/test_annotations.py +++ b/tests/test_annotations.py @@ -69,13 +69,24 @@ def test_get_login_from_committer_email(): assert login == "John Smith" -def test_git_supports_only_matching(): - # We expect tests to run on a system that has a recent version of Git. - assert annotations.git_supports_only_matching() +class TestGetKnownFutureTag: + test_data_path = base.TEST_DIR_PATH / "data/project7" -@mock.patch("check_oldies.annotations.git_supports_only_matching", lambda: False) -def test_old_git_without_only_matching(): - path = base.TEST_DIR_PATH / "data/project4" - futures = annotations.get_all_futures(path, base.TESTING_FUTURE_TAG, whitelist=[]) - assert set(futures.keys()) == {"FEWTURE-BOOM", "FEWTURE-I-AM-AN-ORPHAN"} + def test_basics(self): + tags = annotations.get_known_future_tags( + directory=self.test_data_path, + annotation_regex=base.TESTING_ANNOTATIONS[0], + future_tag_regex=base.TESTING_FUTURE_TAG, + whitelist=(), + ) + assert tags == {"FEWTURE-BOOM1", "FEWTURE-BOOM2"} + + def test_whitelist(self): + tags = annotations.get_known_future_tags( + directory=self.test_data_path, + annotation_regex=base.TESTING_ANNOTATIONS[0], + future_tag_regex=base.TESTING_FUTURE_TAG, + whitelist=["file2.py"], + ) + assert tags == {"FEWTURE-BOOM1"} diff --git a/tests/test_githost.py b/tests/test_githost.py new file mode 100644 index 0000000..0f0b817 --- /dev/null +++ b/tests/test_githost.py @@ -0,0 +1,28 @@ +import os +from unittest import mock + +from check_oldies import branches +from check_oldies import githost + + +FAKE_GITHUB_API_RESPONSE = [ + { + "number": 1234, + "state": "open", + "html_url": "https://github.com/polyconseil/check-oldies/pull/1234", + }, +] + + +@mock.patch.dict(os.environ, {"TOKEN": "secret"}, clear=True) +def test_github_api(requests_mocker): + requests_mocker.get( + "https://api.github.com/repos/polyconseil/check-oldies/pulls", + json=FAKE_GITHUB_API_RESPONSE, + ) + api_access = branches.GitHostApiAccessInfo(auth_token_env_var="TOKEN") + api = githost.GitHubApi("polyconseil", api_access) + pull_request = api.get_pull_request("check-oldies", "my-branch") + assert pull_request.number == 1234 + assert pull_request.state == "open" + assert pull_request.url == "https://github.com/polyconseil/check-oldies/pull/1234" diff --git a/tests/test_requests_mocker.py b/tests/test_requests_mocker.py new file mode 100644 index 0000000..74197b3 --- /dev/null +++ b/tests/test_requests_mocker.py @@ -0,0 +1,16 @@ +import urllib.request + +from . import requests_mocker + + +def test_extract_params(): + url = "https://example.com/?single=1&multiple=2&multiple=3&empty=" + request = urllib.request.Request(url) + params = requests_mocker._extract_params(request) + assert params == {"single": "1", "multiple": ["2", "3"]} + + +def test_strip_query_string(): + url = "https://example.com/path?foo=1" + stripped = requests_mocker._strip_query_string(url) + assert stripped == "https://example.com/path"