Repository: Gurobi/gurobi-logtools

Issue:
Live usage within a Gurobi Callback
Hi there,

I want to use the parser(s) within the Gurobi message callback (`GRB.Callback.MESSAGE`) to parse each line on the fly. In my case I want to store the parsed results as dictionaries within a document database (MongoDB). But I suspect there are more use cases to it.

A simple way to extend this functionality would be to return Truthy / Falsy objects in the `parse` function of each parser. A dictionary with the parsed information of a line of log-message is then returned instead of `True` but all queries of `bool(parser.parse(line))` still evaluate on their True / False values.

Discussion:

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4a70eb2..f371ab7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,8 @@
 ### Changed
 ### Removed
 - remove Python 3.8 support
+### Added
+- All parsers now additionally return the result of a parsed line. This enables live usage within a Gurobi Callback.
 
 ## 3.2.0 - 2024-12-19
 ### Fixed
diff --git a/src/gurobi_logtools/parsers/barrier.py b/src/gurobi_logtools/parsers/barrier.py
index 5a9e92a..572a800 100644
--- a/src/gurobi_logtools/parsers/barrier.py
+++ b/src/gurobi_logtools/parsers/barrier.py
@@ -1,4 +1,5 @@
 import re
+from typing import Union
 
 from gurobi_logtools.parsers.util import float_pattern, typeconvert_groupdict
 
@@ -47,46 +48,50 @@ class BarrierParser:
         self._progress = []
         self._started = False
 
-    def parse(self, line: str) -> bool:
+    def parse(self, line: str) -> dict[str, Union[str, int, float, None]]:
         """Parse the given log line to populate summary and progress data.
 
         Args:
             line (str): A line in the log file.
 
         Returns:
-            bool: Return True if the given line is matched by some pattern.
+           dict[str, Union[str, int, float, None]]: A dictionary containing the parsed data. Empty if the line does not
+            match any pattern.
         """
         barrier_ordering_match = BarrierParser.barrier_ordering_pattern.match(line)
         if barrier_ordering_match:
-            self._summary.update(typeconvert_groupdict(barrier_ordering_match))
-            return True
+            parse_result = typeconvert_groupdict(barrier_ordering_match)
+            self._summary.update(parse_result)
+            return parse_result.copy()
 
         if not self._started:
             match = BarrierParser.barrier_start_pattern.match(line)
             if match:
                 self._started = True
-                return True
-            return False
+                return {"Init": "barrier"}
+            return {}
 
         progress_match = BarrierParser.barrier_progress_pattern.match(line)
         if progress_match:
             entry = {"Type": "barrier"}
             entry.update(typeconvert_groupdict(progress_match))
             self._progress.append(entry)
-            return True
+            return entry.copy()
 
         for barrier_termination_pattern in BarrierParser.barrier_termination_patterns:
             barrier_termination_match = barrier_termination_pattern.match(line)
             if barrier_termination_match:
-                self._summary.update(typeconvert_groupdict(barrier_termination_match))
-                return True
+                parse_result = typeconvert_groupdict(barrier_termination_match)
+                self._summary.update(parse_result)
+                return parse_result.copy()
 
         crossover_match = BarrierParser.barrier_crossover_pattern.match(line)
         if crossover_match:
-            self._summary.update(typeconvert_groupdict(crossover_match))
-            return True
+            parse_result = typeconvert_groupdict(crossover_match)
+            self._summary.update(parse_result)
+            return parse_result.copy()
 
-        return False
+        return {}
 
     def get_summary(self) -> dict:
         """Return the current parsed summary."""
diff --git a/src/gurobi_logtools/parsers/continuous.py b/src/gurobi_logtools/parsers/continuous.py
index d70438b..6a9d6ef 100644
--- a/src/gurobi_logtools/parsers/continuous.py
+++ b/src/gurobi_logtools/parsers/continuous.py
@@ -1,4 +1,5 @@
 import re
