|
2 | 2 | Helper functions for HTML output. |
3 | 3 | """ |
4 | 4 | import inspect |
5 | | -import os.path |
| 5 | +import os |
6 | 6 | import re |
| 7 | +import subprocess |
| 8 | +import traceback |
7 | 9 | from functools import partial, lru_cache |
8 | 10 | from typing import Callable, Match |
9 | 11 | from warnings import warn |
@@ -430,3 +432,96 @@ def extract_toc(text: str): |
430 | 432 | if toc.endswith('<p>'): # CUT was put into its own paragraph |
431 | 433 | toc = toc[:-3].rstrip() |
432 | 434 | return toc |
| 435 | + |
| 436 | + |
| 437 | +def format_git_link(template: str, dobj: pdoc.Doc): |
| 438 | + """ |
| 439 | + Interpolate `template` as a formatted string literal using values extracted |
| 440 | + from `dobj` and the working environment. |
| 441 | + """ |
| 442 | + if not template: |
| 443 | + return None |
| 444 | + try: |
| 445 | + if 'commit' in _str_template_fields(template): |
| 446 | + commit = _git_head_commit() |
| 447 | + abs_path = inspect.getfile(inspect.unwrap(dobj.obj)) |
| 448 | + path = _project_relative_path(abs_path) |
| 449 | + lines, start_line = inspect.getsourcelines(dobj.obj) |
| 450 | + end_line = start_line + len(lines) - 1 |
| 451 | + url = template.format(**locals()) |
| 452 | + return url |
| 453 | + except Exception: |
| 454 | + warn('format_git_link for {} failed:\n{}'.format(dobj.obj, traceback.format_exc())) |
| 455 | + return None |
| 456 | + |
| 457 | + |
| 458 | +@lru_cache() |
| 459 | +def _git_head_commit(): |
| 460 | + """ |
| 461 | + If the working directory is part of a git repository, return the |
| 462 | + head git commit hash. Otherwise, raise a CalledProcessError. |
| 463 | + """ |
| 464 | + process_args = ['git', 'rev-parse', 'HEAD'] |
| 465 | + try: |
| 466 | + commit = subprocess.check_output(process_args, universal_newlines=True).strip() |
| 467 | + return commit |
| 468 | + except OSError as error: |
| 469 | + warn("git executable not found on system:\n{}".format(error)) |
| 470 | + except subprocess.CalledProcessError as error: |
| 471 | + warn( |
| 472 | + "Ensure pdoc is run within a git repository.\n" |
| 473 | + "`{}` failed with output:\n{}" |
| 474 | + .format(' '.join(process_args), error.output) |
| 475 | + ) |
| 476 | + return None |
| 477 | + |
| 478 | + |
| 479 | +@lru_cache() |
| 480 | +def _git_project_root(): |
| 481 | + """ |
| 482 | + Return the path to project root directory or None if indeterminate. |
| 483 | + """ |
| 484 | + path = None |
| 485 | + for cmd in (['git', 'rev-parse', '--show-superproject-working-tree'], |
| 486 | + ['git', 'rev-parse', '--show-toplevel']): |
| 487 | + try: |
| 488 | + path = subprocess.check_output(cmd, universal_newlines=True).rstrip('\r\n') |
| 489 | + if path: |
| 490 | + break |
| 491 | + except (subprocess.CalledProcessError, OSError): |
| 492 | + pass |
| 493 | + return path |
| 494 | + |
| 495 | + |
| 496 | +@lru_cache() |
| 497 | +def _project_relative_path(absolute_path): |
| 498 | + """ |
| 499 | + Convert an absolute path of a python source file to a project-relative path. |
| 500 | + Assumes the project's path is either the current working directory or |
| 501 | + Python library installation. |
| 502 | + """ |
| 503 | + from distutils.sysconfig import get_python_lib |
| 504 | + for prefix_path in (_git_project_root() or os.getcwd(), |
| 505 | + get_python_lib()): |
| 506 | + common_path = os.path.commonpath([prefix_path, absolute_path]) |
| 507 | + if common_path == prefix_path: |
| 508 | + # absolute_path is a descendant of prefix_path |
| 509 | + return os.path.relpath(absolute_path, prefix_path) |
| 510 | + raise RuntimeError( |
| 511 | + "absolute path {!r} is not a descendant of the current working directory " |
| 512 | + "or of the system's python library." |
| 513 | + .format(absolute_path) |
| 514 | + ) |
| 515 | + |
| 516 | + |
| 517 | +@lru_cache() |
| 518 | +def _str_template_fields(template): |
| 519 | + """ |
| 520 | + Return a list of `str.format` field names in a template string. |
| 521 | + """ |
| 522 | + from string import Formatter |
| 523 | + return [ |
| 524 | + field_name |
| 525 | + for _, field_name, _, _ in Formatter().parse(template) |
| 526 | + if field_name is not None |
| 527 | + ] |
0 commit comments