ansys-mechanical-core 0.11.12__py3-none-any.whl → 0.11.14__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.
Files changed (29) hide show
  1. ansys/mechanical/core/__init__.py +0 -1
  2. ansys/mechanical/core/_version.py +48 -48
  3. ansys/mechanical/core/embedding/app.py +681 -610
  4. ansys/mechanical/core/embedding/background.py +11 -2
  5. ansys/mechanical/core/embedding/enum_importer.py +0 -1
  6. ansys/mechanical/core/embedding/global_importer.py +50 -0
  7. ansys/mechanical/core/embedding/imports.py +30 -58
  8. ansys/mechanical/core/embedding/initializer.py +6 -4
  9. ansys/mechanical/core/embedding/logger/__init__.py +219 -219
  10. ansys/mechanical/core/embedding/messages.py +190 -0
  11. ansys/mechanical/core/embedding/resolver.py +48 -41
  12. ansys/mechanical/core/embedding/rpc/__init__.py +36 -0
  13. ansys/mechanical/core/embedding/rpc/client.py +259 -0
  14. ansys/mechanical/core/embedding/rpc/server.py +403 -0
  15. ansys/mechanical/core/embedding/rpc/utils.py +118 -0
  16. ansys/mechanical/core/embedding/runtime.py +22 -0
  17. ansys/mechanical/core/embedding/transaction.py +51 -0
  18. ansys/mechanical/core/feature_flags.py +1 -0
  19. ansys/mechanical/core/ide_config.py +226 -212
  20. ansys/mechanical/core/mechanical.py +2399 -2324
  21. ansys/mechanical/core/misc.py +176 -176
  22. ansys/mechanical/core/pool.py +712 -712
  23. ansys/mechanical/core/run.py +321 -321
  24. {ansys_mechanical_core-0.11.12.dist-info → ansys_mechanical_core-0.11.14.dist-info}/METADATA +40 -27
  25. ansys_mechanical_core-0.11.14.dist-info/RECORD +52 -0
  26. {ansys_mechanical_core-0.11.12.dist-info → ansys_mechanical_core-0.11.14.dist-info}/WHEEL +1 -1
  27. ansys_mechanical_core-0.11.12.dist-info/RECORD +0 -45
  28. {ansys_mechanical_core-0.11.12.dist-info → ansys_mechanical_core-0.11.14.dist-info}/entry_points.txt +0 -0
  29. {ansys_mechanical_core-0.11.12.dist-info → ansys_mechanical_core-0.11.14.dist-info/licenses}/LICENSE +0 -0