+from typing import Union
 
 from gurobi_logtools.parsers.barrier import BarrierParser
 from gurobi_logtools.parsers.pretree_solutions import PreTreeSolutionParser
@@ -36,7 +37,7 @@ class ContinuousParser:
 
         self._pretree_solution_parser = pretree_solution_parser
 
-    def parse(self, line: str) -> bool:
+    def parse(self, line: str) -> dict[str, Union[str, int, float, None]]:
         """Parse the given log line to populate summary and progress data.
 
         It defers to the simplex and the barrier parsers as needed.
@@ -45,37 +46,40 @@ class ContinuousParser:
             line (str): A line in the log file.
 
         Returns:
-            bool: Return True if the given line is matched by some pattern.
+            dict[str, Union[str, int, float, None]]: A dictionary containing the parsed data. Empty if the line does not
+            match any pattern.
         """
 
-        if self._pretree_solution_parser.parse(line):
-            return True
+        if parse_result := self._pretree_solution_parser.parse(line):
+            return parse_result.copy()
 
         mip_relaxation_match = ContinuousParser.mip_relaxation_pattern.match(line)
         if mip_relaxation_match:
             self._current_pattern = "relaxation"
-            self._summary.update(typeconvert_groupdict(mip_relaxation_match))
-            return True
+            parse_result = typeconvert_groupdict(mip_relaxation_match)
+            self._summary.update(parse_result)
+            return parse_result.copy()
 
         for pattern in ContinuousParser.continuous_termination_patterns:
-            match = pattern.match(line)
-            if match:
-                for key, value in typeconvert_groupdict(match).items():
+            parse_result = pattern.match(line)
+            if parse_result:
+                parse_result = typeconvert_groupdict(parse_result)
+                for key, value in parse_result.items():
                     if key in ["OPTIMAL", "SUBOPTIMAL"]:
                         self._summary.update({"Status": key})
                     else:
                         self._summary.update({key: value})
-                return True
+                return parse_result.copy()
 
         if self._current_pattern is None:
-            if self._barrier_parser.parse(line):
+            if parse_result := self._barrier_parser.parse(line):
                 self._current_pattern = "barrier"
-                return True
-            if self._simplex_parser.parse(line):
+                return parse_result.copy()
+            if parse_result := self._simplex_parser.parse(line):
                 self._current_pattern = "simplex"
-                return True
+                return parse_result.copy()
 
-            return False
+            return {}
 
         if self._current_pattern == "barrier":
             matched = self._barrier_parser.parse(line)
@@ -85,15 +89,16 @@ class ContinuousParser:
                 ContinuousParser.barrier_interruption_pattern.match(line)
                 or self._simplex_parser.parse(line)
             ):
+                parse_result = {"Init": "simplex"}
                 self._current_pattern = "simplex"
-                return True
-            return matched
+                return parse_result
+            return matched.copy()
 
         if self._current_pattern == "simplex":
-            match = self._simplex_parser.parse(line)
-            return match
+            parse_result = self._simplex_parser.parse(line)
+            return parse_result.copy()
 
-        return False
+        return {}
 
     def get_summary(self) -> dict:
         """Return the current parsed summary."""
diff --git a/src/gurobi_logtools/parsers/header.py b/src/gurobi_logtools/parsers/header.py
index acdb7c6..a4fa79b 100644
--- a/src/gurobi_logtools/parsers/header.py
+++ b/src/gurobi_logtools/parsers/header.py
@@ -1,4 +1,5 @@
 import re
+from typing import Union
 
 from gurobi_logtools.parsers.util import convert_data_types, typeconvert_groupdict
 
@@ -48,37 +49,40 @@ class HeaderParser:
         self._parameters = {}
         self._started = False
 
