# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_core.ipynb.

# %% auto 0
__all__ = ['DOT', 'NIL', 'GET', 'ATTR', 'BASES', 'CHECK', 'GUARD', 'RETHAS', 'RETSELF', 'DEFAULT', 'CHECKOBJ', 'GUARDOBJ',
           'NAMESPACE', 'CLASSVARS', 'ASSIGNED', 'T', 'O', 'GuardLike', 'iscall', 'isnone', 'notnone', 'isstr',
           'allstrs', 'istuple', 'insattr', 'astrmeta', 'astr', 'attr', 'attrstr']

# %% ../nbs/00_core.ipynb 6
from abc import abstractmethod
from functools import wraps

# %% ../nbs/00_core.ipynb 8
from typing import (
    Any, Self, Type, Union, TypeVar, TypeAlias, TypeGuard, ClassVar, Callable, Optional, Iterable,
)

# %% ../nbs/00_core.ipynb 10
#| export


# %% ../nbs/00_core.ipynb 12
#| export


# %% ../nbs/00_core.ipynb 14
#| export


# %% ../nbs/00_core.ipynb 16
DOT = '.'
NIL = ''

GET = 'get'
ATTR = 'attr' 

BASES = 'bases' 
CHECK = 'check'
GUARD = 'guard'

RETHAS = 'rethas' 
RETSELF = 'retself'

DEFAULT = 'default'

CHECKOBJ = 'checkobj'
GUARDOBJ = 'guardobj'

NAMESPACE = 'namespace'

__DOC__ = '__doc__' 
__NAME__ = '__name__'
__MODULE__ = '__module__'
__QUALNAME__ = '__qualname__'
__ANNOTATIONS__ = '__annotations__'


CLASSVARS = (ATTR, CHECK, GUARD, CHECKOBJ, GUARDOBJ, RETSELF, DEFAULT)
ASSIGNED = (__MODULE__, __DOC__, __ANNOTATIONS__)

# %% ../nbs/00_core.ipynb 18
T = TypeVar('T')
O = TypeVar('O')
GuardLike: TypeAlias = Callable[[O], TypeGuard[T]]
'''TypeAlias for a callable that takes an object of type O and returns a TypeGuard for type T.''';

# %% ../nbs/00_core.ipynb 20
def iscall(x) -> TypeGuard[Callable]:
    '''Check if `x` is `Callable`.'''
    return isinstance(x, Callable)

def isnone(x) -> TypeGuard[None]:
    '''Check if `x` is `None`.'''
    return x is None

def notnone(x) -> TypeGuard[Any]:
    '''Check if `x` is not `None`.'''
    return not isnone(x)

def isstr(x) -> TypeGuard[str]:
    '''Check if `x` is `str`.'''
    return isinstance(x, str)

def allstrs(x) -> TypeGuard[tuple[str, ...]]:
    '''Check if `x` is an iterable of `str`s.'''
    return isinstance(x, Iterable) and all(isstr(a) for a in x)

def istuple(x) -> TypeGuard[tuple]:
    '''Check if `x` is a `tuple`'''
    return isinstance(x, tuple)

# %% ../nbs/00_core.ipynb 22
def insattr(
    obj:      object, 
    attr:     str, 
    default:  Any = None,
    check:    bool = True,
    guard:    Optional[GuardLike] = notnone,
    checkobj: Optional[GuardLike] = None, 
    guardobj: Optional[GuardLike] = notnone, 
    rethas:   bool = False,
    retself:  bool = False,
    __value:  Any = None,
) -> Union[Any, bool, object]:
    '''Get an attribute from an object and check its type.
    
    Parameters
    ----------
    obj : object
        The object to get the attribute from.
        
    attr : str
        The name of the attribute to get.
        
    default : Any, optional
        The default value to return if the attribute is not found, by default None.
        
    check : bool, default: True
        Whether to check the type of the attribute, by default True.
        
    guard : Optional[GuardLike], default: `notnone`
        The guard to check the type of the retrieved attribute, by default `notnone`.
        
    checkobj : Optional[GuardLike], default: None
        The guard to check the type of the object, by default None.
        
    guardobj : Optional[GuardLike], default: `notnone`
        The guard to check the type of the object, by default `notnone`.
        
    rethas : bool, default: False
        Whether to return the boolean result of the typeguard or the attribute, 
        by default False.
        
    retself : bool, default: False
        Whether to return the object if the attribute fails the guard, otherwise
        the default value is returned, by default False.
    '''
    # Step 1: Check if the object is of the correct type
    if (checkobj and iscall(guardobj)) and not guardobj(obj): 
        return default
    
    # Step 2: Try and get the attribute
    try: has = hasattr(obj, attr)
    except: has = False
    
    if not isstr(attr):
        try: attr = getattr(attr, ATTR, attr)
        except: ...
        
    try: val = getattr(obj, attr, default)
    except: val = default
    
    if isnone(val): 
        val = default
    
    if __value is not None: 
        val = __value
    
    if val is None and __value is None:
        try: val = getattr(obj, ATTR, val)
        except: ...
        
    # Step 3: Check if the attribute is of the correct type
    passed = (has and check and iscall(guard)) and guard(val)
    if passed: 
        return has if rethas else val
    
    # Step 4: value failed the guard, check if the boolean
    # i.e. typeguard result should be returned
    if rethas: 
        return has
    
    # Step 5: value failed the guard, check if the object 
    # should be returned
    passed = (check and iscall(guard)) and guardobj(obj)
    if (retself and passed): 
        return obj
    
    return default

