Merge new JSON content into existing JSON file. Performs a polite deep merge where: - New keys are added - Existing keys are preserved (not overwritten) unless both values are dictionaries - Nested dictionaries are merged recursively only when both sides are dictionaries - Lists
(existing_path: Path, new_content: Any, verbose: bool = False)
| 214 | |
| 215 | |
| 216 | def merge_json_files(existing_path: Path, new_content: Any, verbose: bool = False) -> dict[str, Any] | None: |
| 217 | """Merge new JSON content into existing JSON file. |
| 218 | |
| 219 | Performs a polite deep merge where: |
| 220 | - New keys are added |
| 221 | - Existing keys are preserved (not overwritten) unless both values are dictionaries |
| 222 | - Nested dictionaries are merged recursively only when both sides are dictionaries |
| 223 | - Lists and other values are preserved from base if they exist |
| 224 | |
| 225 | Args: |
| 226 | existing_path: Path to existing JSON file |
| 227 | new_content: New JSON content to merge in |
| 228 | verbose: Whether to print merge details |
| 229 | |
| 230 | Returns: |
| 231 | Merged JSON content as dict, or None if the existing file should be left untouched. |
| 232 | """ |
| 233 | # Load existing content first to have a safe fallback |
| 234 | existing_content = None |
| 235 | exists = existing_path.exists() |
| 236 | |
| 237 | if exists: |
| 238 | try: |
| 239 | with open(existing_path, 'r', encoding='utf-8') as f: |
| 240 | # Handle comments (JSONC) natively with json5 |
| 241 | # Note: json5 handles BOM automatically |
| 242 | existing_content = json5.load(f) |
| 243 | except FileNotFoundError: |
| 244 | # Handle race condition where file is deleted after exists() check |
| 245 | exists = False |
| 246 | except Exception as e: |
| 247 | if verbose: |
| 248 | console.print(f"[yellow]Warning: Could not read or parse existing JSON in {existing_path.name} ({e}).[/yellow]") |
| 249 | # Skip merge to preserve existing file if unparseable or inaccessible (e.g. PermissionError) |
| 250 | return None |
| 251 | |
| 252 | # Validate template content |
| 253 | if not isinstance(new_content, dict): |
| 254 | if verbose: |
| 255 | console.print(f"[yellow]Warning: Template content for {existing_path.name} is not a dictionary. Preserving existing settings.[/yellow]") |
| 256 | return None |
| 257 | |
| 258 | if not exists: |
| 259 | return new_content |
| 260 | |
| 261 | # If existing content parsed but is not a dict, skip merge to avoid data loss |
| 262 | if not isinstance(existing_content, dict): |
| 263 | if verbose: |
| 264 | console.print(f"[yellow]Warning: Existing JSON in {existing_path.name} is not an object. Skipping merge to avoid data loss.[/yellow]") |
| 265 | return None |
| 266 | |
| 267 | def deep_merge_polite(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]: |
| 268 | """Recursively merge update dict into base dict, preserving base values.""" |
| 269 | result = base.copy() |
| 270 | for key, value in update.items(): |
| 271 | if key not in result: |
| 272 | # Add new key |
| 273 | result[key] = value |