Skip to content

feat: First tentative for Plugin Mapdl Mechanism python API #3627

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 19 commits into
base: main
Choose a base branch
from

Conversation

FredAns
Copy link
Contributor

@FredAns FredAns commented Dec 19, 2024

Description

**There's now a new command in MAPDL, called *PLUG, to allow the loading of external dynamic libraries, to dynamically add custom commands and features in MAPDL ( for the time of the session). There are a few plugins already shipped with MAPDL ( The gRPC server lib and the DPF Plugin) we can load and use this way.

I've copy/paste what was done for the XPL API implementation **

Issue linked

There's a TFS User Story for this action. But not already a Github ID.

Checklist

Summary by Sourcery

Implement a first draft of the MAPDL plugin mechanism in the Python API, enabling dynamic loading, unloading, and listing of external plugins with automated command injection.

New Features:

  • Introduce ansPlugin class to manage MAPDL plugins via *PLUG commands
  • Expose a new mapdl.plugins property to load, unload, and list plugins

Enhancements:

  • Add PluginError, PluginLoadError, and PluginUnloadError for plugin-related errors

Documentation:

  • Document the new feature in changelog

Tests:

  • Add tests for plugin loading, unloading, command parsing, and dynamic command injection/removal

@FredAns FredAns requested review from germa89 and clatapie December 19, 2024 15:41
@ansys-reviewer-bot
Copy link
Contributor

Thanks for opening a Pull Request. If you want to perform a review write a comment saying:

@ansys-reviewer-bot review

@github-actions github-actions bot added the new feature Request or proposal for a new feature label Dec 19, 2024
@germa89
Copy link
Collaborator

germa89 commented Dec 19, 2024

I will check it out next year. #HappyVacations

@MaxJPRey
Copy link
Contributor

Can't wait to discuss about it early next year when I visit you @FredAns 😃 .

Copy link
Collaborator

@germa89 germa89 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is good. I would also add a string dunder method __str__, so we can do:

>>> print(mapdl.plugins)
MAPDL Plugins
----------------
DPF                          : feature
ABC                          : feature

Copy link

codecov bot commented Jan 10, 2025

Codecov Report

Attention: Patch coverage is 92.63158% with 7 lines in your changes missing coverage. Please review.

Project coverage is 91.28%. Comparing base (1350840) to head (fec2b24).
Report is 2 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3627      +/-   ##
==========================================
- Coverage   91.73%   91.28%   -0.46%     
==========================================
  Files         187      190       +3     
  Lines       15042    15762     +720     
==========================================
+ Hits        13799    14388     +589     
- Misses       1243     1374     +131     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@germa89
Copy link
Collaborator

germa89 commented Apr 23, 2025

@FredAns

I'm having a look at this PR.

  1. It seems that the 202504160030P01 build does not return anything when I issue *PLUG,LIST:
image
  1. Additionally, can I have a dummy plugin so I can test loading and unloading?

@germa89
Copy link
Collaborator

germa89 commented Jul 16, 2025

@FredAns

I'm having a look at this PR.

  1. It seems that the 202504160030P01 build does not return anything when I issue *PLUG,LIST:
image 2. Additionally, can I have a dummy plugin so I can test loading and unloading?

From @FredAns:

You're right, the LIST is not yet coded ..
You can give a try using the DPF Plugin:

*PLUG,LOAD,PluginDPF

It's part of the Mapdl Distrib, both on Linux and Windows .
The PyMapdl can also be loaded this way on Linux : *PLUG,LOAD,GrpcMapdl

@germa89
Copy link
Collaborator

germa89 commented Jul 16, 2025

For some reason the output of *PLUG command does not go through console output. Hence PyMAPDL cannot catch it?

Nevermind

@germa89
Copy link
Collaborator

germa89 commented Jul 16, 2025

@FredAns can we rely on the *PLUG,LOAD format? So I can parse the new MAPDL commands?

*PLUG command : Open The libPluginDPF.so shared Library
 
               : Plugin libPluginDPF.so loaded, Release 0.76
 
*PLUG command : loading of the InitPluginDPF entry point
 
APDLMath : New command [*DPF] registered

@germa89 germa89 marked this pull request as ready for review July 16, 2025 16:12
@germa89 germa89 requested a review from a team as a code owner July 16, 2025 16:12
@germa89 germa89 requested a review from pyansys-ci-bot July 16, 2025 16:12
Copy link
Contributor

sourcery-ai bot commented Jul 16, 2025

Reviewer's Guide

Adds a new plugin mechanism via a dedicated ansPlugin manager integrated into the Mapdl class, enabling dynamic loading, unloading, and listing of external MAPDL plugins along with automatic command injection and removal.

Sequence diagram for loading a plugin via ansPlugin

sequenceDiagram
    actor User
    participant Mapdl
    participant ansPlugin
    participant MAPDL_Server
    User->>Mapdl: mapdl.plugins.load('PluginDPF')
    Mapdl->>ansPlugin: plugins property (init if needed)
    ansPlugin->>MAPDL_Server: *PLUG,LOAD,PluginDPF
    MAPDL_Server-->>ansPlugin: Response (with registered commands)
    ansPlugin->>ansPlugin: _parse_commands(response)
    ansPlugin->>Mapdl: Inject new commands as methods
    ansPlugin-->>User: Return response
