Repository: stravalib/stravalib
Pull request #507 (2024-05-13 15:31:54)

Athlete stats
This fixes the type hierarchy for athlete-related objects.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/stravalib/stravalib/pull/507?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stravalib) Report
Attention: Patch coverage is `92.85714%` with `1 lines` in your changes are missing coverage. Please review.
> :exclamation: No coverage uploaded for pull request base (`pydantic-v2@104dcba`). [Click here to learn what that means](https://docs.codecov.io/docs/error-reference?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stravalib#section-missing-base-commit).

| [Files](https://app.codecov.io/gh/stravalib/stravalib/pull/507?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stravalib) | Patch % | Lines |
|---|---|---|
| [src/stravalib/model.py](https://app.codecov.io/gh/stravalib/stravalib/pull/507?src=pr&el=tree&filepath=src%2Fstravalib%2Fmodel.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stravalib#diff-c3JjL3N0cmF2YWxpYi9tb2RlbC5weQ==) | 90.90% | [1 Missing :warning: ](https://app.codecov.io/gh/stravalib/stravalib/pull/507?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stravalib) |

<details><summary>Additional details and impacted files</summary>


```diff
@@              Coverage Diff               @@
##             pydantic-v2     #507   +/-   ##
==============================================
  Coverage               ?   89.26%           
==============================================
  Files                  ?       10           
  Lines                  ?     1807           
  Branches               ?      170           
==============================================
  Hits                   ?     1613           
  Misses                 ?      154           
  Partials               ?       40           
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/stravalib/stravalib/pull/507?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stravalib).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=stravalib).


Patch:
diff --git a/src/stravalib/client.py b/src/stravalib/client.py
index d44e0da..72dbd3c 100644
--- a/src/stravalib/client.py
+++ b/src/stravalib/client.py
@@ -528,7 +528,7 @@ class Client:
 
     def get_club_members(
         self, club_id: int, limit: int | None = None
-    ) -> BatchedResultsIterator[model.Athlete]:
+    ) -> BatchedResultsIterator[strava_model.ClubAthlete]:
         """Gets the member objects for specified club ID.
 
         https://developers.strava.com/docs/reference/#api-Clubs-getClubMembersById
@@ -551,7 +551,7 @@ class Client:
         )
 
         return BatchedResultsIterator(
-            entity=model.Athlete,
+            entity=strava_model.ClubAthlete,
             bind_client=self,
             result_fetcher=result_fetcher,
             limit=limit,
@@ -588,6 +588,38 @@ class Client:
             limit=limit,
         )
 
+    def get_club_admins(
+        self, club_id: int, limit: int | None = None
+    ) -> BatchedResultsIterator[model.SummaryAthlete]:
+        """Returns a list of the administrators of a given club.
+
+        https://developers.strava.com/docs/reference/#api-Clubs-getClubAdminsById
+
+        Parameters
+        ----------
+        club_id : int
+            The numeric ID for the club.
+        limit : int
+            Maximum number of admins to return. (default unlimited)
+
+        Returns
+        -------
+        class:`BatchedResultsIterator`
+            An iterator of :class:`stravalib.model.SummaryAthlete` objects.
+
+        """
+
+        result_fetcher = functools.partial(
+            self.protocol.get, "/clubs/{id}/admins", id=club_id
+        )
+
+        return BatchedResultsIterator(
+            entity=model.SummaryAthlete,
+            bind_client=self,
+            result_fetcher=result_fetcher,
+            limit=limit,
+        )
+
     def get_activity(
         self, activity_id: int, include_all_efforts: bool = False
     ) -> model.Activity:
@@ -1062,7 +1094,7 @@ class Client:
 
     def get_activity_kudos(
         self, activity_id: int, limit: int | None = None
-    ) -> BatchedResultsIterator[model.ActivityKudos]:
+    ) -> BatchedResultsIterator[model.SummaryAthlete]:
         """Gets the kudos for an activity.
 
         https://developers.strava.com/docs/reference/#api-Activities-getKudoersByActivityId
@@ -1085,7 +1117,7 @@ class Client:
         )
 
         return BatchedResultsIterator(
-            entity=model.ActivityKudos,
+            entity=model.SummaryAthlete,
             bind_client=self,
             result_fetcher=result_fetcher,
             limit=limit,
diff --git a/src/stravalib/model.py b/src/stravalib/model.py
index 8f0a2f6..9891b79 100644
--- a/src/stravalib/model.py
+++ b/src/stravalib/model.py
@@ -32,13 +32,11 @@ from pydantic import BaseModel, Field, field_validator, model_validator
 
 from stravalib import exc, strava_model
 from stravalib.strava_model import (
-    ActivityStats,
     ActivityTotal,
     ActivityType,
     ActivityZone,
     BaseStream,
     Comment,
-    DetailedAthlete,
     DetailedClub,
     DetailedGear,
     DetailedSegment,
@@ -56,7 +54,6 @@ from stravalib.strava_model import (
     SummarySegmentEffort,
     TimedZoneRange,
 )
-from stravalib.strava_model import Route as RouteStrava
 
 if TYPE_CHECKING:
     from stravalib.client import BatchedResultsIterator
@@ -398,7 +395,7 @@ class ActivityTotals(ActivityTotal):
     pass
 
 
-class AthleteStats(ActivityStats):
+class AthleteStats(strava_model.ActivityStats):
     """
     Summary totals for rides, runs and swims, as shown in an athlete's public
     profile. Non-public activities are not counted for these totals.
@@ -416,10 +413,15 @@ class AthleteStats(ActivityStats):
     all_swim_totals: Optional[ActivityTotals] = None
 
 
-class Athlete(
-    DetailedAthlete,
-    BoundClientEntity,
-):
+class MetaAthlete(strava_model.MetaAthlete, BoundClientEntity):
+    pass
+
+
+class SummaryAthlete(MetaAthlete, strava_model.SummaryAthlete):
+    pass
+
+
+class Athlete(SummaryAthlete, strava_model.DetailedAthlete):
     """Represents high level athlete information including
     their name, email, clubs they belong to, bikes, shoes, etc.
 
@@ -436,7 +438,6 @@ class Athlete(
     shoes: Optional[list[SummaryGear]] = None
 
     # Undocumented attributes:
-    is_authenticated: Optional[bool] = None
     athlete_type: Optional[Literal["cyclist", "runner"]] = None
     friend: Optional[str] = None
     follower: Optional[str] = None
@@ -499,20 +500,6 @@ class Athlete(
             LOGGER.warning(f"Unknown athlete type value: {raw_type}")
             return None
 
-    @lazy_property
-    def authenticated_athlete(self) -> Athlete:
-        """
-        Returns an `Athlete` object containing Strava account data
-        for the (authenticated) athlete.
-
-        Returns
-        -------
-        Athlete
-            The detailed information of the authenticated athlete.
-        """
-        assert self.bound_client is not None, "Bound client is not set."
-        return self.bound_client.get_athlete()
-
     @lazy_property
     def stats(self) -> AthleteStats:
         """
@@ -527,52 +514,22 @@ class Athlete(
         `stravalib.exc.NotAuthenticatedAthlete` exception if authentication is
         missing.
         """
-        if not self.is_authenticated_athlete():
-            raise exc.NotAuthenticatedAthlete(
-                "Statistics are only available for the authenticated athlete"
-            )
-        assert self.bound_client is not None, "Bound client is not set."
 
+        assert self.bound_client is not None, "Bound client is not set."
         return self.bound_client.get_athlete_stats(self.id)
 
-    def is_authenticated_athlete(self) -> bool:
-        """Check if the athlete is authenticated
-
-        Returns
-        -------
-        bool
-            Whether the athlete is the authenticated athlete (or not).
-        """
-
-        if self.is_authenticated is None:
-            if self.resource_state == 3:
-                # If the athlete is in detailed state it must be the authenticated athlete
-                self.is_authenticated = True
-            else:
-                # Check if this athlete's id matches the authenticated athlete's id
-                authenticated_athlete = self.authenticated_athlete
-                if authenticated_athlete is None:
-                    return False
-
-                self.is_authenticated = authenticated_athlete.id == self.id
-
-        return self.is_authenticated
-
 
 class ActivityComment(Comment):
     """
     A class representing a comment on an activity.
 
-    This class overrides the `athlete` attribute to be of type :class:`Athlete`
-    instead of :class:`SummaryAthlete`.
-
     Attributes
     ----------
     athlete : Athlete, optional
         The athlete associated with the comment.
     """
 
-    athlete: Optional[Athlete] = None
+    athlete: Optional[SummaryAthlete] = None
 
 
 class ActivityPhotoPrimary(Primary):
@@ -677,18 +634,6 @@ class ActivityPhoto(BaseModel):
         )
 
 
-class ActivityKudos(Athlete):
-    """
-    Represents kudos an athlete received on an activity.
-
-    Notes
-    -----
-    Activity kudos are a subset of athlete properties.
-    """
-
-    pass
-
-
 class ActivityLap(
     Lap,
     BoundClientEntity,
@@ -909,7 +854,7 @@ class MetaActivity(strava_model.MetaActivity, BoundClientEntity):
         return self.bound_client.get_activity_zones(self.id)
 
     @lazy_property
-    def kudos(self) -> BatchedResultsIterator[ActivityKudos]:
+    def kudos(self) -> BatchedResultsIterator[SummaryAthlete]:
         """Retrieves the kudos provided for a specific activity."""
         assert self.bound_client is not None
         return self.bound_client.get_activity_kudos(self.id)
@@ -925,9 +870,7 @@ class MetaActivity(strava_model.MetaActivity, BoundClientEntity):
 
 class SummaryActivity(MetaActivity, strava_model.SummaryActivity):
     # field overrides from superclass for type extensions:
-    athlete: Optional[Athlete] = (
-        None  # TODO: this should also become a MetaAthlete
-    )
+    athlete: Optional[MetaAthlete] = None
     start_latlng: Optional[LatLon] = (
         None  # Do we still need these overrides for latlon?
     )
@@ -1042,7 +985,7 @@ class Stream(BaseStream):
 
 
 class Route(
-    RouteStrava,
+    strava_model.Route,
     BoundClientEntity,
 ):
     """
@@ -1050,7 +993,7 @@ class Route(
     """
 
     # Superclass field overrides for using extended types
-    athlete: Optional[Athlete] = None
+    athlete: Optional[SummaryAthlete] = None
     map: Optional[Map] = None
     segments: Optional[list[Segment]]  # type: ignore[assignment]
 


Test patch:
diff --git a/src/stravalib/tests/integration/test_client.py b/src/stravalib/tests/integration/test_client.py
index 6b18639..6d8d1d8 100644
--- a/src/stravalib/tests/integration/test_client.py
+++ b/src/stravalib/tests/integration/test_client.py
@@ -9,7 +9,7 @@ from responses import matchers
 
 from stravalib.client import ActivityUploader
 from stravalib.exc import AccessUnauthorized, ActivityPhotoUploadFailed
-from stravalib.model import Athlete
+from stravalib.model import Athlete, SummaryAthlete
 from stravalib.strava_model import SummaryActivity
 from stravalib.tests import RESOURCES_DIR
 from stravalib.unithelper import miles
@@ -52,6 +52,7 @@ def default_request_params():
 def test_get_athlete(mock_strava_api, client):
     mock_strava_api.get("/athlete", response_update={"id": 42})
     athlete = client.get_athlete()
+    assert isinstance(athlete, Athlete)
     assert athlete.id == 42
     assert athlete.measurement_preference == "feet"
 
@@ -171,6 +172,19 @@ def test_get_club_activities(mock_strava_api, client):
     assert activities[0].distance == 1000
 
 
+def test_get_club_admins(mock_strava_api, client):
+    mock_strava_api.get(
+        "/clubs/{id}/admins",
+        response_update={"firstname": "Jane"},
+        n_results=2,
+    )
+
+    admins = list(client.get_club_admins(42))
+    assert isinstance(admins[0], SummaryAthlete)
+    assert len(admins) == 2
+    assert admins[0].firstname == "Jane"
+
+
 def test_get_activity_zones(mock_strava_api, client, zone_response):
     """Returns an activities associated zone (related to heart rate and power)
 
@@ -991,30 +1005,6 @@ def test_get_activity_kudos(mock_strava_api, client):
         n_results=2,
     )
     kudoer_list = list(client.get_activity_kudos(42))
+    assert isinstance(kudoer_list[0], SummaryAthlete)
     assert len(kudoer_list) == 2
     assert kudoer_list[0].lastname == "Doe"
-
-
-class TestIsAuthenticatedAthlete:
-    def test_default(self, mock_strava_api, client):
-        mock_strava_api.get("/athlete", response_update={"id": 42})
-        athlete = client.get_athlete()
-        assert athlete.is_authenticated_athlete()
-
-    def test_caching(self):
-        athlete = Athlete(is_authenticated=True)
-        assert athlete.is_authenticated_athlete()
-
-    @pytest.mark.parametrize(
-        "match_id,expected_result", ((False, False), (True, True))
-    )
-    def test_from_summary(
-        self, mock_strava_api, client, match_id, expected_result
-    ):
-        mock_strava_api.get(
-            "/clubs/{id}/members",
-            response_update={"id": 42 if match_id else 21},
-        )
-        mock_strava_api.get("/athlete", response_update={"id": 42})
-        club_members = list(client.get_club_members(99))
-        assert club_members[0].is_authenticated_athlete() == expected_result