sshreader 6.2.0__py3-none-any.whl → 7.0.0__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.
sshreader/__init__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """A Python Package for parallelizing ssh connections via threading and multiprocessing.
2
2
  """
3
- # Copyright (C) 2015-2025 Jesse Almanrode
3
+ # Copyright (C) 2015-2026 Jesse Almanrode
4
4
  #
5
5
  # This program is free software: you can redistribute it and/or modify
6
6
  # it under the terms of the GNU Lesser General Public License as published by
sshreader/customtypes.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """ Typing module for sshreader
2
2
  """
3
- # Copyright (C) 2015-2025 Jesse Almanrode
3
+ # Copyright (C) 2015-2026 Jesse Almanrode
4
4
  #
5
5
  # This program is free software: you can redistribute it and/or modify
6
6
  # it under the terms of the GNU Lesser General Public License as published by
@@ -14,23 +14,23 @@
14
14
  #
15
15
  # You should have received a copy of the GNU Lesser General Public License
16
16
  # along with this program. If not, see <http://www.gnu.org/licenses/>.
17
- from typing import NamedTuple, Optional, Union
17
+ from typing import NamedTuple
18
18
 
19
19
 
20
20
  class Command(NamedTuple):
21
21
  cmd: str
22
- stdout: Optional[Union[str, bytes]]
23
- stderr: Optional[Union[str, bytes]]
22
+ stdout: str | bytes | None
23
+ stderr: str | bytes | None
24
24
  return_code: int
25
25
 
26
26
 
27
27
  class EnvVars(NamedTuple):
28
- username: Optional[str]
29
- agent_keys: Optional[list]
30
- dsa_key: Optional[str]
31
- ecdsa_key: Optional[str]
32
- rsa_key: Optional[str]
28
+ username: str | None
29
+ agent_keys: list[object] | None
30
+ dsa_key: str | None
31
+ ecdsa_key: str | None
32
+ rsa_key: str | None
33
33
 
34
34
 
35
- Timeout = Union[int, float]
35
+ Timeout = int | float
36
36
  TimeoutTuple = tuple[Timeout, Timeout]
@@ -1,6 +1,6 @@
1
1
  """ Scripts portion of sshreader package
2
2
  """
3
- # Copyright (C) 2015-2025 Jesse Almanrode
3
+ # Copyright (C) 2015-2026 Jesse Almanrode
4
4
  #
5
5
  # This program is free software: you can redistribute it and/or modify
6
6
  # it under the terms of the GNU Lesser General Public License as published by
@@ -1,6 +1,6 @@
1
1
  """ A Pythonic implementation of pdsh powered by sshreader
2
2
  """
3
- # Copyright (C) 2015-2025 Jesse Almanrode
3
+ # Copyright (C) 2015-2026 Jesse Almanrode
4
4
  #
5
5
  # This program is free software: you can redistribute it and/or modify
6
6
  # it under the terms of the GNU Lesser General Public License as published by
@@ -19,7 +19,8 @@ import logging
19
19
  import os
20
20
  import sys
21
21
  from collections import defaultdict
22
- from hashlib import md5
22
+ from pathlib import Path
23
+ from typing import Any, Protocol
23
24
 
24
25
  import click
25
26
  from hostlist import collect_hostlist, expand_hostlist
@@ -28,7 +29,7 @@ import sshreader
28
29
 
29
30
  # GLOBALS
30
31
  __author__ = 'Jesse Almanrode'
31
- __version__ = '3.2.0'
32
+ __version__ = '4.0.0'
32
33
  __examples__ = """\b
33
34
  Examples:
34
35
  pydsh -w host1,host2,host3 "uname -r"
@@ -45,7 +46,24 @@ log.setLevel(logging.WARNING)
45
46
  log.propagate = False # Keeps our messages out of the root logger.
46
47
 
47
48
 
48
- def copy_script(scriptfile, job):
49
+ def _job_output(job: sshreader.ServerJob) -> str:
50
+ result = job.results[0]
51
+ output = result.stderr if job.status == 255 else result.stdout
52
+ if output is None:
53
+ return ''
54
+ return output.decode() if isinstance(output, bytes) else output
55
+
56
+
57
+ class _SFTPUploader(Protocol):
58
+ def sftp_put(self, srcfile: str, dstfile: str) -> Any: ...
59
+
60
+
61
+ class _ScriptJob(Protocol):
62
+ _conn: _SFTPUploader
63
+ name: str
64
+
65
+
66
+ def copy_script(scriptfile: str, job: _ScriptJob) -> bool:
49
67
  """ Copy script to remote host using SFTP connection
