Repository: PyVRP/PyVRP
Pull request #260 (2023-07-05 19:55:00)

Use Ruff instead of flake8 and isort
Replaces flake8 and isort with Ruff in the pre-commit configuration. Ruff is almost a drop-in replacement, but there are a few additional checks that we have not previously enforced (and thus result in various changes in this PR).


N-Wouda: Blocked until https://github.com/astral-sh/ruff/issues/5514 is in Ruff.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/PyVRP/PyVRP/pull/260?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PyVRP) Report
Patch coverage: **`90.69`**% and project coverage change: **`-0.15`** :warning:
> Comparison is base [(`9c42239`)](https://app.codecov.io/gh/PyVRP/PyVRP/commit/9c42239274b9ed5f64741fa3e9507ba4ebc8f37c?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PyVRP) 94.00% compared to head [(`85e5ae5`)](https://app.codecov.io/gh/PyVRP/PyVRP/pull/260?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PyVRP) 93.86%.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #260      +/-   ##
==========================================
- Coverage   94.00%   93.86%   -0.15%     
==========================================
  Files          63       63              
  Lines        2470     2477       +7     
  Branches      214      214              
==========================================
+ Hits         2322     2325       +3     
- Misses         48       52       +4     
  Partials      100      100              
```


| [Impacted Files](https://app.codecov.io/gh/PyVRP/PyVRP/pull/260?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PyVRP) | Coverage Δ | |
|---|---|---|
| [pyvrp/Population.py](https://app.codecov.io/gh/PyVRP/PyVRP/pull/260?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PyVRP#diff-cHl2cnAvUG9wdWxhdGlvbi5weQ==) | `95.52% <40.00%> (-4.48%)` | :arrow_down: |
| [pyvrp/search/neighbourhood.py](https://app.codecov.io/gh/PyVRP/PyVRP/pull/260?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PyVRP#diff-cHl2cnAvc2VhcmNoL25laWdoYm91cmhvb2QucHk=) | `97.77% <75.00%> (-2.23%)` | :arrow_down: |
| [pyvrp/GeneticAlgorithm.py](https://app.codecov.io/gh/PyVRP/PyVRP/pull/260?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PyVRP#diff-cHl2cnAvR2VuZXRpY0FsZ29yaXRobS5weQ==) | `95.04% <100.00%> (ø)` | |
| [pyvrp/Model.py](https://app.codecov.io/gh/PyVRP/PyVRP/pull/260?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PyVRP#diff-cHl2cnAvTW9kZWwucHk=) | `100.00% <100.00%> (ø)` | |
| [pyvrp/PenaltyManager.py](https://app.codecov.io/gh/PyVRP/PyVRP/pull/260?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PyVRP#diff-cHl2cnAvUGVuYWx0eU1hbmFnZXIucHk=) | `100.00% <100.00%> (ø)` | |
| [pyvrp/Result.py](https://app.codecov.io/gh/PyVRP/PyVRP/pull/260?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PyVRP#diff-cHl2cnAvUmVzdWx0LnB5) | `100.00% <100.00%> (ø)` | |
| [pyvrp/Statistics.py](https://app.codecov.io/gh/PyVRP/PyVRP/pull/260?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PyVRP#diff-cHl2cnAvU3RhdGlzdGljcy5weQ==) | `100.00% <100.00%> (ø)` | |
| [pyvrp/\_\_init\_\_.py](https://app.codecov.io/gh/PyVRP/PyVRP/pull/260?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PyVRP#diff-cHl2cnAvX19pbml0X18ucHk=) | `100.00% <100.00%> (ø)` | |
| [pyvrp/crossover/\_\_init\_\_.py](https://app.codecov.io/gh/PyVRP/PyVRP/pull/260?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PyVRP#diff-cHl2cnAvY3Jvc3NvdmVyL19faW5pdF9fLnB5) | `100.00% <100.00%> (ø)` | |
| [pyvrp/diversity/\_\_init\_\_.py](https://app.codecov.io/gh/PyVRP/PyVRP/pull/260?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PyVRP#diff-cHl2cnAvZGl2ZXJzaXR5L19faW5pdF9fLnB5) | `100.00% <100.00%> (ø)` | |
| ... and [8 more](https://app.codecov.io/gh/PyVRP/PyVRP/pull/260?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PyVRP) | |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/PyVRP/PyVRP/pull/260?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PyVRP).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PyVRP).


Patch:
diff --git a/.github/workflows/DOC.yml b/.github/workflows/DOC.yml
index 8688708..7c04bc8 100644
--- a/.github/workflows/DOC.yml
+++ b/.github/workflows/DOC.yml
@@ -16,6 +16,7 @@ concurrency:
 
 jobs:
   deploy:
+    name: "Build and deploy documentation"
     environment:
       name: github-pages
       url: ${{ steps.deployment.outputs.page_url }}
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index a1c1245..f7e5ca0 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -17,15 +17,11 @@ repos:
     hooks:
       - id: black-jupyter
 
-  - repo: https://github.com/pycqa/flake8
-    rev: 4.0.1
+  - repo: https://github.com/astral-sh/ruff-pre-commit
+    rev: v0.0.278
     hooks:
-      - id: flake8
-
-  - repo: https://github.com/pycqa/isort
-    rev: 5.12.0
-    hooks:
-      - id: isort
+      - id: ruff
+        args: [--fix, --exit-non-zero-on-fix]
 
   - repo: https://github.com/pre-commit/mirrors-mypy
     rev: v1.4.1
diff --git a/pyproject.toml b/pyproject.toml
index 49bf1f0..6941ee9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -92,10 +92,18 @@ pyvrp = "pyvrp.cli:main"
 line-length = 79
 
 
-[tool.isort]
-case_sensitive = true
-line_length = 79
-profile = "black"
+[tool.ruff]
+line-length = 79
+select = ["E", "F", "I", "NPY", "RET", "RSE", "RUF", "SLF", "SIM", "TCH"]
+
+
+[tool.ruff.per-file-ignores]
+"__init__.py" = ["F401"]  # unused imports
+
+
+[tool.ruff.isort]
+case-sensitive = true
+known-first-party = ["pyvrp"]
 
 
 [tool.mypy]
diff --git a/pyvrp/PenaltyManager.py b/pyvrp/PenaltyManager.py
index b74ad7e..6c60a11 100644
--- a/pyvrp/PenaltyManager.py
+++ b/pyvrp/PenaltyManager.py
@@ -156,8 +156,8 @@ class PenaltyManager:
             return int(
                 min(self._params.penalty_increase * penalty + 1, 1000.0)
             )
-        else:
-            return int(max(self._params.penalty_decrease * penalty - 1, 1.0))
+
+        return int(max(self._params.penalty_decrease * penalty - 1, 1.0))
 
     def register_load_feasible(self, is_load_feasible: bool):
         """
diff --git a/pyvrp/Population.py b/pyvrp/Population.py
index c0e22d3..94d5b6f 100644
--- a/pyvrp/Population.py
+++ b/pyvrp/Population.py
@@ -1,17 +1,14 @@
 from __future__ import annotations
 
-from typing import Callable, Generator, Tuple
+from typing import TYPE_CHECKING, Callable, Generator, Tuple
 from warnings import warn
 
-from ._pyvrp import (
-    CostEvaluator,
-    PopulationParams,
-    Solution,
-    SubPopulation,
-    XorShift128,
-)
+from ._pyvrp import PopulationParams, SubPopulation
 from .exceptions import EmptySolutionWarning
 
+if TYPE_CHECKING:
+    from ._pyvrp import CostEvaluator, Solution, XorShift128
+
 
 class Population:
     """
diff --git a/pyvrp/Statistics.py b/pyvrp/Statistics.py
index 8172022..82a2606 100644
--- a/pyvrp/Statistics.py
+++ b/pyvrp/Statistics.py
@@ -64,11 +64,11 @@ class Statistics:
         # The following lines access private members of the population, but in
         # this case that is mostly OK: we really want to have that access to
         # enable detailed statistics logging.
-        feas_subpop = population._feas  # noqa
+        feas_subpop = population._feas  # noqa: SLF001
         feas_datum = self._collect_from_subpop(feas_subpop, cost_evaluator)
         self.feas_stats.append(feas_datum)
 
-        infeas_subpop = population._infeas  # noqa
+        infeas_subpop = population._infeas  # noqa: SLF001
         infeas_datum = self._collect_from_subpop(infeas_subpop, cost_evaluator)
         self.infeas_stats.append(infeas_datum)
 
@@ -184,7 +184,7 @@ class Statistics:
         ]
 
         with open(where, "w") as fh:
-            header = ["runtime"] + feas_fields + infeas_fields
+            header = ["runtime", *feas_fields, *infeas_fields]
             writer = csv.DictWriter(
                 fh, header, delimiter=delimiter, quoting=quoting, **kwargs
             )
diff --git a/pyvrp/_pyvrp.pyi b/pyvrp/_pyvrp.pyi
index 371b7bd..2475918 100644
--- a/pyvrp/_pyvrp.pyi
+++ b/pyvrp/_pyvrp.pyi
@@ -505,7 +505,6 @@ class Solution:
     def __deepcopy__(self, memo: dict) -> Solution: ...
     def __hash__(self) -> int: ...
     def __eq__(self, other: object) -> bool: ...
-    def __str__(self) -> str: ...
 
 class PopulationParams:
     generation_size: int
diff --git a/pyvrp/cli.py b/pyvrp/cli.py
index 0ee0234..a88206e 100644
--- a/pyvrp/cli.py
+++ b/pyvrp/cli.py
@@ -9,9 +9,9 @@ try:
     import tomli
     from tqdm import tqdm
     from tqdm.contrib.concurrent import process_map
-except ModuleNotFoundError:
+except ModuleNotFoundError as exc:
     msg = "Install 'tqdm' and 'tomli' to use the command line program."
-    raise ModuleNotFoundError(msg)
+    raise ModuleNotFoundError(msg) from exc
 
 import pyvrp.search
 from pyvrp import (
@@ -55,7 +55,7 @@ def tabulate(headers: List[str], rows: np.ndarray) -> str:
     ]
 
     content = [
-        "  ".join(f"{str(c):>{ln}s}" for ln, c in zip(lens, r)) for r in rows
+        "  ".join(f"{c!s:>{ln}s}" for ln, c in zip(lens, r)) for r in rows
     ]
 
     return "\n".join(header + content)
diff --git a/pyvrp/plotting/plot_route_schedule.py b/pyvrp/plotting/plot_route_schedule.py
index c69bb70..a9a84e4 100644
--- a/pyvrp/plotting/plot_route_schedule.py
+++ b/pyvrp/plotting/plot_route_schedule.py
@@ -76,7 +76,7 @@ def plot_route_schedule(
     add_traces(dist, t, drive_time, serv_time, load)
 
     prev_idx = 0  # depot
-    for idx in list(route) + [0]:
+    for idx in [*list(route), 0]:
         stop = data.client(idx)
         delta_time = data.duration(prev_idx, idx)
         delta_dist = data.dist(prev_idx, idx)
diff --git a/pyvrp/plotting/plot_runtimes.py b/pyvrp/plotting/plot_runtimes.py
index 7d72c3d..662e5e8 100644
--- a/pyvrp/plotting/plot_runtimes.py
+++ b/pyvrp/plotting/plot_runtimes.py
@@ -35,7 +35,7 @@ def plot_runtimes(result: Result, ax: Optional[plt.Axes] = None):
     ax.plot(x, result.stats.runtimes)
 
     if result.num_iterations > 1:  # need data to plot a trendline
-        b, c = np.polyfit(x, result.stats.runtimes, 1)  # noqa
+        b, c = np.polyfit(x, result.stats.runtimes, 1)
         ax.plot(b * x + c)
 
     ax.set_xlim(left=0)
diff --git a/pyvrp/read.py b/pyvrp/read.py
index f432c97..63af708 100644
--- a/pyvrp/read.py
+++ b/pyvrp/read.py
@@ -71,6 +71,14 @@ def read(
               decimal;
             * ``'none'`` does no rounding. This is the default.
 
+    Raises
+    ------
+    TypeError
+        When ``round_func`` does not name a rounding function, or is not
+        callable.
+    ValueError
+        When the data file does not provide information on the problem size.
+
     Returns
     -------
     ProblemData
@@ -80,7 +88,7 @@ def read(
         round_func = ROUND_FUNCS[key]
 
     if not callable(round_func):
-        raise ValueError(
+        raise TypeError(
             f"round_func = {round_func} is not understood. Can be a function,"
             f" or one of {ROUND_FUNCS.keys()}."
         )
diff --git a/pyvrp/search/neighbourhood.py b/pyvrp/search/neighbourhood.py
index a94d1e4..b63b7b8 100644
--- a/pyvrp/search/neighbourhood.py
+++ b/pyvrp/search/neighbourhood.py
@@ -1,11 +1,12 @@
 from __future__ import annotations
 
 from dataclasses import dataclass
-from typing import List
+from typing import TYPE_CHECKING, List
 
 import numpy as np
 
-from pyvrp import ProblemData
+if TYPE_CHECKING:
+    from pyvrp import ProblemData
 
 
 @dataclass
@@ -90,7 +91,7 @@ def compute_neighbours(
     top_k = np.argsort(proximity, axis=1, kind="stable")[1:, :k]  # excl. depot
 
     if not params.symmetric_neighbours:
-        return [[]] + top_k.tolist()
+        return [[], *top_k.tolist()]
 
     # Construct a symmetric adjacency matrix and return the adjacent clients
     # as the neighbourhood structure.
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index bde734a..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,8 +0,0 @@
-[flake8]
-per-file-ignores = __init__.py:F401
-extend-ignore =
-    # See https://github.com/PyCQA/pycodestyle/issues/373
-    E203,
-import-order-style = pep8
-application-import-names =
-    pyvrp,


Test patch:
diff --git a/pyvrp/tests/test_GeneticAlgorithm.py b/pyvrp/tests/test_GeneticAlgorithm.py
index b95105d..2b3fb73 100644
--- a/pyvrp/tests/test_GeneticAlgorithm.py
+++ b/pyvrp/tests/test_GeneticAlgorithm.py
@@ -214,7 +214,7 @@ def test_best_initial_solution():
     pop = Population(bpd)
 
     bks = Solution(data, read_solution("data/RC208.sol"))
-    init = [bks] + make_random_solutions(24, data, rng)
+    init = [bks, *make_random_solutions(24, data, rng)]
 
     ls = LocalSearch(data, rng, compute_neighbours(data))
     algo = GeneticAlgorithm(data, pm, rng, pop, ls, srex, init)
diff --git a/pyvrp/tests/test_Population.py b/pyvrp/tests/test_Population.py
index b808aab..2471836 100644
--- a/pyvrp/tests/test_Population.py
+++ b/pyvrp/tests/test_Population.py
@@ -256,10 +256,10 @@ def test_tournament_ranks_by_fitness(k: int):
 
     # Since this test requires the fitness values of the solutions, we have
     # to access the underlying infeasible subpopulation directly.
-    infeas_pop = pop._infeas
+    infeas_pop = pop._infeas  # noqa: SLF001
     infeas_pop.update_fitness(cost_evaluator)
 
-    items = [item for item in pop._infeas]
+    items = [item for item in infeas_pop]
     by_fitness = sorted(items, key=lambda item: item.fitness)
     sol2idx = {item.solution: idx for idx, item in enumerate(by_fitness)}
     infeas_count = np.zeros(len(infeas_pop))
diff --git a/pyvrp/tests/test_SubPopulation.py b/pyvrp/tests/test_SubPopulation.py
index f23eb3d..d784806 100644
--- a/pyvrp/tests/test_SubPopulation.py
+++ b/pyvrp/tests/test_SubPopulation.py
@@ -104,7 +104,7 @@ def test_fitness_is_purely_based_on_cost_when_only_elites():
 
     # The fitness terms should all be bounded to [0, 1], and the values should
     # agree with what we've computed above.
-    assert_(((0 <= actual_fitness) & (actual_fitness <= 1)).all())
+    assert_(((actual_fitness >= 0) & (actual_fitness <= 1)).all())
     assert_allclose(actual_fitness, expected_fitness)
 
 
@@ -140,5 +140,5 @@ def test_fitness_is_average_of_cost_and_diversity_when_no_elites():
 
     # The fitness terms should all be bounded to [0, 1], and the values should
     # agree with what we've computed above.
-    assert_(((0 <= actual_fitness) & (actual_fitness <= 1)).all())
+    assert_(((actual_fitness >= 0) & (actual_fitness <= 1)).all())
     assert_allclose(actual_fitness, expected_fitness)
diff --git a/pyvrp/tests/test_read.py b/pyvrp/tests/test_read.py
index cf262fe..4d982be 100644
--- a/pyvrp/tests/test_read.py
+++ b/pyvrp/tests/test_read.py
@@ -29,7 +29,7 @@ def test_raises_invalid_file(where: str, exception: Exception):
 
 
 def test_raises_unknown_round_func():
-    with assert_raises(ValueError):
+    with assert_raises(TypeError):
         # Unknown round_func, so should raise.
         read("data/OkSmall.txt", round_func="asdbsadfas")