Repository: coala/coala

Issue:
aspect: Convert string to aspect
In the future, aspect will be defined by user in coafile. But, currently there are no way to change a string (like from coafile) into an aspect object. Something like this:

```python
>>> str_to_aspect('CommitMessage')
<aspectclass 'Root.Metadata.CommitMessage'>
```


There are some design need to be fixed though:
- Do we need the string to be an exact match to the aspect? (`'CommitMessage'` or `'Root.Metadata.CommitMessage`')
  - If we choose the short style string to be enough, what happen if there are 2 or more aspect that have common name? (`Root.Something.CommitMessage` and `Root.Metadata.CommitMessage`).
- Should it be case sensitive?

@coala/aspects-developers 
area/aspect status/needs design


Discussion:
@adhikasp Important topic! Good issue :) And I have some very fancy Pythonic idea for this :D 

@coala/aspects-developers Let me surprise you! It's been a while since I created my last PR...

Resolving patch:
diff --git a/coalib/bearlib/aspects/__init__.py b/coalib/bearlib/aspects/__init__.py
--- a/coalib/bearlib/aspects/__init__.py
+++ b/coalib/bearlib/aspects/__init__.py
@@ -1,111 +1,81 @@
+import re
+import sys
+from importlib import import_module
+from inspect import getmembers
+from pkgutil import iter_modules
+from types import ModuleType
+
 from .base import aspectbase
 from .meta import aspectclass
 from .taste import Taste, TasteError
 
-__all__ = ['Root', 'Taste', 'TasteError', 'aspectclass']
+# already import Root here to make it available in submodules that define
+# `Root.subaspect`s, which get imported in wrapper `aspectsModule.__init__`
+# before this module gets replaced with that wrapper in sys.modules
+from .root import Root
+
 
+__all__ = ['Root', 'Taste', 'TasteError', 'aspectclass', 'aspectbase']
 
-class Root(aspectbase, metaclass=aspectclass):
+
+class aspectsModule(ModuleType):
     """
-    The root aspectclass.
-
-    Define sub-aspectclasses with class-bound ``.subaspect`` decorator.
-    Definition string is taken from doc-string of decorated class.
-    Remaining docs are taken from a nested ``docs`` class.
-    Tastes are defined as class attributes that are instances of
-    :class:`coalib.bearlib.aspects.Taste`.
-
-    >>> @Root.subaspect
-    ... class Formatting:
-    ...     \"""
-    ...     A parent aspect for code formatting aspects...
-    ...     \"""
-
-    We can now create subaspects like this:
-
-    >>> @Formatting.subaspect
-    ... class LineLength:
-    ...     \"""
-    ...     This aspect controls the length of a line...
-    ...     \"""
-    ...     class docs:
-    ...        example = "..."
-    ...        example_language = "..."
-    ...        importance_reason = "..."
-    ...        fix_suggestions = "..."
-    ...
-    ...     max_line_length = Taste[int](
-    ...         "Maximum length allowed for a line.",
-    ...         (80, 90, 120), default=80)
-
-    The representation will show the full "path" to the leaf of the tree:
-
-    >>> Root.Formatting.LineLength
-    <aspectclass 'Root.Formatting.LineLength'>
-
-    We can see, which settings are availables:
-
-    >>> Formatting.tastes
-    {}
-    >>> LineLength.tastes
-    {'max_line_length': <....Taste[int] object at ...>}
-
-    And instantiate the aspect with the values, they will be automatically
-    converted:
-
-    >>> Formatting('Python')
-    <coalib.bearlib.aspects.Root.Formatting object at 0x...>
-    >>> LineLength('Python', max_line_length="100").tastes
-    {'max_line_length': 100}
-
-    If no settings are given, the defaults will be taken:
-
-    >>> LineLength('Python').tastes
-    {'max_line_length': 80}
-
-    Tastes can also be made available for only specific languages:
-
-    >>> from coalib.bearlib.languages import Language
-    >>> @Language
-    ... class GreaterTrumpScript:
-    ...     pass
-
-    >>> @Formatting.subaspect
-    ... class Greatness:
-    ...     \"""
-    ...     This aspect controls the greatness of a file...
-    ...     \"""
-    ...
-    ...     min_greatness = Taste[int](
-    ...         "Minimum greatness factor needed for a TrumpScript file. "
-    ...         "This is fact.",
-    ...         (1000000, 1000000000, 1000000000000), default=1000000,
-    ...         languages=('GreaterTrumpScript' ,))
-
-    >>> Greatness.tastes
-    {'min_greatness': <....Taste[int] object at ...>}
-    >>> Greatness('GreaterTrumpScript').tastes
-    {'min_greatness': 1000000}
-    >>> Greatness('GreaterTrumpScript', min_greatness=1000000000000).tastes
-    {'min_greatness': 1000000000000}
-
-    >>> Greatness('Python').tastes
-    {}
-
-    >>> Greatness('Python', min_greatness=1000000000)
-    ... # doctest: +NORMALIZE_WHITESPACE
-    Traceback (most recent call last):
-      ...
-    coalib.bearlib.aspects.taste.TasteError:
-    Root.Formatting.Greatness.min_greatness is not available ...
-
-    >>> Greatness('Python').min_greatness
-    ... # doctest: +NORMALIZE_WHITESPACE
-    Traceback (most recent call last):
-      ...
-    coalib.bearlib.aspects.taste.TasteError:
-    Root.Formatting.Greatness.min_greatness is not available ...
+    A special module wrapper for ``coalib.bearlib.aspects``, allowing
+    case-insensitive aspect lookup by name via direct indexing :)
+
+    >>> import coalib.bearlib.aspects
+
+    >>> coalib.bearlib.aspects['Metadata']
+    <aspectclass 'Root.Metadata'>
+
+    >>> coalib.bearlib.aspects['commitmessage']
+    <aspectclass 'Root.Metadata.CommitMessage'>
+
+    >>> coalib.bearlib.aspects['shortlog.colonExistence']
+    <aspectclass 'Root.Metadata.CommitMessage.Shortlog.ColonExistence'>
     """
