Skip to content

Commit b2106cf

Browse files
authored
refactor(complexity): reduce C901 violations in tools/ — batch 4 (#1408)
* refactor(complexity): reduce C901 violations in tools/ batch 4 (#925) - Extract _check_control_flow_actions() in best_practice_checker.py - Extract _parse_event_data() in tools_service.py - Extract _try_raw_cdn() in tools_updates.py - Extract _process_menu_flow_result(), _build_flow_error_context(), _submit_step() in tools_config_entry_flow.py - Migrate tools_yaml_config.py to class-based pattern (YamlConfigTools) - Migrate tools_bug_report.py to class-based pattern (BugReportTools), extracting _build_formatted_report() - Add defensive guard in _process_menu_flow_result for empty intro_flow_id - Add tests for _try_raw_cdn loop behavior and _submit_step error propagation - Update test fixtures in test_tools_bug_report and test_yaml_config_tool to use mcp.add_tool() (class-based registration pattern) * fixup: ruff format + address Gemini review comments - ruff format all changed files (CI enforces ruff format --check) - _process_menu_flow_result: str | None type hint for intro_flow_id - _process_menu_flow_result: narrow except to (HomeAssistantAPIError, TimeoutError) - fetch_helper_flow_info: remove `and intro_flow_id` guard from MENU branch (restores menu_options surfacing when intro_flow_id is absent) - _try_raw_cdn: narrow except to httpx.RequestError - test_tools_updates: use httpx.ConnectError in exception-continue test * chore(hooks): add ruff-format to pre-commit hook Mirrors the CI ruff format --check gate locally so formatting issues are caught and auto-fixed before push. * Potential fix for pull request finding 'Implicit string concatenation in a list' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * fix: join implicit string concatenation in suggestions list * refactor(complexity): fix C901 violations in tools_config_automations and util_helpers - tools_config_automations: extract _scene_create_in_choose, _check_scene_create_misroute, _validate_condition_platform, _run_python_transform, _run_config_update helpers - util_helpers: extract _parse_json_to_str_list, _sample_state_change, _discover_automation_sample, _automation_event_filter, _fetch_raw_diagnostics, _apply_data_path_resolution, _ws_subscribe_all, _ws_post_subscribe_check, _ws_run_wait_loop, _ws_cleanup, _apply_truncation_cap helpers - pyproject.toml: remove util_helpers.py from C901 per-file-ignores * refactor(service): extract _parse_json_dict_param to deduplicate JSON dict validation _parse_event_data and _parse_service_data shared identical parse-validate-raise logic differing only in the type error message. Extract into a single helper.
1 parent e6cc7a1 commit b2106cf

14 files changed

Lines changed: 1459 additions & 1144 deletions

lefthook.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ pre-commit:
77
glob: "**/*.py"
88
run: uv run ruff check {staged_files} --fix
99
stage_fixed: true
10+
- name: ruff-format
11+
glob: "**/*.py"
12+
run: uv run ruff format {staged_files}
13+
stage_fixed: true
1014
- name: ast-grep
1115
glob: "src/ha_mcp/tools/**/*.py"
1216
run: uv run ast-grep scan

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,6 @@ ignore = [
147147
"src/ha_mcp/tools/tools_search.py" = ["C901"]
148148
"src/ha_mcp/tools/tools_utility.py" = ["C901"]
149149
"src/ha_mcp/tools/smart_search.py" = ["C901"]
150-
"src/ha_mcp/tools/util_helpers.py" = ["C901"]
151150

152151
[tool.pytest.ini_options]
153152
testpaths = ["tests"]

src/ha_mcp/tools/best_practice_checker.py

Lines changed: 34 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -279,10 +279,7 @@ def _check_template_string(
279279
# reframes #695 from "enumerate bad shapes" to "surface every template
280280
# in a logic position". Specific detectors above keep their tailored
281281
# messages.
282-
if (
283-
len(warnings) == initial_count
284-
and _RE_ANY_TEMPLATE.search(template)
285-
):
282+
if len(warnings) == initial_count and _RE_ANY_TEMPLATE.search(template):
286283
warnings.append(
287284
f"Template detected in {position} — if this maps to a native option "
288285
"(`numeric_state`, `state`, `time`, `sun`, `zone`, `device`), use that "
@@ -304,9 +301,7 @@ def _check_choose_actions(
304301
_check_condition_templates(
305302
option.get("conditions", []), warnings, skill_prefix
306303
)
307-
_check_action_tree(
308-
option.get("sequence", []), warnings, skill_prefix
309-
)
304+
_check_action_tree(option.get("sequence", []), warnings, skill_prefix)
310305

311306

312307
def _check_repeat_actions(
@@ -317,6 +312,31 @@ def _check_repeat_actions(
317312
_check_action_tree(repeat.get("sequence", []), warnings, skill_prefix)
318313

319314

315+
def _check_control_flow_actions(
316+
action: dict[str, Any], warnings: list[str], skill_prefix: str | None
317+
) -> None:
318+
"""Check choose/if/then/else/repeat/parallel sub-trees in a single action."""
319+
if "choose" in action:
320+
_check_choose_actions(action["choose"], warnings, skill_prefix)
321+
322+
if "if" in action:
323+
_check_condition_templates(action["if"], warnings, skill_prefix)
324+
325+
for key in ("then", "else", "default"):
326+
nested = action.get(key)
327+
if isinstance(nested, list):
328+
_check_action_tree(nested, warnings, skill_prefix)
329+
330+
if "repeat" in action and isinstance(action["repeat"], dict):
331+
_check_repeat_actions(action["repeat"], warnings, skill_prefix)
332+
333+
# `parallel:` runs sub-actions concurrently — same shape as `sequence`,
334+
# different semantics. Recurse so templates inside parallel branches
335+
# are inspected the same as templates inside choose/repeat sequences.
336+
if "parallel" in action and isinstance(action["parallel"], list):
337+
_check_action_tree(action["parallel"], warnings, skill_prefix)
338+
339+
320340
def _check_action_tree(
321341
actions: Any, warnings: list[str], skill_prefix: str | None
322342
) -> None:
@@ -359,26 +379,7 @@ def _check_action_tree(
359379
if isinstance(target, dict):
360380
_check_target_dict(target, warnings, skill_prefix)
361381

362-
# Nested conditions in choose/if/repeat
363-
if "choose" in action:
364-
_check_choose_actions(action["choose"], warnings, skill_prefix)
365-
366-
if "if" in action:
367-
_check_condition_templates(action["if"], warnings, skill_prefix)
368-
369-
for key in ("then", "else", "default"):
370-
nested = action.get(key)
371-
if isinstance(nested, list):
372-
_check_action_tree(nested, warnings, skill_prefix)
373-
374-
if "repeat" in action and isinstance(action["repeat"], dict):
375-
_check_repeat_actions(action["repeat"], warnings, skill_prefix)
376-
377-
# `parallel:` runs sub-actions concurrently — same shape as `sequence`,
378-
# different semantics. Recurse so templates inside parallel branches
379-
# are inspected the same as templates inside choose/repeat sequences.
380-
if "parallel" in action and isinstance(action["parallel"], list):
381-
_check_action_tree(action["parallel"], warnings, skill_prefix)
382+
_check_control_flow_actions(action, warnings, skill_prefix)
382383

383384

384385
def _check_service_template(
@@ -439,15 +440,19 @@ def _check_target_dict(
439440
f"hardcode the literal value instead. The self-reference is always "
440441
f"resolvable at write time, so the template adds runtime cost without "
441442
f"any flexibility."
442-
+ _ref(skill_prefix, "template-guidelines.md#when-to-avoid-templates")
443+
+ _ref(
444+
skill_prefix, "template-guidelines.md#when-to-avoid-templates"
445+
)
443446
)
444447
else:
445448
warnings.append(
446449
f"Action `target.{field}` uses a template — prefer a hardcoded literal, "
447450
f"or use a `choose` action with native conditions to dispatch to different "
448451
f"hardcoded targets. Templates in target fields fail silently if they "
449452
f"resolve to a non-existent entity."
450-
+ _ref(skill_prefix, "template-guidelines.md#when-to-avoid-templates")
453+
+ _ref(
454+
skill_prefix, "template-guidelines.md#when-to-avoid-templates"
455+
)
451456
)
452457

453458

src/ha_mcp/tools/tools_bug_report.py

Lines changed: 81 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import httpx
1818
from fastmcp import Context
19+
from fastmcp.tools import tool
1920
from pydantic import Field
2021

2122
from ha_mcp import __version__
@@ -28,7 +29,7 @@
2829
get_recent_logs,
2930
get_startup_logs,
3031
)
31-
from .helpers import log_tool_usage
32+
from .helpers import log_tool_usage, register_tool_methods
3233
from .util_helpers import ANSI_ESCAPE_RE
3334

3435
logger = logging.getLogger(__name__)
@@ -388,10 +389,66 @@ async def _fetch_addon_logs() -> str:
388389
return ""
389390

390391

391-
def register_bug_report_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
392-
"""Register bug report tools with the MCP server."""
392+
def _build_formatted_report(
393+
diagnostic_info: dict[str, Any],
394+
mcp_transport: str,
395+
client_info: dict[str, str],
396+
platform_info: dict[str, str],
397+
config_toggles: dict[str, Any],
398+
startup_logs: list[dict[str, Any]],
399+
startup_log_summary: str,
400+
recent_logs: list[dict[str, Any]],
401+
log_summary: str,
402+
addon_logs: str,
403+
) -> str:
404+
report_lines = [
405+
"=== ha-mcp Bug Report Info ===",
406+
"",
407+
f"ha-mcp Version: {diagnostic_info['ha_mcp_version']}",
408+
f"Installation Method: {diagnostic_info['installation_method']}",
409+
f"MCP Transport: {mcp_transport}",
410+
f"MCP Client: {_format_client_info_for_template(client_info)}",
411+
f"Operating System: {platform_info['os']} {platform_info['os_release']} ({platform_info['architecture']})",
412+
f"Python Version: {platform_info['python_version']}",
413+
f"Home Assistant Version: {diagnostic_info['home_assistant_version']}",
414+
f"Connection Status: {diagnostic_info['connection_status']}",
415+
f"Entity Count: {diagnostic_info['entity_count']}",
416+
]
417+
if "location_name" in diagnostic_info:
418+
report_lines.append(f"Location Name: {diagnostic_info['location_name']}")
419+
if "time_zone" in diagnostic_info:
420+
report_lines.append(f"Time Zone: {diagnostic_info['time_zone']}")
421+
if config_toggles:
422+
report_lines.extend(["", "=== ha-mcp Config Toggles ==="])
423+
for key, value in config_toggles.items():
424+
report_lines.append(f" {key}: {value}")
425+
if startup_logs:
426+
report_lines.extend(
427+
[
428+
"",
429+
f"=== Startup Logs ({len(startup_logs)} entries) ===",
430+
startup_log_summary,
431+
]
432+
)
433+
if recent_logs:
434+
report_lines.extend(
435+
[
436+
"",
437+
f"=== Recent Tool Calls ({len(recent_logs)} entries) ===",
438+
log_summary,
439+
]
440+
)
441+
if addon_logs:
442+
report_lines.extend(["", "=== Add-on Container Logs ===", addon_logs])
443+
return "\n".join(report_lines)
444+
393445

394-
@mcp.tool(
446+
class BugReportTools:
447+
def __init__(self, client: Any) -> None:
448+
self._client = client
449+
450+
@tool(
451+
name="ha_report_issue",
395452
tags={"Utilities"},
396453
annotations={
397454
"idempotentHint": True,
@@ -401,6 +458,7 @@ def register_bug_report_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
401458
)
402459
@log_tool_usage
403460
async def ha_report_issue(
461+
self,
404462
tool_call_count: Annotated[
405463
int,
406464
Field(
@@ -476,7 +534,7 @@ async def ha_report_issue(
476534

477535
# Try to get Home Assistant config and connection status
478536
try:
479-
config = await client.get_config()
537+
config = await self._client.get_config()
480538
diagnostic_info["connection_status"] = "Connected"
481539
diagnostic_info["home_assistant_version"] = config.get("version", "Unknown")
482540
diagnostic_info["location_name"] = config.get("location_name", "Unknown")
@@ -487,7 +545,7 @@ async def ha_report_issue(
487545

488546
# Try to get entity count
489547
try:
490-
states = await client.get_states()
548+
states = await self._client.get_states()
491549
if states:
492550
diagnostic_info["entity_count"] = len(states)
493551
except Exception as e:
@@ -511,59 +569,18 @@ async def ha_report_issue(
511569
startup_log_summary = _format_startup_logs(startup_logs)
512570

513571
# Build the formatted report
514-
report_lines = [
515-
"=== ha-mcp Bug Report Info ===",
516-
"",
517-
f"ha-mcp Version: {diagnostic_info['ha_mcp_version']}",
518-
f"Installation Method: {diagnostic_info['installation_method']}",
519-
f"MCP Transport: {mcp_transport}",
520-
f"MCP Client: {_format_client_info_for_template(client_info)}",
521-
f"Operating System: {platform_info['os']} {platform_info['os_release']} ({platform_info['architecture']})",
522-
f"Python Version: {platform_info['python_version']}",
523-
f"Home Assistant Version: {diagnostic_info['home_assistant_version']}",
524-
f"Connection Status: {diagnostic_info['connection_status']}",
525-
f"Entity Count: {diagnostic_info['entity_count']}",
526-
]
527-
528-
# Add optional fields if available
529-
if "location_name" in diagnostic_info:
530-
report_lines.append(f"Location Name: {diagnostic_info['location_name']}")
531-
if "time_zone" in diagnostic_info:
532-
report_lines.append(f"Time Zone: {diagnostic_info['time_zone']}")
533-
534-
if config_toggles:
535-
report_lines.extend(["", "=== ha-mcp Config Toggles ==="])
536-
for key, value in config_toggles.items():
537-
report_lines.append(f" {key}: {value}")
538-
539-
if startup_logs:
540-
report_lines.extend(
541-
[
542-
"",
543-
f"=== Startup Logs ({len(startup_logs)} entries) ===",
544-
startup_log_summary,
545-
]
546-
)
547-
548-
if recent_logs:
549-
report_lines.extend(
550-
[
551-
"",
552-
f"=== Recent Tool Calls ({len(recent_logs)} entries) ===",
553-
log_summary,
554-
]
555-
)
556-
557-
if addon_logs:
558-
report_lines.extend(
559-
[
560-
"",
561-
"=== Add-on Container Logs ===",
562-
addon_logs,
563-
]
564-
)
565-
566-
formatted_report = "\n".join(report_lines)
572+
formatted_report = _build_formatted_report(
573+
diagnostic_info,
574+
mcp_transport,
575+
client_info,
576+
platform_info,
577+
config_toggles,
578+
startup_logs,
579+
startup_log_summary,
580+
recent_logs,
581+
log_summary,
582+
addon_logs,
583+
)
567584

568585
# Generate suggested title up-front so it can be folded into the
569586
# submission URLs as a `&title=` query param. This auto-fills the
@@ -673,6 +690,11 @@ async def ha_report_issue(
673690
}
674691

675692

693+
def register_bug_report_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
694+
"""Register bug report tools with the MCP server."""
695+
register_tool_methods(mcp, BugReportTools(client))
696+
697+
676698
def _format_config_toggles_for_template(toggles: dict[str, Any]) -> str:
677699
"""Render config toggle snapshot as a markdown bullet list.
678700

0 commit comments

Comments
 (0)