Source code for pyutils.argparse_utils

#!/usr/bin/python3

# © 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 (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, Optional

from overrides import overrides

# This module is commonly used by others in here and should avoid
# taking any unnecessary dependencies back on them.

logger = logging.getLogger(__name__)


[docs] class ActionNoYes(argparse.Action): """An argparse Action that allows for commandline arguments like this:: cfg.add_argument( '--enable_the_thing', action=ActionNoYes, default=False, help='Should we enable the thing?' ) This creates the following cmdline arguments:: --enable_the_thing --no_enable_the_thing 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: 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" logger.critical(msg) raise ValueError(msg) if len(option_strings) != 1: 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 --" logger.critical(msg) raise ValueError(msg) opt = opt[2:] opts = ["--" + opt, "--no_" + opt] super().__init__( opts, dest, nargs=0, const=None, default=default, required=required, help=help, ) @overrides 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)
[docs] def valid_bool(v: Any) -> bool: """ If the string is a valid bool, return its value. Otherwise raise. Args: v: data passed to an argument expecting a bool on the cmdline. Returns: The boolean value of v Raises: ArgumentTypeError: parse error (e.g. not a valid bool string) Sample usage:: args.add_argument( '--auto', type=argparse_utils.valid_bool, default=None, metavar='True|False', help='Use your best judgement about --primary and --secondary', ) >>> valid_bool(True) True >>> valid_bool("true") True >>> valid_bool("yes") True >>> valid_bool("on") True >>> valid_bool("1") True >>> valid_bool("off") # Note: expect False; invalid would raise. False >>> valid_bool(12345) Traceback (most recent call last): ... argparse.ArgumentTypeError: 12345 """ if isinstance(v, bool): return v from pyutils.string_utils import to_bool try: return to_bool(v) except Exception as e: raise argparse.ArgumentTypeError(v) from e
[docs] def valid_ip(ip: str) -> str: """ If the string is a valid IPv4 address, return it. Otherwise raise an ArgumentTypeError. Args: ip: data passed to a commandline arg expecting an IP(v4) address. Returns: The IP address, if valid. Raises: ArgumentTypeError: parse error (e.g. not a valid IP address string) Sample usage:: args.add_argument( "-i", "--ip_address", metavar="TARGET_IP_ADDRESS", help="Target IP Address", type=argparse_utils.valid_ip, ) >>> valid_ip("1.2.3.4") '1.2.3.4' >>> valid_ip("localhost") Traceback (most recent call last): ... argparse.ArgumentTypeError: localhost is an invalid IP address """ from pyutils.string_utils import extract_ip_v4 s = extract_ip_v4(ip.strip()) if s is not None: return s msg = f"{ip} is an invalid IP address" logger.error(msg) raise argparse.ArgumentTypeError(msg)
[docs] def valid_mac(mac: str) -> str: """ If the string is a valid MAC address, return it. Otherwise raise an ArgumentTypeError. Args: mac: a value passed to a commandline flag expecting a MAC address. Returns: The MAC address passed Raises: ArgumentTypeError: parse error (e.g. not a valid MAC address) Sample usage:: group.add_argument( "-m", "--mac", metavar="MAC_ADDRESS", help="Target MAC Address", type=argparse_utils.valid_mac, ) >>> valid_mac('12:23:3A:4F:55:66') '12:23:3A:4F:55:66' >>> valid_mac('12-23-3A-4F-55-66') '12-23-3A-4F-55-66' >>> valid_mac('big') Traceback (most recent call last): ... argparse.ArgumentTypeError: big is an invalid MAC address """ from pyutils.string_utils import extract_mac_address s = extract_mac_address(mac) if s is not None: return s msg = f"{mac} is an invalid MAC address" logger.error(msg) raise argparse.ArgumentTypeError(msg)
[docs] def valid_percentage(num: str) -> float: """ If the string is a valid (0 <= n <= 100) percentage, return it. Otherwise raise an ArgumentTypeError. Arg: num: data passed to a flag expecting a percentage with a value between 0 and 100 inclusive. Returns: The number if valid, otherwise raises ArgumentTypeError. Raises: ArgumentTypeError: parse error (e.g. not a valid percentage) Sample usage:: args.add_argument( '--percent_change', type=argparse_utils.valid_percentage, default=0, help='The percent change (0<=n<=100) of foobar', ) >>> valid_percentage("15%") 15.0 >>> valid_percentage('40') 40.0 >>> valid_percentage('115') Traceback (most recent call last): ... argparse.ArgumentTypeError: 115 is an invalid percentage; expected 0 <= n <= 100.0 """ num = num.strip("%") n = float(num) if 0.0 <= n <= 100.0: return n msg = f"{num} is an invalid percentage; expected 0 <= n <= 100.0" logger.error(msg) raise argparse.ArgumentTypeError(msg)
[docs] def valid_filename(filename: str) -> str: """ If the string contains a valid filename that exists on the filesystem, return it. Otherwise raise an ArgumentTypeError. .. note:: This method will accept directories that exist on the filesystem in addition to files. Args: filename: data passed to a flag expecting a valid filename. Returns: The filename if valid, otherwise raises ArgumentTypeError. Raises: ArgumentTypeError: parse error (e.g. file doesn't exist) Sample usage:: args.add_argument( '--network_mac_addresses_file', default='/home/scott/bin/network_mac_addresses.txt', metavar='FILENAME', help='Location of the network_mac_addresses file (must exist!).', type=argparse_utils.valid_filename, ) >>> valid_filename('/tmp') '/tmp' >>> valid_filename('wfwefwefwefwefwefwefwefwef') Traceback (most recent call last): ... argparse.ArgumentTypeError: wfwefwefwefwefwefwefwefwef was not found and is therefore invalid. """ s = filename.strip() if os.path.exists(s): return s msg = f"{filename} was not found and is therefore invalid." logger.error(msg) raise argparse.ArgumentTypeError(msg)
[docs] def valid_date(txt: str) -> datetime.date: """If the string is a valid date, return it. Otherwise raise an ArgumentTypeError. Args: txt: data passed to a commandline flag expecting a date. Returns: the datetime.date described by txt Raises: ArgumentTypeError: parse error (e.g. date not valid) Sample usage:: cfg.add_argument( "--date", nargs=1, type=argparse_utils.valid_date, metavar="DATE STRING", default=None ) >>> valid_date('6/5/2021') datetime.date(2021, 6, 5) .. note:: dates like 'next wednesday' work fine, they are just hard to doctest for without knowing when the testcase will be executed... See :py:mod:`pyutils.datetimes.dateparse_utils` for other examples of usable expressions. >>> valid_date('next wednesday') # doctest: +ELLIPSIS -ANYTHING- """ from pyutils.string_utils import to_date date = to_date(txt) if date is not None: return date msg = f"Cannot parse argument as a date: {txt}" logger.error(msg) raise argparse.ArgumentTypeError(msg)
[docs] def valid_datetime(txt: str) -> datetime.datetime: """If the string is a valid datetime, return it. Otherwise raise an ArgumentTypeError. Args: txt: data passed to a commandline flag expecting a valid datetime.datetime. Returns: The datetime.datetime described by txt Raises: ArgumentTypeError: parse error (e.g. invalid datetime string) Sample usage:: cfg.add_argument( "--override_timestamp", nargs=1, type=argparse_utils.valid_datetime, help="Don't use the current datetime, override to argument.", metavar="DATE/TIME STRING", default=None, ) >>> valid_datetime('6/5/2021 3:01:02') datetime.datetime(2021, 6, 5, 3, 1, 2) >>> valid_datetime('Sun Dec 11 11:50:00 UTC 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 :mod:`pyutils.datetimes.dateparse_utils` for details. These are not included in here because they are hard to write valid doctests for! >>> valid_datetime('next christmas at 4:15am') # doctest: +ELLIPSIS -ANYTHING- """ # Don't choke on the default format of unix date. Work around the # shitty %Z semantics in datetime.strptime. dt: Optional[datetime.datetime] = None try: chunks = txt.split() if len(chunks) == 6: from pyutils.datetimes import datetime_utils tz = datetime_utils.timezone_abbrev_to_tz(chunks[4]) if tz: # Chop out the timezone part, %Z (maybe) won't parse it. txt = " ".join(chunks[:4] + [chunks[5]]) dt = datetime.datetime.strptime(txt, "%a %b %d %H:%M:%S %Y") # Force the right timezone guess datetime_utils.replace_timezone(dt, tz) return dt except Exception: logger.exception("Ignoring exception from datetime_utils.") # Otherwise try dateparse_utils. from pyutils.string_utils import to_datetime dt = to_datetime(txt) if dt is not None: return dt msg = f"Cannot parse argument as datetime: {txt}" logger.error(msg) raise argparse.ArgumentTypeError(msg)
[docs] def valid_duration(txt: str) -> datetime.timedelta: """If the string is a valid time duration, return a datetime.timedelta representing the duration described. This uses `datetime_utils.parse_duration` to parse durations and expects data such as: - 15 days, 3 hours, 15 minutes - 15 days 3 hours 15 minutes - 15d 3h 15m - 15d3h5m - 3m 2s - 1000s If the duration is not parsable, raise an ArgumentTypeError. Args: txt: data passed to a commandline arg expecting a duration. Returns: The datetime.timedelta described by txt. Raises: ArgumentTypeError: parse error (e.g. invalid duration string) Sample usage:: cfg.add_argument( '--ip_cache_max_staleness', type=argparse_utils.valid_duration, default=datetime.timedelta(seconds=60 * 60 * 4), metavar='DURATION', help='Max acceptable age of the IP address cache' ) >>> valid_duration('15d3h5m') datetime.timedelta(days=15, seconds=11100) >>> valid_duration('15 days 3 hours 5 min') datetime.timedelta(days=15, seconds=11100) >>> valid_duration('3m') datetime.timedelta(seconds=180) >>> valid_duration('3 days, 2 hours') datetime.timedelta(days=3, seconds=7200) >>> valid_duration('a little while') Traceback (most recent call last): ... argparse.ArgumentTypeError: a little while is not a valid 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("Exception while parsing a supposed duration: %s", txt) raise argparse.ArgumentTypeError(e) from e
[docs] 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__": import doctest doctest.ELLIPSIS_MARKER = "-ANYTHING-" doctest.testmod()