diff --git a/doc/changelog.d/859.added.md b/doc/changelog.d/859.added.md new file mode 100644 index 000000000..de7a8f524 --- /dev/null +++ b/doc/changelog.d/859.added.md @@ -0,0 +1 @@ +rpyc integration \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 9fb8ff1e0..6c5666a68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,6 +84,10 @@ viz = [ "ansys-tools-visualization-interface>=0.2.6", "usd-core==24.8", ] +rpc = [ + "rpyc==6.0.0", + "toolz==0.12.1", +] [project.scripts] ansys-mechanical = "ansys.mechanical.core.run:cli" diff --git a/src/ansys/mechanical/core/embedding/rpc/__init__.py b/src/ansys/mechanical/core/embedding/rpc/__init__.py new file mode 100644 index 000000000..2d377d389 --- /dev/null +++ b/src/ansys/mechanical/core/embedding/rpc/__init__.py @@ -0,0 +1,27 @@ +# Copyright (C) 2022 - 2024 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""RPC and Mechanical service implementation.""" +from .client import Client +from .server import Server +from .service import MechanicalService +from .utils import get_remote_methods, remote_method diff --git a/src/ansys/mechanical/core/embedding/rpc/client.py b/src/ansys/mechanical/core/embedding/rpc/client.py new file mode 100644 index 000000000..9c9ac6217 --- /dev/null +++ b/src/ansys/mechanical/core/embedding/rpc/client.py @@ -0,0 +1,80 @@ +# Copyright (C) 2022 - 2024 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +"""Client for Mechanical services.""" + +import time + +import rpyc + + +class Client: + """Client for connecting to Mechanical services.""" + + def __init__(self, host: str, port: int, timeout: float = 60.0): + """Initialize the client. + + Parameters + ---------- + host : str, optional + IP address to connect to the server. The default is ``None`` + in which case ``localhost`` is used. + port : int, optional + Port to connect to the Mecahnical server. The default is ``None``, + in which case ``10000`` is used. + timeout : float, optional + Maximum allowable time for connecting to the Mechanical server. + The default is ``60.0``. + + """ + self.host = host + self.port = port + self.timeout = timeout + self.connection = None + self.root = None + self._connect() + + def _connect(self): + self._wait_until_ready() + self.connection = rpyc.connect(self.host, self.port) + self.root = self.connection.root + print(f"Connected to {self.host}:{self.port}") + + def _wait_until_ready(self): + t_max = time.time() + self.timeout + while time.time() < t_max: + try: + conn = rpyc.connect(self.host, self.port) + conn.ping() # Simple ping to check if the connection is healthy + conn.close() + print("Server is ready to connect") + break + except: + time.sleep(2) + else: + raise TimeoutError( + f"Server at {self.host}:{self.port} not ready within {self.timeout} seconds." + ) + + def close(self): + """Close the connection.""" + self.connection.close() + print(f"Connection to {self.host}:{self.port} closed") diff --git a/src/ansys/mechanical/core/embedding/rpc/server.py b/src/ansys/mechanical/core/embedding/rpc/server.py new file mode 100644 index 000000000..2cfa51835 --- /dev/null +++ b/src/ansys/mechanical/core/embedding/rpc/server.py @@ -0,0 +1,100 @@ +# Copyright (C) 2022 - 2024 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +"""Remote Procedure Call (RPC) server.""" + +import threading +import time +import typing + +from rpyc.utils.server import ThreadedServer + +import ansys.mechanical.core as mech +import ansys.mechanical.core.embedding.utils as utils + +from .service import MechanicalService + + +class Server: + """Start rpc server.""" + + def __init__( + self, + service: typing.Type[MechanicalService], + port: int = 18861, + version: int = None, + methods: typing.List[typing.Callable] = [], + impl=None, + ): + """Initialize the server.""" + self._exited = False + self._app: mech.App = None + self._poster = None + self._port = port + self._service = service + self._methods = methods + init_thread = threading.Thread(target=self._start_app, args=(version,)) + print("initializing mechanical") + init_thread.start() + + while self._poster is None: + time.sleep(0.01) + continue + print("done initializing mechanical") + + if impl is None: + self._impl = None + else: + self._impl = impl(self._app) + + my_service = self._service(self._app, self._poster, self._methods, self._impl) + self._server = ThreadedServer(my_service, port=self._port) + + def start(self) -> None: + """Start server on specified port.""" + print( + f"starting mechanical application in server." + f"Listening on port {self._port}\n{self._app}" + ) + self._server.start() + """try: + try: + conn.serve_all() + except KeyboardInterrupt: + print("User interrupt!") + finally: + conn.close()""" + self._exited = True + + def _start_app(self, version: int) -> None: + print("starting app") + self._app = mech.App(version=version) + print("started app") + self._poster = self._app.poster + while True: + if self._exited: + break + try: + utils.sleep(40) + except Exception as e: + print(str(e)) + pass + print("out of loop!") diff --git a/src/ansys/mechanical/core/embedding/rpc/service.py b/src/ansys/mechanical/core/embedding/rpc/service.py new file mode 100644 index 000000000..cb1296930 --- /dev/null +++ b/src/ansys/mechanical/core/embedding/rpc/service.py @@ -0,0 +1,123 @@ +# Copyright (C) 2022 - 2024 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +"""Mechanical service.""" + +import rpyc +import toolz + +from .utils import get_remote_methods + + +class MechanicalService(rpyc.Service): + """Starts Mechanical app services.""" + + def __init__(self, app, poster, functions=[], impl=None): + """Initialize the service.""" + super().__init__() + self._app = app + self._poster = poster + self._install_functions(functions) + self._install_class(impl) + + def _install_functions(self, methods): + """Install the given list of methods.""" + [self._install_function(method) for method in methods] + + def _install_class(self, impl): + """Install methods from the given implemented class.""" + if impl is None: + return + for methodname, method in get_remote_methods(impl): + print(f"installing {methodname} of {impl}") + self._install_method(method) + + def on_connect(self, conn): + """Handle client connection.""" + print("Client connected") + print(self._app) + + def on_disconnect(self, conn): + """Handle client disconnection.""" + print("Client disconnected") + + def _curry_method(self, method, realmethodname): + """Curry the given method.""" + + def posted(*args): + def curried(): + original_method = getattr(method._owner, realmethodname) + result = original_method(*args) + return result + + return self._poster.post(curried) + + return posted + + def _curry_function(self, methodname): + """Curry the given function.""" + wrapped = getattr(self, methodname) + curried_method = toolz.curry(wrapped) + + def posted(*args): + def curried(): + return curried_method(self._app, *args) + + return self._poster.post(curried) + + return posted + + def _install_method(self, method): + """Install methods of impl with inner and exposed pairs.""" + exposed_name = f"exposed_{method.__name__}" + inner_name = f"inner_{method.__name__}" + + def inner_method(*args): + """Convert to inner method.""" + result = method(*args) + return result + + def exposed_method(*args): + """Convert to exposed method.""" + f = self._curry_method(method, method.__name__) + result = f(*args) + return result + + setattr(self, inner_name, inner_method) + setattr(self, exposed_name, exposed_method) + + def _install_function(self, function): + """Install a functions with inner and exposed pairs.""" + print(f"Installing {function}") + exposed_name = f"exposed_{function.__name__}" + inner_name = f"inner_{function.__name__}" + + def inner_method(app, *args): + """Convert to inner method.""" + return function(app, *args) + + def exposed_method(*args): + """Convert to exposed method.""" + f = self._curry_function(inner_name) + return f(*args) + + setattr(self, inner_name, inner_method) + setattr(self, exposed_name, exposed_method) diff --git a/src/ansys/mechanical/core/embedding/rpc/utils.py b/src/ansys/mechanical/core/embedding/rpc/utils.py new file mode 100644 index 000000000..9077e0326 --- /dev/null +++ b/src/ansys/mechanical/core/embedding/rpc/utils.py @@ -0,0 +1,81 @@ +# Copyright (C) 2022 - 2024 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +"""Utilities necessary for remote calls.""" +import typing + + +class remote_method: + """Decorator for passing remote methods. + + Parameters + ---------- + func : Callable + The function to be decorated as a remote method. + """ + + def __init__(self, func): + """Initialize with the given function.""" + self._func = func + + def __call__(self, *args, **kwargs): + """Call the stored function with provided arguments.""" + return self._func(*args, **kwargs) + + def __call_method__(self, instance, *args, **kwargs): + """Call the stored function with the instance and provided arguments.""" + return self._func(instance, *args, **kwargs) + + def __get__(self, obj, objtype): + """Return a partially applied method.""" + from functools import partial + + func = partial(self.__call_method__, obj) + func._is_remote = True + func.__name__ = self._func.__name__ + func._owner = obj + return func + + +def get_remote_methods(obj) -> typing.Generator[typing.Tuple[str, typing.Callable], None, None]: + """Yield names and methods of an object's remote methods. + + A remote method is identified by the presence of an attribute `_is_remote` set to `True`. + + Parameters + ---------- + obj: Any + The object to inspect for remote methods. + + Yields + ------ + Generator[Tuple[str, Callable], None, None] + A tuple containing the method name and the method itself + for each remote method found in the object. + """ + for methodname in dir(obj): + if methodname.startswith("__"): + continue + method = getattr(obj, methodname) + if not callable(method): + continue + if hasattr(method, "_is_remote") and method._is_remote is True: + yield methodname, method