unityflow 0.3.4__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.
unityflow/query.py ADDED
@@ -0,0 +1,406 @@
1
+ """Path-based query and surgical editing for Unity YAML files.
2
+
3
+ Provides JSONPath-like querying and modification of Unity prefab data.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+ from unityflow.formats import export_to_json
12
+ from unityflow.parser import UnityYAMLDocument
13
+
14
+
15
+ @dataclass
16
+ class QueryResult:
17
+ """Result of a path-based query."""
18
+
19
+ path: str
20
+ value: Any
21
+ file_id: int | None = None
22
+
23
+
24
+ def query_path(doc: UnityYAMLDocument, path: str) -> list[QueryResult]:
25
+ """Query a Unity YAML document using a path expression.
26
+
27
+ Path syntax:
28
+ - gameObjects/*/name - all GameObject names
29
+ - gameObjects/12345/name - specific GameObject's name
30
+ - components/*/type - all component types
31
+ - components/12345/localPosition - specific component's position
32
+ - **/m_Name - recursive search for m_Name
33
+
34
+ Args:
35
+ doc: The parsed Unity YAML document
36
+ path: The path expression to query
37
+
38
+ Returns:
39
+ List of QueryResult objects
40
+ """
41
+ # Export to JSON structure for easier querying
42
+ prefab_json = export_to_json(doc, include_raw=False)
43
+ json_data = prefab_json.to_dict()
44
+
45
+ results: list[QueryResult] = []
46
+
47
+ # Parse path
48
+ parts = path.split("/")
49
+
50
+ if not parts:
51
+ return results
52
+
53
+ # Handle different root paths
54
+ root = parts[0]
55
+ rest = parts[1:] if len(parts) > 1 else []
56
+
57
+ if root == "gameObjects":
58
+ _query_objects(prefab_json.game_objects, rest, "gameObjects", results)
59
+ elif root == "components":
60
+ _query_objects(prefab_json.components, rest, "components", results)
61
+ elif root == "**":
62
+ # Recursive search
63
+ _query_recursive(json_data, rest, "", results)
64
+ else:
65
+ # Try direct path on full data
66
+ _query_objects(json_data, parts, "", results)
67
+
68
+ return results
69
+
70
+
71
+ def _query_objects(
72
+ objects: dict[str, Any],
73
+ path_parts: list[str],
74
+ prefix: str,
75
+ results: list[QueryResult],
76
+ ) -> None:
77
+ """Query a dictionary of objects."""
78
+ if not path_parts:
79
+ # Return all objects
80
+ for key, value in objects.items():
81
+ results.append(
82
+ QueryResult(
83
+ path=f"{prefix}/{key}" if prefix else key,
84
+ value=value,
85
+ file_id=int(key) if key.isdigit() else None,
86
+ )
87
+ )
88
+ return
89
+
90
+ selector = path_parts[0]
91
+ rest = path_parts[1:]
92
+
93
+ if selector == "*":
94
+ # Wildcard - query all objects
95
+ for key, obj in objects.items():
96
+ if isinstance(obj, dict):
97
+ _query_value(obj, rest, f"{prefix}/{key}" if prefix else key, results)
98
+ else:
99
+ if not rest:
100
+ results.append(
101
+ QueryResult(
102
+ path=f"{prefix}/{key}" if prefix else key,
103
+ value=obj,
104
+ file_id=int(key) if key.isdigit() else None,
105
+ )
106
+ )
107
+ elif selector in objects:
108
+ # Specific key
109
+ obj = objects[selector]
110
+ if isinstance(obj, dict):
111
+ _query_value(obj, rest, f"{prefix}/{selector}" if prefix else selector, results)
112
+ else:
113
+ if not rest:
114
+ results.append(
115
+ QueryResult(
116
+ path=f"{prefix}/{selector}" if prefix else selector,
117
+ value=obj,
118
+ file_id=int(selector) if selector.isdigit() else None,
119
+ )
120
+ )
121
+
122
+
123
+ def _query_value(
124
+ value: Any,
125
+ path_parts: list[str],
126
+ current_path: str,
127
+ results: list[QueryResult],
128
+ ) -> None:
129
+ """Query a value at a given path."""
130
+ if not path_parts:
131
+ results.append(QueryResult(path=current_path, value=value))
132
+ return
133
+
134
+ key = path_parts[0]
135
+ rest = path_parts[1:]
136
+
137
+ if isinstance(value, dict):
138
+ if key == "*":
139
+ for k, v in value.items():
140
+ _query_value(v, rest, f"{current_path}/{k}", results)
141
+ elif key in value:
142
+ _query_value(value[key], rest, f"{current_path}/{key}", results)
143
+ elif isinstance(value, list):
144
+ if key == "*":
145
+ for i, item in enumerate(value):
146
+ _query_value(item, rest, f"{current_path}[{i}]", results)
147
+ elif key.isdigit():
148
+ idx = int(key)
149
+ if 0 <= idx < len(value):
150
+ _query_value(value[idx], rest, f"{current_path}[{idx}]", results)
151
+
152
+
153
+ def _query_recursive(
154
+ value: Any,
155
+ path_parts: list[str],
156
+ current_path: str,
157
+ results: list[QueryResult],
158
+ ) -> None:
159
+ """Recursively search for a path pattern."""
160
+ if not path_parts:
161
+ return
162
+
163
+ target = path_parts[0]
164
+ rest = path_parts[1:]
165
+
166
+ if isinstance(value, dict):
167
+ for key, val in value.items():
168
+ new_path = f"{current_path}/{key}" if current_path else key
169
+
170
+ # Check if this key matches
171
+ if key == target or (target == "*" and True):
172
+ if rest:
173
+ _query_value(val, rest, new_path, results)
174
+ else:
175
+ results.append(QueryResult(path=new_path, value=val))
176
+
177
+ # Continue recursive search
178
+ _query_recursive(val, path_parts, new_path, results)
179
+
180
+ elif isinstance(value, list):
181
+ for i, item in enumerate(value):
182
+ new_path = f"{current_path}[{i}]"
183
+ _query_recursive(item, path_parts, new_path, results)
184
+
185
+
186
+ def set_value(
187
+ doc: UnityYAMLDocument,
188
+ path: str,
189
+ value: Any,
190
+ *,
191
+ create: bool = False,
192
+ ) -> bool:
193
+ """Set a value at a specific path in the document.
194
+
195
+ Args:
196
+ doc: The Unity YAML document to modify
197
+ path: The path to the value (e.g., "components/12345/localPosition")
198
+ value: The new value to set
199
+ create: If True, create the path if it doesn't exist
200
+
201
+ Returns:
202
+ True if the value was set, False if path not found
203
+
204
+ Note:
205
+ When creating new fields, they are appended at the end. Unity will
206
+ reorder fields according to the C# script declaration order when
207
+ the file is saved in the editor.
208
+ """
209
+ parts = path.split("/")
210
+
211
+ if len(parts) < 2:
212
+ return False
213
+
214
+ file_id_str = parts[1]
215
+ property_path = parts[2:] if len(parts) > 2 else []
216
+
217
+ # Find the object
218
+ if not file_id_str.isdigit():
219
+ return False
220
+
221
+ file_id = int(file_id_str)
222
+ obj = doc.get_by_file_id(file_id)
223
+
224
+ if obj is None:
225
+ return False
226
+
227
+ content = obj.get_content()
228
+ if content is None:
229
+ return False
230
+
231
+ # Navigate to the target
232
+ if not property_path:
233
+ return False
234
+
235
+ target = content
236
+ for part in property_path[:-1]:
237
+ if isinstance(target, dict):
238
+ if part in target:
239
+ target = target[part]
240
+ elif create:
241
+ # Create intermediate dict
242
+ target[part] = {}
243
+ target = target[part]
244
+ else:
245
+ return False
246
+ elif isinstance(target, list) and part.isdigit():
247
+ idx = int(part)
248
+ if 0 <= idx < len(target):
249
+ target = target[idx]
250
+ else:
251
+ return False
252
+ else:
253
+ return False
254
+
255
+ # Set the value
256
+ final_key = property_path[-1]
257
+
258
+ if isinstance(target, dict):
259
+ # Check if key exists
260
+ key_exists = final_key in target
261
+
262
+ # Convert JSON-style keys to Unity-style if needed
263
+ if not key_exists:
264
+ # Try m_FieldName format
265
+ unity_key = f"m_{final_key[0].upper()}{final_key[1:]}"
266
+ if unity_key in target:
267
+ final_key = unity_key
268
+ key_exists = True
269
+
270
+ if key_exists:
271
+ # Update existing value
272
+ target[final_key] = _convert_value(value)
273
+ return True
274
+ elif create:
275
+ # Create new field (appended at end)
276
+ target[final_key] = _convert_value(value)
277
+ return True
278
+ else:
279
+ return False
280
+ elif isinstance(target, list) and final_key.isdigit():
281
+ idx = int(final_key)
282
+ if 0 <= idx < len(target):
283
+ target[idx] = _convert_value(value)
284
+ return True
285
+
286
+ return False
287
+
288
+
289
+ def merge_values(
290
+ doc: UnityYAMLDocument,
291
+ path: str,
292
+ values: dict[str, Any],
293
+ *,
294
+ create: bool = True,
295
+ ) -> tuple[int, int]:
296
+ """Merge multiple values into a target path in the document.
297
+
298
+ This is useful for batch inserting multiple fields at once.
299
+
300
+ Args:
301
+ doc: The Unity YAML document to modify
302
+ path: The path to the target dict (e.g., "components/12345")
303
+ values: Dictionary of key-value pairs to merge
304
+ create: If True, create keys that don't exist
305
+
306
+ Returns:
307
+ Tuple of (updated_count, created_count)
308
+
309
+ Example:
310
+ merge_values(doc, "components/12345", {
311
+ "portalAPrefab": {"fileID": 123, "guid": "abc", "type": 3},
312
+ "portalBPrefab": {"fileID": 456, "guid": "def", "type": 3},
313
+ "rotationStep": 15
314
+ })
315
+ """
316
+ parts = path.split("/")
317
+
318
+ if len(parts) < 2:
319
+ return (0, 0)
320
+
321
+ file_id_str = parts[1]
322
+ property_path = parts[2:] if len(parts) > 2 else []
323
+
324
+ # Find the object
325
+ if not file_id_str.isdigit():
326
+ return (0, 0)
327
+
328
+ file_id = int(file_id_str)
329
+ obj = doc.get_by_file_id(file_id)
330
+
331
+ if obj is None:
332
+ return (0, 0)
333
+
334
+ content = obj.get_content()
335
+ if content is None:
336
+ return (0, 0)
337
+
338
+ # Navigate to the target
339
+ target = content
340
+ for part in property_path:
341
+ if isinstance(target, dict):
342
+ if part in target:
343
+ target = target[part]
344
+ elif create:
345
+ target[part] = {}
346
+ target = target[part]
347
+ else:
348
+ return (0, 0)
349
+ elif isinstance(target, list) and part.isdigit():
350
+ idx = int(part)
351
+ if 0 <= idx < len(target):
352
+ target = target[idx]
353
+ else:
354
+ return (0, 0)
355
+ else:
356
+ return (0, 0)
357
+
358
+ if not isinstance(target, dict):
359
+ return (0, 0)
360
+
361
+ updated_count = 0
362
+ created_count = 0
363
+
364
+ for key, value in values.items():
365
+ converted_value = _convert_value(value)
366
+ if key in target:
367
+ target[key] = converted_value
368
+ updated_count += 1
369
+ elif create:
370
+ target[key] = converted_value
371
+ created_count += 1
372
+
373
+ return (updated_count, created_count)
374
+
375
+
376
+ def _convert_value(value: Any) -> Any:
377
+ """Convert a value from JSON format to Unity YAML format."""
378
+ if isinstance(value, dict):
379
+ # Check if it's a vector
380
+ if set(value.keys()) <= {"x", "y", "z", "w"}:
381
+ return {k: float(v) for k, v in value.items()}
382
+ # Check if it's a reference
383
+ if "fileID" in value:
384
+ return value
385
+ # Recursive conversion
386
+ return {k: _convert_value(v) for k, v in value.items()}
387
+ elif isinstance(value, list):
388
+ return [_convert_value(item) for item in value]
389
+ else:
390
+ return value
391
+
392
+
393
+ def get_value(doc: UnityYAMLDocument, path: str) -> Any | None:
394
+ """Get a value at a specific path.
395
+
396
+ Args:
397
+ doc: The Unity YAML document
398
+ path: The path to the value
399
+
400
+ Returns:
401
+ The value at the path, or None if not found
402
+ """
403
+ results = query_path(doc, path)
404
+ if results:
405
+ return results[0].value
406
+ return None