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
```
This commit is contained in:
Todd Gamblin 2017-09-28 10:47:08 -07:00
parent 46d5901770
commit 41a2652ef2
6 changed files with 280 additions and 25 deletions

View File

@ -27,6 +27,7 @@
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):

View File

@ -32,6 +32,7 @@
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 fmt(s):
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')

View File

@ -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)

View File

@ -29,9 +29,11 @@
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,

View File

@ -31,7 +31,7 @@
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()))

View File

@ -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