Repository: stravalib/stravalib (python)

Issue:
ENH: Exchange Code For Token to return Athelete summary
### Feature Type

- [ ] Adding new functionality to stravalib

- [X] Changing existing functionality in stravalib

- [ ] Removing existing functionality in stravalib


### Problem Description

When calling `client.exchange_code_for_token` the response does not include the athlete summary details
However the strava api does provide an athelete summary in the token swap
Without this we have to call get Athlete thus incurring additional requests to Strava, and reducing overall rate limits

### Feature Description

Add the Athlete dictionary in the `exchange_code_for_token` response

### Alternative Solutions

call get Athlete but this consumes an apps rate limits with the strava api

### Additional Context

_No response_

### Code of Conduct

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

Pull request description:
feat: Return optional SummaryAthlete via exchange code for token with user request
closes #552

## Description

This ensures `exchange_code_for_token` returns the optional `SummaryAthlete` object if requested.
## Type of change

Select the statement best describes this pull request.

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update
- [ ] This is a documentation update
- [ ] This is a infrastructure update (docs, ci, etc)
- [ ] Other (please describe)

## Does your PR include tests

If you are fixing a bug or adding a feature, we appreciate (but do not require)
tests to support whatever fix of feature you're implementing.

- [X] Yes
- [ ] No, i'd like some help with tests
- [ ] This change doesn't require tests

## Did you include your contribution to the change log?

- [x] Yes, `changelog.md` is up-to-date.


Patch:
diff --git a/.github/.dependabot.yml b/.github/.dependabot.yml
new file mode 100644
index 0000000..d549a23
--- /dev/null
+++ b/.github/.dependabot.yml
@@ -0,0 +1,11 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
+
+version: 2
+updates:
+  - package-ecosystem: "pip" # See documentation for possible values
+    directory: "/" # Location of package manifests
+    schedule:
+      interval: "monthly"
diff --git a/changelog.md b/changelog.md
index 84eee8f..11b2878 100644
--- a/changelog.md
+++ b/changelog.md
@@ -4,11 +4,11 @@
 
 ### Added
 
