ansys-mechanical-core 0.11.12__py3-none-any.whl → 0.11.13__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,237 @@
1
+ # Copyright (C) 2022 - 2025 ANSYS, Inc. and/or its affiliates.
2
+ # SPDX-License-Identifier: MIT
3
+ #
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in all
13
+ # copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ # SOFTWARE.
22
+ """Client for Mechanical services."""
23
+
24
+ import os
25
+ import pathlib
26
+ import time
27
+
28
+ import rpyc
29
+
30
+ from ansys.mechanical.core.mechanical import DEFAULT_CHUNK_SIZE
31
+
32
+
33
+ class Client:
34
+ """Client for connecting to Mechanical services."""
35
+
36
+ def __init__(self, host: str, port: int, timeout: float = 120.0):
37
+ """Initialize the client.
38
+
39
+ Parameters
40
+ ----------
41
+ host : str, optional
42
+ IP address to connect to the server. The default is ``None``
43
+ in which case ``localhost`` is used.
44
+ port : int, optional
45
+ Port to connect to the Mecahnical server. The default is ``None``,
46
+ in which case ``10000`` is used.
47
+ timeout : float, optional
48
+ Maximum allowable time for connecting to the Mechanical server.
49
+ The default is ``60.0``.
50
+
51
+ """
52
+ self.host = host
53
+ self.port = port
54
+ self.timeout = timeout
55
+ self.connection = None
56
+ self.root = None
57
+ self._connect()
58
+
59
+ def __getattr__(self, attr):
60
+ """Get attribute from the root object."""
61
+ if hasattr(self.root, attr):
62
+ return getattr(self.root, attr)
63
+ propget_name = f"propget_{attr}"
64
+ if hasattr(self.root, propget_name):
65
+ exposed_fget = getattr(self.root, propget_name)
66
+ return exposed_fget()
67
+ return self.__dict__.items[attr]
68
+
69
+ # TODO - implement setattr
70
+ # def __setattr__(self, attr, value):
71
+ # if hasattr(self.root, attr):
72
+ # inner_prop = getattr(self.root.__class__, attr)
73
+ # if isinstance(inner_prop, property):
74
+ # inner_prop.fset(self.root, value)
75
+ # else:
76
+ # super().__setattr__(attr, value)
77
+
78
+ def _connect(self):
79
+ self._wait_until_ready()
80
+ self.connection = rpyc.connect(self.host, self.port)
81
+ self.root = self.connection.root
82
+ print(f"Connected to {self.host}:{self.port}")
83
+ print(f"Installed methods")
84
+
85
+ def _wait_until_ready(self):
86
+ t_max = time.time() + self.timeout
87
+ while time.time() < t_max:
88
+ try:
89
+ conn = rpyc.connect(self.host, self.port)
90
+ conn.ping() # Simple ping to check if the connection is healthy
91
+ conn.close()
92
+ print("Server is ready to connect")
93
+ break
94
+ except:
95
+ time.sleep(2)
96
+ else:
97
+ raise TimeoutError(
98
+ f"Server at {self.host}:{self.port} not ready within {self.timeout} seconds."
99
+ )
100
+
101
+ def close(self):
102
+ """Close the connection."""
103
+ self.connection.close()
104
+ print(f"Connection to {self.host}:{self.port} closed")
105
+
106
+ def upload(
107
+ self,
108
+ file_name,
109
+ file_location_destination=None,
110
+ chunk_size=DEFAULT_CHUNK_SIZE,
111
+ progress_bar=False,
112
+ ):
113
+ """Upload a file to the server."""
114
+ print(f"arg: {file_name}, {file_location_destination}")
115
+ print()
116
+ if not os.path.exists(file_name):
117
+ print(f"File {file_name} does not exist.")
118
+ return
119
+ file_base_name = os.path.basename(file_name)
120
+ remote_path = os.path.join(file_location_destination, file_base_name)
121
+
122
+ with open(file_name, "rb") as f:
123
+ file_data = f.read()
124
+ self.service_upload(remote_path, file_data)
125
+
126
+ print(f"File {file_name} uploaded to {file_location_destination}")
127
+
128
+ def download(
129
+ self,
130
+ files,
131
+ target_dir=None,
132
+ chunk_size=DEFAULT_CHUNK_SIZE,
133
+ progress_bar=None,
134
+ recursive=False,
135
+ ):
136
+ """Download a file from the server."""
137
+ out_files = []
138
+ os.makedirs(target_dir, exist_ok=True)
139
+
140
+ response = self.service_download(files)
141
+
142
+ if isinstance(response, dict) and response["is_directory"]:
143
+ for relative_file_path in response["files"]:
144
+ full_remote_path = os.path.join(files, relative_file_path)
145
+ local_file_path = os.path.join(target_dir, relative_file_path)
146
+ local_file_dir = os.path.dirname(local_file_path)
147
+ os.makedirs(local_file_dir, exist_ok=True)
148
+
149
+ out_file_path = self._download_file(
150
+ full_remote_path, local_file_path, chunk_size, overwrite=True
151
+ )
152
+ else:
153
+ out_file_path = self._download_file(
154
+ files, os.path.join(target_dir, os.path.basename(files)), chunk_size, overwrite=True
155
+ )
156
+ out_files.append(out_file_path)
157
+
158
+ return out_files
159
+
160
+ def _download_file(self, remote_file_path, local_file_path, chunk_size=1024, overwrite=False):
161
+ if os.path.exists(local_file_path) and not overwrite:
162
+ print(f"File {local_file_path} already exists locally. Skipping download.")
163
+ return
164
+ response = self.service_download(remote_file_path)
165
+ if isinstance(response, dict):
166
+ raise ValueError("Expected a file download, but got a directory response.")
167
+ file_data = response
168
+
169
+ # Write the file data to the local path
170
+ with open(local_file_path, "wb") as f:
171
+ f.write(file_data)
172
+
173
+ print(f"File {remote_file_path} downloaded to {local_file_path}")
174
+
175
+ return local_file_path
176
+
177
+ def download_project(self, extensions=None, target_dir=None, progress_bar=False):
178
+ """Download all project files in the working directory of the Mechanical instance."""
179
+ destination_directory = target_dir.rstrip("\\/")
180
+ if destination_directory:
181
+ path = pathlib.Path(destination_directory)
182
+ path.mkdir(parents=True, exist_ok=True)
183
+ else:
184
+ destination_directory = os.getcwd()
185
+ # relative directory?
186
+ if os.path.isdir(destination_directory):
187
+ if not os.path.isabs(destination_directory):
188
+ # construct full path
189
+ destination_directory = os.path.join(os.getcwd(), destination_directory)
190
+ _project_directory = self.project_directory
191
+ _project_directory = _project_directory.rstrip("\\/")
192
+
193
+ # this is where .mechddb resides
194
+ parent_directory = os.path.dirname(_project_directory)
195
+
196
+ list_of_files = []
197
+
198
+ if not extensions:
199
+ files = self.list_files()
200
+ else:
201
+ files = []
202
+ for each_extension in extensions:
203
+ # mechdb resides one level above project directory
204
+ if "mechdb" == each_extension.lower():
205
+ file_temp = os.path.join(parent_directory, f"*.{each_extension}")
206
+ else:
207
+ file_temp = os.path.join(_project_directory, "**", f"*.{each_extension}")
208
+
209
+ list_files = self._get_files(file_temp, recursive=False)
210
+
211
+ files.extend(list_files)
212
+
213
+ for file in files:
214
+ # create similar hierarchy locally
215
+ new_path = file.replace(parent_directory, destination_directory)
216
+ new_path_dir = os.path.dirname(new_path)
217
+ temp_files = self.download(
218
+ files=file, target_dir=new_path_dir, progress_bar=progress_bar
219
+ )
220
+ list_of_files.extend(temp_files)
221
+ return list_of_files
222
+
223
+ @property
224
+ def is_alive(self):
225
+ """Check if the Mechanical instance is alive."""
226
+ try:
227
+ self.connection.ping()
228
+ return True
229
+ except:
230
+ return False
231
+
232
+ def exit(self):
233
+ """Shuts down the Mechanical instance."""
234
+ print("Requesting server shutdown ...")
235
+ self.root.service_exit()
236
+ self.connection.close()
237
+ print("Disconnected from server")
@@ -0,0 +1,382 @@
1
+ # Copyright (C) 2022 - 2025 ANSYS, Inc. and/or its affiliates.
2
+ # SPDX-License-Identifier: MIT
3
+ #
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in all
13
+ # copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ # SOFTWARE.
22
+ """Remote Procedure Call (RPC) server."""
23
+
24
+ import fnmatch
25
+ import os
26
+ import typing
27
+
28
+ import rpyc
29
+ from rpyc.utils.server import ThreadedServer
30
+ import toolz
31
+
32
+ from ansys.mechanical.core.embedding.background import BackgroundApp
33
+ from ansys.mechanical.core.mechanical import port_in_use
34
+
35
+ from .utils import MethodType, get_remote_methods, remote_method
36
+
37
+ # TODO : implement logging
38
+
39
+ PYMECHANICAL_DEFAULT_RPC_PORT = 20000
40
+
41
+
42
+ class MechanicalService(rpyc.Service):
43
+ """Starts Mechanical app services."""
44
+
45
+ def __init__(self, backgroundapp, functions=[], impl=None):
46
+ """Initialize the service."""
47
+ super().__init__()
48
+ self._backgroundapp = backgroundapp
49
+ self._install_functions(functions)
50
+ self._install_class(impl)
51
+ self.EMBEDDED = True
52
+
53
+ def _install_functions(self, methods):
54
+ """Install the given list of methods."""
55
+ [self._install_function(method) for method in methods]
56
+
57
+ def _install_class(self, impl):
58
+ """Install methods from the given implemented class."""
59
+ print("Install class")
60
+ if impl is None:
61
+ return
62
+ print("Installing methods from class")
63
+ for methodname, method, methodtype in get_remote_methods(impl):
64
+ print(f"installing {methodname} of {impl}")
65
+ if methodtype == MethodType.METHOD:
66
+ self._install_method(method)
67
+ elif methodtype == MethodType.PROP:
68
+ self._install_property(method, methodname)
69
+
70
+ def on_connect(self, conn):
71
+ """Handle client connection."""
72
+ print("Client connected")
73
+ print(self._backgroundapp.app)
74
+
75
+ def on_disconnect(self, conn):
76
+ """Handle client disconnection."""
77
+ print("Client disconnected")
78
+
79
+ def _curry_property(self, prop, propname, get: bool):
80
+ """Curry the given property."""
81
+
82
+ def posted(*arg):
83
+ def curried():
84
+ if get:
85
+ return getattr(prop._owner, propname)
86
+ else:
87
+ setattr(prop._owner, propname, *arg)
88
+
89
+ return self._backgroundapp.try_post(curried)
90
+
91
+ return posted
92
+
93
+ def _curry_method(self, method, realmethodname):
94
+ """Curry the given method."""
95
+
96
+ def posted(*args, **kwargs):
97
+ def curried():
98
+ original_method = getattr(method._owner, realmethodname)
99
+ result = original_method(*args, **kwargs)
100
+ return result
101
+
102
+ return self._backgroundapp.try_post(curried)
103
+
104
+ return posted
105
+
106
+ def _curry_function(self, methodname):
107
+ """Curry the given function."""
108
+ wrapped = getattr(self, methodname)
109
+ curried_method = toolz.curry(wrapped)
110
+
111
+ def posted(*args, **kwargs):
112
+ def curried():
113
+ return curried_method(self._app, *args, **kwargs)
114
+
115
+ return self._backgroundapp.try_post(curried)
116
+
117
+ return posted
118
+
119
+ def _install_property(self, prop: property, propname: str):
120
+ """Install property with inner and exposed pairs."""
121
+ if prop.fget:
122
+ exposed_get_name = f"exposed_propget_{propname}"
123
+
124
+ def exposed_propget():
125
+ """Convert to exposed getter."""
126
+ f = self._curry_property(prop.fget, propname, True)
127
+ result = f()
128
+ return result
129
+
130
+ setattr(self, exposed_get_name, exposed_propget)
131
+ if prop.fset:
132
+ exposed_set_name = f"exposed_propset_{propname}"
133
+
134
+ def exposed_propset(arg):
135
+ """Convert to exposed getter."""
136
+ f = self._curry_property(prop.fset, propname, True)
137
+ result = f(arg)
138
+ return result
139
+
140
+ setattr(self, exposed_set_name, exposed_propset)
141
+
142
+ def _install_method(self, method):
143
+ methodname = method.__name__
144
+ self._install_method_with_name(method, methodname, methodname)
145
+
146
+ def _install_method_with_name(self, method, methodname, innername):
147
+ """Install methods of impl with inner and exposed pairs."""
148
+ exposed_name = f"exposed_{methodname}"
149
+
150
+ def exposed_method(*args, **kwargs):
151
+ """Convert to exposed method."""
152
+ f = self._curry_method(method, innername)
153
+ result = f(*args, **kwargs)
154
+ return result
155
+
156
+ setattr(self, exposed_name, exposed_method)
157
+
158
+ def _install_function(self, function):
159
+ """Install a functions with inner and exposed pairs."""
160
+ print(f"Installing {function}")
161
+ exposed_name = f"exposed_{function.__name__}"
162
+ inner_name = f"inner_{function.__name__}"
163
+
164
+ def inner_method(app, *args, **kwargs):
165
+ """Convert to inner method."""
166
+ return function(app, *args, **kwargs)
167
+
168
+ def exposed_method(*args, **kwargs):
169
+ """Convert to exposed method."""
170
+ f = self._curry_function(inner_name)
171
+ return f(*args, **kwargs)
172
+
173
+ setattr(self, inner_name, inner_method)
174
+ setattr(self, exposed_name, exposed_method)
175
+
176
+ def exposed_service_upload(self, remote_path, file_data):
177
+ """Handle file upload request from client."""
178
+ if not remote_path:
179
+ raise ValueError("The remote file path is empty.")
180
+
181
+ remote_dir = os.path.dirname(remote_path)
182
+
183
+ if remote_dir:
184
+ os.makedirs(remote_dir, exist_ok=True)
185
+
186
+ with open(remote_path, "wb") as f:
187
+ f.write(file_data)
188
+
189
+ print(f"File {remote_path} uploaded successfully.")
190
+
191
+ def exposed_service_download(self, remote_path):
192
+ """Handle file download request from client."""
193
+ # Check if the remote file exists
194
+ if not os.path.exists(remote_path):
195
+ raise FileNotFoundError(f"The file {remote_path} does not exist on the server.")
196
+
197
+ if os.path.isdir(remote_path):
198
+ files = []
199
+ for dirpath, _, filenames in os.walk(remote_path):
200
+ for filename in filenames:
201
+ full_path = os.path.join(dirpath, filename)
202
+ relative_path = os.path.relpath(full_path, remote_path)
203
+ files.append(relative_path)
204
+ return {"is_directory": True, "files": files}
205
+
206
+ with open(remote_path, "rb") as f:
207
+ file_data = f.read()
208
+
209
+ print(f"File {remote_path} downloaded successfully.")
210
+ return file_data
211
+
212
+ def exposed_service_exit(self):
213
+ """Exit the server."""
214
+ self._backgroundapp.stop()
215
+ self._backgroundapp = None
216
+
217
+
218
+ class MechanicalEmbeddedServer:
219
+ """Start rpc server."""
220
+
221
+ def __init__(
222
+ self,
223
+ service: typing.Type[rpyc.Service] = MechanicalService,
224
+ port: int = None,
225
+ version: int = None,
226
+ methods: typing.List[typing.Callable] = [],
227
+ impl=None,
228
+ ):
229
+ """Initialize the server."""
230
+ self._exited = False
231
+ self._background_app = BackgroundApp(version=version)
232
+ self._service = service
233
+ self._methods = methods
234
+ print("Initializing Mechanical ...")
235
+
236
+ self._port = self.get_free_port(port)
237
+ if impl is None:
238
+ self._impl = None
239
+ else:
240
+ self._impl = impl(self._background_app.app)
241
+
242
+ my_service = self._service(self._background_app, self._methods, self._impl)
243
+ self._server = ThreadedServer(my_service, port=self._port)
244
+
245
+ @staticmethod
246
+ def get_free_port(port=None):
247
+ """Get free port.
248
+
249
+ If port is not given, it will find a free port starting from PYMECHANICAL_DEFAULT_RPC_PORT.
250
+ """
251
+ if port is None:
252
+ port = PYMECHANICAL_DEFAULT_RPC_PORT
253
+
254
+ while port_in_use(port):
255
+ port += 1
256
+
257
+ return port
258
+
259
+ def start(self) -> None:
260
+ """Start server on specified port."""
261
+ print(
262
+ f"Starting mechanical application in server.\n"
263
+ f"Listening on port {self._port}\n{self._background_app.app}"
264
+ )
265
+ self._server.start()
266
+ """try:
267
+ try:
268
+ conn.serve_all()
269
+ except KeyboardInterrupt:
270
+ print("User interrupt!")
271
+ finally:
272
+ conn.close()"""
273
+ self._exited = True
274
+
275
+ def stop(self) -> None:
276
+ """Stop the server."""
277
+ print("Stopping the server...")
278
+ self._background_app.stop()
279
+ self._server.close()
280
+ self._exited = True
281
+ print("Server stopped.")
282
+
283
+
284
+ class DefaultServiceMethods:
285
+ """Default service methods for MechanicalEmbeddedServer."""
286
+
287
+ def __init__(self, app):
288
+ """Initialize the DefaultServiceMethods."""
289
+ self._app = app
290
+
291
+ def __repr__(self):
292
+ """Return the representation of the instance."""
293
+ return '"ServiceMethods instance"'
294
+
295
+ @remote_method
296
+ def run_python_script(
297
+ self, script: str, enable_logging=False, log_level="WARNING", progress_interval=2000
298
+ ):
299
+ """Run scripts using Internal python engine."""
300
+ result = self._app.execute_script(script)
301
+ return result
302
+
303
+ @remote_method
304
+ def run_python_script_from_file(
305
+ self,
306
+ file_path: str,
307
+ enable_logging=False,
308
+ log_level="WARNING",
309
+ progress_interval=2000,
310
+ ):
311
+ """Run scripts using Internal python engine."""
312
+ return self._app.execute_script_from_file(file_path)
313
+
314
+ @remote_method
315
+ def clear(self):
316
+ """Clear the current project."""
317
+ self._app.new()
318
+
319
+ @property
320
+ @remote_method
321
+ def project_directory(self):
322
+ """Get the project directory."""
323
+ return self._app.execute_script("""ExtAPI.DataModel.Project.ProjectDirectory""")
324
+
325
+ @remote_method
326
+ def list_files(self):
327
+ """List all files in the project directory."""
328
+ list = []
329
+ mechdbPath = self._app.execute_script("""ExtAPI.DataModel.Project.FilePath""")
330
+ if mechdbPath != "":
331
+ list.append(mechdbPath)
332
+ rootDir = self._app.execute_script("""ExtAPI.DataModel.Project.ProjectDirectory""")
333
+
334
+ for dirPath, dirNames, fileNames in os.walk(rootDir):
335
+ for fileName in fileNames:
336
+ list.append(os.path.join(dirPath, fileName))
337
+ files_out = "\n".join(list).splitlines()
338
+ if not files_out: # pragma: no cover
339
+ print("No files listed")
340
+ return files_out
341
+
342
+ @remote_method
343
+ def _get_files(self, files, recursive=False):
344
+ self_files = self.list_files() # to avoid calling it too much
345
+
346
+ if isinstance(files, str):
347
+ if files in self_files:
348
+ list_files = [files]
349
+ elif "*" in files:
350
+ list_files = fnmatch.filter(self_files, files)
351
+ if not list_files:
352
+ raise ValueError(
353
+ f"The `'files'` parameter ({files}) didn't match any file using "
354
+ f"glob expressions in the remote server."
355
+ )
356
+ else:
357
+ raise ValueError(
358
+ f"The `'files'` parameter ('{files}') does not match any file or pattern."
359
+ )
360
+
361
+ elif isinstance(files, (list, tuple)):
362
+ if not all([isinstance(each, str) for each in files]):
363
+ raise ValueError(
364
+ "The parameter `'files'` can be a list or tuple, but it "
365
+ "should only contain strings."
366
+ )
367
+ list_files = files
368
+ else:
369
+ raise ValueError(
370
+ f"The `file` parameter type ({type(files)}) is not supported."
371
+ "Only strings, tuple of strings, or list of strings are allowed."
372
+ )
373
+
374
+ return list_files
375
+
376
+
377
+ class MechanicalDefaultServer(MechanicalEmbeddedServer):
378
+ """Default server with default service methods."""
379
+
380
+ def __init__(self, **kwargs):
381
+ """Initialize the MechanicalDefaultServer."""
382
+ super().__init__(service=MechanicalService, impl=DefaultServiceMethods, **kwargs)