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.
- kicad_sch_api/collections/__init__.py +21 -0
- kicad_sch_api/collections/base.py +294 -0
- kicad_sch_api/collections/components.py +434 -0
- kicad_sch_api/collections/junctions.py +366 -0
- kicad_sch_api/collections/labels.py +404 -0
- kicad_sch_api/collections/wires.py +406 -0
- kicad_sch_api/core/components.py +5 -0
- kicad_sch_api/core/formatter.py +3 -1
- kicad_sch_api/core/labels.py +348 -0
- kicad_sch_api/core/managers/__init__.py +26 -0
- kicad_sch_api/core/managers/file_io.py +243 -0
- kicad_sch_api/core/managers/format_sync.py +501 -0
- kicad_sch_api/core/managers/graphics.py +579 -0
- kicad_sch_api/core/managers/metadata.py +268 -0
- kicad_sch_api/core/managers/sheet.py +454 -0
- kicad_sch_api/core/managers/text_elements.py +536 -0
- kicad_sch_api/core/managers/validation.py +474 -0
- kicad_sch_api/core/managers/wire.py +346 -0
- kicad_sch_api/core/nets.py +310 -0
- kicad_sch_api/core/no_connects.py +276 -0
- kicad_sch_api/core/parser.py +75 -41
- kicad_sch_api/core/schematic.py +904 -1074
- kicad_sch_api/core/texts.py +343 -0
- kicad_sch_api/core/types.py +13 -4
- kicad_sch_api/geometry/font_metrics.py +3 -1
- kicad_sch_api/geometry/symbol_bbox.py +56 -43
- kicad_sch_api/interfaces/__init__.py +17 -0
- kicad_sch_api/interfaces/parser.py +76 -0
- kicad_sch_api/interfaces/repository.py +70 -0
- kicad_sch_api/interfaces/resolver.py +117 -0
- kicad_sch_api/parsers/__init__.py +14 -0
- kicad_sch_api/parsers/base.py +145 -0
- kicad_sch_api/parsers/label_parser.py +254 -0
- kicad_sch_api/parsers/registry.py +155 -0
- kicad_sch_api/parsers/symbol_parser.py +222 -0
- kicad_sch_api/parsers/wire_parser.py +99 -0
- kicad_sch_api/symbols/__init__.py +18 -0
- kicad_sch_api/symbols/cache.py +467 -0
- kicad_sch_api/symbols/resolver.py +361 -0
- kicad_sch_api/symbols/validators.py +504 -0
- {kicad_sch_api-0.3.4.dist-info → kicad_sch_api-0.4.0.dist-info}/METADATA +1 -1
- kicad_sch_api-0.4.0.dist-info/RECORD +67 -0
- kicad_sch_api-0.3.4.dist-info/RECORD +0 -34
- {kicad_sch_api-0.3.4.dist-info → kicad_sch_api-0.4.0.dist-info}/WHEEL +0 -0
- {kicad_sch_api-0.3.4.dist-info → kicad_sch_api-0.4.0.dist-info}/entry_points.txt +0 -0
- {kicad_sch_api-0.3.4.dist-info → kicad_sch_api-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {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
|
+
}
|