├── .gitignore ├── .mypy.ini ├── .travis.yml ├── LICENSE ├── README.md ├── examples ├── csv_to_deck.py └── example.csv ├── requirements.txt ├── setup.py ├── temporary_logo.png ├── test-requirements.txt ├── tests ├── client_test.py ├── deck_test.py ├── image_utils_test.py ├── integration_test.py ├── json_conversion_test.py ├── test_logo_blue.jpg ├── test_logo_red.png └── user_test.py └── tinycards ├── __init__.py ├── client ├── __init__.py ├── cli.py └── tinycards.py ├── model ├── __init__.py ├── card.py ├── concept.py ├── deck.py ├── fact.py ├── favorite.py ├── searchable_data.py ├── side.py ├── trendable.py ├── trendable_data.py └── user.py └── networking ├── __init__.py ├── error ├── __init__.py └── invalid_response.py ├── form_utils.py ├── image_utils.py ├── json_converter.py └── rest_api.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | .pytest_cache 7 | .mypy_cache/ 8 | 9 | build/ 10 | dist/ 11 | *.egg-info/ 12 | 13 | .idea 14 | /venv 15 | /.env 16 | /.coverage 17 | 18 | # virtualenv: 19 | .Python 20 | bin/ 21 | include/ 22 | lib/ 23 | 24 | # direnv: 25 | .envrc 26 | -------------------------------------------------------------------------------- /.mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | 3 | [mypy-requests_toolbelt.multipart.encoder.*] 4 | ignore_missing_imports = True 5 | 6 | [mypy-retrying.*] 7 | ignore_missing_imports = True 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | matrix: 3 | include: 4 | - python: 3.6 5 | - python: 3.7 6 | dist: xenial 7 | sudo: true 8 | before_install: 9 | - pip install --upgrade setuptools pip 10 | install: 11 | - pip install -e . 12 | - pip install -r test-requirements.txt 13 | script: 14 | - mypy --config-file=.mypy.ini tinycards 15 | - pylama tinycards tests 16 | - if [[ $TRAVIS_PYTHON_VERSION == 3.6 ]]; 17 | then pytest --cov tinycards; 18 | else pytest --ignore tests/client_test.py 19 | --ignore tests/integration_test.py ; 20 | fi 21 | after_success: 22 | - if [[ $TRAVIS_PYTHON_VERSION == 3.6 ]]; 23 | then coveralls; 24 | fi 25 | before_deploy: 26 | - rm -rf build/ 27 | - rm -rf dist/ 28 | - rm -rf *.egg-info/ 29 | deploy: 30 | provider: pypi 31 | user: floscha 32 | password: 33 | secure: fGNlMnLPdmIUulX2oNQF4a78Utfce2Dg9Sld/EAyBkM5B0kz80SxrXHkOOdIfDyOwSnAJAf2nSJcGmbAH2ZfYdG6Bmyhl5ZgH8OzTqUbZdzym2gPKQ2z+aaqxZ0tXrEBMNm6oMhFAG47aPKWTiWynVa4KpYQnl6nfWxPZvdgK60a8WNXKnjgQd8/h6bW1SDb6wY/dy+TTQ5RGxqsqkwG9TuhmGCzopBLdnLhwHL3fr0XivWTqxxBOWvt5GS404X7MrGfjYvDQUYcaUfqDSvNXS5TNYMPBIfB2RumgLXqc7Z88vZ1j26yG364N/9WOOLINOxXkLVCl3+RRyzXGk30vQCN6VSVQe9vXH8XsrTZBepArOm2tYf2Su785pKQjQiC5KwsbWBbj9dS187yewXe5dL9mvwOTOxlzyiXJRgbt40htfYcnx6JrNc1sLxN9QYcGHhazkGh1FzAAcZL7RBazO86zMTP5zZuF16gI7kkMQdI0cj5y8LuN0T1TQ8pehH8kKkqVneJSGtKsLbRB6Sw6dl1iAyH2qKMcvpPk/kM+LzC8NGm1m4gGOBcLKgdb42xQSb5sq7hQrO4Zu0wt1xm3gpmpXD6Ncdx17ngQd1KqSCChoEe/ZWMks/j6wf7tQP/+vShQvyF461UzTYdbJdRijz0WKYDECIaAKsm2cN8SOc= 34 | on: 35 | tags: true 36 | env: 37 | global: 38 | - TINYCARDS_IDENTIFIER=njam-test 39 | - TINYCARDS_PASSWORD=gRaVeATiod 40 | - secure: YtA+3hTUUXrWqUpknuPwUeA+E2mQVBxtpSpPphtwVJuvaFKTkkdj4rt2vKNKUrapguwNjAGiwJXKOyNxS+QG1KfE0O5uECHfEGDnM5kvOvoNX/Cql25HgzJaUA4MnOgVoxVb+5+CYqPHI21uE8I/jb7NrQ/Bu2xWh/i3OFP354Ni9aU//mGu7AKcaXxcFRLKbir4NFpjcJXvYzxselRtWLlrT1KpanBja/PubUvwuxpMuguVnY0QeOuDfRmasVUzya/uu7sBtHidbndm0Ca1PVaK/BtoFWP9hTHaJi5KWx4ihBIV6oXnZh3G9WSMo7PPMCiO1U0ppv/LFwENc+65QKd1ye1lQ8kgHh7esEPoRSE7ZgUQfxsYTaxNH7AUUTUhiRfdThc6KMxTC3bPDJL0lEpSADxq/t/ayQoDMgYTRZKpPx4vi1b4m75YlEsHuMfe9re9NXS/v34FUfdUrBL19i/SY6njAtUSbBmv/2sHanBw4C3CR9+yTTNKaDvJqAhUtJ4WWt4NizL51Ci4CXbOpSMMUQxzpOtE7vAF2jfUPrl2GtPD8z2PEcveb7ejFkmmINGFEADniILwrKmRiPKJcJDW+aH0+TdDSc7wOVx3RT4Y4LiWhTkakik6QB/cPZ+odoD5sWrW5CzWT227MBRnZ1doCrR0uzbbOKOZr/MrEno= 41 | - secure: iT7iBCmirS7y+Y+9EFsCtL396i/DYnoqiGEGB2+ZBYnPcMoSLjApvoJGBmyCcYE/v0T7g9+t/TJCuceFJtVrOQLshGw+SZ9KiPMHyfEqGBN187Zpx2n+TEtA+YcgsVIw8NDMLCtOUUfuwR7fSoR5N0gEL673TUDsCw0h9qxCpG872ZKge1bsAOHgGMnm+eE5rk2GDVjLo1CJOBLxhoRHojq9zNu00Ns/d061KowkcM4+CDfhBH7nfYEQnqQvD0t5qXqNZsvt1Uy4iexzSqmKnm+TRe+3vCHp3NmZkRaZsrkGNqUp97l/E6FN1pD90/1n+JIkcBZ7x7X17f3Ip83NKMmlyUHzoVbawCABDKHchp9Ze8BvxWQQl7OH3V0aZMTzMmi7z2zgFPVELGNPHnlvJSnuucsBp3UOGZG2IMPfMNLR/7biL34PdYFxTxRo83N58z94+2Y/4bcWBuZfejiJVFH9ZI2o+/jj1tOWFoBchiA5Ko784oDONGcRRiQnQv2o9X71IElXehlLrkOoW6jwyzfG3Ur56rB/daBySV5JDuzM7DTDgSsZTSCITdtcz0E5K9gyKVYEgJYKH3nuYDeZ6Po98u1QP53xYIEf5stKrykLekIYJ5oaG+4i9qcZVQsSX4Cf8EtAFpWeRyrRzlxKY2kZTIYioquB0t9oUXd0LQg= 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Florian Schäfer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ⚠️ Since Tinycards has been [disabled by Duolingo](https://support.duolingo.com/hc/en-us/articles/360043909772-UPDATE-Tinycards-Announcement) on September 1, 2020, this repository will no longer be maintained. 2 | 3 | 4 | 5 | # Tinycards Python API 6 | [![Build Status](https://travis-ci.org/floscha/tinycards-python-api.svg?branch=master)](https://travis-ci.org/floscha/tinycards-python-api) 7 | [![Coverage Status](https://coveralls.io/repos/github/floscha/tinycards-python-api/badge.svg?branch=master)](https://coveralls.io/github/floscha/tinycards-python-api?branch=master) 8 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/d7f70b4f2a134b268a9ca610fc0208f9)](https://www.codacy.com/app/floscha/tinycards-python-api?utm_source=github.com&utm_medium=referral&utm_content=floscha/tinycards-python-api&utm_campaign=Badge_Grade) 9 | [![Python Versions](https://img.shields.io/pypi/pyversions/toga.svg)](https://pypi.python.org/pypi/tinycards) 10 | [![PyPI Version](https://img.shields.io/pypi/v/tinycards.svg)](https://pypi.python.org/pypi/tinycards) 11 | [![PyPI Status](https://img.shields.io/pypi/status/tinycards.svg)](https://pypi.python.org/pypi/tinycards) 12 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 13 | 14 | An unofficial Python API for [Tinycards](https://tinycards.duolingo.com/) by Duolingo. 15 | 16 | 17 | ## Installation 18 | 19 | ### Install from PyPI 20 | 21 | The easiest way to get started is to simple install the library like so: 22 | ``` 23 | $ pip install tinycards 24 | ``` 25 | 26 | ### Install from source 27 | If you want to modify the library's source code and try out your changes locally, you might want to consider building from source which works like follows: 28 | 29 | 1. Make sure Python with [Setuptools](https://pypi.python.org/pypi/setuptools) is installed. 30 | 2. From the project's root folder, install using pip: 31 | ``` 32 | $ pip install . 33 | ``` 34 | 35 | ## Usage 36 | 37 | Below is a list of some of the most common functions. 38 | For a more practical example, see the [csv_to_deck.py](https://github.com/floscha/tinycards-python-api/blob/master/examples/csv_to_deck.py) script. 39 | 40 | ### Initialise a new client 41 | 42 | ```python 43 | >>> # A new client with the given identification (e.g., mail address) and password. 44 | >>> client = tinycards.Tinycards('identification', 'password') 45 | 'Logged in as 'username' (user@email.com)' 46 | >>> # If no identification or password are specified, they are taken from ENV. 47 | >>> client = tinycards.Tinycards() 48 | 'Logged in as 'username' (user@email.com)' 49 | ``` 50 | 51 | ### Get info about the currently logged in user. 52 | 53 | ```python 54 | >>> user = client.get_user_info() 55 | { 56 | username: 'bachman', 57 | email: 'bachman@aviato.com', 58 | fullname: 'Erlich Bachman', 59 | ... 60 | } 61 | ``` 62 | 63 | ### Get all decks of a user 64 | 65 | ```python 66 | >>> all_decks = client.get_decks() 67 | >>> [deck.title for deck in all_decks] 68 | ['Deck 1', 'Deck 2', 'Deck 3'] 69 | ``` 70 | 71 | ### Update an existing deck 72 | 73 | ```python 74 | >>> deck_1 = client.find_deck_by_title('Deck 1') 75 | >>> deck_1.title = 'Deck 1.1' 76 | >>> client.update_deck(deck_1) 77 | { 78 | 'title': 'Deck 1.1', 79 | ... 80 | } 81 | ``` 82 | 83 | ### Delete an existing deck 84 | 85 | ```python 86 | >>> deck = client.find_deck_by_title('Some Deck') 87 | { 88 | 'title': 'Some Deck', 89 | 'id': '8176b324-addc-495d-aadc-fad005e5b439' 90 | ... 91 | } 92 | >>> client.delete_deck(deck.id) 93 | { 94 | 'title': 'Some Deck', 95 | 'id': '8176b324-addc-495d-aadc-fad005e5b439' 96 | ... 97 | } 98 | >>> deck = client.find_deck_by_title('Some Deck') 99 | None 100 | ``` 101 | 102 | ## Release a new Version 103 | 1. Bump the version in `setup.py`. 104 | 2. Push a new tag to GitHub: 105 | 1. `git tag 0.01` 106 | 1. `git push origin 0.01` 107 | 3. The [Travis build](https://travis-ci.org/floscha/tinycards-python-api) will deploy the release to [PyPI](https://pypi.org/project/tinycards/). 108 | 109 | ## Development 110 | 111 | ### Local setup 112 | 113 | - Install `virtualenv` and create a so-called "virtual", dedicated environment for the `tinycards-python-api` project: 114 | 115 | ```console 116 | $ pip install -U virtualenv 117 | $ cd /path/to/tinycards-python-api 118 | $ virtualenv . 119 | $ source bin/activate 120 | (tinycards-python-api) $ 121 | ``` 122 | 123 | - Install dependencies within the virtual environment: 124 | 125 | ```console 126 | (tinycards-python-api) $ pip install -e . 127 | (tinycards-python-api) $ pip install -r test-requirements.txt 128 | ``` 129 | 130 | - Develop and test at will. 131 | 132 | - Leave the `virtualenv`: 133 | 134 | ```console 135 | (tinycards-python-api) $ deactivate 136 | $ 137 | ``` 138 | 139 | ### Run Tests 140 | 141 | 1. In order to run the _integration_ tests, you need to set the enviroment variables `TINYCARDS_IDENTIFIER` and `TINYCARDS_PASSWORD`. 142 | [`direnv`](https://direnv.net/) may be useful to set these automatically & permanently: 143 | 144 | ```console 145 | $ touch .envrc 146 | $ echo "export TINYCARDS_IDENTIFIER=" >> .envrc 147 | $ echo "export TINYCARDS_PASSWORD=" >> .envrc 148 | $ direnv allow 149 | direnv: loading .envrc 150 | direnv: export +TINYCARDS_IDENTIFIER +TINYCARDS_PASSWORD 151 | ``` 152 | 153 | 2. Then, from the project's root directory: 154 | 155 | 1. run the unit tests: 156 | 157 | ```console 158 | $ pytest --ignore tests/client_test.py --ignore tests/integration_test.py --cov tinycards 159 | ``` 160 | 161 | 2. run all tests: 162 | **WARNING**: the integration tests **DELETE** all the decks in the account used to test. Please ensure you either are using a dedicated test account, or do not care about losing your existing decks. 163 | 164 | ```console 165 | $ pytest --cov tinycards 166 | ``` 167 | 168 | 3. When all tests were successful, `pytest` will exit with `0`. 169 | -------------------------------------------------------------------------------- /examples/csv_to_deck.py: -------------------------------------------------------------------------------- 1 | """Example script for the Tinycards Python API that creates decks from CSV.""" 2 | import csv 3 | from getpass import getpass 4 | import os 5 | 6 | from tinycards import Tinycards 7 | from tinycards.model import Deck 8 | 9 | 10 | def csv_to_deck(csv_path): 11 | """Creates a Tinycards deck from a CSV file. 12 | 13 | The CSV file is expected to have two columns named 'front' and 'back'. 14 | """ 15 | # Create new deck. 16 | tinycards = Tinycards(user_identifier, user_password) 17 | deck = Deck('French Words') 18 | deck = tinycards.create_deck(deck) 19 | 20 | # Extract data from CSV file. 21 | word_pairs = [] 22 | with open(csv_path, 'r') as csv_file: 23 | csv_reader = csv.DictReader(csv_file) 24 | for row in csv_reader: 25 | current_word_pair = (row['front'], row['back']) 26 | word_pairs.append(current_word_pair) 27 | 28 | # Populate deck with cards from CSV data. 29 | for pair in word_pairs: 30 | deck.add_card(pair) 31 | 32 | # Save changes to Tinycards. 33 | tinycards.update_deck(deck) 34 | 35 | 36 | if __name__ == '__main__': 37 | # Take identifier and password from ENV or ask user if not set. 38 | user_identifier = os.environ.get('TINYCARDS_IDENTIFIER') 39 | if not user_identifier: 40 | print("Input identifier (e.g. email):") 41 | user_identifier = input() 42 | user_password = os.environ.get('TINYCARDS_PASSWORD') 43 | if not user_password: 44 | print("Input password:") 45 | user_password = getpass() 46 | 47 | csv_to_deck('examples/example.csv') 48 | -------------------------------------------------------------------------------- /examples/example.csv: -------------------------------------------------------------------------------- 1 | front,back 2 | être,to be 3 | avoir,to have 4 | aller,to go 5 | pouvoir,to be able to 6 | vouloir,to want 7 | faire,to do 8 | parler, to speak 9 | demander,to ask 10 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | . 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | NAME = 'tinycards' 4 | 5 | setup( 6 | name=NAME, 7 | version='0.281', 8 | description="An unofficial Python API for Tinycards by Duolingo", 9 | url='https://github.com/floscha/tinycards-python-api', 10 | author='Florian Schäfer', 11 | author_email='florian.joh.schaefer@gmail.com', 12 | license='MIT', 13 | packages=find_packages(), 14 | install_requires=[ 15 | 'requests>=2.23.0', 16 | 'requests-toolbelt==0.9.1', 17 | 'retrying==1.3.3', 18 | 'typer>=0.3.0' 19 | ], 20 | zip_safe=False, 21 | entry_points={ 22 | 'console_scripts': [ 23 | 'tinycards = tinycards.client.cli:app', 24 | ] 25 | }, 26 | classifiers=[ 27 | 'Development Status :: 3 - Alpha', 28 | 'Intended Audience :: Developers', 29 | 'License :: OSI Approved :: MIT License', 30 | 'Operating System :: OS Independent', 31 | 'Programming Language :: Python :: 3', 32 | 'Programming Language :: Python :: 3.4', 33 | 'Programming Language :: Python :: 3.5', 34 | 'Programming Language :: Python :: 3.6', 35 | 'Programming Language :: Python :: 3.7', 36 | 'Programming Language :: Python :: 3 :: Only', 37 | 'Topic :: Software Development' 38 | ] 39 | ) 40 | -------------------------------------------------------------------------------- /temporary_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floscha/tinycards-python-api/411f28f5ea295eb70ab830874759cfa16ae70837/temporary_logo.png -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | coveralls==2.0.0 2 | mypy==0.782 3 | pylama==7.7.1 4 | pytest==5.4.3 5 | pytest-cov==2.10.0 6 | -------------------------------------------------------------------------------- /tests/client_test.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | import unittest 3 | 4 | from tinycards import Tinycards 5 | 6 | 7 | class ClientTest(unittest.TestCase): 8 | 9 | def setUp(self): 10 | self.client = Tinycards() 11 | 12 | # Remove all favorites to start from a clean slate. 13 | favorites = self.client.get_favorites() 14 | for fav in favorites: 15 | self.client.remove_favorite(fav.id) 16 | 17 | def tearDown(self): 18 | sleep(1) 19 | 20 | def test_get_trends_returns_10_decks(self): 21 | trends = self.client.get_trends() 22 | 23 | self.assertEqual(10, len(trends)) 24 | 25 | def test_subscribe(self): 26 | """Test subscribing to an example user. 27 | 28 | We chose a popular the official Duolingo user account which can be 29 | expected to not be deleted any time soon: 30 | https://tinycards.duolingo.com/users/Duolingo5900 (ID = 321702331) 31 | 32 | """ 33 | user_id = 321702331 34 | expected_added_subscription = user_id 35 | 36 | added_subscription = self.client.subscribe(user_id) 37 | 38 | self.assertEqual(expected_added_subscription, added_subscription) 39 | 40 | def test_unsubscribe(self): 41 | """Test unsubscribing from an example user. 42 | 43 | For this case we chose Librarium Linguae as another example account 44 | to be able to run tests concurrently: 45 | https://tinycards.duolingo.com/users/BenPulliam (ID = 164877247) 46 | 47 | """ 48 | user_id = 164877247 49 | expected_removed_subscription = self.client.subscribe(user_id) 50 | # TODO Catch error in case subscribing failed. 51 | 52 | removed_subscription = self.client.unsubscribe(user_id) 53 | 54 | self.assertEqual(expected_removed_subscription, removed_subscription) 55 | 56 | def test_favorite_functionality(self): 57 | """Test all functionality to manage favorites.""" 58 | 59 | favorites = self.client.get_favorites() 60 | 61 | self.assertEqual(0, len(favorites)) 62 | 63 | # Add the following deck: 64 | # https://tinycards.duolingo.com/decks/3JyetMiC/writing-arabic 65 | # (ID = 79c92553-369b-41af-b1ee-8f95110eb456) 66 | deck_id = '79c92553-369b-41af-b1ee-8f95110eb456' 67 | 68 | added_favorite = self.client.add_favorite(deck_id) 69 | 70 | favorites = self.client.get_favorites() 71 | self.assertEqual(1, len(favorites)) 72 | self.assertEqual(deck_id, favorites[0].deck.id) 73 | 74 | # Remove the previously added deck from favorites. 75 | expected_removed_id = added_favorite.id 76 | 77 | removed_favorite_id = self.client.remove_favorite(added_favorite.id) 78 | 79 | self.assertEqual(expected_removed_id, removed_favorite_id) 80 | favorites = self.client.get_favorites() 81 | self.assertEqual(0, len(favorites)) 82 | 83 | def test_search(self): 84 | """Test the `search()` method. 85 | 86 | Assumes that the very popular 'Duolingo French Course' will appear as 87 | the top result for the search query 'french'. 88 | 89 | """ 90 | search_query = 'french' 91 | expected_first_result_id = '988b66f6-5fbb-4649-a641-0bebb8541496' 92 | 93 | search_results = self.client.search(search_query) 94 | first_result = search_results[0] 95 | actual_first_result_id = first_result.data.id 96 | 97 | self.assertEqual(expected_first_result_id, actual_first_result_id) 98 | 99 | 100 | if __name__ == '__main__': 101 | unittest.main() 102 | -------------------------------------------------------------------------------- /tests/deck_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from io import StringIO 3 | 4 | from tinycards.model import Deck 5 | 6 | 7 | class DeckTest(unittest.TestCase): 8 | 9 | def test_cards_from_csv(self): 10 | test_deck = Deck('Test Deck') 11 | csv_data = StringIO('front,back\nfront word,back word') 12 | 13 | test_deck.add_cards_from_csv(csv_data) 14 | 15 | first_card = test_deck.cards[0] 16 | self.assertEqual('front word', first_card.front.concepts[0].fact.text) 17 | self.assertEqual('back word', first_card.back.concepts[0].fact.text) 18 | 19 | def test_cards_to_csv(self): 20 | test_deck = Deck('Test Deck') 21 | test_deck.add_card(('front word', 'back word')) 22 | file_buffer = StringIO() 23 | 24 | test_deck.save_cards_to_csv(file_buffer) 25 | 26 | # Excel-generated CSV files use Windows-style line terminator. 27 | line_terminator = '\r\n' 28 | expected_output = 'front,back' + line_terminator \ 29 | + 'front word,back word' + line_terminator 30 | self.assertEqual(expected_output, file_buffer.getvalue()) 31 | 32 | def test_shareable_link(self): 33 | self.assertEqual('', Deck('Public deck').shareable_link) 34 | self.assertEqual('', Deck('Private deck', private=True).shareable_link) 35 | self.assertEqual('https://tiny.cards/decks/LEbBQJFU/test', 36 | Deck('Shareable deck', 37 | private=True, 38 | shareable=True, 39 | compact_id='LEbBQJFU', 40 | slug='test') 41 | .shareable_link) 42 | 43 | 44 | if __name__ == '__main__': 45 | unittest.main() 46 | -------------------------------------------------------------------------------- /tests/image_utils_test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | from tinycards.networking.image_utils import (get_image, mime_type_from_bytes, 4 | mime_type_from_path) 5 | 6 | 7 | DEFAULT_COVER_URL = ('https://s3.amazonaws.com/tinycards/image/' 8 | + '16cb6cbcb086ae0f622d1cfb7553a096') 9 | 10 | 11 | class ImageUtilsTest(unittest.TestCase): 12 | 13 | def test_mime_type_from_bytes(self): 14 | with open(path_to('test_logo_blue.jpg'), 'rb') as img: 15 | self.assertEqual('image/jpeg', mime_type_from_bytes(img.read())) 16 | with open(path_to('test_logo_red.png'), 'rb') as img: 17 | self.assertEqual('image/png', mime_type_from_bytes(img.read())) 18 | with open(path_to('image_utils_test.py'), 'rb') as not_an_img: 19 | with self.assertRaisesRegex(ValueError, 'Unsupported image type'): 20 | mime_type_from_bytes(not_an_img.read()) 21 | 22 | def test_mime_type_from_path(self): 23 | self.assertEqual('image/jpeg', 24 | mime_type_from_path(path_to('test_logo_blue.jpg'))) 25 | self.assertEqual('image/png', 26 | mime_type_from_path(path_to('test_logo_red.png'))) 27 | with self.assertRaisesRegex(ValueError, 'Unsupported image type'): 28 | mime_type_from_path(path_to('image_utils_test.py')) 29 | 30 | def test_get_image(self): 31 | img, mime_type = get_image(DEFAULT_COVER_URL) 32 | self.assertEqual(9659, len(img.read())) 33 | self.assertEqual('image/png', mime_type) 34 | 35 | 36 | def path_to(filename): 37 | current_dir = os.path.dirname(os.path.realpath(__file__)) 38 | return os.path.abspath(os.path.join(current_dir, filename)) 39 | 40 | 41 | if __name__ == '__main__': 42 | unittest.main() 43 | -------------------------------------------------------------------------------- /tests/integration_test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | import requests 4 | 5 | from tinycards import Tinycards 6 | from tinycards.model import Deck 7 | from tinycards.model.deck import NO_TYPING, NO_TYPOS 8 | 9 | 10 | DEFAULT_COVER_URL = 'https://s3.amazonaws.com/tinycards/image/16cb6cbcb086ae0f622d1cfb7553a096' # noqa 11 | 12 | 13 | class TestIntegration(unittest.TestCase): 14 | def _clean_up(self): 15 | """Before tests are run, make sure we start from a clean slate.""" 16 | all_decks = self.tinycards.get_decks() 17 | for d in all_decks: 18 | self.tinycards.delete_deck(d.id) 19 | 20 | def setUp(self): 21 | """Load data needed for most test cases.""" 22 | identifier = os.environ.get('TINYCARDS_IDENTIFIER') 23 | password = os.environ.get('TINYCARDS_PASSWORD') 24 | if not identifier or not password: 25 | raise ValueError("Both identifier and password must be set in ENV") 26 | 27 | self.tinycards = Tinycards(identifier, password) 28 | 29 | # Delete all existing decks to start from a clean slate. 30 | self._clean_up() 31 | 32 | def _test_create_empty_deck(self): 33 | """Create a new empty deck.""" 34 | new_deck = Deck('Test Deck') 35 | created_deck = self.tinycards.create_deck(new_deck) 36 | self.assertTrue(isinstance(created_deck, Deck)) 37 | self.assertEqual('', created_deck.shareable_link) 38 | self.assertEqual(DEFAULT_COVER_URL, created_deck.image_url) 39 | self.assertIsNone(created_deck.cover_image_url) 40 | 41 | num_decks = len(self.tinycards.get_decks()) 42 | self.assertEqual(1, num_decks) 43 | 44 | def _test_update_deck_without_change(self): 45 | """Commit an update without making any changes.""" 46 | first_deck = self.tinycards.get_decks()[0] 47 | 48 | updated_deck = self.tinycards.update_deck(first_deck) 49 | 50 | self.assertTrue(isinstance(updated_deck, Deck)) 51 | 52 | def _test_update_deck_title(self): 53 | """Update the title of our deck.""" 54 | test_deck = self.tinycards.find_deck_by_title('Test Deck') 55 | test_deck.title = 'Updated Test Deck' 56 | 57 | updated_deck = self.tinycards.update_deck(test_deck) 58 | 59 | self.assertTrue(isinstance(updated_deck, Deck)) 60 | self.assertEqual('Updated Test Deck', updated_deck.title) 61 | 62 | def _test_add_cards(self): 63 | """Add to cards to our deck.""" 64 | first_deck = self.tinycards.get_decks()[0] 65 | first_deck.add_card(('front test 1', 'back test 1')) 66 | first_deck.add_card(('front test 2', 'back test 2')) 67 | 68 | updated_deck = self.tinycards.update_deck(first_deck) 69 | 70 | self.assertTrue(isinstance(updated_deck, Deck)) 71 | self.assertEqual(2, len(updated_deck.cards)) 72 | 73 | def _test_delete_deck(self): 74 | """Delete the deck. 75 | 76 | Requires that a deck with title 'Updated Test Deck' was created 77 | earlier. 78 | """ 79 | first_deck = self.tinycards.find_deck_by_title('Updated Test Deck') 80 | 81 | self.tinycards.delete_deck(first_deck.id) 82 | 83 | num_decks = len(self.tinycards.get_decks()) 84 | self.assertEqual(0, num_decks) 85 | 86 | def _test_create_shareable_deck(self): 87 | """Create a new empty, shareable deck.""" 88 | new_deck = Deck('Test shareable Deck', private=True, shareable=True) 89 | created_deck = self.tinycards.create_deck(new_deck) 90 | self.assertTrue(isinstance(created_deck, Deck)) 91 | self.assertNotEqual('', created_deck.shareable_link) 92 | resp = requests.get(created_deck.shareable_link) 93 | self.assertEqual(200, resp.status_code) 94 | self._delete_deck(created_deck.id) # Clean up after ourselves. 95 | 96 | def _test_create_advanced_deck(self): 97 | """Create a new empty deck, with advanced options.""" 98 | deck = Deck( 99 | 'Test advanced Deck', 100 | self.tinycards.user_id, 101 | # Only test knowledge with back side of cards. 102 | blacklisted_side_indices=[0], 103 | # Only test knowledge with questions which do not require any 104 | # typing. 105 | blacklisted_question_types=NO_TYPING, 106 | # Stricter evaluation of answers. 107 | grading_modes=NO_TYPOS, 108 | # Text-to-speech for both front (English) and back (Japanese) 109 | # sides. 110 | tts_languages=['en', 'ja'], 111 | ) 112 | deck = self.tinycards.create_deck(deck) 113 | self._assert_advanced_options_are_set(deck) 114 | # Add a few tests cards and update the deck, in order to test PATCH 115 | # with an application/json content-type: 116 | deck.add_card(('one', 'いち')) 117 | deck.add_card(('two', 'に')) 118 | deck = self.tinycards.update_deck(deck) 119 | self._assert_advanced_options_are_set(deck) 120 | # Set a cover on the deck and update it, in order to test PATCH with a 121 | # multipart-form content-type: 122 | deck.cover = path_to('test_logo_blue.jpg') 123 | deck = self.tinycards.update_deck(deck) 124 | self._assert_advanced_options_are_set(deck) 125 | self._delete_deck(deck.id) # Clean up after ourselves. 126 | 127 | def _assert_advanced_options_are_set(self, deck): 128 | self.assertTrue(isinstance(deck, Deck)) 129 | self.assertEqual([0], deck.blacklisted_side_indices) 130 | self.assertEqual([['ASSISTED_PRODUCTION', 'PRODUCTION'], 131 | ['ASSISTED_PRODUCTION', 'PRODUCTION']], 132 | deck.blacklisted_question_types) 133 | self.assertEqual(['NO_TYPOS', 'NO_TYPOS'], deck.grading_modes) 134 | self.assertEqual(['en', 'ja'], deck.tts_languages) 135 | 136 | def _test_create_deck_with_cover_from_file(self): 137 | """Create a new empty deck, with a cover using a local file.""" 138 | blue_cover_filepath = path_to('test_logo_blue.jpg') 139 | deck = Deck('Test Deck with cover', cover=blue_cover_filepath) 140 | deck = self.tinycards.create_deck(deck) 141 | self.assertTrue(isinstance(deck, Deck)) 142 | self._assert_cover_was_updated_with_file(blue_cover_filepath, 143 | deck.image_url) 144 | self._assert_cover_was_updated_with_file(blue_cover_filepath, 145 | deck.cover_image_url) 146 | # Add a few tests cards (to pass server-side validation) & update the 147 | # deck's cover: 148 | deck.add_card(('front test 1', 'back test 1')) 149 | deck.add_card(('front test 2', 'back test 2')) 150 | red_cover_filepath = path_to('test_logo_red.png') 151 | deck.cover = red_cover_filepath 152 | deck = self.tinycards.update_deck(deck) 153 | self.assertTrue(isinstance(deck, Deck)) 154 | self._assert_cover_was_updated_with_file(red_cover_filepath, 155 | deck.image_url) 156 | self._assert_cover_was_updated_with_file(red_cover_filepath, 157 | deck.cover_image_url) 158 | self._delete_deck(deck.id) # Clean up after ourselves. 159 | 160 | def _test_create_deck_with_cover_from_url(self): 161 | """Create a new empty deck, with a cover using an image available 162 | online. 163 | """ 164 | url = 'https://d9np3dj86nsu2.cloudfront.net/thumb/5bd5092200f7fe41e1d926158b5e8243/350_403' # noqa 165 | deck = Deck('Test Deck with cover', cover=url) 166 | deck = self.tinycards.create_deck(deck) 167 | self.assertTrue(isinstance(deck, Deck)) 168 | self._assert_cover_was_updated_with_url(url, deck.image_url) 169 | self._assert_cover_was_updated_with_url(url, deck.cover_image_url) 170 | # Add a few tests cards (to pass server-side validation) & update the 171 | # deck's cover: 172 | deck.add_card(('front test 1', 'back test 1')) 173 | deck.add_card(('front test 2', 'back test 2')) 174 | url = 'https://d9np3dj86nsu2.cloudfront.net/thumb/8aaa075410df4c562bdd6c42659f02e2/350_403' # noqa 175 | deck.cover = url 176 | deck = self.tinycards.update_deck(deck) 177 | self.assertTrue(isinstance(deck, Deck)) 178 | self._assert_cover_was_updated_with_url(url, deck.image_url) 179 | self._assert_cover_was_updated_with_url(url, deck.cover_image_url) 180 | self._delete_deck(deck.id) # Clean up after ourselves. 181 | 182 | def _assert_cover_was_updated_with_file(self, filepath, deck_cover_url): 183 | self.assertNotEqual(DEFAULT_COVER_URL, deck_cover_url) 184 | self.assertTrue(deck_cover_url.startswith('https://')) 185 | resp = requests.get(deck_cover_url) 186 | self.assertEqual(200, resp.status_code) 187 | with open(filepath, 'rb') as f: 188 | self.assertEqual(f.read(), resp.content) 189 | 190 | def _assert_cover_was_updated_with_url(self, url, deck_cover_url): 191 | self.assertNotEqual(DEFAULT_COVER_URL, deck_cover_url) 192 | self.assertTrue(deck_cover_url.startswith('https://')) 193 | resp_deck = requests.get(deck_cover_url) 194 | self.assertEqual(200, resp_deck.status_code) 195 | resp_source = requests.get(url) 196 | self.assertEqual(200, resp_source.status_code) 197 | self.assertEqual(resp_source.content, resp_deck.content) 198 | 199 | def _delete_deck(self, deck_id): 200 | self.tinycards.delete_deck(deck_id) 201 | num_decks = len(self.tinycards.get_decks()) 202 | self.assertEqual(0, num_decks) 203 | 204 | def test_integration(self): 205 | """Test the whole API. 206 | 207 | Needs to run serially to avoid side effects when operating on the same 208 | backend. 209 | """ 210 | self._test_create_empty_deck() 211 | 212 | self._test_update_deck_without_change() 213 | 214 | self._test_update_deck_title() 215 | 216 | self._test_add_cards() 217 | 218 | self._test_delete_deck() 219 | 220 | self._test_create_shareable_deck() 221 | 222 | self._test_create_advanced_deck() 223 | 224 | self._test_create_deck_with_cover_from_file() 225 | 226 | self._test_create_deck_with_cover_from_url() 227 | 228 | def tearDown(self): 229 | """Clean up after all tests have finished running.""" 230 | # Delete all decks created during the test routines. 231 | self._clean_up() 232 | 233 | 234 | def path_to(filename): 235 | current_dir = os.path.dirname(os.path.realpath(__file__)) 236 | return os.path.abspath(os.path.join(current_dir, filename)) 237 | 238 | 239 | if __name__ == '__main__': 240 | unittest.main() 241 | -------------------------------------------------------------------------------- /tests/json_conversion_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from io import StringIO 3 | 4 | from tinycards.model import Deck 5 | 6 | 7 | class JsonConversionTest(unittest.TestCase): 8 | 9 | def test_cards_from_csv(self): 10 | test_deck = Deck('Test Deck') 11 | csv_data = StringIO('front,back\nfront word,back word') 12 | 13 | test_deck.add_cards_from_csv(csv_data) 14 | 15 | first_card = test_deck.cards[0] 16 | self.assertEqual('front word', first_card.front.concepts[0].fact.text) 17 | self.assertEqual('back word', first_card.back.concepts[0].fact.text) 18 | 19 | def test_cards_to_csv(self): 20 | test_deck = Deck('Test Deck') 21 | test_deck.add_card(('front word', 'back word')) 22 | file_buffer = StringIO() 23 | 24 | test_deck.save_cards_to_csv(file_buffer) 25 | 26 | # Excel-generated CSV files use Windows-style line terminator. 27 | line_terminator = '\r\n' 28 | expected_output = 'front,back' + line_terminator \ 29 | + 'front word,back word' + line_terminator 30 | self.assertEqual(expected_output, file_buffer.getvalue()) 31 | 32 | 33 | if __name__ == '__main__': 34 | unittest.main() 35 | -------------------------------------------------------------------------------- /tests/test_logo_blue.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floscha/tinycards-python-api/411f28f5ea295eb70ab830874759cfa16ae70837/tests/test_logo_blue.jpg -------------------------------------------------------------------------------- /tests/test_logo_red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floscha/tinycards-python-api/411f28f5ea295eb70ab830874759cfa16ae70837/tests/test_logo_red.png -------------------------------------------------------------------------------- /tests/user_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from tinycards.model import User 4 | 5 | 6 | class UserTest(unittest.TestCase): 7 | 8 | def test_user_constructor_sets_correct_properties(self): 9 | expected_creation_date = 0 10 | expected_email = 'abc@xyz.com' 11 | expected_fullname = 'abc' 12 | expected_id = 1234 13 | expected_learning_language = 'fr' 14 | expected_picture_url = 'http://example.org/img' 15 | expected_subscribed = True 16 | expected_subscriber_count = 0 17 | expected_subscription_count = 0 18 | expected_ui_language = 'en' 19 | expected_username = 'xyz' 20 | 21 | test_user = User( 22 | creation_date=expected_creation_date, 23 | email=expected_email, 24 | fullname=expected_fullname, 25 | user_id=expected_id, 26 | learning_language=expected_learning_language, 27 | picture_url=expected_picture_url, 28 | subscribed=expected_subscribed, 29 | subscriber_count=expected_subscriber_count, 30 | subscription_count=expected_subscription_count, 31 | ui_language=expected_ui_language, 32 | username=expected_username 33 | ) 34 | 35 | self.assertEqual(expected_creation_date, test_user.creation_date) 36 | self.assertEqual(expected_email, test_user.email) 37 | self.assertEqual(expected_fullname, test_user.fullname) 38 | self.assertEqual(expected_id, test_user.id) 39 | self.assertEqual(expected_learning_language, 40 | test_user.learning_language) 41 | self.assertEqual(expected_picture_url, test_user.picture_url) 42 | self.assertEqual(expected_subscribed, test_user.subscribed) 43 | self.assertEqual(expected_subscriber_count, test_user.subscriber_count) 44 | self.assertEqual(expected_subscription_count, 45 | test_user.subscription_count) 46 | self.assertEqual(expected_ui_language, test_user.ui_language) 47 | self.assertEqual(expected_username, test_user.username) 48 | 49 | 50 | if __name__ == '__main__': 51 | unittest.main() 52 | -------------------------------------------------------------------------------- /tinycards/__init__.py: -------------------------------------------------------------------------------- 1 | from tinycards.client import Tinycards 2 | from tinycards.model import Card, Deck 3 | 4 | __all__ = ['Card', 'Deck', 'Tinycards'] 5 | -------------------------------------------------------------------------------- /tinycards/client/__init__.py: -------------------------------------------------------------------------------- 1 | from .tinycards import Tinycards 2 | 3 | 4 | __all__ = ['Tinycards'] 5 | -------------------------------------------------------------------------------- /tinycards/client/cli.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from getpass import getpass 3 | from pathlib import Path 4 | 5 | import typer 6 | 7 | from tinycards.client import Tinycards 8 | from tinycards.model import Deck 9 | from tinycards.networking import RestApi 10 | 11 | 12 | tmp_path = Path('/tmp/.tinycards') 13 | user_path = tmp_path / 'user' 14 | jwt_path = tmp_path / 'jwt' 15 | 16 | app = typer.Typer() 17 | 18 | 19 | def _get_api_from_env(): 20 | try: 21 | user_id = int(user_path.read_text()) 22 | jwt = jwt_path.read_text() 23 | except FileNotFoundError: 24 | print("Please login first. (Use \"tinycards login\")") 25 | sys.exit(1) 26 | return RestApi(jwt), user_id 27 | 28 | 29 | @app.command() 30 | def login(identifier: str = None): 31 | print("Logging in:") 32 | if identifier is None: 33 | identifier = input("Enter your username or email: ") 34 | password = getpass("Enter your password: ") 35 | data_source = RestApi() 36 | user_id = data_source.login(identifier, password) 37 | 38 | tmp_path.mkdir(exist_ok=True) 39 | user_path.write_text(str(user_id)) 40 | jwt_path.write_text(data_source.jwt) 41 | 42 | 43 | @app.command() 44 | def logout(): 45 | try: 46 | user_path.unlink() 47 | jwt_path.unlink() 48 | tmp_path.rmdir() 49 | print("Logged out") 50 | except FileNotFoundError: 51 | print("Logout failed. Maybe you weren't even logged in.") 52 | 53 | 54 | def _list_decks(): 55 | api, user_id = _get_api_from_env() 56 | decks = api.get_decks(user_id) 57 | if decks: 58 | for d in decks: 59 | print(d.title) 60 | else: 61 | print("No decks found.") 62 | 63 | 64 | def _create_deck(deck_name: str): 65 | client = Tinycards(silent=True) 66 | client.create_deck(Deck(deck_name)) 67 | 68 | 69 | @app.command() 70 | def decks(action: str, deck_name: str = typer.Argument(None)): 71 | if action == 'list': 72 | _list_decks() 73 | elif action == 'create': 74 | _create_deck(deck_name) 75 | 76 | 77 | if __name__ == '__main__': 78 | app() 79 | -------------------------------------------------------------------------------- /tinycards/client/tinycards.py: -------------------------------------------------------------------------------- 1 | from tinycards.networking import RestApi 2 | 3 | 4 | class Tinycards(object): 5 | """The entry point class to the Tinycards Python API. 6 | 7 | Example: 8 | >>> import tinycards 9 | >>> tinycards_api = tinycards.Tinycards() 10 | 11 | Args: 12 | identifier (str): The Tinycards identifier to use for logging in. 13 | For example, a user's email address. 14 | Will be taken from ENV if not specified: 15 | .. envvar:: TINYCARDS_IDENTIFIER 16 | password (str): The user's password to login to Tinycards. 17 | Will be taken from ENV if not specified. 18 | .. envvar:: TINYCARDS_PASSWORD 19 | silent (bool): Does not output the 'Logged in as ...' message 20 | when set to True. Defaults to False. 21 | """ 22 | 23 | def __init__(self, 24 | identifier=None, 25 | password=None, 26 | silent=False): 27 | """Initialize a new instance of the Tinycards class.""" 28 | self.data_source = RestApi() 29 | self.user_id = self.data_source.login(identifier, password, silent) 30 | 31 | # --- Read user info. 32 | 33 | def get_user_info(self): 34 | """Get info data about the currently logged in user. 35 | 36 | Returns: 37 | user: A User object for the current user. 38 | 39 | """ 40 | user_info = self.data_source.get_user_info(self.user_id) 41 | 42 | return user_info 43 | 44 | # --- Get trends. 45 | 46 | def get_trends(self, types=None, limit=10, page=0, from_language='en'): 47 | """Get Tinycards trends for the current user. 48 | 49 | Args: 50 | types (list): What entities to retrieve. 51 | Can be DECK, DECK_GROUP or USER. 52 | limit: What number of results to should be returned. 53 | page: The page to return when returning more than limit results 54 | (zero-indexed). 55 | from_language: The language used for learning. 56 | 57 | Returns: A list of Trendable objects. 58 | 59 | """ 60 | if not types: 61 | types = ['DECK', 'DECK_GROUP'] 62 | 63 | trendables = self.data_source.get_trends(types, limit, page, 64 | from_language) 65 | 66 | return trendables 67 | 68 | # --- Subscriptions 69 | 70 | def subscribe(self, user_id): 71 | """Subscribe to the given user. 72 | 73 | Args: 74 | user_id: ID of the user to subscribe to. 75 | 76 | Returns: If successful, returns the ID of the user subscribed to. 77 | 78 | """ 79 | added_subscription = self.data_source.subscribe(user_id) 80 | 81 | return added_subscription 82 | 83 | def unsubscribe(self, user_id): 84 | """Unsubscribe the given user. 85 | 86 | Args: 87 | user_id: ID of the user to unsubscribe. 88 | 89 | Returns: If successful, returns the ID of the unsubscribed user. 90 | 91 | """ 92 | removed_subscription = self.data_source.unsubscribe(user_id) 93 | 94 | return removed_subscription 95 | 96 | # --- Deck CRUD 97 | 98 | def get_decks(self, include_cards=True): 99 | """Get all Decks for the currently logged in user. 100 | 101 | Returns: 102 | list: The list of retrieved decks. 103 | 104 | """ 105 | deck_previews = self.data_source.get_decks(self.user_id, 106 | not include_cards) 107 | 108 | return deck_previews 109 | 110 | def get_deck(self, deck_id, include_cards=True): 111 | """Get the Deck with the given ID. 112 | 113 | Args: 114 | deck_id (str): The ID of the deck to retrieve. 115 | include_cards (bool): Only include the cards of the deck when set 116 | to True (as by default). Otherwise cards will be an empty list. 117 | 118 | Returns: 119 | Deck: The retrieved deck. 120 | 121 | """ 122 | deck = self.data_source.get_deck(deck_id, self.user_id, include_cards) 123 | 124 | return deck 125 | 126 | def find_deck_by_title(self, deck_title): 127 | """Find an existing deck by its name if it exists. 128 | 129 | Throws an exception if multiple decks with the same title exist. 130 | 131 | Args: 132 | deck_title (str): The title of the deck to retrieve. 133 | 134 | Returns: 135 | Deck: The retrieved deck if found. None otherwise. 136 | 137 | """ 138 | all_decks = self.get_decks(False) 139 | found = [d for d in all_decks if d.title == deck_title] 140 | if len(found) == 0: 141 | return None 142 | elif len(found) == 1: 143 | return self.get_deck(found[0].id) 144 | else: 145 | raise ValueError("Multiple decks with title '%s' found" 146 | % deck_title) 147 | 148 | def create_deck(self, deck): 149 | """Create a new Deck for the currently logged in user. 150 | 151 | Args: 152 | deck (Deck): The Deck object to create. 153 | 154 | Returns: 155 | Deck: The created Deck object if creation was successful. 156 | 157 | """ 158 | created_deck = self.data_source.create_deck(deck) 159 | 160 | return created_deck 161 | 162 | def update_deck(self, deck): 163 | """Update an existing deck. 164 | 165 | Args: 166 | deck (Deck): The Deck object to update. 167 | 168 | Returns: 169 | Deck: The updated Deck object if update was successful. 170 | 171 | """ 172 | updated_deck = self.data_source.update_deck(deck, self.user_id) 173 | 174 | return updated_deck 175 | 176 | def delete_deck(self, deck_id): 177 | """Delete an existing deck. 178 | 179 | Args: 180 | deck_id (Deck): The ID of the Deck to delete. 181 | 182 | Returns: 183 | Deck: The deleted Deck object if deletion was successful. 184 | 185 | """ 186 | deleted_deck = self.data_source.delete_deck(deck_id) 187 | 188 | return deleted_deck 189 | 190 | # --- Favorites CR(U)D 191 | 192 | def get_favorites(self, user_id=None): 193 | """Get all favorites for the given user. 194 | 195 | Args: 196 | user_id (int): ID of the user to get favorites for. 197 | 198 | Returns: 199 | list: The list of retrieved decks. 200 | 201 | """ 202 | if not user_id: 203 | user_id = self.user_id 204 | 205 | favorite_decks = self.data_source.get_favorites(user_id) 206 | 207 | return favorite_decks 208 | 209 | def add_favorite(self, deck_id): 210 | """Add a deck to the current user's favorites. 211 | 212 | Args: 213 | deck_id: The ID of the deck to be added. 214 | 215 | Returns: 216 | Favorite: The added favorite. 217 | 218 | """ 219 | added_deck = self.data_source.add_favorite(self.user_id, deck_id) 220 | 221 | return added_deck 222 | 223 | def remove_favorite(self, favorite_id): 224 | """Add a deck to the current user's favorites. 225 | 226 | Args: 227 | favorite_id (str): The ID of the favorite to be removed. 228 | 229 | Returns: 230 | str: The ID of the removed favorite. 231 | 232 | """ 233 | removed_favorite_id = self.data_source.remove_favorite(self.user_id, 234 | favorite_id) 235 | 236 | return removed_favorite_id 237 | 238 | # --- Search 239 | 240 | def search(self, 241 | query, 242 | use_fuzzy_search=True, 243 | types=None, 244 | limit=10, 245 | page=0): 246 | """Searches for decks, deck groups, or users on Tinycards. 247 | 248 | Args: 249 | query (str): The used search term(s). 250 | use_fuzzy_search (bool): Whether or not to use fuzzy search. 251 | types (list): What entity to search for. Can be DECK, DECK_GROUP 252 | or USER. 253 | limit: Number of results to be returned. 254 | page: The page to return when more than `limit` results are 255 | available (zero-indexed). 256 | 257 | Returns: A list of Trendable objects. 258 | 259 | """ 260 | trendables = self.data_source.search(query, use_fuzzy_search, types, 261 | limit, page) 262 | 263 | return trendables 264 | -------------------------------------------------------------------------------- /tinycards/model/__init__.py: -------------------------------------------------------------------------------- 1 | from .card import Card 2 | from .concept import Concept 3 | from .deck import Deck 4 | from .fact import Fact 5 | from .favorite import Favorite 6 | from .searchable_data import SearchableData 7 | from .side import Side 8 | from .trendable import Trendable 9 | from .trendable_data import TrendableData 10 | from .user import User 11 | 12 | 13 | __all__ = ['Card', 'Concept', 'Deck', 'Fact', 'Favorite', 'SearchableData', 14 | 'Side', 'Trendable', 'TrendableData', 'User'] 15 | -------------------------------------------------------------------------------- /tinycards/model/card.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | from .concept import Concept 4 | from .fact import Fact 5 | from .side import Side 6 | 7 | 8 | def current_timestamp(): 9 | """Get current time in milliseconds. 10 | 11 | While Python usually works in seconds, JavaScript uses milliseconds and 12 | we want to be compatible. 13 | """ 14 | from time import time 15 | return int(time()) * 1000 16 | 17 | 18 | class Card(object): 19 | """Data class for an Tinycards card entity.""" 20 | 21 | def __init__(self, 22 | front, 23 | back, 24 | card_id=None, 25 | creation_timestamp=None): 26 | """Initialize a new instance of the Card class.""" 27 | self.id = card_id if card_id else str(uuid4()) 28 | 29 | self.creation_timestamp = creation_timestamp or current_timestamp() 30 | 31 | # While Tinycards originally uses a (2 element) tuple to 32 | # represent both sides, we chose to go with a more 33 | # semantic naming here. 34 | if isinstance(front, Side): 35 | self.front = front 36 | elif isinstance(front, str): 37 | self.front = Side(concepts=Concept(Fact(front))) 38 | else: 39 | raise ValueError("Front property can only be of type Side") 40 | if isinstance(back, Side): 41 | self.back = back 42 | elif isinstance(back, str): 43 | self.back = Side(concepts=Concept(Fact(back))) 44 | else: 45 | raise ValueError("Back property can only be of type Side") 46 | 47 | def __str__(self): 48 | return str(self.__dict__) 49 | 50 | def __repr__(self): 51 | return self.__str__() 52 | -------------------------------------------------------------------------------- /tinycards/model/concept.py: -------------------------------------------------------------------------------- 1 | from time import time 2 | from uuid import uuid4 3 | 4 | 5 | class Concept(object): 6 | """Data class for an Tinycards concept entity.""" 7 | 8 | def __init__(self, 9 | fact, 10 | concept_id=None, 11 | creation_timestamp=None, 12 | update_timestamp=None): 13 | """Initialize a new instance of the Concept class.""" 14 | self.fact = fact 15 | self.id = concept_id if concept_id else str(uuid4()) 16 | self.creation_timestamp = (creation_timestamp if creation_timestamp 17 | else time()) 18 | self.update_timestamp = (update_timestamp if update_timestamp 19 | else self.creation_timestamp) 20 | 21 | def __str__(self): 22 | return str(self.__dict__) 23 | 24 | def __repr__(self): 25 | return self.__str__() 26 | -------------------------------------------------------------------------------- /tinycards/model/deck.py: -------------------------------------------------------------------------------- 1 | import csv 2 | 3 | from .card import Card 4 | 5 | 6 | NO_TYPING = [['ASSISTED_PRODUCTION', 'PRODUCTION'], 7 | ['ASSISTED_PRODUCTION', 'PRODUCTION']] 8 | NO_TYPOS = ['NO_TYPOS', 'NO_TYPOS'] 9 | 10 | 11 | class Deck(object): 12 | """Data class for an Tinycards deck entity.""" 13 | 14 | def __init__(self, 15 | title, 16 | description=None, 17 | cover=None, 18 | deck_id=None, 19 | front_language=None, 20 | back_language=None, 21 | cards=None, 22 | private=False, 23 | shareable=False, 24 | slug='', 25 | compact_id='', 26 | blacklisted_side_indices=None, 27 | blacklisted_question_types=None, 28 | grading_modes=None, 29 | tts_languages=None): 30 | ''' 31 | Initialize a new instance of the Deck class. 32 | Args: 33 | cover (string, optional): 34 | The cover image of this deck. If set to a local file path or an 35 | image URL, the corresponding file will be uploaded to 36 | Tinycards. This can also be done after Deck construction, 37 | simply by setting this attribute to a valid non-None value. 38 | After creating or updating a deck, the Tinycards URL of the 39 | cover image can be read from attributes: 40 | - cover_image_url (only set once a custom cover has been 41 | uploaded) 42 | - image_url (always set, either to the URL of the default 43 | cover, or the URL of the custom cover) 44 | private (bool, optional): 45 | If set to False (the default), the deck will be publicly 46 | available. If set to True, it will not. If you need a 47 | "shareable link", please also set shareable to True. 48 | See also below "Visibility of the deck" section. 49 | shareable (bool, optional): 50 | If set to False (the default), the deck will not have any 51 | "shareable link" associated to it. If set to True, it will. 52 | Once the deck has been created, the link generated by Tinycards 53 | is accessible via the shareable_link attribute. 54 | See also below "Visibility of the deck" section. 55 | slug (string, optional): short name for the Deck. Only returned by 56 | Tinycards upon Deck creation. 57 | compact_id (string, optional): short unique ID for the deck. Only 58 | returned by Tinycards upon Deck creation. 59 | blacklisted_side_indices (list, optional): 60 | Optional list of indices of sides to NOT use for knowledge 61 | testing, e.g.: 62 | - [0] to NOT use the front side (0) of the cards and only test 63 | knowledge with their back sides (1), 64 | - [1] to NOT use the back side (1) of the cards and only test 65 | knowledge with their front sides (0). 66 | blacklisted_question_types (list, optional): 67 | Optional list of lists containing the types of questions to 68 | skip for knowledge testing. When provided, the outer list 69 | should contain two inner lists: 70 | - one for the types of questions to skip for knowledge testing 71 | on the front side of cards, 72 | - one for the types of questions to skip for knowledge testing 73 | on the back side of cards. 74 | For example, to test knowledge without having to type answers, 75 | for both sides, one should pass: 76 | [ 77 | ['ASSISTED_PRODUCTION', 'PRODUCTION'], 78 | ['ASSISTED_PRODUCTION', 'PRODUCTION'] 79 | ] 80 | The constant tinycards.model.deck.NO_TYPING can be used for 81 | this. 82 | grading_modes (list, optional): 83 | Optional list of modes to evaluate the answers provided. 84 | When provided, the list should contain two values: 85 | - one for the grading of the front side of cards, 86 | - one for the grading of the back side of cards. 87 | For example, by default, Tinycards tolerate typos in typed 88 | answers. To disable this and have a stricter grading, one 89 | should pass: 90 | ['NO_TYPOS', 'NO_TYPOS'] 91 | The constant tinycards.model.deck.NO_TYPOS can be used for 92 | this. 93 | tts_languages (list, optional): 94 | Optional list of languages to enable text-to-speech. 95 | When provided, the list should contain two values: 96 | - one for the language of the front side of cards, 97 | - one for the language of the back side of cards. 98 | The following languages are currently supported by Tinycards: 99 | - Catalan: 'ca' 100 | - Danish: 'da' 101 | - Dutch: 'nl-NL' 102 | - English: 'en' 103 | - French: 'fr' 104 | - German: 'de' 105 | - Italian: 'it' 106 | - Japanese: 'ja' 107 | - Norwegian: 'no-BO' 108 | - Polish: 'pl' 109 | - Portuguese: 'pt' 110 | - Russian: 'ru' 111 | - Spanish: 'es' 112 | - Swedish: 'sv' 113 | - Turkish: 'tr' 114 | - Welsh: 'cy' 115 | For example, if the front side of cards is in English, and 116 | their back side is in Japanese, one should pass: 117 | ['en', 'ja'] 118 | 119 | Visibility of the deck: 120 | Tinycards' UI let's you specifiy that a deck is visible to: 121 | - Everyone 122 | - People with a private link 123 | - Only me 124 | To achieve the equivalent using Tinycards' API, one needs to 125 | correctly set the private and shareable flags: 126 | - Everyone: private=False, shareable=False 127 | - People with a private link: private=True, shareable=True 128 | - Only me: private=True, shareable=False 129 | 130 | ''' 131 | # IDs: 132 | self.id = deck_id 133 | self.slug = slug 134 | self.compact_id = compact_id 135 | 136 | self.creation_timestamp = None 137 | self.title = title 138 | self.description = description 139 | self.cards = cards if cards else [] 140 | # Cover: 141 | self.cover = cover 142 | # Only set upon response from Tinycards' API. 143 | self.cover_image_url = None 144 | # Only set upon response from Tinycards' API. 145 | self.image_url = None 146 | # Visibility: 147 | self.private = private 148 | self.shareable = shareable 149 | self.shareable_link = ('https://tiny.cards/decks/%s/%s' 150 | % (compact_id, slug) if private and shareable 151 | and compact_id and slug else '') 152 | # Knowledge testing: 153 | self.blacklisted_side_indices = blacklisted_side_indices or [] 154 | self.blacklisted_question_types = blacklisted_question_types or [] 155 | self.grading_modes = grading_modes or [] 156 | self.tts_languages = tts_languages or [] 157 | 158 | def __str__(self): 159 | return str(self.__dict__) 160 | 161 | def __repr__(self): 162 | return self.__str__() 163 | 164 | def add_card(self, card): 165 | """Add a new card to the deck.""" 166 | if isinstance(card, tuple) and len(card) == 2: 167 | new_card = Card(front=card[0], back=card[1]) 168 | else: 169 | raise ValueError("Invalid card used as argument") 170 | self.cards.append(new_card) 171 | 172 | def add_cards_from_csv(self, csv_file, 173 | front_column='front', 174 | back_column='back'): 175 | """Add word pairs from a CSV file as cards to the deck. 176 | 177 | Args: 178 | csv_file: The file buffer that contains the CSV data. 179 | front_column (str): Optional name for the 'front' column. 180 | back_column (str): Optional name for the 'back' column. 181 | 182 | Example: 183 | >>> with open(csv_path, 'r') as csv_file: 184 | >>> deck.add_cards_from_csv(csv_file) 185 | 186 | """ 187 | csv_reader = csv.DictReader(csv_file) 188 | for row in csv_reader: 189 | current_word_pair = (row[front_column], row[back_column]) 190 | self.add_card(current_word_pair) 191 | 192 | def save_cards_to_csv(self, csv_file, 193 | front_column='front', 194 | back_column='back'): 195 | """Save the word pairs from the deck's cards to a CSV file. 196 | 197 | Args: 198 | csv_file: The file buffer to store the CSV data in. 199 | front_column (str): Optional name for the 'front' column. 200 | back_column (str): Optional name for the 'back' column. 201 | 202 | Example: 203 | >>> with open(csv_path, 'w') as csv_file: 204 | >>> deck.save_cards_to_csv(csv_file) 205 | 206 | """ 207 | csv_writer = csv.DictWriter(csv_file, 208 | fieldnames=[front_column, back_column]) 209 | # Add header row first. 210 | csv_writer.writeheader() 211 | # Then add all cards as rows. 212 | for card in self.cards: 213 | front_word = card.front.concepts[0].fact.text 214 | back_word = card.back.concepts[0].fact.text 215 | csv_writer.writerow({front_column: front_word, 216 | back_column: back_word}) 217 | -------------------------------------------------------------------------------- /tinycards/model/fact.py: -------------------------------------------------------------------------------- 1 | """.""" 2 | from uuid import uuid4 3 | 4 | 5 | class Fact(object): 6 | """Data class for an Tinycards fact entity.""" 7 | 8 | def __init__(self, text=None, fact_id=None, fact_type=None, image_url=None, 9 | tts_url=None): 10 | """Initialize a new instance of the Fact class.""" 11 | self.id = fact_id or str(uuid4()).replace('-', '') 12 | self.text = text 13 | self.type = fact_type or 'TEXT' 14 | self.image_url = image_url 15 | self.tts_url = tts_url 16 | 17 | def __str__(self): 18 | return str(self.__dict__) 19 | 20 | def __repr__(self): 21 | return self.__str__() 22 | -------------------------------------------------------------------------------- /tinycards/model/favorite.py: -------------------------------------------------------------------------------- 1 | class Favorite(object): 2 | """A `Favorite` hold a `Deck` object along with some meta data.""" 3 | 4 | def __init__(self, id_, deck): 5 | """Initialize a new instance of the `Favorite` class. 6 | 7 | Args: 8 | id_ (str): Unique identifier of the favorite. 9 | deck (Deck): The favorited deck. 10 | """ 11 | self.id = id_ 12 | self.deck = deck 13 | -------------------------------------------------------------------------------- /tinycards/model/searchable_data.py: -------------------------------------------------------------------------------- 1 | class SearchableData(object): 2 | """The most important data fields of the Searchable class.""" 3 | 4 | def __init__(self, 5 | id_, 6 | name, 7 | description, 8 | average_freshness): 9 | """Initialize a new instance of the SearchableData class. 10 | 11 | Args: 12 | id_: ID of the Deck or Deck-Set. 13 | name: Name of the Deck or Deck-Set. 14 | description: Textual description. 15 | average_freshness: The average freshness over all set's cards. 16 | """ 17 | self.id = id_ 18 | self.name = name 19 | self.description = description 20 | self.average_freshness = average_freshness 21 | -------------------------------------------------------------------------------- /tinycards/model/side.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | from .concept import Concept 4 | 5 | 6 | class Side(object): 7 | """"Data class for an Tinycards side entity.""" 8 | 9 | def __init__(self, 10 | side_id=None, 11 | concepts=None): 12 | """Initialize a new instance of the Side class.""" 13 | self.side_id = side_id if side_id else str(uuid4()) 14 | if isinstance(concepts, Concept): 15 | self.concepts = [concepts] 16 | elif isinstance(concepts, list): 17 | self.concepts = concepts 18 | else: 19 | raise ValueError("Concepts property can only be a Concept \ 20 | or list of Concepts") 21 | 22 | def __str__(self): 23 | return str(self.__dict__) 24 | 25 | def __repr__(self): 26 | return self.__str__() 27 | -------------------------------------------------------------------------------- /tinycards/model/trendable.py: -------------------------------------------------------------------------------- 1 | class Trendable(object): 2 | """Represents a trending object on Tinycards.""" 3 | 4 | def __init__(self, id_, type_, data): 5 | """Initialize a new instance of the Trendable class. 6 | 7 | Args: 8 | id_: ID of the entity. 9 | type_: The entity type (Can be DECK, DECK_GROUP or USER). 10 | data: All data fields of the Trendable. 11 | """ 12 | self.id = id_ 13 | self.type = type_ 14 | self.data = data 15 | -------------------------------------------------------------------------------- /tinycards/model/trendable_data.py: -------------------------------------------------------------------------------- 1 | class TrendableData(object): 2 | """All data fields of the Trendable class.""" 3 | def __init__(self, 4 | blacklisted_question_types, 5 | blacklisted_side_indices, 6 | card_count, 7 | compact_id, 8 | cover_image_url, 9 | created_at, 10 | deck_groups, 11 | description, 12 | enabled, 13 | favorite_count, 14 | from_language, 15 | fullname, 16 | grading_modes, 17 | hashes, 18 | id_, 19 | image_url, 20 | name, 21 | picture, 22 | private, 23 | shareable, 24 | slug, 25 | tag_ids, 26 | tts_languages, 27 | ui_language, 28 | updated_at, 29 | user_id, 30 | username): 31 | """Initialize a new instance of the TrendableData class. 32 | 33 | Args: 34 | blacklisted_question_types (list): 35 | blacklisted_side_indices (list): 36 | card_count (int): The number of cards in the deck. 37 | compact_id (str): The compact version of the deck ID. 38 | cover_image_url: URL for the cover image of the deck. 39 | created_at (float): Timestamp for when the deck was created. 40 | deck_groups (list): 41 | description (str): Textual description of the set. 42 | enabled (bool): Whether or not the deck is enabled. 43 | favorite_count: The number of users who favorited this deck. 44 | from_language (str): The language used for learning. 45 | fullname (str): Full name of the deck's creator. 46 | grading_modes (list): 47 | hashes (dict): The hashes for 'author', 'cardCount', 'deck', 48 | 'deckGroups', and 'favorite'. 49 | id_ (str): Unique identifier of the deck. 50 | image_url (str): 51 | name (str): The name of the deck. 52 | picture (str): URL to the picture of the trendable. 53 | private (bool): Whether or not the deck is private. 54 | shareable (bool): Whether or not the deck can be shared. 55 | slug (str): A more URL-compatible format of the name. 56 | tag_ids (list): List of tags used for the deck. 57 | tts_languages (list): The languages selected for speech generation. 58 | ui_language (str): Language selected for the user interface. 59 | updated_at (float): Timestamp for when the deck was last updated. 60 | user_id (int): User ID of the deck's creator. 61 | username (str): Duolingo user name of the deck's creator. 62 | """ 63 | self.blacklisted_question_types = blacklisted_question_types 64 | self.blacklisted_side_indices = blacklisted_side_indices 65 | self.card_count = card_count 66 | self.compact_id = compact_id 67 | self.cover_image_url = cover_image_url 68 | self.created_at = created_at 69 | self.deck_groups = deck_groups 70 | self.description = description 71 | self.enabled = enabled 72 | self.favorite_count = favorite_count 73 | self.from_language = from_language 74 | self.fullname = fullname 75 | self.grading_modes = grading_modes 76 | self.hashes = hashes 77 | self.id = id_ 78 | self.image_url = image_url 79 | self.name = name 80 | self.picture = picture 81 | self.private = private 82 | self.shareable = shareable 83 | self.slug = slug 84 | self.tag_ids = tag_ids 85 | self.tts_languages = tts_languages 86 | self.ui_language = ui_language 87 | self.updated_at = updated_at 88 | self.user_id = user_id 89 | self.username = username 90 | -------------------------------------------------------------------------------- /tinycards/model/user.py: -------------------------------------------------------------------------------- 1 | class User(object): 2 | """Data class for a Tinycards user entity. 3 | 4 | Args: 5 | creation_date (int): Timestamp of when the user's account was created. 6 | email (string): Email address the user's account is registered to. 7 | fullname (string): The full name of the user. 8 | user_id (int): The user ID generated by Tinycards/Duolingo. 9 | learning_language (str): Language code of the language the user has 10 | be learning last (e.g., 'fr' for French). 11 | picture_url (str): Link to the user's profile picture. 12 | subscribed (bool): ?? 13 | subscriber_count (int): Number of other users that have subscribed to 14 | the current user. 15 | subscription_count (int): Number of other users the current user has 16 | subscribed to. 17 | ui_language (str): Language code of the language the user has selected 18 | for the Tinycards user interface (e.g., 'en' for English). 19 | username (str): The user's unique username used by Tinycards/Duolingo. 20 | """ 21 | 22 | def __init__(self, 23 | creation_date, 24 | email, 25 | fullname, 26 | user_id, 27 | learning_language, 28 | picture_url, 29 | subscribed, 30 | subscriber_count, 31 | subscription_count, 32 | ui_language, 33 | username): 34 | """Initialize a new instance of the User class.""" 35 | self.creation_date = creation_date 36 | self.email = email 37 | self.fullname = fullname 38 | self.id = user_id 39 | self.learning_language = learning_language 40 | self.picture_url = picture_url 41 | self.subscribed = subscribed 42 | self.subscriber_count = subscriber_count 43 | self.subscription_count = subscription_count 44 | self.ui_language = ui_language 45 | self.username = username 46 | -------------------------------------------------------------------------------- /tinycards/networking/__init__.py: -------------------------------------------------------------------------------- 1 | from .rest_api import RestApi 2 | 3 | 4 | __all__ = ['RestApi'] 5 | -------------------------------------------------------------------------------- /tinycards/networking/error/__init__.py: -------------------------------------------------------------------------------- 1 | from .invalid_response import InvalidResponseError 2 | 3 | 4 | __all__ = ['InvalidResponseError'] 5 | -------------------------------------------------------------------------------- /tinycards/networking/error/invalid_response.py: -------------------------------------------------------------------------------- 1 | class InvalidResponseError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /tinycards/networking/form_utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from requests_toolbelt.multipart.encoder import MultipartEncoder 4 | from .image_utils import get_image, mime_type_from_path 5 | 6 | 7 | CARDS = 'cards' 8 | IMAGE_FILE = 'imageFile' 9 | BLACKLISTED_QUESTION_TYPES = 'blacklistedQuestionTypes' 10 | GRADING_MODES = 'gradingModes' 11 | TTS_LANGUAGES = 'ttsLanguages' 12 | # Keys for which the data needs to be JSON-encoded: 13 | JSON_KEYS = set([CARDS, BLACKLISTED_QUESTION_TYPES, GRADING_MODES, 14 | TTS_LANGUAGES]) 15 | # Keys for which the data needs to be encoded in special ways: 16 | SPECIAL_KEYS = set([IMAGE_FILE]).union(JSON_KEYS) 17 | 18 | 19 | def to_multipart_form(data, boundary=None): 20 | """Create a multipart form like produced by HTML forms from a dict.""" 21 | fields = {} 22 | for k, v in data.items(): 23 | if k not in SPECIAL_KEYS: 24 | fields[k] = str(v) if not isinstance(v, bool) else str(v).lower() 25 | if k in JSON_KEYS: 26 | fields[k] = json.dumps(data[k]) 27 | if _has_image_file(data): 28 | fields[IMAGE_FILE] = _get_image(data[IMAGE_FILE]) 29 | return MultipartEncoder(fields=fields, boundary=boundary) 30 | 31 | 32 | def _has_image_file(data): 33 | return IMAGE_FILE in data and data[IMAGE_FILE] 34 | 35 | 36 | # The name seems irrelevant to Tinycards as it isn't used anywhere, doesn't 37 | # appear in the URL, and is always this regardless of the type of the image. 38 | _FILENAME = 'cover.jpg' 39 | 40 | 41 | def _get_image(path_or_url): 42 | ''' 43 | Returns a tuple (filename, file, MIME type) compliant with 44 | requests_toolbelt's MultipartEncoder. 45 | See also: https://toolbelt.readthedocs.io/en/latest/uploading-data.html#uploading-data # noqa 46 | ''' 47 | if os.path.exists(path_or_url): 48 | mime_type = mime_type_from_path(path_or_url) 49 | return (_FILENAME, open(path_or_url, 'rb'), mime_type) 50 | elif path_or_url.startswith('http'): 51 | img, mime_type = get_image(path_or_url) 52 | return (_FILENAME, img, mime_type) 53 | else: 54 | raise ValueError('Unknown image: %s' % path_or_url) 55 | -------------------------------------------------------------------------------- /tinycards/networking/image_utils.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | from mimetypes import guess_type 3 | import requests 4 | 5 | 6 | def get_image(url): 7 | ''' 8 | Get the image at the provided URL and returns a file-like buffer 9 | containing its bytes, and its MIME type. 10 | ''' 11 | resp = requests.get(url) 12 | if not resp.ok: 13 | raise RuntimeError( 14 | 'Failed to download image from %s: %s - %s' 15 | % (url, resp.status_code, resp.text) 16 | ) 17 | img = BytesIO(resp.content) 18 | mime_type = _mime_type(img, resp.headers, url) 19 | return img, mime_type 20 | 21 | 22 | def _mime_type(img, headers, url): 23 | ''' 24 | Try to get the MIME type of the provided image using either the HTTP 25 | response's headers, information available via its URL, or by reading the 26 | beginning of the image's bytes. 27 | ''' 28 | if ('Content-Type' in headers 29 | and headers['Content-Type'].startswith('image/')): 30 | return headers['Content-Type'] 31 | mime_type, _ = guess_type(url) 32 | if mime_type and mime_type.startswith('image/'): 33 | return mime_type 34 | mime_type = mime_type_from_bytes(img.read()) 35 | img.seek(0) # Reset the BytesIO object, so that it can be read again. 36 | return mime_type 37 | 38 | 39 | def mime_type_from_path(img_path): 40 | ''' Guess the MIME type of the provided image. ''' 41 | mime_type, _ = guess_type(img_path) 42 | if mime_type and mime_type.startswith('image/'): 43 | return mime_type 44 | with open(img_path, 'rb') as img: 45 | return mime_type_from_bytes(img.read(_LEN_HEADER)) 46 | 47 | 48 | # Only the first 11 bytes of an image are useful to determine its type. 49 | _LEN_HEADER = 11 50 | 51 | 52 | def mime_type_from_bytes(img_bytes): 53 | ''' Guess the MIME type of the provided image. ''' 54 | img_bytes = img_bytes[:_LEN_HEADER] 55 | if img_bytes[:4] == b'\xff\xd8\xff\xe0' and img_bytes[6:] == b'JFIF\0': 56 | return 'image/jpeg' 57 | elif img_bytes[1:4] == b'PNG': 58 | return 'image/png' 59 | else: 60 | raise ValueError('Unsupported image type') 61 | -------------------------------------------------------------------------------- /tinycards/networking/json_converter.py: -------------------------------------------------------------------------------- 1 | """Several helper functions to convert between data objects and JSON.""" 2 | import json 3 | from tinycards.model import Card, Concept, Deck, Fact, Favorite 4 | from tinycards.model import SearchableData, Side, Trendable, TrendableData 5 | from tinycards.model import User 6 | 7 | 8 | # --- User conversion 9 | 10 | def json_to_user(json_data): 11 | """Convert a JSON dict into a User object.""" 12 | user_obj = User( 13 | creation_date=json_data['creationDate'], 14 | email=json_data['email'], 15 | fullname=json_data['fullname'], 16 | user_id=json_data['id'], 17 | learning_language=json_data['learningLanguage'], 18 | picture_url=json_data['picture'], 19 | subscribed=json_data['subscribed'], 20 | subscriber_count=json_data['subscriberCount'], 21 | subscription_count=json_data['subscriptionCount'], 22 | ui_language=json_data['uiLanguage'], 23 | username=json_data['username'] 24 | ) 25 | 26 | return user_obj 27 | 28 | 29 | # --- Fact conversion 30 | 31 | def json_to_fact(json_data): 32 | """Convert a JSON dict into a Fact object.""" 33 | fact_obj = Fact( 34 | fact_id=json_data['id'], 35 | fact_type=json_data['type'], 36 | text=json_data.get('text'), 37 | image_url=json_data.get('imageUrl'), 38 | tts_url=json_data.get('ttsUrl') 39 | ) 40 | 41 | return fact_obj 42 | 43 | 44 | def fact_to_json(fact_obj): 45 | """Convert a Fact object into a JSON dict.""" 46 | json_data = { 47 | # 'id': fact_obj.id, 48 | 'text': fact_obj.text, 49 | 'type': fact_obj.type 50 | } 51 | 52 | return json_data 53 | 54 | 55 | # --- Concept conversion 56 | 57 | def json_to_concept(json_data): 58 | """Convert a JSON dict into a Concept object.""" 59 | concept_obj = Concept( 60 | fact=json_to_fact(json_data['fact']), 61 | concept_id=json_data['id'], 62 | creation_timestamp=json_data['createdAt'], 63 | update_timestamp=json_data['updatedAt'] 64 | ) 65 | 66 | return concept_obj 67 | 68 | 69 | def concept_to_json(concept_obj): 70 | """Convert a Concept object into a JSON dict.""" 71 | json_data = { 72 | # 'createdAt': concept_obj.creation_timestamp, 73 | 'fact': fact_to_json(concept_obj.fact), 74 | # 'id': concept_obj.id, 75 | # 'noteFacts': [], 76 | # 'updatedAt': concept_obj.update_timestamp, 77 | } 78 | 79 | return json_data 80 | 81 | 82 | # --- Side conversion 83 | 84 | def json_to_side(json_data): 85 | """Convert a JSON dict into a Side object.""" 86 | side_obj = Side( 87 | side_id=json_data['id'], 88 | concepts=[json_to_concept(c) for c in json_data['concepts']] 89 | ) 90 | 91 | return side_obj 92 | 93 | 94 | def side_to_json(side_obj): 95 | """Convert a Side object into a JSON dict.""" 96 | json_data = { 97 | 'concepts': [concept_to_json(c) for c in side_obj.concepts], 98 | # 'id': side_obj.side_id, 99 | } 100 | 101 | return json_data 102 | 103 | 104 | # --- Card conversion 105 | 106 | def json_to_card(json_data): 107 | """Convert a JSON dict into a Card object.""" 108 | card_obj = Card( 109 | front=json_to_side(json_data['sides'][0]), 110 | back=json_to_side(json_data['sides'][1]), 111 | card_id=json_data['id'] 112 | ) 113 | 114 | return card_obj 115 | 116 | 117 | def card_to_json(card_obj): 118 | """Convert a Card object into a JSON dict.""" 119 | json_data = { 120 | # 'id': card_obj.id, 121 | 'creationTimestamp': card_obj.creation_timestamp, 122 | 'sides': [ 123 | side_to_json(card_obj.front), 124 | side_to_json(card_obj.back) 125 | ], 126 | } 127 | 128 | # Add additional fields if not None. 129 | # if card_obj.creation_timestamp: 130 | # json_data['creationTimestamp'] = card_obj.creation_timestamp 131 | 132 | return json_data 133 | 134 | 135 | # --- Deck conversion 136 | 137 | def json_to_deck(json_data): 138 | """Convert a JSON dict into a Deck object.""" 139 | deck = Deck( 140 | title=json_data['name'], 141 | description=json_data['description'], 142 | deck_id=json_data['id'], 143 | compact_id=json_data['compactId'], 144 | slug=json_data['slug'], 145 | cards=([json_to_card(c) for c in json_data['cards']] 146 | if 'cards' in json_data else []), 147 | private=bool(json_data['private']), 148 | shareable=bool(json_data['shareable']), 149 | blacklisted_side_indices=json_data['blacklistedSideIndices'], 150 | blacklisted_question_types=json_data['blacklistedQuestionTypes'], 151 | grading_modes=json_data['gradingModes'], 152 | tts_languages=json_data['ttsLanguages'], 153 | ) 154 | deck.image_url = json_data['imageUrl'] 155 | deck.cover_image_url = json_data['coverImageUrl'] 156 | return deck 157 | 158 | 159 | def deck_to_json(deck_obj, as_json_str=False): 160 | """Convert a Deck object into a JSON dict. 161 | 162 | Contains a lot of placeholder values at the moment. 163 | 164 | Args: 165 | as_json_str (bool): Convert lists into a single JSON string (required 166 | for PATCH with content-type: application/json). 167 | """ 168 | cards = [card_to_json(c) for c in deck_obj.cards] 169 | 170 | json_data = { 171 | 'name': deck_obj.title, 172 | 'description': deck_obj.description, 173 | 'private': deck_obj.private, 174 | 'shareable': deck_obj.shareable, 175 | 'cards': as_obj_or_json_str(cards, as_json_str), 176 | 'ttsLanguages': as_obj_or_json_str( 177 | deck_obj.tts_languages, as_json_str 178 | ), 179 | 'blacklistedSideIndices': as_obj_or_json_str( 180 | deck_obj.blacklisted_side_indices, as_json_str 181 | ), 182 | 'blacklistedQuestionTypes': as_obj_or_json_str( 183 | deck_obj.blacklisted_question_types, as_json_str 184 | ), 185 | 'gradingModes': as_obj_or_json_str( 186 | deck_obj.grading_modes, as_json_str 187 | ), 188 | 'fromLanguage': 'en', 189 | 'imageFile': deck_obj.cover, 190 | 'coverImageUrl': deck_obj.cover_image_url, 191 | } 192 | 193 | return json_data 194 | 195 | 196 | def as_obj_or_json_str(obj, as_json_str): 197 | return json.dumps(obj) if as_json_str else obj 198 | 199 | 200 | # --- Trendable conversion 201 | 202 | def json_to_trendable(json_data): 203 | """Convert a JSON dict into a Trendable object.""" 204 | json_trendable_data = json_data.get('data') 205 | if not json_trendable_data: 206 | raise ValueError("JSON object contains no 'data' field") 207 | 208 | try: 209 | trendable_data = TrendableData( 210 | json_trendable_data['blacklistedQuestionTypes'], 211 | json_trendable_data['blacklistedSideIndices'], 212 | json_trendable_data['cardCount'], 213 | json_trendable_data['compactId'], 214 | json_trendable_data['coverImageUrl'], 215 | json_trendable_data['createdAt'], 216 | json_trendable_data['deckGroups'], 217 | json_trendable_data['description'], 218 | json_trendable_data['enabled'], 219 | json_trendable_data['favoriteCount'], 220 | json_trendable_data['fromLanguage'], 221 | json_trendable_data.get('fullname'), 222 | json_trendable_data['gradingModes'], 223 | json_trendable_data['hashes'], 224 | json_trendable_data['id'], 225 | json_trendable_data['imageUrl'], 226 | json_trendable_data['name'], 227 | json_trendable_data['picture'], 228 | json_trendable_data['private'], 229 | json_trendable_data['shareable'], 230 | json_trendable_data['slug'], 231 | json_trendable_data['tagIds'], 232 | json_trendable_data['ttsLanguages'], 233 | json_trendable_data['uiLanguage'], 234 | json_trendable_data['updatedAt'], 235 | json_trendable_data['userId'], 236 | json_trendable_data['username'] 237 | ) 238 | except KeyError as ke: 239 | raise ke 240 | 241 | trendable_obj = Trendable(id_=json_data['id'], 242 | type_=json_data['type'], 243 | data=trendable_data) 244 | 245 | return trendable_obj 246 | 247 | 248 | def trendable_to_json(trendable_obj: Trendable): 249 | """Convert a Trendable object into a JSON dict.""" 250 | trendable_data = trendable_obj.data 251 | json_trendable_data = { 252 | 'blacklistedQuestionTypes': trendable_data.blacklisted_question_types, 253 | 'blacklistedSideIndices': trendable_data.blacklisted_side_indices, 254 | 'cardCount': trendable_data.card_count, 255 | 'compactId': trendable_data.compact_id, 256 | 'coverImageUrl': trendable_data.cover_image_url, 257 | 'createdAt': trendable_data.created_at, 258 | 'deckGroups': trendable_data.deck_groups, 259 | 'description': trendable_data.description, 260 | 'enabled': trendable_data.enabled, 261 | 'favoriteCount': trendable_data.favorite_count, 262 | 'fromLanguage': trendable_data.from_language, 263 | 'fullname': trendable_data.fullname, 264 | 'gradingModes': trendable_data.grading_modes, 265 | 'hashes': trendable_data.hashes, 266 | 'id': trendable_data.id, 267 | 'imageUrl': trendable_data.image_url, 268 | 'name': trendable_data.name, 269 | 'picture': trendable_data.picture, 270 | 'private': trendable_data.private, 271 | 'shareable': trendable_data.shareable, 272 | 'slug': trendable_data.slug, 273 | 'tagIds': trendable_data.tagIds, 274 | 'ttsLanguages': trendable_data.tts_languages, 275 | 'uiLanguage': trendable_data.ui_language, 276 | 'updatedAt': trendable_data.updated_at, 277 | 'username': trendable_data.username 278 | } 279 | 280 | json_data = { 281 | 'id': trendable_obj.id, 282 | 'type': trendable_obj.type, 283 | 'data': json_trendable_data 284 | } 285 | 286 | return json_data 287 | 288 | 289 | # --- Searchable conversion 290 | 291 | def json_to_searchable(json_data): 292 | """Convert a JSON dict into a Searchable object.""" 293 | json_searchable_data = json_data.get('data') 294 | if not json_searchable_data: 295 | raise ValueError("JSON object contains no 'data' field") 296 | 297 | try: 298 | searchable_data = SearchableData( 299 | json_searchable_data['id'], 300 | json_searchable_data['name'], 301 | json_searchable_data['description'], 302 | json_searchable_data.get('averageFreshness') 303 | ) 304 | except KeyError as ke: 305 | raise ke 306 | 307 | searchable_obj = Trendable(id_=json_data['id'], 308 | type_=json_data['type'], 309 | data=searchable_data) 310 | 311 | return searchable_obj 312 | 313 | 314 | # --- Favorite conversion 315 | 316 | def json_to_favorite(json_data): 317 | """Convert a JSON dict into a Favorite object.""" 318 | favorite_obj = Favorite(id_=json_data['id'], 319 | deck=json_to_deck(json_data['deck'])) 320 | 321 | return favorite_obj 322 | 323 | 324 | def favorite_to_json(favorite_obj: Favorite): 325 | """Convert a Favorite object into a JSON dict.""" 326 | json_data = { 327 | 'id': favorite_obj.id, 328 | 'deck': deck_to_json(favorite_obj.deck) 329 | } 330 | 331 | return json_data 332 | -------------------------------------------------------------------------------- /tinycards/networking/rest_api.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | import requests 5 | from retrying import retry 6 | 7 | from . import json_converter 8 | from .form_utils import to_multipart_form 9 | from .error import InvalidResponseError 10 | 11 | API_URL = 'https://tinycards.duolingo.com/api/1/' 12 | 13 | DEFAULT_HEADERS = { 14 | 'Accept': 'application/json, text/plain, */*', 15 | 'Referer': 'https://tinycards.duolingo.com/', 16 | 'User-Agent': ('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_4)' + 17 | ' AppleWebKit/537.36 (KHTML, like Gecko)' + 18 | ' Chrome/58.0.3029.94 Safari/537.36') 19 | } 20 | 21 | 22 | def _should_retry_login(exception): 23 | if isinstance(exception, InvalidResponseError) \ 24 | and 'Oops, something went wrong!' in str(exception): 25 | return True 26 | return False 27 | 28 | 29 | class RestApi(object): 30 | """Repository-like facade for the Tinycards API. 31 | 32 | Abstracts away all queries to the original Tinycards API and handles all 33 | JSON (un-)marshalling. 34 | """ 35 | 36 | def __init__(self, jwt=None): 37 | """Initialize a new instance of the RestApi class.""" 38 | # JSON web token 39 | self.jwt = jwt 40 | 41 | @retry(stop_max_attempt_number=5, wait_fixed=500, 42 | retry_on_exception=_should_retry_login) 43 | def login(self, 44 | identifier=None, 45 | password=None, 46 | silent=False): 47 | """Log in an user with its Tinycards or Duolingo credentials. 48 | 49 | Args: 50 | identifier (str): The Tinycards identifier to use for logging in. 51 | For example, a user's email address. 52 | Will be taken from ENV if not specified: 53 | .. envvar:: TINYCARDS_IDENTIFIER 54 | password (str): The user's password to login to Tinycards. 55 | Will be taken from ENV if not specified. 56 | .. envvar:: TINYCARDS_PASSWORD 57 | silent (bool): Does not output the 'Logged in as ...' message 58 | when set to True. Defaults to False. 59 | """ 60 | # Take credentials from ENV if not specified. 61 | identifier = identifier or os.environ.get('TINYCARDS_IDENTIFIER') 62 | password = password or os.environ.get('TINYCARDS_PASSWORD') 63 | 64 | request_payload = { 65 | 'identifier': identifier, 66 | 'password': password 67 | } 68 | r = requests.post(url=API_URL + 'login', json=request_payload) 69 | json_response = r.json() 70 | 71 | set_cookie_headers = { 72 | k: v for (k, v) in 73 | [c.split('=') for c in r.headers['set-cookie'].split('; ')] 74 | } 75 | self.jwt = set_cookie_headers.get('jwt_token') 76 | 77 | user_id = json_response.get('id') 78 | if user_id: 79 | if not silent: 80 | print("Logged in as '%s' (%s)" 81 | % (json_response['username'], json_response['email'])) 82 | else: 83 | raise InvalidResponseError("Error while trying to log in:\n%s" 84 | % json_response) 85 | 86 | return user_id 87 | 88 | # --- Read user info. 89 | 90 | def get_user_info(self, user_id): 91 | """Get info data about the given user.""" 92 | request_url = API_URL + 'users/' + str(user_id) 93 | r = requests.get(url=request_url) 94 | 95 | if r.status_code != 200: 96 | raise ValueError(r.text) 97 | 98 | json_response = r.json() 99 | user_info = json_converter.json_to_user(json_response) 100 | 101 | return user_info 102 | 103 | # --- Get trends. 104 | 105 | def get_trends(self, types=None, limit=10, page=0, from_language='en'): 106 | """Get Tinycards trends for the current user. 107 | 108 | Args: 109 | types (list): What entity to search for. Can be DECK, DECK_GROUP 110 | and/or USER. 111 | limit (int): What number of results to should be returned. 112 | page (int): The page to return when returning more than limit 113 | results (zero-indexed). 114 | from_language: The language used for learning. 115 | 116 | Returns: A list of Trendable objects. 117 | 118 | """ 119 | if not types: 120 | types = ['DECK', 'DECK_GROUP'] 121 | 122 | request_url = API_URL + 'trendables' 123 | params = {'types': ','.join(types), 124 | 'limit': limit, 125 | 'page': page, 126 | 'fromLanguage': from_language} 127 | r = requests.get(url=request_url, params=params, 128 | cookies={'jwt_token': self.jwt}) 129 | 130 | if r.status_code != 200: 131 | raise ValueError(r.text) 132 | 133 | json_response = r.json() 134 | json_trendables_list = json_response['trendables'] 135 | trendables = [json_converter.json_to_trendable(trendable) 136 | for trendable in json_trendables_list] 137 | 138 | return trendables 139 | 140 | # --- Subscriptions 141 | 142 | def subscribe(self, user_id): 143 | """Subscribe to the given user. 144 | 145 | Args: 146 | user_id: ID of the user to subscribe to. 147 | 148 | Returns: If successful, returns the ID of the user subscribed to. 149 | 150 | """ 151 | request_url = API_URL + 'users/' + str(user_id) + '/subscriptions' 152 | r = requests.post(url=request_url, cookies={'jwt_token': self.jwt}) 153 | 154 | json_response = r.json() 155 | added_subscription = json_response['addedSubscription'] 156 | 157 | return added_subscription 158 | 159 | def unsubscribe(self, user_id): 160 | """Unsubscribe the given user. 161 | 162 | Args: 163 | user_id: ID of the user to unsubscribe. 164 | 165 | Returns: If successful, returns the ID of the unsubscribed user. 166 | 167 | """ 168 | request_url = API_URL + 'users/' + str(user_id) + '/subscriptions' 169 | r = requests.delete(url=request_url, cookies={'jwt_token': self.jwt}) 170 | 171 | json_response = r.json() 172 | removed_subscription = json_response['removedSubscription'] 173 | 174 | return removed_subscription 175 | 176 | # --- Deck CRUD 177 | 178 | def get_decks(self, user_id, no_cards=False): 179 | """Get all Decks for the currently logged in user. 180 | 181 | Returns: 182 | list: The list of retrieved decks. 183 | 184 | """ 185 | request_url = API_URL + 'decks?userId=' + str(user_id) 186 | r = requests.get(request_url, cookies={'jwt_token': self.jwt}) 187 | 188 | if r.status_code != 200: 189 | raise ValueError(r.text) 190 | 191 | json_response = r.json() 192 | decks = [] 193 | for d in json_response['decks']: 194 | current_deck = json_converter.json_to_deck(d) 195 | decks.append(current_deck) 196 | 197 | if no_cards: 198 | return decks 199 | else: 200 | return [self.get_deck(d.id, user_id) for d in decks] 201 | 202 | def get_deck(self, deck_id, user_id, include_cards=True): 203 | """Get the Deck with the given ID. 204 | 205 | Args: 206 | deck_id (str): The ID of the deck to retrieve. 207 | include_cards (bool): Only include the cards of the deck when set 208 | to True (as by default). Otherwise cards will be an empty list. 209 | 210 | Returns: 211 | Deck: The retrieved deck. 212 | 213 | """ 214 | request_url = API_URL + 'decks/' + deck_id 215 | if include_cards: 216 | request_url += '?expand=true' 217 | r = requests.get(url=request_url, cookies={'jwt_token': self.jwt}) 218 | json_response = r.json() 219 | 220 | deck = json_converter.json_to_deck(json_response) 221 | # Set additional properties. 222 | deck.id = deck_id 223 | 224 | return deck 225 | 226 | def create_deck(self, deck): 227 | """Create a new Deck for the currently logged in user. 228 | 229 | Args: 230 | deck (Deck): The Deck object to create. 231 | 232 | Returns: 233 | Deck: The created Deck object if creation was successful. 234 | 235 | """ 236 | request_payload = json_converter.deck_to_json(deck) 237 | request_payload = to_multipart_form(request_payload) 238 | # Clone headers to not modify the global variable. 239 | headers = dict(DEFAULT_HEADERS) 240 | headers['Content-Type'] = request_payload.content_type 241 | r = requests.post(url=API_URL + 'decks', data=request_payload, 242 | headers=headers, cookies={'jwt_token': self.jwt}) 243 | 244 | json_data = r.json() 245 | created_deck = json_converter.json_to_deck(json_data) 246 | 247 | return created_deck 248 | 249 | def update_deck(self, deck, user_id): 250 | """Update an existing deck. 251 | 252 | Args: 253 | deck (Deck): The Deck object to update. 254 | 255 | Returns: 256 | Deck: The updated Deck object if update was successful. 257 | 258 | """ 259 | # Clone headers to not modify the global variable. 260 | headers = dict(DEFAULT_HEADERS) 261 | if deck.cover: 262 | # A new cover has been set on the deck, send the PATCH request as a 263 | # multipart-form: 264 | request_payload = json_converter.deck_to_json(deck) 265 | request_payload = to_multipart_form(request_payload) 266 | headers['Content-Type'] = request_payload.content_type 267 | else: 268 | # Otherwise, send the PATCH request as JSON: 269 | request_payload = json_converter.deck_to_json(deck, 270 | as_json_str=True) 271 | request_payload = json.dumps(request_payload) 272 | headers['Content-Type'] = 'application/json' 273 | 274 | r = requests.patch(url=API_URL + 'decks/' + deck.id, 275 | data=request_payload, headers=headers, 276 | cookies={'jwt_token': self.jwt}) 277 | 278 | if not r.ok: 279 | raise Exception('Failure while sending updates to server: %s' 280 | % r.text) 281 | 282 | # The response from the PATCH request does not contain cards. 283 | # Therefore, we have to query the updated deck with an extra request. 284 | updated_deck = self.get_deck(deck.id, user_id) 285 | 286 | return updated_deck 287 | 288 | def delete_deck(self, deck_id): 289 | """Delete an existing deck. 290 | 291 | Args: 292 | deck_id (str): The ID of the Deck to delete. 293 | 294 | Returns: 295 | Deck: The deleted Deck object if deletion was successful. 296 | 297 | """ 298 | if not isinstance(deck_id, str): 299 | raise ValueError("'deck_id' parameter must be of type str") 300 | 301 | headers = DEFAULT_HEADERS 302 | 303 | r = requests.delete(url=API_URL + 'decks/' + deck_id, headers=headers, 304 | cookies={'jwt_token': self.jwt}) 305 | 306 | json_data = r.json() 307 | deleted_deck = json_converter.json_to_deck(json_data) 308 | 309 | return deleted_deck 310 | 311 | # --- Favorites CR(U)D 312 | 313 | def get_favorites(self, user_id): 314 | """Get all favorites for the given user. 315 | 316 | Args: 317 | user_id (int): ID of the user to get favorites for. 318 | 319 | Returns: 320 | list: The list of favorites. 321 | 322 | """ 323 | request_url = API_URL + 'users/%d/favorites' % user_id 324 | r = requests.get(url=request_url, cookies={'jwt_token': self.jwt}) 325 | 326 | if r.status_code != 200: 327 | raise ValueError(r.text) 328 | 329 | json_response = r.json() 330 | json_favorite_decks = [fav for fav in json_response['favorites'] 331 | if 'deck' in fav] 332 | favorites = [] 333 | try: 334 | for fav in json_favorite_decks: 335 | current_favorite = json_converter.json_to_favorite(fav) 336 | favorites.append(current_favorite) 337 | except KeyError as ke: 338 | raise Exception("Unexpected JSON format:\n%s" % ke) 339 | 340 | return favorites 341 | 342 | def add_favorite(self, user_id, deck_id): 343 | """Add a deck to the current user's favorites. 344 | 345 | Args: 346 | user_id (int): ID of the user to favorite the deck for. 347 | deck_id: The ID of the deck to be added. 348 | 349 | Returns: 350 | Favorite: The added favorite. 351 | 352 | """ 353 | request_url = API_URL + 'users/%d/favorites' % user_id 354 | request_payload = {'deckId': deck_id} 355 | r = requests.post(url=request_url, json=request_payload, 356 | cookies={'jwt_token': self.jwt}) 357 | 358 | json_response = r.json() 359 | added_favorite = json_converter.json_to_favorite(json_response) 360 | 361 | return added_favorite 362 | 363 | def remove_favorite(self, user_id, favorite_id): 364 | """Add a deck to the current user's favorites. 365 | 366 | Args: 367 | user_id (int): ID of the user to favorite the deck for. 368 | favorite_id (str): The ID of the favorite to be removed. 369 | 370 | Returns: 371 | str: The ID of the removed favorite. 372 | 373 | """ 374 | request_url = API_URL + 'users/%d/favorites/%s' % (user_id, 375 | favorite_id) 376 | r = requests.delete(url=request_url, cookies={'jwt_token': self.jwt}) 377 | 378 | json_response = r.json() 379 | removed_favorite_id = json_response['removedFavoriteId'] 380 | 381 | return removed_favorite_id 382 | 383 | # --- Search 384 | 385 | def search(self, 386 | query, 387 | use_fuzzy_search=True, 388 | types=None, 389 | limit=10, 390 | page=0): 391 | """Searches for decks, deck groups, or users on Tinycards. 392 | 393 | Args: 394 | query (str): The used search term(s). 395 | use_fuzzy_search (bool): Whether or not to use fuzzy search. 396 | types (list): What entity to search for. Can be DECK, DECK_GROUP 397 | or USER. 398 | limit: Number of results to be returned. 399 | page: The page to return when more than `limit` results are 400 | available (zero-indexed). 401 | 402 | Returns: A list of Trendable objects. 403 | 404 | """ 405 | if not types: 406 | types = ['DECK', 'DECK_GROUP'] 407 | 408 | request_url = API_URL + 'searchables' 409 | params = {'query': query, 410 | 'useFuzzySearch': use_fuzzy_search, 411 | 'types': ','.join(types), 412 | 'limit': limit, 413 | 'page': page} 414 | r = requests.get(url=request_url, params=params, 415 | cookies={'jwt_token': self.jwt}) 416 | 417 | if r.status_code != 200: 418 | raise ValueError(r.text) 419 | 420 | json_response = r.json() 421 | json_searchables_list = json_response['searchables'] 422 | searchables = [json_converter.json_to_searchable(searchable) 423 | for searchable in json_searchables_list] 424 | 425 | return searchables 426 | --------------------------------------------------------------------------------