Loading

Sequence diagram for unloading a plugin via ansPlugin

sequenceDiagram
    actor User
    participant Mapdl
    participant ansPlugin
    participant MAPDL_Server
    User->>Mapdl: mapdl.plugins.unload('PluginDPF')
    Mapdl->>ansPlugin: plugins property
    ansPlugin->>MAPDL_Server: *PLUG,UNLOAD,PluginDPF
    MAPDL_Server-->>ansPlugin: Response (with commands to remove)
    ansPlugin->>ansPlugin: _parse_commands(response)
    ansPlugin->>Mapdl: Remove injected commands
    ansPlugin-->>User: Return response
Loading

Class diagram for the new ansPlugin manager and related error classes

classDiagram
    class Mapdl {
        +ansPlugin plugins
    }
    class ansPlugin {
        -weakref _mapdl_weakref
        -str _filename
        -bool _open
        +load(plugin_name, feature="") str
        +unload(plugin_name) str
        +list() list[str]
        -_parse_commands(response) list[str]
        -_set_commands(commands, plugin_name)
        -_deleter_commands(commands, plugin_name)
        -_load_commands(response, plugin_name)
        +_mapdl: Mapdl
        +_log: Logger
    }
    class PluginError {
    }
    class PluginLoadError {
    }
    class PluginUnloadError {
    }
    class MapdlRuntimeError {
    }
    class Logger {
    }
    Mapdl --> ansPlugin : uses
    ansPlugin --> Mapdl : weakref
    ansPlugin --> Logger : uses
    PluginError --|> MapdlRuntimeError
    PluginLoadError --|> PluginError
    PluginUnloadError --|> PluginError
Loading

File-Level Changes

Change Details Files
Integrate plugin manager into Mapdl class
  • import ansPlugin in mapdl_core
  • add _plugin attribute and plugins property with lazy initialization
src/ansys/mapdl/core/mapdl_core.py
Define plugin-specific error hierarchy
  • add PluginError, PluginLoadError, PluginUnloadError classes
src/ansys/mapdl/core/errors.py
Implement ansPlugin manager
  • create ansPlugin class with load, unload, list methods
  • parse plugin responses and inject/delete commands on Mapdl instance
  • use warnings and logging for command registration
src/ansys/mapdl/core/plugin.py
Add unit tests for plugin functionality
  • test loading, unloading, listing, parsing, injection, and deletion behavior
  • fixture to skip tests on unsupported versions
tests/test_plugin.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @FredAns - I've reviewed your changes and they look great!

Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments

### Comment 1
<location> `src/ansys/mapdl/core/plugin.py:102` </location>
<code_context>
+        mapdl = self._mapdl
+
+        for each_command in commands:
+            each_command.replace("*", "star")
+            each_command.replace("/", "slash")
+
+            if hasattr(mapdl, each_command):
</code_context>

<issue_to_address>
String replacements on 'each_command' are not assigned back, so they have no effect.

Since strings are immutable in Python, assign the result of 'replace' back to 'each_command' to ensure the changes take effect.
</issue_to_address>

### Comment 2
<location> `src/ansys/mapdl/core/plugin.py:109` </location>
<code_context>
+                # We are allowing to overwrite existing commands
+                warn(f"Command '{each_command}' already exists in the MAPDL instance.")
+
+            def passer(self, *args, **kwargs):
+                return self.run(*args, **kwargs)
+
+            # Inject docstring
</code_context>

<issue_to_address>
The dynamically created 'passer' function may not bind correctly to the MAPDL instance.

Assigning 'passer' directly may cause binding issues with 'self'. Use functools.partial or types.MethodType to properly bind the method to the MAPDL instance.
</issue_to_address>

### Comment 3
<location> `src/ansys/mapdl/core/plugin.py:217` </location>
<code_context>
+        if "error" in response.lower():
+            raise PluginUnloadError(f"Failed to unload plugin '{plugin_name}'.")
+
+        self._load_commands(response, plugin_name)
+        self._log.info(f"Plugin '{plugin_name}' unloaded successfully.")
+
+        commands = self._parse_commands(response)
</code_context>

<issue_to_address>
Calling '_load_commands' during plugin unload may be unnecessary or confusing.

Reloading commands here may re-add those meant to be removed. Please review if this call is necessary or clarify its intent.
</issue_to_address>

### Comment 4
<location> `src/ansys/mapdl/core/plugin.py:246` </location>
<code_context>
+            raise PluginError("Failed to retrieve the list of loaded plugins.")
+
+        # Parse response and extract plugin names (assuming response is newline-separated text)
+        plugins = [line.strip() for line in response.splitlines() if line.strip()]
+        return plugins
</code_context>

<issue_to_address>
Plugin list parsing may include non-plugin lines if the response format changes.

