Skip to content
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

Create autogenerated dbus docs + default theme change #6124

Open
wants to merge 1 commit 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
4 changes: 4 additions & 0 deletions docs/_static/custom.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.wy-nav-content {
max-width: none;
overflow-x: auto;
}
78 changes: 69 additions & 9 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import os
import shutil
import subprocess
import sys

# If extensions (or modules to document with autodoc) are in another directory,
Expand All @@ -21,7 +22,7 @@
sys.path.insert(0, os.path.abspath('..'))

# configuration required to import test modules
for path in ["../pyanaconda", "../tests", "../tests/lib", "../dracut", "../widgets"]:
for path in ["../pyanaconda", "../tests", "../tests/lib", "../dracut", "../widgets", "../pyanaconda/modules"]:
sys.path.append(os.path.abspath(path))

# -- General configuration -----------------------------------------------------
Expand All @@ -32,8 +33,16 @@
# Add any Sphinx extension module names here, as strings. They can be extensions
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = []
if not os.environ.get("READTHEDOCS") == "True":
extensions.extend(['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.coverage', 'sphinx.ext.inheritance_diagram', 'sphinx.ext.todo'])
extensions.extend(['sphinx.ext.autodoc',
'sphinx.ext.intersphinx',
'sphinx.ext.coverage',
'sphinx.ext.inheritance_diagram',
'sphinx.ext.todo',
'sphinx_autodoc_typehints',
'sphinx.ext.viewcode',
'sphinx.ext.napoleon',
'autoapi.extension',
])

shutil.copy2("../CONTRIBUTING.rst", "contributing.rst")

Expand Down Expand Up @@ -117,12 +126,21 @@ def read_version():

# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
html_theme = 'default'
html_theme = 'sphinx_rtd_theme'

# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#html_theme_options = {}
html_theme_options = {
'navigation_depth': 4,
'collapse_navigation': True,
'sticky_navigation': True,
'prev_next_buttons_location': 'both',
}

html_css_files = [
'custom.css',
]

# Add any paths that contain custom themes here, relative to this directory.
#html_theme_path = []
Expand All @@ -146,7 +164,7 @@ def read_version():
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
html_static_path = ["_static"]

# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format.
Expand Down Expand Up @@ -312,7 +330,9 @@ def read_version():


# Example configuration for intersphinx: refer to the Python standard library.
intersphinx_mapping = {'https://docs.python.org/3': None}
intersphinx_mapping = {
'python': ('https://docs.python.org/3', 'https://docs.python.org/3/objects.inv')
}

# on_rtd is whether we are on readthedocs.org
on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
Expand All @@ -323,6 +343,46 @@ def read_version():
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]

# Group by class
autodoc_member_order = 'source'
autodoc_member_order = 'bysource'

# AutoAPI configuration
autoapi_type = 'python'
autoapi_dirs = ['../pyanaconda/modules']
autoapi_add_toctree_entry = False
autoapi_file_patterns = ['*_interface.py']
autoapi_mock_imports = [
'gi.repository',
'dasbus',
'pydbus',
'dbus',
'blivet',
'pykickstart',
'pyanaconda.core',
'pyanaconda.anaconda_loggers'
]

autoapi_options = [
'members',
'undoc-members',
'show-inheritance',
'show-module-summary',
'special-members: __init__',
'private-members',
]

autoapi_python_class_content = 'both'
autodoc_typehints = "description"

def generate_modules_rst(app):
"""
Runs the generate_modules.py script after AutoAPI has generated documentation.

It is triggered by "builder-inited", ensuring that AutoAPI files exist
before structuring them in modules.rst.
"""
print("[Sphinx] Waiting for AutoAPI to generate documentation...")
subprocess.run(["python", "generate_modules.py"], check=True)
print("[Sphinx] modules.rst generated successfully.")

# otherwise, readthedocs.org uses their theme by default, so no need to specify it
def setup(app):
app.connect("builder-inited", generate_modules_rst)
193 changes: 193 additions & 0 deletions docs/generate_modules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import os
import re
from collections import defaultdict

DOCS_PATH = "autoapi/pyanaconda/modules"
OUTPUT_FILE = "modules.rst"

header = """Anaconda DBUS Modules
=====================

This page contains the D-Bus API documentation, grouped by modules.

.. toctree::
:maxdepth: 2

"""


def submodule_name_to_class_name(submodule_name: str) -> str:
"""
Convert a submodule name (e.g. 'network') to a DBus interface class name
(e.g. 'NetworkInterface').
"""
parts = submodule_name.split("_")
capitalized = [p.capitalize() for p in parts]
return "".join(capitalized) + "Interface"


