Repository: stravalib/stravalib
Pull request #518 (2024-06-02 08:04:07)

Adds SummarySegmentEffort superclass
Tiny PR for adding the `SummarySegmentEffort` superclass.


Patch:
diff --git a/src/stravalib/model.py b/src/stravalib/model.py
index e9c8cff..0794f54 100644
--- a/src/stravalib/model.py
+++ b/src/stravalib/model.py
@@ -28,7 +28,13 @@ from typing import (
 )
 
 from dateutil import parser
-from pydantic import BaseModel, Field, field_validator, model_validator
+from pydantic import (
+    AliasChoices,
+    BaseModel,
+    Field,
+    field_validator,
+    model_validator,
+)
 
 from stravalib import exc, strava_model
 from stravalib.strava_model import (
@@ -39,7 +45,6 @@ from stravalib.strava_model import (
     Comment,
     DetailedClub,
     DetailedGear,
-    DetailedSegmentEffort,
     ExplorerSegment,
     Lap,
     LatLng,
@@ -49,8 +54,6 @@ from stravalib.strava_model import (
     SportType,
     SummaryClub,
     SummaryGear,
-    SummaryPRSegmentEffort,
-    SummarySegmentEffort,
     TimedZoneRange,
 )
 
@@ -708,24 +711,19 @@ class SegmentExplorerResult(
         return self.bound_client.get_segment(self.id)
 
 
-class AthleteSegmentStats(
-    SummarySegmentEffort,
-):
-    """
-    A structure being returned for segment stats for current athlete.
-    """
-
-    # Undocumented attributes:
-    effort_count: Optional[int] = None
-    pr_elapsed_time: Optional[timedelta] = None
-    pr_date: Optional[date] = None
-
-    _naive_local = field_validator("start_date_local")(naive_datetime)
-
-
 class AthletePrEffort(
-    SummaryPRSegmentEffort,
+    strava_model.SummaryPRSegmentEffort,
 ):
+    # Override fields from superclass to match actual responses by Strava API:
+    activity_id: Optional[int] = Field(
+        validation_alias=AliasChoices("pr_activity_id", "activity_id"),
+        default=None,
+    )
+    elapsed_time: Optional[int] = Field(
+        validation_alias=AliasChoices("pr_elapsed_time", "elapsed_time"),
+        default=None,
+    )
+
     # Undocumented attributes:
     distance: Optional[float] = None
     start_date: Optional[datetime] = None
@@ -734,21 +732,16 @@ class AthletePrEffort(
 
     _naive_local = field_validator("start_date_local")(naive_datetime)
 
-    @property
-    def elapsed_time(self) -> Optional[int]:
-        """A property that supports backwards compatibility with the
-        elapsed_time method used in previous versions of stravalib"""
-
-        return self.pr_elapsed_time
-
 
 class SummarySegment(strava_model.SummarySegment, BoundClientEntity):
     # Field overrides from superclass for type extensions:
     start_latlng: Optional[LatLon] = None
     end_latlng: Optional[LatLon] = None
-    athlete_segment_stats: Optional[AthleteSegmentStats] = None
     athlete_pr_effort: Optional[AthletePrEffort] = None
     activity_type: Optional[RelaxedActivityType] = None  # type: ignore[assignment]
+    athlete_segment_stats: Optional[AthleteSegmentStats] = (
+        None  # Actually, this is only part of a (detailed) segment response
+    )
 
     _latlng_check = field_validator(
         "start_latlng", "end_latlng", mode="before"
@@ -801,8 +794,23 @@ class SegmentEffortAchievement(BaseModel):
     effort_count: Optional[int] = None
 
 
+class SummarySegmentEffort(strava_model.SummarySegmentEffort):
+    # Override superclass fields to match actual Strava API responses
+    activity_id: Optional[int] = Field(
+        validation_alias=AliasChoices("pr_activity_id", "activity_id"),
+        default=None,
+    )
+    elapsed_time: Optional[int] = Field(
+        validation_alias=AliasChoices("pr_elapsed_time", "elapsed_time"),
+        default=None,
+    )
+
+    _naive_local = field_validator("start_date_local")(naive_datetime)
+
+
 class BaseEffort(
-    DetailedSegmentEffort,
+    SummarySegmentEffort,
+    strava_model.DetailedSegmentEffort,
     BoundClientEntity,
 ):
     """
@@ -811,10 +819,8 @@ class BaseEffort(
 
     # Field overrides from superclass for type extensions:
     segment: Optional[SummarySegment] = None
-    activity: Optional[Activity] = None
-    athlete: Optional[Athlete] = None
-
-    _naive_local = field_validator("start_date_local")(naive_datetime)
+    activity: Optional[MetaActivity] = None
+    athlete: Optional[MetaAthlete] = None
 
 
 class BestEffort(BaseEffort):
@@ -833,6 +839,18 @@ class SegmentEffort(BaseEffort):
     achievements: Optional[list[SegmentEffortAchievement]] = None
 
 
+class AthleteSegmentStats(
+    SummarySegmentEffort,
+):
+    """
+    A structure being returned for segment stats for current athlete.
+    """
+
+    # Undocumented attributes:
+    effort_count: Optional[int] = None
+    pr_date: Optional[date] = None
+
+
 class MetaActivity(strava_model.MetaActivity, BoundClientEntity):
     @lazy_property
     def comments(self) -> BatchedResultsIterator[ActivityComment]:
@@ -1063,8 +1081,3 @@ class SubscriptionUpdate(BaseModel):
     aspect_type: Optional[str] = None
     event_time: Optional[datetime] = None
     updates: Optional[dict[str, Any]] = None
-
-
-SegmentEffort.update_forward_refs()
-ActivityLap.update_forward_refs()
-BestEffort.update_forward_refs()


Test patch:
diff --git a/src/stravalib/tests/unit/test_model.py b/src/stravalib/tests/unit/test_model.py
index 3fdd6dd..6620c13 100644
--- a/src/stravalib/tests/unit/test_model.py
+++ b/src/stravalib/tests/unit/test_model.py
@@ -18,11 +18,12 @@ from stravalib.model import (
     RelaxedSportType,
     Route,
     Segment,
+    SegmentEffort,
     SegmentExplorerResult,
     Split,
     SubscriptionCallback,
+    SummarySegmentEffort,
 )
-from stravalib.strava_model import LatLng
 from stravalib.tests import TestBase
 
 
@@ -55,6 +56,16 @@ from stravalib.tests import TestBase
             {"start_date_local": "2023-01-17T11:06:07Z"},
             datetime(2023, 1, 17, 11, 6, 7),
         ),
+        (
+            SummarySegmentEffort,
+            {"start_date_local": "2023-01-17T11:06:07Z"},
+            datetime(2023, 1, 17, 11, 6, 7),
+        ),
+        (
+            SegmentEffort,
+            {"start_date_local": "2023-01-17T11:06:07Z"},
+            datetime(2023, 1, 17, 11, 6, 7),
+        ),
     ),
 )
 def test_deserialization_edge_cases(model_class, raw, expected_value):
@@ -62,6 +73,46 @@ def test_deserialization_edge_cases(model_class, raw, expected_value):
     assert getattr(obj, list(raw.keys())[0]) == expected_value
 
 
+@pytest.mark.parametrize(
+    "model_class,raw,parsed_attr,expected_parsed_attr_value",
+    (
+        (SummarySegmentEffort, {"pr_activity_id": 42}, "activity_id", 42),
+        (SummarySegmentEffort, {"activity_id": 42}, "activity_id", 42),
+        (
+            SummarySegmentEffort,
+            {"pr_elapsed_time": 42},
+            "elapsed_time",
+            42,
+        ),
+        (
+            SummarySegmentEffort,
+            {"elapsed_time": 42},
+            "elapsed_time",
+            42,
+        ),
+        (AthletePrEffort, {"pr_activity_id": 42}, "activity_id", 42),
+        (AthletePrEffort, {"activity_id": 42}, "activity_id", 42),
+        (
+            AthletePrEffort,
+            {"pr_elapsed_time": 42},
+            "elapsed_time",
+            42,
+        ),
+        (
+            AthletePrEffort,
+            {"elapsed_time": 42},
+            "elapsed_time",
+            42,
+        ),
+    ),
+)
+def test_strava_api_field_name_inconsistencies(
+    model_class, raw, parsed_attr, expected_parsed_attr_value
+):
+    obj = model_class.model_validate(raw)
+    assert getattr(obj, parsed_attr) == expected_parsed_attr_value
+
+
 def test_subscription_callback_field_names():
     sub_callback_raw = {
         "hub.mode": "subscribe",
@@ -73,11 +124,6 @@ def test_subscription_callback_field_names():
     assert sub_callback.hub_verify_token == "STRAVA"
 
 
-class ExtLatLng(LatLng):
-    def foo(self):
-        return f"[{self[0], self[1]}]"
-
-
 # TODO: do we want to continue to support type?
 @pytest.mark.parametrize(
     "klass,attr,given_type,expected_type",