Module generate_mermaid

Expand source code
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# Automatic Python module class and method documentation generation with Mermaid diagrams
#
# Inputs:
#  - Takes a list of modules from STDIN (one per line, in dot notation)
#  - First argument: output Markdown file
#  - Second argument: path to modules_details.json
#
# Outputs:
#  - A single Markdown file containing:
#    1) A tabulated list of methods for each module (method name, first paragraph of docstring, number of lines, __version__)
#    2) A Mermaid graph diagram for the class inheritance of that module
#    3) Links to class examples in HTML documentation
#    4) Date of generation

# INRAE\\Olivier Vitrac
# Email: olivier.vitrac@agroparistech.fr
# Last Revised:** 2025-01-09

import sys
import os
import importlib
import inspect
from datetime import datetime
import json

# Configuration Preambule
ConfigurationPreambule = """
## Configuration

To run Pizza3, ensure your Python environment is properly configured. This setup assumes that you are operating from the `Pizza3/` directory, which contains the `pizza/` and `docs/` folders. The main folder (`$mainfolder`) is set to the absolute path of the current directory.

### Setting Up PYTHONPATH

Add the following paths to your `PYTHONPATH` environment variable to allow Python to locate the Pizza3 library and its dependencies:

```bash
# Define mainfolder as the absolute path
mainfolder=$(realpath .)

# Dynamically retrieve Python paths
python_lib=$(python -c "import sysconfig; print(sysconfig.get_path('stdlib'))")
lib_dynload=$(python -c "import sysconfig; print(sysconfig.get_path('platlib'))")
site_packages=$(python -c "import site; print(site.getsitepackages()[0])")

# Paths to include in PYTHONPATH
additional_paths=(
    "$mainfolder"
    "$mainfolder/pizza"
    "$python_lib"
    "$lib_dynload"
    "$site_packages"
)

# Set PYTHONPATH dynamically
export PYTHONPATH=$(IFS=:; echo "${additional_paths[*]}")
echo "PYTHONPATH set to: $PYTHONPATH"

# Test Python Interpreter Configuration:
# You should read "Pizza library initialized successfully"
python -c "import pizza"
```

>**Note:**
>
>- The `.ipython/` directory has been excluded as it is not required for Pizza3.
>- If you're using a virtual environment, ensure it's activated before running the script to automatically include the virtual environment's `site-packages` in `PYTHONPATH`.
>- Replace `PYTHON_VERSION` with your actual Python version if different from `3.10`.
>- If you're not using `Conda`, adjust the environment creation and activation commands accordingly.

After setting up the `PYTHONPATH`, you can proceed to explore the Pizza3 modules below.

### Launch `example2.py`
You can now generate your first LAMMPS code from Python and run it with [LAMMPS-GUI](https://github.com/lammps/lammps/releases).
```bash
    mkdir -p ./tmp     # create the output folder tmp/ if it does not exist
    python example2.py # run example2
```
"""

def class_name(cls):
    """Return a string representing the class"""
    return cls.__name__

def classes_tree(module, base_module=None):
    """Extract classes and their inheritances starting from a base module."""
    if base_module is None:
        base_module = module.__name__
    module_classes = set()
    inheritances = []
    def inspect_class(cls):
        if class_name(cls) not in module_classes:
            if cls.__module__.startswith(base_module):
                module_classes.add(class_name(cls))
                for base in cls.__bases__:
                    inheritances.append((class_name(base), class_name(cls)))
                    inspect_class(base)
    for cls in [e for e in module.__dict__.values() if inspect.isclass(e)]:
        inspect_class(cls)
    return module_classes, inheritances

def classes_tree_to_mermaid(module_classes, inheritances):
    lines = ["graph TD;"]
    for c in sorted(module_classes):
        lines.append(f"{c}")
    for (a, b) in sorted(inheritances):
        lines.append(f"{a} --> {b}")
    return "\n".join(lines)

def get_first_paragraph(docstring):
    if not docstring:
        return ""
    lines = docstring.strip().split('\n')
    # First paragraph is up to a blank line or the end of lines
    paragraph = []
    for line in lines:
        if line.strip() == "":
            break
        paragraph.append(line.strip())
    return " ".join(paragraph)

