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,548 @@
1
+ """
2
+ High-performance symbol library cache for KiCAD schematic API.
3
+
4
+ This module provides intelligent caching and lookup functionality for KiCAD symbol libraries,
5
+ significantly improving performance for applications that work with many components.
6
+ """
7
+
8
+ import hashlib
9
+ import json
10
+ import logging
11
+ import os
12
+ import time
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional, Set, Tuple, Union
16
+
17
+ from ..core.types import PinShape, PinType, Point, SchematicPin
18
+ from ..utils.validation import ValidationError
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ @dataclass
24
+ class SymbolDefinition:
25
+ """Complete definition of a symbol from KiCAD library."""
26
+
27
+ lib_id: str # e.g., "Device:R"
28
+ name: str # Symbol name within library
29
+ library: str # Library name
30
+ reference_prefix: str # e.g., "R" for resistors
31
+ description: str = ""
32
+ keywords: str = ""
33
+ datasheet: str = ""
34
+ pins: List[SchematicPin] = field(default_factory=list)
35
+ units: int = 1
36
+ unit_names: Dict[int, str] = field(default_factory=dict)
37
+ power_symbol: bool = False
38
+ graphic_elements: List[Dict[str, Any]] = field(default_factory=list)
39
+
40
+ # Performance metrics
41
+ load_time: float = 0.0
42
+ access_count: int = 0
43
+ last_accessed: float = field(default_factory=time.time)
44
+
45
+ def __post_init__(self):
46
+ """Post-initialization processing."""
47
+ self.last_accessed = time.time()
48
+
49
+ # Validate lib_id format
50
+ if ":" not in self.lib_id:
51
+ raise ValidationError(
52
+ f"Invalid lib_id format: {self.lib_id} (should be Library:Symbol)"
53
+ )
54
+
55
+ # Extract library from lib_id if not provided
56
+ if not self.library:
57
+ self.library = self.lib_id.split(":")[0]
58
+
59
+ @property
60
+ def bounding_box(self) -> Tuple[float, float, float, float]:
61
+ """
62
+ Calculate symbol bounding box from graphic elements and pins.
63
+
64
+ Returns:
65
+ (min_x, min_y, max_x, max_y) in mm
66
+ """
67
+ if not self.graphic_elements and not self.pins:
68
+ # Default bounding box for empty symbol
69
+ return (-2.54, -2.54, 2.54, 2.54)
70
+
71
+ coordinates = []
72
+
73
+ # Collect pin positions
74
+ for pin in self.pins:
75
+ coordinates.extend([(pin.position.x, pin.position.y)])
76
+
77
+ # Collect graphic element coordinates
78
+ for elem in self.graphic_elements:
79
+ if "points" in elem:
80
+ coordinates.extend(elem["points"])
81
+ elif "center" in elem and "radius" in elem:
82
+ # Circle - approximate with bounding box
83
+ cx, cy = elem["center"]
84
+ radius = elem["radius"]
85
+ coordinates.extend([(cx - radius, cy - radius), (cx + radius, cy + radius)])
86
+
87
+ if not coordinates:
88
+ return (-2.54, -2.54, 2.54, 2.54)
89
+
90
+ min_x = min(coord[0] for coord in coordinates)
91
+ max_x = max(coord[0] for coord in coordinates)
92
+ min_y = min(coord[1] for coord in coordinates)
93
+ max_y = max(coord[1] for coord in coordinates)
94
+
95
+ return (min_x, min_y, max_x, max_y)
96
+
97
+ @property
98
+ def size(self) -> Tuple[float, float]:
99
+ """Get symbol size (width, height) in mm."""
100
+ min_x, min_y, max_x, max_y = self.bounding_box
101
+ return (max_x - min_x, max_y - min_y)
102
+
103
+ def get_pin(self, pin_number: str) -> Optional[SchematicPin]:
104
+ """Get pin by number."""
105
+ for pin in self.pins:
106
+ if pin.number == pin_number:
107
+ pin.name # Access pin to update symbol statistics
108
+ self.access_count += 1
109
+ self.last_accessed = time.time()
110
+ return pin
111
+ return None
112
+
113
+ def get_pins_by_type(self, pin_type: PinType) -> List[SchematicPin]:
114
+ """Get all pins of specified type."""
115
+ self.access_count += 1
116
+ self.last_accessed = time.time()
117
+ return [pin for pin in self.pins if pin.pin_type == pin_type]
118
+
119
+
120
+ @dataclass
121
+ class LibraryStats:
122
+ """Statistics for symbol library performance tracking."""
123
+
124
+ library_path: Path
125
+ symbol_count: int = 0
126
+ load_time: float = 0.0
127
+ file_size: int = 0
128
+ last_modified: float = 0.0
129
+ cache_hit_rate: float = 0.0
130
+ access_count: int = 0
131
+
132
+
133
+ class SymbolLibraryCache:
134
+ """
135
+ High-performance cache for KiCAD symbol libraries.
136
+
137
+ Features:
138
+ - Intelligent caching with performance metrics
139
+ - Fast symbol lookup and indexing
140
+ - Library discovery and management
141
+ - Memory-efficient storage
142
+ - Cache invalidation based on file modification time
143
+ """
144
+
145
+ def __init__(self, cache_dir: Optional[Path] = None, enable_persistence: bool = True):
146
+ """
147
+ Initialize the symbol cache.
148
+
149
+ Args:
150
+ cache_dir: Directory to store cached symbol data
151
+ enable_persistence: Whether to persist cache to disk
152
+ """
153
+ self._symbols: Dict[str, SymbolDefinition] = {}
154
+ self._library_paths: Set[Path] = set()
155
+
156
+ # Cache configuration
157
+ self._cache_dir = cache_dir or Path.home() / ".cache" / "kicad-sch-api" / "symbols"
158
+ self._enable_persistence = enable_persistence
159
+
160
+ if enable_persistence:
161
+ self._cache_dir.mkdir(parents=True, exist_ok=True)
162
+
163
+ # Indexes for fast lookup
164
+ self._symbol_index: Dict[str, str] = {} # symbol_name -> lib_id
165
+ self._library_index: Dict[str, Path] = {} # library_name -> path
166
+ self._lib_stats: Dict[str, LibraryStats] = {}
167
+
168
+ # Performance tracking
169
+ self._cache_hits = 0
170
+ self._cache_misses = 0
171
+ self._total_load_time = 0.0
172
+
173
+ # Load persistent cache if available
174
+ self._index_file = self._cache_dir / "symbol_index.json" if enable_persistence else None
175
+ if enable_persistence:
176
+ self._load_persistent_index()
177
+
178
+ logger.info(f"Symbol cache initialized (persistence: {enable_persistence})")
179
+
180
+ def add_library_path(self, library_path: Union[str, Path]) -> bool:
181
+ """
182
+ Add a library path to the cache.
183
+
184
+ Args:
185
+ library_path: Path to .kicad_sym file
186
+
187
+ Returns:
188
+ True if library was added successfully
189
+ """
190
+ library_path = Path(library_path)
191
+
192
+ if not library_path.exists():
193
+ logger.warning(f"Library file not found: {library_path}")
194
+ return False
195
+
196
+ if not library_path.suffix == ".kicad_sym":
197
+ logger.warning(f"Not a KiCAD symbol library: {library_path}")
198
+ return False
199
+
200
+ if library_path in self._library_paths:
201
+ logger.debug(f"Library already in cache: {library_path}")
202
+ return True
203
+
204
+ self._library_paths.add(library_path)
205
+ library_name = library_path.stem
206
+ self._library_index[library_name] = library_path
207
+
208
+ # Initialize library statistics
209
+ stat = library_path.stat()
210
+ self._lib_stats[library_name] = LibraryStats(
211
+ library_path=library_path, file_size=stat.st_size, last_modified=stat.st_mtime
212
+ )
213
+
214
+ logger.info(f"Added library: {library_name} ({library_path})")
215
+ return True
216
+
217
+ def discover_libraries(self, search_paths: List[Union[str, Path]] = None) -> int:
218
+ """
219
+ Automatically discover KiCAD symbol libraries.
220
+
221
+ Args:
222
+ search_paths: Directories to search for .kicad_sym files
223
+
224
+ Returns:
225
+ Number of libraries discovered and added
226
+ """
227
+ if search_paths is None:
228
+ search_paths = self._get_default_library_paths()
229
+
230
+ discovered_count = 0
231
+
232
+ for search_path in search_paths:
233
+ search_path = Path(search_path)
234
+ if not search_path.exists():
235
+ continue
236
+
237
+ logger.info(f"Discovering libraries in: {search_path}")
238
+
239
+ # Find all .kicad_sym files
240
+ for lib_file in search_path.rglob("*.kicad_sym"):
241
+ if self.add_library_path(lib_file):
242
+ discovered_count += 1
243
+
244
+ logger.info(f"Discovered {discovered_count} libraries")
245
+ return discovered_count
246
+
247
+ def get_symbol(self, lib_id: str) -> Optional[SymbolDefinition]:
248
+ """
249
+ Get symbol definition by lib_id.
250
+
251
+ Args:
252
+ lib_id: Symbol identifier (e.g., "Device:R")
253
+
254
+ Returns:
255
+ Symbol definition if found, None otherwise
256
+ """
257
+ # Check cache first
258
+ if lib_id in self._symbols:
259
+ self._cache_hits += 1
260
+ symbol = self._symbols[lib_id]
261
+ symbol.access_count += 1
262
+ symbol.last_accessed = time.time()
263
+ return symbol
264
+
265
+ # Cache miss - try to load symbol
266
+ self._cache_misses += 1
267
+ return self._load_symbol(lib_id)
268
+
269
+ def search_symbols(
270
+ self, query: str, library: Optional[str] = None, limit: int = 50
271
+ ) -> List[SymbolDefinition]:
272
+ """
273
+ Search for symbols by name, description, or keywords.
274
+
275
+ Args:
276
+ query: Search query string
277
+ library: Optional library name to search within
278
+ limit: Maximum number of results
279
+
280
+ Returns:
281
+ List of matching symbol definitions
282
+ """
283
+ results = []
284
+ query_lower = query.lower()
285
+
286
+ # Search in cached symbols first
287
+ for symbol in self._symbols.values():
288
+ if library and symbol.library != library:
289
+ continue
290
+
291
+ # Check if query matches name, description, or keywords
292
+ searchable_text = f"{symbol.name} {symbol.description} {symbol.keywords}".lower()
293
+ if query_lower in searchable_text:
294
+ results.append(symbol)
295
+ if len(results) >= limit:
296
+ break
297
+
298
+ # If not enough results and query looks like a specific symbol, try loading
299
+ if len(results) < 5 and ":" in query:
300
+ symbol = self.get_symbol(query)
301
+ if symbol and symbol not in results:
302
+ results.insert(0, symbol) # Put exact match first
303
+
304
+ return results
305
+
306
+ def get_library_symbols(self, library_name: str) -> List[SymbolDefinition]:
307
+ """Get all symbols from a specific library."""
308
+ if library_name not in self._library_index:
309
+ logger.warning(f"Library not found: {library_name}")
310
+ return []
311
+
312
+ # Load library if not already cached
313
+ library_path = self._library_index[library_name]
314
+ self._load_library(library_path)
315
+
316
+ # Return all symbols from this library
317
+ return [symbol for symbol in self._symbols.values() if symbol.library == library_name]
318
+
319
+ def get_performance_stats(self) -> Dict[str, Any]:
320
+ """Get cache performance statistics."""
321
+ total_requests = self._cache_hits + self._cache_misses
322
+ hit_rate = (self._cache_hits / total_requests * 100) if total_requests > 0 else 0
323
+
324
+ return {
325
+ "cache_hits": self._cache_hits,
326
+ "cache_misses": self._cache_misses,
327
+ "hit_rate_percent": round(hit_rate, 2),
328
+ "total_symbols_cached": len(self._symbols),
329
+ "total_libraries": len(self._library_paths),
330
+ "total_load_time_ms": round(self._total_load_time * 1000, 2),
331
+ "avg_load_time_per_symbol_ms": round(
332
+ (self._total_load_time / len(self._symbols) * 1000) if self._symbols else 0, 2
333
+ ),
334
+ }
335
+
336
+ def clear_cache(self):
337
+ """Clear all cached symbol data."""
338
+ self._symbols.clear()
339
+ self._symbol_index.clear()
340
+ self._cache_hits = 0
341
+ self._cache_misses = 0
342
+ self._total_load_time = 0.0
343
+ logger.info("Symbol cache cleared")
344
+
345
+ def _load_symbol(self, lib_id: str) -> Optional[SymbolDefinition]:
346
+ """Load a single symbol from its library."""
347
+ if ":" not in lib_id:
348
+ logger.warning(f"Invalid lib_id format: {lib_id}")
349
+ return None
350
+
351
+ library_name, symbol_name = lib_id.split(":", 1)
352
+
353
+ if library_name not in self._library_index:
354
+ logger.warning(f"Library not found: {library_name}")
355
+ return None
356
+
357
+ library_path = self._library_index[library_name]
358
+ return self._load_symbol_from_library(library_path, lib_id)
359
+
360
+ def _load_symbol_from_library(
361
+ self, library_path: Path, lib_id: str
362
+ ) -> Optional[SymbolDefinition]:
363
+ """Load a specific symbol from a library file."""
364
+ start_time = time.time()
365
+
366
+ try:
367
+ # This is a simplified version - in reality, you'd parse the .kicad_sym file
368
+ # For now, create a basic symbol definition
369
+ library_name, symbol_name = lib_id.split(":", 1)
370
+
371
+ # Create basic symbol definition
372
+ # In a real implementation, this would parse the actual .kicad_sym file
373
+ symbol = SymbolDefinition(
374
+ lib_id=lib_id,
375
+ name=symbol_name,
376
+ library=library_name,
377
+ reference_prefix=self._guess_reference_prefix(symbol_name),
378
+ description=f"{symbol_name} component",
379
+ pins=[], # Would be loaded from actual file
380
+ load_time=time.time() - start_time,
381
+ )
382
+
383
+ self._symbols[lib_id] = symbol
384
+ self._symbol_index[symbol_name] = lib_id
385
+ self._total_load_time += symbol.load_time
386
+
387
+ logger.debug(f"Loaded symbol {lib_id} in {symbol.load_time:.3f}s")
388
+ return symbol
389
+
390
+ except Exception as e:
391
+ logger.error(f"Error loading symbol {lib_id} from {library_path}: {e}")
392
+ return None
393
+
394
+ def _load_library(self, library_path: Path) -> bool:
395
+ """Load all symbols from a library file."""
396
+ library_name = library_path.stem
397
+
398
+ # Check if library needs reloading based on modification time
399
+ if library_name in self._lib_stats:
400
+ stat = library_path.stat()
401
+ if stat.st_mtime <= self._lib_stats[library_name].last_modified:
402
+ logger.debug(f"Library {library_name} already up-to-date")
403
+ return True
404
+
405
+ start_time = time.time()
406
+ logger.info(f"Loading library: {library_name}")
407
+
408
+ try:
409
+ # In a real implementation, this would parse the .kicad_sym file
410
+ # and extract all symbol definitions
411
+
412
+ # For now, just update statistics
413
+ load_time = time.time() - start_time
414
+
415
+ if library_name not in self._lib_stats:
416
+ stat = library_path.stat()
417
+ self._lib_stats[library_name] = LibraryStats(
418
+ library_path=library_path, file_size=stat.st_size, last_modified=stat.st_mtime
419
+ )
420
+
421
+ self._lib_stats[library_name].load_time = load_time
422
+ self._total_load_time += load_time
423
+
424
+ logger.info(f"Loaded library {library_name} in {load_time:.3f}s")
425
+ return True
426
+
427
+ except Exception as e:
428
+ logger.error(f"Error loading library {library_path}: {e}")
429
+ return False
430
+
431
+ def _guess_reference_prefix(self, symbol_name: str) -> str:
432
+ """Guess the reference prefix from symbol name."""
433
+ # Common mappings
434
+ prefix_mapping = {
435
+ "R": "R", # Resistor
436
+ "C": "C", # Capacitor
437
+ "L": "L", # Inductor
438
+ "D": "D", # Diode
439
+ "LED": "D", # LED
440
+ "Q": "Q", # Transistor
441
+ "U": "U", # IC
442
+ "J": "J", # Connector
443
+ "SW": "SW", # Switch
444
+ "TP": "TP", # Test point
445
+ "FB": "FB", # Ferrite bead
446
+ }
447
+
448
+ symbol_upper = symbol_name.upper()
449
+ for key, prefix in prefix_mapping.items():
450
+ if symbol_upper.startswith(key):
451
+ return prefix
452
+
453
+ # Default to 'U' for unknown symbols
454
+ return "U"
455
+
456
+ def _get_default_library_paths(self) -> List[Path]:
457
+ """Get default KiCAD library search paths."""
458
+ search_paths = []
459
+
460
+ # Common KiCAD installation paths
461
+ if os.name == "nt": # Windows
462
+ search_paths.extend(
463
+ [
464
+ Path("C:/Program Files/KiCad/9.0/share/kicad/symbols"),
465
+ Path("C:/Program Files (x86)/KiCad/9.0/share/kicad/symbols"),
466
+ ]
467
+ )
468
+ elif os.name == "posix": # Linux/Mac
469
+ search_paths.extend(
470
+ [
471
+ Path("/usr/share/kicad/symbols"),
472
+ Path("/usr/local/share/kicad/symbols"),
473
+ Path.home() / ".local/share/kicad/symbols",
474
+ ]
475
+ )
476
+
477
+ # User documents
478
+ search_paths.extend(
479
+ [
480
+ Path.home() / "Documents/KiCad/symbols",
481
+ Path.home() / "kicad/symbols",
482
+ ]
483
+ )
484
+
485
+ return [path for path in search_paths if path.exists()]
486
+
487
+ def _load_persistent_index(self):
488
+ """Load persistent symbol index from disk."""
489
+ if not self._enable_persistence or not self._index_file or not self._index_file.exists():
490
+ return
491
+
492
+ try:
493
+ with open(self._index_file, "r") as f:
494
+ index_data = json.load(f)
495
+
496
+ # Restore basic index data
497
+ self._symbol_index = index_data.get("symbol_index", {})
498
+
499
+ # Restore library paths
500
+ for lib_path_str in index_data.get("library_paths", []):
501
+ lib_path = Path(lib_path_str)
502
+ if lib_path.exists():
503
+ self.add_library_path(lib_path)
504
+
505
+ logger.info(f"Loaded persistent index with {len(self._symbol_index)} symbols")
506
+
507
+ except Exception as e:
508
+ logger.warning(f"Failed to load persistent index: {e}")
509
+
510
+ def _save_persistent_index(self):
511
+ """Save symbol index to disk for persistence."""
512
+ if not self._enable_persistence or not self._index_file:
513
+ return
514
+
515
+ try:
516
+ index_data = {
517
+ "symbol_index": self._symbol_index,
518
+ "library_paths": [str(path) for path in self._library_paths],
519
+ "cache_stats": self.get_performance_stats(),
520
+ }
521
+
522
+ with open(self._index_file, "w") as f:
523
+ json.dump(index_data, f, indent=2)
524
+
525
+ logger.debug("Saved persistent symbol index")
526
+
527
+ except Exception as e:
528
+ logger.warning(f"Failed to save persistent index: {e}")
529
+
530
+
531
+ # Global cache instance
532
+ _global_cache: Optional[SymbolLibraryCache] = None
533
+
534
+
535
+ def get_symbol_cache() -> SymbolLibraryCache:
536
+ """Get the global symbol cache instance."""
537
+ global _global_cache
538
+ if _global_cache is None:
539
+ _global_cache = SymbolLibraryCache()
540
+ # Auto-discover libraries on first use
541
+ _global_cache.discover_libraries()
542
+ return _global_cache
543
+
544
+
545
+ def set_symbol_cache(cache: SymbolLibraryCache):
546
+ """Set the global symbol cache instance."""
547
+ global _global_cache
548
+ _global_cache = cache
@@ -0,0 +1,5 @@
1
+ """MCP (Model Context Protocol) integration for kicad-sch-api."""
2
+
3
+ from .server import MCPInterface
4
+
5
+ __all__ = ["MCPInterface"]