Skip to content

Commit 934ad1c

Browse files
authored
refactor: improve ruff linter config and fix violations (homeassistant-ai#624)
* refactor: improve ruff linter config and fix violations Expand ruff rule sets (add PIE, PERF, ASYNC, A, LOG, SIM, RUF) and fix violations surfaced by the new rules: - B904: add `from e` to raises inside except blocks - E722: replace bare `except:` with `except Exception:` - B007: prefix unused loop variables with `_` - PERF401: convert manual list-append loops to comprehensions/extend - SIM103: simplify needless if/return-bool to direct return - SIM117: combine nested `with` statements - SIM115: use context managers for open() - ASYNC251: replace blocking time.sleep with async equivalent - F821: add missing imports (asyncio) Also remove unused black/isort dev dependencies (ruff handles both), and exclude vendored tests/initial_test_state from linting. * chore: add gitignore entries for WSL artifacts and stale git fragments
1 parent 732449e commit 934ad1c

68 files changed

Lines changed: 400 additions & 460 deletions

File tree

Some content is hidden

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

custom_components/ha_mcp_tools/__init__.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,12 @@
1616

1717
import voluptuous as vol
1818
from homeassistant.config_entries import ConfigEntry
19-
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse
19+
from homeassistant.core import (
20+
HomeAssistant,
21+
ServiceCall,
22+
ServiceResponse,
23+
SupportsResponse,
24+
)
2025
from homeassistant.helpers import config_validation as cv
2126

2227
from .const import ALLOWED_READ_DIRS, ALLOWED_WRITE_DIRS, DOMAIN
@@ -137,12 +142,7 @@ def _is_path_allowed_for_read(config_dir: Path, rel_path: str) -> bool:
137142
return True
138143

139144
# Check for custom_components/**/*.py pattern
140-
if fnmatch.fnmatch(normalized, "custom_components/*/*.py"):
141-
return True
142-
if fnmatch.fnmatch(normalized, "custom_components/**/*.py"):
143-
return True
144-
145-
return False
145+
return fnmatch.fnmatch(normalized, "custom_components/**/*.py")
146146

147147

148148
def _mask_secrets_content(content: str) -> str:
@@ -296,7 +296,7 @@ async def handle_read_file(call: ServiceCall) -> ServiceResponse:
296296
content = await hass.async_add_executor_job(target_file.read_text)
297297

298298
# Apply special handling for specific files
299-
normalized = os.path.normpath(rel_path)
299+
normalized = os.path.normpath(rel_path) # noqa: ASYNC240
300300

301301
# Mask secrets.yaml
302302
if normalized == "secrets.yaml":

homeassistant-addon/start.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ def main() -> int:
135135
# Import and run MCP server directly
136136
try:
137137
log_info("Importing ha_mcp module...")
138-
from ha_mcp.__main__ import mcp, _get_timestamped_uvicorn_log_config
138+
from ha_mcp.__main__ import _get_timestamped_uvicorn_log_config, mcp
139139

140140
log_info("Starting MCP server...")
141141
mcp.run(

pyproject.toml

Lines changed: 32 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -54,30 +54,6 @@ packages = { find = { where = ["src", "."], include = ["ha_mcp*", "tests"] } }
5454
[tool.setuptools.package-data]
5555
ha_mcp = ["py.typed", "_pypi_marker", "resources/*.md", "resources/*.json"]
5656

57-
[tool.black]
58-
line-length = 88
59-
target-version = ['py313']
60-
include = '\.pyi?$'
61-
extend-exclude = '''
62-
/(
63-
# directories
64-
\.eggs
65-
| \.git
66-
| \.hg
67-
| \.mypy_cache
68-
| \.tox
69-
| \.venv
70-
| build
71-
| dist
72-
)/
73-
'''
74-
75-
[tool.isort]
76-
profile = "black"
77-
multi_line_output = 3
78-
line_length = 88
79-
known_first_party = ["ha_mcp"]
80-
8157
[tool.mypy]
8258
python_version = "3.11"
8359
warn_return_any = true
@@ -102,30 +78,43 @@ ignore_missing_imports = true
10278
[tool.ruff]
10379
target-version = "py313"
10480
line-length = 88
81+
extend-exclude = ["tests/initial_test_state"]
10582

10683
[tool.ruff.lint]
10784
select = [
108-
"E", # pycodestyle errors
109-
"W", # pycodestyle warnings
110-
"F", # pyflakes
111-
"I", # isort
112-
"B", # flake8-bugbear
113-
"C4", # flake8-comprehensions
114-
"UP", # pyupgrade
115-
"RUF100", # unused noqa
85+
"E", # pycodestyle errors
86+
"W", # pycodestyle warnings
87+
"F", # pyflakes
88+
"I", # isort
89+
"B", # flake8-bugbear
90+
"C4", # flake8-comprehensions
91+
"UP", # pyupgrade
92+
"RUF", # ruff-specific rules
93+
"PIE", # misc lints
94+
"PERF", # performance anti-patterns
95+
"ASYNC", # async best practices
96+
"A", # builtin shadowing
97+
"LOG", # logging best practices
98+
"SIM", # simplify
11699
]
117100
ignore = [
118-
"E501", # line too long, handled by black
119-
"B008", # do not perform function calls in argument defaults
120-
"C901", # too complex
121-
"B904", # raise exceptions with from None/err - non-critical
122-
"F841", # local variable assigned but never used
123-
"B007", # loop control variable not used within loop body
124-
"W293", # blank line contains whitespace
125-
"W291", # trailing whitespace
126-
"F821", # undefined name (often false positives in complex imports)
127-
"E722", # bare except (temporary for cleanup)
128-
"I001", # import sorting handled by isort
101+
"E501", # line too long — formatter handles this
102+
"B008", # function calls in defaults — FastMCP pattern
103+
"C901", # complexity — too many to fix now
104+
"SIM102", # collapsible-if — sometimes less readable
105+
"SIM108", # ternary — sometimes less readable
106+
"SIM105", # contextlib.suppress — style preference
107+
"ASYNC109",# timeout params are standard in HA API patterns
108+
"RUF001", # ambiguous unicode — HA entity names use degree symbols etc
109+
"RUF003", # ambiguous unicode in comments
110+
"RUF010", # explicit f-string type conversion — str(x) is clearer than !s
111+
"F841", # unused variable — too many to fix now
112+
"RUF059", # unused unpacked variable
113+
"SIM118", # in-dict-keys — style preference
114+
"PIE810", # multiple-starts-ends-with — style preference
115+
"RUF005", # collection-literal-concatenation
116+
"RUF022", # unsorted-dunder-all
117+
"RUF013", # implicit-optional
129118
]
130119

131120
[tool.ruff.lint.per-file-ignores]
@@ -161,10 +150,8 @@ asyncio_mode = "auto"
161150

162151
[dependency-groups]
163152
dev = [
164-
"black>=23.0.0",
165153
"build>=1.2.2",
166154
"docker>=7.1.0",
167-
"isort>=5.12.0",
168155
"mypy>=1.17.0",
169156
"psutil>=7.0.0",
170157
"pytest>=8.4.2",

scripts/validate_server_manifest.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111
from jsonschema import validate
1212

13-
1413
SCHEMA_URL = "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json"
1514

1615

src/ha_mcp/__main__.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def __init__(self, auth_provider):
3333
def _get_oauth_client(self):
3434
"""Get the OAuth client for the current request context."""
3535
from fastmcp.server.dependencies import get_access_token
36+
3637
from ha_mcp.client.rest_client import HomeAssistantClient
3738

3839
# Get the access token from the current request context
@@ -169,11 +170,8 @@ def _check_stdin_available() -> bool:
169170
return True
170171

171172
# Block character devices that aren't TTYs (like /dev/null in Docker without -i)
172-
if stat.S_ISCHR(mode):
173-
return False
174-
175173
# Unknown type - allow it and let the server handle any issues
176-
return True
174+
return not stat.S_ISCHR(mode)
177175

178176

179177
def _handle_config_error(error: Exception) -> None:
@@ -271,7 +269,9 @@ def _create_server():
271269
from pydantic import ValidationError
272270

273271
try:
274-
from ha_mcp.server import HomeAssistantSmartMCPServer # type: ignore[import-not-found]
272+
from ha_mcp.server import (
273+
HomeAssistantSmartMCPServer, # type: ignore[import-not-found]
274+
)
275275

276276
return HomeAssistantSmartMCPServer()
277277
except ValidationError as e:

src/ha_mcp/auth/provider.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,12 @@
1616
from urllib.parse import urlencode
1717

1818
import httpx
19-
from fastmcp.server.auth.auth import AccessToken # FastMCP version has claims field
19+
from fastmcp.server.auth.auth import (
20+
AccessToken, # FastMCP version has claims field
21+
ClientRegistrationOptions,
22+
OAuthProvider,
23+
RevocationOptions,
24+
)
2025
from mcp.server.auth.provider import (
2126
AuthorizationCode,
2227
AuthorizationParams,
@@ -31,12 +36,6 @@
3136
from starlette.responses import HTMLResponse, RedirectResponse, Response
3237
from starlette.routing import Route
3338

34-
from fastmcp.server.auth.auth import (
35-
ClientRegistrationOptions,
36-
OAuthProvider,
37-
RevocationOptions,
38-
)
39-
4039
from .consent_form import create_consent_html, create_error_html
4140

4241
logger = logging.getLogger(__name__)

src/ha_mcp/client/rest_client.py

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,16 @@
1717
class HomeAssistantError(Exception):
1818
"""Base exception for Home Assistant API errors."""
1919

20-
pass
2120

2221

2322
class HomeAssistantConnectionError(HomeAssistantError):
2423
"""Connection error to Home Assistant."""
2524

26-
pass
2725

2826

2927
class HomeAssistantAuthError(HomeAssistantError):
3028
"""Authentication error with Home Assistant."""
3129

32-
pass
3330

3431

3532
class HomeAssistantAPIError(HomeAssistantError):
@@ -432,7 +429,7 @@ async def _resolve_automation_id(self, identifier: str) -> str:
432429
raise HomeAssistantAPIError(
433430
f"Failed to resolve automation {identifier}: {str(e)}",
434431
status_code=404,
435-
)
432+
) from e
436433
else:
437434
# Assume it's already a unique_id
438435
return identifier
@@ -463,7 +460,7 @@ async def get_automation_config(self, identifier: str) -> dict[str, Any]:
463460
raise HomeAssistantAPIError(
464461
f"Automation not found: {identifier} (unique_id: {unique_id})",
465462
status_code=404,
466-
)
463+
) from e
467464
raise
468465

469466
async def upsert_automation_config(
@@ -548,7 +545,7 @@ async def upsert_automation_config(
548545
if "400" in str(e):
549546
raise HomeAssistantAPIError(
550547
f"Invalid automation configuration: {str(e)}", status_code=400
551-
)
548+
) from e
552549
raise
553550

554551
async def delete_automation_config(self, identifier: str) -> dict[str, Any]:
@@ -582,7 +579,7 @@ async def delete_automation_config(self, identifier: str) -> dict[str, Any]:
582579
raise HomeAssistantAPIError(
583580
f"Automation not found: {identifier} (unique_id: {unique_id})",
584581
status_code=404,
585-
)
582+
) from e
586583
elif e.status_code == 405:
587584
raise HomeAssistantAPIError(
588585
f"Cannot delete automation '{identifier}': The HTTP DELETE method is blocked. "
@@ -595,14 +592,14 @@ async def delete_automation_config(self, identifier: str) -> dict[str, Any]:
595592
f"(e.g., 'DELETE_{identifier}') so you can identify and manually delete it later "
596593
f"via the Home Assistant UI (Settings > Automations & Scenes).",
597594
status_code=405,
598-
)
595+
) from e
599596
raise
600597
except Exception as e:
601598
if "404" in str(e):
602599
raise HomeAssistantAPIError(
603600
f"Automation not found: {identifier} (unique_id: {unique_id})",
604601
status_code=404,
605-
)
602+
) from e
606603
raise
607604

