kicad-sch-api 0.3.5__py3-none-any.whl → 0.4.1__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.
Files changed (81) hide show
  1. kicad_sch_api/__init__.py +2 -2
  2. kicad_sch_api/cli/__init__.py +45 -0
  3. kicad_sch_api/cli/base.py +302 -0
  4. kicad_sch_api/cli/bom.py +164 -0
  5. kicad_sch_api/cli/erc.py +229 -0
  6. kicad_sch_api/cli/export_docs.py +289 -0
  7. kicad_sch_api/cli/netlist.py +94 -0
  8. kicad_sch_api/cli/types.py +43 -0
  9. kicad_sch_api/collections/__init__.py +2 -2
  10. kicad_sch_api/collections/base.py +5 -7
  11. kicad_sch_api/collections/components.py +24 -12
  12. kicad_sch_api/collections/junctions.py +31 -43
  13. kicad_sch_api/collections/labels.py +19 -27
  14. kicad_sch_api/collections/wires.py +17 -18
  15. kicad_sch_api/core/collections/__init__.py +5 -0
  16. kicad_sch_api/core/collections/base.py +248 -0
  17. kicad_sch_api/core/component_bounds.py +5 -0
  18. kicad_sch_api/core/components.py +67 -45
  19. kicad_sch_api/core/config.py +85 -3
  20. kicad_sch_api/core/factories/__init__.py +5 -0
  21. kicad_sch_api/core/factories/element_factory.py +276 -0
  22. kicad_sch_api/core/formatter.py +3 -1
  23. kicad_sch_api/core/junctions.py +26 -75
  24. kicad_sch_api/core/labels.py +29 -53
  25. kicad_sch_api/core/managers/__init__.py +26 -0
  26. kicad_sch_api/core/managers/file_io.py +244 -0
  27. kicad_sch_api/core/managers/format_sync.py +501 -0
  28. kicad_sch_api/core/managers/graphics.py +579 -0
  29. kicad_sch_api/core/managers/metadata.py +269 -0
  30. kicad_sch_api/core/managers/sheet.py +454 -0
  31. kicad_sch_api/core/managers/text_elements.py +536 -0
  32. kicad_sch_api/core/managers/validation.py +475 -0
  33. kicad_sch_api/core/managers/wire.py +352 -0
  34. kicad_sch_api/core/nets.py +38 -43
  35. kicad_sch_api/core/no_connects.py +33 -55
  36. kicad_sch_api/core/parser.py +75 -1731
  37. kicad_sch_api/core/schematic.py +951 -1192
  38. kicad_sch_api/core/texts.py +28 -55
  39. kicad_sch_api/core/types.py +60 -22
  40. kicad_sch_api/core/wires.py +27 -75
  41. kicad_sch_api/geometry/font_metrics.py +3 -1
  42. kicad_sch_api/geometry/symbol_bbox.py +40 -21
  43. kicad_sch_api/interfaces/__init__.py +1 -1
  44. kicad_sch_api/interfaces/parser.py +1 -1
  45. kicad_sch_api/interfaces/repository.py +1 -1
  46. kicad_sch_api/interfaces/resolver.py +1 -1
  47. kicad_sch_api/parsers/__init__.py +2 -2
  48. kicad_sch_api/parsers/base.py +7 -10
  49. kicad_sch_api/parsers/elements/__init__.py +22 -0
  50. kicad_sch_api/parsers/elements/graphics_parser.py +564 -0
  51. kicad_sch_api/parsers/elements/label_parser.py +194 -0
  52. kicad_sch_api/parsers/elements/library_parser.py +165 -0
  53. kicad_sch_api/parsers/elements/metadata_parser.py +58 -0
  54. kicad_sch_api/parsers/elements/sheet_parser.py +352 -0
  55. kicad_sch_api/parsers/elements/symbol_parser.py +313 -0
  56. kicad_sch_api/parsers/elements/text_parser.py +250 -0
  57. kicad_sch_api/parsers/elements/wire_parser.py +242 -0
  58. kicad_sch_api/parsers/registry.py +4 -2
  59. kicad_sch_api/parsers/utils.py +80 -0
  60. kicad_sch_api/symbols/__init__.py +1 -1
  61. kicad_sch_api/symbols/cache.py +9 -12
  62. kicad_sch_api/symbols/resolver.py +20 -26
  63. kicad_sch_api/symbols/validators.py +188 -137
  64. kicad_sch_api/validation/__init__.py +25 -0
  65. kicad_sch_api/validation/erc.py +171 -0
  66. kicad_sch_api/validation/erc_models.py +203 -0
  67. kicad_sch_api/validation/pin_matrix.py +243 -0
  68. kicad_sch_api/validation/validators.py +391 -0
  69. {kicad_sch_api-0.3.5.dist-info → kicad_sch_api-0.4.1.dist-info}/METADATA +17 -9
  70. kicad_sch_api-0.4.1.dist-info/RECORD +87 -0
  71. kicad_sch_api/core/manhattan_routing.py +0 -430
  72. kicad_sch_api/core/simple_manhattan.py +0 -228
  73. kicad_sch_api/core/wire_routing.py +0 -380
  74. kicad_sch_api/parsers/label_parser.py +0 -254
  75. kicad_sch_api/parsers/symbol_parser.py +0 -227
  76. kicad_sch_api/parsers/wire_parser.py +0 -99
  77. kicad_sch_api-0.3.5.dist-info/RECORD +0 -58
  78. {kicad_sch_api-0.3.5.dist-info → kicad_sch_api-0.4.1.dist-info}/WHEEL +0 -0
  79. {kicad_sch_api-0.3.5.dist-info → kicad_sch_api-0.4.1.dist-info}/entry_points.txt +0 -0
  80. {kicad_sch_api-0.3.5.dist-info → kicad_sch_api-0.4.1.dist-info}/licenses/LICENSE +0 -0
  81. {kicad_sch_api-0.3.5.dist-info → kicad_sch_api-0.4.1.dist-info}/top_level.txt +0 -0
