diff options
author | Todd Gamblin <tgamblin@llnl.gov> | 2017-09-28 10:47:08 -0700 |
---|---|---|
committer | Todd Gamblin <tgamblin@llnl.gov> | 2017-09-30 16:31:56 -0700 |
commit | 41a2652ef256c673dd457eebe8f8787ba151aeab (patch) | |
tree | 4dc14c389dc4e5ee6a7bf34fe72faac7681651fd | |
parent | 46d5901770010f7ad458a54fb60a3c63bb97de8c (diff) | |
download | spack-41a2652ef256c673dd457eebe8f8787ba151aeab.tar.gz spack-41a2652ef256c673dd457eebe8f8787ba151aeab.tar.bz2 spack-41a2652ef256c673dd457eebe8f8787ba151aeab.tar.xz spack-41a2652ef256c673dd457eebe8f8787ba151aeab.zip |
Add 'spack blame' command: shows contributors to packages
`spack blame` prints out the contributors to a package.
By modification time:
```
$ spack blame --time llvm
LAST_COMMIT LINES % AUTHOR EMAIL
3 days ago 2 0.6 Andrey Prokopenko <andrey.prok@gmail.com>
3 weeks ago 125 34.7 Massimiliano Culpo <massimiliano.culpo@epfl.ch>
3 weeks ago 3 0.8 Peter Scheibel <scheibel1@llnl.gov>
2 months ago 21 5.8 Adam J. Stewart <ajstewart426@gmail.com>
2 months ago 1 0.3 Gregory Becker <becker33@llnl.gov>
3 months ago 116 32.2 Todd Gamblin <tgamblin@llnl.gov>
5 months ago 2 0.6 Jimmy Tang <jcftang@gmail.com>
5 months ago 6 1.7 Jean-Paul Pelteret <jppelteret@gmail.com>
7 months ago 65 18.1 Tom Scogland <tscogland@llnl.gov>
11 months ago 13 3.6 Kelly (KT) Thompson <kgt@lanl.gov>
a year ago 1 0.3 Scott Pakin <pakin@lanl.gov>
a year ago 3 0.8 Erik Schnetter <schnetter@gmail.com>
3 years ago 2 0.6 David Beckingsale <davidbeckingsale@gmail.com>
3 days ago 360 100.0
```
Or by percent contribution:
```
$ spack blame --percent llvm
LAST_COMMIT LINES % AUTHOR EMAIL
3 weeks ago 125 34.7 Massimiliano Culpo <massimiliano.culpo@epfl.ch>
3 months ago 116 32.2 Todd Gamblin <tgamblin@llnl.gov>
7 months ago 65 18.1 Tom Scogland <tscogland@llnl.gov>
2 months ago 21 5.8 Adam J. Stewart <ajstewart426@gmail.com>
11 months ago 13 3.6 Kelly (KT) Thompson <kgt@lanl.gov>
5 months ago 6 1.7 Jean-Paul Pelteret <jppelteret@gmail.com>
3 weeks ago 3 0.8 Peter Scheibel <scheibel1@llnl.gov>
a year ago 3 0.8 Erik Schnetter <schnetter@gmail.com>
3 years ago 2 0.6 David Beckingsale <davidbeckingsale@gmail.com>
3 days ago 2 0.6 Andrey Prokopenko <andrey.prok@gmail.com>
5 months ago 2 0.6 Jimmy Tang <jcftang@gmail.com>
2 months ago 1 0.3 Gregory Becker <becker33@llnl.gov>
a year ago 1 0.3 Scott Pakin <pakin@lanl.gov>
3 days ago 360 100.0
```
-rw-r--r-- | lib/spack/llnl/util/lang.py | 70 | ||||
-rw-r--r-- | lib/spack/spack/cmd/__init__.py | 7 | ||||
-rw-r--r-- | lib/spack/spack/cmd/blame.py | 122 | ||||
-rw-r--r-- | lib/spack/spack/cmd/pkg.py | 32 | ||||
-rw-r--r-- | lib/spack/spack/hooks/case_consistency.py | 15 | ||||
-rw-r--r-- | lib/spack/spack/test/cmd/blame.py | 59 |
6 files changed, 280 insertions, 25 deletions
diff --git a/lib/spack/llnl/util/lang.py b/lib/spack/llnl/util/lang.py index 667572dd8d..07338b21ef 100644 --- a/lib/spack/llnl/util/lang.py +++ b/lib/spack/llnl/util/lang.py @@ -27,6 +27,7 @@ import re import functools import collections import inspect +from datetime import datetime from six import string_types # Ignore emacs backups when listing modules @@ -385,6 +386,75 @@ def dedupe(sequence): seen.add(x) +def pretty_date(time, now=None): + """Convert a datetime or timestamp to a pretty, relative date. + + Args: + time (datetime or int): date to print prettily + now (datetime): dateimte for 'now', i.e. the date the pretty date + is relative to (default is datetime.now()) + + Returns: + (str): pretty string like 'an hour ago', 'Yesterday', + '3 months ago', 'just now', etc. + + Adapted from https://stackoverflow.com/questions/1551382. + + """ + if now is None: + now = datetime.now() + + if type(time) is int: + diff = now - datetime.fromtimestamp(time) + elif isinstance(time, datetime): + diff = now - time + else: + raise ValueError("pretty_date requires a timestamp or datetime") + + second_diff = diff.seconds + day_diff = diff.days + + if day_diff < 0: + return '' + + if day_diff == 0: + if second_diff < 10: + return "just now" + if second_diff < 60: + return str(second_diff) + " seconds ago" + if second_diff < 120: + return "a minute ago" + if second_diff < 3600: + return str(second_diff / 60) + " minutes ago" + if second_diff < 7200: + return "an hour ago" + if second_diff < 86400: + return str(second_diff / 3600) + " hours ago" + if day_diff == 1: + return "yesterday" + if day_diff < 7: + return str(day_diff) + " days ago" + if day_diff < 28: + weeks = day_diff / 7 + if weeks == 1: + return "a week ago" + else: + return str(day_diff / 7) + " weeks ago" + if day_diff < 365: + months = day_diff / 30 + if months == 1: + return "a month ago" + elif months == 12: + months -= 1 + return str(months) + " months ago" + + diff = day_diff / 365 + if diff == 1: + return "a year ago" + else: + return str(diff) + " years ago" + + class RequiredAttributeError(ValueError): def __init__(self, message): diff --git a/lib/spack/spack/cmd/__init__.py b/lib/spack/spack/cmd/__init__.py index 08dbf55dc2..f1e36c2377 100644 --- a/lib/spack/spack/cmd/__init__.py +++ b/lib/spack/spack/cmd/__init__.py @@ -32,6 +32,7 @@ import llnl.util.tty as tty from llnl.util.lang import * from llnl.util.tty.colify import * from llnl.util.tty.color import * +from llnl.util.filesystem import working_dir import spack import spack.config @@ -287,3 +288,9 @@ def display_specs(specs, args=None, **kwargs): raise ValueError( "Invalid mode for display_specs: %s. Must be one of (paths," "deps, short)." % mode) + + +def spack_is_git_repo(): + """Ensure that this instance of Spack is a git clone.""" + with working_dir(spack.prefix): + return os.path.isdir('.git') diff --git a/lib/spack/spack/cmd/blame.py b/lib/spack/spack/cmd/blame.py new file mode 100644 index 0000000000..3bf5e37abb --- /dev/null +++ b/lib/spack/spack/cmd/blame.py @@ -0,0 +1,122 @@ +############################################################################## +# Copyright (c) 2013-2017, Lawrence Livermore National Security, LLC. +# Produced at the Lawrence Livermore National Laboratory. +# +# This file is part of Spack. +# Created by Todd Gamblin, tgamblin@llnl.gov, All rights reserved. +# LLNL-CODE-647188 +# +# For details, see https://github.com/llnl/spack +# Please also see the NOTICE and LICENSE files for our notice and the LGPL. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License (as +# published by the Free Software Foundation) version 2.1, February 1999. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY OF +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the terms and +# conditions of the GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +############################################################################## +import re + +import llnl.util.tty as tty +from llnl.util.lang import pretty_date +from llnl.util.filesystem import working_dir +from llnl.util.tty.colify import colify_table + +import spack +from spack.util.executable import which +from spack.cmd import spack_is_git_repo + + +description = "show contributors to packages" +section = "developer" +level = "long" + + +def setup_parser(subparser): + view_group = subparser.add_mutually_exclusive_group() + view_group.add_argument( + '-t', '--time', dest='view', action='store_const', const='time', + default='time', help='sort by last modification date (default)') + view_group.add_argument( + '-p', '--percent', dest='view', action='store_const', const='percent', + help='sort by percent of code') + view_group.add_argument( + '-g', '--git', dest='view', action='store_const', const='git', + help='show git blame output instead of summary') + + subparser.add_argument( + 'package_name', help='name of package to show contributions for') + + +def blame(parser, args): + # make sure this is a git repo + if not spack_is_git_repo(): + tty.die("This spack is not a git clone. Can't use 'spack pkg'") + git = which('git', required=True) + + # Get package and package file name + pkg = spack.repo.get(args.package_name) + package_py = pkg.module.__file__.rstrip('c') # .pyc -> .py + + # get git blame for the package + with working_dir(spack.prefix): + if args.view == 'git': + git('blame', package_py) + return + else: + output = git('blame', '--line-porcelain', package_py, output=str) + lines = output.split('\n') + + # Histogram authors + counts = {} + emails = {} + last_mod = {} + total_lines = 0 + for line in lines: + match = re.match(r'^author (.*)', line) + if match: + author = match.group(1) + + match = re.match(r'^author-mail (.*)', line) + if match: + email = match.group(1) + + match = re.match(r'^author-time (.*)', line) + if match: + mod = int(match.group(1)) + last_mod[author] = max(last_mod.setdefault(author, 0), mod) + + # ignore comments + if re.match(r'^\t[^#]', line): + counts[author] = counts.setdefault(author, 0) + 1 + emails.setdefault(author, email) + total_lines += 1 + + if args.view == 'time': + rows = sorted( + counts.items(), key=lambda t: last_mod[t[0]], reverse=True) + else: # args.view == 'percent' + rows = sorted(counts.items(), key=lambda t: t[1], reverse=True) + + # Print a nice table with authors and emails + table = [['LAST_COMMIT', 'LINES', '%', 'AUTHOR', 'EMAIL']] + for author, nlines in rows: + table += [[ + pretty_date(last_mod[author]), + nlines, + round(nlines / float(total_lines) * 100, 1), + author, + emails[author]]] + + table += [[''] * 5] + table += [[pretty_date(max(last_mod.values())), total_lines, '100.0'] + + [''] * 3] + + colify_table(table) diff --git a/lib/spack/spack/cmd/pkg.py b/lib/spack/spack/cmd/pkg.py index 7673955db5..8cd6534724 100644 --- a/lib/spack/spack/cmd/pkg.py +++ b/lib/spack/spack/cmd/pkg.py @@ -29,9 +29,11 @@ import os import argparse import llnl.util.tty as tty from llnl.util.tty.colify import colify +from llnl.util.filesystem import working_dir import spack from spack.util.executable import * +from spack.cmd import spack_is_git_repo description = "query packages associated with particular git revisions" section = "developer" @@ -75,26 +77,14 @@ def setup_parser(subparser): help="revision to compare to rev1 (default is HEAD)") -def get_git(fatal=True): - # cd to spack prefix to do git operations - os.chdir(spack.prefix) - - # If this is a non-git version of spack, give up. - if not os.path.isdir('.git'): - if fatal: - tty.die("No git repo in %s. Can't use 'spack pkg'" % spack.prefix) - else: - return None - - return which("git", required=True) - - def list_packages(rev): - git = get_git() pkgpath = os.path.join(spack.packages_path, 'packages') relpath = pkgpath[len(spack.prefix + os.path.sep):] + os.path.sep - output = git('ls-tree', '--full-tree', '--name-only', rev, relpath, - output=str) + + git = which('git', required=True) + with working_dir(spack.prefix): + output = git('ls-tree', '--full-tree', '--name-only', rev, relpath, + output=str) return sorted(line[len(relpath):] for line in output.split('\n') if line) @@ -105,8 +95,9 @@ def pkg_add(args): tty.die("No such package: %s. Path does not exist:" % pkg_name, filename) - git = get_git() - git('-C', spack.packages_path, 'add', filename) + git = which('git', required=True) + with working_dir(spack.prefix): + git('-C', spack.packages_path, 'add', filename) def pkg_list(args): @@ -150,6 +141,9 @@ def pkg_added(args): def pkg(parser, args): + if not spack_is_git_repo(): + tty.die("This spack is not a git clone. Can't use 'spack pkg'") + action = {'add': pkg_add, 'diff': pkg_diff, 'list': pkg_list, diff --git a/lib/spack/spack/hooks/case_consistency.py b/lib/spack/spack/hooks/case_consistency.py index 48f9606444..b76408e66e 100644 --- a/lib/spack/spack/hooks/case_consistency.py +++ b/lib/spack/spack/hooks/case_consistency.py @@ -31,7 +31,7 @@ import platform from llnl.util.filesystem import * import spack -from spack.cmd.pkg import get_git +from spack.cmd import spack_is_git_repo from spack.util.executable import * @@ -64,12 +64,15 @@ def git_case_consistency_check(path): TODO: lowercase for a long while. """ - with working_dir(path): - # Don't bother fixing case if Spack isn't in a git repository - git = get_git(fatal=False) - if git is None: - return + # Don't bother fixing case if Spack isn't in a git repository + if not spack_is_git_repo(): + return + git = which('git', required=False) + if not git: + return + + with working_dir(path): try: git_filenames = git('ls-tree', '--name-only', 'HEAD', output=str) git_filenames = set(re.split(r'\s+', git_filenames.strip())) diff --git a/lib/spack/spack/test/cmd/blame.py b/lib/spack/spack/test/cmd/blame.py new file mode 100644 index 0000000000..0031052b24 --- /dev/null +++ b/lib/spack/spack/test/cmd/blame.py @@ -0,0 +1,59 @@ +############################################################################## +# Copyright (c) 2013-2017, Lawrence Livermore National Security, LLC. +# Produced at the Lawrence Livermore National Laboratory. +# +# This file is part of Spack. +# Created by Todd Gamblin, tgamblin@llnl.gov, All rights reserved. +# LLNL-CODE-647188 +# +# For details, see https://github.com/llnl/spack +# Please also see the NOTICE and LICENSE files for our notice and the LGPL. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License (as +# published by the Free Software Foundation) version 2.1, February 1999. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY OF +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the terms and +# conditions of the GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +############################################################################## +import pytest + +import spack.cmd +from spack.main import SpackCommand +from spack.util.executable import which + +pytestmark = pytest.mark.skipif( + not which('git') or not spack.cmd.spack_is_git_repo(), + reason="needs git") + +blame = SpackCommand('blame') + + +def test_blame_by_modtime(builtin_mock): + """Sanity check the blame command to make sure it works.""" + out = blame('--time', 'mpich') + assert 'LAST_COMMIT' in out + assert 'AUTHOR' in out + assert 'EMAIL' in out + + +def test_blame_by_percent(builtin_mock): + """Sanity check the blame command to make sure it works.""" + out = blame('--percent', 'mpich') + assert 'LAST_COMMIT' in out + assert 'AUTHOR' in out + assert 'EMAIL' in out + + +def test_blame_by_git(builtin_mock, capfd): + """Sanity check the blame command to make sure it works.""" + with capfd.disabled(): + out = blame('--git', 'mpich') + assert 'Mpich' in out + assert 'mock_packages' in out |