diff --git a/doc/pdoc_template/config.mako b/doc/pdoc_template/config.mako new file mode 100644 index 00000000..577094b8 --- /dev/null +++ b/doc/pdoc_template/config.mako @@ -0,0 +1,5 @@ +<%! + +git_link_template = 'https://github.com/pdoc3/pdoc/blob/{commit}/{path}#L{start_line}-L{end_line}' + +%> diff --git a/pdoc/documentation.md b/pdoc/documentation.md index abbccb2a..62f2a9ac 100644 --- a/pdoc/documentation.md +++ b/pdoc/documentation.md @@ -192,7 +192,7 @@ or, alternatively, use [raw string literals]. [Google-style]: http://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings [reST directives]: #supported-rest-directives [template config]: #custom-templates -[recognized delimiters]: http://docs.mathjax.org/en/latest/tex.html#tex-and-latex-math-delimiters +[recognized delimiters]: https://docs.mathjax.org/en/latest/input/tex/delimiters.html [raw string literals]: https://www.journaldev.com/23598/python-raw-string diff --git a/pdoc/html_helpers.py b/pdoc/html_helpers.py index b5ad9812..2580d74d 100644 --- a/pdoc/html_helpers.py +++ b/pdoc/html_helpers.py @@ -2,8 +2,10 @@ Helper functions for HTML output. """ import inspect -import os.path +import os import re +import subprocess +import traceback from functools import partial, lru_cache from typing import Callable, Match from warnings import warn @@ -430,3 +432,96 @@ def extract_toc(text: str): if toc.endswith('

