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

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