Added support for gravatars in the changes, blame and timeline modules.

References to gravatar images are generated with HTML and XML outputs only
as these are the only formats where referencing gravatars makes sense
right now. The HTMLEmbedded format, for example, does not link to any
gravatars as that format prohibits the use of external links.

To accommodate the new images; the width of the generated HTML page has
been slightly increased. However, the HTML page should still fit on a
1280 display.
This commit is contained in:
Adam Waldenberg 2013-07-20 11:45:11 +02:00
parent d07da3dc69
commit 35852f9859
6 changed files with 128 additions and 44 deletions

View File

@ -25,6 +25,7 @@ from changes import FileDiff
import comment import comment
import filtering import filtering
import format import format
import gravatar
import interval import interval
import missing import missing
import multiprocessing import multiprocessing
@ -61,16 +62,16 @@ class BlameThread(threading.Thread):
for j in git_blame_r.readlines(): for j in git_blame_r.readlines():
j = j.decode("utf-8", "replace") j = j.decode("utf-8", "replace")
if Blame.is_blame_line(j): if Blame.is_blame_line(j):
author = Blame.get_author(j) author_mail = Blame.get_author_mail(j)
content = Blame.get_content(j) content = Blame.get_content(j)
__blame_lock__.acquire() # Global lock used to protect calls from here... __blame_lock__.acquire() # Global lock used to protect calls from here...
if self.blames.get((author, self.filename), None) == None: if self.blames.get((author_mail, self.filename), None) == None:
self.blames[(author, self.filename)] = BlameEntry() self.blames[(author_mail, self.filename)] = BlameEntry()
(comments, is_inside_comment) = comment.handle_comment_block(is_inside_comment, self.extension, content) (comments, is_inside_comment) = comment.handle_comment_block(is_inside_comment, self.extension, content)
self.blames[(author, self.filename)].comments += comments self.blames[(author_mail, self.filename)].comments += comments
self.blames[(author, self.filename)].rows += 1 self.blames[(author_mail, self.filename)].rows += 1
__blame_lock__.release() # ...to here. __blame_lock__.release() # ...to here.
git_blame_r.close() git_blame_r.close()
@ -92,7 +93,7 @@ class Blame:
if FileDiff.is_valid_extension(row) and not filtering.set_filtered(FileDiff.get_filename(row)): if FileDiff.is_valid_extension(row) and not filtering.set_filtered(FileDiff.get_filename(row)):
if not missing.add(row): if not missing.add(row):
blame_string = "git blame -w {0} ".format("-C -C -M" if hard else "") + \ blame_string = "git blame -e -w {0} ".format("-C -C -M" if hard else "") + \
interval.get_since() + interval.get_ref() + " -- \"" + row + "\"" interval.get_since() + interval.get_ref() + " -- \"" + row + "\""
thread = BlameThread(blame_string, FileDiff.get_extension(row), self.blames, row.strip()) thread = BlameThread(blame_string, FileDiff.get_extension(row), self.blames, row.strip())
thread.daemon = True thread.daemon = True
@ -117,9 +118,9 @@ class Blame:
return string.find(" (") != -1 return string.find(" (") != -1
@staticmethod @staticmethod
def get_author(string): def get_author_mail(string):
author = re.search(" \((.*?)\d\d\d\d-\d\d-\d\d", string) author_mail = re.search(" \((.*?)\d\d\d\d-\d\d-\d\d", string)
return author.group(1).strip() return author_mail.group(1).strip().lstrip("<").rstrip(">")
@staticmethod @staticmethod
def get_content(string): def get_content(string):
@ -150,8 +151,9 @@ BLAME_INFO_TEXT = N_("Below are the number of rows from each author that have su
"intact in the current revision") "intact in the current revision")
class BlameOutput(Outputable): class BlameOutput(Outputable):
def __init__(self, hard): def __init__(self, changes, hard):
self.hard = hard self.hard = hard
self.changes = changes
Outputable.__init__(self) Outputable.__init__(self)
def output_html(self): def output_html(self):
@ -171,14 +173,20 @@ class BlameOutput(Outputable):
for i, entry in enumerate(blames): for i, entry in enumerate(blames):
work_percentage = str("{0:.2f}".format(100.0 * entry[1].rows / total_blames)) work_percentage = str("{0:.2f}".format(100.0 * entry[1].rows / total_blames))
authorname = self.changes.get_authorname_from_email(entry[0])
blame_xml += "<tr " + ("class=\"odd\">" if i % 2 == 1 else ">") blame_xml += "<tr " + ("class=\"odd\">" if i % 2 == 1 else ">")
blame_xml += "<td>" + entry[0] + "</td>"
if format.get_selected() == "html":
blame_xml += "<td><img src=\"{0}\"/>{1}</td>".format(gravatar.get_url(entry[0]), authorname)
else:
blame_xml += "<td>" + authorname + "</td>"
blame_xml += "<td>" + str(entry[1].rows) + "</td>" blame_xml += "<td>" + str(entry[1].rows) + "</td>"
blame_xml += "<td>" + "{0:.2f}".format(100.0 * entry[1].comments / entry[1].rows) + "</td>" blame_xml += "<td>" + "{0:.2f}".format(100.0 * entry[1].comments / entry[1].rows) + "</td>"
blame_xml += "<td style=\"display: none\">" + work_percentage + "</td>" blame_xml += "<td style=\"display: none\">" + work_percentage + "</td>"
blame_xml += "</tr>" blame_xml += "</tr>"
chart_data += "{{label: \"{0}\", data: {1}}}".format(entry[0], work_percentage) chart_data += "{{label: \"{0}\", data: {1}}}".format(authorname, work_percentage)
if blames[-1] != entry: if blames[-1] != entry:
chart_data += ", " chart_data += ", "
@ -214,7 +222,8 @@ class BlameOutput(Outputable):
print(textwrap.fill(_(BLAME_INFO_TEXT) + ":", width=terminal.get_size()[0]) + "\n") print(textwrap.fill(_(BLAME_INFO_TEXT) + ":", width=terminal.get_size()[0]) + "\n")
terminal.printb(_("Author").ljust(21) + _("Rows").rjust(10) + _("% in comments").rjust(20)) terminal.printb(_("Author").ljust(21) + _("Rows").rjust(10) + _("% in comments").rjust(20))
for i in sorted(__blame__.get_summed_blames().items()): for i in sorted(__blame__.get_summed_blames().items()):
print(i[0].ljust(20)[0:20], end=" ") authorname = self.changes.get_authorname_from_email(i[0])
print(authorname.ljust(20)[0:20], end=" ")
print(str(i[1].rows).rjust(10), end=" ") print(str(i[1].rows).rjust(10), end=" ")
print("{0:.2f}".format(100.0 * i[1].comments / i[1].rows).rjust(19)) print("{0:.2f}".format(100.0 * i[1].comments / i[1].rows).rjust(19))
@ -225,10 +234,12 @@ class BlameOutput(Outputable):
blame_xml = "" blame_xml = ""
for i in sorted(__blame__.get_summed_blames().items()): for i in sorted(__blame__.get_summed_blames().items()):
name_xml = "\t\t\t\t<name>" + i[0] + "</name>\n" authorname = self.changes.get_authorname_from_email(i[0])
name_xml = "\t\t\t\t<name>" + authorname + "</name>\n"
gravatar_xml = "\t\t\t\t<gravatar>" + gravatar.get_url(i[0]) + "</gravatar>\n"
rows_xml = "\t\t\t\t<rows>" + str(i[1].rows) + "</rows>\n" rows_xml = "\t\t\t\t<rows>" + str(i[1].rows) + "</rows>\n"
percentage_in_comments_xml = ("\t\t\t\t<percentage-in-comments>" + "{0:.2f}".format(100.0 * i[1].comments / i[1].rows) + percentage_in_comments_xml = ("\t\t\t\t<percentage-in-comments>" + "{0:.2f}".format(100.0 * i[1].comments / i[1].rows) +
"</percentage-in-comments>\n") "</percentage-in-comments>\n")
blame_xml += "\t\t\t<author>\n" + name_xml + rows_xml + percentage_in_comments_xml + "\t\t\t</author>\n" blame_xml += "\t\t\t<author>\n" + name_xml + gravatar_xml + rows_xml + percentage_in_comments_xml + "\t\t\t</author>\n"
print("\t<blame>\n" + message_xml + "\t\t<authors>\n" + blame_xml + "\t\t</authors>\n\t</blame>") print("\t<blame>\n" + message_xml + "\t\t<authors>\n" + blame_xml + "\t\t</authors>\n\t</blame>")

