Ingest hierarchical KiCad sheets; still skip .history.

Apri progetto collects every *.kicad_sch beside the .kicad_pro and
follows Sheetfile/(sheet)/file= into a subfolder. .history, .git, and
backups are not walked. Not only HubAudio.kicad_sch.
This commit is contained in:
2026-09-21 08:28:49 +02:00
parent 027546b0bd
commit 348f390cce
7 changed files with 447 additions and 35 deletions
@@ -1,8 +1,9 @@
"""Load a KiCad project from the folder that contains ``*.kicad_pro``.
A lone ``.kicad_pro`` file is a manifest (JSON). Schematic bytes live in
sibling ``.kicad_sch`` files in that same directory. Hierarchical
``Sheetfile`` children are followed only when they stay inside that folder.
sibling ``.kicad_sch`` files in that same directory, plus hierarchical
``Sheetfile`` children (including a subfolder path). ``.history``, ``.git``,
and backups are never walked.
"""
from __future__ import annotations
@@ -13,13 +14,25 @@ import json
from dataclasses import dataclass, field
from pathlib import Path
_KEEP_SUFFIX = {
".kicad_sch",
".kicad_pcb",
".kicad_pro",
def skip_kicad_walk_dir(name: str) -> bool:
n = name.rstrip("/").lower()
if not n or n.startswith("."):
return True
return n.endswith("-backups") or n.endswith(".pretty") or n in {
"3dmodels",
"__macosx",
"node_modules",
}
def path_has_skipped_dir(path: Path, folder: Path) -> bool:
try:
rel = path.relative_to(folder)
except ValueError:
return True
return any(skip_kicad_walk_dir(part) for part in rel.parts[:-1])
@dataclass
class KicadProject:
folder: Path
@@ -43,7 +56,7 @@ def sniff_kicad_pro(content: bytes, name: str = "") -> bool:
def find_kicad_pro(folder: Path, preferred_stem: str | None = None) -> Path:
hits: list[Path] = []
for path in folder.rglob("*"):
if not path.is_file() or "-backups" in path.parts:
if not path.is_file() or path_has_skipped_dir(path, folder):
continue
try:
depth = len(path.relative_to(folder).parts)
@@ -119,6 +132,15 @@ def collect_hierarchical_sheets(root_sch: Path, folder: Path) -> list[Path]:
ordered.append(path)
for rel in _sheetfiles_of(path):
child = path.parent / rel.replace("\\", "/")
if ".." in Path(rel.replace("\\", "/")).parts:
continue
try:
resolved = child.resolve()
rel_parts = resolved.relative_to(folder).parts
except ValueError:
continue
if any(skip_kicad_walk_dir(part) for part in rel_parts[:-1]):
continue
queue.append(child)
return ordered
@@ -159,13 +181,18 @@ def copy_kicad_siblings(pro: Path, dest: Path) -> KicadProject:
loaded = load_kicad_project(pro.parent)
dest.mkdir(parents=True, exist_ok=True)
mapping: dict[Path, Path] = {}
folder = loaded.folder.resolve()
for src in [loaded.pro, *loaded.sheets]:
out = dest / src.name
rel = src.resolve().relative_to(folder)
out = dest / rel
out.parent.mkdir(parents=True, exist_ok=True)
out.write_bytes(src.read_bytes())
mapping[src.resolve()] = out
pcb = None
if loaded.pcb is not None:
pcb = dest / loaded.pcb.name
pcb_rel = loaded.pcb.resolve().relative_to(folder)
pcb = dest / pcb_rel
pcb.parent.mkdir(parents=True, exist_ok=True)
pcb.write_bytes(loaded.pcb.read_bytes())
root = mapping[loaded.root_sch.resolve()]
sheets = [mapping[p.resolve()] for p in loaded.sheets]
@@ -81,7 +81,11 @@ _KEEP_SUFFIX = {
def _keep_zip_member(rel: str) -> bool:
from backend.periscopex.kicad_project import skip_kicad_walk_dir
parts = Path(rel).parts
if any(skip_kicad_walk_dir(p) for p in parts[:-1]):
return False
if any(
p.endswith("-backups") or p.endswith(".pretty") or p.lower() in {"3dmodels", "__macosx"}
for p in parts
@@ -162,9 +166,11 @@ def _sheetfiles_of(path: Path) -> list[str]:
def _project_folder(work: Path) -> Path | None:
from backend.periscopex.kicad_project import path_has_skipped_dir
pros = [
p for p in work.rglob("*.kicad_pro")
if p.is_file() and "-backups" not in p.parts
if p.is_file() and not path_has_skipped_dir(p, work)
]
if not pros:
return None
@@ -303,4 +309,9 @@ def materialize_netlist_upload(
def work_sch_files(dest: Path) -> list[Path]:
return sorted(p for p in dest.rglob("*.kicad_sch") if p.is_file())
from backend.periscopex.kicad_project import path_has_skipped_dir
return sorted(
p for p in dest.rglob("*.kicad_sch")
if p.is_file() and not path_has_skipped_dir(p, dest)
)
@@ -364,11 +364,23 @@ def _sheetfiles(tree: Any) -> list[str]:
out: list[str] = []
for sheet in _kids(tree, "sheet"):
for p in _kids(sheet, "property"):
if len(p) >= 3 and str(p[1]) == "Sheetfile":
if len(p) >= 3 and str(p[1]) in ("Sheetfile", "Sheet file"):
rel = str(p[2]).strip()
if rel:
out.append(rel)
return out
for p in _kids(sheet, "file"):
if len(p) >= 2:
rel = str(p[1]).strip()
if rel:
out.append(rel)
for item in sheet:
if not isinstance(item, list) or not item:
continue
if str(item[0]) == "file" and len(item) >= 2:
rel = str(item[1]).strip()
if rel:
out.append(rel)
return list(dict.fromkeys(out))
def _parse_kicad_sch_sheet(tree: Any) -> _SchSheet:
@@ -2,6 +2,13 @@
What's new in Periscope.
## 2.60.10 — 2026-09-21 — Hierarchical sheets, skip .history
Apri progetto still skips `.history` / `.git` / backups so the folder pick does not hang. It now keeps **every** linked schematic: all `*.kicad_sch` next to the `.kicad_pro`, plus `Sheetfile` / `(sheet` / `file=` children in a subfolder. Not only `HubAudio.kicad_sch`.
- [Fixed] USB / Codec / POWER (and other hierarchical modules) are ingested with the root sheet.
- [Fixed] Nested sheet paths are followed; `.history` copies are ignored.
## 2.60.9 — 2026-09-21 — Apri progetto does not walk forever
After folder permission, 2.60.8 kept `Next` disabled because `showDirectoryPicker` then recursed `dirHandle.entries()` / `getFile()` through the whole tree (HubAudio `.history` is ~2000 files). We only read the project directory: `.kicad_pro`, `.kicad_sch`, `.kicad_pcb`. Timeout shows an error instead of a spinner forever. Next enables when a `.kicad_pro` is in.
@@ -3,7 +3,14 @@
import { useCallback, useState, type ChangeEvent, type DragEvent, type MouseEvent } from "react";
import { FileCheck, Loader2, Upload } from "lucide-react";
import { cn } from "@/lib/utils";
import { isKicadProjectFileName, skipWalkDirName } from "@/lib/kicad-project-files";
import {
isKicadProjectFileName,
joinSheetRel,
sheetfilesFromSchText,
skipRel,
skipWalkDirName,
uploadRel,
} from "@/lib/kicad-project-files";
export type FileUploadZoneProps = {
label: string;
@@ -16,7 +23,7 @@ export type FileUploadZoneProps = {
onPickState?: (state: { busy: boolean; error: string | null }) => void;
};
const WALK_MS = 8_000;
const WALK_MS = 12_000;
const WALK_TIMEOUT_IT =
"La cartella è troppo grande o il browser non finisce di leggerla. Scegli solo la cartella del .kicad_pro, non il repo intero.";
@@ -73,26 +80,100 @@ function fileFromEntry(entry: FileSystemFileEntry): Promise<File> {
return new Promise((resolve, reject) => entry.file(resolve, reject));
}
/** Project folder only: kicad_pro / sch / pcb. Do not recurse (HubAudio .history is ~2000 files). */
async function flattenEntry(root: FileSystemEntry): Promise<File[]> {
const out: File[] = [];
if (root.isFile) {
if (!isKicadProjectFileName(root.name)) return out;
const file = await fileFromEntry(root as FileSystemFileEntry);
out.push(stampRelativePath(file, file.name));
return out;
function getDirectoryEntry(dir: FileSystemDirectoryEntry, name: string): Promise<FileSystemDirectoryEntry | null> {
return new Promise((resolve) => {
dir.getDirectory(
name,
{},
(entry) => resolve(entry as FileSystemDirectoryEntry),
() => resolve(null),
);
});
}
if (!root.isDirectory) return out;
if (skipWalkDirName(root.name)) return out;
const kids = await readAllEntries((root as FileSystemDirectoryEntry).createReader());
const nestedPrefix = root.name + "/";
function getFileEntry(dir: FileSystemDirectoryEntry, name: string): Promise<FileSystemFileEntry | null> {
return new Promise((resolve) => {
dir.getFile(
name,
{},
(entry) => resolve(entry as FileSystemFileEntry),
() => resolve(null),
);
});
}
async function fileAtDirectoryEntry(
root: FileSystemDirectoryEntry,
relFromRoot: string,
): Promise<File | null> {
const parts = relFromRoot.replaceAll("\\", "/").split("/").filter(Boolean);
if (parts.length === 0) return null;
let dir: FileSystemDirectoryEntry = root;
for (const part of parts.slice(0, -1)) {
if (skipWalkDirName(part)) return null;
const next = await getDirectoryEntry(dir, part);
if (!next) return null;
dir = next;
}
const base = parts[parts.length - 1];
if (!isKicadProjectFileName(base)) return null;
const fileEntry = await getFileEntry(dir, base);
if (!fileEntry) return null;
return fileFromEntry(fileEntry);
}
async function followLinkedSheets(
byRel: Map<string, File>,
fetchRel: (relFromHandle: string) => Promise<File | null>,
handlePrefix: string,
): Promise<void> {
const queue = [...byRel.entries()]
.filter(([, file]) => uploadRel(file).toLowerCase().includes(".kicad_sch"))
.map(([rel]) => rel);
const seen = new Set(queue);
while (queue.length > 0) {
const schRel = queue.shift()!;
const file = byRel.get(schRel);
if (!file) continue;
const text = await file.text();
for (const sheetfile of sheetfilesFromSchText(text)) {
const joined = joinSheetRel(schRel, sheetfile);
if (!joined || seen.has(joined) || skipRel(joined)) continue;
seen.add(joined);
const inner = handlePrefix && joined.startsWith(handlePrefix)
? joined.slice(handlePrefix.length)
: joined;
const child = byRel.get(joined) ?? (await fetchRel(inner));
if (!child) continue;
const stamped = stampRelativePath(child, joined);
byRel.set(joined, stamped);
queue.push(joined);
}
}
}
/** Project dir kicad files + Sheetfile children. Never list .history / .git. */
async function flattenEntry(root: FileSystemEntry): Promise<File[]> {
const byRel = new Map<string, File>();
if (root.isFile) {
if (!isKicadProjectFileName(root.name)) return [];
const file = await fileFromEntry(root as FileSystemFileEntry);
byRel.set(root.name, stampRelativePath(file, file.name));
return [...byRel.values()];
}
if (!root.isDirectory) return [];
if (skipWalkDirName(root.name)) return [];
const dir = root as FileSystemDirectoryEntry;
const prefix = `${root.name}/`;
const kids = await readAllEntries(dir.createReader());
for (const child of kids) {
if (child.isDirectory) continue;
if (!isKicadProjectFileName(child.name)) continue;
const file = await fileFromEntry(child as FileSystemFileEntry);
out.push(stampRelativePath(file, nestedPrefix + child.name));
byRel.set(prefix + child.name, stampRelativePath(file, prefix + child.name));
}
return out;
await followLinkedSheets(byRel, (inner) => fileAtDirectoryEntry(dir, inner), prefix);
return [...byRel.values()];
}
async function filesFromDropEvent(event: DragEvent): Promise<File[]> {
@@ -122,18 +203,43 @@ function mergeSelection(current: File[], incoming: File[], multiple: boolean, di
type DirHandle = {
name: string;
entries: () => AsyncIterable<[string, { kind: string; getFile?: () => Promise<File> } & DirHandle]>;
getFileHandle?: (name: string) => Promise<{ getFile: () => Promise<File> }>;
getDirectoryHandle?: (name: string) => Promise<DirHandle>;
};
async function fileAtDirHandle(root: DirHandle, relFromRoot: string): Promise<File | null> {
const parts = relFromRoot.replaceAll("\\", "/").split("/").filter(Boolean);
if (parts.length === 0) return null;
let dir: DirHandle = root;
for (const part of parts.slice(0, -1)) {
if (skipWalkDirName(part) || !dir.getDirectoryHandle) return null;
try {
dir = await dir.getDirectoryHandle(part);
} catch {
return null;
}
}
const base = parts[parts.length - 1];
if (!isKicadProjectFileName(base) || !dir.getFileHandle) return null;
try {
const fh = await dir.getFileHandle(base);
return fh.getFile();
} catch {
return null;
}
}
async function filesFromDirectoryHandle(handle: DirHandle, prefix = ""): Promise<File[]> {
const out: File[] = [];
const byRel = new Map<string, File>();
for await (const [name, child] of handle.entries()) {
if (child.kind === "directory") continue;
if (!isKicadProjectFileName(name)) continue;
if (!child.getFile) continue;
const file = await child.getFile();
out.push(stampRelativePath(file, prefix + name));
byRel.set(prefix + name, stampRelativePath(file, prefix + name));
}
return out;
await followLinkedSheets(byRel, (inner) => fileAtDirHandle(handle, inner), prefix);
return [...byRel.values()];
}
function pickFolderViaHiddenInput(): Promise<File[]> {
@@ -146,7 +252,11 @@ function pickFolderViaHiddenInput(): Promise<File[]> {
input.style.display = "none";
const finish = (files: File[]) => {
input.remove();
resolve(files.filter((f) => isKicadProjectFileName(relativePath(f))));
resolve(
files.filter(
(f) => isKicadProjectFileName(relativePath(f)) && !skipRel(relativePath(f)),
),
);
};
input.addEventListener("change", () => finish(Array.from(input.files ?? [])));
input.addEventListener("cancel", () => finish([]));
@@ -56,6 +56,35 @@ export function skipRel(rel: string): boolean {
return low.includes("-backups/") || low.includes("/__macosx/") || low.includes(".pretty/");
}
/** Linked hierarchical sheets: Sheetfile, (file "…"), or file="…". */
export function sheetfilesFromSchText(text: string): string[] {
const found: string[] = [];
const patterns = [
/\(\s*property\s+"Sheetfile"\s+"([^"]+)"/gi,
/\(\s*sheetfile\s+"([^"]+)"/gi,
/\(\s*file\s+"([^"]+\.kicad_sch)"/gi,
/\bfile\s*=\s*"([^"]+\.kicad_sch)"/gi,
];
for (const re of patterns) {
re.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
const rel = (m[1] || "").replaceAll("\\", "/").trim();
if (rel) found.push(rel);
}
}
return [...new Set(found)];
}
export function joinSheetRel(schRel: string, sheetfile: string): string | null {
const file = sheetfile.replaceAll("\\", "/").trim();
if (!file || file.startsWith("/") || file.split("/").includes("..")) return null;
const parent = parentDir(schRel.replaceAll("\\", "/"));
const joined = parent ? `${parent}/${file}` : file;
if (skipRel(joined)) return null;
return joined.replace(/\/+/g, "/");
}
/** Step 1 Next: project name + a .kicad_pro already in the pick (not waiting on the whole tree). */
export function detailsStepReady(projectName: string, files: NamedUpload[]): boolean {
return Boolean(projectName.trim() && files.some((f) => looksLikeKicadPro(f)));
@@ -74,6 +103,12 @@ function sameFolder(parent: string, proDir: string): boolean {
return false;
}
function underProjectDir(parent: string, proDir: string): boolean {
if (sameFolder(parent, proDir)) return true;
if (!proDir) return parent.split("/").filter(Boolean).every((p) => !skipWalkDirName(p));
return parent === proDir || parent.startsWith(`${proDir}/`);
}
function isKicadKeep(file: NamedUpload): boolean {
const base = uploadBase(file).toLowerCase();
return (
@@ -116,7 +151,7 @@ export function filesBesideKicadPro<T extends NamedUpload>(
return incoming.filter((f) => {
const rel = uploadRel(f);
if (skipRel(rel)) return false;
if (!sameFolder(parentDir(rel), dir)) return false;
if (!underProjectDir(parentDir(rel), dir)) return false;
return isKicadKeep(f);
});
}
+210
View File
@@ -406,3 +406,213 @@ def test_real_hubaudio_recursive_tree_is_huge_project_dir_is_small():
assert any(p.name.endswith(".kicad_pcb") for p in project_dir)
assert all(".history" not in str(p) for p in project_dir)
assert len(project_dir) < 40
sch_names = {p.name for p in project_dir if p.suffix.lower() == ".kicad_sch"}
assert "HubAudio.kicad_sch" in sch_names
assert "USB.kicad_sch" in sch_names
assert "Codec.kicad_sch" in sch_names
assert "POWER.kicad_sch" in sch_names
assert len(sch_names) > 1
def _sheet_block(name: str, file: str) -> str:
return f"""
(sheet
(at 50 0)
(size 20 20)
(property "Sheetname" "{name}" (at 50 0 0) (effects (font (size 1.27 1.27))))
(property "Sheetfile" "{file}" (at 50 0 0) (effects (font (size 1.27 1.27))))
)
"""
def _write_hier_modules(folder: Path) -> None:
"""Root + USB/Codec/POWER siblings + sheets/nested.kicad_sch; junk in .history."""
folder.mkdir(parents=True, exist_ok=True)
nested_dir = folder / "sheets"
nested_dir.mkdir()
history = folder / ".history"
history.mkdir()
(history / "HubAudio.kicad_sch").write_text(_sch(_ic("U99", "FAKE", "FAKE")))
(history / "evil.kicad_pro").write_text(_pro("HubAudio.kicad_sch"))
usb = _sch(
_ic("U2", "CH340E", "CH340E"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "dddddddd-dddd-dddd-dddd-dddddddddddd"))
""",
)
codec = _sch(
_ic("U9", "TPD2E007DCKR", "TPD2E007DCKR"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "cccccccccccccccccccccccccccccccccccc"))
""",
)
power = _sch(
_ic("U1", "SPX3819M5-L-3-3", "SPX3819M5-L-3-3"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"))
""",
)
nested = _sch(
_ic("U4", "PCA9534ARGTR", "PCA9534ARGTR"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "ffffffffffffffffffffffffffffffffffffffff"))
""",
)
root = _sch(
_resistor("R1", "10k"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
"""
+ _sheet_block("USB", "USB.kicad_sch")
+ _sheet_block("Codec", "Codec.kicad_sch")
+ _sheet_block("POWER", "POWER.kicad_sch")
+ _sheet_block("Nested", "sheets/nested.kicad_sch"),
)
(folder / "HubAudio.kicad_pro").write_text(_pro("HubAudio.kicad_sch"))
(folder / "HubAudio.kicad_sch").write_text(root)
(folder / "USB.kicad_sch").write_text(usb)
(folder / "Codec.kicad_sch").write_text(codec)
(folder / "POWER.kicad_sch").write_text(power)
(nested_dir / "nested.kicad_sch").write_text(nested)
(folder / "HubAudio.kicad_pcb").write_text(
'(kicad_pcb (version 20240108) (generator pcbnew)\n (net 0 "")\n)\n'
)
def _sheetfiles_from_text(text: str) -> list[str]:
found: list[str] = []
for pat in (
r'\(\s*property\s+"Sheetfile"\s+"([^"]+)"',
r'\(\s*sheetfile\s+"([^"]+)"',
r'\(\s*file\s+"([^"]+\.kicad_sch)"',
r'file\s*=\s*"([^"]+\.kicad_sch)"',
):
found.extend(re.findall(pat, text, flags=re.I))
return list(dict.fromkeys(found))
def _join_sheet_rel(sch_rel: str, sheetfile: str) -> str | None:
file = sheetfile.replace("\\", "/").strip()
if not file or file.startswith("/") or ".." in file.split("/"):
return None
parent = sch_rel.replace("\\", "/").rsplit("/", 1)[0] if "/" in sch_rel.replace("\\", "/") else ""
joined = f"{parent}/{file}" if parent else file
if _skip_rel(joined):
return None
return joined
def _files_beside(incoming: list[dict[str, str]]) -> list[dict[str, str]]:
"""Same keep rule as frontend filesBesideKicadPro (all sch under project dir)."""
hits = [
f
for f in incoming
if _looks_like_kicad_pro(f["name"], f.get("webkitRelativePath", ""))
and not _skip_rel(f.get("webkitRelativePath") or f["name"])
]
if not hits:
return []
pro_rel = (hits[0].get("webkitRelativePath") or hits[0]["name"]).replace("\\", "/")
pro_dir = pro_rel.rsplit("/", 1)[0] if "/" in pro_rel else ""
kept: list[dict[str, str]] = []
for f in incoming:
rel = (f.get("webkitRelativePath") or f["name"]).replace("\\", "/")
if _skip_rel(rel):
continue
parent = rel.rsplit("/", 1)[0] if "/" in rel else ""
under = parent == pro_dir or (
bool(pro_dir) and (parent == pro_dir or parent.startswith(pro_dir + "/"))
) or (not parent and not pro_dir)
if not under:
continue
base = rel.rsplit("/", 1)[-1].lower()
if not (
".kicad_pro" in rel.lower()
or base.endswith((".kicad_sch", ".kicad_pcb"))
):
continue
kept.append(f)
return kept
def test_sheetfiles_from_root_text_usb_codec_power():
text = (
_sheet_block("USB", "USB.kicad_sch")
+ _sheet_block("Codec", "Codec.kicad_sch")
+ _sheet_block("POWER", "POWER.kicad_sch")
+ '(file "sheets/nested.kicad_sch")'
+ 'file="also.kicad_sch"'
)
names = _sheetfiles_from_text(text)
assert "USB.kicad_sch" in names
assert "Codec.kicad_sch" in names
assert "POWER.kicad_sch" in names
assert "sheets/nested.kicad_sch" in names
assert "also.kicad_sch" in names
assert _join_sheet_rel("HubAudio/HubAudio.kicad_sch", "USB.kicad_sch") == "HubAudio/USB.kicad_sch"
assert _join_sheet_rel("HubAudio/HubAudio.kicad_sch", "sheets/nested.kicad_sch") == "HubAudio/sheets/nested.kicad_sch"
assert _join_sheet_rel("HubAudio/HubAudio.kicad_sch", ".history/x.kicad_sch") is None
assert _join_sheet_rel("HubAudio/HubAudio.kicad_sch", "../escape.kicad_sch") is None
def test_keep_every_sibling_sch_not_only_root():
incoming = [
{"name": "HubAudio.kicad_pro", "webkitRelativePath": "HubAudio/HubAudio.kicad_pro"},
{"name": "HubAudio.kicad_sch", "webkitRelativePath": "HubAudio/HubAudio.kicad_sch"},
{"name": "USB.kicad_sch", "webkitRelativePath": "HubAudio/USB.kicad_sch"},
{"name": "Codec.kicad_sch", "webkitRelativePath": "HubAudio/Codec.kicad_sch"},
{"name": "POWER.kicad_sch", "webkitRelativePath": "HubAudio/POWER.kicad_sch"},
{"name": "nested.kicad_sch", "webkitRelativePath": "HubAudio/sheets/nested.kicad_sch"},
{"name": "HubAudio.kicad_pcb", "webkitRelativePath": "HubAudio/HubAudio.kicad_pcb"},
{"name": "HubAudio.kicad_sch", "webkitRelativePath": "HubAudio/.history/HubAudio.kicad_sch"},
]
kept = _files_beside(incoming)
names = { (f.get("webkitRelativePath") or f["name"]).rsplit("/", 1)[-1] for f in kept }
rels = { f.get("webkitRelativePath") or f["name"] for f in kept }
assert "HubAudio.kicad_sch" in names
assert "USB.kicad_sch" in names
assert "Codec.kicad_sch" in names
assert "POWER.kicad_sch" in names
assert "nested.kicad_sch" in names
assert names != {"HubAudio.kicad_sch"}
assert "HubAudio/.history/HubAudio.kicad_sch" not in rels
def test_hier_modules_and_nested_sheet_history_ignored(tmp_path: Path):
src = tmp_path / "HubAudio"
_write_hier_modules(src)
loaded = load_kicad_project(src)
names = {p.name for p in loaded.sheets}
assert names == {
"HubAudio.kicad_sch",
"USB.kicad_sch",
"Codec.kicad_sch",
"POWER.kicad_sch",
"nested.kicad_sch",
}
assert all(".history" not in p.parts for p in loaded.sheets)
assert loaded.pro.name == "HubAudio.kicad_pro"
parts, _nets, fmt = parse_netlist_any(loaded.root_sch)
assert fmt == "kicad_sch"
assert "U2" in parts and "U9" in parts and "U1" in parts and "U4" in parts
assert "U99" not in parts
files = [
(str(p.relative_to(tmp_path)), p.read_bytes())
for p in src.rglob("*")
if p.is_file() and p.suffix.lower() in {".kicad_pro", ".kicad_sch", ".kicad_pcb"}
and ".history" not in p.parts
]
parsed = materialize_netlist_upload(files, tmp_path / "work")
extra = {p.name for p in parsed.extra_sch}
assert extra == {"USB.kicad_sch", "Codec.kicad_sch", "POWER.kicad_sch", "nested.kicad_sch"}
copied = {p.name for p in (tmp_path / "work").rglob("*.kicad_sch")}
assert "nested.kicad_sch" in copied
assert not list((tmp_path / "work").rglob(".history/**/*"))
def test_history_kicad_pro_is_not_the_project(tmp_path: Path):
src = tmp_path / "HubAudio"
_write_hier_modules(src)
loaded = load_kicad_project(src)
assert ".history" not in loaded.pro.parts
assert loaded.pro.parent == src.resolve()