Fortify the argument parsing and handling in the command-line interface
Marco Ricci

Marco Ricci commited on 2024-06-22 21:19:30
Zeige 1 geänderte Dateien mit 286 Einfügungen und 92 Löschungen.


Currently, we use a one-to-one correspondence between command-line
argument name and function argument, via catch-all keyword arguments.
This is very unwieldy to use with type checking, it exposes us to name
clashes with Python keywords, and it doesn't even save that much typing
effort because the arguments still ought to be documented in the
documentation anyway. Therefore, introduce an explicit target parameter
name for the `click` interface and corresponding explicit function
arguments. (The implicit list of (function) arguments that `click`
passes to the function is already readily available in the context
object.)

As a related problem, the code for checking whether incompatible options
were specified also relies (excessively cleverly) on command-line
argument names and function argument names being equal, and sometimes
conflates short and long option names, leading to it missing some
examples of incompatible options. Therefore, instead, explicitly build
a map of (short and long) option names to option objects and test
whether incompatible option objects were used.
... ...
@@ -25,21 +25,136 @@ __all__ = ('derivepassphrase',)
25 25
 prog_name = 'derivepassphrase'
26 26
 
27 27
 
28
-# Implement help text groups. Inspired by
29
-# https://github.com/pallets/click/issues/373#issuecomment-515293746 and
30
-# modified to support group epilogs as well.
28
+def _config_filename() -> str | bytes | pathlib.Path:
29
+    """Return the filename of the configuration file.
30
+
31
+    The file is currently named `settings.json`, located within the
32
+    configuration directory as determined by the `DERIVEPASSPHRASE_PATH`
33
+    environment variable, or by [`click.get_app_dir`][] in POSIX
34
+    mode.
35
+
36
+    """
37
+    path: str | bytes | pathlib.Path
38
+    path = (os.getenv(prog_name.upper() + '_PATH')
39
+            or click.get_app_dir(prog_name, force_posix=True))
40
+    return os.path.join(path, 'settings.json')
41
+
42
+
43
+def _load_config() -> dpp_types.VaultConfig:
44
+    """Load a vault(1)-compatible config from the application directory.
45
+
46
+    The filename is obtained via
47
+    [`derivepassphrase.cli._config_filename`][].  This must be an
48
+    unencrypted JSON file.
49
+
50
+    Returns:
51
+        The vault settings.  See
52
+        [`derivepassphrase.types.VaultConfig`][] for details.
53
+
54
+    Raises:
55
+        OSError:
56
+            There was an OS error accessing the file.
57
+        ValueError:
58
+            The data loaded from the file is not a vault(1)-compatible
59
+            config.
60
+
61
+    """
62
+    filename = _config_filename()
63
+    with open(filename, 'rb') as fileobj:
64
+        data = json.load(fileobj)
65
+    if not dpp_types.is_vault_config(data):
66
+        raise ValueError('Invalid vault config')
67
+    return data
68
+
69
+
70
+def _save_config(config: dpp_types.VaultConfig, /) -> None:
71
+    """Save a vault(1)-compatbile config to the application directory.
72
+
73
+    The filename is obtained via
74
+    [`derivepassphrase.cli._config_filename`][].  The config will be
75
+    stored as an unencrypted JSON file.
76
+
77
+    Args:
78
+        config:
79
+            vault configuration to save.
80
+
81
+    Raises:
82
+        OSError:
83
+            There was an OS error accessing or writing the file.
84
+        ValueError:
85
+            The data cannot be stored as a vault(1)-compatible config.
86
+
87
+    """
88
+    if not dpp_types.is_vault_config(config):
89
+        raise ValueError('Invalid vault config')
90
+    filename = _config_filename()
91
+    with open(filename, 'wt', encoding='UTF-8') as fileobj:
92
+        json.dump(config, fileobj)
93
+
94
+
31 95
 class OptionGroupOption(click.Option):
