From b41e299bde5c9ea48a6d9829a35217e19595ed79 Mon Sep 17 00:00:00 2001 From: Urs Liska Date: Sun, 1 Mar 2020 16:13:32 +0100 Subject: [PATCH 01/27] Remove GitPython dependency Create a Git repository abstraction with just what we need. This will be continued with a Book class holding/storing the info about the whole book, its size and its authors. --- mkdocs_git_authors_plugin/plugin.py | 4 +- mkdocs_git_authors_plugin/repo.py | 287 ++++++++++++++++++++++++++++ mkdocs_git_authors_plugin/util.py | 22 ++- setup.py | 3 +- tests/test_requirements.txt | 3 +- 5 files changed, 304 insertions(+), 15 deletions(-) create mode 100644 mkdocs_git_authors_plugin/repo.py diff --git a/mkdocs_git_authors_plugin/plugin.py b/mkdocs_git_authors_plugin/plugin.py index 5509251..5cfed61 100644 --- a/mkdocs_git_authors_plugin/plugin.py +++ b/mkdocs_git_authors_plugin/plugin.py @@ -5,6 +5,7 @@ class GitAuthorsPlugin(BasePlugin): def __init__(self): self.util = Util() +# self._book = Book() def on_page_markdown(self, markdown, page, config, files): """ @@ -30,7 +31,7 @@ def on_page_markdown(self, markdown, page, config, files): pattern = r"\{\{\s*git_authors_summary\s*\}\}" if not re.search(pattern, markdown, flags=re.IGNORECASE): - return markdown + return markdown authors = self.util.get_authors( path = page.file.abs_src_path @@ -61,7 +62,6 @@ def on_page_context(self, context, page, **kwargs): dict: template context variables """ - authors = self.util.get_authors( path = page.file.abs_src_path ) diff --git a/mkdocs_git_authors_plugin/repo.py b/mkdocs_git_authors_plugin/repo.py new file mode 100644 index 0000000..3841662 --- /dev/null +++ b/mkdocs_git_authors_plugin/repo.py @@ -0,0 +1,287 @@ +import os +import subprocess + +from collections import OrderedDict +from datetime import datetime, timedelta, timezone + +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. + + - command a string ('git' will implicitly be prepended) + - args: a string list with remaining command arguments. + """ + + 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 = ['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. + """ + self._args = args + + def set_command(self, command: str): + """ + Change the Git command. + """ + self._command = command + + def stderr(self): + """ + Return the stderr output of the command as a 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. + """ + 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() + + # Store Commit, indexed by 40 char SHA + self._commits = {} + # Store Page objects, indexed by path + self._pages = {} + + def blame(self, path: str): + """ + Return the cached Page object for path. + """ + if not self._pages.get(path): + self._pages[path] = Page(self, path) + return self._pages[path].commits() + + def commit(self, sha: str): + """ + Return the cached Commit object for sha. + """ + if not self._commits.get(sha): + self._commits[sha] = Commit(self, sha) + return self._commits.get(sha) + + 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). + """ + cmd = GitCommand('rev-parse', ['--show-toplevel']) + cmd.run() + return cmd.stdout()[0] + + def root(self): + """ + Returns the repository root. + """ + return self._root + + +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. + """ + return self._repo + + +class Commit(AbstractRepoObject): + """ + Information about a single commit. + + Stores only information relevant to our plugin: + - author name and email, + - date/time + """ + + def __init__(self, repo: Repo, sha: str): + super().__init__(repo) + self._sha = sha + self._populate() + + def author_name(self): + """ + The commit's author name. + """ + return self._author_name + + def author_email(self): + """ + The commit's author email. + """ + return self._author_email + + def datetime(self): + """ + The commit's commit time. + + Stored as a datetime.datetime object with timezone information. + """ + return self._datetime + + def _populate(self): + """ + Retrieve information about the commit. + """ + cmd = GitCommand('show', [ + '-t', + '--quiet', + "--format='%aN%n%aE%n%ai'", + self.sha() + ]) + cmd.run() + result = cmd.stdout() + + # Author name and email are returned on single lines. + self._author_name = result[0] + self._author_email = result[1] + # Third line includes formatted date/time info + d, t, tz = result[2].split(' ') + d = [int(v) for v in d.split('-')] + t = [int(v) for v in t.split(':')] + # timezone info looks like +hhmm or -hhmm + tz_hours = int(tz[:3]) + th_minutes = int(tz[0] + tz[3:]) + tzinfo = timezone(timedelta(hours=tz_hours,minutes=th_minutes)) + # Construct 'aware' datetime.datetime object + self._datetime = datetime( + d[0], d[1], d[2], t[0], t[1], t[2], tzinfo=tzinfo + ) + + def sha(self): + """ + Return the commit's 40 byte SHA. + """ + return self._sha + + +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: str): + super().__init__(repo) + self._path = path + self._commits = [] + self._execute() + + def commits(self): + """ + Returns the list of blame commits for the given file. + + Each item in this list is a tuple of + - a Commit object + - a string list of lines affected by this commit. + """ + return self._commits + + def _execute(self): + """ + Execute git blame and parse the results. + """ + + cmd = GitCommand('blame', ['-lts', self._path]) + cmd.run() + result = cmd.stdout() + + current_sha = '' + for line in result: + # split result line + sha = line[:40] + # formatted line number, separated by spaces + offset = line[41:].find(' ') + content = line[42+offset:] + if current_sha != sha: + # line has different commit than previous one + self._commits.append(( + self.repo().commit(sha), + [content] + )) + elif sha: + # append line to previous commit's lines list + self._commits[-1][1].append(content) diff --git a/mkdocs_git_authors_plugin/util.py b/mkdocs_git_authors_plugin/util.py index da3910d..7ba77eb 100644 --- a/mkdocs_git_authors_plugin/util.py +++ b/mkdocs_git_authors_plugin/util.py @@ -1,11 +1,13 @@ -from git import Repo import logging from pathlib import Path +from .repo import Repo + + class Util: - def __init__(self, path = "."): - self.repo = Repo(path, search_parent_directories=True) + def __init__(self): + self.repo = Repo() # Cache authors entries by path self._authors = {} @@ -27,9 +29,9 @@ def get_authors(self, path): if authors: return authors - + try: - blame = self.repo.blame('HEAD',path) + blame = self.repo.blame(path) except: logging.warning("%s has no commits" % path) self._authors[path] = False @@ -42,20 +44,20 @@ def get_authors(self, path): authors = {} for commit, lines in blame: - key = commit.author.email + 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 + if commit.datetime() > current_dt: + authors[key]['last_datetime'] = commit.datetime() # Add new author else: authors[key] = { - 'name' : commit.author.name, + 'name' : commit.author_name(), 'email' : key, - 'last_datetime' : commit.committed_datetime, + 'last_datetime' : commit.datetime(), 'lines' : len(lines) } diff --git a/setup.py b/setup.py index fe91cf9..276d23d 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 From f90ef727fb0b57d78976064ca9023b9203e01f88 Mon Sep 17 00:00:00 2001 From: Urs Liska Date: Mon, 2 Mar 2020 15:18:04 +0100 Subject: [PATCH 02/27] Object oriented refactoring Have dedicated objects: - Repo - Author - Page - Commit handle all the data and responsibilities. --- mkdocs_git_authors_plugin/plugin.py | 58 ++-- mkdocs_git_authors_plugin/repo.py | 450 +++++++++++++++++++++++++--- mkdocs_git_authors_plugin/util.py | 100 ------- 3 files changed, 439 insertions(+), 169 deletions(-) delete mode 100644 mkdocs_git_authors_plugin/util.py diff --git a/mkdocs_git_authors_plugin/plugin.py b/mkdocs_git_authors_plugin/plugin.py index 5cfed61..ee341b8 100644 --- a/mkdocs_git_authors_plugin/plugin.py +++ b/mkdocs_git_authors_plugin/plugin.py @@ -1,11 +1,10 @@ import re from mkdocs.plugins import BasePlugin -from .util import Util +from .repo import Repo class GitAuthorsPlugin(BasePlugin): def __init__(self): - self.util = Util() -# self._book = Book() + self._repo = Repo() def on_page_markdown(self, markdown, page, config, files): """ @@ -33,15 +32,14 @@ def on_page_markdown(self, markdown, page, config, files): if not re.search(pattern, markdown, flags=re.IGNORECASE): return markdown - authors = self.util.get_authors( - path = page.file.abs_src_path - ) - authors_summary = self.util.summarize(authors) + page_obj = self.repo().page(page.file.abs_src_path) - return re.sub(pattern, - authors_summary, - markdown, - flags=re.IGNORECASE) + return re.sub( + pattern, + page_obj.authors_summary(), + markdown, + flags=re.IGNORECASE + ) def on_page_context(self, context, page, **kwargs): """ @@ -62,12 +60,32 @@ def on_page_context(self, context, page, **kwargs): dict: template context variables """ - authors = self.util.get_authors( - path = page.file.abs_src_path - ) - authors_summary = self.util.summarize(authors) - - 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.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), + 'contribution' : author.contribution(path, 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 index 3841662..a3363bc 100644 --- a/mkdocs_git_authors_plugin/repo.py +++ b/mkdocs_git_authors_plugin/repo.py @@ -1,7 +1,7 @@ +from pathlib import Path import os import subprocess -from collections import OrderedDict from datetime import datetime, timedelta, timezone class GitCommandError(Exception): @@ -10,6 +10,7 @@ class GitCommandError(Exception): """ pass + class GitCommand(object): """ Wrapper around a Git command. @@ -29,8 +30,10 @@ def __init__(self, command: str, args: list = []): """ Initialize the GitCommand. - - command a string ('git' will implicitly be prepended) - - args: a string list with remaining command arguments. + 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) @@ -42,9 +45,16 @@ def __init__(self, command: str, args: list = []): 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'] @@ -76,18 +86,29 @@ def run(self): 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') @@ -96,6 +117,11 @@ def stderr(self): 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') @@ -109,23 +135,54 @@ class Repo(object): 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 + # Store Page objects, indexed by Path object self._pages = {} + # Store Author objects, indexed by email + self._authors = {} - def blame(self, path: str): + def add_total_lines(self, cnt: int = 1): """ - Return the cached Page object for path. + Add line(s) to the number of total lines in the repository. + + Args: + number of lines to add, default: 1 """ - if not self._pages.get(path): - self._pages[path] = Page(self, path) - return self._pages[path].commits() + 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 commit(self, sha: str): """ - Return the cached Commit object for sha. + 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) @@ -138,17 +195,58 @@ def find_repo_root(self): 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 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 root(self): """ Returns the repository root. + + Args: + + Returns: + str """ return self._root + 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): """ @@ -161,6 +259,11 @@ def __init__(self, repo: Repo): def repo(self): """ Return a reference to the Repo object. + + Args: + + Returns: + Repo instance """ return self._repo @@ -175,29 +278,44 @@ class Commit(AbstractRepoObject): """ def __init__(self, repo: Repo, sha: 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._sha = sha self._populate() - def author_name(self): + def author(self): """ - The commit's author name. - """ - return self._author_name + The commit's author. - def author_email(self): - """ - The commit's author email. + Args: + + Returns: + Author object """ - return self._author_email + return self._author - def datetime(self): + 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 + return self._datetime_string if _type == str else self._datetime def _populate(self): """ @@ -213,24 +331,34 @@ def _populate(self): result = cmd.stdout() # Author name and email are returned on single lines. - self._author_name = result[0] - self._author_email = result[1] + self._author = self.repo().author(result[0], result[1]) + # Third line includes formatted date/time info - d, t, tz = result[2].split(' ') + self._datetime_string = dt = result[2] + d, t, tz = dt.split(' ') d = [int(v) for v in d.split('-')] t = [int(v) for v in t.split(':')] # timezone info looks like +hhmm or -hhmm tz_hours = int(tz[:3]) th_minutes = int(tz[0] + tz[3:]) - tzinfo = timezone(timedelta(hours=tz_hours,minutes=th_minutes)) + # Construct 'aware' datetime.datetime object self._datetime = datetime( - d[0], d[1], d[2], t[0], t[1], t[2], tzinfo=tzinfo + d[0], d[1], d[2], + hour=t[0], + minute=t[1], + second=t[2], + tzinfo=timezone(timedelta(hours=tz_hours,minutes=th_minutes)) ) def sha(self): """ Return the commit's 40 byte SHA. + + Args: + + Returns: + 40-byte SHA string """ return self._sha @@ -244,21 +372,71 @@ class Page(AbstractRepoObject): modified by that commit. """ - def __init__(self, repo: Repo, path: str): + 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._commits = [] + self._sorted = False + self._total_lines = 0 + self._authors = [] self._execute() - def commits(self): + def add_total_lines(self, cnt: int = 1): """ - Returns the list of blame commits for the given file. + Add line(s) to the count of total lines for the page. - Each item in this list is a tuple of - - a Commit object - - a string list of lines affected by this commit. + Arg: + cnt: number of lines to add. Default: 1 """ - return self._commits + self._total_lines += cnt + + def 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. + + NOTE: Sorting should be made configurable and at least + offer sorting by contribution (ASC/DESC). + + Args: + + Returns: + sorted list with Author objects + """ + if not self._sorted: + self._authors = sorted( + self._authors, key = lambda author: author.name() + ) + 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.authors() + authors_summary = [ + "%s" % ( + author.email(), + author.name() + ) + for author in authors + ] + authors_summary = ', '.join(authors_summary) + return "%s" % authors_summary def _execute(self): """ @@ -267,21 +445,195 @@ def _execute(self): cmd = GitCommand('blame', ['-lts', self._path]) cmd.run() - result = cmd.stdout() - current_sha = '' - for line in result: - # split result line + for line in cmd.stdout(): sha = line[:40] - # formatted line number, separated by spaces - offset = line[41:].find(' ') - content = line[42+offset:] - if current_sha != sha: - # line has different commit than previous one - self._commits.append(( - self.repo().commit(sha), - [content] - )) - elif sha: - # append line to previous commit's lines list - self._commits[-1][1].append(content) + if sha: + # assign the line to a commit and count it + commit = self.repo().commit(sha) + 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 deleted file mode 100644 index 7ba77eb..0000000 --- a/mkdocs_git_authors_plugin/util.py +++ /dev/null @@ -1,100 +0,0 @@ -import logging -from pathlib import Path - -from .repo import Repo - - -class Util: - - def __init__(self): - self.repo = Repo() - # Cache authors entries by path - self._authors = {} - - def get_authors(self, path): - """ - 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(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.datetime() > current_dt: - authors[key]['last_datetime'] = commit.datetime() - # Add new author - else: - authors[key] = { - 'name' : commit.author_name(), - 'email' : key, - 'last_datetime' : commit.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): - """ - Summarized list of authors to a HTML string - - Args: - authors (list): List with author dicts - - Returns: - str: HTML text with authors - """ - - authors_summary = ["%s" % (x['email'] ,x['name']) for x in authors] - authors_summary = ', '.join(authors_summary) - return "" + authors_summary + "" From 558dfc38842dfc44ea0ca87fe0f2cb4a22931820 Mon Sep 17 00:00:00 2001 From: Urs Liska Date: Mon, 2 Mar 2020 15:45:53 +0100 Subject: [PATCH 03/27] Preload git blame data This commit creates the Page objects within the on_files MkDocs event. As a result the lines/contribution statistics for the whole book are now available from within *every* Markdown page. --- mkdocs_git_authors_plugin/plugin.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/mkdocs_git_authors_plugin/plugin.py b/mkdocs_git_authors_plugin/plugin.py index ee341b8..541b00e 100644 --- a/mkdocs_git_authors_plugin/plugin.py +++ b/mkdocs_git_authors_plugin/plugin.py @@ -6,6 +6,22 @@ class GitAuthorsPlugin(BasePlugin): def __init__(self): self._repo = Repo() + def on_files(self, files, **kwargs): + """ + Preprocess all markdown pages in the project + + This populates all the lines and total_lines properties + of the pages and the repository, so the total + contribution of an author to the repository can be + retrieved on *any* Markdown page. + """ + cnt = 0 + for file in files: + path = file.abs_src_path + if path.endswith('.md'): + cnt += 1 + _ = self.repo().page(path) + def on_page_markdown(self, markdown, page, config, files): """ Replace jinja tag {{ git_authors_summary }} in markdown. From f37c9f8905bffb618eb58f3e5e7fc8ff55edc8b5 Mon Sep 17 00:00:00 2001 From: Urs Liska Date: Mon, 2 Mar 2020 17:07:27 +0100 Subject: [PATCH 04/27] Handle uncommitted changes The 00000 "commit" shown by git blame for uncommitted lines caused the plugin to crash when trying to execute "git show" on it. Therefore uncommitted stuff is attributed to a fake author whose display characteristics are configurable. --- README.md | 2 +- mkdocs_git_authors_plugin/plugin.py | 2 ++ mkdocs_git_authors_plugin/repo.py | 11 ++++++++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cd2e82d..59bed8e 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: diff --git a/mkdocs_git_authors_plugin/plugin.py b/mkdocs_git_authors_plugin/plugin.py index f15553d..60ad636 100644 --- a/mkdocs_git_authors_plugin/plugin.py +++ b/mkdocs_git_authors_plugin/plugin.py @@ -6,6 +6,8 @@ class GitAuthorsPlugin(BasePlugin): config_scheme = ( ('show_contribution', config_options.Type(bool, default=False)), + ('uncommitted_name', config_options.Type(str, default='Uncommitted')), + ('uncommitted_email', config_options.Type(str, default='#')) ) def __init__(self): diff --git a/mkdocs_git_authors_plugin/repo.py b/mkdocs_git_authors_plugin/repo.py index 2323376..ccb133c 100644 --- a/mkdocs_git_authors_plugin/repo.py +++ b/mkdocs_git_authors_plugin/repo.py @@ -307,7 +307,16 @@ def __init__(self, repo: Repo, sha: str): super().__init__(repo) self._sha = sha - self._populate() + if sha == '0000000000000000000000000000000000000000': + # Create fake commit for uncommitted changes + self._author = self.repo().author( + self.repo().config('uncommitted_name'), + self.repo().config('uncommitted_email') + ) + self._datetime = None + self._datetime_string = '---' + else: + self._populate() def author(self): """ From 98bffc36c2834b081a942cb95a2fd0791d7f8fd2 Mon Sep 17 00:00:00 2001 From: Urs Liska Date: Mon, 2 Mar 2020 17:17:50 +0100 Subject: [PATCH 05/27] Document uncommitted_changes --- README.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 59bed8e..8ec0ea9 100644 --- a/README.md +++ b/README.md @@ -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): @@ -134,6 +134,13 @@ Example output: * Authors: [John Doe](#) (33.33%), [Jane Doe](#) (66.67%) *(more than one author)* * Authors: [John Doe](#) *(one author)* +### `uncommitted_name` and `uncommitted_email` + +Lines that `git blame` consideres uncommitted can't be attributed to an author, +therefore they are assigned to a virtual author `Uncommitted` with a pseudo +email address of `#`. These values can be changed with the options +`uncommitted_name` (default “Uncommitted”) and `uncommitted_email` (default “#”). + ### Aggregating Authors In some repositories authors may have committed with differing name/email combinations. @@ -150,4 +157,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). - From 2739240c164d79d508a85f9036011af7803c6464 Mon Sep 17 00:00:00 2001 From: Urs Liska Date: Tue, 3 Mar 2020 09:10:45 +0100 Subject: [PATCH 06/27] Add {{ git_authors_list }} (book-level summary) Inserting {{ git_authors_list }} in any Markdown file will insert a list of all authors along with their contribution to the whole site. Adds configuration options: - show_lines (default: false) also show the line count in the list (but not on a page's summary) - label_lines (for localization) TODO: Provide *real* configurability of the resulting HTML --- mkdocs_git_authors_plugin/plugin.py | 50 ++++++++++++++++++--- mkdocs_git_authors_plugin/repo.py | 68 +++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 7 deletions(-) diff --git a/mkdocs_git_authors_plugin/plugin.py b/mkdocs_git_authors_plugin/plugin.py index 60ad636..ff5d7b1 100644 --- a/mkdocs_git_authors_plugin/plugin.py +++ b/mkdocs_git_authors_plugin/plugin.py @@ -6,6 +6,8 @@ class GitAuthorsPlugin(BasePlugin): config_scheme = ( ('show_contribution', config_options.Type(bool, default=False)), + ('show_lines', config_options.Type(bool, default=False)), + ('label_lines', config_options.Type(str, default='lines')), ('uncommitted_name', config_options.Type(str, default='Uncommitted')), ('uncommitted_email', config_options.Type(str, default='#')) ) @@ -38,6 +40,40 @@ def on_files(self, files, **kwargs): cnt += 1 _ = 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( + self.repo().authors_summary(), + html + ) + return html + def on_page_markdown(self, markdown, page, config, files): """ Replace jinja tag {{ git_authors_summary }} in markdown. @@ -59,18 +95,18 @@ 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): + if not summary_pattern.search(markdown): return markdown page_obj = self.repo().page(page.file.abs_src_path) - - return re.sub( - pattern, + return summary_pattern.sub( page_obj.authors_summary(), - markdown, - flags=re.IGNORECASE + markdown ) def on_page_context(self, context, page, **kwargs): diff --git a/mkdocs_git_authors_plugin/repo.py b/mkdocs_git_authors_plugin/repo.py index ccb133c..ea8779f 100644 --- a/mkdocs_git_authors_plugin/repo.py +++ b/mkdocs_git_authors_plugin/repo.py @@ -171,6 +171,74 @@ def author(self, name, email: str): self._authors[email] = Author(self, name, email) return self._authors[email] + def authors(self): + """ + Sorted list of authors in the repository. + + NOTE: Currently sorting is hard-coded to be by name. + + Args: + + Returns: + List of Author objects + """ + return sorted([ + author for author in self._authors.values() + ], key = lambda author: author.name()) + + def authors_summary(self): + """ + A summary list of the authors' contributions on book 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 + ) + - Make this sortable (probably with a global plugin option that + also affects the page's authors_summary). + + Args: + + Returns: + Unordered HTML list as a string. + """ + show_contribution = self.config('show_contribution') + show_lines = show_contribution and self.config('show_lines') + label_lines = self.config('label_lines') + result = """ +
    + """ + for author in self.authors(): + contribution = ( + ' (%s)' % author.contribution(None, str) + if show_contribution + else '' + ) + lines = ( + '%s %s' % (author.lines(), label_lines) + if show_lines + else '' + ) + result += """ +
  • {author_name}: + {lines}{contribution}
  • + """.format( + author_email=author.email(), + author_name=author.name(), + lines=lines, + contribution=contribution + ) + result += """ +
