kicad-sch-api 0.3.4__py3-none-any.whl → 0.3.5__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.

Potentially problematic release.


This version of kicad-sch-api might be problematic. Click here for more details.

Files changed (34) hide show
  1. kicad_sch_api/collections/__init__.py +21 -0
  2. kicad_sch_api/collections/base.py +296 -0
  3. kicad_sch_api/collections/components.py +422 -0
  4. kicad_sch_api/collections/junctions.py +378 -0
  5. kicad_sch_api/collections/labels.py +412 -0
  6. kicad_sch_api/collections/wires.py +407 -0
  7. kicad_sch_api/core/labels.py +348 -0
  8. kicad_sch_api/core/nets.py +310 -0
  9. kicad_sch_api/core/no_connects.py +274 -0
  10. kicad_sch_api/core/schematic.py +136 -2
  11. kicad_sch_api/core/texts.py +343 -0
  12. kicad_sch_api/core/types.py +12 -0
  13. kicad_sch_api/geometry/symbol_bbox.py +26 -32
  14. kicad_sch_api/interfaces/__init__.py +17 -0
  15. kicad_sch_api/interfaces/parser.py +76 -0
  16. kicad_sch_api/interfaces/repository.py +70 -0
  17. kicad_sch_api/interfaces/resolver.py +117 -0
  18. kicad_sch_api/parsers/__init__.py +14 -0
  19. kicad_sch_api/parsers/base.py +148 -0
  20. kicad_sch_api/parsers/label_parser.py +254 -0
  21. kicad_sch_api/parsers/registry.py +153 -0
  22. kicad_sch_api/parsers/symbol_parser.py +227 -0
  23. kicad_sch_api/parsers/wire_parser.py +99 -0
  24. kicad_sch_api/symbols/__init__.py +18 -0
  25. kicad_sch_api/symbols/cache.py +470 -0
  26. kicad_sch_api/symbols/resolver.py +367 -0
  27. kicad_sch_api/symbols/validators.py +453 -0
  28. {kicad_sch_api-0.3.4.dist-info → kicad_sch_api-0.3.5.dist-info}/METADATA +1 -1
  29. kicad_sch_api-0.3.5.dist-info/RECORD +58 -0
  30. kicad_sch_api-0.3.4.dist-info/RECORD +0 -34
  31. {kicad_sch_api-0.3.4.dist-info → kicad_sch_api-0.3.5.dist-info}/WHEEL +0 -0
  32. {kicad_sch_api-0.3.4.dist-info → kicad_sch_api-0.3.5.dist-info}/entry_points.txt +0 -0
  33. {kicad_sch_api-0.3.4.dist-info → kicad_sch_api-0.3.5.dist-info}/licenses/LICENSE +0 -0
  34. {kicad_sch_api-0.3.4.dist-info → kicad_sch_api-0.3.5.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,343 @@
1
+ """
2
+ Text element management for KiCAD schematics.
3
+
4
+ This module provides collection classes for managing text elements,
5
+ featuring fast lookup, bulk operations, and validation.
6
+ """
7
+
8
+ import logging
9
+ import uuid
10
+ from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, Union
11
+
12
+ from ..utils.validation import SchematicValidator, ValidationError, ValidationIssue
13
+ from .types import Point, Text
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class TextElement:
19
+ """
20
+ Enhanced wrapper for schematic text elements with modern API.
21
+
22
+ Provides intuitive access to text properties and operations
23
+ while maintaining exact format preservation.
24
+ """
25
+
26
+ def __init__(self, text_data: Text, parent_collection: "TextCollection"):
27
+ """
28
+ Initialize text element wrapper.
29
+
30
+ Args:
31
+ text_data: Underlying text data
32
+ parent_collection: Parent collection for updates
33
+ """
34
+ self._data = text_data
35
+ self._collection = parent_collection
36
+ self._validator = SchematicValidator()
37
+
38
+ # Core properties with validation
39
+ @property
40
+ def uuid(self) -> str:
41
+ """Text element UUID."""
42
+ return self._data.uuid
43
+
44
+ @property
45
+ def text(self) -> str:
46
+ """Text content."""
47
+ return self._data.text
48
+
49
+ @text.setter
50
+ def text(self, value: str):
51
+ """Set text content with validation."""
52
+ if not isinstance(value, str):
53
+ raise ValidationError(f"Text content must be string, got {type(value)}")
54
+ self._data.text = value
55
+ self._collection._mark_modified()
56
+
57
+ @property
58
+ def position(self) -> Point:
59
+ """Text position."""
60
+ return self._data.position
61
+
62
+ @position.setter
63
+ def position(self, value: Union[Point, Tuple[float, float]]):
64
+ """Set text position."""
65
+ if isinstance(value, tuple):
66
+ value = Point(value[0], value[1])
67
+ elif not isinstance(value, Point):
68
+ raise ValidationError(f"Position must be Point or tuple, got {type(value)}")
69
+ self._data.position = value
70
+ self._collection._mark_modified()
71
+
72
+ @property
73
+ def rotation(self) -> float:
74
+ """Text rotation in degrees."""
75
+ return self._data.rotation
76
+
77
+ @rotation.setter
78
+ def rotation(self, value: float):
79
+ """Set text rotation."""
80
+ self._data.rotation = float(value)
81
+ self._collection._mark_modified()
82
+
83
+ @property
84
+ def size(self) -> float:
85
+ """Text size."""
86
+ return self._data.size
87
+
88
+ @size.setter
89
+ def size(self, value: float):
90
+ """Set text size with validation."""
91
+ if value <= 0:
92
+ raise ValidationError(f"Text size must be positive, got {value}")
93
+ self._data.size = float(value)
94
+ self._collection._mark_modified()
95
+
96
+ @property
97
+ def exclude_from_sim(self) -> bool:
98
+ """Whether text is excluded from simulation."""
99
+ return self._data.exclude_from_sim
100
+
101
+ @exclude_from_sim.setter
102
+ def exclude_from_sim(self, value: bool):
103
+ """Set exclude from simulation flag."""
104
+ self._data.exclude_from_sim = bool(value)
105
+ self._collection._mark_modified()
106
+
107
+ def validate(self) -> List[ValidationIssue]:
108
+ """Validate this text element."""
109
+ return self._validator.validate_text(self._data.__dict__)
110
+
111
+ def to_dict(self) -> Dict[str, Any]:
112
+ """Convert text element to dictionary representation."""
113
+ return {
114
+ "uuid": self.uuid,
115
+ "text": self.text,
116
+ "position": {"x": self.position.x, "y": self.position.y},
117
+ "rotation": self.rotation,
118
+ "size": self.size,
119
+ "exclude_from_sim": self.exclude_from_sim,
120
+ }
121
+
122
+ def __str__(self) -> str:
123
+ """String representation."""
124
+ return f"<Text '{self.text}' @ {self.position}>"
125
+
126
+
127
+ class TextCollection:
128
+ """
129
+ Collection class for efficient text element management.
130
+
131
+ Provides fast lookup, filtering, and bulk operations for schematic text elements.
132
+ """
133
+
134
+ def __init__(self, texts: List[Text] = None):
135
+ """
136
+ Initialize text collection.
137
+
138
+ Args:
139
+ texts: Initial list of text data
140
+ """
141
+ self._texts: List[TextElement] = []
142
+ self._uuid_index: Dict[str, TextElement] = {}
143
+ self._content_index: Dict[str, List[TextElement]] = {}
144
+ self._modified = False
145
+
146
+ # Add initial texts
147
+ if texts:
148
+ for text_data in texts:
149
+ self._add_to_indexes(TextElement(text_data, self))
150
+
151
+ logger.debug(f"TextCollection initialized with {len(self._texts)} texts")
152
+
153
+ def add(
154
+ self,
155
+ text: str,
156
+ position: Union[Point, Tuple[float, float]],
157
+ rotation: float = 0.0,
158
+ size: float = 1.27,
159
+ exclude_from_sim: bool = False,
160
+ text_uuid: Optional[str] = None,
161
+ ) -> TextElement:
162
+ """
163
+ Add a new text element to the schematic.
164
+
165
+ Args:
166
+ text: Text content
167
+ position: Text position
168
+ rotation: Text rotation in degrees
169
+ size: Text size
170
+ exclude_from_sim: Whether to exclude from simulation
171
+ text_uuid: Specific UUID for text (auto-generated if None)
172
+
173
+ Returns:
174
+ Newly created TextElement
175
+
176
+ Raises:
177
+ ValidationError: If text data is invalid
178
+ """
179
+ # Validate inputs
180
+ if not isinstance(text, str) or not text.strip():
181
+ raise ValidationError("Text content cannot be empty")
182
+
183
+ if isinstance(position, tuple):
184
+ position = Point(position[0], position[1])
185
+ elif not isinstance(position, Point):
186
+ raise ValidationError(f"Position must be Point or tuple, got {type(position)}")
187
+
188
+ if size <= 0:
189
+ raise ValidationError(f"Text size must be positive, got {size}")
190
+
191
+ # Generate UUID if not provided
192
+ if not text_uuid:
193
+ text_uuid = str(uuid.uuid4())
194
+
195
+ # Check for duplicate UUID
196
+ if text_uuid in self._uuid_index:
197
+ raise ValidationError(f"Text UUID {text_uuid} already exists")
198
+
199
+ # Create text data
200
+ text_data = Text(
201
+ uuid=text_uuid,
202
+ position=position,
203
+ text=text,
204
+ rotation=rotation,
205
+ size=size,
206
+ exclude_from_sim=exclude_from_sim,
207
+ )
208
+
209
+ # Create wrapper and add to collection
210
+ text_element = TextElement(text_data, self)
211
+ self._add_to_indexes(text_element)
212
+ self._mark_modified()
213
+
214
+ logger.debug(f"Added text: {text_element}")
215
+ return text_element
216
+
217
+ def get(self, text_uuid: str) -> Optional[TextElement]:
218
+ """Get text by UUID."""
219
+ return self._uuid_index.get(text_uuid)
220
+
221
+ def remove(self, text_uuid: str) -> bool:
222
+ """
223
+ Remove text by UUID.
224
+
225
+ Args:
226
+ text_uuid: UUID of text to remove
227
+
228
+ Returns:
229
+ True if text was removed, False if not found
230
+ """
231
+ text_element = self._uuid_index.get(text_uuid)
232
+ if not text_element:
233
+ return False
234
+
235
+ # Remove from indexes
236
+ self._remove_from_indexes(text_element)
237
+ self._mark_modified()
238
+
239
+ logger.debug(f"Removed text: {text_element}")
240
+ return True
241
+
242
+ def find_by_content(self, content: str, exact: bool = True) -> List[TextElement]:
243
+ """
244
+ Find texts by content.
245
+
246
+ Args:
247
+ content: Content to search for
248
+ exact: If True, exact match; if False, substring match
249
+
250
+ Returns:
251
+ List of matching text elements
252
+ """
253
+ if exact:
254
+ return self._content_index.get(content, []).copy()
255
+ else:
256
+ matches = []
257
+ for text_element in self._texts:
258
+ if content.lower() in text_element.text.lower():
259
+ matches.append(text_element)
260
+ return matches
261
+
262
+ def filter(self, predicate: Callable[[TextElement], bool]) -> List[TextElement]:
263
+ """
264
+ Filter texts by predicate function.
265
+
266
+ Args:
267
+ predicate: Function that returns True for texts to include
268
+
269
+ Returns:
270
+ List of texts matching predicate
271
+ """
272
+ return [text for text in self._texts if predicate(text)]
273
+
274
+ def bulk_update(self, criteria: Callable[[TextElement], bool], updates: Dict[str, Any]):
275
+ """
276
+ Update multiple texts matching criteria.
277
+
278
+ Args:
279
+ criteria: Function to select texts to update
280
+ updates: Dictionary of property updates
281
+ """
282
+ updated_count = 0
283
+ for text_element in self._texts:
284
+ if criteria(text_element):
285
+ for prop, value in updates.items():
286
+ if hasattr(text_element, prop):
287
+ setattr(text_element, prop, value)
288
+ updated_count += 1
289
+
290
+ if updated_count > 0:
291
+ self._mark_modified()
292
+ logger.debug(f"Bulk updated {updated_count} text properties")
293
+
294
+ def clear(self):
295
+ """Remove all texts from collection."""
296
+ self._texts.clear()
297
+ self._uuid_index.clear()
298
+ self._content_index.clear()
299
+ self._mark_modified()
300
+
301
+ def _add_to_indexes(self, text_element: TextElement):
302
+ """Add text to internal indexes."""
303
+ self._texts.append(text_element)
304
+ self._uuid_index[text_element.uuid] = text_element
305
+
306
+ # Add to content index
307
+ content = text_element.text
308
+ if content not in self._content_index:
309
+ self._content_index[content] = []
310
+ self._content_index[content].append(text_element)
311
+
312
+ def _remove_from_indexes(self, text_element: TextElement):
313
+ """Remove text from internal indexes."""
314
+ self._texts.remove(text_element)
315
+ del self._uuid_index[text_element.uuid]
316
+
317
+ # Remove from content index
318
+ content = text_element.text
319
+ if content in self._content_index:
320
+ self._content_index[content].remove(text_element)
321
+ if not self._content_index[content]:
322
+ del self._content_index[content]
323
+
324
+ def _mark_modified(self):
325
+ """Mark collection as modified."""
326
+ self._modified = True
327
+
328
+ # Collection interface methods
329
+ def __len__(self) -> int:
330
+ """Return number of texts."""
331
+ return len(self._texts)
332
+
333
+ def __iter__(self) -> Iterator[TextElement]:
334
+ """Iterate over texts."""
335
+ return iter(self._texts)
336
+
337
+ def __getitem__(self, index: int) -> TextElement:
338
+ """Get text by index."""
339
+ return self._texts[index]
340
+
341
+ def __bool__(self) -> bool:
342
+ """Return True if collection has texts."""
343
+ return len(self._texts) > 0
@@ -386,6 +386,18 @@ class Image:
386
386
  self.uuid = str(uuid4())
387
387
 
388
388
 
389
+ @dataclass
390
+ class NoConnect:
391
+ """No-connect symbol in schematic."""
392
+
393
+ uuid: str
394
+ position: Point
395
+
396
+ def __post_init__(self):
397
+ if not self.uuid:
398
+ self.uuid = str(uuid4())
399
+
400
+
389
401
  @dataclass
390
402
  class Net:
391
403
  """Electrical net connecting components."""
@@ -59,12 +59,9 @@ class SymbolBoundingBoxCalculator:
59
59
  if not symbol_data:
60
60
  raise ValueError("Symbol data is None or empty")
61
61
 
62
- import sys
63
- # Reduced logging frequency - only log if DEBUG environment variable is set
64
- debug_enabled = os.getenv("CIRCUIT_SYNTH_DEBUG", "").lower() == "true"
65
- if debug_enabled:
66
- print(f"\n=== CALCULATING BOUNDING BOX ===", file=sys.stderr, flush=True)
67
- print(f"include_properties={include_properties}", file=sys.stderr, flush=True)
62
+ # Use proper logging instead of print statements
63
+ logger.debug("=== CALCULATING BOUNDING BOX ===")
64
+ logger.debug(f"include_properties={include_properties}")
68
65
 
69
66
  min_x = float("inf")
70
67
  min_y = float("inf")
@@ -73,7 +70,7 @@ class SymbolBoundingBoxCalculator:
73
70
 
74
71
  # Process main symbol shapes (handle both 'shapes' and 'graphics' keys)
75
72
  shapes = symbol_data.get("shapes", []) or symbol_data.get("graphics", [])
76
- print(f"Processing {len(shapes)} main shapes", file=sys.stderr, flush=True)
73
+ logger.debug(f"Processing {len(shapes)} main shapes")
77
74
  for shape in shapes:
78
75
  shape_bounds = cls._get_shape_bounds(shape)
79
76
  if shape_bounds:
@@ -85,7 +82,7 @@ class SymbolBoundingBoxCalculator:
85
82
 
86
83
  # Process pins (including their labels)
87
84
  pins = symbol_data.get("pins", [])
88
- print(f"Processing {len(pins)} main pins", file=sys.stderr, flush=True)
85
+ logger.debug(f"Processing {len(pins)} main pins")
89
86
  for pin in pins:
90
87
  pin_bounds = cls._get_pin_bounds(pin, pin_net_map)
91
88
  if pin_bounds:
@@ -123,8 +120,8 @@ class SymbolBoundingBoxCalculator:
123
120
  if min_x == float("inf") or max_x == float("-inf"):
124
121
  raise ValueError(f"No valid geometry found in symbol data")
125
122
 
126
- print(f"After geometry processing: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})", file=sys.stderr, flush=True)
127
- print(f" Width: {max_x - min_x:.2f}, Height: {max_y - min_y:.2f}", file=sys.stderr, flush=True)
123
+ logger.debug(f"After geometry processing: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})")
124
+ logger.debug(f" Width: {max_x - min_x:.2f}, Height: {max_y - min_y:.2f}")
128
125
 
129
126
  # Add small margin for text that might extend beyond shapes
130
127
  margin = 0.254 # 10 mils
@@ -162,9 +159,9 @@ class SymbolBoundingBoxCalculator:
162
159
  f"Calculated bounding box: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})"
163
160
  )
