├── requirements.txt ├── tox.ini ├── MANIFEST.in ├── MAINTAINERS ├── .gitignore ├── .travis.yml ├── LICENSE ├── release.sh ├── setup.py ├── README.rst ├── tokens └── __init__.py └── tests └── test_tokens.py /requirements.txt: -------------------------------------------------------------------------------- 1 | requests>=2.7.0 2 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length=120 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt 2 | include *.rst 3 | recursive-include tokens *.py 4 | -------------------------------------------------------------------------------- /MAINTAINERS: -------------------------------------------------------------------------------- 1 | Henning Jacobs 2 | Matthias Kerk 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | build/ 3 | .coverage 4 | *.egg* 5 | __pycache__ 6 | .cache/ 7 | htmlcov/ 8 | *.pyc 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.4" 5 | - "3.5" 6 | install: 7 | - pip install -r requirements.txt 8 | - pip install coveralls 9 | - pip install flake8 10 | script: 11 | - python setup.py test 12 | - python setup.py flake8 13 | after_success: 14 | - coveralls 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2015 Zalando SE 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ $# -ne 1 ]; then 4 | >&2 echo "usage: $0 " 5 | exit 1 6 | fi 7 | 8 | set -xe 9 | 10 | python3 --version 11 | git --version 12 | 13 | version=$1 14 | 15 | sed -i "s/__version__ = .*/__version__ = '${version}'/" */__init__.py 16 | 17 | # Do not tag/push on Go CD 18 | if [ -z "$GO_PIPELINE_LABEL" ]; then 19 | python3 setup.py clean 20 | python3 setup.py test 21 | python3 setup.py flake8 22 | 23 | git add */__init__.py 24 | 25 | git commit -m "Bumped version to $version" 26 | git push 27 | fi 28 | 29 | python3 setup.py sdist bdist_wheel upload 30 | 31 | if [ -z "$GO_PIPELINE_LABEL" ]; then 32 | git tag ${version} 33 | git push --tags 34 | fi 35 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import sys 6 | 7 | from setuptools import setup, find_packages 8 | from setuptools.command.test import test as TestCommand 9 | 10 | 11 | def read_version(package): 12 | with open(os.path.join(package, '__init__.py'), 'r') as fd: 13 | for line in fd: 14 | if line.startswith('__version__ = '): 15 | return line.split()[-1].strip().strip("'") 16 | 17 | 18 | __version__ = read_version('tokens') 19 | 20 | 21 | class PyTest(TestCommand): 22 | 23 | user_options = [('cov-html=', None, 'Generate junit html report')] 24 | 25 | def initialize_options(self): 26 | TestCommand.initialize_options(self) 27 | self.cov = None 28 | self.pytest_args = ['--cov', 'tokens', '--cov-report', 'term-missing', '-v'] 29 | self.cov_html = False 30 | 31 | def finalize_options(self): 32 | TestCommand.finalize_options(self) 33 | if self.cov_html: 34 | self.pytest_args.extend(['--cov-report', 'html']) 35 | 36 | def run_tests(self): 37 | import pytest 38 | errno = pytest.main(self.pytest_args) 39 | sys.exit(errno) 40 | 41 | 42 | setup( 43 | name='stups-tokens', 44 | packages=find_packages(), 45 | version=__version__, 46 | description='Python library to manage OAuth access tokens', 47 | long_description=open('README.rst').read(), 48 | author='Henning Jacobs', 49 | author_email='henning.jacobs@zalando.de', 50 | url='https://github.com/zalando-stups/python-tokens', 51 | license='Apache License Version 2.0', 52 | install_requires=['requests'], 53 | tests_require=['pytest-cov', 'pytest', 'mock'], 54 | extras_require={ 55 | 'tests': ['flake8'], 56 | }, 57 | cmdclass={'test': PyTest}, 58 | test_suite='tests', 59 | classifiers=[ 60 | 'Programming Language :: Python', 61 | 'Programming Language :: Python :: 2.7', 62 | 'Programming Language :: Python :: 3.4', 63 | 'Programming Language :: Python :: 3.5', 64 | 'Development Status :: 4 - Beta', 65 | 'Intended Audience :: Developers', 66 | 'Operating System :: OS Independent', 67 | ], 68 | ) 69 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | Python Tokens 3 | ============= 4 | 5 | .. image:: https://travis-ci.org/zalando-stups/python-tokens.svg?branch=master 6 | :target: https://travis-ci.org/zalando-stups/python-tokens 7 | :alt: Build Status 8 | 9 | .. image:: https://coveralls.io/repos/zalando-stups/python-tokens/badge.svg 10 | :target: https://coveralls.io/r/zalando-stups/python-tokens 11 | :alt: Code Coverage 12 | 13 | .. image:: https://img.shields.io/pypi/v/stups-tokens.svg 14 | :target: https://pypi.python.org/pypi/stups-tokens/ 15 | :alt: Latest PyPI version 16 | 17 | .. image:: https://img.shields.io/pypi/l/stups-tokens.svg 18 | :target: https://pypi.python.org/pypi/stups-tokens/ 19 | :alt: License 20 | 21 | A Python library that keeps OAuth 2.0 service access tokens in memory for your usage. 22 | 23 | Installation 24 | ============ 25 | 26 | .. code-block:: bash 27 | 28 | $ sudo pip3 install --upgrade stups-tokens 29 | 30 | Usage 31 | ===== 32 | 33 | .. code-block:: python 34 | 35 | import requests 36 | import time 37 | import tokens 38 | 39 | # will use OAUTH2_ACCESS_TOKEN_URL environment variable by default 40 | # will try to read application credentials from CREDENTIALS_DIR 41 | tokens.configure(url='https://example.com/access_tokens') 42 | tokens.manage('example', ['read', 'write']) 43 | tokens.start() 44 | 45 | tok = tokens.get('example') 46 | 47 | requests.get('https://example.org/', headers={'Authorization': 'Bearer {}'.format(tok)}) 48 | 49 | time.sleep(3600) # make the token expire 50 | 51 | tok = tokens.get('example') # will refresh the expired token 52 | requests.get('https://example.org/', headers={'Authorization': 'Bearer {}'.format(tok)}) 53 | 54 | This library also allows reading tokens directly from a file. The token needs to be in a file name ``${CREDENTIALS_DIR}/${TOKEN_NAME}-token-secret``: 55 | 56 | .. code-block:: python 57 | 58 | import tokens 59 | 60 | # the environment variable CREDENTIALS_DIR must be set correctly 61 | tokens.configure(from_file_only=True) 62 | tokens.manage('full-access') 63 | tok = tokens.get('full-access') 64 | 65 | requests.get('https://example.org/', headers={'Authorization': 'Bearer {}'.format(tok)}) 66 | 67 | Local testing 68 | ============= 69 | 70 | The "tokens" library allows injecting fixed OAuth2 access tokens via the `OAUTH2_ACCESS_TOKENS` environment variable. 71 | This allows testing applications using the library locally with personal OAuth2 tokens (e.g. generated by "zign"): 72 | 73 | .. code-block:: bash 74 | 75 | $ MY_TOKEN=$(zign token -n mytok) 76 | $ export OAUTH2_ACCESS_TOKENS=mytok=$MY_TOKEN 77 | $ ./myapp.py # start my local Python app using the tokens library 78 | 79 | 80 | Releasing 81 | ========= 82 | 83 | Uploading a new version to PyPI: 84 | 85 | .. code-block:: bash 86 | 87 | $ ./release.sh 88 | 89 | -------------------------------------------------------------------------------- /tokens/__init__.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import json 3 | import logging 4 | import os 5 | import requests 6 | import time 7 | 8 | __version__ = '0.8' 9 | 10 | logger = logging.getLogger('tokens') 11 | 12 | ONE_YEAR = 3600*24*365 13 | EXPIRATION_TOLERANCE_SECS = 60 14 | # TODO: make time value configurable (20 minutes)? 15 | REFRESH_BEFORE_SECS_LEFT = 20 * 60 16 | DEFAULT_HTTP_CONNECT_TIMEOUT = 1.25 17 | DEFAULT_HTTP_SOCKET_TIMEOUT = 2.25 18 | 19 | CONFIG = {'url': os.environ.get('OAUTH2_ACCESS_TOKEN_URL', os.environ.get('OAUTH_ACCESS_TOKEN_URL')), 20 | 'dir': os.environ.get('CREDENTIALS_DIR', ''), 21 | 'from_file_only': False, 22 | 'connect_timeout': DEFAULT_HTTP_CONNECT_TIMEOUT, 23 | 'socket_timeout': DEFAULT_HTTP_SOCKET_TIMEOUT} 24 | 25 | TOKENS = {} 26 | 27 | 28 | class ConfigurationError(Exception): 29 | def __init__(self, msg): 30 | self.msg = msg 31 | 32 | def __str__(self): 33 | return 'Configuration error: {}'.format(self.msg) 34 | 35 | 36 | class InvalidCredentialsError(Exception): 37 | def __init__(self, msg): 38 | self.msg = msg 39 | 40 | def __str__(self): 41 | return 'Invalid OAuth credentials: {}'.format(self.msg) 42 | 43 | 44 | class InvalidTokenResponse(Exception): 45 | def __init__(self, msg): 46 | self.msg = msg 47 | 48 | def __str__(self): 49 | return 'Invalid token response: {}'.format(self.msg) 50 | 51 | 52 | def init_fixed_tokens_from_env(): 53 | env_val = os.environ.get('OAUTH2_ACCESS_TOKENS', '') 54 | for part in filter(None, env_val.split(',')): 55 | key, sep, val = part.partition('=') 56 | logger.info('Using fixed access token "%s"..', key) 57 | TOKENS[key] = {'access_token': val, 'expires_at': time.time() + ONE_YEAR} 58 | 59 | 60 | def configure(**kwargs): 61 | CONFIG.update(kwargs) 62 | 63 | 64 | def manage(token_name, scopes=None, ignore_expiration=False): 65 | """ ignore_expiration will enable using expired tokens in get() 66 | in cases where you token service does not yield a new token """ 67 | TOKENS[token_name] = {'scopes': scopes or [], 'ignore_expiration': ignore_expiration} 68 | init_fixed_tokens_from_env() 69 | 70 | 71 | def start(): 72 | # TODO: start background thread to manage tokens 73 | pass 74 | 75 | 76 | def read_credentials(path): 77 | user_path = os.path.join(path, 'user.json') 78 | try: 79 | with open(user_path) as fd: 80 | user_data = json.load(fd) 81 | except Exception as e: 82 | raise InvalidCredentialsError('Failed to read {}: {}'.format(user_path, e)) 83 | 84 | client_path = os.path.join(path, 'client.json') 85 | try: 86 | with open(client_path) as fd: 87 | client_data = json.load(fd) 88 | except Exception as e: 89 | raise InvalidCredentialsError('Failed to read {}: {}'.format(client_path, e)) 90 | 91 | return user_data, client_data 92 | 93 | 94 | def read_token_from_file(path, token_name): 95 | file_path = os.path.join(path, '{}-token-secret'.format(token_name)) 96 | try: 97 | with open(file_path) as fd: 98 | access_token = fd.read().strip() 99 | except IOError as e: 100 | if e.errno == errno.ENOENT: 101 | pass 102 | else: 103 | raise 104 | else: 105 | token = { 106 | 'access_token': access_token, 107 | 'expires_at': time.time() + 120 108 | } 109 | return token 110 | 111 | 112 | def refresh(token_name): 113 | token = TOKENS[token_name] 114 | path = CONFIG['dir'] 115 | token_from_file = read_token_from_file(path, token_name) 116 | 117 | if token_from_file: 118 | token.update(**token_from_file) 119 | return token 120 | elif CONFIG['from_file_only']: 121 | raise InvalidCredentialsError('Failed to read token "{}" from {}.'.format(token_name, path)) 122 | 123 | logger.info('Refreshing access token "%s"..', token_name) 124 | url = CONFIG['url'] 125 | # http://requests.readthedocs.org/en/master/user/advanced/#timeouts 126 | request_timeout = CONFIG['connect_timeout'], CONFIG['socket_timeout'] 127 | 128 | if not url: 129 | raise ConfigurationError('Missing OAuth access token URL. ' + 130 | 'Either set OAUTH2_ACCESS_TOKEN_URL or use tokens.configure(url=..).') 131 | 132 | user_data, client_data = read_credentials(path) 133 | 134 | try: 135 | body = {'grant_type': 'password', 136 | 'username': user_data['application_username'], 137 | 'password': user_data['application_password'], 138 | 'scope': ' '.join(token['scopes'])} 139 | 140 | auth = (client_data['client_id'], client_data['client_secret']) 141 | except KeyError as e: 142 | raise InvalidCredentialsError('Missing key: {}'.format(e)) 143 | 144 | headers = {'User-Agent': 'python-tokens/{}'.format(__version__)} 145 | 146 | r = requests.post(url, data=body, auth=auth, timeout=request_timeout, headers=headers) 147 | r.raise_for_status() 148 | try: 149 | data = r.json() 150 | token['data'] = data 151 | token['expires_at'] = time.time() + data['expires_in'] 152 | token['access_token'] = data['access_token'] 153 | except Exception as e: 154 | raise InvalidTokenResponse('Expected a JSON object with keys "expires_in" and "access_token": {}'.format(e)) 155 | if not token['access_token']: 156 | raise InvalidTokenResponse('Empty "access_token" value') 157 | return token 158 | 159 | 160 | def get(token_name): 161 | token = TOKENS[token_name] 162 | access_token = token.get('access_token') 163 | if not access_token or time.time() > token['expires_at'] - REFRESH_BEFORE_SECS_LEFT: 164 | try: 165 | refresh(token_name) 166 | access_token = token.get('access_token') 167 | except Exception as e: 168 | if access_token and time.time() < token['expires_at'] + EXPIRATION_TOLERANCE_SECS: 169 | # apply some tolerance, still try our old token if it's still valid 170 | logger.warn('Failed to refresh access token "%s" (but it is still valid): %s', token_name, e) 171 | elif access_token and token.get('ignore_expiration'): 172 | logger.warn('Failed to refresh access token "%s" (ignoring expiration): %s', token_name, e) 173 | else: 174 | raise 175 | 176 | return access_token 177 | -------------------------------------------------------------------------------- /tests/test_tokens.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import pytest 4 | import time 5 | import tokens 6 | from mock import MagicMock 7 | 8 | 9 | VALID_USER_JSON = {'application_username': 'app', 'application_password': 'pass'} 10 | VALID_CLIENT_JSON = {'client_id': 'cid', 'client_secret': 'sec'} 11 | 12 | 13 | def test_init_fixed_tokens_from_env(monkeypatch): 14 | monkeypatch.setattr('os.environ', {'OAUTH2_ACCESS_TOKENS': 'mytok=123,t2=3'}) 15 | tokens.init_fixed_tokens_from_env() 16 | assert '123' == tokens.get('mytok') 17 | assert '3' == tokens.get('t2') 18 | 19 | 20 | def test_read_credentials(tmpdir): 21 | path = str(tmpdir) 22 | user = VALID_USER_JSON 23 | client = VALID_CLIENT_JSON 24 | with open(os.path.join(path, 'user.json'), 'w') as fd: 25 | json.dump(user, fd) 26 | 27 | with open(os.path.join(path, 'client.json'), 'w') as fd: 28 | json.dump(client, fd) 29 | 30 | assert (user, client) == tokens.read_credentials(path) 31 | 32 | with open(os.path.join(path, 'client.json'), 'w') as fd: 33 | fd.write('invalid') 34 | 35 | with pytest.raises(tokens.InvalidCredentialsError): 36 | tokens.read_credentials(path) 37 | 38 | with open(os.path.join(path, 'user.json'), 'w') as fd: 39 | fd.write('invalid') 40 | 41 | with pytest.raises(tokens.InvalidCredentialsError): 42 | tokens.read_credentials(path) 43 | 44 | 45 | def test_get(): 46 | tokens.TOKENS = {'test': {'access_token': 'mytok123', 47 | 'expires_at': time.time() + 3600}} 48 | tokens.get('test') 49 | 50 | 51 | def test_refresh_without_configuration(): 52 | # remove URL config 53 | tokens.configure(dir='', url='') 54 | tokens.manage('mytok', ['scope']) 55 | with pytest.raises(tokens.ConfigurationError) as exc_info: 56 | tokens.refresh('mytok') 57 | assert str(exc_info.value) == 'Configuration error: Missing OAuth access token URL. Either set OAUTH2_ACCESS_TOKEN_URL or use tokens.configure(url=..).' 58 | 59 | 60 | def test_refresh(monkeypatch, tmpdir): 61 | tokens.configure(dir=str(tmpdir), url='') 62 | tokens.manage('mytok', ['myscope']) 63 | with pytest.raises(tokens.ConfigurationError): 64 | tokens.refresh('mytok') 65 | 66 | tokens.configure(dir=str(tmpdir), url='https://example.org') 67 | 68 | with open(os.path.join(str(tmpdir), 'user.json'), 'w') as fd: 69 | json.dump({'application_username': 'app', 'application_password': 'pass'}, fd) 70 | 71 | with open(os.path.join(str(tmpdir), 'client.json'), 'w') as fd: 72 | json.dump({'client_id': 'cid', 'client_secret': 'sec'}, fd) 73 | 74 | response = MagicMock() 75 | response.json.return_value = {'expires_in': 123123, 'access_token': '777'} 76 | monkeypatch.setattr('requests.post', lambda url, **kwargs: response) 77 | tok = tokens.get('mytok') 78 | assert tok == '777' 79 | 80 | 81 | def test_refresh_invalid_credentials(monkeypatch, tmpdir): 82 | tokens.configure(dir=str(tmpdir), url='https://example.org') 83 | tokens.manage('mytok', ['myscope']) 84 | tokens.start() # this does not do anything.. 85 | 86 | with open(os.path.join(str(tmpdir), 'user.json'), 'w') as fd: 87 | # missing password 88 | json.dump({'application_username': 'app'}, fd) 89 | 90 | with open(os.path.join(str(tmpdir), 'client.json'), 'w') as fd: 91 | json.dump({'client_id': 'cid', 'client_secret': 'sec'}, fd) 92 | 93 | with pytest.raises(tokens.InvalidCredentialsError) as exc_info: 94 | tokens.get('mytok') 95 | assert str(exc_info.value) == "Invalid OAuth credentials: Missing key: 'application_password'" 96 | 97 | 98 | def test_refresh_invalid_response(monkeypatch, tmpdir): 99 | tokens.configure(dir=str(tmpdir), url='https://example.org') 100 | tokens.manage('mytok', ['myscope']) 101 | tokens.start() # this does not do anything.. 102 | 103 | response = MagicMock() 104 | response.json.return_value = {'foo': 'bar'} 105 | post = MagicMock() 106 | post.return_value = response 107 | monkeypatch.setattr('requests.post', post) 108 | monkeypatch.setattr('tokens.read_credentials', lambda path: (VALID_USER_JSON, VALID_CLIENT_JSON)) 109 | 110 | with pytest.raises(tokens.InvalidTokenResponse) as exc_info: 111 | tokens.get('mytok') 112 | assert str(exc_info.value) == """Invalid token response: Expected a JSON object with keys "expires_in" and "access_token": 'expires_in'""" 113 | 114 | # verify that we use a proper HTTP timeout.. 115 | post.assert_called_with('https://example.org', 116 | data={'username': 'app', 'scope': 'myscope', 'password': 'pass', 'grant_type': 'password'}, 117 | headers={'User-Agent': 'python-tokens/{}'.format(tokens.__version__)}, 118 | timeout=(1.25, 2.25), auth=('cid', 'sec')) 119 | 120 | response.json.return_value = {'access_token': '', 'expires_in': 100} 121 | with pytest.raises(tokens.InvalidTokenResponse) as exc_info: 122 | tokens.get('mytok') 123 | assert str(exc_info.value) == 'Invalid token response: Empty "access_token" value' 124 | 125 | 126 | def test_get_refresh_failure(monkeypatch, tmpdir): 127 | tokens.configure(dir=str(tmpdir), url='https://example.org') 128 | 129 | with open(os.path.join(str(tmpdir), 'user.json'), 'w') as fd: 130 | json.dump({'application_username': 'app', 'application_password': 'pass'}, fd) 131 | 132 | with open(os.path.join(str(tmpdir), 'client.json'), 'w') as fd: 133 | json.dump({'client_id': 'cid', 'client_secret': 'sec'}, fd) 134 | 135 | exc = Exception('FAIL') 136 | response = MagicMock() 137 | response.raise_for_status.side_effect = exc 138 | monkeypatch.setattr('requests.post', lambda url, **kwargs: response) 139 | logger = MagicMock() 140 | monkeypatch.setattr('tokens.logger', logger) 141 | tokens.TOKENS = {'mytok': {'access_token': 'oldtok', 142 | 'scopes': ['myscope'], 143 | # token is still valid for 10 minutes 144 | 'expires_at': time.time() + (10 * 60)}} 145 | tok = tokens.get('mytok') 146 | assert tok == 'oldtok' 147 | logger.warn.assert_called_with('Failed to refresh access token "%s" (but it is still valid): %s', 'mytok', exc) 148 | 149 | tokens.TOKENS = {'mytok': {'scopes': ['myscope'], 'expires_at': 0}} 150 | with pytest.raises(Exception) as exc_info: 151 | tok = tokens.get('mytok') 152 | assert exc_info.value == exc 153 | 154 | 155 | def test_get_refresh_failure_ignore_expiration_no_access_token(monkeypatch, tmpdir): 156 | tokens.configure(dir=str(tmpdir), url='https://example.org') 157 | 158 | with open(os.path.join(str(tmpdir), 'user.json'), 'w') as fd: 159 | json.dump({'application_username': 'app', 'application_password': 'pass'}, fd) 160 | 161 | with open(os.path.join(str(tmpdir), 'client.json'), 'w') as fd: 162 | json.dump({'client_id': 'cid', 'client_secret': 'sec'}, fd) 163 | 164 | exc = Exception('FAIL') 165 | response = MagicMock() 166 | response.raise_for_status.side_effect = exc 167 | monkeypatch.setattr('requests.post', lambda url, **kwargs: response) 168 | # we never got any access token 169 | tokens.TOKENS = {'mytok': {'ignore_expiration': True, 170 | 'scopes': ['myscope'], 171 | # expired a long time ago.. 172 | 'expires_at': 0}} 173 | with pytest.raises(Exception) as exc_info: 174 | tokens.get('mytok') 175 | assert exc_info.value == exc 176 | 177 | 178 | def test_get_refresh_failure_ignore_expiration(monkeypatch, tmpdir): 179 | tokens.configure(dir=str(tmpdir), url='https://example.org') 180 | 181 | with open(os.path.join(str(tmpdir), 'user.json'), 'w') as fd: 182 | json.dump({'application_username': 'app', 'application_password': 'pass'}, fd) 183 | 184 | with open(os.path.join(str(tmpdir), 'client.json'), 'w') as fd: 185 | json.dump({'client_id': 'cid', 'client_secret': 'sec'}, fd) 186 | 187 | exc = Exception('FAIL') 188 | response = MagicMock() 189 | response.raise_for_status.side_effect = exc 190 | monkeypatch.setattr('requests.post', lambda url, **kwargs: response) 191 | logger = MagicMock() 192 | monkeypatch.setattr('tokens.logger', logger) 193 | tokens.TOKENS = {'mytok': {'access_token': 'expired-token', 194 | 'ignore_expiration': True, 195 | 'scopes': ['myscope'], 196 | # expired a long time ago.. 197 | 'expires_at': 0}} 198 | tok = tokens.get('mytok') 199 | assert tok == 'expired-token' 200 | logger.warn.assert_called_with('Failed to refresh access token "%s" (ignoring expiration): %s', 'mytok', exc) 201 | 202 | 203 | def test_read_from_file(monkeypatch, tmpdir): 204 | tokens.configure(dir=str(tmpdir)) 205 | with open(os.path.join(str(tmpdir), 'mytok-token-secret'), 'w') as fd: 206 | fd.write('my-access-token\n') 207 | tokens.manage('mytok') 208 | tok = tokens.get('mytok') 209 | assert tok == 'my-access-token' 210 | 211 | 212 | def test_read_from_file_fail(monkeypatch, tmpdir): 213 | tokens.configure(dir=str(tmpdir), from_file_only=True) 214 | tokens.manage('mytok') 215 | with pytest.raises(tokens.InvalidCredentialsError) as exc_info: 216 | tokens.get('mytok') 217 | assert str(exc_info.value) == 'Invalid OAuth credentials: Failed to read token "mytok" from {}.'.format(str(tmpdir)) 218 | 219 | 220 | def test_read_from_file_fail_raise(monkeypatch, tmpdir): 221 | tokens.configure(dir=str(tmpdir)) 222 | os.mkdir(os.path.join(str(tmpdir), 'mytok-token-secret')) 223 | tokens.manage('mytok') 224 | with pytest.raises(IOError) as exc_info: 225 | tokens.get('mytok') 226 | --------------------------------------------------------------------------------