diff --git a/examples/strava.ipynb b/examples/strava.ipynb index fb88939..03e76cb 100644 --- a/examples/strava.ipynb +++ b/examples/strava.ipynb @@ -15,12 +15,34 @@ "import runpandas\n", "import os\n", "import pandas as pd\n", - "pd.set_option('display.max_rows', 500)\n" + "pd.set_option('display.max_rows', 500)" ] }, { "cell_type": "code", "execution_count": 2, + "id": "360f8407", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from dotenv import load_dotenv\n", + "load_dotenv()" + ] + }, + { + "cell_type": "code", + "execution_count": 3, "id": "346f6024", "metadata": {}, "outputs": [], @@ -35,21 +57,28 @@ "id": "32938533", "metadata": {}, "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Unable to set attribute media_type on entity \n" + ] + }, { "data": { "text/plain": [ - "Session Running: 25-09-2021 05:29:40\n", - "Total distance (meters) 100940.5\n", - "Total ellapsed time 0 days 17:00:43\n", - "Total moving time 0 days 15:21:50\n", + "Session Running: 18-06-2022 07:08:07\n", + "Total distance (meters) 21389.8\n", + "Total ellapsed time 0 days 02:02:20\n", + "Total moving time 0 days 02:02:19\n", "Average speed (km/h) NaN\n", "Average moving speed (km/h) NaN\n", "Average pace (per 1 km) NaN\n", "Average pace moving (per 1 km) NaN\n", - "Average cadence 65.456174\n", - "Average moving cadence 69.492244\n", - "Average heart rate 119.068156\n", - "Average moving heart rate 120.551058\n", + "Average cadence 87.7889\n", + "Average moving cadence 87.847\n", + "Average heart rate 155.674\n", + "Average moving heart rate 155.713\n", "Average temperature NaN\n", "dtype: object" ] @@ -60,16 +89,19 @@ } ], "source": [ - "activity = runpandas.read_strava('6019886157')\n", + "activity = runpandas.read_strava('7329257123')\n", "activity.summary()" ] } ], "metadata": { + "interpreter": { + "hash": "2a188acd0f27a53b17cfad69c436eac3f19ae51e9e26340e7d32ca2c8c278930" + }, "kernelspec": { - "display_name": "runpandas", + "display_name": "Python 3.8.3 ('runpandas_dev')", "language": "python", - "name": "runpandas" + "name": "python3" }, "language_info": { "codemirror_mode": { @@ -81,7 +113,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.10" + "version": "3.8.3" } }, "nbformat": 4, diff --git a/examples/token.json b/examples/token.json new file mode 100644 index 0000000..fe3a641 --- /dev/null +++ b/examples/token.json @@ -0,0 +1 @@ +"{\"access_token\": \"01dc593b8ab20487df9e6a6fe874ea49aa26f4d5\", \"refresh_token\": \"59c8537628a19d70330e5aca2ec5a558023a2c03\", \"expires_at\": 1658450893}" \ No newline at end of file diff --git a/runpandas/__init__.py b/runpandas/__init__.py index baf5c28..e43da71 100644 --- a/runpandas/__init__.py +++ b/runpandas/__init__.py @@ -2,7 +2,7 @@ from runpandas.reader import _read_dir as read_dir # noqa from runpandas.reader import _read_dir_aggregate as read_dir_aggregate # noqa from runpandas.io.strava._parser import read_strava # noqa -from runpandas.io.strava._client import StravaClient # noqa +from runpandas.io.strava._client import StravaClient # noqa from runpandas.io.nikerun._parser import read_nikerun # noqa from runpandas.io.nikerun._parser import read_dir_nikerun # noqa from runpandas.datasets.utils import activity_examples # noqa diff --git a/runpandas/io/strava/_client.py b/runpandas/io/strava/_client.py index 5d9746d..64762aa 100644 --- a/runpandas/io/strava/_client.py +++ b/runpandas/io/strava/_client.py @@ -6,9 +6,8 @@ import json import time import webbrowser +from json.decoder import JSONDecodeError from http.server import BaseHTTPRequestHandler, HTTPServer -from urllib.request import urlopen, HTTPError -from webbrowser import open_new from urllib.parse import urlsplit, parse_qs @@ -19,6 +18,7 @@ def coalesce(iterable): class HTTPResponder(HTTPServer): allow_reuse_address = True timeout = 60 + access_token = None def handle_timeout(self): self.server_close() @@ -38,8 +38,7 @@ def do_GET(self): self.auth_code = parse_qs(urlsplit(self.path).query)["code"] self.wfile.write( bytes( - "