def get_method_info(obj):
    """Return a dict with method name, docstring first paragraph, number of lines, and __version__."""
    info = {}
    if not (inspect.isfunction(obj) or inspect.ismethod(obj)):
        return info

    name = obj.__name__
    doc = inspect.getdoc(obj)
    first_par = get_first_paragraph(doc)
    try:
        lines, _ = inspect.getsourcelines(obj)
        nlines = len(lines)
    except (OSError, TypeError):
        nlines = 0

    # Attempt to find __version__ attribute:
    # Methods typically won't have their own __version__, but we can check their __globals__ if function
    __version__ = None
    if inspect.isfunction(obj):
        __version__ = obj.__globals__.get('__version__', None)
    # If not found there, we can try attributes
    if hasattr(obj, '__version__'):
        __version__ = getattr(obj, '__version__', __version__)

    info['name'] = name
    info['doc'] = first_par
    info['nlines'] = nlines
    info['__version__'] = __version__ if __version__ is not None else ""
    return info

def get_module_methods(module):
    """Get methods and functions defined at the top-level and within classes of the module."""
    # Top-level functions
    top_methods = []
    for name, obj in inspect.getmembers(module, inspect.isfunction):
        if obj.__module__ == module.__name__:
            top_methods.append(get_method_info(obj))

    # Class methods
    class_methods = []
    for name, cls in inspect.getmembers(module, inspect.isclass):
        if cls.__module__ == module.__name__:
            for mname, mobj in inspect.getmembers(cls, inspect.isfunction):
                # To filter out inherited methods from outside modules if desired:
                if mobj.__module__ == module.__name__:
                    method_info = get_method_info(mobj)
                    method_info['class'] = cls.__name__
                    class_methods.append(method_info)

    return top_methods, class_methods

