Repository: stravalib/stravalib (python)

Issue:
Stravalib2.0 -> Remove BackwardCompatibilityMixin
- [x] Remove this mixin to prevent typing issues such as #437.
- [x] Remove all `_field_conversions` from model classes.

The "enhanced" fields will get the same (parent) type as the overridden fields from the generated strava model.
This will make several tests fail, which will be resolved in other issues.

Pull request description:
Remove backward compatibility mixin
closes #498

## Description

Removes `BackwardCompatibilityMixin` and its related `_field_conversions` model attributes. As a result, all the previously "modified" types that, e.g., returned a pint `Quantity` object instead of a unitless float now return the base type. Later PRs will re-add these customized types but will ensure they match the parent type.

## Type of change

Select the statement best describes this pull request.

- [x] Breaking change (fix or feature that would cause existing functionality to not work as expected)

## Does your PR include tests

- [x] Yes

But the new test is skipped until we implement the new custom types.

## Did you include your contribution to the change log?

Let's add one changelog entry for when the complete `pydantic-v2` branch merges into `main`?


Patch:
diff --git a/src/stravalib/model.py b/src/stravalib/model.py
index d3fcdce..f21d9c9 100644
--- a/src/stravalib/model.py
+++ b/src/stravalib/model.py
@@ -31,10 +31,6 @@ from dateutil import parser
 from pydantic import BaseModel, Field, field_validator, model_validator
 
 from stravalib import exc, strava_model
