Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/pdoc/html_helpers.py: 30%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

322 statements  

1""" 

2Helper functions for HTML output. 

3""" 

4import inspect 

5import os 

6import re 

7import subprocess 

8import textwrap 

9import traceback 

10from contextlib import contextmanager 

11from functools import partial, lru_cache 

12from typing import Callable, Match, Optional 

13from warnings import warn 

14import xml.etree.ElementTree as etree 

15 

16import markdown 

17from markdown.inlinepatterns import InlineProcessor 

18from markdown.util import AtomicString 

19 

20import pdoc 

21 

22 

23@lru_cache() 

24def minify_css(css: str, 

25 _whitespace=partial(re.compile(r'\s*([,{:;}])\s*').sub, r'\1'), 

26 _comments=partial(re.compile(r'/\*.*?\*/', flags=re.DOTALL).sub, ''), 

27 _trailing_semicolon=partial(re.compile(r';\s*}').sub, '}')): 

28 """ 

29 Minify CSS by removing extraneous whitespace, comments, and trailing semicolons. 

30 """ 

31 return _trailing_semicolon(_whitespace(_comments(css))).strip() 

32 

33 

34def minify_html(html: str, 

35 _minify=partial( 

36 re.compile(r'(.*?)(<pre\b.*?</pre\b\s*>)|(.*)', re.IGNORECASE | re.DOTALL).sub, 

37 lambda m, _norm_space=partial(re.compile(r'\s\s+').sub, '\n'): ( 

38 _norm_space(m.group(1) or '') + 

39 (m.group(2) or '') + 

40 _norm_space(m.group(3) or '')))): 

41 """ 

42 Minify HTML by replacing all consecutive whitespace with a single space 

43 (or newline) character, except inside `<pre>` tags. 

44 """ 

45 return _minify(html) 

46 

47 

48def glimpse(text: str, max_length=153, *, paragraph=True, 

49 _split_paragraph=partial(re.compile(r'\s*\n\s*\n\s*').split, maxsplit=1), 

50 _trim_last_word=partial(re.compile(r'\S+$').sub, ''), 

51 _remove_titles=partial(re.compile(r'^(#+|-{4,}|={4,})', re.MULTILINE).sub, ' ')): 

52 """ 

53 Returns a short excerpt (e.g. first paragraph) of text. 

54 If `paragraph` is True, the first paragraph will be returned, 

55 but never longer than `max_length` characters. 

56 """ 

57 text = text.lstrip() 

58 if paragraph: 

59 text, *rest = _split_paragraph(text) 

60 if rest: 

61 text = text.rstrip('.') 

62 text += ' …' 

63 text = _remove_titles(text).strip() 

64 

65 if len(text) > max_length: 

66 text = _trim_last_word(text[:max_length - 2]) 

67 if not text.endswith('.') or not paragraph: 

68 text = text.rstrip('. ') + ' …' 

69 return text 

70 

71 

72_md = markdown.Markdown( 

73 output_format='html', 

74 extensions=[ 

75 "markdown.extensions.abbr", 

76 "markdown.extensions.admonition", 

77 "markdown.extensions.attr_list", 

78 "markdown.extensions.def_list", 

79 "markdown.extensions.fenced_code", 

80 "markdown.extensions.footnotes", 

81 "markdown.extensions.tables", 

82 "markdown.extensions.smarty", 

83 "markdown.extensions.toc", 

84 ], 

85 extension_configs={ 

86 "markdown.extensions.smarty": dict( 

87 smart_dashes=True, 

88 smart_ellipses=True, 

89 smart_quotes=False, 

90 smart_angled_quotes=False, 

91 ), 

92 }, 

93) 

94 

95 

96@contextmanager 

97def _fenced_code_blocks_hidden(text): 

98 def hide(text): 

99 def replace(match): 

100 orig = match.group() 

101 new = f'@{hash(orig)}@' 

102 hidden[new] = orig 

103 return new 

104 