32
-    option_group_name = ''
33
-    epilog = ''
96
+    """A [`click.Option`][] with an associated group name and group epilog.
97
+
98
+    Used by [`derivepassphrase.cli.CommandWithHelpGroups`][] to print
99
+    help sections.  Each subclass contains its own group name and
100
+    epilog.
101
+
102
+    Attributes:
103
+        option_group_name:
104
+            The name of the option group.  Used as a heading on the help
105
+            text for options in this section.
106
+        epilog:
107
+            An epilog to print after listing the options in this
108
+            section.
109
+
110
+    """
111
+    option_group_name: str = ''
112
+    epilog: str = ''
113
+
114
+    def __init__(self, *args, **kwargs):  # type: ignore
115
+        if self.__class__ == __class__:
116
+            raise NotImplementedError()
117
+        return super().__init__(*args, **kwargs)
118
+
34 119
 
35 120
 class CommandWithHelpGroups(click.Command):
121
+    """A [`click.Command`][] with support for help/option groups.
122
+
123
+    Inspired by [a comment on `pallets/click#373`][CLICK_ISSUE], and
124
+    further modified to support group epilogs.
125
+
126
+    [CLICK_ISSUE]: https://github.com/pallets/click/issues/373#issuecomment-515293746
127
+
128
+    """
129
+
36 130
     def format_options(
37 131
         self, ctx: click.Context, formatter: click.HelpFormatter,
38 132
     ) -> None:
133
+        r"""Format options on the help listing, grouped into sections.
134
+
135
+        As part of the `--help` listing, list all options, but grouped
136
+        into sections according to the concrete [`click.Option`][]
137
+        subclass being used.  If the option is an instance of some
138
+        subclass `X` of [`derivepassphrase.cli.OptionGroupOption`][],
139
+        then the section heading and the epilog is taken from
140
+        `X.option_group_name` and `X.epilog`; otherwise, the section
141
+        heading is "Options" (or "Other options" if there are other
142
+        option groups) and the epilog is empty.
143
+
144
+        Args:
145
+            ctx:
146
+                The click context.
147
+            formatter:
148
+                The formatter for the `--help` listing.
149
+
150
+        """
39 151
         help_records: dict[str, list[tuple[str, str]]] = {}
40 152
         epilogs: dict[str, str] = {}
41 153
         params = self.params[:]
42
-        if (help_opt := self.get_help_option(ctx)) and help_opt not in params:
154
+        if (  # pragma: no branch
155
+            (help_opt := self.get_help_option(ctx)) is not None
156
+            and help_opt not in params
157
+        ):
43 158
             params.append(help_opt)
44 159
         for param in params:
45 160
             rec = param.get_help_record(ctx)
... ...
@@ -63,22 +178,37 @@ class CommandWithHelpGroups(click.Command):
63 178
                 with formatter.indentation():
64 179
                     formatter.write_text(epilog)
65 180
 
181
+
66 182
 # Concrete option groups used by this command-line interface.
67 183
 class PasswordGenerationOption(OptionGroupOption):
184
+    """Password generation options for the CLI."""
68 185
     option_group_name = 'Password generation'
69
-    epilog = 'Use NUMBER=0, e.g. "--symbol 0", to exclude a character type from the output'
186
+    epilog = '''
187
+        Use NUMBER=0, e.g. "--symbol 0", to exclude a character type
188
+        from the output.
189
+    '''
190
+
70 191
 
71 192
 class ConfigurationOption(OptionGroupOption):
193
+    """Configuration options for the CLI."""
72 194
     option_group_name = 'Configuration'
73
-    epilog = 'Use $VISUAL or $EDITOR to configure the spawned editor.'
195
+    epilog = '''
196
+        Use $VISUAL or $EDITOR to configure the spawned editor.
197
+    '''
198
+
74 199
 
75 200
 class StorageManagementOption(OptionGroupOption):
201
+    """Storage management options for the CLI."""
76 202
     option_group_name = 'Storage management'
77
-    epilog = 'Using "-" as PATH for standard input/standard output is supported.'
203
+    epilog = '''
204
+        Using "-" as PATH for standard input/standard output is
205
+        supported.
206
+    '''
78 207
 
