├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── LICENSE ├── README.md ├── __init__.py ├── docs ├── Makefile ├── conf.py ├── index.rst ├── make.bat ├── raindropiopy.api.rst ├── raindropiopy.models.rst ├── raindropiopy.rstxx └── requirements.txt ├── examples ├── RUN_ALL.py ├── create_collection.py ├── create_raindrop_file.py ├── create_raindrop_link.py ├── edit_collection.py ├── edit_raindrop.py ├── flasktest │ └── app.py ├── get_meta.py ├── list_authorised_user.py ├── list_collections.py ├── list_system_collections.py ├── list_tags.py ├── sample_bulk_upload_specification.toml ├── sample_upload_file.pdf └── search_raindrop.py ├── justfile ├── noxfile.py ├── poetry.lock ├── pyproject.toml ├── raindropiopy ├── __init__.py ├── api.py └── models.py ├── tests ├── __init__.py ├── api │ ├── cassettes │ │ ├── test_collection_lifecycle.yaml │ │ ├── test_get_collections.yaml │ │ ├── test_get_tags.yaml │ │ ├── test_get_user.yaml │ │ ├── test_lifecycle_raindrop_file.yaml │ │ ├── test_lifecycle_raindrop_link.yaml │ │ ├── test_search.yaml │ │ └── test_system_collections.yaml │ ├── conftest.py │ ├── test_collections.py │ ├── test_models_api.py │ ├── test_models_collection.py │ ├── test_models_raindrop.py │ ├── test_models_tag.py │ ├── test_models_user.py │ ├── test_raindrop.pdf │ ├── test_raindrop.py │ ├── test_tags.py │ └── test_user.py └── conftest.py └── vulture_whitelist.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Local additions 2 | .DS_STORE 3 | poetry.toml 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | pip-wheel-metadata/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | .ruff_cache 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 100 | __pypackages__/ 101 | 102 | # Celery stuff 103 | celerybeat-schedule 104 | celerybeat.pid 105 | 106 | # SageMath parsed files 107 | *.sage.py 108 | 109 | # Environments 110 | .env 111 | .envrc 112 | .venv 113 | env/ 114 | venv/ 115 | ENV/ 116 | env.bak/ 117 | venv.bak/ 118 | 119 | # Spyder project settings 120 | .spyderproject 121 | .spyproject 122 | 123 | # Rope project settings 124 | .ropeproject 125 | 126 | # mkdocs documentation 127 | /site 128 | 129 | # mypy 130 | .mypy_cache/ 131 | .dmypy.json 132 | dmypy.json 133 | 134 | # Pyre type checker 135 | .pyre/ 136 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | fail_fast: true 2 | repos: 3 | 4 | ################################################################################ 5 | # Look for instances of unused imports. 6 | ################################################################################ 7 | - repo: https://github.com/hakancelikdev/unimport 8 | rev: 1.2.1 9 | hooks: 10 | - id: unimport 11 | name: Running check for unused imports 12 | args: 13 | - --include-star-import 14 | - --ignore-init 15 | - --gitignore 16 | 17 | ################################################################################ 18 | # Run Ruff! 19 | ################################################################################ 20 | - repo: https://github.com/astral-sh/ruff-pre-commit 21 | rev: v0.3.7 22 | hooks: 23 | - id: ruff 24 | name: Running ruff linter 25 | args: ["--fix", "raindropiopy/"] 26 | - id: ruff-format 27 | name: Running ruff formatter 28 | args: ["raindropiopy/"] 29 | 30 | ################################################################################ 31 | # Confirm we're not accidentally checking in large files: 32 | ################################################################################ 33 | - repo: https://github.com/pre-commit/pre-commit-hooks 34 | rev: v4.6.0 35 | hooks: 36 | - id: check-added-large-files 37 | name: Running check for large files 38 | 39 | ################################################################################ 40 | # Confirm we don't have unused code: 41 | ################################################################################ 42 | - repo: https://github.com/jendrikseipp/vulture 43 | rev: 'v2.11' # or any later Vulture version 44 | hooks: 45 | - id: vulture 46 | 47 | ################################################################################ 48 | # Run test suite, only at the end since code stuff above is so cyclical! 49 | ################################################################################ 50 | # - repo: local 51 | # hooks: 52 | # - id: system 53 | # name: Running tests 54 | # entry: .venv/bin/python -m pytest 55 | # pass_filenames: false 56 | # language: system 57 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read-the-Docs configuration file. 2 | 3 | # Required 4 | version: 2 5 | 6 | # Build documentation in the docs/ directory with Sphinx 7 | sphinx: 8 | configuration: docs/conf.py 9 | builder: html 10 | 11 | # Set the version of Python and other tools you might need 12 | build: 13 | os: ubuntu-22.04 14 | tools: 15 | python: "3.11" 16 | 17 | python: 18 | install: 19 | - requirements: docs/requirements.txt 20 | - method: pip 21 | path: . 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Peter Borocz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![version](https://img.shields.io/badge/python-3.10+-green)](https://www.python.org/) 2 | [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit)](https://github.com/pre-commit/pre-commit) 3 | [![license](https://img.shields.io/badge/License-MIT-green.svg)](https://github.com/PBorocz/raindrop-io-py/blob/trunk/LICENSE) 4 | 5 | # PROJECT STATUS! 6 | 7 | As of Spring 2024, I don't use RaindropIO anymore and thus will find it rather difficult to support this project. I'll keep up-to-date on CVE's of underlying packages for the foreseeable future but otherwise, FEEL FREE to fork and if you're interesting in taking ownership of the repo, feel free to contact me! (or open an issue) 8 | 9 | # Raindrop-IO-py 10 | 11 | Python wrapper for the API to the [Raindrop.io](https://raindrop.io) Bookmark Manager. 12 | 13 | Capabilities include the ability to create, update, delete both link & file-based Raindrops; create, update delete Raindrop collections, tags etc. 14 | 15 | ## Background 16 | 17 | I wanted to use an existing API for the Raindrop Bookmark Manager ([python-raindropio](https://github.com/atsuoishimoto/python-raindropio)) to perform some bulk operations through a simple command-line interface. However, the API was incomplete didn't seem actively supported anymore. Thus, this is a _fork_ and significant extension of [python-raindropio](https://github.com/atsuoishimoto/python-raindropio) (ht [Atsuo Ishimoto](https://github.com/atsuoishimoto)). 18 | 19 | ## Status 20 | 21 | As the API layer is based on a fork of an existing package, it's reasonably stable. 22 | 23 | ## Requirements 24 | 25 | Requires Python 3.10 or later (well, at least we're developing against 3.11.3). 26 | 27 | ## Install 28 | 29 | ```shell 30 | [.venv] python -m pip install raindrop-io-py 31 | ``` 32 | 33 | ## Setup 34 | 35 | To use this package, you'll need two items: 36 | 37 | - Somewhat obviously, your own account on [Raindrop](https://raindrop.io). 38 | 39 | - However, to get API access to Raindrop, you'll need to create an `integration app` on [Raindrop](https://raindrop.io) site from which you can create API token(s). 40 | 41 | 42 | To setup your `integration app`: 43 | 44 | - Go to [](https://app.raindrop.io/settings/integrations) and select `+ create new app`: 45 | 46 | - Give it a descriptive name and then select the app you just created. 47 | 48 | - Select `Create test token` and copy the token provided. Note that the basis for calling it a _test_ token is that it only gives you access to bookmarks within *your own account*. Raindrop allows you to use their API against other people's environments using oAuth (see untested/unsupported `flask_oauth.py` file in /examples) 49 | 50 | - Save your token into your environment (we use python-dotenv so a simple .env/.envrc file containing your token should suffice), for example: 51 | 52 | ```shell 53 | # If you use direnv or it's equivalent, place something like this in a .env file: 54 | RAINDROP_TOKEN=01234567890-abcdefghf-aSample-API-Token-01234567890-abcdefghf 55 | 56 | # Or for bash: 57 | export RAINDROP_TOKEN=01234567890-abcdefghf-aSample-API-Token-01234567890-abcdefghf 58 | 59 | # Or for fish: 60 | set -gx RAINDROP_TOKEN 01234567890-abcdefghf-aSample-API-Token-01234567890-abcdefghf 61 | 62 | # etc... 63 | ``` 64 | 65 | ## Examples 66 | 67 | A full suite of examples are provided in the `examples` directory. Each can be run independently as: 68 | 69 | ```shell 70 | [.venv] % python examples/list_collections.py 71 | ``` 72 | 73 | or a wrapper script is available to run all of them, in logical order with a small wait to be nice to Raindrop's API: 74 | 75 | ```shell 76 | [.venv] % python examples/RUN_ALL.py 77 | ``` 78 | 79 | ### API Examples 80 | 81 | Here are a few examples of API usage. Note that I don't have testing for the examples below (yet), but the examples folder helps significantly as it runs against your *live* Raindrop environment. 82 | 83 | #### Display All Collections and Unsorted Bookmarks 84 | 85 | This example shows the intended usage of the API as a context-manager, from which any number of calls can be made: 86 | 87 | ```python 88 | import os 89 | 90 | from dotenv import load_dotenv 91 | 92 | from raindropiopy import API, Collection, CollectionRef, Raindrop 93 | 94 | load_dotenv() 95 | 96 | with API(os.environ["RAINDROP_TOKEN"]) as api: 97 | 98 | print("Current Collections:") 99 | for collection in Collection.get_collections(api): 100 | print(collection.title) 101 | 102 | print("\nUnsorted Raindrop Bookmarks):") 103 | for item in Raindrop.search(api, collection=CollectionRef.Unsorted): 104 | print(item.title) 105 | ``` 106 | 107 | #### Create a New Raindrop Bookmark to a URL 108 | 109 | ```python 110 | import os 111 | 112 | from dotenv import load_dotenv 113 | 114 | from raindropiopy import API, Raindrop 115 | 116 | load_dotenv() 117 | 118 | with API(os.environ["RAINDROP_TOKEN"]) as api: 119 | link, title = "https://www.python.org/", "Our Benevolent Dictator's Creation" 120 | print(f"Creating Raindrop to: '{link}' with title: '{title}'...", flush=True, end="") 121 | raindrop = Raindrop.create_link(api, link=link, title=title, tags=["abc", "def"]) 122 | print(f"Done, id={raindrop.id}") 123 | 124 | ``` 125 | 126 | (after this has executed, go to your Raindrop.io environment (site or app) and you should see this Raindrop to python.org available) 127 | 128 | #### Create a New Raindrop Collection 129 | 130 | ```python 131 | import os 132 | import sys 133 | from datetime import datetime 134 | from getpass import getuser 135 | 136 | from dotenv import load_dotenv 137 | 138 | from raindropiopy import API, Collection 139 | 140 | load_dotenv() 141 | 142 | with API(os.environ["RAINDROP_TOKEN"]) as api: 143 | title = f"TEST Collection ({getuser()}@{datetime.now():%Y-%m-%dT%H:%M:%S})" 144 | print(f"Creating collection: '{title}'...", flush=True, end="") 145 | collection = Collection.create(api, title=title) 146 | print(f"Done, {collection.id=}.") 147 | ``` 148 | 149 | (after this has executed, go to your Raindrop.io environment (site or app) and you should see this collection available) 150 | 151 | ## Documentation 152 | 153 | We use [Sphinx](https://www.sphinx-doc.org/en/master/index.html) with [Google-style docstrings](https://www.sphinx-doc.org/en/master/usage/extensions/example_google.html) to document our API. Documentation is hosted by [ReadTheDocs](https://readthedocs.org/) and can be found [here](https://raindrop-io-py.readthedocs.io/en/latest/). 154 | 155 | ## Acknowledgments 156 | 157 | [python-raindropio](https://github.com/atsuoishimoto/python-raindropio) from [Atsuo Ishimoto](https://github.com/atsuoishimoto). 158 | 159 | ## License Foo 160 | 161 | The project is licensed under the MIT License. 162 | 163 | ## Release History 164 | 165 | ### Unreleased 166 | 167 | - SECURITY: Update `tornado` based on CVE-2025-47287 (high severity). 168 | - Convert to new pyproject.toml project keywords based on updated poetry version. 169 | 170 | ### 0.4.4 - 2025-01-13 171 | 172 | - SECURITY: Update `virtualenv` based on CVE-2024-53899 (high severity). 173 | 174 | ### 0.4.3 - 2024-12-31 175 | 176 | - SECURITY: Update `jinja2` based on CVE-2024-56326 & CVE-2024-56201 (both moderate severity). 177 | 178 | ### 0.4.2 - 2024-11-26 179 | 180 | - SECURITY: Update `tornado` based on CVE-2024-52804 (HTTP cookie parsing DoS vulnerability). 181 | 182 | ### 0.4.1 - 2024-07-07 183 | 184 | - SECURITY: Update `certifi3` based on CVE-2024-39689 (remove "GLOBALTRUST" as cert verifier). 185 | 186 | ### 0.4.0 - 2024-06-19 187 | 188 | - SECURITY: Update `urllib3` based on CVE-2024-37891 (moderate). 189 | 190 | - SECURITY: Update `tornado` (used by `sphinx-autobuild`). Used opportunity to update several minor packages as well. 191 | 192 | ### 0.3.0 - 2024-06-07 193 | 194 | - FIXED: Reverted use of 1 py3.11+ construct to support 3.10 now. Changed minimum python version in pyproject.toml to match (ie. ">=3.10,<4.0"). Added new deployment of [Nox](https://nox.thea.codes) to support cross version testing. TTBOMK, this release "should" work against 3.10, 3.11 and 3.12 however this is the first time I've tried to support previous versions in a PyPI package so feel free to let me know if I've missing anything! 195 | 196 | ### 0.2.5 - 2024-05-26 197 | 198 | - SECURITY: Update `requests` to address potential security vulnerability (CVE-2024-35195). 199 | - FIXED: Fixed minor typo in the [Display All Collections and Unsorted Bookmarks](#display-all-collections-and-unsorted-bookmarks) example above; missing closing parens on `print` statements. 200 | 201 | ### 0.2.4 - 2024-05-07 202 | 203 | - SECURITY: Addressed vulnerability in Jinja2. 204 | 205 | ### 0.2.3 - 2024-04-12 206 | 207 | - INTERNAL: In an attempt create a full (ie. file-based) exporter, added a "cache" call to the Raindrop class to return a URL to the cached/permanent pdf/file documents on S3. While the call ostensibly works, the returned URL's don't work against S3 ("item not found"). Thus, use AT YOUR OWN RISK (and let me know if you *do* get a successful use of it! ;-) 208 | - SECURITY: Addressed vulnerabilities in idna and dnspython. 209 | 210 | ### 0.2.2 - 2024-01-18 211 | 212 | - INTERNAL: Create whitelist obo vulture to one set of method arguments that are used dynamically. 213 | - INTERNAL: Moved from stand-alone manage.yaml to incorporate manage commands directly in pyproject.toml (based on manage's 0.2.0 release). Remove manage from local install (run from pipx instead). 214 | - FIXED: Addressed error in nested Collections, handling case of parent reference as either a dict, an int or None. 215 | 216 | ### v0.2.1 - 2023-12-12 217 | - FIXED: Minor bug in recently updated list_collections. 218 | - CHANGED: Continued to remove redundant packages. 219 | 220 | ### v0.2.0 - 2023-12-12 221 | - FIXED: Inability to correctly handle "sub" or child collections. We now correctly unpack 'parent' references on querying child collections...(ht to [@limaceous-bushwhacker](https://github.com/limaceous-bushwhacker) in [issue #12](https://github.com/PBorocz/raindrop-io-py/issues/12)). 222 | - FIXED: Bugs in `examples/list_collections.py` and `examples\list_authorised_user.py`) that were using old collection attribute `internal_` instead of renamed `other` (to list the _other_/non-official attributes associated with a Collection). 223 | - FIXED: False positives from tests associated with collections (noticed after adding test obo sub/child collections). There are a few tests not supported yet so the examples code (which runs against the live Raindrop environment is still valuable). 224 | - CHANGED: Split the command-line portion of the library into a completely separate project. This reduces the size and complexity of the install for this package, allowing it to focus solely on the API interaction with Raindrop and allowing me to experiment more freely with different approaches to a command-line interface. If anyone WAS relying upon the CLI itself (hopefully not heavily), please let me know and I'll expedite the creation of the stand-alone CLI project/package. 225 | 226 | ### v0.1.8 - 2023-10-03 227 | 228 | - FIXED: Addressed error in README.md (ht to [@superkeyor](https://github.com/superkeyor) in [issue #7](https://github.com/PBorocz/raindrop-io-py/issues/7)). 229 | - CHANGED: `SystemCollections.get_status` has been renamed to `SystemCollections.get_counts` to more accurately reflect that it only returns the counts of Raindrops in the 3 SystemCollections only. 230 | - ADDED: `SystemCollections.get_meta` to return the current "state" of your environment, in particular: the date-time associated with the last Raindrop change; if your account is Pro level also the number of "broken" and/or "duplicated" Raindrops in your account. 231 | - ADDED: Reduced CLI startup time as CLI now keeps cached lists of Collections and Tags in conventional (but platform-specific) application state directory. If no changes to the Raindrop environment have occurred since last invocation (determined by the `get_meta` method above), previous state will be used. 232 | - SECURITY: Addressed `gitpython` vulnerabilities (CVE-2023-40590 and CVE-2023-41040). The former is primarily a Windows issue but `gitpython` is only used in the poetry _dev_ group for release support. 233 | - SECURITY: Addressed `urllib3` vulnerability (CVE-2023-43804) inherited from requests library. Similar to above, this is also only used in poetry _dev_ group for release support (thus, will attempt to segregate a bit more strongly). 234 | 235 | ### v0.1.7 - 2023-08-22 236 | 237 | - SECURITY: Another `tornado` update to address vulnerability in parsing Content-Length from header (has a CVE now ➡ `GHSA-qppv-j76h-2rpx`). 238 | 239 | ### v0.1.6 - 2023-08-17 240 | 241 | - SECURITY: Update `tornado` to address vulnerability in parsing Content-Length from header (moderate severity, no CVE). 242 | 243 | ### v0.1.5 - 2023-08-17 244 | 245 | - SECURITY: Update `certifi` to address potential security vulnerability (CVE-2023-37920) (second release attempt) 246 | 247 | ### v0.1.4 - 2023-08-17 248 | 249 | - SECURITY: Update `certifi` to address potential security vulnerability (CVE-2023-37920). 250 | 251 | ### v0.1.3 - 2023-07-20 252 | 253 | - SECURITY: Update `pygments` to 2.15.1 to address potential security vulnerability. 254 | - CHANGED: Moved to py 3.11.3. 255 | 256 | ### v0.1.2 - 2023-07-08 257 | 258 | - FIXED: Per Issue #5, cache `size` may come back from Raindrop as 0 in some cases, relax pydantic type from PositiveInt to `int` (Didn't hear anything back from Rustem regarding the cases in which this can (or should?) occur). 259 | 260 | ### v0.1.1 - 2023-06-06 261 | 262 | - CHANGED: `Raindrop.search` now only takes a single search string (instead of word, tag or important), leaving search string blank results in correct wildcard search behaviour, addresses issue #4. 263 | 264 | ### v0.1.0 - 2023-02-16 265 | 266 | - CHANGED: `Raindrop.create_file` to handle `collection` argument consistent with `Raindrop.create_link`, specifically, either a `Collection`, `CollectionRef` or direct integer collection_id. 267 | - ADDED: Beginning of documentation suite on Read-The-Docs. 268 | 269 | ### v0.0.15 - 2023-02-11 270 | 271 | - CHANGED: `Raindrop.search_paged` is now hidden (can't see a reason to explicitly use it over `Raindrop.search`) 272 | - CHANGED: Several attributes that, while allowed to be set by RaindropIO's API, are now *not* able to be set by this API. For example, you shouldn't be able to change "time" by setting `created` or `last_update` fields on a Raindrop or Collection. 273 | - CHANGED: The `Collection`, `Raindrop` and `Tag` "remove" method is now "delete" to more accurately match with RaindropIO's API). 274 | 275 | ### v0.0.14 - 2023-02-09 276 | 277 | - FIXED: `Raindrop.cache.size` and `Raindrop.cache.created` attributes are now optional (RaindropIO's API doesn't always provide them). 278 | - FIXED: README examples corrected to reflect simpler Raindrop.search call. 279 | 280 | ### v0.0.13 - 2023-02-07 281 | 282 | - CHANGED: Cross-referenced the fields available from the Raindrop API with our API; most available but several optional ones skipped for now. 283 | - CHANGED: (Internal) Remove dependency on ["jashin"](https://github.com/sojin-project/jashin) library by moving to [pydantic](https://docs.pydantic.dev/) for all Raindrop API models. 284 | 285 | ### v0.0.12 - 2023-02-06 286 | 287 | - CHANGED: (Internal) Move from README.org to README.md to allow PyPI to display project information correctly. 288 | 289 | ### v0.0.11 - 2023-02-06 290 | 291 | - CHANGED: Raindrop search API call is now non-paged (the "paged" version is still available as `Raindrop.search_paged`). 292 | 293 | ### v0.0.10 - 2023-02-05 294 | 295 | - ADDED: Ability to specify raindrop field: Description on a created Raindrop (either file or link-based). 296 | - ADDED: Ability to re-query existing search results (eg. after changes) and smoothed out post-search interactions. 297 | 298 | ### v0.0.9 - 2023-02-04 299 | 300 | - ADDED: An ability to view, edit and delete raindrops returned from a search. 301 | - ADDED: A simple `RUN_ALL.py` script to the examples directory to...well, run all the examples in order! 302 | - CHANGED: The display of raindrops returned from a search to include tags and to only show Collection name if all raindrops are across multiple collections. 303 | 304 | ### v0.0.8 - 2023-01-25 305 | 306 | - CHANGED: Added simple version method in root package: 307 | 308 | ```python 309 | from raindropiopy import version 310 | ppprint(version()) 311 | ``` 312 | 313 | ### v0.0.7 - 2023-01-25 314 | 315 | - CHANGED: Moved from keeping README in markdown to org file format. Incorporated package's ChangeLog into README as well (at the bottom). 316 | - CHANGED: Added new manage.py release automation capability (internal only, nothing public-facing). 317 | 318 | ### v0.0.6 - 2023-01-22 319 | 320 | - FIXED: CLI autocomplete now works again after adding support for "single-letter" command-shortcuts. 321 | - ADDED: A set of missing attributes to the Raindrop API model type, eg. file, cache etc. Only attribute still missing is "highlights". 322 | 323 | ### v0.0.5 - 2023-01-21 324 | 325 | - ADDED: Support use of [Vulture](https://github.com/jendrikseipp/vulture) for dead-code analysis (not in pre-commit through due to conflict with ruff's McCabe complexity metric) 326 | - CHANGED: Moved internal module name to match that of package name. Since we couldn't use raindroppy as a package name on PyPI due to similarities with existing packages (one of which was for a **crypto** package), we renamed this package to raindrop-io-py. In concert, the internal module is now `raindropiopy`: 327 | 328 | ```python 329 | from raindroiopy.api import API 330 | ``` 331 | 332 | - FIXED: Sample file upload specification in `examples/create_raindrop_file.py` is now correct. 333 | 334 | .. |docs| image:: https://readthedocs.org/projects/docs/badge/?version=latest 335 | :alt: Documentation Status 336 | :scale: 100% 337 | :target: https://docs.readthedocs.io/en/latest/?badge=latest 338 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | """Top level dunder init.""" 2 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | """Configuration file for the Sphinx documentation builder.""" 2 | 3 | import os 4 | import sys 5 | 6 | # Do this to allow autodoc to actually FIND our raindropiopy package.. 7 | sys.path.insert(0, os.path.abspath(".")) 8 | 9 | # -- Project information ----------------------------------------------------- 10 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 11 | 12 | project = "RaindropIOPY" 13 | copyright = "2023, Peter Borocz" 14 | author = "Peter Borocz" 15 | 16 | # -- General configuration --------------------------------------------------- 17 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 18 | 19 | extensions = [ 20 | "sphinx.ext.autodoc", 21 | "sphinx.ext.napoleon", 22 | ] 23 | 24 | napoleon_google_docstring = True 25 | napoleon_use_param = False 26 | napoleon_use_ivar = True 27 | 28 | templates_path = ["_templates"] 29 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 30 | 31 | 32 | # -- Options for HTML output ------------------------------------------------- 33 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 34 | 35 | html_static_path = ["_static"] 36 | 37 | 38 | # -- Theming ----------------------------------------------------------------- 39 | html_theme = "sphinxdoc" 40 | html_theme_options = {"sidebarwidth": 400} 41 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Raindrop-IO-py documentation master file, created by 2 | sphinx-quickstart on Fri Feb 10 12:23:30 2023. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Raindrop-IO-py 7 | ============== 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | raindropiopy.models 14 | raindropiopy.api 15 | 16 | Indices and Tables 17 | ================== 18 | 19 | * :ref:`genindex` 20 | * :ref:`modindex` 21 | * :ref:`search` 22 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/raindropiopy.api.rst: -------------------------------------------------------------------------------- 1 | Low-level API 2 | ============= 3 | 4 | .. automodule:: raindropiopy.api 5 | :members: 6 | :ignore-module-all: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/raindropiopy.models.rst: -------------------------------------------------------------------------------- 1 | Core Classes 2 | ============ 3 | 4 | .. automodule:: raindropiopy.models 5 | :members: 6 | :ignore-module-all: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/raindropiopy.rstxx: -------------------------------------------------------------------------------- 1 | Raindropiopy package 2 | ==================== 3 | 4 | API Module 5 | ---------- 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # Defining the exact version will make sure things don't break 2 | sphinx==7.2.6 3 | sphinxjp-themes-basicstrap==0.5.0 4 | -------------------------------------------------------------------------------- /examples/RUN_ALL.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Run all the example scripts, in a logical order with sleep time in between.""" 3 | 4 | import runpy 5 | import time 6 | 7 | 8 | def rest(): 9 | """Sleep a second to be nice to Raindrop's API.""" 10 | print("Resting...", end="", flush=True) 11 | time.sleep(2.5) 12 | print() 13 | 14 | 15 | def run(path_name): 16 | """Run the py script at the specified path, waiting afterwards.""" 17 | runpy.run_path(path_name=path_name) 18 | rest() 19 | 20 | 21 | # Order: the first 3 are READ-ONLY, followed by Collection examples and then Raindrop ones. 22 | run("examples/get_meta.py") 23 | run("examples/list_authorised_user.py") 24 | run("examples/list_collections.py") 25 | run("examples/list_tags.py") 26 | run("examples/create_collection.py") 27 | run("examples/edit_collection.py") 28 | run("examples/create_raindrop_file.py") 29 | run("examples/create_raindrop_link.py") 30 | run("examples/edit_raindrop.py") 31 | run("examples/search_raindrop.py") 32 | -------------------------------------------------------------------------------- /examples/create_collection.py: -------------------------------------------------------------------------------- 1 | """Create a new collection.""" 2 | 3 | import os 4 | import sys 5 | 6 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) 7 | 8 | from datetime import datetime 9 | from getpass import getuser 10 | 11 | from dotenv import load_dotenv 12 | 13 | from raindropiopy import API, Collection 14 | 15 | load_dotenv() 16 | 17 | with API(os.environ["RAINDROP_TOKEN"]) as api: 18 | title = f"TEST Collection ({getuser()}@{datetime.now():%Y-%m-%dT%H:%M:%S})" 19 | print(f"Creating collection: '{title}'...", flush=True, end="") 20 | try: 21 | collection = Collection.create(api, title=title) 22 | print(f"Done, {collection.id=}.") 23 | except Exception as exc: 24 | print(f"Sorry, unable to create collection! {exc}") 25 | sys.exit(1) 26 | 27 | # If you want to actually *see* the new collection, comment this 28 | # section out and look it up through any Raindrop mechanism (ie. 29 | # app, url etc.); otherwise, we clean up after ourselves. 30 | print(f"Removing collection: '{title}'...", flush=True, end="") 31 | Collection.delete(api, id=collection.id) 32 | print("Done.") 33 | -------------------------------------------------------------------------------- /examples/create_raindrop_file.py: -------------------------------------------------------------------------------- 1 | """Create a new file-based Raindrop into the Unsorted collection.""" 2 | 3 | import os 4 | import sys 5 | from pathlib import Path 6 | 7 | from dotenv import load_dotenv 8 | 9 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) 10 | 11 | from raindropiopy import API, Raindrop 12 | 13 | load_dotenv() 14 | 15 | with API(os.environ["RAINDROP_TOKEN"]) as api: 16 | # Note that Raindrop only supports a small set of file types, see 17 | # https://help.raindrop.io/files for details. 18 | path_ = Path(__file__).parent / Path("sample_upload_file.pdf") 19 | print(f"Creating Raindrop of: '{path_.name}'...", flush=True, end="") 20 | try: 21 | raindrop = Raindrop.create_file(api, path_, content_type="application/pdf") 22 | print("Done.") 23 | print(f"{raindrop.id=}") 24 | except Exception as exc: 25 | print(f"Sorry, unable to create Raindrop! {exc}") 26 | sys.exit(1) 27 | 28 | # If you want to actually *see* the new Raindrop, comment this 29 | # section out and look it up through any Raindrop mechanism (ie. 30 | # app, url etc.); otherwise, we clean up after ourselves. 31 | print(f"Removing Raindrop: '{path_.name}'...", flush=True, end="") 32 | Raindrop.delete(api, id=raindrop.id) 33 | print("Done.") 34 | -------------------------------------------------------------------------------- /examples/create_raindrop_link.py: -------------------------------------------------------------------------------- 1 | """Create a new link-based Raindrop, defaulting to the Unsorted collection.""" 2 | 3 | import os 4 | import sys 5 | 6 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) 7 | 8 | from dotenv import load_dotenv 9 | 10 | from raindropiopy import API, Raindrop 11 | 12 | load_dotenv() 13 | 14 | with API(os.environ["RAINDROP_TOKEN"]) as api: 15 | link, title = "https://www.python.org/", "Benevolent Dictator's Creation" 16 | try: 17 | print( 18 | f"Creating Raindrop to: '{link}' with title: '{title}'...", 19 | flush=True, 20 | end="", 21 | ) 22 | raindrop = Raindrop.create_link( 23 | api, 24 | link=link, 25 | title=title, 26 | tags=["abc", "def"], 27 | ) 28 | print("Done.") 29 | print(f"{raindrop.id=}") 30 | except Exception as exc: 31 | print(f"Sorry, unable to create Raindrop! {exc}") 32 | sys.exit(1) 33 | 34 | # If you want to actually *see* the new Raindrop, comment this 35 | # section out and look it up through any Raindrop mechanism (ie. 36 | # app, url etc.); otherwise, we clean up after ourselves. 37 | print(f"Removing raindrop: '{title}'...", flush=True, end="") 38 | Raindrop.delete(api, id=raindrop.id) 39 | print("Done.") 40 | -------------------------------------------------------------------------------- /examples/edit_collection.py: -------------------------------------------------------------------------------- 1 | """Create, update and delete a Collection.""" 2 | 3 | import os 4 | import sys 5 | 6 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) 7 | 8 | from dotenv import load_dotenv 9 | 10 | from raindropiopy import API, Collection 11 | 12 | load_dotenv() 13 | 14 | with API(os.environ["RAINDROP_TOKEN"]) as api: 15 | # Create a new collection.. 16 | title = "abcdef" 17 | print(f"Creating collection: '{title}'...", flush=True, end="") 18 | c = Collection.create(api, title=title) 19 | print("Done.") 20 | 21 | # Update it's title (amongst other possibilities) 22 | title = "12345" 23 | print(f"Updating collection: '{title}'...", flush=True, end="") 24 | c = Collection.update(api, id=c.id, title=title) 25 | print("Done.") 26 | 27 | # Cleanup 28 | print(f"Removing collection: '{title}'...", flush=True, end="") 29 | Collection.delete(api, id=c.id) 30 | print("Done.") 31 | -------------------------------------------------------------------------------- /examples/edit_raindrop.py: -------------------------------------------------------------------------------- 1 | """Edit an existing Raindrop.""" 2 | 3 | import os 4 | import sys 5 | 6 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) 7 | 8 | from dotenv import load_dotenv 9 | 10 | from raindropiopy import API, Raindrop 11 | 12 | load_dotenv() 13 | 14 | with API(os.environ["RAINDROP_TOKEN"]) as api: 15 | # Create a new Raindrop.. 16 | link = "https://www.python.org/" 17 | print(f"Creating Raindrop: '{link}'...", flush=True, end="") 18 | raindrop = Raindrop.create_link(api, link=link, tags=["abc", "def"]) 19 | print(f"Done, title is {raindrop.title}.") 20 | 21 | # Update it's title (amongst other possibilities) 22 | print(f"Updating Raindrop: '{link}'...", flush=True, end="") 23 | raindrop = Raindrop.update( 24 | api, 25 | id=raindrop.id, 26 | title="A Nicer Title for Link to Python.org", 27 | ) 28 | print(f"Done, title is now: {raindrop.title}.") 29 | 30 | # Cleanup 31 | print(f"Removing Raindrop: '{link}'...", flush=True, end="") 32 | Raindrop.delete(api, id=raindrop.id) 33 | print("Done.") 34 | -------------------------------------------------------------------------------- /examples/flasktest/app.py: -------------------------------------------------------------------------------- 1 | """Sample minimal Flask app demonstrating oAuth credential handling. WARNING: COMPLETELY UNTESTED!!!.""" 2 | 3 | # 4 | # Note: per Raindrop's API documentation, if you're only accessing 5 | # your own Raindrop environment, you do not need to do this, the 6 | # simple "TEST_TOKEN" approach is completely sufficient. 7 | # 8 | # If you're building tools allowing other people to access THEIR 9 | # Raindrop environment, you'll need to use the oAuth approach. 10 | # 11 | # Ref: https://developer.raindrop.io/v1/authentication/token 12 | # 13 | import os 14 | from typing import Any 15 | 16 | from dotenv import load_env 17 | from flask import Flask, redirect, render_template_string, request 18 | from requests_oauthlib import OAuth2Session 19 | from werkzeug import Response 20 | 21 | from raindropiopy import API, Collection, URL_AUTHORIZE, URL_ACCESS_TOKEN 22 | 23 | load_env() 24 | 25 | CLIENT_ID = os.environ["RAINDROP_CLIENT_ID"] 26 | CLIENT_SECRET = os.environ["RAINDROP_CLIENT_SECRET"] 27 | REDIRECT_URI = "http://localhost:5000/approved" 28 | 29 | app = Flask(__name__) 30 | 31 | INDEX = """ 32 | 33 | Click here for login. 34 | 35 | """ 36 | 37 | 38 | COLLECTIONS = """ 39 | 40 | 45 | 46 | """ 47 | 48 | 49 | def create_oauth2session(*args: Any, **kwargs: Any) -> OAuth2Session: 50 | """Utility method to use requests obo oauth credential handling.""" 51 | session = OAuth2Session(*args, **kwargs) 52 | # session.register_compliance_hook("access_token_response", update_expires) 53 | # session.register_compliance_hook("refresh_token_response", update_expires) 54 | return session 55 | 56 | 57 | @app.route("/approved") 58 | def approved() -> str: 59 | """Route called upon successful authentication.""" 60 | oauth = create_oauth2session(CLIENT_ID, redirect_uri=REDIRECT_URI) 61 | code = request.args.get("code") 62 | token = oauth.fetch_token( 63 | URL_ACCESS_TOKEN, 64 | code=code, 65 | client_id=CLIENT_ID, 66 | client_secret=CLIENT_SECRET, 67 | include_client_id=True, 68 | ) 69 | 70 | with API(token, client_id=CLIENT_ID, client_secret=CLIENT_SECRET) as cnxn: 71 | collections = Collection.get_root_collections(cnxn) 72 | 73 | return render_template_string(COLLECTIONS, collections=collections) 74 | 75 | 76 | @app.route("/login") 77 | def login() -> Response: 78 | """Route called once credentials have been gathered.""" 79 | oauth = create_oauth2session(CLIENT_ID, redirect_uri=REDIRECT_URI) 80 | authorization_url, _ = oauth.authorization_url(URL_AUTHORIZE) 81 | return redirect(authorization_url) 82 | 83 | 84 | @app.route("/") 85 | def index() -> str: 86 | """Top-level route.""" 87 | return render_template_string(INDEX) 88 | 89 | 90 | if __name__ == "__main__": 91 | app.run(debug=True) 92 | -------------------------------------------------------------------------------- /examples/get_meta.py: -------------------------------------------------------------------------------- 1 | """List the results of the "get_meta" call (primarily num of broken links, last change date and pro-level).""" 2 | 3 | import os 4 | import sys 5 | from pprint import pprint 6 | 7 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) 8 | 9 | from dotenv import load_dotenv 10 | 11 | from raindropiopy import API, SystemCollection 12 | 13 | load_dotenv() 14 | 15 | with API(os.environ["RAINDROP_TOKEN"]) as api: 16 | pprint(SystemCollection.get_meta(api)) 17 | -------------------------------------------------------------------------------- /examples/list_authorised_user.py: -------------------------------------------------------------------------------- 1 | """List the attributes associated with user of the token provided.""" 2 | 3 | import os 4 | import sys 5 | 6 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) 7 | 8 | from dotenv import load_dotenv 9 | 10 | from raindropiopy import API, User 11 | 12 | load_dotenv() 13 | 14 | 15 | def _print(key, value): 16 | print(f"{key:.<48} {value!s}") 17 | 18 | 19 | with API(os.environ["RAINDROP_TOKEN"]) as api: 20 | user = User.get(api) 21 | 22 | for attr in ["id", "email", "full_name", "password", "pro", "registered"]: 23 | _print(attr, getattr(user, attr)) 24 | 25 | # User configuration.. 26 | print() 27 | _print("config.broken_level", user.config.broken_level.value) 28 | _print("config.font_color", user.config.font_color) 29 | _print("config.font_size", user.config.font_size) 30 | _print("config.lang", user.config.lang) 31 | _print("config.last_collection", user.config.last_collection) 32 | _print("config.raindrops_sort", user.config.raindrops_sort) 33 | _print("config.raindrops_view", user.config.raindrops_view) 34 | for ( 35 | attr, 36 | value, 37 | ) in user.config.other.items(): # (user "internal_" use only fields) 38 | _print(f"config.other.{attr}", value) 39 | 40 | # User files.. 41 | print() 42 | _print("files.used", user.files.used) 43 | _print("files.size", user.files.size) 44 | _print("files.last_checkpoint", user.files.last_checkpoint) 45 | 46 | # User group membership 47 | for group in user.groups: 48 | print() 49 | _print("groups.group.title", group.title) 50 | _print("groups.group.title", group.title) 51 | _print("groups.hidden", group.hidden) 52 | _print("groups.sort", group.sort) 53 | _print("groups.collectionids", list(group.collectionids)) 54 | -------------------------------------------------------------------------------- /examples/list_collections.py: -------------------------------------------------------------------------------- 1 | """List all the collections associated with the token from our current environment.""" 2 | 3 | import os 4 | import sys 5 | 6 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) 7 | 8 | from dotenv import load_dotenv 9 | 10 | from raindropiopy import API, Collection 11 | 12 | load_dotenv() 13 | 14 | 15 | def print_collection(collection: Collection) -> None: 16 | """Print the collection, somewhat nicely.""" 17 | fprint = lambda key, value: print(f"{key:.<24} {value!s}") # noqa: E731 18 | 19 | print("---------------------------------------------------") 20 | fprint("id", collection.id) 21 | fprint("title", collection.title) 22 | fprint("parent", collection.parent) 23 | fprint("access.draggable", collection.access.draggable) 24 | fprint("access.level", collection.access.level.value) 25 | fprint("collaborators", collection.collaborators) 26 | fprint("color", collection.color) 27 | fprint("count", collection.count) 28 | fprint("cover", collection.cover) 29 | fprint("created", collection.created) 30 | fprint("expanded", collection.expanded) 31 | fprint("public", collection.public) 32 | fprint("sort", collection.sort) 33 | fprint("user", collection.user.id) 34 | fprint("view", collection.view.value) 35 | fprint("last_update", collection.last_update) 36 | 37 | for attr, value in collection.other.items(): 38 | fprint(f"other.{attr}", value) 39 | 40 | 41 | # Note: we don't distinguish here between "root" and "child" 42 | # collections, instead using the convenience method that collapses the 43 | # two into a single list (use collection.parent to distinguish). 44 | with API(os.environ["RAINDROP_TOKEN"]) as api: 45 | for collection in Collection.get_collections(api): 46 | print_collection(collection) 47 | -------------------------------------------------------------------------------- /examples/list_system_collections.py: -------------------------------------------------------------------------------- 1 | """List the title and count of Raindrops in "system" collections, eg. Unsorted, Trash and All.""" 2 | 3 | import os 4 | import sys 5 | 6 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) 7 | 8 | from dotenv import load_dotenv 9 | 10 | from raindropiopy import API, SystemCollection 11 | 12 | load_dotenv() 13 | 14 | 15 | def _print(collection: SystemCollection) -> None: 16 | print("---------------------------------------------------") 17 | print("id: ", collection.id) 18 | print("title: ", collection.title) 19 | print("count: ", collection.count) 20 | 21 | 22 | with API(os.environ["RAINDROP_TOKEN"]) as api: 23 | for collection in SystemCollection.get_counts(api): 24 | _print(collection) 25 | -------------------------------------------------------------------------------- /examples/list_tags.py: -------------------------------------------------------------------------------- 1 | """List all the tags currently associated with the user of the token provided.""" 2 | 3 | import os 4 | import sys 5 | 6 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) 7 | 8 | from dotenv import load_dotenv 9 | 10 | from raindropiopy import API, Tag 11 | 12 | load_dotenv() 13 | 14 | with API(os.environ["RAINDROP_TOKEN"]) as api: 15 | tags = Tag.get(api) 16 | 17 | print(f"{'Tag':10s} {'Count'}") 18 | print(f"{'='*10} {'='*5:}") 19 | total = 0 20 | for tag in sorted(tags, key=lambda tag: tag.tag): 21 | print(f"{tag.tag:10s} {tag.count:5d}") 22 | total += tag.count 23 | 24 | print(f"{'='*10} {'='*5:}") 25 | print(f"{'Total':10s} {total:5d}") 26 | -------------------------------------------------------------------------------- /examples/sample_bulk_upload_specification.toml: -------------------------------------------------------------------------------- 1 | [[requests]] 2 | collection = "Interesting PDFs" 3 | file_path = "/Users/me/Downloads/ToRaindrop/ASamplePDF.pdf" 4 | title = "A Nice, Descriptive Title" 5 | tags = ["aTag", "AnotherTag"] 6 | 7 | [[requests]] 8 | collection = "News" 9 | url = "https://washingtonpost.com" 10 | title = "Washington Post (Democracy Dies in Darkness)" 11 | tags = ["News", "Opinion"] 12 | -------------------------------------------------------------------------------- /examples/sample_upload_file.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PBorocz/raindrop-io-py/9b63b6e4e4746b13485bcdc75b20358cb7144853/examples/sample_upload_file.pdf -------------------------------------------------------------------------------- /examples/search_raindrop.py: -------------------------------------------------------------------------------- 1 | """Example to show how to search across the Raindrop environment.""" 2 | 3 | import os 4 | import sys 5 | from time import sleep 6 | 7 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) 8 | 9 | from dotenv import load_dotenv 10 | 11 | from raindropiopy import API, Raindrop 12 | 13 | load_dotenv() 14 | 15 | 16 | def _do_search(api, **search_args): 17 | """Search for all Raindrops (in Unsorted since we're not passing in a collection.id).""" 18 | print(f"Searching with {search_args}") 19 | for raindrop in Raindrop.search(api, **search_args): 20 | print(f"Found! {raindrop.id=} {raindrop.title=}\n") 21 | 22 | 23 | with API(os.environ["RAINDROP_TOKEN"]) as api: 24 | # Create a sample Raindrop to be searched for: 25 | link = "https://www.python.org/" 26 | title = "Our Benevolent Dictators Creation" 27 | print("Creating sample Raindrop...", flush=True, end="") 28 | try: 29 | raindrop = Raindrop.create_link( 30 | api, 31 | link=link, 32 | title=title, 33 | tags=["abc", "def"], 34 | ) 35 | print("Done.") 36 | print(f"{raindrop.id=}") 37 | except Exception as exc: 38 | print(f"Sorry, unable to create Raindrop! {exc}") 39 | sys.exit(1) 40 | 41 | # Nothing is instantaneous...be nice to Raindrop and wait a bit... 42 | print( 43 | "Waiting 10 seconds for Raindrop's backend to complete indexing....", 44 | flush=True, 45 | end="", 46 | ) 47 | sleep(10) 48 | print("Ok") 49 | 50 | # OK, now (for example), search by tag: 51 | _do_search(api, search="#def") 52 | 53 | # or, search by link domain or title: 54 | _do_search(api, search="python.org") 55 | _do_search(api, search="Benevolent") 56 | 57 | # Cleanup 58 | print("Removing sample Raindrop...", flush=True, end="") 59 | Raindrop.delete(api, id=raindrop.id) 60 | print("Done.") 61 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | set dotenv-load 2 | 3 | # The list of available targets 4 | default: 5 | @just --list 6 | 7 | ################################################################################ 8 | # Build/Release environment management 9 | ################################################################################ 10 | # Refresh the version of 'manage' we have installed in our venv from github. 11 | refresh_manage: 12 | @poetry remove manage --group dev 13 | @poetry add git+https://github.com/PBorocz/manage --group dev 14 | 15 | ################################################################################ 16 | # Development... 17 | ################################################################################ 18 | # Run the build/release management interface 19 | manage *args: 20 | @manage {{args}} 21 | 22 | # Run tests 23 | test *args: 24 | @python -m pytest {{args}} 25 | 26 | # Build docs 27 | docs *args: 28 | # Note: We don't need to copy this in either a github workflow OR our 'manage' environment for releases 29 | # since ReadTheDocs is configured to auto-rebuild our docs *upon each commit to our trunk branch*! 30 | # This is for local use only to test out documentation updates/changes 31 | sphinx-build -v -W -b html "docs" "docs/_build" {{args}} 32 | 33 | # Pre-commit - Run all 34 | pre-commit-all *args: 35 | @pre-commit run --all-files {{args}} 36 | 37 | # Pre-commit - Update to a new pre-commit configuration and run 38 | pre-commit-update *args: 39 | @pre-commit install 40 | @git add .pre-commit-config.yaml 41 | @just pre-commit-all {{args}} 42 | 43 | # Run fawltydeps to assess package usage vs. installation. 44 | fawltydeps *args: 45 | time fawltydeps --detailed {{args}} 46 | 47 | # In lieu of a formal integration test suite, run samples against live Raindrop environment. 48 | examples: 49 | # Listed in order of complexity, list_* are read-only, rest make changes. 50 | # We try to be nice to Raindrop by resting between each file. 51 | python examples/RUN_ALL.py 52 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | """NOX testing harness.""" 2 | 3 | import nox 4 | 5 | PYTHON_VERSIONS = [ 6 | # Most up-to-date patch versions of each major version we support. 7 | "3.10.14", 8 | "3.11.9", 9 | "3.12.3", 10 | ] 11 | 12 | 13 | @nox.session(python=PYTHON_VERSIONS) 14 | def api_test_suite(session): 15 | """Run all our pytest suite.""" 16 | # Export our package requirements from Poetry to requirements.txt (temporarily): 17 | session.run("poetry", "export", "--format=requirements.txt", "--output=requirements.txt", external=True) 18 | 19 | # Install packages into our .nox venv environment (pytest is already included as we're taking 'dev' packages) 20 | session.run("poetry", "install", external=True) 21 | 22 | # Run our actual test suite.. 23 | session.run("pytest") 24 | 25 | # Cleanup (explicitly use `/bin/rm` to avoid our fish alias of `rm` to `rip`) 26 | session.run("/bin/rm", "-f", "requirements.txt", external=True) 27 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project.urls] 2 | "Homepage" = "https://github.com/PBorocz/raindrop-io-py" 3 | "Documentation" = "https://raindrop-io-py.rtfd.io" 4 | 5 | [project] 6 | name = "raindrop-io-py" 7 | version = "0.4.5" 8 | description = "API for Raindrop.io bookmark manager" 9 | authors = [{name="Peter Borocz "}] 10 | classifiers = ['License :: OSI Approved :: MIT License', 'Programming Language :: Python :: 3'] 11 | include = ["LICENSE",] 12 | keywords = ["python"] 13 | license = "MIT" 14 | readme = "README.md" 15 | homepage = "https://github.com/PBorocz/raindrop-io-py" 16 | documentation = "https://raindrop-io-py.rtfd.io" 17 | packages = [{ include = "raindropiopy" }] 18 | requires-python = ">=3.10,<4.0" 19 | 20 | dependencies = [ 21 | "python-dotenv (>=1.0.0)", 22 | "requests-oauthlib (>=1.3.1)", 23 | "pydantic (>=1.10.4,<3.0)", 24 | "email-validator (>=2.1.0)", 25 | ] 26 | 27 | [tool.poetry.group.dev.dependencies] 28 | pre-commit = "^2.21.0" 29 | requests = "^2.28.2" 30 | vcrpy = "^4.2.1" 31 | fawltydeps = "^0.17.0" 32 | pytest = "^7.4.3" 33 | 34 | [tool.poetry.group.docs.dependencies] 35 | sphinx = "^7.2.6" 36 | sphinx-autobuild = "^2021.3.14" 37 | 38 | # [tool.pytest.ini_options] 39 | # testpaths = ["tests"] 40 | # markers = [ 41 | # "unit: marks tests as runnable all the time, locally and fast.", 42 | # "integration: tests that run against a live Raindrop environment, slower.", 43 | # ] 44 | 45 | # When (if?) we're ready (from https://fpgmaas.github.io/cookiecutter-poetry/) 46 | # [tool.mypy] 47 | # files = ["example_project"] 48 | # disallow_untyped_defs = "True" 49 | # disallow_any_unimported = "True" 50 | # no_implicit_optional = "True" 51 | # check_untyped_defs = "True" 52 | # warn_return_any = "True" 53 | # warn_unused_ignores = "True" 54 | # show_error_codes = "True" 55 | 56 | [tool.ruff] 57 | target-version = "py311" 58 | line-length = 120 59 | lint.select = [ 60 | # Which Ruff suites are we running? 61 | # (presented in order from the Ruff documentation page) 62 | "F", # Pyflakes 63 | "E", "W", # Pycodestyle (Errors and Warnings respectively) 64 | "C90", # Mccabe 65 | "I", # Isort 66 | "D", # Pydocstyle 67 | "UP", # pyupgrade 68 | "N", # pep8-naming 69 | "YTT", # flake8-2020 70 | # "ANN", # flake8-annotations 71 | # "S", # flake8-bandit 72 | # "BLE", # flake8-blind-except 73 | # "FBT", # flake8-boolean-trap 74 | "B", # flake8-bugbear 75 | # "A", # flake8-builtins (NO! We use "id" as an attribute, sue me...) 76 | # "C4", # flake8-comprehensions 77 | "T10", # flake8-debugger 78 | # "EM", # flake8-errmsg 79 | # "ISC", # flake8-implicit-str-concat 80 | # "ICN", # flake8-import-conventions 81 | # "T20", # flake8-print (NO! Removes all print statements!!) 82 | # "PT", # flake8-pytest-style 83 | "Q", # flake8-quotes 84 | # "RET", # flake8-return 85 | # "SIM", # flake8-simplify 86 | # "TID", # flake8-tidy-imports 87 | # "ARG", # flake8-unused-arguments 88 | # "DTZ", # flake8-datetimez 89 | # "ERA", # eradicate 90 | # "PD", # pandas-vet 91 | # "PGH", # pygrep-hooks 92 | # "PLC", "PLE", "PLR", "PLW", # pylint 93 | # "PIE", # flake8-pie 94 | "COM", # flake8-commas 95 | "RUF", # Ruff-specific rules 96 | ] 97 | lint.ignore = [ 98 | "D213", 99 | "E402", 100 | "I001", 101 | "C901", # 'process' is too complex (1 case only) 102 | "N999", # Invalid module name 103 | "COM812", # OBO of ruff format but not sure where this is an issue. 104 | "UP017", # Allow use of datetime.timezone.utc for <=py3.10 compatibility. 105 | ] 106 | 107 | [tool.ruff.lint.pydocstyle] 108 | # For more info, see: 109 | # https://github.com/charliermarsh/ruff#does-ruff-support-numpy--or-google-style-docstrings 110 | convention = "google" 111 | 112 | [tool.ruff.lint.mccabe] 113 | max-complexity = 13 114 | 115 | [tool.vulture] 116 | min_confidence = 80 117 | paths = ["raindropiopy", "examples", "vulture_whitelist.py"] 118 | 119 | [build-system] 120 | requires = ["poetry-core>=1.0.0", "setuptools"] 121 | build-backend = "poetry.core.masonry.api" 122 | 123 | # ############################################################################## 124 | # PoeThePoet 125 | # ############################################################################## 126 | [tool.poe] 127 | verbosity = 2 128 | 129 | [tool.poe.tasks] 130 | #------------------------------------------------------------------------------- 131 | # 0: GENERAL Tasks 132 | #------------------------------------------------------------------------------- 133 | NOX = "nox --reuse-venv yes" 134 | 135 | #------------------------------------------------------------------------------- 136 | # 1: Version update (aka "bump") 137 | #------------------------------------------------------------------------------- 138 | VERSION = [ 139 | "_poetry_version", 140 | "_update_readme_version", 141 | "_git_add_version", 142 | "_git_commit", 143 | ] 144 | _update_readme_version = "update_readme_version" # Uses pyproject.toml to update README.md 145 | _git_add_version = "git add pyproject.toml README.md" 146 | _git_commit = "git commit -m 'Bump step commit (from poe)'" 147 | 148 | #------------------------------------------------------------------------------- 149 | # 2: Build 150 | #------------------------------------------------------------------------------- 151 | BUILD = [ 152 | "_clean", 153 | "_pre_commit", 154 | "_poetry_lock_check", 155 | "_poetry_lock_update", 156 | "_poetry_build", 157 | ] 158 | _clean = "rm -rf build *.egg-info" 159 | _pre_commit = "pre-commit run --all-files" 160 | _poetry_lock_check = "poetry check --lock" 161 | _poetry_lock_update = "poetry lock" 162 | _poetry_build = "poetry build" 163 | 164 | #------------------------------------------------------------------------------- 165 | # 3: Release 166 | #------------------------------------------------------------------------------- 167 | RELEASE = [ 168 | "_git_create_tag", 169 | "_git_push_to_github", 170 | "_publish_to_pypi", 171 | "_git_create_release", 172 | ] 173 | _git_push_to_github = "git push --follow-tags" 174 | _publish_to_pypi = "poetry publish" 175 | 176 | #------------------------------------------------------------------------------- 177 | # Support targets. 178 | #------------------------------------------------------------------------------- 179 | [tool.poe.tasks._poetry_version] 180 | shell = "poetry version $bump_level" 181 | 182 | [[tool.poe.tasks._poetry_version.args]] 183 | name = "bump_level" 184 | help = "The semantic version to push new version to, eg. patch, minor, major etc." 185 | default = "patch" 186 | 187 | [tool.poe.tasks._git_create_tag] 188 | interpreter = "fish" 189 | shell = """ 190 | set -l release_version (grep "version =" pyproject.toml | head -1 | awk -F'\"' '{print $2}') 191 | git tag -a "$release_version" --message "$release_version" 192 | """ 193 | 194 | [tool.poe.tasks._git_create_release] 195 | interpreter = "fish" 196 | shell = """ 197 | set -l release_version (grep "version =" pyproject.toml | head -1 | awk -F'\"' '{print $2}') 198 | gh release create "$release_version" --title "$release_version" 199 | """ 200 | -------------------------------------------------------------------------------- /raindropiopy/__init__.py: -------------------------------------------------------------------------------- 1 | """Top level project __init__.""" 2 | 3 | from importlib import metadata 4 | 5 | __all__ = ( 6 | "API", 7 | "Access", 8 | "AccessLevel", 9 | "BrokenLevel", 10 | "Collection", 11 | "CollectionRef", 12 | "FontColor", 13 | "Group", 14 | "Raindrop", 15 | "RaindropSort", 16 | "RaindropType", 17 | "SystemCollection", 18 | "Tag", 19 | "User", 20 | "UserConfig", 21 | "UserFiles", 22 | "UserRef", 23 | "View", 24 | "version", 25 | ) 26 | 27 | from .api import API 28 | from .models import ( 29 | Access, 30 | AccessLevel, 31 | BrokenLevel, 32 | Collection, 33 | CollectionRef, 34 | FontColor, 35 | Group, 36 | Raindrop, 37 | RaindropSort, 38 | RaindropType, 39 | SystemCollection, 40 | Tag, 41 | User, 42 | UserConfig, 43 | UserFiles, 44 | UserRef, 45 | View, 46 | ) 47 | 48 | 49 | def version(): 50 | """Return the canonical version from pyproject.toml used to package this particular release. 51 | 52 | Our version number only appears in a single, canonical location: pyproject.toml. Thus, we 53 | supply this utility method to make it visible within code. 54 | """ 55 | return metadata.version("raindrop_io_py") 56 | -------------------------------------------------------------------------------- /raindropiopy/api.py: -------------------------------------------------------------------------------- 1 | """Low-level API interface to Raindrop, no application semantics, mostly core HTTP verbs. 2 | 3 | Except for instantiating, methods in this class are **not** intended for direct use, they serve as the underlying HTTPS 4 | abstraction layer for calls available for the Core Classes, ie. Collection, Raindrop etc. 5 | """ 6 | 7 | import datetime 8 | import enum 9 | import json 10 | from pathlib import Path 11 | from typing import Any, Final, TypeVar 12 | 13 | import requests 14 | from requests_oauthlib import OAuth2Session 15 | 16 | # Support for generic oauth2 authentication *on behalf of another user* 17 | # (ie. instead of using Raindrop.IO's TEST_TOKEN) 18 | URL_AUTHORIZE: Final = "https://raindrop.io/oauth/authorize" 19 | URL_ACCESS_TOKEN: Final = "https://raindrop.io/oauth/access_token" 20 | URL_REFRESH: Final = "https://raindrop.io/oauth/access_token" 21 | 22 | # In py3.11, we'll be able to do 'from typing import Self' instead 23 | T_API = TypeVar("API") 24 | 25 | 26 | class API: 27 | """Provides communication to the Raindrop.io API server. 28 | 29 | Parameters: 30 | token: Either a string representing a valid RaindropIO Token. 31 | This is either a TEST_TOKEN or a CLIENT token for use in OAuth in 32 | which case the client_id and client_secret must also be provided. 33 | 34 | token_type: Token type to be used on behalf of an oAuth connection. 35 | 36 | Examples: 37 | Can either be used directly as a context manager: 38 | 39 | >>> api = API(token="yourTestTokenFromRaindropIO"): 40 | >>> collections = Collection.get_collections(api) 41 | >>> # ... 42 | 43 | or 44 | 45 | >>> with API(token="yourTestTokenFromRaindropIO") as api: 46 | >>> user = User.get(api) 47 | >>> # ... 48 | """ 49 | 50 | def __init__( 51 | self, 52 | token: str, 53 | client_id: str | None = None, 54 | client_secret: str | None = None, 55 | token_type: str = "Bearer", 56 | ) -> None: 57 | """Instantiate an API connection to Raindrop using the token (and optional client information) provided.""" 58 | self.token = token 59 | self.client_id = client_id 60 | self.client_secret = client_secret 61 | self.token_type = token_type 62 | self.session = None 63 | 64 | # If rate limiting is in effect, set here (UNTESTED!) 65 | self.ratelimit: int | None = None 66 | self.ratelimit_remaining: int | None = None 67 | self.ratelimit_reset: int | None = None 68 | 69 | self.open() 70 | 71 | def _create_session(self) -> OAuth2Session: 72 | """Handle the creation and/or authentication with oAuth handshake.""" 73 | extra: dict[str, Any] | None 74 | if self.client_id and self.client_secret: 75 | extra = { 76 | "client_id": self.client_id, 77 | "client_secret": self.client_secret, 78 | } 79 | else: 80 | extra = None 81 | 82 | def update_token(newtoken: str) -> None: 83 | self.token = newtoken 84 | 85 | token = {"access_token": self.token} if isinstance(self.token, str) else self.token 86 | 87 | return OAuth2Session( 88 | self.client_id, 89 | token=token, 90 | auto_refresh_kwargs=extra, 91 | auto_refresh_url=URL_REFRESH, 92 | token_updater=update_token, 93 | ) 94 | 95 | def open(self) -> None: 96 | """Open a new connection to Raindrop. 97 | 98 | If there's an existing connection already, it'll be closed first. 99 | """ 100 | self.close() 101 | self.session = self._create_session() 102 | 103 | def close(self) -> None: 104 | """Close an existing Raindrop connection. 105 | 106 | Safe to call even if a new session hasn't been created yet. 107 | """ 108 | if self.session: 109 | self.session.close() 110 | self.session = None 111 | 112 | def _json_unknown(self, obj: Any) -> Any: 113 | if isinstance(obj, enum.Enum): 114 | return obj.value 115 | if isinstance(obj, datetime.datetime): 116 | return obj.isoformat() 117 | raise TypeError( 118 | f"Object of type {obj.__class__.__name__} is not JSON serializable", 119 | ) 120 | 121 | def _to_json(self, obj: Any) -> str | None: 122 | """Handle JSON serialisation with a custom serialiser to handle enums and datetimes.""" 123 | if obj is not None: 124 | return json.dumps(obj, default=self._json_unknown) 125 | else: 126 | return None 127 | 128 | def _on_resp(self, resp: Any) -> None: 129 | """Handle a RaindropIO API response, first pulling rate-limiting parms in effect due to high activity levels.""" 130 | 131 | def get_int(name: str) -> int | None: 132 | value = resp.headers.get(name, None) 133 | if value is not None: 134 | return int(value) 135 | return None 136 | 137 | v = get_int("X-RateLimit-Limit") 138 | if v is not None: 139 | self.ratelimit = v 140 | 141 | v = get_int("X-RateLimit-Remaining") 142 | if v is not None: 143 | self.ratelimit_remaining = v 144 | 145 | v = get_int("X-RateLimit-Reset") 146 | if v is not None: 147 | self.ratelimit_reset = v 148 | 149 | resp.raise_for_status() # Let requests library handle HTTP error codes returned. 150 | 151 | def _request_headers_json(self) -> dict[str, str]: 152 | return { 153 | "Content-Type": "application/json", 154 | } 155 | 156 | def get( 157 | self, 158 | url: str, 159 | params: dict[Any, Any] | None = None, 160 | ) -> requests.models.Response: 161 | """Send a GET request. 162 | 163 | Parameters: 164 | url: The url to send the request to. 165 | 166 | params: Optional dictionary of payload to be sent for the :class:`Request`. 167 | 168 | Returns: 169 | :class:`requests.Response` object. 170 | """ 171 | assert self.session 172 | ret = self.session.get(url, headers=self._request_headers_json(), params=params) 173 | self._on_resp(ret) 174 | return ret 175 | 176 | def put(self, url: str, json: Any = None) -> requests.models.Response: 177 | """Low-level call to perform a PUT method against our present connection. 178 | 179 | Parameters: 180 | url: The url to send the PUT request to. 181 | 182 | json: JSON object to be sent. 183 | 184 | Returns: 185 | :class:`requests.Response` object. 186 | """ 187 | json = self._to_json(json) 188 | 189 | assert self.session 190 | ret = self.session.put(url, headers=self._request_headers_json(), data=json) 191 | self._on_resp(ret) 192 | return ret 193 | 194 | def put_file( 195 | self, 196 | url: str, 197 | path: Path, 198 | data: dict, 199 | files: dict, 200 | ) -> requests.models.Response: 201 | """Upload a file by a PUT request. 202 | 203 | Parameters: 204 | url: The url to send the PUT request to. 205 | 206 | path: Path to file to be uploaded. 207 | 208 | data: Dictionary, payload to be sent for the :class:`Request`, e.g. {"collectionId" : aCollection.id} 209 | 210 | files: Dictionary, request library "files" object to be sent for the :class:`Request`, 211 | e.g. {'file': (aFileName, aFileLikeObj, aContentType)} 212 | 213 | Returns: 214 | :class:`requests.Response` object. 215 | """ 216 | assert self.session 217 | ret = self.session.put(url, data=data, files=files) 218 | self._on_resp(ret) 219 | return ret 220 | 221 | def post(self, url: str, json: Any = None) -> requests.models.Response: 222 | """Low-level call to perform a POST method against our present connection. 223 | 224 | Parameters: 225 | url: The url to send the POST request to. 226 | 227 | json: JSON object to be sent. 228 | 229 | Returns: 230 | :class:`requests.Response` object. 231 | """ 232 | json = self._to_json(json) 233 | assert self.session 234 | ret = self.session.post(url, headers=self._request_headers_json(), data=json) 235 | self._on_resp(ret) 236 | return ret 237 | 238 | def delete(self, url: str, json: Any = None) -> requests.models.Response: 239 | """Low-level call to perform a DELETE method against our present connection. 240 | 241 | Parameters: 242 | url: The url to send the DELETE request to. 243 | 244 | json: JSON object to be sent. 245 | 246 | Returns: 247 | :class:`requests.Response` object. 248 | """ 249 | json = self._to_json(json) 250 | 251 | assert self.session 252 | ret = self.session.delete(url, headers=self._request_headers_json(), data=json) 253 | self._on_resp(ret) 254 | return ret 255 | 256 | def __enter__(self) -> T_API: # Note: Py3.11 upgrade to "self" 257 | """Context manager use: if we don't have an active session open yet, open one!.""" 258 | if not self.session: 259 | self.open() 260 | return self 261 | 262 | def __exit__(self, _type, _value, _traceback) -> None: # type: ignore 263 | """Context manager use: once we're done with this API's scope, close connection off.""" 264 | if self.session: 265 | self.close() 266 | -------------------------------------------------------------------------------- /raindropiopy/models.py: -------------------------------------------------------------------------------- 1 | """All data classes to interact with Raindrops API. 2 | 3 | Raindrop.IO has a small set of core data entities (e.g. Raindrops aka bookmarks, Collections, Tags etc.). We 4 | deliver the services provided by Raindrop.IO as a set of class-based methods on these various data entities. 5 | 6 | For example, to create a new raindrop, use Raindrop.create_link(...); a collection would be Collection.create(...) etc. 7 | 8 | """ 9 | 10 | from __future__ import annotations 11 | 12 | import enum 13 | from datetime import datetime 14 | from pathlib import Path 15 | from typing import Any 16 | 17 | import requests 18 | from pydantic import ( 19 | BaseModel, 20 | EmailStr, 21 | Field, 22 | HttpUrl, 23 | NonNegativeInt, 24 | PositiveInt, 25 | root_validator, 26 | validator, 27 | ) 28 | 29 | from .api import T_API # ie. for typing only... 30 | 31 | __all__ = [ 32 | "Access", 33 | "AccessLevel", 34 | "BrokenLevel", 35 | "Collection", 36 | "CollectionRef", 37 | "FontColor", 38 | "Group", 39 | "Raindrop", 40 | "RaindropSort", 41 | "RaindropType", 42 | "Tag", 43 | "User", 44 | "UserFiles", 45 | "UserRef", 46 | "View", 47 | ] 48 | 49 | # Base URL for Raindrop IO's API 50 | URL = "https://api.raindrop.io/rest/v1/{path}" 51 | 52 | 53 | ################################################################################ 54 | # Utility methods 55 | ################################################################################ 56 | def _collect_other_attributes(cls, v): 57 | """Gather all non-recognised/unofficial non-empty attribute values into a single one.""" 58 | skip_attrs = "_id" # We don't need to store alias attributes again (pydantic will take care of) 59 | v["other"] = dict() 60 | for attr, value in v.items(): 61 | if value and attr not in cls.__fields__ and attr not in skip_attrs: 62 | v["other"][attr] = value 63 | return v 64 | 65 | 66 | def _resolve_parent_reference(parent_reference: dict | int | None) -> int | None: 67 | """Convert a Raindrop.IO parent reference dict to just the respective ID of the parent collection. 68 | 69 | For a child collection that has a parent, the reference to the parent received from Raindrop.IO is: 70 | 71 | {"$id": 12345678, "$ref": 'collections'}. 72 | 73 | We don't need the $ref part (at least I don't believe so), so simply pull the $id key. 74 | """ 75 | if parent_reference is None: 76 | return None 77 | elif isinstance(parent_reference, int): 78 | return parent_reference 79 | return parent_reference.get("$id") 80 | 81 | 82 | ################################################################################ 83 | # Enumerated types 84 | ################################################################################ 85 | class AccessLevel(enum.IntEnum): 86 | """Map the Access levels defined by Raindrop's API.""" 87 | 88 | readonly = 1 89 | collaborator_read = 2 90 | collaborator_write = 3 91 | owner = 4 92 | 93 | 94 | class CacheStatus(enum.Enum): 95 | """Represents the various states the cache of a Raindrop might be in.""" 96 | 97 | ready = "ready" 98 | retry = "retry" 99 | failed = "failed" 100 | invalid_origin = "invalid-origin" 101 | invalid_timeout = "invalid-timeout" 102 | invalid_size = "invalid-size" 103 | 104 | 105 | class View(enum.Enum): 106 | """Map the names of the views for Raindrop's API.""" 107 | 108 | list = "list" 109 | simple = "simple" 110 | grid = "grid" 111 | masonry = "masonry" 112 | 113 | 114 | class BrokenLevel(enum.Enum): 115 | """Enumerate user levels.""" 116 | 117 | basic = "basic" 118 | default = "default" 119 | strict = "strict" 120 | off = "off" 121 | 122 | 123 | class FontColor(enum.Enum): 124 | """Enumerate user display themes available.""" 125 | 126 | sunset = "sunset" 127 | night = "night" 128 | 129 | 130 | class RaindropSort(enum.Enum): 131 | """Enumerate Raindrop sort options available.""" 132 | 133 | created_up = "created" 134 | created_dn = "-created" 135 | title_up = "title" 136 | title_dn = "-title" 137 | domain_up = "domain" 138 | domain_dn = "-domain" 139 | last_update_up = "+lastUpdate" 140 | last_update_dn = "-lastUpdate" 141 | 142 | 143 | class RaindropType(enum.Enum): 144 | """Map the types of Raindrop bookmarks possible (ie. what type of content they hold).""" 145 | 146 | link = "link" 147 | article = "article" 148 | image = "image" 149 | video = "video" 150 | document = "document" 151 | audio = "audio" 152 | 153 | 154 | ################################################################################ 155 | # Base Models 156 | ################################################################################ 157 | class CollectionRef(BaseModel): 158 | """Represents a **reference** to a Raindrop Collection (essentially a TypeVar of id: int). 159 | 160 | Note: We also instantiate three particular ``CollectionRefs`` associated with **System** Collections: 161 | *All*, *Trash* and *Unsorted*. 162 | 163 | System Collections always exist and can be explicitly used to query anywhere you'd use a Collection ID. 164 | 165 | """ 166 | 167 | id: int = Field(None, alias="$id") 168 | 169 | 170 | # We define the 3 "system" collections in the Raindrop environment: 171 | CollectionRef.All = CollectionRef( 172 | **{"$id": 0}, 173 | ) # Note: "all" here does NOT include Trash. 174 | CollectionRef.Trash = CollectionRef(**{"$id": -99}) 175 | CollectionRef.Unsorted = CollectionRef(**{"$id": -1}) 176 | 177 | 178 | class UserRef(BaseModel): 179 | """Represents a **reference** to `User` object.""" 180 | 181 | id: int = Field(None, alias="$id") 182 | ref: str = Field(None, alias="$user") 183 | 184 | 185 | class Access(BaseModel): 186 | """Represents Access control level of a `Collection`.""" 187 | 188 | level: AccessLevel 189 | draggable: bool 190 | 191 | 192 | class Collection(BaseModel): 193 | """Represents a Raindrop `Collection`, ie. a group of Raindrop Bookmarks. 194 | 195 | Attributes: 196 | id: The id of the collection (required) 197 | title: The name of the collection. 198 | user: The user who created the collection. 199 | access: Describes current Access levels to the collection (eg. ReadOnly, OwnerOnly etc.). 200 | collaborators: Populated with list of collaborating users iff collection is shared. 201 | color: Primary color of the collection cover. 202 | count: Count of Raindrops in the collection. 203 | cover: URL of the collection's cover. 204 | created: When the collection was created. 205 | expanded: Whether the collection's sub-collection are expanded (on the interface) 206 | last_update: When the collection was last updated. 207 | parent: Parent ID of this is a sub-collection. 208 | public: Are contents of this collection available to non-authenticated users? 209 | sort: The order of the collection. Defines the position of the collection against 210 | all other collections at the same level in the tree (only used for sub-collections?) 211 | view: Current view style of the collection, e.g. list, simple, grid etc. 212 | other: All other attributes received from Raindrop's API (see Warning below) 213 | 214 | Warning: 215 | Attributes in `other` are *NOT* OFFICIALLY SUPPORTED...use at your own risk! 216 | """ 217 | 218 | id: int = Field(None, alias="_id") 219 | title: str 220 | user: UserRef 221 | 222 | access: Access | None 223 | collaborators: list[Any] | None = Field(default_factory=list) 224 | color: str | None = None 225 | count: NonNegativeInt 226 | cover: list[str] | None = Field(default_factory=list) 227 | created: datetime | None 228 | expanded: bool = False 229 | last_update: datetime | None 230 | parent: int | None # Id of parent collection (if any) 231 | public: bool | None 232 | sort: int | None 233 | view: View | None 234 | 235 | # Per API Doc: "Our API response could contain other fields, not described above. 236 | # It's unsafe to use them in your integration! They could be removed or renamed at any time." 237 | other: dict[str, Any] = {} 238 | 239 | # Used to convert parent reference's of sub-collections to simply id's of the respective parent collection. 240 | _extract_parent_id = validator("parent", pre=True, allow_reuse=True)( 241 | _resolve_parent_reference, 242 | ) 243 | 244 | @root_validator(pre=True) 245 | # FIXME: noqa here is because work-around in https://github.com/pydantic/pydantic/issues/568 doesn't work! 246 | def _validator(cls, v): # noqa: N805 247 | """Gather all non-recognised/unofficial attributes into a single attribute.""" 248 | return _collect_other_attributes(cls, v) 249 | 250 | @classmethod 251 | def get_root_collections(cls, api: T_API) -> list[Collection]: 252 | """Get **root** Raindrop collections. 253 | 254 | Args: 255 | api: API Handle to use for the request. 256 | 257 | Returns: 258 | The (potentially empty) list of non-system, **top-level** Collections associated with the API's user. 259 | 260 | Note: 261 | Since Raindrop allows for collections to be nested, the RaindropIO's API distinguishes between Collections 262 | at the top-level/root of a collection hierarchy versus those all that are below the top level, aka 'child' 263 | or 'sub' collections. Thus, use ``get_root_collections`` to get all Collections without parents and 264 | ``get_child_collections`` for all Collections with parents. 265 | """ 266 | ret = api.get(URL.format(path="collections")) 267 | items = ret.json()["items"] 268 | return [cls(**item) for item in items] 269 | 270 | @classmethod 271 | def get_child_collections(cls, api: T_API) -> list[Collection]: 272 | """Get the **child** Raindrop collections (ie. all below root level). 273 | 274 | Args: 275 | api: API Handle to use for the request. 276 | 277 | Returns: 278 | The (potentially empty) list of non-system, **non-top-level** Collections associated with the API's user. 279 | 280 | Note: 281 | Since Raindrop allows for collections to be nested, the RaindropIO's API distinguishes between Collections 282 | at the top-level/root of a collection hierarchy versus those all that are below the top level, aka 'child' 283 | collections. Thus, use ``get_root_collections`` to get all Collections without parents and 284 | ``get_child_collections`` for all Collections with parents. 285 | """ 286 | ret = api.get(URL.format(path="collections/childrens")) 287 | items = ret.json()["items"] 288 | return [cls(**item) for item in items] 289 | 290 | @classmethod 291 | def get_collections(cls, api: T_API) -> list[Collection]: 292 | """Query for all non-system collections (essentially a convenience wrapper, combining root & child Collections). 293 | 294 | Args: 295 | api: API Handle to use for the request. 296 | 297 | Returns: 298 | The (potentially empty) list of all **non-system** Collections associated with the API's user, 299 | ie. hiding the distinction between root/child collections. 300 | """ 301 | return cls.get_root_collections(api) + cls.get_child_collections(api) 302 | 303 | @classmethod 304 | def get(cls, api: T_API, id: int) -> Collection: 305 | """Return a Raindrop Collection instance based on it's id. 306 | 307 | Args: 308 | api: API Handle to use for the request. 309 | 310 | id: Id of Collection to query for. 311 | 312 | Returns: 313 | ``Collection`` instance associated with the id provided. 314 | 315 | Raises: 316 | HTTPError: If the id provided could not be found (specifically 404) 317 | """ 318 | url = URL.format(path=f"collection/{id}") 319 | item = api.get(url).json()["item"] 320 | return cls(**item) 321 | 322 | @classmethod 323 | def create( 324 | cls, 325 | api: T_API, 326 | title: str, 327 | cover: list[str] | None = None, 328 | expanded: bool | None = None, 329 | parent: int | None = None, 330 | public: bool | None = None, 331 | sort: int | None = None, 332 | view: View | None = None, 333 | ) -> Collection: 334 | """Create a new Raindrop collection. 335 | 336 | Args: 337 | api: Required: API Handle to use for the request. 338 | 339 | cover: Optional, URL of collection's cover (as a list but only the first entry is used). 340 | 341 | expanded: Optional, flag for whether or not any of the collection's sub-collections are expanded. 342 | 343 | parent: Optional, Id of the collection's **parent** you want to create nested collections. 344 | 345 | public: Optional, flag for whether or not the collection should be publically available. 346 | 347 | sort: Optional, sort order for Raindrops created in this collection. 348 | 349 | title: Required: Title of the collection to be created. 350 | 351 | view: Optional, View associated with the default view to display Raindrops in this collection. 352 | 353 | Returns: 354 | ``Collection`` instance created. 355 | """ 356 | args: dict[str, Any] = {} 357 | if cover is not None: 358 | args["cover"] = cover 359 | if expanded is not None: 360 | args["expanded"] = cover 361 | if parent is not None: 362 | args["parent"] = parent 363 | if public is not None: 364 | args["public"] = public 365 | if sort is not None: 366 | args["sort"] = sort 367 | if title is not None: 368 | args["title"] = title 369 | if view is not None: 370 | args["view"] = view 371 | 372 | url = URL.format(path="collection") 373 | item = api.post(url, json=args).json()["item"] 374 | return cls(**item) 375 | 376 | @classmethod 377 | def update( 378 | cls, 379 | api: T_API, 380 | id: int, 381 | cover: list[str] | None = None, 382 | expanded: bool | None = None, 383 | parent: int | None = None, 384 | public: bool | None = None, 385 | sort: int | None = None, 386 | title: str | None = None, 387 | view: View | None = None, 388 | ) -> Collection: 389 | """Update an existing Raindrop collection with any of the attribute values provided. 390 | 391 | Args: 392 | api: API Handle to use for the request. 393 | 394 | id: Required, Id of Collection to be updated. 395 | 396 | cover: URL of collection's cover (as a list but only the first entry is used). 397 | 398 | expanded: Flag for whether or not any of the collection's sub-collections are expanded. 399 | 400 | parent: Id of the collection's **parent** to set the current collection to. 401 | 402 | public: Flag for whether or not the collection should be publically available. 403 | 404 | sort: Sort order for Raindrops created in this collection. 405 | 406 | title: New collection title. 407 | 408 | view: View enum associated with the default view to display Raindrops in this collection. 409 | 410 | Returns: 411 | Updated ``Collection`` instance. 412 | """ 413 | args: dict[str, Any] = {} 414 | for attr in ["expanded", "view", "title", "sort", "public", "parent", "cover"]: 415 | if (value := locals().get(attr)) is not None: 416 | args[attr] = value 417 | url = URL.format(path=f"collection/{id}") 418 | item = api.put(url, json=args).json()["item"] 419 | return cls(**item) 420 | 421 | @classmethod 422 | def delete(cls, api: T_API, id: int) -> None: 423 | """Delete a Raindrop collection. 424 | 425 | Args: 426 | api: API Handle to use for the request. 427 | 428 | id: Id of Collection to be deleted. 429 | 430 | Returns: 431 | None. 432 | """ 433 | api.delete(URL.format(path=f"collection/{id}"), json={}) 434 | 435 | @classmethod 436 | def get_or_create(cls, api: T_API, title: str) -> Collection: 437 | """Get a Raindrop collection based on it's **title**, if it doesn't exist, create it. 438 | 439 | Args: 440 | api: API Handle to use for the request. 441 | 442 | title: Title of the collection. 443 | 444 | Returns: 445 | Collection with the specified collection title if it already exists or newly created 446 | collection if it doesn't. 447 | """ 448 | for collection in Collection.get_root_collections(api): 449 | if title.casefold() == collection.title.casefold(): 450 | return collection 451 | 452 | # Doesn't exist, create it! 453 | return Collection.create(api, title=title) 454 | 455 | 456 | class Group(BaseModel): 457 | """Sub-model defining a Raindrop user group.""" 458 | 459 | title: str 460 | hidden: bool 461 | sort: NonNegativeInt 462 | collectionids: list[int] = Field(None, alias="collections") 463 | 464 | 465 | class UserConfig(BaseModel): 466 | """Sub-model defining a Raindrop user's configuration. 467 | 468 | Warning: 469 | Attributes in `other` are NOT OFFICIALLY SUPPORTED!. 470 | """ 471 | 472 | broken_level: BrokenLevel = None 473 | font_color: FontColor | None = None 474 | font_size: int | None = None 475 | lang: str | None = None 476 | last_collection: CollectionRef | None = None 477 | raindrops_sort: RaindropSort | None = None 478 | raindrops_view: View | None = None 479 | 480 | # Per API Doc: "Our API response could contain other fields, not described above. 481 | # It's unsafe to use them in your integration! They could be removed or renamed at any time." 482 | other: dict[str, Any] = {} 483 | 484 | @validator("last_collection", pre=True) 485 | def cast_last_collection_to_ref(cls, v): # noqa: N805 486 | """Cast last_collection provided as a raw int to a valid CollectionRef.""" 487 | return CollectionRef(**{"$id": v}) 488 | 489 | @root_validator(pre=True) 490 | def _validator_other_attributes(cls, v): # noqa: N805 491 | """Gather all non-recognised/unofficial attributes into a single attribute.""" 492 | return _collect_other_attributes(cls, v) 493 | 494 | 495 | class UserFiles(BaseModel): 496 | """Sub-model defining a file associated with a user (?).""" 497 | 498 | used: int 499 | size: PositiveInt 500 | last_checkpoint: datetime = Field(None, alias="lastCheckpoint") 501 | 502 | 503 | class User(BaseModel): 504 | """Raindrop User model.""" 505 | 506 | id: int = Field(None, alias="_id") 507 | email: EmailStr 508 | email_md5: str | None = Field(None, alias="email_MD5") 509 | files: UserFiles 510 | full_name: str = Field(None, alias="fullName") 511 | groups: list[Group] 512 | password: bool 513 | pro: bool 514 | pro_expire: datetime | None = Field(None, alias="proExpire") 515 | registered: datetime 516 | config: UserConfig 517 | 518 | @classmethod 519 | def get(cls, api: T_API) -> User: 520 | """Get all the information about the Raindrop user associated with the API token.""" 521 | user = api.get(URL.format(path="user")).json()["user"] 522 | return cls(**user) 523 | 524 | 525 | class SystemCollection(BaseModel): 526 | """Raindrop **System** collection model, ie. collections for *Unsorted*, *Trash* and *All*. 527 | 528 | Note: 529 | - The *All* collection contains **all** currently active (ie. non-Trash) Raindrops held by the User. 530 | 531 | - The *Unsorted* collection contains Raindrops created that are **not** held within any other collection. 532 | 533 | - The *Trash* collection contains Raindrops that have been recently deleted. 534 | 535 | You won't use this class directly on behalf of individual Raindrops, rather, its definition is on behalf of 536 | a small set of simple "status" calls available from the Raindrop.io API, specifically `get_counts` and `get_meta`. 537 | """ 538 | 539 | id: int = Field(None, alias="_id") 540 | count: NonNegativeInt 541 | title: str | None 542 | 543 | @root_validator(pre=False) 544 | def _validator(cls, values): # noqa: N805 545 | """Map the hard-coded id's of the System Collections to the descriptions used on the UI.""" 546 | _titles = { 547 | CollectionRef.Unsorted.id: "Unsorted", 548 | CollectionRef.All.id: "All", 549 | CollectionRef.Trash.id: "Trash", 550 | } 551 | values["title"] = _titles.get(values["id"]) 552 | return values 553 | 554 | @classmethod 555 | def get_counts(cls, api: T_API) -> list[Collection]: 556 | """Get the count of Raindrops in each of the 3 *system* collections.""" 557 | items = api.get(URL.format(path="user/stats")).json()["items"] 558 | return [cls(**item) for item in items] 559 | 560 | @classmethod 561 | def get_meta(cls, api: T_API) -> dict: 562 | """Get the 'meta' slug from the root/system Collection. 563 | 564 | Contains information about: 565 | 566 | - Last date/time any bookmark was changed. 567 | 568 | - The number of broken links in bookmarks. 569 | 570 | - If your account is a "pro" level. 571 | """ 572 | return api.get(URL.format(path="user/stats")).json()["meta"] 573 | 574 | 575 | class File(BaseModel): 576 | """Represents the attributes associated with a file within a document-based Raindrop.""" 577 | 578 | name: str 579 | size: PositiveInt 580 | type: str 581 | 582 | 583 | class Cache(BaseModel): 584 | """Represents the cache information of Raindrop.""" 585 | 586 | # Per issue #5, we can't rely on Raindrop to always return a non-zero value for `size`, thus 587 | # instead of `PositiveInt`, we use `int`. 588 | status: CacheStatus 589 | size: int | None = None 590 | created: datetime | None = None 591 | 592 | 593 | class Raindrop(BaseModel): 594 | """Core class of a Raindrop bookmark 'item'. 595 | 596 | A Raindrop/bookmark can be of two major types: 597 | 598 | - A **link-based** one, ie. a standard "bookmark" that points to a specific URL (in the link attribute). 599 | 600 | - A **file-based** one, into which a file (of the approved type) is uploaded and stored on the 601 | Raindrop service (details of which are in the file attribute). 602 | 603 | Attributes: 604 | id: The id of the Raindrop. 605 | collection: Collection (or CollectionRef) this Raindrop currently resides in. 606 | cover: The URL of the Raindrop's cover. 607 | created: The creation datetime of the Raindrop. 608 | domain: Hostname of a link, ie. if a Raindrop has link: `https://www.google.com?search=SomeThing`, 609 | domain is `www.google.com`. 610 | excerpt: Description associated with this Raindrop (maximum length: 10k!) 611 | last_update: When this Raindrop was last updated. 612 | link: For a link-based Raindrop, the full URL. 613 | media: Covers list. 614 | tags: A list of Tags associated with the Raindrop. 615 | title: The title of the Raindrop (maximum length: 1k). 616 | type: The type of the Raindrop, e.g. *link*, *document* (I haven't tested other types) 617 | user: The user who created the Raindrop. 618 | broken: True of the link associated with the Raindrop is not reachable anymore. 619 | cache: Details of the permanent cache associated with the Raindrop. 620 | file: Details of the file associated with a **file** based Raindrop. 621 | important: True if this Raindrop is marked as a **Favorite**. 622 | other: All other attributes received from Raindrop's API. 623 | 624 | Warning: 625 | Attributes in `other` are NOT OFFICIALLY SUPPORTED!. 626 | """ 627 | 628 | # "Main" fields (per https://developer.raindrop.io/v1/raindrops) 629 | id: int = Field(None, alias="_id") 630 | collection: Collection | CollectionRef = CollectionRef.Unsorted 631 | cover: str | None 632 | created: datetime | None 633 | domain: str | None 634 | excerpt: str | None # aka 'Description' on the Raindrop UI. 635 | file: File | None 636 | last_update: datetime | None = Field(None, alias="lastUpdate") 637 | link: HttpUrl | None 638 | media: list[dict[str, Any]] | None 639 | tags: list[str] | None 640 | title: str | None 641 | type: RaindropType | None 642 | user: UserRef | None 643 | 644 | # "Other" fields: 645 | broken: bool | None 646 | cache: Cache | None 647 | important: bool | None # aka marked as Favorite. 648 | 649 | # Per API Doc: "Our API response could contain other fields, not described above. 650 | # It's unsafe to use them in your integration! They could be removed or renamed at any time." 651 | other: dict[str, Any] = {} 652 | 653 | @root_validator(pre=True) 654 | def _validator(cls, v): # noqa: N805 655 | """Gather all non-recognised/unofficial attributes into a single attribute.""" 656 | return _collect_other_attributes(cls, v) 657 | 658 | @classmethod 659 | def get(cls, api: T_API, id: int) -> Raindrop: 660 | """Return a Raindrop bookmark based on it's id.""" 661 | item = api.get(URL.format(path=f"{id}")).json()["item"] 662 | return cls(**item) 663 | 664 | @classmethod 665 | def cache(cls, api: T_API, id: int) -> requests.Response: 666 | """Return the requests on behalf of a permanent copy of the specified Raindrop.""" 667 | # Note: In testing in 2024-01, while I was able to get a URL back in this response 668 | # (after a 307 redirect), the URL did NOT work against S3...(essentially an "item not 669 | # found" from the S3 infrastructure). 670 | return api.get(URL.format(path=f"raindrop/{id}/cache")) 671 | 672 | @classmethod 673 | def create_link( 674 | cls, 675 | api: T_API, 676 | link: str, 677 | collection: (Collection | CollectionRef, int) | None = None, 678 | cover: str | None = None, 679 | excerpt: str | None = None, 680 | important: bool | None = None, 681 | media: list[dict[str, Any]] | None = None, 682 | order: int | None = None, 683 | please_parse: bool = False, # If set, asks API to automatically parse metadata in the background 684 | tags: list[str] | None = None, 685 | title: str | None = None, 686 | ) -> Raindrop: 687 | """Create a new link-type Raindrop bookmark. 688 | 689 | Args: 690 | api: API Handle to use for the request. 691 | 692 | link: Required, URL to associate with this Raindrop. 693 | 694 | collection: Optional, Collection (or CollectionRef) to place this Raindrop "into". 695 | If not specified, new Raindrop will be in system Collection "Unsorted". 696 | 697 | cover: Optional, URL of the Raindrop's "cover". 698 | 699 | excerpt: Optional, long description for the Raindrop (internally, Raindrop call's 700 | it an *excerpt* but on the UI it's *Description*). Maximum length is 10k characters. 701 | 702 | important: Optional, Flag to indicate if this Raindrop should be considered important nee a favorite. 703 | 704 | media: Optional, List of media dictionaries (consult RaindropIO's API for somewhat more information. 705 | 706 | order: Optional, Order of Raindrop in respective collection, ie. set to 0 to make Raindrop first. 707 | 708 | please_parse: Optional, Flag that asks API to automatically parse metadata in the background 709 | (not exactly sure which this implies, message me if you know! ;-) 710 | 711 | tags: Optional, List of tags to associated with this Raindrop. 712 | 713 | title: Optional, Title to associated with this Raindrop. 714 | 715 | Returns: 716 | ``Raindrop`` instance created. 717 | 718 | Note: 719 | We don't allow you to set either ``created`` or ``last_update`` attributes. They will be 720 | set appropriately by the RaindropIO service on your behalf. 721 | """ 722 | # Setup the args that will be passed to the underlying Raindrop API, only link is 723 | # absolutely required, rest are optional! 724 | args: dict[str, Any] = dict(type=RaindropType.link, link=link) 725 | 726 | if please_parse: 727 | args["please_parse"] = {} 728 | 729 | for attr in [ 730 | "cover", 731 | "excerpt", 732 | "important", 733 | "media", 734 | "order", 735 | "tags", 736 | "title", 737 | ]: 738 | if (value := locals().get(attr)) is not None: 739 | args[attr] = value 740 | 741 | if collection is not None: 742 | # arg could be **either** an actual collection 743 | # or simply an int collection "id" already, handle either: 744 | if isinstance(collection, Collection | CollectionRef): 745 | args["collection"] = {"$id": collection.id} 746 | else: 747 | args["collection"] = {"$id": collection} 748 | url = URL.format(path="raindrop") 749 | item = api.post(url, json=args).json()["item"] 750 | return cls(**item) 751 | 752 | @classmethod 753 | def create_file( 754 | cls, 755 | api: T_API, 756 | path: Path, 757 | content_type: str, 758 | collection: (Collection | CollectionRef, int) | None = CollectionRef.Unsorted, 759 | tags: list[str] | None = None, 760 | title: str | None = None, 761 | ) -> Raindrop: 762 | """Create a new file-based Raindrop bookmark. 763 | 764 | Args: 765 | api: API Handle to use for the request. 766 | 767 | path: Required, python Path to file to be uploaded. 768 | 769 | content_type: Required, mime-type associated with the file. 770 | 771 | collection: Optional, Collection (or CollectionRef) to place this Raindrop "into". 772 | If not specified, new Raindrop will be in system Collection *Unsorted*. 773 | 774 | tags: Optional, List of tags to associated with this Raindrop. 775 | 776 | title: Optional, Title to associated with this Raindrop. 777 | 778 | Returns: 779 | ``Raindrop`` instance created. 780 | 781 | Note: 782 | Only a limited number of file-types are supported by RaindropIO (minimally, "application/pdf"), 783 | specifically (as of 2023-02): 784 | 785 | - Images (jpeg, gif, png, webp, heic) 786 | 787 | - Videos (mp4, mov, wmv, webm) 788 | 789 | - Books (epub) 790 | 791 | - Documents (pdf, md, txt) 792 | """ 793 | # Uses a different URL for file uploading.. 794 | url = URL.format(path="raindrop/file") 795 | 796 | # NOTE: "put_file" arguments and structure here confirmed through communication 797 | # with RustemM on 2022-11-29 and his subsequent update to API docs. 798 | if isinstance(collection, Collection | CollectionRef): 799 | data = {"collectionId": str(collection.id)} 800 | else: 801 | data = {"collectionId": str(collection)} 802 | 803 | with open(path, "rb") as fh_: 804 | files = {"file": (path.name, fh_, content_type)} 805 | item = api.put_file(url, path, data, files).json()["item"] 806 | raindrop = cls(**item) 807 | 808 | # Raindrop's "Create Raindrop From File" does not allow us to set other attributes, 809 | # thus, we need to check if any of the possible attributes need to be set and do so 810 | # explicitly with another call to "update" the Raindrop we just created. 811 | args: dict[str, Any] = {} 812 | if title is not None: 813 | args["title"] = title 814 | if tags is not None: 815 | args["tags"] = tags 816 | if args: 817 | url = URL.format(path=f"raindrop/{raindrop.id}") 818 | item = api.put(url, json=args).json()["item"] 819 | return cls(**item) 820 | else: 821 | return raindrop 822 | 823 | @classmethod 824 | def update( 825 | cls, 826 | api: T_API, 827 | id: int, 828 | collection: (Collection | CollectionRef, int) | None = None, 829 | cover: str | None = None, 830 | excerpt: str | None = None, 831 | important: bool | None = None, 832 | link: str | None = None, 833 | media: list[dict[str, Any]] | None = None, 834 | order: int | None = None, 835 | please_parse: bool | None = False, 836 | tags: list[str] | None = None, 837 | title: str | None = None, 838 | ) -> Raindrop: 839 | """Update an existing Raindrop bookmark, setting any of the attribute values provided. 840 | 841 | Args: 842 | api: API Handle to use for the request. 843 | 844 | id: Required id of Raindrop to be updated. 845 | 846 | collection: Optional, Collection (or CollectionRef) to move this Raindrop "into". If not specified, 847 | Raindrop will remain in the same collection as it was. 848 | 849 | cover: Optional, new URL to set as the Raindrop's "cover". 850 | 851 | excerpt: Optional, new long description for the Raindrop. Maximum length is 10,000 characters. 852 | 853 | important: Optional, Flag to indicate if this Raindrop should be considered important nee a favorite. 854 | 855 | link: Required, New URL to associate with this Raindrop. 856 | 857 | media: Optional, Updated list of media dictionaries (consult RaindropIO's API for somewhat more information. 858 | 859 | order: Optional, Change order of Raindrop in respective collection. 860 | 861 | please_parse: Optional, Flag that asks API to automatically parse metadata in the background 862 | (not exactly sure which this implies, message me if you know! ;-) 863 | 864 | tags: Optional, New list of tags to associate with this Raindrop. 865 | 866 | title: Optional, New title for this Raindrop. 867 | 868 | Returns: 869 | ``Raindrop`` instance that was updated. 870 | """ 871 | # Setup the args that will be passed to the underlying Raindrop API 872 | args: dict[str, Any] = {} 873 | 874 | if please_parse: 875 | args["please_parse"] = {} 876 | 877 | for attr in [ 878 | "cover", 879 | "excerpt", 880 | "important", 881 | "link", 882 | "media", 883 | "order", 884 | "tags", 885 | "title", 886 | ]: 887 | if (value := locals().get(attr)) is not None: 888 | args[attr] = value 889 | 890 | if collection is not None: 891 | # arg could be **either** an actual collection 892 | # or simply an int collection "id" already, handle either: 893 | if isinstance(collection, Collection | CollectionRef): 894 | args["collection"] = collection.id 895 | else: 896 | args["collection"] = collection 897 | 898 | url = URL.format(path=f"raindrop/{id}") 899 | item = api.put(url, json=args).json()["item"] 900 | return cls(**item) 901 | 902 | @classmethod 903 | def delete(cls, api: T_API, id: int) -> None: 904 | """Delete a Raindrop bookmark. 905 | 906 | Args: 907 | api: API Handle to use for the request. 908 | 909 | id: Required id of Raindrop to be deleted. 910 | 911 | Returns: 912 | None. 913 | """ 914 | api.delete(URL.format(path=f"raindrop/{id}"), json={}) 915 | 916 | @classmethod 917 | def _search_paged( 918 | cls, 919 | api: T_API, 920 | collection: CollectionRef = CollectionRef.All, 921 | search: str | None = None, 922 | page: int = 0, 923 | perpage: int = 50, 924 | ) -> list[Raindrop]: 925 | """Lower-level search for bookmarks on a "paged" basis. 926 | 927 | Raindrop's search API works on a "paged" basis. This method implements the underlying 928 | search reflecting paging (while the primary ``search`` method below hides it 929 | completely). 930 | """ 931 | params = {"perpage": perpage, "page": page} 932 | if search: 933 | params["search"] = search 934 | url = URL.format(path=f"raindrops/{collection.id}") 935 | results = api.get(url, params=params).json() 936 | return [cls(**item) for item in results["items"]] 937 | 938 | @classmethod 939 | def search( 940 | cls, 941 | api: T_API, 942 | collection: Collection | CollectionRef = CollectionRef.All, 943 | search: str | None = None, 944 | ) -> list[Raindrop]: 945 | """Search for Raindrops. 946 | 947 | Args: 948 | api: API Handle to use for the request. 949 | 950 | collection: Optional, ``Collection`` (or ``CollectionRef``) to search over. 951 | Defaults to ``CollectionRef.All``. 952 | 953 | search: Optional, search string to search Raindrops for (see 954 | `Raindrop.io Search Help `_ for more information. 955 | 956 | Returns: 957 | A (potentially empty) list of Raindrops that match the search criteria provided. 958 | """ 959 | page = 0 960 | results = list() 961 | while raindrops := Raindrop._search_paged( 962 | api, 963 | collection, 964 | page=page, 965 | search=search, 966 | ): 967 | results.extend(raindrops) 968 | page += 1 969 | return results 970 | 971 | 972 | class Tag(BaseModel): 973 | """Represents existing Tags, either all or just a specific collection.""" 974 | 975 | tag: str = Field(None, alias="_id") 976 | count: int 977 | 978 | @classmethod 979 | def get(cls, api: T_API, collection_id: int | None = None) -> list[Tag]: 980 | """Get all the tags currently defined, either in a specific collections or across all collections. 981 | 982 | Args: 983 | api: API Handle to use for the request. 984 | 985 | collection_id: Optional, Id of specific collection to limit search for all tags. 986 | 987 | Returns: 988 | List of ``Tag``. 989 | """ 990 | url = URL.format(path="tags") 991 | if collection_id: 992 | url += "/" + str(collection_id) 993 | items = api.get(url).json()["items"] 994 | return [Tag(**item) for item in items] 995 | 996 | @classmethod 997 | def delete(cls, api: T_API, tags: list[str]) -> None: 998 | """Delete one or more Tags. 999 | 1000 | Args: 1001 | api: API Handle to use for the request. 1002 | 1003 | tags: List of tags to be deleted. 1004 | 1005 | Returns: 1006 | None. 1007 | """ 1008 | api.delete(URL.format(path="tags"), json={}) 1009 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Top level test dunder init.""" 2 | -------------------------------------------------------------------------------- /tests/api/cassettes/test_collection_lifecycle.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: '{"title": "TEST Collection (peter"}' 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | Content-Length: 12 | - '35' 13 | Content-Type: 14 | - application/json 15 | User-Agent: 16 | - python-requests/2.31.0 17 | method: POST 18 | uri: https://api.raindrop.io/rest/v1/collection 19 | response: 20 | body: 21 | string: '{"result":true,"item":{"title":"TEST Collection (peter","description":"","user":{"$ref":"users","$id":1006974},"public":false,"view":"list","count":0,"cover":[],"expanded":true,"creatorRef":1006974,"lastAction":"2023-12-12T00:10:54.129Z","created":"2023-12-12T00:10:54.130Z","lastUpdate":"2023-12-12T00:10:54.130Z","_id":39866648,"sort":39866648,"slug":"test-collection-peter","__v":0,"access":{"for":1006974,"level":4,"root":false,"draggable":true},"author":true}}' 22 | headers: 23 | CF-Cache-Status: 24 | - DYNAMIC 25 | CF-RAY: 26 | - 8341bf358a286802-SJC 27 | Cache-Control: 28 | - private 29 | Connection: 30 | - keep-alive 31 | Content-Encoding: 32 | - gzip 33 | Content-Type: 34 | - application/json; charset=utf-8 35 | Date: 36 | - Tue, 12 Dec 2023 00:10:54 GMT 37 | Server: 38 | - cloudflare 39 | Transfer-Encoding: 40 | - chunked 41 | alt-svc: 42 | - h3=":443"; ma=86400 43 | etag: 44 | - W/"1d1-27nZ5VlsGMR6TwhEr8nwdD3eTDw" 45 | server-timing: 46 | - dc;desc=eu 47 | - total;dur=419.574 48 | x-powered-by: 49 | - Express 50 | x-ratelimit-limit: 51 | - '120' 52 | x-ratelimit-remaining: 53 | - '110' 54 | x-ratelimit-reset: 55 | - '1702339864' 56 | status: 57 | code: 200 58 | message: OK 59 | - request: 60 | body: '{"title": "EDITED TEST Collection (peter"}' 61 | headers: 62 | Accept: 63 | - '*/*' 64 | Accept-Encoding: 65 | - gzip, deflate 66 | Connection: 67 | - keep-alive 68 | Content-Length: 69 | - '42' 70 | Content-Type: 71 | - application/json 72 | User-Agent: 73 | - python-requests/2.31.0 74 | method: PUT 75 | uri: https://api.raindrop.io/rest/v1/collection/39866648 76 | response: 77 | body: 78 | string: '{"result":true,"item":{"_id":39866648,"title":"EDITED TEST Collection 79 | (peter","description":"","slug":"edited-test-collection-peter","user":{"$ref":"users","$id":1006974},"creatorRef":{"_id":1006974,"name":"MadHun","email":""},"public":false,"view":"list","count":0,"cover":[],"sort":39866648,"expanded":true,"lastAction":"2023-12-12T00:10:54.129Z","created":"2023-12-12T00:10:54.130Z","lastUpdate":"2023-12-12T00:10:54.817Z","access":{"for":1006974,"level":4,"root":false,"draggable":true},"author":true}}' 80 | headers: 81 | CF-Cache-Status: 82 | - DYNAMIC 83 | CF-RAY: 84 | - 8341bf3bda2f15d0-SJC 85 | Cache-Control: 86 | - private 87 | Connection: 88 | - keep-alive 89 | Content-Encoding: 90 | - gzip 91 | Content-Type: 92 | - application/json; charset=utf-8 93 | Date: 94 | - Tue, 12 Dec 2023 00:10:54 GMT 95 | Server: 96 | - cloudflare 97 | Transfer-Encoding: 98 | - chunked 99 | alt-svc: 100 | - h3=":443"; ma=86400 101 | etag: 102 | - W/"1fa-/Oo+mK2rZ6BZwCy/07fuk2rtYYw" 103 | server-timing: 104 | - dc;desc=eu 105 | - total;dur=77.405 106 | x-powered-by: 107 | - Express 108 | x-ratelimit-limit: 109 | - '120' 110 | x-ratelimit-remaining: 111 | - '109' 112 | x-ratelimit-reset: 113 | - '1702339864' 114 | status: 115 | code: 200 116 | message: OK 117 | - request: 118 | body: null 119 | headers: 120 | Accept: 121 | - '*/*' 122 | Accept-Encoding: 123 | - gzip, deflate 124 | Connection: 125 | - keep-alive 126 | Content-Type: 127 | - application/json 128 | User-Agent: 129 | - python-requests/2.31.0 130 | method: GET 131 | uri: https://api.raindrop.io/rest/v1/collection/39866648 132 | response: 133 | body: 134 | string: '{"result":true,"item":{"_id":39866648,"title":"EDITED TEST Collection 135 | (peter","description":"","user":{"$ref":"users","$id":1006974},"public":false,"view":"list","count":0,"cover":[],"expanded":true,"creatorRef":{"_id":1006974,"name":"MadHun","email":""},"lastAction":"2023-12-12T00:10:54.129Z","created":"2023-12-12T00:10:54.130Z","lastUpdate":"2023-12-12T00:10:54.817Z","sort":39866648,"slug":"edited-test-collection-peter","access":{"for":1006974,"level":4,"root":false,"draggable":true},"author":true}}' 136 | headers: 137 | CF-Cache-Status: 138 | - DYNAMIC 139 | CF-RAY: 140 | - 8341bf3eb969642c-SJC 141 | Cache-Control: 142 | - private 143 | Connection: 144 | - keep-alive 145 | Content-Encoding: 146 | - gzip 147 | Content-Type: 148 | - application/json; charset=utf-8 149 | Date: 150 | - Tue, 12 Dec 2023 00:10:55 GMT 151 | Server: 152 | - cloudflare 153 | Transfer-Encoding: 154 | - chunked 155 | alt-svc: 156 | - h3=":443"; ma=86400 157 | etag: 158 | - W/"1fa-ozuQ7P+7GSpJkPnu2eoQ1o6HCZU" 159 | server-timing: 160 | - dc;desc=eu 161 | - total;dur=23.764 162 | x-powered-by: 163 | - Express 164 | x-ratelimit-limit: 165 | - '120' 166 | x-ratelimit-remaining: 167 | - '108' 168 | x-ratelimit-reset: 169 | - '1702339864' 170 | status: 171 | code: 200 172 | message: OK 173 | - request: 174 | body: '{}' 175 | headers: 176 | Accept: 177 | - '*/*' 178 | Accept-Encoding: 179 | - gzip, deflate 180 | Connection: 181 | - keep-alive 182 | Content-Length: 183 | - '2' 184 | Content-Type: 185 | - application/json 186 | User-Agent: 187 | - python-requests/2.31.0 188 | method: DELETE 189 | uri: https://api.raindrop.io/rest/v1/collection/39866648 190 | response: 191 | body: 192 | string: '{"result":true}' 193 | headers: 194 | CF-Cache-Status: 195 | - DYNAMIC 196 | CF-RAY: 197 | - 8341bf407c6f1748-SJC 198 | Cache-Control: 199 | - private 200 | Connection: 201 | - keep-alive 202 | Content-Length: 203 | - '15' 204 | Content-Type: 205 | - application/json; charset=utf-8 206 | Date: 207 | - Tue, 12 Dec 2023 00:10:55 GMT 208 | Server: 209 | - cloudflare 210 | alt-svc: 211 | - h3=":443"; ma=86400 212 | etag: 213 | - '"f-ayLlCL3PuzXSThdu78iReSEjl6Y"' 214 | server-timing: 215 | - dc;desc=eu 216 | - total;dur=132.108 217 | x-powered-by: 218 | - Express 219 | x-ratelimit-limit: 220 | - '120' 221 | x-ratelimit-remaining: 222 | - '107' 223 | x-ratelimit-reset: 224 | - '1702339864' 225 | status: 226 | code: 200 227 | message: OK 228 | - request: 229 | body: null 230 | headers: 231 | Accept: 232 | - '*/*' 233 | Accept-Encoding: 234 | - gzip, deflate 235 | Connection: 236 | - keep-alive 237 | Content-Type: 238 | - application/json 239 | User-Agent: 240 | - python-requests/2.31.0 241 | method: GET 242 | uri: https://api.raindrop.io/rest/v1/collection/39866648 243 | response: 244 | body: 245 | string: '{"result":false,"status":404,"errorMessage":"Not found"}' 246 | headers: 247 | CF-Cache-Status: 248 | - DYNAMIC 249 | CF-RAY: 250 | - 8341bf437d811601-SJC 251 | Cache-Control: 252 | - private 253 | Connection: 254 | - keep-alive 255 | Content-Encoding: 256 | - gzip 257 | Content-Type: 258 | - application/json; charset=utf-8 259 | Date: 260 | - Tue, 12 Dec 2023 00:10:56 GMT 261 | Server: 262 | - cloudflare 263 | Transfer-Encoding: 264 | - chunked 265 | alt-svc: 266 | - h3=":443"; ma=86400 267 | etag: 268 | - W/"38-iJko4beMFEiuGB7qKIgn7EIFr2M" 269 | server-timing: 270 | - dc;desc=eu 271 | - total;dur=16.441 272 | x-powered-by: 273 | - Express 274 | x-ratelimit-limit: 275 | - '120' 276 | x-ratelimit-remaining: 277 | - '106' 278 | x-ratelimit-reset: 279 | - '1702339864' 280 | status: 281 | code: 404 282 | message: Not Found 283 | version: 1 284 | -------------------------------------------------------------------------------- /tests/api/cassettes/test_get_collections.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | Content-Type: 12 | - application/json 13 | User-Agent: 14 | - python-requests/2.31.0 15 | method: GET 16 | uri: https://api.raindrop.io/rest/v1/collections 17 | response: 18 | body: 19 | string: '{"result":true,"items":[{"_id":26109849,"title":"ToRead","description":"","public":false,"view":"grid","count":10,"cover":["https://up.raindrop.io/collection/thumbs/261/098/49/b066882190cb8d0597c4265bdcdc9fc3.png"],"expanded":true,"user":{"$ref":"users","$id":1006974},"creatorRef":{"_id":1006974,"name":"MadHun","email":""},"lastAction":"2023-12-11T23:56:51.279Z","created":"2022-07-23T19:09:13.565Z","lastUpdate":"2023-12-11T23:56:51.279Z","sort":26109849,"slug":"to-read","color":"#6b7271","access":{"for":1006974,"level":4,"root":false,"draggable":true},"author":true},{"_id":26109558,"title":"Recipes","description":"","public":false,"view":"simple","count":326,"cover":["https://up.raindrop.io/collection/templates/materia-flat-food-vol-1/i26.png"],"expanded":true,"user":{"$ref":"users","$id":1006974},"creatorRef":{"_id":1006974,"name":"MadHun","email":""},"lastAction":"2023-11-19T22:09:57.247Z","created":"2022-07-23T18:42:12.323Z","lastUpdate":"2023-12-11T23:59:20.038Z","sort":5,"slug":"recipes","color":"#fb6c64","access":{"for":1006974,"level":4,"root":false,"draggable":true},"author":true},{"_id":26115550,"title":"ResponsiveImages","description":"","public":false,"view":"simple","count":6,"cover":[],"expanded":false,"sort":0,"user":{"$ref":"users","$id":1006974},"creatorRef":{"_id":1006974,"name":"MadHun","email":""},"lastAction":"2023-06-06T19:23:44.027Z","created":"2022-07-24T01:03:46.031Z","lastUpdate":"2023-06-06T19:23:44.027Z","slug":"responsive-images","access":{"for":1006974,"level":4,"root":false,"draggable":true},"author":true},{"_id":29384146,"title":"Python","description":"","public":false,"view":"list","count":5,"cover":[],"expanded":false,"sort":0,"user":{"$ref":"users","$id":1006974},"creatorRef":{"_id":1006974,"name":"MadHun","email":""},"lastAction":"2023-09-17T23:55:23.715Z","created":"2022-11-27T19:19:14.812Z","lastUpdate":"2023-09-17T23:55:23.715Z","slug":"python","access":{"for":1006974,"level":4,"root":false,"draggable":true},"author":true},{"_id":29479296,"title":"Cooking-Products","description":"","public":false,"view":"list","count":15,"cover":[],"expanded":false,"sort":0,"user":{"$ref":"users","$id":1006974},"creatorRef":{"_id":1006974,"name":"MadHun","email":""},"lastAction":"2023-09-17T23:54:37.579Z","created":"2022-12-01T19:18:53.991Z","lastUpdate":"2023-09-17T23:54:37.579Z","slug":"cooking-products","access":{"for":1006974,"level":4,"root":false,"draggable":true},"author":true},{"_id":29479383,"title":"Cooking-Skills","description":"","public":false,"view":"list","count":8,"cover":[],"expanded":false,"sort":0,"user":{"$ref":"users","$id":1006974},"creatorRef":{"_id":1006974,"name":"MadHun","email":""},"lastAction":"2023-03-19T00:57:42.594Z","created":"2022-12-01T19:21:01.854Z","lastUpdate":"2023-03-29T22:38:12.274Z","slug":"cooking-skills","access":{"for":1006974,"level":4,"root":false,"draggable":true},"author":true}]}' 20 | headers: 21 | CF-Cache-Status: 22 | - DYNAMIC 23 | CF-RAY: 24 | - 8341bf25caf39e52-SJC 25 | Cache-Control: 26 | - private 27 | Connection: 28 | - keep-alive 29 | Content-Encoding: 30 | - gzip 31 | Content-Type: 32 | - application/json; charset=utf-8 33 | Date: 34 | - Tue, 12 Dec 2023 00:10:51 GMT 35 | Server: 36 | - cloudflare 37 | Transfer-Encoding: 38 | - chunked 39 | alt-svc: 40 | - h3=":443"; ma=86400 41 | etag: 42 | - W/"b53-Zvn3tCP73SkHVR+xKe40yQyTz9M" 43 | server-timing: 44 | - dc;desc=eu 45 | - find_collections;dur=22.071 46 | - total;dur=31.827 47 | x-powered-by: 48 | - Express 49 | x-ratelimit-limit: 50 | - '120' 51 | x-ratelimit-remaining: 52 | - '115' 53 | x-ratelimit-reset: 54 | - '1702339864' 55 | status: 56 | code: 200 57 | message: OK 58 | - request: 59 | body: null 60 | headers: 61 | Accept: 62 | - '*/*' 63 | Accept-Encoding: 64 | - gzip, deflate 65 | Connection: 66 | - keep-alive 67 | Content-Type: 68 | - application/json 69 | User-Agent: 70 | - python-requests/2.31.0 71 | method: GET 72 | uri: https://api.raindrop.io/rest/v1/collections/childrens 73 | response: 74 | body: 75 | string: '{"result":true,"items":[{"_id":39866550,"title":"SubRecipes","description":"","user":{"$ref":"users","$id":1006974},"parent":{"$ref":"collections","$id":26109558},"public":false,"view":"list","count":0,"cover":[],"sort":-1,"expanded":true,"creatorRef":{"_id":1006974,"name":"MadHun","email":""},"lastAction":"2023-12-11T23:59:19.578Z","created":"2023-12-11T23:59:19.578Z","lastUpdate":"2023-12-11T23:59:19.578Z","slug":"sub-recipes","access":{"for":1006974,"level":4,"root":false,"draggable":true},"author":true},{"_id":39866430,"title":"ToReadSubCollection","description":"","user":{"$ref":"users","$id":1006974},"parent":{"$ref":"collections","$id":26109849},"public":false,"view":"list","count":2,"cover":[],"sort":-1,"expanded":true,"creatorRef":{"_id":1006974,"name":"MadHun","email":""},"lastAction":"2023-12-11T23:56:51.279Z","created":"2023-12-11T23:33:57.577Z","lastUpdate":"2023-12-11T23:56:51.279Z","slug":"to-read-sub-collection","access":{"for":1006974,"level":4,"root":false,"draggable":true},"author":true},{"_id":39866552,"title":"Another 76 | Nested","description":"","user":{"$ref":"users","$id":1006974},"parent":{"$ref":"collections","$id":39866430},"public":false,"view":"list","count":0,"cover":[],"sort":-1,"expanded":true,"creatorRef":{"_id":1006974,"name":"MadHun","email":""},"lastAction":"2023-12-11T23:59:46.386Z","created":"2023-12-11T23:59:46.387Z","lastUpdate":"2023-12-11T23:59:46.387Z","slug":"another-nested","access":{"for":1006974,"level":4,"root":false,"draggable":true},"author":true}]}' 77 | headers: 78 | CF-Cache-Status: 79 | - DYNAMIC 80 | CF-RAY: 81 | - 8341bf2b1ffdcfb8-SJC 82 | Cache-Control: 83 | - private 84 | Connection: 85 | - keep-alive 86 | Content-Encoding: 87 | - gzip 88 | Content-Type: 89 | - application/json; charset=utf-8 90 | Date: 91 | - Tue, 12 Dec 2023 00:10:52 GMT 92 | Server: 93 | - cloudflare 94 | Transfer-Encoding: 95 | - chunked 96 | alt-svc: 97 | - h3=":443"; ma=86400 98 | etag: 99 | - W/"5ef-HihIJuAJwvEhbcEO9ujnnIVKDi4" 100 | server-timing: 101 | - dc;desc=eu 102 | - find_collections;dur=21.933 103 | - total;dur=31.501 104 | x-powered-by: 105 | - Express 106 | x-ratelimit-limit: 107 | - '120' 108 | x-ratelimit-remaining: 109 | - '114' 110 | x-ratelimit-reset: 111 | - '1702339864' 112 | status: 113 | code: 200 114 | message: OK 115 | - request: 116 | body: null 117 | headers: 118 | Accept: 119 | - '*/*' 120 | Accept-Encoding: 121 | - gzip, deflate 122 | Connection: 123 | - keep-alive 124 | Content-Type: 125 | - application/json 126 | User-Agent: 127 | - python-requests/2.31.0 128 | method: GET 129 | uri: https://api.raindrop.io/rest/v1/collections 130 | response: 131 | body: 132 | string: '{"result":true,"items":[{"_id":26109849,"title":"ToRead","description":"","public":false,"view":"grid","count":10,"cover":["https://up.raindrop.io/collection/thumbs/261/098/49/b066882190cb8d0597c4265bdcdc9fc3.png"],"expanded":true,"user":{"$ref":"users","$id":1006974},"creatorRef":{"_id":1006974,"name":"MadHun","email":""},"lastAction":"2023-12-11T23:56:51.279Z","created":"2022-07-23T19:09:13.565Z","lastUpdate":"2023-12-11T23:56:51.279Z","sort":26109849,"slug":"to-read","color":"#6b7271","access":{"for":1006974,"level":4,"root":false,"draggable":true},"author":true},{"_id":26109558,"title":"Recipes","description":"","public":false,"view":"simple","count":326,"cover":["https://up.raindrop.io/collection/templates/materia-flat-food-vol-1/i26.png"],"expanded":true,"user":{"$ref":"users","$id":1006974},"creatorRef":{"_id":1006974,"name":"MadHun","email":""},"lastAction":"2023-11-19T22:09:57.247Z","created":"2022-07-23T18:42:12.323Z","lastUpdate":"2023-12-11T23:59:20.038Z","sort":5,"slug":"recipes","color":"#fb6c64","access":{"for":1006974,"level":4,"root":false,"draggable":true},"author":true},{"_id":26115550,"title":"ResponsiveImages","description":"","public":false,"view":"simple","count":6,"cover":[],"expanded":false,"sort":0,"user":{"$ref":"users","$id":1006974},"creatorRef":{"_id":1006974,"name":"MadHun","email":""},"lastAction":"2023-06-06T19:23:44.027Z","created":"2022-07-24T01:03:46.031Z","lastUpdate":"2023-06-06T19:23:44.027Z","slug":"responsive-images","access":{"for":1006974,"level":4,"root":false,"draggable":true},"author":true},{"_id":29384146,"title":"Python","description":"","public":false,"view":"list","count":5,"cover":[],"expanded":false,"sort":0,"user":{"$ref":"users","$id":1006974},"creatorRef":{"_id":1006974,"name":"MadHun","email":""},"lastAction":"2023-09-17T23:55:23.715Z","created":"2022-11-27T19:19:14.812Z","lastUpdate":"2023-09-17T23:55:23.715Z","slug":"python","access":{"for":1006974,"level":4,"root":false,"draggable":true},"author":true},{"_id":29479296,"title":"Cooking-Products","description":"","public":false,"view":"list","count":15,"cover":[],"expanded":false,"sort":0,"user":{"$ref":"users","$id":1006974},"creatorRef":{"_id":1006974,"name":"MadHun","email":""},"lastAction":"2023-09-17T23:54:37.579Z","created":"2022-12-01T19:18:53.991Z","lastUpdate":"2023-09-17T23:54:37.579Z","slug":"cooking-products","access":{"for":1006974,"level":4,"root":false,"draggable":true},"author":true},{"_id":29479383,"title":"Cooking-Skills","description":"","public":false,"view":"list","count":8,"cover":[],"expanded":false,"sort":0,"user":{"$ref":"users","$id":1006974},"creatorRef":{"_id":1006974,"name":"MadHun","email":""},"lastAction":"2023-03-19T00:57:42.594Z","created":"2022-12-01T19:21:01.854Z","lastUpdate":"2023-03-29T22:38:12.274Z","slug":"cooking-skills","access":{"for":1006974,"level":4,"root":false,"draggable":true},"author":true}]}' 133 | headers: 134 | CF-Cache-Status: 135 | - DYNAMIC 136 | CF-RAY: 137 | - 8341bf2d194015c6-SJC 138 | Cache-Control: 139 | - private 140 | Connection: 141 | - keep-alive 142 | Content-Encoding: 143 | - gzip 144 | Content-Type: 145 | - application/json; charset=utf-8 146 | Date: 147 | - Tue, 12 Dec 2023 00:10:52 GMT 148 | Server: 149 | - cloudflare 150 | Transfer-Encoding: 151 | - chunked 152 | alt-svc: 153 | - h3=":443"; ma=86400 154 | etag: 155 | - W/"b53-Zvn3tCP73SkHVR+xKe40yQyTz9M" 156 | server-timing: 157 | - dc;desc=eu 158 | - find_collections;dur=23.680 159 | - total;dur=33.675 160 | x-powered-by: 161 | - Express 162 | x-ratelimit-limit: 163 | - '120' 164 | x-ratelimit-remaining: 165 | - '113' 166 | x-ratelimit-reset: 167 | - '1702339864' 168 | status: 169 | code: 200 170 | message: OK 171 | - request: 172 | body: null 173 | headers: 174 | Accept: 175 | - '*/*' 176 | Accept-Encoding: 177 | - gzip, deflate 178 | Connection: 179 | - keep-alive 180 | Content-Type: 181 | - application/json 182 | User-Agent: 183 | - python-requests/2.31.0 184 | method: GET 185 | uri: https://api.raindrop.io/rest/v1/collections/childrens 186 | response: 187 | body: 188 | string: '{"result":true,"items":[{"_id":39866550,"title":"SubRecipes","description":"","user":{"$ref":"users","$id":1006974},"parent":{"$ref":"collections","$id":26109558},"public":false,"view":"list","count":0,"cover":[],"sort":-1,"expanded":true,"creatorRef":{"_id":1006974,"name":"MadHun","email":""},"lastAction":"2023-12-11T23:59:19.578Z","created":"2023-12-11T23:59:19.578Z","lastUpdate":"2023-12-11T23:59:19.578Z","slug":"sub-recipes","access":{"for":1006974,"level":4,"root":false,"draggable":true},"author":true},{"_id":39866430,"title":"ToReadSubCollection","description":"","user":{"$ref":"users","$id":1006974},"parent":{"$ref":"collections","$id":26109849},"public":false,"view":"list","count":2,"cover":[],"sort":-1,"expanded":true,"creatorRef":{"_id":1006974,"name":"MadHun","email":""},"lastAction":"2023-12-11T23:56:51.279Z","created":"2023-12-11T23:33:57.577Z","lastUpdate":"2023-12-11T23:56:51.279Z","slug":"to-read-sub-collection","access":{"for":1006974,"level":4,"root":false,"draggable":true},"author":true},{"_id":39866552,"title":"Another 189 | Nested","description":"","user":{"$ref":"users","$id":1006974},"parent":{"$ref":"collections","$id":39866430},"public":false,"view":"list","count":0,"cover":[],"sort":-1,"expanded":true,"creatorRef":{"_id":1006974,"name":"MadHun","email":""},"lastAction":"2023-12-11T23:59:46.386Z","created":"2023-12-11T23:59:46.387Z","lastUpdate":"2023-12-11T23:59:46.387Z","slug":"another-nested","access":{"for":1006974,"level":4,"root":false,"draggable":true},"author":true}]}' 190 | headers: 191 | CF-Cache-Status: 192 | - DYNAMIC 193 | CF-RAY: 194 | - 8341bf2ee8971749-SJC 195 | Cache-Control: 196 | - private 197 | Connection: 198 | - keep-alive 199 | Content-Encoding: 200 | - gzip 201 | Content-Type: 202 | - application/json; charset=utf-8 203 | Date: 204 | - Tue, 12 Dec 2023 00:10:52 GMT 205 | Server: 206 | - cloudflare 207 | Transfer-Encoding: 208 | - chunked 209 | alt-svc: 210 | - h3=":443"; ma=86400 211 | etag: 212 | - W/"5ef-HihIJuAJwvEhbcEO9ujnnIVKDi4" 213 | server-timing: 214 | - dc;desc=eu 215 | - find_collections;dur=22.102 216 | - total;dur=31.037 217 | x-powered-by: 218 | - Express 219 | x-ratelimit-limit: 220 | - '120' 221 | x-ratelimit-remaining: 222 | - '112' 223 | x-ratelimit-reset: 224 | - '1702339864' 225 | status: 226 | code: 200 227 | message: OK 228 | version: 1 229 | -------------------------------------------------------------------------------- /tests/api/cassettes/test_get_tags.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | Content-Type: 12 | - application/json 13 | User-Agent: 14 | - python-requests/2.31.0 15 | method: GET 16 | uri: https://api.raindrop.io/rest/v1/tags 17 | response: 18 | body: 19 | string: '{"result":true,"items":[{"_id":"Chicken","count":51},{"_id":"Indian","count":45},{"_id":"Noodles","count":39},{"_id":"chinese","count":34},{"_id":"Soup","count":30},{"_id":"*****","count":27},{"_id":"Thai","count":27},{"_id":"Baking","count":19},{"_id":"Mexican","count":19},{"_id":"Italian","count":18},{"_id":"Seafood","count":18},{"_id":"Sides","count":17},{"_id":"American","count":16},{"_id":"Dessert","count":16},{"_id":"Bread","count":14},{"_id":"Vegetable","count":14},{"_id":"****","count":13},{"_id":"Korean","count":13},{"_id":"Pasta","count":13},{"_id":"Asian","count":11},{"_id":"Pork","count":10},{"_id":"Testing","count":9},{"_id":"Tofu","count":9},{"_id":"***","count":8},{"_id":"Shrimp","count":7},{"_id":"Salads","count":6},{"_id":"Japanese","count":5},{"_id":"Rice","count":5},{"_id":"Turkey","count":4},{"_id":"Vietnamese","count":4},{"_id":"Beef","count":3},{"_id":"Burmese","count":3},{"_id":"Eggplant","count":3},{"_id":"Holiday","count":3},{"_id":"Lamb","count":3},{"_id":"Pizza","count":3},{"_id":"Potatoes","count":3},{"_id":"StickyRice","count":3},{"_id":"**","count":2},{"_id":"African","count":2},{"_id":"French","count":2},{"_id":"Hungarian","count":2},{"_id":"Lunch","count":2},{"_id":"Paleo","count":2},{"_id":"Appetizer","count":1},{"_id":"BBQ","count":1},{"_id":"Brunch","count":1},{"_id":"Chili","count":1},{"_id":"Greek","count":1},{"_id":"Sourdough","count":1},{"_id":"Tibet","count":1},{"_id":"Tuna","count":1},{"_id":"Turkish","count":1}]}' 20 | headers: 21 | CF-Cache-Status: 22 | - DYNAMIC 23 | CF-RAY: 24 | - 8341c026cab967cd-SJC 25 | Cache-Control: 26 | - private 27 | Connection: 28 | - keep-alive 29 | Content-Encoding: 30 | - gzip 31 | Content-Type: 32 | - application/json; charset=utf-8 33 | Date: 34 | - Tue, 12 Dec 2023 00:11:32 GMT 35 | Server: 36 | - cloudflare 37 | Transfer-Encoding: 38 | - chunked 39 | alt-svc: 40 | - h3=":443"; ma=86400 41 | etag: 42 | - W/"5c6-+oGdHEIR0Cq+yfdEh5tf92QjgPE" 43 | server-timing: 44 | - dc;desc=eu 45 | - total;dur=40.053 46 | x-api-cache: 47 | - MISS 48 | x-powered-by: 49 | - Express 50 | x-ratelimit-limit: 51 | - '120' 52 | x-ratelimit-remaining: 53 | - '119' 54 | x-ratelimit-reset: 55 | - '1702339952' 56 | status: 57 | code: 200 58 | message: OK 59 | version: 1 60 | -------------------------------------------------------------------------------- /tests/api/cassettes/test_get_user.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | Content-Type: 12 | - application/json 13 | User-Agent: 14 | - python-requests/2.31.0 15 | method: GET 16 | uri: https://api.raindrop.io/rest/v1/user 17 | response: 18 | body: 19 | string: '{"result":true,"user":{"tfa":{"enabled":false},"files":{"size":10000000000,"lastCheckPoint":"2023-10-01T21:47:03.246Z","used":8947690},"_id":1006974,"avatar":"","pro":true,"name":"MadHun","fullName":"MadHun","email":"peter.borocz+raindrop@gmail.com","groups":[{"title":"Collections","hidden":false,"sort":0,"collections":[29479383,29479296,29384146,26115550,26109849,26109558]}],"lastAction":"2023-12-12T00:10:55.616Z","lastVisit":"2023-12-12T00:06:55.272Z","registered":"2022-07-23T18:12:33.975Z","lastUpdate":"2023-12-12T00:10:55.616Z","config":{"raindrops_view":"list","raindrops_hide":["simple_info","grid_highlights","list_cover","simple_excerpt","simple_tags","grid_excerpt","grid_note","grid_info","list_excerpt","list_highlights","list_note"],"raindrops_buttons":["select","new_tab","preview","edit","remove"],"raindrops_search_by_score":true,"raindrops_search_incollection":true,"broken_level":"default","font_size":0,"add_default_collection":-1,"acknowledge":[],"tags_hide":false,"filters_hide":false,"mobile_add_auto_save":true,"raindrops_sort":"created","raindrops_grid_cover_size":2,"raindrops_list_cover_right":false,"default_collection_view":"list","last_collection":39866430},"password":true}}' 20 | headers: 21 | CF-Cache-Status: 22 | - DYNAMIC 23 | CF-RAY: 24 | - 8341c0406c6e9688-SJC 25 | Cache-Control: 26 | - private 27 | Connection: 28 | - keep-alive 29 | Content-Encoding: 30 | - gzip 31 | Content-Type: 32 | - application/json; charset=utf-8 33 | Date: 34 | - Tue, 12 Dec 2023 00:11:36 GMT 35 | Server: 36 | - cloudflare 37 | Transfer-Encoding: 38 | - chunked 39 | alt-svc: 40 | - h3=":443"; ma=86400 41 | etag: 42 | - W/"4b9-sVYuQmkFy3ChBzxbd0sMDVZ/Q/o" 43 | server-timing: 44 | - dc;desc=eu 45 | - get;dur=8.439 46 | - total;dur=18.417 47 | x-powered-by: 48 | - Express 49 | x-ratelimit-limit: 50 | - '120' 51 | x-ratelimit-remaining: 52 | - '118' 53 | x-ratelimit-reset: 54 | - '1702339952' 55 | status: 56 | code: 200 57 | message: OK 58 | version: 1 59 | -------------------------------------------------------------------------------- /tests/api/cassettes/test_lifecycle_raindrop_file.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: !!binary | 4 | LS1hYjRiZDc4NWZmMWQ4ZDM2ZDYzOWYwOThjMWFjNDZkYw0KQ29udGVudC1EaXNwb3NpdGlvbjog 5 | Zm9ybS1kYXRhOyBuYW1lPSJjb2xsZWN0aW9uSWQiDQoNCi0xDQotLWFiNGJkNzg1ZmYxZDhkMzZk 6 | NjM5ZjA5OGMxYWM0NmRjDQpDb250ZW50LURpc3Bvc2l0aW9uOiBmb3JtLWRhdGE7IG5hbWU9ImZp 7 | bGUiOyBmaWxlbmFtZT0idGVzdF9yYWluZHJvcC5wZGYiDQpDb250ZW50LVR5cGU6IGFwcGxpY2F0 8 | aW9uL3BkZg0KDQolUERGLTEuMwolxOXy5eun86DQxMYKMyAwIG9iago8PCAvRmlsdGVyIC9GbGF0 9 | ZURlY29kZSAvTGVuZ3RoIDQ5MSA+PgpzdHJlYW0KeAF9U9Fu0zAUffdXHN6cB9zYadJYmibRtGhF 10 | DG3MGkKIh5G1bKwpazOkfT5O7HtJuzLlwc71ufece3y9xSW2SP1XaIOJNdgt8QUbjKpWo26h+6+t 11 | PaI7bRi37ncp1n32WtxhhdEFTk4wOq8WM592eorprOoJirHKMTEYlxmKPPc/BzSdghSeZusTu63u 12 | 4IVNVWntWNQNpg5vU5WmqYGrkYWEuLgGI+c6qW4F6Rbu4zyB+4W58+zz82ooTBgStk+Va5VbWyBQ 13 | mVA/LqG+CfW/QV79SToxRsgfYeNJE2SFKiHvKfKUwEMg1xRYJvgO9+G4quy4Kl0onbGqf/3rMrgU 14 | lmH/Qp4tb27vNz//6wDGR7nyslS5sWV0QMd76Bdx6IBvt2/ujprjtlvEo5eRmwAWkiBtjEA2j1Ro 15 | aFfnMRh9MSPM+0iBRPSIVfz/TYAdMcSLgmy9+/vlmDsqFpIjm0MsJz8z5nXFQReNAOsiWfWOK3JB 16 | AvNJAA9kUfZnkse57DUrvyXMjskfE7HvwJSPePPQcM3dA/HxKbv5FI1/gaCbUK/MOvLj81dkylhb 17 | xseuwwuMy3DC4V/gGbX3LsEkUwXk9dyLCU/Qx/wm7yYn3MMnQi/ioFQU6JL6EeLZ8slWdwW/vjno 18 | QVz+BRh2CcMKZW5kc3RyZWFtCmVuZG9iagoxIDAgb2JqCjw8IC9UeXBlIC9QYWdlIC9QYXJlbnQg 19 | MiAwIFIgL1Jlc291cmNlcyA0IDAgUiAvQ29udGVudHMgMyAwIFIgL01lZGlhQm94IFswIDAgNjEy 20 | IDc5Ml0KPj4KZW5kb2JqCjQgMCBvYmoKPDwgL1Byb2NTZXQgWyAvUERGIC9UZXh0IF0gL0NvbG9y 21 | U3BhY2UgPDwgL0NzMSA1IDAgUiA+PiAvRm9udCA8PCAvVFQxIDYgMCBSCi9UVDIgNyAwIFIgPj4g 22 | Pj4KZW5kb2JqCjkgMCBvYmoKPDwgL04gMyAvQWx0ZXJuYXRlIC9EZXZpY2VSR0IgL0xlbmd0aCAy 23 | NjEyIC9GaWx0ZXIgL0ZsYXRlRGVjb2RlID4+CnN0cmVhbQp4AZ2Wd1RT2RaHz703vdASIiAl9Bp6 24 | CSDSO0gVBFGJSYBQAoaEJnZEBUYUESlWZFTAAUeHImNFFAuDgmLXCfIQUMbBUURF5d2MawnvrTXz 25 | 3pr9x1nf2ee319ln733XugBQ/IIEwnRYAYA0oVgU7uvBXBITy8T3AhgQAQ5YAcDhZmYER/hEAtT8 26 | vT2ZmahIxrP27i6AZLvbLL9QJnPW/3+RIjdDJAYACkXVNjx+JhflApRTs8UZMv8EyvSVKTKGMTIW 27 | oQmirCLjxK9s9qfmK7vJmJcm5KEaWc4ZvDSejLtQ3pol4aOMBKFcmCXgZ6N8B2W9VEmaAOX3KNPT 28 | +JxMADAUmV/M5yahbIkyRRQZ7onyAgAIlMQ5vHIOi/k5aJ4AeKZn5IoEiUliphHXmGnl6Mhm+vGz 29 | U/liMSuUw03hiHhMz/S0DI4wF4Cvb5ZFASVZbZloke2tHO3tWdbmaPm/2d8eflP9Pch6+1XxJuzP 30 | nkGMnlnfbOysL70WAPYkWpsds76VVQC0bQZA5eGsT+8gAPIFALTenPMehmxeksTiDCcLi+zsbHMB 31 | n2suK+g3+5+Cb8q/hjn3mcvu+1Y7phc/gSNJFTNlReWmp6ZLRMzMDA6Xz2T99xD/48A5ac3Jwyyc 32 | n8AX8YXoVVHolAmEiWi7hTyBWJAuZAqEf9Xhfxg2JwcZfp1rFGh1XwB9hTlQuEkHyG89AEMjAyRu 33 | P3oCfetbEDEKyL68aK2Rr3OPMnr+5/ofC1yKbuFMQSJT5vYMj2RyJaIsGaPfhGzBAhKQB3SgCjSB 34 | LjACLGANHIAzcAPeIACEgEgQA5YDLkgCaUAEskE+2AAKQTHYAXaDanAA1IF60AROgjZwBlwEV8AN 35 | cAsMgEdACobBSzAB3oFpCILwEBWiQaqQFqQPmULWEBtaCHlDQVA4FAPFQ4mQEJJA+dAmqBgqg6qh 36 | Q1A99CN0GroIXYP6oAfQIDQG/QF9hBGYAtNhDdgAtoDZsDscCEfCy+BEeBWcBxfA2+FKuBY+DrfC 37 | F+Eb8AAshV/CkwhAyAgD0UZYCBvxREKQWCQBESFrkSKkAqlFmpAOpBu5jUiRceQDBoehYZgYFsYZ 38 | 44dZjOFiVmHWYkow1ZhjmFZMF+Y2ZhAzgfmCpWLVsaZYJ6w/dgk2EZuNLcRWYI9gW7CXsQPYYew7 39 | HA7HwBniHHB+uBhcMm41rgS3D9eMu4Drww3hJvF4vCreFO+CD8Fz8GJ8Ib4Kfxx/Ht+PH8a/J5AJ 40 | WgRrgg8hliAkbCRUEBoI5wj9hBHCNFGBqE90IoYQecRcYimxjthBvEkcJk6TFEmGJBdSJCmZtIFU 41 | SWoiXSY9Jr0hk8k6ZEdyGFlAXk+uJJ8gXyUPkj9QlCgmFE9KHEVC2U45SrlAeUB5Q6VSDahu1Fiq 42 | mLqdWk+9RH1KfS9HkzOX85fjya2Tq5FrleuXeyVPlNeXd5dfLp8nXyF/Sv6m/LgCUcFAwVOBo7BW 43 | oUbhtMI9hUlFmqKVYohimmKJYoPiNcVRJbySgZK3Ek+pQOmw0iWlIRpC06V50ri0TbQ62mXaMB1H 44 | N6T705PpxfQf6L30CWUlZVvlKOUc5Rrls8pSBsIwYPgzUhmljJOMu4yP8zTmuc/jz9s2r2le/7wp 45 | lfkqbip8lSKVZpUBlY+qTFVv1RTVnaptqk/UMGomamFq2Wr71S6rjc+nz3eez51fNP/k/IfqsLqJ 46 | erj6avXD6j3qkxqaGr4aGRpVGpc0xjUZmm6ayZrlmuc0x7RoWgu1BFrlWue1XjCVme7MVGYls4s5 47 | oa2u7act0T6k3as9rWOos1hno06zzhNdki5bN0G3XLdTd0JPSy9YL1+vUe+hPlGfrZ+kv0e/W3/K 48 | wNAg2mCLQZvBqKGKob9hnmGj4WMjqpGr0SqjWqM7xjhjtnGK8T7jWyawiZ1JkkmNyU1T2NTeVGC6 49 | z7TPDGvmaCY0qzW7x6Kw3FlZrEbWoDnDPMh8o3mb+SsLPYtYi50W3RZfLO0sUy3rLB9ZKVkFWG20 50 | 6rD6w9rEmmtdY33HhmrjY7POpt3mta2pLd92v+19O5pdsN0Wu067z/YO9iL7JvsxBz2HeIe9DvfY 51 | dHYou4R91RHr6OG4zvGM4wcneyex00mn351ZzinODc6jCwwX8BfULRhy0XHhuBxykS5kLoxfeHCh 52 | 1FXbleNa6/rMTdeN53bEbcTd2D3Z/bj7Kw9LD5FHi8eUp5PnGs8LXoiXr1eRV6+3kvdi72rvpz46 53 | Pok+jT4Tvna+q30v+GH9Av12+t3z1/Dn+tf7TwQ4BKwJ6AqkBEYEVgc+CzIJEgV1BMPBAcG7gh8v 54 | 0l8kXNQWAkL8Q3aFPAk1DF0V+nMYLiw0rCbsebhVeH54dwQtYkVEQ8S7SI/I0shHi40WSxZ3RslH 55 | xUXVR01Fe0WXRUuXWCxZs+RGjFqMIKY9Fh8bFXskdnKp99LdS4fj7OIK4+4uM1yWs+zacrXlqcvP 56 | rpBfwVlxKh4bHx3fEP+JE8Kp5Uyu9F+5d+UE15O7h/uS58Yr543xXfhl/JEEl4SyhNFEl8RdiWNJ 57 | rkkVSeMCT0G14HWyX/KB5KmUkJSjKTOp0anNaYS0+LTTQiVhirArXTM9J70vwzSjMEO6ymnV7lUT 58 | okDRkUwoc1lmu5iO/kz1SIwkmyWDWQuzarLeZ0dln8pRzBHm9OSa5G7LHcnzyft+NWY1d3Vnvnb+ 59 | hvzBNe5rDq2F1q5c27lOd13BuuH1vuuPbSBtSNnwy0bLjWUb326K3tRRoFGwvmBos+/mxkK5QlHh 60 | vS3OWw5sxWwVbO3dZrOtatuXIl7R9WLL4oriTyXckuvfWX1X+d3M9oTtvaX2pft34HYId9zd6brz 61 | WJliWV7Z0K7gXa3lzPKi8re7V+y+VmFbcWAPaY9kj7QyqLK9Sq9qR9Wn6qTqgRqPmua96nu37Z3a 62 | x9vXv99tf9MBjQPFBz4eFBy8f8j3UGutQW3FYdzhrMPP66Lqur9nf19/RO1I8ZHPR4VHpcfCj3XV 63 | O9TXN6g3lDbCjZLGseNxx2/94PVDexOr6VAzo7n4BDghOfHix/gf754MPNl5in2q6Sf9n/a20FqK 64 | WqHW3NaJtqQ2aXtMe9/pgNOdHc4dLT+b/3z0jPaZmrPKZ0vPkc4VnJs5n3d+8kLGhfGLiReHOld0 65 | Prq05NKdrrCu3suBl69e8blyqdu9+/xVl6tnrjldO32dfb3thv2N1h67npZf7H5p6bXvbb3pcLP9 66 | luOtjr4Ffef6Xfsv3va6feWO/50bA4sG+u4uvnv/Xtw96X3e/dEHqQ9eP8x6OP1o/WPs46InCk8q 67 | nqo/rf3V+Ndmqb307KDXYM+ziGePhrhDL/+V+a9PwwXPqc8rRrRG6ketR8+M+YzderH0xfDLjJfT 68 | 44W/Kf6295XRq59+d/u9Z2LJxPBr0euZP0reqL45+tb2bedk6OTTd2nvpqeK3qu+P/aB/aH7Y/TH 69 | kensT/hPlZ+NP3d8CfzyeCZtZubf94Tz+wplbmRzdHJlYW0KZW5kb2JqCjUgMCBvYmoKWyAvSUND 70 | QmFzZWQgOSAwIFIgXQplbmRvYmoKMTIgMCBvYmoKPDwgL1R5cGUgL1N0cnVjdFRyZWVSb290IC9L 71 | IDExIDAgUiA+PgplbmRvYmoKMTEgMCBvYmoKPDwgL1R5cGUgL1N0cnVjdEVsZW0gL1MgL0RvY3Vt 72 | ZW50IC9QIDEyIDAgUiAvSyBbIDEzIDAgUiAxNCAwIFIgMTUgMCBSIDE2IDAgUgoxNyAwIFIgXSAg 73 | Pj4KZW5kb2JqCjEzIDAgb2JqCjw8IC9UeXBlIC9TdHJ1Y3RFbGVtIC9TIC9IIC9QIDExIDAgUiAv 74 | UGcgMSAwIFIgL0sgMSAgPj4KZW5kb2JqCjE0IDAgb2JqCjw8IC9UeXBlIC9TdHJ1Y3RFbGVtIC9T 75 | IC9QIC9QIDExIDAgUiAvUGcgMSAwIFIgL0sgMiAgPj4KZW5kb2JqCjE1IDAgb2JqCjw8IC9UeXBl 76 | IC9TdHJ1Y3RFbGVtIC9TIC9IMSAvUCAxMSAwIFIgL1BnIDEgMCBSIC9LIDMgID4+CmVuZG9iagox 77 | NiAwIG9iago8PCAvVHlwZSAvU3RydWN0RWxlbSAvUyAvUCAvUCAxMSAwIFIgL1BnIDEgMCBSIC9L 78 | IDQgID4+CmVuZG9iagoxNyAwIG9iago8PCAvVHlwZSAvU3RydWN0RWxlbSAvUyAvUCAvUCAxMSAw 79 | IFIgL1BnIDEgMCBSIC9LIDUgID4+CmVuZG9iagoyIDAgb2JqCjw8IC9UeXBlIC9QYWdlcyAvTWVk 80 | aWFCb3ggWzAgMCA2MTIgNzkyXSAvQ291bnQgMSAvS2lkcyBbIDEgMCBSIF0gPj4KZW5kb2JqCjE4 81 | IDAgb2JqCjw8IC9UeXBlIC9DYXRhbG9nIC9PdXRsaW5lcyAxMCAwIFIgL1BhZ2VzIDIgMCBSIC9N 82 | YXJrSW5mbyA8PCAvTWFya2VkIHRydWUKPj4gL1N0cnVjdFRyZWVSb290IDEyIDAgUiA+PgplbmRv 83 | YmoKOCAwIG9iagpbIDEgMCBSICAvWFlaIDAgNzkyIDAgXQplbmRvYmoKMTAgMCBvYmoKPDwgL0Zp 84 | cnN0IDE5IDAgUiAvTGFzdCAxOSAwIFIgL0NvdW50IDEgPj4KZW5kb2JqCjE5IDAgb2JqCjw8IC9Q 85 | YXJlbnQgMTAgMCBSIC9UaXRsZSAoSGVhZGluZykgL0Rlc3QgWyAxIDAgUiAvWFlaIDcyIDYzNCBu 86 | dWxsIF0gPj4KZW5kb2JqCjYgMCBvYmoKPDwgL1R5cGUgL0ZvbnQgL1N1YnR5cGUgL1RydWVUeXBl 87 | IC9CYXNlRm9udCAvQUFBQUFCK0hlbHZldGljYS1Cb2xkIC9Gb250RGVzY3JpcHRvcgoyMCAwIFIg 88 | L0VuY29kaW5nIC9NYWNSb21hbkVuY29kaW5nIC9GaXJzdENoYXIgMzIgL0xhc3RDaGFyIDExMCAv 89 | V2lkdGhzIFsgMjc4CjMzMyAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAg 90 | MCAwIDAgMCAwIDAgMCAwIDAgMCAwIDcyMiAwIDcyMgo3MjIgNjY3IDAgMCA3MjIgMjc4IDAgMCA2 91 | MTEgMCA3MjIgMCAwIDAgMCAwIDYxMSAwIDY2NyAwIDAgNjY3IDAgMCAwIDAgMCAwCjAgNTU2IDAg 92 | MCA2MTEgNTU2IDAgNjExIDAgMjc4IDAgMCAwIDAgNjExIF0gPj4KZW5kb2JqCjIwIDAgb2JqCjw8 93 | IC9UeXBlIC9Gb250RGVzY3JpcHRvciAvRm9udE5hbWUgL0FBQUFBQitIZWx2ZXRpY2EtQm9sZCAv 94 | RmxhZ3MgMzIgL0ZvbnRCQm94ClstMTAxOCAtNDgxIDEzNzIgOTYyXSAvSXRhbGljQW5nbGUgMCAv 95 | QXNjZW50IDc3MCAvRGVzY2VudCAtMjMwIC9DYXBIZWlnaHQKNzIwIC9TdGVtViAxNDkgL1hIZWln 96 | aHQgNTMyIC9TdGVtSCAxMjcgL0F2Z1dpZHRoIDYyMiAvTWF4V2lkdGggMTUwMCAvRm9udEZpbGUy 97 | CjIxIDAgUiA+PgplbmRvYmoKMjEgMCBvYmoKPDwgL0xlbmd0aDEgNTg5NiAvTGVuZ3RoIDM0NDAg 98 | L0ZpbHRlciAvRmxhdGVEZWNvZGUgPj4Kc3RyZWFtCngBnVh7cFTVGT/n3Ht3s3mRJbm7QNbN3uQm 99 | IRvYTUIePATCkhAxPiIg7vLOQCDBJKBECqIOYyNCrLXtWGKBqgWhyDhOfJSGjArNWPEBCrbClKZY 100 | purUOv6hpYWS5NLfd+8NWcLodJrN795zzj33O+d8v++123b/Aw0shW1lEqtbVL9+NTP/1DbG+NMr 101 | W+rXW33pDdwPr9zYFrD6vA933+r1a1rs/mXM37CmebP9vvw8Y2krGhvqV1nP2QDu5Y0YsOeX4q43 102 | trRtsvpSM+51zetW2s9lrM+CLfWb7PUZrRdorW9psOart+E+fv26DTQPfypdblt/f4M9n0fRb2cc 103 | 1ww2jQl6jGsaPpjh/Lu01xyh54C2+Hjh8lE3/4tlJpjDe8dvNe/n/xo6e6lvID/xivM1zHPYcsx3 104 | HAVGAWPJHM//nPSIuZL5jn3J6GarC1kPxiXWWMh7mIyX0TiKxniWxcaxdJaEua2FR5nCcq8bYUfx 105 | 2kQMxk8SrJZNZ0Us2zwrvSaxvOtf68EOZdZS2M1YoPrhpjFVLMgM9gwP8ixO+i4w6liqHGE5Si+b 106 | iP1NqO1mrrroK5z/ONbNrz7WzapuOsJcTFq+DI/XTAgEqpuquvgKdBonYCCoodU0ITCnS8qdMy+a 107 | Ewt0BDrmruoIzAk01q/qknPNOx40dMTCgS42P9qE64Ko1lUZy7zWbIjFpkLOWpKDVzC9IwYJa20J 108 | uJtD4UFMundCbaBLyquL3hXt2lqV2VVZFcvUtEB117G6aNexqkwtFsOs5ms7xY7p3NaeW7Dn5iCe 109 | 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 154 | 5vrYA9oKOLa+MfegJnSYVRxmYYYbT0Yjb3LkRJWKJhU5EmGR2giIYttPy334epFeURx9/MWzRp83 155 | KK3bMqth8FW58rkFuZFXTg5mi86FoQWWTunKrjYw+7dFszt8yUCTCjQd5pMP8y1kE1gYCb8CX4tu 156 | xq8YNTDlW9lt7C7zFY7fOqiEZpSWGJtFf5HCWxqaNza0Na2snxhZ14yfRK0ZNCsK0G+kbQDqdfY0 157 | 8ALwOvA28EfgM+AiXpKBDEAHSoEqYAGwCmgD2q/af5jPrrU5C4zoo2a97vmsEf3ZI/pY57r51SP6 158 | t4zozx3Rp99q4/dzx4g+znDd84Uj+rER/foRfZz/uvdNHuPOv2bE86YR/Vbq/xceql4ECmVuZHN0 159 | cmVhbQplbmRvYmoKNyAwIG9iago8PCAvVHlwZSAvRm9udCAvU3VidHlwZSAvVHJ1ZVR5cGUgL0Jh 160 | c2VGb250IC9BQUFBQUMrSGVsdmV0aWNhIC9Gb250RGVzY3JpcHRvcgoyMiAwIFIgL0VuY29kaW5n 161 | IC9NYWNSb21hbkVuY29kaW5nIC9GaXJzdENoYXIgMzIgL0xhc3RDaGFyIDEyMCAvV2lkdGhzIFsg 162 | Mjc4CjAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMjc4IDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAw 163 | IDAgMCAwIDAgMCAwIDAgNjY3IDAKNzIyIDAgNjExIDAgMCAwIDAgMCAwIDAgMCAwIDY2NyAwIDcy 164 | MiA2NjcgNjExIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDU1Ngo1NTYgNTAwIDU1NiA1NTYgMjc4 165 | IDAgNTU2IDIyMiAwIDUwMCAyMjIgODMzIDU1NiA1NTYgNTU2IDAgMzMzIDUwMCAyNzggNTU2CjAg 166 | MCA1MDAgXSA+PgplbmRvYmoKMjIgMCBvYmoKPDwgL1R5cGUgL0ZvbnREZXNjcmlwdG9yIC9Gb250 167 | TmFtZSAvQUFBQUFDK0hlbHZldGljYSAvRmxhZ3MgMzIgL0ZvbnRCQm94IFstOTUxIC00ODEgMTQ0 168 | NSAxMTIyXQovSXRhbGljQW5nbGUgMCAvQXNjZW50IDc3MCAvRGVzY2VudCAtMjMwIC9DYXBIZWln 169 | aHQgNzE3IC9TdGVtViA5OCAvWEhlaWdodAo1MjMgL1N0ZW1IIDg1IC9BdmdXaWR0aCA0NDEgL01h 170 | eFdpZHRoIDE1MDAgL0ZvbnRGaWxlMiAyMyAwIFIgPj4KZW5kb2JqCjIzIDAgb2JqCjw8IC9MZW5n 171 | dGgxIDEyMjM2IC9MZW5ndGggNzgwMyAvRmlsdGVyIC9GbGF0ZURlY29kZSA+PgpzdHJlYW0KeAHF 172 | Wnt4VNW13/u8ZyaZzPv9OpnMTCaTd0jIkECGkITwSIQEIUECSUggQVHEEIEKNyo+iIgi8hBa65On 173 | NENIYQJiqUXB3lbRVlRqrV7R2t7m2tuLXhVm5q59JqQhtX784dd7zuz3Ofus9dtrr7X2nt21clU7 174 | SkY9iEaz5resWIKkK2sDQlT14uUtKxJlfQOkv1zc3eVKlNmzCNGblqxYujxRls1BSPHo0lvWDL9v 175 | aEbI8FxHe0tboh1dgbSoAyoSZTwO0rSO5V2rE2XdRUi7brlt8XC7IQ3KpctbVg9/H70PZdetLcvb 176 | E89nNUOatuK2O7qGywykC1asbB9+HgO9zK0IQ60dTQTeyEUhFdxLEeI/Uzwq1ZB2uFpW/3rZopTS 177 | L5BakMqLah6R0g8+zD7/VfsVn2KL8DVUyKT+SAu8x/ljfoSSMLQPKbaMtEjvQWSPoKYAPo5YPIgW 178 | BfDP8Ak0CY1DfiBHA83tgZ/hF1H5mJqTaMI1Nehn+BiqAfrHo/SR1yKoGd0g1ViHOxpEU+GZ0V0f 179 | H9M1Oo5fQgxqC0TwUVfluk5TRQTVByJoGoQyCIUQAoHJJtSD96BHITwFgUad+CG0BsJGCE9AYEZy 180 | +6E0iB/qZ4TQcbwGWfD0kIJxztGZnSa5wvlWBHMDTzrfM318AptBuD7C5v5kJJssx0/hH6M25MTP 181 | Iw9ei6pROt51xH+Lsxma9qMVEHog0FKM8f5+R77zJZyJPAyGd7zIweCjzj/mZTk/yYtQuN/5si/C 182 | QPJzB5RCKc5T9iedP7Mvdb4E4WCi6YAfnjjq3G+/xbnVEcG7+p2P2SMY3tmSSFbZ4dWjzuX+7c62 183 | PKl95vYIdbDfGYT2uSGFs6hYdBbaLzpzfBEBQznLPtOZkfdrZxq8CI+5oFNPSO202bc6J0CTw17p 184 | mwDhBD6Ad6MMvLvfM915HLLA7pFp/uLtEfyDI9XpeZ4IXhsqqk7f7q/2efwznR5/lc8H+bln+Q38 185 | TfxkPp8P8Om8lxd5K68TNIJKUApJglwQBD6CX+gvc3In8EFUBrAcPCJwAhvBP4FK5gQ+JFUeOiYw 186 | AiUgQReJfzhABFYXwQcHVCQHmaOclOMi+NCRRNWhkBOmEEaM1KCiSB4iiBGFBQpNR2H8cIRD9xm6 187 | y0xlmknqYFXFP4uapZarceCfXyZsD2+fUd8QPmBvDOeTTNzeePVx09XMP027VkFTe3kgMKNuzZHu 188 | FcuWVLa7K5vdle0QmsMPdXeYwj2tLtfhZStIgytMe5tbF3eQtKU9vMLdXhFe5q5wHe6W3hvTvIQ0 189 | d7srDqMllXMaDi8JtVf0d4e6K90tFY1HWstXNl3zrY0j31pZ/i3fKiedrSTfapXeG/OtJtLcSr7V 190 | RL7VRL7VGmqVvkWYr+ysL7+jC6TTVdk5wxVOrw9Pmz2/IexqaayI4D1QWbEKIfYUUrEnUTrbgyxM 191 | DnIiFH8PwgWSxm6Mf8qeQarY8vh/0yUwqoMkULGyUnQKPYx2oz7EoX2QT0cL0U70Gl4Gk3sBGkDn 192 | sQNlox6Y+BE0E/0Kx+NvoiXoOXi+C72MtqHDKAneWY700LoZe+JroRyCfCvaEH8GpaFidD86iYLQ 193 | 62Y0FN8fPwKtdehGdAAdhPf/Hbupw4w2/pP4RSSg2dDnBmh5Mz4z3gfaLhN02Cyo3YBewh76QrwD 194 | mVAJUPdD9GP0NPo5+gu+Bw/EO+Ld8XPxj0BWTciG6uFehwfwR3Qfc3/8h/E/x2OARDrKgK82o63o 195 | Wei/D+5TGOFKfDPuwlvxNipE3UMNMPexxlgUcPCDNp0Kquk29CAgMIhOo7+hr/HnlIlW0V30K/HC 196 | +P8gBZoBXBJO2lE33A/AvRl4OoE5nIun4Fl4HX4cb8O/oTKoG6kG6k5qNfUpXUsvoNfQv2HuYPrZ 197 | TexOThH7In4ifib+NjKCWbgJrUTrgbuX0Tl0CX2DaejLhj24BJfjhXD34N3UIH4aD1Kz8Cl8jjqA 198 | /4A/xp/jyxRLJVF6KkB1UVupg9TL1Ot0J72NfoL+A/0FM4ml2KfZTzgP/7tYa2xj7PV4Sfyj+Feg 199 | YwUkwsiUo1q0CLUAtyvA9PwbcHEI7j4YtdPoFfSadH+MbWgIfQUoIKzBFpyPa+CuxTfgJbgTP4mP 200 | w/2SRMuXFAwEJaPUlJGyUfVUK7Wc6qHepnpoK51BT6fn031wn6XP05fpywzLaBk9M5WZhjYxy5ld 201 | cO9h9jH9zBtskJ3E1rJz2R52I7uJXsy+yZ7n1nObuX7uc+6voBdn8rfxm2B0XgOZ/TnI8t8vBqcB 202 | 9fnoVrQYV+BWtB1G42ncgnpButrwg4DXCpQeb6LX01OpXJCGl9APQFp3oXVoI70APR1/lz6A3gFJ 203 | uQW67EF7mXJkZ3fA6NyDckGK/n5vhVF/Cr0A8+Ig4IRQIHajNO9E9iXkC/kz/Ok+ryfNnSq6wCbY 204 | rBazyWjQ67QatSo5SSGXCTzHMjSFUWalu6rZFfY2hxmvu7o6i5TdLVDRMqqiGea6K1x17TNhF3mv 205 | BZqueTIETy4Z82Qo8WRo5EmscpWi0qxMV6XbFf51hdsVwfNnN0D+4Qp3oys8JOVrpPyjUj4Z8qII 206 | L7gqTR0VrjBudlWGq7o7eiubK7Iy8WAIIJBnZRLFEkIK0nEYTWlZBxoYTSFPVIYt7orKsNkNeWij 207 | PZUtbeFZsxsqK6yi2Ah1UFXXAN/IyuwMA53ooaQ2d9tDkRBqbSa5lgUNYbqlMUw1k77UgbDRXRE2 208 | rv3E9Pfi1VzlplGNYcpT1dLeWxUONT8E4JJiMym1bILSjHoXdEvd19gQxvcNE0FoXAaUEnITRsPT 209 | vMwVlrnL3R29y5oBXFTX0G8JWSTtHEazGvrNIbNUyMocNK0vEYH7wazJWZNJWiKa1ifSP96bqH/r 210 | FElN609/COmMuhEAMEHAPQ3oDLsWSx9xA7HFJGovRr2LiwEnuBoxsNkJ9EwJUyAztCfMeqa1hHvq 211 | r5LRUZEgrnlZRb/MbJGsVHkjPN/cq5oAIwXPq9yu3i/AnDe7h/5ybU3LcA3nUX2BSCMZ6BFZCeOW 212 | q/luYk09wHWHyd1BxrdbGlMou02VoyqgTKAhNId1YOFnNYhhVyNUgLuZOSOCZLMaDmO8uTGC4/dF 213 | UIV9EHxsetFCaM4kotZZAd+HQlYmVGSIkMvOdFXBl6uIrLh6Xb3T2npdVa4OECbGI6XQ0N7bmAMI 214 | 1jcATmgOfDHUaB3Jtjc2ToB+ckg/8Ao83tsIPSwb7gFSqSonCg/lZoK1pb2zGmY3hHsqrOFQRSOM 215 | AojvqVkN4VMguY2N8FTeCKVAMXGtEzTnA815GdBekOgFnJse6KKxt5f0Wd/gFsOnenutvWS+JcoR 216 | jMZWhIYrIog8QiCP4J5Z8C4kbtEqjYHoFoGsRoLpOBDpqxIFTv13I1w0Qje8OR6oLZIQLv6eEA5e 217 | D8ITrgvhkhFKr0G4FGguIQhP/NchPOkahMu+G+HQCN1A5GSgNiQhXP49ITzlehCuuC6EK0covQbh 218 | KqC5kiA89V+HcPU1CE/7boSnj9ANRM4AaqdLCM/8nhCuuR6Ea68L4RtGKL0G4VlA8w0E4dn/OoTr 219 | rkG4/rsRnjNCNxB5I1A7R0J47veE8LzrQbjhuhBuHKH0GoTnA82NBOGbRhAOWcNotB7uGaN20feu 220 | mBdcA3nTd0O+cIQRoHoRkL9Qgrz5e4K85Xogb70uyBePUHoN5G1A82ICefv/I+RLRkHOalA5FYSd 221 | jCDqY+eiHbAWfp75WMr3QX4A6pqhLDJ3oDoI3bATUgJpMYRqeMcG6QZ8Bm2A+h5IN3IHIA91EMhz 222 | 3dQBtBHayDeMUO6BvAIcYlguQQybhLCqfBFSF2ocrpGq/yGi/qHm2ytoqIZPIPZbmjmo42FtJ/uW 223 | tkSVHGhLgk05JUqBChVSQ6xBWqSDVbwBVqIIVtJmZEFkdxHBmjpxFaEiNBddwKX4VnyaqqUtdDWj 224 | Ye5iYuwybhzXw53jYvyt/KOwR9UhfCirk+2Ur5afVaQq6mBBWA7knoO9CRoom5LYeBRyIoiBIKgi 225 | CJ2DQMqQp9+HPKQ8pDSksvfRcXgLobmB49ATC2luXoFaVPsglDObI1f+gz35zZQIU3MZNrIA3T6I 226 | etAF+JY3pMUZtJw1Gi24DZkZtk1c3A6bSrWXaqK1le0Vn6KymqG83PEFenffm29egI0S2NJGOwBW 227 | BmiVAxrp6J5Q8fzk+epl1LLkZeq11J0iPy25Wk3ZBWcK49Q6EPIJDiOlcPgEJs/amZLntmTI9J50 228 | g9mfEcGLjojdS6QPltZES2tVX9YMXRpCZdGyIU0wJxrEak0wL3fKmpDGZGEFs4fz8iYmgFmLEMAo 229 | gGHf5+67cVMTzh9fVDjO53WL6lFZWnSR9STP8YZE6sfU6fVVt64qvyf2I3zoWG3eIzPXxVb9groT 230 | U8tDN/hrbi9e3Hhf7IPoVnqWe/wjj+bbYsHo/GVTFj01wRm9zGp33XTnQ405vkBR8/7Nd7wASD8P 231 | OCwGHJJBGpaGnA+ot2uofEHhSKGQwygIeVqLJdmjNJst58XujVdRJSwSBqMSX15sUHv0Xo5neYan 232 | eYpnOblKyMfYAJFMo8jHvA5W6gHCawZw2+QBVsldqKKAW4lFtY6ngLVz7ZO7ppdYUt7779iPz1L1 233 | OGfvtobdsfujfQf0vtsaH6qfitU4+/JOVvvOy7E3/3wy1n9VFpgDwIMMBUMm3sEwMtoBu5oygbsZ 234 | WxS04EFmuSKC5x0Rt70/zII0TBdRWVmpNEh5uVpRL6pJcPfRl6/8inozmnOGPTkQK++LtpF/FEDe 235 | mK8kedGhllBhZ1KnZk3SWg1TrWvQdejW6hhecKhVKjlWppBvywWK0yQxMp0uj7EYUmRAgt4QwQog 236 | 4SqKEglRtcYYJGRES1UgLZDgprzcJq2YD6POATpuJImEmF9U2EdtO/3X8x/E8s/QPavL74h14U33 237 | 72VP/v7sC/HoVmZwgjNGr4S/OyjYL0HsagkPH3o8pOGTp+FqthE3sJ1sm241KxhOwOaVGVmxLVTu 238 | Fl3eZs3tmlU6WuNw6mx6WnQYdIxXk+ZxIJnMyjsUlNdmFVwevdNjoPNSOq0Wv+D1+OTmdP95cdu1 239 | gn9p6Ldwo7LS0rJogp2g2ijNgKAmGGyCmRDIy8Ug61dFnRbziVxzvAM7MYi4Ue/OxjnYKzHtpqdu 240 | enblxCUxyxlq377lbyxvnTuP5WmFJvuSPIlJ4tuCa2MlZ2jbisd+FHTE5NTTeQujG/YVuFf2vDLH 241 | X6UTtaVzv3g0zxrtBUya428zX7KfoBzY34mFFvpTfG6vt0hZKE71tnrXKu9Mk90smJRGD9Wo7FAe 242 | SKXlygmpaalymrGZ7tfl5ARsE3Q0MyEgy6XkSkGdlupMz81VmzzGaYIn3ZLv9KinIU+OOS//KXHZ 243 | 8ACDEhjWBYCERh0MkgBDPSTpBdUQGfnsaEHT7dIkqknPVjuRQHkpb5aH81i8dCYKoKxsKWEzQFfY 244 | tc4AsupNAWw24SwmgGQ+RQB7FDgb8rwfIofGBo0GiGCyBQIqFUw5VamUlWKiae5GTQTngmFtI0Fd 245 | OC6tIJ/RuwnqqZxeZzRIY6HXMW6XzzseYwc/bvE3Kxb0z5j5zJlfzN6ENZf/iKecSMm76UJ41/yS 246 | c69vm70p9qP/jP3X7t00VYMvrKt9zDXpqdUF+Z6szMIFx16N/eGL7rI7Hm+9Jd+Vm5NasvT0pbc2 247 | PfRfDJhODHuTiHkNZJVH40IWzDkQTzGCDHQ5ukzRHpa5zJmFTQtNgVrVpZpLMD0uDav0MqJrQZbI 248 | jFWLhcxrMfUvY2r2ZN83f2OVMFmJjq+Lvy/tOqbAfnIp+n2oOCMXy1UKa5LNV1Ct6pQtU/FBQZMk 249 | o635fJrMrkqylwSobH/JsRKqJD/Do1HxrGDzpRptEdwbchvtTt5nz1ZQ9kJFKV9aatPx/ox9aZZJ 250 | Vr9teoqv2Dxx0ot4BzA0iLejhJoZFoGL0dMgAomhLxsChUOGnkyG7KHsIWIeYI5IQpBeNF6firDZ 251 | g4tSRGRyWEVkcOlELKai8ZSILHajCAxDRMZ3eGgTQ9qUJg3pRKzEKZjjOT0m+nUcjCeYDvckXECm 252 | mVoHD8EnlNid6vP6SOItHFc0XouVK2sXNW4XO/KXt+bV44FJ+qR71z5cIsr3sf/77MnuVUZPkkOd 253 | keltyjDIxr9+17aTx3f0vjE/c9qeLXobp0y25SzFtwiZpqwF9TMz6l/dXV29M7rDlkrT9yVx5e5Q 254 | 9bKfPrjtOS2+SHRTd/wDxsO+DN6IA60IZe/h99resdGpQoqDAjfHaGd5tdxhVyh0PsHismSrsrEf 255 | qc1O1wPiySYJVBj3ixeliYVgKsFPHVQn0DNpDJzcwOm8WCOHSM8bvVgrc3gTlodIvrZATaDQqHWU 256 | hIDenZYASRL6gu6+kueaz3795YW1c/KDe6glW7Y8/INB79SX2Zej/1kzOzYUuxSLhUvcNRvXffbS 257 | /g+Ovrlj4WGQMwrBTjt9jqmVfKm9oZy9ZrzTtE84YKKnC+rdOprWcXYLn2zXKay81WpU+TSY9lFq 258 | i13uM5pt8P8jf0RcuW5YYoCz0pqhYJCYpVHKQhKPccgseJL0ci9SalXApTpFxZuhxCJaxJhiaIUh 259 | 2YtSNBDJTJwXM5gTiX9BRIXogUQcICqgCRmMoGmJeOgTUlFAxIEqVKECnjr/sbFPtXL9C9NzH3xs 260 | xb3mPsdfT7z1Ddb81sbUht9ZfO++5U89/f7GO99+BRd8Cn8TTGABg+L4BXoIxlWB7OjOUP545VTl 261 | POVeZr+V9Qg6KsWuQoLdzmvllN2oYLO12Sq/WmNxKnwWs8P5gLiyfDT7MMBjx9ZissnkCGOTAniz 262 | QYTMlBfJrYIXGISfNAs0RLwloef0CGyJukDtLiRsocJxmoIvH3t63dN71j64H/fW50489EzZC7cd 263 | iX3z+Qd40WfvvPbvvzj3S2r8OMcMyv7NpG2LG3DWN3/G80CHVMcvMBb458IG/3J5cFJozQ7hCcte 264 | J80qqRRWp1dqUvS6UFJIJ/gteIbiKH0Gv0qfsb4rvCc773zX/ZnxM7fijPqMhlogsGJayi6DPS3I 265 | 8bxBtNt4ud2g8PA7bHttx2AOMB5DisfGmuVJvFrpS7H7WIsvLZv3mc1e32/FPQnhB9mXRP+3UWJP 266 | QXNAktM0IifEiwC7kpgOVcjNsDT8LYRZhnN61SqNSqvSqRguyZNqTfPCKsXuxQ67zMh7kUKv9OJk 267 | pdsiQhULkWACuUpWQSSZEknXSKYkI5BxN769Cd3eRESIOKWiA6YUuHMgQKBrOEBbDUIkmfBUjsfU 268 | wPniIo3qyufsozsenpOrO8zfkFe3ZnLd2difsek/sFORPv3QXftY7Gam3nzj7FumP/PsK01FU0u2 269 | ZM+yqbAb/hujcHnMu6rqniO9mBwOAZsBaxXKyL4Fa5iaUIC3c3I7jVN0QUMyp5GbwXQok9V+o4bX 270 | pCidSkp5RWc2ma+IS9cnRCzaFDxN/CzVaENSBl5LXq5mfFFBPrggZF5welgvgGlxFxYU/tRdNqBO 271 | M9rMijpX/0D/tm1s+bgFFPUchW/8yeYrbfQPN++T6NoARofMAQOqD2XCKAtG3ij4GJ92Fb9KELTJ 272 | lFaPkNrO8fokebJfbjFhvR8ZzEYTHKM4IrYm5sDIGgK8KEm7BTEZUEl5gU5PKHK3mqwUgEi1e8NA 273 | qGDePX+qzxp05D2w4ugAKKv3Z4vBZxufjM6mnu0e37DrfBQO8RA9BfThErC1ZH1WFLLxnzAAJkfL 274 | ibkFnP08DQpGduDvlJyOlp4egUlaRYEGdasBmQ3H4GIyLp9nT/5K4r0HeCf+sQLtDbU1UniCgM0U 275 | CISRm8cuZddwq/kH2EH6NfoCrNRYDk43yGhqA/U4gEhTQY1MxrDwRxm3XAOoCTz8ZcZyMoEFlSYH 276 | /4Dm5Dwn5yzJMkruRwpzUnK/2DqIDQkrSwArNdeqPjVJfmdpGbGuGMIDNdkBYZ3q58wD2aZAE7tO 277 | dUollArEaSDiuxJYwQUyEDFe7e45hF//NLYEH/401r/jEHvyykF8JnZbtJWy9cZulfjbCNhNlLDz 278 | h2AUh9eblB/RsOIcBRksABNrzjJYUwBYGwcGyMJV6gPw5zzMVORF94VKeIFXcilGwag0pvgEH0z5 279 | avNcxVJFktsjt9jdZjnFGD2i3WhP5njEWW0eWitPh4FS++GEB+63+MnBlhDoxGyP34vMvvQITh4t 280 | RBdVl4YuXV0AG0thiVEzJPmcxOG4KlH6YYkyXvUQQLCG5WqUhPWHxjXe3lObmVb6TPu7tRknbq5Z 281 | 9sQxi3/Fkr0DTM7OG9ImlqVVza3/4ZzN0fHUZzfP2rwnuoU6sTx/xpNvEMmT5I4eAh1KdhsWhvKO 282 | cWc4iuF0nE/XzXXxrC6J0plUYPkRZ1LILbzFgpL8MosNZ5v8ZmS2gvt1zfRIqMCEfQS+hsCtHp4i 283 | GCy8fhQrZI6ATlJimCd4w8GZBzouzso8Zs9dH/JPL86yDuC9QP/Cuh/Pe4bMldbStmRDeeHtndE3 284 | gFiYJSXx9xgR7HqStFPyaKhgp7Bd9YTheWafsEe13xARzgrvMJ8o/6RLmiBwdhOfZNcozLzZrKd8 285 | KRarzKc3W6wRLAPrPqy9EwuCEcsuGfVM2EryKrQy0LRqyot5I+TYZMjJdUlehFUQCQYw5rQSIsmY 286 | k4gY8TSNtF9APHZDgQa0LiWCpZMM+If35c48/vz27c/CwY4rsf/9fewK1vyR68Ipe7YvfPxK/8GL 287 | 9IXYX8CdicZ+ggNXwGkMERveHbuR8QDrSpSKukKZ+4W9RipdcNnUSs6u51M4pd2mSFVSPpMlTQ6e 288 | mehPTTG7077VM5NcM7UkZ7DzYTNYEWvxMl5kBcZYA0TYrPQi2ijxJLFF/DPijSXGjCxCCnBBQj7h 289 | z3RiV8BlVbupV/d6qo6fqPRAHMvuKwrd9IOjsWNdu9bU5ZYMrPnNWz0LDp9o23XXvD304c3T0ktj 290 | fwIen9m+qNAxLfp7YjtgHlOPwRxUoxtCXh/tTR5PT2UYpaCilDK1LMknEDFUywWLFhMfBZk12giu 291 | hImVMB/E+wRVAxa3pux09DSxwIn9HaKfJdEbsR8w9w/qn7uZNdlVVtWDj8FUGSzaTdEv0VTfyuhO 292 | Mi/K4+/QR5kZcPImB2eHHimW7WS3a57Q7dTvzODS0zy+IrFKnJo21Tc3bZ5vSdpS75qkNclrlN3u 293 | rrQuT5d3j2NfppYG081mMdlaZNFbjTaTPkuXnZ6i6IQVepGH8qQmy5mA1vSqza7lGXv2roAih5cp 294 | VRSPcsQci9NkMPmMk9K9vC/dkqd0+lSTkC/bnJvXP+JvgAoJEo8jGlRBjrAbzIF4eCULS1lJpSSW 295 | sDNxFuXVw9JVVDpFJIMzbRhWryJiMyBn10CdVWcSsSslVURiqjJZ8MlF7PXI5LCaFeGcJUQOtU0k 296 | K9jECifhsEpeqyQiVwUf3FettIaVxGV4CStZRv4f17AgOF4f/lzwVOxr2znRd8cjGyd3/W7wbzdP 297 | oQ6w3klPLOmsTK+98+Xyzvc++PwMj4/hWfNz5827qTINPLXUjGl373xx8/yOiflTa0NVGWatPSez 298 | 8vFHzr33FPU1yJIx/jklY+eDdqj7aXK2/JQSR3BZyMMYgkaaU8rVFlDXcLrHj/RKfQrtpCn6igF2 299 | 0sAXGfb2x/giOURJR0uHVNGLkvEgHgiZB1fXbN5C4o7sO3rwoFefl+zQOaf41s/fsoWdH3t7a7Sy 300 | WKvA1GaZcPdS6pWtYG8o1BP/mP4A5rMRKFwYmhDRndVRMq2gM2vNunTuTvodMLaIVcoRlyxnQXeZ 301 | eJMJlhDZcn+SwmLBfkLsW1e9gRqivIj4w/AnViZlpUQgiGsCOzoJQmHNSZzu8ZIf6AN6PbjYknvv 302 | ixWegQOUe9zSrZ/UZ+E+BnZF68Y175v/I0p5+c0nJ2bMeaJuI/WuhcxP2BSgP2JyIHWjw3KhLMwV 303 | HEZcWZguwOGknHDyeTiLJldrDlNCMJibpy2wYiPYcGLGHX/68uvfxXbgNZ/GvozFLuI1TE7sAbyG 304 | jV6O/g4/FruV8hBMpCveDue5vu2yQyUNusGDfHA+rAAVwm74eFSBKlGVdFZsmnQerBbO5ZITa3Vw 305 | Cu1G2C2fhxrgVBehXgOBXBzst6PJ5JoSqG6/pbu9q3Nxi/SE1AzRXRAehACKAE7kIThfhBBx096F 306 | 8BmEr6A7AYIJQjqEYgjVEBogdEBYDeFBCDsh7IMQgXAWwrsQPoPwFTArQDBBSIdQHB++oG80ksew 307 | Cri2nD2mXD6mXDGmXDWmPGtMuW5MuX5Mec6YMkFpNH2tY8qLx5TbxpSlcR3F35Ix7R1jyp1jyjeP 308 | KZPzYaPpkc7Bj+qfeIej228bU14xprxyTPmOMWXpnPuo/uHE5zX9rybl/wMoe37kCmVuZHN0cmVh 309 | bQplbmRvYmoKMjQgMCBvYmoKPDwgL1RpdGxlIChzYW1wbGUpIC9Qcm9kdWNlciAobWFjT1MgVmVy 310 | c2lvbiAxMy4wLjEgXChCdWlsZCAyMkE0MDBcKSBRdWFydHogUERGQ29udGV4dCkKL0NyZWF0b3Ig 311 | KFBhZ2VzKSAvQ3JlYXRpb25EYXRlIChEOjIwMjMwMTEyMDAwNzE2WjAwJzAwJykgL01vZERhdGUg 312 | KEQ6MjAyMzAxMTIwMDA3MTZaMDAnMDAnKQo+PgplbmRvYmoKeHJlZgowIDI1CjAwMDAwMDAwMDAg 313 | NjU1MzUgZiAKMDAwMDAwMDU4NSAwMDAwMCBuIAowMDAwMDA0MDY1IDAwMDAwIG4gCjAwMDAwMDAw 314 | MjIgMDAwMDAgbiAKMDAwMDAwMDY4OSAwMDAwMCBuIAowMDAwMDAzNTA5IDAwMDAwIG4gCjAwMDAw 315 | MDQ0NTEgMDAwMDAgbiAKMDAwMDAwODYwOCAwMDAwMCBuIAowMDAwMDA0MjY3IDAwMDAwIG4gCjAw 316 | MDAwMDA3OTcgMDAwMDAgbiAKMDAwMDAwNDMwNiAwMDAwMCBuIAowMDAwMDAzNTk4IDAwMDAwIG4g 317 | CjAwMDAwMDM1NDQgMDAwMDAgbiAKMDAwMDAwMzcwNCAwMDAwMCBuIAowMDAwMDAzNzc2IDAwMDAw 318 | IG4gCjAwMDAwMDM4NDggMDAwMDAgbiAKMDAwMDAwMzkyMSAwMDAwMCBuIAowMDAwMDAzOTkzIDAw 319 | MDAwIG4gCjAwMDAwMDQxNDggMDAwMDAgbiAKMDAwMDAwNDM2NCAwMDAwMCBuIAowMDAwMDA0ODIz 320 | IDAwMDAwIG4gCjAwMDAwMDUwODAgMDAwMDAgbiAKMDAwMDAwOTAxMyAwMDAwMCBuIAowMDAwMDA5 321 | MjYzIDAwMDAwIG4gCjAwMDAwMTcxNTUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSAyNSAvUm9v 322 | dCAxOCAwIFIgL0luZm8gMjQgMCBSIC9JRCBbIDw0NTc0ZmE3ZmIwYWMzYzQyM2I4ZGExMDU2NDZj 323 | OTk3Zj4KPDQ1NzRmYTdmYjBhYzNjNDIzYjhkYTEwNTY0NmM5OTdmPiBdID4+CnN0YXJ0eHJlZgox 324 | NzM1MwolJUVPRgoNCi0tYWI0YmQ3ODVmZjFkOGQzNmQ2MzlmMDk4YzFhYzQ2ZGMtLQ0K 325 | headers: 326 | Accept: 327 | - '*/*' 328 | Accept-Encoding: 329 | - gzip, deflate 330 | Connection: 331 | - keep-alive 332 | Content-Length: 333 | - '18291' 334 | Content-Type: 335 | - multipart/form-data; boundary=ab4bd785ff1d8d36d639f098c1ac46dc 336 | User-Agent: 337 | - python-requests/2.31.0 338 | method: PUT 339 | uri: https://api.raindrop.io/rest/v1/raindrop/file 340 | response: 341 | body: 342 | string: '{"result":true,"item":{"_id":693116161,"link":"https://api.raindrop.io/v2/raindrop/693116161/file?type=application/pdf","domain":"up.raindrop.io","title":"test_raindrop.pdf","excerpt":"","note":"","type":"document","user":{"$ref":"users","$id":1006974},"creatorRef":1006974,"cover":"https://rdl.ink/render/https%3A%2F%2Fup.raindrop.io%2Fraindrop%2Ffiles%2F693%2F116%2F161%2Ftest_raindrop.pdf","media":[],"tags":[],"file":{"name":"test_raindrop.pdf","size":18012,"type":"application/pdf"},"removed":false,"sort":693116161,"created":"2023-12-12T00:11:52.905Z","lastUpdate":"2023-12-12T00:11:53.046Z","__v":0,"collection":{"$ref":"collections","$id":-1,"oid":-1},"collectionId":-1}}' 343 | headers: 344 | CF-Cache-Status: 345 | - DYNAMIC 346 | CF-RAY: 347 | - 8341c0a6ac9b171a-SJC 348 | Cache-Control: 349 | - private 350 | Connection: 351 | - keep-alive 352 | Content-Encoding: 353 | - gzip 354 | Content-Type: 355 | - application/json; charset=utf-8 356 | Date: 357 | - Tue, 12 Dec 2023 00:11:53 GMT 358 | Server: 359 | - cloudflare 360 | Transfer-Encoding: 361 | - chunked 362 | alt-svc: 363 | - h3=":443"; ma=86400 364 | etag: 365 | - W/"2a7-WlQ1y/GCoS5peJ4PJnlAHm1NLfU" 366 | server-timing: 367 | - dc;desc=eu 368 | - total;dur=218.246 369 | x-powered-by: 370 | - Express 371 | x-ratelimit-limit: 372 | - '120' 373 | x-ratelimit-remaining: 374 | - '119' 375 | x-ratelimit-reset: 376 | - '1702339972' 377 | status: 378 | code: 200 379 | message: OK 380 | - request: 381 | body: '{"title": "A Sample Title", "tags": ["SampleTag"]}' 382 | headers: 383 | Accept: 384 | - '*/*' 385 | Accept-Encoding: 386 | - gzip, deflate 387 | Connection: 388 | - keep-alive 389 | Content-Length: 390 | - '50' 391 | Content-Type: 392 | - application/json 393 | User-Agent: 394 | - python-requests/2.31.0 395 | method: PUT 396 | uri: https://api.raindrop.io/rest/v1/raindrop/693116161 397 | response: 398 | body: 399 | string: '{"result":true,"item":{"_id":693116161,"link":"https://api.raindrop.io/v2/raindrop/693116161/file?type=application/pdf","domain":"up.raindrop.io","title":"A 400 | Sample Title","excerpt":"","note":"","type":"document","user":{"$ref":"users","$id":1006974},"creatorRef":{"_id":1006974,"avatar":"","name":"MadHun","email":""},"cover":"https://rdl.ink/render/https%3A%2F%2Fup.raindrop.io%2Fraindrop%2Ffiles%2F693%2F116%2F161%2Ftest_raindrop.pdf","media":[],"tags":["SampleTag"],"highlights":[],"file":{"name":"test_raindrop.pdf","size":18012,"type":"application/pdf"},"removed":false,"sort":693116161,"created":"2023-12-12T00:11:52.905Z","lastUpdate":"2023-12-12T00:11:53.368Z","collection":{"$ref":"collections","$id":-1,"oid":-1},"__v":1,"collectionId":-1}}' 401 | headers: 402 | CF-Cache-Status: 403 | - DYNAMIC 404 | CF-RAY: 405 | - 8341c0a9d8d315b8-SJC 406 | Cache-Control: 407 | - private 408 | Connection: 409 | - keep-alive 410 | Content-Encoding: 411 | - gzip 412 | Content-Type: 413 | - application/json; charset=utf-8 414 | Date: 415 | - Tue, 12 Dec 2023 00:11:53 GMT 416 | Server: 417 | - cloudflare 418 | Transfer-Encoding: 419 | - chunked 420 | alt-svc: 421 | - h3=":443"; ma=86400 422 | etag: 423 | - W/"2ee-89AhOTYXZY3+2SgpMKk0TxducNM" 424 | server-timing: 425 | - dc;desc=eu 426 | - update_raindrop;dur=48.501 427 | - total;dur=58.265 428 | x-powered-by: 429 | - Express 430 | x-ratelimit-limit: 431 | - '120' 432 | x-ratelimit-remaining: 433 | - '112' 434 | x-ratelimit-reset: 435 | - '1702339952' 436 | status: 437 | code: 200 438 | message: OK 439 | - request: 440 | body: '{}' 441 | headers: 442 | Accept: 443 | - '*/*' 444 | Accept-Encoding: 445 | - gzip, deflate 446 | Connection: 447 | - keep-alive 448 | Content-Length: 449 | - '2' 450 | Content-Type: 451 | - application/json 452 | User-Agent: 453 | - python-requests/2.31.0 454 | method: DELETE 455 | uri: https://api.raindrop.io/rest/v1/raindrop/693116161 456 | response: 457 | body: 458 | string: '{"result":true,"item":{"_id":693116161,"link":"https://api.raindrop.io/v2/raindrop/693116161/file?type=application/pdf","title":"A 459 | Sample Title","excerpt":"","note":"","type":"document","user":{"$ref":"users","$id":1006974},"cover":"https://rdl.ink/render/https%3A%2F%2Fup.raindrop.io%2Fraindrop%2Ffiles%2F693%2F116%2F161%2Ftest_raindrop.pdf","tags":["SampleTag"],"removed":true,"collection":{"$ref":"collections","$id":-99,"oid":-99},"media":[],"created":"2023-12-12T00:11:52.905Z","lastUpdate":"2023-12-12T00:11:53.775Z","domain":"up.raindrop.io","creatorRef":{"_id":1006974,"avatar":"","name":"MadHun","email":""},"sort":693116161,"file":{"name":"test_raindrop.pdf","size":18012,"type":"application/pdf"},"collectionId":-99,"order":0}}' 460 | headers: 461 | CF-Cache-Status: 462 | - DYNAMIC 463 | CF-RAY: 464 | - 8341c0ac6b1b171a-SJC 465 | Cache-Control: 466 | - private 467 | Connection: 468 | - keep-alive 469 | Content-Encoding: 470 | - gzip 471 | Content-Type: 472 | - application/json; charset=utf-8 473 | Date: 474 | - Tue, 12 Dec 2023 00:11:53 GMT 475 | Server: 476 | - cloudflare 477 | Transfer-Encoding: 478 | - chunked 479 | alt-svc: 480 | - h3=":443"; ma=86400 481 | etag: 482 | - W/"2e2-PNelEYQat04xOVxRuONIcJVI88g" 483 | server-timing: 484 | - dc;desc=eu 485 | - total;dur=67.943 486 | x-powered-by: 487 | - Express 488 | x-ratelimit-limit: 489 | - '120' 490 | x-ratelimit-remaining: 491 | - '111' 492 | x-ratelimit-reset: 493 | - '1702339952' 494 | status: 495 | code: 200 496 | message: OK 497 | - request: 498 | body: null 499 | headers: 500 | Accept: 501 | - '*/*' 502 | Accept-Encoding: 503 | - gzip, deflate 504 | Connection: 505 | - keep-alive 506 | Content-Type: 507 | - application/json 508 | User-Agent: 509 | - python-requests/2.31.0 510 | method: GET 511 | uri: https://api.raindrop.io/rest/v1/693116161 512 | response: 513 | body: 514 | string: ' 515 | 516 | 517 | 518 | 519 | 520 | 521 | 522 | Error 523 | 524 | 525 | 526 | 527 | 528 |
Cannot GET /rest/v1/693116161
529 | 530 | 531 | 532 | 533 | 534 | ' 535 | headers: 536 | CF-Cache-Status: 537 | - DYNAMIC 538 | CF-RAY: 539 | - 8341c0aef840fa92-SJC 540 | Cache-Control: 541 | - private 542 | Connection: 543 | - keep-alive 544 | Content-Encoding: 545 | - gzip 546 | Content-Type: 547 | - text/html; charset=utf-8 548 | Date: 549 | - Tue, 12 Dec 2023 00:11:54 GMT 550 | Server: 551 | - cloudflare 552 | Transfer-Encoding: 553 | - chunked 554 | alt-svc: 555 | - h3=":443"; ma=86400 556 | content-security-policy: 557 | - default-src 'none' 558 | server-timing: 559 | - dc;desc=eu 560 | - total;dur=9.651 561 | x-content-type-options: 562 | - nosniff 563 | x-powered-by: 564 | - Express 565 | x-ratelimit-limit: 566 | - '120' 567 | x-ratelimit-remaining: 568 | - '110' 569 | x-ratelimit-reset: 570 | - '1702339952' 571 | status: 572 | code: 404 573 | message: Not Found 574 | version: 1 575 | -------------------------------------------------------------------------------- /tests/api/cassettes/test_lifecycle_raindrop_link.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: '{"type": "link", "link": "https://www.google.com/", "excerpt": "excerpt/description 4 | text", "important": true, "tags": ["abc", "def"], "title": "a Title"}' 5 | headers: 6 | Accept: 7 | - '*/*' 8 | Accept-Encoding: 9 | - gzip, deflate 10 | Connection: 11 | - keep-alive 12 | Content-Length: 13 | - '153' 14 | Content-Type: 15 | - application/json 16 | User-Agent: 17 | - python-requests/2.31.0 18 | method: POST 19 | uri: https://api.raindrop.io/rest/v1/raindrop 20 | response: 21 | body: 22 | string: '{"result":true,"item":{"link":"https://www.google.com/","title":"a 23 | Title","excerpt":"excerpt/description text","note":"","type":"link","user":{"$ref":"users","$id":1006974},"cover":"","tags":["abc","def"],"important":true,"removed":false,"media":[],"created":"2023-12-12T00:11:50.615Z","lastUpdate":"2023-12-12T00:11:50.615Z","domain":"google.com","collection":{"$ref":"collections","$id":-1,"oid":-1},"_id":693116158,"creatorRef":1006974,"sort":693116158,"__v":0,"collectionId":-1}}' 24 | headers: 25 | CF-Cache-Status: 26 | - DYNAMIC 27 | CF-RAY: 28 | - 8341c098a90d9459-SJC 29 | Cache-Control: 30 | - private 31 | Connection: 32 | - keep-alive 33 | Content-Encoding: 34 | - gzip 35 | Content-Type: 36 | - application/json; charset=utf-8 37 | Date: 38 | - Tue, 12 Dec 2023 00:11:50 GMT 39 | Server: 40 | - cloudflare 41 | Transfer-Encoding: 42 | - chunked 43 | alt-svc: 44 | - h3=":443"; ma=86400 45 | etag: 46 | - W/"1e3-+mTu4bhqwABd/dquCZzpDAAohe8" 47 | server-timing: 48 | - dc;desc=eu 49 | - total;dur=56.622 50 | x-powered-by: 51 | - Express 52 | x-ratelimit-limit: 53 | - '120' 54 | x-ratelimit-remaining: 55 | - '117' 56 | x-ratelimit-reset: 57 | - '1702339952' 58 | status: 59 | code: 200 60 | message: OK 61 | - request: 62 | body: '{"title": "a NEW/EDITED Title"}' 63 | headers: 64 | Accept: 65 | - '*/*' 66 | Accept-Encoding: 67 | - gzip, deflate 68 | Connection: 69 | - keep-alive 70 | Content-Length: 71 | - '31' 72 | Content-Type: 73 | - application/json 74 | User-Agent: 75 | - python-requests/2.31.0 76 | method: PUT 77 | uri: https://api.raindrop.io/rest/v1/raindrop/693116158 78 | response: 79 | body: 80 | string: '{"result":true,"item":{"_id":693116158,"link":"https://www.google.com/","domain":"google.com","title":"a 81 | NEW/EDITED Title","excerpt":"excerpt/description text","note":"","type":"link","user":{"$ref":"users","$id":1006974},"creatorRef":{"_id":1006974,"avatar":"","name":"MadHun","email":""},"cover":"","media":[],"tags":["abc","def"],"highlights":[],"important":true,"removed":false,"sort":693116158,"created":"2023-12-12T00:11:50.615Z","lastUpdate":"2023-12-12T00:11:51.123Z","collection":{"$ref":"collections","$id":-1,"oid":-1},"__v":1,"collectionId":-1}}' 82 | headers: 83 | CF-Cache-Status: 84 | - DYNAMIC 85 | CF-RAY: 86 | - 8341c09bde3a642f-SJC 87 | Cache-Control: 88 | - private 89 | Connection: 90 | - keep-alive 91 | Content-Encoding: 92 | - gzip 93 | Content-Type: 94 | - application/json; charset=utf-8 95 | Date: 96 | - Tue, 12 Dec 2023 00:11:51 GMT 97 | Server: 98 | - cloudflare 99 | Transfer-Encoding: 100 | - chunked 101 | alt-svc: 102 | - h3=":443"; ma=86400 103 | etag: 104 | - W/"22d-gFYs5LEGwGTkkS4+xXn4NS9A4RU" 105 | server-timing: 106 | - dc;desc=eu 107 | - update_raindrop;dur=50.985 108 | - total;dur=60.129 109 | x-powered-by: 110 | - Express 111 | x-ratelimit-limit: 112 | - '120' 113 | x-ratelimit-remaining: 114 | - '116' 115 | x-ratelimit-reset: 116 | - '1702339952' 117 | status: 118 | code: 200 119 | message: OK 120 | - request: 121 | body: '{}' 122 | headers: 123 | Accept: 124 | - '*/*' 125 | Accept-Encoding: 126 | - gzip, deflate 127 | Connection: 128 | - keep-alive 129 | Content-Length: 130 | - '2' 131 | Content-Type: 132 | - application/json 133 | User-Agent: 134 | - python-requests/2.31.0 135 | method: DELETE 136 | uri: https://api.raindrop.io/rest/v1/raindrop/693116158 137 | response: 138 | body: 139 | string: '{"result":true,"item":{"_id":693116158,"link":"https://www.google.com/","title":"a 140 | NEW/EDITED Title","excerpt":"excerpt/description text","note":"","type":"link","user":{"$ref":"users","$id":1006974},"cover":"","tags":["abc","def"],"important":true,"removed":true,"media":[],"created":"2023-12-12T00:11:50.615Z","lastUpdate":"2023-12-12T00:11:52.001Z","domain":"google.com","collection":{"$ref":"collections","$id":-99,"oid":-99},"creatorRef":{"_id":1006974,"avatar":"","name":"MadHun","email":""},"sort":693116158,"collectionId":-99,"order":0}}' 141 | headers: 142 | CF-Cache-Status: 143 | - DYNAMIC 144 | CF-RAY: 145 | - 8341c09e4c9615d0-SJC 146 | Cache-Control: 147 | - private 148 | Connection: 149 | - keep-alive 150 | Content-Encoding: 151 | - gzip 152 | Content-Type: 153 | - application/json; charset=utf-8 154 | Date: 155 | - Tue, 12 Dec 2023 00:11:52 GMT 156 | Server: 157 | - cloudflare 158 | Transfer-Encoding: 159 | - chunked 160 | alt-svc: 161 | - h3=":443"; ma=86400 162 | etag: 163 | - W/"221-5BRTdMVxCubZTayBfWyoZza7o8k" 164 | server-timing: 165 | - dc;desc=eu 166 | - total;dur=133.584 167 | x-powered-by: 168 | - Express 169 | x-ratelimit-limit: 170 | - '120' 171 | x-ratelimit-remaining: 172 | - '115' 173 | x-ratelimit-reset: 174 | - '1702339952' 175 | status: 176 | code: 200 177 | message: OK 178 | - request: 179 | body: null 180 | headers: 181 | Accept: 182 | - '*/*' 183 | Accept-Encoding: 184 | - gzip, deflate 185 | Connection: 186 | - keep-alive 187 | Content-Type: 188 | - application/json 189 | User-Agent: 190 | - python-requests/2.31.0 191 | method: GET 192 | uri: https://api.raindrop.io/rest/v1/693116158 193 | response: 194 | body: 195 | string: ' 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | Error 204 | 205 | 206 | 207 | 208 | 209 |
Cannot GET /rest/v1/693116158
210 | 211 | 212 | 213 | 214 | 215 | ' 216 | headers: 217 | CF-Cache-Status: 218 | - DYNAMIC 219 | CF-RAY: 220 | - 8341c0a4e9a715a4-SJC 221 | Cache-Control: 222 | - private 223 | Connection: 224 | - keep-alive 225 | Content-Encoding: 226 | - gzip 227 | Content-Type: 228 | - text/html; charset=utf-8 229 | Date: 230 | - Tue, 12 Dec 2023 00:11:52 GMT 231 | Server: 232 | - cloudflare 233 | Transfer-Encoding: 234 | - chunked 235 | alt-svc: 236 | - h3=":443"; ma=86400 237 | content-security-policy: 238 | - default-src 'none' 239 | server-timing: 240 | - dc;desc=eu 241 | - total;dur=10.574 242 | x-content-type-options: 243 | - nosniff 244 | x-powered-by: 245 | - Express 246 | x-ratelimit-limit: 247 | - '120' 248 | x-ratelimit-remaining: 249 | - '114' 250 | x-ratelimit-reset: 251 | - '1702339952' 252 | status: 253 | code: 404 254 | message: Not Found 255 | version: 1 256 | -------------------------------------------------------------------------------- /tests/api/cassettes/test_system_collections.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | Content-Type: 12 | - application/json 13 | User-Agent: 14 | - python-requests/2.31.0 15 | method: GET 16 | uri: https://api.raindrop.io/rest/v1/user/stats 17 | response: 18 | body: 19 | string: '{"result":true,"items":[{"_id":0,"count":380},{"_id":-1,"count":8},{"_id":-99,"count":97}],"meta":{"_id":1006974,"changedBookmarksDate":"2023-12-11T23:59:46.765Z","broken":{"count":2},"pro":true}}' 20 | headers: 21 | CF-Cache-Status: 22 | - DYNAMIC 23 | CF-RAY: 24 | - 8341bf30d8c2fa72-SJC 25 | Cache-Control: 26 | - private 27 | Connection: 28 | - keep-alive 29 | Content-Encoding: 30 | - gzip 31 | Content-Type: 32 | - application/json; charset=utf-8 33 | Date: 34 | - Tue, 12 Dec 2023 00:10:53 GMT 35 | Server: 36 | - cloudflare 37 | Transfer-Encoding: 38 | - chunked 39 | alt-svc: 40 | - h3=":443"; ma=86400 41 | etag: 42 | - W/"c4-LEi/eAlUxAhc000TaMJklEtiZiA" 43 | server-timing: 44 | - dc;desc=eu 45 | - total;dur=27.384 46 | x-api-cache: 47 | - MISS 48 | x-powered-by: 49 | - Express 50 | x-ratelimit-limit: 51 | - '120' 52 | x-ratelimit-remaining: 53 | - '111' 54 | x-ratelimit-reset: 55 | - '1702339864' 56 | status: 57 | code: 200 58 | message: OK 59 | version: 1 60 | -------------------------------------------------------------------------------- /tests/api/conftest.py: -------------------------------------------------------------------------------- 1 | """Provide all shared test fixtures for API tests.""" 2 | 3 | import pytest 4 | from pathlib import Path 5 | 6 | from vcr import VCR 7 | 8 | from raindropiopy import API 9 | 10 | 11 | # Define once for "from tests.api.conftest import vcr" in every test where we touch Raindrop. 12 | vcr = VCR( 13 | cassette_library_dir=str(Path(__file__).parent / Path("cassettes")), 14 | filter_headers=["Authorization"], 15 | path_transformer=VCR.ensure_suffix(".yaml"), 16 | ) 17 | 18 | 19 | @pytest.fixture() 20 | def mock_api(): 21 | """Fixture for a "mock" API instance.""" 22 | yield API("dummy") 23 | -------------------------------------------------------------------------------- /tests/api/test_collections.py: -------------------------------------------------------------------------------- 1 | """Test all the methods in the Raindrop Collection API.""" 2 | 3 | import pytest 4 | from getpass import getuser 5 | 6 | import requests 7 | 8 | from raindropiopy import Collection, CollectionRef, SystemCollection 9 | from tests.api.conftest import vcr 10 | 11 | 12 | @vcr.use_cassette() 13 | def test_get_collections(api) -> None: 14 | """Test that we can get collections currently defined using all 3 methods in api/models.py. 15 | 16 | (Note: we can't check on the contents since they're dependent on whose running the test!). 17 | """ 18 | count_roots = 0 19 | for collection in Collection.get_root_collections(api): 20 | assert collection.id 21 | assert collection.title 22 | count_roots += 1 23 | 24 | count_children = 0 25 | for collection in Collection.get_child_collections(api): 26 | assert collection.id 27 | assert collection.title 28 | count_children += 1 29 | 30 | assert count_roots + count_children == len(Collection.get_collections(api)) 31 | 32 | 33 | @vcr.use_cassette() 34 | def test_system_collections(api) -> None: 35 | """Test that we can information on the "system" collections.""" 36 | system = SystemCollection.get_counts(api) 37 | assert system 38 | assert isinstance(system, list) 39 | assert len(system) == 3, "Sorry, we expect to always have *3* system collections!" 40 | 41 | for collection in system: 42 | # models.py adds titles for us, make sure they come through... 43 | assert collection.title 44 | 45 | # ...and, that they're right! 46 | if collection.id == CollectionRef.All.id: 47 | assert collection.title == "All" 48 | if collection.id == CollectionRef.Trash.id: 49 | assert collection.title == "Trash" 50 | if collection.id == CollectionRef.Unsorted.id: 51 | assert collection.title == "Unsorted" 52 | 53 | 54 | @vcr.use_cassette() 55 | def test_collection_lifecycle(api) -> None: 56 | """Test that we can roundtrip a collection, ie. create, update, get and delete.""" 57 | title = f"TEST Collection ({getuser()}" 58 | 59 | # Step 1: Create! 60 | collection = Collection.create(api, title=title) 61 | assert collection 62 | assert collection.id 63 | assert collection.title == title 64 | 65 | # Step 2: Edit... 66 | 67 | title = title.replace("TEST Collection", "EDITED TEST Collection") 68 | Collection.update(api, id=collection.id, title=title) 69 | collection = Collection.get(api, collection.id) 70 | assert collection.title == title 71 | 72 | # Step 3: Delete... 73 | Collection.delete(api, id=collection.id) 74 | with pytest.raises(requests.exceptions.HTTPError): 75 | Collection.get(api, collection.id) 76 | -------------------------------------------------------------------------------- /tests/api/test_models_api.py: -------------------------------------------------------------------------------- 1 | """Test out the API using a patched requests module.""" 2 | 3 | import json 4 | import time 5 | from unittest.mock import patch 6 | 7 | from requests import Response 8 | 9 | from raindropiopy import API 10 | 11 | 12 | def test_refresh() -> None: 13 | """Test the refresh method.""" 14 | api = API( 15 | { 16 | "access_token": "anAccessToken", 17 | "refresh_token": "aRefreshToken", 18 | "expires_at": time.time() - 100000, 19 | }, 20 | ) 21 | with patch("requests.Session.request") as m: 22 | resp = Response() 23 | resp.status_code = 200 24 | updated = {"access_token": "updated", "expires_at": time.time() + 100000} 25 | resp._content = json.dumps(updated).encode() 26 | m.return_value = resp 27 | 28 | # Test 29 | api.get("https://localhost", {}) 30 | 31 | # Confirm 32 | refresh, local = m.call_args_list 33 | assert refresh[0] == ("POST", "https://raindrop.io/oauth/access_token") 34 | assert local[0] == ("GET", "https://localhost") 35 | 36 | assert isinstance(api.token, dict) 37 | assert api.token["access_token"] == "updated" 38 | -------------------------------------------------------------------------------- /tests/api/test_models_collection.py: -------------------------------------------------------------------------------- 1 | """Test all the methods in the Raindrop Collection API.""" 2 | 3 | import datetime 4 | import json 5 | from unittest.mock import patch, Mock 6 | 7 | import pytest 8 | 9 | from raindropiopy import AccessLevel, Collection, SystemCollection, View 10 | 11 | COLLECTION = { 12 | "_id": 1000, 13 | "access": {"draggable": True, "for": 10000, "level": 4, "root": False}, 14 | "author": True, 15 | "count": 0, 16 | "cover": ["https://www.aRandomCover.org"], 17 | "created": "2020-01-01T00:00:00Z", 18 | "creatorRef": {"_id": 10000, "full_name": "user name"}, 19 | "expanded": False, 20 | "last_update": "2020-01-02T00:00:00Z", 21 | "public": False, 22 | "sort": 3000, 23 | "title": "aCollectionTitle", 24 | "user": {"$db": "", "$id": 10000, "$ref": "users"}, 25 | "view": "list", 26 | # Note: NO parent attribute here as this is a root level collection. 27 | } 28 | 29 | SUB_COLLECTION = { # Parent is the collection above.. 30 | "_id": 1001, 31 | "access": {"draggable": True, "for": 10000, "level": 4, "root": False}, 32 | "author": True, 33 | "count": 0, 34 | "cover": ["https://www.aRandomCover.org"], 35 | "created": "2020-01-01T00:00:00Z", 36 | "creatorRef": {"_id": 10000, "full_name": "user name"}, 37 | "expanded": False, 38 | "last_update": "2020-01-02T00:00:00Z", 39 | "public": False, 40 | "sort": 3000, 41 | "title": "aSubCollectionTitle", 42 | "user": {"$db": "", "$id": 10000, "$ref": "users"}, 43 | "view": "list", 44 | # Difference from above, *this* now is the sub-collection and refers to the parent above! 45 | "parent": {"$db": "", "$id": 1000, "$ref": "collections"}, 46 | } 47 | 48 | system_collection = { 49 | "_id": -1, # Must be one of [0, -1, -99] 50 | "count": 5, 51 | } 52 | 53 | 54 | def test_get_root_collections(mock_api) -> None: 55 | """Test that we can get the "root" collections.""" 56 | with patch("requests_oauthlib.OAuth2Session.get") as patched_request: 57 | mock_response = Mock(headers={"X-RateLimit-Limit": "100"}) 58 | mock_response.json.return_value = {"items": [COLLECTION]} 59 | patched_request.return_value = mock_response 60 | 61 | # Test 62 | collections = Collection.get_root_collections(mock_api) 63 | 64 | # Confirm 65 | assert collections 66 | assert len(collections) == 1 67 | collection = collections[0] 68 | 69 | assert collection.id == 1000 70 | assert collection.access.level == AccessLevel.owner 71 | assert collection.access.draggable is True 72 | assert collection.collaborators == [] 73 | assert collection.color is None 74 | assert collection.count == 0 75 | assert collection.cover == ["https://www.aRandomCover.org"] 76 | assert collection.created == datetime.datetime( 77 | 2020, 78 | 1, 79 | 1, 80 | 0, 81 | 0, 82 | 0, 83 | tzinfo=datetime.timezone.utc, 84 | ) 85 | assert collection.expanded is False 86 | assert collection.last_update == datetime.datetime( 87 | 2020, 88 | 1, 89 | 2, 90 | 0, 91 | 0, 92 | 0, 93 | tzinfo=datetime.timezone.utc, 94 | ) 95 | assert collection.parent is None # This IS the parent collection, thus, it has no parent itself! 96 | assert collection.public is False 97 | assert collection.sort == 3000 98 | assert collection.title == "aCollectionTitle" 99 | assert collection.user.id == 10000 100 | assert collection.view == View.list 101 | 102 | 103 | def test_get_child_collections(mock_api) -> None: 104 | """Test that we can get the "children" collections.""" 105 | with patch("requests_oauthlib.OAuth2Session.get") as patched_request: 106 | mock_response = Mock(headers={"X-RateLimit-Limit": "100"}) 107 | mock_response.json.return_value = {"items": [SUB_COLLECTION]} 108 | patched_request.return_value = mock_response 109 | 110 | # Test 111 | collections = Collection.get_child_collections(mock_api) 112 | 113 | # Confirm 114 | assert collections 115 | assert len(collections) == 1 116 | collection = collections[0] 117 | 118 | assert collection.id == 1001 119 | assert collection.parent == 1000 120 | 121 | 122 | def test_get(mock_api) -> None: 123 | """Test that we can get a specific collection.""" 124 | with patch("requests_oauthlib.OAuth2Session.request") as patched_request: 125 | mock_response = Mock(headers={"X-RateLimit-Limit": "100"}) 126 | mock_response.json.return_value = {"item": COLLECTION} 127 | patched_request.return_value = mock_response 128 | 129 | # Test 130 | c = Collection.get(mock_api, 1000) 131 | 132 | # Confirm 133 | assert c.id == 1000 134 | 135 | 136 | def test_delete(mock_api) -> None: 137 | """Test that we can delete an existing collection. 138 | 139 | FIXME: Add test for trying to delete a non-existent collection 140 | """ 141 | with patch("requests_oauthlib.OAuth2Session.request") as patched_request: 142 | Collection.delete(mock_api, id=1000) 143 | assert patched_request.call_args[0] == ( 144 | "DELETE", 145 | "https://api.raindrop.io/rest/v1/collection/1000", 146 | ) 147 | 148 | 149 | def test_get_system_collection_status(mock_api) -> None: 150 | """Test the call to the "get_counts" method.""" 151 | with patch("requests_oauthlib.OAuth2Session.request") as patched_request: 152 | patched_request.return_value.json.return_value = {"items": [system_collection]} 153 | assert SystemCollection.get_counts(mock_api)[0].id == -1 154 | assert SystemCollection.get_counts(mock_api)[0].title == "Unsorted" 155 | assert SystemCollection.get_counts(mock_api)[0].count == 5 156 | 157 | 158 | def test_parent_dereferencing() -> None: 159 | """Test that we can correct 'de-reference' a parent.""" 160 | base = { 161 | "_id": 1001, 162 | "access": {"draggable": True, "for": 10000, "level": 4, "root": False}, 163 | "author": True, 164 | "count": 0, 165 | "cover": ["https://www.aRandomCover.org"], 166 | "created": "2020-01-01T00:00:00Z", 167 | "creatorRef": {"_id": 10000, "full_name": "user name"}, 168 | "expanded": False, 169 | "last_update": "2020-01-02T00:00:00Z", 170 | "public": False, 171 | "sort": 3000, 172 | "title": "aSubCollectionTitle", 173 | "user": {"$db": "", "$id": 10000, "$ref": "users"}, 174 | "view": "list", 175 | } 176 | 177 | # Test 178 | Collection(**base) 179 | 180 | base["parent"] = {"$db": "", "$id": 1000, "$ref": "collections"} 181 | Collection(**base) 182 | 183 | base["parent"] = 123456789 184 | Collection(**base) 185 | 186 | with pytest.raises(AttributeError): 187 | base["parent"] = ["123456789", "ABC"] 188 | Collection(**base) 189 | 190 | # Example of collection taken from raindropiocli's state capability. 191 | state_json = """{ 192 | "id": 39866550, 193 | "title": "SubCollection", 194 | "user": { 195 | "id": 1006974, 196 | "ref": null 197 | }, 198 | "access": { 199 | "level": 4, 200 | "draggable": true 201 | }, 202 | "collaborators": [], 203 | "color": null, 204 | "count": 0, 205 | "cover": [], 206 | "created": "2023-12-11T23:59:19.578000+00:00", 207 | "expanded": true, 208 | "last_update": null, 209 | "parent": 26109558, 210 | "public": false, 211 | "sort": -1, 212 | "view": "list", 213 | "other": { 214 | "creatorRef": { 215 | "_id": 1006974, 216 | "name": "MadHun", 217 | "email": "" 218 | }, 219 | "lastAction": "2023-12-11T23:59:19.578Z", 220 | "lastUpdate": "2023-12-11T23:59:19.578Z", 221 | "slug": "sub-collection", 222 | "author": true 223 | } 224 | }""" 225 | Collection(**json.loads(state_json)) 226 | 227 | 228 | # FIXME: Need to figure out how to mock better, as is, this test is meaningless. 229 | def tst_update(mock_api) -> None: 230 | """Test that we can update an existing collection. 231 | 232 | FIXME: Add test for trying to update non-existent collection 233 | """ 234 | with patch("requests_oauthlib.OAuth2Session.request") as patched_request: 235 | mock_response = Mock(headers={"X-RateLimit-Limit": "100"}) 236 | mock_response.json.return_value = {"item": COLLECTION} 237 | patched_request.return_value = mock_response 238 | 239 | # Test 240 | title = str(datetime.datetime.now()) 241 | c = Collection.update(mock_api, id=1000, title=title, view=View.list) 242 | 243 | # Confirm 244 | assert c.id == 1000 245 | assert c.title == title 246 | 247 | 248 | # FIXME: Need to figure out how to mock better, as is, this test is meaningless. 249 | def tst_create(mock_api) -> None: 250 | """Test that we can create a new collection. 251 | 252 | FIXME: Add test for trying to create a collection that's already there. 253 | """ 254 | with patch("requests_oauthlib.OAuth2Session.request") as patched_request: 255 | mock_response = Mock(headers={"X-RateLimit-Limit": "100"}) 256 | mock_response.json.return_value = {"item": COLLECTION} 257 | patched_request.return_value = mock_response 258 | 259 | # Test 260 | c = Collection.create(mock_api, title="abcdef") 261 | 262 | # Confirm 263 | assert c.id == 1000 264 | -------------------------------------------------------------------------------- /tests/api/test_models_raindrop.py: -------------------------------------------------------------------------------- 1 | """Test all the core methods of the Raindrop API.""" 2 | 3 | import datetime 4 | from pathlib import Path 5 | from unittest.mock import patch 6 | 7 | from raindropiopy import API, Raindrop, RaindropType, CollectionRef 8 | 9 | raindrop = { 10 | "_id": 2000, 11 | # "collection": -1, 12 | "collection": {"$db": "", "$id": -1, "$ref": "collections"}, 13 | "cover": "", 14 | "created": "2020-01-01T00:00:00.000Z", 15 | "creatorRef": 3000, 16 | "domain": "www.example.com", 17 | "excerpt": "excerpt text", 18 | "important": False, 19 | "lastUpdate": "2020-01-01T01:01:01Z", 20 | "link": "https://www.example.com/", 21 | "media": [], 22 | "pleaseParse": {"weight": 1}, 23 | "sort": 3333333, 24 | "tags": ["abc", "def"], 25 | "title": "title", 26 | "type": "link", 27 | "user": {"$id": 3000, "$user": "users"}, 28 | } 29 | 30 | 31 | def test_get() -> None: 32 | """Test get method.""" 33 | api = API("dummy") 34 | with patch("raindropiopy.api.OAuth2Session.request") as m: 35 | m.return_value.json.return_value = {"item": raindrop} 36 | 37 | c = Raindrop.get(api, 2000) 38 | 39 | assert c.id == 2000 40 | assert c.collection.id == -1 41 | assert c.cover == "" 42 | assert c.created == datetime.datetime( 43 | 2020, 44 | 1, 45 | 1, 46 | 0, 47 | 0, 48 | 0, 49 | tzinfo=datetime.timezone.utc, 50 | ) 51 | assert c.domain == "www.example.com" 52 | assert c.excerpt == "excerpt text" 53 | assert c.last_update == datetime.datetime( 54 | 2020, 55 | 1, 56 | 1, 57 | 1, 58 | 1, 59 | 1, 60 | tzinfo=datetime.timezone.utc, 61 | ) 62 | assert c.link == "https://www.example.com/" 63 | assert c.media == [] 64 | assert c.tags == ["abc", "def"] 65 | assert c.title == "title" 66 | assert c.type == RaindropType.link 67 | assert c.user.id == 3000 68 | 69 | 70 | def test_search() -> None: 71 | """Test search method.""" 72 | api = API("dummy") 73 | with patch("raindropiopy.api.OAuth2Session.request") as m: 74 | m.return_value.json.return_value = {"items": [raindrop]} 75 | found = Raindrop._search_paged(api) 76 | assert found[0].id == 2000 77 | 78 | 79 | def test_create_link() -> None: 80 | """Test ability to create a link-based Raindrop.""" 81 | api = API("dummy") 82 | with patch("raindropiopy.api.OAuth2Session.request") as m: 83 | m.return_value.json.return_value = {"item": raindrop} 84 | item = Raindrop.create_link(api, link="https://example.com") 85 | assert item.id == 2000 86 | 87 | 88 | def test_create_file() -> None: 89 | """Test ability to create a file-based Raindrop.""" 90 | api = API("dummy") 91 | content_type = "text/plain" 92 | with patch("raindropiopy.api.OAuth2Session.request") as m: 93 | # FIXME: Note that for now, we're *not* testing the ability to 94 | # set either a title or tags on the following 95 | # file-based create call (even though the capability is 96 | # exists). 97 | Raindrop.create_file(api, Path(__file__), content_type=content_type) 98 | 99 | assert m.call_args[0] == ( 100 | "PUT", 101 | "https://api.raindrop.io/rest/v1/raindrop/file", 102 | ) 103 | assert "data" in m.call_args[1] 104 | assert m.call_args[1]["data"] == { 105 | "collectionId": str(CollectionRef.Unsorted.id), 106 | } # ie. Default is collection isn't specified is Unsorted 107 | 108 | assert "files" in m.call_args[1] 109 | assert "file" in m.call_args[1]["files"] 110 | 111 | assert type(m.call_args[1]["files"]["file"]) == tuple 112 | file_ = m.call_args[1]["files"]["file"] 113 | assert len(file_) == 3 114 | 115 | fn_, fh_, ct_ = file_ 116 | assert fn_ == Path(__file__).name 117 | assert ct_ == content_type 118 | 119 | 120 | def test_update() -> None: 121 | """Test ability to update an existing Raindrop.""" 122 | api = API("dummy") 123 | with patch("raindropiopy.api.OAuth2Session.request") as m: 124 | m.return_value.json.return_value = {"item": raindrop} 125 | item = Raindrop.update(api, id=2000, link="https://example.com") 126 | assert item.id == 2000 127 | 128 | 129 | def test_delete() -> None: 130 | """Test ability to delete a Raindrop.""" 131 | api = API("dummy") 132 | with patch("raindropiopy.api.OAuth2Session.request") as m: 133 | Raindrop.delete(api, id=2000) 134 | 135 | assert m.call_args[0] == ( 136 | "DELETE", 137 | "https://api.raindrop.io/rest/v1/raindrop/2000", 138 | ) 139 | -------------------------------------------------------------------------------- /tests/api/test_models_tag.py: -------------------------------------------------------------------------------- 1 | """Test the Tag API.""" 2 | 3 | from unittest.mock import patch 4 | 5 | from raindropiopy import API, Tag 6 | 7 | TAG = {"_id": "a Sample Tag", "count": 1} 8 | 9 | 10 | def test_get() -> None: 11 | """Test that we can lookup a tag.""" 12 | api = API("dummy") 13 | with patch("raindropiopy.api.OAuth2Session.request") as m: 14 | m.return_value.json.return_value = {"items": [TAG]} 15 | 16 | tags = Tag.get(api) 17 | 18 | assert len(tags) == 1 19 | 20 | tag = tags[0] 21 | assert tag.tag == "a Sample Tag" 22 | assert tag.count == 1 23 | -------------------------------------------------------------------------------- /tests/api/test_models_user.py: -------------------------------------------------------------------------------- 1 | """Test the ability of the API on the User object.""" 2 | 3 | import datetime 4 | from unittest.mock import patch 5 | 6 | from raindropiopy import API, User, BrokenLevel, FontColor, RaindropSort, View 7 | 8 | test_user = { 9 | "_id": 1000, 10 | "config": { 11 | "broken_level": "default", 12 | "font_color": "sunset", 13 | "font_size": 20, 14 | "lang": "en", 15 | "last_collection": 1, # Will/should be cast to a CollectionRef. 16 | "raindrops_sort": "-lastUpdate", 17 | "raindrops_view": "list", 18 | }, 19 | "email": "mail@example.com", 20 | "email_MD5": "1111111111", 21 | "files": { 22 | "lastCheckpoint": "2020-01-01T02:02:02.000Z", 23 | "size": 10000000000, 24 | "used": 0, 25 | }, 26 | "fullName": "test user", 27 | "groups": [ 28 | { 29 | "collections": [ 30 | 2000, 31 | 3000, 32 | ], 33 | "hidden": False, 34 | "sort": 0, 35 | "title": "My Collections", 36 | }, 37 | ], 38 | "password": True, 39 | "pro": True, 40 | "proExpire": "2022-01-01T01:01:01.000Z", 41 | "provider": "twitter", 42 | "registered": "2020-01-02T01:1:1.0Z", 43 | } 44 | 45 | 46 | def test_get() -> None: 47 | """Test that we can get/lookup the user.""" 48 | api = API("dummy") 49 | with patch("raindropiopy.api.OAuth2Session.request") as m: 50 | m.return_value.json.return_value = {"user": test_user} 51 | user = User.get(api) 52 | 53 | assert user.id == 1000 54 | 55 | assert user.config.broken_level == BrokenLevel.default 56 | assert user.config.font_color == FontColor.sunset 57 | assert user.config.font_size == 20 58 | assert user.config.lang == "en" 59 | assert user.config.last_collection.id == 1 60 | assert user.config.raindrops_sort == RaindropSort.last_update_dn 61 | assert user.config.raindrops_view == View.list 62 | 63 | assert user.email == "mail@example.com" 64 | assert user.email_md5 == "1111111111" 65 | assert user.files.size == 10000000000 66 | assert user.files.used == 0 67 | assert user.files.last_checkpoint == datetime.datetime( 68 | 2020, 69 | 1, 70 | 1, 71 | 2, 72 | 2, 73 | 2, 74 | tzinfo=datetime.timezone.utc, 75 | ) 76 | assert user.full_name == "test user" 77 | assert user.groups[0].hidden is False 78 | assert user.groups[0].sort == 0 79 | assert user.groups[0].title == "My Collections" 80 | assert list(user.groups[0].collectionids) == [2000, 3000] 81 | assert user.password is True 82 | assert user.pro is True 83 | assert user.registered == datetime.datetime( 84 | 2020, 85 | 1, 86 | 2, 87 | 1, 88 | 1, 89 | 1, 90 | tzinfo=datetime.timezone.utc, 91 | ) 92 | -------------------------------------------------------------------------------- /tests/api/test_raindrop.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PBorocz/raindrop-io-py/9b63b6e4e4746b13485bcdc75b20358cb7144853/tests/api/test_raindrop.pdf -------------------------------------------------------------------------------- /tests/api/test_raindrop.py: -------------------------------------------------------------------------------- 1 | """Test all the core methods of the Raindrop API.""" 2 | 3 | from pathlib import Path 4 | 5 | import pytest 6 | import requests 7 | 8 | from raindropiopy import Raindrop, RaindropType 9 | from tests.api.conftest import vcr 10 | 11 | 12 | @pytest.fixture 13 | def sample_raindrop_link(): 14 | """Fixture to return sample raindrop data.""" 15 | return ( 16 | "https://www.google.com/", 17 | { 18 | "excerpt": "excerpt/description text", 19 | "important": True, 20 | "tags": ["abc", "def"], 21 | "title": "a Title", 22 | }, 23 | ) 24 | 25 | 26 | @pytest.fixture 27 | def search_raindrops(api): 28 | """Fixture to setup and teardown 2 link-based raindrops for search testing.""" 29 | link, args = ( 30 | "https://www.google.com/", 31 | { 32 | "title": "ELGOOG 1234567890", 33 | "tags": ["ABCDEFG", "HIJKLMO"], 34 | "please_parse": True, 35 | }, 36 | ) 37 | raindrop_google = Raindrop.create_link(api, link, **args) 38 | 39 | link, args = ( 40 | "https://www.python.com/", 41 | { 42 | "title": "NOHTYP 1234567890", 43 | "tags": ["HIJKLMO", "PQRSTUV"], 44 | "please_parse": True, 45 | }, 46 | ) 47 | raindrop_python = Raindrop.create_link(api, link, **args) 48 | 49 | # Before we let the test rip, allow time for asynchronuous indexing on Raindrop's backend to catch-up. 50 | import time 51 | 52 | time.sleep(10) 53 | 54 | yield 55 | 56 | Raindrop.delete(api, id=raindrop_google.id) 57 | Raindrop.delete(api, id=raindrop_python.id) 58 | 59 | 60 | @vcr.use_cassette() 61 | def test_lifecycle_raindrop_link(api, sample_raindrop_link) -> None: 62 | """Test that we can roundtrip a regular/link-based raindrop, ie. create, update, get and delete.""" 63 | # TEST: Create 64 | link, args = sample_raindrop_link 65 | raindrop = Raindrop.create_link(api, link, **args) 66 | assert raindrop is not None 67 | assert isinstance(raindrop, Raindrop) 68 | assert raindrop.id 69 | assert raindrop.link == link 70 | assert raindrop.important == args.get("important") 71 | assert raindrop.tags == args.get("tags") 72 | assert raindrop.title == args.get("title") 73 | assert raindrop.excerpt == args.get("excerpt") 74 | assert raindrop.type == RaindropType.link 75 | 76 | # TEST: Edit... 77 | title = "a NEW/EDITED Title" 78 | edited_raindrop = Raindrop.update(api, raindrop.id, title=title) 79 | assert edited_raindrop.title == title 80 | 81 | # TEST: Delete... 82 | Raindrop.delete(api, id=raindrop.id) 83 | with pytest.raises(requests.exceptions.HTTPError): 84 | Raindrop.get(api, raindrop.id) 85 | 86 | 87 | @vcr.use_cassette() 88 | def test_lifecycle_raindrop_file(api) -> None: 89 | """Test that we can roundtrip a *file-base* raindrop, ie. create, update, get and delete.""" 90 | # TEST: Create a link using this test file as the file to upload. 91 | path_ = Path(__file__).parent / Path("test_raindrop.pdf") 92 | 93 | raindrop = Raindrop.create_file( 94 | api, 95 | path_, 96 | "application/pdf", 97 | title="A Sample Title", 98 | tags=["SampleTag"], 99 | ) 100 | assert raindrop is not None 101 | assert isinstance(raindrop, Raindrop) 102 | assert raindrop.id 103 | assert raindrop.file.name == path_.name 104 | assert raindrop.type == RaindropType.document 105 | 106 | # TEST: Delete... 107 | Raindrop.delete(api, id=raindrop.id) 108 | with pytest.raises(requests.exceptions.HTTPError): 109 | Raindrop.get(api, raindrop.id) 110 | 111 | 112 | @vcr.use_cassette() 113 | def test_search(api, search_raindrops) -> None: 114 | """Test that we can *search* raindrops.""" 115 | 116 | def _print(results): 117 | links = [drop.link for drop in results] 118 | return ",".join(links) 119 | 120 | # TEST: Can we search by "word"? 121 | results = Raindrop.search(api, search="ELGOOG") # Title 122 | assert len(results) == 1, f"Sorry, expected 1 for 'ELGOOG' but got the following {_print(results)}" 123 | 124 | # TEST: Can we search by "word"? 125 | results = Raindrop.search(api, search="ELGOOG") # Title 126 | assert len(results) == 1, f"Sorry, expected 1 for 'ELGOOG' but got the following {_print(results)}" 127 | 128 | results = Raindrop.search(api, search="1234567890") 129 | assert len(results) == 2, f"Sorry, expected 2 for '1234567890' but got the following {_print(results)}" 130 | 131 | # TEST: Can we search by "tag"? 132 | results = Raindrop.search(api, search="#ABCDEFG") 133 | assert len(results) == 1, f"Sorry, expected 1 for tag 'ABCDEFG' but got the following {_print(results)}" 134 | 135 | results = Raindrop.search(api, search="#HIJKLMO") 136 | assert len(results) == 2, f"Sorry, expected 2 for tag 'HIJKLMO' but got the following {_print(results)}" 137 | 138 | # TEST: What happens if we search with NO parameters? We should get back ALL the bookmarks 139 | # associated with the current test token's environment, including the test ones. 140 | results = Raindrop.search(api) 141 | 142 | # Confirm that *at least* the 2 test cases are also in the results (at least in case 143 | # previous tests left leftover entries) 144 | found = sum(map(lambda raindrop: "1234567890" in raindrop.title, results)) 145 | assert found >= 2, "Sorry, expected to find the 2 entries we setup in the test for wildcard search but didn't!" 146 | -------------------------------------------------------------------------------- /tests/api/test_tags.py: -------------------------------------------------------------------------------- 1 | """Test the only easy method in the Raindrop Tag API.""" 2 | 3 | from raindropiopy import CollectionRef, Tag 4 | from tests.api.conftest import vcr 5 | 6 | 7 | @vcr.use_cassette() 8 | def test_get_tags(api) -> None: 9 | """Test that we can get tags currently defined. 10 | 11 | (Note: we can't check on the contents since they're dependent on whose running the test!). 12 | """ 13 | all_tags = Tag.get(api, CollectionRef.All.id) 14 | assert all_tags is not None 15 | assert isinstance(all_tags, list) 16 | assert len(all_tags) > 0, "Sorry, we expect to have at least *1* tag for this user?!" 17 | -------------------------------------------------------------------------------- /tests/api/test_user.py: -------------------------------------------------------------------------------- 1 | """Test that we can get the currently logged in user (ie. the one associated with the current TOKEN).""" 2 | 3 | from raindropiopy import User 4 | from tests.api.conftest import vcr 5 | 6 | 7 | @vcr.use_cassette() 8 | def test_get_user(api) -> None: 9 | """Test that we can information on the current user. 10 | 11 | (Note: we can't check on the contents since they're dependent on who's running the test!). 12 | """ 13 | user = User.get(api) 14 | assert user is not None 15 | assert isinstance(user, User) 16 | for attr in ["id", "email", "full_name", "password", "pro", "registered"]: 17 | assert ( 18 | getattr(user, attr) is not None 19 | ), f"We expected required attribute '{attr}' to be populated for the current user!" 20 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Provide shared test fixtures across *all* test suites.""" 2 | 3 | import os 4 | 5 | import dotenv 6 | import pytest 7 | 8 | from raindropiopy import API 9 | 10 | dotenv.load_dotenv() 11 | 12 | 13 | @pytest.fixture 14 | def api(): 15 | """Fixture to return a valid API instance.""" 16 | yield API(os.environ["RAINDROP_TOKEN"]) 17 | -------------------------------------------------------------------------------- /vulture_whitelist.py: -------------------------------------------------------------------------------- 1 | """Vulture whitelist.""" 2 | 3 | # fmt: off 4 | excerpt # noqa: B018 F821 unused variable (raindropiopy/models.py:669) 5 | important # noqa: B018 F821 unused variable (raindropiopy/models.py:670) 6 | media # noqa: B018 F821 unused variable (raindropiopy/models.py:671) 7 | order # noqa: B018 F821 unused variable (raindropiopy/models.py:672) 8 | excerpt # noqa: B018 F821 unused variable (raindropiopy/models.py:841) 9 | important # noqa: B018 F821 unused variable (raindropiopy/models.py:842) 10 | media # noqa: B018 F821 unused variable (raindropiopy/models.py:844) 11 | order # noqa: B018 F821 unused variable (raindropiopy/models.py:845) 12 | # fmt: on 13 | --------------------------------------------------------------------------------