#!/usr/bin/env python3 """ Dynamic documentation generator for McRogueFace. Extracts all documentation directly from the compiled module using introspection. """ import os import sys import inspect import datetime import html import re import types from pathlib import Path def transform_doc_links(docstring, format='html', base_url=''): """Transform MCRF_LINK patterns based on output format. Detects pattern: "See also: TEXT (docs/path.md)" Transforms to appropriate format for output type. For HTML/web formats, properly escapes content before inserting HTML tags. """ if not docstring: return docstring link_pattern = r'See also: ([^(]+) \(([^)]+)\)' def replace_link(match): text, ref = match.group(1).strip(), match.group(2).strip() if format == 'html': # Convert docs/foo.md → foo.html and escape for safe HTML href = html.escape(ref.replace('docs/', '').replace('.md', '.html'), quote=True) text_escaped = html.escape(text) return f'

See also: {text_escaped}

' elif format == 'web': # Link to hosted docs and escape for safe HTML web_path = ref.replace('docs/', '').replace('.md', '') href = html.escape(f"{base_url}/{web_path}", quote=True) text_escaped = html.escape(text) return f'

See also: {text_escaped}

' elif format == 'markdown': # Markdown link return f'\n**See also:** [{text}]({ref})' else: # 'python' or default # Keep as plain text for Python docstrings return match.group(0) # For HTML formats, escape the entire docstring first, then process links if format in ('html', 'web'): # Split by the link pattern, escape non-link parts, then reassemble parts = [] last_end = 0 for match in re.finditer(link_pattern, docstring): # Escape the text before this match if match.start() > last_end: parts.append(html.escape(docstring[last_end:match.start()])) # Process the link (replace_link handles escaping internally) parts.append(replace_link(match)) last_end = match.end() # Escape any remaining text after the last match if last_end < len(docstring): parts.append(html.escape(docstring[last_end:])) return ''.join(parts) else: # For non-HTML formats, just do simple replacement return re.sub(link_pattern, replace_link, docstring) # Must be run with McRogueFace as interpreter try: import mcrfpy except ImportError: print("Error: This script must be run with McRogueFace as the interpreter") print("Usage: ./build/mcrogueface --exec generate_dynamic_docs.py") sys.exit(1) def parse_docstring(docstring): """Parse a docstring to extract signature, description, args, returns, and raises.""" if not docstring: return {"signature": "", "description": "", "args": [], "returns": "", "raises": "", "example": ""} lines = docstring.strip().split('\n') result = { "signature": "", "description": "", "args": [], "returns": "", "raises": "", "example": "" } # First line often contains the signature if lines and '(' in lines[0] and ')' in lines[0]: result["signature"] = lines[0].strip() lines = lines[1:] if len(lines) > 1 else [] # Parse the rest current_section = "description" description_lines = [] returns_lines = [] raises_lines = [] example_lines = [] for line in lines: line_lower = line.strip().lower() # Detect section headers if line_lower.startswith("args:") or line_lower.startswith("arguments:"): current_section = "args" continue elif line_lower.startswith("returns:") or line_lower.startswith("return:"): current_section = "returns" # Capture any text on the same line as "Returns:" content_after_colon = line[line.find(':')+1:].strip() if content_after_colon: returns_lines.append(content_after_colon) continue elif line_lower.startswith("raises:") or line_lower.startswith("raise:"): current_section = "raises" # Capture any text on the same line as "Raises:" content_after_colon = line[line.find(':')+1:].strip() if content_after_colon: raises_lines.append(content_after_colon) continue elif line_lower.startswith("example:") or line_lower.startswith("examples:"): current_section = "example" continue elif line_lower.startswith("note:"): # Notes go into description if description_lines: description_lines.append("") description_lines.append(line) continue # Skip blank lines unless we're in example section if not line.strip() and current_section != "example": continue # Add content to appropriate section if current_section == "description": description_lines.append(line) elif current_section == "args" and line.strip(): # Parse argument lines like " filename: Path to file" match = re.match(r'\s+(\w+):\s*(.+)', line) if match: result["args"].append({ "name": match.group(1), "description": match.group(2).strip() }) elif current_section == "returns" and line.strip(): returns_lines.append(line.strip()) elif current_section == "raises" and line.strip(): raises_lines.append(line.strip()) elif current_section == "example": example_lines.append(line) result["description"] = '\n'.join(description_lines).strip() result["returns"] = ' '.join(returns_lines).strip() result["raises"] = ' '.join(raises_lines).strip() result["example"] = '\n'.join(example_lines).strip() return result def get_all_functions(): """Get all module-level functions.""" functions = {} for name in dir(mcrfpy): if name.startswith('_'): continue obj = getattr(mcrfpy, name) if inspect.isbuiltin(obj) or inspect.isfunction(obj): doc_info = parse_docstring(obj.__doc__) functions[name] = { "name": name, "doc": obj.__doc__ or "", "parsed": doc_info } return functions def get_all_classes(): """Get all classes and their methods/properties.""" classes = {} for name in dir(mcrfpy): if name.startswith('_'): continue obj = getattr(mcrfpy, name) if inspect.isclass(obj): class_info = { "name": name, "doc": obj.__doc__ or "", "methods": {}, "properties": {}, "bases": [base.__name__ for base in obj.__bases__ if base.__name__ != 'object'] } # Get methods and properties for attr_name in dir(obj): if attr_name.startswith('__') and attr_name != '__init__': continue try: attr = getattr(obj, attr_name) if callable(attr): method_doc = attr.__doc__ or "" class_info["methods"][attr_name] = { "doc": method_doc, "parsed": parse_docstring(method_doc) } elif isinstance(attr, property): # Pure Python property prop_doc = (attr.fget.__doc__ if attr.fget else "") or "" class_info["properties"][attr_name] = { "doc": prop_doc, "readonly": attr.fset is None } elif isinstance(attr, (types.GetSetDescriptorType, types.MemberDescriptorType)): # C++ extension property (PyGetSetDef or PyMemberDef) prop_doc = attr.__doc__ or "" # Check if docstring indicates read-only (convention: "read-only" in description) readonly = "read-only" in prop_doc.lower() class_info["properties"][attr_name] = { "doc": prop_doc, "readonly": readonly } except: pass classes[name] = class_info return classes def get_constants(): """Get module constants.""" constants = {} for name in dir(mcrfpy): if name.startswith('_') or name[0].islower(): continue obj = getattr(mcrfpy, name) if not (inspect.isclass(obj) or callable(obj)): constants[name] = { "name": name, "value": repr(obj) if not name.startswith('default_') else f"<{name}>", "type": type(obj).__name__ } return constants def get_module_attributes(): """#356: dynamic module attributes (current_scene, scenes, timers, ...). They are getset descriptors on type(mcrfpy) rather than module-dict entries, so the dir()+getattr() sweep above cannot describe them: getattr returns the live *value* (None, at generation time), not the declared type. Read the descriptor. """ import types as _types attrs = {} for name, descr in vars(type(mcrfpy)).items(): if name.startswith('_'): continue if isinstance(descr, _types.GetSetDescriptorType): doc = descr.__doc__ or "" attrs[name] = { "name": name, "doc": doc, "readonly": "read-only" in doc.lower(), } return attrs def generate_html_docs(): """Generate HTML documentation.""" functions = get_all_functions() classes = get_all_classes() constants = get_constants() module_attrs = get_module_attributes() html_content = f""" McRogueFace API Reference