# %% ../nbs/00_core.ipynb 24
class astrmeta(type):
    '''A metaclass for attributes to extend instance checks and equality.'''
    
    attr: ClassVar[str]
    '''The name of the attribute to get / set / or check if an object has.'''
    
    check: ClassVar[bool] = True
    '''Whether to check the type of the attribute, by default True.'''
    
    guard: ClassVar[Optional[GuardLike]] = notnone
    '''The guard to check the type of the retrieved attribute, by default `notnone`.'''
    
    checkobj: ClassVar[Optional[bool]] = True
    '''The guard to check the type of the object, by default None.'''
    
    guardobj: ClassVar[Optional[GuardLike]] = notnone
    '''The guard to check the type of the object, by default `notnone`.'''
    
    retself: ClassVar[bool] = True
    '''Whether to return the object if the attribute fails the guard, otherwise the default value is returned, by default False.'''
    
    default: ClassVar[Any] = None
    '''The default value to return if the attribute is not found, by default None.'''
    
    clsvars: tuple[str, ...] = CLASSVARS
    '''The tuple of class variable names.'''
    
    def __new__(mcls: Type[Self], name: str, bases: tuple = tuple(), dct: dict = dict(), *args, **kwargs) -> Self:
        # new = super().__new__(mcls, name, bases, dct, *args, **kwargs)
        kwargs.setdefault('__args', args)
        new = super().__new__(mcls, name, bases, dct)
        new.setvars(**kwargs)
        return new
    
    def __init__(cls: Self, name: str, bases: tuple = tuple(), dct: dict = dict(), *args, **kwargs):
        super().__init__(name, bases, dct)
        # NOTE: likely unneeded as it is called in __new__
        cls.setvars(**kwargs)
            
    def __init_subclass__(cls: Self, *args, **kwargs):
        super().__init_subclass__(*args, **kwargs)
        # NOTE: likely unneeded as it is called in __new__
        cls.setvars(**kwargs)
        
    @classmethod
    @abstractmethod
    def __guard__(cls: Type[Self], ins: object) -> TypeGuard[Type[Self]]:
        '''Checks if the instance `ins` has the attribute `cls.attr`.'''
        return cls.has(cls, ins)
    
    def __instancecheck__(cls: Type[Self], __instance: object) -> bool:
        '''Checks if the instance has the attribute `cls.attr`.
        
        Examples
        --------
        >>> # create the foo attribtue via subclassing
        ... class foo(attr):
        ...    attr = 'foo'
        ...
        ... # create two classes that have a foo attribute
        ... class testa: 
        ...    foo = 1; bar = 2; hi = 'no'; qux = dict(a=2);
        ... 
        ... class testb: 
        ...    foo = None; bar = -10; hello = 'yes'; qux = dict(a=5);
        ... 
        ... # test equality and instance check works via astrmeta __eq__ and __instancecheck__ methods
        ... foo == testa, foo == testb, isinstance(testa, foo), isinstance(testb, foo)
        (True, True, True, True)
        '''
        try: sub = issubclass(__instance, cls)
        except: sub = False
        return cls.__guard__(__instance) or sub
    
    def __eq__(cls: Self, ins: object) -> bool:
        '''Checks if the instance has the attribute `cls.attr`.
        
        Examples
        --------
        >>> # create the foo attribtue via subclassing
        ... class foo(attr):
        ...    attr = 'foo'
        ...
        ... # create two classes that have a foo attribute
        ... class testa: 
        ...    foo = 1; bar = 2; hi = 'no'; qux = dict(a=2);
        ... 
        ... class testb: 
        ...    foo = None; bar = -10; hello = 'yes'; qux = dict(a=5);
        ... 
        ... # test equality and instance check works via attrmeta __eq__ and __instancecheck__ methods
        ... foo == testa, foo == testb, isinstance(testa, foo), isinstance(testb, foo)
        (True, True, True, True)
        '''
        if isinstance(ins, cls): 
            return True
        return super().__eq__(ins)
    
    def __hash__(cls: Self) -> int:
        return super().__hash__()
    
    def getvars(cls: Self, **kwargs) -> dict:
        '''Returns a dictionary of class variables using class defaults if not found in `kwargs`.'''
        return {a: kwargs.get(a, getattr(cls, a, None)) for a in cls.clsvars}
    
    def setvars(cls: Self, **kwargs) -> Self:
        '''Set all class variables using their defaults values if not found in `kwargs`.'''
        for k, v in cls.getvars(**kwargs).items():
            setattr(cls, k, v)
        
        clsattr = getattr(cls, ATTR, NIL)
        clsname = getattr(cls, __NAME__, NIL).lower()
        # no attr but class has a name, use that for the attribute
        if not clsattr and clsname: 
            setattr(cls, ATTR, clsname)
        
        # cls.attr is 'attr' but class has a different name (e.g. subclass)
        if clsattr == ATTR and clsname != clsattr:
            setattr(cls, ATTR, clsname)
        return cls
    
    def __make_astr__(cls, name: str, *args, attr: Optional[str] = None, **kwargs): 
        '''Make a new attribute class with the given attribute name.'''
        # class name, bases and dct
        kwargs.setdefault(ATTR, attr or name)
        bases = kwargs.pop(BASES, (cls, ))
        ndict = kwargs.pop(NAMESPACE, {})
        ndict.update(__annotations__ = cls.__annotations__, __module__ = cls.__module__, attr = attr)
        return cls.__class__(name, bases, ndict, *args, **kwargs)
    
    def __make_astrs__(cls: Self, attrs: tuple[str, ...] = tuple(), *args, **kwargs):
        attrs = attrs or (getattr(cls, __NAME__, None), )
        return tuple(cls.__make_astr__(
            getattr(cls, __NAME__, attr), *args, **kwargs, 
            attr=attr) for attr in attrs
        )
    
    def __make_or_call__(cls: Self, *args, **kwargs):
        len0 = len(args) == 0
        arg0 = (args if len0 else args[0])
        # no args, create a single attribute using the class __name__ as the attribute name
        if len0:
            return cls.__make_astrs__(*args, **kwargs)
        
        # if here, then there are arguments
        elif allstrs(args):
            # args are all strings (e.g. names of attributes), make many attributes
            attrs = cls.__make_astrs__(attrs=args, **kwargs)
            return attrs[0] if len(attrs) == 1 else attrs
        
        elif istuple(arg0) and allstrs(arg0) and allstrs(args[1:]):
            # args0 is a tuple of all strings (e.g. names of attributes) so
            # args is something like: (('attr1', ...), 'attr2', 'attr3', ...)
            return cls.__make_or_call__(*arg0, *args[1:], **kwargs)
        
        elif istuple(arg0) and allstrs(arg0) and len(arg0) == 1:
            return cls.__make_or_call__(*arg0, *args[1:], **kwargs)
        
        elif istuple(arg0) and allstrs(arg0):
            # args0 is a tuple of all strings (e.g. names of attributes)
            attrs = cls.__make_astrs__(attrs=arg0, *args[1:], **kwargs)
            return attrs[0] if len(attrs) == 1 else attrs
        
        else:
            # called with arguments, but not all are strings, use the classes __call__ method
            return cls.__call__(cls, *args, **kwargs)
        return cls.__make_astrs__(*args, **kwargs)
    
    
    def __deco__(cls: Self, **kwargs) -> Callable[[type], Self]:
        def decorator(sub: type) -> Self:
            return cls.__make_astr__(sub.__name__, bases=(cls, ), **kwargs)
            @wraps(sub, assigned = (__MODULE__, __NAME__, __QUALNAME__), updated=())
            class kls(cls):
                __annotations__ = cls.__annotations__
                def __init__(self, *args, **kwargs):
                    super().__init__(*args, **kwargs)
            return kls
        return decorator
    
    @wraps(insattr)
    def get(cls: Self, obj: object) -> Union[Any, bool, object]:
        '''Get the attribute specified by the this class from the object.'''
        if DOT in cls.attr: 
            return cls.dot(obj, rethas = False)
        return insattr(obj, rethas = False, **cls.getvars())
    
    @wraps(insattr)
    def has(cls, obj: object) -> TypeGuard[bool]:
        '''Returns whether the object has the attribute specified by the this class.'''
        if DOT in cls.attr: return cls.dot(obj, rethas=True)
        return insattr(obj, rethas=True, **cls.getvars())
    
    def set(cls: Self, obj: object, val: Any) -> Type[Self]:
        '''Set the attribute specified by the this class on the object to the value `val`.'''
        setattr(obj, cls.attr, val)
        return cls
    
    def dot(cls: Self, obj: object, **kwargs):
        '''Returns get(...) --> get(...) --> ... --> get / has for a dotted attribute name e.g. `attr1.attr2.attr3`.'''
        names = getattr(cls, ATTR, NIL).split(DOT)
        return cls.chain(obj, *names, **kwargs)
    
    def chain(cls: Self, obj: object, *names, **kwargs):
        '''Returns chain cls.get until for each attribute in *names until the end, then call get / has 
        for a dotted attribute name e.g. `attr1.attr2.attr3`.'''
        kwds = cls.getvars(**kwargs)
        rethas = kwargs.get(RETHAS, False)
        for i, name in enumerate(names):
            if isinstance(name, str): 
                attr = cls.__make_astr__(name, **kwds)
            
            if i == len(attr) - 1:
                return attr.has(obj) if rethas else attr.get(obj)
            
            kws = {**kwds, **dict(attr=attr, rethas=False)}
            obj = insattr(obj, **kws)
        return obj
    
    def __repr__(cls: Self) -> str:
        return f'{cls.__name__}({cls.attr})'
    
    def __str__(cls: Self) -> str:
        return f'{cls.__name__}({cls.attr})'

