Merge pull request #240 from ThomasBenjaminCook/issue-105-upstream-clean

Closes #105 — Linux KiCad 8/9/10 real-pcbnew smoke matrix. Thanks @ThomasBenjaminCook.
This commit is contained in:
mixelpixx
2026-06-12 12:37:06 -04:00
committed by GitHub
4 changed files with 250 additions and 24 deletions

View File

@@ -14,7 +14,7 @@ jobs:
strategy: strategy:
matrix: matrix:
os: [ubuntu-24.04, ubuntu-22.04, windows-latest, macos-latest] os: [ubuntu-24.04, ubuntu-22.04, windows-latest, macos-latest]
node-version: [18.x, 20.x, 22.x] node-version: [20.x, 22.x]
steps: steps:
- name: Checkout code - name: Checkout code
@@ -33,6 +33,7 @@ jobs:
run: npm run build run: npm run build
- name: Run linter - name: Run linter
shell: bash
run: npm run lint || echo "Linter not configured yet" run: npm run lint || echo "Linter not configured yet"
- name: Run TypeScript tests - name: Run TypeScript tests
@@ -88,46 +89,225 @@ jobs:
integration-tests: integration-tests:
name: Integration Tests (Linux + KiCAD) name: Integration Tests (Linux + KiCAD)
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
kicad: ["8.0", "9.0", "10.0"]
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
cache: "npm"
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: "3.12" python-version: "3.12"
cache: "pip" cache: "pip"
- name: Add KiCAD PPA and Install KiCAD 9.0 - name: Add KiCAD PPA and Install KiCAD ${{ matrix.kicad }}
run: | run: |
sudo add-apt-repository --yes ppa:kicad/kicad-9.0-releases sudo add-apt-repository --yes ppa:kicad/kicad-${{ matrix.kicad }}-releases
sudo apt-get update sudo apt-get update
sudo apt-get install -y kicad kicad-libraries sudo apt-get install -y kicad kicad-libraries
mapfile -t KICAD_PYTHON_PACKAGES < <(
{
apt-cache search kicad
apt-cache search pcbnew
} | awk '
BEGIN { IGNORECASE = 1 }
{
name = $1
line = tolower($0)
if (name !~ /nightly/ && (line ~ /kicad/ || line ~ /pcbnew/) && line ~ /python/) print name
}
' | sort -u
)
if [ "${#KICAD_PYTHON_PACKAGES[@]}" -gt 0 ]; then
echo "Installing KiCad Python packages: ${KICAD_PYTHON_PACKAGES[*]}"
sudo apt-get install -y "${KICAD_PYTHON_PACKAGES[@]}"
fi
KICAD_PYTHONPATH="$(python3 - <<'PY'
import glob
import os
import subprocess
import sys
from pathlib import Path
candidates = []
seen = set()
def add_candidate(root: Path) -> None:
if not root.exists():
return
root_str = str(root)
if root_str not in seen:
seen.add(root_str)
candidates.append(root_str)
def add_candidate_for_file(file_path: Path) -> None:
if file_path.name == "__init__.py" and file_path.parent.name == "pcbnew":
add_candidate(file_path.parent.parent)
return
if file_path.parent.name == "pcbnew":
add_candidate(file_path.parent.parent)
return
add_candidate(file_path.parent)
def looks_like_pcbnew(file_path: Path) -> bool:
name = file_path.name
if name == "pcbnew.py":
return True
if name == "__init__.py" and file_path.parent.name == "pcbnew":
return True
return name.endswith(".so") and (
name.startswith("pcbnew") or name.startswith("_pcbnew")
)
def collect_from_package(package: str) -> None:
try:
result = subprocess.run(
["dpkg", "-L", package],
check=True,
capture_output=True,
text=True,
)
except subprocess.CalledProcessError:
return
for raw_path in result.stdout.splitlines():
path = Path(raw_path)
if path.is_file() and looks_like_pcbnew(path):
add_candidate_for_file(path)
package_result = subprocess.run(
["dpkg-query", "-W", "-f=${Package}\n"],
check=True,
capture_output=True,
text=True,
)
installed_packages = [
package
for package in package_result.stdout.splitlines()
if "kicad" in package or "pcbnew" in package
]
for package in installed_packages:
collect_from_package(package)
file_patterns = [
"/usr/lib/python3/dist-packages/pcbnew.py",
"/usr/lib/python3/dist-packages/pcbnew*.so",
"/usr/lib/python3/dist-packages/_pcbnew*.so",
"/usr/lib/python3/dist-packages/pcbnew/__init__.py",
"/usr/lib/python3/dist-packages/pcbnew/_pcbnew*.so",
"/usr/lib/kicad*/lib/python3/dist-packages/pcbnew.py",
"/usr/lib/kicad*/lib/python3/dist-packages/pcbnew*.so",
"/usr/lib/kicad*/lib/python3/dist-packages/_pcbnew*.so",
"/usr/lib/kicad*/lib/python3/dist-packages/pcbnew/__init__.py",
"/usr/lib/kicad*/lib/python3/dist-packages/pcbnew/_pcbnew*.so",
"/usr/lib/*/dist-packages/pcbnew.py",
"/usr/lib/*/dist-packages/pcbnew*.so",
"/usr/lib/*/dist-packages/_pcbnew*.so",
"/usr/lib/*/dist-packages/pcbnew/__init__.py",
"/usr/lib/*/dist-packages/pcbnew/_pcbnew*.so",
"/usr/local/lib/python*/dist-packages/pcbnew.py",
"/usr/local/lib/python*/dist-packages/pcbnew*.so",
"/usr/local/lib/python*/dist-packages/_pcbnew*.so",
"/usr/local/lib/python*/dist-packages/pcbnew/__init__.py",
"/usr/local/lib/python*/dist-packages/pcbnew/_pcbnew*.so",
"/usr/share/kicad*/scripting/pcbnew.py",
"/usr/share/kicad*/scripting/pcbnew*.so",
"/usr/share/kicad*/scripting/_pcbnew*.so",
"/usr/share/kicad*/scripting/pcbnew/__init__.py",
"/usr/share/kicad*/scripting/pcbnew/_pcbnew*.so",
]
for pattern in file_patterns:
for raw_path in glob.glob(pattern):
path = Path(raw_path)
if path.is_file() and looks_like_pcbnew(path):
add_candidate_for_file(path)
if not candidates:
for file_path in Path("/usr").rglob("*"):
if file_path.is_file() and looks_like_pcbnew(file_path):
add_candidate_for_file(file_path)
def import_succeeds(candidate: str) -> bool:
env = os.environ.copy()
existing = env.get("PYTHONPATH", "")
env["PYTHONPATH"] = (
f"{candidate}:{existing}" if existing else candidate
)
probe = subprocess.run(
[
sys.executable,
"-c",
(
"import pcbnew; "
"print(getattr(pcbnew, '__file__', '')); "
"print(pcbnew.GetBuildVersion())"
),
],
capture_output=True,
text=True,
env=env,
)
if probe.returncode == 0:
print(
f"Using pcbnew from {probe.stdout.splitlines()[0]}",
file=sys.stderr,
)
return True
return False
working_candidates = [candidate for candidate in candidates if import_succeeds(candidate)]
print(":".join(working_candidates))
PY
)"
if [ -z "$KICAD_PYTHONPATH" ]; then
echo "Unable to locate pcbnew Python bindings after installing KiCad ${{ matrix.kicad }}" >&2
{
apt-cache search kicad
apt-cache search pcbnew
} | awk 'BEGIN { IGNORECASE = 1 } tolower($0) ~ /python/ { print }' || true
dpkg-query -W -f='${Package}\n' | grep -Ei 'kicad|pcbnew' || true
find /usr -path '*dist-packages*' \( -name 'pcbnew.py' -o -name 'pcbnew*.so' -o -name '_pcbnew*.so' \) -print || true
exit 1
fi
echo "KICAD_PYTHONPATH=$KICAD_PYTHONPATH" >> $GITHUB_ENV
- name: Verify KiCAD installation - name: Verify KiCAD installation
run: | run: |
kicad-cli version || echo "kicad-cli not found" export KICAD_CLI="kicad-cli"
python3 -c "import pcbnew; print(f'pcbnew version: {pcbnew.GetBuildVersion()}')" || echo "pcbnew module not found" export PYTHONPATH="$KICAD_PYTHONPATH:$PYTHONPATH"
"$KICAD_CLI" version
python3 -c "import pcbnew; print(f'pcbnew version: {pcbnew.GetBuildVersion()}')"
- name: Install dependencies - name: Install dependencies
run: | run: |
npm ci python -m pip install --upgrade pip
pip install -r requirements.txt pip install -r requirements.txt
if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi
- name: Build TypeScript - name: Run real pcbnew smoke tests
run: npm run build env:
KICAD_USE_REAL_PCBNEW: "1"
- name: Run integration tests
run: | run: |
echo "Integration tests not yet configured" export PYTHONPATH="$KICAD_PYTHONPATH:$PYTHONPATH"
# pytest tests/integration/ pytest tests/test_real_pcbnew_matrix.py -m "integration and real_pcbnew"
# Docker build test # Docker build test
docker-build: docker-build:

