Source code for navmazing

"""A simplified navigation framework with prerequisite, object and intelligence support.

An example is below::

    from navmazing import Navigate, NavigateStep, NavigateToSibling

    navigate = Navigate()

    class Provider(object):
        def __init__(self, name):
            self.name = name


    @navigate.register(Provider, 'New')
    class AddANewProvider(NavigateStep)
        prerequisite = NavigateToSibling('All')

        def step(self):
            click('Add New Button')

    @navigate.register(Provider, 'All')
    class ShowAllProviders(NavigateStep)
        def am_i_here(self):
            return check_if_i_am_already_on_page()

        def step(self):
            click('All button')

"""
import inspect
from operator import attrgetter


class NavigationDestinationNotFound(Exception):
    """Simple Exception when navigations can't be found"""
    def __init__(self, name, cls, possibilities):
        self.name = name
        self.cls = cls
        self.possibilities = possibilities

    def __str__(self):
        return ("Couldn't find the destination [{}] with the given class [{}]"
            " the following were available [{}]").format(
            self.name, self.cls, ", ".join(sorted(list(self.possibilities))))


class NavigationTriesExceeded(Exception):
    """Simple Exception when navigations can't be found"""
    def __init__(self, name):
        self.name = name

    def __str__(self):
        return "Navigation failed to reach [{}] in the specificed tries".format(
            self.name)


class Navigate(object):
    def __init__(self):
        """Initializes the destination dictionary for the Navigate object """
        self.dest_dict = {}

    def register(self, cls, name=None):
        """Decorator that registers a class with an optional name"""
        def f(obj):
            """This is part of the decorator class

            This function is returned and run with the class it decorates as the obj argument.
            The destination name is either the supplied name, or the class name of the NavigateStep
            object.
            """
            destination_name = name or obj.__name__
            obj._name = destination_name
            self.dest_dict[cls, destination_name] = obj
            return obj
        return f

    def get_class(self, cls_or_obj, name):
        cls = type(cls_or_obj) if not inspect.isclass(cls_or_obj) else cls_or_obj
        for class_ in cls.__mro__:
            try:
                nav = self.dest_dict[class_, name]
            except KeyError:
                continue
            else:
                break
        else:
            raise NavigationDestinationNotFound(name, cls.__name__,
                self.list_destinations(cls))

        return nav

    def navigate(self, cls_or_obj, name, *args, **kwargs):
        """This function performs the navigation

        We first determine if we have a class of an instance and find the class
        either way. We then walk the MRO for the class and attempt to find a matching
        destination name in the dest_dict. KeyErrors are expected and accepted. This
        allows us to override a destination in a subclass if we so desire, as the MRO
        walk means we will always go to the overridden version first.

        In any case, we instantiate the NavigateStep object there and then with the
        information we have been given, namely the object that we are using as context
        and this Navigate object. We next try to run the .go() method of the NavigateStep object

        If we exhaust the MRO and we have still not found a match, we raise an exception.
        """
        nav = self.get_class(cls_or_obj, name)
        return nav(cls_or_obj, self).go(0, *args, **kwargs)

    def list_destinations(self, cls_or_obj):
        """Lists all available destinations for a given object

        This function lists all available destinations for a given object. If the object
        overrides a destination, only the overridden one will be displayed.
        """
        destinations = set()
        cls = type(cls_or_obj) if not isinstance(cls_or_obj, type) else cls_or_obj
        for _class in cls.__mro__[::-1]:
            for the_class, name in self.dest_dict:
                if the_class == _class:
                    destinations.add(name)
        return destinations


class NavigateToObject(object):
    """This is a helper descriptor for navigation destinations which are on another class/object.

    For instance, imagine you have a different object that has a 'ViewAll', destination that
    needs to be visited before you can click on 'New'. In this instance, you would need to make the
    'New' destination use 'ViewAll' as a prerequisite. As this would need no other special
    input, we can use NavigateToObject as a helper. This will set prerequisite to be a
    callable that will navigate to the prerequisite step on the other object.
    """
    def __init__(self, other_obj, target, obj=None):
        self.target = target
        self.obj = obj
        self.other_obj = other_obj

    def __get__(self, obj, owner):
        if self.obj is None:
            return type(self)(self.other_obj, self.target, obj or owner)
        else:
            return self

    def __call__(self):
        return self.obj.navigate_obj.navigate(self.other_obj, self.target)