164
161
 
165
- print(f"FINAL BBOX: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})", file=sys.stderr, flush=True)
166
- print(f" Width: {max_x - min_x:.2f}, Height: {max_y - min_y:.2f}", file=sys.stderr, flush=True)
167
- print("=" * 50 + "\n", file=sys.stderr, flush=True)
162
+ logger.debug(f"FINAL BBOX: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})")
163
+ logger.debug(f" Width: {max_x - min_x:.2f}, Height: {max_y - min_y:.2f}")
164
+ logger.debug("=" * 50)
168
165
 
169
166
  return (min_x, min_y, max_x, max_y)
170
167
 
@@ -196,8 +193,7 @@ class SymbolBoundingBoxCalculator:
196
193
  if not symbol_data:
197
194
  raise ValueError("Symbol data is None or empty")
198
195
 
199
- import sys
200
- print(f"\n=== CALCULATING PLACEMENT BOUNDING BOX (NO PIN LABELS) ===", file=sys.stderr, flush=True)
196
+ logger.debug("=== CALCULATING PLACEMENT BOUNDING BOX (NO PIN LABELS) ===")
201
197
 
202
198
  min_x = float("inf")
203
199
  min_y = float("inf")
@@ -206,7 +202,7 @@ class SymbolBoundingBoxCalculator:
206
202
 
