Skip to content

Commit 197b746

Browse files
committed
mypy complete, added several future refactoring notes
1 parent d7da2be commit 197b746

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+1028
-907
lines changed

docs/NAMING_AND_TYPING.md

Lines changed: 166 additions & 163 deletions
Large diffs are not rendered by default.

fitbit_client/auth/callback_handler.py

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,40 +2,65 @@
22

33
# Standard library imports
44
from http.server import BaseHTTPRequestHandler
5+
from http.server import HTTPServer
6+
from logging import Logger
57
from logging import getLogger
8+
from socket import socket
9+
from typing import Any # Used only for type declarations, not in runtime code
10+
from typing import Callable
611
from typing import Dict
12+
from typing import List
13+
from typing import Tuple
14+
from typing import Type
15+
from typing import TypeVar
16+
from typing import Union
717
from urllib.parse import parse_qs
818
from urllib.parse import urlparse
919

1020
# Local imports
1121
from fitbit_client.exceptions import InvalidGrantException
1222
from fitbit_client.exceptions import InvalidRequestException
23+
from fitbit_client.utils.types import JSONDict
24+
25+
# Type variable for server
26+
T = TypeVar("T", bound=HTTPServer)
1327

1428

1529
class CallbackHandler(BaseHTTPRequestHandler):
1630
"""Handle OAuth2 callback requests"""
1731

18-
def __init__(self, *args, **kwargs) -> None:
32+
logger: Logger
33+
34+
def __init__(self, *args: Any, **kwargs: Any) -> None:
35+
"""Initialize the callback handler.
36+
37+
The signature matches BaseHTTPRequestHandler's __init__ method:
38+
__init__(self, request: Union[socket, Tuple[bytes, socket]],
39+
client_address: Tuple[str, int],
40+
server: HTTPServer)
41+
42+
But we use *args, **kwargs to avoid type compatibility issues with the parent class.
43+
"""
1944
self.logger = getLogger("fitbit_client.callback_handler")
2045
super().__init__(*args, **kwargs)
2146

2247
def parse_query_parameters(self) -> Dict[str, str]:
2348
"""Parse and validate query parameters from callback URL
2449
2550
Returns:
26-
Dictionary of parsed parameters
51+
Dictionary of parsed parameters with single values
2752
2853
Raises:
2954
InvalidRequestException: If required parameters are missing
3055
InvalidGrantException: If authorization code is invalid/expired
3156
"""
32-
query_components = parse_qs(urlparse(self.path).query)
57+
query_components: Dict[str, List[str]] = parse_qs(urlparse(self.path).query)
3358
self.logger.debug(f"Query parameters: {query_components}")
3459

3560
# Check for error response
3661
if "error" in query_components:
37-
error_type = query_components["error"][0]
38-
error_desc = query_components.get("error_description", ["Unknown error"])[0]
62+
error_type: str = query_components["error"][0]
63+
error_desc: str = query_components.get("error_description", ["Unknown error"])[0]
3964

4065
if error_type == "invalid_grant":
4166
raise InvalidGrantException(
@@ -47,8 +72,10 @@ def parse_query_parameters(self) -> Dict[str, str]:
4772
)
4873

4974
# Check for required parameters
50-
required_params = ["code", "state"]
51-
missing_params = [param for param in required_params if param not in query_components]
75+
required_params: List[str] = ["code", "state"]
76+
missing_params: List[str] = [
77+
param for param in required_params if param not in query_components
78+
]
5279
if missing_params:
5380
raise InvalidRequestException(
5481
message=f"Missing required parameters: {', '.join(missing_params)}",
@@ -57,6 +84,7 @@ def parse_query_parameters(self) -> Dict[str, str]:
5784
field_name="callback_params",
5885
)
5986

87+
# Convert from Dict[str, List[str]] to Dict[str, str] by taking first value of each
6088
return {k: v[0] for k, v in query_components.items()}
6189

6290
def send_success_response(self) -> None:
@@ -65,7 +93,7 @@ def send_success_response(self) -> None:
6593
self.send_header("Content-Type", "text/html")
6694
self.end_headers()
6795

68-
response = """
96+
response: str = """
6997
<html>
7098
<body>
7199
<h1>Authentication Successful!</h1>
@@ -84,7 +112,7 @@ def send_error_response(self, error_message: str) -> None:
84112
self.send_header("Content-Type", "text/html")
85113
self.end_headers()
86114

87-
response = f"""
115+
response: str = f"""
88116
<html>
89117
<body>
90118
<h1>Authentication Error</h1>
@@ -126,6 +154,11 @@ def do_GET(self) -> None:
126154
# Re-raise for server to handle
127155
raise
128156

129-
def log_message(self, format: str, *args: str) -> None:
130-
"""Override default logging to use our logger instead"""
131-
self.logger.debug(f"Server log: {format%args}")
157+
def log_message(self, format_str: str, *args: Union[str, int, float]) -> None:
158+
"""Override default logging to use our logger instead
159+
160+
Args:
161+
format_str: Format string for the log message
162+
args: Values to be formatted into the string
163+
"""
164+
self.logger.debug(f"Server log: {format_str % args}")

