diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..7357b5b --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,23 @@ +# See https://pre-commit.com for more information. +# See https://pre-commit.com/hooks.html for more hooks. +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-added-large-files + - id: check-toml + - id: debug-statements + - id: check-yaml + args: + - --unsafe + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/charliermarsh/ruff-pre-commit + rev: v0.7.3 + hooks: + - id: ruff + args: ["--fix"] + +ci: + autofix_commit_msg: 🎨 [pre-commit.ci] Auto format from pre-commit.com hooks + autoupdate_commit_msg: ⬆ [pre-commit.ci] pre-commit autoupdate diff --git a/docs/helpers.md b/docs/helpers.md index e2db225..ca457eb 100644 --- a/docs/helpers.md +++ b/docs/helpers.md @@ -1,13 +1,13 @@ # Helpers -Monkay comes with two helpers +Monkay comes with some helpers -- `load(path, allow_splits=":.")`: Load a path like Monkay. `allow_splits` allows to configure if attributes are seperated via . or :. +- `load(path, *, allow_splits=":.", package=None)`: Load a path like Monkay. `allow_splits` allows to configure if attributes are seperated via . or :. When both are specified, both split ways are possible (Default). -- `load_any(module_path, potential_attrs, *, non_first_deprecated=False)`: Checks for a module if any attribute name matches. Return attribute value or raises ImportError when non matches. +- `load_any(module_path, potential_attrs, *, non_first_deprecated=False, package=None)`: Checks for a module if any attribute name matches. Return attribute value or raises ImportError when non matches. When `non_first_deprecated` is `True`, a DeprecationMessage is issued for the non-first attribute which matches. This can be handy for deprecating module interfaces. - +- `absolutify_import(import_path, package)`. Converts a relative import_path (absolute import pathes are returned unchanged) to an absolute import path. Example: diff --git a/docs/release-notes.md b/docs/release-notes.md index c6050f8..e722e0c 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,5 +1,19 @@ # Release notes +## Version 0.0.4 + +### Added + +- `find_missing` test method. +- `getter` attribute saving the injected getter. +- `absolutify_import` helper. +- Add pre-commit. + +### Changed + +- Rename typo `settings_preload_name` to `settings_preloads_name`. +- Fix relative imports. + ## Version 0.0.3 ### Added diff --git a/docs/specials.md b/docs/specials.md new file mode 100644 index 0000000..9343de0 --- /dev/null +++ b/docs/specials.md @@ -0,0 +1,6 @@ +# Specials + +## Overwriting the used package for relative imports + +Provide the `package` parameter to Monkay. By default it is set to the `__spec__.parent` of the module. +For a toplevel module it is the same name like the module. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..5248995 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,53 @@ +# Testing + + +## Temporary overwrites + +For tests, but not limited to, Monkay provides three methods returning a contextmanager which provides threadsafe a temporary overwrite: + +- `with_settings(settings)` +- `with_extensions(extensions_dict, *, apply_extensions=False)` +- `with_instance(instance, * apply_extensions=False,use_extensions_overwrite=True)` + + +## Check lazy imports + +Monkay provides the debug method `find_missing(*, all_var=None, search_pathes=None, ignore_deprecated_import_errors=False, require_search_path_all_var=True)`. +It is quite expensive so it should be only called for debugging, testing and in error cases. +It returns a dictionary containing items which had issues, e.g. imports failed or not in `__all__` variable. + +When providing `search_pathes` (module pathes as string), all exports are checked if they are in the value set of Monkey. + +When providing `__all__` as `all_var`, it is checked for all imports. + +Returned is a dictionary in the format: + +- key: import name or import path +- value: set with errors + +Errors: + +- `all_var`: key is not in the provided `__all__` variable +- `import`: key had an ImportError +- `search_path_extra`: key (here a path) is not included in lazy imports. +- `search_path_import`: import of key (here the search path) failed +- `search_path_all_var`: module imported as search path had no `__all__`. This error can be disabled with `require_search_path_all_var=False` + +### Ignore import errors when lazy import is deprecated + +The parameter `ignore_deprecated_import_errors=True` silences errors happening when an lazy import which was marked as deprecated failed. + +### Example + +Using Monkay for tests is confortable and easy: + +``` python + +import edgy + +def test_edgy_lazy_imports(): + assert not edgy.monkay.find_missing(all_var=edgy.__all__, search_pathes=["edgy.core.files", "edgy.core.db.fields", "edgy.core.connection"]) + +``` + +That was the test. Now we know that no lazy import is broken. diff --git a/mkdocs.yml b/mkdocs.yml index b814253..c6158e8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,8 +1,10 @@ site_name: Monkay site_description: The ultimate preload, settings, lazy import manager.. -site_url: https://devkral.github.io/monkay +site_url: https://dymmond.github.io/monkay nav: - Home: index.md - Tutorial: tutorial.md - Helpers: helpers.md + - Testing: testing.md + - Specials: specials.md diff --git a/monkay/__init__.py b/monkay/__init__.py index f074ca5..3001856 100644 --- a/monkay/__init__.py +++ b/monkay/__init__.py @@ -10,4 +10,5 @@ "ExtensionProtocol", "load", "load_any", + "absolutify_import", ] diff --git a/monkay/base.py b/monkay/base.py index d0b9b85..9ea0276 100644 --- a/monkay/base.py +++ b/monkay/base.py @@ -12,6 +12,7 @@ TYPE_CHECKING, Any, Generic, + Literal, Protocol, TypedDict, TypeVar, @@ -35,18 +36,24 @@ class DeprecatedImport(TypedDict, total=False): DeprecatedImport.__required_keys__ = frozenset({"deprecated"}) -def load(path: str, allow_splits: str = ":.") -> Any: +def load(path: str, *, allow_splits: str = ":.", package: None | str = None) -> Any: splitted = path.rsplit(":", 1) if ":" in allow_splits else [] if len(splitted) < 2 and "." in allow_splits: splitted = path.rsplit(".", 1) if len(splitted) != 2: raise ValueError(f"invalid path: {path}") - module = import_module(splitted[0]) + module = import_module(splitted[0], package) return getattr(module, splitted[1]) -def load_any(path: str, attrs: Sequence[str], *, non_first_deprecated: bool = False) -> Any | None: - module = import_module(path) +def load_any( + path: str, + attrs: Sequence[str], + *, + non_first_deprecated: bool = False, + package: None | str = None, +) -> Any | None: + module = import_module(path, package) first_name: None | str = None for attr in attrs: @@ -74,7 +81,33 @@ def _stub_previous_getattr(name: str) -> Any: raise AttributeError(f'Module has no attribute: "{name}" (Monkay).') +def _obj_to_full_name(obj: Any) -> str: + if not isclass(obj): + obj = type(obj) + return f"{obj.__module__}.{obj.__qualname__}" + + +def absolutify_import(import_path: str, package: str | None) -> str: + if not package or not import_path: + return import_path + dot_count: int = 0 + try: + while import_path[dot_count] == ".": + dot_count += 1 + except IndexError: + raise ValueError("not an import path") from None + if dot_count == 0: + return import_path + if dot_count - 2 > package.count("."): + raise ValueError("Out of bound, tried to cross parent.") + if dot_count > 1: + package = package.rsplit(".", dot_count - 1)[0] + + return f"{package}.{import_path.lstrip('.')}" + + class Monkay(Generic[INSTANCE, SETTINGS]): + getter: Callable[..., Any] _instance: None | INSTANCE = None _instance_var: ContextVar[INSTANCE | None] | None = None # extensions are pretended to always exist, we check the _extensions_var @@ -101,6 +134,7 @@ def __init__( settings_ctx_name: str = "monkay_settings_ctx", extensions_applied_ctx_name: str = "monkay_extensions_applied_ctx", skip_all_update: bool = False, + package: str | None = "", ) -> None: if with_instance is True: with_instance = "monkay_instance_ctx" @@ -108,6 +142,9 @@ def __init__( if with_extensions is True: with_extensions = "monkay_extensions_ctx" with_extensions = with_extensions + if package == "" and global_dict.get("__spec__"): + package = global_dict["__spec__"].parent + self.package = package or None self._cached_imports: dict[str, Any] = {} self.uncached_imports: set[str] = set(uncached_imports) @@ -139,7 +176,7 @@ def __init__( getter: Callable[..., Any] = self.module_getter if "__getattr__" in global_dict: getter = partial(getter, chained_getter=global_dict["__getattr__"]) - global_dict["__getattr__"] = getter + global_dict["__getattr__"] = self.getter = getter if not skip_all_update: all_var = global_dict.setdefault("__all__", []) global_dict["__all__"] = self.update_all_var(all_var) @@ -171,8 +208,9 @@ def instance(self) -> INSTANCE | None: def set_instance( self, instance: INSTANCE, + *, apply_extensions: bool = True, - use_extension_overwrite: bool = True, + use_extensions_overwrite: bool = True, ) -> None: assert self._instance_var is not None, "Monkay not enabled for instances" # need to address before the instance is swapped @@ -180,23 +218,24 @@ def set_instance( raise RuntimeError("Other apply process in the same context is active.") self._instance = instance if apply_extensions and self._extensions_var is not None: - self.apply_extensions(use_overwrite=use_extension_overwrite) + self.apply_extensions(use_overwrite=use_extensions_overwrite) @contextmanager def with_instance( self, instance: INSTANCE | None, + *, apply_extensions: bool = False, - use_extension_overwrite: bool = True, + use_extensions_overwrite: bool = True, ) -> Generator: assert self._instance_var is not None, "Monkay not enabled for instances" # need to address before the instance is swapped - if apply_extensions and self._extensions_applied_var.get() is not None: + if apply_extensions and self._extensions_var is not None and self._extensions_applied_var.get() is not None: raise RuntimeError("Other apply process in the same context is active.") token = self._instance_var.set(instance) try: if apply_extensions and self._extensions_var is not None: - self.apply_extensions(use_overwrite=use_extension_overwrite) + self.apply_extensions(use_overwrite=use_extensions_overwrite) yield finally: self._instance_var.reset(token) @@ -278,12 +317,15 @@ def add_extension( def with_extensions( self, extensions: dict[str, ExtensionProtocol[INSTANCE, SETTINGS]] | None, + *, apply_extensions: bool = False, ) -> Generator: # why None, for temporary using the real extensions assert self._extensions_var is not None, "Monkay not enabled for extensions" token = self._extensions_var.set(extensions) try: + if apply_extensions and self.instance is not None: + self.apply_extensions() yield finally: self._extensions_var.reset(token) @@ -301,9 +343,84 @@ def update_all_var(self, all_var: Sequence[str]) -> list[str]: all_var.append(var) return all_var + def find_missing( + self, + *, + all_var: None | Sequence[str] = None, + search_pathes: None | Sequence[str] = None, + ignore_deprecated_import_errors: bool = False, + require_search_path_all_var: bool = True, + ) -> dict[ + str, + set[ + Literal[ + "all_var", + "import", + "search_path_extra", + "search_path_import", + "search_path_all_var", + ] + ], + ]: + """Debug method to check""" + + assert self.getter is not None + missing: dict[ + str, + set[ + Literal[ + "all_var", + "import", + "search_path_extra", + "search_path_import", + "search_path_all_var", + ] + ], + ] = {} + key_set = set(chain(self.lazy_imports.keys(), self.deprecated_lazy_imports.keys())) + value_pathes_set: set[str] = set() + for name in key_set: + found_path: str = "" + if name in self.lazy_imports and isinstance(self.lazy_imports[name], str): + found_path = cast(str, self.lazy_imports[name]).replace(":", ".") + elif name in self.deprecated_lazy_imports and isinstance(self.deprecated_lazy_imports[name]["path"], str): + found_path = cast(str, self.deprecated_lazy_imports[name]["path"]).replace(":", ".") + if found_path: + value_pathes_set.add(absolutify_import(found_path, self.package)) + try: + returnobj = self.getter(name, no_warn_deprecated=True) + if not found_path: + value_pathes_set.add(_obj_to_full_name(returnobj)) + except ImportError: + if not ignore_deprecated_import_errors or name not in self.deprecated_lazy_imports: + missing.setdefault(name, set()).add("import") + if search_pathes: + for search_path in search_pathes: + try: + mod = import_module(search_path, self.package) + except ImportError: + missing.setdefault(search_path, set()).add("search_path_import") + continue + try: + all_var_search = mod.__all__ + except AttributeError: + if require_search_path_all_var: + missing.setdefault(search_path, set()).add("search_path_all_var") + + continue + for export_name in all_var_search: + export_path = absolutify_import(f"{search_path}.{export_name}", self.package) + if export_path not in value_pathes_set: + missing.setdefault(export_path, set()).add("search_path_extra") + if all_var is not None: + for name in key_set.difference(all_var): + missing.setdefault(name, set()).add("all_var") + + return missing + @cached_property def _settings(self) -> SETTINGS: - settings: Any = load(self.settings_path) + settings: Any = load(self.settings_path, package=self.package) if isclass(settings): settings = settings() return settings @@ -348,18 +465,30 @@ def add_deprecated_lazy_import( raise KeyError(f'"{name}" is already a deprecated lazy import') self.deprecated_lazy_imports[name] = value - def module_getter(self, key: str, *, chained_getter: Callable[[str], Any] = _stub_previous_getattr) -> Any: + def module_getter( + self, + key: str, + *, + chained_getter: Callable[[str], Any] = _stub_previous_getattr, + no_warn_deprecated: bool = False, + ) -> Any: + """ + Module Getter which handles lazy imports. + The injected version containing a potential found __getattr__ handler as chained_getter + is availabe as getter attribute. + """ lazy_import = self.lazy_imports.get(key) if lazy_import is None: deprecated = self.deprecated_lazy_imports.get(key) if deprecated is not None: lazy_import = deprecated["path"] - warn_strs = [f'Attribute: "{key}" is deprecated.'] - if deprecated.get("reason"): - warn_strs.append(f"Reason: {deprecated['reason']}.") - if deprecated.get("new_attribute"): - warn_strs.append(f'Use "{deprecated["new_attribute"]}" instead.') - warnings.warn("\n".join(warn_strs), DeprecationWarning, stacklevel=2) + if not no_warn_deprecated: + warn_strs = [f'Attribute: "{key}" is deprecated.'] + if deprecated.get("reason"): + warn_strs.append(f"Reason: {deprecated['reason']}.") + if deprecated.get("new_attribute"): + warn_strs.append(f'Use "{deprecated["new_attribute"]}" instead.') + warnings.warn("\n".join(warn_strs), DeprecationWarning, stacklevel=2) if lazy_import is None: return chained_getter(key) @@ -367,7 +496,7 @@ def module_getter(self, key: str, *, chained_getter: Callable[[str], Any] = _stu if callable(lazy_import): value: Any = lazy_import() else: - value = load(lazy_import) + value = load(lazy_import, package=self.package) if key in self.uncached_imports: return value else: @@ -380,7 +509,7 @@ def _handle_preloads(self, preloads: Iterable[str]) -> None: for preload in preloads: splitted = preload.rsplit(":", 1) try: - module = import_module(splitted[0]) + module = import_module(splitted[0], self.package) except ImportError: module = None if module is not None and len(splitted) == 2: diff --git a/pyproject.toml b/pyproject.toml index 76775ea..e19e478 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,8 @@ path = "monkay/__about__.py" [tool.hatch.envs.default] dependencies = [ - "pydantic_settings" + "pydantic_settings", + "pre-commit" ] [tool.hatch.envs.docs] diff --git a/tests/targets/fn_module.py b/tests/targets/fn_module.py index 95fb360..b926043 100644 --- a/tests/targets/fn_module.py +++ b/tests/targets/fn_module.py @@ -2,5 +2,12 @@ def bar(): return "bar" +def bar2(): + return "bar2" + + def deprecated(): return "deprecated" + + +__all__ = ["bar", "bar2", "deprecated"] diff --git a/tests/targets/module_full.py b/tests/targets/module_full.py index 7b9c12f..0b90e8d 100644 --- a/tests/targets/module_full.py +++ b/tests/targets/module_full.py @@ -24,7 +24,8 @@ class FakeApp: settings_extensions_name="extensions", uncached_imports=["settings"], lazy_imports={ - "bar": "tests.targets.fn_module:bar", + "bar": ".fn_module:bar", + "bar2": "..targets.fn_module:bar2", "dynamic": lambda: "dynamic", "settings": lambda: monkay.settings, }, diff --git a/tests/targets/module_preloaded1.py b/tests/targets/module_preloaded1.py index e69de29..9f046f3 100644 --- a/tests/targets/module_preloaded1.py +++ b/tests/targets/module_preloaded1.py @@ -0,0 +1,4 @@ +not_included_export = False + + +__all__ = ["not_included_export"] diff --git a/tests/test_basic.py b/tests/test_basic.py index 1121aac..49093d8 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -47,6 +47,7 @@ def test_attrs(): # in extras assert mod.foo() == "foo" assert mod.bar() == "bar" + assert mod.bar2() == "bar2" with pytest.raises(KeyError): mod.monkay.add_lazy_import("bar", "tests.targets.fn_module:bar") with pytest.raises(KeyError): @@ -190,3 +191,29 @@ def test_caches(): assert not mod.monkay._cached_imports assert "_settings" not in mod.monkay.__dict__ + + +def test_find_missing(): + import tests.targets.module_full as mod + + # __all__ is autogenerated + assert not mod.monkay.find_missing(all_var=mod.__all__, search_pathes=["tests.targets.fn_module"]) + assert mod.monkay.find_missing( + all_var=mod.__all__, + search_pathes=["tests.targets.not_existing", "tests.targets.module_preloaded1"], + ) == { + "tests.targets.not_existing": {"search_path_import"}, + "tests.targets.module_preloaded1.not_included_export": {"search_path_extra"}, + } + assert mod.monkay.find_missing(all_var={}, search_pathes=["tests.targets.module_full_preloaded1"]) == { + "bar": {"all_var"}, + "bar2": { + "all_var", + }, + "dynamic": {"all_var"}, + "settings": {"all_var"}, + "deprecated": {"all_var"}, + "tests.targets.module_full_preloaded1": { + "search_path_all_var", + }, + }