def extract_summary_from_rst(index_rst_path, class_name):
"""
Search in the given RST file for the line: .. py:class:: <class_name>
Then take the first suitable indented line as a docstring:
- At least 3 spaces indentation
- Not empty
- Does not start with 'Bases:'
Returns the found summary (string) or None if none found.
"""
if not os.path.isfile(index_rst_path):
return None

class_directive_regex = re.compile(rf"^\.\.\s+py:class::\s+{class_name}\s*$")
next_directive_regex = re.compile(r"^\.\.\s+py:")

with open(index_rst_path, "r", encoding="utf-8") as f:
lines = f.readlines()

found_class = False
min_indent = 1

for _, line in enumerate(lines):
striped_line = line.rstrip("\n")

if not found_class:
if class_directive_regex.match(striped_line):
found_class = True
continue
else:
if next_directive_regex.match(striped_line):
break

match_indent = re.match(r"^(\s+)(.*)$", line)
if not match_indent:
break

indent_str, text = match_indent.groups()
indent_len = len(indent_str)
if indent_len < min_indent:
continue

text = text.strip()
if not text:
continue
if text.startswith("Bases:"):
continue

return text

return None


def build_module_tree():
"""
Build a nested structure of modules and their interfaces:
{
'submodules': {
'boss': {
'submodules': {...},
'interfaces': {
'autoapi/pyanaconda/modules/boss/boss_interface/index': 'docstring'
}
}
},
'interfaces': {...}
}
"""
def create_node():
return {
"interfaces": {},
"submodules": defaultdict(create_node),
}

tree = create_node()

for root, _, _ in os.walk(DOCS_PATH):
if not root.endswith("_interface"):
continue

rel_path = os.path.relpath(root, DOCS_PATH)
parts = rel_path.split(os.sep)
last_part = parts[-1]
if not last_part.endswith("_interface"):
continue

submodule_name = last_part[:-10] # remove "_interface"
class_name = submodule_name_to_class_name(submodule_name)

index_rst_file = os.path.join(root, "index.rst")
if not os.path.exists(index_rst_file):
index_rst_file = os.path.join(root, "index")
if not os.path.exists(index_rst_file):
continue

docstring_summary = extract_summary_from_rst(index_rst_file, class_name)

parent_parts = parts[:-1]
current = tree
for p in parent_parts:
if p.endswith("_interface"):
p = p[:-10]
current = current["submodules"][p]

interface_doc_ref = os.path.join(root, "index").replace(os.sep, "/")
current["interfaces"][interface_doc_ref] = docstring_summary

return tree


def write_tree(f, node, current_module=None, depth=0):
"""
Recursively write the content of the module tree to the output file.

- depth=0: root level. Prints any interfaces found directly under the root, if any.
- depth=1: top-level modules. Prints the module name, and the summary for the interface
matching the module name (e.g. 'network_interface/index').
A toctree listing references to all interfaces (including submodules) follows.
- depth>1: recursion stops (you can adjust if deeper levels are needed).
"""
if current_module is None:
current_module = []

if depth > 1:
return

module_name = ".".join(current_module)

if depth == 1:
def collect_interfaces(n):
interfaces = dict(n["interfaces"])
for sub in n["submodules"].values():
interfaces.update(collect_interfaces(sub))
return interfaces
aggregated_interfaces = collect_interfaces(node)
else:
aggregated_interfaces = node["interfaces"]

if aggregated_interfaces:
underline = "=" if depth == 0 else "-" if depth == 1 else "~"

if module_name:
f.write(f"\n{module_name}\n{underline * len(module_name)}\n")

if depth == 1 and module_name:
main_submodule_name = current_module[-1]
main_interface_suffix = f"{main_submodule_name}_interface/index"
for path, summary in aggregated_interfaces.items():
if path.endswith(main_interface_suffix) and summary:
f.write(f"\n{summary}\n")
break

f.write("\n.. toctree::\n :maxdepth: 1\n\n")
for path in sorted(aggregated_interfaces.keys()):
f.write(f" {path}\n")

if depth < 1:
for sub_name in sorted(node["submodules"]):
write_tree(f, node["submodules"][sub_name], current_module + [sub_name], depth + 1)


if __name__ == "__main__":
module_tree = build_module_tree()
with open(OUTPUT_FILE, "w", encoding="utf-8") as file:
file.write(header)
write_tree(file, module_tree)
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ Contents:
release
list-harddrives
developer
modules
ci-status
4 changes: 4 additions & 0 deletions docs/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# Declare Python requirements required to build your docs.
sphinx>=4.2.0
sphinx-autoapi>=1.9.0
sphinx_rtd_theme>=1.0.0
sphinx-autodoc-typehints>=1.12.0
sphinxcontrib-napoleon>=0.7
graphviz>=0.20.0