<gh_stars>1-10
from collections import defaultdict as _defaultdict
from typing import Set, Dict, List

import astroid as _astroid

from .abc_checker import ABChecker as _ABChecker

__all__ = [
    'Checker',
    'ClassChecker',
    'ExpressionChecker',
    'FunctionChecker',
    'IfChecker',
    'ImportChecker',
    'LoopChecker',
]


class ClassChecker(_ABChecker):
    def defines_class(self, class_names=None, require_all=True):
        """
        If class_names is None the function returns True if any classes are defined.

        If class_names is a single string value the function returns True if that class is defined.

        If class_names is an Iterable of strings the function behaviour will depend on require_all.
        If require_all is True the function will return True if all class names in class_names are defined.
        If require_all is False the function will return True if any class names in class_names are defined.

        Class names can be qualified or unqualified with the Checker module_name
        """
        class_names = self.bundle(class_names)
        definitions = self.class_definitions(True)
        if class_names is None:
            return len(definitions) != 0
        condition = all if require_all else any
        return condition(self.qname(class_name) in definitions for class_name in class_names)

    def initializes_class(self, classes=None, require_all=False):
        """
        If classes is None the function returns True if any classes are initialised

        Classes can either be passed as a string or a Type object.
        String values are assumed to refer to classes defined within the Checker code.
        Type values refer to classes outside the Checker code.

        If classes is a single value the function returns True if the class is initialised at least once in the code.

        If classes is an Iterable the function behaviour will depend on require_all.
        If require_all is True the function will return True if all classes in classes are initialised.
        If require_all is False the function will return True if any class in classes is initialised.

        Note:
            List, Set, Tuple, and Dict literals are considered class initialisations

        Example:

            ::

                code = '''
                class Point:
                    def __init__(self, x, y):
                        self.x = x
                        self.y = y

                points = []
                for i in range(3):
                    for j in range(3):
                        points.append(Point(i, j))
                '''

                checker = ClassChecker(code)
                checker.initializes_class()  # True
                checker.initializes_class(range)  # True
                checker.initializes_class('Point')  # True
                checker.initializes_class(tuple)  # False
                checker.initializes_class([range, list, 'Point'], require_all=True)  # True
        """
        classes = self.bundle(classes)
        initializations = self.class_initializations()
        if classes is None:
            return len(initializations) != 0
        condition = all if require_all else any
        return condition(self.qname(class_) in initializations for class_ in classes)

    def class_definitions(self, qualified=False) -> Set[str]:
        """
        Returns the names of all defined classes.
        Returns fully qualified names if `qualified` is True and base names otherwise.
        """
        return {class_def.qname() if qualified else class_def.name for class_def in
                self.nodes_of_class(_astroid.ClassDef)}

    def class_initializations(self) -> Set[str]:
        """
        Returns the names of all initialised classes.
        Names are fully qualified.
        """
        calls = set()
        literals = self.nodes_of_class((_astroid.Dict, _astroid.List, _astroid.Set, _astroid.Tuple))
        calls.update(literal.qname() for literal in literals)
        for call in self.nodes_of_class(_astroid.Call):
            # ToDo - Guard against inference error
            val = next(call.func.infer())
            if isinstance(val, _astroid.ClassDef):
                calls.add(val.qname())
        return calls


class ExpressionChecker(_ABChecker):
    def uses_starred(self) -> bool:
        """Returns True if a starred expression is used"""
        return self.uses(_astroid.Starred)

    def uses_comprehension(self) -> bool:
        """Returns True if comprehension is used"""
        return self.uses(_astroid.Comprehension)

    def uses_list_comprehension(self) -> bool:
        """Returns True if list comprehension is used"""
        return self.uses(_astroid.ListComp)

    def uses_set_comprehension(self) -> bool:
        """Returns True if set comprehension is used"""
        return self.uses(_astroid.SetComp)

    def uses_dict_comprehension(self) -> bool:
        """Returns True if dict comprehension is used"""
        return self.uses(_astroid.DictComp)


