From 608efc368f3f731bed5fcbb36ad2bbe8fa0973b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:04:38 +0000 Subject: [PATCH 01/15] Initial plan From 2089526ebc43e10476a98b301e75b7b0defc25b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:07:43 +0000 Subject: [PATCH 02/15] Add solver version and minimum-version checks Co-authored-by: davide-f <67809479+davide-f@users.noreply.github.com> --- pysmspp/smspp_tools.py | 118 +++++++++++++++++++++++++++++++++++++++ test/test_smspp_tools.py | 33 +++++++++++ 2 files changed, 151 insertions(+) diff --git a/pysmspp/smspp_tools.py b/pysmspp/smspp_tools.py index 8bf7224..0b39257 100644 --- a/pysmspp/smspp_tools.py +++ b/pysmspp/smspp_tools.py @@ -5,6 +5,7 @@ import subprocess import threading import time +from itertools import zip_longest from pathlib import Path import numpy as np @@ -40,6 +41,34 @@ def _status_code_of(status): return None if res is None else int(res.group()) +def _parse_tool_version(version): + """ + The semantic version found in a tool version string, e.g. "0.7.1" in + "SMS++ tools version 0.7.1", and None where none can be found. + """ + if version is None: + return None + + res = re.search(r"\d+(?:\.\d+){1,}", str(version)) + return None if res is None else res.group() + + +def _version_tuple_of(version): + parsed = _parse_tool_version(version) + if parsed is None: + return None + return tuple(int(component) for component in parsed.split(".")) + + +def _is_older_version(current, minimum): + for cur, req in zip_longest(current, minimum, fillvalue=0): + if cur < req: + return True + if cur > req: + return False + return False + + def _enqueue_pipe_lines(pipe, stream_name, messages): try: for line in iter(pipe.readline, ""): @@ -83,6 +112,9 @@ def __init__( fp_solution: Path | str | None = None, configsolution: Path | str | None = None, help_option: str = "-h", + version_option: str = "--version", + fallback_version_option: str | None = "-V", + minimum_version: str | None = None, shell: bool = False, **kwargs, ): @@ -110,6 +142,14 @@ def __init__( When provided, option "-C" is added to the executable call to specify the configuration solution file. help_option : str, optional The option to display the help message, by default "-h". + version_option : str, optional + The option to display the version message, by default "--version". + fallback_version_option : str | None, optional + Alternative version option when version_option is not supported, + by default "-V". + minimum_version : str | None, optional + Optional minimum compatible version (for example "0.7.1"), by + default None. shell : bool, optional Whether to execute the command through the shell. Defaults to False. **kwargs @@ -122,6 +162,9 @@ def __init__( else: self._solver_path = str(solver_path) self._help_option = help_option + self._version_option = version_option + self._fallback_version_option = fallback_version_option + self._minimum_version = minimum_version self.fp_network = ( None if fp_network is None else str(Path(fp_network).resolve()) @@ -233,6 +276,80 @@ def help(self, print_message=True): print(msg) return msg + def version(self, print_message=True): + """ + Print and return the semantic version reported by the SMS++ solver. + + Parameters + ---------- + print_message : bool, optional + Whether to print the full version output, by default True. + + Returns + ------- + str + The semantic version (e.g. "0.7.1") parsed from the tool output. + """ + options = [self._version_option] + if self._fallback_version_option is not None: + options.append(self._fallback_version_option) + + msg = None + succeeded = False + for option in options: + result = subprocess.run( + [self._solver_path, option], + capture_output=True, + shell=self._shell, + check=False, + text=True, + ) + msg = result.stdout + os.linesep + result.stderr + if result.returncode == 0: + succeeded = True + break + + if not succeeded: + raise ValueError( + f"Failed to get version from {self._solver_path} using options {options}:" + f"\n{msg}" + ) + + parsed = _parse_tool_version(msg) + if parsed is None: + raise ValueError( + f"Could not parse version from {self._solver_path} output:\n{msg}" + ) + + if print_message: + print(msg) + return parsed + + def ensure_minimum_version(self): + """ + Raise when the solver version is older than the required minimum one. + """ + if self._minimum_version is None: + return + + minimum = _version_tuple_of(self._minimum_version) + if minimum is None: + raise ValueError( + f"Invalid minimum_version '{self._minimum_version}' for {self._solver_path}" + ) + + current_text = self.version(print_message=False) + current = _version_tuple_of(current_text) + if current is None: + raise ValueError( + f"Could not parse version for {self._solver_path}: {current_text}" + ) + + if _is_older_version(current, minimum): + raise ValueError( + f"{self._solver_path} version {current_text} is older than required minimum version {self._minimum_version}" + ) + def optimize(self, logging=True, tracking_period=0.1): """ Run the SMSPP Solver tool. @@ -252,6 +369,7 @@ def optimize(self, logging=True, tracking_period=0.1): ) if not Path(self.fp_network).exists(): raise FileNotFoundError(f"Network file {self.fp_network} does not exist.") + self.ensure_minimum_version() command_raw = self.calculate_executable_call() command_str = " ".join(command_raw) diff --git a/test/test_smspp_tools.py b/test/test_smspp_tools.py index 3d009e4..e45a215 100644 --- a/test/test_smspp_tools.py +++ b/test/test_smspp_tools.py @@ -20,6 +20,22 @@ def calculate_executable_call(self): return [sys.executable, "-c", code] +def test_version_reads_solver_version(): + solver = UCBlockSolver(solver_path=sys.executable) + version = solver.version(print_message=False) + assert version.count(".") >= 1 + + +def test_version_uses_fallback_option(): + solver = UCBlockSolver( + solver_path=sys.executable, + version_option="--not-a-valid-python-version-option", + fallback_version_option="-V", + ) + version = solver.version(print_message=False) + assert version.count(".") >= 1 + + def test_optimize_reads_subprocess_output_portably(tmp_path): fp_network = tmp_path / "network.nc4" fp_config = tmp_path / "config.txt" @@ -45,6 +61,23 @@ def test_optimize_reads_subprocess_output_portably(tmp_path): assert fp_log.read_text() == result.log +def test_optimize_checks_minimum_solver_version(tmp_path): + fp_network = tmp_path / "network.nc4" + fp_config = tmp_path / "config.txt" + fp_network.write_text("fake network") + fp_config.write_text("fake config") + + solver = FakeSolver( + solver_path=sys.executable, + fp_network=fp_network, + configfile=fp_config, + minimum_version="99.0.0", + ) + + with pytest.raises(ValueError, match="older than required minimum version"): + solver.optimize(logging=False, tracking_period=0.01) + + def test_status_code_of_a_log(): """The status of a run is the one SMS++ printed, not a finite value.""" solver = UCBlockSolver() From 3f5eecb22244ea2b5b870ae203eb1aa9302d1206 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:08:27 +0000 Subject: [PATCH 03/15] Fix version lookup for shell mode Co-authored-by: davide-f <67809479+davide-f@users.noreply.github.com> --- pysmspp/smspp_tools.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/pysmspp/smspp_tools.py b/pysmspp/smspp_tools.py index 0b39257..e0fd3a2 100644 --- a/pysmspp/smspp_tools.py +++ b/pysmspp/smspp_tools.py @@ -278,12 +278,13 @@ def help(self, print_message=True): def version(self, print_message=True): """ - Print and return the semantic version reported by the SMS++ solver. + Return the semantic version reported by the SMS++ solver. Parameters ---------- print_message : bool, optional - Whether to print the full version output, by default True. + Whether to print the raw version output from the solver, by default + True. Returns ------- @@ -297,8 +298,13 @@ def version(self, print_message=True): msg = None succeeded = False for option in options: + command = ( + f"{self._solver_path} {option}" + if self._shell + else [self._solver_path, option] + ) result = subprocess.run( - [self._solver_path, option], + command, capture_output=True, shell=self._shell, check=False, From b3267d335698426c9c334644eaf13edaffa60df2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:09:16 +0000 Subject: [PATCH 04/15] Cache version compatibility checks Co-authored-by: davide-f <67809479+davide-f@users.noreply.github.com> --- pysmspp/smspp_tools.py | 20 ++++++++++++++++++++ test/test_smspp_tools.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/pysmspp/smspp_tools.py b/pysmspp/smspp_tools.py index e0fd3a2..c2593f2 100644 --- a/pysmspp/smspp_tools.py +++ b/pysmspp/smspp_tools.py @@ -192,6 +192,9 @@ def __init__( self._subprocess_time = None self._solution_time = None self._computational_time = None + self._version_output = None + self._parsed_version = None + self._minimum_version_checked = False self._kwargs = kwargs if "c" in self._kwargs: @@ -290,7 +293,18 @@ def version(self, print_message=True): ------- str The semantic version (e.g. "0.7.1") parsed from the tool output. + + Raises + ------ + ValueError + If version retrieval fails for all configured version options, or + if the tool output does not contain a parseable semantic version. """ + if self._parsed_version is not None: + if print_message and self._version_output is not None: + print(self._version_output) + return self._parsed_version + options = [self._version_option] if self._fallback_version_option is not None: options.append(self._fallback_version_option) @@ -327,6 +341,9 @@ def version(self, print_message=True): f"Could not parse version from {self._solver_path} output:\n{msg}" ) + self._version_output = msg + self._parsed_version = parsed + if print_message: print(msg) return parsed @@ -337,6 +354,8 @@ def ensure_minimum_version(self): """ if self._minimum_version is None: return + if self._minimum_version_checked: + return minimum = _version_tuple_of(self._minimum_version) if minimum is None: @@ -355,6 +374,7 @@ def ensure_minimum_version(self): raise ValueError( f"{self._solver_path} version {current_text} is older than required minimum version {self._minimum_version}" ) + self._minimum_version_checked = True def optimize(self, logging=True, tracking_period=0.1): """ diff --git a/test/test_smspp_tools.py b/test/test_smspp_tools.py index e45a215..0be044b 100644 --- a/test/test_smspp_tools.py +++ b/test/test_smspp_tools.py @@ -78,6 +78,34 @@ def test_optimize_checks_minimum_solver_version(tmp_path): solver.optimize(logging=False, tracking_period=0.01) +def test_minimum_version_check_is_cached(tmp_path, monkeypatch): + fp_network = tmp_path / "network.nc4" + fp_config = tmp_path / "config.txt" + fp_network.write_text("fake network") + fp_config.write_text("fake config") + + solver = FakeSolver( + solver_path=sys.executable, + fp_network=fp_network, + configfile=fp_config, + minimum_version="0.0.1", + ) + + calls = {"count": 0} + version = solver.version + + def counting_version(*args, **kwargs): + calls["count"] += 1 + return version(*args, **kwargs) + + monkeypatch.setattr(solver, "version", counting_version) + + solver.ensure_minimum_version() + solver.ensure_minimum_version() + + assert calls["count"] == 1 + + def test_status_code_of_a_log(): """The status of a run is the one SMS++ printed, not a finite value.""" solver = UCBlockSolver() From ecba75b2c17afbbcbaecff4619b055f34c465cfc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:09:53 +0000 Subject: [PATCH 05/15] Avoid duplicate version-option probes Co-authored-by: davide-f <67809479+davide-f@users.noreply.github.com> --- pysmspp/smspp_tools.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pysmspp/smspp_tools.py b/pysmspp/smspp_tools.py index c2593f2..ea52986 100644 --- a/pysmspp/smspp_tools.py +++ b/pysmspp/smspp_tools.py @@ -306,7 +306,10 @@ def version(self, print_message=True): return self._parsed_version options = [self._version_option] - if self._fallback_version_option is not None: + if ( + self._fallback_version_option is not None + and self._fallback_version_option not in options + ): options.append(self._fallback_version_option) msg = None From 1b0c57a4ec282d88b96d42e9522bc56dddb8a35f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:10:32 +0000 Subject: [PATCH 06/15] Test version gate before subprocess launch Co-authored-by: davide-f <67809479+davide-f@users.noreply.github.com> --- test/test_smspp_tools.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/test_smspp_tools.py b/test/test_smspp_tools.py index 0be044b..aadfb80 100644 --- a/test/test_smspp_tools.py +++ b/test/test_smspp_tools.py @@ -3,6 +3,7 @@ import numpy as np import pytest +import pysmspp.smspp_tools as smspp_tools_module from pysmspp import InvestmentBlockSolver, SMSPPSolverTool, UCBlockSolver @@ -61,7 +62,7 @@ def test_optimize_reads_subprocess_output_portably(tmp_path): assert fp_log.read_text() == result.log -def test_optimize_checks_minimum_solver_version(tmp_path): +def test_optimize_checks_minimum_solver_version(tmp_path, monkeypatch): fp_network = tmp_path / "network.nc4" fp_config = tmp_path / "config.txt" fp_network.write_text("fake network") @@ -74,6 +75,11 @@ def test_optimize_checks_minimum_solver_version(tmp_path): minimum_version="99.0.0", ) + def fail_if_launched(*args, **kwargs): + raise AssertionError("optimize subprocess should not start on version mismatch") + + monkeypatch.setattr(smspp_tools_module.psutil, "Popen", fail_if_launched) + with pytest.raises(ValueError, match="older than required minimum version"): solver.optimize(logging=False, tracking_period=0.01) From 34475462b187dcdded3a16599fe2e2c295b0b9db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:11:24 +0000 Subject: [PATCH 07/15] Refine version-check caching and tests Co-authored-by: davide-f <67809479+davide-f@users.noreply.github.com> --- pysmspp/smspp_tools.py | 9 ++++++-- test/test_smspp_tools.py | 45 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/pysmspp/smspp_tools.py b/pysmspp/smspp_tools.py index ea52986..43d6ac1 100644 --- a/pysmspp/smspp_tools.py +++ b/pysmspp/smspp_tools.py @@ -195,6 +195,7 @@ def __init__( self._version_output = None self._parsed_version = None self._minimum_version_checked = False + self._validated_minimum_version = None self._kwargs = kwargs if "c" in self._kwargs: @@ -357,14 +358,17 @@ def ensure_minimum_version(self): """ if self._minimum_version is None: return - if self._minimum_version_checked: - return minimum = _version_tuple_of(self._minimum_version) if minimum is None: raise ValueError( f"Invalid minimum_version '{self._minimum_version}' for {self._solver_path}" ) + if ( + self._minimum_version_checked + and self._validated_minimum_version == minimum + ): + return current_text = self.version(print_message=False) current = _version_tuple_of(current_text) @@ -378,6 +382,7 @@ def ensure_minimum_version(self): f"{self._solver_path} version {current_text} is older than required minimum version {self._minimum_version}" ) self._minimum_version_checked = True + self._validated_minimum_version = minimum def optimize(self, logging=True, tracking_period=0.1): """ diff --git a/test/test_smspp_tools.py b/test/test_smspp_tools.py index aadfb80..6003e94 100644 --- a/test/test_smspp_tools.py +++ b/test/test_smspp_tools.py @@ -37,6 +37,31 @@ def test_version_uses_fallback_option(): assert version.count(".") >= 1 +def test_version_is_cached_and_deduplicates_identical_options(monkeypatch): + calls = {"count": 0} + + def fake_run(command, **kwargs): + calls["count"] += 1 + return smspp_tools_module.subprocess.CompletedProcess( + args=command, + returncode=0, + stdout="SMS++ tools version 0.7.1\n", + stderr="", + ) + + monkeypatch.setattr(smspp_tools_module.subprocess, "run", fake_run) + + solver = UCBlockSolver( + solver_path="ucblock_solver", + version_option="--version", + fallback_version_option="--version", + ) + + assert solver.version(print_message=False) == "0.7.1" + assert solver.version(print_message=False) == "0.7.1" + assert calls["count"] == 1 + + def test_optimize_reads_subprocess_output_portably(tmp_path): fp_network = tmp_path / "network.nc4" fp_config = tmp_path / "config.txt" @@ -112,6 +137,26 @@ def counting_version(*args, **kwargs): assert calls["count"] == 1 +def test_minimum_version_cache_tracks_requirement(tmp_path): + fp_network = tmp_path / "network.nc4" + fp_config = tmp_path / "config.txt" + fp_network.write_text("fake network") + fp_config.write_text("fake config") + + solver = FakeSolver( + solver_path=sys.executable, + fp_network=fp_network, + configfile=fp_config, + minimum_version="0.0.1", + ) + + solver.ensure_minimum_version() + solver._minimum_version = "99.0.0" + + with pytest.raises(ValueError, match="older than required minimum version"): + solver.ensure_minimum_version() + + def test_status_code_of_a_log(): """The status of a run is the one SMS++ printed, not a finite value.""" solver = UCBlockSolver() From cab85522329fb7d70a5cb1296ac43844583761b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:12:06 +0000 Subject: [PATCH 08/15] Align version probe execution context Co-authored-by: davide-f <67809479+davide-f@users.noreply.github.com> --- pysmspp/smspp_tools.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/pysmspp/smspp_tools.py b/pysmspp/smspp_tools.py index 43d6ac1..cf27fb5 100644 --- a/pysmspp/smspp_tools.py +++ b/pysmspp/smspp_tools.py @@ -315,19 +315,23 @@ def version(self, print_message=True): msg = None succeeded = False + run_kwargs = { + "capture_output": True, + "shell": self._shell, + "check": False, + "text": True, + } + networkdir = None if self.fp_network is None else os.path.split(self.fp_network)[0] + if networkdir: + run_kwargs["cwd"] = networkdir + for option in options: command = ( f"{self._solver_path} {option}" if self._shell else [self._solver_path, option] ) - result = subprocess.run( - command, - capture_output=True, - shell=self._shell, - check=False, - text=True, - ) + result = subprocess.run(command, **run_kwargs) msg = result.stdout + os.linesep + result.stderr if result.returncode == 0: succeeded = True From 4d49df944a809890f82e75a3c072e079cfdc69b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:12:43 +0000 Subject: [PATCH 09/15] Harden version probe command handling Co-authored-by: davide-f <67809479+davide-f@users.noreply.github.com> --- pysmspp/smspp_tools.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pysmspp/smspp_tools.py b/pysmspp/smspp_tools.py index cf27fb5..944c745 100644 --- a/pysmspp/smspp_tools.py +++ b/pysmspp/smspp_tools.py @@ -2,6 +2,7 @@ import os import queue import re +import shlex import subprocess import threading import time @@ -322,14 +323,15 @@ def version(self, print_message=True): "text": True, } networkdir = None if self.fp_network is None else os.path.split(self.fp_network)[0] - if networkdir: + if networkdir and Path(networkdir).is_dir(): run_kwargs["cwd"] = networkdir for option in options: + command_parts = [self._solver_path, option] command = ( - f"{self._solver_path} {option}" + " ".join(shlex.quote(part) for part in command_parts) if self._shell - else [self._solver_path, option] + else command_parts ) result = subprocess.run(command, **run_kwargs) msg = result.stdout + os.linesep + result.stderr From 89a444baada53a9ff7e1a199934bb7f1b0165cc0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:13:29 +0000 Subject: [PATCH 10/15] Use fallback when primary version output is unparsable Co-authored-by: davide-f <67809479+davide-f@users.noreply.github.com> --- pysmspp/smspp_tools.py | 18 +++++++----------- test/test_smspp_tools.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/pysmspp/smspp_tools.py b/pysmspp/smspp_tools.py index 944c745..e6564e1 100644 --- a/pysmspp/smspp_tools.py +++ b/pysmspp/smspp_tools.py @@ -315,7 +315,7 @@ def version(self, print_message=True): options.append(self._fallback_version_option) msg = None - succeeded = False + parsed = None run_kwargs = { "capture_output": True, "shell": self._shell, @@ -335,20 +335,16 @@ def version(self, print_message=True): ) result = subprocess.run(command, **run_kwargs) msg = result.stdout + os.linesep + result.stderr - if result.returncode == 0: - succeeded = True + if result.returncode != 0: + continue + parsed = _parse_tool_version(msg) + if parsed is not None: break - if not succeeded: - raise ValueError( - f"Failed to get version from {self._solver_path} using options {options}:" - f"\n{msg}" - ) - - parsed = _parse_tool_version(msg) if parsed is None: raise ValueError( - f"Could not parse version from {self._solver_path} output:\n{msg}" + f"Failed to parse version from {self._solver_path} using options {options}:" + f"\n{msg}" ) self._version_output = msg diff --git a/test/test_smspp_tools.py b/test/test_smspp_tools.py index 6003e94..5521e9c 100644 --- a/test/test_smspp_tools.py +++ b/test/test_smspp_tools.py @@ -37,6 +37,35 @@ def test_version_uses_fallback_option(): assert version.count(".") >= 1 +def test_version_fallback_runs_when_primary_output_has_no_version(monkeypatch): + responses = [ + smspp_tools_module.subprocess.CompletedProcess( + args=["ucblock_solver", "--version"], + returncode=0, + stdout="no semantic version here\n", + stderr="", + ), + smspp_tools_module.subprocess.CompletedProcess( + args=["ucblock_solver", "-V"], + returncode=0, + stdout="SMS++ tools version 0.7.1\n", + stderr="", + ), + ] + + def fake_run(*args, **kwargs): + return responses.pop(0) + + monkeypatch.setattr(smspp_tools_module.subprocess, "run", fake_run) + + solver = UCBlockSolver( + solver_path="ucblock_solver", + version_option="--version", + fallback_version_option="-V", + ) + assert solver.version(print_message=False) == "0.7.1" + + def test_version_is_cached_and_deduplicates_identical_options(monkeypatch): calls = {"count": 0} From 5b7fe04e27169e9f6fbeef5170ec9f2f520600b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:14:25 +0000 Subject: [PATCH 11/15] Support shell-mode version probes Co-authored-by: davide-f <67809479+davide-f@users.noreply.github.com> --- pysmspp/smspp_tools.py | 5 ++--- test/test_smspp_tools.py | 7 +++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/pysmspp/smspp_tools.py b/pysmspp/smspp_tools.py index e6564e1..ce57c95 100644 --- a/pysmspp/smspp_tools.py +++ b/pysmspp/smspp_tools.py @@ -327,11 +327,10 @@ def version(self, print_message=True): run_kwargs["cwd"] = networkdir for option in options: - command_parts = [self._solver_path, option] command = ( - " ".join(shlex.quote(part) for part in command_parts) + f"{self._solver_path} {shlex.quote(option)}" if self._shell - else command_parts + else [self._solver_path, option] ) result = subprocess.run(command, **run_kwargs) msg = result.stdout + os.linesep + result.stderr diff --git a/test/test_smspp_tools.py b/test/test_smspp_tools.py index 5521e9c..aefda80 100644 --- a/test/test_smspp_tools.py +++ b/test/test_smspp_tools.py @@ -66,6 +66,13 @@ def fake_run(*args, **kwargs): assert solver.version(print_message=False) == "0.7.1" +def test_version_supports_shell_solver_commands(): + code = "print('SMS++ tools version 0.7.1')" + solver_cmd = f'{sys.executable} -c "{code}"' + solver = UCBlockSolver(solver_path=solver_cmd, shell=True) + assert solver.version(print_message=False) == "0.7.1" + + def test_version_is_cached_and_deduplicates_identical_options(monkeypatch): calls = {"count": 0} From ba9b03e007ab460e8174ef55fd6ee0bef2d74607 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:15:11 +0000 Subject: [PATCH 12/15] Parse shell command tokens for version probing Co-authored-by: davide-f <67809479+davide-f@users.noreply.github.com> --- pysmspp/smspp_tools.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pysmspp/smspp_tools.py b/pysmspp/smspp_tools.py index ce57c95..762b698 100644 --- a/pysmspp/smspp_tools.py +++ b/pysmspp/smspp_tools.py @@ -327,11 +327,12 @@ def version(self, print_message=True): run_kwargs["cwd"] = networkdir for option in options: - command = ( - f"{self._solver_path} {shlex.quote(option)}" - if self._shell - else [self._solver_path, option] - ) + if self._shell: + command_parts = shlex.split(self._solver_path) + command_parts.append(option) + command = " ".join(shlex.quote(part) for part in command_parts) + else: + command = [self._solver_path, option] result = subprocess.run(command, **run_kwargs) msg = result.stdout + os.linesep + result.stderr if result.returncode != 0: From a678ec2beeac7d13bf282edc810b829ad2155848 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:04:31 +0000 Subject: [PATCH 13/15] Remove minimum-version gating from solver tools Co-authored-by: davide-f <67809479+davide-f@users.noreply.github.com> --- pysmspp/smspp_tools.py | 57 -------------------------------- test/test_smspp_tools.py | 70 ---------------------------------------- 2 files changed, 127 deletions(-) diff --git a/pysmspp/smspp_tools.py b/pysmspp/smspp_tools.py index 762b698..af5ee2d 100644 --- a/pysmspp/smspp_tools.py +++ b/pysmspp/smspp_tools.py @@ -6,7 +6,6 @@ import subprocess import threading import time -from itertools import zip_longest from pathlib import Path import numpy as np @@ -54,22 +53,6 @@ def _parse_tool_version(version): return None if res is None else res.group() -def _version_tuple_of(version): - parsed = _parse_tool_version(version) - if parsed is None: - return None - return tuple(int(component) for component in parsed.split(".")) - - -def _is_older_version(current, minimum): - for cur, req in zip_longest(current, minimum, fillvalue=0): - if cur < req: - return True - if cur > req: - return False - return False - - def _enqueue_pipe_lines(pipe, stream_name, messages): try: for line in iter(pipe.readline, ""): @@ -115,7 +98,6 @@ def __init__( help_option: str = "-h", version_option: str = "--version", fallback_version_option: str | None = "-V", - minimum_version: str | None = None, shell: bool = False, **kwargs, ): @@ -148,9 +130,6 @@ def __init__( fallback_version_option : str | None, optional Alternative version option when version_option is not supported, by default "-V". - minimum_version : str | None, optional - Optional minimum compatible version (for example "0.7.1"), by - default None. shell : bool, optional Whether to execute the command through the shell. Defaults to False. **kwargs @@ -165,7 +144,6 @@ def __init__( self._help_option = help_option self._version_option = version_option self._fallback_version_option = fallback_version_option - self._minimum_version = minimum_version self.fp_network = ( None if fp_network is None else str(Path(fp_network).resolve()) @@ -195,8 +173,6 @@ def __init__( self._computational_time = None self._version_output = None self._parsed_version = None - self._minimum_version_checked = False - self._validated_minimum_version = None self._kwargs = kwargs if "c" in self._kwargs: @@ -354,38 +330,6 @@ def version(self, print_message=True): print(msg) return parsed - def ensure_minimum_version(self): - """ - Raise when the solver version is older than the required minimum one. - """ - if self._minimum_version is None: - return - - minimum = _version_tuple_of(self._minimum_version) - if minimum is None: - raise ValueError( - f"Invalid minimum_version '{self._minimum_version}' for {self._solver_path}" - ) - if ( - self._minimum_version_checked - and self._validated_minimum_version == minimum - ): - return - - current_text = self.version(print_message=False) - current = _version_tuple_of(current_text) - if current is None: - raise ValueError( - f"Could not parse version for {self._solver_path}: {current_text}" - ) - - if _is_older_version(current, minimum): - raise ValueError( - f"{self._solver_path} version {current_text} is older than required minimum version {self._minimum_version}" - ) - self._minimum_version_checked = True - self._validated_minimum_version = minimum - def optimize(self, logging=True, tracking_period=0.1): """ Run the SMSPP Solver tool. @@ -405,7 +349,6 @@ def optimize(self, logging=True, tracking_period=0.1): ) if not Path(self.fp_network).exists(): raise FileNotFoundError(f"Network file {self.fp_network} does not exist.") - self.ensure_minimum_version() command_raw = self.calculate_executable_call() command_str = " ".join(command_raw) diff --git a/test/test_smspp_tools.py b/test/test_smspp_tools.py index aefda80..b319d5a 100644 --- a/test/test_smspp_tools.py +++ b/test/test_smspp_tools.py @@ -123,76 +123,6 @@ def test_optimize_reads_subprocess_output_portably(tmp_path): assert fp_log.read_text() == result.log -def test_optimize_checks_minimum_solver_version(tmp_path, monkeypatch): - fp_network = tmp_path / "network.nc4" - fp_config = tmp_path / "config.txt" - fp_network.write_text("fake network") - fp_config.write_text("fake config") - - solver = FakeSolver( - solver_path=sys.executable, - fp_network=fp_network, - configfile=fp_config, - minimum_version="99.0.0", - ) - - def fail_if_launched(*args, **kwargs): - raise AssertionError("optimize subprocess should not start on version mismatch") - - monkeypatch.setattr(smspp_tools_module.psutil, "Popen", fail_if_launched) - - with pytest.raises(ValueError, match="older than required minimum version"): - solver.optimize(logging=False, tracking_period=0.01) - - -def test_minimum_version_check_is_cached(tmp_path, monkeypatch): - fp_network = tmp_path / "network.nc4" - fp_config = tmp_path / "config.txt" - fp_network.write_text("fake network") - fp_config.write_text("fake config") - - solver = FakeSolver( - solver_path=sys.executable, - fp_network=fp_network, - configfile=fp_config, - minimum_version="0.0.1", - ) - - calls = {"count": 0} - version = solver.version - - def counting_version(*args, **kwargs): - calls["count"] += 1 - return version(*args, **kwargs) - - monkeypatch.setattr(solver, "version", counting_version) - - solver.ensure_minimum_version() - solver.ensure_minimum_version() - - assert calls["count"] == 1 - - -def test_minimum_version_cache_tracks_requirement(tmp_path): - fp_network = tmp_path / "network.nc4" - fp_config = tmp_path / "config.txt" - fp_network.write_text("fake network") - fp_config.write_text("fake config") - - solver = FakeSolver( - solver_path=sys.executable, - fp_network=fp_network, - configfile=fp_config, - minimum_version="0.0.1", - ) - - solver.ensure_minimum_version() - solver._minimum_version = "99.0.0" - - with pytest.raises(ValueError, match="older than required minimum version"): - solver.ensure_minimum_version() - - def test_status_code_of_a_log(): """The status of a run is the one SMS++ printed, not a finite value.""" solver = UCBlockSolver() From 6b1e059e5859397dbe8d9f16df034c5b7303b8ce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:17:50 +0000 Subject: [PATCH 14/15] Simplify solver version utility API Co-authored-by: davide-f <67809479+davide-f@users.noreply.github.com> --- pysmspp/smspp_tools.py | 103 ++++++++++++--------------------------- test/test_smspp_tools.py | 41 ++-------------- 2 files changed, 35 insertions(+), 109 deletions(-) diff --git a/pysmspp/smspp_tools.py b/pysmspp/smspp_tools.py index af5ee2d..f900538 100644 --- a/pysmspp/smspp_tools.py +++ b/pysmspp/smspp_tools.py @@ -2,7 +2,6 @@ import os import queue import re -import shlex import subprocess import threading import time @@ -41,18 +40,6 @@ def _status_code_of(status): return None if res is None else int(res.group()) -def _parse_tool_version(version): - """ - The semantic version found in a tool version string, e.g. "0.7.1" in - "SMS++ tools version 0.7.1", and None where none can be found. - """ - if version is None: - return None - - res = re.search(r"\d+(?:\.\d+){1,}", str(version)) - return None if res is None else res.group() - - def _enqueue_pipe_lines(pipe, stream_name, messages): try: for line in iter(pipe.readline, ""): @@ -96,8 +83,6 @@ def __init__( fp_solution: Path | str | None = None, configsolution: Path | str | None = None, help_option: str = "-h", - version_option: str = "--version", - fallback_version_option: str | None = "-V", shell: bool = False, **kwargs, ): @@ -125,11 +110,6 @@ def __init__( When provided, option "-C" is added to the executable call to specify the configuration solution file. help_option : str, optional The option to display the help message, by default "-h". - version_option : str, optional - The option to display the version message, by default "--version". - fallback_version_option : str | None, optional - Alternative version option when version_option is not supported, - by default "-V". shell : bool, optional Whether to execute the command through the shell. Defaults to False. **kwargs @@ -142,8 +122,6 @@ def __init__( else: self._solver_path = str(solver_path) self._help_option = help_option - self._version_option = version_option - self._fallback_version_option = fallback_version_option self.fp_network = ( None if fp_network is None else str(Path(fp_network).resolve()) @@ -171,8 +149,6 @@ def __init__( self._subprocess_time = None self._solution_time = None self._computational_time = None - self._version_output = None - self._parsed_version = None self._kwargs = kwargs if "c" in self._kwargs: @@ -257,12 +233,15 @@ def help(self, print_message=True): print(msg) return msg - def version(self, print_message=True): + def version(self, fallback_option="-V", print_message=True): """ Return the semantic version reported by the SMS++ solver. Parameters ---------- + fallback_option : str | None, optional + Alternative option to try when "--version" does not return a + parseable semantic version, by default "-V". print_message : bool, optional Whether to print the raw version output from the solver, by default True. @@ -275,60 +254,40 @@ def version(self, print_message=True): Raises ------ ValueError - If version retrieval fails for all configured version options, or - if the tool output does not contain a parseable semantic version. + If the tool output does not contain a parseable semantic version. """ - if self._parsed_version is not None: - if print_message and self._version_output is not None: - print(self._version_output) - return self._parsed_version - - options = [self._version_option] - if ( - self._fallback_version_option is not None - and self._fallback_version_option not in options - ): - options.append(self._fallback_version_option) - - msg = None - parsed = None - run_kwargs = { - "capture_output": True, - "shell": self._shell, - "check": False, - "text": True, - } - networkdir = None if self.fp_network is None else os.path.split(self.fp_network)[0] - if networkdir and Path(networkdir).is_dir(): - run_kwargs["cwd"] = networkdir - - for option in options: + def _run(option): + command = [self._solver_path, option] if self._shell: - command_parts = shlex.split(self._solver_path) - command_parts.append(option) - command = " ".join(shlex.quote(part) for part in command_parts) - else: - command = [self._solver_path, option] - result = subprocess.run(command, **run_kwargs) - msg = result.stdout + os.linesep + result.stderr - if result.returncode != 0: - continue - parsed = _parse_tool_version(msg) - if parsed is not None: - break - - if parsed is None: + command = f"{self._solver_path} {option}" + result = subprocess.run( + command, + capture_output=True, + shell=self._shell, + check=False, + ) + stdout = result.stdout + stderr = result.stderr + if isinstance(stdout, bytes): + stdout = stdout.decode("utf-8") + if isinstance(stderr, bytes): + stderr = stderr.decode("utf-8") + return str(stdout) + os.linesep + str(stderr) + + msg = _run("--version") + res = re.search(r"\d+(?:\.\d+){1,}", msg) + if res is None and fallback_option is not None: + msg = _run(fallback_option) + res = re.search(r"\d+(?:\.\d+){1,}", msg) + + if res is None: raise ValueError( - f"Failed to parse version from {self._solver_path} using options {options}:" - f"\n{msg}" + f"Failed to parse version from {self._solver_path} output:\n{msg}" ) - self._version_output = msg - self._parsed_version = parsed - if print_message: print(msg) - return parsed + return res.group() def optimize(self, logging=True, tracking_period=0.1): """ diff --git a/test/test_smspp_tools.py b/test/test_smspp_tools.py index b319d5a..77c5be2 100644 --- a/test/test_smspp_tools.py +++ b/test/test_smspp_tools.py @@ -28,12 +28,8 @@ def test_version_reads_solver_version(): def test_version_uses_fallback_option(): - solver = UCBlockSolver( - solver_path=sys.executable, - version_option="--not-a-valid-python-version-option", - fallback_version_option="-V", - ) - version = solver.version(print_message=False) + solver = UCBlockSolver(solver_path=sys.executable) + version = solver.version(fallback_option="-V", print_message=False) assert version.count(".") >= 1 @@ -58,12 +54,8 @@ def fake_run(*args, **kwargs): monkeypatch.setattr(smspp_tools_module.subprocess, "run", fake_run) - solver = UCBlockSolver( - solver_path="ucblock_solver", - version_option="--version", - fallback_version_option="-V", - ) - assert solver.version(print_message=False) == "0.7.1" + solver = UCBlockSolver(solver_path="ucblock_solver") + assert solver.version(fallback_option="-V", print_message=False) == "0.7.1" def test_version_supports_shell_solver_commands(): @@ -73,31 +65,6 @@ def test_version_supports_shell_solver_commands(): assert solver.version(print_message=False) == "0.7.1" -def test_version_is_cached_and_deduplicates_identical_options(monkeypatch): - calls = {"count": 0} - - def fake_run(command, **kwargs): - calls["count"] += 1 - return smspp_tools_module.subprocess.CompletedProcess( - args=command, - returncode=0, - stdout="SMS++ tools version 0.7.1\n", - stderr="", - ) - - monkeypatch.setattr(smspp_tools_module.subprocess, "run", fake_run) - - solver = UCBlockSolver( - solver_path="ucblock_solver", - version_option="--version", - fallback_version_option="--version", - ) - - assert solver.version(print_message=False) == "0.7.1" - assert solver.version(print_message=False) == "0.7.1" - assert calls["count"] == 1 - - def test_optimize_reads_subprocess_output_portably(tmp_path): fp_network = tmp_path / "network.nc4" fp_config = tmp_path / "config.txt" From 9d2c2ca27995f8c1fb5fbca09503eadfc315bdb9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:28:09 +0000 Subject: [PATCH 15/15] Use single version option argument Co-authored-by: davide-f <67809479+davide-f@users.noreply.github.com> --- pysmspp/smspp_tools.py | 13 +++++-------- test/test_smspp_tools.py | 25 ++++++++----------------- 2 files changed, 13 insertions(+), 25 deletions(-) diff --git a/pysmspp/smspp_tools.py b/pysmspp/smspp_tools.py index f900538..5596bd8 100644 --- a/pysmspp/smspp_tools.py +++ b/pysmspp/smspp_tools.py @@ -233,15 +233,14 @@ def help(self, print_message=True): print(msg) return msg - def version(self, fallback_option="-V", print_message=True): + def version(self, option="--version", print_message=True): """ Return the semantic version reported by the SMS++ solver. Parameters ---------- - fallback_option : str | None, optional - Alternative option to try when "--version" does not return a - parseable semantic version, by default "-V". + option : str, optional + Option to query the tool version, by default "--version". print_message : bool, optional Whether to print the raw version output from the solver, by default True. @@ -256,6 +255,7 @@ def version(self, fallback_option="-V", print_message=True): ValueError If the tool output does not contain a parseable semantic version. """ + def _run(option): command = [self._solver_path, option] if self._shell: @@ -274,11 +274,8 @@ def _run(option): stderr = stderr.decode("utf-8") return str(stdout) + os.linesep + str(stderr) - msg = _run("--version") + msg = _run(option) res = re.search(r"\d+(?:\.\d+){1,}", msg) - if res is None and fallback_option is not None: - msg = _run(fallback_option) - res = re.search(r"\d+(?:\.\d+){1,}", msg) if res is None: raise ValueError( diff --git a/test/test_smspp_tools.py b/test/test_smspp_tools.py index 77c5be2..9f20aa6 100644 --- a/test/test_smspp_tools.py +++ b/test/test_smspp_tools.py @@ -27,35 +27,26 @@ def test_version_reads_solver_version(): assert version.count(".") >= 1 -def test_version_uses_fallback_option(): +def test_version_with_custom_option(): solver = UCBlockSolver(solver_path=sys.executable) - version = solver.version(fallback_option="-V", print_message=False) + version = solver.version(option="-V", print_message=False) assert version.count(".") >= 1 -def test_version_fallback_runs_when_primary_output_has_no_version(monkeypatch): - responses = [ - smspp_tools_module.subprocess.CompletedProcess( +def test_version_raises_if_output_is_not_a_semantic_version(monkeypatch): + def fake_run(*args, **kwargs): + return smspp_tools_module.subprocess.CompletedProcess( args=["ucblock_solver", "--version"], returncode=0, stdout="no semantic version here\n", stderr="", - ), - smspp_tools_module.subprocess.CompletedProcess( - args=["ucblock_solver", "-V"], - returncode=0, - stdout="SMS++ tools version 0.7.1\n", - stderr="", - ), - ] - - def fake_run(*args, **kwargs): - return responses.pop(0) + ) monkeypatch.setattr(smspp_tools_module.subprocess, "run", fake_run) solver = UCBlockSolver(solver_path="ucblock_solver") - assert solver.version(fallback_option="-V", print_message=False) == "0.7.1" + with pytest.raises(ValueError, match="Failed to parse version"): + solver.version(print_message=False) def test_version_supports_shell_solver_commands():