# © Copyright 2021-2022, Scott Gasch
-"""Helpers for commandline argument parsing."""
+"""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
def __init__(self, option_strings, dest, default=None, required=False, help=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,
@overrides
def __call__(self, parser, namespace, values, option_strings=None):
- if option_strings.startswith('--no-') or option_strings.startswith('--no_'):
+ 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:
"""
- If the string is a valid bool, return its value.
+ 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 or raises an ArgumentTypeError on error.
+
+ 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("1")
True
+ >>> valid_bool("off") # Note: expect False; invalid would raise.
+ False
+
>>> valid_bool(12345)
Traceback (most recent call last):
...
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 otherwise.
+
+ 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'
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 or raises ArgumentTypeError on error.
+
+ 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'
def valid_percentage(num: str) -> float:
"""
- If the string is a valid percentage, return it. Otherwise raise
- an ArgumentTypeError.
+ 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.
+
+ 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
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
def valid_filename(filename: str) -> str:
"""
- If the string is a valid filename, return it. Otherwise raise
- an ArgumentTypeError.
+ 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.
+
+ 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'
"""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 or raises ArgumentTypeError on error.
+
+ 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 test for without knowing when the testcase will be
- # executed...
+ .. 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-
"""
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)
"""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 or raises ArgumentTypeError on error.
+
+ 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)
- # Again, these types of expressions work fine but are
- # difficult to test with doctests because the answer is
- # relative to the time the doctest is executed.
+ .. 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-
"""
dt = to_datetime(txt)
if dt is not None:
return dt
- msg = f'Cannot parse argument as datetime: {txt}'
+ msg = f"Cannot parse argument as datetime: {txt}"
logger.error(msg)
raise argparse.ArgumentTypeError(msg)
def valid_duration(txt: str) -> datetime.timedelta:
"""If the string is a valid time duration, return a
- datetime.timedelta representing the period of time. Otherwise
- maybe raise an ArgumentTypeError or potentially just treat the
- time window as zero in length.
+ 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 or raises ArgumentTypeError
+ on error.
+
+ 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('your mom')
- datetime.timedelta(0)
+ >>> 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.datetimez.datetime_utils import parse_duration
+ from pyutils.datetimes.datetime_utils import parse_duration
try:
- secs = parse_duration(txt)
+ secs = parse_duration(txt, raise_on_error=True)
return datetime.timedelta(seconds=secs)
except Exception as e:
logger.exception(e)
raise argparse.ArgumentTypeError(e) from e
-if __name__ == '__main__':
+if __name__ == "__main__":
import doctest
- doctest.ELLIPSIS_MARKER = '-ANYTHING-'
+ doctest.ELLIPSIS_MARKER = "-ANYTHING-"
doctest.testmod()