608605
async def start_config_flow(
@@ -845,7 +842,7 @@ async def get_script_config(self, script_id: str) -> dict[str, Any]:
845842
if e.status_code == 404:
846843
raise HomeAssistantAPIError(
847844
f"Script not found: {script_id}", status_code=404
848-
)
845+
) from e
849846
raise
850847
except Exception as e:
851848
logger.error(f"Failed to get script config for {script_id}: {e}")
@@ -897,7 +894,7 @@ async def delete_script_config(self, script_id: str) -> dict[str, Any]:
897894
if e.status_code == 404:
898895
raise HomeAssistantAPIError(
899896
f"Script not found: {script_id}", status_code=404
900-
)
897+
) from e
901898
elif e.status_code == 405:
902899
raise HomeAssistantAPIError(
903900
f"Cannot delete script '{script_id}': The HTTP DELETE method is blocked. "
@@ -912,9 +909,7 @@ async def delete_script_config(self, script_id: str) -> dict[str, Any]:
912909
f"(e.g., 'DELETE_{script_id}') so you can identify and manually delete it later "
913910
f"via the Home Assistant UI (Settings > Automations & Scenes > Scripts).",
914911
status_code=405,
915-
)
916-
raise
917-
except Exception as e:
912+
) from e
918913
raise
919914

