From 60fa6bb2d5d257066410f86113fbea6960374610 Mon Sep 17 00:00:00 2001 From: "Haroldo G. Santos" Date: Mon, 8 Jun 2026 08:49:40 -0400 Subject: [PATCH 1/8] Migrate from cbcbox to mipster for the bundled CBC library cbcbox shipped CBC source builds; mipster (https://pypi.org/project/mipster/) ships self-contained, hardware-tuned wheels for Linux x86_64/aarch64, macOS x86_64/arm64, and Windows x86_64 with CPUID auto-dispatch (generic / AVX2 / Haswell / NEON variants). - Replace `cbcbox>=2.929` with `mipster>=0.2.0`. - Use `mipster.lib_path()` instead of platform-specific path joining; this returns the right shared library for the current OS and selected variant. - Drop the manual libCbc.so / libCbc-0.dll / libCbc.dylib branching. - Keep `os.add_dll_directory()` on Windows so the loader can find the sibling MinGW runtime DLLs that mipster bundles next to libmipster-N.dll. - Remove unused Cbc_get/setAllowablePercentageGap CFFI declarations (these are not exported by libmipster and were never called). --- mip/cbc.py | 41 ++++++++++------------------------------- pyproject.toml | 2 +- 2 files changed, 11 insertions(+), 32 deletions(-) diff --git a/mip/cbc.py b/mip/cbc.py index cf777d34..db72b5a8 100644 --- a/mip/cbc.py +++ b/mip/cbc.py @@ -70,36 +70,20 @@ cbclib = ffi.dlopen(libfile) os.chdir(old_dir) else: - import cbcbox as _cbcbox - - _lib_dir = _cbcbox.cbc_lib_dir() - if "linux" in platform.lower(): - if not os_is_64_bit: - raise NotImplementedError("Linux 32 bits platform not supported.") - libfile = os.path.join(_lib_dir, "libCbc.so") - elif platform.lower().startswith("win"): - if not os_is_64_bit: - raise NotImplementedError("Win32 platform not supported.") - # autotools/MinGW places DLLs under bin/, not lib/ - _bin_dir = os.path.join(_cbcbox.cbc_dist_dir(), "bin") - libfile = os.path.join(_bin_dir, "libCbc-0.dll") - if not os.path.exists(libfile): - raise FileNotFoundError( - "libCbc-0.dll not found in cbcbox Windows distribution at" - " {}. The cbcbox Windows wheel may only contain a static" - " libCbc.a. A shared libCbc-0.dll is required.".format(_bin_dir) - ) - # Python 3.8+ ignores PATH for DLL resolution; use add_dll_directory + import mipster as _mipster + + if not os_is_64_bit: + raise NotImplementedError("32-bit platforms are not supported.") + libfile = _mipster.lib_path() + if platform.lower().startswith("win"): + # Python 3.8+ ignores PATH for DLL resolution; let Windows find + # any sibling DLLs (libgcc, libstdc++, libwinpthread) via + # add_dll_directory on the directory holding libmipster-N.dll. + _bin_dir = os.path.dirname(libfile) if hasattr(os, "add_dll_directory"): os.add_dll_directory(_bin_dir) elif _bin_dir not in os.environ.get("PATH", ""): os.environ["PATH"] = _bin_dir + ";" + os.environ["PATH"] - elif platform.lower().startswith("darwin") or platform.lower().startswith( - "macos" - ): - libfile = os.path.join(_lib_dir, "libCbc.dylib") - else: - raise NotImplementedError("Your operating system/platform is not supported") cbclib = ffi.dlopen(libfile) has_cbc = True except Exception as e: @@ -317,11 +301,6 @@ void Cbc_setAllowableFractionGap(Cbc_Model *model, double allowedFracionGap); - double Cbc_getAllowablePercentageGap(Cbc_Model *model); - - void Cbc_setAllowablePercentageGap(Cbc_Model *model, - double allowedPercentageGap); - double Cbc_getMaximumSeconds(Cbc_Model *model); void Cbc_setMaximumSeconds(Cbc_Model *model, double maxSeconds); diff --git a/pyproject.toml b/pyproject.toml index 5edb731a..196c72b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ classifiers = [ ] dynamic = ["version"] -dependencies = ["cffi>=1.15", "cbcbox>=2.929"] +dependencies = ["cffi>=1.15", "mipster>=0.2.0"] [project.optional-dependencies] numpy = [ From d9a327f77617a8fbd1fcd9e5017f82227c463581 Mon Sep 17 00:00:00 2001 From: Haroldo Santos Date: Mon, 8 Jun 2026 19:56:06 -0400 Subject: [PATCH 2/8] chore: require mipster>=0.2.4 Version 0.2.4 fixes three C API bugs required for correctness: - Auto-generate column names so translate() works in lazy callbacks - Sync bound==value when proven optimal (prevents spurious assertion failures) - Restore rSlk/rActv pointers after MIP solve (prevents null-pointer crash) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 196c72b6..cebbe6c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ classifiers = [ ] dynamic = ["version"] -dependencies = ["cffi>=1.15", "mipster>=0.2.0"] +dependencies = ["cffi>=1.15", "mipster>=0.2.4"] [project.optional-dependencies] numpy = [ From 38d574fd2f196783fec72db900189864d37166fc Mon Sep 17 00:00:00 2001 From: Haroldo Santos Date: Mon, 8 Jun 2026 19:58:46 -0400 Subject: [PATCH 3/8] style: apply black formatting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- mip/cbc.py | 42 ++++++-------- mip/gurobi.py | 30 +++++----- mip/highs.py | 158 ++++++++++++++++++++++++++++---------------------- 3 files changed, 121 insertions(+), 109 deletions(-) diff --git a/mip/cbc.py b/mip/cbc.py index db72b5a8..57931b3d 100644 --- a/mip/cbc.py +++ b/mip/cbc.py @@ -91,8 +91,7 @@ has_cbc = False if has_cbc: - ffi.cdef( - """ + ffi.cdef(""" typedef int(*cbc_progress_callback)(void *model, int phase, int step, @@ -541,8 +540,7 @@ const char *Cbc_featureName(int i); void Cbc_reset(Cbc_Model *model); - """ - ) + """) CHAR_ONE = "{}".format(chr(1)).encode("utf-8") CHAR_ZERO = "\0".encode("utf-8") @@ -1001,7 +999,9 @@ def clique_merge(self, constrs: Optional[List["mip.Constr"]] = None): strengthenPacking = cbclib.Cbc_strengthenPackingRows strengthenPacking(self._model, nr, idxr) - def optimize(self, relax: bool = False, lp_preprocess: bool = False) -> OptimizationStatus: + def optimize( + self, relax: bool = False, lp_preprocess: bool = False + ) -> OptimizationStatus: # get name indexes from an osi problem def cbc_get_osi_name_indexes(osi_solver) -> Dict[str, int]: nameIdx = {} @@ -1014,12 +1014,10 @@ def cbc_get_osi_name_indexes(osi_solver) -> Dict[str, int]: return nameIdx # progress callback - @ffi.callback( - """ + @ffi.callback(""" int (void *, int, int, const char *, double, double, double, int, int *, void *) - """ - ) + """) def cbc_progress_callback( model, phase: int, @@ -1042,11 +1040,9 @@ def cbc_inc_callback( return # cut callback - @ffi.callback( - """ + @ffi.callback(""" void (void *osi_solver, void *osi_cuts, void *app_data, int level, int npass) - """ - ) + """) def cbc_cut_callback(osi_solver, osi_cuts, app_data, depth, npass): if ( osi_solver == ffi.NULL @@ -1182,7 +1178,9 @@ def cbc_cut_callback(osi_solver, osi_cuts, app_data, depth, npass): # user-specified cut passes override the cuts-level default if self.model.cut_passes != -1: - cbclib.Cbc_setIntParam(self._model, INT_PARAM_CUT_PASS, self.model.cut_passes) + cbclib.Cbc_setIntParam( + self._model, INT_PARAM_CUT_PASS, self.model.cut_passes + ) if self.model.clique == 0: cbc_set_parameter(self, "clique", "off") @@ -1470,10 +1468,8 @@ def write(self, file_path: str): elif ".bas" in file_path.lower(): cbclib.Cbc_writeBasis(self._model, fpstr, CHAR_ONE, 2) else: - raise ValueError( - "Enter a valid extension (.lp, .mps or .bas) \ - to indicate the file format" - ) + raise ValueError("Enter a valid extension (.lp, .mps or .bas) \ + to indicate the file format") def read(self, file_path: str) -> None: if not isfile(file_path): @@ -1502,10 +1498,8 @@ def read(self, file_path: str) -> None: logger.info("Optimal LP basis successfully loaded.") else: - raise ValueError( - "Enter a valid extension (.lp, .mps or .bas) \ - to indicate the file format" - ) + raise ValueError("Enter a valid extension (.lp, .mps or .bas) \ + to indicate the file format") def set_start(self, start: List[Tuple[Var, numbers.Real]]) -> None: # Augment start list with default zero values for absent non-continuous variables @@ -1795,7 +1789,9 @@ def add_var( numnz = len(column.constrs) isInt = ( - CHAR_ONE if var_type.upper() == "B" or var_type.upper() == "I" else CHAR_ZERO + CHAR_ONE + if var_type.upper() == "B" or var_type.upper() == "I" + else CHAR_ZERO ) cbclib.Osi_addCol( self.osi, diff --git a/mip/gurobi.py b/mip/gurobi.py index fa3a8c43..944292b5 100644 --- a/mip/gurobi.py +++ b/mip/gurobi.py @@ -99,8 +99,7 @@ has_gurobi = True grblib = ffi.dlopen(lib_path) - ffi.cdef( - """ + ffi.cdef(""" typedef struct _GRBmodel GRBmodel; typedef struct _GRBenv GRBenv; @@ -243,8 +242,7 @@ int GRBdelconstrs (GRBmodel *model, int numdel, int *ind); int GRBreset (GRBmodel *model, int clearall); - """ - ) + """) GRBloadenv = grblib.GRBloadenv GRBnewmodel = grblib.GRBnewmodel @@ -341,13 +339,11 @@ def __init__(self, model: Model, name: str, sense: str, modelp: CData = ffi.NULL """modelp should be informed if a model should not be created, but only allow access to an existing one""" if not has_gurobi: - raise FileNotFoundError( - """Gurobi not found. Plase check if the + raise FileNotFoundError("""Gurobi not found. Plase check if the Gurobi dynamic loadable library is reachable or define the environment variable GUROBI_HOME indicating the gurobi installation path. - """ - ) + """) super().__init__(model, name, sense) @@ -589,14 +585,14 @@ def set_max_iter(self, max_iter: int): def set_num_threads(self, threads: int): self.__threads = threads - def optimize(self, relax: bool = False, lp_preprocess: bool = False) -> OptimizationStatus: + def optimize( + self, relax: bool = False, lp_preprocess: bool = False + ) -> OptimizationStatus: # todo add branch_selector and incumbent_updater callbacks - @ffi.callback( - """ + @ffi.callback(""" int (GRBmodel *, void *, int, void *) - """ - ) + """) def callback( p_model: CData, p_cbdata: CData, where: int, p_usrdata: CData ) -> int: @@ -802,9 +798,7 @@ def callback( self.__obj_val = self.get_dbl_attr("ObjVal") self.__x = ffi.new("double[{}]".format(self.num_cols())) attr = "X".encode("utf-8") - st = GRBgetdblattrarray( - self._model, attr, 0, self.num_cols(), self.__x - ) + st = GRBgetdblattrarray(self._model, attr, 0, self.num_cols(), self.__x) if st: raise ParameterNotAvailable("Error querying Gurobi solution") # duals are only valid at optimality for Gurobi, skip Pi/RC @@ -1026,7 +1020,9 @@ def constr_get_expr(self, constr: Constr) -> LinExpr: nnz = ffi.new("int *") # obtaining number of non-zeros - st = GRBgetconstrs(self._model, nnz, ffi.NULL, ffi.NULL, ffi.NULL, constr.idx, 1) + st = GRBgetconstrs( + self._model, nnz, ffi.NULL, ffi.NULL, ffi.NULL, constr.idx, 1 + ) if st != 0: raise ParameterNotAvailable( "Could not get info for constraint {}".format(constr.idx) diff --git a/mip/highs.py b/mip/highs.py index 32272554..eba17d9d 100644 --- a/mip/highs.py +++ b/mip/highs.py @@ -51,8 +51,7 @@ has_highs = False if has_highs: - ffi.cdef( - """ + ffi.cdef(""" /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ /* */ /* This file is part of the HiGHS linear optimization suite */ @@ -675,14 +674,13 @@ const char* value); HighsInt Highs_getScaledModelStatus(const void* highs); - """ - ) + """) STATUS_ERROR = highslib.kHighsStatusError # Initial capacities for the pending col/row caches (grows geometrically). -_CACHE_INITIAL_CAP = 8192 # column / row slots -_CACHE_INITIAL_NZ_CAP = 32768 # row-NZ slots (4 × col cap) +_CACHE_INITIAL_CAP = 8192 # column / row slots +_CACHE_INITIAL_NZ_CAP = 32768 # row-NZ slots (4 × col cap) _SIZEOF_INT = ffi.sizeof("int") _SIZEOF_DOUBLE = ffi.sizeof("double") @@ -734,28 +732,28 @@ def __init__(self, model: mip.Model, name: str, sense: str): # ── Column / row caches ──────────────────────────────────────────── # Pending columns (not yet committed to HiGHS). - self._col_committed = 0 # cols actually in HiGHS - self._col_fill = 0 # pending cols in cache + self._col_committed = 0 # cols actually in HiGHS + self._col_fill = 0 # pending cols in cache self._col_cap = _CACHE_INITIAL_CAP - self._c_col_lb = ffi.new("double[]", _CACHE_INITIAL_CAP) - self._c_col_ub = ffi.new("double[]", _CACHE_INITIAL_CAP) + self._c_col_lb = ffi.new("double[]", _CACHE_INITIAL_CAP) + self._c_col_ub = ffi.new("double[]", _CACHE_INITIAL_CAP) self._c_col_obj = ffi.new("double[]", _CACHE_INITIAL_CAP) - self._c_col_int = ffi.new("int[]", _CACHE_INITIAL_CAP) - self._col_names: list = [] # (logical_idx, encoded_bytes) pairs - self._pending_int_count = 0 # integer vars among pending cols + self._c_col_int = ffi.new("int[]", _CACHE_INITIAL_CAP) + self._col_names: list = [] # (logical_idx, encoded_bytes) pairs + self._pending_int_count = 0 # integer vars among pending cols # Pending rows (not yet committed to HiGHS). NZs stored in CSR. self._row_committed = 0 self._row_fill = 0 self._row_cap = _CACHE_INITIAL_CAP - self._c_row_lb = ffi.new("double[]", _CACHE_INITIAL_CAP) - self._c_row_ub = ffi.new("double[]", _CACHE_INITIAL_CAP) - self._c_row_starts = ffi.new("int[]", _CACHE_INITIAL_CAP) # NZ start per row + self._c_row_lb = ffi.new("double[]", _CACHE_INITIAL_CAP) + self._c_row_ub = ffi.new("double[]", _CACHE_INITIAL_CAP) + self._c_row_starts = ffi.new("int[]", _CACHE_INITIAL_CAP) # NZ start per row self._row_nz_fill = 0 self._row_nz_cap = _CACHE_INITIAL_NZ_CAP - self._c_row_indices = ffi.new("int[]", _CACHE_INITIAL_NZ_CAP) - self._c_row_values = ffi.new("double[]", _CACHE_INITIAL_NZ_CAP) - self._row_names: list = [] # (logical_idx, encoded_bytes) pairs + self._c_row_indices = ffi.new("int[]", _CACHE_INITIAL_NZ_CAP) + self._c_row_values = ffi.new("double[]", _CACHE_INITIAL_NZ_CAP) + self._row_names: list = [] # (logical_idx, encoded_bytes) pairs # Name→index dicts for O(1) lookups without flushing pending cols/rows. # Set to None after remove_vars/remove_constrs/read, which invalidate indices. @@ -771,65 +769,75 @@ def __del__(self): def _grow_cols(self: "SolverHighs"): new_cap = self._col_cap * 2 n = self._col_fill - new_lb = ffi.new("double[]", new_cap) - new_ub = ffi.new("double[]", new_cap) + new_lb = ffi.new("double[]", new_cap) + new_ub = ffi.new("double[]", new_cap) new_obj = ffi.new("double[]", new_cap) - new_int = ffi.new("int[]", new_cap) - ffi.memmove(new_lb, self._c_col_lb, n * _SIZEOF_DOUBLE) - ffi.memmove(new_ub, self._c_col_ub, n * _SIZEOF_DOUBLE) + new_int = ffi.new("int[]", new_cap) + ffi.memmove(new_lb, self._c_col_lb, n * _SIZEOF_DOUBLE) + ffi.memmove(new_ub, self._c_col_ub, n * _SIZEOF_DOUBLE) ffi.memmove(new_obj, self._c_col_obj, n * _SIZEOF_DOUBLE) ffi.memmove(new_int, self._c_col_int, n * _SIZEOF_INT) - self._c_col_lb = new_lb - self._c_col_ub = new_ub + self._c_col_lb = new_lb + self._c_col_ub = new_ub self._c_col_obj = new_obj self._c_col_int = new_int - self._col_cap = new_cap + self._col_cap = new_cap def _grow_rows(self: "SolverHighs"): new_cap = self._row_cap * 2 n = self._row_fill - new_lb = ffi.new("double[]", new_cap) - new_ub = ffi.new("double[]", new_cap) - new_starts = ffi.new("int[]", new_cap) - ffi.memmove(new_lb, self._c_row_lb, n * _SIZEOF_DOUBLE) - ffi.memmove(new_ub, self._c_row_ub, n * _SIZEOF_DOUBLE) + new_lb = ffi.new("double[]", new_cap) + new_ub = ffi.new("double[]", new_cap) + new_starts = ffi.new("int[]", new_cap) + ffi.memmove(new_lb, self._c_row_lb, n * _SIZEOF_DOUBLE) + ffi.memmove(new_ub, self._c_row_ub, n * _SIZEOF_DOUBLE) ffi.memmove(new_starts, self._c_row_starts, n * _SIZEOF_INT) - self._c_row_lb = new_lb - self._c_row_ub = new_ub + self._c_row_lb = new_lb + self._c_row_ub = new_ub self._c_row_starts = new_starts - self._row_cap = new_cap + self._row_cap = new_cap def _grow_row_nz(self: "SolverHighs", needed: int): new_cap = max(self._row_nz_cap * 2, self._row_nz_fill + needed) nz = self._row_nz_fill - new_idx = ffi.new("int[]", new_cap) + new_idx = ffi.new("int[]", new_cap) new_val = ffi.new("double[]", new_cap) ffi.memmove(new_idx, self._c_row_indices, nz * _SIZEOF_INT) - ffi.memmove(new_val, self._c_row_values, nz * _SIZEOF_DOUBLE) + ffi.memmove(new_val, self._c_row_values, nz * _SIZEOF_DOUBLE) self._c_row_indices = new_idx - self._c_row_values = new_val - self._row_nz_cap = new_cap + self._c_row_values = new_val + self._row_nz_cap = new_cap def _flush_cols(self: "SolverHighs"): n = self._col_fill if n == 0: return - check(self._lib.Highs_addCols( - self._model, n, - self._c_col_obj, self._c_col_lb, self._c_col_ub, - 0, ffi.NULL, ffi.NULL, ffi.NULL, - )) - if self._pending_int_count > 0: - check(self._lib.Highs_changeColsIntegralityByRange( + check( + self._lib.Highs_addCols( self._model, - self._col_committed, - self._col_committed + n - 1, - self._c_col_int, - )) + n, + self._c_col_obj, + self._c_col_lb, + self._c_col_ub, + 0, + ffi.NULL, + ffi.NULL, + ffi.NULL, + ) + ) + if self._pending_int_count > 0: + check( + self._lib.Highs_changeColsIntegralityByRange( + self._model, + self._col_committed, + self._col_committed + n - 1, + self._c_col_int, + ) + ) for col_idx, name_bytes in self._col_names: check(self._lib.Highs_passColName(self._model, col_idx, name_bytes)) - self._col_committed += n - self._col_fill = 0 + self._col_committed += n + self._col_fill = 0 self._pending_int_count = 0 self._col_names.clear() @@ -837,17 +845,23 @@ def _flush_rows(self: "SolverHighs"): n = self._row_fill if n == 0: return - check(self._lib.Highs_addRows( - self._model, n, - self._c_row_lb, self._c_row_ub, - self._row_nz_fill, - self._c_row_starts, self._c_row_indices, self._c_row_values, - )) + check( + self._lib.Highs_addRows( + self._model, + n, + self._c_row_lb, + self._c_row_ub, + self._row_nz_fill, + self._c_row_starts, + self._c_row_indices, + self._c_row_values, + ) + ) for row_idx, name_bytes in self._row_names: self._lib.Highs_passRowName(self._model, row_idx, name_bytes) self._row_committed += n - self._row_fill = 0 - self._row_nz_fill = 0 + self._row_fill = 0 + self._row_nz_fill = 0 self._row_names.clear() def _flush(self: "SolverHighs"): @@ -970,13 +984,13 @@ def add_var( # Normal path: buffer the column. if self._col_fill == self._col_cap: self._grow_cols() - self._c_col_lb[self._col_fill] = lb - self._c_col_ub[self._col_fill] = ub + self._c_col_lb[self._col_fill] = lb + self._c_col_ub[self._col_fill] = ub self._c_col_obj[self._col_fill] = obj self._c_col_int[self._col_fill] = self._var_type_map[var_type] self._col_fill += 1 if var_type != mip.CONTINUOUS: - self._num_int_vars += 1 + self._num_int_vars += 1 self._pending_int_count += 1 if name: self._col_names.append((col, name.encode("utf-8"))) @@ -1003,12 +1017,12 @@ def add_constr(self: "SolverHighs", lin_expr: "mip.LinExpr", name: str = ""): if self._row_nz_fill + num_nz > self._row_nz_cap: self._grow_row_nz(num_nz) - self._c_row_lb[self._row_fill] = lower - self._c_row_ub[self._row_fill] = upper + self._c_row_lb[self._row_fill] = lower + self._c_row_ub[self._row_fill] = upper self._c_row_starts[self._row_fill] = self._row_nz_fill for var, coef in lin_expr.expr.items(): self._c_row_indices[self._row_nz_fill] = var.idx - self._c_row_values[self._row_nz_fill] = coef + self._c_row_values[self._row_nz_fill] = coef self._row_nz_fill += 1 self._row_fill += 1 @@ -1301,7 +1315,9 @@ def set_max_iter(self: "SolverHighs", max_iter: int): def get_max_nodes_same_incumbent(self: "SolverHighs") -> int: return self._get_int_option_value("mip_max_stall_nodes") - def set_max_nodes_same_incumbent(self: "SolverHighs", max_nodes_same_incumbent: int): + def set_max_nodes_same_incumbent( + self: "SolverHighs", max_nodes_same_incumbent: int + ): self._set_int_option_value("mip_max_stall_nodes", max_nodes_same_incumbent) def set_num_threads(self: "SolverHighs", threads: int): @@ -1794,7 +1810,9 @@ def _get_dual_solution_status(self: "SolverHighs"): return self._get_int_info_value("dual_solution_status") def _has_dual_solution(self: "SolverHighs"): - return self._get_dual_solution_status() == self._lib.kHighsSolutionStatusFeasible + return ( + self._get_dual_solution_status() == self._lib.kHighsSolutionStatusFeasible + ) def get_status(self: "SolverHighs") -> mip.OptimizationStatus: OS = mip.OptimizationStatus @@ -1834,7 +1852,9 @@ def get_status(self: "SolverHighs") -> mip.OptimizationStatus: status = status_map[highs_status] if status is None: # depends on solution status - status = OS.FEASIBLE if self._has_primal_solution() else OS.NO_SOLUTION_FOUND + status = ( + OS.FEASIBLE if self._has_primal_solution() else OS.NO_SOLUTION_FOUND + ) return status def cgraph_density(self: "SolverHighs") -> float: From 3a906e9c7e12a070029eda4cec8b97bdce9170ce Mon Sep 17 00:00:00 2001 From: Haroldo Santos Date: Mon, 8 Jun 2026 20:48:53 -0400 Subject: [PATCH 4/8] style: apply black 22.3.0 formatting (matching pre-commit config) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- mip/cbc.py | 34 +++++++++++++++++++++------------- mip/gurobi.py | 22 +++++++++++++--------- mip/highs.py | 18 +++++++----------- 3 files changed, 41 insertions(+), 33 deletions(-) diff --git a/mip/cbc.py b/mip/cbc.py index 57931b3d..f3fe1123 100644 --- a/mip/cbc.py +++ b/mip/cbc.py @@ -91,7 +91,8 @@ has_cbc = False if has_cbc: - ffi.cdef(""" + ffi.cdef( + """ typedef int(*cbc_progress_callback)(void *model, int phase, int step, @@ -540,7 +541,8 @@ const char *Cbc_featureName(int i); void Cbc_reset(Cbc_Model *model); - """) + """ + ) CHAR_ONE = "{}".format(chr(1)).encode("utf-8") CHAR_ZERO = "\0".encode("utf-8") @@ -1014,10 +1016,12 @@ def cbc_get_osi_name_indexes(osi_solver) -> Dict[str, int]: return nameIdx # progress callback - @ffi.callback(""" + @ffi.callback( + """ int (void *, int, int, const char *, double, double, double, int, int *, void *) - """) + """ + ) def cbc_progress_callback( model, phase: int, @@ -1040,9 +1044,11 @@ def cbc_inc_callback( return # cut callback - @ffi.callback(""" + @ffi.callback( + """ void (void *osi_solver, void *osi_cuts, void *app_data, int level, int npass) - """) + """ + ) def cbc_cut_callback(osi_solver, osi_cuts, app_data, depth, npass): if ( osi_solver == ffi.NULL @@ -1468,8 +1474,10 @@ def write(self, file_path: str): elif ".bas" in file_path.lower(): cbclib.Cbc_writeBasis(self._model, fpstr, CHAR_ONE, 2) else: - raise ValueError("Enter a valid extension (.lp, .mps or .bas) \ - to indicate the file format") + raise ValueError( + "Enter a valid extension (.lp, .mps or .bas) \ + to indicate the file format" + ) def read(self, file_path: str) -> None: if not isfile(file_path): @@ -1498,8 +1506,10 @@ def read(self, file_path: str) -> None: logger.info("Optimal LP basis successfully loaded.") else: - raise ValueError("Enter a valid extension (.lp, .mps or .bas) \ - to indicate the file format") + raise ValueError( + "Enter a valid extension (.lp, .mps or .bas) \ + to indicate the file format" + ) def set_start(self, start: List[Tuple[Var, numbers.Real]]) -> None: # Augment start list with default zero values for absent non-continuous variables @@ -1789,9 +1799,7 @@ def add_var( numnz = len(column.constrs) isInt = ( - CHAR_ONE - if var_type.upper() == "B" or var_type.upper() == "I" - else CHAR_ZERO + CHAR_ONE if var_type.upper() == "B" or var_type.upper() == "I" else CHAR_ZERO ) cbclib.Osi_addCol( self.osi, diff --git a/mip/gurobi.py b/mip/gurobi.py index 944292b5..e332c12f 100644 --- a/mip/gurobi.py +++ b/mip/gurobi.py @@ -99,7 +99,8 @@ has_gurobi = True grblib = ffi.dlopen(lib_path) - ffi.cdef(""" + ffi.cdef( + """ typedef struct _GRBmodel GRBmodel; typedef struct _GRBenv GRBenv; @@ -242,7 +243,8 @@ int GRBdelconstrs (GRBmodel *model, int numdel, int *ind); int GRBreset (GRBmodel *model, int clearall); - """) + """ + ) GRBloadenv = grblib.GRBloadenv GRBnewmodel = grblib.GRBnewmodel @@ -339,11 +341,13 @@ def __init__(self, model: Model, name: str, sense: str, modelp: CData = ffi.NULL """modelp should be informed if a model should not be created, but only allow access to an existing one""" if not has_gurobi: - raise FileNotFoundError("""Gurobi not found. Plase check if the + raise FileNotFoundError( + """Gurobi not found. Plase check if the Gurobi dynamic loadable library is reachable or define the environment variable GUROBI_HOME indicating the gurobi installation path. - """) + """ + ) super().__init__(model, name, sense) @@ -590,9 +594,11 @@ def optimize( ) -> OptimizationStatus: # todo add branch_selector and incumbent_updater callbacks - @ffi.callback(""" + @ffi.callback( + """ int (GRBmodel *, void *, int, void *) - """) + """ + ) def callback( p_model: CData, p_cbdata: CData, where: int, p_usrdata: CData ) -> int: @@ -1020,9 +1026,7 @@ def constr_get_expr(self, constr: Constr) -> LinExpr: nnz = ffi.new("int *") # obtaining number of non-zeros - st = GRBgetconstrs( - self._model, nnz, ffi.NULL, ffi.NULL, ffi.NULL, constr.idx, 1 - ) + st = GRBgetconstrs(self._model, nnz, ffi.NULL, ffi.NULL, ffi.NULL, constr.idx, 1) if st != 0: raise ParameterNotAvailable( "Could not get info for constraint {}".format(constr.idx) diff --git a/mip/highs.py b/mip/highs.py index eba17d9d..c26b7b97 100644 --- a/mip/highs.py +++ b/mip/highs.py @@ -51,7 +51,8 @@ has_highs = False if has_highs: - ffi.cdef(""" + ffi.cdef( + """ /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ /* */ /* This file is part of the HiGHS linear optimization suite */ @@ -674,7 +675,8 @@ const char* value); HighsInt Highs_getScaledModelStatus(const void* highs); - """) + """ + ) STATUS_ERROR = highslib.kHighsStatusError @@ -1315,9 +1317,7 @@ def set_max_iter(self: "SolverHighs", max_iter: int): def get_max_nodes_same_incumbent(self: "SolverHighs") -> int: return self._get_int_option_value("mip_max_stall_nodes") - def set_max_nodes_same_incumbent( - self: "SolverHighs", max_nodes_same_incumbent: int - ): + def set_max_nodes_same_incumbent(self: "SolverHighs", max_nodes_same_incumbent: int): self._set_int_option_value("mip_max_stall_nodes", max_nodes_same_incumbent) def set_num_threads(self: "SolverHighs", threads: int): @@ -1810,9 +1810,7 @@ def _get_dual_solution_status(self: "SolverHighs"): return self._get_int_info_value("dual_solution_status") def _has_dual_solution(self: "SolverHighs"): - return ( - self._get_dual_solution_status() == self._lib.kHighsSolutionStatusFeasible - ) + return self._get_dual_solution_status() == self._lib.kHighsSolutionStatusFeasible def get_status(self: "SolverHighs") -> mip.OptimizationStatus: OS = mip.OptimizationStatus @@ -1852,9 +1850,7 @@ def get_status(self: "SolverHighs") -> mip.OptimizationStatus: status = status_map[highs_status] if status is None: # depends on solution status - status = ( - OS.FEASIBLE if self._has_primal_solution() else OS.NO_SOLUTION_FOUND - ) + status = OS.FEASIBLE if self._has_primal_solution() else OS.NO_SOLUTION_FOUND return status def cgraph_density(self: "SolverHighs") -> float: From 57f1881660486d54992ccc3bd00861136f4bacdc Mon Sep 17 00:00:00 2001 From: Haroldo Santos Date: Mon, 8 Jun 2026 21:01:48 -0400 Subject: [PATCH 5/8] fix: gracefully disable HiGHS on Windows when C API is not accessible highspy's _core.pyd on Windows only exports the Python init symbol (PyInit__core) -- the C API functions like Highs_create are compiled in but not exported as DLL symbols. On Linux/macOS all symbols in a shared library are visible, so ffi.dlopen() works there. After loading the library via ffi.dlopen(), test whether Highs_create is actually accessible. If not (Windows + highspy), set has_highs=False so that all HiGHS tests are skipped gracefully instead of crashing with 'function/symbol not found' errors. Users on Windows who need HiGHS can install highsbox (which ships a proper highs.dll with exported C symbols) and set PMIP_HIGHS_LIBRARY to point to it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- mip/highs.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/mip/highs.py b/mip/highs.py index c26b7b97..fddf03ec 100644 --- a/mip/highs.py +++ b/mip/highs.py @@ -22,7 +22,10 @@ logger.debug(f"Choosing HiGHS library {libfile} via {ENV_KEY}.") else: # Prefer highspy (official HiGHS package): its _core extension module - # statically links the full HiGHS C API and exports all symbols. + # contains the full HiGHS C API. On Linux/macOS all symbols are + # visible in the shared library; on Windows only the Python init + # symbol is exported from the .pyd, so the C API is not accessible + # via dlopen there. We detect that below after loading. try: import highspy._core as _highs_core @@ -678,6 +681,23 @@ """ ) + # On Windows, highspy's _core.pyd does not export C symbols (only the + # Python init function is exported from a .pyd). Verify the C API is + # actually accessible; if not, disable HiGHS gracefully. + try: + _ = highslib.Highs_create + except AttributeError: + logger.error( + "HiGHS C API symbols not accessible in the loaded library " + f"({libfile!r}). " + "This typically happens on Windows with the highspy package " + "because its .pyd does not export C symbols. " + "Install highsbox for Windows support, or set PMIP_HIGHS_LIBRARY " + "to point to a highs.dll that exports the C API." + ) + has_highs = False + +if has_highs: STATUS_ERROR = highslib.kHighsStatusError # Initial capacities for the pending col/row caches (grows geometrically). From c7cdea9a51f437969518d3873affb35883f68a19 Mon Sep 17 00:00:00 2001 From: Haroldo Santos Date: Mon, 8 Jun 2026 21:05:13 -0400 Subject: [PATCH 6/8] fix: use highsbox as fallback for HiGHS C API on Windows highspy's _core.pyd on Windows does not export C symbols (only PyInit_* is exported from a Windows DLL by default). On Linux/macOS the .so exports all symbols so ffi.dlopen + Highs_create works fine; on Windows it raises AttributeError. Changes: - After dlopen succeeds, probe Highs_create accessibility. If it fails (Windows + highspy), automatically retry with highsbox's highs.dll. - highsbox is added as a Windows-only optional dependency in [highs]: highsbox>=1.9.0; sys_platform == 'win32' so 'pip install mip[highs]' on Windows installs both highspy (Python bindings) and highsbox (C API DLL), giving full HiGHS functionality. - Hoist the highsbox path resolver to a module-level helper function _get_highsbox_libfile() so it is reachable both at initial load and at the fallback probe point. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- mip/highs.py | 64 +++++++++++++++++++++++++++++++------------------- pyproject.toml | 2 +- 2 files changed, 41 insertions(+), 25 deletions(-) diff --git a/mip/highs.py b/mip/highs.py index fddf03ec..8ae192be 100644 --- a/mip/highs.py +++ b/mip/highs.py @@ -14,6 +14,24 @@ # try loading the solver library ffi = cffi.FFI() + + +def _get_highsbox_libfile(): + """Return the path to the highsbox HiGHS shared library.""" + import highsbox + + root = highsbox.highs_dist_dir() + platform = sys.platform.lower() + if "linux" in platform: + return os.path.join(root, "lib", "libhighs.so") + elif platform.startswith("win"): + return os.path.join(root, "bin", "highs.dll") + elif any(platform.startswith(p) for p in ("darwin", "macos")): + return os.path.join(root, "lib", "libhighs.dylib") + else: + raise NotImplementedError(f"{sys.platform} not supported!") + + try: ENV_KEY = "PMIP_HIGHS_LIBRARY" if ENV_KEY in os.environ: @@ -25,26 +43,14 @@ # contains the full HiGHS C API. On Linux/macOS all symbols are # visible in the shared library; on Windows only the Python init # symbol is exported from the .pyd, so the C API is not accessible - # via dlopen there. We detect that below after loading. + # via dlopen there. We detect that below and fall back to highsbox. try: import highspy._core as _highs_core libfile = _highs_core.__file__ logger.debug(f"Choosing HiGHS library {libfile} via highspy package.") except ImportError: - # Fall back to highsbox (third-party binary distribution). - import highsbox - - root = highsbox.highs_dist_dir() - platform = sys.platform.lower() - if "linux" in platform: - libfile = os.path.join(root, "lib", "libhighs.so") - elif platform.startswith("win"): - libfile = os.path.join(root, "bin", "highs.dll") - elif any(platform.startswith(p) for p in ("darwin", "macos")): - libfile = os.path.join(root, "lib", "libhighs.dylib") - else: - raise NotImplementedError(f"{sys.platform} not supported!") + libfile = _get_highsbox_libfile() logger.debug(f"Choosing HiGHS library {libfile} via highsbox package.") highslib = ffi.dlopen(libfile) @@ -682,20 +688,30 @@ ) # On Windows, highspy's _core.pyd does not export C symbols (only the - # Python init function is exported from a .pyd). Verify the C API is - # actually accessible; if not, disable HiGHS gracefully. + # Python init function is exported from a .pyd). Detect this and + # automatically fall back to highsbox, which ships a proper highs.dll. try: _ = highslib.Highs_create except AttributeError: - logger.error( - "HiGHS C API symbols not accessible in the loaded library " - f"({libfile!r}). " - "This typically happens on Windows with the highspy package " - "because its .pyd does not export C symbols. " - "Install highsbox for Windows support, or set PMIP_HIGHS_LIBRARY " - "to point to a highs.dll that exports the C API." + logger.warning( + f"HiGHS C API not accessible via {libfile!r} " + "(typical on Windows with highspy). " + "Falling back to highsbox." ) - has_highs = False + try: + libfile = _get_highsbox_libfile() + highslib = ffi.dlopen(libfile) + _ = highslib.Highs_create # verify symbols are accessible + logger.debug( + f"Choosing HiGHS library {libfile} via highsbox package (fallback)." + ) + except Exception as e: + logger.error( + f"highsbox fallback also failed: {e}. " + "HiGHS will not be available. " + "Install highsbox (pip install highsbox) for HiGHS support on Windows." + ) + has_highs = False if has_highs: STATUS_ERROR = highslib.kHighsStatusError diff --git a/pyproject.toml b/pyproject.toml index cebbe6c8..7821b925 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ numpy = [ "numpy>=1.25,<3", ] gurobi = ["gurobipy>=10"] -highs = ["highspy>=1.9.0"] +highs = ["highspy>=1.9.0", "highsbox>=1.9.0; sys_platform == 'win32'"] test = [ "pytest>=7.4,<9", "networkx>=2.8.8,<4", From 10854ab6665d468e2c6620728b540c0b40e2e6e6 Mon Sep 17 00:00:00 2001 From: Haroldo Santos Date: Mon, 8 Jun 2026 21:12:08 -0400 Subject: [PATCH 7/8] ci: cancel in-progress runs when new commit is pushed Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/github-ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/github-ci.yml b/.github/workflows/github-ci.yml index 96ea6da1..ecceee22 100644 --- a/.github/workflows/github-ci.yml +++ b/.github/workflows/github-ci.yml @@ -2,6 +2,10 @@ name: CI on: [push, pull_request] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: pre-commit: From adec698cccd8df8e8096603d3d925047a28ed716 Mon Sep 17 00:00:00 2001 From: Haroldo Santos Date: Mon, 8 Jun 2026 21:50:55 -0400 Subject: [PATCH 8/8] fix: guard __del__ against partially-constructed objects (PyPy safe) On PyPy, the GC may call __del__ on an object whose __init__ raised before all attributes were set (e.g. SolverGurobi.__init__ raises early when Gurobi is not installed, before _ownsModel/_venv_loaded are set). CPython avoids this because reference-counting drops the object immediately; PyPy's tracing GC processes it later. Replace bare attribute access in __del__ with getattr(..., default) in: - SolverGurobi.__del__: guard _ownsModel and _venv_loaded - SolverHighs.__del__: guard _model (avoid calling Highs_destroy(NULL)) - SolverCbc.__del__: guard _model (avoid calling Cbc_deleteModel(NULL)) - SolverOsi.__del__: guard owns_solver Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- mip/cbc.py | 5 +++-- mip/gurobi.py | 7 ++++--- mip/highs.py | 3 ++- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/mip/cbc.py b/mip/cbc.py index f3fe1123..3d25d661 100644 --- a/mip/cbc.py +++ b/mip/cbc.py @@ -1645,7 +1645,8 @@ def remove_vars(self, varsList: List[int]): cbclib.Cbc_deleteCols(self._model, len(varsList), idx) def __del__(self): - cbclib.Cbc_deleteModel(self._model) + if getattr(self, "_model", None) is not None: + cbclib.Cbc_deleteModel(self._model) def get_problem_name(self) -> str: namep = self.__name_space @@ -1776,7 +1777,7 @@ def __clear_sol(self: "SolverOsi"): self.__obj_val = None def __del__(self): - if self.owns_solver: + if getattr(self, "owns_solver", False): cbclib.Osi_deleteSolver(self.osi) def add_var( diff --git a/mip/gurobi.py b/mip/gurobi.py index e332c12f..be2f8b34 100644 --- a/mip/gurobi.py +++ b/mip/gurobi.py @@ -428,11 +428,12 @@ def __clear_sol(self): self.__obj_val = None def __del__(self): - # freeing Gurobi model and environment - if self._ownsModel: + # Guard against partially-constructed objects (e.g. PyPy GC may call + # __del__ even if __init__ raised before setting these attributes). + if getattr(self, "_ownsModel", False): if self._model: GRBfreemodel(self._model) - if self._env and self._venv_loaded: + if self._env and getattr(self, "_venv_loaded", False): GRBfreeenv(self._env) def add_var( diff --git a/mip/highs.py b/mip/highs.py index 8ae192be..2c965df1 100644 --- a/mip/highs.py +++ b/mip/highs.py @@ -800,7 +800,8 @@ def __init__(self, model: mip.Model, name: str, sense: str): def __del__(self): self._name_buffer = None - self._lib.Highs_destroy(self._model) + if getattr(self, "_model", None) is not None: + self._lib.Highs_destroy(self._model) # ── Cache grow / flush helpers ─────────────────────────────────────────