Skip to content

Commit

Permalink
Create autogenerated dbus docs
Browse files Browse the repository at this point in the history
  • Loading branch information
adamkankovsky committed Feb 19, 2025
1 parent 2c34a65 commit 246240a
Show file tree
Hide file tree
Showing 5 changed files with 271 additions and 9 deletions.
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 i, 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, dirs, files 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 f:
f.write(header)
write_tree(f, 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

0 comments on commit 246240a

Please sign in to comment.