McRogueFace API Reference

Generated on {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}

This documentation was dynamically generated from the compiled module.

Threading: any access to mcrfpy objects from a non-main thread must happen inside with mcrfpy.lock():; behavior outside the lock is undefined. See docs/threading-model.md.

Table of Contents

Module Attributes

Dynamic attributes of the mcrfpy module itself.

""" for attr_name in sorted(module_attrs.keys()): attr_info = module_attrs[attr_name] ro = ' (read-only)' if attr_info["readonly"] else '' html_content += f"""

mcrfpy.{attr_name}{ro}

{html.escape(attr_info["doc"])}

""" html_content += """

Functions

""" # Generate function documentation for func_name in sorted(functions.keys()): func_info = functions[func_name] parsed = func_info["parsed"] # Use signature if available (already includes name), otherwise use just name heading = parsed['signature'] if parsed['signature'] else f"{func_name}(...)" html_content += f"""

{heading}

""" if parsed['description']: description = transform_doc_links(parsed['description'], format='html') html_content += f"

{description}

\n" if parsed['args']: html_content += "

Arguments:

\n \n" if parsed['returns']: html_content += f"

Returns: {html.escape(parsed['returns'])}

\n" if parsed['raises']: html_content += f"

Raises: {html.escape(parsed['raises'])}

\n" if parsed['example']: html_content += f"

Example:

\n
{html.escape(parsed['example'])}
\n" html_content += "
\n" # Generate class documentation html_content += "\n

Classes

\n" for class_name in sorted(classes.keys()): class_info = classes[class_name] html_content += f"""

{class_name}

