avocado-framework/avocado

View on GitHub
avocado/core/safeloader/imported.py

Summary

Maintainability
A
1 hr
Test Coverage
A
97%
import ast
import os
import sys
from importlib.machinery import PathFinder

from avocado.core.safeloader.utils import get_statement_import_as


class ImportedSymbol:
    """A representation of an importable symbol.

    Attributes:

    module_path : str
    symbol : str
    importer_fs_path: str or None
    """

    def __init__(
        self,
        module_path,
        symbol="",
        importer_fs_path=None,
        module_alias="",
        symbol_alias="",
    ):
        #: Path from where the symbol was imported.  On a statement such as
        #: "import os", module_path is "os" and there's no symbol.
        #: On a statement such as from unittest.mock import mock_open",
        #: the module_path is "unittest.mock".  On a statement such as
        #: "from ..foo import bar", module_path is "..foo" (relative).
        self.module_path = module_path
        #: The name of the imported symbol.  On a statement such as
        #: "import os", there's no symbol.  On a statement such as
        #: "from unittest import mock"", the symbol is "mock" (even
        #: though it may actually also be a module, but it's impossible
        #: to know for sure).  On a statement such as "from unittest.mock
        #: import mock_open", symbol is "mock_open".
        self.symbol = symbol
        #: The full, absolute filesystem path of the module importing
        #: this symbol.  This is used for relative path calculations,
        #: but it's limited to relative modules that also share the
        #: filesystem location.  An example is "/path/to/mytest.py",
        #: that can contain:
        #:
        #: from .base import BaseTestClass
        #:
        #: And thus will have a symbol of "BaseTestClass" and the
        #: module as ".base".  The relative filesystem path of the
        #: module (which should contain the symbol) will be
        #: "/path/to".
        #:
        #: And if "/path/to/common/test.py" contains:
        #:
        #: from ..base import BaseTestClass
        #:
        #: The relative filesystem path of the module (which should
        #: contain the symbol) will be "/path/to".
        self.importer_fs_path = importer_fs_path
        #: An optional alias for the module, such as when a
        #: "import os as operating_system" statement is given.
        self.module_alias = module_alias
        #: An optional alias the symbol, such as when a
        #: "from os import path as os_path" is given
        self.symbol_alias = symbol_alias

    def _walk_importable_components(self, symbol_is_module=False):
        if symbol_is_module:
            full_name = f"{self.module_path}.{self.symbol}"
        else:
            full_name = self.module_path
        # Stripping leading dots, as relative paths will be handled with
        # insertion to module_paths with the relative path
        components = full_name.strip(".").split(".")
        for index, component in enumerate(components):
            if index > 0:
                previous = components[index - 1]
            else:
                previous = ""
            yield (component, previous)

    def get_importable_spec(self, symbol_is_module=False):
        """Returns the specification of an actual importable module.

        This is a check based on the limitations that we do not
        actually perform an import, and assumes a directory structure
        with modules.

        :param symbol_is_module: if it's known that the symbol is also
                                 a module, include it in the search for
                                 an importable spec
        :type symbol_is_module: bool
        """
        modules_paths = sys.path
        modules_paths.insert(0, self.get_relative_module_fs_path())
        spec = None
        for component, previous in self._walk_importable_components(symbol_is_module):
            if previous:
                modules_paths = [
                    os.path.join(mod, previous) for mod in modules_paths[:]
                ]
            spec = PathFinder.find_spec(component, modules_paths)
            if spec is None:
                break
        return spec

    def is_importable(self, symbol_is_module=False):
        """Checks whether this imported symbol seems to be importable.

        This is a check based on the limitations that we do not
        actually perform an import, and assumes a directory structure
        with modules.

        :param symbol_is_module: if it's known that the symbol is also
                                 a module, include it in the search for
                                 an importable spec
        :type symbol_is_module: bool
        """
        return self.get_importable_spec(symbol_is_module) is not None

    @staticmethod
    def _split_last_module_path_component(module_path):
        """Splits a module path into a lower and topmost components.

        It also discards any information about relative location.

        :param module_path: a module path, such as "os" or "os.path"
                            or "..selftests.utils"
        :type module_path: str
        :returns: the lower and topmost components
        :rtype: tuple
        """
        non_relative = module_path.strip(".")
        if "." in non_relative:
            module_components = non_relative.rsplit(".", 1)
            if len(module_components) == 2:
                return (module_components[0], module_components[1])
        return ("", non_relative)

    @staticmethod
    def _get_relative_prefix(statement):
        """Returns the string that represents to the relative import level.

        :param statement: an "import from" ast statement
        :type statement: :class:`ast.ImportFrom`
        :returns: the string that represents the relative import level.
        :rtype: str
        """
        relative_level = getattr(statement, "level", 0) or 0
        return "".join(["." for _ in range(relative_level)])

    @staticmethod
    def get_symbol_from_statement(statement):
        return ImportedSymbol.get_symbol_module_path_from_statement(statement)[0]

    @staticmethod
    def get_module_path_from_statement(statement):
        return ImportedSymbol.get_symbol_module_path_from_statement(statement)[1]

    @staticmethod
    def get_symbol_module_path_from_statement(statement, name_index=0):
        symbol = ""
        module_path = ""
        module_alias = ""
        symbol_alias = ""
        import_as = get_statement_import_as(statement)
        names = list(import_as.keys())
        as_names = list(import_as.values())

        if isinstance(statement, ast.Import):
            # On an Import statement, it's impossible to import a symbol
            # so everything is the module_path
            module_path = names[name_index]
            module_alias = as_names[name_index]

        elif isinstance(statement, ast.ImportFrom):
            symbol = names[name_index]
            relative = ImportedSymbol._get_relative_prefix(statement)
            module_name = statement.module or ""
            module_path = relative + module_name
            symbol_alias = as_names[name_index]

        return symbol, module_path, module_alias, symbol_alias

    @property
    def module_name(self):
        """The final name of the module from its importer perspective.

        If a alias exists, it will be the alias name.  If not, it will
        be the original name.
        """
        if self.module_alias:
            return self.module_alias
        return self.module_path

    @property
    def symbol_name(self):
        """The final name of the symbol from its importer perspective.

        If a alias exists, it will be the alias name.  If not, it will
        be the original name.
        """
        if self.symbol_alias:
            return self.symbol_alias
        return self.symbol

    @classmethod
    def from_statement(cls, statement, importer_fs_path=None, index=0):
        (
            symbol,
            module_path,
            module_alias,
            symbol_alias,
        ) = cls.get_symbol_module_path_from_statement(statement, index)
        return cls(module_path, symbol, importer_fs_path, module_alias, symbol_alias)

    def to_str(self):
        """Returns a string representation of the plausible statement used."""
        if not self.symbol:
            return f"import {self.module_path}"
        return f"from {self.module_path} import {self.symbol}"

    def is_relative(self):
        """Returns whether the imported symbol is on a relative path."""
        return self.module_path.startswith(".")

    def get_relative_module_fs_path(self):
        """Returns the module base dir, based on its relative path

        The base dir for the module is the directory where one is
        expected to find the first module of the module path.  For a
        module path of "..foo.bar", and its importer being at
        "/abs/path/test.py", the base dir where "foo" is supposed to
        be found would be "/abs".  And as a consequence, "bar" would
        be found at "/abs/foo/bar".

        This assumes that the module path is indeed related to the location
        of its importer.  This may not be true if the namespaces match, but
        are distributed across different filesystem paths.
        """
        path = os.path.dirname(self.importer_fs_path)
        for char in self.module_path[1:]:
            if char != ".":
                break
            path = os.path.dirname(path)
        return path

    def get_parent_fs_path(self):
        if self.is_relative():
            return self.get_relative_module_fs_path()
        parent_path = os.path.dirname(self.importer_fs_path)
        if self.module_path:
            return os.path.join(parent_path, self.module_path)
        return parent_path

    def __repr__(self):
        return (
            f'<ImportedSymbol module_path="{self.module_path}"'
            f'symbol="{self.symbol}"'
            f'importer_fs_path="{self.importer_fs_path}">'
        )

    def __eq__(self, other):
        return (
            (self.module_path == other.module_path)
            and (self.symbol == other.symbol)
            and (self.importer_fs_path == other.importer_fs_path)
        )