105 text = re.compile(r'^(?P<fence>```+|~~~+).*\n' 

106 r'(?:.*\n)*?' 

107 r'^(?P=fence)[ ]*(?!.)', re.MULTILINE).sub(replace, text) 

108 return text 

109 

110 def unhide(text): 

111 for k, v in hidden.items(): 

112 text = text.replace(k, v) 

113 return text 

114 

115 hidden = {} 

116 # Via a manager object (a list) so modifications can pass back and forth as result[0] 

117 result = [hide(text)] 

118 yield result 

119 result[0] = unhide(result[0]) 

120 

121 

122class _ToMarkdown: 

123 """ 

124 This class serves as a namespace for methods converting common 

125 documentation formats into markdown our Python-Markdown with 

126 addons can ingest. 

127 

128 If debugging regexs (I can't imagine why that would be necessary 

129 — they are all perfect!) an insta-preview tool such as RegEx101.com 

130 will come in handy. 

131 """ 

132 @staticmethod 

133 def _deflist(name, type, desc): 

134 """ 

135 Returns `name`, `type`, and `desc` formatted as a 

136 Python-Markdown definition list entry. See also: 

137 https://python-markdown.github.io/extensions/definition_lists/ 

138 """ 

139 # Wrap any identifiers and string literals in parameter type spec 

140 # in backticks while skipping common "stopwords" such as 'or', 'of', 

141 # 'optional' ... See §4 Parameters: 

142 # https://numpydoc.readthedocs.io/en/latest/format.html#sections 

143 type_parts = re.split(r'( *(?: of | or |, *default(?:=|\b)|, *optional\b) *)', type or '') 

144 type_parts[::2] = [f'`{s}`' if s else s 

145 for s in type_parts[::2]] 

146 type = ''.join(type_parts) 

147 

148 desc = desc or '&nbsp;' 

149 assert _ToMarkdown._is_indented_4_spaces(desc) 

150 assert name or type 

151 ret = "" 

152 if name: 

153 # NOTE: Triple-backtick argument names so we skip linkifying them 

154 ret += f"**```{name.replace(', ', '```**, **```')}```**" 

155 if type: 

156 ret += f' :&ensp;{type}' if ret else type 

157 ret += f'\n: {desc}\n\n' 

158 return ret 

159 

160 @staticmethod 

161 def _numpy_params(match): 

162 """ Converts NumpyDoc parameter (etc.) sections into Markdown. """ 

163 name, type, desc = match.group("name", "type", "desc") 

164 type = type or match.groupdict().get('just_type', None) 

165 desc = desc.strip() 

166 return _ToMarkdown._deflist(name, type, desc) 

167 

168 @staticmethod 

169 def _numpy_seealso(match): 

170 """ 

171 Converts NumpyDoc "See Also" section either into referenced code, 

172 optionally within a definition list. 

173 """ 

174 spec_with_desc, simple_list = match.groups() 

175 if spec_with_desc: 

176 spec_desc_strings = [] 

177 for line in filter(None, spec_with_desc.split('\n')): 

178 spec, desc = map(str.strip, line.split(':', 1)) 

179 spec_desc_strings.append(f'`{spec}`\n: {desc}') 

180 return '\n\n'.join(spec_desc_strings) 

181 return ', '.join(f'`{i}`' for i in simple_list.split(', ')) 

182 

183 @staticmethod 

184 def _numpy_sections(match): 

185 """ 

186 Convert sections with parameter, return, and see also lists to Markdown 

187 lists. 

188 """ 

189 section, body = match.groups() 

190 section = section.title() 

191 if section == 'See Also': 

192 body = re.sub(r'\n\s{4}\s*', ' ', body) # Handle line continuation 

193 body = re.sub(r'^((?:\n?[\w.]* ?: .*)+)|(.*\w.*)', 

194 _ToMarkdown._numpy_seealso, body) 

195 elif section in ('Returns', 'Yields', 'Raises', 'Warns'): 

