Repository: stravalib/stravalib

Issue:
ENH: Graceful handling of Pydantic validation errors (for unexpected sports type / activity type)
### Feature Type

- [ ] Adding new functionality to stravalib

- [X] Changing existing functionality in stravalib

- [ ] Removing existing functionality in stravalib


### Problem Description

For unexpected activity/sport types, parsing `Activity` objects crashes with a Pydantic `ValidationError`. On (rare) occasions, these unexpected (and undocumented) types pop up in responses from Strava.

### Feature Description

We can add custom types and validators for (at least) the mentioned fields `activity_type` and `sports_type`. For unexpected types, we can then parse them and log a warning instead of crashing.

### Alternative Solutions

Unfeasible: requesting Strava to always keep the documentation 100% up-to-date.

### Additional Context

_No response_

### Code of Conduct

- [X] I agree to follow this project's Code of Conduct

Comments:

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index c1e950f..26e03a2 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -25,7 +25,7 @@ default_stages: [commit]
 repos:
   # Misc commit checks
   - repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v4.4.0
+    rev: v4.5.0
     # ref: https://github.com/pre-commit/pre-commit-hooks#hooks-available
     hooks:
       # Autoformat: Makes sure files end in a newline and only a newline.
@@ -37,14 +37,14 @@ repos:
 
   # Linting code using ruff
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.0.292
+    rev: v0.1.9
     hooks:
     - id: ruff
       args: [--fix, --exit-non-zero-on-fix]
 
   # Black for auto code formatting
   - repo: https://github.com/psf/black
-    rev: 23.9.1
+    rev: 23.12.1
     hooks:
       - id: black
         entry: bash -c 'black "$@"; git add -u' --
diff --git a/changelog.md b/changelog.md
index d127d7c..8cfcf9e 100644
--- a/changelog.md
+++ b/changelog.md
@@ -2,12 +2,13 @@
 
 ## Unreleased
 
-- Fix: forgot to update model CI build to follow new src layout (@lwasser, #438)
+- Fix: Forgot to update model CI build to follow new src layout (@lwasser, #438)
 - Fix: Type annotation on field that are of type timedelta are now correct (@enadeau, #440)
 - Fix: Correct type for ActivityPhoto sizes attribute (@jsamoocha, #444)
 - Fix: codespell config - ignore resources dir in tests (@lwasser, #445)
 - Add: Support for Strava's new read rate limits (@jsamoocha, #446)
-- Fix: ignore documentation autogenerated api stubs (@lwasser, #447)
+- Fix: Ignore documentation autogenerated api stubs (@lwasser, #447)
+- Add: Improved handling of unexpected activity types (@jsamoocha, #454)
 
 ### Breaking Changes
 
diff --git a/src/stravalib/model.py b/src/stravalib/model.py
index 49d9e09..5879ae1 100644
--- a/src/stravalib/model.py
+++ b/src/stravalib/model.py
@@ -348,6 +348,30 @@ class BoundClientEntity(BaseModel):
     bound_client: Optional[Any] = Field(None, exclude=True)
 
 
+class RelaxedActivityType(ActivityType):
+    @root_validator(pre=True)
+    def check_activity_type(cls, values: dict[str, Any]) -> dict[str, Any]:
+        v = values["__root__"]
+        if v not in get_args(ActivityType.__fields__["__root__"].type_):
+            LOGGER.warning(
+                f'Unexpected activity type. Given={v}, replacing by "Workout"'
+            )
+            values["__root__"] = "Workout"
+        return values
+
+
+class RelaxedSportType(SportType):
+    @root_validator(pre=True)
+    def check_sport_type(cls, values: dict[str, Any]) -> dict[str, Any]:
+        v = values["__root__"]
+        if v not in get_args(SportType.__fields__["__root__"].type_):
+            LOGGER.warning(
+                f'Unexpected sport type. Given={v}, replacing by "Workout"'
+            )
+            values["__root__"] = "Workout"
+        return values
+
+
 class LatLon(LatLng, BackwardCompatibilityMixin, DeprecatedSerializableMixin):
     """
     Enables backward compatibility for legacy namedtuple
@@ -935,7 +959,7 @@ class Segment(
     map: Optional[Map] = None
     athlete_segment_stats: Optional[AthleteSegmentStats] = None
     athlete_pr_effort: Optional[AthletePrEffort] = None
-    activity_type: Optional[ActivityType] = None  # type: ignore[assignment]
+    activity_type: Optional[RelaxedActivityType] = None  # type: ignore[assignment]
 
     # Undocumented attributes:
     start_latitude: Optional[float] = None
@@ -952,6 +976,7 @@ class Segment(
         "elevation_high": uh.meters,
         "elevation_low": uh.meters,
         "total_elevation_gain": uh.meters,
+        "activity_type": enum_value,
     }
 
     _latlng_check = validator(
@@ -1040,6 +1065,8 @@ class Activity(
     end_latlng: Optional[LatLon] = None
     map: Optional[Map] = None
     gear: Optional[Gear] = None
+    type: Optional[RelaxedActivityType] = None
+    sport_type: Optional[RelaxedSportType] = None
     # Ignoring types here given there are overrides
     best_efforts: Optional[list[BestEffort]] = None  # type: ignore[assignment]
     segment_efforts: Optional[list[SegmentEffort]] = None  # type: ignore[assignment]


Test patch:
diff --git a/src/stravalib/tests/unit/test_model.py b/src/stravalib/tests/unit/test_model.py
index b9c6074..ec01344 100644
--- a/src/stravalib/tests/unit/test_model.py
+++ b/src/stravalib/tests/unit/test_model.py
@@ -225,6 +225,23 @@ def test_backward_compatible_attribute_lookup(
         assert not hasattr(lookup_expression, "bound_client")
 
 
+@pytest.mark.parametrize(
+    "klass,attr,given_type,expected_type",
+    (
+        (Activity, "type", "Run", "Run"),
+        (Activity, "sport_type", "Run", "Run"),
+        (Activity, "type", "FooBar", "Workout"),
+        (Activity, "sport_type", "FooBar", "Workout"),
+        (Segment, "activity_type", "Run", "Run"),
+        (Segment, "activity_type", "FooBar", "Workout"),
+    ),
+)
+def test_relaxed_activity_type_validation(
+    klass, attr, given_type, expected_type
+):
+    assert getattr(klass(**{attr: given_type}), attr) == expected_type
+
+
 class ModelTest(TestBase):
     def setUp(self):
         super(ModelTest, self).setUp()