@@ -11,7 +11,7 @@ from typing import Any, Callable, Dict, Generic, Iterator, List, Optional, TypeV
11
11
 
12
12
  logger = logging.getLogger(__name__)
13
13
 
14
- T = TypeVar('T') # Type variable for collection items
14
+ T = TypeVar("T") # Type variable for collection items
15
15
 
16
16
 
17
17
  class IndexedCollection(Generic[T], ABC):
@@ -183,6 +183,7 @@ class IndexedCollection(Generic[T], ABC):
183
183
  Returns:
184
184
  List of matching items
185
185
  """
186
+
186
187
  def matches_criteria(item: T) -> bool:
187
188
  for attr, value in criteria.items():
188
189
  if not hasattr(item, attr) or getattr(item, attr) != value:
@@ -257,10 +258,7 @@ class IndexedCollection(Generic[T], ABC):
257
258
  def _rebuild_indexes(self) -> None:
258
259
  """Rebuild all indexes."""
259
260
  # Rebuild UUID index
260
- self._uuid_index = {
261
- self._get_item_uuid(item): i
262
- for i, item in enumerate(self._items)
263
- }
261
+ self._uuid_index = {self._get_item_uuid(item): i for i, item in enumerate(self._items)}
264
262
 
265
263
  # Let subclasses rebuild their additional indexes
266
264
  self._build_additional_indexes()
@@ -282,7 +280,7 @@ class IndexedCollection(Generic[T], ABC):
282
280
  "uuid_index_size": len(self._uuid_index),
283
281
  "modified": self._modified,
284
282
  "indexes_dirty": self._dirty_indexes,
285
- "collection_type": self.__class__.__name__
283
+ "collection_type": self.__class__.__name__,
286
284
  }
287
285
 
288
286
  @property
@@ -293,4 +291,4 @@ class IndexedCollection(Generic[T], ABC):
293
291
  def mark_clean(self) -> None:
294
292
  """Mark collection as clean (not modified)."""
295
293
  self._modified = False
296
- logger.debug(f"Marked {self.__class__.__name__} as clean")
294
+ logger.debug(f"Marked {self.__class__.__name__} as clean")
@@ -250,6 +250,7 @@ class ComponentCollection(IndexedCollection[Component]):
250
250
 
251
251
  # Always snap component position to KiCAD grid (1.27mm = 50mil)
252
252
  from ..core.geometry import snap_to_grid
253
+
253
254
  snapped_pos = snap_to_grid((position.x, position.y), grid_size=1.27)
254
255
  position = Point(snapped_pos[0], snapped_pos[1])
255
256
 
@@ -269,7 +270,7 @@ class ComponentCollection(IndexedCollection[Component]):
269
270
  in_bom=True,
270
271
  on_board=True,
271
272
  footprint=footprint,
272
- properties=properties.copy()
273
+ properties=properties.copy(),
273
274
  )
274
275
 
275
276
  # Create component wrapper
@@ -335,16 +336,27 @@ class ComponentCollection(IndexedCollection[Component]):
335
336
 
336
337
  # Map common component types to standard prefixes
337
338
  ref_prefixes = {
338
- "R": "R", "Resistor": "R",
339
- "C": "C", "Capacitor": "C",
340
- "L": "L", "Inductor": "L",
341
- "D": "D", "Diode": "D",
342
- "Q": "Q", "Transistor": "Q",
343
- "U": "U", "IC": "U", "Amplifier": "U",
344
- "J": "J", "Connector": "J",
345
- "SW": "SW", "Switch": "SW",
346
- "F": "F", "Fuse": "F",
347
- "TP": "TP", "TestPoint": "TP",
339
+ "R": "R",
340
+ "Resistor": "R",
341
+ "C": "C",
342
+ "Capacitor": "C",
343
+ "L": "L",
344
+ "Inductor": "L",
345
+ "D": "D",
346
+ "Diode": "D",
347
+ "Q": "Q",
348
+ "Transistor": "Q",
349
+ "U": "U",
350
+ "IC": "U",
351
+ "Amplifier": "U",
352
+ "J": "J",
353
+ "Connector": "J",
354
+ "SW": "SW",
355
+ "Switch": "SW",
356
+ "F": "F",
357
+ "Fuse": "F",
358
+ "TP": "TP",
359
+ "TestPoint": "TP",
348
360
  }
349
361
 
350
362
  prefix = ref_prefixes.get(base_ref, "U")
@@ -419,4 +431,4 @@ class ComponentCollection(IndexedCollection[Component]):
419
431
  self._mark_indexes_dirty()
420
432
 
421
433
  logger.info(f"Bulk updated {len(matching_components)} components")
422
- return len(matching_components)
434
+ return len(matching_components)
@@ -89,26 +89,22 @@ class JunctionCollection(IndexedCollection[Junction]):
89
89
  pos_key = (position.x, position.y)
90
90
  if pos_key in self._position_index:
91
91
  existing = self._position_index[pos_key]
92
- raise ValueError(f"Junction already exists at position {position} (UUID: {existing.uuid})")
92
+ raise ValueError(
93
+ f"Junction already exists at position {position} (UUID: {existing.uuid})"
94
+ )
93
95
 
94
96
  # Generate UUID if not provided
95
97
  if junction_uuid is None:
96
98
  junction_uuid = str(uuid_module.uuid4())
97
99
 
98
100
  # Create junction
99
- junction = Junction(
100
- uuid=junction_uuid,
101
- position=position,
102
- diameter=diameter
103
- )
101
+ junction = Junction(uuid=junction_uuid, position=position, diameter=diameter)
104
102
 
105
103
  # Add to collection using base class method
106
104
  return super().add(junction)
107
105
 
108
106
  def get_junction_at_position(
109
- self,
110
- position: Union[Point, Tuple[float, float]],
111
- tolerance: float = 0.0
107
+ self, position: Union[Point, Tuple[float, float]], tolerance: float = 0.0
112
108
  ) -> Optional[Junction]:
113
109
  """
