Skip to content

Commit

Permalink
Merge pull request #39 from ManderaGeneral/allow_typing_hacky
Browse files Browse the repository at this point in the history
Allow typing and still trigger by hash and eq
  • Loading branch information
Mandera committed Jun 2, 2023
2 parents 10c4314 + fd146b7 commit 06bf562
Show file tree
Hide file tree
Showing 12 changed files with 214 additions and 129 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/workflow_dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,5 @@ jobs:
- name: Run unittests
run: |
cd repos/generalimport/generalimport/test
python -m unittest discover
python -m unittest discover -v
2 changes: 1 addition & 1 deletion examples/4how_it_works.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
- When `generalimport` is instantiated it creates a new importer for `sys.meta_path`.
- This importer will return 'fake' modules for matching names and scope.
- The scope ensures only your own imports are faked.
- The fake module will recursively return itself when asked for an attribute.
- The fake module will recursively return a FakeModule instance when asked for an attribute.
- When used in any way (\_\_call\_\_, \_\_add\_\_, \_\_str\_\_ etc) it raises `generalimport.MissingDependencyException`.
- This exception has the 'skip-exceptions' from `unittest` and `pytest` as bases, which means that tests will automatically be skipped.
"""
12 changes: 4 additions & 8 deletions generalimport/__init__.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@

from generalimport.generalimport_bottom import get_installed_modules_names, module_is_installed, import_module, module_name_is_namespace, module_is_namespace, spec_is_namespace, _get_previous_frame_filename, _get_top_name, _get_scope_from_filename, get_spec, fake_module_check
from generalimport.generalimport_bottom import get_installed_modules_names, module_is_installed, import_module, module_name_is_namespace, module_is_namespace, spec_is_namespace, get_spec, fake_module_check
from generalimport.exception import MissingDependencyException, MissingOptionalDependency
from generalimport.fake_module import FakeModule, is_imported
from generalimport.dunders import DynamicDunder, NON_CALLABLE_DUNDERS, CALLABLE_CLASS_DUNDERS, CALLABLE_DUNDERS
from generalimport.fake_module import FakeModule
from generalimport.general_importer import GeneralImporter
from generalimport.import_catcher import ImportCatcher
from generalimport.top import generalimport, get_importer, reset_generalimport





from generalimport.top import generalimport, get_importer, reset_generalimport, is_imported
90 changes: 90 additions & 0 deletions generalimport/dunders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import typing

from generalimport.generalimport_bottom import _module_in_stack
from generalimport.import_catcher import ErrorPars


class DynamicDunder:
""" Inherit to define a dynamic dunder.
All subclasses' triggers are tested for truthy before a MissingOptionalDependency is raised.
Returns result() of first triggered dynamic dunder. """
subclasses: typing.List[typing.Type["DynamicDunder"]] = []

def __init_subclass__(cls, **kwargs):
cls.subclasses.append(cls)

def __init__(self, error_pars: ErrorPars):
self.error_pars = error_pars

def trigger(self): return True
def result(self): ...


NON_CALLABLE_DUNDERS = (
# Callable
"__annotations__", "__closure__", "__code__", "__defaults__", "__globals__", "__kwdefaults__",
# Info
"__bases__", "__class__", "__dict__", "__doc__", "__module__", "__name__", "__qualname__", "__all__", "__slots__",
# Pydantic
"_nparams",
)
CALLABLE_CLASS_DUNDERS = [
# Lookup
"__class_getitem__",
]
CALLABLE_DUNDERS = [
# Binary
"__ilshift__", "__invert__", "__irshift__", "__ixor__", "__lshift__", "__rlshift__", "__rrshift__", "__rshift__",
# Callable
"__call__",
# Cast
"__bool__", "__bytes__", "__complex__", "__float__", "__int__", "__iter__", "__hash__",
# Compare
"__eq__", "__ge__", "__gt__", "__instancecheck__", "__le__", "__lt__", "__ne__", "__subclasscheck__",
# Context
"__enter__", "__exit__",
# Delete
"__delattr__", "__delitem__", "__delslice__",
# Info
"__sizeof__", "__subclasses__",
# Iterable
"__len__", "__next__", "__reversed__", "__contains__", "__getitem__", "__setitem__",
# Logic
"__and__", "__iand__", "__ior__", "__or__", "__rand__", "__ror__", "__rxor__", "__xor__",
# Lookup
"__dir__",
# Math
"__abs__", "__add__", "__ceil__", "__divmod__", "__floor__", "__floordiv__", "__iadd__", "__ifloordiv__",
"__imod__", "__imul__", "__ipow__", "__isub__", "__itruediv__", "__mod__", "__mul__", "__neg__", "__pos__",
"__pow__", "__radd__", "__rdiv__", "__rdivmod__", "__rfloordiv__", "__rmod__", "__rmul__", "__round__",
"__rpow__", "__rsub__", "__rtruediv__", "__sub__", "__truediv__", "__trunc__",
# Matrix
"__imatmul__", "__matmul__", "__rmatmul__",
# Object
"__init_subclass__", "__prepare__", "__set_name__",
# Pickle
"__getnewargs__", "__getnewargs_ex__", "__getstate__", "__reduce__", "__reduce_ex__",
# String
"__format__", "__fspath__", "__repr__", "__str__",
# Thread
"__aenter__", "__aexit__", "__aiter__", "__anext__", "__await__",
# Typing
"__origin__",
]


class DynamicEQ(DynamicDunder):
def trigger(self):
return _module_in_stack(module=typing) and self.error_pars.caller == "__eq__" and bool(self.error_pars.args)

def result(self):
other = self.error_pars.args[0]
return id(self) == id(other)


class DynamicHash(DynamicDunder):
def trigger(self):
return _module_in_stack(module=typing) and self.error_pars.caller == "__hash__"

def result(self):
return id(self)
117 changes: 36 additions & 81 deletions generalimport/fake_module.py
Original file line number Diff line number Diff line change
@@ -1,77 +1,23 @@
from typing import Optional
import sys
import logging
from functools import partialmethod
from generalimport.exception import MissingOptionalDependency, MissingDependencyException

from generalimport.exception import MissingDependencyException
from generalimport.dunders import DynamicDunder, NON_CALLABLE_DUNDERS, CALLABLE_CLASS_DUNDERS, CALLABLE_DUNDERS
from generalimport.import_catcher import ErrorPars

EXCEPTION_NAMING_PATTERNS = ["Exception", "Error"]

logger = logging.getLogger("generalimport")


NON_CALLABLE_DUNDERS = (
# Callable
"__annotations__", "__closure__", "__code__", "__defaults__", "__globals__", "__kwdefaults__",
# Info
"__bases__", "__class__", "__dict__", "__doc__", "__module__", "__name__", "__qualname__", "__all__", "__slots__",
# Pydantic
"_nparams",
)


CALLABLE_CLASS_DUNDERS = [
# Lookup
"__class_getitem__",
]

CALLABLE_DUNDERS = [
# Binary
"__ilshift__", "__invert__", "__irshift__", "__ixor__", "__lshift__", "__rlshift__", "__rrshift__", "__rshift__",
# Callable
"__call__",
# Cast
"__bool__", "__bytes__", "__complex__", "__float__", "__int__", "__iter__", "__hash__",
# Compare
"__eq__", "__ge__", "__gt__", "__instancecheck__", "__le__", "__lt__", "__ne__", "__subclasscheck__",
# Context
"__enter__", "__exit__",
# Delete
"__delattr__", "__delitem__", "__delslice__",
# Info
"__sizeof__", "__subclasses__",
# Iterable
"__len__", "__next__", "__reversed__", "__contains__", "__getitem__", "__setitem__",
# Logic
"__and__", "__iand__", "__ior__", "__or__", "__rand__", "__ror__", "__rxor__", "__xor__",
# Lookup
"__dir__",
# Math
"__abs__", "__add__", "__ceil__", "__divmod__", "__floor__", "__floordiv__", "__iadd__", "__ifloordiv__",
"__imod__", "__imul__", "__ipow__", "__isub__", "__itruediv__", "__mod__", "__mul__", "__neg__", "__pos__",
"__pow__", "__radd__", "__rdiv__", "__rdivmod__", "__rfloordiv__", "__rmod__", "__rmul__", "__round__",
"__rpow__", "__rsub__", "__rtruediv__", "__sub__", "__truediv__", "__trunc__",
# Matrix
"__imatmul__", "__matmul__", "__rmatmul__",
# Object
"__init_subclass__", "__prepare__", "__set_name__",
# Pickle
"__getnewargs__", "__getnewargs_ex__", "__getstate__", "__reduce__", "__reduce_ex__",
# String
"__format__", "__fspath__", "__repr__", "__str__",
# Thread
"__aenter__", "__aexit__", "__aiter__", "__anext__", "__await__",
# Typing
"__origin__",
]



class FakeModule:
""" Behaves like a module but any attrs asked for always returns self.
Raises a ModuleNotFoundError when used in any way.
Unhandled use-cases: https://github.com/ManderaGeneral/generalimport/issues?q=is%3Aissue+is%3Aopen+label%3Aunhandled """
__path__ = []
# __args__ = [] # Doesn't seem necessary
SENTINEL = object()