920915

src/ha_mcp/client/websocket_client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -449,9 +449,9 @@ async def send_command(self, command_type: str, **kwargs: Any) -> dict[str, Any]
449449
)
450450
return {"success": True, **response}
451451

452-
except TimeoutError:
452+
except TimeoutError as e:
453453
self.cancel_pending_response(message_id)
454-
raise Exception("Command timeout")
454+
raise Exception("Command timeout") from e
455455
except Exception:
456456
self.cancel_pending_response(message_id)
457457
raise

src/ha_mcp/tools/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
from .smart_search import SmartSearchTools, create_smart_search_tools
55

66
__all__ = [
7-
"SmartSearchTools",
8-
"create_smart_search_tools",
97
"DeviceControlTools",
8+
"SmartSearchTools",
109
"create_device_control_tools",
10+
"create_smart_search_tools",
1111
]

src/ha_mcp/tools/backup.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import asyncio
88
import logging
99
from datetime import datetime
10-
from typing import TYPE_CHECKING, Any, Annotated
10+
from typing import TYPE_CHECKING, Annotated, Any
1111

1212
from pydantic import Field
1313

@@ -214,7 +214,7 @@ async def create_backup(
214214
if ws_client:
215215
try:
216216
await ws_client.disconnect()
217-
except:
217+
except Exception:
218218
pass # Ignore errors during cleanup
219219

220220

@@ -345,7 +345,7 @@ async def restore_backup(
345345
if ws_client:
346346
try:
347347
await ws_client.disconnect()
348-
except:
348+
except Exception:
349349
pass # Ignore errors during cleanup
350350

351351

0 commit comments

Comments
 (0)