Repository: python-adaptive/adaptive

Issue:
Use in script with BlockingRunner: get log and/or feedback on progress
In a notebook, I can use `live_info()` to display the progress. Is something similar available if I run `adaptive` in a script?

Discussion:

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 6c10c53..2405489 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,6 @@
 repos:
   - repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v4.4.0
+    rev: v4.5.0
     hooks:
       - id: trailing-whitespace
       - id: end-of-file-fixer
@@ -9,16 +9,16 @@ repos:
       - id: debug-statements
       - id: check-ast
   - repo: https://github.com/psf/black
-    rev: 23.3.0
+    rev: 24.2.0
     hooks:
       - id: black-jupyter
-  - repo: https://github.com/charliermarsh/ruff-pre-commit
-    rev: "v0.0.269"
+  - repo: https://github.com/astral-sh/ruff-pre-commit
+    rev: "v0.2.1"
     hooks:
       - id: ruff
         args: ["--fix"]
   - repo: https://github.com/nbQA-dev/nbQA
-    rev: 1.7.0
+    rev: 1.7.1
     hooks:
       - id: nbqa-black
         additional_dependencies: [jupytext, black]
@@ -26,7 +26,7 @@ repos:
         args: ["ruff", "--fix", "--ignore=E402,B018,F704"]
         additional_dependencies: [jupytext, ruff]
   - repo: https://github.com/pre-commit/mirrors-mypy
-    rev: "v1.3.0"
+    rev: "v1.8.0"
     hooks:
       - id: mypy
         exclude: ipynb_filter.py|docs/source/conf.py
diff --git a/adaptive/learner/average_learner1D.py b/adaptive/learner/average_learner1D.py
index 12c7c9a..5dc9097 100644
--- a/adaptive/learner/average_learner1D.py
+++ b/adaptive/learner/average_learner1D.py
@@ -79,8 +79,9 @@ class AverageLearner1D(Learner1D):
         self,
         function: Callable[[tuple[int, Real]], Real],
         bounds: tuple[Real, Real],
-        loss_per_interval: None
-        | (Callable[[Sequence[Real], Sequence[Real]], float]) = None,
+        loss_per_interval: None | (
+            Callable[[Sequence[Real], Sequence[Real]], float]
+        ) = None,
         delta: float = 0.2,
         alpha: float = 0.005,
         neighbor_sampling: float = 0.3,
@@ -310,7 +311,7 @@ class AverageLearner1D(Learner1D):
         new point, since in general n << min_samples and this point will need
         to be resampled many more times"""
         points, (loss_improvement,) = self._ask_points_without_adding(1)
-        seed_points = [(seed, x) for seed, x in zip(range(n), n * points)]
+        seed_points = list(zip(range(n), n * points))
         loss_improvements = [loss_improvement / n] * n
         return seed_points, loss_improvements  # type: ignore[return-value]
 
diff --git a/adaptive/learner/integrator_learner.py b/adaptive/learner/integrator_learner.py
index 74aebe1..0fc97df 100644
--- a/adaptive/learner/integrator_learner.py
+++ b/adaptive/learner/integrator_learner.py
@@ -71,7 +71,6 @@ class DivergentIntegralError(ValueError):
 
 
 class _Interval:
-
     """
     Attributes
     ----------
diff --git a/adaptive/learner/learner1D.py b/adaptive/learner/learner1D.py
index 8385e67..bf04743 100644
--- a/adaptive/learner/learner1D.py
+++ b/adaptive/learner/learner1D.py
@@ -135,7 +135,7 @@ def triangle_loss(xs: XsType1, ys: YsType1) -> Float:
         pts = [(x, *y) for x, y in zip(xs, ys)]  # type: ignore[misc]
         vol = simplex_volume_in_embedding
     else:
-        pts = [(x, y) for x, y in zip(xs, ys)]
+        pts = list(zip(xs, ys))
         vol = volume
     return sum(vol(pts[i : i + 3]) for i in range(N)) / N
 
@@ -633,10 +633,12 @@ class Learner1D(BaseLearner):
     def tell_many(
         self,
         xs: Sequence[Float] | np.ndarray,
-        ys: Sequence[Float]
-        | Sequence[Sequence[Float]]
-        | Sequence[np.ndarray]
-        | np.ndarray,
+        ys: (
+            Sequence[Float]
+            | Sequence[Sequence[Float]]
+            | Sequence[np.ndarray]
+            | np.ndarray
+        ),
         *,
         force: bool = False,
     ) -> None:
