X-Git-Url: https://wannabe.guru.org/gitweb/?a=blobdiff_plain;f=src%2Fpyutils%2Fargparse_utils.py;h=fec7d36ecc1a6178735d0559dd7308ac44b76874;hb=HEAD;hp=f142d7e4a4adeb42c7e6f2d82031fc99ed732232;hpb=682fab38c65f3df70597b044c9b76c5dd81bf9d4;p=pyutils.git diff --git a/src/pyutils/argparse_utils.py b/src/pyutils/argparse_utils.py index f142d7e..fec7d36 100644 --- a/src/pyutils/argparse_utils.py +++ b/src/pyutils/argparse_utils.py @@ -1,19 +1,24 @@ #!/usr/bin/python3 -# © Copyright 2021-2022, Scott Gasch +# © Copyright 2021-2023, Scott Gasch """These are helpers for commandline argument parsing meant to work -with Python's :mod:`argparse` module from the standard library. It -contains validators for new argument types (such as free-form dates, -durations, IP addresses, etc...) and an action that creates a pair of -flags: one to disable a feature and another to enable it. +with Python's :mod:`argparse` module from the standard library (See: +https://docs.python.org/3/library/argparse.html). It contains +validators for new argument types (such as free-form dates, durations, +IP addresses, etc...) and an action that creates a pair of flags: one +to disable a feature and another to enable it. + +See also :py:class:`pyutils.config.OptionalRawFormatter` which is +automatically enabled if you use :py:mod:`config` module. + """ import argparse import datetime import logging import os -from typing import Any +from typing import Any, Optional from overrides import overrides @@ -40,25 +45,36 @@ class ActionNoYes(argparse.Action): These arguments can be used to indicate the inclusion or exclusion of binary exclusive behaviors. + + Raises: + ValueError: illegal argument value or combination + """ - def __init__(self, option_strings, dest, default=None, required=False, help=None): + def __init__( + self, + option_strings: str, + dest: str, + default: Optional[str] = None, + required: bool = False, + help: Optional[str] = None, + ): if default is None: - msg = 'You must provide a default with Yes/No action' + msg = "You must provide a default with Yes/No action" logger.critical(msg) raise ValueError(msg) if len(option_strings) != 1: - msg = 'Only single argument is allowed with NoYes action' + msg = "Only single argument is allowed with NoYes action" logger.critical(msg) raise ValueError(msg) opt = option_strings[0] - if not opt.startswith('--'): - msg = 'Yes/No arguments must be prefixed with --' + if not opt.startswith("--"): + msg = "Yes/No arguments must be prefixed with --" logger.critical(msg) raise ValueError(msg) opt = opt[2:] - opts = ['--' + opt, '--no_' + opt] + opts = ["--" + opt, "--no_" + opt] super().__init__( opts, dest, @@ -70,11 +86,12 @@ class ActionNoYes(argparse.Action): ) @overrides - def __call__(self, parser, namespace, values, option_strings=None): - if option_strings.startswith('--no-') or option_strings.startswith('--no_'): - setattr(namespace, self.dest, False) - else: - setattr(namespace, self.dest, True) + def __call__(self, parser, namespace, values, option_strings: Optional[str] = None): + if option_strings is not None: + if option_strings.startswith("--no-") or option_strings.startswith("--no_"): + setattr(namespace, self.dest, False) + else: + setattr(namespace, self.dest, True) def valid_bool(v: Any) -> bool: @@ -85,7 +102,10 @@ def valid_bool(v: Any) -> bool: v: data passed to an argument expecting a bool on the cmdline. Returns: - The boolean value of v or raises an ArgumentTypeError on error. + The boolean value of v + + Raises: + ArgumentTypeError: parse error (e.g. not a valid bool string) Sample usage:: @@ -140,7 +160,10 @@ def valid_ip(ip: str) -> str: ip: data passed to a commandline arg expecting an IP(v4) address. Returns: - The IP address, if valid. Raises ArgumentTypeError otherwise. + The IP address, if valid. + + Raises: + ArgumentTypeError: parse error (e.g. not a valid IP address string) Sample usage:: @@ -180,7 +203,10 @@ def valid_mac(mac: str) -> str: mac: a value passed to a commandline flag expecting a MAC address. Returns: - The MAC address passed or raises ArgumentTypeError on error. + The MAC address passed + + Raises: + ArgumentTypeError: parse error (e.g. not a valid MAC address) Sample usage:: @@ -226,6 +252,9 @@ def valid_percentage(num: str) -> float: Returns: The number if valid, otherwise raises ArgumentTypeError. + Raises: + ArgumentTypeError: parse error (e.g. not a valid percentage) + Sample usage:: args.add_argument( @@ -247,7 +276,7 @@ def valid_percentage(num: str) -> float: argparse.ArgumentTypeError: 115 is an invalid percentage; expected 0 <= n <= 100.0 """ - num = num.strip('%') + num = num.strip("%") n = float(num) if 0.0 <= n <= 100.0: return n @@ -272,6 +301,9 @@ def valid_filename(filename: str) -> str: Returns: The filename if valid, otherwise raises ArgumentTypeError. + Raises: + ArgumentTypeError: parse error (e.g. file doesn't exist) + Sample usage:: args.add_argument( @@ -307,7 +339,10 @@ def valid_date(txt: str) -> datetime.date: txt: data passed to a commandline flag expecting a date. Returns: - the datetime.date described by txt or raises ArgumentTypeError on error. + the datetime.date described by txt + + Raises: + ArgumentTypeError: parse error (e.g. date not valid) Sample usage:: @@ -325,7 +360,8 @@ def valid_date(txt: str) -> datetime.date: .. note:: dates like 'next wednesday' work fine, they are just hard to doctest for without knowing when the testcase will be - executed... + executed... See :py:mod:`pyutils.datetimes.dateparse_utils` + for other examples of usable expressions. >>> valid_date('next wednesday') # doctest: +ELLIPSIS -ANYTHING- @@ -335,7 +371,7 @@ def valid_date(txt: str) -> datetime.date: date = to_date(txt) if date is not None: return date - msg = f'Cannot parse argument as a date: {txt}' + msg = f"Cannot parse argument as a date: {txt}" logger.error(msg) raise argparse.ArgumentTypeError(msg) @@ -348,7 +384,10 @@ def valid_datetime(txt: str) -> datetime.datetime: txt: data passed to a commandline flag expecting a valid datetime.datetime. Returns: - The datetime.datetime described by txt or raises ArgumentTypeError on error. + The datetime.datetime described by txt + + Raises: + ArgumentTypeError: parse error (e.g. invalid datetime string) Sample usage:: @@ -364,10 +403,13 @@ def valid_datetime(txt: str) -> datetime.datetime: >>> valid_datetime('6/5/2021 3:01:02') datetime.datetime(2021, 6, 5, 3, 1, 2) + >>> valid_datetime('Sun Dec 11 11:50:00 PST 2022') + datetime.datetime(2022, 12, 11, 11, 50) + .. note:: Because this code uses an English date-expression parsing grammar internally, much more complex datetimes can be expressed in free form. - See: `tests/datetimez/dateparse_utils_test.py` for examples. These + See :mod:`pyutils.datetimes.dateparse_utils` for details. These are not included in here because they are hard to write valid doctests for! @@ -379,7 +421,14 @@ def valid_datetime(txt: str) -> datetime.datetime: dt = to_datetime(txt) if dt is not None: return dt - msg = f'Cannot parse argument as datetime: {txt}' + + # Don't choke on the default format of unix date. + try: + return datetime.datetime.strptime(txt, "%a %b %d %H:%M:%S %Z %Y") + except Exception: + pass + + msg = f"Cannot parse argument as datetime: {txt}" logger.error(msg) raise argparse.ArgumentTypeError(msg) @@ -403,8 +452,10 @@ def valid_duration(txt: str) -> datetime.timedelta: txt: data passed to a commandline arg expecting a duration. Returns: - The datetime.timedelta described by txt or raises ArgumentTypeError - on error. + The datetime.timedelta described by txt. + + Raises: + ArgumentTypeError: parse error (e.g. invalid duration string) Sample usage:: @@ -433,18 +484,86 @@ def valid_duration(txt: str) -> datetime.timedelta: ... argparse.ArgumentTypeError: a little while is not a valid duration. """ - from pyutils.datetimez.datetime_utils import parse_duration + from pyutils.datetimes.datetime_utils import parse_duration try: secs = parse_duration(txt, raise_on_error=True) return datetime.timedelta(seconds=secs) except Exception as e: - logger.exception(e) + logger.exception("Exception while parsing a supposed duration: %s", txt) + raise argparse.ArgumentTypeError(e) from e + + +def valid_byte_count(txt: str) -> int: + """If the string is a valid number of bytes, return an integer + representing the requested byte count. This method uses + :meth:`string_utils.suffix_string_to_number` to parse and and + accepts / understands: + + - plain numbers (123456) + - numbers with ISO suffixes (Mb, Gb, Pb, etc...) + + Args: + txt: data passed to a commandline arg expecting a duration. + + Returns: + An integer number of bytes. + + Raises: + ArgumentTypeError: parse error (e.g. byte count not parsable) + + Sample usage:: + + cfg.add_argument( + '--max_file_size', + type=argparse_utils.valid_byte_count, + default=(1024 * 1024), + metavar='NUM_BYTES', + help='The largest file we may create', + ) + + >>> valid_byte_count('1Mb') + 1048576 + + >>> valid_byte_count("1234567") + 1234567 + + >>> valid_byte_count("1M") + 1048576 + + >>> valid_byte_count("1.2Gb") + 1288490188 + + >>> valid_byte_count('1.2') # <--- contains a decimal + Traceback (most recent call last): + ... + argparse.ArgumentTypeError: Invalid byte count: 1.2 + + >>> valid_byte_count(1234567) # <--- not a string + Traceback (most recent call last): + ... + argparse.ArgumentTypeError: Invalid byte count: 1234567 + + >>> valid_byte_count('On a dark and stormy night') + Traceback (most recent call last): + ... + argparse.ArgumentTypeError: Invalid byte count: On a dark and stormy night + + """ + from pyutils.string_utils import suffix_string_to_number + + try: + num_bytes = suffix_string_to_number(txt) + if num_bytes: + return num_bytes + raise argparse.ArgumentTypeError(f"Invalid byte count: {txt}") + except Exception as e: + logger.exception("Exception while parsing a supposed byte count: %s", txt) raise argparse.ArgumentTypeError(e) from e -if __name__ == '__main__': +if __name__ == "__main__": import doctest - doctest.ELLIPSIS_MARKER = '-ANYTHING-' + doctest.ELLIPSIS_MARKER = "-ANYTHING-" doctest.testmod()