diff --git a/pyproject.toml b/pyproject.toml index a10fdd5..0162a7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,6 @@ dynamic = ["version"] [project.optional-dependencies] extra = [ # extra requirements that may be dropped at some point -# "bluepy>=2.5.2", "fastparquet>=0.8.3,!=2023.1.0", # needed by pandas to read and write parquet files "orjson", # faster json decoder used by fastparquet "tables>=3.6.1", # needed by pandas to read and write hdf files @@ -117,13 +116,15 @@ source = [ branch = true parallel = false omit = [ - "src/blueetl/_version.py", + "*/blueetl/_version.py", + "*/blueetl/adapters/bluepy/*.py", + "*/blueetl/external/**/*.py", ] [tool.coverage.report] show_missing = true precision = 0 -fail_under = 80 +fail_under = 70 [tool.pydocstyle] # D413: no blank line after last section @@ -140,6 +141,8 @@ extension-pkg-allow-list = ["numpy", "lxml", "pydantic"] # List of plugins (as comma separated values of python module names) to load, # usually to register additional checkers. load-plugins = ["pylint_pydantic"] +# List of module names for which member attributes should not be checked. +ignored-modules = ["bluepy"] [tool.pylint.design] # Maximum number of arguments for function / method. diff --git a/src/blueetl/adapters/bluepy/__init__.py b/src/blueetl/adapters/bluepy/__init__.py index fec03dc..0e094c3 100644 --- a/src/blueetl/adapters/bluepy/__init__.py +++ b/src/blueetl/adapters/bluepy/__init__.py @@ -1 +1,5 @@ """Bluepy implementation.""" +from blueetl.utils import import_optional_dependency + +# Immediately raise an error with a custom message if the submodule is imported +import_optional_dependency("bluepy") diff --git a/src/blueetl/adapters/bluepy/circuit.py b/src/blueetl/adapters/bluepy/circuit.py index d9a0362..9f1e724 100644 --- a/src/blueetl/adapters/bluepy/circuit.py +++ b/src/blueetl/adapters/bluepy/circuit.py @@ -2,13 +2,13 @@ from collections.abc import Mapping from typing import Optional -import bluepy +from bluepy import Circuit from blueetl.adapters.interfaces.circuit import CircuitInterface, NodePopulationInterface from blueetl.utils import checksum_json -class CircuitImpl(CircuitInterface[bluepy.Circuit]): +class CircuitImpl(CircuitInterface[Circuit]): """Bluepy circuit implementation.""" def checksum(self) -> str: diff --git a/src/blueetl/adapters/bluepy/simulation.py b/src/blueetl/adapters/bluepy/simulation.py index ad4c2e0..da7e5ad 100644 --- a/src/blueetl/adapters/bluepy/simulation.py +++ b/src/blueetl/adapters/bluepy/simulation.py @@ -4,8 +4,8 @@ from functools import cached_property from typing import Optional, Union -import bluepy import pandas as pd +from bluepy import Simulation from bluepy.exceptions import BluePyError from bluepy.impl.compartment_report import CompartmentReport, SomaReport from bluepy.impl.spike_report import SpikeReport @@ -53,7 +53,7 @@ def get(self, group=None, t_start=None, t_stop=None, t_step=None) -> pd.DataFram class ReportCollection(UserDict): """Collection of reports as: name -> population -> report.""" - def __init__(self, simulation: bluepy.Simulation) -> None: + def __init__(self, simulation: Simulation) -> None: """Init the report collection with the specified simulation.""" super().__init__() self._simulation = simulation @@ -68,7 +68,7 @@ def __getitem__(self, name) -> Mapping[Optional[str], PopulationReportInterface] return self.data[name] -class SimulationImpl(SimulationInterface[bluepy.Simulation]): +class SimulationImpl(SimulationInterface[Simulation]): """Bluepy simulation implementation.""" def is_complete(self) -> bool: diff --git a/src/blueetl/adapters/bluepysnap/circuit.py b/src/blueetl/adapters/bluepysnap/circuit.py index 20ea640..cb88ddb 100644 --- a/src/blueetl/adapters/bluepysnap/circuit.py +++ b/src/blueetl/adapters/bluepysnap/circuit.py @@ -2,13 +2,13 @@ from collections.abc import Mapping from typing import Optional -import bluepysnap +from bluepysnap import Circuit from blueetl.adapters.interfaces.circuit import CircuitInterface, NodePopulationInterface from blueetl.utils import checksum_json -class CircuitImpl(CircuitInterface[bluepysnap.Circuit]): +class CircuitImpl(CircuitInterface[Circuit]): """Bluepysnap circuit implementation.""" def checksum(self) -> str: diff --git a/src/blueetl/adapters/bluepysnap/simulation.py b/src/blueetl/adapters/bluepysnap/simulation.py index 7ce8493..248cbe2 100644 --- a/src/blueetl/adapters/bluepysnap/simulation.py +++ b/src/blueetl/adapters/bluepysnap/simulation.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Optional -import bluepysnap +from bluepysnap import Simulation from blueetl.adapters.bluepysnap.circuit import CircuitImpl from blueetl.adapters.interfaces.circuit import CircuitInterface @@ -15,7 +15,7 @@ ) -class SimulationImpl(SimulationInterface[bluepysnap.Simulation]): +class SimulationImpl(SimulationInterface[Simulation]): """Bluepysnap simulation implementation.""" def is_complete(self) -> bool: diff --git a/src/blueetl/adapters/circuit.py b/src/blueetl/adapters/circuit.py index c3f46ce..1760ff4 100644 --- a/src/blueetl/adapters/circuit.py +++ b/src/blueetl/adapters/circuit.py @@ -17,13 +17,9 @@ def _load_impl(self, config: str) -> Optional[CircuitInterface]: return None CircuitImpl: type[CircuitInterface] if config.endswith(".json"): - from bluepysnap import Circuit - - from blueetl.adapters.bluepysnap.circuit import CircuitImpl + from blueetl.adapters.bluepysnap.circuit import Circuit, CircuitImpl else: - from bluepy import Circuit - - from blueetl.adapters.bluepy.circuit import CircuitImpl + from blueetl.adapters.bluepy.circuit import Circuit, CircuitImpl return CircuitImpl(Circuit(config)) def checksum(self) -> str: diff --git a/src/blueetl/adapters/simulation.py b/src/blueetl/adapters/simulation.py index 7a9ec4e..5d816f3 100644 --- a/src/blueetl/adapters/simulation.py +++ b/src/blueetl/adapters/simulation.py @@ -22,13 +22,9 @@ def _load_impl(self, config: str) -> Optional[SimulationInterface]: return None SimulationImpl: type[SimulationInterface] if config.endswith(".json"): - from bluepysnap import Simulation - - from blueetl.adapters.bluepysnap.simulation import SimulationImpl + from blueetl.adapters.bluepysnap.simulation import Simulation, SimulationImpl else: - from bluepy import Simulation - - from blueetl.adapters.bluepy.simulation import SimulationImpl + from blueetl.adapters.bluepy.simulation import Simulation, SimulationImpl return SimulationImpl(Simulation(config)) def is_complete(self) -> bool: diff --git a/src/blueetl/utils.py b/src/blueetl/utils.py index da84e04..d95edea 100644 --- a/src/blueetl/utils.py +++ b/src/blueetl/utils.py @@ -1,5 +1,6 @@ """Common utilities.""" import hashlib +import importlib import itertools import json import logging @@ -8,7 +9,6 @@ from collections.abc import Iterable, Iterator from contextlib import contextmanager from functools import cache, cached_property -from importlib import import_module from pathlib import Path from typing import Any, Callable, Optional, Union @@ -123,7 +123,7 @@ def import_by_string(full_name: str) -> Callable: The imported function. """ module_name, _, func_name = full_name.rpartition(".") - return getattr(import_module(module_name), func_name) + return getattr(importlib.import_module(module_name), func_name) def resolve_path(*paths: StrOrPath, symlinks: bool = False) -> Path: @@ -269,3 +269,24 @@ def all_equal(iterable: Iterable) -> bool: return False prev = item return True + + +def import_optional_dependency(name: str) -> Any: + """Import an optional dependency. + + If a dependency is missing, an ImportError with a custom message is raised. Based on: + https://github.com/pandas-dev/pandas/blob/0d853e77/pandas/compat/_optional.py#L85 + + Args: + name: The module name. + + Returns: + ModuleType: The imported module, if found. + """ + try: + module = importlib.import_module(name) + except ImportError as ex: + msg = f"Missing optional dependency {name!r}. Use pip to install it." + raise ImportError(msg) from ex + + return module diff --git a/tests/unit/adapters/test_circuit.py b/tests/unit/adapters/test_circuit.py index 59ab3d1..a2ce8ca 100644 --- a/tests/unit/adapters/test_circuit.py +++ b/tests/unit/adapters/test_circuit.py @@ -1,13 +1,10 @@ import pickle -import bluepy -import bluepysnap import pytest -import bluepysnap.nodes + from blueetl.adapters import circuit as test_module from blueetl.adapters.base import AdapterError -from tests.unit.utils import TEST_DATA_PATH -import bluepy.cells +from tests.unit.utils import BLUEPY_AVAILABLE, TEST_DATA_PATH, assert_isinstance @pytest.mark.parametrize( @@ -18,8 +15,8 @@ "sonata/circuit_config.json", "default", { - "circuit": bluepysnap.Circuit, - "population": bluepysnap.nodes.NodePopulation, + "circuit": "bluepysnap.Circuit", + "population": "bluepysnap.nodes.NodePopulation", }, id="snap", ) @@ -29,10 +26,11 @@ "bbp/CircuitConfig", None, { - "circuit": bluepy.Circuit, - "population": bluepy.cells.CellCollection, + "circuit": "bluepy.Circuit", + "population": "bluepy.cells.CellCollection", }, id="bluepy", + marks=pytest.mark.skipif(not BLUEPY_AVAILABLE, reason="bluepy not available"), ) ), ], @@ -42,11 +40,11 @@ def test_circuit_adapter(path, population, expected_classes, monkeypatch): # enter the circuit dir to resolve relative paths in bluepy monkeypatch.chdir(path.parent) obj = test_module.CircuitAdapter(TEST_DATA_PATH / path) - assert isinstance(obj.instance, expected_classes["circuit"]) + assert_isinstance(obj.instance, expected_classes["circuit"]) # access methods and properties pop = obj.nodes[population] - assert isinstance(pop, expected_classes["population"]) + assert_isinstance(pop, expected_classes["population"]) checksum = obj.checksum() assert isinstance(checksum, str) @@ -56,7 +54,7 @@ def test_circuit_adapter(path, population, expected_classes, monkeypatch): loaded = pickle.loads(dumped) assert isinstance(loaded, test_module.CircuitAdapter) - assert isinstance(loaded.instance, expected_classes["circuit"]) + assert_isinstance(loaded.instance, expected_classes["circuit"]) # no cached_properties should be loaded after unpickling assert sorted(loaded.__dict__) == ["_impl"] assert sorted(loaded._impl.__dict__) == ["_circuit"] diff --git a/tests/unit/adapters/test_simulation.py b/tests/unit/adapters/test_simulation.py index 5707038..eca6332 100644 --- a/tests/unit/adapters/test_simulation.py +++ b/tests/unit/adapters/test_simulation.py @@ -1,17 +1,10 @@ import pickle -import bluepy.cells -import bluepy.impl.compartment_report -import bluepy.impl.spike_report -import bluepysnap.frame_report -import bluepysnap.nodes -import bluepysnap.spike_report import pytest from blueetl.adapters import simulation as test_module from blueetl.adapters.base import AdapterError -from blueetl.adapters.bluepy.simulation import PopulationReportImpl, PopulationSpikesReportImpl -from tests.unit.utils import TEST_DATA_PATH +from tests.unit.utils import BLUEPY_AVAILABLE, TEST_DATA_PATH, assert_isinstance @pytest.mark.parametrize( @@ -23,11 +16,11 @@ "default", ["soma_report", "section_report"], { - "simulation": bluepysnap.Simulation, - "population": bluepysnap.nodes.NodePopulation, - "spikes": bluepysnap.spike_report.PopulationSpikeReport, - "soma_report": bluepysnap.frame_report.PopulationSomaReport, - "section_report": bluepysnap.frame_report.PopulationCompartmentReport, + "simulation": "bluepysnap.Simulation", + "population": "bluepysnap.nodes.NodePopulation", + "spikes": "bluepysnap.spike_report.PopulationSpikeReport", + "soma_report": "bluepysnap.frame_report.PopulationSomaReport", + "section_report": "bluepysnap.frame_report.PopulationCompartmentReport", }, id="snap", ) @@ -38,13 +31,14 @@ None, ["soma", "AllCompartments"], { - "simulation": bluepy.Simulation, - "population": bluepy.cells.CellCollection, - "spikes": PopulationSpikesReportImpl, - "soma": PopulationReportImpl, - "AllCompartments": PopulationReportImpl, + "simulation": "bluepy.Simulation", + "population": "bluepy.cells.CellCollection", + "spikes": "blueetl.adapters.bluepy.simulation.PopulationSpikesReportImpl", + "soma": "blueetl.adapters.bluepy.simulation.PopulationReportImpl", + "AllCompartments": "blueetl.adapters.bluepy.simulation.PopulationReportImpl", }, id="bluepy", + marks=pytest.mark.skipif(not BLUEPY_AVAILABLE, reason="bluepy not available"), ) ), ], @@ -54,28 +48,28 @@ def test_simulation_adapter(path, population, reports, expected_classes, monkeyp # enter the circuit dir to resolve relative paths in bluepy monkeypatch.chdir(path.parent) obj = test_module.SimulationAdapter(TEST_DATA_PATH / path) - assert isinstance(obj.instance, expected_classes["simulation"]) + assert_isinstance(obj.instance, expected_classes["simulation"]) assert obj.exists() is True assert obj.is_complete() is True # access methods and properties pop = obj.circuit.nodes[population] - assert isinstance(pop, expected_classes["population"]) + assert_isinstance(pop, expected_classes["population"]) spikes = obj.spikes[population] - assert isinstance(spikes, expected_classes["spikes"]) + assert_isinstance(spikes, expected_classes["spikes"]) for report_name in reports: report = obj.reports[report_name][population] - assert isinstance(report, expected_classes[report_name]) + assert_isinstance(report, expected_classes[report_name]) # test pickle roundtrip dumped = pickle.dumps(obj) loaded = pickle.loads(dumped) assert isinstance(loaded, test_module.SimulationAdapter) - assert isinstance(loaded.instance, expected_classes["simulation"]) + assert_isinstance(loaded.instance, expected_classes["simulation"]) # no cached_properties should be loaded after unpickling assert sorted(loaded.__dict__) == ["_impl"] assert sorted(loaded._impl.__dict__) == ["_simulation"] diff --git a/tests/unit/utils.py b/tests/unit/utils.py index e5d2a95..0b061c7 100644 --- a/tests/unit/utils.py +++ b/tests/unit/utils.py @@ -1,9 +1,17 @@ +import importlib from pathlib import Path import pandas as pd import pydantic import xarray as xr +try: + import bluepy + + BLUEPY_AVAILABLE = True +except ImportError: + BLUEPY_AVAILABLE = False + TEST_DATA_PATH = Path(__file__).parent / "data" @@ -34,9 +42,16 @@ def iterallvalues(obj): def assert_not_duplicates(obj): - # verify that obj doesn't contain duplicate instances + """Verify that obj doesn't contain duplicate instances.""" ids = set() for v in iterallvalues(obj): if isinstance(v, (dict, list, tuple)): assert id(v) not in ids, f"Duplicate {type(v).__name__}: {v}" ids.add(id(v)) + + +def assert_isinstance(instance, class_name): + """Verify that instance is an instance of class_name (given as string).""" + module_name, _, class_name = class_name.rpartition(".") + cls = getattr(importlib.import_module(module_name), class_name) + assert isinstance(instance, cls) diff --git a/tox.ini b/tox.ini index 57e781b..514881c 100644 --- a/tox.ini +++ b/tox.ini @@ -43,6 +43,7 @@ deps = {[base]testdeps} brion;platform_system=='Linux' elephant>=0.10.0,<0.13.0 # CPDF output changed in 0.13.0 + bluepy>=2.5.2 commands = python -m pytest -vs tests/functional {posargs} [testenv:check-packaging]