##############################
# File: src/circuit_synth/core/component.py
##############################
import keyword
import os
import re
import warnings
from dataclasses import dataclass, field
from typing import Any, Dict, Iterator, List, Optional, Union
from ._logger import context_logger
from .decorators import get_current_circuit
from .exception import (
CircuitSynthError,
ComponentError,
LibraryNotFound,
ValidationError,
)
from .pin import Pin
from .simple_pin_access import PinGroup, SimplifiedPinAccess
# NOTE: SymbolLibCache is imported lazily in __post_init__ to avoid circular imports
# DO NOT import it at module level
[docs]
@dataclass
class Component(SimplifiedPinAccess):
"""
Represents an electronic component that references a KiCad symbol.
If multiple pins share the same name (e.g. "GND"), __getitem__ returns a PinGroup,
so something like:
comp["GND"] += net
will connect *all* GND pins to that net.
Additional fields can be passed as keyword arguments and will be stored
in _extra_fields for later access (e.g., mfg_part_num, tolerance, etc.).
"""
symbol: str
ref: Optional[str] = None
value: Optional[str] = None
footprint: Optional[str] = None
datasheet: Optional[str] = None
description: Optional[str] = None
_extra_fields: Dict[str, Any] = field(default_factory=dict, repr=False)
# Store pins by integer index
_pins: Dict[str, Pin] = field(
default_factory=dict, init=False, repr=False
) # Now keyed by pin number instead of index
# For name-based lookup, store lists of pins by name
_pin_names: Dict[str, List[Pin]] = field(
default_factory=dict, init=False, repr=False
)
_is_prefix: bool = field(default=False, init=False, repr=False)
_user_reference: str = field(default="", init=False, repr=False)
ALLOWED_REASSIGN = {"value"}
[docs]
def __init__(
self,
symbol: str,
ref: Optional[str] = None,
value: Optional[str] = None,
footprint: Optional[str] = None,
datasheet: Optional[str] = None,
description: Optional[str] = None,
**kwargs,
):
"""
Initialize Component with support for arbitrary additional fields.
Args:
symbol: KiCad symbol reference (e.g., "Device:R")
ref: Component reference (e.g., "R1")
value: Component value (e.g., "10k")
footprint: KiCad footprint reference
datasheet: URL to component datasheet
description: Component description
**kwargs: Additional fields (e.g., mfg_part_num, tolerance, etc.)
"""
# Initialize the fields that are normally handled by dataclass first
self._extra_fields = {}
self._pins = {}
self._pin_names = {}
self._is_prefix = False
self._user_reference = ""
# Set the standard dataclass fields (this will trigger __setattr__ for ref)
self.symbol = symbol
self.ref = ref
self.value = value
self.footprint = footprint
self.datasheet = datasheet
self.description = description
# Store any additional keyword arguments in _extra_fields (validation happens later)
for key, value in kwargs.items():
self._extra_fields[key] = value
# Call the original post_init logic
self.__post_init__()
# Now validate the extra fields after the object is fully initialized
for key, value in kwargs.items():
try:
self._validate_property(key, value)
except ValidationError as e:
raise ValidationError(f"Invalid property '{key}': {e}")
def __post_init__(self):
self._validate_symbol(self.symbol)
# Lazy import to avoid circular dependency
# (kicad_symbol_cache may import from core during initialization)
try:
from ..kicad.kicad_symbol_cache import SymbolLibCache
except ImportError as e:
context_logger.error(
f"Failed to import SymbolLibCache: {e}",
component="COMPONENT",
)
# Create empty component without pins
return
# Instead of using SharedParserManager + parse_symbol,
# we load flattened data from the SymbolLibCache
try:
symbol_data = SymbolLibCache.get_symbol_data(self.symbol) # e.g. "Device:C"
context_logger.debug(
"Loaded symbol data from cache",
component="COMPONENT",
symbol=self.symbol,
has_pins="pins" in symbol_data,
pin_count=(
len(symbol_data.get("pins", [])) if "pins" in symbol_data else 0
),
)
except FileNotFoundError as e:
context_logger.error(
"Library file not found for symbol",
component="COMPONENT",
symbol=self.symbol,
error=str(e),
)
raise LibraryNotFound(f"Failed to load symbol '{self.symbol}': {e}")
except KeyError as e:
context_logger.error(
"Symbol not found in library",
component="COMPONENT",
symbol=self.symbol,
error=str(e),
)
raise LibraryNotFound(f"Symbol not found: '{self.symbol}': {e}")
except Exception as e:
# fallback for anything else
context_logger.error(
"Error while loading symbol from cache",
component="COMPONENT",
symbol=self.symbol,
error=str(e),
)
raise LibraryNotFound(f"Failed to load symbol '{self.symbol}': {e}")
# Store standard properties from symbol_data if not already set on component
if self.description is None:
self.description = symbol_data.get("description")
if self.datasheet is None:
self.datasheet = symbol_data.get("datasheet")
# Store keywords and filters in _extra_fields for later use
if "keywords" not in self._extra_fields and symbol_data.get("keywords"):
self._extra_fields["ki_keywords"] = symbol_data.get("keywords")
if "ki_fp_filters" not in self._extra_fields and symbol_data.get("fp_filters"):
self._extra_fields["ki_fp_filters"] = symbol_data.get("fp_filters")
# Clear any old pin data in case we re-init
self._pins.clear()
self._pin_names.clear()
# Build pin objects from the symbol_data
# symbol_data["pins"] is typically a list of dicts:
# [ {"function": "passive", "name": "~", "number": "1", ...}, ... ]
pins_loaded = 0
for pin_info in symbol_data.get("pins", []):
pin_num = pin_info.get("number", "")
if not pin_num: # Skip pins without numbers
context_logger.warning(
"Skipping pin without number in symbol",
component="COMPONENT",
symbol=self.symbol,
)
continue
new_pin = Pin(
name=pin_info.get("name", "~"),
num=pin_num,
func=pin_info.get("function", "passive"),
unit=1,
x=pin_info.get("x", 0),
y=pin_info.get("y", 0),
length=pin_info.get("length", 2.54),
orientation=pin_info.get("orientation", 0),
)
new_pin._component = self
new_pin._component_pin_id = int(pin_num) if pin_num.isdigit() else 0
# Store by pin number
self._pins[pin_num] = new_pin
pins_loaded += 1
# Also store by pin name
nm = new_pin.name
if nm and nm not in ("~", ""):
if nm not in self._pin_names:
self._pin_names[nm] = []
self._pin_names[nm].append(new_pin)
context_logger.debug(
"Component pins loaded",
component="COMPONENT",
symbol=self.symbol,
ref=self.ref,
pins_loaded=pins_loaded,
pin_numbers=list(self._pins.keys()),
pin_names=list(self._pin_names.keys()),
)
# Handle case where no reference is provided
if not self.ref:
# No reference provided - this is allowed for template components
self._user_reference = ""
self._is_prefix = True
return
# Validate non-empty string references
if not self.ref.strip():
raise ValidationError("Reference must be a non-empty string")
# Use the reference provided in constructor
user_ref = self.ref.strip()
# Only set _user_reference and _is_prefix if not already set by __setattr__
if not hasattr(self, "_user_reference"):
self._user_reference = user_ref
# Check if final or prefix-only
trailing_digits = re.search(r"\d+$", user_ref)
self._is_prefix = not bool(trailing_digits)
context_logger.debug(
"Component detected reference",
component="COMPONENT",
symbol=self.symbol,
reference_type="final" if not self._is_prefix else "prefix",
reference=user_ref,
)
# See if there's an active circuit
circuit = get_current_circuit()
if circuit is None:
# No active circuit - this is OK for template components
context_logger.debug(
"No active circuit for component - can be used as template",
component="COMPONENT",
reference=self.ref,
symbol=self.symbol,
)
return
# If we do have an active circuit, add the component
circuit.add_component(self)
def _validate_symbol(self, symbol: str):
if not symbol or not isinstance(symbol, str):
raise CircuitSynthError("Invalid symbol format: Must be non-empty string.")
parts = symbol.split(":")
if len(parts) != 2 or not parts[0] or not parts[1]:
raise CircuitSynthError(
f"Invalid symbol format '{symbol}': Should be 'Library:Symbol'"
)
def __getattr__(self, name: str) -> Any:
if name in self._extra_fields:
return self._extra_fields[name]
raise AttributeError(
f"'{type(self).__name__}' object has no attribute '{name}'"
)
def __setattr__(self, name: str, value: Any) -> None:
if name == "_validate_property":
super().__setattr__(name, value)
return
# Special handling for ref assignments
if name == "ref":
# Allow None values during initialization, but validate string references
if value is not None and (not isinstance(value, str) or not value.strip()):
raise ValidationError("Reference must be a non-empty string")
# Store the user's reference (handle None values)
if value is None:
super().__setattr__("_user_reference", "")
super().__setattr__(
"_is_prefix", True
) # Default to prefix when no reference
super().__setattr__("ref", None) # Update the dataclass field
context_logger.debug(
"Component reference set to None", component="COMPONENT"
)
else:
cleaned_value = value.strip()
super().__setattr__("_user_reference", cleaned_value)
# Check if it's a final reference (has trailing digits)
trailing_digits = re.search(r"\d+$", cleaned_value)
super().__setattr__("_is_prefix", not bool(trailing_digits))
super().__setattr__("ref", cleaned_value) # Update the dataclass field
context_logger.debug(
"Component reference set",
component="COMPONENT",
reference=value,
reference_type="final" if not self._is_prefix else "prefix",
)
return # Don't process as dataclass field after special handling
# Dataclass fields
if name in self.__dataclass_fields__:
super().__setattr__(name, value)
return
# If it's a known attribute name, disallow changing except "value"
if (name in dir(self)) and (name not in self.ALLOWED_REASSIGN):
raise ValidationError("collides with existing attribute")
# Validate new property name
self._validate_property(name, value)
if not hasattr(self, "_extra_fields"):
super().__setattr__("_extra_fields", {})
self._extra_fields[name] = value
def _validate_property(self, name: str, value: Any) -> None:
if name.startswith("_"):
raise ValidationError("leading underscore")
if name and name[0].isdigit():
raise ValidationError("start with a digit")
if keyword.iskeyword(name):
raise ValidationError("reserved keyword")
if not re.match(r"^[A-Za-z][A-Za-z0-9_]*$", name):
raise ValidationError("invalid characters")
if isinstance(value, str) and not value.strip():
raise ValidationError("cannot be empty")
# Pin access methods are now inherited from SimplifiedPinAccess
[docs]
def __call__(self, *args, **kwargs):
"""
Cloning a prefix-based ref is allowed, but final references are not.
"""
if re.search(r"\d+$", self.ref or ""):
raise ComponentError(
"trailing digits in ref, can't clone from a final ref. "
"Please supply a new prefix or final ref manually."
)
# If we're cloning a template component (no ref) inside a circuit context,
# generate a default prefix based on the symbol
ref_to_use = self.ref
if not ref_to_use and get_current_circuit() is not None:
# Generate a default prefix based on the component symbol
symbol_parts = self.symbol.split(":")
if len(symbol_parts) >= 2:
# Use the symbol name as prefix (e.g., "Device:R" -> "R")
default_prefix = symbol_parts[1]
# Clean up the prefix to be a valid reference
default_prefix = re.sub(r"[^A-Za-z0-9_]", "", default_prefix)
if default_prefix and default_prefix[0].isalpha():
ref_to_use = default_prefix
else:
ref_to_use = "U" # Generic fallback
else:
ref_to_use = "U" # Generic fallback
new_c = type(self)(
symbol=self.symbol,
ref=ref_to_use,
value=self.value,
footprint=self.footprint,
datasheet=self.datasheet,
description=self.description,
)
for k, v in self._extra_fields.items():
setattr(new_c, k, v)
# The unified pin access is automatically initialized in __post_init__
return new_c
def __mul__(self, count: int):
if count < 1:
raise ComponentError("Multiplication count must be >= 1.")
return tuple(self() for _ in range(count))
def __iter__(self) -> Iterator[Pin]:
return iter(self._pins.values())
def __len__(self) -> int:
return len(self._pins)
def __str__(self) -> str:
desc = self.value if self.value else self.symbol
return f"{self.ref} ({desc})"
[docs]
def to_dict(self) -> Dict[str, Any]:
# Prepare properties dict, including those stored in _extra_fields
properties = {}
if "ki_keywords" in self._extra_fields:
properties["ki_keywords"] = self._extra_fields["ki_keywords"]
if "ki_fp_filters" in self._extra_fields:
properties["ki_fp_filters"] = self._extra_fields["ki_fp_filters"]
# Add other relevant fields from _extra_fields if needed in the future
data = {
"symbol": self.symbol or "",
"ref": self.ref or "",
"value": self.value or "",
"footprint": self.footprint or "",
"datasheet": self.datasheet or "", # Ensure empty string instead of None
"description": self.description
or "", # Ensure empty string instead of None
"properties": properties, # Add standard properties dict
"_extra_fields": dict(
self._extra_fields
), # Keep original for now if needed elsewhere
"pins": [],
}
# Add all extra fields to the top level for easy access
for key, value in self._extra_fields.items():
if key not in data and key not in ["ki_keywords", "ki_fp_filters"]:
data[key] = value
# Sort pins by number for consistent output
sorted_pins = sorted(
self._pins.items(),
key=lambda x: int(x[0]) if x[0].isdigit() else float("inf"),
)
for pin_num, pin_obj in sorted_pins:
data["pins"].append(
{
"pin_id": pin_num, # Use pin number instead of index
"name": pin_obj.name,
"func": pin_obj.func,
"unit": pin_obj.unit,
"x": pin_obj.x,
"y": pin_obj.y,
"length": pin_obj.length,
"orientation": pin_obj.orientation,
}
)
return data
[docs]
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Component":
if "symbol" not in data:
raise ValidationError("symbol field missing")
if "ref" not in data:
raise ValidationError("ref field missing")
comp = cls(
symbol=data["symbol"],
ref=data["ref"],
value=data.get("value"),
footprint=data.get("footprint"),
datasheet=data.get("datasheet"),
description=data.get("description"),
)
known_top = {
"symbol",
"ref",
"value",
"footprint",
"datasheet",
"description",
"pins",
"_extra_fields",
}
for k, v in data.items():
if k not in known_top:
setattr(comp, k, v)
for k, v in data.get("_extra_fields", {}).items():
setattr(comp, k, v)
comp._pins.clear()
comp._pin_names.clear()
for pinfo in data.get("pins", []):
pin_num = pinfo.get("number", pinfo.get("num", ""))
if not pin_num: # Skip pins without numbers
context_logger.warning(
"Skipping pin without number in component",
component="COMPONENT",
component_ref=comp.ref,
)
continue
if pin_num in comp._pins:
raise ComponentError(f"Duplicate pin number '{pin_num}' in from_dict")
pin_obj = Pin(
name=pinfo.get("name", "~"),
num=pin_num,
func=pinfo.get("func", "unspecified"),
unit=pinfo.get("unit", 1),
x=pinfo.get("x", 0),
y=pinfo.get("y", 0),
length=pinfo.get("length", 0),
orientation=pinfo.get("orientation", 0),
)
pin_obj._component = comp
pin_obj._component_pin_id = int(pin_num) if pin_num.isdigit() else 0
# Store by pin number
comp._pins[pin_num] = pin_obj
# Also store by name if it has one
pin_name = pinfo.get("name")
if pin_name and pin_name not in ("~", ""):
comp._pin_names.setdefault(pin_name, []).append(pin_obj)
n = pin_obj.name
if n and n not in ("~", ""):
if n not in comp._pin_names:
comp._pin_names[n] = []
comp._pin_names[n].append(pin_obj)
return comp
# NEW: Provide a helper to list all pins for debugging
[docs]
def print_pin_names(self) -> str:
"""
Returns a formatted string of all pins, showing index, name, and number.
Useful for debugging symbol pin naming mismatches.
"""
lines = []
lines.append(
f"--- Pin listing for Component '{self.ref}' (symbol='{self.symbol}') ---"
)
for idx, pin in sorted(self._pins.items(), key=lambda x: x[0]):
lines.append(f" Pin index={idx}, name='{pin.name}', number='{pin.num}'")
return "\n".join(lines)