diff --git a/libvcs/utils/module_loading.py b/libvcs/utils/module_loading.py new file mode 100644 index 000000000..bd937e0ef --- /dev/null +++ b/libvcs/utils/module_loading.py @@ -0,0 +1,80 @@ +import os +import sys +from importlib import import_module +from importlib.util import find_spec as importlib_find + + +def cached_import(module_path, class_name): + """ + Credit: https://github.com/django/django/blob/4.0.4/django/utils/module_loading.py + """ + modules = sys.modules + if module_path not in modules or ( + # Module is not fully initialized. + getattr(modules[module_path], "__spec__", None) is not None + and getattr(modules[module_path].__spec__, "_initializing", False) is True + ): + import_module(module_path) + return getattr(modules[module_path], class_name) + + +def import_string(dotted_path): + """ + Import a dotted module path and return the attribute/class designated by the + last name in the path. Raise ImportError if the import failed. + + Credit: https://github.com/django/django/blob/4.0.4/django/utils/module_loading.py + """ + try: + module_path, class_name = dotted_path.rsplit(".", 1) + except ValueError as err: + raise ImportError("%s doesn't look like a module path" % dotted_path) from err + + try: + return cached_import(module_path, class_name) + except AttributeError as err: + raise ImportError( + 'Module "%s" does not define a "%s" attribute/class' + % (module_path, class_name) + ) from err + + +def module_has_submodule(package, module_name): + """See if 'module' is in 'package'. + + Credit: https://github.com/django/django/blob/4.0.4/django/utils/module_loading.py + """ + try: + package_name = package.__name__ + package_path = package.__path__ + except AttributeError: + # package isn't a package. + return False + + full_module_name = package_name + "." + module_name + try: + return importlib_find(full_module_name, package_path) is not None + except ModuleNotFoundError: + # When module_name is an invalid dotted path, Python raises + # ModuleNotFoundError. + return False + + +def module_dir(module): + """ + Find the name of the directory that contains a module, if possible. + + Raise ValueError otherwise, e.g. for namespace packages that are split + over several directories. + + Credit: https://github.com/django/django/blob/4.0.4/django/utils/module_loading.py + """ + # Convert to list because __path__ may not support indexing. + paths = list(getattr(module, "__path__", [])) + if len(paths) == 1: + return paths[0] + else: + filename = getattr(module, "__file__", None) + if filename is not None: + return os.path.dirname(filename) + raise ValueError("Cannot determine directory containing %s" % module) diff --git a/setup.cfg b/setup.cfg index 0b189b7a8..6c7b0f35b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -19,3 +19,4 @@ line_length = 88 [tool:pytest] addopts = --tb=short --no-header --showlocals --doctest-modules doctest_optionflags = ELLIPSIS NORMALIZE_WHITESPACE +norecursedirs = tests/utils/test_module diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/utils/test_module/__init__.py b/tests/utils/test_module/__init__.py new file mode 100644 index 000000000..d8a5fe2ed --- /dev/null +++ b/tests/utils/test_module/__init__.py @@ -0,0 +1,5 @@ +class SiteMock: + _registry = {} + + +site = SiteMock() diff --git a/tests/utils/test_module/__main__.py b/tests/utils/test_module/__main__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/utils/test_module/another_bad_module.py b/tests/utils/test_module/another_bad_module.py new file mode 100644 index 000000000..769bb2b24 --- /dev/null +++ b/tests/utils/test_module/another_bad_module.py @@ -0,0 +1,11 @@ +from . import site + +content = "Another Bad Module" + +site._registry.update( + { + "foo": "bar", + } +) + +raise Exception("Some random exception.") diff --git a/tests/utils/test_module/another_good_module.py b/tests/utils/test_module/another_good_module.py new file mode 100644 index 000000000..7ab8224f5 --- /dev/null +++ b/tests/utils/test_module/another_good_module.py @@ -0,0 +1,9 @@ +from . import site + +content = "Another Good Module" + +site._registry.update( + { + "lorem": "ipsum", + } +) diff --git a/tests/utils/test_module/bad_module.py b/tests/utils/test_module/bad_module.py new file mode 100644 index 000000000..728799ef2 --- /dev/null +++ b/tests/utils/test_module/bad_module.py @@ -0,0 +1,3 @@ +import a_package_name_that_does_not_exist # NOQA + +content = "Bad Module" diff --git a/tests/utils/test_module/child_module/__init__.py b/tests/utils/test_module/child_module/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/utils/test_module/child_module/grandchild_module.py b/tests/utils/test_module/child_module/grandchild_module.py new file mode 100644 index 000000000..09bd1c4b7 --- /dev/null +++ b/tests/utils/test_module/child_module/grandchild_module.py @@ -0,0 +1 @@ +content = "Grandchild Module" diff --git a/tests/utils/test_module/good_module.py b/tests/utils/test_module/good_module.py new file mode 100644 index 000000000..90a038855 --- /dev/null +++ b/tests/utils/test_module/good_module.py @@ -0,0 +1 @@ +content = "Good Module" diff --git a/tests/utils/test_module/main_module.py b/tests/utils/test_module/main_module.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/utils/test_module_loading.py b/tests/utils/test_module_loading.py new file mode 100644 index 000000000..a72d339ba --- /dev/null +++ b/tests/utils/test_module_loading.py @@ -0,0 +1,146 @@ +""" +Credit: https://github.com/django/django/blob/4.0.4/tests/utils_tests/test_module_loading.py + +From April 25th, 2021. Changes: + +- pytest compatibility, use monkeypatch.syspath_prepend +- Removed django-specific material +""" # noqa: E501 +import os +import sys +import unittest +from importlib import import_module + +import pytest + +from libvcs.utils.module_loading import import_string, module_has_submodule + +PY310 = sys.version_info >= (3, 10) + + +class DefaultLoader(unittest.TestCase): + def test_loader(self): + "Normal module existence can be tested" + test_module = import_module("tests.utils.test_module") + test_no_submodule = import_module("tests.utils.test_no_submodule") + + # An importable child + self.assertTrue(module_has_submodule(test_module, "good_module")) + mod = import_module("tests.utils.test_module.good_module") + self.assertEqual(mod.content, "Good Module") + + # A child that exists, but will generate an import error if loaded + self.assertTrue(module_has_submodule(test_module, "bad_module")) + with self.assertRaises(ImportError): + import_module("tests.utils.test_module.bad_module") + + # A child that doesn't exist + self.assertFalse(module_has_submodule(test_module, "no_such_module")) + with self.assertRaises(ImportError): + import_module("tests.utils.test_module.no_such_module") + + # A child that doesn't exist, but is the name of a package on the path + self.assertFalse(module_has_submodule(test_module, "django")) + with self.assertRaises(ImportError): + import_module("tests.utils.test_module.django") + + # Don't be confused by caching of import misses + import types # NOQA: causes attempted import of tests.utils.types + + self.assertFalse(module_has_submodule(sys.modules["tests.utils"], "types")) + + # A module which doesn't have a __path__ (so no submodules) + self.assertFalse(module_has_submodule(test_no_submodule, "anything")) + with self.assertRaises(ImportError): + import_module("tests.utils.test_no_submodule.anything") + + def test_has_sumbodule_with_dotted_path(self): + """Nested module existence can be tested.""" + test_module = import_module("tests.utils.test_module") + # A grandchild that exists. + self.assertIs( + module_has_submodule(test_module, "child_module.grandchild_module"), True + ) + # A grandchild that doesn't exist. + self.assertIs( + module_has_submodule(test_module, "child_module.no_such_module"), False + ) + # A grandchild whose parent doesn't exist. + self.assertIs( + module_has_submodule(test_module, "no_such_module.grandchild_module"), False + ) + # A grandchild whose parent is not a package. + self.assertIs( + module_has_submodule(test_module, "good_module.no_such_module"), False + ) + + +class EggLoader: + def setUp(self): + self.egg_dir = "%s/eggs" % os.path.dirname(__file__) + + def tearDown(self): + sys.path_importer_cache.clear() + + sys.modules.pop("egg_module.sub1.sub2.bad_module", None) + sys.modules.pop("egg_module.sub1.sub2.good_module", None) + sys.modules.pop("egg_module.sub1.sub2", None) + sys.modules.pop("egg_module.sub1", None) + sys.modules.pop("egg_module.bad_module", None) + sys.modules.pop("egg_module.good_module", None) + sys.modules.pop("egg_module", None) + + def test_shallow_loader(self, monkeypatch: pytest.MonkeyPatch): + "Module existence can be tested inside eggs" + egg_name = "%s/test_egg.egg" % self.egg_dir + monkeypatch.syspath_prepend(egg_name) + egg_module = import_module("egg_module") + + # An importable child + self.assertTrue(module_has_submodule(egg_module, "good_module")) + mod = import_module("egg_module.good_module") + self.assertEqual(mod.content, "Good Module") + + # A child that exists, but will generate an import error if loaded + self.assertTrue(module_has_submodule(egg_module, "bad_module")) + with self.assertRaises(ImportError): + import_module("egg_module.bad_module") + + # A child that doesn't exist + self.assertFalse(module_has_submodule(egg_module, "no_such_module")) + with self.assertRaises(ImportError): + import_module("egg_module.no_such_module") + + def test_deep_loader(self, monkeypatch: pytest.MonkeyPatch): + "Modules deep inside an egg can still be tested for existence" + egg_name = "%s/test_egg.egg" % self.egg_dir + monkeypatch.syspath_prepend(egg_name) + egg_module = import_module("egg_module.sub1.sub2") + + # An importable child + self.assertTrue(module_has_submodule(egg_module, "good_module")) + mod = import_module("egg_module.sub1.sub2.good_module") + self.assertEqual(mod.content, "Deep Good Module") + + # A child that exists, but will generate an import error if loaded + self.assertTrue(module_has_submodule(egg_module, "bad_module")) + with self.assertRaises(ImportError): + import_module("egg_module.sub1.sub2.bad_module") + + # A child that doesn't exist + self.assertFalse(module_has_submodule(egg_module, "no_such_module")) + with self.assertRaises(ImportError): + import_module("egg_module.sub1.sub2.no_such_module") + + +class ModuleImportTests: + def test_import_string(self): + cls = import_string("libvcs.utils.module_loading.import_string") + self.assertEqual(cls, import_string) + + # Test exceptions raised + with self.assertRaises(ImportError): + import_string("no_dots_in_path") + msg = 'Module "tests.utils" does not define a "unexistent" attribute' + with self.assertRaisesMessage(ImportError, msg): + import_string("tests.utils.unexistent") diff --git a/tests/utils/test_no_submodule.py b/tests/utils/test_no_submodule.py new file mode 100644 index 000000000..3c424ac78 --- /dev/null +++ b/tests/utils/test_no_submodule.py @@ -0,0 +1 @@ +# Used to test for modules which don't have submodules.