diff --git a/README.md b/README.md index a96e36f00..5ce39ce9c 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ A schedule the solver stopped on at the limit is reported as `Feasible` rather t ### Solver -`OPTIMIZER_SOLVER` selects the MILP solver: `cbc` (default), the CBC binary pulp resolves from `PATH` or `OPTIMIZER_CBC_PATH`, the 2.10.10 release build in the image; `highs`, HiGHS run in process through `highspy`; or `hybrid`, CBC for the joint probe and HiGHS for the cost, tie break and continuity stages. HiGHS is asked to prove to the same absolute gap as CBC, its own relative gap of 1e-4 is switched off, and its model is built in bulk (`optimizer/highs.py`, coin-or/pulp#957). On HiGHS the continuity stage is interrupted once it runs `CUTOFF_MARGIN` past its limit, keeping its incumbent; CBC is left to its own time limit. +`OPTIMIZER_SOLVER` selects the MILP solver: `cbc` (default), the CBC binary pulp resolves from `PATH` or `OPTIMIZER_CBC_PATH`, the 2.10.10 release build in the image; `highs`, HiGHS run in process through `highspy`; or `hybrid`, CBC for the joint probe and HiGHS for the cost, tie break and continuity stages. HiGHS is asked to prove to the same absolute gap as CBC, its own relative gap of 1e-4 is switched off. On HiGHS the continuity stage is interrupted once it runs `CUTOFF_MARGIN` past its limit, keeping its incumbent; CBC is left to its own time limit. Measured over 170 captured requests, 150 of them slow ones: HiGHS alone is the fastest in sum but proves 8 of the 54 mid-sized requests, which CBC solves in 1 to 5 s, 2.5 to 5 times slower, overruns the probe and pays a 6 to 8 s split for each, which lifted production p99 from 6.4 to 8.5 s. `hybrid` matches CBC on those (p90 2.05 against 2.01 s) and keeps HiGHS's quality on the slow ones (11 CBC Not Solved become 1), at the price of the 19 slow requests HiGHS proves joint in about 1.3 s, which CBC cannot prove in the probe and the hybrid splits instead; its total time is CBC's. diff --git a/pyproject.toml b/pyproject.toml index e7a73657c..5fc84eca7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "matplotlib", "numpy>=2.5.4", "pandas>=2.3.3", - "pulp>=4.0.0", + "pulp>=4.0.1", "pydantic>=2.14.1", "pydantic-settings>=2.15.0", "pyjwt>=2.15.1", diff --git a/src/optimizer/highs.py b/src/optimizer/highs.py deleted file mode 100644 index 7b588be04..000000000 --- a/src/optimizer/highs.py +++ /dev/null @@ -1,74 +0,0 @@ -"""pulp's HiGHS solver with the model built in bulk. - -pulp 4.0.0 feeds HiGHS one addCol and one addRow call per column and row, and re-reads the -objective for every column. On this model (3k columns, 3.5k rows) that build costs 0.028 s per -solve natively, 6.5 % of a HiGHS request at the median and 15 % at p90 in the amd64 benchmark, -and every stage of a request pays it again. One addCols and one addRows call bring it to -0.004-0.006 s. This is coin-or/pulp#957 verbatim, kept here until a pulp release carries it; -dropping this module and the BulkHiGHS reference in Optimizer._solver is then the whole change. -""" -import warnings -from math import inf - -import highspy -import numpy as np -import pulp -from pulp import constants - - -class BulkHiGHS(pulp.HiGHS): - def buildSolverModel(self, lp): - obj_mult = -1 if lp.sense == constants.LpMaximize else 1 - - exported_vars = lp.exported_variables() - id_to_col = {v.id: j for j, v in enumerate(exported_vars)} - - cost = np.zeros(len(exported_vars)) - for var, coefficient in lp.objective.items(): - j = id_to_col.get(var.id) - if j is not None: - cost[j] = obj_mult * coefficient - lower = np.array([-inf if var.lowBound is None else var.lowBound for var in exported_vars], dtype=np.float64) - upper = np.array([inf if var.upBound is None else var.upBound for var in exported_vars], dtype=np.float64) - empty = np.zeros(0, dtype=np.int32) - lp.solverModel.addCols(len(exported_vars), cost, lower, upper, 0, empty, empty, np.zeros(0)) - - if self.mip: - integer = np.fromiter((j for j, var in enumerate(exported_vars) if var.cat == constants.LpInteger), - dtype=np.int32) - if len(integer): - lp.solverModel.changeColsIntegrality(len(integer), integer, - np.full(len(integer), highspy.HighsVarType.kInteger)) - - constraints = lp.constraints() - starts: list[int] = [] - indices: list[int] = [] - coefficients: list[float] = [] - row_lower: list[float] = [] - row_upper: list[float] = [] - for constraint in constraints: - starts.append(len(indices)) - for var, coefficient in constraint.items(): - if coefficient != 0: - indices.append(id_to_col[var.id]) - coefficients.append(coefficient) - lb = constraint.getLb() - ub = constraint.getUb() - row_lower.append(-inf if lb is None else lb) - row_upper.append(inf if ub is None else ub) - lp.solverModel.addRows(len(constraints), - np.array(row_lower, dtype=np.float64), np.array(row_upper, dtype=np.float64), - len(indices), np.array(starts, dtype=np.int32), np.array(indices, dtype=np.int32), - np.array(coefficients, dtype=np.float64)) - - if self.optionsDict.get("warmStart", False): - indices = [] - values: list[float] = [] - for j, var in enumerate(exported_vars): - if var.varValue is not None: - indices.append(j) - values.append(var.varValue) - if not indices: - warnings.warn("No variable with value found: warmStart aborted") - return - lp.solverModel.setSolution(len(indices), indices, values) diff --git a/src/optimizer/optimizer.py b/src/optimizer/optimizer.py index f595b8515..30a7a20fe 100644 --- a/src/optimizer/optimizer.py +++ b/src/optimizer/optimizer.py @@ -1,5 +1,4 @@ import os -import re import threading import time from contextlib import contextmanager @@ -12,7 +11,6 @@ import numpy as np import pulp -from .highs import BulkHiGHS from .settings import OptimizerSettings @@ -838,7 +836,7 @@ def _stage_solver(self) -> str: def _solver(self, tmpdir, **options): """The solver of the current stage with the shared settings. CBC writes its scratch files to tmpdir.""" if self._stage_solver() == 'highs': - # the cost stage reads its gap from the stats, not a CBC log, see _stage_gap + # HiGHS reports its bound in the stats, no log to parse, see _stage_gap options.pop('logPath', None) # HiGHS also stops at a relative gap of 1e-4 by default, CBC only on the absolute one options.setdefault('gapRel', 0) @@ -847,7 +845,7 @@ def _solver(self, tmpdir, **options): options.update(callbackTuple=(self._interrupt, None), callbacksToActivate=[types.kCallbackMipInterrupt, types.kCallbackSimplexInterrupt, types.kCallbackIpmInterrupt]) - return BulkHiGHS(msg=False, threads=self.settings.num_threads, **options) + return pulp.HiGHS(msg=False, threads=self.settings.num_threads, **options) # pulp resolves the binary unless one is configured: a cbc on PATH, the 2.10 release build # the image installs. The cbcbox wheel of the pulp cbc extra is deliberately not used, its @@ -883,20 +881,20 @@ def _hard_limit(self, seconds: float): finally: self._cutoff = None - def _stage_gap(self, stats, log_path, scale) -> float | None: - """Distance between the cost stage's schedule and the solver's bound, in currency.""" - if self._stage_solver() != 'highs': - return self._cbc_gap(log_path, scale) - # pulp 4 leaves best_bound unset for HiGHS, the solver model has it. A proven solve has - # nothing left open, the way CBC's log prints no bound for one + @staticmethod + def _stage_gap(stats, scale) -> float | None: + """Distance between the cost stage's schedule and the solver's bound, in currency. + + HiGHS reports its bound natively, CBC's is parsed from the log pulp is handed as logPath. + A proven solve has nothing left open, the way CBC's log prints no bound for one. + """ if not stats.has_solution: return None if stats.status in PROVEN: return 0. - info = self.problem.solverModel.getInfo() - if not (isfinite(info.objective_function_value) and isfinite(info.mip_dual_bound)): + if stats.best_bound is None: return None - return abs(info.mip_dual_bound - info.objective_function_value) / scale + return abs(stats.best_bound - stats.objective) / scale @contextmanager def _timed(self, stage): @@ -913,26 +911,6 @@ def _timed(self, stage): self.stage_seconds[stage] = round( self.stage_seconds.get(stage, 0.) + time.monotonic() - started, 4) - @staticmethod - def _cbc_gap(log_path, scale) -> float | None: - """Distance between CBC's schedule and its bound, from the log, in currency. - - The solution file carries no bound, only the log does: a proven solve prints none and the - gap is zero, a stopped one prints the bound it reached. - """ - # no log, no solver run behind the values, see the faked stages in the tests - if not os.path.exists(log_path): - return None - with open(log_path) as log: - text = log.read() - value = re.search(r'^Objective value:\s+(\S+)', text, re.MULTILINE) - if value is None: - return None - bound = re.search(r'^(?:Lower|Upper) bound:\s+(\S+)', text, re.MULTILINE) - if bound is None: - return 0. - return abs(float(bound.group(1)) - float(value.group(1))) / scale - def _pin_integers(self): """Freeze every integer variable on the value it currently holds, undo data returned. @@ -1271,10 +1249,10 @@ def _probe_then_split(self, tmpdir, deadline) -> None: reserve = (0. if self.settings.time_limit is None else self.settings.time_limit * PREFERENCE_TIME_SHARE) remaining = None if deadline is None else max(min(COST_TIME_LIMIT, deadline - reserve - time.monotonic()), 0.1) - log_path = os.path.join(tmpdir, 'cost.log') with self._timed('cost'): - self.stats = self.problem.solve(self._solver(tmpdir, timeLimit=remaining, logPath=log_path, gapAbs=scaled_gap)) - self.cost_stage_gap = self._stage_gap(self.stats, log_path, scale) + self.stats = self.problem.solve(self._solver(tmpdir, timeLimit=remaining, gapAbs=scaled_gap, + logPath=os.path.join(tmpdir, 'cost.log'))) + self.cost_stage_gap = self._stage_gap(self.stats, scale) if self.stats.has_solution and self._is_feasible(): # the cost stage is allowed to stop on its gap, so proven is not required of it, only diff --git a/tests/test_bulk_highs.py b/tests/test_bulk_highs.py deleted file mode 100644 index fd6a89690..000000000 --- a/tests/test_bulk_highs.py +++ /dev/null @@ -1,36 +0,0 @@ -"""BulkHiGHS hands HiGHS the same model as pulp's own HiGHS, column by column and row by row.""" -import highspy -import numpy as np -import pulp -import pytest -from test_objective_split import build - -from optimizer.highs import BulkHiGHS - - -def arrays(solver, case): - optimizer = build(case) - optimizer.create_model() - problem = optimizer.problem - problem.solverModel = highspy.Highs() - solver.buildSolverModel(problem) - lp = problem.solverModel.getLp() - return { - 'cost': list(lp.col_cost_), 'col_lower': list(lp.col_lower_), 'col_upper': list(lp.col_upper_), - 'integrality': list(lp.integrality_), 'row_lower': list(lp.row_lower_), 'row_upper': list(lp.row_upper_), - 'start': list(lp.a_matrix_.start_), 'index': list(lp.a_matrix_.index_), 'value': list(lp.a_matrix_.value_), - 'sense': lp.sense_, 'num_col': lp.num_col_, 'num_row': lp.num_row_, - } - - -@pytest.mark.parametrize('case', ['012-early-charging-not-perfect', '026-attenuate-grid-peaks', - '028-attenuate-grid-peaks-long-horizon']) -def test_same_model_as_pulp(case): - stock = arrays(pulp.HiGHS(msg=False), case) - bulk = arrays(BulkHiGHS(msg=False), case) - - assert bulk.keys() == stock.keys() - for key, value in stock.items(): - assert bulk[key] == value, key - assert stock['num_col'] > 10 and stock['num_row'] > 10 # a real model, not an empty one - assert np.count_nonzero(np.array(stock['integrality']) == highspy.HighsVarType.kInteger) > 0 diff --git a/tests/test_highs.py b/tests/test_highs.py index faf6fbf5c..a4451615a 100644 --- a/tests/test_highs.py +++ b/tests/test_highs.py @@ -34,8 +34,7 @@ def test_stored_optimum_without_gap(case): assert result['objective_value'] == pytest.approx(expected['objective_value'], abs=1e-4, rel=1e-5) -def test_cost_stage_gap_comes_from_the_solver_model(): - # pulp 4 leaves best_bound unset for HiGHS, so the gap is read from HiGHS itself +def test_cost_stage_gap_comes_from_the_stats(): optimizer = highs('026-attenuate-grid-peaks', probe_seconds=0) optimizer.solve() @@ -43,6 +42,21 @@ def test_cost_stage_gap_comes_from_the_solver_model(): assert optimizer.cost_stage_gap == 0 # proven, nothing left open +def test_stopped_cost_stage_reports_its_gap(): + # the bound comes back in the problem's sense: a maximised cost objective with a bound above it + optimizer = highs('028-attenuate-grid-peaks-long-horizon') + optimizer.create_model() + scale = optimizer.objective_scale + optimizer.problem.setObjective(optimizer.cost_objective * scale) + + with TemporaryDirectory() as tmpdir, optimizer._timed('cost'): + stats = optimizer.problem.solve(optimizer._solver(tmpdir, timeLimit=0.5)) + + assert stats.status == pulp.LpSolveStatus.TimeLimit + assert stats.best_bound > stats.objective + assert optimizer._stage_gap(stats, scale) == (stats.best_bound - stats.objective) / scale + + def test_overrunning_solve_is_interrupted_and_keeps_its_incumbent(): # a long horizon HiGHS needs about 3 s for, cut off after a tenth of a second optimizer = highs('028-attenuate-grid-peaks-long-horizon') diff --git a/tests/test_hybrid.py b/tests/test_hybrid.py index 26483e9e1..d080f601f 100644 --- a/tests/test_hybrid.py +++ b/tests/test_hybrid.py @@ -7,7 +7,6 @@ import pytest from test_objective_split import build -from optimizer.highs import BulkHiGHS from optimizer.optimizer import Optimizer from optimizer.settings import OptimizerSettings @@ -54,9 +53,9 @@ def test_split_runs_on_highs(solvers): assert optimizer.solve_path == 'split' stages = dict(solvers) - assert stages['cost'] is BulkHiGHS - assert stages['tie_break'] is BulkHiGHS - # the gap of a HiGHS cost stage comes from the solver model, not a CBC log + assert stages['cost'] is pulp.HiGHS + assert stages['tie_break'] is pulp.HiGHS + # the gap of a HiGHS cost stage comes from the stats, not a CBC log assert optimizer.cost_stage_gap == 0 diff --git a/uv.lock b/uv.lock index 1489d4d0d..4a4665c6d 100644 --- a/uv.lock +++ b/uv.lock @@ -833,7 +833,7 @@ requires-dist = [ { name = "matplotlib" }, { name = "numpy", specifier = ">=2.5.4" }, { name = "pandas", specifier = ">=2.3.3" }, - { name = "pulp", specifier = ">=4.0.0" }, + { name = "pulp", specifier = ">=4.0.1" }, { name = "pydantic", specifier = ">=2.14.1" }, { name = "pydantic-settings", specifier = ">=2.15.0" }, { name = "pyjwt", specifier = ">=2.15.1" }, @@ -974,28 +974,28 @@ wheels = [ [[package]] name = "pulp" -version = "4.0.0" +version = "4.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "orloge" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/24/ea/7cc2de7e3d6ca3f943c8e4b4c831831bbf61e08123dc6958c19c9ea5a1ff/pulp-4.0.0.tar.gz", hash = "sha256:0f7fa97ae524ba724baa32d23390345df50ae80d42d6dbdc4d4b0f57dae8187c", size = 162406, upload-time = "2026-09-25T08:03:53.681Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/91/3ef5d6ba7a8622861d485c07c1ea38d19ad5193dd8743c9e43093f8511e4/pulp-4.0.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3935d8fec4daa645defa406c5f6ea5591ddde2083ddfc3a3edfb59dd48f9100c", size = 652743, upload-time = "2026-09-25T08:02:53.99Z" }, - { url = "https://files.pythonhosted.org/packages/dd/5f/b555a789c986c929f32209e0a71dd673d093fee3c22fe6da1b6444d57aaa/pulp-4.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6bcf66da6fb7d9d76ff8e84cb1bd8a1b3f1e3fb91287a018eb59d9f4000444ca", size = 641554, upload-time = "2026-09-25T08:02:55.537Z" }, - { url = "https://files.pythonhosted.org/packages/28/da/393e92928027e5203c18b9b78fb6042655699bd8f1465bdbb102182b8118/pulp-4.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bd139d4640ec78ead2ed769a1ca3df2b8fcdd9547eadcc49972654112292497", size = 690794, upload-time = "2026-09-25T08:02:56.862Z" }, - { url = "https://files.pythonhosted.org/packages/37/5b/155c5950456ce67d5c66f9e946110eeaa69595cf4c4c8c5237249a2b666a/pulp-4.0.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f79d9222f9180a47d66b86f8a1853f2903104117120ab9f0fbac6191ccf24276", size = 702255, upload-time = "2026-09-25T08:02:58.14Z" }, - { url = "https://files.pythonhosted.org/packages/19/2e/f41e3d5c920c649401e046f6fd6a10125210438aab44e5cbd4de1d9c7d95/pulp-4.0.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:20251a0c69d5c0d507419888876ac32fb6346d172c87164e3b62a4abd3ee6a55", size = 817474, upload-time = "2026-09-25T08:02:59.703Z" }, - { url = "https://files.pythonhosted.org/packages/de/5c/c0ec8db4f4e541b20674aae28c573ef321d2fc1e9ec0535c09bc7199e6bc/pulp-4.0.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:588b2d40b5d414cbbdc33d03488b4eb3b4ab959b92de8e6c6ee92d892cff07d4", size = 724510, upload-time = "2026-09-25T08:03:00.874Z" }, - { url = "https://files.pythonhosted.org/packages/63/cf/ec91eeccbd9952dee5bc07b4a8475e295e6b8a99ab47662ae6e59dca9e55/pulp-4.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e75b1ddaae4f3e91bfed734dc99382a8c66fb22c4eb599c0213bad09a067f6ed", size = 692330, upload-time = "2026-09-25T08:03:02.268Z" }, - { url = "https://files.pythonhosted.org/packages/95/a5/c66e998e601e240485075088de3601693f7d591acb9af3f6b4fcc02c93d6/pulp-4.0.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7f4a48e0b771b6012cf34738dbf0937bbca6267986e421376eae458e6e475270", size = 733362, upload-time = "2026-09-25T08:03:03.633Z" }, - { url = "https://files.pythonhosted.org/packages/96/8f/84359102093a77f19bd80bac4596c5bd49d792eaf49dda2aeb3bfa16d7ca/pulp-4.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:24b1d3dfdfad58a1a68c368201a0466e80728c25fa49363e6b1078f1141acd36", size = 869019, upload-time = "2026-09-25T08:03:04.808Z" }, - { url = "https://files.pythonhosted.org/packages/46/a8/0a61d6a66b24b92b3c122f7e5012aa485f5d54a88f29f781c612ba376c03/pulp-4.0.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:60f2109a1b18e9e1d00e9711fd1bc42463ae2a0ddd5b1cb60d3cb78f808a1448", size = 978573, upload-time = "2026-09-25T08:03:06.562Z" }, - { url = "https://files.pythonhosted.org/packages/6d/13/460000ec4418da3327ae98cb79869615d7d55701a5eb8593190001c254dc/pulp-4.0.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1413ff0cddf4d2c0fd359929af4d911b92983831cb9456802e5a7bd3ffd6ee2a", size = 939129, upload-time = "2026-09-25T08:03:08.047Z" }, - { url = "https://files.pythonhosted.org/packages/66/99/678b05aacdde2a365ed8f25d468b7eafee8b29fa0805e4eb6b57b84bacb1/pulp-4.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2cb8d26bf71d52acf962ed0d17f27f1fa8ed015a3d47b9a1c2a5b6bbbcdb0a63", size = 903858, upload-time = "2026-09-25T08:03:09.52Z" }, - { url = "https://files.pythonhosted.org/packages/14/12/75f57eb056ec4321f439d147ec277cb47d72e145f1524a67dc5a13264eaa/pulp-4.0.0-cp313-cp313-win32.whl", hash = "sha256:454b43e2a337ce3af39280cae2663e11dc06440f05d3383b0f6b9a70487550a2", size = 525258, upload-time = "2026-09-25T08:03:10.942Z" }, - { url = "https://files.pythonhosted.org/packages/24/3c/810bf99e9238acb579b6d297623d76477b03b11861e25b0170eafb44c67a/pulp-4.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:c09472196a7304c442a6387e06e1f208e21e18be74a524c0d385f38569c5b9b5", size = 541759, upload-time = "2026-09-25T08:03:12.418Z" }, - { url = "https://files.pythonhosted.org/packages/49/29/938ea74a3d49cbc2ef4badd25c9c5060382b2074db6c8196869680fcc80a/pulp-4.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:6e16bf606dc556ff37f6868fadb4505dda2f08bfd8f8b5b7ae863011502b3db7", size = 525889, upload-time = "2026-09-25T08:03:13.697Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/6c/96/a303e7e43c66402fed1d401615eae48eda2fe5bb9da62f28c55b108b7041/pulp-4.0.1.tar.gz", hash = "sha256:976f1184a69ab2aceb12e7c14e375775f184bddb8e66ee611ebaccbcad537f8c", size = 165276, upload-time = "2026-10-14T08:08:22.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/2f/fcbb51c0863ac81d9bf10a0f82b1d424ee2d4ee0ce10284d000af2160461/pulp-4.0.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:abfbc721e531ecceb78b0223d942525759b3eec2c60e93dafa0d2e2230e40464", size = 659170, upload-time = "2026-10-14T08:07:18.325Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ed/7ce4d413315d73a92fe96cc9d26190f4e527c7754143df278396f11ac230/pulp-4.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:70913b31a7d393cef1c4f7a8c80a94337056b93835996f624e053f54e039afb7", size = 647981, upload-time = "2026-10-14T08:07:19.671Z" }, + { url = "https://files.pythonhosted.org/packages/de/ea/64a61c095ace8e88643c431cbc4480b5c27196a75f205c1a73f8ea3653c5/pulp-4.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ecb3e6c75ff6b161f7e531a630b0e3a47ff2f26e441591f4c82e94b3cfcc53", size = 698215, upload-time = "2026-10-14T08:07:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/04/36/ae469204d18a0d377eb91846fc4a36891b3ff7420cf3bbba4a6e01285710/pulp-4.0.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c1b9cb6a15fd975343d3ff3238bef1c80d0911482eeb72477d175b1b826b9bec", size = 705967, upload-time = "2026-10-14T08:07:22.697Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/7393c314a06f002027c5d6701c7292d7b40cdb8580b5d5ca87e508c8649b/pulp-4.0.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46831fe315bc2feb5ef48c1e15ca9cb29871754fcd86c672222af4675eeb5541", size = 812451, upload-time = "2026-10-14T08:07:24.574Z" }, + { url = "https://files.pythonhosted.org/packages/6f/64/9e32f38981f1afae905629097a8896510d2c94158b1093d66bae45ea59ff/pulp-4.0.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3233b330e7b2725600ce271ab1e40c209ebec2df80e3b1f4cd0006a4e0c54c3b", size = 731664, upload-time = "2026-10-14T08:07:25.903Z" }, + { url = "https://files.pythonhosted.org/packages/a0/00/57d7f3f4dc448d9a0d1c54ad9b33b715b5dcb3dc4b0a06cccf0fedf13bd0/pulp-4.0.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b210bf7b1ac4d9a204fc9539c17ac1c49748ba6c08f02e6128225dff5330ccdb", size = 695503, upload-time = "2026-10-14T08:07:27.237Z" }, + { url = "https://files.pythonhosted.org/packages/39/12/518631c4abc96f67ef002a8d96a5666a5767bd0a7777b29ba1d0a85870d5/pulp-4.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ea12167f12b8b21016ca3d9a5826070cf2189b9bb2757e4795da406ea663ff18", size = 736893, upload-time = "2026-10-14T08:07:28.483Z" }, + { url = "https://files.pythonhosted.org/packages/cd/31/b4f19cdf65587b91ad2a902b51a80e231510cebfcdba4cb4682171ae1e5e/pulp-4.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c6e1e7f8f46886fd013f544cb38a1a4271b6563aa0b79c443c2ff35a68468c89", size = 875539, upload-time = "2026-10-14T08:07:29.99Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/b1f971a67d1308eb708f4429e804801f4a8660b0f19a18f2d7dcdadbfa0e/pulp-4.0.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:f0a69654f312ded69f3a41b72c45fcfd642831938041e06e71a579b1ff18faf9", size = 980697, upload-time = "2026-10-14T08:07:31.754Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ff/53558e6b942d77573212d969ed93bdc3b3183b8f333d1f2d77933f716f0c/pulp-4.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:21726179c4c92ea877deb48118ba8d91ca20977738533247e378817063b1e747", size = 942871, upload-time = "2026-10-14T08:07:33.281Z" }, + { url = "https://files.pythonhosted.org/packages/48/a0/1ee53d5087e2d0fd63a689d2c956c955c31886d64d6d0bb5b6505cf5ba6b/pulp-4.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b386380c76b6250f77a2ccdf3547639739af687bd72e25f8e5f0b097d066506a", size = 906473, upload-time = "2026-10-14T08:07:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/35/af/be02fa394278d66bd023cc5f14bdaf6f24ac53264c9cd942727a551ff250/pulp-4.0.1-cp313-cp313-win32.whl", hash = "sha256:178961f5aeda067867c9d0418a888df83932c4421c4074befc704d10ea9ba3d0", size = 531694, upload-time = "2026-10-14T08:07:36.079Z" }, + { url = "https://files.pythonhosted.org/packages/43/42/f2523f414257bf32592950f5f24a1adab231653fddc93f100a881e64bd4e/pulp-4.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:f237415178b57335c4fcafc5b158bc50c6e56710201c7726ec0dd88f6fde8d6b", size = 548203, upload-time = "2026-10-14T08:07:37.451Z" }, + { url = "https://files.pythonhosted.org/packages/47/d5/c7e0245f68063c9af7bb53a72128537c35a78cabf65fe64f37742e2fd556/pulp-4.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:00d026cf4b86e5ff128a2813d088ab1c696829e254fbe960b1db9b1d07106bcd", size = 534131, upload-time = "2026-10-14T08:07:38.852Z" }, ] [[package]]