Repository: MushroomMaula/fastapi_login

Issue:
TypeError: the first argument must be callable
My program was developed based on fastapi_login==1.6.2 

Today, I upgrade fastapi_login to the latest version: 1.7.0. When I login, it raises error.

some of the error code:

```
2021-09-23 14:58:04 | TypeError: the first argument must be callable
-- | --
  |   | 2021-09-23 14:58:04 | self._user_callback = ordered_partial(callback, *args, **kwargs)
  |   | 2021-09-23 14:58:04 | File "/usr/local/lib/python3.8/site-packages/fastapi_login/fastapi_login.py", line 147, in decorator
  |   | 2021-09-23 14:58:04 | user = load_user(name)
```

The function `user_loader` in `fastapi_login.py` of version 1.6.2 is:

```python
    def user_loader(self, callback: Union[Callable, Awaitable]) -> Union[Callable, Awaitable]:
        self._user_callback = callback
        return callback
```

However, In the latest version, it becomes to:

```python
def user_loader(self, *args, **kwargs) -> Union[Callable, Awaitable]:
        def decorator(callback: Union[Callable, Awaitable]):
            """
            The actual setter of the load_user callback
            Args:
                callback (Callable or Awaitable): The callback which returns the user
            Returns:
                Partial of the callback with given args and keyword arguments already set
            """
            self._user_callback = ordered_partial(callback, *args, **kwargs)
        return decorator
```

