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

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