Fix SameFileError in batch sweeps; guard self-copy in runner

run_parameter_sweep/run_monte_carlo write each variant .cir into a work_dir,
then call run_netlist with that same work_dir -- which tried to shutil.copy2
the file onto itself (SameFileError), so every batch run failed before
simulating. Add _safe_copy() that skips when src and dst resolve to the same
path, used at all four staging-copy sites in run_simulation/run_netlist.

Caught by headless claude-cli testing of the batch tools (no batch test
existed). Adds test_runner.py (_safe_copy unit tests) and a TestBatchSweeps
integration class.
This commit is contained in:
Ryan Malloy 2026-06-21 22:24:24 -06:00
parent 32a7a2a0dc
commit 9c252606f7
3 changed files with 74 additions and 4 deletions

View File

@ -16,6 +16,18 @@ from .config import (
from .raw_parser import RawFile, parse_raw_file
def _safe_copy(src: Path, dst: Path) -> None:
"""Copy src to dst, skipping when they are the same file.
Batch runs write each variant netlist directly into the work_dir and then
pass that same work_dir to run_simulation/run_netlist, so the staging copy
would otherwise be a file-onto-itself (shutil raises SameFileError).
"""
if Path(src).resolve() == Path(dst).resolve():
return
shutil.copy2(src, dst)
@dataclass
class SimulationResult:
"""Result of a simulation run."""
@ -90,13 +102,13 @@ async def run_simulation(
try:
# Copy schematic to work directory
work_schematic = work_dir / schematic_path.name
shutil.copy2(schematic_path, work_schematic)
_safe_copy(schematic_path, work_schematic)
# Copy any .lib, .sub files from the same directory
src_dir = schematic_path.parent
for ext in [".lib", ".sub", ".inc", ".model"]:
for f in src_dir.glob(f"*{ext}"):
shutil.copy2(f, work_dir / f.name)
_safe_copy(f, work_dir / f.name)
# Convert path to Windows format for Wine
# Wine maps Z: to root filesystem
@ -267,13 +279,13 @@ async def run_netlist(
try:
# Copy netlist to work directory
work_netlist = work_dir / netlist_path.name
shutil.copy2(netlist_path, work_netlist)
_safe_copy(netlist_path, work_netlist)
# Copy included files
src_dir = netlist_path.parent
for ext in [".lib", ".sub", ".inc", ".model"]:
for f in src_dir.glob(f"*{ext}"):
shutil.copy2(f, work_dir / f.name)
_safe_copy(f, work_dir / f.name)
win_path = "Z:" + str(work_netlist).replace("/", "\\")

View File

@ -207,3 +207,34 @@ class TestColpittsOscillator:
# With L=1u, C1=C2=100p: Cseries = 50p
# f = 1/(2*pi*sqrt(1e-6 * 50e-12)) ~ 22.5 MHz
# This is quite high, but we just verify oscillation exists
@pytest.mark.integration
class TestBatchSweeps:
"""End-to-end batch runs (regression for the SameFileError staging bug)."""
_RC = (
"* RC lowpass sweep\n"
".param Rval=1k\n"
"V1 in 0 AC 1\n"
"R1 in out {Rval}\n"
"C1 out 0 159n\n"
".ac dec 10 1 1meg\n"
".end\n"
)
async def test_parameter_sweep_runs_all(self, ltspice_available):
from mcltspice.batch import run_parameter_sweep
result = await run_parameter_sweep(
self._RC, "Rval", [500.0, 1000.0, 2000.0], timeout=90
)
assert result.failure_count == 0, f"sweep failures: {result.failure_count}"
assert result.success_count == 3
async def test_monte_carlo_runs_all(self, ltspice_available):
from mcltspice.batch import run_monte_carlo
result = await run_monte_carlo(self._RC, 4, {"R1": 0.05}, timeout=90, seed=1)
assert result.failure_count == 0, f"mc failures: {result.failure_count}"
assert result.success_count == 4

27
tests/test_runner.py Normal file
View File

@ -0,0 +1,27 @@
"""Unit tests for runner helpers (no LTspice needed)."""
from mcltspice.runner import _safe_copy
class TestSafeCopy:
def test_skips_same_file(self, tmp_path):
# Copying a file onto itself must not raise (was SameFileError in batch runs).
f = tmp_path / "a.cir"
f.write_text("hello")
_safe_copy(f, f)
assert f.read_text() == "hello"
def test_skips_same_file_via_unnormalized_path(self, tmp_path):
# Same file referenced through a "." segment -> resolve() equal -> skip.
f = tmp_path / "a.cir"
f.write_text("x")
alias = tmp_path / "." / "a.cir"
_safe_copy(f, alias) # must not raise
assert f.read_text() == "x"
def test_copies_distinct_files(self, tmp_path):
src = tmp_path / "a.cir"
src.write_text("data")
dst = tmp_path / "b.cir"
_safe_copy(src, dst)
assert dst.read_text() == "data"