Skip to content

Commit 2f28fda

Browse files
authored
Strava fetch methods return model objects rather than Any (#68)
Closes #64 I decided to have the fetch methods return values with the following type signature: `tuple[list[<Vertical> | None], Any]`. The first element in the tuple is the list of model objects that were parsed using the response from the API request. It's also possibly `None` since there may be a parsing failure. The second element is the raw response payload, which we return in case the caller wants more data than we provide in the model definition. In this PR I wrote two Strava public methods: `fetch_social_posting_vertical` and `fetch_physical_activity_vertical`. I also set the `BaseVertical.id` field to be self-generating.
1 parent 2a3f28d commit 2f28fda

File tree

4 files changed

+360
-16
lines changed

4 files changed

+360
-16
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ warn_return_any = true
3636
strict_optional = true
3737
disallow_incomplete_defs = true
3838
exclude = ["tests"]
39+
plugins = ['pydantic.mypy']
3940

4041
[tool.ruff]
4142
line-length = 88

src/pardner/services/strava.py

Lines changed: 158 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1-
from typing import Any, Iterable, Optional, override
1+
from collections import defaultdict
2+
from datetime import datetime, timedelta
3+
from typing import Any, Iterable, Literal, Optional, override
4+
from urllib.parse import urljoin
25

36
from pardner.exceptions import UnsupportedRequestException, UnsupportedVerticalException
47
from pardner.services import BaseTransferService
58
from pardner.services.utils import scope_as_set, scope_as_string
6-
from pardner.verticals import PhysicalActivityVertical, Vertical
9+
from pardner.verticals import PhysicalActivityVertical, SocialPostingVertical, Vertical
10+
from pardner.verticals.sub_verticals import AssociatedMediaSubVertical
711

812

913
class StravaTransferService(BaseTransferService):
@@ -64,9 +68,150 @@ def scope_for_verticals(self, verticals: Iterable[Vertical]) -> set[str]:
6468
sub_scopes.update(['activity:read', 'profile:read_all'])
6569
return sub_scopes
6670

71+
def _convert_to_datetime(self, raw_datetime: str | None) -> datetime | None:
72+
if raw_datetime:
73+
return datetime.strptime(raw_datetime, '%Y-%m-%dT%H:%M:%SZ')
74+
return None
75+
76+
def _parse_social_posting(self, raw_data: Any) -> SocialPostingVertical | None:
77+
"""
78+
Given the response from the API request, creates a
79+
:class:`SocialPostingVertical` model object, if possible.
80+
81+
:param raw_data: the JSON representation of the data returned by the request.
82+
83+
:returns: :class:`SocialPostingVertical` or ``None``, depending on whether it
84+
was possible to extract data from the response
85+
"""
86+
if not isinstance(raw_data, dict):
87+
return None
88+
raw_data_dict = defaultdict(dict, raw_data)
89+
90+
created_at = raw_data_dict.get('start_date')
91+
if created_at:
92+
created_at = self._convert_to_datetime(created_at)
93+
94+
url_str = urljoin(
95+
'https://www.strava.com/activities/', str(raw_data_dict.get('id'))
96+
)
97+
interaction_count = raw_data_dict.get('kudos_count', 0) + raw_data_dict.get(
98+
'comment_count', 0
99+
)
100+
101+
status: Literal['public', 'private', 'restricted'] = 'public'
102+
if raw_data_dict.get('private'):
103+
status = 'private'
104+
elif raw_data_dict.get('visibility') == 'followers_only':
105+
status = 'restricted'
106+
107+
associated_media_list = []
108+
if raw_data_dict.get('total_photo_count', 0) > 0:
109+
photo_urls = (
110+
raw_data_dict['photos'].get('primary', {}).get('urls', {}).values()
111+
)
112+
associated_media_list = [
113+
AssociatedMediaSubVertical(image_url=photo_url)
114+
for photo_url in photo_urls
115+
]
116+
117+
return SocialPostingVertical(
118+
creator_user_id=str(raw_data_dict['athlete'].get('id')),
119+
service=self._service_name,
120+
created_at=created_at,
121+
url=url_str,
122+
associated_media=associated_media_list,
123+
interaction_count=interaction_count,
124+
status=status,
125+
text=raw_data_dict.get('description'),
126+
title=raw_data_dict.get('name'),
127+
)
128+
129+
def fetch_social_posting_vertical(
130+
self, request_params: dict[str, Any] = {}, count: int = 30
131+
) -> tuple[list[SocialPostingVertical | None], Any]:
132+
"""
133+
Fetches and returns social postings created by the authorized user.
134+
135+
:param count: number of posts to request. At most 30 at a time.
136+
:param request_params: any other endpoint-specific parameters to be sent
137+
to the endpoint. Depending on the parameters passed, this could override
138+
the other arguments to this method.
139+
140+
:returns: two elements: the first, a list of :class:`SocialPostingVertical`s
141+
or ``None``, if unable to parse; the second, the raw response from making the
142+
request.
143+
144+
:raises: :class:`UnsupportedRequestException` if the request is unable to be
145+
made.
146+
"""
147+
max_count = 30
148+
if count <= max_count:
149+
raw_social_postings = self._get_resource_from_path(
150+
'athlete/activities', params={'per_page': count, **request_params}
151+
).json()
152+
return [
153+
self._parse_social_posting(raw_social_posting)
154+
for raw_social_posting in raw_social_postings
155+
], raw_social_postings
156+
raise UnsupportedRequestException(
157+
self._service_name,
158+
f'can only make a request for at most {max_count} posts at a time.',
159+
)
160+
161+
def _parse_physical_activity(
162+
self, raw_data: Any
163+
) -> PhysicalActivityVertical | None:
164+
"""
165+
Given the response from the API request, creates a
166+
:class:`PhysicalActivityVertical` model object, if possible.
167+
168+
:param raw_data: the JSON representation of the data returned by the request.
169+
170+
:returns: :class:`PhysicalActivityVertical` or ``None``, depending on whether it
171+
was possible to extract data from the response
172+
"""
173+
social_posting = self._parse_social_posting(raw_data)
174+
if not social_posting:
175+
return None
176+
177+
social_posting_dict = social_posting.model_dump()
178+
raw_data_dict = defaultdict(dict, raw_data)
179+
180+
start_datetime = self._convert_to_datetime(raw_data_dict.get('start_date'))
181+
duration_s = raw_data_dict.get('elapsed_time')
182+
duration_timedelta = timedelta(seconds=duration_s) if duration_s else None
183+
end_datetime = (
184+
start_datetime + duration_timedelta
185+
if start_datetime and duration_timedelta
186+
else None
187+
)
188+
189+
start_latlng = raw_data_dict.get('start_latlng')
190+
end_latlng = raw_data_dict.get('end_latlng')
191+
192+
social_posting_dict.update(
193+
{
194+
'vertical_name': 'physical_activity',
195+
'activity_type': raw_data_dict.get('sport_type'),
196+
'distance': raw_data_dict.get('distance'),
197+
'elevation_high': raw_data_dict.get('elev_high'),
198+
'elevation_low': raw_data_dict.get('elev_low'),
199+
'kilocalories': raw_data_dict.get('calories'),
200+
'max_speed': raw_data_dict.get('max_speed'),
201+
'start_datetime': start_datetime,
202+
'end_datetime': end_datetime,
203+
'start_latitude': start_latlng[0] if start_latlng else None,
204+
'start_longitude': start_latlng[1] if start_latlng else None,
205+
'end_latitude': end_latlng[0] if end_latlng else None,
206+
'end_longitude': end_latlng[1] if end_latlng else None,
207+
}
208+
)
209+
210+
return PhysicalActivityVertical.model_validate(social_posting_dict)
211+
67212
def fetch_physical_activity_vertical(
68213
self, request_params: dict[str, Any] = {}, count: int = 30
69-
) -> list[Any]:
214+
) -> tuple[list[PhysicalActivityVertical | None], Any]:
70215
"""
71216
Fetches and returns activities completed by the authorized user.
72217
@@ -75,19 +220,22 @@ def fetch_physical_activity_vertical(
75220
to the endpoint. Depending on the parameters passed, this could override
76221
the other arguments to this method.
77222
78-
:returns: a list of dictionary objects with information for the activities from
79-
the authorized user.
223+
:returns: two elements: the first, a list of :class:`PhysicalActivityVertical`s
224+
or ``None``, if unable to parse; the second, the raw response from making the
225+
request.
80226
81227
:raises: :class:`UnsupportedRequestException` if the request is unable to be
82228
made.
83229
"""
84230
max_count = 30
85231
if count <= max_count:
86-
return list(
87-
self._get_resource_from_path(
88-
'athlete/activities', params={'per_page': count, **request_params}
89-
).json()
90-
)
232+
raw_activities = self._get_resource_from_path(
233+
'athlete/activities', params={'per_page': count, **request_params}
234+
).json()
235+
return [
236+
self._parse_physical_activity(raw_activity)
237+
for raw_activity in raw_activities
238+
], raw_activities
91239
raise UnsupportedRequestException(
92240
self._service_name,
93241
f'can only make a request for at most {max_count} activities at a time.',

src/pardner/verticals/base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import uuid
12
from abc import ABC
23
from datetime import datetime
34
from typing import Type
@@ -12,7 +13,7 @@ class BaseVertical(BaseModel, ABC):
1213
supported by every transfer service.
1314
"""
1415

15-
id: str
16+
id: str = Field(default_factory=lambda: uuid.uuid4().hex)
1617
creator_user_id: str
1718
service: str = Field(
1819
description='The name of the service the data was pulled from.'

0 commit comments

Comments
 (0)