kicad-sch-api 0.3.0__py3-none-any.whl → 0.5.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.
Files changed (112) hide show
  1. kicad_sch_api/__init__.py +68 -3
  2. kicad_sch_api/cli/__init__.py +45 -0
  3. kicad_sch_api/cli/base.py +302 -0
  4. kicad_sch_api/cli/bom.py +164 -0
  5. kicad_sch_api/cli/erc.py +229 -0
  6. kicad_sch_api/cli/export_docs.py +289 -0
  7. kicad_sch_api/cli/kicad_to_python.py +169 -0
  8. kicad_sch_api/cli/netlist.py +94 -0
  9. kicad_sch_api/cli/types.py +43 -0
  10. kicad_sch_api/collections/__init__.py +36 -0
  11. kicad_sch_api/collections/base.py +604 -0
  12. kicad_sch_api/collections/components.py +1623 -0
  13. kicad_sch_api/collections/junctions.py +206 -0
  14. kicad_sch_api/collections/labels.py +508 -0
  15. kicad_sch_api/collections/wires.py +292 -0
  16. kicad_sch_api/core/__init__.py +37 -2
  17. kicad_sch_api/core/collections/__init__.py +5 -0
  18. kicad_sch_api/core/collections/base.py +248 -0
  19. kicad_sch_api/core/component_bounds.py +34 -7
  20. kicad_sch_api/core/components.py +213 -52
  21. kicad_sch_api/core/config.py +110 -15
  22. kicad_sch_api/core/connectivity.py +692 -0
  23. kicad_sch_api/core/exceptions.py +175 -0
  24. kicad_sch_api/core/factories/__init__.py +5 -0
  25. kicad_sch_api/core/factories/element_factory.py +278 -0
  26. kicad_sch_api/core/formatter.py +60 -9
  27. kicad_sch_api/core/geometry.py +94 -5
  28. kicad_sch_api/core/junctions.py +26 -75
  29. kicad_sch_api/core/labels.py +324 -0
  30. kicad_sch_api/core/managers/__init__.py +30 -0
  31. kicad_sch_api/core/managers/base.py +76 -0
  32. kicad_sch_api/core/managers/file_io.py +246 -0
  33. kicad_sch_api/core/managers/format_sync.py +502 -0
  34. kicad_sch_api/core/managers/graphics.py +580 -0
  35. kicad_sch_api/core/managers/hierarchy.py +661 -0
  36. kicad_sch_api/core/managers/metadata.py +271 -0
  37. kicad_sch_api/core/managers/sheet.py +492 -0
  38. kicad_sch_api/core/managers/text_elements.py +537 -0
  39. kicad_sch_api/core/managers/validation.py +476 -0
  40. kicad_sch_api/core/managers/wire.py +410 -0
  41. kicad_sch_api/core/nets.py +305 -0
  42. kicad_sch_api/core/no_connects.py +252 -0
  43. kicad_sch_api/core/parser.py +194 -970
  44. kicad_sch_api/core/parsing_utils.py +63 -0
  45. kicad_sch_api/core/pin_utils.py +103 -9
  46. kicad_sch_api/core/schematic.py +1328 -1079
  47. kicad_sch_api/core/texts.py +316 -0
  48. kicad_sch_api/core/types.py +159 -23
  49. kicad_sch_api/core/wires.py +27 -75
  50. kicad_sch_api/exporters/__init__.py +10 -0
  51. kicad_sch_api/exporters/python_generator.py +610 -0
  52. kicad_sch_api/exporters/templates/default.py.jinja2 +65 -0
  53. kicad_sch_api/geometry/__init__.py +38 -0
  54. kicad_sch_api/geometry/font_metrics.py +22 -0
  55. kicad_sch_api/geometry/routing.py +211 -0
  56. kicad_sch_api/geometry/symbol_bbox.py +608 -0
  57. kicad_sch_api/interfaces/__init__.py +17 -0
  58. kicad_sch_api/interfaces/parser.py +76 -0
  59. kicad_sch_api/interfaces/repository.py +70 -0
  60. kicad_sch_api/interfaces/resolver.py +117 -0
  61. kicad_sch_api/parsers/__init__.py +14 -0
  62. kicad_sch_api/parsers/base.py +145 -0
  63. kicad_sch_api/parsers/elements/__init__.py +22 -0
  64. kicad_sch_api/parsers/elements/graphics_parser.py +564 -0
  65. kicad_sch_api/parsers/elements/label_parser.py +216 -0
  66. kicad_sch_api/parsers/elements/library_parser.py +165 -0
  67. kicad_sch_api/parsers/elements/metadata_parser.py +58 -0
  68. kicad_sch_api/parsers/elements/sheet_parser.py +352 -0
  69. kicad_sch_api/parsers/elements/symbol_parser.py +485 -0
  70. kicad_sch_api/parsers/elements/text_parser.py +250 -0
  71. kicad_sch_api/parsers/elements/wire_parser.py +242 -0
  72. kicad_sch_api/parsers/registry.py +155 -0
  73. kicad_sch_api/parsers/utils.py +80 -0
  74. kicad_sch_api/symbols/__init__.py +18 -0
  75. kicad_sch_api/symbols/cache.py +467 -0
  76. kicad_sch_api/symbols/resolver.py +361 -0
  77. kicad_sch_api/symbols/validators.py +504 -0
  78. kicad_sch_api/utils/logging.py +555 -0
  79. kicad_sch_api/utils/logging_decorators.py +587 -0
  80. kicad_sch_api/utils/validation.py +16 -22
  81. kicad_sch_api/validation/__init__.py +25 -0
  82. kicad_sch_api/validation/erc.py +171 -0
  83. kicad_sch_api/validation/erc_models.py +203 -0
  84. kicad_sch_api/validation/pin_matrix.py +243 -0
  85. kicad_sch_api/validation/validators.py +391 -0
  86. kicad_sch_api/wrappers/__init__.py +14 -0
  87. kicad_sch_api/wrappers/base.py +89 -0
  88. kicad_sch_api/wrappers/wire.py +198 -0
  89. kicad_sch_api-0.5.1.dist-info/METADATA +540 -0
  90. kicad_sch_api-0.5.1.dist-info/RECORD +114 -0
  91. kicad_sch_api-0.5.1.dist-info/entry_points.txt +4 -0
  92. {kicad_sch_api-0.3.0.dist-info → kicad_sch_api-0.5.1.dist-info}/top_level.txt +1 -0
  93. mcp_server/__init__.py +34 -0
  94. mcp_server/example_logging_integration.py +506 -0
  95. mcp_server/models.py +252 -0
  96. mcp_server/server.py +357 -0
  97. mcp_server/tools/__init__.py +32 -0
  98. mcp_server/tools/component_tools.py +516 -0
  99. mcp_server/tools/connectivity_tools.py +532 -0
  100. mcp_server/tools/consolidated_tools.py +1216 -0
  101. mcp_server/tools/pin_discovery.py +333 -0
  102. mcp_server/utils/__init__.py +38 -0
  103. mcp_server/utils/logging.py +127 -0
  104. mcp_server/utils.py +36 -0
  105. kicad_sch_api/core/manhattan_routing.py +0 -430
  106. kicad_sch_api/core/simple_manhattan.py +0 -228
  107. kicad_sch_api/core/wire_routing.py +0 -380
  108. kicad_sch_api-0.3.0.dist-info/METADATA +0 -483
  109. kicad_sch_api-0.3.0.dist-info/RECORD +0 -31
  110. kicad_sch_api-0.3.0.dist-info/entry_points.txt +0 -2
  111. {kicad_sch_api-0.3.0.dist-info → kicad_sch_api-0.5.1.dist-info}/WHEEL +0 -0
  112. {kicad_sch_api-0.3.0.dist-info → kicad_sch_api-0.5.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,250 @@
1
+ """
2
+ Text and text box elements parser for KiCAD schematics.
3
+
4
+ Handles parsing and serialization of Text and text box elements.
5
+ """
6
+
7
+ import logging
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ import sexpdata
11
+
12
+ from ...core.config import config
13
+ from ..base import BaseElementParser
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class TextParser(BaseElementParser):
19
+ """Parser for Text and text box elements."""
20
+
21
+ def __init__(self):
22
+ """Initialize text parser."""
23
+ super().__init__("text")
24
+
25
+ def _parse_text(self, item: List[Any]) -> Optional[Dict[str, Any]]:
26
+ """Parse a text element."""
27
+ # Format: (text "text" (exclude_from_sim no) (at x y rotation) (effects ...) (uuid ...))
28
+ if len(item) < 2:
29
+ return None
30
+
31
+ text_data = {
32
+ "text": str(item[1]),
33
+ "exclude_from_sim": False,
34
+ "position": {"x": 0, "y": 0},
35
+ "rotation": 0,
36
+ "size": config.defaults.font_size,
37
+ "uuid": None,
38
+ }
39
+
40
+ for elem in item[2:]:
41
+ if not isinstance(elem, list):
42
+ continue
43
+
44
+ elem_type = str(elem[0]) if isinstance(elem[0], sexpdata.Symbol) else None
45
+
46
+ if elem_type == "exclude_from_sim":
47
+ if len(elem) >= 2:
48
+ text_data["exclude_from_sim"] = str(elem[1]) == "yes"
49
+ elif elem_type == "at":
50
+ if len(elem) >= 3:
51
+ text_data["position"] = {"x": float(elem[1]), "y": float(elem[2])}
52
+ if len(elem) >= 4:
53
+ text_data["rotation"] = float(elem[3])
54
+ elif elem_type == "effects":
55
+ for effect_elem in elem[1:]:
56
+ if isinstance(effect_elem, list) and str(effect_elem[0]) == "font":
57
+ for font_elem in effect_elem[1:]:
58
+ if isinstance(font_elem, list) and str(font_elem[0]) == "size":
59
+ if len(font_elem) >= 2:
60
+ text_data["size"] = float(font_elem[1])
61
+ elif elem_type == "uuid":
62
+ text_data["uuid"] = str(elem[1]) if len(elem) > 1 else None
63
+
64
+ return text_data
65
+
66
+
67
+ def _parse_text_box(self, item: List[Any]) -> Optional[Dict[str, Any]]:
68
+ """Parse a text_box element."""
69
+ # Format: (text_box "text" (exclude_from_sim no) (at x y rotation) (size w h) (margins ...) (stroke ...) (fill ...) (effects ...) (uuid ...))
70
+ if len(item) < 2:
71
+ return None
72
+
73
+ text_box_data = {
74
+ "text": str(item[1]),
75
+ "exclude_from_sim": False,
76
+ "position": {"x": 0, "y": 0},
77
+ "rotation": 0,
78
+ "size": {"width": 0, "height": 0},
79
+ "margins": (0.9525, 0.9525, 0.9525, 0.9525),
80
+ "stroke_width": 0,
81
+ "stroke_type": "solid",
82
+ "fill_type": "none",
83
+ "font_size": config.defaults.font_size,
84
+ "justify_horizontal": "left",
85
+ "justify_vertical": "top",
86
+ "uuid": None,
87
+ }
88
+
89
+ for elem in item[2:]:
90
+ if not isinstance(elem, list):
91
+ continue
92
+
93
+ elem_type = str(elem[0]) if isinstance(elem[0], sexpdata.Symbol) else None
94
+
95
+ if elem_type == "exclude_from_sim":
96
+ if len(elem) >= 2:
97
+ text_box_data["exclude_from_sim"] = str(elem[1]) == "yes"
98
+ elif elem_type == "at":
99
+ if len(elem) >= 3:
100
+ text_box_data["position"] = {"x": float(elem[1]), "y": float(elem[2])}
101
+ if len(elem) >= 4:
102
+ text_box_data["rotation"] = float(elem[3])
103
+ elif elem_type == "size":
104
+ if len(elem) >= 3:
105
+ text_box_data["size"] = {"width": float(elem[1]), "height": float(elem[2])}
106
+ elif elem_type == "margins":
107
+ if len(elem) >= 5:
108
+ text_box_data["margins"] = (
109
+ float(elem[1]),
110
+ float(elem[2]),
111
+ float(elem[3]),
112
+ float(elem[4]),
113
+ )
114
+ elif elem_type == "stroke":
115
+ for stroke_elem in elem[1:]:
116
+ if isinstance(stroke_elem, list):
117
+ stroke_type = str(stroke_elem[0])
118
+ if stroke_type == "width" and len(stroke_elem) >= 2:
119
+ text_box_data["stroke_width"] = float(stroke_elem[1])
120
+ elif stroke_type == "type" and len(stroke_elem) >= 2:
121
+ text_box_data["stroke_type"] = str(stroke_elem[1])
122
+ elif elem_type == "fill":
123
+ for fill_elem in elem[1:]:
124
+ if isinstance(fill_elem, list) and str(fill_elem[0]) == "type":
125
+ text_box_data["fill_type"] = (
126
+ str(fill_elem[1]) if len(fill_elem) >= 2 else "none"
127
+ )
128
+ elif elem_type == "effects":
129
+ for effect_elem in elem[1:]:
130
+ if isinstance(effect_elem, list):
131
+ effect_type = str(effect_elem[0])
132
+ if effect_type == "font":
133
+ for font_elem in effect_elem[1:]:
134
+ if isinstance(font_elem, list) and str(font_elem[0]) == "size":
135
+ if len(font_elem) >= 2:
136
+ text_box_data["font_size"] = float(font_elem[1])
137
+ elif effect_type == "justify":
138
+ if len(effect_elem) >= 2:
139
+ text_box_data["justify_horizontal"] = str(effect_elem[1])
140
+ if len(effect_elem) >= 3:
141
+ text_box_data["justify_vertical"] = str(effect_elem[2])
142
+ elif elem_type == "uuid":
143
+ text_box_data["uuid"] = str(elem[1]) if len(elem) > 1 else None
144
+
145
+ return text_box_data
146
+
147
+
148
+ def _text_to_sexp(self, text_data: Dict[str, Any]) -> List[Any]:
149
+ """Convert text element to S-expression."""
150
+ sexp = [sexpdata.Symbol("text"), text_data["text"]]
151
+
152
+ # Add exclude_from_sim
153
+ exclude_sim = text_data.get("exclude_from_sim", False)
154
+ sexp.append(
155
+ [sexpdata.Symbol("exclude_from_sim"), sexpdata.Symbol("yes" if exclude_sim else "no")]
156
+ )
157
+
158
+ # Add position
159
+ pos = text_data["position"]
160
+ x, y = pos["x"], pos["y"]
161
+ rotation = text_data.get("rotation", 0)
162
+
163
+ # Format coordinates properly
164
+ if isinstance(x, float) and x.is_integer():
165
+ x = int(x)
166
+ if isinstance(y, float) and y.is_integer():
167
+ y = int(y)
168
+
169
+ sexp.append([sexpdata.Symbol("at"), x, y, rotation])
170
+
171
+ # Add effects (font properties)
172
+ size = text_data.get("size", config.defaults.font_size)
173
+ effects = [sexpdata.Symbol("effects")]
174
+ font = [sexpdata.Symbol("font"), [sexpdata.Symbol("size"), size, size]]
175
+ effects.append(font)
176
+ sexp.append(effects)
177
+
178
+ # Add UUID
179
+ if "uuid" in text_data:
180
+ sexp.append([sexpdata.Symbol("uuid"), text_data["uuid"]])
181
+
182
+ return sexp
183
+
184
+
185
+ def _text_box_to_sexp(self, text_box_data: Dict[str, Any]) -> List[Any]:
186
+ """Convert text box element to S-expression."""
187
+ sexp = [sexpdata.Symbol("text_box"), text_box_data["text"]]
188
+
189
+ # Add exclude_from_sim
190
+ exclude_sim = text_box_data.get("exclude_from_sim", False)
191
+ sexp.append(
192
+ [sexpdata.Symbol("exclude_from_sim"), sexpdata.Symbol("yes" if exclude_sim else "no")]
193
+ )
194
+
195
+ # Add position
196
+ pos = text_box_data["position"]
197
+ x, y = pos["x"], pos["y"]
198
+ rotation = text_box_data.get("rotation", 0)
199
+
200
+ # Format coordinates properly
201
+ if isinstance(x, float) and x.is_integer():
202
+ x = int(x)
203
+ if isinstance(y, float) and y.is_integer():
204
+ y = int(y)
205
+
206
+ sexp.append([sexpdata.Symbol("at"), x, y, rotation])
207
+
208
+ # Add size
209
+ size = text_box_data["size"]
210
+ w, h = size["width"], size["height"]
211
+ sexp.append([sexpdata.Symbol("size"), w, h])
212
+
213
+ # Add margins
214
+ margins = text_box_data.get("margins", (0.9525, 0.9525, 0.9525, 0.9525))
215
+ sexp.append([sexpdata.Symbol("margins"), margins[0], margins[1], margins[2], margins[3]])
216
+
217
+ # Add stroke
218
+ stroke_width = text_box_data.get("stroke_width", 0)
219
+ stroke_type = text_box_data.get("stroke_type", "solid")
220
+ stroke_sexp = [sexpdata.Symbol("stroke")]
221
+ stroke_sexp.append([sexpdata.Symbol("width"), stroke_width])
222
+ stroke_sexp.append([sexpdata.Symbol("type"), sexpdata.Symbol(stroke_type)])
223
+ sexp.append(stroke_sexp)
224
+
225
+ # Add fill
226
+ fill_type = text_box_data.get("fill_type", "none")
227
+ fill_sexp = [sexpdata.Symbol("fill")]
228
+ fill_sexp.append([sexpdata.Symbol("type"), sexpdata.Symbol(fill_type)])
229
+ sexp.append(fill_sexp)
230
+
231
+ # Add effects (font properties and justification)
232
+ font_size = text_box_data.get("font_size", config.defaults.font_size)
233
+ justify_h = text_box_data.get("justify_horizontal", "left")
234
+ justify_v = text_box_data.get("justify_vertical", "top")
235
+
236
+ effects = [sexpdata.Symbol("effects")]
237
+ font = [sexpdata.Symbol("font"), [sexpdata.Symbol("size"), font_size, font_size]]
238
+ effects.append(font)
239
+ effects.append(
240
+ [sexpdata.Symbol("justify"), sexpdata.Symbol(justify_h), sexpdata.Symbol(justify_v)]
241
+ )
242
+ sexp.append(effects)
243
+
244
+ # Add UUID
245
+ if "uuid" in text_box_data:
246
+ sexp.append([sexpdata.Symbol("uuid"), text_box_data["uuid"]])
247
+
248
+ return sexp
249
+
250
+
@@ -0,0 +1,242 @@
1
+ """
2
+ Wire and connection element parsers for KiCAD schematics.
3
+
4
+ Handles parsing and serialization of connection elements:
5
+ - Wire
6
+ - Junction
7
+ - No-connect
8
+ """
9
+
10
+ import logging
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ import sexpdata
14
+
15
+ from ...core.config import config
16
+ from ..base import BaseElementParser
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class WireParser(BaseElementParser):
22
+ """Parser for wire and connection elements."""
23
+
24
+ def __init__(self):
25
+ """Initialize wire parser."""
26
+ super().__init__("wire")
27
+
28
+ def _parse_wire(self, item: List[Any]) -> Optional[Dict[str, Any]]:
29
+ """Parse a wire definition."""
30
+ wire_data = {
31
+ "points": [],
32
+ "stroke_width": 0.0,
33
+ "stroke_type": config.defaults.stroke_type,
34
+ "uuid": None,
35
+ "wire_type": "wire", # Default to wire (vs bus)
36
+ }
37
+
38
+ for elem in item[1:]:
39
+ if not isinstance(elem, list):
40
+ continue
41
+
42
+ elem_type = str(elem[0]) if isinstance(elem[0], sexpdata.Symbol) else None
43
+
44
+ if elem_type == "pts":
45
+ # Parse points: (pts (xy x1 y1) (xy x2 y2) ...)
46
+ for pt in elem[1:]:
47
+ if isinstance(pt, list) and len(pt) >= 3:
48
+ if str(pt[0]) == "xy":
49
+ x, y = float(pt[1]), float(pt[2])
50
+ wire_data["points"].append({"x": x, "y": y})
51
+
52
+ elif elem_type == "stroke":
53
+ # Parse stroke: (stroke (width 0) (type default))
54
+ for stroke_elem in elem[1:]:
55
+ if isinstance(stroke_elem, list) and len(stroke_elem) >= 2:
56
+ stroke_type = str(stroke_elem[0])
57
+ if stroke_type == "width":
58
+ wire_data["stroke_width"] = float(stroke_elem[1])
59
+ elif stroke_type == "type":
60
+ wire_data["stroke_type"] = str(stroke_elem[1])
61
+
62
+ elif elem_type == "uuid":
63
+ wire_data["uuid"] = str(elem[1]) if len(elem) > 1 else None
64
+
65
+ # Only return wire if it has at least 2 points
66
+ if len(wire_data["points"]) >= 2:
67
+ return wire_data
68
+ else:
69
+ logger.warning(f"Wire has insufficient points: {len(wire_data['points'])}")
70
+ return None
71
+
72
+
73
+ def _parse_junction(self, item: List[Any]) -> Optional[Dict[str, Any]]:
74
+ """Parse a junction definition."""
75
+ junction_data = {
76
+ "position": {"x": 0, "y": 0},
77
+ "diameter": 0,
78
+ "color": (0, 0, 0, 0),
79
+ "uuid": None,
80
+ }
81
+
82
+ for elem in item[1:]:
83
+ if not isinstance(elem, list):
84
+ continue
85
+
86
+ elem_type = str(elem[0]) if isinstance(elem[0], sexpdata.Symbol) else None
87
+
88
+ if elem_type == "at":
89
+ # Parse position: (at x y)
90
+ if len(elem) >= 3:
91
+ junction_data["position"] = {"x": float(elem[1]), "y": float(elem[2])}
92
+
93
+ elif elem_type == "diameter":
94
+ # Parse diameter: (diameter value)
95
+ if len(elem) >= 2:
96
+ junction_data["diameter"] = float(elem[1])
97
+
98
+ elif elem_type == "color":
99
+ # Parse color: (color r g b a)
100
+ if len(elem) >= 5:
101
+ junction_data["color"] = (
102
+ int(elem[1]),
103
+ int(elem[2]),
104
+ int(elem[3]),
105
+ int(elem[4]),
106
+ )
107
+
108
+ elif elem_type == "uuid":
109
+ junction_data["uuid"] = str(elem[1]) if len(elem) > 1 else None
110
+
111
+ return junction_data
112
+
113
+
114
+ def _parse_no_connect(self, item: List[Any]) -> Optional[Dict[str, Any]]:
115
+ """Parse a no_connect symbol."""
116
+ # Format: (no_connect (at x y) (uuid ...))
117
+ no_connect_data = {"position": {"x": 0, "y": 0}, "uuid": None}
118
+
119
+ for elem in item[1:]:
120
+ if not isinstance(elem, list):
121
+ continue
122
+
123
+ elem_type = str(elem[0]) if isinstance(elem[0], sexpdata.Symbol) else None
124
+
125
+ if elem_type == "at":
126
+ if len(elem) >= 3:
127
+ no_connect_data["position"] = {"x": float(elem[1]), "y": float(elem[2])}
128
+ elif elem_type == "uuid":
129
+ no_connect_data["uuid"] = str(elem[1]) if len(elem) > 1 else None
130
+
131
+ return no_connect_data
132
+
133
+
134
+ def _wire_to_sexp(self, wire_data: Dict[str, Any]) -> List[Any]:
135
+ """Convert wire to S-expression."""
136
+ sexp = [sexpdata.Symbol("wire")]
137
+
138
+ # Add points (pts section)
139
+ points = wire_data.get("points", [])
140
+ if len(points) >= 2:
141
+ pts_sexp = [sexpdata.Symbol("pts")]
142
+ for point in points:
143
+ if isinstance(point, dict):
144
+ x, y = point["x"], point["y"]
145
+ elif isinstance(point, (list, tuple)) and len(point) >= 2:
146
+ x, y = point[0], point[1]
147
+ else:
148
+ # Assume it's a Point object
149
+ x, y = point.x, point.y
150
+
151
+ # Format coordinates properly (avoid unnecessary .0 for integers)
152
+ if isinstance(x, float) and x.is_integer():
153
+ x = int(x)
154
+ if isinstance(y, float) and y.is_integer():
155
+ y = int(y)
156
+
157
+ pts_sexp.append([sexpdata.Symbol("xy"), x, y])
158
+ sexp.append(pts_sexp)
159
+
160
+ # Add stroke information
161
+ stroke_width = wire_data.get("stroke_width", config.defaults.stroke_width)
162
+ stroke_type = wire_data.get("stroke_type", config.defaults.stroke_type)
163
+ stroke_sexp = [sexpdata.Symbol("stroke")]
164
+
165
+ # Format stroke width (use int for 0, preserve float for others)
166
+ if isinstance(stroke_width, float) and stroke_width == 0.0:
167
+ stroke_width = 0
168
+
169
+ stroke_sexp.append([sexpdata.Symbol("width"), stroke_width])
170
+ stroke_sexp.append([sexpdata.Symbol("type"), sexpdata.Symbol(stroke_type)])
171
+ sexp.append(stroke_sexp)
172
+
173
+ # Add UUID
174
+ if "uuid" in wire_data:
175
+ sexp.append([sexpdata.Symbol("uuid"), wire_data["uuid"]])
176
+
177
+ return sexp
178
+
179
+
180
+ def _junction_to_sexp(self, junction_data: Dict[str, Any]) -> List[Any]:
181
+ """Convert junction to S-expression."""
182
+ sexp = [sexpdata.Symbol("junction")]
183
+
184
+ # Add position
185
+ pos = junction_data["position"]
186
+ if isinstance(pos, dict):
187
+ x, y = pos["x"], pos["y"]
188
+ elif isinstance(pos, (list, tuple)) and len(pos) >= 2:
189
+ x, y = pos[0], pos[1]
190
+ else:
191
+ # Assume it's a Point object
192
+ x, y = pos.x, pos.y
193
+
194
+ # Format coordinates properly
195
+ if isinstance(x, float) and x.is_integer():
196
+ x = int(x)
197
+ if isinstance(y, float) and y.is_integer():
198
+ y = int(y)
199
+
200
+ sexp.append([sexpdata.Symbol("at"), x, y])
201
+
202
+ # Add diameter
203
+ diameter = junction_data.get("diameter", 0)
204
+ sexp.append([sexpdata.Symbol("diameter"), diameter])
205
+
206
+ # Add color (RGBA)
207
+ color = junction_data.get("color", (0, 0, 0, 0))
208
+ if isinstance(color, (list, tuple)) and len(color) >= 4:
209
+ sexp.append([sexpdata.Symbol("color"), color[0], color[1], color[2], color[3]])
210
+ else:
211
+ sexp.append([sexpdata.Symbol("color"), 0, 0, 0, 0])
212
+
213
+ # Add UUID
214
+ if "uuid" in junction_data:
215
+ sexp.append([sexpdata.Symbol("uuid"), junction_data["uuid"]])
216
+
217
+ return sexp
218
+
219
+
220
+ def _no_connect_to_sexp(self, no_connect_data: Dict[str, Any]) -> List[Any]:
221
+ """Convert no_connect to S-expression."""
222
+ sexp = [sexpdata.Symbol("no_connect")]
223
+
224
+ # Add position
225
+ pos = no_connect_data["position"]
226
+ x, y = pos["x"], pos["y"]
227
+
228
+ # Format coordinates properly
229
+ if isinstance(x, float) and x.is_integer():
230
+ x = int(x)
231
+ if isinstance(y, float) and y.is_integer():
232
+ y = int(y)
233
+
234
+ sexp.append([sexpdata.Symbol("at"), x, y])
235
+
236
+ # Add UUID
237
+ if "uuid" in no_connect_data:
238
+ sexp.append([sexpdata.Symbol("uuid"), no_connect_data["uuid"]])
239
+
240
+ return sexp
241
+
242
+
@@ -0,0 +1,155 @@
1
+ """
2
+ Parser registry for managing S-expression element parsers.
3
+
4
+ Provides a central registry for all element parsers and handles
5
+ dispatching parsing requests to the appropriate parser.
6
+ """
7
+
8
+ import logging
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from ..interfaces.parser import IElementParser
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class ElementParserRegistry:
17
+ """
18
+ Central registry for all S-expression element parsers.
19
+
20
+ This class manages the registration of element-specific parsers
21
+ and provides a unified interface for parsing any S-expression element.
22
+ """
23
+
24
+ def __init__(self):
25
+ """Initialize the parser registry."""
26
+ self._parsers: Dict[str, IElementParser] = {}
27
+ self._fallback_parser: Optional[IElementParser] = None
28
+ self._logger = logger.getChild(self.__class__.__name__)
29
+
30
+ def register(self, element_type: str, parser: IElementParser) -> None:
31
+ """
32
+ Register a parser for a specific element type.
33
+
34
+ Args:
35
+ element_type: The S-expression element type (e.g., "symbol", "wire")
36
+ parser: Parser instance that handles this element type
37
+
38
+ Raises:
39
+ ValueError: If element_type is already registered
40
+ """
41
+ if element_type in self._parsers:
42
+ self._logger.warning(f"Overriding existing parser for element type: {element_type}")
43
+
44
+ self._parsers[element_type] = parser
45
+ self._logger.debug(f"Registered parser for element type: {element_type}")
46
+
47
+ def unregister(self, element_type: str) -> bool:
48
+ """
49
+ Unregister a parser for a specific element type.
50
+
51
+ Args:
52
+ element_type: The element type to unregister
53
+
54
+ Returns:
55
+ True if parser was removed, False if not found
56
+ """
57
+ if element_type in self._parsers:
58
+ del self._parsers[element_type]
59
+ self._logger.debug(f"Unregistered parser for element type: {element_type}")
60
+ return True
61
+ return False
62
+
63
+ def set_fallback_parser(self, parser: IElementParser) -> None:
64
+ """
65
+ Set a fallback parser for unknown element types.
66
+
67
+ Args:
68
+ parser: Parser to use when no specific parser is registered
69
+ """
70
+ self._fallback_parser = parser
71
+ self._logger.debug("Set fallback parser")
72
+
73
+ def parse_element(self, element: List[Any]) -> Optional[Dict[str, Any]]:
74
+ """
75
+ Parse an S-expression element using the appropriate registered parser.
76
+
77
+ Args:
78
+ element: S-expression element to parse
79
+
80
+ Returns:
81
+ Parsed element data or None if parsing failed
82
+ """
83
+ if not element or not isinstance(element, list):
84
+ self._logger.debug("Invalid element: not a list or empty")
85
+ return None
86
+
87
+ element_type = element[0] if element else None
88
+ # Convert sexpdata.Symbol to string for lookup
89
+ element_type_str = str(element_type) if element_type else None
90
+ if not element_type_str:
91
+ self._logger.debug(f"Invalid element type: {element_type}")
92
+ return None
93
+
94
+ # Try specific parser first
95
+ parser = self._parsers.get(element_type_str)
96
+ if parser:
97
+ self._logger.debug(f"Using registered parser for element type: {element_type_str}")
98
+ return parser.parse(element)
99
+
100
+ # Try fallback parser
101
+ if self._fallback_parser:
102
+ self._logger.debug(
103
+ f"Using fallback parser for unknown element type: {element_type_str}"
104
+ )
105
+ return self._fallback_parser.parse(element)
106
+
107
+ # No parser available
108
+ self._logger.warning(f"No parser available for element type: {element_type_str}")
109
+ return None
110
+
111
+ def parse_elements(self, elements: List[List[Any]]) -> List[Dict[str, Any]]:
112
+ """
113
+ Parse multiple S-expression elements.
114
+
115
+ Args:
116
+ elements: List of S-expression elements to parse
117
+
118
+ Returns:
119
+ List of parsed element data (excluding failed parses)
120
+ """
121
+ results = []
122
+ for element in elements:
123
+ parsed = self.parse_element(element)
124
+ if parsed is not None:
125
+ results.append(parsed)
126
+
127
+ self._logger.debug(f"Parsed {len(results)} of {len(elements)} elements")
128
+ return results
129
+
130
+ def get_registered_types(self) -> List[str]:
131
+ """
132
+ Get list of all registered element types.
133
+
134
+ Returns:
135
+ List of registered element type names
136
+ """
137
+ return list(self._parsers.keys())
138
+
139
+ def has_parser(self, element_type: str) -> bool:
140
+ """
141
+ Check if a parser is registered for the given element type.
142
+
143
+ Args:
144
+ element_type: Element type to check
145
+
146
+ Returns:
147
+ True if parser is registered
148
+ """
149
+ return element_type in self._parsers
150
+
151
+ def clear(self) -> None:
152
+ """Clear all registered parsers."""
153
+ self._parsers.clear()
154
+ self._fallback_parser = None
155
+ self._logger.debug("Cleared all registered parsers")