├── .github ├── ISSUE_TEMPLATE │ ├── bug_report_md │ ├── config.yml │ └── feature_request.md ├── dependabot.yml ├── settings.yml └── workflows │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .releaserc.yml ├── LICENSE ├── Makefile ├── README.md ├── docs ├── advanced-usage.md ├── index.md ├── models.md ├── readwise-api.md └── readwise-reader-api.md ├── mkdocs.yaml ├── readwise ├── __init__.py ├── api.py └── models.py ├── renovate.json ├── requirements-dev.txt ├── requirements-docs.txt ├── requirements.txt ├── setup.py └── tests └── unit └── test_api.py /.github/ISSUE_TEMPLATE/bug_report_md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F41B Bug Report" 3 | about: "If something isn't working as expected \U0001F914." 4 | title: '' 5 | labels: bug 6 | 7 | --- 8 | 9 | 14 | 15 | 16 | ### Used Release 17 | 18 | 19 | ### Debug Output 20 | 21 | 22 | ### Panic Output 23 | 24 | 25 | ### Expected Behavior 26 | What should have happened? 27 | 28 | ### Actual Behavior 29 | What actually happened? 30 | 31 | ### Steps to Reproduce 32 | 33 | 34 | ### Important Factoids 35 | 36 | 37 | ### References 38 | 41 | 42 | ### Community Note 43 | 44 | * Please vote on this issue by adding a 👍 [reaction](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) to the original issue to help the community and maintainers prioritize this request 45 | * If you are interested in working on this issue or have submitted a pull request, please leave a comment 46 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | blank_issues_enabled: false 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F680 Feature Request" 3 | about: "I have a suggestion (and might want to implement myself \U0001F642)!" 4 | title: '' 5 | labels: enhancement 6 | 7 | --- 8 | 9 | 10 | ### Description 11 | 12 | 13 | 14 | ### Potential Configuration 15 | 16 | 17 | 18 | ```hcl 19 | # Copy-paste your configurations here. 20 | ``` 21 | 22 | ### References 23 | 24 | 29 | 30 | 31 | 32 | ### Community Note 33 | 34 | * Please vote on this issue by adding a 👍 [reaction](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) to the original issue to help the community and maintainers prioritize this request 35 | * If you are interested in working on this issue or have submitted a pull request, please leave a comment 36 | 37 | 38 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | 8 | - package-ecosystem: "pip" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | 13 | - package-ecosystem: "gitsubmodule" 14 | directory: "/" 15 | schedule: 16 | interval: "daily" 17 | 18 | - package-ecosystem: "docker" 19 | directory: "/" 20 | schedule: 21 | interval: "daily" 22 | -------------------------------------------------------------------------------- /.github/settings.yml: -------------------------------------------------------------------------------- 1 | repository: 2 | name: "pyreadwise" 3 | description: "Python Module to use the Readwise API" 4 | homepage: "https://rwxd.github.io/pyreadwise/" 5 | topics: "python, readwise" 6 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | on: 3 | push: 4 | branches: 5 | - "*" 6 | tags: 7 | - "v*.*.*" 8 | 9 | jobs: 10 | pre-commit: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-python@v4 15 | with: 16 | python-version: '3.11' 17 | 18 | - name: Debug 19 | run: pwd && ls -la 20 | 21 | - name: Setup 22 | run: make setup 23 | 24 | - name: Run pre-commit 25 | run: pre-commit run --show-diff-on-failure --all-files 26 | env: 27 | SKIP: "no-commit-to-branch" 28 | 29 | test: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@v3 33 | - uses: actions/setup-python@v4 34 | with: 35 | python-version: '3.11' 36 | 37 | - name: Debug 38 | run: pwd && ls -la 39 | 40 | - name: Setup 41 | run: make setup 42 | 43 | - name: Run pytest 44 | run: make unit 45 | 46 | pypi: 47 | runs-on: ubuntu-latest 48 | needs: 49 | - test 50 | - pre-commit 51 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 52 | steps: 53 | - uses: actions/checkout@v3 54 | - uses: actions/setup-python@v4 55 | with: 56 | python-version: '3.11' 57 | 58 | - name: Install dependencies 59 | run: pip install wheel 60 | 61 | - name: Build package 62 | run: python setup.py sdist bdist_wheel 63 | 64 | - name: Publish package 65 | uses: pypa/gh-action-pypi-publish@release/v1 66 | with: 67 | password: ${{ secrets.PYPI_API_TOKEN }} 68 | 69 | docs: 70 | runs-on: ubuntu-latest 71 | needs: 72 | - test 73 | - pre-commit 74 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 75 | steps: 76 | - uses: actions/checkout@v3 77 | 78 | - name: Setup 79 | run: make setup 80 | 81 | - name: build docs 82 | run: make build-docs 83 | 84 | - name: Deploy 🚀 85 | uses: JamesIves/github-pages-deploy-action@v4.6.4 86 | with: 87 | branch: pages 88 | folder: ./site 89 | token: ${{ secrets.GH_TOKEN }} 90 | 91 | semantic-release: 92 | uses: rwxd/gh-templates/.github/workflows/common-semantic-release.yml@main 93 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 94 | needs: 95 | - docs 96 | - test 97 | - pre-commit 98 | secrets: 99 | token: ${{ secrets.GH_TOKEN }} 100 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | test.py 2 | site/ 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | cover/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | .pybuilder/ 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | # For a library or package, you might want to ignore these files since the code is 90 | # intended to run in multiple environments; otherwise, check them in: 91 | # .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # poetry 101 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 102 | # This is especially recommended for binary packages to ensure reproducibility, and is more 103 | # commonly ignored for libraries. 104 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 105 | #poetry.lock 106 | 107 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 108 | __pypackages__/ 109 | 110 | # Celery stuff 111 | celerybeat-schedule 112 | celerybeat.pid 113 | 114 | # SageMath parsed files 115 | *.sage.py 116 | 117 | # Environments 118 | .env 119 | .venv 120 | env/ 121 | venv/ 122 | ENV/ 123 | env.bak/ 124 | venv.bak/ 125 | 126 | # Spyder project settings 127 | .spyderproject 128 | .spyproject 129 | 130 | # Rope project settings 131 | .ropeproject 132 | 133 | # mkdocs documentation 134 | /site 135 | 136 | # mypy 137 | .mypy_cache/ 138 | .dmypy.json 139 | dmypy.json 140 | 141 | # Pyre type checker 142 | .pyre/ 143 | 144 | # pytype static type analyzer 145 | .pytype/ 146 | 147 | # Cython debug symbols 148 | cython_debug/ 149 | 150 | # PyCharm 151 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can 152 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 153 | # and can be added to the global gitignore or merged into this file. For a more nuclear 154 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 155 | #.idea/ 156 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: "v4.4.0" 5 | hooks: 6 | - id: check-yaml 7 | - id: check-json 8 | - id: end-of-file-fixer 9 | - id: trailing-whitespace 10 | - id: check-added-large-files 11 | - id: check-symlinks 12 | - id: no-commit-to-branch 13 | - id: trailing-whitespace 14 | - id: debug-statements 15 | - id: requirements-txt-fixer 16 | 17 | - repo: https://github.com/psf/black 18 | rev: "23.1.0" 19 | hooks: 20 | - id: black 21 | args: 22 | - "--skip-string-normalization" 23 | 24 | - repo: https://github.com/asottile/pyupgrade 25 | rev: "v3.3.1" 26 | hooks: 27 | - id: pyupgrade 28 | 29 | - repo: https://github.com/pre-commit/mirrors-mypy 30 | rev: "v1.0.1" 31 | hooks: 32 | - id: mypy 33 | args: 34 | - "--ignore-missing-imports" 35 | additional_dependencies: 36 | - types-requests 37 | - types-pyyaml 38 | 39 | - repo: https://github.com/PyCQA/flake8 40 | rev: 6.0.0 41 | hooks: 42 | - id: flake8 43 | args: 44 | - "--max-line-length=100" 45 | 46 | - repo: https://github.com/pycqa/isort 47 | rev: 5.12.0 48 | hooks: 49 | - id: isort 50 | name: isort (python) 51 | args: 52 | - "--profile" 53 | - "black" 54 | - "--filter-files" 55 | 56 | - repo: https://github.com/PyCQA/autoflake 57 | rev: v2.0.1 58 | hooks: 59 | - id: autoflake 60 | args: 61 | - "--remove-all-unused-imports" 62 | - "--ignore-init-module-imports" 63 | -------------------------------------------------------------------------------- /.releaserc.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | - "@semantic-release/commit-analyzer" 3 | - "@semantic-release/release-notes-generator" 4 | - "@semantic-release/github" 5 | 6 | branches: 7 | - "main" 8 | - "+([0-9])?(.{+([0-9]),x}).x" 9 | - name: "alpha" 10 | prerelease: "alpha" 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023, rwxd 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PROJECT_NAME := "wallabag2readwise" 2 | 3 | help: 4 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 5 | 6 | setup: ## Setup required things 7 | python3 -m pip install -U -r requirements.txt 8 | python3 -m pip install -U -r requirements-dev.txt 9 | python3 -m pip install -U -r requirements-docs.txt 10 | pre-commit install 11 | 12 | serve-docs: ## Serve the documentation locally 13 | mkdocs serve 14 | 15 | build-docs: ## Build the documentation 16 | mkdocs build 17 | 18 | unit: ## Run unit tests 19 | python3 -m pytest -vvl tests/unit 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | docs/index.md -------------------------------------------------------------------------------- /docs/advanced-usage.md: -------------------------------------------------------------------------------- 1 | # Advanced Usage 2 | 3 | ## Custom request 4 | 5 | You can use the `get`, `post`, `delete` or `request` methods to make a custom request. 6 | 7 | ```python 8 | from readwise import Readwise 9 | 10 | client = Readwise('token') 11 | response = client.get('/books/', params={'category': 'articles'}) 12 | response.raise_for_status() 13 | print(response.json()) 14 | ``` 15 | 16 | Custom http method 17 | 18 | ```python 19 | from readwise import Readwise 20 | 21 | client = Readwise('token') 22 | response = client.request('get', '/books/', params={'category': 'articles'}) 23 | response.raise_for_status() 24 | print(response.json()) 25 | ``` 26 | 27 | ## Pagination 28 | 29 | A helper method for pagination is available. 30 | 31 | ```python 32 | from readwise import Readwise 33 | 34 | client = Readwise('token') 35 | for response in client.get_pagination('/books/', params={'category': 'articles'}): 36 | response.raise_for_status() 37 | for book in response.json()['results']: 38 | print(book) 39 | ``` 40 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Python Module to use the Readwise API 2 | 3 | This module is a wrapper for the Readwise API. 4 | 5 | It allows you to easily access your Readwise data in Python. 6 | 7 | ## Installation 8 | 9 | ```bash 10 | pip install -U readwise 11 | ``` 12 | 13 | ## How to use 14 | 15 | ### Readwise API 16 | 17 | ```python 18 | from readwise import Readwise 19 | 20 | client = Readwise('token') 21 | 22 | books = client.get_books(category='articles') 23 | 24 | for book in books: 25 | highlights = client.get_book_highlights(book.id) 26 | if len(highlights) > 0: 27 | print(book.title) 28 | for highlight in highlights: 29 | print(highlight.text) 30 | ``` 31 | 32 | ### Readwise Readwise API 33 | 34 | ```python 35 | from readwise import ReadwiseReader 36 | 37 | client = ReadwiseReader('token') 38 | 39 | response = client.create_document('https://www.example.com') 40 | response.raise_for_status() 41 | ``` 42 | 43 | ## Documentation 44 | 45 | The latest documentation can be found at 46 | 47 | If you've checked out the source code (for example to review a PR), you can build the latest documentation by running `make serve-docs`. 48 | -------------------------------------------------------------------------------- /docs/models.md: -------------------------------------------------------------------------------- 1 | # Models 2 | 3 | ::: readwise.models 4 | -------------------------------------------------------------------------------- /docs/readwise-api.md: -------------------------------------------------------------------------------- 1 | # Readwise API 2 | 3 | ::: readwise.api.Readwise 4 | options: 5 | show_if_no_docstring: true 6 | -------------------------------------------------------------------------------- /docs/readwise-reader-api.md: -------------------------------------------------------------------------------- 1 | # Readwise Reader API 2 | 3 | ::: readwise.api.ReadwiseReader 4 | options: 5 | show_if_no_docstring: true 6 | -------------------------------------------------------------------------------- /mkdocs.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | site_name: pyreadwise 3 | site_description: Python module to use the readwise api 4 | site_author: rwxd 5 | site_url: https://rwxd.github.io/pyreadwise 6 | dev_addr: 0.0.0.0:8000 7 | 8 | repo_name: "pyreadwise" 9 | repo_url: "https://github.com/rwxd/pyreadwise" 10 | 11 | docs_dir: "./docs" 12 | edit_uri: "edit/source/docs/content/" 13 | 14 | # https://squidfunk.github.io/mkdocs-material/ 15 | theme: 16 | name: "material" 17 | palette: 18 | - scheme: slate 19 | primary: teal 20 | accent: teal 21 | toggle: 22 | icon: material/toggle-switch 23 | name: Switch to light mode 24 | - scheme: default 25 | primary: teal 26 | accent: teal 27 | toggle: 28 | icon: material/toggle-switch-off-outline 29 | name: Switch to dark mode 30 | icon: 31 | logo: material/code-braces-box 32 | # logo: material/pine-tree 33 | # favicon: assets/page/favicon-32x32.png 34 | language: en 35 | include_sidebar: true 36 | features: 37 | - content.code.annotate 38 | - navigation.tabs 39 | - toc.integrate 40 | feature: 41 | tabs: false 42 | i18n: 43 | prev: "Previous" 44 | next: "Next" 45 | font: 46 | text: Inter 47 | code: Fira Code 48 | 49 | copyright: "Copyright © 2023 rwxd" 50 | 51 | plugins: 52 | - search 53 | - material-plausible 54 | - mkdocstrings: 55 | handlers: 56 | python: 57 | import: 58 | - https://docs.python-requests.org/en/master/objects.inv 59 | options: 60 | docstring_style: google 61 | docstring_section_style: "table" 62 | merge_init_into_class: True 63 | line_length: 100 64 | members_order: 'source' 65 | 66 | show_signature_annotations: True 67 | separate_signature: True 68 | 69 | markdown_extensions: 70 | - pymdownx.highlight: 71 | linenums: true 72 | linenums_style: pymdownx-inline 73 | guess_lang: true 74 | - pymdownx.superfences 75 | - pymdownx.inlinehilite 76 | - pymdownx.keys 77 | - pymdownx.snippets 78 | - pymdownx.tabbed: 79 | alternate_style: true 80 | - toc: 81 | permalink: "⚑" 82 | 83 | nav: 84 | - "Home": 85 | - "Quickstart": 'index.md' 86 | - "Advanced Usage": "advanced-usage.md" 87 | - "Readwise API": readwise-api.md 88 | - "Readwise Reader API": readwise-reader-api.md 89 | - "Models": models.md 90 | 91 | extra: 92 | social: 93 | - icon: fontawesome/brands/github 94 | link: https://github.com/rwxd 95 | analytics: 96 | provider: plausible 97 | domain: rwxd.github.io/pyreadwise 98 | src: "https://plausible.chaops.de/js/script.js" 99 | -------------------------------------------------------------------------------- /readwise/__init__.py: -------------------------------------------------------------------------------- 1 | from readwise.api import Readwise, ReadwiseReader 2 | 3 | __all__ = [ 4 | "Readwise", 5 | "ReadwiseReader", 6 | ] 7 | -------------------------------------------------------------------------------- /readwise/api.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | from time import sleep 4 | from typing import Any, Generator, Literal 5 | 6 | import requests 7 | from requests.models import ChunkedEncodingError 8 | 9 | from readwise.models import ( 10 | ReadwiseBook, 11 | ReadwiseHighlight, 12 | ReadwiseReaderDocument, 13 | ReadwiseTag, 14 | ) 15 | 16 | 17 | class ReadwiseRateLimitException(Exception): 18 | """Raised when the Readwise API rate limit is exceeded.""" 19 | 20 | pass 21 | 22 | 23 | class Readwise: 24 | def __init__( 25 | self, 26 | token: str, 27 | ): 28 | """ 29 | Initialize a Readwise API client. 30 | 31 | Documentation for the Readwise API can be found here: 32 | https://readwise.io/api_deets 33 | 34 | Args: 35 | token: Readwise API token 36 | """ 37 | self._token = token 38 | self._url = "https://readwise.io/api/v2" 39 | 40 | @property 41 | def _session(self) -> requests.Session: 42 | """ 43 | Return a requests.Session object with the Readwise API token set as an 44 | Authorization header. 45 | """ 46 | session = requests.Session() 47 | session.headers.update( 48 | { 49 | "Accept": "application/json", 50 | "Authorization": f"Token {self._token}", 51 | } 52 | ) 53 | return session 54 | 55 | def _request( 56 | self, method: str, endpoint: str, params: dict = {}, data: dict = {} 57 | ) -> requests.Response: 58 | """ 59 | Make a request to the Readwise API. 60 | 61 | The Readwise API has a rate limit of 240 requests per minute. This 62 | method will raise a ReadwiseRateLimitException if the rate limit is 63 | exceeded. 64 | 65 | The Exception will be raised after 8 retries with exponential backoff. 66 | 67 | Args: 68 | method: HTTP method 69 | endpoint: API endpoint 70 | params: Query parameters 71 | data: Request body 72 | 73 | Returns: 74 | requests.Response 75 | """ 76 | url = self._url + endpoint 77 | logging.debug(f'Calling "{method}" on "{url}" with params: {params}') 78 | response = self._session.request(method, url, params=params, json=data) 79 | while response.status_code == 429: 80 | seconds = int(response.headers["Retry-After"]) 81 | logging.warning(f"Rate limited by Readwise, retrying in {seconds} seconds") 82 | sleep(seconds) 83 | response = self._session.request(method, url, params=params, data=data) 84 | response.raise_for_status() 85 | return response 86 | 87 | def get(self, endpoint: str, params: dict = {}) -> requests.Response: 88 | """ 89 | Make a GET request to the Readwise API. 90 | 91 | Examples: 92 | >>> client.get('/highlights/') 93 | 94 | Args: 95 | endpoint: API endpoint 96 | params: Query parameters 97 | 98 | Returns: 99 | requests.Response 100 | """ 101 | logging.debug(f'Getting "{endpoint}" with params: {params}') 102 | return self._request("GET", endpoint, params=params) 103 | 104 | def get_with_limit_20(self, endpoint: str, params: dict = {}) -> requests.Response: 105 | """ 106 | Get a response from the Readwise API with a rate limit of 20 requests 107 | per minute. 108 | 109 | The rate limit of 20 requests per minute needs to be used at the 110 | endpoints /highlights/ and /books/ because they return a lot of data. 111 | 112 | Args: 113 | endpoint: API endpoint 114 | params: Query parameters 115 | Returns: 116 | requests.Response 117 | """ 118 | return self.get(endpoint, params) 119 | 120 | def post(self, endpoint: str, data: dict = {}) -> requests.Response: 121 | """ 122 | Make a POST request to the Readwise API. 123 | 124 | Examples: 125 | >>> client.post('/highlights/', {'highlights': [{'text': 'foo'}]}) 126 | 127 | Args: 128 | endpoint: API endpoint 129 | data: Request body 130 | 131 | Returns: 132 | requests.Response 133 | """ 134 | url = self._url + endpoint 135 | logging.debug(f'Posting "{url}" with data: {data}') 136 | response = self._request("POST", endpoint, data=data) 137 | response.raise_for_status() 138 | return response 139 | 140 | def delete(self, endpoint: str) -> requests.Response: 141 | """ 142 | Make a DELETE request to the Readwise API. 143 | 144 | Examples: 145 | >>> client.delete('/highlights/1234') 146 | 147 | Args: 148 | endpoint: API endpoint 149 | 150 | Returns: 151 | requests.Response 152 | """ 153 | logging.debug(f'Deleting "{endpoint}"') 154 | return self._request("DELETE", endpoint) 155 | 156 | def get_pagination( 157 | self, endpoint: str, params: dict = {} 158 | ) -> Generator[dict, None, None]: 159 | """ 160 | Get a response from the Readwise API with pagination. 161 | 162 | Args: 163 | endpoint: API endpoint 164 | params: Query parameters 165 | Yields: 166 | Response data 167 | """ 168 | yield from self._get_pagination("get", endpoint, params) 169 | 170 | def get_pagination_limit_20( 171 | self, endpoint: str, params: dict = {}, page_size: int = 1000 172 | ) -> Generator[dict, None, None]: 173 | """ 174 | Get a response from the Readwise API with pagination and a rate limit 175 | of 20 requests per minute. 176 | 177 | Args: 178 | endpoint: API endpoint 179 | params: Query parameters 180 | page_size: Number of items per page 181 | Yields: 182 | Response data 183 | """ 184 | yield from self._get_pagination( 185 | "get_with_limit_20", endpoint, params, page_size 186 | ) 187 | 188 | def _get_pagination( 189 | self, 190 | get_method: Literal["get", "get_with_limit_20"], 191 | endpoint: str, 192 | params: dict = {}, 193 | page_size: int = 1000, 194 | ) -> Generator[dict, None, None]: 195 | """ 196 | Get a response from the Readwise API with pagination. 197 | 198 | Args: 199 | get_method: Method to use for making requests 200 | endpoint: API endpoint 201 | params: Query parameters 202 | page_size: Number of items per page 203 | Yields: 204 | dict: Response data 205 | """ 206 | page = 1 207 | while True: 208 | response = getattr(self, get_method)( 209 | endpoint, params={"page": page, "page_size": page_size, **params} 210 | ) 211 | data = response.json() 212 | yield data 213 | if type(data) == list or not data.get("next"): 214 | break 215 | page += 1 216 | 217 | def get_books( 218 | self, category: Literal["articles", "books", "tweets", "podcasts"] 219 | ) -> Generator[ReadwiseBook, None, None]: 220 | """ 221 | Get all Readwise books. 222 | 223 | Args: 224 | category: Book category 225 | 226 | Returns: 227 | A generator of ReadwiseBook objects 228 | """ 229 | for data in self.get_pagination_limit_20( 230 | "/books/", params={"category": category} 231 | ): 232 | for book in data["results"]: 233 | yield ReadwiseBook( 234 | id=book["id"], 235 | title=book["title"], 236 | author=book["author"], 237 | category=book["category"], 238 | source=book["source"], 239 | num_highlights=book["num_highlights"], 240 | last_highlight_at=datetime.fromisoformat(book["last_highlight_at"]) 241 | if book["last_highlight_at"] 242 | else None, 243 | updated=datetime.fromisoformat(book["updated"]) 244 | if book["updated"] 245 | else None, 246 | cover_image_url=book["cover_image_url"], 247 | highlights_url=book["highlights_url"], 248 | source_url=book["source_url"], 249 | asin=book["asin"], 250 | tags=[ 251 | ReadwiseTag(id=tag["id"], name=tag["name"]) 252 | for tag in book["tags"] 253 | ], 254 | document_note=book["document_note"], 255 | ) 256 | 257 | def get_book_highlights( 258 | self, book_id: str 259 | ) -> Generator[ReadwiseHighlight, None, None]: 260 | """ 261 | Get all highlights for a Readwise book. 262 | 263 | Args: 264 | book_id: Readwise book ID 265 | 266 | Returns: 267 | A generator of ReadwiseHighlight objects 268 | """ 269 | for data in self.get_pagination_limit_20( 270 | "/highlights/", params={"book_id": book_id} 271 | ): 272 | for highlight in data["results"]: 273 | yield ReadwiseHighlight( 274 | id=highlight["id"], 275 | text=highlight["text"], 276 | note=highlight["note"], 277 | location=highlight["location"], 278 | location_type=highlight["location_type"], 279 | url=highlight["url"], 280 | color=highlight["color"], 281 | updated=datetime.fromisoformat(highlight["updated"]) 282 | if highlight["updated"] 283 | else None, 284 | book_id=highlight["book_id"], 285 | tags=[ 286 | ReadwiseTag(id=tag["id"], name=tag["name"]) 287 | for tag in highlight["tags"] 288 | ], 289 | ) 290 | 291 | def create_highlight( 292 | self, 293 | text: str, 294 | title: str, 295 | author: str | None = None, 296 | highlighted_at: datetime | None = None, 297 | source_url: str | None = None, 298 | category: str = "articles", 299 | note: str | None = None, 300 | ): 301 | """ 302 | Create a Readwise highlight. 303 | 304 | Args: 305 | text: Highlight text 306 | title: Book title 307 | author: Book author 308 | highlighted_at: Date and time the highlight was created 309 | source_url: URL of the book 310 | category: Book category 311 | note: Highlight note 312 | """ 313 | payload = {"text": text, "title": title, "category": category} 314 | if author: 315 | payload["author"] = author 316 | if highlighted_at: 317 | payload["highlighted_at"] = highlighted_at.isoformat() 318 | if source_url: 319 | payload["source_url"] = source_url 320 | if note: 321 | payload["note"] = note 322 | 323 | self.post("/highlights/", {"highlights": [payload]}) 324 | 325 | def get_book_tags(self, book_id: str) -> Generator[ReadwiseTag, None, None]: 326 | """ 327 | Get all tags for a Readwise book. 328 | 329 | Args: 330 | book_id: Readwise book ID 331 | 332 | Returns: 333 | A generator of ReadwiseTag objects 334 | """ 335 | for data in self.get_pagination_limit_20( 336 | f"/books/{book_id}/tags/", params={"book_id": book_id} 337 | ): 338 | for tag in data: 339 | yield ReadwiseTag(id=tag["id"], name=tag["name"]) 340 | 341 | def add_tag(self, book_id: str, tag: str): 342 | """ 343 | Add a tag to a Readwise book. 344 | 345 | Args: 346 | book_id: Readwise book ID 347 | tag: Tag name 348 | 349 | Returns: 350 | requests.Response 351 | """ 352 | logging.debug(f'Adding tag "{tag}" to book "{book_id}"') 353 | payload = {"name": tag} 354 | self.post(f"/books/{book_id}/tags/", payload) 355 | 356 | def delete_tag(self, book_id: str, tag_id: str): 357 | """ 358 | Delete a tag from a Readwise book. 359 | 360 | Args: 361 | book_id: Readwise book ID 362 | 363 | Returns: 364 | requests.Response 365 | """ 366 | logging.debug(f'Deleting tag "{tag_id}"') 367 | self.delete(f"/books/{book_id}/tags/{tag_id}") 368 | 369 | 370 | class ReadwiseReader: 371 | def __init__( 372 | self, 373 | token: str, 374 | ): 375 | """ 376 | Readwise Reader API client. 377 | 378 | Documentation for the Readwise Reader API can be found here: 379 | https://readwise.io/reader_api 380 | 381 | Args: 382 | token: Readwise Reader Connector token 383 | """ 384 | self._token = token 385 | self._url = "https://readwise.io/api/v3" 386 | 387 | @property 388 | def _session(self) -> requests.Session: 389 | """ 390 | Session object for making requests. 391 | The headers are set to include the token. 392 | """ 393 | session = requests.Session() 394 | session.headers.update( 395 | { 396 | "Accept": "application/json", 397 | "Authorization": f"Token {self._token}", 398 | } 399 | ) 400 | return session 401 | 402 | def _request( 403 | self, method: str, endpoint: str, params: dict = {}, data: dict = {} 404 | ) -> requests.Response: 405 | """ 406 | Make a request to the Readwise Reader API. 407 | The request is rate limited to 20 calls per minute. 408 | 409 | Args: 410 | method: HTTP method 411 | endpoint: API endpoints 412 | params: Query parameters 413 | data: Request body 414 | 415 | Returns: 416 | requests.Response 417 | """ 418 | url = self._url + endpoint 419 | logging.debug(f'Calling "{method}" on "{url}" with params: {params}') 420 | response = self._session.request(method, url, params=params, json=data) 421 | while response.status_code == 429: 422 | seconds = int(response.headers["Retry-After"]) 423 | logging.warning(f"Rate limited by Readwise, retrying in {seconds} seconds") 424 | sleep(seconds) 425 | response = self._session.request(method, url, params=params, data=data) 426 | response.raise_for_status() 427 | return response 428 | 429 | def get(self, endpoint: str, params: dict = {}) -> requests.Response: 430 | """ 431 | Make a GET request to the Readwise Reader API client. 432 | 433 | Args: 434 | endpoint: API endpoints 435 | params: Query parameters 436 | 437 | Returns: 438 | requests.Response 439 | """ 440 | logging.debug(f'Getting "{endpoint}" with params: {params}') 441 | return self._request("GET", endpoint, params=params) 442 | 443 | def get_with_limit_20(self, endpoint: str, params: dict = {}) -> requests.Response: 444 | """ 445 | Get a response from the Readwise Reader API with a rate limit of 20 requests 446 | per minute. 447 | 448 | Args: 449 | endpoint: API endpoint 450 | params: Query parameters 451 | Returns: 452 | requests.Response 453 | """ 454 | return self.get(endpoint, params) 455 | 456 | def _get_pagination( 457 | self, 458 | get_method: Literal["get", "get_with_limit_20"], 459 | endpoint: str, 460 | params: dict = {}, 461 | ) -> Generator[dict, None, None]: 462 | """ 463 | Get a response from the Readwise Reader API with pagination. 464 | 465 | Args: 466 | get_method: Method to use for making requests 467 | endpoint: API endpoint 468 | params: Query parameters 469 | page_size: Number of items per page 470 | Yields: 471 | dict: Response data 472 | """ 473 | pageCursor = None 474 | while True: 475 | if pageCursor: 476 | params.update({"pageCursor": pageCursor}) 477 | logging.debug(f'Getting page with cursor "{pageCursor}"') 478 | try: 479 | response = getattr(self, get_method)(endpoint, params=params) 480 | except ChunkedEncodingError: 481 | logging.error(f'Error getting page with cursor "{pageCursor}"') 482 | sleep(5) 483 | continue 484 | data = response.json() 485 | yield data 486 | if ( 487 | type(data) == list 488 | or not data.get("nextPageCursor") 489 | or data.get("nextPageCursor") == pageCursor 490 | ): 491 | break 492 | pageCursor = data.get("nextPageCursor") 493 | 494 | def get_pagination_limit_20( 495 | self, endpoint: str, params: dict = {} 496 | ) -> Generator[dict, None, None]: 497 | """ 498 | Get a response from the Readwise Reader API with pagination and a rate limit 499 | of 20 requests per minute. 500 | 501 | Args: 502 | endpoint: API endpoint 503 | params: Query parameters 504 | page_size: Number of items per page 505 | Yields: 506 | Response data 507 | """ 508 | yield from self._get_pagination("get_with_limit_20", endpoint, params) 509 | 510 | def post(self, endpoint: str, data: dict = {}) -> requests.Response: 511 | """ 512 | Make a POST request to the Readwise Reader API. 513 | 514 | Args: 515 | endpoint: API endpoints 516 | data: Request body 517 | 518 | Returns: 519 | requests.Response 520 | """ 521 | url = self._url + endpoint 522 | logging.debug(f'Posting "{url}" with data: {data}') 523 | response = self._request("POST", endpoint, data=data) 524 | response.raise_for_status() 525 | return response 526 | 527 | def create_document( 528 | self, 529 | url: str, 530 | html: str | None = None, 531 | should_clean_html: bool | None = None, 532 | title: str | None = None, 533 | author: str | None = None, 534 | summary: str | None = None, 535 | published_at: datetime | None = None, 536 | image_url: str | None = None, 537 | location: Literal["new", "later", "archive", "feed"] = "new", 538 | saved_using: str | None = None, 539 | tags: list[str] = [], 540 | ) -> requests.Response: 541 | """ 542 | Create a document in Readwise Reader. 543 | 544 | Args: 545 | url: Document URL 546 | html: Document HTML 547 | should_clean_html: Whether to clean the HTML 548 | title: Document title 549 | author: Document author 550 | summary: Document summary 551 | published_at: Date and time the document was published 552 | image_url: An image URL to use as cover image 553 | location: Document location 554 | saved_using: How the document was saved 555 | tags: List of tags 556 | 557 | Returns: 558 | requests.Response 559 | """ 560 | data: dict[str, Any] = { 561 | "url": url, 562 | "tags": tags, 563 | "location": location, 564 | } 565 | 566 | if html: 567 | data["html"] = html 568 | 569 | if should_clean_html is not None: 570 | data["should_clean_html"] = should_clean_html 571 | 572 | if title: 573 | data["title"] = title 574 | 575 | if author: 576 | data["author"] = author 577 | 578 | if summary: 579 | data["summary"] = summary 580 | 581 | if published_at: 582 | data["published_at"] = published_at.isoformat() 583 | 584 | if image_url: 585 | data["image_url"] = image_url 586 | 587 | if saved_using: 588 | data["saved_using"] = saved_using 589 | 590 | return self.post("/save/", data) 591 | 592 | def get_documents( 593 | self, params: dict = {} 594 | ) -> Generator[ReadwiseReaderDocument, None, None]: 595 | for data in self.get_pagination_limit_20("/list/", params=params): 596 | for document in data["results"]: 597 | yield ReadwiseReaderDocument( 598 | id=document["id"], 599 | url=document["url"], 600 | source_url=document["source_url"], 601 | title=document["title"], 602 | author=document["author"], 603 | source=document["source"], 604 | category=document["category"], 605 | location=document["location"], 606 | tags=document["tags"], 607 | site_name=document["site_name"], 608 | word_count=document["word_count"], 609 | created_at=datetime.fromisoformat(document["created_at"]), 610 | updated_at=datetime.fromisoformat(document["updated_at"]), 611 | notes=document["notes"], 612 | published_date=document["published_date"], 613 | summary=document["summary"], 614 | image_url=document["image_url"], 615 | parent_id=document["parent_id"], 616 | reading_progress=document["reading_progress"], 617 | ) 618 | -------------------------------------------------------------------------------- /readwise/models.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import datetime 3 | from typing import Any, Optional 4 | 5 | 6 | @dataclass 7 | class ReadwiseTag: 8 | """Represents a Readwise tag. 9 | 10 | Attributes: 11 | id: The tag's ID. 12 | name: The tag's name. 13 | """ 14 | 15 | id: str 16 | name: str 17 | 18 | 19 | @dataclass 20 | class ReadwiseBook: 21 | """ 22 | Represents a Readwise book. 23 | 24 | Attributes: 25 | id: The book's ID. 26 | title: The book's title. 27 | author: The book's author. 28 | category: The book's category. 29 | source: The book's source. 30 | num_highlights: The number of highlights for the book. 31 | last_highlight_at: The date and time of the last highlight for the book. 32 | updated: The date and time the book was last updated. 33 | cover_image_url: The URL of the book's cover image. 34 | highlights_url: The URL of the book's highlights. 35 | source_url: The URL of the book's source. 36 | asin: The book's ASIN. 37 | tags: The book's tags. 38 | document_note: The book's document note. 39 | """ 40 | 41 | id: str 42 | title: str 43 | author: str 44 | category: str 45 | source: str 46 | num_highlights: int 47 | last_highlight_at: datetime | None 48 | updated: datetime | None 49 | cover_image_url: str 50 | highlights_url: str 51 | source_url: str 52 | asin: str 53 | tags: list[ReadwiseTag] 54 | document_note: str 55 | 56 | 57 | @dataclass 58 | class ReadwiseHighlight: 59 | """ 60 | Represents a Readwise highlight. 61 | 62 | Attributes: 63 | id: The highlight's ID. 64 | text: The highlight's text. 65 | note: The highlight's note. 66 | location: The highlight's location. 67 | location_type: The highlight's location type. 68 | url: The highlight's URL. 69 | color: The highlight's color. 70 | updated: The date and time the highlight was last updated. 71 | book_id: The ID of the book the highlight is from. 72 | tags: The highlight's tags. 73 | """ 74 | 75 | id: str 76 | text: str 77 | note: str 78 | location: int 79 | location_type: str 80 | url: str | None 81 | color: str 82 | updated: datetime | None 83 | book_id: str 84 | tags: list[ReadwiseTag] 85 | 86 | 87 | @dataclass 88 | class ReadwiseReaderDocument: 89 | id: str 90 | url: str 91 | source_url: str 92 | title: str 93 | author: str 94 | source: str 95 | category: str 96 | location: str 97 | tags: dict[str, Any] 98 | site_name: str 99 | word_count: int 100 | created_at: datetime 101 | updated_at: datetime 102 | notes: str 103 | published_date: str 104 | summary: str 105 | image_url: str 106 | parent_id: Optional[str] 107 | reading_progress: float 108 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["github>rwxd/renovate-config"] 4 | } 5 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pre-commit==3.8.0 2 | pytest==8.3.3 3 | -------------------------------------------------------------------------------- /requirements-docs.txt: -------------------------------------------------------------------------------- 1 | material-plausible-plugin==0.2.0 2 | material-plausible-plugin==0.2.0 3 | mkdocs==1.6.1 4 | mkdocs-material==9.5.38 5 | mkdocstrings[python]==0.26.1 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | from subprocess import SubprocessError, check_output 3 | 4 | from setuptools import setup 5 | 6 | with open('./requirements.txt') as f: 7 | required = f.read().splitlines() 8 | 9 | this_directory = path.abspath(path.dirname(__file__)) 10 | with open(path.join(this_directory, 'README.md'), encoding='utf-8') as f: 11 | long_description = f.read() 12 | 13 | version = '1.0.0' 14 | 15 | try: 16 | version = ( 17 | check_output(['git', 'describe', '--tags']).strip().decode().replace('v', '') 18 | ) 19 | except SubprocessError as e: 20 | print(e) 21 | 22 | setup( 23 | name='readwise', 24 | version=version, 25 | description='Readwise api client', 26 | long_description=long_description, 27 | long_description_content_type='text/markdown', 28 | author='rwxd', 29 | author_email='rwxd@pm.me', 30 | url='https://github.com/rwxd/pyreadwise', 31 | license='MIT', 32 | packages=['readwise'], 33 | install_requires=required, 34 | classifiers=[ 35 | 'Programming Language :: Python :: 3.11', 36 | ], 37 | ) 38 | -------------------------------------------------------------------------------- /tests/unit/test_api.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from unittest.mock import Mock, patch 3 | 4 | from requests import Session 5 | 6 | from readwise.api import Readwise, ReadwiseReader 7 | 8 | readwise_client = Readwise('test_token') 9 | readwise_reader = ReadwiseReader('test_token') 10 | 11 | 12 | @patch.object(Session, 'request') 13 | def test_paging(mock_get): 14 | page1 = Mock() 15 | page1.status_code = 200 16 | page1.json.return_value = { 17 | 'next': 'https://example.com/api/v2/books/?page=2', 18 | 'results': [ 19 | { 20 | 'id': 1, 21 | 'title': 'Test Book', 22 | } 23 | ], 24 | } 25 | 26 | page2 = Mock() 27 | page2.status_code = 200 28 | page2.json.return_value = { 29 | 'next': None, 30 | 'results': [ 31 | { 32 | 'id': 2, 33 | 'title': 'Test Book 2', 34 | } 35 | ], 36 | } 37 | 38 | mock_get.side_effect = [ 39 | page1, 40 | page2, 41 | ] 42 | 43 | generator = readwise_client.get_pagination('test/') 44 | assert next(generator) == page1.json.return_value 45 | assert next(generator) == page2.json.return_value 46 | 47 | 48 | @patch.object(Session, 'request') 49 | def test_get_books_empty(mock_get): 50 | mock_get.return_value.status_code = 200 51 | mock_get.return_value.json.return_value = {'results': [], 'next': None} 52 | highlights = list(readwise_client.get_books('articles')) 53 | assert len(highlights) == 0 54 | 55 | 56 | @patch.object(Session, 'request') 57 | def test_get_books(mock_get): 58 | mock_get.return_value.status_code = 200 59 | mock_get.return_value.json.return_value = { 60 | 'next': None, 61 | 'results': [ 62 | { 63 | 'id': 1, 64 | 'title': 'Test Book', 65 | 'author': 'Test Author', 66 | 'category': 'article', 67 | 'source': 'Test Source', 68 | 'num_highlights': 1, 69 | 'last_highlight_at': '2022-12-27T11:33:09Z', 70 | 'updated': '2020-01-01T00:00:00Z', 71 | 'cover_image_url': 'https://example.com/image.jpg', 72 | 'highlights_url': 'https://example.com/highlights', 73 | 'source_url': 'https://example.com/source', 74 | 'asin': 'test_asin', 75 | 'tags': [ 76 | {'id': 1, 'name': 'test_tag'}, 77 | {'id': 2, 'name': 'test_tag_2'}, 78 | ], 79 | 'document_note': 'test_note', 80 | } 81 | ], 82 | } 83 | books = list(readwise_client.get_books('articles')) 84 | assert len(books) == 1 85 | assert books[0].id == 1 86 | assert books[0].title == 'Test Book' 87 | assert books[0].author == 'Test Author' 88 | assert books[0].category == 'article' 89 | assert books[0].source == 'Test Source' 90 | assert books[0].num_highlights == 1 91 | assert books[0].last_highlight_at == datetime.fromisoformat('2022-12-27T11:33:09Z') 92 | assert books[0].updated == datetime.fromisoformat('2020-01-01T00:00:00Z') 93 | assert books[0].cover_image_url == 'https://example.com/image.jpg' 94 | assert books[0].highlights_url == 'https://example.com/highlights' 95 | assert books[0].source_url == 'https://example.com/source' 96 | assert books[0].asin == 'test_asin' 97 | assert len(books[0].tags) == 2 98 | assert books[0].tags[0].id == 1 99 | assert books[0].tags[0].name == 'test_tag' 100 | assert books[0].tags[1].id == 2 101 | assert books[0].tags[1].name == 'test_tag_2' 102 | assert books[0].document_note == 'test_note' 103 | 104 | 105 | @patch.object(Session, 'request') 106 | def test_get_book_highlights_empty(mock_get): 107 | mock_get.return_value.status_code = 200 108 | mock_get.return_value.json.return_value = { 109 | 'next': None, 110 | 'results': [], 111 | } 112 | highlights = list(readwise_client.get_book_highlights('1')) 113 | assert len(highlights) == 0 114 | 115 | 116 | @patch.object(Session, 'request') 117 | def test_get_book_highlights(mock_get): 118 | mock_get.return_value.status_code = 200 119 | mock_get.return_value.json.return_value = { 120 | 'next': None, 121 | 'results': [ 122 | { 123 | 'id': 1, 124 | 'text': 'Test Highlight', 125 | 'note': 'Test Note', 126 | 'location': 1, 127 | 'location_type': 'page', 128 | 'url': 'https://example.com/highlight', 129 | 'color': 'yellow', 130 | 'updated': '2020-01-01T00:00:00Z', 131 | 'book_id': 1, 132 | 'tags': [ 133 | {'id': 1, 'name': 'test_tag'}, 134 | {'id': 2, 'name': 'test_tag_2'}, 135 | ], 136 | } 137 | ], 138 | } 139 | highlights = list(readwise_client.get_book_highlights('1')) 140 | assert len(highlights) == 1 141 | assert highlights[0].id == 1 142 | assert highlights[0].text == 'Test Highlight' 143 | assert highlights[0].note == 'Test Note' 144 | assert highlights[0].location == 1 145 | assert highlights[0].location_type == 'page' 146 | assert highlights[0].url == 'https://example.com/highlight' 147 | assert highlights[0].color == 'yellow' 148 | assert highlights[0].updated == datetime.fromisoformat('2020-01-01T00:00:00Z') 149 | assert highlights[0].book_id == 1 150 | assert len(highlights[0].tags) == 2 151 | assert highlights[0].tags[0].id == 1 152 | assert highlights[0].tags[0].name == 'test_tag' 153 | assert highlights[0].tags[1].id == 2 154 | assert highlights[0].tags[1].name == 'test_tag_2' 155 | --------------------------------------------------------------------------------