diff --git a/README.md b/README.md index cd2e82d..5715328 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ ![PyPI](https://img.shields.io/pypi/v/mkdocs-git-authors-plugin) ![PyPI - Downloads](https://img.shields.io/pypi/dm/mkdocs-git-authors-plugin) [![codecov](https://codecov.io/gh/timvink/mkdocs-git-authors-plugin/branch/master/graph/badge.svg)](https://codecov.io/gh/timvink/mkdocs-git-authors-plugin) - + # mkdocs-git-authors-plugin [MkDocs](https://www.mkdocs.org/) plugin to display git authors of a markdown page: @@ -39,7 +39,7 @@ plugins: ### In markdown pages -You can use ``{{ git_authors_summary }}`` to insert a summary of the authors of a page. Authors are sorted by their name and have a `mailto:` link with their email. +You can use ``{{ git_authors_summary }}`` to insert a summary of the authors of a page. Authors are sorted by their name and have a `mailto:` link with their email. An example output: @@ -57,7 +57,7 @@ no supported themes *yet*. ### Customizing existing themes -[MkDocs](https://www.mkdocs.org/) offers possibilities to [customize an existing theme](https://www.mkdocs.org/user-guide/styling-your-docs/#customizing-a-theme). +[MkDocs](https://www.mkdocs.org/) offers possibilities to [customize an existing theme](https://www.mkdocs.org/user-guide/styling-your-docs/#customizing-a-theme). As an example, if you use [mkdocs-material](https://github.com/squidfunk/mkdocs-material) you can easily implement git-authors by [overriding a template block](https://squidfunk.github.io/mkdocs-material/customization/#overriding-template-blocks): @@ -150,4 +150,3 @@ Jane Doe This will map commits made with the `private-email.com` to the company address. For more details and further options (e.g. mapping between different names or misspellings etc. see the [git-blame documentation](https://git-scm.com/docs/git-blame#_mapping_authors). - diff --git a/mkdocs_git_authors_plugin/plugin.py b/mkdocs_git_authors_plugin/plugin.py index ce0260e..e98afe0 100644 --- a/mkdocs_git_authors_plugin/plugin.py +++ b/mkdocs_git_authors_plugin/plugin.py @@ -1,15 +1,112 @@ import re from mkdocs.config import config_options from mkdocs.plugins import BasePlugin -from .util import Util +from .repo import Repo +from . import util class GitAuthorsPlugin(BasePlugin): config_scheme = ( ('show_contribution', config_options.Type(bool, default=False)), + ('show_line_count', config_options.Type(bool, default=False)), + ('count_empty_lines', config_options.Type(bool, default=True)), + ('sort_authors_by', config_options.Choice( + ['name', 'contribution'], default='name') + ), + ('sort_reverse', config_options.Type(bool, default=False)) ) def __init__(self): - self.util = Util() + self._repo = Repo() + + def on_config(self, config, **kwargs): + """ + Store the plugin configuration in the Repo object. + + The config event is the first event called on build and is run + immediately after the user configuration is loaded and validated. Any + alterations to the config should be made here. + + https://www.mkdocs.org/user-guide/plugins/#on_config + + NOTE: This is only the dictionary with the plugin configuration, + not the global config which is passed to the various event handlers. + + Args: + config: global configuration object + + Returns: + (updated) configuration object + """ + self.repo().set_config(self.config) + + def on_files(self, files, config, **kwargs): + """ + Preprocess all markdown pages in the project. + + The files event is called after the files collection is populated from + the docs_dir. Use this event to add, remove, or alter files in the + collection. Note that Page objects have not yet been associated with the + file objects in the collection. Use Page Events to manipulate page + specific data. + + https://www.mkdocs.org/user-guide/plugins/#on_files + + This populates all the lines and total_lines properties + of the pages and the repository. The event is executed after on_config, + but before all other events. When any page or template event + is called, all pages have already been parsed and their statistics + been aggregated. + So in any on_page_XXX event the contributions of an author + to the current page *and* the repository as a whole are available. + + Args: + files: global files collection + config: global configuration object + + Returns: + global files collection + """ + for file in files: + path = file.abs_src_path + if path.endswith('.md'): + _ = self.repo().page(path) + + def on_page_content(self, html, page, config, files, **kwargs): + """ + Replace jinja tag {{ git_authors_list }} in HTML. + + The page_content event is called after the Markdown text is + rendered to HTML (but before being passed to a template) and + can be used to alter the HTML body of the page. + + https://www.mkdocs.org/user-guide/plugins/#on_page_content + + We replace the authors list in this event in order to be able + to replace it with arbitrary HTML content (which might otherwise + end up in styled HTML in a code block). + + Args: + html: the processed HTML of the page + page: mkdocs.nav.Page instance + config: global configuration object + site_navigation: global navigation object + + Returns: + str: HTML text of page as string + """ + list_pattern = re.compile( + r"\{\{\s*git_authors_list\s*\}\}", + flags=re.IGNORECASE + ) + if list_pattern.search(html): + html = list_pattern.sub( + util.repo_authors_summary( + self.repo().get_authors(), + self.config + ), + html + ) + return html def on_page_markdown(self, markdown, page, config, files): """ @@ -32,22 +129,21 @@ def on_page_markdown(self, markdown, page, config, files): str: Markdown source text of page as string """ - pattern = r"\{\{\s*git_authors_summary\s*\}\}" + summary_pattern = re.compile( + r"\{\{\s*git_authors_summary\s*\}\}", + flags=re.IGNORECASE + ) - if not re.search(pattern, markdown, flags=re.IGNORECASE): - return markdown + if not summary_pattern.search(markdown): + return markdown - authors = self.util.get_authors( - path = page.file.abs_src_path + page_obj = self.repo().page(page.file.abs_src_path) + return summary_pattern.sub( + page_obj.authors_summary(), + markdown ) - authors_summary = self.util.summarize(authors, self.config) - - return re.sub(pattern, - authors_summary, - markdown, - flags=re.IGNORECASE) - def on_page_context(self, context, page, **kwargs): + def on_page_context(self, context, page, config, nav, **kwargs): """ Add 'git_authors' and 'git_authors_summary' variables to template context. @@ -56,23 +152,48 @@ def on_page_context(self, context, page, **kwargs): is created and can be used to alter the context for that specific page only. + https://www.mkdocs.org/user-guide/plugins/#on_page_context + Note this is called *after* on_page_markdown() Args: context (dict): template context variables page (class): mkdocs.nav.Page instance + config: global configuration object + nav: global navigation object Returns: dict: template context variables """ - - authors = self.util.get_authors( - path = page.file.abs_src_path - ) - authors_summary = self.util.summarize(authors, self.config) - - context['git_authors'] = authors - context['git_authors_summary'] = authors_summary - - return context \ No newline at end of file + path = page.file.abs_src_path + page_obj = self.repo().page(path) + authors = page_obj.get_authors() + + # NOTE: last_datetime is currently given as a + # string in the format + # '2020-02-24 17:49:14 +0100' + # omitting the 'str' argument would result in a + # datetime.datetime object with tzinfo instead. + # Should this be formatted differently? + context['git_authors'] = [ + { + 'name' : author.name(), + 'email' : author.email(), + 'last_datetime' : author.datetime(path, str), + 'lines' : author.lines(path), + 'lines_all_pages' : author.lines(), + 'contribution' : author.contribution(path, str), + 'contribution_all_pages' : author.contribution(None, str) + } + for author in authors + ] + context['git_authors_summary'] = page_obj.authors_summary() + + return context + + def repo(self): + """ + Reference to the Repo object of the current project. + """ + return self._repo diff --git a/mkdocs_git_authors_plugin/repo.py b/mkdocs_git_authors_plugin/repo.py new file mode 100644 index 0000000..0b70963 --- /dev/null +++ b/mkdocs_git_authors_plugin/repo.py @@ -0,0 +1,745 @@ +from pathlib import Path +import logging +import os +import re +import subprocess + +from . import util + +class GitCommandError(Exception): + """ + Exception thrown by a GitCommand. + """ + pass + + +class GitCommand(object): + """ + Wrapper around a Git command. + + Instantiate with a command name and an optional args list. + These can later be modified with set_command() and set_args(). + + Execute the command with run() + + If successful the results can be read as string lists with + - stdout() + - stderr() + In case of an error a verbose GitCommandError is raised. + """ + + def __init__(self, command: str, args: list = []): + """ + Initialize the GitCommand. + + Args: + command a string ('git' will implicitly be prepended) + args: a string list with remaining command arguments. + Defaults to an empty list + """ + + self.set_command(command) + self.set_args(args) + self._stdout = None + self._stderr = None + self._completed = False + + def run(self): + """ + Execute the configured Git command. + + In case of success the results can be retrieved as string lists + with self.stdout() and self.stderr(), otherwise a GitCommandError + is raised. + + Args: + + Returns: + The process's return code. + Note: usually the result will be used through the methods. + """ + + args = ['git'] + args.append(self._command) + args.extend(self._args) + p = subprocess.run( + args, + encoding='utf8', + capture_output=True + ) + try: + p.check_returncode() + except subprocess.CalledProcessError as e: + msg = ['GitCommand error:'] + msg.append('Command "%s" failed' % ' '.join(args)) + msg.append('Return code: %s' % p.returncode) + msg.append('Output:') + msg.append(p.stdout) + msg.append('Error messages:') + msg.append(p.stderr) + raise GitCommandError('\n'.join(msg)) + + self._stdout = p.stdout.strip('\'\n').split('\n') + self._stderr = p.stderr.strip('\'\n').split('\n') + + self._completed = True + return p.returncode + + def set_args(self, args: list): + """ + Change the command arguments. + + Args: + args: list of process arguments + """ + self._args = args + + def set_command(self, command: str): + """ + Change the Git command. + + Args: + command: string with the git-NNN command name. + """ + self._command = command + + def stderr(self): + """ + Return the stderr output of the command as a string list. + + Args: + + Returns: + string list + """ + if not self._completed: + raise GitCommandError('Trying to read from uncompleted GitCommand') + return self._stderr + + def stdout(self): + """ + Return the stdout output of the command as a string list. + + Args: + + Returns: + string list + """ + if not self._completed: + raise GitCommandError('Trying to read from uncompleted GitCommand') + return self._stdout + + +class Repo(object): + """ + Abstraction of a Git repository (i.e. the MkDocs project). + """ + + def __init__(self): + self._root = self.find_repo_root() + self._total_lines = 0 + + # Store Commit, indexed by 40 char SHA + self._commits = {} + # Store Page objects, indexed by Path object + self._pages = {} + # Store Author objects, indexed by email + self._authors = {} + + def add_total_lines(self, cnt: int = 1): + """ + Add line(s) to the number of total lines in the repository. + + Args: + number of lines to add, default: 1 + """ + self._total_lines += cnt + + def author(self, name, email: str): + """Return an Author object identified by name and email. + + Note: authors are indexed by their email only. + If no Author object has yet been registered + a new one is created using name and email. + + Args: + name: author's full name + email: author's email address. + + Returns: + Author object + """ + if not self._authors.get(email, None): + self._authors[email] = Author(self, name, email) + return self._authors[email] + + def get_authors(self): + """ + Sorted list of authors in the repository. + + Default sort order is by ascending names, which can be changed + to descending and/or by contribution + + Args: + + Returns: + List of Author objects + """ + return sorted([ + author for author in self._authors.values() + ], + key=self._sort_key, + reverse=self.config('sort_reverse') + ) + + def config(self, key: str = ''): + """ + Return the plugin configuration dictionary or a single config value. + + Args: + key: lookup key or an empty string. + """ + return self._config.get(key) if key else self._config + + def find_repo_root(self): + """ + Determine the root directory of the Git repository, + in case the current working directory is different from that. + + Raises a GitCommandError if we're not in a Git repository + (or Git is not installed). + + Args: + + Returns: + path as a string + """ + cmd = GitCommand('rev-parse', ['--show-toplevel']) + cmd.run() + return cmd.stdout()[0] + + def get_commit(self, sha: str, **kwargs): + """ + Return the (cached) Commit object for given sha. + + Implicitly creates a new Commit object upon first request, + which will trigger the git show processing. + + Args: + 40-byte SHA string + + Returns: + Commit object + """ + if not self._commits.get(sha): + self._commits[sha] = Commit(self, sha, **kwargs) + return self._commits.get(sha) + + def page(self, path): + """ + Return the (cached) Page object for given path. + + Implicitly creates a new Page object upon first request, + which will trigger the git blame processing. + + Args: + path: path (str or Path) to the page's markdown source. + + Returns: + Page object + """ + if type(path) == str: + path = Path(path) + if not self._pages.get(path): + self._pages[path] = Page(self, path) + return self._pages[path] + + def set_config(self, plugin_config): + """ + Store the plugin configuration in the Repo instance. + + Args: + - plugin_config: dictionary + """ + self._config = plugin_config + + def _sort_key(self, author): + """ + Return a sort key for an author. + + Args: + author: an Author object + + Returns: + comparison key for the sorted() function, + determined by the 'sort_authors_by' configuration option + """ + func = getattr(author, self.config('sort_authors_by')) + return func() + + def total_lines(self): + """ + The total number of lines in the project's markdown files + (as counted through git blame). + + Args: + + Returns: + int total number of lines in the project's markdown files + """ + return self._total_lines + + +class AbstractRepoObject(object): + """ + Base class for objects that live with a repository context. + """ + + def __init__(self, repo: Repo): + self._repo = repo + + def repo(self): + """ + Return a reference to the Repo object. + + Args: + + Returns: + Repo instance + """ + return self._repo + + +class Commit(AbstractRepoObject): + """ + Information about a single commit. + + Stores only information relevant to our plugin: + - author name and email, + - date/time + - summary (not used at this point) + """ + + def __init__( + self, + repo: Repo, + sha: str, + author_name: str, + author_email: str, + author_time: str, + author_tz: str, + summary: str + ): + """Initialize a commit from its SHA. + + Populates the object running git show. + + Args: + repo: reference to the Repo instance + sha: 40-byte SHA string + """ + + super().__init__(repo) + + self._author = self.repo().author( + author_name, + author_email + ) + self._datetime = util.commit_datetime(author_time, author_tz) + self._datetime_string = util.commit_datetime_string(self._datetime) + self._summary = summary + + def author(self): + """ + The commit's author. + + Args: + + Returns: + Author object + """ + return self._author + + def datetime(self, _type=str): + """ + The commit's commit time. + + Stored as a datetime.datetime object with timezone information. + + Args: + _type: str or other type expression + + Returns: + The commit's commit time, either as a formatted string (_type=str) + or as a datetime.datetime expression with tzinfo + """ + return self._datetime_string if _type == str else self._datetime + + +class Page(AbstractRepoObject): + """ + Results of git blame for a given file. + + Stores a list of tuples with a reference to a + Commit object and a list of consecutive lines + modified by that commit. + """ + + def __init__(self, repo: Repo, path: Path): + """ + Instantiante a Page object + + Args: + repo: Reference to the global Repo instance + path: Absolute path to the page's Markdown file + """ + super().__init__(repo) + self._path = path + self._sorted = False + self._total_lines = 0 + self._authors = [] + try: + self._process_git_blame() + except GitCommandError: + logging.warning( + '%s has not been committed yet. Lines are not counted' % path + ) + + def add_total_lines(self, cnt: int = 1): + """ + Add line(s) to the count of total lines for the page. + + Arg: + cnt: number of lines to add. Default: 1 + """ + self._total_lines += cnt + + def get_authors(self): + """ + Return a sorted list of authors for the page + + The list is sorted once upon first request. + Sorting is done by author name. + + Args: + + Returns: + sorted list with Author objects + """ + if not self._sorted: + repo = self.repo() + self._authors = sorted( + self._authors, + key=repo._sort_key, + reverse=repo.config('sort_reverse') + ) + self._sorted = True + return self._authors + + def authors_summary(self): + """ + Summarized list of authors to a HTML string + + Args: + Returns: + str: HTML text with authors + """ + + authors = self.get_authors() + authors_summary = [] + for author in authors: + contrib = ( + ' (%s)' % author.contribution(self.path(), str) + if self.repo().config('show_contribution') + and len(self.get_authors()) > 1 + else '' + ) + authors_summary.append( + "%s%s" % ( + author.email(), + author.name(), + contrib + )) + authors_summary = ', '.join(authors_summary) + return "%s" % authors_summary + + def _process_git_blame(self): + """ + Execute git blame and parse the results. + + This retrieves all data we need, also for the Commit object. + Each line will be associated with a Commit object and counted + to its author's "account". + Whether empty lines are counted is determined by the + count_empty_lines configuration option. + + git blame --porcelain will produce output like the following + for each line in a file: + + When a commit is first seen in that file: + 30ed8daf1c48e4a7302de23b6ed262ab13122d31 1 2 1 + author John Doe + author-mail + author-time 1580742131 + author-tz +0100 + committer John Doe + committer-mail + committer-time 1580742131 + summary Fancy commit message title + filename home/docs/README.md + line content (indicated by TAB. May be empty after that) + + When a commit has already been seen *in that file*: + 82a3e5021b7131e31fc5b110194a77ebee907955 4 5 + line content + + In this case the metadata is not repeated, but it is guaranteed that + a Commit object with that SHA has already been created so we don't + need that information anymore. + + When a line has not been committed yet: + 0000000000000000000000000000000000000000 1 1 1 + author Not Committed Yet + author-mail + author-time 1583342617 + author-tz +0100 + committer Not Committed Yet + committer-mail + committer-time 1583342617 + committer-tz +0100 + summary Version of books/main/docs/index.md from books/main/docs/index.md + previous 1f0c3455841488fe0f010e5f56226026b5c5d0b3 books/main/docs/index.md + filename books/main/docs/index.md + uncommitted line content + + In this case exactly one Commit object with the special SHA and fake + author will be created and counted. + + Args: + --- + Returns: + --- (this method works through side effects) + """ + + re_sha = re.compile('^\w{40}') + + cmd = GitCommand('blame', ['--porcelain', str(self._path)]) + cmd.run() + + commit_data = {} + for line in cmd.stdout(): + key = line.split(' ')[0] + m = re_sha.match(key) + if m: + commit_data = { + 'sha': key + } + elif key in [ + 'author', + 'author-mail', + 'author-time', + 'author-tz', + 'summary' + ]: + commit_data[key] = line[len(key)+1:] + elif line.startswith('\t'): + # assign the line to a commit + # and create the Commit object if necessary + commit = self.repo().get_commit( + commit_data.get('sha'), + # The following values are guaranteed to be present + # when a commit is seen for the first time, + # so they can be used for creating a Commit object. + author_name=commit_data.get('author'), + author_email=commit_data.get('author-mail'), + author_time=commit_data.get('author-time'), + author_tz=commit_data.get('author-tz'), + summary=commit_data.get('summary') + ) + if len(line) > 1 or self.repo().config('count_empty_lines'): + author = commit.author() + if author not in self._authors: + self._authors.append(author) + author.add_lines(self, commit) + self.add_total_lines() + self.repo().add_total_lines() + + def path(self): + """ + The path to the markdown file. + + Args: + + Returns: + Absolute path as Path object. + """ + return self._path + + def total_lines(self): + """ + Total number of lines in the markdown source file. + + Args: + + Returns: + int + """ + return self._total_lines + + +class Author(AbstractRepoObject): +# Sorted after Page for the function annotations + """ + Abstraction of an author in the Git repository. + """ + + def __init__(self, repo: Repo, name: str, email: str): + """ + Instantiate an Author. + + Args: + repo: reference to the global Repo instance + name: author's full name + email: author's email + """ + super().__init__(repo) + self._name = name + self._email = email + self._pages = {} + + def add_lines(self, page: Page, commit: Commit, lines: int = 1): + """ + Add line(s) in a given page/commit to the author's data. + + Args: + page: Page object referencing the markdown file + commit: commit in which the line was edited (=> timestamp) + lines: number of lines to add. Default: 1 + """ + path = page.path() + entry = self.page(path, page) + entry['lines'] += lines + current_dt = entry.get('datetime') + commit_dt = commit.datetime() + if not current_dt or commit_dt > current_dt: + entry['datetime'] = commit_dt + entry['datetime_str'] = commit.datetime(str) + + def contribution(self, path=None, _type=float): + """ + The author's relative contribution to a page or the repository. + + The result is a number between 0 and 1, optionally formatted to percent + + Args: + path: path to a file or None (default) + if a path is given the author's contribution to *this* page + is calculated, otherwise to the whole repository. + _type: 'float' (default) or 'str' + if _type refers to the str type the result is a formatted + string, otherwise the raw floating point number. + + Returns: + formatted string or floating point number + """ + lines = self.lines(path) + total_lines = ( + self.page(path)['page'].total_lines() + if path + else self.repo().total_lines() + ) + result = lines / total_lines + if _type == float: + return result + else: + return str(round(result * 100, 2)) + '%' + + def datetime(self, path, fmt=str): + """ + The author's last modification date for a given page. + + Args: + path: path (str or Path) to a page + fmt: str (default) or anything + + Returns: + a formatted string (fmt=str) + or a datetime.datetime object with tzinfo + """ + if type(path) == str: + path = Path(path) + key = 'datetime_str' if fmt == str else 'datetime' + return self.page(path).get(key) + + def email(self): + """ + The author's email address + + Args: + + Returns: + email address as string + """ + return self._email + + def lines(self, path=None): + """ + The author's total number of lines on a page or in the repository. + + Args: + path: path (str or Page) to a markdown file, or None (default) + + Returns: + number of lines (int) in the repository (path=None) + or on the given page. + """ + if path: + return self.page(path)['lines'] + else: + return sum([ + v['lines'] for v in self._pages.values() + ]) + + def name(self): + """ + The author's full name + + Args: + + Returns: + The full name as a string. + """ + return self._name + + def page(self, path, page=None): + """ + A dictionary with the author's contribution to a page. + + If there is no entry for the given page yet a new one is + created, optionally using a passed Page object as a fallback + or creating a new one. + + Args: + path: path (str or Path) to a page's markdown file + page: page to use if not already present (default: None) + + Returns: + dict, indexed by path: + - page: reference to a (new) Page object + - lines: author's number of lines in the page + [ + - datetime + - datetime_str + ]: information about the latest modification of the page + by the author. Will not be present in the freshly instantiated + entry. + """ + if type(path) == str: + path = Path(path) + if not self._pages.get(path): + self._pages[path] = { + 'page': page or self.repo().page(path), + 'lines': 0 + # datetime and datetime_str will be populated later + } + return self._pages[path] diff --git a/mkdocs_git_authors_plugin/util.py b/mkdocs_git_authors_plugin/util.py index 004f0d0..55618e4 100644 --- a/mkdocs_git_authors_plugin/util.py +++ b/mkdocs_git_authors_plugin/util.py @@ -1,113 +1,90 @@ -from git import Repo -import logging -from pathlib import Path - -class Util: - - def __init__(self, path = "."): - self.repo = Repo(path, search_parent_directories=True) - # Cache authors entries by path - self._authors = {} - - def get_authors(self, path): +from datetime import datetime, timezone, timedelta + +def commit_datetime(author_time: str, author_tz: str): + """ + Convert a commit's timestamp to an aware datetime object. + + Args: + author_time: Unix timestamp string + author_tz: string in the format +hhmm + + Returns: + datetime.datetime object with tzinfo + """ + + # timezone info looks like +hhmm or -hhmm + tz_hours = int(author_tz[:3]) + th_minutes = int(author_tz[0] + author_tz[3:]) + + return datetime.fromtimestamp( + int(author_time), + timezone(timedelta(hours=tz_hours,minutes=th_minutes)) + ) + + +def commit_datetime_string(dt: datetime): + """ + Return a string representation for a commit's timestamp. + + Args: + dt: datetime object with tzinfo + + Returns: + string representation (should be localized) + """ + return dt.strftime('%c %z') + + +def repo_authors_summary(authors, config: dict): + """ + A summary list of the authors' contributions on repo level. + + Iterates over all authors and produces an HTML
    list with + their names and overall contribution details (lines/percentage). + + TODO: + - The output should be configurable or at least localizable + (suggestions: + - load a template with named fields for the values + (user may provide alternative template) + - provide plugin configuration options for the various labels + ) + + Args: + authors: sorted list of Author objects + config: plugin's config dict + + Returns: + Unordered HTML list as a string. + """ + show_contribution = config['show_contribution'] + show_line_count = show_contribution and config['show_line_count'] + result = """ + +
      """ - Determine git authors for a given file - - Args: - path (str): Location of a file that is part of a GIT repository - - Returns: - list (str): unique authors, or empty list - """ - - authors = self._authors.get(path, []) - - if authors == False: - return [] - - if authors: - return authors - - try: - blame = self.repo.blame('HEAD',path) - except: - logging.warning("%s has no commits" % path) - self._authors[path] = False - return [] - - if len(Path(path).read_text()) == 0: - logging.warning("%s has no lines" % path) - self._authors[path] = False - return [] - - authors = {} - for commit, lines in blame: - key = commit.author.email - - # Update existing author - if authors.get(key): - authors[key]['lines'] = authors[key]['lines'] + len(lines) - current_dt = authors.get(key,{}).get('last_datetime') - if commit.committed_datetime > current_dt: - authors[key]['last_datetime'] = commit.committed_datetime - # Add new author - else: - authors[key] = { - 'name' : commit.author.name, - 'email' : key, - 'last_datetime' : commit.committed_datetime, - 'lines' : len(lines) - } - - authors = [authors[key] for key in authors] - authors = sorted(authors, key = lambda i: i['name']) - - total_lines = sum([x.get('lines') for x in authors]) - for author in authors: - author['contribution'] = self._format_perc(author['lines'] / total_lines) - - self._authors[path] = authors - - return authors - - @staticmethod - def _format_perc(n, decimals = 2): - """Formats a decimal as a percentage - - Args: - n (float): [description] - """ - assert n >= 0 - assert n <= 1 - return str(round(n * 100, decimals)) + '%' - - @staticmethod - def summarize(authors, config): - """ - Summarized list of authors to a HTML string - - Args: - authors (list): List with author dicts - - Returns: - str: HTML text with authors - """ - - def format_author(author): - contrib = ( - ' (%s)' % author['contribution'] - if ( - config['show_contribution'] - and len(authors) > 1 - ) - else '' - ) - return "%s%s" % ( - author['email'], - author['name'], - contrib - ) - - authors_summary = [format_author(author) for author in authors] - authors_summary = ', '.join(authors_summary) - return "" + authors_summary + "" + for author in authors: + contribution = ( + ' (%s)' % author.contribution(None, str) + if show_contribution + else '' + ) + lines = ( + '%s lines' % author.lines() + if show_line_count + else '' + ) + result += """ +
    • {author_name}: + {lines}{contribution}
    • + """.format( + author_email=author.email(), + author_name=author.name(), + lines=lines, + contribution=contribution + ) + result += """ + +
    + """ + return result diff --git a/setup.py b/setup.py index 7fbb83e..3e9ec23 100644 --- a/setup.py +++ b/setup.py @@ -21,8 +21,7 @@ "Operating System :: OS Independent", ], install_requires=[ - 'mkdocs>=0.17', - 'GitPython' + 'mkdocs>=0.17' ], packages=find_packages(), entry_points={ diff --git a/tests/test_requirements.txt b/tests/test_requirements.txt index 155fb49..fac3612 100644 --- a/tests/test_requirements.txt +++ b/tests/test_requirements.txt @@ -1,4 +1,5 @@ pytest pytest-cov codecov -click \ No newline at end of file +click +GitPython \ No newline at end of file