Skip to content

Commit b5d7d4a

Browse files
committed
feat: enable mypy strict mode with comprehensive type improvements
Enable mypy strict mode and fix all type errors across the codebase: Type System Enhancements: - Enable strict mode in pyproject.toml - Add schema parameter to client.get() and client.post() with overloads for type-safe returns - Create type aliases in types_defs.py for YouTrack API responses - Add return type annotations to all __init__ methods (-> None) - Fix generic type parameters (FastMCP -> FastMCP[None]) Code Cleanup: - Remove unreachable code from users.py and projects.py - Simplify isinstance checks after type narrowing - Remove unused close() methods from tool classes - Remove backwards compatibility code for dict-based issue creation API Resource Improvements: - Use schema parameter in all client.get/post calls for proper typing - Return typed responses (list[IssueDict], IssueCommentDict, etc.) - Fix arg-type errors with explicit type annotations Testing: - Update test_add_comment to work with new schema parameter - All 62 tests passing - Mypy strict mode: 0 errors
1 parent 283bc8f commit b5d7d4a

14 files changed

Lines changed: 221 additions & 219 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -152,17 +152,10 @@ line-ending = "auto"
152152
# MyPy configuration
153153
[tool.mypy]
154154
python_version = "3.12"
155+
strict = true
155156
show_column_numbers = true
156157
show_error_context = false
157158
follow_imports = "normal"
158-
ignore_missing_imports = true
159-
disallow_untyped_calls = false
160-
warn_return_any = false
161-
strict_optional = true
162-
warn_redundant_casts = true
163-
warn_unused_ignores = false
164-
disallow_untyped_defs = false # Changed to false to allow gradual typing
165-
check_untyped_defs = false
166159
plugins = ["pydantic.mypy"]
167160
warn_unreachable = true
168161
mypy_path = ["src"]

src/youtrack_rocket_mcp/api/client.py

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,19 @@
66
import logging
77
import random
88
from types import TracebackType
9-
from typing import TYPE_CHECKING, Any
9+
from typing import TYPE_CHECKING, Any, TypeVar, cast, overload
10+
11+
import httpx
12+
from pydantic import BaseModel, ConfigDict
1013

1114
from youtrack_rocket_mcp.api.types import JSONDict, JSONValue, QueryParams
15+
from youtrack_rocket_mcp.config import config
16+
17+
T = TypeVar('T')
1218

1319
if TYPE_CHECKING:
1420
pass
1521

16-
import httpx
17-
from pydantic import BaseModel, ConfigDict
18-
19-
from youtrack_rocket_mcp.config import config
2022

2123
logger = logging.getLogger(__name__)
2224

