diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index f345201..0000000 --- a/.gitattributes +++ /dev/null @@ -1,7 +0,0 @@ -[attr]py-file eol=lf -*.py py-file - -.git* export-ignore -MANIFEST.in export-ignore -codecov.yml export-ignore -.git-fame.1.md export-ignore diff --git a/.github/workflows/comment-bot.yml b/.github/workflows/comment-bot.yml index 7b05266..4451632 100644 --- a/.github/workflows/comment-bot.yml +++ b/.github/workflows/comment-bot.yml @@ -1,10 +1,9 @@ name: Comment Bot on: issue_comment: - types: [created, edited] + types: [created] pull_request_review_comment: - types: [created, edited] - + types: [created] jobs: tag: # /tag if: startsWith(github.event.comment.body, '/tag ') @@ -21,7 +20,6 @@ jobs: post = (context.eventName == "issue_comment" ? github.reactions.createForIssueComment : github.reactions.createForPullRequestReviewComment) - if (!["admin", "write"].includes(perm.data.permission)){ post({ owner: context.repo.owner, repo: context.repo.repo, @@ -50,7 +48,3 @@ jobs: post({ owner: context.repo.owner, repo: context.repo.repo, comment_id: context.payload.comment.id, content: "rocket"}) - always: - runs-on: ubuntu-latest - steps: - - run: echo prevent failure when other jobs are skipped diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5c26a02..1a35d3a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,35 +6,59 @@ on: - cron: '3 2 1 * *' # M H d m w (monthly at 2:03) jobs: check: + if: github.event_name != 'pull_request' || github.head_ref != 'devel' name: Check runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 + with: + fetch-depth: 0 - uses: actions/setup-python@v2 with: python-version: '3.x' - - name: Install + - name: set PYSHA + run: echo "PYSHA=$(python -VV | sha256sum | cut -d' ' -f1)" >> $GITHUB_ENV + - uses: actions/cache@v1 + with: + path: ~/.cache/pre-commit + key: pre-commit|${{ env.PYSHA }}|${{ hashFiles('.pre-commit-config.yaml') }} + - name: Test run: | pip install -U tox - pip install -U . - - name: Test - run: tox + tox + env: + TOXENV: 'setup.py,nodeps' + - name: Self install + run: pip install -U .[dev] + - name: Build + run: | + python setup.py sdist bdist_wheel + twine check dist/* + - uses: reviewdog/action-setup@v1 + - if: github.event_name != 'schedule' + name: flake8 + run: | + pre-commit run -a flake8 | reviewdog -f=pep8 -name=Format -tee -reporter=github-check -filter-mode nofilter env: - TOXENV: 'flake8,setup.py,nodeps' + REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Lint + run: pre-commit run -a --show-diff-on-failure test: + if: github.event_name != 'pull_request' || github.head_ref != 'devel' strategy: matrix: - python: [2.7, 3.5, 3.6, 3.7, 3.8] + python: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9] name: Python ${{ matrix.python }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 + with: + fetch-depth: 0 - uses: actions/setup-python@v2 with: python-version: ${{ matrix.python }} - name: Install - run: | - pip install -U tox setuptools_scm + run: pip install -U tox - name: Test run: tox -e py${PYVER/./} env: @@ -45,30 +69,32 @@ jobs: with: parallel: true finish: + if: github.event_name != 'pull_request' || github.head_ref != 'devel' name: Coverage needs: test runs-on: ubuntu-latest steps: - name: Coveralls Finished - uses: exoplanet-dev/coveralls-python-action@develop # https://github.com/AndreMiras/coveralls-python-action/pull/5 + uses: AndreMiras/coveralls-python-action@develop with: parallel-finished: true deploy: + if: github.event_name != 'pull_request' || github.head_ref != 'devel' name: Deploy - needs: [check, test, finish] + needs: [check, test] runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 + with: + fetch-depth: 0 - uses: actions/setup-python@v2 with: python-version: '3.x' - name: Install run: | sudo apt-get install -yqq pandoc - pip install setuptools_scm - git fetch --unshallow --tags pip install .[dev] - make build .dockerignore snapcraft.yaml + make build .dockerignore - if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') uses: casperdcl/deploy-pypi@v1 with: @@ -77,7 +103,6 @@ jobs: skip_existing: true - id: collect_assets name: Collect assets - shell: bash run: | echo "::set-output name=asset_path::$(ls dist/*.whl)" echo "::set-output name=asset_name::$(basename dist/*.whl)" @@ -85,13 +110,10 @@ jobs: echo "::set-output name=asset_name_sig::$(basename dist/*.whl.asc 2>/dev/null)" if [[ $GITHUB_REF == refs/tags/v* ]]; then echo ::set-output name=docker_tags::latest,${GITHUB_REF/refs\/tags\/v/} - echo ::set-output name=snap_channel::stable elif [[ $GITHUB_REF == refs/heads/master ]]; then echo ::set-output name=docker_tags::master - echo ::set-output name=snap_channel::candidate elif [[ $GITHUB_REF == refs/heads/devel ]]; then echo ::set-output name=docker_tags::devel - echo ::set-output name=snap_channel::edge fi git log --pretty='format:%d%n- %s%n%b---' $(git tag --sort=v:refname | tail -n2 | head -n1)..HEAD > _CHANGES.md - if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') @@ -122,36 +144,10 @@ jobs: asset_path: ${{ steps.collect_assets.outputs.asset_path_sig }} asset_name: ${{ steps.collect_assets.outputs.asset_name_sig }} asset_content_type: text/plain - - name: Snap install - uses: samuelmeuli/action-snapcraft@v1 - with: - use_lxd: true - - name: Snap build - shell: bash - run: | - export SNAPCRAFT_IMAGE_INFO='{"build_url": "https://github.com/casperdcl/git-fame/actions/runs/'$GITHUB_RUN_ID'"}' - sg lxd -c 'snapcraft --use-lxd' - env: - SNAPCRAFT_BUILD_INFO: 1 # https://snapcraft.io/blog/introducing-developer-notifications-for-snap-security-updates - - if: github.event_name == 'push' && steps.collect_assets.outputs.snap_channel - name: Snap login - uses: samuelmeuli/action-snapcraft@v1 - with: - skip_install: true - snapcraft_token: ${{ secrets.SNAP_TOKEN }} - - if: github.event_name == 'push' && steps.collect_assets.outputs.snap_channel - name: Snap deploy - shell: bash - run: | - if [ -n "$(ls git-fame*.snap 2>/dev/null)" ]; then - sudo snapcraft upload git-fame*.snap --release $CHANNEL - fi - env: - CHANNEL: ${{ steps.collect_assets.outputs.snap_channel }} - name: Docker build push uses: elgohr/Publish-Docker-Github-Action@master with: - name: ${{ github.actor }}/git-fame + name: ${{ github.repository }} tags: ${{ steps.collect_assets.outputs.docker_tags }} password: ${{ secrets.DOCKER_PWD }} username: ${{ secrets.DOCKER_USR }} @@ -159,9 +155,36 @@ jobs: - name: Docker push GitHub uses: elgohr/Publish-Docker-Github-Action@master with: - name: ${{ github.actor }}/git-fame/git-fame + name: ${{ github.repository }}/git-fame tags: ${{ steps.collect_assets.outputs.docker_tags }} password: ${{ github.token }} username: ${{ github.actor }} registry: docker.pkg.github.com no_push: ${{ steps.collect_assets.outputs.docker_tags == '' }} + deploy-snap: + if: github.event_name != 'pull_request' || github.head_ref != 'devel' + name: Deploy Snap + needs: [check, test] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - id: snap_channel + name: Snap channel + run: | + if [[ $GITHUB_REF == refs/tags/v* ]]; then + echo ::set-output name=release::stable,candidate + elif [[ $GITHUB_REF == refs/heads/master ]]; then + echo ::set-output name=release::candidate + elif [[ $GITHUB_REF == refs/heads/devel ]]; then + echo ::set-output name=release::edge + fi + - id: snap_build + uses: snapcore/action-build@v1 + - if: github.event_name == 'push' && steps.snap_channel.outputs.release + uses: snapcore/action-publish@v1 + with: + store_login: ${{ secrets.SNAP_TOKEN }} + snap: ${{ steps.snap_build.outputs.snap }} + release: ${{ steps.snap_channel.outputs.release }} diff --git a/.gitignore b/.gitignore index fad3293..76836fb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,17 +4,18 @@ *.so # Packages -*.egg-info -build/ -dist/ -snapcraft.yaml -git-fame_*_amd64.snap -.dockerignore +/gitfame/_dist_ver.py +/.eggs/ +/*.egg-info +/build/ +/dist/ +/git-fame_*_amd64.snap +/.dockerignore # Unit test / coverage reports .tox/ .coverage -__pycache__ +__pycache__/ nosetests.xml # Translations diff --git a/.meta/.snapcraft.yml b/.meta/.snapcraft.yml deleted file mode 100644 index c108e7b..0000000 --- a/.meta/.snapcraft.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: git-fame -summary: Pretty-print `git` repository collaborators sorted by contributions -version: '{version}' -adopt-info: git-fame -grade: stable -confinement: strict -base: core18 -license: MPL-2.0 -parts: - git-fame: - plugin: python - python-packages: [tqdm, pyyaml, setuptools_scm] - stage-packages: [git] - source: {source} - source-commit: '{commit}' - parse-info: [setup.py] - override-build: | - snapcraftctl build - cp $SNAPCRAFT_PART_BUILD/git-fame_completion.bash $SNAPCRAFT_PART_INSTALL/completion.sh -apps: - git-fame: - command: bin/git-fame - completer: completion.sh - plugs: [home] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..c6c7843 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,28 @@ +default_language_version: + python: python3 +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.3.0 + hooks: + - id: check-added-large-files + - id: check-case-conflict + - id: check-docstring-first + - id: check-executables-have-shebangs + - id: check-toml + - id: check-yaml + - id: end-of-file-fixer + - id: mixed-line-ending + - id: trailing-whitespace +- hooks: + - id: flake8 + additional_dependencies: + - flake8-bugbear + - flake8-comprehensions + - flake8-debugger + - flake8-string-format + repo: https://gitlab.com/pycqa/flake8 + rev: 3.8.4 +- hooks: + - id: isort + repo: https://github.com/timothycrosley/isort + rev: 5.6.4 diff --git a/LICENCE b/LICENCE index 8202689..f0b17f7 100644 --- a/LICENCE +++ b/LICENCE @@ -1,5 +1,5 @@ * files: * - MPLv2.0 2016-2019 (c) Casper da Costa-Luis + MPLv2.0 2016-2020 (c) Casper da Costa-Luis [casperdcl](https://github.com/casperdcl). diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 24816d6..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,13 +0,0 @@ -# Misc -include .coveragerc -include LICENCE -include Makefile -include tox.ini -include git-fame_completion.bash - -# Test suite -recursive-include gitfame/tests *.py - -# Examples/Documentation -include README.rst -include gitfame/git-fame.1 diff --git a/Makefile b/Makefile index 1a56398..a8b0165 100644 --- a/Makefile +++ b/Makefile @@ -71,14 +71,8 @@ gitfame/git-fame.1: .meta/.git-fame.1.md gitfame/_gitfame.py cat "$<" - |\ pandoc -o "$@" -s -t man -snapcraft.yaml: .meta/.snapcraft.yml - cat "$<" | sed -e "s/{version}/$$(python -m gitfame --version 2>&1)/g" \ - -e "s/{commit}/$$(git describe --always)/g" \ - -e 's/{source}/./g' > "$@" - .dockerignore: - echo '*' > $@ - echo '!dist/*.whl' >> $@ + @+python -c "fd=open('.dockerignore', 'w'); fd.write('*\n!dist/*.whl\n')" distclean: @+make coverclean @@ -88,6 +82,8 @@ prebuildclean: @+python -c "import shutil; shutil.rmtree('build', True)" @+python -c "import shutil; shutil.rmtree('dist', True)" @+python -c "import shutil; shutil.rmtree('git_fame.egg-info', True)" + @+python -c "import shutil; shutil.rmtree('.eggs', True)" + @+python -c "import os; os.remove('gitfame/_dist_ver.py') if os.path.exists('gitfame/_dist_ver.py') else None" coverclean: @+python -c "import os; os.remove('.coverage') if os.path.exists('.coverage') else None" @+python -c "import shutil; shutil.rmtree('gitfame/__pycache__', True)" @@ -122,9 +118,6 @@ buildupload: @make build @make pypi -snap: - @make -B snapcraft.yaml - snapcraft docker: @make build @make .dockerignore diff --git a/README.rst b/README.rst index e911fc9..f41cdcc 100644 --- a/README.rst +++ b/README.rst @@ -170,6 +170,8 @@ Documentation -v, --version Print module version and exit. --branch= Branch or tag [default: HEAD] up to which to check. --sort= [default: loc]|commits|files|hours|months. + --loc= [default: surviving]|ins(ertions)|del(etions) + What `loc` represents. Use 'ins,del' to count both. --excl= Excluded files (default: None). In no-regex mode, may be a comma-separated list. Escape (\,) for a literal comma (may require \\, in shell). @@ -182,7 +184,7 @@ Documentation May be multiple comma-separated values. -n, --no-regex Assume are comma-separated exact matches rather than regular expressions [default: False]. - NB: if regex is enabled `,` is equivalent to `|`. + NB: if regex is enabled ',' is equivalent to '|'. -s, --silent-progress Suppress `tqdm` [default: False]. --warn-binary Don't silently skip files which appear to be binary data [default: False]. @@ -280,7 +282,7 @@ We are grateful for all |GitHub-Contributions|. .. |Build-Status| image:: https://img.shields.io/github/workflow/status/casperdcl/git-fame/Test/master?logo=GitHub :target: https://github.com/casperdcl/git-fame/actions?query=workflow%3ATest -.. |Coverage-Status| image:: https://coveralls.io/repos/casperdcl/git-fame/badge.svg?branch=master +.. |Coverage-Status| image:: https://img.shields.io/coveralls/github/casperdcl/git-fame/master?logo=coveralls :target: https://coveralls.io/github/casperdcl/git-fame .. |Branch-Coverage-Status| image:: https://codecov.io/gh/casperdcl/git-fame/branch/master/graph/badge.svg :target: https://codecov.io/gh/casperdcl/git-fame @@ -304,7 +306,7 @@ We are grateful for all |GitHub-Contributions|. :target: https://github.com/casperdcl/git-fame/pulse .. |Gift-Casper| image:: https://img.shields.io/badge/gift-donate-dc10ff.svg :target: https://caspersci.uk.to/donate -.. |PyPI-Status| image:: https://img.shields.io/pypi/v/git-fame.svg +.. |PyPI-Status| image:: https://img.shields.io/pypi/v/git-fame.svg?logo=python&logoColor=white :target: https://pypi.org/project/git-fame .. |PyPI-Downloads| image:: https://img.shields.io/pypi/dm/git-fame.svg?label=pypi%20downloads&logo=python&logoColor=white :target: https://pypi.org/project/git-fame diff --git a/git-fame_completion.bash b/git-fame_completion.bash index 88db9e0..067e24b 100644 --- a/git-fame_completion.bash +++ b/git-fame_completion.bash @@ -15,6 +15,9 @@ _git_fame() --cost) COMPREPLY=($(compgen -W 'months cocomo hours commits' -- ${cur})) ;; + --loc) + COMPREPLY=($(compgen -W 'surviving insertions deletions' -- ${cur})) + ;; --format) COMPREPLY=($(compgen -W 'pipe markdown yaml json csv tsv tabulate' -- ${cur})) ;; @@ -32,7 +35,7 @@ _git_fame() ;; *) if [ ${COMP_WORDS[1]} == fame ]; then - COMPREPLY=($(compgen -dW '-h --help -v --version --cost --branch --since --sort --incl --excl -n --no-regex -s --silent-progress --warn-binary -t --bytype -w --ignore-whitespace -e --show-email --enum -M -C --format --manpath --log' -- ${cur})) + COMPREPLY=($(compgen -dW '-h --help -v --version --cost --branch --since --sort --loc --incl --excl -n --no-regex -s --silent-progress --warn-binary -t --bytype -w --ignore-whitespace -e --show-email --enum -M -C --format --manpath --log' -- ${cur})) fi ;; esac diff --git a/gitfame/__init__.py b/gitfame/__init__.py index 732cdcf..72cf18a 100644 --- a/gitfame/__init__.py +++ b/gitfame/__init__.py @@ -1,10 +1,12 @@ -from ._gitfame import __author__ -from ._gitfame import __date__ -from ._gitfame import __licence__ -from ._gitfame import __copyright__ -from ._gitfame import __version__ -from ._gitfame import __license__ -from ._gitfame import main +from ._gitfame import ( + __author__, + __copyright__, + __date__, + __licence__, + __license__, + __version__, + main, +) __all__ = ['main', '__author__', '__date__', '__licence__', '__copyright__', '__version__', '__license__'] diff --git a/gitfame/__main__.py b/gitfame/__main__.py index 938f954..7d70827 100644 --- a/gitfame/__main__.py +++ b/gitfame/__main__.py @@ -1,2 +1,3 @@ from ._gitfame import main # pragma: no cover + main() # pragma: no cover diff --git a/gitfame/_gitfame.py b/gitfame/_gitfame.py index a5419e7..afa46c8 100755 --- a/gitfame/_gitfame.py +++ b/gitfame/_gitfame.py @@ -12,6 +12,9 @@ -v, --version Print module version and exit. --branch= Branch or tag [default: HEAD] up to which to check. --sort= [default: loc]|commits|files|hours|months. + --loc= surviving|ins(ertions)|del(etions) + What `loc` represents. Use 'ins,del' to count both. + defaults to 'surviving' unless `--cost` is specified. --excl= Excluded files (default: None). In no-regex mode, may be a comma-separated list. Escape (\,) for a literal comma (may require \\, in shell). @@ -22,9 +25,11 @@ person-hours (based on commit times). Methods: month(s)|cocomo|hour(s)|commit(s). May be multiple comma-separated values. + Alters `--loc` default to imply 'ins' (COCOMO) or + 'ins,del' (hours). -n, --no-regex Assume are comma-separated exact matches rather than regular expressions [default: False]. - NB: if regex is enabled `,` is equivalent to `|`. + NB: if regex is enabled ',' is equivalent to '|'. -s, --silent-progress Suppress `tqdm` [default: False]. --warn-binary Don't silently skip files which appear to be binary data [default: False]. @@ -43,43 +48,40 @@ --manpath= Directory in which to install git-fame man pages. --log= FATAL|CRITICAL|ERROR|WARN(ING)|[default: INFO]|DEBUG|NOTSET. """ -from __future__ import print_function -from __future__ import division -# from __future__ import absolute_import -from functools import partial +from __future__ import division, print_function + import logging import os -from os import path import re import subprocess -from ._utils import TERM_WIDTH, int_cast_or_len, fext, _str, \ - check_output, tqdm, TqdmStream, print_unicode, Str, string_types, \ - merge_stats - - -def get_version_dist(name=__name__): - from pkg_resources import DistributionNotFound, get_distribution - - try: - return get_distribution(name).version - except DistributionNotFound: - return "UNKNOWN" - +# from __future__ import absolute_import +from functools import partial +from os import path +from ._utils import ( + TERM_WIDTH, + Str, + TqdmStream, + _str, + check_output, + fext, + int_cast_or_len, + merge_stats, + print_unicode, + string_types, + tqdm, +) + +# version detector. Precedence: installed dist, git, 'UNKNOWN' try: - from setuptools_scm import get_version + from ._dist_ver import __version__ except ImportError: - ROOT = path.abspath(path.dirname(path.dirname(__file__))) - if path.exists(path.join(ROOT, ".git")): - __version__ = "UNKNOWN - please install setuptools_scm" - else: - __version__ = get_version_dist() -else: try: - __version__ = get_version(root="..", relative_to=__file__) - except LookupError: - __version__ = get_version_dist() + from setuptools_scm import get_version + __version__ = get_version(root='..', relative_to=__file__) + except (ImportError, LookupError): + __version__ = "UNKNOWN" __author__ = "Casper da Costa-Luis " __date__ = "2016-2020" __licence__ = "[MPLv2.0](https://mozilla.org/MPL/2.0/)" @@ -88,17 +90,28 @@ def get_version_dist(name=__name__): __license__ = __licence__ # weird foreign language log = logging.getLogger(__name__) -RE_AUTHS = re.compile( +# processing `blame --line-porcelain` +RE_AUTHS_BLAME = re.compile( r'^\w+ \d+ \d+ (\d+)\nauthor (.+?)$.*?\ncommitter-time (\d+)', flags=re.M | re.DOTALL) -# finds all non-escaped commas -# NB: does not support escaping of escaped character -RE_CSPILT = re.compile(r'(?\s*$', flags=re.M) -# finds "boundary" line-porcelain messages RE_BLAME_BOUNDS = re.compile( r'^\w+\s+\d+\s+\d+(\s+\d+)?\s*$[^\t]*?^boundary\s*$[^\t]*?^\t.*?$\r?\n', flags=re.M | re.DOTALL) +# processing `log --format="aN%aN ct%ct" --numstat` +RE_AUTHS_LOG = re.compile(r"^aN(.+?) ct(\d+)\n\n", flags=re.M) +RE_STAT_BINARY = re.compile(r"^\s*?-\s*-.*?\n", flags=re.M) +RE_RENAME = re.compile(r"\{.+? => (.+?)\}") +# finds all non-escaped commas +# NB: does not support escaping of escaped character +RE_CSPILT = re.compile(r'(?": {"loc": int, "files": {}, "commits": int, "ctimes": [int]}}""" since = ["--since", since] if since else [] @@ -228,52 +240,93 @@ def _get_auth_stats( if include_files.search(i) if not (exclude_files and exclude_files.search(i))] log.log(logging.NOTSET, "files:\n" + '\n'.join(file_list)) + churn = churn or set() + + if churn & CHURN_SLOC: + base_cmd = git_cmd + ["blame", "--line-porcelain"] + since + else: + base_cmd = git_cmd + ["log", "--format=aN%aN ct%ct", "--numstat"] + since + + if ignore_whitespace: + base_cmd.append("-w") + if M: + base_cmd.append("-M") + if C: + base_cmd.extend(["-C", "-C"]) # twice to include file creation auth_stats = {} - for fname in tqdm(file_list, desc=gitdir if prefix_gitdir else "Processing", - disable=silent_progress, unit="file"): - git_blame_cmd = git_cmd + ["blame", "--line-porcelain", branch, fname] + \ - since - if prefix_gitdir: - fname = path.join(gitdir, fname) - if ignore_whitespace: - git_blame_cmd.append("-w") - if M: - git_blame_cmd.append("-M") - if C: - git_blame_cmd.extend(["-C", "-C"]) # twice to include file creation - try: - blame_out = check_output(git_blame_cmd, stderr=subprocess.STDOUT) - except Exception as e: - getattr(log, "warn" if warn_binary else "debug")(fname + ':' + str(e)) - continue - log.log(logging.NOTSET, blame_out) - # Strip boundary messages, - # preventing user with nearest commit to boundary owning the LOC - blame_out = RE_BLAME_BOUNDS.sub('', blame_out) - loc_auth_times = RE_AUTHS.findall(blame_out) + def stats_append(fname, auth, loc, tstamp): + auth = _str(auth) + tstamp = int(tstamp) + try: + auth_stats[auth]["loc"] += loc + except KeyError: + auth_stats[auth] = {"loc": loc, "files": {fname}, "ctimes": []} + else: + auth_stats[auth]["files"].add(fname) + auth_stats[auth]["ctimes"].append(tstamp) - for loc, auth, tstamp in loc_auth_times: # for each chunk - loc = int(loc) - auth = _str(auth) - tstamp = int(tstamp) + if bytype: + fext_key = ("." + fext(fname)) if fext(fname) else "._None_ext" + # auth_stats[auth].setdefault(fext_key, 0) try: - auth_stats[auth]["loc"] += loc + auth_stats[auth][fext_key] += loc except KeyError: - auth_stats[auth] = {"loc": loc, "files": set([fname]), "ctimes": []} - else: - auth_stats[auth]["files"].add(fname) - auth_stats[auth]["ctimes"].append(tstamp) + auth_stats[auth][fext_key] = loc - if bytype: - fext_key = ("." + fext(fname)) if fext(fname) else "._None_ext" - # auth_stats[auth].setdefault(fext_key, 0) - try: - auth_stats[auth][fext_key] += loc - except KeyError: - auth_stats[auth][fext_key] = loc + if churn & CHURN_SLOC: + for fname in tqdm(file_list, desc=gitdir if prefix_gitdir else "Processing", + disable=silent_progress, unit="file"): + if prefix_gitdir: + fname = path.join(gitdir, fname) + try: + blame_out = check_output( + base_cmd + [branch, fname], stderr=subprocess.STDOUT) + except Exception as err: + getattr(log, "warn" if warn_binary else "debug")(fname + ':' + str(err)) + continue + log.log(logging.NOTSET, blame_out) + + # Strip boundary messages, + # preventing user with nearest commit to boundary owning the LOC + blame_out = RE_BLAME_BOUNDS.sub('', blame_out) + loc_auth_times = RE_AUTHS_BLAME.findall(blame_out) + + for loc, auth, tstamp in loc_auth_times: # for each chunk + loc = int(loc) + stats_append(fname, auth, loc, tstamp) + else: + with tqdm(total=1, desc=gitdir if prefix_gitdir else "Processing", + disable=silent_progress, unit="repo") as t: + blame_out = check_output(base_cmd + [branch], stderr=subprocess.STDOUT) + t.update() + log.log(logging.NOTSET, blame_out) + + # Strip binary files + for fname in set(RE_STAT_BINARY.findall(blame_out)): + getattr(log, "warn" if warn_binary else "debug")( + "binary:" + fname.strip()) + blame_out = RE_STAT_BINARY.sub('', blame_out) + + blame_out = RE_AUTHS_LOG.split(blame_out) + blame_out = zip(blame_out[1::3], blame_out[2::3], blame_out[3::3]) + for auth, tstamp, fnames in blame_out: + fnames = fnames.split('\naN', 1)[0] + for i in fnames.strip().split('\n'): + try: + inss, dels, fname = i.split('\t') + except ValueError: + log.warn(i) + else: + fname = RE_RENAME.sub(r'\\2', fname) + loc = ( + int(inss) if churn & CHURN_INS and inss else 0) + ( + int(dels) if churn & CHURN_DEL and dels else 0) + stats_append(fname, auth, loc, tstamp) + + # quickly count commits (even if no surviving loc) log.log(logging.NOTSET, "authors:" + '; '.join(auth_stats.keys())) auth_commits = check_output( git_cmd + ["shortlog", "-s", "-e", branch] + since) @@ -288,7 +341,7 @@ def _get_auth_stats( auth_stats[auth]["commits"] += int(ncom) except KeyError: auth_stats[auth] = {"loc": 0, - "files": set([]), + "files": set(), "commits": int(ncom), "ctimes": []} if show_email: @@ -298,7 +351,7 @@ def _get_auth_stats( auth_stats = {} for auth, stats in getattr(old, 'iteritems', old.items)(): i = auth_stats.setdefault(auth2em[auth], {"loc": 0, - "files": set([]), + "files": set(), "commits": 0, "ctimes": []}) i["loc"] += stats["loc"] @@ -347,6 +400,20 @@ def run(args): include_files = re.compile(args.incl) # include_files = re.compile(args.incl, flags=re.M) + cost = set(args.cost.lower().split(',')) if args.cost else set() + churn = set(args.loc.lower().split(',')) if args.loc else set() + if not churn: + if cost & COST_HOURS: + churn = CHURN_INS | CHURN_DEL + elif cost & COST_MONTHS: + churn = CHURN_INS + else: + churn = CHURN_SLOC + + if churn & (CHURN_INS | CHURN_DEL) and args.excl: + log.warn("--loc=ins,del includes historical files" + " which may need to be added to --excl") + auth_stats = {} statter = partial( _get_auth_stats, @@ -355,12 +422,14 @@ def run(args): silent_progress=args.silent_progress, ignore_whitespace=args.ignore_whitespace, M=args.M, C=args.C, warn_binary=args.warn_binary, bytype=args.bytype, - show_email=args.show_email, prefix_gitdir=len(gitdirs) > 1) + show_email=args.show_email, prefix_gitdir=len(gitdirs) > 1, + churn=churn) # concurrent multi-repo processing if len(gitdirs) > 1: try: from concurrent.futures import ThreadPoolExecutor # NOQA + from tqdm.contrib.concurrent import thread_map mapper = partial(thread_map, desc="Repos", unit="repo", miniters=1, disable=args.silent_progress or len(gitdirs) <= 1) @@ -376,7 +445,7 @@ def run(args): else: auth_stats[auth] = stats - stats_tot = dict((k, 0) for stats in auth_stats.values() for k in stats) + stats_tot = {k: 0 for stats in auth_stats.values() for k in stats} log.debug(stats_tot) for k in stats_tot: stats_tot[k] = sum(int_cast_or_len(stats.get(k, 0)) @@ -392,7 +461,7 @@ def run(args): print_unicode(tabulate( auth_stats, stats_tot, - args.sort, args.bytype, args.format, args.cost, args.enum)) + args.sort, args.bytype, args.format, cost, args.enum)) def get_main_parser(): @@ -411,11 +480,12 @@ def main(args=None): log.debug(args) if args.manpath is not None: + import sys from os import path from shutil import copyfile - from pkg_resources import resource_filename, Requirement - import sys - fi = resource_filename(Requirement.parse('git-fame'), 'gitfame/git-fame.1') + + from pkg_resources import resource_filename + fi = resource_filename(__name__, 'git-fame.1') fo = path.join(args.manpath, 'git-fame.1') copyfile(fi, fo) log.info("written:" + fo) diff --git a/gitfame/_utils.py b/gitfame/_utils.py index 3f2d209..0dcf92e 100644 --- a/gitfame/_utils.py +++ b/gitfame/_utils.py @@ -1,10 +1,11 @@ from __future__ import print_function -from functools import partial -import sys -import subprocess + import logging +import subprocess +import sys +from functools import partial -from tqdm import tqdm +from tqdm import tqdm as tqdm_std from tqdm.utils import _screen_shape_wrapper try: @@ -22,10 +23,10 @@ try: from threading import RLock except ImportError: - pass + tqdm = tqdm_std else: - tqdm.set_lock(RLock()) - tqdm = partial(tqdm, lock_args=(False,)) + tqdm_std.set_lock(RLock()) + tqdm = partial(tqdm_std, lock_args=(False,)) __author__ = "Casper da Costa-Luis " __date__ = "2016-2020" @@ -45,7 +46,7 @@ class TqdmStream(object): @classmethod def write(cls, msg): - tqdm.write(msg, end='') + tqdm_std.write(msg, end='') def check_output(*a, **k): diff --git a/gitfame/git-fame.1 b/gitfame/git-fame.1 index a16a99e..312ebf7 100644 --- a/gitfame/git-fame.1 +++ b/gitfame/git-fame.1 @@ -59,6 +59,13 @@ Branch or tag [default: HEAD] up to which to check. .RS .RE .TP +.B \-\-loc=\f[I]type\f[] +[default: surviving]|ins(ertions)|del(etions) What \f[C]loc\f[] +represents. +Use \[aq]ins,del\[aq] to count both. +.RS +.RE +.TP .B \-\-excl=\f[I]f\f[] Excluded files (default: None). In no\-regex mode, may be a comma\-separated list. @@ -89,7 +96,7 @@ May be multiple comma\-separated values. .B \-n, \-\-no\-regex Assume are comma\-separated exact matches rather than regular expressions [default: False]. -NB: if regex is enabled \f[C],\f[] is equivalent to \f[C]|\f[]. +NB: if regex is enabled \[aq],\[aq] is equivalent to \[aq]|\[aq]. .RS .RE .TP diff --git a/gitfame/tests/tests_gitfame.py b/gitfame/tests/tests_gitfame.py index 618bc95..0a55913 100644 --- a/gitfame/tests/tests_gitfame.py +++ b/gitfame/tests/tests_gitfame.py @@ -1,29 +1,32 @@ from __future__ import unicode_literals import sys +from json import loads from os import path from shutil import rmtree from tempfile import mkdtemp +from textwrap import dedent + # import re # from nose import with_setup from nose.plugins.skip import SkipTest + +from gitfame import _gitfame, main + # from io import IOBase # to support unicode strings from gitfame._utils import StringIO -from gitfame import _gitfame -from gitfame import main -from textwrap import dedent # test data auth_stats = { - u'Not Committed Yet': {'files': set([ + u'Not Committed Yet': {'files': { 'gitfame/_gitfame.py', 'gitfame/_utils.py', 'Makefile', 'MANIFEST.in' - ]), + }, 'loc': 75, 'ctimes': [], 'commits': 0}, - u'Casper da Costa-Luis': {'files': set([ + u'Casper da Costa-Luis': {'files': { 'gitfame/_utils.py', 'gitfame/__main__.py', 'setup.cfg', 'gitfame/_gitfame.py', 'gitfame/__init__.py', 'git-fame_completion.bash', 'Makefile', 'MANIFEST.in', '.gitignore', - 'setup.py']), 'loc': 538, 'ctimes': [ + 'setup.py'}, 'loc': 538, 'ctimes': [ 1510942009, 1517426360, 1532103452, 1543323944, 1548030670, 1459558286, 1510942009, 1459559144, 1481150373, 1510942009, 1548030670, 1517178199, 1481150379, 1517426360, 1548030670, 1459625059, 1510942009, 1517426360, @@ -51,8 +54,8 @@ def test_tabulate(): def test_tabulate_cost(): """Test cost estimates""" - assert (_gitfame.tabulate( - auth_stats, stats_tot, cost="hours,COCOMO") == dedent("""\ + assert (_gitfame.tabulate(auth_stats, stats_tot, cost={"hours", "months"}) == dedent( + """\ Total commits: 35 Total files: 14 Total hours: 5.5 @@ -114,7 +117,6 @@ def test_tabulate_yaml(): def test_tabulate_json(): """Test JSON tabulate""" - from json import loads res = loads(_gitfame.tabulate(auth_stats, stats_tot, backend='json')) assert (res == loads(dedent("""\ {"total": {"files": 14, "loc": 613, "commits": 35}, @@ -149,7 +151,6 @@ def test_tabulate_tabulate(): def test_tabulate_enum(): """Test --enum tabulate""" - from json import loads res = loads(_gitfame.tabulate( auth_stats, stats_tot, backend='json', row_nums=True)) assert res['columns'][0] == '#' @@ -216,6 +217,11 @@ def test_main(): ['--no-regex'], ['--no-regex', '--incl', 'setup.py,README.rst'], ['--excl', r'.*\.py'], + ['--loc', 'ins,del'], + ['--cost', 'hour'], + ['--cost', 'month'], + ['--cost', 'month', '--excl', r'.*\.py'], + ['-e'], ['-w'], ['-M'], ['-C'], diff --git a/gitfame/tests/tests_utils.py b/gitfame/tests/tests_utils.py index 1c1491e..cce5d3d 100644 --- a/gitfame/tests/tests_utils.py +++ b/gitfame/tests/tests_utils.py @@ -1,4 +1,5 @@ from __future__ import unicode_literals + from gitfame import _utils diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ca661d3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,7 @@ +[build-system] +requires = ["setuptools>=42", "wheel", "setuptools_scm[toml]>=3.4"] +build-backend = "setuptools.build_meta" + +[tool.setuptools_scm] +write_to = "gitfame/_dist_ver.py" +write_to_template = "__version__ = '{version}'\n" diff --git a/setup.cfg b/setup.cfg index b9bffee..17554b6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,99 @@ +[metadata] +name = git-fame +url = https://github.com/casperdcl/git-fame +project_urls = + Changelog = https://github.com/casperdcl/git-fame/releases + Documentation = https://github.com/casperdcl/git-fame/#git-fame +licence = MPL 2.0 +license_file = LICENCE +description = Pretty-print `git` repository collaborators sorted by contributions +long_description = file: README.rst +long_description_content_type = text/x-rst +author = Casper da Costa-Luis +author_email = casper.dcl@physics.org +keywords = git, blame, git-blame, git-log, code-analysis, cost, loc, author, commit, shortlog, ls-files +platforms = any +provides = gitfame +# Trove classifiers (https://pypi.org/pypi?%3Aaction=list_classifiers) +classifiers = + Development Status :: 5 - Production/Stable + Environment :: Console + Environment :: MacOS X + Environment :: Other Environment + Environment :: Win32 (MS Windows) + Environment :: X11 Applications + Framework :: IPython + Intended Audience :: Developers + Intended Audience :: Education + Intended Audience :: End Users/Desktop + Intended Audience :: Other Audience + Intended Audience :: System Administrators + License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0) + Operating System :: MacOS + Operating System :: MacOS :: MacOS X + Operating System :: Microsoft + Operating System :: Microsoft :: MS-DOS + Operating System :: Microsoft :: Windows + Operating System :: POSIX + Operating System :: POSIX :: BSD + Operating System :: POSIX :: BSD :: FreeBSD + Operating System :: POSIX :: Linux + Operating System :: POSIX :: SunOS/Solaris + Operating System :: Unix + Programming Language :: Python + Programming Language :: Python :: 2 + Programming Language :: Python :: 2.7 + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.5 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: Implementation + Programming Language :: Python :: Implementation :: IronPython + Programming Language :: Python :: Implementation :: PyPy + Programming Language :: Unix Shell + Topic :: Desktop Environment + Topic :: Education :: Computer Aided Instruction (CAI) + Topic :: Education :: Testing + Topic :: Office/Business + Topic :: Other/Nonlisted Topic + Topic :: Software Development :: Build Tools + Topic :: Software Development :: Libraries + Topic :: Software Development :: Libraries :: Python Modules + Topic :: Software Development :: Pre-processors + Topic :: Software Development :: User Interfaces + Topic :: System :: Installation/Setup + Topic :: System :: Logging + Topic :: System :: Monitoring + Topic :: System :: Shells + Topic :: Terminals + Topic :: Utilities +[options] +setup_requires = setuptools>=42; setuptools_scm[toml]>=3.4 +install_requires = argopt>=0.3.5; setuptools; tabulate; tqdm>=4.44.0 +python_requires = >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.* +tests_require = nose; flake8; coverage +include_package_data = True +packages = find: +[options.extras_require] +yaml = pyyaml +tabulate = +full = pyyaml +dev = pyyaml; py-make>=0.1.0; twine; wheel; pre-commit +[options.entry_points] +console_scripts = + git-fame = gitfame:main +[options.package_data] +gitfame = git-fame.1 [bdist_wheel] universal = 1 [flake8] -max-line-length=80 -ignore=E111,E114 -exclude=.eggs,.tox,build +ignore = E111,E114 +max_line_length = 88 +exclude = .eggs,.tox,dist,build,dist,.git,__pycache__ + +[isort] +profile = black +known_first_party = gitfame,tests diff --git a/setup.py b/setup.py old mode 100644 new mode 100755 index bc52819..dc57a78 --- a/setup.py +++ b/setup.py @@ -1,136 +1,26 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -from ast import literal_eval -from io import open as io_open -import os -try: - from setuptools import setup -except ImportError: - from distutils.core import setup import sys +from os import path -try: - if '--cython' in sys.argv: - sys.argv.remove('--cython') - from Cython.Build import cythonize - else: - raise ImportError('--cython') -except ImportError: - def cythonize(*args, **kwargs): - return [] - +from setuptools import setup -__author__ = None -__licence__ = None -src_dir = os.path.abspath(os.path.dirname(__file__)) -main_file = os.path.join(src_dir, 'gitfame', '_gitfame.py') -for line in io_open(main_file, mode='r'): - if line.startswith('__author__'): - __author__ = literal_eval(line.split('=', 1)[1].strip()) - elif line.startswith('__licence__'): - __licence__ = literal_eval(line.split('=', 1)[1].strip()) - -# Executing makefile commands if specified -if sys.argv[1].lower().strip() == 'make': +src_dir = path.abspath(path.dirname(__file__)) +if sys.argv[1].lower().strip() == 'make': # exec Makefile commands import pymake - # Filename of the makefile - fpath = os.path.join(src_dir, 'Makefile') + fpath = path.join(src_dir, 'Makefile') pymake.main(['-f', fpath] + sys.argv[2:]) # Stop to avoid setup.py raising non-standard command error sys.exit(0) -extras_require = dict(yaml=['pyyaml'], tabulate=[]) -extras_require['full'] = list(set(sum( - extras_require.values(), []))) -extras_require['dev'] = list(set( - extras_require['full'] + ['py-make>=0.1.0', 'twine', 'wheel'])) +ext_modules = [] +if '--cython' in sys.argv: + sys.argv.remove('--cython') + try: + from Cython.Build import cythonize + ext_modules = cythonize([ + "gitfame/_gitfame.py", "gitfame/_utils.py"], nthreads=2) + except ImportError: + pass -README_rst = '' -fndoc = os.path.join(src_dir, 'README.rst') -with io_open(fndoc, mode='r', encoding='utf-8') as fd: - README_rst = fd.read() -setup( - name='git-fame', - use_scm_version=True, - setup_requires=["setuptools_scm"], - description='Pretty-print `git` repository collaborators' - ' sorted by contributions', - long_description=README_rst, - license=__licence__.lstrip('[').split(']')[0], - author=__author__.split('<')[0].strip(), - author_email=__author__.split('<')[1][:-1], - url='https://github.com/casperdcl/git-fame', - platforms=['any'], - packages=['gitfame'], - provides=['gitfame'], - install_requires=['argopt>=0.3.5', 'tabulate', 'tqdm>=4.44.0'], - extras_require=extras_require, - entry_points={'console_scripts': ['git-fame=gitfame:main'], }, - package_data={'gitfame': ['LICENCE', 'git-fame.1']}, - ext_modules=cythonize(["gitfame/_gitfame.py", "gitfame/_utils.py"], - nthreads=2), - python_requires='>=2.7, !=3.0.*, !=3.1.*', - classifiers=[ - # Trove classifiers - # (https://pypi.org/pypi?%3Aaction=list_classifiers) - 'Development Status :: 5 - Production/Stable', - 'Environment :: Console', - 'Environment :: MacOS X', - 'Environment :: Other Environment', - 'Environment :: Win32 (MS Windows)', - 'Environment :: X11 Applications', - 'Framework :: IPython', - 'Intended Audience :: Developers', - 'Intended Audience :: Education', - 'Intended Audience :: End Users/Desktop', - 'Intended Audience :: Other Audience', - 'Intended Audience :: System Administrators', - 'License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)', - 'Operating System :: MacOS', - 'Operating System :: MacOS :: MacOS X', - 'Operating System :: Microsoft', - 'Operating System :: Microsoft :: MS-DOS', - 'Operating System :: Microsoft :: Windows', - 'Operating System :: POSIX', - 'Operating System :: POSIX :: BSD', - 'Operating System :: POSIX :: BSD :: FreeBSD', - 'Operating System :: POSIX :: Linux', - 'Operating System :: POSIX :: SunOS/Solaris', - 'Operating System :: Unix', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.2', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: Implementation', - 'Programming Language :: Python :: Implementation :: IronPython', - 'Programming Language :: Python :: Implementation :: PyPy', - 'Programming Language :: Unix Shell', - 'Topic :: Desktop Environment', - 'Topic :: Education :: Computer Aided Instruction (CAI)', - 'Topic :: Education :: Testing', - 'Topic :: Office/Business', - 'Topic :: Other/Nonlisted Topic', - 'Topic :: Software Development :: Build Tools', - 'Topic :: Software Development :: Libraries', - 'Topic :: Software Development :: Libraries :: Python Modules', - 'Topic :: Software Development :: Pre-processors', - 'Topic :: Software Development :: User Interfaces', - 'Topic :: System :: Installation/Setup', - 'Topic :: System :: Logging', - 'Topic :: System :: Monitoring', - 'Topic :: System :: Shells', - 'Topic :: Terminals', - 'Topic :: Utilities' - ], - keywords='git blame git-blame git-log code-analysis cost loc' - ' author commit shortlog ls-files', - test_suite='nose.collector', - tests_require=['nose', 'flake8', 'coverage'], -) +setup(use_scm_version=True, test_suite='nose.collector', ext_modules=ext_modules) diff --git a/snapcraft.yaml b/snapcraft.yaml new file mode 100644 index 0000000..93f2067 --- /dev/null +++ b/snapcraft.yaml @@ -0,0 +1,31 @@ +name: git-fame +summary: Pretty-print `git` repository collaborators sorted by contributions +description: https://github.com/casperdcl/git-fame +adopt-info: git-fame +grade: stable +confinement: strict +base: core18 +license: MPL-2.0 +parts: + git-fame: + plugin: python + python-packages: [pyyaml] + source: . + build-snaps: + - snapd + build-packages: [git] + stage-packages: [git] + override-build: | + snapcraftctl build + # prevent user site packages interfering with this snap - reference: + # https://github.com/snapcore/snapcraft/blob/19393ef36cd773a28131cec10cc0bfb3bf9c7e77/tools/snapcraft-override-build.sh#L18 + sed -ri 's/^(ENABLE_USER_SITE = )None$/\1False/' $SNAPCRAFT_PART_INSTALL/usr/lib/python*/site.py + cp $SNAPCRAFT_PART_BUILD/git-fame_completion.bash $SNAPCRAFT_PART_INSTALL/completion.sh + override-stage: | + snapcraftctl stage + snapcraftctl set-version $(usr/bin/python3 -m gitfame --version) +apps: + git-fame: + command: bin/git-fame + completer: completion.sh + plugs: [home] diff --git a/tox.ini b/tox.ini index 4a8bd77..a10cd76 100644 --- a/tox.ini +++ b/tox.ini @@ -5,13 +5,12 @@ [tox] # deprecation warning: py{26,32,33,34} -envlist = py{27,33,34,35,36,37,38,py,py3}, flake8, setup.py, nodeps +envlist = py{27,35,36,37,38,39,py,py3}, flake8, setup.py, nodeps +isolated_build = True [core] -deps = - nose -commands = - nosetests -d -v gitfame/ +deps = nose +commands = nosetests -d -v gitfame/ [coverage] deps = @@ -52,8 +51,7 @@ deps = {[extra]deps} [testenv:flake8] deps = flake8 -commands = - flake8 -j 8 --count --statistics . +commands = flake8 -j 8 --count --statistics . [testenv:setup.py] deps =