207
203
  # Process main symbol shapes (handle both 'shapes' and 'graphics' keys)
208
204
  shapes = symbol_data.get("shapes", []) or symbol_data.get("graphics", [])
209
- print(f"Processing {len(shapes)} main shapes", file=sys.stderr, flush=True)
205
+ logger.debug(f"Processing {len(shapes)} main shapes")
210
206
  for shape in shapes:
211
207
  shape_bounds = cls._get_shape_bounds(shape)
212
208
  if shape_bounds:
@@ -218,7 +214,7 @@ class SymbolBoundingBoxCalculator:
218
214
 
219
215
  # Process pins WITHOUT labels (just pin endpoints)
220
216
  pins = symbol_data.get("pins", [])
221
- print(f"Processing {len(pins)} main pins (NO LABELS)", file=sys.stderr, flush=True)
217
+ logger.debug(f"Processing {len(pins)} main pins (NO LABELS)")
222
218
  for pin in pins:
223
219
  pin_bounds = cls._get_pin_bounds_no_labels(pin)
224
220
  if pin_bounds:
@@ -256,8 +252,8 @@ class SymbolBoundingBoxCalculator:
256
252
  if min_x == float("inf") or max_x == float("-inf"):
257
253
  raise ValueError(f"No valid geometry found in symbol data")
