# ------------------------------------------------------------------------------
# Name:         prebase.py
# Purpose:      classes for anything in music21 to inherit from.
#
# Authors:      Michael Scott Asato Cuthbert
#
# Copyright:    Copyright © 2019 Michael Scott Asato Cuthbert
# License:      BSD, see license.txt
# ------------------------------------------------------------------------------
'''
Classes for pseudo-m21 objects to inherit from.  The most import attributes that nearly
everything in music21 -- not just things that live in streams --
should inherit from are given below.

Concept borrowed from m21j.
'''
from __future__ import annotations

import typing as t
import unittest

class ProtoM21Object:
    '''
    A class for pseudo-m21 objects to inherit from.  Any object can inherit from
    ProtoM21Object, and it makes sense for anything a user is likely to encounter
    to inherit from it.  Certain translators, etc. can choose to skip it.

    >>> class PitchCounter(prebase.ProtoM21Object):
    ...     def _reprInternal(self):
    ...         return 'no pitches'

    >>> pc = PitchCounter()
    >>> pc.classes
    ('PitchCounter', 'ProtoM21Object', 'object')
    >>> PitchCounter in pc.classSet
    True
    >>> 'Note' in pc.classSet
    False

    For a True/False intersection check against an iterable, use `classSet.isdisjoint`:

    >>> classList = ('music21.note.Note', 'music21.note.Rest')
    >>> pc.classSet.isdisjoint(classList)
    True
    >>> repr(pc)
    '<...PitchCounter no pitches>'

    ProtoM21Objects, like other Python primitives, cannot be put into streams --
    this is what base.Music21Object does.

    A ProtoM21Object defines several methods relating to unified representation
    and keeping track of the classes of the object.  It has no instance attributes
    or properties, and thus adds a very small creation time impact: recent
    tests show that an empty object with an empty `__init__()` method can
    be created in about 175ns while an empty object that subclasses ProtoM21Object
    with the same empty `__init__()` takes only 180ns, or a 5ns impact.  On
    real objects, the creation time percentage hit is usually much smaller.

    ProtoM21Objects have no __init__() defined, so do not call super().__init__() on
    objects that only inherit from ProtoM21Object unless you like wasting 200ns.
    '''

    # Defines the order of presenting names in the documentation; use strings
    _DOC_ORDER = [
        'classes',
        'classSet',
    ]

    # documentation for all attributes (not properties or methods)
    _DOC_ATTR: dict[str, str] = {}

    # this dictionary stores as a tuple of strings for each Class so that
    # it only needs to be made once (11 microseconds per call, can be
    # a big part of iteration; from cache just 1 microsecond)
    _classTupleCacheDict: dict[type, tuple[str, ...]] = {}
    _classSetCacheDict: dict[type, frozenset[str|type]] = {}

    __slots__: tuple[str, ...] = ()

    @property
    def classes(self) -> tuple[str, ...]:
        '''
        Returns a tuple containing the names (strings, not objects) of classes that this
        object belongs to -- starting with the object's class name and going up the mro()
        for the object.

        Notes are Music21Objects:

        >>> n = note.Note('C#')
        >>> n.classes
        ('Note', 'NotRest', 'GeneralNote', 'Music21Object', 'ProtoM21Object', 'object')

        Durations are not, but they inherit from ProtoM21Object

        >>> d = duration.Duration('half')
        >>> d.classes
        ('Duration', 'ProtoM21Object', 'SlottedObjectMixin', 'object')

        Having quick access to these things as strings makes it easier to do comparisons:

        Example: find GClefs that are not Treble clefs (or treble 8vb, etc.):

        >>> s = stream.Stream()
        >>> s.insert(10, clef.GClef())
        >>> s.insert(20, clef.TrebleClef())
        >>> s.insert(30, clef.FrenchViolinClef())
        >>> s.insert(40, clef.Treble8vbClef())
        >>> s.insert(50, clef.BassClef())
        >>> s2 = stream.Stream()
        >>> for thing in s:
        ...    if isinstance(thing, clef.GClef) and not isinstance(thing, clef.TrebleClef):
        ...        s2.insert(thing)
        >>> s2.show('text')
        {10.0} <music21.clef.GClef>
        {30.0} <music21.clef.FrenchViolinClef>

        * Changed in v2: returns a tuple, not a list.
        '''
        try:
            return self._classTupleCacheDict[self.__class__]
        except KeyError:
            classTuple = tuple(x.__name__ for x in self.__class__.mro())
            self._classTupleCacheDict[self.__class__] = classTuple
            return classTuple

    @property
    def classSet(self) -> frozenset[str|type]:
        '''
        Returns a set (that is, unordered, but indexed) of all classes that
        this class belongs to, including
        string names, fullyQualified string names, and objects themselves.

        It's cached on a per-class basis, so makes for a really fast way of checking to
        see if something belongs
        to a particular class when you don't know if the user has given a string,
        a fully qualified string name, or an object.

        Did I mention it's fast?  It's not as fast as x in n.classes or isinstance(n, x)
        if you know whether it's a string or class, but this is good and safe to take
        in either a string or a class.

        >>> n = note.Note()
        >>> 'Note' in n.classSet
        True
        >>> 'music21.note.Note' in n.classSet
        True
        >>> note.Note in n.classSet
        True

        >>> 'Rest' in n.classSet
        False
        >>> note.Rest in n.classSet
        False

        For checking if an object is part of any number of objects use `not`
        with `isdisjoint`.  A little unwiedly but works super fast:

        >>> checkClasses = (spanner.Slur, note.NotRest)
        >>> not n.classSet.isdisjoint(checkClasses)
        True

        >>> object in n.classSet
        True

        >>> sorted([s for s in n.classSet if isinstance(s, str)])
        ['GeneralNote',
         'Music21Object',
         'NotRest',
         'Note',
         'ProtoM21Object',
         'base.Music21Object',
         'builtins.object',
         'music21.base.Music21Object',
         'music21.note.GeneralNote',
         'music21.note.NotRest',
         'music21.note.Note',
         'music21.prebase.ProtoM21Object',
         'note.GeneralNote',
         'note.NotRest',
         'note.Note',
         'object',
         'prebase.ProtoM21Object']

        >>> sorted([s for s in n.classSet if not isinstance(s, str)], key=lambda x: x.__name__)
        [<class 'music21.note.GeneralNote'>,
         <class 'music21.base.Music21Object'>,
         <class 'music21.note.NotRest'>,
         <class 'music21.note.Note'>,
         <class 'music21.prebase.ProtoM21Object'>,
         <class 'object'>]

        * Changed in v8: partially qualified objects such as 'note.Note' have been added.
        '''
        try:
            return self._classSetCacheDict[self.__class__]
        except KeyError:
            classList: list[str|type] = list(self.classes)
            classList.extend(self.__class__.mro())
            fullyQualifiedStrings = [x.__module__ + '.' + x.__name__ for x in self.__class__.mro()]
            classList.extend(fullyQualifiedStrings)
            partiallyQualifiedStrings = [x.replace('music21.', '') for x in fullyQualifiedStrings]
            classList.extend(partiallyQualifiedStrings)
            classSet = frozenset(classList)
            self._classSetCacheDict[self.__class__] = classSet
            return classSet

    def __repr__(self) -> str:
        '''
        Defines the default representation for a ProtoM21Object
        which includes the module name, the class name, and additional
        information, such as the memory location:

        >>> p = prebase.ProtoM21Object()
        >>> repr(p)
        '<music21.prebase.ProtoM21Object object at 0x112590380>'

        The additional information is defined in the `_reprInternal` method,
        so objects inheriting from ProtoM21Object (such as Music21Object)
        should change `_reprInternal` and not `__repr__`.

        Except for music21.base itself, any object in a file called base.py
        has the base part removed.

        >>> from music21.midi.base import MidiEvent
        >>> me = MidiEvent()
        >>> repr(me)
        '<music21.midi.MidiEvent UNKNOWN, track=None>'
        >>> me._reprInternal()
        'UNKNOWN, track=None'
        '''
        reprHead = '<'
        if self.__module__ != '__main__':
            reprHead += self.__module__ + '.'
        reprHead += self.__class__.__qualname__
        if '.base.' in reprHead and 'music21.base' not in reprHead:
            reprHead = reprHead.replace('.base', '')
        strRepr = self._reprInternal()
        if strRepr and not strRepr.startswith(':'):
            reprHead += ' '

        if strRepr:
            reprHead += strRepr.strip()
        return reprHead + '>'

    def _reprInternal(self) -> str:
        '''
        Defines the insides of the representation.

        Overload this method for most objects.

        A default representation:

        >>> p = prebase.ProtoM21Object()
        >>> p._reprInternal()
        'object at 0x112590380'

        A more complex `_reprInternal` that handles the case of objects
        with `.id` defined is found in Music21Object.
        '''
        return f'object at {hex(id(self))}'


del t


class Test(unittest.TestCase):
    def testCopyAndDeepcopy(self):
        from music21.test.commonTest import testCopyAll
        testCopyAll(self, globals())

    def test_reprInternal(self):
        from music21.base import Music21Object
        b = Music21Object()
        b.id = 'hello'
        r = repr(b)
        self.assertEqual(r, '<music21.base.Music21Object id=hello>')


# ---------------------------------------------------------
if __name__ == '__main__':
    import music21
    music21.mainTest(Test)