""" if class_info['bases']: html_content += f"

Inherits from: {', '.join(class_info['bases'])}

\n" if class_info['doc']: html_content += f"

{html.escape(class_info['doc'])}

\n" # Properties if class_info['properties']: html_content += "

Properties:

\n \n" # Methods if class_info['methods']: html_content += "

Methods:

\n" for method_name, method_info in sorted(class_info['methods'].items()): if method_name == '__init__': continue parsed = method_info['parsed'] # Use signature if available (already includes name), otherwise use just name heading = parsed['signature'] if parsed['signature'] else f"{method_name}(...)" html_content += f"""
{heading}
""" if parsed['description']: description = transform_doc_links(parsed['description'], format='html') html_content += f"

{description}

\n" if parsed['args']: html_content += "
\n" for arg in parsed['args']: html_content += f"
{arg['name']}: {html.escape(arg['description'])}
\n" html_content += "
\n" if parsed['returns']: html_content += f"

Returns: {html.escape(parsed['returns'])}

\n" if parsed['raises']: html_content += f"

Raises: {html.escape(parsed['raises'])}

\n" html_content += "
\n" html_content += "
\n" # Constants html_content += "\n

Constants

\n \n" html_content += """
""" # Write the file output_path = Path("docs/api_reference_dynamic.html") output_path.parent.mkdir(exist_ok=True) output_path.write_text(html_content) print(f"Generated {output_path}") print(f"Found {len(functions)} functions, {len(classes)} classes, {len(constants)} constants") def generate_markdown_docs(): """Generate Markdown documentation.""" functions = get_all_functions() classes = get_all_classes() constants = get_constants() module_attrs = get_module_attributes() md_content = f"""# McRogueFace API Reference *Generated on {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}* *This documentation was dynamically generated from the compiled module.* **Threading:** any access to mcrfpy objects from a non-main thread must happen inside `with mcrfpy.lock():`; behavior outside the lock is undefined. See [docs/threading-model.md](threading-model.md). ## Table of Contents - [Functions](#functions) - [Classes](#classes) """ # Add classes to TOC for class_name in sorted(classes.keys()): md_content += f" - [{class_name}](#{class_name.lower()})\n" md_content += "- [Module Attributes](#module-attributes)\n" md_content += "- [Constants](#constants)\n\n" # Module attributes (#356) md_content += "## Module Attributes\n\n" md_content += "Dynamic attributes of the `mcrfpy` module itself.\n\n" for attr_name in sorted(module_attrs.keys()): attr_info = module_attrs[attr_name] ro = " *(read-only)*" if attr_info["readonly"] else "" md_content += f"### `mcrfpy.{attr_name}`{ro}\n\n{attr_info['doc']}\n\n" # Functions md_content += "## Functions\n\n" for func_name in sorted(functions.keys()): func_info = functions[func_name] parsed = func_info["parsed"] # Use signature if available (already includes name), otherwise use just name heading = parsed['signature'] if parsed['signature'] else f"{func_name}(...)" md_content += f"### `{heading}`\n\n" if parsed['description']: description = transform_doc_links(parsed['description'], format='markdown') md_content += f"{description}\n\n" if parsed['args']: md_content += "**Arguments:**\n" for arg in parsed['args']: md_content += f"- `{arg['name']}`: {arg['description']}\n" md_content += "\n" if parsed['returns']: md_content += f"**Returns:** {parsed['returns']}\n\n" if parsed['raises']: md_content += f"**Raises:** {parsed['raises']}\n\n" if parsed['example']: md_content += f"**Example:**\n```python\n{parsed['example']}\n```\n\n" # Classes md_content += "## Classes\n\n" for class_name in sorted(classes.keys()): class_info = classes[class_name] md_content += f"### {class_name}\n\n" if class_info['bases']: md_content += f"*Inherits from: {', '.join(class_info['bases'])}*\n\n" if class_info['doc']: md_content += f"{class_info['doc']}\n\n" # Properties if class_info['properties']: md_content += "**Properties:**\n" for prop_name, prop_info in sorted(class_info['properties'].items()): readonly = " *(read-only)*" if prop_info['readonly'] else "" md_content += f"- `{prop_name}`{readonly}" if prop_info['doc']: md_content += f": {prop_info['doc']}" md_content += "\n" md_content += "\n" # Methods if class_info['methods']: md_content += "**Methods:**\n\n" for method_name, method_info in sorted(class_info['methods'].items()): if method_name == '__init__': continue parsed = method_info['parsed'] # Use signature if available (already includes name), otherwise use just name heading = parsed['signature'] if parsed['signature'] else f"{method_name}(...)" md_content += f"#### `{heading}`\n\n" if parsed['description']: description = transform_doc_links(parsed['description'], format='markdown') md_content += f"{description}\n\n" if parsed['args']: md_content += "**Arguments:**\n" for arg in parsed['args']: md_content += f"- `{arg['name']}`: {arg['description']}\n" md_content += "\n" if parsed['returns']: md_content += f"**Returns:** {parsed['returns']}\n\n" if parsed['raises']: md_content += f"**Raises:** {parsed['raises']}\n\n" # Constants md_content += "## Constants\n\n" for const_name, const_info in sorted(constants.items()): md_content += f"- `{const_name}` ({const_info['type']}): {const_info['value']}\n" # Write the file output_path = Path("docs/API_REFERENCE_DYNAMIC.md") output_path.parent.mkdir(exist_ok=True) output_path.write_text(md_content) print(f"Generated {output_path}") if __name__ == "__main__": print("Generating dynamic documentation from mcrfpy module...") generate_html_docs() generate_markdown_docs() print("Documentation generation complete!") sys.exit(0) # #350: headless --exec must declare its outcome