diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..7d4d22a --- /dev/null +++ b/.flake8 @@ -0,0 +1,6 @@ +# .flake8 +[flake8] +select = BLK,C,E,F,I,W +ignore = E203,W503,E501,F401,E731 +max-line-length = 88 +max-complexity = 10 diff --git a/.gitignore b/.gitignore index fd447c5..8cdcc42 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,7 @@ __pycache__ build *.egg-info .tox +.nox docs/_build/ -data \ No newline at end of file +data +test_token.json diff --git a/.travis.yml b/.travis.yml index 776368c..7f79984 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,16 @@ language: python python: - - "3.5" - - "3.6" - - "3.7-dev" + - 3.6 + - 3.7 + - 3.8 install: - - pip install pipenv - - pipenv install -script: pytest \ No newline at end of file + - pip install nox +env: + - SESSION=tests +jobs: + include: + - python: 3.8 + env: SESSION=lint + - python: 3.8 + env: SESSION=docs +script: nox -s $SESSION diff --git a/Pipfile b/Pipfile index 5e5e37f..bef28be 100644 --- a/Pipfile +++ b/Pipfile @@ -7,11 +7,8 @@ verify_ssl = true [packages] requests-oauthlib = "*" -sphinx = "*" -sphinx-rtd-theme = "*" flask = "*" twine = "*" -wheel = "*" -ipython = "*" requests-mock = "*" pytest = "*" +pandas = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 8da1e2b..967b2b2 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "e01f2b609eff7adb0d944f23c89bad07ae8dae38c762e0efe1b2bfbd70d81959" + "sha256": "dc5fb6bd54030c827b464a9dfac7b7783226b74bd88deda65f67db89ad226c3c" }, "pipfile-spec": 6, "requires": {}, @@ -14,54 +14,67 @@ ] }, "default": { - "alabaster": { - "hashes": [ - "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359", - "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02" - ], - "version": "==0.7.12" - }, - "atomicwrites": { - "hashes": [ - "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", - "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6" - ], - "version": "==1.3.0" - }, "attrs": { "hashes": [ - "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", - "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399" - ], - "version": "==19.1.0" - }, - "babel": { - "hashes": [ - "sha256:6778d85147d5d85345c14a26aada5e478ab04e39b078b0745ee6870c2b5cf669", - "sha256:8cba50f48c529ca3fa18cf81fa9403be176d374ac4d60738b839122dfaaa3d23" - ], - "version": "==2.6.0" - }, - "backcall": { - "hashes": [ - "sha256:38ecd85be2c1e78f77fd91700c76e14667dc21e2713b63876c0eb901196e01e4", - "sha256:bbbf4b1e5cd2bdb08f915895b51081c041bac22394fdfcfdfbe9f14b77c08bf2" + "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594", + "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc" ], - "version": "==0.1.0" + "version": "==20.2.0" }, "bleach": { "hashes": [ - "sha256:213336e49e102af26d9cde77dd2d0397afabc5a6bf2fed985dc35b5d1e285a16", - "sha256:3fdf7f77adcf649c9911387df51254b813185e32b2c6619f690b593a617e19fa" + "sha256:52b5919b81842b1854196eaae5ca29679a2f2e378905c346d3ca8227c2c66080", + "sha256:9f8ccbeb6183c6e6cddea37592dfb0167485c1e3b13b3363bc325aa8bda3adbd" ], - "version": "==3.1.0" + "version": "==3.2.1" }, "certifi": { "hashes": [ - "sha256:59b7658e26ca9c7339e00f8f4636cdfe59d34fa37b9b04f6f9e9926b3cece1a5", - "sha256:b26104d6835d1f5e49452a26eb2ff87fe7090b89dfcaee5ea2212697e1e1d7ae" - ], - "version": "==2019.3.9" + "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3", + "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41" + ], + "version": "==2020.6.20" + }, + "cffi": { + "hashes": [ + "sha256:005f2bfe11b6745d726dbb07ace4d53f057de66e336ff92d61b8c7e9c8f4777d", + "sha256:09e96138280241bd355cd585148dec04dbbedb4f46128f340d696eaafc82dd7b", + "sha256:0b1ad452cc824665ddc682400b62c9e4f5b64736a2ba99110712fdee5f2505c4", + "sha256:0ef488305fdce2580c8b2708f22d7785ae222d9825d3094ab073e22e93dfe51f", + "sha256:15f351bed09897fbda218e4db5a3d5c06328862f6198d4fb385f3e14e19decb3", + "sha256:22399ff4870fb4c7ef19fff6eeb20a8bbf15571913c181c78cb361024d574579", + "sha256:23e5d2040367322824605bc29ae8ee9175200b92cb5483ac7d466927a9b3d537", + "sha256:2791f68edc5749024b4722500e86303a10d342527e1e3bcac47f35fbd25b764e", + "sha256:2f9674623ca39c9ebe38afa3da402e9326c245f0f5ceff0623dccdac15023e05", + "sha256:3363e77a6176afb8823b6e06db78c46dbc4c7813b00a41300a4873b6ba63b171", + "sha256:33c6cdc071ba5cd6d96769c8969a0531be2d08c2628a0143a10a7dcffa9719ca", + "sha256:3b8eaf915ddc0709779889c472e553f0d3e8b7bdf62dab764c8921b09bf94522", + "sha256:3cb3e1b9ec43256c4e0f8d2837267a70b0e1ca8c4f456685508ae6106b1f504c", + "sha256:3eeeb0405fd145e714f7633a5173318bd88d8bbfc3dd0a5751f8c4f70ae629bc", + "sha256:44f60519595eaca110f248e5017363d751b12782a6f2bd6a7041cba275215f5d", + "sha256:4d7c26bfc1ea9f92084a1d75e11999e97b62d63128bcc90c3624d07813c52808", + "sha256:529c4ed2e10437c205f38f3691a68be66c39197d01062618c55f74294a4a4828", + "sha256:6642f15ad963b5092d65aed022d033c77763515fdc07095208f15d3563003869", + "sha256:85ba797e1de5b48aa5a8427b6ba62cf69607c18c5d4eb747604b7302f1ec382d", + "sha256:8f0f1e499e4000c4c347a124fa6a27d37608ced4fe9f7d45070563b7c4c370c9", + "sha256:a624fae282e81ad2e4871bdb767e2c914d0539708c0f078b5b355258293c98b0", + "sha256:b0358e6fefc74a16f745afa366acc89f979040e0cbc4eec55ab26ad1f6a9bfbc", + "sha256:bbd2f4dfee1079f76943767fce837ade3087b578aeb9f69aec7857d5bf25db15", + "sha256:bf39a9e19ce7298f1bd6a9758fa99707e9e5b1ebe5e90f2c3913a47bc548747c", + "sha256:c11579638288e53fc94ad60022ff1b67865363e730ee41ad5e6f0a17188b327a", + "sha256:c150eaa3dadbb2b5339675b88d4573c1be3cb6f2c33a6c83387e10cc0bf05bd3", + "sha256:c53af463f4a40de78c58b8b2710ade243c81cbca641e34debf3396a9640d6ec1", + "sha256:cb763ceceae04803adcc4e2d80d611ef201c73da32d8f2722e9d0ab0c7f10768", + "sha256:cc75f58cdaf043fe6a7a6c04b3b5a0e694c6a9e24050967747251fb80d7bce0d", + "sha256:d80998ed59176e8cba74028762fbd9b9153b9afc71ea118e63bbf5d4d0f9552b", + "sha256:de31b5164d44ef4943db155b3e8e17929707cac1e5bd2f363e67a56e3af4af6e", + "sha256:e66399cf0fc07de4dce4f588fc25bfe84a6d1285cc544e67987d22663393926d", + "sha256:f0620511387790860b249b9241c2f13c3a80e21a73e0b861a2df24e9d6f56730", + "sha256:f4eae045e6ab2bb54ca279733fe4eb85f1effda392666308250714e01907f394", + "sha256:f92cdecb618e5fa4658aeb97d5eb3d2f47aa94ac6477c6daf0f306c5a3b9e6b1", + "sha256:f92f789e4f9241cd262ad7a555ca2c648a98178a953af117ef7fad46aa1d5591" + ], + "version": "==1.14.3" }, "chardet": { "hashes": [ @@ -72,62 +85,73 @@ }, "click": { "hashes": [ - "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", - "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7" - ], - "version": "==7.0" - }, - "decorator": { - "hashes": [ - "sha256:86156361c50488b84a3f148056ea716ca587df2f0de1d34750d35c21312725de", - "sha256:f069f3a01830ca754ba5258fde2278454a0b5b79e0d7f5c13b3b97e57d4acff6" - ], - "version": "==4.4.0" + "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", + "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" + ], + "version": "==7.1.2" + }, + "colorama": { + "hashes": [ + "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", + "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" + ], + "version": "==0.4.4" + }, + "cryptography": { + "hashes": [ + "sha256:07ca431b788249af92764e3be9a488aa1d39a0bc3be313d826bbec690417e538", + "sha256:13b88a0bd044b4eae1ef40e265d006e34dbcde0c2f1e15eb9896501b2d8f6c6f", + "sha256:32434673d8505b42c0de4de86da8c1620651abd24afe91ae0335597683ed1b77", + "sha256:3cd75a683b15576cfc822c7c5742b3276e50b21a06672dc3a800a2d5da4ecd1b", + "sha256:4e7268a0ca14536fecfdf2b00297d4e407da904718658c1ff1961c713f90fd33", + "sha256:545a8550782dda68f8cdc75a6e3bf252017aa8f75f19f5a9ca940772fc0cb56e", + "sha256:55d0b896631412b6f0c7de56e12eb3e261ac347fbaa5d5e705291a9016e5f8cb", + "sha256:5849d59358547bf789ee7e0d7a9036b2d29e9a4ddf1ce5e06bb45634f995c53e", + "sha256:6dc59630ecce8c1f558277ceb212c751d6730bd12c80ea96b4ac65637c4f55e7", + "sha256:7117319b44ed1842c617d0a452383a5a052ec6aa726dfbaffa8b94c910444297", + "sha256:75e8e6684cf0034f6bf2a97095cb95f81537b12b36a8fedf06e73050bb171c2d", + "sha256:7b8d9d8d3a9bd240f453342981f765346c87ade811519f98664519696f8e6ab7", + "sha256:a035a10686532b0587d58a606004aa20ad895c60c4d029afa245802347fab57b", + "sha256:a4e27ed0b2504195f855b52052eadcc9795c59909c9d84314c5408687f933fc7", + "sha256:a733671100cd26d816eed39507e585c156e4498293a907029969234e5e634bc4", + "sha256:a75f306a16d9f9afebfbedc41c8c2351d8e61e818ba6b4c40815e2b5740bb6b8", + "sha256:bd717aa029217b8ef94a7d21632a3bb5a4e7218a4513d2521c2a2fd63011e98b", + "sha256:d25cecbac20713a7c3bc544372d42d8eafa89799f492a43b79e1dfd650484851", + "sha256:d26a2557d8f9122f9bf445fc7034242f4375bd4e95ecda007667540270965b13", + "sha256:d3545829ab42a66b84a9aaabf216a4dce7f16dbc76eb69be5c302ed6b8f4a29b", + "sha256:d3d5e10be0cf2a12214ddee45c6bd203dab435e3d83b4560c03066eda600bfe3", + "sha256:efe15aca4f64f3a7ea0c09c87826490e50ed166ce67368a68f315ea0807a20df" + ], + "version": "==3.2.1" }, "docutils": { "hashes": [ - "sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6", - "sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274", - "sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6" + "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af", + "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc" ], - "version": "==0.14" + "version": "==0.16" }, "flask": { "hashes": [ - "sha256:2271c0070dbcb5275fad4a82e29f23ab92682dc45f9dfbc22c02ba9b9322ce48", - "sha256:a080b744b7e345ccfcbc77954861cb05b3c63786e93f2b3875e0913d44b43f05" + "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060", + "sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557" ], "index": "pypi", - "version": "==1.0.2" + "version": "==1.1.2" }, "idna": { "hashes": [ - "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", - "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" - ], - "version": "==2.8" - }, - "imagesize": { - "hashes": [ - "sha256:3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8", - "sha256:f3832918bc3c66617f92e35f5d70729187676313caa60c187eb0f28b8fe5e3b5" + "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", + "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" ], - "version": "==1.1.0" + "version": "==2.10" }, - "ipython": { + "iniconfig": { "hashes": [ - "sha256:b038baa489c38f6d853a3cfc4c635b0cda66f2864d136fe8f40c1a6e334e2a6b", - "sha256:f5102c1cd67e399ec8ea66bcebe6e3968ea25a8977e53f012963e5affeb1fe38" + "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", + "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32" ], - "index": "pypi", - "version": "==7.4.0" - }, - "ipython-genutils": { - "hashes": [ - "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8", - "sha256:eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8" - ], - "version": "==0.2.0" + "version": "==1.1.1" }, "itsdangerous": { "hashes": [ @@ -136,19 +160,26 @@ ], "version": "==1.1.0" }, - "jedi": { + "jeepney": { "hashes": [ - "sha256:2bb0603e3506f708e792c7f4ad8fc2a7a9d9c2d292a358fbbd58da531695595b", - "sha256:2c6bcd9545c7d6440951b12b44d373479bf18123a401a52025cf98563fbd826c" + "sha256:3479b861cc2b6407de5188695fa1a8d57e5072d7059322469b62628869b8e36e", + "sha256:d6c6b49683446d2407d2fe3acb7a368a77ff063f9182fe427da15d622adc24cf" ], - "version": "==0.13.3" + "version": "==0.4.3" }, "jinja2": { "hashes": [ - "sha256:065c4f02ebe7f7cf559e49ee5a95fb800a9e4528727aec6f24402a5374c65013", - "sha256:14dd6caf1527abb21f08f86c784eac40853ba93edb79552aa1e4b8aef1b61c7b" + "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", + "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" + ], + "version": "==2.11.2" + }, + "keyring": { + "hashes": [ + "sha256:4e34ea2fdec90c1c43d6610b5a5fafa1b9097db1802948e90caf5763974b8f8d", + "sha256:9aeadd006a852b78f4b4ef7c7556c2774d2432bbef8ee538a3e9089ac8b11466" ], - "version": "==2.10.1" + "version": "==21.4.0" }, "markupsafe": { "hashes": [ @@ -156,13 +187,16 @@ "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", + "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", + "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", + "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", @@ -179,148 +213,189 @@ "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", - "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7" + "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", + "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", + "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" ], "version": "==1.1.1" }, - "more-itertools": { - "hashes": [ - "sha256:2112d2ca570bb7c3e53ea1a35cd5df42bb0fd10c45f0fb97178679c3c03d64c7", - "sha256:c3e4748ba1aad8dba30a4886b0b1a2004f9a863837b8654e7059eebf727afa5a" - ], - "markers": "python_version > '2.7'", - "version": "==7.0.0" + "numpy": { + "hashes": [ + "sha256:0ee77786eebbfa37f2141fd106b549d37c89207a0d01d8852fde1c82e9bfc0e7", + "sha256:199bebc296bd8a5fc31c16f256ac873dd4d5b4928dfd50e6c4995570fc71a8f3", + "sha256:1a307bdd3dd444b1d0daa356b5f4c7de2e24d63bdc33ea13ff718b8ec4c6a268", + "sha256:1ea7e859f16e72ab81ef20aae69216cfea870676347510da9244805ff9670170", + "sha256:271139653e8b7a046d11a78c0d33bafbddd5c443a5b9119618d0652a4eb3a09f", + "sha256:35bf5316af8dc7c7db1ad45bec603e5fb28671beb98ebd1d65e8059efcfd3b72", + "sha256:463792a249a81b9eb2b63676347f996d3f0082c2666fd0604f4180d2e5445996", + "sha256:50d3513469acf5b2c0406e822d3f314d7ac5788c2b438c24e5dd54d5a81ef522", + "sha256:50f68ebc439821b826823a8da6caa79cd080dee2a6d5ab9f1163465a060495ed", + "sha256:51e8d2ae7c7e985c7bebf218e56f72fa93c900ad0c8a7d9fbbbf362f45710f69", + "sha256:522053b731e11329dd52d258ddf7de5288cae7418b55e4b7d32f0b7e31787e9d", + "sha256:5ea4401ada0d3988c263df85feb33818dc995abc85b8125f6ccb762009e7bc68", + "sha256:604d2e5a31482a3ad2c88206efd43d6fcf666ada1f3188fd779b4917e49b7a98", + "sha256:6ff88bcf1872b79002569c63fe26cd2cda614e573c553c4d5b814fb5eb3d2822", + "sha256:7197ee0a25629ed782c7bd01871ee40702ffeef35bc48004bc2fdcc71e29ba9d", + "sha256:741d95eb2b505bb7a99fbf4be05fa69f466e240c2b4f2d3ddead4f1b5f82a5a5", + "sha256:83af653bb92d1e248ccf5fdb05ccc934c14b936bcfe9b917dc180d3f00250ac6", + "sha256:8802d23e4895e0c65e418abe67cdf518aa5cbb976d97f42fd591f921d6dffad0", + "sha256:8edc4d687a74d0a5f8b9b26532e860f4f85f56c400b3a98899fc44acb5e27add", + "sha256:942d2cdcb362739908c26ce8dd88db6e139d3fa829dd7452dd9ff02cba6b58b2", + "sha256:9a0669787ba8c9d3bb5de5d9429208882fb47764aa79123af25c5edc4f5966b9", + "sha256:9d08d84bb4128abb9fbd9f073e5c69f70e5dab991a9c42e5b4081ea5b01b5db0", + "sha256:9f7f56b5e85b08774939622b7d45a5d00ff511466522c44fc0756ac7692c00f2", + "sha256:a2daea1cba83210c620e359de2861316f49cc7aea8e9a6979d6cb2ddab6dda8c", + "sha256:b9074d062d30c2779d8af587924f178a539edde5285d961d2dfbecbac9c4c931", + "sha256:c4aa79993f5d856765819a3651117520e41ac3f89c3fc1cb6dee11aa562df6da", + "sha256:d78294f1c20f366cde8a75167f822538a7252b6e8b9d6dbfb3bdab34e7c1929e", + "sha256:dfdc8b53aa9838b9d44ed785431ca47aa3efaa51d0d5dd9c412ab5247151a7c4", + "sha256:dffed17848e8b968d8d3692604e61881aa6ef1f8074c99e81647ac84f6038535", + "sha256:e080087148fd70469aade2abfeadee194357defd759f9b59b349c6192aba994c", + "sha256:e983cbabe10a8989333684c98fdc5dd2f28b236216981e0c26ed359aaa676772", + "sha256:ea6171d2d8d648dee717457d0f75db49ad8c2f13100680e284d7becf3dc311a6", + "sha256:eefc13863bf01583a85e8c1121a901cc7cb8f059b960c4eba30901e2e6aba95f", + "sha256:efd656893171bbf1331beca4ec9f2e74358fc732a2084f664fd149cc4b3441d2" + ], + "version": "==1.19.3" }, "oauthlib": { "hashes": [ - "sha256:0ce32c5d989a1827e3f1148f98b9085ed2370fc939bf524c9c851d8714797298", - "sha256:3e1e14f6cde7e5475128d30e97edc3bfb4dc857cb884d8714ec161fdbb3b358e" + "sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889", + "sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea" ], - "version": "==3.0.1" + "version": "==3.1.0" }, "packaging": { "hashes": [ - "sha256:0c98a5d0be38ed775798ece1b9727178c4469d9c3b4ada66e8e6b7849f8732af", - "sha256:9e1cbf8c12b1f1ce0bb5344b8d7ecf66a6f8a6e91bcb0c84593ed6d3ab5c4ab3" + "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", + "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" + ], + "version": "==20.4" + }, + "pandas": { + "hashes": [ + "sha256:09e0503758ad61afe81c9069505f8cb8c1e36ea8cc1e6826a95823ef5b327daf", + "sha256:0a11a6290ef3667575cbd4785a1b62d658c25a2fd70a5adedba32e156a8f1773", + "sha256:0d9a38a59242a2f6298fff45d09768b78b6eb0c52af5919ea9e45965d7ba56d9", + "sha256:112c5ba0f9ea0f60b2cc38c25f87ca1d5ca10f71efbee8e0f1bee9cf584ed5d5", + "sha256:185cf8c8f38b169dbf7001e1a88c511f653fbb9dfa3e048f5e19c38049e991dc", + "sha256:3aa8e10768c730cc1b610aca688f588831fa70b65a26cb549fbb9f35049a05e0", + "sha256:41746d520f2b50409dffdba29a15c42caa7babae15616bcf80800d8cfcae3d3e", + "sha256:43cea38cbcadb900829858884f49745eb1f42f92609d368cabcc674b03e90efc", + "sha256:5378f58172bd63d8c16dd5d008d7dcdd55bf803fcdbe7da2dcb65dbbf322f05b", + "sha256:54404abb1cd3f89d01f1fb5350607815326790efb4789be60508f458cdd5ccbf", + "sha256:5dac3aeaac5feb1016e94bde851eb2012d1733a222b8afa788202b836c97dad5", + "sha256:5fdb2a61e477ce58d3f1fdf2470ee142d9f0dde4969032edaf0b8f1a9dafeaa2", + "sha256:6613c7815ee0b20222178ad32ec144061cb07e6a746970c9160af1ebe3ad43b4", + "sha256:6d2b5b58e7df46b2c010ec78d7fb9ab20abf1d306d0614d3432e7478993fbdb0", + "sha256:8a5d7e57b9df2c0a9a202840b2881bb1f7a648eba12dd2d919ac07a33a36a97f", + "sha256:8b4c2055ebd6e497e5ecc06efa5b8aa76f59d15233356eb10dad22a03b757805", + "sha256:a15653480e5b92ee376f8458197a58cca89a6e95d12cccb4c2d933df5cecc63f", + "sha256:a7d2547b601ecc9a53fd41561de49a43d2231728ad65c7713d6b616cd02ddbed", + "sha256:a979d0404b135c63954dea79e6246c45dd45371a88631cdbb4877d844e6de3b6", + "sha256:b1f8111635700de7ac350b639e7e452b06fc541a328cf6193cf8fc638804bab8", + "sha256:c5a3597880a7a29a31ebd39b73b2c824316ae63a05c3c8a5ce2aea3fc68afe35", + "sha256:c681e8fcc47a767bf868341d8f0d76923733cbdcabd6ec3a3560695c69f14a1e", + "sha256:cf135a08f306ebbcfea6da8bf775217613917be23e5074c69215b91e180caab4", + "sha256:e2b8557fe6d0a18db4d61c028c6af61bfed44ef90e419ed6fadbdc079eba141e" ], - "version": "==19.0" - }, - "parso": { - "hashes": [ - "sha256:17cc2d7a945eb42c3569d4564cdf49bde221bc2b552af3eca9c1aad517dcdd33", - "sha256:2e9574cb12e7112a87253e14e2c380ce312060269d04bd018478a3c92ea9a376" - ], - "version": "==0.4.0" - }, - "pexpect": { - "hashes": [ - "sha256:2094eefdfcf37a1fdbfb9aa090862c1a4878e5c7e0e7e7088bdb511c558e5cd1", - "sha256:9e2c1fd0e6ee3a49b28f95d4b33bc389c89b20af6a1255906e90ff1262ce62eb" - ], - "markers": "sys_platform != 'win32'", - "version": "==4.7.0" - }, - "pickleshare": { - "hashes": [ - "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca", - "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56" - ], - "version": "==0.7.5" + "index": "pypi", + "version": "==1.1.4" }, "pkginfo": { "hashes": [ - "sha256:7424f2c8511c186cd5424bbf31045b77435b37a8d604990b79d4e70d741148bb", - "sha256:a6d9e40ca61ad3ebd0b72fbadd4fba16e4c0e4df0428c041e01e06eb6ee71f32" + "sha256:a6a4ac943b496745cec21f14f021bbd869d5e9b4f6ec06918cffea5a2f4b9193", + "sha256:ce14d7296c673dc4c61c759a0b6c14bae34e34eb819c0017bb6ca5b7292c56e9" ], - "version": "==1.5.0.1" + "version": "==1.6.1" }, "pluggy": { "hashes": [ - "sha256:25a1bc1d148c9a640211872b4ff859878d422bccb59c9965e04eed468a0aa180", - "sha256:964cedd2b27c492fbf0b7f58b3284a09cf7f99b0f715941fb24a439b3af1bd1a" - ], - "version": "==0.11.0" - }, - "prompt-toolkit": { - "hashes": [ - "sha256:11adf3389a996a6d45cc277580d0d53e8a5afd281d0c9ec71b28e6f121463780", - "sha256:2519ad1d8038fd5fc8e770362237ad0364d16a7650fb5724af6997ed5515e3c1", - "sha256:977c6583ae813a37dc1c2e1b715892461fcbdaa57f6fc62f33a528c4886c8f55" + "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", + "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" ], - "version": "==2.0.9" + "version": "==0.13.1" }, - "ptyprocess": { + "py": { "hashes": [ - "sha256:923f299cc5ad920c68f2bc0bc98b75b9f838b93b599941a6b63ddbc2476394c0", - "sha256:d7cc528d76e76342423ca640335bd3633420dc1366f258cb31d05e865ef5ca1f" + "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", + "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" ], - "version": "==0.6.0" + "version": "==1.9.0" }, - "py": { + "pycparser": { "hashes": [ - "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", - "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53" + "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", + "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" ], - "version": "==1.8.0" + "version": "==2.20" }, "pygments": { "hashes": [ - "sha256:31cba6ffb739f099a85e243eff8cb717089fdd3c7300767d9fc34cb8e1b065f5", - "sha256:5ad302949b3c98dd73f8d9fcdc7e9cb592f120e32a18e23efd7f3dc51194472b" + "sha256:381985fcc551eb9d37c52088a32914e00517e57f4a21609f48141ba08e193fa0", + "sha256:88a0bbcd659fcb9573703957c6b9cff9fab7295e6e76db54c9d00ae42df32773" ], - "version": "==2.4.0" + "version": "==2.7.2" }, "pyparsing": { "hashes": [ - "sha256:1873c03321fc118f4e9746baf201ff990ceb915f433f23b395f5580d1840cb2a", - "sha256:9b6323ef4ab914af344ba97510e966d64ba91055d6b9afa6b30799340e89cc03" + "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", + "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], - "version": "==2.4.0" + "version": "==2.4.7" }, "pytest": { "hashes": [ - "sha256:13c5e9fb5ec5179995e9357111ab089af350d788cbc944c628f3cde72285809b", - "sha256:f21d2f1fb8200830dcbb5d8ec466a9c9120e20d8b53c7585d180125cce1d297a" + "sha256:4288fed0d9153d9646bfcdf0c0428197dba1ecb27a33bb6e031d002fa88653fe", + "sha256:c0a7e94a8cdbc5422a51ccdad8e6f1024795939cc89159a0ae7f0b316ad3823e" ], "index": "pypi", - "version": "==4.4.0" + "version": "==6.1.2" + }, + "python-dateutil": { + "hashes": [ + "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", + "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" + ], + "version": "==2.8.1" }, "pytz": { "hashes": [ - "sha256:303879e36b721603cc54604edcac9d20401bdbe31e1e4fdee5b9f98d5d31dfda", - "sha256:d747dd3d23d77ef44c6a3526e274af6efeb0a6f1afd5a69ba4d5be4098c8e141" + "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed", + "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048" ], - "version": "==2019.1" + "version": "==2020.1" }, "readme-renderer": { "hashes": [ - "sha256:bb16f55b259f27f75f640acf5e00cf897845a8b3e4731b5c1a436e4b8529202f", - "sha256:c8532b79afc0375a85f10433eca157d6b50f7d6990f337fa498c96cd4bfc203d" + "sha256:267854ac3b1530633c2394ead828afcd060fc273217c42ac36b6be9c42cd9a9d", + "sha256:6b7e5aa59210a40de72eb79931491eaf46fefca2952b9181268bd7c7c65c260a" ], - "version": "==24.0" + "version": "==28.0" }, "requests": { "hashes": [ - "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", - "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31" + "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b", + "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898" ], - "version": "==2.22.0" + "version": "==2.24.0" }, "requests-mock": { "hashes": [ - "sha256:7a5fa99db5e3a2a961b6f20ed40ee6baeff73503cf0a553cc4d679409e6170fb", - "sha256:8ca0628dc66d3f212878932fd741b02aa197ad53fd2228164800a169a4a826af" + "sha256:11215c6f4df72702aa357f205cf1e537cffd7392b3e787b58239bde5fb3db53b", + "sha256:e68f46844e4cee9d447150343c9ae875f99fa8037c6dcf5f15bf1fe9ab43d226" ], "index": "pypi", - "version": "==1.5.2" + "version": "==1.8.0" }, "requests-oauthlib": { "hashes": [ - "sha256:bd6533330e8748e94bf0b214775fed487d309b8b8fe823dc45641ebcd9a32f57", - "sha256:d3ed0c8f2e3bbc6b344fa63d6f933745ab394469da38db16bdddb461c7e25140" + "sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d", + "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a", + "sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc" ], "index": "pypi", - "version": "==1.2.0" + "version": "==1.3.0" }, "requests-toolbelt": { "hashes": [ @@ -329,114 +404,56 @@ ], "version": "==0.9.1" }, - "six": { - "hashes": [ - "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", - "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" - ], - "version": "==1.12.0" - }, - "snowballstemmer": { - "hashes": [ - "sha256:919f26a68b2c17a7634da993d91339e288964f93c274f1343e3bbbe2096e1128", - "sha256:9f3bcd3c401c3e862ec0ebe6d2c069ebc012ce142cce209c098ccb5b09136e89" - ], - "version": "==1.2.1" - }, - "sphinx": { - "hashes": [ - "sha256:423280646fb37944dd3c85c58fb92a20d745793a9f6c511f59da82fa97cd404b", - "sha256:de930f42600a4fef993587633984cc5027dedba2464bcf00ddace26b40f8d9ce" - ], - "index": "pypi", - "version": "==2.0.1" - }, - "sphinx-rtd-theme": { + "rfc3986": { "hashes": [ - "sha256:00cf895504a7895ee433807c62094cf1e95f065843bf3acd17037c3e9a2becd4", - "sha256:728607e34d60456d736cc7991fd236afb828b21b82f956c5ea75f94c8414040a" + "sha256:112398da31a3344dc25dbf477d8df6cb34f9278a94fee2625d89e4514be8bb9d", + "sha256:af9147e9aceda37c91a05f4deb128d4b4b49d6b199775fd2d2927768abdc8f50" ], - "index": "pypi", - "version": "==0.4.3" + "version": "==1.4.0" }, - "sphinxcontrib-applehelp": { + "secretstorage": { "hashes": [ - "sha256:edaa0ab2b2bc74403149cb0209d6775c96de797dfd5b5e2a71981309efab3897", - "sha256:fb8dee85af95e5c30c91f10e7eb3c8967308518e0f7488a2828ef7bc191d0d5d" + "sha256:15da8a989b65498e29be338b3b279965f1b8f09b9668bd8010da183024c8bff6", + "sha256:b5ec909dde94d4ae2fa26af7c089036997030f0cf0a5cb372b4cccabd81c143b" ], - "version": "==1.0.1" + "markers": "sys_platform == 'linux'", + "version": "==3.1.2" }, - "sphinxcontrib-devhelp": { - "hashes": [ - "sha256:6c64b077937330a9128a4da74586e8c2130262f014689b4b89e2d08ee7294a34", - "sha256:9512ecb00a2b0821a146736b39f7aeb90759834b07e81e8cc23a9c70bacb9981" - ], - "version": "==1.0.1" - }, - "sphinxcontrib-htmlhelp": { - "hashes": [ - "sha256:4670f99f8951bd78cd4ad2ab962f798f5618b17675c35c5ac3b2132a14ea8422", - "sha256:d4fd39a65a625c9df86d7fa8a2d9f3cd8299a3a4b15db63b50aac9e161d8eff7" - ], - "version": "==1.0.2" - }, - "sphinxcontrib-jsmath": { - "hashes": [ - "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", - "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" - ], - "version": "==1.0.1" - }, - "sphinxcontrib-qthelp": { + "six": { "hashes": [ - "sha256:513049b93031beb1f57d4daea74068a4feb77aa5630f856fcff2e50de14e9a20", - "sha256:79465ce11ae5694ff165becda529a600c754f4bc459778778c7017374d4d406f" + "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", + "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], - "version": "==1.0.2" + "version": "==1.15.0" }, - "sphinxcontrib-serializinghtml": { + "toml": { "hashes": [ - "sha256:c0efb33f8052c04fd7a26c0a07f1678e8512e0faec19f4aa8f2473a8b81d5227", - "sha256:db6615af393650bf1151a6cd39120c29abaf93cc60db8c48eb2dddbfdc3a9768" + "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", + "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" ], - "version": "==1.1.3" + "version": "==0.10.2" }, "tqdm": { "hashes": [ - "sha256:0a860bf2683fdbb4812fe539a6c22ea3f1777843ea985cb8c3807db448a0f7ab", - "sha256:e288416eecd4df19d12407d0c913cbf77aa8009d7fddb18f632aded3bdbdda6b" + "sha256:9ad44aaf0fc3697c06f6e05c7cf025dd66bc7bcb7613c66d85f4464c47ac8fad", + "sha256:ef54779f1c09f346b2b5a8e5c61f96fbcb639929e640e59f8cf810794f406432" ], - "version": "==4.32.1" - }, - "traitlets": { - "hashes": [ - "sha256:9c4bd2d267b7153df9152698efb1050a5d84982d3384a37b2c1f7723ba3e7835", - "sha256:c6cb5e6f57c5a9bdaa40fa71ce7b4af30298fbab9ece9815b5d995ab6217c7d9" - ], - "version": "==4.3.2" + "version": "==4.51.0" }, "twine": { "hashes": [ - "sha256:0fb0bfa3df4f62076cab5def36b1a71a2e4acb4d1fa5c97475b048117b1a6446", - "sha256:d6c29c933ecfc74e9b1d9fa13aa1f87c5d5770e119f5a4ce032092f0ff5b14dc" + "sha256:34352fd52ec3b9d29837e6072d5a2a7c6fe4290e97bba46bb8d478b5c598f7ab", + "sha256:ba9ff477b8d6de0c89dd450e70b2185da190514e91c42cc62f96850025c10472" ], "index": "pypi", - "version": "==1.13.0" + "version": "==3.2.0" }, "urllib3": { "hashes": [ - "sha256:4c291ca23bbb55c76518905869ef34bdd5f0e46af7afe6861e8375643ffee1a0", - "sha256:9a247273df709c4fedb38c711e44292304f73f39ab01beda9f6b9fc375669ac3" - ], - "index": "pypi", - "version": "==1.24.2" - }, - "wcwidth": { - "hashes": [ - "sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e", - "sha256:f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c" + "sha256:8d7eaa5a82a1cac232164990f04874c594c9453ec55eef02eab885aa02fc17a2", + "sha256:f5321fbe4bf3fefa0efd0bfe7fb14e90909eb62a48ccda331726b4319897dd5e" ], - "version": "==0.1.7" + "version": "==1.25.11" }, "webencodings": { "hashes": [ @@ -447,18 +464,10 @@ }, "werkzeug": { "hashes": [ - "sha256:865856ebb55c4dcd0630cdd8f3331a1847a819dda7e8c750d3db6f2aa6c0209c", - "sha256:a0b915f0815982fb2a09161cb8f31708052d0951c3ba433ccc5e1aa276507ca6" + "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43", + "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c" ], - "version": "==0.15.4" - }, - "wheel": { - "hashes": [ - "sha256:66a8fd76f28977bb664b098372daef2b27f60dc4d1688cfab7b37a09448f0e9d", - "sha256:8eb4a788b3aec8abf5ff68d4165441bc57420c9f64ca5f471f58c3969fe08668" - ], - "index": "pypi", - "version": "==0.33.1" + "version": "==1.0.1" } }, "develop": {} diff --git a/README.md b/README.md index dc22b73..720ed9a 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,22 @@ ## Installation -Package is on pypi, so install as follows, or clone the repo and install dependencies using pipenv. +Easiest way is to get it from PyPI: -`pip install oura` or `pipenv install oura` +`pip install oura` ## Getting started -Once you register an application, 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). +Both personal access tokens and oauth flows are supported by the API (and by +this library). For personal use, the simplest way to start is by getting +yourself a PAT and supplying it to a client: + +``` +client = OuraClient(personal_access_token="MY_TOKEN") +``` + +If you are using oauth, there are a few more steps. First, register an application +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). ``` ./token-request.py ``` @@ -45,7 +54,6 @@ oura = OuraClient(, ) oura.user_info() oura.sleep_summary(start='2018-12-05', end='2018-12-10') oura.activity_summary(start='2018-12-25') -oura.readiness_summary() # throws exception since start is None ``` @@ -54,4 +62,22 @@ The `refresh_callback` is a fuction that takes a token dict and saves it somewhe {'token_type': 'bearer', 'refresh_token': , 'access_token': , 'expires_in': 86400, 'expires_at': 1546485086.3277025} ``` +## Working with pandas +You can also make requests and have the data converted to pandas dataframes by +using the pandas client. Some customization is available but subject to +future improvement. + +``` +client = OuraClientDataFrame(...) +bedtime = client.bedtime_df(start, end, convert=True) + +In [3]: client.bedtime_df() +Out[3]: + bedtime_window status + date + 2020-03-17 {'start': -3600, 'end': 0} IDEAL_BEDTIME_AVAILABLE + 2020-03-18 {'start': None, 'end': None} LOW_SLEEP_SCORES +``` + + Live your life. diff --git a/docs/Makefile b/docs/Makefile index 298ea9e..994c9f5 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -16,4 +16,7 @@ help: # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +clean: + rm -rf _build diff --git a/docs/api.rst b/docs/api.rst index e820fe3..d7e403e 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -6,12 +6,20 @@ API Purpose ================ -Reference for full api surface. +Reference for api surface. -Client +Module Index ================ .. automodule:: oura.client :synopsis: Probably the best way to call the Oura API using python. :members: +.. automodule:: oura.client_pandas + :synopsis: Extends the client by providing pandas functionality. + :members: + +.. automodule:: oura.writers + :synopsis: Various ways to export data (excel, console, etc). + :members: + diff --git a/docs/auth.rst b/docs/auth.rst index 737fdb6..5984813 100644 --- a/docs/auth.rst +++ b/docs/auth.rst @@ -3,7 +3,11 @@ Authentication and Authorization ******************************** -Oura uses OAuth2 to allow a user to grant access to their data. +There are two choices for auth: + +* oauth2 for making requests on behalf of other users +* personal access tokens, which are unsurprisingly for personal use + See the `official documentation `_ @@ -26,4 +30,19 @@ In following the standard flow, you would have some code under your `/callback` token_response = auth_client.fetch_access_token(code=code) -Now you are ready to make authenticated API requests. Please use this power responsibly. \ No newline at end of file +Now you are ready to make authenticated API requests. Please use this power responsibly. + +Personal Access Token +===================== + +You can also access your own data using a personal_access_token - get one from +the cloud portal and save the value somewhere, like an environment variable. Or +somewhere else, it's your token anyway. Then just pass it to a new +:class:`oura.OuraClient` instance and you'll be ready to go. See what I mean :: + + import os + from oura import OuraClient + my_token = os.getenv('MY_TOKEN') + client = OuraClient(personal_access_token=my_token) + who_am_i = client.user_info() + diff --git a/docs/conf.py b/docs/conf.py index d09f863..6d4a56d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,7 +20,7 @@ # -- Project information ----------------------------------------------------- project = 'python-oura' -copyright = '2019, Jon Hagg' +copyright = '2020, Jon Hagg' author = 'Jon Hagg' # The short X.Y version diff --git a/docs/requirements.txt b/docs/requirements.txt index f351768..a0373ff 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,26 +1,6 @@ -alabaster==0.7.12 -Babel==2.6.0 -certifi==2018.11.29 -chardet==3.0.4 -Click==7.0 -docutils==0.14 -Flask==1.0.2 -idna==2.8 -imagesize==1.1.0 -itsdangerous==1.1.0 -Jinja2==2.10.1 -MarkupSafe==1.1.0 -oauthlib==2.1.0 -packaging==18.0 -Pygments==2.3.1 -pyparsing==2.3.0 -pytz==2018.7 -requests==2.21.0 -requests-oauthlib==1.0.0 -six==1.12.0 -snowballstemmer==1.2.1 -Sphinx==1.8.3 -sphinx-rtd-theme==0.4.2 -sphinxcontrib-websupport==1.1.0 -urllib3==1.24.2 -Werkzeug==0.15.3 +sphinx +sphinx-rtd-theme +pandas +pytest +requests-mock +requests-oauthlib diff --git a/docs/summaries.rst b/docs/summaries.rst index 23f0c55..5cbc841 100644 --- a/docs/summaries.rst +++ b/docs/summaries.rst @@ -3,16 +3,16 @@ Daily summaries ******************************** -Oura's API is based on the idea of daily summaries. For each kind of data (sleep, activity, readiness) -there is an endpoint which will return summaries for one or more day. They take a start and end date in the query string, -but if you only supply the start date you'll get back data for just that day. +Oura's API is based on the idea of daily summaries. For each kind of data (sleep, activity, readiness, bedtime) +there is an endpoint which will return summaries for one or more day. They each +take an optional start date and end date (YYYY-MM-DD). -See the `official documentation `_ +See the `official documentation `_ for behavior regarding the dates. Usage ======================== -If you just want to make some requests, it's fairly easy. Just do this:: +If you just want to make some requests, it's fairly easy. Just do this :: from oura import OuraClient oura = OuraClient(client_id=MY_CLIENT_ID, access_token=ACCESS_TOKEN) @@ -34,7 +34,7 @@ For example:: client = OuraClient(client_id=MY_CLIENT_ID, client_secret=MY_CLIENT_SECRET, access_token, refresh_token, refresh_callback=save_token_to_db) -Now you are ready to get all the data, provided the user has granted you the required scopes.:: +Now you are ready to get all the data, provided the user has granted you the required scopes. :: from datetime import date today = str(date.today()) # 2019-01-06, e,g, YYYY-MM-DD, or use whatever start/end date you want diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..18a5368 --- /dev/null +++ b/noxfile.py @@ -0,0 +1,48 @@ +import nox + +nox.options.sessions = "lint", "tests" +locations = ["oura", "tests", "samples"] + + +@nox.session +def tests(session): + args = session.posargs + session.install("pipenv") + session.run("pipenv", "sync") + session.run("pipenv", "run", "pytest", *args) + + +@nox.session +def lint(session): + args = session.posargs or locations + session.install("flake8", "black", "isort") + session.run("flake8", *args) + session.run("black", "--check", "--diff", *args) + session.run("isort", "-m", "3", "--tc", "--check", "--diff", *args) + + +@nox.session +def format(session): + black(session) + isort(session) + + +def black(session): + args = session.posargs or locations + session.install("black") + session.run("black", *args) + + +def isort(session): + args = session.posargs or locations + session.install("isort") + session.run("isort", "-m", "3", "--tc", *args) + + +@nox.session +def docs(session): + session.chdir("docs") + session.install("-r", "requirements.txt") + # session.run("sphinx-apidoc", "-f", "-o", "source", "../oura") + # session.run("make", "clean", external=True) + session.run("make", "html", external=True) diff --git a/oura/__init__.py b/oura/__init__.py index 0d67fa0..fb90c58 100644 --- a/oura/__init__.py +++ b/oura/__init__.py @@ -6,7 +6,13 @@ ------------------ -It's a description for __init__.py, innit. +Welcome to the python oura library! + +For more information, please check the github: + https://github.com/turing-complet/python-ouraring + """ -from .client import OuraClient, OuraOAuth2Client \ No newline at end of file +from .auth import OAuthRequestHandler, OuraOAuth2Client, PersonalRequestHandler +from .client import OuraClient +from .client_pandas import OuraClientDataFrame diff --git a/oura/auth.py b/oura/auth.py new file mode 100644 index 0000000..eaf9bd5 --- /dev/null +++ b/oura/auth.py @@ -0,0 +1,112 @@ +import requests +from requests_oauthlib import OAuth2Session + + +class OuraOAuth2Client: + """ + Use this for authorizing user and obtaining initial access and refresh token. + Should be one time usage per user. + """ + + AUTHORIZE_BASE_URL = "https://cloud.ouraring.com/oauth/authorize" + TOKEN_BASE_URL = "https://api.ouraring.com/oauth/token" + SCOPE = ["email", "personal", "daily"] + + def __init__(self, client_id, client_secret): + + """ + Initialize the client for oauth flow. + + :param client_id: The client id from oura portal. + :type client_id: str + :param client_secret: The client secret from oura portal. + :type client_secret: str + """ + self.client_id = client_id + self.client_secret = client_secret + + self.session = OAuth2Session( + client_id, + auto_refresh_url=self.TOKEN_BASE_URL, + ) + + def authorize_endpoint(self, scope=None, redirect_uri=None, **kwargs): + """ + Build the authorization url for a user to click. + + :param scope: Scopes to request from the user. Defaults to self.SCOPE + :type scope: str + :param redirect_uri: Where to redirect after user grants access. + :type redirect_uri: str + """ + self.session.scope = scope or self.SCOPE + if redirect_uri: + self.session.redirect_uri = redirect_uri + return self.session.authorization_url(self.AUTHORIZE_BASE_URL, **kwargs) + + def fetch_access_token(self, code): + """ + Exchange the auth code for an access and refresh token. + + :param code: Authorization code from query string + :type code: str + """ + return self.session.fetch_token( + self.TOKEN_BASE_URL, code=code, client_secret=self.client_secret + ) + + +class OAuthRequestHandler: + TOKEN_BASE_URL = "https://api.ouraring.com/oauth/token" + + def __init__( + self, + client_id, + client_secret=None, + access_token=None, + refresh_token=None, + refresh_callback=None, + ): + + self.client_id = client_id + self.client_secret = client_secret + + token = {} + if access_token: + token["access_token"] = access_token + if refresh_token: + token["refresh_token"] = refresh_token + + self._session = OAuth2Session( + client_id, + token=token, + auto_refresh_url=self.TOKEN_BASE_URL, + token_updater=refresh_callback, + ) + + def make_request(self, url): + method = "GET" + response = self._session.request(method, url) + if response.status_code == 401: + self._refresh_token() + response = self._session.request(method, url) + return response + + def _refresh_token(self): + token = self._session.refresh_token( + self.TOKEN_BASE_URL, + client_id=self.client_id, + client_secret=self.client_secret, + ) + if self._session.token_updater: + self._session.token_updater(token) + + return token + + +class PersonalRequestHandler: + def __init__(self, personal_access_token): + self.personal_access_token = personal_access_token + + def make_request(self, url): + return requests.get(url, params={"access_token": self.personal_access_token}) diff --git a/oura/client.py b/oura/client.py index 3e96d37..8d85c65 100644 --- a/oura/client.py +++ b/oura/client.py @@ -1,192 +1,139 @@ - -from requests_oauthlib import OAuth2Session -from . import exceptions import json -import requests - -class OuraOAuth2Client: - """ - Use this for authorizing user and obtaining initial access and refresh token. - Should be one time usage per user. - """ - - AUTHORIZE_BASE_URL = "https://cloud.ouraring.com/oauth/authorize" - TOKEN_BASE_URL = "https://api.ouraring.com/oauth/token" - SCOPE = ["email", "personal", "daily"] - - def __init__(self, client_id, client_secret): - - """ - Initialize the client for oauth flow. - - :param client_id: The client id from oura portal. - :type client_id: str - :param client_secret: The client secret from oura portal. - :type client_secret: str - """ - self.client_id = client_id - self.client_secret = client_secret - self.session = OAuth2Session( - client_id, - auto_refresh_url=self.TOKEN_BASE_URL, - ) - - - def authorize_endpoint(self, scope=None, redirect_uri=None, **kwargs): - """ - Build the authorization url for a user to click. - - :param scope: Scopes to request from the user. Defaults to self.SCOPE - :type scope: str - :param redirect_uri: Where to redirect after user grants access. - :type redirect_uri: str - """ - self.session.scope = scope or self.SCOPE - if redirect_uri: - self.session.redirect_uri = redirect_uri - return self.session.authorization_url(self.AUTHORIZE_BASE_URL, **kwargs) - - - def fetch_access_token(self, code): - """ - Exchange the auth code for an access and refresh token. - - :param code: Authorization code from query string - :type code: str - """ - return self.session.fetch_token( - self.TOKEN_BASE_URL, - code=code, - client_secret = self.client_secret) +from . import OAuthRequestHandler, PersonalRequestHandler, exceptions class OuraClient: - """ - Use this class for making requests on behalf of a user. If refresh_token and expires_at are supplied, - access_token should be refreshed automatically and passed to the refresh_callback function, along with - other properties in the response. + """Make requests to Oura's API. Provide either oauth client and token + information to make requests on behalf of users, or a personal access token + to access your own data. """ API_ENDPOINT = "https://api.ouraring.com" - TOKEN_BASE_URL = "https://api.ouraring.com/oauth/token" - def __init__(self, client_id, client_secret=None, access_token=None, refresh_token=None, refresh_callback=None): + def __init__( + self, + client_id=None, + client_secret=None, + access_token=None, + refresh_token=None, + refresh_callback=None, + personal_access_token=None, + ): """ - Initialize the client - - :param client_id: The client id from oura portal. + :param client_id: The client id - identifies your application. :type client_id: str - :param client_secret: The client secret from oura portal. Required for auto refresh. + :param client_secret: The client secret. Required for auto refresh. :type client_secret: str - :param access_token: Auth token. + :param access_token: Access token. :type access_token: str :param refresh_token: Use this to renew tokens when they expire :type refresh_token: str - :param refresh_callback: Method to save the access token, refresh token, expires at + :param refresh_callback: Callback to handle token response :type refresh_callback: callable - """ + :param personal_access_token: Token used for accessing personal data + :type personal_access_token: str - self.client_id = client_id - self.client_secret = client_secret - token = {} - if access_token: - token.update({ 'access_token': access_token }) - if refresh_token: - token.update({ 'refresh_token': refresh_token }) + """ - self._session = OAuth2Session( - client_id, - token=token, - auto_refresh_url=self.TOKEN_BASE_URL, - token_updater=refresh_callback - ) + if client_id is not None: + self._auth_handler = OAuthRequestHandler( + client_id, client_secret, access_token, refresh_token, refresh_callback + ) + if personal_access_token is not None: + self._auth_handler = PersonalRequestHandler(personal_access_token) def user_info(self): """ - Returns information about the logged in user (who the access token was issued for). + Returns information about the current user. See https://cloud.ouraring.com/docs/personal-info """ url = "{}/v1/userinfo".format(self.API_ENDPOINT) return self._make_request(url) - def sleep_summary(self, start=None, end=None): """ - Get sleep summary for the given date range. See https://cloud.ouraring.com/docs/sleep + Get sleep summary for the given date range. See + https://cloud.ouraring.com/docs/sleep - :param start: Beginning of date range - :type start: date + :param start: Beginning of date range, YYYY-MM-DD + :type start: str :param end: End of date range, or None if you want the current day. - :type end: date + :type end: str, optional """ - url = self._build_summary_url(start, end, "sleep") - return self._make_request(url) - + return self._get_summary(start, end, "sleep") def activity_summary(self, start=None, end=None): """ - Get activity summary for the given date range. See https://cloud.ouraring.com/docs/activity + Get activity summary for the given date range. + See https://cloud.ouraring.com/docs/activity - :param start: Beginning of date range - :type start: date + :param start: Beginning of date range, YYYY-MM-DD + :type start: str :param end: End of date range, or None if you want the current day. - :type end: date + :type end: str, optional """ - url = self._build_summary_url(start, end, "activity") - return self._make_request(url) - + return self._get_summary(start, end, "activity") def readiness_summary(self, start=None, end=None): """ - Get readiness summary for the given date range. See https://cloud.ouraring.com/docs/readiness + Get readiness summary for the given date range. See + https://cloud.ouraring.com/docs/readiness - :param start: Beginning of date range - :type start: date + :param start: Beginning of date range, YYYY-MM-DD + :type start: str :param end: End of date range, or None if you want the current day. - :type end: date + :type end: str, optional """ - url = self._build_summary_url(start, end, "readiness") - return self._make_request(url) - - - def _make_request(self, url, data=None, method=None, **kwargs): - data = data or {} - method = method or 'GET' - response = self._session.request(method, url, data=data, **kwargs) - if response.status_code == 401: - self._refresh_token() - response = self._session.request(method, url, data=data, **kwargs) - - exceptions.detect_and_raise_error(response) - payload = json.loads(response.content.decode('utf8')) - return payload + return self._get_summary(start, end, "readiness") + def bedtime_summary(self, start=None, end=None): + """ + Get bedtime summary for the given date range. See + https://cloud.ouraring.com/docs/bedtime - def _build_summary_url(self, start, end, datatype): - if start is None: - raise ValueError("Request for {} summary must include start date.".format(datatype)) + :param start: Beginning of date range, YYYY-MM-DD + :type start: str - url = "{0}/v1/{1}?start={2}".format(self.API_ENDPOINT, datatype, start) - if end: - url = "{0}&end={1}".format(url, end) - return url + :param end: End of date range, or None if you want the current day. + :type end: str, optional + """ + return self._get_summary(start, end, "bedtime") + def _get_summary(self, start, end, summary_type): + url = self._build_summary_url(start, end, summary_type) + return self._make_request(url) - def _refresh_token(self): - token = self._session.refresh_token(self.TOKEN_BASE_URL, client_id=self.client_id, client_secret=self.client_secret) - if self._session.token_updater: - self._session.token_updater(token) + def _make_request(self, url): + response = self._auth_handler.make_request(url) + exceptions.detect_and_raise_error(response) + payload = json.loads(response.content.decode("utf8")) + return payload - return token \ No newline at end of file + def _build_summary_url(self, start, end, summary_type): + url = "{0}/v1/{1}".format(self.API_ENDPOINT, summary_type) + params = {} + if start is not None: + if not isinstance(start, str): + raise TypeError("start date must be of type str") + params["start"] = start + + if end is not None: + if not isinstance(end, str): + raise TypeError("end date must be of type str") + params["end"] = end + + qs = "&".join([f"{k}={v}" for k, v in params.items()]) + url = f"{url}?{qs}" + return url diff --git a/oura/client_pandas.py b/oura/client_pandas.py new file mode 100644 index 0000000..cfae0ee --- /dev/null +++ b/oura/client_pandas.py @@ -0,0 +1,204 @@ +import pandas as pd + +from .client import OuraClient +from .converters import ActivityConverter, SleepConverter, UnitConverter + + +def to_pandas(summary, metrics=None, date_key="summary_date"): + """ + Creates a dataframe from a summary object + + :param summary: A summary object returned from API + :type summary: dictionary of dictionaries. See https://cloud.ouraring.com/docs/readiness for an example + + :param metrics: The metrics to include in the DF. None includes all metrics + :type metrics: A list of metric names, or alternatively a string for one metric name + + :param date_key: Column to set as the index, mainly for internal use + :type date_key: str + """ + + if isinstance(summary, dict): + summary = [summary] + df = pd.DataFrame(summary) + if df.size == 0: + return df + if metrics is not None: + if type(metrics) == str: + metrics = [metrics] + else: + metrics = metrics.copy() + # drop any invalid cols the user may have entered + metrics = [m for m in metrics if m in df.columns] + + # always include summary_date (or date_key, as for bedtime) + if date_key not in metrics: + metrics.insert(0, date_key) + + df = df[metrics] + df[date_key] = pd.to_datetime(df[date_key]).dt.date + df = df.set_index(date_key) + return df + + +class OuraClientDataFrame(OuraClient): + """ + Similiar to OuraClient, but data is returned instead + as a pandas.DataFrame object. Each row will correspond to a single day + of data, indexed by the date. + + Methods that have a `convert` paramter will apply + transformations to a set of columns by default. This can be + overridden by passing in a specific set of columns to convert, or disabled + entirely by passing `convert=False` + """ + + def __init__( + self, + client_id=None, + client_secret=None, + access_token=None, + refresh_token=None, + refresh_callback=None, + personal_access_token=None, + ): + super().__init__( + client_id, + client_secret, + access_token, + refresh_token, + refresh_callback, + personal_access_token, + ) + + def sleep_df( + self, start=None, end=None, metrics=None, convert=True, convert_cols=None + ): + """ + Create a dataframe from sleep summary dict object. + + :param start: Beginning of date range + :type start: string representation of a date i.e. '2020-10-31' + + :param end: End of date range, or None if you want the current day. + :type end: string representation of a date i.e. '2020-10-31' + + :param metrics: Metrics to include in the df. + :type metrics: A list of strings, or a string + + :param convert: Whether to convert datetime columns to pandas types + :type convert: bool + + :param convert_cols: If convert is True, a set of columns to convert, + or None for the default. Currently supported column types include + datetime, timespan, and hypnogram + :type convert_cols: list + """ + sleep_summary = super().sleep_summary(start, end)["sleep"] + df = to_pandas(sleep_summary, metrics) + if convert: + return SleepConverter(convert_cols).convert_metrics(df) + return df + + def activity_df( + self, start=None, end=None, metrics=None, convert=True, convert_cols=None + ): + """ + Create a dataframe from activity summary dict object. + + :param start: Beginning of date range + :type start: string representation of a date i.e. '2020-10-31' + + :param end: End of date range, or None if you want the current day. + :type end: string representation of a date i.e. '2020-10-31' + + :param metrics: Metrics to include in the df. + :type metrics: A list of strings, or a string + + :param convert: Whether to convert datetime columns to pandas types + :type convert: bool + + :param convert_cols: If convert is True, a set of columns to convert, + or None for the default. Currently supported column types include + datetime. + :type convert_cols: list + """ + activity_summary = super().activity_summary(start, end)["activity"] + df = to_pandas(activity_summary, metrics) + if convert: + return ActivityConverter(convert_cols).convert_metrics(df) + return df + + def readiness_df(self, start=None, end=None, metrics=None): + """ + Create a dataframe from ready summary dict object. + + :param start: Beginning of date range + :type start: string representation of a date i.e. '2020-10-31' + + :param end: End of date range, or None if you want the current day. + :type end: string representation of a date i.e. '2020-10-31' + + :param metrics: Metrics to include in the df. + :type metrics: A list of strings, or a string + """ + readiness_summary = super().readiness_summary(start, end)["readiness"] + return to_pandas(readiness_summary, metrics) + + def bedtime_df(self, start=None, end=None, metrics=None): + """ + Create a dataframe from bedtime summary + + :param start: Beginning of date range + :type start: string representation of a date i.e. '2020-10-31' + + :param end: End of date range, or None if you want the current day. + :type end: string representation of a date i.e. '2020-10-31' + + :param metrics: Metrics to include in the df. + :type metrics: A list of strings, or a string + """ + + bedtime_summary = super().bedtime_summary(start, end)["ideal_bedtimes"] + return to_pandas(bedtime_summary, metrics, date_key="date") + + # TODO: use multi index instead of prefix? + def combined_df_edited(self, start=None, end=None, metrics=None): + """ + Combines sleep, activity, and summary into one DF + Some cols are unit converted for easier use or readability. + + If user specifies a metric that appears in all 3 summaries, + i.e. 'score', then all 3 metrics will be returned. + + Each summary's column is prepended with the summary name. + i.e. sleep summary 'total' metric will be re-named 'SLEEP.total' + + :param start: Beginning of date range + :type start: string representation of a date i.e. '2020-10-31' + + :param end: End of date range, or None if you want the current day. + :type end: string representation of a date i.e. '2020-10-31' + + :param metrics: Metrics to include in the df. + :type metrics: A list of strings, or a string + """ + + def prefix_cols(df, prefix): + d_to_rename = {} + for col in df.columns: + if col != "summary_date": + d_to_rename[col] = prefix + ":" + col + return df.rename(columns=d_to_rename) + + sleep_df = self.sleep_df(start, end, metrics) + sleep_df = prefix_cols(sleep_df, "SLEEP") + readiness_df = self.readiness_df(start, end, metrics) + readiness_df = prefix_cols(readiness_df, "READY") + activity_df = self.activity_df(start, end, metrics) + activity_df = prefix_cols(activity_df, "ACTIVITY") + + combined_df = sleep_df.merge(readiness_df, on="summary_date").merge( + activity_df, on="summary_date" + ) + return combined_df diff --git a/oura/converters.py b/oura/converters.py new file mode 100644 index 0000000..f25f3d6 --- /dev/null +++ b/oura/converters.py @@ -0,0 +1,127 @@ +import pandas as pd + + +class UnitConverter: + """ + Use this class to convert units for certain dataframe cols + + :param convert_cols: A set of columns to apply predefined conversions + :type convert_cols: list/set + """ + + all_dt_metrics = [] + all_sec_metrics = [] + all_metrics = all_dt_metrics + all_sec_metrics + + def __init__(self, convert_cols=None): + if convert_cols is not None: + convert_cols = set(convert_cols) + defaults = set(self.all_metrics) + invalid = convert_cols - defaults + if any(invalid): + print(f"Ignoring metrics with no conversion: {invalid}") + self.convert_cols = list(convert_cols & defaults) + else: + self.convert_cols = self.all_metrics + + def _rename_converted_cols(self, df, metrics, suffix_str): + """ + Rename converted cols by adding a suffix to the col name + For example, 'bedtime_start' becomes 'bedtime_start_dt_adjusted' + + :param df: a dataframe + :type df: pandas dataframe obj + + :param metrics: metrics to rename + :type metrics: list of strings + + :param suffix_str: the str to append to each metric name + :type suffix_str: str + """ + updated_headers = [header + suffix_str for header in metrics] + d_to_rename = dict(zip(metrics, updated_headers)) + df = df.rename(columns=d_to_rename) + return df + + def _convert_to_dt(self, df, dt_metrics): + """ + Convert dataframe fields to datetime dtypes + + :param df: dataframe + :type df: pandas dataframe obj + + :param dt_metrics: List of metrics to be converted to datetime + :type dt_metrics: List + """ + for i, dt_metric in enumerate(dt_metrics): + df[dt_metric] = pd.to_datetime(df[dt_metric], format="%Y-%m-%d %H:%M:%S") + df = self._rename_converted_cols(df, dt_metrics, "_dt_adjusted") + return df + + def _convert_to_hrs(self, df, sec_metrics): + """ + Convert fields from seconds to minutes + + :param df: dataframe + :type df: pandas dataframe obj + + :param sec_metrics: List of metrics to be converted from sec -> hrs + :type sec_metrics: List + """ + df[sec_metrics] = df[sec_metrics] / 60 / 60 + df = self._rename_converted_cols(df, sec_metrics, "_in_hrs") + return df + + def _select_cols(self, df, subset): + return [c for c in df.columns if c in set(subset) & set(self.convert_cols)] + + def convert_metrics(self, df): + """ + Convert metrics to new unit type + + :param df: dataframe + :type df: pandas dataframe obj + """ + dt_metrics = self._select_cols(df, self.all_dt_metrics) + df = self._convert_to_dt(df, dt_metrics) + + sec_metrics = self._select_cols(df, self.all_sec_metrics) + df = self._convert_to_hrs(df, sec_metrics) + return df + + +class SleepConverter(UnitConverter): + all_dt_metrics = ["bedtime_end", "bedtime_start"] + all_sec_metrics = [ + "awake", + "deep", + "duration", + "light", + "onset_latency", + "rem", + "total", + ] + hypnogram_5min = ["hypnogram_5min"] + all_metrics = all_dt_metrics + all_sec_metrics + hypnogram_5min + + def convert_hypnogram_helper(self, hypnogram): + d = {"1": "D", "2": "L", "3": "R", "4": "A"} + return "".join(list(map(lambda h: d[h], hypnogram))) + + def convert_hypnogram(self, sleep_df): + if "hypnogram_5min" in sleep_df.columns: + sleep_df["hypnogram_5min"] = sleep_df["hypnogram_5min"].apply( + self.convert_hypnogram_helper + ) + return sleep_df + + def convert_metrics(self, df): + df = super().convert_metrics(df) + if "hypnogram_5min" in self.convert_cols: + df = self.convert_hypnogram(df) + return df + + +class ActivityConverter(UnitConverter): + all_dt_metrics = ["day_end", "day_start"] + all_metrics = all_dt_metrics diff --git a/oura/exceptions.py b/oura/exceptions.py index f7c34b9..9cfef33 100644 --- a/oura/exceptions.py +++ b/oura/exceptions.py @@ -1,64 +1,66 @@ import json + class Timeout(Exception): """ Used when a timeout occurs. """ + pass class HTTPException(Exception): def __init__(self, response, *args, **kwargs): try: - errors = json.loads(response.content.decode('utf8'))['errors'] - message = '\n'.join([error['message'] for error in errors]) + errors = json.loads(response.content.decode("utf8"))["errors"] + message = "\n".join([error["message"] for error in errors]) except Exception: - if hasattr(response, 'status_code') and response.status_code == 401: - message = response.content.decode('utf8') + if hasattr(response, "status_code") and response.status_code == 401: + message = response.content.decode("utf8") else: message = response super(HTTPException, self).__init__(message, *args, **kwargs) class HTTPBadRequest(HTTPException): - """Generic >= 400 error - """ + """Generic >= 400 error""" + pass class HTTPUnauthorized(HTTPException): - """401 - """ + """401""" + pass class HTTPForbidden(HTTPException): - """403 - """ + """403""" + pass class HTTPNotFound(HTTPException): - """404 - """ + """404""" + pass class HTTPConflict(HTTPException): - """409 - returned when creating conflicting resources - """ + """409 - returned when creating conflicting resources""" + pass class HTTPTooManyRequests(HTTPException): - """429 - returned when exceeding rate limits - """ + """429 - returned when exceeding rate limits""" + pass class HTTPServerError(HTTPException): - """Generic >= 500 error - """ + """Generic >= 500 error""" + pass @@ -73,7 +75,7 @@ def detect_and_raise_error(response): raise HTTPConflict(response) elif response.status_code == 429: exc = HTTPTooManyRequests(response) - exc.retry_after_secs = int(response.headers['Retry-After']) + exc.retry_after_secs = int(response.headers["Retry-After"]) raise exc elif response.status_code >= 500: raise HTTPServerError(response) diff --git a/oura/models/activity.py b/oura/models/activity.py deleted file mode 100644 index 9ae572d..0000000 --- a/oura/models/activity.py +++ /dev/null @@ -1,75 +0,0 @@ - -from helper import OuraModel, from_json - -class Activity(OuraModel): - _KEYS = [ - "summary_date", - "day_start", - "day_end", - "timezone", - "score", - "score_stay_active", - "score_move_every_hour", - "score_meet_daily_targets", - "score_training_frequency", - "score_training_volume", - "score_recovery_time", - "daily_movement", - "non_wear", - "rest", - "inactive", - "inactivity_alerts", - "low", - "medium", - "high", - "steps", - "cal_total", - "cal_active", - "met_min_inactive", - "met_min_low", - "met_min_medium_plus", - "met_min_medium", - "met_min_high", - "average_met", - "class_5min", - "met_1min" - ] - - -if __name__ == '__main__': - test = """ -{ - "summary_date": "2016-09-03", - "day_start": "2016-09-03T04:00:00+03:00", - "day_end": "2016-09-04T03:59:59+03:00", - "timezone": 180, - "score": 87, - "score_stay_active": 90, - "score_move_every_hour": 100, - "score_meet_daily_targets": 60, - "score_training_frequency": 96, - "score_training_volume": 95, - "score_recovery_time": 100, - "daily_movement": 7806, - "non_wear": 313, - "rest": 426, - "inactive": 429, - "inactivity_alerts": 0, - "low": 224, - "medium": 49, - "high": 0, - "steps": 9206, - "cal_total": 2540, - "cal_active": 416, - "met_min_inactive": 9, - "met_min_low": 167, - "met_min_medium_plus": 159, - "met_min_medium": 159, - "met_min_high": 0, - "average_met": 1.4375, - "class_5min":"1112211111111111111111111111111111111111111111233322322223333323322222220000000000000000000000000000000000000000000000000000000233334444332222222222222322333444432222222221230003233332232222333332333333330002222222233233233222212222222223121121111222111111122212321223211111111111111111", - "met_1min": [ 1.2,1.1,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,1.1,0.9,0.9,0.9,0.9,1.2,0.9,1.1,1.2,1.1,1.1,0.9,0.9,0.9,1.1,0.9,0.9,1.1,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,1.1,0.9,1.2,0.9,1.1,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,1.3,0.9,1.1,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,1.3,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,1.1,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,1.2,0.9,0.9,0.9,1.1,0.9,1.1,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,1.1,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,1.9,2.7,2.8,1.6,1.8,1.5,1.5,1.8,1.6,1.9,1.4,1.9,1.4,1.5,1.7,1.7,1.4,1.5,1.5,1.7,1.3,1.7,1.7,1.9,1.5,1.4,1.8,2.2,1.4,1.6,1.7,1.7,1.4,1.5,1.6,1.4,1.4,1.7,1.6,1.3,1.3,1.4,1.3,2.6,1.6,1.7,1.5,1.6,1.6,1.8,1.9,1.8,1.7,2,1.8,2,1.7,1.5,1.3,2.4,1.4,1.6,2,2.8,1.8,1.5,1.8,1.6,1.5,1.8,1.8,1.4,1.6,1.7,1.7,1.6,1.5,1.5,1.8,1.8,1.7,1.8,1.8,1.5,2.4,1.9,1.3,1.2,1.4,1.3,1.5,1.2,1.4,1.4,1.6,1.5,1.6,1.4,1.4,1.6,1.6,1.6,1.8,1.7,1.3,1.9,1.3,1.2,1.2,1.3,1.5,1.4,1.4,1.3,1.7,1.2,1.3,1.5,1.7,1.5,2.6,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.9,3.6,0.9,0.1,0.1,0.1,0.1,0.1,3.3,3.8,3.6,2.3,3.1,3.2,3.5,4.3,3.6,1.7,1.6,2.8,2.1,3.3,4.9,3.3,1.8,5,4.6,5.3,4.9,4.9,5.4,5.4,5.2,5.3,4.5,5.3,4.5,4.4,5,5.3,4.8,4.6,1.8,4.4,3.6,3.5,2.9,2.6,3.1,0.9,0.1,2.9,3.8,1.7,2.8,1.8,1.5,1.4,1.4,1.3,1.4,1.3,1.4,1.3,1.3,1.2,1.3,1.6,1.5,1.5,1.4,1.8,1.3,1.4,1.3,1.4,1.6,1.6,1.4,1.3,1.4,1.4,1.6,1.5,1.4,2,1.5,1.4,1.4,1.3,1.2,1.3,1.3,1.6,1.6,1.5,1.5,1.8,1.5,1.2,1.2,1.5,1.6,1.5,1.7,1.7,1.5,1.6,2.5,1.5,1.3,1.2,1.4,1.6,1.3,1.6,1.7,2,1.2,1.3,1.9,3.3,2.8,1.7,1.4,1.4,1.4,1.5,1.4,1.5,1.3,2,1.4,1.2,1.5,1.2,1.2,1.8,2.4,3,4.6,4,3.6,2.2,0.9,4,3.3,2.6,4.4,2.3,4.5,5.2,5.2,5,5.3,5,4.6,5.4,5.7,5.5,5.2,5.5,3.8,5,5,4.4,4.8,5.5,4.1,4.5,3.2,3.3,2.6,4,3.4,2.1,1.5,1.5,1.4,1.4,1.5,1.3,1.3,1.5,1.4,1.2,1.2,1.4,1.2,1.2,1.2,1.2,1.1,1.3,1.6,1.8,1.5,1.3,1.5,1.5,1.6,1.5,1.6,1.4,1.4,1.4,1.3,1.3,1.3,1.3,1.2,1.3,1.2,1.2,1.2,0.9,1.1,1.1,1.1,1.1,1.7,1.1,0.9,0.9,0.9,1.1,1.1,0.9,1.1,0.9,1.2,1.3,2.4,2.2,1.6,0.9,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,2.4,2.7,1.3,1.4,1.3,1.2,1.3,1.2,1.4,1.4,2.2,1.7,2.9,1.3,1.4,1.2,1.3,1.8,2.1,2.2,2.5,1.9,2.3,2.7,2.3,2,1.7,2,2.1,1.7,1.8,1.2,1.2,0.9,0.9,1.3,1.4,1.2,1.6,1.7,2.4,2.4,2,1.2,1.3,1.3,1.2,1.3,2.4,1.2,1.2,1.3,2,1.3,1.8,1.2,1.2,1.2,1.2,1.8,1.7,1.3,1.3,1.6,1.8,2.2,1.3,1.5,1.5,1.8,1.3,1.7,1.8,2.1,2,1.9,1.6,2,1.8,2,1.6,1.2,1.7,1.5,1.5,2.3,2.6,3.3,3.3,1.5,1.2,1.3,1.5,1.3,1.5,1.5,3.7,2.4,3.3,3,3.7,4.5,2.8,1.3,1.9,2.2,1.6,1.3,1.2,1.3,1.3,2.9,3.3,2,2.2,2.6,2.7,4.5,3.2,4.5,3.3,2.1,3.4,3,2.7,3.3,2.1,2.3,1.7,1.7,2.8,0.9,2.2,0.9,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,1.4,1.6,1.2,1.2,1.3,1.7,1.3,1.5,1.3,1.3,1.3,1.3,1.5,2.9,1.5,1.2,1.4,1.2,1.3,1.3,1.4,1.3,1.4,1.4,1.2,1.2,1.3,1.2,1.2,1.2,1.2,1.4,1.4,1.3,1.2,1.2,1.2,1.9,1.4,1.3,1.4,1.3,1.7,1.3,2.1,2.9,1.9,1.8,1.6,1.4,1.4,1.7,1.2,1.5,1.6,1.9,1.5,1.8,1.3,1.2,1.8,2.3,2,2.2,1.7,1.5,1.2,1.2,1.2,1.1,1.1,1.4,3.3,2,1.5,2.4,2.4,1.6,2.6,2.5,2.3,1.5,1.2,1.2,1.2,1.3,1.2,1.2,1.3,2,1.5,1.7,1.2,1.3,1.6,1.5,1.4,1.4,1.4,1.2,1.2,1.1,1.1,0.9,0.9,1.3,0.9,0.9,0.9,0.9,0.9,1.3,1.1,1.1,1.3,0.9,0.9,1.3,0.9,1.5,2.1,2.1,1.2,1.2,1.3,1.2,1.2,1.5,1.4,1.3,1.2,1.2,1.3,1.3,1.2,1.3,1.2,1.2,1.2,1.2,1.2,1.4,1.2,1.5,1.5,1.4,1.4,1.5,1.5,1.3,1.2,1.2,0.9,2.3,1.8,1.3,1.2,1.2,1.1,0.9,0.9,0.9,1.2,1.6,0.9,0.9,0.9,0.9,0.9,0.9,1.1,0.9,0.9,0.9,0.9,0.9,1.9,1.2,1.3,1.1,1.3,1.1,0.9,0.9,0.9,1.2,0.9,0.9,0.9,0.9,0.9,0.9,1.1,0.9,1.1,0.9,0.9,0.9,0.9,1.2,0.9,0.9,0.9,1.1,0.9,0.9,1.2,1.6,1.4,1.3,1.4,1.5,1.2,1.2,1.1,0.9,0.9,1.1,1.1,0.9,0.9,1.1,1.1,0.9,0.9,0.9,0.9,0.9,1.1,1.1,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,1.1,0.9,1.1,0.9,0.9,0.9,0.9,0.9,0.9,1.1,0.9,0.9,1.1,1.3,0.9,1.3,1.1,1.1,0.9,1.1,0.9,1.1,0.9,1.3,1.2,0.9,1.1,0.9,0.9,0.9,1.1,0.9,0.9,1.1,1.2,1.6,0.9,1.1,1.4,3.7,2.8,3.2,2.7,1.2,1.2,1.3,1.3,1.3,1.2,1.2,0.9,0.9,0.9,1.1,1.1,0.9,1.1,1.3,0.9,1.1,1.1,1.1,1.3,4.1,1.5,1.7,1.2,1.2,1.2,1.2,1.2,1.2,1.2,1.1,0.9,0.9,0.9,1.1,1.3,0.9,0.9,0.9,0.9,0.9,0.9,1.1,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,1.1,0.9,0.9,0.9,0.9,1.1,0.9,0.9,1.1,0.9,0.9,0.9,0.9,0.9,1.1,0.9,0.9,0.9,0.9,0.9,0.9,0.9,1.1,0.9,1.3,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9,0.9 ] - }""" - - activity = from_json(test, Activity) - print(activity) \ No newline at end of file diff --git a/oura/models/helper.py b/oura/models/helper.py deleted file mode 100644 index 1c0e3a7..0000000 --- a/oura/models/helper.py +++ /dev/null @@ -1,39 +0,0 @@ -import logging -import json - -logger = logging.getLogger(__name__) - -class OuraModel: - # TODO factor out common keys, like "summary_date" - _KEYS = [] - - def __init__(self, json_raw=None, json_parsed=None): - obj = json_parsed if json_parsed is not None else json.loads(json_raw) - set_attrs(self, obj) - - - def __str__(self): - return ", ".join( ["{}={}".format(k, getattr(self, k)) for k in self._KEYS ] ) - - -def set_attrs(instance, lookup): - [setattr(instance, k, lookup[k]) for k in instance._KEYS if k in lookup.keys()] - - -def from_dict(response_dict, typename: OuraModel): - obj = typename() - - for k in obj._KEYS: - if k in response_dict.keys(): - setattr(obj, k, response_dict[k]) - else: - setattr(obj, k, None) - logger.warning("Expected property missing from json response. property={}, class={}".format(k, typename.__class__.__name__)) - - return obj - - -def from_json(raw_json, typename: OuraModel): - - json_dict = json.loads(raw_json) - return from_dict(json_dict, typename) \ No newline at end of file diff --git a/oura/models/readiness.py b/oura/models/readiness.py deleted file mode 100644 index 08677d7..0000000 --- a/oura/models/readiness.py +++ /dev/null @@ -1,35 +0,0 @@ - -from helper import OuraModel, from_json - -class Readiness(OuraModel): - _KEYS = [ - "summary_date", - "period_id", - "score", - "score_previous_night", - "score_sleep_balance", - "score_previous_day", - "score_activity_balance", - "score_resting_hr", - "score_recovery_index", - "score_temperature" - ] - - -if __name__ == '__main__': - test = """ -{ - "summary_date": "2016-09-03", - "period_id": 0, - "score": 62, - "score_previous_night": 5, - "score_sleep_balance": 75, - "score_previous_day": 61, - "score_activity_balance": 77, - "score_resting_hr": 98, - "score_recovery_index": 45, - "score_temperature": 86 -}""" - - readiness = from_json(test, Readiness) - print(readiness) \ No newline at end of file diff --git a/oura/models/sleep.py b/oura/models/sleep.py deleted file mode 100644 index af9a2b4..0000000 --- a/oura/models/sleep.py +++ /dev/null @@ -1,79 +0,0 @@ - -from helper import OuraModel, from_json - -class Sleep(OuraModel): - _KEYS = [ - "summary_date", - "period_id", - "is_longest", - "timezone", - "bedtime_start", - "bedtime_end", - "score", - "score_total", - "score_disturbances", - "score_efficiency", - "score_latency", - "score_rem", - "score_deep", - "score_alignment", - "total", - "duration", - "awake", - "light", - "rem", - "deep", - "onset_latency", - "restless", - "efficiency", - "midpoint_time", - "hr_lowest", - "hr_average", - "rmssd", - "breath_average", - "temperature_delta", - "hypnogram_5min", - "hr_5min", - "rmssd_5min" - ] - - -if __name__ == '__main__': - test = """ -{ - "summary_date": "2017-11-05", - "period_id": 0, - "is_longest": 1, - "timezone": 120, - "bedtime_start": "2017-11-06T02:13:19+02:00", - "bedtime_end": "2017-11-06T08:12:19+02:00", - "score": 70, - "score_total": 57, - "score_disturbances": 83, - "score_efficiency": 99, - "score_latency": 88, - "score_rem": 97, - "score_deep": 59, - "score_alignment": 31, - "total": 20310, - "duration": 21540, - "awake": 1230, - "light": 10260, - "rem": 7140, - "deep": 2910, - "onset_latency": 480, - "restless": 39, - "efficiency": 94, - "midpoint_time": 11010, - "hr_lowest": 49, - "hr_average": 56.375, - "rmssd": 54, - "breath_average": 13, - "temperature_delta": -0.06, - "hypnogram_5min": "443432222211222333321112222222222111133333322221112233333333332232222334", - "hr_5min": [0, 53, 51, 0, 50, 50, 49, 49, 50, 50, 51, 52, 52, 51, 53, 58, 60, 60, 59, 58, 58, 58, 58, 55, 55, 55, 55, 56, 56, 55, 53, 53, 53, 53, 53, 53, 57, 58, 60, 60, 59, 57, 59, 58, 56, 56, 56, 56, 55, 55, 56, 56, 57, 58, 55, 56, 57, 60, 58, 58, 59, 57, 54, 54, 53, 52, 52, 55, 53, 54, 56, 0], - "rmssd_5min": [0, 0, 62, 0, 75, 52, 56, 56, 64, 57, 55, 78, 77, 83, 70, 35, 21, 25, 49, 44, 48, 48, 62, 69, 66, 64, 79, 59, 67, 66, 70, 63, 53, 57, 53, 57, 38, 26, 18, 24, 30, 35, 36, 46, 53, 59, 50, 50, 53, 53, 57, 52, 41, 37, 49, 47, 48, 35, 32, 34, 52, 57, 62, 57, 70, 81, 81, 65, 69, 72, 64, 0] -}""" - - sleep = from_json(test, Sleep) - print(sleep) \ No newline at end of file diff --git a/oura/models/summary_list.py b/oura/models/summary_list.py deleted file mode 100644 index b437e4d..0000000 --- a/oura/models/summary_list.py +++ /dev/null @@ -1,74 +0,0 @@ - -import json -from helper import OuraModel, from_json, from_dict, set_attrs -from datetime import datetime -from sleep import Sleep -from activity import Activity -from readiness import Readiness - -class OuraSummary: - - def __init__(self, summary_dict): - self.summary_dict = summary_dict - set_attrs(self, json.loads(summary_dict)) - - - def _by_date(self, typename): - - result = {} # date -> OuraModel object - - for item in self.summary_dict: - - # parse item into an OuraModel so it has summary_date defined - obj = typename(json_parsed=item) - summary_date = obj.summary_date - date_obj = datetime.strptime(summary_date, "%Y-%m-%d").date() - - result[date_obj] = obj - - return result - - -class SleepSummary(OuraSummary, OuraModel): - _KEYS = ["sleep"] - - def by_date(self): - return self._by_date(Sleep) - - -class ActivitySummary(OuraSummary, OuraModel): - _KEYS = ["activity"] - - def by_date(self): - return self._by_date(Activity) - - -class ReadinessSummary(OuraSummary, OuraModel): - _KEYS = ["readiness"] - - def by_date(self): - return self._by_date(Readiness) - - -if __name__ == '__main__': - test = """ -{ - "readiness" : [ - { - "summary_date": "2016-09-03", - "period_id": "0", - "score": "62", - "score_previous_night": "5", - "score_sleep_balance": "75", - "score_previous_day": "61", - "score_activity_balance": "77", - "score_resting_hr": "98", - "score_recovery_index": "45", - "score_temperature": "86" - } - ] -}""" - - - summary = ReadinessSummary(test) - print(summary.by_date()) \ No newline at end of file diff --git a/oura/models/user_info.py b/oura/models/user_info.py deleted file mode 100644 index 83d48ae..0000000 --- a/oura/models/user_info.py +++ /dev/null @@ -1,20 +0,0 @@ -import json -from helper import OuraModel, from_json - -class UserInfo(OuraModel): - _KEYS = ['age', 'weight', 'gender', 'email'] - - -if __name__ == '__main__': - - test = """ -{ - "age": 27, - "weight": 80, - "email": "john.doe@the.domain", - "surprise" : "wow this is new" -}""" - - u = from_json(test, UserInfo) - print(u) - \ No newline at end of file diff --git a/oura/writers.py b/oura/writers.py new file mode 100644 index 0000000..b194651 --- /dev/null +++ b/oura/writers.py @@ -0,0 +1,73 @@ +import pandas as pd + + +def save_as_xlsx(df, file, index=True, **to_excel_kwargs): + """ + Save dataframe as .xlsx file with dates properly formatted + + :param df: dataframe to save + :type df: df object + + :param file: File path + :type file: string + + :param index: save df index, in this case summary_date + :type index: Boolean + """ + + def localize(df): + """ + Remove tz from datetime cols since Excel doesn't allow + """ + tz_cols = df.select_dtypes(include=["datetimetz"]).columns + for tz_col in tz_cols: + df[tz_col] = df[tz_col].dt.tz_localize(None) + return df + + import xlsxwriter + + df = df.copy() + df = localize(df) + writer = pd.ExcelWriter( + file, + engine="xlsxwriter", + date_format="m/d/yyy", + datetime_format="m/d/yyy h:mmAM/PM", + ) + df.to_excel(writer, index=index, **to_excel_kwargs) + writer.save() + + +def tableize(df, tablefmt="pretty", is_print=True, filename=None): + """ + Converts dataframe to a formatted table + For more details, see https://pypi.org/project/tabulate/ + + :param df: dataframe to save + :type df: df object + + :param tablefmt: format of table + :type tablefmt: string + + :param is_print: print to standard output? + :type is_print: boolean + + :param filename: optionally, filename to print to + :type filename: string + """ + from tabulate import tabulate + + table = tabulate( + df, + headers="keys", + tablefmt=tablefmt, + showindex=True, + stralign="center", + numalign="center", + ) + if is_print: + print(table) + if filename: + with open(filename, "w") as f: + print(table, file=f) + return table diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2ddcdfe --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +flask +pandas +pytest +requests-mock +requests-oauthlib +twine diff --git a/samples/sample.py b/samples/sample.py index d4d9213..af2e447 100644 --- a/samples/sample.py +++ b/samples/sample.py @@ -1,41 +1,50 @@ -from oura import OuraClient -import os import json +import os from datetime import datetime +from oura import OuraClient + + +def get_self(): + pat = os.getenv("OURA_PAT") + client = OuraClient(personal_access_token=pat) + user_info = client.user_info() + print(user_info) + + def setEnvironment(envFile): basePath = os.path.dirname(os.path.abspath(__file__)) fullPath = os.path.join(basePath, envFile) with open(fullPath) as file: env = json.load(file) - os.environ['OURA_CLIENT_ID'] = env['client_id'] - os.environ['OURA_CLIENT_SECRET'] = env['client_secret'] - os.environ['OURA_ACCESS_TOKEN'] = env['access_token'] - os.environ['OURA_REFRESH_TOKEN'] = env['refresh_token'] + os.environ["OURA_CLIENT_ID"] = env["client_id"] + os.environ["OURA_CLIENT_SECRET"] = env["client_secret"] + os.environ["OURA_ACCESS_TOKEN"] = env["access_token"] + os.environ["OURA_REFRESH_TOKEN"] = env["refresh_token"] def appendFile(filename, token_dict): basePath = os.path.dirname(os.path.abspath(__file__)) fullPath = os.path.join(basePath, filename) - with open(fullPath, 'r+') as file: + with open(fullPath, "r+") as file: prev = json.load(file) curr = { - 'client_id': prev.pop('client_id'), - 'client_secret': prev.pop('client_secret'), - 'access_token': token_dict['access_token'], - 'refresh_token': token_dict['refresh_token'], - 'previous': json.dumps(prev) + "client_id": prev.pop("client_id"), + "client_secret": prev.pop("client_secret"), + "access_token": token_dict["access_token"], + "refresh_token": token_dict["refresh_token"], + "previous": json.dumps(prev), } file.seek(0) json.dump(curr, file) def getOuraClient(envFile): - client_id = os.getenv('OURA_CLIENT_ID') - client_secret = os.getenv('OURA_CLIENT_SECRET') - access_token = os.getenv('OURA_ACCESS_TOKEN') - refresh_token = os.getenv('OURA_REFRESH_TOKEN') + client_id = os.getenv("OURA_CLIENT_ID") + client_secret = os.getenv("OURA_CLIENT_SECRET") + access_token = os.getenv("OURA_ACCESS_TOKEN") + refresh_token = os.getenv("OURA_REFRESH_TOKEN") refresh_callback = lambda x: appendFile(envFile, x) auth_client = OuraClient( @@ -43,13 +52,14 @@ def getOuraClient(envFile): client_secret=client_secret, access_token=access_token, refresh_token=refresh_token, - refresh_callback=refresh_callback - ) - + refresh_callback=refresh_callback, + ) + return auth_client + if __name__ == "__main__": - + envFile = "token.json" setEnvironment(envFile) client = getOuraClient(envFile) diff --git a/setup.py b/setup.py index ebcc2ea..30a3a1b 100644 --- a/setup.py +++ b/setup.py @@ -12,17 +12,15 @@ from setuptools import find_packages, setup, Command # Package meta-data. -NAME = 'oura' -DESCRIPTION = 'Oura api client.' -URL = 'https://github.com/turing-complet/python-ouraring' -EMAIL = 'jhagg314@gmail.com' -AUTHOR = 'Jon Hagg' -REQUIRES_PYTHON = '>=3.5.3' -VERSION = '1.0.4' - -REQUIRED = [ - 'requests-oauthlib' -] +NAME = "oura" +DESCRIPTION = "Oura api client." +URL = "https://github.com/turing-complet/python-ouraring" +EMAIL = "jhagg314@gmail.com" +AUTHOR = "Jon Hagg" +REQUIRES_PYTHON = ">=3.6" +VERSION = "1.1.4" + +REQUIRED = ["requests-oauthlib", "pandas"] EXTRAS = { # 'fancy feature': ['django'], @@ -31,97 +29,91 @@ here = os.path.abspath(os.path.dirname(__file__)) try: - with io.open(os.path.join(here, 'README.md'), encoding='utf-8') as f: - long_description = '\n' + f.read() + with io.open(os.path.join(here, "README.md"), encoding="utf-8") as f: + long_description = "\n" + f.read() except FileNotFoundError: long_description = DESCRIPTION about = {} -about['__version__'] = VERSION +about["__version__"] = VERSION class UploadCommand(Command): """Support setup.py upload.""" - description = 'Build and publish the package.' - user_options = [ - ('test', None, 'Upload to test server') - ] + description = "Build and publish the package." + user_options = [("test", None, "Upload to test server")] @staticmethod def status(s): """Prints things in bold.""" - print('\033[1m{0}\033[0m'.format(s)) + print("\033[1m{0}\033[0m".format(s)) def initialize_options(self): self.test = False - self.test_server = 'https://test.pypi.org/legacy/' + self.test_server = "https://test.pypi.org/legacy/" def finalize_options(self): pass def run(self): try: - self.status('Removing previous builds…') - rmtree(os.path.join(here, 'dist')) + self.status("Removing previous builds…") + rmtree(os.path.join(here, "dist")) except OSError: pass - self.status('Building Source and Wheel (universal) distribution…') - os.system('{0} setup.py sdist bdist_wheel --universal'.format(sys.executable)) + self.status("Building Source and Wheel (universal) distribution…") + os.system("{0} setup.py sdist bdist_wheel --universal".format(sys.executable)) if self.test: - self.status('Uploading the package to test server via Twine…') - os.system('twine upload --repository-url {} dist/*'.format(self.test_server)) + self.status("Uploading the package to test server via Twine…") + os.system( + "twine upload --repository-url {} dist/*".format(self.test_server) + ) else: - self.status('Uploading the package to PyPI via Twine…') - os.system('twine upload dist/*') + self.status("Uploading the package to PyPI via Twine…") + os.system("twine upload dist/*") # self.status('Pushing git tags…') # os.system('git tag v{0}'.format(about['__version__'])) # os.system('git push --tags') - sys.exit() # Where the magic happens: setup( name=NAME, - version=about['__version__'], + version=about["__version__"], description=DESCRIPTION, long_description=long_description, - long_description_content_type='text/markdown', + long_description_content_type="text/markdown", author=AUTHOR, author_email=EMAIL, python_requires=REQUIRES_PYTHON, url=URL, - packages=find_packages(exclude=('tests',)), + packages=find_packages(exclude=("tests",)), # If your package is a single module, use this instead of 'packages': # py_modules=['oura.client'], - # entry_points={ # 'console_scripts': ['mycli=mymodule:cli'], # }, install_requires=REQUIRED, extras_require=EXTRAS, include_package_data=True, - license='MIT', + license="MIT", # $ setup.py publish support. - classifiers = [ - 'Development Status :: 3 - Alpha', - 'Intended Audience :: Developers', - 'Natural Language :: English', - 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', + classifiers=[ + "Intended Audience :: Developers", + "Natural Language :: English", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", ], cmdclass={ - 'upload': UploadCommand, + "upload": UploadCommand, }, ) diff --git a/tests/__init__.py b/tests/__init__.py index 3ce070d..9c1d7b4 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1,2 @@ -from . import test_auth, test_client \ No newline at end of file +from . import test_auth, test_client, test_client_pandas +from .mock_client import MockClient, MockDataFrameClient diff --git a/tests/mock_client.py b/tests/mock_client.py new file mode 100644 index 0000000..560d898 --- /dev/null +++ b/tests/mock_client.py @@ -0,0 +1,149 @@ +from oura import OuraClient, OuraClientDataFrame + + +class MockClient(OuraClient): + def user_info(self): + return { + "age": 27, + "weight": 80, + "gender": "male", + "email": "john.doe@the.domain", + } + + def activity_summary(self, start=None, end=None): + minutes_per_day = 1440 + return { + "activity": [ + { + "summary_date": "2016-09-03", + "day_start": "2016-09-03T04:00:00+03:00", + "day_end": "2016-09-04T03:59:59+03:00", + "timezone": 180, + "score": 87, + "score_stay_active": 90, + "score_move_every_hour": 100, + "score_meet_daily_targets": 60, + "score_training_frequency": 96, + "score_training_volume": 95, + "score_recovery_time": 100, + "daily_movement": 7806, + "non_wear": 313, + "rest": 426, + "inactive": 429, + "inactivity_alerts": 0, + "low": 224, + "medium": 49, + "high": 0, + "steps": 9206, + "cal_total": 2540, + "cal_active": 416, + "met_min_inactive": 9, + "met_min_low": 167, + "met_min_medium_plus": 159, + "met_min_medium": 159, + "met_min_high": 0, + "average_met": 1.4375, + "class_5min": "1112211111111111111111111111111111111111111111233322322223333323322222220000000000000000000000000000000000000000000000000000000233334444332222222222222322333444432222222221230003233332232222333332333333330002222222233233233222212222222223121121111222111111122212321223211111111111111111", + "met_1min": [0.9] * minutes_per_day, + "rest_mode_state": 0, + } + ] + } + + def sleep_summary(self, start=None, end=None): + return { + "sleep": [ + { + "summary_date": "2017-11-05", + "period_id": 0, + "is_longest": 1, + "timezone": 120, + "bedtime_start": "2017-11-06T02:13:19+02:00", + "bedtime_end": "2017-11-06T08:12:19+02:00", + "score": 70, + "score_total": 57, + "score_disturbances": 83, + "score_efficiency": 99, + "score_latency": 88, + "score_rem": 97, + "score_deep": 59, + "score_alignment": 31, + "total": 20310, + "duration": 21540, + "awake": 1230, + "light": 10260, + "rem": 7140, + "deep": 2910, + "onset_latency": 480, + "restless": 39, + "efficiency": 94, + "midpoint_time": 11010, + "hr_lowest": 49, + "hr_average": 56.375, + "rmssd": 54, + "breath_average": 13, + "temperature_delta": -0.06, + "hypnogram_5min": "443432222211222333321112222222222111133333322221112233333333332232222334", + "hr_5min": [52] * 72, + "rmssd_5min": [61] * 72, + } + ] + } + + def readiness_summary(self, start=None, end=None): + return { + "readiness": [ + { + "summary_date": "2016-09-03", + "period_id": 0, + "score": 62, + "score_previous_night": 5, + "score_sleep_balance": 75, + "score_previous_day": 61, + "score_activity_balance": 77, + "score_resting_hr": 98, + "score_hrv_balance": 90, + "score_recovery_index": 45, + "score_temperature": 86, + "rest_mode_state": 0, + } + ] + } + + def bedtime_summary(self, start=None, end=None): + return { + "ideal_bedtimes": [ + { + "date": "2020-03-17", + "bedtime_window": {"start": -3600, "end": 0}, + "status": "IDEAL_BEDTIME_AVAILABLE", + }, + { + "date": "2020-03-18", + "bedtime_window": {"start": None, "end": None}, + "status": "LOW_SLEEP_SCORES", + }, + ] + } + + +class MockOneDayClient(MockClient): + def activity_summary(self, start=None, end=None): + resp = super().activity_summary(start, end) + return {"activity": resp["activity"][0]} + + def sleep_summary(self, start=None, end=None): + resp = super().sleep_summary(start, end) + return {"sleep": resp["sleep"][0]} + + def readiness_summary(self, start=None, end=None): + resp = super().readiness_summary(start, end) + return {"readiness": resp["readiness"][0]} + + def bedtime_summary(self, start=None, end=None): + resp = super().bedtime_summary(start, end) + return {"ideal_bedtimes": resp["ideal_bedtimes"][0]} + + +class MockDataFrameClient(OuraClientDataFrame, MockClient): + pass diff --git a/tests/test_auth.py b/tests/test_auth.py index b33255b..e92a887 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,8 +1,10 @@ -import pytest -from oura import OuraOAuth2Client -import requests_mock import json +import requests_mock + +from oura import OuraOAuth2Client + + def test_build_authorize_endpoint(): client = OuraOAuth2Client("test_client", "test_secret") actual_url, state = client.authorize_endpoint(scope=["email", "daily"], state="foo") @@ -10,14 +12,20 @@ def test_build_authorize_endpoint(): assert expected == actual_url assert "foo" == state + def test_token_request(): client = OuraOAuth2Client("test_client", "test_secret") fake_code = "fake_code" with requests_mock.mock() as m: - m.post(client.TOKEN_BASE_URL, text=json.dumps({ - 'access_token': 'fake_return_access_token', - 'refresh_token': 'fake_return_refresh_token' - })) + m.post( + client.TOKEN_BASE_URL, + text=json.dumps( + { + "access_token": "fake_return_access_token", + "refresh_token": "fake_return_refresh_token", + } + ), + ) retval = client.fetch_access_token(fake_code) - assert "fake_return_access_token" == retval['access_token'] - assert "fake_return_refresh_token" == retval['refresh_token'] \ No newline at end of file + assert "fake_return_access_token" == retval["access_token"] + assert "fake_return_refresh_token" == retval["refresh_token"] diff --git a/tests/test_client.py b/tests/test_client.py index 0d78912..7c5c2b2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,25 +1,26 @@ -import pytest -from oura import OuraClient -import requests_mock -from requests_mock import ANY -import requests -from urllib.parse import urlparse, parse_qs import json -import functools +from urllib.parse import parse_qs, urlparse + +import requests_mock + +from oura import OuraClient adapter = requests_mock.Adapter() + def test_summary_url(): - client = OuraClient('test_id') - url = client._build_summary_url(start='start-date', end=None, datatype='sleep') + client = OuraClient("test_id") + url = client._build_summary_url(start="start-date", end=None, summary_type="sleep") parsed_url = urlparse(url) params = parse_qs(parsed_url.query) - assert 'end' not in params.keys() + assert "end" not in params.keys() - url2 = client._build_summary_url(start='start-date', end='end_date', datatype='sleep') + url2 = client._build_summary_url( + start="start-date", end="end_date", summary_type="sleep" + ) parsed_url = urlparse(url2) params = parse_qs(parsed_url.query) - assert 'end' in params.keys() + assert "end" in params.keys() def test_token_refresh(): @@ -29,16 +30,30 @@ def test_token_refresh(): def token_updater(token): update_called.append(1) - client = OuraClient('test_id', access_token='token', refresh_callback=token_updater) - adapter.register_uri(requests_mock.POST, requests_mock.ANY, status_code=401, text=json.dumps({ - 'access_token': 'fake_return_access_token', - 'refresh_token': 'fake_return_refresh_token' - })) - adapter.register_uri(requests_mock.GET, requests_mock.ANY, status_code=401, text=json.dumps({ 'a': 'b'})) - - client._session.mount(client.API_ENDPOINT, adapter) + client = OuraClient( + client_id="test_id", access_token="token", refresh_callback=token_updater + ) + adapter.register_uri( + requests_mock.POST, + requests_mock.ANY, + status_code=401, + text=json.dumps( + { + "access_token": "fake_return_access_token", + "refresh_token": "fake_return_refresh_token", + } + ), + ) + adapter.register_uri( + requests_mock.GET, + requests_mock.ANY, + status_code=401, + text=json.dumps({"a": "b"}), + ) + + client._auth_handler._session.mount(client.API_ENDPOINT, adapter) try: - resp = client.user_info() - except: + client.user_info() + except Exception: pass - assert len(update_called) == 1 \ No newline at end of file + assert len(update_called) == 1 diff --git a/tests/test_client_pandas.py b/tests/test_client_pandas.py new file mode 100644 index 0000000..b946b9a --- /dev/null +++ b/tests/test_client_pandas.py @@ -0,0 +1,132 @@ +import json +import os +from datetime import date + +import pandas as pd +import pytest + +from .mock_client import MockDataFrameClient + +client = MockDataFrameClient() + + +def test_sleep_summary_df(): + """ + Objectives: + 1. Test that dataframe summary_date match the args passed into + start and end date + + 2. Test that the correct number of metrics are being returned + + 3. Test raw and edited dataframes are returning correctly named + fields and correct data types + """ + start = "2017-11-05" + end = "2017-11-05" + df1 = client.sleep_df(start, convert=False) + # check all cols are included + assert df1.shape == (1, 31) + # check that start date parameter is correct + assert df1.index[0] == date(2017, 11, 5) + + df2 = client.sleep_df(start, end, metrics=["bedtime_start", "score"], convert=False) + # check that correct metrics are being included + assert df2.shape[1] == 2 + # check that end date parameter is correct + assert df2.index[-1] == date(2017, 11, 5) + # check that data type has not been altered + assert type(df2["bedtime_start"][0]) == str + + # test that invalid metric 'zzz' is dropped + df_raw3 = client.sleep_df( + start, end, metrics=["bedtime_start", "zzz"], convert=False + ) + assert df_raw3.shape[1] == 1 + + # check that bedtime start has been renamed and is now a timestamp + df_edited = client.sleep_df(start, end, metrics=["bedtime_start", "zzz"]) + assert type(df_edited["bedtime_start_dt_adjusted"][0]) != str + + +def test_activity_summary_df(): + start = "2016-09-03" + end = "2016-09-04" + df1 = client.activity_df(start, convert=False) + # check all cols are included + assert df1.shape == (1, 30) + assert df1.index[0] == date(2016, 9, 3) + + df2 = client.activity_df(start, end, metrics=["day_start", "medium"], convert=False) + assert df2.shape[1] == 2 + assert df2.index[-1] == date(2016, 9, 3) + assert type(df2["day_start"][0]) == str + + # test that invalid metric is dropped + df_raw3 = client.activity_df( + start, end, metrics=["day_start", "zzz"], convert=False + ) + assert df_raw3.shape[1] == 1 + + # check that day_start has been renamed and is now a timestamp + df_edited = client.activity_df(start, end, metrics=["day_start", "zzz"]) + assert type(df_edited["day_start_dt_adjusted"][0]) != str + + +def test_ready_summary_df(): + start = "2016-09-03" + end = "2016-09-04" + df1 = client.readiness_df(start) + # check all cols are included + assert df1.shape == (1, 11) + assert df1.index[0] == date(2016, 9, 3) + + df2 = client.readiness_df( + start, + end, + metrics=["score_hrv_balance", "score_recovery_index"], + ) + assert df2.shape[1] == 2 + assert df2.index[-1] == date(2016, 9, 3) + + # test that invalid metric is dropped + df_raw3 = client.readiness_df(start, end, metrics=["score_hrv_balance", "zzz"]) + assert df_raw3.shape[1] == 1 + + df_edited = client.readiness_df(start, end, metrics="score_hrv_balance") + assert pd.DataFrame.equals(df_raw3, df_edited) + + +def test_bedtime_df(): + df = client.bedtime_df(metrics=["bedtime_window"]) + assert df.shape == (2, 1) + assert "date" == df.index.name + + +@pytest.mark.skip +def test_combined_summary_df(): + combined_df_edited1 = client.combined_df_edited(start="2020-09-30") + # check all cols are included + assert combined_df_edited1.shape == (0, 72) + assert combined_df_edited1.index[0] > date(2020, 9, 29) + + # check start and end dates work accordingly + combined_df_edited2 = client.combined_df_edited( + start="2020-09-30", + end="2020-10-01", + metrics=["score_hrv_balance", "steps", "efficiency"], + ) + assert combined_df_edited2.shape[1] == 3 + assert combined_df_edited2.index[-1] < date(2020, 10, 2) + + # test that invalid metric is dropped + combined_df_edited2 = client.combined_df_edited( + start="2020-09-30", + end="2020-10-01", + metrics=["score_hrv_balance", "steps", "bedtime_start", "zzz"], + ) + assert combined_df_edited2.shape[1] == 3 + + # check that columns are pre-fixed with their summary name + assert "ACTIVITY:steps" in combined_df_edited2 + # check that columns are suffixed with unit conversions + assert "SLEEP:bedtime_start_dt_adjusted" in combined_df_edited2 diff --git a/tests/test_converters.py b/tests/test_converters.py new file mode 100644 index 0000000..3516281 --- /dev/null +++ b/tests/test_converters.py @@ -0,0 +1,50 @@ +from oura.converters import ActivityConverter, SleepConverter + +from .mock_client import MockDataFrameClient + + +def _check_list_equal(a, b): + assert sorted(a) == sorted(b) + + +def test_sleep_default(): + sc = SleepConverter() + _check_list_equal(SleepConverter.all_metrics, sc.convert_cols) + + +def test_activity_default(): + ac = ActivityConverter() + _check_list_equal(ActivityConverter.all_metrics, ac.convert_cols) + + +def test_user_input(): + expected = ["awake", "deep"] + sc = SleepConverter(expected) + _check_list_equal(expected, sc.convert_cols) + + +def test_warn_invalid_col(): + foo = "foo" + ac = ActivityConverter([foo]) + assert foo not in ac.convert_cols + + +def test_hypnogram_helper(): + hypnogram_5min = ( + "443432222211222333321112222222222111133333322221112233333333332232222334" + ) + sc = SleepConverter() + result = sc.convert_hypnogram_helper(hypnogram_5min) + expected = ( + "AARARLLLLLDDLLLRRRRLDDDLLLLLLLLLLDDDDRRRRRRLLLLDDDLLRRRRRRRRRRLLRLLLLRRA" + ) + assert expected == result + + +def test_convert_hypnogram(): + client = MockDataFrameClient() + sleep_df = client.sleep_df(convert_cols=["rem"]) + assert "4" in sleep_df.hypnogram_5min[0] + + sleep_df = client.sleep_df() + assert "A" in sleep_df.hypnogram_5min[0] diff --git a/tests/test_writers.py b/tests/test_writers.py new file mode 100644 index 0000000..26d80cb --- /dev/null +++ b/tests/test_writers.py @@ -0,0 +1,37 @@ +import os + +import pytest + +from .mock_client import MockDataFrameClient + +client = MockDataFrameClient() + + +@pytest.mark.skip +def test_save_xlsx(): + """ + Check that both raw and edited df's save without issue + """ + df_raw = client.sleep_df_raw(start="2020-09-30") + df_edited = client.sleep_df_edited( + start="2020-09-30", + end="2020-10-01", + metrics=["bedtime_start", "bedtime_end", "score"], + ) + raw_file = "df_raw.xlsx" + edited_file = "df_edited.xlsx" + client.save_as_xlsx(df_raw, raw_file, sheet_name="hello world") + client.save_as_xlsx(df_edited, "df_edited.xlsx") + assert os.path.exists(raw_file) + assert os.path.exists(edited_file) + + +@pytest.mark.skip +def test_tableize(): + """ + Check that df was printed to file + """ + f = "df_tableized.txt" + df_raw = client.sleep_df_raw(start="2020-09-30", metrics="score") + client.tableize(df_raw, filename=f) + assert os.path.exists(f)