-    def parse(self, line: str) -> bool:
+    def parse(self, line: str) -> dict[str, Union[str, float, int, None]]:
         """Parse the given log line to populate summary data.
 
         Args:
             line (str): A line in the log file.
 
         Returns:
-            bool: Return True if the given line is matched by some pattern.
+            dict[str, Union[str, int, float, None]]: A dictionary containing the parsed data. Empty if the line does not
+            match any pattern.
         """
         match = self.parameter_change_pattern.match(line)
         if match:
             self._parameters[match.group("ParamName")] = convert_data_types(
                 match.group("ParamValue")
             )
-            return True
+            return self._parameters.copy()
 
         for pattern in self.header_start_patterns:
             match = pattern.match(line)
             if match:
                 self._started = True
-                self._summary.update(typeconvert_groupdict(match))
-                return True
+                parse_result = typeconvert_groupdict(match)
+                self._summary.update(parse_result)
+                return parse_result
 
         if self._started:
             for pattern in self.header_other_patterns:
                 match = pattern.match(line)
                 if match:
-                    self._summary.update(typeconvert_groupdict(match))
-                    return True
+                    parse_result = typeconvert_groupdict(match)
+                    self._summary.update(parse_result)
+                    return parse_result
 
-        return False
+        return {}
 
     def get_summary(self) -> dict:
         """Return the current parsed summary."""
diff --git a/src/gurobi_logtools/parsers/nodelog.py b/src/gurobi_logtools/parsers/nodelog.py
index ecccb27..50f9165 100644
--- a/src/gurobi_logtools/parsers/nodelog.py
+++ b/src/gurobi_logtools/parsers/nodelog.py
@@ -1,4 +1,5 @@
 import re