-- Add: Add more information about how our mock fixture works (@lwasser, #292)
+- Add: `SummaryAthlete` return to exchange_code_for_token (@lwasser, #552)
+- Add: Add more information about how our mock test fixture works (@lwasser, #292)
 
 ### Fixed
-
-- Fix: Update segment_efforts api and return warnings (@lwasser, #321)
+- Fix: Update `segment_efforts` api and return warnings (@lwasser, #321)
 
 ## v2.1.0
 
diff --git a/src/stravalib/client.py b/src/stravalib/client.py
index c6943d8..243b49d 100644
--- a/src/stravalib/client.py
+++ b/src/stravalib/client.py
@@ -22,6 +22,7 @@ from typing import (
     Literal,
     NoReturn,
     Protocol,
+    Tuple,
     TypeVar,
     cast,
 )
@@ -41,6 +42,7 @@ from stravalib.exc import (
     warn_param_unofficial,
     warn_param_unsupported,
 )
+from stravalib.model import SummaryAthlete
 from stravalib.protocol import AccessInfo, ApiV3, Scope
 from stravalib.util import limiter
 
@@ -175,8 +177,12 @@ class Client:
         )
 
     def exchange_code_for_token(
-        self, client_id: int, client_secret: str, code: str
-    ) -> AccessInfo:
+        self,
+        client_id: int,
+        client_secret: str,
+        code: str,
+        return_athlete: bool = False,
+    ) -> AccessInfo | Tuple[AccessInfo, SummaryAthlete | None]:
         """Exchange the temporary authorization code (returned with redirect
         from Strava authorization URL) for a short-lived access token and a
         refresh token (used to obtain the next access token later on).
@@ -189,18 +195,46 @@ class Client:
             The developer client secret
         code : str
             The temporary authorization code
+        return_athlete : bool (default = False)
+            Whether to return a SummaryAthlete object (or not)
+            This parameter is currently undocumented and could change
+            at any time.
 
         Returns
         -------
-        dict
-            Dictionary containing the access_token, refresh_token and
+        AccessInfo
+            TypedDictionary containing the access_token, refresh_token and
             expires_at (number of seconds since Epoch when the provided access
             token will expire)
+        tuple
+            Contains:
+             - the `AccessInfo` typed dict containing token values
+             - a `SummaryAthlete` object representing the authenticated user.
+
+        Notes
+        -----
+        Strava by default returns `SummaryAthlete` information during
+        this exchange. However this return is currently undocumented
+        and could change at any time.
         """
-        return self.protocol.exchange_code_for_token(
-            client_id=client_id, client_secret=client_secret, code=code
+        access_info, athlete_data = self.protocol.exchange_code_for_token(
+            client_id=client_id,
+            client_secret=client_secret,
+            code=code,
+            return_athlete=return_athlete,
         )
 
+        # Return both access_info and athlete if requested. Athlete will be None if Strava
+        # doesn't return it in their end point response.
+        if return_athlete:
+            if athlete_data:
+                summary_athlete = SummaryAthlete.model_validate(athlete_data)
+            else:
+                summary_athlete = None
+            return access_info, summary_athlete
+        else:
+            return access_info
+
     def refresh_access_token(
         self, client_id: int, client_secret: str, refresh_token: str
     ) -> AccessInfo:
diff --git a/src/stravalib/protocol.py b/src/stravalib/protocol.py
index a2901fd..2577349 100644
--- a/src/stravalib/protocol.py
+++ b/src/stravalib/protocol.py
@@ -162,8 +162,12 @@ class ApiV3(metaclass=abc.ABCMeta):
         )
 
     def exchange_code_for_token(
-        self, client_id: int, client_secret: str, code: str
-    ) -> AccessInfo:
+        self,
+        client_id: int,
+        client_secret: str,
+        code: str,
+        return_athlete: bool = False,
+    ) -> tuple[AccessInfo, dict[str, Any] | None]:
         """Exchange the temporary authorization code (returned with redirect
         from Strava authorization URL) for a short-lived access token and a
         refresh token (used to obtain the next access token later on).
@@ -176,6 +180,10 @@ class ApiV3(metaclass=abc.ABCMeta):
             The developer client secret
         code : str
             The temporary authorization code
+        return_athlete : bool (optional, default=False)
+            Whether to return the `SummaryAthlete` object with the token
+            response. This behavior is currently undocumented and could
+            change at any time. Default is `False`.
 
         Returns
         -------
@@ -184,6 +192,7 @@ class ApiV3(metaclass=abc.ABCMeta):
             expires_at (number of seconds since Epoch when the provided
             access token will expire)
         """
+        # The method returns: No rates present in response headers
         response = self._request(
             f"https://{self.server}/oauth/token",
             params={
@@ -200,7 +209,12 @@ class ApiV3(metaclass=abc.ABCMeta):
             "expires_at": response["expires_at"],
         }
         self.access_token = response["access_token"]
-        return access_info
+        if return_athlete:
+            # This will be None if Strava removes undocumented athlete response
+            # from the undocumented endpoint return
+            return access_info, response.get("athlete")
+        else:
+            return access_info, None
 
     def refresh_access_token(
         self, client_id: int, client_secret: str, refresh_token: str


Test patch:
diff --git a/src/stravalib/tests/integration/test_client.py b/src/stravalib/tests/integration/test_client.py
index a333780..274bcfc 100644
--- a/src/stravalib/tests/integration/test_client.py
+++ b/src/stravalib/tests/integration/test_client.py
@@ -3,6 +3,7 @@ import json
 import os
 import warnings
 from unittest import mock
+from unittest.mock import patch
 
 import pytest
 import responses
@@ -55,6 +56,32 @@ def default_request_params():
     return default_request_params
 
 
+@pytest.fixture
+def raw_exchange_response():
+    """Expected response from the protocol exchange_code_for_token method."""
+    return (
+        {
+            "access_token": "123456",
+            "refresh_token": "789sdf987",
+            "expires_at": 1732417459,
+        },
+        {
+            "id": 10295934,
+            "username": "foo_bar",
+            "resource_state": 2,
+            "firstname": "Foo",
+            "lastname": "Bar",
+            "bio": "A bio",
+            "city": "City",
+            "state": "State",
+            "country": "Country",
+            "sex": "F",
+            "premium": True,
+            "summit": True,
+        },
+    )
+
+
 def test_get_athlete(mock_strava_api, client):
     mock_strava_api.get("/athlete", response_update={"id": 42})
     athlete = client.get_athlete()
@@ -1084,3 +1111,60 @@ def test_get_activity_kudos(mock_strava_api, client):
     assert isinstance(kudoer_list[0], SummaryAthlete)
     assert len(kudoer_list) == 2
     assert kudoer_list[0].lastname == "Doe"
+
+
+@patch("stravalib.protocol.ApiV3.exchange_code_for_token")
+def test_exchange_code_for_token_athlete(
+    mock_protocol_exchange_code, client, raw_exchange_response
+):
+    """If a user requests athlete data, then it should be returned as a
+    SummaryAthlete object."""
+    mock_protocol_exchange_code.return_value = raw_exchange_response
+
+    access_info, summary_athlete = client.exchange_code_for_token(
+        client_id=123,
+        client_secret="secret",
+        code="temp_code",
+        return_athlete=True,
+    )
+
+    assert access_info["access_token"] == "123456"
+    assert summary_athlete.firstname == "Foo"
+
+
+@patch("stravalib.protocol.ApiV3.exchange_code_for_token")
+def test_exchange_code_for_token_missing_athlete(
+    mock_protocol_exchange_code, client, raw_exchange_response
+):
+    """If athlete=True but Strava modifies the api response and athlete return
+    is None client should only return AccessInfo object"""
+    mock_protocol_exchange_code.return_value = (raw_exchange_response[0], None)
+    access_info, athlete = client.exchange_code_for_token(
+        client_id=123,
+        client_secret="secret",
+        code="temp_code",
+        return_athlete=True,
+    )
+
+    assert athlete is None
+    assert access_info["access_token"] == "123456"
+
+
+@patch("stravalib.protocol.ApiV3.exchange_code_for_token")
+def test_exchange_code_for_token_no_athlete(
+    mock_protocol_exchange_code, client, raw_exchange_response
+):
+    """When athlete isn't True, only return AccessInfo Typed Dict."""
+    mock_protocol_exchange_code.return_value = raw_exchange_response
+
+    access_info = client.exchange_code_for_token(
+        client_id=123,
+        client_secret="secret",
+        code="temp_code",
+        return_athlete=False,
+    )
+
+    assert (
+        access_info["access_token"] == raw_exchange_response[0]["access_token"]
+    )
+    assert isinstance(access_info, dict)
diff --git a/src/stravalib/tests/unit/test_protocol_auth.py b/src/stravalib/tests/unit/test_protocol_auth.py
new file mode 100644
index 0000000..06b72ad
--- /dev/null
+++ b/src/stravalib/tests/unit/test_protocol_auth.py
@@ -0,0 +1,69 @@
+from unittest.mock import patch
+
+import pytest
+
+from stravalib.protocol import ApiV3
+
+
+@pytest.fixture
+def mock_token_exchange_response():
+    """A fixture that mocks a token exchange response that includes
+    the athlete info"""
+    return {
+        "access_token": "mock_access_token",
+        "refresh_token": "mock_refresh_token",
+        "expires_at": 1234567890,
+        "athlete": {
+            "id": 12345,
+            "username": "mock_athlete",
+            "firstname": "Mock",
+            "lastname": "Athlete",
+        },
+    }
+
+
+@pytest.fixture
+def mock_token_refresh_response():
+    """A fixture that mocks the response for a refreshed token."""
+    return {
+        "access_token": "new_mock_access_token",
+        "refresh_token": "new_mock_refresh_token",
+        "expires_at": 9876543210,
+    }
+
+
+@patch("stravalib.protocol.ApiV3._request")
+def test_exchange_code_for_token_athlete(
+    mock_request, mock_token_exchange_response
+):
+    # Set _request to return the raw dictionary
+    api_instance = ApiV3()
+    mock_request.return_value = mock_token_exchange_response
+
+    exchange_response, athlete = api_instance.exchange_code_for_token(
+        client_id=123,
+        client_secret="secret",
+        code="auth_code",
+        return_athlete=True,
+    )
+
+    assert exchange_response["access_token"] == "mock_access_token"
+    assert athlete["id"] == 12345
+
+
+@patch("stravalib.protocol.ApiV3._request")
+def test_exchange_code_for_token_no_athlete(
+    mock_request, mock_token_exchange_response
+):
+    api_instance = ApiV3()
+    mock_request.return_value = mock_token_exchange_response
+
+    access_info, athlete_info = api_instance.exchange_code_for_token(
+        client_id=123,
+        client_secret="secret",
+        code="auth_code",
+    )
+
+    assert athlete_info is None
+    assert len(access_info) == 3
+    assert access_info["access_token"] == "mock_access_token"