Adds a __repr__ to graph.
[pyutils.git] / src / pyutils / argparse_utils.py
index f142d7e4a4adeb42c7e6f2d82031fc99ed732232..fec7d36ecc1a6178735d0559dd7308ac44b76874 100644 (file)
@@ -1,19 +1,24 @@
 #!/usr/bin/python3
 
 #!/usr/bin/python3
 
-# © Copyright 2021-2022, Scott Gasch
+# © Copyright 2021-2023, Scott Gasch
 
 """These are helpers for commandline argument parsing meant to work
 
 """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
 """
 
 import argparse
 import datetime
 import logging
 import os
-from typing import Any
+from typing import Any, Optional
 
 from overrides import overrides
 
 
 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.
 
     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:
         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:
             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]
             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:]
             logger.critical(msg)
             raise ValueError(msg)
 
         opt = opt[2:]
-        opts = ['--' + opt, '--no_' + opt]
+        opts = ["--" + opt, "--no_" + opt]
         super().__init__(
             opts,
             dest,
         super().__init__(
             opts,
             dest,
@@ -70,11 +86,12 @@ class ActionNoYes(argparse.Action):
         )
 
     @overrides
         )
 
     @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:
 
 
 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:
         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::
 
 
     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:
         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::
 
 
     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:
         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::
 
 
     Sample usage::
 
@@ -226,6 +252,9 @@ def valid_percentage(num: str) -> float:
     Returns:
         The number if valid, otherwise raises ArgumentTypeError.
 
     Returns:
         The number if valid, otherwise raises ArgumentTypeError.
 
+    Raises:
+        ArgumentTypeError: parse error (e.g. not a valid percentage)
+
     Sample usage::
 
         args.add_argument(
     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
 
     """
     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
     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.
 
     Returns:
         The filename if valid, otherwise raises ArgumentTypeError.
 
+    Raises:
+        ArgumentTypeError: parse error (e.g. file doesn't exist)
+
     Sample usage::
 
         args.add_argument(
     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:
         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::
 
 
     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
     .. 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-
 
     >>> 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
     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)
 
     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:
         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::
 
 
     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('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.
     .. 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!
 
         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
     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)
 
     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:
         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::
 
 
     Sample usage::
 
@@ -433,18 +484,86 @@ def valid_duration(txt: str) -> datetime.timedelta:
     ...
     argparse.ArgumentTypeError: a little while is not a valid duration.
     """
     ...
     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:
 
     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
 
 
         raise argparse.ArgumentTypeError(e) from e
 
 
-if __name__ == '__main__':
+if __name__ == "__main__":
     import doctest
 
     import doctest
 
-    doctest.ELLIPSIS_MARKER = '-ANYTHING-'
+    doctest.ELLIPSIS_MARKER = "-ANYTHING-"
     doctest.testmod()
     doctest.testmod()