+from typing import Union
 
 from gurobi_logtools.parsers.util import (
     convert_data_types,
@@ -67,27 +68,28 @@ class NodeLogParser:
         summary.update({f"Cuts: {name}": count for name, count in self._cuts.items()})
         return summary
 
-    def parse(self, line: str) -> bool:
+    def parse(self, line: str) -> dict[str, Union[str, int, float, None]]:
         """Parse the given log line to populate summary and progress data.
 
         Args:
             line (str): A line in the log file.
 
         Returns:
-            bool: Return True if the given line is matched by some pattern.
+            dict[str, Union[str, int, float, None]]: A dictionary containing the parsed data. Empty if the line does not
+            match any pattern.
         """
 
         for regex in self.tree_search_final_stats:
             match = regex.match(line)
             if match:
-                entry = typeconvert_groupdict(match)
-                self._summary.update(entry)
-                return True
+                parse_result = typeconvert_groupdict(match)
+                self._summary.update(parse_result)
+                return parse_result.copy()
 
         match = self.cut_report_start.match(line)
         if match:
             self._in_cut_report = True
-            return True
+            return {"Init": "cutreport"}
 
         if self._in_cut_report:
             match = self.cut_report_line.match(line)
@@ -95,24 +97,25 @@ class NodeLogParser:
                 self._cuts[match.group("Name")] = convert_data_types(
                     match.group("Count")
                 )
-                return True
+                return self._cuts.copy()
 
         # Wait for the header before matching any log lines.
         if not self._started:
             match = self.tree_search_start.match(line)
             if match:
                 self._started = True
-                return True
-            return False
+                return {"Init": "treesearch"}
+            return {}
 
         # Match log lines.
         for regex in self.line_types:
             match = regex.match(line)
             if match:
-                self._progress.append(typeconvert_groupdict(match))
-                return True
+                parse_result = typeconvert_groupdict(match)
+                self._progress.append(parse_result)
+                return parse_result.copy()
 
-        return False
+        return {}
 
     def get_progress(self) -> list:
         """Return the progress of the search tree."""
diff --git a/src/gurobi_logtools/parsers/norel.py b/src/gurobi_logtools/parsers/norel.py
index 0cf624e..a00787d 100644
--- a/src/gurobi_logtools/parsers/norel.py
+++ b/src/gurobi_logtools/parsers/norel.py
@@ -1,4 +1,5 @@
 import re
+from typing import Union
 
 from gurobi_logtools.parsers.util import typeconvert_groupdict
 
@@ -36,26 +37,27 @@ class NoRelParser:
             result["NoRelBestSol"] = self._incumbent
         return result
 
-    def parse(self, line: str) -> bool:
+    def parse(self, line: str) -> dict[str, Union[str, int, float, None]]:
         """Parse the given log line to populate summary and progress data.
 
         Args:
             line (str): A line in the log file.
 
         Returns:
-            bool: Return True if the given line is matched by some pattern.
+            dict[str, Union[str, int, float, None]]: A dictionary containing the parsed data. Empty if the line does not
+            match any pattern.
         """
         if not self._started:
             match = self.norel_log_start.match(line)
             if match:
                 self._started = True
-                return True
-            return False
+                return {"Init": "norel"}
+            return {}
 
         match = self.norel_primal_regex.match(line)
         if match:
             self._incumbent = float(match.group("Incumbent"))
-            return True
+            return {"Incumbent": self._incumbent}
 
         for regex in self.norel_elapsed:
             match = regex.match(line)
@@ -64,9 +66,9 @@ class NoRelParser:
                 if self._incumbent is not None:
                     entry["Incumbent"] = self._incumbent
                 self._progress.append(entry)
-                return True
+                return entry.copy()
 
-        return False
+        return {}
 
     def get_progress(self) -> list:
         """Return the progress of the norel heuristic."""
diff --git a/src/gurobi_logtools/parsers/presolve.py b/src/gurobi_logtools/parsers/presolve.py
index 452d116..7edd9ce 100644
--- a/src/gurobi_logtools/parsers/presolve.py
+++ b/src/gurobi_logtools/parsers/presolve.py
@@ -1,4 +1,5 @@
 import re
+from typing import Union
 
 from gurobi_logtools.parsers.pretree_solutions import PreTreeSolutionParser
 from gurobi_logtools.parsers.util import typeconvert_groupdict
@@ -78,32 +79,35 @@ class PresolveParser:
         self._started = False
         self._pretree_solution_parser = pretree_solution_parser
 
-    def parse(self, line: str) -> bool:
+    def parse(self, line: str) -> dict[str, Union[str, int, float, None]]:
         """Parse the given log line to populate summary data.
 
         Args:
             line (str): A line in the log file.
 
         Returns:
-            bool: Return True if the given line is matched by some pattern.
+            dict[str, Union[str, int, float, None]]: A dictionary containing the parsed data. Empty if the line does not
+            match any pattern.
         """
         if not self._started:
             match = PresolveParser.presolve_start_pattern.match(line)
             if match:
                 # The start line encodes information that should be stored
                 self._started = True
-                self._summary.update(typeconvert_groupdict(match))
-                return True
-            return False
+                parse_result = typeconvert_groupdict(match)
+                self._summary.update(parse_result)
+                return parse_result.copy()
+            return {}
 
-        if self._pretree_solution_parser.parse(line):
-            return True
+        if parse_result := self._pretree_solution_parser.parse(line):
+            return parse_result.copy()
 
         for pattern in PresolveParser.presolve_intermediate_patterns:
             match = pattern.match(line)
             if match:
-                self._summary.update(typeconvert_groupdict(match))
-                return True
+                parse_result = typeconvert_groupdict(match)
+                self._summary.update(parse_result)
+                return parse_result.copy()
 
         match = PresolveParser.presolve_all_removed.match(line)
         if match:
@@ -114,9 +118,9 @@ class PresolveParser:
                     "PresolvedNumNZs": 0,
                 }
             )
-            return True
+            return self._summary.copy()
 
-        return False
+        return {}
 
     def get_summary(self) -> dict:
         """Return the current parsed summary."""