diff --git a/adaptive/learner/learnerND.py b/adaptive/learner/learnerND.py
index edc839d..c8eacea 100644
--- a/adaptive/learner/learnerND.py
+++ b/adaptive/learner/learnerND.py
@@ -987,9 +987,11 @@ class LearnerND(BaseLearner):
             xs = ys = np.linspace(0, 1, n)
             xys = [xs[:, None], ys[None, :]]
             values = [
-                cut_mapping[i]
-                if i in cut_mapping
-                else xys.pop(0) * (b[1] - b[0]) + b[0]
+                (
+                    cut_mapping[i]
+                    if i in cut_mapping
+                    else xys.pop(0) * (b[1] - b[0]) + b[0]
+                )
                 for i, b in enumerate(self._bbox)
             ]
 
diff --git a/adaptive/runner.py b/adaptive/runner.py
index 6444335..84fcecd 100644
--- a/adaptive/runner.py
+++ b/adaptive/runner.py
@@ -470,7 +470,7 @@ class BlockingRunner(BaseRunner):
         npoints_goal: int | None = None,
         end_time_goal: datetime | None = None,
         duration_goal: timedelta | int | float | None = None,
-        executor: (ExecutorTypes | None) = None,
+        executor: ExecutorTypes | None = None,
         ntasks: int | None = None,
         log: bool = False,
         shutdown_executor: bool = False,
@@ -629,7 +629,7 @@ class AsyncRunner(BaseRunner):
         npoints_goal: int | None = None,
         end_time_goal: datetime | None = None,
         duration_goal: timedelta | int | float | None = None,
-        executor: (ExecutorTypes | None) = None,
+        executor: ExecutorTypes | None = None,
         ntasks: int | None = None,
         log: bool = False,
         shutdown_executor: bool = False,
@@ -722,6 +722,14 @@ class AsyncRunner(BaseRunner):
         """
         self.task.cancel()
 
+    def block_until_done(self) -> None:
+        if in_ipynb():
+            raise RuntimeError(
+                "Cannot block the event loop when running in a Jupyter notebook."
+                " Use `await runner.task` instead."
+            )
+        self.ioloop.run_until_complete(self.task)
+
     def live_plot(
         self,
         *,
@@ -768,6 +776,55 @@ class AsyncRunner(BaseRunner):
         """
         return live_info(self, update_interval=update_interval)
 
+    def live_info_terminal(
+        self, *, update_interval: float = 0.5, overwrite_previous: bool = True
+    ) -> asyncio.Task:
+        """
+        Display live information about the runner in the terminal.
+
+        This function provides a live update of the runner's status in the terminal.
+        The update can either overwrite the previous status or be printed on a new line.
+
+        Parameters
+        ----------
+        update_interval : float, optional
+            The time interval (in seconds) at which the runner's status is updated in the terminal.
+            Default is 0.5 seconds.
+        overwrite_previous : bool, optional
+            If True, each update will overwrite the previous status in the terminal.
+            If False, each update will be printed on a new line.
+            Default is True.
+
+        Returns
+        -------
+        asyncio.Task
+            The asynchronous task responsible for updating the runner's status in the terminal.
+
+        Examples
+        --------
+        >>> runner = AsyncRunner(...)
+        >>> runner.live_info_terminal(update_interval=1.0, overwrite_previous=False)
+
+        Notes
+        -----
+        This function uses ANSI escape sequences to control the terminal's cursor position.
+        It might not work as expected on all terminal emulators.
+        """
+
+        async def _update(runner: AsyncRunner) -> None:
+            try:
+                while not runner.task.done():
+                    if overwrite_previous:
+                        # Clear the terminal
+                        print("\033[H\033[J", end="")
+                    print(_info_text(runner, separator="\t"))
+                    await asyncio.sleep(update_interval)
+
+            except asyncio.CancelledError:
+                print("Live info display cancelled.")
+
+        return self.ioloop.create_task(_update(self))
+
     async def _run(self) -> None:
         first_completed = asyncio.FIRST_COMPLETED
 
@@ -847,6 +904,43 @@ class AsyncRunner(BaseRunner):
         return self.saving_task
 
 
