Skip to content

A user tool to debug visibility of objects #522

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 59 additions & 17 deletions autoapi/_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
PythonAttribute,
PythonData,
PythonException,
_trace_visibility,
HideReason,
)
from .settings import OWN_PAGE_LEVELS, TEMPLATE_DIR

Expand All @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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."""
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
130 changes: 95 additions & 35 deletions autoapi/_objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []

Expand All @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -192,17 +228,65 @@ 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.

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:
Expand All @@ -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_]

Expand Down Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions autoapi/extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we utilise logging levels instead of needing to invent our own way of configuring verbosity?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep. I was just worrying about not changing the current logging behaviour too much!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, looking at this again, it adds way too much output to debug mode. I think it makes sense to have this separate for debugging visibility. Also I split the visibility debugging into two verbosity levels as usually level 1 will suffice, but allowing for deeper debugging,.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In which case please can you add some documentation for this new config option to docs/reference/config.rst. You could also add a small section about debugging to docs/how_to.rst, possibly as a subsection to "How to Customise What Gets Documented", or linking to your new section from there.

app.add_autodocumenter(documenters.AutoapiFunctionDocumenter)
app.add_autodocumenter(documenters.AutoapiPropertyDocumenter)
app.add_autodocumenter(documenters.AutoapiDecoratorDocumenter)
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down Expand Up @@ -103,3 +104,8 @@ filename = "CHANGELOG.rst"
package = "autoapi"
title_format = "v{version} ({project_date})"
underlines = ["-", "^", "\""]

[dependency-groups]
dev = [
"tox>=4.26.0",
]
Loading