196 body = re.sub(r'^(?:(?P<name>\*{0,2}\w+(?:, \*{0,2}\w+)*)' 

197 r'(?: ?: (?P<type>.*))|' 

198 r'(?P<just_type>\w[^\n`*]*))(?<!\.)$' 

199 r'(?P<desc>(?:\n(?: {4}.*|$))*)', 

200 _ToMarkdown._numpy_params, body, flags=re.MULTILINE) 

201 elif section in ('Parameters', 'Receives', 'Other Parameters', 

202 'Arguments', 'Args', 'Attributes'): 

203 name = r'(?:\w|\{\w+(?:,\w+)+\})+' # Support curly brace expansion 

204 body = re.sub(r'^(?P<name>\*{0,2}' + name + r'(?:, \*{0,2}' + name + r')*)' 

205 r'(?: ?: (?P<type>.*))?(?<!\.)$' 

206 r'(?P<desc>(?:\n(?: {4}.*|$))*)', 

207 _ToMarkdown._numpy_params, body, flags=re.MULTILINE) 

208 return f'{section}\n-----\n{body}' 

209 

210 @staticmethod 

211 def numpy(text): 

212 """ 

213 Convert `text` in numpydoc docstring format to Markdown 

214 to be further converted later. 

215 """ 

216 return re.sub(r'^(\w[\w ]+)\n-{3,}\n' 

217 r'((?:(?!.+\n-+).*$\n?)*)', 

218 _ToMarkdown._numpy_sections, text, flags=re.MULTILINE) 

219 

220 @staticmethod 

221 def _is_indented_4_spaces(txt, _3_spaces_or_less=re.compile(r'\n\s{0,3}\S').search): 

222 return '\n' not in txt or not _3_spaces_or_less(txt) 

223 

224 @staticmethod 

225 def _fix_indent(name, type, desc): 

226 """Maybe fix indent from 2 to 4 spaces.""" 

227 if not _ToMarkdown._is_indented_4_spaces(desc): 

228 desc = desc.replace('\n', '\n ') 

229 return name, type, desc 

230 

231 @staticmethod 

232 def indent(indent, text, *, clean_first=False): 

233 if clean_first: 

234 text = inspect.cleandoc(text) 

235 return re.sub(r'\n', f'\n{indent}', indent + text.rstrip()) 

236 

237 @staticmethod 

238 def google(text): 

239 """ 

240 Convert `text` in Google-style docstring format to Markdown 

241 to be further converted later. 

242 """ 

243 def googledoc_sections(match): 

244 section, body = match.groups('') 

245 if not body: 

246 return match.group() 

247 body = textwrap.dedent(body) 

248 section = section.title() 

249 if section in ('Args', 'Attributes'): 

250 body = re.compile( 

251 r'^([\w*]+)(?: \(([\w.,=|\[\] -]+)\))?: ' 

252 r'((?:.*)(?:\n(?: {2,}.*|$))*)', re.MULTILINE).sub( 

253 lambda m: _ToMarkdown._deflist(*_ToMarkdown._fix_indent(*m.groups())), 

254 inspect.cleandoc(f'\n{body}') 

255 ) 

256 elif section in ('Returns', 'Yields', 'Raises', 'Warns'): 

257 body = re.compile( 

258 r'^()([\w.,|\[\] ]+): ' 

259 r'((?:.*)(?:\n(?: {2,}.*|$))*)', re.MULTILINE).sub( 

260 lambda m: _ToMarkdown._deflist(*_ToMarkdown._fix_indent(*m.groups())), 

261 inspect.cleandoc(f'\n{body}') 

262 ) 

263 # Convert into markdown sections. End underlines with '=' 

264 # to avoid matching and re-processing as Numpy sections. 

265 return f'\n{section}\n-----=\n{body}' 

266 

267 text = re.compile(r'^([A-Z]\w+):$\n' 

268 r'((?:\n?(?: {2,}.*|$))+)', re.MULTILINE).sub(googledoc_sections, text) 

269 return text 

270 

271 @staticmethod 

272 def _admonition(match, module=None, limit_types=None): 

273 indent, type, value, text = match.groups() 