258
254
 
259
- print(f"After geometry processing: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})", file=sys.stderr, flush=True)
260
- print(f" Width: {max_x - min_x:.2f}, Height: {max_y - min_y:.2f}", file=sys.stderr, flush=True)
255
+ logger.debug(f"After geometry processing: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})")
256
+ logger.debug(f" Width: {max_x - min_x:.2f}, Height: {max_y - min_y:.2f}")
261
257
 
262
258
  # Add small margin for visual spacing
263
259
  margin = 0.635 # 25mil margin (reduced from 50mil)
@@ -274,9 +270,9 @@ class SymbolBoundingBoxCalculator:
274
270
  min_y -= property_spacing + property_height # Reference above
275
271
  max_y += property_spacing + property_height # Value below
276
272
 
277
- print(f"FINAL PLACEMENT BBOX: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})", file=sys.stderr, flush=True)
278
- print(f" Width: {max_x - min_x:.2f}, Height: {max_y - min_y:.2f}", file=sys.stderr, flush=True)
279
- print("=" * 50 + "\n", file=sys.stderr, flush=True)
273
+ logger.debug(f"FINAL PLACEMENT BBOX: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})")
274
+ logger.debug(f" Width: {max_x - min_x:.2f}, Height: {max_y - min_y:.2f}")
275
+ logger.debug("=" * 50)
280
276
 
