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 ' '
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' : {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}{{: 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: {value}'
300 elif type == 'versionadded':
301 title = f'Added in version: {value}'
302 elif type == 'deprecated' and value:
303 title = f'Deprecated since version: {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': {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 ]