From 9c252606f7d50412f35a629206b645dc6d916ce1 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sun, 21 Jun 2026 22:24:24 -0600 Subject: [PATCH] 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. --- src/mcltspice/runner.py | 20 ++++++++++++++++---- tests/test_integration.py | 31 +++++++++++++++++++++++++++++++ tests/test_runner.py | 27 +++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 tests/test_runner.py diff --git a/src/mcltspice/runner.py b/src/mcltspice/runner.py index f0a1071..07791dc 100644 --- a/src/mcltspice/runner.py +++ b/src/mcltspice/runner.py @@ -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("/", "\\") diff --git a/tests/test_integration.py b/tests/test_integration.py index c38ede8..c3d389e 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -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 diff --git a/tests/test_runner.py b/tests/test_runner.py new file mode 100644 index 0000000..0329d0a --- /dev/null +++ b/tests/test_runner.py @@ -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"