114
110
  Get junction at a specific position.
@@ -137,7 +133,7 @@ class JunctionCollection(IndexedCollection[Junction]):
137
133
  for junction in self._items:
138
134
  dx = abs(junction.position.x - target_x)
139
135
  dy = abs(junction.position.y - target_y)
140
- distance = (dx ** 2 + dy ** 2) ** 0.5
136
+ distance = (dx**2 + dy**2) ** 0.5
141
137
 
142
138
  if distance <= tolerance:
143
139
  return junction
@@ -145,9 +141,7 @@ class JunctionCollection(IndexedCollection[Junction]):
145
141
  return None
146
142
 
147
143
  def has_junction_at_position(
148
- self,
149
- position: Union[Point, Tuple[float, float]],
150
- tolerance: float = 0.0
144
+ self, position: Union[Point, Tuple[float, float]], tolerance: float = 0.0
151
145
  ) -> bool:
152
146
  """
153
147
  Check if a junction exists at a specific position.
@@ -162,11 +156,7 @@ class JunctionCollection(IndexedCollection[Junction]):
162
156
  return self.get_junction_at_position(position, tolerance) is not None
163
157
 
164
158
  def find_junctions_in_region(
165
- self,
166
- min_x: float,
167
- min_y: float,
168
- max_x: float,
169
- max_y: float
159
+ self, min_x: float, min_y: float, max_x: float, max_y: float
170
160
  ) -> List[Junction]:
171
161
  """