281
277
  return (min_x, min_y, max_x, max_y)
282
278
 
@@ -459,7 +455,6 @@ class SymbolBoundingBoxCalculator:
459
455
  cls, pin: Dict[str, Any], pin_net_map: Optional[Dict[str, str]] = None
460
456
  ) -> Optional[Tuple[float, float, float, float]]:
461
457
  """Get bounding box for a pin including its labels."""
462
- import sys
463
458
 
464
459
  # Handle both formats: 'at' array or separate x/y/orientation
465
460
  if "at" in pin:
@@ -493,11 +488,11 @@ class SymbolBoundingBoxCalculator:
493
488
  # If no net name match, use minimal fallback to avoid oversized bounding boxes
494
489
  if pin_net_map and pin_number in pin_net_map:
495
490
  label_text = pin_net_map[pin_number]
496
- print(f" PIN {pin_number}: ✅ USING NET '{label_text}' (len={len(label_text)}), at=({x:.2f}, {y:.2f}), angle={angle}", file=sys.stderr, flush=True)
491
+ logger.debug(f" PIN {pin_number}: ✅ USING NET '{label_text}' (len={len(label_text)}), at=({x:.2f}, {y:.2f}), angle={angle}")
497
492
  else:
498
493
  # No net match - use minimal size (3 chars) instead of potentially long pin name