# %% ../nbs/00_core.ipynb 26
class astr(metaclass=astrmeta):
    attr: ClassVar[str]
    '''The name of the attribute to get / set / or check if an object has.'''
    
    check: ClassVar[bool] = True
    '''Whether to check the type of the attribute, by default True.'''
    
    guard: ClassVar[Optional[GuardLike]] = notnone
    '''The guard to check the type of the retrieved attribute, by default `notnone`.'''
    
    checkobj: ClassVar[Optional[bool]] = True
    '''The guard to check the type of the object, by default None.'''
    
    guardobj: ClassVar[Optional[GuardLike]] = notnone
    '''The guard to check the type of the object, by default `notnone`.'''
    
    retself: ClassVar[bool] = True
    '''Whether to return the object if the attribute fails the guard, otherwise the default value is returned, by default False.'''
    
    default: ClassVar[Any] = None
    '''The default value to return if the attribute is not found, by default None.'''
    
    clsvars: tuple[str, ...] = CLASSVARS
    '''The tuple of class variable names.'''
    
    def __new__(cls, *args, **kwargs):
        if args: return cls.many(*args, **kwargs)
        if kwargs: return cls.deco(**kwargs)
        new = super().__new__(cls)
        return new
    
    def __call__(self, obj: object, *args, **kwargs) -> object:
        return self.get(obj) if kwargs.get(GET, True) else self.has(obj)
    
    def __init_subclass__(cls: Type[Self], *args, **kwargs):
        super().__init_subclass__()
        # NOTE: likely unneeded as it is called in __new__
        cls.setvars(**kwargs)
        
    @classmethod
    @abstractmethod
    def __guard__(cls: Type[Self], ins: object) -> TypeGuard[Type[Self]]:
        '''Checks if the instance `ins` has the attribute `cls.attr`.'''
        return cls.has(ins, )
    
    @classmethod
    def many(cls: Self, attrs: tuple[str, ...] = tuple(), *args, **kwargs) -> Self:
        if isinstance(attrs, str): attrs = tuple((attrs, ))
        attrs = cls.__make_or_call__(attrs, *args, **kwargs)
        if istuple(attrs) and len(attrs) == 1: return attrs[0]
        return attrs
    
    @classmethod
    def deco(cls: Type[Self], **kwargs) -> Type[Self]:
        return cls.__deco__(**kwargs)

# %% ../nbs/00_core.ipynb 27
@wraps(astr, assigned=ASSIGNED, updated=())
class attr(astr):
    '''An alias for the `astr` class.
    
    See Also
    --------
    astr : A class for getting, setting and checking if an object has an attribute.
    '''
    
@wraps(astr, assigned=ASSIGNED, updated=())
class attrstr(astr):
    '''An alias for the `astr` class.
    
    See Also
    --------
    astr : A class for getting, setting and checking if an object has an attribute.
    '''