172
162
  Find all junctions within a rectangular region.
@@ -183,16 +173,13 @@ class JunctionCollection(IndexedCollection[Junction]):
183
173
  matching_junctions = []
184
174
 
185
175
  for junction in self._items:
186
- if (min_x <= junction.position.x <= max_x and
187
- min_y <= junction.position.y <= max_y):
176
+ if min_x <= junction.position.x <= max_x and min_y <= junction.position.y <= max_y:
188
177
  matching_junctions.append(junction)
189
178
 
190
179
  return matching_junctions
191
180
 
192
181
  def update_junction_position(
193
- self,
194
- junction_uuid: str,
195
- new_position: Union[Point, Tuple[float, float]]
182
+ self, junction_uuid: str, new_position: Union[Point, Tuple[float, float]]
196
183
  ) -> bool:
197
184
  """
198
185
  Update the position of an existing junction.
@@ -286,8 +273,9 @@ class JunctionCollection(IndexedCollection[Junction]):
286
273
 
287
274
  # Allow small tolerance for floating point precision
288
275
  tolerance = grid_size * 0.01
289
- if (x_remainder > tolerance and x_remainder < grid_size - tolerance) or \
290
- (y_remainder > tolerance and y_remainder < grid_size - tolerance):
276
+ if (x_remainder > tolerance and x_remainder < grid_size - tolerance) or (
277
+ y_remainder > tolerance and y_remainder < grid_size - tolerance
278
+ ):
291
279
  misaligned.append(junction)
292
280
 
293
281
  return misaligned
@@ -310,8 +298,10 @@ class JunctionCollection(IndexedCollection[Junction]):
310
298
  aligned_y = round(junction.position.y / grid_size) * grid_size
311
299
 
312
300
  # Check if position needs to change
313
- if (abs(junction.position.x - aligned_x) > 0.001 or
314
- abs(junction.position.y - aligned_y) > 0.001):
301
+ if (
302
+ abs(junction.position.x - aligned_x) > 0.001
303
+ or abs(junction.position.y - aligned_y) > 0.001
304
+ ):
315
305
 
316
306
  # Update position
317
307
  junction.position = Point(aligned_x, aligned_y)
@@ -326,11 +316,7 @@ class JunctionCollection(IndexedCollection[Junction]):
326
316
 
327
317
  # Bulk operations
328
318
  def remove_junctions_in_region(
329
- self,
330
- min_x: float,
331
- min_y: float,
332
- max_x: float,
333
- max_y: float
319
+ self, min_x: float, min_y: float, max_x: float, max_y: float
334
320
  ) -> int:
335
321
  """
336
322
  Remove all junctions within a rectangular region.
@@ -365,14 +351,16 @@ class JunctionCollection(IndexedCollection[Junction]):
365
351
  # Calculate diameter statistics
366
352
  diameters = [junction.diameter for junction in self._items]
367
353
  if diameters:
