# Licensed under a 3-clause BSD style license - see LICENSE.rst import concurrent.futures import inspect import pickle import pytest from astropy.utils.decorators import (deprecated_attribute, deprecated, sharedmethod, classproperty, lazyproperty, format_doc, deprecated_renamed_argument) from astropy.utils.exceptions import (AstropyDeprecationWarning, AstropyPendingDeprecationWarning, AstropyUserWarning) class NewDeprecationWarning(AstropyDeprecationWarning): """ New Warning subclass to be used to test the deprecated decorator's ``warning_type`` parameter. """ def test_deprecated_attribute(): class DummyClass: def __init__(self): self._foo = 42 self._bar = 4242 self._message = '42' self._alternative = [42] self._pending = {42} def set_private(self): self._foo = 100 self._bar = 1000 self._message = '100' self._alternative = [100] self._pending = {100} foo = deprecated_attribute('foo', '0.2') bar = deprecated_attribute('bar', '0.2', warning_type=NewDeprecationWarning) alternative = deprecated_attribute('alternative', '0.2', alternative='other') message = deprecated_attribute('message', '0.2', message='MSG') pending = deprecated_attribute('pending', '0.2', pending=True) dummy = DummyClass() with pytest.warns(AstropyDeprecationWarning, match="The foo attribute is " "deprecated and may be removed in a future version.") as w: dummy.foo assert len(w) == 1 with pytest.warns(NewDeprecationWarning, match="The bar attribute is " "deprecated and may be removed in a future version.") as w: dummy.bar assert len(w) == 1 with pytest.warns(AstropyDeprecationWarning, match="MSG"): dummy.message with pytest.warns(AstropyDeprecationWarning, match=r"Use other instead\."): dummy.alternative with pytest.warns(AstropyPendingDeprecationWarning): dummy.pending dummy.set_private() # This needs to be defined outside of the test function, because we # want to try to pickle it. @deprecated('100.0') class TA: """ This is the class docstring. """ def __init__(self): """ This is the __init__ docstring """ pass class TMeta(type): metaclass_attr = 1 @deprecated('100.0') class TB(metaclass=TMeta): pass @deprecated('100.0', warning_type=NewDeprecationWarning) class TC: """ This class has the custom warning. """ pass def test_deprecated_class(): orig_A = TA.__bases__[0] # The only thing that should be different about the new class # is __doc__, __init__, __bases__ and __subclasshook__. # and __init_subclass__ for Python 3.6+. for x in dir(orig_A): if x not in ('__doc__', '__init__', '__bases__', '__dict__', '__subclasshook__', '__init_subclass__'): assert getattr(TA, x) == getattr(orig_A, x) with pytest.warns(AstropyDeprecationWarning) as w: TA() assert len(w) == 1 if TA.__doc__ is not None: assert 'function' not in TA.__doc__ assert 'deprecated' in TA.__doc__ assert 'function' not in TA.__init__.__doc__ assert 'deprecated' in TA.__init__.__doc__ # Make sure the object is picklable pickle.dumps(TA) with pytest.warns(NewDeprecationWarning) as w: TC() assert len(w) == 1 def test_deprecated_class_with_new_method(): """ Test that a class with __new__ method still works even if it accepts additional arguments. This previously failed because the deprecated decorator would wrap objects __init__ which takes no arguments. """ @deprecated('1.0') class A: def __new__(cls, a): return super().__new__(cls) # Creating an instance should work but raise a DeprecationWarning with pytest.warns(AstropyDeprecationWarning) as w: A(1) assert len(w) == 1 @deprecated('1.0') class B: def __new__(cls, a): return super().__new__(cls) def __init__(self, a): pass # Creating an instance should work but raise a DeprecationWarning with pytest.warns(AstropyDeprecationWarning) as w: B(1) assert len(w) == 1 def test_deprecated_class_with_super(): """ Regression test for an issue where classes that used ``super()`` in their ``__init__`` did not actually call the correct class's ``__init__`` in the MRO. """ @deprecated('100.0') class TB: def __init__(self, a, b): super().__init__() with pytest.warns(AstropyDeprecationWarning) as w: TB(1, 2) assert len(w) == 1 if TB.__doc__ is not None: assert 'function' not in TB.__doc__ assert 'deprecated' in TB.__doc__ assert 'function' not in TB.__init__.__doc__ assert 'deprecated' in TB.__init__.__doc__ def test_deprecated_class_with_custom_metaclass(): """ Regression test for an issue where deprecating a class with a metaclass other than type did not restore the metaclass properly. """ with pytest.warns(AstropyDeprecationWarning) as w: TB() assert len(w) == 1 assert type(TB) is TMeta assert TB.metaclass_attr == 1 def test_deprecated_static_and_classmethod(): """ Regression test for issue introduced by https://github.com/astropy/astropy/pull/2811 and mentioned also here: https://github.com/astropy/astropy/pull/2580#issuecomment-51049969 where it appears that deprecated staticmethods didn't work on Python 2.6. """ class A: """Docstring""" @deprecated('1.0') @staticmethod def B(): pass @deprecated('1.0') @classmethod def C(cls): pass with pytest.warns(AstropyDeprecationWarning) as w: A.B() assert len(w) == 1 if A.__doc__ is not None: assert 'deprecated' in A.B.__doc__ with pytest.warns(AstropyDeprecationWarning) as w: A.C() assert len(w) == 1 if A.__doc__ is not None: assert 'deprecated' in A.C.__doc__ def test_deprecated_argument(): # Tests the decorator with function, method, staticmethod and classmethod. class Test: @classmethod @deprecated_renamed_argument('clobber', 'overwrite', '1.3') def test1(cls, overwrite): return overwrite @staticmethod @deprecated_renamed_argument('clobber', 'overwrite', '1.3') def test2(overwrite): return overwrite @deprecated_renamed_argument('clobber', 'overwrite', '1.3') def test3(self, overwrite): return overwrite @deprecated_renamed_argument('clobber', 'overwrite', '1.3', warning_type=NewDeprecationWarning) def test4(self, overwrite): return overwrite @deprecated_renamed_argument('clobber', 'overwrite', '1.3', relax=False) def test1(overwrite): return overwrite for method in [Test().test1, Test().test2, Test().test3, Test().test4, test1]: # As positional argument only assert method(1) == 1 # As new keyword argument assert method(overwrite=1) == 1 # Using the deprecated name with pytest.warns(AstropyDeprecationWarning, match=r"1\.3") as w: assert method(clobber=1) == 1 assert len(w) == 1 assert 'test_decorators.py' in str(w[0].filename) if method.__name__ == 'test4': assert issubclass(w[0].category, NewDeprecationWarning) # Using both. Both keyword with pytest.raises(TypeError), pytest.warns(AstropyDeprecationWarning): method(clobber=2, overwrite=1) # One positional, one keyword with pytest.raises(TypeError), pytest.warns(AstropyDeprecationWarning): method(1, clobber=2) def test_deprecated_argument_custom_message(): @deprecated_renamed_argument('foo', 'bar', '4.0', message='Custom msg') def test(bar=0): pass with pytest.warns(AstropyDeprecationWarning, match='Custom msg'): test(foo=0) def test_deprecated_argument_in_kwargs(): # To rename an argument that is consumed by "kwargs" the "arg_in_kwargs" # parameter is used. @deprecated_renamed_argument('clobber', 'overwrite', '1.3', arg_in_kwargs=True) def test(**kwargs): return kwargs['overwrite'] # As positional argument only with pytest.raises(TypeError): test(1) # As new keyword argument assert test(overwrite=1) == 1 # Using the deprecated name with pytest.warns(AstropyDeprecationWarning, match=r"1\.3") as w: assert test(clobber=1) == 1 assert len(w) == 1 assert 'test_decorators.py' in str(w[0].filename) # Using both. Both keyword with pytest.raises(TypeError), pytest.warns(AstropyDeprecationWarning): test(clobber=2, overwrite=1) # One positional, one keyword with pytest.raises(TypeError), pytest.warns(AstropyDeprecationWarning): test(1, clobber=2) def test_deprecated_argument_relaxed(): # Relax turns the TypeError if both old and new keyword are used into # a warning. @deprecated_renamed_argument('clobber', 'overwrite', '1.3', relax=True) def test(overwrite): return overwrite # As positional argument only assert test(1) == 1 # As new keyword argument assert test(overwrite=1) == 1 # Using the deprecated name with pytest.warns(AstropyDeprecationWarning, match=r"1\.3") as w: assert test(clobber=1) == 1 assert len(w) == 1 # Using both. Both keyword with pytest.warns(AstropyUserWarning) as w: assert test(clobber=2, overwrite=1) == 1 assert len(w) == 2 assert '"clobber" was deprecated' in str(w[0].message) assert '"clobber" and "overwrite" keywords were set' in str(w[1].message) # One positional, one keyword with pytest.warns(AstropyUserWarning) as w: assert test(1, clobber=2) == 1 assert len(w) == 2 assert '"clobber" was deprecated' in str(w[0].message) assert '"clobber" and "overwrite" keywords were set' in str(w[1].message) def test_deprecated_argument_pending(): # Relax turns the TypeError if both old and new keyword are used into # a warning. @deprecated_renamed_argument('clobber', 'overwrite', '1.3', pending=True) def test(overwrite): return overwrite # As positional argument only assert test(1) == 1 # As new keyword argument assert test(overwrite=1) == 1 # Using the deprecated name assert test(clobber=1) == 1 # Using both. Both keyword assert test(clobber=2, overwrite=1) == 1 # One positional, one keyword assert test(1, clobber=2) == 1 def test_deprecated_argument_multi_deprecation(): @deprecated_renamed_argument(['x', 'y', 'z'], ['a', 'b', 'c'], [1.3, 1.2, 1.3], relax=True) def test(a, b, c): return a, b, c with pytest.warns(AstropyDeprecationWarning) as w: assert test(x=1, y=2, z=3) == (1, 2, 3) assert len(w) == 3 # Make sure relax is valid for all arguments with pytest.warns(AstropyUserWarning) as w: assert test(x=1, y=2, z=3, b=3) == (1, 3, 3) assert len(w) == 4 with pytest.warns(AstropyUserWarning) as w: assert test(x=1, y=2, z=3, a=3) == (3, 2, 3) assert len(w) == 4 with pytest.warns(AstropyUserWarning) as w: assert test(x=1, y=2, z=3, c=5) == (1, 2, 5) assert len(w) == 4 def test_deprecated_argument_multi_deprecation_2(): @deprecated_renamed_argument(['x', 'y', 'z'], ['a', 'b', 'c'], [1.3, 1.2, 1.3], relax=[True, True, False]) def test(a, b, c): return a, b, c with pytest.warns(AstropyUserWarning) as w: assert test(x=1, y=2, z=3, b=3) == (1, 3, 3) assert len(w) == 4 with pytest.warns(AstropyUserWarning) as w: assert test(x=1, y=2, z=3, a=3) == (3, 2, 3) assert len(w) == 4 with pytest.raises(TypeError), pytest.warns(AstropyUserWarning): assert test(x=1, y=2, z=3, c=5) == (1, 2, 5) def test_deprecated_argument_not_allowed_use(): # If the argument is supposed to be inside the kwargs one needs to set the # arg_in_kwargs parameter. Without it it raises a TypeError. with pytest.raises(TypeError): @deprecated_renamed_argument('clobber', 'overwrite', '1.3') def test1(**kwargs): return kwargs['overwrite'] # Cannot replace "*args". with pytest.raises(TypeError): @deprecated_renamed_argument('overwrite', 'args', '1.3') def test2(*args): return args # Cannot replace "**kwargs". with pytest.raises(TypeError): @deprecated_renamed_argument('overwrite', 'kwargs', '1.3') def test3(**kwargs): return kwargs def test_deprecated_argument_remove(): @deprecated_renamed_argument('x', None, '2.0', alternative='astropy.y') def test(dummy=11, x=3): return dummy, x with pytest.warns(AstropyDeprecationWarning, match=r"Use astropy\.y instead") as w: assert test(x=1) == (11, 1) assert len(w) == 1 with pytest.warns(AstropyDeprecationWarning) as w: assert test(x=1, dummy=10) == (10, 1) assert len(w) == 1 with pytest.warns(AstropyDeprecationWarning, match=r'Use astropy\.y instead'): test(121, 1) == (121, 1) assert test() == (11, 3) assert test(121) == (121, 3) assert test(dummy=121) == (121, 3) def test_sharedmethod_reuse_on_subclasses(): """ Regression test for an issue where sharedmethod would bind to one class for all time, causing the same method not to work properly on other subclasses of that class. It has the same problem when the same sharedmethod is called on different instances of some class as well. """ class AMeta(type): def foo(cls): return cls.x class A: x = 3 def __init__(self, x): self.x = x @sharedmethod def foo(self): return self.x a1 = A(1) a2 = A(2) assert a1.foo() == 1 assert a2.foo() == 2 # Similar test now, but for multiple subclasses using the same sharedmethod # as a classmethod assert A.foo() == 3 class B(A): x = 5 assert B.foo() == 5 def test_classproperty_docstring(): """ Tests that the docstring is set correctly on classproperties. This failed previously due to a bug in Python that didn't always set __doc__ properly on instances of property subclasses. """ class A: # Inherits docstring from getter @classproperty def foo(cls): """The foo.""" return 1 assert A.__dict__['foo'].__doc__ == "The foo." class B: # Use doc passed to classproperty constructor def _get_foo(cls): return 1 foo = classproperty(_get_foo, doc="The foo.") assert B.__dict__['foo'].__doc__ == "The foo." def test_classproperty_lazy_threadsafe(fast_thread_switching): """ Test that a class property with lazy=True is thread-safe. """ workers = 8 with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor: # This is testing for race conditions, so try many times in the # hope that we'll get the timing right. for p in range(10000): class A: @classproperty(lazy=True) def foo(cls): nonlocal calls calls += 1 return object() # Have all worker threads query in parallel calls = 0 futures = [executor.submit(lambda: A.foo) for i in range(workers)] # Check that only one call happened and they all received it values = [future.result() for future in futures] assert calls == 1 assert values[0] is not None assert values == [values[0]] * workers def test_lazyproperty_threadsafe(fast_thread_switching): """ Test thread safety of lazyproperty. """ # This test is generally similar to test_classproperty_lazy_threadsafe # above. See there for comments. class A: def __init__(self): self.calls = 0 @lazyproperty def foo(self): self.calls += 1 return object() workers = 8 with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor: for p in range(10000): a = A() futures = [executor.submit(lambda: a.foo) for i in range(workers)] values = [future.result() for future in futures] assert a.calls == 1 assert a.foo is not None assert values == [a.foo] * workers def test_format_doc_stringInput_simple(): # Simple tests with string input docstring_fail = '' # Raises an valueerror if input is empty with pytest.raises(ValueError): @format_doc(docstring_fail) def testfunc_fail(): pass docstring = 'test' # A first test that replaces an empty docstring @format_doc(docstring) def testfunc_1(): pass assert inspect.getdoc(testfunc_1) == docstring # Test that it replaces an existing docstring @format_doc(docstring) def testfunc_2(): '''not test''' pass assert inspect.getdoc(testfunc_2) == docstring def test_format_doc_stringInput_format(): # Tests with string input and formatting docstring = 'yes {0} no {opt}' # Raises an indexerror if not given the formatted args and kwargs with pytest.raises(IndexError): @format_doc(docstring) def testfunc1(): pass # Test that the formatting is done right @format_doc(docstring, '/', opt='= life') def testfunc2(): pass assert inspect.getdoc(testfunc2) == 'yes / no = life' # Test that we can include the original docstring docstring2 = 'yes {0} no {__doc__}' @format_doc(docstring2, '/') def testfunc3(): '''= 2 / 2 * life''' pass assert inspect.getdoc(testfunc3) == 'yes / no = 2 / 2 * life' def test_format_doc_objectInput_simple(): # Simple tests with object input def docstring_fail(): pass # Self input while the function has no docstring raises an error with pytest.raises(ValueError): @format_doc(docstring_fail) def testfunc_fail(): pass def docstring0(): '''test''' pass # A first test that replaces an empty docstring @format_doc(docstring0) def testfunc_1(): pass assert inspect.getdoc(testfunc_1) == inspect.getdoc(docstring0) # Test that it replaces an existing docstring @format_doc(docstring0) def testfunc_2(): '''not test''' pass assert inspect.getdoc(testfunc_2) == inspect.getdoc(docstring0) def test_format_doc_objectInput_format(): # Tests with object input and formatting def docstring(): '''test {0} test {opt}''' pass # Raises an indexerror if not given the formatted args and kwargs with pytest.raises(IndexError): @format_doc(docstring) def testfunc_fail(): pass # Test that the formatting is done right @format_doc(docstring, '+', opt='= 2 * test') def testfunc2(): pass assert inspect.getdoc(testfunc2) == 'test + test = 2 * test' # Test that we can include the original docstring def docstring2(): '''test {0} test {__doc__}''' pass @format_doc(docstring2, '+') def testfunc3(): '''= 4 / 2 * test''' pass assert inspect.getdoc(testfunc3) == 'test + test = 4 / 2 * test' def test_format_doc_selfInput_simple(): # Simple tests with self input # Self input while the function has no docstring raises an error with pytest.raises(ValueError): @format_doc(None) def testfunc_fail(): pass # Test that it keeps an existing docstring @format_doc(None) def testfunc_1(): '''not test''' pass assert inspect.getdoc(testfunc_1) == 'not test' def test_format_doc_selfInput_format(): # Tests with string input which is '__doc__' (special case) and formatting # Raises an indexerror if not given the formatted args and kwargs with pytest.raises(IndexError): @format_doc(None) def testfunc_fail(): '''dum {0} dum {opt}''' pass # Test that the formatting is done right @format_doc(None, 'di', opt='da dum') def testfunc1(): '''dum {0} dum {opt}''' pass assert inspect.getdoc(testfunc1) == 'dum di dum da dum' # Test that we cannot recursively insert the original documentation @format_doc(None, 'di') def testfunc2(): '''dum {0} dum {__doc__}''' pass assert inspect.getdoc(testfunc2) == 'dum di dum ' def test_format_doc_onMethod(): # Check if the decorator works on methods too, to spice it up we try double # decorator docstring = 'what we do {__doc__}' class TestClass: @format_doc(docstring) @format_doc(None, 'strange.') def test_method(self): '''is {0}''' pass assert inspect.getdoc(TestClass.test_method) == 'what we do is strange.' def test_format_doc_onClass(): # Check if the decorator works on classes too docstring = 'what we do {__doc__} {0}{opt}' @format_doc(docstring, 'strange', opt='.') class TestClass: '''is''' pass assert inspect.getdoc(TestClass) == 'what we do is strange.'