Coverage for src/griffe2md/rendering.py: 52.53%
212 statements
« prev ^ index » next coverage.py v7.4.4, created at 2024-03-20 11:44 +0100
« prev ^ index » next coverage.py v7.4.4, created at 2024-03-20 11:44 +0100
1"""This module implements rendering utilities."""
3from __future__ import annotations
5import enum
6import logging
7import random
8import re
9import string
10import sys
11import warnings
12from functools import lru_cache, partial
13from typing import TYPE_CHECKING, Any, Callable, Match, Pattern, Sequence
15from griffe.docstrings.dataclasses import (
16 DocstringAttribute,
17 DocstringClass,
18 DocstringFunction,
19 DocstringModule,
20 DocstringSectionAttributes,
21 DocstringSectionClasses,
22 DocstringSectionFunctions,
23 DocstringSectionModules,
24)
25from griffe.exceptions import AliasResolutionError, CyclicAliasError
26from jinja2 import pass_context
27from markupsafe import Markup
29if TYPE_CHECKING:
30 from griffe.dataclasses import Alias, Attribute, Class, Function, Module, Object
31 from jinja2.runtime import Context
34logger = logging.getLogger(__name__)
37class Order(enum.Enum):
38 """Enumeration for the possible members ordering."""
40 alphabetical = "alphabetical"
41 """Alphabetical order."""
42 source = "source"
43 """Source code order."""
46default_config: dict = {
47 "docstring_style": "google",
48 "docstring_options": {"ignore_init_summary": True},
49 "show_root_heading": True,
50 "show_root_full_path": True,
51 "show_root_members_full_path": True,
52 "show_object_full_path": True,
53 "show_category_heading": False,
54 "show_if_no_docstring": True,
55 "show_signature": True,
56 "show_signature_annotations": False,
57 "signature_crossrefs": False,
58 "separate_signature": True,
59 "line_length": 80,
60 "merge_init_into_class": True,
61 "show_docstring_attributes": True,
62 "show_docstring_description": True,
63 "show_docstring_examples": True,
64 "show_docstring_other_parameters": True,
65 "show_docstring_parameters": True,
66 "show_docstring_raises": True,
67 "show_docstring_receives": True,
68 "show_docstring_returns": True,
69 "show_docstring_warns": True,
70 "show_docstring_yields": True,
71 "show_bases": True,
72 "show_submodules": True,
73 "group_by_category": False,
74 "heading_level": 2,
75 "members_order": Order.alphabetical.value,
76 "docstring_section_style": "list",
77 "members": None,
78 "inherited_members": True,
79 "filters": ["!^_"],
80 "annotations_path": "brief",
81 "preload_modules": None,
82 "load_external_modules": False,
83 "allow_inspection": True,
84 "summary": True,
85}
88def do_any(seq: Sequence, attribute: str | None = None) -> bool:
89 """Check if at least one of the item in the sequence evaluates to true.
91 The `any` builtin as a filter for Jinja templates.
93 Arguments:
94 seq: An iterable object.
95 attribute: The attribute name to use on each object of the iterable.
97 Returns:
98 A boolean telling if any object of the iterable evaluated to True.
99 """
100 if attribute is None:
101 return any(seq)
102 return any(_[attribute] for _ in seq)
105def _sort_key_alphabetical(item: Object | Alias) -> Any:
106 # chr(sys.maxunicode) is a string that contains the final unicode
107 # character, so if 'name' isn't found on the object, the item will go to
108 # the end of the list.
109 return item.name or chr(sys.maxunicode)
112def _sort_key_source(item: Object | Alias) -> Any:
113 # if 'lineno' is none, the item will go to the start of the list.
114 return item.lineno if item.lineno is not None else -1
117order_map = {
118 Order.alphabetical: _sort_key_alphabetical,
119 Order.source: _sort_key_source,
120}
123def do_format_code(code: str, line_length: int) -> str:
124 """Format code using Black.
126 Parameters:
127 code: The code to format.
128 line_length: The line length to give to Black.
130 Returns:
131 The same code, formatted.
132 """
133 code = code.strip()
134 if len(code) < line_length:
135 return code
136 formatter = _get_black_formatter()
137 return formatter(code, line_length)
140_stash_key_alphabet = string.ascii_letters + string.digits
143def _gen_key(length: int) -> str:
144 return "_" + "".join(random.choice(_stash_key_alphabet) for _ in range(max(1, length - 1))) # noqa: S311
147def _gen_stash_key(stash: dict[str, str], length: int) -> str:
148 key = _gen_key(length)
149 while key in stash:
150 key = _gen_key(length)
151 return key
154def _stash_crossref(stash: dict[str, str], crossref: str, *, length: int) -> str:
155 key = _gen_stash_key(stash, length)
156 stash[key] = crossref
157 return key
160def _format_signature(name: Markup, signature: str, line_length: int) -> str:
161 name = str(name).strip() # type: ignore[assignment]
162 signature = signature.strip()
163 if len(name + signature) < line_length:
164 return name + signature
166 # Black cannot format names with dots, so we replace
167 # the whole name with a string of equal length
168 name_length = len(name)
169 formatter = _get_black_formatter()
170 formatable = f"def {'x' * name_length}{signature}: pass"
171 formatted = formatter(formatable, line_length)
173 # We put back the original name
174 # and remove starting `def ` and trailing `: pass`
175 return name + formatted[4:-5].strip()[name_length:-1]
178@pass_context
179def do_format_signature(
180 context: Context,
181 callable_path: Markup,
182 function: Function,
183 line_length: int,
184 *,
185 annotations: bool | None = None,
186 crossrefs: bool = False,
187) -> str:
188 """Format a signature using Black.
190 Parameters:
191 context: Jinja context, passed automatically.
192 callable_path: The path of the callable we render the signature of.
193 function: The function we render the signature of.
194 line_length: The line length to give to Black.
195 annotations: Whether to show type annotations.
196 crossrefs: Whether to cross-reference types in the signature.
198 Returns:
199 The same code, formatted.
200 """
201 env = context.environment
202 template = env.get_template("signature.md.jinja")
203 config_annotations = context.parent["config"]["show_signature_annotations"]
204 old_stash_ref_filter = env.filters["stash_crossref"]
206 stash: dict[str, str] = {}
207 if (annotations or config_annotations) and crossrefs: 207 ↛ 208line 207 didn't jump to line 208, because the condition on line 207 was never true
208 env.filters["stash_crossref"] = partial(_stash_crossref, stash)
210 if annotations is None: 210 ↛ 213line 210 didn't jump to line 213, because the condition on line 210 was never false
211 new_context = context.parent
212 else:
213 new_context = dict(context.parent)
214 new_context["config"] = dict(new_context["config"])
215 new_context["config"]["show_signature_annotations"] = annotations
216 try:
217 signature = template.render(new_context, function=function, signature=True)
218 finally:
219 env.filters["stash_crossref"] = old_stash_ref_filter
221 signature = _format_signature(callable_path, signature, line_length)
223 if stash: 223 ↛ 224line 223 didn't jump to line 224, because the condition on line 223 was never true
224 for key, value in stash.items():
225 signature = re.sub(rf"\b{key}\b", value, signature)
227 return signature
230@pass_context
231def do_format_attribute(
232 context: Context,
233 attribute_path: Markup,
234 attribute: Attribute,
235 line_length: int,
236 *,
237 crossrefs: bool = False,
238) -> str:
239 """Format an attribute using Black.
241 Parameters:
242 context: Jinja context, passed automatically.
243 attribute_path: The path of the callable we render the signature of.
244 attribute: The attribute we render the signature of.
245 line_length: The line length to give to Black.
246 crossrefs: Whether to cross-reference types in the signature.
248 Returns:
249 The same code, formatted.
250 """
251 env = context.environment
252 template = env.get_template("expression.md.jinja")
253 annotations = context.parent["config"]["show_signature_annotations"]
254 separate_signature = context.parent["config"]["separate_signature"]
255 old_stash_ref_filter = env.filters["stash_crossref"]
257 stash: dict[str, str] = {}
258 if separate_signature and crossrefs:
259 env.filters["stash_crossref"] = partial(_stash_crossref, stash)
261 try:
262 signature = str(attribute_path).strip()
263 if annotations and attribute.annotation:
264 annotation = template.render(context.parent, expression=attribute.annotation, signature=True)
265 signature += f": {annotation}"
266 if attribute.value:
267 value = template.render(context.parent, expression=attribute.value, signature=True)
268 signature += f" = {value}"
269 finally:
270 env.filters["stash_crossref"] = old_stash_ref_filter
272 signature = do_format_code(signature, line_length)
274 if stash:
275 for key, value in stash.items():
276 signature = re.sub(rf"\b{key}\b", value, signature)
278 return signature
281def do_order_members(
282 members: Sequence[Object | Alias],
283 order: Order,
284 members_list: bool | list[str] | None,
285) -> Sequence[Object | Alias]:
286 """Order members given an ordering method.
288 Parameters:
289 members: The members to order.
290 order: The ordering method.
291 members_list: An optional member list (manual ordering).
293 Returns:
294 The same members, ordered.
295 """
296 if isinstance(members_list, list) and members_list: 296 ↛ 297line 296 didn't jump to line 297, because the condition on line 296 was never true
297 sorted_members = []
298 members_dict = {member.name: member for member in members}
299 for name in members_list:
300 if name in members_dict:
301 sorted_members.append(members_dict[name])
302 return sorted_members
303 return sorted(members, key=order_map[order])
306@lru_cache
307def _warn_crossref() -> None:
308 warnings.warn(
309 "The `crossref` filter is deprecated and will be removed in a future version",
310 DeprecationWarning,
311 stacklevel=1,
312 )
315def do_crossref(path: str, *, brief: bool = True) -> Markup:
316 """Deprecated. Filter to create cross-references.
318 Parameters:
319 path: The path to link to.
320 brief: Show only the last part of the path, add full path as hover.
322 Returns:
323 Markup text.
324 """
325 _warn_crossref()
326 full_path = path
327 if brief:
328 path = full_path.split(".")[-1]
329 return Markup("<span data-autorefs-optional-hover={full_path}>{path}</span>").format(full_path=full_path, path=path)
332@lru_cache
333def _warn_multi_crossref() -> None:
334 warnings.warn(
335 "The `multi_crossref` filter is deprecated and will be removed in a future version",
336 DeprecationWarning,
337 stacklevel=1,
338 )
341def do_multi_crossref(text: str, *, code: bool = True) -> Markup:
342 """Deprecated. Filter to create cross-references.
344 Parameters:
345 text: The text to scan.
346 code: Whether to wrap the result in a code tag.
348 Returns:
349 Markup text.
350 """
351 _warn_multi_crossref()
352 group_number = 0
353 variables = {}
355 def repl(match: Match) -> str:
356 nonlocal group_number
357 group_number += 1
358 path = match.group()
359 path_var = f"path{group_number}"
360 variables[path_var] = path
361 return f"<span data-autorefs-optional-hover={{{path_var}}}>{{{path_var}}}</span>"
363 text = re.sub(r"([\w.]+)", repl, text)
364 if code:
365 text = f"<code>{text}</code>"
366 return Markup(text).format(**variables)
369def do_heading(content: str, heading_level: int) -> str:
370 """Render a Markdown heading."""
371 return f"\n{'#' * heading_level} {content}\n\n"
374def do_split_path(path: str, full_path: str) -> list[tuple[str, str]]:
375 """Split object paths for building cross-references.
377 Parameters:
378 path: The path to split.
380 Returns:
381 A list of pairs (title, full path).
382 """
383 if "." not in path: 383 ↛ 385line 383 didn't jump to line 385, because the condition on line 383 was never false
384 return [(path, full_path)]
385 pairs = []
386 full_path = ""
387 for part in path.split("."):
388 if full_path:
389 full_path += f".{part}"
390 else:
391 full_path = part
392 pairs.append((part, full_path))
393 return pairs
396def _keep_object(name: str, filters: Sequence[tuple[Pattern, bool]]) -> bool:
397 keep = None
398 rules = set()
399 for regex, exclude in filters:
400 rules.add(exclude)
401 if regex.search(name):
402 keep = not exclude
403 if keep is None:
404 if rules == {False}: 404 ↛ 406line 404 didn't jump to line 406, because the condition on line 404 was never true
405 # only included stuff, no match = reject
406 return False
407 # only excluded stuff, or included and excluded stuff, no match = keep
408 return True
409 return keep
412def do_filter_objects(
413 objects_dictionary: dict[str, Object | Alias],
414 *,
415 filters: Sequence[tuple[Pattern, bool]] | None = None,
416 members_list: bool | list[str] | None = None,
417 inherited_members: bool | list[str] = False,
418 keep_no_docstrings: bool = True,
419) -> list[Object | Alias]:
420 """Filter a dictionary of objects based on their docstrings.
422 Parameters:
423 objects_dictionary: The dictionary of objects.
424 filters: Filters to apply, based on members' names.
425 Each element is a tuple: a pattern, and a boolean indicating whether
426 to reject the object if the pattern matches.
427 members_list: An optional, explicit list of members to keep.
428 When given and empty, return an empty list.
429 When given and not empty, ignore filters and docstrings presence/absence.
430 inherited_members: Whether to keep inherited members or exclude them.
431 keep_no_docstrings: Whether to keep objects with no/empty docstrings (recursive check).
433 Returns:
434 A list of objects.
435 """
436 inherited_members_specified = False
437 if inherited_members is True: 437 ↛ 440line 437 didn't jump to line 440, because the condition on line 437 was never false
438 # Include all inherited members.
439 objects = list(objects_dictionary.values())
440 elif inherited_members is False:
441 # Include no inherited members.
442 objects = [obj for obj in objects_dictionary.values() if not obj.inherited]
443 else:
444 # Include specific inherited members.
445 inherited_members_specified = True
446 objects = [
447 obj for obj in objects_dictionary.values() if not obj.inherited or obj.name in set(inherited_members)
448 ]
450 if members_list is True: 450 ↛ 452line 450 didn't jump to line 452, because the condition on line 450 was never true
451 # Return all pre-selected members.
452 return objects
454 if members_list is False or members_list == []: 454 ↛ 456line 454 didn't jump to line 456, because the condition on line 454 was never true
455 # Return selected inherited members, if any.
456 return [obj for obj in objects if obj.inherited]
458 if members_list is not None: 458 ↛ 460line 458 didn't jump to line 460, because the condition on line 458 was never true
459 # Return selected members (keeping any pre-selected inherited members).
460 return [
461 obj for obj in objects if obj.name in set(members_list) or (inherited_members_specified and obj.inherited)
462 ]
464 # Use filters and docstrings.
465 if filters: 465 ↛ 469line 465 didn't jump to line 469, because the condition on line 465 was never false
466 objects = [
467 obj for obj in objects if _keep_object(obj.name, filters) or (inherited_members_specified and obj.inherited)
468 ]
469 if keep_no_docstrings: 469 ↛ 472line 469 didn't jump to line 472, because the condition on line 469 was never false
470 return objects
472 return [obj for obj in objects if obj.has_docstrings or (inherited_members_specified and obj.inherited)]
475@lru_cache(maxsize=1)
476def _get_black_formatter() -> Callable[[str, int], str]:
477 try:
478 from black import InvalidInput, Mode, format_str
479 except ModuleNotFoundError:
480 logger.info("Formatting signatures requires Black to be installed.")
481 return lambda text, _: text
483 def formatter(code: str, line_length: int) -> str:
484 mode = Mode(line_length=line_length)
485 try:
486 return format_str(code, mode=mode)
487 except InvalidInput:
488 return code
490 return formatter
493def from_private_package(obj: Object | Alias) -> bool:
494 """Tell if an alias points to an object coming from a corresponding private package.
496 For example, return true for an alias in package `ast` pointing at an object in package `_ast`.
498 Parameters:
499 obj: The object (alias) to check.
501 Returns:
502 True or false.
503 """
504 if not obj.is_alias: 504 ↛ 505line 504 didn't jump to line 505, because the condition on line 504 was never true
505 return False
506 try:
507 return obj.target.package.name == f"_{obj.parent.package.name}" # type: ignore[union-attr]
508 except (AliasResolutionError, CyclicAliasError):
509 return False
512def do_as_attributes_section(
513 attributes: Sequence[Attribute],
514 *,
515 check_public: bool = True,
516) -> DocstringSectionAttributes:
517 """Build an attributes section from a list of attributes.
519 Parameters:
520 attributes: The attributes to build the section from.
521 check_public: Whether to check if the attribute is public.
523 Returns:
524 An attributes docstring section.
525 """
526 return DocstringSectionAttributes(
527 [
528 DocstringAttribute(
529 name=attribute.name,
530 description=attribute.docstring.value.split("\n", 1)[0] if attribute.docstring else "",
531 annotation=attribute.annotation,
532 value=attribute.value, # type: ignore[arg-type]
533 )
534 for attribute in attributes
535 if not check_public or attribute.is_public(check_name=False) or from_private_package(attribute)
536 ],
537 )
540def do_as_functions_section(functions: Sequence[Function], *, check_public: bool = True) -> DocstringSectionFunctions:
541 """Build a functions section from a list of functions.
543 Parameters:
544 functions: The functions to build the section from.
545 check_public: Whether to check if the function is public.
547 Returns:
548 A functions docstring section.
549 """
550 return DocstringSectionFunctions(
551 [
552 DocstringFunction(
553 name=function.name,
554 description=function.docstring.value.split("\n", 1)[0] if function.docstring else "",
555 )
556 for function in functions
557 if not check_public or function.is_public(check_name=False) or from_private_package(function)
558 ],
559 )
562def do_as_classes_section(classes: Sequence[Class], *, check_public: bool = True) -> DocstringSectionClasses:
563 """Build a classes section from a list of classes.
565 Parameters:
566 classes: The classes to build the section from.
567 check_public: Whether to check if the class is public.
569 Returns:
570 A classes docstring section.
571 """
572 return DocstringSectionClasses(
573 [
574 DocstringClass(
575 name=cls.name,
576 description=cls.docstring.value.split("\n", 1)[0] if cls.docstring else "",
577 )
578 for cls in classes
579 if not check_public or cls.is_public(check_name=False) or from_private_package(cls)
580 ],
581 )
584def do_as_modules_section(modules: Sequence[Module], *, check_public: bool = True) -> DocstringSectionModules:
585 """Build a modules section from a list of modules.
587 Parameters:
588 modules: The modules to build the section from.
589 check_public: Whether to check if the module is public.
591 Returns:
592 A modules docstring section.
593 """
594 return DocstringSectionModules(
595 [
596 DocstringModule(
597 name=module.name,
598 description=module.docstring.value.split("\n", 1)[0] if module.docstring else "",
599 )
600 for module in modules
601 if not check_public or module.is_public(check_name=False) or from_private_package(module)
602 ],
603 )