diff --git a/src/gurobi_logtools/parsers/pretree_solutions.py b/src/gurobi_logtools/parsers/pretree_solutions.py
index 67bedae..75592b3 100644
--- a/src/gurobi_logtools/parsers/pretree_solutions.py
+++ b/src/gurobi_logtools/parsers/pretree_solutions.py
@@ -1,4 +1,5 @@
 import re
+from typing import Union
 
 from gurobi_logtools.parsers.util import typeconvert_groupdict
 
@@ -19,20 +20,21 @@ class PreTreeSolutionParser:
         self._summary = {}
         # self._started = False
 
-    def parse(self, line: str) -> bool:
+    def parse(self, line: str) -> dict[str, Union[str, int, float, None]]:
         """Parse the given log line to populate summary data.
 
         Args:
             line (str): A line in the log file.
 
         Returns:
-            bool: Return True if the given line is matched by some pattern.
+            dict[str, Union[str, int, float, None]]: A dictionary containing the parsed data.
         """
         match = self.pretree_solution_regex.match(line)
         if match:
-            self._progress.append(typeconvert_groupdict(match))
-            return True
-        return False
+            parse_result = typeconvert_groupdict(match)
+            self._progress.append(parse_result)
+            return parse_result.copy()
+        return {}
 
     def get_summary(self) -> dict:
         """Return the current parsed summary."""
diff --git a/src/gurobi_logtools/parsers/simplex.py b/src/gurobi_logtools/parsers/simplex.py
index 7044d0d..d610ef9 100644
--- a/src/gurobi_logtools/parsers/simplex.py
+++ b/src/gurobi_logtools/parsers/simplex.py
@@ -1,4 +1,5 @@
 import re
+from typing import Union
 
 from gurobi_logtools.parsers.util import float_pattern, typeconvert_groupdict
 
@@ -32,14 +33,15 @@ class SimplexParser:
         self._progress = []
         self._started = False
 
-    def parse(self, line: str) -> bool:
+    def parse(self, line: str) -> dict[str, Union[str, int, float, None]]:
         """Parse the given log line to populate summary and progress data.
 
         Args:
             line (str): A line in the log file.
 
         Returns:
-            bool: Return True if the given line is matched by some pattern.
+            dict[str, Union[str, int, float, None]]: A dictionary containing the parsed data. Empty if the line does not
+            match any pattern.
         """
         # Check this first since the termination line might appear
         # without any progress log in the concurrent case.
@@ -47,25 +49,25 @@ class SimplexParser:
         for regex in self.simplex_termination_patterns:
             match = regex.match(line)
             if match:
-                entry = typeconvert_groupdict(match)
-                self._summary.update(entry)
-                return True
+                parse_result = typeconvert_groupdict(match)
+                self._summary.update(parse_result)
+                return parse_result.copy()
 
         if not self._started:
             match = SimplexParser.simplex_start_pattern.match(line)
             if match:
                 self._started = True
-                return True
-            return False
+                return {"Init": "simplex"}
+            return {}
 
         progress_match = SimplexParser.simplex_progress_pattern.match(line)
         if progress_match:
-            entry = {"Type": "simplex"}
-            entry.update(typeconvert_groupdict(progress_match))
-            self._progress.append(entry)
-            return True
+            parse_result = {"Type": "simplex"}
+            parse_result.update(typeconvert_groupdict(progress_match))
+            self._progress.append(parse_result)
+            return parse_result.copy()
 
-        return False
+        return {}
 
     def get_summary(self) -> dict:
         """Return the current parsed summary."""
diff --git a/src/gurobi_logtools/parsers/single_log.py b/src/gurobi_logtools/parsers/single_log.py
index a5ba15d..4662e9b 100644
--- a/src/gurobi_logtools/parsers/single_log.py
+++ b/src/gurobi_logtools/parsers/single_log.py
@@ -1,4 +1,5 @@
 import pathlib
+from typing import Union
 
 from gurobi_logtools.parsers.continuous import ContinuousParser
 from gurobi_logtools.parsers.header import HeaderParser
