Skip to content

Commit 4d10ed9

Browse files
julienldclaude
andauthored
feat: Add python_transform for cross-platform dashboard updates (#496)
* chore: Add local/ to gitignore Files in local/ directory should not be tracked by git. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * feat: Add python_transform for cross-platform dashboard updates Replace jq dependency with Python expressions + AST validation to solve Windows ARM64 compatibility (#412). - Add python_sandbox.py with AST validation and safe execution - Add python_transform parameter to ha_config_set_dashboard - Comprehensive security: AST whitelist + restricted execution - 100% backward compatible (jq_transform still works) - Works on all platforms (Windows, Linux, macOS, ARM) Tests: - 22 unit tests for sandbox security and functionality - 8 E2E tests for integration and error handling Closes #412 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: Use correct fixture name mcp_client in E2E tests * fix: Use MCPAssertions wrapper for E2E tests * fix: remove invalid 'contains' from SAFE_METHODS whitelist Python's built-in str type does not have a .contains() method. Substring containment is handled by the 'in' operator (ast.In), which is already correctly whitelisted in SAFE_NODES. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 03ad555 commit 4d10ed9

4 files changed

Lines changed: 941 additions & 9 deletions

File tree

src/ha_mcp/tools/tools_config_dashboards.py

Lines changed: 163 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,15 @@
1616
from pydantic import Field
1717

1818
from ..config import get_global_settings
19+
from ..utils.python_sandbox import PythonSandboxError, get_security_documentation, safe_execute
1920
from .helpers import log_tool_usage
2021
from .util_helpers import parse_json_param
2122

2223
logger = logging.getLogger(__name__)
2324

2425
# Try to import jq - it's not available on Windows ARM64
2526
try:
26-
import jq
27+
import jq # noqa: F401 - Used to check availability, re-imported in function
2728
JQ_AVAILABLE = True
2829
except ImportError:
2930
JQ_AVAILABLE = False
@@ -427,14 +428,29 @@ async def ha_config_set_dashboard(
427428
str | None,
428429
Field(
429430
description="jq expression to transform existing dashboard config. "
430-
"Mutually exclusive with config. Requires config_hash for validation. "
431+
"Mutually exclusive with config and python_transform. Requires config_hash for validation. "
431432
"Examples: '.views[0].sections[1].cards[0].icon = \"mdi:thermometer\"', "
432433
"'.views[0].cards += [{\"type\": \"button\", \"entity\": \"light.bedroom\"}]', "
433434
"'del(.views[0].sections[0].cards[2])'. "
434435
"MULTI-OP: Chain with '|': 'del(.views[0].cards[2]) | .views[0].cards[0].icon = \"mdi:new\"'. "
435436
"Use ha_dashboard_find_card() to get jq_path for targeted edits."
436437
),
437438
] = None,
439+
python_transform: Annotated[
440+
str | None,
441+
Field(
442+
description="Python expression to transform existing dashboard config. "
443+
"Mutually exclusive with config and jq_transform. "
444+
"Requires config_hash for validation. "
445+
"See PYTHON TRANSFORM SECURITY below for allowed operations. "
446+
"Examples: "
447+
"Simple: python_transform=\"config['views'][0]['cards'][0]['icon'] = 'mdi:lamp'\" "
448+
"Pattern: python_transform=\"for card in config['views'][0]['cards']: if 'light' in card.get('entity', ''): card['icon'] = 'mdi:lightbulb'\" "
449+
"Multi-op: python_transform=\"config['views'][0]['cards'][0]['icon'] = 'mdi:lamp'; del config['views'][0]['cards'][2]\" "
450+
"\n\n"
451+
+ get_security_documentation(),
452+
),
453+
] = None,
438454
config_hash: Annotated[
439455
str | None,
440456
Field(
@@ -465,12 +481,13 @@ async def ha_config_set_dashboard(
465481
Create or update a Home Assistant dashboard.
466482
467483
Creates a new dashboard or updates an existing one with the provided configuration.
468-
Supports two modes: full config replacement OR jq-based transformation.
484+
Supports three modes: full config replacement, Python transformation, OR jq-based transformation.
469485
470486
IMPORTANT: url_path must contain a hyphen (-) to be valid.
471487
472488
WHEN TO USE WHICH MODE:
473-
- jq_transform: Preferred for edits. Surgical changes, fewer tokens.
489+
- python_transform: RECOMMENDED for edits. Surgical/pattern-based updates, works on all platforms.
490+
- jq_transform: Legacy mode. Requires jq binary (not available on Windows ARM64).
474491
- config: New dashboards only, or full restructure. Replaces everything.
475492
476493
JQ TRANSFORM EXAMPLES:
@@ -489,6 +506,13 @@ async def ha_config_set_dashboard(
489506
490507
TIP: Use ha_dashboard_find_card() to get the jq_path for any card.
491508
509+
PYTHON TRANSFORM EXAMPLES (RECOMMENDED):
510+
- Update card icon: 'config["views"][0]["cards"][0]["icon"] = "mdi:thermometer"'
511+
- Add card: 'config["views"][0]["cards"].append({"type": "button", "entity": "light.bedroom"})'
512+
- Delete card: 'del config["views"][0]["cards"][2]'
513+
- Pattern-based update: 'for card in config["views"][0]["cards"]: if "light" in card.get("entity", ""): card["icon"] = "mdi:lightbulb"'
514+
- Multi-operation: 'config["views"][0]["cards"][0]["icon"] = "mdi:a"; config["views"][0]["cards"][1]["icon"] = "mdi:b"'
515+
492516
MODERN DASHBOARD BEST PRACTICES (2024+):
493517
- Use "sections" view type (default) with grid-based layouts
494518
- Use "tile" cards as primary card type (replaces legacy entity/light/climate cards)
@@ -589,18 +613,148 @@ async def ha_config_set_dashboard(
589613
],
590614
}
591615

592-
# Validate mutual exclusivity of config and jq_transform
593-
if config is not None and jq_transform is not None:
616+
# Validate mutual exclusivity of config, jq_transform, and python_transform
617+
transforms_provided = sum(
618+
[
619+
config is not None,
620+
jq_transform is not None,
621+
python_transform is not None,
622+
]
623+
)
624+
625+
if transforms_provided > 1:
594626
return {
595627
"success": False,
596628
"action": "set",
597-
"error": "Cannot use both 'config' and 'jq_transform' parameters",
629+
"error": "Cannot use multiple transform methods simultaneously",
598630
"suggestions": [
599-
"Use 'config' for full replacement",
600-
"Use 'jq_transform' for targeted changes to existing dashboard",
631+
"Use only ONE of: config, jq_transform, or python_transform",
632+
"config: Full replacement",
633+
"jq_transform: jq-based edits (requires jq installation)",
634+
"python_transform: Python-based edits (recommended, works everywhere)",
601635
],
602636
}
603637

638+
# Handle python_transform mode
639+
if python_transform is not None:
640+
# config_hash is REQUIRED
641+
if config_hash is None:
642+
return {
643+
"success": False,
644+
"action": "python_transform",
645+
"url_path": url_path,
646+
"error": "config_hash is required for python_transform",
647+
"suggestions": [
648+
"Call ha_config_get_dashboard() first",
649+
"Use the config_hash from that response",
650+
],
651+
}
652+
653+
# Fetch current dashboard config
654+
get_data: dict[str, Any] = {"type": "lovelace/config", "force": True}
655+
if url_path:
656+
get_data["url_path"] = url_path
657+
658+
response = await client.send_websocket_message(get_data)
659+
660+
if isinstance(response, dict) and not response.get("success", True):
661+
error_msg = response.get("error", {})
662+
if isinstance(error_msg, dict):
663+
error_msg = error_msg.get("message", str(error_msg))
664+
return {
665+
"success": False,
666+
"action": "python_transform",
667+
"url_path": url_path,
668+
"error": f"Dashboard not found or inaccessible: {error_msg}",
669+
"suggestions": [
670+
"python_transform requires an existing dashboard",
671+
"Use 'config' parameter to create a new dashboard",
672+
"Verify dashboard exists with ha_config_get_dashboard(list_only=True)",
673+
],
674+
}
675+
676+
current_config = (
677+
response.get("result") if isinstance(response, dict) else response
678+
)
679+
if not isinstance(current_config, dict):
680+
return {
681+
"success": False,
682+
"action": "python_transform",
683+
"url_path": url_path,
684+
"error": "Current dashboard config is invalid",
685+
"suggestions": [
686+
"Initialize dashboard with 'config' parameter first"
687+
],
688+
}
689+
690+
# Validate config_hash for optimistic locking
691+
current_hash = _compute_config_hash(current_config)
692+
if current_hash != config_hash:
693+
return {
694+
"success": False,
695+
"action": "python_transform",
696+
"url_path": url_path,
697+
"error": "Dashboard modified since last read (conflict)",
698+
"suggestions": [
699+
"Call ha_config_get_dashboard() again",
700+
"Use the fresh config_hash from that response",
701+
],
702+
}
703+
704+
# Apply Python transformation with validation
705+
try:
706+
transformed_config = safe_execute(python_transform, current_config)
707+
except PythonSandboxError as e:
708+
return {
709+
"success": False,
710+
"action": "python_transform",
711+
"url_path": url_path,
712+
"error": str(e),
713+
"suggestions": [
714+
"Check expression syntax",
715+
"Ensure only allowed operations are used",
716+
"See tool description for allowed operations",
717+
f"Expression: {python_transform[:100]}...",
718+
],
719+
}
720+
721+
# Save transformed config
722+
save_data: dict[str, Any] = {
723+
"type": "lovelace/config/save",
724+
"config": transformed_config,
725+
}
726+
if url_path:
727+
save_data["url_path"] = url_path
728+
729+
save_result = await client.send_websocket_message(save_data)
730+
731+
if isinstance(save_result, dict) and not save_result.get("success", True):
732+
error_msg = save_result.get("error", {})
733+
if isinstance(error_msg, dict):
734+
error_msg = error_msg.get("message", str(error_msg))
735+
return {
736+
"success": False,
737+
"action": "python_transform",
738+
"url_path": url_path,
739+
"error": f"Failed to save transformed config: {error_msg}",
740+
"suggestions": [
741+
"Expression may have produced invalid dashboard structure",
742+
"Verify config format is valid Lovelace JSON",
743+
],
744+
}
745+
746+
# Compute new hash for potential chaining
747+
new_config_hash = _compute_config_hash(transformed_config)
748+
749+
return {
750+
"success": True,
751+
"action": "python_transform",
752+
"url_path": url_path,
753+
"config_hash": new_config_hash,
754+
"python_expression": python_transform,
755+
"message": f"Dashboard {url_path} updated via Python transform",
756+
}
757+
604758
# Handle jq_transform mode
605759
if jq_transform is not None:
606760
# config_hash is REQUIRED for jq_transform

0 commit comments

Comments
 (0)