├── .flake8 ├── .github └── workflows │ ├── lint.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── README.md ├── docs ├── Makefile ├── api.rst ├── auth.rst ├── conf.py ├── index.rst ├── make.bat ├── requirements.txt └── summaries.rst ├── noxfile.py ├── oura ├── __init__.py ├── auth.py ├── client.py ├── client_pandas.py ├── converters.py ├── exceptions.py ├── export │ ├── __init__.py │ └── writers.py └── v2 │ ├── __init__.py │ ├── client.py │ └── client_pandas.py ├── requirements.txt ├── samples ├── sample.py └── token.json ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── mock_client.py ├── mock_client_v2.py ├── test_auth.py ├── test_client.py ├── test_client_pandas.py ├── test_converters.py ├── test_mocks.py └── test_writers.py └── token_request.py /.flake8: -------------------------------------------------------------------------------- 1 | # .flake8 2 | [flake8] 3 | select = BLK,C,E,F,I,W 4 | ignore = E203,W503,E501,F401,E731 5 | max-line-length = 88 6 | max-complexity = 10 7 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: push 4 | 5 | jobs: 6 | lint: 7 | if: "!contains(github.event.head_commit.message, 'skip_ci')" 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: actions/setup-python@v5 12 | - run: python -m pip install --upgrade pip nox 13 | - run: nox -s lint 14 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 package 📦 to PyPI 2 | on: 3 | push: 4 | tags: 5 | - v*.*.* 6 | 7 | jobs: 8 | release: 9 | name: Publish python package 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-python@v5 14 | - run: python -m pip install --upgrade build 15 | - run: python -m build 16 | 17 | - name: Publish to PyPI 18 | if: startsWith(github.ref, 'refs/tags') 19 | uses: pypa/gh-action-pypi-publish@release/v1 20 | with: 21 | password: ${{ secrets.PYPI_API_TOKEN }} 22 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Pytest 2 | 3 | on: push 4 | 5 | jobs: 6 | test: 7 | if: "!contains(github.event.head_commit.message, 'skip_ci')" 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | python-version: [3.8, 3.9] 13 | 14 | name: Python ${{ matrix.python-version }} 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | 23 | - run: python -m pip install --upgrade pip nox 24 | - run: nox -s tests 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .*env 2 | .vscode 3 | __pycache__ 4 | build 5 | *.egg-info 6 | .tox 7 | .nox 8 | docs/_build/ 9 | data 10 | test_token.json 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | tabulate = "*" 8 | pytest = "*" 9 | twine = "*" 10 | requests-mock = "*" 11 | xlsxwriter = "*" 12 | 13 | [packages] 14 | requests-oauthlib = "*" 15 | pandas = "*" 16 | Flask = "*" 17 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "8c7dc197321c79d9432c793614c7ca8ac8cbd74060b01400195d59b368c687e7" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": {}, 8 | "sources": [ 9 | { 10 | "name": "pypi", 11 | "url": "https://pypi.org/simple", 12 | "verify_ssl": true 13 | } 14 | ] 15 | }, 16 | "default": { 17 | "certifi": { 18 | "hashes": [ 19 | "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d", 20 | "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412" 21 | ], 22 | "version": "==2022.6.15" 23 | }, 24 | "charset-normalizer": { 25 | "hashes": [ 26 | "sha256:5189b6f22b01957427f35b6a08d9a0bc45b46d3788ef5a92e978433c7a35f8a5", 27 | "sha256:575e708016ff3a5e3681541cb9d79312c416835686d054a23accb873b254f413" 28 | ], 29 | "version": "==2.1.0" 30 | }, 31 | "click": { 32 | "hashes": [ 33 | "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e", 34 | "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48" 35 | ], 36 | "version": "==8.1.3" 37 | }, 38 | "flask": { 39 | "hashes": [ 40 | "sha256:15972e5017df0575c3d6c090ba168b6db90259e620ac8d7ea813a396bad5b6cb", 41 | "sha256:9013281a7402ad527f8fd56375164f3aa021ecfaff89bfe3825346c24f87e04c" 42 | ], 43 | "index": "pypi", 44 | "version": "==2.1.3" 45 | }, 46 | "idna": { 47 | "hashes": [ 48 | "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", 49 | "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" 50 | ], 51 | "version": "==3.3" 52 | }, 53 | "importlib-metadata": { 54 | "hashes": [ 55 | "sha256:637245b8bab2b6502fcbc752cc4b7a6f6243bb02b31c5c26156ad103d3d45670", 56 | "sha256:7401a975809ea1fdc658c3aa4f78cc2195a0e019c5cbc4c06122884e9ae80c23" 57 | ], 58 | "markers": "python_version < '3.10'", 59 | "version": "==4.12.0" 60 | }, 61 | "itsdangerous": { 62 | "hashes": [ 63 | "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44", 64 | "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a" 65 | ], 66 | "version": "==2.1.2" 67 | }, 68 | "jinja2": { 69 | "hashes": [ 70 | "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852", 71 | "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61" 72 | ], 73 | "version": "==3.1.2" 74 | }, 75 | "markupsafe": { 76 | "hashes": [ 77 | "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003", 78 | "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88", 79 | "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5", 80 | "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7", 81 | "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a", 82 | "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603", 83 | "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1", 84 | "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135", 85 | "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247", 86 | "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6", 87 | "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601", 88 | "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77", 89 | "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02", 90 | "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e", 91 | "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63", 92 | "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f", 93 | "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980", 94 | "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b", 95 | "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812", 96 | "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff", 97 | "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96", 98 | "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1", 99 | "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925", 100 | "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a", 101 | "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6", 102 | "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e", 103 | "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f", 104 | "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4", 105 | "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f", 106 | "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3", 107 | "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c", 108 | "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a", 109 | "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417", 110 | "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a", 111 | "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a", 112 | "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37", 113 | "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452", 114 | "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933", 115 | "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a", 116 | "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7" 117 | ], 118 | "version": "==2.1.1" 119 | }, 120 | "numpy": { 121 | "hashes": [ 122 | "sha256:1408c3527a74a0209c781ac82bde2182b0f0bf54dea6e6a363fe0cc4488a7ce7", 123 | "sha256:173f28921b15d341afadf6c3898a34f20a0569e4ad5435297ba262ee8941e77b", 124 | "sha256:1865fdf51446839ca3fffaab172461f2b781163f6f395f1aed256b1ddc253622", 125 | "sha256:3119daed207e9410eaf57dcf9591fdc68045f60483d94956bee0bfdcba790953", 126 | "sha256:35590b9c33c0f1c9732b3231bb6a72d1e4f77872390c47d50a615686ae7ed3fd", 127 | "sha256:37e5ebebb0eb54c5b4a9b04e6f3018e16b8ef257d26c8945925ba8105008e645", 128 | "sha256:37ece2bd095e9781a7156852e43d18044fd0d742934833335599c583618181b9", 129 | "sha256:3ab67966c8d45d55a2bdf40701536af6443763907086c0a6d1232688e27e5447", 130 | "sha256:47f10ab202fe4d8495ff484b5561c65dd59177949ca07975663f4494f7269e3e", 131 | "sha256:55df0f7483b822855af67e38fb3a526e787adf189383b4934305565d71c4b148", 132 | "sha256:5d732d17b8a9061540a10fda5bfeabca5785700ab5469a5e9b93aca5e2d3a5fb", 133 | "sha256:68b69f52e6545af010b76516f5daaef6173e73353e3295c5cb9f96c35d755641", 134 | "sha256:7e8229f3687cdadba2c4faef39204feb51ef7c1a9b669247d49a24f3e2e1617c", 135 | "sha256:8002574a6b46ac3b5739a003b5233376aeac5163e5dcd43dd7ad062f3e186129", 136 | "sha256:876f60de09734fbcb4e27a97c9a286b51284df1326b1ac5f1bf0ad3678236b22", 137 | "sha256:9ce242162015b7e88092dccd0e854548c0926b75c7924a3495e02c6067aba1f5", 138 | "sha256:a35c4e64dfca659fe4d0f1421fc0f05b8ed1ca8c46fb73d9e5a7f175f85696bb", 139 | "sha256:aeba539285dcf0a1ba755945865ec61240ede5432df41d6e29fab305f4384db2", 140 | "sha256:b15c3f1ed08df4980e02cc79ee058b788a3d0bef2fb3c9ca90bb8cbd5b8a3a04", 141 | "sha256:c2f91f88230042a130ceb1b496932aa717dcbd665350beb821534c5c7e15881c", 142 | "sha256:d748ef349bfef2e1194b59da37ed5a29c19ea8d7e6342019921ba2ba4fd8b624", 143 | "sha256:e0d7447679ae9a7124385ccf0ea990bb85bb869cef217e2ea6c844b6a6855073" 144 | ], 145 | "markers": "platform_machine != 'aarch64' and platform_machine != 'arm64' and python_version < '3.10'", 146 | "version": "==1.23.1" 147 | }, 148 | "oauthlib": { 149 | "hashes": [ 150 | "sha256:23a8208d75b902797ea29fd31fa80a15ed9dc2c6c16fe73f5d346f83f6fa27a2", 151 | "sha256:6db33440354787f9b7f3a6dbd4febf5d0f93758354060e802f6c06cb493022fe" 152 | ], 153 | "version": "==3.2.0" 154 | }, 155 | "pandas": { 156 | "hashes": [ 157 | "sha256:07238a58d7cbc8a004855ade7b75bbd22c0db4b0ffccc721556bab8a095515f6", 158 | "sha256:0daf876dba6c622154b2e6741f29e87161f844e64f84801554f879d27ba63c0d", 159 | "sha256:16ad23db55efcc93fa878f7837267973b61ea85d244fc5ff0ccbcfa5638706c5", 160 | "sha256:1d9382f72a4f0e93909feece6fef5500e838ce1c355a581b3d8f259839f2ea76", 161 | "sha256:24ea75f47bbd5574675dae21d51779a4948715416413b30614c1e8b480909f81", 162 | "sha256:2893e923472a5e090c2d5e8db83e8f907364ec048572084c7d10ef93546be6d1", 163 | "sha256:2ff7788468e75917574f080cd4681b27e1a7bf36461fe968b49a87b5a54d007c", 164 | "sha256:41fc406e374590a3d492325b889a2686b31e7a7780bec83db2512988550dadbf", 165 | "sha256:48350592665ea3cbcd07efc8c12ff12d89be09cd47231c7925e3b8afada9d50d", 166 | "sha256:605d572126eb4ab2eadf5c59d5d69f0608df2bf7bcad5c5880a47a20a0699e3e", 167 | "sha256:6dfbf16b1ea4f4d0ee11084d9c026340514d1d30270eaa82a9f1297b6c8ecbf0", 168 | "sha256:6f803320c9da732cc79210d7e8cc5c8019aad512589c910c66529eb1b1818230", 169 | "sha256:721a3dd2f06ef942f83a819c0f3f6a648b2830b191a72bbe9451bcd49c3bd42e", 170 | "sha256:755679c49460bd0d2f837ab99f0a26948e68fa0718b7e42afbabd074d945bf84", 171 | "sha256:78b00429161ccb0da252229bcda8010b445c4bf924e721265bec5a6e96a92e92", 172 | "sha256:958a0588149190c22cdebbc0797e01972950c927a11a900fe6c2296f207b1d6f", 173 | "sha256:a3924692160e3d847e18702bb048dc38e0e13411d2b503fecb1adf0fcf950ba4", 174 | "sha256:d51674ed8e2551ef7773820ef5dab9322be0828629f2cbf8d1fc31a0c4fed640", 175 | "sha256:d5ebc990bd34f4ac3c73a2724c2dcc9ee7bf1ce6cf08e87bb25c6ad33507e318", 176 | "sha256:d6c0106415ff1a10c326c49bc5dd9ea8b9897a6ca0c8688eb9c30ddec49535ef", 177 | "sha256:e48fbb64165cda451c06a0f9e4c7a16b534fcabd32546d531b3c240ce2844112" 178 | ], 179 | "index": "pypi", 180 | "version": "==1.4.3" 181 | }, 182 | "python-dateutil": { 183 | "hashes": [ 184 | "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", 185 | "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" 186 | ], 187 | "version": "==2.8.2" 188 | }, 189 | "pytz": { 190 | "hashes": [ 191 | "sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7", 192 | "sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c" 193 | ], 194 | "version": "==2022.1" 195 | }, 196 | "requests": { 197 | "hashes": [ 198 | "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983", 199 | "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349" 200 | ], 201 | "version": "==2.28.1" 202 | }, 203 | "requests-oauthlib": { 204 | "hashes": [ 205 | "sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5", 206 | "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a" 207 | ], 208 | "index": "pypi", 209 | "version": "==1.3.1" 210 | }, 211 | "six": { 212 | "hashes": [ 213 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 214 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 215 | ], 216 | "version": "==1.16.0" 217 | }, 218 | "urllib3": { 219 | "hashes": [ 220 | "sha256:8298d6d56d39be0e3bc13c1c97d133f9b45d797169a0e11cdd0e0489d786f7ec", 221 | "sha256:879ba4d1e89654d9769ce13121e0f94310ea32e8d2f8cf587b77c08bbcdb30d6" 222 | ], 223 | "version": "==1.26.10" 224 | }, 225 | "werkzeug": { 226 | "hashes": [ 227 | "sha256:60ab4823078f08fdb36b5b83b35f1c422eab8c92929eba5487e1bd52d2316fd4", 228 | "sha256:6a3fe061435495aed49c1ea54dbdf1529b6333bb7ddbe20089e4360250b040ec" 229 | ], 230 | "version": "==2.2.0a1" 231 | }, 232 | "zipp": { 233 | "hashes": [ 234 | "sha256:05b45f1ee8f807d0cc928485ca40a07cb491cf092ff587c0df9cb1fd154848d2", 235 | "sha256:47c40d7fe183a6f21403a199b3e4192cca5774656965b0a4988ad2f8feb5f009" 236 | ], 237 | "version": "==3.8.1" 238 | } 239 | }, 240 | "develop": { 241 | "attrs": { 242 | "hashes": [ 243 | "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4", 244 | "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd" 245 | ], 246 | "version": "==21.4.0" 247 | }, 248 | "bleach": { 249 | "hashes": [ 250 | "sha256:085f7f33c15bd408dd9b17a4ad77c577db66d76203e5984b1bd59baeee948b2a", 251 | "sha256:0d03255c47eb9bd2f26aa9bb7f2107732e7e8fe195ca2f64709fcf3b0a4a085c" 252 | ], 253 | "version": "==5.0.1" 254 | }, 255 | "certifi": { 256 | "hashes": [ 257 | "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d", 258 | "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412" 259 | ], 260 | "version": "==2022.6.15" 261 | }, 262 | "cffi": { 263 | "hashes": [ 264 | "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5", 265 | "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef", 266 | "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104", 267 | "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426", 268 | "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405", 269 | "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375", 270 | "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a", 271 | "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e", 272 | "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc", 273 | "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf", 274 | "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185", 275 | "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497", 276 | "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3", 277 | "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35", 278 | "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c", 279 | "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83", 280 | "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21", 281 | "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca", 282 | "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984", 283 | "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac", 284 | "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd", 285 | "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee", 286 | "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a", 287 | "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2", 288 | "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192", 289 | "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7", 290 | "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585", 291 | "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f", 292 | "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e", 293 | "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27", 294 | "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b", 295 | "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e", 296 | "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e", 297 | "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d", 298 | "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c", 299 | "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415", 300 | "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82", 301 | "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02", 302 | "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314", 303 | "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325", 304 | "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c", 305 | "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3", 306 | "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914", 307 | "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045", 308 | "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d", 309 | "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9", 310 | "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5", 311 | "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2", 312 | "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c", 313 | "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3", 314 | "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2", 315 | "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8", 316 | "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d", 317 | "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d", 318 | "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9", 319 | "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162", 320 | "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76", 321 | "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4", 322 | "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e", 323 | "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9", 324 | "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6", 325 | "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b", 326 | "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01", 327 | "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0" 328 | ], 329 | "version": "==1.15.1" 330 | }, 331 | "charset-normalizer": { 332 | "hashes": [ 333 | "sha256:5189b6f22b01957427f35b6a08d9a0bc45b46d3788ef5a92e978433c7a35f8a5", 334 | "sha256:575e708016ff3a5e3681541cb9d79312c416835686d054a23accb873b254f413" 335 | ], 336 | "version": "==2.1.0" 337 | }, 338 | "commonmark": { 339 | "hashes": [ 340 | "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60", 341 | "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9" 342 | ], 343 | "version": "==0.9.1" 344 | }, 345 | "cryptography": { 346 | "hashes": [ 347 | "sha256:190f82f3e87033821828f60787cfa42bff98404483577b591429ed99bed39d59", 348 | "sha256:2be53f9f5505673eeda5f2736bea736c40f051a739bfae2f92d18aed1eb54596", 349 | "sha256:30788e070800fec9bbcf9faa71ea6d8068f5136f60029759fd8c3efec3c9dcb3", 350 | "sha256:3d41b965b3380f10e4611dbae366f6dc3cefc7c9ac4e8842a806b9672ae9add5", 351 | "sha256:4c590ec31550a724ef893c50f9a97a0c14e9c851c85621c5650d699a7b88f7ab", 352 | "sha256:549153378611c0cca1042f20fd9c5030d37a72f634c9326e225c9f666d472884", 353 | "sha256:63f9c17c0e2474ccbebc9302ce2f07b55b3b3fcb211ded18a42d5764f5c10a82", 354 | "sha256:6bc95ed67b6741b2607298f9ea4932ff157e570ef456ef7ff0ef4884a134cc4b", 355 | "sha256:7099a8d55cd49b737ffc99c17de504f2257e3787e02abe6d1a6d136574873441", 356 | "sha256:75976c217f10d48a8b5a8de3d70c454c249e4b91851f6838a4e48b8f41eb71aa", 357 | "sha256:7bc997818309f56c0038a33b8da5c0bfbb3f1f067f315f9abd6fc07ad359398d", 358 | "sha256:80f49023dd13ba35f7c34072fa17f604d2f19bf0989f292cedf7ab5770b87a0b", 359 | "sha256:91ce48d35f4e3d3f1d83e29ef4a9267246e6a3be51864a5b7d2247d5086fa99a", 360 | "sha256:a958c52505c8adf0d3822703078580d2c0456dd1d27fabfb6f76fe63d2971cd6", 361 | "sha256:b62439d7cd1222f3da897e9a9fe53bbf5c104fff4d60893ad1355d4c14a24157", 362 | "sha256:b7f8dd0d4c1f21759695c05a5ec8536c12f31611541f8904083f3dc582604280", 363 | "sha256:d204833f3c8a33bbe11eda63a54b1aad7aa7456ed769a982f21ec599ba5fa282", 364 | "sha256:e007f052ed10cc316df59bc90fbb7ff7950d7e2919c9757fd42a2b8ecf8a5f67", 365 | "sha256:f2dcb0b3b63afb6df7fd94ec6fbddac81b5492513f7b0436210d390c14d46ee8", 366 | "sha256:f721d1885ecae9078c3f6bbe8a88bc0786b6e749bf32ccec1ef2b18929a05046", 367 | "sha256:f7a6de3e98771e183645181b3627e2563dcde3ce94a9e42a3f427d2255190327", 368 | "sha256:f8c0a6e9e1dd3eb0414ba320f85da6b0dcbd543126e30fcc546e7372a7fbf3b9" 369 | ], 370 | "version": "==37.0.4" 371 | }, 372 | "docutils": { 373 | "hashes": [ 374 | "sha256:33995a6753c30b7f577febfc2c50411fec6aac7f7ffeb7c4cfe5991072dcf9e6", 375 | "sha256:5e1de4d849fee02c63b040a4a3fd567f4ab104defd8a5511fbbc24a8a017efbc" 376 | ], 377 | "version": "==0.19" 378 | }, 379 | "idna": { 380 | "hashes": [ 381 | "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", 382 | "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" 383 | ], 384 | "version": "==3.3" 385 | }, 386 | "importlib-metadata": { 387 | "hashes": [ 388 | "sha256:637245b8bab2b6502fcbc752cc4b7a6f6243bb02b31c5c26156ad103d3d45670", 389 | "sha256:7401a975809ea1fdc658c3aa4f78cc2195a0e019c5cbc4c06122884e9ae80c23" 390 | ], 391 | "markers": "python_version < '3.10'", 392 | "version": "==4.12.0" 393 | }, 394 | "iniconfig": { 395 | "hashes": [ 396 | "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", 397 | "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32" 398 | ], 399 | "version": "==1.1.1" 400 | }, 401 | "jeepney": { 402 | "hashes": [ 403 | "sha256:5efe48d255973902f6badc3ce55e2aa6c5c3b3bc642059ef3a91247bcfcc5806", 404 | "sha256:c0a454ad016ca575060802ee4d590dd912e35c122fa04e70306de3d076cce755" 405 | ], 406 | "version": "==0.8.0" 407 | }, 408 | "keyring": { 409 | "hashes": [ 410 | "sha256:782e1cd1132e91bf459fcd243bcf25b326015c1ac0b198e4408f91fa6791062b", 411 | "sha256:e67fc91a7955785fd2efcbccdd72d7dacf136dbc381d27de305b2b660b3de886" 412 | ], 413 | "version": "==23.7.0" 414 | }, 415 | "packaging": { 416 | "hashes": [ 417 | "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb", 418 | "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522" 419 | ], 420 | "version": "==21.3" 421 | }, 422 | "pkginfo": { 423 | "hashes": [ 424 | "sha256:848865108ec99d4901b2f7e84058b6e7660aae8ae10164e015a6dcf5b242a594", 425 | "sha256:a84da4318dd86f870a9447a8c98340aa06216bfc6f2b7bdc4b8766984ae1867c" 426 | ], 427 | "version": "==1.8.3" 428 | }, 429 | "pluggy": { 430 | "hashes": [ 431 | "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159", 432 | "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3" 433 | ], 434 | "version": "==1.0.0" 435 | }, 436 | "py": { 437 | "hashes": [ 438 | "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", 439 | "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378" 440 | ], 441 | "version": "==1.11.0" 442 | }, 443 | "pycparser": { 444 | "hashes": [ 445 | "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9", 446 | "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206" 447 | ], 448 | "version": "==2.21" 449 | }, 450 | "pygments": { 451 | "hashes": [ 452 | "sha256:5eb116118f9612ff1ee89ac96437bb6b49e8f04d8a13b514ba26f620208e26eb", 453 | "sha256:dc9c10fb40944260f6ed4c688ece0cd2048414940f1cea51b8b226318411c519" 454 | ], 455 | "version": "==2.12.0" 456 | }, 457 | "pyparsing": { 458 | "hashes": [ 459 | "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb", 460 | "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc" 461 | ], 462 | "version": "==3.0.9" 463 | }, 464 | "pytest": { 465 | "hashes": [ 466 | "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c", 467 | "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45" 468 | ], 469 | "index": "pypi", 470 | "version": "==7.1.2" 471 | }, 472 | "readme-renderer": { 473 | "hashes": [ 474 | "sha256:73b84905d091c31f36e50b4ae05ae2acead661f6a09a9abb4df7d2ddcdb6a698", 475 | "sha256:a727999acfc222fc21d82a12ed48c957c4989785e5865807c65a487d21677497" 476 | ], 477 | "version": "==35.0" 478 | }, 479 | "requests": { 480 | "hashes": [ 481 | "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983", 482 | "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349" 483 | ], 484 | "version": "==2.28.1" 485 | }, 486 | "requests-mock": { 487 | "hashes": [ 488 | "sha256:0a2d38a117c08bb78939ec163522976ad59a6b7fdd82b709e23bb98004a44970", 489 | "sha256:8d72abe54546c1fc9696fa1516672f1031d72a55a1d66c85184f972a24ba0eba" 490 | ], 491 | "index": "pypi", 492 | "version": "==1.9.3" 493 | }, 494 | "requests-toolbelt": { 495 | "hashes": [ 496 | "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f", 497 | "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0" 498 | ], 499 | "version": "==0.9.1" 500 | }, 501 | "rfc3986": { 502 | "hashes": [ 503 | "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", 504 | "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c" 505 | ], 506 | "version": "==2.0.0" 507 | }, 508 | "rich": { 509 | "hashes": [ 510 | "sha256:2eb4e6894cde1e017976d2975ac210ef515d7548bc595ba20e195fb9628acdeb", 511 | "sha256:63a5c5ce3673d3d5fbbf23cd87e11ab84b6b451436f1b7f19ec54b6bc36ed7ca" 512 | ], 513 | "version": "==12.5.1" 514 | }, 515 | "secretstorage": { 516 | "hashes": [ 517 | "sha256:0a8eb9645b320881c222e827c26f4cfcf55363e8b374a021981ef886657a912f", 518 | "sha256:755dc845b6ad76dcbcbc07ea3da75ae54bb1ea529eb72d15f83d26499a5df319" 519 | ], 520 | "markers": "sys_platform == 'linux'", 521 | "version": "==3.3.2" 522 | }, 523 | "six": { 524 | "hashes": [ 525 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 526 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 527 | ], 528 | "version": "==1.16.0" 529 | }, 530 | "tabulate": { 531 | "hashes": [ 532 | "sha256:0ba055423dbaa164b9e456abe7920c5e8ed33fcc16f6d1b2f2d152c8e1e8b4fc", 533 | "sha256:436f1c768b424654fce8597290d2764def1eea6a77cfa5c33be00b1bc0f4f63d", 534 | "sha256:6c57f3f3dd7ac2782770155f3adb2db0b1a269637e42f27599925e64b114f519" 535 | ], 536 | "index": "pypi", 537 | "version": "==0.8.10" 538 | }, 539 | "tomli": { 540 | "hashes": [ 541 | "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", 542 | "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" 543 | ], 544 | "version": "==2.0.1" 545 | }, 546 | "twine": { 547 | "hashes": [ 548 | "sha256:42026c18e394eac3e06693ee52010baa5313e4811d5a11050e7d48436cf41b9e", 549 | "sha256:96b1cf12f7ae611a4a40b6ae8e9570215daff0611828f5fe1f37a16255ab24a0" 550 | ], 551 | "index": "pypi", 552 | "version": "==4.0.1" 553 | }, 554 | "typing-extensions": { 555 | "hashes": [ 556 | "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02", 557 | "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6" 558 | ], 559 | "markers": "python_version < '3.9'", 560 | "version": "==4.3.0" 561 | }, 562 | "urllib3": { 563 | "hashes": [ 564 | "sha256:8298d6d56d39be0e3bc13c1c97d133f9b45d797169a0e11cdd0e0489d786f7ec", 565 | "sha256:879ba4d1e89654d9769ce13121e0f94310ea32e8d2f8cf587b77c08bbcdb30d6" 566 | ], 567 | "version": "==1.26.10" 568 | }, 569 | "webencodings": { 570 | "hashes": [ 571 | "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", 572 | "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" 573 | ], 574 | "version": "==0.5.1" 575 | }, 576 | "xlsxwriter": { 577 | "hashes": [ 578 | "sha256:df0aefe5137478d206847eccf9f114715e42aaea077e6a48d0e8a2152e983010", 579 | "sha256:e89f4a1d2fa2c9ea15cde77de95cd3fd8b0345d0efb3964623f395c8c4988b7f" 580 | ], 581 | "index": "pypi", 582 | "version": "==3.0.3" 583 | }, 584 | "zipp": { 585 | "hashes": [ 586 | "sha256:05b45f1ee8f807d0cc928485ca40a07cb491cf092ff587c0df9cb1fd154848d2", 587 | "sha256:47c40d7fe183a6f21403a199b3e4192cca5774656965b0a4988ad2f8feb5f009" 588 | ], 589 | "version": "==3.8.1" 590 | } 591 | } 592 | } 593 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ## Installation 3 | 4 | Easiest way is to get it from [PyPI](https://pypi.org/project/oura/): 5 | 6 | `pip install oura` 7 | 8 | ## Project maintenance 9 | If anyone is interested in taking over maintenance of this project, please contact me at 10 | turingcomplet@proton.me or submit an issue. Alternatively, feel free to simply fork this 11 | repo or create a new one and publish the package under a new name. In that case, I will 12 | add a link here. I still plan on doing what I can to keep this up to date, but don't 13 | feel I can commit to maintaining a level of quality and responsiveness that you all 14 | deserve. 15 | 16 | ## Note about the v2 API 17 | All sections in the readme should apply to the v2 API that is being rolled out, except that the 18 | methods will differ, pandas support is less sophisticated, and I haven't tested the v2 19 | clients with the OAuth2 flow. The auth has been updated to pass tokens in the http 20 | header, but the usage of the underlying `requests-oauthlib` library has not been 21 | changed. 22 | 23 | Enjoy the latest clients as follows (and see 24 | [docs](https://cloud.ouraring.com/v2/docs)). All methods except `personal_info` take a 25 | `start_date`, `end_date`, and `next_token`. 26 | ``` 27 | from oura.v2 import OuraClientV2, OuraClientDataFrameV2 28 | v2 = OuraClientDataFrameV2(personal_access_token="MY_PAT") 29 | 30 | # methods will be named after the url path (see docs linked above) 31 | v2.heartrate() 32 | 33 | # pandas methods end with _df 34 | v2.tags_df() 35 | ``` 36 | 37 | ## Getting started 38 | 39 | Both personal access tokens and oauth flows are supported by the API (and by 40 | this library). For personal use, the simplest way to start is by getting 41 | yourself a PAT and supplying it to a client: 42 | 43 | ``` 44 | client = OuraClient(personal_access_token="MY_TOKEN") 45 | ``` 46 | 47 | If you are using oauth, there are a few more steps. First, register an application 48 | Then you can use this sample script to authorize access to your own data or some test account data. It will follow the auth code flow and print out the token response. Make sure to add localhost:3030 to the redirect uris for your app (the port can be changed in the script). 49 | ``` 50 | ./token-request.py 51 | ``` 52 | 53 | Some sample code is located in the [samples](samples) directory, maybe it will be useful for you. Maybe it will change your life for the better. Maybe it will cause you to rethink using this project at all. Let me know the outcome if you feel like it. 54 | 55 | 56 | ## Business time 57 | 58 | If you are writing a real application, use the following pattern. Basically, the work is done by the underlying oauthlib to use the refresh token whenever the access token has expired, and you supply the refresh callback to save the new tokens for next time. This seems to have worked fine for me, but I don't actually use this library that much 59 | ``` 60 | from oura import OuraClient, OuraOAuth2Client 61 | 62 | auth_client = OuraOAuth2Client(client_id='my_application', client_secret='random-string') 63 | url = auth_client.authorize_endpoint(scope='defaults to all scopes', 'https://localhost/myendpoint') 64 | # user clicks url, auth happens, then redirect to given url 65 | ``` 66 | 67 | Now we handle the redirect by exchanging an auth code for a token 68 | 69 | ``` 70 | # save this somewhere, see below 71 | token_dict = auth_client.fetch_access_token(code='auth_code_from_query_string') 72 | ``` 73 | 74 | Now that's out of the way, you can call the api: 75 | ``` 76 | # supply all the params for auto refresh 77 | oura = OuraClient(, , , ) 78 | 79 | # or just these for make calls until token expires 80 | oura = OuraClient(, ) 81 | 82 | # make authenticated API calls 83 | oura.user_info() 84 | oura.sleep_summary(start='2018-12-05', end='2018-12-10') 85 | oura.activity_summary(start='2018-12-25') 86 | ``` 87 | 88 | 89 | The `refresh_callback` is a fuction that takes a token dict and saves it somewhere. It will look like: 90 | ``` 91 | {'token_type': 'bearer', 'refresh_token': , 'access_token': , 'expires_in': 86400, 'expires_at': 1546485086.3277025} 92 | ``` 93 | 94 | ## Working with pandas 95 | You can also make requests and have the data converted to pandas dataframes by 96 | using the pandas client. Some customization is available but subject to 97 | future improvement. 98 | 99 | ``` 100 | client = OuraClientDataFrame(...) 101 | bedtime = client.bedtime_df(start, end, convert=True) 102 | 103 | In [3]: client.bedtime_df() 104 | Out[3]: 105 | bedtime_window status 106 | date 107 | 2020-03-17 {'start': -3600, 'end': 0} IDEAL_BEDTIME_AVAILABLE 108 | 2020-03-18 {'start': None, 'end': None} LOW_SLEEP_SCORES 109 | ``` 110 | 111 | 112 | Live your life. 113 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = . 8 | BUILDDIR = _build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 20 | 21 | clean: 22 | rm -rf _build 23 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | .. _api: 2 | 3 | API 4 | *** 5 | 6 | Purpose 7 | ================ 8 | 9 | Reference for api surface. 10 | 11 | Module Index 12 | ================ 13 | 14 | .. automodule:: oura.client 15 | :synopsis: Probably the best way to call the Oura API using python. 16 | :members: 17 | 18 | .. automodule:: oura.client_pandas 19 | :synopsis: Extends the client by providing pandas functionality. 20 | :members: 21 | 22 | .. automodule:: oura.writers 23 | :synopsis: Various ways to export data (excel, console, etc). 24 | :members: 25 | 26 | -------------------------------------------------------------------------------- /docs/auth.rst: -------------------------------------------------------------------------------- 1 | .. _auth: 2 | 3 | Authentication and Authorization 4 | ******************************** 5 | 6 | There are two choices for auth: 7 | 8 | * oauth2 for making requests on behalf of other users 9 | * personal access tokens, which are unsurprisingly for personal use 10 | 11 | 12 | See the `official documentation `_ 13 | 14 | Requesting Authorization 15 | ======================== 16 | 17 | The :class:`oura.OuraOAuth2Client` class has an :meth:`authorize_endpoint` method 18 | which returns a url that a user can click to grant access. 19 | 20 | This can be called as follows:: 21 | 22 | from oura import OuraOAuth2Client 23 | auth_client = OuraOAuth2Client(client_id=MY_CLIENT_ID, client_secret=SUPER_SECRET_VALUE) 24 | url = auth_client.authorize_endpoint(scope = ["email", "personal", "daily"], redirect_uri='http://my.domain.com/callback') 25 | 26 | 27 | In following the standard flow, you would have some code under your `/callback` endpoint that does this :: 28 | 29 | code = request.args.get('code') # e.g. 30 | token_response = auth_client.fetch_access_token(code=code) 31 | 32 | 33 | Now you are ready to make authenticated API requests. Please use this power responsibly. 34 | 35 | Personal Access Token 36 | ===================== 37 | 38 | You can also access your own data using a personal_access_token - get one from 39 | the cloud portal and save the value somewhere, like an environment variable. Or 40 | somewhere else, it's your token anyway. Then just pass it to a new 41 | :class:`oura.OuraClient` instance and you'll be ready to go. See what I mean :: 42 | 43 | import os 44 | from oura import OuraClient 45 | my_token = os.getenv('MY_TOKEN') 46 | client = OuraClient(personal_access_token=my_token) 47 | who_am_i = client.user_info() 48 | 49 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import os 16 | import sys 17 | sys.path.insert(0, os.path.abspath('..')) 18 | 19 | 20 | # -- Project information ----------------------------------------------------- 21 | 22 | project = 'python-ouraring' 23 | copyright = '2022, turing-compet' 24 | author = 'turing-complet' 25 | 26 | # The short X.Y version 27 | version = '' 28 | # The full version, including alpha/beta/rc tags 29 | release = '' 30 | 31 | 32 | # -- General configuration --------------------------------------------------- 33 | 34 | # If your documentation needs a minimal Sphinx version, state it here. 35 | # 36 | # needs_sphinx = '1.0' 37 | 38 | # Add any Sphinx extension module names here, as strings. They can be 39 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 40 | # ones. 41 | extensions = [ 42 | 'sphinx.ext.autodoc', 43 | ] 44 | 45 | # Add any paths that contain templates here, relative to this directory. 46 | templates_path = ['_templates'] 47 | 48 | # The suffix(es) of source filenames. 49 | # You can specify multiple suffix as a list of string: 50 | # 51 | # source_suffix = ['.rst', '.md'] 52 | source_suffix = '.rst' 53 | 54 | # The master toctree document. 55 | master_doc = 'index' 56 | 57 | # The language for content autogenerated by Sphinx. Refer to documentation 58 | # for a list of supported languages. 59 | # 60 | # This is also used if you do content translation via gettext catalogs. 61 | # Usually you set "language" from the command line for these cases. 62 | language = None 63 | 64 | # List of patterns, relative to source directory, that match files and 65 | # directories to ignore when looking for source files. 66 | # This pattern also affects html_static_path and html_extra_path. 67 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 68 | 69 | # The name of the Pygments (syntax highlighting) style to use. 70 | pygments_style = None 71 | 72 | autodoc_default_options = { 73 | 'member-order': 'bysource', 74 | 'special-members': '__init__', 75 | 'undoc-members': None, 76 | 'exclude-members': '__weakref__' 77 | } 78 | 79 | # -- Options for HTML output ------------------------------------------------- 80 | 81 | # The theme to use for HTML and HTML Help pages. See the documentation for 82 | # a list of builtin themes. 83 | # 84 | html_theme = 'sphinx_rtd_theme' 85 | 86 | # Theme options are theme-specific and customize the look and feel of a theme 87 | # further. For a list of options available for each theme, see the 88 | # documentation. 89 | # 90 | # html_theme_options = {} 91 | 92 | # Add any paths that contain custom static files (such as style sheets) here, 93 | # relative to this directory. They are copied after the builtin static files, 94 | # so a file named "default.css" will overwrite the builtin "default.css". 95 | html_static_path = ['_static'] 96 | 97 | # Custom sidebar templates, must be a dictionary that maps document names 98 | # to template names. 99 | # 100 | # The default sidebars (for documents that don't match any pattern) are 101 | # defined by theme itself. Builtin themes are using these templates by 102 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 103 | # 'searchbox.html']``. 104 | # 105 | # html_sidebars = {} 106 | 107 | 108 | # -- Options for HTMLHelp output --------------------------------------------- 109 | 110 | # Output file base name for HTML help builder. 111 | htmlhelp_basename = 'python-ouradoc' 112 | 113 | 114 | # -- Options for LaTeX output ------------------------------------------------ 115 | 116 | latex_elements = { 117 | # The paper size ('letterpaper' or 'a4paper'). 118 | # 119 | # 'papersize': 'letterpaper', 120 | 121 | # The font size ('10pt', '11pt' or '12pt'). 122 | # 123 | # 'pointsize': '10pt', 124 | 125 | # Additional stuff for the LaTeX preamble. 126 | # 127 | # 'preamble': '', 128 | 129 | # Latex figure (float) alignment 130 | # 131 | # 'figure_align': 'htbp', 132 | } 133 | 134 | # Grouping the document tree into LaTeX files. List of tuples 135 | # (source start file, target name, title, 136 | # author, documentclass [howto, manual, or own class]). 137 | latex_documents = [ 138 | (master_doc, 'python-oura.tex', 'python-oura Documentation', 139 | 'turing-compet', 'manual'), 140 | ] 141 | 142 | 143 | # -- Options for manual page output ------------------------------------------ 144 | 145 | # One entry per manual page. List of tuples 146 | # (source start file, name, description, authors, manual section). 147 | man_pages = [ 148 | (master_doc, 'python-oura', 'python-oura Documentation', 149 | [author], 1) 150 | ] 151 | 152 | 153 | # -- Options for Texinfo output ---------------------------------------------- 154 | 155 | # Grouping the document tree into Texinfo files. List of tuples 156 | # (source start file, target name, title, author, 157 | # dir menu entry, description, category) 158 | texinfo_documents = [ 159 | (master_doc, 'python-oura', 'python-oura Documentation', 160 | author, 'python-oura', 'One line description of project.', 161 | 'Miscellaneous'), 162 | ] 163 | 164 | 165 | # -- Options for Epub output ------------------------------------------------- 166 | 167 | # Bibliographic Dublin Core info. 168 | epub_title = project 169 | 170 | # The unique identifier of the text. This can be a ISBN number 171 | # or the project homepage. 172 | # 173 | # epub_identifier = '' 174 | 175 | # A unique identification for the text. 176 | # 177 | # epub_uid = '' 178 | 179 | # A list of files that should not be packed into the epub file. 180 | epub_exclude_files = ['search.html'] 181 | 182 | 183 | # -- Extension configuration ------------------------------------------------- 184 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. python-oura documentation master file, created by 2 | sphinx-quickstart on Wed Jan 2 23:21:15 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 python-oura's documentation! 7 | ======================================= 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | Usage 14 | ----- 15 | 16 | More detailed documentation to get you started 17 | 18 | .. toctree:: 19 | :maxdepth: 2 20 | 21 | auth 22 | summaries 23 | 24 | 25 | API Reference 26 | ------------- 27 | 28 | In-depth reference guide for developing software with Oura API. 29 | 30 | .. toctree:: 31 | :maxdepth: 2 32 | 33 | api 34 | -------------------------------------------------------------------------------- /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 | 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% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | sphinx-rtd-theme 3 | pandas 4 | pytest 5 | requests-mock 6 | requests-oauthlib 7 | -------------------------------------------------------------------------------- /docs/summaries.rst: -------------------------------------------------------------------------------- 1 | .. _summaries: 2 | 3 | Daily summaries 4 | ******************************** 5 | 6 | Oura's API is based on the idea of daily summaries. For each kind of data (sleep, activity, readiness, bedtime) 7 | there is an endpoint which will return summaries for one or more day. They each 8 | take an optional start date and end date (YYYY-MM-DD). 9 | 10 | See the `official documentation `_ for behavior regarding the dates. 11 | 12 | Usage 13 | ======================== 14 | 15 | If you just want to make some requests, it's fairly easy. Just do this :: 16 | 17 | from oura import OuraClient 18 | oura = OuraClient(client_id=MY_CLIENT_ID, access_token=ACCESS_TOKEN) 19 | 20 | And you're set to call summary methods. 21 | 22 | 23 | If you want a more automated approach, your application should implement a way to store and retrieve the token information for each user, 24 | and create each instance of :class:`oura.OuraClient` with that information (as well the client_id to identify your app). 25 | 26 | For example:: 27 | 28 | from oura import OuraClient 29 | token = get_token_from_database(some_user_id) # you implement this 30 | access_token = token['access_token'] 31 | refresh_token = token['refresh_token'] 32 | 33 | # you implement save_token_to_db() function 34 | client = OuraClient(client_id=MY_CLIENT_ID, client_secret=MY_CLIENT_SECRET, access_token, refresh_token, refresh_callback=save_token_to_db) 35 | 36 | 37 | Now you are ready to get all the data, provided the user has granted you the required scopes. :: 38 | 39 | from datetime import date 40 | today = str(date.today()) # 2019-01-06, e,g, YYYY-MM-DD, or use whatever start/end date you want 41 | sleep_summary = client.sleep_summary(start=today) 42 | 43 | 44 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | import nox 2 | 3 | nox.options.sessions = "lint", "tests" 4 | locations = ["oura", "tests", "samples", "noxfile.py"] 5 | 6 | 7 | @nox.session 8 | def tests(session): 9 | args = session.posargs 10 | session.install("pipenv") 11 | session.run("pipenv", "sync", "--dev") 12 | session.run("pipenv", "run", "pytest", *args) 13 | 14 | 15 | @nox.session 16 | def lint(session): 17 | args = session.posargs or locations 18 | session.install("flake8", "black", "isort") 19 | session.run("flake8", *args) 20 | session.run("black", "--check", "--diff", *args) 21 | session.run("isort", "--profile", "black", "--check", "--diff", *args) 22 | 23 | 24 | @nox.session 25 | def format(session): 26 | black(session) 27 | isort(session) 28 | 29 | 30 | def black(session): 31 | args = session.posargs or locations 32 | session.install("black") 33 | session.run("black", *args) 34 | 35 | 36 | def isort(session): 37 | args = session.posargs or locations 38 | session.install("isort") 39 | session.run("isort", "--profile", "black", *args) 40 | 41 | 42 | @nox.session 43 | def docs(session): 44 | session.chdir("docs") 45 | session.install("-r", "requirements.txt") 46 | # session.run("sphinx-apidoc", "-f", "-o", "source", "../oura") 47 | # session.run("make", "clean", external=True) 48 | session.run("make", "html", external=True) 49 | -------------------------------------------------------------------------------- /oura/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | 5 | Oura API Library 6 | 7 | ------------------ 8 | 9 | Welcome to the python oura library! 10 | 11 | For more information, please check the github: 12 | https://github.com/turing-complet/python-ouraring 13 | 14 | 15 | """ 16 | from .auth import OAuthRequestHandler, OuraOAuth2Client, PersonalRequestHandler 17 | from .client import OuraClient 18 | from .client_pandas import OuraClientDataFrame 19 | from .export import writers 20 | -------------------------------------------------------------------------------- /oura/auth.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from requests_oauthlib import OAuth2Session 3 | 4 | 5 | class OuraOAuth2Client: 6 | """ 7 | Use this for authorizing user and obtaining initial access and refresh token. 8 | Should be one time usage per user. 9 | """ 10 | 11 | AUTHORIZE_BASE_URL = "https://cloud.ouraring.com/oauth/authorize" 12 | TOKEN_BASE_URL = "https://api.ouraring.com/oauth/token" 13 | SCOPE = ["email", "personal", "daily", "heartrate", "workout", "tag", "session"] 14 | 15 | def __init__(self, client_id, client_secret): 16 | """ 17 | Initialize the client for oauth flow. 18 | 19 | :param client_id: The client id from oura portal. 20 | :type client_id: str 21 | :param client_secret: The client secret from oura portal. 22 | :type client_secret: str 23 | """ 24 | self.client_id = client_id 25 | self.client_secret = client_secret 26 | 27 | self.session = OAuth2Session( 28 | client_id, 29 | auto_refresh_url=self.TOKEN_BASE_URL, 30 | ) 31 | 32 | def authorize_endpoint(self, scope=None, redirect_uri=None, **kwargs): 33 | """ 34 | Build the authorization url for a user to click. 35 | 36 | :param scope: Scopes to request from the user. Defaults to self.SCOPE 37 | :type scope: str 38 | :param redirect_uri: Where to redirect after user grants access. 39 | :type redirect_uri: str 40 | """ 41 | self.session.scope = scope or self.SCOPE 42 | if redirect_uri: 43 | self.session.redirect_uri = redirect_uri 44 | return self.session.authorization_url(self.AUTHORIZE_BASE_URL, **kwargs) 45 | 46 | def fetch_access_token(self, code): 47 | """ 48 | Exchange the auth code for an access and refresh token. 49 | 50 | :param code: Authorization code from query string 51 | :type code: str 52 | """ 53 | return self.session.fetch_token( 54 | self.TOKEN_BASE_URL, code=code, client_secret=self.client_secret 55 | ) 56 | 57 | 58 | class OAuthRequestHandler: 59 | TOKEN_BASE_URL = "https://api.ouraring.com/oauth/token" 60 | TOKEN_REVOKE_URL = "https://api.ouraring.com/oauth/revoke" 61 | 62 | def __init__( 63 | self, 64 | client_id, 65 | client_secret=None, 66 | access_token=None, 67 | refresh_token=None, 68 | refresh_callback=None, 69 | ): 70 | 71 | self.client_id = client_id 72 | self.client_secret = client_secret 73 | 74 | token = {} 75 | if access_token: 76 | token["access_token"] = access_token 77 | if refresh_token: 78 | token["refresh_token"] = refresh_token 79 | 80 | self._session = OAuth2Session( 81 | client_id, 82 | token=token, 83 | auto_refresh_url=self.TOKEN_BASE_URL, 84 | token_updater=refresh_callback, 85 | ) 86 | 87 | def make_request(self, url, method="GET"): 88 | response = self._session.request(method, url) 89 | if response.status_code == 401: 90 | self._refresh_token() 91 | response = self._session.request(method, url) 92 | return response 93 | 94 | def make_request_v2(self, url, method="GET"): 95 | return self.make_request(url, method) 96 | 97 | def _refresh_token(self): 98 | token = self._session.refresh_token( 99 | self.TOKEN_BASE_URL, 100 | client_id=self.client_id, 101 | client_secret=self.client_secret, 102 | ) 103 | if self._session.token_updater: 104 | self._session.token_updater(token) 105 | 106 | return token 107 | 108 | def revoke_token(self): 109 | return self._session.request("POST", self.TOKEN_REVOKE_URL) 110 | 111 | 112 | class PersonalRequestHandler: 113 | TOKEN_REVOKE_URL = "https://api.ouraring.com/oauth/revoke" 114 | 115 | def __init__(self, personal_access_token): 116 | self.personal_access_token = personal_access_token 117 | 118 | def make_request(self, url, method="GET"): 119 | requests_method = requests.post if method == "POST" else requests.get 120 | return requests_method(url, params={"access_token": self.personal_access_token}) 121 | 122 | def make_request_v2(self, url, method="GET"): 123 | requests_method = requests.post if method == "POST" else requests.get 124 | headers = {"Authorization": f"Bearer {self.personal_access_token}"} 125 | return requests_method(url, headers=headers) 126 | 127 | def revoke_token(self): 128 | return self.make_request_v2(self.TOKEN_REVOKE_URL, "POST") 129 | -------------------------------------------------------------------------------- /oura/client.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from . import OAuthRequestHandler, PersonalRequestHandler, exceptions 4 | 5 | 6 | class OuraClient: 7 | """Make requests to Oura's API. Provide either oauth client and token 8 | information to make requests on behalf of users, or a personal access token 9 | to access your own data. 10 | """ 11 | 12 | API_ENDPOINT = "https://api.ouraring.com" 13 | 14 | def __init__( 15 | self, 16 | client_id=None, 17 | client_secret=None, 18 | access_token=None, 19 | refresh_token=None, 20 | refresh_callback=None, 21 | personal_access_token=None, 22 | ): 23 | """ 24 | :param client_id: The client id - identifies your application. 25 | :type client_id: str 26 | 27 | :param client_secret: The client secret. Required for auto refresh. 28 | :type client_secret: str 29 | 30 | :param access_token: Access token. 31 | :type access_token: str 32 | 33 | :param refresh_token: Use this to renew tokens when they expire 34 | :type refresh_token: str 35 | 36 | :param refresh_callback: Callback to handle token response 37 | :type refresh_callback: callable 38 | 39 | :param personal_access_token: Token used for accessing personal data 40 | :type personal_access_token: str 41 | 42 | """ 43 | 44 | if client_id is not None: 45 | self._auth_handler = OAuthRequestHandler( 46 | client_id, client_secret, access_token, refresh_token, refresh_callback 47 | ) 48 | 49 | if personal_access_token is not None: 50 | self._auth_handler = PersonalRequestHandler(personal_access_token) 51 | 52 | def user_info(self): 53 | """ 54 | Returns information about the current user. 55 | 56 | See https://cloud.ouraring.com/docs/personal-info 57 | """ 58 | url = "{}/v1/userinfo".format(self.API_ENDPOINT) 59 | return self._make_request(url) 60 | 61 | def sleep_summary(self, start=None, end=None): 62 | """ 63 | Get sleep summary for the given date range. See 64 | https://cloud.ouraring.com/docs/sleep 65 | 66 | :param start: Beginning of date range, YYYY-MM-DD 67 | :type start: str 68 | 69 | :param end: End of date range, or None if you want the current day. 70 | :type end: str, optional 71 | """ 72 | return self._get_summary(start, end, "sleep") 73 | 74 | def activity_summary(self, start=None, end=None): 75 | """ 76 | Get activity summary for the given date range. 77 | See https://cloud.ouraring.com/docs/activity 78 | 79 | :param start: Beginning of date range, YYYY-MM-DD 80 | :type start: str 81 | 82 | :param end: End of date range, or None if you want the current day. 83 | :type end: str, optional 84 | """ 85 | return self._get_summary(start, end, "activity") 86 | 87 | def readiness_summary(self, start=None, end=None): 88 | """ 89 | Get readiness summary for the given date range. See 90 | https://cloud.ouraring.com/docs/readiness 91 | 92 | :param start: Beginning of date range, YYYY-MM-DD 93 | :type start: str 94 | 95 | :param end: End of date range, or None if you want the current day. 96 | :type end: str, optional 97 | """ 98 | return self._get_summary(start, end, "readiness") 99 | 100 | def bedtime_summary(self, start=None, end=None): 101 | """ 102 | Get bedtime summary for the given date range. See 103 | https://cloud.ouraring.com/docs/bedtime 104 | 105 | :param start: Beginning of date range, YYYY-MM-DD 106 | :type start: str 107 | 108 | :param end: End of date range, or None if you want the current day. 109 | :type end: str, optional 110 | """ 111 | return self._get_summary(start, end, "bedtime") 112 | 113 | def _get_summary(self, start, end, summary_type): 114 | url = self._build_summary_url(start, end, summary_type) 115 | return self._make_request(url) 116 | 117 | def _make_request(self, url): 118 | response = self._auth_handler.make_request(url) 119 | exceptions.detect_and_raise_error(response) 120 | payload = json.loads(response.content.decode("utf8")) 121 | return payload 122 | 123 | def _build_summary_url(self, start, end, summary_type): 124 | url = "{0}/v1/{1}".format(self.API_ENDPOINT, summary_type) 125 | params = {} 126 | if start is not None: 127 | if not isinstance(start, str): 128 | raise TypeError("start date must be of type str") 129 | params["start"] = start 130 | 131 | if end is not None: 132 | if not isinstance(end, str): 133 | raise TypeError("end date must be of type str") 134 | params["end"] = end 135 | 136 | qs = "&".join([f"{k}={v}" for k, v in params.items()]) 137 | url = f"{url}?{qs}" 138 | return url 139 | -------------------------------------------------------------------------------- /oura/client_pandas.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | 3 | from .client import OuraClient 4 | from .converters import ActivityConverter, SleepConverter, UnitConverter 5 | 6 | 7 | def to_pandas(summary, metrics=None, date_key="summary_date"): 8 | """ 9 | Creates a dataframe from a summary object 10 | 11 | :param summary: A summary object returned from API 12 | :type summary: list of dictionaries. See https://cloud.ouraring.com/docs/readiness for an example 13 | 14 | :param metrics: The metrics to include in the DF. None includes all metrics 15 | :type metrics: A list of metric names, or alternatively a string for one metric name 16 | 17 | :param date_key: Column to set as the index 18 | :type date_key: str 19 | """ 20 | 21 | if isinstance(summary, dict): 22 | summary = [summary] 23 | df = pd.DataFrame(summary) 24 | if df.size == 0: 25 | return df 26 | if metrics is not None: 27 | if isinstance(metrics, str): 28 | metrics = [metrics] 29 | else: 30 | metrics = metrics.copy() 31 | # drop any invalid cols the user may have entered 32 | metrics = [m for m in metrics if m in df.columns] 33 | 34 | # always include summary_date (or date_key, as for bedtime) 35 | if date_key not in metrics: 36 | metrics.insert(0, date_key) 37 | 38 | df = df[metrics] 39 | df[date_key] = pd.to_datetime(df[date_key]).dt.date 40 | df = df.set_index(date_key) 41 | return df 42 | 43 | 44 | class OuraClientDataFrame(OuraClient): 45 | """ 46 | Similiar to OuraClient, but data is returned instead 47 | as a pandas.DataFrame object. Each row will correspond to a single day 48 | of data, indexed by the date. 49 | 50 | Methods that have a `convert` paramter will apply 51 | transformations to a set of columns by default. This can be 52 | overridden by passing in a specific set of columns to convert, or disabled 53 | entirely by passing `convert=False` 54 | """ 55 | 56 | def __init__( 57 | self, 58 | client_id=None, 59 | client_secret=None, 60 | access_token=None, 61 | refresh_token=None, 62 | refresh_callback=None, 63 | personal_access_token=None, 64 | ): 65 | super().__init__( 66 | client_id, 67 | client_secret, 68 | access_token, 69 | refresh_token, 70 | refresh_callback, 71 | personal_access_token, 72 | ) 73 | 74 | def user_info_df(self): 75 | user_info = super().user_info() 76 | return pd.DataFrame([user_info]) 77 | 78 | def sleep_df( 79 | self, start=None, end=None, metrics=None, convert=True, convert_cols=None 80 | ): 81 | """ 82 | Create a dataframe from sleep summary dict object. 83 | 84 | :param start: Beginning of date range 85 | :type start: string representation of a date i.e. '2020-10-31' 86 | 87 | :param end: End of date range, or None if you want the current day. 88 | :type end: string representation of a date i.e. '2020-10-31' 89 | 90 | :param metrics: Metrics to include in the df. 91 | :type metrics: A list of strings, or a string 92 | 93 | :param convert: Whether to convert datetime columns to pandas types 94 | :type convert: bool 95 | 96 | :param convert_cols: If convert is True, a set of columns to convert, 97 | or None for the default. Currently supported column types include 98 | datetime, timespan, and hypnogram 99 | :type convert_cols: list 100 | """ 101 | sleep_summary = super().sleep_summary(start, end)["sleep"] 102 | df = to_pandas(sleep_summary, metrics) 103 | if convert: 104 | return SleepConverter(convert_cols).convert_metrics(df) 105 | return df 106 | 107 | def activity_df( 108 | self, start=None, end=None, metrics=None, convert=True, convert_cols=None 109 | ): 110 | """ 111 | Create a dataframe from activity summary dict object. 112 | 113 | :param start: Beginning of date range 114 | :type start: string representation of a date i.e. '2020-10-31' 115 | 116 | :param end: End of date range, or None if you want the current day. 117 | :type end: string representation of a date i.e. '2020-10-31' 118 | 119 | :param metrics: Metrics to include in the df. 120 | :type metrics: A list of strings, or a string 121 | 122 | :param convert: Whether to convert datetime columns to pandas types 123 | :type convert: bool 124 | 125 | :param convert_cols: If convert is True, a set of columns to convert, 126 | or None for the default. Currently supported column types include 127 | datetime. 128 | :type convert_cols: list 129 | """ 130 | activity_summary = super().activity_summary(start, end)["activity"] 131 | df = to_pandas(activity_summary, metrics) 132 | if convert: 133 | return ActivityConverter(convert_cols).convert_metrics(df) 134 | return df 135 | 136 | def readiness_df(self, start=None, end=None, metrics=None): 137 | """ 138 | Create a dataframe from ready summary dict object. 139 | 140 | :param start: Beginning of date range 141 | :type start: string representation of a date i.e. '2020-10-31' 142 | 143 | :param end: End of date range, or None if you want the current day. 144 | :type end: string representation of a date i.e. '2020-10-31' 145 | 146 | :param metrics: Metrics to include in the df. 147 | :type metrics: A list of strings, or a string 148 | """ 149 | readiness_summary = super().readiness_summary(start, end)["readiness"] 150 | return to_pandas(readiness_summary, metrics) 151 | 152 | def bedtime_df(self, start=None, end=None, metrics=None): 153 | """ 154 | Create a dataframe from bedtime summary 155 | 156 | :param start: Beginning of date range 157 | :type start: string representation of a date i.e. '2020-10-31' 158 | 159 | :param end: End of date range, or None if you want the current day. 160 | :type end: string representation of a date i.e. '2020-10-31' 161 | 162 | :param metrics: Metrics to include in the df. 163 | :type metrics: A list of strings, or a string 164 | """ 165 | 166 | bedtime_summary = super().bedtime_summary(start, end)["ideal_bedtimes"] 167 | return to_pandas(bedtime_summary, metrics, date_key="date") 168 | 169 | # TODO: use multi index instead of prefix? 170 | def combined_df_edited(self, start=None, end=None, metrics=None): 171 | """ 172 | Combines sleep, activity, and summary into one DF 173 | Some cols are unit converted for easier use or readability. 174 | 175 | If user specifies a metric that appears in all 3 summaries, 176 | i.e. 'score', then all 3 metrics will be returned. 177 | 178 | Each summary's column is prepended with the summary name. 179 | i.e. sleep summary 'total' metric will be re-named 'SLEEP.total' 180 | 181 | :param start: Beginning of date range 182 | :type start: string representation of a date i.e. '2020-10-31' 183 | 184 | :param end: End of date range, or None if you want the current day. 185 | :type end: string representation of a date i.e. '2020-10-31' 186 | 187 | :param metrics: Metrics to include in the df. 188 | :type metrics: A list of strings, or a string 189 | """ 190 | 191 | def prefix_cols(df, prefix): 192 | d_to_rename = {} 193 | for col in df.columns: 194 | if col != "summary_date": 195 | d_to_rename[col] = prefix + ":" + col 196 | return df.rename(columns=d_to_rename) 197 | 198 | sleep_df = self.sleep_df(start, end, metrics) 199 | sleep_df = prefix_cols(sleep_df, "SLEEP") 200 | readiness_df = self.readiness_df(start, end, metrics) 201 | readiness_df = prefix_cols(readiness_df, "READY") 202 | activity_df = self.activity_df(start, end, metrics) 203 | activity_df = prefix_cols(activity_df, "ACTIVITY") 204 | 205 | combined_df = sleep_df.merge(readiness_df, on="summary_date").merge( 206 | activity_df, on="summary_date" 207 | ) 208 | return combined_df 209 | -------------------------------------------------------------------------------- /oura/converters.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | 3 | 4 | class UnitConverter: 5 | """ 6 | Use this class to convert units for certain dataframe cols 7 | 8 | :param convert_cols: A set of columns to apply predefined conversions 9 | :type convert_cols: list/set 10 | """ 11 | 12 | all_dt_metrics = [] 13 | all_sec_metrics = [] 14 | all_metrics = all_dt_metrics + all_sec_metrics 15 | 16 | def __init__(self, convert_cols=None): 17 | if convert_cols is not None: 18 | convert_cols = set(convert_cols) 19 | defaults = set(self.all_metrics) 20 | invalid = convert_cols - defaults 21 | if any(invalid): 22 | print(f"Ignoring metrics with no conversion: {invalid}") 23 | self.convert_cols = list(convert_cols & defaults) 24 | else: 25 | self.convert_cols = self.all_metrics 26 | 27 | def _rename_converted_cols(self, df, metrics, suffix_str): 28 | """ 29 | Rename converted cols by adding a suffix to the col name 30 | For example, 'bedtime_start' becomes 'bedtime_start_dt_adjusted' 31 | 32 | :param df: a dataframe 33 | :type df: pandas dataframe obj 34 | 35 | :param metrics: metrics to rename 36 | :type metrics: list of strings 37 | 38 | :param suffix_str: the str to append to each metric name 39 | :type suffix_str: str 40 | """ 41 | updated_headers = [header + suffix_str for header in metrics] 42 | d_to_rename = dict(zip(metrics, updated_headers)) 43 | df = df.rename(columns=d_to_rename) 44 | return df 45 | 46 | def _convert_to_dt(self, df, dt_metrics): 47 | """ 48 | Convert dataframe fields to datetime dtypes 49 | 50 | :param df: dataframe 51 | :type df: pandas dataframe obj 52 | 53 | :param dt_metrics: List of metrics to be converted to datetime 54 | :type dt_metrics: List 55 | """ 56 | for _, dt_metric in enumerate(dt_metrics): 57 | df[dt_metric] = pd.to_datetime(df[dt_metric], format="%Y-%m-%d %H:%M:%S") 58 | df = self._rename_converted_cols(df, dt_metrics, "_dt_adjusted") 59 | return df 60 | 61 | def _convert_to_hrs(self, df, sec_metrics): 62 | """ 63 | Convert fields from seconds to minutes 64 | 65 | :param df: dataframe 66 | :type df: pandas dataframe obj 67 | 68 | :param sec_metrics: List of metrics to be converted from sec -> hrs 69 | :type sec_metrics: List 70 | """ 71 | df[sec_metrics] = df[sec_metrics] / 60 / 60 72 | df = self._rename_converted_cols(df, sec_metrics, "_in_hrs") 73 | return df 74 | 75 | def _select_cols(self, df, subset): 76 | return [c for c in df.columns if c in set(subset) & set(self.convert_cols)] 77 | 78 | def convert_metrics(self, df): 79 | """ 80 | Convert metrics to new unit type 81 | 82 | :param df: dataframe 83 | :type df: pandas dataframe obj 84 | """ 85 | dt_metrics = self._select_cols(df, self.all_dt_metrics) 86 | df = self._convert_to_dt(df, dt_metrics) 87 | 88 | sec_metrics = self._select_cols(df, self.all_sec_metrics) 89 | df = self._convert_to_hrs(df, sec_metrics) 90 | return df 91 | 92 | 93 | class SleepConverter(UnitConverter): 94 | all_dt_metrics = ["bedtime_end", "bedtime_start"] 95 | all_sec_metrics = [ 96 | "awake", 97 | "deep", 98 | "duration", 99 | "light", 100 | "onset_latency", 101 | "rem", 102 | "total", 103 | ] 104 | hypnogram_5min = ["hypnogram_5min"] 105 | all_metrics = all_dt_metrics + all_sec_metrics + hypnogram_5min 106 | 107 | def convert_hypnogram_helper(self, hypnogram): 108 | d = {"1": "D", "2": "L", "3": "R", "4": "A"} 109 | return "".join(list(map(lambda h: d[h], hypnogram))) 110 | 111 | def convert_hypnogram(self, sleep_df): 112 | if "hypnogram_5min" in sleep_df.columns: 113 | sleep_df["hypnogram_5min"] = sleep_df["hypnogram_5min"].apply( 114 | self.convert_hypnogram_helper 115 | ) 116 | return sleep_df 117 | 118 | def convert_metrics(self, df): 119 | df = super().convert_metrics(df) 120 | if "hypnogram_5min" in self.convert_cols: 121 | df = self.convert_hypnogram(df) 122 | return df 123 | 124 | 125 | class ActivityConverter(UnitConverter): 126 | all_dt_metrics = ["day_end", "day_start"] 127 | all_metrics = all_dt_metrics 128 | -------------------------------------------------------------------------------- /oura/exceptions.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | 4 | class Timeout(Exception): 5 | """ 6 | Used when a timeout occurs. 7 | """ 8 | 9 | pass 10 | 11 | 12 | class HTTPException(Exception): 13 | def __init__(self, response, *args, **kwargs): 14 | try: 15 | errors = json.loads(response.content.decode("utf8"))["errors"] 16 | message = "\n".join([error["message"] for error in errors]) 17 | except Exception: 18 | if hasattr(response, "status_code") and response.status_code == 401: 19 | message = response.content.decode("utf8") 20 | else: 21 | message = response 22 | super(HTTPException, self).__init__(message, *args, **kwargs) 23 | 24 | 25 | class HTTPBadRequest(HTTPException): 26 | """Generic >= 400 error""" 27 | 28 | pass 29 | 30 | 31 | class HTTPUnauthorized(HTTPException): 32 | """401""" 33 | 34 | pass 35 | 36 | 37 | class HTTPForbidden(HTTPException): 38 | """403""" 39 | 40 | pass 41 | 42 | 43 | class HTTPNotFound(HTTPException): 44 | """404""" 45 | 46 | pass 47 | 48 | 49 | class HTTPConflict(HTTPException): 50 | """409 - returned when creating conflicting resources""" 51 | 52 | pass 53 | 54 | 55 | class HTTPUpgradeRequired(HTTPException): 56 | """426 - returned when the user does not have an updated version of the app""" 57 | 58 | pass 59 | 60 | 61 | class HTTPTooManyRequests(HTTPException): 62 | """429 - returned when exceeding rate limits""" 63 | 64 | pass 65 | 66 | 67 | class HTTPServerError(HTTPException): 68 | """Generic >= 500 error""" 69 | 70 | pass 71 | 72 | 73 | def detect_and_raise_error(response): 74 | if response.status_code == 401: 75 | raise HTTPUnauthorized(response) 76 | elif response.status_code == 403: 77 | raise HTTPForbidden(response) 78 | elif response.status_code == 404: 79 | raise HTTPNotFound(response) 80 | elif response.status_code == 409: 81 | raise HTTPConflict(response) 82 | elif response.status_code == 426: 83 | raise HTTPUpgradeRequired(response) 84 | elif response.status_code == 429: 85 | exc = HTTPTooManyRequests(response) 86 | exc.retry_after_secs = int(response.headers["Retry-After"]) 87 | raise exc 88 | elif response.status_code >= 500: 89 | raise HTTPServerError(response) 90 | elif response.status_code >= 400: 91 | raise HTTPBadRequest(response) 92 | -------------------------------------------------------------------------------- /oura/export/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turing-complet/python-ouraring/35fd39c1b1adf62860b2e14059f6a908e1c85848/oura/export/__init__.py -------------------------------------------------------------------------------- /oura/export/writers.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | 3 | 4 | def save_as_xlsx(df, file, index=True, **to_excel_kwargs): 5 | """ 6 | Save dataframe as .xlsx file with dates properly formatted 7 | 8 | :param df: dataframe to save 9 | :type df: df object 10 | 11 | :param file: File path 12 | :type file: string 13 | 14 | :param index: save df index, in this case summary_date 15 | :type index: Boolean 16 | """ 17 | 18 | def localize(df): 19 | """ 20 | Remove tz from datetime cols since Excel doesn't allow 21 | """ 22 | tz_cols = df.select_dtypes(include=["datetimetz"]).columns 23 | for tz_col in tz_cols: 24 | df[tz_col] = df[tz_col].dt.tz_localize(None) 25 | return df 26 | 27 | import xlsxwriter 28 | 29 | df = df.copy() 30 | df = localize(df) 31 | with pd.ExcelWriter( 32 | file, 33 | engine="xlsxwriter", 34 | date_format="m/d/yyy", 35 | datetime_format="m/d/yyy h:mmAM/PM", 36 | ) as writer: 37 | df.to_excel(writer, index=index, **to_excel_kwargs) 38 | 39 | 40 | def tableize(df, tablefmt="pretty", is_print=True): 41 | """ 42 | Converts dataframe to a formatted table 43 | For more details, see https://pypi.org/project/tabulate/ 44 | 45 | :param df: dataframe to save 46 | :type df: df object 47 | 48 | :param tablefmt: format of table 49 | :type tablefmt: string 50 | 51 | :param is_print: print to standard output? 52 | :type is_print: boolean 53 | """ 54 | from tabulate import tabulate 55 | 56 | table = tabulate( 57 | df, 58 | headers="keys", 59 | tablefmt=tablefmt, 60 | showindex=True, 61 | stralign="center", 62 | numalign="center", 63 | ) 64 | if is_print: 65 | print(table) 66 | return table 67 | -------------------------------------------------------------------------------- /oura/v2/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import OuraClientV2 2 | from .client_pandas import OuraClientDataFrameV2 3 | -------------------------------------------------------------------------------- /oura/v2/client.py: -------------------------------------------------------------------------------- 1 | import json 2 | from urllib.parse import urlencode 3 | 4 | from .. import exceptions 5 | from ..auth import OAuthRequestHandler, PersonalRequestHandler 6 | 7 | 8 | class OuraClientV2: 9 | 10 | API_ENDPOINT = "https://api.ouraring.com/v2/usercollection" 11 | 12 | def __init__( 13 | self, 14 | client_id=None, 15 | client_secret=None, 16 | access_token=None, 17 | refresh_token=None, 18 | refresh_callback=None, 19 | personal_access_token=None, 20 | ): 21 | """ 22 | :param client_id: The client id - identifies your application. 23 | :type client_id: str 24 | 25 | :param client_secret: The client secret. Required for auto refresh. 26 | :type client_secret: str 27 | 28 | :param access_token: Access token. 29 | :type access_token: str 30 | 31 | :param refresh_token: Use this to renew tokens when they expire 32 | :type refresh_token: str 33 | 34 | :param refresh_callback: Callback to handle token response 35 | :type refresh_callback: callable 36 | 37 | :param personal_access_token: Token used for accessing personal data 38 | :type personal_access_token: str 39 | """ 40 | 41 | if client_id is not None: 42 | self._auth_handler = OAuthRequestHandler( 43 | client_id, client_secret, access_token, refresh_token, refresh_callback 44 | ) 45 | 46 | if personal_access_token is not None: 47 | self._auth_handler = PersonalRequestHandler(personal_access_token) 48 | 49 | def daily_activity(self, start_date=None, end_date=None, next_token=None): 50 | # end_date default to current UTC date 51 | # start_date default to end_date - 1 day 52 | return self._get_summary(start_date, end_date, next_token, "daily_activity") 53 | 54 | def daily_readiness(self, start_date=None, end_date=None, next_token=None): 55 | return self._get_summary(start_date, end_date, next_token, "daily_readiness") 56 | 57 | def daily_sleep(self, start_date=None, end_date=None, next_token=None): 58 | return self._get_summary(start_date, end_date, next_token, "daily_sleep") 59 | 60 | def daily_spo2(self, start_date=None, end_date=None, next_token=None): 61 | return self._get_summary(start_date, end_date, next_token, "daily_spo2") 62 | 63 | def daily_stress(self, start_date=None, end_date=None, next_token=None): 64 | return self._get_summary(start_date, end_date, next_token, "daily_stress") 65 | 66 | def enhanced_tag(self, start_date=None, end_date=None, next_token=None): 67 | return self._get_summary(start_date, end_date, next_token, "enhanced_tag") 68 | 69 | def heartrate(self, start_date=None, end_date=None, next_token=None): 70 | return self._get_summary(start_date, end_date, next_token, "heartrate") 71 | 72 | def personal_info(self): 73 | return self._get_summary(None, None, None, "personal_info") 74 | 75 | def rest_mode_period(self, start_date=None, end_date=None, next_token=None): 76 | return self._get_summary(start_date, end_date, next_token, "rest_mode_period") 77 | 78 | def ring_configuration(self, next_token=None): 79 | return self._get_summary(None, None, next_token, "ring_configuration") 80 | 81 | def session(self, start_date=None, end_date=None, next_token=None): 82 | return self._get_summary(start_date, end_date, next_token, "session") 83 | 84 | def sleep(self, start_date=None, end_date=None, next_token=None): 85 | return self._get_summary(start_date, end_date, next_token, "sleep") 86 | 87 | def sleep_time(self, start_date=None, end_date=None, next_token=None): 88 | return self._get_summary(start_date, end_date, next_token, "sleep_time") 89 | 90 | def workouts(self, start_date=None, end_date=None, next_token=None): 91 | return self._get_summary(start_date, end_date, next_token, "workout") 92 | 93 | def _get_summary(self, start_date, end_date, next_token, summary_type): 94 | url = self._build_summary_url(start_date, end_date, next_token, summary_type) 95 | return self._make_request(url) 96 | 97 | def _make_request(self, url): 98 | response = self._auth_handler.make_request_v2(url) 99 | exceptions.detect_and_raise_error(response) 100 | payload = json.loads(response.content.decode("utf8")) 101 | return payload 102 | 103 | def _build_summary_url(self, start_date, end_date, next_token, summary_type): 104 | url = f"{self.API_ENDPOINT}/{summary_type}" 105 | params = {} 106 | if start_date is not None: 107 | if not isinstance(start_date, str): 108 | raise TypeError("start date must be of type str") 109 | key = "start_datetime" if summary_type == "heartrate" else "start_date" 110 | params[key] = start_date 111 | 112 | if end_date is not None: 113 | if not isinstance(end_date, str): 114 | raise TypeError("end date must be of type str") 115 | key = "end_datetime" if summary_type == "heartrate" else "end_date" 116 | params[key] = end_date 117 | 118 | if next_token is not None: 119 | params["next_token"] = next_token 120 | 121 | qs = urlencode(params) 122 | url = f"{url}?{qs}" if qs != "" else url 123 | return url 124 | 125 | def revoke_token(self): 126 | response = self._auth_handler.revoke_token() 127 | exceptions.detect_and_raise_error(response) 128 | payload = json.loads(response.content.decode("utf8")) 129 | return payload 130 | -------------------------------------------------------------------------------- /oura/v2/client_pandas.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | 3 | from .client import OuraClientV2 4 | 5 | 6 | def to_pandas(summary, metrics=None, date_key="timestamp"): 7 | """ 8 | Creates a dataframe from a summary object 9 | 10 | :param summary: A summary object returned from API 11 | :type summary: list of dictionaries. See https://cloud.ouraring.com/v2/docs#tag/Daily-Activity for example 12 | 13 | :param metrics: The metrics to include in the DF. None includes all metrics 14 | :type metrics: A list of metric names, or alternatively a string for one metric name 15 | 16 | :param date_key: Column to set as the index 17 | :type date_key: str 18 | """ 19 | 20 | if isinstance(summary, dict): 21 | summary = [summary] 22 | df = pd.DataFrame(summary) 23 | if df.size == 0: 24 | return df 25 | if metrics is not None: 26 | if isinstance(metrics, str): 27 | metrics = [metrics] 28 | else: 29 | metrics = metrics.copy() 30 | # drop any invalid cols the user may have entered 31 | metrics = [m for m in metrics if m in df.columns] 32 | 33 | # always include summary_date (or date_key, as for bedtime) 34 | if date_key not in metrics: 35 | metrics.insert(0, date_key) 36 | 37 | df = df[metrics] 38 | df[date_key] = pd.to_datetime(df[date_key]).dt.date 39 | df = df.set_index(date_key) 40 | return df 41 | 42 | 43 | class OuraClientDataFrameV2(OuraClientV2): 44 | """ 45 | Similiar to OuraClientV2, but data is returned instead 46 | as a pandas.DataFrame object. Each row will correspond to a single day 47 | of data, indexed by the date. 48 | """ 49 | 50 | def __init__( 51 | self, 52 | client_id=None, 53 | client_secret=None, 54 | access_token=None, 55 | refresh_token=None, 56 | refresh_callback=None, 57 | personal_access_token=None, 58 | ): 59 | super().__init__( 60 | client_id, 61 | client_secret, 62 | access_token, 63 | refresh_token, 64 | refresh_callback, 65 | personal_access_token, 66 | ) 67 | 68 | def activity_df(self, start=None, end=None, metrics=None): 69 | activity_summary = super().daily_activity(start, end)["data"] 70 | df = to_pandas(activity_summary, metrics) 71 | return df 72 | 73 | def heart_rate_df(self, start=None, end=None, metrics=None): 74 | readiness_summary = super().heartrate(start, end)["data"] 75 | return to_pandas(readiness_summary, metrics) 76 | 77 | def personal_info_df(self): 78 | info = super().personal_info() 79 | return pd.DataFrame([info]) 80 | 81 | def sessions_df(self, start=None, end=None, metrics=None): 82 | sessions = super().session(start, end)["data"] 83 | return to_pandas(sessions, metrics, date_key="day") 84 | 85 | def tags_df(self, start=None, end=None, metrics=None): 86 | tags = super().tags(start, end)["data"] 87 | return to_pandas(tags, metrics) 88 | 89 | def workouts_df(self, start=None, end=None, metrics=None): 90 | workouts = super().workouts(start, end)["data"] 91 | return to_pandas(workouts, metrics, date_key="day") 92 | 93 | def sleep_df(self, start=None, end=None, metrics=None): 94 | raise NotImplementedError 95 | 96 | def readiness_df(self, start=None, end=None, metrics=None): 97 | raise NotImplementedError 98 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | pandas 3 | pytest 4 | requests-mock 5 | requests-oauthlib 6 | twine 7 | -------------------------------------------------------------------------------- /samples/sample.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from datetime import datetime 4 | 5 | from oura import OuraClient 6 | 7 | 8 | def get_self(): 9 | pat = os.getenv("OURA_PAT") 10 | client = OuraClient(personal_access_token=pat) 11 | user_info = client.user_info() 12 | print(user_info) 13 | 14 | 15 | def setEnvironment(envFile): 16 | basePath = os.path.dirname(os.path.abspath(__file__)) 17 | fullPath = os.path.join(basePath, envFile) 18 | with open(fullPath) as file: 19 | env = json.load(file) 20 | os.environ["OURA_CLIENT_ID"] = env["client_id"] 21 | os.environ["OURA_CLIENT_SECRET"] = env["client_secret"] 22 | os.environ["OURA_ACCESS_TOKEN"] = env["access_token"] 23 | os.environ["OURA_REFRESH_TOKEN"] = env["refresh_token"] 24 | 25 | 26 | def appendFile(filename, token_dict): 27 | 28 | basePath = os.path.dirname(os.path.abspath(__file__)) 29 | fullPath = os.path.join(basePath, filename) 30 | with open(fullPath, "r+") as file: 31 | prev = json.load(file) 32 | curr = { 33 | "client_id": prev.pop("client_id"), 34 | "client_secret": prev.pop("client_secret"), 35 | "access_token": token_dict["access_token"], 36 | "refresh_token": token_dict["refresh_token"], 37 | "previous": json.dumps(prev), 38 | } 39 | file.seek(0) 40 | json.dump(curr, file) 41 | 42 | 43 | def getOuraClient(envFile): 44 | client_id = os.getenv("OURA_CLIENT_ID") 45 | client_secret = os.getenv("OURA_CLIENT_SECRET") 46 | access_token = os.getenv("OURA_ACCESS_TOKEN") 47 | refresh_token = os.getenv("OURA_REFRESH_TOKEN") 48 | refresh_callback = lambda x: appendFile(envFile, x) 49 | 50 | auth_client = OuraClient( 51 | client_id=client_id, 52 | client_secret=client_secret, 53 | access_token=access_token, 54 | refresh_token=refresh_token, 55 | refresh_callback=refresh_callback, 56 | ) 57 | 58 | return auth_client 59 | 60 | 61 | if __name__ == "__main__": 62 | 63 | envFile = "token.json" 64 | setEnvironment(envFile) 65 | client = getOuraClient(envFile) 66 | today = datetime.today() 67 | sleep = client.sleep_summary(today) 68 | print(sleep) 69 | -------------------------------------------------------------------------------- /samples/token.json: -------------------------------------------------------------------------------- 1 | {"client_id": "ABCD", "client_secret": "EFGH", "access_token": "1234HJKL", "refresh_token": "6789QWERT"} -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = oura 3 | version = 1.3.0 4 | author = turing-complet 5 | author_email = turingcomplet@proton.me 6 | description = Oura API client 7 | long_description = file: README.md 8 | long_description_content_type = text/markdown 9 | url = https://github.com/turing-complet/python-ouraring 10 | project_urls = 11 | Bug Tracker = https://github.com/turing-complet/python-ouraring/issues 12 | classifiers = 13 | License :: OSI Approved :: MIT License 14 | Operating System :: OS Independent 15 | Programming Language :: Python :: 3 16 | Programming Language :: Python :: 3.8 17 | Programming Language :: Python :: 3.9 18 | 19 | [options] 20 | packages = find: 21 | python_requires = >=3.8 22 | install_requires = 23 | requests-oauthlib 24 | pandas 25 | 26 | [options.packages.find] 27 | exclude = tests 28 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from . import test_auth, test_client, test_client_pandas 2 | from .mock_client import MockClient, MockDataFrameClient 3 | -------------------------------------------------------------------------------- /tests/mock_client.py: -------------------------------------------------------------------------------- 1 | from oura import OuraClient, OuraClientDataFrame 2 | 3 | 4 | class MockClient(OuraClient): 5 | def user_info(self): 6 | return { 7 | "age": 27, 8 | "weight": 80, 9 | "gender": "male", 10 | "email": "john.doe@the.domain", 11 | } 12 | 13 | def activity_summary(self, start=None, end=None): 14 | minutes_per_day = 1440 15 | return { 16 | "activity": [ 17 | { 18 | "summary_date": "2016-09-03", 19 | "day_start": "2016-09-03T04:00:00+03:00", 20 | "day_end": "2016-09-04T03:59:59+03:00", 21 | "timezone": 180, 22 | "score": 87, 23 | "score_stay_active": 90, 24 | "score_move_every_hour": 100, 25 | "score_meet_daily_targets": 60, 26 | "score_training_frequency": 96, 27 | "score_training_volume": 95, 28 | "score_recovery_time": 100, 29 | "daily_movement": 7806, 30 | "non_wear": 313, 31 | "rest": 426, 32 | "inactive": 429, 33 | "inactivity_alerts": 0, 34 | "low": 224, 35 | "medium": 49, 36 | "high": 0, 37 | "steps": 9206, 38 | "cal_total": 2540, 39 | "cal_active": 416, 40 | "met_min_inactive": 9, 41 | "met_min_low": 167, 42 | "met_min_medium_plus": 159, 43 | "met_min_medium": 159, 44 | "met_min_high": 0, 45 | "average_met": 1.4375, 46 | "class_5min": "1112211111111111111111111111111111111111111111233322322223333323322222220000000000000000000000000000000000000000000000000000000233334444332222222222222322333444432222222221230003233332232222333332333333330002222222233233233222212222222223121121111222111111122212321223211111111111111111", 47 | "met_1min": [0.9] * minutes_per_day, 48 | "rest_mode_state": 0, 49 | } 50 | ] 51 | } 52 | 53 | def sleep_summary(self, start=None, end=None): 54 | return { 55 | "sleep": [ 56 | { 57 | "summary_date": "2017-11-05", 58 | "period_id": 0, 59 | "is_longest": 1, 60 | "timezone": 120, 61 | "bedtime_start": "2017-11-06T02:13:19+02:00", 62 | "bedtime_end": "2017-11-06T08:12:19+02:00", 63 | "score": 70, 64 | "score_total": 57, 65 | "score_disturbances": 83, 66 | "score_efficiency": 99, 67 | "score_latency": 88, 68 | "score_rem": 97, 69 | "score_deep": 59, 70 | "score_alignment": 31, 71 | "total": 20310, 72 | "duration": 21540, 73 | "awake": 1230, 74 | "light": 10260, 75 | "rem": 7140, 76 | "deep": 2910, 77 | "onset_latency": 480, 78 | "restless": 39, 79 | "efficiency": 94, 80 | "midpoint_time": 11010, 81 | "hr_lowest": 49, 82 | "hr_average": 56.375, 83 | "rmssd": 54, 84 | "breath_average": 13, 85 | "temperature_delta": -0.06, 86 | "hypnogram_5min": "443432222211222333321112222222222111133333322221112233333333332232222334", 87 | "hr_5min": [52] * 72, 88 | "rmssd_5min": [61] * 72, 89 | } 90 | ] 91 | } 92 | 93 | def readiness_summary(self, start=None, end=None): 94 | return { 95 | "readiness": [ 96 | { 97 | "summary_date": "2016-09-03", 98 | "period_id": 0, 99 | "score": 62, 100 | "score_previous_night": 5, 101 | "score_sleep_balance": 75, 102 | "score_previous_day": 61, 103 | "score_activity_balance": 77, 104 | "score_resting_hr": 98, 105 | "score_hrv_balance": 90, 106 | "score_recovery_index": 45, 107 | "score_temperature": 86, 108 | "rest_mode_state": 0, 109 | } 110 | ] 111 | } 112 | 113 | def bedtime_summary(self, start=None, end=None): 114 | return { 115 | "ideal_bedtimes": [ 116 | { 117 | "date": "2020-03-17", 118 | "bedtime_window": {"start": -3600, "end": 0}, 119 | "status": "IDEAL_BEDTIME_AVAILABLE", 120 | }, 121 | { 122 | "date": "2020-03-18", 123 | "bedtime_window": {"start": None, "end": None}, 124 | "status": "LOW_SLEEP_SCORES", 125 | }, 126 | ] 127 | } 128 | 129 | 130 | class MockOneDayClient(MockClient): 131 | def activity_summary(self, start=None, end=None): 132 | resp = super().activity_summary(start, end) 133 | return {"activity": resp["activity"][0]} 134 | 135 | def sleep_summary(self, start=None, end=None): 136 | resp = super().sleep_summary(start, end) 137 | return {"sleep": resp["sleep"][0]} 138 | 139 | def readiness_summary(self, start=None, end=None): 140 | resp = super().readiness_summary(start, end) 141 | return {"readiness": resp["readiness"][0]} 142 | 143 | def bedtime_summary(self, start=None, end=None): 144 | resp = super().bedtime_summary(start, end) 145 | return {"ideal_bedtimes": resp["ideal_bedtimes"][0]} 146 | 147 | 148 | class MockDataFrameClient(OuraClientDataFrame, MockClient): 149 | pass 150 | -------------------------------------------------------------------------------- /tests/mock_client_v2.py: -------------------------------------------------------------------------------- 1 | from oura.v2 import OuraClientDataFrameV2, OuraClientV2 2 | 3 | 4 | class MockClientV2(OuraClientV2): 5 | def personal_info(self): 6 | return { 7 | "age": 31, 8 | "weight": 74.8, 9 | "height": 1.8, 10 | "biological_sex": "male", 11 | "email": "example@example.com", 12 | } 13 | 14 | def daily_activity(self, start_date=None, end_date=None, next_token=None): 15 | return { 16 | "data": [ 17 | { 18 | "class_5_min": "000000000000000000000000000000000000000000000000000000000000000000000000003444544444445545455443454554454443334333322330000000000232232222222232222222322223222000000022332233422333222232233333222222222222222332223212233222122221111111111111121111111111111111111111111111111111111111111111", 19 | "score": 82, 20 | "active_calories": 1222, 21 | "average_met_minutes": 1.90625, 22 | "contributors": { 23 | "meet_daily_targets": 43, 24 | "move_every_hour": 100, 25 | "recovery_time": 100, 26 | "stay_active": 98, 27 | "training_frequency": 71, 28 | "training_volume": 98, 29 | }, 30 | "equivalent_walking_distance": 20122, 31 | "high_activity_met_minutes": 444, 32 | "high_activity_time": 3000, 33 | "inactivity_alerts": 0, 34 | "low_activity_met_minutes": 117, 35 | "low_activity_time": 10020, 36 | "medium_activity_met_minutes": 391, 37 | "medium_activity_time": 6060, 38 | "met": { 39 | "interval": 60, 40 | "items": [ # truncated for readability 41 | 0.1, 42 | 0.1, 43 | 0.1, 44 | 0.1, 45 | 0.9, 46 | 0.9, 47 | 0.9, 48 | 0.1, 49 | 0.1, 50 | 0.1, 51 | 0.1, 52 | 0.1, 53 | 0.9, 54 | 0.9, 55 | 0.9, 56 | 0.9, 57 | 0.9, 58 | 0.9, 59 | 0.9, 60 | ], 61 | "timestamp": "2021-11-26T04:00:00.000-08:00", 62 | }, 63 | "meters_to_target": -16200, 64 | "non_wear_time": 27480, 65 | "resting_time": 18840, 66 | "sedentary_met_minutes": 10, 67 | "sedentary_time": 21000, 68 | "steps": 18430, 69 | "target_calories": 350, 70 | "target_meters": 7000, 71 | "total_calories": 3446, 72 | "day": "2021-11-26", 73 | "timestamp": "2021-11-26T04:00:00-08:00", 74 | } 75 | ], 76 | "next_token": None, 77 | } 78 | 79 | def heartrate(self, start_date=None, end_date=None, next_token=None): 80 | return { 81 | "data": [ 82 | {"bpm": 60, "source": "sleep", "timestamp": "2021-01-01T01:02:03+00:00"} 83 | ], 84 | "next_token": None, 85 | } 86 | 87 | def session(self, start_date=None, end_date=None, next_token=None): 88 | return { 89 | "data": [ 90 | { 91 | "day": "2021-11-12", 92 | "start_datetime": "2021-11-12T12:32:09-08:00", 93 | "end_datetime": "2021-11-12T12:40:49-08:00", 94 | "type": "rest", 95 | "heart_rate": None, 96 | "heart_rate_variability": None, 97 | "mood": None, 98 | "motion_count": { 99 | "interval": 5, 100 | "items": [0], 101 | "timestamp": "2021-11-12T12:32:09.000-08:00", 102 | }, 103 | } 104 | ], 105 | "next_token": None, 106 | } 107 | 108 | def tags(self, start_date=None, end_date=None, next_token=None): 109 | return { 110 | "data": [ 111 | { 112 | "day": "2021-01-01", 113 | "text": "Need coffee", 114 | "timestamp": "2021-01-01T01:02:03-08:00", 115 | "tags": ["tag_generic_nocaffeine"], 116 | } 117 | ], 118 | "next_token": None, 119 | } 120 | 121 | def workouts(self, start_date=None, end_date=None, next_token=None): 122 | return { 123 | "data": [ 124 | { 125 | "activity": "cycling", 126 | "calories": 300, 127 | "day": "2021-01-01", 128 | "distance": 13500.5, 129 | "end_datetime": "2021-01-01T01:00:00.000000+00:00", 130 | "intensity": "moderate", 131 | "label": None, 132 | "source": "manual", 133 | "start_datetime": "2021-01-01T01:30:00.000000+00:00", 134 | } 135 | ], 136 | "next_token": None, 137 | } 138 | 139 | 140 | class MockDataFrameClientV2(OuraClientDataFrameV2, MockClientV2): 141 | pass 142 | -------------------------------------------------------------------------------- /tests/test_auth.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import requests_mock 4 | 5 | from oura import OuraOAuth2Client 6 | 7 | 8 | def test_build_authorize_endpoint(): 9 | client = OuraOAuth2Client("test_client", "test_secret") 10 | actual_url, state = client.authorize_endpoint(scope=["email", "daily"], state="foo") 11 | expected = "https://cloud.ouraring.com/oauth/authorize?response_type=code&client_id=test_client&scope=email+daily&state=foo" 12 | assert expected == actual_url 13 | assert "foo" == state 14 | 15 | 16 | def test_token_request(): 17 | client = OuraOAuth2Client("test_client", "test_secret") 18 | fake_code = "fake_code" 19 | with requests_mock.mock() as m: 20 | m.post( 21 | client.TOKEN_BASE_URL, 22 | text=json.dumps( 23 | { 24 | "access_token": "fake_return_access_token", 25 | "refresh_token": "fake_return_refresh_token", 26 | } 27 | ), 28 | ) 29 | retval = client.fetch_access_token(fake_code) 30 | assert "fake_return_access_token" == retval["access_token"] 31 | assert "fake_return_refresh_token" == retval["refresh_token"] 32 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | import json 2 | from urllib.parse import parse_qs, urlparse 3 | 4 | import requests_mock 5 | 6 | from oura import OuraClient 7 | 8 | adapter = requests_mock.Adapter() 9 | 10 | 11 | def test_summary_url(): 12 | client = OuraClient("test_id") 13 | url = client._build_summary_url(start="start-date", end=None, summary_type="sleep") 14 | parsed_url = urlparse(url) 15 | params = parse_qs(parsed_url.query) 16 | assert "end" not in params.keys() 17 | 18 | url2 = client._build_summary_url( 19 | start="start-date", end="end_date", summary_type="sleep" 20 | ) 21 | parsed_url = urlparse(url2) 22 | params = parse_qs(parsed_url.query) 23 | assert "end" in params.keys() 24 | 25 | 26 | def test_token_refresh(): 27 | update_called = [] 28 | 29 | # hacky way to test side effect 30 | def token_updater(token): 31 | update_called.append(1) 32 | 33 | client = OuraClient( 34 | client_id="test_id", access_token="token", refresh_callback=token_updater 35 | ) 36 | adapter.register_uri( 37 | requests_mock.POST, 38 | requests_mock.ANY, 39 | status_code=401, 40 | text=json.dumps( 41 | { 42 | "access_token": "fake_return_access_token", 43 | "refresh_token": "fake_return_refresh_token", 44 | } 45 | ), 46 | ) 47 | adapter.register_uri( 48 | requests_mock.GET, 49 | requests_mock.ANY, 50 | status_code=401, 51 | text=json.dumps({"a": "b"}), 52 | ) 53 | 54 | client._auth_handler._session.mount(client.API_ENDPOINT, adapter) 55 | try: 56 | client.user_info() 57 | except Exception: 58 | pass 59 | assert len(update_called) == 1 60 | -------------------------------------------------------------------------------- /tests/test_client_pandas.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from datetime import date 4 | 5 | import pandas as pd 6 | import pytest 7 | 8 | from .mock_client import MockDataFrameClient 9 | 10 | client = MockDataFrameClient() 11 | 12 | 13 | def test_sleep_summary_df(): 14 | """ 15 | Objectives: 16 | 1. Test that dataframe summary_date match the args passed into 17 | start and end date 18 | 19 | 2. Test that the correct number of metrics are being returned 20 | 21 | 3. Test raw and edited dataframes are returning correctly named 22 | fields and correct data types 23 | """ 24 | start = "2017-11-05" 25 | end = "2017-11-05" 26 | df1 = client.sleep_df(start, convert=False) 27 | # check all cols are included 28 | assert df1.shape == (1, 31) 29 | # check that start date parameter is correct 30 | assert df1.index[0] == date(2017, 11, 5) 31 | 32 | df2 = client.sleep_df(start, end, metrics=["bedtime_start", "score"], convert=False) 33 | # check that correct metrics are being included 34 | assert df2.shape[1] == 2 35 | # check that end date parameter is correct 36 | assert df2.index[-1] == date(2017, 11, 5) 37 | # check that data type has not been altered 38 | assert isinstance(df2["bedtime_start"][0], str) 39 | 40 | # test that invalid metric 'zzz' is dropped 41 | df_raw3 = client.sleep_df( 42 | start, end, metrics=["bedtime_start", "zzz"], convert=False 43 | ) 44 | assert df_raw3.shape[1] == 1 45 | 46 | # check that bedtime start has been renamed and is now a timestamp 47 | df_edited = client.sleep_df(start, end, metrics=["bedtime_start", "zzz"]) 48 | assert not isinstance(df_edited["bedtime_start_dt_adjusted"][0], str) 49 | 50 | 51 | def test_activity_summary_df(): 52 | start = "2016-09-03" 53 | end = "2016-09-04" 54 | df1 = client.activity_df(start, convert=False) 55 | # check all cols are included 56 | assert df1.shape == (1, 30) 57 | assert df1.index[0] == date(2016, 9, 3) 58 | 59 | df2 = client.activity_df(start, end, metrics=["day_start", "medium"], convert=False) 60 | assert df2.shape[1] == 2 61 | assert df2.index[-1] == date(2016, 9, 3) 62 | assert isinstance(df2["day_start"][0], str) 63 | 64 | # test that invalid metric is dropped 65 | df_raw3 = client.activity_df( 66 | start, end, metrics=["day_start", "zzz"], convert=False 67 | ) 68 | assert df_raw3.shape[1] == 1 69 | 70 | # check that day_start has been renamed and is now a timestamp 71 | df_edited = client.activity_df(start, end, metrics=["day_start", "zzz"]) 72 | assert not isinstance(df_edited["day_start_dt_adjusted"][0], str) 73 | 74 | 75 | def test_ready_summary_df(): 76 | start = "2016-09-03" 77 | end = "2016-09-04" 78 | df1 = client.readiness_df(start) 79 | # check all cols are included 80 | assert df1.shape == (1, 11) 81 | assert df1.index[0] == date(2016, 9, 3) 82 | 83 | df2 = client.readiness_df( 84 | start, 85 | end, 86 | metrics=["score_hrv_balance", "score_recovery_index"], 87 | ) 88 | assert df2.shape[1] == 2 89 | assert df2.index[-1] == date(2016, 9, 3) 90 | 91 | # test that invalid metric is dropped 92 | df_raw3 = client.readiness_df(start, end, metrics=["score_hrv_balance", "zzz"]) 93 | assert df_raw3.shape[1] == 1 94 | 95 | df_edited = client.readiness_df(start, end, metrics="score_hrv_balance") 96 | assert pd.DataFrame.equals(df_raw3, df_edited) 97 | 98 | 99 | def test_bedtime_df(): 100 | df = client.bedtime_df(metrics=["bedtime_window"]) 101 | assert df.shape == (2, 1) 102 | assert "date" == df.index.name 103 | 104 | 105 | @pytest.mark.skip 106 | def test_combined_summary_df(): 107 | combined_df_edited1 = client.combined_df_edited(start="2020-09-30") 108 | # check all cols are included 109 | assert combined_df_edited1.shape == (0, 72) 110 | assert combined_df_edited1.index[0] > date(2020, 9, 29) 111 | 112 | # check start and end dates work accordingly 113 | combined_df_edited2 = client.combined_df_edited( 114 | start="2020-09-30", 115 | end="2020-10-01", 116 | metrics=["score_hrv_balance", "steps", "efficiency"], 117 | ) 118 | assert combined_df_edited2.shape[1] == 3 119 | assert combined_df_edited2.index[-1] < date(2020, 10, 2) 120 | 121 | # test that invalid metric is dropped 122 | combined_df_edited2 = client.combined_df_edited( 123 | start="2020-09-30", 124 | end="2020-10-01", 125 | metrics=["score_hrv_balance", "steps", "bedtime_start", "zzz"], 126 | ) 127 | assert combined_df_edited2.shape[1] == 3 128 | 129 | # check that columns are pre-fixed with their summary name 130 | assert "ACTIVITY:steps" in combined_df_edited2 131 | # check that columns are suffixed with unit conversions 132 | assert "SLEEP:bedtime_start_dt_adjusted" in combined_df_edited2 133 | -------------------------------------------------------------------------------- /tests/test_converters.py: -------------------------------------------------------------------------------- 1 | from oura.converters import ActivityConverter, SleepConverter 2 | 3 | from .mock_client import MockDataFrameClient 4 | 5 | 6 | def _check_list_equal(a, b): 7 | assert sorted(a) == sorted(b) 8 | 9 | 10 | def test_sleep_default(): 11 | sc = SleepConverter() 12 | _check_list_equal(SleepConverter.all_metrics, sc.convert_cols) 13 | 14 | 15 | def test_activity_default(): 16 | ac = ActivityConverter() 17 | _check_list_equal(ActivityConverter.all_metrics, ac.convert_cols) 18 | 19 | 20 | def test_user_input(): 21 | expected = ["awake", "deep"] 22 | sc = SleepConverter(expected) 23 | _check_list_equal(expected, sc.convert_cols) 24 | 25 | 26 | def test_warn_invalid_col(): 27 | foo = "foo" 28 | ac = ActivityConverter([foo]) 29 | assert foo not in ac.convert_cols 30 | 31 | 32 | def test_hypnogram_helper(): 33 | hypnogram_5min = ( 34 | "443432222211222333321112222222222111133333322221112233333333332232222334" 35 | ) 36 | sc = SleepConverter() 37 | result = sc.convert_hypnogram_helper(hypnogram_5min) 38 | expected = ( 39 | "AARARLLLLLDDLLLRRRRLDDDLLLLLLLLLLDDDDRRRRRRLLLLDDDLLRRRRRRRRRRLLRLLLLRRA" 40 | ) 41 | assert expected == result 42 | 43 | 44 | def test_convert_hypnogram(): 45 | client = MockDataFrameClient() 46 | sleep_df = client.sleep_df(convert_cols=["rem"]) 47 | assert "4" in sleep_df.hypnogram_5min[0] 48 | 49 | sleep_df = client.sleep_df() 50 | assert "A" in sleep_df.hypnogram_5min[0] 51 | -------------------------------------------------------------------------------- /tests/test_mocks.py: -------------------------------------------------------------------------------- 1 | from .mock_client import MockDataFrameClient 2 | from .mock_client_v2 import MockDataFrameClientV2 3 | 4 | 5 | def test_v1(): 6 | v1 = MockDataFrameClient() 7 | v1.activity_df() 8 | v1.bedtime_df() 9 | v1.readiness_df() 10 | v1.user_info_df() 11 | 12 | 13 | def test_v2(): 14 | v2 = MockDataFrameClientV2() 15 | v2.activity_df() 16 | v2.heart_rate_df() 17 | v2.personal_info_df() 18 | v2.sessions_df() 19 | v2.tags_df() 20 | v2.workouts_df() 21 | -------------------------------------------------------------------------------- /tests/test_writers.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from oura.export.writers import save_as_xlsx, tableize 4 | 5 | from .mock_client import MockDataFrameClient 6 | 7 | client = MockDataFrameClient() 8 | 9 | 10 | def test_save_xlsx(): 11 | """ 12 | Check that both raw and edited df's save without issue 13 | """ 14 | df_raw = client.sleep_df(start="2020-09-30", convert=False) 15 | df_edited = client.sleep_df( 16 | start="2020-09-30", 17 | end="2020-10-01", 18 | metrics=["bedtime_start", "bedtime_end", "score"], 19 | ) 20 | raw_file = "df_raw.xlsx" 21 | edited_file = "df_edited.xlsx" 22 | save_as_xlsx(df_raw, raw_file, sheet_name="hello world") 23 | save_as_xlsx(df_edited, edited_file) 24 | assert os.path.exists(raw_file) 25 | assert os.path.exists(edited_file) 26 | 27 | 28 | def test_tableize(): 29 | """ 30 | Check df table is correct 31 | """ 32 | expected = """ 33 | +--------------+-------+ 34 | | summary_date | score | 35 | +--------------+-------+ 36 | | 2017-11-05 | 70 | 37 | +--------------+-------+ 38 | """.strip() 39 | df = client.sleep_df(start="2020-09-30", metrics="score", convert=False) 40 | table = tableize(df, is_print=False) 41 | assert expected == table 42 | -------------------------------------------------------------------------------- /token_request.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | import threading 5 | import webbrowser 6 | from oura import OuraOAuth2Client 7 | from flask import Flask, request 8 | 9 | app = Flask(__name__) 10 | 11 | @app.route('/') 12 | def index(): 13 | auth_code = request.args.get('code') 14 | try: 15 | auth_client.fetch_access_token(auth_code) 16 | print("Save these values!") 17 | for key, value in auth_client.session.token.items(): 18 | print('{} = {}'.format(key, value)) 19 | return "

You are now authorized to access the Oura API!

" 20 | except Exception as e: 21 | print(e) 22 | return "

Error retrieving a token {}

".format(e) 23 | 24 | 25 | def browser_authorize(auth_client): 26 | url, _ = auth_client.authorize_endpoint() 27 | threading.Timer(1, webbrowser.open, args=(url,)).start() 28 | 29 | 30 | if __name__ == '__main__': 31 | 32 | if not (len(sys.argv) == 3): 33 | print("Arguments: client_id and client_secret") 34 | sys.exit(1) 35 | 36 | os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = "1" 37 | auth_client = OuraOAuth2Client(*sys.argv[1:]) 38 | browser_authorize(auth_client) 39 | 40 | # test_response = server.oura.user_info() 41 | app.run(debug = False, host='0.0.0.0', port=3030) 42 | 43 | input("Press any key to close") 44 | --------------------------------------------------------------------------------