View File

@@ -29,6 +29,7 @@ addopts =
markers = markers =
unit: Unit tests (fast, no external dependencies) unit: Unit tests (fast, no external dependencies)
integration: Integration tests (requires KiCAD) integration: Integration tests (requires KiCAD)
real_pcbnew: Headless tests that exercise real KiCad SWIG bindings
slow: Slow-running tests slow: Slow-running tests
linux: Linux-specific tests linux: Linux-specific tests
windows: Windows-specific tests windows: Windows-specific tests

View File

@@ -6,6 +6,7 @@ test module can trigger their import, preventing crashes on systems where the
real KiCAD environment is not fully initialised for testing. real KiCAD environment is not fully initialised for testing.
""" """
import os
import sys import sys
import types import types
from unittest.mock import MagicMock from unittest.mock import MagicMock
@@ -16,12 +17,13 @@ from unittest.mock import MagicMock
# attribute access (pcbnew.BOARD, pcbnew.PCB_TRACK, …) returns a mock # attribute access (pcbnew.BOARD, pcbnew.PCB_TRACK, …) returns a mock
# rather than raising AttributeError. # rather than raising AttributeError.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_pcbnew = MagicMock(name="pcbnew") if os.environ.get("KICAD_USE_REAL_PCBNEW") != "1":
_pcbnew.__file__ = "/fake/pcbnew.cpython-313-x86_64-linux-gnu.so" _pcbnew = MagicMock(name="pcbnew")
_pcbnew.__name__ = "pcbnew" _pcbnew.__file__ = "/fake/pcbnew.cpython-313-x86_64-linux-gnu.so"
_pcbnew.__spec__ = None _pcbnew.__name__ = "pcbnew"
_pcbnew.GetBuildVersion.return_value = "9.0.0-stub" _pcbnew.__spec__ = None
sys.modules["pcbnew"] = _pcbnew _pcbnew.GetBuildVersion.return_value = "9.0.0-stub"
sys.modules["pcbnew"] = _pcbnew
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Stub: skip (kicad-skip — use real module if available, stub otherwise) # Stub: skip (kicad-skip — use real module if available, stub otherwise)

View File

@@ -0,0 +1,43 @@
import os
import sys
from pathlib import Path
import pytest
PYTHON_DIR = Path(__file__).parent.parent / "python"
sys.path.insert(0, str(PYTHON_DIR))
pytestmark = [
pytest.mark.integration,
pytest.mark.real_pcbnew,
pytest.mark.linux,
]
@pytest.fixture(autouse=True)
def require_real_pcbnew() -> None:
if os.environ.get("KICAD_USE_REAL_PCBNEW") != "1":
pytest.skip("real pcbnew smoke tests require KICAD_USE_REAL_PCBNEW=1")
def test_project_commands_create_and_load_board_with_real_pcbnew(tmp_path: Path) -> None:
import pcbnew
from commands.project import ProjectCommands
version = pcbnew.GetBuildVersion()
assert version
assert not str(version).endswith("-stub")
commands = ProjectCommands()
result = commands.create_project({"name": "matrix_smoke", "path": str(tmp_path)})
assert result["success"] is True, result
board_path = Path(result["project"]["boardPath"])
assert board_path.exists()
board = pcbnew.LoadBoard(str(board_path))
assert board is not None
assert hasattr(board, "GetFileName")
assert board.GetFileName().endswith(".kicad_pcb")