+def _info_text(runner, separator: str = "\n"):
+    status = runner.status()
+
+    color_map = {
+        "cancelled": "\033[33m",  # Yellow
+        "failed": "\033[31m",  # Red
+        "running": "\033[34m",  # Blue
+        "finished": "\033[32m",  # Green
+    }
+
+    overhead = runner.overhead()
+    if overhead < 50:
+        overhead_color = "\033[32m"  # Green
+    else:
+        overhead_color = "\033[31m"  # Red
+
+    info = [
+        ("time", str(datetime.now())),
+        ("status", f"{color_map[status]}{status}\033[0m"),
+        ("elapsed time", str(timedelta(seconds=runner.elapsed_time()))),
+        ("overhead", f"{overhead_color}{overhead:.2f}%\033[0m"),
+    ]
+
+    with suppress(Exception):
+        info.append(("# of points", runner.learner.npoints))
+
+    with suppress(Exception):
+        info.append(("# of samples", runner.learner.nsamples))
+
+    with suppress(Exception):
+        info.append(("latest loss", f'{runner.learner._cache["loss"]:.3f}'))
+
+    width = 30
+    formatted_info = [f"{k}: {v}".ljust(width) for i, (k, v) in enumerate(info)]
+    return separator.join(formatted_info)
+
+
 # Default runner
 Runner = AsyncRunner
 
@@ -956,7 +1050,7 @@ def _ensure_executor(executor: ExecutorTypes | None) -> concurrent.Executor:
 
 
 def _get_ncores(
-    ex: (ExecutorTypes),
+    ex: ExecutorTypes,
 ) -> int:
     """Return the maximum  number of cores that an executor can use."""
     if with_ipyparallel:
diff --git a/docs/source/tutorial/tutorial.parallelism.md b/docs/source/tutorial/tutorial.parallelism.md
index 6b91f12..5decc61 100644
--- a/docs/source/tutorial/tutorial.parallelism.md
+++ b/docs/source/tutorial/tutorial.parallelism.md
@@ -89,7 +89,7 @@ if __name__ == "__main__":
     runner.start_periodic_saving(dict(fname=fname), interval=600)
 
     # block until runner goal reached
-    runner.ioloop.run_until_complete(runner.task)
+    runner.block_until_done()
 
     # save one final time before exiting
     learner.save(fname)
diff --git a/pyproject.toml b/pyproject.toml
index f0ea8ed..1cdc34d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -97,6 +97,8 @@ python_version = "3.9"
 [tool.ruff]
 line-length = 150
 target-version = "py39"
+
+[tool.ruff.lint]
 select = ["B", "C", "E", "F", "W", "T", "B9", "I", "UP"]
 ignore = [
     "T20",     # flake8-print
@@ -109,14 +111,14 @@ ignore = [
     "D401",    # First line of docstring should be in imperative mood
 ]
 
+[tool.ruff.lint.mccabe]
+max-complexity = 18
+
 [tool.ruff.per-file-ignores]
 "tests/*" = ["SLF001"]
 "ci/*" = ["INP001"]
 "tests/test_examples.py" = ["E501"]
 
-[tool.ruff.mccabe]
-max-complexity = 18
-
 [tool.versioningit]
 
 [tool.versioningit.vcs]


Test patch:
diff --git a/adaptive/tests/test_runner.py b/adaptive/tests/test_runner.py
index e36abcb..f6bb603 100644
--- a/adaptive/tests/test_runner.py
+++ b/adaptive/tests/test_runner.py
@@ -1,4 +1,3 @@
-import asyncio
 import platform
 import sys
 import time
@@ -34,7 +33,7 @@ def blocking_runner(learner, **kw):
 
 def async_runner(learner, **kw):
     runner = AsyncRunner(learner, executor=SequentialExecutor(), **kw)
-    asyncio.get_event_loop().run_until_complete(runner.task)
+    runner.block_until_done()
 
 
 runners = [simple, blocking_runner, async_runner]
@@ -71,7 +70,7 @@ def test_aync_def_function():
 
     learner = Learner1D(f, (-1, 1))
     runner = AsyncRunner(learner, npoints_goal=10)
-    asyncio.get_event_loop().run_until_complete(runner.task)
+    runner.block_until_done()
 
 
 # --- Test with different executors
@@ -158,7 +157,7 @@ def test_loky_executor(loky_executor):
 def test_default_executor():
     learner = Learner1D(linear, (-1, 1))
     runner = AsyncRunner(learner, npoints_goal=10)
-    asyncio.get_event_loop().run_until_complete(runner.task)
+    runner.block_until_done()
 
 
 def test_auto_goal():