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.
- kicad_sch_api/__init__.py +2 -2
- kicad_sch_api/cli/__init__.py +45 -0
- kicad_sch_api/cli/base.py +302 -0
- kicad_sch_api/cli/bom.py +164 -0
- kicad_sch_api/cli/erc.py +229 -0
- kicad_sch_api/cli/export_docs.py +289 -0
- kicad_sch_api/cli/netlist.py +94 -0
- kicad_sch_api/cli/types.py +43 -0
- kicad_sch_api/collections/__init__.py +2 -2
- kicad_sch_api/collections/base.py +5 -7
- kicad_sch_api/collections/components.py +24 -12
- kicad_sch_api/collections/junctions.py +31 -43
- kicad_sch_api/collections/labels.py +19 -27
- kicad_sch_api/collections/wires.py +17 -18
- kicad_sch_api/core/collections/__init__.py +5 -0
- kicad_sch_api/core/collections/base.py +248 -0
- kicad_sch_api/core/component_bounds.py +5 -0
- kicad_sch_api/core/components.py +67 -45
- kicad_sch_api/core/config.py +85 -3
- kicad_sch_api/core/factories/__init__.py +5 -0
- kicad_sch_api/core/factories/element_factory.py +276 -0
- kicad_sch_api/core/formatter.py +3 -1
- kicad_sch_api/core/junctions.py +26 -75
- kicad_sch_api/core/labels.py +29 -53
- kicad_sch_api/core/managers/__init__.py +26 -0
- kicad_sch_api/core/managers/file_io.py +244 -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 +269 -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 +475 -0
- kicad_sch_api/core/managers/wire.py +352 -0
- kicad_sch_api/core/nets.py +38 -43
- kicad_sch_api/core/no_connects.py +33 -55
- kicad_sch_api/core/parser.py +75 -1731
- kicad_sch_api/core/schematic.py +951 -1192
- kicad_sch_api/core/texts.py +28 -55
- kicad_sch_api/core/types.py +60 -22
- kicad_sch_api/core/wires.py +27 -75
- kicad_sch_api/geometry/font_metrics.py +3 -1
- kicad_sch_api/geometry/symbol_bbox.py +40 -21
- kicad_sch_api/interfaces/__init__.py +1 -1
- kicad_sch_api/interfaces/parser.py +1 -1
- kicad_sch_api/interfaces/repository.py +1 -1
- kicad_sch_api/interfaces/resolver.py +1 -1
- kicad_sch_api/parsers/__init__.py +2 -2
- kicad_sch_api/parsers/base.py +7 -10
- kicad_sch_api/parsers/elements/__init__.py +22 -0
- kicad_sch_api/parsers/elements/graphics_parser.py +564 -0
- kicad_sch_api/parsers/elements/label_parser.py +194 -0
- kicad_sch_api/parsers/elements/library_parser.py +165 -0
- kicad_sch_api/parsers/elements/metadata_parser.py +58 -0
- kicad_sch_api/parsers/elements/sheet_parser.py +352 -0
- kicad_sch_api/parsers/elements/symbol_parser.py +313 -0
- kicad_sch_api/parsers/elements/text_parser.py +250 -0
- kicad_sch_api/parsers/elements/wire_parser.py +242 -0
- kicad_sch_api/parsers/registry.py +4 -2
- kicad_sch_api/parsers/utils.py +80 -0
- kicad_sch_api/symbols/__init__.py +1 -1
- kicad_sch_api/symbols/cache.py +9 -12
- kicad_sch_api/symbols/resolver.py +20 -26
- kicad_sch_api/symbols/validators.py +188 -137
- kicad_sch_api/validation/__init__.py +25 -0
- kicad_sch_api/validation/erc.py +171 -0
- kicad_sch_api/validation/erc_models.py +203 -0
- kicad_sch_api/validation/pin_matrix.py +243 -0
- kicad_sch_api/validation/validators.py +391 -0
- {kicad_sch_api-0.3.5.dist-info → kicad_sch_api-0.4.1.dist-info}/METADATA +17 -9
- kicad_sch_api-0.4.1.dist-info/RECORD +87 -0
- kicad_sch_api/core/manhattan_routing.py +0 -430
- kicad_sch_api/core/simple_manhattan.py +0 -228
- kicad_sch_api/core/wire_routing.py +0 -380
- kicad_sch_api/parsers/label_parser.py +0 -254
- kicad_sch_api/parsers/symbol_parser.py +0 -227
- kicad_sch_api/parsers/wire_parser.py +0 -99
- kicad_sch_api-0.3.5.dist-info/RECORD +0 -58
- {kicad_sch_api-0.3.5.dist-info → kicad_sch_api-0.4.1.dist-info}/WHEEL +0 -0
- {kicad_sch_api-0.3.5.dist-info → kicad_sch_api-0.4.1.dist-info}/entry_points.txt +0 -0
- {kicad_sch_api-0.3.5.dist-info → kicad_sch_api-0.4.1.dist-info}/licenses/LICENSE +0 -0
- {kicad_sch_api-0.3.5.dist-info → kicad_sch_api-0.4.1.dist-info}/top_level.txt +0 -0
kicad_sch_api/core/junctions.py
CHANGED
|
@@ -9,55 +9,34 @@ import logging
|
|
|
9
9
|
import uuid as uuid_module
|
|
10
10
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
11
11
|
|
|
12
|
+
from .collections import BaseCollection
|
|
12
13
|
from .types import Junction, Point
|
|
13
14
|
|
|
14
15
|
logger = logging.getLogger(__name__)
|
|
15
16
|
|
|
16
17
|
|
|
17
|
-
class JunctionCollection:
|
|
18
|
+
class JunctionCollection(BaseCollection[Junction]):
|
|
18
19
|
"""
|
|
19
20
|
Professional junction collection with enhanced management features.
|
|
20
21
|
|
|
22
|
+
Inherits from BaseCollection for standard operations and adds junction-specific
|
|
23
|
+
functionality.
|
|
24
|
+
|
|
21
25
|
Features:
|
|
22
|
-
- Fast UUID-based lookup and indexing
|
|
26
|
+
- Fast UUID-based lookup and indexing (inherited)
|
|
23
27
|
- Position-based junction queries
|
|
24
|
-
- Bulk operations for performance
|
|
28
|
+
- Bulk operations for performance (inherited)
|
|
25
29
|
- Validation and conflict detection
|
|
26
30
|
"""
|
|
27
31
|
|
|
28
|
-
def __init__(self, junctions: Optional[List[Junction]] = None):
|
|
32
|
+
def __init__(self, junctions: Optional[List[Junction]] = None) -> None:
|
|
29
33
|
"""
|
|
30
34
|
Initialize junction collection.
|
|
31
35
|
|
|
32
36
|
Args:
|
|
33
37
|
junctions: Initial list of junctions
|
|
34
38
|
"""
|
|
35
|
-
|
|
36
|
-
self._uuid_index: Dict[str, int] = {}
|
|
37
|
-
self._modified = False
|
|
38
|
-
|
|
39
|
-
# Build UUID index
|
|
40
|
-
self._rebuild_index()
|
|
41
|
-
|
|
42
|
-
logger.debug(f"JunctionCollection initialized with {len(self._junctions)} junctions")
|
|
43
|
-
|
|
44
|
-
def _rebuild_index(self):
|
|
45
|
-
"""Rebuild UUID index for fast lookups."""
|
|
46
|
-
self._uuid_index = {junction.uuid: i for i, junction in enumerate(self._junctions)}
|
|
47
|
-
|
|
48
|
-
def __len__(self) -> int:
|
|
49
|
-
"""Number of junctions in collection."""
|
|
50
|
-
return len(self._junctions)
|
|
51
|
-
|
|
52
|
-
def __iter__(self):
|
|
53
|
-
"""Iterate over junctions."""
|
|
54
|
-
return iter(self._junctions)
|
|
55
|
-
|
|
56
|
-
def __getitem__(self, uuid: str) -> Junction:
|
|
57
|
-
"""Get junction by UUID."""
|
|
58
|
-
if uuid not in self._uuid_index:
|
|
59
|
-
raise KeyError(f"Junction with UUID '{uuid}' not found")
|
|
60
|
-
return self._junctions[self._uuid_index[uuid]]
|
|
39
|
+
super().__init__(junctions, collection_name="junctions")
|
|
61
40
|
|
|
62
41
|
def add(
|
|
63
42
|
self,
|
|
@@ -94,35 +73,12 @@ class JunctionCollection:
|
|
|
94
73
|
# Create junction
|
|
95
74
|
junction = Junction(uuid=uuid, position=position, diameter=diameter, color=color)
|
|
96
75
|
|
|
97
|
-
# Add to collection
|
|
98
|
-
self.
|
|
99
|
-
self._uuid_index[uuid] = len(self._junctions) - 1
|
|
100
|
-
self._modified = True
|
|
76
|
+
# Add to collection using base class method
|
|
77
|
+
self._add_item(junction)
|
|
101
78
|
|
|
102
79
|
logger.debug(f"Added junction at {position}, UUID={uuid}")
|
|
103
80
|
return uuid
|
|
104
81
|
|
|
105
|
-
def remove(self, uuid: str) -> bool:
|
|
106
|
-
"""
|
|
107
|
-
Remove junction by UUID.
|
|
108
|
-
|
|
109
|
-
Args:
|
|
110
|
-
uuid: Junction UUID to remove
|
|
111
|
-
|
|
112
|
-
Returns:
|
|
113
|
-
True if junction was removed, False if not found
|
|
114
|
-
"""
|
|
115
|
-
if uuid not in self._uuid_index:
|
|
116
|
-
return False
|
|
117
|
-
|
|
118
|
-
index = self._uuid_index[uuid]
|
|
119
|
-
del self._junctions[index]
|
|
120
|
-
self._rebuild_index()
|
|
121
|
-
self._modified = True
|
|
122
|
-
|
|
123
|
-
logger.debug(f"Removed junction: {uuid}")
|
|
124
|
-
return True
|
|
125
|
-
|
|
126
82
|
def get_at_position(
|
|
127
83
|
self, position: Union[Point, Tuple[float, float]], tolerance: float = 0.01
|
|
128
84
|
) -> Optional[Junction]:
|
|
@@ -139,7 +95,7 @@ class JunctionCollection:
|
|
|
139
95
|
if isinstance(position, tuple):
|
|
140
96
|
position = Point(position[0], position[1])
|
|
141
97
|
|
|
142
|
-
for junction in self.
|
|
98
|
+
for junction in self._items:
|
|
143
99
|
if junction.position.distance_to(position) <= tolerance:
|
|
144
100
|
return junction
|
|
145
101
|
|
|
@@ -162,40 +118,35 @@ class JunctionCollection:
|
|
|
162
118
|
point = Point(point[0], point[1])
|
|
163
119
|
|
|
164
120
|
matching_junctions = []
|
|
165
|
-
for junction in self.
|
|
121
|
+
for junction in self._items:
|
|
166
122
|
if junction.position.distance_to(point) <= tolerance:
|
|
167
123
|
matching_junctions.append(junction)
|
|
168
124
|
|
|
169
125
|
return matching_junctions
|
|
170
126
|
|
|
171
127
|
def get_statistics(self) -> Dict[str, Any]:
|
|
172
|
-
"""Get junction collection statistics."""
|
|
173
|
-
|
|
174
|
-
|
|
128
|
+
"""Get junction collection statistics (extends base statistics)."""
|
|
129
|
+
base_stats = super().get_statistics()
|
|
130
|
+
if not self._items:
|
|
131
|
+
return {**base_stats, "total_junctions": 0, "avg_diameter": 0, "positions": []}
|
|
175
132
|
|
|
176
|
-
avg_diameter = sum(j.diameter for j in self.
|
|
177
|
-
positions = [(j.position.x, j.position.y) for j in self.
|
|
133
|
+
avg_diameter = sum(j.diameter for j in self._items) / len(self._items)
|
|
134
|
+
positions = [(j.position.x, j.position.y) for j in self._items]
|
|
178
135
|
|
|
179
136
|
return {
|
|
180
|
-
|
|
137
|
+
**base_stats,
|
|
138
|
+
"total_junctions": len(self._items),
|
|
181
139
|
"avg_diameter": avg_diameter,
|
|
182
140
|
"positions": positions,
|
|
183
|
-
"unique_diameters": len(set(j.diameter for j in self.
|
|
184
|
-
"unique_colors": len(set(j.color for j in self.
|
|
141
|
+
"unique_diameters": len(set(j.diameter for j in self._items)),
|
|
142
|
+
"unique_colors": len(set(j.color for j in self._items)),
|
|
185
143
|
}
|
|
186
144
|
|
|
187
|
-
def clear(self):
|
|
188
|
-
"""Remove all junctions from collection."""
|
|
189
|
-
self._junctions.clear()
|
|
190
|
-
self._uuid_index.clear()
|
|
191
|
-
self._modified = True
|
|
192
|
-
logger.debug("Cleared all junctions")
|
|
193
|
-
|
|
194
145
|
@property
|
|
195
146
|
def modified(self) -> bool:
|
|
196
147
|
"""Check if collection has been modified."""
|
|
197
|
-
return self.
|
|
148
|
+
return self.is_modified()
|
|
198
149
|
|
|
199
|
-
def mark_saved(self):
|
|
150
|
+
def mark_saved(self) -> None:
|
|
200
151
|
"""Mark collection as saved (reset modified flag)."""
|
|
201
|
-
self.
|
|
152
|
+
self.reset_modified_flag()
|
kicad_sch_api/core/labels.py
CHANGED
|
@@ -10,7 +10,8 @@ import uuid
|
|
|
10
10
|
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, Union
|
|
11
11
|
|
|
12
12
|
from ..utils.validation import SchematicValidator, ValidationError, ValidationIssue
|
|
13
|
-
from .
|
|
13
|
+
from .collections import BaseCollection
|
|
14
|
+
from .types import Label, Point
|
|
14
15
|
|
|
15
16
|
logger = logging.getLogger(__name__)
|
|
16
17
|
|
|
@@ -114,10 +115,13 @@ class LabelElement:
|
|
|
114
115
|
return f"<Label '{self.text}' @ {self.position}>"
|
|
115
116
|
|
|
116
117
|
|
|
117
|
-
class LabelCollection:
|
|
118
|
+
class LabelCollection(BaseCollection[LabelElement]):
|
|
118
119
|
"""
|
|
119
120
|
Collection class for efficient label element management.
|
|
120
121
|
|
|
122
|
+
Inherits from BaseCollection for standard operations and adds label-specific
|
|
123
|
+
functionality including text-based indexing.
|
|
124
|
+
|
|
121
125
|
Provides fast lookup, filtering, and bulk operations for schematic label elements.
|
|
122
126
|
"""
|
|
123
127
|
|
|
@@ -128,18 +132,17 @@ class LabelCollection:
|
|
|
128
132
|
Args:
|
|
129
133
|
labels: Initial list of label data
|
|
130
134
|
"""
|
|
131
|
-
|
|
132
|
-
|
|
135
|
+
# Initialize base collection
|
|
136
|
+
super().__init__([], collection_name="labels")
|
|
137
|
+
|
|
138
|
+
# Additional label-specific index
|
|
133
139
|
self._text_index: Dict[str, List[LabelElement]] = {}
|
|
134
|
-
self._modified = False
|
|
135
140
|
|
|
136
141
|
# Add initial labels
|
|
137
142
|
if labels:
|
|
138
143
|
for label_data in labels:
|
|
139
144
|
self._add_to_indexes(LabelElement(label_data, self))
|
|
140
145
|
|
|
141
|
-
logger.debug(f"LabelCollection initialized with {len(self._labels)} labels")
|
|
142
|
-
|
|
143
146
|
def add(
|
|
144
147
|
self,
|
|
145
148
|
text: str,
|
|
@@ -201,9 +204,7 @@ class LabelCollection:
|
|
|
201
204
|
logger.debug(f"Added label: {label_element}")
|
|
202
205
|
return label_element
|
|
203
206
|
|
|
204
|
-
|
|
205
|
-
"""Get label by UUID."""
|
|
206
|
-
return self._uuid_index.get(label_uuid)
|
|
207
|
+
# get() method inherited from BaseCollection
|
|
207
208
|
|
|
208
209
|
def get_by_text(self, text: str) -> List[LabelElement]:
|
|
209
210
|
"""Get all labels with the given text."""
|
|
@@ -219,13 +220,19 @@ class LabelCollection:
|
|
|
219
220
|
Returns:
|
|
220
221
|
True if label was removed, False if not found
|
|
221
222
|
"""
|
|
222
|
-
label_element = self.
|
|
223
|
+
label_element = self.get(label_uuid)
|
|
223
224
|
if not label_element:
|
|
224
225
|
return False
|
|
225
226
|
|
|
226
|
-
# Remove from
|
|
227
|
-
|
|
228
|
-
self.
|
|
227
|
+
# Remove from text index
|
|
228
|
+
text = label_element.text
|
|
229
|
+
if text in self._text_index:
|
|
230
|
+
self._text_index[text].remove(label_element)
|
|
231
|
+
if not self._text_index[text]:
|
|
232
|
+
del self._text_index[text]
|
|
233
|
+
|
|
234
|
+
# Remove using base class method
|
|
235
|
+
super().remove(label_uuid)
|
|
229
236
|
|
|
230
237
|
logger.debug(f"Removed label: {label_element}")
|
|
231
238
|
return True
|
|
@@ -245,7 +252,7 @@ class LabelCollection:
|
|
|
245
252
|
return self._text_index.get(text, []).copy()
|
|
246
253
|
else:
|
|
247
254
|
matches = []
|
|
248
|
-
for label_element in self.
|
|
255
|
+
for label_element in self._items:
|
|
249
256
|
if text.lower() in label_element.text.lower():
|
|
250
257
|
matches.append(label_element)
|
|
251
258
|
return matches
|
|
@@ -260,7 +267,7 @@ class LabelCollection:
|
|
|
260
267
|
Returns:
|
|
261
268
|
List of labels matching predicate
|
|
262
269
|
"""
|
|
263
|
-
return [label for label in self.
|
|
270
|
+
return [label for label in self._items if predicate(label)]
|
|
264
271
|
|
|
265
272
|
def bulk_update(self, criteria: Callable[[LabelElement], bool], updates: Dict[str, Any]):
|
|
266
273
|
"""
|
|
@@ -271,7 +278,7 @@ class LabelCollection:
|
|
|
271
278
|
updates: Dictionary of property updates
|
|
272
279
|
"""
|
|
273
280
|
updated_count = 0
|
|
274
|
-
for label_element in self.
|
|
281
|
+
for label_element in self._items:
|
|
275
282
|
if criteria(label_element):
|
|
276
283
|
for prop, value in updates.items():
|
|
277
284
|
if hasattr(label_element, prop):
|
|
@@ -284,15 +291,12 @@ class LabelCollection:
|
|
|
284
291
|
|
|
285
292
|
def clear(self):
|
|
286
293
|
"""Remove all labels from collection."""
|
|
287
|
-
self._labels.clear()
|
|
288
|
-
self._uuid_index.clear()
|
|
289
294
|
self._text_index.clear()
|
|
290
|
-
|
|
295
|
+
super().clear()
|
|
291
296
|
|
|
292
297
|
def _add_to_indexes(self, label_element: LabelElement):
|
|
293
|
-
"""Add label to internal indexes."""
|
|
294
|
-
self.
|
|
295
|
-
self._uuid_index[label_element.uuid] = label_element
|
|
298
|
+
"""Add label to internal indexes (base + text index)."""
|
|
299
|
+
self._add_item(label_element)
|
|
296
300
|
|
|
297
301
|
# Add to text index
|
|
298
302
|
text = label_element.text
|
|
@@ -300,18 +304,6 @@ class LabelCollection:
|
|
|
300
304
|
self._text_index[text] = []
|
|
301
305
|
self._text_index[text].append(label_element)
|
|
302
306
|
|
|
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
307
|
def _update_text_index(self, old_text: str, label_element: LabelElement):
|
|
316
308
|
"""Update text index when label text changes."""
|
|
317
309
|
# Remove from old text index
|
|
@@ -326,23 +318,7 @@ class LabelCollection:
|
|
|
326
318
|
self._text_index[new_text] = []
|
|
327
319
|
self._text_index[new_text].append(label_element)
|
|
328
320
|
|
|
329
|
-
|
|
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
|
-
|
|
321
|
+
# Collection interface methods - __len__, __iter__, __getitem__ inherited from BaseCollection
|
|
346
322
|
def __bool__(self) -> bool:
|
|
347
323
|
"""Return True if collection has labels."""
|
|
348
|
-
return len(self.
|
|
324
|
+
return len(self._items) > 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,244 @@
|
|
|
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 ..config import config
|
|
15
|
+
from ..formatter import ExactFormatter
|
|
16
|
+
from ..parser import SExpressionParser
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FileIOManager:
|
|
22
|
+
"""
|
|
23
|
+
Manages file I/O operations for KiCAD schematics.
|
|
24
|
+
|
|
25
|
+
Responsible for:
|
|
26
|
+
- Loading schematic files with validation
|
|
27
|
+
- Saving with format preservation
|
|
28
|
+
- Creating backup files
|
|
29
|
+
- Managing file paths and metadata
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self):
|
|
33
|
+
"""Initialize the FileIOManager."""
|
|
34
|
+
self._parser = SExpressionParser(preserve_format=True)
|
|
35
|
+
self._formatter = ExactFormatter()
|
|
36
|
+
|
|
37
|
+
def load_schematic(self, file_path: Union[str, Path]) -> Dict[str, Any]:
|
|
38
|
+
"""
|
|
39
|
+
Load a KiCAD schematic file.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
file_path: Path to .kicad_sch file
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Parsed schematic data
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
FileNotFoundError: If file doesn't exist
|
|
49
|
+
ValidationError: If file is invalid or corrupted
|
|
50
|
+
"""
|
|
51
|
+
start_time = time.time()
|
|
52
|
+
file_path = Path(file_path)
|
|
53
|
+
|
|
54
|
+
if not file_path.exists():
|
|
55
|
+
raise FileNotFoundError(f"Schematic file not found: {file_path}")
|
|
56
|
+
|
|
57
|
+
if not file_path.suffix == ".kicad_sch":
|
|
58
|
+
raise ValidationError(f"Not a KiCAD schematic file: {file_path}")
|
|
59
|
+
|
|
60
|
+
logger.info(f"Loading schematic: {file_path}")
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
schematic_data = self._parser.parse_file(file_path)
|
|
64
|
+
load_time = time.time() - start_time
|
|
65
|
+
logger.info(f"Loaded schematic in {load_time:.3f}s")
|
|
66
|
+
|
|
67
|
+
return schematic_data
|
|
68
|
+
|
|
69
|
+
except Exception as e:
|
|
70
|
+
logger.error(f"Failed to load schematic {file_path}: {e}")
|
|
71
|
+
raise ValidationError(f"Invalid schematic file: {e}") from e
|
|
72
|
+
|
|
73
|
+
def save_schematic(
|
|
74
|
+
self,
|
|
75
|
+
schematic_data: Dict[str, Any],
|
|
76
|
+
file_path: Union[str, Path],
|
|
77
|
+
preserve_format: bool = True,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""
|
|
80
|
+
Save schematic data to file.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
schematic_data: Schematic data to save
|
|
84
|
+
file_path: Target file path
|
|
85
|
+
preserve_format: Whether to preserve exact formatting
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
PermissionError: If file cannot be written
|
|
89
|
+
ValidationError: If data is invalid
|
|
90
|
+
"""
|
|
91
|
+
start_time = time.time()
|
|
92
|
+
file_path = Path(file_path)
|
|
93
|
+
|
|
94
|
+
logger.info(f"Saving schematic: {file_path}")
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
# Ensure parent directory exists
|
|
98
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
|
|
100
|
+
# Convert to S-expression format and save
|
|
101
|
+
sexp_data = self._parser._schematic_data_to_sexp(schematic_data)
|
|
102
|
+
formatted_content = self._formatter.format(sexp_data)
|
|
103
|
+
|
|
104
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
105
|
+
f.write(formatted_content)
|
|
106
|
+
|
|
107
|
+
save_time = time.time() - start_time
|
|
108
|
+
logger.info(f"Saved schematic in {save_time:.3f}s")
|
|
109
|
+
|
|
110
|
+
except PermissionError as e:
|
|
111
|
+
logger.error(f"Permission denied saving to {file_path}: {e}")
|
|
112
|
+
raise
|
|
113
|
+
except Exception as e:
|
|
114
|
+
logger.error(f"Failed to save schematic to {file_path}: {e}")
|
|
115
|
+
raise ValidationError(f"Save failed: {e}") from e
|
|
116
|
+
|
|
117
|
+
def create_backup(self, file_path: Union[str, Path], suffix: str = ".backup") -> Path:
|
|
118
|
+
"""
|
|
119
|
+
Create a backup copy of the schematic file.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
file_path: Source file to backup
|
|
123
|
+
suffix: Backup file suffix
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Path to backup file
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
FileNotFoundError: If source file doesn't exist
|
|
130
|
+
PermissionError: If backup cannot be created
|
|
131
|
+
"""
|
|
132
|
+
file_path = Path(file_path)
|
|
133
|
+
|
|
134
|
+
if not file_path.exists():
|
|
135
|
+
raise FileNotFoundError(f"Cannot backup non-existent file: {file_path}")
|
|
136
|
+
|
|
137
|
+
# Create backup with timestamp if suffix doesn't include one
|
|
138
|
+
if suffix == ".backup":
|
|
139
|
+
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
|
140
|
+
backup_path = file_path.with_suffix(f".{timestamp}.backup")
|
|
141
|
+
else:
|
|
142
|
+
backup_path = file_path.with_suffix(f"{file_path.suffix}{suffix}")
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
# Copy file content
|
|
146
|
+
backup_path.write_bytes(file_path.read_bytes())
|
|
147
|
+
logger.info(f"Created backup: {backup_path}")
|
|
148
|
+
return backup_path
|
|
149
|
+
|
|
150
|
+
except Exception as e:
|
|
151
|
+
logger.error(f"Failed to create backup {backup_path}: {e}")
|
|
152
|
+
raise PermissionError(f"Backup failed: {e}") from e
|
|
153
|
+
|
|
154
|
+
def validate_file_path(self, file_path: Union[str, Path]) -> Path:
|
|
155
|
+
"""
|
|
156
|
+
Validate and normalize a file path for schematic operations.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
file_path: Path to validate
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Normalized Path object
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
ValidationError: If path is invalid
|
|
166
|
+
"""
|
|
167
|
+
file_path = Path(file_path)
|
|
168
|
+
|
|
169
|
+
# Ensure .kicad_sch extension
|
|
170
|
+
if not file_path.suffix:
|
|
171
|
+
file_path = file_path.with_suffix(".kicad_sch")
|
|
172
|
+
elif file_path.suffix != ".kicad_sch":
|
|
173
|
+
raise ValidationError(f"Invalid schematic file extension: {file_path.suffix}")
|
|
174
|
+
|
|
175
|
+
# Validate path characters
|
|
176
|
+
try:
|
|
177
|
+
file_path.resolve()
|
|
178
|
+
except (OSError, ValueError) as e:
|
|
179
|
+
raise ValidationError(f"Invalid file path: {e}") from e
|
|
180
|
+
|
|
181
|
+
return file_path
|
|
182
|
+
|
|
183
|
+
def get_file_info(self, file_path: Union[str, Path]) -> Dict[str, Any]:
|
|
184
|
+
"""
|
|
185
|
+
Get file system information about a schematic file.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
file_path: Path to analyze
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Dictionary with file information
|
|
192
|
+
|
|
193
|
+
Raises:
|
|
194
|
+
FileNotFoundError: If file doesn't exist
|
|
195
|
+
"""
|
|
196
|
+
file_path = Path(file_path)
|
|
197
|
+
|
|
198
|
+
if not file_path.exists():
|
|
199
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
200
|
+
|
|
201
|
+
stat = file_path.stat()
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
"path": str(file_path.resolve()),
|
|
205
|
+
"size": stat.st_size,
|
|
206
|
+
"modified": stat.st_mtime,
|
|
207
|
+
"created": getattr(stat, "st_birthtime", stat.st_ctime),
|
|
208
|
+
"readable": file_path.is_file() and file_path.exists(),
|
|
209
|
+
"writable": file_path.parent.exists() and file_path.parent.is_dir(),
|
|
210
|
+
"extension": file_path.suffix,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
def create_empty_schematic_data(self) -> Dict[str, Any]:
|
|
214
|
+
"""
|
|
215
|
+
Create empty schematic data structure.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
Empty schematic data dictionary
|
|
219
|
+
"""
|
|
220
|
+
return {
|
|
221
|
+
"kicad_sch": {
|
|
222
|
+
"version": 20230819,
|
|
223
|
+
"generator": config.file_format.generator_default,
|
|
224
|
+
"uuid": None, # Will be set by calling code
|
|
225
|
+
"paper": config.paper.default,
|
|
226
|
+
"lib_symbols": {},
|
|
227
|
+
"symbol": [],
|
|
228
|
+
"wire": [],
|
|
229
|
+
"junction": [],
|
|
230
|
+
"label": [],
|
|
231
|
+
"hierarchical_label": [],
|
|
232
|
+
"global_label": [],
|
|
233
|
+
"text": [],
|
|
234
|
+
"text_box": [],
|
|
235
|
+
"polyline": [],
|
|
236
|
+
"rectangle": [],
|
|
237
|
+
"circle": [],
|
|
238
|
+
"arc": [],
|
|
239
|
+
"image": [],
|
|
240
|
+
"sheet": [],
|
|
241
|
+
"sheet_instances": [],
|
|
242
|
+
"symbol_instances": [],
|
|
243
|
+
}
|
|
244
|
+
}
|