Make argparse utils' valid_date work with the default format of UNIX date.
[pyutils.git] / src / pyutils / argparse_utils.py
index daca1df10ef66ee913b0afa6e6ff8e226e6aa22e..0ce429f1e4957b6c8d469ca555eb3f6f07147e7b 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
@@ -39,21 +49,21 @@ class ActionNoYes(argparse.Action):
 
     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,
@@ -66,7 +76,7 @@ class ActionNoYes(argparse.Action):
 
     @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)
@@ -74,7 +84,15 @@ class ActionNoYes(argparse.Action):
 
 def valid_bool(v: Any) -> bool:
     """
-    If the string is a valid bool, return its value.  Sample usage::
+    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',
@@ -99,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):
     ...
@@ -118,9 +139,17 @@ def valid_bool(v: Any) -> bool:
 def valid_ip(ip: str) -> str:
     """
     If the string is a valid IPv4 address, return it.  Otherwise raise
-    an ArgumentTypeError.  Sample usage::
+    an ArgumentTypeError.
 
-        group.add_argument(
+    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",
@@ -150,7 +179,15 @@ def valid_ip(ip: str) -> str:
 def valid_mac(mac: str) -> str:
     """
     If the string is a valid MAC address, return it.  Otherwise raise
-    an ArgumentTypeError.  Sample usage::
+    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",
@@ -185,7 +222,16 @@ def valid_mac(mac: str) -> str:
 def valid_percentage(num: str) -> float:
     """
     If the string is a valid (0 <= n <= 100) percentage, return it.
-    Otherwise raise an ArgumentTypeError.  Sample usage::
+    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',
@@ -206,7 +252,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
@@ -217,8 +263,21 @@ 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.  Sample usage::
+    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',
@@ -247,7 +306,15 @@ def valid_filename(filename: str) -> str:
 
 def valid_date(txt: str) -> datetime.date:
     """If the string is a valid date, return it.  Otherwise raise
-    an ArgumentTypeError.  Sample usage::
+    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",
@@ -260,9 +327,11 @@ def valid_date(txt: str) -> datetime.date:
     >>> 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-
@@ -272,14 +341,22 @@ 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)
 
 
 def valid_datetime(txt: str) -> datetime.datetime:
     """If the string is a valid datetime, return it.  Otherwise raise
-    an ArgumentTypeError.  Sample usage::
+    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",
@@ -293,11 +370,15 @@ def valid_datetime(txt: str) -> datetime.datetime:
     >>> valid_datetime('6/5/2021 3:01:02')
     datetime.datetime(2021, 6, 5, 3, 1, 2)
 
-    Because this thing 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('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 :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-
@@ -307,24 +388,56 @@ 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)
 
 
 def valid_duration(txt: str) -> datetime.timedelta:
     """If the string is a valid time duration, return a
-    datetime.timedelta representing the period of time.
+    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 * 12),
+            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)
 
@@ -335,9 +448,8 @@ def valid_duration(txt: str) -> datetime.timedelta:
     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, raise_on_error=True)
@@ -347,8 +459,8 @@ def valid_duration(txt: str) -> datetime.timedelta:
         raise argparse.ArgumentTypeError(e) from e
 
 
-if __name__ == '__main__':
+if __name__ == "__main__":
     import doctest
 
-    doctest.ELLIPSIS_MARKER = '-ANYTHING-'
+    doctest.ELLIPSIS_MARKER = "-ANYTHING-"
     doctest.testmod()