From 222004fe8cb118aaf8bd7371a14b69163dfa2907 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 12 Nov 2024 18:22:04 +0100 Subject: [PATCH 1/6] Changes: - add find_missing function for easy testing - update docs --- docs/helpers.md | 4 +- docs/release-notes.md | 7 ++ docs/testing.md | 53 ++++++++++ mkdocs.yml | 3 +- monkay/base.py | 150 +++++++++++++++++++++++++---- tests/targets/fn_module.py | 3 + tests/targets/module_preloaded1.py | 4 + tests/test_basic.py | 32 +++++- 8 files changed, 232 insertions(+), 24 deletions(-) create mode 100644 docs/testing.md diff --git a/docs/helpers.md b/docs/helpers.md index e2db225..9ad91df 100644 --- a/docs/helpers.md +++ b/docs/helpers.md @@ -3,9 +3,9 @@ Monkay comes with two 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. diff --git a/docs/release-notes.md b/docs/release-notes.md index c6050f8..37d1cff 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,5 +1,12 @@ # Release notes +## Version 0.0.4 + +### Added + +- `find_missing` test method. +- `getter` attribute saving the injected getter. + ## Version 0.0.3 ### Added 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..dea8a7d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,8 +1,9 @@ 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 diff --git a/monkay/base.py b/monkay/base.py index d0b9b85..a276479 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,14 @@ 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__}" + + 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 +115,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 +123,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__"].name + self.package = package or None self._cached_imports: dict[str, Any] = {} self.uncached_imports: set[str] = set(uncached_imports) @@ -139,7 +157,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 +189,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 +199,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 +298,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 +324,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: + was_set: bool = False + if name in self.lazy_imports and isinstance(self.lazy_imports[name], str): + value_pathes_set.add(cast(str, self.lazy_imports[name]).replace(":", ".")) + was_set = True + elif name in self.deprecated_lazy_imports and isinstance(self.deprecated_lazy_imports[name]["path"], str): + value_pathes_set.add(cast(str, self.deprecated_lazy_imports[name]["path"]).replace(":", ".")) + was_set = True + try: + returnobj = self.getter(name, no_warn_deprecated=True) + if not was_set: + 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 = f"{search_path}.{export_name}" + 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 +446,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 +477,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 +490,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/tests/targets/fn_module.py b/tests/targets/fn_module.py index 95fb360..5803768 100644 --- a/tests/targets/fn_module.py +++ b/tests/targets/fn_module.py @@ -4,3 +4,6 @@ def bar(): def deprecated(): return "deprecated" + + +__all__ = ["bar", "deprecated"] 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..a6b1940 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -62,7 +62,10 @@ def test_attrs(): assert isinstance(mod.settings, BaseSettings) with pytest.warns(DeprecationWarning) as record: assert mod.deprecated() == "deprecated" - assert record[0].message.args[0] == 'Attribute: "deprecated" is deprecated.\nReason: old.\nUse "super_new" instead.' + assert ( + record[0].message.args[0] + == 'Attribute: "deprecated" is deprecated.\nReason: old.\nUse "super_new" instead.' + ) def test_load(): @@ -190,3 +193,30 @@ 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"}, + "dynamic": {"all_var"}, + "settings": {"all_var"}, + "deprecated": {"all_var"}, + "tests.targets.module_full_preloaded1": { + "search_path_all_var", + }, + } From 9eb58205e8dddf4f48e20a9805dd30ae94d7c2dd Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 12 Nov 2024 18:25:31 +0100 Subject: [PATCH 2/6] update release note --- docs/release-notes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/release-notes.md b/docs/release-notes.md index 37d1cff..6fe2850 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -7,6 +7,10 @@ - `find_missing` test method. - `getter` attribute saving the injected getter. +### Changed + +- Rename typo `settings_preload_name` to `settings_preloads_name`. + ## Version 0.0.3 ### Added From bb373655aeb0de82433608d29bc9a9edebcdbb32 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 12 Nov 2024 18:51:00 +0100 Subject: [PATCH 3/6] Changes: - release notes - specials - fix relative imports and add tests - add absolutify_import --- docs/helpers.md | 4 +- docs/release-notes.md | 2 + docs/specials.md | 6 ++ mkdocs.yml | 1 + monkay/__init__.py | 1 + monkay/base.py | 127 ++++++++++++++++++++++++++--------- tests/targets/fn_module.py | 6 +- tests/targets/module_full.py | 3 +- tests/test_basic.py | 17 ++--- 9 files changed, 121 insertions(+), 46 deletions(-) create mode 100644 docs/specials.md diff --git a/docs/helpers.md b/docs/helpers.md index 9ad91df..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=":.", 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, 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 6fe2850..c881444 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -6,10 +6,12 @@ - `find_missing` test method. - `getter` attribute saving the injected getter. +- `absolutify_import` helper. ### Changed - Rename typo `settings_preload_name` to `settings_preloads_name`. +- Fix relative imports. ## Version 0.0.3 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/mkdocs.yml b/mkdocs.yml index dea8a7d..c6158e8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -7,3 +7,4 @@ nav: - 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 a276479..01e7901 100644 --- a/monkay/base.py +++ b/monkay/base.py @@ -87,14 +87,37 @@ def _obj_to_full_name(obj: Any) -> str: 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 _extensions: dict[str, ExtensionProtocol[INSTANCE, SETTINGS]] - _extensions_var: None | ContextVar[None | dict[str, ExtensionProtocol[INSTANCE, SETTINGS]]] = None - _extensions_applied: None | ContextVar[dict[str, ExtensionProtocol[INSTANCE, SETTINGS]] | None] = None + _extensions_var: ( + None | ContextVar[None | dict[str, ExtensionProtocol[INSTANCE, SETTINGS]]] + ) = None + _extensions_applied: ( + None | ContextVar[dict[str, ExtensionProtocol[INSTANCE, SETTINGS]] | None] + ) = None _settings_var: ContextVar[SETTINGS | None] | None = None def __init__( @@ -103,7 +126,8 @@ def __init__( *, with_instance: str | bool = False, with_extensions: str | bool = False, - extension_order_key_fn: None | Callable[[ExtensionProtocol[INSTANCE, SETTINGS]], Any] = None, + extension_order_key_fn: None + | Callable[[ExtensionProtocol[INSTANCE, SETTINGS]], Any] = None, settings_path: str = "", preloads: Iterable[str] = (), settings_preload_name: str = "", @@ -124,7 +148,7 @@ def __init__( with_extensions = "monkay_extensions_ctx" with_extensions = with_extensions if package == "" and global_dict.get("__spec__"): - package = global_dict["__spec__"].name + package = global_dict["__spec__"].parent self.package = package or None self._cached_imports: dict[str, Any] = {} @@ -139,7 +163,9 @@ def __init__( self.add_deprecated_lazy_import(name, deprecated_import) self.settings_path = settings_path if self.settings_path: - self._settings_var = global_dict[settings_ctx_name] = ContextVar(settings_ctx_name, default=None) + self._settings_var = global_dict[settings_ctx_name] = ContextVar( + settings_ctx_name, default=None + ) if settings_preload_name: warnings.warn( @@ -162,17 +188,23 @@ def __init__( all_var = global_dict.setdefault("__all__", []) global_dict["__all__"] = self.update_all_var(all_var) if with_instance: - self._instance_var = global_dict[with_instance] = ContextVar(with_instance, default=None) + self._instance_var = global_dict[with_instance] = ContextVar( + with_instance, default=None + ) if with_extensions: self.extension_order_key_fn = extension_order_key_fn self._extensions = {} - self._extensions_var = global_dict[with_extensions] = ContextVar(with_extensions, default=None) - self._extensions_applied_var = global_dict[extensions_applied_ctx_name] = ContextVar( - extensions_applied_ctx_name, default=None + self._extensions_var = global_dict[with_extensions] = ContextVar( + with_extensions, default=None + ) + self._extensions_applied_var = global_dict[extensions_applied_ctx_name] = ( + ContextVar(extensions_applied_ctx_name, default=None) ) self._handle_extensions() - def clear_caches(self, settings_cache: bool = True, import_cache: bool = True) -> None: + def clear_caches( + self, settings_cache: bool = True, import_cache: bool = True + ) -> None: if settings_cache: self.__dict__.pop("_settings", None) if import_cache: @@ -211,7 +243,11 @@ def with_instance( ) -> 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_var is not None 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: @@ -231,9 +267,9 @@ def apply_extensions(self, use_overwrite: bool = True) -> None: extensions_applied = self._extensions_applied_var.get() if extensions_applied is not None: raise RuntimeError("Other apply process in the same context is active.") - extensions_ordered: Iterable[tuple[str, ExtensionProtocol[INSTANCE, SETTINGS]]] = cast( - dict[str, ExtensionProtocol[INSTANCE, SETTINGS]], extensions - ).items() + extensions_ordered: Iterable[ + tuple[str, ExtensionProtocol[INSTANCE, SETTINGS]] + ] = cast(dict[str, ExtensionProtocol[INSTANCE, SETTINGS]], extensions).items() if self.extension_order_key_fn is not None: extensions_ordered = sorted( @@ -251,19 +287,27 @@ def apply_extensions(self, use_overwrite: bool = True) -> None: finally: self._extensions_applied_var.reset(token) - def ensure_extension(self, name_or_extension: str | ExtensionProtocol[INSTANCE, SETTINGS]) -> None: + def ensure_extension( + self, name_or_extension: str | ExtensionProtocol[INSTANCE, SETTINGS] + ) -> None: assert self._extensions_var is not None, "Monkay not enabled for extensions" - extensions: dict[str, ExtensionProtocol[INSTANCE, SETTINGS]] | None = self._extensions_var.get() + extensions: dict[str, ExtensionProtocol[INSTANCE, SETTINGS]] | None = ( + self._extensions_var.get() + ) if extensions is None: extensions = self._extensions if isinstance(name_or_extension, str): name = name_or_extension extension = extensions.get(name) - elif not isclass(name_or_extension) and isinstance(name_or_extension, ExtensionProtocol): + elif not isclass(name_or_extension) and isinstance( + name_or_extension, ExtensionProtocol + ): name = name_or_extension.name extension = extensions.get(name, name_or_extension) else: - raise RuntimeError('Provided extension "{name_or_extension}" does not implement the ExtensionProtocol') + raise RuntimeError( + 'Provided extension "{name_or_extension}" does not implement the ExtensionProtocol' + ) if name in self._extensions_applied_var.get(): return @@ -358,22 +402,31 @@ def find_missing( ] ], ] = {} - key_set = set(chain(self.lazy_imports.keys(), self.deprecated_lazy_imports.keys())) + key_set = set( + chain(self.lazy_imports.keys(), self.deprecated_lazy_imports.keys()) + ) value_pathes_set: set[str] = set() for name in key_set: - was_set: bool = False + found_path: str = "" if name in self.lazy_imports and isinstance(self.lazy_imports[name], str): - value_pathes_set.add(cast(str, self.lazy_imports[name]).replace(":", ".")) - was_set = True - elif name in self.deprecated_lazy_imports and isinstance(self.deprecated_lazy_imports[name]["path"], str): - value_pathes_set.add(cast(str, self.deprecated_lazy_imports[name]["path"]).replace(":", ".")) - was_set = True + 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 was_set: + 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: + 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: @@ -386,11 +439,15 @@ def find_missing( all_var_search = mod.__all__ except AttributeError: if require_search_path_all_var: - missing.setdefault(search_path, set()).add("search_path_all_var") + missing.setdefault(search_path, set()).add( + "search_path_all_var" + ) continue for export_name in all_var_search: - export_path = f"{search_path}.{export_name}" + 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: @@ -468,8 +525,12 @@ def module_getter( 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) + 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) @@ -486,7 +547,9 @@ def module_getter( def _handle_preloads(self, preloads: Iterable[str]) -> None: if self.settings_preloads_name: - preloads = chain(preloads, getattr(self.settings, self.settings_preloads_name)) + preloads = chain( + preloads, getattr(self.settings, self.settings_preloads_name) + ) for preload in preloads: splitted = preload.rsplit(":", 1) try: diff --git a/tests/targets/fn_module.py b/tests/targets/fn_module.py index 5803768..b926043 100644 --- a/tests/targets/fn_module.py +++ b/tests/targets/fn_module.py @@ -2,8 +2,12 @@ def bar(): return "bar" +def bar2(): + return "bar2" + + def deprecated(): return "deprecated" -__all__ = ["bar", "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/test_basic.py b/tests/test_basic.py index a6b1940..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): @@ -62,10 +63,7 @@ def test_attrs(): assert isinstance(mod.settings, BaseSettings) with pytest.warns(DeprecationWarning) as record: assert mod.deprecated() == "deprecated" - assert ( - record[0].message.args[0] - == 'Attribute: "deprecated" is deprecated.\nReason: old.\nUse "super_new" instead.' - ) + assert record[0].message.args[0] == 'Attribute: "deprecated" is deprecated.\nReason: old.\nUse "super_new" instead.' def test_load(): @@ -199,9 +197,7 @@ 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 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"], @@ -209,10 +205,11 @@ def test_find_missing(): "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"] - ) == { + 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"}, From c63e8d3c912d9966f4197fd18ba3ddc0d9aad2e3 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 12 Nov 2024 18:58:05 +0100 Subject: [PATCH 4/6] fix formatting --- monkay/base.py | 96 ++++++++++++++------------------------------------ 1 file changed, 26 insertions(+), 70 deletions(-) diff --git a/monkay/base.py b/monkay/base.py index 01e7901..9ea0276 100644 --- a/monkay/base.py +++ b/monkay/base.py @@ -112,12 +112,8 @@ class Monkay(Generic[INSTANCE, SETTINGS]): _instance_var: ContextVar[INSTANCE | None] | None = None # extensions are pretended to always exist, we check the _extensions_var _extensions: dict[str, ExtensionProtocol[INSTANCE, SETTINGS]] - _extensions_var: ( - None | ContextVar[None | dict[str, ExtensionProtocol[INSTANCE, SETTINGS]]] - ) = None - _extensions_applied: ( - None | ContextVar[dict[str, ExtensionProtocol[INSTANCE, SETTINGS]] | None] - ) = None + _extensions_var: None | ContextVar[None | dict[str, ExtensionProtocol[INSTANCE, SETTINGS]]] = None + _extensions_applied: None | ContextVar[dict[str, ExtensionProtocol[INSTANCE, SETTINGS]] | None] = None _settings_var: ContextVar[SETTINGS | None] | None = None def __init__( @@ -126,8 +122,7 @@ def __init__( *, with_instance: str | bool = False, with_extensions: str | bool = False, - extension_order_key_fn: None - | Callable[[ExtensionProtocol[INSTANCE, SETTINGS]], Any] = None, + extension_order_key_fn: None | Callable[[ExtensionProtocol[INSTANCE, SETTINGS]], Any] = None, settings_path: str = "", preloads: Iterable[str] = (), settings_preload_name: str = "", @@ -163,9 +158,7 @@ def __init__( self.add_deprecated_lazy_import(name, deprecated_import) self.settings_path = settings_path if self.settings_path: - self._settings_var = global_dict[settings_ctx_name] = ContextVar( - settings_ctx_name, default=None - ) + self._settings_var = global_dict[settings_ctx_name] = ContextVar(settings_ctx_name, default=None) if settings_preload_name: warnings.warn( @@ -188,23 +181,17 @@ def __init__( all_var = global_dict.setdefault("__all__", []) global_dict["__all__"] = self.update_all_var(all_var) if with_instance: - self._instance_var = global_dict[with_instance] = ContextVar( - with_instance, default=None - ) + self._instance_var = global_dict[with_instance] = ContextVar(with_instance, default=None) if with_extensions: self.extension_order_key_fn = extension_order_key_fn self._extensions = {} - self._extensions_var = global_dict[with_extensions] = ContextVar( - with_extensions, default=None - ) - self._extensions_applied_var = global_dict[extensions_applied_ctx_name] = ( - ContextVar(extensions_applied_ctx_name, default=None) + self._extensions_var = global_dict[with_extensions] = ContextVar(with_extensions, default=None) + self._extensions_applied_var = global_dict[extensions_applied_ctx_name] = ContextVar( + extensions_applied_ctx_name, default=None ) self._handle_extensions() - def clear_caches( - self, settings_cache: bool = True, import_cache: bool = True - ) -> None: + def clear_caches(self, settings_cache: bool = True, import_cache: bool = True) -> None: if settings_cache: self.__dict__.pop("_settings", None) if import_cache: @@ -243,11 +230,7 @@ def with_instance( ) -> 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_var is not None - 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: @@ -267,9 +250,9 @@ def apply_extensions(self, use_overwrite: bool = True) -> None: extensions_applied = self._extensions_applied_var.get() if extensions_applied is not None: raise RuntimeError("Other apply process in the same context is active.") - extensions_ordered: Iterable[ - tuple[str, ExtensionProtocol[INSTANCE, SETTINGS]] - ] = cast(dict[str, ExtensionProtocol[INSTANCE, SETTINGS]], extensions).items() + extensions_ordered: Iterable[tuple[str, ExtensionProtocol[INSTANCE, SETTINGS]]] = cast( + dict[str, ExtensionProtocol[INSTANCE, SETTINGS]], extensions + ).items() if self.extension_order_key_fn is not None: extensions_ordered = sorted( @@ -287,27 +270,19 @@ def apply_extensions(self, use_overwrite: bool = True) -> None: finally: self._extensions_applied_var.reset(token) - def ensure_extension( - self, name_or_extension: str | ExtensionProtocol[INSTANCE, SETTINGS] - ) -> None: + def ensure_extension(self, name_or_extension: str | ExtensionProtocol[INSTANCE, SETTINGS]) -> None: assert self._extensions_var is not None, "Monkay not enabled for extensions" - extensions: dict[str, ExtensionProtocol[INSTANCE, SETTINGS]] | None = ( - self._extensions_var.get() - ) + extensions: dict[str, ExtensionProtocol[INSTANCE, SETTINGS]] | None = self._extensions_var.get() if extensions is None: extensions = self._extensions if isinstance(name_or_extension, str): name = name_or_extension extension = extensions.get(name) - elif not isclass(name_or_extension) and isinstance( - name_or_extension, ExtensionProtocol - ): + elif not isclass(name_or_extension) and isinstance(name_or_extension, ExtensionProtocol): name = name_or_extension.name extension = extensions.get(name, name_or_extension) else: - raise RuntimeError( - 'Provided extension "{name_or_extension}" does not implement the ExtensionProtocol' - ) + raise RuntimeError('Provided extension "{name_or_extension}" does not implement the ExtensionProtocol') if name in self._extensions_applied_var.get(): return @@ -402,20 +377,14 @@ def find_missing( ] ], ] = {} - key_set = set( - chain(self.lazy_imports.keys(), self.deprecated_lazy_imports.keys()) - ) + 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(":", ".") + 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: @@ -423,10 +392,7 @@ def find_missing( 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 - ): + 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: @@ -439,15 +405,11 @@ def find_missing( all_var_search = mod.__all__ except AttributeError: if require_search_path_all_var: - missing.setdefault(search_path, set()).add( - "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 - ) + 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: @@ -525,12 +487,8 @@ def module_getter( 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 - ) + 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) @@ -547,9 +505,7 @@ def module_getter( def _handle_preloads(self, preloads: Iterable[str]) -> None: if self.settings_preloads_name: - preloads = chain( - preloads, getattr(self.settings, self.settings_preloads_name) - ) + preloads = chain(preloads, getattr(self.settings, self.settings_preloads_name)) for preload in preloads: splitted = preload.rsplit(":", 1) try: From 83da7b652cf57acd5b721a617e09694ace72475c Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 12 Nov 2024 19:00:31 +0100 Subject: [PATCH 5/6] add pre-commit --- .pre-commit-config.yaml | 23 +++++++++++++++++++++++ pyproject.toml | 3 ++- 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 .pre-commit-config.yaml 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/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] From f1ccdcc97faa2c222d88243a4665ed61dcaa0cc6 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 12 Nov 2024 19:00:54 +0100 Subject: [PATCH 6/6] update release notes --- docs/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes.md b/docs/release-notes.md index c881444..e722e0c 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -7,6 +7,7 @@ - `find_missing` test method. - `getter` attribute saving the injected getter. - `absolutify_import` helper. +- Add pre-commit. ### Changed