-from stravalib import unithelper as uh
-from stravalib.field_conversions import (
-    timezone,
-)
 from stravalib.strava_model import (
     ActivityStats,
     ActivityTotal,
@@ -197,69 +193,6 @@ def check_valid_location(
 AllDateTypes = Union[datetime, str, bytes, int, float]
 
 
-class BackwardCompatibilityMixin:
-    """
-    Mixin that intercepts attribute lookup and raises warnings or modifies
-    return values based on what is defined in the following class attributes:
-
-    * _field_conversions
-
-    Notes
-    ------
-    The class attributes below are not yet implemented:
-    * _deprecated_fields (TODO)
-    * _unsupported_fields (TODO)
-    """
-
-    pass
-
-    def __getattribute__(self, attr: str) -> Any:
-        """A method to retrieve attributes from the object.
-
-        Parameters
-        ----------
-        attr : str
-            The name of the attribute to retrieve.
-
-        Returns
-        -------
-        Any
-            The value of the requested attribute.
-
-        Notes
-        -----
-        This method is called whenever an attribute is accessed on the object.
-        It is used to control attribute retrieval and perform additional
-        actions.
-
-        """
-        value = object.__getattribute__(self, attr)
-        if attr in ["_field_conversions", "bound_client"] or attr.startswith(
-            "_"
-        ):
-            return value
-        try:
-            # This won't return a attribute error if it's none
-            if attr in self._field_conversions:
-                return self._field_conversions[attr](value)
-        except (TypeError, AttributeError):
-            # Handle case where _field_conversions is None or not subscriptable
-            pass
-        try:
-            value.bound_client = self.bound_client
-            return value
-        except (AttributeError, ValueError):
-            pass
-        try:
-            for v in value:
-                v.bound_client = self.bound_client
-            return value
-        except (AttributeError, ValueError, TypeError):
-            # TypeError if v is not iterable
-            pass
-        return value
-
-
 class BoundClientEntity(BaseModel):
     """A class that bounds the Client object to the model."""
 
@@ -323,7 +256,7 @@ class RelaxedSportType(SportType):
         return values
 
 
-class LatLon(LatLng, BackwardCompatibilityMixin):
+class LatLon(LatLng):
     """
     Enables backward compatibility for legacy namedtuple
     """
@@ -381,7 +314,6 @@ class LatLon(LatLng, BackwardCompatibilityMixin):
 
 class Club(
     DetailedClub,
-    BackwardCompatibilityMixin,
     BoundClientEntity,
 ):
     """
@@ -434,12 +366,12 @@ class Club(
         return self.bound_client.get_club_activities(self.id)
 
 
-class Gear(DetailedGear, BackwardCompatibilityMixin):
+class Gear(DetailedGear):
     """
     Represents a piece of gear (equipment) used in physical activities.
     """
 
-    _field_conversions = {"distance": uh.meters}
+    pass
 
 
 class Bike(Gear):
@@ -460,19 +392,14 @@ class Shoe(Gear):
     pass
 
 
-class ActivityTotals(ActivityTotal, BackwardCompatibilityMixin):
+class ActivityTotals(ActivityTotal):
     """An objecting containing a set of total values for an activity including
     elapsed time, moving time, distance and elevation gain."""
 
-    _field_conversions = {
-        "distance": uh.meters,
-        "elevation_gain": uh.meters,
-    }
-    elapsed_time: Optional[timedelta] = None  # type: ignore[assignment]
-    moving_time: Optional[timedelta] = None  # type: ignore[assignment]
+    pass
 
 
-class AthleteStats(ActivityStats, BackwardCompatibilityMixin):
+class AthleteStats(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.
@@ -489,15 +416,9 @@ class AthleteStats(ActivityStats, BackwardCompatibilityMixin):
     all_run_totals: Optional[ActivityTotals] = None
     all_swim_totals: Optional[ActivityTotals] = None
 
-    _field_conversions = {
-        "biggest_ride_distance": uh.meters,
-        "biggest_climb_elevation_gain": uh.meters,
-    }
-
 
 class Athlete(
     DetailedAthlete,
-    BackwardCompatibilityMixin,
     BoundClientEntity,
 ):
     """Represents high level athlete information including
@@ -698,7 +619,7 @@ class ActivityPhotoMeta(PhotosSummary):
     use_primary_photo: Optional[bool] = None
 
 
-class ActivityPhoto(BaseModel, BackwardCompatibilityMixin):
+class ActivityPhoto(BaseModel):
     """A full photo record attached to an activity.
 
     Notes
@@ -747,7 +668,7 @@ class ActivityPhoto(BaseModel, BackwardCompatibilityMixin):
         else:
             photo_type = "(no type)"
             idfield = "id"
-            idval = self.id
+            idval = self.uid
 
         return "<{clz} {type} {idfield}={id}>".format(
             clz=self.__class__.__name__,
@@ -771,7 +692,6 @@ class ActivityKudos(Athlete):
 
 class ActivityLap(
     Lap,
-    BackwardCompatibilityMixin,
     BoundClientEntity,
 ):
     # Field overrides from superclass for type extensions:
@@ -784,15 +704,6 @@ class ActivityLap(
     max_heartrate: Optional[float] = None
     device_watts: Optional[bool] = None
 
-    _field_conversions = {
-        "distance": uh.meters,
-        "total_elevation_gain": uh.meters,
-        "average_speed": uh.meters_per_second,
-        "max_speed": uh.meters_per_second,
-    }
-    elapsed_time: Optional[timedelta] = None  # type: ignore[assignment]
-    moving_time: Optional[timedelta] = None  # type: ignore[assignment]
-
     _naive_local = field_validator("start_date_local")(naive_datetime)
 
 
@@ -804,7 +715,6 @@ class Map(PolylineMap):
 
 class Split(
     strava_model.Split,
-    BackwardCompatibilityMixin,
 ):
     """
     A split -- may be metric or standard units (which has no bearing
@@ -815,19 +725,9 @@ class Split(
     average_heartrate: Optional[float] = None
     average_grade_adjusted_speed: Optional[float] = None
 
-    _field_conversions = {
-        "distance": uh.meters,
-        "elevation_difference": uh.meters,
-        "average_speed": uh.meters_per_second,
-        "average_grade_adjusted_speed": uh.meters_per_second,
-    }
-    elapsed_time: Optional[timedelta] = None  # type: ignore[assignment]
-    moving_time: Optional[timedelta] = None  # type: ignore[assignment]
-
 
 class SegmentExplorerResult(
     ExplorerSegment,
-    BackwardCompatibilityMixin,
     BoundClientEntity,
 ):
     """
@@ -844,8 +744,6 @@ class SegmentExplorerResult(
     # Undocumented attributes:
     starred: Optional[bool] = None
 
-    _field_conversions = {"elev_difference", uh.meters, "distance", uh.meters}
-
     _check_latlng = field_validator(
         "start_latlng", "end_latlng", mode="before"
     )(check_valid_location)
@@ -869,7 +767,6 @@ class SegmentExplorerResult(
 
 class AthleteSegmentStats(
     SummarySegmentEffort,
-    BackwardCompatibilityMixin,
 ):
     """
     A structure being returned for segment stats for current athlete.
@@ -880,17 +777,11 @@ class AthleteSegmentStats(
     pr_elapsed_time: Optional[timedelta] = None
     pr_date: Optional[date] = None
 
-    _field_conversions = {
-        "distance": uh.meters,
-    }
-    elapsed_time: Optional[timedelta] = None  # type: ignore[assignment]
-
     _naive_local = field_validator("start_date_local")(naive_datetime)
 
 
 class AthletePrEffort(
     SummaryPRSegmentEffort,
-    BackwardCompatibilityMixin,
 ):
     # Undocumented attributes:
     distance: Optional[float] = None
@@ -898,15 +789,10 @@ class AthletePrEffort(
     start_date_local: Optional[datetime] = None
     is_kom: Optional[bool] = None
 
-    _field_conversions = {
-        "distance": uh.meters,
-    }
-    pr_elapsed_time: Optional[timedelta] = None  # type: ignore[assignment]
-
     _naive_local = field_validator("start_date_local")(naive_datetime)
 
     @property
-    def elapsed_time(self) -> Optional[timedelta]:
+    def elapsed_time(self) -> Optional[int]:
         """A property that supports backwards compatibility with the
         elapsed_time method used in previous versions of stravalib"""
 
@@ -915,7 +801,6 @@ class AthletePrEffort(
 
 class Segment(
     DetailedSegment,
-    BackwardCompatibilityMixin,
     BoundClientEntity,
 ):
     """
@@ -940,13 +825,6 @@ class Segment(
     starred_date: Optional[datetime] = None
     elevation_profile: Optional[str] = None
 
-    _field_conversions = {
-        "distance": uh.meters,
-        "elevation_high": uh.meters,
-        "elevation_low": uh.meters,
-        "total_elevation_gain": uh.meters,
-    }
-
     _latlng_check = field_validator(
         "start_latlng", "end_latlng", mode="before"
     )(check_valid_location)
@@ -981,7 +859,6 @@ class SegmentEffortAchievement(BaseModel):
 
 class BaseEffort(
     DetailedSegmentEffort,
-    BackwardCompatibilityMixin,
     BoundClientEntity,
 ):
     """
@@ -993,12 +870,6 @@ class BaseEffort(
     activity: Optional[Activity] = None
     athlete: Optional[Athlete] = None
 
-    _field_conversions = {
-        "distance": uh.meters,
-    }
-    moving_time: Optional[timedelta] = None  # type: ignore[assignment]
-    elapsed_time: Optional[timedelta] = None  # type: ignore[assignment]
-
     _naive_local = field_validator("start_date_local")(naive_datetime)
 
 
@@ -1020,7 +891,6 @@ class SegmentEffort(BaseEffort):
 
 class Activity(
     DetailedActivity,
-    BackwardCompatibilityMixin,
     BoundClientEntity,
 ):
     """
@@ -1075,16 +945,6 @@ class Activity(
     segment_leaderboard_opt_out: Optional[bool] = None
     perceived_exertion: Optional[int] = None
 
-    _field_conversions = {
-        "timezone": timezone,
-        "distance": uh.meters,
-        "total_elevation_gain": uh.meters,
-        "average_speed": uh.meters_per_second,
-        "max_speed": uh.meters_per_second,
-    }
-    moving_time: Optional[timedelta] = None  # type: ignore[assignment]
-    elapsed_time: Optional[timedelta] = None  # type: ignore[assignment]
-
     _latlng_check = field_validator(
         "start_latlng", "end_latlng", mode="before"
     )(check_valid_location)
@@ -1135,8 +995,6 @@ class DistributionBucket(TimedZoneRange):
     return `int` or `float`.
     """
 
-    _field_conversions = {"time": uh.seconds}
-
     # Overrides due to a bug in the Strava API docs
     # Because the created strava_model.py has this typed as int we will need
     # to override these types
@@ -1146,7 +1004,6 @@ class DistributionBucket(TimedZoneRange):
 
 class BaseActivityZone(
     ActivityZone,
-    BackwardCompatibilityMixin,
     BoundClientEntity,
 ):
     """
@@ -1163,7 +1020,7 @@ class BaseActivityZone(
     type: Optional[Literal["heartrate", "power", "pace"]] = None  # type: ignore[assignment]
 
 
-class Stream(BaseStream, BackwardCompatibilityMixin):
+class Stream(BaseStream):
     """
     Stream of readings from the activity, effort or segment.
     """
@@ -1177,7 +1034,6 @@ class Stream(BaseStream, BackwardCompatibilityMixin):
 
 class Route(
     RouteStrava,
-    BackwardCompatibilityMixin,
     BoundClientEntity,
 ):
     """
@@ -1189,10 +1045,8 @@ class Route(
     map: Optional[Map] = None
     segments: Optional[list[Segment]]  # type: ignore[assignment]
 
-    _field_conversions = {"distance": uh.meters, "elevation_gain": uh.meters}
-
 
-class Subscription(BackwardCompatibilityMixin, BoundClientEntity):
+class Subscription(BaseModel):
     """
     Represents a Webhook Event Subscription.
     """
@@ -1210,10 +1064,7 @@ class Subscription(BackwardCompatibilityMixin, BoundClientEntity):
     updated_at: Optional[datetime] = None
 
 
-# Note: the DeprecatedSerializableMixin inherited from BaseModel
-# So we may have to add BaseModel to some of these smaller objects that
-# relied upon the mixin
-class SubscriptionCallback(BaseModel, BackwardCompatibilityMixin):
+class SubscriptionCallback(BaseModel):
     """
     Represents a Webhook Event Subscription Callback.
     """
@@ -1248,7 +1099,7 @@ class SubscriptionCallback(BaseModel, BackwardCompatibilityMixin):
         assert self.hub_verify_token == verify_token
 
 
-class SubscriptionUpdate(BackwardCompatibilityMixin, BoundClientEntity):
+class SubscriptionUpdate(BaseModel):
     """
     Represents a Webhook Event Subscription Update.
     """


Test patch:
diff --git a/src/stravalib/tests/integration/test_client.py b/src/stravalib/tests/integration/test_client.py
index ad3d2bf..476afd9 100644
--- a/src/stravalib/tests/integration/test_client.py
+++ b/src/stravalib/tests/integration/test_client.py
@@ -7,12 +7,11 @@ import pytest
 import responses
 from responses import matchers
 
-import stravalib.unithelper as uh
 from stravalib.client import ActivityUploader
 from stravalib.exc import AccessUnauthorized, ActivityPhotoUploadFailed
 from stravalib.model import Athlete
 from stravalib.tests import RESOURCES_DIR
-from stravalib.unithelper import UnitConverter, meters, miles
+from stravalib.unithelper import miles
 
 
 @pytest.fixture
@@ -156,7 +155,7 @@ def test_get_activity_laps(mock_strava_api, client):
     )
     laps = list(client.get_activity_laps(42))
     assert len(laps) == 2
-    assert laps[0].distance == meters(1000)
+    assert laps[0].distance == 1000
 
 
 def test_get_club_activities(mock_strava_api, client):
@@ -168,7 +167,7 @@ def test_get_club_activities(mock_strava_api, client):
 
     activities = list(client.get_club_activities(42))
     assert len(activities) == 2
-    assert activities[0].distance == meters(1000)
+    assert activities[0].distance == 1000
 
 
 def test_get_activity_zones(mock_strava_api, client, zone_response):
@@ -783,9 +782,7 @@ def test_get_athlete_stats(
             client.get_athlete_stats(athlete_id)
     else:
         stats = client.get_athlete_stats(athlete_id)
-        assert stats.biggest_ride_distance == UnitConverter("meters")(
-            expected_biggest_ride_distance
-        )
+        assert stats.biggest_ride_distance == expected_biggest_ride_distance
 
 
 def test_get_gear(mock_strava_api, client):
@@ -826,10 +823,10 @@ def test_get_activities_quantity_addition(mock_strava_api, client):
         n_results=2,
     )
     act_list = list(client.get_activities(limit=2))
-    total_d = uh.meters(0)
+    total_d = 0
     total_d += act_list[0].distance
     total_d += act_list[1].distance
-    assert total_d == uh.meters(2000.0)
+    assert total_d == 2000.0
 
 
 def test_get_segment(mock_strava_api, client):
diff --git a/src/stravalib/tests/unit/test_model.py b/src/stravalib/tests/unit/test_model.py
index beda593..3fdd6dd 100644
--- a/src/stravalib/tests/unit/test_model.py
+++ b/src/stravalib/tests/unit/test_model.py
@@ -1,30 +1,29 @@
 from datetime import datetime
-from typing import List, Optional
 
-import pint
 import pytest
-from pydantic import BaseModel
 
 from stravalib import model
-from stravalib import unithelper as uh
 from stravalib.model import (
     Activity,
     ActivityLap,
     ActivityPhoto,
-    BackwardCompatibilityMixin,
+    ActivityTotals,
+    AthletePrEffort,
+    AthleteSegmentStats,
+    AthleteStats,
     BaseEffort,
-    BoundClientEntity,
     # Club, # CLUB IS not currently used in this module
     LatLon,
     RelaxedActivityType,
     RelaxedSportType,
+    Route,
     Segment,
     SegmentExplorerResult,
+    Split,
     SubscriptionCallback,
 )
 from stravalib.strava_model import LatLng
 from stravalib.tests import TestBase
-from stravalib.unithelper import Quantity
 
 
 @pytest.mark.parametrize(
@@ -40,7 +39,7 @@ from stravalib.unithelper import Quantity
         (Segment, {"start_latlng": []}, None),
         (SegmentExplorerResult, {"start_latlng": []}, None),
         (ActivityPhoto, {"location": []}, None),
-        (Activity, {"timezone": "foobar"}, None),
+        # (Activity, {"timezone": "foobar"}, None),  TODO re-add this test when custom types are implemented
         (
             Activity,
             {"start_date_local": "2023-01-17T11:06:07Z"},
@@ -74,115 +73,11 @@ def test_subscription_callback_field_names():
     assert sub_callback.hub_verify_token == "STRAVA"
 
 
-# Below are some toy classes to test type extensions and attribute lookup:
-class A(BaseModel, BackwardCompatibilityMixin):
-    x: Optional[int] = None
-
-
-class ConversionA(A, BackwardCompatibilityMixin):
-    _field_conversions = {"x": uh.meters}
-
-
-class BoundA(A, BoundClientEntity):
-    pass
-
-
-class B(BaseModel, BackwardCompatibilityMixin):
-    a: Optional[A] = None
-    bound_a: Optional[BoundA] = None
-
-
-class BoundB(B, BoundClientEntity):
-    pass
-
-
-class C(BaseModel, BackwardCompatibilityMixin):
-    a: Optional[List[A]] = None
-    bound_a: Optional[List[BoundA]] = None
-
-
-class BoundC(C, BoundClientEntity):
-    pass
-
-
-class D(BaseModel, BackwardCompatibilityMixin):
-    a: Optional[LatLng] = None
-
-
-class ExtA(A):
-    def foo(self):
-        return self.x
-
-
 class ExtLatLng(LatLng):
     def foo(self):
         return f"[{self[0], self[1]}]"
 
 
-@pytest.mark.parametrize(
-    "lookup_expression,expected_result,expected_bound_client",
-    (
-        (A().x, None, False),
-        (B().a, None, False),
-        (A(x=1).x, 1, False),
-        (B(a=A(x=1)).a, A(x=1), False),
-        (C(a=[A(x=1), A(x=2)]).a[1], A(x=2), False),
-        (ConversionA(x=1).x, pint.Quantity("1 meter"), False),
-        (BoundA(x=1).x, 1, False),
-        (B(bound_a=BoundA(x=1)).bound_a, BoundA(x=1), None),
-        (
-            B(bound_a=BoundA(x=1, bound_client=1)).bound_a,
-            BoundA(x=1, bound_client=1),
-            True,
-        ),
-        (BoundB(a=A(x=1)).a, A(x=1), False),
-        (BoundB(a=A(x=1), bound_client=1).a, A(x=1), False),
-        (BoundB(bound_a=BoundA(x=1)).bound_a, BoundA(x=1), None),
-        (
-            BoundB(bound_a=BoundA(x=1), bound_client=1).bound_a,
-            BoundA(x=1, bound_client=1),
-            True,
-        ),
-        (C(bound_a=[BoundA(x=1), BoundA(x=2)]).bound_a[1], BoundA(x=2), None),
-        (
-            C(
-                bound_a=[
-                    BoundA(x=1, bound_client=1),
-                    BoundA(x=2, bound_client=1),
-                ]
-            ).bound_a[1],
-            BoundA(x=2, bound_client=1),
-            True,
-        ),
-        (BoundC(a=[A(x=1), A(x=2)]).a[1], A(x=2), False),
-        (BoundC(a=[A(x=1), A(x=2)], bound_client=1).a[1], A(x=2), False),
-        (
-            BoundC(bound_a=[BoundA(x=1), BoundA(x=2)]).bound_a[1],
-            BoundA(x=2),
-            None,
-        ),
-        (
-            BoundC(bound_a=[BoundA(x=1), BoundA(x=2)], bound_client=1).bound_a[
-                1
-            ],
-            BoundA(x=2, bound_client=1),
-            True,
-        ),
-    ),
-)
-def test_backward_compatible_attribute_lookup(
-    lookup_expression, expected_result, expected_bound_client
-):
-    assert lookup_expression == expected_result
-
-    if expected_bound_client:
-        assert lookup_expression.bound_client is not None
-    elif expected_bound_client is None:
-        assert lookup_expression.bound_client is None
-    elif not expected_bound_client:
-        assert not hasattr(lookup_expression, "bound_client")
-
-
 # TODO: do we want to continue to support type?
 @pytest.mark.parametrize(
     "klass,attr,given_type,expected_type",
@@ -205,6 +100,80 @@ def test_relaxed_activity_type_validation(
         assert obj == RelaxedActivityType(root=expected_type)
 
 
+# TODO: these classes are just temporary placeholders and should be replace with
+# actual custom types.
+
+
+class DistanceType:
+    pass
+
+
+class TimeDeltaType:
+    pass
+
+
+class TimezoneType:
+    pass
+
+
+class VelocityType:
+    pass
+
+
+@pytest.mark.skip(reason="not implemented yet")
+@pytest.mark.parametrize(
+    "model_type,attr,expected_base_type,expected_extended_type",
+    (
+        (ActivityTotals, "distance", float, DistanceType),
+        (ActivityTotals, "elevation_gain", float, DistanceType),
+        (ActivityTotals, "elapsed_time", int, TimeDeltaType),
+        (ActivityTotals, "moving_time", int, TimeDeltaType),
+        (AthleteStats, "biggest_ride_distance", float, DistanceType),
+        (AthleteStats, "biggest_climb_elevation_gain", float, DistanceType),
+        (ActivityLap, "distance", float, DistanceType),
+        (ActivityLap, "total_elevation_gain", float, DistanceType),
+        (ActivityLap, "average_speed", float, VelocityType),
+        (ActivityLap, "max_speed", float, VelocityType),
+        (ActivityLap, "elapsed_time", int, TimeDeltaType),
+        (ActivityLap, "moving_time", int, TimeDeltaType),
+        (Split, "distance", float, DistanceType),
+        (Split, "elevation_difference", float, DistanceType),
+        (Split, "average_speed", float, VelocityType),
+        (Split, "average_grade_adjusted_speed", float, VelocityType),
+        (Split, "elapsed_time", int, TimeDeltaType),
+        (Split, "moving_time", int, TimeDeltaType),
+        (SegmentExplorerResult, "elev_difference", float, DistanceType),
+        (SegmentExplorerResult, "distance", float, DistanceType),
+        (AthleteSegmentStats, "distance", float, DistanceType),
+        (AthleteSegmentStats, "elapsed_time", int, TimeDeltaType),
+        (AthletePrEffort, "distance", float, DistanceType),
+        (AthletePrEffort, "pr_elapsed_time", int, TimeDeltaType),
+        (Segment, "distance", float, DistanceType),
+        (Segment, "elevation_high", float, DistanceType),
+        (Segment, "elevation_low", float, DistanceType),
+        (Segment, "total_elevation_gain", float, DistanceType),
+        (BaseEffort, "distance", float, DistanceType),
+        (BaseEffort, "elapsed_time", int, TimeDeltaType),
+        (BaseEffort, "moving_time", int, TimeDeltaType),
+        (Activity, "distance", float, DistanceType),
+        (Activity, "timezone", str, TimezoneType),
+        (Activity, "total_elevation_gain", float, DistanceType),
+        (Activity, "average_speed", float, VelocityType),
+        (Activity, "max_speed", float, VelocityType),
+        (Activity, "elapsed_time", int, TimeDeltaType),
+        (Activity, "moving_time", int, TimeDeltaType),
+        (Route, "distance", float, DistanceType),
+        (Route, "elevation_gain", float, DistanceType),
+    ),
+)
+def test_extended_types(
+    model_type, attr, expected_base_type, expected_extended_type
+):
+    obj = model_type.model_validate(dict(**{attr: 42}))
+    assert isinstance(getattr(obj, attr), expected_base_type)
+    assert isinstance(getattr(obj, attr), expected_extended_type)
+
+
 class ModelTest(TestBase):
     def setUp(self):
         super(ModelTest, self).setUp()
@@ -238,68 +207,6 @@ class ModelTest(TestBase):
         self.assertEqual(3, len(a.clubs))
         self.assertEqual("Team Roaring Mouse", a.clubs[0].name)
 
-    def test_speed_units(self):
-        a = model.Activity()
-
-        a.max_speed = 1000  # m/s
-        a.average_speed = 1000  # m/s
-        self.assertAlmostEqual(3600.0, float(uh.kph(a.max_speed)))
-        self.assertAlmostEqual(3600.0, float(uh.kph(a.average_speed)))
-
-        a.max_speed = uh.mph(1.0)
-        # print repr(a.max_speed)
-
-        self.assertAlmostEqual(1.61, float(uh.kph(a.max_speed)), places=2)
-
-    def test_time_intervals(self):
-        segment = model.Segment()
-        # s.pr_time = XXXX
-
-        split = model.Split()
-        split.moving_time = 3.1
-        split.elapsed_time = 5.73
-
-    def test_distance_units(self):
-        # Gear
-        g = model.Gear()
-        g.distance = 1000
-        self.assertEqual(1.0, float(uh.kilometers(g.distance)))
-
-        # Metric Split
-        split = model.Split()
-        split.distance = 1000  # meters
-        split.elevation_difference = 1000  # meters
-        self.assertIsInstance(split.distance, Quantity)
-        self.assertIsInstance(split.elevation_difference, Quantity)
-        self.assertEqual(1.0, float(uh.kilometers(split.distance)))
-        self.assertEqual(1.0, float(uh.kilometers(split.elevation_difference)))
-        split = None
-
-        # Segment
-        s = model.Segment()
-        s.distance = 1000
-        s.elevation_high = 2000
-        s.elevation_low = 1000
-        self.assertIsInstance(s.distance, Quantity)
-        self.assertIsInstance(s.elevation_high, Quantity)
-        self.assertIsInstance(s.elevation_low, Quantity)
-        self.assertEqual(1.0, float(uh.kilometers(s.distance)))
-        self.assertEqual(2.0, float(uh.kilometers(s.elevation_high)))
-        self.assertEqual(1.0, float(uh.kilometers(s.elevation_low)))
-
-        # Activity
-        a = model.Activity()
-        a.distance = 1000  # m
-        a.total_elevation_gain = 1000  # m
-        self.assertIsInstance(a.distance, Quantity)
-        self.assertIsInstance(a.total_elevation_gain, Quantity)
-        self.assertEqual(1.0, float(uh.kilometers(a.distance)))
-        self.assertEqual(1.0, float(uh.kilometers(a.total_elevation_gain)))
-
-    def test_weight_units(self):
-        """ """
-        # PowerActivityZone
-
     def test_subscription_deser(self):
         d = {
             "id": 1,