fitbit_client/auth/callback_server.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414
from threading import Thread
1515
from time import sleep
1616
from time import time
17+
from typing import Any
1718
from typing import IO
1819
from typing import Optional
20+
from typing import Tuple
1921
from urllib.parse import urlparse
2022

2123
# Third party imports
@@ -71,6 +73,21 @@ def __init__(self, redirect_uri: str) -> None:
7173
self.cert_file: Optional[IO[bytes]] = None
7274
self.key_file: Optional[IO[bytes]] = None
7375

76+
def create_handler(
77+
self, request: Any, client_address: Tuple[str, int], server: HTTPServer
78+
) -> CallbackHandler:
79+
"""Factory function to create CallbackHandler instances.
80+
81+
Args:
82+
request: The request from the client
83+
client_address: The client's address
84+
server: The HTTPServer instance
85+
86+
Returns:
87+
A new CallbackHandler instance
88+
"""
89+
return CallbackHandler(request, client_address, server)
90+
7491
def start(self) -> None:
7592
"""
7693
Start callback server in background thread
@@ -81,7 +98,8 @@ def start(self) -> None:
8198
self.logger.debug(f"Starting HTTPS server on {self.host}:{self.port}")
8299

83100
try:
84-
self.server = HTTPServer((self.host, self.port), CallbackHandler)
101+
# Use the factory function instead of directly passing CallbackHandler class
102+
self.server = HTTPServer((self.host, self.port), self.create_handler)
85103

86104
# Create SSL context and certificate
87105
self.logger.debug("Creating SSL context and certificate")

fitbit_client/auth/oauth.py

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@
99
from os import environ
1010
from os.path import exists
1111
from secrets import token_urlsafe
12-
from typing import Any
13-
from typing import Dict
1412
from typing import List
1513
from typing import Optional
1614
from typing import Tuple
@@ -28,6 +26,7 @@
2826
from fitbit_client.exceptions import InvalidGrantException
2927
from fitbit_client.exceptions import InvalidRequestException
3028
from fitbit_client.exceptions import InvalidTokenException
29+
from fitbit_client.utils.types import TokenDict
3130

3231

3332
class FitbitOAuth2:
@@ -110,12 +109,14 @@ def _generate_code_challenge(self) -> str:
110109
challenge = sha256(self.code_verifier.encode("utf-8")).digest()
111110
return urlsafe_b64encode(challenge).decode("utf-8").rstrip("=")
112111

113-
def _load_token(self) -> Optional[Dict[str, Any]]:
112+
def _load_token(self) -> Optional[TokenDict]:
114113
"""Load token from file if it exists and is valid"""
115114
try:
116115
if exists(self.token_cache_path):
117116
with open(self.token_cache_path, "r") as f:
118-
token = json.load(f)
117+
token_data = json.load(f)
118+
# Convert the loaded data to our TokenDict type
119+
token: TokenDict = token_data
119120

120121
expires_at = token.get("expires_at", 0)
121122
if expires_at > datetime.now().timestamp() + 300: # 5 min buffer
@@ -131,7 +132,7 @@ def _load_token(self) -> Optional[Dict[str, Any]]:
131132
return None
132133
return None
133134

134-
def _save_token(self, token: Dict[str, Any]) -> None:
135+
def _save_token(self, token: TokenDict) -> None:
135136
"""Save token to file"""
136137
with open(self.token_cache_path, "w") as f:
137138
json.dump(token, f)
@@ -181,25 +182,29 @@ def is_authenticated(self) -> bool:
181182
if not self.token:
182183
return False
183184
expires_at = self.token.get("expires_at", 0)
184-
return expires_at > datetime.now().timestamp()
185+
return bool(expires_at > datetime.now().timestamp())
185186

186187
def get_authorization_url(self) -> Tuple[str, str]:
187188
"""Get the Fitbit authorization URL"""
188-
return self.session.authorization_url(
189+
auth_url_tuple = self.session.authorization_url(
189190
self.AUTH_URL, code_challenge=self.code_challenge, code_challenge_method="S256"
190191
)
192+
return (str(auth_url_tuple[0]), str(auth_url_tuple[1]))
191193

192-
def fetch_token(self, authorization_response: str) -> Dict[str, Any]:
194+
def fetch_token(self, authorization_response: str) -> TokenDict:
193195
"""Exchange authorization code for access token"""
194196
try:
195197
auth = HTTPBasicAuth(self.client_id, self.client_secret)
196-
return self.session.fetch_token(
198+
token_data = self.session.fetch_token(
197199
self.TOKEN_URL,
198200
authorization_response=authorization_response,
199201
code_verifier=self.code_verifier,
200202
auth=auth,
201203
include_client_id=True,
202204
)
205+
# Convert to our typed dictionary
206+
token: TokenDict = token_data
207+
return token
203208
except Exception as e:
204209
error_msg = str(e).lower()
205210

@@ -226,7 +231,7 @@ def fetch_token(self, authorization_response: str) -> Dict[str, Any]:
226231
self.logger.error(f"OAuthException: {e.__class__.__name__}: {str(e)}")
227232
raise
228233

229-
def refresh_token(self, refresh_token: str) -> Dict[str, Any]:
234+
def refresh_token(self, refresh_token: str) -> TokenDict:
230235
"""Refresh the access token"""
231236
try:
232237
auth = HTTPBasicAuth(self.client_id, self.client_secret)
@@ -236,7 +241,9 @@ def refresh_token(self, refresh_token: str) -> Dict[str, Any]:
236241
"grant_type": "refresh_token",
237242
}
238243

239-
token = self.session.refresh_token(self.TOKEN_URL, auth=auth, **extra)
244+
token_data = self.session.refresh_token(self.TOKEN_URL, auth=auth, **extra)
245+
# Convert to our typed dictionary
246+
token: TokenDict = token_data
240247
self._save_token(token)
241248
return token
242249
except Exception as e:

fitbit_client/exceptions.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,20 @@ class ValidationException(RequestException):
122122
class ClientValidationException(FitbitAPIException):
123123
"""Superclass for validations that take place before making a request"""
124124

125-
pass
125+
def __init__(
126+
self,
127+
message: str,
128+
error_type: str = "client_validation",
129+
field_name: Optional[str] = None,
130+
raw_response: Optional[Dict[str, Any]] = None,
131+
):
132+
super().__init__(
133+
message=message,
134+
error_type=error_type,
135+
status_code=None,
136+
raw_response=raw_response,
137+
field_name=field_name,
138+
)
126139

127140

128141
class InvalidDateException(ClientValidationException):
@@ -133,7 +146,7 @@ def __init__(
133146
):
134147
super().__init__(
135148
message=message or f"Invalid date format. Expected YYYY-MM-DD, got: {date_str}",
136-
error_type="client_validation",
149+
error_type="invalid_date",
137150
field_name=field_name,
138151
)
139152
self.date_str = date_str
@@ -153,12 +166,7 @@ def __init__(
153166
# Use the provided reason directly - don't override it
154167
message = f"Invalid date range: {reason}"
155168

156-
super().__init__(
157-
message=message,
158-
status_code=400,
159-
error_type="client_validation",
160-
field_name="date_range",
161-
)
169+
super().__init__(message=message, error_type="invalid_date_range", field_name="date_range")
162170
self.start_date = start_date
163171
self.end_date = end_date
164172
self.max_days = max_days
@@ -202,7 +210,7 @@ def __init__(
202210
if resource_name:
203211
error_msg = f"{error_msg} for {resource_name}"
204212

205-
super().__init__(message=error_msg, field_name=field_name, error_type="client_validation")
213+
super().__init__(message=error_msg, field_name=field_name, error_type="intraday_validation")
206214
self.allowed_values = allowed_values
207215
self.resource_name = resource_name
208216

fitbit_client/resources/active_zone_minutes.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
# Standard library imports
44
from typing import Any
55
from typing import Dict
6+
from typing import cast
67

78
# Local imports
89
from fitbit_client.resources.base import BaseResource
910
from fitbit_client.resources.constants import Period
1011
from fitbit_client.utils.date_validation import validate_date_param
1112
from fitbit_client.utils.date_validation import validate_date_range_params
13+
from fitbit_client.utils.types import JSONDict
1214

1315

1416
class ActiveZoneMinutesResource(BaseResource):
@@ -28,7 +30,7 @@ class ActiveZoneMinutesResource(BaseResource):
2830
@validate_date_param()
2931
def get_azm_timeseries_by_date(
3032
self, date: str, period: Period = Period.ONE_DAY, user_id: str = "-", debug: bool = False
31-
) -> Dict[str, Any]:
33+
) -> JSONDict:
3234
"""
3335
Get Active Zone Minutes time series data for a period starting from the specified date.
3436
@@ -54,16 +56,17 @@ def get_azm_timeseries_by_date(
5456
if period != Period.ONE_DAY:
5557
raise ValueError("Only 1d period is supported for AZM time series")
5658

57-
return self._make_request(
59+
result = self._make_request(
5860
f"activities/active-zone-minutes/date/{date}/{period.value}.json",
5961
user_id=user_id,
6062
debug=debug,
6163
)
64+
return cast(JSONDict, result)
6265

6366
@validate_date_range_params(max_days=1095, resource_name="AZM time series")
6467
def get_azm_timeseries_by_interval(
6568
self, start_date: str, end_date: str, user_id: str = "-", debug: bool = False
66-
) -> Dict[str, Any]:
69+
) -> JSONDict:
6770
"""
6871
Get Active Zone Minutes time series data for a specified date range.
6972
@@ -89,8 +92,9 @@ def get_azm_timeseries_by_interval(
8992
Note:
9093
Maximum date range is 1095 days (approximately 3 years)
9194
"""
92-
return self._make_request(
95+
result = self._make_request(
9396
f"activities/active-zone-minutes/date/{start_date}/{end_date}.json",
9497
user_id=user_id,
9598
debug=debug,
9699
)
100+
return cast(JSONDict, result)

0 commit comments

Comments
 (0)