Add schematic sexp validation and export_pdf working_dir fix
validate_project now scans all .kicad_sch files for two classes of silent rendering failure: quoted (property "private" ...) that should be a bare keyword in KiCad 9, and lib_id references with no matching lib_symbols entry. Both cause kicad-cli to blank entire pages during export without any error message. Also pass working_dir to run_kicad_command in export_pdf for robust library path resolution. Addresses feedback from ESP32-P4 project (agent thread message 021).
This commit is contained in:
parent
9d6cbc452c
commit
56705cf345
@ -9,6 +9,7 @@ for live data, falling back to file-based checks otherwise.
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from mckicad.server import mcp
|
||||
@ -17,6 +18,102 @@ from mckicad.utils.ipc_client import check_kicad_availability, kicad_ipc_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Regex for detecting quoted "private" keyword — should be bare keyword in KiCad 9+
|
||||
_PROPERTY_PRIVATE_RE = re.compile(r'\(property\s+"private"\s+')
|
||||
|
||||
# Regex for extracting lib_id references from component instances
|
||||
_LIB_ID_RE = re.compile(r'\(lib_id\s+"([^"]+)"\)')
|
||||
|
||||
# Regex for extracting top-level symbol names from lib_symbols section
|
||||
_LIB_SYMBOL_RE = re.compile(r'\(symbol\s+"([^"]+)"')
|
||||
|
||||
|
||||
def _validate_schematic_sexp(schematic_path: str) -> list[str]:
|
||||
"""Check a .kicad_sch file for known sexp malformations.
|
||||
|
||||
Currently detects:
|
||||
1. ``(property "private" ...)`` — should be bare keyword ``(property private ...)``
|
||||
in KiCad 9+. A quoted "private" corrupts the sexp parser and silently blanks
|
||||
the entire page during PDF/SVG export.
|
||||
2. Missing ``lib_symbols`` entries — component instances that reference a ``lib_id``
|
||||
with no matching symbol definition in the ``(lib_symbols ...)`` section. Missing
|
||||
entries cause kicad-cli to render the entire page blank.
|
||||
|
||||
Returns a list of human-readable issue strings (empty if clean).
|
||||
"""
|
||||
issues: list[str] = []
|
||||
basename = os.path.basename(schematic_path)
|
||||
|
||||
try:
|
||||
with open(schematic_path) as f:
|
||||
content = f.read()
|
||||
except Exception as e:
|
||||
issues.append(f"{basename}: could not read file: {e}")
|
||||
return issues
|
||||
|
||||
# 1. property "private" malformation
|
||||
matches = _PROPERTY_PRIVATE_RE.findall(content)
|
||||
if matches:
|
||||
issues.append(
|
||||
f'{basename}: {len(matches)} malformed (property "private" ...) — '
|
||||
f"should be bare keyword (property private ...). "
|
||||
f"This silently blanks the page during PDF/SVG export."
|
||||
)
|
||||
|
||||
# 2. lib_symbols completeness check
|
||||
# Extract the lib_symbols section
|
||||
lib_symbols_start = content.find("(lib_symbols")
|
||||
if lib_symbols_start != -1:
|
||||
# Find all top-level symbol definitions in lib_symbols
|
||||
# We need to be careful to only look inside the lib_symbols section.
|
||||
# Find the matching close paren by counting nesting depth.
|
||||
depth = 0
|
||||
lib_symbols_end = lib_symbols_start
|
||||
for i in range(lib_symbols_start, len(content)):
|
||||
if content[i] == "(":
|
||||
depth += 1
|
||||
elif content[i] == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
lib_symbols_end = i + 1
|
||||
break
|
||||
|
||||
lib_symbols_section = content[lib_symbols_start:lib_symbols_end]
|
||||
|
||||
# Top-level symbols in lib_symbols — these are at nesting depth 1
|
||||
# inside lib_symbols, e.g. (symbol "Device:R" ...)
|
||||
defined_symbols: set[str] = set()
|
||||
for m in _LIB_SYMBOL_RE.finditer(lib_symbols_section):
|
||||
sym_name = m.group(1)
|
||||
# Only count top-level symbols (not sub-unit definitions like "Device:R_0_1")
|
||||
# Sub-units contain underscores after the main symbol name
|
||||
# but top-level symbols can also have underscores (e.g. "Device:Crystal_GND24")
|
||||
# The reliable check: top-level definitions are at a specific nesting depth.
|
||||
# Simpler heuristic: if the preceding context is "(lib_symbols\n" or ")\n"
|
||||
# at the right depth, it's top-level. But easiest: collect all, then
|
||||
# a lib_id match means it's present.
|
||||
defined_symbols.add(sym_name)
|
||||
|
||||
# Component lib_id references (outside lib_symbols section)
|
||||
before_lib = content[:lib_symbols_start]
|
||||
after_lib = content[lib_symbols_end:]
|
||||
component_text = before_lib + after_lib
|
||||
|
||||
referenced_ids: set[str] = set()
|
||||
for m in _LIB_ID_RE.finditer(component_text):
|
||||
referenced_ids.add(m.group(1))
|
||||
|
||||
missing = referenced_ids - defined_symbols
|
||||
if missing:
|
||||
missing_list = sorted(missing)
|
||||
issues.append(
|
||||
f"{basename}: {len(missing)} lib_id reference(s) have no matching "
|
||||
f"lib_symbols entry: {', '.join(missing_list)}. "
|
||||
f"Missing entries cause kicad-cli to blank the entire page."
|
||||
)
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def validate_project(project_path: str) -> dict[str, Any]:
|
||||
@ -91,6 +188,17 @@ def validate_project(project_path: str) -> dict[str, Any]:
|
||||
except Exception as e:
|
||||
issues.append(f"Error reading project file: {e}")
|
||||
|
||||
# Validate schematic sexp integrity (all .kicad_sch files in project dir)
|
||||
if "schematic" in files:
|
||||
project_dir = os.path.dirname(project_path)
|
||||
sch_files = [
|
||||
os.path.join(project_dir, f)
|
||||
for f in os.listdir(project_dir)
|
||||
if f.endswith(".kicad_sch")
|
||||
]
|
||||
for sch_path in sorted(sch_files):
|
||||
issues.extend(_validate_schematic_sexp(sch_path))
|
||||
|
||||
# Optional live analysis via KiCad IPC
|
||||
ipc_analysis: dict[str, Any] = {}
|
||||
ipc_status = check_kicad_availability()
|
||||
|
||||
@ -315,6 +315,7 @@ def export_pdf(project_path: str, file_type: str = "pcb") -> dict[str, Any]:
|
||||
command_args=cmd_args,
|
||||
input_files=[source_file],
|
||||
output_files=[output_file],
|
||||
working_dir=project_dir,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
|
||||
136
tests/test_analysis.py
Normal file
136
tests/test_analysis.py
Normal file
@ -0,0 +1,136 @@
|
||||
"""Tests for project analysis and validation tools."""
|
||||
|
||||
import os
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestValidateSchematicSexp:
|
||||
"""Tests for the _validate_schematic_sexp helper."""
|
||||
|
||||
def _write_sch(self, tmp_path: str, name: str, content: str) -> str:
|
||||
path = os.path.join(tmp_path, name)
|
||||
with open(path, "w") as f:
|
||||
f.write(content)
|
||||
return path
|
||||
|
||||
def test_clean_schematic_no_issues(self, tmp_output_dir):
|
||||
from mckicad.tools.analysis import _validate_schematic_sexp
|
||||
|
||||
content = textwrap.dedent("""\
|
||||
(kicad_sch (version 20231120) (generator "eeschema")
|
||||
(lib_symbols
|
||||
(symbol "Device:R"
|
||||
(property private "KLC_S3.3" "Valid bare keyword"
|
||||
(at 0 0 0)
|
||||
(effects (font (size 1.27 1.27)) (hide yes))
|
||||
)
|
||||
(pin passive line (at -3.81 0 0) (length 2.54) (name "1"))
|
||||
)
|
||||
)
|
||||
(symbol (lib_id "Device:R") (at 100 100 0)
|
||||
(property "Reference" "R1" (at 0 0 0))
|
||||
)
|
||||
)
|
||||
""")
|
||||
path = self._write_sch(tmp_output_dir, "clean.kicad_sch", content)
|
||||
issues = _validate_schematic_sexp(path)
|
||||
assert issues == []
|
||||
|
||||
def test_detects_quoted_property_private(self, tmp_output_dir):
|
||||
from mckicad.tools.analysis import _validate_schematic_sexp
|
||||
|
||||
content = textwrap.dedent("""\
|
||||
(kicad_sch (version 20231120) (generator "eeschema")
|
||||
(lib_symbols
|
||||
(symbol "Device:Crystal_GND24"
|
||||
(property "private" "KLC_S3.3" The rectangle is not a symbol body
|
||||
(at 0 -12.7 0)
|
||||
(effects (font (size 1.27 1.27)) (hide yes))
|
||||
)
|
||||
(property "private" "KLC_S4.1" Some pins are on 50mil grid
|
||||
(at 0 -15.24 0)
|
||||
(effects (font (size 1.27 1.27)) (hide yes))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
""")
|
||||
path = self._write_sch(tmp_output_dir, "malformed.kicad_sch", content)
|
||||
issues = _validate_schematic_sexp(path)
|
||||
assert len(issues) == 1
|
||||
assert 'property "private"' in issues[0]
|
||||
assert "2 malformed" in issues[0]
|
||||
|
||||
def test_detects_missing_lib_symbols(self, tmp_output_dir):
|
||||
from mckicad.tools.analysis import _validate_schematic_sexp
|
||||
|
||||
content = textwrap.dedent("""\
|
||||
(kicad_sch (version 20231120) (generator "eeschema")
|
||||
(lib_symbols
|
||||
(symbol "Device:R"
|
||||
(pin passive line (at -3.81 0 0) (length 2.54) (name "1"))
|
||||
)
|
||||
)
|
||||
(symbol (lib_id "Device:R") (at 100 100 0)
|
||||
(property "Reference" "R1" (at 0 0 0))
|
||||
)
|
||||
(symbol (lib_id "Device:C") (at 200 100 0)
|
||||
(property "Reference" "C1" (at 0 0 0))
|
||||
)
|
||||
(symbol (lib_id "MyLib:CustomIC") (at 300 100 0)
|
||||
(property "Reference" "U1" (at 0 0 0))
|
||||
)
|
||||
)
|
||||
""")
|
||||
path = self._write_sch(tmp_output_dir, "missing_syms.kicad_sch", content)
|
||||
issues = _validate_schematic_sexp(path)
|
||||
assert len(issues) == 1
|
||||
assert "2 lib_id" in issues[0]
|
||||
assert "Device:C" in issues[0]
|
||||
assert "MyLib:CustomIC" in issues[0]
|
||||
|
||||
def test_detects_both_issues(self, tmp_output_dir):
|
||||
from mckicad.tools.analysis import _validate_schematic_sexp
|
||||
|
||||
content = textwrap.dedent("""\
|
||||
(kicad_sch (version 20231120) (generator "eeschema")
|
||||
(lib_symbols
|
||||
(symbol "Device:R"
|
||||
(property "private" "KLC_S3.3" malformed
|
||||
(at 0 0 0)
|
||||
(effects (font (size 1.27 1.27)) (hide yes))
|
||||
)
|
||||
)
|
||||
)
|
||||
(symbol (lib_id "Device:R") (at 100 100 0))
|
||||
(symbol (lib_id "Missing:Symbol") (at 200 100 0))
|
||||
)
|
||||
""")
|
||||
path = self._write_sch(tmp_output_dir, "both.kicad_sch", content)
|
||||
issues = _validate_schematic_sexp(path)
|
||||
assert len(issues) == 2
|
||||
# One for property private, one for missing lib_symbol
|
||||
issue_text = " ".join(issues)
|
||||
assert "private" in issue_text
|
||||
assert "Missing:Symbol" in issue_text
|
||||
|
||||
def test_nonexistent_file(self):
|
||||
from mckicad.tools.analysis import _validate_schematic_sexp
|
||||
|
||||
issues = _validate_schematic_sexp("/tmp/nonexistent_12345.kicad_sch")
|
||||
assert len(issues) == 1
|
||||
assert "could not read" in issues[0]
|
||||
|
||||
def test_no_lib_symbols_section(self, tmp_output_dir):
|
||||
from mckicad.tools.analysis import _validate_schematic_sexp
|
||||
|
||||
content = textwrap.dedent("""\
|
||||
(kicad_sch (version 20231120) (generator "eeschema")
|
||||
)
|
||||
""")
|
||||
path = self._write_sch(tmp_output_dir, "empty.kicad_sch", content)
|
||||
issues = _validate_schematic_sexp(path)
|
||||
assert issues == []
|
||||
Loading…
x
Reference in New Issue
Block a user