Repository: moogar0880/PyTrakt

Issue:
accessing User.list attribute throws python exception
python version: 3.6.8
trakt version: 2.12.0

when trying to get a list of the users lists an exception is returned
ie
```
import trakt
from trakt.users import User, UserList
from pprint import pprint

trakt.core.CLIENT_ID = cfg['client_id']
trakt.core.CLIENT_SECRET = cfg['client_secret']
trakt.core.OAUTH_TOKEN = cfg['oauth_token']

u = User('my_user')
pprint(u.lists)
```

returns the following exception:

```
Traceback (most recent call last):
  File "./tlist.py", line 38, in <module>
    pprint(u.lists)
  File "/opt/mediascripts/lib/python3.6/site-packages/trakt/core.py", line 474, in inner
    return generator.send(json_data)
  File "/opt/mediascripts/lib/python3.6/site-packages/trakt/users.py", line 299, in lists
    **extract_ids(ul)) for ul in data]
  File "/opt/mediascripts/lib/python3.6/site-packages/trakt/users.py", line 299, in <listcomp>
    **extract_ids(ul)) for ul in data]
TypeError: type object got multiple values for keyword argument 'user'
```

However, querying a specific list by name does work
`mylist = u.get_list('my-list-name')`



Comments:

Patch:
diff --git a/trakt/core.py b/trakt/core.py
index e03d1a2..5823ce0 100644
--- a/trakt/core.py
+++ b/trakt/core.py
@@ -14,6 +14,7 @@ from collections import namedtuple
 from functools import wraps
 from requests.compat import urljoin
 from requests_oauthlib import OAuth2Session
+from datetime import datetime, timedelta
 from trakt import errors
 
 __author__ = 'Jon Nappi'
@@ -44,6 +45,12 @@ CONFIG_PATH = os.path.join(os.path.expanduser('~'), '.pytrakt.json')
 #: Your personal Trakt.tv OAUTH Bearer Token
 OAUTH_TOKEN = api_key = None
 
+# OAuth token validity checked
+OAUTH_TOKEN_VALID = None
+
+# Your OAUTH token expiration date
+OAUTH_EXPIRES_AT = None
+
 # Your OAUTH refresh token
 OAUTH_REFRESH = None
 
@@ -185,10 +192,13 @@ def oauth_auth(username, client_id=None, client_secret=None, store=False,
     # Fetch, assign, and return the access token
     oauth.fetch_token(token_url, client_secret=CLIENT_SECRET, code=oauth_pin)
     OAUTH_TOKEN = oauth.token['access_token']
+    OAUTH_REFRESH = oauth.token['refresh_token']
+    OAUTH_EXPIRES_AT = oauth.token["created_at"] + oauth.token["expires_in"]
 
     if store:
         _store(CLIENT_ID=CLIENT_ID, CLIENT_SECRET=CLIENT_SECRET,
-               OAUTH_TOKEN=OAUTH_TOKEN)
+               OAUTH_TOKEN=OAUTH_TOKEN, OAUTH_REFRESH=OAUTH_REFRESH,
+               OAUTH_EXPIRES_AT=OAUTH_EXPIRES_AT)
     return OAUTH_TOKEN
 
 
