Adds a __repr__ to graph.
[pyutils.git] / src / pyutils / argparse_utils.py
index f142d7e4a4adeb42c7e6f2d82031fc99ed732232..fec7d36ecc1a6178735d0559dd7308ac44b76874 100644 (file)
@@ -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()