View File

@ -23,6 +23,8 @@ from localization import N_
from outputable import Outputable from outputable import Outputable
import extensions import extensions
import filtering import filtering
import format
import gravatar
import interval import interval
import os import os
import subprocess import subprocess
@ -66,10 +68,11 @@ class Commit:
self.filediffs = [] self.filediffs = []
commit_line = string.split("|") commit_line = string.split("|")
if commit_line.__len__() == 3: if commit_line.__len__() == 4:
self.date = commit_line[0] self.date = commit_line[0]
self.sha = commit_line[1] self.sha = commit_line[1]
self.author = commit_line[2].strip() self.author = commit_line[2].strip()
self.email = commit_line[3].strip()
def add_filediff(self, filediff): def add_filediff(self, filediff):
self.filediffs.append(filediff) self.filediffs.append(filediff)
@ -79,7 +82,7 @@ class Commit:
@staticmethod @staticmethod
def is_commit_line(string): def is_commit_line(string):
return string.split("|").__len__() == 3 return string.split("|").__len__() == 4
class AuthorInfo: class AuthorInfo:
insertions = 0 insertions = 0
@ -87,9 +90,12 @@ class AuthorInfo:
commits = 0 commits = 0
class Changes: class Changes:
authors = {}
authors_dateinfo = {}
def __init__(self, hard): def __init__(self, hard):
self.commits = [] self.commits = []
git_log_r = subprocess.Popen("git log --pretty=\"%cd|%H|%aN\" --stat=100000,8192 --no-merges -w " + git_log_r = subprocess.Popen("git log --pretty=\"%cd|%H|%aN|%aE\" --stat=100000,8192 --no-merges -w " +
interval.get_since() + interval.get_until() + interval.get_since() + interval.get_until() +
"{0} --date=short".format("-C -C -M" if hard else ""), "{0} --date=short".format("-C -C -M" if hard else ""),
shell=True, bufsize=1, stdout=subprocess.PIPE).stdout shell=True, bufsize=1, stdout=subprocess.PIPE).stdout
@ -136,18 +142,28 @@ class Changes:
authors[key].deletions += j.deletions authors[key].deletions += j.deletions
def get_authorinfo_list(self): def get_authorinfo_list(self):
authors = {} if not self.authors:
for i in self.commits: for i in self.commits:
Changes.__modify_authorinfo__(authors, i.author, i) Changes.__modify_authorinfo__(self.authors, (i.author, i.email), i)
return authors return self.authors
def get_authordateinfo_list(self): def get_authordateinfo_list(self):
authors = {} if not self.authors_dateinfo:
for i in self.commits: for i in self.commits:
Changes.__modify_authorinfo__(authors, (i.date, i.author), i) Changes.__modify_authorinfo__(self.authors_dateinfo, (i.date, i.author, i.email), i)
return authors return self.authors_dateinfo
def get_authorname_from_email(self, email):
if not self.authors:
get_authorinfo_list(self)
for author in self.authors:
if author[1] == email:
return author[0]
return "Unknown"
__changes__ = None __changes__ = None
@ -187,13 +203,18 @@ class ChangesOutput(Outputable):
percentage = 0 if total_changes == 0 else (authorinfo.insertions + authorinfo.deletions) / total_changes * 100 percentage = 0 if total_changes == 0 else (authorinfo.insertions + authorinfo.deletions) / total_changes * 100
changes_xml += "<tr " + ("class=\"odd\">" if i % 2 == 1 else ">") changes_xml += "<tr " + ("class=\"odd\">" if i % 2 == 1 else ">")
changes_xml += "<td>" + entry + "</td>"
if format.get_selected() == "html":
changes_xml += "<td><img src=\"{0}\"/>{1}</td>".format(gravatar.get_url(entry[1]), entry[0])
else:
changes_xml += "<td>" + entry[0] + "</td>"
changes_xml += "<td>" + str(authorinfo.commits) + "</td>" changes_xml += "<td>" + str(authorinfo.commits) + "</td>"
changes_xml += "<td>" + str(authorinfo.insertions) + "</td>" changes_xml += "<td>" + str(authorinfo.insertions) + "</td>"
changes_xml += "<td>" + str(authorinfo.deletions) + "</td>" changes_xml += "<td>" + str(authorinfo.deletions) + "</td>"
changes_xml += "<td>" + "{0:.2f}".format(percentage) + "</td>" changes_xml += "<td>" + "{0:.2f}".format(percentage) + "</td>"
changes_xml += "</tr>" changes_xml += "</tr>"
chart_data += "{{label: \"{0}\", data: {1}}}".format(entry, "{0:.2f}".format(percentage)) chart_data += "{{label: \"{0}\", data: {1}}}".format(entry[0], "{0:.2f}".format(percentage))
if sorted(authorinfo_list)[-1] != entry: if sorted(authorinfo_list)[-1] != entry:
chart_data += ", " chart_data += ", "
@ -239,7 +260,7 @@ class ChangesOutput(Outputable):
authorinfo = authorinfo_list.get(i) authorinfo = authorinfo_list.get(i)
percentage = 0 if total_changes == 0 else (authorinfo.insertions + authorinfo.deletions) / total_changes * 100 percentage = 0 if total_changes == 0 else (authorinfo.insertions + authorinfo.deletions) / total_changes * 100
print(i.ljust(20)[0:20], end=" ") print(i[0].ljust(20)[0:20], end=" ")
print(str(authorinfo.commits).rjust(13), end=" ") print(str(authorinfo.commits).rjust(13), end=" ")
print(str(authorinfo.insertions).rjust(13), end=" ") print(str(authorinfo.insertions).rjust(13), end=" ")
print(str(authorinfo.deletions).rjust(14), end=" ") print(str(authorinfo.deletions).rjust(14), end=" ")
@ -262,14 +283,14 @@ class ChangesOutput(Outputable):
for i in sorted(authorinfo_list): for i in sorted(authorinfo_list):
authorinfo = authorinfo_list.get(i) authorinfo = authorinfo_list.get(i)
percentage = 0 if total_changes == 0 else (authorinfo.insertions + authorinfo.deletions) / total_changes * 100 percentage = 0 if total_changes == 0 else (authorinfo.insertions + authorinfo.deletions) / total_changes * 100
name_xml = "\t\t\t\t<name>" + i[0] + "</name>\n"
name_xml = "\t\t\t\t<name>" + i + "</name>\n" gravatar_xml = "\t\t\t\t<gravatar>" + gravatar.get_url(i[1]) + "</gravatar>\n"
commits_xml = "\t\t\t\t<commits>" + str(authorinfo.commits) + "</commits>\n" commits_xml = "\t\t\t\t<commits>" + str(authorinfo.commits) + "</commits>\n"
insertions_xml = "\t\t\t\t<insertions>" + str(authorinfo.insertions) + "</insertions>\n" insertions_xml = "\t\t\t\t<insertions>" + str(authorinfo.insertions) + "</insertions>\n"
deletions_xml = "\t\t\t\t<deletions>" + str(authorinfo.deletions) + "</deletions>\n" deletions_xml = "\t\t\t\t<deletions>" + str(authorinfo.deletions) + "</deletions>\n"
percentage_xml = "\t\t\t\t<percentage-of-changes>" + "{0:.2f}".format(percentage) + "</percentage-of-changes>\n" percentage_xml = "\t\t\t\t<percentage-of-changes>" + "{0:.2f}".format(percentage) + "</percentage-of-changes>\n"
changes_xml += ("\t\t\t<author>\n" + name_xml + commits_xml + insertions_xml + changes_xml += ("\t\t\t<author>\n" + name_xml + gravatar_xml + commits_xml + insertions_xml +
deletions_xml + percentage_xml + "\t\t\t</author>\n") deletions_xml + percentage_xml + "\t\t\t</author>\n")
print("\t<changes>\n" + message_xml + "\t\t<authors>\n" + changes_xml + "\t\t</authors>\n\t</changes>") print("\t<changes>\n" + message_xml + "\t\t<authors>\n" + changes_xml + "\t\t</authors>\n\t</changes>")