499
494
  label_text = "XXX" # 3-character placeholder for unmatched pins
500
- print(f" PIN {pin_number}: ⚠️ NO MATCH, using minimal fallback (pin name was '{pin_name}'), at=({x:.2f}, {y:.2f})", file=sys.stderr, flush=True)
495
+ logger.debug(f" PIN {pin_number}: ⚠️ NO MATCH, using minimal fallback (pin name was '{pin_name}'), at=({x:.2f}, {y:.2f})")
501
496
 
502
497
  if label_text and label_text != "~": # ~ means no name
503
498
  # Calculate text dimensions
@@ -510,7 +505,7 @@ class SymbolBoundingBoxCalculator:
510
505
  # For vertical text: height = char_count * char_height (characters stack vertically)
511
506
  name_height = len(label_text) * cls.DEFAULT_TEXT_HEIGHT
512
507
 
513
- print(f" label_width={name_width:.2f}, label_height={name_height:.2f} (len={len(label_text)})", file=sys.stderr, flush=True)
508
+ logger.debug(f" label_width={name_width:.2f}, label_height={name_height:.2f} (len={len(label_text)})")
514
509
 
515
510
  # Adjust bounds based on pin orientation
516
511
  # Labels are placed at PIN ENDPOINT with offset, extending AWAY from the component
@@ -520,19 +515,19 @@ class SymbolBoundingBoxCalculator:
520
515
 
521
516
  if angle == 0: # Pin points right - label extends LEFT from endpoint
522
517
  label_x = end_x - offset - name_width
523
- print(f" Angle 0 (Right pin): min_x {min_x:.2f} -> {label_x:.2f} (offset={offset:.3f})", file=sys.stderr, flush=True)
518
+ logger.debug(f" Angle 0 (Right pin): min_x {min_x:.2f} -> {label_x:.2f} (offset={offset:.3f})")
524
519
  min_x = min(min_x, label_x)
525
520
  elif angle == 180: # Pin points left - label extends RIGHT from endpoint
526
521
  label_x = end_x + offset + name_width
527
- print(f" Angle 180 (Left pin): max_x {max_x:.2f} -> {label_x:.2f} (offset={offset:.3f})", file=sys.stderr, flush=True)
522
+ logger.debug(f" Angle 180 (Left pin): max_x {max_x:.2f} -> {label_x:.2f} (offset={offset:.3f})")
528
523
  max_x = max(max_x, label_x)
529
524
  elif angle == 90: # Pin points up - label extends DOWN from endpoint
530
525
  label_y = end_y - offset - name_height
531
- print(f" Angle 90 (Up pin): min_y {min_y:.2f} -> {label_y:.2f} (offset={offset:.3f})", file=sys.stderr, flush=True)
526
+ logger.debug(f" Angle 90 (Up pin): min_y {min_y:.2f} -> {label_y:.2f} (offset={offset:.3f})")
532
527
  min_y = min(min_y, label_y)
