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:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user