class FunctionChecker(_ABChecker):
    def calls_function(self, functions=None, require_all=False):
        """
        If functions is None the function returns True if any functions are called

        Functions can either be passed as a string or a Function/Method object.
        String values are assumed to refer to functions defined within the Checker code.
        Function/Method values refer to functions outside the Checker code.

        If functions is a single value the function returns True if the function is called at least once in the code.

        If functions is an Iterable the function behaviour will depend on require_all.
        If require_all is True the function will return True if all functions in functions are called.
        If require_all is False the function will return True if any function in functions is called.

        Example:

            ::

                code = '''
                class Point:
                    def __init__(self, x, y):
                       self.x = x
                       self.y = y

                    def add(self, other):
                        ...

                points = []
                point1 = Point(1, 3)
                point2 = Point(4, 9)
                point1.add(point2)
                points.append(point1)
                '''

                checker = FunctionChecker(code)
                checker.calls_function(list.append)  # True
                checker.calls_function('Point.add')  # True
                checker.calls_function([list.append, 'Point.add', another_function], require_all=False)  # True
        """
        functions = self.bundle(functions)
        calls = self.function_calls()
        if functions is None:
            return len(calls) != 0
        condition = all if require_all else any
        return condition(self.qname(function) in calls for function in functions)

    def defines_function(self, function_names=None, require_all=True):
        """
        If function_names is None the function returns True if any functions are defined.

        If function_names is a single string value the function returns True if that function is defined.

        If function_names is an Iterable of strings the function behaviour will depend on require_all.
        If require_all is True the function will return True if all function names in function_names are defined.
        If require_all is False the function will return True if any function names in function_names are defined.

        Function names can be qualified or unqualified with the Checker module_name.

        Note:
            Methods are not distinguished from functions but must be referred to by their qualified name.

            For Example: ::

                code = '''
                class Point:
                    def add(self, other):
                        ...
                '''

                checker = FunctionChecker(code)
                checker.defines_function('add')  # False
                checker.defines_function('Point.add')  # True
        """
        function_names = self.bundle(function_names)
        definitions = self.function_definitions(True)
        if function_names is None:
            return len(definitions) != 0
        condition = all if require_all else any
        return condition(self.qname(function_name) in definitions for function_name in function_names)

    def function_definitions(self, qualified=False):
        """
        Returns the names of all defined functions.
        Returns fully qualified names if `qualified` is True and base names otherwise.
        """
        return {func_def.qname() if qualified else func_def.name for func_def in
                self.nodes_of_class(_astroid.FunctionDef)}

    def function_calls(self):
        """
        Returns the names of all called functions.
        Names are fully qualified.
        """
        calls = set()
        for call in self.nodes_of_class(_astroid.Call):
            # ToDo - Guard against inference error
            val = next(call.func.infer())
            if isinstance(val, (_astroid.FunctionDef, _astroid.BoundMethod, _astroid.UnboundMethod)):
                calls.add(val.qname())
        return calls

    def count_function_defs(self) -> int:
        """Returns the number of function definitions"""
        return self.count(_astroid.FunctionDef)

    def count_function_calls(self) -> int:
        """Returns the static number of function calls"""
        return self.count(_astroid.Call)

    def defines_nested_function(self) -> bool:
        """Returns True if a function is defined within another function"""
        return self.max_nesting(_astroid.FunctionDef) > 1

    def uses_recursion(self) -> bool:
        """Returns True if there is a recursive function call"""
        calls = _defaultdict(set)
        function_defs = self.nodes_of_class(_astroid.FunctionDef)
        for definition in function_defs:
            for call in definition.nodes_of_class(_astroid.Call):
                # ToDo - Guard against inference error
                calls[definition.name].add(next(call.func.infer()).name)

        def _contains_loops(g):
            path = set()

            def visit(vertex):
                path.add(vertex)
                for neighbour in g.get(vertex, ()):
                    if neighbour in path or visit(neighbour):
                        return True
                path.remove(vertex)
                return False

            return any(visit(v) for v in g)

        return _contains_loops(calls)


class IfChecker(_ABChecker):
    def uses_if(self) -> bool:
        """Returns True if an if statement is used"""
        return self.uses(_astroid.If)

    def uses_elif(self) -> bool:
        """Returns True if an elif statement is used"""
        ifs = self.nodes_of_class(_astroid.If)
        return any(if_.has_elif_block() for if_ in ifs)

    def uses_else(self) -> bool:
        """Returns True if an else statement is used"""
        ifs = self.nodes_of_class(_astroid.If)
        return any(len(if_.orelse) and not if_.has_elif_block() for if_ in ifs)

    def count_if(self) -> int:
        """Returns the number of if statements"""
        elifs = sum(n.has_elif_block() for n in self.nodes_of_class(_astroid.If))
        return self.count(_astroid.If) - elifs

    def count_elif(self) -> int:
        """Returns the number of elif statements"""
        return sum(n.has_elif_block() for n in self.nodes_of_class(_astroid.If))

    def count_else(self) -> int:
        """Returns the number of else statements"""
        ifs = self.nodes_of_class(_astroid.If)
        return sum(len(if_.orelse) and not if_.has_elif_block() for if_ in ifs)


