from typing import Dict
from typing import Any
import importlib


def lazily_import(name: str, package: str, mapping: Dict[str, str]) -> Any:
    """Lazily imports modules and items within them.
    
    Lazy importing means that modules are only imported when they are first
    accessed. This can save memory and keep namespaces decluttered.
    
    This code is adapted from PEP 562: https://www.python.org/dev/peps/pep-0562/
    which outlines how the decision to incorporate '__getattr__' functions to 
    modules allows lazy loading. Rather than place this function solely within
    '__getattr__', it is included here seprately so that it can easily be called 
    by '__init__.py' files throughout amicus and by users (as 
    'amicus.lazily_import').
    
    To effictively use 'lazily_import' in an '__init__.py' file, the user needs
    to also include an 'importables' dict which indicates how users should
    accesss imported modules and included items. This modules includes such an
    example 'importables' dict and how to easily add this function to a 
    '__getattr__' function.
    
    Instead of limiting its lazy imports to full import paths as the example in 
    PEP 562, this function has 2 major advantages:
        1) It allows importing items within modules and not just modules. The
            function first tries to import 'name' assuming it is a module. But 
            if that fails, it parses the last portion of 'name' and attempts to 
            import the preceding module and then returns the item within it.
        2) It allows import paths that are less than the full import path by
            using the 'importables' dict. 'importables' has keys which are the
            name of the attribute being sought and values which are the full
            import path (dropping the leading '.'). 'importables' thus acts
            as the normal import block in an __init__.py file but insures that
            all importing is done lazily.
            
    Args:
        name (str): name of module or item within a module.
        package (str): name of package from which the module is sought.
        mapping (Dict[str, str]): keys are the access names for items sought
            and values are the import path where the item is actually located.
        
    Raises:
        AttributeError: if there is no module or item matching 'name' im 
            'mapping'.
        
    Returns:
        Any: a module or item stored within a module.
        
    """
    if name in mapping:
        key = '.' + mapping[name]
        try:
            return importlib.import_module(key, package = package)
        except ModuleNotFoundError:
            item = key.split('.')[-1]
            module_name = key[:-len(item) - 1]
            module = importlib.import_module(module_name, package = package)
            return getattr(module, item)
    raise AttributeError(f'module {package} has no attribute {name}')