Skip to content

Commit 6c809cc

Browse files
committed
add refresh token support
1 parent fd9ce3d commit 6c809cc

File tree

4 files changed

+89
-19
lines changed

4 files changed

+89
-19
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,5 @@ venv.bak/
105105

106106
# PyCharm
107107
.idea/
108+
109+
upload-instructions

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ If we assume you saved the token value in a `access.token` file in your home dir
1515
initalize the client as follows:
1616

1717
```
18-
from pathlib import path
18+
from pathlib import Path
1919
from feedly.session import FeedlySession
2020
2121
token = (Path.home() / 'access.token').read_text().strip()
22-
sess = FeedlySession(auth_token=token)
22+
sess = FeedlySession(token)
2323
```
2424
Clients are lightweight -- you can keep a client around for the lifetime of your program,
2525
or you can create a new one when needed. It's a bit more efficient to keep it around. If you
@@ -112,3 +112,9 @@ quite a few entries at a time, see the previous section for details. Once you ge
112112
the client will stop any attempted requests until you have available quota.
113113

114114
To debug things, set the log level to `DEBUG`. This will print log messages on every API request.
115+
116+
### Token Management
117+
The above examples assume the auth (access) token is valid. However these tokens do expire. Instead
118+
of passing the auth token itself, you can create a `feedly.session.Auth` implementation to refresh
119+
the auth token. A file based implementation is already provided (`FileAuthStore`). Once this is done
120+
the client will automatically try to refresh the auth token if a `401` response is encountered.

feedly/session.py

Lines changed: 78 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,32 +11,87 @@
1111
from requests.exceptions import HTTPError
1212

1313
from requests import Session
14-
import urllib
1514

1615
from feedly.data import FeedlyUser
1716
from feedly.protocol import RateLimitedAPIError, BadRequestAPIError, UnauthorizedAPIError, ServerAPIError, APIClient, WrappedHTTPError
18-
from feedly.stream import EnterpriseStreamId
17+
18+
19+
class Auth:
20+
"""
21+
simple class to manage tokens
22+
"""
23+
def __init__(self, client_id:str='feedlydev', client_secret:str='feedlydev'):
24+
self.client_id:str = client_id
25+
self.client_secret:str = client_secret
26+
self._auth_token:str = None
27+
self.refresh_token:str = None
28+
29+
@property
30+
def auth_token(self):
31+
return self._auth_token
32+
33+
@auth_token.setter
34+
def auth_token(self, token:str):
35+
self._auth_token = token
36+
37+
38+
class FileAuthStore(Auth):
39+
"""
40+
a file based token storage scheme
41+
"""
42+
def __init__(self, token_dir:Path, client_id:str='feedlydev', client_secret:str='feedlydev'):
43+
"""
44+
45+
:param token_dir: the directory to store the tokens
46+
:param client_id: the client id to use when refreshing the auth token. the default value works for developer tokens.
47+
:param client_secret: the client secret to use when refreshing the auth token. the default value works for developer tokens.
48+
"""
49+
super().__init__(client_id, client_secret)
50+
if not token_dir.is_dir():
51+
raise ValueError(f'{token_dir.absolute()} does not exist!')
52+
53+
refresh_path = token_dir / 'refresh.token'
54+
if refresh_path.is_file():
55+
self.refresh_token = refresh_path.read_text().strip()
56+
57+
self.auth_token_path:Path = token_dir / 'access.token'
58+
self._auth_token = self.auth_token_path.read_text().strip()
59+
60+
@Auth.auth_token.setter
61+
def auth_token(self, token:str):
62+
self._auth_token = token
63+
self.auth_token_path.write_text(token)
1964

2065

2166
class FeedlySession(APIClient):
22-
def __init__(self, auth_token:str, api_host:str='https://feedly.com', name:str='feedly.python.client',
23-
user_id:str=None, client_name='feedly.python.client'):
67+
def __init__(self, auth:Union[str, Auth], api_host:str='https://feedly.com', user_id:str=None, client_name='feedly.python.client'):
68+
"""
69+
:param auth: either the access token str to use when making requests or an Auth object to manage tokens
70+
:param api_host: the feedly api server host.
71+
:param user_id: the user id to use when making requests. If not set, a request will be made to determine the user from the auth token.
72+
:param client_name: the name of your client, set this to something that can identify your app.
73+
"""
2474
super().__init__()
2575
if not client_name:
2676
raise ValueError('you must identify your client!')
2777