368
- stats.update({
369
- "diameter_stats": {
370
- "min": min(diameters),
371
- "max": max(diameters),
372
- "average": sum(diameters) / len(diameters)
373
- },
374
- "grid_aligned": len(self._items) - len(self.validate_grid_alignment()),
375
- "misaligned": len(self.validate_grid_alignment())
376
- })
377
-
378
- return stats
354
+ stats.update(
355
+ {
356
+ "diameter_stats": {
357
+ "min": min(diameters),
358
+ "max": max(diameters),
359
+ "average": sum(diameters) / len(diameters),
360
+ },
361
+ "grid_aligned": len(self._items) - len(self.validate_grid_alignment()),
362
+ "misaligned": len(self.validate_grid_alignment()),
363
+ }
364
+ )
365
+
366
+ return stats
@@ -25,7 +25,7 @@ class Label:
25
25
  position: Point,
26
26
  rotation: float = 0.0,
27
27
  label_type: str = "label",
28
- effects: Optional[Dict[str, Any]] = None
28
+ effects: Optional[Dict[str, Any]] = None,
29
29
  ):
30
30
  self.uuid = uuid
31
31
  self.text = text
@@ -150,7 +150,7 @@ class LabelCollection(IndexedCollection[Label]):
150
150
  position=position,
151
151
  rotation=rotation,
152
152
  label_type=label_type,
153
- effects=effects or {}
153
+ effects=effects or {},
154
154
  )
155
155
 
156
156
  # Add to collection using base class method
@@ -176,9 +176,7 @@ class LabelCollection(IndexedCollection[Label]):
176
176
  return self._text_index.get(text_key, []).copy()
177
177
 
178
178
  def get_labels_at_position(
179
- self,
180
- position: Union[Point, Tuple[float, float]],
181
- tolerance: float = 0.0
179
+ self, position: Union[Point, Tuple[float, float]], tolerance: float = 0.0
182
180
  ) -> List[Label]:
183
181
  """
184
182
  Get all labels at a specific position.
@@ -208,7 +206,7 @@ class LabelCollection(IndexedCollection[Label]):
208
206
  for label in self._items:
209
207
  dx = abs(label.position.x - target_x)
210
208
  dy = abs(label.position.y - target_y)
211
- distance = (dx ** 2 + dy ** 2) ** 0.5
209
+ distance = (dx**2 + dy**2) ** 0.5
212
210
 
213
211
  if distance <= tolerance:
214
212
  matching_labels.append(label)
@@ -251,11 +249,7 @@ class LabelCollection(IndexedCollection[Label]):
251
249
  return self.get_labels_by_text(net_name, case_sensitive)
252
250
 
253
251
  def find_labels_in_region(
254
- self,
255
- min_x: float,
256
- min_y: float,
257
- max_x: float,
258
- max_y: float
252
+ self, min_x: float, min_y: float, max_x: float, max_y: float
259
253
  ) -> List[Label]:
260
254
  """
261
255
  Find all labels within a rectangular region.
@@ -272,8 +266,7 @@ class LabelCollection(IndexedCollection[Label]):
272
266
  matching_labels = []
273
267
 
274
268
  for label in self._items:
275
- if (min_x <= label.position.x <= max_x and
276
- min_y <= label.position.y <= max_y):
269
+ if min_x <= label.position.x <= max_x and min_y <= label.position.y <= max_y:
277
270
  matching_labels.append(label)
278
271
 
279
272
  return matching_labels
@@ -308,9 +301,7 @@ class LabelCollection(IndexedCollection[Label]):
308
301
  return True
309
302
 
310
303
  def update_label_position(
311
- self,
312
- label_uuid: str,
313
- new_position: Union[Point, Tuple[float, float]]
304
+ self, label_uuid: str, new_position: Union[Point, Tuple[float, float]]
314
305
  ) -> bool:
