diff --git a/autoapi/_mapper.py b/autoapi/_mapper.py index 872c8b41..0130b4f6 100644 --- a/autoapi/_mapper.py +++ b/autoapi/_mapper.py @@ -29,6 +29,8 @@ PythonAttribute, PythonData, PythonException, + _trace_visibility, + HideReason, ) from .settings import OWN_PAGE_LEVELS, TEMPLATE_DIR @@ -43,6 +45,10 @@ def in_stdlib(module_name: str) -> bool: LOGGER = sphinx.util.logging.getLogger(__name__) +def _color_info(msg: str) -> None: + LOGGER.info(colorize("bold", "[AutoAPI] ") + colorize("darkgreen", msg)) + + def _expand_wildcard_placeholder(original_module, originals_map, placeholder): """Expand a wildcard placeholder to a sequence of named placeholders. @@ -329,12 +335,7 @@ def find_files(patterns, dirs, ignore): for sub_dir in subdirectories.copy(): # iterate copy as we adapt subdirectories during loop if _path_matches_patterns(os.path.join(root, sub_dir), ignore): - LOGGER.info( - colorize("bold", "[AutoAPI] ") - + colorize( - "darkgreen", f"Ignoring directory: {root}/{sub_dir}/" - ) - ) + _color_info(f"Ignoring directory: {root}/{sub_dir}/") # adapt original subdirectories inplace subdirectories.remove(sub_dir) # recurse into remaining directories @@ -348,12 +349,7 @@ def find_files(patterns, dirs, ignore): # Skip ignored files if _path_matches_patterns(os.path.join(root, filename), ignore): - LOGGER.info( - colorize("bold", "[AutoAPI] ") - + colorize( - "darkgreen", f"Ignoring file: {root}/{filename}" - ) - ) + _color_info(f"Ignoring file: {root}/{filename}") continue # Make sure the path is full @@ -388,6 +384,7 @@ def output_rst(self, source_suffix): def _output_top_rst(self): # Render Top Index top_level_index = os.path.join(self.dir_root, "index.rst") + pages = [obj for obj in self.objects_to_render.values() if obj.display] if not pages: msg = ( @@ -499,7 +496,12 @@ def _skip_if_stdlib(self): and not obj["inherited_from"]["is_abstract"] and module not in documented_modules ): - obj["hide"] = True + _trace_visibility( + self.app, + f"Hiding {obj['qual_name']} as determined to be Python standard Library (found as {obj['full_name']})", + verbose=2, + ) + obj["hide_reason"] = HideReason.STD_LIBRARY def _resolve_placeholders(self): """Resolve objects that have been imported from elsewhere.""" @@ -518,13 +520,27 @@ def _hide_yo_kids(self): for module in self.paths.values(): if module["all"] is not None: all_names = set(module["all"]) + for n in all_names: + _trace_visibility(self.app, f" {n}") for child in module["children"]: if child["qual_name"] not in all_names: - child["hide"] = True + _trace_visibility( + self.app, + f"Hiding {child['full_name']}, as {child['qual_name']} not in __all__", + ) + child["hide_reason"] = HideReason.NOT_IN_ALL elif module["type"] == "module": + _trace_visibility( + self.app, + f"Testing if any children of {module['full_name']} have already been documented", + ) for child in module["children"]: if "original_path" in child: - child["hide"] = True + _trace_visibility( + self.app, + f"Hiding {child['full_name']} as it appears to be in your public API at {child['original_path']}", + ) + child["hide_reason"] = HideReason.NOT_PUBLIC def map(self, options=None): self._skip_if_stdlib() @@ -567,14 +583,28 @@ def _render_selection(self): assert obj.type in self.own_page_types self.objects_to_render[obj.id] = obj else: + if obj.subpackages or obj.submodules: + _trace_visibility( + self.app, + f"Not rendering the following as {obj.id} set to not display because object is {obj.hide_reason}", + verbose=2, + ) for module in itertools.chain(obj.subpackages, obj.submodules): - module.obj["hide"] = True + _trace_visibility( + self.app, f" {module.obj['full_name']}", verbose=2 + ) + module.obj["hide_reason"] = HideReason.PARENT_HIDDEN def _inner(parent): for child in parent.children: self.all_objects[child.id] = child if not parent.display: - child.obj["hide"] = True + _trace_visibility( + self.app, + f"Hiding {child.id} as parent {parent.id} will not be displayed", + verbose=2, + ) + child.obj["hide_reason"] = HideReason.PARENT_HIDDEN if child.display and child.type in self.own_page_types: self.objects_to_render[child.id] = child @@ -584,6 +614,18 @@ def _inner(parent): for obj in list(self.all_objects.values()): _inner(obj) + modules = [ + obj + for obj in self.all_objects.values() + if obj.type == "module" and obj.docstring == "" + ] + if modules and "undoc-members" not in self.app.config.autoapi_options: + _color_info( + "The following modules have no top-level documentation, and so were skipped as undocumented:" + ) + for m in modules: + _color_info(f" {m.id}") + def create_class(self, data, options=None): """Create a class from the passed in data diff --git a/autoapi/_objects.py b/autoapi/_objects.py index 99e0b12e..705a6bf7 100644 --- a/autoapi/_objects.py +++ b/autoapi/_objects.py @@ -2,16 +2,31 @@ import functools import pathlib +import sys + +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + from backports.strenum import StrEnum + +from typing import List import sphinx import sphinx.util import sphinx.util.logging +from sphinx.util.console import colorize + from .settings import OWN_PAGE_LEVELS LOGGER = sphinx.util.logging.getLogger(__name__) +def _trace_visibility(app, msg: str, verbose=1) -> None: + if app.config.autoapi_verbose_visibility >= verbose: + LOGGER.info(colorize("bold", f"[AutoAPI] [Visibility] {msg}")) + + def _format_args(args_info, include_annotations=True, ignore_self=None): result = [] @@ -29,6 +44,21 @@ def _format_args(args_info, include_annotations=True, ignore_self=None): return ", ".join(result) +class HideReason(StrEnum): + NOT_HIDDEN = "not hidden" + UNDOC_MEMBER = "undocumented" + PRIVATE_MEMBER = "a private member" + SPECIAL_MEMBER = "a special member" + IMPORTED_MEMBER = "an imported member" + INHERITED_MEMBER = "an inherited member" + IS_NEW_OR_INIT = "__new__ or __init__" + NOT_IN_ALL = "not in __all__ for module" + NOT_PUBLIC = "assumed to not be public API" + PARENT_HIDDEN = "parent is hidden" + STD_LIBRARY = "part of Python standard library" + SKIP_MEMBER = "`autoapi-skip-member` returned false for the object" + + class PythonObject: """A class representing an entity from the parsed source code. @@ -44,7 +74,13 @@ class PythonObject: type: str def __init__( - self, obj, jinja_env, app, url_root, options=None, class_content="class" + self, + obj, + jinja_env, + app, + url_root, + options: List[str] = [], + class_content="class", ): self.app = app self.obj = obj @@ -78,7 +114,7 @@ def __init__( # For later self._class_content = class_content - self._display_cache: bool | None = None + self._display_cache: HideReason | None = None def __getstate__(self): """Obtains serialisable data for pickling.""" @@ -192,6 +228,45 @@ def is_special_member(self) -> bool: """Whether this object is a special member (True) or not (False).""" return self.short_name.startswith("__") and self.short_name.endswith("__") + @property + def hide_reason(self) -> HideReason: + skip_undoc_member = self.is_undoc_member and "undoc-members" not in self.options + skip_private_member = ( + self.is_private_member and "private-members" not in self.options + ) + skip_special_member = ( + self.is_special_member and "special-members" not in self.options + ) + skip_imported_member = self.imported and "imported-members" not in self.options + skip_inherited_member = ( + self.inherited and "inherited-members" not in self.options + ) + + reason = HideReason.NOT_HIDDEN + if self.obj.get("hide-reason"): + reason = self.obj.get("hide-reason") + elif skip_undoc_member: + reason = HideReason.UNDOC_MEMBER + elif skip_private_member: + reason = HideReason.UNDOC_MEMBER + elif skip_special_member: + reason = HideReason.SPECIAL_MEMBER + elif skip_imported_member: + reason = HideReason.IMPORTED_MEMBER + elif skip_inherited_member: + reason = HideReason.INHERITED_MEMBER + + # Allow user to override + # If we told the api we were skipping already, keep the reason as originally + skip = reason != HideReason.NOT_HIDDEN + api_says_skip = self.app.emit_firstresult( + "autoapi-skip-member", self.type, self.id, self, skip, self.options + ) + if not skip and api_says_skip: + reason = HideReason.SKIP_MEMBER + + return reason + @property def display(self) -> bool: """Whether this object should be displayed in documentation. @@ -199,10 +274,19 @@ def display(self) -> bool: This attribute depends on the configuration options given in :confval:`autoapi_options` and the result of :event:`autoapi-skip-member`. """ + if self._display_cache is None: - self._display_cache = not self._ask_ignore(self._should_skip()) + self._display_cache = self.hide_reason + if self._display_cache != HideReason.NOT_HIDDEN: + _trace_visibility( + self.app, f"Skipping {self.id} due to {self.hide_reason}" + ) + else: + _trace_visibility( + self.app, f"Skipping {self.id} due to {self.hide_reason}", verbose=2 + ) - return self._display_cache + return self._display_cache == HideReason.NOT_HIDDEN @property def summary(self) -> str: @@ -218,35 +302,6 @@ def summary(self) -> str: return "" - def _should_skip(self) -> bool: - skip_undoc_member = self.is_undoc_member and "undoc-members" not in self.options - skip_private_member = ( - self.is_private_member and "private-members" not in self.options - ) - skip_special_member = ( - self.is_special_member and "special-members" not in self.options - ) - skip_imported_member = self.imported and "imported-members" not in self.options - skip_inherited_member = ( - self.inherited and "inherited-members" not in self.options - ) - - return ( - self.obj.get("hide", False) - or skip_undoc_member - or skip_private_member - or skip_special_member - or skip_imported_member - or skip_inherited_member - ) - - def _ask_ignore(self, skip: bool) -> bool: - ask_result = self.app.emit_firstresult( - "autoapi-skip-member", self.type, self.id, self, skip, self.options - ) - - return ask_result if ask_result is not None else skip - def _children_of_type(self, type_: str) -> list[PythonObject]: return [child for child in self.children if child.type == type_] @@ -305,11 +360,16 @@ def __init__(self, *args, **kwargs): Can be any of: abstractmethod, async, classmethod, property, staticmethod. """ - def _should_skip(self) -> bool: - return super()._should_skip() or self.name in ( + @property + def hide_reason(self) -> HideReason: + is_new_or_init = self.name in ( "__new__", "__init__", ) + hide_reason = super().hide_reason + if hide_reason != HideReason.NOT_HIDDEN and is_new_or_init: + return HideReason.IS_NEW_OR_INIT + return hide_reason class PythonProperty(PythonObject): diff --git a/autoapi/extension.py b/autoapi/extension.py index 218f28e8..335aef41 100644 --- a/autoapi/extension.py +++ b/autoapi/extension.py @@ -261,6 +261,7 @@ def setup(app): app.add_config_value("autoapi_generate_api_docs", True, "html") app.add_config_value("autoapi_prepare_jinja_env", None, "html") app.add_config_value("autoapi_own_page_level", "module", "html") + app.add_config_value("autoapi_verbose_visibility", 0, "html") app.add_autodocumenter(documenters.AutoapiFunctionDocumenter) app.add_autodocumenter(documenters.AutoapiPropertyDocumenter) app.add_autodocumenter(documenters.AutoapiDecoratorDocumenter) diff --git a/pyproject.toml b/pyproject.toml index b05576e4..89b1f375 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "PyYAML", "sphinx>=7.4.0", 'stdlib_list;python_version<"3.10"', + 'backports.strenum>=1.3.1;python_version<"3.11"', ] dynamic = ["version"] @@ -103,3 +104,8 @@ filename = "CHANGELOG.rst" package = "autoapi" title_format = "v{version} ({project_date})" underlines = ["-", "^", "\""] + +[dependency-groups] +dev = [ + "tox>=4.26.0", +]