From 3cde73ae3fb29ad6480b8860f620de9d41e4b558 Mon Sep 17 00:00:00 2001 From: Aurelien Lourot Date: Mon, 1 Jan 2018 23:18:01 +0100 Subject: [PATCH] Implements #22 - Support for user-authenticated API calls --- .coveragerc | 2 +- README.md | 47 ++++++++++++++++++++++++++++--------- quizler/lib.py | 18 +++++++++++--- quizler/utils.py | 16 ++++++------- tests/quizler/test_lib.py | 42 ++++++++++++++++++++++++++++----- tests/quizler/test_utils.py | 29 +++++++++++++---------- 6 files changed, 112 insertions(+), 42 deletions(-) diff --git a/.coveragerc b/.coveragerc index 3cea068..b34ce5f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,2 +1,2 @@ [report] -fail_under = 97 +fail_under = 98 diff --git a/README.md b/README.md index 1d95582..f79c6a2 100644 --- a/README.md +++ b/README.md @@ -5,30 +5,55 @@ Collection of utils for Quizlet flash cards -## Requirements +## Installation + +```bash +$ pip install quizler +``` Tested on: - macOS / Python 3.6.1 - Ubuntu 14.04 / Python 3.5.3 -To install Python requirements (virtualenv is recommended): +## Usage + +### Command-line interface + +On the command-line Quizler can be used to access public information read-only: ```bash -pip install -r requirements.txt +$ export USER_ID=john +$ export CLIENT_ID=Ab0Cd1Ef2G +$ quizler sets --terms +Found sets: 2 + German + Hello = Hallo + How are you? = Wie geht's? + French + Hello = Bonjour + How are you? = Comment ça va? ``` -## Usage - -Quizler relies onto environment variables to operate. Currently there're two: +- `USER_ID` - Quizlet username whose information you want to access (your own username can be viewed + right at your avatar in the top right corner on quizlet.com) +- `CLIENT_ID` - Quizlet Client ID which can be obtained on the + [Quizlet API dashboard](https://quizlet.com/api-dashboard) -- `USER_ID` - your username on quizlet, it can be viewed right at your avatar in the top right on quizlet.com -- `CLIENT_ID` - Quizlet Client ID can be obtained in [Quizlet API dashboard](https://quizlet.com/api-dashboard) +### Python library -Set this two before using the script. To work with quizler just invoke CLI, e.g.: +As a Python library Quizler can also access private information: -```bash -python main.py common ``` +>>> from quizler.utils import reset_term_stats +>>> reset_term_stats(set_id=123456789, term_id=1234567890, client_id='Ab0Cd1Ef2G', user_id='john', access_token='46a54395f3d1108feca56c7f6ca8dd3d') +Deleting "Hello = Hallo"... +Re-creating "Hello = Hallo"... +Done +``` + +The access token can be obtained by a web app implementing +[Quizlet's authentication flow](https://quizlet.com/api/2.0/docs/authorization-code-flow). +[Here is an example](https://github.com/quizl/backend). Powered by Quizlet diff --git a/quizler/lib.py b/quizler/lib.py index 8573230..1cd9848 100644 --- a/quizler/lib.py +++ b/quizler/lib.py @@ -15,13 +15,25 @@ def get_api_envs(): return client_id, user_id -def api_call(method: str, end_point: str, params: Dict[str, str], client_id: str): +def api_call(method: str, end_point: str, params: Dict[str, str] = None, client_id: str = None, + access_token: str = None): """Call given API end_point with API keys.""" + if bool(client_id) == bool(access_token): + raise ValueError('Either client_id or access_token') + url = 'https://api.quizlet.com/2.0/{}'.format(end_point) - params['client_id'] = client_id + + if not params: + params = {} + if client_id: + params['client_id'] = client_id + + headers = {'Authorization': 'Bearer {}'.format(access_token)} if access_token else None + # pylint: disable=too-many-function-args - response = requests.request(method, url, params=params) + response = requests.request(method, url, params=params, headers=headers) # pylint: enable=too-many-function-args + # pylint: disable=no-member if int(response.status_code / 100) != 2: # pylint: enable=no-member diff --git a/quizler/utils.py b/quizler/utils.py index 66248ff..7092630 100644 --- a/quizler/utils.py +++ b/quizler/utils.py @@ -10,7 +10,7 @@ def get_user_sets(client_id, user_id): """Find all user sets.""" - data = api_call('get', 'users/{}/sets'.format(user_id), {}, client_id) + data = api_call('get', 'users/{}/sets'.format(user_id), client_id=client_id) return [WordSet.from_dict(wordset) for wordset in data] @@ -53,17 +53,17 @@ def print_common_terms(common_terms: List[Tuple[str, str, Set[str]]]): print(' {}'.format(term)) -def delete_term(set_id, term_id, client_id): +def delete_term(set_id, term_id, access_token): """Delete the given term.""" - api_call('delete', 'sets/{}/terms/{}'.format(set_id, term_id), {}, client_id) + api_call('delete', 'sets/{}/terms/{}'.format(set_id, term_id), access_token=access_token) -def add_term(set_id, term: Term, client_id): +def add_term(set_id, term: Term, access_token): """Add the given term to the given set.""" - api_call('post', 'sets/{}/terms'.format(set_id), term.to_dict(), client_id) + api_call('post', 'sets/{}/terms'.format(set_id), term.to_dict(), access_token=access_token) -def reset_term_stats(set_id, term_id, client_id, user_id): +def reset_term_stats(set_id, term_id, client_id, user_id, access_token): """Reset the stats of a term by deleting and re-creating it.""" found_sets = [user_set for user_set in get_user_sets(client_id, user_id) if user_set.set_id == set_id] @@ -81,9 +81,9 @@ def reset_term_stats(set_id, term_id, client_id, user_id): raise NotImplementedError('"{}" has an image and is thus not supported'.format(term)) print('Deleting "{}"...'.format(term)) - delete_term(set_id, term_id, client_id) + delete_term(set_id, term_id, access_token) print('Re-creating "{}"...'.format(term)) - add_term(set_id, term, client_id) + add_term(set_id, term, access_token) print('Done') diff --git a/tests/quizler/test_lib.py b/tests/quizler/test_lib.py index 2f8868a..9e3ed8a 100644 --- a/tests/quizler/test_lib.py +++ b/tests/quizler/test_lib.py @@ -1,5 +1,6 @@ # pylint: disable=no-self-use,missing-docstring,invalid-name +import json import unittest from unittest import mock @@ -33,26 +34,55 @@ def test_all_envs_are_set(self): class TestApiCall(unittest.TestCase): + def test_no_client_id_or_access_token(self): + with self.assertRaises(ValueError): + api_call('get', 'end_point') + + def test_client_id_and_access_token(self): + with self.assertRaises(ValueError): + api_call('get', 'end_point', client_id='client_id', access_token='token') + @mock.patch('requests.request') def test_unknown_endpoint(self, mock_request): mock_request.return_value.status_code = 404 with self.assertRaises(ValueError): - api_call('get', 'unknown_end_point', {}, 'client_id') + api_call('get', 'unknown_end_point', client_id='client_id') + + @mock.patch('requests.request') + def test_correct_url_was_called_with_client_id(self, mock_request): + mock_request.return_value.status_code = 200 + api_call('get', 'end_point', client_id='client_id') + mock_request.assert_called_once_with( + 'get', + 'https://api.quizlet.com/2.0/end_point', + params={'client_id': 'client_id'}, + headers=None + ) @mock.patch('requests.request') - def test_correct_url_was_called(self, mock_request): + def test_correct_url_was_called_with_access_token(self, mock_request): mock_request.return_value.status_code = 200 - api_call('get', 'end_point', {}, 'client_id') + api_call('get', 'end_point', access_token='token') mock_request.assert_called_once_with( 'get', 'https://api.quizlet.com/2.0/end_point', - params={'client_id': 'client_id'} + params={}, + headers={'Authorization': 'Bearer token'} ) @mock.patch('requests.request') - def test_correct_output_was_returned(self, mock_request): + def test_json_output_was_returned(self, mock_request): response = mock.Mock() response.status_code = 200 mock_request.return_value = response - data = api_call('get', 'end_point', {}, 'client_id') + data = api_call('get', 'end_point', client_id='client_id') self.assertEqual(data, response.json()) + + @mock.patch('requests.request') + def test_non_json_output(self, mock_request): + response = mock.Mock() + response.status_code = 200 + response.json = mock.Mock(side_effect=json.decoder.JSONDecodeError('', '', 1)) + mock_request.return_value = response + data = api_call('get', 'end_point', client_id='client_id') + self.assertEqual(data, None) diff --git a/tests/quizler/test_utils.py b/tests/quizler/test_utils.py index fd7d834..533dccb 100644 --- a/tests/quizler/test_utils.py +++ b/tests/quizler/test_utils.py @@ -118,13 +118,12 @@ class TestDeleteTerm(unittest.TestCase): def test_one_term(self, mock_api_call): set_id = 1 term_id = 2 - client_id = 3 - delete_term(set_id, term_id, client_id) + access_token = 'token' + delete_term(set_id, term_id, access_token) mock_api_call.assert_called_once_with( 'delete', 'sets/{}/terms/{}'.format(set_id, term_id), - {}, - client_id + access_token=access_token ) @@ -134,13 +133,13 @@ class TestAddTerm(unittest.TestCase): def test_one_term(self, mock_api_call): set_id = 1 term = TermFactory() - client_id = 3 - add_term(set_id, term, client_id) + access_token = 'token' + add_term(set_id, term, access_token) mock_api_call.assert_called_once_with( 'post', 'sets/{}/terms'.format(set_id), term.to_dict(), - client_id + access_token=access_token ) @@ -162,8 +161,9 @@ def test_set_not_found(self, mock_get_user_sets): term_id = self.term0.term_id client_id = 1 user_id = 2 + access_token = 'token' with self.assertRaises(ValueError): - reset_term_stats(unknown_set_id, term_id, client_id, user_id) + reset_term_stats(unknown_set_id, term_id, client_id, user_id, access_token) def test_term_not_found(self, mock_get_user_sets): mock_get_user_sets.return_value = self.wordsets @@ -171,8 +171,9 @@ def test_term_not_found(self, mock_get_user_sets): unknown_term_id = -1 client_id = 1 user_id = 2 + access_token = 'token' with self.assertRaises(ValueError): - reset_term_stats(set_id, unknown_term_id, client_id, user_id) + reset_term_stats(set_id, unknown_term_id, client_id, user_id, access_token) def test_term_has_image(self, mock_get_user_sets): mock_get_user_sets.return_value = self.wordsets @@ -180,8 +181,9 @@ def test_term_has_image(self, mock_get_user_sets): term_id = self.term0.term_id client_id = 1 user_id = 2 + access_token = 'token' with self.assertRaises(NotImplementedError): - reset_term_stats(set_id, term_id, client_id, user_id) + reset_term_stats(set_id, term_id, client_id, user_id, access_token) @mock.patch('quizler.utils.add_term') @mock.patch('quizler.utils.delete_term') @@ -191,6 +193,7 @@ def test_one_term(self, mock_delete_term, mock_add_term, mock_get_user_sets): term_id = self.term1.term_id client_id = 1 user_id = 2 - reset_term_stats(set_id, term_id, client_id, user_id) - mock_delete_term.assert_called_once_with(set_id, term_id, client_id) - mock_add_term.assert_called_once_with(set_id, self.term1, client_id) + access_token = 'token' + reset_term_stats(set_id, term_id, client_id, user_id, access_token) + mock_delete_term.assert_called_once_with(set_id, term_id, access_token) + mock_add_term.assert_called_once_with(set_id, self.term1, access_token)