├── .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 | [](https://travis-ci.org/floscha/tinycards-python-api)
7 | [](https://coveralls.io/github/floscha/tinycards-python-api?branch=master)
8 | [](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 | [](https://pypi.python.org/pypi/tinycards)
10 | [](https://pypi.python.org/pypi/tinycards)
11 | [](https://pypi.python.org/pypi/tinycards)
12 | [](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 |
--------------------------------------------------------------------------------