From ce6215906307baf0dca28f46444001389abd584e Mon Sep 17 00:00:00 2001 From: Scott Gasch Date: Mon, 12 Jun 2023 15:24:48 -0700 Subject: [PATCH] More sanity with exception types and raises docs. --- src/pyutils/argparse_utils.py | 37 +++++++- src/pyutils/collectionz/interval_tree.py | 2 +- src/pyutils/collectionz/trie.py | 3 +- src/pyutils/compress/letter_compress.py | 5 +- src/pyutils/config.py | 7 ++ src/pyutils/datetimes/dateparse_utils.py | 9 +- src/pyutils/datetimes/datetime_utils.py | 17 +++- src/pyutils/decorator_utils.py | 7 ++ src/pyutils/dict_utils.py | 5 +- src/pyutils/exec_utils.py | 20 +++-- src/pyutils/files/directory_filter.py | 3 + src/pyutils/files/file_utils.py | 6 ++ src/pyutils/files/lockfile.py | 3 + src/pyutils/logging_utils.py | 27 +++++- src/pyutils/math_utils.py | 6 ++ src/pyutils/parallelize/executors.py | 23 ++++- src/pyutils/parallelize/thread_utils.py | 9 +- src/pyutils/search/logical_search.py | 12 ++- src/pyutils/security/acl.py | 5 +- src/pyutils/string_utils.py | 108 ++++++++++++++++++----- src/pyutils/text_utils.py | 11 ++- src/pyutils/typez/centcount.py | 49 +++++++++- src/pyutils/typez/histogram.py | 10 ++- src/pyutils/typez/money.py | 50 ++++++++++- src/pyutils/typez/persistent.py | 16 ++++ src/pyutils/typez/rate.py | 6 +- src/pyutils/typez/type_utils.py | 3 + 27 files changed, 397 insertions(+), 62 deletions(-) diff --git a/src/pyutils/argparse_utils.py b/src/pyutils/argparse_utils.py index 01c37f5..97a5774 100644 --- a/src/pyutils/argparse_utils.py +++ b/src/pyutils/argparse_utils.py @@ -45,6 +45,10 @@ 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): @@ -90,7 +94,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:: @@ -145,7 +152,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:: @@ -187,6 +197,9 @@ def valid_mac(mac: str) -> str: Returns: The MAC address passed or raises ArgumentTypeError on error. + Raises: + ArgumentTypeError: parse error (e.g. not a valid MAC address) + Sample usage:: group.add_argument( @@ -231,6 +244,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( @@ -277,6 +293,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( @@ -314,6 +333,9 @@ def valid_date(txt: str) -> datetime.date: Returns: the datetime.date described by txt or raises ArgumentTypeError on error. + Raises: + ArgumentTypeError: parse error (e.g. date not valid) + Sample usage:: cfg.add_argument( @@ -356,6 +378,9 @@ def valid_datetime(txt: str) -> datetime.datetime: Returns: The datetime.datetime described by txt or raises ArgumentTypeError on error. + Raises: + ArgumentTypeError: parse error (e.g. invalid datetime string) + Sample usage:: cfg.add_argument( @@ -422,6 +447,9 @@ def valid_duration(txt: str) -> datetime.timedelta: The datetime.timedelta described by txt or raises ArgumentTypeError on error. + Raises: + ArgumentTypeError: parse error (e.g. invalid duration string) + Sample usage:: cfg.add_argument( @@ -468,8 +496,6 @@ def valid_byte_count(txt: str) -> int: - plain numbers (123456) - numbers with ISO suffixes (Mb, Gb, Pb, etc...) - If the byte count is not parsable, raise an ArgumentTypeError. - Args: txt: data passed to a commandline arg expecting a duration. @@ -477,6 +503,9 @@ def valid_byte_count(txt: str) -> int: An integer number of bytes or raises ArgumentTypeError on error. + Raises: + ArgumentTypeError: parse error (e.g. byte count not parsable) + Sample usage:: cfg.add_argument( diff --git a/src/pyutils/collectionz/interval_tree.py b/src/pyutils/collectionz/interval_tree.py index 7cd40cf..c4e4e9a 100644 --- a/src/pyutils/collectionz/interval_tree.py +++ b/src/pyutils/collectionz/interval_tree.py @@ -83,7 +83,7 @@ class AugmentedIntervalTree(bst.BinarySearchTree): @staticmethod def _assert_value_must_be_range(value: Any) -> NumericRange: if not isinstance(value, NumericRange): - raise Exception( + raise TypeError( "AugmentedIntervalTree expects to use NumericRanges, see bst for a " + "general purpose tree usable for other types." ) diff --git a/src/pyutils/collectionz/trie.py b/src/pyutils/collectionz/trie.py index ba7031b..93138dc 100644 --- a/src/pyutils/collectionz/trie.py +++ b/src/pyutils/collectionz/trie.py @@ -118,8 +118,7 @@ class Trie(object): def __getitem__(self, item: Sequence[Any]) -> Dict[Any, Any]: """Given an item, return its trie node which contains all - of the successor (child) node pointers. If the item is not - a node in the Trie, raise a KeyError. + of the successor (child) node pointers. Args: item: the item whose node is to be retrieved diff --git a/src/pyutils/compress/letter_compress.py b/src/pyutils/compress/letter_compress.py index d2dfa7a..9725353 100644 --- a/src/pyutils/compress/letter_compress.py +++ b/src/pyutils/compress/letter_compress.py @@ -35,6 +35,9 @@ def compress(uncompressed: str) -> bytes: Returns: the compressed bytes + Raises: + ValueError: uncompressed text contains illegal character + >>> import binascii >>> binascii.hexlify(compress('this is a test')) b'a2133da67b0ee859d0' @@ -52,7 +55,7 @@ def compress(uncompressed: str) -> bytes: bits = ord(letter) - ord("a") + 1 # 1..26 else: if letter not in special_characters: - raise Exception( + raise ValueError( f'"{uncompressed}" contains uncompressable char="{letter}"' ) bits = special_characters[letter] diff --git a/src/pyutils/config.py b/src/pyutils/config.py index f4647c4..a1aa5f9 100644 --- a/src/pyutils/config.py +++ b/src/pyutils/config.py @@ -403,6 +403,9 @@ class Config: Otherwise False is returned. + Raises: + Exception: On error reading from zookeeper + >>> to_bool('True') True @@ -604,6 +607,10 @@ class Config: A dict containing the parsed program configuration. Note that this can be safely ignored since it is also saved in `config.config` and may be used directly using that identifier. + + Raises: + Exception: if unrecognized config argument(s) are detected and the + --config_rejects_unrecognized_arguments argument is enabled. """ if self.config_parse_called: return self.config diff --git a/src/pyutils/datetimes/dateparse_utils.py b/src/pyutils/datetimes/dateparse_utils.py index d6665d7..586008d 100755 --- a/src/pyutils/datetimes/dateparse_utils.py +++ b/src/pyutils/datetimes/dateparse_utils.py @@ -154,7 +154,11 @@ class ParseException(Exception): class RaisingErrorListener(antlr4.DiagnosticErrorListener): - """An error listener that raises ParseExceptions.""" + """An error listener that raises ParseExceptions. + + Raises: + ParseException: on parse error + """ def syntaxError(self, recognizer, offendingSymbol, line, column, msg, e): raise ParseException(msg) @@ -305,6 +309,9 @@ class DateParser(dateparse_utilsListener): A datetime.datetime representing the parsed date/time or None on error. + Raises: + ParseException: an exception happened during parsing + .. note:: Parsed date expressions without any time part return diff --git a/src/pyutils/datetimes/datetime_utils.py b/src/pyutils/datetimes/datetime_utils.py index b2a9d10..6a38e92 100644 --- a/src/pyutils/datetimes/datetime_utils.py +++ b/src/pyutils/datetimes/datetime_utils.py @@ -117,6 +117,9 @@ def add_timezone(dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.datetim A datetime identical to dt, the input datetime, except for that a timezone has been added. + Raises: + ValueError: if dt is already a timezone aware datetime. + .. warning:: This doesn't change the hour, minute, second, day, month, etc... @@ -152,7 +155,7 @@ def add_timezone(dt: datetime.datetime, tz: datetime.tzinfo) -> datetime.datetim if is_timezone_aware(dt): if dt.tzinfo == tz: return dt - raise Exception( + raise ValueError( f"{dt} is already timezone aware; use replace_timezone or translate_timezone " + "depending on the semantics you want. See the pydocs / code." ) @@ -518,6 +521,9 @@ def n_timeunits_from_base( base: a datetime representing the base date the result should be relative to. + Raises: + ValueError: unit is invalid + Returns: A datetime that is count units before of after the base datetime. @@ -956,6 +962,9 @@ def minute_number(hour: int, minute: int) -> MinuteOfDay: Returns: The minute number requested. Raises `ValueError` on bad input. + Raises: + ValueError: invalid hour or minute input argument + >>> minute_number(0, 0) 0 @@ -1055,6 +1064,9 @@ def parse_duration(duration: str, raise_on_error: bool = False) -> int: Returns: A count of seconds represented by the input string. + Raises: + ValueError: bad duration and raise_on_error is set. + >>> parse_duration('15 days, 2 hours') 1303200 @@ -1359,6 +1371,9 @@ def easter(year: int, method: int = EASTER_WESTERN): `The Calendar FAQ: Easter `_ + Raises: + ValueError if method argument is invalid + """ if not (1 <= method <= 3): diff --git a/src/pyutils/decorator_utils.py b/src/pyutils/decorator_utils.py index f085697..06785c0 100644 --- a/src/pyutils/decorator_utils.py +++ b/src/pyutils/decorator_utils.py @@ -410,6 +410,10 @@ def predicated_retry_with_backoff( that we should stop calling or False to indicate a retry is necessary + Raises: + ValueError: on invalid arguments; e.g. backoff must be >= 1.0, + delay_sec must be >= 0.0, tries must be > 0. + .. note:: If after `tries` attempts the wrapped function is still @@ -952,6 +956,9 @@ def call_probabilistically(probability_of_call: float) -> Callable: probability_of_call: probability with which to invoke the wrapped function. Must be 0 <= probabilty <= 1.0. + Raises: + ValueError: invalid probability of call arg + Example usage... this example would skip the invocation of `log_the_entire_request_message` 95% of the time and only invoke if 5% of the time.:: diff --git a/src/pyutils/dict_utils.py b/src/pyutils/dict_utils.py index 0dbe18a..8e6d6cb 100644 --- a/src/pyutils/dict_utils.py +++ b/src/pyutils/dict_utils.py @@ -337,13 +337,16 @@ def parallel_lists_to_dict(keys: List[Hashable], values: List[Any]) -> AnyDict: Returns: A dict composed of zipping the keys list and values list together. + Raises: + ValueError: if keys and values lists not the same length. + >>> k = ['name', 'phone', 'address', 'zip'] >>> v = ['scott', '555-1212', '123 main st.', '12345'] >>> parallel_lists_to_dict(k, v) {'name': 'scott', 'phone': '555-1212', 'address': '123 main st.', 'zip': '12345'} """ if len(keys) != len(values): - raise Exception("Parallel keys and values lists must have the same length") + raise ValueError("Parallel keys and values lists must have the same length") return dict(zip(keys, values)) diff --git a/src/pyutils/exec_utils.py b/src/pyutils/exec_utils.py index 6839ef6..6fcd340 100644 --- a/src/pyutils/exec_utils.py +++ b/src/pyutils/exec_utils.py @@ -38,6 +38,9 @@ def cmd_showing_output( exited. Raises `TimeoutExpired` after killing the subprocess if the timeout expires. + Raises: + TimeoutExpired: if timeout expires before child terminates + Side effects: prints all output of the child process (stdout or stderr) """ @@ -93,9 +96,7 @@ def cmd_showing_output( def cmd_exitcode(command: str, timeout_seconds: Optional[float] = None) -> int: """Run a command silently in the background and return its exit - code once it has finished. If timeout_seconds is provided and the - command runs longer than timeout_seconds, raise a `TimeoutExpired` - exception. + code once it has finished. Args: command: the command to run @@ -106,6 +107,10 @@ def cmd_exitcode(command: str, timeout_seconds: Optional[float] = None) -> int: the exit status of the subprocess once the subprocess has exited + Raises: + TimeoutExpired: if timeout_seconds is provided and the child process + executes longer than the limit. + >>> cmd_exitcode('/bin/echo foo', 10.0) 0 @@ -152,10 +157,7 @@ def cmd(command: str, timeout_seconds: Optional[float] = None) -> str: def run_silently(command: str, timeout_seconds: Optional[float] = None) -> None: - """Run a command silently but raise - `subprocess.CalledProcessError` if it fails (i.e. returns a - non-zero return value) and raise a `TimeoutExpired` if it runs too - long. + """Run a command silently. Args: command: the command to run. @@ -167,6 +169,10 @@ def run_silently(command: str, timeout_seconds: Optional[float] = None) -> None: No return value; error conditions (including non-zero child process exits) produce exceptions. + Raises: + CalledProcessError: if the child process fails (i.e. exit != 0) + TimeoutExpired: if the child process executes too long. + >>> run_silently("/usr/bin/true") >>> run_silently("/usr/bin/false") diff --git a/src/pyutils/files/directory_filter.py b/src/pyutils/files/directory_filter.py index 74d49fb..243056e 100644 --- a/src/pyutils/files/directory_filter.py +++ b/src/pyutils/files/directory_filter.py @@ -30,6 +30,9 @@ class DirectoryFileFilter(object): content to-be-written is identical to the contents of the file on disk allowing calling code to safely skip the write. + Raises: + ValueError: directory doesn't exist + >>> testfile = '/tmp/directory_filter_text_f39e5b58-c260-40da-9448-ad1c3b2a69c2.txt' >>> contents = b'This is a test' >>> with open(testfile, 'wb') as wf: diff --git a/src/pyutils/files/file_utils.py b/src/pyutils/files/file_utils.py index d05cae6..16b71ab 100644 --- a/src/pyutils/files/file_utils.py +++ b/src/pyutils/files/file_utils.py @@ -62,6 +62,9 @@ def slurp_file( Returns: A list of lines from the read and transformed file contents. + + Raises: + Exception: filename not found or can't be read. """ ret = [] @@ -310,6 +313,9 @@ def create_path_if_not_exist( on_error: If set, it's invoked on error conditions and passed then path and OSError that it caused. + Raises: + OSError: an exception occurred and on_error not set. + See also :meth:`does_file_exist`. .. warning:: diff --git a/src/pyutils/files/lockfile.py b/src/pyutils/files/lockfile.py index 96bc40a..1d78dd4 100644 --- a/src/pyutils/files/lockfile.py +++ b/src/pyutils/files/lockfile.py @@ -104,6 +104,9 @@ class LockFile(contextlib.AbstractContextManager): Note that this is required for zookeeper based locks. override_command: don't use argv to determine our commandline rather use this instead if provided. + + Raises: + Exception: Zookeeper lock path without an expiration timestamp """ self.is_locked: bool = False self.lockfile: str = "" diff --git a/src/pyutils/logging_utils.py b/src/pyutils/logging_utils.py index 8832c29..f5461d6 100644 --- a/src/pyutils/logging_utils.py +++ b/src/pyutils/logging_utils.py @@ -949,6 +949,10 @@ def initialize_logging(logger=None) -> logging.Logger: :meth:`bootstrap.initialize` decorator on your program's entry point, it will call this for you. See :meth:`pyutils.bootstrap.initialize` for more details. + + Raises: + ValueError: if logging level is invalid + """ global LOGGING_INITIALIZED @@ -1077,6 +1081,11 @@ class OutputMultiplexer(object): handles: if FILEHANDLES bit is set, this should be a list of already opened filehandles you'd like to output into. The handles will remain open after the scope of the multiplexer. + + Raises: + ValueError: invalid combination of arguments (e.g. the filenames + argument is present but the filenames bit isn't set, the handle + argument is present but the handles bit isn't set, etc...) """ if logger is None: logger = logging.getLogger(None) @@ -1105,7 +1114,16 @@ class OutputMultiplexer(object): return self.destination_bitv def set_destination_bitv(self, destination_bitv: int): - """Change the output destination_bitv to the one provided.""" + """Change the output destination_bitv to the one provided. + + Args: + destination_bitv: the new destination bitvector to set. + + Raises: + ValueError: invalid combination of arguments (e.g. the filenames + argument is present but the filenames bit isn't set, the handle + argument is present but the handles bit isn't set, etc...) + """ if destination_bitv & self.Destination.FILENAMES and self.f is None: raise ValueError("Filename argument is required if bitv & FILENAMES") if destination_bitv & self.Destination.FILEHANDLES and self.h is None: @@ -1113,7 +1131,12 @@ class OutputMultiplexer(object): self.destination_bitv = destination_bitv def print(self, *args, **kwargs): - """Produce some output to all sinks.""" + """Produce some output to all sinks. Use the same arguments as the + print-builtin. + + Raises: + TypeError: Illegal argument types encountered + """ from pyutils.string_utils import _sprintf, strip_escape_sequences end = kwargs.pop("end", None) diff --git a/src/pyutils/math_utils.py b/src/pyutils/math_utils.py index fe1e906..61c8c4b 100644 --- a/src/pyutils/math_utils.py +++ b/src/pyutils/math_utils.py @@ -200,6 +200,9 @@ def gcd_float_sequence(lst: List[float]) -> float: Args: lst: a list of operands + + Raises: + ValueError: if the list doesn't contain at least one number. """ if len(lst) <= 0: raise ValueError("Need at least one number") @@ -282,6 +285,9 @@ def is_prime(n: int) -> bool: Returns: True if n is prime and False otherwise. + Raises: + TypeError: if argument is not an into + .. note:: Obviously(?) very slow for very large input numbers until diff --git a/src/pyutils/parallelize/executors.py b/src/pyutils/parallelize/executors.py index 1519119..7ab4eb6 100644 --- a/src/pyutils/parallelize/executors.py +++ b/src/pyutils/parallelize/executors.py @@ -244,6 +244,10 @@ class ThreadExecutor(BaseExecutor): @overrides def submit(self, function: Callable, *args, **kwargs) -> fut.Future: + """ + Raises: + Exception: executor is shutting down already. + """ if self.already_shutdown: raise Exception('Submitted work after shutdown.') self.adjust_task_count(+1) @@ -305,6 +309,10 @@ class ProcessExecutor(BaseExecutor): @overrides def submit(self, function: Callable, *args, **kwargs) -> fut.Future: + """ + Raises: + Exception: executor is shutting down already. + """ if self.already_shutdown: raise Exception('Submitted work after shutdown.') start = time.time() @@ -849,6 +857,9 @@ class RemoteExecutor(BaseExecutor): Args: workers: A list of remote workers we can call on to do tasks. policy: A policy for selecting remote workers for tasks. + + Raises: + RemoteExecutorException: unable to find a place to schedule work. """ super().__init__() @@ -1463,7 +1474,11 @@ class RemoteExecutor(BaseExecutor): self, bundle: BundleDetails ) -> Optional[fut.Future]: """Something unexpectedly failed with bundle. Either retry it - from the beginning or throw in the towel and give up on it.""" + from the beginning or throw in the towel and give up on it. + + Raises: + RemoteExecutorException: a bundle fails repeatedly. + """ is_original = bundle.src_bundle is None bundle.worker = None @@ -1500,7 +1515,11 @@ class RemoteExecutor(BaseExecutor): @overrides def submit(self, function: Callable, *args, **kwargs) -> fut.Future: """Submit work to be done. This is the user entry point of this - class.""" + class. + + Raises: + Exception: executor is already shutting down. + """ if self.already_shutdown: raise Exception('Submitted work after shutdown.') pickle = _make_cloud_pickle(function, *args, **kwargs) diff --git a/src/pyutils/parallelize/thread_utils.py b/src/pyutils/parallelize/thread_utils.py index 4e891f1..11c1c31 100644 --- a/src/pyutils/parallelize/thread_utils.py +++ b/src/pyutils/parallelize/thread_utils.py @@ -198,10 +198,11 @@ class ThreadWithReturnValue(threading.Thread, Runnable): A thread can be joined many times. - :meth:`join` raises a RuntimeError if an attempt is made to join the - current thread as that would cause a deadlock. It is also an - error to join a thread before it has been started and - attempts to do so raises the same exception. + Raises: + RuntimeError: an attempt is made to join the current thread + as that would cause a deadlock. It is also an error to join + a thread before it has been started and attempts to do so + raises the same exception. """ threading.Thread.join(self, *args) return self._return diff --git a/src/pyutils/search/logical_search.py b/src/pyutils/search/logical_search.py index 8819134..b3d642a 100644 --- a/src/pyutils/search/logical_search.py +++ b/src/pyutils/search/logical_search.py @@ -320,6 +320,11 @@ class Corpus(object): yield token def evaluate(corpus: Corpus, stack: List[str]): + """ + Raises: + ParseError: bad number of operations, unbalanced parenthesis, + unknown operators, internal errors. + """ node_stack: List[Node] = [] for token in stack: node = None @@ -403,7 +408,12 @@ class Node(object): self.operands = operands def eval(self) -> Set[str]: - """Evaluate this node.""" + """Evaluate this node. + + Raises: + ParseError: unexpected operands, invalid key:value syntax, wrong + number of operands for operation, other invalid queries. + """ evaled_operands: List[Union[Set[str], str]] = [] for operand in self.operands: diff --git a/src/pyutils/security/acl.py b/src/pyutils/security/acl.py index 8243c8c..5d6abf1 100644 --- a/src/pyutils/security/acl.py +++ b/src/pyutils/security/acl.py @@ -116,6 +116,9 @@ class SimpleACL(ABC): default_answer: pass this argument to provide the ACL with a default answer. + Raises: + ValueError: Invalid Order argument + .. note:: By using `order_to_check_allow_deny` and `default_answer` you @@ -128,7 +131,7 @@ class SimpleACL(ABC): Order.ALLOW_DENY, Order.DENY_ALLOW, ): - raise Exception( + raise ValueError( 'order_to_check_allow_deny must be Order.ALLOW_DENY or ' + 'Order.DENY_ALLOW' ) diff --git a/src/pyutils/string_utils.py b/src/pyutils/string_utils.py index 22964c6..7e2b999 100644 --- a/src/pyutils/string_utils.py +++ b/src/pyutils/string_utils.py @@ -356,6 +356,9 @@ def is_number(in_str: str) -> bool: True if the string contains a valid numberic value and False otherwise. + Raises: + TypeError: the input argument isn't a string + See also :meth:`is_integer_number`, :meth:`is_decimal_number`, :meth:`is_hexidecimal_integer_number`, :meth:`is_octal_integer_number`, etc... @@ -363,7 +366,7 @@ def is_number(in_str: str) -> bool: >>> is_number(100.5) Traceback (most recent call last): ... - ValueError: 100.5 + TypeError: 100.5 >>> is_number("100.5") True >>> is_number("test") @@ -373,10 +376,10 @@ def is_number(in_str: str) -> bool: >>> is_number([1, 2, 3]) Traceback (most recent call last): ... - ValueError: [1, 2, 3] + TypeError: [1, 2, 3] """ if not is_string(in_str): - raise ValueError(in_str) + raise TypeError(in_str) return NUMBER_RE.match(in_str) is not None @@ -415,6 +418,9 @@ def is_hexidecimal_integer_number(in_str: str) -> bool: Returns: True if the string is a hex integer number and False otherwise. + Raises: + TypeError: the input argument isn't a string + See also :meth:`is_integer_number`, :meth:`is_decimal_number`, :meth:`is_octal_integer_number`, :meth:`is_binary_integer_number`, etc... @@ -431,18 +437,18 @@ def is_hexidecimal_integer_number(in_str: str) -> bool: >>> is_hexidecimal_integer_number(12345) # Not a string Traceback (most recent call last): ... - ValueError: 12345 + TypeError: 12345 >>> is_hexidecimal_integer_number(101.4) Traceback (most recent call last): ... - ValueError: 101.4 + TypeError: 101.4 >>> is_hexidecimal_integer_number(0x1A3E) Traceback (most recent call last): ... - ValueError: 6718 + TypeError: 6718 """ if not is_string(in_str): - raise ValueError(in_str) + raise TypeError(in_str) return HEX_NUMBER_RE.match(in_str) is not None @@ -454,6 +460,9 @@ def is_octal_integer_number(in_str: str) -> bool: Returns: True if the string is a valid octal integral number and False otherwise. + Raises: + TypeError: the input argument isn't a string + See also :meth:`is_integer_number`, :meth:`is_decimal_number`, :meth:`is_hexidecimal_integer_number`, :meth:`is_binary_integer_number`, etc... @@ -470,7 +479,7 @@ def is_octal_integer_number(in_str: str) -> bool: False """ if not is_string(in_str): - raise ValueError(in_str) + raise TypeError(in_str) return OCT_NUMBER_RE.match(in_str) is not None @@ -482,6 +491,9 @@ def is_binary_integer_number(in_str: str) -> bool: Returns: True if the string contains a binary integral number and False otherwise. + Raises: + TypeError: the input argument isn't a string + See also :meth:`is_integer_number`, :meth:`is_decimal_number`, :meth:`is_hexidecimal_integer_number`, :meth:`is_octal_integer_number`, etc... @@ -500,7 +512,7 @@ def is_binary_integer_number(in_str: str) -> bool: False """ if not is_string(in_str): - raise ValueError(in_str) + raise TypeError(in_str) return BIN_NUMBER_RE.match(in_str) is not None @@ -512,6 +524,9 @@ def to_int(in_str: str) -> int: Returns: The integral value of the string or raises on error. + Raises: + TypeError: the input argument isn't a string + See also :meth:`is_integer_number`, :meth:`is_decimal_number`, :meth:`is_hexidecimal_integer_number`, :meth:`is_octal_integer_number`, :meth:`is_binary_integer_number`, etc... @@ -528,9 +543,13 @@ def to_int(in_str: str) -> int: Traceback (most recent call last): ... ValueError: invalid literal for int() with base 10: 'test' + >>> to_int(123) + Traceback (most recent call last): + ... + TypeError: 123 """ if not is_string(in_str): - raise ValueError(in_str) + raise TypeError(in_str) if is_binary_integer_number(in_str): return int(in_str, 2) if is_octal_integer_number(in_str): @@ -550,6 +569,9 @@ def number_string_to_integer(in_str: str) -> int: Returns: The integer whose value was parsed from in_str. + Raises: + ValueError: unable to parse a chunk of the number string + See also :meth:`integer_to_number_string`. .. warning:: @@ -703,6 +725,9 @@ def add_thousands_separator( Returns: A numeric string with thousands separators added appropriately. + Raises: + ValueError: a non-numeric string argument is presented + >>> add_thousands_separator('12345678') '12,345,678' >>> add_thousands_separator(12345678) @@ -803,7 +828,7 @@ def is_email(in_str: Any) -> bool: head = head.replace(" ", "")[1:-1] return EMAIL_RE.match(head + "@" + tail) is not None - except ValueError: + except (TypeError, ValueError): # borderline case in which we have multiple "@" signs but the # head part is correctly escaped. if ESCAPED_AT_SIGN.search(in_str) is not None: @@ -910,6 +935,9 @@ def is_credit_card(in_str: Any, card_type: str = None) -> bool: Returns: True if in_str is a valid credit card number. + Raises: + KeyError: card_type is invalid + .. warning:: This code is not verifying the authenticity of the credit card (i.e. not checking whether it's a real card that can be charged); rather @@ -1259,6 +1287,9 @@ def contains_html(in_str: str) -> bool: True if the given string contains HTML/XML tags and False otherwise. + Raises: + TypeError: the input argument isn't a string + See also :meth:`strip_html`. .. warning:: @@ -1274,7 +1305,7 @@ def contains_html(in_str: str) -> bool: """ if not is_string(in_str): - raise ValueError(in_str) + raise TypeError(in_str) return HTML_RE.search(in_str) is not None @@ -1286,6 +1317,9 @@ def words_count(in_str: str) -> int: Returns: The number of words contained in the given string. + Raises: + TypeError: the input argument isn't a string + .. note:: This method is "smart" in that it does consider only sequences of one or more letter and/or numbers to be "words". Thus a @@ -1300,7 +1334,7 @@ def words_count(in_str: str) -> int: 4 """ if not is_string(in_str): - raise ValueError(in_str) + raise TypeError(in_str) return len(WORDS_COUNT_RE.findall(in_str)) @@ -1357,6 +1391,9 @@ def generate_random_alphanumeric_string(size: int) -> str: A string of the specified size containing random characters (uppercase/lowercase ascii letters and digits). + Raises: + ValueError: size < 1 + See also :meth:`asciify`, :meth:`generate_uuid`. >>> random.seed(22) @@ -1378,11 +1415,14 @@ def reverse(in_str: str) -> str: Returns: The reversed (chracter by character) string. + Raises: + TypeError: the input argument isn't a string + >>> reverse('test') 'tset' """ if not is_string(in_str): - raise ValueError(in_str) + raise TypeError(in_str) return in_str[::-1] @@ -1397,6 +1437,9 @@ def camel_case_to_snake_case(in_str: str, *, separator: str = "_"): original string if it is not a valid camel case string or some other error occurs. + Raises: + TypeError: the input argument isn't a string + See also :meth:`is_camel_case`, :meth:`is_snake_case`, and :meth:`is_slug`. >>> camel_case_to_snake_case('MacAddressExtractorFactory') @@ -1405,7 +1448,7 @@ def camel_case_to_snake_case(in_str: str, *, separator: str = "_"): 'Luke Skywalker' """ if not is_string(in_str): - raise ValueError(in_str) + raise TypeError(in_str) if not is_camel_case(in_str): return in_str return CAMEL_CASE_REPLACE_RE.sub(lambda m: m.group(1) + separator, in_str).lower() @@ -1425,6 +1468,9 @@ def snake_case_to_camel_case( provided or the original string back again if it is not valid snake case or another error occurs. + Raises: + TypeError: the input argument isn't a string + See also :meth:`is_camel_case`, :meth:`is_snake_case`, and :meth:`is_slug`. >>> snake_case_to_camel_case('this_is_a_test') @@ -1433,7 +1479,7 @@ def snake_case_to_camel_case( 'Han Solo' """ if not is_string(in_str): - raise ValueError(in_str) + raise TypeError(in_str) if not is_snake_case(in_str, separator=separator): return in_str tokens = [s.title() for s in in_str.split(separator) if is_full_string(s)] @@ -1529,6 +1575,9 @@ def strip_html(in_str: str, keep_tag_content: bool = False) -> str: A string with all HTML tags removed (optionally with tag contents preserved). + Raises: + TypeError: the input argument isn't a string + See also :meth:`contains_html`. .. note:: @@ -1543,7 +1592,7 @@ def strip_html(in_str: str, keep_tag_content: bool = False) -> str: 'test: click here' """ if not is_string(in_str): - raise ValueError(in_str) + raise TypeError(in_str) r = HTML_TAG_ONLY_RE if keep_tag_content else HTML_RE return r.sub("", in_str) @@ -1559,6 +1608,9 @@ def asciify(in_str: str) -> str: by translating all non-ascii chars into their closest possible ASCII representation (eg: ó -> o, Ë -> E, ç -> c...). + Raises: + TypeError: the input argument isn't a string + See also :meth:`to_ascii`, :meth:`generate_random_alphanumeric_string`. .. warning:: @@ -1568,7 +1620,7 @@ def asciify(in_str: str) -> str: 'eeuuooaaeynAAACIINOE' """ if not is_string(in_str): - raise ValueError(in_str) + raise TypeError(in_str) # "NFKD" is the algorithm which is able to successfully translate # the most of non-ascii chars. @@ -1599,6 +1651,9 @@ def slugify(in_str: str, *, separator: str = "-") -> str: * all chars are encoded as ascii (by using :meth:`asciify`) * is safe for URL + Raises: + TypeError: the input argument isn't a string + See also :meth:`is_slug` and :meth:`asciify`. >>> slugify('Top 10 Reasons To Love Dogs!!!') @@ -1607,7 +1662,7 @@ def slugify(in_str: str, *, separator: str = "-") -> str: 'monster-magnet' """ if not is_string(in_str): - raise ValueError(in_str) + raise TypeError(in_str) # replace any character that is NOT letter or number with spaces out = NO_LETTERS_OR_NUMBERS_RE.sub(" ", in_str.lower()).strip() @@ -1639,6 +1694,9 @@ def to_bool(in_str: str) -> bool: Otherwise False is returned. + Raises: + TypeError: the input argument isn't a string + See also :mod:`pyutils.argparse_utils`. >>> to_bool('True') @@ -1660,7 +1718,7 @@ def to_bool(in_str: str) -> bool: True """ if not is_string(in_str): - raise ValueError(in_str) + raise TypeError(in_str) return in_str.lower() in set(["true", "1", "yes", "y", "t", "on"]) @@ -1871,13 +1929,16 @@ def indent(in_str: str, amount: int) -> str: Returns: An indented string created by prepending amount spaces. + Raises: + TypeError: the input argument isn't a string + See also :meth:`dedent`. >>> indent('This is a test', 4) ' This is a test' """ if not is_string(in_str): - raise ValueError(in_str) + raise TypeError(in_str) line_separator = '\n' lines = [" " * amount + line for line in in_str.split(line_separator)] return line_separator.join(lines) @@ -2434,6 +2495,9 @@ def to_ascii(txt: str): Returns: txt encoded as an ASCII byte string. + Raises: + TypeError: the input argument isn't a string or bytes + See also :meth:`to_base64`, :meth:`to_bitstring`, :meth:`to_bytes`, :meth:`generate_random_alphanumeric_string`, :meth:`asciify`. @@ -2447,7 +2511,7 @@ def to_ascii(txt: str): return txt.encode('ascii') if isinstance(txt, bytes): return txt - raise Exception('to_ascii works with strings and bytes') + raise TypeError('to_ascii works with strings and bytes') def to_base64( diff --git a/src/pyutils/text_utils.py b/src/pyutils/text_utils.py index 6cf6411..f696c59 100644 --- a/src/pyutils/text_utils.py +++ b/src/pyutils/text_utils.py @@ -49,6 +49,9 @@ def get_console_rows_columns() -> RowsColumns: Returns: The number of rows/columns on the current console or None if we can't tell or an error occurred. + + Raises: + Exception: if the console size can't be determined. """ from pyutils.exec_utils import cmd @@ -172,6 +175,9 @@ def bar_graph_string( left_end: the character at the left side of the graph right_end: the character at the right side of the graph + Raises: + ValueError: if percentage is invalid + See also :meth:`bar_graph`, :meth:`sparkline`. >>> bar_graph_string(5, 10, fgcolor='', reset_seq='') @@ -325,6 +331,9 @@ def justify_string( * 'r' = right alignment padding: the padding character to use while justifying + Raises: + ValueError: if alignment argument is invalid. + >>> justify_string('This is another test', width=40, alignment='c') ' This is another test ' >>> justify_string('This is another test', width=40, alignment='l') @@ -349,7 +358,7 @@ def justify_string( else: string = padding + string else: - raise ValueError + raise ValueError('alignment must be l, r, j, or c.') return string diff --git a/src/pyutils/typez/centcount.py b/src/pyutils/typez/centcount.py index a17060d..81348ba 100644 --- a/src/pyutils/typez/centcount.py +++ b/src/pyutils/typez/centcount.py @@ -70,16 +70,19 @@ class CentCount(object): currency: optionally declare the currency being represented by this instance. If provided it will guard against operations such as attempting to add it to non-matching currencies. - strict_mode: if True, the instance created will object if you - compare or aggregate it with non-CentCount objects; that is, + strict_mode: if True, the instance created will object (raise) if + compared or aggregated with non-CentCount objects; that is, strict_mode disallows comparison with literal numbers or aggregation with literal numbers. + + Raises: + ValueError: invalid money string passed in """ self.strict_mode = strict_mode if isinstance(centcount, str): ret = CentCount._parse(centcount) if ret is None: - raise Exception(f'Unable to parse money string "{centcount}"') + raise ValueError(f'Unable to parse money string "{centcount}"') centcount = ret[0] currency = ret[1] if isinstance(centcount, float): @@ -108,6 +111,11 @@ class CentCount(object): return CentCount(centcount=-self.centcount, currency=self.currency) def __add__(self, other): + """ + Raises: + TypeError: if addend is not compatible or the object is in strict + mode and the addend is not another CentCount. + """ if isinstance(other, CentCount): if self.currency == other.currency: return CentCount( @@ -123,6 +131,11 @@ class CentCount(object): return self.__add__(CentCount(other, self.currency)) def __sub__(self, other): + """ + Raises: + TypeError: if amount is not compatible or the object is in strict + mode and the amount is not another CentCount. + """ if isinstance(other, CentCount): if self.currency == other.currency: return CentCount( @@ -139,6 +152,9 @@ class CentCount(object): def __mul__(self, other): """ + Raises: + TypeError: if factor is not compatible. + .. note:: Multiplication and division are performed by converting the @@ -172,6 +188,9 @@ class CentCount(object): def __truediv__(self, other): """ + Raises: + TypeError: the divisor is not compatible + .. note:: Multiplication and division are performed by converting the @@ -212,6 +231,11 @@ class CentCount(object): __radd__ = __add__ def __rsub__(self, other): + """ + Raises: + TypeError: amount is not compatible or, if the object is in + strict mode, the amount is not a CentCount. + """ if isinstance(other, CentCount): if self.currency == other.currency: return CentCount( @@ -235,6 +259,10 @@ class CentCount(object): # Override comparison operators to also compare currency. # def __eq__(self, other): + """ + Raises: + TypeError: In strict mode and the other object isn't a CentCount. + """ if other is None: return False if isinstance(other, CentCount): @@ -251,6 +279,11 @@ class CentCount(object): return not result def __lt__(self, other): + """ + Raises: + TypeError: amounts have different currencies or, if this object + is in strict mode, the amount must be a CentCount. + """ if isinstance(other, CentCount): if self.currency == other.currency: return self.centcount < other.centcount @@ -263,6 +296,11 @@ class CentCount(object): return self.centcount < int(other) def __gt__(self, other): + """ + Raises: + TypeError: amounts have different currencies or, if this object + is in strict mode, the amount must be a CentCount. + """ if isinstance(other, CentCount): if self.currency == other.currency: return self.centcount > other.centcount @@ -313,11 +351,14 @@ class CentCount(object): Args: s: the string to be parsed + + Raises: + ValueError: input string cannot be parsed. """ chunks = CentCount._parse(s) if chunks is not None: return CentCount(chunks[0], chunks[1]) - raise Exception(f'Unable to parse money string "{s}"') + raise ValueError(f'Unable to parse money string "{s}"') if __name__ == "__main__": diff --git a/src/pyutils/typez/histogram.py b/src/pyutils/typez/histogram.py index bcb1c5f..8de88bd 100644 --- a/src/pyutils/typez/histogram.py +++ b/src/pyutils/typez/histogram.py @@ -79,13 +79,16 @@ class SimpleHistogram(Generic[T]): buckets we are counting population in. See also :meth:`n_evenly_spaced_buckets` to generate these buckets more easily. + + Raises: + ValueError: buckets overlap """ from pyutils.math_utils import NumericPopulation self.buckets: Dict[Tuple[Bound, Bound], Count] = {} for start_end in buckets: if self._get_bucket(start_end[0]) is not None: - raise Exception("Buckets overlap?!") + raise ValueError("Buckets overlap?!") self.buckets[start_end] = 0 self.sigma: float = 0.0 self.stats: NumericPopulation = NumericPopulation() @@ -109,11 +112,14 @@ class SimpleHistogram(Generic[T]): Returns: A list of bounds that define N evenly spaced buckets + + Raises: + ValueError: min is not < max """ ret: List[Tuple[int, int]] = [] stride = int((max_bound - min_bound) / n) if stride <= 0: - raise Exception("Min must be < Max") + raise ValueError("Min must be < Max") imax = math.ceil(max_bound) imin = math.floor(min_bound) for bucket_start in range(imin, imax, stride): diff --git a/src/pyutils/typez/money.py b/src/pyutils/typez/money.py index 5eafd63..2cb9d3c 100644 --- a/src/pyutils/typez/money.py +++ b/src/pyutils/typez/money.py @@ -43,12 +43,15 @@ class Money(object): strict_mode: if True, disallows comparison or arithmetic operations between Money instances and any non-Money types (e.g. literal numbers). + + Raises: + ValueError: unable to parse a money string """ self.strict_mode = strict_mode if isinstance(amount, str): ret = Money._parse(amount) if ret is None: - raise Exception(f'Unable to parse money string "{amount}"') + raise ValueError(f'Unable to parse money string "{amount}"') amount = ret[0] currency = ret[1] if not isinstance(amount, Decimal): @@ -90,6 +93,11 @@ class Money(object): return Money(amount=-self.amount, currency=self.currency) def __add__(self, other): + """ + Raises: + TypeError: attempt to add incompatible amounts or, if in strict + mode, attempt to add a Money with a literal. + """ if isinstance(other, Money): if self.currency == other.currency: return Money(amount=self.amount + other.amount, currency=self.currency) @@ -105,14 +113,19 @@ class Money(object): ) def __sub__(self, other): + """ + Raises: + TypeError: attempt to subtract incompatible amounts or, if in strict + mode, attempt to add a Money with a literal. + """ if isinstance(other, Money): if self.currency == other.currency: return Money(amount=self.amount - other.amount, currency=self.currency) else: - raise TypeError("Incompatible currencies in add expression") + raise TypeError("Incompatible currencies in sibtraction expression") else: if self.strict_mode: - raise TypeError("In strict_mode only two moneys can be added") + raise TypeError("In strict_mode only two moneys can be subtracted") else: return Money( amount=self.amount - Decimal(float(other)), @@ -120,6 +133,10 @@ class Money(object): ) def __mul__(self, other): + """ + Raises: + TypeError: attempt to multiply two Money objects. + """ if isinstance(other, Money): raise TypeError("can not multiply monetary quantities") else: @@ -129,6 +146,10 @@ class Money(object): ) def __truediv__(self, other): + """ + Raises: + TypeError: attempt to divide two Money objects. + """ if isinstance(other, Money): raise TypeError("can not divide monetary quantities") else: @@ -211,6 +232,11 @@ class Money(object): __radd__ = __add__ def __rsub__(self, other): + """ + Raises: + TypeError: attempt to subtract incompatible amounts or, if in strict + mode, attempt to add a Money with a literal. + """ if isinstance(other, Money): if self.currency == other.currency: return Money(amount=other.amount - self.amount, currency=self.currency) @@ -231,6 +257,11 @@ class Money(object): # Override comparison operators to also compare currency. # def __eq__(self, other): + """ + Raises: + TypeError: in strict mode, an attempt to compare a Money with a + non-Money object. + """ if other is None: return False if isinstance(other, Money): @@ -247,6 +278,10 @@ class Money(object): return not result def __lt__(self, other): + """ + TypeError: attempt to compare incompatible amounts or, if in strict + mode, attempt to compare a Money with a literal. + """ if isinstance(other, Money): if self.currency == other.currency: return self.amount < other.amount @@ -259,6 +294,10 @@ class Money(object): return self.amount < Decimal(float(other)) def __gt__(self, other): + """ + TypeError: attempt to compare incompatible amounts or, if in strict + mode, attempt to compare a Money with a literal. + """ if isinstance(other, Money): if self.currency == other.currency: return self.amount > other.amount @@ -309,11 +348,14 @@ class Money(object): Args: s: the string to parse + + Raises: + ValueError: unable to parse a string """ chunks = Money._parse(s) if chunks is not None: return Money(chunks[0], chunks[1]) - raise Exception(f'Unable to parse money string "{s}"') + raise ValueError(f'Unable to parse money string "{s}"') if __name__ == "__main__": diff --git a/src/pyutils/typez/persistent.py b/src/pyutils/typez/persistent.py index 4776260..f577bd8 100644 --- a/src/pyutils/typez/persistent.py +++ b/src/pyutils/typez/persistent.py @@ -184,6 +184,10 @@ class PicklingFileBasedPersistent(FileBasedPersistent): def load( cls: type[PicklingFileBasedPersistent], ) -> Optional[PicklingFileBasedPersistent]: + """ + Raises: + Exception: failure to load from file. + """ filename = cls.get_filename() if cls.should_we_load_data(filename): logger.debug("Attempting to load state from %s", filename) @@ -202,6 +206,10 @@ class PicklingFileBasedPersistent(FileBasedPersistent): @overrides def save(self) -> bool: + """ + Raises: + Exception: failure to save to file. + """ filename = self.get_filename() if self.should_we_save_data(filename): logger.debug("Trying to save state in %s", filename) @@ -266,6 +274,10 @@ class JsonFileBasedPersistent(FileBasedPersistent): @classmethod @overrides def load(cls: type[JsonFileBasedPersistent]) -> Optional[JsonFileBasedPersistent]: + """ + Raises: + Exception: failure to load from file. + """ filename = cls.get_filename() if cls.should_we_load_data(filename): logger.debug("Trying to load state from %s", filename) @@ -295,6 +307,10 @@ class JsonFileBasedPersistent(FileBasedPersistent): @overrides def save(self) -> bool: + """ + Raises: + Exception: failure to save to file. + """ filename = self.get_filename() if self.should_we_save_data(filename): logger.debug("Trying to save state in %s", filename) diff --git a/src/pyutils/typez/rate.py b/src/pyutils/typez/rate.py index f9842bd..e68fd21 100644 --- a/src/pyutils/typez/rate.py +++ b/src/pyutils/typez/rate.py @@ -35,6 +35,10 @@ class Rate(object): percentage: provides the multiplier as a percentage percent_change: provides the multiplier as a percent change to the base amount + + Raises: + ValueError: if more than one of percentage, percent_change and + multiplier is provided """ count = 0 if multiplier is not None: @@ -53,7 +57,7 @@ class Rate(object): self.multiplier = 1.0 + percent_change / 100 count += 1 if count != 1: - raise Exception( + raise ValueError( "Exactly one of percentage, percent_change or multiplier is required." ) diff --git a/src/pyutils/typez/type_utils.py b/src/pyutils/typez/type_utils.py index e648fa0..d4fcb7b 100644 --- a/src/pyutils/typez/type_utils.py +++ b/src/pyutils/typez/type_utils.py @@ -23,6 +23,9 @@ def unwrap_optional(x: Optional[Any]) -> Any: If the Optional[Type] argument is None, however, raise an exception. + Raises: + AssertionError: the parameter is, indeed, of NoneType. + >>> x: Optional[bool] = True >>> unwrap_optional(x) True -- 2.47.1