"""Optimized S-expression parser for KiCad objects with cursor-based approach."""
from __future__ import annotations
import logging
from abc import ABC
from dataclasses import MISSING, dataclass, field, fields
from enum import Enum
from typing import (
Any,
ClassVar,
Dict,
List,
Optional,
Type,
TypeVar,
Union,
cast,
get_args,
get_origin,
get_type_hints,
)
from .sexpdata import Symbol
from .sexpr_parser import SExpr, SExprParser, str_to_sexpr
T = TypeVar("T", bound="KiCadObject")
[docs]
@dataclass(eq=False)
class KiCadPrimitive(ABC):
"""Base class for KiCad primitive values - simplified like OptionalFlag."""
base_type: ClassVar[type] = object # To be overridden in subclasses
token_name: str
value: Any = None
required: bool = True
__found__: bool = field(default=False, init=False)
def __str__(self) -> str:
return str(self.value)
[docs]
def __repr__(self) -> str:
"""Developer-friendly representation."""
parts = [repr(self.value)]
if self.token_name:
parts.append(f"token={repr(self.token_name)}")
parts.append(f"found={self.__found__}")
return f"{self.__class__.__name__}({', '.join(parts)})"
[docs]
def __bool__(self) -> bool:
"""Boolean conversion - returns True if found and has truthy value."""
return self.__found__
[docs]
def __call__(self, value: Any) -> "KiCadPrimitive":
"""Set value using function call syntax: primitive(new_value)."""
self.value = value
self.__found__ = True
return self
[docs]
def to_sexpr(self) -> Optional[List[Any]]:
"""Convert to S-expression format."""
if not self.__found__ and not self.required:
return None
return [self.token_name, self.value]
[docs]
def __eq__(self, other: object) -> bool:
"""Equality comparison excluding __found__ field."""
if not isinstance(other, KiCadPrimitive):
return False
return (
self.__class__ == other.__class__
and self.token_name == other.token_name
and self.value == other.value
and self.required == other.required
)
[docs]
@dataclass(eq=False)
class KiCadStr(KiCadPrimitive):
"""String wrapper for KiCad values."""
base_type: ClassVar[type] = str
token_name: str = ""
value: str = ""
required: bool = True
[docs]
@dataclass(eq=False)
class KiCadInt(KiCadPrimitive):
"""Integer wrapper for KiCad values."""
base_type: ClassVar[type] = int
token_name: str = ""
value: int = 0
required: bool = True
[docs]
@dataclass(eq=False)
class KiCadFloat(KiCadPrimitive):
"""Float wrapper for KiCad values."""
base_type: ClassVar[type] = float
token_name: str = ""
value: float = 0.0
required: bool = True
[docs]
@dataclass
class ParseCursor:
"""Lightweight cursor for tracking position in S-expression."""
sexpr: SExpr # Current S-expression
parser: SExprParser # Single parser (passed through)
path: List[str] # Path for debugging
strictness: ParseStrictness # Parse strictness level
[docs]
def enter(self, sexpr: SExpr, name: str) -> "ParseCursor":
"""Create new cursor for nested object."""
# Create new parser for nested object to track usage independently
nested_parser = SExprParser(sexpr)
return ParseCursor(
sexpr=sexpr,
parser=nested_parser, # New parser for nested object
path=self.path + [name],
strictness=self.strictness, # Pass through strictness
)
[docs]
def get_path_str(self) -> str:
return " > ".join(self.path)
[docs]
class ParseStrictness(Enum):
"""Parser strictness levels for error handling."""
STRICT = "strict" # Raise exceptions for all parsing errors
SILENT = "silent" # Silently use defaults for missing fields
FAILSAFE = "failsafe" # Log warnings and use defaults for missing fields
[docs]
class FieldType(Enum):
"""Optimized classification with correct Optional/Required handling."""
PRIMITIVE = "primitive" # str, int, float (required)
OPTIONAL_PRIMITIVE = "optional_primitive" # Optional[str], etc. (optional)
KICAD_PRIMITIVE = "kicad_primitive" # KiCadStr, KiCadInt, KiCadFloat (required)
OPTIONAL_KICAD_PRIMITIVE = (
"optional_kicad_primitive" # Optional[KiCadStr], etc. (optional)
)
LIST = "list" # List[T] AND Optional[List[T]] - both treated equally!
KICAD_OBJECT = "kicad_object" # KiCadObject (required)
OPTIONAL_KICAD_OBJECT = "optional_kicad_object" # Optional[KiCadObject] (optional)
OPTIONAL_FLAG = "optional_flag" # OptionalFlag (always optional by definition)
[docs]
@dataclass
class FieldInfo:
"""Complete field information for optimized parsing."""
name: str
field_type: FieldType
inner_type: Type[Any]
position_index: int
token_name: Optional[str] = None
[docs]
@dataclass
class KiCadObject(ABC):
"""Base class for KiCad S-expression objects with cursor-based parsing."""
__token_name__: ClassVar[str] = ""
__legacy_token_names__: ClassVar[List[str]] = []
_field_info_cache: ClassVar[List[FieldInfo]]
_field_defaults_cache: ClassVar[Dict[str, Any]]
[docs]
def __post_init__(self) -> None:
"""Validate token name is defined."""
if not self.__token_name__:
raise ValueError(
f"Class {self.__class__.__name__} must define __token_name__"
)
@classmethod
def _log_parse_issue(cls, cursor: ParseCursor, message: str) -> None:
"""Log parsing issues based on strictness level from cursor."""
if cursor.strictness == ParseStrictness.STRICT:
raise ValueError(message)
elif cursor.strictness == ParseStrictness.FAILSAFE:
logging.warning(message)
# SILENT mode: do nothing
[docs]
@classmethod
def from_sexpr(
cls: Type[T],
sexpr: Union[str, SExpr],
strictness: ParseStrictness = ParseStrictness.STRICT,
) -> T:
"""Single public entry point - parser created once here."""
# Create parser only once here
if isinstance(sexpr, str):
parser = SExprParser.from_string(sexpr)
sexpr = parser.sexpr
else:
parser = SExprParser(sexpr)
# Create cursor with parser and parse directly
cursor = ParseCursor(
sexpr=sexpr, parser=parser, path=[cls.__name__], strictness=strictness
)
return cls._parse_recursive(cursor)
[docs]
@classmethod
def from_str(
cls: Type[T],
sexpr_string: str,
strictness: ParseStrictness = ParseStrictness.STRICT,
) -> T:
"""Parse from S-expression string - convenience method for better clarity."""
sexpr = str_to_sexpr(sexpr_string)
return cls.from_sexpr(sexpr, strictness)
@classmethod
def _parse_recursive(cls: Type[T], cursor: ParseCursor) -> T:
"""Internal recursive parse function - uses existing parser."""
token = str(cursor.sexpr[0]) if cursor.sexpr else "empty"
valid_tokens = [cls.__token_name__] + (cls.__legacy_token_names__ or [])
if not cursor.sexpr or token not in valid_tokens:
raise ValueError(
f"Token mismatch at {cursor.get_path_str()}: "
f"expected '{cls.__token_name__}', got '{token}'"
)
field_infos = cls._classify_fields()
field_defaults = cls._get_field_defaults()
parsed_values = {}
for field_info in field_infos:
value = cls._parse_field_recursive(field_info, cursor, field_defaults)
if value is not None:
parsed_values[field_info.name] = value
elif field_info.name in field_defaults:
parsed_values[field_info.name] = field_defaults[field_info.name]
# Check for unused parameters and warn
unused = cursor.parser.get_unused_parameters()
if unused and cursor.strictness != ParseStrictness.SILENT:
unused_summary = cls._format_unused_parameters(unused)
cls._log_parse_issue(
cursor,
f"{cursor.get_path_str()}: Unused parameters in {cls.__name__}: {unused_summary}",
)
return cls(**parsed_values)
@classmethod
def _format_unused_parameters(cls, unused: List[Any]) -> str:
"""Format unused parameters for concise logging.
Args:
unused: List of unused S-expression parameters
Returns:
Concise string representation of unused parameters
"""
if not unused:
return "[]"
# Create short representations of each unused parameter
short_params = []
for param in unused:
if isinstance(param, list) and len(param) > 0:
# For lists, show first element (token name) and count
token_name = param[0] if param else "unknown"
short_params.append(f"{token_name}[{len(param) - 1} params]")
else:
# For simple values, show them directly but truncate if too long
param_str = str(param)
if len(param_str) > 30:
short_params.append(f"{param_str[:27]}...")
else:
short_params.append(param_str)
return f"[{', '.join(short_params)}] ({len(unused)} total)"
@classmethod
def _classify_fields(cls) -> List[FieldInfo]:
"""Pre-classify all fields for optimized parsing with caching."""
if not hasattr(cls, "_field_info_cache"):
field_types = get_type_hints(cls)
field_infos: List[FieldInfo] = []
position_index = 0
for dataclass_field in fields(cls):
if dataclass_field.name.startswith("_"):
continue
field_type = field_types[dataclass_field.name]
field_info = cls._classify_field(
dataclass_field.name, field_type, position_index
)
field_infos.append(field_info)
position_index += 1
cls._field_info_cache = field_infos
return cls._field_info_cache
@classmethod
def _get_field_defaults(cls) -> Dict[str, Any]:
"""Get field defaults with caching."""
if not hasattr(cls, "_field_defaults_cache"):
result = {}
for f in fields(cls):
if f.default != MISSING:
result[f.name] = f.default
elif f.default_factory != MISSING: # type: ignore
# Call default_factory to get the actual default instance
result[f.name] = f.default_factory() # type: ignore
cls._field_defaults_cache = result
return cls._field_defaults_cache
@classmethod
def _classify_field(
cls, name: str, field_type: Type[Any], position: int
) -> FieldInfo:
"""Correct classification with list simplification."""
is_optional = get_origin(field_type) is Union and type(None) in get_args(
field_type
)
inner_type = field_type
if is_optional:
inner_type = next(
arg for arg in get_args(field_type) if arg is not type(None)
)
# Lists are ALWAYS treated equally - Optional[List] = List
if get_origin(inner_type) in (list, List):
list_element_type = get_args(inner_type)[0] if get_args(inner_type) else str
# Handle Union types in lists (e.g., List[Union[Arc, Circle, ...]])
if get_origin(list_element_type) is Union:
# For Union types, we'll use a special marker to indicate multi-token parsing
return FieldInfo(
name=name,
field_type=FieldType.LIST,
inner_type=list_element_type,
position_index=position,
token_name="__UNION__", # Special marker for union types
)
return FieldInfo(
name=name,
field_type=FieldType.LIST, # Always LIST, never optional
inner_type=list_element_type,
position_index=position,
token_name=(
getattr(list_element_type, "__token_name__", None)
if hasattr(list_element_type, "__token_name__")
else None
),
)
# OptionalFlag
try:
if isinstance(inner_type, type) and issubclass(inner_type, OptionalFlag):
return FieldInfo(
name=name,
field_type=FieldType.OPTIONAL_FLAG,
inner_type=inner_type,
position_index=position,
)
except TypeError:
pass
# KiCadPrimitive
try:
if isinstance(inner_type, type) and issubclass(inner_type, KiCadPrimitive):
field_type_enum = (
FieldType.OPTIONAL_KICAD_PRIMITIVE
if is_optional
else FieldType.KICAD_PRIMITIVE
)
return FieldInfo(
name=name,
field_type=field_type_enum,
inner_type=inner_type,
position_index=position,
token_name=name, # Use field name as token name by default
)
except TypeError:
pass
# KiCadObject
try:
if isinstance(inner_type, type) and issubclass(inner_type, KiCadObject):
field_type_enum = (
FieldType.OPTIONAL_KICAD_OBJECT
if is_optional
else FieldType.KICAD_OBJECT
)
return FieldInfo(
name=name,
field_type=field_type_enum,
inner_type=inner_type,
position_index=position,
token_name=getattr(inner_type, "__token_name__", None),
)
except TypeError:
pass
# Primitive
field_type_enum = (
FieldType.OPTIONAL_PRIMITIVE if is_optional else FieldType.PRIMITIVE
)
return FieldInfo(
name=name,
field_type=field_type_enum,
inner_type=inner_type,
position_index=position,
)
@classmethod
def _parse_field_recursive(
cls,
field_info: FieldInfo,
cursor: ParseCursor,
field_defaults: Dict[str, Any],
) -> Any:
"""Simplified logic with correct Required/Optional handling."""
if field_info.field_type == FieldType.LIST:
return cls._parse_list_with_cursor(field_info, cursor)
elif field_info.field_type == FieldType.OPTIONAL_FLAG:
return cls._parse_optional_flag_with_cursor(field_info, cursor)
elif field_info.field_type in (
FieldType.KICAD_OBJECT,
FieldType.OPTIONAL_KICAD_OBJECT,
):
result = cls._parse_nested_object(field_info, cursor)
# Validation: Required objects must be found
if result is None and field_info.field_type == FieldType.KICAD_OBJECT:
cls._log_parse_issue(
cursor,
f"{cursor.get_path_str()}: Required object '{field_info.name}' not found",
)
return result
elif field_info.field_type in (
FieldType.KICAD_PRIMITIVE,
FieldType.OPTIONAL_KICAD_PRIMITIVE,
):
primitive_result = cls._parse_kicad_primitive_with_cursor(
field_info, cursor, field_defaults
)
# Validation: Required KiCad primitives must be found
if (
primitive_result is None
and field_info.field_type == FieldType.KICAD_PRIMITIVE
):
cls._log_parse_issue(
cursor,
f"{cursor.get_path_str()}: Required KiCad primitive '{field_info.name}' not found",
)
return primitive_result
else: # PRIMITIVE or OPTIONAL_PRIMITIVE
result = cls._parse_primitive_with_cursor(
field_info, cursor, field_defaults
)
# Validation: Required primitives must be found
if result is None and field_info.field_type == FieldType.PRIMITIVE:
cls._log_parse_issue(
cursor,
f"{cursor.get_path_str()}: Required field '{field_info.name}' not found",
)
return result
@classmethod
def _parse_list_with_cursor(
cls,
field_info: FieldInfo,
cursor: ParseCursor,
) -> List[Any]:
"""Parse list of values with cursor tracking."""
result: List[Any] = []
_ = cursor.sexpr[0] # Token at index 0, skip in enumeration
if field_info.token_name: # List of KiCadObjects
if field_info.token_name == "__UNION__": # Special handling for Union types
# Get all possible types from the Union
union_types = get_args(field_info.inner_type)
# Create a mapping from token_name to type
token_to_type = {}
for union_type in union_types:
if hasattr(union_type, "__token_name__"):
token_to_type[union_type.__token_name__] = union_type
# Parse items by matching their token names
for token_idx, item in enumerate(cursor.sexpr[1:], 1):
if isinstance(item, list) and item:
item_token = str(item[0])
if item_token in token_to_type:
cursor.parser.mark_used(token_idx)
item_cursor = cursor.enter(
item, f"{item_token}[{len(result)}]"
)
parsed_item = token_to_type[item_token]._parse_recursive(
item_cursor
)
result.append(parsed_item)
else:
# Original single-type parsing
for token_idx, item in enumerate(cursor.sexpr[1:], 1):
if (
isinstance(item, list)
and item
and str(item[0]) == field_info.token_name
):
cursor.parser.mark_used(token_idx) # Mark in main parser
item_cursor = cursor.enter(
item, f"{field_info.token_name}[{len(result)}]"
)
parsed_item = field_info.inner_type._parse_recursive(
item_cursor
)
result.append(parsed_item)
else: # List of primitives
list_fields = [
fi for fi in cls._classify_fields() if fi.field_type == FieldType.LIST
]
if len(list_fields) == 1:
for token_idx, item in enumerate(cursor.sexpr[1:], 1):
if token_idx not in cursor.parser.used_indices and not isinstance(
item, list
):
cursor.parser.mark_used(token_idx)
converted = cls._convert_value(item, field_info.inner_type)
result.append(converted)
return result # Always list, never None
@classmethod
def _parse_nested_object(
cls,
field_info: FieldInfo,
cursor: ParseCursor,
) -> Optional[KiCadObject]:
"""Parse nested KiCadObject using token name."""
if not field_info.token_name:
return None
_ = cursor.sexpr[0] # Token at index 0, skip in enumeration
for token_idx, item in enumerate(cursor.sexpr[1:], 1):
if (
isinstance(item, list)
and item
and str(item[0]) == field_info.token_name
):
cursor.parser.mark_used(token_idx) # Mark in main parser
nested_cursor = cursor.enter(item, field_info.token_name)
return cast(
KiCadObject,
field_info.inner_type._parse_recursive(nested_cursor),
)
return None
@classmethod
def _parse_optional_flag_with_cursor(
cls,
field_info: FieldInfo,
cursor: ParseCursor,
) -> Optional[OptionalFlag]:
"""Parse OptionalFlag with cursor tracking."""
_ = cursor.sexpr[0] # Token at index 0, skip in enumeration
for token_idx, item in enumerate(cursor.sexpr[1:], 1):
# Handle both simple flags and flags with values
if (
isinstance(item, list)
and len(item) >= 1
and str(item[0]) == field_info.name
):
cursor.parser.mark_used(token_idx) # Mark in main parser
token_value = str(item[1]) if len(item) > 1 else None
result = OptionalFlag(
field_info.name, is_token=True, token_value=token_value
)
result.__found__ = True
return result
elif str(item) == field_info.name:
cursor.parser.mark_used(token_idx) # Mark in main parser
result = OptionalFlag(field_info.name, is_token=True)
result.__found__ = True
return result
# Not found - return None for optional fields
return None
@classmethod
def _parse_kicad_primitive_with_cursor(
cls,
field_info: FieldInfo,
cursor: ParseCursor,
field_defaults: Dict[str, Any],
) -> Optional[KiCadPrimitive]:
"""Parse KiCad primitive value."""
is_required = field_info.field_type == FieldType.KICAD_PRIMITIVE
def create_instance(
raw_value: Any, token_name: str, found: bool = True
) -> KiCadPrimitive:
"""Helper to create and configure KiCadPrimitive instance."""
instance = field_info.inner_type(token_name, raw_value, is_required)
instance.__found__ = found
return cast(KiCadPrimitive, instance)
# Determine the token name to search for
# If there's a default value with a token_name, use that
# Otherwise use the field name
search_token_name = field_info.name
default_value = field_defaults.get(field_info.name)
if default_value is not None and isinstance(default_value, KiCadPrimitive):
if default_value.token_name:
search_token_name = default_value.token_name
# Search for field in S-expression
for token_idx, item in enumerate(cursor.sexpr[1:], 1):
# Named form: (token_name value)
if (
isinstance(item, list)
and len(item) >= 2
and str(item[0]) == search_token_name
):
cursor.parser.mark_used(token_idx)
raw_value = cls._convert_value(item[1], field_info.inner_type.base_type)
return create_instance(raw_value, search_token_name)
# Positional form: plain value at expected position
# Skip Symbol objects as they should be handled by OptionalFlag parsing
if (
not isinstance(item, list)
and not isinstance(item, Symbol)
and field_info.position_index == (token_idx - 1)
):
cursor.parser.mark_used(token_idx)
raw_value = cls._convert_value(item, field_info.inner_type.base_type)
return create_instance(raw_value, "")
# Field not found - handle defaults and missing values
default_value = field_defaults.get(field_info.name)
if default_value is not None:
if is_required:
cls._log_parse_issue(
cursor,
f"{cursor.get_path_str()}: Missing required field '{field_info.name}', using default",
)
return cast(Optional[KiCadPrimitive], default_value)
# No default value available
if is_required:
cls._log_parse_issue(
cursor,
f"{cursor.get_path_str()}: Required KiCad primitive field '{field_info.name}' not found",
)
return None
@classmethod
def _parse_primitive_with_cursor(
cls,
field_info: FieldInfo,
cursor: ParseCursor,
field_defaults: Dict[str, Any],
) -> Any:
"""Parse primitive value with cursor tracking."""
_ = cursor.sexpr[0] # Token at index 0, skip in enumeration
# Try named field first: (field_name value)
for token_idx, item in enumerate(cursor.sexpr[1:], 1):
if (
isinstance(item, list)
and len(item) >= 2
and str(item[0]) == field_info.name
):
cursor.parser.mark_used(token_idx) # Mark in main parser
try:
return cls._convert_value(item[1], field_info.inner_type)
except ValueError as e:
cls._log_parse_issue(
cursor,
f"{cursor.get_path_str()}: Conversion failed for '{field_info.name}': {e}",
)
# Try positional access
if field_info.position_index < len(cursor.sexpr[1:]):
value = cursor.sexpr[1:][field_info.position_index]
if not isinstance(value, list):
cursor.parser.mark_used(
field_info.position_index + 1
) # Mark in main parser
try:
return cls._convert_value(value, field_info.inner_type)
except ValueError as e:
cls._log_parse_issue(
cursor,
f"{cursor.get_path_str()}: Positional conversion failed for '{field_info.name}': {e}",
)
# Handle missing values
is_optional_field = field_info.field_type in (
FieldType.OPTIONAL_PRIMITIVE,
FieldType.OPTIONAL_KICAD_OBJECT,
FieldType.OPTIONAL_FLAG,
)
default_value = field_defaults.get(field_info.name)
if default_value is not None:
if not is_optional_field:
cls._log_parse_issue(
cursor,
f"{cursor.get_path_str()}: Missing field '{field_info.name}' (using default: {default_value})",
)
return default_value
if not is_optional_field:
cls._log_parse_issue(
cursor,
f"{cursor.get_path_str()}: Missing required field '{field_info.name}', returning None",
)
return None
@classmethod
def _convert_value(cls, value: Any, target_type: Type[Any]) -> Any:
"""Convert value to target type with error handling."""
if value is None:
raise ValueError(f"Cannot convert None to {target_type.__name__}")
try:
if target_type == int:
return int(value)
elif target_type == str:
return str(value)
elif target_type == float:
return float(value)
elif target_type == bool:
return str(value).lower() in ("yes", "true", "1")
elif isinstance(target_type, type) and issubclass(target_type, Enum):
# Handle enum conversion - try by value first, then by name
if isinstance(value, target_type):
return value
try:
return target_type(value)
except ValueError:
# Try by name if value lookup failed
return target_type[str(value).upper()]
else:
raise ValueError(f"Unsupported type: {target_type}")
except (ValueError, TypeError) as e:
raise ValueError(f"Cannot convert '{value}' to {target_type.__name__}: {e}")
[docs]
def to_sexpr(self) -> SExpr:
"""Convert to S-expression using simple field iteration."""
result: SExpr = [self.__token_name__]
field_infos = self._classify_fields()
field_defaults = self._get_field_defaults()
for field_info in field_infos:
value = getattr(self, field_info.name)
# Lists are never None - always serialize (even if empty)
if field_info.field_type == FieldType.LIST:
if isinstance(value, list):
for item in value:
if isinstance(item, KiCadObject):
result.append(item.to_sexpr())
elif isinstance(item, Enum):
result.append(item.value)
else:
result.append(item)
# Empty lists are serialized as empty, not skipped
elif value is None:
is_optional_field = field_info.field_type in (
FieldType.OPTIONAL_PRIMITIVE,
FieldType.OPTIONAL_KICAD_OBJECT,
FieldType.OPTIONAL_KICAD_PRIMITIVE,
FieldType.OPTIONAL_FLAG,
)
has_default = field_info.name in field_defaults
if is_optional_field or has_default:
continue # Skip optional None fields
else:
raise ValueError(
f"Required field '{field_info.name}' is None in {self.__class__.__name__}. "
f"Field type: {field_info.field_type}"
)
else:
# Normal serialization for primitives/objects
if isinstance(value, KiCadObject):
result.append(value.to_sexpr())
elif isinstance(value, KiCadPrimitive):
# Serialize KiCad primitives using their to_sexpr method
# Only serialize if the primitive was actually found in parsing
if value.__found__:
sexpr = value.to_sexpr()
if sexpr is not None:
result.append(sexpr)
elif isinstance(value, OptionalFlag):
# Only add the flag to the result if it was found
if value.__found__:
if value.token_value:
result.append([value.token, value.token_value])
else:
result.append([value.token])
else:
# Primitives are added directly, not as named fields
# Convert enum to its value for serialization
if isinstance(value, Enum):
result.append(value.value)
else:
result.append(value)
return result
# def __eq__(self, other: object) -> bool:
# """Fast and robust equality comparison for KiCadObjects."""
# if not isinstance(other, KiCadObject):
# return False
# if self.__class__ != other.__class__:
# return False
# field_infos = self._classify_fields()
# for field_info in field_infos:
# self_value = getattr(self, field_info.name)
# other_value = getattr(other, field_info.name)
# if (self_value is None) != (other_value is None):
# return False
# if self_value is None and other_value is None:
# continue
# if not isinstance(other_value, type(self_value)):
# return False
# if isinstance(self_value, list):
# if len(self_value) != len(other_value):
# return False
# for self_item, other_item in zip(self_value, other_value):
# if isinstance(self_item, KiCadObject):
# if not self_item.__eq__(other_item): # Recursive comparison
# return False
# else:
# if self_item != other_item:
# return False
# elif isinstance(self_value, KiCadObject):
# if not self_value.__eq__(other_value): # Recursive comparison
# return False
# else:
# if self_value != other_value:
# return False
# return True
# def __hash__(self) -> int:
# """Hash implementation - required when implementing __eq__."""
# return hash((self.__class__.__name__, self.__token_name__))
[docs]
def to_sexpr_str(self, _indent_level: int = 0) -> str:
"""Convert to KiCad-formatted S-expression string using to_sexpr() with custom formatting.
Args:
_indent_level: Internal parameter for recursion depth
Returns:
Formatted S-expression string
"""
sexpr = self.to_sexpr()
return self._format_sexpr_kicad_style(sexpr, _indent_level)
def _format_sexpr_kicad_style(self, sexpr: Any, indent_level: int = 0) -> str:
"""Format S-expression in KiCad style with tabs and unquoted tokens."""
if not isinstance(sexpr, list):
return self._format_primitive_value(sexpr)
if not sexpr:
return "()"
current_indent = "\t" * indent_level
token_name = str(sexpr[0])
if len(sexpr) == 1:
return f"{current_indent}({token_name})"
# Separate primitives and nested lists
primitive_values = []
nested_lists = []
for item in sexpr[1:]:
if isinstance(item, list):
nested_lists.append(item)
else:
# Special handling for Type and Uuid tokens - don't quote their values
if token_name in ("type", "uuid") and isinstance(item, str):
primitive_values.append(item) # No quotes for Type/Uuid values
else:
primitive_values.append(self._format_primitive_value(item))
# Check for single line format: only primitives and short enough
if not nested_lists and len(sexpr) <= 4:
all_items = [token_name] + primitive_values
return f"{current_indent}({' '.join(all_items)})"
# Multi-line format: primitives on first line, nested lists indented
primitive_part = f" {' '.join(primitive_values)}" if primitive_values else ""
lines = [f"{current_indent}({token_name}{primitive_part}"]
for nested_item in nested_lists:
nested_formatted = self._format_sexpr_kicad_style(
nested_item, indent_level + 1
)
lines.append(nested_formatted)
lines.append(f"{current_indent})")
return "\n".join(lines)
def _format_primitive_value(self, value: Any) -> str:
"""Format primitive values for S-expression serialization."""
if isinstance(value, bool):
return "yes" if value else "no"
elif isinstance(value, Enum):
return str(value.value) # Enum values without quotes
elif isinstance(value, str):
# Check if this is a boolean-like token value (yes/no) - don't quote these
if value in ("yes", "no"):
return value
# Escape backslashes first, then quotes (order is important!)
escaped_value = value.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped_value}"' # Regular strings are quoted
else:
return str(value) # Numbers and other values as-is
[docs]
def __str__(self) -> str:
"""String representation showing only non-None values (except for required fields)."""
field_infos = self._classify_fields()
field_defaults = self._get_field_defaults()
non_none_fields = []
for field_info in field_infos:
value = getattr(self, field_info.name)
# Check if field is optional
is_optional_field = field_info.field_type in (
FieldType.OPTIONAL_PRIMITIVE,
FieldType.OPTIONAL_KICAD_OBJECT,
FieldType.OPTIONAL_KICAD_PRIMITIVE,
FieldType.OPTIONAL_FLAG,
)
has_default = field_info.name in field_defaults
# Show field if:
# 1. Value is not None (for any field), OR
# 2. Field is required (not optional and no default) even if None
# Skip optional fields that are None
if is_optional_field and value is None:
continue
elif value is not None or (not is_optional_field and not has_default):
# Format value for display
if isinstance(value, list) and len(value) == 0:
# Skip empty lists for optional fields
if is_optional_field or has_default:
continue
display_value = "[]"
elif isinstance(value, OptionalFlag):
# Use OptionalFlag's own __str__ method
if value.__found__:
display_value = str(value)
else:
continue
elif isinstance(value, KiCadObject):
# Use the custom __str__ for nested KiCadObjects
display_value = str(value)
elif isinstance(value, KiCadPrimitive):
# Show KiCad primitive value with type info
display_value = f"{value.__class__.__name__}({value.value!r})"
elif isinstance(value, list):
# Handle lists of KiCadObjects recursively
if value and isinstance(value[0], KiCadObject):
formatted_items = [str(item) for item in value]
display_value = f"[{', '.join(formatted_items)}]"
else:
display_value = repr(value)
else:
display_value = repr(value)
non_none_fields.append(f"{field_info.name}={display_value}")
return f"{self.__class__.__name__}({', '.join(non_none_fields)})"
[docs]
@dataclass
class OptionalFlag:
"""Enhanced flag container for optional tokens in S-expressions.
Can handle:
1. Simple presence flags: (locked) -> token="locked", is_token=True, token_value=None
2. Tokens with values: (locked yes) -> token="locked", is_token=True, token_value="yes"
3. Simple strings: "locked" -> token="locked", is_token=False, token_value=None
"""
token: str
is_token: bool = (
False # True if it was a token like (locked), False if simple string
)
token_value: Optional[str] = (
None # Additional value after token like "yes" in (locked yes)
)
__found__: bool = False
[docs]
def __str__(self) -> str:
"""Clean string representation."""
if self.is_token:
if self.token_value:
return (
f"OptionalFlag(({self.token} {self.token_value})={self.__found__})"
)
else:
return f"OptionalFlag(({self.token})={self.__found__})"
else:
return f"OptionalFlag({self.token}={self.__found__})"
[docs]
def __repr__(self) -> str:
"""Developer-friendly representation."""
parts = [f"'{self.token}'"]
if self.token_value is not None:
parts.append(f"value={repr(self.token_value)}")
if not self.is_token:
parts.append("string")
# Always show found status for consistency
parts.append(f"found={self.__found__}")
return f"OptionalFlag({', '.join(parts)})"
[docs]
def __eq__(self, other: object) -> bool:
"""Equality comparison for OptionalFlag objects."""
if not isinstance(other, OptionalFlag):
return False
return (
self.token == other.token
and self.is_token == other.is_token
and self.token_value == other.token_value
and self.__found__ == other.__found__
)
[docs]
def __hash__(self) -> int:
"""Hash implementation - required when implementing __eq__."""
return hash((self.token, self.is_token, self.token_value, self.__found__))
[docs]
def __bool__(self) -> bool:
"""Boolean conversion - returns the logical boolean value based on token_value."""
if not self.__found__:
return False
# If there's a token_value, interpret yes/no
if self.token_value:
return self.token_value.lower() in ("yes", "true", "1")
# If no token_value, the presence of the flag means True
return True
[docs]
def to_sexpr(self) -> str:
"""Convert back to S-expression format for round-trip."""
if not self.__found__:
return ""
if self.is_token:
if self.token_value:
return f"({self.token} {self.token_value})"
else:
return f"({self.token})"
else:
return self.token
[docs]
@classmethod
def create_bool_flag(cls, token: str) -> "OptionalFlag":
"""Create a simple boolean flag (presence indicates True)."""
return cls(token=token, is_token=True)