50
68
 
51
69
  :param scriptfile: Path to script file
@@ -56,23 +74,25 @@ def copy_script(scriptfile, job):
56
74
  try:
57
75
  job._conn.sftp_put(scriptfile, '/tmp/' + script_name)
58
76
  except Exception as err:
59
- return err
77
+ log.exception('Failed to copy script %s to %s: %s', scriptfile, getattr(job, 'name', '<unknown>'), err)
78
+ return False
79
+ return True
60
80
 
61
81
 
62
- def output(thisjob):
82
+ def output(thisjob: sshreader.ServerJob) -> None:
63
83
  """ Print output from jobs as they complete in a format that could be piped to dshbak
64
84
 
65
85
  :param thisjob: <ServerJob> object
66
86
  :return: None
67
87
  """
68
- result = thisjob.results[0] if thisjob.status == 255 else thisjob.results[0].stdout
88
+ result = _job_output(thisjob)
69
89
  if len(result) != 0:
70
90
  for line in result.split('\n'):
71
91
  sshreader.echo(str(thisjob.name) + ': ' + str(line))
72
92
  return None
73
93
 
74
94
 
75
- def dshbak(jobresults):
95
+ def dshbak(jobresults: list[sshreader.ServerJob]) -> None:
76
96
  """ Output the results of the jobs grouped by hosts
77
97
 
78
98
  Similar to piping output to `dshbak`
@@ -81,17 +101,14 @@ def dshbak(jobresults):
81
101
  :return: None
82
102
  """
83
103
  for thisjob in jobresults:
84
- if thisjob.status == 255:
85
- result = thisjob.results[0]
86
- else:
87
- result = thisjob.results[0].stdout
104
+ result = _job_output(thisjob)
88
105
  if len(result) != 0:
89
106
  click.echo(str('-' * 16) + '\n' + str(thisjob.name) + '\n' + str('-' * 16))
90
107
  click.echo(result)
91
108
  return None
92
109
 
93
110
 
94
- def coalesce(jobresults):
111
+ def coalesce(jobresults: list[sshreader.ServerJob]) -> None:
95
112
  """ Output the results of jobs coalescing identical output from hosts
96
113
 
97
114
  Similar to piping output to `dshbak -c`
@@ -99,23 +116,150 @@ def coalesce(jobresults):
99
116
  :param jobresults: List of <ServerJob> objects
100
117
  :return: None
101
118
  """
102
- job_hashes = defaultdict(list)
103
- output_hashes = dict()
119
+ output_groups: dict[str, list[str]] = defaultdict(list)
104
120
  for job in jobresults:
105
- result = job.results[0] if job.status == 255 else job.results[0].stdout
106
- md5sum = md5(result.encode()).hexdigest()
107
- job_hashes[md5sum].append(job.name)
108
- if md5sum not in output_hashes:
109
- output_hashes[md5sum] = result
121
+ output_groups[_job_output(job)].append(job.name)
110
122
 
111
- for md5sum, stdout in output_hashes.items():
123
+ for stdout, hosts in output_groups.items():
112
124
  if len(stdout) != 0:
113
- click.echo(str('-' * 16) + '\n' + collect_hostlist(job_hashes[md5sum]) + '\n' + str('-' * 16))
125
+ click.echo(str('-' * 16) + '\n' + collect_hostlist(hosts) + '\n' + str('-' * 16))
114
126
  click.echo(stdout)
115
127
  return None
116
128
 
117
129
 