class ImportChecker(_ABChecker):
    def uses_import(self) -> bool:
        """Returns True if an import statement is used (does not include import-from statements)"""
        return self.uses(_astroid.Import)

    def uses_import_from(self) -> bool:
        """Returns True if an import-from statement is used"""
        return self.uses(_astroid.ImportFrom)

    def count_import(self) -> int:
        """Returns the number of imported modules or functions (does not include import-from statements)"""
        return len(self.import_statements())

    def count_import_from(self) -> int:
        """
        Returns the number of import-from statements.
        Imports from the same module are aggregated
        """
        return len(self.import_from_statements())

    def import_statements(self) -> Set[str]:
        """Returns a set of strings of all imported modules/functions (does not include import-from statements)"""
        imports = self.nodes_of_class(_astroid.Import)
        modules = {module[0] for import_ in imports for module in import_.names}
        return modules

    def import_from_statements(self) -> Dict[str, List[str]]:
        """
        Returns a dictionary mapping of imported modules to identifiers.
        Imports from the same module are aggregated
        """
        import_froms = self.nodes_of_class(_astroid.ImportFrom)
        imports = _defaultdict(list)
        for import_from in import_froms:
            imports[import_from.modname].extend([name[0] for name in import_from.names])
        return dict(imports)

    def imported_modules(self) -> Set[str]:
        """Returns the set of all imported modules (including import-from statements)"""
        modules = self.import_statements()
        modules |= self.import_from_statements().keys()
        return modules

    def all_imports(self) -> Set[str]:
        """
        Returns the set of all imported modules or identifiers

        For Example: ::

            code = '''
            from math import sqrt
            import re
            '''

            checker = ImportChecker(code)
            checker.all_imports()  # {'re', 'math.sqrt'}
        """
        modules = self.import_statements()
        import_froms = self.import_from_statements()
        import_froms = {f'{mod}.{frm}' for mod, frms in import_froms.items() for frm in frms}
        return modules | import_froms

    def imports(self, identifier, require_all=False) -> bool:
        """
        If identifier is None the function returns True if any modules are imported.

        If identifier is a single string value the function returns True if that identifier or module is imported.

        If identifier is an Iterable of strings the function behaviour will depend on require_all.
        If require_all is True the function will return True if all identifier/module names in identifier are imported.
        If require_all is False the function will return True if any identifier/module names in identifier are imported.
        """
        identifier = self.bundle(identifier)
        imports = self.all_imports()
        if identifier is None:
            return len(imports) > 0
        return self.any_or_all(require_all, (cpt in imports for cpt in identifier))


class LoopChecker(_ABChecker):
    def uses_loop(self) -> bool:
        """Returns True if a loop (for or while) is used - this does not include comprehension"""
        return self.uses((_astroid.For, _astroid.While))

    def uses_for(self) -> bool:
        """Returns True if a for loop is used - this does not include comprehension"""
        return self.uses(_astroid.For)

    def uses_while(self) -> bool:
        """Returns True if a while loop is used"""
        return self.uses(_astroid.While)

    def count_loops(self) -> int:
        """Returns the number of loops (for or while) - this does not include comprehension"""
        return self.count((_astroid.For, _astroid.While))

    def count_for(self) -> int:
        """Returns the number of for loops - this does not include comprehension"""
        return self.count(_astroid.For)

    def count_while(self) -> int:
        """Returns the number of while loops"""
        return self.count(_astroid.While)

    def max_loop_nesting(self) -> int:
        """
        Returns the maximum level of loop (for or while) nesting.
        A single loop has a nesting level of 1 a single nested loop has a nesting level of two and so on.
        If there are no loops, the max loop nesting is 0.
        This does not include comprehension.

        For example: ::

            code = '''
            for i in range(10):
                j = 0
                while j < i:
                    j += 1
            '''
            checker = LoopChecker(code)
            checker.max_loop_nesting()  # is 2
        """
        return self.max_nesting((_astroid.For, _astroid.While))

    def max_for_loop_nesting(self) -> int:
        """
        Returns the maximum level of for loop nesting.
        A single for loop has a nesting level of 1 a single nested for loop has a nesting level of two and so on.
        If there are no for loops, the max loop nesting is 0.
        This does not include comprehension.

        For example: ::

            code = '''
            for i in range(10):
                j = 0
                while j < i:
                    j += 1
            '''
            checker = LoopChecker(code)
            checker.max_for_loop_nesting()  # is 1

        Note:
            max_for_loop_nesting only checks for for loops.
            Other loop types and code blocks are searched but only for loops are counted.

        For example: ::

            code = '''
                for i in range(10):
                    j = 0
                    while j < i:
                        for k in range(j):
                            print(k)
                        j += 1
                '''
                checker = LoopChecker(code)
                checker.max_for_loop_nesting()  # is 2
                # Whereas
                checker.max_loop_nesting()  # is 3
        """
        return self.max_nesting(_astroid.For)

    def max_while_loop_nesting(self) -> int:
        """
        Returns the maximum level of while loop nesting.
        A single while loop has a nesting level of 1 a single nested while loop has a nesting level of two and so on.
        If there are no while loops, the max loop nesting is 0.
        This does not include comprehension.

        For example: ::

            code = '''
            for i in range(10):
                j = 0
                while j < i:
                    j += 1
            '''
            checker = LoopChecker(code)
            checker.max_while_loop_nesting()  # is 1

        Note:
            max_while_loop_nesting only checks for while loops.
            Other loop types and code blocks are searched but only while loops are counted.

        For example: ::

            code = '''
                for i in range(10):
                    j = 0
                    while j < i:
                        for k in range(j):
                            print(k)
                        j += 1
                '''
                checker = LoopChecker(code)
                checker.max_while_loop_nesting()  # is 1
                # Whereas
                checker.max_loop_nesting()  # is 3
        """
        return self.max_nesting(_astroid.While)

    def uses_nested_loop(self) -> bool:
        """Returns True if there is a nested loop (for or while)"""
        return self.max_loop_nesting() > 1

    def uses_nested_for(self) -> bool:
        """Returns True if there is a nested for loop"""
        return self.max_for_loop_nesting() > 1

    def uses_nested_while(self) -> bool:
        """Returns True if there is a nested while loop"""
        return self.max_while_loop_nesting() > 1