274 

275 if limit_types and type not in limit_types: 

276 return match.group(0) 

277 

278 if text is None: 

279 text = "" 

280 

281 if type == 'include' and module: 

282 try: 

283 return _ToMarkdown._include_file(indent, value, 

284 _ToMarkdown._directive_opts(text), module) 

285 except Exception as e: 

286 raise RuntimeError(f'`.. include:: {value}` error in module {module.name!r}: {e}') 

287 if type in ('image', 'figure'): 

288 alt_text = text.translate(str.maketrans({ 

289 '\n': ' ', 

290 '[': '\\[', 

291 ']': '\\]'})).strip() 

292 return f'{indent}![{alt_text}]({value}){{: loading=lazy}}\n' 

293 if type == 'math': 

294 return _ToMarkdown.indent(indent, 

295 f'\\[ {text.strip()} \\]', 

296 clean_first=True) 

297 

298 if type == 'versionchanged': 

299 title = f'Changed in version:&ensp;{value}' 

300 elif type == 'versionadded': 

301 title = f'Added in version:&ensp;{value}' 

302 elif type == 'deprecated' and value: 

303 title = f'Deprecated since version:&ensp;{value}' 

304 elif type == 'admonition': 

305 title = value 

306 elif type.lower() == 'todo': 

307 title = 'TODO' 

308 text = f'{value} {text}' 

309 else: 

310 title = type.capitalize() 

311 if value: 

312 title += f':&ensp;{value}' 

313 

314 text = _ToMarkdown.indent(indent + ' ', text, clean_first=True) 

315 return f'{indent}!!! {type} "{title}"\n{text}\n' 

316 

317 @staticmethod 

318 def admonitions(text, module, limit_types=None): 

319 """ 

320 Process reStructuredText's block directives such as 

321 `.. warning::`, `.. deprecated::`, `.. versionadded::`, etc. 

322 and turn them into Python-M>arkdown admonitions. 

323 

324 `limit_types` is optionally a set of directives to limit processing to. 

325 

326 See: https://python-markdown.github.io/extensions/admonition/ 

327 """ 

328 substitute = partial(re.compile(r'^(?P<indent> *)\.\. ?(\w+)::(?: *(.*))?' 

329 r'((?:\n(?:(?P=indent) +.*| *$))*[^\r\n])*', 

330 re.MULTILINE).sub, 

331 partial(_ToMarkdown._admonition, module=module, 

332 limit_types=limit_types)) 

333 # Apply twice for nested (e.g. image inside warning) 

334 return substitute(substitute(text)) 

335 

336 @staticmethod 

337 def _include_file(indent: str, path: str, options: dict, module: pdoc.Module) -> str: 

338 start_line = int(options.get('start-line', 0)) 

339 end_line = int(options.get('end-line', 0)) or None 

340 start_after = options.get('start-after') 

341 end_before = options.get('end-before') 

342 

343 with open(os.path.normpath(os.path.join(os.path.dirname(module.obj.__file__), path)), 

344 encoding='utf-8') as f: 

345 text = ''.join(list(f)[start_line:end_line]) 

346 

347 if start_after: 

348 text = text[text.index(start_after) + len(start_after):] 

349 if end_before: 

350 text = text[:text.index(end_before)] 

351 

352 return _ToMarkdown.indent(indent, text) 

353 

354 @staticmethod 

355 def _directive_opts(text: str) -> dict: 

356 return dict(re.findall(r'^ *:([^:]+): *(.*)', text, re.MULTILINE)) 

357 

358 DOCTESTS_RE = re.compile(r'^(?:>>> .*)(?:\n.+)*', re.MULTILINE) 

359 

360 @staticmethod 

361 def doctests(text): 

362 """ 

363 Fence non-fenced (`~~~`) top-level (0-indented) 

364 doctest blocks so they render as Python code. 

365 """ 

366 text = _ToMarkdown.DOCTESTS_RE.sub( 

367 lambda match: f'```python-repl\n{match.group()}\n```\n', text) 

