Files
cthulhu/tools/generate_dbus_documentation.py
Storm Dragon 53614b13b9 Prepare for stable release 2025.12.09
- Remove all DEBUG print statements from AIAssistant plugin
- Update version to 2025.12.09 across all build files
  - src/cthulhu/cthulhuVersion.py
  - meson.build
  - distro-packages/Arch-Linux/PKGBUILD
- Add OCR optional dependencies to meson.build
  - pdf2image: PDF processing for OCR
  - scipy: Scientific computing for OCR analysis
  - webcolors: Color name resolution for OCR
- Add explicit OCR Python packages to PKGBUILD optdepends
  - python-pdf2image
  - python-scipy
  - python-webcolors
- Remove temporary test files from repository root
  - test_atspi_version.py
  - test_axtext_basic.py
  - test_modern_atspi_keystroke.py

All changes validated with successful local build.
Ready for final testing before stable release tag.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 09:21:52 -05:00

478 lines
18 KiB
Python
Executable File

#!/usr/bin/python
# generate_dbus_documentation.py
#
# Generate markdown documentation for Cthulhu's D-Bus remote controller.
# Must be run while Cthulhu is active with the D-Bus service enabled.
#
# Copyright 2025 Igalia, S.L.
# Author: Joanmarie Diggs <jdiggs@igalia.com>
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
"""Generate markdown documentation for Cthulhu's D-Bus remote controller."""
import os
import sys
from dasbus.connection import SessionMessageBus
from dasbus.error import DBusError
SERVICE_NAME = "org.stormux.Cthulhu.Service"
SERVICE_PATH = "/org/stormux/Cthulhu/Service"
def get_system_commands(proxy):
"""Get system-level commands from the main service interface."""
try:
commands = proxy.ListCommands()
return sorted(commands, key=lambda x: x[0])
except DBusError as e:
print(f"Error getting system commands: {e}", file=sys.stderr)
return []
def get_modules(proxy):
"""Get list of registered modules."""
try:
modules = proxy.ListModules()
return sorted(modules)
except DBusError as e:
print(f"Error getting modules: {e}", file=sys.stderr)
return []
def get_module_info(bus, module_name):
"""Get all commands, parameterized commands, getters, and setters for a module."""
object_path = f"{SERVICE_PATH}/{module_name}"
try:
module_proxy = bus.get_proxy(SERVICE_NAME, object_path)
commands = module_proxy.ListCommands()
parameterized_commands = module_proxy.ListParameterizedCommands()
getters = module_proxy.ListRuntimeGetters()
setters = module_proxy.ListRuntimeSetters()
return {
"commands": sorted(commands, key=lambda x: x[0]),
"parameterized_commands": sorted(parameterized_commands, key=lambda x: x[0]),
"getters": sorted(getters, key=lambda x: x[0]),
"setters": sorted(setters, key=lambda x: x[0])
}
except DBusError as e:
print(f"Error getting info for module {module_name}: {e}", file=sys.stderr)
return {
"commands": [],
"parameterized_commands": [],
"getters": [],
"setters": []
}
def format_system_commands(commands):
"""Format system-level commands as markdown."""
lines = []
lines.append("## Service-Level Commands")
lines.append("")
lines.append(
"These commands are available directly on the main service object "
"at `/org/stormux/Cthulhu/Service`."
)
lines.append("")
for name, description in commands:
lines.append(f"- **`{name}`:** {description}")
lines.append("")
return "\n".join(lines)
def _group_structural_navigator_commands(commands):
"""Group structural navigator commands by object type."""
groups = {}
other = []
def normalize_obj_type(obj_type): # pylint: disable=too-many-return-statements
"""Convert to a canonical form for grouping."""
# Group all heading commands together. Ditto for link commands.
if obj_type.startswith("Heading"):
return "Heading"
if "Link" in obj_type:
return "Link"
# Common plural patterns
if obj_type.endswith("ies"): # Entries -> Entry
return obj_type[:-3] + "y"
if obj_type.endswith("xes"): # Checkboxes -> Checkbox
return obj_type[:-2]
if obj_type.endswith("shes") or obj_type.endswith("ches"): # Matches -> Match
return obj_type[:-2]
if obj_type.endswith("s") and not obj_type.endswith("ss"):
return obj_type[:-1]
return obj_type
for name, description in commands:
# Extract the object type from command names like NextHeading, ListButtons, etc.
if name.startswith("Next") or name.startswith("Previous"):
prefix = "Next" if name.startswith("Next") else "Previous"
obj_type = name[len(prefix):]
normalized = normalize_obj_type(obj_type)
if normalized not in groups:
if normalized == "Heading":
display = "Headings"
elif not obj_type.endswith("s"):
display = obj_type + "s"
else:
display = obj_type
groups[normalized] = {"commands": [], "display_name": display}
groups[normalized]["commands"].append((name, description))
elif name.startswith("List"):
obj_type = name[4:]
normalized = normalize_obj_type(obj_type)
if normalized not in groups:
display = "Headings" if normalized == "Heading" else obj_type
groups[normalized] = {"commands": [], "display_name": display}
else:
current_display = groups[normalized]["display_name"]
if not current_display.endswith("s") or normalized == "Heading":
new_display = "Headings" if normalized == "Heading" else obj_type
groups[normalized]["display_name"] = new_display
groups[normalized]["commands"].append((name, description))
else:
# Other commands like ContainerStart, CycleMode
other.append((name, description))
return groups, other
# pylint: disable-next=too-many-branches,too-many-statements,too-many-locals
def format_module_commands(module_name, info):
"""Format module-level commands as markdown."""
lines = []
lines.append(f"### {module_name}")
lines.append("")
lines.append(f"**Object Path:** `/org/stormux/Cthulhu/Service/{module_name}`")
lines.append("")
# Commands - special handling for certain modules
if info["commands"]:
lines.append("#### Commands")
lines.append("")
lines.append("**Method:** `org.stormux.Cthulhu.Module.ExecuteCommand`")
lines.append("")
lines.append(
"**Parameters:** `CommandName` (string), "
"[`NotifyUser`](README-REMOTE-CONTROLLER.md#user-notification-applicability) (boolean)"
)
lines.append("")
if module_name == "SpeechAndVerbosityManager":
# Group related increase/decrease commands
def sort_speech_commands(cmd_tuple):
name, _ = cmd_tuple
# Define groups and their order
groups = {
"Rate": 0, "Pitch": 1, "Volume": 2,
}
# Check if it's an Increase/Decrease command
for group_name, group_order in groups.items():
if group_name in name:
if name.startswith("Increase"):
return (group_order, 0, name)
if name.startswith("Decrease"):
return (group_order, 1, name)
# Other commands go at the end, sorted alphabetically
return (100, 0, name)
sorted_commands = sorted(info["commands"], key=sort_speech_commands)
for name, description in sorted_commands:
lines.append(f"- **`{name}`:** {description}")
lines.append("")
elif module_name == "FlatReviewPresenter":
# Group Go commands at the top
def sort_flat_review_commands(cmd_tuple):
name, _ = cmd_tuple
if name.startswith("Go"):
return (0, name)
return (1, name)
sorted_commands = sorted(info["commands"], key=sort_flat_review_commands)
for name, description in sorted_commands:
lines.append(f"- **`{name}`:** {description}")
lines.append("")
elif module_name == "CaretNavigator":
# Group related navigation commands
def sort_caret_commands(cmd_tuple):
name, _ = cmd_tuple
# Define groups for Character, Word, Line, File
if "Character" in name:
group = 0
elif "Word" in name:
group = 1
elif "Line" in name:
group = 2
elif "File" in name:
group = 3
else:
group = 100
# Within each group: Next, Previous, Start, End, Toggle
if name.startswith("Next"):
order = 0
elif name.startswith("Previous"):
order = 1
elif name.startswith("Start"):
order = 0
elif name.startswith("End"):
order = 1
elif name.startswith("Toggle"):
order = 2
else:
order = 99
return (group, order, name)
sorted_commands = sorted(info["commands"], key=sort_caret_commands)
for name, description in sorted_commands:
lines.append(f"- **`{name}`:** {description}")
lines.append("")
elif module_name == "StructuralNavigator":
groups, other = _group_structural_navigator_commands(info["commands"])
# Show grouped commands by object type first
for obj_type in sorted(groups.keys()):
cmds = groups[obj_type]
display_name = cmds["display_name"]
lines.append(f"##### {display_name}")
lines.append("")
# Sort commands in a specific order: Next, Previous, List, grouped by variant
def sort_key(cmd_tuple):
name, _ = cmd_tuple
# Extract base command and any suffix (like Level1, UnvisitedLink, etc.)
if name.startswith("Next"):
prefix_order = 0
suffix = name[4:] # Remove "Next"
elif name.startswith("Previous"):
prefix_order = 1
suffix = name[8:] # Remove "Previous"
elif name.startswith("List"):
prefix_order = 2
suffix = name[4:] # Remove "List"
else:
prefix_order = 3
suffix = name
# Extract level number or variant for proper ordering
level_or_variant = 0
if "Level" in suffix:
# For headings: extract level number
try:
level_or_variant = int(suffix.split("Level")[1])
except (IndexError, ValueError):
pass
elif "Unvisited" in suffix:
# For links: Unvisited comes after base
level_or_variant = 1
elif "Visited" in suffix:
# For links: Visited comes after Unvisited
level_or_variant = 2
return (level_or_variant, prefix_order, name)
sorted_commands = sorted(cmds["commands"], key=sort_key)
for name, desc in sorted_commands:
lines.append(f"- **`{name}`:** {desc}")
lines.append("")
# Show uncategorized commands at the end
if other:
lines.append("##### Other")
lines.append("")
for name, description in other:
lines.append(f"- **`{name}`:** {description}")
lines.append("")
else:
for name, description in info["commands"]:
lines.append(f"- **`{name}`:** {description}")
lines.append("")
# Parameterized Commands
if info["parameterized_commands"]:
lines.append("#### Parameterized Commands")
lines.append("")
lines.append("**Method:** `org.stormux.Cthulhu.Module.ExecuteParameterizedCommand`")
lines.append("")
for name, description, parameters in info["parameterized_commands"]:
param_list = ", ".join([f"`{pname}` ({ptype})" for pname, ptype in parameters])
if param_list:
lines.append(f"- **`{name}`:** {description} Parameters: {param_list}")
else:
lines.append(f"- **`{name}`:** {description}")
lines.append("")
# Runtime Settings (combine getters and setters)
if info["getters"] or info["setters"]:
lines.append("#### Settings")
lines.append("")
lines.append("**Methods:** `org.stormux.Cthulhu.Module.ExecuteRuntimeGetter` / `org.stormux.Cthulhu.Module.ExecuteRuntimeSetter`")
lines.append("")
lines.append(
"**Parameters:** `PropertyName` (string), "
"`Value` (variant, setter only)"
)
lines.append("")
# Build a merged dictionary of properties
# Prefer setter descriptions as they may contain range/default info
properties = {}
for name, description in info["getters"]:
properties[name] = {"description": description, "getter": True, "setter": False}
for name, description in info["setters"]:
if name in properties:
properties[name]["setter"] = True
properties[name]["description"] = description
else:
properties[name] = {"description": description, "getter": False, "setter": True}
# Output sorted properties with annotations
for name in sorted(properties.keys()):
prop = properties[name]
description = prop["description"]
annotation = ""
if prop["getter"] and not prop["setter"]:
annotation = " (getter only)"
elif prop["setter"] and not prop["getter"]:
annotation = " (setter only)"
else:
# Both getter and setter - change "Returns" or "Sets" to "Gets/Sets"
if description.startswith("Returns "):
description = description.replace("Returns ", "Gets/Sets ", 1)
elif description.startswith("Sets "):
description = description.replace("Sets ", "Gets/Sets ", 1)
lines.append(f"- **`{name}`:** {description}{annotation}")
lines.append("")
return "\n".join(lines)
def generate_documentation():
"""Generate the complete documentation."""
try:
bus = SessionMessageBus()
except DBusError as e:
print(f"Error connecting to D-Bus: {e}", file=sys.stderr)
return None
try:
proxy = bus.get_proxy(SERVICE_NAME, SERVICE_PATH)
except DBusError as e:
print(f"Error connecting to Cthulhu service: {e}", file=sys.stderr)
print("Make sure Cthulhu is running with the D-Bus service enabled.", file=sys.stderr)
return None
system_commands = get_system_commands(proxy)
modules = get_modules(proxy)
module_infos = {}
for module_name in modules:
module_infos[module_name] = get_module_info(bus, module_name)
total_commands = len(system_commands)
total_commands += sum(len(info["commands"]) for info in module_infos.values())
total_commands += sum(len(info["parameterized_commands"]) for info in module_infos.values())
total_getters = sum(len(info["getters"]) for info in module_infos.values())
total_setters = sum(len(info["setters"]) for info in module_infos.values())
lines = []
lines.append("# Cthulhu D-Bus Service Commands Reference")
lines.append("")
lines.append(
f"This document lists all commands ({total_commands}), "
f"runtime getters ({total_getters}), and runtime setters ({total_setters}) available"
)
lines.append("via Cthulhu's D-Bus Remote Controller interface.")
lines.append("")
lines.append("The service can be accessed at:")
lines.append("")
lines.append("- **Service Name:** `org.stormux.Cthulhu.Service`")
lines.append("- **Main Object Path:** `/org/stormux/Cthulhu/Service`")
lines.append("- **Module Object Paths:** `/org/stormux/Cthulhu/Service/ModuleName`")
lines.append("")
lines.append(
"Additional information about using the remote controller can be found in "
"[README-REMOTE-CONTROLLER.md](README-REMOTE-CONTROLLER.md)."
)
lines.append("")
lines.append("---")
lines.append("")
# System commands
lines.append(format_system_commands(system_commands))
lines.append("---")
lines.append("")
# Module commands
lines.append("## Modules")
lines.append("")
lines.append(
"Each module exposes commands, getters, and setters on its object "
"at `/org/stormux/Cthulhu/Service/ModuleName`."
)
lines.append("")
for module_name in modules:
lines.append(format_module_commands(module_name, module_infos[module_name]))
lines.append("---")
lines.append("")
return "\n".join(lines)
def main():
"""Main entry point."""
# Write to parent directory since script is in tools/
script_dir = os.path.dirname(os.path.abspath(__file__))
parent_dir = os.path.dirname(script_dir)
output_file = os.path.join(parent_dir, "REMOTE-CONTROLLER-COMMANDS.md")
print("Generating D-Bus documentation...", file=sys.stderr)
documentation = generate_documentation()
if documentation is None:
print("Failed to generate documentation.", file=sys.stderr)
return 1
try:
with open(output_file, "w", encoding="utf-8") as f:
f.write(documentation)
print(f"Documentation written to {output_file}", file=sys.stderr)
return 0
except IOError as e:
print(f"Error writing to {output_file}: {e}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())