315
306
  """
316
307
  Update the position of an existing label.
@@ -399,14 +390,15 @@ class LabelCollection(IndexedCollection[Label]):
399
390
  stats = super().get_statistics()
400
391
 
401
392
  # Add label-specific statistics
402
- stats.update({
403
- "unique_texts": len(self._text_index),
404
- "unique_positions": len(self._position_index),
405
- "label_types": {
406
- label_type: len(labels)
407
- for label_type, labels in self._type_index.items()
408
- },
409
- "net_count": len(self.get_net_names())
410
- })
411
-
412
- return stats
393
+ stats.update(
394
+ {
395
+ "unique_texts": len(self._text_index),
396
+ "unique_positions": len(self._position_index),
397
+ "label_types": {
398
+ label_type: len(labels) for label_type, labels in self._type_index.items()
399
+ },
400
+ "net_count": len(self.get_net_names()),
401
+ }
402
+ )
403
+
404
+ return stats
@@ -65,7 +65,7 @@ class WireCollection(IndexedCollection[Wire]):
65
65
  self._endpoint_index[endpoint].append(wire)
66
66
 
67
67
  # Type index
68
- wire_type = getattr(wire, 'wire_type', WireType.WIRE)
68
+ wire_type = getattr(wire, "wire_type", WireType.WIRE)
69
69
  if wire_type not in self._type_index:
70
70
  self._type_index[wire_type] = []
71
71
  self._type_index[wire_type].append(wire)
@@ -114,7 +114,7 @@ class WireCollection(IndexedCollection[Wire]):
114
114
  points=[start, end],
115
115
  wire_type=wire_type,
116
116
  stroke_width=stroke_width,
117
- stroke_type=stroke_type
117
+ stroke_type=stroke_type,
118
118
  )
119
119
 
120
120
  # Add to collection using base class method
@@ -165,7 +165,7 @@ class WireCollection(IndexedCollection[Wire]):
165
165
  points=converted_points,
166
166
  wire_type=wire_type,
167
167
  stroke_width=stroke_width,
168
- stroke_type=stroke_type
168
+ stroke_type=stroke_type,
169
169
  )
170
170
 
171
171
  # Add to collection using base class method
@@ -262,9 +262,7 @@ class WireCollection(IndexedCollection[Wire]):
262
262
  return networks
263
263
 
264
264
  def modify_wire_path(
265
- self,
266
- wire_uuid: str,
267
- new_points: List[Union[Point, Tuple[float, float]]]
265
+ self, wire_uuid: str, new_points: List[Union[Point, Tuple[float, float]]]
268
266
  ) -> bool:
269
267
  """
270
268
  Modify the path of an existing wire.
@@ -325,7 +323,7 @@ class WireCollection(IndexedCollection[Wire]):
325
323
  self,
326
324
  wire_type: Optional[WireType] = None,
327
325
  stroke_width: Optional[float] = None,
328
- stroke_type: Optional[str] = None
326
+ stroke_type: Optional[str] = None,
329
327
  ) -> int:
