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

# %% auto 0
__all__ = ['RegistryCollection', 'NameRegistry', 'Singleton', 'Solo', 'Empty', 'Module', 'EmptyModule']

# %% ../nbs/02_core.ipynb 4
from types import ModuleType
from typing import Any, Self, Optional

# %% ../nbs/02_core.ipynb 5
from .cons import P
from .util import safeattr, keymatch

# %% ../nbs/02_core.ipynb 7
class RegistryCollection(dict):
    '''
    A dictionary subclass used for registering and accessing instances by name or abbreviation.

    Attributes
    ----------
    No additional attributes beyond those provided by `dict`.

    Methods
    -------
    register(instance)
        Register an instance in the collection, accessible by its name or abbreviation.
    match(key, attr='name', default=None, icase=True, ispace=True, iunder=True, n=1, cutoff=0.8)
        Find and return an instance matching the key with various string matching options.
    lookup(name, default=None)
        Lookup an instance by name, using `match` with default settings.
    initer(val, iattr, kattr='name')
        Returns a set of `kattr` attribute values for instances where `val` is found in `iattr`.
    markers(cell_type)
        Return the set of marker genes for the given cell type.
    '''
    def register(self, instance, attrs: list[str] = None):
        '''
        Register an instance in the collection, accessible by its name or abbreviation.

        Parameters
        ----------
        instance : object
            The instance to register.

        attrs: list[str]
            Additional attributes to register the instance under.
        '''
        self[instance.name] = instance
        setattr(self, safeattr(instance.name), instance)
        for attr in (attrs or []):
            if hasattr(instance, attr) and not hasattr(self, safeattr(getattr(instance,attr))):
                setattr(self, safeattr(getattr(instance,attr)), instance)

    def match(
        self, key, attr: str = 'name', default = None, 
        icase: bool = True, ispace: bool = True, iunder: bool = True,
        n: int = 1, cutoff: float = 0.8
    ):
        '''
        Find and return an instance matching the key with various string matching options.

        Parameters
        ----------
        key : str
            The key to match against instance attributes.
        attr : str, optional
            The attribute of the instances to match against, by default 'name'.
        default : any, optional
            The default value to return if no match is found, by default None.
        icase : bool, optional
            Whether to ignore case during matching, by default True.
        ispace : bool, optional
            Whether to ignore spaces during matching, by default True.
        iunder : bool, optional
            Whether to ignore underscores during matching, by default True.
        n : int, optional
            The maximum number of matches to return, by default 1.
        cutoff : float, optional
            The cutoff for considering a match as valid, by default 0.8.

        Returns
        -------
        object or default
            The matched instance or the default value if no match is found.
        '''
        keys = list(self.keys())
        vals = list(map(lambda x: getattr(x, attr, None), self.values()))
        kwds = dict(n=n, cutoff=cutoff, icase=icase, ispace=ispace, iunder=iunder)
        for i, val in enumerate(vals):
            if (same := keymatch(key, keys[i], val, **kwds)): return self[keys[i]]
        return default

    def initer(self, val: str, iattr: str, kattr: str = 'name'):
        '''Checks if `val` is in instance attribute `iattr` and returns the set of `kattr` values.
        Note that `iattr` is short for "iterable attribue" (e.g. a set or list) and `kattr` is short
        for "key attribute" (e.g. the name of the instance).
        '''
        return {getattr(el, kattr) for el in self.values() if val in getattr(el, iattr)}

# %% ../nbs/02_core.ipynb 9
class NameRegistry(type):
    '''
    A metaclass for creating classes that have a name registry.

    Attributes
    ----------
    registry : RegistryCollection
        A property that returns the class registry.

    _registry : RegistryCollection
        A collection that holds instances of the class.


    Methods
    -------
    __new__(cls, name, bases, namespace, **kwargs)
        Create a new class with a registry.
    __call__(cls, name, *args, **kwargs)
        Call the class constructor and register the new instance.
    __getattr__(cls, name)
        Custom attribute access method that searches in the registry if the attribute is not found.
    '''
    
    def __new__(cls, name, bases, namespace, **kwargs):
        '''
        Create a new class with a registry.

        Parameters
        ----------
        name : str
            The name of the new class.
        bases : tuple
            The base classes of the new class.
        namespace : dict
            The namespace containing the definitions for the class.

        Returns
        -------
        type
            The newly created class.
        '''
        namespace['_registry'] = RegistryCollection()
        return super().__new__(cls, name, bases, namespace, **kwargs)
    
    def __call__(cls, name, *args, **kwargs):
        '''
        Call the class constructor and register the new instance.

        Parameters
        ----------
        name : str
            The name of the instance.
        args : tuple
            Positional arguments for the instance creation.
        kwargs : dict
            Keyword arguments for the instance creation.

        Returns
        -------
        object
            The newly created and registered instance, or the existing instance if found in the registry.
        '''
        registry = cls._registry
        if name not in registry:
            instance = super().__call__(name, *args, **kwargs)
            registry.register(instance)
            return instance
        return registry[name]
    
    @property
    def registry(cls): return cls._registry
    
    def lookup(cls, name, default = None, cutoff: float = 0.5, **kwargs):
        '''Find and return an instance matching name with various tolerance options.

        Parameters
        ----------
        name : str
            The key to match against instance attributes.
        attr : str, default: 'name'
            The attribute of the instances to match against.
        default : any, optional
            The default value to return if no match is found, by default None.
        
        icase : bool, optional
            Whether to ignore case during matching, by default True.
        
        ispace : bool, optional
            Whether to ignore spaces during matching, by default True.
        
        iunder : bool, optional
            Whether to ignore underscores during matching, by default True.
        
        n : int, default: 1
            The maximum number of matches to return, by default 1.
        
        cutoff : float, default: 0.8
            The cutoff for considering a match as valid, by default 0.8.

        Returns
        -------
        object or default
            The matched instance or the default value if no match is found.
        '''
        return cls.registry.match(name, default=default, cutoff=cutoff, **kwargs)

    def __getattr__(cls, name):
        try: return super().__getattr__(name)
        except (AttributeError, KeyError):
            try: return cls.lookup(name)
            except KeyError:
                raise AttributeError(f"'{cls.__name__}' has no attribute '{name}'")