View File

@ -78,7 +78,7 @@ class Runner:
outputable.output(changes.ChangesOutput(self.hard)) outputable.output(changes.ChangesOutput(self.hard))
if changes.get(self.hard).get_commits(): if changes.get(self.hard).get_commits():
outputable.output(blame.BlameOutput(self.hard)) outputable.output(blame.BlameOutput(changes.get(self.hard), self.hard))
if self.timeline: if self.timeline:
outputable.output(timeline.Timeline(changes.get(self.hard), self.useweeks)) outputable.output(timeline.Timeline(changes.get(self.hard), self.useweeks))

34
gitinspector/gravatar.py Normal file
View File

@ -0,0 +1,34 @@
# coding: utf-8
#
# Copyright © 2013 Ejwa Software. All rights reserved.
#
# This file is part of gitinspector.
#
# gitinspector is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# gitinspector 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
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with gitinspector. If not, see <http://www.gnu.org/licenses/>.
import format
import hashlib
import urllib
def get_url(email):
md5hash = hashlib.md5(email.lower().strip()).hexdigest()
base_url = "http://www.gravatar.com/avatar/" + md5hash
params = None
if format.get_selected() == "html":
params = {"default": "identicon", "size": 20}
elif format.get_selected() == "xml":
params = {"default": "identicon"}
return base_url + "?" + urllib.urlencode(params)

