multiSSH3 4.81__tar.gz → 4.83__tar.gz

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.

Potentially problematic release.


This version of multiSSH3 might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: multiSSH3
3
- Version: 4.81
3
+ Version: 4.83
4
4
  Summary: Run commands on multiple hosts via SSH
5
5
  Home-page: https://github.com/yufei-pan/multiSSH3
6
6
  Author: Yufei Pan
@@ -164,8 +164,6 @@ Following document is generated courtesy of Mr.ChatGPT-o1 Preview:
164
164
  - [multissh](#multissh)
165
165
  - [Table of Contents](#table-of-contents)
166
166
  - [Features](#features)
167
- - [Installation](#installation)
168
- - [Usage](#usage)
169
167
  - [Basic Syntax](#basic-syntax)
170
168
  - [Command-Line Options](#command-line-options)
171
169
  - [Examples](#examples)
@@ -194,36 +192,10 @@ Following document is generated courtesy of Mr.ChatGPT-o1 Preview:
194
192
  - **Interactive Mode**: Run interactive commands with curses-based UI for monitoring.
195
193
  - **Quiet Mode**: Suppress output for cleaner automation scripts.
196
194
 
197
- ## Installation
198
-
199
- 1. **Clone the Repository**
200
-
201
- ```bash
202
- git clone https://github.com/yourusername/multissh.git
203
- ```
204
-
205
- 2. **Navigate to the Directory**
206
-
207
- ```bash
208
- cd multissh
209
- ```
210
-
211
- 3. **Make the Script Executable**
212
-
213
- ```bash
214
- chmod +x multissh.py
215
- ```
216
-
217
- 4. **Install Dependencies**
218
-
219
- Ensure you have Python 3 and the required modules installed. You may need to install `curses` and `ipaddress` modules if they are not already available.
220
-
221
- ## Usage
222
-
223
195
  ### Basic Syntax
224
196
 
225
197
  ```bash
226
- ./multissh.py [options] <hosts> <commands>
198
+ mssh [options] <hosts> <commands>
227
199
  ```
228
200
 
229
201
  - `<hosts>`: Comma-separated list of target hosts. Supports ranges and wildcards.
@@ -267,7 +239,7 @@ Following document is generated courtesy of Mr.ChatGPT-o1 Preview:
267
239
  ### Running a Command on Multiple Hosts
268
240
 
269
241
  ```bash
270
- ./multissh.py "host1,host2,host3" "uptime"
242
+ mssh "host1,host2,host3" "uptime"
271
243
  ```
272
244
 
273
245
  This command runs `uptime` on `host1`, `host2`, and `host3`.
@@ -275,7 +247,7 @@ This command runs `uptime` on `host1`, `host2`, and `host3`.
275
247
  ### Copying Files to Multiple Hosts
276
248
 
277
249
  ```bash
278
- ./multissh.py -f "/path/to/local/file.txt" "host1,host2,host3" "/remote/path/"
250
+ mssh -f "/path/to/local/file.txt" "host1,host2,host3" "/remote/path/"
279
251
  ```
280
252
 
281
253
  This command copies `file.txt` to `/remote/path/` on the specified hosts.
@@ -283,7 +255,7 @@ This command copies `file.txt` to `/remote/path/` on the specified hosts.
283
255
  ### Using Hostname Ranges
284
256
 
285
257
  ```bash
286
- ./multissh.py "host[01-05]" "hostname"
258
+ mssh "host[01-05]" "hostname"
287
259
  ```
288
260
 
289
261
  This expands to `host01`, `host02`, `host03`, `host04`, `host05` and runs `hostname` on each.
@@ -291,7 +263,7 @@ This expands to `host01`, `host02`, `host03`, `host04`, `host05` and runs `hostn
291
263
  ### Using IPMI
292
264
 
293
265
  ```bash
294
- ./multissh.py --ipmi "192.168.1.[100-105]" "chassis power status"
266
+ mssh --ipmi "192.168.1.[100-105]" "chassis power status"
295
267
  ```
296
268
 
297
269
  Runs `ipmitool chassis power status` on the specified IPMI interfaces.
@@ -299,7 +271,7 @@ Runs `ipmitool chassis power status` on the specified IPMI interfaces.
299
271
  ### Using Password Authentication
300
272
 
301
273
  ```bash
302
- ./multissh.py -p "yourpassword" "host1,host2" "whoami"
274
+ mssh -p "yourpassword" "host1,host2" "whoami"
303
275
  ```
304
276
 
305
277
  Uses `sshpass` to provide the password for SSH authentication.
@@ -307,7 +279,7 @@ Uses `sshpass` to provide the password for SSH authentication.
307
279
  ### Skipping Unreachable Hosts
308
280
 
309
281
  ```bash
310
- ./multissh.py -su "host1,host2,host3" "date"
282
+ mssh -su "host1,host2,host3" "date"
311
283
  ```
312
284
 
313
285
  Skips hosts that are unreachable during execution.
@@ -315,7 +287,7 @@ Skips hosts that are unreachable during execution.
315
287
  ### JSON Output
316
288
 
317
289
  ```bash
318
- ./multissh.py -j "host1,host2" "uname -a"
290
+ mssh -j "host1,host2" "uname -a"
319
291
  ```
320
292
 
321
293
  Outputs the results in JSON format, suitable for parsing.
@@ -323,7 +295,7 @@ Outputs the results in JSON format, suitable for parsing.
323
295
  ### Quiet Mode
324
296
 
325
297
  ```bash
326
- ./multissh.py -q "host1,host2" "ls /nonexistent"
298
+ mssh -q "host1,host2" "ls /nonexistent"
327
299
  ```
328
300
 
329
301
  Suppresses all output, useful for scripts where you only care about exit codes.
@@ -148,8 +148,6 @@ Following document is generated courtesy of Mr.ChatGPT-o1 Preview:
148
148
  - [multissh](#multissh)
149
149
  - [Table of Contents](#table-of-contents)
150
150
  - [Features](#features)
151
- - [Installation](#installation)
152
- - [Usage](#usage)
153
151
  - [Basic Syntax](#basic-syntax)
154
152
  - [Command-Line Options](#command-line-options)
155
153
  - [Examples](#examples)
@@ -178,36 +176,10 @@ Following document is generated courtesy of Mr.ChatGPT-o1 Preview:
178
176
  - **Interactive Mode**: Run interactive commands with curses-based UI for monitoring.
179
177
  - **Quiet Mode**: Suppress output for cleaner automation scripts.
180
178
 
181
- ## Installation
182
-
183
- 1. **Clone the Repository**
184
-
185
- ```bash
186
- git clone https://github.com/yourusername/multissh.git
187
- ```
188
-
189
- 2. **Navigate to the Directory**
190
-
191
- ```bash
192
- cd multissh
193
- ```
194
-
195
- 3. **Make the Script Executable**
196
-
197
- ```bash
198
- chmod +x multissh.py
199
- ```
200
-
201
- 4. **Install Dependencies**
202
-
203
- Ensure you have Python 3 and the required modules installed. You may need to install `curses` and `ipaddress` modules if they are not already available.
204
-
205
- ## Usage
206
-
207
179
  ### Basic Syntax
208
180
 
209
181
  ```bash
210
- ./multissh.py [options] <hosts> <commands>
182
+ mssh [options] <hosts> <commands>
211
183
  ```
212
184
 
213
185
  - `<hosts>`: Comma-separated list of target hosts. Supports ranges and wildcards.
@@ -251,7 +223,7 @@ Following document is generated courtesy of Mr.ChatGPT-o1 Preview:
251
223
  ### Running a Command on Multiple Hosts
252
224
 
253
225
  ```bash
254
- ./multissh.py "host1,host2,host3" "uptime"
226
+ mssh "host1,host2,host3" "uptime"
255
227
  ```
256
228
 
257
229
  This command runs `uptime` on `host1`, `host2`, and `host3`.
@@ -259,7 +231,7 @@ This command runs `uptime` on `host1`, `host2`, and `host3`.
259
231
  ### Copying Files to Multiple Hosts
260
232
 
261
233
  ```bash
262
- ./multissh.py -f "/path/to/local/file.txt" "host1,host2,host3" "/remote/path/"
234
+ mssh -f "/path/to/local/file.txt" "host1,host2,host3" "/remote/path/"
263
235
  ```
264
236
 
265
237
  This command copies `file.txt` to `/remote/path/` on the specified hosts.
@@ -267,7 +239,7 @@ This command copies `file.txt` to `/remote/path/` on the specified hosts.
267
239
  ### Using Hostname Ranges
268
240
 
269
241
  ```bash
270
- ./multissh.py "host[01-05]" "hostname"
242
+ mssh "host[01-05]" "hostname"
271
243
  ```
272
244
 
273
245
  This expands to `host01`, `host02`, `host03`, `host04`, `host05` and runs `hostname` on each.
@@ -275,7 +247,7 @@ This expands to `host01`, `host02`, `host03`, `host04`, `host05` and runs `hostn
275
247
  ### Using IPMI
276
248
 
277
249
  ```bash
278
- ./multissh.py --ipmi "192.168.1.[100-105]" "chassis power status"
250
+ mssh --ipmi "192.168.1.[100-105]" "chassis power status"
279
251
  ```
280
252
 
281
253
  Runs `ipmitool chassis power status` on the specified IPMI interfaces.
@@ -283,7 +255,7 @@ Runs `ipmitool chassis power status` on the specified IPMI interfaces.
283
255
  ### Using Password Authentication
284
256
 
285
257
  ```bash
286
- ./multissh.py -p "yourpassword" "host1,host2" "whoami"
258
+ mssh -p "yourpassword" "host1,host2" "whoami"
287
259
  ```
288
260
 
289
261
  Uses `sshpass` to provide the password for SSH authentication.
@@ -291,7 +263,7 @@ Uses `sshpass` to provide the password for SSH authentication.
291
263
  ### Skipping Unreachable Hosts
292
264
 
293
265
  ```bash
294
- ./multissh.py -su "host1,host2,host3" "date"
266
+ mssh -su "host1,host2,host3" "date"
295
267
  ```
296
268
 
297
269
  Skips hosts that are unreachable during execution.
@@ -299,7 +271,7 @@ Skips hosts that are unreachable during execution.
299
271
  ### JSON Output
300
272
 
301
273
  ```bash
302
- ./multissh.py -j "host1,host2" "uname -a"
274
+ mssh -j "host1,host2" "uname -a"
303
275
  ```
304
276
 
305
277
  Outputs the results in JSON format, suitable for parsing.
@@ -307,7 +279,7 @@ Outputs the results in JSON format, suitable for parsing.
307
279
  ### Quiet Mode
308
280
 
309
281
  ```bash
310
- ./multissh.py -q "host1,host2" "ls /nonexistent"
282
+ mssh -q "host1,host2" "ls /nonexistent"
311
283
  ```
312
284
 
313
285
  Suppresses all output, useful for scripts where you only care about exit codes.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: multiSSH3
3
- Version: 4.81
3
+ Version: 4.83
4
4
  Summary: Run commands on multiple hosts via SSH
5
5
  Home-page: https://github.com/yufei-pan/multiSSH3
6
6
  Author: Yufei Pan
@@ -164,8 +164,6 @@ Following document is generated courtesy of Mr.ChatGPT-o1 Preview:
164
164
  - [multissh](#multissh)
165
165
  - [Table of Contents](#table-of-contents)
166
166
  - [Features](#features)
167
- - [Installation](#installation)
168
- - [Usage](#usage)
169
167
  - [Basic Syntax](#basic-syntax)
170
168
  - [Command-Line Options](#command-line-options)
171
169
  - [Examples](#examples)
@@ -194,36 +192,10 @@ Following document is generated courtesy of Mr.ChatGPT-o1 Preview:
194
192
  - **Interactive Mode**: Run interactive commands with curses-based UI for monitoring.
195
193
  - **Quiet Mode**: Suppress output for cleaner automation scripts.
196
194
 
197
- ## Installation
198
-
199
- 1. **Clone the Repository**
200
-
201
- ```bash
202
- git clone https://github.com/yourusername/multissh.git
203
- ```
204
-
205
- 2. **Navigate to the Directory**
206
-
207
- ```bash
208
- cd multissh
209
- ```
210
-
211
- 3. **Make the Script Executable**
212
-
213
- ```bash
214
- chmod +x multissh.py
215
- ```
216
-
217
- 4. **Install Dependencies**
218
-
219
- Ensure you have Python 3 and the required modules installed. You may need to install `curses` and `ipaddress` modules if they are not already available.
220
-
221
- ## Usage
222
-
223
195
  ### Basic Syntax
224
196
 
225
197
  ```bash
226
- ./multissh.py [options] <hosts> <commands>
198
+ mssh [options] <hosts> <commands>
227
199
  ```
228
200
 
229
201
  - `<hosts>`: Comma-separated list of target hosts. Supports ranges and wildcards.
@@ -267,7 +239,7 @@ Following document is generated courtesy of Mr.ChatGPT-o1 Preview:
267
239
  ### Running a Command on Multiple Hosts
268
240
 
269
241
  ```bash
270
- ./multissh.py "host1,host2,host3" "uptime"
242
+ mssh "host1,host2,host3" "uptime"
271
243
  ```
272
244
 
273
245
  This command runs `uptime` on `host1`, `host2`, and `host3`.
@@ -275,7 +247,7 @@ This command runs `uptime` on `host1`, `host2`, and `host3`.
275
247
  ### Copying Files to Multiple Hosts
276
248
 
277
249
  ```bash
278
- ./multissh.py -f "/path/to/local/file.txt" "host1,host2,host3" "/remote/path/"
250
+ mssh -f "/path/to/local/file.txt" "host1,host2,host3" "/remote/path/"
279
251
  ```
280
252
 
281
253
  This command copies `file.txt` to `/remote/path/` on the specified hosts.
@@ -283,7 +255,7 @@ This command copies `file.txt` to `/remote/path/` on the specified hosts.
283
255
  ### Using Hostname Ranges
284
256
 
285
257
  ```bash
286
- ./multissh.py "host[01-05]" "hostname"
258
+ mssh "host[01-05]" "hostname"
287
259
  ```
288
260
 
289
261
  This expands to `host01`, `host02`, `host03`, `host04`, `host05` and runs `hostname` on each.
@@ -291,7 +263,7 @@ This expands to `host01`, `host02`, `host03`, `host04`, `host05` and runs `hostn
291
263
  ### Using IPMI
292
264
 
293
265
  ```bash
294
- ./multissh.py --ipmi "192.168.1.[100-105]" "chassis power status"
266
+ mssh --ipmi "192.168.1.[100-105]" "chassis power status"
295
267
  ```
296
268
 
297
269
  Runs `ipmitool chassis power status` on the specified IPMI interfaces.
@@ -299,7 +271,7 @@ Runs `ipmitool chassis power status` on the specified IPMI interfaces.
299
271
  ### Using Password Authentication
300
272
 
301
273
  ```bash
302
- ./multissh.py -p "yourpassword" "host1,host2" "whoami"
274
+ mssh -p "yourpassword" "host1,host2" "whoami"
303
275
  ```
304
276
 
305
277
  Uses `sshpass` to provide the password for SSH authentication.
@@ -307,7 +279,7 @@ Uses `sshpass` to provide the password for SSH authentication.
307
279
  ### Skipping Unreachable Hosts
308
280
 
309
281
  ```bash
310
- ./multissh.py -su "host1,host2,host3" "date"
282
+ mssh -su "host1,host2,host3" "date"
311
283
  ```
312
284
 
313
285
  Skips hosts that are unreachable during execution.
@@ -315,7 +287,7 @@ Skips hosts that are unreachable during execution.
315
287
  ### JSON Output
316
288
 
317
289
  ```bash
318
- ./multissh.py -j "host1,host2" "uname -a"
290
+ mssh -j "host1,host2" "uname -a"
319
291
  ```
320
292
 
321
293
  Outputs the results in JSON format, suitable for parsing.
@@ -323,7 +295,7 @@ Outputs the results in JSON format, suitable for parsing.
323
295
  ### Quiet Mode
324
296
 
325
297
  ```bash
326
- ./multissh.py -q "host1,host2" "ls /nonexistent"
298
+ mssh -q "host1,host2" "ls /nonexistent"
327
299
  ```
328
300
 
329
301
  Suppresses all output, useful for scripts where you only care about exit codes.
@@ -15,7 +15,9 @@ import io
15
15
  import signal
16
16
  import functools
17
17
  import glob
18
- #import fnmatch
18
+ import shutil
19
+ import getpass
20
+
19
21
  try:
20
22
  # Check if functiools.cache is available
21
23
  cache_decorator = functools.cache
@@ -27,7 +29,7 @@ except AttributeError:
27
29
  # If neither is available, use a dummy decorator
28
30
  def cache_decorator(func):
29
31
  return func
30
- version = '4.81'
32
+ version = '4.83'
31
33
  VERSION = version
32
34
 
33
35
  CONFIG_FILE = '/etc/multiSSH3.config.json'
@@ -87,13 +89,20 @@ __build_in_default_config = {
87
89
  'Warning: Permanently added',
88
90
  'mux_client_request_session',
89
91
  'disabling multiplexing',
92
+ 'Killed by signal',
93
+ 'Connection reset by peer',
90
94
  ],
91
95
  '_DEFAULT_CALLED': True,
92
96
  '_DEFAULT_RETURN_UNFINISHED': False,
93
97
  '_DEFAULT_UPDATE_UNREACHABLE_HOSTS': True,
94
98
  '_DEFAULT_NO_START': False,
95
99
  '_etc_hosts': {},
96
- '_sshpassAvailable': False,
100
+ '_sshpassPath': None,
101
+ '_sshPath': None,
102
+ '_scpPath': None,
103
+ '_ipmitoolPath': None,
104
+ '_rsyncPath': None,
105
+ '_bashPath': None,
97
106
  '__ERROR_MESSAGES_TO_IGNORE_REGEX':None,
98
107
  }
99
108
 
@@ -139,7 +148,8 @@ _DEFAULT_UPDATE_UNREACHABLE_HOSTS = __configs_from_file.get('_DEFAULT_UPDATE_UNR
139
148
  _DEFAULT_NO_START = __configs_from_file.get('_DEFAULT_NO_START', __build_in_default_config['_DEFAULT_NO_START'])
140
149
 
141
150
  # form the regex from the list
142
- if '__ERROR_MESSAGES_TO_IGNORE_REGEX' in __configs_from_file:
151
+ __ERROR_MESSAGES_TO_IGNORE_REGEX = __configs_from_file.get('__ERROR_MESSAGES_TO_IGNORE_REGEX', __build_in_default_config['__ERROR_MESSAGES_TO_IGNORE_REGEX'])
152
+ if __ERROR_MESSAGES_TO_IGNORE_REGEX:
143
153
  print('Using __ERROR_MESSAGES_TO_IGNORE_REGEX from config file, ignoring ERROR_MESSAGES_TO_IGNORE')
144
154
  __ERROR_MESSAGES_TO_IGNORE_REGEX = re.compile(__configs_from_file['__ERROR_MESSAGES_TO_IGNORE_REGEX'])
145
155
  else:
@@ -152,7 +162,7 @@ __global_suppress_printout = True
152
162
  __mainReturnCode = 0
153
163
  __failedHosts = set()
154
164
  class Host:
155
- def __init__(self, name, command, files = None,ipmi = False,interface_ip_prefix = None,scp=False,extraargs=None):
165
+ def __init__(self, name, command, files = None,ipmi = False,interface_ip_prefix = None,scp=False,extraargs=None,gatherMode=False):
156
166
  self.name = name # the name of the host (hostname or IP address)
157
167
  self.command = command # the command to run on the host
158
168
  self.returncode = None # the return code of the command
@@ -164,13 +174,14 @@ class Host:
164
174
  self.ipmi = ipmi # whether to use ipmi to connect to the host
165
175
  self.interface_ip_prefix = interface_ip_prefix # the prefix of the ip address of the interface to be used to connect to the host
166
176
  self.scp = scp # whether to use scp to copy files to the host
177
+ self.gatherMode = gatherMode # whether the host is in gather mode
167
178
  self.extraargs = extraargs # extra arguments to be passed to ssh
168
179
  self.resolvedName = None # the resolved IP address of the host
169
180
  def __iter__(self):
170
181
  return zip(['name', 'command', 'returncode', 'stdout', 'stderr'], [self.name, self.command, self.returncode, self.stdout, self.stderr])
171
182
  def __repr__(self):
172
183
  # return the complete data structure
173
- return f"Host(name={self.name}, command={self.command}, returncode={self.returncode}, stdout={self.stdout}, stderr={self.stderr}, output={self.output}, printedLines={self.printedLines}, files={self.files}, ipmi={self.ipmi}, interface_ip_prefix={self.interface_ip_prefix}, scp={self.scp}, extraargs={self.extraargs}, resolvedName={self.resolvedName})"
184
+ return f"Host(name={self.name}, command={self.command}, returncode={self.returncode}, stdout={self.stdout}, stderr={self.stderr}, output={self.output}, printedLines={self.printedLines}, files={self.files}, ipmi={self.ipmi}, interface_ip_prefix={self.interface_ip_prefix}, scp={self.scp}, gatherMode={self.gatherMode}, extraargs={self.extraargs}, resolvedName={self.resolvedName})"
174
185
  def __str__(self):
175
186
  return f"Host(name={self.name}, command={self.command}, returncode={self.returncode}, stdout={self.stdout}, stderr={self.stderr})"
176
187
 
@@ -189,12 +200,24 @@ _etc_hosts = __configs_from_file.get('_etc_hosts', __build_in_default_config['_e
189
200
  _env_file = DEFAULT_ENV_FILE
190
201
 
191
202
  # check if command sshpass is available
192
- _sshpassAvailable = __configs_from_file.get('_sshpassAvailable', __build_in_default_config['_sshpassAvailable'])
193
- try:
194
- subprocess.run(['which', 'sshpass'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
195
- _sshpassAvailable = True
196
- except:
197
- pass
203
+ _binPaths = {}
204
+ def check_path(program_name):
205
+ global __configs_from_file
206
+ global __build_in_default_config
207
+ global _binPaths
208
+ config_key = f'_{program_name}Path'
209
+ program_path = (
210
+ __configs_from_file.get(config_key) or
211
+ __build_in_default_config.get(config_key) or
212
+ shutil.which(program_name)
213
+ )
214
+ if program_path:
215
+ _binPaths[program_name] = program_path
216
+ return True
217
+ return False
218
+
219
+ [check_path(program) for program in ['sshpass', 'ssh', 'scp', 'ipmitool','rsync','bash']]
220
+
198
221
 
199
222
 
200
223
  @cache_decorator
@@ -582,64 +605,124 @@ def ssh_command(host, sem, timeout=60,passwds=None):
582
605
  global _emo
583
606
  global __ERROR_MESSAGES_TO_IGNORE_REGEX
584
607
  global __ipmiiInterfaceIPPrefix
585
- global _sshpassAvailable
586
- with sem:
587
- try:
588
- host.username = None
589
- host.address = host.name
590
- if '@' in host.name:
591
- host.username, host.address = host.name.rsplit('@',1)
592
- if "#HOST#" in host.command.upper() or "#HOSTNAME#" in host.command.upper():
593
- host.command = host.command.replace("#HOST#",host.address).replace("#HOSTNAME#",host.address).replace("#host#",host.address).replace("#hostname#",host.address)
594
- if "#USER#" in host.command.upper() or "#USERNAME#" in host.command.upper():
595
- if host.username:
596
- host.command = host.command.replace("#USER#",host.username).replace("#USERNAME#",host.username).replace("#user#",host.username).replace("#username#",host.username)
597
- else:
598
- host.command = host.command.replace("#USER#",'CURRENT_USER').replace("#USERNAME#",'CURRENT_USER').replace("#user#",'CURRENT_USER').replace("#username#",'CURRENT_USER')
599
- formatedCMD = []
600
- if host.extraargs:
601
- extraargs = host.extraargs.split()
602
- else:
603
- extraargs = []
604
- if __ipmiiInterfaceIPPrefix:
605
- host.interface_ip_prefix = __ipmiiInterfaceIPPrefix if host.ipmi and not host.interface_ip_prefix else host.interface_ip_prefix
606
- if host.interface_ip_prefix:
607
- try:
608
- hostOctets = getIP(host.address,local=False).split('.')
609
- prefixOctets = host.interface_ip_prefix.split('.')
610
- host.address = '.'.join(prefixOctets[:3]+hostOctets[min(3,len(prefixOctets)):])
611
- host.resolvedName = host.username + '@' if host.username else ''
612
- host.resolvedName += host.address
613
- except:
614
- host.resolvedName = host.name
608
+ global _binPaths
609
+ try:
610
+ host.username = None
611
+ host.address = host.name
612
+ if '@' in host.name:
613
+ host.username, host.address = host.name.rsplit('@',1)
614
+ if "#HOST#" in host.command.upper() or "#HOSTNAME#" in host.command.upper():
615
+ host.command = host.command.replace("#HOST#",host.address).replace("#HOSTNAME#",host.address).replace("#host#",host.address).replace("#hostname#",host.address)
616
+ if "#USER#" in host.command.upper() or "#USERNAME#" in host.command.upper():
617
+ if host.username:
618
+ host.command = host.command.replace("#USER#",host.username).replace("#USERNAME#",host.username).replace("#user#",host.username).replace("#username#",host.username)
615
619
  else:
620
+ current_user = getpass.getuser()
621
+ host.command = host.command.replace("#USER#",current_user).replace("#USERNAME#",current_user).replace("#user#",current_user).replace("#username#",current_user)
622
+ formatedCMD = []
623
+ if host.extraargs and type(host.extraargs) == str:
624
+ extraargs = host.extraargs.split()
625
+ elif host.extraargs and type(host.extraargs) == list:
626
+ extraargs = [str(arg) for arg in host.extraargs]
627
+ else:
628
+ extraargs = []
629
+ if __ipmiiInterfaceIPPrefix:
630
+ host.interface_ip_prefix = __ipmiiInterfaceIPPrefix if host.ipmi and not host.interface_ip_prefix else host.interface_ip_prefix
631
+ if host.interface_ip_prefix:
632
+ try:
633
+ hostOctets = getIP(host.address,local=False).split('.')
634
+ prefixOctets = host.interface_ip_prefix.split('.')
635
+ host.address = '.'.join(prefixOctets[:3]+hostOctets[min(3,len(prefixOctets)):])
636
+ host.resolvedName = host.username + '@' if host.username else ''
637
+ host.resolvedName += host.address
638
+ except:
616
639
  host.resolvedName = host.name
617
- if host.ipmi:
640
+ else:
641
+ host.resolvedName = host.name
642
+ if host.ipmi:
643
+ if 'ipmitool' in _binPaths:
618
644
  if host.command.startswith('ipmitool '):
619
645
  host.command = host.command.replace('ipmitool ','')
646
+ elif host.command.startswith(_binPaths['ipmitool']):
647
+ host.command = host.command.replace(_binPaths['ipmitool'],'')
620
648
  if not host.username:
621
649
  host.username = 'admin'
622
- if passwds:
623
- formatedCMD = ['bash','-c',f'ipmitool -H {host.address} -U {host.username} -P {passwds} {" ".join(extraargs)} {host.command}']
650
+ if 'bash' in _binPaths:
651
+ if passwds:
652
+ formatedCMD = [_binPaths['bash'],'-c',f'ipmitool -H {host.address} -U {host.username} -P {passwds} {" ".join(extraargs)} {host.command}']
653
+ else:
654
+ formatedCMD = [_binPaths['bash'],'-c',f'ipmitool -H {host.address} -U {host.username} {" ".join(extraargs)} {host.command}']
624
655
  else:
625
- formatedCMD = ['bash','-c',f'ipmitool -H {host.address} -U {host.username} {" ".join(extraargs)} {host.command}']
656
+ if passwds:
657
+ formatedCMD = [_binPaths['ipmitool'],f'-H {host.address}',f'-U {host.username}',f'-P {passwds}'] + extraargs + [host.command]
658
+ else:
659
+ formatedCMD = [_binPaths['ipmitool'],f'-H {host.address}',f'-U {host.username}'] + extraargs + [host.command]
660
+ elif 'ssh' in _binPaths:
661
+ host.output.append('Ipmitool not found on the local machine! Trying ipmitool on the remote machine...')
662
+ host.ipmi = False
663
+ host.interface_ip_prefix = None
664
+ host.command = 'ipmitool '+host.command if not host.command.startswith('ipmitool ') else host.command
665
+ ssh_command(host,sem,timeout,passwds)
666
+ return
626
667
  else:
627
- if host.files:
628
- if host.scp:
629
- formatedCMD = ['scp','-rpB'] + extraargs +['--']+host.files+[f'{host.resolvedName}:{host.command}']
668
+ host.output.append('Ipmitool not found on the local machine! Please install ipmitool to use ipmi mode.')
669
+ host.stderr.append('Ipmitool not found on the local machine! Please install ipmitool to use ipmi mode.')
670
+ host.returncode = 1
671
+ return
672
+ else:
673
+ if host.files:
674
+ if host.scp:
675
+ if 'scp' in _binPaths:
676
+ useScp = True
677
+ elif 'rsync' in _binPaths:
678
+ host.output.append('scp not found on the local machine! Trying to use rsync...')
679
+ useScp = False
630
680
  else:
631
- formatedCMD = ['rsync','-ahlX','--partial','--inplace', '--info=name'] + extraargs +['--']+host.files+[f'{host.resolvedName}:{host.command}']
681
+ host.output.append('scp not found on the local machine! Please install scp or rsync to use file sync mode.')
682
+ host.stderr.append('scp not found on the local machine! Please install scp or rsync to use file sync mode.')
683
+ host.returncode = 1
684
+ return
685
+ elif 'rsync' in _binPaths:
686
+ useScp = False
687
+ elif 'scp' in _binPaths:
688
+ host.output.append('rsync not found on the local machine! Trying to use scp...')
689
+ useScp = True
690
+ else:
691
+ host.output.append('rsync not found on the local machine! Please install rsync or scp to use file sync mode.')
692
+ host.stderr.append('rsync not found on the local machine! Please install rsync or scp to use file sync mode.')
693
+ host.returncode = 1
694
+ return
695
+ if host.gatherMode:
696
+ fileArgs = [f'{host.resolvedName}:{file}' for file in host.files] + [host.command]
632
697
  else:
633
- formatedCMD = ['ssh'] + extraargs +['--']+ [host.resolvedName, host.command]
634
- if passwds and _sshpassAvailable:
635
- formatedCMD = ['sshpass', '-p', passwds] + formatedCMD
636
- elif passwds:
637
- host.output.append('Warning: sshpass is not available. Please install sshpass to use password authentication.')
638
- #host.stderr.append('Warning: sshpass is not available. Please install sshpass to use password authentication.')
639
- host.output.append('Please provide password via live input or use ssh key authentication.')
640
- # # try to send the password via __keyPressesIn
641
- # __keyPressesIn[-1] = list(passwds) + ['\n']
642
- # __keyPressesIn.append([])
698
+ fileArgs = host.files + [f'{host.resolvedName}:{host.command}']
699
+ if useScp:
700
+ formatedCMD = [_binPaths['scp'],'-rpB'] + extraargs +['--']+fileArgs
701
+ else:
702
+ formatedCMD = [_binPaths['rsync'],'-ahlX','--partial','--inplace', '--info=name'] + extraargs +['--']+fileArgs
703
+ else:
704
+ formatedCMD = [_binPaths['ssh']] + extraargs +['--']+ [host.resolvedName, host.command]
705
+ if passwds and 'sshpass' in _binPaths:
706
+ formatedCMD = [_binPaths['sshpass'], '-p', passwds] + formatedCMD
707
+ elif passwds:
708
+ host.output.append('Warning: sshpass is not available. Please install sshpass to use password authentication.')
709
+ #host.stderr.append('Warning: sshpass is not available. Please install sshpass to use password authentication.')
710
+ host.output.append('Please provide password via live input or use ssh key authentication.')
711
+ # # try to send the password via __keyPressesIn
712
+ # __keyPressesIn[-1] = list(passwds) + ['\n']
713
+ # __keyPressesIn.append([])
714
+ except Exception as e:
715
+ import traceback
716
+ host.output.append(f'Error occurred while formatting the command : {host.command}!')
717
+ host.stderr.append(f'Error occurred while formatting the command : {host.command}!')
718
+ host.stderr.extend(str(e).split('\n'))
719
+ host.output.extend(str(e).split('\n'))
720
+ host.stderr.extend(traceback.format_exc().split('\n'))
721
+ host.output.extend(traceback.format_exc().split('\n'))
722
+ host.returncode = -1
723
+ return
724
+ with sem:
725
+ try:
643
726
  host.output.append('Running command: '+' '.join(formatedCMD))
644
727
  #host.stdout = []
645
728
  proc = subprocess.Popen(formatedCMD,stdout=subprocess.PIPE,stderr=subprocess.PIPE,stdin=subprocess.PIPE)
@@ -733,7 +816,7 @@ def ssh_command(host, sem, timeout=60,passwds=None):
733
816
  host.command = 'ipmitool '+host.command if not host.command.startswith('ipmitool ') else host.command
734
817
  ssh_command(host,sem,timeout,passwds)
735
818
  # If transfering files, we will try again using scp if rsync connection is not successful
736
- if host.files and not host.scp and host.returncode != 0 and host.stderr:
819
+ if host.files and not host.scp and not useScp and host.returncode != 0 and host.stderr:
737
820
  host.stderr = []
738
821
  host.stdout = []
739
822
  host.output.append('Rsync connection failed! Trying SCP connection...')
@@ -1242,7 +1325,7 @@ def formHostStr(host) -> str:
1242
1325
  def __formCommandArgStr(oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT,password = DEFAULT_PASSWORD,
1243
1326
  nowatch = DEFAULT_NO_WATCH,json = DEFAULT_JSON_MODE,max_connections=DEFAULT_MAX_CONNECTIONS,
1244
1327
  files = None,ipmi = DEFAULT_IPMI,interface_ip_prefix = DEFAULT_INTERFACE_IP_PREFIX,
1245
- scp=DEFAULT_SCP,username=DEFAULT_USERNAME,extraargs=DEFAULT_EXTRA_ARGS,skipUnreachable=DEFAULT_SKIP_UNREACHABLE,
1328
+ scp=DEFAULT_SCP,gather_mode = False,username=DEFAULT_USERNAME,extraargs=DEFAULT_EXTRA_ARGS,skipUnreachable=DEFAULT_SKIP_UNREACHABLE,
1246
1329
  no_env=DEFAULT_NO_ENV,greppable=DEFAULT_GREPPABLE_MODE,skip_hosts = DEFAULT_SKIP_HOSTS,
1247
1330
  file_sync = False, error_only = DEFAULT_ERROR_ONLY,
1248
1331
  shortend = False) -> str:
@@ -1257,6 +1340,7 @@ def __formCommandArgStr(oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT
1257
1340
  if ipmi: argsList.append('--ipmi')
1258
1341
  if interface_ip_prefix and interface_ip_prefix != DEFAULT_INTERFACE_IP_PREFIX: argsList.append(f'--interface_ip_prefix="{interface_ip_prefix}"' if not shortend else f'-pre="{interface_ip_prefix}"')
1259
1342
  if scp: argsList.append('--scp')
1343
+ if gather_mode: argsList.append('--gather_mode' if not shortend else '-gm')
1260
1344
  if username and username != DEFAULT_USERNAME: argsList.append(f'--username="{username}"' if not shortend else f'-u="{username}"')
1261
1345
  if extraargs and extraargs != DEFAULT_EXTRA_ARGS: argsList.append(f'--extraargs="{extraargs}"' if not shortend else f'-ea="{extraargs}"')
1262
1346
  if skipUnreachable: argsList.append('--skipUnreachable' if not shortend else '-su')
@@ -1270,7 +1354,7 @@ def __formCommandArgStr(oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT
1270
1354
  def getStrCommand(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT,password = DEFAULT_PASSWORD,
1271
1355
  nowatch = DEFAULT_NO_WATCH,json = DEFAULT_JSON_MODE,called = _DEFAULT_CALLED,max_connections=DEFAULT_MAX_CONNECTIONS,
1272
1356
  files = None,ipmi = DEFAULT_IPMI,interface_ip_prefix = DEFAULT_INTERFACE_IP_PREFIX,returnUnfinished = _DEFAULT_RETURN_UNFINISHED,
1273
- scp=DEFAULT_SCP,username=DEFAULT_USERNAME,extraargs=DEFAULT_EXTRA_ARGS,skipUnreachable=DEFAULT_SKIP_UNREACHABLE,
1357
+ scp=DEFAULT_SCP,gather_mode = False,username=DEFAULT_USERNAME,extraargs=DEFAULT_EXTRA_ARGS,skipUnreachable=DEFAULT_SKIP_UNREACHABLE,
1274
1358
  no_env=DEFAULT_NO_ENV,greppable=DEFAULT_GREPPABLE_MODE,willUpdateUnreachableHosts=_DEFAULT_UPDATE_UNREACHABLE_HOSTS,no_start=_DEFAULT_NO_START,
1275
1359
  skip_hosts = DEFAULT_SKIP_HOSTS, curses_min_char_len = DEFAULT_CURSES_MINIMUM_CHAR_LEN, curses_min_line_len = DEFAULT_CURSES_MINIMUM_LINE_LEN,
1276
1360
  single_window = DEFAULT_SINGLE_WINDOW,file_sync = False,error_only = DEFAULT_ERROR_ONLY, shortend = False):
@@ -1279,7 +1363,7 @@ def getStrCommand(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_O
1279
1363
  files = frozenset(files) if files else None
1280
1364
  argsStr = __formCommandArgStr(oneonone = oneonone, timeout = timeout,password = password,
1281
1365
  nowatch = nowatch,json = json,max_connections=max_connections,
1282
- files = files,ipmi = ipmi,interface_ip_prefix = interface_ip_prefix,scp=scp,
1366
+ files = files,ipmi = ipmi,interface_ip_prefix = interface_ip_prefix,scp=scp,gather_mode = gather_mode,
1283
1367
  username=username,extraargs=extraargs,skipUnreachable=skipUnreachable,no_env=no_env,
1284
1368
  greppable=greppable,skip_hosts = skip_hosts, file_sync = file_sync,error_only = error_only, shortend = shortend)
1285
1369
  commandStr = '"' + '" "'.join(commands) + '"' if commands else ''
@@ -1288,7 +1372,7 @@ def getStrCommand(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_O
1288
1372
  def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT,password = DEFAULT_PASSWORD,
1289
1373
  nowatch = DEFAULT_NO_WATCH,json = DEFAULT_JSON_MODE,called = _DEFAULT_CALLED,max_connections=DEFAULT_MAX_CONNECTIONS,
1290
1374
  files = None,ipmi = DEFAULT_IPMI,interface_ip_prefix = DEFAULT_INTERFACE_IP_PREFIX,returnUnfinished = _DEFAULT_RETURN_UNFINISHED,
1291
- scp=DEFAULT_SCP,username=DEFAULT_USERNAME,extraargs=DEFAULT_EXTRA_ARGS,skipUnreachable=DEFAULT_SKIP_UNREACHABLE,
1375
+ scp=DEFAULT_SCP,gather_mode = False,username=DEFAULT_USERNAME,extraargs=DEFAULT_EXTRA_ARGS,skipUnreachable=DEFAULT_SKIP_UNREACHABLE,
1292
1376
  no_env=DEFAULT_NO_ENV,greppable=DEFAULT_GREPPABLE_MODE,willUpdateUnreachableHosts=_DEFAULT_UPDATE_UNREACHABLE_HOSTS,no_start=_DEFAULT_NO_START,
1293
1377
  skip_hosts = DEFAULT_SKIP_HOSTS, curses_min_char_len = DEFAULT_CURSES_MINIMUM_CHAR_LEN, curses_min_line_len = DEFAULT_CURSES_MINIMUM_LINE_LEN,
1294
1378
  single_window = DEFAULT_SINGLE_WINDOW,file_sync = False,error_only = DEFAULT_ERROR_ONLY):
@@ -1418,9 +1502,9 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
1418
1502
  continue
1419
1503
  if host.strip() in skipHostsList: continue
1420
1504
  if file_sync:
1421
- hosts.append(Host(host.strip(), os.path.dirname(command)+os.path.sep, files = [command],ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs))
1505
+ hosts.append(Host(host.strip(), os.path.dirname(command)+os.path.sep, files = [command],ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs,gatherMode=gather_mode))
1422
1506
  else:
1423
- hosts.append(Host(host.strip(), command, files = files,ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs))
1507
+ hosts.append(Host(host.strip(), command, files = files,ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs,gatherMode=gather_mode))
1424
1508
  if not __global_suppress_printout:
1425
1509
  print(f"Running command: {command} on host: {host}")
1426
1510
  if not __global_suppress_printout: print('-'*80)
@@ -1462,9 +1546,9 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
1462
1546
  continue
1463
1547
  if host.strip() in skipHostsList: continue
1464
1548
  if file_sync:
1465
- hosts.append(Host(host.strip(), os.path.dirname(command)+os.path.sep, files = [command],ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs))
1549
+ hosts.append(Host(host.strip(), os.path.dirname(command)+os.path.sep, files = [command],ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs,gatherMode=gather_mode))
1466
1550
  else:
1467
- hosts.append(Host(host.strip(), command, files = files,ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs))
1551
+ hosts.append(Host(host.strip(), command, files = files,ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs,gatherMode=gather_mode))
1468
1552
  if not __global_suppress_printout and len(commands) > 1:
1469
1553
  print('-'*80)
1470
1554
  print(f"Running command: {command} on hosts: {hostStr}" + (f"; skipping: {skipHostStr}" if skipHostStr else ''))
@@ -1532,7 +1616,7 @@ def main():
1532
1616
  global __mainReturnCode
1533
1617
  global __failedHosts
1534
1618
  global __ipmiiInterfaceIPPrefix
1535
- global _sshpassAvailable
1619
+ global _binPaths
1536
1620
  global _env_file
1537
1621
  _emo = False
1538
1622
  # We handle the signal
@@ -1548,6 +1632,7 @@ def main():
1548
1632
  parser.add_argument("-f","--file", action='append', help="The file to be copied to the hosts. Use -f multiple times to copy multiple files")
1549
1633
  parser.add_argument('--file_sync', action='store_true', help=f'Operate in file sync mode, sync path in <COMMANDS> from this machine to <HOSTS>. Treat --file <FILE> and <COMMANDS> both as source as source and destination will be the same in this mode. (default: {DEFAULT_FILE_SYNC})', default=DEFAULT_FILE_SYNC)
1550
1634
  parser.add_argument('--scp', action='store_true', help=f'Use scp for copying files instead of rsync. Need to use this on windows. (default: {DEFAULT_SCP})', default=DEFAULT_SCP)
1635
+ parser.add_argument('-gm','--gather_mode', action='store_true', help=f'Gather files from the hosts instead of sending files to the hosts. Will send remote files specified in <FILE> to local path specified in <COMMANDS> (default: False)', default=False)
1551
1636
  #parser.add_argument("-d",'-c',"--destination", type=str, help="The destination of the files. Same as specify with commands. Added for compatibility. Use #HOST# or #HOSTNAME# to replace the host name in the destination")
1552
1637
  parser.add_argument("-t","--timeout", type=int, help=f"Timeout for each command in seconds (default: {DEFAULT_CLI_TIMEOUT} (disabled))", default=DEFAULT_CLI_TIMEOUT)
1553
1638
  parser.add_argument("-r","--repeat", type=int, help=f"Repeat the command for a number of times (default: {DEFAULT_REPEAT})", default=DEFAULT_REPEAT)
@@ -1570,7 +1655,7 @@ def main():
1570
1655
  parser.add_argument("-su","--skip_unreachable", action='store_true', help=f"Skip unreachable hosts while using --repeat. Note: Timedout Hosts are considered unreachable. Note: multiple command sequence will still auto skip unreachable hosts. (default: {DEFAULT_SKIP_UNREACHABLE})", default=DEFAULT_SKIP_UNREACHABLE)
1571
1656
  parser.add_argument("-sh","--skip_hosts", type=str, help=f"Skip the hosts in the list. (default: {DEFAULT_SKIP_HOSTS if DEFAULT_SKIP_HOSTS else 'None'})", default=DEFAULT_SKIP_HOSTS)
1572
1657
  parser.add_argument('--generate_default_config_file', action='store_true', help=f'Generate / store the default config file from command line argument and current config at {CONFIG_FILE}')
1573
- parser.add_argument("-V","--version", action='version', version=f'%(prog)s {version} {("with sshpass " if _sshpassAvailable else "")}by {AUTHOR} ({AUTHOR_EMAIL})')
1658
+ parser.add_argument("-V","--version", action='version', version=f'%(prog)s {version} with [ {", ".join(_binPaths.keys())} ] by {AUTHOR} ({AUTHOR_EMAIL})')
1574
1659
 
1575
1660
  # parser.add_argument('-u', '--user', metavar='user', type=str, nargs=1,
1576
1661
  # help='the user to use to connect to the hosts')
@@ -1623,7 +1708,7 @@ def main():
1623
1708
  if not __global_suppress_printout:
1624
1709
  print('> ' + getStrCommand(args.hosts,args.commands,oneonone=args.oneonone,timeout=args.timeout,password=args.password,
1625
1710
  nowatch=args.nowatch,json=args.json,called=args.no_output,max_connections=args.max_connections,
1626
- files=args.file,file_sync=args.file_sync,ipmi=args.ipmi,interface_ip_prefix=args.interface_ip_prefix,scp=args.scp,username=args.username,
1711
+ files=args.file,file_sync=args.file_sync,ipmi=args.ipmi,interface_ip_prefix=args.interface_ip_prefix,scp=args.scp,gather_mode = args.gather_mode,username=args.username,
1627
1712
  extraargs=args.extraargs,skipUnreachable=args.skip_unreachable,no_env=args.no_env,greppable=args.greppable,skip_hosts = args.skip_hosts,
1628
1713
  curses_min_char_len = args.window_width, curses_min_line_len = args.window_height,single_window=args.single_window,error_only=args.error_only))
1629
1714
  if args.error_only:
@@ -1638,7 +1723,7 @@ def main():
1638
1723
  hosts = run_command_on_hosts(args.hosts,args.commands,
1639
1724
  oneonone=args.oneonone,timeout=args.timeout,password=args.password,
1640
1725
  nowatch=args.nowatch,json=args.json,called=args.no_output,max_connections=args.max_connections,
1641
- files=args.file,file_sync=args.file_sync,ipmi=args.ipmi,interface_ip_prefix=args.interface_ip_prefix,scp=args.scp,username=args.username,
1726
+ files=args.file,file_sync=args.file_sync,ipmi=args.ipmi,interface_ip_prefix=args.interface_ip_prefix,scp=args.scp,gather_mode = args.gather_mode,username=args.username,
1642
1727
  extraargs=args.extraargs,skipUnreachable=args.skip_unreachable,no_env=args.no_env,greppable=args.greppable,skip_hosts = args.skip_hosts,
1643
1728
  curses_min_char_len = args.window_width, curses_min_line_len = args.window_height,single_window=args.single_window,error_only=args.error_only)
1644
1729
  #print('*'*80)
@@ -2,7 +2,7 @@ from setuptools import setup
2
2
 
3
3
  setup(
4
4
  name='multiSSH3',
5
- version='4.81',
5
+ version='4.83',
6
6
  description='Run commands on multiple hosts via SSH',
7
7
  long_description=open('README.md').read(),
8
8
  long_description_content_type='text/markdown',
File without changes
File without changes