368 return text 

369 

370 @staticmethod 

371 def raw_urls(text): 

372 """Wrap URLs in Python-Markdown-compatible <angle brackets>.""" 

373 pattern = re.compile(r""" 

374 (?P<code_span> # matches whole code span 

375 (?<!`)(?P<fence>`+)(?!`) # a string of backticks 

376 .*? 

377 (?<!`)(?P=fence)(?!`)) 

378 | 

379 (?P<markdown_link>\[.*?\]\(.*\)) # matches whole inline link 

380 | 

381 (?<![<\"\']) # does not start with <, ", ' 

382 (?P<url>(?:http|ftp)s?:// # url with protocol 

383 [^>\s()]+ # url part before any (, ) 

384 (?:\([^>\s)]*\))* # optionally url part within parentheses 

385 [^>\s)]* # url part after any ) 

386 )""", re.VERBOSE) 

387 

388 text = pattern.sub( 

389 lambda m: (f'<{m.group("url")}>') if m.group('url') else m.group(), text) 

390 return text 

391 

392 

393class _MathPattern(InlineProcessor): 

394 NAME = 'pdoc-math' 

395 PATTERN = r'(?<!\S|\\)(?:\\\((.+?)\\\)|\\\[(.+?)\\\]|\$\$(.+?)\$\$)' 

396 PRIORITY = 181 # Larger than that of 'escape' pattern 

397 

398 def handleMatch(self, m, data): 

399 for value, is_block in zip(m.groups(), (False, True, True)): 

400 if value: 

401 break 

402 script = etree.Element('script', type=f"math/tex{'; mode=display' if is_block else ''}") 

403 preview = etree.Element('span', {'class': 'MathJax_Preview'}) 

404 preview.text = script.text = AtomicString(value) 

405 wrapper = etree.Element('span') 

406 wrapper.extend([preview, script]) 

407 return wrapper, m.start(0), m.end(0) 

408 

409 

410def to_html(text: str, *, 

411 docformat: Optional[str] = None, 

412 module: Optional[pdoc.Module] = None, 

413 link: Optional[Callable[..., str]] = None, 

414 latex_math: bool = False): 

415 """ 

416 Returns HTML of `text` interpreted as `docformat`. `__docformat__` is respected 

417 if present, otherwise Numpydoc and Google-style docstrings are assumed, 

418 as well as pure Markdown. 

419 

420 `module` should be the documented module (so the references can be 

421 resolved) and `link` is the hyperlinking function like the one in the 

422 example template. 

423 """ 

424 # Optionally register our math syntax processor 

425 if not latex_math and _MathPattern.NAME in _md.inlinePatterns: 

426 _md.inlinePatterns.deregister(_MathPattern.NAME) 

427 elif latex_math and _MathPattern.NAME not in _md.inlinePatterns: 

428 _md.inlinePatterns.register(_MathPattern(_MathPattern.PATTERN), 

429 _MathPattern.NAME, 

430 _MathPattern.PRIORITY) 

431 

432 md = to_markdown(text, docformat=docformat, module=module, link=link) 

433 return _md.reset().convert(md) 

434 

435 

436def to_markdown(text: str, *, 

437 docformat: Optional[str] = None, 

438 module: Optional[pdoc.Module] = None, 

439 link: Optional[Callable[..., str]] = None): 

440 """ 

441 Returns `text`, assumed to be a docstring in `docformat`, converted to markdown. 

442 `__docformat__` is respected 

443 if present, otherwise Numpydoc and Google-style docstrings are assumed, 

444 as well as pure Markdown. 

445 

446 `module` should be the documented module (so the references can be 

447 resolved) and `link` is the hyperlinking function like the one in the 

448 example template. 

449 """ 

450 if not docformat: 

451 docformat = str(getattr(getattr(module, 'obj', None), '__docformat__', 'numpy,google ')) 

452 docformat, *_ = docformat.lower().split() 

453 if not (set(docformat.split(',')) & {'', 'numpy', 'google'}): 

