Source code for kicadfiles.json_base_element

"""Base class for JSON-based KiCad file parsing.

This module provides automatic JSON serialization/deserialization for KiCad files
like .kicad_pro project files. It mirrors the functionality of base_element.py
but is designed specifically for JSON format files.

Key Features:
- Automatic type-aware parsing based on dataclass field definitions
- Round-trip preservation with exact structure preservation option
- Nested object handling for complex data structures
- File I/O methods with proper encoding and extension validation
"""

import copy
import json
from abc import ABC
from dataclasses import MISSING, dataclass, field, fields
from typing import Any, Dict, List, Optional, Type, TypeVar, Union, get_args, get_origin

from .base_element import ParseStrictness

T = TypeVar("T", bound="JsonObject")


[docs] @dataclass class JsonObject(ABC): """Base class for JSON-based KiCad objects. This class provides similar functionality to KiCadObject but for JSON format files like .kicad_pro project files. It automatically handles serialization/deserialization based on dataclass field definitions. Features: - Automatic JSON parsing based on dataclass field definitions - Round-trip preservation with `preserve_original` option - Type-aware serialization/deserialization for nested objects - File I/O methods with extension validation and encoding support - Recursive handling of Optional, List, and nested JsonObject types Usage: @dataclass class MyKiCadFile(JsonObject): version: int = 1 data: Optional[Dict[str, Any]] = None # Parse from file obj = MyKiCadFile.from_file("file.json") # Parse from dictionary obj = MyKiCadFile.from_dict({"version": 2, "data": {...}}) # Export with exact round-trip preservation data = obj.to_dict(preserve_original=True) """ _original_data: Optional[Dict[str, Any]] = field( default=None, init=False, repr=False )
[docs] @classmethod def from_dict(cls: Type[T], data: Dict[str, Any]) -> T: """Create instance from dictionary data. Args: data: Dictionary containing the data Returns: Instance of the class """ class_fields = {f.name: f for f in fields(cls) if f.init} kwargs = {} for field_name, field_obj in class_fields.items(): if field_name in data: kwargs[field_name] = cls._parse_field_value( data[field_name], field_obj.type ) elif field_obj.default is not MISSING: kwargs[field_name] = field_obj.default elif field_obj.default_factory is not MISSING: kwargs[field_name] = field_obj.default_factory() instance = cls(**kwargs) instance._original_data = copy.deepcopy(data) return instance
@classmethod def _parse_field_value(cls, value: Any, field_type: Any) -> Any: """Parse a field value based on its type annotation. Args: value: The value to parse field_type: The expected type Returns: Parsed value """ if value is None: return None origin = get_origin(field_type) if origin is Union: args = get_args(field_type) if len(args) == 2 and type(None) in args: # Handle Optional[SomeType] actual_type = args[0] if args[1] is type(None) else args[1] if value is not None and _is_json_object_type(actual_type): return actual_type.from_dict(value) return value elif origin is list or origin is List: if not isinstance(value, list): return value args = get_args(field_type) if args and _is_json_object_type(args[0]): return [args[0].from_dict(item) for item in value] return value elif _is_json_object_type(field_type): # Direct JsonObject type return field_type.from_dict(value) return value
[docs] @classmethod def from_str( cls: Type[T], json_string: str, strictness: ParseStrictness = ParseStrictness.STRICT, ) -> T: """Parse from JSON string. Args: json_string: JSON content as string strictness: Parse strictness level (not used for JSON, kept for compatibility) Returns: Instance of the class Raises: ValueError: If the JSON string is invalid """ try: data = json.loads(json_string) except json.JSONDecodeError as e: raise ValueError(f"Invalid JSON format: {e}") from e return cls.from_dict(data)
[docs] @classmethod def from_file( cls: Type[T], file_path: str, strictness: ParseStrictness = ParseStrictness.STRICT, encoding: str = "utf-8", ) -> T: """Parse from JSON file. Args: file_path: Path to JSON file strictness: Parse strictness level (not used for JSON, kept for compatibility) encoding: File encoding (default: utf-8) Returns: Instance of the class Raises: FileNotFoundError: If the file doesn't exist ValueError: If the file contains invalid JSON UnicodeDecodeError: If the file encoding is incorrect """ # Subclasses should override this for file extension validation with open(file_path, "r", encoding=encoding) as f: content = f.read() return cls.from_str(content, strictness)
[docs] def to_dict(self, preserve_original: bool = False) -> Dict[str, Any]: """Convert to dictionary format. Args: preserve_original: If True and original data exists, return updated original data preserving the exact structure Returns: Dictionary representation """ if preserve_original and self._original_data is not None: return self._update_original_data() else: return self._to_dict_full()
def _to_dict_full(self) -> Dict[str, Any]: """Convert to full dictionary with all fields.""" result = {} for field_obj in fields(self): if not field_obj.init or field_obj.name.startswith("_"): continue value = getattr(self, field_obj.name) if value is not MISSING: result[field_obj.name] = self._serialize_value(value) return result def _serialize_value(self, value: Any) -> Any: """Serialize a single value for dictionary output.""" if value is None: return None if isinstance(value, JsonObject): return value.to_dict(preserve_original=False) if isinstance(value, list): return [self._serialize_value(item) for item in value] if isinstance(value, dict): return {k: self._serialize_value(v) for k, v in value.items()} return value def _update_original_data(self) -> Dict[str, Any]: """Update original data with current values while preserving structure.""" if self._original_data is None: return self._to_dict_full() updated = copy.deepcopy(self._original_data) current_full = self._to_dict_full() self._update_nested_dict(updated, current_full) return updated def _update_nested_dict( self, original: Dict[str, Any], current: Dict[str, Any] ) -> None: """Recursively update nested dictionary preserving original structure.""" for key in original: if key in current: if isinstance(original[key], dict) and isinstance(current[key], dict): self._update_nested_dict(original[key], current[key]) else: original[key] = current[key]
[docs] def to_json_str( self, pretty_print: bool = True, preserve_original: bool = False ) -> str: """Convert to JSON string format. Args: pretty_print: Whether to format JSON with indentation preserve_original: If True, preserve original structure for exact round-trip Returns: JSON string representation """ data = self.to_dict(preserve_original=preserve_original) return ( json.dumps(data, indent=2, ensure_ascii=False) if pretty_print else json.dumps(data, separators=(",", ":"), ensure_ascii=False) )
[docs] def save_to_file( self, file_path: str, encoding: str = "utf-8", preserve_original: bool = False ) -> None: """Save to JSON file format. Args: file_path: Path to write the JSON file encoding: File encoding (default: utf-8) preserve_original: Whether to preserve original structure """ content = self.to_json_str( pretty_print=True, preserve_original=preserve_original ) with open(file_path, "w", encoding=encoding) as f: f.write(content)
def _is_json_object_type(type_hint: Any) -> bool: """Check if a type hint represents a JsonObject subclass.""" try: return isinstance(type_hint, type) and issubclass(type_hint, JsonObject) except (TypeError, AttributeError): return False