@@ -1,2324 +1,2399 @@
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
-
23
- """Connect to Mechanical gRPC server and issues commands."""
24
- import atexit
25
- from contextlib import closing
26
- import datetime
27
- import fnmatch
28
- from functools import wraps
29
- import glob
30
- import os
31
- import pathlib
32
- import socket
33
- import threading
34
- import time
35
- import weakref
36
-
37
- import ansys.api.mechanical.v0.mechanical_pb2 as mechanical_pb2
38
- import ansys.api.mechanical.v0.mechanical_pb2_grpc as mechanical_pb2_grpc
39
- import ansys.platform.instancemanagement as pypim
40
- from ansys.platform.instancemanagement import Instance
41
- import ansys.tools.path as atp
42
- import grpc
43
-
44
- import ansys.mechanical.core as pymechanical
45
- from ansys.mechanical.core import LOG
46
- from ansys.mechanical.core.errors import (
47
- MechanicalExitedError,
48
- MechanicalRuntimeError,
49
- VersionError,
50
- protect_grpc,
51
- )
52
- from ansys.mechanical.core.launcher import MechanicalLauncher
53
- from ansys.mechanical.core.misc import (
54
- check_valid_ip,
55
- check_valid_port,
56
- check_valid_start_instance,
57
- threaded,
58
- )
59
-
60
- # Checking if tqdm is installed.
61
- # If it is, the default value for progress_bar is true.
62
- try:
63
- from tqdm import tqdm
64
-
65
- _HAS_TQDM = True
66
- """Whether or not tqdm is installed."""
67
- except ModuleNotFoundError: # pragma: no cover
68
- _HAS_TQDM = False
69
-
70
- # Default 256 MB message length
71
- MAX_MESSAGE_LENGTH = int(os.environ.get("PYMECHANICAL_MAX_MESSAGE_LENGTH", 256 * 1024**2))
72
- """Default message length."""
73
-
74
- # Chunk sizes for streaming and file streaming
75
- DEFAULT_CHUNK_SIZE = 256 * 1024 # 256 kB
76
- """Default chunk size."""
77
- DEFAULT_FILE_CHUNK_SIZE = 1024 * 1024 # 1MB
78
- """Default file chunk size."""
79
-
80
-
81
- def setup_logger(loglevel="INFO", log_file=True, mechanical_instance=None):
82
- """Initialize the logger for the given mechanical instance."""
83
- # Return existing log if this function has already been called
84
- if hasattr(setup_logger, "log"):
85
- return setup_logger.log
86
- else:
87
- setup_logger.log = LOG.add_instance_logger("Mechanical", mechanical_instance)
88
-
89
- setup_logger.log.setLevel(loglevel)
90
-
91
- if log_file:
92
- if isinstance(log_file, str):
93
- setup_logger.log.log_to_file(filename=log_file, level=loglevel)
94
-
95
- return setup_logger.log
96
-
97
-
98
- def suppress_logging(func):
99
- """Decorate a function to suppress the logging for a Mechanical instance."""
100
-
101
- @wraps(func)
102
- def wrapper(*args, **kwargs):
103
- mechanical = args[0]
104
- prior_log_level = mechanical.log.level
105
- if prior_log_level != "CRITICAL":
106
- mechanical.set_log_level("CRITICAL")
107
-
108
- out = func(*args, **kwargs)
109
-
110
- if prior_log_level != "CRITICAL":
111
- mechanical.set_log_level(prior_log_level)
112
-
113
- return out
114
-
115
- return wrapper
116
-
117
-
118
- LOCALHOST = "127.0.0.1"
119
- """Localhost address."""
120
-
121
- MECHANICAL_DEFAULT_PORT = 10000
122
- """Default Mechanical port."""
123
-
124
- GALLERY_INSTANCE = [None]
125
- """List of gallery instances."""
126
-
127
-
128
- def _cleanup_gallery_instance(): # pragma: no cover
129
- """Clean up any leftover instances of Mechanical from building the gallery."""
130
- if GALLERY_INSTANCE[0] is not None:
131
- mechanical = Mechanical(
132
- ip=GALLERY_INSTANCE[0]["ip"],
133
- port=GALLERY_INSTANCE[0]["port"],
134
- )
135
- mechanical.exit(force=True)
136
-
137
-
138
- atexit.register(_cleanup_gallery_instance)
139
-
140
-
141
- def port_in_use(port, host=LOCALHOST):
142
- """Check whether a port is in use at the given host.
143
-
144
- You must actually *bind* the address. Just checking if you can create
145
- a socket is insufficient because it is possible to run into permission
146
- errors like::
147
-
148
- An attempt was made to access a socket in a way forbidden by its
149
- access permissions.
150
- """
151
- with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
152
- if sock.connect_ex((host, port)) == 0:
153
- return True
154
- else:
155
- return False
156
-
157
-
158
- def check_ports(port_range, ip="localhost"):
159
- """Check the state of ports in a port range."""
160
- ports = {}
161
- for port in port_range:
162
- ports[port] = port_in_use(port, ip)
163
- return ports
164
-
165
-
166
- def close_all_local_instances(port_range=None, use_thread=True):
167
- """Close all Mechanical instances within a port range.
168
-
169
- You can use this method when cleaning up from a failed pool or
170
- batch run.
171
-
172
- Parameters
173
- ----------
174
- port_range : list, optional
175
- List of a range of ports to use when cleaning up Mechanical. The
176
- default is ``None``, in which case the ports managed by
177
- PyMechanical are used.
178
-
179
- use_thread : bool, optional
180
- Whether to use threads to close the Mechanical instances.
181
- The default is ``True``. So this call will return immediately.
182
-
183
- Examples
184
- --------
185
- Close all Mechanical instances connected on local ports.
186
-
187
- >>> import ansys.mechanical.core as pymechanical
188
- >>> pymechanical.close_all_local_instances()
189
-
190
- """
191
- if port_range is None:
192
- port_range = pymechanical.LOCAL_PORTS
193
-
194
- @threaded
195
- def close_mechanical_threaded(port, name="Closing Mechanical instance in a thread"):
196
- close_mechanical(port, name)
197
-
198
- def close_mechanical(port, name="Closing Mechanical instance"):
199
- try:
200
- mechanical = Mechanical(port=port)
201
- LOG.debug(f"{name}: {mechanical.name}.")
202
- mechanical.exit(force=True)
203
- except OSError: # pragma: no cover
204
- pass
205
-
206
- ports = check_ports(port_range)
207
- for port_temp, state in ports.items():
208
- if state:
209
- if use_thread:
210
- close_mechanical_threaded(port_temp)
211
- else:
212
- close_mechanical(port_temp)
213
-
214
-
215
- def create_ip_file(ip, path):
216
- """Create the ``mylocal.ip`` file needed to change the IP address of the gRPC server."""
217
- file_name = os.path.join(path, "mylocal.ip")
218
- with open(file_name, "w", encoding="utf-8") as f:
219
- f.write(ip)
220
-
221
-
222
- def get_mechanical_path(allow_input=True):
223
- """Get path.
224
-
225
- Deprecated - use `ansys.tools.path.get_mechanical_path` instead
226
- """
227
- return atp.get_mechanical_path(allow_input)
228
-
229
-
230
- def check_valid_mechanical():
231
- """Change to see if the default Mechanical path is valid.
232
-
233
- Example (windows)
234
- -----------------
235
-
236
- >>> from ansys.mechanical.core import mechanical
237
- >>> from ansys.tools.path import change_default_mechanical_path
238
- >>> mechanical_path = 'C:/Program Files/ANSYS Inc/v251/aisol/bin/win64/AnsysWBU.exe'
239
- >>> change_default_mechanical_path(mechanical_pth)
240
- >>> mechanical.check_valid_mechanical()
241
- True
242
-
243
-
244
- """
245
- mechanical_path = atp.get_mechanical_path(False)
246
- if mechanical_path is None:
247
- return False
248
- mechanical_version = atp.version_from_path("mechanical", mechanical_path)
249
- return not (mechanical_version < 232 and os.name != "posix")
250
-
251
-
252
- def change_default_mechanical_path(exe_loc):
253
- """Change default path.
254
-
255
- Deprecated - use `ansys.tools.path.change_default_mechanical_path` instead.
256
- """
257
- return atp.change_default_mechanical_path(exe_loc)
258
-
259
-
260
- def save_mechanical_path(exe_loc=None): # pragma: no cover
261
- """Save path.
262
-
263
- Deprecated - use `ansys.tools.path.save_mechanical_path` instead.
264
- """
265
- return atp.save_mechanical_path(exe_loc)
266
-
267
-
268
- client_to_server_loglevel = {
269
- "DEBUG": 1,
270
- "INFO": 2,
271
- "WARN": 3,
272
- "WARNING": 3,
273
- "ERROR": 4,
274
- "CRITICAL": 5,
275
- }
276
-
277
-
278
- class Mechanical(object):
279
- """Connects to a gRPC Mechanical server and allows commands to be passed."""
280
-
281
- # Required by `_name` method to be defined before __init__ be
282
- _ip = None
283
- _port = None
284
-
285
- def __init__(
286
- self,
287
- ip=None,
288
- port=None,
289
- timeout=60.0,
290
- loglevel="WARNING",
291
- log_file=False,
292
- log_mechanical=None,
293
- cleanup_on_exit=False,
294
- channel=None,
295
- remote_instance=None,
296
- keep_connection_alive=True,
297
- **kwargs,
298
- ):
299
- """Initialize the member variable based on the arguments.
300
-
301
- Parameters
302
- ----------
303
- ip : str, optional
304
- IP address to connect to the server. The default is ``None``
305
- in which case ``localhost`` is used.
306
- port : int, optional
307
- Port to connect to the Mecahnical server. The default is ``None``,
308
- in which case ``10000`` is used.
309
- timeout : float, optional
310
- Maximum allowable time for connecting to the Mechanical server.
311
- The default is ``60.0``.
312
- loglevel : str, optional
313
- Level of messages to print to the console. The default is ``WARNING``.
314
-
315
- - ``ERROR`` prints only error messages.
316
- - ``WARNING`` prints warning and error messages.
317
- - ``INFO`` prints info, warning and error messages.
318
- - ``DEBUG`` prints debug, info, warning and error messages.
319
-
320
- log_file : bool, optional
321
- Whether to copy the messages to a file named ``logs.log``, which is
322
- located where the Python script is executed. The default is ``False``.
323
- log_mechanical : str, optional
324
- Path to the output file on the local disk for writing every script
325
- command to. The default is ``None``. However, you might set
326
- ``"log_mechanical='pymechanical_log.txt"`` to write all commands that are
327
- sent to Mechanical via PyMechanical in this file so that you can use them
328
- to run a script within Mechanical without PyMechanical.
329
- cleanup_on_exit : bool, optional
330
- Whether to exit Mechanical when Python exits. The default is ``False``,
331
- in which case Mechanical is not exited when the garbage for this Mechanical
332
- instance is collected.
333
- channel : grpc.Channel, optional
334
- gRPC channel to use for the connection. The default is ``None``.
335
- You can use this parameter as an alternative to the ``ip`` and ``port``
336
- parameters.
337
- remote_instance : ansys.platform.instancemanagement.Instance
338
- Corresponding remote instance when Mechanical is launched
339
- through PyPIM. The default is ``None``. If a remote instance
340
- is specified, this instance is deleted when the
341
- :func:`mecahnical.exit <ansys.mechanical.core.Mechanical.exit>`
342
- function is called.
343
- keep_connection_alive : bool, optional
344
- Whether to keep the gRPC connection alive by running a background thread
345
- and making dummy calls for remote connections. The default is ``True``.
346
-
347
- Examples
348
- --------
349
- Connect to a Mechanical instance already running on locally on the
350
- default port (``10000``).
351
-
352
- >>> from ansys.mechanical import core as pymechanical
353
- >>> mechanical = pymechanical.Mechanical()
354
-
355
- Connect to a Mechanical instance running on the LAN on a default port.
356
-
357
- >>> mechanical = pymechanical.Mechanical('192.168.1.101')
358
-
359
- Connect to a Mechanical instance running on the LAN on a non-default port.
360
-
361
- >>> mechanical = pymechanical.Mechanical('192.168.1.101', port=60001)
362
-
363
- If you want to customize the channel, you can connect directly to gRPC channels.
364
- For example, if you want to create an insecure channel with a maximum message
365
- length of 8 MB, you would run:
366
-
367
- >>> import grpc
368
- >>> channel_temp = grpc.insecure_channel(
369
- ... '127.0.0.1:10000',
370
- ... options=[
371
- ... ("grpc.max_receive_message_length", 8*1024**2),
372
- ... ],
373
- ... )
374
- >>> mechanical = pymechanical.Mechanical(channel=channel_temp)
375
- """
376
- self._remote_instance = remote_instance
377
- self._channel = channel
378
- self._keep_connection_alive = keep_connection_alive
379
-
380
- self._locked = False # being used within MechanicalPool
381
-
382
- # ip could be a machine name. Convert it to an IP address.
383
- ip_temp = ip
384
- if channel is not None:
385
- if ip is not None or port is not None:
386
- raise ValueError(
387
- "If `channel` is specified, neither `port` nor `ip` can be specified."
388
- )
389
- elif ip is None:
390
- ip_temp = "127.0.0.1"
391
- else:
392
- ip_temp = socket.gethostbyname(ip) # Converting ip or host name to ip
393
-
394
- self._ip = ip_temp
395
- self._port = port
396
-
397
- self._start_parm = kwargs
398
-
399
- self._cleanup_on_exit = cleanup_on_exit
400
- self._busy = False # used to check if running a command on the server
401
-
402
- self._local = ip_temp in ["127.0.0.1", "127.0.1.1", "localhost"]
403
- if "local" in kwargs: # pragma: no cover # allow this to be overridden
404
- self._local = kwargs["local"]
405
-
406
- self._health_response_queue = None
407
- self._exiting = False
408
- self._exited = None
409
-
410
- self._version = None
411
-
412
- if port is None:
413
- port = MECHANICAL_DEFAULT_PORT
414
- self._port = port
415
-
416
- self._stub = None
417
- self._timeout = timeout
418
-
419
- if channel is None:
420
- self._channel = self._create_channel(ip_temp, port)
421
- else:
422
- self._channel = channel
423
-
424
- self._logLevel = loglevel
425
- self._log_file = log_file
426
- self._log_mechanical = log_mechanical
427
-
428
- self._log = LOG.add_instance_logger(self.name, self, level=loglevel) # instance logger
429
- # adding a file handler to the logger
430
- if log_file:
431
- if not isinstance(log_file, str):
432
- log_file = "instance.log"
433
- self._log.log_to_file(filename=log_file, level=loglevel)
434
-
435
- self._log_file_mechanical = log_mechanical
436
- if log_mechanical:
437
- if not isinstance(log_mechanical, str):
438
- self._log_file_mechanical = "pymechanical_log.txt"
439
- else:
440
- self._log_file_mechanical = log_mechanical
441
-
442
- # temporarily disable logging
443
- # useful when we run some dummy calls
444
- self._disable_logging = False
445
-
446
- if self._local:
447
- self.log_info("Mechanical connection is treated as local.")
448
- else:
449
- self.log_info("Mechanical connection is treated as remote.")
450
-
451
- # connect and validate to the channel
452
- self._multi_connect(timeout=timeout)
453
-
454
- self.log_info("Mechanical is ready to accept grpc calls.")
455
-
456
- def __del__(self): # pragma: no cover
457
- """Clean up on exit."""
458
- if self._cleanup_on_exit:
459
- try:
460
- self.exit(force=True)
461
- except grpc.RpcError as e:
462
- self.log_error(f"exit: {e}")
463
-
464
- # def _set_log_level(self, level):
465
- # """Set an alias for the log level."""
466
- # self.set_log_level(level)
467
-
468
- @property
469
- def log(self):
470
- """Log associated with the current Mechanical instance."""
471
- return self._log
472
-
473
- @property
474
- def version(self) -> str:
475
- """Get the Mechanical version based on the instance.
476
-
477
- Examples
478
- --------
479
- Get the version of the connected Mechanical instance.
480
-
481
- >>> mechanical.version
482
- '251'
483
- """
484
- if self._version is None:
485
- try:
486
- self._disable_logging = True
487
- script = (
488
- 'clr.AddReference("Ans.Utilities")\n'
489
- "import Ansys\n"
490
- "config = Ansys.Utilities.ApplicationConfiguration.DefaultConfiguration\n"
491
- "config.VersionInfo.VersionString"
492
- )
493
- self._version = self.run_python_script(script)
494
- except grpc.RpcError: # pragma: no cover
495
- raise
496
- finally:
497
- self._disable_logging = False
498
- return self._version
499
-
500
- @property
501
- def name(self):
502
- """Name (unique identifier) of the Mechanical instance."""
503
- try:
504
- if self._channel is not None:
505
- if self._remote_instance is not None: # pragma: no cover
506
- return f"GRPC_{self._channel._channel._channel.target().decode()}"
507
- else:
508
- return f"GRPC_{self._channel._channel.target().decode()}"
509
- except Exception as e: # pragma: no cover
510
- LOG.error(f"Error getting the Mechanical instance name: {str(e)}")
511
-
512
- return f"GRPC_instance_{id(self)}" # pragma: no cover
513
-
514
- @property
515
- def busy(self):
516
- """Return True when the Mechanical gRPC server is executing a command."""
517
- return self._busy
518
-
519
- @property
520
- def locked(self):
521
- """Instance is in use within a pool."""
522
- return self._locked
523
-
524
- @locked.setter
525
- def locked(self, new_value):
526
- """Instance is in use within a pool."""
527
- self._locked = new_value
528
-
529
- def _multi_connect(self, n_attempts=5, timeout=60):
530
- """Try to connect over a series of attempts to the channel.
531
-
532
- Parameters
533
- ----------
534
- n_attempts : int, optional
535
- Number of connection attempts. The default is ``5``.
536
- timeout : float, optional
537
- Maximum allowable time in seconds for establishing a connection.
538
- The default is ``60``.
539
- """
540
- # This prevents a single failed connection from blocking other attempts
541
- connected = False
542
- attempt_timeout = timeout / n_attempts
543
- self.log_debug(
544
- f"timetout:{timeout} n_attempts:{n_attempts} attempt_timeout={attempt_timeout}"
545
- )
546
-
547
- max_time = time.time() + timeout
548
- i = 1
549
- while time.time() < max_time and i <= n_attempts:
550
- self.log_debug(f"Connection attempt {i} with attempt timeout {attempt_timeout}s")
551
- connected = self._connect(timeout=attempt_timeout)
552
-
553
- if connected:
554
- self.log_debug(f"Connection attempt {i} succeeded.")
555
- break
556
-
557
- i += 1
558
- else: # pragma: no cover
559
- self.log_debug(
560
- f"Reached either maximum amount of connection attempts "
561
- f"({n_attempts}) or timeout ({timeout} s)."
562
- )
563
-
564
- if not connected: # pragma: no cover
565
- raise IOError(f"Unable to connect to Mechanical instance at {self._channel_str}.")
566
-
567
- @property
568
- def _channel_str(self):
569
- """Target string, generally in the form of ``ip:port``, such as ``127.0.0.1:10000``."""
570
- if self._channel is not None:
571
- if self._remote_instance is not None:
572
- return self._channel._channel._channel.target().decode() # pragma: no cover
573
- else:
574
- return self._channel._channel.target().decode()
575
- return "" # pragma: no cover
576
-
577
- def _connect(self, timeout=12, enable_health_check=False):
578
- """Connect a gRPC channel to a remote or local Mechanical instance.
579
-
580
- Parameters
581
- ----------
582
- timeout : float
583
- Maximum allowable time in seconds for establishing a connection. The
584
- default is ``12``.
585
- enable_health_check : bool, optional
586
- Whether to enable a check to see if the connection is healthy.
587
- The default is ``False``.
588
- """
589
- self._state = grpc.channel_ready_future(self._channel)
590
- self._stub = mechanical_pb2_grpc.MechanicalServiceStub(self._channel)
591
-
592
- # verify connection
593
- time_start = time.time()
594
- while ((time.time() - time_start) < timeout) and not self._state._matured:
595
- time.sleep(0.01)
596
-
597
- if not self._state._matured: # pragma: no cover
598
- return False
599
-
600
- self.log_debug("Established a connection to the Mechanical gRPC server.")
601
-
602
- self.wait_till_mechanical_is_ready(timeout)
603
-
604
- # keeps Mechanical session alive
605
- self._timer = None
606
- if not self._local and self._keep_connection_alive: # pragma: no cover
607
- self._initialised = threading.Event()
608
- self._t_trigger = time.time()
609
- self._t_delay = 30
610
- self._timer = threading.Thread(
611
- target=Mechanical._threaded_heartbeat, args=(weakref.proxy(self),)
612
- )
613
- self._timer.daemon = True
614
- self._timer.start()
615
-
616
- # enable health check
617
- if enable_health_check: # pragma: no cover
618
- self._enable_health_check()
619
-
620
- self.__server_version = None
621
-
622
- return True
623
-
624
- def _enable_health_check(self): # pragma: no cover
625
- """Place the status of the health check in the health response queue."""
626
- # lazy imports here to speed up module load
627
- from grpc_health.v1 import health_pb2, health_pb2_grpc
628
-
629
- def _consume_responses(response_iterator, response_queue):
630
- try:
631
- for response in response_iterator:
632
- response_queue.put(response)
633
- # NOTE: We're doing absolutely nothing with this as
634
- # this point since the server-side health check
635
- # doesn't change state.
636
- except Exception:
637
- if self._exiting:
638
- return
639
- self._exited = True
640
- raise MechanicalExitedError(
641
- "Lost connection with the Mechanical gRPC server."
642
- ) from None
643
-
644
- # enable health check
645
- from queue import Queue
646
-
647
- request = health_pb2.HealthCheckRequest()
648
- self._health_stub = health_pb2_grpc.HealthStub(self._channel)
649
- rendezvous = self._health_stub.Watch(request)
650
-
651
- # health check feature implemented after 2023 R1
652
- try:
653
- status = rendezvous.next()
654
- except Exception as err:
655
- if err.code().name != "UNIMPLEMENTED":
656
- raise err
657
- return
658
-
659
- if status.status != health_pb2.HealthCheckResponse.SERVING:
660
- raise MechanicalRuntimeError(
661
- "Cannot enable health check and/or connect to the Mechanical server."
662
- )
663
-
664
- self._health_response_queue = Queue()
665
-
666
- # allow main process to exit by setting daemon to true
667
- thread = threading.Thread(
668
- target=_consume_responses,
669
- args=(rendezvous, self._health_response_queue),
670
- daemon=True,
671
- )
672
- thread.start()
673
-
674
- def _threaded_heartbeat(self): # pragma: no cover
675
- """To call from a thread to verify that a Mechanical instance is alive."""
676
- self._initialised.set()
677
- while True:
678
- if self._exited:
679
- break
680
- try:
681
- time.sleep(self._t_delay)
682
- if not self.is_alive:
683
- break
684
- except ReferenceError:
685
- break
686
- # except Exception:
687
- # continue
688
-
689
- def _create_channel(self, ip, port):
690
- """Create an unsecured gRPC channel."""
691
- check_valid_ip(ip)
692
-
693
- # open the channel
694
- channel_str = f"{ip}:{port}"
695
- LOG.debug(f"Opening insecure channel at {channel_str}.")
696
- return grpc.insecure_channel(
697
- channel_str,
698
- options=[
699
- ("grpc.max_receive_message_length", MAX_MESSAGE_LENGTH),
700
- ],
701
- )
702
-
703
- @property
704
- def is_alive(self) -> bool:
705
- """Whether there is an active connection to the Mechanical gRPC server."""
706
- if self._exited:
707
- return False
708
-
709
- if self._busy: # pragma: no cover
710
- return True
711
-
712
- try: # pragma: no cover
713
- self._make_dummy_call()
714
- return True
715
- except grpc.RpcError:
716
- return False
717
-
718
- @staticmethod
719
- def set_log_level(loglevel):
720
- """Set the log level.
721
-
722
- Parameters
723
- ----------
724
- loglevel : str, int
725
- Level of logging. Options are ``"DEBUG"``, ``"INFO"``, ``"WARNING"``
726
- and ``"ERROR"``.
727
-
728
- Examples
729
- --------
730
- Set the log level to the ``"DEBUG"`` level.
731
-
732
- # >>> mechanical.set_log_level('DEBUG')
733
- #
734
- # Set the log level to info
735
- #
736
- # >>> mechanical.set_log_level('INFO')
737
- #
738
- # Set the log level to warning
739
- #
740
- # >>> mechanical.set_log_level('WARNING')
741
- #
742
- # Set the log level to error
743
- #
744
- # >>> mechanical.set_log_level('ERROR')
745
- """
746
- if isinstance(loglevel, str):
747
- loglevel = loglevel.upper()
748
- setup_logger(loglevel=loglevel)
749
-
750
- def get_product_info(self):
751
- """Get product information by running a script on the Mechanical gRPC server."""
752
-
753
- def _get_jscript_product_info_command():
754
- return (
755
- 'ExtAPI.Application.ScriptByName("jscript").ExecuteCommand'
756
- '("var productInfo = DS.Script.getProductInfo();returnFromScript(productInfo);")'
757
- )
758
-
759
- def _get_python_product_info_command():
760
- return (
761
- 'clr.AddReference("Ansys.Mechanical.Application")\n'
762
- "Ansys.Mechanical.Application.ProductInfo.ProductInfoAsString"
763
- )
764
-
765
- try:
766
- self._disable_logging = True
767
- if int(self.version) >= 232:
768
- script = _get_python_product_info_command()
769
- else:
770
- script = _get_jscript_product_info_command()
771
- return self.run_python_script(script)
772
- except grpc.RpcError:
773
- raise
774
- finally:
775
- self._disable_logging = False
776
-
777
- @suppress_logging
778
- def __repr__(self):
779
- """Get the user-readable string form of the Mechanical instance."""
780
- try:
781
- if self._exited:
782
- return "Mechanical exited."
783
- return self.get_product_info()
784
- except grpc.RpcError:
785
- return "Error getting product info."
786
-
787
- def launch(self, cleanup_on_exit=True):
788
- """Launch Mechanical in batch or UI mode.
789
-
790
- Parameters
791
- ----------
792
- cleanup_on_exit : bool, optional
793
- Whether to exit Mechanical when Python exits. The default is ``True``.
794
- When ``False``, Mechanical is not exited when the garbage for this
795
- Mechanical instance is collected.
796
- """
797
- if not self._local:
798
- raise RuntimeError("Can only launch with a local instance of Mechanical.")
799
-
800
- # let us respect the current cleanup behavior
801
- if self._cleanup_on_exit:
802
- self.exit()
803
-
804
- exec_file = self._start_parm.get("exec_file", get_mechanical_path(allow_input=False))
805
- batch = self._start_parm.get("batch", True)
806
- additional_switches = self._start_parm.get("additional_switches", None)
807
- additional_envs = self._start_parm.get("additional_envs", None)
808
- port = launch_grpc(
809
- exec_file=exec_file,
810
- batch=batch,
811
- additional_switches=additional_switches,
812
- additional_envs=additional_envs,
813
- verbose=True,
814
- )
815
- # update the new cleanup behavior
816
- self._cleanup_on_exit = cleanup_on_exit
817
- self._port = port
818
- self._channel = self._create_channel(self._ip, port)
819
- self._connect(port)
820
-
821
- self.log_info("Mechanical is ready to accept gRPC calls.")
822
-
823
- def wait_till_mechanical_is_ready(self, wait_time=-1):
824
- """Wait until Mechanical is ready.
825
-
826
- Parameters
827
- ----------
828
- wait_time : float, optional
829
- Maximum allowable time in seconds for connecting to the Mechanical gRPC server.
830
- """
831
- time_1 = datetime.datetime.now()
832
-
833
- sleep_time = 0.5
834
- if wait_time == -1: # pragma: no cover
835
- self.log_info("Waiting for Mechanical to be ready...")
836
- else:
837
- self.log_info(f"Waiting for Mechanical to be ready. Maximum wait time: {wait_time}s")
838
-
839
- while not self.__isMechanicalReady():
840
- time_2 = datetime.datetime.now()
841
- time_interval = time_2 - time_1
842
- time_interval_seconds = int(time_interval.total_seconds())
843
-
844
- self.log_debug(
845
- f"Mechanical is not ready. You've been waiting for {time_interval_seconds}."
846
- )
847
- if self._timeout != -1:
848
- if time_interval_seconds > wait_time:
849
- self.log_debug(
850
- f"Allowed wait time {wait_time}s. "
851
- f"Waited so for {time_interval_seconds}s, "
852
- f"before throwing the error."
853
- )
854
- raise RuntimeError(
855
- f"Couldn't connect to Mechanical. " f"Waited for {time_interval_seconds}s."
856
- )
857
-
858
- time.sleep(sleep_time)
859
-
860
- time_2 = datetime.datetime.now()
861
- time_interval = time_2 - time_1
862
- time_interval_seconds = int(time_interval.total_seconds())
863
-
864
- self.log_info(f"Mechanical is ready. It took {time_interval_seconds} seconds to verify.")
865
-
866
- def __isMechanicalReady(self):
867
- """Whether the Mechanical gRPC server is ready.
868
-
869
- Returns
870
- -------
871
- bool
872
- ``True`` if Mechanical is ready, ``False`` otherwise.
873
- """
874
- try:
875
- script = "ExtAPI.DataModel.Project.ProductVersion"
876
- self.run_python_script(script)
877
- except grpc.RpcError as error:
878
- self.log_debug(f"Mechanical is not ready. Error:{error}.")
879
- return False
880
-
881
- return True
882
-
883
- @staticmethod
884
- def convert_to_server_log_level(log_level):
885
- """Convert the log level to the server log level.
886
-
887
- Parameters
888
- ----------
889
- log_level : str
890
- Level of logging. Options are ``"DEBUG"``, ``"INFO"``, ``"WARNING"``,
891
- ``"ERROR"``, and ``"CRITICAL"``.
892
-
893
- Returns
894
- -------
895
- Converted log level for the server.
896
- """
897
- value = client_to_server_loglevel.get(log_level)
898
-
899
- if value is not None:
900
- return value
901
-
902
- raise ValueError(
903
- f"Log level {log_level} is invalid. Possible values are "
904
- f"'DEBUG','INFO', 'WARNING', 'ERROR', and 'CRITICAL'."
905
- )
906
-
907
- def run_python_script(
908
- self, script_block: str, enable_logging=False, log_level="WARNING", progress_interval=2000
909
- ):
910
- """Run a Python script block inside Mechanical.
911
-
912
- It returns the string value of the last executed statement. If the value cannot be
913
- returned as a string, it will return an empty string.
914
-
915
- Parameters
916
- ----------
917
- script_block : str
918
- Script block (one or more lines) to run.
919
- enable_logging: bool, optional
920
- Whether to enable logging. The default is ``False``.
921
- log_level: str
922
- Level of logging. The default is ``"WARNING"``. Options are ``"DEBUG"``,
923
- ``"INFO"``, ``"WARNING"``, and ``"ERROR"``.
924
- progress_interval: int, optional
925
- Frequency in milliseconds for getting log messages from the server.
926
- The default is ``2000``.
927
-
928
- Returns
929
- -------
930
- str
931
- Script result.
932
-
933
- Examples
934
- --------
935
- Return a value from a simple calculation.
936
-
937
- >>> mechanical.run_python_script('2+3')
938
- '5'
939
-
940
- Return a string value from Project object.
941
-
942
- >>> mechanical.run_python_script('ExtAPI.DataModel.Project.ProductVersion')
943
- '2025 R1'
944
-
945
- Return an empty string, when you try to return the Project object.
946
-
947
- >>> mechanical.run_python_script('ExtAPI.DataModel.Project')
948
- ''
949
-
950
- Return an empty string for assignments.
951
-
952
- >>> mechanical.run_python_script('version = ExtAPI.DataModel.Project.ProductVersion')
953
- ''
954
-
955
- Return value from the last executed statement from a variable.
956
-
957
- >>> script='''
958
- addition = 2 + 3
959
- multiplication = 3 * 4
960
- multiplication
961
- '''
962
- >>> mechanical.run_python_script(script)
963
- '12'
964
-
965
- Return value from last executed statement from a function call.
966
-
967
- >>> script='''
968
- import math
969
- math.pow(2,3)
970
- '''
971
- >>> mechanical.run_python_script(script)
972
- '8'
973
-
974
- Handle an error scenario.
975
-
976
- >>> script = 'hello_world()'
977
- >>> import grpc
978
- >>> try:
979
- mechanical.run_python_script(script)
980
- except grpc.RpcError as error:
981
- print(error.details())
982
- name 'hello_world' is not defined
983
-
984
- """
985
- self.verify_valid_connection()
986
- result_as_string = self.__call_run_python_script(
987
- script_block, enable_logging, log_level, progress_interval
988
- )
989
- return result_as_string
990
-
991
- def run_python_script_from_file(
992
- self, file_path, enable_logging=False, log_level="WARNING", progress_interval=2000
993
- ):
994
- """Run the contents a python file inside Mechanical.
995
-
996
- It returns the string value of the last executed statement. If the value cannot be
997
- returned as a string, it will return an empty string.
998
-
999
- Parameters
1000
- ----------
1001
- file_path :
1002
- Path for the Python file.
1003
- enable_logging: bool, optional
1004
- Whether to enable logging. The default is ``False``.
1005
- log_level: str
1006
- Level of logging. The default is ``"WARNING"``. Options are ``"DEBUG"``,
1007
- ``"INFO"``, ``"WARNING"``, and ``"ERROR"``.
1008
- progress_interval: int, optional
1009
- Frequency in milliseconds for getting log messages from the server.
1010
- The default is ``2000``.
1011
-
1012
- Returns
1013
- -------
1014
- str
1015
- Script result.
1016
-
1017
- Examples
1018
- --------
1019
- Return a value from a simple calculation.
1020
-
1021
- Contents of **simple.py** file
1022
-
1023
- 2+3
1024
-
1025
- >>> mechanical.run_python_script_from_file('simple.py')
1026
- '5'
1027
-
1028
- Return a value from a simple function call.
1029
-
1030
- Contents of **test.py** file
1031
-
1032
- import math
1033
-
1034
- math.pow(2,3)
1035
-
1036
- >>> mechanical.run_python_script_from_file('test.py')
1037
- '8'
1038
-
1039
- """
1040
- self.verify_valid_connection()
1041
- self.log_debug(f"run_python_script_from_file started")
1042
- script_code = Mechanical.__readfile(file_path)
1043
- self.log_debug(f"run_python_script_from_file started")
1044
- return self.run_python_script(script_code, enable_logging, log_level, progress_interval)
1045
-
1046
- def exit(self, force=False):
1047
- """Exit Mechanical.
1048
-
1049
- Parameters
1050
- ----------
1051
- force : bool, optional
1052
- Whether to force Mechanical to exit. The default is ``False``, in which case
1053
- only Mechanical in UI mode asks for confirmation. This parameter overrides
1054
- any environment variables that may inhibit exiting Mechanical.
1055
-
1056
- Examples
1057
- --------
1058
- Exit Mechanical.
1059
-
1060
- >>> mechanical.Exit(force=True)
1061
-
1062
- """
1063
- if not force:
1064
- if not get_start_instance():
1065
- self.log_info("Ignoring exit due to PYMECHANICAL_START_INSTANCE=False")
1066
- return
1067
-
1068
- # or building the gallery
1069
- if pymechanical.BUILDING_GALLERY:
1070
- self._log.info("Ignoring exit due to BUILDING_GALLERY=True")
1071
- return
1072
-
1073
- if self._exited:
1074
- return
1075
-
1076
- self.verify_valid_connection()
1077
-
1078
- self._exiting = True
1079
-
1080
- self.log_debug("In shutdown.")
1081
- request = mechanical_pb2.ShutdownRequest(force_exit=force)
1082
- self.log_debug("Shutting down...")
1083
-
1084
- self._busy = True
1085
- try:
1086
- self._stub.Shutdown(request)
1087
- except grpc._channel._InactiveRpcError as error:
1088
- self.log_warning("Mechanical exit failed: {str(error}.")
1089
- finally:
1090
- self._busy = False
1091
-
1092
- self._exited = True
1093
- self._stub = None
1094
-
1095
- if self._remote_instance is not None: # pragma: no cover
1096
- self.log_debug("PyPIM delete has started.")
1097
- try:
1098
- self._remote_instance.delete()
1099
- except Exception as error:
1100
- self.log_warning("Remote instance delete failed: {str(error}.")
1101
- self.log_debug("PyPIM delete has finished.")
1102
-
1103
- self._remote_instance = None
1104
- self._channel = None
1105
- else:
1106
- self.log_debug("No PyPIM cleanup is needed.")
1107
-
1108
- local_ports = pymechanical.LOCAL_PORTS
1109
- if self._local and self._port in local_ports:
1110
- local_ports.remove(self._port)
1111
-
1112
- self.log_info("Shutdown has finished.")
1113
-
1114
- @protect_grpc
1115
- def upload(
1116
- self,
1117
- file_name,
1118
- file_location_destination=None,
1119
- chunk_size=DEFAULT_FILE_CHUNK_SIZE,
1120
- progress_bar=True,
1121
- ):
1122
- """Upload a file to the Mechanical instance.
1123
-
1124
- Parameters
1125
- ----------
1126
- file_name : str
1127
- Local file to upload. Only the file name is needed if the file
1128
- is relative to the current working directory. Otherwise, the full path
1129
- is needed.
1130
- file_location_destination : str, optional
1131
- File location on the Mechanical server to upload the file to. The default is
1132
- ``None``, in which case the project directory is used.
1133
- chunk_size : int, optional
1134
- Chunk size in bytes. The default is ``1048576``.
1135
- progress_bar : bool, optional
1136
- Whether to show a progress bar using ``tqdm``. The default is ``True``.
1137
- A progress bar is helpful for viewing upload progress.
1138
-
1139
- Returns
1140
- -------
1141
- str
1142
- Base name of the uploaded file.
1143
-
1144
- Examples
1145
- --------
1146
- Upload the ``hsec.x_t`` file with the progress bar not shown.
1147
-
1148
- >>> mechanical.upload('hsec.x_t', progress_bar=False)
1149
- """
1150
- self.verify_valid_connection()
1151
-
1152
- if not os.path.isfile(file_name):
1153
- raise FileNotFoundError(f"Unable to locate filename {file_name}.")
1154
-
1155
- self._log.debug(f"Uploading file '{file_name}' to the Mechanical instance.")
1156
-
1157
- if file_location_destination is None:
1158
- file_location_destination = self.project_directory
1159
-
1160
- self._busy = True
1161
- try:
1162
- chunks_generator = self.get_file_chunks(
1163
- file_location_destination,
1164
- file_name,
1165
- chunk_size=chunk_size,
1166
- progress_bar=progress_bar,
1167
- )
1168
- response = self._stub.UploadFile(chunks_generator)
1169
- self.log_debug(f"upload_file response is {response.is_ok}.")
1170
- finally:
1171
- self._busy = False
1172
-
1173
- if not response.is_ok: # pragma: no cover
1174
- raise IOError("File failed to upload.")
1175
- return os.path.basename(file_name)
1176
-
1177
- def get_file_chunks(self, file_location, file_name, chunk_size, progress_bar):
1178
- """Construct the file upload request for the server.
1179
-
1180
- Parameters
1181
- ----------
1182
- file_location_destination : str, optional
1183
- Directory where the file to upload to the server is located.
1184
- file_name : str
1185
- Name of the file to upload.
1186
- chunk_size : int
1187
- Chunk size in bytes.
1188
- progress_bar : bool
1189
- Whether to show a progress bar using ``tqdm``.
1190
- """
1191
- pbar = None
1192
- if progress_bar:
1193
- if not _HAS_TQDM: # pragma: no cover
1194
- raise ModuleNotFoundError(
1195
- f"To use the keyword argument 'progress_bar', you must have "
1196
- f"installed the 'tqdm' package. To avoid this message, you can "
1197
- f"set 'progress_bar=False'."
1198
- )
1199
-
1200
- n_bytes = os.path.getsize(file_name)
1201
-
1202
- base_name = os.path.basename(file_name)
1203
- pbar = tqdm(
1204
- total=n_bytes,
1205
- desc=f"Uploading {base_name} to {self._channel_str}:{file_location}.",
1206
- unit="B",
1207
- unit_scale=True,
1208
- unit_divisor=1024,
1209
- )
1210
-
1211
- with open(file_name, "rb") as f:
1212
- while True:
1213
- piece = f.read(chunk_size)
1214
- length = len(piece)
1215
- if length == 0:
1216
- if pbar is not None:
1217
- pbar.close()
1218
- return
1219
-
1220
- if pbar is not None:
1221
- pbar.update(length)
1222
-
1223
- chunk = mechanical_pb2.Chunk(payload=piece, size=length)
1224
- yield mechanical_pb2.FileUploadRequest(
1225
- file_name=os.path.basename(file_name), file_location=file_location, chunk=chunk
1226
- )
1227
-
1228
- @property
1229
- def project_directory(self):
1230
- """Get the project directory for the currently connected Mechanical instance.
1231
-
1232
- Examples
1233
- --------
1234
- Get the project directory of the connected Mechanical instance.
1235
-
1236
- >>> mechanical.project_directory
1237
- '/tmp/ANSYS.username.1/AnsysMech3F97/Project_Mech_Files/'
1238
-
1239
- """
1240
- return self.run_python_script("ExtAPI.DataModel.Project.ProjectDirectory")
1241
-
1242
- def list_files(self):
1243
- """List the files in the working directory of Mechanical.
1244
-
1245
- Returns
1246
- -------
1247
- list
1248
- List of files in the working directory of Mechanical.
1249
-
1250
- Examples
1251
- --------
1252
- List the files in the working directory.
1253
-
1254
- >>> files = mechanical.list_files()
1255
- >>> for file in files: print(file)
1256
- """
1257
- result = self.run_python_script(
1258
- "import pymechanical_helpers\npymechanical_helpers.GetAllProjectFiles(ExtAPI)"
1259
- )
1260
-
1261
- files_out = result.splitlines()
1262
- if not files_out: # pragma: no cover
1263
- self.log_warning("No files listed")
1264
- return files_out
1265
-
1266
- def _get_files(self, files, recursive=False):
1267
- self_files = self.list_files() # to avoid calling it too much
1268
-
1269
- if isinstance(files, str):
1270
- if self._local: # pragma: no cover
1271
- # in local mode
1272
- if os.path.exists(files):
1273
- if not os.path.isabs(files):
1274
- list_files = [os.path.join(os.getcwd(), files)]
1275
- else:
1276
- # file exist
1277
- list_files = [files]
1278
- elif "*" in files:
1279
- # using filter
1280
- list_files = glob.glob(files, recursive=recursive)
1281
- if not list_files:
1282
- raise ValueError(
1283
- f"The `'files'` parameter ({files}) didn't match any file using "
1284
- f"glob expressions in the local client."
1285
- )
1286
- else:
1287
- raise ValueError(
1288
- f"The files parameter ('{files}') does not match any file or pattern."
1289
- )
1290
- else: # Remote or looking into Mechanical working directory
1291
- if files in self_files:
1292
- list_files = [files]
1293
- elif "*" in files:
1294
- # try filter on the list_files
1295
- if recursive:
1296
- self.log_warning(
1297
- "Because the 'recursive' keyword argument does not work with "
1298
- "remote instances, it is ignored."
1299
- )
1300
- list_files = fnmatch.filter(self_files, files)
1301
- if not list_files:
1302
- raise ValueError(
1303
- f"The `'files'` parameter ({files}) didn't match any file using "
1304
- f"glob expressions in the remote server."
1305
- )
1306
- else:
1307
- raise ValueError(
1308
- f"The `'files'` parameter ('{files}') does not match any file or pattern."
1309
- )
1310
-
1311
- elif isinstance(files, (list, tuple)):
1312
- if not all([isinstance(each, str) for each in files]):
1313
- raise ValueError(
1314
- "The parameter `'files'` can be a list or tuple, but it "
1315
- "should only contain strings."
1316
- )
1317
- list_files = files
1318
- else:
1319
- raise ValueError(
1320
- f"The `file` parameter type ({type(files)}) is not supported."
1321
- "Only strings, tuple of strings, or list of strings are allowed."
1322
- )
1323
-
1324
- return list_files
1325
-
1326
- def download(
1327
- self,
1328
- files,
1329
- target_dir=None,
1330
- chunk_size=DEFAULT_CHUNK_SIZE,
1331
- progress_bar=None,
1332
- recursive=False,
1333
- ): # pragma: no cover
1334
- """Download files from the working directory of the Mechanical instance.
1335
-
1336
- It downloads them from the working directory to the target directory. It returns the list
1337
- of local file paths for the downloaded files.
1338
-
1339
- Parameters
1340
- ----------
1341
- files : str, list[str], tuple(str)
1342
- One or more files on the Mechanical server to download. The files must be
1343
- in the same directory as the Mechanical instance. You can use the
1344
- :func:`Mechanical.list_files <ansys.mechanical.core.mechanical.list_files>`
1345
- function to list current files. Alternatively, you can specify *glob expressions* to
1346
- match file names. For example, you could use ``file*`` to match every file whose
1347
- name starts with ``file``.
1348
- target_dir: str
1349
- Default directory to copy the downloaded files to. The default is ``None`` and
1350
- current working directory will be used as target directory.
1351
- chunk_size : int, optional
1352
- Chunk size in bytes. The default is ``262144``. The value must be less than 4 MB.
1353
- progress_bar : bool, optional
1354
- Whether to show a progress bar using ``tqdm``. The default is ``None``, in
1355
- which case a progress bar is shown. A progress bar is helpful for viewing download
1356
- progress.
1357
- recursive : bool, optional
1358
- Whether to use recursion when using a glob pattern search. The default is ``False``.
1359
-
1360
- Returns
1361
- -------
1362
- List[str]
1363
- List of local file paths.
1364
-
1365
- Notes
1366
- -----
1367
- There are some considerations to keep in mind when using the ``download()`` method:
1368
-
1369
- * The glob pattern search does not search recursively in remote instances.
1370
- * In a remote instance, it is not possible to list or download files in a
1371
- location other than the Mechanical working directory.
1372
- * If you are connected to a local instance and provide a file path, downloading files
1373
- from a different folder is allowed but is not recommended.
1374
-
1375
- Examples
1376
- --------
1377
- Download a single file.
1378
-
1379
- >>> local_file_path_list = mechanical.download('file.out')
1380
-
1381
- Download all files starting with ``file``.
1382
-
1383
- >>> local_file_path_list = mechanical.download('file*')
1384
-
1385
- Download every file in the Mechanical working directory.
1386
-
1387
- >>> local_file_path_list = mechanical.download('*.*')
1388
-
1389
- Alternatively, the recommended method is to use the
1390
- :func:`download_project() <ansys.mechanical.core.mechanical.Mechanical.download_project>`
1391
- method to download all files.
1392
-
1393
- >>> local_file_path_list = mechanical.download_project()
1394
-
1395
- """
1396
- self.verify_valid_connection()
1397
-
1398
- if chunk_size > 4 * 1024 * 1024: # 4MB
1399
- raise ValueError(
1400
- "Chunk sizes bigger than 4 MB can generate unstable behaviour in PyMechanical. "
1401
- "Decrease the ``chunk_size`` value."
1402
- )
1403
-
1404
- list_files = self._get_files(files, recursive=recursive)
1405
-
1406
- if target_dir:
1407
- path = pathlib.Path(target_dir)
1408
- path.mkdir(parents=True, exist_ok=True)
1409
- else:
1410
- target_dir = os.getcwd()
1411
-
1412
- out_files = []
1413
-
1414
- for each_file in list_files:
1415
- try:
1416
- file_name = os.path.basename(each_file) # Getting only the name of the file.
1417
- # We try to avoid that when the full path is supplied. It crashes when trying
1418
- # to do `os.path.join(target_dir"os.getcwd()", file_name "full filename path"`
1419
- # This produces the file structure to flat out, but it is fine,
1420
- # because recursive does not work in remote.
1421
- self._busy = True
1422
- out_file_path = self._download(
1423
- each_file,
1424
- out_file_name=os.path.join(target_dir, file_name),
1425
- chunk_size=chunk_size,
1426
- progress_bar=progress_bar,
1427
- )
1428
- out_files.append(out_file_path)
1429
- except FileNotFoundError:
1430
- # So far the gRPC interface returns the size of the file equal
1431
- # zero, if the file does not exist, or if its size is zero,
1432
- # but they are two different things.
1433
- # In theory, since we are obtaining the files name from
1434
- # `mechanical.list_files()`, they do exist, so
1435
- # if there is any error, it means their size is zero.
1436
- pass # This is not the best.
1437
- finally:
1438
- self._busy = False
1439
-
1440
- return out_files
1441
-
1442
- @protect_grpc
1443
- def _download(
1444
- self,
1445
- target_name,
1446
- out_file_name,
1447
- chunk_size=DEFAULT_CHUNK_SIZE,
1448
- progress_bar=None,
1449
- ):
1450
- """Download a file from the Mechanical instance.
1451
-
1452
- Parameters
1453
- ----------
1454
- target_name : str
1455
- Name of the target file on the server. The file must be in the same
1456
- directory as the Mechanical instance. You can use the
1457
- ``mechanical.list_files()`` function to list current files.
1458
- out_file_name : str
1459
- Name of the output file if the name is to differ from that for the target
1460
- file.
1461
- chunk_size : int, optional
1462
- Chunk size in bytes. The default is ``"DEFAULT_CHUNK_SIZE"``, in which case
1463
- 256 kB is used. The value must be less than 4 MB.
1464
- progress_bar : bool, optional
1465
- Whether to show a progress bar using ``tqdm``. The default is ``None``, in
1466
- which case a progress bar is shown. A progress bar is helpful for showing download
1467
- progress.
1468
-
1469
- Examples
1470
- --------
1471
- Download the remote result file "file.rst" as "my_result.rst".
1472
-
1473
- >>> mechanical.download('file.rst', 'my_result.rst')
1474
- """
1475
- self.verify_valid_connection()
1476
-
1477
- if not progress_bar and _HAS_TQDM:
1478
- progress_bar = True
1479
-
1480
- request = mechanical_pb2.FileDownloadRequest(file_path=target_name, chunk_size=chunk_size)
1481
-
1482
- responses = self._stub.DownloadFile(request)
1483
-
1484
- file_size = self.save_chunks_to_file(
1485
- responses, out_file_name, progress_bar=progress_bar, target_name=target_name
1486
- )
1487
-
1488
- if not file_size: # pragma: no cover
1489
- raise FileNotFoundError(f'File "{out_file_name}" is empty or does not exist')
1490
-
1491
- self.log_info(f"{out_file_name} with size {file_size} has been written.")
1492
-
1493
- return out_file_name
1494
-
1495
- def save_chunks_to_file(self, responses, filename, progress_bar=False, target_name=""):
1496
- """Save chunks to a local file.
1497
-
1498
- Parameters
1499
- ----------
1500
- responses :
1501
- filename : str
1502
- Name of the local file to save chunks to.
1503
- progress_bar : bool, optional
1504
- Whether to show a progress bar using ``tqdm``. The default is ``False``.
1505
- target_name : str, optional
1506
- Name of the target file on the server. The default is ``""``. The file
1507
- must be in the same directory as the Mechanical instance. You can use the
1508
- ``mechanical.list_files()`` function to list current files.
1509
-
1510
- Returns
1511
- -------
1512
- file_size : int
1513
- File size saved in bytes. If ``0`` is returned, no file was written.
1514
- """
1515
- pbar = None
1516
- if progress_bar:
1517
- if not _HAS_TQDM: # pragma: no cover
1518
- raise ModuleNotFoundError(
1519
- "To use the keyword argument 'progress_bar', you need to have installed "
1520
- "the 'tqdm' package.To avoid this message you can set 'progress_bar=False'."
1521
- )
1522
-
1523
- file_size = 0
1524
- with open(filename, "wb") as f:
1525
- for response in responses:
1526
- f.write(response.chunk.payload)
1527
- payload_size = len(response.chunk.payload)
1528
- file_size += payload_size
1529
- if pbar is None:
1530
- pbar = tqdm(
1531
- total=response.file_size,
1532
- desc=f"Downloading {self._channel_str}:{target_name} to {filename}",
1533
- unit="B",
1534
- unit_scale=True,
1535
- unit_divisor=1024,
1536
- )
1537
- pbar.update(payload_size)
1538
- else:
1539
- pbar.update(payload_size)
1540
-
1541
- if pbar is not None:
1542
- pbar.close()
1543
-
1544
- return file_size
1545
-
1546
- def download_project(self, extensions=None, target_dir=None, progress_bar=False):
1547
- """Download all project files in the working directory of the Mechanical instance.
1548
-
1549
- It downloads them from the working directory to the target directory. It returns the list
1550
- of local file paths for the downloaded files.
1551
-
1552
- Parameters
1553
- ----------
1554
- extensions : list[str], tuple[str], optional
1555
- List of extensions for filtering files before downloading them. The
1556
- default is ``None``.
1557
- target_dir : str, optional
1558
- Path for downloading the files to. The default is ``None``.
1559
- progress_bar : bool, optional
1560
- Whether to show a progress bar using ``tqdm``. The default is ``False``.
1561
- A progress bar is helpful for viewing download progress.
1562
-
1563
- Returns
1564
- -------
1565
- List[str]
1566
- List of local file paths.
1567
-
1568
- Examples
1569
- --------
1570
- Download all the files in the project.
1571
-
1572
- >>> local_file_path_list = mechanical.download_project()
1573
- """
1574
- destination_directory = target_dir.rstrip("\\/")
1575
-
1576
- # let us create the directory, if it doesn't exist
1577
- if destination_directory:
1578
- path = pathlib.Path(destination_directory)
1579
- path.mkdir(parents=True, exist_ok=True)
1580
- else:
1581
- destination_directory = os.getcwd()
1582
-
1583
- # relative directory?
1584
- if os.path.isdir(destination_directory):
1585
- if not os.path.isabs(destination_directory):
1586
- # construct full path
1587
- destination_directory = os.path.join(os.getcwd(), destination_directory)
1588
-
1589
- project_directory = self.project_directory
1590
- # remove the trailing slash - server could be windows or linux
1591
- project_directory = project_directory.rstrip("\\/")
1592
-
1593
- # this is where .mechddb resides
1594
- parent_directory = os.path.dirname(project_directory)
1595
-
1596
- list_of_files = []
1597
-
1598
- if not extensions:
1599
- files = self.list_files()
1600
- else:
1601
- files = []
1602
- for each_extension in extensions:
1603
- # mechdb resides one level above project directory
1604
- if "mechdb" == each_extension.lower():
1605
- file_temp = os.path.join(parent_directory, f"*.{each_extension}")
1606
- else:
1607
- file_temp = os.path.join(project_directory, "**", f"*.{each_extension}")
1608
-
1609
- if self._local:
1610
- list_files_expanded = self._get_files(file_temp, recursive=True)
1611
-
1612
- if "mechdb" == each_extension.lower():
1613
- # if we have more than one .mechdb in the parent folder
1614
- # filter to have only the current mechdb
1615
- self_files = self.list_files()
1616
- filtered_files = []
1617
- for temp_file in list_files_expanded:
1618
- if temp_file in self_files:
1619
- filtered_files.append(temp_file)
1620
- list_files = filtered_files
1621
- else:
1622
- list_files = list_files_expanded
1623
- else:
1624
- list_files = self._get_files(file_temp, recursive=False)
1625
-
1626
- files.extend(list_files)
1627
-
1628
- for file in files:
1629
- # create similar hierarchy locally
1630
- new_path = file.replace(parent_directory, destination_directory)
1631
- new_path_dir = os.path.dirname(new_path)
1632
- temp_files = self.download(
1633
- files=file, target_dir=new_path_dir, progress_bar=progress_bar
1634
- )
1635
- list_of_files.extend(temp_files)
1636
-
1637
- return list_of_files
1638
-
1639
- def clear(self):
1640
- """Clear the database.
1641
-
1642
- Examples
1643
- --------
1644
- Clear the database.
1645
-
1646
- >>> mechanical.clear()
1647
-
1648
- """
1649
- self.run_python_script("ExtAPI.DataModel.Project.New()")
1650
-
1651
- def _make_dummy_call(self):
1652
- try:
1653
- self._disable_logging = True
1654
- self.run_python_script("ExtAPI.DataModel.Project.ProjectDirectory")
1655
- except grpc.RpcError: # pragma: no cover
1656
- raise
1657
- finally:
1658
- self._disable_logging = False
1659
-
1660
- @staticmethod
1661
- def __readfile(file_path):
1662
- """Get the contents of the file as a string."""
1663
- # open text file in read mode
1664
- text_file = open(file_path, "r", encoding="utf-8")
1665
- # read whole file to a string
1666
- data = text_file.read()
1667
- # close file
1668
- text_file.close()
1669
-
1670
- return data
1671
-
1672
- def __call_run_python_script(
1673
- self, script_code: str, enable_logging, log_level, progress_interval
1674
- ):
1675
- """Run the Python script block on the server.
1676
-
1677
- Parameters
1678
- ----------
1679
- script_block : str
1680
- Script block (one or more lines) to run.
1681
- enable_logging: bool
1682
- Whether to enable logging
1683
- log_level: str
1684
- Level of logging. Options are ``"DEBUG"``, ``"INFO"``, ``"WARNING"``,
1685
- and ``"ERROR"``.
1686
- timeout: int, optional
1687
- Frequency in milliseconds for getting log messages from the server.
1688
-
1689
- Returns
1690
- -------
1691
- str
1692
- Script result.
1693
-
1694
- """
1695
- log_level_server = self.convert_to_server_log_level(log_level)
1696
- request = mechanical_pb2.RunScriptRequest()
1697
- request.script_code = script_code
1698
- request.enable_logging = enable_logging
1699
- request.logger_severity = log_level_server
1700
- request.progress_interval = progress_interval
1701
-
1702
- result = ""
1703
- self._busy = True
1704
-
1705
- try:
1706
- for runscript_response in self._stub.RunPythonScript(request):
1707
- if runscript_response.log_info == "__done__":
1708
- result = runscript_response.script_result
1709
- break
1710
- else:
1711
- if enable_logging:
1712
- self.log_message(log_level, runscript_response.log_info)
1713
- except grpc.RpcError as error:
1714
- error_info = error.details()
1715
- error_info_lower = error_info.lower()
1716
- # For the given script, return value cannot be converted to string.
1717
- if (
1718
- "the expected result" in error_info_lower
1719
- and "cannot be return via this API." in error_info
1720
- ):
1721
- if enable_logging:
1722
- self.log_debug(f"Ignoring the conversion error.{error_info}")
1723
- result = ""
1724
- else:
1725
- raise
1726
- finally:
1727
- self._busy = False
1728
-
1729
- self._log_mechanical_script(script_code)
1730
-
1731
- return result
1732
-
1733
- def log_message(self, log_level, message):
1734
- """Log the message using the given log level.
1735
-
1736
- Parameters
1737
- ----------
1738
- log_level: str
1739
- Level of logging. Options are ``"DEBUG"``, ``"INFO"``, ``"WARNING"``,
1740
- and ``"ERROR"``.
1741
- message : str
1742
- Message to log.
1743
-
1744
- Examples
1745
- --------
1746
- Log a debug message.
1747
-
1748
- >>> mechanical.log_message('DEBUG', 'debug message')
1749
-
1750
- Log an info message.
1751
-
1752
- >>> mechanical.log_message('INFO', 'info message')
1753
-
1754
- """
1755
- if log_level == "DEBUG":
1756
- self.log_debug(message)
1757
- elif log_level == "INFO":
1758
- self.log_info(message)
1759
- elif log_level == "WARNING":
1760
- self.log_warning(message)
1761
- elif log_level == "ERROR":
1762
- self.log_error(message)
1763
-
1764
- def log_debug(self, message):
1765
- """Log the debug message."""
1766
- if self._disable_logging:
1767
- return
1768
- self._log.debug(message)
1769
-
1770
- def log_info(self, message):
1771
- """Log the info message."""
1772
- if self._disable_logging:
1773
- return
1774
- self._log.info(message)
1775
-
1776
- def log_warning(self, message):
1777
- """Log the warning message."""
1778
- if self._disable_logging:
1779
- return
1780
- self._log.warning(message)
1781
-
1782
- def log_error(self, message):
1783
- """Log the error message."""
1784
- if self._disable_logging:
1785
- return
1786
- self._log.error(message)
1787
-
1788
- def verify_valid_connection(self):
1789
- """Verify whether the connection to Mechanical is valid."""
1790
- if self._exited:
1791
- raise MechanicalExitedError("Mechanical has already exited.")
1792
-
1793
- if self._stub is None: # pragma: no cover
1794
- raise ValueError(
1795
- "There is not a valid connection to Mechanical. Launch or connect to it first."
1796
- )
1797
-
1798
- @property
1799
- def exited(self):
1800
- """Whether Mechanical already exited."""
1801
- return self._exited
1802
-
1803
- def _log_mechanical_script(self, script_code):
1804
- if self._disable_logging:
1805
- return
1806
-
1807
- if self._log_file_mechanical:
1808
- try:
1809
- with open(self._log_file_mechanical, "a", encoding="utf-8") as file:
1810
- file.write(script_code)
1811
- file.write("\n")
1812
- except IOError as e: # pragma: no cover
1813
- self.log_warning(f"I/O error({e.errno}): {e.strerror}")
1814
- except Exception as e: # pragma: no cover
1815
- self.log_warning("Unexpected error:" + str(e))
1816
-
1817
-
1818
- def get_start_instance(start_instance_default=True):
1819
- """Check if the ``PYMECHANICAL_START_INSTANCE`` environment variable exists and is valid.
1820
-
1821
- Parameters
1822
- ----------
1823
- start_instance_default : bool, optional
1824
- Value to return when ``PYMECHANICAL_START_INSTANCE`` is unset.
1825
-
1826
- Returns
1827
- -------
1828
- bool
1829
- ``True`` when the ``PYMECHANICAL_START_INSTANCE`` environment variable exists
1830
- and is valid, ``False`` when this environment variable does not exist or is not valid.
1831
- If it is unset, ``start_instance_default`` is returned.
1832
-
1833
- Raises
1834
- ------
1835
- OSError
1836
- Raised when ``PYMECHANICAL_START_INSTANCE`` is not either ``True`` or ``False``
1837
- (case independent).
1838
-
1839
- """
1840
- if "PYMECHANICAL_START_INSTANCE" in os.environ:
1841
- if os.environ["PYMECHANICAL_START_INSTANCE"].lower() not in [
1842
- "true",
1843
- "false",
1844
- ]: # pragma: no cover
1845
- val = os.environ["PYMECHANICAL_START_INSTANCE"]
1846
- raise OSError(
1847
- f'Invalid value "{val}" for PYMECHANICAL_START_INSTANCE\n'
1848
- 'PYMECHANICAL_START_INSTANCE should be either "TRUE" or "FALSE"'
1849
- )
1850
- return os.environ["PYMECHANICAL_START_INSTANCE"].lower() == "true"
1851
- return start_instance_default
1852
-
1853
-
1854
- def launch_grpc(
1855
- exec_file="",
1856
- batch=True,
1857
- port=MECHANICAL_DEFAULT_PORT,
1858
- additional_switches=None,
1859
- additional_envs=None,
1860
- verbose=False,
1861
- ) -> int:
1862
- """Start Mechanical locally in gRPC mode.
1863
-
1864
- Parameters
1865
- ----------
1866
- exec_file : str, optional
1867
- Path for the Mechanical executable file. The default is ``None``, in which
1868
- case the cached location is used.
1869
- batch : bool, optional
1870
- Whether to launch Mechanical in batch mode. The default is ``True``.
1871
- When ``False``, Mechanical is launched in UI mode.
1872
- port : int, optional
1873
- Port to launch the Mechanical instance on. The default is
1874
- ``MECHANICAL_DEFAULT_PORT``. The final port is the first
1875
- port available after (or including) this port.
1876
- additional_switches : list, optional
1877
- List of additional arguments to pass. The default is ``None``.
1878
- additional_envs : dictionary, optional
1879
- Dictionary of additional environment variables to pass. The default
1880
- is ``None``.
1881
- verbose : bool, optional
1882
- Whether to print all output when launching and running Mechanical. The
1883
- default is ``False``. Printing all output is not recommended unless
1884
- you are debugging the startup of Mechanical.
1885
-
1886
- Returns
1887
- -------
1888
- int
1889
- Port number that the Mechanical instance started on.
1890
-
1891
- Notes
1892
- -----
1893
- If ``PYMECHANICAL_START_INSTANCE`` is set to FALSE, the ``launch_mechanical``
1894
- method looks for an existing instance of Mechanical at ``PYMECHANICAL_IP`` on port
1895
- ``PYMECHANICAL_PORT``, with default to ``127.0.0.1`` and ``10000`` if unset.
1896
- This is typically used for automated documentation and testing.
1897
-
1898
- Examples
1899
- --------
1900
- Launch Mechanical using the default configuration.
1901
-
1902
- >>> from ansys.mechanical.core import launch_mechanical
1903
- >>> mechanical = launch_mechanical()
1904
-
1905
- Launch Mechanical using a specified executable file.
1906
-
1907
- >>> exec_file_path = 'C:/Program Files/ANSYS Inc/v251/aisol/bin/win64/AnsysWBU.exe'
1908
- >>> mechanical = launch_mechanical(exec_file_path)
1909
-
1910
- """
1911
- # verify version
1912
- if atp.version_from_path("mechanical", exec_file) < 232:
1913
- raise VersionError("The Mechanical gRPC interface requires Mechanical 2023 R2 or later.")
1914
-
1915
- # get the next available port
1916
- local_ports = pymechanical.LOCAL_PORTS
1917
- if port is None:
1918
- if not local_ports:
1919
- port = MECHANICAL_DEFAULT_PORT
1920
- else:
1921
- port = max(local_ports) + 1
1922
-
1923
- while port_in_use(port) or port in local_ports:
1924
- port += 1
1925
- local_ports.append(port)
1926
-
1927
- mechanical_launcher = MechanicalLauncher(
1928
- batch, port, exec_file, additional_switches, additional_envs, verbose
1929
- )
1930
- mechanical_launcher.launch()
1931
-
1932
- return port
1933
-
1934
-
1935
- def launch_remote_mechanical(version=None) -> (grpc.Channel, Instance): # pragma: no cover
1936
- """Start Mechanical remotely using the Product Instance Management (PIM) API.
1937
-
1938
- When calling this method, you must ensure that you are in an environment
1939
- where PyPIM is configured. You can use the
1940
- :func:`pypim.is_configured <ansys.platform.instancemanagement.is_configured>`
1941
- method to verify that PyPIM is configured.
1942
-
1943
- Parameters
1944
- ----------
1945
- version : str, optional
1946
- Mechanical version to run in the three-digit format. For example, ``"251"`` to
1947
- run 2025 R1. The default is ``None``, in which case the server runs the latest
1948
- installed version.
1949
-
1950
- Returns
1951
- -------
1952
- Tuple containing channel, remote_instance.
1953
- """
1954
- pim = pypim.connect()
1955
- instance = pim.create_instance(product_name="mechanical", product_version=version)
1956
-
1957
- LOG.info("PyPIM wait for ready has started.")
1958
- instance.wait_for_ready()
1959
- LOG.info("PyPIM wait for ready has finished.")
1960
-
1961
- channel = instance.build_grpc_channel(
1962
- options=[
1963
- ("grpc.max_receive_message_length", MAX_MESSAGE_LENGTH),
1964
- ]
1965
- )
1966
-
1967
- return channel, instance
1968
-
1969
-
1970
- def launch_mechanical(
1971
- allow_input=True,
1972
- exec_file=None,
1973
- batch=True,
1974
- loglevel="ERROR",
1975
- log_file=False,
1976
- log_mechanical=None,
1977
- additional_switches=None,
1978
- additional_envs=None,
1979
- start_timeout=120,
1980
- port=None,
1981
- ip=None,
1982
- start_instance=None,
1983
- verbose_mechanical=False,
1984
- clear_on_connect=False,
1985
- cleanup_on_exit=True,
1986
- version=None,
1987
- keep_connection_alive=True,
1988
- ) -> Mechanical:
1989
- """Start Mechanical locally.
1990
-
1991
- Parameters
1992
- ----------
1993
- allow_input: bool, optional
1994
- Whether to allow user input when discovering the path to the Mechanical
1995
- executable file.
1996
- exec_file : str, optional
1997
- Path for the Mechanical executable file. The default is ``None``,
1998
- in which case the cached location is used. If PyPIM is configured
1999
- and this parameter is set to ``None``, PyPIM launches Mechanical
2000
- using its ``version`` parameter.
2001
- batch : bool, optional
2002
- Whether to launch Mechanical in batch mode. The default is ``True``.
2003
- When ``False``, Mechanical launches in UI mode.
2004
- loglevel : str, optional
2005
- Level of messages to print to the console.
2006
- Options are:
2007
-
2008
- - ``"WARNING"``: Prints only Ansys warning messages.
2009
- - ``"ERROR"``: Prints only Ansys error messages.
2010
- - ``"INFO"``: Prints all Ansys messages.
2011
-
2012
- The default is ``WARNING``.
2013
- log_file : bool, optional
2014
- Whether to copy the messages to a file named ``logs.log``, which is
2015
- located where the Python script is executed. The default is ``False``.
2016
- log_mechanical : str, optional
2017
- Path to the output file on the local disk to write every script
2018
- command to. The default is ``None``. However, you might set
2019
- ``"log_mechanical='pymechanical_log.txt'"`` to write all commands that are
2020
- sent to Mechanical via PyMechanical to this file. You can then use these
2021
- commands to run a script within Mechanical without PyMechanical.
2022
- additional_switches : list, optional
2023
- Additional switches for Mechanical. The default is ``None``.
2024
- additional_envs : dictionary, optional
2025
- Dictionary of additional environment variables to pass. The default
2026
- is ``None``.
2027
- start_timeout : float, optional
2028
- Maximum allowable time in seconds to connect to the Mechanical server.
2029
- The default is ``120``.
2030
- port : int, optional
2031
- Port to launch the Mechanical gRPC server on. The default is ``None``,
2032
- in which case ``10000`` is used. The final port is the first
2033
- port available after (or including) this port. You can override the
2034
- default behavior of this parameter with the
2035
- ``PYMECHANICAL_PORT=<VALID PORT>`` environment variable.
2036
- ip : str, optional
2037
- IP address to use only when ``start_instance`` is ``False``. The
2038
- default is ``None``, in which case ``"127.0.0.1"`` is used. If you
2039
- provide an IP address, ``start_instance`` is set to ``False``.
2040
- A host name can be provided as an alternative to an IP address.
2041
- start_instance : bool, optional
2042
- Whether to launch and connect to a new Mechanical instance. The default
2043
- is ``None``, in which case an attempt is made to connect to an existing
2044
- Mechanical instance at the given ``ip`` and ``port`` parameters, which have
2045
- defaults of ``"127.0.0.1"`` and ``10000`` respectively. When ``True``,
2046
- a local instance of Mechanical is launched. You can override the default
2047
- behavior of this parameter with the ``PYMECHANICAL_START_INSTANCE=FALSE``
2048
- environment variable.
2049
- verbose_mechanical : bool, optional
2050
- Whether to enable printing of all output when launching and running
2051
- a Mechanical instance. The default is ``False``. This parameter should be
2052
- set to ``True`` for debugging only as output can be tracked within
2053
- PyMechanical.
2054
- clear_on_connect : bool, optional
2055
- When ``start_instance`` is ``False``, whether to clear the environment
2056
- when connecting to Mechanical. The default is ``False``. When ``True``,
2057
- a fresh environment is provided when you connect to Mechanical.
2058
- cleanup_on_exit : bool, optional
2059
- Whether to exit Mechanical when Python exits. The default is ``True``.
2060
- When ``False``, Mechanical is not exited when the garbage for this Mechanical
2061
- instance is collected.
2062
- version : str, optional
2063
- Mechanical version to run in the three-digit format. For example, ``"251"``
2064
- for 2025 R1. The default is ``None``, in which case the server runs the
2065
- latest installed version. If PyPIM is configured and ``exce_file=None``,
2066
- PyPIM launches Mechanical using its ``version`` parameter.
2067
- keep_connection_alive : bool, optional
2068
- Whether to keep the gRPC connection alive by running a background thread
2069
- and making dummy calls for remote connections. The default is ``True``.
2070
-
2071
- Returns
2072
- -------
2073
- ansys.mechanical.core.mechanical.Mechanical
2074
- Instance of Mechanical.
2075
-
2076
- Notes
2077
- -----
2078
- If the environment is configured to use `PyPIM <https://pypim.docs.pyansys.com>`_
2079
- and ``start_instance=True``, then starting the instance is delegated to PyPIM.
2080
- In this case, most of the preceding parameters are ignored because the server-side
2081
- configuration is used.
2082
-
2083
- Examples
2084
- --------
2085
- Launch Mechanical.
2086
-
2087
- >>> from ansys.mechanical.core import launch_mechanical
2088
- >>> mech = launch_mechanical()
2089
-
2090
- Launch Mechanical using a specified executable file.
2091
-
2092
- >>> exec_file_path = 'C:/Program Files/ANSYS Inc/v251/aisol/bin/win64/AnsysWBU.exe'
2093
- >>> mech = launch_mechanical(exec_file_path)
2094
-
2095
- Connect to an existing Mechanical instance at IP address ``192.168.1.30`` on port
2096
- ``50001``.
2097
-
2098
- >>> mech = launch_mechanical(start_instance=False, ip='192.168.1.30', port=50001)
2099
- """
2100
- # Start Mechanical with PyPIM if the environment is configured for it
2101
- # and a directive on how to launch Mechanical was not passed.
2102
- if pypim.is_configured() and exec_file is None: # pragma: no cover
2103
- LOG.info("Starting Mechanical remotely. The startup configuration will be ignored.")
2104
- channel, remote_instance = launch_remote_mechanical(version=version)
2105
- return Mechanical(
2106
- channel=channel,
2107
- remote_instance=remote_instance,
2108
- loglevel=loglevel,
2109
- log_file=log_file,
2110
- log_mechanical=log_mechanical,
2111
- timeout=start_timeout,
2112
- cleanup_on_exit=cleanup_on_exit,
2113
- keep_connection_alive=keep_connection_alive,
2114
- )
2115
-
2116
- if ip is None:
2117
- ip = os.environ.get("PYMECHANICAL_IP", LOCALHOST)
2118
- else: # pragma: no cover
2119
- start_instance = False
2120
- ip = socket.gethostbyname(ip) # Converting ip or host name to ip
2121
-
2122
- check_valid_ip(ip) # double check
2123
-
2124
- if port is None:
2125
- port = int(os.environ.get("PYMECHANICAL_PORT", MECHANICAL_DEFAULT_PORT))
2126
- check_valid_port(port)
2127
-
2128
- # connect to an existing instance if enabled
2129
- if start_instance is None:
2130
- start_instance = check_valid_start_instance(
2131
- os.environ.get("PYMECHANICAL_START_INSTANCE", True)
2132
- )
2133
-
2134
- # special handling when building the gallery outside of CI. This
2135
- # creates an instance of Mechanical the first time if PYMECHANICAL_START_INSTANCE
2136
- # is False.
2137
- # when you launch, treat it as local.
2138
- # when you connect, treat it as remote. We cannot differentiate between
2139
- # local vs container scenarios. In the container scenarios, we could be connecting
2140
- # to a container using local ip and port
2141
- if pymechanical.BUILDING_GALLERY: # pragma: no cover
2142
- # launch an instance of PyMechanical if it does not already exist and
2143
- # starting instances is allowed
2144
- if start_instance and GALLERY_INSTANCE[0] is None:
2145
- mechanical = launch_mechanical(
2146
- start_instance=True,
2147
- cleanup_on_exit=False,
2148
- loglevel=loglevel,
2149
- )
2150
- GALLERY_INSTANCE[0] = {"ip": mechanical._ip, "port": mechanical._port}
2151
- return mechanical
2152
-
2153
- # otherwise, connect to the existing gallery instance if available
2154
- elif GALLERY_INSTANCE[0] is not None:
2155
- mechanical = Mechanical(
2156
- ip=GALLERY_INSTANCE[0]["ip"],
2157
- port=GALLERY_INSTANCE[0]["port"],
2158
- cleanup_on_exit=False,
2159
- loglevel=loglevel,
2160
- local=False,
2161
- )
2162
- # we are connecting to the existing gallery instance,
2163
- # we need to clear Mechanical.
2164
- mechanical.clear()
2165
-
2166
- return mechanical
2167
-
2168
- # finally, if running on CI/CD, connect to the default instance
2169
- else:
2170
- mechanical = Mechanical(
2171
- ip=ip, port=port, cleanup_on_exit=False, loglevel=loglevel, local=False
2172
- )
2173
- # we are connecting for gallery generation,
2174
- # we need to clear Mechanical.
2175
- mechanical.clear()
2176
- return mechanical
2177
-
2178
- if not start_instance:
2179
- mechanical = Mechanical(
2180
- ip=ip,
2181
- port=port,
2182
- loglevel=loglevel,
2183
- log_file=log_file,
2184
- log_mechanical=log_mechanical,
2185
- timeout=start_timeout,
2186
- cleanup_on_exit=cleanup_on_exit,
2187
- keep_connection_alive=keep_connection_alive,
2188
- local=False,
2189
- )
2190
- if clear_on_connect:
2191
- mechanical.clear()
2192
-
2193
- # setting ip for the grpc server
2194
- if ip != LOCALHOST: # Default local ip is 127.0.0.1
2195
- create_ip_file(ip, os.getcwd())
2196
-
2197
- return mechanical
2198
-
2199
- # verify executable
2200
- if exec_file is None:
2201
- exec_file = get_mechanical_path(allow_input)
2202
- if exec_file is None: # pragma: no cover
2203
- raise FileNotFoundError(
2204
- "Path to the Mechanical executable file is invalid or cache cannot be loaded. "
2205
- "Enter a path manually by specifying a value for the "
2206
- "'exec_file' parameter."
2207
- )
2208
- else: # verify ansys exists at this location
2209
- if not os.path.isfile(exec_file):
2210
- raise FileNotFoundError(
2211
- f'This path for the Mechanical executable is invalid: "{exec_file}"\n'
2212
- "Enter a path manually by specifying a value for the "
2213
- "'exec_file' parameter."
2214
- )
2215
-
2216
- start_parm = {
2217
- "exec_file": exec_file,
2218
- "batch": batch,
2219
- "additional_switches": additional_switches,
2220
- "additional_envs": additional_envs,
2221
- }
2222
-
2223
- try:
2224
- port = launch_grpc(port=port, verbose=verbose_mechanical, **start_parm)
2225
- start_parm["local"] = True
2226
- mechanical = Mechanical(
2227
- ip=ip,
2228
- port=port,
2229
- loglevel=loglevel,
2230
- log_file=log_file,
2231
- log_mechanical=log_mechanical,
2232
- timeout=start_timeout,
2233
- cleanup_on_exit=cleanup_on_exit,
2234
- keep_connection_alive=keep_connection_alive,
2235
- **start_parm,
2236
- )
2237
- except Exception as exception: # pragma: no cover
2238
- # pass
2239
- raise exception
2240
-
2241
- return mechanical
2242
-
2243
-
2244
- def connect_to_mechanical(
2245
- ip=None,
2246
- port=None,
2247
- loglevel="ERROR",
2248
- log_file=False,
2249
- log_mechanical=None,
2250
- connect_timeout=120,
2251
- clear_on_connect=False,
2252
- cleanup_on_exit=False,
2253
- keep_connection_alive=True,
2254
- ) -> Mechanical:
2255
- """Connect to an existing Mechanical server instance.
2256
-
2257
- Parameters
2258
- ----------
2259
- ip : str, optional
2260
- IP address for connecting to an existing Mechanical instance. The
2261
- IP address defaults to ``"127.0.0.1"``.
2262
- port : int, optional
2263
- Port to listen on for an existing Mechanical instance. The default is ``None``,
2264
- in which case ``10000`` is used. You can override the
2265
- default behavior of this parameter with the
2266
- ``PYMECHANICAL_PORT=<VALID PORT>`` environment variable.
2267
- loglevel : str, optional
2268
- Level of messages to print to the console.
2269
- Options are:
2270
-
2271
- - ``"WARNING"``: Prints only Ansys warning messages.
2272
- - ``"ERROR"``: Prints only Ansys error messages.
2273
- - ``"INFO"``: Prints all Ansys messages.
2274
-
2275
- The default is ``WARNING``.
2276
- log_file : bool, optional
2277
- Whether to copy the messages to a file named ``logs.log``, which is
2278
- located where the Python script is executed. The default is ``False``.
2279
- log_mechanical : str, optional
2280
- Path to the output file on the local disk to write every script
2281
- command to. The default is ``None``. However, you might set
2282
- ``"log_mechanical='pymechanical_log.txt'"`` to write all commands that are
2283
- sent to Mechanical via PyMechanical to this file. You can then use these
2284
- commands to run a script within Mechanical without PyMechanical.
2285
- connect_timeout : float, optional
2286
- Maximum allowable time in seconds to connect to the Mechanical server.
2287
- The default is ``120``.
2288
- clear_on_connect : bool, optional
2289
- Whether to clear the Mechanical instance when connecting. The default is ``False``.
2290
- When ``True``, a fresh environment is provided when you connect to Mechanical.
2291
- cleanup_on_exit : bool, optional
2292
- Whether to exit Mechanical when Python exits. The default is ``False``.
2293
- When ``False``, Mechanical is not exited when the garbage for this Mechanical
2294
- instance is collected.
2295
- keep_connection_alive : bool, optional
2296
- Whether to keep the gRPC connection alive by running a background thread
2297
- and making dummy calls for remote connections. The default is ``True``.
2298
-
2299
- Returns
2300
- -------
2301
- ansys.mechanical.core.mechanical.Mechanical
2302
- Instance of Mechanical.
2303
-
2304
- Examples
2305
- --------
2306
- Connect to an existing Mechanical instance at IP address ``192.168.1.30`` on port
2307
- ``50001``..
2308
-
2309
-
2310
- >>> from ansys.mechanical.core import connect_to_mechanical
2311
- >>> pymech = connect_to_mechanical(ip='192.168.1.30', port=50001)
2312
- """
2313
- return launch_mechanical(
2314
- start_instance=False,
2315
- loglevel=loglevel,
2316
- log_file=log_file,
2317
- log_mechanical=log_mechanical,
2318
- start_timeout=connect_timeout,
2319
- port=port,
2320
- ip=ip,
2321
- clear_on_connect=clear_on_connect,
2322
- cleanup_on_exit=cleanup_on_exit,
2323
- keep_connection_alive=keep_connection_alive,
2324
- )
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
+
23
+ """Connect to Mechanical gRPC server and issues commands."""
24
+ import atexit
25
+ from contextlib import closing
26
+ import datetime
27
+ import fnmatch
28
+ from functools import wraps
29
+ import glob
30
+ import os
31
+ import pathlib
32
+ import socket
33
+ import subprocess # nosec: B404
34
+ import sys
35
+ import threading
36
+ import time
37
+ from typing import Optional
38
+ import warnings
39
+ import weakref
40
+
41
+ import ansys.api.mechanical.v0.mechanical_pb2 as mechanical_pb2
42
+ import ansys.api.mechanical.v0.mechanical_pb2_grpc as mechanical_pb2_grpc
43
+ import ansys.tools.path as atp
44
+ import grpc
45
+
46
+ import ansys.mechanical.core as pymechanical
47
+ from ansys.mechanical.core import LOG
48
+ from ansys.mechanical.core.errors import (
49
+ MechanicalExitedError,
50
+ MechanicalRuntimeError,
51
+ VersionError,
52
+ protect_grpc,
53
+ )
54
+ from ansys.mechanical.core.launcher import MechanicalLauncher
55
+ from ansys.mechanical.core.misc import (
56
+ check_valid_ip,
57
+ check_valid_port,
58
+ check_valid_start_instance,
59
+ threaded,
60
+ )
61
+
62
+ # Check if PyPIM is installed
63
+ try:
64
+ import ansys.platform.instancemanagement as pypim # pragma: nocover noqa: F401
65
+
66
+ _HAS_ANSYS_PIM = True
67
+ """Whether or not PyPIM exists."""
68
+ except ImportError:
69
+ _HAS_ANSYS_PIM = False
70
+
71
+
72
+ # Checking if tqdm is installed.
73
+ # If it is, the default value for progress_bar is true.
74
+ try:
75
+ from tqdm import tqdm
76
+
77
+ _HAS_TQDM = True
78
+ """Whether or not tqdm is installed."""
79
+ except ModuleNotFoundError: # pragma: no cover
80
+ _HAS_TQDM = False
81
+
82
+ # Default 256 MB message length
83
+ MAX_MESSAGE_LENGTH = int(os.environ.get("PYMECHANICAL_MAX_MESSAGE_LENGTH", 256 * 1024**2))
84
+ """Default message length."""
85
+
86
+ # Chunk sizes for streaming and file streaming
87
+ DEFAULT_CHUNK_SIZE = 256 * 1024 # 256 kB
88
+ """Default chunk size."""
89
+ DEFAULT_FILE_CHUNK_SIZE = 1024 * 1024 # 1MB
90
+ """Default file chunk size."""
91
+
92
+
93
+ def setup_logger(loglevel="INFO", log_file=True, mechanical_instance=None):
94
+ """Initialize the logger for the given mechanical instance."""
95
+ # Return existing log if this function has already been called
96
+ if hasattr(setup_logger, "log"):
97
+ return setup_logger.log
98
+ else:
99
+ setup_logger.log = LOG.add_instance_logger("Mechanical", mechanical_instance)
100
+
101
+ setup_logger.log.setLevel(loglevel)
102
+
103
+ if log_file:
104
+ if isinstance(log_file, str):
105
+ setup_logger.log.log_to_file(filename=log_file, level=loglevel)
106
+
107
+ return setup_logger.log
108
+
109
+
110
+ def suppress_logging(func):
111
+ """Decorate a function to suppress the logging for a Mechanical instance."""
112
+
113
+ @wraps(func)
114
+ def wrapper(*args, **kwargs):
115
+ mechanical = args[0]
116
+ prior_log_level = mechanical.log.level
117
+ if prior_log_level != "CRITICAL":
118
+ mechanical.set_log_level("CRITICAL")
119
+
120
+ out = func(*args, **kwargs)
121
+
122
+ if prior_log_level != "CRITICAL":
123
+ mechanical.set_log_level(prior_log_level)
124
+
125
+ return out
126
+
127
+ return wrapper
128
+
129
+
130
+ LOCALHOST = "127.0.0.1"
131
+ """Localhost address."""
132
+
133
+ MECHANICAL_DEFAULT_PORT = 10000
134
+ """Default Mechanical port."""
135
+
136
+ GALLERY_INSTANCE = [None]
137
+ """List of gallery instances."""
138
+
139
+
140
+ def _cleanup_gallery_instance(): # pragma: no cover
141
+ """Clean up any leftover instances of Mechanical from building the gallery."""
142
+ if GALLERY_INSTANCE[0] is not None:
143
+ mechanical = Mechanical(
144
+ ip=GALLERY_INSTANCE[0]["ip"],
145
+ port=GALLERY_INSTANCE[0]["port"],
146
+ )
147
+ mechanical.exit(force=True)
148
+
149
+
150
+ atexit.register(_cleanup_gallery_instance)
151
+
152
+
153
+ def port_in_use(port, host=LOCALHOST):
154
+ """Check whether a port is in use at the given host.
155
+
156
+ You must actually *bind* the address. Just checking if you can create
157
+ a socket is insufficient because it is possible to run into permission
158
+ errors like::
159
+
160
+ An attempt was made to access a socket in a way forbidden by its
161
+ access permissions.
162
+ """
163
+ with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
164
+ if sock.connect_ex((host, port)) == 0:
165
+ return True
166
+ else:
167
+ return False
168
+
169
+
170
+ def check_ports(port_range, ip="localhost"):
171
+ """Check the state of ports in a port range."""
172
+ ports = {}
173
+ for port in port_range:
174
+ ports[port] = port_in_use(port, ip)
175
+ return ports
176
+
177
+
178
+ def close_all_local_instances(port_range=None, use_thread=True):
179
+ """Close all Mechanical instances within a port range.
180
+
181
+ You can use this method when cleaning up from a failed pool or
182
+ batch run.
183
+
184
+ Parameters
185
+ ----------
186
+ port_range : list, optional
187
+ List of a range of ports to use when cleaning up Mechanical. The
188
+ default is ``None``, in which case the ports managed by
189
+ PyMechanical are used.
190
+
191
+ use_thread : bool, optional
192
+ Whether to use threads to close the Mechanical instances.
193
+ The default is ``True``. So this call will return immediately.
194
+
195
+ Examples
196
+ --------
197
+ Close all Mechanical instances connected on local ports.
198
+
199
+ >>> import ansys.mechanical.core as pymechanical
200
+ >>> pymechanical.close_all_local_instances()
201
+
202
+ """
203
+ if port_range is None:
204
+ port_range = pymechanical.LOCAL_PORTS
205
+
206
+ @threaded
207
+ def close_mechanical_threaded(port, name="Closing Mechanical instance in a thread"):
208
+ close_mechanical(port, name)
209
+
210
+ def close_mechanical(port, name="Closing Mechanical instance"):
211
+ try:
212
+ mechanical = Mechanical(port=port)
213
+ LOG.debug(f"{name}: {mechanical.name}.")
214
+ mechanical.exit(force=True)
215
+ except OSError: # pragma: no cover
216
+ pass
217
+
218
+ ports = check_ports(port_range)
219
+ for port_temp, state in ports.items():
220
+ if state:
221
+ if use_thread:
222
+ close_mechanical_threaded(port_temp)
223
+ else:
224
+ close_mechanical(port_temp)
225
+
226
+
227
+ def create_ip_file(ip, path):
228
+ """Create the ``mylocal.ip`` file needed to change the IP address of the gRPC server."""
229
+ file_name = os.path.join(path, "mylocal.ip")
230
+ with open(file_name, "w", encoding="utf-8") as f:
231
+ f.write(ip)
232
+
233
+
234
+ def get_mechanical_path(allow_input=True):
235
+ """Get path.
236
+
237
+ Deprecated - use `ansys.tools.path.get_mechanical_path` instead
238
+ """
239
+ return atp.get_mechanical_path(allow_input)
240
+
241
+
242
+ def check_valid_mechanical():
243
+ """Change to see if the default Mechanical path is valid.
244
+
245
+ Example (windows)
246
+ -----------------
247
+
248
+ >>> from ansys.mechanical.core import mechanical
249
+ >>> from ansys.tools.path import change_default_mechanical_path
250
+ >>> mechanical_path = 'C:/Program Files/ANSYS Inc/v251/aisol/bin/win64/AnsysWBU.exe'
251
+ >>> change_default_mechanical_path(mechanical_pth)
252
+ >>> mechanical.check_valid_mechanical()
253
+ True
254
+
255
+
256
+ """
257
+ mechanical_path = atp.get_mechanical_path(False)
258
+ if mechanical_path is None:
259
+ return False
260
+ mechanical_version = atp.version_from_path("mechanical", mechanical_path)
261
+ return not (mechanical_version < 232 and os.name != "posix")
262
+
263
+
264
+ def change_default_mechanical_path(exe_loc):
265
+ """Change default path.
266
+
267
+ Deprecated - use `ansys.tools.path.change_default_mechanical_path` instead.
268
+ """
269
+ return atp.change_default_mechanical_path(exe_loc)
270
+
271
+
272
+ def save_mechanical_path(exe_loc=None): # pragma: no cover
273
+ """Save path.
274
+
275
+ Deprecated - use `ansys.tools.path.save_mechanical_path` instead.
276
+ """
277
+ return atp.save_mechanical_path(exe_loc)
278
+
279
+
280
+ client_to_server_loglevel = {
281
+ "DEBUG": 1,
282
+ "INFO": 2,
283
+ "WARN": 3,
284
+ "WARNING": 3,
285
+ "ERROR": 4,
286
+ "CRITICAL": 5,
287
+ }
288
+
289
+
290
+ class Mechanical(object):
291
+ """Connects to a gRPC Mechanical server and allows commands to be passed."""
292
+
293
+ # Required by `_name` method to be defined before __init__ be
294
+ _ip = None
295
+ _port = None
296
+
297
+ def __init__(
298
+ self,
299
+ ip=None,
300
+ port=None,
301
+ timeout=60.0,
302
+ loglevel="WARNING",
303
+ log_file=False,
304
+ log_mechanical=None,
305
+ cleanup_on_exit=False,
306
+ channel=None,
307
+ remote_instance=None,
308
+ keep_connection_alive=True,
309
+ **kwargs,
310
+ ):
311
+ """Initialize the member variable based on the arguments.
312
+
313
+ Parameters
314
+ ----------
315
+ ip : str, optional
316
+ IP address to connect to the server. The default is ``None``
317
+ in which case ``localhost`` is used.
318
+ port : int, optional
319
+ Port to connect to the Mecahnical server. The default is ``None``,
320
+ in which case ``10000`` is used.
321
+ timeout : float, optional
322
+ Maximum allowable time for connecting to the Mechanical server.
323
+ The default is ``60.0``.
324
+ loglevel : str, optional
325
+ Level of messages to print to the console. The default is ``WARNING``.
326
+
327
+ - ``ERROR`` prints only error messages.
328
+ - ``WARNING`` prints warning and error messages.
329
+ - ``INFO`` prints info, warning and error messages.
330
+ - ``DEBUG`` prints debug, info, warning and error messages.
331
+
332
+ log_file : bool, optional
333
+ Whether to copy the messages to a file named ``logs.log``, which is
334
+ located where the Python script is executed. The default is ``False``.
335
+ log_mechanical : str, optional
336
+ Path to the output file on the local disk for writing every script
337
+ command to. The default is ``None``. However, you might set
338
+ ``"log_mechanical='pymechanical_log.txt"`` to write all commands that are
339
+ sent to Mechanical via PyMechanical in this file so that you can use them
340
+ to run a script within Mechanical without PyMechanical.
341
+ cleanup_on_exit : bool, optional
342
+ Whether to exit Mechanical when Python exits. The default is ``False``,
343
+ in which case Mechanical is not exited when the garbage for this Mechanical
344
+ instance is collected.
345
+ channel : grpc.Channel, optional
346
+ gRPC channel to use for the connection. The default is ``None``.
347
+ You can use this parameter as an alternative to the ``ip`` and ``port``
348
+ parameters.
349
+ remote_instance : ansys.platform.instancemanagement.Instance
350
+ Corresponding remote instance when Mechanical is launched
351
+ through PyPIM. The default is ``None``. If a remote instance
352
+ is specified, this instance is deleted when the
353
+ :func:`mecahnical.exit <ansys.mechanical.core.Mechanical.exit>`
354
+ function is called.
355
+ keep_connection_alive : bool, optional
356
+ Whether to keep the gRPC connection alive by running a background thread
357
+ and making dummy calls for remote connections. The default is ``True``.
358
+
359
+ Examples
360
+ --------
361
+ Connect to a Mechanical instance already running on locally on the
362
+ default port (``10000``).
363
+
364
+ >>> from ansys.mechanical import core as pymechanical
365
+ >>> mechanical = pymechanical.Mechanical()
366
+
367
+ Connect to a Mechanical instance running on the LAN on a default port.
368
+
369
+ >>> mechanical = pymechanical.Mechanical('192.168.1.101')
370
+
371
+ Connect to a Mechanical instance running on the LAN on a non-default port.
372
+
373
+ >>> mechanical = pymechanical.Mechanical('192.168.1.101', port=60001)
374
+
375
+ If you want to customize the channel, you can connect directly to gRPC channels.
376
+ For example, if you want to create an insecure channel with a maximum message
377
+ length of 8 MB, you would run:
378
+
379
+ >>> import grpc
380
+ >>> channel_temp = grpc.insecure_channel(
381
+ ... '127.0.0.1:10000',
382
+ ... options=[
383
+ ... ("grpc.max_receive_message_length", 8*1024**2),
384
+ ... ],
385
+ ... )
386
+ >>> mechanical = pymechanical.Mechanical(channel=channel_temp)
387
+ """
388
+ self._remote_instance = remote_instance
389
+ self._channel = channel
390
+ self._keep_connection_alive = keep_connection_alive
391
+
392
+ self._locked = False # being used within MechanicalPool
393
+
394
+ # ip could be a machine name. Convert it to an IP address.
395
+ ip_temp = ip
396
+ if channel is not None:
397
+ if ip is not None or port is not None:
398
+ raise ValueError(
399
+ "If `channel` is specified, neither `port` nor `ip` can be specified."
400
+ )
401
+ elif ip is None:
402
+ ip_temp = "127.0.0.1"
403
+ else:
404
+ ip_temp = socket.gethostbyname(ip) # Converting ip or host name to ip
405
+
406
+ self._ip = ip_temp
407
+ self._port = port
408
+
409
+ self._start_parm = kwargs
410
+
411
+ self._cleanup_on_exit = cleanup_on_exit
412
+ self._busy = False # used to check if running a command on the server
413
+
414
+ self._local = ip_temp in ["127.0.0.1", "127.0.1.1", "localhost"]
415
+ if "local" in kwargs: # pragma: no cover # allow this to be overridden
416
+ self._local = kwargs["local"]
417
+
418
+ self._health_response_queue = None
419
+ self._exiting = False
420
+ self._exited = None
421
+
422
+ self._version = None
423
+
424
+ if port is None:
425
+ port = MECHANICAL_DEFAULT_PORT
426
+ self._port = port
427
+
428
+ self._stub = None
429
+ self._timeout = timeout
430
+
431
+ if channel is None:
432
+ self._channel = self._create_channel(ip_temp, port)
433
+ else:
434
+ self._channel = channel
435
+
436
+ self._logLevel = loglevel
437
+ self._log_file = log_file
438
+ self._log_mechanical = log_mechanical
439
+
440
+ self._log = LOG.add_instance_logger(self.name, self, level=loglevel) # instance logger
441
+ # adding a file handler to the logger
442
+ if log_file:
443
+ if not isinstance(log_file, str):
444
+ log_file = "instance.log"
445
+ self._log.log_to_file(filename=log_file, level=loglevel)
446
+
447
+ self._log_file_mechanical = log_mechanical
448
+ if log_mechanical:
449
+ if not isinstance(log_mechanical, str):
450
+ self._log_file_mechanical = "pymechanical_log.txt"
451
+ else:
452
+ self._log_file_mechanical = log_mechanical
453
+
454
+ # temporarily disable logging
455
+ # useful when we run some dummy calls
456
+ self._disable_logging = False
457
+
458
+ if self._local:
459
+ self.log_info("Mechanical connection is treated as local.")
460
+ else:
461
+ self.log_info("Mechanical connection is treated as remote.")
462
+
463
+ # connect and validate to the channel
464
+ self._multi_connect(timeout=timeout)
465
+ self.log_info("Mechanical is ready to accept grpc calls.")
466
+ self._rpc_type = "grpc"
467
+
468
+ def __del__(self): # pragma: no cover
469
+ """Clean up on exit."""
470
+ if self._cleanup_on_exit:
471
+ try:
472
+ self.exit(force=True)
473
+ except grpc.RpcError as e:
474
+ self.log_error(f"exit: {e}")
475
+
476
+ # def _set_log_level(self, level):
477
+ # """Set an alias for the log level."""
478
+ # self.set_log_level(level)
479
+
480
+ @property
481
+ def log(self):
482
+ """Log associated with the current Mechanical instance."""
483
+ return self._log
484
+
485
+ @property
486
+ def version(self) -> str:
487
+ """Get the Mechanical version based on the instance.
488
+
489
+ Examples
490
+ --------
491
+ Get the version of the connected Mechanical instance.
492
+
493
+ >>> mechanical.version
494
+ '251'
495
+ """
496
+ if self._version is None:
497
+ try:
498
+ self._disable_logging = True
499
+ script = (
500
+ 'clr.AddReference("Ans.Utilities")\n'
501
+ "import Ansys\n"
502
+ "config = Ansys.Utilities.ApplicationConfiguration.DefaultConfiguration\n"
503
+ "config.VersionInfo.VersionString"
504
+ )
505
+ self._version = self.run_python_script(script)
506
+ except grpc.RpcError: # pragma: no cover
507
+ raise
508
+ finally:
509
+ self._disable_logging = False
510
+ return self._version
511
+
512
+ @property
513
+ def name(self):
514
+ """Name (unique identifier) of the Mechanical instance."""
515
+ try:
516
+ if self._channel is not None:
517
+ if self._remote_instance is not None: # pragma: no cover
518
+ return f"GRPC_{self._channel._channel._channel.target().decode()}"
519
+ else:
520
+ return f"GRPC_{self._channel._channel.target().decode()}"
521
+ except Exception as e: # pragma: no cover
522
+ LOG.error(f"Error getting the Mechanical instance name: {str(e)}")
523
+
524
+ return f"GRPC_instance_{id(self)}" # pragma: no cover
525
+
526
+ @property
527
+ def busy(self):
528
+ """Return True when the Mechanical gRPC server is executing a command."""
529
+ return self._busy
530
+
531
+ @property
532
+ def locked(self):
533
+ """Instance is in use within a pool."""
534
+ return self._locked
535
+
536
+ @locked.setter
537
+ def locked(self, new_value):
538
+ """Instance is in use within a pool."""
539
+ self._locked = new_value
540
+
541
+ def _multi_connect(self, n_attempts=5, timeout=60):
542
+ """Try to connect over a series of attempts to the channel.
543
+
544
+ Parameters
545
+ ----------
546
+ n_attempts : int, optional
547
+ Number of connection attempts. The default is ``5``.
548
+ timeout : float, optional
549
+ Maximum allowable time in seconds for establishing a connection.
550
+ The default is ``60``.
551
+ """
552
+ # This prevents a single failed connection from blocking other attempts
553
+ connected = False
554
+ attempt_timeout = timeout / n_attempts
555
+ self.log_debug(
556
+ f"timetout:{timeout} n_attempts:{n_attempts} attempt_timeout={attempt_timeout}"
557
+ )
558
+
559
+ max_time = time.time() + timeout
560
+ i = 1
561
+ while time.time() < max_time and i <= n_attempts:
562
+ self.log_debug(f"Connection attempt {i} with attempt timeout {attempt_timeout}s")
563
+ connected = self._connect(timeout=attempt_timeout)
564
+
565
+ if connected:
566
+ self.log_debug(f"Connection attempt {i} succeeded.")
567
+ break
568
+
569
+ i += 1
570
+ else: # pragma: no cover
571
+ self.log_debug(
572
+ f"Reached either maximum amount of connection attempts "
573
+ f"({n_attempts}) or timeout ({timeout} s)."
574
+ )
575
+
576
+ if not connected: # pragma: no cover
577
+ raise IOError(f"Unable to connect to Mechanical instance at {self._channel_str}.")
578
+
579
+ @property
580
+ def _channel_str(self):
581
+ """Target string, generally in the form of ``ip:port``, such as ``127.0.0.1:10000``."""
582
+ if self._channel is not None:
583
+ if self._remote_instance is not None:
584
+ return self._channel._channel._channel.target().decode() # pragma: no cover
585
+ else:
586
+ return self._channel._channel.target().decode()
587
+ return "" # pragma: no cover
588
+
589
+ def _connect(self, timeout=12, enable_health_check=False):
590
+ """Connect a gRPC channel to a remote or local Mechanical instance.
591
+
592
+ Parameters
593
+ ----------
594
+ timeout : float
595
+ Maximum allowable time in seconds for establishing a connection. The
596
+ default is ``12``.
597
+ enable_health_check : bool, optional
598
+ Whether to enable a check to see if the connection is healthy.
599
+ The default is ``False``.
600
+ """
601
+ self._state = grpc.channel_ready_future(self._channel)
602
+ self._stub = mechanical_pb2_grpc.MechanicalServiceStub(self._channel)
603
+
604
+ # verify connection
605
+ time_start = time.time()
606
+ while ((time.time() - time_start) < timeout) and not self._state._matured:
607
+ time.sleep(0.01)
608
+
609
+ if not self._state._matured: # pragma: no cover
610
+ return False
611
+
612
+ self.log_debug("Established a connection to the Mechanical gRPC server.")
613
+
614
+ self.wait_till_mechanical_is_ready(timeout)
615
+
616
+ # keeps Mechanical session alive
617
+ self._timer = None
618
+ if not self._local and self._keep_connection_alive: # pragma: no cover
619
+ self._initialised = threading.Event()
620
+ self._t_trigger = time.time()
621
+ self._t_delay = 30
622
+ self._timer = threading.Thread(
623
+ target=Mechanical._threaded_heartbeat, args=(weakref.proxy(self),)
624
+ )
625
+ self._timer.daemon = True
626
+ self._timer.start()
627
+
628
+ # enable health check
629
+ if enable_health_check: # pragma: no cover
630
+ self._enable_health_check()
631
+
632
+ self.__server_version = None
633
+
634
+ return True
635
+
636
+ def _enable_health_check(self): # pragma: no cover
637
+ """Place the status of the health check in the health response queue."""
638
+ # lazy imports here to speed up module load
639
+ from grpc_health.v1 import health_pb2, health_pb2_grpc
640
+
641
+ def _consume_responses(response_iterator, response_queue):
642
+ try:
643
+ for response in response_iterator:
644
+ response_queue.put(response)
645
+ # NOTE: We're doing absolutely nothing with this as
646
+ # this point since the server-side health check
647
+ # doesn't change state.
648
+ except Exception:
649
+ if self._exiting:
650
+ return
651
+ self._exited = True
652
+ raise MechanicalExitedError(
653
+ "Lost connection with the Mechanical gRPC server."
654
+ ) from None
655
+
656
+ # enable health check
657
+ from queue import Queue
658
+
659
+ request = health_pb2.HealthCheckRequest()
660
+ self._health_stub = health_pb2_grpc.HealthStub(self._channel)
661
+ rendezvous = self._health_stub.Watch(request)
662
+
663
+ # health check feature implemented after 2023 R1
664
+ try:
665
+ status = rendezvous.next()
666
+ except Exception as err:
667
+ if err.code().name != "UNIMPLEMENTED":
668
+ raise err
669
+ return
670
+
671
+ if status.status != health_pb2.HealthCheckResponse.SERVING:
672
+ raise MechanicalRuntimeError(
673
+ "Cannot enable health check and/or connect to the Mechanical server."
674
+ )
675
+
676
+ self._health_response_queue = Queue()
677
+
678
+ # allow main process to exit by setting daemon to true
679
+ thread = threading.Thread(
680
+ target=_consume_responses,
681
+ args=(rendezvous, self._health_response_queue),
682
+ daemon=True,
683
+ )
684
+ thread.start()
685
+
686
+ def _threaded_heartbeat(self): # pragma: no cover
687
+ """To call from a thread to verify that a Mechanical instance is alive."""
688
+ self._initialised.set()
689
+ while True:
690
+ if self._exited:
691
+ break
692
+ try:
693
+ time.sleep(self._t_delay)
694
+ if not self.is_alive:
695
+ break
696
+ except ReferenceError:
697
+ break
698
+ # except Exception:
699
+ # continue
700
+
701
+ def _create_channel(self, ip, port):
702
+ """Create an unsecured gRPC channel."""
703
+ check_valid_ip(ip)
704
+
705
+ # open the channel
706
+ channel_str = f"{ip}:{port}"
707
+ LOG.debug(f"Opening insecure channel at {channel_str}.")
708
+ return grpc.insecure_channel(
709
+ channel_str,
710
+ options=[
711
+ ("grpc.max_receive_message_length", MAX_MESSAGE_LENGTH),
712
+ ],
713
+ )
714
+
715
+ @property
716
+ def is_alive(self) -> bool:
717
+ """Whether there is an active connection to the Mechanical gRPC server."""
718
+ if self._exited:
719
+ return False
720
+
721
+ if self._busy: # pragma: no cover
722
+ return True
723
+
724
+ try: # pragma: no cover
725
+ self._make_dummy_call()
726
+ return True
727
+ except grpc.RpcError:
728
+ return False
729
+
730
+ @staticmethod
731
+ def set_log_level(loglevel):
732
+ """Set the log level.
733
+
734
+ Parameters
735
+ ----------
736
+ loglevel : str, int
737
+ Level of logging. Options are ``"DEBUG"``, ``"INFO"``, ``"WARNING"``
738
+ and ``"ERROR"``.
739
+
740
+ Examples
741
+ --------
742
+ Set the log level to the ``"DEBUG"`` level.
743
+
744
+ # >>> mechanical.set_log_level('DEBUG')
745
+ #
746
+ # Set the log level to info
747
+ #
748
+ # >>> mechanical.set_log_level('INFO')
749
+ #
750
+ # Set the log level to warning
751
+ #
752
+ # >>> mechanical.set_log_level('WARNING')
753
+ #
754
+ # Set the log level to error
755
+ #
756
+ # >>> mechanical.set_log_level('ERROR')
757
+ """
758
+ if isinstance(loglevel, str):
759
+ loglevel = loglevel.upper()
760
+ setup_logger(loglevel=loglevel)
761
+
762
+ def get_product_info(self):
763
+ """Get product information by running a script on the Mechanical gRPC server."""
764
+
765
+ def _get_jscript_product_info_command():
766
+ return (
767
+ 'ExtAPI.Application.ScriptByName("jscript").ExecuteCommand'
768
+ '("var productInfo = DS.Script.getProductInfo();returnFromScript(productInfo);")'
769
+ )
770
+
771
+ def _get_python_product_info_command():
772
+ return (
773
+ 'clr.AddReference("Ansys.Mechanical.Application")\n'
774
+ "Ansys.Mechanical.Application.ProductInfo.ProductInfoAsString"
775
+ )
776
+
777
+ try:
778
+ self._disable_logging = True
779
+ if int(self.version) >= 232:
780
+ script = _get_python_product_info_command()
781
+ else:
782
+ script = _get_jscript_product_info_command()
783
+ return self.run_python_script(script)
784
+ except grpc.RpcError:
785
+ raise
786
+ finally:
787
+ self._disable_logging = False
788
+
789
+ @suppress_logging
790
+ def __repr__(self):
791
+ """Get the user-readable string form of the Mechanical instance."""
792
+ try:
793
+ if self._exited:
794
+ return "Mechanical exited."
795
+ return self.get_product_info()
796
+ except grpc.RpcError:
797
+ return "Error getting product info."
798
+
799
+ def launch(self, cleanup_on_exit=True):
800
+ """Launch Mechanical in batch or UI mode.
801
+
802
+ Parameters
803
+ ----------
804
+ cleanup_on_exit : bool, optional
805
+ Whether to exit Mechanical when Python exits. The default is ``True``.
806
+ When ``False``, Mechanical is not exited when the garbage for this
807
+ Mechanical instance is collected.
808
+ """
809
+ if not self._local:
810
+ raise RuntimeError("Can only launch with a local instance of Mechanical.")
811
+
812
+ # let us respect the current cleanup behavior
813
+ if self._cleanup_on_exit:
814
+ self.exit()
815
+
816
+ exec_file = self._start_parm.get("exec_file", get_mechanical_path(allow_input=False))
817
+ batch = self._start_parm.get("batch", True)
818
+ additional_switches = self._start_parm.get("additional_switches", None)
819
+ additional_envs = self._start_parm.get("additional_envs", None)
820
+ port = launch_grpc(
821
+ exec_file=exec_file,
822
+ batch=batch,
823
+ additional_switches=additional_switches,
824
+ additional_envs=additional_envs,
825
+ verbose=True,
826
+ )
827
+ # update the new cleanup behavior
828
+ self._cleanup_on_exit = cleanup_on_exit
829
+ self._port = port
830
+ self._channel = self._create_channel(self._ip, port)
831
+ self._connect(port)
832
+
833
+ self.log_info("Mechanical is ready to accept gRPC calls.")
834
+
835
+ def wait_till_mechanical_is_ready(self, wait_time=-1):
836
+ """Wait until Mechanical is ready.
837
+
838
+ Parameters
839
+ ----------
840
+ wait_time : float, optional
841
+ Maximum allowable time in seconds for connecting to the Mechanical gRPC server.
842
+ """
843
+ time_1 = datetime.datetime.now()
844
+
845
+ sleep_time = 0.5
846
+ if wait_time == -1: # pragma: no cover
847
+ self.log_info("Waiting for Mechanical to be ready...")
848
+ else:
849
+ self.log_info(f"Waiting for Mechanical to be ready. Maximum wait time: {wait_time}s")
850
+
851
+ while not self.__isMechanicalReady():
852
+ time_2 = datetime.datetime.now()
853
+ time_interval = time_2 - time_1
854
+ time_interval_seconds = int(time_interval.total_seconds())
855
+
856
+ self.log_debug(
857
+ f"Mechanical is not ready. You've been waiting for {time_interval_seconds}."
858
+ )
859
+ if self._timeout != -1:
860
+ if time_interval_seconds > wait_time:
861
+ self.log_debug(
862
+ f"Allowed wait time {wait_time}s. "
863
+ f"Waited so for {time_interval_seconds}s, "
864
+ f"before throwing the error."
865
+ )
866
+ raise RuntimeError(
867
+ f"Couldn't connect to Mechanical. " f"Waited for {time_interval_seconds}s."
868
+ )
869
+
870
+ time.sleep(sleep_time)
871
+
872
+ time_2 = datetime.datetime.now()
873
+ time_interval = time_2 - time_1
874
+ time_interval_seconds = int(time_interval.total_seconds())
875
+
876
+ self.log_info(f"Mechanical is ready. It took {time_interval_seconds} seconds to verify.")
877
+
878
+ def __isMechanicalReady(self):
879
+ """Whether the Mechanical gRPC server is ready.
880
+
881
+ Returns
882
+ -------
883
+ bool
884
+ ``True`` if Mechanical is ready, ``False`` otherwise.
885
+ """
886
+ try:
887
+ script = "ExtAPI.DataModel.Project.ProductVersion"
888
+ self.run_python_script(script)
889
+ except grpc.RpcError as error:
890
+ self.log_debug(f"Mechanical is not ready. Error:{error}.")
891
+ return False
892
+
893
+ return True
894
+
895
+ @staticmethod
896
+ def convert_to_server_log_level(log_level):
897
+ """Convert the log level to the server log level.
898
+
899
+ Parameters
900
+ ----------
901
+ log_level : str
902
+ Level of logging. Options are ``"DEBUG"``, ``"INFO"``, ``"WARNING"``,
903
+ ``"ERROR"``, and ``"CRITICAL"``.
904
+
905
+ Returns
906
+ -------
907
+ Converted log level for the server.
908
+ """
909
+ value = client_to_server_loglevel.get(log_level)
910
+
911
+ if value is not None:
912
+ return value
913
+
914
+ raise ValueError(
915
+ f"Log level {log_level} is invalid. Possible values are "
916
+ f"'DEBUG','INFO', 'WARNING', 'ERROR', and 'CRITICAL'."
917
+ )
918
+
919
+ def run_python_script(
920
+ self, script_block: str, enable_logging=False, log_level="WARNING", progress_interval=2000
921
+ ):
922
+ """Run a Python script block inside Mechanical.
923
+
924
+ It returns the string value of the last executed statement. If the value cannot be
925
+ returned as a string, it will return an empty string.
926
+
927
+ Parameters
928
+ ----------
929
+ script_block : str
930
+ Script block (one or more lines) to run.
931
+ enable_logging: bool, optional
932
+ Whether to enable logging. The default is ``False``.
933
+ log_level: str
934
+ Level of logging. The default is ``"WARNING"``. Options are ``"DEBUG"``,
935
+ ``"INFO"``, ``"WARNING"``, and ``"ERROR"``.
936
+ progress_interval: int, optional
937
+ Frequency in milliseconds for getting log messages from the server.
938
+ The default is ``2000``.
939
+
940
+ Returns
941
+ -------
942
+ str
943
+ Script result.
944
+
945
+ Examples
946
+ --------
947
+ Return a value from a simple calculation.
948
+
949
+ >>> mechanical.run_python_script('2+3')
950
+ '5'
951
+
952
+ Return a string value from Project object.
953
+
954
+ >>> mechanical.run_python_script('ExtAPI.DataModel.Project.ProductVersion')
955
+ '2025 R1'
956
+
957
+ Return an empty string, when you try to return the Project object.
958
+
959
+ >>> mechanical.run_python_script('ExtAPI.DataModel.Project')
960
+ ''
961
+
962
+ Return an empty string for assignments.
963
+
964
+ >>> mechanical.run_python_script('version = ExtAPI.DataModel.Project.ProductVersion')
965
+ ''
966
+
967
+ Return value from the last executed statement from a variable.
968
+
969
+ >>> script='''
970
+ addition = 2 + 3
971
+ multiplication = 3 * 4
972
+ multiplication
973
+ '''
974
+ >>> mechanical.run_python_script(script)
975
+ '12'
976
+
977
+ Return value from last executed statement from a function call.
978
+
979
+ >>> script='''
980
+ import math
981
+ math.pow(2,3)
982
+ '''
983
+ >>> mechanical.run_python_script(script)
984
+ '8'
985
+
986
+ Handle an error scenario.
987
+
988
+ >>> script = 'hello_world()'
989
+ >>> import grpc
990
+ >>> try:
991
+ mechanical.run_python_script(script)
992
+ except grpc.RpcError as error:
993
+ print(error.details())
994
+ name 'hello_world' is not defined
995
+
996
+ """
997
+ self.verify_valid_connection()
998
+ result_as_string = self.__call_run_python_script(
999
+ script_block, enable_logging, log_level, progress_interval
1000
+ )
1001
+ return result_as_string
1002
+
1003
+ def run_python_script_from_file(
1004
+ self, file_path, enable_logging=False, log_level="WARNING", progress_interval=2000
1005
+ ):
1006
+ """Run the contents a python file inside Mechanical.
1007
+
1008
+ It returns the string value of the last executed statement. If the value cannot be
1009
+ returned as a string, it will return an empty string.
1010
+
1011
+ Parameters
1012
+ ----------
1013
+ file_path :
1014
+ Path for the Python file.
1015
+ enable_logging: bool, optional
1016
+ Whether to enable logging. The default is ``False``.
1017
+ log_level: str
1018
+ Level of logging. The default is ``"WARNING"``. Options are ``"DEBUG"``,
1019
+ ``"INFO"``, ``"WARNING"``, and ``"ERROR"``.
1020
+ progress_interval: int, optional
1021
+ Frequency in milliseconds for getting log messages from the server.
1022
+ The default is ``2000``.
1023
+
1024
+ Returns
1025
+ -------
1026
+ str
1027
+ Script result.
1028
+
1029
+ Examples
1030
+ --------
1031
+ Return a value from a simple calculation.
1032
+
1033
+ Contents of **simple.py** file
1034
+
1035
+ 2+3
1036
+
1037
+ >>> mechanical.run_python_script_from_file('simple.py')
1038
+ '5'
1039
+
1040
+ Return a value from a simple function call.
1041
+
1042
+ Contents of **test.py** file
1043
+
1044
+ import math
1045
+
1046
+ math.pow(2,3)
1047
+
1048
+ >>> mechanical.run_python_script_from_file('test.py')
1049
+ '8'
1050
+
1051
+ """
1052
+ self.verify_valid_connection()
1053
+ self.log_debug(f"run_python_script_from_file started")
1054
+ script_code = Mechanical.__readfile(file_path)
1055
+ self.log_debug(f"run_python_script_from_file started")
1056
+ return self.run_python_script(script_code, enable_logging, log_level, progress_interval)
1057
+
1058
+ def exit(self, force=False):
1059
+ """Exit Mechanical.
1060
+
1061
+ Parameters
1062
+ ----------
1063
+ force : bool, optional
1064
+ Whether to force Mechanical to exit. The default is ``False``, in which case
1065
+ only Mechanical in UI mode asks for confirmation. This parameter overrides
1066
+ any environment variables that may inhibit exiting Mechanical.
1067
+
1068
+ Examples
1069
+ --------
1070
+ Exit Mechanical.
1071
+
1072
+ >>> mechanical.Exit(force=True)
1073
+
1074
+ """
1075
+ if not force:
1076
+ if not get_start_instance():
1077
+ self.log_info("Ignoring exit due to PYMECHANICAL_START_INSTANCE=False")
1078
+ return
1079
+
1080
+ # or building the gallery
1081
+ if pymechanical.BUILDING_GALLERY:
1082
+ self._log.info("Ignoring exit due to BUILDING_GALLERY=True")
1083
+ return
1084
+
1085
+ if self._exited:
1086
+ return
1087
+
1088
+ self.verify_valid_connection()
1089
+
1090
+ self._exiting = True
1091
+
1092
+ self.log_debug("In shutdown.")
1093
+ request = mechanical_pb2.ShutdownRequest(force_exit=force)
1094
+ self.log_debug("Shutting down...")
1095
+
1096
+ self._busy = True
1097
+ try:
1098
+ self._stub.Shutdown(request)
1099
+ except grpc._channel._InactiveRpcError as error:
1100
+ self.log_warning("Mechanical exit failed: {str(error}.")
1101
+ finally:
1102
+ self._busy = False
1103
+
1104
+ self._exited = True
1105
+ self._stub = None
1106
+
1107
+ if self._remote_instance is not None: # pragma: no cover
1108
+ self.log_debug("PyPIM delete has started.")
1109
+ try:
1110
+ self._remote_instance.delete()
1111
+ except Exception as error:
1112
+ self.log_warning("Remote instance delete failed: {str(error}.")
1113
+ self.log_debug("PyPIM delete has finished.")
1114
+
1115
+ self._remote_instance = None
1116
+ self._channel = None
1117
+ else:
1118
+ self.log_debug("No PyPIM cleanup is needed.")
1119
+
1120
+ local_ports = pymechanical.LOCAL_PORTS
1121
+ if self._local and self._port in local_ports:
1122
+ local_ports.remove(self._port)
1123
+
1124
+ self.log_info("Shutdown has finished.")
1125
+
1126
+ @protect_grpc
1127
+ def upload(
1128
+ self,
1129
+ file_name,
1130
+ file_location_destination=None,
1131
+ chunk_size=DEFAULT_FILE_CHUNK_SIZE,
1132
+ progress_bar=True,
1133
+ ):
1134
+ """Upload a file to the Mechanical instance.
1135
+
1136
+ Parameters
1137
+ ----------
1138
+ file_name : str
1139
+ Local file to upload. Only the file name is needed if the file
1140
+ is relative to the current working directory. Otherwise, the full path
1141
+ is needed.
1142
+ file_location_destination : str, optional
1143
+ File location on the Mechanical server to upload the file to. The default is
1144
+ ``None``, in which case the project directory is used.
1145
+ chunk_size : int, optional
1146
+ Chunk size in bytes. The default is ``1048576``.
1147
+ progress_bar : bool, optional
1148
+ Whether to show a progress bar using ``tqdm``. The default is ``True``.
1149
+ A progress bar is helpful for viewing upload progress.
1150
+
1151
+ Returns
1152
+ -------
1153
+ str
1154
+ Base name of the uploaded file.
1155
+
1156
+ Examples
1157
+ --------
1158
+ Upload the ``hsec.x_t`` file with the progress bar not shown.
1159
+
1160
+ >>> mechanical.upload('hsec.x_t', progress_bar=False)
1161
+ """
1162
+ self.verify_valid_connection()
1163
+
1164
+ if not os.path.isfile(file_name):
1165
+ raise FileNotFoundError(f"Unable to locate filename {file_name}.")
1166
+
1167
+ self._log.debug(f"Uploading file '{file_name}' to the Mechanical instance.")
1168
+
1169
+ if file_location_destination is None:
1170
+ file_location_destination = self.project_directory
1171
+
1172
+ self._busy = True
1173
+ try:
1174
+ chunks_generator = self.get_file_chunks(
1175
+ file_location_destination,
1176
+ file_name,
1177
+ chunk_size=chunk_size,
1178
+ progress_bar=progress_bar,
1179
+ )
1180
+ response = self._stub.UploadFile(chunks_generator)
1181
+ self.log_debug(f"upload_file response is {response.is_ok}.")
1182
+ finally:
1183
+ self._busy = False
1184
+
1185
+ if not response.is_ok: # pragma: no cover
1186
+ raise IOError("File failed to upload.")
1187
+ return os.path.basename(file_name)
1188
+
1189
+ def get_file_chunks(self, file_location, file_name, chunk_size, progress_bar):
1190
+ """Construct the file upload request for the server.
1191
+
1192
+ Parameters
1193
+ ----------
1194
+ file_location_destination : str, optional
1195
+ Directory where the file to upload to the server is located.
1196
+ file_name : str
1197
+ Name of the file to upload.
1198
+ chunk_size : int
1199
+ Chunk size in bytes.
1200
+ progress_bar : bool
1201
+ Whether to show a progress bar using ``tqdm``.
1202
+ """
1203
+ pbar = None
1204
+ if progress_bar:
1205
+ if not _HAS_TQDM: # pragma: no cover
1206
+ raise ModuleNotFoundError(
1207
+ f"To use the keyword argument 'progress_bar', you must have "
1208
+ f"installed the 'tqdm' package. To avoid this message, you can "
1209
+ f"set 'progress_bar=False'."
1210
+ )
1211
+
1212
+ n_bytes = os.path.getsize(file_name)
1213
+
1214
+ base_name = os.path.basename(file_name)
1215
+ pbar = tqdm(
1216
+ total=n_bytes,
1217
+ desc=f"Uploading {base_name} to {self._channel_str}:{file_location}.",
1218
+ unit="B",
1219
+ unit_scale=True,
1220
+ unit_divisor=1024,
1221
+ )
1222
+
1223
+ with open(file_name, "rb") as f:
1224
+ while True:
1225
+ piece = f.read(chunk_size)
1226
+ length = len(piece)
1227
+ if length == 0:
1228
+ if pbar is not None:
1229
+ pbar.close()
1230
+ return
1231
+
1232
+ if pbar is not None:
1233
+ pbar.update(length)
1234
+
1235
+ chunk = mechanical_pb2.Chunk(payload=piece, size=length)
1236
+ yield mechanical_pb2.FileUploadRequest(
1237
+ file_name=os.path.basename(file_name), file_location=file_location, chunk=chunk
1238
+ )
1239
+
1240
+ @property
1241
+ def project_directory(self):
1242
+ """Get the project directory for the currently connected Mechanical instance.
1243
+
1244
+ Examples
1245
+ --------
1246
+ Get the project directory of the connected Mechanical instance.
1247
+
1248
+ >>> mechanical.project_directory
1249
+ '/tmp/ANSYS.username.1/AnsysMech3F97/Project_Mech_Files/'
1250
+
1251
+ """
1252
+ return self.run_python_script("ExtAPI.DataModel.Project.ProjectDirectory")
1253
+
1254
+ def list_files(self):
1255
+ """List the files in the working directory of Mechanical.
1256
+
1257
+ Returns
1258
+ -------
1259
+ list
1260
+ List of files in the working directory of Mechanical.
1261
+
1262
+ Examples
1263
+ --------
1264
+ List the files in the working directory.
1265
+
1266
+ >>> files = mechanical.list_files()
1267
+ >>> for file in files: print(file)
1268
+ """
1269
+ result = self.run_python_script(
1270
+ "import pymechanical_helpers\npymechanical_helpers.GetAllProjectFiles(ExtAPI)"
1271
+ )
1272
+
1273
+ files_out = result.splitlines()
1274
+ if not files_out: # pragma: no cover
1275
+ self.log_warning("No files listed")
1276
+ return files_out
1277
+
1278
+ def _get_files(self, files, recursive=False):
1279
+ self_files = self.list_files() # to avoid calling it too much
1280
+
1281
+ if isinstance(files, str):
1282
+ if self._local: # pragma: no cover
1283
+ # in local mode
1284
+ if os.path.exists(files):
1285
+ if not os.path.isabs(files):
1286
+ list_files = [os.path.join(os.getcwd(), files)]
1287
+ else:
1288
+ # file exist
1289
+ list_files = [files]
1290
+ elif "*" in files:
1291
+ # using filter
1292
+ list_files = glob.glob(files, recursive=recursive)
1293
+ if not list_files:
1294
+ raise ValueError(
1295
+ f"The `'files'` parameter ({files}) didn't match any file using "
1296
+ f"glob expressions in the local client."
1297
+ )
1298
+ else:
1299
+ raise ValueError(
1300
+ f"The files parameter ('{files}') does not match any file or pattern."
1301
+ )
1302
+ else: # Remote or looking into Mechanical working directory
1303
+ if files in self_files:
1304
+ list_files = [files]
1305
+ elif "*" in files:
1306
+ # try filter on the list_files
1307
+ if recursive:
1308
+ self.log_warning(
1309
+ "Because the 'recursive' keyword argument does not work with "
1310
+ "remote instances, it is ignored."
1311
+ )
1312
+ list_files = fnmatch.filter(self_files, files)
1313
+ if not list_files:
1314
+ raise ValueError(
1315
+ f"The `'files'` parameter ({files}) didn't match any file using "
1316
+ f"glob expressions in the remote server."
1317
+ )
1318
+ else:
1319
+ raise ValueError(
1320
+ f"The `'files'` parameter ('{files}') does not match any file or pattern."
1321
+ )
1322
+
1323
+ elif isinstance(files, (list, tuple)):
1324
+ if not all([isinstance(each, str) for each in files]):
1325
+ raise ValueError(
1326
+ "The parameter `'files'` can be a list or tuple, but it "
1327
+ "should only contain strings."
1328
+ )
1329
+ list_files = files
1330
+ else:
1331
+ raise ValueError(
1332
+ f"The `file` parameter type ({type(files)}) is not supported."
1333
+ "Only strings, tuple of strings, or list of strings are allowed."
1334
+ )
1335
+
1336
+ return list_files
1337
+
1338
+ def download(
1339
+ self,
1340
+ files,
1341
+ target_dir=None,
1342
+ chunk_size=DEFAULT_CHUNK_SIZE,
1343
+ progress_bar=None,
1344
+ recursive=False,
1345
+ ): # pragma: no cover
1346
+ """Download files from the working directory of the Mechanical instance.
1347
+
1348
+ It downloads them from the working directory to the target directory. It returns the list
1349
+ of local file paths for the downloaded files.
1350
+
1351
+ Parameters
1352
+ ----------
1353
+ files : str, list[str], tuple(str)
1354
+ One or more files on the Mechanical server to download. The files must be
1355
+ in the same directory as the Mechanical instance. You can use the
1356
+ :func:`Mechanical.list_files <ansys.mechanical.core.mechanical.list_files>`
1357
+ function to list current files. Alternatively, you can specify *glob expressions* to
1358
+ match file names. For example, you could use ``file*`` to match every file whose
1359
+ name starts with ``file``.
1360
+ target_dir: str
1361
+ Default directory to copy the downloaded files to. The default is ``None`` and
1362
+ current working directory will be used as target directory.
1363
+ chunk_size : int, optional
1364
+ Chunk size in bytes. The default is ``262144``. The value must be less than 4 MB.
1365
+ progress_bar : bool, optional
1366
+ Whether to show a progress bar using ``tqdm``. The default is ``None``, in
1367
+ which case a progress bar is shown. A progress bar is helpful for viewing download
1368
+ progress.
1369
+ recursive : bool, optional
1370
+ Whether to use recursion when using a glob pattern search. The default is ``False``.
1371
+
1372
+ Returns
1373
+ -------
1374
+ List[str]
1375
+ List of local file paths.
1376
+
1377
+ Notes
1378
+ -----
1379
+ There are some considerations to keep in mind when using the ``download()`` method:
1380
+
1381
+ * The glob pattern search does not search recursively in remote instances.
1382
+ * In a remote instance, it is not possible to list or download files in a
1383
+ location other than the Mechanical working directory.
1384
+ * If you are connected to a local instance and provide a file path, downloading files
1385
+ from a different folder is allowed but is not recommended.
1386
+
1387
+ Examples
1388
+ --------
1389
+ Download a single file.
1390
+
1391
+ >>> local_file_path_list = mechanical.download('file.out')
1392
+
1393
+ Download all files starting with ``file``.
1394
+
1395
+ >>> local_file_path_list = mechanical.download('file*')
1396
+
1397
+ Download every file in the Mechanical working directory.
1398
+
1399
+ >>> local_file_path_list = mechanical.download('*.*')
1400
+
1401
+ Alternatively, the recommended method is to use the
1402
+ :func:`download_project() <ansys.mechanical.core.mechanical.Mechanical.download_project>`
1403
+ method to download all files.
1404
+
1405
+ >>> local_file_path_list = mechanical.download_project()
1406
+
1407
+ """
1408
+ self.verify_valid_connection()
1409
+
1410
+ if chunk_size > 4 * 1024 * 1024: # 4MB
1411
+ raise ValueError(
1412
+ "Chunk sizes bigger than 4 MB can generate unstable behaviour in PyMechanical. "
1413
+ "Decrease the ``chunk_size`` value."
1414
+ )
1415
+
1416
+ list_files = self._get_files(files, recursive=recursive)
1417
+
1418
+ if target_dir:
1419
+ path = pathlib.Path(target_dir)
1420
+ path.mkdir(parents=True, exist_ok=True)
1421
+ else:
1422
+ target_dir = os.getcwd()
1423
+
1424
+ out_files = []
1425
+
1426
+ for each_file in list_files:
1427
+ try:
1428
+ file_name = os.path.basename(each_file) # Getting only the name of the file.
1429
+ # We try to avoid that when the full path is supplied. It crashes when trying
1430
+ # to do `os.path.join(target_dir"os.getcwd()", file_name "full filename path"`
1431
+ # This produces the file structure to flat out, but it is fine,
1432
+ # because recursive does not work in remote.
1433
+ self._busy = True
1434
+ out_file_path = self._download(
1435
+ each_file,
1436
+ out_file_name=os.path.join(target_dir, file_name),
1437
+ chunk_size=chunk_size,
1438
+ progress_bar=progress_bar,
1439
+ )
1440
+ out_files.append(out_file_path)
1441
+ except FileNotFoundError:
1442
+ # So far the gRPC interface returns the size of the file equal
1443
+ # zero, if the file does not exist, or if its size is zero,
1444
+ # but they are two different things.
1445
+ # In theory, since we are obtaining the files name from
1446
+ # `mechanical.list_files()`, they do exist, so
1447
+ # if there is any error, it means their size is zero.
1448
+ pass # This is not the best.
1449
+ finally:
1450
+ self._busy = False
1451
+
1452
+ return out_files
1453
+
1454
+ @protect_grpc
1455
+ def _download(
1456
+ self,
1457
+ target_name,
1458
+ out_file_name,
1459
+ chunk_size=DEFAULT_CHUNK_SIZE,
1460
+ progress_bar=None,
1461
+ ):
1462
+ """Download a file from the Mechanical instance.
1463
+
1464
+ Parameters
1465
+ ----------
1466
+ target_name : str
1467
+ Name of the target file on the server. The file must be in the same
1468
+ directory as the Mechanical instance. You can use the
1469
+ ``mechanical.list_files()`` function to list current files.
1470
+ out_file_name : str
1471
+ Name of the output file if the name is to differ from that for the target
1472
+ file.
1473
+ chunk_size : int, optional
1474
+ Chunk size in bytes. The default is ``"DEFAULT_CHUNK_SIZE"``, in which case
1475
+ 256 kB is used. The value must be less than 4 MB.
1476
+ progress_bar : bool, optional
1477
+ Whether to show a progress bar using ``tqdm``. The default is ``None``, in
1478
+ which case a progress bar is shown. A progress bar is helpful for showing download
1479
+ progress.
1480
+
1481
+ Examples
1482
+ --------
1483
+ Download the remote result file "file.rst" as "my_result.rst".
1484
+
1485
+ >>> mechanical.download('file.rst', 'my_result.rst')
1486
+ """
1487
+ self.verify_valid_connection()
1488
+
1489
+ if not progress_bar and _HAS_TQDM:
1490
+ progress_bar = True
1491
+
1492
+ request = mechanical_pb2.FileDownloadRequest(file_path=target_name, chunk_size=chunk_size)
1493
+
1494
+ responses = self._stub.DownloadFile(request)
1495
+
1496
+ file_size = self.save_chunks_to_file(
1497
+ responses, out_file_name, progress_bar=progress_bar, target_name=target_name
1498
+ )
1499
+
1500
+ if not file_size: # pragma: no cover
1501
+ raise FileNotFoundError(f'File "{out_file_name}" is empty or does not exist')
1502
+
1503
+ self.log_info(f"{out_file_name} with size {file_size} has been written.")
1504
+
1505
+ return out_file_name
1506
+
1507
+ def save_chunks_to_file(self, responses, filename, progress_bar=False, target_name=""):
1508
+ """Save chunks to a local file.
1509
+
1510
+ Parameters
1511
+ ----------
1512
+ responses :
1513
+ filename : str
1514
+ Name of the local file to save chunks to.
1515
+ progress_bar : bool, optional
1516
+ Whether to show a progress bar using ``tqdm``. The default is ``False``.
1517
+ target_name : str, optional
1518
+ Name of the target file on the server. The default is ``""``. The file
1519
+ must be in the same directory as the Mechanical instance. You can use the
1520
+ ``mechanical.list_files()`` function to list current files.
1521
+
1522
+ Returns
1523
+ -------
1524
+ file_size : int
1525
+ File size saved in bytes. If ``0`` is returned, no file was written.
1526
+ """
1527
+ pbar = None
1528
+ if progress_bar:
1529
+ if not _HAS_TQDM: # pragma: no cover
1530
+ raise ModuleNotFoundError(
1531
+ "To use the keyword argument 'progress_bar', you need to have installed "
1532
+ "the 'tqdm' package.To avoid this message you can set 'progress_bar=False'."
1533
+ )
1534
+
1535
+ file_size = 0
1536
+ with open(filename, "wb") as f:
1537
+ for response in responses:
1538
+ f.write(response.chunk.payload)
1539
+ payload_size = len(response.chunk.payload)
1540
+ file_size += payload_size
1541
+ if pbar is None:
1542
+ pbar = tqdm(
1543
+ total=response.file_size,
1544
+ desc=f"Downloading {self._channel_str}:{target_name} to {filename}",
1545
+ unit="B",
1546
+ unit_scale=True,
1547
+ unit_divisor=1024,
1548
+ )
1549
+ pbar.update(payload_size)
1550
+ else:
1551
+ pbar.update(payload_size)
1552
+
1553
+ if pbar is not None:
1554
+ pbar.close()
1555
+
1556
+ return file_size
1557
+
1558
+ def download_project(self, extensions=None, target_dir=None, progress_bar=False):
1559
+ """Download all project files in the working directory of the Mechanical instance.
1560
+
1561
+ It downloads them from the working directory to the target directory. It returns the list
1562
+ of local file paths for the downloaded files.
1563
+
1564
+ Parameters
1565
+ ----------
1566
+ extensions : list[str], tuple[str], optional
1567
+ List of extensions for filtering files before downloading them. The
1568
+ default is ``None``.
1569
+ target_dir : str, optional
1570
+ Path for downloading the files to. The default is ``None``.
1571
+ progress_bar : bool, optional
1572
+ Whether to show a progress bar using ``tqdm``. The default is ``False``.
1573
+ A progress bar is helpful for viewing download progress.
1574
+
1575
+ Returns
1576
+ -------
1577
+ List[str]
1578
+ List of local file paths.
1579
+
1580
+ Examples
1581
+ --------
1582
+ Download all the files in the project.
1583
+
1584
+ >>> local_file_path_list = mechanical.download_project()
1585
+ """
1586
+ destination_directory = target_dir.rstrip("\\/")
1587
+
1588
+ # let us create the directory, if it doesn't exist
1589
+ if destination_directory:
1590
+ path = pathlib.Path(destination_directory)
1591
+ path.mkdir(parents=True, exist_ok=True)
1592
+ else:
1593
+ destination_directory = os.getcwd()
1594
+
1595
+ # relative directory?
1596
+ if os.path.isdir(destination_directory):
1597
+ if not os.path.isabs(destination_directory):
1598
+ # construct full path
1599
+ destination_directory = os.path.join(os.getcwd(), destination_directory)
1600
+
1601
+ project_directory = self.project_directory
1602
+ # remove the trailing slash - server could be windows or linux
1603
+ project_directory = project_directory.rstrip("\\/")
1604
+
1605
+ # this is where .mechddb resides
1606
+ parent_directory = os.path.dirname(project_directory)
1607
+
1608
+ list_of_files = []
1609
+
1610
+ if not extensions:
1611
+ files = self.list_files()
1612
+ else:
1613
+ files = []
1614
+ for each_extension in extensions:
1615
+ # mechdb resides one level above project directory
1616
+ if "mechdb" == each_extension.lower():
1617
+ file_temp = os.path.join(parent_directory, f"*.{each_extension}")
1618
+ else:
1619
+ file_temp = os.path.join(project_directory, "**", f"*.{each_extension}")
1620
+
1621
+ if self._local:
1622
+ list_files_expanded = self._get_files(file_temp, recursive=True)
1623
+
1624
+ if "mechdb" == each_extension.lower():
1625
+ # if we have more than one .mechdb in the parent folder
1626
+ # filter to have only the current mechdb
1627
+ self_files = self.list_files()
1628
+ filtered_files = []
1629
+ for temp_file in list_files_expanded:
1630
+ if temp_file in self_files:
1631
+ filtered_files.append(temp_file)
1632
+ list_files = filtered_files
1633
+ else:
1634
+ list_files = list_files_expanded
1635
+ else:
1636
+ list_files = self._get_files(file_temp, recursive=False)
1637
+
1638
+ files.extend(list_files)
1639
+
1640
+ for file in files:
1641
+ # create similar hierarchy locally
1642
+ new_path = file.replace(parent_directory, destination_directory)
1643
+ new_path_dir = os.path.dirname(new_path)
1644
+ temp_files = self.download(
1645
+ files=file, target_dir=new_path_dir, progress_bar=progress_bar
1646
+ )
1647
+ list_of_files.extend(temp_files)
1648
+
1649
+ return list_of_files
1650
+
1651
+ def clear(self):
1652
+ """Clear the database.
1653
+
1654
+ Examples
1655
+ --------
1656
+ Clear the database.
1657
+
1658
+ >>> mechanical.clear()
1659
+
1660
+ """
1661
+ self.run_python_script("ExtAPI.DataModel.Project.New()")
1662
+
1663
+ def _make_dummy_call(self):
1664
+ try:
1665
+ self._disable_logging = True
1666
+ self.run_python_script("ExtAPI.DataModel.Project.ProjectDirectory")
1667
+ except grpc.RpcError: # pragma: no cover
1668
+ raise
1669
+ finally:
1670
+ self._disable_logging = False
1671
+
1672
+ @staticmethod
1673
+ def __readfile(file_path):
1674
+ """Get the contents of the file as a string."""
1675
+ # open text file in read mode
1676
+ text_file = open(file_path, "r", encoding="utf-8")
1677
+ # read whole file to a string
1678
+ data = text_file.read()
1679
+ # close file
1680
+ text_file.close()
1681
+
1682
+ return data
1683
+
1684
+ def __call_run_python_script(
1685
+ self, script_code: str, enable_logging, log_level, progress_interval
1686
+ ):
1687
+ """Run the Python script block on the server.
1688
+
1689
+ Parameters
1690
+ ----------
1691
+ script_block : str
1692
+ Script block (one or more lines) to run.
1693
+ enable_logging: bool
1694
+ Whether to enable logging
1695
+ log_level: str
1696
+ Level of logging. Options are ``"DEBUG"``, ``"INFO"``, ``"WARNING"``,
1697
+ and ``"ERROR"``.
1698
+ timeout: int, optional
1699
+ Frequency in milliseconds for getting log messages from the server.
1700
+
1701
+ Returns
1702
+ -------
1703
+ str
1704
+ Script result.
1705
+
1706
+ """
1707
+ log_level_server = self.convert_to_server_log_level(log_level)
1708
+ request = mechanical_pb2.RunScriptRequest()
1709
+ request.script_code = script_code
1710
+ request.enable_logging = enable_logging
1711
+ request.logger_severity = log_level_server
1712
+ request.progress_interval = progress_interval
1713
+
1714
+ result = ""
1715
+ self._busy = True
1716
+
1717
+ try:
1718
+ for runscript_response in self._stub.RunPythonScript(request):
1719
+ if runscript_response.log_info == "__done__":
1720
+ result = runscript_response.script_result
1721
+ break
1722
+ else:
1723
+ if enable_logging:
1724
+ self.log_message(log_level, runscript_response.log_info)
1725
+ except grpc.RpcError as error:
1726
+ error_info = error.details()
1727
+ error_info_lower = error_info.lower()
1728
+ # For the given script, return value cannot be converted to string.
1729
+ if (
1730
+ "the expected result" in error_info_lower
1731
+ and "cannot be return via this API." in error_info
1732
+ ):
1733
+ if enable_logging:
1734
+ self.log_debug(f"Ignoring the conversion error.{error_info}")
1735
+ result = ""
1736
+ else:
1737
+ raise
1738
+ finally:
1739
+ self._busy = False
1740
+
1741
+ self._log_mechanical_script(script_code)
1742
+
1743
+ return result
1744
+
1745
+ def log_message(self, log_level, message):
1746
+ """Log the message using the given log level.
1747
+
1748
+ Parameters
1749
+ ----------
1750
+ log_level: str
1751
+ Level of logging. Options are ``"DEBUG"``, ``"INFO"``, ``"WARNING"``,
1752
+ and ``"ERROR"``.
1753
+ message : str
1754
+ Message to log.
1755
+
1756
+ Examples
1757
+ --------
1758
+ Log a debug message.
1759
+
1760
+ >>> mechanical.log_message('DEBUG', 'debug message')
1761
+
1762
+ Log an info message.
1763
+
1764
+ >>> mechanical.log_message('INFO', 'info message')
1765
+
1766
+ """
1767
+ if log_level == "DEBUG":
1768
+ self.log_debug(message)
1769
+ elif log_level == "INFO":
1770
+ self.log_info(message)
1771
+ elif log_level == "WARNING":
1772
+ self.log_warning(message)
1773
+ elif log_level == "ERROR":
1774
+ self.log_error(message)
1775
+
1776
+ def log_debug(self, message):
1777
+ """Log the debug message."""
1778
+ if self._disable_logging:
1779
+ return
1780
+ self._log.debug(message)
1781
+
1782
+ def log_info(self, message):
1783
+ """Log the info message."""
1784
+ if self._disable_logging:
1785
+ return
1786
+ self._log.info(message)
1787
+
1788
+ def log_warning(self, message):
1789
+ """Log the warning message."""
1790
+ if self._disable_logging:
1791
+ return
1792
+ self._log.warning(message)
1793
+
1794
+ def log_error(self, message):
1795
+ """Log the error message."""
1796
+ if self._disable_logging:
1797
+ return
1798
+ self._log.error(message)
1799
+
1800
+ def verify_valid_connection(self):
1801
+ """Verify whether the connection to Mechanical is valid."""
1802
+ if self._exited:
1803
+ raise MechanicalExitedError("Mechanical has already exited.")
1804
+
1805
+ if self._stub is None: # pragma: no cover
1806
+ raise ValueError(
1807
+ "There is not a valid connection to Mechanical. Launch or connect to it first."
1808
+ )
1809
+
1810
+ @property
1811
+ def exited(self):
1812
+ """Whether Mechanical already exited."""
1813
+ return self._exited
1814
+
1815
+ def _log_mechanical_script(self, script_code):
1816
+ if self._disable_logging:
1817
+ return
1818
+
1819
+ if self._log_file_mechanical:
1820
+ try:
1821
+ with open(self._log_file_mechanical, "a", encoding="utf-8") as file:
1822
+ file.write(script_code)
1823
+ file.write("\n")
1824
+ except IOError as e: # pragma: no cover
1825
+ self.log_warning(f"I/O error({e.errno}): {e.strerror}")
1826
+ except Exception as e: # pragma: no cover
1827
+ self.log_warning("Unexpected error:" + str(e))
1828
+
1829
+
1830
+ def get_start_instance(start_instance_default=True):
1831
+ """Check if the ``PYMECHANICAL_START_INSTANCE`` environment variable exists and is valid.
1832
+
1833
+ Parameters
1834
+ ----------
1835
+ start_instance_default : bool, optional
1836
+ Value to return when ``PYMECHANICAL_START_INSTANCE`` is unset.
1837
+
1838
+ Returns
1839
+ -------
1840
+ bool
1841
+ ``True`` when the ``PYMECHANICAL_START_INSTANCE`` environment variable exists
1842
+ and is valid, ``False`` when this environment variable does not exist or is not valid.
1843
+ If it is unset, ``start_instance_default`` is returned.
1844
+
1845
+ Raises
1846
+ ------
1847
+ OSError
1848
+ Raised when ``PYMECHANICAL_START_INSTANCE`` is not either ``True`` or ``False``
1849
+ (case independent).
1850
+
1851
+ """
1852
+ if "PYMECHANICAL_START_INSTANCE" in os.environ:
1853
+ if os.environ["PYMECHANICAL_START_INSTANCE"].lower() not in [
1854
+ "true",
1855
+ "false",
1856
+ ]: # pragma: no cover
1857
+ val = os.environ["PYMECHANICAL_START_INSTANCE"]
1858
+ raise OSError(
1859
+ f'Invalid value "{val}" for PYMECHANICAL_START_INSTANCE\n'
1860
+ 'PYMECHANICAL_START_INSTANCE should be either "TRUE" or "FALSE"'
1861
+ )
1862
+ return os.environ["PYMECHANICAL_START_INSTANCE"].lower() == "true"
1863
+ return start_instance_default
1864
+
1865
+
1866
+ def launch_grpc(
1867
+ exec_file="",
1868
+ batch=True,
1869
+ port=MECHANICAL_DEFAULT_PORT,
1870
+ additional_switches=None,
1871
+ additional_envs=None,
1872
+ verbose=False,
1873
+ ) -> int:
1874
+ """Start Mechanical locally in gRPC mode.
1875
+
1876
+ Parameters
1877
+ ----------
1878
+ exec_file : str, optional
1879
+ Path for the Mechanical executable file. The default is ``None``, in which
1880
+ case the cached location is used.
1881
+ batch : bool, optional
1882
+ Whether to launch Mechanical in batch mode. The default is ``True``.
1883
+ When ``False``, Mechanical is launched in UI mode.
1884
+ port : int, optional
1885
+ Port to launch the Mechanical instance on. The default is
1886
+ ``MECHANICAL_DEFAULT_PORT``. The final port is the first
1887
+ port available after (or including) this port.
1888
+ additional_switches : list, optional
1889
+ List of additional arguments to pass. The default is ``None``.
1890
+ additional_envs : dictionary, optional
1891
+ Dictionary of additional environment variables to pass. The default
1892
+ is ``None``.
1893
+ verbose : bool, optional
1894
+ Whether to print all output when launching and running Mechanical. The
1895
+ default is ``False``. Printing all output is not recommended unless
1896
+ you are debugging the startup of Mechanical.
1897
+
1898
+ Returns
1899
+ -------
1900
+ int
1901
+ Port number that the Mechanical instance started on.
1902
+
1903
+ Notes
1904
+ -----
1905
+ If ``PYMECHANICAL_START_INSTANCE`` is set to FALSE, the ``launch_mechanical``
1906
+ method looks for an existing instance of Mechanical at ``PYMECHANICAL_IP`` on port
1907
+ ``PYMECHANICAL_PORT``, with default to ``127.0.0.1`` and ``10000`` if unset.
1908
+ This is typically used for automated documentation and testing.
1909
+
1910
+ Examples
1911
+ --------
1912
+ Launch Mechanical using the default configuration.
1913
+
1914
+ >>> from ansys.mechanical.core import launch_mechanical
1915
+ >>> mechanical = launch_mechanical()
1916
+
1917
+ Launch Mechanical using a specified executable file.
1918
+
1919
+ >>> exec_file_path = 'C:/Program Files/ANSYS Inc/v251/aisol/bin/win64/AnsysWBU.exe'
1920
+ >>> mechanical = launch_mechanical(exec_file_path)
1921
+
1922
+ """
1923
+ # verify version
1924
+ if atp.version_from_path("mechanical", exec_file) < 232:
1925
+ raise VersionError("The Mechanical gRPC interface requires Mechanical 2023 R2 or later.")
1926
+
1927
+ # get the next available port
1928
+ local_ports = pymechanical.LOCAL_PORTS
1929
+ if port is None:
1930
+ if not local_ports:
1931
+ port = MECHANICAL_DEFAULT_PORT
1932
+ else:
1933
+ port = max(local_ports) + 1
1934
+
1935
+ while port_in_use(port) or port in local_ports:
1936
+ port += 1
1937
+ local_ports.append(port)
1938
+
1939
+ mechanical_launcher = MechanicalLauncher(
1940
+ batch, port, exec_file, additional_switches, additional_envs, verbose
1941
+ )
1942
+ mechanical_launcher.launch()
1943
+
1944
+ return port
1945
+
1946
+
1947
+ def launch_rpyc(
1948
+ exec_file="",
1949
+ batch=True,
1950
+ port=MECHANICAL_DEFAULT_PORT,
1951
+ additional_switches=None,
1952
+ additional_envs=None,
1953
+ verbose=False,
1954
+ ) -> int:
1955
+ """Start Mechanical locally in RPyC mode."""
1956
+ _version = atp.version_from_path("mechanical", exec_file)
1957
+ if _version < 232:
1958
+ raise VersionError("The Mechanical gRPC interface requires Mechanical 2023 R2 or later.")
1959
+
1960
+ # get the next available port
1961
+ local_ports = pymechanical.LOCAL_PORTS
1962
+ if port is None:
1963
+ if not local_ports:
1964
+ port = MECHANICAL_DEFAULT_PORT
1965
+ else:
1966
+ port = max(local_ports) + 1
1967
+
1968
+ while port_in_use(port) or port in local_ports:
1969
+ port += 1
1970
+ local_ports.append(port)
1971
+
1972
+ server_script = """
1973
+ import sys
1974
+ from ansys.mechanical.core.embedding.rpc import MechanicalDefaultServer
1975
+ server = MechanicalDefaultServer(port=int(sys.argv[1]), version=int(sys.argv[2]))
1976
+ server.start()
1977
+ """
1978
+ env_copy = os.environ.copy()
1979
+ try:
1980
+ embedded_server = subprocess.Popen(
1981
+ [sys.executable, "-c", server_script, str(port), str(_version)], env=env_copy
1982
+ ) # nosec: B603
1983
+ except:
1984
+ raise RuntimeError("Unable to start the embedded server.")
1985
+
1986
+ return port
1987
+
1988
+
1989
+ def launch_remote_mechanical(
1990
+ version=None,
1991
+ ) -> (grpc.Channel, Optional["Instance"]): # pragma: no cover
1992
+ """Start Mechanical remotely using the Product Instance Management (PIM) API.
1993
+
1994
+ When calling this method, you must ensure that you are in an environment
1995
+ where PyPIM is configured. You can use the
1996
+ :func:`pypim.is_configured <ansys.platform.instancemanagement.is_configured>`
1997
+ method to verify that PyPIM is configured.
1998
+
1999
+ Parameters
2000
+ ----------
2001
+ version : str, optional
2002
+ Mechanical version to run in the three-digit format. For example, ``"251"`` to
2003
+ run 2025 R1. The default is ``None``, in which case the server runs the latest
2004
+ installed version.
2005
+
2006
+ Returns
2007
+ -------
2008
+ Tuple containing channel, remote_instance.
2009
+ """
2010
+ # Display warning if PyPIM is not installed
2011
+ if not _HAS_ANSYS_PIM:
2012
+ warnings.warn(
2013
+ "Installation of pim option required! Use ``pip install ansys-mechanical-core[pim]``."
2014
+ )
2015
+ return
2016
+
2017
+ pim = pypim.connect()
2018
+ instance = pim.create_instance(product_name="mechanical", product_version=version)
2019
+
2020
+ LOG.info("PyPIM wait for ready has started.")
2021
+ instance.wait_for_ready()
2022
+ LOG.info("PyPIM wait for ready has finished.")
2023
+
2024
+ channel = instance.build_grpc_channel(
2025
+ options=[
2026
+ ("grpc.max_receive_message_length", MAX_MESSAGE_LENGTH),
2027
+ ]
2028
+ )
2029
+
2030
+ return channel, instance
2031
+
2032
+
2033
+ def launch_mechanical(
2034
+ allow_input=True,
2035
+ exec_file=None,
2036
+ batch=True,
2037
+ loglevel="ERROR",
2038
+ log_file=False,
2039
+ log_mechanical=None,
2040
+ additional_switches=None,
2041
+ additional_envs=None,
2042
+ start_timeout=120,
2043
+ port=None,
2044
+ ip=None,
2045
+ start_instance=None,
2046
+ verbose_mechanical=False,
2047
+ clear_on_connect=False,
2048
+ cleanup_on_exit=True,
2049
+ version=None,
2050
+ keep_connection_alive=True,
2051
+ backend="mechanical",
2052
+ ) -> Mechanical:
2053
+ """Start Mechanical locally.
2054
+
2055
+ Parameters
2056
+ ----------
2057
+ allow_input: bool, optional
2058
+ Whether to allow user input when discovering the path to the Mechanical
2059
+ executable file.
2060
+ exec_file : str, optional
2061
+ Path for the Mechanical executable file. The default is ``None``,
2062
+ in which case the cached location is used. If PyPIM is configured
2063
+ and this parameter is set to ``None``, PyPIM launches Mechanical
2064
+ using its ``version`` parameter.
2065
+ batch : bool, optional
2066
+ Whether to launch Mechanical in batch mode. The default is ``True``.
2067
+ When ``False``, Mechanical launches in UI mode.
2068
+ loglevel : str, optional
2069
+ Level of messages to print to the console.
2070
+ Options are:
2071
+
2072
+ - ``"WARNING"``: Prints only Ansys warning messages.
2073
+ - ``"ERROR"``: Prints only Ansys error messages.
2074
+ - ``"INFO"``: Prints all Ansys messages.
2075
+
2076
+ The default is ``WARNING``.
2077
+ log_file : bool, optional
2078
+ Whether to copy the messages to a file named ``logs.log``, which is
2079
+ located where the Python script is executed. The default is ``False``.
2080
+ log_mechanical : str, optional
2081
+ Path to the output file on the local disk to write every script
2082
+ command to. The default is ``None``. However, you might set
2083
+ ``"log_mechanical='pymechanical_log.txt'"`` to write all commands that are
2084
+ sent to Mechanical via PyMechanical to this file. You can then use these
2085
+ commands to run a script within Mechanical without PyMechanical.
2086
+ additional_switches : list, optional
2087
+ Additional switches for Mechanical. The default is ``None``.
2088
+ additional_envs : dictionary, optional
2089
+ Dictionary of additional environment variables to pass. The default
2090
+ is ``None``.
2091
+ start_timeout : float, optional
2092
+ Maximum allowable time in seconds to connect to the Mechanical server.
2093
+ The default is ``120``.
2094
+ port : int, optional
2095
+ Port to launch the Mechanical gRPC server on. The default is ``None``,
2096
+ in which case ``10000`` is used. The final port is the first
2097
+ port available after (or including) this port. You can override the
2098
+ default behavior of this parameter with the
2099
+ ``PYMECHANICAL_PORT=<VALID PORT>`` environment variable.
2100
+ ip : str, optional
2101
+ IP address to use only when ``start_instance`` is ``False``. The
2102
+ default is ``None``, in which case ``"127.0.0.1"`` is used. If you
2103
+ provide an IP address, ``start_instance`` is set to ``False``.
2104
+ A host name can be provided as an alternative to an IP address.
2105
+ start_instance : bool, optional
2106
+ Whether to launch and connect to a new Mechanical instance. The default
2107
+ is ``None``, in which case an attempt is made to connect to an existing
2108
+ Mechanical instance at the given ``ip`` and ``port`` parameters, which have
2109
+ defaults of ``"127.0.0.1"`` and ``10000`` respectively. When ``True``,
2110
+ a local instance of Mechanical is launched. You can override the default
2111
+ behavior of this parameter with the ``PYMECHANICAL_START_INSTANCE=FALSE``
2112
+ environment variable.
2113
+ verbose_mechanical : bool, optional
2114
+ Whether to enable printing of all output when launching and running
2115
+ a Mechanical instance. The default is ``False``. This parameter should be
2116
+ set to ``True`` for debugging only as output can be tracked within
2117
+ PyMechanical.
2118
+ clear_on_connect : bool, optional
2119
+ When ``start_instance`` is ``False``, whether to clear the environment
2120
+ when connecting to Mechanical. The default is ``False``. When ``True``,
2121
+ a fresh environment is provided when you connect to Mechanical.
2122
+ cleanup_on_exit : bool, optional
2123
+ Whether to exit Mechanical when Python exits. The default is ``True``.
2124
+ When ``False``, Mechanical is not exited when the garbage for this Mechanical
2125
+ instance is collected.
2126
+ version : str, optional
2127
+ Mechanical version to run in the three-digit format. For example, ``"251"``
2128
+ for 2025 R1. The default is ``None``, in which case the server runs the
2129
+ latest installed version. If PyPIM is configured and ``exec_file=None``,
2130
+ PyPIM launches Mechanical using its ``version`` parameter.
2131
+ keep_connection_alive : bool, optional
2132
+ Whether to keep the gRPC connection alive by running a background thread
2133
+ and making dummy calls for remote connections. The default is ``True``.
2134
+ backend : str, optional
2135
+ Type of RPC to use. The default is ``"mechanical"`` which uses grpc.
2136
+ The other option is ``"python"`` which uses RPyC.
2137
+
2138
+ Returns
2139
+ -------
2140
+ ansys.mechanical.core.mechanical.Mechanical
2141
+ Instance of Mechanical.
2142
+
2143
+ Notes
2144
+ -----
2145
+ If the environment is configured to use `PyPIM <https://pypim.docs.pyansys.com>`_
2146
+ and ``start_instance=True``, then starting the instance is delegated to PyPIM.
2147
+ In this case, most of the preceding parameters are ignored because the server-side
2148
+ configuration is used.
2149
+
2150
+ Examples
2151
+ --------
2152
+ Launch Mechanical.
2153
+
2154
+ >>> from ansys.mechanical.core import launch_mechanical
2155
+ >>> mech = launch_mechanical()
2156
+
2157
+ Launch Mechanical using a specified executable file.
2158
+
2159
+ >>> exec_file_path = 'C:/Program Files/ANSYS Inc/v251/aisol/bin/win64/AnsysWBU.exe'
2160
+ >>> mech = launch_mechanical(exec_file_path)
2161
+
2162
+ Connect to an existing Mechanical instance at IP address ``192.168.1.30`` on port
2163
+ ``50001``.
2164
+
2165
+ >>> mech = launch_mechanical(start_instance=False, ip='192.168.1.30', port=50001)
2166
+ """
2167
+ # Start Mechanical with PyPIM if the environment is configured for it
2168
+ # and a directive on how to launch Mechanical was not passed.
2169
+ if _HAS_ANSYS_PIM and pypim.is_configured() and exec_file is None: # pragma: no cover
2170
+ LOG.info("Starting Mechanical remotely. The startup configuration will be ignored.")
2171
+ channel, remote_instance = launch_remote_mechanical(version=version)
2172
+ return Mechanical(
2173
+ channel=channel,
2174
+ remote_instance=remote_instance,
2175
+ loglevel=loglevel,
2176
+ log_file=log_file,
2177
+ log_mechanical=log_mechanical,
2178
+ timeout=start_timeout,
2179
+ cleanup_on_exit=cleanup_on_exit,
2180
+ keep_connection_alive=keep_connection_alive,
2181
+ )
2182
+
2183
+ if ip is None:
2184
+ ip = os.environ.get("PYMECHANICAL_IP", LOCALHOST)
2185
+ else: # pragma: no cover
2186
+ start_instance = False
2187
+ ip = socket.gethostbyname(ip) # Converting ip or host name to ip
2188
+
2189
+ check_valid_ip(ip) # double check
2190
+
2191
+ if port is None:
2192
+ port = int(os.environ.get("PYMECHANICAL_PORT", MECHANICAL_DEFAULT_PORT))
2193
+ check_valid_port(port)
2194
+
2195
+ # connect to an existing instance if enabled
2196
+ if start_instance is None:
2197
+ start_instance = check_valid_start_instance(
2198
+ os.environ.get("PYMECHANICAL_START_INSTANCE", True)
2199
+ )
2200
+
2201
+ # special handling when building the gallery outside of CI. This
2202
+ # creates an instance of Mechanical the first time if PYMECHANICAL_START_INSTANCE
2203
+ # is False.
2204
+ # when you launch, treat it as local.
2205
+ # when you connect, treat it as remote. We cannot differentiate between
2206
+ # local vs container scenarios. In the container scenarios, we could be connecting
2207
+ # to a container using local ip and port
2208
+ if pymechanical.BUILDING_GALLERY: # pragma: no cover
2209
+ # launch an instance of PyMechanical if it does not already exist and
2210
+ # starting instances is allowed
2211
+ if start_instance and GALLERY_INSTANCE[0] is None:
2212
+ mechanical = launch_mechanical(
2213
+ start_instance=True,
2214
+ cleanup_on_exit=False,
2215
+ loglevel=loglevel,
2216
+ )
2217
+ GALLERY_INSTANCE[0] = {"ip": mechanical._ip, "port": mechanical._port}
2218
+ return mechanical
2219
+
2220
+ # otherwise, connect to the existing gallery instance if available
2221
+ elif GALLERY_INSTANCE[0] is not None:
2222
+ mechanical = Mechanical(
2223
+ ip=GALLERY_INSTANCE[0]["ip"],
2224
+ port=GALLERY_INSTANCE[0]["port"],
2225
+ cleanup_on_exit=False,
2226
+ loglevel=loglevel,
2227
+ local=False,
2228
+ )
2229
+ # we are connecting to the existing gallery instance,
2230
+ # we need to clear Mechanical.
2231
+ mechanical.clear()
2232
+
2233
+ return mechanical
2234
+
2235
+ # finally, if running on CI/CD, connect to the default instance
2236
+ else:
2237
+ mechanical = Mechanical(
2238
+ ip=ip, port=port, cleanup_on_exit=False, loglevel=loglevel, local=False
2239
+ )
2240
+ # we are connecting for gallery generation,
2241
+ # we need to clear Mechanical.
2242
+ mechanical.clear()
2243
+ return mechanical
2244
+
2245
+ if not start_instance:
2246
+ mechanical = Mechanical(
2247
+ ip=ip,
2248
+ port=port,
2249
+ loglevel=loglevel,
2250
+ log_file=log_file,
2251
+ log_mechanical=log_mechanical,
2252
+ timeout=start_timeout,
2253
+ cleanup_on_exit=cleanup_on_exit,
2254
+ keep_connection_alive=keep_connection_alive,
2255
+ local=False,
2256
+ )
2257
+ if clear_on_connect:
2258
+ mechanical.clear()
2259
+
2260
+ # setting ip for the grpc server
2261
+ if ip != LOCALHOST: # Default local ip is 127.0.0.1
2262
+ create_ip_file(ip, os.getcwd())
2263
+
2264
+ return mechanical
2265
+
2266
+ # verify executable
2267
+ if exec_file is None:
2268
+ exec_file = get_mechanical_path(allow_input)
2269
+ if exec_file is None: # pragma: no cover
2270
+ raise FileNotFoundError(
2271
+ "Path to the Mechanical executable file is invalid or cache cannot be loaded. "
2272
+ "Enter a path manually by specifying a value for the "
2273
+ "'exec_file' parameter."
2274
+ )
2275
+ else: # verify ansys exists at this location
2276
+ if not os.path.isfile(exec_file):
2277
+ raise FileNotFoundError(
2278
+ f'This path for the Mechanical executable is invalid: "{exec_file}"\n'
2279
+ "Enter a path manually by specifying a value for the "
2280
+ "'exec_file' parameter."
2281
+ )
2282
+
2283
+ start_parm = {
2284
+ "exec_file": exec_file,
2285
+ "batch": batch,
2286
+ "additional_switches": additional_switches,
2287
+ "additional_envs": additional_envs,
2288
+ }
2289
+
2290
+ if backend == "mechanical":
2291
+ try:
2292
+ port = launch_grpc(port=port, verbose=verbose_mechanical, **start_parm)
2293
+ start_parm["local"] = True
2294
+ mechanical = Mechanical(
2295
+ ip=ip,
2296
+ port=port,
2297
+ loglevel=loglevel,
2298
+ log_file=log_file,
2299
+ log_mechanical=log_mechanical,
2300
+ timeout=start_timeout,
2301
+ cleanup_on_exit=cleanup_on_exit,
2302
+ keep_connection_alive=keep_connection_alive,
2303
+ **start_parm,
2304
+ )
2305
+ except Exception as exception: # pragma: no cover
2306
+ # pass
2307
+ raise exception
2308
+ elif backend == "python":
2309
+ port = launch_rpyc(port=port, **start_parm)
2310
+ from ansys.mechanical.core.embedding.rpc.client import Client
2311
+
2312
+ mechanical = Client(
2313
+ "localhost", port, timeout=start_timeout, cleanup_on_exit=cleanup_on_exit
2314
+ )
2315
+
2316
+ return mechanical
2317
+
2318
+
2319
+ def connect_to_mechanical(
2320
+ ip=None,
2321
+ port=None,
2322
+ loglevel="ERROR",
2323
+ log_file=False,
2324
+ log_mechanical=None,
2325
+ connect_timeout=120,
2326
+ clear_on_connect=False,
2327
+ cleanup_on_exit=False,
2328
+ keep_connection_alive=True,
2329
+ ) -> Mechanical:
2330
+ """Connect to an existing Mechanical server instance.
2331
+
2332
+ Parameters
2333
+ ----------
2334
+ ip : str, optional
2335
+ IP address for connecting to an existing Mechanical instance. The
2336
+ IP address defaults to ``"127.0.0.1"``.
2337
+ port : int, optional
2338
+ Port to listen on for an existing Mechanical instance. The default is ``None``,
2339
+ in which case ``10000`` is used. You can override the
2340
+ default behavior of this parameter with the
2341
+ ``PYMECHANICAL_PORT=<VALID PORT>`` environment variable.
2342
+ loglevel : str, optional
2343
+ Level of messages to print to the console.
2344
+ Options are:
2345
+
2346
+ - ``"WARNING"``: Prints only Ansys warning messages.
2347
+ - ``"ERROR"``: Prints only Ansys error messages.
2348
+ - ``"INFO"``: Prints all Ansys messages.
2349
+
2350
+ The default is ``WARNING``.
2351
+ log_file : bool, optional
2352
+ Whether to copy the messages to a file named ``logs.log``, which is
2353
+ located where the Python script is executed. The default is ``False``.
2354
+ log_mechanical : str, optional
2355
+ Path to the output file on the local disk to write every script
2356
+ command to. The default is ``None``. However, you might set
2357
+ ``"log_mechanical='pymechanical_log.txt'"`` to write all commands that are
2358
+ sent to Mechanical via PyMechanical to this file. You can then use these
2359
+ commands to run a script within Mechanical without PyMechanical.
2360
+ connect_timeout : float, optional
2361
+ Maximum allowable time in seconds to connect to the Mechanical server.
2362
+ The default is ``120``.
2363
+ clear_on_connect : bool, optional
2364
+ Whether to clear the Mechanical instance when connecting. The default is ``False``.
2365
+ When ``True``, a fresh environment is provided when you connect to Mechanical.
2366
+ cleanup_on_exit : bool, optional
2367
+ Whether to exit Mechanical when Python exits. The default is ``False``.
2368
+ When ``False``, Mechanical is not exited when the garbage for this Mechanical
2369
+ instance is collected.
2370
+ keep_connection_alive : bool, optional
2371
+ Whether to keep the gRPC connection alive by running a background thread
2372
+ and making dummy calls for remote connections. The default is ``True``.
2373
+
2374
+ Returns
2375
+ -------
2376
+ ansys.mechanical.core.mechanical.Mechanical
2377
+ Instance of Mechanical.
2378
+
2379
+ Examples
2380
+ --------
2381
+ Connect to an existing Mechanical instance at IP address ``192.168.1.30`` on port
2382
+ ``50001``..
2383
+
2384
+
2385
+ >>> from ansys.mechanical.core import connect_to_mechanical
2386
+ >>> pymech = connect_to_mechanical(ip='192.168.1.30', port=50001)
2387
+ """
2388
+ return launch_mechanical(
2389
+ start_instance=False,
2390
+ loglevel=loglevel,
2391
+ log_file=log_file,
2392
+ log_mechanical=log_mechanical,
2393
+ start_timeout=connect_timeout,
2394
+ port=port,
2395
+ ip=ip,
2396
+ clear_on_connect=clear_on_connect,
2397
+ cleanup_on_exit=cleanup_on_exit,
2398
+ keep_connection_alive=keep_connection_alive,
2399
+ )