class Checker(
    ClassChecker,
    ExpressionChecker,
    FunctionChecker,
    IfChecker,
    ImportChecker,
    LoopChecker,
):
    """A generic checker - subtypes all more specific checkers"""

from collections import defaultdict as _defaultdict
from typing import Set, Dict, List

import astroid as _astroid

from .abc_checker import ABChecker as _ABChecker

__all__ = [
    'Checker',
    'ClassChecker',
    'ExpressionChecker',
    'FunctionChecker',
    'IfChecker',
    'ImportChecker',
    'LoopChecker',
]


class ClassChecker(_ABChecker):
    def defines_class(self, class_names=None, require_all=True):
        """
        If class_names is None the function returns True if any classes are defined.

        If class_names is a single string value the function returns True if that class is defined.

        If class_names is an Iterable of strings the function behaviour will depend on require_all.
        If require_all is True the function will return True if all class names in class_names are defined.
        If require_all is False the function will return True if any class names in class_names are defined.

        Class names can be qualified or unqualified with the Checker module_name
        """
        class_names = self.bundle(class_names)
        definitions = self.class_definitions(True)
        if class_names is None:
            return len(definitions) != 0
        condition = all if require_all else any
        return condition(self.qname(class_name) in definitions for class_name in class_names)

    def initializes_class(self, classes=None, require_all=False):
        """
        If classes is None the function returns True if any classes are initialised

        Classes can either be passed as a string or a Type object.
        String values are assumed to refer to classes defined within the Checker code.
        Type values refer to classes outside the Checker code.

        If classes is a single value the function returns True if the class is initialised at least once in the code.

        If classes is an Iterable the function behaviour will depend on require_all.
        If require_all is True the function will return True if all classes in classes are initialised.
        If require_all is False the function will return True if any class in classes is initialised.

        Note:
            List, Set, Tuple, and Dict literals are considered class initialisations

        Example:

            ::

                code = '''
                class Point:
                    def __init__(self, x, y):
                        self.x = x
                        self.y = y

                points = []
                for i in range(3):
                    for j in range(3):
                        points.append(Point(i, j))
                '''

                checker = ClassChecker(code)
                checker.initializes_class()  # True
                checker.initializes_class(range)  # True
                checker.initializes_class('Point')  # True
                checker.initializes_class(tuple)  # False
                checker.initializes_class([range, list, 'Point'], require_all=True)  # True
        """
        classes = self.bundle(classes)
        initializations = self.class_initializations()
        if classes is None:
            return len(initializations) != 0
        condition = all if require_all else any
        return condition(self.qname(class_) in initializations for class_ in classes)

    def class_definitions(self, qualified=False) -> Set[str]:
        """
        Returns the names of all defined classes.
        Returns fully qualified names if `qualified` is True and base names otherwise.
        """
        return {class_def.qname() if qualified else class_def.name for class_def in
                self.nodes_of_class(_astroid.ClassDef)}

    def class_initializations(self) -> Set[str]:
        """
        Returns the names of all initialised classes.
        Names are fully qualified.
        """
        calls = set()
        literals = self.nodes_of_class((_astroid.Dict, _astroid.List, _astroid.Set, _astroid.Tuple))
        calls.update(literal.qname() for literal in literals)
        for call in self.nodes_of_class(_astroid.Call):
            # ToDo - Guard against inference error
            val = next(call.func.infer())
            if isinstance(val, _astroid.ClassDef):
                calls.add(val.qname())
        return calls


