chore: normalize all tracked files to LF line endings
Mechanical application of the `.gitattributes` rules from the prior commit. All 50 files differ only in line endings — verified by `git diff --cached --ignore-all-space` being empty. Before: main had 42 CRLF + 27 LF Python files plus mixed-ending in YAML, templates, and shell scripts. After: every text file is LF (except the Windows-native *.ps1, *.bat scripts which remain CRLF per gitattributes). This eliminates the noisy-diff failure mode seen in PR #102, where a small logic change produced a 918-line diff due to whole-file CRLF→LF conversion.
This commit is contained in:
@@ -1,250 +1,250 @@
|
||||
"""
|
||||
Parser for KiCad .kicad_mod footprint files.
|
||||
|
||||
Extracts the fields that the MCP get_footprint_info tool exposes to clients:
|
||||
name – footprint name (str)
|
||||
library – library nickname, injected by caller (str)
|
||||
description – (descr "…") token (str | None)
|
||||
keywords – (tags "…") token (str | None)
|
||||
pads – list of pad objects: [{number, type, shape}, …] (list[dict])
|
||||
layers – sorted unique list of canonical layer names used (list[str])
|
||||
courtyard – {"width": float, "height": float} from F.CrtYd geometry (dict | None)
|
||||
attributes – {"type": str, "board_only": bool, …} (dict | None)
|
||||
|
||||
KiCad S-expression file format reference:
|
||||
https://dev-docs.kicad.org/en/file-formats/sexpr-intro/index.html#_footprint
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Parse a .kicad_mod file and return a dict whose keys match the fields
|
||||
expected by the TypeScript MCP tool handler (src/tools/library.ts).
|
||||
|
||||
Returns None if the file does not exist or cannot be read.
|
||||
"""
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
logger.debug(f"parse_kicad_mod: file not found: {file_path}")
|
||||
return None
|
||||
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except OSError as e:
|
||||
logger.warning(f"parse_kicad_mod: cannot read {file_path}: {e}")
|
||||
return None
|
||||
|
||||
logger.debug(f"parse_kicad_mod: parsing {path.name} ({len(content)} chars)")
|
||||
|
||||
result: Dict[str, Any] = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Footprint name: (footprint "NAME" …
|
||||
# Per spec, in a library file the name is the ENTRY_NAME only (no lib prefix).
|
||||
# ------------------------------------------------------------------
|
||||
m = re.search(r'^\s*\(footprint\s+"((?:[^"\\]|\\.)*)"', content, re.MULTILINE)
|
||||
if not m:
|
||||
# Older / unquoted format
|
||||
m = re.search(r"^\s*\(footprint\s+(\S+)", content, re.MULTILINE)
|
||||
result["name"] = _unescape(m.group(1)) if m else path.stem
|
||||
logger.debug(f"parse_kicad_mod: name={result['name']!r}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Description: (descr "…")
|
||||
# ------------------------------------------------------------------
|
||||
m = re.search(r'\(descr\s+"((?:[^"\\]|\\.)*)"\)', content)
|
||||
result["description"] = _unescape(m.group(1)) if m else None
|
||||
logger.debug(f"parse_kicad_mod: description={result['description']!r}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Keywords / tags: (tags "…")
|
||||
# ------------------------------------------------------------------
|
||||
m = re.search(r'\(tags\s+"((?:[^"\\]|\\.)*)"\)', content)
|
||||
result["keywords"] = _unescape(m.group(1)) if m else None
|
||||
logger.debug(f"parse_kicad_mod: keywords={result['keywords']!r}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Attributes: (attr TYPE [board_only] [exclude_from_pos_files] [exclude_from_bom])
|
||||
# TYPE is smd | through_hole (no quotes)
|
||||
# ------------------------------------------------------------------
|
||||
m = re.search(r"\(attr\s+([^)]+)\)", content)
|
||||
if m:
|
||||
tokens = m.group(1).split()
|
||||
result["attributes"] = {
|
||||
"type": tokens[0] if tokens else "unspecified",
|
||||
"board_only": "board_only" in tokens,
|
||||
"exclude_from_pos_files": "exclude_from_pos_files" in tokens,
|
||||
"exclude_from_bom": "exclude_from_bom" in tokens,
|
||||
}
|
||||
else:
|
||||
result["attributes"] = None
|
||||
logger.debug(f"parse_kicad_mod: attributes={result['attributes']!r}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pads: (pad "NUMBER" TYPE SHAPE …)
|
||||
# Return each pad as an object; deduplicate by number (first wins).
|
||||
# ------------------------------------------------------------------
|
||||
result["pads"] = _extract_pads(content)
|
||||
logger.debug(f"parse_kicad_mod: pads count={len(result['pads'])}, pads={result['pads']}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Layers: all unique canonical layer names across the whole file.
|
||||
# Sources:
|
||||
# (layer "NAME") – single-layer items (fp_line, fp_text, …)
|
||||
# (layers "A" "B" …) – pad layer lists
|
||||
# ------------------------------------------------------------------
|
||||
layers: set = set()
|
||||
for m in re.finditer(r'\(layer\s+"([^"]+)"\)', content):
|
||||
layers.add(m.group(1))
|
||||
for m in re.finditer(r"\(layers\s+([^)]+)\)", content):
|
||||
for lyr in re.findall(r'"([^"]+)"', m.group(1)):
|
||||
layers.add(lyr)
|
||||
result["layers"] = sorted(layers)
|
||||
logger.debug(f"parse_kicad_mod: layers={result['layers']}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Courtyard: derive bounding box from F.CrtYd geometry.
|
||||
# Prefer fp_rect (most common for standard footprints), fall back to
|
||||
# fp_line segments.
|
||||
# ------------------------------------------------------------------
|
||||
result["courtyard"] = _extract_courtyard(content)
|
||||
logger.debug(f"parse_kicad_mod: courtyard={result['courtyard']!r}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _extract_pads(content: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Parse all (pad …) blocks and return a list of pad objects.
|
||||
|
||||
Each object has:
|
||||
number – pad number string, e.g. "1", "A1", "GND"
|
||||
type – thru_hole | smd | np_thru_hole | connect
|
||||
shape – rect | circle | oval | roundrect | trapezoid | custom
|
||||
|
||||
Pads are deduplicated by number (first occurrence wins) so that the
|
||||
list represents the logical pads of the footprint, not duplicated
|
||||
copper entries.
|
||||
"""
|
||||
pads: List[Dict[str, Any]] = []
|
||||
seen_numbers: dict = {}
|
||||
|
||||
# KiCad 6+ quoted format: (pad "NUMBER" TYPE SHAPE …)
|
||||
quoted_pattern = re.compile(
|
||||
r'\(pad\s+"([^"]*)"\s+'
|
||||
r"(thru_hole|smd|np_thru_hole|connect)\s+"
|
||||
r"(rect|circle|oval|roundrect|trapezoid|custom)\b"
|
||||
)
|
||||
for m in quoted_pattern.finditer(content):
|
||||
number, ptype, shape = m.group(1), m.group(2), m.group(3)
|
||||
if number not in seen_numbers:
|
||||
seen_numbers[number] = True
|
||||
pads.append({"number": number, "type": ptype, "shape": shape})
|
||||
|
||||
if not pads:
|
||||
# Older / unquoted format: (pad NUMBER TYPE SHAPE …)
|
||||
unquoted_pattern = re.compile(
|
||||
r"\(pad\s+(\S+)\s+"
|
||||
r"(thru_hole|smd|np_thru_hole|connect)\s+"
|
||||
r"(rect|circle|oval|roundrect|trapezoid|custom)\b"
|
||||
)
|
||||
for m in unquoted_pattern.finditer(content):
|
||||
number, ptype, shape = m.group(1), m.group(2), m.group(3)
|
||||
if number not in seen_numbers:
|
||||
seen_numbers[number] = True
|
||||
pads.append({"number": number, "type": ptype, "shape": shape})
|
||||
|
||||
return pads
|
||||
|
||||
|
||||
def _unescape(s: str) -> str:
|
||||
"""Reverse KiCad S-expression string escaping."""
|
||||
return s.replace('\\"', '"').replace("\\\\", "\\")
|
||||
|
||||
|
||||
def _extract_blocks(content: str, token: str) -> List[str]:
|
||||
"""
|
||||
Return all S-expression blocks that start with `(token ` by tracking
|
||||
parenthesis depth. This correctly handles nested parens inside blocks.
|
||||
"""
|
||||
blocks: List[str] = []
|
||||
pattern = re.compile(r"\(" + re.escape(token) + r"\b")
|
||||
|
||||
for match in pattern.finditer(content):
|
||||
start = match.start()
|
||||
depth = 0
|
||||
i = start
|
||||
while i < len(content):
|
||||
ch = content[i]
|
||||
if ch == "(":
|
||||
depth += 1
|
||||
elif ch == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
blocks.append(content[start : i + 1])
|
||||
break
|
||||
i += 1
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def _extract_courtyard(content: str) -> Optional[Dict[str, float]]:
|
||||
"""
|
||||
Compute the courtyard bounding box from F.CrtYd geometry.
|
||||
|
||||
Strategy:
|
||||
1. Try fp_rect blocks on F.CrtYd — derive width/height from start/end.
|
||||
2. Fall back to fp_line segments on F.CrtYd — compute bounding box of
|
||||
all endpoints.
|
||||
"""
|
||||
xs: List[float] = []
|
||||
ys: List[float] = []
|
||||
|
||||
# --- fp_rect pass ---
|
||||
for block in _extract_blocks(content, "fp_rect"):
|
||||
if "F.CrtYd" not in block:
|
||||
continue
|
||||
s = re.search(r"\(start\s+([-\d.]+)\s+([-\d.]+)\)", block)
|
||||
e = re.search(r"\(end\s+([-\d.]+)\s+([-\d.]+)\)", block)
|
||||
if s and e:
|
||||
xs += [float(s.group(1)), float(e.group(1))]
|
||||
ys += [float(s.group(2)), float(e.group(2))]
|
||||
logger.debug(
|
||||
f"_extract_courtyard: fp_rect F.CrtYd "
|
||||
f"start=({s.group(1)},{s.group(2)}) end=({e.group(1)},{e.group(2)})"
|
||||
)
|
||||
|
||||
# --- fp_line pass (only if fp_rect found nothing) ---
|
||||
if not xs:
|
||||
for block in _extract_blocks(content, "fp_line"):
|
||||
if "F.CrtYd" not in block:
|
||||
continue
|
||||
for m in re.finditer(r"\((?:start|end)\s+([-\d.]+)\s+([-\d.]+)\)", block):
|
||||
xs.append(float(m.group(1)))
|
||||
ys.append(float(m.group(2)))
|
||||
|
||||
if not xs:
|
||||
logger.debug("_extract_courtyard: no F.CrtYd geometry found")
|
||||
return None
|
||||
|
||||
width = round(abs(max(xs) - min(xs)), 6)
|
||||
height = round(abs(max(ys) - min(ys)), 6)
|
||||
logger.debug(f"_extract_courtyard: result width={width} height={height}")
|
||||
return {"width": width, "height": height}
|
||||
"""
|
||||
Parser for KiCad .kicad_mod footprint files.
|
||||
|
||||
Extracts the fields that the MCP get_footprint_info tool exposes to clients:
|
||||
name – footprint name (str)
|
||||
library – library nickname, injected by caller (str)
|
||||
description – (descr "…") token (str | None)
|
||||
keywords – (tags "…") token (str | None)
|
||||
pads – list of pad objects: [{number, type, shape}, …] (list[dict])
|
||||
layers – sorted unique list of canonical layer names used (list[str])
|
||||
courtyard – {"width": float, "height": float} from F.CrtYd geometry (dict | None)
|
||||
attributes – {"type": str, "board_only": bool, …} (dict | None)
|
||||
|
||||
KiCad S-expression file format reference:
|
||||
https://dev-docs.kicad.org/en/file-formats/sexpr-intro/index.html#_footprint
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Parse a .kicad_mod file and return a dict whose keys match the fields
|
||||
expected by the TypeScript MCP tool handler (src/tools/library.ts).
|
||||
|
||||
Returns None if the file does not exist or cannot be read.
|
||||
"""
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
logger.debug(f"parse_kicad_mod: file not found: {file_path}")
|
||||
return None
|
||||
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except OSError as e:
|
||||
logger.warning(f"parse_kicad_mod: cannot read {file_path}: {e}")
|
||||
return None
|
||||
|
||||
logger.debug(f"parse_kicad_mod: parsing {path.name} ({len(content)} chars)")
|
||||
|
||||
result: Dict[str, Any] = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Footprint name: (footprint "NAME" …
|
||||
# Per spec, in a library file the name is the ENTRY_NAME only (no lib prefix).
|
||||
# ------------------------------------------------------------------
|
||||
m = re.search(r'^\s*\(footprint\s+"((?:[^"\\]|\\.)*)"', content, re.MULTILINE)
|
||||
if not m:
|
||||
# Older / unquoted format
|
||||
m = re.search(r"^\s*\(footprint\s+(\S+)", content, re.MULTILINE)
|
||||
result["name"] = _unescape(m.group(1)) if m else path.stem
|
||||
logger.debug(f"parse_kicad_mod: name={result['name']!r}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Description: (descr "…")
|
||||
# ------------------------------------------------------------------
|
||||
m = re.search(r'\(descr\s+"((?:[^"\\]|\\.)*)"\)', content)
|
||||
result["description"] = _unescape(m.group(1)) if m else None
|
||||
logger.debug(f"parse_kicad_mod: description={result['description']!r}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Keywords / tags: (tags "…")
|
||||
# ------------------------------------------------------------------
|
||||
m = re.search(r'\(tags\s+"((?:[^"\\]|\\.)*)"\)', content)
|
||||
result["keywords"] = _unescape(m.group(1)) if m else None
|
||||
logger.debug(f"parse_kicad_mod: keywords={result['keywords']!r}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Attributes: (attr TYPE [board_only] [exclude_from_pos_files] [exclude_from_bom])
|
||||
# TYPE is smd | through_hole (no quotes)
|
||||
# ------------------------------------------------------------------
|
||||
m = re.search(r"\(attr\s+([^)]+)\)", content)
|
||||
if m:
|
||||
tokens = m.group(1).split()
|
||||
result["attributes"] = {
|
||||
"type": tokens[0] if tokens else "unspecified",
|
||||
"board_only": "board_only" in tokens,
|
||||
"exclude_from_pos_files": "exclude_from_pos_files" in tokens,
|
||||
"exclude_from_bom": "exclude_from_bom" in tokens,
|
||||
}
|
||||
else:
|
||||
result["attributes"] = None
|
||||
logger.debug(f"parse_kicad_mod: attributes={result['attributes']!r}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pads: (pad "NUMBER" TYPE SHAPE …)
|
||||
# Return each pad as an object; deduplicate by number (first wins).
|
||||
# ------------------------------------------------------------------
|
||||
result["pads"] = _extract_pads(content)
|
||||
logger.debug(f"parse_kicad_mod: pads count={len(result['pads'])}, pads={result['pads']}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Layers: all unique canonical layer names across the whole file.
|
||||
# Sources:
|
||||
# (layer "NAME") – single-layer items (fp_line, fp_text, …)
|
||||
# (layers "A" "B" …) – pad layer lists
|
||||
# ------------------------------------------------------------------
|
||||
layers: set = set()
|
||||
for m in re.finditer(r'\(layer\s+"([^"]+)"\)', content):
|
||||
layers.add(m.group(1))
|
||||
for m in re.finditer(r"\(layers\s+([^)]+)\)", content):
|
||||
for lyr in re.findall(r'"([^"]+)"', m.group(1)):
|
||||
layers.add(lyr)
|
||||
result["layers"] = sorted(layers)
|
||||
logger.debug(f"parse_kicad_mod: layers={result['layers']}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Courtyard: derive bounding box from F.CrtYd geometry.
|
||||
# Prefer fp_rect (most common for standard footprints), fall back to
|
||||
# fp_line segments.
|
||||
# ------------------------------------------------------------------
|
||||
result["courtyard"] = _extract_courtyard(content)
|
||||
logger.debug(f"parse_kicad_mod: courtyard={result['courtyard']!r}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _extract_pads(content: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Parse all (pad …) blocks and return a list of pad objects.
|
||||
|
||||
Each object has:
|
||||
number – pad number string, e.g. "1", "A1", "GND"
|
||||
type – thru_hole | smd | np_thru_hole | connect
|
||||
shape – rect | circle | oval | roundrect | trapezoid | custom
|
||||
|
||||
Pads are deduplicated by number (first occurrence wins) so that the
|
||||
list represents the logical pads of the footprint, not duplicated
|
||||
copper entries.
|
||||
"""
|
||||
pads: List[Dict[str, Any]] = []
|
||||
seen_numbers: dict = {}
|
||||
|
||||
# KiCad 6+ quoted format: (pad "NUMBER" TYPE SHAPE …)
|
||||
quoted_pattern = re.compile(
|
||||
r'\(pad\s+"([^"]*)"\s+'
|
||||
r"(thru_hole|smd|np_thru_hole|connect)\s+"
|
||||
r"(rect|circle|oval|roundrect|trapezoid|custom)\b"
|
||||
)
|
||||
for m in quoted_pattern.finditer(content):
|
||||
number, ptype, shape = m.group(1), m.group(2), m.group(3)
|
||||
if number not in seen_numbers:
|
||||
seen_numbers[number] = True
|
||||
pads.append({"number": number, "type": ptype, "shape": shape})
|
||||
|
||||
if not pads:
|
||||
# Older / unquoted format: (pad NUMBER TYPE SHAPE …)
|
||||
unquoted_pattern = re.compile(
|
||||
r"\(pad\s+(\S+)\s+"
|
||||
r"(thru_hole|smd|np_thru_hole|connect)\s+"
|
||||
r"(rect|circle|oval|roundrect|trapezoid|custom)\b"
|
||||
)
|
||||
for m in unquoted_pattern.finditer(content):
|
||||
number, ptype, shape = m.group(1), m.group(2), m.group(3)
|
||||
if number not in seen_numbers:
|
||||
seen_numbers[number] = True
|
||||
pads.append({"number": number, "type": ptype, "shape": shape})
|
||||
|
||||
return pads
|
||||
|
||||
|
||||
def _unescape(s: str) -> str:
|
||||
"""Reverse KiCad S-expression string escaping."""
|
||||
return s.replace('\\"', '"').replace("\\\\", "\\")
|
||||
|
||||
|
||||
def _extract_blocks(content: str, token: str) -> List[str]:
|
||||
"""
|
||||
Return all S-expression blocks that start with `(token ` by tracking
|
||||
parenthesis depth. This correctly handles nested parens inside blocks.
|
||||
"""
|
||||
blocks: List[str] = []
|
||||
pattern = re.compile(r"\(" + re.escape(token) + r"\b")
|
||||
|
||||
for match in pattern.finditer(content):
|
||||
start = match.start()
|
||||
depth = 0
|
||||
i = start
|
||||
while i < len(content):
|
||||
ch = content[i]
|
||||
if ch == "(":
|
||||
depth += 1
|
||||
elif ch == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
blocks.append(content[start : i + 1])
|
||||
break
|
||||
i += 1
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def _extract_courtyard(content: str) -> Optional[Dict[str, float]]:
|
||||
"""
|
||||
Compute the courtyard bounding box from F.CrtYd geometry.
|
||||
|
||||
Strategy:
|
||||
1. Try fp_rect blocks on F.CrtYd — derive width/height from start/end.
|
||||
2. Fall back to fp_line segments on F.CrtYd — compute bounding box of
|
||||
all endpoints.
|
||||
"""
|
||||
xs: List[float] = []
|
||||
ys: List[float] = []
|
||||
|
||||
# --- fp_rect pass ---
|
||||
for block in _extract_blocks(content, "fp_rect"):
|
||||
if "F.CrtYd" not in block:
|
||||
continue
|
||||
s = re.search(r"\(start\s+([-\d.]+)\s+([-\d.]+)\)", block)
|
||||
e = re.search(r"\(end\s+([-\d.]+)\s+([-\d.]+)\)", block)
|
||||
if s and e:
|
||||
xs += [float(s.group(1)), float(e.group(1))]
|
||||
ys += [float(s.group(2)), float(e.group(2))]
|
||||
logger.debug(
|
||||
f"_extract_courtyard: fp_rect F.CrtYd "
|
||||
f"start=({s.group(1)},{s.group(2)}) end=({e.group(1)},{e.group(2)})"
|
||||
)
|
||||
|
||||
# --- fp_line pass (only if fp_rect found nothing) ---
|
||||
if not xs:
|
||||
for block in _extract_blocks(content, "fp_line"):
|
||||
if "F.CrtYd" not in block:
|
||||
continue
|
||||
for m in re.finditer(r"\((?:start|end)\s+([-\d.]+)\s+([-\d.]+)\)", block):
|
||||
xs.append(float(m.group(1)))
|
||||
ys.append(float(m.group(2)))
|
||||
|
||||
if not xs:
|
||||
logger.debug("_extract_courtyard: no F.CrtYd geometry found")
|
||||
return None
|
||||
|
||||
width = round(abs(max(xs) - min(xs)), 6)
|
||||
height = round(abs(max(ys) - min(ys)), 6)
|
||||
logger.debug(f"_extract_courtyard: result width={width} height={height}")
|
||||
return {"width": width, "height": height}
|
||||
|
||||
Reference in New Issue
Block a user