diff --git a/docs/source/conf.py b/docs/source/conf.py index 99a9155..17474b3 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -14,20 +14,20 @@ import os.path import sys -sys.path.insert(0, os.path.abspath("../..")) -sys.path.insert(0, os.path.abspath("../../../py-pde")) +sys.path.insert(0, os.path.abspath("../..")) # noqa: PTH100 +sys.path.insert(0, os.path.abspath("../../../py-pde")) # noqa: PTH100 sys.path.insert(0, ".") from datetime import date -import sphinx_simplify_typehints # @UnresolvedImport @UnusedImport +import sphinx_simplify_typehints # -- Project information ----------------------------------------------------- project = "py-droplets" module_name = "droplets" author = "David Zwicker" -copyright = f"{date.today().year}, {author}" # @ReservedAssignment +copyright = f"{date.today().year}, {author}" # noqa: A001 # The short X.Y version import droplets diff --git a/docs/source/quickstart/contributing.rst b/docs/source/quickstart/contributing.rst index 711f9f6..8882250 100644 --- a/docs/source/quickstart/contributing.rst +++ b/docs/source/quickstart/contributing.rst @@ -19,8 +19,9 @@ define a droplet class that stores additional information by subclassing Coding style """""""""""" -The coding style is enforced using `isort `_ -and `black `_. Moreover, we use `Google Style docstrings +The coding style is enforced using `ruff `_, based on the +styles suggest by `isort `_ and +`black `_. Moreover, we use `Google Style docstrings `_, which might be best `learned by example `_. diff --git a/docs/source/run_autodoc.py b/docs/source/run_autodoc.py index 6e4bc04..d935598 100755 --- a/docs/source/run_autodoc.py +++ b/docs/source/run_autodoc.py @@ -4,10 +4,11 @@ import logging import os import subprocess as sp +from pathlib import Path logging.basicConfig(level=logging.INFO) -OUTPUT_PATH = "packages" +OUTPUT_PATH = Path("packages") def replace_in_file(infile, replacements, outfile=None): @@ -26,21 +27,21 @@ def replace_in_file(infile, replacements, outfile=None): if outfile is None: outfile = infile - with open(infile) as fp: + with infile.open() as fp: content = fp.read() for key, value in replacements.items(): content = content.replace(key, value) - with open(outfile, "w") as fp: + with outfile.open("w") as fp: fp.write(content) def main(folder="droplets"): # remove old files - for path in glob.glob(f"{OUTPUT_PATH}/*.rst"): + for path in OUTPUT_PATH.glob("*.rst"): logging.info("Remove file `%s`", path) - os.remove(path) + path.unlink() # run sphinx-apidoc sp.check_call( @@ -50,7 +51,7 @@ def main(folder="droplets"): "--maxdepth", "4", "--output-dir", - OUTPUT_PATH, + str(OUTPUT_PATH), "--module-first", f"../../{folder}", # path of the package f"../../{folder}/tests", # ignored path @@ -59,7 +60,7 @@ def main(folder="droplets"): ) # replace unwanted information - for path in glob.glob(f"{OUTPUT_PATH}/*.rst"): + for path in OUTPUT_PATH.glob("*.rst"): logging.info("Patch file `%s`", path) replace_in_file(path, {"Submodules\n----------\n\n": ""}) diff --git a/droplets/droplet_tracks.py b/droplets/droplet_tracks.py index 1e040ad..8029a6b 100644 --- a/droplets/droplet_tracks.py +++ b/droplets/droplet_tracks.py @@ -328,7 +328,7 @@ def _write_hdf_dataset(self, hdf_path, key: str = "droplet_track"): else: # create empty dataset to indicate empty emulsion - dataset = hdf_path.create_dataset(key, shape=tuple()) + dataset = hdf_path.create_dataset(key, shape=()) dataset.attrs["droplet_class"] = "None" return dataset @@ -549,7 +549,7 @@ def match_tracks( tracks.append(DropletTrack(droplets=[droplet], times=[time])) if found_multiple_overlap: - logger.debug(f"Found multiple overlapping droplet(s) at t={time}") + logger.debug("Found multiple overlapping droplet(s) at t=%g", time) elif method == "distance": # track droplets by their physical distance @@ -591,11 +591,11 @@ def match_tracks( tracks.append(DropletTrack(droplets=[droplet], times=[time])) else: - raise ValueError(f"Unknown tracking method {method}") + raise ValueError("Unknown tracking method `%s`", method) # check kwargs if kwargs: - logger.warning(f"Unused keyword arguments: {kwargs}") + logger.warning("Unused keyword arguments: %s", kwargs) # add all emulsions successively using the given algorithm t_last = None diff --git a/droplets/droplets.py b/droplets/droplets.py index 821aeaf..1ebdfe7 100644 --- a/droplets/droplets.py +++ b/droplets/droplets.py @@ -192,7 +192,6 @@ def __eq__(self, other): def check_data(self): """Method that checks the validity and consistency of self.data.""" - pass @property def _args(self): @@ -1092,8 +1091,10 @@ def __init__( opt_modes = spherical.spherical_index_count(l) - 1 logger.warning( "The length of `amplitudes` should be such that all orders are " - f"captured for the perturbations with the highest degree ({l}). " - f"Consider increasing the size of the array to {opt_modes}." + "captured for the perturbations with the highest degree (%d). " + "Consider increasing the size of the array to %d.", + l, + opt_modes, ) @preserve_scalars diff --git a/droplets/emulsions.py b/droplets/emulsions.py index 7691130..e27e9e8 100644 --- a/droplets/emulsions.py +++ b/droplets/emulsions.py @@ -373,7 +373,7 @@ def _write_hdf_dataset(self, hdf_path, key: str = "emulsion"): else: # create empty dataset to indicate empty emulsion - dataset = hdf_path.create_dataset(key, shape=tuple()) + dataset = hdf_path.create_dataset(key, shape=()) dataset.attrs["droplet_class"] = "None" return dataset @@ -675,15 +675,15 @@ def plot( if len(self) == 0: # empty emulsions can be plotted in all dimensions :) return PlotReference(ax, [], {}) + if self.dim is None or self.dim <= 1: raise NotImplementedError( f"Plotting emulsions in {self.dim} dimensions is not implemented." ) - elif self.dim > 2: - if Emulsion._show_projection_warning: - logger = logging.getLogger(self.__class__.__name__) - logger.warning("A projection on the first two axes is shown.") - Emulsion._show_projection_warning = False + elif self.dim > 2 and Emulsion._show_projection_warning: + logger = logging.getLogger(self.__class__.__name__) + logger.warning("A projection on the first two axes is shown.") + Emulsion._show_projection_warning = False # plot background and determine bounds for the droplets if field is not None: diff --git a/droplets/image_analysis.py b/droplets/image_analysis.py index e8f1165..4a38832 100644 --- a/droplets/image_analysis.py +++ b/droplets/image_analysis.py @@ -107,7 +107,7 @@ def _locate_droplets_in_mask_cartesian(mask: ScalarField) -> Emulsion: # locate individual clusters in the padded image labels, num_labels = ndimage.label(mask.data) - grid._logger.info(f"Found {num_labels} cluster(s) in image") + grid._logger.info("Found %d cluster(s) in image", num_labels) if num_labels == 0: example_drop = SphericalDroplet(np.zeros(grid.dim), radius=0) return Emulsion.empty(example_drop) @@ -164,11 +164,11 @@ def _locate_droplets_in_mask_cartesian(mask: ScalarField) -> Emulsion: emulsion = Emulsion(droplets) num_candidates = len(emulsion) if num_candidates < num_labels: - grid._logger.info(f"Only {num_candidates} candidate(s) inside bounds") + grid._logger.info("Only %d candidate(s) inside bounds", num_candidates) emulsion.remove_overlapping(grid=grid) if len(emulsion) < num_candidates: - grid._logger.info(f"Only {num_candidates} candidate(s) not overlapping") + grid._logger.info("Only %d candidate(s) not overlapping", num_candidates) return emulsion @@ -212,8 +212,6 @@ def _locate_droplets_in_mask_spherical(mask: ScalarField) -> Emulsion: class _SpanningDropletSignal(RuntimeError): """Exception signaling that an untypical droplet spanning the system was found.""" - ... - def _locate_droplets_in_mask_cylindrical_single( grid: CylindricalSymGrid, mask: np.ndarray @@ -299,7 +297,7 @@ def _locate_droplets_in_mask_cylindrical(mask: ScalarField) -> Emulsion: except _SpanningDropletSignal: pass else: - grid._logger.info(f"Found {len(candidates)} droplet candidates.") + grid._logger.info("Found %d droplet candidates.", len(candidates)) # keep droplets that are inside the central area droplets = Emulsion() @@ -310,7 +308,7 @@ def _locate_droplets_in_mask_cylindrical(mask: ScalarField) -> Emulsion: if z_min <= droplet.position[2] <= z_max: droplets.append(droplet) - grid._logger.info(f"Kept {len(droplets)} central droplets.") + grid._logger.info("Kept %d central droplets.", len(droplets)) # filter overlapping droplets (e.g. due to duplicates) droplets.remove_overlapping() @@ -373,7 +371,7 @@ def locate_droplets( field, threshold="auto", refine=True, - refine_args={'vmin': None, 'vmax': None}, + refine_args={"vmin": None, "vmax": None}, ) :code:`field` is the scalar field, in which the droplets are located. The @@ -840,8 +838,9 @@ def get_length_scale( for window_size in [5, 1, 0.2]: bracket = [max_est / window_size, max_est, max_est * window_size] logger.debug( - f"Search maximum of structure factor in interval {bracket} with " - f"window_size={window_size}" + "Seek maximal structure factor in interval %s with window_size=%g", + bracket, + window_size, ) try: result = optimize.minimize_scalar( @@ -854,7 +853,8 @@ def get_length_scale( if not result.success: logger.warning( "Maximization of structure factor resulted in the " - f"following message: {result.message}" + "following message: %s", + result.message, ) length_scale = 2 * np.pi / result.x break # found some answer, which we will use diff --git a/droplets/resources/make_spheres_3d.py b/droplets/resources/make_spheres_3d.py index 58658ac..e3fb899 100755 --- a/droplets/resources/make_spheres_3d.py +++ b/droplets/resources/make_spheres_3d.py @@ -54,4 +54,4 @@ num_list.add(num) # store the number of generated spheres - f.attrs["num_list"] = list(sorted(num_list)) + f.attrs["num_list"] = sorted(num_list) diff --git a/droplets/trackers.py b/droplets/trackers.py index b594cdd..ffc4d28 100644 --- a/droplets/trackers.py +++ b/droplets/trackers.py @@ -12,6 +12,7 @@ from __future__ import annotations import math +from pathlib import Path from typing import Any, Callable, Literal from pde.fields.base import FieldBase @@ -112,7 +113,7 @@ def finalize(self, info: InfoDict | None = None) -> None: import json data = {"times": self.times, "length_scales": self.length_scales} - with open(self.filename, "w") as fp: + with Path(self.filename).open("w") as fp: json.dump(data, fp) @@ -153,7 +154,7 @@ def __init__( .. code-block:: python droplet_tracker = DropletTracker( - 1, refine=True, refine_args={'vmin': None, 'vmax': None} + 1, refine=True, refine_args={"vmin": None, "vmax": None} ) :code:`field` is the scalar field, in which the droplets are located. The diff --git a/examples/tutorial/Using the py-droplets package.ipynb b/examples/tutorial/Using the py-droplets package.ipynb index cc47b24..e15685f 100644 --- a/examples/tutorial/Using the py-droplets package.ipynb +++ b/examples/tutorial/Using the py-droplets package.ipynb @@ -19,6 +19,7 @@ "\n", "# import necessary packages\n", "import pde\n", + "\n", "import droplets" ] }, diff --git a/pyproject.toml b/pyproject.toml index d844cd3..fb82667 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,41 @@ namespaces = false [tool.setuptools_scm] write_to = "droplets/_version.py" +[tool.ruff] +target-version = "py38" +exclude = ["scripts/templates"] + +[tool.ruff.format] +docstring-code-format = true + +[tool.ruff.lint] +select = [ + "UP", # pyupgrade + "I", # isort + "A", # flake8-builtins + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "FA", # flake8-future-annotations + "ISC", # flake8-implicit-str-concat + "ICN", # flake8-import-conventions + "LOG", # flake8-logging + "G", # flake8-logging-format + "PIE", # flake8-pie + "PT", # flake8-pytest-style + "Q", # flake8-quotes + "RSE", # flake8-raise + "RET", # flake8-return + "SIM", # flake8-simplify + "PTH", # flake8-use-pathlib +] +ignore = ["B007", "B027", "B028", "SIM108", "ISC001", "PT006", "PT011", "RET504", "RET505", "RET506"] + +[tool.ruff.lint.isort] +section-order = ["future", "standard-library", "third-party", "first-party", "my-modules", "self", "local-folder"] + +[tool.ruff.lint.isort.sections] +my-modules = ["pde"] +self = ["droplets"] [tool.black] target_version = ["py39"] diff --git a/scripts/format_code.sh b/scripts/format_code.sh index 0d91fc6..8608a54 100755 --- a/scripts/format_code.sh +++ b/scripts/format_code.sh @@ -7,10 +7,10 @@ find . -name '*.py' -exec pyupgrade --py39-plus {} + popd > /dev/null echo "Formating import statements..." -isort .. +ruff check --fix --config=../pyproject.toml .. echo "Formating docstrings..." docformatter --in-place --black --recursive .. echo "Formating source code..." -black .. \ No newline at end of file +ruff format --config=../pyproject.toml .. \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 7c93873..4573b9e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,7 @@ from pde.tools.numba import random_seed -@pytest.fixture(scope="function", autouse=False, name="rng") +@pytest.fixture(autouse=False, name="rng") def init_random_number_generators(): """Get a random number generator and set the seed of the random number generator. @@ -21,8 +21,8 @@ def init_random_number_generators(): return np.random.default_rng(0) -@pytest.fixture(scope="function", autouse=True) -def setup_and_teardown(): +@pytest.fixture(autouse=True) +def _setup_and_teardown(): """Helper function adjusting environment before and after tests.""" # ensure we use the Agg backend, so figures are not displayed plt.switch_backend("agg") diff --git a/tests/requirements.txt b/tests/requirements.txt index 8da3fa0..eee7d9c 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,11 +1,10 @@ -r ../requirements.txt -black>=24 docformatter>=1.7 -isort>=5.1 pyupgrade>=3 pytest>=5.4 pytest-cov>=2.8 pytest-xdist>=1.30 mypy>=0.770 +ruff>=0.6 types-PyYAML types-tqdm \ No newline at end of file diff --git a/tests/test_droplets.py b/tests/test_droplets.py index fe0d449..255fd9c 100644 --- a/tests/test_droplets.py +++ b/tests/test_droplets.py @@ -57,7 +57,7 @@ def test_random_droplet(dim, rng): def test_perturbed_droplet_2d(): """Test methods of perturbed droplets in 2d.""" d = droplets.PerturbedDroplet2D([0, 1], 1, 0.1, [0.0, 0.1, 0.2]) - d.volume + assert d.volume > 0 d.interface_distance(0.1) d.interface_position(0.1) d.interface_curvature(0.1) @@ -66,7 +66,7 @@ def test_perturbed_droplet_2d(): def test_perturbed_droplet_3d(): """Test methods of perturbed droplets in 3d.""" d = droplets.PerturbedDroplet3D([0, 1, 2], 1, 0.1, [0.0, 0.1, 0.2, 0.3]) - d.volume_approx + assert d.volume_approx > 0 d.interface_distance(0.1, 0.2) d.interface_position(0.1, 0.2) d.interface_curvature(0.1, 0.2) @@ -75,7 +75,7 @@ def test_perturbed_droplet_3d(): def test_perturbed_droplet_3d_axis_sym(): """Test methods of axisymmetrically perturbed droplets in 3d.""" d = droplets.PerturbedDroplet3DAxisSym([0, 0, 0], 1, 0.1, [0.0, 0.1]) - d.volume_approx + assert d.volume_approx > 0 d.interface_distance(0.1) d.interface_curvature(0.1) @@ -251,7 +251,8 @@ def test_droplet_merge(cls, dim): # test simple merge d3 = d1.merge(d2, inplace=False) - assert d3 is not d1 and d3 is not d2 + assert d3 is not d1 + assert d3 is not d2 np.testing.assert_allclose(d3.position, [1] * dim) assert d3.volume == pytest.approx(d1.volume + d2.volume) if cls == droplets.DiffuseDroplet: @@ -268,7 +269,8 @@ def test_droplet_merge(cls, dim): # test inplace merge d4 = d1.merge(d2, inplace=True) - assert d4 is d1 and d3 is not d2 + assert d4 is d1 + assert d3 is not d2 np.testing.assert_allclose(d4.position, [1] * dim) assert d4.volume == pytest.approx(d3.volume) if cls == droplets.DiffuseDroplet: diff --git a/tests/test_emulsion.py b/tests/test_emulsion.py index b2e0116..e221832 100644 --- a/tests/test_emulsion.py +++ b/tests/test_emulsion.py @@ -46,7 +46,7 @@ def test_empty_emulsion(caplog): e = Emulsion([]) assert len(e) == 0 with pytest.raises(RuntimeError): - e.data + print(e.data) e.append(d1, force_consistency=True) assert e.dtype == d1.data.dtype @@ -73,7 +73,7 @@ def test_empty_emulsion(caplog): e.append(d2) assert len(e) == 1 - e.data # raises warning + print(e.data) # raises warning assert "inconsistent" in caplog.text @@ -136,7 +136,7 @@ def test_emulsion_incompatible(): e = Emulsion([d1, d2]) assert len(e) == 2 with pytest.raises(TypeError): - e.data + print(e.data) # same type d1 = SphericalDroplet([1], 2) @@ -293,8 +293,10 @@ def test_emulsion_random(dim, grid, rng): em = Emulsion.from_random(10, bounds, radius=(1, 2), rng=rng) assert 1 < len(em) <= 10 assert em.dim == dim - assert np.all(em.data["position"] > 10) and np.all(em.data["position"] < 30) - assert np.all(em.data["radius"] > 1) and np.all(em.data["radius"] < 2) + assert np.all(em.data["position"] > 10) + assert np.all(em.data["position"] < 30) + assert np.all(em.data["radius"] > 1) + assert np.all(em.data["radius"] < 2) @pytest.mark.parametrize("proc", [1, 2]) diff --git a/tests/test_examples.py b/tests/test_examples.py index 0dbf274..ed0a99a 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -2,6 +2,8 @@ .. codeauthor:: David Zwicker """ +from __future__ import annotations + import os import subprocess as sp import sys @@ -39,7 +41,7 @@ def test_example(path): proc.kill() outs, errs = proc.communicate() - msg = "Script `%s` failed with following output:" % path + msg = f"Script `{path}` failed with following output:" if outs: msg = f"{msg}\nSTDOUT:\n{outs}" if errs: diff --git a/tests/test_spherical.py b/tests/test_spherical.py index b0e0c10..c98ddef 100644 --- a/tests/test_spherical.py +++ b/tests/test_spherical.py @@ -119,7 +119,7 @@ def test_spherical_harmonics_real(): for m2 in range(-deg, m1 + 1): def integrand(t, p): - return Ylm(deg, m1, t, p) * Ylm(deg, m2, t, p) * np.sin(t) + return Ylm(deg, m1, t, p) * Ylm(deg, m2, t, p) * np.sin(t) # noqa: B023 overlap = integrate.dblquad( integrand, 0, 2 * np.pi, lambda _: 0, lambda _: np.pi