diff --git a/cmmnbuild_dep_manager/__main__.py b/cmmnbuild_dep_manager/__main__.py index 78a20bc5b797188b9696e8f0bfdfac376f89c3ae..db94e0c67416206c04af597477320fc1a0e71b76 100644 --- a/cmmnbuild_dep_manager/__main__.py +++ b/cmmnbuild_dep_manager/__main__.py @@ -56,6 +56,7 @@ def configure_parser(parser: argparse.ArgumentParser): "install", "is_installed", "is_registered", + "is_resolved", "jar_path", "jars", "list", diff --git a/cmmnbuild_dep_manager/cmmnbuild_dep_manager.py b/cmmnbuild_dep_manager/cmmnbuild_dep_manager.py index e56255d27c810bce48aab5324f67107613f0a223..ce002ccec7f287d336f4995c85878a2b0cad29f3 100644 --- a/cmmnbuild_dep_manager/cmmnbuild_dep_manager.py +++ b/cmmnbuild_dep_manager/cmmnbuild_dep_manager.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- '''CommonBuild Dependency Manager Copyright (c) CERN 2015-2020 @@ -29,6 +28,7 @@ Authors: ''' from contextlib import contextmanager +import functools import glob import importlib import json @@ -40,6 +40,7 @@ import re import shutil import signal import site + import six import zipfile @@ -51,14 +52,27 @@ import entrypoints PKG_MODULES_JSON = pathlib.Path(__file__).parent / 'modules.json' +def _requires_resolver(fn): + """A decorator to ensure that a resolver is setup before the function is run.""" + @functools.wraps(fn) + def wrapped(self, *args, **kwargs): + if not getattr(self, '_resolver', None): + self._setup_resolver() + return fn(self, *args, **kwargs) + return wrapped + + class Manager(object): def __init__(self, pkg=None, lvl=None): + # Note: There are two patterns being used with Manager: + # 1: Manager() - a thing that can be manipulated and resolved explicitly + # 2: Manager(pkg=...) - a thing that will be resolved automatically during + # construction. logging.basicConfig() self.log = logging.getLogger(__package__) if lvl is not None: self.log.setLevel(lvl) - self._setup_resolver() needs_installation = pkg is not None and not self.is_installed(pkg) if needs_installation: # Temporarily set the log level to notify the user that this could @@ -183,6 +197,7 @@ class Manager(object): return matches[1], matches[2] + @_requires_resolver def class_doc(self, obj_or_string): '''Return URLs of the documentation and source code of a class @@ -409,7 +424,7 @@ class Manager(object): self.log.info('using resolver: {0} ({1})'.format(resolver.__name__, resolver.description)) break - def _find_supported_resolvers(self, module): + def _find_supported_resolvers_for_module(self, module): supported_resolvers = [] from .resolver import resolvers for resolver in resolvers(): @@ -421,7 +436,45 @@ class Manager(object): pass return supported_resolvers + def _find_supported_resolvers(self): + """Given the installed modules, return a list of possible resolvers. + """ + from .resolver import resolvers + + # We start with *all* resolvers, then whittle them down as we see each + # of the installed modules. + resolvers = set(resolvers()) + for module in self._load_modules(): + supported_resolvers = self._find_supported_resolvers_for_module(module) + resolvers.intersection_update(supported_resolvers) + + if not resolvers: + raise ValueError("This environment is not resolvable - there is no resolver that can resolve all of the dependencies.") + + return resolvers + + def is_resolved(self): + """Determine whether the JARs needed for this environment have been resolved. + """ + modules = self._load_modules() + + for module_name, module_version in modules.items(): + if module_version == '': + # An empty version indicates that the module has not been resolved yet + # (see docs for _load_modules). + return False + if module_version != self._module_version(module_name): + return False + return True + + def _module_version(self, module_name): + """Return the version of the package which is installed""" + module = importlib.import_module(module_name) + return module.__version__ + + @_requires_resolver def _find_module_info(self, module_name): + """Return the version and the Java dependencies for the given package""" module = importlib.import_module(module_name) # Check __***_deps__ exists on the module. For example for the cbng_web @@ -432,7 +485,7 @@ class Manager(object): deps = list(getattr(module, var_name_for_dependencies)) if not deps: - supported_resolvers = self._find_supported_resolvers(module) + supported_resolvers = self._find_supported_resolvers_for_module(module) supported_resolvers_explanation = '' if supported_resolvers: supported_resolvers_explanation = '\nResolvers compatible with this module:\n{}'.format( @@ -453,7 +506,7 @@ class Manager(object): modules = self._load_modules() for name in args: try: - version, deps = self._find_module_info(name) + version = self._module_version(name) if name not in modules or modules[name] != version: modules[name] = '' @@ -502,11 +555,16 @@ class Manager(object): return name in modules.keys() def is_installed(self, name, version=None): - '''Check if module is installed''' + """Check if module is installed (registered and resolved). + + If no version is passed then the version that comes from the imported + module's ``__version__`` attribute will be used. + + """ modules = self._load_modules() if name in modules: if version is None: - version, _ = self._find_module_info(name) + version = self._module_version(name) return modules[name] == version return False @@ -514,11 +572,12 @@ class Manager(object): '''Returns a list of the currently registered modules''' return sorted(self._load_modules().keys()) + @_requires_resolver def resolve(self, force_resolver=None): '''Resolve dependencies for all registered modules using CBNG''' if force_resolver is not None: self._setup_resolver(force_resolver) - self.log.info('resolving dependencies') + self.log.info('Resolving JARs for this environment') self.log.debug('lib directory is {0}'.format(self.jar_path())) all_dependencies = [] diff --git a/cmmnbuild_dep_manager/tests/conftest.py b/cmmnbuild_dep_manager/tests/conftest.py index de7147389f0ce858497677fdb2dee92fbb1e9674..39cc843bc3f1f79c4434a3a7a6dab339ad0f85f1 100644 --- a/cmmnbuild_dep_manager/tests/conftest.py +++ b/cmmnbuild_dep_manager/tests/conftest.py @@ -3,6 +3,7 @@ import shutil import pytest from cmmnbuild_dep_manager.cmmnbuild_dep_manager import PKG_MODULES_JSON +import cmmnbuild_dep_manager TMP_PKG_MODULES_JSON = ( @@ -41,3 +42,13 @@ def autoclean_modules_json(): TMP_PKG_MODULES_JSON.rename(PKG_MODULES_JSON) if TMP_LIB_DIR.exists(): TMP_LIB_DIR.rename(LIB_DIR) + + +@pytest.fixture +def cleaned_modules(): + # Clear the modules.json before and after the test + if PKG_MODULES_JSON.exists(): + PKG_MODULES_JSON.unlink() + yield + if PKG_MODULES_JSON.exists(): + PKG_MODULES_JSON.unlink() diff --git a/cmmnbuild_dep_manager/tests/test_Manager__load_modules.py b/cmmnbuild_dep_manager/tests/test_Manager__load_modules.py index 358656e332a205628e68c50905dbe460abf52eb3..3dad3fb0f64f99a1d1891814ec6f107b9a5b938e 100644 --- a/cmmnbuild_dep_manager/tests/test_Manager__load_modules.py +++ b/cmmnbuild_dep_manager/tests/test_Manager__load_modules.py @@ -120,7 +120,8 @@ def test_pkg_needs_update(caplog): assert r == {'fake_cmmnbuild_pkg': ""} assert caplog.record_tuples == [ ('cmmnbuild_dep_manager', logging.WARN, - 'fake_cmmnbuild_pkg is being updated from 1.2.2 to 1.2.3')] + 'fake_cmmnbuild_pkg is being updated from 1.2.2 to 1.2.3'), + ] def test_pkg_no_update_bad_entrypoint(caplog): @@ -134,4 +135,5 @@ def test_pkg_no_update_bad_entrypoint(caplog): assert r == {'fake_cmmnbuild_pkg': ""} assert caplog.record_tuples == [ ('cmmnbuild_dep_manager', logging.WARN, - 'fake_cmmnbuild_pkg is being updated from 1.2.3 to 1.2.4')] + 'fake_cmmnbuild_pkg is being updated from 1.2.3 to 1.2.4'), + ] diff --git a/cmmnbuild_dep_manager/tests/test_cmmnbuild_dep_manager.py b/cmmnbuild_dep_manager/tests/test_cmmnbuild_dep_manager.py index 2d2ce4666537ccc1571a27137babf3db26ab410a..de6ff3d71077905cd5797b058c0879181567a248 100644 --- a/cmmnbuild_dep_manager/tests/test_cmmnbuild_dep_manager.py +++ b/cmmnbuild_dep_manager/tests/test_cmmnbuild_dep_manager.py @@ -9,6 +9,7 @@ import pytest import cmmnbuild_dep_manager as cbdm from cmmnbuild_dep_manager.resolver import Resolver +from .test_Manager__load_modules import tmp_modules_content, FakePkgMgr class SimpleResolver(Resolver): @@ -58,19 +59,18 @@ def tmp_mod(name, dependencies=None, version=None): sys.modules.pop(name) -def test_no_such_module(caplog, simple_resolver): +def test_no_such_module(caplog, simple_resolver, cleaned_modules): cbdm.Manager('this_mod_doesnt_exist') assert caplog.record_tuples == [ ("cmmnbuild_dep_manager", logging.INFO, - 'Package "this_mod_doesnt_exist" is not yet set up - ' - 'installing and resolving JARs'), + 'Package "this_mod_doesnt_exist" is not yet set up - installing and resolving JARs'), ("cmmnbuild_dep_manager", logging.ERROR, 'this_mod_doesnt_exist not found'), ] -def test_module_missing_special_resolver_attr(caplog, simple_resolver): - with tmp_mod('a_cmmnbuild_test_module'): +def test_module_missing_special_resolver_attr(caplog, simple_resolver, cleaned_modules): + with tmp_mod('a_cmmnbuild_test_module', version='1.2.3'): cbdm.Manager('a_cmmnbuild_test_module') warning = ( @@ -81,7 +81,7 @@ def test_module_missing_special_resolver_attr(caplog, simple_resolver): assert warning in caplog.record_tuples -def test_module_dependencies_no_version(caplog, simple_resolver): +def test_module_dependencies_no_version(caplog, simple_resolver, cleaned_modules): with tmp_mod('a_cmmnbuild_test_module', dependencies=['j1', 'j2']): cbdm.Manager('a_cmmnbuild_test_module') warning = ( @@ -194,6 +194,7 @@ def test_imports_removed_on_success(mocked_mgr): assert java.util in sys.modules.values() assert java.util not in sys.modules.values() + def test_imports_removed_on_failure(mocked_mgr): pytest.importorskip('jpype', minversion='1.0') mgr = mocked_mgr[0] @@ -204,6 +205,7 @@ def test_imports_removed_on_failure(mocked_mgr): raise ValueError() assert java.util not in sys.modules.values() + def test_imports_are_reentrant(mocked_mgr): pytest.importorskip('jpype', minversion='1.0') mgr = mocked_mgr[0] @@ -217,3 +219,33 @@ def test_imports_are_reentrant(mocked_mgr): assert java.lang not in sys.modules.values() assert java.util not in sys.modules.values() assert java.lang not in sys.modules.values() + + +@pytest.fixture +def no_cbng_web_ping_allowed(): + with unittest.mock.patch( + 'cmmnbuild_dep_manager.resolver.cbng_web.CbngWebResolver.is_available', + side_effect=RuntimeError('The is_available method was called on the CbngWebResolver') + ): + yield + + +def test_init_manager_no_ping_cbng_web(no_cbng_web_ping_allowed): + # We shouldn't need cbng-web to be up in order to initialise a manager for + # an already resolved environment. + assert cbdm.Manager() + + +def test_init_manager_no_ping_cbng_web_with_package_name_already_resolved(no_cbng_web_ping_allowed): + # We shouldn't need cbng-web to be up in order to initialise a manager for + # an already resolved environment. + with FakePkgMgr.fake_pkg("fake_cmmnbuild_pkg", "1.2.3", entrypoint="1.2.3"): + with tmp_modules_content({'fake_cmmnbuild_pkg': '1.2.3'}): + assert cbdm.Manager('fake_cmmnbuild_pkg') + + +def test_resolve_manager_pings_cbng_web(no_cbng_web_ping_allowed): + # Validate that resolving does indeed hit the cbng-web endpoint, + # and that the no_cbng_web_ping_allowed fixture is doing the right thing. + with pytest.raises(RuntimeError): + assert cbdm.Manager().resolve()