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,434 @@
1
+ """
2
+ S-expression parser for KiCAD schematic files.
3
+
4
+ This module provides robust parsing and writing capabilities for KiCAD's S-expression format,
5
+ with exact format preservation and enhanced error handling.
6
+ """
7
+
8
+ import logging
9
+ import uuid
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional, Tuple, Union
12
+
13
+ import sexpdata
14
+
15
+ from ..utils.validation import ValidationError, ValidationIssue
16
+ from .formatter import ExactFormatter
17
+ from .types import Junction, Label, Net, Point, SchematicSymbol, Wire
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class SExpressionParser:
23
+ """
24
+ High-performance S-expression parser for KiCAD schematic files.
25
+
26
+ Features:
27
+ - Exact format preservation
28
+ - Enhanced error handling with detailed validation
29
+ - Optimized for large schematics
30
+ - Support for KiCAD 9 format
31
+ """
32
+
33
+ def __init__(self, preserve_format: bool = True):
34
+ """
35
+ Initialize the parser.
36
+
37
+ Args:
38
+ preserve_format: If True, preserve exact formatting when writing
39
+ """
40
+ self.preserve_format = preserve_format
41
+ self._formatter = ExactFormatter() if preserve_format else None
42
+ self._validation_issues = []
43
+ logger.info(f"S-expression parser initialized (format preservation: {preserve_format})")
44
+
45
+ def parse_file(self, filepath: Union[str, Path]) -> Dict[str, Any]:
46
+ """
47
+ Parse a KiCAD schematic file with comprehensive validation.
48
+
49
+ Args:
50
+ filepath: Path to the .kicad_sch file
51
+
52
+ Returns:
53
+ Parsed schematic data structure
54
+
55
+ Raises:
56
+ FileNotFoundError: If file doesn't exist
57
+ ValidationError: If parsing fails or validation issues found
58
+ """
59
+ filepath = Path(filepath)
60
+ if not filepath.exists():
61
+ raise FileNotFoundError(f"Schematic file not found: {filepath}")
62
+
63
+ logger.info(f"Parsing schematic file: {filepath}")
64
+
65
+ try:
66
+ # Read file content
67
+ with open(filepath, "r", encoding="utf-8") as f:
68
+ content = f.read()
69
+
70
+ # Parse S-expression
71
+ sexp_data = self.parse_string(content)
72
+
73
+ # Validate structure
74
+ self._validate_schematic_structure(sexp_data, filepath)
75
+
76
+ # Convert to internal format
77
+ schematic_data = self._sexp_to_schematic_data(sexp_data)
78
+ schematic_data["_original_content"] = content # Store for format preservation
79
+ schematic_data["_file_path"] = str(filepath)
80
+
81
+ logger.info(
82
+ f"Successfully parsed schematic with {len(schematic_data.get('components', []))} components"
83
+ )
84
+ return schematic_data
85
+
86
+ except Exception as e:
87
+ logger.error(f"Error parsing {filepath}: {e}")
88
+ raise ValidationError(f"Failed to parse schematic: {e}") from e
89
+
90
+ def parse_string(self, content: str) -> Any:
91
+ """
92
+ Parse S-expression content from string.
93
+
94
+ Args:
95
+ content: S-expression string content
96
+
97
+ Returns:
98
+ Parsed S-expression data structure
99
+
100
+ Raises:
101
+ ValidationError: If parsing fails
102
+ """
103
+ try:
104
+ return sexpdata.loads(content)
105
+ except Exception as e:
106
+ raise ValidationError(f"Invalid S-expression format: {e}") from e
107
+
108
+ def write_file(self, schematic_data: Dict[str, Any], filepath: Union[str, Path]):
109
+ """
110
+ Write schematic data to file with exact format preservation.
111
+
112
+ Args:
113
+ schematic_data: Schematic data structure
114
+ filepath: Path to write to
115
+ """
116
+ filepath = Path(filepath)
117
+
118
+ # Convert internal format to S-expression
119
+ sexp_data = self._schematic_data_to_sexp(schematic_data)
120
+
121
+ # Format content
122
+ if self.preserve_format and "_original_content" in schematic_data:
123
+ # Use format-preserving writer
124
+ content = self._formatter.format_preserving_write(
125
+ sexp_data, schematic_data["_original_content"]
126
+ )
127
+ else:
128
+ # Standard S-expression formatting
129
+ content = self.dumps(sexp_data)
130
+
131
+ # Ensure directory exists
132
+ filepath.parent.mkdir(parents=True, exist_ok=True)
133
+
134
+ # Write to file
135
+ with open(filepath, "w", encoding="utf-8") as f:
136
+ f.write(content)
137
+
138
+ logger.info(f"Schematic written to: {filepath}")
139
+
140
+ def dumps(self, data: Any, pretty: bool = True) -> str:
141
+ """
142
+ Convert S-expression data to string.
143
+
144
+ Args:
145
+ data: S-expression data structure
146
+ pretty: If True, format with proper indentation
147
+
148
+ Returns:
149
+ Formatted S-expression string
150
+ """
151
+ if pretty and self._formatter:
152
+ return self._formatter.format(data)
153
+ else:
154
+ return sexpdata.dumps(data)
155
+
156
+ def _validate_schematic_structure(self, sexp_data: Any, filepath: Path):
157
+ """Validate the basic structure of a KiCAD schematic."""
158
+ self._validation_issues.clear()
159
+
160
+ if not isinstance(sexp_data, list) or len(sexp_data) == 0:
161
+ self._validation_issues.append(
162
+ ValidationIssue("structure", "Invalid schematic format: not a list", "error")
163
+ )
164
+
165
+ # Check for kicad_sch header
166
+ if not (isinstance(sexp_data[0], sexpdata.Symbol) and str(sexp_data[0]) == "kicad_sch"):
167
+ self._validation_issues.append(
168
+ ValidationIssue("format", "Missing kicad_sch header", "error")
169
+ )
170
+
171
+ # Collect validation issues and raise if any errors found
172
+ errors = [issue for issue in self._validation_issues if issue.level == "error"]
173
+ if errors:
174
+ error_messages = [f"{issue.category}: {issue.message}" for issue in errors]
175
+ raise ValidationError(f"Validation failed: {'; '.join(error_messages)}")
176
+
177
+ def _sexp_to_schematic_data(self, sexp_data: List[Any]) -> Dict[str, Any]:
178
+ """Convert S-expression data to internal schematic format."""
179
+ schematic_data = {
180
+ "version": None,
181
+ "generator": None,
182
+ "uuid": None,
183
+ "title_block": {},
184
+ "components": [],
185
+ "wires": [],
186
+ "junctions": [],
187
+ "labels": [],
188
+ "nets": [],
189
+ "lib_symbols": {},
190
+ }
191
+
192
+ # Process top-level elements
193
+ for item in sexp_data[1:]: # Skip kicad_sch header
194
+ if not isinstance(item, list):
195
+ continue
196
+
197
+ if len(item) == 0:
198
+ continue
199
+
200
+ element_type = str(item[0]) if isinstance(item[0], sexpdata.Symbol) else None
201
+
202
+ if element_type == "version":
203
+ schematic_data["version"] = item[1] if len(item) > 1 else None
204
+ elif element_type == "generator":
205
+ schematic_data["generator"] = item[1] if len(item) > 1 else None
206
+ elif element_type == "uuid":
207
+ schematic_data["uuid"] = item[1] if len(item) > 1 else None
208
+ elif element_type == "title_block":
209
+ schematic_data["title_block"] = self._parse_title_block(item)
210
+ elif element_type == "symbol":
211
+ component = self._parse_symbol(item)
212
+ if component:
213
+ schematic_data["components"].append(component)
214
+ elif element_type == "wire":
215
+ wire = self._parse_wire(item)
216
+ if wire:
217
+ schematic_data["wires"].append(wire)
218
+ elif element_type == "junction":
219
+ junction = self._parse_junction(item)
220
+ if junction:
221
+ schematic_data["junctions"].append(junction)
222
+ elif element_type == "label":
223
+ label = self._parse_label(item)
224
+ if label:
225
+ schematic_data["labels"].append(label)
226
+ elif element_type == "lib_symbols":
227
+ schematic_data["lib_symbols"] = self._parse_lib_symbols(item)
228
+
229
+ return schematic_data
230
+
231
+ def _schematic_data_to_sexp(self, schematic_data: Dict[str, Any]) -> List[Any]:
232
+ """Convert internal schematic format to S-expression data."""
233
+ sexp_data = [sexpdata.Symbol("kicad_sch")]
234
+
235
+ # Add version and generator
236
+ if schematic_data.get("version"):
237
+ sexp_data.append([sexpdata.Symbol("version"), schematic_data["version"]])
238
+ if schematic_data.get("generator"):
239
+ sexp_data.append([sexpdata.Symbol("generator"), schematic_data["generator"]])
240
+ if schematic_data.get("uuid"):
241
+ sexp_data.append([sexpdata.Symbol("uuid"), schematic_data["uuid"]])
242
+
243
+ # Add title block
244
+ if schematic_data.get("title_block"):
245
+ sexp_data.append(self._title_block_to_sexp(schematic_data["title_block"]))
246
+
247
+ # Add lib_symbols
248
+ if schematic_data.get("lib_symbols"):
249
+ sexp_data.append(self._lib_symbols_to_sexp(schematic_data["lib_symbols"]))
250
+
251
+ # Add components
252
+ for component in schematic_data.get("components", []):
253
+ sexp_data.append(self._symbol_to_sexp(component))
254
+
255
+ # Add wires
256
+ for wire in schematic_data.get("wires", []):
257
+ sexp_data.append(self._wire_to_sexp(wire))
258
+
259
+ # Add junctions
260
+ for junction in schematic_data.get("junctions", []):
261
+ sexp_data.append(self._junction_to_sexp(junction))
262
+
263
+ # Add labels
264
+ for label in schematic_data.get("labels", []):
265
+ sexp_data.append(self._label_to_sexp(label))
266
+
267
+ return sexp_data
268
+
269
+ def _parse_title_block(self, item: List[Any]) -> Dict[str, Any]:
270
+ """Parse title block information."""
271
+ title_block = {}
272
+ for sub_item in item[1:]:
273
+ if isinstance(sub_item, list) and len(sub_item) >= 2:
274
+ key = str(sub_item[0]) if isinstance(sub_item[0], sexpdata.Symbol) else None
275
+ if key:
276
+ title_block[key] = sub_item[1] if len(sub_item) > 1 else None
277
+ return title_block
278
+
279
+ def _parse_symbol(self, item: List[Any]) -> Optional[Dict[str, Any]]:
280
+ """Parse a symbol (component) definition."""
281
+ try:
282
+ symbol_data = {
283
+ "lib_id": None,
284
+ "position": Point(0, 0),
285
+ "rotation": 0,
286
+ "uuid": None,
287
+ "reference": None,
288
+ "value": None,
289
+ "footprint": None,
290
+ "properties": {},
291
+ "pins": [],
292
+ "in_bom": True,
293
+ "on_board": True,
294
+ }
295
+
296
+ for sub_item in item[1:]:
297
+ if not isinstance(sub_item, list) or len(sub_item) == 0:
298
+ continue
299
+
300
+ element_type = (
301
+ str(sub_item[0]) if isinstance(sub_item[0], sexpdata.Symbol) else None
302
+ )
303
+
304
+ if element_type == "lib_id":
305
+ symbol_data["lib_id"] = sub_item[1] if len(sub_item) > 1 else None
306
+ elif element_type == "at":
307
+ if len(sub_item) >= 3:
308
+ symbol_data["position"] = Point(float(sub_item[1]), float(sub_item[2]))
309
+ if len(sub_item) > 3:
310
+ symbol_data["rotation"] = float(sub_item[3])
311
+ elif element_type == "uuid":
312
+ symbol_data["uuid"] = sub_item[1] if len(sub_item) > 1 else None
313
+ elif element_type == "property":
314
+ prop_data = self._parse_property(sub_item)
315
+ if prop_data:
316
+ prop_name = prop_data.get("name")
317
+ if prop_name == "Reference":
318
+ symbol_data["reference"] = prop_data.get("value")
319
+ elif prop_name == "Value":
320
+ symbol_data["value"] = prop_data.get("value")
321
+ elif prop_name == "Footprint":
322
+ symbol_data["footprint"] = prop_data.get("value")
323
+ else:
324
+ symbol_data["properties"][prop_name] = prop_data.get("value")
325
+ elif element_type == "in_bom":
326
+ symbol_data["in_bom"] = sub_item[1] == "yes" if len(sub_item) > 1 else True
327
+ elif element_type == "on_board":
328
+ symbol_data["on_board"] = sub_item[1] == "yes" if len(sub_item) > 1 else True
329
+
330
+ return symbol_data
331
+
332
+ except Exception as e:
333
+ logger.warning(f"Error parsing symbol: {e}")
334
+ return None
335
+
336
+ def _parse_property(self, item: List[Any]) -> Optional[Dict[str, Any]]:
337
+ """Parse a property definition."""
338
+ if len(item) < 3:
339
+ return None
340
+
341
+ return {
342
+ "name": item[1] if len(item) > 1 else None,
343
+ "value": item[2] if len(item) > 2 else None,
344
+ }
345
+
346
+ def _parse_wire(self, item: List[Any]) -> Optional[Dict[str, Any]]:
347
+ """Parse a wire definition."""
348
+ # Implementation for wire parsing
349
+ # This would parse pts, stroke, uuid elements
350
+ return {}
351
+
352
+ def _parse_junction(self, item: List[Any]) -> Optional[Dict[str, Any]]:
353
+ """Parse a junction definition."""
354
+ # Implementation for junction parsing
355
+ return {}
356
+
357
+ def _parse_label(self, item: List[Any]) -> Optional[Dict[str, Any]]:
358
+ """Parse a label definition."""
359
+ # Implementation for label parsing
360
+ return {}
361
+
362
+ def _parse_lib_symbols(self, item: List[Any]) -> Dict[str, Any]:
363
+ """Parse lib_symbols section."""
364
+ # Implementation for lib_symbols parsing
365
+ return {}
366
+
367
+ # Conversion methods from internal format to S-expression
368
+ def _title_block_to_sexp(self, title_block: Dict[str, Any]) -> List[Any]:
369
+ """Convert title block to S-expression."""
370
+ sexp = [sexpdata.Symbol("title_block")]
371
+ for key, value in title_block.items():
372
+ sexp.append([sexpdata.Symbol(key), value])
373
+ return sexp
374
+
375
+ def _symbol_to_sexp(self, symbol_data: Dict[str, Any]) -> List[Any]:
376
+ """Convert symbol to S-expression."""
377
+ sexp = [sexpdata.Symbol("symbol")]
378
+
379
+ if symbol_data.get("lib_id"):
380
+ sexp.append([sexpdata.Symbol("lib_id"), symbol_data["lib_id"]])
381
+
382
+ # Add position and rotation
383
+ pos = symbol_data.get("position", Point(0, 0))
384
+ rotation = symbol_data.get("rotation", 0)
385
+ if rotation != 0:
386
+ sexp.append([sexpdata.Symbol("at"), pos.x, pos.y, rotation])
387
+ else:
388
+ sexp.append([sexpdata.Symbol("at"), pos.x, pos.y])
389
+
390
+ if symbol_data.get("uuid"):
391
+ sexp.append([sexpdata.Symbol("uuid"), symbol_data["uuid"]])
392
+
393
+ # Add properties
394
+ if symbol_data.get("reference"):
395
+ sexp.append([sexpdata.Symbol("property"), "Reference", symbol_data["reference"]])
396
+ if symbol_data.get("value"):
397
+ sexp.append([sexpdata.Symbol("property"), "Value", symbol_data["value"]])
398
+ if symbol_data.get("footprint"):
399
+ sexp.append([sexpdata.Symbol("property"), "Footprint", symbol_data["footprint"]])
400
+
401
+ for prop_name, prop_value in symbol_data.get("properties", {}).items():
402
+ sexp.append([sexpdata.Symbol("property"), prop_name, prop_value])
403
+
404
+ # Add BOM and board settings
405
+ sexp.append([sexpdata.Symbol("in_bom"), "yes" if symbol_data.get("in_bom", True) else "no"])
406
+ sexp.append(
407
+ [sexpdata.Symbol("on_board"), "yes" if symbol_data.get("on_board", True) else "no"]
408
+ )
409
+
410
+ return sexp
411
+
412
+ def _wire_to_sexp(self, wire_data: Dict[str, Any]) -> List[Any]:
413
+ """Convert wire to S-expression."""
414
+ # Implementation for wire conversion
415
+ return [sexpdata.Symbol("wire")]
416
+
417
+ def _junction_to_sexp(self, junction_data: Dict[str, Any]) -> List[Any]:
418
+ """Convert junction to S-expression."""
419
+ # Implementation for junction conversion
420
+ return [sexpdata.Symbol("junction")]
421
+
422
+ def _label_to_sexp(self, label_data: Dict[str, Any]) -> List[Any]:
423
+ """Convert label to S-expression."""
424
+ # Implementation for label conversion
425
+ return [sexpdata.Symbol("label")]
426
+
427
+ def _lib_symbols_to_sexp(self, lib_symbols: Dict[str, Any]) -> List[Any]:
428
+ """Convert lib_symbols to S-expression."""
429
+ # Implementation for lib_symbols conversion
430
+ return [sexpdata.Symbol("lib_symbols")]
431
+
432
+ def get_validation_issues(self) -> List[ValidationIssue]:
433
+ """Get list of validation issues from last parse operation."""
434
+ return self._validation_issues.copy()