diff --git a/pyproject.toml b/pyproject.toml index 3240386..03e3abe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,3 +34,6 @@ where = ["src"] [tool.setuptools.dynamic] version = {attr = "ducktools.lazyimporter.__version__"} + +[tool.black] +skip-string-normalization = true diff --git a/src/ducktools/lazyimporter.py b/src/ducktools/lazyimporter.py index e86436b..30d13c0 100644 --- a/src/ducktools/lazyimporter.py +++ b/src/ducktools/lazyimporter.py @@ -102,6 +102,14 @@ def __init__(self, module_name, asname=None): self.module_name = module_name self.asname = asname + if self.import_level > 0 and asname is None: + raise ValueError( + "Relative imports are not allowed without an assigned name." + ) + + if self.asname is not None and not asname.isidentifier(): + raise ValueError(f"{self.asname!r} is not a valid Python identifier.") + def __repr__(self): return ( f"{self.__class__.__name__}(" @@ -126,15 +134,11 @@ def do_import(self, globs=None): submod_used = [self.module_basename] for submod in self.submodule_names: submod_used.append(submod) - try: - mod = getattr(mod, submod) - except AttributeError: - invalid_module = ".".join(submod_used) - raise ModuleNotFoundError(f"No module named {invalid_module!r}") + mod = getattr(mod, submod) if self.asname: return {self.asname: mod} - else: + else: # pragma: no cover return {self.module_basename: mod} @@ -159,6 +163,9 @@ def __init__(self, module_name, attrib_name, asname=None): self.attrib_name = attrib_name self.asname = asname if asname is not None else attrib_name + if not self.asname.isidentifier(): + raise ValueError(f"{self.asname!r} is not a valid Python identifier.") + def __repr__(self): return ( f"{self.__class__.__name__}(" @@ -208,6 +215,10 @@ def __init__(self, module_name, attrib_names): self.module_name = module_name self.attrib_names = attrib_names + for name in self.asnames: + if not name.isidentifier(): + raise ValueError(f"{name!r} is not a valid Python identifier.") + def __repr__(self): return ( f"{self.__class__.__name__}(" @@ -226,6 +237,7 @@ def __eq__(self, other): @property def asnames(self): """ + Get a list of all the names that will be assigned by this importer :return: list of 'asname' names to give as 'dir' for LazyImporter bindings :rtype: list[str] @@ -276,14 +288,20 @@ def __init__(self, module_name, except_module, asname): Inside a LazyImporter :param module_name: Name of the 'try' module + :type module_name: str :param except_module: Name of the module to import in the case that the 'try' module fails + :type except_module: str :param asname: Name to use for either on successful import + :type asname: str """ self.module_name = module_name self.except_module = except_module self.asname = asname + if not self.asname.isidentifier(): + raise ValueError(f"{self.asname!r} is not a valid Python identifier.") + def __repr__(self): return ( f"{self.__class__.__name__}(" @@ -362,11 +380,7 @@ def do_import(self, globs=None): for submod in submodule_names: submod_used.append(submod) - try: - mod = getattr(mod, submod) - except AttributeError: - invalid_module = ".".join(submod_used) - raise ModuleNotFoundError(f"No module named {invalid_module!r}") + mod = getattr(mod, submod) return {self.asname: mod} @@ -459,7 +473,7 @@ def group_importers(inst): importers = {} for imp in inst._imports: # noqa - if imp.import_level > 0 and inst._globals is None: # noqa + if getattr(imp, "import_level", 0) > 0 and inst._globals is None: # noqa raise ValueError( "Attempted to setup relative import without providing globals()." ) @@ -501,7 +515,7 @@ def group_importers(inst): ) else: raise TypeError( - f"{imp} is not an instance of " + f"{imp!r} is not an instance of " f"ModuleImport, FromImport, MultiFromImport or TryExceptImport" ) return importers @@ -513,7 +527,7 @@ class LazyImporter: _importers = _ImporterGrouper() - def __init__(self, imports, *, globs=None): + def __init__(self, imports, *, globs=None, eager_process=False): """ Create a LazyImporter to import modules and objects when they are accessed on this importer object. @@ -524,11 +538,16 @@ def __init__(self, imports, *, globs=None): :type imports: list[ModuleImport | FromImport | MultiFromImport | TryExceptImport] :param globs: globals object for relative imports :type globs: dict[str, typing.Any] + :param eager_process: filter and check the imports eagerly + :type eager_process: bool """ # Keep original imports for __repr__ self._imports = imports self._globals = globs + if eager_process: + _ = self._importers + def __getattr__(self, name): # This performs the imports associated with the name of the attribute # and sets the result to that name. @@ -553,7 +572,11 @@ def __dir__(self): return sorted(self._importers.keys()) def __repr__(self): - return f"{self.__class__.__name__}(imports={self._imports!r})" + return ( + f"{self.__class__.__name__}" + f"(imports={self._imports!r}, " + f"globs={self._globals!r})" + ) def get_importer_state(importer): @@ -565,10 +588,16 @@ def get_importer_state(importer): :return: Dict of imported_modules and lazy_modules :rtype: dict[str, dict[str, typing.Any] | list[str]] """ + # Get the dir *before* looking at __dict__ + # Calling 'dir' in the block would cause the __dict__ size to change + # And fail iteration + importer_dir = dir(importer) + imported_attributes = { - k: v for k, v in importer.__dict__.items() if k in dir(importer) + k: v for k, v in importer.__dict__.items() if k in importer_dir } - lazy_attributes = [k for k in dir(importer) if k not in imported_attributes] + + lazy_attributes = [k for k in importer_dir if k not in imported_attributes] return { "imported_attributes": imported_attributes, diff --git a/tests/example_modules/ex_othermod/__init__.py b/tests/example_modules/ex_othermod/__init__.py index f1ee4eb..26e2ae4 100644 --- a/tests/example_modules/ex_othermod/__init__.py +++ b/tests/example_modules/ex_othermod/__init__.py @@ -1,11 +1,14 @@ from ducktools.lazyimporter import ( LazyImporter, FromImport, + get_module_funcs, ) name = "ex_othermod" laz = LazyImporter( - [FromImport("..ex_mod.ex_submod", "name")], + [FromImport("..ex_mod.ex_submod", "name", "submod_name")], globs=globals(), ) + +__getattr__, __dir__ = get_module_funcs(laz, module_name=__name__) diff --git a/tests/test_basic_imports.py b/tests/test_basic_imports.py index f4cfe81..5605b47 100644 --- a/tests/test_basic_imports.py +++ b/tests/test_basic_imports.py @@ -31,6 +31,13 @@ def test_module_import(self): assert example_1 is laz.example_1 + def test_module_import_asname(self): + laz = LazyImporter([ModuleImport("example_1", asname="ex1")]) + + import example_1 # noqa # pyright: ignore + + assert example_1 is laz.ex1 + def test_from_import(self): """ Test a basic from import from a module @@ -68,6 +75,11 @@ def test_imports_submod(self): assert laz_sub.ex_mod.name == "ex_mod" assert laz_sub.ex_mod.ex_submod.name == "ex_submod" + def test_imports_submod_asname(self): + laz_sub = LazyImporter([ModuleImport("ex_mod.ex_submod", asname="ex_submod")]) + + assert laz_sub.ex_submod.name == "ex_submod" + def test_submod_from(self): """ Test a from import from a submodule @@ -115,8 +127,22 @@ def test_try_except_import(self): assert laz2.ex_mod.name == "ex_mod" + def test_try_except_submod_import(self): + """ + Test a try/except import with submodules + """ + laz = LazyImporter( + [ + TryExceptImport( + "module_does_not_exist", "ex_mod.ex_submod", "ex_submod" + ), + ] + ) -class TestImportsWithinExamples: + assert laz.ex_submod.name == "ex_submod" + + +class TestRelativeImports: def test_relative_import(self): import example_modules.lazy_submod_ex as lse @@ -130,4 +156,4 @@ def test_relative_import(self): def test_submod_relative_import(self): from example_modules.ex_othermod import laz - assert laz.name == "ex_submod" + assert laz.submod_name == "ex_submod" diff --git a/tests/test_funcs.py b/tests/test_funcs.py new file mode 100644 index 0000000..eed5186 --- /dev/null +++ b/tests/test_funcs.py @@ -0,0 +1,61 @@ +""" +Test the external functions +""" + +from ducktools.lazyimporter import ( + ModuleImport, + FromImport, + MultiFromImport, + TryExceptImport, + _SubmoduleImports, + MultiFromImport, + get_importer_state, + get_module_funcs, + LazyImporter, +) + + +class TestImporterState: + def test_module_importer_state(self): + laz = LazyImporter([ModuleImport("collections")]) + + state = get_importer_state(laz) + + assert state["lazy_attributes"] == ["collections"] + assert state["imported_attributes"] == {} + + collections_mod = laz.collections + + state = get_importer_state(laz) + + assert state["lazy_attributes"] == [] + assert state["imported_attributes"] == {"collections": collections_mod} + + +class TestModuleFuncs: + def test_getattr_func(self): + import collections + + laz = LazyImporter([ModuleImport("collections")]) + + getattr_func, _ = get_module_funcs(laz) + + assert getattr_func("collections") is collections + + def test_dir_func(self): + laz = LazyImporter([ModuleImport("collections")]) + + _, dir_func = get_module_funcs(laz) + + assert dir_func() == ["collections"] + + def test_getattr_module_func(self): + import example_modules.ex_othermod as ex_othermod # noqa # pyright: ignore + + assert ex_othermod.submod_name == "ex_submod" + + def test_dir_module_func(self): + import example_modules.ex_othermod as ex_othermod # noqa # pyright: ignore + + assert "name" in dir(ex_othermod) + assert "submod_name" in dir(ex_othermod) diff --git a/tests/test_import_errors.py b/tests/test_import_errors.py new file mode 100644 index 0000000..9f0b51c --- /dev/null +++ b/tests/test_import_errors.py @@ -0,0 +1,168 @@ +import pytest + +from ducktools.lazyimporter import ( + ModuleImport, + FromImport, + MultiFromImport, + TryExceptImport, + _SubmoduleImports, + MultiFromImport, + LazyImporter, +) + + +def test_missing_import(): + laz = LazyImporter([ModuleImport("importlib")]) + + with pytest.raises(AttributeError): + _ = laz.missing_attribute + + +def test_invalid_input(): + with pytest.raises(TypeError) as e: + laz = LazyImporter(["importlib"], eager_process=True) + + assert e.match( + "'importlib' is not an instance of " + "ModuleImport, FromImport, MultiFromImport or TryExceptImport" + ) + + +def test_invalid_relative_import(): + with pytest.raises(ValueError) as e: + _ = ModuleImport(".relative_module") + + assert e.match("Relative imports are not allowed without an assigned name.") + + +class TestInvalidIdentifiers: + def test_modimport_invalid(self): + with pytest.raises(ValueError) as e: + _ = ModuleImport("modname", "##invalid_identifier##") + + assert e.match(f"'##invalid_identifier##' is not a valid Python identifier.") + + def test_fromimport_invalid(self): + with pytest.raises(ValueError) as e: + _ = FromImport("modname", "attribute", "##invalid_identifier##") + + assert e.match(f"'##invalid_identifier##' is not a valid Python identifier.") + + def test_multifromimport_invalid(self): + with pytest.raises(ValueError) as e: + _ = MultiFromImport("modname", [("attribute", "##invalid_identifier##")]) + + assert e.match(f"'##invalid_identifier##' is not a valid Python identifier.") + + def test_tryexceptimport_invalid(self): + with pytest.raises(ValueError) as e: + _ = TryExceptImport("modname", "altmod", "##invalid_identifier##") + + assert e.match(f"'##invalid_identifier##' is not a valid Python identifier.") + + +class TestNameClash: + def test_fromimport_clash(self): + """ + Multiple FromImports with clashing 'asname' parameters + """ + + with pytest.raises(ValueError) as e: + laz = LazyImporter( + [ + FromImport("collections", "namedtuple", "nt"), + FromImport("typing", "NamedTuple", "nt"), + ], + eager_process=True, + ) + + assert e.match("'nt' used for multiple imports.") + + def test_multifromimport_clash(self): + """ + Multiple FromImports with clashing 'asname' parameters + """ + + with pytest.raises(ValueError) as e: + laz = LazyImporter( + [ + MultiFromImport( + "collections", [("namedtuple", "nt"), ("defaultdict", "nt")] + ), + ], + eager_process=True, + ) + + assert e.match("'nt' used for multiple imports.") + + def test_mixedimport_clash(self): + with pytest.raises(ValueError) as e: + laz = LazyImporter( + [ + FromImport("mod1", "matching_mod_name"), + ModuleImport("matching_mod_name"), + ], + eager_process=True, + ) + + assert e.match("'matching_mod_name' used for multiple imports.") + + +class TestNoGlobals: + def test_relative_module_noglobals(self): + """ + ModuleImport relative without globals + """ + with pytest.raises(ValueError) as e: + laz = LazyImporter( + [ModuleImport(".relative_module", asname="relative_module")], + eager_process=True, + ) + + assert e.match( + "Attempted to setup relative import without providing globals()." + ) + + def test_relative_from_noglobals(self): + """ + FromImport relative without globals + """ + with pytest.raises(ValueError) as e: + laz = LazyImporter( + [FromImport(".relative_module", "attribute")], + eager_process=True, + ) + + assert e.match( + "Attempted to setup relative import without providing globals()." + ) + + +class TestImportErrors: + def test_module_import_nosubmod_asname(self): + laz = LazyImporter( + [ + ModuleImport("importlib.util.fakemod", asname="fakemod"), + ] + ) + + with pytest.raises(ModuleNotFoundError) as e: + _ = laz.fakemod + + assert e.match("No module named 'importlib.util.fakemod'") + + def test_tryexcept_import_nosubmod_asname(self): + laz = LazyImporter( + [ + TryExceptImport( + "importlib.util.fakemod1", + "importlib.util.fakemod", + asname="fakemod", + ), + ] + ) + + with pytest.raises(ModuleNotFoundError) as e: + _ = laz.fakemod + + assert e.match("No module named 'importlib.util.fakemod'") diff --git a/tests/test_internals.py b/tests/test_internals.py index c18bcd9..68cd206 100644 --- a/tests/test_internals.py +++ b/tests/test_internals.py @@ -5,9 +5,9 @@ FromImport, MultiFromImport, TryExceptImport, - LazyImporter, _SubmoduleImports, - MultiFromImport, + _ImporterGrouper, + LazyImporter, ) @@ -22,6 +22,19 @@ def test_equal_module(self): assert mod1 != mod2 + mod3 = ModuleImport("collections", "c") + assert mod3 == mod2 + + def test_equal_submod_import(self): + mod1 = _SubmoduleImports("importlib", {"importlib.util"}) + mod2 = _SubmoduleImports("importlib", {"importlib.util"}) + + assert mod1 == mod2 + + mod3 = _SubmoduleImports("importlib", set()) + + assert mod1 != mod3 + def test_equal_from(self): from1 = FromImport("collections", "namedtuple") from2 = FromImport("collections", "namedtuple") @@ -56,119 +69,196 @@ def test_unequal_different_types(self): from1 = FromImport("collections", "namedtuple") mf1 = MultiFromImport("collections", ["namedtuple", "defaultdict"]) te1 = TryExceptImport("tomllib", "tomli", "tomllib") + subm1 = _SubmoduleImports("importlib", {"importlib.util"}) - combs = itertools.combinations([mod1, from1, mf1, te1], 2) + combs = itertools.combinations([mod1, from1, mf1, te1, subm1], 2) for i1, i2 in combs: assert i1 != i2 - -def test_no_duplication(): - importer = LazyImporter([ModuleImport("collections"), ModuleImport("collections")]) - - assert dir(importer) == ["collections"] - assert importer._importers == {"collections": _SubmoduleImports("collections")} - - -def test_submodule_gather(): - importer = LazyImporter( - [ - ModuleImport("collections.abc"), - ] - ) - - assert dir(importer) == ["collections"] - - assert importer._importers == { - "collections": _SubmoduleImports("collections", {"collections.abc"}) - } - - -def test_asname_gather(): - importer = LazyImporter( - [ - ModuleImport("collections.abc", "abc"), - ] - ) - - assert dir(importer) == ["abc"] - assert importer._importers == {"abc": ModuleImport("collections.abc", "abc")} - - -def test_from_gather(): - importer = LazyImporter( - [ - FromImport("dataclasses", "dataclass"), - FromImport("dataclasses", "dataclass", "dc"), - ] - ) - - assert dir(importer) == ["dataclass", "dc"] - - assert importer._importers == { - "dataclass": FromImport("dataclasses", "dataclass"), - "dc": FromImport("dataclasses", "dataclass", "dc"), - } - - -def test_mixed_gather(): - importer = LazyImporter( - [ - ModuleImport("collections"), - ModuleImport("collections.abc"), - ModuleImport("functools", "ft"), - FromImport("dataclasses", "dataclass"), - FromImport("typing", "NamedTuple", "nt"), - ] - ) - - assert dir(importer) == ["collections", "dataclass", "ft", "nt"] - - assert importer._importers == { - "collections": _SubmoduleImports("collections", {"collections.abc"}), - "dataclass": FromImport("dataclasses", "dataclass"), - "ft": ModuleImport("functools", "ft"), - "nt": FromImport("typing", "NamedTuple", "nt"), - } - - -def test_multi_from(): - multi_from = MultiFromImport( - "collections", ["defaultdict", ("namedtuple", "nt"), "OrderedDict"] - ) - from_imp = FromImport("functools", "partial") - mod_imp = ModuleImport("importlib.util") - - # Resulting submodule import - submod_imp = _SubmoduleImports("importlib", {"importlib.util"}) - - importer = LazyImporter([multi_from, from_imp, mod_imp]) - - assert dir(importer) == sorted( - ["defaultdict", "nt", "OrderedDict", "partial", "importlib"] - ) - - assert importer._importers == { - "defaultdict": multi_from, - "nt": multi_from, - "OrderedDict": multi_from, - "partial": from_imp, - "importlib": submod_imp, - } - - -def test_relative_basename(): - from_imp_level0 = FromImport("mod", "obj") - from_imp_level1 = FromImport(".mod", "obj") - from_imp_level2 = FromImport("..mod", "obj") - - assert from_imp_level0.import_level == 0 - assert from_imp_level1.import_level == 1 - assert from_imp_level2.import_level == 2 - - assert ( - from_imp_level0.module_name_noprefix - == from_imp_level1.module_name_noprefix - == from_imp_level2.module_name_noprefix - == "mod" - ) + def test_import_repr_module(self): + mod1 = ModuleImport(module_name='collections', asname=None) + mod1str = "ModuleImport(module_name='collections', asname=None)" + + assert repr(mod1) == mod1str + + def test_import_repr_from(self): + from1 = FromImport( + module_name='collections', attrib_name='namedtuple', asname='namedtuple' + ) + from1str = ( + "FromImport(module_name='collections', " + "attrib_name='namedtuple', " + "asname='namedtuple')" + ) + + assert repr(from1) == from1str + + def test_import_repr_multifrom(self): + mf1 = MultiFromImport( + module_name='collections', attrib_names=['namedtuple', 'defaultdict'] + ) + mf1str = ( + "MultiFromImport(module_name='collections', " + "attrib_names=['namedtuple', 'defaultdict'])" + ) + assert repr(mf1) == mf1str + + def test_import_repr_tryexcept(self): + te1 = TryExceptImport( + module_name='tomllib', except_module='tomli', asname='tomllib' + ) + te1str = ( + "TryExceptImport(" + "module_name='tomllib', " + "except_module='tomli', " + "asname='tomllib')" + ) + + assert repr(te1) == te1str + + def test_import_repr_submod(self): + subm1 = _SubmoduleImports( + module_name='importlib', + submodules={'importlib.util'}, + ) + subm1str = ( + "_SubmoduleImports(" + "module_name='importlib', " + "submodules={'importlib.util'})" + ) + assert repr(subm1) == subm1str + + def test_importer_repr(self): + globs = globals() + imports = [ModuleImport("functools"), FromImport("collections", "namedtuple")] + laz = LazyImporter(imports=imports, globs=globs) + + laz_str = f"LazyImporter(imports={imports!r}, globs={globs!r})" + + assert repr(laz) == laz_str + + +class TestGatherImports: + def test_no_duplication(self): + importer = LazyImporter( + [ModuleImport("collections"), ModuleImport("collections")] + ) + + assert dir(importer) == ["collections"] + assert importer._importers == {"collections": _SubmoduleImports("collections")} + + def test_submodule_gather(self): + importer = LazyImporter( + [ + ModuleImport("collections.abc"), + ] + ) + + assert dir(importer) == ["collections"] + + assert importer._importers == { + "collections": _SubmoduleImports("collections", {"collections.abc"}) + } + + def test_asname_gather(self): + importer = LazyImporter( + [ + ModuleImport("collections.abc", "abc"), + ] + ) + + assert dir(importer) == ["abc"] + assert importer._importers == {"abc": ModuleImport("collections.abc", "abc")} + + def test_from_gather(self): + importer = LazyImporter( + [ + FromImport("dataclasses", "dataclass"), + FromImport("dataclasses", "dataclass", "dc"), + ] + ) + + assert dir(importer) == ["dataclass", "dc"] + + assert importer._importers == { + "dataclass": FromImport("dataclasses", "dataclass"), + "dc": FromImport("dataclasses", "dataclass", "dc"), + } + + def test_mixed_gather(self): + importer = LazyImporter( + [ + ModuleImport("collections"), + ModuleImport("collections.abc"), + ModuleImport("functools", "ft"), + FromImport("dataclasses", "dataclass"), + FromImport("typing", "NamedTuple", "nt"), + ] + ) + + assert dir(importer) == ["collections", "dataclass", "ft", "nt"] + + assert importer._importers == { + "collections": _SubmoduleImports("collections", {"collections.abc"}), + "dataclass": FromImport("dataclasses", "dataclass"), + "ft": ModuleImport("functools", "ft"), + "nt": FromImport("typing", "NamedTuple", "nt"), + } + + def test_multi_from(self): + multi_from = MultiFromImport( + "collections", ["defaultdict", ("namedtuple", "nt"), "OrderedDict"] + ) + from_imp = FromImport("functools", "partial") + mod_imp = ModuleImport("importlib.util") + + # Resulting submodule import + submod_imp = _SubmoduleImports("importlib", {"importlib.util"}) + + importer = LazyImporter([multi_from, from_imp, mod_imp]) + + assert dir(importer) == sorted( + ["defaultdict", "nt", "OrderedDict", "partial", "importlib"] + ) + + assert importer._importers == { + "defaultdict": multi_from, + "nt": multi_from, + "OrderedDict": multi_from, + "partial": from_imp, + "importlib": submod_imp, + } + + +class TestLevels: + def test_relative_fromimport_basename(self): + from_imp_level0 = FromImport("mod", "obj") + from_imp_level1 = FromImport(".mod", "obj") + from_imp_level2 = FromImport("..mod", "obj") + + assert from_imp_level0.import_level == 0 + assert from_imp_level1.import_level == 1 + assert from_imp_level2.import_level == 2 + + assert ( + from_imp_level0.module_name_noprefix + == from_imp_level1.module_name_noprefix + == from_imp_level2.module_name_noprefix + == "mod" + ) + + def test_relative_exceptimport_basename(self): + tryexcept_imp_level = TryExceptImport( + "..submodreal", "...submodexcept", "asname" + ) + + assert tryexcept_imp_level.import_level == 2 + assert tryexcept_imp_level.except_import_level == 3 + + +def test_import_grouper_access(): + """ + Test that the ImporterGrouper has been placed on the class + """ + assert isinstance(LazyImporter._importers, _ImporterGrouper)