Search code examples
pythonpython-3.xexceptionstack-traceraise

Cleaning up interal path and stack levels in CustomException messages raised


Since we are raising, not excepting the CustomException, I have to learn for new stuff on handing a stacktrace that exists not as a raised except but as the exception that will be raised, if that makes sense. I just want to get rid of the CustomException's internal and the handler raiser information and only show information relevant to the caller that called the handler that raised the exception.

I'm struggling a little with cleaning up my Custom Exception's stack trace. Because this Custom exception will offer early typo and incorrect coding, I want to clean up it's message and stack trace to not include references to internal module path and function / method levels. FE. rather then showing "variable expects types.List[int]", I want to to show "variable expects List[int].". But that particular enhancement is not what I am struggling with. The cleanup enhancement I am struggling with and asking for help with is this: rather that showing:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<cwd>/fibonacci.py", line 67, in fib
    raise ArgumentError("index", (int, List[int], Tuple[int,int]),
my_custom_modules.my_custom_exceptions.argumenterror.ArgumentError: index expects (<class 'int'>, typing.List[int],   
typing.Tuple[int, int]) but found (0, 1, 2)

I wish it to more elegantly show:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<cwd>/fibonacci.py", line 67, in fib
    raise ArgumentError("index", (int, List[int], Tuple[int,int]),
ArgumentError: index expects (int, list[int], tuple[int, int]) but found (0, 1, 2)

Notice the module structure is reduced to only the Exception class name only.

So I have reduced and simplified the code to make it easier to weed through but to illustrate the problem I still have to keep a directory structure. Here are links for 3 files, 1 is this text and the other 2 are the code sections shown below.

https://gist.github.com/ismaelharunid/88dd8a246ac42203312b14fe1874f60f/raw/6af13d6c798506c99cbeb68ef457a80da5e153a2/ArgumentError_readme.MD

https://gist.github.com/ismaelharunid/7ef52774d887a4aadc328bb8d08a9fb5/raw/3f3dde00cbe170bf96146964ca0b73d7355d0128/ArgumentError_argumenterror.py

https://gist.githubusercontent.com/ismaelharunid/6a19968b737f360a80bf9a0fb1b8f060/raw/b7bad77c261f9ce5d17b13d6d53f8a409dc08cde/ArgumentError_fibonacci.py

The custom exception code:

#./my_custom_modules/my_custom_exceptions/argumenterror.py

from types import GenericAlias


class ArgumentError(ValueError):
    '''
    A substitution for ValueError specific for function and method
    argument variable annotations which reduces the need for
    repetitive validation code and message specing.

    Parameters:
    ===========
        name (:str)
            The guilty variable argument name.
        expects (:type, Generic, [type, Generic])
            Annotations for the expected guilty variable value.
        found (:Any)
            The actual value of the guilty variable is question.
        *specs (:*Any)
            addition line specs.
        **variables (:**Any)
            additional variables to make available to the specs.
    '''

    MessageSpec = "{name} expects {expects!r} but found {found!r}"

    def __new__(cls, name, expects, found, *specs, **variables):
        "see help(ArgumentError) for correct annotations."
        return super().__new__(cls)

    def __init__(self, name, expects, found, *specs, **variables):
        "see help(ArgumentError) for correct annotations."
        expects_ = self.__expects__(expects)
        message = self.__message__(name=name,
                                   expects=expects_,
                                   found=found,
                                   **variables)
        if specs:
            details = tuple(self.__detail__(spec,
                                            name=name,
                                            expects=expects_,
                                            found=found,
                                            **variables)
                                for spec in specs)
            self.__tbinit__(message, details)
        else:
            self.__tbinit__(message)

    def __expects__(self, expects, _depth=0):
        '''
        internal expects formatting method.
        strip "typing." and ("<class ", "'>"), and other extreme
        details to keep message sweeter.  oh well, next version.
        for now let's keep it simple and easily readable.
        '''
        return expects

    def __message__(self, **variables):
        "internal message formatting method"
        return self.MessageSpec.format(**variables)

    def __detail__(self, spec, **variables):
        "internal extra message lines formatting method"
        return spec.format(**variables)

    def __tbinit__(self, *lines):
        "internal preprocessor to allow stack and message cleanup"
        super().__init__(*lines)

The usage module code:

'''
./fibonacci.py

A fibonacci sequence generator, mostly for annotation demonstration
purposes.  Includes a single function fib.  See function fib for usage
documentation.

Examples:
=========

from fibonacci import fib

fib(3)  # -> 2
fib(-4)  # -> -3
fib(-5)  # -> 5
fib((-6, 6)) # -> (-8, 5, -3, 2, -1, 1, 0, 1, 1, 2, 3, 5, 8)
fib([-7]) # -> (13, 13)
fib([-8, 8]) # -> (-21, 21)
fib([9, -10, 11]) # -> (34, -55, 89)

raises ArgumentError:
=====================
fib(9, -10)
#ArgumentError: cache expects list[int] but found -10

fib(())
#ArgumentError: index expects (int, list[int], tuple[int, int]) but found ()

fib((0,))
#ArgumentError: index expects (int, list[int], tuple[int, int]) but found (0,)

fib((0,1,2))
#ArgumentError: index expects (int, list[int], tuple[int, int]) but found (0, 1, 2)
'''

from typing import List, Tuple

from my_custom_modules.my_custom_exceptions.argumenterror \
        import ArgumentError


def fib(index:[int, Tuple[int,int, List[int]]],
               cache:List[int]=[0, 1]):
    '''
    Returns the nth(index) or sequence of fibonacci number(s).

    Parameters:
    ===========
        index :(int | tuple[int, int] | list[*int])
            The index or index range (inclusive) of fibonacci number(s)
            to return.
        cache :(list[int])
            For caching purposes only, not for use as a parameter,
            but you can always use it to force regeneration but
            just be sure you use [0, 1].  Other values would render a
            custom sequence and may not handle negative indexes
            correctly.  It's not a global variable simply to help
            support the example.  Yeah a bit OCD!
    '''
    if not (isinstance(index, int)
            or (isinstance(index, list)
                and all(isinstance(i, int) for i in index))
            or (isinstance(index, tuple)
                and len(index) == 2
                and all(isinstance(i, int) for i in index))):
        raise ArgumentError("index", (int, List[int], Tuple[int,int]),
                            index)
    if not (isinstance(cache, list)
            and len(cache) >= 2
            and all(isinstance(i, int) for i in cache)):
        raise ArgumentError("cache", list, cache)

    single = isinstance(index, int)
    m = abs(index) if single else max(abs(v) for v in index)
    while m >= len(cache):
        cache.append(sum(cache[-2:]))
    if single:
        return cache[abs(index)] if index >= 0 or index % 2 else \
                -cache[-index]
    if isinstance(index, list):
        return tuple(cache[abs(i)] 
                     if i >= 0 or i % 2 else
                     -cache[-i]
                     for i in index)
    return tuple(cache[abs(i)] 
                 if i >= 0 or i % 2 else
                 -cache[abs(i)]
                 for i in range(index[0], index[1] + 1))

And finally the testcase code:

from fibonacci import fib

fib(3)  # -> 2
fib(-4)  # -> -3
fib(-5)  # -> 5
fib((-6, 6)) # -> (-8, 5, -3, 2, -1, 1, 0, 1, 1, 2, 3, 5, 8)
fib([-7]) # -> (13, 13)
fib([-8, 8]) # -> (-21, 21)
fib([9, -10, 11]) # -> (34, -55, 89)
fib(9, -10)
#ArgumentError: cache expects list[int] but found -10

fib(())
#ArgumentError: index expects (int, list[int], tuple[int, int]) but found ()

fib((0,))
#ArgumentError: index expects (int, list[int], tuple[int, int]) but found (0,)

fib((0,1,2))
#ArgumentError: index expects (int, list[int], tuple[int, int]) 
but found (0, 1, 2)

Solution

  • Well, I guess I was over ambitious and it was just not even a good idea. So I scaled back to the minimum requirements for what I wanted to accomplish. Basically I find myself spending too much time writing argument checks and it slows me down and even sometimes causes me to loose focus. So, I rethought it and came up with this simple solution.

    # ./expects.py
    
    from typing import *
    from collections import abc as cabc
    
    
    NoneType = type(None)
    
    def _expects(typing, depth=None, _depth=0):
        if depth is not None and _depth >= depth:
            return "..."
        if typing is type(None):
            return "None"
        if isinstance(typing, type):
            return typing.__name__
        origin = get_origin(typing)
        sep, args = ",", None
        if origin:
            args = get_args(typing)
            name = origin.__name__ if isinstance(origin, type) else \
                    origin._name
            if typing._inst:
                sep = '|'
        elif isinstance(typing, cabc.Sequence):
            name, sep, args = "", "|", typing
        elif callable(typing):
            name = typing.__name__
        else:
            name = repr(typing)
        if args:
            items = sep.join(_expects(e, depth, _depth+1) for e in args) \
                    if depth is None or _depth+1 < depth else \
                    "..."
            return "{:}[{:}]".format(name, items)
        return name
    
    __EXPECTS_CACHE__ = {}
    
    def expects(method, name, found, depth=None, cache=True):
        typing = get_type_hints(method)[name]
        hashkey = (tuple(typing) if isinstance(typing, list) else
                   typing, depth) # because list is unhashable
        expects = None
        if cache:
            try:
                expects = __EXPECTS_CACHE__[hashkey]
            except KeyError:
                pass
        elif cache is None:
            __EXPECTS_CACHE__.clear()
        if expects is None:
            expects = _expects(typing, depth)
            if cache:
                __EXPECTS_CACHE__[hashkey] = expects
        return "{name} expects {expects} but found {found!r}" \
                .format(name=name, expects=expects, found=found)
    
    class ArgumentError(ValueError):
    
        def __new__(cls, method, name, found, depth=None):
            return super().__new__(cls)
    
        def __init__(self, method, name, found, depth=None):
            super().__init__(expects(method, name, found, depth))
    

    The usage is simple and I will doc out the functions after I apply a little polish and testing. But basically you just pass 3 arguments to Argumenterror, which are the , and the , and it creates a nice short information exception. Or alternatively you can pass expects the same arguments to get the message only. Short sweet and fairly light. here is an example usage:

    >>> from expects import *
    >>> def foo(n:[int,Tuple[int,int]]):
    ...     if not (isinstance(n, int) or (isinstance(n, tuple) and len(n) == 2)):
    ...         raise ArgumentError(foo, "n", n)
    ... 
    >>> foo(None)
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
      File "<stdin>", line 3, in foo
    expects.ArgumentError: n expects [int|tuple[int,int]] but found None
    >>> 
    

    Alternatively I could wring a code generators to do the type hinting to arguments checking / validation, that would be sort of cool. But doing dynamic hinting to argument checking is just going to be a drain and slow doen the code especially for functions and methods that get called often or in loops. So that is now off the board. But yeah a code generator to write custom checks would run once and either make a .py file or cache it. Maybe I will try implementing that at some future time using some of the stuff I learned one my earlier implementation.