The implementation treats all non-empty lines as plugin names, which could include unrelated text if the response format changes. Please add validation to ensure only actual plugin names are returned.
</issue_to_address>

### Comment 5
<location> `tests/test_plugin.py:64` </location>
<code_context>
+    assert TEST_PLUGIN in plugins.list(), "Plugin should be loaded"
+
+
+def test_plugin_unload(plugins):
+    plugins.unload(TEST_PLUGIN)
+    assert TEST_PLUGIN not in plugins.list(), "Plugin should be unloaded"
</code_context>

<issue_to_address>
Missing test for unloading a plugin that is not loaded.

Add a test to verify that unloading a plugin that is not loaded raises PluginUnloadError.
</issue_to_address>

### Comment 6
<location> `tests/test_plugin.py:69` </location>
<code_context>
+    assert TEST_PLUGIN not in plugins.list(), "Plugin should be unloaded"
+
+
+def test_parse_commands(plugins, dpf_load_response):
+    commands = plugins._parse_commands(dpf_load_response)
+
</code_context>

<issue_to_address>
No test for _parse_commands with empty or malformed response.

Add tests for _parse_commands to cover empty and malformed responses, verifying it returns an empty list without raising exceptions.
</issue_to_address>

<suggested_fix>
<<<<<<< SEARCH
def test_parse_commands(plugins, dpf_load_response):
    commands = plugins._parse_commands(dpf_load_response)

=======
def test_parse_commands(plugins, dpf_load_response):
    commands = plugins._parse_commands(dpf_load_response)
    # Existing test can have assertions if needed

def test_parse_commands_empty_response(plugins):
    """Test _parse_commands with an empty response."""
    empty_response = ""
    commands = plugins._parse_commands(empty_response)
    assert commands == [], "Expected empty list for empty response"

def test_parse_commands_malformed_response(plugins):
    """Test _parse_commands with a malformed response."""
    malformed_response = "This is not a valid command list"
    commands = plugins._parse_commands(malformed_response)
    assert commands == [], "Expected empty list for malformed response"
>>>>>>> REPLACE

</suggested_fix>

### Comment 7
<location> `tests/test_plugin.py:77` </location>
<code_context>
+    assert "*DPF" in commands, "Expected command '*DPF' should be in the list"
+
+
+def test_load_commands(plugins, dpf_load_response):
+    commands = plugins._parse_commands(dpf_load_response)
+    assert isinstance(commands, list), "Commands should be a list"
</code_context>

<issue_to_address>
No test for _set_commands overwriting existing commands.

Add a test to verify that calling _set_commands with an existing command name triggers the warning and overwrites the command as expected.

Suggested implementation:

```python

def test_load_commands(plugins, dpf_load_response):
    commands = plugins._parse_commands(dpf_load_response)
    assert isinstance(commands, list), "Commands should be a list"
    assert len(commands) > 0, "Commands list should not be empty"

    for command in commands:


def test_set_commands_overwrites_existing(monkeypatch, plugins):
    """
    Test that _set_commands overwrites an existing command and triggers a warning.
    """
    # Setup: Add a command
    plugins._commands = {"EXISTING": lambda: "old"}
    warnings = []

    # Monkeypatch the warning mechanism if it's using warnings.warn or logger.warning
    def fake_warn(msg, *args, **kwargs):
        warnings.append(msg)
    if hasattr(plugins, "logger"):
        monkeypatch.setattr(plugins.logger, "warning", fake_warn)
    else:
        import warnings as pywarnings
        monkeypatch.setattr(pywarnings, "warn", fake_warn)

    # Overwrite the command
    def new_func():
        return "new"
    plugins._set_commands({"EXISTING": new_func})

    # Check that the warning was triggered
    assert any("EXISTING" in str(w) for w in warnings), "Expected warning about overwriting command"

    # Check that the command was overwritten
    assert plugins._commands["EXISTING"] is new_func, "Command should be overwritten with new function"

```

- If your codebase uses a different warning or logging mechanism, adjust the monkeypatching accordingly.
- If `_set_commands` is not a public method, you may need to access it differently or adjust test visibility.
- If the warning is not triggered via `warnings.warn` or `logger.warning`, update the test to capture the correct warning mechanism.
</issue_to_address>

### Comment 8
<location> `tests/test_plugin.py:86` </location>
<code_context>
+        assert hasattr(plugins._mapdl, command)
+
+
+def test_deleter_commands(plugins, dpf_load_response):
+    commands = plugins._parse_commands(dpf_load_response)
+    assert isinstance(commands, list), "Commands should be a list"
</code_context>

<issue_to_address>
No test for _deleter_commands with commands that do not exist.

Please add a test to verify that _deleter_commands handles non-existent command names gracefully without raising errors.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +109 to +110
def passer(self, *args, **kwargs):
return self.run(*args, **kwargs)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): The dynamically created 'passer' function may not bind correctly to the MAPDL instance.

Assigning 'passer' directly may cause binding issues with 'self'. Use functools.partial or types.MethodType to properly bind the method to the MAPDL instance.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
new feature Request or proposal for a new feature
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants