Repository: pytoyoda/pytoyoda
Pull request #39: Refactor controller module
Author: login: CM000n
Labels: name: :wrench: enhancement
Created: 2025-03-12T20:44:12Z | Merged: 2025-03-13T12:11:02Z

Here are the most important changes I made in the refactored code:

**Better data structure for token information:**

- Introduction of a `TokenInfo` data class to encapsulate the token data
- Replacement of the global `_TOKEN_CACHE` with a class variable
- Getter properties for token attributes for a cleaner interface

**Improved method splitting:**

- Splitting the `_authenticate()` method into smaller, more specific methods
- Clear separation between authentication, authorization and token retrieval
- Introduction of a context manager method for the HTTP client

**More robust error handling:**

- Check for missing fields in the token responses
- Clearer error messages
- Better logging

**Cleaner code organization:**

- Improved docstrings with descriptions of parameters and return values
- Consistent formatting
- More sensible method order

Helper classes and methods:

- New `_prepare_headers()` method to reduce code duplication
- Validation of HTTP methods at the beginning


These changes should make the code more maintainable, readable and robust while retaining full functionality.


Diff:
diff --git a/pytoyoda/controller.py b/pytoyoda/controller.py
index 644e577c..ce56c511 100644
--- a/pytoyoda/controller.py
+++ b/pytoyoda/controller.py
@@ -1,9 +1,11 @@
 """Toyota Connected Services Controller."""
 
 import logging
+from contextlib import asynccontextmanager
+from dataclasses import dataclass
 from datetime import datetime, timedelta
 from http import HTTPStatus
-from typing import Any, Dict, Optional
+from typing import Any, ClassVar, Dict, Optional
 from urllib import parse
 
 import hishel
@@ -26,47 +28,85 @@
 
 _LOGGER: logging.Logger = logging.getLogger(__name__)
 
-# In-memory cache as class dictionary
-# Each user name is used as a key
-_TOKEN_CACHE: Dict[str, Dict[str, Any]] = {}
 
-# TODO There is an issue if you login with the application on a phone as all the tokens change. # noqa: E501
-#      This seems to work sometimes but no others. Needs investigation.
+@dataclass
+class TokenInfo:
+    """Class to store token information."""
+
+    access_token: str
+    refresh_token: str
+    uuid: str
+    expiration: datetime
 
 
 class Controller:
-    """Controller class."""
+    """Controller class for Toyota Connected Services."""
+
+    # Class variable for token cache
+    _TOKEN_CACHE: ClassVar[Dict[str, TokenInfo]] = {}
 
     def __init__(self, username: str, password: str, timeout: int = 60) -> None:
-        """Initialise Controller class."""
+        """Initialize Controller class.
+
+        Args:
+            username: Toyota account username
+            password: Toyota account password
+            timeout: HTTP request timeout in seconds
+
+        """
         self._username: str = username
         self._password: str = password
-        self._token: Optional[str] = None
-        self._token_expiration: Optional[datetime] = None
-        self._refresh_token: Optional[str] = None
-        self._uuid: Optional[str] = None
         self._timeout = timeout
+
+        # URLs
         self._api_base_url = httpx.URL(API_BASE_URL)
         self._access_token_url = httpx.URL(ACCESS_TOKEN_URL)
         self._authenticate_url = httpx.URL(AUTHENTICATE_URL)
         self._authorize_url = httpx.URL(AUTHORIZE_URL)
 
-        if self._username in _TOKEN_CACHE:
-            cache_data = _TOKEN_CACHE[self._username]
-            self._token = cache_data["access_token"]
-            self._refresh_token = cache_data["refresh_token"]
-            self._uuid = cache_data["uuid"]
-            self._token_expiration = cache_data["expiration"]
+        # Authentication state
+        self._token_info: Optional[TokenInfo] = None
+
+        # Load from cache if available
+        if self._username in self._TOKEN_CACHE:
+            self._token_info = self._TOKEN_CACHE[self._username]
+
+    @property
+    def _token(self) -> Optional[str]:
+        """Get the current access token."""
+        return self._token_info.access_token if self._token_info else None
+
+    @property
+    def _refresh_token(self) -> Optional[str]:
+        """Get the current refresh token."""
+        return self._token_info.refresh_token if self._token_info else None
+
+    @property
+    def _uuid(self) -> Optional[str]:
+        """Get the current UUID."""
+        return self._token_info.uuid if self._token_info else None
+
+    @property
+    def _token_expiration(self) -> Optional[datetime]:
+        """Get the token expiration datetime."""
+        return self._token_info.expiration if self._token_info else None
+
+    def _is_token_valid(self) -> bool:
+        """Check if the current token is valid and not expired."""
+        if not self._token_info:
+            return False
+        return self._token_info.expiration > datetime.now()
 
     async def login(self) -> None:
-        """Perform first login."""
+        """Perform initial login if necessary."""
         if not self._is_token_valid():
             await self._update_token()
 
     async def _update_token(self) -> None:
-        """Login to toyota servers.
+        """Update the authentication token.
 
-        Retrieve token and UUID for the account.
+        First tries to refresh the token if available, falls back to
+        full authentication.
 
         """
         if not self._is_token_valid():
@@ -75,94 +115,137 @@ async def _update_token(self) -> None:
                     await self._refresh_tokens()
                     return
                 except ToyotaLoginError:
-                    pass
+                    _LOGGER.debug(
+                        "Token refresh failed, falling back to full authentication"
+                    )
 
             await self._authenticate()
 
-    async def _authenticate(self):
+    @asynccontextmanager
+    async def _get_http_client(self):
+        """Context manager for HTTP client with consistent timeout."""
+        async with hishel.AsyncCacheClient(timeout=self._timeout) as client:
+            yield client
+
+    async def _authenticate(self) -> None:
         """Authenticate with username and password."""
-        _LOGGER.debug("Authenticating")
-        async with hishel.AsyncCacheClient() as client:
-            data: Dict[str, Any] = {}
-            for _ in range(10):
-                if "callbacks" in data:
-                    for cb in data["callbacks"]:
-                        if (
-                            cb["type"] == "NameCallback"
-                            and cb["output"][0]["value"] == "User Name"
-                        ):
-                            cb["input"][0]["value"] = self._username
-                        elif cb["type"] == "PasswordCallback":
-                            cb["input"][0]["value"] = self._password
-                        elif (
-                            cb["type"] == "TextOutputCallback"
-                            and cb["output"][0]["value"] == "User Not Found"
-                        ):
-                            raise ToyotaInvalidUsernameError(
-                                "Authentication Failed. User Not Found."
-                            )
-                resp = await client.post(
-                    self._authenticate_url, json=data
-                )  # , headers=standard_headers)
-                _LOGGER.debug(format_httpx_response(resp))
-
-                if resp.status_code != HTTPStatus.OK:
-                    raise ToyotaLoginError(
-                        f"Authentication Failed. {resp.status_code}, {resp.text}."
-                    )
-                data = resp.json()
-                # Wait for tokenId to be returned in response
-                if "tokenId" in data:
-                    break
-
-            if "tokenId" not in data:
-                raise ToyotaLoginError("Authentication Failed. Unknown method.")
-
-            # Authorise
-            resp = await client.get(
-                self._authorize_url,
-                headers={"cookie": f"iPlanetDirectoryPro={data['tokenId']}"},
-            )
+        _LOGGER.debug("Authenticating with username and password")
+
+        async with self._get_http_client() as client:
+            # Authentication flow
+            auth_data = await self._perform_authentication(client)
+
+            # Authorization flow
+            auth_code = await self._perform_authorization(client, auth_data["tokenId"])
+
+            # Token retrieval
+            token_data = await self._retrieve_tokens(client, auth_code)
+
+            # Update tokens
+            self._update_tokens(token_data)
+
+    async def _perform_authentication(self, client) -> Dict[str, Any]:
+        """Perform the authentication part of the login flow."""
+        data: Dict[str, Any] = {}
+
+        for _ in range(10):  # Try up to 10 times
+            if "callbacks" in data:
+                for cb in data["callbacks"]:
+                    if (
+                        cb["type"] == "NameCallback"
+                        and cb["output"][0]["value"] == "User Name"
+                    ):
+                        cb["input"][0]["value"] = self._username
+                    elif cb["type"] == "PasswordCallback":
+                        cb["input"][0]["value"] = self._password
+                    elif (
+                        cb["type"] == "TextOutputCallback"
+                        and cb["output"][0]["value"] == "User Not Found"
+                    ):
+                        raise ToyotaInvalidUsernameError(
+                            "Authentication Failed. User Not Found."
+                        )
+
+            resp = await client.post(self._authenticate_url, json=data)
             _LOGGER.debug(format_httpx_response(resp))
 
-            if resp.status_code != HTTPStatus.FOUND:
+            if resp.status_code != HTTPStatus.OK:
                 raise ToyotaLoginError(
-                    f"Authorization failed. {resp.status_code}, {resp.text}."
+                    f"Authentication Failed. {resp.status_code}, {resp.text}."
                 )
-            authentication_code = parse.parse_qs(
-                httpx.URL(resp.headers.get("location")).query.decode()
-            )["code"]
 
-            # Retrieve tokens
-            resp = await client.post(
-                self._access_token_url,
-                headers={"authorization": "basic b25lYXBwOm9uZWFwcA=="},
-                data={
-                    "client_id": "oneapp",
-                    "code": authentication_code,
-                    "redirect_uri": "com.toyota.oneapp:/oauth2Callback",
-                    "grant_type": "authorization_code",
-                    "code_verifier": "plain",
-                },
+            data = resp.json()
+
+            # Wait for tokenId to be returned in response
+            if "tokenId" in data:
+                return data
+
+        raise ToyotaLoginError(
+            "Authentication Failed. Token ID not received after multiple attempts."
+        )
+
+    async def _perform_authorization(self, client, token_id: str) -> str:
+        """Perform the authorization part of the login flow.
+
+        Args:
+            client: HTTP client
+            token_id: Token ID from authentication
+
+        Returns:
+            Authentication code
+
+        """
+        resp = await client.get(
+            self._authorize_url,
+            headers={"cookie": f"iPlanetDirectoryPro={token_id}"},
+        )
+        _LOGGER.debug(format_httpx_response(resp))
+
+        if resp.status_code != HTTPStatus.FOUND:
+            raise ToyotaLoginError(
+                f"Authorization failed. {resp.status_code}, {resp.text}."
             )
-            _LOGGER.debug(format_httpx_response(resp))
 
-            if resp.status_code != HTTPStatus.OK:
-                raise ToyotaLoginError(
-                    f"Token retrieval failed. {resp.status_code}, {resp.text}."
-                )
+        return parse.parse_qs(httpx.URL(resp.headers.get("location")).query.decode())[
+            "code"
+        ]
 
-            self._update_tokens(resp.json())
+    async def _retrieve_tokens(self, client, auth_code: str) -> Dict[str, Any]:
+        """Retrieve access and refresh tokens.
 
-    def _is_token_valid(self) -> bool:
-        """Check if token is valid."""
-        if self._token is None or self._token_expiration is None:
-            return False
+        Args:
+            client: HTTP client
+            auth_code: Authorization code
+
+        Returns:
+            Token response data
+
+        """
+        resp = await client.post(
+            self._access_token_url,
+            headers={"authorization": "basic b25lYXBwOm9uZWFwcA=="},
+            data={
+                "client_id": "oneapp",
+                "code": auth_code,
+                "redirect_uri": "com.toyota.oneapp:/oauth2Callback",
+                "grant_type": "authorization_code",
+                "code_verifier": "plain",
+            },
+        )
+        _LOGGER.debug(format_httpx_response(resp))
+
+        if resp.status_code != HTTPStatus.OK:
+            raise ToyotaLoginError(
+                f"Token retrieval failed. {resp.status_code}, {resp.text}."
+            )
 
-        return self._token_expiration > datetime.now()
+        return resp.json()
 
     async def _refresh_tokens(self) -> None:
-        async with hishel.AsyncCacheClient() as client:
+        """Refresh the access token using the refresh token."""
+        _LOGGER.debug("Refreshing tokens")
+
+        async with self._get_http_client() as client:
             resp = await client.post(
                 self._access_token_url,
                 headers={"authorization": "basic b25lYXBwOm9uZWFwcA=="},
@@ -183,37 +266,48 @@ async def _refresh_tokens(self) -> None:
 
             self._update_tokens(resp.json())
 
-    def _update_tokens(self, resp: Dict[str, Any]):
-        access_tokens: Dict[str, Any] = resp
-        if (
-            "access_token" not in access_tokens
-            or "id_token" not in access_tokens
-            or "refresh_token" not in access_tokens
-            or "expires_in" not in access_tokens
-        ):
+    def _update_tokens(self, response_data: Dict[str, Any]) -> None:
+        """Update token information from response data.
+
+        Args:
+            response_data: Token response data from API
+
+        Raises:
+            ToyotaLoginError: If required tokens are missing
+
+        """
+        # Verify all required tokens are present
+        required_fields = ["access_token", "id_token", "refresh_token", "expires_in"]
+        missing_fields = [
+            field for field in required_fields if field not in response_data
+        ]
+
+        if missing_fields:
             raise ToyotaLoginError(
-                f"Token retrieval failed. Missing Tokens. \
-                {access_tokens.status_code}, \
-                {access_tokens.text}."
+                f"Token retrieval failed. Missing fields: {', '.join(missing_fields)}"
             )
-        self._token = access_tokens["access_token"]
-        self._refresh_token = access_tokens["refresh_token"]
-        self._uuid = jwt.decode(
-            access_tokens["id_token"],
+
+        # Decode the JWT to get the UUID
+        uuid = jwt.decode(
+            response_data["id_token"],
             algorithms=["RS256"],
             options={"verify_signature": False},
             audience="oneappsdkclient",
         )["uuid"]
-        self._token_expiration = datetime.now() + timedelta(
-            seconds=access_tokens["expires_in"]
+
+        # Calculate expiration time
+        expiration = datetime.now() + timedelta(seconds=response_data["expires_in"])
+
+        # Update token info
+        self._token_info = TokenInfo(
+            access_token=response_data["access_token"],
+            refresh_token=response_data["refresh_token"],
+            uuid=uuid,
+            expiration=expiration,
         )
 
-        _TOKEN_CACHE[self._username] = {
-            "access_token": self._token,
-            "refresh_token": self._refresh_token,
-            "uuid": self._uuid,
-            "expiration": self._token_expiration,
-        }
+        # Update cache
+        self._TOKEN_CACHE[self._username] = self._token_info
 
     async def request_raw(  # noqa: PLR0913
         self,
@@ -224,71 +318,91 @@ async def request_raw(  # noqa: PLR0913
         params: Optional[Dict[str, Any]] = None,
         headers: Optional[Dict[str, Any]] = None,
     ) -> httpx.Response:
-        """Shared request method.
+        """Send a raw HTTP request to the Toyota API.
 
         Args:
-            method (str): The HTTP method to use for the request.
-            endpoint (str): The endpoint to send the request to.
-            vin (Optional[str]): The VIN (Vehicle Identification Number)
-                to include in the request. Defaults to None.
-            body (Optional[Dict[str, Any]]): The JSON body to include in
-                the request. Defaults to None.
-            params (Optional[Dict[str, Any]]): The query parameters to
-                include in the request. Defaults to None.
-            headers (Optional[Dict[str, Any]]): The headers to include in
-                the request. Defaults to None.
+            method: The HTTP method to use ("GET", "POST", "PUT", "DELETE")
+            endpoint: The API endpoint to request
+            vin: Vehicle Identification Number (optional)
+            body: Request body as dictionary (optional)
+            params: URL query parameters (optional)
+            headers: Additional HTTP headers (optional)
 
         Returns:
-            httpx.Response: The httpx response from the request.
+            The raw HTTP response
 
         Raises:
-            ToyotaApiError: Raise if a API error occurres.
+            ToyotaInternalError: If an invalid HTTP method is provided
+            ToyotaApiError: If the API returns an error response
 
         """
-        if method not in ("GET", "POST", "PUT", "DELETE"):
-            raise ToyotaInternalError("Invalid request method provided")
+        valid_methods = ("GET", "POST", "PUT", "DELETE")
+        if method not in valid_methods:
+            raise ToyotaInternalError(
+                f"Invalid request method: {method}. Must be one of {valid_methods}"
+            )
 
+        # Ensure we have a valid token
         if not self._is_token_valid():
             await self._update_token()
 
-        if headers is None:
-            headers = {}
-        headers.update(
-            {
-                "x-api-key": "tTZipv6liF74PwMfk9Ed68AQ0bISswwf3iHQdqcF",
-                "x-guid": self._uuid,
-                "guid": self._uuid,
-                "authorization": f"Bearer {self._token}",
-                "x-channel": "ONEAPP",
-                "x-brand": "T",
-                "user-agent": "okhttp/4.10.0",
-            },
-        )
-        # Add vin if passed
-        if vin is not None:
-            headers.update({"vin": vin})
+        # Prepare headers
+        request_headers = self._prepare_headers(vin, headers)
 
+        # Make the request
         async with httpx.AsyncClient(timeout=self._timeout) as client:
             response = await client.request(
                 method,
                 f"{self._api_base_url}{endpoint}",
-                headers=headers,
+                headers=request_headers,
                 json=body,
                 params=params,
                 follow_redirects=True,
             )
             _LOGGER.debug(format_httpx_response(response))
 
-            if response.status_code in [
-                HTTPStatus.OK,
-                HTTPStatus.ACCEPTED,
-            ]:
+            if response.status_code in [HTTPStatus.OK, HTTPStatus.ACCEPTED]:
                 return response
 
         raise ToyotaApiError(
-            f"Request Failed.  {response.status_code}, {response.text}."
+            f"Request Failed. {response.status_code}, {response.text}."
         )
 