def main():
    if len(sys.argv) < 3:
        print("Usage: generate_mermaid.py output_markdown.md modules_details.json < modules_list.txt", file=sys.stderr)
        sys.exit(1)

    output_markdown = sys.argv[1]
    modules_details_json = sys.argv[2]

    if not os.path.isfile(modules_details_json):
        print(f"Error: JSON file '{modules_details_json}' does not exist.", file=sys.stderr)
        sys.exit(1)

    # === NEW SECTION: Load Module Details from JSON ===
    # This section loads the modules_details.json to access module details
    with open(modules_details_json, 'r', encoding='utf-8') as f:
        modules_details = json.load(f)
    # === END OF NEW SECTION ===

    # Read module names from STDIN
    modules = [line.strip() for line in sys.stdin if line.strip()]

    with open(output_markdown, "w", encoding="utf-8") as f:
        # Write the main header and generation date
        f.write(f"# Pizza Modules Documentation\n\n")
        f.write(f"Generated on: **{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}**\n\n")
        f.write('<hr style="border: none; height: 1px; background-color: #e0e0e0;" />\n\n')
        # Write the configuration preambule
        f.write(ConfigurationPreambule + "\n\n")
        f.write('<hr style="border: none; height: 1px; background-color: #e0e0e0;" />\n\n')

        # Add a styled, responsive Table of Contents with three columns
        f.write('<a id="table_of_contents" name="table_of_contents"></a>\n')
        f.write("## Main Classes\n\n")
        f.write('<div id="table_of_contents" style="display: flex; flex-wrap: wrap; gap: 20px; justify-content: space-between; overflow-x: auto; padding: 10px;">\n')
        for idx, mod_name in enumerate(sorted(modules)):
            f.write('<div style="flex: 1 1 calc(33.33% - 20px); min-width: 200px;">\n')
            f.write(f'<a href="#{mod_name.replace(".", "_")}" style="text-decoration: none; font-weight: bold;">\n')
            f.write(f'{idx + 1}. {mod_name}\n')
            f.write('</a>\n')
            f.write('</div>\n')
        f.write('</div>\n\n')

        # Iterate over each module
        for i, mod_name in enumerate(sorted(modules)):
            # Attempt to import the module
            try:
                mod = importlib.import_module(mod_name)
            except Exception as e:
                f.write(f"## Module `{mod_name}`\n\n")
                f.write(f"**Error importing module**: {e}\n\n")
                continue

            # anchor
            f.write(f'<a id="{mod_name.replace(".", "_")}" name="{mod_name.replace(".", "_")}"></a>\n')
            # Generate anchors for TOC, previous module, and next module
            prev_mod = sorted(modules)[i - 1] if i > 0 else None
            next_mod = sorted(modules)[i + 1] if i < len(modules) - 1 else None
            # Navigation links container with alignment styling
            f.write('<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; font-size: 0.8em;">')
            # Add link to the previous module if it exists (left-aligned)
            if prev_mod:
                f.write(f'<a href="#{prev_mod.replace(".", "_")}" title="Go to Previous Module: {prev_mod}" style="text-decoration: none;">⬅️ Previous</a>\n')
            else:
                f.write('<span></span>\n')  # Empty span to maintain layout consistency
            # Add link to TOC (centered)
            f.write('<a href="#table_of_contents" title="Back to Table of Contents" style="text-decoration: none;">⬆️ TOC</a>\n')
            # Add link to the next module if it exists (right-aligned)
            if next_mod:
                f.write(f'<a href="#{next_mod.replace(".", "_")}" title="Go to Next Module: {next_mod}" style="text-decoration: none;">➡️ Next</a>\n')
            else:
                f.write('<span></span>\n')  # Empty span to maintain layout consistency
            # Close the navigation container
            f.write('</div>\n\n')

            # Start the module section
            f.write(f'## Module `{mod_name}`\n\n')

            # Get class inheritance tree
            module_classes, inheritances = classes_tree(mod, base_module='pizza')
            # Generate Mermaid diagram if classes are found
            if module_classes:
                f.write("### Class Inheritance Diagram\n")
                f.write("```mermaid\n")
                f.write(classes_tree_to_mermaid(module_classes, inheritances))
                f.write("\n```\n\n")
            else:
                f.write("*No classes found in this module.*\n\n")

            # === NEW SECTION: Add Link to Class Examples ===
            # This section retrieves module details from the JSON and adds a link to class_examples.html
            module_detail = modules_details.get(mod_name)
            if module_detail:
                local_path = module_detail.get("local_path", "")
                number_of_examples = module_detail.get("number_of_examples", 0)
                url_anchor = module_detail.get("url_anchor", "")
                # Construct relative link (e.g., "class_examples.html#pizza_dscript")
                link = f"class_examples.html{url_anchor}"
                # Add the link in bold with module name and number of examples
                f.write(f"**[Class Examples for `{local_path}` ({number_of_examples})]({link})**\n\n")
            else:
                # If module details are missing, indicate that class examples are not available
                f.write(f"**Class Examples:** Not available.\n\n")
            # === END OF NEW SECTION ===

            # Get methods of the module
            top_methods, class_methods = get_module_methods(mod)

            if top_methods or class_methods:
                f.write("### Methods Table\n\n")
                # Write the table header
                f.write("| Class | Method | Docstring First Paragraph | # Lines | __version__ |\n")
                f.write("|-------|---------|---------------------------|---------|-------------|\n")
                # Write top-level methods
                for m in top_methods:
                    f.write(f"| (module-level) | `{m['name']}` | {m['doc']} | {m['nlines']} | {m['__version__']} |\n")
                # Write class methods
                for m in class_methods:
                    f.write(f"| `{m.get('class','')}` | `{m['name']}` | {m['doc']} | {m['nlines']} | {m['__version__']} |\n")
                f.write("\n")
            else:
                f.write("*No methods found in this module.*\n\n")

if __name__ == "__main__":
    main()

Functions

def class_name(cls)

Return a string representing the class

Expand source code
def class_name(cls):
    """Return a string representing the class"""
    return cls.__name__
def classes_tree(module, base_module=None)

Extract classes and their inheritances starting from a base module.

Expand source code
def classes_tree(module, base_module=None):
    """Extract classes and their inheritances starting from a base module."""
    if base_module is None:
        base_module = module.__name__
    module_classes = set()
    inheritances = []
    def inspect_class(cls):
        if class_name(cls) not in module_classes:
            if cls.__module__.startswith(base_module):
                module_classes.add(class_name(cls))
                for base in cls.__bases__:
                    inheritances.append((class_name(base), class_name(cls)))
                    inspect_class(base)
    for cls in [e for e in module.__dict__.values() if inspect.isclass(e)]:
        inspect_class(cls)
    return module_classes, inheritances
