From 5d8297646d96fc626d0b61fa1e184f07bc182f3f Mon Sep 17 00:00:00 2001 From: Justin Wong Date: Mon, 19 Oct 2020 16:28:42 -0500 Subject: [PATCH 01/21] adding a pandas wrapper --- .gitignore | 3 +- oura/__init__.py | 5 +- oura/client_pandas.py | 327 ++++++++++++++++++++++++++++++++++++ tests/__init__.py | 2 +- tests/test_client_pandas.py | 134 +++++++++++++++ 5 files changed, 467 insertions(+), 4 deletions(-) create mode 100644 oura/client_pandas.py create mode 100644 tests/test_client_pandas.py diff --git a/.gitignore b/.gitignore index fd447c5..0cfc60b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ build *.egg-info .tox docs/_build/ -data \ No newline at end of file +data +test_token.json diff --git a/oura/__init__.py b/oura/__init__.py index 0d67fa0..8912d14 100644 --- a/oura/__init__.py +++ b/oura/__init__.py @@ -6,7 +6,8 @@ ------------------ -It's a description for __init__.py, innit. +It's a description for __init__.py, innit. """ -from .client import OuraClient, OuraOAuth2Client \ No newline at end of file +from .client import OuraClient, OuraOAuth2Client +from .client_pandas import OuraClientDataFrame diff --git a/oura/client_pandas.py b/oura/client_pandas.py new file mode 100644 index 0000000..6a00f2c --- /dev/null +++ b/oura/client_pandas.py @@ -0,0 +1,327 @@ +from datetime import datetime, timedelta +from collections import defaultdict +import pandas as pd + +from .client import OuraClient + +class OuraClientDataFrame(OuraClient): + """ + Similiar to OuraClient, but data is returned instead + as a pandas.DataFrame (df) object + """ + + def __init__(self, client_id, client_secret=None, access_token=None, refresh_token=None, refresh_callback=None): + super().__init__(client_id, client_secret, access_token, refresh_token, refresh_callback) + + + def __summary_df(self, summary, metrics=None): + """ + 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 + """ + df = pd.DataFrame(summary) + if metrics: + if type(metrics) == str: + metrics = [metrics] + else: + metrics = metrics.copy() + #drop any invalid cols the user may have entered + metrics = [metric for metric in metrics if metric in df.columns] + #summary_date is a required col + if 'summary_date' not in metrics: + metrics.insert(0, 'summary_date') + df = df[metrics] + df['summary_date'] = pd.to_datetime(df['summary_date']).dt.date + df = df.set_index('summary_date') + return df + + + def sleep_df_raw(self, start=None, end=None, metrics=None): + """ + Create a dataframe from sleep summary dict object. + The dataframe is minimally edited, i.e 'raw' + + :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 + """ + sleep_summary = self.sleep_summary(start, end)['sleep'] + return self.__summary_df(sleep_summary, metrics) + + + def sleep_df_edited(self, start=None, end=None, metrics=None): + """ + Create a dataframe from sleep summary dict object. + Some cols are unit converted for easier use or readability. + + :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 + """ + sleep_df = self.sleep_df_raw(start, end, metrics) + sleep_df = SleepConverter().convert_metrics(sleep_df) + return sleep_df + + + def activity_df_raw(self, start=None, end=None, metrics=None): + """ + Create a dataframe from activity summary dict object. + The dataframe is minimally edited, i.e 'raw' + + :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 + """ + activity_summary = self.activity_summary(start, end)['activity'] + return self.__summary_df(activity_summary, metrics) + + + def activity_df_edited(self, start=None, end=None, metrics=None): + """ + Create a dataframe from activity summary dict object. + Some cols are unit converted for easier use or readability. + + :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 + """ + activity_df = self.activity_df_raw(start, end, metrics) + return ActivityConverter().convert_metrics(activity_df) + + + def readiness_df_raw(self, start=None, end=None, metrics=None): + """ + Create a dataframe from ready summary dict object. + The dataframe is minimally edited, i.e 'raw' + + :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 = self.readiness_summary(start, end)['readiness'] + return self.__summary_df(readiness_summary, metrics) + + + def readiness_df_edited(self, start=None, end=None, metrics=None): + """ + Create a dataframe from ready summary dict object. + Readiness has no cols to unit convert. + + :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 + """ + return self.readiness_df_raw(start, end, metrics) + + + 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_edited(start, end, metrics) + sleep_df = prefix_cols(sleep_df, 'SLEEP') + readiness_df = self.readiness_df_edited(start, end, metrics) + readiness_df = prefix_cols(readiness_df, 'READY') + activity_df = self.activity_df_edited(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 + + + def save_as_xlsx(self, 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(self, 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 + + +class UnitConverter(): + """ + Use this class to convert units for certain dataframe cols + """ + + all_dt_metrics = [] + all_sec_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 convert_metrics(self, df): + """ + Convert metrics to new unit type + + :param df: dataframe + :type df: pandas dataframe obj + """ + dt_metrics = [col for col in df.columns if col in self.all_dt_metrics] + sec_metrics = [col for col in df.columns if col in self.all_sec_metrics] + if dt_metrics: + df = self.convert_to_dt(df, dt_metrics) + if 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'] + +class ActivityConverter(UnitConverter): + all_dt_metrics = ['day_end', 'day_start'] + all_sec_metrics = [] + diff --git a/tests/__init__.py b/tests/__init__.py index 3ce070d..633bcca 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -from . import test_auth, test_client \ No newline at end of file +from . import test_auth, test_client, test_client_pandas diff --git a/tests/test_client_pandas.py b/tests/test_client_pandas.py new file mode 100644 index 0000000..1cc7b78 --- /dev/null +++ b/tests/test_client_pandas.py @@ -0,0 +1,134 @@ +import pytest +import os +from datetime import date +import pandas as pd + +parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +os.sys.path.insert(0, parent_dir) + +from oura import OuraClientDataFrame +import json + +#test_token.json is .gitignored +with open(os.path.join(parent_dir, 'tests/', 'test_token.json'), 'r') as f: + env = json.load(f) +client = OuraClientDataFrame(env['client_id'], env['client_secret'], env['access_token']) + + +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 + """ + sleep_df_raw1 = client.sleep_df_raw(start='2020-09-30') + #check all cols are included + assert sleep_df_raw1.shape[1] >= 36 + #check that start date parameter is correct + assert sleep_df_raw1.index[0] > date(2020, 9, 29) + + sleep_df_raw2 = client.sleep_df_raw(start='2020-09-30', end='2020-10-01', metrics=['bedtime_start', 'score']) + #check that correct metrics are being included + assert sleep_df_raw2.shape[1] == 2 + #check that end date parameter is correct + assert sleep_df_raw2.index[-1] < date(2020, 10, 2) + #check that data type has not been altered + assert type(sleep_df_raw2['bedtime_start'][0]) == str + + #test that invalid metric 'zzz' is dropped + sleep_df_raw3 = client.sleep_df_raw(start='2020-09-30', end='2020-10-01', metrics=['bedtime_start', 'zzz']) + assert sleep_df_raw3.shape[1] == 1 + + #check that bedtime start has been renamed and is now a timestamp + sleep_df_edited = client.sleep_df_edited(start='2020-09-30', end='2020-10-01', metrics=['bedtime_start', 'zzz']) + assert type(sleep_df_edited['bedtime_start_dt_adjusted'][0]) != str + + +def test_activity_summary_df(): + activity_df_raw1 = client.activity_df_raw(start='2020-09-30') + #check all cols are included + assert activity_df_raw1.shape[1] >= 34 + assert activity_df_raw1.index[0] > date(2020, 9, 29) + + activity_df_raw2 = client.activity_df_raw(start='2020-09-30', end='2020-10-01', metrics=['day_start', 'medium']) + assert activity_df_raw2.shape[1] == 2 + assert activity_df_raw2.index[-1] < date(2020, 10, 2) + assert type(activity_df_raw2['day_start'][0]) == str + + #test that invalid metric is dropped + activity_df_raw3 = client.activity_df_raw(start='2020-09-30', end='2020-10-01', metrics=['day_start', 'zzz']) + assert activity_df_raw3.shape[1] == 1 + + #check that day_start has been renamed and is now a timestamp + activity_df_edited = client.activity_df_edited(start='2020-09-30', end='2020-10-01', metrics=['day_start', 'zzz']) + assert type(activity_df_edited['day_start_dt_adjusted'][0]) != str + + +def test_ready_summary_df(): + readiness_df_raw1 = client.readiness_df_raw(start='2020-09-30') + #check all cols are included + assert readiness_df_raw1.shape[1] >= 10 + assert readiness_df_raw1.index[0] > date(2020, 9, 29) + + readiness_df_raw2 = client.readiness_df_raw(start='2020-09-30', end='2020-10-01', metrics=['score_hrv_balance', 'score_recovery_index']) + assert readiness_df_raw2.shape[1] == 2 + assert readiness_df_raw2.index[-1] < date(2020, 10, 2) + + #test that invalid metric is dropped + readiness_df_raw3 = client.readiness_df_raw(start='2020-09-30', end='2020-10-01', metrics=['score_hrv_balance', 'zzz']) + assert readiness_df_raw3.shape[1] == 1 + + #check that readiness edited and readiness raw is the same + readiness_df_edited = client.readiness_df_edited(start='2020-09-30', end='2020-10-01', metrics='score_hrv_balance') + assert pd.DataFrame.equals(readiness_df_raw3, readiness_df_edited) + #assert type(readiness_df_edited['day_start_dt_adjusted'][0]) != str + + +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[1] >= 80 + 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 + + +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) + + +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) From 9dbb0d0434fc08d9cdbaba510aefcd85b0f49fbe Mon Sep 17 00:00:00 2001 From: Jon Hagg Date: Fri, 23 Oct 2020 01:10:25 -0700 Subject: [PATCH 02/21] initial reworking of ci and package updates --- .flake8 | 6 + .travis.yml | 10 +- Pipfile | 1 + Pipfile.lock | 495 ++++++++++++++++++++++++------------ noxfile.py | 25 ++ requirements.txt | 10 + setup.py | 5 +- tests/test_auth.py | 3 +- tests/test_client.py | 3 +- tests/test_client_pandas.py | 27 +- 10 files changed, 405 insertions(+), 180 deletions(-) create mode 100644 .flake8 create mode 100644 noxfile.py create mode 100644 requirements.txt diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..ffa69b0 --- /dev/null +++ b/.flake8 @@ -0,0 +1,6 @@ +# .flake8 +[flake8] +select = BLK,C,E,F,W +ignore = E203,W503 +max-line-length = 88 +max-complexity = 10 diff --git a/.travis.yml b/.travis.yml index 776368c..44b4c93 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,9 @@ language: python python: - - "3.5" - "3.6" - - "3.7-dev" + - "3.7" + - "3.8" + - "3.9" install: - - pip install pipenv - - pipenv install -script: pytest \ No newline at end of file + - pip install nox +script: nox diff --git a/Pipfile b/Pipfile index 5e5e37f..d503913 100644 --- a/Pipfile +++ b/Pipfile @@ -15,3 +15,4 @@ wheel = "*" ipython = "*" requests-mock = "*" pytest = "*" +pandas = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 8da1e2b..06629e3 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "e01f2b609eff7adb0d944f23c89bad07ae8dae38c762e0efe1b2bfbd70d81959" + "sha256": "99603ab7cf2400404ba3fbbb484d243e59e69d68466ff4462083782b891fa090" }, "pipfile-spec": 6, "requires": {}, @@ -21,47 +21,81 @@ ], "version": "==0.7.12" }, - "atomicwrites": { - "hashes": [ - "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", - "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6" - ], - "version": "==1.3.0" - }, "attrs": { "hashes": [ - "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", - "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399" + "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594", + "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc" ], - "version": "==19.1.0" + "version": "==20.2.0" }, "babel": { "hashes": [ - "sha256:6778d85147d5d85345c14a26aada5e478ab04e39b078b0745ee6870c2b5cf669", - "sha256:8cba50f48c529ca3fa18cf81fa9403be176d374ac4d60738b839122dfaaa3d23" + "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38", + "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4" ], - "version": "==2.6.0" + "version": "==2.8.0" }, "backcall": { "hashes": [ - "sha256:38ecd85be2c1e78f77fd91700c76e14667dc21e2713b63876c0eb901196e01e4", - "sha256:bbbf4b1e5cd2bdb08f915895b51081c041bac22394fdfcfdfbe9f14b77c08bf2" + "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e", + "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255" ], - "version": "==0.1.0" + "version": "==0.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,55 +106,95 @@ }, "click": { "hashes": [ - "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", - "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7" - ], - "version": "==7.0" + "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", + "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" + ], + "version": "==7.1.2" + }, + "colorama": { + "hashes": [ + "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", + "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" + ], + "version": "==0.4.4" + }, + "cryptography": { + "hashes": [ + "sha256:21b47c59fcb1c36f1113f3709d37935368e34815ea1d7073862e92f810dc7499", + "sha256:451cdf60be4dafb6a3b78802006a020e6cd709c22d240f94f7a0696240a17154", + "sha256:4549b137d8cbe3c2eadfa56c0c858b78acbeff956bd461e40000b2164d9167c6", + "sha256:48ee615a779ffa749d7d50c291761dc921d93d7cf203dca2db663b4f193f0e49", + "sha256:559d622aef2a2dff98a892eef321433ba5bc55b2485220a8ca289c1ecc2bd54f", + "sha256:5d52c72449bb02dd45a773a203196e6d4fae34e158769c896012401f33064396", + "sha256:65beb15e7f9c16e15934569d29fb4def74ea1469d8781f6b3507ab896d6d8719", + "sha256:680da076cad81cdf5ffcac50c477b6790be81768d30f9da9e01960c4b18a66db", + "sha256:762bc5a0df03c51ee3f09c621e1cee64e3a079a2b5020de82f1613873d79ee70", + "sha256:89aceb31cd5f9fc2449fe8cf3810797ca52b65f1489002d58fe190bfb265c536", + "sha256:983c0c3de4cb9fcba68fd3f45ed846eb86a2a8b8d8bc5bb18364c4d00b3c61fe", + "sha256:99d4984aabd4c7182050bca76176ce2dbc9fa9748afe583a7865c12954d714ba", + "sha256:9d9fc6a16357965d282dd4ab6531013935425d0dc4950df2e0cf2a1b1ac1017d", + "sha256:a7597ffc67987b37b12e09c029bd1dc43965f75d328076ae85721b84046e9ca7", + "sha256:ab010e461bb6b444eaf7f8c813bb716be2d78ab786103f9608ffd37a4bd7d490", + "sha256:b12e715c10a13ca1bd27fbceed9adc8c5ff640f8e1f7ea76416352de703523c8", + "sha256:b2bded09c578d19e08bd2c5bb8fed7f103e089752c9cf7ca7ca7de522326e921", + "sha256:b372026ebf32fe2523159f27d9f0e9f485092e43b00a5adacf732192a70ba118", + "sha256:cb179acdd4ae1e4a5a160d80b87841b3d0e0be84af46c7bb2cd7ece57a39c4ba", + "sha256:e97a3b627e3cb63c415a16245d6cef2139cca18bb1183d1b9375a1c14e83f3b3", + "sha256:f0e099fc4cc697450c3dd4031791559692dd941a95254cb9aeded66a7aa8b9bc", + "sha256:f99317a0fa2e49917689b8cf977510addcfaaab769b3f899b9c481bbd76730c2" + ], + "version": "==3.1.1" }, "decorator": { "hashes": [ - "sha256:86156361c50488b84a3f148056ea716ca587df2f0de1d34750d35c21312725de", - "sha256:f069f3a01830ca754ba5258fde2278454a0b5b79e0d7f5c13b3b97e57d4acff6" + "sha256:41fa54c2a0cc4ba648be4fd43cff00aedf5b9465c9bf18d64325bc225f08f760", + "sha256:e3a62f0520172440ca0dcc823749319382e377f37f140a0b99ef45fecb84bfe7" ], - "version": "==4.4.0" + "version": "==4.4.2" }, "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" + "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", + "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" ], - "version": "==2.8" + "version": "==2.10" }, "imagesize": { "hashes": [ - "sha256:3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8", - "sha256:f3832918bc3c66617f92e35f5d70729187676313caa60c187eb0f28b8fe5e3b5" + "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1", + "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1" ], - "version": "==1.1.0" + "version": "==1.2.0" + }, + "iniconfig": { + "hashes": [ + "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", + "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32" + ], + "version": "==1.1.1" }, "ipython": { "hashes": [ - "sha256:b038baa489c38f6d853a3cfc4c635b0cda66f2864d136fe8f40c1a6e334e2a6b", - "sha256:f5102c1cd67e399ec8ea66bcebe6e3968ea25a8977e53f012963e5affeb1fe38" + "sha256:2e22c1f74477b5106a6fb301c342ab8c64bb75d702e350f05a649e8cb40a0fb8", + "sha256:a331e78086001931de9424940699691ad49dfb457cea31f5471eae7b78222d5e" ], "index": "pypi", - "version": "==7.4.0" + "version": "==7.18.1" }, "ipython-genutils": { "hashes": [ @@ -138,17 +212,32 @@ }, "jedi": { "hashes": [ - "sha256:2bb0603e3506f708e792c7f4ad8fc2a7a9d9c2d292a358fbbd58da531695595b", - "sha256:2c6bcd9545c7d6440951b12b44d373479bf18123a401a52025cf98563fbd826c" + "sha256:86ed7d9b750603e4ba582ea8edc678657fb4007894a12bcf6f4bb97892f31d20", + "sha256:98cc583fa0f2f8304968199b01b6b4b94f469a1f4a74c1560506ca2a211378b5" ], - "version": "==0.13.3" + "version": "==0.17.2" + }, + "jeepney": { + "hashes": [ + "sha256:3479b861cc2b6407de5188695fa1a8d57e5072d7059322469b62628869b8e36e", + "sha256:d6c6b49683446d2407d2fe3acb7a368a77ff063f9182fe427da15d622adc24cf" + ], + "markers": "sys_platform == 'linux'", + "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 +245,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,46 +271,96 @@ "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:04c7d4ebc5ff93d9822075ddb1751ff392a4375e5885299445fcebf877f179d5", + "sha256:0bfd85053d1e9f60234f28f63d4a5147ada7f432943c113a11afcf3e65d9d4c8", + "sha256:0c66da1d202c52051625e55a249da35b31f65a81cb56e4c69af0dfb8fb0125bf", + "sha256:0d310730e1e793527065ad7dde736197b705d0e4c9999775f212b03c44a8484c", + "sha256:1669ec8e42f169ff715a904c9b2105b6640f3f2a4c4c2cb4920ae8b2785dac65", + "sha256:2117536e968abb7357d34d754e3733b0d7113d4c9f1d921f21a3d96dec5ff716", + "sha256:3733640466733441295b0d6d3dcbf8e1ffa7e897d4d82903169529fd3386919a", + "sha256:4339741994c775396e1a274dba3609c69ab0f16056c1077f18979bec2a2c2e6e", + "sha256:51ee93e1fac3fe08ef54ff1c7f329db64d8a9c5557e6c8e908be9497ac76374b", + "sha256:54045b198aebf41bf6bf4088012777c1d11703bf74461d70cd350c0af2182e45", + "sha256:58d66a6b3b55178a1f8a5fe98df26ace76260a70de694d99577ddeab7eaa9a9d", + "sha256:59f3d687faea7a4f7f93bd9665e5b102f32f3fa28514f15b126f099b7997203d", + "sha256:62139af94728d22350a571b7c82795b9d59be77fc162414ada6c8b6a10ef5d02", + "sha256:7118f0a9f2f617f921ec7d278d981244ba83c85eea197be7c5a4f84af80a9c3c", + "sha256:7c6646314291d8f5ea900a7ea9c4261f834b5b62159ba2abe3836f4fa6705526", + "sha256:967c92435f0b3ba37a4257c48b8715b76741410467e2bdb1097e8391fccfae15", + "sha256:9a3001248b9231ed73894c773142658bab914645261275f675d86c290c37f66d", + "sha256:aba1d5daf1144b956bc87ffb87966791f5e9f3e1f6fab3d7f581db1f5b598f7a", + "sha256:addaa551b298052c16885fc70408d3848d4e2e7352de4e7a1e13e691abc734c1", + "sha256:b594f76771bc7fc8a044c5ba303427ee67c17a09b36e1fa32bde82f5c419d17a", + "sha256:c35a01777f81e7333bcf276b605f39c872e28295441c265cd0c860f4b40148c1", + "sha256:cebd4f4e64cfe87f2039e4725781f6326a61f095bc77b3716502bed812b385a9", + "sha256:d526fa58ae4aead839161535d59ea9565863bb0b0bdb3cc63214613fb16aced4", + "sha256:d7ac33585e1f09e7345aa902c281bd777fdb792432d27fca857f39b70e5dd31c", + "sha256:e6ddbdc5113628f15de7e4911c02aed74a4ccff531842c583e5032f6e5a179bd", + "sha256:eb25c381d168daf351147713f49c626030dcff7a393d5caa62515d415a6071d8" + ], + "version": "==1.19.2" }, "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:206d7c3e5356dcadf082e64dc25c24bc8541718045826074f96346e9d6d05a20", + "sha256:24f61f40febe47edac271eda45d683e42838b7db2bd0f82574d9800259d2b182", + "sha256:3a038cd5da602b955d335aa80cbaa0e5774f68501ff47b9c21509906981478da", + "sha256:427be9938b2f79ab298de84f87693914cda238a27cf10580da96caf3dff64115", + "sha256:54f5f564058b0280d588c3758abde82e280702c440db5faf0c686b80336096f9", + "sha256:5a8a84b75ca3a29bb4263b35d5ed9fcaae2b062f014feed8c5daa897339c7d85", + "sha256:84a4ffe668df357e31f98c829536e3a7142c3036c82f996e639f644c5d32eda1", + "sha256:882012763668af54b48f1412bab95c5cc0a7ccce5a2a8221cfc3839a6e3394ef", + "sha256:920d30fdff65a079f071db635d282b4f583c2b26f2b58d5dca218aac7c59974d", + "sha256:a605054fbca71ed1d08bb2aef6f73c84a579bbac956bfe8f9718d5e84cb41248", + "sha256:b11b496c317dbe007898de699fd59eaf687d0fe8c1b7dad109db6010155d28ae", + "sha256:babbeda2f83b0686c9ad38d93b10516e68cdcd5771007eb80a763e98aaf44613", + "sha256:c22e40f1b4d162ca18eb6b2c572e63eef220dbc9cc3de0241cefb77972621bb7", + "sha256:ca31ac8578d48da354cf66a473d4d5ff99277ca71d321dc7ea4e6fad3c6bb0fd", + "sha256:ca71a5aa9eeb3ef5b31feca7d9b6369d6b3d0b2e9c85d7a89abe3ecb013f1e86", + "sha256:d6b1f9d506dc23da2915bcae5c5968990049c9cec44108bd9855d2c7c89d91dc", + "sha256:d89dbc58aec1544722a8d5046f880b597c497ef8a82c5fe695b4b2effafac5ec", + "sha256:df43ea0e9fd9f9672b0de9cac26d01255ad50481994bf3cb4687c21eec2d7bbc", + "sha256:fd6f05b6101d0e76f3e5c26a47be5be7be96ed84ef3981dc1852e76898e73594" ], - "version": "==19.0" + "index": "pypi", + "version": "==1.1.3" }, "parso": { "hashes": [ - "sha256:17cc2d7a945eb42c3569d4564cdf49bde221bc2b552af3eca9c1aad517dcdd33", - "sha256:2e9574cb12e7112a87253e14e2c380ce312060269d04bd018478a3c92ea9a376" + "sha256:97218d9159b2520ff45eb78028ba8b50d2bc61dcc062a9682666f2dc4bd331ea", + "sha256:caba44724b994a8a5e086460bb212abc5a8bc46951bf4a9a1210745953622eb9" ], - "version": "==0.4.0" + "version": "==0.7.1" }, "pexpect": { "hashes": [ - "sha256:2094eefdfcf37a1fdbfb9aa090862c1a4878e5c7e0e7e7088bdb511c558e5cd1", - "sha256:9e2c1fd0e6ee3a49b28f95d4b33bc389c89b20af6a1255906e90ff1262ce62eb" + "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937", + "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c" ], "markers": "sys_platform != 'win32'", - "version": "==4.7.0" + "version": "==4.8.0" }, "pickleshare": { "hashes": [ @@ -229,25 +371,24 @@ }, "pkginfo": { "hashes": [ - "sha256:7424f2c8511c186cd5424bbf31045b77435b37a8d604990b79d4e70d741148bb", - "sha256:a6d9e40ca61ad3ebd0b72fbadd4fba16e4c0e4df0428c041e01e06eb6ee71f32" + "sha256:78d032b5888ec06d7f9d18fbf8c0549a6a3477081b34cb769119a07183624fc1", + "sha256:dd008e95b13141ddd05d7e8881f0c0366a998ab90b25c2db794a1714b71583cc" ], - "version": "==1.5.0.1" + "version": "==1.6.0" }, "pluggy": { "hashes": [ - "sha256:25a1bc1d148c9a640211872b4ff859878d422bccb59c9965e04eed468a0aa180", - "sha256:964cedd2b27c492fbf0b7f58b3284a09cf7f99b0f715941fb24a439b3af1bd1a" + "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", + "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" ], - "version": "==0.11.0" + "version": "==0.13.1" }, "prompt-toolkit": { "hashes": [ - "sha256:11adf3389a996a6d45cc277580d0d53e8a5afd281d0c9ec71b28e6f121463780", - "sha256:2519ad1d8038fd5fc8e770362237ad0364d16a7650fb5724af6997ed5515e3c1", - "sha256:977c6583ae813a37dc1c2e1b715892461fcbdaa57f6fc62f33a528c4886c8f55" + "sha256:25c95d2ac813909f813c93fde734b6e44406d1477a9faef7c915ff37d39c0a8c", + "sha256:7debb9a521e0b1ee7d2fe96ee4bd60ef03c6492784de0547337ca4433e46aa63" ], - "version": "==2.0.9" + "version": "==3.0.8" }, "ptyprocess": { "hashes": [ @@ -258,69 +399,84 @@ }, "py": { "hashes": [ - "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", - "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53" + "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", + "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" ], - "version": "==1.8.0" + "version": "==1.9.0" + }, + "pycparser": { + "hashes": [ + "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", + "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" + ], + "version": "==2.20" }, "pygments": { "hashes": [ - "sha256:31cba6ffb739f099a85e243eff8cb717089fdd3c7300767d9fc34cb8e1b065f5", - "sha256:5ad302949b3c98dd73f8d9fcdc7e9cb592f120e32a18e23efd7f3dc51194472b" + "sha256:307543fe65c0947b126e83dd5a61bd8acbd84abec11f43caebaf5534cbc17998", + "sha256:926c3f319eda178d1bd90851e4317e6d8cdb5e292a3386aac9bd75eca29cf9c7" ], - "version": "==2.4.0" + "version": "==2.7.1" }, "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:7a8190790c17d79a11f847fba0b004ee9a8122582ebff4729a082c109e81a4c9", + "sha256:8f593023c1a0f916110285b6efd7f99db07d59546e3d8c36fc60e2ab05d3be92" ], "index": "pypi", - "version": "==4.4.0" + "version": "==6.1.1" + }, + "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,56 +485,71 @@ ], "version": "==0.9.1" }, + "rfc3986": { + "hashes": [ + "sha256:112398da31a3344dc25dbf477d8df6cb34f9278a94fee2625d89e4514be8bb9d", + "sha256:af9147e9aceda37c91a05f4deb128d4b4b49d6b199775fd2d2927768abdc8f50" + ], + "version": "==1.4.0" + }, + "secretstorage": { + "hashes": [ + "sha256:15da8a989b65498e29be338b3b279965f1b8f09b9668bd8010da183024c8bff6", + "sha256:b5ec909dde94d4ae2fa26af7c089036997030f0cf0a5cb372b4cccabd81c143b" + ], + "markers": "sys_platform == 'linux'", + "version": "==3.1.2" + }, "six": { "hashes": [ - "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", - "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" + "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", + "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], - "version": "==1.12.0" + "version": "==1.15.0" }, "snowballstemmer": { "hashes": [ - "sha256:919f26a68b2c17a7634da993d91339e288964f93c274f1343e3bbbe2096e1128", - "sha256:9f3bcd3c401c3e862ec0ebe6d2c069ebc012ce142cce209c098ccb5b09136e89" + "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0", + "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52" ], - "version": "==1.2.1" + "version": "==2.0.0" }, "sphinx": { "hashes": [ - "sha256:423280646fb37944dd3c85c58fb92a20d745793a9f6c511f59da82fa97cd404b", - "sha256:de930f42600a4fef993587633984cc5027dedba2464bcf00ddace26b40f8d9ce" + "sha256:321d6d9b16fa381a5306e5a0b76cd48ffbc588e6340059a729c6fdd66087e0e8", + "sha256:ce6fd7ff5b215af39e2fcd44d4a321f6694b4530b6f2b2109b64d120773faea0" ], "index": "pypi", - "version": "==2.0.1" + "version": "==3.2.1" }, "sphinx-rtd-theme": { "hashes": [ - "sha256:00cf895504a7895ee433807c62094cf1e95f065843bf3acd17037c3e9a2becd4", - "sha256:728607e34d60456d736cc7991fd236afb828b21b82f956c5ea75f94c8414040a" + "sha256:22c795ba2832a169ca301cd0a083f7a434e09c538c70beb42782c073651b707d", + "sha256:373413d0f82425aaa28fb288009bf0d0964711d347763af2f1b65cafcb028c82" ], "index": "pypi", - "version": "==0.4.3" + "version": "==0.5.0" }, "sphinxcontrib-applehelp": { "hashes": [ - "sha256:edaa0ab2b2bc74403149cb0209d6775c96de797dfd5b5e2a71981309efab3897", - "sha256:fb8dee85af95e5c30c91f10e7eb3c8967308518e0f7488a2828ef7bc191d0d5d" + "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a", + "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58" ], - "version": "==1.0.1" + "version": "==1.0.2" }, "sphinxcontrib-devhelp": { "hashes": [ - "sha256:6c64b077937330a9128a4da74586e8c2130262f014689b4b89e2d08ee7294a34", - "sha256:9512ecb00a2b0821a146736b39f7aeb90759834b07e81e8cc23a9c70bacb9981" + "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e", + "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4" ], - "version": "==1.0.1" + "version": "==1.0.2" }, "sphinxcontrib-htmlhelp": { "hashes": [ - "sha256:4670f99f8951bd78cd4ad2ab962f798f5618b17675c35c5ac3b2132a14ea8422", - "sha256:d4fd39a65a625c9df86d7fa8a2d9f3cd8299a3a4b15db63b50aac9e161d8eff7" + "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f", + "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b" ], - "version": "==1.0.2" + "version": "==1.0.3" }, "sphinxcontrib-jsmath": { "hashes": [ @@ -389,54 +560,60 @@ }, "sphinxcontrib-qthelp": { "hashes": [ - "sha256:513049b93031beb1f57d4daea74068a4feb77aa5630f856fcff2e50de14e9a20", - "sha256:79465ce11ae5694ff165becda529a600c754f4bc459778778c7017374d4d406f" + "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72", + "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6" ], - "version": "==1.0.2" + "version": "==1.0.3" }, "sphinxcontrib-serializinghtml": { "hashes": [ - "sha256:c0efb33f8052c04fd7a26c0a07f1678e8512e0faec19f4aa8f2473a8b81d5227", - "sha256:db6615af393650bf1151a6cd39120c29abaf93cc60db8c48eb2dddbfdc3a9768" + "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc", + "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a" ], - "version": "==1.1.3" + "version": "==1.1.4" + }, + "toml": { + "hashes": [ + "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f", + "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88" + ], + "version": "==0.10.1" }, "tqdm": { "hashes": [ - "sha256:0a860bf2683fdbb4812fe539a6c22ea3f1777843ea985cb8c3807db448a0f7ab", - "sha256:e288416eecd4df19d12407d0c913cbf77aa8009d7fddb18f632aded3bdbdda6b" + "sha256:43ca183da3367578ebf2f1c2e3111d51ea161ed1dc4e6345b86e27c2a93beff7", + "sha256:69dfa6714dee976e2425a9aab84b622675b7b1742873041e3db8a8e86132a4af" ], - "version": "==4.32.1" + "version": "==4.50.2" }, "traitlets": { "hashes": [ - "sha256:9c4bd2d267b7153df9152698efb1050a5d84982d3384a37b2c1f7723ba3e7835", - "sha256:c6cb5e6f57c5a9bdaa40fa71ce7b4af30298fbab9ece9815b5d995ab6217c7d9" + "sha256:178f4ce988f69189f7e523337a3e11d91c786ded9360174a3d9ca83e79bc5396", + "sha256:69ff3f9d5351f31a7ad80443c2674b7099df13cc41fc5fa6e2f6d3b0330b0426" ], - "version": "==4.3.2" + "version": "==5.0.5" }, "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" + "sha256:8d7eaa5a82a1cac232164990f04874c594c9453ec55eef02eab885aa02fc17a2", + "sha256:f5321fbe4bf3fefa0efd0bfe7fb14e90909eb62a48ccda331726b4319897dd5e" ], - "index": "pypi", - "version": "==1.24.2" + "version": "==1.25.11" }, "wcwidth": { "hashes": [ - "sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e", - "sha256:f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c" + "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784", + "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83" ], - "version": "==0.1.7" + "version": "==0.2.5" }, "webencodings": { "hashes": [ @@ -447,18 +624,18 @@ }, "werkzeug": { "hashes": [ - "sha256:865856ebb55c4dcd0630cdd8f3331a1847a819dda7e8c750d3db6f2aa6c0209c", - "sha256:a0b915f0815982fb2a09161cb8f31708052d0951c3ba433ccc5e1aa276507ca6" + "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43", + "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c" ], - "version": "==0.15.4" + "version": "==1.0.1" }, "wheel": { "hashes": [ - "sha256:66a8fd76f28977bb664b098372daef2b27f60dc4d1688cfab7b37a09448f0e9d", - "sha256:8eb4a788b3aec8abf5ff68d4165441bc57420c9f64ca5f471f58c3969fe08668" + "sha256:497add53525d16c173c2c1c733b8f655510e909ea78cc0e29d374243544b77a2", + "sha256:99a22d87add3f634ff917310a3d87e499f19e663413a52eb9232c447aa646c9f" ], "index": "pypi", - "version": "==0.33.1" + "version": "==0.35.1" } }, "develop": {} diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..96b148a --- /dev/null +++ b/noxfile.py @@ -0,0 +1,25 @@ +import nox + +nox.options.sessions = "lint", "tests" + + +@nox.session +def tests(session): + args = session.posargs + session.install("pipenv") + session.run("pipenv", "sync") + session.run("pytest", *args) + + +@nox.session +def lint(session): + args = session.posargs or ["oura", "tests", "samples"] + session.install("flake8", "flake8-black") + session.run("flake8", *args) + + +@nox.session +def black(session): + args = session.posargs or locations + session.install("black") + session.run("black", *args) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..90ef365 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +flask==1.1.2 +ipython==7.18.1 +pandas==1.1.3 +pytest==6.1.1 +requests-mock==1.8.0 +requests-oauthlib==1.3.0 +sphinx-rtd-theme==0.5.0 +sphinx==3.2.1 +twine==3.2.0 +wheel==0.35.1 diff --git a/setup.py b/setup.py index ebcc2ea..196d997 100644 --- a/setup.py +++ b/setup.py @@ -22,6 +22,7 @@ REQUIRED = [ 'requests-oauthlib' + 'pandas' ] EXTRAS = { @@ -80,7 +81,6 @@ def run(self): # self.status('Pushing git tags…') # os.system('git tag v{0}'.format(about['__version__'])) # os.system('git push --tags') - sys.exit() @@ -113,13 +113,12 @@ def run(self): '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', + 'Programming Language :: Python :: 3.8', ], cmdclass={ 'upload': UploadCommand, diff --git a/tests/test_auth.py b/tests/test_auth.py index b33255b..e5ad5be 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,4 +1,3 @@ -import pytest from oura import OuraOAuth2Client import requests_mock import json @@ -20,4 +19,4 @@ def test_token_request(): })) 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_refresh_token" == retval['refresh_token'] diff --git a/tests/test_client.py b/tests/test_client.py index 0d78912..d97b27a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,4 +1,3 @@ -import pytest from oura import OuraClient import requests_mock from requests_mock import ANY @@ -41,4 +40,4 @@ def token_updater(token): resp = client.user_info() except: 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 index 1cc7b78..7565c55 100644 --- a/tests/test_client_pandas.py +++ b/tests/test_client_pandas.py @@ -9,13 +9,17 @@ from oura import OuraClientDataFrame import json -#test_token.json is .gitignored -with open(os.path.join(parent_dir, 'tests/', 'test_token.json'), 'r') as f: - env = json.load(f) -client = OuraClientDataFrame(env['client_id'], env['client_secret'], env['access_token']) +@pytest.fixture +def client(): + #test_token.json is .gitignored + with open(os.path.join(parent_dir, 'tests/', 'test_token.json'), 'r') as f: + env = json.load(f) + client = OuraClientDataFrame(env['client_id'], env['client_secret'], env['access_token']) + return client -def test_sleep_summary_df(): +@pytest.mark.skip +def test_sleep_summary_df(client): """ Objectives: 1. Test that dataframe summary_date match the args passed into @@ -49,7 +53,8 @@ def test_sleep_summary_df(): assert type(sleep_df_edited['bedtime_start_dt_adjusted'][0]) != str -def test_activity_summary_df(): +@pytest.mark.skip +def test_activity_summary_df(client): activity_df_raw1 = client.activity_df_raw(start='2020-09-30') #check all cols are included assert activity_df_raw1.shape[1] >= 34 @@ -69,7 +74,8 @@ def test_activity_summary_df(): assert type(activity_df_edited['day_start_dt_adjusted'][0]) != str -def test_ready_summary_df(): +@pytest.mark.skip +def test_ready_summary_df(client): readiness_df_raw1 = client.readiness_df_raw(start='2020-09-30') #check all cols are included assert readiness_df_raw1.shape[1] >= 10 @@ -89,6 +95,7 @@ def test_ready_summary_df(): #assert type(readiness_df_edited['day_start_dt_adjusted'][0]) != str +@pytest.mark.skip def test_combined_summary_df(): combined_df_edited1 = client.combined_df_edited(start='2020-09-30') #check all cols are included @@ -110,7 +117,8 @@ def test_combined_summary_df(): assert 'SLEEP:bedtime_start_dt_adjusted' in combined_df_edited2 -def test_save_xlsx(): +@pytest.mark.skip +def test_save_xlsx(client): """ Check that both raw and edited df's save without issue """ @@ -124,7 +132,8 @@ def test_save_xlsx(): assert os.path.exists(edited_file) -def test_tableize(): +@pytest.mark.skip +def test_tableize(client): """ Check that df was printed to file """ From 66bb513e94bcbd32397c5f1b7f096add2cd5c83a Mon Sep 17 00:00:00 2001 From: Jon Hagg Date: Fri, 23 Oct 2020 01:12:35 -0700 Subject: [PATCH 03/21] run black --- noxfile.py | 4 +- oura/client.py | 55 ++++++++------- oura/client_pandas.py | 105 ++++++++++++++++----------- oura/exceptions.py | 40 ++++++----- oura/models/activity.py | 8 +-- oura/models/helper.py | 14 ++-- oura/models/readiness.py | 8 +-- oura/models/sleep.py | 8 +-- oura/models/summary_list.py | 15 ++-- oura/models/user_info.py | 6 +- samples/sample.py | 38 +++++----- tests/test_auth.py | 19 +++-- tests/test_client.py | 37 +++++++--- tests/test_client_pandas.py | 137 ++++++++++++++++++++++-------------- 14 files changed, 292 insertions(+), 202 deletions(-) diff --git a/noxfile.py b/noxfile.py index 96b148a..5d0e1be 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,7 +1,7 @@ import nox nox.options.sessions = "lint", "tests" - +locations = ["oura", "tests", "samples"] @nox.session def tests(session): @@ -13,7 +13,7 @@ def tests(session): @nox.session def lint(session): - args = session.posargs or ["oura", "tests", "samples"] + args = session.posargs or locations session.install("flake8", "flake8-black") session.run("flake8", *args) diff --git a/oura/client.py b/oura/client.py index 3e96d37..2815a15 100644 --- a/oura/client.py +++ b/oura/client.py @@ -1,9 +1,9 @@ - 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. @@ -18,7 +18,7 @@ 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. @@ -32,7 +32,6 @@ def __init__(self, client_id, client_secret): 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. @@ -47,23 +46,21 @@ def authorize_endpoint(self, scope=None, redirect_uri=None, **kwargs): 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. + 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) + self.TOKEN_BASE_URL, code=code, client_secret=self.client_secret + ) class OuraClient: """ - Use this class for making requests on behalf of a user. If refresh_token and expires_at are supplied, + 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. """ @@ -71,7 +68,14 @@ class OuraClient: 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, + client_secret=None, + access_token=None, + refresh_token=None, + refresh_callback=None, + ): """ Initialize the client @@ -97,18 +101,17 @@ def __init__(self, client_id, client_secret=None, access_token=None, refresh_tok self.client_secret = client_secret token = {} if access_token: - token.update({ 'access_token': access_token }) + token.update({"access_token": access_token}) if refresh_token: - token.update({ 'refresh_token': 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 + token_updater=refresh_callback, ) - def user_info(self): """ Returns information about the logged in user (who the access token was issued for). @@ -118,7 +121,6 @@ def user_info(self): 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 @@ -132,7 +134,6 @@ def sleep_summary(self, start=None, end=None): url = self._build_summary_url(start, end, "sleep") return self._make_request(url) - def activity_summary(self, start=None, end=None): """ Get activity summary for the given date range. See https://cloud.ouraring.com/docs/activity @@ -146,7 +147,6 @@ def activity_summary(self, start=None, end=None): url = self._build_summary_url(start, end, "activity") return self._make_request(url) - def readiness_summary(self, start=None, end=None): """ Get readiness summary for the given date range. See https://cloud.ouraring.com/docs/readiness @@ -160,33 +160,36 @@ def readiness_summary(self, start=None, end=None): 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' + 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')) + payload = json.loads(response.content.decode("utf8")) return payload - def _build_summary_url(self, start, end, datatype): if start is None: - raise ValueError("Request for {} summary must include start date.".format(datatype)) + raise ValueError( + "Request for {} summary must include start date.".format(datatype) + ) url = "{0}/v1/{1}?start={2}".format(self.API_ENDPOINT, datatype, start) if end: url = "{0}&end={1}".format(url, end) return url - def _refresh_token(self): - token = self._session.refresh_token(self.TOKEN_BASE_URL, client_id=self.client_id, client_secret=self.client_secret) + 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 \ No newline at end of file + return token diff --git a/oura/client_pandas.py b/oura/client_pandas.py index 6a00f2c..b32e5c5 100644 --- a/oura/client_pandas.py +++ b/oura/client_pandas.py @@ -4,15 +4,24 @@ from .client import OuraClient + class OuraClientDataFrame(OuraClient): """ Similiar to OuraClient, but data is returned instead as a pandas.DataFrame (df) object """ - def __init__(self, client_id, client_secret=None, access_token=None, refresh_token=None, refresh_callback=None): - super().__init__(client_id, client_secret, access_token, refresh_token, refresh_callback) - + def __init__( + self, + client_id, + client_secret=None, + access_token=None, + refresh_token=None, + refresh_callback=None, + ): + super().__init__( + client_id, client_secret, access_token, refresh_token, refresh_callback + ) def __summary_df(self, summary, metrics=None): """ @@ -30,17 +39,16 @@ def __summary_df(self, summary, metrics=None): metrics = [metrics] else: metrics = metrics.copy() - #drop any invalid cols the user may have entered + # drop any invalid cols the user may have entered metrics = [metric for metric in metrics if metric in df.columns] - #summary_date is a required col - if 'summary_date' not in metrics: - metrics.insert(0, 'summary_date') + # summary_date is a required col + if "summary_date" not in metrics: + metrics.insert(0, "summary_date") df = df[metrics] - df['summary_date'] = pd.to_datetime(df['summary_date']).dt.date - df = df.set_index('summary_date') + df["summary_date"] = pd.to_datetime(df["summary_date"]).dt.date + df = df.set_index("summary_date") return df - def sleep_df_raw(self, start=None, end=None, metrics=None): """ Create a dataframe from sleep summary dict object. @@ -55,10 +63,9 @@ def sleep_df_raw(self, start=None, end=None, metrics=None): :param metrics: Metrics to include in the df. :type metrics: A list of strings, or a string """ - sleep_summary = self.sleep_summary(start, end)['sleep'] + sleep_summary = self.sleep_summary(start, end)["sleep"] return self.__summary_df(sleep_summary, metrics) - def sleep_df_edited(self, start=None, end=None, metrics=None): """ Create a dataframe from sleep summary dict object. @@ -77,7 +84,6 @@ def sleep_df_edited(self, start=None, end=None, metrics=None): sleep_df = SleepConverter().convert_metrics(sleep_df) return sleep_df - def activity_df_raw(self, start=None, end=None, metrics=None): """ Create a dataframe from activity summary dict object. @@ -92,10 +98,9 @@ def activity_df_raw(self, start=None, end=None, metrics=None): :param metrics: Metrics to include in the df. :type metrics: A list of strings, or a string """ - activity_summary = self.activity_summary(start, end)['activity'] + activity_summary = self.activity_summary(start, end)["activity"] return self.__summary_df(activity_summary, metrics) - def activity_df_edited(self, start=None, end=None, metrics=None): """ Create a dataframe from activity summary dict object. @@ -113,7 +118,6 @@ def activity_df_edited(self, start=None, end=None, metrics=None): activity_df = self.activity_df_raw(start, end, metrics) return ActivityConverter().convert_metrics(activity_df) - def readiness_df_raw(self, start=None, end=None, metrics=None): """ Create a dataframe from ready summary dict object. @@ -128,10 +132,9 @@ def readiness_df_raw(self, start=None, end=None, metrics=None): :param metrics: Metrics to include in the df. :type metrics: A list of strings, or a string """ - readiness_summary = self.readiness_summary(start, end)['readiness'] + readiness_summary = self.readiness_summary(start, end)["readiness"] return self.__summary_df(readiness_summary, metrics) - def readiness_df_edited(self, start=None, end=None, metrics=None): """ Create a dataframe from ready summary dict object. @@ -148,7 +151,6 @@ def readiness_df_edited(self, start=None, end=None, metrics=None): """ return self.readiness_df_raw(start, end, metrics) - def combined_df_edited(self, start=None, end=None, metrics=None): """ Combines sleep, activity, and summary into one DF @@ -173,21 +175,22 @@ def combined_df_edited(self, start=None, end=None, metrics=None): def prefix_cols(df, prefix): d_to_rename = {} for col in df.columns: - if col != 'summary_date': - d_to_rename[col] = prefix + ':' + col + if col != "summary_date": + d_to_rename[col] = prefix + ":" + col return df.rename(columns=d_to_rename) sleep_df = self.sleep_df_edited(start, end, metrics) - sleep_df = prefix_cols(sleep_df, 'SLEEP') + sleep_df = prefix_cols(sleep_df, "SLEEP") readiness_df = self.readiness_df_edited(start, end, metrics) - readiness_df = prefix_cols(readiness_df, 'READY') + readiness_df = prefix_cols(readiness_df, "READY") activity_df = self.activity_df_edited(start, end, metrics) - activity_df = prefix_cols(activity_df, 'ACTIVITY') + activity_df = prefix_cols(activity_df, "ACTIVITY") - combined_df = sleep_df.merge(readiness_df, on='summary_date').merge(activity_df, on='summary_date') + combined_df = sleep_df.merge(readiness_df, on="summary_date").merge( + activity_df, on="summary_date" + ) return combined_df - def save_as_xlsx(self, df, file, index=True, **to_excel_kwargs): """ Save dataframe as .xlsx file with dates properly formatted @@ -206,20 +209,25 @@ def localize(df): """ Remove tz from datetime cols since Excel doesn't allow """ - tz_cols = df.select_dtypes(include=['datetimetz']).columns + 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",) + 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(self, df, tablefmt='pretty', is_print=True, filename=None): + def tableize(self, df, tablefmt="pretty", is_print=True, filename=None): """ Converts dataframe to a formatted table For more details, see https://pypi.org/project/tabulate/ @@ -237,16 +245,24 @@ def tableize(self, df, tablefmt='pretty', is_print=True, filename=None): :type filename: string """ from tabulate import tabulate - table = tabulate(df, headers='keys', tablefmt=tablefmt, showindex=True, stralign='center', numalign='center') + + 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: + with open(filename, "w") as f: print(table, file=f) return table -class UnitConverter(): +class UnitConverter: """ Use this class to convert units for certain dataframe cols """ @@ -284,8 +300,8 @@ def convert_to_dt(self, df, dt_metrics): :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') + 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): @@ -299,7 +315,7 @@ def convert_to_hrs(self, df, sec_metrics): :type sec_metrics: List """ df[sec_metrics] = df[sec_metrics] / 60 / 60 - df = self.rename_converted_cols(df, sec_metrics, '_in_hrs') + df = self.rename_converted_cols(df, sec_metrics, "_in_hrs") return df def convert_metrics(self, df): @@ -317,11 +333,20 @@ def convert_metrics(self, df): 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'] + all_dt_metrics = ["bedtime_end", "bedtime_start"] + all_sec_metrics = [ + "awake", + "deep", + "duration", + "light", + "onset_latency", + "rem", + "total", + ] + class ActivityConverter(UnitConverter): - all_dt_metrics = ['day_end', 'day_start'] + all_dt_metrics = ["day_end", "day_start"] all_sec_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 index 9ae572d..80cba4e 100644 --- a/oura/models/activity.py +++ b/oura/models/activity.py @@ -1,6 +1,6 @@ - from helper import OuraModel, from_json + class Activity(OuraModel): _KEYS = [ "summary_date", @@ -32,11 +32,11 @@ class Activity(OuraModel): "met_min_high", "average_met", "class_5min", - "met_1min" + "met_1min", ] -if __name__ == '__main__': +if __name__ == "__main__": test = """ { "summary_date": "2016-09-03", @@ -72,4 +72,4 @@ class Activity(OuraModel): }""" activity = from_json(test, Activity) - print(activity) \ No newline at end of file + print(activity) diff --git a/oura/models/helper.py b/oura/models/helper.py index 1c0e3a7..b3498c1 100644 --- a/oura/models/helper.py +++ b/oura/models/helper.py @@ -3,6 +3,7 @@ logger = logging.getLogger(__name__) + class OuraModel: # TODO factor out common keys, like "summary_date" _KEYS = [] @@ -11,9 +12,8 @@ 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 ] ) + return ", ".join(["{}={}".format(k, getattr(self, k)) for k in self._KEYS]) def set_attrs(instance, lookup): @@ -28,12 +28,16 @@ def from_dict(response_dict, typename: OuraModel): 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__)) - + 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 + return from_dict(json_dict, typename) diff --git a/oura/models/readiness.py b/oura/models/readiness.py index 08677d7..5dbb745 100644 --- a/oura/models/readiness.py +++ b/oura/models/readiness.py @@ -1,6 +1,6 @@ - from helper import OuraModel, from_json + class Readiness(OuraModel): _KEYS = [ "summary_date", @@ -12,11 +12,11 @@ class Readiness(OuraModel): "score_activity_balance", "score_resting_hr", "score_recovery_index", - "score_temperature" + "score_temperature", ] -if __name__ == '__main__': +if __name__ == "__main__": test = """ { "summary_date": "2016-09-03", @@ -32,4 +32,4 @@ class Readiness(OuraModel): }""" readiness = from_json(test, Readiness) - print(readiness) \ No newline at end of file + print(readiness) diff --git a/oura/models/sleep.py b/oura/models/sleep.py index af9a2b4..58c68a0 100644 --- a/oura/models/sleep.py +++ b/oura/models/sleep.py @@ -1,6 +1,6 @@ - from helper import OuraModel, from_json + class Sleep(OuraModel): _KEYS = [ "summary_date", @@ -34,11 +34,11 @@ class Sleep(OuraModel): "temperature_delta", "hypnogram_5min", "hr_5min", - "rmssd_5min" + "rmssd_5min", ] -if __name__ == '__main__': +if __name__ == "__main__": test = """ { "summary_date": "2017-11-05", @@ -76,4 +76,4 @@ class Sleep(OuraModel): }""" sleep = from_json(test, Sleep) - print(sleep) \ No newline at end of file + print(sleep) diff --git a/oura/models/summary_list.py b/oura/models/summary_list.py index b437e4d..9818621 100644 --- a/oura/models/summary_list.py +++ b/oura/models/summary_list.py @@ -1,4 +1,3 @@ - import json from helper import OuraModel, from_json, from_dict, set_attrs from datetime import datetime @@ -6,24 +5,23 @@ from activity import Activity from readiness import Readiness -class OuraSummary: +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 + 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 @@ -50,7 +48,7 @@ def by_date(self): return self._by_date(Readiness) -if __name__ == '__main__': +if __name__ == "__main__": test = """ { "readiness" : [ @@ -69,6 +67,5 @@ def by_date(self): ] }""" - summary = ReadinessSummary(test) - print(summary.by_date()) \ No newline at end of file + print(summary.by_date()) diff --git a/oura/models/user_info.py b/oura/models/user_info.py index 83d48ae..804b153 100644 --- a/oura/models/user_info.py +++ b/oura/models/user_info.py @@ -1,11 +1,12 @@ import json from helper import OuraModel, from_json + class UserInfo(OuraModel): - _KEYS = ['age', 'weight', 'gender', 'email'] + _KEYS = ["age", "weight", "gender", "email"] -if __name__ == '__main__': +if __name__ == "__main__": test = """ { @@ -17,4 +18,3 @@ class UserInfo(OuraModel): u = from_json(test, UserInfo) print(u) - \ No newline at end of file diff --git a/samples/sample.py b/samples/sample.py index d4d9213..eca9823 100644 --- a/samples/sample.py +++ b/samples/sample.py @@ -3,39 +3,40 @@ import json from datetime import datetime + 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 +44,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/tests/test_auth.py b/tests/test_auth.py index e5ad5be..e76b3ba 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -2,6 +2,7 @@ import requests_mock import json + def test_build_authorize_endpoint(): client = OuraOAuth2Client("test_client", "test_secret") actual_url, state = client.authorize_endpoint(scope=["email", "daily"], state="foo") @@ -9,14 +10,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'] + 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 d97b27a..3857334 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,17 +8,20 @@ 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, datatype="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", datatype="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(): @@ -28,12 +31,24 @@ 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 = 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) try: diff --git a/tests/test_client_pandas.py b/tests/test_client_pandas.py index 7565c55..845d033 100644 --- a/tests/test_client_pandas.py +++ b/tests/test_client_pandas.py @@ -9,12 +9,15 @@ from oura import OuraClientDataFrame import json + @pytest.fixture def client(): - #test_token.json is .gitignored - with open(os.path.join(parent_dir, 'tests/', 'test_token.json'), 'r') as f: + # test_token.json is .gitignored + with open(os.path.join(parent_dir, "tests/", "test_token.json"), "r") as f: env = json.load(f) - client = OuraClientDataFrame(env['client_id'], env['client_secret'], env['access_token']) + client = OuraClientDataFrame( + env["client_id"], env["client_secret"], env["access_token"] + ) return client @@ -30,91 +33,119 @@ def test_sleep_summary_df(client): 3. Test raw and edited dataframes are returning correctly named fields and correct data types """ - sleep_df_raw1 = client.sleep_df_raw(start='2020-09-30') - #check all cols are included + sleep_df_raw1 = client.sleep_df_raw(start="2020-09-30") + # check all cols are included assert sleep_df_raw1.shape[1] >= 36 - #check that start date parameter is correct + # check that start date parameter is correct assert sleep_df_raw1.index[0] > date(2020, 9, 29) - sleep_df_raw2 = client.sleep_df_raw(start='2020-09-30', end='2020-10-01', metrics=['bedtime_start', 'score']) - #check that correct metrics are being included + sleep_df_raw2 = client.sleep_df_raw( + start="2020-09-30", end="2020-10-01", metrics=["bedtime_start", "score"] + ) + # check that correct metrics are being included assert sleep_df_raw2.shape[1] == 2 - #check that end date parameter is correct + # check that end date parameter is correct assert sleep_df_raw2.index[-1] < date(2020, 10, 2) - #check that data type has not been altered - assert type(sleep_df_raw2['bedtime_start'][0]) == str + # check that data type has not been altered + assert type(sleep_df_raw2["bedtime_start"][0]) == str - #test that invalid metric 'zzz' is dropped - sleep_df_raw3 = client.sleep_df_raw(start='2020-09-30', end='2020-10-01', metrics=['bedtime_start', 'zzz']) + # test that invalid metric 'zzz' is dropped + sleep_df_raw3 = client.sleep_df_raw( + start="2020-09-30", end="2020-10-01", metrics=["bedtime_start", "zzz"] + ) assert sleep_df_raw3.shape[1] == 1 - #check that bedtime start has been renamed and is now a timestamp - sleep_df_edited = client.sleep_df_edited(start='2020-09-30', end='2020-10-01', metrics=['bedtime_start', 'zzz']) - assert type(sleep_df_edited['bedtime_start_dt_adjusted'][0]) != str + # check that bedtime start has been renamed and is now a timestamp + sleep_df_edited = client.sleep_df_edited( + start="2020-09-30", end="2020-10-01", metrics=["bedtime_start", "zzz"] + ) + assert type(sleep_df_edited["bedtime_start_dt_adjusted"][0]) != str @pytest.mark.skip def test_activity_summary_df(client): - activity_df_raw1 = client.activity_df_raw(start='2020-09-30') - #check all cols are included + activity_df_raw1 = client.activity_df_raw(start="2020-09-30") + # check all cols are included assert activity_df_raw1.shape[1] >= 34 assert activity_df_raw1.index[0] > date(2020, 9, 29) - activity_df_raw2 = client.activity_df_raw(start='2020-09-30', end='2020-10-01', metrics=['day_start', 'medium']) + activity_df_raw2 = client.activity_df_raw( + start="2020-09-30", end="2020-10-01", metrics=["day_start", "medium"] + ) assert activity_df_raw2.shape[1] == 2 assert activity_df_raw2.index[-1] < date(2020, 10, 2) - assert type(activity_df_raw2['day_start'][0]) == str + assert type(activity_df_raw2["day_start"][0]) == str - #test that invalid metric is dropped - activity_df_raw3 = client.activity_df_raw(start='2020-09-30', end='2020-10-01', metrics=['day_start', 'zzz']) + # test that invalid metric is dropped + activity_df_raw3 = client.activity_df_raw( + start="2020-09-30", end="2020-10-01", metrics=["day_start", "zzz"] + ) assert activity_df_raw3.shape[1] == 1 - #check that day_start has been renamed and is now a timestamp - activity_df_edited = client.activity_df_edited(start='2020-09-30', end='2020-10-01', metrics=['day_start', 'zzz']) - assert type(activity_df_edited['day_start_dt_adjusted'][0]) != str + # check that day_start has been renamed and is now a timestamp + activity_df_edited = client.activity_df_edited( + start="2020-09-30", end="2020-10-01", metrics=["day_start", "zzz"] + ) + assert type(activity_df_edited["day_start_dt_adjusted"][0]) != str @pytest.mark.skip def test_ready_summary_df(client): - readiness_df_raw1 = client.readiness_df_raw(start='2020-09-30') - #check all cols are included + readiness_df_raw1 = client.readiness_df_raw(start="2020-09-30") + # check all cols are included assert readiness_df_raw1.shape[1] >= 10 assert readiness_df_raw1.index[0] > date(2020, 9, 29) - readiness_df_raw2 = client.readiness_df_raw(start='2020-09-30', end='2020-10-01', metrics=['score_hrv_balance', 'score_recovery_index']) + readiness_df_raw2 = client.readiness_df_raw( + start="2020-09-30", + end="2020-10-01", + metrics=["score_hrv_balance", "score_recovery_index"], + ) assert readiness_df_raw2.shape[1] == 2 assert readiness_df_raw2.index[-1] < date(2020, 10, 2) - #test that invalid metric is dropped - readiness_df_raw3 = client.readiness_df_raw(start='2020-09-30', end='2020-10-01', metrics=['score_hrv_balance', 'zzz']) + # test that invalid metric is dropped + readiness_df_raw3 = client.readiness_df_raw( + start="2020-09-30", end="2020-10-01", metrics=["score_hrv_balance", "zzz"] + ) assert readiness_df_raw3.shape[1] == 1 - #check that readiness edited and readiness raw is the same - readiness_df_edited = client.readiness_df_edited(start='2020-09-30', end='2020-10-01', metrics='score_hrv_balance') + # check that readiness edited and readiness raw is the same + readiness_df_edited = client.readiness_df_edited( + start="2020-09-30", end="2020-10-01", metrics="score_hrv_balance" + ) assert pd.DataFrame.equals(readiness_df_raw3, readiness_df_edited) - #assert type(readiness_df_edited['day_start_dt_adjusted'][0]) != str + # assert type(readiness_df_edited['day_start_dt_adjusted'][0]) != str @pytest.mark.skip def test_combined_summary_df(): - combined_df_edited1 = client.combined_df_edited(start='2020-09-30') - #check all cols are included + combined_df_edited1 = client.combined_df_edited(start="2020-09-30") + # check all cols are included assert combined_df_edited1.shape[1] >= 80 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']) + # 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']) + # 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 + # 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 @pytest.mark.skip @@ -122,12 +153,16 @@ def test_save_xlsx(client): """ 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') + 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) @@ -137,7 +172,7 @@ def test_tableize(client): """ Check that df was printed to file """ - f = 'df_tableized.txt' - df_raw = client.sleep_df_raw(start='2020-09-30', metrics='score') + 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) From 30d603be75c019651caa9b5bfdf6e6ed47081c90 Mon Sep 17 00:00:00 2001 From: Jon Hagg Date: Fri, 23 Oct 2020 01:33:13 -0700 Subject: [PATCH 04/21] sort imports and get linting to pass --- .flake8 | 4 ++-- noxfile.py | 12 +++++++++++- oura/client.py | 18 +++++++++--------- oura/client_pandas.py | 2 -- oura/models/helper.py | 2 +- oura/models/summary_list.py | 5 +++-- oura/models/user_info.py | 1 - samples/sample.py | 5 +++-- tests/test_auth.py | 6 ++++-- tests/test_client.py | 15 +++++++-------- tests/test_client_pandas.py | 9 +++++---- 11 files changed, 45 insertions(+), 34 deletions(-) diff --git a/.flake8 b/.flake8 index ffa69b0..7d4d22a 100644 --- a/.flake8 +++ b/.flake8 @@ -1,6 +1,6 @@ # .flake8 [flake8] -select = BLK,C,E,F,W -ignore = E203,W503 +select = BLK,C,E,F,I,W +ignore = E203,W503,E501,F401,E731 max-line-length = 88 max-complexity = 10 diff --git a/noxfile.py b/noxfile.py index 5d0e1be..e93b08a 100644 --- a/noxfile.py +++ b/noxfile.py @@ -3,6 +3,7 @@ nox.options.sessions = "lint", "tests" locations = ["oura", "tests", "samples"] + @nox.session def tests(session): args = session.posargs @@ -14,8 +15,10 @@ def tests(session): @nox.session def lint(session): args = session.posargs or locations - session.install("flake8", "flake8-black") + 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 @@ -23,3 +26,10 @@ def black(session): args = session.posargs or locations session.install("black") session.run("black", *args) + + +@nox.session +def isort(session): + args = session.posargs or locations + session.install("isort") + session.run("isort", "-m", "3", "--tc", *args) diff --git a/oura/client.py b/oura/client.py index 2815a15..e83a830 100644 --- a/oura/client.py +++ b/oura/client.py @@ -1,7 +1,8 @@ +import json + from requests_oauthlib import OAuth2Session + from . import exceptions -import json -import requests class OuraOAuth2Client: @@ -59,10 +60,9 @@ def fetch_access_token(self, code): 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. + """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 response properties. """ API_ENDPOINT = "https://api.ouraring.com" @@ -80,10 +80,10 @@ def __init__( """ Initialize the client - :param client_id: The client id from oura portal. + :param client_id: The client id. :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. @@ -92,7 +92,7 @@ def __init__( :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 """ diff --git a/oura/client_pandas.py b/oura/client_pandas.py index b32e5c5..03a11d8 100644 --- a/oura/client_pandas.py +++ b/oura/client_pandas.py @@ -1,5 +1,3 @@ -from datetime import datetime, timedelta -from collections import defaultdict import pandas as pd from .client import OuraClient diff --git a/oura/models/helper.py b/oura/models/helper.py index b3498c1..77f7401 100644 --- a/oura/models/helper.py +++ b/oura/models/helper.py @@ -1,5 +1,5 @@ -import logging import json +import logging logger = logging.getLogger(__name__) diff --git a/oura/models/summary_list.py b/oura/models/summary_list.py index 9818621..cbdebe0 100644 --- a/oura/models/summary_list.py +++ b/oura/models/summary_list.py @@ -1,9 +1,10 @@ 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 helper import OuraModel, set_attrs from readiness import Readiness +from sleep import Sleep class OuraSummary: diff --git a/oura/models/user_info.py b/oura/models/user_info.py index 804b153..e01f462 100644 --- a/oura/models/user_info.py +++ b/oura/models/user_info.py @@ -1,4 +1,3 @@ -import json from helper import OuraModel, from_json diff --git a/samples/sample.py b/samples/sample.py index eca9823..568f52c 100644 --- a/samples/sample.py +++ b/samples/sample.py @@ -1,8 +1,9 @@ -from oura import OuraClient -import os import json +import os from datetime import datetime +from oura import OuraClient + def setEnvironment(envFile): basePath = os.path.dirname(os.path.abspath(__file__)) diff --git a/tests/test_auth.py b/tests/test_auth.py index e76b3ba..e92a887 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,7 +1,9 @@ -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") diff --git a/tests/test_client.py b/tests/test_client.py index 3857334..81b5ba4 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,10 +1,9 @@ -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() @@ -52,7 +51,7 @@ def token_updater(token): client._session.mount(client.API_ENDPOINT, adapter) try: - resp = client.user_info() - except: + client.user_info() + except Exception: pass assert len(update_called) == 1 diff --git a/tests/test_client_pandas.py b/tests/test_client_pandas.py index 845d033..68b9548 100644 --- a/tests/test_client_pandas.py +++ b/tests/test_client_pandas.py @@ -1,14 +1,15 @@ -import pytest +import json import os from datetime import date + import pandas as pd +import pytest + +from oura import OuraClientDataFrame parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) os.sys.path.insert(0, parent_dir) -from oura import OuraClientDataFrame -import json - @pytest.fixture def client(): From 696110b2341dc36995a990a729b9c314c8eb3af8 Mon Sep 17 00:00:00 2001 From: Jon Hagg Date: Sat, 24 Oct 2020 14:57:57 -0700 Subject: [PATCH 05/21] update travis config --- .travis.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index 44b4c93..116a8f8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,13 @@ language: python python: - - "3.6" - - "3.7" - - "3.8" - - "3.9" + - 3.7 + - 3.8 install: - pip install nox -script: nox +env: + - SESSION=tests +jobs: + include: + - python: 3.8 + env: SESSION=lint +script: nox -s $SESSION From 3bb356c328c4cbfaeaefc38ced756bf51d1f988c Mon Sep 17 00:00:00 2001 From: Jon Hagg Date: Sat, 24 Oct 2020 19:22:13 -0700 Subject: [PATCH 06/21] allow personal access token auth and wrapper for bedtime summary --- oura/client.py | 130 ++++++++++++++++++++++++++---------- oura/client_pandas.py | 10 ++- setup.py | 4 +- tests/test_client.py | 6 +- tests/test_client_pandas.py | 4 +- 5 files changed, 107 insertions(+), 47 deletions(-) diff --git a/oura/client.py b/oura/client.py index e83a830..668ac40 100644 --- a/oura/client.py +++ b/oura/client.py @@ -1,5 +1,6 @@ import json +import requests from requests_oauthlib import OAuth2Session from . import exceptions @@ -59,6 +60,62 @@ def fetch_access_token(self, code): ) +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.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, + ) + + 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}) + + 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 @@ -66,19 +123,21 @@ class OuraClient: """ API_ENDPOINT = "https://api.ouraring.com" - TOKEN_BASE_URL = "https://api.ouraring.com/oauth/token" def __init__( self, - client_id, + client_id=None, client_secret=None, access_token=None, refresh_token=None, refresh_callback=None, + personal_access_token=None, ): """ - Initialize the client + Initialize the client - requires either oauth credentials or a personal + access token. Requests made using an instance will be done using a + fixed "mode" :param client_id: The client id. :type client_id: str @@ -95,22 +154,18 @@ def __init__( :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}) + if client_id is not None: + self._auth_handler = OAuthRequestHandler( + client_id, client_secret, access_token, refresh_token, refresh_callback + ) - self._session = OAuth2Session( - client_id, - token=token, - auto_refresh_url=self.TOKEN_BASE_URL, - token_updater=refresh_callback, - ) + if personal_access_token is not None: + self._auth_handler = PersonalRequestHandler(personal_access_token) def user_info(self): """ @@ -123,7 +178,8 @@ def user_info(self): 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 @@ -136,7 +192,8 @@ def sleep_summary(self, start=None, end=None): 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 @@ -149,7 +206,8 @@ def activity_summary(self, start=None, end=None): 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 @@ -160,13 +218,22 @@ def readiness_summary(self, start=None, end=None): 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) + def bedtime_summary(self, start=None, end=None): + """ + Get bedtime summary for the given date range. See + https://cloud.ouraring.com/docs/bedtime + + :param start: Beginning of date range + :type start: date + + :param end: End of date range, or None if you want the current day. + :type end: date + """ + url = self._build_summary_url(start, end, "bedtime") + return self._make_request(url) + + 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")) @@ -182,14 +249,3 @@ def _build_summary_url(self, start, end, datatype): if end: url = "{0}&end={1}".format(url, end) return 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) - - return token diff --git a/oura/client_pandas.py b/oura/client_pandas.py index 03a11d8..0afd57e 100644 --- a/oura/client_pandas.py +++ b/oura/client_pandas.py @@ -11,14 +11,20 @@ class OuraClientDataFrame(OuraClient): def __init__( self, - client_id, + 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 + client_id, + client_secret, + access_token, + refresh_token, + refresh_callback, + personal_access_token, ) def __summary_df(self, summary, metrics=None): diff --git a/setup.py b/setup.py index 196d997..9dfa562 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ URL = 'https://github.com/turing-complet/python-ouraring' EMAIL = 'jhagg314@gmail.com' AUTHOR = 'Jon Hagg' -REQUIRES_PYTHON = '>=3.5.3' +REQUIRES_PYTHON = '>=3.7' VERSION = '1.0.4' REQUIRED = [ @@ -114,8 +114,6 @@ def run(self): 'License :: OSI Approved :: MIT License', 'Programming Language :: Python', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', diff --git a/tests/test_client.py b/tests/test_client.py index 81b5ba4..423d375 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -30,7 +30,9 @@ def test_token_refresh(): def token_updater(token): update_called.append(1) - client = OuraClient("test_id", access_token="token", refresh_callback=token_updater) + client = OuraClient( + client_id="test_id", access_token="token", refresh_callback=token_updater + ) adapter.register_uri( requests_mock.POST, requests_mock.ANY, @@ -49,7 +51,7 @@ def token_updater(token): text=json.dumps({"a": "b"}), ) - client._session.mount(client.API_ENDPOINT, adapter) + client._auth_handler._session.mount(client.API_ENDPOINT, adapter) try: client.user_info() except Exception: diff --git a/tests/test_client_pandas.py b/tests/test_client_pandas.py index 68b9548..093e319 100644 --- a/tests/test_client_pandas.py +++ b/tests/test_client_pandas.py @@ -16,9 +16,7 @@ def client(): # test_token.json is .gitignored with open(os.path.join(parent_dir, "tests/", "test_token.json"), "r") as f: env = json.load(f) - client = OuraClientDataFrame( - env["client_id"], env["client_secret"], env["access_token"] - ) + client = OuraClientDataFrame(personal_access_token=env["personal_access_token"]) return client From e6c9c6ef36de8e378b61431dc0e4c5772aed9106 Mon Sep 17 00:00:00 2001 From: Jon Hagg Date: Sat, 24 Oct 2020 21:11:53 -0700 Subject: [PATCH 07/21] move auth to own module --- .gitignore | 1 + oura/__init__.py | 3 +- oura/auth.py | 112 +++++++++++++++++++++++++++++++++++++++++++++ oura/client.py | 115 +---------------------------------------------- 4 files changed, 116 insertions(+), 115 deletions(-) create mode 100644 oura/auth.py diff --git a/.gitignore b/.gitignore index 0cfc60b..8cdcc42 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ __pycache__ build *.egg-info .tox +.nox docs/_build/ data test_token.json diff --git a/oura/__init__.py b/oura/__init__.py index 8912d14..f3cfa51 100644 --- a/oura/__init__.py +++ b/oura/__init__.py @@ -9,5 +9,6 @@ It's a description for __init__.py, innit. """ -from .client import OuraClient, OuraOAuth2Client +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..3139858 --- /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.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, + ) + + 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 668ac40..98c1487 100644 --- a/oura/client.py +++ b/oura/client.py @@ -1,119 +1,6 @@ import json -import requests -from requests_oauthlib import OAuth2Session - -from . import exceptions - - -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.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, - ) - - 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}) +from . import OAuthRequestHandler, PersonalRequestHandler, exceptions class OuraClient: From f61c602ad357b2ead39cba6513954ee85b6178a1 Mon Sep 17 00:00:00 2001 From: Jon Hagg Date: Sun, 25 Oct 2020 18:48:05 -0700 Subject: [PATCH 08/21] add mock client for tests --- docs/auth.rst | 7 ++- docs/conf.py | 2 +- docs/summaries.rst | 2 +- oura/auth.py | 4 +- oura/client_pandas.py | 18 +++--- tests/__init__.py | 1 + tests/mock_client.py | 125 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 146 insertions(+), 13 deletions(-) create mode 100644 tests/mock_client.py diff --git a/docs/auth.rst b/docs/auth.rst index 737fdb6..78330b6 100644 --- a/docs/auth.rst +++ b/docs/auth.rst @@ -26,4 +26,9 @@ 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 +===================== + +TODO 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/summaries.rst b/docs/summaries.rst index 23f0c55..b54430c 100644 --- a/docs/summaries.rst +++ b/docs/summaries.rst @@ -3,7 +3,7 @@ Daily summaries ******************************** -Oura's API is based on the idea of daily summaries. For each kind of data (sleep, activity, readiness) +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 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. diff --git a/oura/auth.py b/oura/auth.py index 3139858..eaf9bd5 100644 --- a/oura/auth.py +++ b/oura/auth.py @@ -73,9 +73,9 @@ def __init__( token = {} if access_token: - token.update({"access_token": access_token}) + token["access_token"] = access_token if refresh_token: - token.update({"refresh_token": refresh_token}) + token["refresh_token"] = refresh_token self._session = OAuth2Session( client_id, diff --git a/oura/client_pandas.py b/oura/client_pandas.py index 0afd57e..6c4068b 100644 --- a/oura/client_pandas.py +++ b/oura/client_pandas.py @@ -6,7 +6,7 @@ class OuraClientDataFrame(OuraClient): """ Similiar to OuraClient, but data is returned instead - as a pandas.DataFrame (df) object + as a pandas.DataFrame object """ def __init__( @@ -27,7 +27,7 @@ def __init__( personal_access_token, ) - def __summary_df(self, summary, metrics=None): + def _summary_df(self, summary, metrics=None): """ Creates a dataframe from a summary object @@ -38,6 +38,8 @@ def __summary_df(self, summary, metrics=None): :type metrics: A list of metric names, or alternatively a string for one metric name """ df = pd.DataFrame(summary) + if df.size == 0: + return df if metrics: if type(metrics) == str: metrics = [metrics] @@ -67,8 +69,8 @@ def sleep_df_raw(self, start=None, end=None, metrics=None): :param metrics: Metrics to include in the df. :type metrics: A list of strings, or a string """ - sleep_summary = self.sleep_summary(start, end)["sleep"] - return self.__summary_df(sleep_summary, metrics) + sleep_summary = super().sleep_summary(start, end)["sleep"] + return self._summary_df(sleep_summary, metrics) def sleep_df_edited(self, start=None, end=None, metrics=None): """ @@ -102,8 +104,8 @@ def activity_df_raw(self, start=None, end=None, metrics=None): :param metrics: Metrics to include in the df. :type metrics: A list of strings, or a string """ - activity_summary = self.activity_summary(start, end)["activity"] - return self.__summary_df(activity_summary, metrics) + activity_summary = super().activity_summary(start, end)["activity"] + return self._summary_df(activity_summary, metrics) def activity_df_edited(self, start=None, end=None, metrics=None): """ @@ -136,8 +138,8 @@ def readiness_df_raw(self, start=None, end=None, metrics=None): :param metrics: Metrics to include in the df. :type metrics: A list of strings, or a string """ - readiness_summary = self.readiness_summary(start, end)["readiness"] - return self.__summary_df(readiness_summary, metrics) + readiness_summary = super().readiness_summary(start, end)["readiness"] + return self._summary_df(readiness_summary, metrics) def readiness_df_edited(self, start=None, end=None, metrics=None): """ diff --git a/tests/__init__.py b/tests/__init__.py index 633bcca..654ab52 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1,2 @@ from . import test_auth, test_client, test_client_pandas +from .mock_client import MockDataFrameClient, MockOuraClient diff --git a/tests/mock_client.py b/tests/mock_client.py new file mode 100644 index 0000000..cabaa10 --- /dev/null +++ b/tests/mock_client.py @@ -0,0 +1,125 @@ +from oura import OuraClient, OuraClientDataFrame + + +class MockOuraClient(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 MockDataFrameClient(OuraClientDataFrame, MockOuraClient): + pass From 5e6e80454f11f37eb7b9e345061377d4372c99c2 Mon Sep 17 00:00:00 2001 From: Jon Hagg Date: Mon, 26 Oct 2020 01:34:31 -0700 Subject: [PATCH 09/21] fix mock structure, update tests --- tests/__init__.py | 2 +- tests/mock_client.py | 172 +++++++++++++++++++----------------- tests/test_client_pandas.py | 121 ++++++++++--------------- 3 files changed, 138 insertions(+), 157 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index 654ab52..9c1d7b4 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,2 +1,2 @@ from . import test_auth, test_client, test_client_pandas -from .mock_client import MockDataFrameClient, MockOuraClient +from .mock_client import MockClient, MockDataFrameClient diff --git a/tests/mock_client.py b/tests/mock_client.py index cabaa10..2e86f78 100644 --- a/tests/mock_client.py +++ b/tests/mock_client.py @@ -1,7 +1,7 @@ from oura import OuraClient, OuraClientDataFrame -class MockOuraClient(OuraClient): +class MockClient(OuraClient): def user_info(self): return { "age": 27, @@ -13,95 +13,101 @@ def user_info(self): 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, - } + "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, - } + "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, - } + "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): @@ -121,5 +127,5 @@ def bedtime_summary(self, start=None, end=None): } -class MockDataFrameClient(OuraClientDataFrame, MockOuraClient): +class MockDataFrameClient(OuraClientDataFrame, MockClient): pass diff --git a/tests/test_client_pandas.py b/tests/test_client_pandas.py index 093e319..a4fca67 100644 --- a/tests/test_client_pandas.py +++ b/tests/test_client_pandas.py @@ -5,23 +5,12 @@ import pandas as pd import pytest -from oura import OuraClientDataFrame +from .mock_client import MockDataFrameClient -parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -os.sys.path.insert(0, parent_dir) +client = MockDataFrameClient() -@pytest.fixture -def client(): - # test_token.json is .gitignored - with open(os.path.join(parent_dir, "tests/", "test_token.json"), "r") as f: - env = json.load(f) - client = OuraClientDataFrame(personal_access_token=env["personal_access_token"]) - return client - - -@pytest.mark.skip -def test_sleep_summary_df(client): +def test_sleep_summary_df(): """ Objectives: 1. Test that dataframe summary_date match the args passed into @@ -32,96 +21,82 @@ def test_sleep_summary_df(client): 3. Test raw and edited dataframes are returning correctly named fields and correct data types """ - sleep_df_raw1 = client.sleep_df_raw(start="2020-09-30") + start = "2017-11-05" + end = "2017-11-05" + df_raw1 = client.sleep_df_raw(start) # check all cols are included - assert sleep_df_raw1.shape[1] >= 36 + assert df_raw1.shape == (1, 31) # check that start date parameter is correct - assert sleep_df_raw1.index[0] > date(2020, 9, 29) + assert df_raw1.index[0] == date(2017, 11, 5) - sleep_df_raw2 = client.sleep_df_raw( - start="2020-09-30", end="2020-10-01", metrics=["bedtime_start", "score"] - ) + df_raw2 = client.sleep_df_raw(start, end, metrics=["bedtime_start", "score"]) # check that correct metrics are being included - assert sleep_df_raw2.shape[1] == 2 + assert df_raw2.shape[1] == 2 # check that end date parameter is correct - assert sleep_df_raw2.index[-1] < date(2020, 10, 2) + assert df_raw2.index[-1] == date(2017, 11, 5) # check that data type has not been altered - assert type(sleep_df_raw2["bedtime_start"][0]) == str + assert type(df_raw2["bedtime_start"][0]) == str # test that invalid metric 'zzz' is dropped - sleep_df_raw3 = client.sleep_df_raw( - start="2020-09-30", end="2020-10-01", metrics=["bedtime_start", "zzz"] - ) - assert sleep_df_raw3.shape[1] == 1 + df_raw3 = client.sleep_df_raw(start, end, metrics=["bedtime_start", "zzz"]) + assert df_raw3.shape[1] == 1 # check that bedtime start has been renamed and is now a timestamp - sleep_df_edited = client.sleep_df_edited( - start="2020-09-30", end="2020-10-01", metrics=["bedtime_start", "zzz"] - ) - assert type(sleep_df_edited["bedtime_start_dt_adjusted"][0]) != str + df_edited = client.sleep_df_edited(start, end, metrics=["bedtime_start", "zzz"]) + assert type(df_edited["bedtime_start_dt_adjusted"][0]) != str -@pytest.mark.skip -def test_activity_summary_df(client): - activity_df_raw1 = client.activity_df_raw(start="2020-09-30") +def test_activity_summary_df(): + start = "2016-09-03" + end = "2016-09-04" + df_raw1 = client.activity_df_raw(start) # check all cols are included - assert activity_df_raw1.shape[1] >= 34 - assert activity_df_raw1.index[0] > date(2020, 9, 29) + assert df_raw1.shape == (1, 30) + assert df_raw1.index[0] == date(2016, 9, 3) - activity_df_raw2 = client.activity_df_raw( - start="2020-09-30", end="2020-10-01", metrics=["day_start", "medium"] - ) - assert activity_df_raw2.shape[1] == 2 - assert activity_df_raw2.index[-1] < date(2020, 10, 2) - assert type(activity_df_raw2["day_start"][0]) == str + df_raw2 = client.activity_df_raw(start, end, metrics=["day_start", "medium"]) + assert df_raw2.shape[1] == 2 + assert df_raw2.index[-1] == date(2016, 9, 3) + assert type(df_raw2["day_start"][0]) == str # test that invalid metric is dropped - activity_df_raw3 = client.activity_df_raw( - start="2020-09-30", end="2020-10-01", metrics=["day_start", "zzz"] - ) - assert activity_df_raw3.shape[1] == 1 + df_raw3 = client.activity_df_raw(start, end, metrics=["day_start", "zzz"]) + assert df_raw3.shape[1] == 1 # check that day_start has been renamed and is now a timestamp - activity_df_edited = client.activity_df_edited( - start="2020-09-30", end="2020-10-01", metrics=["day_start", "zzz"] - ) - assert type(activity_df_edited["day_start_dt_adjusted"][0]) != str + df_edited = client.activity_df_edited(start, end, metrics=["day_start", "zzz"]) + assert type(df_edited["day_start_dt_adjusted"][0]) != str -@pytest.mark.skip -def test_ready_summary_df(client): - readiness_df_raw1 = client.readiness_df_raw(start="2020-09-30") +def test_ready_summary_df(): + start = "2016-09-03" + end = "2016-09-04" + df_raw1 = client.readiness_df_raw(start) # check all cols are included - assert readiness_df_raw1.shape[1] >= 10 - assert readiness_df_raw1.index[0] > date(2020, 9, 29) + assert df_raw1.shape == (1, 11) + assert df_raw1.index[0] == date(2016, 9, 3) - readiness_df_raw2 = client.readiness_df_raw( - start="2020-09-30", - end="2020-10-01", + df_raw2 = client.readiness_df_raw( + start, + end, metrics=["score_hrv_balance", "score_recovery_index"], ) - assert readiness_df_raw2.shape[1] == 2 - assert readiness_df_raw2.index[-1] < date(2020, 10, 2) + assert df_raw2.shape[1] == 2 + assert df_raw2.index[-1] == date(2016, 9, 3) # test that invalid metric is dropped - readiness_df_raw3 = client.readiness_df_raw( - start="2020-09-30", end="2020-10-01", metrics=["score_hrv_balance", "zzz"] - ) - assert readiness_df_raw3.shape[1] == 1 + df_raw3 = client.readiness_df_raw(start, end, metrics=["score_hrv_balance", "zzz"]) + assert df_raw3.shape[1] == 1 - # check that readiness edited and readiness raw is the same - readiness_df_edited = client.readiness_df_edited( - start="2020-09-30", end="2020-10-01", metrics="score_hrv_balance" - ) - assert pd.DataFrame.equals(readiness_df_raw3, readiness_df_edited) - # assert type(readiness_df_edited['day_start_dt_adjusted'][0]) != str + df_edited = client.readiness_df_edited(start, end, metrics="score_hrv_balance") + assert pd.DataFrame.equals(df_raw3, df_edited) @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[1] >= 80 + assert combined_df_edited1.shape == (0, 72) assert combined_df_edited1.index[0] > date(2020, 9, 29) # check start and end dates work accordingly @@ -148,7 +123,7 @@ def test_combined_summary_df(): @pytest.mark.skip -def test_save_xlsx(client): +def test_save_xlsx(): """ Check that both raw and edited df's save without issue """ @@ -167,7 +142,7 @@ def test_save_xlsx(client): @pytest.mark.skip -def test_tableize(client): +def test_tableize(): """ Check that df was printed to file """ From e171705956ab0f889bfce0202cea4f8b32e1e66f Mon Sep 17 00:00:00 2001 From: Jon Hagg Date: Fri, 30 Oct 2020 00:58:00 -0700 Subject: [PATCH 10/21] better docstrings and validation --- oura/client.py | 66 ++++++++++++++++++++++---------------------- tests/test_client.py | 4 +-- 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/oura/client.py b/oura/client.py index 98c1487..19bfbb0 100644 --- a/oura/client.py +++ b/oura/client.py @@ -4,9 +4,9 @@ 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 response properties. + """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" @@ -22,17 +22,13 @@ def __init__( ): """ - Initialize the client - requires either oauth credentials or a personal - access token. Requests made using an instance will be done using a - fixed "mode" - - :param client_id: The client id. + :param client_id: The client id - identifies your application. :type client_id: str :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 @@ -68,71 +64,75 @@ def sleep_summary(self, start=None, end=None): 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 - :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 - :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) + 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 - :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, "bedtime") + 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 _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 - def _build_summary_url(self, start, end, datatype): + def _build_summary_url(self, start, end, summary_type): if start is None: raise ValueError( - "Request for {} summary must include start date.".format(datatype) + "Request for {} summary must include start date.".format(summary_type) ) + if not isinstance(start, str): + raise TypeError("start date must be of type str") + + url = "{0}/v1/{1}?start={2}".format(self.API_ENDPOINT, summary_type, start) - url = "{0}/v1/{1}?start={2}".format(self.API_ENDPOINT, datatype, start) - if end: + if end is not None: + if not isinstance(end, str): + raise TypeError("end date must be of type str") url = "{0}&end={1}".format(url, end) return url diff --git a/tests/test_client.py b/tests/test_client.py index 423d375..7c5c2b2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -10,13 +10,13 @@ def test_summary_url(): client = OuraClient("test_id") - url = client._build_summary_url(start="start-date", end=None, datatype="sleep") + 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() url2 = client._build_summary_url( - start="start-date", end="end_date", datatype="sleep" + start="start-date", end="end_date", summary_type="sleep" ) parsed_url = urlparse(url2) params = parse_qs(parsed_url.query) From 7f4fbdfb71e32f32a9f5a4aa7bb9d2a4c2c17d75 Mon Sep 17 00:00:00 2001 From: jon Date: Sat, 31 Oct 2020 04:11:43 -0700 Subject: [PATCH 11/21] nox for docs --- .travis.yml | 2 ++ docs/auth.rst | 12 +++++++++++- noxfile.py | 15 +++++++++++++-- samples/sample.py | 7 +++++++ 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 116a8f8..27e27c4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,4 +10,6 @@ jobs: include: - python: 3.8 env: SESSION=lint + - python: 3.8 + env: SESSION=docs script: nox -s $SESSION diff --git a/docs/auth.rst b/docs/auth.rst index 78330b6..61c77b6 100644 --- a/docs/auth.rst +++ b/docs/auth.rst @@ -31,4 +31,14 @@ Now you are ready to make authenticated API requests. Please use this power resp Personal Access Token ===================== -TODO +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 +`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/noxfile.py b/noxfile.py index e93b08a..0e9c8e6 100644 --- a/noxfile.py +++ b/noxfile.py @@ -9,7 +9,7 @@ def tests(session): args = session.posargs session.install("pipenv") session.run("pipenv", "sync") - session.run("pytest", *args) + session.run("pipenv", "run", "pytest", *args) @nox.session @@ -22,14 +22,25 @@ def lint(session): @nox.session +def format(session): + black(session) + isort(session) + + def black(session): args = session.posargs or locations session.install("black") session.run("black", *args) -@nox.session 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("make", "html", external=True) diff --git a/samples/sample.py b/samples/sample.py index 568f52c..af2e447 100644 --- a/samples/sample.py +++ b/samples/sample.py @@ -5,6 +5,13 @@ 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) From a7134a5fb6a675a78ca2d1254a2b4575cc18096d Mon Sep 17 00:00:00 2001 From: Jon Hagg Date: Sat, 31 Oct 2020 15:46:28 -0700 Subject: [PATCH 12/21] cleanup packages --- Pipfile | 3 - Pipfile.lock | 284 +++++++++++++++--------------------------- docs/api.rst | 3 + docs/requirements.txt | 33 ++--- noxfile.py | 1 + requirements.txt | 17 ++- 6 files changed, 119 insertions(+), 222 deletions(-) diff --git a/Pipfile b/Pipfile index d503913..d40198e 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 = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 06629e3..a4fe9c6 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "99603ab7cf2400404ba3fbbb484d243e59e69d68466ff4462083782b891fa090" + "sha256": "d26affca3cbe031cb2d7a848431ea85408d50e2baa4f5b76bac5f9f7c8defdad" }, "pipfile-spec": 6, "requires": {}, @@ -14,13 +14,6 @@ ] }, "default": { - "alabaster": { - "hashes": [ - "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359", - "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02" - ], - "version": "==0.7.12" - }, "attrs": { "hashes": [ "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594", @@ -28,13 +21,6 @@ ], "version": "==20.2.0" }, - "babel": { - "hashes": [ - "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38", - "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4" - ], - "version": "==2.8.0" - }, "backcall": { "hashes": [ "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e", @@ -120,30 +106,30 @@ }, "cryptography": { "hashes": [ - "sha256:21b47c59fcb1c36f1113f3709d37935368e34815ea1d7073862e92f810dc7499", - "sha256:451cdf60be4dafb6a3b78802006a020e6cd709c22d240f94f7a0696240a17154", - "sha256:4549b137d8cbe3c2eadfa56c0c858b78acbeff956bd461e40000b2164d9167c6", - "sha256:48ee615a779ffa749d7d50c291761dc921d93d7cf203dca2db663b4f193f0e49", - "sha256:559d622aef2a2dff98a892eef321433ba5bc55b2485220a8ca289c1ecc2bd54f", - "sha256:5d52c72449bb02dd45a773a203196e6d4fae34e158769c896012401f33064396", - "sha256:65beb15e7f9c16e15934569d29fb4def74ea1469d8781f6b3507ab896d6d8719", - "sha256:680da076cad81cdf5ffcac50c477b6790be81768d30f9da9e01960c4b18a66db", - "sha256:762bc5a0df03c51ee3f09c621e1cee64e3a079a2b5020de82f1613873d79ee70", - "sha256:89aceb31cd5f9fc2449fe8cf3810797ca52b65f1489002d58fe190bfb265c536", - "sha256:983c0c3de4cb9fcba68fd3f45ed846eb86a2a8b8d8bc5bb18364c4d00b3c61fe", - "sha256:99d4984aabd4c7182050bca76176ce2dbc9fa9748afe583a7865c12954d714ba", - "sha256:9d9fc6a16357965d282dd4ab6531013935425d0dc4950df2e0cf2a1b1ac1017d", - "sha256:a7597ffc67987b37b12e09c029bd1dc43965f75d328076ae85721b84046e9ca7", - "sha256:ab010e461bb6b444eaf7f8c813bb716be2d78ab786103f9608ffd37a4bd7d490", - "sha256:b12e715c10a13ca1bd27fbceed9adc8c5ff640f8e1f7ea76416352de703523c8", - "sha256:b2bded09c578d19e08bd2c5bb8fed7f103e089752c9cf7ca7ca7de522326e921", - "sha256:b372026ebf32fe2523159f27d9f0e9f485092e43b00a5adacf732192a70ba118", - "sha256:cb179acdd4ae1e4a5a160d80b87841b3d0e0be84af46c7bb2cd7ece57a39c4ba", - "sha256:e97a3b627e3cb63c415a16245d6cef2139cca18bb1183d1b9375a1c14e83f3b3", - "sha256:f0e099fc4cc697450c3dd4031791559692dd941a95254cb9aeded66a7aa8b9bc", - "sha256:f99317a0fa2e49917689b8cf977510addcfaaab769b3f899b9c481bbd76730c2" - ], - "version": "==3.1.1" + "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" }, "decorator": { "hashes": [ @@ -174,13 +160,6 @@ ], "version": "==2.10" }, - "imagesize": { - "hashes": [ - "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1", - "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1" - ], - "version": "==1.2.0" - }, "iniconfig": { "hashes": [ "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", @@ -190,11 +169,11 @@ }, "ipython": { "hashes": [ - "sha256:2e22c1f74477b5106a6fb301c342ab8c64bb75d702e350f05a649e8cb40a0fb8", - "sha256:a331e78086001931de9424940699691ad49dfb457cea31f5471eae7b78222d5e" + "sha256:c987e8178ced651532b3b1ff9965925bfd445c279239697052561a9ab806d28f", + "sha256:cbb2ef3d5961d44e6a963b9817d4ea4e1fa2eb589c371a470fed14d8d40cbd6a" ], "index": "pypi", - "version": "==7.18.1" + "version": "==7.19.0" }, "ipython-genutils": { "hashes": [ @@ -222,7 +201,6 @@ "sha256:3479b861cc2b6407de5188695fa1a8d57e5072d7059322469b62628869b8e36e", "sha256:d6c6b49683446d2407d2fe3acb7a368a77ff063f9182fe427da15d622adc24cf" ], - "markers": "sys_platform == 'linux'", "version": "==0.4.3" }, "jinja2": { @@ -279,34 +257,42 @@ }, "numpy": { "hashes": [ - "sha256:04c7d4ebc5ff93d9822075ddb1751ff392a4375e5885299445fcebf877f179d5", - "sha256:0bfd85053d1e9f60234f28f63d4a5147ada7f432943c113a11afcf3e65d9d4c8", - "sha256:0c66da1d202c52051625e55a249da35b31f65a81cb56e4c69af0dfb8fb0125bf", - "sha256:0d310730e1e793527065ad7dde736197b705d0e4c9999775f212b03c44a8484c", - "sha256:1669ec8e42f169ff715a904c9b2105b6640f3f2a4c4c2cb4920ae8b2785dac65", - "sha256:2117536e968abb7357d34d754e3733b0d7113d4c9f1d921f21a3d96dec5ff716", - "sha256:3733640466733441295b0d6d3dcbf8e1ffa7e897d4d82903169529fd3386919a", - "sha256:4339741994c775396e1a274dba3609c69ab0f16056c1077f18979bec2a2c2e6e", - "sha256:51ee93e1fac3fe08ef54ff1c7f329db64d8a9c5557e6c8e908be9497ac76374b", - "sha256:54045b198aebf41bf6bf4088012777c1d11703bf74461d70cd350c0af2182e45", - "sha256:58d66a6b3b55178a1f8a5fe98df26ace76260a70de694d99577ddeab7eaa9a9d", - "sha256:59f3d687faea7a4f7f93bd9665e5b102f32f3fa28514f15b126f099b7997203d", - "sha256:62139af94728d22350a571b7c82795b9d59be77fc162414ada6c8b6a10ef5d02", - "sha256:7118f0a9f2f617f921ec7d278d981244ba83c85eea197be7c5a4f84af80a9c3c", - "sha256:7c6646314291d8f5ea900a7ea9c4261f834b5b62159ba2abe3836f4fa6705526", - "sha256:967c92435f0b3ba37a4257c48b8715b76741410467e2bdb1097e8391fccfae15", - "sha256:9a3001248b9231ed73894c773142658bab914645261275f675d86c290c37f66d", - "sha256:aba1d5daf1144b956bc87ffb87966791f5e9f3e1f6fab3d7f581db1f5b598f7a", - "sha256:addaa551b298052c16885fc70408d3848d4e2e7352de4e7a1e13e691abc734c1", - "sha256:b594f76771bc7fc8a044c5ba303427ee67c17a09b36e1fa32bde82f5c419d17a", - "sha256:c35a01777f81e7333bcf276b605f39c872e28295441c265cd0c860f4b40148c1", - "sha256:cebd4f4e64cfe87f2039e4725781f6326a61f095bc77b3716502bed812b385a9", - "sha256:d526fa58ae4aead839161535d59ea9565863bb0b0bdb3cc63214613fb16aced4", - "sha256:d7ac33585e1f09e7345aa902c281bd777fdb792432d27fca857f39b70e5dd31c", - "sha256:e6ddbdc5113628f15de7e4911c02aed74a4ccff531842c583e5032f6e5a179bd", - "sha256:eb25c381d168daf351147713f49c626030dcff7a393d5caa62515d415a6071d8" - ], - "version": "==1.19.2" + "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": [ @@ -324,28 +310,33 @@ }, "pandas": { "hashes": [ - "sha256:206d7c3e5356dcadf082e64dc25c24bc8541718045826074f96346e9d6d05a20", - "sha256:24f61f40febe47edac271eda45d683e42838b7db2bd0f82574d9800259d2b182", - "sha256:3a038cd5da602b955d335aa80cbaa0e5774f68501ff47b9c21509906981478da", - "sha256:427be9938b2f79ab298de84f87693914cda238a27cf10580da96caf3dff64115", - "sha256:54f5f564058b0280d588c3758abde82e280702c440db5faf0c686b80336096f9", - "sha256:5a8a84b75ca3a29bb4263b35d5ed9fcaae2b062f014feed8c5daa897339c7d85", - "sha256:84a4ffe668df357e31f98c829536e3a7142c3036c82f996e639f644c5d32eda1", - "sha256:882012763668af54b48f1412bab95c5cc0a7ccce5a2a8221cfc3839a6e3394ef", - "sha256:920d30fdff65a079f071db635d282b4f583c2b26f2b58d5dca218aac7c59974d", - "sha256:a605054fbca71ed1d08bb2aef6f73c84a579bbac956bfe8f9718d5e84cb41248", - "sha256:b11b496c317dbe007898de699fd59eaf687d0fe8c1b7dad109db6010155d28ae", - "sha256:babbeda2f83b0686c9ad38d93b10516e68cdcd5771007eb80a763e98aaf44613", - "sha256:c22e40f1b4d162ca18eb6b2c572e63eef220dbc9cc3de0241cefb77972621bb7", - "sha256:ca31ac8578d48da354cf66a473d4d5ff99277ca71d321dc7ea4e6fad3c6bb0fd", - "sha256:ca71a5aa9eeb3ef5b31feca7d9b6369d6b3d0b2e9c85d7a89abe3ecb013f1e86", - "sha256:d6b1f9d506dc23da2915bcae5c5968990049c9cec44108bd9855d2c7c89d91dc", - "sha256:d89dbc58aec1544722a8d5046f880b597c497ef8a82c5fe695b4b2effafac5ec", - "sha256:df43ea0e9fd9f9672b0de9cac26d01255ad50481994bf3cb4687c21eec2d7bbc", - "sha256:fd6f05b6101d0e76f3e5c26a47be5be7be96ed84ef3981dc1852e76898e73594" + "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" ], "index": "pypi", - "version": "==1.1.3" + "version": "==1.1.4" }, "parso": { "hashes": [ @@ -371,10 +362,10 @@ }, "pkginfo": { "hashes": [ - "sha256:78d032b5888ec06d7f9d18fbf8c0549a6a3477081b34cb769119a07183624fc1", - "sha256:dd008e95b13141ddd05d7e8881f0c0366a998ab90b25c2db794a1714b71583cc" + "sha256:a6a4ac943b496745cec21f14f021bbd869d5e9b4f6ec06918cffea5a2f4b9193", + "sha256:ce14d7296c673dc4c61c759a0b6c14bae34e34eb819c0017bb6ca5b7292c56e9" ], - "version": "==1.6.0" + "version": "==1.6.1" }, "pluggy": { "hashes": [ @@ -413,10 +404,10 @@ }, "pygments": { "hashes": [ - "sha256:307543fe65c0947b126e83dd5a61bd8acbd84abec11f43caebaf5534cbc17998", - "sha256:926c3f319eda178d1bd90851e4317e6d8cdb5e292a3386aac9bd75eca29cf9c7" + "sha256:381985fcc551eb9d37c52088a32914e00517e57f4a21609f48141ba08e193fa0", + "sha256:88a0bbcd659fcb9573703957c6b9cff9fab7295e6e76db54c9d00ae42df32773" ], - "version": "==2.7.1" + "version": "==2.7.2" }, "pyparsing": { "hashes": [ @@ -427,11 +418,11 @@ }, "pytest": { "hashes": [ - "sha256:7a8190790c17d79a11f847fba0b004ee9a8122582ebff4729a082c109e81a4c9", - "sha256:8f593023c1a0f916110285b6efd7f99db07d59546e3d8c36fc60e2ab05d3be92" + "sha256:4288fed0d9153d9646bfcdf0c0428197dba1ecb27a33bb6e031d002fa88653fe", + "sha256:c0a7e94a8cdbc5422a51ccdad8e6f1024795939cc89159a0ae7f0b316ad3823e" ], "index": "pypi", - "version": "==6.1.1" + "version": "==6.1.2" }, "python-dateutil": { "hashes": [ @@ -507,71 +498,6 @@ ], "version": "==1.15.0" }, - "snowballstemmer": { - "hashes": [ - "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0", - "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52" - ], - "version": "==2.0.0" - }, - "sphinx": { - "hashes": [ - "sha256:321d6d9b16fa381a5306e5a0b76cd48ffbc588e6340059a729c6fdd66087e0e8", - "sha256:ce6fd7ff5b215af39e2fcd44d4a321f6694b4530b6f2b2109b64d120773faea0" - ], - "index": "pypi", - "version": "==3.2.1" - }, - "sphinx-rtd-theme": { - "hashes": [ - "sha256:22c795ba2832a169ca301cd0a083f7a434e09c538c70beb42782c073651b707d", - "sha256:373413d0f82425aaa28fb288009bf0d0964711d347763af2f1b65cafcb028c82" - ], - "index": "pypi", - "version": "==0.5.0" - }, - "sphinxcontrib-applehelp": { - "hashes": [ - "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a", - "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58" - ], - "version": "==1.0.2" - }, - "sphinxcontrib-devhelp": { - "hashes": [ - "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e", - "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4" - ], - "version": "==1.0.2" - }, - "sphinxcontrib-htmlhelp": { - "hashes": [ - "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f", - "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b" - ], - "version": "==1.0.3" - }, - "sphinxcontrib-jsmath": { - "hashes": [ - "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", - "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" - ], - "version": "==1.0.1" - }, - "sphinxcontrib-qthelp": { - "hashes": [ - "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72", - "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6" - ], - "version": "==1.0.3" - }, - "sphinxcontrib-serializinghtml": { - "hashes": [ - "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc", - "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a" - ], - "version": "==1.1.4" - }, "toml": { "hashes": [ "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f", @@ -581,10 +507,10 @@ }, "tqdm": { "hashes": [ - "sha256:43ca183da3367578ebf2f1c2e3111d51ea161ed1dc4e6345b86e27c2a93beff7", - "sha256:69dfa6714dee976e2425a9aab84b622675b7b1742873041e3db8a8e86132a4af" + "sha256:9ad44aaf0fc3697c06f6e05c7cf025dd66bc7bcb7613c66d85f4464c47ac8fad", + "sha256:ef54779f1c09f346b2b5a8e5c61f96fbcb639929e640e59f8cf810794f406432" ], - "version": "==4.50.2" + "version": "==4.51.0" }, "traitlets": { "hashes": [ @@ -628,14 +554,6 @@ "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c" ], "version": "==1.0.1" - }, - "wheel": { - "hashes": [ - "sha256:497add53525d16c173c2c1c733b8f655510e909ea78cc0e29d374243544b77a2", - "sha256:99a22d87add3f634ff917310a3d87e499f19e663413a52eb9232c447aa646c9f" - ], - "index": "pypi", - "version": "==0.35.1" } }, "develop": {} diff --git a/docs/api.rst b/docs/api.rst index e820fe3..abf0de1 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -15,3 +15,6 @@ Client :synopsis: Probably the best way to call the Oura API using python. :members: +.. automodule:: oura.client_pandas + :synopsis: Probably the best way to call the Oura API using python. + :members: diff --git a/docs/requirements.txt b/docs/requirements.txt index f351768..6163981 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,26 +1,7 @@ -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 +flask +pandas +pytest +requests-mock +requests-oauthlib diff --git a/noxfile.py b/noxfile.py index 0e9c8e6..2af7acf 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,5 +1,6 @@ import nox +nox.options.reuse_existing_virtualenvs = True nox.options.sessions = "lint", "tests" locations = ["oura", "tests", "samples"] diff --git a/requirements.txt b/requirements.txt index 90ef365..17f4241 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,7 @@ -flask==1.1.2 -ipython==7.18.1 -pandas==1.1.3 -pytest==6.1.1 -requests-mock==1.8.0 -requests-oauthlib==1.3.0 -sphinx-rtd-theme==0.5.0 -sphinx==3.2.1 -twine==3.2.0 -wheel==0.35.1 +flask +ipython +pandas +pytest +requests-mock +requests-oauthlib +twine From 50405f4b023f7c8dd58e77b70b76e43e3e94f7cc Mon Sep 17 00:00:00 2001 From: Jon Hagg Date: Sun, 1 Nov 2020 13:23:16 -0800 Subject: [PATCH 13/21] separate files for writing output and converters, flatten bedtime json before data frame --- .travis.yml | 2 + oura/client_pandas.py | 188 +++++------------------------------- oura/converters.py | 91 +++++++++++++++++ oura/writers.py | 73 ++++++++++++++ tests/test_client_pandas.py | 30 ------ tests/test_writers.py | 37 +++++++ 6 files changed, 228 insertions(+), 193 deletions(-) create mode 100644 oura/converters.py create mode 100644 oura/writers.py create mode 100644 tests/test_writers.py diff --git a/.travis.yml b/.travis.yml index 27e27c4..16a2663 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,7 @@ language: python python: + - 3.5 + - 3.6 - 3.7 - 3.8 install: diff --git a/oura/client_pandas.py b/oura/client_pandas.py index 6c4068b..fab69ba 100644 --- a/oura/client_pandas.py +++ b/oura/client_pandas.py @@ -1,6 +1,7 @@ import pandas as pd from .client import OuraClient +from .converters import ActivityConverter, SleepConverter, UnitConverter class OuraClientDataFrame(OuraClient): @@ -27,7 +28,7 @@ def __init__( personal_access_token, ) - def _summary_df(self, summary, metrics=None): + def _summary_df(self, summary, metrics=None, date_key="summary_date"): """ Creates a dataframe from a summary object @@ -51,8 +52,8 @@ def _summary_df(self, summary, metrics=None): if "summary_date" not in metrics: metrics.insert(0, "summary_date") df = df[metrics] - df["summary_date"] = pd.to_datetime(df["summary_date"]).dt.date - df = df.set_index("summary_date") + df[date_key] = pd.to_datetime(df[date_key]).dt.date + df = df.set_index(date_key) return df def sleep_df_raw(self, start=None, end=None, metrics=None): @@ -157,6 +158,27 @@ def readiness_df_edited(self, start=None, end=None, metrics=None): """ return self.readiness_df_raw(start, end, metrics) + def bedtime_df_raw(self, start=None, end=None, metrics=None): + """ + Create a dataframe from bedtime summary + The dataframe is minimally edited, i.e 'raw' + + :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"] + for s in bedtime_summary: + s["window_start"] = s["bedtime_window"]["start"] + s["window_end"] = s["bedtime_window"]["end"] + del s["bedtime_window"] + return self._summary_df(bedtime_summary, metrics, date_key="date") + def combined_df_edited(self, start=None, end=None, metrics=None): """ Combines sleep, activity, and summary into one DF @@ -196,163 +218,3 @@ def prefix_cols(df, prefix): activity_df, on="summary_date" ) return combined_df - - def save_as_xlsx(self, 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(self, 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 - - -class UnitConverter: - """ - Use this class to convert units for certain dataframe cols - """ - - all_dt_metrics = [] - all_sec_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 convert_metrics(self, df): - """ - Convert metrics to new unit type - - :param df: dataframe - :type df: pandas dataframe obj - """ - dt_metrics = [col for col in df.columns if col in self.all_dt_metrics] - sec_metrics = [col for col in df.columns if col in self.all_sec_metrics] - if dt_metrics: - df = self.convert_to_dt(df, dt_metrics) - if 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", - ] - - -class ActivityConverter(UnitConverter): - all_dt_metrics = ["day_end", "day_start"] - all_sec_metrics = [] diff --git a/oura/converters.py b/oura/converters.py new file mode 100644 index 0000000..57a9204 --- /dev/null +++ b/oura/converters.py @@ -0,0 +1,91 @@ +import pandas as pd + + +class UnitConverter: + """ + Use this class to convert units for certain dataframe cols + """ + + all_dt_metrics = [] + all_sec_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 convert_metrics(self, df): + """ + Convert metrics to new unit type + + :param df: dataframe + :type df: pandas dataframe obj + """ + dt_metrics = [col for col in df.columns if col in self.all_dt_metrics] + sec_metrics = [col for col in df.columns if col in self.all_sec_metrics] + if dt_metrics: + df = self.convert_to_dt(df, dt_metrics) + if 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", + ] + + +class ActivityConverter(UnitConverter): + all_dt_metrics = ["day_end", "day_start"] + all_sec_metrics = [] 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/tests/test_client_pandas.py b/tests/test_client_pandas.py index a4fca67..f917fe2 100644 --- a/tests/test_client_pandas.py +++ b/tests/test_client_pandas.py @@ -120,33 +120,3 @@ def test_combined_summary_df(): 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 - - -@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) 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) From f12e365698306db7fe6e0b24c82237fe4c704d80 Mon Sep 17 00:00:00 2001 From: Jon Hagg Date: Sun, 1 Nov 2020 13:32:59 -0800 Subject: [PATCH 14/21] drop ipython dependency --- .travis.yml | 1 - Pipfile | 1 - Pipfile.lock | 94 +++--------------------------------------------- requirements.txt | 1 - 4 files changed, 4 insertions(+), 93 deletions(-) diff --git a/.travis.yml b/.travis.yml index 16a2663..7f79984 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,5 @@ language: python python: - - 3.5 - 3.6 - 3.7 - 3.8 diff --git a/Pipfile b/Pipfile index d40198e..bef28be 100644 --- a/Pipfile +++ b/Pipfile @@ -9,7 +9,6 @@ verify_ssl = true requests-oauthlib = "*" flask = "*" twine = "*" -ipython = "*" requests-mock = "*" pytest = "*" pandas = "*" diff --git a/Pipfile.lock b/Pipfile.lock index a4fe9c6..967b2b2 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "d26affca3cbe031cb2d7a848431ea85408d50e2baa4f5b76bac5f9f7c8defdad" + "sha256": "dc5fb6bd54030c827b464a9dfac7b7783226b74bd88deda65f67db89ad226c3c" }, "pipfile-spec": 6, "requires": {}, @@ -21,13 +21,6 @@ ], "version": "==20.2.0" }, - "backcall": { - "hashes": [ - "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e", - "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255" - ], - "version": "==0.2.0" - }, "bleach": { "hashes": [ "sha256:52b5919b81842b1854196eaae5ca29679a2f2e378905c346d3ca8227c2c66080", @@ -131,13 +124,6 @@ ], "version": "==3.2.1" }, - "decorator": { - "hashes": [ - "sha256:41fa54c2a0cc4ba648be4fd43cff00aedf5b9465c9bf18d64325bc225f08f760", - "sha256:e3a62f0520172440ca0dcc823749319382e377f37f140a0b99ef45fecb84bfe7" - ], - "version": "==4.4.2" - }, "docutils": { "hashes": [ "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af", @@ -167,21 +153,6 @@ ], "version": "==1.1.1" }, - "ipython": { - "hashes": [ - "sha256:c987e8178ced651532b3b1ff9965925bfd445c279239697052561a9ab806d28f", - "sha256:cbb2ef3d5961d44e6a963b9817d4ea4e1fa2eb589c371a470fed14d8d40cbd6a" - ], - "index": "pypi", - "version": "==7.19.0" - }, - "ipython-genutils": { - "hashes": [ - "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8", - "sha256:eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8" - ], - "version": "==0.2.0" - }, "itsdangerous": { "hashes": [ "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", @@ -189,13 +160,6 @@ ], "version": "==1.1.0" }, - "jedi": { - "hashes": [ - "sha256:86ed7d9b750603e4ba582ea8edc678657fb4007894a12bcf6f4bb97892f31d20", - "sha256:98cc583fa0f2f8304968199b01b6b4b94f469a1f4a74c1560506ca2a211378b5" - ], - "version": "==0.17.2" - }, "jeepney": { "hashes": [ "sha256:3479b861cc2b6407de5188695fa1a8d57e5072d7059322469b62628869b8e36e", @@ -338,28 +302,6 @@ "index": "pypi", "version": "==1.1.4" }, - "parso": { - "hashes": [ - "sha256:97218d9159b2520ff45eb78028ba8b50d2bc61dcc062a9682666f2dc4bd331ea", - "sha256:caba44724b994a8a5e086460bb212abc5a8bc46951bf4a9a1210745953622eb9" - ], - "version": "==0.7.1" - }, - "pexpect": { - "hashes": [ - "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937", - "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c" - ], - "markers": "sys_platform != 'win32'", - "version": "==4.8.0" - }, - "pickleshare": { - "hashes": [ - "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca", - "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56" - ], - "version": "==0.7.5" - }, "pkginfo": { "hashes": [ "sha256:a6a4ac943b496745cec21f14f021bbd869d5e9b4f6ec06918cffea5a2f4b9193", @@ -374,20 +316,6 @@ ], "version": "==0.13.1" }, - "prompt-toolkit": { - "hashes": [ - "sha256:25c95d2ac813909f813c93fde734b6e44406d1477a9faef7c915ff37d39c0a8c", - "sha256:7debb9a521e0b1ee7d2fe96ee4bd60ef03c6492784de0547337ca4433e46aa63" - ], - "version": "==3.0.8" - }, - "ptyprocess": { - "hashes": [ - "sha256:923f299cc5ad920c68f2bc0bc98b75b9f838b93b599941a6b63ddbc2476394c0", - "sha256:d7cc528d76e76342423ca640335bd3633420dc1366f258cb31d05e865ef5ca1f" - ], - "version": "==0.6.0" - }, "py": { "hashes": [ "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", @@ -500,10 +428,10 @@ }, "toml": { "hashes": [ - "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f", - "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88" + "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", + "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" ], - "version": "==0.10.1" + "version": "==0.10.2" }, "tqdm": { "hashes": [ @@ -512,13 +440,6 @@ ], "version": "==4.51.0" }, - "traitlets": { - "hashes": [ - "sha256:178f4ce988f69189f7e523337a3e11d91c786ded9360174a3d9ca83e79bc5396", - "sha256:69ff3f9d5351f31a7ad80443c2674b7099df13cc41fc5fa6e2f6d3b0330b0426" - ], - "version": "==5.0.5" - }, "twine": { "hashes": [ "sha256:34352fd52ec3b9d29837e6072d5a2a7c6fe4290e97bba46bb8d478b5c598f7ab", @@ -534,13 +455,6 @@ ], "version": "==1.25.11" }, - "wcwidth": { - "hashes": [ - "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784", - "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83" - ], - "version": "==0.2.5" - }, "webencodings": { "hashes": [ "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", diff --git a/requirements.txt b/requirements.txt index 17f4241..2ddcdfe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ flask -ipython pandas pytest requests-mock From 3c996a69b8b4eb0e4b9f4b173bf3c7388206d098 Mon Sep 17 00:00:00 2001 From: Jon Hagg Date: Mon, 2 Nov 2020 02:03:58 -0800 Subject: [PATCH 15/21] start date optional, some docs cleanup --- docs/Makefile | 5 ++++- docs/api.rst | 11 ++++++++--- docs/auth.rst | 8 ++++++-- docs/requirements.txt | 1 - docs/summaries.rst | 10 +++++----- noxfile.py | 3 ++- oura/client.py | 21 +++++++++++---------- 7 files changed, 36 insertions(+), 23 deletions(-) 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 abf0de1..d7e403e 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -6,9 +6,9 @@ API Purpose ================ -Reference for full api surface. +Reference for api surface. -Client +Module Index ================ .. automodule:: oura.client @@ -16,5 +16,10 @@ Client :members: .. automodule:: oura.client_pandas - :synopsis: Probably the best way to call the Oura API using python. + :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 61c77b6..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 `_ @@ -34,7 +38,7 @@ 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 -`OuraClient` instance and you'll be ready to go. See what I mean:: +:class:`oura.OuraClient` instance and you'll be ready to go. See what I mean :: import os from oura import OuraClient diff --git a/docs/requirements.txt b/docs/requirements.txt index 6163981..a0373ff 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,6 +1,5 @@ sphinx sphinx-rtd-theme -flask pandas pytest requests-mock diff --git a/docs/summaries.rst b/docs/summaries.rst index b54430c..5cbc841 100644 --- a/docs/summaries.rst +++ b/docs/summaries.rst @@ -4,15 +4,15 @@ Daily summaries ******************************** 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 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. +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 index 2af7acf..18a5368 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,6 +1,5 @@ import nox -nox.options.reuse_existing_virtualenvs = True nox.options.sessions = "lint", "tests" locations = ["oura", "tests", "samples"] @@ -44,4 +43,6 @@ def isort(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/client.py b/oura/client.py index 19bfbb0..8d85c65 100644 --- a/oura/client.py +++ b/oura/client.py @@ -52,7 +52,7 @@ def __init__( 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 """ @@ -122,17 +122,18 @@ def _make_request(self, url): return payload def _build_summary_url(self, start, end, summary_type): - if start is None: - raise ValueError( - "Request for {} summary must include start date.".format(summary_type) - ) - if not isinstance(start, str): - raise TypeError("start date must be of type str") - - url = "{0}/v1/{1}?start={2}".format(self.API_ENDPOINT, summary_type, start) + 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") - url = "{0}&end={1}".format(url, end) + params["end"] = end + + qs = "&".join([f"{k}={v}" for k, v in params.items()]) + url = f"{url}?{qs}" return url From db6dd63e669908a4c2c0c49b11400853aca3b8b6 Mon Sep 17 00:00:00 2001 From: Jon Hagg Date: Mon, 9 Nov 2020 02:07:16 -0800 Subject: [PATCH 16/21] remove unused code and simplify pandas interface --- oura/client_pandas.py | 143 +++++++++++++----------------------- oura/converters.py | 14 ++-- oura/models/activity.py | 75 ------------------- oura/models/helper.py | 43 ----------- oura/models/readiness.py | 35 --------- oura/models/sleep.py | 79 -------------------- oura/models/summary_list.py | 72 ------------------ oura/models/user_info.py | 19 ----- tests/mock_client.py | 14 ++++ tests/test_client_pandas.py | 32 +++++--- 10 files changed, 94 insertions(+), 432 deletions(-) delete mode 100644 oura/models/activity.py delete mode 100644 oura/models/helper.py delete mode 100644 oura/models/readiness.py delete mode 100644 oura/models/sleep.py delete mode 100644 oura/models/summary_list.py delete mode 100644 oura/models/user_info.py diff --git a/oura/client_pandas.py b/oura/client_pandas.py index fab69ba..c3afa72 100644 --- a/oura/client_pandas.py +++ b/oura/client_pandas.py @@ -4,6 +4,41 @@ 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 + """ + + if isinstance(summary, dict): + summary = [summary] + df = pd.DataFrame(summary) + if df.size == 0: + return df + if metrics: + 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] + + # summary_date is a required col + # TODO: handle bedtime + if "summary_date" not in metrics: + metrics.insert(0, "summary_date") + + 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 @@ -28,35 +63,7 @@ def __init__( personal_access_token, ) - def _summary_df(self, 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 - """ - df = pd.DataFrame(summary) - if df.size == 0: - return df - if metrics: - if type(metrics) == str: - metrics = [metrics] - else: - metrics = metrics.copy() - # drop any invalid cols the user may have entered - metrics = [metric for metric in metrics if metric in df.columns] - # summary_date is a required col - if "summary_date" not in metrics: - metrics.insert(0, "summary_date") - df = df[metrics] - df[date_key] = pd.to_datetime(df[date_key]).dt.date - df = df.set_index(date_key) - return df - - def sleep_df_raw(self, start=None, end=None, metrics=None): + def sleep_df(self, start=None, end=None, metrics=None, convert=True): """ Create a dataframe from sleep summary dict object. The dataframe is minimally edited, i.e 'raw' @@ -71,27 +78,12 @@ def sleep_df_raw(self, start=None, end=None, metrics=None): :type metrics: A list of strings, or a string """ sleep_summary = super().sleep_summary(start, end)["sleep"] - return self._summary_df(sleep_summary, metrics) - - def sleep_df_edited(self, start=None, end=None, metrics=None): - """ - Create a dataframe from sleep summary dict object. - Some cols are unit converted for easier use or readability. - - :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 - """ - sleep_df = self.sleep_df_raw(start, end, metrics) - sleep_df = SleepConverter().convert_metrics(sleep_df) - return sleep_df + df = to_pandas(sleep_summary, metrics) + if convert: + return SleepConverter().convert_metrics(df) + return df - def activity_df_raw(self, start=None, end=None, metrics=None): + def activity_df(self, start=None, end=None, metrics=None, convert=True): """ Create a dataframe from activity summary dict object. The dataframe is minimally edited, i.e 'raw' @@ -106,26 +98,12 @@ def activity_df_raw(self, start=None, end=None, metrics=None): :type metrics: A list of strings, or a string """ activity_summary = super().activity_summary(start, end)["activity"] - return self._summary_df(activity_summary, metrics) - - def activity_df_edited(self, start=None, end=None, metrics=None): - """ - Create a dataframe from activity summary dict object. - Some cols are unit converted for easier use or readability. - - :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 - """ - activity_df = self.activity_df_raw(start, end, metrics) - return ActivityConverter().convert_metrics(activity_df) + df = to_pandas(activity_summary, metrics) + if convert: + return ActivityConverter().convert_metrics(df) + return df - def readiness_df_raw(self, start=None, end=None, metrics=None): + def readiness_df(self, start=None, end=None, metrics=None): """ Create a dataframe from ready summary dict object. The dataframe is minimally edited, i.e 'raw' @@ -140,25 +118,9 @@ def readiness_df_raw(self, start=None, end=None, metrics=None): :type metrics: A list of strings, or a string """ readiness_summary = super().readiness_summary(start, end)["readiness"] - return self._summary_df(readiness_summary, metrics) - - def readiness_df_edited(self, start=None, end=None, metrics=None): - """ - Create a dataframe from ready summary dict object. - Readiness has no cols to unit convert. - - :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 - """ - return self.readiness_df_raw(start, end, metrics) + return to_pandas(readiness_summary, metrics) - def bedtime_df_raw(self, start=None, end=None, metrics=None): + def bedtime_df(self, start=None, end=None, metrics=None): """ Create a dataframe from bedtime summary The dataframe is minimally edited, i.e 'raw' @@ -177,8 +139,9 @@ def bedtime_df_raw(self, start=None, end=None, metrics=None): s["window_start"] = s["bedtime_window"]["start"] s["window_end"] = s["bedtime_window"]["end"] del s["bedtime_window"] - return self._summary_df(bedtime_summary, metrics, date_key="date") + 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 @@ -207,11 +170,11 @@ def prefix_cols(df, prefix): d_to_rename[col] = prefix + ":" + col return df.rename(columns=d_to_rename) - sleep_df = self.sleep_df_edited(start, end, metrics) + sleep_df = self.sleep_df(start, end, metrics) sleep_df = prefix_cols(sleep_df, "SLEEP") - readiness_df = self.readiness_df_edited(start, end, metrics) + readiness_df = self.readiness_df(start, end, metrics) readiness_df = prefix_cols(readiness_df, "READY") - activity_df = self.activity_df_edited(start, end, metrics) + 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( diff --git a/oura/converters.py b/oura/converters.py index 57a9204..8521b34 100644 --- a/oura/converters.py +++ b/oura/converters.py @@ -9,7 +9,7 @@ class UnitConverter: all_dt_metrics = [] all_sec_metrics = [] - def rename_converted_cols(self, df, metrics, suffix_str): + 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' @@ -28,7 +28,7 @@ def rename_converted_cols(self, df, metrics, suffix_str): df = df.rename(columns=d_to_rename) return df - def convert_to_dt(self, df, dt_metrics): + def _convert_to_dt(self, df, dt_metrics): """ Convert dataframe fields to datetime dtypes @@ -40,10 +40,10 @@ def convert_to_dt(self, df, dt_metrics): """ 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") + df = self._rename_converted_cols(df, dt_metrics, "_dt_adjusted") return df - def convert_to_hrs(self, df, sec_metrics): + def _convert_to_hrs(self, df, sec_metrics): """ Convert fields from seconds to minutes @@ -54,7 +54,7 @@ def convert_to_hrs(self, df, sec_metrics): :type sec_metrics: List """ df[sec_metrics] = df[sec_metrics] / 60 / 60 - df = self.rename_converted_cols(df, sec_metrics, "_in_hrs") + df = self._rename_converted_cols(df, sec_metrics, "_in_hrs") return df def convert_metrics(self, df): @@ -67,9 +67,9 @@ def convert_metrics(self, df): dt_metrics = [col for col in df.columns if col in self.all_dt_metrics] sec_metrics = [col for col in df.columns if col in self.all_sec_metrics] if dt_metrics: - df = self.convert_to_dt(df, dt_metrics) + df = self._convert_to_dt(df, dt_metrics) if sec_metrics: - df = self.convert_to_hrs(df, sec_metrics) + df = self._convert_to_hrs(df, sec_metrics) return df diff --git a/oura/models/activity.py b/oura/models/activity.py deleted file mode 100644 index 80cba4e..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) diff --git a/oura/models/helper.py b/oura/models/helper.py deleted file mode 100644 index 77f7401..0000000 --- a/oura/models/helper.py +++ /dev/null @@ -1,43 +0,0 @@ -import json -import logging - -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) diff --git a/oura/models/readiness.py b/oura/models/readiness.py deleted file mode 100644 index 5dbb745..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) diff --git a/oura/models/sleep.py b/oura/models/sleep.py deleted file mode 100644 index 58c68a0..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) diff --git a/oura/models/summary_list.py b/oura/models/summary_list.py deleted file mode 100644 index cbdebe0..0000000 --- a/oura/models/summary_list.py +++ /dev/null @@ -1,72 +0,0 @@ -import json -from datetime import datetime - -from activity import Activity -from helper import OuraModel, set_attrs -from readiness import Readiness -from sleep import Sleep - - -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()) diff --git a/oura/models/user_info.py b/oura/models/user_info.py deleted file mode 100644 index e01f462..0000000 --- a/oura/models/user_info.py +++ /dev/null @@ -1,19 +0,0 @@ -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) diff --git a/tests/mock_client.py b/tests/mock_client.py index 2e86f78..a739e9a 100644 --- a/tests/mock_client.py +++ b/tests/mock_client.py @@ -127,5 +127,19 @@ def bedtime_summary(self, start=None, end=None): } +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]} + + class MockDataFrameClient(OuraClientDataFrame, MockClient): pass diff --git a/tests/test_client_pandas.py b/tests/test_client_pandas.py index f917fe2..4e10f78 100644 --- a/tests/test_client_pandas.py +++ b/tests/test_client_pandas.py @@ -23,13 +23,15 @@ def test_sleep_summary_df(): """ start = "2017-11-05" end = "2017-11-05" - df_raw1 = client.sleep_df_raw(start) + df_raw1 = client.sleep_df(start, convert=False) # check all cols are included assert df_raw1.shape == (1, 31) # check that start date parameter is correct assert df_raw1.index[0] == date(2017, 11, 5) - df_raw2 = client.sleep_df_raw(start, end, metrics=["bedtime_start", "score"]) + df_raw2 = client.sleep_df( + start, end, metrics=["bedtime_start", "score"], convert=False + ) # check that correct metrics are being included assert df_raw2.shape[1] == 2 # check that end date parameter is correct @@ -38,45 +40,51 @@ def test_sleep_summary_df(): assert type(df_raw2["bedtime_start"][0]) == str # test that invalid metric 'zzz' is dropped - df_raw3 = client.sleep_df_raw(start, end, metrics=["bedtime_start", "zzz"]) + 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_edited(start, end, metrics=["bedtime_start", "zzz"]) + 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" - df_raw1 = client.activity_df_raw(start) + df_raw1 = client.activity_df(start, convert=False) # check all cols are included assert df_raw1.shape == (1, 30) assert df_raw1.index[0] == date(2016, 9, 3) - df_raw2 = client.activity_df_raw(start, end, metrics=["day_start", "medium"]) + df_raw2 = client.activity_df( + start, end, metrics=["day_start", "medium"], convert=False + ) assert df_raw2.shape[1] == 2 assert df_raw2.index[-1] == date(2016, 9, 3) assert type(df_raw2["day_start"][0]) == str # test that invalid metric is dropped - df_raw3 = client.activity_df_raw(start, end, metrics=["day_start", "zzz"]) + 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_edited(start, end, metrics=["day_start", "zzz"]) + 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" - df_raw1 = client.readiness_df_raw(start) + df_raw1 = client.readiness_df(start) # check all cols are included assert df_raw1.shape == (1, 11) assert df_raw1.index[0] == date(2016, 9, 3) - df_raw2 = client.readiness_df_raw( + df_raw2 = client.readiness_df( start, end, metrics=["score_hrv_balance", "score_recovery_index"], @@ -85,10 +93,10 @@ def test_ready_summary_df(): assert df_raw2.index[-1] == date(2016, 9, 3) # test that invalid metric is dropped - df_raw3 = client.readiness_df_raw(start, end, metrics=["score_hrv_balance", "zzz"]) + df_raw3 = client.readiness_df(start, end, metrics=["score_hrv_balance", "zzz"]) assert df_raw3.shape[1] == 1 - df_edited = client.readiness_df_edited(start, end, metrics="score_hrv_balance") + df_edited = client.readiness_df(start, end, metrics="score_hrv_balance") assert pd.DataFrame.equals(df_raw3, df_edited) From 89ed8f7a0c8091647161955e5a2f7dbf5a6c1984 Mon Sep 17 00:00:00 2001 From: Jon Hagg Date: Wed, 11 Nov 2020 22:29:02 -0800 Subject: [PATCH 17/21] handle bedtime date key and add converter --- oura/client_pandas.py | 41 ++++++++++++++++---------- oura/converters.py | 4 +++ tests/mock_client.py | 4 +++ tests/test_client_pandas.py | 58 +++++++++++++++++++------------------ 4 files changed, 63 insertions(+), 44 deletions(-) diff --git a/oura/client_pandas.py b/oura/client_pandas.py index c3afa72..19b5857 100644 --- a/oura/client_pandas.py +++ b/oura/client_pandas.py @@ -1,7 +1,12 @@ import pandas as pd from .client import OuraClient -from .converters import ActivityConverter, SleepConverter, UnitConverter +from .converters import ( + ActivityConverter, + BedtimeConverter, + SleepConverter, + UnitConverter, +) def to_pandas(summary, metrics=None, date_key="summary_date"): @@ -20,7 +25,7 @@ def to_pandas(summary, metrics=None, date_key="summary_date"): df = pd.DataFrame(summary) if df.size == 0: return df - if metrics: + if metrics is not None: if type(metrics) == str: metrics = [metrics] else: @@ -28,10 +33,9 @@ def to_pandas(summary, metrics=None, date_key="summary_date"): # drop any invalid cols the user may have entered metrics = [m for m in metrics if m in df.columns] - # summary_date is a required col - # TODO: handle bedtime - if "summary_date" not in metrics: - metrics.insert(0, "summary_date") + # 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 @@ -66,7 +70,6 @@ def __init__( def sleep_df(self, start=None, end=None, metrics=None, convert=True): """ Create a dataframe from sleep summary dict object. - The dataframe is minimally edited, i.e 'raw' :param start: Beginning of date range :type start: string representation of a date i.e. '2020-10-31' @@ -76,6 +79,9 @@ def sleep_df(self, start=None, end=None, metrics=None, convert=True): :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 """ sleep_summary = super().sleep_summary(start, end)["sleep"] df = to_pandas(sleep_summary, metrics) @@ -86,7 +92,6 @@ def sleep_df(self, start=None, end=None, metrics=None, convert=True): def activity_df(self, start=None, end=None, metrics=None, convert=True): """ Create a dataframe from activity summary dict object. - The dataframe is minimally edited, i.e 'raw' :param start: Beginning of date range :type start: string representation of a date i.e. '2020-10-31' @@ -96,6 +101,9 @@ def activity_df(self, start=None, end=None, metrics=None, convert=True): :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 """ activity_summary = super().activity_summary(start, end)["activity"] df = to_pandas(activity_summary, metrics) @@ -106,7 +114,6 @@ def activity_df(self, start=None, end=None, metrics=None, convert=True): def readiness_df(self, start=None, end=None, metrics=None): """ Create a dataframe from ready summary dict object. - The dataframe is minimally edited, i.e 'raw' :param start: Beginning of date range :type start: string representation of a date i.e. '2020-10-31' @@ -120,10 +127,9 @@ def readiness_df(self, start=None, end=None, metrics=None): readiness_summary = super().readiness_summary(start, end)["readiness"] return to_pandas(readiness_summary, metrics) - def bedtime_df(self, start=None, end=None, metrics=None): + def bedtime_df(self, start=None, end=None, metrics=None, convert=True): """ Create a dataframe from bedtime summary - The dataframe is minimally edited, i.e 'raw' :param start: Beginning of date range :type start: string representation of a date i.e. '2020-10-31' @@ -133,13 +139,16 @@ def bedtime_df(self, start=None, end=None, metrics=None): :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 """ + bedtime_summary = super().bedtime_summary(start, end)["ideal_bedtimes"] - for s in bedtime_summary: - s["window_start"] = s["bedtime_window"]["start"] - s["window_end"] = s["bedtime_window"]["end"] - del s["bedtime_window"] - return to_pandas(bedtime_summary, metrics, date_key="date") + df = to_pandas(bedtime_summary, metrics, date_key="date") + if convert: + return BedtimeConverter().convert_metrics(df) + return df # TODO: use multi index instead of prefix? def combined_df_edited(self, start=None, end=None, metrics=None): diff --git a/oura/converters.py b/oura/converters.py index 8521b34..7e4f79a 100644 --- a/oura/converters.py +++ b/oura/converters.py @@ -89,3 +89,7 @@ class SleepConverter(UnitConverter): class ActivityConverter(UnitConverter): all_dt_metrics = ["day_end", "day_start"] all_sec_metrics = [] + + +class BedtimeConverter(UnitConverter): + all_dt_metrics = ["date"] diff --git a/tests/mock_client.py b/tests/mock_client.py index a739e9a..560d898 100644 --- a/tests/mock_client.py +++ b/tests/mock_client.py @@ -140,6 +140,10 @@ 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_client_pandas.py b/tests/test_client_pandas.py index 4e10f78..b946b9a 100644 --- a/tests/test_client_pandas.py +++ b/tests/test_client_pandas.py @@ -23,23 +23,21 @@ def test_sleep_summary_df(): """ start = "2017-11-05" end = "2017-11-05" - df_raw1 = client.sleep_df(start, convert=False) + df1 = client.sleep_df(start, convert=False) # check all cols are included - assert df_raw1.shape == (1, 31) + assert df1.shape == (1, 31) # check that start date parameter is correct - assert df_raw1.index[0] == date(2017, 11, 5) + assert df1.index[0] == date(2017, 11, 5) - df_raw2 = client.sleep_df( - start, end, metrics=["bedtime_start", "score"], convert=False - ) + df2 = client.sleep_df(start, end, metrics=["bedtime_start", "score"], convert=False) # check that correct metrics are being included - assert df_raw2.shape[1] == 2 + assert df2.shape[1] == 2 # check that end date parameter is correct - assert df_raw2.index[-1] == date(2017, 11, 5) + assert df2.index[-1] == date(2017, 11, 5) # check that data type has not been altered - assert type(df_raw2["bedtime_start"][0]) == str + assert type(df2["bedtime_start"][0]) == str - # test that invalid metric 'zzz' is dropped + # test that invalid metric 'zzz' is dropped df_raw3 = client.sleep_df( start, end, metrics=["bedtime_start", "zzz"], convert=False ) @@ -53,19 +51,17 @@ def test_sleep_summary_df(): def test_activity_summary_df(): start = "2016-09-03" end = "2016-09-04" - df_raw1 = client.activity_df(start, convert=False) + df1 = client.activity_df(start, convert=False) # check all cols are included - assert df_raw1.shape == (1, 30) - assert df_raw1.index[0] == date(2016, 9, 3) + assert df1.shape == (1, 30) + assert df1.index[0] == date(2016, 9, 3) - df_raw2 = client.activity_df( - start, end, metrics=["day_start", "medium"], convert=False - ) - assert df_raw2.shape[1] == 2 - assert df_raw2.index[-1] == date(2016, 9, 3) - assert type(df_raw2["day_start"][0]) == str + 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 + # test that invalid metric is dropped df_raw3 = client.activity_df( start, end, metrics=["day_start", "zzz"], convert=False ) @@ -79,20 +75,20 @@ def test_activity_summary_df(): def test_ready_summary_df(): start = "2016-09-03" end = "2016-09-04" - df_raw1 = client.readiness_df(start) + df1 = client.readiness_df(start) # check all cols are included - assert df_raw1.shape == (1, 11) - assert df_raw1.index[0] == date(2016, 9, 3) + assert df1.shape == (1, 11) + assert df1.index[0] == date(2016, 9, 3) - df_raw2 = client.readiness_df( + df2 = client.readiness_df( start, end, metrics=["score_hrv_balance", "score_recovery_index"], ) - assert df_raw2.shape[1] == 2 - assert df_raw2.index[-1] == date(2016, 9, 3) + assert df2.shape[1] == 2 + assert df2.index[-1] == date(2016, 9, 3) - # test that invalid metric is dropped + # test that invalid metric is dropped df_raw3 = client.readiness_df(start, end, metrics=["score_hrv_balance", "zzz"]) assert df_raw3.shape[1] == 1 @@ -100,6 +96,12 @@ def test_ready_summary_df(): 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") @@ -116,7 +118,7 @@ def test_combined_summary_df(): assert combined_df_edited2.shape[1] == 3 assert combined_df_edited2.index[-1] < date(2020, 10, 2) - # test that invalid metric is dropped + # test that invalid metric is dropped combined_df_edited2 = client.combined_df_edited( start="2020-09-30", end="2020-10-01", From d1bf64b84917654c1a6975a59e62be78d3c916fc Mon Sep 17 00:00:00 2001 From: Jon Hagg Date: Wed, 11 Nov 2020 22:50:23 -0800 Subject: [PATCH 18/21] add new features to readme --- README.md | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) 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. From 439e7e69dab0521c1273e131de2f2cf12edaf2ee Mon Sep 17 00:00:00 2001 From: jon Date: Sun, 15 Nov 2020 20:00:55 -0800 Subject: [PATCH 19/21] add hypnogram converter and allow column level override --- oura/client_pandas.py | 29 ++++++++------------- oura/converters.py | 54 ++++++++++++++++++++++++++++++++-------- tests/test_converters.py | 50 +++++++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 29 deletions(-) create mode 100644 tests/test_converters.py diff --git a/oura/client_pandas.py b/oura/client_pandas.py index 19b5857..7c3c6a3 100644 --- a/oura/client_pandas.py +++ b/oura/client_pandas.py @@ -1,12 +1,7 @@ import pandas as pd from .client import OuraClient -from .converters import ( - ActivityConverter, - BedtimeConverter, - SleepConverter, - UnitConverter, -) +from .converters import ActivityConverter, SleepConverter, UnitConverter def to_pandas(summary, metrics=None, date_key="summary_date"): @@ -67,7 +62,9 @@ def __init__( personal_access_token, ) - def sleep_df(self, start=None, end=None, metrics=None, convert=True): + def sleep_df( + self, start=None, end=None, metrics=None, convert=True, convert_cols=None + ): """ Create a dataframe from sleep summary dict object. @@ -86,10 +83,12 @@ def sleep_df(self, start=None, end=None, metrics=None, convert=True): sleep_summary = super().sleep_summary(start, end)["sleep"] df = to_pandas(sleep_summary, metrics) if convert: - return SleepConverter().convert_metrics(df) + return SleepConverter(convert_cols).convert_metrics(df) return df - def activity_df(self, start=None, end=None, metrics=None, convert=True): + def activity_df( + self, start=None, end=None, metrics=None, convert=True, convert_cols=None + ): """ Create a dataframe from activity summary dict object. @@ -108,7 +107,7 @@ def activity_df(self, start=None, end=None, metrics=None, convert=True): activity_summary = super().activity_summary(start, end)["activity"] df = to_pandas(activity_summary, metrics) if convert: - return ActivityConverter().convert_metrics(df) + return ActivityConverter(convert_cols).convert_metrics(df) return df def readiness_df(self, start=None, end=None, metrics=None): @@ -127,7 +126,7 @@ def readiness_df(self, start=None, end=None, metrics=None): readiness_summary = super().readiness_summary(start, end)["readiness"] return to_pandas(readiness_summary, metrics) - def bedtime_df(self, start=None, end=None, metrics=None, convert=True): + def bedtime_df(self, start=None, end=None, metrics=None): """ Create a dataframe from bedtime summary @@ -139,16 +138,10 @@ def bedtime_df(self, start=None, end=None, metrics=None, convert=True): :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 """ bedtime_summary = super().bedtime_summary(start, end)["ideal_bedtimes"] - df = to_pandas(bedtime_summary, metrics, date_key="date") - if convert: - return BedtimeConverter().convert_metrics(df) - return df + 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): diff --git a/oura/converters.py b/oura/converters.py index 7e4f79a..f25f3d6 100644 --- a/oura/converters.py +++ b/oura/converters.py @@ -4,10 +4,25 @@ 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): """ @@ -57,6 +72,9 @@ def _convert_to_hrs(self, df, sec_metrics): 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 @@ -64,12 +82,11 @@ def convert_metrics(self, df): :param df: dataframe :type df: pandas dataframe obj """ - dt_metrics = [col for col in df.columns if col in self.all_dt_metrics] - sec_metrics = [col for col in df.columns if col in self.all_sec_metrics] - if dt_metrics: - df = self._convert_to_dt(df, dt_metrics) - if sec_metrics: - df = self._convert_to_hrs(df, sec_metrics) + 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 @@ -84,12 +101,27 @@ class SleepConverter(UnitConverter): "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))) -class ActivityConverter(UnitConverter): - all_dt_metrics = ["day_end", "day_start"] - all_sec_metrics = [] + 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 BedtimeConverter(UnitConverter): - all_dt_metrics = ["date"] +class ActivityConverter(UnitConverter): + all_dt_metrics = ["day_end", "day_start"] + all_metrics = all_dt_metrics 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] From 815d594e4b1c5f7c8297d549d8caa6eec7f45500 Mon Sep 17 00:00:00 2001 From: jon Date: Sun, 15 Nov 2020 21:23:36 -0800 Subject: [PATCH 20/21] bump version and format setup.py --- oura/__init__.py | 6 +++- setup.py | 85 +++++++++++++++++++++++------------------------- 2 files changed, 45 insertions(+), 46 deletions(-) diff --git a/oura/__init__.py b/oura/__init__.py index f3cfa51..fb90c58 100644 --- a/oura/__init__.py +++ b/oura/__init__.py @@ -6,7 +6,11 @@ ------------------ -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 .auth import OAuthRequestHandler, OuraOAuth2Client, PersonalRequestHandler diff --git a/setup.py b/setup.py index 9dfa562..30a3a1b 100644 --- a/setup.py +++ b/setup.py @@ -12,18 +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.7' -VERSION = '1.0.4' - -REQUIRED = [ - 'requests-oauthlib' - 'pandas' -] +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'], @@ -32,51 +29,51 @@ 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__'])) @@ -87,38 +84,36 @@ def run(self): # 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 :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', + 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, }, ) From 8057ca31c0363d7cb0e5e8493931dc440c798f34 Mon Sep 17 00:00:00 2001 From: jon Date: Sun, 15 Nov 2020 21:46:43 -0800 Subject: [PATCH 21/21] update docstrings for pandas client --- oura/client_pandas.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/oura/client_pandas.py b/oura/client_pandas.py index 7c3c6a3..cfae0ee 100644 --- a/oura/client_pandas.py +++ b/oura/client_pandas.py @@ -13,6 +13,9 @@ def to_pandas(summary, metrics=None, date_key="summary_date"): :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): @@ -41,7 +44,13 @@ def to_pandas(summary, metrics=None, date_key="summary_date"): class OuraClientDataFrame(OuraClient): """ Similiar to OuraClient, but data is returned instead - as a pandas.DataFrame object + 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__( @@ -79,6 +88,11 @@ def sleep_df( :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) @@ -103,6 +117,11 @@ def activity_df( :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)