mcpforunityserver 9.4.0b20260203025228__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli/__init__.py +3 -0
- cli/commands/__init__.py +3 -0
- cli/commands/animation.py +84 -0
- cli/commands/asset.py +280 -0
- cli/commands/audio.py +125 -0
- cli/commands/batch.py +171 -0
- cli/commands/code.py +182 -0
- cli/commands/component.py +190 -0
- cli/commands/editor.py +447 -0
- cli/commands/gameobject.py +487 -0
- cli/commands/instance.py +93 -0
- cli/commands/lighting.py +123 -0
- cli/commands/material.py +239 -0
- cli/commands/prefab.py +248 -0
- cli/commands/scene.py +231 -0
- cli/commands/script.py +222 -0
- cli/commands/shader.py +226 -0
- cli/commands/texture.py +540 -0
- cli/commands/tool.py +58 -0
- cli/commands/ui.py +258 -0
- cli/commands/vfx.py +421 -0
- cli/main.py +281 -0
- cli/utils/__init__.py +31 -0
- cli/utils/config.py +58 -0
- cli/utils/confirmation.py +37 -0
- cli/utils/connection.py +254 -0
- cli/utils/constants.py +23 -0
- cli/utils/output.py +195 -0
- cli/utils/parsers.py +112 -0
- cli/utils/suggestions.py +34 -0
- core/__init__.py +0 -0
- core/config.py +67 -0
- core/constants.py +4 -0
- core/logging_decorator.py +37 -0
- core/telemetry.py +551 -0
- core/telemetry_decorator.py +164 -0
- main.py +845 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/METADATA +328 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/RECORD +105 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/WHEEL +5 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/entry_points.txt +3 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/licenses/LICENSE +21 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/top_level.txt +7 -0
- models/__init__.py +4 -0
- models/models.py +56 -0
- models/unity_response.py +70 -0
- services/__init__.py +0 -0
- services/api_key_service.py +235 -0
- services/custom_tool_service.py +499 -0
- services/registry/__init__.py +22 -0
- services/registry/resource_registry.py +53 -0
- services/registry/tool_registry.py +51 -0
- services/resources/__init__.py +86 -0
- services/resources/active_tool.py +48 -0
- services/resources/custom_tools.py +57 -0
- services/resources/editor_state.py +304 -0
- services/resources/gameobject.py +243 -0
- services/resources/layers.py +30 -0
- services/resources/menu_items.py +35 -0
- services/resources/prefab.py +191 -0
- services/resources/prefab_stage.py +40 -0
- services/resources/project_info.py +40 -0
- services/resources/selection.py +56 -0
- services/resources/tags.py +31 -0
- services/resources/tests.py +88 -0
- services/resources/unity_instances.py +125 -0
- services/resources/windows.py +48 -0
- services/state/external_changes_scanner.py +245 -0
- services/tools/__init__.py +83 -0
- services/tools/batch_execute.py +93 -0
- services/tools/debug_request_context.py +86 -0
- services/tools/execute_custom_tool.py +43 -0
- services/tools/execute_menu_item.py +32 -0
- services/tools/find_gameobjects.py +110 -0
- services/tools/find_in_file.py +181 -0
- services/tools/manage_asset.py +119 -0
- services/tools/manage_components.py +131 -0
- services/tools/manage_editor.py +64 -0
- services/tools/manage_gameobject.py +260 -0
- services/tools/manage_material.py +111 -0
- services/tools/manage_prefabs.py +209 -0
- services/tools/manage_scene.py +111 -0
- services/tools/manage_script.py +645 -0
- services/tools/manage_scriptable_object.py +87 -0
- services/tools/manage_shader.py +71 -0
- services/tools/manage_texture.py +581 -0
- services/tools/manage_vfx.py +120 -0
- services/tools/preflight.py +110 -0
- services/tools/read_console.py +151 -0
- services/tools/refresh_unity.py +153 -0
- services/tools/run_tests.py +317 -0
- services/tools/script_apply_edits.py +1006 -0
- services/tools/set_active_instance.py +120 -0
- services/tools/utils.py +348 -0
- transport/__init__.py +0 -0
- transport/legacy/port_discovery.py +329 -0
- transport/legacy/stdio_port_registry.py +65 -0
- transport/legacy/unity_connection.py +910 -0
- transport/models.py +68 -0
- transport/plugin_hub.py +787 -0
- transport/plugin_registry.py +182 -0
- transport/unity_instance_middleware.py +262 -0
- transport/unity_transport.py +94 -0
- utils/focus_nudge.py +589 -0
- utils/module_discovery.py +55 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from typing import Annotated, Any
|
|
2
|
+
from types import SimpleNamespace
|
|
3
|
+
|
|
4
|
+
from fastmcp import Context
|
|
5
|
+
from mcp.types import ToolAnnotations
|
|
6
|
+
|
|
7
|
+
from services.registry import mcp_for_unity_tool
|
|
8
|
+
from transport.legacy.unity_connection import get_unity_connection_pool
|
|
9
|
+
from transport.unity_instance_middleware import get_unity_instance_middleware
|
|
10
|
+
from transport.plugin_hub import PluginHub
|
|
11
|
+
from core.config import config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@mcp_for_unity_tool(
|
|
15
|
+
description="Set the active Unity instance for this client/session. Accepts Name@hash or hash.",
|
|
16
|
+
annotations=ToolAnnotations(
|
|
17
|
+
title="Set Active Instance",
|
|
18
|
+
),
|
|
19
|
+
)
|
|
20
|
+
async def set_active_instance(
|
|
21
|
+
ctx: Context,
|
|
22
|
+
instance: Annotated[str, "Target instance (Name@hash or hash prefix)"]
|
|
23
|
+
) -> dict[str, Any]:
|
|
24
|
+
transport = (config.transport_mode or "stdio").lower()
|
|
25
|
+
|
|
26
|
+
# Discover running instances based on transport
|
|
27
|
+
if transport == "http":
|
|
28
|
+
# In remote-hosted mode, filter sessions by user_id
|
|
29
|
+
user_id = ctx.get_state(
|
|
30
|
+
"user_id") if config.http_remote_hosted else None
|
|
31
|
+
sessions_data = await PluginHub.get_sessions(user_id=user_id)
|
|
32
|
+
sessions = sessions_data.sessions
|
|
33
|
+
instances = []
|
|
34
|
+
for session_id, session in sessions.items():
|
|
35
|
+
project = session.project or "Unknown"
|
|
36
|
+
hash_value = session.hash
|
|
37
|
+
if not hash_value:
|
|
38
|
+
continue
|
|
39
|
+
inst_id = f"{project}@{hash_value}"
|
|
40
|
+
instances.append(SimpleNamespace(
|
|
41
|
+
id=inst_id,
|
|
42
|
+
hash=hash_value,
|
|
43
|
+
name=project,
|
|
44
|
+
session_id=session_id,
|
|
45
|
+
))
|
|
46
|
+
else:
|
|
47
|
+
pool = get_unity_connection_pool()
|
|
48
|
+
instances = pool.discover_all_instances(force_refresh=True)
|
|
49
|
+
|
|
50
|
+
if not instances:
|
|
51
|
+
return {
|
|
52
|
+
"success": False,
|
|
53
|
+
"error": "No Unity instances are currently connected. Start Unity and press 'Start Session'."
|
|
54
|
+
}
|
|
55
|
+
ids = {inst.id: inst for inst in instances if getattr(inst, "id", None)}
|
|
56
|
+
|
|
57
|
+
value = (instance or "").strip()
|
|
58
|
+
if not value:
|
|
59
|
+
return {
|
|
60
|
+
"success": False,
|
|
61
|
+
"error": "Instance identifier is required. "
|
|
62
|
+
"Use mcpforunity://instances to copy a Name@hash or provide a hash prefix."
|
|
63
|
+
}
|
|
64
|
+
resolved = None
|
|
65
|
+
if "@" in value:
|
|
66
|
+
resolved = ids.get(value)
|
|
67
|
+
if resolved is None:
|
|
68
|
+
return {
|
|
69
|
+
"success": False,
|
|
70
|
+
"error": f"Instance '{value}' not found. "
|
|
71
|
+
"Use mcpforunity://instances to copy an exact Name@hash."
|
|
72
|
+
}
|
|
73
|
+
else:
|
|
74
|
+
lookup = value.lower()
|
|
75
|
+
matches = []
|
|
76
|
+
for inst in instances:
|
|
77
|
+
if not getattr(inst, "id", None):
|
|
78
|
+
continue
|
|
79
|
+
inst_hash = getattr(inst, "hash", "")
|
|
80
|
+
if inst_hash and inst_hash.lower().startswith(lookup):
|
|
81
|
+
matches.append(inst)
|
|
82
|
+
if not matches:
|
|
83
|
+
return {
|
|
84
|
+
"success": False,
|
|
85
|
+
"error": f"Instance hash '{value}' does not match any running Unity editors. "
|
|
86
|
+
"Use mcpforunity://instances to confirm the available hashes."
|
|
87
|
+
}
|
|
88
|
+
if len(matches) > 1:
|
|
89
|
+
matching_ids = ", ".join(
|
|
90
|
+
inst.id for inst in matches if getattr(inst, "id", None)
|
|
91
|
+
) or "multiple instances"
|
|
92
|
+
return {
|
|
93
|
+
"success": False,
|
|
94
|
+
"error": f"Instance hash '{value}' is ambiguous ({matching_ids}). "
|
|
95
|
+
"Provide the full Name@hash from mcpforunity://instances."
|
|
96
|
+
}
|
|
97
|
+
resolved = matches[0]
|
|
98
|
+
|
|
99
|
+
if resolved is None:
|
|
100
|
+
# Should be unreachable due to logic above, but satisfies static analysis
|
|
101
|
+
return {
|
|
102
|
+
"success": False,
|
|
103
|
+
"error": "Internal error: Instance resolution failed."
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# Store selection in middleware (session-scoped)
|
|
107
|
+
middleware = get_unity_instance_middleware()
|
|
108
|
+
# We use middleware.set_active_instance to persist the selection.
|
|
109
|
+
# The session key is an internal detail but useful for debugging response.
|
|
110
|
+
middleware.set_active_instance(ctx, resolved.id)
|
|
111
|
+
session_key = middleware.get_session_key(ctx)
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
"success": True,
|
|
115
|
+
"message": f"Active instance set to {resolved.id}",
|
|
116
|
+
"data": {
|
|
117
|
+
"instance": resolved.id,
|
|
118
|
+
"session_key": session_key,
|
|
119
|
+
},
|
|
120
|
+
}
|
services/tools/utils.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Shared helper utilities for MCP server tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
_TRUTHY = {"true", "1", "yes", "on"}
|
|
10
|
+
_FALSY = {"false", "0", "no", "off"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def coerce_bool(value: Any, default: bool | None = None) -> bool | None:
|
|
14
|
+
"""Attempt to coerce a loosely-typed value to a boolean."""
|
|
15
|
+
if value is None:
|
|
16
|
+
return default
|
|
17
|
+
if isinstance(value, bool):
|
|
18
|
+
return value
|
|
19
|
+
if isinstance(value, str):
|
|
20
|
+
lowered = value.strip().lower()
|
|
21
|
+
if lowered in _TRUTHY:
|
|
22
|
+
return True
|
|
23
|
+
if lowered in _FALSY:
|
|
24
|
+
return False
|
|
25
|
+
return default
|
|
26
|
+
return bool(value)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_json_payload(value: Any) -> Any:
|
|
30
|
+
"""
|
|
31
|
+
Attempt to parse a value that might be a JSON string into its native object.
|
|
32
|
+
|
|
33
|
+
This is a tolerant parser used to handle cases where MCP clients or LLMs
|
|
34
|
+
serialize complex objects (lists, dicts) into strings. It also handles
|
|
35
|
+
scalar values like numbers, booleans, and null.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
value: The input value (can be str, list, dict, etc.)
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The parsed JSON object/list if the input was a valid JSON string,
|
|
42
|
+
or the original value if parsing failed or wasn't necessary.
|
|
43
|
+
"""
|
|
44
|
+
if not isinstance(value, str):
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
val_trimmed = value.strip()
|
|
48
|
+
|
|
49
|
+
# Fast path: if it doesn't look like JSON structure, return as is
|
|
50
|
+
if not (
|
|
51
|
+
(val_trimmed.startswith("{") and val_trimmed.endswith("}")) or
|
|
52
|
+
(val_trimmed.startswith("[") and val_trimmed.endswith("]")) or
|
|
53
|
+
val_trimmed in ("true", "false", "null") or
|
|
54
|
+
(val_trimmed.replace(".", "", 1).replace("-", "", 1).isdigit())
|
|
55
|
+
):
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
return json.loads(value)
|
|
60
|
+
except (json.JSONDecodeError, ValueError):
|
|
61
|
+
# If parsing fails, assume it was meant to be a literal string
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def coerce_int(value: Any, default: int | None = None) -> int | None:
|
|
66
|
+
"""Attempt to coerce a loosely-typed value to an integer."""
|
|
67
|
+
if value is None:
|
|
68
|
+
return default
|
|
69
|
+
try:
|
|
70
|
+
if isinstance(value, bool):
|
|
71
|
+
return default
|
|
72
|
+
if isinstance(value, int):
|
|
73
|
+
return value
|
|
74
|
+
s = str(value).strip()
|
|
75
|
+
if s.lower() in ("", "none", "null"):
|
|
76
|
+
return default
|
|
77
|
+
return int(float(s))
|
|
78
|
+
except Exception:
|
|
79
|
+
return default
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def coerce_float(value: Any, default: float | None = None) -> float | None:
|
|
83
|
+
"""Attempt to coerce a loosely-typed value to a float-like number."""
|
|
84
|
+
if value is None:
|
|
85
|
+
return default
|
|
86
|
+
try:
|
|
87
|
+
# Treat booleans as invalid numeric input instead of coercing to 0/1.
|
|
88
|
+
if isinstance(value, bool):
|
|
89
|
+
return default
|
|
90
|
+
if isinstance(value, (int, float)):
|
|
91
|
+
return float(value)
|
|
92
|
+
s = str(value).strip()
|
|
93
|
+
if s.lower() in ("", "none", "null"):
|
|
94
|
+
return default
|
|
95
|
+
return float(s)
|
|
96
|
+
except (TypeError, ValueError):
|
|
97
|
+
return default
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def normalize_properties(value: Any) -> tuple[dict[str, Any] | None, str | None]:
|
|
101
|
+
"""
|
|
102
|
+
Robustly normalize a properties parameter to a dict.
|
|
103
|
+
|
|
104
|
+
Handles various input formats from MCP clients/LLMs:
|
|
105
|
+
- None -> (None, None)
|
|
106
|
+
- dict -> (dict, None)
|
|
107
|
+
- JSON string -> (parsed_dict, None) or (None, error_message)
|
|
108
|
+
- Invalid values -> (None, error_message)
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Tuple of (parsed_dict, error_message). If error_message is set, parsed_dict is None.
|
|
112
|
+
"""
|
|
113
|
+
if value is None:
|
|
114
|
+
return None, None
|
|
115
|
+
|
|
116
|
+
# Already a dict - return as-is
|
|
117
|
+
if isinstance(value, dict):
|
|
118
|
+
return value, None
|
|
119
|
+
|
|
120
|
+
# Try parsing as string
|
|
121
|
+
if isinstance(value, str):
|
|
122
|
+
# Check for obviously invalid values from serialization bugs
|
|
123
|
+
if value in ("[object Object]", "undefined", "null", ""):
|
|
124
|
+
return None, f"properties received invalid value: '{value}'. Expected a JSON object like {{\"key\": value}}"
|
|
125
|
+
|
|
126
|
+
parsed = parse_json_payload(value)
|
|
127
|
+
if isinstance(parsed, dict):
|
|
128
|
+
return parsed, None
|
|
129
|
+
|
|
130
|
+
return None, f"properties must be a JSON object (dict), got string that parsed to {type(parsed).__name__}"
|
|
131
|
+
|
|
132
|
+
return None, f"properties must be a dict or JSON string, got {type(value).__name__}"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def normalize_vector3(value: Any, param_name: str = "vector") -> tuple[list[float] | None, str | None]:
|
|
136
|
+
"""
|
|
137
|
+
Normalize a vector parameter to [x, y, z] format.
|
|
138
|
+
|
|
139
|
+
Handles various input formats from MCP clients/LLMs:
|
|
140
|
+
- None -> (None, None)
|
|
141
|
+
- list/tuple [x, y, z] -> ([x, y, z], None)
|
|
142
|
+
- dict {x, y, z} -> ([x, y, z], None)
|
|
143
|
+
- JSON string "[x, y, z]" or "{x, y, z}" -> parsed and normalized
|
|
144
|
+
- comma-separated string "x, y, z" -> ([x, y, z], None)
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Tuple of (parsed_vector, error_message). If error_message is set, parsed_vector is None.
|
|
148
|
+
"""
|
|
149
|
+
if value is None:
|
|
150
|
+
return None, None
|
|
151
|
+
|
|
152
|
+
# Handle dict with x/y/z keys (e.g., {"x": 0, "y": 1, "z": 2})
|
|
153
|
+
if isinstance(value, dict):
|
|
154
|
+
if all(k in value for k in ("x", "y", "z")):
|
|
155
|
+
try:
|
|
156
|
+
vec = [float(value["x"]), float(value["y"]), float(value["z"])]
|
|
157
|
+
if all(math.isfinite(n) for n in vec):
|
|
158
|
+
return vec, None
|
|
159
|
+
return None, f"{param_name} values must be finite numbers, got {value}"
|
|
160
|
+
except (ValueError, TypeError, KeyError):
|
|
161
|
+
return None, f"{param_name} dict values must be numbers, got {value}"
|
|
162
|
+
return None, f"{param_name} dict must have 'x', 'y', 'z' keys, got {list(value.keys())}"
|
|
163
|
+
|
|
164
|
+
# If already a list/tuple with 3 elements, convert to floats
|
|
165
|
+
if isinstance(value, (list, tuple)) and len(value) == 3:
|
|
166
|
+
try:
|
|
167
|
+
vec = [float(value[0]), float(value[1]), float(value[2])]
|
|
168
|
+
if all(math.isfinite(n) for n in vec):
|
|
169
|
+
return vec, None
|
|
170
|
+
return None, f"{param_name} values must be finite numbers, got {value}"
|
|
171
|
+
except (ValueError, TypeError):
|
|
172
|
+
return None, f"{param_name} values must be numbers, got {value}"
|
|
173
|
+
|
|
174
|
+
# Try parsing as string
|
|
175
|
+
if isinstance(value, str):
|
|
176
|
+
# Check for obviously invalid values
|
|
177
|
+
if value in ("[object Object]", "undefined", "null", ""):
|
|
178
|
+
return None, f"{param_name} received invalid value: '{value}'. Expected [x, y, z] array or {{x, y, z}} object"
|
|
179
|
+
|
|
180
|
+
parsed = parse_json_payload(value)
|
|
181
|
+
|
|
182
|
+
# Handle parsed dict
|
|
183
|
+
if isinstance(parsed, dict):
|
|
184
|
+
return normalize_vector3(parsed, param_name)
|
|
185
|
+
|
|
186
|
+
# Handle parsed list
|
|
187
|
+
if isinstance(parsed, list) and len(parsed) == 3:
|
|
188
|
+
try:
|
|
189
|
+
vec = [float(parsed[0]), float(parsed[1]), float(parsed[2])]
|
|
190
|
+
if all(math.isfinite(n) for n in vec):
|
|
191
|
+
return vec, None
|
|
192
|
+
return None, f"{param_name} values must be finite numbers, got {parsed}"
|
|
193
|
+
except (ValueError, TypeError):
|
|
194
|
+
return None, f"{param_name} values must be numbers, got {parsed}"
|
|
195
|
+
|
|
196
|
+
# Handle comma-separated strings "1,2,3", "[1,2,3]", or "(1,2,3)"
|
|
197
|
+
s = value.strip()
|
|
198
|
+
if (s.startswith("[") and s.endswith("]")) or (s.startswith("(") and s.endswith(")")):
|
|
199
|
+
s = s[1:-1]
|
|
200
|
+
parts = [p.strip() for p in (s.split(",") if "," in s else s.split())]
|
|
201
|
+
if len(parts) == 3:
|
|
202
|
+
try:
|
|
203
|
+
vec = [float(parts[0]), float(parts[1]), float(parts[2])]
|
|
204
|
+
if all(math.isfinite(n) for n in vec):
|
|
205
|
+
return vec, None
|
|
206
|
+
return None, f"{param_name} values must be finite numbers, got {value}"
|
|
207
|
+
except (ValueError, TypeError):
|
|
208
|
+
return None, f"{param_name} values must be numbers, got {value}"
|
|
209
|
+
|
|
210
|
+
return None, f"{param_name} must be a [x, y, z] array or {{x, y, z}} object, got: {value}"
|
|
211
|
+
|
|
212
|
+
return None, f"{param_name} must be a list, dict, or string, got {type(value).__name__}"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def normalize_color(value: Any, output_range: str = "float") -> tuple[list[float] | None, str | None]:
|
|
216
|
+
"""
|
|
217
|
+
Normalize a color parameter to [r, g, b, a] format.
|
|
218
|
+
|
|
219
|
+
Handles various input formats from MCP clients/LLMs:
|
|
220
|
+
- None -> (None, None)
|
|
221
|
+
- list/tuple [r, g, b] or [r, g, b, a] -> normalized with optional alpha
|
|
222
|
+
- dict {r, g, b} or {r, g, b, a} -> converted to list
|
|
223
|
+
- hex string "#RGB", "#RRGGBB", "#RRGGBBAA" -> parsed to [r, g, b, a]
|
|
224
|
+
- JSON string -> parsed and normalized
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
value: The color value to normalize
|
|
228
|
+
output_range: "float" for 0.0-1.0 range, "int" for 0-255 range
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Tuple of (parsed_color, error_message). If error_message is set, parsed_color is None.
|
|
232
|
+
"""
|
|
233
|
+
if value is None:
|
|
234
|
+
return None, None
|
|
235
|
+
|
|
236
|
+
def _to_output_range(components: list[float], from_hex: bool = False) -> list:
|
|
237
|
+
"""Convert color components to the requested output range."""
|
|
238
|
+
if output_range == "int":
|
|
239
|
+
if from_hex:
|
|
240
|
+
# Already 0-255 from hex parsing
|
|
241
|
+
return [int(c) for c in components]
|
|
242
|
+
# Check if input is normalized (0-1) or already 0-255
|
|
243
|
+
if all(0 <= c <= 1 for c in components):
|
|
244
|
+
return [int(round(c * 255)) for c in components]
|
|
245
|
+
return [int(c) for c in components]
|
|
246
|
+
else: # float
|
|
247
|
+
if from_hex:
|
|
248
|
+
# Convert 0-255 to 0-1
|
|
249
|
+
return [c / 255.0 for c in components]
|
|
250
|
+
if any(c > 1 for c in components):
|
|
251
|
+
return [c / 255.0 for c in components]
|
|
252
|
+
return [float(c) for c in components]
|
|
253
|
+
|
|
254
|
+
# Handle dict with r/g/b keys
|
|
255
|
+
if isinstance(value, dict):
|
|
256
|
+
if all(k in value for k in ("r", "g", "b")):
|
|
257
|
+
try:
|
|
258
|
+
color = [float(value["r"]), float(value["g"]), float(value["b"])]
|
|
259
|
+
if "a" in value:
|
|
260
|
+
color.append(float(value["a"]))
|
|
261
|
+
else:
|
|
262
|
+
if output_range == "int" and all(0 <= c <= 1 for c in color):
|
|
263
|
+
color.append(1.0)
|
|
264
|
+
else:
|
|
265
|
+
color.append(1.0 if output_range == "float" else 255)
|
|
266
|
+
return _to_output_range(color), None
|
|
267
|
+
except (ValueError, TypeError, KeyError):
|
|
268
|
+
return None, f"color dict values must be numbers, got {value}"
|
|
269
|
+
return None, f"color dict must have 'r', 'g', 'b' keys, got {list(value.keys())}"
|
|
270
|
+
|
|
271
|
+
# Already a list/tuple - validate
|
|
272
|
+
if isinstance(value, (list, tuple)):
|
|
273
|
+
if len(value) in (3, 4):
|
|
274
|
+
try:
|
|
275
|
+
color = [float(c) for c in value]
|
|
276
|
+
if len(color) == 3:
|
|
277
|
+
if output_range == "int" and all(0 <= c <= 1 for c in color):
|
|
278
|
+
color.append(1.0)
|
|
279
|
+
else:
|
|
280
|
+
color.append(1.0 if output_range == "float" else 255)
|
|
281
|
+
return _to_output_range(color), None
|
|
282
|
+
except (ValueError, TypeError):
|
|
283
|
+
return None, f"color values must be numbers, got {value}"
|
|
284
|
+
return None, f"color must have 3 or 4 components, got {len(value)}"
|
|
285
|
+
|
|
286
|
+
# Try parsing as string
|
|
287
|
+
if isinstance(value, str):
|
|
288
|
+
if value in ("[object Object]", "undefined", "null", ""):
|
|
289
|
+
return None, f"color received invalid value: '{value}'. Expected [r, g, b, a] or {{r, g, b, a}}"
|
|
290
|
+
|
|
291
|
+
# Handle hex colors
|
|
292
|
+
if value.startswith("#"):
|
|
293
|
+
h = value.lstrip("#")
|
|
294
|
+
try:
|
|
295
|
+
if len(h) == 3:
|
|
296
|
+
# Short form #RGB -> expand to #RRGGBB
|
|
297
|
+
components = [int(c + c, 16) for c in h] + [255]
|
|
298
|
+
return _to_output_range(components, from_hex=True), None
|
|
299
|
+
elif len(h) == 6:
|
|
300
|
+
components = [int(h[i:i+2], 16) for i in (0, 2, 4)] + [255]
|
|
301
|
+
return _to_output_range(components, from_hex=True), None
|
|
302
|
+
elif len(h) == 8:
|
|
303
|
+
components = [int(h[i:i+2], 16) for i in (0, 2, 4, 6)]
|
|
304
|
+
return _to_output_range(components, from_hex=True), None
|
|
305
|
+
except ValueError:
|
|
306
|
+
return None, f"Invalid hex color: {value}"
|
|
307
|
+
return None, f"Invalid hex color length: {value}"
|
|
308
|
+
|
|
309
|
+
# Try parsing as JSON
|
|
310
|
+
parsed = parse_json_payload(value)
|
|
311
|
+
|
|
312
|
+
# Handle parsed dict
|
|
313
|
+
if isinstance(parsed, dict):
|
|
314
|
+
return normalize_color(parsed, output_range)
|
|
315
|
+
|
|
316
|
+
# Handle parsed list
|
|
317
|
+
if isinstance(parsed, (list, tuple)) and len(parsed) in (3, 4):
|
|
318
|
+
try:
|
|
319
|
+
color = [float(c) for c in parsed]
|
|
320
|
+
if len(color) == 3:
|
|
321
|
+
if output_range == "int" and all(0 <= c <= 1 for c in color):
|
|
322
|
+
color.append(1.0)
|
|
323
|
+
else:
|
|
324
|
+
color.append(1.0 if output_range == "float" else 255)
|
|
325
|
+
return _to_output_range(color), None
|
|
326
|
+
except (ValueError, TypeError):
|
|
327
|
+
return None, f"color values must be numbers, got {parsed}"
|
|
328
|
+
|
|
329
|
+
# Handle tuple-style strings "(r, g, b)" or "(r, g, b, a)"
|
|
330
|
+
s = value.strip()
|
|
331
|
+
if (s.startswith("[") and s.endswith("]")) or (s.startswith("(") and s.endswith(")")):
|
|
332
|
+
s = s[1:-1]
|
|
333
|
+
parts = [p.strip() for p in s.split(",")]
|
|
334
|
+
if len(parts) in (3, 4):
|
|
335
|
+
try:
|
|
336
|
+
color = [float(p) for p in parts]
|
|
337
|
+
if len(color) == 3:
|
|
338
|
+
if output_range == "int" and all(0 <= c <= 1 for c in color):
|
|
339
|
+
color.append(1.0)
|
|
340
|
+
else:
|
|
341
|
+
color.append(1.0 if output_range == "float" else 255)
|
|
342
|
+
return _to_output_range(color), None
|
|
343
|
+
except (ValueError, TypeError):
|
|
344
|
+
pass # Fall through to error message
|
|
345
|
+
|
|
346
|
+
return None, f"Failed to parse color string: {value}"
|
|
347
|
+
|
|
348
|
+
return None, f"color must be a list, dict, hex string, or JSON string, got {type(value).__name__}"
|
transport/__init__.py
ADDED
|
File without changes
|