@@ -204,33 +206,57 @@ async def _make_request(
204206
# Should never reach here
205207
raise YouTrackAPIError(f'Request failed after {self.max_retries} attempts')
206208

207-
async def get(self, endpoint: str, params: QueryParams | None = None, **kwargs: Any) -> JSONValue:
209+
@overload
210+
async def get(self, endpoint: str, params: QueryParams | None = None, *, schema: type[T], **kwargs: Any) -> T: ...
211+
212+
@overload
213+
async def get(self, endpoint: str, params: QueryParams | None = None, **kwargs: Any) -> JSONValue: ...
214+
215+
async def get(
216+
self, endpoint: str, params: QueryParams | None = None, schema: type[T] | None = None, **kwargs: Any
217+
) -> T | JSONValue:
208218
"""
209219
Make a GET request.
210220
211221
Args:
212222
endpoint: API endpoint
213223
params: Query parameters
224+
schema: Optional type for return value (for type checking only)
214225
**kwargs: Additional request arguments
215226
216227
Returns:
217228
Parsed JSON response
218229
"""
219-
return await self._make_request('GET', endpoint, params=params, **kwargs)
230+
result = await self._make_request('GET', endpoint, params=params, **kwargs)
231+
if schema is not None:
232+
return cast(T, result)
233+
return result
234+
235+
@overload
236+
async def post(self, endpoint: str, data: JSONDict | None = None, *, schema: type[T], **kwargs: Any) -> T: ...
237+
238+
@overload
239+
async def post(self, endpoint: str, data: JSONDict | None = None, **kwargs: Any) -> JSONValue: ...
220240

221-
async def post(self, endpoint: str, data: JSONDict | None = None, **kwargs: Any) -> JSONValue:
241+
async def post(
242+
self, endpoint: str, data: JSONDict | None = None, schema: type[T] | None = None, **kwargs: Any
243+
) -> T | JSONValue:
222244
"""
223245
Make a POST request.
224246
225247
Args:
226248
endpoint: API endpoint
227249
data: Request body data
250+
schema: Optional type for return value (for type checking only)
228251
**kwargs: Additional request arguments
229252
230253
Returns:
231254
Parsed JSON response
232255
"""
233-
return await self._make_request('POST', endpoint, data=data, **kwargs)
256+
result = await self._make_request('POST', endpoint, data=data, **kwargs)
257+
if schema is not None:
258+
return cast(T, result)
259+
return result
234260

235261
async def put(self, endpoint: str, data: JSONDict | None = None, **kwargs: Any) -> JSONValue:
236262
"""

src/youtrack_rocket_mcp/api/resources/issues.py

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
get_field_types_from_project,
1616
)
1717
from youtrack_rocket_mcp.api.resources.projects import ProjectsClient
18+
from youtrack_rocket_mcp.api.schemas import IssueCommentDict
1819
from youtrack_rocket_mcp.api.types import CustomFieldData, FieldTypes, FieldValue, JSONDict
1920
from youtrack_rocket_mcp.utils.period_parser import parse_period_to_minutes
2021

@@ -142,17 +143,17 @@ def _format_custom_field(field_key: str, field_value: FieldValue, field_type: st
142143
else:
143144
field_entry['value'] = field_value
144145
elif field_type == 'SingleUserIssueCustomField':
145-
field_entry['value'] = {'login': field_value} if isinstance(field_value, str) else field_value # type: ignore[assignment]
146+
field_entry['value'] = {'login': field_value} if isinstance(field_value, str) else field_value
146147
elif field_type in ['SingleEnumIssueCustomField', 'StateIssueCustomField']:
147-
field_entry['value'] = {'name': field_value} if isinstance(field_value, str) else field_value # type: ignore[assignment]
148+
field_entry['value'] = {'name': field_value} if isinstance(field_value, str) else field_value
148149
elif field_type in [
149150
'DateIssueCustomField',
150151
'SimpleIssueCustomField',
151152
'TextIssueCustomField',
152153
] or field_type.startswith('Multi'):
153154
field_entry['value'] = field_value
154155
else:
155-
field_entry['value'] = {'name': field_value} if isinstance(field_value, str) else field_value # type: ignore[assignment]
156+
field_entry['value'] = {'name': field_value} if isinstance(field_value, str) else field_value
156157

157158
return field_entry
158159

@@ -274,12 +275,14 @@ async def create_issue(
274275
issue_id = result['error_issue_id']
275276
logger.info(f'Issue created (draft/incomplete): {issue_id}')
276277
# Return an Issue object preserving input data
277-
return Issue(
278-
id=issue_id,
279-
summary=summary,
280-
description=description,
281-
project={'id': project_id},
282-
custom_fields=[],
278+
return Issue.model_validate(
279+
{
280+
'id': issue_id,
281+
'summary': summary,
282+
'description': description,
283+
'project': {'id': project_id},
284+
'custom_fields': [],
285+
},
283286
)
284287

285288
# Normal successful response
@@ -290,17 +293,23 @@ async def create_issue(
290293
try:
291294
return Issue.model_validate(result)
292295
except (ValueError, TypeError, KeyError):
293-
return Issue(
294-
id=issue_id,
295-
summary=summary or result.get('summary', ''),
296-
description=description or result.get('description'),
297-
project={'id': project_id},
296+
return Issue.model_validate(
297+
{
298+
'id': issue_id,
299+
'summary': summary or result.get('summary', ''),
300+
'description': description or result.get('description'),
301+
'project': {'id': project_id},
302+
}
298303
)
299304
except (ValueError, TypeError, KeyError, AttributeError):
300305
logger.exception('Error parsing response')
301306
# Still return something if we have a response
302-
return Issue(
303-
id=f'response-{response.status_code}', summary=summary or 'Created', project={'id': project_id}
307+
return Issue.model_validate(
308+
{
309+
'id': f'response-{response.status_code}',
310+
'summary': summary or 'Created',
311+
'project': {'id': project_id},
312+
}
304313
)
305314

306315
except (httpx.HTTPError, ValueError) as e:
@@ -419,7 +428,7 @@ async def get_issue_comments(self, issue_id: str) -> list[JSONDict]:
419428
return issue_response['comments']
420429
return []
421430

422-
async def add_comment(self, issue_id: str, text: str) -> JSONDict:
431+
async def add_comment(self, issue_id: str, text: str) -> IssueCommentDict:
423432
"""
424433
Add a comment to an issue.
425434
@@ -431,4 +440,4 @@ async def add_comment(self, issue_id: str, text: str) -> JSONDict:
431440
The created comment data
432441
"""
433442
data = {'text': text}
434-
return await self.client.post(f'issues/{issue_id}/comments', data=data)
443+
return await self.client.post(f'issues/{issue_id}/comments', data=data, schema=IssueCommentDict)

src/youtrack_rocket_mcp/api/resources/projects.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from pydantic import BaseModel, Field
99

1010
from youtrack_rocket_mcp.api.client import YouTrackClient
11+
from youtrack_rocket_mcp.api.schemas import IssueDict, ProjectCustomFieldDict
1112
from youtrack_rocket_mcp.api.types import JSONDict, JSONList
1213

1314
logger = logging.getLogger(__name__)
@@ -111,7 +112,7 @@ async def get_project_by_name(self, project_name: str) -> Project | None:
111112

112113
return None
113114

114-
async def get_project_issues(self, project_id: str, limit: int = 10) -> JSONList:
115+
async def get_project_issues(self, project_id: str, limit: int = 10) -> list[IssueDict]:
115116
"""
116117
Get issues for a specific project.
117118
@@ -143,7 +144,7 @@ async def get_project_issues(self, project_id: str, limit: int = 10) -> JSONList
143144
params = {'$filter': f'project/id eq "{project_id}"', '$top': limit, 'fields': fields}
144145

145146
try:
146-
issues = await self.client.get('issues', params=params)
147+
issues = await self.client.get('issues', params=params, schema=list[IssueDict])
147148
logger.info(f'Retrieved {len(issues) if isinstance(issues, list) else 0} issues')
148149
except (ValueError, KeyError, TypeError):
149150
logger.exception(f'Error getting issues for project {project_id}')
@@ -315,7 +316,7 @@ async def delete_project(self, project_id: str) -> None:
315316
"""
316317
await self.client.delete(f'admin/projects/{project_id}')
317318

318-
async def get_custom_fields(self, project_id: str) -> JSONList:
319+
async def get_custom_fields(self, project_id: str) -> list[ProjectCustomFieldDict]:
319320
"""
320321
Get custom fields for a project with detailed information.
321322
@@ -336,7 +337,9 @@ async def get_custom_fields(self, project_id: str) -> JSONList:
336337
params = {'fields': fields}
337338

338339
try:
339-
result = await self.client.get(f'admin/projects/{project_id}/customFields', params=params)
340+
result = await self.client.get(
341+
f'admin/projects/{project_id}/customFields', params=params, schema=list[ProjectCustomFieldDict]
342+
)
340343
field_count = len(result) if isinstance(result, list) else 0
341344
logger.info(f'Successfully retrieved {field_count} custom fields for project {project_id}')
342345

@@ -376,7 +379,9 @@ async def get_custom_fields(self, project_id: str) -> JSONList:
376379
logger.exception(f'Error getting detailed custom fields for project {project_id}')
377380
# Fallback to basic request without fields parameter
378381
try:
379-
basic_result = await self.client.get(f'admin/projects/{project_id}/customFields')
382+
basic_result = await self.client.get(
383+
f'admin/projects/{project_id}/customFields', schema=list[ProjectCustomFieldDict]
384+
)
380385
logger.warning(f'Falling back to basic custom fields request for project {project_id}')
381386
except (ValueError, KeyError, TypeError):
382387
logger.exception('Fallback request also failed')
@@ -386,7 +391,9 @@ async def get_custom_fields(self, project_id: str) -> JSONList:
386391
else:
387392
return enhanced_fields
388393

389-
async def add_custom_field(self, project_id: str, field_id: str, empty_field_text: str | None = None) -> JSONDict:
394+
async def add_custom_field(
395+
self, project_id: str, field_id: str, empty_field_text: str | None = None
396+
) -> ProjectCustomFieldDict:
390397
"""
391398
Add a custom field to a project.
392399
@@ -403,7 +410,9 @@ async def add_custom_field(self, project_id: str, field_id: str, empty_field_tex
403410
if empty_field_text:
404411
data['emptyFieldText'] = empty_field_text # type: ignore[assignment]
405412

406-
return await self.client.post(f'admin/projects/{project_id}/customFields', data=data)
413+
return await self.client.post(
414+
f'admin/projects/{project_id}/customFields', data=data, schema=ProjectCustomFieldDict
415+
)
407416

408417
async def get_project_fields_from_issues(self, project_id: str) -> JSONDict:
409418
"""

src/youtrack_rocket_mcp/api/resources/search.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@
22
YouTrack Search API client with advanced search capabilities.
33
"""
44

5+
from typing import Any
6+
57
from youtrack_rocket_mcp.api.client import YouTrackClient
6-
from youtrack_rocket_mcp.api.types import CustomFieldData, JSONList
8+
from youtrack_rocket_mcp.api.schemas import CustomFieldSettingDict, IssueDict
9+
from youtrack_rocket_mcp.api.types import CustomFieldData
710

811

912
class SearchClient:
1013
"""Client for advanced search operations in YouTrack."""
1114

1215
@staticmethod
13-
def format_custom_fields(custom_fields: list) -> dict[str, str | None]:
16+
def format_custom_fields(custom_fields: list[Any]) -> dict[str, str | None]:
1417
"""
1518
Format custom fields from array to dictionary {name: value}.
1619
@@ -74,7 +77,7 @@ async def search_issues(
7477
sort_by: str | None = None,
7578
sort_order: str | None = None,
7679
custom_fields: list[str] | None = None,
77-
) -> JSONList:
80+
) -> list[IssueDict]:
7881
"""
7982
Search for issues with advanced query capabilities.
8083
@@ -130,7 +133,7 @@ async def search_issues(
130133
params['$sortOrder'] = sort_order.lower()
131134

132135
# Make the API request
133-
issues = await self.client.get('issues', params=params)
136+
issues = await self.client.get('issues', params=params, schema=list[IssueDict])
134137

135138
# Format custom fields for each issue
136139
for issue in issues:
@@ -143,7 +146,7 @@ async def search_issues(
143146

144147
async def search_with_custom_field_values(
145148
self, query: str, custom_field_values: CustomFieldData, limit: int = 10
146-
) -> JSONList:
149+
) -> list[IssueDict]:
147150
"""
148151
Search for issues with specific custom field values.
149152
@@ -194,7 +197,7 @@ async def search_with_filter(
194197
updated_before: str | None = None,
195198
custom_fields: CustomFieldData | None = None,
196199
limit: int = 10,
197-
) -> JSONList:
200+
) -> list[IssueDict]:
198201
"""
199202
Search for issues using a structured filter approach.
200203
@@ -260,7 +263,7 @@ async def search_with_filter(
260263
return await self.search_with_custom_field_values(base_query, custom_fields, limit)
261264
return await self.search_issues(base_query, limit=limit)
262265

263-
async def get_available_custom_fields(self, project_id: str | None = None) -> JSONList:
266+
async def get_available_custom_fields(self, project_id: str | None = None) -> list[CustomFieldSettingDict]:
264267
"""
265268
Get all available custom fields, optionally for a specific project.
266269
@@ -280,4 +283,4 @@ async def get_available_custom_fields(self, project_id: str | None = None) -> JS
280283
fields = 'id,name,localizedName,fieldType(id,name),isPrivate,isPublic,aliases'
281284
params = {'fields': fields}
282285

