Repository: django-oscar/django-oscar

Issue:
Don't use `deprecated` decorator on a class
I'm currently on v1.0.2

I recently tried upgrading to 1.1.1 but I encountered a confusing problem.  I started receiving an error like:

``` python
TypeError at /
Error when calling the metaclass bases
    function() argument 1 must be code, not str

...

/home/docker/venv/local/lib/python2.7/site-packages/oscar/apps/catalogue/views.py in <module>
    'catalogue.search_handlers', 'get_product_search_handler_class') ...
▶ Local vars

/home/docker/venv/local/lib/python2.7/site-packages/oscar/core/loading.py in get_class
    return get_classes(module_label, [classname])[0] ...
▶ Local vars

/home/docker/venv/local/lib/python2.7/site-packages/oscar/core/loading.py in get_classes
        local_module = _import_module(local_module_label, classnames) ...
▶ Local vars

/home/docker/venv/local/lib/python2.7/site-packages/oscar/core/loading.py in _import_module
        return __import__(module_label, fromlist=classnames) ...
▼ Local vars
Variable    Value
classnames  ['get_product_search_handler_class']
module_label    'dpm.catalogue.search_handlers'

./dpm/catalogue/search_handlers.py in <module>
    from oscar.apps.catalogue.search_handlers import ProductSearchHandler as OscarProductSearchHandler

    class ProductSearchHandler(OscarProductSearchHandler): ...
        def get_search_queryset(self):
            sqs = super(ProductSearchHandler, self).get_search_queryset()
            return sqs.exclude(num_in_stock=0)
▼ Local vars
Variable    Value
OscarProductSearchHandler    None
```

It seemed like a circular import or something.  I just now worked out what is wrong ...

In 1.1.1 catalogue/search_handlers.py there is this:

``` python
# Deprecated name. TODO: Remove in Oscar 1.2
ProductSearchHandler = deprecated(SolrProductSearchHandler)
```

This is fine if you try and instantiate (i.e. `__call__`) the class. But apparently it breaks any code that tries to inherit from that class.

Once I worked this out I had an easy fix for my app, just import `SolrProductSearchHandler` and inherit from that instead.

...but what happened is not 'deprecation', it was a breaking change - I guess unintentional?

It seems a different decorator is required if 'painless' deprecation of possibly-overridden classes is intended, eg:

``` python
def deprecated_cls(cls):
    class NewClass(cls):
        def __init__(self, *args, **kwargs):
            message = "Class '%s' is deprecated and will be " \
                "removed in the next major version of django-oscar" \
                % cls.__name__
            warnings.warn(message, DeprecationWarning, stacklevel=2)
            super(NewClass, self).__init__(*args, **kwargs)
    return NewClass
```



Discussion:

Resolving patch:
diff --git a/src/oscar/core/decorators.py b/src/oscar/core/decorators.py
--- a/src/oscar/core/decorators.py
+++ b/src/oscar/core/decorators.py
@@ -1,7 +1,22 @@
+try:
+    from types import ClassType
+except ImportError:
+    # Python 3
+    CHECK_TYPES = (type,)
+else:
+    # Python 2: new and old-style classes
+    CHECK_TYPES = (type, ClassType)
 import warnings
 
 
-def deprecated(f):
+def deprecated(obj):
+    if isinstance(obj, CHECK_TYPES):
+        return _deprecated_cls(cls=obj)
+    else:
+        return _deprecated_func(f=obj)
+
+
+def _deprecated_func(f):
     def _deprecated(*args, **kwargs):
         message = "Method '%s' is deprecated and will be " \
             "removed in the next major version of django-oscar" \
@@ -9,3 +24,14 @@ def _deprecated(*args, **kwargs):
         warnings.warn(message, DeprecationWarning, stacklevel=2)
         return f(*args, **kwargs)
     return _deprecated
+
+
+def _deprecated_cls(cls):
+    class Deprecated(cls):
+        def __init__(self, *args, **kwargs):
+            message = "Class '%s' is deprecated and will be " \
+                "removed in the next major version of django-oscar" \
+                % cls.__name__
+            warnings.warn(message, DeprecationWarning, stacklevel=2)
+            super(Deprecated, self).__init__(*args, **kwargs)
+    return Deprecated


Test patch:
diff --git a/tests/unit/core/decorator_tests.py b/tests/unit/core/decorator_tests.py
--- a/tests/unit/core/decorator_tests.py
+++ b/tests/unit/core/decorator_tests.py
@@ -1,6 +1,9 @@
+import warnings
+
 from django.contrib.auth.models import AnonymousUser, Permission
 from django.test import TestCase
 
+from oscar.core.decorators import deprecated
 from oscar.test import factories
 from oscar.views.decorators import check_permissions
 
@@ -35,4 +38,53 @@ def test_permissions_are_checked(self):
             check_permissions(user_without_perm, ['address.add_country']))
 
 
+class TestDeprecatedDecorator(TestCase):
+
+    def test_decorate_function(self):
+
+        @deprecated
+        def func():
+            return True
+
+        with warnings.catch_warnings(record=True) as caught:
+            warnings.simplefilter("always")
+            self.assertTrue(func())
+            assert len(caught) == 1
+            assert issubclass(caught[0].category, DeprecationWarning)
+
+    def test_decorate_class(self):
+
+        class Cls(object):
+            val = False
+
+            def __init__(self):
+                self.val = True
+
+        Deprecated = deprecated(Cls)  # noqa
+
+        with warnings.catch_warnings(record=True) as caught:
+            warnings.simplefilter("always")
+            obj = Deprecated()
+            self.assertTrue(obj.val)
+            assert len(caught) == 1
+            assert issubclass(caught[0].category, DeprecationWarning)
+
+    def test_subclass_decorated(self):
+
+        class Cls(object):
+            val = False
+
+            def __init__(self):
+                self.val = True
+
+        Deprecated = deprecated(Cls)  # noqa
+
+        class SubCls(Deprecated):
+            pass
 
+        with warnings.catch_warnings(record=True) as caught:
+            warnings.simplefilter("always")
+            obj = SubCls()
+            self.assertTrue(obj.val)
+            assert len(caught) == 1
+            assert issubclass(caught[0].category, DeprecationWarning)