79
-def validate_occurrence_constraint(
208
+def _validate_occurrence_constraint(
80 209
     ctx: click.Context, param: click.Parameter, value: Any,
81 210
 ) -> int | None:
211
+    """Check that the occurrence constraint is valid (int, 0 or larger)."""
82 212
     if value is None:
83 213
         return value
84 214
     if isinstance(value, int):
... ...
@@ -92,9 +222,11 @@ def validate_occurrence_constraint(
92 222
         raise click.BadParameter('not a non-negative integer')
93 223
     return int_value
94 224
 
95
-def validate_length(
225
+
226
+def _validate_length(
96 227
     ctx: click.Context, param: click.Parameter, value: Any,
97 228
 ) -> int | None:
229
+    """Check that the length is valid (int, 1 or larger)."""
98 230
     if value is None:
99 231
         return value
100 232
     if isinstance(value, int):
... ...
@@ -119,69 +251,90 @@ def validate_length(
119 251
         backups of the settings and the SSH key, if any.
120 252
     ''',
121 253
 )
122
-@click.option('-p', '--phrase', is_flag=True,
254
+@click.option('-p', '--phrase', 'use_phrase', is_flag=True,
123 255
               help='prompts you for your passphrase',
124 256
               cls=PasswordGenerationOption)
125
-@click.option('-k', '--key', is_flag=True,
257
+@click.option('-k', '--key', 'use_key', is_flag=True,
126 258
               help='uses your SSH private key to generate passwords',
127 259
               cls=PasswordGenerationOption)
128
-@click.option('-l', '--length', metavar='NUMBER', callback=validate_length,
260
+@click.option('-l', '--length', metavar='NUMBER',
261
+              callback=_validate_length,
129 262
               help='emits password of length NUMBER',
130 263
               cls=PasswordGenerationOption)
131 264
 @click.option('-r', '--repeat', metavar='NUMBER',
132
-              callback=validate_occurrence_constraint,
265
+              callback=_validate_occurrence_constraint,
133 266
               help='allows maximum of NUMBER repeated adjacent chars',
134 267
               cls=PasswordGenerationOption)
135 268
 @click.option('--lower', metavar='NUMBER',
136
-              callback=validate_occurrence_constraint,
269
+              callback=_validate_occurrence_constraint,
137 270
               help='includes at least NUMBER lowercase letters',
138 271
               cls=PasswordGenerationOption)
139 272
 @click.option('--upper', metavar='NUMBER',
140
-              callback=validate_occurrence_constraint,
273
+              callback=_validate_occurrence_constraint,
141 274
               help='includes at least NUMBER uppercase letters',
142 275
               cls=PasswordGenerationOption)
143 276
 @click.option('--number', metavar='NUMBER',
144
-              callback=validate_occurrence_constraint,
277
+              callback=_validate_occurrence_constraint,
145 278
               help='includes at least NUMBER digits',
146 279
               cls=PasswordGenerationOption)
147 280
 @click.option('--space', metavar='NUMBER',
148
-              callback=validate_occurrence_constraint,
281
+              callback=_validate_occurrence_constraint,
149 282
               help='includes at least NUMBER spaces',
150 283
               cls=PasswordGenerationOption)
151 284
 @click.option('--dash', metavar='NUMBER',
152
-              callback=validate_occurrence_constraint,
285
+              callback=_validate_occurrence_constraint,
153 286
               help='includes at least NUMBER "-" or "_"',
154 287
               cls=PasswordGenerationOption)
155 288
 @click.option('--symbol', metavar='NUMBER',
156
-              callback=validate_occurrence_constraint,
289
+              callback=_validate_occurrence_constraint,
157 290
               help='includes at least NUMBER symbol chars',
158 291
               cls=PasswordGenerationOption)
159
-@click.option('-n', '--notes', is_flag=True,
292
+@click.option('-n', '--notes', 'edit_notes', is_flag=True,
160 293
               help='spawn an editor to edit notes for SERVICE',
161 294
               cls=ConfigurationOption)
162
-@click.option('-c', '--config', is_flag=True,
295
+@click.option('-c', '--config', 'store_config_only', is_flag=True,
163 296
               help='saves the given settings for SERVICE or global',
164 297
               cls=ConfigurationOption)
165
-@click.option('-x', '--delete', is_flag=True,
298
+@click.option('-x', '--delete', 'delete_service_settings', is_flag=True,
166 299
               help='deletes settings for SERVICE',
167 300
               cls=ConfigurationOption)
168 301
 @click.option('--delete-globals', is_flag=True,
169 302
               help='deletes the global shared settings',
170 303
               cls=ConfigurationOption)
171
-@click.option('-X', '--clear', is_flag=True,
304
+@click.option('-X', '--clear', 'clear_all_settings', is_flag=True,
172 305
               help='deletes all settings',
173 306
               cls=ConfigurationOption)
174
-@click.option('-e', '--export', metavar='PATH', type=click.File('wt'),
307
+@click.option('-e', '--export', 'export_settings', metavar='PATH',
308
+              type=click.Path(file_okay=True, allow_dash=True, exists=False),
175 309
               help='export all saved settings into file PATH',
176 310
               cls=StorageManagementOption)
177
-@click.option('-i', '--import', metavar='PATH', type=click.File('rt'),
311
+@click.option('-i', '--import', 'import_settings', metavar='PATH',
312
+              type=click.Path(file_okay=True, allow_dash=True, exists=False),
178 313
               help='import saved settings from file PATH',
179 314
               cls=StorageManagementOption)
180 315
 @click.version_option(version=dpp.__version__, prog_name=prog_name)
181 316
 @click.argument('service', required=False)
182 317
 @click.pass_context
183 318
 def derivepassphrase(
184
-    ctx: click.Context, service: str | None = None, **kwargs: Any,
319
+    ctx: click.Context, /, *,
320
+    service: str | None = None,
321
+    use_phrase: bool = False,
322
+    use_key: bool = False,
323
+    length: int | None = None,
324
+    repeat: int | None = None,
325
+    lower: int | None = None,
326
+    upper: int | None = None,
327
+    number: int | None = None,
328
+    space: int | None = None,
329
+    dash: int | None = None,
330
+    symbol: int | None = None,
331
+    edit_notes: bool = False,
332
+    store_config_only: bool = False,
333
+    delete_service_settings: bool = False,
334
+    delete_globals: bool = False,
335
+    clear_all_settings: bool = False,
336
+    export_settings: TextIO | pathlib.Path | os.PathLike[str] | None = None,
337
+    import_settings: TextIO | pathlib.Path | os.PathLike[str] | None = None,
185 338
 ) -> None:
186 339
     """Derive a strong passphrase, deterministically, from a master secret.
187 340
 
... ...
@@ -196,7 +349,9 @@ def derivepassphrase(
196 349
 
197 350
     This is a [`click`][CLICK]-powered command-line interface function,
198 351
     and not intended for programmatic use.  Call with arguments
199
-    `['--help']` to see full documentation of the interface.
352
+    `['--help']` to see full documentation of the interface.  (See also
353
+    [`click.testing.CliRunner`][] for controlled, programmatic
354
+    invocation.)
200 355
 
201 356
     [CLICK]: https://click.palletsprojects.com/
202 357
 
... ...
@@ -205,125 +360,164 @@ def derivepassphrase(
205 360
             The `click` context.
206 361
 
207 362
     Other Parameters:
208
-        service (str | None):
363
+        service:
209 364
             A service name.  Required, unless operating on global
210 365
             settings or importing/exporting settings.
211
-        phrase (bool):
366
+        use_phrase:
212 367
             Command-line argument `-p`/`--phrase`.  If given, query the
213 368
             user for a passphrase instead of an SSH key.
214
-        key (bool):
369
+        use_key:
215 370
             Command-line argument `-k`/`--key`.  If given, query the
216 371
             user for an SSH key instead of a passphrase.
217
-        length (int | None):
372
+        length:
218 373
             Command-line argument `-l`/`--length`.  Override the default
219 374
             length of the generated passphrase.
220
-        repeat (int | None):
375
+        repeat:
221 376
             Command-line argument `-r`/`--repeat`.  Override the default
222 377
             repetition limit if positive, or disable the repetition
223 378
             limit if 0.
224
-        lower (int | None):
379
+        lower:
225 380
             Command-line argument `--lower`.  Require a given amount of
226 381
             ASCII lowercase characters if positive, else forbid ASCII
227 382
             lowercase characters if 0.
228
-        upper (int | None):
383
+        upper:
229 384
             Command-line argument `--upper`.  Same as `lower`, but for
230 385
             ASCII uppercase characters.
231
-        number (int | None):
386
+        number:
232 387
             Command-line argument `--number`.  Same as `lower`, but for
233 388
             ASCII digits.
234
-        space (int | None):
389
+        space:
235 390
             Command-line argument `--number`.  Same as `lower`, but for
236 391
             the space character.
237
-        dash (int | None):
392
+        dash:
238 393
             Command-line argument `--number`.  Same as `lower`, but for
239 394
             the hyphen-minus and underscore characters.
240
-        symbol (int | None):
395
+        symbol:
241 396
             Command-line argument `--number`.  Same as `lower`, but for
242 397
             all other ASCII printable characters (except backquote).
243
-        notes (bool):
398
+        edit_notes:
244 399
             Command-line argument `-n`/`--notes`.  If given, spawn an
245 400
             editor to edit notes for `service`.
246
-        config (bool):
401
+        store_config_only:
247 402
             Command-line argument `-c`/`--config`.  If given, saves the
248 403
             other given settings (`--key`, ..., `--symbol`) to the
249 404
             configuration file, either specifically for `service` or as
250 405
             global settings.
251
-        delete (bool):
406
+        delete_service_settings:
252 407
             Command-line argument `-x`/`--delete`.  If given, removes
253 408
             the settings for `service` from the configuration file.
254
-        delete_globals (bool):
409
+        delete_globals:
255 410
             Command-line argument `--delete-globals`.  If given, removes
256 411
             the global settings from the configuration file.
257
-        clear (bool):
412
+        clear_all_settings:
258 413
             Command-line argument `-X`/`--clear`.  If given, removes all
259 414
             settings from the configuration file.
260
-        export (TextIO | click.utils.LazyFile | None):
261
-            Command-line argument `-e`/`--export`.  If given, exports
262
-            the settings to the given file object (or
263
-            a `click.utils.LazyFile` instance that eventually supplies
264
-            such a file object), which must be open for writing and
265
-            accept `str` inputs.
266
-        import (TextIO | click.utils.LazyFile | None):
267
-            Command-line argument `-i`/`--import`.  If given, imports
268
-            the settings from the given file object (or
269
-            a `click.utils.LazyFile` instance that eventually supplies
270
-            such a file object), which must be open for reading and
271
-            yield `str` values.
415
+        export_settings:
416
+            Command-line argument `-e`/`--export`.  If a file object,
417
+            then it must be open for writing and accept `str` inputs.
418
+            Otherwise, a filename to open for writing.  Using `-` for
419
+            standard output is supported.
420
+        import_settings:
421
+            Command-line argument `-i`/`--import`.  If a file object, it
422
+            must be open for reading and yield `str` values.  Otherwise,
423
+            a filename to open for reading.  Using `-` for standard
424
+            input is supported.
272 425
 
273 426
     """
274
-    options: dict[type[click.Option], list[str]] = {}
427
+
428
+    options_in_group: dict[type[click.Option], list[click.Option]] = {}
429
+    params_by_str: dict[str, click.Parameter] = {}
275 430
     for param in ctx.command.params:
276 431
         if isinstance(param, click.Option):
277
-            param_name = param.human_readable_name
278 432
             group: type[click.Option]
279
-            if isinstance(param, PasswordGenerationOption):
433
+            match param:
434
+                case PasswordGenerationOption():
280 435
                     group = PasswordGenerationOption
281
-            elif isinstance(param, ConfigurationOption):
436
+                case ConfigurationOption():
282 437
                     group = ConfigurationOption
283
-            elif isinstance(param, StorageManagementOption):
438
+                case StorageManagementOption():
284 439
                     group = StorageManagementOption
285
-            elif isinstance(param, OptionGroupOption):
286
-                raise AssertionError(f'Unknown option group for {param!r}')
287
-            else:
440
+                case OptionGroupOption():
441
+                    raise AssertionError(
442
+                        f'Unknown option group for {param!r}')
443
+                case _:
288 444
                     group = click.Option
289
-            options.setdefault(group, []).append(param_name)
445
+            options_in_group.setdefault(group, []).append(param)
446
+        params_by_str[param.human_readable_name] = param
447
+        for name in param.opts + param.secondary_opts:
448
+            params_by_str[name] = param
449
+
450
+    def is_param_set(param: click.Parameter):
451
+        return bool(ctx.params.get(param.human_readable_name))
452
+
290 453
     def check_incompatible_options(
291
-        param_name: str, *incompatible: str
454
+        param: click.Parameter | str, *incompatible: click.Parameter | str,
292 455
     ) -> None:
293
-        parsed_params = ctx.params
294
-        if parsed_params.get(param_name) is None:
456
+        if isinstance(param, str):
457
+            param = params_by_str[param]
458
+        assert isinstance(param, click.Parameter)
459
+        if not is_param_set(param):
295 460
             return
296 461
         for other in incompatible:
297
-            if other != param_name and parsed_params.get(other) is not None:
298
-                param_name = param_name.replace('_', '-')
299
-                other = other.replace('_', '-')
300
-                raise click.UsageError(
301
-                     f'--{param_name} and --{other} are mutually exclusive')
302
-    check_incompatible_options('phrase', 'key')
462
+            if isinstance(other, str):
463
+                other = params_by_str[other]
464
+            assert isinstance(other, click.Parameter)
465
+            if other != param and is_param_set(other):
466
+                opt_str = param.opts[0]
467
+                other_str = other.opts[0]
468
+                raise click.BadOptionUsage(
469
+                    opt_str, f'mutually exclusive with {other_str}', ctx=ctx)
470
+
471
+    def get_config() -> dpp_types.VaultConfig:
472
+        try:
473
+            return _load_config()
474
+        except FileNotFoundError:
475
+            return {'services': {}}
476
+        except Exception as e:
477
+            ctx.fail(f'cannot load config: {e}')
478
+
479
+    configuration: dpp_types.VaultConfig
480
+
481
+    check_incompatible_options('--phrase', '--key')
303 482
     for group in (ConfigurationOption, StorageManagementOption):
304
-        for opt in options[group]:
305
-            if opt != 'config':
306
-                check_incompatible_options(opt,
307
-                                           *options[PasswordGenerationOption])
483
+        for opt in options_in_group[group]:
484
+            if opt != params_by_str['--config']:
485
+                check_incompatible_options(
486
+                    opt, *options_in_group[PasswordGenerationOption])
487
+
308 488
     for group in (ConfigurationOption, StorageManagementOption):
309
-        for opt in options[group]:
310
-            check_incompatible_options(opt,
311
-                                       *options[ConfigurationOption],
312
-                                       *options[StorageManagementOption])
313
-    for opt in ['notes', 'delete']:
314
-        if kwargs.get(opt) is not None and not service:
315
-            opt = opt.replace('_', '-')
316
-            raise click.UsageError(f'--{opt} requires a SERVICE')
317
-    for opt in ['delete_globals', 'clear'] + options[StorageManagementOption]:
318
-        if kwargs.get(opt) is not None and service:
319
-            opt = opt.replace('_', '-')
489
+        for opt in options_in_group[group]:
490
+            check_incompatible_options(
491
+                opt, *options_in_group[ConfigurationOption],
492
+                *options_in_group[StorageManagementOption])
493
+    sv_options = (options_in_group[PasswordGenerationOption] +
494
+                  [params_by_str['--notes'], params_by_str['--delete']])
495
+    sv_options.remove(params_by_str['--key'])
496
+    sv_options.remove(params_by_str['--phrase'])
497
+    for param in sv_options:
498
+        if is_param_set(param) and not service:
499
+            opt_str = param.opts[0]
500
+            raise click.UsageError(f'{opt_str} requires a SERVICE')
501
+    for param in [params_by_str['--key'], params_by_str['--phrase']]:
502
+        if (
503
+            is_param_set(param)
504
+            and not (service or is_param_set(params_by_str['--config']))
505
+        ):
506
+            opt_str = param.opts[0]
507
+            raise click.UsageError(f'{opt_str} requires a SERVICE or --config')
508
+    no_sv_options = [params_by_str['--delete-globals'],
509
+                     params_by_str['--clear'],
510
+                     *options_in_group[StorageManagementOption]]
511
+    for param in no_sv_options:
512
+        if is_param_set(param) and service:
513
+            opt_str = param.opts[0]
320 514
             raise click.UsageError(
321
-                f'--{opt} does not take a SERVICE argument')
515
+                f'{opt_str} does not take a SERVICE argument')
322 516
     #if kwargs['length'] is None:
323 517
     #    kwargs['length'] = dpp.Vault.__init__.__kwdefaults__['length']
324 518
     #if kwargs['repeat'] is None:
325 519
     #    kwargs['repeat'] = dpp.Vault.__init__.__kwdefaults__['repeat']
326
-    click.echo(repr({'service': service, **kwargs}))
520
+    click.echo(repr(ctx.params))
327 521
 
328 522
 
329 523
 if __name__ == '__main__':
330 524