View File

@ -128,7 +128,7 @@
}} }}
body > div {{ body > div {{
margin: 0 auto; margin: 0 auto;
width: 50em; width: 58em;
}} }}
div.box {{ div.box {{
border: 4px solid #ddd; border: 4px solid #ddd;
@ -158,7 +158,7 @@
}} }}
table.git {{ table.git {{
font-size: small; font-size: small;
width: 60%; width: 65%;
padding-right: 5px; padding-right: 5px;
}} }}
table.full {{ table.full {{
@ -179,9 +179,9 @@
-moz-border-radius: 0px 0px 8px 8px; -moz-border-radius: 0px 0px 8px 8px;
text-align: center; text-align: center;
}} }}
table.git td {{ table.git td, table.git th, table#timeline td, table#timeline th {{
padding: 0.4em; padding: 0.35em;
height: 1em; height: 2em;
}} }}
table.git td div.insert {{ table.git td div.insert {{
background-color: #7a7; background-color: #7a7;
@ -204,7 +204,7 @@
top: 5px; top: 5px;
bottom: 5px; bottom: 5px;
right: 0px; right: 0px;
width: 40%; width: 35%;
font-size: x-small; font-size: x-small;
height: 210px; height: 210px;
}} }}
@ -227,6 +227,15 @@
border: 1px solid #bbb; border: 1px solid #bbb;
cursor: hand; cursor: hand;
}} }}
td img {{
border-radius: 3px 3px 3px 3px;
-moz-border-radius: 3px 3px 3px 3px;
vertical-align: middle;
margin-right: 5px;
width: 20px;
height: 20px;
opacity: 0.85;
}}
</style> </style>
</head> </head>
<body> <body>