def classes_tree_to_mermaid(module_classes, inheritances)
Expand source code
def classes_tree_to_mermaid(module_classes, inheritances):
    lines = ["graph TD;"]
    for c in sorted(module_classes):
        lines.append(f"{c}")
    for (a, b) in sorted(inheritances):
        lines.append(f"{a} --> {b}")
    return "\n".join(lines)
def get_first_paragraph(docstring)
Expand source code
def get_first_paragraph(docstring):
    if not docstring:
        return ""
    lines = docstring.strip().split('\n')
    # First paragraph is up to a blank line or the end of lines
    paragraph = []
    for line in lines:
        if line.strip() == "":
            break
        paragraph.append(line.strip())
    return " ".join(paragraph)
def get_method_info(obj)

Return a dict with method name, docstring first paragraph, number of lines, and version.

Expand source code
def get_method_info(obj):
    """Return a dict with method name, docstring first paragraph, number of lines, and __version__."""
    info = {}
    if not (inspect.isfunction(obj) or inspect.ismethod(obj)):
        return info

    name = obj.__name__
    doc = inspect.getdoc(obj)
    first_par = get_first_paragraph(doc)
    try:
        lines, _ = inspect.getsourcelines(obj)
        nlines = len(lines)
    except (OSError, TypeError):
        nlines = 0

    # Attempt to find __version__ attribute:
    # Methods typically won't have their own __version__, but we can check their __globals__ if function
    __version__ = None
    if inspect.isfunction(obj):
        __version__ = obj.__globals__.get('__version__', None)
    # If not found there, we can try attributes
    if hasattr(obj, '__version__'):
        __version__ = getattr(obj, '__version__', __version__)

    info['name'] = name
    info['doc'] = first_par
    info['nlines'] = nlines
    info['__version__'] = __version__ if __version__ is not None else ""
    return info
def get_module_methods(module)

Get methods and functions defined at the top-level and within classes of the module.

Expand source code
def get_module_methods(module):
    """Get methods and functions defined at the top-level and within classes of the module."""
    # Top-level functions
    top_methods = []
    for name, obj in inspect.getmembers(module, inspect.isfunction):
        if obj.__module__ == module.__name__:
            top_methods.append(get_method_info(obj))

    # Class methods
    class_methods = []
    for name, cls in inspect.getmembers(module, inspect.isclass):
        if cls.__module__ == module.__name__:
            for mname, mobj in inspect.getmembers(cls, inspect.isfunction):
                # To filter out inherited methods from outside modules if desired:
                if mobj.__module__ == module.__name__:
                    method_info = get_method_info(mobj)
                    method_info['class'] = cls.__name__
                    class_methods.append(method_info)

    return top_methods, class_methods