class ExpressionChecker(_ABChecker):
    def uses_starred(self) -> bool:
        """Returns True if a starred expression is used"""
        return self.uses(_astroid.Starred)

    def uses_comprehension(self) -> bool:
        """Returns True if comprehension is used"""
        return self.uses(_astroid.Comprehension)

    def uses_list_comprehension(self) -> bool:
        """Returns True if list comprehension is used"""
        return self.uses(_astroid.ListComp)

    def uses_set_comprehension(self) -> bool:
        """Returns True if set comprehension is used"""
        return self.uses(_astroid.SetComp)

    def uses_dict_comprehension(self) -> bool:
        """Returns True if dict comprehension is used"""
        return self.uses(_astroid.DictComp)


class FunctionChecker(_ABChecker):
    def calls_function(self, functions=None, require_all=False):
        """
        If functions is None the function returns True if any functions are called

        Functions can either be passed as a string or a Function/Method object.
        String values are assumed to refer to functions defined within the Checker code.
        Function/Method values refer to functions outside the Checker code.

        If functions is a single value the function returns True if the function is called at least once in the code.

        If functions is an Iterable the function behaviour will depend on require_all.
        If require_all is True the function will return True if all functions in functions are called.
        If require_all is False the function will return True if any function in functions is called.

        Example:

            ::

                code = '''
                class Point:
                    def __init__(self, x, y):
                       self.x = x
                       self.y = y

                    def add(self, other):
                        ...

                points = []
                point1 = Point(1, 3)
                point2 = Point(4, 9)
                point1.add(point2)
                points.append(point1)
                '''

                checker = FunctionChecker(code)
                checker.calls_function(list.append)  # True
                checker.calls_function('Point.add')  # True
                checker.calls_function([list.append, 'Point.add', another_function], require_all=False)  # True
        """
        functions = self.bundle(functions)
        calls = self.function_calls()
        if functions is None:
            return len(calls) != 0
        condition = all if require_all else any
        return condition(self.qname(function) in calls for function in functions)

    def defines_function(self, function_names=None, require_all=True):
        """
        If function_names is None the function returns True if any functions are defined.

        If function_names is a single string value the function returns True if that function is defined.

        If function_names is an Iterable of strings the function behaviour will depend on require_all.
        If require_all is True the function will return True if all function names in function_names are defined.
        If require_all is False the function will return True if any function names in function_names are defined.

        Function names can be qualified or unqualified with the Checker module_name.

        Note:
            Methods are not distinguished from functions but must be referred to by their qualified name.

            For Example: ::

                code = '''
                class Point:
                    def add(self, other):
                        ...
                '''

                checker = FunctionChecker(code)
                checker.defines_function('add')  # False
                checker.defines_function('Point.add')  # True
        """
        function_names = self.bundle(function_names)
        definitions = self.function_definitions(True)
        if function_names is None:
            return len(definitions) != 0
        condition = all if require_all else any
        return condition(self.qname(function_name) in definitions for function_name in function_names)

    def function_definitions(self, qualified=False):
        """
        Returns the names of all defined functions.
        Returns fully qualified names if `qualified` is True and base names otherwise.
        """
        return {func_def.qname() if qualified else func_def.name for func_def in
                self.nodes_of_class(_astroid.FunctionDef)}

    def function_calls(self):
        """
        Returns the names of all called functions.
        Names are fully qualified.
        """
        calls = set()
        for call in self.nodes_of_class(_astroid.Call):
            # ToDo - Guard against inference error
            val = next(call.func.infer())
            if isinstance(val, (_astroid.FunctionDef, _astroid.BoundMethod, _astroid.UnboundMethod)):
                calls.add(val.qname())
        return calls

    def count_function_defs(self) -> int:
        """Returns the number of function definitions"""
        return self.count(_astroid.FunctionDef)

    def count_function_calls(self) -> int:
        """Returns the static number of function calls"""
        return self.count(_astroid.Call)

    def defines_nested_function(self) -> bool:
        """Returns True if a function is defined within another function"""
        return self.max_nesting(_astroid.FunctionDef) > 1

    def uses_recursion(self) -> bool:
        """Returns True if there is a recursive function call"""
        calls = _defaultdict(set)
        function_defs = self.nodes_of_class(_astroid.FunctionDef)
        for definition in function_defs:
            for call in definition.nodes_of_class(_astroid.Call):
                # ToDo - Guard against inference error
                calls[definition.name].add(next(call.func.infer()).name)

        def _contains_loops(g):
            path = set()

            def visit(vertex):
                path.add(vertex)
                for neighbour in g.get(vertex, ()):
                    if neighbour in path or visit(neighbour):
                        return True
                path.remove(vertex)
                return False

            return any(visit(v) for v in g)

        return _contains_loops(calls)