@@ -89,14 +90,15 @@ class SingleLogParser:
         summary["ChangedParams"] = self.header_parser.changed_params()
         return summary
 
-    def parse(self, line: str) -> bool:
+    def parse(self, line: str) -> dict[str, Union[str, int, float, None]]:
         """Parse the given log line to populate the component parsers in sequence.
 
         Args:
             line (str): A line in the log file.
 
         Returns:
-            bool: Return True if the given line is matched by some pattern.
+            dict[str, Union[str, int, float, None]]: A dictionary containing the parsed data. Empty if the line does not
+            match any pattern.
         """
         # Initially, only check the header parser until started
         if not self.started:
@@ -106,26 +108,26 @@ class SingleLogParser:
                 self.started = True
                 if self.write_to_dir:
                     self.lines.append(line)
-            return matched
+            return matched.copy()
 
         if self.write_to_dir:
             self.lines.append(line)
 
         # First try the current parser
         assert self.current_parser not in self.future_parsers
-        if self.current_parser.parse(line):
-            return True
+        if parse_result := self.current_parser.parse(line):
+            return parse_result.copy()
 
         # Check if any future parsers should take over
         for i, parser in enumerate(self.future_parsers):
-            if parser.parse(line):
+            if parse_result := parser.parse(line):
                 self.current_parser = parser
                 self.future_parsers = self.future_parsers[i + 1 :]
-                return True
+                return parse_result
 
         # Check if the line matches any pattern of the termination parser
-        if self.termination_parser.parse(line):
-            return True
+        if parse_result := self.termination_parser.parse(line):
+            return parse_result
 
         # Nothing matched
-        return False
+        return {}
diff --git a/src/gurobi_logtools/parsers/termination.py b/src/gurobi_logtools/parsers/termination.py
index fb52695..0eb003c 100644
--- a/src/gurobi_logtools/parsers/termination.py
+++ b/src/gurobi_logtools/parsers/termination.py
@@ -1,4 +1,5 @@
 import re
+from typing import Union
 
 from gurobi_logtools.parsers.util import typeconvert_groupdict
 
@@ -54,14 +55,15 @@ class TerminationParser:
     def __init__(self):
         self._summary = {}
 
-    def parse(self, line: str) -> bool:
+    def parse(self, line: str) -> dict[str, Union[str, int, float, None]]:
         """Return True if the line is matched by some pattern.
 
         Args:
             line (str): A line in the log file.
 
         Returns:
-            bool: Return True if the given line is matched by some pattern.
+            dict[str, Union[str, int, float, None]]: A dictionary containing the parsed data. Empty if the line does not
+            match any pattern.
         """
         for pattern in TerminationParser.patterns:
             match = pattern.match(line)
@@ -71,8 +73,8 @@ class TerminationParser:
                         self._summary.update({"Status": key})
                     else:
                         self._summary.update({key: value})
-                return True
-        return False
+                return self._summary.copy()
+        return {}
 
     def get_summary(self) -> dict:
         """Return the current parsed summary."""
diff --git a/tox.ini b/tox.ini
index 9dd1aa0..afb3ba5 100644
--- a/tox.ini
+++ b/tox.ini
@@ -4,12 +4,11 @@
 # and then run "tox" from this directory.
 
 [tox]
-envlist = py38,py39,py310,py311,py312,pre-commit,testnb
+envlist = py39,py310,py311,py312,pre-commit,testnb
 isolated_build = True
 
 [gh-actions]
 python =
-    3.8: py38
     3.9: py39,pre-commit,testnb
     3.10: py310
     3.11: py311


Test patch:
diff --git a/tests/parsers/test_single_log.py b/tests/parsers/test_single_log.py
index 1d455b5..2158dd0 100644
--- a/tests/parsers/test_single_log.py
+++ b/tests/parsers/test_single_log.py
@@ -1,3 +1,5 @@
+from datetime import datetime
+
 from gurobi_logtools.parsers.single_log import SingleLogParser
 from gurobi_logtools.parsers.util import parse_lines
 