454 warn(f'__docformat__ value {docformat!r} in module {module!r} not supported. ' 

455 'Supported values are: numpy, google.') 

456 docformat = 'numpy,google' 

457 

458 with _fenced_code_blocks_hidden(text) as result: 

459 text = result[0] 

460 

461 text = _ToMarkdown.admonitions(text, module) 

462 

463 if 'google' in docformat: 

464 text = _ToMarkdown.google(text) 

465 

466 text = _ToMarkdown.doctests(text) 

467 text = _ToMarkdown.raw_urls(text) 

468 

469 # If doing both, do numpy after google, otherwise google-style's 

470 # headings are incorrectly interpreted as numpy params 

471 if 'numpy' in docformat: 

472 text = _ToMarkdown.numpy(text) 

473 

474 if module and link: 

475 # Hyperlink markdown code spans not within markdown hyperlinks. 

476 # E.g. `code` yes, but not [`code`](...). RE adapted from: 

477 # https://github.com/Python-Markdown/markdown/blob/ada40c66/markdown/inlinepatterns.py#L106 

478 # Also avoid linking triple-backticked arg names in deflists. 

479 linkify = partial(_linkify, link=link, module=module, wrap_code=True) 

480 text = re.sub(r'(?P<inside_link>\[[^\]]*?)?' 

481 r'(?:(?<!\\)(?:\\{2})+(?=`)|(?<!\\)(?P<fence>`+)' 

482 r'(?P<code>.+?)(?<!`)' 

483 r'(?P=fence)(?!`))', 

484 lambda m: (m.group() 

485 if m.group('inside_link') or len(m.group('fence')) > 2 

486 else linkify(m)), text) 

487 result[0] = text 

488 text = result[0] 

489 

490 return text 

491 

492 

493class ReferenceWarning(UserWarning): 

494 """ 

495 This warning is raised in `to_html` when a object reference in markdown 

496 doesn't match any documented objects. 

497 

498 Look for this warning to catch typos / references to obsolete symbols. 

499 """ 

500 

501 

502def _linkify(match: Match, *, link: Callable[..., str], module: pdoc.Module, wrap_code=False): 

503 try: 

504 code_span = match.group('code') 

505 except IndexError: 

506 code_span = match.group() 

507 

508 is_type_annotation = re.match(r'^[`\w\s.,\[\]()]+$', code_span) 

509 if not is_type_annotation: 

510 return match.group() 

511 

512 def handle_refname(match): 

513 nonlocal link, module 

514 refname = match.group() 

515 dobj = module.find_ident(refname) 

516 if isinstance(dobj, pdoc.External): 

517 # If this is a single-word reference, 

518 # most likely an argument name. Skip linking External. 

519 if '.' not in refname: 

520 return refname 

521 # If refname in documentation has a typo or is obsolete, warn. 

522 # XXX: Assume at least the first part of refname, i.e. the package, is correct. 

523 module_part = module.find_ident(refname.split('.')[0]) 

524 if not isinstance(module_part, pdoc.External): 

525 warn(f'Code reference `{refname}` in module "{module.refname}" does not match any ' 

526 'documented object.', 

527 ReferenceWarning, stacklevel=3) 

528 return link(dobj) 

529 

530 if wrap_code: 

531 code_span = code_span.replace('[', '\\[') 

532 linked = re.sub(r'[a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)*(?:\(\))?', handle_refname, code_span) 

533 if wrap_code: 

534 # Wrapping in HTML <code> as opposed to backticks evaluates markdown */_ markers, 

535 # so let's escape them in text (but not in HTML tag attributes). 

536 # Backticks also cannot be used because html returned from `link()` 

537 # would then become escaped. 

538 # This finds overlapping matches, https://stackoverflow.com/a/5616910/1090455 

539 cleaned = re.sub(r'(_(?=[^>]*?(?:<|$)))', r'\\\1', linked) 

540 return f'<code>{cleaned}</code>' 

541 return linked 

542 

543 

544def extract_toc(text: str): 