class IfChecker(_ABChecker):
    def uses_if(self) -> bool:
        """Returns True if an if statement is used"""
        return self.uses(_astroid.If)

    def uses_elif(self) -> bool:
        """Returns True if an elif statement is used"""
        ifs = self.nodes_of_class(_astroid.If)
        return any(if_.has_elif_block() for if_ in ifs)

    def uses_else(self) -> bool:
        """Returns True if an else statement is used"""
        ifs = self.nodes_of_class(_astroid.If)
        return any(len(if_.orelse) and not if_.has_elif_block() for if_ in ifs)

    def count_if(self) -> int:
        """Returns the number of if statements"""
        elifs = sum(n.has_elif_block() for n in self.nodes_of_class(_astroid.If))
        return self.count(_astroid.If) - elifs

    def count_elif(self) -> int:
        """Returns the number of elif statements"""
        return sum(n.has_elif_block() for n in self.nodes_of_class(_astroid.If))

    def count_else(self) -> int:
        """Returns the number of else statements"""
        ifs = self.nodes_of_class(_astroid.If)
        return sum(len(if_.orelse) and not if_.has_elif_block() for if_ in ifs)


class ImportChecker(_ABChecker):
    def uses_import(self) -> bool:
        """Returns True if an import statement is used (does not include import-from statements)"""
        return self.uses(_astroid.Import)

    def uses_import_from(self) -> bool:
        """Returns True if an import-from statement is used"""
        return self.uses(_astroid.ImportFrom)

    def count_import(self) -> int:
        """Returns the number of imported modules or functions (does not include import-from statements)"""
        return len(self.import_statements())

    def count_import_from(self) -> int:
        """
        Returns the number of import-from statements.
        Imports from the same module are aggregated
        """
        return len(self.import_from_statements())

    def import_statements(self) -> Set[str]:
        """Returns a set of strings of all imported modules/functions (does not include import-from statements)"""
        imports = self.nodes_of_class(_astroid.Import)
        modules = {module[0] for import_ in imports for module in import_.names}
        return modules

    def import_from_statements(self) -> Dict[str, List[str]]:
        """
        Returns a dictionary mapping of imported modules to identifiers.
        Imports from the same module are aggregated
        """
        import_froms = self.nodes_of_class(_astroid.ImportFrom)
        imports = _defaultdict(list)
        for import_from in import_froms:
            imports[import_from.modname].extend([name[0] for name in import_from.names])
        return dict(imports)

    def imported_modules(self) -> Set[str]:
        """Returns the set of all imported modules (including import-from statements)"""
        modules = self.import_statements()
        modules |= self.import_from_statements().keys()
        return modules

    def all_imports(self) -> Set[str]:
        """
        Returns the set of all imported modules or identifiers

        For Example: ::

            code = '''
            from math import sqrt
            import re
            '''

            checker = ImportChecker(code)
            checker.all_imports()  # {'re', 'math.sqrt'}
        """
        modules = self.import_statements()
        import_froms = self.import_from_statements()
        import_froms = {f'{mod}.{frm}' for mod, frms in import_froms.items() for frm in frms}
        return modules | import_froms

    def imports(self, identifier, require_all=False) -> bool:
        """
        If identifier is None the function returns True if any modules are imported.

        If identifier is a single string value the function returns True if that identifier or module is imported.

        If identifier is an Iterable of strings the function behaviour will depend on require_all.
        If require_all is True the function will return True if all identifier/module names in identifier are imported.
        If require_all is False the function will return True if any identifier/module names in identifier are imported.
        """
        identifier = self.bundle(identifier)
        imports = self.all_imports()
        if identifier is None:
            return len(imports) > 0
        return self.any_or_all(require_all, (cpt in imports for cpt in identifier))