28-
self.auth_token:str = auth_token
78+
if isinstance(auth, str):
79+
token:str = auth
80+
auth = Auth()
81+
auth.auth_token = token
82+
83+
self.auth:Auth = auth
2984
self.api_host:str = api_host
3085
self.session = Session()
3186
self.session.mount('https://feedly.com', HTTPAdapter(max_retries=1)) # as to treat feedly server and connection errors identically
3287
self.client_name = client_name
3388
self.timeout:int = 10
3489
self.max_tries:int = 3
35-
self.name = urllib.parse.quote_plus(name)
3690

3791
user_data = {'id': user_id} if user_id else {}
3892
self._user:FeedlyUser = FeedlyUser(user_data, self)
3993
self._valid:bool = None
94+
self._last_token_refresh_attempt:float = 0
4095

4196
def __repr__(self):
4297
return f'<feedly client user={self.user.id}>'
@@ -75,14 +130,13 @@ def do_api_request(self, relative_url:str, method:str=None, data:Dict=None,
75130
:raises: requests.exceptions.HTTPError on failure. An appropriate subclass may be raised when appropriate,
76131
(see the ones defined in this module).
77132
"""
78-
79133
if self.timeout is None:
80134
timeout = self.timeout
81135

82136
if max_tries is None:
83137
max_tries = self.max_tries
84138

85-
if self.auth_token is None:
139+
if self.auth.auth_token is None:
86140
raise ValueError('authorization token required!')
87141

88142
if relative_url[0] != '/':
@@ -113,7 +167,7 @@ def do_api_request(self, relative_url:str, method:str=None, data:Dict=None,
113167
if self.rate_limiter.rate_limited:
114168
until = datetime.datetime.fromtimestamp(self.rate_limiter.until).isoformat()
115169
raise ValueError(f'Too many requests. Client is rate limited until {until}')
116-
headers = {'Authorization': self.auth_token}
170+
headers = {'Authorization': self.auth.auth_token}
117171
if data:
118172
headers['Content-Type'] = 'application/json'
119173

@@ -142,6 +196,17 @@ def do_api_request(self, relative_url:str, method:str=None, data:Dict=None,
142196
if code == 400:
143197
raise BadRequestAPIError(e)
144198
elif code == 401:
199+
if not relative_url.startswith('/v3/auth') and self.auth.refresh_token and time.time() - self._last_token_refresh_attempt > 86400:
200+
try:
201+
self._last_token_refresh_attempt = time.time()
202+
auth_data = {'refresh_token': self.auth.refresh_token, 'grant_type': 'refresh_token',
203+
'client_id': self.auth.client_id, 'client_secret': self.auth.client_secret}
204+
token_data = self.do_api_request('/v3/auth/token', data=auth_data)
205+
self.auth.auth_token = token_data['access_token']
206+
return self.do_api_request(relative_url=relative_url, method=method, data=data, timeout=timeout, max_tries=max_tries)
207+
except Exception as e2:
208+
logging.info('error refreshing access token', exc_info=e2)
209+
# fall through to raise auth error
145210
raise UnauthorizedAPIError(e)
146211
elif code == 429:
147212
if not self.rate_limiter.rate_limited:
@@ -154,13 +219,13 @@ def do_api_request(self, relative_url:str, method:str=None, data:Dict=None,
154219

155220
if __name__ == '__main__':
156221
logging.basicConfig(level='DEBUG')
157-
token = (Path.home() / 'access.token').read_text().strip()
222+
# token = (Path.home() / 'access.token').read_text().strip()
223+
auth = FileAuthStore(Path.home())
158224
# print(sess.user['fullName'])
159225

160-
uid = 'uid'
161-
sess = FeedlySession(auth_token=token, user_id=uid)
226+
sess = FeedlySession(auth)
162227

163-
# sess.user.get_enterprise_tags()
228+
sess.user.get_enterprise_tags()
164229
# sess.user.get_enterprise_categories()
165230

166231
# with FeedlySession(auth_token=token, user_id=uid) as sess:

setup.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,9 @@
1818
EMAIL = 'kireet@feedly.com'
1919
AUTHOR = 'Kireet'
2020
REQUIRES_PYTHON = '>=3.6.0'
21-
VERSION = 0.13
21+
VERSION = 0.14
2222

2323
# What packages are required for this module to be executed?
24-
#REQUIRED = [
25-
# 'requests', 'maya', 'records',
26-
#]
2724
with open('requirements.txt') as f:
2825
REQUIRED = f.read().splitlines()
2926

0 commit comments

Comments
 (0)