-    parent = None
 
-    _tastes = {}
+    def __init__(self, module):
+        """
+        Take over all members from original `module` object and load all
+        direct ``Root.subaspect`` classes from their corresponding submodules
+        (all submodules starting with uppercase letters), and add them as
+        members to this wrapper module.
+        """
+        super().__init__(module.__name__)
+        self.__dict__.update(getmembers(module))
+        for _, submodname, _ in iter_modules(module.__path__):
+            if submodname[0].isupper():
+                submod = import_module('.' + submodname, module.__name__)
+                subaspect = getattr(submod, submodname)
+                setattr(self, submodname, subaspect)
+
+    def __getitem__(self, aspectname):
+        regex = re.compile('(^|\.)%s$' % aspectname.lower())
+        matches = []
+
+        def search(aspects):
+            """
+            Recursively search in `aspects` for those matching
+            case-insensitively the given ``aspectname``.
+            """
+            for aspect in aspects:
+                if regex.search(aspect.__qualname__.lower()):
+                    matches.append(aspect)
+                if aspect.subaspects:
+                    search(aspect.subaspects.values())
+
+        search([Root])
+        if not matches:
+            raise LookupError('no aspect named %s' % repr(aspectname))
+
+        if len(matches) > 1:
+            raise LookupError('multiple aspects named %s. choose from %s' % (
+                repr(aspectname),
+                repr(sorted(matches, key=lambda a: a.__qualname__))))
+
+        return matches[0]
+
+
+# replace original module with wrapper
+sys.modules[__name__] = aspectsModule(sys.modules[__name__])
diff --git a/coalib/bearlib/aspects/root.py b/coalib/bearlib/aspects/root.py
new file mode 100644
--- /dev/null
+++ b/coalib/bearlib/aspects/root.py
@@ -0,0 +1,110 @@
+from .base import aspectbase
+from .meta import aspectclass
+
+
+class Root(aspectbase, metaclass=aspectclass):
+    """
+    The root aspectclass.
+
+    Define sub-aspectclasses with class-bound ``.subaspect`` decorator.
+    Definition string is taken from doc-string of decorated class.
+    Remaining docs are taken from a nested ``docs`` class.
+    Tastes are defined as class attributes that are instances of
+    :class:`coalib.bearlib.aspects.Taste`.
+
+    >>> from coalib.bearlib.aspects import Taste
+
+    >>> @Root.subaspect
+    ... class Formatting:
+    ...     \"""
+    ...     A parent aspect for code formatting aspects...
+    ...     \"""
+
+    We can now create subaspects like this:
+
+    >>> @Formatting.subaspect
+    ... class LineLength:
+    ...     \"""
+    ...     This aspect controls the length of a line...
+    ...     \"""
+    ...     class docs:
+    ...        example = "..."
+    ...        example_language = "..."
+    ...        importance_reason = "..."
+    ...        fix_suggestions = "..."
+    ...
+    ...     max_line_length = Taste[int](
+    ...         "Maximum length allowed for a line.",
+    ...         (80, 90, 120), default=80)
+
+    The representation will show the full "path" to the leaf of the tree:
+
+    >>> Root.Formatting.LineLength
+    <aspectclass 'Root.Formatting.LineLength'>
+
+    We can see, which settings are availables:
+
+    >>> Formatting.tastes
+    {}
+    >>> LineLength.tastes
+    {'max_line_length': <....Taste[int] object at ...>}
+
+    And instantiate the aspect with the values, they will be automatically
+    converted:
+
+    >>> Formatting('Python')
+    <....Root.Formatting object at 0x...>
+    >>> LineLength('Python', max_line_length="100").tastes
+    {'max_line_length': 100}
+
+    If no settings are given, the defaults will be taken:
+
+    >>> LineLength('Python').tastes
+    {'max_line_length': 80}
+
+    Tastes can also be made available for only specific languages:
+
+    >>> from coalib.bearlib.languages import Language
+    >>> @Language
+    ... class GreaterTrumpScript:
+    ...     pass
+
+    >>> @Formatting.subaspect
+    ... class Greatness:
+    ...     \"""
+    ...     This aspect controls the greatness of a file...
+    ...     \"""
+    ...
+    ...     min_greatness = Taste[int](
+    ...         "Minimum greatness factor needed for a TrumpScript file. "
+    ...         "This is fact.",
+    ...         (1000000, 1000000000, 1000000000000), default=1000000,
+    ...         languages=('GreaterTrumpScript' ,))
+
+    >>> Greatness.tastes
+    {'min_greatness': <....Taste[int] object at ...>}
+    >>> Greatness('GreaterTrumpScript').tastes
+    {'min_greatness': 1000000}
+    >>> Greatness('GreaterTrumpScript', min_greatness=1000000000000).tastes
+    {'min_greatness': 1000000000000}
+
+    >>> Greatness('Python').tastes
+    {}
+
+    >>> Greatness('Python', min_greatness=1000000000)
+    ... # doctest: +NORMALIZE_WHITESPACE
+    Traceback (most recent call last):
+      ...
+    coalib.bearlib.aspects.taste.TasteError:
+    Root.Formatting.Greatness.min_greatness is not available ...
+
+    >>> Greatness('Python').min_greatness
+    ... # doctest: +NORMALIZE_WHITESPACE
+    Traceback (most recent call last):
+      ...
+    coalib.bearlib.aspects.taste.TasteError:
+    Root.Formatting.Greatness.min_greatness is not available ...
+    """
+    parent = None
+
+    _tastes = {}