# %% ../nbs/02_core.ipynb 11
class Singleton(type):
    '''
    A metaclass for creating Singleton classes. A Singleton class created using
    this metaclass will only ever have one instance: the class itself acts as
    the instance. This is achieved by overriding the `__new__` and `__call__`
    methods of the type metaclass.

    Parameters
    ----------
    *args : P.args
        Arguments passed to the class constructor.
    **kwargs : P.kwargs
        Keyword arguments passed to the class constructor.

    Returns
    -------
    type[Self]
        The single instance of the class.

    Examples
    --------
    >>> class EmptyModule(ModuleType, metaclass=Singleton): 
    ...     def __init__(self: Self, name: str = 'empty', doc: Optional[str] = 'The EmptyModule singleton.'):
    ...         super().__init__(name, doc)    
    >>> EmptyModule is EmptyModule, EmptyModule() == EmptyModule, EmptyModule is EmptyModule(), EmptyModule() is EmptyModule
    (True, True, True, True)

    Notes
    -----
    - The Singleton pattern is useful when exactly one object is needed to
      coordinate actions across the system.
    - This implementation of Singleton uses the `__new__` method to intercept
      the class instantiation and ensure only one instance is created.
    - The `__call__` method is overridden to return this instance whenever
      the class is "called" to create a new instance, effectively ignoring
      subsequent instantiation requests.
    - The `__bool__` method is overridden to always return False, to indicate
      that the Singleton instance should not be considered as a boolean True
      value, despite being a non-None object.
    - The `__repr__` method provides a human-readable representation of the
      Singleton instance, indicating its unique nature.
    - The `__doc__` property is overridden to return the documentation of the
      single instance if available, or a default message indicating it is a
      singleton.

    Warnings
    --------
    Using a Singleton can introduce global state into an application, which
    can lead to issues if not carefully managed. It can also make testing
    more difficult, as the state is preserved across tests unless explicitly
    reset.
    '''
    def __call__(cls, *args: P.args, **kwargs: P.kwargs) -> type[Self]:
        '''Return the singleton instance.'''
        if not hasattr(cls, '_ins'): cls._ins = super().__call__(*args, **kwargs)
        return cls
    
    def __bool__(cls) -> bool: return False
    def __repr__(cls) -> str: return f'<singleton {cls.__name__}>'
    
    @property
    def __doc__(cls) -> str: 
        try: return cls._ins.__doc__
        except AttributeError: return f'The {cls.__name__} singleton.'
        
    def __new__(cls, name: str, bases: tuple[type, ...], namespace: dict[str, Any]) -> type[Self]:
        '''Create the singleton instance.'''
        new = super().__new__(cls, name, bases, namespace)
        # invoke the singleton instance
        return new() 

# %% ../nbs/02_core.ipynb 12
# class EmptyModule(ModuleType, metaclass=Solo): 
#     def __init__(self: Self, name: str = 'empty', doc: Optional[str] = 'The EmptyModule singleton.'):
#         super().__init__(name, doc)

# %% ../nbs/02_core.ipynb 13
class Solo(type):
    _ins = {}
    def __new__(cls, name: str = 'Solo', bases: tuple = (), namespace: dict = {}, **kwargs):
        insdict = namespace.get('_ins', cls._ins)
        insdict.update(cls._ins)
        namespace.update(_ins=insdict)
        if name not in insdict:
            cls._ins[name] = super().__new__(cls, name, bases, namespace, **kwargs)
        return cls._ins[name]
        
    def __instancecheck__(self, __instance: Any) -> bool: 
        return super().__instancecheck__(__instance)
    
    def __subclasscheck__(self, __subclass: type) -> bool: 
        return super().__subclasscheck__(__subclass)
    
    def __eq__(cls, ins) -> bool: 
        return isinstance(ins, (cls, cls.__class__))
    
    def __hash__(cls) -> int: 
        return hash(cls.__name__)
    
    def __repr__(cls) -> str: 
        return f'<{type(cls).__name__.lower()} {cls.__name__}>'
    
    def __call__(cls, *args, **kwargs): 
        return cls._ins[cls.__name__]

# %% ../nbs/02_core.ipynb 14
class Empty(Solo): ...

class Module(ModuleType, metaclass=Empty): ...

class EmptyModule(ModuleType, metaclass=Solo): ... 