'): # CUT was put into its own paragraph toc = toc[:-3].rstrip() return toc + + +def format_git_link(template: str, dobj: pdoc.Doc): + """ + Interpolate `template` as a formatted string literal using values extracted + from `dobj` and the working environment. + """ + if not template: + return None + try: + if 'commit' in _str_template_fields(template): + commit = _git_head_commit() + abs_path = inspect.getfile(inspect.unwrap(dobj.obj)) + path = _project_relative_path(abs_path) + lines, start_line = inspect.getsourcelines(dobj.obj) + end_line = start_line + len(lines) - 1 + url = template.format(**locals()) + return url + except Exception: + warn('format_git_link for {} failed:\n{}'.format(dobj.obj, traceback.format_exc())) + return None + + +@lru_cache() +def _git_head_commit(): + """ + If the working directory is part of a git repository, return the + head git commit hash. Otherwise, raise a CalledProcessError. + """ + process_args = ['git', 'rev-parse', 'HEAD'] + try: + commit = subprocess.check_output(process_args, universal_newlines=True).strip() + return commit + except OSError as error: + warn("git executable not found on system:\n{}".format(error)) + except subprocess.CalledProcessError as error: + warn( + "Ensure pdoc is run within a git repository.\n" + "`{}` failed with output:\n{}" + .format(' '.join(process_args), error.output) + ) + return None + + +@lru_cache() +def _git_project_root(): + """ + Return the path to project root directory or None if indeterminate. + """ + path = None + for cmd in (['git', 'rev-parse', '--show-superproject-working-tree'], + ['git', 'rev-parse', '--show-toplevel']): + try: + path = subprocess.check_output(cmd, universal_newlines=True).rstrip('\r\n') + if path: + break + except (subprocess.CalledProcessError, OSError): + pass + return path + + +@lru_cache() +def _project_relative_path(absolute_path): + """ + Convert an absolute path of a python source file to a project-relative path. + Assumes the project's path is either the current working directory or + Python library installation. + """ + from distutils.sysconfig import get_python_lib + for prefix_path in (_git_project_root() or os.getcwd(), + get_python_lib()): + common_path = os.path.commonpath([prefix_path, absolute_path]) + if common_path == prefix_path: + # absolute_path is a descendant of prefix_path + return os.path.relpath(absolute_path, prefix_path) + raise RuntimeError( + "absolute path {!r} is not a descendant of the current working directory " + "or of the system's python library." + .format(absolute_path) + ) + + +@lru_cache() +def _str_template_fields(template): + """ + Return a list of `str.format` field names in a template string. + """ + from string import Formatter + return [ + field_name + for _, field_name, _, _ in Formatter().parse(template) + if field_name is not None + ] diff --git a/pdoc/templates/config.mako b/pdoc/templates/config.mako index 48c0b61a..6e48c097 100644 --- a/pdoc/templates/config.mako +++ b/pdoc/templates/config.mako @@ -12,6 +12,15 @@ # Disabling this can improve rendering speed of large modules. show_source_code = True + # If set, format links to objects in online source code repository + # according to this template. Supported keywords for interpolation + # are: commit, path, start_line, end_line. + #git_link_template = 'https://github.com/USER/PROJECT/blob/{commit}/{path}#L{start_line}-L{end_line}' + #git_link_template = 'https://gitlab.com/USER/PROJECT/blob/{commit}/{path}#L{start_line}-L{end_line}' + #git_link_template = 'https://bitbucket.org/USER/PROJECT/src/{commit}/{path}#lines-{start_line}:{end_line}' + #git_link_template = 'https://CGIT_HOSTNAME/PROJECT/tree/{path}?id={commit}#n{start-line}' + git_link_template = None + # A prefix to use for every HTML hyperlink in the generated documentation. # No prefix results in all links being relative. link_prefix = '' diff --git a/pdoc/templates/css.mako b/pdoc/templates/css.mako index 29c9a66d..f82ffc9e 100644 --- a/pdoc/templates/css.mako +++ b/pdoc/templates/css.mako @@ -196,14 +196,22 @@ background: inherit; /* Don't grey-back parameters */ } - .source summary { + .source summary, + .git-link-div { color: #666; text-align: right; font-weight: 400; font-size: .8em; text-transform: uppercase; - cursor: pointer; } + .source summary > * { + white-space: nowrap; + cursor: pointer; + } + .git-link { + color: inherit; + margin-left: 1em; + } .source pre { max-height: 500px; overflow: auto; diff --git a/pdoc/templates/html.mako b/pdoc/templates/html.mako index ef0ce266..a108153a 100644 --- a/pdoc/templates/html.mako +++ b/pdoc/templates/html.mako @@ -2,7 +2,7 @@ import os import pdoc - from pdoc.html_helpers import extract_toc, glimpse, to_html as _to_html + from pdoc.html_helpers import extract_toc, glimpse, to_html as _to_html, format_git_link def link(d, name=None, fmt='{}'): @@ -21,12 +21,22 @@ <%def name="ident(name)">${name} <%def name="show_source(d)"> - % if show_source_code and d.source and d.obj is not getattr(d.inherits, 'obj', None): -

- Source code -
${d.source | h}
-
+ % if (show_source_code or git_link_template) and d.source and d.obj is not getattr(d.inherits, 'obj', None): + <% git_link = format_git_link(git_link_template, d) %> + % if show_source_code: +
+ + Expand source code + % if git_link: + Browse git + %endif + +
${d.source | h}
+
+ % elif git_link: + %endif + %endif <%def name="show_desc(d, short=False)"> diff --git a/pdoc/test/__init__.py b/pdoc/test/__init__.py index 72af83f2..d52e75bc 100644 --- a/pdoc/test/__init__.py +++ b/pdoc/test/__init__.py @@ -3,6 +3,7 @@ """ import inspect import os +import shutil import signal import sys import threading @@ -26,7 +27,7 @@ from pdoc.cli import main, parser from pdoc.html_helpers import ( minify_css, minify_html, glimpse, to_html, - ReferenceWarning, extract_toc, + ReferenceWarning, extract_toc, format_git_link, ) TESTS_BASEDIR = os.path.abspath(os.path.dirname(__file__) or '.') @@ -790,6 +791,15 @@ def test_extract_toc(self): toc = extract_toc(text) self.assertEqual(toc, expected) + @unittest.skipIf(shutil.which("git") is None, reason="test assumes git installed on system") + def test_format_git_link(self): + url = format_git_link( + template='https://github.com/pdoc3/pdoc/blob/{commit}/{path}#L{start_line}-L{end_line}', + dobj=pdoc.Module(EXAMPLE_MODULE).find_ident('module.foo'), + ) + self.assertRegex(url, r"https://github.com/pdoc3/pdoc/blob/[0-9a-f]{40}" + r"/pdoc/test/example_pkg/module.py#L\d+-L\d+") + class Docformats(unittest.TestCase): @classmethod