+    def _prepare_headers(
+        self,
+        vin: Optional[str] = None,
+        additional_headers: Optional[Dict[str, Any]] = None,
+    ) -> Dict[str, str]:
+        """Prepare headers for API requests.
+
+        Args:
+            vin: Vehicle Identification Number (optional)
+            additional_headers: Additional headers to include (optional)
+
+        Returns:
+            Complete headers dictionary
+
+        """
+        headers = {
+            "x-api-key": "tTZipv6liF74PwMfk9Ed68AQ0bISswwf3iHQdqcF",
+            "x-guid": self._uuid,
+            "guid": self._uuid,
+            "authorization": f"Bearer {self._token}",
+            "x-channel": "ONEAPP",
+            "x-brand": "T",
+            "user-agent": "okhttp/4.10.0",
+        }
+
+        # Add VIN if provided
+        if vin is not None:
+            headers["vin"] = vin
+
+        # Add additional headers
+        if additional_headers:
+            headers.update(additional_headers)
+
+        return headers
+
     async def request_json(  # noqa: PLR0913
         self,
         method: str,
@@ -297,28 +411,23 @@ async def request_json(  # noqa: PLR0913
         body: Optional[Dict[str, Any]] = None,
         params: Optional[Dict[str, Any]] = None,
         headers: Optional[Dict[str, Any]] = None,
-    ) -> str:
-        """Send a JSON request to the specified endpoint.
+    ) -> Dict[str, Any]:
+        """Send a request to the Toyota API and return JSON response.
 
         Args:
-            method (str): The HTTP method to use for the request.
-            endpoint (str): The endpoint to send the request to.
-            vin (Optional[str]): The VIN (Vehicle Identification Number)
-                to include in the request. Defaults to None.
-            body (Optional[Dict[str, Any]]): The JSON body to include in
-                the request. Defaults to None.
-            params (Optional[Dict[str, Any]]): The query parameters to
-                include in the request. Defaults to None.
-            headers (Optional[Dict[str, Any]]): The headers to include in
-                the request. Defaults to None.
+            method: The HTTP method to use ("GET", "POST", "PUT", "DELETE")
+            endpoint: The API endpoint to request
+            vin: Vehicle Identification Number (optional)
+            body: Request body as dictionary (optional)
+            params: URL query parameters (optional)
+            headers: Additional HTTP headers (optional)
 
         Returns:
-            json: The JSON response from the request.
+            The JSON response as a dictionary
 
         Examples:
-            response = await request_json("GET", "/cars", vin="1234567890")
+            response = await controller.request_json("GET", "/cars", vin="1234567890")
 
         """
         response = await self.request_raw(method, endpoint, vin, body, params, headers)
-
         return response.json()
diff --git a/tests/integration_tests/conftest.py b/tests/integration_tests/conftest.py
index f610f505..cbcf87f3 100644
--- a/tests/integration_tests/conftest.py
+++ b/tests/integration_tests/conftest.py
@@ -4,7 +4,9 @@
 
 import pytest
 
-from pytoyoda.controller import _TOKEN_CACHE
+from pytoyoda.controller import Controller
+
+_TOKEN_CACHE = Controller._TOKEN_CACHE  # pylint: disable=W0212
 
 TEST_USER = "user@email.info"
 TEST_PASSWORD = "password"
diff --git a/tests/integration_tests/test_authentication.py b/tests/integration_tests/test_authentication.py
index 8042892c..479e470c 100644
--- a/tests/integration_tests/test_authentication.py
+++ b/tests/integration_tests/test_authentication.py
@@ -10,9 +10,11 @@
 from pytest_httpx import HTTPXMock
 
 from pytoyoda import MyT
-from pytoyoda.controller import _TOKEN_CACHE
+from pytoyoda.controller import Controller, TokenInfo
 from pytoyoda.exceptions import ToyotaInvalidUsernameError, ToyotaLoginError
 
+_TOKEN_CACHE = Controller._TOKEN_CACHE  # pylint: disable=W0212
+
 
 def build_routes(httpx_mock: HTTPXMock, filenames: List[str]) -> None:  # noqa: D103
     for filename in filenames:
@@ -79,14 +81,13 @@ async def test_authenticate_invalid_password(httpx_mock: HTTPXMock):  # noqa: D1
 
 @pytest.mark.asyncio
 async def test_authenticate_refresh_token(httpx_mock: HTTPXMock):  # noqa: D103
-    _TOKEN_CACHE[TEST_USER] = {
-        "access_token": TEST_TOKEN,
-        "refresh_token": TEST_TOKEN,
-        "uuid": TEST_UUID,
-        "expiration": datetime(
-            2024, 1, 1, 16, 20, 20, 316881
-        ),  # expired expiration datetime
-    }
+    #  Create token with expired 'expiration' datetime.
+    _TOKEN_CACHE[TEST_USER] = TokenInfo(
+        access_token=TEST_TOKEN,
+        refresh_token=TEST_TOKEN,
+        uuid=TEST_UUID,
+        expiration=datetime(2024, 1, 1, 16, 20, 20, 316881),
+    )
 
     build_routes(httpx_mock, ["authenticate_refresh_token.json"])
 
@@ -99,12 +100,12 @@ async def test_authenticate_refresh_token(httpx_mock: HTTPXMock):  # noqa: D103
 @pytest.mark.asyncio
 async def test_get_static_data(httpx_mock: HTTPXMock):  # noqa: D103
     #  Create valid token => Means no authentication requests
-    _TOKEN_CACHE[TEST_USER] = {
-        "access_token": TEST_TOKEN,
-        "refresh_token": TEST_TOKEN,
-        "uuid": TEST_UUID,
-        "expiration": datetime.now() + timedelta(hours=4),  # valid expiration datetime
-    }
+    _TOKEN_CACHE[TEST_USER] = TokenInfo(
+        access_token=TEST_TOKEN,
+        refresh_token=TEST_TOKEN,
+        uuid=TEST_UUID,
+        expiration=datetime.now() + timedelta(hours=4),
+    )
 
     # Ensure expired cache file.
     build_routes(httpx_mock, ["get_static_data.json"])


Reviews:
user: login: ajain-93
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T09:27:27Z
user: login: CM000n
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T10:27:50Z
user: login: ajain-93
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T11:38:03Z
user: login: ajain-93
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-13T11:38:47Z

Review comments:
diff_hunk: @@ -26,47 +28,85 @@
 
 _LOGGER: logging.Logger = logging.getLogger(__name__)
 
-# In-memory cache as class dictionary
-# Each user name is used as a key
-_TOKEN_CACHE: Dict[str, Dict[str, Any]] = {}
 
-# TODO There is an issue if you login with the application on a phone as all the tokens change. # noqa: E501
path: pytoyoda/controller.py
user: login: ajain-93
body: @CM000n Is still the case? Should we leave this in for future?
created_at: 2025-03-13T09:27:27Z
author_association: MEMBER
diff_hunk: @@ -26,47 +28,85 @@
 
 _LOGGER: logging.Logger = logging.getLogger(__name__)
 
-# In-memory cache as class dictionary
-# Each user name is used as a key
-_TOKEN_CACHE: Dict[str, Dict[str, Any]] = {}
 
-# TODO There is an issue if you login with the application on a phone as all the tokens change. # noqa: E501
path: pytoyoda/controller.py
user: login: CM000n
body: Possibly? But we never received any issues regarding this again. And I doubt that the comment here would have helped solve the problem in any way 😉 
created_at: 2025-03-13T10:27:50Z
author_association: MEMBER
diff_hunk: @@ -26,47 +28,85 @@
 
 _LOGGER: logging.Logger = logging.getLogger(__name__)
 
-# In-memory cache as class dictionary
-# Each user name is used as a key
-_TOKEN_CACHE: Dict[str, Dict[str, Any]] = {}
 
-# TODO There is an issue if you login with the application on a phone as all the tokens change. # noqa: E501
path: pytoyoda/controller.py
user: login: ajain-93
body: Fair enough, if it shows up again, tracking should be through a issue rather than comments.
created_at: 2025-03-13T11:38:03Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-03-13T07:31:45Z
body: ## [Codecov](https://app.codecov.io/gh/pytoyoda/pytoyoda/pull/39?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pytoyoda) Report
Attention: Patch coverage is `92.39130%` with `7 lines` in your changes missing coverage. Please review.

:white_check_mark: All tests successful. No failed tests found.

| [Files with missing lines](https://app.codecov.io/gh/pytoyoda/pytoyoda/pull/39?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pytoyoda) | Patch % | Lines |
|---|---|---|
| [pytoyoda/controller.py](https://app.codecov.io/gh/pytoyoda/pytoyoda/pull/39?src=pr&el=tree&filepath=pytoyoda%2Fcontroller.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pytoyoda#diff-cHl0b3lvZGEvY29udHJvbGxlci5weQ==) | 92.39% | [7 Missing :warning: ](https://app.codecov.io/gh/pytoyoda/pytoyoda/pull/39?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pytoyoda) |

| [Flag](https://app.codecov.io/gh/pytoyoda/pytoyoda/pull/39/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pytoyoda) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/pytoyoda/pytoyoda/pull/39/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pytoyoda) | `80.96% <92.39%> (+0.19%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pytoyoda#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Files with missing lines](https://app.codecov.io/gh/pytoyoda/pytoyoda/pull/39?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pytoyoda) | Coverage Δ | |
|---|---|---|
| [pytoyoda/controller.py](https://app.codecov.io/gh/pytoyoda/pytoyoda/pull/39?src=pr&el=tree&filepath=pytoyoda%2Fcontroller.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pytoyoda#diff-cHl0b3lvZGEvY29udHJvbGxlci5weQ==) | `91.60% <92.39%> (+0.30%)` | :arrow_up: |

author_association: NONE