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-05-14

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

# Configuration Preambule
ConfigurationPreambule = """
## **Ask SFPPy ChatBot**
<table><tr><td><a href="https://chatgpt.com/g/g-6780fa0b1180819198ea1d962dd4064c-sfppy" target="_chat" title="SFPPy Chatbot"><img src="assets/SFPPy_social.png" alt="SFPPy – Chatbot" width="64"/></a></td>
<td><strong>Our ChatBot can assist you across all stages of your workflow</strong> – <i>from setup and configuration to finalizing notebooks and reports</i>.<br/> It has been trained on the scientific concepts and internal structure of SFPPy. You can ask it to clarify migration modeling logic, regulatory workflows, or even request it to refactor or adapt example code to your specific needs.</td></tr></table>

---

## **Running SFPPy**
<table><tr><td><img src="assets/SFPPy.svg" alt="SFPPy – Scientific Framework for Food Packaging" width="64"/></td>
<td><strong>SFPPy</strong> enables the <strong>compliance assessment of food contact materials</strong> and facilitates <strong>risk assessments</strong> through <strong>migration modeling</strong>.<br/>The term <strong>"<a href="MigrationModeling/" title="Book on Migrantion Modeling & Risk Assessment" target="_book">Migration Modeling</a></strong> refers to the <strong>simulation of mass transfer</strong> (primarily diffusive) from packaging materials into food.</td></tr></table>

#### **What this guide covers:**
1. **Running SFPPy/example1.py** (or `example2.py`, `example3.py`) immediately after cloning the repository.
2. **Using the object-oriented `patankar` library** to solve custom migration modeling problems.

SFPPy runs on **Windows, Linux, macOS**, and any system supporting **Python 3.x**.  
For the best experience, we recommend using [Jupyter](https://docs.jupyter.org/en/latest/) notebooks, which allow you to execute and document your workflow in a single document—especially useful for compliance testing.

---

### **📌 Prerequisites**
Before running SFPPy, make sure you have:

✅ **A basic understanding of Migration Modeling** → Read this [document]("MigrationModeling/").  
✅ **Python 3.x (≥3.8) installed**.  
✅ **Dependencies installed**:
   - If your Python distribution does **not include scientific libraries** (NumPy, SciPy, Matplotlib, Pandas), install them using:
     ```bash
     pip install -r requirements.txt
     ```
   - Alternatively, follow the **[installation guide](installation.html)** for Conda-based setup.  
✅ **An interactive environment** (recommended):
   - Install [Jupyter](https://docs.jupyter.org/en/latest/) or [Spyder](https://docs.spyder-ide.org/current/index.html) for a better experience.

---

### **📌 Running a Script with SFPPy**

This section explains how to **run `SFPPy/yourscript.py`**, assuming your scripts are stored inside the `SFPPy` directory alongside the example scripts (`example1.py`, `example2.py`, `example3.py`).

Using this approach **avoids system-wide modifications** and ensures that all SFPPy modules are found.

---

#### **🔹 Step 1: Navigate to the SFPPy directory**
```bash
cd SFPPy
```
This ensures that you are inside the **SFPPy project folder** before running a script.

---

#### **🔹 Step 2: Run Your Script**

##### **Option A: Standard Python Execution**
Use the appropriate command based on your operating system:

| **OS**                   | **Shell**      | **Command** ^(*)^                                   |
| ------------------------ | -------------- | --------------------------------------------------- |
| **Linux/macOS**          | `bash`         | `export PYTHONPATH=$(pwd) && python3 yourscript.py` |
| **Windows (cmd.exe)**    | Command Prompt | `set PYTHONPATH=%CD% && py -3 yourscript.py`        |
| **Windows (PowerShell)** | PowerShell     | `$env:PYTHONPATH="$PWD"; & py -3 yourscript.py`     |

<small>^*^ Replace `yourscript.py` with your script (e.g., `example1.py`).</small>

---

#### **🔹 Alternative: Running SFPPy Inside a Conda Environment**

**Option B: Standard Python Execution**. If you installed SFPPy via Conda, follow these steps:

##### **Step 2a: Create the SFPPy Conda Environment**
Run the following command **from the `SFPPy` directory**:
```bash
conda env create -f environment.yml
```
This creates a Conda environment named **`sfppy`** with all required dependencies.

##### **Step 2b: Activate the Environment**
```bash
conda activate sfppy
```

Once the environment is activated, run your script:
```bash
python yourscript.py # or `conda activate sfppy && python yourscript.py`
```
"""

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"# SFPPy 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\n\n")
        f.write('<hr style="border: none; height: 3px; background-color: #4CAF50;" />\n\n\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='patankar')
            # 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#patankar_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"# SFPPy 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\n\n")
        f.write('<hr style="border: none; height: 3px; background-color: #4CAF50;" />\n\n\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='patankar')
            # 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#patankar_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")