diff --git a/.gitignore b/.gitignore index eef735d..55f2429 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ __pycache__/ *$py.class build/ dist/ +docs/_build/ # Project settings .idea/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0c95dff..1a330ea 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,6 @@ repos: - id: end-of-file-fixer - id: check-yaml - id: debug-statements - - id: flake8 - repo: local hooks: - id: rst diff --git a/.project b/.project new file mode 100644 index 0000000..60d6595 --- /dev/null +++ b/.project @@ -0,0 +1,17 @@ + + + oop-ext + + + + + + org.python.pydev.PyDevBuilder + + + + + + org.python.pydev.pythonNature + + diff --git a/.pydevproject b/.pydevproject new file mode 100644 index 0000000..d001f0a --- /dev/null +++ b/.pydevproject @@ -0,0 +1,5 @@ + + +Default +python interpreter + diff --git a/.travis.yml b/.travis.yml index 8fb146b..91158da 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,54 +1,40 @@ language: python - stages: - test - name: deploy if: repo = ESSS/oop_ext AND tag IS present - install: - pip install -U pip - pip install -U tox - script: - tox - jobs: include: - - python: '3.6' - env: TOXENV=linting - - - python: '3.6' - env: TOXENV=docs - - - python: '2.7' - env: TOXENV=py27 - - - python: '3.6' - env: TOXENV=py36 - - - python: '3.7' - env: TOXENV=py37 - sudo: required - dist: xenial - - - stage: deploy - python: '3.6' - env: - install: pip install -U setuptools setuptools_scm - script: skip - deploy: - provider: pypi - distributions: sdist bdist_wheel - user: lincrosenbach - password: - # Install travis-ci CLI tool and run the command travis encrypt to generate - # the secure password - secure: REPLACE - on: - tags: true - repo: ESSS/oop_ext - python: 3.6 - + - python: '3.6' + env: TOXENV=linting + - python: '3.6' + env: TOXENV=docs + - python: '3.6' + env: TOXENV=py36 + - python: '3.7' + env: TOXENV=py37 + sudo: required + dist: xenial + - stage: deploy + python: '3.6' + env: + install: pip install -U setuptools setuptools_scm + script: skip + deploy: + provider: pypi + distributions: sdist bdist_wheel + user: lincrosenbach + password: + secure: j5/b7JtmPg4xCHShWKgNLDbXTqHI2aRzrGA/ItJmR+k6GQkeiIZy12N72F6vbx/b3HSJi9vfElVM9zzWQFAfxAk86FFpzDQ1K7kFv9haLDYG/z76j6x1Kq4QD1VYHiqY9G1XRBvTEUrwBROa76/vxC5vX0Mg8cqXpj4TLRD/qw6jCzUwmSY6garFRciAaa5ppyLdxgTSQEYZN04nT0YnxvIeNaXhQSWGI44HFAJTlxk9VRmbb5BGRP/bX2UIt//pdLsYfFMNIvR0qymQBeeFOf5H8yB4uGpKKmco1Qv415Pe7vvuZgeiL8ou1R2ZkmkdGu8taZzK8WPkzZ2Fe+KWl4joqgrU/jsYuNnwK0v7PHUGZEW7zj5lZcm1+68hjY90XsItrJy/RT6xquBZvfyOZeiOkKFtB5K8VgmnxnG0V5t9eflv0Kk3lwydFV+8fGKJA7CxMT0Qo+DMabbYlRSg6xy+AfiLEzE1hj5azs9ox2JYyoPSIcquHjxh8u5D3kmqh0E7R6KWtOIHSghHJ7MY3VbPb/QgnyvHVTOI+NvMP9uXVXAke0j1bZAscKZFsYdXnG58wqjKSmA0cXEgDyFUw8pPj46EC5ecbqZTGkRlY0C7tPXCaZx0eK79byS8tZL4D/55ffYbbVD70t8Glr0k11yOdSqK2WlXI/UXUQGCGaA= + on: + tags: true + repo: ESSS/oop_ext + python: 3.6 branches: only: - master diff --git a/README.rst b/README.rst index 06c5097..c6505be 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,5 @@ ====================================================================== -Oop ext +OOP Extensions ====================================================================== @@ -21,10 +21,10 @@ Oop ext .. image:: https://img.shields.io/readthedocs/pip.svg :target: https://oop-ext.readthedocs.io/en/latest/ -What is Oop ext ? +What is OOP Extensions ? ================================================================================ -OOP Extensions is a set of utilities for object orienting programming which is missing on Python core libraries. +OOP Extensions is a set of utilities for object oriented programming which is missing on Python core libraries. Contributing @@ -40,7 +40,7 @@ Release ------- A reminder for the maintainers on how to make a new release. -Note that the VERSION should folow the semantic versioning as X.Y.Z +Note that the VERSION should follow the semantic versioning as X.Y.Z Ex.: v1.0.5 1. Create a ``release-VERSION`` branch from ``upstream/master``. diff --git a/appveyor.yml b/appveyor.yml index e2e9070..0e3e17f 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -8,9 +8,6 @@ environment: - PYTHON: "C:\\Python36" TOXENV: "docs" - - PYTHON: "C:\\Python27" - TOXENV: "py27" - - PYTHON: "C:\\Python36" TOXENV: "py36" diff --git a/docs/changelog.rst b/docs/changelog.rst index 491ea73..069c0fb 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1 +1 @@ -.. include:: ../CHANGELOG +.. include:: ../CHANGELOG.RST diff --git a/setup.cfg b/setup.cfg index 2bc1350..1ad91cc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,9 +1,6 @@ [bdist_wheel] universal = 1 -[flake8] -exclude = docs - [aliases] test = pytest diff --git a/setup.py b/setup.py index 365d7d8..7d9e384 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ with io.open("README.rst", encoding="UTF-8") as readme_file: readme = readme_file.read() -with io.open("CHANGELOG", encoding="UTF-8") as changelog_file: +with io.open("CHANGELOG.RST", encoding="UTF-8") as changelog_file: history = changelog_file.read() requirements = [] @@ -20,7 +20,6 @@ 'Development Status :: 2 - Pre-Alpha', "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", ], @@ -30,7 +29,7 @@ license="MIT license", long_description=readme + "\n\n" + history, include_package_data=True, - python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*", + python_requires=">=3.6", keywords="oop_ext", name="oop-ext", packages=setuptools.find_packages(where="src"), diff --git a/src/oop_ext/conftest.py b/src/oop_ext/conftest.py new file mode 100644 index 0000000..5456af3 --- /dev/null +++ b/src/oop_ext/conftest.py @@ -0,0 +1,120 @@ +import pytest + + +class _ShowHandledExceptionsError: + ''' + Helper class to deal with handled exceptions. + ''' + + def __init__(self): + self._handled_exceptions = [] + self._handled_exceptions_types = [] + + def _OnHandledException(self): + ''' + Called when a handled exceptions was found. + ''' + import traceback + from io import StringIO + s = StringIO() + traceback.print_exc(file=s) + self._handled_exceptions_types.append(sys.exc_info()[0]) + self._handled_exceptions.append(s.getvalue()) + + def __enter__(self, *args, **kwargs): + from oop_ext.foundation import handle_exception + handle_exception.on_exception_handled.Register(self._OnHandledException) + return self + + def __exit__(self, *args, **kwargs): + from oop_ext.foundation import handle_exception + handle_exception.on_exception_handled.Unregister(self._OnHandledException) + + def ClearHandledExceptions(self): + ''' + Clears the handled exceptions + ''' + del self._handled_exceptions_types[:] + del self._handled_exceptions[:] + + def GetHandledExceptionTypes(self): + ''' + :return list(type): + Returns a list with the exception types we found. + ''' + return self._handled_exceptions_types + + def GetHandledExceptions(self): + ''' + :return list(str): + Returns a list with the representation of the handled exceptions. + ''' + return self._handled_exceptions + + def RaiseFoundExceptions(self): + ''' + Raises error for the handled exceptions found. + ''' + + def ToString(s): + if not isinstance(s, str): + s = s.decode('utf-8', 'replace') + return s + + if self._handled_exceptions: + raise AssertionError('\n'.join(ToString(i) for i in self._handled_exceptions)) + + +@pytest.yield_fixture(scope="function", autouse=True) +def handled_exceptions(): + ''' + This method will be called for all the functions automatically. + + For users which expect handled exceptions, it's possible to declare the fixture and + say that the errors are expected and clear them later. + + I.e.: + + from oop_ext.foundation.handle_exception import IgnoringHandleException + from oop_ext.foundation import handle_exception + + def testSomething(handled_exceptions): + with IgnoringHandleException(): + try: + raise RuntimeError('test') + except: + handle_exception.HandleException() + + # Check that they're there... + assert len(handled_exceptions.GetHandledExceptions()) == 1 + + # Clear them + handled_exceptions.ClearHandledExceptions() + + Note that test-cases can still deal with this API without using a fixture by importing handled_exceptions + and using it as an object. + + I.e.: + + from oop_ext.fixtures import handled_exceptions + handled_exceptions.GetHandledExceptions() + handled_exceptions.ClearHandledExceptions() + ''' + try: + with _ShowHandledExceptionsError() as show_handled_exceptions_error: + handled_exceptions.ClearHandledExceptions = \ + show_handled_exceptions_error.ClearHandledExceptions + + handled_exceptions.GetHandledExceptions = \ + show_handled_exceptions_error.GetHandledExceptions + + handled_exceptions.GetHandledExceptionTypes = \ + show_handled_exceptions_error.GetHandledExceptionTypes + + yield show_handled_exceptions_error + finally: + handled_exceptions.ClearHandledExceptions = None + handled_exceptions.GetHandledExceptions = None + handled_exceptions.GetHandledExceptionTypes = None + + show_handled_exceptions_error.RaiseFoundExceptions() diff --git a/src/oop_ext/foundation/__init__.py b/src/oop_ext/foundation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/oop_ext/foundation/_tests/__init__.py b/src/oop_ext/foundation/_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/oop_ext/foundation/_tests/test_cached_method.py b/src/oop_ext/foundation/_tests/test_cached_method.py new file mode 100644 index 0000000..1ccb247 --- /dev/null +++ b/src/oop_ext/foundation/_tests/test_cached_method.py @@ -0,0 +1,163 @@ + +import pytest + +from oop_ext.foundation.cached_method import ( + AttributeBasedCachedMethod, CachedMethod, LastResultCachedMethod) + + +def testCacheMethod(_cached_obj): + cache = MyMethod = CachedMethod(_cached_obj.CachedMethod) + + MyMethod(1) + _cached_obj.CheckCounts(cache, method=1, miss=1) + + MyMethod(1) + _cached_obj.CheckCounts(cache, hit=1) + + MyMethod(2) + _cached_obj.CheckCounts(cache, method=1, miss=1) + + MyMethod(2) + _cached_obj.CheckCounts(cache, hit=1) + + # ALL results are stored, so these calls are HITs + MyMethod(1) + _cached_obj.CheckCounts(cache, hit=1) + + MyMethod(2) + _cached_obj.CheckCounts(cache, hit=1) + + +def testCacheMethodEnabled(_cached_obj): + cache = MyMethod = CachedMethod(_cached_obj.CachedMethod) + + MyMethod(1) + _cached_obj.CheckCounts(cache, method=1, miss=1) + + MyMethod(1) + _cached_obj.CheckCounts(cache, hit=1) + + MyMethod.enabled = False + + MyMethod(1) + _cached_obj.CheckCounts(cache, method=1, miss=1) + + MyMethod.enabled = True + + MyMethod(1) + _cached_obj.CheckCounts(cache, hit=1) + + +def testCacheMethodLastResultCachedMethod(_cached_obj): + cache = MyMethod = LastResultCachedMethod(_cached_obj.CachedMethod) + + MyMethod(1) + _cached_obj.CheckCounts(cache, method=1, miss=1) + + MyMethod(1) + _cached_obj.CheckCounts(cache, hit=1) + + MyMethod(2) + _cached_obj.CheckCounts(cache, method=1, miss=1) + + MyMethod(2) + _cached_obj.CheckCounts(cache, hit=1) + + # Only the LAST result is stored, so this call is a MISS. + MyMethod(1) + _cached_obj.CheckCounts(cache, method=1, miss=1) + + +def testCacheMethodObjectInKey(_cached_obj): + cache = MyMethod = CachedMethod(_cached_obj.CachedMethod) + + class MyObject: + + def __init__(self): + self.name = 'alpha' + self.id = 1 + + def __str__(self): + return '%s %d' % (self.name, self.id) + + alpha = MyObject() + + MyMethod(alpha) + _cached_obj.CheckCounts(cache, method=1, miss=1) + + MyMethod(alpha) + _cached_obj.CheckCounts(cache, hit=1) + + alpha.name = 'bravo' + alpha.id = 2 + + MyMethod(alpha) + _cached_obj.CheckCounts(cache, method=1, miss=1) + + +def testCacheMethodAttributeBasedCachedMethod(): + + class TestObject: + + def __init__(self): + self.name = 'alpha' + self.id = 1 + self.n_calls = 0 + + def Foo(self, par): + self.n_calls += 1 + return '%s %d' % (par, self.id) + + alpha = TestObject() + alpha.Foo = AttributeBasedCachedMethod(alpha.Foo, 'id', cache_size=3) + alpha.Foo('test1') + alpha.Foo('test1') + + assert alpha.n_calls == 1 + + alpha.Foo('test2') + assert alpha.n_calls == 2 + assert len(alpha.Foo._results) == 2 + + alpha.id = 3 + alpha.Foo('test2') + assert alpha.n_calls == 3 + + assert len(alpha.Foo._results) == 3 + + alpha.Foo('test3') + assert alpha.n_calls == 4 + assert len(alpha.Foo._results) == 3 + + +@pytest.fixture +def _cached_obj(): + ''' + A test_object common to many cached_method tests. + ''' + + class TestObj: + + def __init__(self): + self.method_count = 0 + + def CachedMethod(self, *args, **kwargs): + self.method_count += 1 + return self.method_count + + def CheckCounts(self, cache, method=0, miss=0, hit=0): + + if not hasattr(cache, 'check_counts'): + cache.check_counts = dict(method=0, miss=0, hit=0, call=0) + + cache.check_counts['method'] += method + cache.check_counts['miss'] += miss + cache.check_counts['hit'] += hit + cache.check_counts['call'] += (miss + hit) + + assert self.method_count == cache.check_counts['method'] + assert cache.miss_count == cache.check_counts['miss'] + assert cache.hit_count == cache.check_counts['hit'] + assert cache.call_count == cache.check_counts['call'] + + return TestObj() diff --git a/src/oop_ext/foundation/_tests/test_decorators.py b/src/oop_ext/foundation/_tests/test_decorators.py new file mode 100644 index 0000000..de86ebf --- /dev/null +++ b/src/oop_ext/foundation/_tests/test_decorators.py @@ -0,0 +1,152 @@ + +import warnings + +import pytest + +from oop_ext.foundation import is_frozen +from oop_ext.foundation.decorators import Abstract, Deprecated, Implements, Override + + +def testImplements(): + with pytest.raises(AssertionError): + + class IFoo: + + def DoIt(self): + '' + + class Implementation: + + @Implements(IFoo.DoIt) + def DoNotDoIt(self): + '' + + class IFoo: + + def Foo(self): + ''' + docstring + ''' + + class Impl: + + @Implements(IFoo.Foo) + def Foo(self): + return self.__class__.__name__ + + assert IFoo.Foo.__doc__ == Impl.Foo.__doc__ + + # Just for 100% coverage. + assert Impl().Foo() == 'Impl' + + +def testOverride(): + + def TestOK(): + + class A: + + def Method(self): + ''' + docstring + ''' + + class B(A): + + @Override(A.Method) + def Method(self): + return 2 + + b = B() + assert b.Method() == 2 + assert A.Method.__doc__ == B.Method.__doc__ + + def TestERROR(): + + class A: + + def MyMethod(self): + '' + + class B(A): + + @Override(A.Method) # it will raise an error at this point + def Method(self): + '' + + def TestNoMatch(): + + class A: + + def Method(self): + '' + + class B(A): + + @Override(A.Method) + def MethodNoMatch(self): + '' + + TestOK() + with pytest.raises(AttributeError): + TestERROR() + + with pytest.raises(AssertionError): + TestNoMatch() + + +def testDeprecated(monkeypatch): + + def MyWarn(*args, **kwargs): + warn_params.append((args, kwargs)) + + monkeypatch.setattr(warnings, 'warn', MyWarn) + + was_development = is_frozen.SetIsDevelopment(True) + try: + # Emit messages when in development + warn_params = [] + + # ... deprecation with alternative + @Deprecated('OtherMethod') + def Method1(): + pass + + # ... deprecation without alternative + @Deprecated() + def Method2(): + pass + + Method1() + Method2() + assert warn_params == [ + (("DEPRECATED: 'Method1' is deprecated, use 'OtherMethod' instead",), {'stacklevel': 2}), + (("DEPRECATED: 'Method2' is deprecated",), {'stacklevel': 2}) + ] + + # No messages on release code + is_frozen.SetIsDevelopment(False) + + warn_params = [] + + @Deprecated() + def FrozenMethod(): + pass + + FrozenMethod() + assert warn_params == [] + finally: + is_frozen.SetIsDevelopment(was_development) + + +def testAbstract(): + + class Alpha: + + @Abstract + def Method(self): + '' + + alpha = Alpha() + with pytest.raises(NotImplementedError): + alpha.Method() diff --git a/src/oop_ext/foundation/_tests/test_immutable.py b/src/oop_ext/foundation/_tests/test_immutable.py new file mode 100644 index 0000000..43adb69 --- /dev/null +++ b/src/oop_ext/foundation/_tests/test_immutable.py @@ -0,0 +1,109 @@ +from copy import copy, deepcopy + +import pytest + +from oop_ext.foundation.immutable import AsImmutable, IdentityHashableRef, ImmutableDict + + +def testImmutable(): + + class MyClass: + pass + + d = AsImmutable(dict(a=1, b=dict(b=2))) + assert d == {'a': 1, 'b': {'b': 2}} + with pytest.raises(NotImplementedError): + d.__setitem__('a', 2) + + assert d['b'].AsMutable() == dict(b=2) + AsImmutable(d, return_str_if_not_expected=False) + d = d.AsMutable() + d['a'] = 2 + + c = deepcopy(d) + assert c == d + + c = copy(d) + assert c == d + assert AsImmutable({1, 2, 3}) == {1, 2, 3} + assert AsImmutable(([1, 2], [2, 3])) == ((1, 2), (2, 3)) + assert AsImmutable(None) is None + assert isinstance(AsImmutable({1, 2, 4}), frozenset) + assert isinstance(AsImmutable(frozenset([1, 2, 4])), frozenset) + assert isinstance(AsImmutable([1, 2, 4]), tuple) + assert isinstance(AsImmutable((1, 2, 4)), tuple) + + # Primitive non-container types + def AssertIsSame(value): + assert AsImmutable(value) is value + + AssertIsSame(True) + AssertIsSame(1.) + AssertIsSame(1) + AssertIsSame('a') # native string + AssertIsSame(b'b') # native bytes + AssertIsSame(str('a')) # future's str compatibility type + AssertIsSame(bytes(b'b')) # future's byte compatibility type + + # Dealing with derived values + a = MyClass() + assert AsImmutable(a, return_str_if_not_expected=True) == str(a) + with pytest.raises(RuntimeError): + AsImmutable(a, return_str_if_not_expected=False) + + # Derived basics + class MyStr(str): + pass + + assert AsImmutable(MyStr('alpha')) == 'alpha' + + class MyList(list): + pass + + assert AsImmutable(MyList()) == () + + class MySet(set): + pass + + assert AsImmutable(MySet()) == frozenset() + + +def testImmutableDict(): + d = ImmutableDict(alpha=1, bravo=2) + + with pytest.raises(NotImplementedError): + d['charlie'] = 3 + + with pytest.raises(NotImplementedError): + del d['alpha'] + + with pytest.raises(NotImplementedError): + d.clear() + + with pytest.raises(NotImplementedError): + d.setdefault('charlie', 3) + + with pytest.raises(NotImplementedError): + d.popitem() + + with pytest.raises(NotImplementedError): + d.update({'charlie':3}) + + +def testIdentityHashableRef(): + a = {1: 2} + b = {1: 2} + + assert IdentityHashableRef(a)() is a + assert a == b + assert IdentityHashableRef(a) != IdentityHashableRef(b) + assert IdentityHashableRef(a) == IdentityHashableRef(a) + + set_a = {IdentityHashableRef(a)} + assert IdentityHashableRef(a) in set_a + assert IdentityHashableRef(b) not in set_a + + dict_b = {IdentityHashableRef(b): 7} + assert IdentityHashableRef(a) not in dict_b + assert IdentityHashableRef(b) in dict_b + assert dict_b[IdentityHashableRef(b)] == 7 diff --git a/src/oop_ext/foundation/_tests/test_is_frozen.py b/src/oop_ext/foundation/_tests/test_is_frozen.py new file mode 100644 index 0000000..2657cf2 --- /dev/null +++ b/src/oop_ext/foundation/_tests/test_is_frozen.py @@ -0,0 +1,29 @@ + +from oop_ext.foundation.is_frozen import IsDevelopment, IsFrozen, SetIsDevelopment, SetIsFrozen + + +def testIsFrozenIsDevelopment(): + # Note: this test is checking if we're always running tests while not in frozen mode, + # still, we have to do a try..finally to make sure we restore things to the proper state. + was_frozen = IsFrozen() + try: + assert IsFrozen() == False + assert IsDevelopment() == True + + SetIsDevelopment(False) + assert IsFrozen() == False + assert IsDevelopment() == False + + SetIsDevelopment(True) + assert IsFrozen() == False + assert IsDevelopment() == True + + SetIsFrozen(True) + assert IsFrozen() == True + assert IsDevelopment() == True + + SetIsFrozen(False) + assert IsFrozen() == False + assert IsDevelopment() == True + finally: + SetIsFrozen(was_frozen) diff --git a/src/oop_ext/foundation/_tests/test_odict.py b/src/oop_ext/foundation/_tests/test_odict.py new file mode 100644 index 0000000..b5fd684 --- /dev/null +++ b/src/oop_ext/foundation/_tests/test_odict.py @@ -0,0 +1,31 @@ + +from oop_ext.foundation.odict import odict + + +def testInsert(): + d = odict() + d[1] = 'alpha' + d[3] = 'charlie' + + assert list(d.items()) == [(1, 'alpha'), (3, 'charlie')] + + d.insert(0, 0, 'ZERO') + assert list(d.items()) == [(0, 'ZERO'), (1, 'alpha'), (3, 'charlie')] + + d.insert(2, 2, 'bravo') + assert list(d.items()) == [(0, 'ZERO'), (1, 'alpha'), (2, 'bravo'), (3, 'charlie')] + + d.insert(99, 4, 'echo') + assert list(d.items()) == [(0, 'ZERO'), (1, 'alpha'), (2, 'bravo'), (3, 'charlie'), (4, 'echo')] + + +def testDelWithSlices(): + d = odict() + d[1] = 1 + d[2] = 2 + d[3] = 3 + + del d[1:] + + assert len(d) == 1 + assert d[1] == 1 diff --git a/src/oop_ext/foundation/_tests/test_singleton.py b/src/oop_ext/foundation/_tests/test_singleton.py new file mode 100644 index 0000000..3d91103 --- /dev/null +++ b/src/oop_ext/foundation/_tests/test_singleton.py @@ -0,0 +1,155 @@ + +import pytest + +from oop_ext.foundation.callback import After +from oop_ext.foundation.decorators import Override +from oop_ext.foundation.singleton import ( + PushPopSingletonError, Singleton, SingletonAlreadySetError, SingletonNotSetError) + + +def CheckCurrentSingleton(singleton_class, value): + singleton = singleton_class.GetSingleton() + assert singleton.value == value + + +def testSingleton(): + + class MySingleton(Singleton): + + def __init__(self, value): + self.value = value + + @classmethod + @Override(Singleton.CreateDefaultSingleton) + def CreateDefaultSingleton(cls): + return MySingleton(value=0) + + # Default singleton (created automatically and also put in the stack) + CheckCurrentSingleton(MySingleton, 0) + default_singleton = MySingleton.GetSingleton() + default_singleton.value = 10 + + # SetSingleton must be called only when there is no singleton set. In this case, + # GetSingleton already set the singleton. + with pytest.raises(SingletonAlreadySetError): + MySingleton.SetSingleton(MySingleton(value=999)) + CheckCurrentSingleton(MySingleton, 10) + + # push a new instance and test it + MySingleton.PushSingleton(MySingleton(2000)) + CheckCurrentSingleton(MySingleton, 2000) + + # Calling SetSingleton after using Push/Pop is an error: we do this so that + # in tests we know someone is doing a SetSingleton when they shouldn't + with pytest.raises(PushPopSingletonError): + MySingleton.SetSingleton(MySingleton(value=10)) + + # pop, returns to the initial + MySingleton.PopSingleton() + CheckCurrentSingleton(MySingleton, 10) + + # SetSingleton given SingletonAlreadySet when outside Push/Pop + with pytest.raises(SingletonAlreadySetError): + MySingleton.SetSingleton(MySingleton(value=999)) + CheckCurrentSingleton(MySingleton, 10) + + # The singleton set with "SetSingleton" or created automatically by "GetSingleton" is not + # part of the stack + with pytest.raises(PushPopSingletonError): + MySingleton.PopSingleton() + + +def testSetSingleton(): + + class MySingleton(Singleton): + + def __init__(self, value=None): + self.value = value + + assert not MySingleton.HasSingleton() + + MySingleton.SetSingleton(MySingleton(value=999)) + assert MySingleton.HasSingleton() + CheckCurrentSingleton(MySingleton, 999) + + with pytest.raises(SingletonAlreadySetError): + MySingleton.SetSingleton(MySingleton(value=999)) + + MySingleton.ClearSingleton() + assert not MySingleton.HasSingleton() + + with pytest.raises(SingletonNotSetError): + MySingleton.ClearSingleton() + + +def testPushPop(): + + class MySingleton(Singleton): + + def __init__(self, value=None): + self.value = value + + MySingleton.PushSingleton() + + assert MySingleton.GetStackCount() == 1 + + with pytest.raises(PushPopSingletonError): + MySingleton.ClearSingleton() + + MySingleton.PushSingleton() + assert MySingleton.GetStackCount() == 2 + + MySingleton.PopSingleton() + assert MySingleton.GetStackCount() == 1 + + MySingleton.PopSingleton() + assert MySingleton.GetStackCount() == 0 + + with pytest.raises(PushPopSingletonError): + MySingleton.PopSingleton() + + +def testSingletonOptimization(): + + class MySingleton(Singleton): + pass + + class MockClass: + called = False + + def ObtainStack(self, *args, **kwargs): + self.called = True + + obj = MockClass() + After(MySingleton._ObtainStack, obj.ObtainStack) + + obj.called = False + MySingleton.GetSingleton() + assert obj.called + + obj.called = False + MySingleton.GetSingleton() + assert not obj.called + + +def testGetSingletonThreadSafe(mocker): + from threading import Event, Thread + + class MySingleton(Singleton): + + @classmethod + def SlowConstructor(cls, event): + event.wait(1) + return MySingleton() + + thrlist = [Thread(target=MySingleton.GetSingleton) for _ in range(3)] + create_singleton_mock = mocker.patch.object(MySingleton, "CreateDefaultSingleton") + + event = Event() + create_singleton_mock.side_effect = lambda: MySingleton.SlowConstructor(event) + for thread in thrlist: + thread.start() + event.set() + for thread in thrlist: + thread.join() + assert create_singleton_mock.call_count == 1 diff --git a/src/oop_ext/foundation/_tests/test_types.py b/src/oop_ext/foundation/_tests/test_types.py new file mode 100644 index 0000000..f092c47 --- /dev/null +++ b/src/oop_ext/foundation/_tests/test_types.py @@ -0,0 +1,60 @@ + +import copy + +from oop_ext.foundation.types_ import Null + + +def testNull(): + # constructing and calling + + dummy = Null() + dummy = Null('value') + n = Null('value', param='value') + + n() + n('value') + n('value', param='value') + + # attribute handling + n.attr1 + n.attr1.attr2 + n.method1() + n.method1().method2() + n.method('value') + n.method(param='value') + n.method('value', param='value') + n.attr1.method1() + n.method1().attr1 + + n.attr1 = 'value' + n.attr1.attr2 = 'value' + + del n.attr1 + del n.attr1.attr2.attr3 + + # Iteration + for _ in n: + 'Not executed' + + # representation and conversion to a string + assert repr(n) == '' + assert str(n) == 'Null' + + # truth value + assert bool(n) == False + assert bool(n.foo()) == False + + dummy = Null() + # context manager + with dummy: + assert dummy.__name__ == 'Null' # Name should return a str + + # Null objects are always equal to other null object + assert n != 1 + assert n == dummy + + +def testNullCopy(): + n = Null() + n1 = copy.copy(n) + assert str(n) == str(n1) diff --git a/src/oop_ext/foundation/_tests/test_weak_ref.py b/src/oop_ext/foundation/_tests/test_weak_ref.py new file mode 100644 index 0000000..d6f88e0 --- /dev/null +++ b/src/oop_ext/foundation/_tests/test_weak_ref.py @@ -0,0 +1,532 @@ + +import sys +import weakref + +import pytest + +from oop_ext.foundation.weak_ref import ( + GetRealObj, GetWeakProxy, GetWeakRef, IsSame, IsWeakProxy, IsWeakRef, WeakList, WeakMethodProxy, + WeakMethodRef, WeakSet) + + +#=================================================================================================== +# _Stub +#=================================================================================================== +class _Stub: + + def __hash__(self): + return 1 + + def __eq__(self, o): + return True # always equal + + def __ne__(self, o): + return not self == o + + def Method(self): + pass + + +class Obj: + + def __init__(self, name): + self.name = name + + def __repr__(self): + return self.name + + +def testStub(): + a = _Stub() + b = _Stub() + assert not a != a + assert not a != b + assert a == a + assert a == b + a.Method() + + +def testIsSame(): + s1 = _Stub() + s2 = _Stub() + + r1 = weakref.ref(s1) + r2 = weakref.ref(s2) + + p1 = weakref.proxy(s1) + p2 = weakref.proxy(s2) + + assert IsSame(s1, s1) + assert not IsSame(s1, s2) + + assert IsSame(s1, r1) + assert IsSame(s1, p1) + + assert not IsSame(s1, r2) + assert not IsSame(s1, p2) + + assert IsSame(p2, r2) + assert IsSame(r1, p1) + assert not IsSame(r1, p2) + + with pytest.raises(ReferenceError): + IsSame(p1, p2) + + +def testGetWeakRef(): + b = GetWeakRef(None) + assert b() is None + + +def testGeneral(): + b = _Stub() + r = GetWeakRef(b.Method) + assert r() is not None # should not be a regular weak ref here (but a weak method ref) + + assert IsWeakRef(r) + assert not IsWeakProxy(r) + + r = GetWeakProxy(b.Method) + r() + assert IsWeakProxy(r) + assert not IsWeakRef(r) + + r = weakref.ref(b) + b2 = _Stub() + r2 = weakref.ref(b2) + assert r == r2 + assert hash(r) == hash(r2) + + r = GetWeakRef(b.Method) + r2 = GetWeakRef(b.Method) + assert r == r2 + assert hash(r) == hash(r2) + + +def testGetRealObj(): + b = _Stub() + r = GetWeakRef(b) + assert GetRealObj(r) is b + + r = GetWeakRef(None) + assert GetRealObj(r) is None + + +def testGetWeakProxyFromWeakRef(): + b = _Stub() + r = GetWeakRef(b) + proxy = GetWeakProxy(r) + assert IsWeakProxy(proxy) + + +def testWeakSet(): + weak_set = WeakSet() + s1 = _Stub() + s2 = _Stub() + + weak_set.add(s1) + assert isinstance(next(iter(weak_set)), _Stub) + + assert s1 in weak_set + CustomAssertEqual(len(weak_set), 1) + del s1 + CustomAssertEqual(len(weak_set), 0) + + weak_set.add(s2) + CustomAssertEqual(len(weak_set), 1) + weak_set.remove(s2) + CustomAssertEqual(len(weak_set), 0) + + weak_set.add(s2) + weak_set.clear() + CustomAssertEqual(len(weak_set), 0) + + weak_set.add(s2) + weak_set.add(s2) + weak_set.add(s2) + CustomAssertEqual(len(weak_set), 1) + del s2 + CustomAssertEqual(len(weak_set), 0) + + # >>> Testing with FUNCTION + + # Adding twice, having one + def function(): + pass + + weak_set.add(function) + weak_set.add(function) + CustomAssertEqual(len(weak_set), 1) + + +def testRemove(): + weak_set = WeakSet() + + s1 = _Stub() + + CustomAssertEqual(len(weak_set), 0) + + # Trying remove, raises KeyError + with pytest.raises(KeyError): + weak_set.remove(s1) + CustomAssertEqual(len(weak_set), 0) + + # Trying discard, no exception raised + weak_set.discard(s1) + CustomAssertEqual(len(weak_set), 0) + + +def testWeakSet2(): + weak_set = WeakSet() + + # >>> Removing with DEL + s1 = _Stub() + weak_set.add(s1.Method) + CustomAssertEqual(len(weak_set), 1) + del s1 + CustomAssertEqual(len(weak_set), 0) + + # >>> Removing with REMOVE + s2 = _Stub() + weak_set.add(s2.Method) + CustomAssertEqual(len(weak_set), 1) + weak_set.remove(s2.Method) + CustomAssertEqual(len(weak_set), 0) + + +def testWeakSetUnionWithWeakSet(): + ws1, ws2 = WeakSet(), WeakSet() + a, b, c = Obj('a'), Obj('b'), Obj('c') + + ws1.add(a) + ws1.add(b) + + ws2.add(a) + ws2.add(c) + + ws3 = ws1.union(ws2) + assert set(ws3) == set(ws2.union(ws1)) == {a, b, c} + + del c + assert set(ws3) == set(ws2.union(ws1)) == {a, b} + + +def testWeakSetUnionWithSet(): + ws = WeakSet() + a, b, c = Obj('a'), Obj('b'), Obj('c') + + ws.add(a) + ws.add(b) + + s = {a, c} + + ws3 = ws.union(s) + assert set(ws3) == set(s.union(set(ws))) == {a, b, c} + + del b + assert set(ws3) == set(s.union(set(ws))) == {a, c} + + +def testWeakSetSubWithWeakSet(): + ws1, ws2 = WeakSet(), WeakSet() + a, b, c = Obj('a'), Obj('b'), Obj('c') + + ws1.add(a) + ws1.add(b) + + ws2.add(a) + ws2.add(c) + assert set(ws1 - ws2) == {b} + assert set(ws2 - ws1) == {c} + + del c + assert set(ws1 - ws2) == {b} + assert set(ws2 - ws1) == set() + + +def testWeakSetSubWithSet(): + ws = WeakSet() + s = set() + a, b, c = Obj('a'), Obj('b'), Obj('c') + + ws.add(a) + ws.add(b) + + s.add(a) + s.add(c) + + assert set(ws - s) == {b} + assert s - ws == {c} + + del b + assert set(ws - s) == set() + assert s - ws == {c} + + +def testWithError(): + weak_set = WeakSet() + + # Not WITH, everything ok + s1 = _Stub() + weak_set.add(s1.Method) + CustomAssertEqual(len(weak_set), 1) + del s1 + CustomAssertEqual(len(weak_set), 0) + + # Using WITH, s2 is not deleted from weak_set + s2 = _Stub() + with pytest.raises(KeyError): + raise KeyError('key') + CustomAssertEqual(len(weak_set), 0) + + weak_set.add(s2.Method) + CustomAssertEqual(len(weak_set), 1) + del s2 + CustomAssertEqual(len(weak_set), 0) + + +def testFunction(): + weak_set = WeakSet() + + def function(): + 'Never called' + + # Adding twice, having one. + weak_set.add(function) + weak_set.add(function) + CustomAssertEqual(len(weak_set), 1) + + # Removing function + weak_set.remove(function) + assert len(weak_set) == 0 + + +def CustomAssertEqual(a, b): + ''' + Avoiding using "assert a == b" because it adds another reference to the ref-count. + ''' + if a == b: + pass + else: + assert False, "{} != {}".format(a, b) + + +def SetupTestAttributes(): + + class C: + + def f(self, y=0): + return self.x + y + + class D: + + def f(self): + 'Never called' + + c = C() + c.x = 1 + d = D() + + return (C, c, d) + + +def testCustomAssertEqual(): + with pytest.raises(AssertionError) as excinfo: + CustomAssertEqual(1, 2) + + assert str(excinfo.value) == '1 != 2\nassert False' + + +def testRefcount(): + _, c, _ = SetupTestAttributes() + + CustomAssertEqual(sys.getrefcount(c), 2) # 2: one in self, and one as argument to getrefcount() + cf = c.f + CustomAssertEqual(sys.getrefcount(c), 3) # 3: as above, plus cf + rf = WeakMethodRef(c.f) + pf = WeakMethodProxy(c.f) + CustomAssertEqual(sys.getrefcount(c), 3) + del cf + CustomAssertEqual(sys.getrefcount(c), 2) + rf2 = WeakMethodRef(c.f) + CustomAssertEqual(sys.getrefcount(c), 2) + del rf + del rf2 + del pf + CustomAssertEqual(sys.getrefcount(c), 2) + + +def testDies(): + _, c, _ = SetupTestAttributes() + + rf = WeakMethodRef(c.f) + pf = WeakMethodProxy(c.f) + assert not rf.is_dead() + assert not pf.is_dead() + assert rf()() == 1 + assert pf(2) == 3 + c = None + assert rf.is_dead() + assert pf.is_dead() + assert rf() == None + with pytest.raises(ReferenceError): + pf() + + +def testWorksWithFunctions(): + SetupTestAttributes() + + def foo(y): + return y + 1 + + rf = WeakMethodRef(foo) + pf = WeakMethodProxy(foo) + assert foo(1) == 2 + assert rf()(1) == 2 + assert pf(1) == 2 + assert not rf.is_dead() + assert not pf.is_dead() + + +def testWorksWithUnboundMethods(): + C, c, _ = SetupTestAttributes() + + meth = C.f + rf = WeakMethodRef(meth) + pf = WeakMethodProxy(meth) + assert meth(c) == 1 + assert rf()(c) == 1 + assert pf(c) == 1 + assert not rf.is_dead() + assert not pf.is_dead() + + +def testEq(): + _, c, d = SetupTestAttributes() + + rf1 = WeakMethodRef(c.f) + rf2 = WeakMethodRef(c.f) + assert rf1 == rf2 + rf3 = WeakMethodRef(d.f) + assert rf1 != rf3 + del c + assert rf1.is_dead() + assert rf2.is_dead() + assert rf1 == rf2 + + +def testProxyEq(): + _, c, d = SetupTestAttributes() + + pf1 = WeakMethodProxy(c.f) + pf2 = WeakMethodProxy(c.f) + pf3 = WeakMethodProxy(d.f) + assert pf1 == pf2 + assert pf3 != pf2 + del c + assert pf1 == pf2 + assert pf1.is_dead() + assert pf2.is_dead() + + +def testHash(): + _, c, _ = SetupTestAttributes() + + r = WeakMethodRef(c.f) + r2 = WeakMethodRef(c.f) + assert r == r2 + h = hash(r) + assert hash(r) == hash(r2) + del c + assert r() is None + assert hash(r) == h + + +def testRepr(): + _, c, _ = SetupTestAttributes() + + r = WeakMethodRef(c.f) + assert str(r)[:33] == '' + + +def testWeakRefToWeakMethodRef(): + + def Foo(): + 'Never called' + + r = WeakMethodRef(Foo) + m_ref = weakref.ref(r) + assert m_ref() is r + + +def testWeakList(): + weak_list = WeakList() + s1 = _Stub() + s2 = _Stub() + + weak_list.append(s1) + assert isinstance(weak_list[0], _Stub) + + assert s1 in weak_list + assert 1 == len(weak_list) + del s1 + assert 0 == len(weak_list) + + weak_list.append(s2) + assert 1 == len(weak_list) + weak_list.remove(s2) + assert 0 == len(weak_list) + + weak_list.append(s2) + del weak_list[:] + assert 0 == len(weak_list) + + weak_list.append(s2) + del s2 + del weak_list[:] + assert 0 == len(weak_list) + + s1 = _Stub() + weak_list.append(s1) + assert 1 == len(weak_list[:]) + + del s1 + + assert 0 == len(weak_list[:]) + + def m1(): + 'Never called' + + weak_list.append(m1) + assert 1 == len(weak_list[:]) + del m1 + assert 0 == len(weak_list[:]) + + s = _Stub() + weak_list.append(s.Method) + assert 1 == len(weak_list[:]) + s = weakref.ref(s) + assert 0 == len(weak_list[:]) + assert s() is None + + s0 = _Stub() + s1 = _Stub() + weak_list.extend([s0, s1]) + assert len(weak_list) == 2 + + +def testSetItem(): + weak_list = WeakList() + s1 = _Stub() + s2 = _Stub() + weak_list.append(s1) + weak_list.append(s1) + assert s1 == weak_list[0] + weak_list[0] = s2 + assert s2 == weak_list[0] diff --git a/src/oop_ext/foundation/cached_method.py b/src/oop_ext/foundation/cached_method.py new file mode 100644 index 0000000..186a39d --- /dev/null +++ b/src/oop_ext/foundation/cached_method.py @@ -0,0 +1,185 @@ + +from .immutable import AsImmutable +from .odict import odict +from .types_ import Method +from .weak_ref import WeakMethodRef + + +#=================================================================================================== +# AbstractCachedMethod +#=================================================================================================== +class AbstractCachedMethod(Method): + ''' + Base class for cache-manager. + The abstract class does not implement the storage of results. + ''' + + def __init__(self, cached_method=None): + # REMARKS: Use WeakMethodRef to avoid cyclic reference. + self._method = WeakMethodRef(cached_method) + self.enabled = True + self.ResetCounters() + + def __call__(self, *args, **kwargs): + key = self.GetCacheKey(*args, **kwargs) + result = None + + if self.enabled and self._HasResult(key): + self.hit_count += 1 + result = self._GetCacheResult(key, result) + else: + self.miss_count += 1 + result = self._CallMethod(*args, **kwargs) + self._AddCacheResult(key, result) + + self.call_count += 1 + return result + + def _CallMethod(self, *args, **kwargs): + return self._method()(*args, **kwargs) + + def GetCacheKey(self, *args, **kwargs): + ''' + Use the arguments to build the cache-key. + ''' + if args: + if kwargs: + return AsImmutable(args), AsImmutable(kwargs) + + return AsImmutable(args) + + if kwargs: + return AsImmutable(kwargs) + + def _HasResult(self, key): + raise NotImplementedError() + + def _AddCacheResult(self, key, result): + raise NotImplementedError() + + def DoClear(self): + raise NotImplementedError() + + def Clear(self): + self.DoClear() + self.ResetCounters() + + def ResetCounters(self): + self.call_count = 0 + self.hit_count = 0 + self.miss_count = 0 + + def _GetCacheResult(self, key, result): + raise NotImplementedError() + + +#=================================================================================================== +# CachedMethod +#=================================================================================================== +class CachedMethod(AbstractCachedMethod): + ''' + Stores ALL the different results and never delete them. + ''' + + def __init__(self, cached_method=None): + super().__init__(cached_method) + self._results = {} + + def _HasResult(self, key): + return key in self._results + + def _AddCacheResult(self, key, result): + self._results[key] = result + + def DoClear(self): + self._results.clear() + + def _GetCacheResult(self, key, result): + return self._results[key] + + +#=================================================================================================== +# ImmutableParamsCachedMethod +#=================================================================================================== +class ImmutableParamsCachedMethod(CachedMethod): + ''' + Expects all parameters to already be immutable + Considers only the positional parameters of key, ignoring the keyword arguments + ''' + + def GetCacheKey(self, *args, **kwargs): + ''' + Use the arguments to build the cache-key. + ''' + return args + + +#=================================================================================================== +# LastResultCachedMethod +#=================================================================================================== +class LastResultCachedMethod(AbstractCachedMethod): + ''' + A cache that stores only the last result. + ''' + + def __init__(self, cached_method=None): + super().__init__(cached_method) + self._key = None + self._result = None + + def _HasResult(self, key): + return self._key == key + + def _AddCacheResult(self, key, result): + self._key = key + self._result = result + + def DoClear(self): + self._key = None + self._result = None + + def _GetCacheResult(self, key, result): + return self._result + + +#=================================================================================================== +# AttributeBasedCachedMethod +#=================================================================================================== +class AttributeBasedCachedMethod(CachedMethod): + ''' + This cached method consider changes in object attributes + ''' + + def __init__(self, cached_method, attr_name_list, cache_size=1, results=None): + ''' + :type cached_method: bound method to be cached + :param cached_method: + :type attr_name_list: attr names in a C{str} separated by spaces OR in a sequence of C{str} + :param attr_name_list: + :type cache_size: the cache size + :param cache_size: + :type results: an optional ref. to an C{odict} for keep cache results + :param results: + ''' + CachedMethod.__init__(self, cached_method) + if isinstance(attr_name_list, str): + self._attr_name_list = attr_name_list.split() + else: + self._attr_name_list = attr_name_list + self._cache_size = cache_size + if results is None: + self._results = odict() + else: + self._results = results + + def GetCacheKey(self, *args, **kwargs): + instance = self._method().__self__ + for attr_name in self._attr_name_list: + kwargs['_object_%s' % attr_name] = getattr(instance, attr_name) + return AbstractCachedMethod.GetCacheKey(self, *args, **kwargs) + + def _AddCacheResult(self, key, result): + CachedMethod._AddCacheResult(self, key, result) + if len(self._results) > self._cache_size: + key0 = next(iter(self._results)) + del self._results[key0] diff --git a/src/oop_ext/foundation/callback/__init__.py b/src/oop_ext/foundation/callback/__init__.py new file mode 100644 index 0000000..2e6c371 --- /dev/null +++ b/src/oop_ext/foundation/callback/__init__.py @@ -0,0 +1,18 @@ +from ._callback import ErrorNotHandledInCallback, FunctionNotRegisteredError, HandleErrorOnCallback +from ._callbacks import Callbacks +from ._fast_callback import Callback +from ._priority_callback import PriorityCallback +from ._shortcuts import After, Before, Remove, WrapForCallback + +__all__ = [ + 'ErrorNotHandledInCallback', + 'FunctionNotRegisteredError', + 'HandleErrorOnCallback', + 'Callbacks', + 'Callback', + 'PriorityCallback', + 'After', + 'Before', + 'Remove', + 'WrapForCallback', +] diff --git a/src/oop_ext/foundation/callback/_callback.py b/src/oop_ext/foundation/callback/_callback.py new file mode 100644 index 0000000..916b585 --- /dev/null +++ b/src/oop_ext/foundation/callback/_callback.py @@ -0,0 +1,54 @@ + + +#=================================================================================================== +# FunctionNotRegisteredError +#=================================================================================================== +class FunctionNotRegisteredError(RuntimeError): + pass + + +#=================================================================================================== +# ErrorNotHandledInCallback +#=================================================================================================== +class ErrorNotHandledInCallback(RuntimeError): + ''' + This class identifies an error that should not be handled in the callback. + ''' + + +#=================================================================================================== +# HandleErrorOnCallback +#=================================================================================================== +def HandleErrorOnCallback(func, *args, **kwargs): + ''' + Called when there's some error calling a callback. + + :param object func: + The callback called. + + :param list args: + The arguments passed to the callback. + + :param dict kwargs: + The keyword arguments passed to the callback. + ''' + if hasattr(func, 'func_code'): + name, filename, line = ( + func.__code__.co_name, + func.__code__.co_filename, + func.__code__.co_firstlineno + ) + # Use default python trace format so that we have linking on pydev. + func = '\n File "{}", line {}, in {} (Called from Callback)\n'.format(filename, line, name) + else: + # That's ok, it may be that it's not really a method. + func = '{}\n'.format(repr(func)) + + msg = 'Error while trying to call {}'.format(func) + if args: + msg += 'Args: {}\n'.format(args) + if kwargs: + msg += 'Kwargs: {}\n'.format(kwargs) + + from oop_ext.foundation import handle_exception + handle_exception.HandleException(msg) diff --git a/src/oop_ext/foundation/callback/_callback_wrapper.py b/src/oop_ext/foundation/callback/_callback_wrapper.py new file mode 100644 index 0000000..08af416 --- /dev/null +++ b/src/oop_ext/foundation/callback/_callback_wrapper.py @@ -0,0 +1,21 @@ + +from oop_ext.foundation.types_ import Method + + +#=================================================================================================== +# _CallbackWrapper +#=================================================================================================== +class _CallbackWrapper(Method): + + def __init__(self, weak_method_callback): + self.weak_method_callback = weak_method_callback + + # Maintaining the OriginalMethod() interface that clients expect. + self.OriginalMethod = weak_method_callback + + def __call__(self, sender, *args, **kwargs): + c = self.weak_method_callback() + if c is None: + raise ReferenceError('This should never happen: The sender already died, so, '\ + 'how can this method still be called?') + c(sender(), *args, **kwargs) diff --git a/src/oop_ext/foundation/callback/_callbacks.py b/src/oop_ext/foundation/callback/_callbacks.py new file mode 100644 index 0000000..97b624d --- /dev/null +++ b/src/oop_ext/foundation/callback/_callbacks.py @@ -0,0 +1,34 @@ + +from ._shortcuts import After, Before, Remove + + +#=================================================================================================== +# Callbacks +#=================================================================================================== +class Callbacks: + ''' + Holds created callbacks, making it easy to disconnect later. + + Note: keeps a strong reference to the callback and the sender, thus, they won't be garbage- + collected while still connected in this case. + ''' + + def __init__(self): + self._callbacks = [] + + def Before(self, sender, *callbacks, **kwargs): + sender = Before(sender, *callbacks, **kwargs) + for callback in callbacks: + self._callbacks.append((sender, callback)) + return sender + + def After(self, sender, *callbacks, **kwargs): + sender = After(sender, *callbacks, **kwargs) + for callback in callbacks: + self._callbacks.append((sender, callback)) + return sender + + def RemoveAll(self): + for sender, callback in self._callbacks: + Remove(sender, callback) + self._callbacks[:] = [] diff --git a/src/oop_ext/foundation/callback/_fast_callback.py b/src/oop_ext/foundation/callback/_fast_callback.py new file mode 100644 index 0000000..0f7e2bb --- /dev/null +++ b/src/oop_ext/foundation/callback/_fast_callback.py @@ -0,0 +1,401 @@ + +import functools +import inspect +import logging +import types +import weakref + +from oop_ext.foundation.compat import GetClassForUnboundMethod +from oop_ext.foundation.is_frozen import IsDevelopment +from oop_ext.foundation.odict import odict +from oop_ext.foundation.reraise import Reraise +from oop_ext.foundation.weak_ref import WeakMethodProxy + +from ._callback_wrapper import _CallbackWrapper + +log = logging.getLogger(__name__) + + +#=================================================================================================== +# Callback +#=================================================================================================== +class Callback: + ''' + Object that provides a way for others to connect in it and later call it to call + those connected. + + .. note:: This implementation is improved in that it works directly accessing functions based + on a key in an ordered dict, so, Register, Unregister and Contains are much faster than the + old callback. + + .. note:: it only stores weakrefs to objects connected + + .. note:: __slots__ added, so, it cannot have weakrefs to it (but as it stores weakrefs + internally, that shouldn't be a problem). If weakrefs are really needed, + __weakref__ should be added to the slots. + + Determining kind of callable (Python 3) + --------------------------------------- + + Many parts of callback implementation rely on identifying the kind of callable: is it a + free function? is it a function bound to an object? + + Below there is a table to help understand how different objects are classified: + + |has__self__|has__call__|has__call__self__|isbuiltin|isfunction|ismethod + --------------------|-----------|-----------|-----------------|---------|----------|-------- + free function |False |True |True |False |True |False + bound method |True |True |True |False |False |True + class method |True |True |True |False |False |True + bound class method |True |True |True |False |False |True + function object |False |True |True |False |False |False + builtin function |True |True |True |True |False |False + object |True |True |True |True |False |False + custom object |False |False |False |False |False |False + string |False |False |False |False |False |False + + where rows are: + + ```python + def free_fn(foo): + # `free function` + pass + + class Foo: + + def bound_fn(self, foo): + pass + + class Bar: + + @classmethod + def class_fn(cls, foo): + pass + + class ObjectFn: + + def __call__(self, foo): + pass + + foo = Foo() # foo is `custom object`, foo.bound_fn is `bound method` + bar = Bar() # Bar.class_fn is `class method`, bar.class_fn is `bound class method` + + object_fn = ObjectFn() # `function object` + + obj = object() # `object` + string = 'foo' # `string` + builtin_fn = string.split # `builtin function` + ``` + + and where columns are: + + * isbuiltin: inspect.isbuiltin + * isfunction: inspect.isfunction + * ismethod: inspect.ismethod + * has__self__: hasattr(obj, '__self__') + * has__call__: hasattr(obj, '__call__') + * has__call__self__: hasattr(obj.__call__, '__self__') if hasattr(obj, '__call__') else False + ''' + + __slots__ = [ + '_callbacks', + '_handle_errors', + '__weakref__', + ] + + # This constant defines whether the errors will be handled by default in all Callbacks or not. + # Handled errors won't stop the execution if an exception happens when executing the callbacks. + DEFAULT_HANDLE_ERRORS = True + + INFO_POS_FUNC_OBJ = 0 + INFO_POS_FUNC_FUNC = 1 + INFO_POS_FUNC_CLASS = 2 + + # Can be set to True to debug (should be removed after all applications + # properly test the new behavior). + DEBUG_NEW_WEAKREFS = False + + def __init__(self, handle_errors=None): + ''' + :param bool handle_errors: + If True, any errors raised while calling the callbacks will not stop the execution + flow of the application, but will call the system error handler so that error + does not fail silently. + ''' + if handle_errors is None: + handle_errors = self.DEFAULT_HANDLE_ERRORS + self._handle_errors = handle_errors + # _callbacks is no longer lazily created: This makes the creation a bit slower, but + # everything else is faster (as having to check for hasattr each time is slow). + self._callbacks = odict() + + def _GetKey(self, func, extra_args): + ''' + :param object func: + The function for which we want the key. + + :rtype: object + :returns: + Returns the key to be used to access the object. + + .. note:: The key is guaranteed to be unique among the living objects, but if the object + is garbage collected, a new function may end up having the same key. + ''' + if func.__class__ == _CallbackWrapper: + func = func.OriginalMethod() + + try: + if func.__self__ is not None: + # bound method + return (id(func.__self__), id(func.__func__), id(func.__self__.__class__)) + else: + return (id(func.__func__), id(GetClassForUnboundMethod(func))) + + except AttributeError: + # not a method -- a callable: create a strong reference (the CallbackWrapper + # is depending on this behaviour... is it correct?) + return id(func) + + def _GetInfo(self, func): + ''' + :rtype: tuple(func_obj, func_func, func_class) + :returns: + Returns a tuple with the information needed to call a method later on (close to the + WeakMethodRef, but a bit more specialized -- and faster for this context). + ''' + # Note: if it's a _CallbackWrapper, we want to register it and not the 'original method' + # at this point, but if it's a WeakMethodProxy, register the original method (we'll make a + # weak reference later anyways). + if func.__class__ == WeakMethodProxy: + func = func.GetWrappedFunction() + + if _IsCallableObject(func): + if self.DEBUG_NEW_WEAKREFS: + obj_str = '{}'.format(func.__class__) + print('Changed behavior for: %s' % obj_str) + + def ondie(r): + # I.e.: the hint here is that a reference may die before expected + print('Reference died: {}'.format(obj_str)) + + return (weakref.ref(func, ondie), None, None) + return (weakref.ref(func), None, None) + + try: + if func.__self__ is not None and func.__func__ is not None: + # bound method + return (weakref.ref(func.__self__), func.__func__, func.__self__.__class__) + else: + # unbound method + return (None, func.__func__, GetClassForUnboundMethod(func)) + except AttributeError: + # not a method -- a callable: create a strong reference (the CallbackWrapper + # is depending on this behaviour... is it correct?) + return (None, func, None) + + def __call__(self, *args, **kwargs): # @DontTrace + ''' + Calls every registered function with the given args and kwargs. + ''' + callbacks = self._callbacks + if not callbacks: + return + + to_call = [] + + for id, info_and_extra_args in list(callbacks.items()): # iterate in a copy + info = info_and_extra_args[0] + func_obj = info[self.INFO_POS_FUNC_OBJ] + if func_obj is not None: + # Ok, we have a self. + func_obj = func_obj() + if func_obj is None: + # self is dead + del callbacks[id] + else: + func_func = info[self.INFO_POS_FUNC_FUNC] + if func_func is None: + to_call.append((func_obj, info_and_extra_args[1])) + else: + to_call.append( + ( + types.MethodType(func_func, func_obj), + info_and_extra_args[1] + ) + ) + else: + func_func = info[self.INFO_POS_FUNC_FUNC] + if func_func.__class__ == _CallbackWrapper: + # The instance of the _CallbackWrapper already died! (func_obj is None) + original_method = func_func.OriginalMethod() + if original_method is None: + del callbacks[id] + continue + + # No self: either classmethod or just callable + to_call.append((func_func, info_and_extra_args[1])) + + to_call = self._FilterToCall(to_call, args, kwargs) + + # let's keep the 'if' outside of the iteration... + if self._handle_errors: + for func, extra_args in to_call: + try: + func(*extra_args + args, **kwargs) + except Exception as e: + from oop_ext.foundation.callback import ErrorNotHandledInCallback + # Note that if some error shouldn't really be handled here, clients can raise + # a subclass of ErrorNotHandledInCallback + if isinstance(e, ErrorNotHandledInCallback): + Reraise(e, 'Error while trying to call %r' % func) + else: + import traceback + from oop_ext.foundation.callback import HandleErrorOnCallback + # We need to log the current stack so we can at least know who called this + # callback. + log.error('Error while trying to call {!r}:\n\n{}'.format( + func, ''.join(traceback.format_stack()))) + HandleErrorOnCallback(func, *extra_args + args, **kwargs) + else: + for func, extra_args in to_call: + try: + func(*extra_args + args, **kwargs) + except Exception as e: + Reraise(e, 'Error while trying to call %r' % func) + + def _FilterToCall(self, to_call, args, kwargs): + ''' + Provides a chance for subclasses to filter the function/extra arguments to call. + + :param list(tuple(method,tuple)) to_call: + list(function_to_call, extra_arguments) + + :param args: + Arguments being passed to the call. + + :param kwargs: + Keyword arguments being passed to the call. + + :return list(tuple(method,tuple): + Return the filtered list with the function/extra arguments to call. + ''' + return to_call + + _EXTRA_ARGS_CONSTANT = tuple() + + def Register(self, func, extra_args=_EXTRA_ARGS_CONSTANT): + ''' + Registers a function in the callback. + + :param object func: + Method or function that will be called later. + + :param list(object) extra_args: + A list with the objects to be used + ''' + if IsDevelopment() and hasattr(func, 'im_class'): + # TODO: Python 3 - This can be removed after deprecating Python 2 + if not inspect.isclass(getattr(func, 'im_class')): + msg = '%r object has inconsistent internal attributes and is not compatible with ' \ + 'Callback.\nim_class = %r\n(If using a MagicMock, remember to pass spec=lambda:None).' + raise RuntimeError(msg % (func, getattr(func, 'im_class'))) + if extra_args is not self._EXTRA_ARGS_CONSTANT: + extra_args = tuple(extra_args) + + key = self._GetKey(func, extra_args) + callbacks = self._callbacks + callbacks.pop(key, None) # Remove if it exists + callbacks[key] = (self._GetInfo(func), extra_args) + + def Contains(self, func, extra_args=_EXTRA_ARGS_CONSTANT): + ''' + :param object func: + The function that may be contained in this callback. + + :rtype: bool + :returns: + True if the function is already registered within the callbacks and False + otherwise. + ''' + key = self._GetKey(func, extra_args) + + callbacks = self._callbacks + + info_and_extra_args = callbacks.get(key) + if info_and_extra_args is None: + return False + + if func.__class__ == WeakMethodProxy: + func = func.GetWrappedFunction() + + # We must check if it's actually the same, because it may be that the ids we've gotten for + # this object were actually from a garbage-collected function that was previously registered. + + info = info_and_extra_args[0] + func_obj = info[self.INFO_POS_FUNC_OBJ] + func_func = info[self.INFO_POS_FUNC_FUNC] + if func_obj is not None: + # Ok, we have a self. + func_obj = func_obj() + if func_obj is None: + # self is dead + del callbacks[key] + return False + else: + return func is func_obj or (func_func is not None and func == types.MethodType(func_func, func_obj)) + else: + if func_func.__class__ == _CallbackWrapper: + # The instance of the _CallbackWrapper already died! (func_obj is None) + original_method = func_func.OriginalMethod() + if original_method is None: + del callbacks[key] + return False + return original_method == func + + if func_func == func: + return True + try: + f = func.__func__ + except AttributeError: + return False + else: + return f == func_func + + raise AssertionError('Should not get here!') + + def Unregister(self, func, extra_args=_EXTRA_ARGS_CONSTANT): + ''' + Unregister a function previously registered with Register. + + :param object func: + The function to be unregistered. + ''' + key = self._GetKey(func, extra_args) + + try: + # As there can only be 1 instance with the same id alive, it should be OK just + # deleting it directly (because if there was a dead reference pointing to it it will + # be already dead anyways) + del self._callbacks[key] + except (KeyError, AttributeError): + # Even when unregistering some function that isn't registered we shouldn't trigger an + # exception, just do nothing + pass + + def UnregisterAll(self): + ''' + Unregisters all functions + ''' + self._callbacks.clear() + + def __len__(self): + return len(self._callbacks) + + +def _IsCallableObject(func): + return not inspect.isbuiltin(func) and \ + not inspect.isfunction(func) and \ + not inspect.ismethod(func) and \ + not func.__class__ == functools.partial and \ + func.__class__ != _CallbackWrapper and \ + not getattr(func, '__CALLBACK_KEEP_STRONG_REFERENCE__', False) diff --git a/src/oop_ext/foundation/callback/_priority_callback.py b/src/oop_ext/foundation/callback/_priority_callback.py new file mode 100644 index 0000000..0cfe35b --- /dev/null +++ b/src/oop_ext/foundation/callback/_priority_callback.py @@ -0,0 +1,60 @@ + +from oop_ext.foundation.decorators import Override +from oop_ext.foundation.odict import odict + +from ._fast_callback import Callback + + +#=================================================================================================== +# PriorityCallback +#=================================================================================================== +class PriorityCallback(Callback): + ''' + Class that's able to give a priority to the added callbacks when they're registered. + ''' + + INFO_POS_PRIORITY = 3 + + @Override(Callback._GetInfo) + def _GetInfo(self, func, priority): + ''' + Overridden to add the priority to the info. + + :param int priority: + The priority to be set to the added callback. + ''' + info = Callback._GetInfo(self, func) + return info + (priority,) + + @Override(Callback.Register) + def Register(self, func, extra_args=Callback._EXTRA_ARGS_CONSTANT, priority=5): + ''' + Register a function in the callback. + :param object func: + Method or function that will be called later. + + :param int priority: + If passed, it'll be be used to put the callback into the correct place based on the + priority passed (lower numbers have higher priority). + ''' + if extra_args is not self._EXTRA_ARGS_CONSTANT: + extra_args = tuple(extra_args) + + key = self._GetKey(func, extra_args) + try: + callbacks = self._callbacks + except AttributeError: + callbacks = self._callbacks = odict() + + callbacks.pop(key, None) # Remove if it exists + new_info = self._GetInfo(func, priority) + + i = 0 + for i, (info, _extra) in enumerate(callbacks.values()): + if info[self.INFO_POS_PRIORITY] > priority: + break + else: + # Iterated all... so, go one more the last position. + i += 1 + + callbacks.insert(i, key, (new_info, extra_args)) diff --git a/src/oop_ext/foundation/callback/_shortcuts.py b/src/oop_ext/foundation/callback/_shortcuts.py new file mode 100644 index 0000000..6cc4213 --- /dev/null +++ b/src/oop_ext/foundation/callback/_shortcuts.py @@ -0,0 +1,227 @@ + +import weakref + +from oop_ext.foundation.types_ import Method +from oop_ext.foundation.weak_ref import WeakMethodRef + +from ._callback_wrapper import _CallbackWrapper +from ._fast_callback import Callback, GetClassForUnboundMethod + + +#=================================================================================================== +# _CreateBeforeOrAfter +#=================================================================================================== +def _CreateBeforeOrAfter(method, callback, sender_as_parameter, before=True): + + wrapper = WrapForCallback(method) + original_method = wrapper.OriginalMethod() + + extra_args = [] + + if sender_as_parameter: + try: + im_self = original_method.__self__ + except AttributeError: + pass + else: + extra_args.append(weakref.ref(im_self)) + + # this is not garbage collected directly when added to the wrapper (which will create a WeakMethodRef to it) + # because it's not a real method, so, WeakMethodRef will actually maintain a strong reference to it. + callback = _CallbackWrapper(WeakMethodRef(callback)) + + if before: + wrapper.AppendBefore(callback, extra_args) + else: + wrapper.AppendAfter(callback, extra_args) + + return wrapper + + +#=================================================================================================== +# Before +#=================================================================================================== +def Before(method, callback, sender_as_parameter=False): + ''' + Registers the given callback to be executed before the given method is called, with the + same arguments. + + The method can be eiher an unbound method or a bound method. If it is an unbound method, + *all* instances of the class will generate callbacks when method is called. If it is a bound + method, only the method of the instance will generate callbacks. + + Remarks: + The function has changed its signature to accept an extra parameter (sender_as_parameter). + Using "*args" as before made impossible to add new parameters to the function. + ''' + return _CreateBeforeOrAfter(method, callback, sender_as_parameter) + + +#=================================================================================================== +# After +#=================================================================================================== +def After(method, callback, sender_as_parameter=False): + ''' + Registers the given callbacks to be execute after the given method is called, with the same + arguments. + + The method can be eiher an unbound method or a bound method. If it is an unbound method, + *all* instances of the class will generate callbacks when method is called. If it is a bound + method, only the method of the instance will generate callbacks. + + Remarks: + This function has changed its signature to accept an extra parameter (sender_as_parameter). + Using "*args" as before made impossible to add new parameters to the function. + ''' + return _CreateBeforeOrAfter(method, callback, sender_as_parameter, before=False) + + +#=================================================================================================== +# Remove +#=================================================================================================== +def Remove(method, callback): + ''' + Removes the given callback from a method previously connected using after or before. + Return true if the callback was removed, false otherwise. + ''' + wrapped = _GetWrapped(method) + if wrapped: + return wrapped.Remove(callback) + return False + + +#=================================================================================================== +# Implementation Details +#=================================================================================================== +class _MethodWrapper(Method): # It needs to be a subclass of Method for interface checks. + + __slots__ = [ + '_before', + '_after', + '_method', + '_name', + 'OriginalMethod', + ] + + def __init__(self, method): + self._before = None + self._after = None + self._method = WeakMethodRef(method) + self._name = method.__name__ + + # Maintaining the OriginalMethod() interface that clients expect. + self.OriginalMethod = self._method + + def __repr__(self): + return '_MethodWrapper({}): {}'.format(id(self), self._name) + + def __call__(self, *args, **kwargs): + + if self._before is not None: + self._before(*args, **kwargs) + + m = self._method() + if m is None: + raise ReferenceError( + "Error: the object that contained this method (%s) has already been garbage collected" + % self._name) + + result = m(*args, **kwargs) + + if self._after is not None: + self._after(*args, **kwargs) + + return result + + def AppendBefore(self, callback, extra_args=None, handle_errors=True): + ''' + Append the given callbacks in the list of callback to be executed BEFORE the method. + ''' + if extra_args is None: + extra_args = [] + + if self._before is None: + self._before = Callback(handle_errors=handle_errors) + self._before.Register(callback, extra_args) + + def AppendAfter(self, callback, extra_args=None, handle_errors=True): + ''' + Append the given callbacks in the list of callback to be executed AFTER the method. + ''' + if extra_args is None: + extra_args = [] + + if self._after is None: + self._after = Callback(handle_errors=handle_errors) + self._after.Register(callback, extra_args) + + def Remove(self, callback): + ''' + Remove the given callback from both the BEFORE and AFTER callbacks lists. + ''' + result = False + + if self._before is not None and self._before.Contains(callback): + self._before.Unregister(callback) + result = True + if self._after is not None and self._after.Contains(callback): + self._after.Unregister(callback) + result = True + + return result + + +#=================================================================================================== +# _GetWrapped +#=================================================================================================== +def _GetWrapped(method): + ''' + Returns true if the given method is already wrapped. + ''' + if isinstance(method, _MethodWrapper): + return method + try: + return method._wrapped_instance + except AttributeError: + return None + + +#=================================================================================================== +# WrapForCallback +#=================================================================================================== +def WrapForCallback(method): + '''Generates a wrapper for the given method, or returns the method itself + if it is already a wrapper. + ''' + wrapped = _GetWrapped(method) + if wrapped is not None: + # its a wrapper already + if not hasattr(method, '__self__'): + return wrapped + + # Taking care for the situation where we add a callback to the class and later to the + # instance. + # Note that the other way around does not work at all (i.e.: if a callback is first added + # to the instance, there's no way we'll find about that when adding it to the class + # anyways). + if method.__self__ is None: + if wrapped._method._obj is None: + return wrapped + + wrapper = _MethodWrapper(method) + if not hasattr(method, '__self__') or method.__self__ is None: + # override the class method + + # we must make it a regular call for classmethods (it MUST not be a bound + # method nor class when doing that). + def call(*args, **kwargs): + return wrapper(*args, **kwargs) + + call.__name__ = method.__name__ + call._wrapped_instance = wrapper + + setattr(GetClassForUnboundMethod(method), method.__name__, call) + else: + # override the instance method + setattr(method.__self__, method.__name__, wrapper) + return wrapper diff --git a/src/oop_ext/foundation/callback/_tests/__init__.py b/src/oop_ext/foundation/callback/_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/oop_ext/foundation/callback/_tests/test_callback.py b/src/oop_ext/foundation/callback/_tests/test_callback.py new file mode 100644 index 0000000..ebf0184 --- /dev/null +++ b/src/oop_ext/foundation/callback/_tests/test_callback.py @@ -0,0 +1,1084 @@ + +import weakref + +import pytest + +from oop_ext.foundation.callback import After, Before, Callback, Callbacks, Remove +from oop_ext.foundation.types_ import Null +from oop_ext.foundation.weak_ref import GetWeakProxy, WeakMethodProxy, WeakMethodRef + + +#=================================================================================================== +# _MyClass +#=================================================================================================== +class _MyClass: + + def SetAlpha(self, value): + self.alpha = value + + def SetBravo(self, value): + self.bravo = value + + +class C: + + def __init__(self, test_case): + self.test_case = test_case + + def foo(self, arg): + self.test_case.foo_called = (self, arg) + return arg + + +class Stub: + + def call(self, *args, **kwargs): + pass + + +@pytest.yield_fixture(autouse=True) +def restore_test_classes(): + """ + It can't reliably bind callbacks to methods in local temporary classes from Python 3 onward, + as callback is unable to later know to which class was bound to. + + For this reason, we have to use global, shared classes for these tests. This fixture makes sure + these test classes used are always restored to clean state to avoid undesired side-effects in + other tests. + """ + original_stub_call = Stub.call + original_c_foo = C.foo + + yield + Stub.call = original_stub_call + C.foo = original_c_foo + + +#=================================================================================================== +# Test +#=================================================================================================== +class Test: + + def setup_method(self, method): + self.foo_called = None + + self.a = C(self) + self.b = C(self) + + def after(*args): + self.after_called = args + self.after_count += 1 + + self.after = after + self.after_called = None + self.after_count = 0 + + def before(*args): + self.before_called = args + self.before_count += 1 + + self.before = before + self.before_called = None + self.before_count = 0 + + def testClassOverride(self): + Before(C.foo, self.before) + After(C.foo, self.after) + + self.a.foo(1) + assert self.foo_called == (self.a, 1) + assert self.after_called == (self.a, 1) + assert self.after_count == 1 + assert self.before_called == (self.a, 1) + assert self.before_count == 1 + + self.b.foo(2) + assert self.foo_called == (self.b, 2) + assert self.after_called == (self.b, 2) + assert self.after_count == 2 + assert self.before_called == (self.b, 2) + assert self.before_count == 2 + + assert Remove(C.foo, self.before) + + self.a.foo(3) + assert self.foo_called == (self.a, 3) + assert self.after_called == (self.a, 3) + assert self.after_count == 3 + assert self.before_called == (self.b, 2) + assert self.before_count == 2 + + def testInstanceOverride(self): + Before(self.a.foo, self.before) + After(self.a.foo, self.after) + + self.a.foo(1) + assert self.foo_called == (self.a, 1) + assert self.after_called == (1,) + assert self.before_called == (1,) + assert self.after_count == 1 + assert self.before_count == 1 + + self.b.foo(2) + assert self.foo_called == (self.b, 2) + assert self.after_called == (1,) + assert self.before_called == (1,) + assert self.after_count == 1 + assert self.before_count == 1 + + assert Remove(self.a.foo, self.before) == True + + self.a.foo(2) + assert self.foo_called == (self.a, 2) + assert self.after_called == (2,) + assert self.before_called == (1,) + assert self.after_count == 2 + assert self.before_count == 1 + + Before(self.a.foo, self.before) + Before(self.a.foo, self.before) # Registering twice has no effect the 2nd time + + self.a.foo(5) + assert self.before_called == (5,) + assert self.before_count == 2 + + def testBoundMethodsWrong(self): + foo = self.a.foo + Before(foo, self.before) + After(foo, self.after) + + foo(10) + assert 0 == self.before_count + assert 0 == self.after_count + + def testBoundMethodsRight(self): + foo = self.a.foo + foo = Before(foo, self.before) + foo = After(foo, self.after) + + foo(10) + assert self.before_count == 1 + assert self.after_count == 1 + + def testReferenceDies(self): + + class Receiver: + + def before(dummy, *args): # @NoSelf + self.before_count += 1 + self.before_args = args + + rec = Receiver() + self.before_count = 0 + self.before_args = None + + foo = self.a.foo + foo = Before(foo, rec.before) + + foo(10) + assert self.before_args == (10,) + assert self.before_count == 1 + + del rec # kill the receiver + + foo(20) + assert self.before_args == (10,) + assert self.before_count == 1 + + def testSenderDies(self): + + class Sender: + + def foo(s, *args): # @NoSelf + s.args = args + + def __del__(dummy): # @NoSelf + self.sender_died = True + + self.sender_died = False + s = Sender() + w = weakref.ref(s) + Before(s.foo, self.before) + s.foo(10) + f = s.foo # hold a strong reference to s + assert self.before_count == 1 + assert self.before_called == (10,) + + assert not self.sender_died + del s + assert self.sender_died + + with pytest.raises(ReferenceError): + f(10) # must have already died: we don't have a strong reference + assert w() is None + + def testLessArgs(self): + + class C: + + def foo(self, _x, _y, **_kwargs): + pass + + def after_2(x, y, *args, **kwargs): + self.after_2_res = x, y + + def after_1(x, *args, **kwargs): + self.after_1_res = x + + def after_0(*args, **kwargs): + self.after_0_res = 0 + + self.after_2_res = None + self.after_1_res = None + self.after_0_res = None + + c = C() + + After(c.foo, after_2) + After(c.foo, after_1) + After(c.foo, after_0) + + c.foo(10, 20, foo=1) + assert self.after_2_res == (10, 20) + assert self.after_1_res == 10 + assert self.after_0_res == 0 + + def testWithCallable(self): + + class Stub: + + def call(self, _b): + pass + + class Aux: + + def __call__(self, _b): + self.called = True + + s = Stub() + a = Aux() + After(s.call, a) + s.call(True) + + assert a.called + + def testCallback(self): + + self.args = [None, None] + + def f1(*args): + self.args[0] = args + + def f2(*args): + self.args[1] = args + + c = Callback() + c.Register(f1) + + c(1, 2) + + assert self.args[0] == (1, 2) + + c.Unregister(f1) + self.args[0] = None + c(10, 20) + assert self.args[0] == None + + def foo(): pass + + # self.assertNotRaises(FunctionNotRegisteredError, c.Unregister, foo) + c.Unregister(foo) + + def test_extra_args(self): + ''' + Tests the extra-args parameter in Register method. + ''' + self.zulu_calls = [] + + def zulu_one(*args): + self.zulu_calls.append(args) + + def zulu_too(*args): + self.zulu_calls.append(args) + + alpha = Callback() + alpha.Register(zulu_one, [1, 2]) + + assert self.zulu_calls == [] + + alpha('a') + assert self.zulu_calls == [ + (1, 2, 'a') + ] + + alpha('a', 'b', 'c') + assert self.zulu_calls == [ + (1, 2, 'a'), + (1, 2, 'a', 'b', 'c') + ] + + # Test a second method with extra-args + alpha.Register(zulu_too, [9]) + + alpha('a') + assert self.zulu_calls == [ + (1, 2, 'a'), + (1, 2, 'a', 'b', 'c'), + (1, 2, 'a'), + (9, 'a'), + ] + + def test_sender_as_parameter(self): + self.zulu_calls = [] + + def zulu_one(*args): + self.zulu_calls.append(args) + + def zulu_two(*args): + self.zulu_calls.append(args) + + Before(self.a.foo, zulu_one, sender_as_parameter=True) + + assert self.zulu_calls == [] + self.a.foo(0) + assert self.zulu_calls == [(self.a, 0)] + + # The second method registered with the sender_as_parameter on did not receive it. + Before(self.a.foo, zulu_two, sender_as_parameter=True) + + self.zulu_calls = [] + self.a.foo(1) + assert self.zulu_calls == [(self.a, 1), (self.a, 1)] + + def test_sender_as_parameter_after_and_before(self): + self.zulu_calls = [] + + def zulu_one(*args): + self.zulu_calls.append((1, args)) + + def zulu_too(*args): + self.zulu_calls.append((2, args)) + + Before(self.a.foo, zulu_one, sender_as_parameter=True) + After(self.a.foo, zulu_too) + + assert self.zulu_calls == [] + self.a.foo(0) + assert self.zulu_calls == [(1, (self.a, 0)), (2, (0,))] + + def testContains(self): + + def foo(x): + pass + + c = Callback() + assert not c.Contains(foo) + c.Register(foo) + + assert c.Contains(foo) + c.Unregister(foo) + assert not c.Contains(foo) + + def testCallbackReceiverDies(self): + + class A: + + def on_foo(dummy, *args): # @NoSelf + self.args = args + + self.args = None + a = A() + weak_a = weakref.ref(a) + + foo = Callback() + foo.Register(a.on_foo) + + foo(1, 2) + assert self.args == (1, 2) + assert weak_a() is a + + foo(3, 4) + assert self.args == (3, 4) + assert weak_a() is a + + del a + assert weak_a() is None + foo(5, 6) + assert self.args == (3, 4) + + def testActionMethodDies(self): + + class A: + + def foo(self): + pass + + def FooAfter(): + self.after_exec += 1 + + self.after_exec = 0 + + a = A() + weak_a = weakref.ref(a) + After(a.foo, FooAfter) + a.foo() + + assert self.after_exec == 1 + + del a + + # IMPORTANT: behaviour change. The description below is for the previous + # behaviour. That is not true anymore (the circular reference is not kept anymore) + + # callback creates a circular reference; that's ok, because we want + # to still be able to do "x = a.foo" and keep a strong reference to it + + assert weak_a() is None + + def testAfterRegisterMultipleAndUnregisterOnce(self): + + class A: + + def foo(self): + pass + + a = A() + + def FooAfter1(): + Remove(a.foo, FooAfter1) + self.after_exec += 1 + + def FooAfter2(): + self.after_exec += 1 + + self.after_exec = 0 + After(a.foo, FooAfter1) + After(a.foo, FooAfter2) + a.foo() + + # it was iterating in the original after, so, this case + # was only giving 1 result and not 2 as it should + assert 2 == self.after_exec + + a.foo() + assert 3 == self.after_exec + a.foo() + assert 4 == self.after_exec + + After(a.foo, FooAfter2) + After(a.foo, FooAfter2) + After(a.foo, FooAfter2) + + a.foo() + assert 5 == self.after_exec + + Remove(a.foo, FooAfter2) + a.foo() + assert 5 == self.after_exec + + def testOnClassMethod(self): + + class A: + + @classmethod + def foo(cls): + pass + + self.after_exec_class_method = 0 + + def FooAfterClassMethod(): + self.after_exec_class_method += 1 + + self.after_exec_self_method = 0 + + def FooAfterSelfMethod(): + self.after_exec_self_method += 1 + + After(A.foo, FooAfterClassMethod) + + a = A() + After(a.foo, FooAfterSelfMethod) + + a.foo() + assert 1 == self.after_exec_class_method + assert 1 == self.after_exec_self_method + + Remove(A.foo, FooAfterClassMethod) + a.foo() + assert 1 == self.after_exec_class_method + assert 2 == self.after_exec_self_method + + def testSenderDies2(self): + After(self.a.foo, self.after, True) + self.a.foo(1) + assert (self.a, 1) == self.after_called + + a = weakref.ref(self.a) + self.after_called = None + self.foo_called = None + del self.a + assert a() is None + + def testCallbacks(self): + self.called = 0 + + def bar(*args): + self.called += 1 + + callbacks = Callbacks() + callbacks.Before(self.a.foo, bar) + callbacks.After(self.a.foo, bar) + + self.a.foo(1) + assert 2 == self.called + callbacks.RemoveAll() + self.a.foo(1) + assert 2 == self.called + + def testAfterRemove(self): + + my_object = _MyClass() + my_object.SetAlpha(0) + my_object.SetBravo(0) + + After(my_object.SetAlpha, my_object.SetBravo) + + my_object.SetAlpha(1) + assert my_object.bravo == 1 + + Remove(my_object.SetAlpha, my_object.SetBravo) + + my_object.SetAlpha(2) + assert my_object.bravo == 1 + + def testAfterRemoveCallback(self): + my_object = _MyClass() + my_object.SetAlpha(0) + my_object.SetBravo(0) + + # Test After/Remove with a callback + event = Callback() + After(my_object.SetAlpha, event) + event.Register(my_object.SetBravo) + + my_object.SetAlpha(3) + assert my_object.bravo == 3 + + Remove(my_object.SetAlpha, event) + + my_object.SetAlpha(4) + assert my_object.bravo == 3 + + def testAfterRemoveCallbackAndSenderAsParameter(self): + my_object = _MyClass() + my_object.SetAlpha(0) + my_object.SetBravo(0) + + def event(obj_or_value, value): + self._value = value + + # Test After/Remove with a callback and sender_as_parameter + After(my_object.SetAlpha, event, sender_as_parameter=True) + + my_object.SetAlpha(3) + + assert 3 == self._value + + Remove(my_object.SetAlpha, event) + + my_object.SetAlpha(4) + assert 3 == self._value + + def testDeadCallbackCleared(self): + my_object = _MyClass() + my_object.SetAlpha(0) + my_object.SetBravo(0) + self._value = [] + + class B: + + def event(s, value): # @NoSelf + self._b_value = value + + class A: + + def event(s, obj, value): # @NoSelf + self._a_value = value + + a = A() + b = B() + + # Test After/Remove with a callback and sender_as_parameter + After(my_object.SetAlpha, a.event, sender_as_parameter=True) + After(my_object.SetAlpha, b.event, sender_as_parameter=False) + + w = weakref.ref(a) + my_object.SetAlpha(3) + assert 3 == self._a_value + assert 3 == self._b_value + del a + my_object.SetAlpha(4) + assert 3 == self._a_value + assert 4 == self._b_value + assert w() is None + + def testRemoveCallback(self): + + class C: + + def __init__(self, name): + self.name = name + + def OnCallback(self): + pass + + def __eq__(self, other): + return self.name == other.name + + def __ne__(self, other): + return not self == other + + instance1 = C('instance') + instance2 = C('instance') + assert instance1 == instance2 + + c = Callback() + c.Register(instance1.OnCallback) + c.Register(instance2.OnCallback) + + # removing first callback, and checking that it was actually removed as expected + c.Unregister(instance1.OnCallback) + assert not c.Contains(instance1.OnCallback) == True + + # self.assertNotRaises(RuntimeError, c.Unregister, instance1.OnCallback) + c.Unregister(instance1.OnCallback) + + # removing second callback, and checking that it was actually removed as expected + c.Unregister(instance2.OnCallback) + assert not c.Contains(instance2.OnCallback) == True + + # self.assertNotRaises(RuntimeError, c.Unregister, instance2.OnCallback) + c.Unregister(instance2.OnCallback) + + def testRegisterTwice(self): + self.called = 0 + + def After(*args): + self.called += 1 + + c = Callback() + c.Register(After) + c.Register(After) + c.Register(After) + c() + assert self.called == 1 + + def testHandleErrorOnCallback(self, mocker): + old_default_handle_errors = Callback.DEFAULT_HANDLE_ERRORS + Callback.DEFAULT_HANDLE_ERRORS = False + try: + + self.called = 0 + + def After(*args, **kwargs): + self.called += 1 + raise RuntimeError('test') + + def After2(*args, **kwargs): + self.called += 1 + raise RuntimeError('test2') + + c = Callback(handle_errors=True) + c.Register(After) + c.Register(After2) + + mocked = mocker.patch('oop_ext.foundation.handle_exception.HandleException', autospec=True) + c() + assert self.called == 2 + assert mocked.call_count == 2 + + mocked.reset_mock() + c(1, a=2) + assert self.called == 4 + assert mocked.call_count == 2 + from _pytest.pytester import LineMatcher + matcher = LineMatcher(mocked.call_args[0][0].splitlines()) + expected = [ + 'Error while trying to call .After2 at 0x*>', + 'Args: (1,)', + "Kwargs: {'a': 2}", + ] + matcher.fnmatch_lines(expected) + + # test the default behaviour: errors are not handled and stop execution as usual + self.called = 0 + c = Callback() + c.Register(After) + c.Register(After2) + with pytest.raises(RuntimeError): + c() + assert self.called == 1 + finally: + Callback.DEFAULT_HANDLE_ERRORS = old_default_handle_errors + + def testAfterBeforeHandleError(self, mocker): + + class C: + + def Method(self, x): + return x * 2 + + def AfterMethod(*args): + self.before_called += 1 + raise RuntimeError + + def BeforeMethod(*args): + self.after_called += 1 + raise RuntimeError + + self.before_called = 0 + self.after_called = 0 + + c = C() + Before(c.Method, BeforeMethod) + After(c.Method, AfterMethod) + + mocked = mocker.patch('oop_ext.foundation.callback.HandleErrorOnCallback', autospec=True) + assert c.Method(10) == 20 + assert self.before_called == 1 + assert self.after_called == 1 + assert mocked.call_count == 2 + + mocked.reset_mock() + assert c.Method(20) == 40 + assert self.before_called == 2 + assert self.after_called == 2 + + def testKeyReusedAfterDead(self, monkeypatch): + self._gotten_key = False + + def GetKey(*args, **kwargs): + self._gotten_key = True + return 1 + + monkeypatch.setattr(Callback, '_GetKey', GetKey) + + def AfterMethod(*args): + pass + + def AfterMethodB(*args): + pass + + c = Callback() + + c.Register(AfterMethod) + self._gotten_key = False + assert not c.Contains(AfterMethodB) + assert c.Contains(AfterMethod) + assert self._gotten_key + + # As we made _GetKey return always the same, this will make it remove one and add the + # other one, so, the contains will have to check if they're actually the same or not. + c.Register(AfterMethodB) + self._gotten_key = False + assert c.Contains(AfterMethodB) + assert not c.Contains(AfterMethod) + assert self._gotten_key + + class A: + + def __init__(self): + self._a = 0 + + def GetA(self): + return self._a + + def SetA(self, value): + self._a = value + + a = property(GetA, SetA) + + a = A() + # If registering a bound, it doesn't contain the unbound + c.Register(a.SetA) + assert not c.Contains(AfterMethodB) + assert not c.Contains(A.SetA) + assert c.Contains(a.SetA) + + # But if registering an unbound, it contains the bound + c.Register(A.SetA) + assert not c.Contains(AfterMethodB) + assert c.Contains(A.SetA) + assert c.Contains(a.SetA) + + c.Register(a.SetA) + assert len(c) == 1 + del a + assert not c.Contains(AfterMethodB) + assert len(c) == 0 + + a = A() + from oop_ext.foundation.callback._callback_wrapper import _CallbackWrapper + c.Register(_CallbackWrapper(WeakMethodRef(a.SetA))) + assert len(c) == 1 + del a + assert not c.Contains(AfterMethodB) + assert len(c) == 0 + + def testNeedsUnregister(self): + c = Callback() + + # Even when the function isn't registered, we not raise an error. + def Func(): + pass + + # self.assertNotRaises(RuntimeError, c.Unregister, Func) + c.Unregister(Func) + + def testUnregisterAll(self): + c = Callback() + + # self.assertNotRaises(AttributeError, c.UnregisterAll) + c.UnregisterAll() + + self.called = False + + def Func(): + self.called = True + + c.Register(Func) + c.UnregisterAll() + + c() + assert self.called == False + + def testOnClassAndOnInstance1(self): + vals = [] + + def OnCall1(instance, val): + vals.append(('call_instance', val)) + + def OnCall2(val): + vals.append(('call_class', val)) + + After(Stub.call, OnCall1) + s = Stub() + After(s.call, OnCall2) + + s.call(True) + assert [('call_instance', True), ('call_class', True)] == vals + + def testOnClassAndOnInstance2(self): + vals = [] + + def OnCall1(instance, val): + vals.append(('call_class', val)) + + def OnCall2(val): + vals.append(('call_instance', val)) + + s = Stub() + After(s.call, OnCall2) + After(Stub.call, OnCall1) + + # Tricky thing here: because we added the callback in the class after we added it to the + # instance, the callback on the instance cannot be rebound, thus, calling it on the instance + # won't really trigger the callback on the class (not really what would be expected of the + # after method, but I couldn't find a reasonable way to overcome that). + # A solution could be keeping track of all callbacks and rebinding all existent ones in the + # instances to the one in the class, but it seems overkill for such an odd situation. + s.call(True) + assert [('call_instance', True), ] == vals + + def testOnNullClass(self): + + class _MyNullSubClass(Null): + + def GetIstodraw(self): + return True + + s = _MyNullSubClass() + + def AfterSetIstodraw(): + pass + + After(s.SetIstodraw, AfterSetIstodraw) + + def testListMethodAsCallback(self, mocker): + ''' + This was based on a failure on + souring20.core.model.multiple_simulation_runs._tests.test_multiple_simulation_runs.testNormalExecution + which failed with "TypeError: cannot create weak reference to 'list' object" + ''' + vals = [] + + class Stub: + + def call(self, *args, **kwargs): + pass + + s = Stub() + After(s.call, vals.append) + + s.call('call_append') + assert ['call_append'] == vals + + def testCallbackWithMagicMock(self, mocker): + """ + Check that we can register mock.MagicMock objects in callbacks. + + This makes it easier to test that public callbacks are being called with correct arguments. + + Usage (in testing, of course): + + save_listener = mock.MagicMock(spec=lambda: None) + project_manager.on_save.Register(save_listener) + project_manager.SlotSave() + assert save_listener.call_args == mock.call('foo.file', '.txt') + + Instead of the more traditional: + + def OnSave(filename, ext): + self.filename = filename + self.ext = ext + + self.filename = None + self.ext = ext + + project_manager.on_save.Register(OnSave) + project_manager.SlotSave() + assert (self.filename, self.ext) == ('foo.file', '.txt') + """ + c = Callback() + + with pytest.raises(RuntimeError): + c.Register(mocker.MagicMock()) + + magic_mock = mocker.stub() + c = Callback() + c.Register(magic_mock) + + c(10, name='X') + assert magic_mock.call_args_list == [mocker.call(10, name='X')] + + c(20, name='Y') + assert magic_mock.call_args_list == [mocker.call(10, name='X'), mocker.call(20, name='Y')] + + c.Unregister(magic_mock) + c(30, name='Z') + assert len(magic_mock.call_args_list) == 2 + + def testCallbackInstanceWeakRef(self): + + class Obj: + + def __init__(self): + self.called = False + + def __call__(self): + self.called = True + + c = Callback() + obj = Obj() + c.Register(obj) + c() + assert c.Contains(obj) + assert obj.called + obj = weakref.ref(obj) + assert obj() is None + + def testBeforeAfterWeakProxy(self): + + class Foo: + + def __init__(self): + Before(self.SetFilename, GetWeakProxy(self._BeforeSetFilename)) + After(self.SetFilename, GetWeakProxy(self._AfterSetFilename)) + self.before = False + self.after = False + + def _BeforeSetFilename(self, *args, **kwargs): + self.before = True + + def _AfterSetFilename(self, *args, **kwargs): + self.after = True + + def SetFilename(self, f): + pass + + foo = Foo() + foo.SetFilename('bar') + assert foo.before + assert foo.after + + def testKeepStrongReference(self): + + class Obj: + __CALLBACK_KEEP_STRONG_REFERENCE__ = True + + def __init__(self): + self.called = False + + def __call__(self): + self.called = True + + c = Callback() + obj = Obj() + c.Register(obj) + c() + assert c.Contains(obj) + assert obj.called + obj = weakref.ref(obj) + # Not collected because of __CALLBACK_KEEP_STRONG_REFERENCE__ in the class. + assert obj() is not None + + def testWeakMethodProxy(self): + + class Obj: + + def Foo(self): + self.called = True + + obj = Obj() + proxy = WeakMethodProxy(obj.Foo) + + c = Callback() + c.Register(proxy) + c() + assert obj.called + assert c.Contains(proxy) + obj = weakref.ref(obj) + assert obj() is None + c() + assert len(c) == 0 + + def testWeakMethodProxy2(self): + + def Foo(): + self.called = True + + proxy = WeakMethodProxy(Foo) + + c = Callback() + c.Register(proxy) + c() + assert self.called + assert c.Contains(proxy) + + def testWeakRefToCallback(self): + c = Callback() + c_ref = weakref.ref(c) + assert c_ref() is c + + def testCallbackAndPartial(self): + from functools import partial + called = [] + + def Method(a): + called.append(a) + + c = Callback() + c.Register(lambda: Method('lambda')) + c.Register(partial(Method, 'partial')) + c() + assert called == ['lambda', 'partial'] diff --git a/src/oop_ext/foundation/callback/_tests/test_priority_callback.py b/src/oop_ext/foundation/callback/_tests/test_priority_callback.py new file mode 100644 index 0000000..043f3d3 --- /dev/null +++ b/src/oop_ext/foundation/callback/_tests/test_priority_callback.py @@ -0,0 +1,32 @@ + +from oop_ext.foundation.callback import PriorityCallback + + +def testPriorityCallback(): + priority_callback = PriorityCallback() + + called = [] + + def OnCall1(): + called.append(1) + + def OnCall2(): + called.append(2) + + def OnCall3(): + called.append(3) + + def OnCall4(): + called.append(4) + + def OnCall5(): + called.append(5) + + priority_callback.Register(OnCall1, priority=2) + priority_callback.Register(OnCall2, priority=2) + priority_callback.Register(OnCall3, priority=1) + priority_callback.Register(OnCall4, priority=3) + priority_callback.Register(OnCall5, priority=2) + + priority_callback() + assert called == [3, 1, 2, 5, 4] diff --git a/src/oop_ext/foundation/callback/_tests/test_single_call_callback.py b/src/oop_ext/foundation/callback/_tests/test_single_call_callback.py new file mode 100644 index 0000000..b03ca08 --- /dev/null +++ b/src/oop_ext/foundation/callback/_tests/test_single_call_callback.py @@ -0,0 +1,90 @@ + +import pytest + +from oop_ext.foundation.callback.single_call_callback import SingleCallCallback + + +def testSingleCallCallback(): + + class Stub: + pass + + stub = Stub() + callback = SingleCallCallback(stub) + + called = [] + + def Method1(arg): + called.append(arg) + + def Method2(arg): + called.append(arg) + + def Method3(arg): + called.append(arg) + + callback.Register(Method1) + + callback() + + assert called == [stub] + + with pytest.raises(AssertionError): + callback() + + assert called == [stub] + + callback.Register(Method1) # It was already there, so, won't be called again. + + assert called == [stub] + + callback.Register(Method2) + + assert called == [stub, stub] + + del stub + del called[:] + + with pytest.raises(ReferenceError): + callback.Register(Method1) + + +def testSingleCallCallbackNoParameter(): + + class Stub: + pass + + callback = SingleCallCallback(None) + + called = [] + + def Method1(): + called.append('Method1') + + def Method2(): + called.append('Method2') + + callback.Register(Method1) + + callback() + + assert called == ['Method1'] + + callback.Register(Method2) + + assert called == ['Method1', 'Method2'] + + with pytest.raises(AssertionError): + callback() + + callback.AllowCallingAgain() + callback() + + assert called == ['Method1', 'Method2', 'Method1', 'Method2'] + + callback.Register(Method1) + assert called == ['Method1', 'Method2', 'Method1', 'Method2'] + + callback.Unregister(Method1) + callback.Register(Method1) + assert called == ['Method1', 'Method2', 'Method1', 'Method2', 'Method1'] diff --git a/src/oop_ext/foundation/callback/single_call_callback.py b/src/oop_ext/foundation/callback/single_call_callback.py new file mode 100644 index 0000000..1233db4 --- /dev/null +++ b/src/oop_ext/foundation/callback/single_call_callback.py @@ -0,0 +1,86 @@ + +from ._fast_callback import Callback + + +#=================================================================================================== +# SingleCallCallback +#=================================================================================================== +class SingleCallCallback: + ''' + Callback-like implementation used for a callback to which __call__ is called only once (and + subsequent calls will always trigger the same callback). + + The callback parameter is pre-registered and kept as a weak-reference. + ''' + + def __init__(self, callback_parameter): + ''' + :param object callback_parameter: + A weak-reference is kept to this object (because the usual use-case is making a call + passing the object that contains this callback). + ''' + from oop_ext.foundation.weak_ref import GetWeakRef + if callback_parameter is None: + self._callback_parameter = None + else: + self._callback_parameter = GetWeakRef(callback_parameter) + self._done_callbacks = Callback() + self._done = False + + self._args = () + self._kwargs = {} + + def __call__(self, *args, **kwargs): + if self._done: + raise AssertionError('This callback can only be called once.') + + # Keep the args passed to call it later on... + self._args = args + self._kwargs = kwargs + + if self._callback_parameter is not None: + callback_parameter = self._callback_parameter() + if callback_parameter is None: + raise ReferenceError('Callback parameter is already garbage collected.') + else: + callback_parameter = None + + # We can dispose of it (as of now, callbacks should be called directly). + self._done = True + if callback_parameter is not None: + self._done_callbacks(callback_parameter, *args, **kwargs) + else: + self._done_callbacks(*args, **kwargs) + + def Unregister(self, fn): + self._done_callbacks.Unregister(fn) + + def UnregisterAll(self): + self._done_callbacks.UnregisterAll() + + def Register(self, fn): + if self._callback_parameter is not None: + callback_parameter = self._callback_parameter() + if callback_parameter is None: + raise ReferenceError('Callback parameter is already garbage collected.') + else: + callback_parameter = None + + contains = self._done_callbacks.Contains(fn) + + self._done_callbacks.Register(fn) + if self._done and not contains: + if callback_parameter is not None: + fn(callback_parameter, *self._args, **self._kwargs) + else: + fn(*self._args, **self._kwargs) + + def AllowCallingAgain(self): + ''' + This callback is usually called only once, afterwards, any registry will call it directly + (and the callback cannot be called anymore). + + By calling this method, we allow calling this callback again (and stop directly notifying + clients just registered until it's called again). + ''' + self._done = False diff --git a/src/oop_ext/foundation/compat.py b/src/oop_ext/foundation/compat.py new file mode 100644 index 0000000..23bac6e --- /dev/null +++ b/src/oop_ext/foundation/compat.py @@ -0,0 +1,33 @@ +''' +A compatibility module for quirks when porting from py2->py3. +''' + + +def __str__(self): + return self.__unicode__().encode('utf-8') + + +def GetClassForUnboundMethod(method): + """ + On Python 3 there are no unbound methods anymore. They are only regular functions. + + This function abstracts that difference and implements a workaround for Python 3. + + However this has a drawback: callback to method of local classes AREN'T SUPPORTED anymore, + as it is impossible to retrieve their class object just by method object alone. + """ + locals_name = '' + + # Find the class which this method belongs too. We need this because on Python 3, unbound + # methods are just regular functions with no reference to its class + names = method.__qualname__.split('.') + names.pop() + method_class = method.__globals__[names.pop(0)] + while names: + name = names.pop(0) + if name == locals_name: + raise NotImplementedError("Impossible to retrieve class object for " + "unbound methods in local classes.") + + method_class = getattr(method_class, name) + return method_class diff --git a/src/oop_ext/foundation/decorators.py b/src/oop_ext/foundation/decorators.py new file mode 100644 index 0000000..f73d494 --- /dev/null +++ b/src/oop_ext/foundation/decorators.py @@ -0,0 +1,188 @@ + +import warnings + +from oop_ext.foundation.is_frozen import IsDevelopment + +''' +Collection of decorator with ONLY standard library dependencies. +''' + + +#=================================================================================================== +# Override +#=================================================================================================== +def Override(method): + ''' + Decorator that marks that a method overrides a method in the superclass. + + :param type method: + The overridden method + + :returns function: + The decorated function + + .. note:: This decorator actually works by only making the user to access the class and the overridden method at + class level scope, so if in the future that method gets deleted or renamed, the import of the decorated method will + fail. + + Example:: + + class MyInterface: + def foo(): + pass + + class MyClass(MyInterface): + + @Overrides(MyInterace.foo) + def foo(): + pass + ''' + + def Wrapper(func): + if func.__name__ != method.__name__: + msg = "Wrong @Override: %r expected, but overwriting %r." + msg = msg % (func.__name__, method.__name__) + raise AssertionError(msg) + + if func.__doc__ is None: + func.__doc__ = method.__doc__ + + return func + + return Wrapper + + +#=================================================================================================== +# Implements +#=================================================================================================== +def Implements(method): + ''' + Decorator that marks that a method implements a method in some interface. + + :param function method: + The implemented method + + :returns function: + The decorated function + + :raises AssertionError: + if the implementation method's name is different from the one + that is being defined. This is a common error when copying/pasting the @Implements code. + + .. note:: This decorator actually works by only making the user to access the class and the implemented method at + class level scope, so if in the future that method gets deleted or renamed, the import of the decorated method will + fail. + + Example:: + + class MyInterface: + def foo(): + pass + + class MyClass(MyInterface): + + @Implements(MyInterace.foo) + def foo(): + pass + ''' + + def Wrapper(func): + if func.__name__ != method.__name__: + msg = "Wrong @Implements: %r expected, but overwriting %r." + msg = msg % (func.__name__, method.__name__) + raise AssertionError(msg) + + if func.__doc__ is None: + func.__doc__ = method.__doc__ + + return func + + return Wrapper + + +#=================================================================================================== +# Deprecated +#=================================================================================================== +def Deprecated(name=None): + ''' + Decorator that marks a method as deprecated. + + :param str name: + The name of the method that substitutes this one, if any. + ''' + if not IsDevelopment(): + # Optimization: we don't want deprecated to add overhead in release mode. + + def DeprecatedDecorator(func): + return func + + else: + + def DeprecatedDecorator(func): + ''' + The actual deprecated decorator, configured with the name parameter. + ''' + + def DeprecatedWrapper(*args, **kwargs): + ''' + This method wrapper gives a deprecated message before calling the original + implementation. + ''' + if name is not None: + msg = 'DEPRECATED: \'%s\' is deprecated, use \'%s\' instead' % (func.__name__, name) + else: + msg = 'DEPRECATED: \'%s\' is deprecated' % func.__name__ + warnings.warn(msg, stacklevel=2) + return func(*args, **kwargs) + + DeprecatedWrapper.__name__ = func.__name__ + DeprecatedWrapper.__doc__ = func.__doc__ + return DeprecatedWrapper + + return DeprecatedDecorator + + +#=================================================================================================== +# Abstract +#=================================================================================================== +def Abstract(func): + ''' + Decorator to make methods 'abstract', which are meant to be overwritten in subclasses. If some + subclass doesn't override the method, it will raise NotImplementedError when called. Note that + this decorator should be used together with :dec:Override. + + Example:: + + class Base(object): + + @Abstract + def Foo(self): + """ + This method ... + """ + # no body required here; an exception will be raised automatically + + + class Derived(Base): + + @Override(Base.Foo) + def Foo(self): + ... + + ''' + + def AbstractWrapper(self, *args, **kwargs): + ''' + This wrapper method replaces the implementation of the (abstract) method, providing a + friendly message to the user. + ''' + # # Unused argument args, kwargs + # # pylint: disable-msg=W0613 + msg = 'method {} not implemented in class {}.'.format(repr(func.__name__), repr(self.__class__)) + raise NotImplementedError(msg) + + # # Redefining build-in + # # pylint: disable-msg=W0622 + AbstractWrapper.__name__ = func.__name__ + AbstractWrapper.__doc__ = func.__doc__ + return AbstractWrapper diff --git a/src/oop_ext/foundation/exceptions.py b/src/oop_ext/foundation/exceptions.py new file mode 100644 index 0000000..b61d576 --- /dev/null +++ b/src/oop_ext/foundation/exceptions.py @@ -0,0 +1,17 @@ + + +#=================================================================================================== +# ExceptionToUnicode +#=================================================================================================== +def ExceptionToUnicode(exception): + """ + Python 3 exception handling already deals with string error messages. Here we + will only append the original exception message to the returned message (this is automatically done in Python 2 + since the original exception message is added into the new exception while Python 3 keeps the original exception + as a separated attribute + """ + messages = [] + while exception: + messages.append(str(exception)) + exception = exception.__cause__ or exception.__context__ + return '\n'.join(messages) diff --git a/src/oop_ext/foundation/handle_exception.py b/src/oop_ext/foundation/handle_exception.py new file mode 100644 index 0000000..35f91a0 --- /dev/null +++ b/src/oop_ext/foundation/handle_exception.py @@ -0,0 +1,81 @@ + +import logging +from contextlib import contextmanager + +from oop_ext.foundation.callback import Callback + +''' +This module contains utility functions for dealing with exceptions that should be handled in the +application. +''' + +# Callback for clients that want to hear about handled exceptions. +on_exception_handled = Callback() + +_ignore_expected_exception = 0 + +# hack because some tests need this +LOG_ERRORS = True + + +#=================================================================================================== +# StartIgnoreHandleException +#=================================================================================================== +def StartIgnoreHandleException(): + ''' + Starts ignoring any handled exception (will still call the on_exception_handled(), but + won't log nor call the excepthook). + ''' + global _ignore_expected_exception + _ignore_expected_exception += 1 + + +#=================================================================================================== +# EndIgnoreHandleException +#=================================================================================================== +def EndIgnoreHandleException(): + ''' + Stops ignoring handled exceptions. + ''' + global _ignore_expected_exception + _ignore_expected_exception -= 1 + + +#=================================================================================================== +# IgnoringHandleException +#=================================================================================================== +@contextmanager +def IgnoringHandleException(): + ''' + Ignores exception handling during context. + ''' + StartIgnoreHandleException() + yield + EndIgnoreHandleException() + + +#=================================================================================================== +# HandleException +#=================================================================================================== +def HandleException(msg=''): + ''' + Handles the current exception (in sys.exc_info()) without actually continuing its raise. + + It should be used when for some reason the current exception should not stop the current + execution flow but should still be shown to the user and reported accordingly. + ''' + # Let listeners know about the exception (usually the test case) + # TODO: if handle error has any bug in this callback, it calls handle error again in a loop + on_exception_handled() + + if _ignore_expected_exception > 0: + return # Don't log nor call excepthook. + + if LOG_ERRORS: + logger = logging.getLogger(__name__) + logger.exception(msg) + + # And just call the except hook (and keep on going with the calls) -- in the application + # this should trigger the except dialog. + import sys + sys.excepthook(*sys.exc_info()) diff --git a/src/oop_ext/foundation/immutable.py b/src/oop_ext/foundation/immutable.py new file mode 100644 index 0000000..81165e6 --- /dev/null +++ b/src/oop_ext/foundation/immutable.py @@ -0,0 +1,208 @@ + +''' + Defines types and functions to generate immutable structures. + + USER: The cache-manager uses this module to generate a valid KEY for its cache dictionary. +''' + +_IMMUTABLE_TYPES = {float, str, bytes, bool, type(None)} +_IMMUTABLE_TYPES.update({int}) + + +#=================================================================================================== +# RegisterAsImmutable +#=================================================================================================== +def RegisterAsImmutable(immutable_type): + ''' + Registers the given class as being immutable. This makes it be immutable for this module and + also registers a faster copy in the copy module (to return the same instance being copied). + + :param type immutable_type: + The type to be considered immutable. + ''' + _IMMUTABLE_TYPES.add(immutable_type) + + # Fix it for the copy too! + import copy + copy._copy_dispatch[immutable_type] = copy._copy_immutable + + +#=================================================================================================== +# AsImmutable +#=================================================================================================== +def AsImmutable(value, return_str_if_not_expected=True): + ''' + Returns the given instance as a immutable object: + - Converts lists to tuples + - Converts dicts to ImmutableDicts + - Converts other objects to str + - Does not convert basic types (int/float/str/bool) + + :param object value: + The value to be returned as an immutable value + + :param bool return_str_if_not_expected: + If True, a string representation of the object will be returned if we're unable to match the + type as a known type (otherwise, an error is thrown if we cannot handle the passed type). + + :rtype: object + :returns: + Returns an immutable representation of the passed object + ''' + + # Micro-optimization (a 40% improvement on the AsImmutable function overall in a real case + # using sci20 processes). + # Checking the type of the class before going to the isinstance series and added + # SemanticAssociation as an immutable object. + value_class = value.__class__ + + if value_class in _IMMUTABLE_TYPES: + return value + + if value_class == dict: + return ImmutableDict((i, AsImmutable(j)) for i, j in value.items()) + + if value_class in (tuple, list): + return tuple(AsImmutable(i) for i in value) + + if value_class in (set, frozenset): + return frozenset(value) + + # Now, on to the isinstance series... + if isinstance(value, int): + return value + + if isinstance(value, (float, str, bytes, bool)): + return value + + if isinstance(value, dict): + return ImmutableDict((i, AsImmutable(j)) for i, j in value.items()) + + if isinstance(value, (tuple, list)): + return tuple(AsImmutable(i) for i in value) + + if isinstance(value, (set, frozenset)): + return frozenset(value) + + if return_str_if_not_expected: + return str(value) + + else: + raise RuntimeError('Cannot make %s immutable (not supported).' % value) + + +#=================================================================================================== +# ImmutableDict +#=================================================================================================== +class ImmutableDict(dict): + '''A hashable dict.''' + + def __init__(self, *args, **kwds): + dict.__init__(self, *args, **kwds) + + def __deepcopy__(self, memo): + return self # it's immutable, so, there's no real need to make any copy + + def __setitem__(self, key, value): + raise NotImplementedError("dict is immutable") + + def __delitem__(self, key): + raise NotImplementedError("dict is immutable") + + def clear(self): + raise NotImplementedError("dict is immutable") + + def setdefault(self, k, default=None): + raise NotImplementedError("dict is immutable") + + def popitem(self): + raise NotImplementedError("dict is immutable") + + def update(self, other): + raise NotImplementedError("dict is immutable") + + def __hash__(self): + if not hasattr(self, '_hash'): + # must be sorted (could give different results for dicts that should be the same + # if it's not). + self._hash = hash(tuple(sorted(self.items()))) + + return self._hash + + def AsMutable(self): + ''' + :rtype: this dict as a new dict that can be changed (without altering the state + of this immutable dict). + ''' + return dict(self.items()) + + def __reduce__(self): + """ + Making ImmutableDict work with newer versions of pickle protocol. + + Without this, it uses the default behavior on loading which tries to create an empty dict + and then set its items, which is not an allowed operation on ImmutableDict. + + In general, there are higher level functions to be redefined for pickle customization, but + for dict subclasses we need to define __reduce__ method. For more details of this special + case, see __reduce__ in the referenced docs (links below). + + See also: + - https://docs.python.org/2/library/pickle.html#pickling-and-unpickling-extension-types + - https://docs.python.org/3/library/pickle.html#pickling-class-instances + + :return tuple: + (Callable, tuple of arguments). See __reduce__ docs for more details. + """ + return (ImmutableDict, (list(self.items()),)) + + +#=================================================================================================== +# IdentityHashableRef +#=================================================================================================== +class IdentityHashableRef: + ''' + Represents a immutable reference to an object. + + Useful when is desired to use some mutable object as key in a dict or element in a set. + Any form of overwriting the `__hash__`, `__eq__`, or `__ne__` in the original object is ignored + when taking the hash or comparing the reference (for they to be equal they must point to the + same object and if equal they will have the same hash). + + Usage: + + ``` + foo = NonHashableWithFancyEquality() + ref_to_foo = IdentityHashableRef(foo) + + ref_to_foo() is foo # True + + aset = set() + aset.add(IdentityHashableRef(foo)) + IdentityHashableRef(foo) in aset # True + + adict = dict() + adict[IdentityHashableRef(foo)] = 7 + IdentityHashableRef(foo) in adict # True + ``` + ''' + + _SENTINEL = object() + + def __init__(self, original): + self._original = original + + def __eq__(self, other): + return self._original is getattr(other, '_original', self._SENTINEL) + + def __ne__(self, other): + return self._original is not getattr(other, '_original', self._SENTINEL) + + def __hash__(self): + return id(self._original) + + def __call__(self): + return self._original + + +RegisterAsImmutable(IdentityHashableRef) diff --git a/src/oop_ext/foundation/is_frozen.py b/src/oop_ext/foundation/is_frozen.py new file mode 100644 index 0000000..35583ee --- /dev/null +++ b/src/oop_ext/foundation/is_frozen.py @@ -0,0 +1,87 @@ + +import sys + +''' +frozen +Setup the sys.frozen attribute when the application is not in release mode. +This attribute is automatically set when the source code is in an executable. + +Use "IsFrozen" instead of "sys.frozen == False" because some libraries (pywin32) checks for the +attribute existence, not the value. +''' + +_is_frozen = hasattr(sys, 'frozen') and getattr(sys, 'frozen') + + +def IsFrozen(): + ''' + Returns true if the code is frozen, that is, the code is inside a generated executable. + + Frozen == False means the we are running the code using Python interpreter, usually associated with the code being + in development. + ''' + return _is_frozen + + +def SetIsFrozen(is_frozen): + ''' + Sets the is_frozen value manually, overriding the "calculated" value. + + :param bool is_frozen: + The new value for is_frozen. + + :returns bool: + Returns the original value, before the given value is set. + ''' + global _is_frozen + try: + return _is_frozen + finally: + _is_frozen = is_frozen + + +_is_development = not _is_frozen + + +def IsDevelopment(): + ''' + This function is used to indentify if we're in a development environment or production + environment. + + :return bool: + Returns True if we're in a development environment or False if we're in a production + environment. + + By default, the "development environment" is understood as not in frozen mode. However, be + careful not think that this will always be equivalent to 'not IsFrozen()'. This could also + return True in frozen environment, particularly when running tests on the executable. + + ..seealso:: SetIsDevelopment to understand why. + ''' + return _is_development + + +def SetIsDevelopment(is_development): + ''' + :param bool is_development: + The new is-development value, which is returned by ..seealso:: IsDevelopment. + + :return bool: + The previous value of is-development property. + + We wanted this method for the following reason: + Some methods we use in our codebase can make some checks/assertions that might be overly time-consuming to + have them running in production code. Therefore, the helper IsDevelopment is used to know if those methods + should run or not. However, due to the fact that we run tests on the executable and we want those methods + to be executed during testing, we need this method to make sure IsDevelopment returns true even in "frozen + environment". + + DevelopmentCheckType is an example of a method using IsDevelopment to be enabled. + + So always mind this difference and think. + ''' + global _is_development + try: + return _is_development + finally: + _is_development = is_development diff --git a/src/oop_ext/foundation/odict.py b/src/oop_ext/foundation/odict.py new file mode 100644 index 0000000..92fa543 --- /dev/null +++ b/src/oop_ext/foundation/odict.py @@ -0,0 +1,39 @@ + +import collections + + +class _OrderedDict(collections.OrderedDict): + + def insert(self, index, key, value, dict_setitem=dict.__setitem__): + """ + Convenience method to have same interface as `ruamel.ordereddict`, which as traditionally + used on Python 2. + """ + self[key] = value + # Determine which direction is cheaper to move items first. If new item is more to the left + # of center, move items to its left to first, otherwise it is cheaper to move items to + # right to last. + # + # Note that `move_to_end` is a O(1) operation that just swaps endpoints of underlying + # double linked list maintained by C-extension ordered dict. + if (len(self) - index) <= (len(self) // 2): + moved = [k for i, k in enumerate(self.keys()) if i >= index and k != key] + last = True + else: + moved = reversed([k for i, k in enumerate(self.keys()) if i < index or k == key]) + last = False + for k in moved: + self.move_to_end(k, last=last) + + def __delitem__(self, key): + if isinstance(key, slice): + # Properly deal with slices (based on order). + keys = list(self.keys()) + for k in keys[key]: + collections.OrderedDict.__delitem__(self, k) + + else: + collections.OrderedDict.__delitem__(self, key) + + +odict = _OrderedDict diff --git a/src/oop_ext/foundation/reraise.py b/src/oop_ext/foundation/reraise.py new file mode 100644 index 0000000..a41f2bf --- /dev/null +++ b/src/oop_ext/foundation/reraise.py @@ -0,0 +1,11 @@ + + +def Reraise(exception, message, separator='\n'): + """ + Forwards to a `raise exc from cause` statement. Kept alive for backwards compatibility + (`separator` argument only kept for this reason). + """ + # Important: Don't create a local variable for the new exception otherwise we'll get a + # cyclic reference between the exception and its traceback, meaning the traceback will + # keep all frames (and their contents) alive. + raise type(exception)(message) from exception diff --git a/src/oop_ext/foundation/singleton.py b/src/oop_ext/foundation/singleton.py new file mode 100644 index 0000000..88778e5 --- /dev/null +++ b/src/oop_ext/foundation/singleton.py @@ -0,0 +1,287 @@ + +import threading + + +#=================================================================================================== +# SingletonError +#=================================================================================================== +class SingletonError(RuntimeError): + ''' + Base class for all Singleton-related exceptions. + ''' + + +#=================================================================================================== +# SingletonAlreadySetError +#=================================================================================================== +class SingletonAlreadySetError(SingletonError): + ''' + Trying to set a singleton when the class already have one defined. + ''' + + +#=================================================================================================== +# SingletonNotSetError +#=================================================================================================== +class SingletonNotSetError(SingletonError): + ''' + Trying to clear a singleton when there's none defined. + ''' + + +#=================================================================================================== +# PushPopSingletonError +#=================================================================================================== +class PushPopSingletonError(SingletonError): + ''' + Trying to set a singleton between a PushSingleton/PopSingleton calls. + ''' + + +#=================================================================================================== +# Singleton +#=================================================================================================== +class Singleton: + ''' + Base class for singletons. + + A Singleton class should have a unique instance during the lifetime of the application. Besides + the functionality of obtaining the singleton instance, this class also provides methods to push + and pop singletons, useful for testing, where you push a singleton into a known state during + setUp and pops it back during tearDown + ''' + + # name of the attribute that holds the stack of singletons + __singleton_stack_start_index = 0 + __lock = threading.RLock() + + _singleton_classes = set() + + @staticmethod + def ResetDefaultSingletonInstances(): + ''' + This singleton class is intended to be used in tests with the push / pop protocol. However some singleton + dependencies might be hidden away from the test creator (or even be introduced after the test creation) making + easy for a code to access and change the default class singleton (for example registering on its callbacks). + + This code is intended to clear any change made in such default singletons. Pushed singletons will not be cleared + because if a test has correctly pushed it singleton, it is reasonable to assume that the test will correctly + clean (pop) it. + + TODO: ETK-1235 As soon as the classes with ResetInstance are moved to do not be a singleton, then this method + can be removed. + ''' + for cls in Singleton._singleton_classes: + if cls._UsingDefaultSingleton(): + instance = cls.GetSingleton() + instance.ResetInstance() + + @classmethod + def GetSingleton(cls): + ''' + :rtype: Singleton + :returns: + Returns the current singleton instance. + + .. note:: This function is thread-safe, but all the other methods (such as SetSingleton, + PushSingleton, PopSingleton, etc) are not (which should be Ok as those are mostly + test-related, as singletons shouldn't really be changed after the application is up + especially on multi-threaded environments). + ''' + Singleton._singleton_classes.add(cls) + + try: + # Make common case faster. + return cls.__singleton_singleton_stack__[-1] + except (AttributeError, IndexError): + with cls.__lock: + # Only lock if the 'fast path' did not work. + stack = cls._ObtainStack() + + if not stack: # Faster than doing len(stack) == 0 + return cls.SetSingleton(None) + + return stack[-1] + + @classmethod + def SetSingleton(cls, instance): + ''' + Sets the current singleton. + + :param Singleton instance: + The Singleton to pass as parameter + + :rtype: Singleton + :returns: + The singleton passed as parameter. + + @raise PushPopSingletonError + @raise SingletonAlreadySetError + ''' + stack = cls._ObtainStack() + + # Error if we trying to use SetSingleton between a Push/Pop + if len(stack) != cls.__singleton_stack_start_index: + raise PushPopSingletonError('SetSingleton can not be called between a Push/Pop pair.') + + if len(stack) > 0: + raise SingletonAlreadySetError('SetSingleton can only be called when there is no singleton set.') + + # Obtain default instance (if needed) + if instance is None: + instance = cls.CreateDefaultSingleton() + + # Set the stack[0] as the singleton + if len(stack) == 0: + stack.append(instance) + cls.__singleton_stack_start_index = 1 + else: + stack[0] = instance + + assert cls.__singleton_stack_start_index == 1 + + return instance + + @classmethod + def _UsingDefaultSingleton(cls): + ''' + Checks if the current singleton instance is the default instance. + + :rtype: bool + :returns: + True if the current singleton instance is the default created instance. Returns False if the current instance + is a pushed singleton or if no instance is currently set + ''' + stack = cls._ObtainStack() + has_pushed = len(stack) != cls.__singleton_stack_start_index + has_singleton = cls.HasSingleton() + + return has_singleton and not has_pushed + + def ResetInstance(self): + ''' + Restore the instance original configuration. Singleton classes should not have a internal state to reset + (as described in issue ETK-1235), so subclasses that implement this method are strong candidates to be refactored + to do not be a singleton. + + This method is used to avoid interference between tests while ETK-1235 is not implemented. + ''' + pass + + @classmethod + def ClearSingleton(cls): + ''' + Clears the current singleton + ''' + stack = cls._ObtainStack() + + # Error if we trying to use ClearSingleton between a Push/Pop + if len(stack) != cls.__singleton_stack_start_index: + raise PushPopSingletonError('ClearSingleton can not be called between a Push/Pop pair.') + + if not stack: + raise SingletonNotSetError('ClearSingleton can only be called when THERE IS singleton set.') + + del stack[0] + cls.__singleton_stack_start_index = 0 + + @classmethod + def HasSingleton(cls): + ''' + Do we have any singleton set? + + :rtype: bool + :returns: + True if there's a singleton set. + ''' + stack = cls._ObtainStack() + return len(stack) > 0 + + @classmethod + def CreateDefaultSingleton(cls): + ''' + Creates the default singleton instance, that will be used when no singleton has been installed. + By default, tries to create the class without constructor. + + :rtype: Singleton + :returns: + an instance of the singleton subclass + ''' + return cls() + + # Push/Pop ------------------------------------------------------------------------------------- + + @classmethod + def PushSingleton(cls, instance=None): + ''' + Pushes the given singleton to the top of the stack. The previous singleton will be restored + when PopSingleton is called. + + :param Singleton instance: + The singleton to install as the current one. If not given, a new singleton default + is created. + + :rtype: Singleton + :returns: + The current singleton. + ''' + if instance is None: + instance = cls.CreateDefaultSingleton() + stack = cls._ObtainStack() + +# DEBUG CODE +# print '%s.PushSingleton' % cls.__name__, map(id, stack) +# if len(stack) > 1: +# from coilib50.debug import PrintTrace +# PrintTrace(count=5) + + stack.append(instance) + return instance + + @classmethod + def PopSingleton(cls): + ''' + Restores the singleton that was the current before the last PushSingleton. + + :rtype: Singleton + :returns: + Return the removed singleton. + ''' + stack = cls._ObtainStack() + +# DEBUG CODE +# print '%s.PopSingleton' % cls.__name__, map(id, stack) +# if len(stack) > 1: +# from coilib50.debug import PrintTrace +# PrintTrace(count=5) + + if len(stack) == cls.__singleton_stack_start_index: + raise PushPopSingletonError('PopSingleton called without a pair PushSingleton call') + + return cls._ObtainStack().pop(-1) + + @classmethod + def _ObtainStack(cls): + ''' + Obtains the stack of singletons. + + :rtype: list + :returns: + The singleton stack. + ''' + try: + return cls.__singleton_singleton_stack__ + except AttributeError: + assert cls is not Singleton, 'This method can only be called from a Singleton subclass.' + stack = [] + cls.__singleton_singleton_stack__ = stack + return stack + + @classmethod + def GetStackCount(cls): + ''' + @return int: + The number of elements added int the stack using PushSingleton. + ''' + stack = cls._ObtainStack() + return len(stack) - cls.__singleton_stack_start_index diff --git a/src/oop_ext/foundation/types_.py b/src/oop_ext/foundation/types_.py new file mode 100644 index 0000000..c835ddf --- /dev/null +++ b/src/oop_ext/foundation/types_.py @@ -0,0 +1,142 @@ +''' +Extensions to python native types. +''' + +#=================================================================================================== +# Method +#=================================================================================================== +class Method: + ''' + This class is an 'organization' class, so that subclasses are considered as methods + (and its __call__ method is checked for the parameters) + ''' + + +#=================================================================================================== +# Null +#=================================================================================================== +class Null: + ''' + This is a sample implementation of the 'Null Object' design pattern. + + Roughly, the goal with Null objects is to provide an 'intelligent' + replacement for the often used primitive data type None in Python or + Null (or Null pointers) in other languages. These are used for many + purposes including the important case where one member of some group + of otherwise similar elements is special for whatever reason. Most + often this results in conditional statements to distinguish between + ordinary elements and the primitive Null value. + + Among the advantages of using Null objects are the following: + + - Superfluous conditional statements can be avoided + by providing a first class object alternative for + the primitive value None. + + - Code readability is improved. + + - Null objects can act as a placeholder for objects + with behaviour that is not yet implemented. + + - Null objects can be replaced for any other class. + + - Null objects are very predictable at what they do. + + To cope with the disadvantage of creating large numbers of passive + objects that do nothing but occupy memory space Null objects are + often combined with the Singleton pattern. + + For more information use any internet search engine and look for + combinations of these words: Null, object, design and pattern. + + Dinu C. Gherman, + August 2001 + + --- + + A class for implementing Null objects. + + This class ignores all parameters passed when constructing or + calling instances and traps all attribute and method requests. + Instances of it always (and reliably) do 'nothing'. + + The code might benefit from implementing some further special + Python methods depending on the context in which its instances + are used. Especially when comparing and coercing Null objects + the respective methods' implementation will depend very much + on the environment and, hence, these special methods are not + provided here. + ''' + + # object constructing + + def __init__(self, *_args, **_kwargs): + "Ignore parameters." + # Setting the name of what's gotten (so that __name__ is properly preserved). + self.__dict__['_Null__name__'] = 'Null' + return None + + def __call__(self, *_args, **_kwargs): + "Ignore method calls." + return self + + def __getattr__(self, mname): + "Ignore attribute requests." + if mname == '__getnewargs__': + raise AttributeError('No support for that (pickle causes error if it returns self in this case.)') + + if mname == '__name__': + return self.__dict__['_Null__name__'] + + return self + + def __setattr__(self, _name, _value): + "Ignore attribute setting." + return self + + def __delattr__(self, _name): + "Ignore deleting attributes." + return self + + def __enter__(self): + return self + + def __exit__(self, *args, **kwargs): + return self + + def __repr__(self): + "Return a string representation." + return "" + + def __str__(self): + "Convert to a string and return it." + return "Null" + + def __bool__(self): + "Null objects are always false" + return False + + def __nonzero__(self): + # Py 2 compatibility + return self.__bool__() + + # iter + + def __iter__(self): + "I will stop it in the first iteration" + return self + + def __next__(self): + "Stop the iteration right now" + raise StopIteration() + + def next(self): + # Py 2 compatibility + return self.__next__() + + def __eq__(self, o): + "It is just equal to another Null object." + return self.__class__ == o.__class__ + + +NULL = Null() # Create a default instance to be used. diff --git a/src/oop_ext/foundation/weak_ref.py b/src/oop_ext/foundation/weak_ref.py new file mode 100644 index 0000000..a2b0e65 --- /dev/null +++ b/src/oop_ext/foundation/weak_ref.py @@ -0,0 +1,452 @@ + +import inspect +import weakref +from types import LambdaType, MethodType + +from oop_ext.foundation.decorators import Implements + + +#=================================================================================================== +# WeakList +#=================================================================================================== +class WeakList: + ''' + The weak list is a list that will only keep weak-references to objects passed to it. + + When iterating the actual objects are used, but internally, only weakrefs are kept. + + It does not contain the whole list interface (but can be extended as needed). + + IMPORTANT: if you got here and need to implement a new feature or fix a bug, + consider replacing this implementation by this one instead: + https://github.com/apieum/weakreflist + ''' + + def __init__(self, initlist=None): + self.data = [] + + if initlist is not None: + for x in initlist: + self.append(x) + + @Implements(list.append) + def append(self, item): + self.data.append(GetWeakRef(item)) + + @Implements(list.extend) + def extend(self, lst): + for o in lst: + self.append(o) + + def __iter__(self): + # iterate in a copy + for ref in self.data[:]: + d = ref() + if d is None: + self.data.remove(ref) + else: + yield d + + def remove(self, item): + ''' + Remove first occurrence of a value. + + It differs from the normal version because it will not raise an exception if the + item is not found (because it may be garbage-collected already). + + :param object item: + The object to be removed. + ''' + # iterate in a copy + for ref in self.data[:]: + d = ref() + + if d is None: + self.data.remove(ref) + + elif d == item: + self.data.remove(ref) + break + + def __len__(self): + i = 0 + for _k in self: # we make an iteration to remove dead references... + i += 1 + return i + + def __delitem__(self, i): + self.data.__delitem__(i) + + def __getitem__(self, i): + if isinstance(i, slice): + slice_ = [] + for ref in self.data[i.start:i.stop:i.step]: + d = ref() + if d is not None: + slice_.append(d) + + return WeakList(slice_) + else: + return self.data[i]() + + def __setitem__(self, i, item): + ''' + Set a weakref of item on the ith position + ''' + self.data[i] = GetWeakRef(item) + + def __str__(self): + return '\n'.join(str(x) for x in self) + + +#=================================================================================================== +# WeakMethodRef +#=================================================================================================== +class WeakMethodRef: + ''' + Weak reference to bound-methods. This allows the client to hold a bound method + while allowing GC to work. + + Based on recipe from Python Cookbook, page 191. Differs by only working on + boundmethods and returning a true boundmethod in the __call__() function. + + Keeps a reference to an object but doesn't prevent that object from being garbage collected. + ''' + + __slots__ = [ + '_obj', + '_func', + '_class', + '_hash', + '__weakref__', + ] + + def __init__(self, method): + try: + if method.__self__ is not None: + # bound method + self._obj = weakref.ref(method.__self__) + else: + # unbound method + self._obj = None + self._func = method.__func__ + self._class = method.__self__.__class__ + except AttributeError: + # not a method -- a callable: create a strong reference (the CallbackWrapper + # is depending on this behaviour... is it correct?) + self._obj = None + self._func = method + self._class = None + + def __call__(self): + ''' + Return a new bound-method like the original, or the original function if refers just to + a function or unbound method. + + @return: + None if the original object doesn't exist anymore. + ''' + if self.is_dead(): + return None + if self._obj is not None: + # we have an instance: return a bound method + return MethodType(self._func, self._obj()) + else: + # we don't have an instance: return just the function + return self._func + + def is_dead(self): + '''Returns True if the referenced callable was a bound method and + the instance no longer exists. Otherwise, return False. + ''' + return self._obj is not None and self._obj() is None + + def __eq__(self, other): + try: + return type(self) is type(other) and self() == other() + except: + return False + + def __ne__(self, other): + return not self == other + + def __hash__(self): + if not hasattr(self, '_hash'): + # The hash should be immutable (must be calculated once and never changed -- otherwise + # we won't be able to get it when the object dies) + self._hash = hash(WeakMethodRef.__call__(self)) + + return self._hash + + def __repr__(self): + func_name = getattr(self._func, '__name__', str(self._func)) + if self._obj is not None: + obj = self._obj() + if obj is None: + obj_str = '' + else: + obj_str = '%X' % id(obj) + msg = '' + return msg % (self._class.__name__, func_name, obj_str) + else: + return '' % func_name + + +#=================================================================================================== +# WeakMethodProxy +#=================================================================================================== +class WeakMethodProxy(WeakMethodRef): + ''' + Like ref, but calling it will cause the referent method to be called with the same + arguments. If the referent's object no longer lives, ReferenceError is raised. + ''' + + def GetWrappedFunction(self): + return WeakMethodRef.__call__(self) + + def __call__(self, *args, **kwargs): + func = WeakMethodRef.__call__(self) + if func is None: + raise ReferenceError('Object is dead. Was of class: {}'.format(self._class)) + else: + return func(*args, **kwargs) + + def __eq__(self, other): + try: + func1 = WeakMethodRef.__call__(self) + func2 = WeakMethodRef.__call__(other) + return type(self) == type(other) and func1 == func2 + except: + return False + + +#=================================================================================================== +# WeakSet +#=================================================================================================== +class WeakSet: + ''' + Just like `weakref.WeakSet`, but supports adding methods (the standard `weakref.WeakSet` can't + add methods, this feature comes from `oop_ext.foundation.weak_ref.GetWeakRef`, see `testWeakSet2`). + + It does not contain the whole set interface (but can be extended as needed). + + ..see:: oop_ext.foundation.weak_ref.GetWeakRef + ..see:: weakref.WeakSet + ''' + + def __init__(self): + self.data = set() + + def add(self, item): + self.data.add(GetWeakRef(item)) + + def clear(self): + self.data.clear() + + def __iter__(self): + # iterate in a copy + for ref in self.data.copy(): + d = ref() + if d is None: + self.data.remove(ref) + else: + yield d + + def remove(self, item): + ''' + Remove an item from the available data. + + :param object item: + The object to be removed. + ''' + self.data.remove(GetWeakRef(item)) + + def union(self, another_set): + result = WeakSet() + result.data = self.data.copy() + for i in another_set: + result.add(i) + return result + + def __sub__(self, another_set): + result = WeakSet() + result.data = self.data.copy() + for i in another_set: + result.discard(i) + return result + + def __rsub__(self, another_set): + result = another_set.copy() + for i in self: + result.discard(i) + return result + + def discard(self, item): + try: + self.remove(item) + except KeyError: + pass + + def __len__(self): + i = 0 + for _k in self: # we make an iteration to remove dead references... + i += 1 + return i + + def __str__(self): + return '\n'.join(str(x) for x in self) + + +#=================================================================================================== +# IsWeakProxy +#=================================================================================================== +def IsWeakProxy(obj): + ''' + Returns whether the given object is a weak-proxy + ''' + return isinstance(obj, (weakref.ProxyType, WeakMethodProxy)) + + +#=================================================================================================== +# IsWeakRef +#=================================================================================================== +def IsWeakRef(obj): + ''' + Returns wheter ths given object is a weak-reference. + ''' + return isinstance(obj, (weakref.ReferenceType, WeakMethodRef)) and not isinstance(obj, WeakMethodProxy) + + +#=================================================================================================== +# IsWeakObj +#=================================================================================================== +def IsWeakObj(obj): + ''' + Returns whether the given object is a weak object. Either a weak-proxy or a weak-reference. + + :param obj: The object that may be a weak reference or proxy + :return bool: True if it is a proxy or a weak reference. + ''' + return IsWeakProxy(obj) or IsWeakRef(obj) + + +#=================================================================================================== +# GetRealObj +#=================================================================================================== +def GetRealObj(obj): + ''' + Returns the real-object from a weakref, or the object itself otherwise. + ''' + if IsWeakRef(obj): + return obj() + if isinstance(obj, LambdaType): + return obj() + return obj + + +#=================================================================================================== +# GetWeakProxy +#=================================================================================================== +def GetWeakProxy(obj): + ''' + :param obj: This is the object we want to get as a proxy + :return: + Returns the object as a proxy (if it is still not already a proxy or a weak ref, in which case the passed object + is returned itself) + ''' + if obj is None: + return None + + if not IsWeakProxy(obj): + + if IsWeakRef(obj): + obj = obj() + + # for methods we cannot create regular weak-refs + if inspect.ismethod(obj): + return WeakMethodProxy(obj) + + return weakref.proxy(obj) + + return obj + + +# Keep the same lambda for weak-refs (to be reused among all places that use GetWeakRef(None) +_EMPTY_LAMBDA = lambda:None + + +#=================================================================================================== +# GetWeakRef +#=================================================================================================== +def GetWeakRef(obj): + ''' + :type obj: this is the object we want to get as a weak ref + :param obj: + @return the object as a proxy (if it is still not already a proxy or a weak ref, in which case the passed + object is returned itself) + ''' + if obj is None: + return _EMPTY_LAMBDA + + if IsWeakProxy(obj): + raise RuntimeError('Unable to get weak ref for proxy.') + + if not IsWeakRef(obj): + + # for methods we cannot create regular weak-refs + if inspect.ismethod(obj): + return WeakMethodRef(obj) + + return weakref.ref(obj) + return obj + + +#=================================================================================================== +# IsSame +#=================================================================================================== +def IsSame(o1, o2): + ''' + This checks for the identity even if one of the parameters is a weak reference + + :param o1: + first object to compare + + :param o2: + second object to compare + + @raise + RuntimeError if both of the passed parameters are weak references + ''' + # get rid of weak refs (we only need special treatment for proxys) + if IsWeakRef(o1): + o1 = o1() + if IsWeakRef(o2): + o2 = o2() + + # simple case (no weak objects) + if not IsWeakObj(o1) and not IsWeakObj(o2): + return o1 is o2 + + # all weak proxys + if IsWeakProxy(o1) and IsWeakProxy(o2): + if not o1 == o2: + # if they are not equal, we know they're not the same + return False + + # but we cannot say anything if they are the same if they are equal + raise ReferenceError('Cannot check if object is same if both arguments passed are weak objects') + + # one is weak and the other is not + if IsWeakObj(o1): + weak = o1 + original = o2 + else: + weak = o2 + original = o1 + + weaks = weakref.getweakrefs(original) + for w in weaks: + if w is weak: # check the weak object identity + return True + + return False diff --git a/src/oop_ext/interface/__init__.py b/src/oop_ext/interface/__init__.py new file mode 100644 index 0000000..f24cfcd --- /dev/null +++ b/src/oop_ext/interface/__init__.py @@ -0,0 +1,45 @@ + +from ._adaptable_interface import IAdaptable +from ._interface import ( + AssertDeclaresInterface, AssertImplements, AssertImplementsFullChecking, Attribute, + BadImplementationError, CacheInterfaceAttrs, DeclareClassImplements, GetImplementedInterfaces, + ImplementsInterface, Interface, InterfaceError, InterfaceImplementationMetaClass, + InterfaceImplementorStub, IsImplementation, IsImplementationOfAny, ReadOnlyAttribute) + +''' + Interfaces module. + + A Interface describes a behaviour that some objects must implement. + + To declare a interface, just subclass from Interface:: + + class IFoo(interface.Interface): + ... + + To create a class that implements that interface, use interface.Implements: + + class Foo(object): + interface.Implements(IFoo) + + If Foo doesn't implement some method from IFoo, an exception is raised at class creation time. +''' + +__all__ = [ + 'AssertDeclaresInterface', + 'AssertImplements', + 'AssertImplementsFullChecking', + 'Attribute', + 'BadImplementationError', + 'CacheInterfaceAttrs', + 'GetImplementedInterfaces', + 'IAdaptable', + 'Interface', + 'InterfaceError', + 'InterfaceImplementationMetaClass', + 'InterfaceImplementorStub', + 'IsImplementation', + 'ReadOnlyAttribute', + 'ImplementsInterface', + 'DeclareClassImplements', + 'IsImplementationOfAny', +] diff --git a/src/oop_ext/interface/_adaptable_interface.py b/src/oop_ext/interface/_adaptable_interface.py new file mode 100644 index 0000000..1f112b6 --- /dev/null +++ b/src/oop_ext/interface/_adaptable_interface.py @@ -0,0 +1,29 @@ + +from ._interface import Interface + + +#=================================================================================================== +# IAdaptable +#=================================================================================================== +class IAdaptable(Interface): + ''' + An interface for an object that is adaptable. + + Adaptable objects can be queried about interfaces they adapt to (to which they + may respond or not). + + For example: + + a = [some IAdaptable]; + x = a.GetAdapter(IFoo); + if x is not None: + [do IFoo things with x] + ''' + + def GetAdapter(self, interface_class): + ''' + :type interface_class: this is the interface for which an adaptation is required + :param interface_class: + :rtype: an object implementing the required interface or None if this object cannot + adapt to that interface. + ''' diff --git a/src/oop_ext/interface/_interface.py b/src/oop_ext/interface/_interface.py new file mode 100644 index 0000000..7ec5178 --- /dev/null +++ b/src/oop_ext/interface/_interface.py @@ -0,0 +1,967 @@ +''' +This module provides a basic interface concept. + +To use, create an interface: + + +class IMyCalculator(Interface): + + def Sum(self, *values): + ... + + +Have classes implement an interface: + +class MyCalculatorImpl(object): + + ImplementsInterface(IMyCalculator) + + ... + + +Then, to check an interface: + + +impl = MyCalculatorImpl() + +if IsImplementation(impl, IMyCalculator): + ... + + +Or if it *needs* to check an interface: + +AssertImplements(impl, IMyCalculator) + + +''' + +import inspect +import sys + +from oop_ext.foundation.decorators import Deprecated +from oop_ext.foundation.is_frozen import IsDevelopment +from oop_ext.foundation.reraise import Reraise +from oop_ext.foundation.types_ import Method + + +#=================================================================================================== +# InterfaceError +#=================================================================================================== +class InterfaceError(RuntimeError): + pass + + +#=================================================================================================== +# BadImplementationError +#=================================================================================================== +class BadImplementationError(InterfaceError): + pass + + +#=================================================================================================== +# InterfaceImplementationMetaClass +#=================================================================================================== +class InterfaceImplementationMetaClass(type): + + def __new__(cls, name, bases, dct): + C = type.__new__(cls, name, bases, dct) + if IsDevelopment(): # Only doing check in dev mode. + for I in dct.get('__implements__', []): + # Will do full checking this first time, and also cache the results + AssertImplements(C, I) + return C + + +#=================================================================================================== +# InterfaceImplementorStub +#=================================================================================================== +class InterfaceImplementorStub: + ''' + A helper for acting as a stub for some object (in this way, we're only able to access + attributes declared directly in the interface. + + It forwards the calls to the actual implementor (the wrapped object) + ''' + + def __init__(self, wrapped, implemented_interface): + self.__wrapped = wrapped + self.__implemented_interface = implemented_interface + + self.__interface_methods, self.__attrs = \ + cache_interface_attrs.GetInterfaceMethodsAndAttrs(implemented_interface) + + def GetWrappedFromImplementorStub(self): + ''' + Really big and awkward name because we don't want name-clashes + ''' + return self.__wrapped + + def __getattr__(self, attr): + if attr not in self.__attrs and attr not in self.__interface_methods: + raise AttributeError("Error. The interface {} does not have the attribute '{}' declared.".format(self.__implemented_interface, attr)) + return getattr(self.__wrapped, attr) + + def __getitem__(self, *args, **kwargs): + if '__getitem__' not in self.__interface_methods: + raise AttributeError("Error. The interface {} does not have the attribute '{}' declared.".format(self.__implemented_interface, '__getitem__')) + return self.__wrapped.__getitem__(*args, **kwargs) + + def __setitem__(self, *args, **kwargs): + if '__setitem__' not in self.__interface_methods: + raise AttributeError("Error. The interface {} does not have the attribute '{}' declared.".format(self.__implemented_interface, '__setitem__')) + return self.__wrapped.__setitem__(*args, **kwargs) + + def __repr__(self): + return '' % self.__wrapped + + def __call__(self, *args, **kwargs): + if '__call__' not in self.__interface_methods: + raise AttributeError("Error. The interface {} does not have the attribute '{}' declared.".format(self.__implemented_interface, '__call__')) + return self.__wrapped.__call__(*args, **kwargs) + + +#=================================================================================================== +# Interface +#=================================================================================================== +class Interface: + '''Base class for interfaces. + + A interface describes a behavior that some objects must implement. + ''' + + # : instance to check if we are receiving an argument during __new__ + _SENTINEL = [] + + def __new__(cls, class_=_SENTINEL): + # if no class is given, raise InterfaceError('trying to instantiate interface') + # check if class_or_object implements this interface + from ._adaptable_interface import IAdaptable + + if class_ is cls._SENTINEL: + raise InterfaceError('Can\'t instantiate Interface.') + else: + if isinstance(class_, type): + # We're doing something as Interface(InterfaceImpl) -- not instancing + _AssertImplementsFullChecking(class_, cls, check_attr=False) + return class_ + elif isinstance(class_, InterfaceImplementorStub): + return class_ + else: + implemented_interfaces = GetImplementedInterfaces(class_) + + if cls in implemented_interfaces: + return InterfaceImplementorStub(class_, cls) + + elif IAdaptable in implemented_interfaces: + adapter = class_.GetAdapter(cls) + if adapter is not None: + return InterfaceImplementorStub(adapter, cls) + + # We're doing something as Interface(InterfaceImpl()) -- instancing + _AssertImplementsFullChecking(class_, cls, check_attr=True) + return InterfaceImplementorStub(class_, cls) + + +#=================================================================================================== +# _GetClassForInterfaceChecking +#=================================================================================================== +def _GetClassForInterfaceChecking(class_or_instance): + if _IsClass(class_or_instance): + return class_or_instance # is class + elif isinstance(class_or_instance, InterfaceImplementorStub): + return _GetClassForInterfaceChecking(class_or_instance.GetWrappedFromImplementorStub()) + + return class_or_instance.__class__ # is instance + + +def _IsClass(obj): + return isinstance(obj, type) + + +#=================================================================================================== +# IsImplementation +#=================================================================================================== +def IsImplementation(class_or_instance, interface): + ''' + :type class_or_instance: type or classobj or object + + :type interface: Type[Interface] + + :rtype: bool + + :see: :py:func:`.AssertImplements` + ''' + try: + is_interface = issubclass(interface, Interface) + except TypeError as e: + Reraise(e, "interface={} (type {})".format(interface, type(interface))) + + if not is_interface: + raise InterfaceError( + 'To check against an interface, an interface is required (received: %s -- mro:%s)' % + (interface, interface.__mro__) + ) + + class_ = _GetClassForInterfaceChecking(class_or_instance) + + is_implementation, _reason = _CheckIfClassImplements(class_, interface) + + # Check older revisions of this file for helper debug code in this place. + + return is_implementation + + +#=================================================================================================== +# IsImplementationOfAny +#=================================================================================================== +def IsImplementationOfAny(class_or_instance, interfaces): + ''' + Check if the class or instance implements any of the given interfaces + + :type class_or_instance: type or classobj or object + + :type interfaces: list(Interface) + + :rtype: bool + + :see: :py:func:`.IsImplementation` + ''' + for interface in interfaces: + if IsImplementation(class_or_instance, interface): + return True + + return False + + +#=================================================================================================== +# AssertImplements +#=================================================================================================== +def AssertImplements(class_or_instance, interface): + ''' + If given a class, will try to match the class against a given interface. If given an object + (instance), will try to match the class of the given object. + + NOTE: The Interface must have been explicitly declared through :py:func:`ImplementsInterface`. + + :type class_or_instance: type or classobj or object + + :type interface: Interface + + :raises BadImplementationError: + If the object's class does not implement the given :arg interface:. + + :raises InterfaceError: + In case the :arg interface: object is not really an interface. + + .. attention:: Caching + Will do a full checking only once, and then cache the result for the given class. + + .. attention:: Runtime modifications + Runtime modifications in the instances (appending methods or attributed) won't affect + implementation checking (after the first check), because what is really being tested is the + class. + ''' + class_ = _GetClassForInterfaceChecking(class_or_instance) + + is_implementation, reason = _CheckIfClassImplements(class_, interface) + + assert is_implementation, reason + + +#=================================================================================================== +# __ResultsCache +#=================================================================================================== +class __ResultsCache: + + def __init__(self): + self._cache = {} + + def SetResult(self, args, result): + self._cache[args] = result + + def GetResult(self, args): + return self._cache.get(args, None) + + def ForgetResult(self, args): + self._cache.pop(args, None) + + +__ImplementsCache = __ResultsCache() + +__ImplementedInterfacesCache = __ResultsCache() + + +#=================================================================================================== +# _CheckIfClassImplements +#=================================================================================================== +def _CheckIfClassImplements(class_, interface): + ''' + :type class_: type or classobj + :param class_: + A class type (NOT an instance of the class). + + :type interface: Interface + + :rtype: (bool, str) or (bool, None) + :returns: + (is_implementation, reason) + If the class doesn't implement the given interface, will return False, and a message stating + the reason (missing methods, etc.). The message may be None. + ''' + assert _IsClass(class_) + + # Using explicit memoization, because we need to forget some values at some times + cache = __ImplementsCache + + cached_result = cache.GetResult((class_, interface)) + if cached_result is not None: + return cached_result + + is_implementation = True + reason = None + + # Exception: Null implements every Interface (useful for Null Object Pattern and for testing) + from oop_ext.foundation.types_ import Null + + if not issubclass(class_, Null): + if _IsInterfaceDeclared(class_, interface): + # It is required to explicitly declare that the class implements the interface. + + # Since this will only run *once*, a full check is also done here to ensure it is really + # implementing. + try: + _AssertImplementsFullChecking(class_, interface, check_attr=False) + except BadImplementationError as e: + is_implementation = False + from oop_ext.foundation.exceptions import ExceptionToUnicode + reason = ExceptionToUnicode(e) + else: + is_implementation = False + reason = 'The class {} does not declare that it implements the interface {}.'.format( + class_, interface) + + result = (is_implementation, reason) + cache.SetResult((class_, interface), result) + return result + + +#=================================================================================================== +# _IsImplementationFullChecking +#=================================================================================================== +def _IsImplementationFullChecking(class_or_instance, interface): + ''' + Used internally by Attribute. + + :see: :py:func:`._AssertImplementsFullChecking` + :type class_or_instance: type or instance + :param class_or_instance: + Class or instance to check + + :param Interface interface: + Interface to check + + :rtype: bool + :returns: + If it implements the interface + ''' + try: + _AssertImplementsFullChecking(class_or_instance, interface) + except BadImplementationError: + return False + else: + return True + + +#=================================================================================================== +# Attribute +#=================================================================================================== +class Attribute: + ''' + ''' + _do_not_check_instance = object() + + def __init__(self, attribute_type, instance=_do_not_check_instance): + ''' + :param type attribute_type: + Will check the attribute type in the implementation against this type. + Checks if the attribute is a direct instance of attribute_type, or of it implements it. + + :param object instance: + If passed, will check for *equality* against this instance. The default is to not check + for equality. + ''' + self.attribute_type = attribute_type + self.instance = instance + + def Match(self, attribute): + ''' + :param object attribute: + Object that will be compared to see if it matches the expected interface. + + :rtype: (bool, str) + :returns: + If the given object implements or inherits from the interface expected by this + attribute, will return (True, None), otherwise will return (False, message), where + message is an error message of why there was a mismatch (may be None also). + ''' + msg = None + + if isinstance(attribute, self.attribute_type): + return (True, None) + + if self.instance is not self._do_not_check_instance: + if self.instance == attribute: + return (True, None) + else: + return ( + False, + 'The instance ({}) does not match the expected one ({}).'.format( + self.instance, attribute) + ) + + try: + if _IsImplementationFullChecking(attribute, self.attribute_type): + return (True, msg) + except InterfaceError as exception_msg: + # Necessary because whenever a value is compared to an interface it does not inherits + # from, IsImplementation raises an InterfaceError. In this context, an error like that + # will mean that our candidate attribute is in fact not matching the interface, so we + # capture this error and return False. + msg = exception_msg + + return (False, None) + + +#=================================================================================================== +# ReadOnlyAttribute +#=================================================================================================== +class ReadOnlyAttribute(Attribute): + ''' + This is an attribute that should be treated as read-only (note that usually this means that + the related property should be also declared as read-only). + ''' + + +#=================================================================================================== +# CacheInterfaceAttrs +#=================================================================================================== +class CacheInterfaceAttrs: + ''' + Cache for holding the attrs for a given interface (separated by attrs and methods). + ''' + + _ATTRIBUTE_CLASSES = (Attribute, ReadOnlyAttribute) + INTERFACE_OWN_METHODS = {i for i in dir(Interface) if inspect.isfunction(getattr(Interface, i))} + FUTURE_OBJECT_ATTRS = ('next', '__long__', '__nonzero__', '__unicode__', '__native__') + + @classmethod + def RegisterAttributeClass(cls, attribute_class): + ''' + Registers a class to be considered as an attribute class. + + This provides a way of extending the Interface behavior by declaring new attributes classes + such as ScalarAttribute. + + :param Attribute attribute_class: + An Attribute class to register as an attribute class. + + :return set(Attribute): + Returns a set with all the current attribute classes. + ''' + result = set(cls._ATTRIBUTE_CLASSES) + result.add(attribute_class) + cls._ATTRIBUTE_CLASSES = tuple(result) + return result + + def __GetInterfaceMethodsAndAttrs(self, interface): + ''' + :type interface: the interface from where the methods and attributes should be gotten. + :param interface: + :rtype: the interface methods and attributes available in a given interface. + ''' + all_attrs = dir(interface) + + interface_methods = dict() + interface_attrs = dict() + interface_vars = vars(interface) + + # Python 3+ changed how functions are represented, so it isn't possible anymore to + # determine if a function is a method BEFORE it is bound to an object. + # For this reason, it is necessary to also search by functions on Python and to filter out + # functions like `__new__`, which are part of `Interface` class implementation and not part + # expected interface. + for attr in all_attrs: + # If name inherited from `Interface`, check if isn't in list of reserved names + if attr not in interface_vars: + if attr in self.INTERFACE_OWN_METHODS: + continue + + val = getattr(interface, attr) + + if type(val) in self._ATTRIBUTE_CLASSES: + interface_attrs[attr] = val + + if _IsMethod(val, include_functions=True): + interface_methods[attr] = val + + return interface_methods, interface_attrs + + def GetInterfaceMethodsAndAttrs(self, interface): + ''' + We have to make the creation of the ImmutableParamsCacheManager lazy because + otherwise we'd enter a cyclic import. + + :type interface: the interface from where the methods and attributes should be gotten + :param interface: + (used as the cache-key) + :rtype: @see: CacheInterfaceAttrs.__GetInterfaceMethodsAndAttrs + ''' + try: + cache = self.cache + except AttributeError: + # create it on the 1st access + from oop_ext.foundation.cached_method import ImmutableParamsCachedMethod + cache = self.cache = ImmutableParamsCachedMethod(self.__GetInterfaceMethodsAndAttrs) + return cache(interface) + + +# cache for the interface attrs (for Methods and Attrs). +cache_interface_attrs = CacheInterfaceAttrs() + + +#=================================================================================================== +# _IsMethod +#=================================================================================================== +def _IsMethod(member, include_functions): + ''' + Consider method the following: + 1) Methods + 2) Functions (if include_functions is True) + 3) instances of Method (should it be implementors of "IMethod"?) + + USER: cache mechanism for coilib50.basic.process + ''' + if include_functions and inspect.isfunction(member): + return True + elif inspect.ismethod(member): + return True + elif isinstance(member, Method): + return True + return False + + +#=================================================================================================== +# AssertImplementsFullChecking +#=================================================================================================== +@Deprecated(AssertImplements) +def AssertImplementsFullChecking(class_or_instance, interface, check_attr=True): + return AssertImplements(class_or_instance, interface) + + +#=================================================================================================== +# _AssertImplementsFullChecking +#=================================================================================================== +def _AssertImplementsFullChecking(class_or_instance, interface, check_attr=True): + ''' + Used internally. + + This method will check each member of the given instance (or class) comparing them against the + ones declared in the interface, making sure that it actually implements it even if it does not + declare it so using ImplementsInterface. + + .. note:: Slow + This method is *slow*, so make sure to never use it in hot-spots. + + :raises BadImplementationError: + If :arg class_or_instance: doesn't implement this interface. + ''' + # Moved from the file to avoid cyclic import: + from oop_ext.foundation.types_ import Null + + try: + is_interface = issubclass(interface, Interface) + except TypeError as e: + Reraise(e, "interface={} (type {})".format(interface, type(interface))) + + if not is_interface: + raise InterfaceError( + 'To check against an interface, an interface is required (received: %s -- mro:%s)' % + (interface, interface.__mro__) + ) + + if isinstance(class_or_instance, Null): + return True + + try: + classname = class_or_instance.__name__ + except: + classname = class_or_instance.__class__.__name__ + + if classname == 'InterfaceImplementorStub': + return _AssertImplementsFullChecking(class_or_instance.GetWrappedFromImplementorStub(), interface, check_attr) + + interface_methods, interface_attrs = cache_interface_attrs.GetInterfaceMethodsAndAttrs(interface) + if check_attr: + for attr_name, val in interface_attrs.items(): + if hasattr(class_or_instance, attr_name): + attr = getattr(class_or_instance, attr_name) + match, match_msg = val.Match(attr) + if not match: + msg = 'Attribute %r for class %s does not match the interface %s' + msg = msg % (attr_name, class_or_instance, interface) + if match_msg: + msg += ': ' + match_msg + raise BadImplementationError(msg) + else: + msg = 'Attribute %r is missing in class %s and it is required by interface %s' + msg = msg % (attr_name, class_or_instance, interface) + raise BadImplementationError(msg) + + def GetArgSpec(method): + ''' + Get the arguments for the method, considering the possibility of instances of Method, + in which case, we must obtain the arguments of the instance "__call__" method. + + USER: cache mechanism for coilib50.basic.process + ''' + if isinstance(method, Method): + argspec = inspect.getfullargspec(method.__call__) + else: + argspec = inspect.getfullargspec(method) + assert (not argspec.kwonlyargs) and (not argspec.kwonlydefaults), 'Not supported' + return argspec[:4] + + for name in interface_methods: + # only check the interface methods (because trying to get all the instance methods is + # too slow). + try: + cls_method = getattr(class_or_instance, name) + if not _IsMethod(cls_method, True): + raise AttributeError + + except AttributeError: + msg = 'Method %r is missing in class %r (required by interface %r)' + raise BadImplementationError(msg % (name, classname, interface.__name__)) + else: + interface_method = interface_methods[name] + + c_args, c_varargs, c_varkw, c_defaults = GetArgSpec(cls_method) + + if c_varargs is not None and c_varkw is not None: + if not c_args or c_args == ['self'] or c_args == ['cls']: + # Accept the implementor if it matches the signature: (*args, **kwargs) + # Accept the implementor if it matches the signature: (self, *args, **kwargs) + # Accept the implementor if it matches the signature: (cls, *args, **kwargs) + continue + + i_args, i_varargs, i_varkw, i_defaults = GetArgSpec(interface_method) + + # Rules: + # + # 1. Variable arguments or keyword arguments: if present + # in interface, then it MUST be present in class too + # + # 2. Arguments: names must be the same + # + # 3. Defaults: for now we assume that default values + # must be the same too + mismatch_varargs = i_varargs is not None and c_varargs is None + mismatch_varkw = i_varkw is not None and c_varkw is None + mismatch_args = i_args != c_args + mismatch_defaults = i_defaults != c_defaults + if mismatch_varargs or mismatch_varkw or mismatch_args or mismatch_defaults: + class_sign = inspect.formatargspec(c_args, c_varargs, c_varkw, c_defaults) + interface_sign = inspect.formatargspec(i_args, i_varargs, i_varkw, i_defaults) + msg = '\nMethod %s.%s signature:\n %s\ndiffers from defined in interface %s\n %s' + msg = msg % (classname, name, class_sign, interface.__name__, interface_sign) + raise BadImplementationError(msg) + +#=================================================================================================== +# PROFILING FOR ASSERT IMPLEMENTS + +# NOTE: There was code here for profiling AssertImplements in revisions prior to 2013-03-19. +# That code can be useful for seeing where exactly it is being slow. +#=================================================================================================== + + +class _IfGuard: + ''' + Guard that raises an error if an attempt to convert it to a boolean value is made. + ''' + + def __bool__(self): + raise RuntimeError('Invalid attempt to test interface.ImplementsInterface(). Did you mean interface.IsImplementation()?') + + +__IF_GUARD = _IfGuard() + +DEBUG = False + + +#=================================================================================================== +# ImplementsInterface +#=================================================================================================== +def ImplementsInterface(*interfaces, **kwargs): + ''' + Make sure a class implements the given interfaces. Must be used in as class decorator: + + ```python + @ImplementsInterface(IFoo) + class Foo(object): + pass + ``` + + To avoid checking if the class implements declared interfaces during class creation time, or for + old-style classes, make sure to pass the flag `no_check` as True: + + ```python + @ImplementsInterface(IFoo, no_check=True) + class Foo(object): + pass + ``` + ''' + no_check = kwargs.pop('no_check', False) + assert len(kwargs) == 0, "Expected only 'no_init_check' as kwargs. Found: {}".format(kwargs) + + called = [False] + + class Check: + + def __init__(self): + + def _OnDie(ref): + # We may just use warnings.warn in the future, after our + # codebase is properly 'sanitized', instead of handle_exception. + # + # This is to prevent users of doing an ImplementsInterface() + # without using it as a decorator. + if not called[0]: + if not DEBUG: + created_at_line = '\nSet DEBUG == True in: {} to see location.'.format(__file__) + else: + # This may be slow, so, just do it if DEBUG is enabled. + import traceback + created_at_line = traceback.extract_stack(sys._getframe(), limit=10) + try: + if isinstance(created_at_line, ''.__class__): + created_at_str = created_at_line + else: + created_at_str = str('').join(traceback.format_list(created_at_line)) + + raise AssertionError( + 'A call with ImplementsInterface({}) was not properly done as a class decorator.\nCreated at: {}'.format( + ', '.join(tuple(str(x.__name__ if hasattr(x, '__name__') else x) for x in interfaces),), created_at_str)) + except: + from oop_ext.foundation import handle_exception + handle_exception.HandleException() + + import weakref + self._ref = weakref.ref(self, _OnDie) + + def __call__(self, type_): + called[0] = True + namespace = type_ + curr = getattr(namespace, '__implements__', None) + if curr is not None: + all_interfaces = curr + interfaces + else: + all_interfaces = interfaces + namespace.__implements__ = all_interfaces + + if not no_check: + if IsDevelopment(): # Only doing check in dev mode. + for I in interfaces: + # Will do full checking this first time, and also cache the results + AssertImplements(type_, I) + + return type_ + + def __bool__(self): + called[0] = True + raise RuntimeError('Invalid attempt to test interface.ImplementsInterface(). Did you ' + 'mean interface.IsImplementation()?') + + def __nonzero__(self): + # Py 2 compatibility + return self.__bool__() + + return Check() + + +#=================================================================================================== +# DeclareClassImplements +#=================================================================================================== +def DeclareClassImplements(class_, *interfaces): + ''' + This is a way to tell, from outside of the class, that a given :arg class_: implements the + given :arg interfaces:. + + .. attention:: Use Implements whenever possible + This method should be used only when you can't use :py:func:`Implements`, or when you can't + change the code of the class being declared, i.e., when you: + * Can't add metaclass because the class already has one + * Class can't depend on the library where the interface is defined + * Class is defined from bindings + * Class is defined in an external library + * Class is defined by generated code + + :type interfaces: list(Interface) + :type class_: type + + :raises BadImplementationError: + If, after checking the methods, :arg class_: doesn't really implement the :arg interface:. + + .. note:: Inheritance + When you use this method to declare that a base class implements a given interface, you + should *also* use this in the derived classes, it does not propagate automatically to + the derived classes. See testDeclareClassImplements. + ''' + assert _IsClass(class_) + + from itertools import chain + + old_implements = getattr(class_, '__implements__', []) + class_.__implements__ = list(chain(old_implements, interfaces)) + + # This check must be done *after* adding the interfaces to __implements__, because it will + # also check that the interfaces are declared there. + try: + for interface in interfaces: + # Forget any previous checks + __ImplementsCache.ForgetResult((class_, interface)) + __ImplementedInterfacesCache.ForgetResult(class_) + + AssertImplements(class_, interface) + except: + # Roll back... + class_.__implements__ = old_implements + raise + + +#=================================================================================================== +# _GetMROForOldStyleClass +#=================================================================================================== +def _GetMROForOldStyleClass(class_): + ''' + :type class_: classobj + :param class_: + An old-style class + + :rtype: list(classobj) + :return: + A list with all the bases in the older MRO (method resolution order) + ''' + + def _CalculateMro(class_, mro): + for base in class_.__bases__: + if base not in mro: + mro.append(base) + _CalculateMro(base, mro) + + mro = [class_] + _CalculateMro(class_, mro) + return mro + + +#=================================================================================================== +# _GetClassImplementedInterfaces +#=================================================================================================== +def _GetClassImplementedInterfaces(class_): + cache = __ImplementedInterfacesCache + result = cache.GetResult(class_) + if result is not None: + return result + + result = set() + + mro = inspect.getmro(class_) + + for c in mro: + interfaces = getattr(c, '__implements__', ()) + for interface in interfaces: + interface_mro = inspect.getmro(interface) + + for interface_type in interface_mro: + if interface_type in (Interface, object): + continue + result.add(interface_type) + + result = frozenset(result) + + cache.SetResult(class_, result) + return result + + +#=================================================================================================== +# GetImplementedInterfaces +#=================================================================================================== +def GetImplementedInterfaces(class_or_object): + ''' + :rtype: frozenset([interfaces]) + The interfaces implemented by the object or class passed. + ''' + class_ = _GetClassForInterfaceChecking(class_or_object) + + # we have to build the cache attribute given the name of the class, otherwise setting in a base + # class before a subclass may give errors. + return _GetClassImplementedInterfaces(class_) + + +#=================================================================================================== +# _IsInterfaceDeclared +#=================================================================================================== +def _IsInterfaceDeclared(class_, interface): + ''' + :type interface: Interface or iterable(Interface) + :param interface: + The target interface(s). If multitple interfaces are passed the method will return True + if the given class or instance implements any of the given interfaces. + + :rtype: True if the object declares the interface passed and False otherwise. Note that + to declare an interface, the class MUST have declared + + >>> ImplementsInterface(Class) + ''' + if class_ is None: + return False + + is_collection = False + if isinstance(interface, (set, list, tuple)): + is_collection = True + for i in interface: + if not issubclass(i, Interface): + raise InterfaceError('To check against an interface, an interface is required (received: %s -- mro:%s)' % + (interface, interface.__mro__)) + elif not issubclass(interface, Interface): + raise InterfaceError('To check against an interface, an interface is required (received: %s -- mro:%s)' % + (interface, interface.__mro__)) + + declared_interfaces = GetImplementedInterfaces(class_) + + # This set will include all interfaces (and its subclasses) declared for the given objec + declared_and_subclasses = set() + for implemented in declared_interfaces: + declared_and_subclasses.update(implemented.__mro__) + + # Discarding object (it will always be returned in the mro collection) + declared_and_subclasses.discard(object) + + if not is_collection: + return interface in declared_and_subclasses + else: + return bool(set(interface).intersection(declared_and_subclasses)) + +#=================================================================================================== +# PROFILING FOR IsInterfaceDeclared + +# NOTE: There was code here for profiling IsInterfaceDeclared in revisions prior to 2013-03-19. +# That code can be useful for seeing where exactly it is being called. +#=================================================================================================== + + +#=================================================================================================== +# AssertDeclaresInterface +#=================================================================================================== +@Deprecated(AssertImplements) +def AssertDeclaresInterface(class_or_instance, interface): + return AssertImplements(class_or_instance, interface) diff --git a/src/oop_ext/interface/_tests/__init__.py b/src/oop_ext/interface/_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/oop_ext/interface/_tests/test_interface.py b/src/oop_ext/interface/_tests/test_interface.py new file mode 100644 index 0000000..4a256a3 --- /dev/null +++ b/src/oop_ext/interface/_tests/test_interface.py @@ -0,0 +1,877 @@ + +import textwrap + +import pytest + +from oop_ext.foundation.types_ import Method, Null +from oop_ext.interface import ( + AssertImplements, Attribute, BadImplementationError, DeclareClassImplements, + GetImplementedInterfaces, IAdaptable, ImplementsInterface, Interface, InterfaceError, + InterfaceImplementorStub, IsImplementation, ReadOnlyAttribute) + + +#=================================================================================================== +# _InterfM1 +#=================================================================================================== +class _InterfM1(Interface): + + def m1(self): + '' + + +#=================================================================================================== +# _InterfM2 +#=================================================================================================== +class _InterfM2(Interface): + + def m2(self): + '' + + +#=================================================================================================== +# _InterfM3 +#=================================================================================================== +class _InterfM3(Interface): + + def m3(self, arg1, arg2): + '' + + +#=================================================================================================== +# _InterfM4 +#=================================================================================================== +class _InterfM4(_InterfM3): + + def m4(self): + '' + + +def testBasics(): + + class I(Interface): + + def foo(self, a, b=None): + '' + + def bar(self): + '' + + @ImplementsInterface(I) + class C: + + def foo(self, a, b=None): + '' + + def bar(self): + '' + + class C2: + + def foo(self, a, b=None): + '' + + def bar(self): + '' + + class D: + '' + + assert IsImplementation(I(C()), I) == True # OK + + assert IsImplementation(C, I) == True # OK + assert IsImplementation(C2, I) == False # Does not declare + assert not IsImplementation(D, I) == True # nope + + assert I(C) is C + assert I(C2) is C2 + with pytest.raises(InterfaceError): + I() + + with pytest.raises(BadImplementationError): + I(D) + + # Now declare that C2 implements I + DeclareClassImplements(C2, I) + + assert IsImplementation(C2, I) == True # Does not declare + + +def testMissingMethod(): + + class I(Interface): + + def foo(self, a, b=None): + '' + + with pytest.raises(AssertionError, match=r"Method 'foo' is missing in class 'C' \(required by interface 'I'\)"): + + @ImplementsInterface(I) + class C: + pass + + def TestMissingSignature(): + + @ImplementsInterface(I) + class C: + + def foo(self, a): + '' + + with pytest.raises(AssertionError) as e: + TestMissingSignature() + assert str(e.value) == textwrap.dedent(""" + Method C.foo signature: + (self, a) + differs from defined in interface I + (self, a, b=None)""") + + def TestMissingSignatureOptional(): + + @ImplementsInterface(I) + class C: + + def foo(self, a, b): + '' + + with pytest.raises(AssertionError) as e: + TestMissingSignatureOptional() + assert str(e.value) == textwrap.dedent(""" + Method C.foo signature: + (self, a, b) + differs from defined in interface I + (self, a, b=None)""") + + def TestWrongParameterName(): + + @ImplementsInterface(I) + class C: + + def foo(self, a, c): + '' + + with pytest.raises(AssertionError) as e: + TestWrongParameterName() + assert str(e.value) == textwrap.dedent(""" + Method C.foo signature: + (self, a, c) + differs from defined in interface I + (self, a, b=None)""") + + +def testSubclasses(): + + class I(Interface): + + def foo(self, a, b=None): + '' + + @ImplementsInterface(I) + class C: + + def foo(self, a, b=None): + '' + + class D(C): + '' + + +def testSubclasses2(): + + class I(Interface): + + def foo(self): + '' + + class I2(Interface): + + def bar(self): + '' + + @ImplementsInterface(I) + class C: + + def foo(self): + '' + + @ImplementsInterface(I2) + class D(C): + + def bar(self): + '' + + class E(D): + '' + + assert GetImplementedInterfaces(C) == {I} + assert GetImplementedInterfaces(D) == {I2, I} + assert GetImplementedInterfaces(E) == {I2, I} + + +def testNoName(): + + class I(Interface): + + def MyMethod(self, foo): + '' + + class C: + + def MyMethod(self, bar): + '' + + with pytest.raises(AssertionError): + AssertImplements(C(), I) + + +def testAttributes(): + + class IZoo(Interface): + zoo = Attribute(int) + + class I(Interface): + foo = Attribute(int) + bar = Attribute(str) + foobar = Attribute(int, None) + a_zoo = Attribute(IZoo) + + @ImplementsInterface(IZoo) + class Zoo: + pass + + # NOTE: This class 'C' doesn't REALLY implements 'I', although it says so. The problem is + # that there's a flaw with attributes *not being checked*. + + # In fact: Attributes should not be in the (Abstract) properties COULD be in + # the interface, but they SHOULD NOT be type-checked (because it involves a + # getter call, and this affects runtime behaviour). + # This should be reviewed later. + @ImplementsInterface(I) + class C: + pass + + c1 = C() + c1.foo = 10 + c1.bar = 'hello' + c1.foobar = 20 + + a_zoo = Zoo() + a_zoo.zoo = 99 + c1.a_zoo = a_zoo + + c2 = C() + + assert IsImplementation(C, I) == True + assert IsImplementation(c1, I) == True + assert IsImplementation(c2, I) == True + + # NOTE: Testing private methods here + # If we do a deprecated "full check", then its behaviour is a little bit more correct. + from oop_ext.interface._interface import _IsImplementationFullChecking + assert not _IsImplementationFullChecking(C, I) == True # only works with instances + assert _IsImplementationFullChecking(c1, I) == True # OK, has attributes + assert not _IsImplementationFullChecking(c2, I) == True # not all the attributes necessary + + # must not be true if including an object that doesn't implement IZoo interface expected for + # a_zoo attribute + c1.a_zoo = 'wrong' + assert not _IsImplementationFullChecking(c1, I) == True # failed, invalid attr type + c1.a_zoo = a_zoo + + # test if we can set foobar to None + c1.foobar = None + assert IsImplementation(c1, I) == True # OK + + c1.foobar = 'hello' + assert not _IsImplementationFullChecking(c1, I) == True # failed, invalid attr type + + +def testPassNoneInAssertImplementsFullChecking(): + with pytest.raises(AssertionError): + AssertImplements(None, _InterfM1) + + with pytest.raises(AssertionError): + AssertImplements(10, _InterfM1) + + +def testNoCheck(): + + @ImplementsInterface(_InterfM1, no_check=True) + class NoCheck: + pass + + no_check = NoCheck() + with pytest.raises(AssertionError): + AssertImplements(no_check, _InterfM1) + + +def testCallbackAndInterfaces(): + ''' + Tests if the interface "AssertImplements" works with "callbacked" methods. + ''' + + @ImplementsInterface(_InterfM1) + class My: + + def m1(self): + '' + + def MyCallback(): + '' + + from oop_ext.foundation.callback import After + + o = My() + AssertImplements(o, _InterfM1) + + After(o.m1, MyCallback) + + AssertImplements(o, _InterfM1) + AssertImplements(o, _InterfM1) # Not raises BadImplementationError + + +def testInterfaceStub(): + + @ImplementsInterface(_InterfM1) + class My: + + def m1(self): + return 'm1' + + def m2(self): + '' + + m0 = My() + m1 = _InterfM1(m0) # will make sure that we only access the attributes/methods declared in the interface + assert 'm1' == m1.m1() + getattr(m0, 'm2') # Not raises AttributeError + with pytest.raises(AttributeError): + getattr(m1, 'm2') + + _InterfM1(m1) # Not raise BadImplementationError + + +def testIsImplementationWithSubclasses(): + ''' + Checks if the IsImplementation method works with subclasses interfaces. + + Given that an interface I2 inherits from I1 of a given object declared that it implements I2 + then it is implicitly declaring that implements I1. + ''' + + @ImplementsInterface(_InterfM2) + class My2: + + def m2(self): + '' + + @ImplementsInterface(_InterfM3) + class My3: + + def m3(self, arg1, arg2): + '' + + @ImplementsInterface(_InterfM4) + class My4: + + def m3(self, arg1, arg2): + '' + + def m4(self): + '' + + m2 = My2() + m3 = My3() + m4 = My4() + + # My2 + assert IsImplementation(m2, _InterfM3) == False + + # My3 + assert IsImplementation(m3, _InterfM3) == True + assert IsImplementation(m3, _InterfM4) == False + + # My4 + assert IsImplementation(m4, _InterfM4) == True + assert IsImplementation(m4, _InterfM3) == True + + # When wrapped in an m4 interface it should still accept m3 as a declared interface + wrapped_intef_m4 = _InterfM4(m4) + assert IsImplementation(wrapped_intef_m4, _InterfM4) == True + assert IsImplementation(wrapped_intef_m4, _InterfM3) == True + + +def testIsImplementationWithBuiltInObjects(): + + my_number = 10 + assert IsImplementation(my_number, _InterfM1) == False + + +def testClassImplementMethod(): + ''' + Tests replacing a method that implements an interface with a class. + + The class must be derived from "Method" in order to be accepted as a valid + implementation. + ''' + + @ImplementsInterface(_InterfM1) + class My: + + def m1(self): + '' + + class MyRightMethod(Method): + + def __call__(self): + '' + + class MyWrongMethod: + + def __call__(self): + '' + + # NOTE: It doesn't matter runtime modifications in the instance, what is really being tested + # is the *class* of the object (My) is what is really being tested. + m = My() + m.m1 = MyWrongMethod() + assert IsImplementation(m, _InterfM1) == True + + m.m1 = MyRightMethod() + assert IsImplementation(m, _InterfM1) == True + + # NOTE: Testing behaviour of private methods here. + from oop_ext.interface._interface import _IsImplementationFullChecking + + m = My() + m.m1 = MyWrongMethod() + r = _IsImplementationFullChecking(m, _InterfM1) + assert r == False + + m.m1 = MyRightMethod() + r = _IsImplementationFullChecking(m, _InterfM1) + assert r == True + + del m.m1 + assert IsImplementation(m, _InterfM1) == True + + +def testGetImplementedInterfaces(): + + @ImplementsInterface(_InterfM1) + class A: + + def m1(self): + '' + + class B(A): + '' + + @ImplementsInterface(_InterfM4) + class C: + + def m4(self): + '' + + def m3(self, arg1, arg2): + '' + + assert 1 == len(GetImplementedInterfaces(B())) + assert set(GetImplementedInterfaces(C())) == {_InterfM4, _InterfM3} + + +def testGetImplementedInterfaces2(): + + @ImplementsInterface(_InterfM1) + class A: + + def m1(self): + '' + + @ImplementsInterface(_InterfM2) + class B(A): + + def m2(self): + '' + + assert 2 == len(GetImplementedInterfaces(B())) + with pytest.raises(AssertionError): + AssertImplements(A(), _InterfM2) + + AssertImplements(B(), _InterfM2) + + +def testAdaptableInterface(): + + @ImplementsInterface(IAdaptable) + class A: + + def GetAdapter(self, interface_class): + if interface_class == _InterfM1: + return B() + + @ImplementsInterface(_InterfM1) + class B: + + def m1(self): + '' + + a = A() + b = _InterfM1(a) # will try to adapt, as it does not directly implements m1 + assert b is not None + b.m1() # has m1 + with pytest.raises(AttributeError): + getattr(b, 'non_existent') + + assert isinstance(b, InterfaceImplementorStub) + + +def testNull(): + AssertImplements(Null(), _InterfM2) # Not raises BadImplementationError + + class ExtendedNull(Null): + '' + + AssertImplements(ExtendedNull(), _InterfM2) # Not raises BadImplementationError + + +def testSetItem(): + + class InterfSetItem(Interface): + + def __setitem__(self, item_id, subject): + '' + + def __getitem__(self, item_id): + '' + + @ImplementsInterface(InterfSetItem) + class A: + + def __setitem__(self, item_id, subject): + self.set = (item_id, subject) + + def __getitem__(self, item_id): + return self.set + + a = InterfSetItem(A()) + a['10'] = 1 + assert ('10', 1) == a['10'] + + +def testAssertImplementsDoesNotDirObject(): + ''' + AssertImplements does not attempt to __getattr__ methods from an object, it only considers + methods that are actually bound to the class. + ''' + + class M1: + + def __getattr__(self, attr): + assert attr == 'm1' # This test only accepts this attribute + + class MyMethod(Method): + + def __call__(self): + '' + + return MyMethod() + + m1 = M1() + m1.m1() + with pytest.raises(AssertionError): + AssertImplements(m1, _InterfM1) + + +def testImplementorWithAny(): + ''' + You must explicitly declare that you implement an Interface. + ''' + + class M3: + + def m3(self, *args, **kwargs): + '' + + with pytest.raises(AssertionError): + AssertImplements(M3(), _InterfM3) + + +def testInterfaceCheckRequiresInterface(): + + class M3: + + def m3(self, *args, **kwargs): + '' + + with pytest.raises(InterfaceError): + AssertImplements(M3(), M3) + + with pytest.raises(InterfaceError): + IsImplementation(M3(), M3) + + +def testReadOnlyAttribute(): + + class IZoo(Interface): + zoo = ReadOnlyAttribute(int) + + @ImplementsInterface(IZoo) + class Zoo: + + def __init__(self, value): + self.zoo = value + + a_zoo = Zoo(value=99) + AssertImplements(a_zoo, IZoo) + + +def testReadOnlyAttributeMissingImplementation(): + ''' + First implementation of changes in interfaces to support read-only attributes was not + including read-only attributes when AssertImplements was called. + + This caused missing read-only attributes to go unnoticed and sometimes false positives, + recognizing objects as valid implementations when in fact they weren't. + ''' + + class IZoo(Interface): + zoo = ReadOnlyAttribute(int) + + # Doesn't have necessary 'zoo' attribute, should raise a bad implementation error + @ImplementsInterface(IZoo) + class FlawedZoo: + + def __init__(self, value): + '' + + # NOTE: Testing private methods here + from oop_ext.interface._interface import _AssertImplementsFullChecking + + a_flawed_zoo = FlawedZoo(value=101) + with pytest.raises(BadImplementationError): + _AssertImplementsFullChecking(a_flawed_zoo, IZoo) + + +def testImplementsTwice(): + + class I1(Interface): + + def Method1(self): + '' + + class I2(Interface): + + def Method2(self): + '' + + def Create(): + + @ImplementsInterface(I1) + @ImplementsInterface(I2) + class Foo: + + def Method2(self): + '' + + # Error because I1 is not implemented. + with pytest.raises(AssertionError): + Create() + + +def testDeclareClassImplements(): + + class I1(Interface): + + def M1(self): + '' + + class I2(Interface): + + def M2(self): + '' + + class C0: + '' + + class C1: + + def M1(self): + '' + + class C2: + + def M2(self): + '' + + @ImplementsInterface(I2) + class C2B: + + def M2(self): + '' + + class C12B(C1, C2B): + '' + + with pytest.raises(AssertionError): + DeclareClassImplements(C0, I1) + + assert IsImplementation(C1, I1) == False + with pytest.raises(AssertionError): + AssertImplements(C1, I1) + + assert IsImplementation(C12B, I1) == False # C1 still does not implements I1 + + DeclareClassImplements(C1, I1) + + assert IsImplementation(C1, I1) == True + AssertImplements(C1, I1) + + # C1 is parent of C12B, and, above, it was declared that C1 implements I1, so C12B should + # automatically implement I1. But this is not automatic, so you must also declare for it! + + assert IsImplementation(C12B, I1) == False # not automatic + assert IsImplementation(C12B, I2) == True # inheritance for Implements still works automatically + + DeclareClassImplements(C12B, I1) + + assert IsImplementation(C12B, I1) == True # now it implements + assert IsImplementation(C12B, I2) == True + + DeclareClassImplements(C2, I2) + + assert IsImplementation(C2, I2) == True + AssertImplements(C2, I2) + + # Exception: if I define a class *after* using DeclareClassImplements in the base, it works: + class C12(C1, C2): + '' + + AssertImplements(C12, I1) + AssertImplements(C12, I2) + + +def testCallableInterfaceStub(): + ''' + Validates that is possible to create stubs for interfaces of callables (i.e. declaring + __call__ method in interface). + + If a stub for a interface not declared as callable is tried to be executed as callable it + raises an error. + ''' + + # ok, calling a stub for a callable + class IFoo(Interface): + + def __call__(self, bar): + '' + + @ImplementsInterface(IFoo) + class Foo: + + def __call__(self, bar): + '' + + foo = Foo() + stub = IFoo(foo) + stub(bar=None) # NotRaises TypeError + + # wrong, calling a stub for a non-callable + class IBar(Interface): + + def something(self, stuff): + '' + + @ImplementsInterface(IBar) + class Bar: + + def something(self, stuff): + '' + + bar = Bar() + stub = IBar(bar) + with pytest.raises(AttributeError): + stub(stuff=None) + + +def testImplementsInterfaceAsBoolError(): + ''' + Test if the common erroneous use of interface.ImplementsInterface() instead of + interface.IsImplementation() to test if an object implements an interface correctly + raises a RuntimeError. + ''' + + class I1(Interface): + + def M1(self): + pass + + @ImplementsInterface(I1) + class C1: + + def M1(self): + pass + + obj = C1() + + assert IsImplementation(obj, I1) + + with pytest.raises(RuntimeError): + if ImplementsInterface(obj, I1): + pytest.fail('Managed to test "if ImplementsInterface(obj, I1):"') + + +@pytest.mark.parametrize('check_before', [True, False]) +@pytest.mark.parametrize('autospec', [True, False]) +def test_interface_subclass_mocked(mocker, check_before, autospec): + """ + Interfaces check implementation during class declaration. However a + subclass doesn't have its implementation checked. + + Then if there is an implementation AFTER subclass is already mocked + the interface check will only work if `autospec` is used. + + Note that interface implementation checks keeps a cache, so if + subclass was checked before it was mocked it would pass anyway. + + :type mocker: MockTestFixture + :type check_before: bool + :type autospec: bool + """ + from oop_ext import interface + + class II(interface.Interface): + + def foo(self, a, b, c): + pass + + @interface.ImplementsInterface(II) + class Foo: + + def foo(self, a, b, c): + pass + + class Bar(Foo): + pass + + if check_before: + interface.IsImplementation(Bar, II) + + mocker.patch.object(Foo, 'foo', autospec=autospec) + + assert interface.IsImplementation(Bar, II) == (autospec or check_before) + +@pytest.mark.xfail(run=False, reason="core dumped") +def testErrorOnInterfaceDeclaration(handled_exceptions): + + def Check(): + + class Foo: + + from oop_ext import interface + interface.ImplementsInterface(_InterfM1) + + from oop_ext.foundation.handle_exception import IgnoringHandleException + with IgnoringHandleException(): + Check() + assert len(handled_exceptions.GetHandledExceptions()) == 1 + handled_exceptions.ClearHandledExceptions() diff --git a/src/oop_ext/main.py b/src/oop_ext/main.py deleted file mode 100644 index 0992e31..0000000 --- a/src/oop_ext/main.py +++ /dev/null @@ -1,4 +0,0 @@ - - -def package_name(): - return 'oop_ext' diff --git a/test/test_main.py b/test/test_main.py deleted file mode 100644 index 1ede053..0000000 --- a/test/test_main.py +++ /dev/null @@ -1,3 +0,0 @@ -def test_main(): - from oop_ext import main - assert main.package_name() == 'oop_ext' diff --git a/tox.ini b/tox.ini index 61eefdc..fd27910 100644 --- a/tox.ini +++ b/tox.ini @@ -1,11 +1,10 @@ [tox] -envlist = py27, py36, linting, docs +envlist = py36, linting, docs [travis] python = 3.6: py36 3.5: py35 - 2.7: py27 [testenv] passenv = TOXENV CI TRAVIS TRAVIS_* APPVEYOR APPVEYOR_* @@ -28,7 +27,3 @@ extras = docs commands = sphinx-build -W -b html . _build - -[flake8] -max-line-length = 120 -ignore = E203,W503