View File

@ -22,6 +22,8 @@ from __future__ import unicode_literals
from localization import N_ from localization import N_
from outputable import Outputable from outputable import Outputable
import datetime import datetime
import format
import gravatar
import terminal import terminal
import textwrap import textwrap
@ -37,9 +39,9 @@ class TimelineData:
if useweeks: if useweeks:
yearweek = datetime.date(int(i[0][0][0:4]), int(i[0][0][5:7]), int(i[0][0][8:10])).isocalendar() yearweek = datetime.date(int(i[0][0][0:4]), int(i[0][0][5:7]), int(i[0][0][8:10])).isocalendar()
key = (i[0][1], str(yearweek[0]) + "W" + "{0:02d}".format(yearweek[1])) key = ((i[0][1], i[0][2]), str(yearweek[0]) + "W" + "{0:02d}".format(yearweek[1]))
else: else:
key = (i[0][1], i[0][0][0:7]) key = ((i[0][1], i[0][2]), i[0][0][0:7])
if self.entries.get(key, None) == None: if self.entries.get(key, None) == None:
self.entries[key] = i[1] self.entries[key] = i[1]
@ -117,7 +119,7 @@ def __output_row__text__(timeline_data, periods, names):
for name in names: for name in names:
if timeline_data.is_author_in_periods(periods, name): if timeline_data.is_author_in_periods(periods, name):
print(name.ljust(20)[0:20], end=" ") print(name[0].ljust(20)[0:20], end=" ")
for period in periods: for period in periods:
multiplier = timeline_data.get_multiplier(period, 9) multiplier = timeline_data.get_multiplier(period, 9)
signs = timeline_data.get_author_signs_in_period(name, period, multiplier) signs = timeline_data.get_author_signs_in_period(name, period, multiplier)
@ -146,7 +148,13 @@ def __output_row__html__(timeline_data, periods, names):
for name in names: for name in names:
if timeline_data.is_author_in_periods(periods, name): if timeline_data.is_author_in_periods(periods, name):
timeline_xml += "<tr" + (" class=\"odd\">" if i % 2 == 1 else ">") timeline_xml += "<tr" + (" class=\"odd\">" if i % 2 == 1 else ">")
timeline_xml += "<td>" + name + "</td>" #timeline_xml += "<td>" + name[0] + "</td>"
if format.get_selected() == "html":
timeline_xml += "<td><img src=\"{0}\"/>{1}</td>".format(gravatar.get_url(name[1]), name[0])
else:
timeline_xml += "<td>" + name[0] + "</td>"
for period in periods: for period in periods:
multiplier = timeline_data.get_multiplier(period, 14) multiplier = timeline_data.get_multiplier(period, 14)
signs = timeline_data.get_author_signs_in_period(name, period, multiplier) signs = timeline_data.get_author_signs_in_period(name, period, multiplier)
@ -225,7 +233,8 @@ class Timeline(Outputable):
if len(signs_str) == 0: if len(signs_str) == 0:
signs_str = "." signs_str = "."
authors_xml += "\t\t\t\t\t<author>\n\t\t\t\t\t\t<name>" + name + "</name>\n" authors_xml += "\t\t\t\t\t<author>\n\t\t\t\t\t\t<name>" + name[0] + "</name>\n"
authors_xml += "\t\t\t\t\t\t<gravatar>" + gravatar.get_url(name[1]) + "</gravatar>\n"
authors_xml += "\t\t\t\t\t\t<work>" + signs_str + "</work>\n\t\t\t\t\t</author>\n" authors_xml += "\t\t\t\t\t\t<work>" + signs_str + "</work>\n\t\t\t\t\t</author>\n"
authors_xml += "\t\t\t\t</authors>\n" authors_xml += "\t\t\t\t</authors>\n"