533
528
  elif angle == 270: # Pin points down - label extends UP from endpoint
534
529
  label_y = end_y + offset + name_height
535
- print(f" Angle 270 (Down pin): max_y {max_y:.2f} -> {label_y:.2f} (offset={offset:.3f})", file=sys.stderr, flush=True)
530
+ logger.debug(f" Angle 270 (Down pin): max_y {max_y:.2f} -> {label_y:.2f} (offset={offset:.3f})")
536
531
  max_y = max(max_y, label_y)
537
532
 
538
533
  # Pin numbers are typically placed near the component body
@@ -551,7 +546,7 @@ class SymbolBoundingBoxCalculator:
551
546
  max_x += margin
552
547
  max_y += margin
553
548
 
554
- print(f" Pin bounds: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})", file=sys.stderr, flush=True)
549
+ logger.debug(f" Pin bounds: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})")
555
550
  return (min_x, min_y, max_x, max_y)
556
551
 
557
552
  @classmethod
@@ -559,7 +554,6 @@ class SymbolBoundingBoxCalculator:
559
554
  cls, pin: Dict[str, Any]
560
555
  ) -> Optional[Tuple[float, float, float, float]]:
561
556
  """Get bounding box for a pin WITHOUT labels - for placement calculations only."""
562
- import sys
563
557
 
564
558
  # Handle both formats: 'at' array or separate x/y/orientation
565
559
  if "at" in pin:
@@ -0,0 +1,17 @@
1
+ """
2
+ Core interfaces for KiCAD schematic API.
3
+
4
+ This module provides abstract interfaces for the main components of the system,
5
+ enabling better separation of concerns and testability.
6
+ """
7
+
8
+ from .parser import IElementParser, ISchematicParser
9
+ from .repository import ISchematicRepository
10
+ from .resolver import ISymbolResolver
11
+
12
+ __all__ = [
13
+ "IElementParser",
14
+ "ISchematicParser",
15
+ "ISchematicRepository",
16
+ "ISymbolResolver",
17
+ ]
@@ -0,0 +1,76 @@
1
+ """
2
+ Parser interfaces for S-expression elements.
3
+
4
+ These interfaces define the contract for parsing different types of KiCAD
5
+ S-expression elements, enabling modular and testable parsing.
6
+ """
7
+
8
+ from abc import ABC, abstractmethod
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional, Protocol, Union
11
+
12
+
13
+ class IElementParser(Protocol):
14
+ """Interface for parsing individual S-expression elements."""
15
+
16
+ def can_parse(self, element: List[Any]) -> bool:
17
+ """
18
+ Check if this parser can handle the given S-expression element.
19
+
20
+ Args:
21
+ element: S-expression element (list with type as first item)
22
+
23
+ Returns:
24
+ True if this parser can handle the element type
25
+ """
26
+ ...
27
+
28
+ def parse(self, element: List[Any]) -> Optional[Dict[str, Any]]:
29
+ """
30
+ Parse an S-expression element into a dictionary representation.
31
+
32
+ Args:
33
+ element: S-expression element to parse
34
+
35
+ Returns:
36
+ Parsed element as dictionary, or None if parsing failed
37
+
38
+ Raises:
39
+ ParseError: If element is malformed or cannot be parsed
40
+ """
41
+ ...
42
+
43
+
44
+ class ISchematicParser(Protocol):
45
+ """Interface for high-level schematic parsing operations."""
46
+
47
+ def parse_file(self, filepath: Union[str, Path]) -> Dict[str, Any]:
48
+ """
49
+ Parse a complete KiCAD schematic file.
50
+
51
+ Args:
52
+ filepath: Path to the .kicad_sch file
53
+
54
+ Returns:
55
+ Complete schematic data structure
56
+
57
+ Raises:
58
+ FileNotFoundError: If file doesn't exist
59
+ ParseError: If file format is invalid
60
+ """
61
+ ...
62
+
63
+ def parse_string(self, content: str) -> Dict[str, Any]:
64
+ """
65
+ Parse schematic content from a string.
66
+
67
+ Args:
68
+ content: S-expression content as string
69
+
70
+ Returns:
71
+ Complete schematic data structure
72
+
73
+ Raises:
74
+ ParseError: If content format is invalid
75
+ """
76
+ ...