More writing examples and improving documentation.
[pyutils.git] / src / pyutils / argparse_utils.py
index 3b466b0d93da43bcf69c98215fc993bb121871e6..d1e28ba80b2ae758a7a9bfd41ba91fcadfada26a 100644 (file)
@@ -2,7 +2,17 @@
 
 # © 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
@@ -74,7 +84,23 @@ class ActionNoYes(argparse.Action):
 
 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
@@ -91,6 +117,9 @@ def valid_bool(v: Any) -> bool:
     >>> valid_bool("1")
     True
 
+    >>> valid_bool("off")   # Note: expect False; invalid would raise.
+    False
+
     >>> valid_bool(12345)
     Traceback (most recent call last):
     ...
@@ -112,6 +141,22 @@ 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 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'
 
@@ -136,6 +181,22 @@ 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 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'
 
@@ -160,8 +221,24 @@ def valid_mac(mac: str) -> str:
 
 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
@@ -186,8 +263,29 @@ def valid_percentage(num: str) -> float:
 
 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'
@@ -210,12 +308,30 @@ 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 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...
+
     >>> valid_date('next wednesday') # doctest: +ELLIPSIS
     -ANYTHING-
     """
@@ -233,12 +349,33 @@ 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 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: `tests/datetimez/dateparse_utils_test.py` for examples.  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-
     """
@@ -254,21 +391,57 @@ def valid_datetime(txt: str) -> datetime.datetime:
 
 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
 
     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)