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.

@@ -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)