def __init__(self, spec, trigger: Optional[str] = None, catcher=None):
self.name = spec.name
Expand All @@ -83,29 +29,49 @@ def __init__(self, spec, trigger: Optional[str] = None, catcher=None):
self.__spec__ = spec
self.__fake_module__ = True # Should not be needed, but let's keep it for safety?

@staticmethod
def _error_func(name, trigger, caller, catcher):
required_by = f" (required by '{trigger}')" if trigger else ""
name_part = f"{name}{required_by} " if name else ""

@classmethod
def _dynamic_dunder_check(cls, error_pars: ErrorPars):
for dynamic_dunder_cls in DynamicDunder.subclasses:
dynamic_dunder = dynamic_dunder_cls(error_pars=error_pars)
if dynamic_dunder.trigger():
return dynamic_dunder.result()

return cls.SENTINEL

@classmethod
def _get_error_message(cls, error_pars: ErrorPars):
required_by = f" (required by '{error_pars.trigger}')" if error_pars.trigger else ""
name_part = f"{error_pars.name}{required_by} " if error_pars.name else ""

msg_list = [
f"Optional dependency {name_part}was used but it isn't installed.",
f"Triggered by '{caller}'.",
f"Triggered by '{error_pars.caller}'.",
]
if catcher and catcher.message:
msg_list.append(catcher.message)
if error_pars.catcher is not None and error_pars.catcher.message:
msg_list.append(error_pars.catcher.message)