You add the function `ordered_partial` here cause my problem. But When I refer to the release note, I did not find any notice for this change, only find a pull request note in #56 . It seems that your code is not backwards compatible. @MushroomMaula 

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8aa667a..caac8d4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,25 @@
+# 1.7.1
+Fixes backwards compatibility ([#58](https://github.com/MushroomMaula/fastapi_login/issues/58)) of the ``LoginManager.user_loader`` decorator.
+In version 1.7.0 it was not possible to do the following anymore:
+```py
+@manager.user_loader
+def load_user(email):
+    ...
+```
+This has been fixed now.
+
+It is however recommended to just add empty parentheses after the decorator
+if you don't wish to pass extra arguments to your callback. 
+````python
+@manager.user_loader()
+def load_user(email):
+    ...
+````
+Because of the backwards compatibility it is not possible to pass a 
+callable object as the first argument to the decorator.
+If this is needed it has to be passed as a keyword argument.
+This is detailed more in depth in the [documentation](https://fastapi-login.readthedocs.io/advanced_usage/#predefining-additional-user_loader-arguments).
+
 # 1.7.0
 Configuration is now more pythonic using arguments on initialization
     instead of class attributes
@@ -9,7 +31,7 @@ Configuration is now more pythonic using arguments on initialization
 Added ``py.typed`` file for better mypy support.
 
 The ``user_loader`` decorator now takes (keyword) arguments, which will then be used, when
-the declared callback is called. Have a look at the [documentation](https://fastapi-login.readthedocs.io/advanced_usage.html#predefining-additional-user_loader-arguments)
+the declared callback is called. Have a look at the [documentation](https://fastapi-login.readthedocs.io/advanced_usage/#predefining-additional-user_loader-arguments)
 
 # 1.6.3
 - Fixes bug not being able to catch ``LoginManager.not_authenticated_exception`` in ``LoginManager.has_scopes``. ([#47](https://github.com/MushroomMaula/fastapi_login/issues/47) thanks to [kigawas](https://github.com/kigawas))
diff --git a/README.md b/README.md
index ba96e01..cd6d260 100644
--- a/README.md
+++ b/README.md
@@ -44,7 +44,7 @@ Now we have to provide the ``LoginManager`` with a way to load our user. The
 `user_loader` callback should either return your user object or ``None``
 
 ````python
-@manager.user_loader
+@manager.user_loader()
 def load_user(email: str):  # could also be an asynchronous function
     user = fake_db.get(email)
     return user
@@ -96,6 +96,9 @@ class NotAuthenticatedException(Exception):
 def exc_handler(request, exc):
     return RedirectResponse(url='/login')
 
+# This will be deprecated in the future
+# set your exception when initiating the instance
+# manager = LoginManager(..., custom_exception=NotAuthenticatedException)
 manager.not_authenticated_exception = NotAuthenticatedException
 # You also have to add an exception handler to your app instance
 app.add_exception_handler(NotAuthenticatedException, exc_handler)
diff --git a/docs/advanced_usage.md b/docs/advanced_usage.md
index 84997d6..3ceb93e 100644
--- a/docs/advanced_usage.md
+++ b/docs/advanced_usage.md
@@ -88,4 +88,24 @@ The ``LoginManager.user_loader`` can also take arguments which will be passed on
 callback
 ````python hl_lines="1"
 {!../docs_src/advanced_usage/adv_usage_009.py!}
-````
\ No newline at end of file
+````
+!!! bug "Callable as extra argument" 
+    Before version 1.7.0 empty parentheses were not needed after the decorator. 
+    To provide backwards compatibility it's still possible to omit them as of v1.7.1.
+    Because of this it's however not possible to pass a callable object as the 
+    first extra argument.
+
+    This will not work:
+    ````py
+    def some_callable_object(...):
+        ...
+
+    @manager.user_loader(some_callable_object)
+    def load_user(email, some_callable):
+        ...
+    ````
+    If you need this functionality you need to use keyword arguments:
+    ```py
+    @manager.user_loader(some_callable=some_callable_object)
+    def load_user(email, some_callable)
+    ```
\ No newline at end of file
diff --git a/docs/setup.md b/docs/setup.md
index 4d47957..c90db78 100644
--- a/docs/setup.md
+++ b/docs/setup.md
@@ -59,6 +59,27 @@ It's as simple as it gets, you just have to add this decorator to the query func
 ````
 
 !!! note 
-    ``manager`` in this context is an instance of ``LoginManager`` 
+    ``manager`` in this context is an instance of ``LoginManager``
+
+!!! bug "Callable as extra argument" 
+    Before version 1.7.0 the empty parentheses were not needed after the decorator. 
+    To provide backwards compatibility it's still possible to omit them as of v1.7.1.
+    Because of this it's however not possible to pass a callable object as the 
+    first extra argument.
+
+    This will not work:
+    ````py
+    def some_callable_object(...):
+        ...
+
+    @manager.user_loader(some_callable_object)
+    def load_user(email, some_callable):
+        ...
+    ````
+    If you need this functionality you need to use keyword arguments:
+    ```py
+    @manager.user_loader(some_callable=some_callable_object)
+    def load_user(email, some_callable)
+    ```
 
 To see how to use ``fastapi-login`` as a dependency continue with [usage](usage.md).
\ No newline at end of file
diff --git a/docs/stylesheets/reference.css b/docs/stylesheets/reference.css
index 8657ec3..578bede 100644
--- a/docs/stylesheets/reference.css
+++ b/docs/stylesheets/reference.css
@@ -3,5 +3,5 @@
 }
 
 thead {
-  background: hsla(175, 100%, 62%, 1);
+  background: hsl(0, 0%, 90%);
 }
\ No newline at end of file
diff --git a/docs/usage.md b/docs/usage.md
index 0baa4c2..3efd740 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -8,7 +8,7 @@ First we need to create a login route for our users.
 ````
 
 !!! note
-    We use the same url(``"/login"``) we have used as the ``tokenUrl`` argument,
+    We use the same url(``"/login"``) we have used as the ``token_url`` argument,
     when initiating our ``LoginManager`` instance
 
 ## Returning token
@@ -31,11 +31,11 @@ can be used as a dependency.
 ````
 
 !!! note 
-    ``user`` in this case will be whatever ``LoginManager.user_loader`` returns.
+    ``user`` in this case will be whatever the with ``LoginManager.user_loader`` decorated function returns.
     In case user is ``None`` the `LoginManager.not_authenticated_exception`
     will be thrown, this results by default to 
-    `fastapi_login.exception.InvalidCredentialsException`.
-    If a [custom exception](advanced_usage.md#exception-handling) been set by the user it will be
+    `fastapi_login.exceptions.InvalidCredentialsException`.
+    If a [custom exception](advanced_usage.md#exception-handling) has been set by the user it will be
     raised instead.
 
 !!! attention
@@ -44,4 +44,4 @@ can be used as a dependency.
     ````
     Bearer <token>
     ````
-    If your token is sent as a cookie have a look at [Advanced Usage](http://127.0.0.1:8000/advanced_usage.html#cookies)
\ No newline at end of file
+    If your token is sent as a cookie have a look at [Advanced Usage](https://fastapi-login.readthedocs.io/advanced_usage/#cookies)
\ No newline at end of file
diff --git a/docs_src/advanced_usage/adv_usage_004.py b/docs_src/advanced_usage/adv_usage_004.py
index 2c16abf..f3ba47f 100644
--- a/docs_src/advanced_usage/adv_usage_004.py
+++ b/docs_src/advanced_usage/adv_usage_004.py
@@ -5,7 +5,7 @@ from starlette.responses import RedirectResponse
 class NotAuthenticatedException(Exception):
     pass
 
-# Before version 1.6.4
+# Before version 1.7.0
 # manager.not_authenticated_exception = NotAuthenticatedException
 
 # The updated way
diff --git a/docs_src/setup/setup_005.py b/docs_src/setup/setup_005.py
index 057455a..1dfedd9 100644
--- a/docs_src/setup/setup_005.py
+++ b/docs_src/setup/setup_005.py
@@ -1,3 +1,3 @@
-@manager.user_loader
+@manager.user_loader()
 def query_user(user_id: str):
     ...
diff --git a/fastapi_login/fastapi_login.py b/fastapi_login/fastapi_login.py
index bfe6c73..fae8b7c 100644
--- a/fastapi_login/fastapi_login.py
+++ b/fastapi_login/fastapi_login.py
@@ -146,6 +146,22 @@ class LoginManager(OAuth2PasswordBearer):
             """
             self._user_callback = ordered_partial(callback, *args, **kwargs)
 
+        # If the only argument is also a callable this will lead to errors
+        if len(args) == 1 and len(kwargs) == 0 and callable(args[0]):
+            # No arguments have been passed and no empty parentheses have been used
+            # this was the old way (before 1.7.0) of decorating the method.
+            # Thus we assume the first argument is the actual callback.
+            fn = args[0]
+            # If we dont empty args the callback will be passed twice to ordered_partial
+            args = ()
+
+            warnings.warn(SyntaxWarning(
+                "As of version 1.7.0 decorating your callback like this is not recommended anymore.\n"
+                "Please add empty parentheses like this @manager.user_loader() if you don't"
+                "which to pass additional arguments to your callback."
+            ))
+            return decorator(fn)
+
         return decorator
 
     def _get_payload(self, token: str):
diff --git a/poetry.lock b/poetry.lock
index 3f04e9b..4bd5cb3 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -261,7 +261,7 @@ mkdocs = ">=1.1,<2.0"
 
 [[package]]
 name = "mkdocs-material"
-version = "7.2.6"
+version = "7.3.0"
 description = "A Material Design theme for MkDocs"
 category = "dev"
 optional = false
@@ -284,20 +284,20 @@ python-versions = ">=3.6"
 
 [[package]]
 name = "mkdocstrings"
-version = "0.15.2"
+version = "0.16.0"
 description = "Automatic documentation from sources, for MkDocs."
 category = "dev"
 optional = false
-python-versions = ">=3.6,<4.0"
+python-versions = ">=3.6"
 
 [package.dependencies]
 Jinja2 = ">=2.11.1,<4.0"
 Markdown = ">=3.3,<4.0"
 MarkupSafe = ">=1.1,<3.0"
-mkdocs = ">=1.1.1,<2.0.0"
-mkdocs-autorefs = ">=0.1,<0.3"
+mkdocs = ">=1.2,<2.0"
+mkdocs-autorefs = ">=0.1,<0.4"
 pymdown-extensions = ">=6.3,<9.0"
-pytkdocs = ">=0.2.0,<0.12.0"
+pytkdocs = ">=0.2.0,<0.13.0"
 
 [[package]]
 name = "multidict"
@@ -779,16 +779,16 @@ mkdocs-autorefs = [
     {file = "mkdocs_autorefs-0.2.1-py3-none-any.whl", hash = "sha256:f301b983a34259df90b3fcf7edc234b5e6c7065bd578781e66fd90b8cfbe76be"},
 ]
 mkdocs-material = [
-    {file = "mkdocs-material-7.2.6.tar.gz", hash = "sha256:4bdeff63904680865676ceb3193216934de0b33fa5b2446e0a84ade60929ee54"},
-    {file = "mkdocs_material-7.2.6-py2.py3-none-any.whl", hash = "sha256:4c6939b9d7d5c6db948ab02df8525c64211828ddf33286acea8b9d2115cec369"},
+    {file = "mkdocs-material-7.3.0.tar.gz", hash = "sha256:07db0580fa96c3473aee99ec3fb4606a1a5a1e4f4467e64c0cd1ba8da5b6476e"},
+    {file = "mkdocs_material-7.3.0-py2.py3-none-any.whl", hash = "sha256:b183c27dc0f44e631bbc32c51057f61a3e2ba8b3c1080e59f944167eeba9ff1d"},
 ]
 mkdocs-material-extensions = [
     {file = "mkdocs-material-extensions-1.0.3.tar.gz", hash = "sha256:bfd24dfdef7b41c312ede42648f9eb83476ea168ec163b613f9abd12bbfddba2"},
     {file = "mkdocs_material_extensions-1.0.3-py3-none-any.whl", hash = "sha256:a82b70e533ce060b2a5d9eb2bc2e1be201cf61f901f93704b4acf6e3d5983a44"},
 ]
 mkdocstrings = [
-    {file = "mkdocstrings-0.15.2-py3-none-any.whl", hash = "sha256:8d6cbe64c07ae66739010979ca01d49dd2f64d1a45009f089d217b9cd2a65e36"},
-    {file = "mkdocstrings-0.15.2.tar.gz", hash = "sha256:c2fee9a3a644647c06eb2044fdfede1073adfd1a55bf6752005d3db10705fe73"},
+    {file = "mkdocstrings-0.16.0-py3-none-any.whl", hash = "sha256:cfc99bc7850fb2ae00519c33f7ae5274609857631822eda561d8a9fb8adb6d79"},
+    {file = "mkdocstrings-0.16.0.tar.gz", hash = "sha256:58016945a348e03cc92bbd11cbacb3e41b064ddfc33d4b02ff4304fb45eab104"},
 ]
 multidict = [
     {file = "multidict-5.1.0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:b7993704f1a4b204e71debe6095150d43b2ee6150fa4f44d6d966ec356a8d61f"},
diff --git a/requirements.txt b/requirements.txt
index 9e8bf5c..f8b5a32 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -13,16 +13,16 @@ ghp-import==2.0.1; python_version >= "3.6"
 idna==3.2; python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.5"
 importlib-metadata==4.8.1; python_version >= "3.6"
 iniconfig==1.1.1; python_version >= "3.6"
-jinja2==3.0.1; python_version >= "3.6" and python_version < "4.0"
+jinja2==3.0.1; python_version >= "3.6"
 markdown-include==0.6.0
 markdown==3.3.4; python_version >= "3.6" and python_version < "4.0"
-markupsafe==2.0.1; python_version >= "3.6" and python_version < "4.0"
+markupsafe==2.0.1; python_version >= "3.6"
 mergedeep==1.3.4; python_version >= "3.6"
 mkdocs-autorefs==0.2.1; python_version >= "3.6" and python_version < "4.0"
 mkdocs-material-extensions==1.0.3; python_version >= "3.6"
-mkdocs-material==7.2.6
+mkdocs-material==7.3.0
 mkdocs==1.2.2; python_version >= "3.6"
-mkdocstrings==0.15.2; python_version >= "3.6" and python_version < "4.0"
+mkdocstrings==0.16.0; python_version >= "3.6"
 multidict==5.1.0; python_version >= "3.6"
 packaging==21.0; python_version >= "3.6"
 passlib==1.7.4
@@ -37,7 +37,7 @@ pyparsing==2.4.7; python_version >= "3.6" and python_full_version < "3.0.0" or p
 pytest-asyncio==0.15.1; python_version >= "3.6"
 pytest==6.2.5; python_version >= "3.6"
 python-dateutil==2.8.2; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.3.0" and python_version >= "3.6"
-pytkdocs==0.11.1; python_full_version >= "3.6.1" and python_full_version < "4.0.0" and python_version >= "3.6" and python_version < "4.0"
+pytkdocs==0.11.1; python_full_version >= "3.6.1" and python_full_version < "4.0.0" and python_version >= "3.6"
 pyyaml-env-tag==0.1; python_version >= "3.6"
 pyyaml==5.4.1; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.6"
 requests==2.26.0; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0"
diff --git a/setup.py b/setup.py
index 63b9d06..f0bde7e 100644
--- a/setup.py
+++ b/setup.py
@@ -6,7 +6,7 @@ with open("README.md", 'r') as f:
 
 setuptools.setup(
     name="fastapi-login",
-    version="1.7.0",
+    version="1.7.1",
     author="Max Rausch-Dupont",
     author_email="maxrd79@gmail.com",
     descritpion="Flask-Login like package for FastAPI",


Test patch:
diff --git a/tests/test_advanced/test_exception_handling.py b/tests/test_advanced/test_exception_handling.py
index 8d94150..e229ddd 100644
--- a/tests/test_advanced/test_exception_handling.py
+++ b/tests/test_advanced/test_exception_handling.py
@@ -32,6 +32,11 @@ def exception_manager(app, secret, token_url, load_user_fn, custom_exception) ->
     return instance
 
 
+def test_exception_setter_deprecated_warns(clean_manager, custom_exception):
+    with pytest.deprecated_call():
+        clean_manager.not_authenticated_exception = custom_exception
+
+
 @pytest.mark.asyncio
 async def test_exception_call_cookie_error_user_header_false(clean_manager):
     # setup clean_manager
diff --git a/tests/test_basic_functionality.py b/tests/test_basic_functionality.py
index 3309edb..9a6c562 100644
--- a/tests/test_basic_functionality.py
+++ b/tests/test_basic_functionality.py
@@ -67,6 +67,42 @@ async def test_user_loader_with_arguments(clean_manager, default_data, load_user
     loader.assert_called_with(default_data['sub'], db)
 
 
+@pytest.mark.asyncio
+async def test_user_loader_decorator_syntax_no_args(clean_manager, default_data):
+
+    @clean_manager.user_loader()
+    def load_user(email: str):
+        return default_data["sub"]
+
+    token = clean_manager.create_access_token(data=default_data)
+    result = await clean_manager.get_current_user(token)
+    assert result == default_data["sub"]
+
+
+@pytest.mark.asyncio
+async def test_user_loader_decorator_syntax_no_args_backwards_compatible(clean_manager, default_data):
+
+    @clean_manager.user_loader
+    def load_user(email: str):
+        return default_data["sub"]
+
+    token = clean_manager.create_access_token(data=default_data)
+    result = await clean_manager.get_current_user(token)
+    assert result == default_data["sub"]
+
+
+def test_user_loader_backwards_compatible_syntax_warns(clean_manager, load_user_fn):
+    with pytest.warns(SyntaxWarning) as record:
+        @clean_manager.user_loader
+        def fn(sub):
+            pass
+
+        clean_manager.user_loader(load_user_fn)
+
+    # A SyntaxWarning should be issued both times
+    assert len(record) == 2
+
+
 def test_token_from_cookie(clean_manager):
     request = Mock(cookies={clean_manager.cookie_name: "test-value"})
     token = clean_manager._token_from_cookie(request)