From 8d1ca81150f2137186cdc7e4dbbc5bb55848c4bd Mon Sep 17 00:00:00 2001 From: Sorin Sbarnea Date: Tue, 12 Sep 2023 19:34:39 +0100 Subject: [PATCH] Modernize codebase (#38) - Require python 3.9 or newer - Adopt new PEP517 packaging - Update linters - rename lib/ to src/ --- .config/requirements.in | 6 + .github/workflows/ack.yml | 10 ++ .github/workflows/push.yml | 13 ++ .github/workflows/release.yml | 69 ++++++++++ .github/workflows/tox.yml | 251 +++++++++++++++++++--------------- .gitignore | 2 + .isort.cfg | 2 - .pre-commit-config.yaml | 25 ++-- .pylintrc | 12 -- .yamllint | 3 + MANIFEST.in | 7 - mypy.ini | 15 -- pyproject.toml | 88 +++++++++++- setup.cfg | 87 ------------ setup.py | 3 - {lib => src}/gri/__init__.py | 0 {lib => src}/gri/__main__.py | 103 +++++++------- {lib => src}/gri/abc.py | 43 +++--- {lib => src}/gri/console.py | 15 +- {lib => src}/gri/constants.py | 0 {lib => src}/gri/gerrit.py | 68 +++------ {lib => src}/gri/github.py | 36 ++--- {lib => src}/gri/label.py | 20 ++- tox.ini | 57 ++++---- 24 files changed, 483 insertions(+), 452 deletions(-) create mode 100644 .config/requirements.in create mode 100644 .github/workflows/ack.yml create mode 100644 .github/workflows/push.yml create mode 100644 .github/workflows/release.yml delete mode 100644 .isort.cfg delete mode 100644 .pylintrc delete mode 100644 MANIFEST.in delete mode 100644 mypy.ini delete mode 100644 setup.cfg delete mode 100644 setup.py rename {lib => src}/gri/__init__.py (100%) rename {lib => src}/gri/__main__.py (84%) rename {lib => src}/gri/abc.py (77%) rename {lib => src}/gri/console.py (92%) rename {lib => src}/gri/constants.py (100%) rename {lib => src}/gri/gerrit.py (81%) rename {lib => src}/gri/github.py (80%) rename {lib => src}/gri/label.py (80%) diff --git a/.config/requirements.in b/.config/requirements.in new file mode 100644 index 0000000..158b6e3 --- /dev/null +++ b/.config/requirements.in @@ -0,0 +1,6 @@ +click-help-colors>=0.6 +click>=8.1.4 +enrich>=1.2.1 +pygithub +pyyaml>=5.3.1 +requests diff --git a/.github/workflows/ack.yml b/.github/workflows/ack.yml new file mode 100644 index 0000000..291eb88 --- /dev/null +++ b/.github/workflows/ack.yml @@ -0,0 +1,10 @@ +--- +# See https://github.com/ansible/devtools/blob/main/.github/workflows/ack.yml +name: ack +"on": + pull_request_target: + types: [opened, labeled, unlabeled, synchronize] + +jobs: + ack: + uses: ansible/devtools/.github/workflows/ack.yml@main diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml new file mode 100644 index 0000000..1debf04 --- /dev/null +++ b/.github/workflows/push.yml @@ -0,0 +1,13 @@ +--- +# See https://github.com/ansible/devtools/blob/main/.github/workflows/push.yml +name: push +"on": + push: + branches: + - main + - "releases/**" + - "stable/**" + +jobs: + ack: + uses: ansible/devtools/.github/workflows/push.yml@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..90c93a4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,69 @@ +--- +# cspell:ignore mislav +name: release + +"on": + release: + types: [published] + workflow_dispatch: + +jobs: + # https://github.com/marketplace/actions/actions-tagger + actions-tagger: + runs-on: windows-latest + steps: + - uses: Actions-R-Us/actions-tagger@latest + env: + GITHUB_TOKEN: "${{ github.token }}" + pypi: + name: Publish to PyPI registry + environment: release + runs-on: ubuntu-22.04 + permissions: + id-token: write + + env: + FORCE_COLOR: 1 + PY_COLORS: 1 + TOXENV: pkg + + steps: + - name: Switch to using Python 3.9 by default + uses: actions/setup-python@v4 + with: + python-version: 3.9 + + - name: Install tox + run: python3 -m pip install --user "tox>=4.0.0" + + - name: Check out src from Git + uses: actions/checkout@v4 + with: + fetch-depth: 0 # needed by setuptools-scm + submodules: true + + - name: Build dists + run: python -m tox + + - name: Publish to pypi.org + if: >- # "create" workflows run separately from "push" & "pull_request" + github.event_name == 'release' + uses: pypa/gh-action-pypi-publish@release/v1 + + homebrew: + name: Bump homebrew formula + environment: release + runs-on: ubuntu-22.04 + needs: pypi + + env: + FORCE_COLOR: 1 + PY_COLORS: 1 + TOXENV: pkg + + steps: + - name: Check out src from Git + uses: actions/checkout@v4 + with: + fetch-depth: 0 # needed by setuptools-scm + submodules: true diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml index 0ec180b..fedec38 100644 --- a/.github/workflows/tox.yml +++ b/.github/workflows/tox.yml @@ -1,128 +1,165 @@ +--- name: tox on: - create: # is used for publishing to PyPI and TestPyPI - tags: # any tag regardless of its name, no branches - push: # only publishes pushes to the main branch to TestPyPI - branches: # any integration branch but not tag - - "master" - tags-ignore: - - "**" + push: # only publishes pushes to the main branch to TestPyPI + branches: # any integration branch but not tag + - "main" pull_request: - schedule: - - cron: 1 0 * * * # Run daily at 0:01 UTC + branches: + - "main" + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +env: + FORCE_COLOR: 1 # tox, pytest, ansible-lint + PY_COLORS: 1 jobs: + pre: + name: pre + runs-on: ubuntu-22.04 + outputs: + matrix: ${{ steps.generate_matrix.outputs.matrix }} + steps: + - name: Determine matrix + id: generate_matrix + uses: coactions/dynamic-matrix@v1 + with: + min_python: "3.9" + max_python: "3.11" + other_names: | + lint + pkg + + platforms: linux,macos build: - name: ${{ matrix.tox_env }} - runs-on: ubuntu-latest + name: ${{ matrix.name }} + runs-on: ${{ matrix.os || 'ubuntu-22.04' }} + needs: + - pre + defaults: + run: + shell: ${{ matrix.shell || 'bash'}} strategy: fail-fast: false - matrix: - include: - - tox_env: lint - # - tox_env: docs - - tox_env: py36 - PREFIX: PYTEST_REQPASS=0 - - tox_env: py37 - PREFIX: PYTEST_REQPASS=0 - - tox_env: py38 - PREFIX: PYTEST_REQPASS=0 - - tox_env: packaging - + matrix: ${{ fromJson(needs.pre.outputs.matrix) }} + # max-parallel: 5 + # The matrix testing goal is to cover the *most likely* environments + # which are expected to be used by users in production. Avoid adding a + # combination unless there are good reasons to test it, like having + # proof that we failed to catch a bug by not running it. Using + # distribution should be preferred instead of custom builds. + env: + # vars safe to be passed to wsl: + WSLENV: FORCE_COLOR:PYTEST_REQPASS:TOXENV:GITHUB_STEP_SUMMARY + # Number of expected test passes, safety measure for accidental skip of + # tests. Update value if you add/remove tests. + PYTEST_REQPASS: 0 steps: - - uses: actions/checkout@v1 - - name: Find python version - id: py_ver - shell: python - if: ${{ contains(matrix.tox_env, 'py') }} - run: | - v = '${{ matrix.tox_env }}'.split('-')[0].lstrip('py') - print('::set-output name=version::{0}.{1}'.format(v[0],v[1:])) - # Even our lint and other envs need access to tox - - name: Install a default Python - uses: actions/setup-python@v2 - if: ${{ ! contains(matrix.tox_env, 'py') }} - # Be sure to install the version of python needed by a specific test, if necessary - - name: Set up Python version - uses: actions/setup-python@v2 - if: ${{ contains(matrix.tox_env, 'py') }} + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # needed by setuptools-scm + submodules: true + + - name: Set pre-commit cache + uses: actions/cache@v3 + if: ${{ matrix.passed_name == 'lint' }} + with: + path: | + ~/.cache/pre-commit + key: pre-commit-${{ matrix.name || matrix.passed_name }}-${{ hashFiles('.pre-commit-config.yaml') }} + + - name: Set ansible cache(s) + uses: actions/cache@v3 with: - python-version: ${{ steps.py_ver.outputs.version }} - - name: Install dependencies + path: | + .cache/eco + examples/playbooks/collections/ansible_collections + ~/.cache/ansible-compat + ~/.ansible/collections + ~/.ansible/roles + key: ${{ matrix.name || matrix.passed_name }}-${{ hashFiles('tools/test-eco.sh', 'requirements.yml', 'examples/playbooks/collections/requirements.yml') }} + + - name: Set up Python ${{ matrix.python_version || '3.9' }} + uses: actions/setup-python@v4 + with: + cache: pip + python-version: ${{ matrix.python_version || '3.9' }} + + - name: Install tox run: | - docker version - docker info - python -m pip install -U pip - pip install tox - - name: Run tox -e ${{ matrix.tox_env }} + python3 -m pip install --upgrade pip + python3 -m pip install --upgrade "tox>=4.0.0" + + - name: Log installed dists + run: python3 -m pip freeze --all + + - name: Initialize tox envs ${{ matrix.passed_name }} + run: python3 -m tox --notest --skip-missing-interpreters false -vv -e ${{ matrix.passed_name }} + timeout-minutes: 5 # average is under 1, but macos can be over 3 + + # sequential run improves browsing experience (almost no speed impact) + - name: tox -e ${{ matrix.passed_name }} + run: python3 -m tox -e ${{ matrix.passed_name }} + + # - name: Combine coverage data + # if: ${{ startsWith(matrix.passed_name, 'py') }} + # # produce a single .coverage file at repo root + # run: tox -e coverage + + # - name: Upload coverage data + # if: ${{ startsWith(matrix.passed_name, 'py') }} + # uses: codecov/codecov-action@v3 + # with: + # name: ${{ matrix.passed_name }} + # fail_ci_if_error: false # see https://github.com/codecov/codecov-action/issues/598 + # token: ${{ secrets.CODECOV_TOKEN }} + # verbose: true # optional (default = false) + + - name: Archive logs + uses: actions/upload-artifact@v3 + with: + name: logs.zip + path: .tox/**/log/ + # https://github.com/actions/upload-artifact/issues/123 + continue-on-error: true + + - name: Report failure if git reports dirty status run: | - echo "${{ matrix.PREFIX }} tox -e ${{ matrix.tox_env }}" - ${{ matrix.PREFIX }} tox -e ${{ matrix.tox_env }} + if [[ -n $(git status -s) ]]; then + # shellcheck disable=SC2016 + echo -n '::error file=git-status::' + printf '### Failed as git reported modified and/or untracked files\n```\n%s\n```\n' "$(git status -s)" | tee -a "$GITHUB_STEP_SUMMARY" + exit 99 + fi + # https://github.com/actions/toolkit/issues/193 + + check: # This job does nothing and is only used for the branch protection + if: always() + permissions: + pull-requests: write # allow codenotify to comment on pull-request - publish: - name: Publish to PyPI registry needs: - build - runs-on: ubuntu-latest - env: - PY_COLORS: 1 - TOXENV: packaging + runs-on: ubuntu-latest steps: - - name: Switch to using Python 3.6 by default - uses: actions/setup-python@v2 + - name: Decide whether the needed jobs succeeded or failed + uses: re-actors/alls-green@release/v1 with: - python-version: 3.6 - - name: Install tox - run: python -m pip install --user tox + jobs: ${{ toJSON(needs) }} + - name: Check out src from Git - uses: actions/checkout@v2 - with: - # Get shallow Git history (default) for tag creation events - # but have a complete clone for any other workflows. - # Both options fetch tags but since we're going to remove - # one from HEAD in non-create-tag workflows, we need full - # history for them. - fetch-depth: >- - ${{ - ( - github.event_name == 'create' && - github.event.ref_type == 'tag' - ) && - 1 || 0 - }} - - name: Drop Git tags from HEAD for non-tag-create events - if: >- - github.event_name != 'create' || - github.event.ref_type != 'tag' - run: >- - git tag --points-at HEAD - | - xargs git tag --delete - - name: Build dists - run: python -m tox - - name: Publish to test.pypi.org - if: >- - ( - github.event_name == 'push' && - github.ref == format( - 'refs/heads/{0}', github.event.repository.default_branch - ) - ) || - ( - github.event_name == 'create' && - github.event.ref_type == 'tag' - ) - uses: pypa/gh-action-pypi-publish@master - with: - password: ${{ secrets.testpypi_password }} - repository_url: https://test.pypi.org/legacy/ - - name: Publish to pypi.org - if: >- # "create" workflows run separately from "push" & "pull_request" - github.event_name == 'create' && - github.event.ref_type == 'tag' - uses: pypa/gh-action-pypi-publish@master - with: - password: ${{ secrets.pypi_password }} + uses: actions/checkout@v4 + + - name: Notify repository owners about lint change affecting them + uses: sourcegraph/codenotify@v0.6.4 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # https://github.com/sourcegraph/codenotify/issues/19 + continue-on-error: true diff --git a/.gitignore b/.gitignore index 6ad491d..19cbf0f 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,5 @@ pip-wheel-metadata credentials.json pip-wheel-metadata +src/gri/_version.py +report.html diff --git a/.isort.cfg b/.isort.cfg deleted file mode 100644 index dbf3c5e..0000000 --- a/.isort.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[settings] -known_third_party = requests,setuptools diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 046477e..668ac54 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,8 +1,9 @@ repos: - - repo: https://github.com/markdownlint/markdownlint - rev: v0.11.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: "v0.0.287" hooks: - - id: markdownlint + - id: ruff + args: [--fix, --exit-non-zero-on-fix] - repo: https://github.com/psf/black rev: 23.9.1 hooks: @@ -17,17 +18,6 @@ repos: - id: check-executables-have-shebangs - id: check-merge-conflict - id: debug-statements - language_version: python3 - - repo: https://github.com/PyCQA/flake8 - rev: 4.0.1 - hooks: - - id: flake8 - additional_dependencies: - - pydocstyle>=5.1.1 - - flake8-absolute-import - - flake8-black>=0.1.1 - - flake8-docstrings>=1.5.0 - language_version: python3 - repo: https://github.com/adrienverge/yamllint.git rev: v1.26.3 hooks: @@ -41,7 +31,7 @@ repos: - id: mypy # empty args needed in order to match mypy cli behavior args: [] - entry: mypy lib/ + entry: mypy src/ pass_filenames: false additional_dependencies: - click-help-colors @@ -52,13 +42,14 @@ repos: - types-requests - types-PyYAML - types-dataclasses - - repo: https://github.com/pre-commit/mirrors-pylint - rev: v3.0.0a4 + - repo: https://github.com/pylint-dev/pylint + rev: v2.17.5 hooks: - id: pylint additional_dependencies: - click-help-colors - click-option-group + - enrich - packaging - pygithub - pyyaml diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index d9f6805..0000000 --- a/.pylintrc +++ /dev/null @@ -1,12 +0,0 @@ -[MESSAGES CONTROL] - -disable = - # TODO(ssbarnea): remove temporary skips adding during initial adoption: - fixme, - import-error, # https://github.com/pre-commit/pre-commit/issues/813 - missing-class-docstring, - missing-function-docstring, - missing-module-docstring, - -[REPORTS] -output-format = colorized diff --git a/.yamllint b/.yamllint index effc6de..de8b2cf 100644 --- a/.yamllint +++ b/.yamllint @@ -9,6 +9,9 @@ rules: brackets: max-spaces-inside: 1 level: error + comments: + # prettier compatibility + min-spaces-from-content: 1 document-start: disable line-length: disable truthy: diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index af6c491..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,7 +0,0 @@ -include README.md -include LICENSE -include setup.py -include tox.ini - -global-exclude __pycache__ -global-exclude *.py[cod] diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index 47b2bda..0000000 --- a/mypy.ini +++ /dev/null @@ -1,15 +0,0 @@ -[mypy] -python_version = 3.6 -color_output = True -error_summary = True -disallow_untyped_calls=True -warn_redundant_casts=True - - -# 3rd party ignores, to remove once they add hints - -[mypy-urllib.urlparse] -ignore_missing_imports = True - -[mypy-click_help_colors] -ignore_missing_imports = True diff --git a/pyproject.toml b/pyproject.toml index 33f2e72..ab8816c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,18 +1,96 @@ [build-system] requires = [ - "pip >= 19.3.1", - "setuptools >= 42", - "setuptools_scm[toml] >= 3.5.0", - "setuptools_scm_git_archive >= 1.1", - "wheel >= 0.33.6", + "setuptools >= 65.3.0", # required by pyproject+setuptools_scm integration and editable installs + "setuptools_scm[toml] >= 7.0.5", # required for "no-local-version" scheme + ] build-backend = "setuptools.build_meta" +[project] +# https://peps.python.org/pep-0621/#readme +requires-python = ">=3.9" +dynamic = ["version", "dependencies", "optional-dependencies"] +name = "gri" +description = "Git Review Interface for Gerrit and Github" +readme = "README.md" +authors = [{ "name" = "Sorin Sbarnea", "email" = "sorin.sbarnea@gmail.com" }] +maintainers = [{ "name" = "Sorin Sbarnea", "email" = "sorin.sbarnea@gmail.com" }] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: Information Technology", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: MIT License", + "Operating System :: MacOS", + "Operating System :: POSIX", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python", + "Topic :: System :: Systems Administration", + "Topic :: Software Development :: Quality Assurance", + "Topic :: Software Development :: Testing", + "Topic :: Utilities", +] +keywords = ["gerrit", "git", "github", "review"] + +[project.urls] +homepage = "https://github.com/pycontribs/gri" +repository = "https://github.com/pycontribs/gri" +changelog = "https://github.com/pycontribs/gri/releases" + +[project.scripts] +gri = "gri.__main__:cli" +grib = "gri.__main__:cli_bugs" + [tool.black] +target-version = ["py39"] line-length = 88 [tool.isort] profile = "black" +[tool.mypy] +python_version = 3.9 +[[tool.mypy.overrides]] +module = [ + "click_help_colors", +] +ignore_missing_imports = true +ignore_errors = true + +[tool.pylint."MESSAGES CONTROL"] +disable = [ + "missing-function-docstring", + "missing-class-docstring", + "missing-module-docstring" +] +[tool.ruff] +ignore = [ + "ANN", + "ARG", + "D", + "DTZ", + "E501", # black managed + "PLR", + "PTH", + "S605", + "TRY", +] +select = ["ALL"] +target-version = "py39" +# Same as Black. +line-length = 88 +[tool.setuptools.dynamic] +optional-dependencies.test = { file = [".config/requirements-test.txt"] } +optional-dependencies.lock = { file = [".config/requirements-lock.txt"] } +dependencies = { file = [".config/requirements.in"] } + [tool.setuptools_scm] local_scheme = "no-local-version" +write_to = "src/gri/_version.py" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 61f4cc1..0000000 --- a/setup.cfg +++ /dev/null @@ -1,87 +0,0 @@ -# -*- coding: utf-8 -*- -[metadata] -name = gri -description = Git Review Interface for Gerrit and Github -long_description = file: README.md -long_description_content_type = text/markdown -maintainer = Sorin Sbarnea -author = Sorin Sbarnea -author-email = sorin.sbarnea@gmail.com -maintainer-email = sorin.sbarnea@gmail.com -url = https://github.com/pycontribs/gri -project_urls = - Source=https://github.com/pycontribs/gri - Tracker=https://github.com/pycontribs/gri - CI = https://github.com/pycontribs/gri/actions -platforms = any -license = MIT -license_file = LICENSE -classifiers = - Development Status :: 5 - Production/Stable - Environment :: Console - Intended Audience :: Developers - Operating System :: OS Independent - License :: OSI Approved :: MIT License - Topic :: Software Development :: Testing - Topic :: Software Development :: Libraries - Topic :: Utilities - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.6 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Topic :: Software Development :: Bug Tracking - Topic :: Software Development :: Quality Assurance - Topic :: Software Development :: Testing - Topic :: Utilities -keywords = - gerrit - git - github - review - -[options] -use_scm_version = True -python_requires = >=3.6 -package_dir = - = lib -packages = find: -include_package_data = True -zip_safe = False - -# These are required during `setup.py` run: -setup_requires = - setuptools_scm>=1.15.0 - setuptools_scm_git_archive>=1.0 - -# These are required in actual runtime: -install_requires = - click-help-colors>=0.6 - click>=8.1.4 - dataclasses; python_version<"3.7" - enrich>=1.2.1 - pygithub - pyyaml>=5.3.1 - requests - -[options.entry_points] -console_scripts = - gri=gri.__main__:cli - grib=gri.__main__:cli_bugs - -[options.packages.find] -where = lib - -[flake8] -max-complexity = 22 -max-line-length = 88 -ignore = - B011 - C901, - D - E203, - E402, - W503, - -[pep8] -max-line-length = 99 diff --git a/setup.py b/setup.py deleted file mode 100644 index 4063c22..0000000 --- a/setup.py +++ /dev/null @@ -1,3 +0,0 @@ -from setuptools import setup - -setup(name="gri", use_scm_version=True, setup_requires=["setuptools_scm"]) diff --git a/lib/gri/__init__.py b/src/gri/__init__.py similarity index 100% rename from lib/gri/__init__.py rename to src/gri/__init__.py diff --git a/lib/gri/__main__.py b/src/gri/__main__.py similarity index 84% rename from lib/gri/__main__.py rename to src/gri/__main__.py index 18b72bc..5146af5 100755 --- a/lib/gri/__main__.py +++ b/src/gri/__main__.py @@ -1,28 +1,26 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- +from __future__ import annotations + +import contextlib import logging import os import sys from functools import wraps -from typing import List, Optional, Type, Union +from urllib.parse import urlparse import click from click_help_colors import HelpColorsGroup -from gri.abc import Query, Review, Server -from gri.console import TERMINAL_THEME, bootstrap, get_logging_level -from gri.constants import RC_CONFIG_ERROR, RC_PARTIAL_RUN -from gri.gerrit import GerritServer -from gri.github import GithubServer from requests.exceptions import HTTPError from rich import box from rich.markdown import Markdown from rich.table import Table from yaml import YAMLError, dump, safe_load -try: - from urllib.parse import urlparse -except ImportError: - from urlparse import urlparse # type: ignore +from gri.abc import Query, Review, Server +from gri.console import TERMINAL_THEME, bootstrap, get_logging_level +from gri.constants import RC_CONFIG_ERROR, RC_PARTIAL_RUN +from gri.gerrit import GerritServer +from gri.github import GithubServer term = bootstrap() @@ -49,7 +47,7 @@ def inner_func(*args, **kwargs): # after if ctx.invoked_subcommand is None: LOG.debug( - "I was invoked without subcommand, assuming implicit `owned` command" + "I was invoked without subcommand, assuming implicit `owned` command", ) ctx.invoke(owned) @@ -79,7 +77,7 @@ def load_config(self, config_file: str) -> dict: ) config_file_full = config_file_full = os.path.expanduser(GERTTY_CFG_FILE) try: - with open(config_file_full, "r") as stream: + with open(config_file_full, encoding="utf-8") as stream: return dict(safe_load(stream)) except (FileNotFoundError, YAMLError) as exc: LOG.error(exc) @@ -92,10 +90,10 @@ def __init__(self, ctx: click.Context) -> None: self.kind = "" # keep it until we make this abc self.ctx = ctx self.cfg = Config(file=ctx.params["config"]) - self.servers: List[Server] = [] + self.servers: list[Server] = [] self.user = ctx.params["user"] self.errors = 0 # number of errors encountered - self.query_details: List[str] = [] + self.query_details: list[str] = [] server = ctx.params["server"] try: for srv in ( @@ -104,17 +102,14 @@ def __init__(self, ctx: click.Context) -> None: else [self.cfg["servers"][int(server)]] ): try: - # TODO(ssbarnea): make server type configurable parsed_uri = urlparse(srv["url"]) - srv_class: Union[ - Type[GithubServer], Type[GerritServer] - ] = GerritServer + srv_class: type[GithubServer | GerritServer] = GerritServer if parsed_uri.netloc == "github.com": srv_class = GithubServer self.servers.append( - srv_class(url=srv["url"], name=srv["name"], ctx=self.ctx) + srv_class(url=srv["url"], name=srv["name"], ctx=self.ctx), ) - except SystemError as exc: + except SystemError as exc: # noqa: PERF203 LOG.error(exc) except IndexError: LOG.error("Unable to find server %s index in server list.", server) @@ -123,11 +118,11 @@ def __init__(self, ctx: click.Context) -> None: LOG.error("List of servers is invalid or empty.") sys.exit(RC_CONFIG_ERROR) - self.reviews: List[Review] = list() + self.reviews: list[Review] = [] term.print(self.header()) def run_query(self, query: Query, kind: str) -> int: - """Performs a query and stores result inside reviews attribute""" + """Performs a query and stores result inside reviews attribute.""" errors = 0 self.reviews.clear() self.query_details = [] @@ -136,7 +131,11 @@ def run_query(self, query: Query, kind: str) -> int: for review in server.query(query=query, kind=kind): self.reviews.append(review) self.query_details.append(server.mk_query(query, kind=kind)) - except (HTTPError, RuntimeError, NotImplementedError) as exc: + except ( # noqa: PERF203 + HTTPError, + RuntimeError, + NotImplementedError, + ) as exc: LOG.error(exc) errors += 1 @@ -151,7 +150,7 @@ def report( query: Query, title: str = "Reviews", max_score: int = 1, - action: Optional[str] = None, + action: str | None = None, ) -> None: """Produce a table report based on a query.""" LOG.debug("Running report() for %s", query) @@ -189,11 +188,14 @@ def report( term.print(f"[dim]-- {cnt} changes listed {self.query_details}[/]") def display_config(self) -> None: - msg = dump( - dict(self.cfg), default_flow_style=False, tags=False, sort_keys=False - ) # type: ignore + msg = dump( # type: ignore[call-overload] + data=dict(self.cfg), + default_flow_style=False, + tags=False, + sort_keys=False, + ) - term.print(Markdown("```yaml\n# %s\n%s\n```" % (self.cfg.config_file, msg))) + term.print(Markdown(f"```yaml\n# {self.cfg.config_file}\n{msg}\n```")) class AppIssues(App): @@ -209,7 +211,7 @@ def __init__(self, ctx: click.Context) -> None: class CustomGroup(HelpColorsGroup): - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) # injects common options shared across all commands using this group options = [ @@ -236,10 +238,14 @@ def __init__(self, *args, **kwargs): help="Reduce verbosity level, can be specified twice.", ), click.core.Option( - ["-v", "--verbose"], count=True, help="Increase verbosity level" + ["-v", "--verbose"], + count=True, + help="Increase verbosity level", ), click.core.Option( - ["--user", "-u"], default="self", help="Query another user than self" + ["--user", "-u"], + default="self", + help="Query another user than self", ), click.core.Option( ["--config"], @@ -257,17 +263,16 @@ def __init__(self, *args, **kwargs): ] self.params.extend(options) - def get_command(self, ctx: click.Context, cmd_name: str) -> Optional[click.Command]: - """Undocumented command aliases for lazy users""" + def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None: + """Undocumented command aliases for lazy users.""" aliases = { "o": owned, "m": merged, "i": incoming, } - try: + with contextlib.suppress(KeyError): cmd_name = aliases[cmd_name].name or "undefined" - except KeyError: - pass + return super().get_command(ctx, cmd_name) @@ -276,7 +281,7 @@ def get_command(self, ctx: click.Context, cmd_name: str) -> Optional[click.Comma invoke_without_command=True, help_headers_color="yellow", help_options_color="green", - context_settings=dict(max_content_width=9999), + context_settings={"max_content_width": 9999}, chain=True, ) @click.pass_context @@ -301,9 +306,7 @@ def process_result(result, **kwargs): # pylint: disable=unused-argument @cli.command() @click.pass_context def owned(ctx): - """Changes originated from current user (implicit)""" - # query = "status:open" - # query += f" owner:{ctx.obj.user}" + """Changes originated from current user (implicit).""" if ctx.obj.user == "self": title = "Own reviews" else: @@ -314,7 +317,7 @@ def owned(ctx): @cli.command() @click.pass_context def incoming(ctx): - """Incoming reviews""" + """Incoming reviews.""" ctx.obj.report(query=Query("incoming"), title=incoming.__doc__) @@ -326,7 +329,7 @@ def incoming(ctx): help="Number of days to look back, adds -age:NUM", ) def merged(ctx, age): - """Merged in the last number of days""" + """Merged in the last number of days.""" ctx.obj.report(query=Query("merged", age=age), title=f"Merged Reviews ({age}d)") @@ -343,7 +346,7 @@ def merged(ctx, age): help="Number of days to look back, adds -age:NUM", ) def project_merged(ctx, age, project_name): - """Merged by project in the last number of days""" + """Merged by project in the last number of days.""" ctx.obj.report( query=Query("project_merged", age=age, project_name=project_name), title=f"Project Merged Reviews ({age}d)", @@ -354,14 +357,12 @@ def project_merged(ctx, age, project_name): # @click.pass_context # def custom(ctx): # """Custom query""" -# query = f"cc:{ctx.obj.user} status:open" -# ctx.obj.report(query=query, title="Custom query") @cli.command() @click.pass_context def watched(ctx): - """Watched reviews based on server side filters""" + """Watched reviews based on server side filters.""" ctx.obj.report(query=Query("watched"), title=watched.__doc__) @@ -382,8 +383,8 @@ def draft(ctx): ) def abandon(ctx, age): """Abandon changes (delete for drafts) when they are >90 days old - and with very low score. Requires -f to perform the action.""" - + and with very low score. Requires -f to perform the action. + """ ctx.obj.report( query=Query("abandon", age=age), title=f"Reviews to abandon ({age}d)", @@ -404,14 +405,14 @@ def config(ctx): invoke_without_command=True, help_headers_color="yellow", help_options_color="green", - context_settings=dict(max_content_width=9999), + context_settings={"max_content_width": 9999}, chain=True, ) @click.pass_context @command_line_wrapper # pylint: disable=unused-argument def cli_bugs(ctx: click.Context, **kwargs): - """grib is gri brother that retrieves bugs instead of reviews.""" + """Grib is gri brother that retrieves bugs instead of reviews.""" ctx.obj = AppIssues(ctx=ctx) diff --git a/lib/gri/abc.py b/src/gri/abc.py similarity index 77% rename from lib/gri/abc.py rename to src/gri/abc.py index d0290f4..da6ea25 100644 --- a/lib/gri/abc.py +++ b/src/gri/abc.py @@ -1,7 +1,7 @@ import datetime -from abc import ABC +from abc import ABC, abstractmethod +from collections.abc import Iterator from dataclasses import dataclass -from typing import Dict, Iterator, List from gri.console import link from gri.label import Label @@ -18,11 +18,13 @@ class Server(ABC): # pylint: disable=too-few-public-methods def __init__(self) -> None: self.name = "Unknown" - def query(self, query: Query, kind: str = "review") -> List: - raise NotImplementedError() + @abstractmethod + def query(self, query: Query, kind: str = "review") -> list: + raise NotImplementedError + @abstractmethod def mk_query(self, query: Query, kind: str) -> str: - raise NotImplementedError() + raise NotImplementedError class Review: # pylint: disable=too-many-instance-attributes @@ -39,7 +41,7 @@ def __init__(self, data: dict, server) -> None: self.project = "" self.branch = "master" self.topic = "" - self.labels: Dict[str, Label] = {} + self.labels: dict[str, Label] = {} self.server = server def age(self) -> int: @@ -53,21 +55,18 @@ def __repr__(self) -> str: def __getattr__(self, name): if name in self.data: return self.data[name] - raise AttributeError(f"{name} not found in {self.data}") + msg = f"{name} not found in {self.data}" + raise AttributeError(msg) # if name == "number": - # return self.data["_number"] - # return None def short_project(self) -> str: return self.project - def colorize(self, text: str) -> str: # pylint: disable=no-self-use + def colorize(self, text: str) -> str: return text def as_columns(self) -> list: """Return review info as columns with rich text.""" - # return [self.title] - result = [] # avoid use of emoji due to: @@ -84,12 +83,10 @@ def as_columns(self) -> list: msg += f" [branch][{self.branch}][/]" # description/detail column - msg += "[dim]: %s[/]" % (self.title) + msg += f"[dim]: {self.title}[/]" if self.topic: - topic_url = "{}#/q/topic:{}+(status:open+OR+status:merged)".format( - self.server.url, self.topic - ) + topic_url = f"{self.server.url}#/q/topic:{self.topic}+(status:open+OR+status:merged)" msg += f" {link(topic_url, self.topic)}" if self.status == "NEW" and not self.mergeable: @@ -105,13 +102,13 @@ def as_columns(self) -> list: for label in self._get_labels(meta=True): # we do not display labels with no value if label.value: - msg += " %s" % label + msg += f" {label}" result.extend([msg.strip(), f" [dim]{self.score*100:.0f}%[/]"]) return result - def _get_labels(self, meta=False) -> Iterator[Label]: + def _get_labels(self, *, meta: bool = False) -> Iterator[Label]: """Return labels that are part of meta group or opposite.""" for label in self.labels.values(): if label.is_meta() == meta: @@ -119,14 +116,14 @@ def _get_labels(self, meta=False) -> Iterator[Label]: @property def is_mergeable(self): - raise NotImplementedError() + raise NotImplementedError def is_reviewed(self) -> bool: - pass + raise NotImplementedError def __lt__(self, other) -> bool: - pass + return self.score >= other.score - def abandon(self, dry=True) -> None: + def abandon(self, *, dry: bool = True) -> None: # shell out here because HTTPS api to abandon can fail - pass + raise NotImplementedError diff --git a/lib/gri/console.py b/src/gri/console.py similarity index 92% rename from lib/gri/console.py rename to src/gri/console.py index 0edc880..1269cd9 100644 --- a/lib/gri/console.py +++ b/src/gri/console.py @@ -43,7 +43,7 @@ "veryhigh": "dim red", # Very high danger "branch": "magenta", "wip": "bold yellow", - } + }, ) @@ -54,12 +54,13 @@ def bootstrap() -> Console: logging_console = Console(file=sys.stderr, force_terminal=1, theme=theme) logger = logging.getLogger() # type: logging.Logger - # logger.setLevel(logging.DEBUG) handler = RichHandler( - console=logging_console, show_time=False, show_path=False, markup=True - ) # type: ignore - # logger.addHandler(handler) + console=logging_console, + show_time=False, + show_path=False, + markup=True, + ) logger.handlers = [handler] logger.propagate = False @@ -104,7 +105,9 @@ def get_logging_level(ctx) -> int: class MyCodeBlock(CodeBlock): # pylint: disable=unused-argument def __rich_console__( - self, console: rich.console.Console, options: ConsoleOptions + self, + console: rich.console.Console, + options: ConsoleOptions, ) -> RenderResult: code = str(self.text).rstrip() syntax = Syntax(code, self.lexer_name, theme=self.theme) diff --git a/lib/gri/constants.py b/src/gri/constants.py similarity index 100% rename from lib/gri/constants.py rename to src/gri/constants.py diff --git a/lib/gri/gerrit.py b/src/gri/gerrit.py similarity index 81% rename from lib/gri/gerrit.py rename to src/gri/gerrit.py index 9062ef8..83211fc 100644 --- a/lib/gri/gerrit.py +++ b/src/gri/gerrit.py @@ -4,23 +4,18 @@ import netrc import os import re -from typing import Dict, List +from urllib.parse import urlencode, urlparse import requests -from gri.abc import Query, Review, Server -from gri.label import Label from requests.auth import HTTPBasicAuth, HTTPDigestAuth -try: - from urllib.parse import urlencode, urlparse -except ImportError: - from urlparse import urlencode, urlparse # type: ignore - +from gri.abc import Query, Review, Server +from gri.label import Label LOG = logging.getLogger(__package__) # Used only to force outdated Digest auth for servers not using standard auth -KNOWN_SERVERS: Dict[str, Dict] = { +KNOWN_SERVERS: dict[str, dict] = { "https://code.engineering.redhat.com/gerrit/": { "auth": HTTPDigestAuth, "verify": False, @@ -67,16 +62,16 @@ def __init__(self, url: str, name: str = "", ctx=None) -> None: netrc_file, ) else: - self.__session.auth = self.auth_class(token[0], token[2]) + self.__session.auth = self.auth_class(token[0], token[2]) # type: ignore[arg-type] self.__session.headers.update( { "Content-Type": "application/json;charset=UTF-8", "Access-Control-Allow-Origin": "*", - } + }, ) - def query(self, query: Query, kind="review") -> List: + def query(self, query: Query, kind="review") -> list: # Gerrit knows only about reviews if kind != "review": return [] @@ -113,8 +108,9 @@ def mk_query(self, query: Query, kind: str) -> str: if query.name == "project_merged": return f"{query.project_name} status:merged -age:{query.age}d" + msg = f"{query.name} query not implemented by {self.__class__}" raise NotImplementedError( - f"{query.name} query not implemented by {self.__class__}" + msg, ) @staticmethod @@ -139,21 +135,19 @@ def __init__(self, data: dict, server) -> None: self.starred = data.get("starred", False) self.server = server - if "topic" not in data: - self.topic = "" - else: - self.topic = data["topic"] + self.topic = data.get("topic", "") self.title = data["subject"] self.updated = datetime.datetime.strptime( - self.data["updated"][:-3], "%Y-%m-%d %H:%M:%S.%f" + self.data["updated"][:-3], + "%Y-%m-%d %H:%M:%S.%f", ) if re.compile("^\\[?(WIP|DNM|POC).+$", re.IGNORECASE).match(self.title): self.is_wip = True - self.url = "{}#/c/{}/".format(self.server.url, self.number) + self.url = f"{self.server.url}#/c/{self.number}/" # Secret ScoreRank implementation which aims to map any review on a # scale from 0 to 1, where 1 is alredy merged, and 0 is something that @@ -164,9 +158,8 @@ def __init__(self, data: dict, server) -> None: self.score = 1.0 # disabled staring as it does not effectively affect chance of merging # if not self.starred: - # self.score *= 0.9 - self.labels: Dict[str, Label] = {} + self.labels: dict[str, Label] = {} for label_name, label_data in data.get("labels", {}).items(): label = Label(label_name, label_data) @@ -213,55 +206,26 @@ def colorize(self, text: str) -> str: # def as_columns(self) -> list: # """Return review info as columns with rich text.""" - # result = [] - # # avoid use of emoji due to: # # https://github.com/willmcgugan/rich/issues/148 - # star = "[bright_yellow]★[/] " if self.starred else "" - - # result.append(f"{star}{self.colorize(link(self.url, self.number))}") - - # result.append(f"[dim]{self.age():3}[/]" if self.age() else "") - - # msg = f"[{ 'wip' if self.is_wip else 'normal' }]{self.short_project()}[/]" # if self.branch != "master": - # msg += f" [branch][{self.branch}][/]" - - # msg += "[dim]: %s[/]" % (self.title) # if self.topic: - # topic_url = "{}#/q/topic:{}+(status:open+OR+status:merged)".format( # self.server.url, self.topic - # ) - # msg += f" {link(topic_url, self.topic)}" # if self.status == "NEW" and not self.mergeable: - # msg += " [veryhigh]cannot-merge[/]" - # result.append(msg) - # msg = "" # for label in self.labels.values(): # if label.value: # # we print only labels without 0 value - # msg += " %s" % label - - # result.extend([msg.strip(), f" [dim]{self.score*100:.0f}%[/]"]) - - # return result def is_reviewed(self) -> bool: return self.data["labels"]["Code-Review"]["value"] > 1 - def __lt__(self, other) -> bool: - return self.score >= other.score - - def abandon(self, dry=True) -> None: + def abandon(self, *, dry: bool = True) -> None: # shell out here because HTTPS api to abandon can fail - if self.draft: - action = "delete" - else: - action = "abandon" + action = "delete" if self.draft else "abandon" LOG.warning("Performing %s on %s", action, self.number) if not dry: diff --git a/lib/gri/github.py b/src/gri/github.py similarity index 80% rename from lib/gri/github.py rename to src/gri/github.py index 1b9cdba..328c837 100644 --- a/lib/gri/github.py +++ b/src/gri/github.py @@ -1,17 +1,13 @@ import logging import os from datetime import datetime, timedelta -from typing import Dict, List +from urllib.parse import urlparse import github + from gri.abc import Query, Review, Server from gri.label import Label -try: - from urllib.parse import urlparse -except ImportError: - from urlparse import urlparse # type: ignore - LOG = logging.getLogger(__package__) @@ -24,7 +20,7 @@ def __init__(self, url: str, name: str = "", ctx=None) -> None: token = os.environ.get("HOMEBREW_GITHUB_API_TOKEN") self.github = github.Github(login_or_token=token) - def query(self, query: Query, kind="review") -> List: + def query(self, query: Query, kind="review") -> list: LOG.debug("Called query=%s and kind=%s", query, kind) reviews = [] limit = 50 @@ -32,14 +28,14 @@ def query(self, query: Query, kind="review") -> List: for _, item in zip(range(limit), results): review = PullRequest(data=item.raw_data, server=self) reviews.append(review) - # mine = [x for x in self.github.search_issues("author:@me")] - # print(mine) return reviews def mk_query( - self, query: Query, kind: str = "review" - ) -> str: # pylint: disable=no-self-use - """Return query string based on""" + self, + query: Query, + kind: str = "review", + ) -> str: + """Return query string based on.""" # https://docs.github.com/en/free-pro-team@latest/github/searching-for-information-on-github/searching-issues-and-pull-requests kind = "is:pr" if kind == "review" else "is:issue" @@ -61,7 +57,8 @@ def mk_query( day = (datetime.now() - timedelta(days=query.age)).date().isoformat() return f"{kind} is:merged author:@me updated:>={day}" - raise NotImplementedError(f"Unable to build query for {query.name}") + msg = f"Unable to build query for {query.name}" + raise NotImplementedError(msg) class PullRequest(Review): # pylint: disable=too-many-instance-attributes @@ -71,7 +68,6 @@ def __init__(self, data: dict, server) -> None: self.url = data["html_url"] self.number = data["number"] self.data = data - # LOG.error(data) self.server = server self.updated = datetime.strptime(self.data["updated_at"], "%Y-%m-%dT%H:%M:%SZ") self.state = data["state"] @@ -81,19 +77,12 @@ def __init__(self, data: dict, server) -> None: if data.get("draft", False): self.is_wip = True - self.labels: Dict[str, Label] = {} + self.labels: dict[str, Label] = {} if isinstance(data.get("labels", {}), list): # github for label_data in data.get("labels", []): label = Label(label_data["name"], label_data) self.labels[label_data["name"]] = label - # TODO add labels - # print(_) - # locked - # assignee - # closed_at - # closed_by - @property def status(self): return self.data["state"] @@ -101,3 +90,6 @@ def status(self): @property def is_mergeable(self): return self.state == "open" + + def is_reviewed(self) -> bool: + raise NotImplementedError diff --git a/lib/gri/label.py b/src/gri/label.py similarity index 80% rename from lib/gri/label.py rename to src/gri/label.py index 5eb9f3d..8a48057 100644 --- a/lib/gri/label.py +++ b/src/gri/label.py @@ -23,17 +23,15 @@ def __init__(self, name, data) -> None: self.value += -1 if data.get("optional", False): self.value = 1 - for unknown in set(data.keys()) - set( - [ - "blocking", - "approved", - "recommended", - "disliked", - "rejected", - "value", - "optional", - ] - ): + for unknown in set(data.keys()) - { + "blocking", + "approved", + "recommended", + "disliked", + "rejected", + "value", + "optional", + }: LOG.debug("Found unknown label field %s: %s", unknown, data.get(unknown)) def is_meta(self) -> bool: diff --git a/tox.ini b/tox.ini index 9c5c826..2680868 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,8 @@ [tox] -envlist = lint,packaging,py{36,37,38,39,310} +envlist = + lint + pkg + py{39,310,311} minversion = 4.0 isolated_build = true skip_missing_interpreters = true @@ -20,9 +23,9 @@ passenv = http_proxy https_proxy no_proxy -deps = - pip == 19.1.1 -whitelist_externals = bash +allowlist_externals = + sh + rm commands = gri --help -gri -o report.html owned incoming merged abandon draft watched @@ -35,25 +38,14 @@ passenv = {[testenv]passenv} extras = lint deps = pre-commit >= 2 - twine - readme-renderer[md] >= 24.0 - pip >= 18.0.0 - pep517 # `usedevelop = true` overrides `skip_install` instruction, it's unwanted usedevelop = false # don't install molecule itself in this env skip_install = true commands = python -m pre_commit run --all-files --show-diff-on-failure - bash -c "rm -rf {toxinidir}/dist/ && mkdir -p {toxinidir}/dist/" - python -m pep517.build \ - --source \ - --binary \ - --out-dir {toxinidir}/dist/ \ - {toxinidir} - twine check dist/* -[testenv:packaging] +[testenv:pkg] description = Do packaging/distribution. If tag is not present or PEP440 compliant upload to PYPI could fail @@ -62,21 +54,22 @@ usedevelop = false # don't install molecule itself in this env skip_install = true deps = - collective.checkdocs >= 0.2 - pep517 >= 0.8.2 - pip >= 20.2.2 - toml >= 0.10.1 - twine >= 3.2.0 # pyup: ignore + build >= 0.9.0 + twine >= 4.0.1 setenv = commands = - rm -rfv {toxinidir}/dist/ - python -m pep517.build \ - --source \ - --binary \ - --out-dir {toxinidir}/dist/ \ - {toxinidir} - # metadata validation - sh -c "python -m twine check {toxinidir}//dist/*" -whitelist_externals = - rm - sh + # build wheel and sdist using PEP-517 + {envpython} -c 'import os.path, shutil, sys; \ + dist_dir = os.path.join("{toxinidir}", "dist"); \ + os.path.isdir(dist_dir) or sys.exit(0); \ + print("Removing \{!s\} contents...".format(dist_dir), file=sys.stderr); \ + shutil.rmtree(dist_dir)' + {envpython} -m build --outdir {toxinidir}/dist/ {toxinidir} + # Validate metadata using twine + twine check --strict {toxinidir}/dist/* + # Install the wheel + sh -c 'python3 -m pip install "gri @ file://$(echo {toxinidir}/dist/*.whl)"' + # call the tool + gri --help + # Uninstall it + python3 -m pip uninstall -y gri