class NavigateToSibling(object):
    """This is a helper descriptor for navigation destinations which are linked to the same class.

    For instance, imagine you have an object that has a 'ViewAll', destination that needs to
    be visited before you can click on 'New'. In this instance, you would need to make the
    'New' destination use 'ViewAll' as a prerequisite. As this would need no other special
    input, we can use NavigateToSibling as a helper. This will set prerequisite to be a
    callable that will navigate to the prerequisite step.
    """
    def __init__(self, target, obj=None):
        self.target = target
        self.obj = obj

    def __get__(self, obj, owner):
        if self.obj is None:
            return type(self)(self.target, obj or owner)
        else:
            return self

    def __call__(self):
        return self.obj.navigate_obj.navigate(self.obj.obj, self.target)


class NavigateToAttribute(object):
    """This is a helper descriptor for destinations which are linked to an attribute of the object.

    For instance, imagine you have an object that has an attribute(parent) which has a 'ViewAll',
    destination that needs to be visited before you can click on 'New'. In this instance,
    you would need to make the 'New' destination use 'ViewAll' as a prerequisite. As this
    would need no other special input, we can use NavigateToAttribute as a helper, supplying
    only the name of the attribute which stores the object to be used in the navigation,
    and the destination name. This will set prerequisite to be a callable that will navigate
    to the prerequisite step.
    """
    def __init__(self, attr_name, target, obj=None):
        self.target = target
        self.obj = obj
        self.attr_name = attr_name
        self._get_attr = attrgetter(attr_name)

    def __get__(self, obj, owner):
        if self.obj is None:
            return type(self)(self.attr_name, self.target, obj or owner)
        else:
            return self

    def __call__(self):
        attr = self._get_attr(self.obj.obj)
        return self.obj.navigate_obj.navigate(attr, self.target)


class NavigateStep(object):
    """A Navigation Step object

    The NavigateStep object runs through several key stages
    1) It checks to see if we are already at that navigation step, if so, we return
    2) It runs the prerequisite to see if there is a step that is required to be run
       before this one.
    3) It runs the step function to navigate to the current step after the prerequisite has been
       completed
    """
    _default_tries = 3

    def __init__(self, obj, navigate_obj):
        """ NavigateStep object.

        A NavigateStep object should always recieve the object it is linked to
        and this is stored in the obj attribute. The navigate_obj is the Navigate() instance
        that the destination is registered against. This allows it to navigate inside pre-requisites
        using the NavigateToSibling and NavigateToAttribute helpers described above.
        """
        self.obj = obj
        self.navigate_obj = navigate_obj

    def am_i_here(self, *args, **kwargs):
        """Describes if the navigation is already at the requested destination.

        This is a default and is generally overridden.
        """
        return False

    def resetter(self, *args, **kwargs):
        """Describes any steps required to reset the view after navigating or if already there.

        This is a default and is generally overridden.
        """
        pass

    def prerequisite(self, *args, **kwargs):
        """Describes a step that must be carried our prior to this one.

        This often calls a previous navigate_to, often using one of the helpers, NavigateToSibling
        which will navigate to a given destination using the same object, or NavigateToAttribute
        which will navigate to a destination against an object describe by the attribute of the
        parent object.

        This is a default and is generally overridden.
        """
        pass

    def step(self, *args, **kwargs):
        """Describes the work to be done to get to the destination after the prequisite is met.

        This is a default and is generally overridden.
        """
        return

    def do_nav(self, _tries, *args, **kwargs):
        """Describes how the navigation should take place."""
        try:
            self.step(*args, **kwargs)
        except:
            self.go(_tries, *args, **kwargs)

    def pre_navigate(self, _tries, *args, **kwargs):
        """Describes steps that takes place before any prerequisite or navigation takes place.

        This is a default and is generally overridden.
        """
        if _tries > self._default_tries:
            raise NavigationTriesExceeded(self._name)
        else:
            return

    def post_navigate(self, _tries, *args, **kwargs):
        """Describes steps that takes place before any prerequisite after navigation takes place.

        This is a default and is generally overridden.
        """
        return

    def go(self, _tries=0, *args, **kwargs):
        """Describes the flow of navigation."""
        _tries += 1
        self.pre_navigate(_tries, *args, **kwargs)
        print("NAVIGATE: Checking if already at {}".format(self._name))
        here = False
        try:
            here = self.am_i_here(*args, **kwargs)
        except Exception as e:
            print("NAVIGATE: Exception raised [{}] whilst checking if already at {}".format(
                e, self._name))
        if here:
            print("NAVIGATE: Already at {}".format(self._name))
        else:
            print("NAVIGATE: I'm not at {}".format(self._name))
            self.parent = self.prerequisite(*args, **kwargs)
            print("NAVIGATE: Heading to destination {}".format(self._name))
            self.do_nav(_tries, *args, **kwargs)
        self.resetter(*args, **kwargs)
        self.post_navigate(_tries, *args, **kwargs)


navigate = Navigate()