def main()
Expand source code
def main():
    if len(sys.argv) < 3:
        print("Usage: generate_mermaid.py output_markdown.md modules_details.json < modules_list.txt", file=sys.stderr)
        sys.exit(1)

    output_markdown = sys.argv[1]
    modules_details_json = sys.argv[2]

    if not os.path.isfile(modules_details_json):
        print(f"Error: JSON file '{modules_details_json}' does not exist.", file=sys.stderr)
        sys.exit(1)

    # === NEW SECTION: Load Module Details from JSON ===
    # This section loads the modules_details.json to access module details
    with open(modules_details_json, 'r', encoding='utf-8') as f:
        modules_details = json.load(f)
    # === END OF NEW SECTION ===

    # Read module names from STDIN
    modules = [line.strip() for line in sys.stdin if line.strip()]

    with open(output_markdown, "w", encoding="utf-8") as f:
        # Write the main header and generation date
        f.write(f"# Pizza Modules Documentation\n\n")
        f.write(f"Generated on: **{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}**\n\n")
        f.write('<hr style="border: none; height: 1px; background-color: #e0e0e0;" />\n\n')
        # Write the configuration preambule
        f.write(ConfigurationPreambule + "\n\n")
        f.write('<hr style="border: none; height: 1px; background-color: #e0e0e0;" />\n\n')

        # Add a styled, responsive Table of Contents with three columns
        f.write('<a id="table_of_contents" name="table_of_contents"></a>\n')
        f.write("## Main Classes\n\n")
        f.write('<div id="table_of_contents" style="display: flex; flex-wrap: wrap; gap: 20px; justify-content: space-between; overflow-x: auto; padding: 10px;">\n')
        for idx, mod_name in enumerate(sorted(modules)):
            f.write('<div style="flex: 1 1 calc(33.33% - 20px); min-width: 200px;">\n')
            f.write(f'<a href="#{mod_name.replace(".", "_")}" style="text-decoration: none; font-weight: bold;">\n')
            f.write(f'{idx + 1}. {mod_name}\n')
            f.write('</a>\n')
            f.write('</div>\n')
        f.write('</div>\n\n')

        # Iterate over each module
        for i, mod_name in enumerate(sorted(modules)):
            # Attempt to import the module
            try:
                mod = importlib.import_module(mod_name)
            except Exception as e:
                f.write(f"## Module `{mod_name}`\n\n")
                f.write(f"**Error importing module**: {e}\n\n")
                continue

            # anchor
            f.write(f'<a id="{mod_name.replace(".", "_")}" name="{mod_name.replace(".", "_")}"></a>\n')
            # Generate anchors for TOC, previous module, and next module
            prev_mod = sorted(modules)[i - 1] if i > 0 else None
            next_mod = sorted(modules)[i + 1] if i < len(modules) - 1 else None
            # Navigation links container with alignment styling
            f.write('<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; font-size: 0.8em;">')
            # Add link to the previous module if it exists (left-aligned)
            if prev_mod:
                f.write(f'<a href="#{prev_mod.replace(".", "_")}" title="Go to Previous Module: {prev_mod}" style="text-decoration: none;">⬅️ Previous</a>\n')
            else:
                f.write('<span></span>\n')  # Empty span to maintain layout consistency
            # Add link to TOC (centered)
            f.write('<a href="#table_of_contents" title="Back to Table of Contents" style="text-decoration: none;">⬆️ TOC</a>\n')
            # Add link to the next module if it exists (right-aligned)
            if next_mod:
                f.write(f'<a href="#{next_mod.replace(".", "_")}" title="Go to Next Module: {next_mod}" style="text-decoration: none;">➡️ Next</a>\n')
            else:
                f.write('<span></span>\n')  # Empty span to maintain layout consistency
            # Close the navigation container
            f.write('</div>\n\n')

            # Start the module section
            f.write(f'## Module `{mod_name}`\n\n')

            # Get class inheritance tree
            module_classes, inheritances = classes_tree(mod, base_module='pizza')
            # Generate Mermaid diagram if classes are found
            if module_classes:
                f.write("### Class Inheritance Diagram\n")
                f.write("```mermaid\n")
                f.write(classes_tree_to_mermaid(module_classes, inheritances))
                f.write("\n```\n\n")
            else:
                f.write("*No classes found in this module.*\n\n")

            # === NEW SECTION: Add Link to Class Examples ===
            # This section retrieves module details from the JSON and adds a link to class_examples.html
            module_detail = modules_details.get(mod_name)
            if module_detail:
                local_path = module_detail.get("local_path", "")
                number_of_examples = module_detail.get("number_of_examples", 0)
                url_anchor = module_detail.get("url_anchor", "")
                # Construct relative link (e.g., "class_examples.html#pizza_dscript")
                link = f"class_examples.html{url_anchor}"
                # Add the link in bold with module name and number of examples
                f.write(f"**[Class Examples for `{local_path}` ({number_of_examples})]({link})**\n\n")
            else:
                # If module details are missing, indicate that class examples are not available
                f.write(f"**Class Examples:** Not available.\n\n")
            # === END OF NEW SECTION ===

            # Get methods of the module
            top_methods, class_methods = get_module_methods(mod)

            if top_methods or class_methods:
                f.write("### Methods Table\n\n")
                # Write the table header
                f.write("| Class | Method | Docstring First Paragraph | # Lines | __version__ |\n")
                f.write("|-------|---------|---------------------------|---------|-------------|\n")
                # Write top-level methods
                for m in top_methods:
                    f.write(f"| (module-level) | `{m['name']}` | {m['doc']} | {m['nlines']} | {m['__version__']} |\n")
                # Write class methods
                for m in class_methods:
                    f.write(f"| `{m.get('class','')}` | `{m['name']}` | {m['doc']} | {m['nlines']} | {m['__version__']} |\n")
                f.write("\n")
            else:
                f.write("*No methods found in this module.*\n\n")