class LoopChecker(_ABChecker):
    def uses_loop(self) -> bool:
        """Returns True if a loop (for or while) is used - this does not include comprehension"""
        return self.uses((_astroid.For, _astroid.While))

    def uses_for(self) -> bool:
        """Returns True if a for loop is used - this does not include comprehension"""
        return self.uses(_astroid.For)

    def uses_while(self) -> bool:
        """Returns True if a while loop is used"""
        return self.uses(_astroid.While)

    def count_loops(self) -> int:
        """Returns the number of loops (for or while) - this does not include comprehension"""
        return self.count((_astroid.For, _astroid.While))

    def count_for(self) -> int:
        """Returns the number of for loops - this does not include comprehension"""
        return self.count(_astroid.For)

    def count_while(self) -> int:
        """Returns the number of while loops"""
        return self.count(_astroid.While)

    def max_loop_nesting(self) -> int:
        """
        Returns the maximum level of loop (for or while) nesting.
        A single loop has a nesting level of 1 a single nested loop has a nesting level of two and so on.
        If there are no loops, the max loop nesting is 0.
        This does not include comprehension.

        For example: ::

            code = '''
            for i in range(10):
                j = 0
                while j < i:
                    j += 1
            '''
            checker = LoopChecker(code)
            checker.max_loop_nesting()  # is 2
        """
        return self.max_nesting((_astroid.For, _astroid.While))

    def max_for_loop_nesting(self) -> int:
        """
        Returns the maximum level of for loop nesting.
        A single for loop has a nesting level of 1 a single nested for loop has a nesting level of two and so on.
        If there are no for loops, the max loop nesting is 0.
        This does not include comprehension.

        For example: ::

            code = '''
            for i in range(10):
                j = 0
                while j < i:
                    j += 1
            '''
            checker = LoopChecker(code)
            checker.max_for_loop_nesting()  # is 1

        Note:
            max_for_loop_nesting only checks for for loops.
            Other loop types and code blocks are searched but only for loops are counted.

        For example: ::

            code = '''
                for i in range(10):
                    j = 0
                    while j < i:
                        for k in range(j):
                            print(k)
                        j += 1
                '''
                checker = LoopChecker(code)
                checker.max_for_loop_nesting()  # is 2
                # Whereas
                checker.max_loop_nesting()  # is 3
        """
        return self.max_nesting(_astroid.For)

    def max_while_loop_nesting(self) -> int:
        """
        Returns the maximum level of while loop nesting.
        A single while loop has a nesting level of 1 a single nested while loop has a nesting level of two and so on.
        If there are no while loops, the max loop nesting is 0.
        This does not include comprehension.

        For example: ::

            code = '''
            for i in range(10):
                j = 0
                while j < i:
                    j += 1
            '''
            checker = LoopChecker(code)
            checker.max_while_loop_nesting()  # is 1

        Note:
            max_while_loop_nesting only checks for while loops.
            Other loop types and code blocks are searched but only while loops are counted.

        For example: ::

            code = '''
                for i in range(10):
                    j = 0
                    while j < i:
                        for k in range(j):
                            print(k)
                        j += 1
                '''
                checker = LoopChecker(code)
                checker.max_while_loop_nesting()  # is 1
                # Whereas
                checker.max_loop_nesting()  # is 3
        """
        return self.max_nesting(_astroid.While)

    def uses_nested_loop(self) -> bool:
        """Returns True if there is a nested loop (for or while)"""
        return self.max_loop_nesting() > 1

    def uses_nested_for(self) -> bool:
        """Returns True if there is a nested for loop"""
        return self.max_for_loop_nesting() > 1

    def uses_nested_while(self) -> bool:
        """Returns True if there is a nested while loop"""
        return self.max_while_loop_nesting() > 1


class Checker(
    ClassChecker,
    ExpressionChecker,
    FunctionChecker,
    IfChecker,
    ImportChecker,
    LoopChecker,
):
    """A generic checker - subtypes all more specific checkers"""