330
328
  """
331
329
  Bulk update stroke properties for wires.
@@ -368,15 +366,16 @@ class WireCollection(IndexedCollection[Wire]):
368
366
  stats = super().get_statistics()
369
367
 
370
368
  # Add wire-specific statistics
371
- stats.update({
372
- "endpoint_count": len(self._endpoint_index),
373
- "wire_types": {
374
- wire_type.value: len(wires)
375
- for wire_type, wires in self._type_index.items()
376
- },
377
- "networks": len(self.find_wire_networks()),
378
- "total_length": sum(self._calculate_wire_length(wire) for wire in self._items)
379
- })
369
+ stats.update(
370
+ {
371
+ "endpoint_count": len(self._endpoint_index),
372
+ "wire_types": {
373
+ wire_type.value: len(wires) for wire_type, wires in self._type_index.items()
374
+ },
375
+ "networks": len(self.find_wire_networks()),
376
+ "total_length": sum(self._calculate_wire_length(wire) for wire in self._items),
377
+ }
378
+ )
380
379
 
381
380
  return stats
382
381
 
@@ -400,8 +399,8 @@ class WireCollection(IndexedCollection[Wire]):
400
399
 
401
400
  dx = end_point.x - start_point.x
402
401
  dy = end_point.y - start_point.y
403
- segment_length = (dx ** 2 + dy ** 2) ** 0.5
402
+ segment_length = (dx**2 + dy**2) ** 0.5
404
403
 
405
404
  total_length += segment_length
406
405
 
407
- return total_length
406
+ return total_length
@@ -0,0 +1,5 @@
1
+ """Collection base classes for schematic elements."""
2
+
3
+ from .base import BaseCollection
4
+
5
+ __all__ = ["BaseCollection"]
@@ -0,0 +1,248 @@
1
+ """
2
+ Base collection class for schematic elements.
3
+
4
+ Provides common functionality for all collection types including UUID indexing,
5
+ modification tracking, and standard collection operations.
6
+ """
7
+
8
+ import logging
9
+ from typing import Any, Callable, Dict, Generic, Iterator, List, Optional, Protocol, TypeVar
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class HasUUID(Protocol):
15
+ """Protocol for objects that have a UUID attribute."""
16
+
17
+ @property
18
+ def uuid(self) -> str:
19
+ """UUID of the object."""
20
+ ...
21
+
22
+
23
+ T = TypeVar("T", bound=HasUUID)
24
+
25
+
26
+ class BaseCollection(Generic[T]):
27
+ """
28
+ Generic base class for schematic element collections.
29
+
30
+ Provides common functionality:
31
+ - UUID-based indexing for fast lookup
32
+ - Modification tracking
33
+ - Standard collection operations (__len__, __iter__, __getitem__)
34
+ - Index rebuilding and management
35
+
36
+ Type parameter T must implement the HasUUID protocol.
37
+ """
38
+
39
+ def __init__(self, items: Optional[List[T]] = None, collection_name: str = "items") -> None:
40
+ """
41
+ Initialize base collection.
42
+
43
+ Args:
44
+ items: Initial list of items
45
+ collection_name: Name for logging (e.g., "wires", "junctions")
46
+ """
47
+ self._items: List[T] = items or []
48
+ self._uuid_index: Dict[str, int] = {}
49
+ self._modified = False
50
+ self._collection_name = collection_name
51
+
52
+ # Build UUID index
53
+ self._rebuild_index()
54
+
55
+ logger.debug(f"{collection_name} collection initialized with {len(self._items)} items")
56
+
57
+ def _rebuild_index(self) -> None:
58
+ """Rebuild UUID index for fast lookups."""
59
+ self._uuid_index = {item.uuid: i for i, item in enumerate(self._items)}
60
+
61
+ def _mark_modified(self) -> None:
62
+ """Mark collection as modified."""
63
+ self._modified = True
64
+
65
+ def is_modified(self) -> bool:
66
+ """Check if collection has been modified."""
67
+ return self._modified
68
+
69
+ def reset_modified_flag(self) -> None:
70
+ """Reset modified flag (typically after save)."""
71
+ self._modified = False
72
+
73
+ # Standard collection protocol methods
74
+ def __len__(self) -> int:
75
+ """Return number of items in collection."""
76
+ return len(self._items)
77
+
78
+ def __iter__(self) -> Iterator[T]:
79
+ """Iterate over items in collection."""
80
+ return iter(self._items)
81
+
82
+ def __getitem__(self, key: Any) -> T:
83
+ """
84
+ Get item by UUID or index.
85
+
86
+ Args:
87
+ key: UUID string or integer index
88
+
89
+ Returns:
90
+ Item at the specified location
91
+
92
+ Raises:
93
+ KeyError: If UUID not found
94
+ IndexError: If index out of range
95
+ TypeError: If key is neither string nor int
96
+ """
97
+ if isinstance(key, str):
98
+ # UUID lookup
99
+ if key not in self._uuid_index:
100
+ raise KeyError(f"Item with UUID '{key}' not found")
101
+ return self._items[self._uuid_index[key]]
102
+ elif isinstance(key, int):
103
+ # Index lookup
104
+ return self._items[key]
105
+ else:
106
+ raise TypeError(f"Key must be string (UUID) or int (index), got {type(key)}")
107
+
108
+ def __contains__(self, key: Any) -> bool:
109
+ """
110
+ Check if item exists in collection.
111
+
112
+ Args:
113
+ key: UUID string or item object
114
+
115
+ Returns:
116
+ True if item exists
117
+ """
118
+ if isinstance(key, str):
119
+ return key in self._uuid_index
120
+ elif hasattr(key, "uuid"):
121
+ return key.uuid in self._uuid_index
122
+ return False
123
+
124
+ def get(self, uuid: str) -> Optional[T]:
125
+ """
126
+ Get item by UUID.
127
+
128
+ Args:
129
+ uuid: Item UUID
130
+
131
+ Returns:
132
+ Item if found, None otherwise
133
+ """
134
+ if uuid not in self._uuid_index:
135
+ return None
136
+ return self._items[self._uuid_index[uuid]]
137
+
138
+ def remove(self, uuid: str) -> bool:
139
+ """
140
+ Remove item by UUID.
141
+
142
+ Args:
143
+ uuid: UUID of item to remove
144
+
145
+ Returns:
146
+ True if item was removed, False if not found
147
+ """
148
+ if uuid not in self._uuid_index:
149
+ return False
150
+
151
+ index = self._uuid_index[uuid]
152
+ del self._items[index]
153
+ self._rebuild_index()
154
+ self._mark_modified()
155
+
156
+ logger.debug(f"Removed item with UUID {uuid} from {self._collection_name}")
157
+ return True
158
+
159
+ def clear(self) -> None:
160
+ """Remove all items from collection."""
161
+ self._items.clear()
162
+ self._uuid_index.clear()
163
+ self._mark_modified()
164
+ logger.debug(f"Cleared all items from {self._collection_name}")
165
+
166
+ def find(self, predicate: Callable[[T], bool]) -> List[T]:
167
+ """
168
+ Find all items matching a predicate.
169
+
170
+ Args:
171
+ predicate: Function that returns True for matching items
172
+
173
+ Returns:
174
+ List of matching items
175
+ """
176
+ return [item for item in self._items if predicate(item)]
177
+
178
+ def filter(self, **criteria) -> List[T]:
179
+ """
180
+ Filter items by attribute values.
181
+
182
+ Args:
183
+ **criteria: Attribute name/value pairs to match
184
+
185
+ Returns:
186
+ List of matching items
187
+
188
+ Example:
189
+ collection.filter(wire_type=WireType.BUS, stroke_width=0.5)
190
+ """
191
+ matches = []
192
+ for item in self._items:
193
+ if all(getattr(item, key, None) == value for key, value in criteria.items()):
194
+ matches.append(item)
195
+ return matches
196
+
197
+ def get_statistics(self) -> Dict[str, Any]:
198
+ """
199
+ Get collection statistics.
200
+
201
+ Returns:
202
+ Dictionary with statistics
203
+ """
204
+ return {
205
+ "total_items": len(self._items),
206
+ "modified": self._modified,
207
+ "indexed_items": len(self._uuid_index),
208
+ }
209
+
210
+ def _add_item(self, item: T) -> None:
211
+ """
212
+ Add item to internal storage and index.
213
+
214
+ Args:
215
+ item: Item to add
216
+
217
+ Raises:
218
+ ValueError: If item UUID already exists
219
+ """
220
+ if item.uuid in self._uuid_index:
221
+ raise ValueError(f"Item with UUID '{item.uuid}' already exists")
222
+
223
+ self._items.append(item)
224
+ self._uuid_index[item.uuid] = len(self._items) - 1
225
+ self._mark_modified()
226
+
227
+ def bulk_update(self, criteria: Dict[str, Any], updates: Dict[str, Any]) -> int:
228
+ """
229
+ Update multiple items matching criteria.
230
+
231
+ Args:
232
+ criteria: Attribute name/value pairs to match
233
+ updates: Attribute name/value pairs to update
234
+
235
+ Returns:
236
+ Number of items updated
237
+ """
238
+ matching_items = self.filter(**criteria)
239
+ for item in matching_items:
240
+ for key, value in updates.items():
241
+ if hasattr(item, key):
242
+ setattr(item, key, value)
243
+
244
+ if matching_items:
245
+ self._mark_modified()
246
+
247
+ logger.debug(f"Bulk updated {len(matching_items)} items in {self._collection_name}")
248
+ return len(matching_items)