From e61a5117669cf1efbd9aae971e9fdd674ca3e4ee Mon Sep 17 00:00:00 2001 From: Arthur Borem Date: Fri, 5 Sep 2025 15:24:48 -0500 Subject: [PATCH 1/2] Strava fetch methods return model objects rather than Any --- src/pardner/services/strava.py | 169 +++++++++++++++- src/pardner/verticals/base.py | 3 +- tests/test_transfer_services/test_strava.py | 204 +++++++++++++++++++- 3 files changed, 361 insertions(+), 15 deletions(-) diff --git a/src/pardner/services/strava.py b/src/pardner/services/strava.py index 01c5ff3..0c6ec2c 100644 --- a/src/pardner/services/strava.py +++ b/src/pardner/services/strava.py @@ -1,9 +1,16 @@ -from typing import Any, Iterable, Optional, override +from collections import defaultdict +from datetime import datetime, timedelta +from typing import Any, Iterable, Literal, Optional, override +from urllib.parse import urljoin + +from pydantic import AnyHttpUrl from pardner.exceptions import UnsupportedRequestException, UnsupportedVerticalException from pardner.services import BaseTransferService from pardner.services.utils import scope_as_set, scope_as_string from pardner.verticals import PhysicalActivityVertical, Vertical +from pardner.verticals.social_posting import SocialPostingVertical +from pardner.verticals.sub_verticals.associated_media import AssociatedMediaSubVertical class StravaTransferService(BaseTransferService): @@ -64,9 +71,150 @@ def scope_for_verticals(self, verticals: Iterable[Vertical]) -> set[str]: sub_scopes.update(['activity:read', 'profile:read_all']) return sub_scopes + def _convert_to_datetime(self, raw_datetime: str | None) -> datetime | None: + if raw_datetime: + return datetime.strptime(raw_datetime, '%Y-%m-%dT%H:%M:%SZ') + return None + + def _parse_social_posting(self, raw_data: Any) -> SocialPostingVertical | None: + """ + Given the response from the API request, creates a + :class:`SocialPostingVertical` model object, if possible. + + :param raw_data: the JSON representation of the data returned by the request. + + :returns: :class:`SocialPostingVertical` or ``None``, depending on whether it + was possible to extract data from the response + """ + if not isinstance(raw_data, dict): + return None + raw_data_dict = defaultdict(dict, raw_data) + + created_at = raw_data_dict.get('start_date') + if created_at: + created_at = self._convert_to_datetime(created_at) + + url_str = urljoin( + 'https://www.strava.com/activities/', str(raw_data_dict.get('id')) + ) + interaction_count = raw_data_dict.get('kudos_count', 0) + raw_data_dict.get( + 'comment_count', 0 + ) + + status: Literal['public', 'private', 'restricted'] = 'public' + if raw_data_dict.get('private'): + status = 'private' + elif raw_data_dict.get('visibility') == 'followers_only': + status = 'restricted' + + associated_media_list = [] + if raw_data_dict.get('total_photo_count', 0) > 0: + photo_urls = ( + raw_data_dict['photos'].get('primary', {}).get('urls', {}).values() + ) + associated_media_list = [ + AssociatedMediaSubVertical(image_url=photo_url) + for photo_url in photo_urls + ] + + return SocialPostingVertical( + creator_user_id=str(raw_data_dict['athlete'].get('id')), + service=self._service_name, + created_at=created_at, + url=AnyHttpUrl(url_str), + associated_media=associated_media_list, + interaction_count=interaction_count, + status=status, + text=raw_data_dict.get('description'), + title=raw_data_dict.get('name'), + ) + + def fetch_social_posting_vertical( + self, request_params: dict[str, Any] = {}, count: int = 30 + ) -> tuple[list[SocialPostingVertical | None], Any]: + """ + Fetches and returns social postings created by the authorized user. + + :param count: number of posts to request. At most 30 at a time. + :param request_params: any other endpoint-specific parameters to be sent + to the endpoint. Depending on the parameters passed, this could override + the other arguments to this method. + + :returns: two elements: the first, a list of :class:`SocialPostingVertical`s + or ``None``, if unable to parse; the second, the raw response from making the + request. + + :raises: :class:`UnsupportedRequestException` if the request is unable to be + made. + """ + max_count = 30 + if count <= max_count: + raw_social_postings = self._get_resource_from_path( + 'athlete/activities', params={'per_page': count, **request_params} + ).json() + return [ + self._parse_social_posting(raw_social_posting) + for raw_social_posting in raw_social_postings + ], raw_social_postings + raise UnsupportedRequestException( + self._service_name, + f'can only make a request for at most {max_count} posts at a time.', + ) + + def _parse_physical_activity( + self, raw_data: Any + ) -> PhysicalActivityVertical | None: + """ + Given the response from the API request, creates a + :class:`PhysicalActivityVertical` model object, if possible. + + :param raw_data: the JSON representation of the data returned by the request. + + :returns: :class:`PhysicalActivityVertical` or ``None``, depending on whether it + was possible to extract data from the response + """ + social_posting = self._parse_social_posting(raw_data) + if not social_posting: + return None + + social_posting_dict = social_posting.model_dump() + raw_data_dict = defaultdict(dict, raw_data) + + start_datetime = self._convert_to_datetime(raw_data_dict.get('start_date')) + duration_s = raw_data_dict.get('elapsed_time') + duration_timedelta = timedelta(seconds=duration_s) if duration_s else None + end_datetime = ( + start_datetime + duration_timedelta + if start_datetime and duration_timedelta + else None + ) + + start_latlng = raw_data_dict.get('start_latlng') + end_latlng = raw_data_dict.get('end_latlng') + + social_posting_dict.update( + { + 'vertical_name': 'physical_activity', + 'activity_type': raw_data_dict.get('sport_type'), + 'distance': raw_data_dict.get('distance'), + 'elevation_high': raw_data_dict.get('elev_high'), + 'elevation_low': raw_data_dict.get('elev_low'), + 'kilocalories': raw_data_dict.get('calories'), + 'max_speed': raw_data_dict.get('max_speed'), + 'start_datetime': start_datetime, + 'end_datetime': end_datetime, + 'start_latitude': start_latlng[0] if start_latlng else None, + 'start_longitude': start_latlng[1] if start_latlng else None, + 'end_latitude': end_latlng[0] if end_latlng else None, + 'end_longitude': end_latlng[1] if end_latlng else None, + } + ) + + return PhysicalActivityVertical.model_validate(social_posting_dict) + def fetch_physical_activity_vertical( self, request_params: dict[str, Any] = {}, count: int = 30 - ) -> list[Any]: + ) -> tuple[list[PhysicalActivityVertical | None], Any]: """ Fetches and returns activities completed by the authorized user. @@ -75,19 +223,22 @@ def fetch_physical_activity_vertical( to the endpoint. Depending on the parameters passed, this could override the other arguments to this method. - :returns: a list of dictionary objects with information for the activities from - the authorized user. + :returns: two elements: the first, a list of :class:`PhysicalActivityVertical`s + or ``None``, if unable to parse; the second, the raw response from making the + request. :raises: :class:`UnsupportedRequestException` if the request is unable to be made. """ max_count = 30 if count <= max_count: - return list( - self._get_resource_from_path( - 'athlete/activities', params={'per_page': count, **request_params} - ).json() - ) + raw_activities = self._get_resource_from_path( + 'athlete/activities', params={'per_page': count, **request_params} + ).json() + return [ + self._parse_physical_activity(raw_activity) + for raw_activity in raw_activities + ], raw_activities raise UnsupportedRequestException( self._service_name, f'can only make a request for at most {max_count} activities at a time.', diff --git a/src/pardner/verticals/base.py b/src/pardner/verticals/base.py index bcb09ae..0beb253 100644 --- a/src/pardner/verticals/base.py +++ b/src/pardner/verticals/base.py @@ -1,3 +1,4 @@ +import uuid from abc import ABC from datetime import datetime from typing import Type @@ -12,7 +13,7 @@ class BaseVertical(BaseModel, ABC): supported by every transfer service. """ - id: str + id: str = Field(default_factory=lambda: uuid.uuid4().hex) creator_user_id: str service: str = Field( description='The name of the service the data was pulled from.' diff --git a/tests/test_transfer_services/test_strava.py b/tests/test_transfer_services/test_strava.py index 38dcb79..7d7af0a 100644 --- a/tests/test_transfer_services/test_strava.py +++ b/tests/test_transfer_services/test_strava.py @@ -1,4 +1,7 @@ +import datetime + import pytest +from pydantic import AnyHttpUrl from requests import HTTPError from pardner.exceptions import UnsupportedRequestException, UnsupportedVerticalException @@ -36,18 +39,209 @@ def test_fetch_physical_activity_vertical_raises_http_exception( def test_fetch_physical_activity_vertical(mocker, mock_strava_transfer_service): - sample_response = [{'object': 1}, {'object': 2}] + # Adapted from + # https://developers.strava.com/docs/reference/#api-Activities-getLoggedInAthleteActivities + sample_response = [ + { + 'resource_state': 2, + 'athlete': {'id': 134815, 'resource_state': 1}, + 'name': 'Happy Friday', + 'distance': 24931.4, + 'moving_time': 4500, + 'elapsed_time': 4500, + 'total_elevation_gain': 0, + 'type': 'Ride', + 'sport_type': 'MountainBikeRide', + 'workout_type': None, + 'id': 154504250376823, + 'external_id': 'garmin_push_12345678987654321', + 'upload_id': 987654321234567891234, + 'description': 'mock description', + 'start_date': '2018-05-02T12:15:09Z', + 'start_date_local': '2018-05-02T05:15:09Z', + 'timezone': '(GMT-08:00) America/Los_Angeles', + 'calories': 870.2, + 'utc_offset': -25200, + 'start_latlng': [41.41, -41.41], + 'end_latlng': [42.42, -42.42], + 'location_city': None, + 'location_state': None, + 'location_country': 'United States', + 'achievement_count': 0, + 'kudos_count': 3, + 'comment_count': 1, + 'athlete_count': 1, + 'photo_count': 0, + 'map': { + 'id': 'a12345678987654321', + 'summary_polyline': None, + 'resource_state': 2, + }, + 'trainer': True, + 'commute': False, + 'manual': False, + 'private': False, + 'visibility': 'followers_only', + 'flagged': False, + 'gear_id': 'b12345678987654321', + 'from_accepted_tag': False, + 'average_speed': 5.54, + 'max_speed': 11, + 'average_cadence': 67.1, + 'average_watts': 175.3, + 'weighted_average_watts': 210, + 'kilojoules': 788.7, + 'device_watts': True, + 'has_heartrate': True, + 'average_heartrate': 140.3, + 'max_heartrate': 178, + 'max_watts': 406, + 'pr_count': 0, + 'total_photo_count': 1, + 'photos': { + 'primary': {'urls': {'1': 'https://url1.com', '2': 'https://url2.com'}} + }, + 'has_kudoed': False, + 'suffer_score': 82, + }, + { + 'resource_state': 2, + 'athlete': {'id': 167560, 'resource_state': 1}, + 'name': 'Bondcliff', + 'distance': 23676.5, + 'moving_time': 5400, + 'elapsed_time': 5400, + 'total_elevation_gain': 0, + 'type': 'Ride', + 'sport_type': 'MountainBikeRide', + 'workout_type': None, + 'id': 1234567809, + 'external_id': 'garmin_push_12345678987654321', + 'upload_id': 1234567819, + 'start_date': '2018-04-30T12:35:51Z', + 'start_date_local': '2018-04-30T05:35:51Z', + 'timezone': '(GMT-08:00) America/Los_Angeles', + 'utc_offset': -25200, + 'start_latlng': None, + 'end_latlng': None, + 'location_city': None, + 'location_state': None, + 'location_country': 'United States', + 'achievement_count': 0, + 'kudos_count': 4, + 'comment_count': 0, + 'elev_high': 182.5, + 'elev_low': 179.9, + 'athlete_count': 1, + 'photo_count': 0, + 'map': {'id': 'a12345689', 'summary_polyline': None, 'resource_state': 2}, + 'trainer': True, + 'commute': False, + 'manual': False, + 'private': False, + 'flagged': False, + 'gear_id': 'b12345678912343', + 'from_accepted_tag': False, + 'average_speed': 4.385, + 'max_speed': 8.8, + 'average_cadence': 69.8, + 'average_watts': 200, + 'weighted_average_watts': 214, + 'kilojoules': 1080, + 'device_watts': True, + 'has_heartrate': True, + 'average_heartrate': 152.4, + 'max_heartrate': 183, + 'max_watts': 403, + 'pr_count': 0, + 'total_photo_count': 1, + 'has_kudoed': False, + 'suffer_score': 162, + }, + ] response_object = mocker.MagicMock() response_object.json.return_value = sample_response oauth2_session_get = mock_oauth2_session_get(mocker, response_object) - assert ( - mock_strava_transfer_service.fetch_physical_activity_vertical() - == sample_response - ) + model_objs, _ = mock_strava_transfer_service.fetch_physical_activity_vertical() + model_obj1, model_obj2 = model_objs + assert ( oauth2_session_get.call_args.args[1] == 'https://www.strava.com/api/v3/athlete/activities' ) + + model_obj1_json = model_obj1.model_dump() + del model_obj1_json['id'] + + assert model_obj1_json == { + 'creator_user_id': '134815', + 'service': 'Strava', + 'vertical_name': 'physical_activity', + 'created_at': datetime.datetime(2018, 5, 2, 12, 15, 9), + 'url': AnyHttpUrl('https://www.strava.com/activities/154504250376823'), + 'abstract': None, + 'associated_media': [ + { + 'audio_url': None, + 'image_url': AnyHttpUrl('https://url1.com/'), + 'video_url': None, + }, + { + 'audio_url': None, + 'image_url': AnyHttpUrl('https://url2.com/'), + 'video_url': None, + }, + ], + 'interaction_count': 4, + 'keywords': [], + 'shared_content': [], + 'status': 'restricted', + 'text': 'mock description', + 'title': 'Happy Friday', + 'activity_type': 'MountainBikeRide', + 'distance': 24931.4, + 'elevation_high': None, + 'elevation_low': None, + 'kilocalories': 870.2, + 'max_speed': 11.0, + 'start_datetime': datetime.datetime(2018, 5, 2, 12, 15, 9), + 'start_latitude': 41.41, + 'start_longitude': -41.41, + 'end_datetime': datetime.datetime(2018, 5, 2, 13, 30, 9), + 'end_latitude': 42.42, + 'end_longitude': -42.42, + } + + model_obj2_json = model_obj2.model_dump() + del model_obj2_json['id'] + + assert model_obj2_json == { + 'creator_user_id': '167560', + 'service': 'Strava', + 'vertical_name': 'physical_activity', + 'created_at': datetime.datetime(2018, 4, 30, 12, 35, 51), + 'url': AnyHttpUrl('https://www.strava.com/activities/1234567809'), + 'abstract': None, + 'associated_media': [], + 'interaction_count': 4, + 'keywords': [], + 'shared_content': [], + 'status': 'public', + 'text': None, + 'title': 'Bondcliff', + 'activity_type': 'MountainBikeRide', + 'distance': 23676.5, + 'elevation_high': 182.5, + 'elevation_low': 179.9, + 'kilocalories': None, + 'max_speed': 8.8, + 'start_datetime': datetime.datetime(2018, 4, 30, 12, 35, 51), + 'start_latitude': None, + 'start_longitude': None, + 'end_datetime': datetime.datetime(2018, 4, 30, 14, 5, 51), + 'end_latitude': None, + 'end_longitude': None, + } From a5a6ba4757ce24cac151774e35a338c11de48b73 Mon Sep 17 00:00:00 2001 From: Arthur Borem Date: Fri, 5 Sep 2025 15:34:42 -0500 Subject: [PATCH 2/2] Removed lengthy import statements and added pydantic plugin to mypy --- pyproject.toml | 1 + src/pardner/services/strava.py | 9 +++------ 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e28c6f2..6b51f5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ warn_return_any = true strict_optional = true disallow_incomplete_defs = true exclude = ["tests"] +plugins = ['pydantic.mypy'] [tool.ruff] line-length = 88 diff --git a/src/pardner/services/strava.py b/src/pardner/services/strava.py index 0c6ec2c..d8bb486 100644 --- a/src/pardner/services/strava.py +++ b/src/pardner/services/strava.py @@ -3,14 +3,11 @@ from typing import Any, Iterable, Literal, Optional, override from urllib.parse import urljoin -from pydantic import AnyHttpUrl - from pardner.exceptions import UnsupportedRequestException, UnsupportedVerticalException from pardner.services import BaseTransferService from pardner.services.utils import scope_as_set, scope_as_string -from pardner.verticals import PhysicalActivityVertical, Vertical -from pardner.verticals.social_posting import SocialPostingVertical -from pardner.verticals.sub_verticals.associated_media import AssociatedMediaSubVertical +from pardner.verticals import PhysicalActivityVertical, SocialPostingVertical, Vertical +from pardner.verticals.sub_verticals import AssociatedMediaSubVertical class StravaTransferService(BaseTransferService): @@ -121,7 +118,7 @@ def _parse_social_posting(self, raw_data: Any) -> SocialPostingVertical | None: creator_user_id=str(raw_data_dict['athlete'].get('id')), service=self._service_name, created_at=created_at, - url=AnyHttpUrl(url_str), + url=url_str, associated_media=associated_media_list, interaction_count=interaction_count, status=status,