If class_names is None the function returns True if any classes are defined.          If class_names is a single string value the function returns True if that class is defined.          If class_names is an Iterable of strings the function behaviour will depend on require_all.         If require_all is True the function will return True if all class names in class_names are defined.         If require_all is False the function will return True if any class names in class_names are defined.          Class names can be qualified or unqualified with the Checker module_name If classes is None the function returns True if any classes are initialised          Classes can either be passed as a string or a Type object.         String values are assumed to refer to classes defined within the Checker code.         Type values refer to classes outside the Checker code.          If classes is a single value the function returns True if the class is initialised at least once in the code.          If classes is an Iterable the function behaviour will depend on require_all.         If require_all is True the function will return True if all classes in classes are initialised.         If require_all is False the function will return True if any class in classes is initialised.          Note:             List, Set, Tuple, and Dict literals are considered class initialisations          Example:              ::                  code = '''                 class Point:                     def __init__(self, x, y):                         self.x = x                         self.y = y                  points = []                 for i in range(3):                     for j in range(3):                         points.append(Point(i, j))                 '''                  checker = ClassChecker(code)                 checker.initializes_class()  # True                 checker.initializes_class(range)  # True                 checker.initializes_class('Point')  # True                 checker.initializes_class(tuple)  # False                 checker.initializes_class([range, list, 'Point'], require_all=True)  # True Returns the names of all defined classes.         Returns fully qualified names if `qualified` is True and base names otherwise. Returns the names of all initialised classes.         Names are fully qualified. # ToDo - Guard against inference error Returns True if a starred expression is used Returns True if comprehension is used Returns True if list comprehension is used Returns True if set comprehension is used Returns True if dict comprehension is used If functions is None the function returns True if any functions are called          Functions can either be passed as a string or a Function/Method object.         String values are assumed to refer to functions defined within the Checker code.         Function/Method values refer to functions outside the Checker code.          If functions is a single value the function returns True if the function is called at least once in the code.          If functions is an Iterable the function behaviour will depend on require_all.         If require_all is True the function will return True if all functions in functions are called.         If require_all is False the function will return True if any function in functions is called.          Example:              ::                  code = '''                 class Point:                     def __init__(self, x, y):                        self.x = x                        self.y = y                      def add(self, other):                         ...                  points = []                 point1 = Point(1, 3)                 point2 = Point(4, 9)                 point1.add(point2)                 points.append(point1)                 '''                  checker = FunctionChecker(code)                 checker.calls_function(list.append)  # True                 checker.calls_function('Point.add')  # True                 checker.calls_function([list.append, 'Point.add', another_function], require_all=False)  # True If function_names is None the function returns True if any functions are defined.          If function_names is a single string value the function returns True if that function is defined.          If function_names is an Iterable of strings the function behaviour will depend on require_all.         If require_all is True the function will return True if all function names in function_names are defined.         If require_all is False the function will return True if any function names in function_names are defined.          Function names can be qualified or unqualified with the Checker module_name.          Note:             Methods are not distinguished from functions but must be referred to by their qualified name.              For Example: ::                  code = '''                 class Point:                     def add(self, other):                         ...                 '''                  checker = FunctionChecker(code)                 checker.defines_function('add')  # False                 checker.defines_function('Point.add')  # True Returns the names of all defined functions.         Returns fully qualified names if `qualified` is True and base names otherwise. Returns the names of all called functions.         Names are fully qualified. # ToDo - Guard against inference error Returns the number of function definitions Returns the static number of function calls Returns True if a function is defined within another function Returns True if there is a recursive function call # ToDo - Guard against inference error Returns True if an if statement is used Returns True if an elif statement is used Returns True if an else statement is used Returns the number of if statements Returns the number of elif statements Returns the number of else statements Returns True if an import statement is used (does not include import-from statements) Returns True if an import-from statement is used Returns the number of imported modules or functions (does not include import-from statements) Returns the number of import-from statements.         Imports from the same module are aggregated Returns a set of strings of all imported modules/functions (does not include import-from statements) Returns a dictionary mapping of imported modules to identifiers.         Imports from the same module are aggregated Returns the set of all imported modules (including import-from statements) Returns the set of all imported modules or identifiers          For Example: ::              code = '''             from math import sqrt             import re             '''              checker = ImportChecker(code)             checker.all_imports()  # {'re', 'math.sqrt'} If identifier is None the function returns True if any modules are imported.          If identifier is a single string value the function returns True if that identifier or module is imported.          If identifier is an Iterable of strings the function behaviour will depend on require_all.         If require_all is True the function will return True if all identifier/module names in identifier are imported.         If require_all is False the function will return True if any identifier/module names in identifier are imported. Returns True if a loop (for or while) is used - this does not include comprehension Returns True if a for loop is used - this does not include comprehension Returns True if a while loop is used Returns the number of loops (for or while) - this does not include comprehension Returns the number of for loops - this does not include comprehension Returns the number of while loops Returns the maximum level of loop (for or while) nesting.         A single loop has a nesting level of 1 a single nested loop has a nesting level of two and so on.         If there are no loops, the max loop nesting is 0.         This does not include comprehension.          For example: ::              code = '''             for i in range(10):                 j = 0                 while j < i:                     j += 1             '''             checker = LoopChecker(code)             checker.max_loop_nesting()  # is 2 Returns the maximum level of for loop nesting.         A single for loop has a nesting level of 1 a single nested for loop has a nesting level of two and so on.         If there are no for loops, the max loop nesting is 0.         This does not include comprehension.          For example: ::              code = '''             for i in range(10):                 j = 0                 while j < i:                     j += 1             '''             checker = LoopChecker(code)             checker.max_for_loop_nesting()  # is 1          Note:             max_for_loop_nesting only checks for for loops.             Other loop types and code blocks are searched but only for loops are counted.          For example: ::              code = '''                 for i in range(10):                     j = 0                     while j < i:                         for k in range(j):                             print(k)                         j += 1                 '''                 checker = LoopChecker(code)                 checker.max_for_loop_nesting()  # is 2                 # Whereas                 checker.max_loop_nesting()  # is 3 Returns the maximum level of while loop nesting.         A single while loop has a nesting level of 1 a single nested while loop has a nesting level of two and so on.         If there are no while loops, the max loop nesting is 0.         This does not include comprehension.          For example: ::              code = '''             for i in range(10):                 j = 0                 while j < i:                     j += 1             '''             checker = LoopChecker(code)             checker.max_while_loop_nesting()  # is 1          Note:             max_while_loop_nesting only checks for while loops.             Other loop types and code blocks are searched but only while loops are counted.          For example: ::              code = '''                 for i in range(10):                     j = 0                     while j < i:                         for k in range(j):                             print(k)                         j += 1                 '''                 checker = LoopChecker(code)                 checker.max_while_loop_nesting()  # is 1                 # Whereas                 checker.max_loop_nesting()  # is 3 Returns True if there is a nested loop (for or while) Returns True if there is a nested for loop Returns True if there is a nested while loop A generic checker - subtypes all more specific checkers