@@ -263,11 +273,13 @@ def get_device_token(device_code, client_id=None, client_secret=None,
         data = response.json()
         OAUTH_TOKEN = data.get('access_token')
         OAUTH_REFRESH = data.get('refresh_token')
+        OAUTH_EXPIRES_AT = data.get("created_at") + data.get("expires_in")
 
         if store:
             _store(
                 CLIENT_ID=CLIENT_ID, CLIENT_SECRET=CLIENT_SECRET,
-                OAUTH_TOKEN=OAUTH_TOKEN, OAUTH_REFRESH=OAUTH_REFRESH
+                OAUTH_TOKEN=OAUTH_TOKEN, OAUTH_REFRESH=OAUTH_REFRESH,
+                OAUTH_EXPIRES_AT=OAUTH_EXPIRES_AT
             )
 
     return response
@@ -351,6 +363,55 @@ Comment = namedtuple('Comment', ['id', 'parent_id', 'created_at', 'comment',
                                  'updated_at', 'likes', 'user_rating'])
 
 
+def _validate_token(s):
+    """Check if current OAuth token has not expired"""
+    global OAUTH_TOKEN_VALID
+    current = datetime.utcnow()
+    expires_at = datetime.utcfromtimestamp(OAUTH_EXPIRES_AT)
+    if expires_at - current > timedelta(days=2):
+        OAUTH_TOKEN_VALID = True
+    else:
+        _refresh_token(s)
+
+
+def _refresh_token(s):
+    """Request Trakt API for a new valid OAuth token using refresh_token"""
+    global OAUTH_TOKEN, OAUTH_EXPIRES_AT, OAUTH_REFRESH, OAUTH_TOKEN_VALID
+    s.logger.info("OAuth token has expired, refreshing now...")
+    url = urljoin(BASE_URL, '/oauth/token')
+    data = {
+                'client_id': CLIENT_ID,
+                'client_secret': CLIENT_SECRET,
+                'refresh_token': OAUTH_REFRESH,
+                'redirect_uri': REDIRECT_URI,
+                'grant_type': 'refresh_token'
+            }
+    response = requests.post(url, json=data, headers=HEADERS)
+    s.logger.debug('RESPONSE [post] (%s): %s', url, str(response))
+    if response.status_code == 200:
+        data = response.json()
+        OAUTH_TOKEN = data.get("access_token")
+        OAUTH_REFRESH = data.get("refresh_token")
+        OAUTH_EXPIRES_AT = data.get("created_at") + data.get("expires_in")
+        OAUTH_TOKEN_VALID = True
+        s.logger.info(
+            "OAuth token successfully refreshed, valid until",
+            datetime.fromtimestamp(OAUTH_EXPIRES_AT)
+        )
+        _store(
+            CLIENT_ID=CLIENT_ID, CLIENT_SECRET=CLIENT_SECRET,
+            OAUTH_TOKEN=OAUTH_TOKEN, OAUTH_REFRESH=OAUTH_REFRESH,
+            OAUTH_EXPIRES_AT=OAUTH_EXPIRES_AT
+        )
+    elif response.status_code == 401:
+        s.logger.debug(
+            "Rejected - Unable to refresh expired OAuth token, "
+            "refresh_token is invalid"
+        )
+    elif response.status_code in s.error_map:
+        raise s.error_map[response.status_code]()
+
+
 def _bootstrapped(f):
     """Bootstrap your authentication environment when authentication is needed
     and if a file at `CONFIG_PATH` exists. The process is completed by setting
@@ -358,8 +419,8 @@ def _bootstrapped(f):
     """
     @wraps(f)
     def inner(*args, **kwargs):
-        global CLIENT_ID, CLIENT_SECRET, OAUTH_TOKEN, OAUTH_REFRESH
-        global APPLICATION_ID
+        global CLIENT_ID, CLIENT_SECRET, OAUTH_TOKEN, OAUTH_EXPIRES_AT
+        global OAUTH_REFRESH, APPLICATION_ID
         if (CLIENT_ID is None or CLIENT_SECRET is None) and \
                 os.path.exists(CONFIG_PATH):
             # Load in trakt API auth data from CONFIG_PATH
@@ -372,11 +433,17 @@ def _bootstrapped(f):
                 CLIENT_SECRET = config_data.get('CLIENT_SECRET', None)
             if OAUTH_TOKEN is None:
                 OAUTH_TOKEN = config_data.get('OAUTH_TOKEN', None)
+            if OAUTH_EXPIRES_AT is None:
+                OAUTH_EXPIRES_AT = config_data.get('OAUTH_EXPIRES_AT', None)
             if OAUTH_REFRESH is None:
                 OAUTH_REFRESH = config_data.get('OAUTH_REFRESH', None)
             if APPLICATION_ID is None:
                 APPLICATION_ID = config_data.get('APPLICATION_ID', None)
 
+            # Check token validity and refresh token if needed
+            if (not OAUTH_TOKEN_VALID and OAUTH_EXPIRES_AT is not None and
+                    OAUTH_REFRESH is not None):
+                _validate_token(args[0])
             # For backwards compatability with trakt<=2.3.0
             if api_key is not None and OAUTH_TOKEN is None:
                 OAUTH_TOKEN = api_key
diff --git a/trakt/errors.py b/trakt/errors.py
index 586af7c..7d69f10 100644
--- a/trakt/errors.py
+++ b/trakt/errors.py
@@ -11,7 +11,7 @@ __all__ = ['TraktException', 'BadRequestException', 'OAuthException',
            'TraktUnavailable']
 
 
-class TraktException(BaseException):
+class TraktException(Exception):
     """Base Exception type for trakt module"""
     http_code = message = None
 
diff --git a/trakt/users.py b/trakt/users.py
index b75ad6a..d4f48bd 100644
--- a/trakt/users.py
+++ b/trakt/users.py
@@ -295,6 +295,10 @@ class User(object):
             data = yield 'users/{username}/lists'.format(
                 username=self.username
             )
+            for ul in data:
+                if "user" in ul:
+                    # user will be replaced with the self User object
+                    del ul["user"]
             self._lists = [UserList(creator=self.username, user=self,
                            **extract_ids(ul)) for ul in data]
         yield self._lists


Test patch:
diff --git a/tests/mock_data/users.json b/tests/mock_data/users.json
index a1ff93d..f0222c3 100644
--- a/tests/mock_data/users.json
+++ b/tests/mock_data/users.json
@@ -206,7 +206,15 @@
                 "item_count":5,
                 "comment_count":0,
                 "likes":0,
-                "ids":{"trakt":55,"slug":"star-wars-in-machete-order"}
+                "ids":{"trakt":55,"slug":"star-wars-in-machete-order"},
+                "user":{
+                    "username": "sean",
+                    "private": false,
+                    "name": "Sean Rudford",
+                    "vip": true,
+                    "vip_ep": true,
+                    "ids":{"slug":"sean"}
+                }
             },
             {
                 "name":"Vampires FTW",
@@ -221,7 +229,15 @@
                 "item_count":5,
                 "comment_count":0,
                 "likes":0,
-                "ids":{"trakt":52,"slug":"vampires-ftw"}
+                "ids":{"trakt":52,"slug":"vampires-ftw"},
+                "user":{
+                    "username": "sean",
+                    "private": false,
+                    "name": "Sean Rudford",
+                    "vip": true,
+                    "vip_ep": true,
+                    "ids":{"slug":"sean"}
+                }
             }
         ],
         "POST": {