kicad-sch-api 0.3.4__py3-none-any.whl → 0.4.0__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 (47) hide show
  1. kicad_sch_api/collections/__init__.py +21 -0
  2. kicad_sch_api/collections/base.py +294 -0
  3. kicad_sch_api/collections/components.py +434 -0
  4. kicad_sch_api/collections/junctions.py +366 -0
  5. kicad_sch_api/collections/labels.py +404 -0
  6. kicad_sch_api/collections/wires.py +406 -0
  7. kicad_sch_api/core/components.py +5 -0
  8. kicad_sch_api/core/formatter.py +3 -1
  9. kicad_sch_api/core/labels.py +348 -0
  10. kicad_sch_api/core/managers/__init__.py +26 -0
  11. kicad_sch_api/core/managers/file_io.py +243 -0
  12. kicad_sch_api/core/managers/format_sync.py +501 -0
  13. kicad_sch_api/core/managers/graphics.py +579 -0
  14. kicad_sch_api/core/managers/metadata.py +268 -0
  15. kicad_sch_api/core/managers/sheet.py +454 -0
  16. kicad_sch_api/core/managers/text_elements.py +536 -0
  17. kicad_sch_api/core/managers/validation.py +474 -0
  18. kicad_sch_api/core/managers/wire.py +346 -0
  19. kicad_sch_api/core/nets.py +310 -0
  20. kicad_sch_api/core/no_connects.py +276 -0
  21. kicad_sch_api/core/parser.py +75 -41
  22. kicad_sch_api/core/schematic.py +904 -1074
  23. kicad_sch_api/core/texts.py +343 -0
  24. kicad_sch_api/core/types.py +13 -4
  25. kicad_sch_api/geometry/font_metrics.py +3 -1
  26. kicad_sch_api/geometry/symbol_bbox.py +56 -43
  27. kicad_sch_api/interfaces/__init__.py +17 -0
  28. kicad_sch_api/interfaces/parser.py +76 -0
  29. kicad_sch_api/interfaces/repository.py +70 -0
  30. kicad_sch_api/interfaces/resolver.py +117 -0
  31. kicad_sch_api/parsers/__init__.py +14 -0
  32. kicad_sch_api/parsers/base.py +145 -0
  33. kicad_sch_api/parsers/label_parser.py +254 -0
  34. kicad_sch_api/parsers/registry.py +155 -0
  35. kicad_sch_api/parsers/symbol_parser.py +222 -0
  36. kicad_sch_api/parsers/wire_parser.py +99 -0
  37. kicad_sch_api/symbols/__init__.py +18 -0
  38. kicad_sch_api/symbols/cache.py +467 -0
  39. kicad_sch_api/symbols/resolver.py +361 -0
  40. kicad_sch_api/symbols/validators.py +504 -0
  41. {kicad_sch_api-0.3.4.dist-info → kicad_sch_api-0.4.0.dist-info}/METADATA +1 -1
  42. kicad_sch_api-0.4.0.dist-info/RECORD +67 -0
  43. kicad_sch_api-0.3.4.dist-info/RECORD +0 -34
  44. {kicad_sch_api-0.3.4.dist-info → kicad_sch_api-0.4.0.dist-info}/WHEEL +0 -0
  45. {kicad_sch_api-0.3.4.dist-info → kicad_sch_api-0.4.0.dist-info}/entry_points.txt +0 -0
  46. {kicad_sch_api-0.3.4.dist-info → kicad_sch_api-0.4.0.dist-info}/licenses/LICENSE +0 -0
  47. {kicad_sch_api-0.3.4.dist-info → kicad_sch_api-0.4.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,348 @@
1
+ """
2
+ Label element management for KiCAD schematics.
3
+
4
+ This module provides collection classes for managing label 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 Label, Point
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class LabelElement:
19
+ """
20
+ Enhanced wrapper for schematic label elements with modern API.
21
+
22
+ Provides intuitive access to label properties and operations
23
+ while maintaining exact format preservation.
24
+ """
25
+
26
+ def __init__(self, label_data: Label, parent_collection: "LabelCollection"):
27
+ """
28
+ Initialize label element wrapper.
29
+
30
+ Args:
31
+ label_data: Underlying label data
32
+ parent_collection: Parent collection for updates
33
+ """
34
+ self._data = label_data
35
+ self._collection = parent_collection
36
+ self._validator = SchematicValidator()
37
+
38
+ # Core properties with validation
39
+ @property
40
+ def uuid(self) -> str:
41
+ """Label element UUID."""
42
+ return self._data.uuid
43
+
44
+ @property
45
+ def text(self) -> str:
46
+ """Label text (net name)."""
47
+ return self._data.text
48
+
49
+ @text.setter
50
+ def text(self, value: str):
51
+ """Set label text with validation."""
52
+ if not isinstance(value, str) or not value.strip():
53
+ raise ValidationError("Label text cannot be empty")
54
+ old_text = self._data.text
55
+ self._data.text = value.strip()
56
+ self._collection._update_text_index(old_text, self)
57
+ self._collection._mark_modified()
58
+
59
+ @property
60
+ def position(self) -> Point:
61
+ """Label position."""
62
+ return self._data.position
63
+
64
+ @position.setter
65
+ def position(self, value: Union[Point, Tuple[float, float]]):
66
+ """Set label position."""
67
+ if isinstance(value, tuple):
68
+ value = Point(value[0], value[1])
69
+ elif not isinstance(value, Point):
70
+ raise ValidationError(f"Position must be Point or tuple, got {type(value)}")
71
+ self._data.position = value
72
+ self._collection._mark_modified()
73
+
74
+ @property
75
+ def rotation(self) -> float:
76
+ """Label rotation in degrees."""
77
+ return self._data.rotation
78
+
79
+ @rotation.setter
80
+ def rotation(self, value: float):
81
+ """Set label rotation."""
82
+ self._data.rotation = float(value)
83
+ self._collection._mark_modified()
84
+
85
+ @property
86
+ def size(self) -> float:
87
+ """Label text size."""
88
+ return self._data.size
89
+
90
+ @size.setter
91
+ def size(self, value: float):
92
+ """Set label size with validation."""
93
+ if value <= 0:
94
+ raise ValidationError(f"Label size must be positive, got {value}")
95
+ self._data.size = float(value)
96
+ self._collection._mark_modified()
97
+
98
+ def validate(self) -> List[ValidationIssue]:
99
+ """Validate this label element."""
100
+ return self._validator.validate_label(self._data.__dict__)
101
+
102
+ def to_dict(self) -> Dict[str, Any]:
103
+ """Convert label element to dictionary representation."""
104
+ return {
105
+ "uuid": self.uuid,
106
+ "text": self.text,
107
+ "position": {"x": self.position.x, "y": self.position.y},
108
+ "rotation": self.rotation,
109
+ "size": self.size,
110
+ }
111
+
112
+ def __str__(self) -> str:
113
+ """String representation."""
114
+ return f"<Label '{self.text}' @ {self.position}>"
115
+
116
+
117
+ class LabelCollection:
118
+ """
119
+ Collection class for efficient label element management.
120
+
121
+ Provides fast lookup, filtering, and bulk operations for schematic label elements.
122
+ """
123
+
124
+ def __init__(self, labels: List[Label] = None):
125
+ """
126
+ Initialize label collection.
127
+
128
+ Args:
129
+ labels: Initial list of label data
130
+ """
131
+ self._labels: List[LabelElement] = []
132
+ self._uuid_index: Dict[str, LabelElement] = {}
133
+ self._text_index: Dict[str, List[LabelElement]] = {}
134
+ self._modified = False
135
+
136
+ # Add initial labels
137
+ if labels:
138
+ for label_data in labels:
139
+ self._add_to_indexes(LabelElement(label_data, self))
140
+
141
+ logger.debug(f"LabelCollection initialized with {len(self._labels)} labels")
142
+
143
+ def add(
144
+ self,
145
+ text: str,
146
+ position: Union[Point, Tuple[float, float]],
147
+ rotation: float = 0.0,
148
+ size: float = 1.27,
149
+ label_uuid: Optional[str] = None,
150
+ ) -> LabelElement:
151
+ """
152
+ Add a new label element to the schematic.
153
+
154
+ Args:
155
+ text: Label text (net name)
156
+ position: Label position
157
+ rotation: Label rotation in degrees
158
+ size: Label text size
159
+ label_uuid: Specific UUID for label (auto-generated if None)
160
+
161
+ Returns:
162
+ Newly created LabelElement
163
+
164
+ Raises:
165
+ ValidationError: If label data is invalid
166
+ """
167
+ # Validate inputs
168
+ if not isinstance(text, str) or not text.strip():
169
+ raise ValidationError("Label text cannot be empty")
170
+
171
+ if isinstance(position, tuple):
172
+ position = Point(position[0], position[1])
173
+ elif not isinstance(position, Point):
174
+ raise ValidationError(f"Position must be Point or tuple, got {type(position)}")
175
+
176
+ if size <= 0:
177
+ raise ValidationError(f"Label size must be positive, got {size}")
178
+
179
+ # Generate UUID if not provided
180
+ if not label_uuid:
181
+ label_uuid = str(uuid.uuid4())
182
+
183
+ # Check for duplicate UUID
184
+ if label_uuid in self._uuid_index:
185
+ raise ValidationError(f"Label UUID {label_uuid} already exists")
186
+
187
+ # Create label data
188
+ label_data = Label(
189
+ uuid=label_uuid,
190
+ position=position,
191
+ text=text.strip(),
192
+ rotation=rotation,
193
+ size=size,
194
+ )
195
+
196
+ # Create wrapper and add to collection
197
+ label_element = LabelElement(label_data, self)
198
+ self._add_to_indexes(label_element)
199
+ self._mark_modified()
200
+
201
+ logger.debug(f"Added label: {label_element}")
202
+ return label_element
203
+
204
+ def get(self, label_uuid: str) -> Optional[LabelElement]:
205
+ """Get label by UUID."""
206
+ return self._uuid_index.get(label_uuid)
207
+
208
+ def get_by_text(self, text: str) -> List[LabelElement]:
209
+ """Get all labels with the given text."""
210
+ return self._text_index.get(text, []).copy()
211
+
212
+ def remove(self, label_uuid: str) -> bool:
213
+ """
214
+ Remove label by UUID.
215
+
216
+ Args:
217
+ label_uuid: UUID of label to remove
218
+
219
+ Returns:
220
+ True if label was removed, False if not found
221
+ """
222
+ label_element = self._uuid_index.get(label_uuid)
223
+ if not label_element:
224
+ return False
225
+
226
+ # Remove from indexes
227
+ self._remove_from_indexes(label_element)
228
+ self._mark_modified()
229
+
230
+ logger.debug(f"Removed label: {label_element}")
231
+ return True
232
+
233
+ def find_by_text(self, text: str, exact: bool = True) -> List[LabelElement]:
234
+ """
235
+ Find labels by text.
236
+
237
+ Args:
238
+ text: Text to search for
239
+ exact: If True, exact match; if False, substring match
240
+
241
+ Returns:
242
+ List of matching label elements
243
+ """
244
+ if exact:
245
+ return self._text_index.get(text, []).copy()
246
+ else:
247
+ matches = []
248
+ for label_element in self._labels:
249
+ if text.lower() in label_element.text.lower():
250
+ matches.append(label_element)
251
+ return matches
252
+
253
+ def filter(self, predicate: Callable[[LabelElement], bool]) -> List[LabelElement]:
254
+ """
255
+ Filter labels by predicate function.
256
+
257
+ Args:
258
+ predicate: Function that returns True for labels to include
259
+
260
+ Returns:
261
+ List of labels matching predicate
262
+ """
263
+ return [label for label in self._labels if predicate(label)]
264
+
265
+ def bulk_update(self, criteria: Callable[[LabelElement], bool], updates: Dict[str, Any]):
266
+ """
267
+ Update multiple labels matching criteria.
268
+
269
+ Args:
270
+ criteria: Function to select labels to update
271
+ updates: Dictionary of property updates
272
+ """
273
+ updated_count = 0
274
+ for label_element in self._labels:
275
+ if criteria(label_element):
276
+ for prop, value in updates.items():
277
+ if hasattr(label_element, prop):
278
+ setattr(label_element, prop, value)
279
+ updated_count += 1
280
+
281
+ if updated_count > 0:
282
+ self._mark_modified()
283
+ logger.debug(f"Bulk updated {updated_count} label properties")
284
+
285
+ def clear(self):
286
+ """Remove all labels from collection."""
287
+ self._labels.clear()
288
+ self._uuid_index.clear()
289
+ self._text_index.clear()
290
+ self._mark_modified()
291
+
292
+ def _add_to_indexes(self, label_element: LabelElement):
293
+ """Add label to internal indexes."""
294
+ self._labels.append(label_element)
295
+ self._uuid_index[label_element.uuid] = label_element
296
+
297
+ # Add to text index
298
+ text = label_element.text
299
+ if text not in self._text_index:
300
+ self._text_index[text] = []
301
+ self._text_index[text].append(label_element)
302
+
303
+ def _remove_from_indexes(self, label_element: LabelElement):
304
+ """Remove label from internal indexes."""
305
+ self._labels.remove(label_element)
306
+ del self._uuid_index[label_element.uuid]
307
+
308
+ # Remove from text index
309
+ text = label_element.text
310
+ if text in self._text_index:
311
+ self._text_index[text].remove(label_element)
312
+ if not self._text_index[text]:
313
+ del self._text_index[text]
314
+
315
+ def _update_text_index(self, old_text: str, label_element: LabelElement):
316
+ """Update text index when label text changes."""
317
+ # Remove from old text index
318
+ if old_text in self._text_index:
319
+ self._text_index[old_text].remove(label_element)
320
+ if not self._text_index[old_text]:
321
+ del self._text_index[old_text]
322
+
323
+ # Add to new text index
324
+ new_text = label_element.text
325
+ if new_text not in self._text_index:
326
+ self._text_index[new_text] = []
327
+ self._text_index[new_text].append(label_element)
328
+
329
+ def _mark_modified(self):
330
+ """Mark collection as modified."""
331
+ self._modified = True
332
+
333
+ # Collection interface methods
334
+ def __len__(self) -> int:
335
+ """Return number of labels."""
336
+ return len(self._labels)
337
+
338
+ def __iter__(self) -> Iterator[LabelElement]:
339
+ """Iterate over labels."""
340
+ return iter(self._labels)
341
+
342
+ def __getitem__(self, index: int) -> LabelElement:
343
+ """Get label by index."""
344
+ return self._labels[index]
345
+
346
+ def __bool__(self) -> bool:
347
+ """Return True if collection has labels."""
348
+ return len(self._labels) > 0
@@ -0,0 +1,26 @@
1
+ """
2
+ Schematic management modules for separating responsibilities.
3
+
4
+ This package contains specialized managers for different aspects of schematic
5
+ manipulation, enabling clean separation of concerns and better maintainability.
6
+ """
7
+
8
+ from .file_io import FileIOManager
9
+ from .format_sync import FormatSyncManager
10
+ from .graphics import GraphicsManager
11
+ from .metadata import MetadataManager
12
+ from .sheet import SheetManager
13
+ from .text_elements import TextElementManager
14
+ from .validation import ValidationManager
15
+ from .wire import WireManager
16
+
17
+ __all__ = [
18
+ "FileIOManager",
19
+ "FormatSyncManager",
20
+ "GraphicsManager",
21
+ "MetadataManager",
22
+ "SheetManager",
23
+ "TextElementManager",
24
+ "ValidationManager",
25
+ "WireManager",
26
+ ]
@@ -0,0 +1,243 @@
1
+ """
2
+ File I/O Manager for KiCAD schematic operations.
3
+
4
+ Handles all file system interactions including loading, saving, and backup operations
5
+ while maintaining exact format preservation.
6
+ """
7
+
8
+ import logging
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any, Dict, Optional, Union
12
+
13
+ from ...utils.validation import ValidationError
14
+ from ..formatter import ExactFormatter
15
+ from ..parser import SExpressionParser
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class FileIOManager:
21
+ """
22
+ Manages file I/O operations for KiCAD schematics.
23
+
24
+ Responsible for:
25
+ - Loading schematic files with validation
26
+ - Saving with format preservation
27
+ - Creating backup files
28
+ - Managing file paths and metadata
29
+ """
30
+
31
+ def __init__(self):
32
+ """Initialize the FileIOManager."""
33
+ self._parser = SExpressionParser(preserve_format=True)
34
+ self._formatter = ExactFormatter()
35
+
36
+ def load_schematic(self, file_path: Union[str, Path]) -> Dict[str, Any]:
37
+ """
38
+ Load a KiCAD schematic file.
39
+
40
+ Args:
41
+ file_path: Path to .kicad_sch file
42
+
43
+ Returns:
44
+ Parsed schematic data
45
+
46
+ Raises:
47
+ FileNotFoundError: If file doesn't exist
48
+ ValidationError: If file is invalid or corrupted
49
+ """
50
+ start_time = time.time()
51
+ file_path = Path(file_path)
52
+
53
+ if not file_path.exists():
54
+ raise FileNotFoundError(f"Schematic file not found: {file_path}")
55
+
56
+ if not file_path.suffix == ".kicad_sch":
57
+ raise ValidationError(f"Not a KiCAD schematic file: {file_path}")
58
+
59
+ logger.info(f"Loading schematic: {file_path}")
60
+
61
+ try:
62
+ schematic_data = self._parser.parse_file(file_path)
63
+ load_time = time.time() - start_time
64
+ logger.info(f"Loaded schematic in {load_time:.3f}s")
65
+
66
+ return schematic_data
67
+
68
+ except Exception as e:
69
+ logger.error(f"Failed to load schematic {file_path}: {e}")
70
+ raise ValidationError(f"Invalid schematic file: {e}") from e
71
+
72
+ def save_schematic(
73
+ self,
74
+ schematic_data: Dict[str, Any],
75
+ file_path: Union[str, Path],
76
+ preserve_format: bool = True,
77
+ ) -> None:
78
+ """
79
+ Save schematic data to file.
80
+
81
+ Args:
82
+ schematic_data: Schematic data to save
83
+ file_path: Target file path
84
+ preserve_format: Whether to preserve exact formatting
85
+
86
+ Raises:
87
+ PermissionError: If file cannot be written
88
+ ValidationError: If data is invalid
89
+ """
90
+ start_time = time.time()
91
+ file_path = Path(file_path)
92
+
93
+ logger.info(f"Saving schematic: {file_path}")
94
+
95
+ try:
96
+ # Ensure parent directory exists
97
+ file_path.parent.mkdir(parents=True, exist_ok=True)
98
+
99
+ # Convert to S-expression format and save
100
+ sexp_data = self._parser._schematic_data_to_sexp(schematic_data)
101
+ formatted_content = self._formatter.format(sexp_data)
102
+
103
+ with open(file_path, "w", encoding="utf-8") as f:
104
+ f.write(formatted_content)
105
+
106
+ save_time = time.time() - start_time
107
+ logger.info(f"Saved schematic in {save_time:.3f}s")
108
+
109
+ except PermissionError as e:
110
+ logger.error(f"Permission denied saving to {file_path}: {e}")
111
+ raise
112
+ except Exception as e:
113
+ logger.error(f"Failed to save schematic to {file_path}: {e}")
114
+ raise ValidationError(f"Save failed: {e}") from e
115
+
116
+ def create_backup(self, file_path: Union[str, Path], suffix: str = ".backup") -> Path:
117
+ """
118
+ Create a backup copy of the schematic file.
119
+
120
+ Args:
121
+ file_path: Source file to backup
122
+ suffix: Backup file suffix
123
+
124
+ Returns:
125
+ Path to backup file
126
+
127
+ Raises:
128
+ FileNotFoundError: If source file doesn't exist
129
+ PermissionError: If backup cannot be created
130
+ """
131
+ file_path = Path(file_path)
132
+
133
+ if not file_path.exists():
134
+ raise FileNotFoundError(f"Cannot backup non-existent file: {file_path}")
135
+
136
+ # Create backup with timestamp if suffix doesn't include one
137
+ if suffix == ".backup":
138
+ timestamp = time.strftime("%Y%m%d_%H%M%S")
139
+ backup_path = file_path.with_suffix(f".{timestamp}.backup")
140
+ else:
141
+ backup_path = file_path.with_suffix(f"{file_path.suffix}{suffix}")
142
+
143
+ try:
144
+ # Copy file content
145
+ backup_path.write_bytes(file_path.read_bytes())
146
+ logger.info(f"Created backup: {backup_path}")
147
+ return backup_path
148
+
149
+ except Exception as e:
150
+ logger.error(f"Failed to create backup {backup_path}: {e}")
151
+ raise PermissionError(f"Backup failed: {e}") from e
152
+
153
+ def validate_file_path(self, file_path: Union[str, Path]) -> Path:
154
+ """
155
+ Validate and normalize a file path for schematic operations.
156
+
157
+ Args:
158
+ file_path: Path to validate
159
+
160
+ Returns:
161
+ Normalized Path object
162
+
163
+ Raises:
164
+ ValidationError: If path is invalid
165
+ """
166
+ file_path = Path(file_path)
167
+
168
+ # Ensure .kicad_sch extension
169
+ if not file_path.suffix:
170
+ file_path = file_path.with_suffix(".kicad_sch")
171
+ elif file_path.suffix != ".kicad_sch":
172
+ raise ValidationError(f"Invalid schematic file extension: {file_path.suffix}")
173
+
174
+ # Validate path characters
175
+ try:
176
+ file_path.resolve()
177
+ except (OSError, ValueError) as e:
178
+ raise ValidationError(f"Invalid file path: {e}") from e
179
+
180
+ return file_path
181
+
182
+ def get_file_info(self, file_path: Union[str, Path]) -> Dict[str, Any]:
183
+ """
184
+ Get file system information about a schematic file.
185
+
186
+ Args:
187
+ file_path: Path to analyze
188
+
189
+ Returns:
190
+ Dictionary with file information
191
+
192
+ Raises:
193
+ FileNotFoundError: If file doesn't exist
194
+ """
195
+ file_path = Path(file_path)
196
+
197
+ if not file_path.exists():
198
+ raise FileNotFoundError(f"File not found: {file_path}")
199
+
200
+ stat = file_path.stat()
201
+
202
+ return {
203
+ "path": str(file_path.resolve()),
204
+ "size": stat.st_size,
205
+ "modified": stat.st_mtime,
206
+ "created": getattr(stat, "st_birthtime", stat.st_ctime),
207
+ "readable": file_path.is_file() and file_path.exists(),
208
+ "writable": file_path.parent.exists() and file_path.parent.is_dir(),
209
+ "extension": file_path.suffix,
210
+ }
211
+
212
+ def create_empty_schematic_data(self) -> Dict[str, Any]:
213
+ """
214
+ Create empty schematic data structure.
215
+
216
+ Returns:
217
+ Empty schematic data dictionary
218
+ """
219
+ return {
220
+ "kicad_sch": {
221
+ "version": 20230819,
222
+ "generator": "kicad-sch-api",
223
+ "uuid": None, # Will be set by calling code
224
+ "paper": "A4",
225
+ "lib_symbols": {},
226
+ "symbol": [],
227
+ "wire": [],
228
+ "junction": [],
229
+ "label": [],
230
+ "hierarchical_label": [],
231
+ "global_label": [],
232
+ "text": [],
233
+ "text_box": [],
234
+ "polyline": [],
235
+ "rectangle": [],
236
+ "circle": [],
237
+ "arc": [],
238
+ "image": [],
239
+ "sheet": [],
240
+ "sheet_instances": [],
241
+ "symbol_instances": [],
242
+ }
243
+ }