diff --git a/.github/workflows/ci-build.yaml b/.github/workflows/ci-build.yaml index 156985300..30b1cb07e 100644 --- a/.github/workflows/ci-build.yaml +++ b/.github/workflows/ci-build.yaml @@ -176,6 +176,48 @@ jobs: - name: Dynawo and powsybl tests run: pytest tests/dynamic tests/powsybl -v + windows-pytests: + runs-on: windows-latest + timeout-minutes: 60 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v7 + + - name: Install Julia 1.12 + uses: julia-actions/setup-julia@v3 + with: + version: '1.12' + + - name: Cache Julia depot + uses: actions/cache@v6 + with: + path: ~/.julia + key: julia-depot-${{ runner.os }}-julia1.12-${{ hashFiles('gridfm_datakit/juliapkg.json') }} + restore-keys: | + julia-depot-${{ runner.os }}-julia1.12- + + - name: Set up Python 3.12 + uses: actions/setup-python@v7 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: pyproject.toml + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip wheel + pip install -e ".[test]" + + - name: Set up Julia packages (PowerModels) + run: python -c "from juliacall import Main as jl; jl.seval('using PowerModels, Ipopt, Memento')" + + - name: Unit tests + run: | + export SKIP_LARGE_GRIDS=1 + pytest tests/ -v --durations=25 + codeql: name: CodeQL (Python) runs-on: ubuntu-latest diff --git a/gridfm_datakit/network.py b/gridfm_datakit/network.py index 6efca19e5..991376cc3 100644 --- a/gridfm_datakit/network.py +++ b/gridfm_datakit/network.py @@ -59,6 +59,16 @@ from gridfm_datakit.utils.idx_gen import GEN_BUS, GEN_STATUS, PG, QG +def _julia_string(value: str) -> str: + """Return ``value`` as a Julia string literal. + + Backslashes, quotes and ``$`` are escaped, so Windows paths and paths + containing interpolation characters reach Julia unchanged. + """ + escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$") + return f'"{escaped}"' + + def correct_network(network_path: str, force: bool = False) -> str: """ Load a MATPOWER network using PowerModels via run_julia @@ -101,8 +111,8 @@ def correct_network(network_path: str, force: bool = False) -> str: julia_code = [ "using PowerModels", "PowerModels.silence()", - f'data = PowerModels.parse_file("{network_path}")', - f'PowerModels.export_matpower("{tmp_path}", data)', + f"data = PowerModels.parse_file({_julia_string(network_path)})", + f"PowerModels.export_matpower({_julia_string(tmp_path)}, data)", ] # Run Julia diff --git a/gridfm_datakit/process/solver_output.py b/gridfm_datakit/process/solver_output.py index b18b55e65..bf8c74c2c 100644 --- a/gridfm_datakit/process/solver_output.py +++ b/gridfm_datakit/process/solver_output.py @@ -40,7 +40,9 @@ from typing import Dict, Iterator, Optional try: - _LIBC: Optional[ctypes.CDLL] = ctypes.CDLL(None) + _LIBC: Optional[ctypes.CDLL] = ctypes.CDLL( + "ucrtbase" if sys.platform == "win32" else None, + ) except Exception: # pragma: no cover - platform without a loadable libc _LIBC = None diff --git a/tests/test_correct_network.py b/tests/test_correct_network.py new file mode 100644 index 000000000..15ca68492 --- /dev/null +++ b/tests/test_correct_network.py @@ -0,0 +1,41 @@ +""" +Test cases for passing network file paths to Julia in correct_network. +""" + +from pathlib import Path + +import pytest + +from gridfm_datakit.network import ( + _julia_string, + correct_network, + get_pglib_source_path, +) + + +@pytest.mark.parametrize( + "value", + [ + "/plain/case14.m", + "C:\\Users\\runner\\AppData\\Local\\case14.m", + 'dir "quoted"/case14.m', + "dir/$HOME/case14.m", + ], +) +def test_julia_string_round_trips(value): + from juliacall import Main as jl + + assert jl.seval(_julia_string(value)) == value + + +def test_correct_network_handles_special_characters_in_path(tmp_path): + directory = tmp_path / "grid $HOME dir" + directory.mkdir() + source = directory / "case14_ieee.m" + with open(get_pglib_source_path("case14_ieee"), encoding="utf-8") as f: + source.write_text(f.read(), encoding="utf-8") + + corrected = correct_network(str(source)) + + assert corrected == str(directory / "case14_ieee_corrected.m") + assert Path(corrected).stat().st_size > 0 diff --git a/tests/test_solver_output.py b/tests/test_solver_output.py index b338a1603..e97d267a2 100644 --- a/tests/test_solver_output.py +++ b/tests/test_solver_output.py @@ -5,6 +5,7 @@ import ctypes import os +import sys import pytest @@ -88,7 +89,7 @@ class TestRedirectCStdio: # capturing fd-level output that bypasses Python's sys.stdout. def test_redirects_c_level_writes_to_file(self, tmp_path, capfd): - libc = ctypes.CDLL(None) + libc = ctypes.CDLL("ucrtbase" if sys.platform == "win32" else None) log = tmp_path / "out.log" with redirect_fds(str(log)):