545 """ 

546 Returns HTML Table of Contents containing markdown titles in `text`. 

547 """ 

548 with _fenced_code_blocks_hidden(text) as result: 

549 result[0] = _ToMarkdown.DOCTESTS_RE.sub('', result[0]) 

550 text = result[0] 

551 toc, _ = _md.reset().convert(f'[TOC]\n\n@CUT@\n\n{text}').split('@CUT@', 1) 

552 if toc.endswith('<p>'): # CUT was put into its own paragraph 

553 toc = toc[:-3].rstrip() 

554 return toc 

555 

556 

557def format_git_link(template: str, dobj: pdoc.Doc): 

558 """ 

559 Interpolate `template` as a formatted string literal using values extracted 

560 from `dobj` and the working environment. 

561 """ 

562 if not template: 

563 return None 

564 try: 

565 if 'commit' in _str_template_fields(template): 

566 commit = _git_head_commit() 

567 obj = pdoc._unwrap_descriptor(dobj) 

568 abs_path = inspect.getfile(inspect.unwrap(obj)) 

569 path = _project_relative_path(abs_path) 

570 

571 # Urls should always use / instead of \\ 

572 if os.name == 'nt': 

573 path = path.replace('\\', '/') 

574 

575 lines, start_line = inspect.getsourcelines(obj) 

576 start_line = start_line or 1 # GH-296 

577 end_line = start_line + len(lines) - 1 

578 url = template.format(**locals()) 

579 return url 

580 except Exception: 

581 warn(f'format_git_link for {obj} failed:\n{traceback.format_exc()}') 

582 return None 

583 

584 

585@lru_cache() 

586def _git_head_commit(): 

587 """ 

588 If the working directory is part of a git repository, return the 

589 head git commit hash. Otherwise, raise a CalledProcessError. 

590 """ 

591 process_args = ['git', 'rev-parse', 'HEAD'] 

592 try: 

593 commit = subprocess.check_output(process_args, universal_newlines=True).strip() 

594 return commit 

595 except OSError as error: 

596 warn(f"git executable not found on system:\n{error}") 

597 except subprocess.CalledProcessError as error: 

598 warn( 

599 "Ensure pdoc is run within a git repository.\n" 

600 f"`{' '.join(process_args)}` failed with output:\n{error.output}" 

601 ) 

602 return None 

603 

604 

605@lru_cache() 

606def _git_project_root(): 

607 """ 

608 Return the path to project root directory or None if indeterminate. 

609 """ 

610 for cmd in (['git', 'rev-parse', '--show-superproject-working-tree'], 

611 ['git', 'rev-parse', '--show-toplevel']): 

612 try: 

613 path = subprocess.check_output(cmd, universal_newlines=True).rstrip('\r\n') 

614 if path: 

615 return os.path.normpath(path) 

616 except (subprocess.CalledProcessError, OSError): 

617 pass 

618 return None 

619 

620 

621@lru_cache() 

622def _project_relative_path(absolute_path): 

623 """ 

624 Convert an absolute path of a python source file to a project-relative path. 

625 Assumes the project's path is either the current working directory or 

626 Python library installation. 

627 """ 

628 from sysconfig import get_path 

629 libdir = get_path("platlib") 

630 for prefix_path in (_git_project_root() or os.getcwd(), libdir): 

631 common_path = os.path.commonpath([prefix_path, absolute_path]) 

632 if os.path.samefile(common_path, prefix_path): 

633 # absolute_path is a descendant of prefix_path 

634 return os.path.relpath(absolute_path, prefix_path) 

635 raise RuntimeError( 

636 f"absolute path {absolute_path!r} is not a descendant of the current working directory " 

637 "or of the system's python library." 

638 ) 

639 

640 

641@lru_cache() 

642def _str_template_fields(template): 

643 """ 

644 Return a list of `str.format` field names in a template string. 

645 """ 

646 from string import Formatter 

647 return [ 

648 field_name 

649 for _, field_name, _, _ in Formatter().parse(template) 

650 if field_name is not None 

651 ]