118
- def validate_hostlist(ctx, param, value):
130
+ def setup_script_file(cmd: str, file_flag: bool, confirm_flag: bool) -> tuple[str | list[str], sshreader.Hook | None]:
131
+ """ Prepare script file for remote execution
132
+
133
+ Validates script file, extracts shebang, and creates pre-hook for copying.
134
+ Prompts for confirmation unless --confirm flag is set.
135
+
136
+ :param cmd: Command string or script path
137
+ :param file_flag: Boolean indicating if cmd is a file path
138
+ :param confirm_flag: Boolean indicating if confirmation should be skipped
139
+ :return: Tuple of (cmd_list, prehook) or (cmd, None) if not a file
140
+ """
141
+ if not file_flag:
142
+ return cmd, None
143
+
144
+ script_path = Path(cmd)
145
+ if not script_path.exists() or not script_path.is_file():
146
+ raise click.BadParameter('Script file not found: ' + str(script_path))
147
+
148
+ script_name = script_path.name
149
+ log.info('Creating copy_script prehook for: ' + script_name)
150
+ prehook = sshreader.Hook(copy_script, args=[str(script_path)], ssh_established=True)
151
+
152
+ content = script_path.read_text(encoding='utf-8', errors='replace')
153
+ first_line = content.splitlines()[0] if content.splitlines() else ''
154
+ if not first_line.startswith('#!'):
155
+ raise click.UsageError('Script must start with #!')
156
+
157
+ # Request confirmation unless --confirm flag is set
158
+ if not confirm_flag:
159
+ click.echo(f'\nScript: {script_path.resolve()}')
160
+ click.echo(f'Interpreter: {first_line[2:].strip()}')
161
+ if not click.confirm('Execute this script on selected hosts?'):
162
+ raise click.Abort()
163
+ click.echo()
164
+
165
+ cmd_list = [first_line.split('#!').pop().strip() + ' /tmp/' + script_name, 'rm /tmp/' + script_name]
166
+ return cmd_list, prehook
167
+
168
+
169
+ def create_jobs(hostlist: list[str], cmd: str | list[str], username: str, password: str | None,
170
+ keyfile: str | None, keypass: str | None, port: int, timeout: int, sha2: bool,
171
+ prehook: sshreader.Hook | None, posthook: sshreader.Hook | None,
172
+ file_flag: bool) -> list[sshreader.ServerJob]:
173
+ """ Create ServerJob objects for each host
174
+
175
+ :param hostlist: List of hosts from expanded hostlist expression
176
+ :param cmd: Command(s) to run on each host
177
+ :param username: SSH username
178
+ :param password: SSH password
179
+ :param keyfile: Path to SSH private key
180
+ :param keypass: Passphrase for private key
181
+ :param port: Default SSH port
182
+ :param timeout: SSH timeout in seconds
183
+ :param sha2: Use SHA2 for RSA keys
184
+ :param prehook: Pre-execution hook (if any)
185
+ :param posthook: Post-execution hook (if any)
186
+ :param file_flag: Boolean indicating if cmd is from a script file
187
+ :return: List of ServerJob objects
188
+ """
189
+ jobs = []
190
+ for host in hostlist:
191
+ # Handle port specified in host string
192
+ if ':' in host:
193
+ log.info('SSH Port declared in host: ' + host)
194
+ host, port_str = host.split(':', 1)
195
+ try:
196
+ port = int(port_str)
197
+ except Exception:
198
+ raise click.BadParameter('Invalid port in host: ' + host) from Exception
199
+ log.debug((host, port))
200
+ else:
201
+ port = port # Use default port from args
202
+
203
+ # Create job with appropriate authentication
204
+ if keyfile:
205
+ job = sshreader.ServerJob(
206
+ host, cmd, username=username, keyfile=keyfile,
207
+ key_pass=keypass, combine_output=True, rsa_sha2=sha2,
208
+ timeout=(0.5, timeout)
209
+ )
210
+ else:
211
+ job = sshreader.ServerJob(
212
+ host, cmd, username=username, password=password,
213
+ combine_output=True, rsa_sha2=sha2,
214
+ timeout=(0.5, timeout)
215
+ )
216
+
217
+ job.ssh_port = port
218
+ if posthook:
219
+ log.info('Adding posthook to ServerJob for: ' + host)
220
+ job.post_hook = posthook
221
+ if file_flag and prehook:
222
+ log.info('Adding prehook to ServerJob for: ' + host)
223
+ job.pre_hook = prehook
224
+ jobs.append(job)
225
+
226
+ return jobs
227
+
228
+
229
+ def execute_jobs(jobs: list[sshreader.ServerJob], dshbak_flag: bool, coalesce_flag: bool,
230
+ redline_flag: bool) -> list[sshreader.ServerJob]:
231
+ """ Execute jobs and handle output formatting
232
+
233
+ :param jobs: List of ServerJob objects
234
+ :param dshbak_flag: Format output like dshbak
235
+ :param coalesce_flag: Coalesce similar output
236
+ :param redline_flag: Run without thread/process overhead
237
+ :return: List of completed jobs
238
+ """
239
+ log.info(f'Sending {len(jobs)} ServerJobs to sshreader module')
240
+
241
+ if dshbak_flag or coalesce_flag:
242
+ # With output aggregation, use progress bar
243
+ if redline_flag:
244
+ jobs_finished = sshreader.sshread(jobs, pcount=0, tcount=0, progress_bar=True)
245
+ else:
246
+ jobs_finished = sshreader.sshread(jobs, tcount=0, progress_bar=True)
247
+
248
+ if coalesce_flag:
249
+ coalesce(jobs_finished)
250
+ else:
251
+ dshbak(jobs_finished)
252
+ else:
253
+ # Without aggregation, output as jobs complete
254
+ if redline_flag:
255
+ sshreader.sshread(jobs, pcount=0, tcount=0, print_lock=True)
256
+ else:
257
+ sshreader.sshread(jobs, tcount=0, print_lock=True)
258
+
259
+ return jobs
260
+
261
+
262
+ def validate_hostlist(ctx: click.Context | None, param: click.Parameter | str, value: str) -> list[str]:
119
263
  """ Callback for click to expand hostlist expressions or error
120
264
 
121
265
  :param ctx: Click context
@@ -126,56 +270,20 @@ def validate_hostlist(ctx, param, value):
126
270
  try:
127
271
  return expand_hostlist(value)
128
272
  except Exception:
129
- raise click.BadOptionUsage(param, 'Invalid hostlist expression') from None
273
+ option_name = param if isinstance(param, str) else param.name
274
+ raise click.BadOptionUsage(option_name, 'Invalid hostlist expression') from None
130
275
 
131
276
 
132
- @click.command(epilog=__examples__)
133
- @click.version_option(version=__version__)
134
- @click.option('--hostlist', '-w', metavar='EXPR', required=True, callback=validate_hostlist,
135
- help='Hostlist expression')
136
- @click.option('--username', '-u', help='Override ssh username')
137
- @click.option('--keyfile', '-k', type=click.Path(exists=True, dir_okay=False), help='Private key file')
138
- @click.option('--keypass', '-K', is_flag=True, help='Prompt for private key password')
139
- @click.option('--prompt', '-p', is_flag=True, help='Prompt for ssh password')
140
- @click.option('--password', '-P', help='Supply ssh password')
141
- @click.option('--dshbak', '-D', is_flag=True, help='Group output by host')
142
- @click.option('--coalesce', '-C', is_flag=True, help='Coalesce similar output from hosts')
143
- @click.option('--file', '-F', is_flag=True, help='Treat CMD as a script file')
144
- @click.option('--debug', '-d', is_flag=True, help='Enable debug output')
145
- @click.option('--verbose', '-v', count=True, help='Increase debug verbosity')
146
- @click.option('--redline', is_flag=True, help='Run pydsh faster')
147
- @click.option('--port', default=22, help='SSH Port')
148
- @click.option('--sha2', is_flag=True, default=True, help='Use SHA2 Hash Algorithm for Keys')
149
- @click.argument('cmd', nargs=1)
150
- def cli(**kwargs):
151
- """ Run ssh commands in parallel across hosts
152
- """
153
- if kwargs['debug']:
154
- log.setLevel(logging.INFO)
155
- if kwargs['verbose']:
156
- logging.getLogger('sshreader').setLevel(logging.INFO)
157
- if kwargs['verbose'] > 1:
158
- log.setLevel(logging.DEBUG)
159
- if kwargs['verbose'] > 2:
160
- logging.getLogger('sshreader').setLevel(logging.DEBUG)
161
- log.debug(kwargs)
162
- if kwargs['file']:
163
- mkpath = click.Path(exists=True, dir_okay=False)
164
- script_path = mkpath(kwargs['cmd'])
165
- script_name = os.path.split(script_path)[1]
166
- log.info('Creating copy_script prehook for: ' + script_name)
167
- prehook = sshreader.Hook(copy_script, args=[script_path], ssh_established=True)
168
- with open(script_path) as s:
169
- script = s.readline()
170
- if script.startswith('#!') is False:
171
- raise click.UsageError('Script must start with #!')
172
- kwargs['cmd'] = [script.split('#!').pop().strip() + ' /tmp/' + script_name, 'rm /tmp/' + script_name]
173
- sshenv = sshreader.envvars()
174
- log.debug(sshenv)
277
+ def setup_authentication(kwargs: dict[str, Any], sshenv: sshreader.EnvVars) -> dict[str, Any]:
278
+ """ Configure SSH authentication credentials
175
279
 
