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:
Ryan Malloy 2026-03-05 10:46:47 -07:00
parent 9d6cbc452c
commit 56705cf345
3 changed files with 245 additions and 0 deletions

View File

@ -9,6 +9,7 @@ for live data, falling back to file-based checks otherwise.
import json import json
import logging import logging
import os import os
import re
from typing import Any from typing import Any
from mckicad.server import mcp 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__) 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() @mcp.tool()
def validate_project(project_path: str) -> dict[str, Any]: 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: except Exception as e:
issues.append(f"Error reading project file: {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 # Optional live analysis via KiCad IPC
ipc_analysis: dict[str, Any] = {} ipc_analysis: dict[str, Any] = {}
ipc_status = check_kicad_availability() ipc_status = check_kicad_availability()

View File

@ -315,6 +315,7 @@ def export_pdf(project_path: str, file_type: str = "pcb") -> dict[str, Any]:
command_args=cmd_args, command_args=cmd_args,
input_files=[source_file], input_files=[source_file],
output_files=[output_file], output_files=[output_file],
working_dir=project_dir,
) )
if result.returncode != 0: if result.returncode != 0:

136
tests/test_analysis.py Normal file
View 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 == []