+ """ + return result + def commit(self, sha: str): """ Return the (cached) Commit object for given sha. From 0869525fa57778a1fe42604adf036aad90c84e6f Mon Sep 17 00:00:00 2001 From: Urs Liska Date: Tue, 3 Mar 2020 09:55:46 +0100 Subject: [PATCH 07/27] Make sort order configurable Adds configuration options - sort_by (choice: 'name', 'contribution') - sort_reverse (bool, default: False) --- mkdocs_git_authors_plugin/plugin.py | 4 ++++ mkdocs_git_authors_plugin/repo.py | 32 ++++++++++++++++++++++------- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/mkdocs_git_authors_plugin/plugin.py b/mkdocs_git_authors_plugin/plugin.py index ff5d7b1..4f4e948 100644 --- a/mkdocs_git_authors_plugin/plugin.py +++ b/mkdocs_git_authors_plugin/plugin.py @@ -8,6 +8,10 @@ class GitAuthorsPlugin(BasePlugin): ('show_contribution', config_options.Type(bool, default=False)), ('show_lines', config_options.Type(bool, default=False)), ('label_lines', config_options.Type(str, default='lines')), + ('sort_by', config_options.Choice( + ['name', 'contribution'], default='name') + ), + ('sort_reverse', config_options.Type(bool, default=False)), ('uncommitted_name', config_options.Type(str, default='Uncommitted')), ('uncommitted_email', config_options.Type(str, default='#')) ) diff --git a/mkdocs_git_authors_plugin/repo.py b/mkdocs_git_authors_plugin/repo.py index ea8779f..393be03 100644 --- a/mkdocs_git_authors_plugin/repo.py +++ b/mkdocs_git_authors_plugin/repo.py @@ -175,7 +175,8 @@ def authors(self): """ Sorted list of authors in the repository. - NOTE: Currently sorting is hard-coded to be by name. + Default sort order is by ascending names, which can be changed + to descending and/or by contribution Args: @@ -183,8 +184,11 @@ def authors(self): List of Author objects """ return sorted([ - author for author in self._authors.values() - ], key = lambda author: author.name()) + author for author in self._authors.values() + ], + key=self._sort_key, + reverse=self.config('sort_reverse') + ) def authors_summary(self): """ @@ -321,6 +325,20 @@ def set_config(self, plugin_config): """ 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_by' configuration option + """ + func = getattr(author, self.config('sort_by')) + return func() + def total_lines(self): """ The total number of lines in the project's markdown files @@ -498,17 +516,17 @@ def authors(self): The list is sorted once upon first request. Sorting is done by author name. - NOTE: Sorting should be made configurable and at least - offer sorting by contribution (ASC/DESC). - Args: Returns: sorted list with Author objects """ if not self._sorted: + repo = self.repo() self._authors = sorted( - self._authors, key = lambda author: author.name() + self._authors, + key=repo._sort_key, + reverse=repo.config('sort_reverse') ) self._sorted = True return self._authors From bc2fa5343e9c13a26c9779e562110f7ca89570a5 Mon Sep 17 00:00:00 2001 From: Urs Liska Date: Tue, 3 Mar 2020 10:22:11 +0100 Subject: [PATCH 08/27] Fix tracebacks with uncommitted files --- mkdocs_git_authors_plugin/repo.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/mkdocs_git_authors_plugin/repo.py b/mkdocs_git_authors_plugin/repo.py index 393be03..5526f77 100644 --- a/mkdocs_git_authors_plugin/repo.py +++ b/mkdocs_git_authors_plugin/repo.py @@ -1,4 +1,5 @@ from pathlib import Path +import logging import os import subprocess @@ -498,7 +499,12 @@ def __init__(self, repo: Repo, path: Path): self._sorted = False self._total_lines = 0 self._authors = [] - self._execute() + try: + self._execute() + except GitCommandError: + logging.warning( + '%s has not been committed yet. Lines are not counted' % path + ) def add_total_lines(self, cnt: int = 1): """ @@ -563,7 +569,7 @@ def _execute(self): Execute git blame and parse the results. """ - cmd = GitCommand('blame', ['-lts', self._path]) + cmd = GitCommand('blame', ['-lts', str(self._path)]) cmd.run() for line in cmd.stdout(): From ca06d20f40d985423a9322678a905f08000bd2fa Mon Sep 17 00:00:00 2001 From: Urs Liska Date: Tue, 3 Mar 2020 12:16:19 +0100 Subject: [PATCH 09/27] Add option count_empty_lines There may be users not interested in counting empty lines as content. --- mkdocs_git_authors_plugin/plugin.py | 1 + mkdocs_git_authors_plugin/repo.py | 29 +++++++++++++++++++---------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/mkdocs_git_authors_plugin/plugin.py b/mkdocs_git_authors_plugin/plugin.py index 4f4e948..e0c9338 100644 --- a/mkdocs_git_authors_plugin/plugin.py +++ b/mkdocs_git_authors_plugin/plugin.py @@ -7,6 +7,7 @@ class GitAuthorsPlugin(BasePlugin): config_scheme = ( ('show_contribution', config_options.Type(bool, default=False)), ('show_lines', config_options.Type(bool, default=False)), + ('count_empty_lines', config_options.Type(bool, default=True)), ('label_lines', config_options.Type(str, default='lines')), ('sort_by', config_options.Choice( ['name', 'contribution'], default='name') diff --git a/mkdocs_git_authors_plugin/repo.py b/mkdocs_git_authors_plugin/repo.py index 5526f77..1e4417c 100644 --- a/mkdocs_git_authors_plugin/repo.py +++ b/mkdocs_git_authors_plugin/repo.py @@ -1,6 +1,7 @@ from pathlib import Path import logging import os +import re import subprocess from datetime import datetime, timedelta, timezone @@ -572,17 +573,25 @@ def _execute(self): cmd = GitCommand('blame', ['-lts', str(self._path)]) cmd.run() + # Retrieve SHA and content from the line, discarding + # file path and line number + line_pattern = re.compile('(.*?)\s.*\s*\d\)(\s*.*)') + for line in cmd.stdout(): - sha = line[:40] - if sha: - # assign the line to a commit and count it - commit = self.repo().commit(sha) - 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() + m = line_pattern.match(line) + if m: + sha = m.group(1) + content = m.group(2).strip() + + if content or self.repo().config('count_empty_lines'): + # assign the line to a commit and count it + commit = self.repo().commit(sha) + 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): """ From bac88c052b05b8fc27eb22119dadba54bc4bf975 Mon Sep 17 00:00:00 2001 From: Urs Liska Date: Tue, 3 Mar 2020 12:18:47 +0100 Subject: [PATCH 10/27] Remove obsolete counter variable --- mkdocs_git_authors_plugin/plugin.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/mkdocs_git_authors_plugin/plugin.py b/mkdocs_git_authors_plugin/plugin.py index e0c9338..c422410 100644 --- a/mkdocs_git_authors_plugin/plugin.py +++ b/mkdocs_git_authors_plugin/plugin.py @@ -38,11 +38,9 @@ def on_files(self, files, **kwargs): contribution of an author to the repository can be retrieved on *any* Markdown page. """ - cnt = 0 for file in files: path = file.abs_src_path if path.endswith('.md'): - cnt += 1 _ = self.repo().page(path) def on_page_content(self, html, page, config, files, **kwargs): From 251adc3bff08e3b72d72d6587795f6cf900660ed Mon Sep 17 00:00:00 2001 From: Urs Liska Date: Tue, 3 Mar 2020 19:05:12 +0100 Subject: [PATCH 11/27] Rename show_lines to show_line_count --- mkdocs_git_authors_plugin/plugin.py | 2 +- mkdocs_git_authors_plugin/repo.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mkdocs_git_authors_plugin/plugin.py b/mkdocs_git_authors_plugin/plugin.py index c422410..9238fe2 100644 --- a/mkdocs_git_authors_plugin/plugin.py +++ b/mkdocs_git_authors_plugin/plugin.py @@ -6,7 +6,7 @@ class GitAuthorsPlugin(BasePlugin): config_scheme = ( ('show_contribution', config_options.Type(bool, default=False)), - ('show_lines', config_options.Type(bool, default=False)), + ('show_line_count', config_options.Type(bool, default=False)), ('count_empty_lines', config_options.Type(bool, default=True)), ('label_lines', config_options.Type(str, default='lines')), ('sort_by', config_options.Choice( diff --git a/mkdocs_git_authors_plugin/repo.py b/mkdocs_git_authors_plugin/repo.py index 1e4417c..c24b2ce 100644 --- a/mkdocs_git_authors_plugin/repo.py +++ b/mkdocs_git_authors_plugin/repo.py @@ -215,7 +215,7 @@ def authors_summary(self): Unordered HTML list as a string. """ show_contribution = self.config('show_contribution') - show_lines = show_contribution and self.config('show_lines') + show_line_count = show_contribution and self.config('show_line_count') label_lines = self.config('label_lines') result = """
    @@ -228,7 +228,7 @@ def authors_summary(self): ) lines = ( '%s %s' % (author.lines(), label_lines) - if show_lines + if show_line_count else '' ) result += """ From 0da94092cda587363940e81d53e3af62acb9edcc Mon Sep 17 00:00:00 2001 From: Urs Liska Date: Tue, 3 Mar 2020 19:09:38 +0100 Subject: [PATCH 12/27] Rename sort_by to sort_authors_by --- mkdocs_git_authors_plugin/plugin.py | 2 +- mkdocs_git_authors_plugin/repo.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mkdocs_git_authors_plugin/plugin.py b/mkdocs_git_authors_plugin/plugin.py index 9238fe2..690a8dc 100644 --- a/mkdocs_git_authors_plugin/plugin.py +++ b/mkdocs_git_authors_plugin/plugin.py @@ -9,7 +9,7 @@ class GitAuthorsPlugin(BasePlugin): ('show_line_count', config_options.Type(bool, default=False)), ('count_empty_lines', config_options.Type(bool, default=True)), ('label_lines', config_options.Type(str, default='lines')), - ('sort_by', config_options.Choice( + ('sort_authors_by', config_options.Choice( ['name', 'contribution'], default='name') ), ('sort_reverse', config_options.Type(bool, default=False)), diff --git a/mkdocs_git_authors_plugin/repo.py b/mkdocs_git_authors_plugin/repo.py index c24b2ce..1277ba0 100644 --- a/mkdocs_git_authors_plugin/repo.py +++ b/mkdocs_git_authors_plugin/repo.py @@ -336,9 +336,9 @@ def _sort_key(self, author): Returns: comparison key for the sorted() function, - determined by the 'sort_by' configuration option + determined by the 'sort_authors_by' configuration option """ - func = getattr(author, self.config('sort_by')) + func = getattr(author, self.config('sort_authors_by')) return func() def total_lines(self): From e5c54ed11db3d3ed58876c392b5503faa8b77586 Mon Sep 17 00:00:00 2001 From: Urs Liska Date: Tue, 3 Mar 2020 19:18:59 +0100 Subject: [PATCH 13/27] Clean up plugin events signature and docstrings --- mkdocs_git_authors_plugin/plugin.py | 39 ++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/mkdocs_git_authors_plugin/plugin.py b/mkdocs_git_authors_plugin/plugin.py index 690a8dc..fd00d9f 100644 --- a/mkdocs_git_authors_plugin/plugin.py +++ b/mkdocs_git_authors_plugin/plugin.py @@ -24,19 +24,46 @@ def on_config(self, config, **kwargs): """ Store the plugin configuration in the Repo object. - This is only the dictionary with the plugin configuration, + 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, **kwargs): + def on_files(self, files, config, **kwargs): """ - Preprocess all markdown pages in the project + 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, so the total contribution of an author to the repository can be retrieved on *any* Markdown page. + + Args: + files: global files collection + config: global configuration object + + Returns: + global files collection """ for file in files: path = file.abs_src_path @@ -112,7 +139,7 @@ def on_page_markdown(self, markdown, page, config, files): markdown ) - 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. @@ -121,11 +148,15 @@ 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 From bbba45713494a06bca6744c81d59b754b03ce111 Mon Sep 17 00:00:00 2001 From: Urs Liska Date: Tue, 3 Mar 2020 19:36:41 +0100 Subject: [PATCH 14/27] Add lines_all_pages and contribution_all_pages to page context --- mkdocs_git_authors_plugin/plugin.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mkdocs_git_authors_plugin/plugin.py b/mkdocs_git_authors_plugin/plugin.py index fd00d9f..d77312f 100644 --- a/mkdocs_git_authors_plugin/plugin.py +++ b/mkdocs_git_authors_plugin/plugin.py @@ -178,7 +178,9 @@ def on_page_context(self, context, page, config, nav, **kwargs): 'email' : author.email(), 'last_datetime' : author.datetime(path, str), 'lines' : author.lines(path), - 'contribution' : author.contribution(path, str) + 'lines_all_pages' : author.lines(), + 'contribution' : author.contribution(path, str), + 'contribution_all_pages' : author.contribution(None, str) } for author in authors ] From 7b178dc60045504c975c8d0f8c3f1ae10cd059bc Mon Sep 17 00:00:00 2001 From: Urs Liska Date: Tue, 3 Mar 2020 19:58:20 +0100 Subject: [PATCH 15/27] Rename authors() to get_authors() --- mkdocs_git_authors_plugin/plugin.py | 2 +- mkdocs_git_authors_plugin/repo.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/mkdocs_git_authors_plugin/plugin.py b/mkdocs_git_authors_plugin/plugin.py index d77312f..a747c0d 100644 --- a/mkdocs_git_authors_plugin/plugin.py +++ b/mkdocs_git_authors_plugin/plugin.py @@ -164,7 +164,7 @@ def on_page_context(self, context, page, config, nav, **kwargs): path = page.file.abs_src_path page_obj = self.repo().page(path) - authors = page_obj.authors() + authors = page_obj.get_authors() # NOTE: last_datetime is currently given as a # string in the format diff --git a/mkdocs_git_authors_plugin/repo.py b/mkdocs_git_authors_plugin/repo.py index 1277ba0..1fe0f03 100644 --- a/mkdocs_git_authors_plugin/repo.py +++ b/mkdocs_git_authors_plugin/repo.py @@ -173,7 +173,7 @@ def author(self, name, email: str): self._authors[email] = Author(self, name, email) return self._authors[email] - def authors(self): + def get_authors(self): """ Sorted list of authors in the repository. @@ -220,7 +220,7 @@ def authors_summary(self): result = """
      """ - for author in self.authors(): + for author in self.get_authors(): contribution = ( ' (%s)' % author.contribution(None, str) if show_contribution @@ -516,7 +516,7 @@ def add_total_lines(self, cnt: int = 1): """ self._total_lines += cnt - def authors(self): + def get_authors(self): """ Return a sorted list of authors for the page @@ -547,13 +547,13 @@ def authors_summary(self): str: HTML text with authors """ - authors = self.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.authors()) > 1 + and len(self.get_authors()) > 1 else '' ) authors_summary.append( From 270cc1f77ef04112de7644a4183bb62e31b574c2 Mon Sep 17 00:00:00 2001 From: Urs Liska Date: Tue, 3 Mar 2020 20:07:54 +0100 Subject: [PATCH 16/27] Remove (unused) function Repo.root() --- mkdocs_git_authors_plugin/repo.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/mkdocs_git_authors_plugin/repo.py b/mkdocs_git_authors_plugin/repo.py index 1fe0f03..2a591d4 100644 --- a/mkdocs_git_authors_plugin/repo.py +++ b/mkdocs_git_authors_plugin/repo.py @@ -307,17 +307,6 @@ def page(self, path): self._pages[path] = Page(self, path) return self._pages[path] - def root(self): - """ - Returns the repository root. - - Args: - - Returns: - str - """ - return self._root - def set_config(self, plugin_config): """ Store the plugin configuration in the Repo instance. From 7f1726cf0167842c674e7db010b9422880e4d0bc Mon Sep 17 00:00:00 2001 From: Urs Liska Date: Tue, 3 Mar 2020 22:12:00 +0100 Subject: [PATCH 17/27] Use repo argument directly in Commit.__init__ We don't have to go through the member accessor function when we have the repo reference as a function argument. --- mkdocs_git_authors_plugin/repo.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mkdocs_git_authors_plugin/repo.py b/mkdocs_git_authors_plugin/repo.py index 2a591d4..d27ceec 100644 --- a/mkdocs_git_authors_plugin/repo.py +++ b/mkdocs_git_authors_plugin/repo.py @@ -386,9 +386,9 @@ def __init__(self, repo: Repo, sha: str): self._sha = sha if sha == '0000000000000000000000000000000000000000': # Create fake commit for uncommitted changes - self._author = self.repo().author( - self.repo().config('uncommitted_name'), - self.repo().config('uncommitted_email') + self._author = repo.author( + repo.config('uncommitted_name'), + repo.config('uncommitted_email') ) self._datetime = None self._datetime_string = '---' From 422653b44e01d6d1469910153f4b7c405ba690b7 Mon Sep 17 00:00:00 2001 From: Urs Liska Date: Tue, 3 Mar 2020 22:18:16 +0100 Subject: [PATCH 18/27] Proper comment for populating uncommitted lines --- mkdocs_git_authors_plugin/repo.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mkdocs_git_authors_plugin/repo.py b/mkdocs_git_authors_plugin/repo.py index d27ceec..b596987 100644 --- a/mkdocs_git_authors_plugin/repo.py +++ b/mkdocs_git_authors_plugin/repo.py @@ -385,7 +385,9 @@ def __init__(self, repo: Repo, sha: str): super().__init__(repo) self._sha = sha if sha == '0000000000000000000000000000000000000000': - # Create fake commit for uncommitted changes + # This indicates an uncommitted line, so there's + # no actual Git commit to inspect. Instead we + # populate the Commit object wtih a fake Author. self._author = repo.author( repo.config('uncommitted_name'), repo.config('uncommitted_email') From acd4506562c17f96eae5a2b524ec29e8451c07a0 Mon Sep 17 00:00:00 2001 From: Urs Liska Date: Tue, 3 Mar 2020 22:26:44 +0100 Subject: [PATCH 19/27] Remove Commit.sha() This method is only used once and can easily be avoided by directly passing the sha argument to Commit._populate. Incidentally: Move the handling of uncommitted lines *into* Commit._populate --- mkdocs_git_authors_plugin/repo.py | 46 ++++++++++++++----------------- 1 file changed, 20 insertions(+), 26 deletions(-) diff --git a/mkdocs_git_authors_plugin/repo.py b/mkdocs_git_authors_plugin/repo.py index b596987..bc4cf33 100644 --- a/mkdocs_git_authors_plugin/repo.py +++ b/mkdocs_git_authors_plugin/repo.py @@ -383,19 +383,7 @@ def __init__(self, repo: Repo, sha: str): """ super().__init__(repo) - self._sha = sha - if sha == '0000000000000000000000000000000000000000': - # This indicates an uncommitted line, so there's - # no actual Git commit to inspect. Instead we - # populate the Commit object wtih a fake Author. - self._author = repo.author( - repo.config('uncommitted_name'), - repo.config('uncommitted_email') - ) - self._datetime = None - self._datetime_string = '---' - else: - self._populate() + self._populate(sha) def author(self): """ @@ -423,15 +411,32 @@ def datetime(self, _type=str): """ return self._datetime_string if _type == str else self._datetime - def _populate(self): + def _populate(self, sha: str): """ Retrieve information about the commit. + + Args: + sha: 40-byte SHA string of the commit + Returns: + """ + if sha == '0000000000000000000000000000000000000000': + # This indicates an uncommitted line, so there's + # no actual Git commit to inspect. Instead we + # populate the Commit object wtih a fake Author. + self._author = repo.author( + repo.config('uncommitted_name'), + repo.config('uncommitted_email') + ) + self._datetime = None + self._datetime_string = '---' + return + cmd = GitCommand('show', [ '-t', '--quiet', "--format='%aN%n%aE%n%ai'", - self.sha() + sha ]) cmd.run() result = cmd.stdout() @@ -457,17 +462,6 @@ def _populate(self): tzinfo=timezone(timedelta(hours=tz_hours,minutes=th_minutes)) ) - def sha(self): - """ - Return the commit's 40 byte SHA. - - Args: - - Returns: - 40-byte SHA string - """ - return self._sha - class Page(AbstractRepoObject): """ From 71eeae270408f6bb224ebe9ebc30ccebf778707e Mon Sep 17 00:00:00 2001 From: Urs Liska Date: Tue, 3 Mar 2020 22:36:04 +0100 Subject: [PATCH 20/27] Factor out commit_datetime function This is not related to the actual object but a simple conversion function and therefore an optimal candidate for a generic "util" module --- mkdocs_git_authors_plugin/repo.py | 20 +++----------------- mkdocs_git_authors_plugin/util.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 17 deletions(-) create mode 100644 mkdocs_git_authors_plugin/util.py diff --git a/mkdocs_git_authors_plugin/repo.py b/mkdocs_git_authors_plugin/repo.py index bc4cf33..2cf913c 100644 --- a/mkdocs_git_authors_plugin/repo.py +++ b/mkdocs_git_authors_plugin/repo.py @@ -4,7 +4,7 @@ import re import subprocess -from datetime import datetime, timedelta, timezone +from . import util class GitCommandError(Exception): """ @@ -445,22 +445,8 @@ def _populate(self, sha: str): self._author = self.repo().author(result[0], result[1]) # Third line includes formatted date/time info - self._datetime_string = dt = result[2] - d, t, tz = dt.split(' ') - d = [int(v) for v in d.split('-')] - t = [int(v) for v in t.split(':')] - # timezone info looks like +hhmm or -hhmm - tz_hours = int(tz[:3]) - th_minutes = int(tz[0] + tz[3:]) - - # Construct 'aware' datetime.datetime object - self._datetime = datetime( - d[0], d[1], d[2], - hour=t[0], - minute=t[1], - second=t[2], - tzinfo=timezone(timedelta(hours=tz_hours,minutes=th_minutes)) - ) + self._datetime_string = result[2] + self._datetime = util.commit_datetime(self._datetime_string) class Page(AbstractRepoObject): diff --git a/mkdocs_git_authors_plugin/util.py b/mkdocs_git_authors_plugin/util.py new file mode 100644 index 0000000..c887629 --- /dev/null +++ b/mkdocs_git_authors_plugin/util.py @@ -0,0 +1,29 @@ +from datetime import datetime, timezone, timedelta + +def commit_datetime(dt: str): + """ + Convert a commit's datetime string to a + datetime.datetime object with timezone info. + + Args: + A string returned from the %ai formatting argument + in a git show command. + + Returns: + datetime.datetime object with tzinfo + """ + d, t, tz = dt.split(' ') + d = [int(v) for v in d.split('-')] + t = [int(v) for v in t.split(':')] + # timezone info looks like +hhmm or -hhmm + tz_hours = int(tz[:3]) + th_minutes = int(tz[0] + tz[3:]) + + # Construct 'aware' datetime.datetime object + return datetime( + d[0], d[1], d[2], + hour=t[0], + minute=t[1], + second=t[2], + tzinfo=timezone(timedelta(hours=tz_hours,minutes=th_minutes)) + ) From d5f61599d344708f58ab4fb7ea228604a53e978e Mon Sep 17 00:00:00 2001 From: Urs Liska Date: Tue, 3 Mar 2020 22:38:21 +0100 Subject: [PATCH 21/27] Rename Page._execute to _process_git_blame --- mkdocs_git_authors_plugin/repo.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mkdocs_git_authors_plugin/repo.py b/mkdocs_git_authors_plugin/repo.py index 2cf913c..c6c5bd8 100644 --- a/mkdocs_git_authors_plugin/repo.py +++ b/mkdocs_git_authors_plugin/repo.py @@ -472,7 +472,7 @@ def __init__(self, repo: Repo, path: Path): self._total_lines = 0 self._authors = [] try: - self._execute() + self._process_git_blame() except GitCommandError: logging.warning( '%s has not been committed yet. Lines are not counted' % path @@ -536,7 +536,7 @@ def authors_summary(self): authors_summary = ', '.join(authors_summary) return "%s" % authors_summary - def _execute(self): + def _process_git_blame(self): """ Execute git blame and parse the results. """ From 709b0e0da2b89bfb70cc8f1c171bea7d0c77d618 Mon Sep 17 00:00:00 2001 From: Urs Liska Date: Tue, 3 Mar 2020 22:51:49 +0100 Subject: [PATCH 22/27] Rename Repo.commit() to Repo.get_commit() The previous name raised concerns about users being worried "commit" might be used as a verb (i.e. creating commits in the actual repository). --- mkdocs_git_authors_plugin/repo.py | 36 +++++++++++++++---------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/mkdocs_git_authors_plugin/repo.py b/mkdocs_git_authors_plugin/repo.py index c6c5bd8..d690fb8 100644 --- a/mkdocs_git_authors_plugin/repo.py +++ b/mkdocs_git_authors_plugin/repo.py @@ -245,23 +245,6 @@ def authors_summary(self): """ return result - def commit(self, sha: str): - """ - 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) - return self._commits.get(sha) - def config(self, key: str = ''): """ Return the plugin configuration dictionary or a single config value. @@ -288,6 +271,23 @@ def find_repo_root(self): cmd.run() return cmd.stdout()[0] + def get_commit(self, sha: str): + """ + 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) + return self._commits.get(sha) + def page(self, path): """ Return the (cached) Page object for given path. @@ -556,7 +556,7 @@ def _process_git_blame(self): if content or self.repo().config('count_empty_lines'): # assign the line to a commit and count it - commit = self.repo().commit(sha) + commit = self.repo().get_commit(sha) author = commit.author() if author not in self._authors: self._authors.append(author) From e6c6a8bb705fb9e02aae913ff1a27b9605b00777 Mon Sep 17 00:00:00 2001 From: Urs Liska Date: Tue, 3 Mar 2020 23:25:20 +0100 Subject: [PATCH 23/27] Remove localization config variables The configuration variables intended for localization have to be removed because the functionality will at one point be handled by localization. See #14 --- README.md | 7 ------- mkdocs_git_authors_plugin/plugin.py | 5 +---- mkdocs_git_authors_plugin/repo.py | 8 ++------ 3 files changed, 3 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 8ec0ea9..5715328 100644 --- a/README.md +++ b/README.md @@ -134,13 +134,6 @@ Example output: * Authors: [John Doe](#) (33.33%), [Jane Doe](#) (66.67%) *(more than one author)* * Authors: [John Doe](#) *(one author)* -### `uncommitted_name` and `uncommitted_email` - -Lines that `git blame` consideres uncommitted can't be attributed to an author, -therefore they are assigned to a virtual author `Uncommitted` with a pseudo -email address of `#`. These values can be changed with the options -`uncommitted_name` (default “Uncommitted”) and `uncommitted_email` (default “#”). - ### Aggregating Authors In some repositories authors may have committed with differing name/email combinations. diff --git a/mkdocs_git_authors_plugin/plugin.py b/mkdocs_git_authors_plugin/plugin.py index a747c0d..5e55353 100644 --- a/mkdocs_git_authors_plugin/plugin.py +++ b/mkdocs_git_authors_plugin/plugin.py @@ -8,13 +8,10 @@ class GitAuthorsPlugin(BasePlugin): ('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)), - ('label_lines', config_options.Type(str, default='lines')), ('sort_authors_by', config_options.Choice( ['name', 'contribution'], default='name') ), - ('sort_reverse', config_options.Type(bool, default=False)), - ('uncommitted_name', config_options.Type(str, default='Uncommitted')), - ('uncommitted_email', config_options.Type(str, default='#')) + ('sort_reverse', config_options.Type(bool, default=False)) ) def __init__(self): diff --git a/mkdocs_git_authors_plugin/repo.py b/mkdocs_git_authors_plugin/repo.py index d690fb8..9b5ab55 100644 --- a/mkdocs_git_authors_plugin/repo.py +++ b/mkdocs_git_authors_plugin/repo.py @@ -216,7 +216,6 @@ def authors_summary(self): """ show_contribution = self.config('show_contribution') show_line_count = show_contribution and self.config('show_line_count') - label_lines = self.config('label_lines') result = """
        """ @@ -227,7 +226,7 @@ def authors_summary(self): else '' ) lines = ( - '%s %s' % (author.lines(), label_lines) + '%s lines' % author.lines() if show_line_count else '' ) @@ -424,10 +423,7 @@ def _populate(self, sha: str): # This indicates an uncommitted line, so there's # no actual Git commit to inspect. Instead we # populate the Commit object wtih a fake Author. - self._author = repo.author( - repo.config('uncommitted_name'), - repo.config('uncommitted_email') - ) + self._author = repo.author('Uncommitted', '#') self._datetime = None self._datetime_string = '---' return From 65d177999eac005b0dcc0445f16c7fabceeb15c4 Mon Sep 17 00:00:00 2001 From: Urs Liska Date: Wed, 4 Mar 2020 10:16:22 +0100 Subject: [PATCH 24/27] Wrap authors list in a This is necessary to get a hold on the whole element through CSS, for example to make it invisible (in certain contexts). --- mkdocs_git_authors_plugin/repo.py | 44 ++++++++++++++++--------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/mkdocs_git_authors_plugin/repo.py b/mkdocs_git_authors_plugin/repo.py index 9b5ab55..78d5618 100644 --- a/mkdocs_git_authors_plugin/repo.py +++ b/mkdocs_git_authors_plugin/repo.py @@ -217,29 +217,31 @@ def authors_summary(self): show_contribution = self.config('show_contribution') show_line_count = show_contribution and self.config('show_line_count') result = """ -
          - """ - for author in self.get_authors(): - contribution = ( - ' (%s)' % author.contribution(None, str) - if show_contribution - else '' - ) - lines = ( - '%s lines' % author.lines() - if show_line_count - else '' + +
            + """ + for author in self.get_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 += """ -
          • {author_name}: - {lines}{contribution}
          • - """.format( - author_email=author.email(), - author_name=author.name(), - lines=lines, - contribution=contribution - ) - result += """ +
          """ return result From 4a208dcafd0657e524ab30f1c5babf0f8c28216c Mon Sep 17 00:00:00 2001 From: Urs Liska Date: Wed, 4 Mar 2020 10:22:33 +0100 Subject: [PATCH 25/27] Clarify event order in on_files It should be clear to any future contributor that when the on_page event stage is reached all pages have already been parsed and the repo-wide statistics are available. --- mkdocs_git_authors_plugin/plugin.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/mkdocs_git_authors_plugin/plugin.py b/mkdocs_git_authors_plugin/plugin.py index 5e55353..ac9be3b 100644 --- a/mkdocs_git_authors_plugin/plugin.py +++ b/mkdocs_git_authors_plugin/plugin.py @@ -51,9 +51,12 @@ def on_files(self, files, config, **kwargs): https://www.mkdocs.org/user-guide/plugins/#on_files This populates all the lines and total_lines properties - of the pages and the repository, so the total - contribution of an author to the repository can be - retrieved on *any* Markdown page. + 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 From ba91edd300df5e5775aa3e93d839929db52b734f Mon Sep 17 00:00:00 2001 From: Urs Liska Date: Wed, 4 Mar 2020 11:29:34 +0100 Subject: [PATCH 26/27] Format repo_authors list in util This is to separate concerns: Move the formatting of the list of authors to the repo not in the Git classes but separately. --- mkdocs_git_authors_plugin/plugin.py | 6 +++- mkdocs_git_authors_plugin/repo.py | 54 ---------------------------- mkdocs_git_authors_plugin/util.py | 55 +++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 55 deletions(-) diff --git a/mkdocs_git_authors_plugin/plugin.py b/mkdocs_git_authors_plugin/plugin.py index ac9be3b..e98afe0 100644 --- a/mkdocs_git_authors_plugin/plugin.py +++ b/mkdocs_git_authors_plugin/plugin.py @@ -2,6 +2,7 @@ from mkdocs.config import config_options from mkdocs.plugins import BasePlugin from .repo import Repo +from . import util class GitAuthorsPlugin(BasePlugin): config_scheme = ( @@ -99,7 +100,10 @@ def on_page_content(self, html, page, config, files, **kwargs): ) if list_pattern.search(html): html = list_pattern.sub( - self.repo().authors_summary(), + util.repo_authors_summary( + self.repo().get_authors(), + self.config + ), html ) return html diff --git a/mkdocs_git_authors_plugin/repo.py b/mkdocs_git_authors_plugin/repo.py index 78d5618..be0f307 100644 --- a/mkdocs_git_authors_plugin/repo.py +++ b/mkdocs_git_authors_plugin/repo.py @@ -192,60 +192,6 @@ def get_authors(self): reverse=self.config('sort_reverse') ) - def authors_summary(self): - """ - A summary list of the authors' contributions on book 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 - ) - - Make this sortable (probably with a global plugin option that - also affects the page's authors_summary). - - Args: - - Returns: - Unordered HTML list as a string. - """ - show_contribution = self.config('show_contribution') - show_line_count = show_contribution and self.config('show_line_count') - result = """ - -
            - """ - for author in self.get_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 - def config(self, key: str = ''): """ Return the plugin configuration dictionary or a single config value. diff --git a/mkdocs_git_authors_plugin/util.py b/mkdocs_git_authors_plugin/util.py index c887629..c87c282 100644 --- a/mkdocs_git_authors_plugin/util.py +++ b/mkdocs_git_authors_plugin/util.py @@ -27,3 +27,58 @@ def commit_datetime(dt: str): second=t[2], tzinfo=timezone(timedelta(hours=tz_hours,minutes=th_minutes)) ) + + +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 = """ + +
              + """ + 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 From 6f5822c641452cea3edb82c2bbb9ed63bd254d2e Mon Sep 17 00:00:00 2001 From: Urs Liska Date: Wed, 4 Mar 2020 18:36:58 +0100 Subject: [PATCH 27/27] Use --porcelain version of git blame Closes #20 While parsing the --porcelain output of git blame is more complicated than that of git blame -lts * it should be considered more robust * it provides substantially more information: - we can get all information about the commits that we need - this makes it obsolete to call `git show` on the commits The format of the commit timestamp is different in this command, therfore this had to modify the datetime processing functions, which were moved to util.py at the same time. --- mkdocs_git_authors_plugin/repo.py | 156 ++++++++++++++++++++---------- mkdocs_git_authors_plugin/util.py | 40 ++++---- 2 files changed, 129 insertions(+), 67 deletions(-) diff --git a/mkdocs_git_authors_plugin/repo.py b/mkdocs_git_authors_plugin/repo.py index be0f307..0b70963 100644 --- a/mkdocs_git_authors_plugin/repo.py +++ b/mkdocs_git_authors_plugin/repo.py @@ -218,7 +218,7 @@ def find_repo_root(self): cmd.run() return cmd.stdout()[0] - def get_commit(self, sha: str): + def get_commit(self, sha: str, **kwargs): """ Return the (cached) Commit object for given sha. @@ -232,7 +232,7 @@ def get_commit(self, sha: str): Commit object """ if not self._commits.get(sha): - self._commits[sha] = Commit(self, sha) + self._commits[sha] = Commit(self, sha, **kwargs) return self._commits.get(sha) def page(self, path): @@ -317,9 +317,19 @@ class Commit(AbstractRepoObject): 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): + 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. @@ -330,7 +340,14 @@ def __init__(self, repo: Repo, sha: str): """ super().__init__(repo) - self._populate(sha) + + 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): """ @@ -358,40 +375,6 @@ def datetime(self, _type=str): """ return self._datetime_string if _type == str else self._datetime - def _populate(self, sha: str): - """ - Retrieve information about the commit. - - Args: - sha: 40-byte SHA string of the commit - Returns: - - """ - if sha == '0000000000000000000000000000000000000000': - # This indicates an uncommitted line, so there's - # no actual Git commit to inspect. Instead we - # populate the Commit object wtih a fake Author. - self._author = repo.author('Uncommitted', '#') - self._datetime = None - self._datetime_string = '---' - return - - cmd = GitCommand('show', [ - '-t', - '--quiet', - "--format='%aN%n%aE%n%ai'", - sha - ]) - cmd.run() - result = cmd.stdout() - - # Author name and email are returned on single lines. - self._author = self.repo().author(result[0], result[1]) - - # Third line includes formatted date/time info - self._datetime_string = result[2] - self._datetime = util.commit_datetime(self._datetime_string) - class Page(AbstractRepoObject): """ @@ -483,24 +466,97 @@ def authors_summary(self): 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) """ - cmd = GitCommand('blame', ['-lts', str(self._path)]) - cmd.run() + re_sha = re.compile('^\w{40}') - # Retrieve SHA and content from the line, discarding - # file path and line number - line_pattern = re.compile('(.*?)\s.*\s*\d\)(\s*.*)') + cmd = GitCommand('blame', ['--porcelain', str(self._path)]) + cmd.run() + commit_data = {} for line in cmd.stdout(): - m = line_pattern.match(line) + key = line.split(' ')[0] + m = re_sha.match(key) if m: - sha = m.group(1) - content = m.group(2).strip() - - if content or self.repo().config('count_empty_lines'): - # assign the line to a commit and count it - commit = self.repo().get_commit(sha) + 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) diff --git a/mkdocs_git_authors_plugin/util.py b/mkdocs_git_authors_plugin/util.py index c87c282..55618e4 100644 --- a/mkdocs_git_authors_plugin/util.py +++ b/mkdocs_git_authors_plugin/util.py @@ -1,34 +1,40 @@ from datetime import datetime, timezone, timedelta -def commit_datetime(dt: str): +def commit_datetime(author_time: str, author_tz: str): """ - Convert a commit's datetime string to a - datetime.datetime object with timezone info. + Convert a commit's timestamp to an aware datetime object. Args: - A string returned from the %ai formatting argument - in a git show command. + author_time: Unix timestamp string + author_tz: string in the format +hhmm Returns: datetime.datetime object with tzinfo """ - d, t, tz = dt.split(' ') - d = [int(v) for v in d.split('-')] - t = [int(v) for v in t.split(':')] + # timezone info looks like +hhmm or -hhmm - tz_hours = int(tz[:3]) - th_minutes = int(tz[0] + tz[3:]) + tz_hours = int(author_tz[:3]) + th_minutes = int(author_tz[0] + author_tz[3:]) - # Construct 'aware' datetime.datetime object - return datetime( - d[0], d[1], d[2], - hour=t[0], - minute=t[1], - second=t[2], - tzinfo=timezone(timedelta(hours=tz_hours,minutes=th_minutes)) + 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.