@@ -110,3 +112,219 @@ def test_multiknapsack():
 
     pretreesols_progress = parser.pretree_solution_parser.get_progress()
     assert len(pretreesols_progress) == 2
+
+
+def test_multiknapsack_iter():
+    parser = SingleLogParser()
+
+    parsed_lines = [
+        {},
+        {
+            "Platform": "mac64[x86], gurobi_cl",
+            "Time": datetime(2024, 7, 5, 19, 18, 47),
+            "Version": "9.5.0",
+        },
+        {},
+        {"LogFile": '"multiknapsack.log"'},
+        {},
+        {},
+        {"Version": "9.5.0"},
+        {},
+        {},
+        {"ModelFilePath": "multiknapsack.mps.bz2"},
+        {"ReadingTime": 2.31},
+        {"Columns": 1000, "ModelName": "", "Nonzeros": 1000000, "Rows": 1000},
+        {},
+        {},
+        {"LogicalProcessors": 8, "PhysicalCores": 4, "Threads": 8},
+        {},
+        {"NumConstrs": 1000, "NumNZs": 1000000, "NumVars": 1000},
+        {"Fingerprint": "0x1dcb478a"},
+        {
+            "PresolvedNumBinVars": 0,
+            "PresolvedNumConVars": 0,
+            "PresolvedNumIntVars": 1000,
+        },
+        {},
+        {"MaxCoeff": 1.0, "MinCoeff": 8e-07},
+        {"MaxObjCoeff": 1.0, "MinObjCoeff": 0.0003},
+        {"MaxBound": 0.0, "MinBound": 0.0},
+        {"MaxRHS": 10000.0, "MinRHS": 2.0},
+        {"Incumbent": 2.650807},
+        {},
+        {"PresolveTime": 0.63},
+        {
+            "PresolvedNumConstrs": 249,
+            "PresolvedNumNZs": 249000,
+            "PresolvedNumVars": 1000,
+        },
+        {
+            "PresolvedNumBinVars": 0,
+            "PresolvedNumConVars": 0,
+            "PresolvedNumIntVars": 1000,
+        },
+        {},
+        {"Init": "norel"},
+        {"Incumbent": 15.6561779},
+        {"Incumbent": 20.2661725},
+        {"Incumbent": 21.6309598},
+        {"Incumbent": 23.7055101},
+        {"Incumbent": 24.1081356},
+        {"BestBd": 24.6734, "Incumbent": 24.1081356, "Time": 8},
+        {"BestBd": 24.6734, "Incumbent": 24.1081356, "Time": 15},
+        {"BestBd": 24.6734, "Incumbent": 24.1081356, "Time": 21},
+        {"BestBd": 24.6734, "Incumbent": 24.1081356, "Time": 29},
+        {},
+        {"RelaxIterCount": 32, "RelaxObj": 24.67344, "RelaxTime": 0.02},
+        {},
+        {"Incumbent": 15.5735142},
+        {},
+        {},
+        {"Init": "treesearch"},
+        {},
+        {
+            "BestBd": 24.67344,
+            "CurrentNode": 0,
+            "Depth": 0,
+            "Gap": 0.584,
+            "Incumbent": 15.57351,
+            "IntInf": 5,
+            "ItPerNode": None,
+            "Obj": 24.67344,
+            "RemainingNodes": 0,
+            "Time": 0,
+        },
+        {
+            "BestBd": 24.67344,
+            "CurrentNode": 0,
+            "Gap": 0.0345,
+            "Incumbent": 23.8510977,
+            "ItPerNode": None,
+            "NewSolution": "H",
+            "RemainingNodes": 0,
+            "Time": 0,
+        },
+        {
+            "BestBd": 24.67344,
+            "CurrentNode": 0,
+            "Gap": 0.023399999999999997,
+            "Incumbent": 24.1081356,
+            "ItPerNode": None,
+            "NewSolution": "H",
+            "RemainingNodes": 0,
+            "Time": 0,
+        },
+        {
+            "BestBd": 24.54405,
+            "CurrentNode": 0,
+            "Depth": 0,
+            "Gap": 0.0181,
+            "Incumbent": 24.10814,
+            "IntInf": 7,
+            "ItPerNode": None,
+            "Obj": 24.54405,
+            "RemainingNodes": 0,
+            "Time": 1,
+        },
+        {
+            "BestBd": 24.54405,
+            "CurrentNode": 0,
+            "Depth": 0,
+            "Gap": 0.0181,
+            "Incumbent": 24.10814,
+            "IntInf": 5,
+            "ItPerNode": None,
+            "Obj": 24.54405,
+            "RemainingNodes": 0,
+            "Time": 1,
+        },
+        {
+            "BestBd": 24.54405,
+            "CurrentNode": 0,
+            "Depth": 0,
+            "Gap": 0.0181,
+            "Incumbent": 24.10814,
+            "IntInf": 7,
+            "ItPerNode": None,
+            "Obj": 24.54405,
+            "RemainingNodes": 0,
+            "Time": 1,
+        },
+        {
+            "BestBd": 24.49025,
+            "CurrentNode": 0,
+            "Depth": 0,
+            "Gap": 0.0159,
+            "Incumbent": 24.10814,
+            "IntInf": 6,
+            "ItPerNode": None,
+            "Obj": 24.49025,
+            "RemainingNodes": 0,
+            "Time": 1,
+        },
+        {
+            "BestBd": 24.48961,
+            "CurrentNode": 0,
+            "Depth": 0,
+            "Gap": 0.0158,
+            "Incumbent": 24.10814,
+            "IntInf": 7,
+            "ItPerNode": None,
+            "Obj": 24.48961,
+            "RemainingNodes": 0,
+            "Time": 1,
+        },
+        {
+            "BestBd": 24.45227,
+            "CurrentNode": 0,
+            "Depth": 0,
+            "Gap": 0.0143,
+            "Incumbent": 24.10814,
+            "IntInf": 8,
+            "ItPerNode": None,
+            "Obj": 24.45227,
+            "RemainingNodes": 0,
+            "Time": 1,
+        },
+        {
+            "BestBd": 24.40551,
+            "CurrentNode": 0,
+            "Depth": 0,
+            "Gap": 0.0123,
+            "Incumbent": 24.10814,
+            "IntInf": 8,
+            "ItPerNode": None,
+            "Obj": 24.40551,
+            "RemainingNodes": 0,
+            "Time": 1,
+        },
+        {
+            "BestBd": 24.40551,
+            "CurrentNode": 0,
+            "Depth": 0,
+            "Gap": 0.0123,
+            "Incumbent": 24.10814,
+            "IntInf": 8,
+            "ItPerNode": None,
+            "Obj": 24.40551,
+            "RemainingNodes": 2,
+            "Time": 1,
+        },
+        {},
+        {"Init": "cutreport"},
+        {"Gomory": 8},
+        {"Gomory": 8, "Lift-and-project": 1},
+        {},
+        {"IterCount": 2566, "NodeCount": 838, "Runtime": 1.27, "Work": 0.53},
+        {"Cores": 8, "Threads": 8},
+        {},
+        {"Cores": 8, "SolCount": 5, "Threads": 8},
+        {},
+        {"Cores": 8, "SolCount": 5, "Status": "OPTIMAL", "Threads": 8},
+        {"MIPGap": 0.0, "ObjBound": 24.10813557868, "ObjVal": 24.10813557868},
+    ]
+
+    with open("tests/assets/multiknapsack.log") as infile:
+        lines = iter(infile)
+        res = [parser.parse(line) for line in lines]
+        assert res == parsed_lines