176
- if kwargs['port'] <= 0 or not isinstance(kwargs['port'], int):
177
- raise click.BadOptionUsage('port', 'Please enter a positive integer')
280
+ Handles the logic for determining which authentication method to use
281
+ (keyfile, password, SSH agent, or prompt for password).
178
282
 
283
+ :param kwargs: Dictionary of command-line arguments
284
+ :param sshenv: sshreader.EnvVars object with environment SSH info
285
+ :return: Updated kwargs with authentication credentials configured
286
+ """
179
287
  if kwargs['username'] is None:
180
288
  if sshenv.username is None:
181
289
  raise click.ClickException('Unable to determine ssh username. Please provide one using --username')
@@ -196,7 +304,7 @@ def cli(**kwargs):
196
304
  kwargs['keyfile'] = sshenv.dsa_key
197
305
  log.info('Using DSA private key file')
198
306
  else:
199
- if len(sshenv.agent_keys) == 0:
307
+ if sshenv.agent_keys is None or len(sshenv.agent_keys) == 0:
200
308
  if not all((kwargs['username'], kwargs['password'])):
201
309
  raise click.ClickException('Unable to find ssh key to use and password not supplied.')
202
310
  else:
@@ -214,7 +322,7 @@ def cli(**kwargs):
214
322
  # If you specify a password or prompt for one it overrides the ssh key
215
323
  if not kwargs['password']:
216
324
  if not kwargs['prompt']:
217
- if not kwargs['keyfile'] and len(sshenv.agent_keys) == 0:
325
+ if not kwargs['keyfile'] and (sshenv.agent_keys is None or len(sshenv.agent_keys) == 0):
218
326
  raise click.ClickException('Unable to find ssh key to use and password not supplied or prompt enabled.')
219
327
  else:
220
328
  log.info('Prompting for password and disabling discovered SSH keyfiles')
@@ -226,46 +334,75 @@ def cli(**kwargs):
226
334
  # You provided a password, ignore the SSH key
227
335
  kwargs['keyfile'] = None
228
336
 
337
+ return kwargs
338
+
339
+
340
+ @click.command(epilog=__examples__)
341
+ @click.version_option(version=__version__)
342
+ @click.option('--hostlist', '-w', metavar='EXPR', required=True, callback=validate_hostlist,
343
+ help='Hostlist expression')
344
+ @click.option('--username', '-u', help='Override ssh username')
345
+ @click.option('--keyfile', '-k', type=click.Path(exists=True, dir_okay=False), help='Private key file')
346
+ @click.option('--keypass', '-K', is_flag=True, help='Prompt for private key password')
347
+ @click.option('--prompt', '-p', is_flag=True, help='Prompt for ssh password')
348
+ @click.option('--password', '-P', help='Supply ssh password')
349
+ @click.option('--dshbak', '-D', is_flag=True, help='Group output by host')
350
+ @click.option('--coalesce', '-C', is_flag=True, help='Coalesce similar output from hosts')
351
+ @click.option('--file', '-F', is_flag=True, help='Treat CMD as a script file')
352
+ @click.option('--confirm', is_flag=True, help='Skip confirmation prompt when executing scripts')
353
+ @click.option('--debug', '-d', is_flag=True, help='Enable debug output')
354
+ @click.option('--verbose', '-v', count=True, help='Increase debug verbosity')
355
+ @click.option('--redline', is_flag=True, help='Run pydsh faster')
356
+ @click.option('--port', default=22, type=click.IntRange(1, 65535), help='SSH Port')
357
+ @click.option('--timeout', default=30, type=click.IntRange(1, 3600), help='SSH connection timeout in seconds')
358
+ @click.option('--sha2', is_flag=True, default=True, help='Use SHA2 Hash Algorithm for Keys')
359
+ @click.argument('cmd', nargs=1)
360
+ def cli(**kwargs: Any) -> None:
361
+ """ Run ssh commands in parallel across hosts
362
+ """
363
+ # Setup logging
364
+ if kwargs['debug']:
365
+ log.setLevel(logging.INFO)
366
+ if kwargs['verbose']:
367
+ logging.getLogger('sshreader').setLevel(logging.INFO)
368
+ if kwargs['verbose'] > 1:
369
+ log.setLevel(logging.DEBUG)
370
+ if kwargs['verbose'] > 2:
371
+ logging.getLogger('sshreader').setLevel(logging.DEBUG)
229
372
  log.debug(kwargs)
230
- posthook = sshreader.Hook(target=output)
231
- jobs = list()
232
- for host in kwargs['hostlist']:
233
- if ':' in host:
234
- log.info('SSH Port declared in host: ' + host)
235
- host, port = host.split(':')
236
- log.debug((host, port))
237
- else:
238
- port = kwargs['port']
239
- if kwargs['keyfile']:
240
- job = sshreader.ServerJob(host, kwargs['cmd'], username=kwargs['username'], keyfile=kwargs['keyfile'],
241
- key_pass=kwargs['keypass'], combine_output=True, rsa_sha2=kwargs['sha2'])
242
- else:
243
- job = sshreader.ServerJob(host, kwargs['cmd'], username=kwargs['username'], password=kwargs['password'],
244
- combine_output=True, rsa_sha2=kwargs['sha2'])
245
- job.ssh_port = port
246
- if kwargs['dshbak'] is False and kwargs['coalesce'] is False:
247
- log.info('Adding posthook to ServerJob for: ' + host)
248
- job.post_hook = posthook
249
- if kwargs['file']:
250
- log.info('Adding prehook to ServerJob for: ' + host)
251
- job.pre_hook = prehook
252
- jobs.append(job)
253
373
 
254
- log.info(f'Sending {len(jobs)} ServerJobs to sshreader module')
374
+ # Setup script file if provided
375
+ cmd, prehook = setup_script_file(kwargs['cmd'], kwargs['file'], kwargs['confirm'])
376
+
377
+ # Configure authentication
378
+ sshenv = sshreader.envvars()
379
+ log.debug(sshenv)
380
+ kwargs = setup_authentication(kwargs, sshenv)
381
+ log.debug(kwargs)
382
+
383
+ # Setup output formatting
384
+ posthook = None
255
385
  if kwargs['dshbak'] is False and kwargs['coalesce'] is False:
256
- if kwargs['redline']:
257
- sshreader.sshread(jobs, pcount=0, tcount=0, print_lock=True)
258
- else:
259
- sshreader.sshread(jobs, tcount=0, print_lock=True)
260
- else:
261
- if kwargs['redline']:
262
- jobs_finished = sshreader.sshread(jobs, pcount=0, tcount=0, progress_bar=True)
263
- else:
264
- jobs_finished = sshreader.sshread(jobs, tcount=0, progress_bar=True)
265
- if kwargs['coalesce']:
266
- coalesce(jobs_finished)
267
- else:
268
- dshbak(jobs_finished)
386
+ posthook = sshreader.Hook(target=output)
387
+
388
+ # Create jobs for all hosts
389
+ jobs = create_jobs(
390
+ hostlist=kwargs['hostlist'],
391
+ cmd=cmd,
392
+ username=kwargs['username'],
393
+ password=kwargs['password'],
394
+ keyfile=kwargs['keyfile'],
395
+ keypass=kwargs['keypass'],
396
+ port=kwargs['port'],
397
+ timeout=kwargs['timeout'],
398
+ sha2=kwargs['sha2'],
399
+ prehook=prehook,
400
+ posthook=posthook,
401
+ file_flag=kwargs['file']
402
+ )
403
+
404
+ # Execute jobs
405
+ execute_jobs(jobs, kwargs['dshbak'], kwargs['coalesce'], kwargs['redline'])
269
406
  sys.exit(0)
270
407
 
271
408