You may now close this window." - + "

", + "

You may now close this window." + "

", "utf-8", ) ) @@ -51,6 +50,18 @@ def log_message(self, *args): class StravaClient(Client): + """ + The StravaClient object is a helper tool for handling + the authentication process (i.e. authorization, token update, ...) against the Strava. + + Parameters + ---------- + token_file: File, The Strava access token path where it will be kept or loaded, optional + refresh_token: str, The Strava refresh token, optional + client_secret: str, The strava client secret used for token refresh, optional + client_id: int, The Strava client id used for token refresh, optional + """ + def __init__( self, *args, @@ -61,15 +72,11 @@ def __init__( **kwargs ): super(self.__class__, self).__init__(*args, **kwargs) - self.token_file = coalesce( - (token_file, os.getenv("STRAVA_TOKEN_FILE", None)) - ) + self.token_file = coalesce((token_file, os.getenv("STRAVA_TOKEN_FILE", None))) self.client_secret = coalesce( (client_secret, os.getenv("STRAVA_CLIENT_SECRET", None)) ) - self.client_id = coalesce( - (client_id, os.getenv("STRAVA_CLIENT_ID", None)) - ) + self.client_id = coalesce((client_id, os.getenv("STRAVA_CLIENT_ID", None))) if self.token_file is not None: self.get_token_from_file() @@ -78,26 +85,50 @@ def __init__( self.refresh_token = refresh_token def set_token_from_dict(self, token): + """ + It extracts the token components from a token dict response. + """ + self.access_token = token["access_token"] self.refresh_token = token["refresh_token"] self.token_expires_at = token["expires_at"] def get_token_from_file(self): + """ + Gets the token from a token_file and returns as dict. + """ if self.token_file is not None: with open(self.token_file, "r") as f: - token = json.load(f) + try: + token = json.load(f) + except JSONDecodeError: + print( + "problems on reading %s file. It considers an empty file." + % self.token_file + ) + token = None if token is not None: if type(token) == str: token = json.loads(token) self.set_token_from_dict(token) def save_token_to_file(self, token): + """ + It saves the token to the file + + Parameters + ---------- + token: File, the file where the token will be stored at. + """ if self.token_file is None: return None with open(self.token_file, "w") as f: f.write(json.dumps(token)) def refresh(self): + """ + It updates the access token if the token is expired. + """ if time.time() > self.token_expires_at: token = self.refresh_access_token( client_id=self.client_id, @@ -109,6 +140,14 @@ def refresh(self): f.write(json.dumps(token)) def authenticate_web(self): + """ + It handles the authentication web negotiation with Strava. + + Returns + ------- + token_response: dict, the token response from Strava. + + """ scope = [ "read", "read_all", @@ -135,7 +174,8 @@ def authenticate_web(self): code=httpServer.access_token, ) # Save it to file so we can use it until it expires. - access_token_string = json.dumps(token_response) if self.token_file is not None: with open(self.token_file, "w+") as f: - json.dump(access_token_string, f) + json.dump(token_response, f) + + return token_response diff --git a/runpandas/io/strava/_parser.py b/runpandas/io/strava/_parser.py index 08f89ca..cb21845 100644 --- a/runpandas/io/strava/_parser.py +++ b/runpandas/io/strava/_parser.py @@ -1,7 +1,6 @@ """ Tools for pulling and parsing stream data from Strava. """ -import time from datetime import timedelta import pandas as pd from pandas import TimedeltaIndex @@ -9,7 +8,6 @@ from runpandas.types import Activity from runpandas.types import columns from runpandas.io.strava._client import StravaClient -import os COLUMNS_SCHEMA = { @@ -83,7 +81,6 @@ def read_strava( Return a obj:`runpandas.Activity` if `to_df=True`, otherwise a :obj:`pandas.DataFrame` will be returned. """ - if client is None: client = StravaClient() client.refresh() diff --git a/runpandas/tests/io/data/strava/token.json b/runpandas/tests/io/data/strava/token.json new file mode 100644 index 0000000..78081ff --- /dev/null +++ b/runpandas/tests/io/data/strava/token.json @@ -0,0 +1 @@ +{"access_token": "2334444", "refresh_token": "235555", "expires_at": 1658341120.318284} \ No newline at end of file diff --git a/runpandas/tests/test_moving_acessors.py b/runpandas/tests/test_moving_acessors.py index b3105d5..860925d 100644 --- a/runpandas/tests/test_moving_acessors.py +++ b/runpandas/tests/test_moving_acessors.py @@ -4,6 +4,7 @@ import os import json +import time import pytest from pandas import Timedelta from stravalib.protocol import ApiV3 @@ -11,6 +12,7 @@ from stravalib.model import Stream from runpandas import reader from runpandas import read_strava +from runpandas import StravaClient pytestmark = pytest.mark.stable @@ -20,6 +22,28 @@ def dirpath(datapath): return datapath("io", "data") +@pytest.fixture(scope="session") +def valid_token_file(tmpdir_factory): + return tmpdir_factory.getbasetemp().join("token.json") + + +@pytest.fixture +def strava_client(valid_token_file): + file_handler = open(valid_token_file, "w") + file_handler.write( + '{"access_token": "2334444", "refresh_token": "235555", "expires_at": %d}' + % (time.time() + 3600) + ) + file_handler.close() + + client = StravaClient( + client_id="STRAVA_ID", + client_secret="STRAVA_CLIENT_SECRET", + token_file=valid_token_file, + ) + return client + + class MockResponse: def __init__(self, json_file): with open(json_file) as json_handler: @@ -68,7 +92,7 @@ def test_metrics_validate(dirpath): frame_without_index.only_moving() -def test_only_moving_acessor(dirpath, mocker): +def test_only_moving_acessor(dirpath, mocker, strava_client): gpx_file = os.path.join(dirpath, "gpx", "stopped_example.gpx") frame_gpx = reader._read_file(gpx_file, to_df=False) frame_gpx["distpos"] = frame_gpx.compute.distance(correct_distance=False) @@ -116,6 +140,7 @@ def test_only_moving_acessor(dirpath, mocker): activity_strava = read_strava( activity_id=4437021783, access_token=None, + client=strava_client, refresh_token=None, to_df=False, ) diff --git a/runpandas/tests/test_strava_client.py b/runpandas/tests/test_strava_client.py new file mode 100644 index 0000000..c1799a9 --- /dev/null +++ b/runpandas/tests/test_strava_client.py @@ -0,0 +1,114 @@ +""" +Test module for Strava Client Authentication module +""" +import os +import json +import pytest +from unittest.mock import PropertyMock +from runpandas.io.strava._client import StravaClient, HTTPResponder + + +@pytest.fixture +def dirpath(datapath): + return datapath("io", "data") + + +@pytest.fixture(scope="session") +def valid_token_file(tmpdir_factory): + return tmpdir_factory.getbasetemp().join("token.json") + + +def test_stravaclient_with_enviroment_variables(dirpath): + os.environ["STRAVA_CLIENT_SECRET"] = str("STRAVA_CLIENT_SECRET") + os.environ["STRAVA_CLIENT_ID"] = str("STRAVA_ID") + refresh_token = "REFRESHTOKEN" + client = StravaClient(refresh_token=refresh_token) + + assert client.client_secret == "STRAVA_CLIENT_SECRET" + assert client.client_id == "STRAVA_ID" + assert client.token_file is None + + +def test_stravaclient_with_arguments(dirpath): + os.environ["STRAVA_CLIENT_SECRET"] = str("STRAVA_CLIENT_SECRET") + os.environ["STRAVA_CLIENT_ID"] = str("STRAVA_ID") + refresh_token = "REFRESHTOKEN" + client = StravaClient( + refresh_token=refresh_token, + client_id="STRAVA_ID", + client_secret="STRAVA_CLIENT_SECRET", + ) + + assert client.client_secret == "STRAVA_CLIENT_SECRET" + assert client.client_id == "STRAVA_ID" + assert client.token_file is None + + # if token file is empty, so it can't write on it. + assert client.save_token_to_file({"test": "test"}) is None + + +def test_stravaclient_rw_token_file(dirpath, valid_token_file): + # create a token valid file + file_handler = open(valid_token_file, "w") + file_handler.write( + '{"access_token": "2334444", "refresh_token": "235555", \ + "expires_at": 1658341120.318284}' + ) + file_handler.close() + + client = StravaClient( + client_id="STRAVA_ID", + client_secret="STRAVA_CLIENT_SECRET", + token_file=valid_token_file, + ) + + assert client.access_token == "2334444" + assert client.refresh_token == "235555" + assert client.token_expires_at == 1658341120.318284 + + assert client.client_secret == "STRAVA_CLIENT_SECRET" + assert client.client_id == "STRAVA_ID" + + token = { + "access_token": "222", + "refresh_token": "111", + "expires_at": 1658341120.318284, + } + client.save_token_to_file(token) + with open(valid_token_file) as f: + assert json.loads(f.read()) == json.loads( + '{"access_token": "222", "refresh_token": "111", "expires_at": 1658341120.318284}' + ) + + +def test_stravaclient_authenticate(dirpath, valid_token_file, mocker): + mocker.patch("webbrowser.open") + mocker.patch.object(HTTPResponder, "handle_request", return_value=None) + mocker.patch.object( + StravaClient, + "exchange_code_for_token", + return_value={ + "access_token": "1", + "refresh_token": "1", + "expires_at": 1658450893, + }, + ) + mocker.patch.object( + HTTPResponder, "access_token", new_callable=PropertyMock, return_value="123" + ) + + os.environ["STRAVA_CLIENT_SECRET"] = str("STRAVA_CLIENT_SECRET") + os.environ["STRAVA_CLIENT_ID"] = str("STRAVA_ID") + refresh_token = "REFRESHTOKEN" + client = StravaClient( + refresh_token=refresh_token, + client_id="STRAVA_ID", + client_secret="STRAVA_CLIENT_SECRET", + token_file=valid_token_file, + ) + client.authenticate_web() + with open(valid_token_file) as f: + returned = json.load(f) + assert returned["access_token"] == "1" + assert returned["refresh_token"] == "1" + assert returned["expires_at"] == 1658450893 diff --git a/runpandas/tests/test_strava_parser.py b/runpandas/tests/test_strava_parser.py index 7910767..3b5eb08 100644 --- a/runpandas/tests/test_strava_parser.py +++ b/runpandas/tests/test_strava_parser.py @@ -4,10 +4,12 @@ import os import json +import time import pytest from pandas import DataFrame, Timedelta, Timestamp from runpandas import read_strava from runpandas import types +from runpandas import StravaClient from stravalib.protocol import ApiV3 from stravalib.client import Client from stravalib.model import Stream @@ -44,8 +46,30 @@ def dirpath(datapath): return datapath("io", "data") +@pytest.fixture(scope="session") +def valid_token_file(tmpdir_factory): + return tmpdir_factory.getbasetemp().join("token.json") + + +@pytest.fixture +def strava_client(valid_token_file): + file_handler = open(valid_token_file, "w") + file_handler.write( + '{"access_token": "2334444", "refresh_token": "235555", "expires_at": %d}' + % (time.time() + 3600) + ) + file_handler.close() + + client = StravaClient( + client_id="STRAVA_ID", + client_secret="STRAVA_CLIENT_SECRET", + token_file=valid_token_file, + ) + return client + + @pytest.fixture -def strava_activity(dirpath, mocker): +def strava_activity(dirpath, mocker, strava_client): activity_json = os.path.join(dirpath, "strava", "activity.json") streams_json = os.path.join(dirpath, "strava", "streams.json") @@ -61,13 +85,14 @@ def strava_activity(dirpath, mocker): activity_id=4437021783, access_token=None, refresh_token=None, + client=strava_client, to_df=False, ) return activity @pytest.fixture -def strava_dataframe(dirpath, mocker): +def strava_dataframe(dirpath, mocker, strava_client): activity_json = os.path.join(dirpath, "strava", "activity.json") streams_json = os.path.join(dirpath, "strava", "streams.json") @@ -82,13 +107,14 @@ def strava_dataframe(dirpath, mocker): activity = read_strava( activity_id=4437021783, access_token=None, + client=strava_client, refresh_token=None, to_df=True, ) return activity -def test_read_strava_basic_dataframe(dirpath, mocker): +def test_read_strava_basic_dataframe(dirpath, mocker, strava_client): activity_json = os.path.join(dirpath, "strava", "activity.json") streams_json = os.path.join(dirpath, "strava", "streams.json") @@ -104,6 +130,7 @@ def test_read_strava_basic_dataframe(dirpath, mocker): activity_id=4437021783, access_token=None, refresh_token=None, + client=strava_client, to_df=True, ) assert isinstance(activity, DataFrame) @@ -124,7 +151,7 @@ def test_read_strava_basic_dataframe(dirpath, mocker): assert activity.size == 15723 -def test_read_strava_activity(dirpath, mocker): +def test_read_strava_activity(dirpath, mocker, strava_client): activity_json = os.path.join(dirpath, "strava", "activity.json") streams_json = os.path.join(dirpath, "strava", "streams.json") @@ -141,6 +168,7 @@ def test_read_strava_activity(dirpath, mocker): activity_id=4437021783, access_token=None, refresh_token=None, + client=strava_client, to_df=False, ) assert isinstance(activity, types.Activity)