Test patch:
diff --git a/tests/bearlib/aspects/ModuleTest.py b/tests/bearlib/aspects/ModuleTest.py
new file mode 100644
--- /dev/null
+++ b/tests/bearlib/aspects/ModuleTest.py
@@ -0,0 +1,48 @@
+from types import ModuleType
+
+import coalib.bearlib.aspects
+
+import pytest
+
+
+class aspectsModuleTest:
+
+    def test_module(self):
+        # check that module is correctly wrapped
+        assert isinstance(coalib.bearlib.aspects, ModuleType)
+        assert type(coalib.bearlib.aspects) is not ModuleType
+        assert (type(coalib.bearlib.aspects) is
+                coalib.bearlib.aspects.aspectsModule)
+
+    def test__getitem__(self):
+        # check a leaf aspect
+        for aspectname in ['aspectsYEAH', 'spelling.aspectsYEAH',
+                           'root.SPELLING.aspectsYEAH']:
+            assert (coalib.bearlib.aspects[aspectname] is
+                    coalib.bearlib.aspects.Root.Spelling.aspectsYEAH)
+        # check a container aspect
+        for aspectname in ['Spelling', 'SPELLING', 'ROOT.spelling']:
+            assert (coalib.bearlib.aspects[aspectname] is
+                    coalib.bearlib.aspects.Root.Spelling)
+        # check root aspect
+        for aspectname in ['Root', 'root', 'ROOT']:
+            assert (coalib.bearlib.aspects[aspectname] is
+                    coalib.bearlib.aspects.Root)
+
+    def test__getitem__no_match(self):
+        for aspectname in ['noaspect', 'NOASPECT', 'Root.aspectsYEAH']:
+            with pytest.raises(LookupError) as exc:
+                coalib.bearlib.aspects[aspectname]
+            exc.match(r"^no aspect named '%s'$" % aspectname)
+
+    def test__getitem__multi_match(self):
+        for aspectname in ['Length', 'length', 'LENGTH']:
+            with pytest.raises(LookupError) as exc:
+                coalib.bearlib.aspects[aspectname]
+            exc.match(r"^multiple aspects named '%s'. " % aspectname +
+                      r'choose from '
+                      r'\[<aspectclass'
+                      r" 'Root.Metadata.CommitMessage.Body.Length'>,"
+                      r' <aspectclass'
+                      r" 'Root.Metadata.CommitMessage.Shortlog.Length'>"
+                      r'\]$')