return " ".join(msg_list)

@classmethod
def _error_func(cls, error_pars):
result = cls._dynamic_dunder_check(error_pars=error_pars)
if result is not cls.SENTINEL:
return result

msg = " ".join(msg_list)
msg = cls._get_error_message(error_pars=error_pars)

logger.debug(msg=msg)
raise MissingDependencyException(msg=msg)

def error_func(self, _caller: str, *args, **kwargs):
self._error_func(name=self.name, trigger=self.trigger, caller=_caller, catcher=self.catcher)
error_pars = ErrorPars(name=self.name, trigger=self.trigger, caller=_caller, catcher=self.catcher, args=args, kwargs=kwargs)
return self._error_func(error_pars=error_pars)

@classmethod
def error_func_class(cls, _caller: str, *args, **kwargs):
cls._error_func(name=None, trigger=None, caller=_caller, catcher=None)
error_pars = ErrorPars(name=None, trigger=None, caller=_caller, catcher=None, args=args, kwargs=kwargs)
return cls._error_func(error_pars=error_pars)

@staticmethod
def _item_is_exception(item):
Expand All @@ -118,7 +84,7 @@ def _item_is_dunder(item):
def __getattr__(self, item):
fakemodule = FakeModule(spec=self.__spec__, trigger=item, catcher=self.catcher)
if self._item_is_exception(item=item) or self._item_is_dunder(item=item):
fakemodule.error_func(item)
return fakemodule.error_func(item)
return fakemodule


Expand All @@ -133,14 +99,3 @@ def __getattr__(self, item):



def is_imported(module_name: str) -> bool:
"""
Returns True if the module was actually imported, False, if generalimport mocked it.
"""
module = sys.modules.get(module_name)
try:
return bool(module and not isinstance(module, FakeModule))
except MissingDependencyException as exc:
# isinstance() raises MissingDependencyException: fake module
pass
return False
3 changes: 2 additions & 1 deletion generalimport/general_importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
import sys
from logging import getLogger

from generalimport import FakeModule, spec_is_namespace, _get_top_name, get_spec, fake_module_check, module_is_namespace
from generalimport.fake_module import FakeModule
from generalimport.generalimport_bottom import spec_is_namespace, _get_top_name, get_spec, fake_module_check, module_is_namespace


class GeneralImporter:
Expand Down
10 changes: 9 additions & 1 deletion generalimport/generalimport_bottom.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import importlib
import pkgutil
import sys
from inspect import getmodule
from pathlib import Path


Expand Down Expand Up @@ -70,7 +71,6 @@ def fake_module_check(obj, error=True):
return False



def _get_previous_frame_filename(depth):
frame = sys._getframe(depth)
files = ("importlib", "generalimport_bottom.py")
Expand All @@ -82,6 +82,14 @@ def _get_previous_frame_filename(depth):
return filename
frame = frame.f_back

def _module_in_stack(module):
frame = sys._getframe(0)
while frame:
if getmodule(frame) is module:
return True
frame = frame.f_back
return False

def _get_scope_from_filename(filename):
last_part = Path(filename).parts[-1]
return filename[0:filename.index(last_part)]
Expand Down
14 changes: 13 additions & 1 deletion generalimport/import_catcher.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import dataclasses
from logging import getLogger
from typing import Optional, Union

from generalimport import _get_previous_frame_filename, _get_top_name, _get_scope_from_filename
from generalimport.generalimport_bottom import _get_previous_frame_filename, _get_top_name, _get_scope_from_filename


class ImportCatcher:
Expand Down Expand Up @@ -54,3 +56,13 @@ def _handle_scope(self):
filename = _get_previous_frame_filename(depth=6)
self.latest_scope_filename = filename
return filename.startswith(self._scope)


@dataclasses.dataclass
class ErrorPars:
name: Optional[str]
trigger: Optional[str]
caller: str
catcher: Optional[ImportCatcher]
args: Union[list, tuple]
kwargs: dict
2 changes: 2 additions & 0 deletions generalimport/min.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
""" Standalone minimal example """

import sys
import importlib
from unittest.case import SkipTest
Expand Down
Loading

0 comments on commit 06bf562

Please sign in to comment.