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:
		| @@ -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): | ||||
|   | ||||
| @@ -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') | ||||
|   | ||||
							
								
								
									
										122
									
								
								lib/spack/spack/cmd/blame.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										122
									
								
								lib/spack/spack/cmd/blame.py
									
									
									
									
									
										Normal 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) | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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())) | ||||
|   | ||||
							
								
								
									
										59
									
								
								lib/spack/spack/test/cmd/blame.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								lib/spack/spack/test/cmd/blame.py
									
									
									
									
									
										Normal 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 | ||||
		Reference in New Issue
	
	Block a user
	 Todd Gamblin
					Todd Gamblin