CollageLabs/candyshop

View on GitHub
candyshop/environment.py

Summary

Maintainability
D
3 days
Test Coverage
# -*- coding: utf-8 -*-
#
# Please refer to AUTHORS.rst for a complete list of Copyright holders.
# Copyright (C) 2016-2022, Candyshop Developers.

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.

# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.
"""
``candyshop.environment`` is a module for creating Odoo environments.

This module implements an abstraction layer to create an environment where
bundles can be consulted for different reports.
"""

import os
import sys
import shutil
import tempfile

from sh import git

from .bundle import Bundle

DEFAULT_URL = 'https://github.com/odoo/odoo'
DEFAULT_BRANCH = '15.0'


class Environment(object):
    """
    An Environment is a virtual space where you can enclose bundles.

    Think about it as an invisible container where you can put bundles
    to study its relationships; for example, listing all modules and see which
    ones have missing dependencies (that are not within the environment).
    """

    def __init__(self, init=True, init_from=None,
                 url=DEFAULT_URL, branch=DEFAULT_BRANCH):
        """
        Initialize the ``Environment`` instance.

        :param init: (boolean) specifies if the environment should be
                     initialized, that is, if an Odoo repo should be cloned
                     and the native addons added as bundles. Default: True.
        :param init_from: (string) a path pointing to an Odoo codebase. If
                          present, the Odoo codebase will be taken from this
                          folder instead of cloning from start.
        :param url: (string) an URL pointing to a git repository. This URL is
                     used to clone the Odoo Codebase if ``init`` is ``True``
                     and ``init_from`` is ``None``.
        :param branch: (string) the branch used to clone ``url``.
        :return: an ``Environment`` instance.

        .. versionadded:: 0.1.0
        """
        #: Attribute ``Environment.bundles`` (list): A list of ``Bundle``
        #: instances representing the bundles contained in this environment.
        self.bundles = []

        #: Attribute ``Environment.path`` (string): A path pointing to
        #: the temporary directory where odoo and OCA dependencies will be
        #: cloned.
        self.path = tempfile.mkdtemp()

        if init:
            self.__initialize_odoo(url, branch, init_from)

    def __initialize_odoo(self, url=DEFAULT_URL, branch=DEFAULT_BRANCH,
                          init_from=None):
        """
        Private method to clone an Odoo codebase inside the Environment path.

        This method clones an odoo codebase specified by ``url`` and
        ``branch`` so that bundles can have the native odoo bundles to compare
        with. Without this method, native modules (base, board, etc) would
        appear as missing dependencies.

        .. versionadded:: 0.1.0
        """
        if init_from:
            if not os.path.isdir(init_from):
                raise Exception('init_from directory "{0}" doesn\'t'
                                ' exist'.format(init_from))
            odoo_dir = os.path.abspath(init_from)
        else:
            odoo_dir = os.path.join(self.path, 'odoo')
            if not os.path.isdir(odoo_dir):
                self.__git_clone(url, branch, odoo_dir)
        self.addbundles([
            os.path.join(odoo_dir, 'addons'),
            os.path.join(odoo_dir, 'odoo', 'addons')
        ])

    @staticmethod
    def __git_clone(url, branch, path):
        """
        Private method to clone a git repository.

        This method clones a git repository specified by ``url`` and
        ``branch`` to a folder ``path``. The ``--depth=1`` option is passed
        to the command to avoid cloning full history.

        .. versionadded:: 0.1.0
        """
        try:
            git.clone(url, path, quiet=True, depth=1, branch=branch)
        except BaseException:
            print('There was a problem cloning {0}.'.format(url))
            raise

    def __clone_deptree(self):
        """
        Private method that clones the dependency tree of existing bundles.

        It reads the oca_dependencies attribute of each bundle, clones each one
        (if any) and then adds them as bundles (which invokes this method again
        to satisfy dependencies in the new bundles).

        .. versionadded:: 0.1.0
        """
        for bundle in self.bundles:
            for name, url, branch in bundle.oca_dependencies:
                bundle_dir = os.path.join(self.path, name)
                if os.path.isdir(bundle_dir):
                    continue
                self.__git_clone(url=url, branch=branch, path=bundle_dir)
                self.addbundles([bundle_dir])

    def __deps_notin_e(self, deps=None):
        """
        Private method that informs about missing modules in the environment.

        :param deps: (list) a list of module names to check.
        :return: (generator) a generator that produces an iterable of
                 module names that are not present in the environment.

        .. versionadded:: 0.1.0
        """
        deps = deps or []
        for dep in deps:
            if dep not in self.get_modules_slug_list():
                yield dep

    def addbundles(self, locations=None, exclude_tests=True):
        """
        Public method that inserts bundles inside the environment.

        This method register a list of bundles and builds the dependency tree
        of each bundle recursively by calling ``__clone_deptree()``.

        :param locations: (list) a list of strings containing relative or
                          absolute paths to directories containig bundles.
        :param exclude_tests: (boolean) if ``True``, will exclude modules
                              inside ``tests`` directories.

        .. versionadded:: 0.1.0
        """
        locations = locations or []
        for location in locations:
            location = os.path.abspath(location)
            if location in self.get_bundle_path_list() or \
               not os.path.isdir(location):
                continue
            try:
                self.bundles.append(Bundle(location, exclude_tests))
            except BaseException:
                print(('There was a problem inserting the bundle'
                       ' located at {0}').format(location))
                raise
            else:
                self.__clone_deptree()

    def destroy(self):
        """
        Public method to destroy an ``Environment`` instance.

        This method empties the bundle list and deletes the environment path,
        including all previously clones bundles.

        .. versionadded:: 0.1.0
        """
        self.bundles = []
        shutil.rmtree(self.path)

    def reset(self):
        """
        Public method to destroy and initialize the ``Environment`` instance.

        This method calls ``self.destroy()`` on the instance and then
        re-initializes the Odoo environment.
        """
        self.destroy()
        self.__initialize_odoo()

    def get_bundle_path_list(self):
        """
        Public method that informs about bundle paths.

        :return: (generator) a generator that produces an iterable of
                 paths pointing to the bundles registered so far.

        .. versionadded:: 0.1.0
        """
        for bundle in self.bundles:
            yield bundle.path

    def get_modules_list(self):
        """
        Public method that informs about modules instances.

        :return: (generator) a generator that produces an iterable of
                 ``Module`` instances of all the modules present within
                 the environment.

        .. versionadded:: 0.1.0
        """
        for bundle in self.bundles:
            for module in bundle.modules:
                yield module

    def get_modules_slug_list(self):
        """
        Public method that informs about module names.

        :return: (generator) a generator that produces an iterable of strings
                 containing the names of all the modules present within the
                 environment.

        .. versionadded:: 0.1.0
        """
        for bundle in self.bundles:
            for module in bundle.modules:
                yield module.properties.slug

    def get_notmet_dependencies(self):
        """
        Public method that informs about missing dependencies in modules.

        :return: (generator) a generator that produces an iterable of
                 dictionaries containing references to each bundle that have
                 unmet dependencies within a module. The output is something
                 similar tho this::

                    [
                        {'bundle-name': {
                            'module_name': ['missing_module_a',
                                            'missing_module_b']
                            }
                        }
                    ]

        .. versionadded:: 0.1.0
        """
        for module in self.get_modules_list():
            if hasattr(module.properties, 'depends'):
                deplist = list(self.__deps_notin_e(module.properties.depends))
                if not deplist:
                    continue
                yield {module.bundle.name: {module.properties.slug: deplist}}

    def get_notmet_record_ids(self):
        """
        Public method that informs about missing dependencies in XML files.

        :return: (generator) a generator that produces an iterable of
                 dictionaries containing references to each bundle that have
                 unmet dependencies refereced inside XML record ids. The output
                 is something similar to this::

                    [
                        {'bundle-name': {
                            'module_name/path/file.xml': ['missing_module_a',
                                                          'missing_module_b']
                             }
                        }
                    ]

        .. versionadded:: 0.1.0
        """
        for module in self.get_modules_list():
            for data in module.get_record_ids_module_references():
                for xml, refs in data.items():
                    deplist = list(self.__deps_notin_e(refs))
                    if not deplist:
                        continue
                    relxml = os.path.join(module.properties.slug, xml)
                    yield {module.bundle.name: {relxml: deplist}}

    def get_notmet_dependencies_report(self):
        """
        Public method that reports missing dependencies in modules.

        :return: (string) a report of human readable output for the
                 ``get_notmet_dependencies()`` method.

        .. versionadded:: 0.1.0
        """
        report = list(self.get_notmet_dependencies())
        if report:
            print('The following module dependencies are not found'
                  ' in the environment:')
            for item in report:
                bundle, data = list(item.items())[0]
                module, depends = list(data.items())[0]
                print('')
                print('    Bundle: {0}'.format(bundle))
                print('    Module: {0}'.format(module))
                print('    Missing dependencies:')
                for dep in depends:
                    print('        - {0}'.format(dep))
            print('')
            sys.exit(1)
        else:
            print('All dependencies are satisfied in the environment.')

    def get_notmet_record_ids_report(self):
        """
        Public method that reports missing dependencies in XML files.

        :return: (string) a report of human readable output for the
                 ``get_notmet_record_ids()`` method.

        .. versionadded:: 0.1.0
        """
        report = list(self.get_notmet_record_ids())
        if report:
            print('The following record ids are not found in the environment:')
            for item in report:
                bundle, data = list(item.items())[0]
                xmlfile, depends = list(data.items())[0]
                print('')
                print('    Bundle: {0}'.format(bundle))
                print('    XML file: {0}'.format(xmlfile))
                print('    Missing references:')
                for dep in depends:
                    print('        - {0}'.format(dep))
            print('')
            sys.exit(1)
        else:
            print('All references are present in the environment.')