283-
return await self.client.get(endpoint, params=params)
286+
return await self.client.get(endpoint, params=params, schema=list[CustomFieldSettingDict])

src/youtrack_rocket_mcp/api/resources/users.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from pydantic import BaseModel
88

99
from youtrack_rocket_mcp.api.client import YouTrackClient
10-
from youtrack_rocket_mcp.api.types import JSONList
10+
from youtrack_rocket_mcp.api.schemas import UserGroupDict
1111

1212
logger = logging.getLogger(__name__)
1313

@@ -114,7 +114,7 @@ async def get_user_by_login(self, login: str) -> User | None:
114114

115115
return None
116116

117-
async def get_user_groups(self, user_id: str) -> JSONList:
117+
async def get_user_groups(self, user_id: str) -> list[UserGroupDict]:
118118
"""
119119
Get groups for a user.
120120
@@ -124,7 +124,7 @@ async def get_user_groups(self, user_id: str) -> JSONList:
124124
Returns:
125125
List of group data
126126
"""
127-
return await self.client.get(f'users/{user_id}/groups')
127+
return await self.client.get(f'users/{user_id}/groups', schema=list[UserGroupDict])
128128

129129
async def check_user_permissions(self, user_id: str, permission: str) -> bool:
130130
"""

0 commit comments

Comments
 (0)