kicad-sch-api 0.0.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.
Potentially problematic release.
This version of kicad-sch-api might be problematic. Click here for more details.
- kicad_sch_api/__init__.py +112 -0
- kicad_sch_api/core/__init__.py +23 -0
- kicad_sch_api/core/components.py +652 -0
- kicad_sch_api/core/formatter.py +312 -0
- kicad_sch_api/core/parser.py +434 -0
- kicad_sch_api/core/schematic.py +478 -0
- kicad_sch_api/core/types.py +369 -0
- kicad_sch_api/library/__init__.py +10 -0
- kicad_sch_api/library/cache.py +548 -0
- kicad_sch_api/mcp/__init__.py +5 -0
- kicad_sch_api/mcp/server.py +500 -0
- kicad_sch_api/py.typed +1 -0
- kicad_sch_api/utils/__init__.py +15 -0
- kicad_sch_api/utils/validation.py +447 -0
- kicad_sch_api-0.0.1.dist-info/METADATA +226 -0
- kicad_sch_api-0.0.1.dist-info/RECORD +20 -0
- kicad_sch_api-0.0.1.dist-info/WHEEL +5 -0
- kicad_sch_api-0.0.1.dist-info/entry_points.txt +2 -0
- kicad_sch_api-0.0.1.dist-info/licenses/LICENSE +21 -0
- kicad_sch_api-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main Schematic class for KiCAD schematic manipulation.
|
|
3
|
+
|
|
4
|
+
This module provides the primary interface for loading, modifying, and saving
|
|
5
|
+
KiCAD schematic files with exact format preservation and professional features.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
13
|
+
|
|
14
|
+
from ..library.cache import get_symbol_cache
|
|
15
|
+
from ..utils.validation import SchematicValidator, ValidationError, ValidationIssue
|
|
16
|
+
from .components import ComponentCollection
|
|
17
|
+
from .formatter import ExactFormatter
|
|
18
|
+
from .parser import SExpressionParser
|
|
19
|
+
from .types import Junction, Label, Net, Point, SchematicSymbol, TitleBlock, Wire
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Schematic:
|
|
25
|
+
"""
|
|
26
|
+
Professional KiCAD schematic manipulation class.
|
|
27
|
+
|
|
28
|
+
Features:
|
|
29
|
+
- Exact format preservation
|
|
30
|
+
- Enhanced component management with fast lookup
|
|
31
|
+
- Advanced library integration
|
|
32
|
+
- Comprehensive validation
|
|
33
|
+
- Performance optimization for large schematics
|
|
34
|
+
- AI agent integration via MCP
|
|
35
|
+
|
|
36
|
+
This class provides a modern, intuitive API while maintaining exact compatibility
|
|
37
|
+
with KiCAD's native file format.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, schematic_data: Dict[str, Any] = None, file_path: Optional[str] = None):
|
|
41
|
+
"""
|
|
42
|
+
Initialize schematic object.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
schematic_data: Parsed schematic data
|
|
46
|
+
file_path: Original file path (for format preservation)
|
|
47
|
+
"""
|
|
48
|
+
# Core data
|
|
49
|
+
self._data = schematic_data or self._create_empty_schematic_data()
|
|
50
|
+
self._file_path = Path(file_path) if file_path else None
|
|
51
|
+
self._original_content = self._data.get("_original_content", "")
|
|
52
|
+
|
|
53
|
+
# Initialize parser and formatter
|
|
54
|
+
self._parser = SExpressionParser(preserve_format=True)
|
|
55
|
+
self._formatter = ExactFormatter()
|
|
56
|
+
self._validator = SchematicValidator()
|
|
57
|
+
|
|
58
|
+
# Initialize component collection
|
|
59
|
+
component_symbols = [
|
|
60
|
+
SchematicSymbol(**comp) if isinstance(comp, dict) else comp
|
|
61
|
+
for comp in self._data.get("components", [])
|
|
62
|
+
]
|
|
63
|
+
self._components = ComponentCollection(component_symbols)
|
|
64
|
+
|
|
65
|
+
# Track modifications for save optimization
|
|
66
|
+
self._modified = False
|
|
67
|
+
self._last_save_time = None
|
|
68
|
+
|
|
69
|
+
# Performance tracking
|
|
70
|
+
self._operation_count = 0
|
|
71
|
+
self._total_operation_time = 0.0
|
|
72
|
+
|
|
73
|
+
logger.debug(f"Schematic initialized with {len(self._components)} components")
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def load(cls, file_path: Union[str, Path]) -> "Schematic":
|
|
77
|
+
"""
|
|
78
|
+
Load a KiCAD schematic file.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
file_path: Path to .kicad_sch file
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Loaded Schematic object
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
FileNotFoundError: If file doesn't exist
|
|
88
|
+
ValidationError: If file is invalid or corrupted
|
|
89
|
+
"""
|
|
90
|
+
start_time = time.time()
|
|
91
|
+
file_path = Path(file_path)
|
|
92
|
+
|
|
93
|
+
logger.info(f"Loading schematic: {file_path}")
|
|
94
|
+
|
|
95
|
+
parser = SExpressionParser(preserve_format=True)
|
|
96
|
+
schematic_data = parser.parse_file(file_path)
|
|
97
|
+
|
|
98
|
+
load_time = time.time() - start_time
|
|
99
|
+
logger.info(f"Loaded schematic in {load_time:.3f}s")
|
|
100
|
+
|
|
101
|
+
return cls(schematic_data, str(file_path))
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def create(cls, name: str = "Untitled", version: str = "20230121") -> "Schematic":
|
|
105
|
+
"""
|
|
106
|
+
Create a new empty schematic.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
name: Schematic name
|
|
110
|
+
version: KiCAD version string
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
New empty Schematic object
|
|
114
|
+
"""
|
|
115
|
+
schematic_data = cls._create_empty_schematic_data()
|
|
116
|
+
schematic_data["version"] = version
|
|
117
|
+
schematic_data["title_block"] = {"title": name}
|
|
118
|
+
|
|
119
|
+
logger.info(f"Created new schematic: {name}")
|
|
120
|
+
return cls(schematic_data)
|
|
121
|
+
|
|
122
|
+
# Core properties
|
|
123
|
+
@property
|
|
124
|
+
def components(self) -> ComponentCollection:
|
|
125
|
+
"""Collection of all components in the schematic."""
|
|
126
|
+
return self._components
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def version(self) -> Optional[str]:
|
|
130
|
+
"""KiCAD version string."""
|
|
131
|
+
return self._data.get("version")
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def generator(self) -> Optional[str]:
|
|
135
|
+
"""Generator string (e.g., 'eeschema')."""
|
|
136
|
+
return self._data.get("generator")
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def uuid(self) -> Optional[str]:
|
|
140
|
+
"""Schematic UUID."""
|
|
141
|
+
return self._data.get("uuid")
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def title_block(self) -> Dict[str, Any]:
|
|
145
|
+
"""Title block information."""
|
|
146
|
+
return self._data.get("title_block", {})
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def file_path(self) -> Optional[Path]:
|
|
150
|
+
"""Current file path."""
|
|
151
|
+
return self._file_path
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def modified(self) -> bool:
|
|
155
|
+
"""Whether schematic has been modified since last save."""
|
|
156
|
+
return self._modified or self._components._modified
|
|
157
|
+
|
|
158
|
+
# File operations
|
|
159
|
+
def save(self, file_path: Optional[Union[str, Path]] = None, preserve_format: bool = True):
|
|
160
|
+
"""
|
|
161
|
+
Save schematic to file.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
file_path: Output file path (uses current path if None)
|
|
165
|
+
preserve_format: Whether to preserve exact formatting
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
ValidationError: If schematic data is invalid
|
|
169
|
+
"""
|
|
170
|
+
start_time = time.time()
|
|
171
|
+
|
|
172
|
+
# Use current file path if not specified
|
|
173
|
+
if file_path is None:
|
|
174
|
+
if self._file_path is None:
|
|
175
|
+
raise ValidationError("No file path specified and no current file")
|
|
176
|
+
file_path = self._file_path
|
|
177
|
+
else:
|
|
178
|
+
file_path = Path(file_path)
|
|
179
|
+
self._file_path = file_path
|
|
180
|
+
|
|
181
|
+
# Validate before saving
|
|
182
|
+
issues = self.validate()
|
|
183
|
+
errors = [issue for issue in issues if issue.level.value in ("error", "critical")]
|
|
184
|
+
if errors:
|
|
185
|
+
raise ValidationError("Cannot save schematic with validation errors", errors)
|
|
186
|
+
|
|
187
|
+
# Update data structure with current component state
|
|
188
|
+
self._sync_components_to_data()
|
|
189
|
+
|
|
190
|
+
# Write file
|
|
191
|
+
if preserve_format and self._original_content:
|
|
192
|
+
# Use format-preserving writer
|
|
193
|
+
sexp_data = self._parser._schematic_data_to_sexp(self._data)
|
|
194
|
+
content = self._formatter.format_preserving_write(sexp_data, self._original_content)
|
|
195
|
+
else:
|
|
196
|
+
# Standard formatting
|
|
197
|
+
sexp_data = self._parser._schematic_data_to_sexp(self._data)
|
|
198
|
+
content = self._formatter.format(sexp_data)
|
|
199
|
+
|
|
200
|
+
# Ensure directory exists
|
|
201
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
|
|
203
|
+
# Write to file
|
|
204
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
205
|
+
f.write(content)
|
|
206
|
+
|
|
207
|
+
# Update state
|
|
208
|
+
self._modified = False
|
|
209
|
+
self._components._modified = False
|
|
210
|
+
self._last_save_time = time.time()
|
|
211
|
+
|
|
212
|
+
save_time = time.time() - start_time
|
|
213
|
+
logger.info(f"Saved schematic to {file_path} in {save_time:.3f}s")
|
|
214
|
+
|
|
215
|
+
def save_as(self, file_path: Union[str, Path], preserve_format: bool = True):
|
|
216
|
+
"""Save schematic to a new file path."""
|
|
217
|
+
self.save(file_path, preserve_format)
|
|
218
|
+
|
|
219
|
+
def backup(self, suffix: str = ".backup") -> Path:
|
|
220
|
+
"""
|
|
221
|
+
Create a backup of the current schematic file.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
suffix: Suffix to add to backup filename
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
Path to backup file
|
|
228
|
+
"""
|
|
229
|
+
if not self._file_path:
|
|
230
|
+
raise ValidationError("Cannot backup - no file path set")
|
|
231
|
+
|
|
232
|
+
backup_path = self._file_path.with_suffix(self._file_path.suffix + suffix)
|
|
233
|
+
|
|
234
|
+
if self._file_path.exists():
|
|
235
|
+
import shutil
|
|
236
|
+
|
|
237
|
+
shutil.copy2(self._file_path, backup_path)
|
|
238
|
+
logger.info(f"Created backup: {backup_path}")
|
|
239
|
+
|
|
240
|
+
return backup_path
|
|
241
|
+
|
|
242
|
+
# Validation and analysis
|
|
243
|
+
def validate(self) -> List[ValidationIssue]:
|
|
244
|
+
"""
|
|
245
|
+
Validate the schematic for errors and issues.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
List of validation issues found
|
|
249
|
+
"""
|
|
250
|
+
# Sync current state to data for validation
|
|
251
|
+
self._sync_components_to_data()
|
|
252
|
+
|
|
253
|
+
# Use validator to check schematic
|
|
254
|
+
issues = self._validator.validate_schematic_data(self._data)
|
|
255
|
+
|
|
256
|
+
# Add component-level validation
|
|
257
|
+
component_issues = self._components.validate_all()
|
|
258
|
+
issues.extend(component_issues)
|
|
259
|
+
|
|
260
|
+
return issues
|
|
261
|
+
|
|
262
|
+
def get_summary(self) -> Dict[str, Any]:
|
|
263
|
+
"""Get summary information about the schematic."""
|
|
264
|
+
component_stats = self._components.get_statistics()
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
"file_path": str(self._file_path) if self._file_path else None,
|
|
268
|
+
"version": self.version,
|
|
269
|
+
"uuid": self.uuid,
|
|
270
|
+
"title": self.title_block.get("title", ""),
|
|
271
|
+
"component_count": len(self._components),
|
|
272
|
+
"modified": self.modified,
|
|
273
|
+
"last_save": self._last_save_time,
|
|
274
|
+
"component_stats": component_stats,
|
|
275
|
+
"performance": {
|
|
276
|
+
"operation_count": self._operation_count,
|
|
277
|
+
"avg_operation_time_ms": round(
|
|
278
|
+
(
|
|
279
|
+
(self._total_operation_time / self._operation_count * 1000)
|
|
280
|
+
if self._operation_count > 0
|
|
281
|
+
else 0
|
|
282
|
+
),
|
|
283
|
+
2,
|
|
284
|
+
),
|
|
285
|
+
},
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
# Wire and connection management (basic implementation)
|
|
289
|
+
def add_wire(
|
|
290
|
+
self, start: Union[Point, Tuple[float, float]], end: Union[Point, Tuple[float, float]]
|
|
291
|
+
) -> str:
|
|
292
|
+
"""
|
|
293
|
+
Add a wire connection.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
start: Start point
|
|
297
|
+
end: End point
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
UUID of created wire
|
|
301
|
+
"""
|
|
302
|
+
if isinstance(start, tuple):
|
|
303
|
+
start = Point(start[0], start[1])
|
|
304
|
+
if isinstance(end, tuple):
|
|
305
|
+
end = Point(end[0], end[1])
|
|
306
|
+
|
|
307
|
+
wire = Wire(uuid=str(uuid.uuid4()), start=start, end=end)
|
|
308
|
+
|
|
309
|
+
if "wires" not in self._data:
|
|
310
|
+
self._data["wires"] = []
|
|
311
|
+
|
|
312
|
+
self._data["wires"].append(wire.__dict__)
|
|
313
|
+
self._modified = True
|
|
314
|
+
|
|
315
|
+
logger.debug(f"Added wire: {start} -> {end}")
|
|
316
|
+
return wire.uuid
|
|
317
|
+
|
|
318
|
+
def remove_wire(self, wire_uuid: str) -> bool:
|
|
319
|
+
"""Remove wire by UUID."""
|
|
320
|
+
wires = self._data.get("wires", [])
|
|
321
|
+
for i, wire in enumerate(wires):
|
|
322
|
+
if wire.get("uuid") == wire_uuid:
|
|
323
|
+
del wires[i]
|
|
324
|
+
self._modified = True
|
|
325
|
+
logger.debug(f"Removed wire: {wire_uuid}")
|
|
326
|
+
return True
|
|
327
|
+
return False
|
|
328
|
+
|
|
329
|
+
# Library management
|
|
330
|
+
@property
|
|
331
|
+
def libraries(self) -> "LibraryManager":
|
|
332
|
+
"""Access to library management."""
|
|
333
|
+
if not hasattr(self, "_library_manager"):
|
|
334
|
+
from ..library.manager import LibraryManager
|
|
335
|
+
|
|
336
|
+
self._library_manager = LibraryManager(self)
|
|
337
|
+
return self._library_manager
|
|
338
|
+
|
|
339
|
+
# Utility methods
|
|
340
|
+
def clear(self):
|
|
341
|
+
"""Clear all components, wires, and other elements."""
|
|
342
|
+
self._data["components"] = []
|
|
343
|
+
self._data["wires"] = []
|
|
344
|
+
self._data["junctions"] = []
|
|
345
|
+
self._data["labels"] = []
|
|
346
|
+
self._components = ComponentCollection()
|
|
347
|
+
self._modified = True
|
|
348
|
+
logger.info("Cleared schematic")
|
|
349
|
+
|
|
350
|
+
def clone(self, new_name: Optional[str] = None) -> "Schematic":
|
|
351
|
+
"""Create a copy of this schematic."""
|
|
352
|
+
import copy
|
|
353
|
+
|
|
354
|
+
cloned_data = copy.deepcopy(self._data)
|
|
355
|
+
|
|
356
|
+
if new_name:
|
|
357
|
+
cloned_data["title_block"]["title"] = new_name
|
|
358
|
+
cloned_data["uuid"] = str(uuid.uuid4()) # New UUID for clone
|
|
359
|
+
|
|
360
|
+
return Schematic(cloned_data)
|
|
361
|
+
|
|
362
|
+
# Performance optimization
|
|
363
|
+
def rebuild_indexes(self):
|
|
364
|
+
"""Rebuild internal indexes for performance."""
|
|
365
|
+
# This would rebuild component indexes, etc.
|
|
366
|
+
logger.info("Rebuilt schematic indexes")
|
|
367
|
+
|
|
368
|
+
def get_performance_stats(self) -> Dict[str, Any]:
|
|
369
|
+
"""Get performance statistics."""
|
|
370
|
+
cache_stats = get_symbol_cache().get_performance_stats()
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
"schematic": {
|
|
374
|
+
"operation_count": self._operation_count,
|
|
375
|
+
"total_operation_time_s": round(self._total_operation_time, 3),
|
|
376
|
+
"avg_operation_time_ms": round(
|
|
377
|
+
(
|
|
378
|
+
(self._total_operation_time / self._operation_count * 1000)
|
|
379
|
+
if self._operation_count > 0
|
|
380
|
+
else 0
|
|
381
|
+
),
|
|
382
|
+
2,
|
|
383
|
+
),
|
|
384
|
+
},
|
|
385
|
+
"components": self._components.get_statistics(),
|
|
386
|
+
"symbol_cache": cache_stats,
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
# Internal methods
|
|
390
|
+
def _sync_components_to_data(self):
|
|
391
|
+
"""Sync component collection state back to data structure."""
|
|
392
|
+
self._data["components"] = [comp._data.__dict__ for comp in self._components]
|
|
393
|
+
|
|
394
|
+
@staticmethod
|
|
395
|
+
def _create_empty_schematic_data() -> Dict[str, Any]:
|
|
396
|
+
"""Create empty schematic data structure."""
|
|
397
|
+
return {
|
|
398
|
+
"version": "20230121",
|
|
399
|
+
"generator": "kicad-sch-api",
|
|
400
|
+
"uuid": str(uuid.uuid4()),
|
|
401
|
+
"title_block": {
|
|
402
|
+
"title": "Untitled",
|
|
403
|
+
"date": "",
|
|
404
|
+
"revision": "1.0",
|
|
405
|
+
"company": "",
|
|
406
|
+
"size": "A4",
|
|
407
|
+
},
|
|
408
|
+
"components": [],
|
|
409
|
+
"wires": [],
|
|
410
|
+
"junctions": [],
|
|
411
|
+
"labels": [],
|
|
412
|
+
"nets": [],
|
|
413
|
+
"lib_symbols": {},
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
# Context manager support for atomic operations
|
|
417
|
+
def __enter__(self):
|
|
418
|
+
"""Enter atomic operation context."""
|
|
419
|
+
# Create backup for potential rollback
|
|
420
|
+
if self._file_path and self._file_path.exists():
|
|
421
|
+
self._backup_path = self.backup(".atomic_backup")
|
|
422
|
+
return self
|
|
423
|
+
|
|
424
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
425
|
+
"""Exit atomic operation context."""
|
|
426
|
+
if exc_type is not None:
|
|
427
|
+
# Exception occurred - rollback if possible
|
|
428
|
+
if hasattr(self, "_backup_path") and self._backup_path.exists():
|
|
429
|
+
logger.warning("Exception in atomic operation - rolling back")
|
|
430
|
+
# Restore from backup
|
|
431
|
+
restored_data = self._parser.parse_file(self._backup_path)
|
|
432
|
+
self._data = restored_data
|
|
433
|
+
self._modified = True
|
|
434
|
+
else:
|
|
435
|
+
# Success - clean up backup
|
|
436
|
+
if hasattr(self, "_backup_path") and self._backup_path.exists():
|
|
437
|
+
self._backup_path.unlink()
|
|
438
|
+
|
|
439
|
+
def __str__(self) -> str:
|
|
440
|
+
"""String representation."""
|
|
441
|
+
title = self.title_block.get("title", "Untitled")
|
|
442
|
+
component_count = len(self._components)
|
|
443
|
+
return f"<Schematic '{title}': {component_count} components>"
|
|
444
|
+
|
|
445
|
+
def __repr__(self) -> str:
|
|
446
|
+
"""Detailed representation."""
|
|
447
|
+
return (
|
|
448
|
+
f"Schematic(file='{self._file_path}', "
|
|
449
|
+
f"components={len(self._components)}, "
|
|
450
|
+
f"modified={self.modified})"
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
# Convenience functions for common operations
|
|
455
|
+
def load_schematic(file_path: Union[str, Path]) -> Schematic:
|
|
456
|
+
"""
|
|
457
|
+
Load a KiCAD schematic file.
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
file_path: Path to .kicad_sch file
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
Loaded Schematic object
|
|
464
|
+
"""
|
|
465
|
+
return Schematic.load(file_path)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def create_schematic(name: str = "New Circuit") -> Schematic:
|
|
469
|
+
"""
|
|
470
|
+
Create a new empty schematic.
|
|
471
|
+
|
|
472
|
+
Args:
|
|
473
|
+
name: Schematic name for title block
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
New Schematic object
|
|
477
|
+
"""
|
|
478
|
+
return Schematic.create(name)
|