├── .github └── workflows │ └── python-publish.yml ├── .gitignore ├── LICENSE.txt ├── Pipfile ├── Pipfile.lock ├── README.md ├── docs ├── Makefile ├── make.bat └── source │ ├── conf.py │ ├── index.rst │ ├── installation.rst │ ├── modules.rst │ ├── quickstart.rst │ └── rmapi.rst ├── requirements.txt ├── rmapy ├── __init__.py ├── api.py ├── collections.py ├── config.py ├── const.py ├── document.py ├── exceptions.py ├── folder.py ├── meta.py └── types.py ├── setup.cfg └── setup.py /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/python 3 | # Edit at https://www.gitignore.io/?templates=python 4 | 5 | ### Python ### 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | pip-wheel-metadata/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .nox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # celery beat schedule file 99 | celerybeat-schedule 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # End of https://www.gitignore.io/api/python 132 | 133 | package.sh 134 | VERSION 135 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2019 Stijn Van Campenhout 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. 8 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | 8 | [packages] 9 | requests = "*" 10 | pyaml = "*" 11 | sphinx = "*" 12 | urllib3 = ">=1.26.5" 13 | 14 | [requires] 15 | python_version = "3.7" 16 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "f7fb5bcb7b1ad271a4b43ccd956c97f6d6a530869b7d9b8454aaf4defcadf09f" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.7" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "alabaster": { 20 | "hashes": [ 21 | "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359", 22 | "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02" 23 | ], 24 | "version": "==0.7.12" 25 | }, 26 | "babel": { 27 | "hashes": [ 28 | "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9", 29 | "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0" 30 | ], 31 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 32 | "version": "==2.9.1" 33 | }, 34 | "certifi": { 35 | "hashes": [ 36 | "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee", 37 | "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8" 38 | ], 39 | "version": "==2021.5.30" 40 | }, 41 | "chardet": { 42 | "hashes": [ 43 | "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", 44 | "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" 45 | ], 46 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 47 | "version": "==4.0.0" 48 | }, 49 | "docutils": { 50 | "hashes": [ 51 | "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125", 52 | "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61" 53 | ], 54 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 55 | "version": "==0.17.1" 56 | }, 57 | "idna": { 58 | "hashes": [ 59 | "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", 60 | "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" 61 | ], 62 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 63 | "version": "==2.10" 64 | }, 65 | "imagesize": { 66 | "hashes": [ 67 | "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1", 68 | "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1" 69 | ], 70 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 71 | "version": "==1.2.0" 72 | }, 73 | "jinja2": { 74 | "hashes": [ 75 | "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4", 76 | "sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4" 77 | ], 78 | "markers": "python_version >= '3.6'", 79 | "version": "==3.0.1" 80 | }, 81 | "markupsafe": { 82 | "hashes": [ 83 | "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298", 84 | "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64", 85 | "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b", 86 | "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567", 87 | "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff", 88 | "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74", 89 | "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35", 90 | "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26", 91 | "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7", 92 | "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75", 93 | "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f", 94 | "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135", 95 | "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8", 96 | "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a", 97 | "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914", 98 | "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18", 99 | "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8", 100 | "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2", 101 | "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d", 102 | "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b", 103 | "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f", 104 | "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb", 105 | "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833", 106 | "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415", 107 | "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902", 108 | "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9", 109 | "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d", 110 | "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066", 111 | "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f", 112 | "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5", 113 | "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94", 114 | "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509", 115 | "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51", 116 | "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872" 117 | ], 118 | "markers": "python_version >= '3.6'", 119 | "version": "==2.0.1" 120 | }, 121 | "packaging": { 122 | "hashes": [ 123 | "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7", 124 | "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14" 125 | ], 126 | "markers": "python_version >= '3.6'", 127 | "version": "==21.0" 128 | }, 129 | "pyaml": { 130 | "hashes": [ 131 | "sha256:29a5c2a68660a799103d6949167bd6c7953d031449d08802386372de1db6ad71", 132 | "sha256:67081749a82b72c45e5f7f812ee3a14a03b3f5c25ff36ec3b290514f8c4c4b99" 133 | ], 134 | "index": "pypi", 135 | "version": "==20.4.0" 136 | }, 137 | "pygments": { 138 | "hashes": [ 139 | "sha256:a18f47b506a429f6f4b9df81bb02beab9ca21d0a5fee38ed15aef65f0545519f", 140 | "sha256:d66e804411278594d764fc69ec36ec13d9ae9147193a1740cd34d272ca383b8e" 141 | ], 142 | "markers": "python_version >= '3.5'", 143 | "version": "==2.9.0" 144 | }, 145 | "pyparsing": { 146 | "hashes": [ 147 | "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", 148 | "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" 149 | ], 150 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", 151 | "version": "==2.4.7" 152 | }, 153 | "pytz": { 154 | "hashes": [ 155 | "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da", 156 | "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798" 157 | ], 158 | "version": "==2021.1" 159 | }, 160 | "pyyaml": { 161 | "hashes": [ 162 | "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf", 163 | "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696", 164 | "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393", 165 | "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77", 166 | "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922", 167 | "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5", 168 | "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8", 169 | "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10", 170 | "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc", 171 | "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018", 172 | "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e", 173 | "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253", 174 | "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347", 175 | "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183", 176 | "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541", 177 | "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb", 178 | "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185", 179 | "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc", 180 | "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db", 181 | "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa", 182 | "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46", 183 | "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122", 184 | "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b", 185 | "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63", 186 | "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df", 187 | "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc", 188 | "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247", 189 | "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6", 190 | "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0" 191 | ], 192 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", 193 | "version": "==5.4.1" 194 | }, 195 | "requests": { 196 | "hashes": [ 197 | "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", 198 | "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" 199 | ], 200 | "index": "pypi", 201 | "version": "==2.25.1" 202 | }, 203 | "snowballstemmer": { 204 | "hashes": [ 205 | "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2", 206 | "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914" 207 | ], 208 | "version": "==2.1.0" 209 | }, 210 | "sphinx": { 211 | "hashes": [ 212 | "sha256:5747f3c855028076fcff1e4df5e75e07c836f0ac11f7df886747231092cfe4ad", 213 | "sha256:dff357e6a208eb7edb2002714733ac21a9fe597e73609ff417ab8cf0c6b4fbb8" 214 | ], 215 | "index": "pypi", 216 | "version": "==4.0.3" 217 | }, 218 | "sphinxcontrib-applehelp": { 219 | "hashes": [ 220 | "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a", 221 | "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58" 222 | ], 223 | "markers": "python_version >= '3.5'", 224 | "version": "==1.0.2" 225 | }, 226 | "sphinxcontrib-devhelp": { 227 | "hashes": [ 228 | "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e", 229 | "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4" 230 | ], 231 | "markers": "python_version >= '3.5'", 232 | "version": "==1.0.2" 233 | }, 234 | "sphinxcontrib-htmlhelp": { 235 | "hashes": [ 236 | "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07", 237 | "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2" 238 | ], 239 | "markers": "python_version >= '3.6'", 240 | "version": "==2.0.0" 241 | }, 242 | "sphinxcontrib-jsmath": { 243 | "hashes": [ 244 | "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", 245 | "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" 246 | ], 247 | "markers": "python_version >= '3.5'", 248 | "version": "==1.0.1" 249 | }, 250 | "sphinxcontrib-qthelp": { 251 | "hashes": [ 252 | "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72", 253 | "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6" 254 | ], 255 | "markers": "python_version >= '3.5'", 256 | "version": "==1.0.3" 257 | }, 258 | "sphinxcontrib-serializinghtml": { 259 | "hashes": [ 260 | "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd", 261 | "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952" 262 | ], 263 | "markers": "python_version >= '3.5'", 264 | "version": "==1.1.5" 265 | }, 266 | "urllib3": { 267 | "hashes": [ 268 | "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4", 269 | "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f" 270 | ], 271 | "index": "pypi", 272 | "version": "==1.26.6" 273 | } 274 | }, 275 | "develop": {} 276 | } 277 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rMapy 2 | This is an (unofficial) Remarkable Cloud API Client written in Python. 3 | 4 | 5 | ### API Support 6 | 7 | * ☑️ List content in the cloud 8 | * ☑️ Work with documents & folders 9 | * ☑️ create a folder 10 | * ☑️ move / rename a document or folder 11 | * ☑️ create a document 12 | * ☑️ edit a document 13 | * ☑️ delete a document or folder 14 | * ❎ cli interface 15 | * ❎ export pdf with annotations 16 | 17 | -------------------------------------------------------------------------------- /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 = source 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/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=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 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/source/conf.py: -------------------------------------------------------------------------------- 1 | import guzzle_sphinx_theme 2 | # Configuration file for the Sphinx documentation builder. 3 | # 4 | # This file only contains a selection of the most common options. For a full 5 | # list see the documentation: 6 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 7 | 8 | # -- Path setup -------------------------------------------------------------- 9 | 10 | # If extensions (or modules to document with autodoc) are in another directory, 11 | # add these directories to sys.path here. If the directory is relative to the 12 | # documentation root, use os.path.abspath to make it absolute, like shown here. 13 | # 14 | import os 15 | import sys 16 | sys.path.insert(0, os.path.abspath('../..')) 17 | 18 | 19 | # -- Project information ----------------------------------------------------- 20 | 21 | project = 'rmapy' 22 | copyright = '2019, Stijn Van Campenhout' 23 | author = 'Stijn Van Campenhout' 24 | 25 | # The full version, including alpha/beta/rc tags 26 | release = '0.1' 27 | 28 | 29 | # -- General configuration --------------------------------------------------- 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | 35 | 36 | extensions = [ 37 | 'sphinx.ext.napoleon', 38 | 'sphinx_autodoc_typehints' 39 | ] 40 | 41 | # Add any paths that contain templates here, relative to this directory. 42 | templates_path = ['_templates'] 43 | 44 | # List of patterns, relative to source directory, that match files and 45 | # directories to ignore when looking for source files. 46 | # This pattern also affects html_static_path and html_extra_path. 47 | exclude_patterns = [] 48 | 49 | 50 | # -- Options for HTML output ------------------------------------------------- 51 | 52 | # The theme to use for HTML and HTML Help pages. See the documentation for 53 | # a list of builtin themes. 54 | # 55 | html_theme = 'alabaster' 56 | 57 | # Add any paths that contain custom static files (such as style sheets) here, 58 | # relative to this directory. They are copied after the builtin static files, 59 | # so a file named "default.css" will overwrite the builtin "default.css". 60 | html_static_path = ['_static'] 61 | 62 | 63 | napoleon_google_docstring = True 64 | napoleon_numpy_docstring = True 65 | napoleon_include_init_with_doc = False 66 | napoleon_include_private_with_doc = False 67 | napoleon_include_special_with_doc = True 68 | napoleon_use_admonition_for_examples = False 69 | napoleon_use_admonition_for_notes = False 70 | napoleon_use_admonition_for_references = False 71 | napoleon_use_ivar = False 72 | napoleon_use_param = True 73 | napoleon_use_rtype = True 74 | 75 | 76 | html_theme_path = guzzle_sphinx_theme.html_theme_path() 77 | html_theme = 'guzzle_sphinx_theme' 78 | 79 | # Register the theme as an extension to generate a sitemap.xml 80 | extensions.append("guzzle_sphinx_theme") 81 | 82 | # Guzzle theme options (see theme.conf for more information) 83 | html_theme_options = { 84 | # Set the name of the project to appear in the sidebar 85 | "project_nav_name": "Project Name", 86 | } 87 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. rmapy documentation master file, created by 2 | sphinx-quickstart on Tue Sep 17 19:24:29 2019. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to rmapy's documentation! 7 | ================================= 8 | 9 | This is an (unofficial) Remarkable Cloud API Client written in Python. 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | API Support 15 | ~~~~~~~~~~~ 16 | 17 | | ☑️ List content in the cloud 18 | | ☑️ Work with documents & folders 19 | | ☑️ create a folder 20 | | ☑️ move / rename a document or folder 21 | | ☑️ create a document 22 | | ☑️ edit a document 23 | | ☑️ delete a document or folder 24 | | ❎ cli interface 25 | | ❎ export pdf with annotations 26 | 27 | 28 | 29 | Installation 30 | ~~~~~~~~~~~~ 31 | 32 | .. toctree:: 33 | :maxdepth: 2 34 | 35 | installation 36 | 37 | Quick start 38 | ~~~~~~~~~~~ 39 | 40 | .. toctree:: 41 | :maxdepth: 2 42 | 43 | quickstart 44 | 45 | 46 | API Documentation 47 | ~~~~~~~~~~~~~~~~~ 48 | 49 | 50 | .. toctree:: 51 | :maxdepth: 2 52 | 53 | rmapy 54 | 55 | .. automodule:: rmapy 56 | 57 | 58 | Indices and tables 59 | ================== 60 | 61 | * :ref:`genindex` 62 | * :ref:`modindex` 63 | * :ref:`search` 64 | -------------------------------------------------------------------------------- /docs/source/installation.rst: -------------------------------------------------------------------------------- 1 | Pip 2 | === 3 | 4 | Like any other package, you can install rmapy using pip: 5 | 6 | 7 | .. code-block:: bash 8 | 9 | pip install rmapy 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/source/modules.rst: -------------------------------------------------------------------------------- 1 | rmapy 2 | ===== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | rmapy 8 | -------------------------------------------------------------------------------- /docs/source/quickstart.rst: -------------------------------------------------------------------------------- 1 | quick start 2 | ========== 3 | 4 | If you previously used the go package `rmapi`_ ,the keys for authorization 5 | are re-used because we use the same storage location & format. 6 | 7 | If not, you'll need to register the client as a new device on `my remarkable`_. 8 | 9 | 10 | .. _my remarkable: https://my.remarkable.com/device/desktop/connect 11 | 12 | .. _rmapi: https://github.com/juruen/rmapi 13 | 14 | 15 | Registering the API Client 16 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 17 | 18 | Registering the device is easy. Go to `my remarkable`_ to register a new device 19 | and use the code you see on the webpage 20 | 21 | .. code-block:: python 22 | :linenos: 23 | 24 | 25 | from rmapy.api import Client 26 | 27 | rmapy = Client() 28 | # Should return False 29 | rmapy.is_auth() 30 | # This registers the client as a new device. The received device token is 31 | # stored in the users directory in the file ~/.rmapi, the same as with the 32 | # go rmapi client. 33 | # Get a token at https://my.remarkable.com/device/desktop/connect. 34 | rmapy.register_device("fkgzzklrs") 35 | # It's always a good idea to renew the user token every time you start 36 | # a new session. 37 | rmapy.renew_token() 38 | # Should return True 39 | rmapy.is_auth() 40 | 41 | Working with items 42 | ~~~~~~~~~~~~~~~~~~ 43 | 44 | The remarkable fs structure is flat containing metadata objects of two types: 45 | 46 | * DocumentType 47 | * CollectionType 48 | 49 | We can list the items in the Cloud 50 | 51 | .. code-block:: python 52 | :linenos: 53 | 54 | >>> from rmapy.api import Client 55 | >>> rmapy = Client() 56 | >>> rmapy.renew_token() 57 | True 58 | >>> collection = rmapy.get_meta_items() 59 | >>> collection 60 | 61 | >>> len(collection) 62 | 181 63 | >>> # Count the amount of documents 64 | ... from rmapy.document import Document 65 | >>> len([f for f in collection if isinstance(f, Document)]) 66 | 139 67 | >>> # Count the amount of folders 68 | ... from rmapy.folder import Folder 69 | >>> len([f for f in collection if isinstance(f, Folder)]) 70 | 42 71 | 72 | 73 | 74 | DocumentType 75 | ```````````` 76 | 77 | A DocumentType is a document. This can be a pdf, epub or notebook. 78 | These types are represented by the object :class:`rmapy.document.Document` 79 | 80 | 81 | Changing the metadata is easy 82 | 83 | .. code-block:: python 84 | :linenos: 85 | 86 | 87 | >>> from rmapy.api import Client 88 | >>> rmapy = Client() 89 | >>> rmapy.renew_token() 90 | True 91 | >>> collection = rmapy.get_meta_items() 92 | >>> doc = [ d for d in collection if d.VissibleName == 'ModernC'][0] 93 | >>> doc 94 | 95 | >>> doc.to_dict() 96 | {'ID': 'a969fcd6-64b0-4f71-b1ce-d9533ec4a2a3', 'Version': 1, 'Message': '', 'Succes': True, 'BlobURLGet': '', 'BlobURLGetExpires': '0001-01-01T00:00:00Z', 'BlobURLPut': '', 'BlobURLPutExpires': '', 'ModifiedClient': '2019-09-18T20:12:07.206206Z', 'Type': 'DocumentType', 'VissibleName': 'ModernC', 'CurrentPage': 0, 'Bookmarked': False, 'Parent': ''} 97 | >>> doc.VissibleName = "Modern C: The book of wisdom" 98 | >>> # push the changes back to the Remarkable Cloud 99 | ... rmapy.update_metadata(doc) 100 | True 101 | >>> collection = rmapy.get_meta_items() 102 | >>> doc = [ d for d in docs if d.VissibleName == 'ModernC'][0] 103 | Traceback (most recent call last): 104 | File "", line 1, in 105 | IndexError: list index out of range 106 | >>> doc = [ d for d in docs if d.VissibleName == 'Modern C: The book of wisdom'][0] 107 | >>> doc 108 | 109 | >>> doc.to_dict() 110 | {'ID': 'a969fcd6-64b0-4f71-b1ce-d9533ec4a2a3', 'Version': 1, 'Message': '', 'Succes': True, 'BlobURLGet': '', 'BlobURLGetExpires': '0001-01-01T00:00:00Z', 'BlobURLPut': '', 'BlobURLPutExpires': '', 'ModifiedClient': '2019-09-18T20:12:07.206206Z', 'Type': 'DocumentType', 'VissibleName': 'Modern C: The book of wisdom', 'CurrentPage': 0, 'Bookmarked': False, 'Parent': ''} 111 | 112 | 113 | CollectionType 114 | `````````````` 115 | 116 | A CollectionType is a Folder. 117 | 118 | These types are represented by the object :class:`rmapy.folder.Folder` 119 | 120 | Working with folders is easy! 121 | 122 | .. code-block:: python 123 | :linenos: 124 | 125 | 126 | >>> from rmapy.api import Client 127 | >>> rmapy = Client() 128 | >>> rmapy.renew_token() 129 | True 130 | >>> collection = rmapy.get_meta_items() 131 | >>> collection 132 | 133 | >>> from rmapy.folder import Folder 134 | >>> # Get all the folders. Note that the fs of Remarkable is flat in the cloud 135 | ... folders = [ f for f in collection if isinstance(f, Folder) ] 136 | >>> folders 137 | [, , ...] 138 | >>> # Get the root folders 139 | ... root = [ f for f in folders if f.Parent == "" ] 140 | >>> root 141 | [, , ...] 142 | >>> # Create a new folder 143 | ... new_folder = Folder("New Folder") 144 | >>> new_folder 145 | 146 | >>> rmapy.create_folder(new_folder) 147 | True 148 | >>> # verify 149 | ... [ f for f in rmapy.get_meta_items() if f.VissibleName == "New Folder" ] 150 | [] 151 | >>> [ f for f in rmapy.get_meta_items() if f.VissibleName == "New Folder" ][0].ID == new_folder.ID 152 | True 153 | >>> # Move a document in a folder 154 | ... doc = rmapy.get_doc("a969fcd6-64b0-4f71-b1ce-d9533ec4a2a3") 155 | >>> doc 156 | 157 | >>> doc.Parent = new_folder.ID 158 | >>> # Submit the changes 159 | ... rmapy.update_metadata(doc) 160 | True 161 | >>> doc = rmapy.get_doc("a969fcd6-64b0-4f71-b1ce-d9533ec4a2a3") 162 | >>> doc.Parent == new_folder.ID 163 | True 164 | 165 | 166 | Uploading & downloading 167 | ~~~~~~~~~~~~~~~~~~~~~~~~ 168 | 169 | reMarkable has a "special" file format for the raw documents. 170 | This is basically a zip file with files describing the document. 171 | 172 | Here is the content of an archive retried on the tablet as example: 173 | 174 | * 384327f5-133e-49c8-82ff-30aa19f3cfa40.content 175 | * 384327f5-133e-49c8-82ff-30aa19f3cfa40-metadata.json 176 | * 384326f5-133e-49c8-82ff-30aa19f3cfa40.pdf 177 | * 384327f5-133e-49c8-82ff-30aa19f3cfa40.pagedata 178 | * 384327f5-133e-49c8-82ff-30aa19f3cfa40.thumbnails/0.jpg 179 | * 384327f5-133e-49c8-82ff-30aa19f3cfa40.highlights/9b75d8df-1d06-4c59-8f3e-4cf69aa96cd9.json 180 | 181 | As the .zip file from remarkable is simply a normal .zip file 182 | containing specific file formats. 183 | 184 | Highlights are stored in the `{uuid}.highlights/` folder. 185 | 186 | You can find some help about the format at the following URL: 187 | https://remarkablewiki.com/tech/filesystem 188 | 189 | Uploading 190 | ````````` 191 | 192 | To upload a pdf or epub file, we'll first need to convert it into 193 | the remarkable file format: 194 | 195 | 196 | .. code-block:: python 197 | :linenos: 198 | 199 | 200 | >>> from rmapy.document import ZipDocument 201 | >>> from rmapy.api import Client 202 | >>> rm = Client() 203 | >>> rm.renew_token() 204 | True 205 | >>> rawDocument = ZipDocument(doc="/home/svancampenhout/27-11-2019.pdf") 206 | >>> rawDocument 207 | 208 | >>> rawDocument.metadata["VissibleName"] 209 | '27-11-2019' 210 | 211 | Now we can upload this to a specific folder: 212 | 213 | .. code-block:: python 214 | :linenos: 215 | 216 | 217 | >>> books = [ i for i in rm.get_meta_items() if i.VissibleName == "Boeken" ][0] 218 | >>> rm.upload(rawDocument, books) 219 | True 220 | 221 | And verify its existance: 222 | 223 | .. code-block:: python 224 | :linenos: 225 | 226 | >>> [ i.VissibleName for i in collection.children(books) if i.Type == "DocumentType" ] 227 | ['Origin - Dan Brown', 'Flatland', 'Game Of Thrones', '27-11-2019'] 228 | 229 | -------------------------------------------------------------------------------- /docs/source/rmapi.rst: -------------------------------------------------------------------------------- 1 | rmapy package 2 | ============= 3 | 4 | Submodules 5 | ---------- 6 | 7 | rmapy.api module 8 | ---------------- 9 | 10 | .. automodule:: rmapy.api 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | rmapy.collections module 16 | ------------------------ 17 | 18 | .. automodule:: rmapy.collections 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | rmapy.config module 24 | ------------------- 25 | 26 | .. automodule:: rmapy.config 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | rmapy.const module 32 | ------------------ 33 | 34 | .. automodule:: rmapy.const 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | rmapy.document module 40 | --------------------- 41 | 42 | .. automodule:: rmapy.document 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | rmapy.exceptions module 48 | ----------------------- 49 | 50 | .. automodule:: rmapy.exceptions 51 | :members: 52 | :undoc-members: 53 | :show-inheritance: 54 | 55 | rmapy.folder module 56 | ------------------- 57 | 58 | .. automodule:: rmapy.folder 59 | :members: 60 | :undoc-members: 61 | :show-inheritance: 62 | 63 | rmapy.meta module 64 | ----------------- 65 | 66 | .. automodule:: rmapy.meta 67 | :members: 68 | :undoc-members: 69 | :show-inheritance: 70 | 71 | rmapy.types module 72 | ------------------ 73 | 74 | .. automodule:: rmapy.types 75 | :members: 76 | :undoc-members: 77 | :show-inheritance: 78 | 79 | 80 | Module contents 81 | --------------- 82 | 83 | .. automodule:: rmapy 84 | :members: 85 | :undoc-members: 86 | :show-inheritance: 87 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | pyaml==19.4.1 3 | sphinx==2.2.0 4 | sphinx-autodoc-typehints==1.8.0 5 | guzzle-sphinx-theme==0.7.11 6 | urllib3>=1.26.5 7 | 8 | -------------------------------------------------------------------------------- /rmapy/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. note:: 3 | This is an unofficial api client for the Remarkable Cloud. Use at your own 4 | risk. 5 | """ 6 | -------------------------------------------------------------------------------- /rmapy/api.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from logging import getLogger 3 | from datetime import datetime 4 | from typing import Union, Optional 5 | from uuid import uuid4 6 | from .collections import Collection 7 | from .config import load, dump 8 | from .document import Document, ZipDocument, from_request_stream 9 | from .folder import Folder 10 | from .exceptions import ( 11 | AuthError, 12 | DocumentNotFound, 13 | ApiError, 14 | UnsupportedTypeError,) 15 | from .const import (RFC3339Nano, 16 | USER_AGENT, 17 | BASE_URL, 18 | DEVICE_TOKEN_URL, 19 | USER_TOKEN_URL, 20 | DEVICE,) 21 | 22 | log = getLogger("rmapy") 23 | DocumentOrFolder = Union[Document, Folder] 24 | 25 | 26 | class Client(object): 27 | """API Client for Remarkable Cloud 28 | 29 | This allows you to authenticate & communicate with the Remarkable Cloud 30 | and does all the heavy lifting for you. 31 | """ 32 | 33 | token_set = { 34 | "devicetoken": "", 35 | "usertoken": "" 36 | } 37 | 38 | def __init__(self): 39 | config = load() 40 | if "devicetoken" in config: 41 | self.token_set["devicetoken"] = config["devicetoken"] 42 | if "usertoken" in config: 43 | self.token_set["usertoken"] = config["usertoken"] 44 | 45 | def request(self, method: str, path: str, 46 | data=None, 47 | body=None, headers=None, 48 | params=None, stream=False) -> requests.Response: 49 | """Creates a request against the Remarkable Cloud API 50 | 51 | This function automatically fills in the blanks of base 52 | url & authentication. 53 | 54 | Args: 55 | method: The request method. 56 | path: complete url or path to request. 57 | data: raw data to put/post/... 58 | body: the body to request with. This will be converted to json. 59 | headers: a dict of additional headers to add to the request. 60 | params: Query params to append to the request. 61 | stream: Should the response be a stream? 62 | Returns: 63 | A Response instance containing most likely the response from 64 | the server. 65 | """ 66 | 67 | if headers is None: 68 | headers = {} 69 | if not path.startswith("http"): 70 | if not path.startswith('/'): 71 | path = '/' + path 72 | url = f"{BASE_URL}{path}" 73 | else: 74 | url = path 75 | 76 | _headers = { 77 | "user-agent": USER_AGENT, 78 | } 79 | 80 | if self.token_set["usertoken"]: 81 | token = self.token_set["usertoken"] 82 | _headers["Authorization"] = f"Bearer {token}" 83 | for k in headers.keys(): 84 | _headers[k] = headers[k] 85 | log.debug(url, _headers) 86 | r = requests.request(method, url, 87 | json=body, 88 | data=data, 89 | headers=_headers, 90 | params=params, 91 | stream=stream) 92 | return r 93 | 94 | def register_device(self, code: str): 95 | """Registers a device on the Remarkable Cloud. 96 | 97 | This uses a unique code the user gets from 98 | https://my.remarkable.com/device/desktop/connect to register 99 | a new device or client to be able to execute api calls. 100 | 101 | Args: 102 | code: A unique One time code the user can get 103 | at https://my.remarkable.com/device/desktop/connect. 104 | Returns: 105 | True 106 | Raises: 107 | AuthError: We didn't recieved an devicetoken from the Remarkable 108 | Cloud. 109 | """ 110 | 111 | uuid = str(uuid4()) 112 | body = { 113 | "code": code, 114 | "deviceDesc": DEVICE, 115 | "deviceID": uuid, 116 | 117 | } 118 | response = self.request("POST", DEVICE_TOKEN_URL, body=body) 119 | if response.ok: 120 | self.token_set["devicetoken"] = response.text 121 | dump(self.token_set) 122 | return True 123 | else: 124 | raise AuthError("Can't register device") 125 | 126 | def renew_token(self): 127 | """Fetches a new user_token. 128 | 129 | This is the second step of the authentication of the Remarkable Cloud. 130 | Before each new session, you should fetch a new user token. 131 | User tokens have an unknown expiration date. 132 | 133 | Returns: 134 | True 135 | 136 | Raises: 137 | AuthError: An error occurred while renewing the user token. 138 | """ 139 | 140 | if not self.token_set["devicetoken"]: 141 | raise AuthError("Please register a device first") 142 | token = self.token_set["devicetoken"] 143 | response = self.request("POST", USER_TOKEN_URL, None, headers={ 144 | "Authorization": f"Bearer {token}" 145 | }) 146 | if response.ok: 147 | self.token_set["usertoken"] = response.text 148 | dump(self.token_set) 149 | return True 150 | else: 151 | raise AuthError("Can't renew token: {e}".format( 152 | e=response.status_code)) 153 | 154 | def is_auth(self) -> bool: 155 | """Is the client authenticated 156 | 157 | Returns: 158 | bool: True if the client is authenticated 159 | """ 160 | 161 | if self.token_set["devicetoken"] and self.token_set["usertoken"]: 162 | return True 163 | else: 164 | return False 165 | 166 | def get_meta_items(self) -> Collection: 167 | """Returns a new collection from meta items. 168 | 169 | It fetches all meta items from the Remarkable Cloud and stores them 170 | in a collection, wrapping them in the correct class. 171 | 172 | Returns: 173 | Collection: a collection of Documents & Folders from the Remarkable 174 | Cloud 175 | """ 176 | 177 | response = self.request("GET", "/document-storage/json/2/docs") 178 | collection = Collection() 179 | log.debug(response.text) 180 | for item in response.json(): 181 | collection.add(item) 182 | 183 | return collection 184 | 185 | def get_doc(self, _id: str) -> Optional[DocumentOrFolder]: 186 | """Get a meta item by ID 187 | 188 | Fetch a meta item from the Remarkable Cloud by ID. 189 | 190 | Args: 191 | _id: The id of the meta item. 192 | 193 | Returns: 194 | A Document or Folder instance of the requested ID. 195 | Raises: 196 | DocumentNotFound: When a document cannot be found. 197 | """ 198 | 199 | log.debug(f"GETTING DOC {_id}") 200 | response = self.request("GET", "/document-storage/json/2/docs", 201 | params={ 202 | "doc": _id, 203 | "withBlob": True 204 | }) 205 | log.debug(response.url) 206 | data_response = response.json() 207 | log.debug(data_response) 208 | 209 | if len(data_response) > 0: 210 | if data_response[0]["Type"] == "CollectionType": 211 | return Folder(**data_response[0]) 212 | elif data_response[0]["Type"] == "DocumentType": 213 | return Document(**data_response[0]) 214 | else: 215 | raise DocumentNotFound(f"Could not find document {_id}") 216 | return None 217 | 218 | def download(self, document: Document) -> ZipDocument: 219 | """Download a ZipDocument 220 | 221 | This will download a raw document from the Remarkable Cloud containing 222 | the real document. See the documentation for ZipDocument for more 223 | information. 224 | 225 | Args: 226 | document: A Document instance we should download 227 | 228 | Returns: 229 | A ZipDocument instance, containing the raw data files from a 230 | document. 231 | """ 232 | 233 | if not document.BlobURLGet: 234 | doc = self.get_doc(document.ID) 235 | if isinstance(doc, Document): 236 | document = doc 237 | else: 238 | raise UnsupportedTypeError( 239 | "We expected a document, got {type}" 240 | .format(type=type(doc))) 241 | log.debug("BLOB", document.BlobURLGet) 242 | r = self.request("GET", document.BlobURLGet, stream=True) 243 | return from_request_stream(document.ID, r) 244 | 245 | def delete(self, doc: DocumentOrFolder): 246 | """Delete a document from the cloud. 247 | 248 | Args: 249 | doc: A Document or folder to delete. 250 | Raises: 251 | ApiError: an error occurred while uploading the document. 252 | """ 253 | 254 | response = self.request("PUT", "/document-storage/json/2/delete", 255 | body=[{ 256 | "ID": doc.ID, 257 | "Version": doc.Version 258 | }]) 259 | 260 | return self.check_response(response) 261 | 262 | def upload(self, zip_doc: ZipDocument, to: Folder = Folder(ID="")): 263 | """Upload a document to the cloud. 264 | 265 | Add a new document to the Remarkable Cloud. 266 | 267 | Args: 268 | zip_doc: A ZipDocument instance containing the data of a Document. 269 | to: the parent of the document. (Default root) 270 | Raises: 271 | ApiError: an error occurred while uploading the document. 272 | 273 | """ 274 | 275 | blob_url_put = self._upload_request(zip_doc) 276 | zip_doc.dump(zip_doc.zipfile) 277 | response = self.request("PUT", blob_url_put, data=zip_doc.zipfile.read()) 278 | # Reset seek 279 | zip_doc.zipfile.seek(0) 280 | if response.ok: 281 | doc = Document(**zip_doc.metadata) 282 | doc.ID = zip_doc.ID 283 | doc.Parent = to.ID 284 | return self.update_metadata(doc) 285 | else: 286 | raise ApiError("an error occured while uploading the document.", 287 | response=response) 288 | 289 | def update_metadata(self, docorfolder: DocumentOrFolder): 290 | """Send an update of the current metadata of a meta object 291 | 292 | Update the meta item. 293 | 294 | Args: 295 | docorfolder: A document or folder to update the meta information 296 | from. 297 | """ 298 | 299 | req = docorfolder.to_dict() 300 | req["Version"] = self.get_current_version(docorfolder) + 1 301 | req["ModifiedClient"] = datetime.utcnow().strftime(RFC3339Nano) 302 | res = self.request("PUT", 303 | "/document-storage/json/2/upload/update-status", 304 | body=[req]) 305 | 306 | return self.check_response(res) 307 | 308 | def get_current_version(self, docorfolder: DocumentOrFolder) -> int: 309 | """Get the latest version info from a Document or Folder 310 | 311 | This fetches the latest meta information from the Remarkable Cloud 312 | and returns the version information. 313 | 314 | Args: 315 | docorfolder: A Document or Folder instance. 316 | Returns: 317 | the version information. 318 | Raises: 319 | DocumentNotFound: cannot find the requested Document or Folder. 320 | ApiError: An error occurred while processing the request. 321 | """ 322 | 323 | try: 324 | d = self.get_doc(docorfolder.ID) 325 | except DocumentNotFound: 326 | return 0 327 | if not d: 328 | return 0 329 | return int(d.Version) 330 | 331 | def _upload_request(self, zip_doc: ZipDocument) -> str: 332 | zip_file, req = zip_doc.create_request() 333 | res = self.request("PUT", "/document-storage/json/2/upload/request", 334 | body=[req]) 335 | if not res.ok: 336 | raise ApiError( 337 | f"upload request failed with status {res.status_code}", 338 | response=res) 339 | response = res.json() 340 | if len(response) > 0: 341 | dest = response[0].get("BlobURLPut", None) 342 | if dest: 343 | return dest 344 | else: 345 | raise ApiError( 346 | "Cannot create a folder. because BlobURLPut is not set", 347 | response=res) 348 | 349 | def create_folder(self, folder: Folder): 350 | """Create a new folder meta object. 351 | 352 | This needs to be done in 3 steps: 353 | 354 | #. Create an upload request for a new CollectionType meta object. 355 | #. Upload a zipfile with a *.content file containing an empty object. 356 | #. Update the meta object with the new name. 357 | 358 | Args: 359 | folder: A folder instance. 360 | Returns: 361 | True if the folder is created. 362 | """ 363 | 364 | zip_folder, req = folder.create_request() 365 | res = self.request("PUT", "/document-storage/json/2/upload/request", 366 | body=[req]) 367 | if not res.ok: 368 | raise ApiError( 369 | f"upload request failed with status {res.status_code}", 370 | response=res) 371 | response = res.json() 372 | if len(response) > 0: 373 | dest = response[0].get("BlobURLPut", None) 374 | if dest: 375 | res = self.request("PUT", dest, data=zip_folder.read()) 376 | else: 377 | raise ApiError( 378 | "Cannot create a folder. because BlobURLPut is not set", 379 | response=res) 380 | if res.ok: 381 | self.update_metadata(folder) 382 | return True 383 | 384 | @staticmethod 385 | def check_response(response: requests.Response): 386 | """Check the response from an API Call 387 | 388 | Does some sanity checking on the Response 389 | 390 | Args: 391 | response: A API Response 392 | 393 | Returns: 394 | True if the response looks ok 395 | 396 | Raises: 397 | ApiError: When the response contains an error 398 | """ 399 | 400 | if response.ok: 401 | if len(response.json()) > 0: 402 | if response.json()[0]["Success"]: 403 | return True 404 | else: 405 | log.error("Got A non success response") 406 | msg = response.json()[0]["Message"] 407 | log.error(msg) 408 | raise ApiError(f"{msg}", 409 | response=response) 410 | else: 411 | log.error("Got An empty response") 412 | raise ApiError("Got An empty response", 413 | response=response) 414 | else: 415 | log.error(f"Got An invalid HTTP Response: {response.status_code}") 416 | raise ApiError( 417 | f"Got An invalid HTTP Response: {response.status_code}", 418 | response=response) 419 | -------------------------------------------------------------------------------- /rmapy/collections.py: -------------------------------------------------------------------------------- 1 | from .document import Document 2 | from .folder import Folder 3 | from typing import NoReturn, List, Union 4 | from .exceptions import FolderNotFound 5 | 6 | DocumentOrFolder = Union[Document, Folder] 7 | 8 | 9 | class Collection(object): 10 | """A collection of meta items 11 | 12 | This is basically the content of the Remarkable Cloud. 13 | 14 | Attributes: 15 | items: A list containing the items. 16 | """ 17 | 18 | def __init__(self, *items: List[DocumentOrFolder]): 19 | self.items: List[DocumentOrFolder] = [] 20 | 21 | for i in items: 22 | self.items.append(i) 23 | 24 | def add(self, doc_dict: dict) -> None: 25 | """Add an item to the collection. 26 | It wraps it in the correct class based on the Type parameter of the 27 | dict. 28 | 29 | Args: 30 | doc_dict: A dict representing a document or folder. 31 | """ 32 | 33 | if doc_dict.get("Type", None) == "DocumentType": 34 | self.add_document(doc_dict) 35 | elif doc_dict.get("Type", None) == "CollectionType": 36 | self.add_folder(doc_dict) 37 | else: 38 | raise TypeError("Unsupported type: {_type}" 39 | .format(_type=doc_dict.get("Type", None))) 40 | 41 | def add_document(self, doc_dict: dict) -> None: 42 | """Add a document to the collection 43 | 44 | Args: 45 | doc_dict: A dict representing a document. 46 | """ 47 | 48 | self.items.append(Document(**doc_dict)) 49 | 50 | def add_folder(self, dir_dict: dict) -> None: 51 | """Add a document to the collection 52 | 53 | Args: 54 | dir_dict: A dict representing a folder. 55 | """ 56 | 57 | self.items.append(Folder(**dir_dict)) 58 | 59 | def parent(self, doc_or_folder: DocumentOrFolder) -> Folder: 60 | """Returns the paren of a Document or Folder 61 | 62 | Args: 63 | doc_or_folder: A document or folder to get the parent from 64 | 65 | Returns: 66 | The parent folder. 67 | """ 68 | 69 | results = [i for i in self.items if i.ID == doc_or_folder.ID] 70 | if len(results) > 0 and isinstance(results[0], Folder): 71 | return results[0] 72 | else: 73 | raise FolderNotFound("Could not found the parent of the document.") 74 | 75 | def children(self, folder: Folder = None) -> List[DocumentOrFolder]: 76 | """Get all the children from a folder 77 | 78 | Args: 79 | folder: A folder where to get the children from. If None, this will 80 | get the children in the root. 81 | Returns: 82 | a list of documents an folders. 83 | """ 84 | 85 | if folder: 86 | return [i for i in self.items if i.Parent == folder.ID] 87 | else: 88 | return [i for i in self.items if i.Parent == ""] 89 | 90 | def __len__(self) -> int: 91 | return len(self.items) 92 | 93 | def __getitem__(self, position: int) -> DocumentOrFolder: 94 | return self.items[position] 95 | -------------------------------------------------------------------------------- /rmapy/config.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from yaml import BaseLoader 3 | from yaml import load as yml_load 4 | from yaml import dump as yml_dump 5 | from typing import Dict 6 | 7 | 8 | def load() -> dict: 9 | """Load the .rmapy config file""" 10 | 11 | config_file_path = Path.joinpath(Path.home(), ".rmapi") 12 | config: Dict[str, str] = {} 13 | if Path.exists(config_file_path): 14 | with open(config_file_path, 'r') as config_file: 15 | config = dict(yml_load(config_file.read(), Loader=BaseLoader)) 16 | 17 | return config 18 | 19 | 20 | def dump(config: dict) -> None: 21 | """Dump config to the .rmapy config file 22 | 23 | Args: 24 | config: A dict containing data to dump to the .rmapi 25 | config file. 26 | """ 27 | 28 | config_file_path = Path.joinpath(Path.home(), ".rmapi") 29 | 30 | with open(config_file_path, 'w') as config_file: 31 | config_file.write(yml_dump(config)) 32 | 33 | 34 | -------------------------------------------------------------------------------- /rmapy/const.py: -------------------------------------------------------------------------------- 1 | RFC3339Nano = "%Y-%m-%dT%H:%M:%SZ" 2 | USER_AGENT = "rmapy" 3 | AUTH_BASE_URL = "https://webapp-production-dot-remarkable-production.appspot.com" 4 | BASE_URL = "https://document-storage-production-dot-remarkable-production.appspot.com" # noqa 5 | DEVICE_TOKEN_URL = AUTH_BASE_URL + "/token/json/2/device/new" 6 | USER_TOKEN_URL = AUTH_BASE_URL + "/token/json/2/user/new" 7 | DEVICE = "desktop-windows" 8 | SERVICE_MGR_URL = "https://service-manager-production-dot-remarkable-production.appspot.com" # noqa 9 | -------------------------------------------------------------------------------- /rmapy/document.py: -------------------------------------------------------------------------------- 1 | import os 2 | from io import BytesIO 3 | from zipfile import ZipFile, ZIP_DEFLATED 4 | import shutil 5 | from uuid import uuid4 6 | import json 7 | from typing import TypeVar, List, Tuple 8 | from logging import getLogger 9 | from requests import Response 10 | from .meta import Meta 11 | 12 | log = getLogger("rmapy") 13 | BytesOrString = TypeVar("BytesOrString", BytesIO, str) 14 | 15 | 16 | class RmPage(object): 17 | """A Remarkable Page 18 | 19 | Contains the metadata, the page itself & thumbnail. 20 | 21 | """ 22 | def __init__(self, page, metadata=None, order=0, thumbnail=None, _id=None): 23 | self.page = page 24 | if metadata: 25 | self.metadata = metadata 26 | else: 27 | self.metadata = {"layers": [{"name": "Layer 1"}]} 28 | 29 | self.order = order 30 | if thumbnail: 31 | self.thumbnail = thumbnail 32 | if _id: 33 | self.ID = _id 34 | else: 35 | self.ID = str(uuid4()) 36 | 37 | def __str__(self) -> str: 38 | """String representation of this object""" 39 | return f"" 40 | 41 | def __repr__(self) -> str: 42 | """String representation of this object""" 43 | return self.__str__() 44 | 45 | class Highlight(object): 46 | """ Highlight represents all highlights on a page created using the highligher pen 47 | in EPUB documents. 48 | 49 | Functionality introduced in Remarkable 2.7 software. 50 | 51 | Contains the page_id where the highlights are located and the highlights 52 | metadata for the page from the Remarkable Cloud. 53 | 54 | Corresponds to single .json file in the .highlights/ folder. 55 | 56 | Attributes: 57 | page_id: The ID of the page where the highlight is located. 58 | highlight_data: A dictionary containing all highlight data. 59 | """ 60 | 61 | def __init__(self, page_id: str, highlight_data: str): 62 | self.page_id = page_id 63 | self.highlight_data = json.loads(highlight_data) 64 | 65 | def __str__(self) -> str: 66 | """String representation of this object""" 67 | return f"" 68 | 69 | def __repr__(self) -> str: 70 | """String representation of this object""" 71 | return self.__str__() 72 | 73 | class Document(Meta): 74 | """ Document represents a real object expected in most 75 | calls by the remarkable API 76 | 77 | This contains the metadata from a document. 78 | 79 | Attributes: 80 | ID: Id of the meta object. 81 | Version: The version of this object. 82 | Success: If the last API Call was a success. 83 | BlobURLGet: The url to get the data blob from. Can be empty. 84 | BlobURLGetExpires: The expiration date of the Get url. 85 | BlobURLPut: The url to upload the data blob to. Can be empty. 86 | BlobURLPutExpires: The expiration date of the Put url. 87 | ModifiedClient: When the last change was by the client. 88 | Type: Currently there are only 2 known types: DocumentType & 89 | CollectionType. 90 | VissibleName: The human name of the object. 91 | CurrentPage: The current selected page of the object. 92 | Bookmarked: If the object is bookmarked. 93 | Parent: If empty, this object is is the root folder. This can be an ID 94 | of a CollectionType. 95 | 96 | """ 97 | 98 | def __init__(self, **kwargs): 99 | super(Document, self).__init__(**kwargs) 100 | self.Type = "DocumentType" 101 | 102 | def __str__(self): 103 | """String representation of this object""" 104 | return f"" 105 | 106 | def __repr__(self): 107 | """String representation of this object""" 108 | return self.__str__() 109 | 110 | 111 | class ZipDocument(object): 112 | """ 113 | Here is the content of an archive retried on the tablet as example: 114 | 115 | * 384327f5-133e-49c8-82ff-30aa19f3cfa40.content 116 | * 384327f5-133e-49c8-82ff-30aa19f3cfa40-metadata.json 117 | * 384326f5-133e-49c8-82ff-30aa19f3cfa40.rm 118 | * 384327f5-133e-49c8-82ff-30aa19f3cfa40.pagedata 119 | * 384327f5-133e-49c8-82ff-30aa19f3cfa40.thumbnails/0.jpg 120 | * 384327f5-133e-49c8-82ff-30aa19f3cfa40.highlights/9b75d8df-1d06-4c59-8f3e-4cf69aa96cd9.json 121 | 122 | As the .zip file from remarkable is simply a normal .zip file 123 | containing specific file formats, this package is a helper to 124 | read and write zip files with the correct format expected by 125 | the tablet. 126 | 127 | In order to correctly use this package, you will have to understand 128 | the format of a Remarkable zip file, and the format of the files 129 | that it contains. 130 | 131 | You can find some help about the format at the following URL: 132 | https://remarkablewiki.com/tech/filesystem 133 | 134 | Attributes: 135 | content: Sane defaults for the .content file in the zip. 136 | metadata: parameters describing this blob. 137 | highlights: list of contents of the .highlights folder 138 | pagedata: the content of the .pagedata file. 139 | zipfile: The raw zipfile in memory. 140 | pdf: the raw pdf file if there is one. 141 | epub: the raw epub file if there is one. 142 | rm: A list of :class:rmapy.document.RmPage in this zip. 143 | 144 | """ 145 | def __init__(self, _id=None, doc=None, file=None): 146 | """Create a new instance of a ZipDocument 147 | 148 | Args: 149 | _id: Can be left empty to generate one 150 | doc: a raw pdf, epub or rm (.lines) file. 151 | file: a zipfile to convert from 152 | """ 153 | # {"extraMetadata": {}, 154 | # "fileType": "pdf", 155 | # "pageCount": 0, 156 | # "lastOpenedPage": 0, 157 | # "lineHeight": -1, 158 | # "margins": 180, 159 | # "textScale": 1, 160 | # "transform": {}} 161 | self.content = { 162 | "extraMetadata": { 163 | # "LastBrushColor": "Black", 164 | # "LastBrushThicknessScale": "2", 165 | # "LastColor": "Black", 166 | # "LastEraserThicknessScale": "2", 167 | # "LastEraserTool": "Eraser", 168 | # "LastPen": "Ballpoint", 169 | # "LastPenColor": "Black", 170 | # "LastPenThicknessScale": "2", 171 | # "LastPencil": "SharpPencil", 172 | # "LastPencilColor": "Black", 173 | # "LastPencilThicknessScale": "2", 174 | # "LastTool": "SharpPencil", 175 | # "ThicknessScale": "2" 176 | }, 177 | # "FileType": "", 178 | # "FontName": "", 179 | "lastOpenedPage": 0, 180 | "lineHeight": -1, 181 | "margins": 180, 182 | # "Orientation": "portrait", 183 | "pageCount": 0, 184 | # "Pages": [], 185 | "textScale": 1, 186 | "transform": { 187 | # "M11": 1, 188 | # "M12": 0, 189 | # "M13": 0, 190 | # "M21": 0, 191 | # "M22": 1, 192 | # "M23": 0, 193 | # "M31": 0, 194 | # "M32": 0, 195 | # "M33": 1, 196 | } 197 | } 198 | 199 | self.metadata = { 200 | "deleted": False, 201 | "lastModified": "1568368808000", 202 | "metadatamodified": False, 203 | "modified": False, 204 | "parent": "", 205 | "pinned": False, 206 | "synced": True, 207 | "type": "DocumentType", 208 | "version": 1, 209 | "VissibleName": "New Document" 210 | } 211 | 212 | self.pagedata = "b''" 213 | 214 | self.zipfile = BytesIO() 215 | self.pdf = None 216 | self.epub = None 217 | self.rm: List[RmPage] = [] 218 | self.ID = None 219 | 220 | self.highlights: List[Highlight] = [] 221 | 222 | if not _id: 223 | _id = str(uuid4()) 224 | self.ID = _id 225 | if doc: 226 | ext = doc[-4:] 227 | if ext.endswith("pdf"): 228 | self.content["fileType"] = "pdf" 229 | self.pdf = BytesIO() 230 | with open(doc, 'rb') as fb: 231 | self.pdf.write(fb.read()) 232 | self.pdf.seek(0) 233 | if ext.endswith("epub"): 234 | self.content["fileType"] = "epub" 235 | self.epub = BytesIO() 236 | with open(doc, 'rb') as fb: 237 | self.epub.write(fb.read()) 238 | self.epub.seek(0) 239 | elif ext.endswith("rm"): 240 | self.content["fileType"] = "notebook" 241 | with open(doc, 'rb') as fb: 242 | self.rm.append(RmPage(page=BytesIO(fb.read()))) 243 | name = os.path.splitext(os.path.basename(doc))[0] 244 | self.metadata["VissibleName"] = name 245 | 246 | if file: 247 | self.load(file) 248 | 249 | def __str__(self) -> str: 250 | """string representation of this class""" 251 | return f"" 252 | 253 | def __repr__(self) -> str: 254 | """string representation of this class""" 255 | return self.__str__() 256 | 257 | def create_request(self) -> Tuple[BytesIO, dict]: 258 | return self.zipfile, { 259 | "ID": self.ID, 260 | "Type": "DocumentType", 261 | "Version": self.metadata["version"] 262 | } 263 | 264 | def dump(self, file: BytesOrString) -> None: 265 | """Dump the contents of ZipDocument back to a zip file. 266 | 267 | This builds a zipfile to upload back to the Remarkable Cloud. 268 | 269 | Args: 270 | file: Where to save the zipfile 271 | 272 | """ 273 | with ZipFile(file, "w", ZIP_DEFLATED) as zf: 274 | zf.writestr(f"{self.ID}.content", 275 | json.dumps(self.content)) 276 | zf.writestr(f"{self.ID}.pagedata", 277 | self.pagedata) 278 | 279 | if self.pdf: 280 | zf.writestr(f"{self.ID}.pdf", 281 | self.pdf.read()) 282 | 283 | if self.epub: 284 | zf.writestr(f"{self.ID}.epub", 285 | self.epub.read()) 286 | 287 | for highlight in self.highlights: 288 | zf.writestr(f"{self.ID}.highlights/{highlight.page_id}.json", 289 | json.dumps(highlight.highlight_data)) 290 | 291 | for page in self.rm: 292 | 293 | zf.writestr(f"{self.ID}/{page.order}.rm", 294 | page.page.read()) 295 | 296 | zf.writestr(f"{self.ID}/{page.order}-metadata.json", 297 | json.dumps(page.metadata)) 298 | page.page.seek(0) 299 | try: 300 | zf.writestr(f"{self.ID}.thumbnails/{page.order}.jpg", 301 | page.thumbnail.read()) 302 | except AttributeError: 303 | log.debug(f"missing thumbnail during dump: {self.ID}: {page.order}") 304 | pass 305 | if isinstance(file, BytesIO): 306 | file.seek(0) 307 | 308 | def load(self, file: BytesOrString) -> None: 309 | """Load a zipfile into this class. 310 | 311 | Extracts the zipfile and reads in the contents. 312 | 313 | Args: 314 | file: A string of a file location or a BytesIO instance of a raw 315 | zipfile 316 | """ 317 | 318 | self.zipfile = BytesIO() 319 | self.zipfile.seek(0) 320 | if isinstance(file, str): 321 | with open(file, 'rb') as f: 322 | shutil.copyfileobj(f, self.zipfile) 323 | elif isinstance(file, BytesIO): 324 | self.zipfile = file 325 | self.zipfile.seek(0) 326 | else: 327 | raise Exception("Unsupported file type.") 328 | with ZipFile(self.zipfile, 'r') as zf: 329 | with zf.open(f"{self.ID}.content", 'r') as content: 330 | self.content = json.load(content) 331 | try: 332 | with zf.open(f"{self.ID}.metadata", 'r') as metadata: 333 | self.metadata = json.load(metadata) 334 | except KeyError: 335 | pass 336 | try: 337 | with zf.open(f"{self.ID}.pagedata", 'r') as pagedata: 338 | self.pagedata = str(pagedata.read()) 339 | except KeyError: 340 | pass 341 | 342 | try: 343 | with zf.open(f"{self.ID}.pdf", 'r') as pdf: 344 | self.pdf = BytesIO(pdf.read()) 345 | except KeyError: 346 | pass 347 | 348 | try: 349 | with zf.open(f"{self.ID}.epub", 'r') as epub: 350 | self.epub = BytesIO(epub.read()) 351 | except KeyError: 352 | pass 353 | 354 | # Get Highlights 355 | highlights = [x for x in zf.namelist() 356 | if x.startswith(f"{self.ID}.highlights/") and x.endswith('.json')] 357 | for highlight in highlights: 358 | with zf.open(highlight, 'r') as highlight_fp: 359 | page_id = highlight.replace(f"{self.ID}.highlights/", "").replace(".json", "") 360 | self.highlights.append(Highlight(page_id, highlight_fp.read())) 361 | 362 | # Get the RM pages 363 | pages = [x for x in zf.namelist() 364 | if x.startswith(f"{self.ID}/") and x.endswith('.rm')] 365 | for p in pages: 366 | page_number = int(p.replace(f"{self.ID}/", "") 367 | .replace(".rm", "")) 368 | with zf.open(p, 'r') as rm: 369 | page = BytesIO(rm.read()) 370 | page.seek(0) 371 | 372 | p_meta = p.replace(".rm", "-metadata.json") 373 | try: 374 | with zf.open(p_meta, 'r') as md: 375 | metadata = json.load(md) 376 | except KeyError: 377 | log.debug(f"missing metadata: {p_meta}") 378 | metadata = None 379 | thumbnail_name = p.replace(".rm", ".jpg") 380 | thumbnail_name = thumbnail_name.replace("/", ".thumbnails/") 381 | try: 382 | with zf.open(thumbnail_name, 'r') as tn: 383 | thumbnail = BytesIO(tn.read()) 384 | thumbnail.seek(0) 385 | except KeyError: 386 | log.debug(f"missing thumbnail: {thumbnail_name}") 387 | thumbnail = None 388 | 389 | self.rm.append(RmPage(page, metadata, page_number, thumbnail, 390 | self.ID)) 391 | 392 | self.zipfile.seek(0) 393 | 394 | 395 | def from_zip(_id: str, file: str) -> ZipDocument: 396 | """Return A ZipDocument from a zipfile. 397 | 398 | Create a ZipDocument instance from a zipfile. 399 | 400 | Args: 401 | _id: The object ID this zipfile represents. 402 | file: the filename of the zipfile. 403 | Returns: 404 | An instance of the supplied zipfile. 405 | """ 406 | 407 | return ZipDocument(_id, file=file) 408 | 409 | 410 | def from_request_stream(_id: str, stream: Response) -> ZipDocument: 411 | """Return a ZipDocument from a request stream containing a zipfile. 412 | 413 | This is used with the BlobGETUrl from a :class:`rmapy.document.Document`. 414 | 415 | Args: 416 | _id: The object ID this zipfile represents. 417 | stream: a stream containing the zipfile. 418 | Returns: 419 | the object of the downloaded zipfile. 420 | """ 421 | 422 | tmp = BytesIO() 423 | for chunk in stream.iter_content(chunk_size=8192): 424 | tmp.write(chunk) 425 | zd = ZipDocument(_id=_id) 426 | zd.load(tmp) 427 | return zd 428 | -------------------------------------------------------------------------------- /rmapy/exceptions.py: -------------------------------------------------------------------------------- 1 | class AuthError(Exception): 2 | """Authentication error""" 3 | def __init__(self, msg): 4 | super(AuthError, self).__init__(msg) 5 | 6 | 7 | class DocumentNotFound(Exception): 8 | """Could not found a requested document""" 9 | def __init__(self, msg): 10 | super(DocumentNotFound, self).__init__(msg) 11 | 12 | 13 | class UnsupportedTypeError(Exception): 14 | """Not the expected type""" 15 | def __init__(self, msg): 16 | super(UnsupportedTypeError, self).__init__(msg) 17 | 18 | 19 | class FolderNotFound(Exception): 20 | """Could not found a requested folder""" 21 | def __init__(self, msg): 22 | super(FolderNotFound, self).__init__(msg) 23 | 24 | 25 | class ApiError(Exception): 26 | """Could not found a requested document""" 27 | def __init__(self, msg, response=None): 28 | self.response = response 29 | super(ApiError, self).__init__(msg) 30 | -------------------------------------------------------------------------------- /rmapy/folder.py: -------------------------------------------------------------------------------- 1 | from .meta import Meta 2 | from datetime import datetime 3 | from uuid import uuid4 4 | from io import BytesIO 5 | from zipfile import ZipFile, ZIP_DEFLATED 6 | from .const import RFC3339Nano 7 | from typing import Tuple, Optional 8 | 9 | 10 | class ZipFolder(object): 11 | """A dummy zipfile to create a folder 12 | 13 | This is needed to create a folder on the Remarkable Cloud 14 | """ 15 | 16 | def __init__(self, _id: str): 17 | """Creates a zipfile in memory 18 | 19 | Args: 20 | _id: the ID to create a zipFolder for 21 | """ 22 | super(ZipFolder, self).__init__() 23 | self.ID = _id 24 | self.file = BytesIO() 25 | self.Version = 1 26 | with ZipFile(self.file, 'w', ZIP_DEFLATED) as zf: 27 | zf.writestr(f"{self.ID}.content", "{}") 28 | self.file.seek(0) 29 | 30 | 31 | class Folder(Meta): 32 | """ 33 | A Meta type of object used to represent a folder. 34 | """ 35 | 36 | def __init__(self, name: Optional[str] = None, **kwargs) -> None: 37 | """Create a Folder instance 38 | 39 | Args: 40 | name: An optional name for this folder. In the end, a name is 41 | really needed, but can be omitted to set a later time. 42 | """ 43 | 44 | super(Folder, self).__init__(**kwargs) 45 | self.Type = "CollectionType" 46 | if name: 47 | self.VissibleName = name 48 | if not self.ID: 49 | self.ID = str(uuid4()) 50 | 51 | def create_request(self) -> Tuple[BytesIO, dict]: 52 | """Prepares the necessary parameters to create this folder. 53 | 54 | This creates a ZipFolder & the necessary json body to 55 | create an upload request. 56 | """ 57 | 58 | return ZipFolder(self.ID).file, { 59 | "ID": self.ID, 60 | "Type": "CollectionType", 61 | "Version": 1 62 | } 63 | 64 | def update_request(self) -> dict: 65 | """Prepares the necessary parameters to update a folder. 66 | 67 | This sets some parameters in the data structure to submit to the API. 68 | """ 69 | 70 | data = self.to_dict() 71 | data["Version"] = data.get("Version", 0) + 1 72 | data["ModifiedClient"] = datetime.utcnow().strftime(RFC3339Nano) 73 | return data 74 | 75 | def __str__(self): 76 | return f"" 77 | 78 | def __repr__(self): 79 | return self.__str__() 80 | -------------------------------------------------------------------------------- /rmapy/meta.py: -------------------------------------------------------------------------------- 1 | class Meta(object): 2 | """ Meta represents a real object expected in most 3 | calls by the remarkable API 4 | 5 | This class is used to be subclassed by for new types. 6 | 7 | Attributes: 8 | ID: Id of the meta object. 9 | Version: The version of this object. 10 | Success: If the last API Call was a success. 11 | BlobURLGet: The url to get the data blob from. Can be empty. 12 | BlobURLGetExpires: The expiration date of the Get url. 13 | BlobURLPut: The url to upload the data blob to. Can be empty. 14 | BlobURLPutExpires: The expiration date of the Put url. 15 | ModifiedClient: When the last change was by the client. 16 | Type: Currently there are only 2 known types: DocumentType & 17 | CollectionType. 18 | VissibleName: The human name of the object. 19 | CurrentPage: The current selected page of the object. 20 | Bookmarked: If the object is bookmarked. 21 | Parent: If empty, this object is is the root folder. This can be an ID 22 | of a CollectionType. 23 | 24 | """ 25 | 26 | ID = "" 27 | Version = 0 28 | Message = "" 29 | Success = True 30 | BlobURLGet = "" 31 | BlobURLGetExpires = "" 32 | BlobURLPut = "" 33 | BlobURLPutExpires = "" 34 | ModifiedClient = "" 35 | Type = "" 36 | VissibleName = "" 37 | CurrentPage = 1 38 | Bookmarked = False 39 | Parent = "" 40 | 41 | def __init__(self, **kwargs): 42 | k_keys = self.to_dict().keys() 43 | for k in k_keys: 44 | setattr(self, k, kwargs.get(k, getattr(self, k))) 45 | 46 | def to_dict(self) -> dict: 47 | """Return a dict representation of this object. 48 | 49 | Used for API Calls. 50 | 51 | Returns 52 | a dict of the current object. 53 | """ 54 | 55 | return { 56 | "ID": self.ID, 57 | "Version": self.Version, 58 | "Message": self.Message, 59 | "Success": self.Success, 60 | "BlobURLGet": self.BlobURLGet, 61 | "BlobURLGetExpires": self.BlobURLGetExpires, 62 | "BlobURLPut": self.BlobURLPut, 63 | "BlobURLPutExpires": self.BlobURLPutExpires, 64 | "ModifiedClient": self.ModifiedClient, 65 | "Type": self.Type, 66 | "VissibleName": self.VissibleName, 67 | "CurrentPage": self.CurrentPage, 68 | "Bookmarked": self.Bookmarked, 69 | "Parent": self.Parent 70 | } 71 | 72 | -------------------------------------------------------------------------------- /rmapy/types.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subutux/rmapy/45485d4dbf38c9441580f120399dae58e6e07f1d/rmapy/types.py -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | # This includes the license file(s) in the wheel. 3 | # https://wheel.readthedocs.io/en/stable/user_guide.html#including-license-files-in-the-generated-wheel-file 4 | license_files = LICENSE.txt 5 | 6 | [bdist_wheel] 7 | # This flag says to generate wheels that support both Python 2 and Python 8 | # 3. If your code will not run unchanged on both Python 2 and 3, you will 9 | # need to generate separate wheels for each Python version that you 10 | # support. Removing this line (or setting universal to 0) will prevent 11 | # bdist_wheel from trying to make a universal wheel. For more see: 12 | # https://packaging.python.org/guides/distributing-packages-using-setuptools/#wheels 13 | universal=0 14 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """A setuptools based setup module. 2 | 3 | See: 4 | https://packaging.python.org/guides/distributing-packages-using-setuptools/ 5 | https://github.com/pypa/sampleproject 6 | """ 7 | 8 | # Always prefer setuptools over distutils 9 | from setuptools import setup, find_packages 10 | from os import path 11 | here = path.abspath(path.dirname(__file__)) 12 | 13 | # Get the long description from the README file 14 | with open(path.join(here, 'README.md'), encoding='utf-8') as f: 15 | long_description = f.read() 16 | 17 | # Arguments marked as "Required" below must be included for upload to PyPI. 18 | # Fields marked as "Optional" may be commented out. 19 | 20 | setup( 21 | # This is the name of your project. The first time you publish this 22 | # package, this name will be registered for you. It will determine how 23 | # users can install this project, e.g.: 24 | # 25 | # $ pip install sampleprojectk 26 | # 27 | # And where it will live on PyPI: https://pypi.org/project/sampleproject/ 28 | # 29 | # There are some restrictions on what makes a valid project name 30 | # specification here: 31 | # https://packaging.python.org/specifications/core-metadata/#name 32 | name='rmapy', # Required 33 | 34 | # Versions should comply with PEP 440: 35 | # https://www.python.org/dev/peps/pep-0440/ 36 | # 37 | # For a discussion on single-sourcing the version across setup.py and the 38 | # project code, see 39 | # https://packaging.python.org/en/latest/single_source_version.html 40 | version='0.3.1', # Required 41 | 42 | # This is a one-line description or tagline of what your project does. This 43 | # corresponds to the "Summary" metadata field: 44 | # https://packaging.python.org/specifications/core-metadata/#summary 45 | description='Remarkable Cloud Api Client', # Optional 46 | 47 | # This is an optional longer description of your project that represents 48 | # the body of text which users will see when they visit PyPI. 49 | # 50 | # Often, this is the same as your README, so you can just read it in from 51 | # that file directly (as we have already done above) 52 | # 53 | # This field corresponds to the "Description" metadata field: 54 | # https://packaging.python.org/specifications/core-metadata/#description-optional 55 | long_description=long_description, # Optional 56 | 57 | # Denotes that our long_description is in Markdown; valid values are 58 | # text/plain, text/x-rst, and text/markdown 59 | # 60 | # Optional if long_description is written in reStructuredText (rst) but 61 | # required for plain-text or Markdown; if unspecified, "applications should 62 | # attempt to render [the long_description] as text/x-rst; charset=UTF-8 and 63 | # fall back to text/plain if it is not valid rst" (see link below) 64 | # 65 | # This field corresponds to the "Description-Content-Type" metadata field: 66 | # https://packaging.python.org/specifications/core-metadata/#description-content-type-optional 67 | long_description_content_type='text/markdown', # Optional (see note above) 68 | 69 | # This should be a valid link to your project's main homepage. 70 | # 71 | # This field corresponds to the "Home-Page" metadata field: 72 | # https://packaging.python.org/specifications/core-metadata/#home-page-optional 73 | url='https://github.com/subutux/rmapy', # Optional 74 | 75 | # This should be your name or the name of the organization which owns the 76 | # project. 77 | author='Stijn Van Campenhout', # Optional 78 | 79 | # This should be a valid email address corresponding to the author listed 80 | # above. 81 | author_email='subutux@gmail.com', # Optional 82 | 83 | # Classifiers help users find your project by categorizing it. 84 | # 85 | # For a list of valid classifiers, see https://pypi.org/classifiers/ 86 | classifiers=[ # Optional 87 | # How mature is this project? Common values are 88 | # 3 - Alpha 89 | # 4 - Beta 90 | # 5 - Production/Stable 91 | #'Development Status :: 3 - Alpha', 92 | 'Development Status :: 4 - Beta', 93 | #'Development Status :: 5 - Production/Stable' 94 | #'Development Status :: 6 - Mature' 95 | # Indicate who your project is intended for 96 | 'Intended Audience :: Developers', 97 | 98 | # Pick your license as you wish 99 | 'License :: OSI Approved :: MIT License', 100 | 101 | # Specify the Python versions you support here. In particular, ensure 102 | # that you indicate whether you support Python 2, Python 3 or both. 103 | # These classifiers are *not* checked by 'pip install'. See instead 104 | # 'python_requires' below. 105 | 'Programming Language :: Python :: 3.6', 106 | 'Programming Language :: Python :: 3.7', 107 | ], 108 | 109 | # This field adds keywords for your project which will appear on the 110 | # project page. What does your project relate to? 111 | # 112 | # Note that this is a string of words separated by whitespace, not a list. 113 | keywords='remarkable rmapy cloud paper tablet', # Optional 114 | 115 | # You can just specify package directories manually here if your project is 116 | # simple. Or you can use find_packages(). 117 | # 118 | # Alternatively, if you just want to distribute a single Python file, use 119 | # the `py_modules` argument instead as follows, which will expect a file 120 | # called `my_module.py` to exist: 121 | # 122 | # py_modules=["my_module"], 123 | # 124 | packages=find_packages(exclude=['contrib', 'docs', 'tests']), # Required 125 | 126 | # Specify which Python versions you support. In contrast to the 127 | # 'Programming Language' classifiers above, 'pip install' will check this 128 | # and refuse to install the project if the version does not match. If you 129 | # do not support Python 2, you can simplify this to '>=3.5' or similar, see 130 | # https://packaging.python.org/guides/distributing-packages-using-setuptools/#python-requires 131 | python_requires='>=3.6, <4', 132 | 133 | # This field lists other packages that your project depends on to run. 134 | # Any package you put here will be installed by pip when your project is 135 | # installed, so they must be valid existing projects. 136 | # 137 | # For an analysis of "install_requires" vs pip's requirements files see: 138 | # https://packaging.python.org/en/latest/requirements.html 139 | install_requires=[ 140 | 'requests', 141 | 'pyaml==19.4.1' 142 | ], 143 | 144 | # List additional groups of dependencies here (e.g. development 145 | # dependencies). Users will be able to install these using the "extras" 146 | # syntax, for example: 147 | # 148 | # $ pip install sampleproject[dev] 149 | # 150 | # Similar to `install_requires` above, these must be valid existing 151 | # projects. 152 | extras_require={ # Optional 153 | 'doc': [ 154 | 'sphinx==2.2.0', 155 | 'sphinx-autodoc-typehints==1.8.0', 156 | 'guzzle-sphinx-theme==0.7.11' 157 | ], 158 | }, 159 | 160 | # If there are data files included in your packages that need to be 161 | # installed, specify them here. 162 | # 163 | # If using Python 2.6 or earlier, then these have to be included in 164 | # MANIFEST.in as well. 165 | # package_data={ # Optional 166 | # 'sample': ['package_data.dat'], 167 | # }, 168 | 169 | # Although 'package_data' is the preferred approach, in some case you may 170 | # need to place data files outside of your packages. See: 171 | # http://docs.python.org/3.4/distutils/setupscript.html#installing-additional-files 172 | # 173 | # In this case, 'data_file' will be installed into '/my_data' 174 | # data_files=[('my_data', ['data/data_file'])], # Optional 175 | 176 | # To provide executable scripts, use entry points in preference to the 177 | # "scripts" keyword. Entry points provide cross-platform support and allow 178 | # `pip` to create the appropriate form of executable for the target 179 | # platform. 180 | # 181 | # For example, the following would provide a command called `sample` which 182 | # executes the function `main` from this package when invoked: 183 | # TODO Create a command line tool 184 | # entry_points={ # Optional 185 | # 'console_scripts': [ 186 | # 'sample=sample:main', 187 | # ], 188 | # }, 189 | 190 | # List additional URLs that are relevant to your project as a dict. 191 | # 192 | # This field corresponds to the "Project-URL" metadata fields: 193 | # https://packaging.python.org/specifications/core-metadata/#project-url-multiple-use 194 | # 195 | # Examples listed include a pattern for specifying where the package tracks 196 | # issues, where the source is hosted, where to say thanks to the package 197 | # maintainers, and where to support the project financially. The key is 198 | # what's used to render the link text on PyPI. 199 | project_urls={ # Optional 200 | 'Bug Reports': 'https://github.com/subutux/rmapy/issues', 201 | 'Funding': 'https://donate.pypi.org', 202 | 'Say Thanks!': 'https://www.paypal.me/subutux', 203 | 'Source': 'https://github.com/subutux/rmapy/', 204 | }, 205 | ) 206 | --------------------------------------------------------------------------------