diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index e6356ee..71ff7da 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -5,6 +5,10 @@ optional solver dependency because it is difficult to build on common pip-only environments, is not published on PyPI above version 3.0, and is only needed for the solver-backed unit-operation models. Use the conda-forge environment in `environment.yml`, or a local source build of Assimulo, for solver-backed runs. +Unit-operation modules and their solver-free calculations remain importable +without Assimulo. Attempting to construct an Assimulo-backed problem or solver +without that optional dependency raises an installation error at the solver +boundary. Current bounds: diff --git a/PharmaPy/Containers.py b/PharmaPy/Containers.py index 52d6c5c..746034d 100644 --- a/PharmaPy/Containers.py +++ b/PharmaPy/Containers.py @@ -5,8 +5,7 @@ @author: dcasasor """ -from assimulo.solvers import CVode -from assimulo.problem import Explicit_Problem +from PharmaPy._assimulo import CVode, Explicit_Problem from PharmaPy.Phases import LiquidPhase, SolidPhase, classify_phases from PharmaPy.Streams import LiquidStream, SolidStream diff --git a/PharmaPy/Crystallizers.py b/PharmaPy/Crystallizers.py index 93211f5..3467f9f 100644 --- a/PharmaPy/Crystallizers.py +++ b/PharmaPy/Crystallizers.py @@ -4,8 +4,7 @@ # import autograd.numpy as np -from assimulo.solvers import CVode -from assimulo.problem import Explicit_Problem +from PharmaPy._assimulo import CVode, Explicit_Problem from PharmaPy.Phases import classify_phases from PharmaPy.Streams import LiquidStream, SolidStream diff --git a/PharmaPy/Distillation.py b/PharmaPy/Distillation.py index f4ad02f..aa33ba0 100644 --- a/PharmaPy/Distillation.py +++ b/PharmaPy/Distillation.py @@ -1,5 +1,5 @@ import numpy as np -from assimulo.problem import Implicit_Problem +from PharmaPy._assimulo import IDA, Implicit_Problem from PharmaPy.Phases import classify_phases from PharmaPy.Streams import VaporStream from PharmaPy.Connections import get_inputs_new @@ -10,8 +10,6 @@ from PharmaPy.Plotting import plot_distrib from PharmaPy.CheckModule import check_modeling_objects -from assimulo.solvers import IDA - import scipy.optimize import scipy.sparse diff --git a/PharmaPy/Drying_Model.py b/PharmaPy/Drying_Model.py index 47e847e..37d87b5 100644 --- a/PharmaPy/Drying_Model.py +++ b/PharmaPy/Drying_Model.py @@ -6,8 +6,7 @@ """ import numpy as np import numpy.matlib -from assimulo.problem import Explicit_Problem -from assimulo.solvers import CVode +from PharmaPy._assimulo import CVode, Explicit_Problem import matplotlib.pyplot as plt import scipy from scipy.interpolate import CubicSpline diff --git a/PharmaPy/DynamicExtraction.py b/PharmaPy/DynamicExtraction.py index f894012..4171380 100644 --- a/PharmaPy/DynamicExtraction.py +++ b/PharmaPy/DynamicExtraction.py @@ -1,7 +1,7 @@ import numpy as np from scipy import linalg from scipy.optimize import root -from assimulo.problem import Implicit_Problem +from PharmaPy._assimulo import IDA, Implicit_Problem from PharmaPy.Phases import classify_phases from PharmaPy.Connections import get_inputs_new @@ -12,9 +12,6 @@ from PharmaPy.Extractors import BatchExtractor, validate_gamma_method from PharmaPy.Plotting import plot_distrib -from assimulo.solvers import IDA -from assimulo.solvers import Radau5DAE - import copy from matplotlib.ticker import MaxNLocator, AutoMinorLocator diff --git a/PharmaPy/Evaporators.py b/PharmaPy/Evaporators.py index f2f2b6a..49762c4 100644 --- a/PharmaPy/Evaporators.py +++ b/PharmaPy/Evaporators.py @@ -5,7 +5,9 @@ # from autograd import numpy as np # from autograd import jacobian as jacauto from PharmaPy.Commons import (mid_fn, trapezoidal_rule, eval_state_events, - handle_events, unpack_states, flatten_states) + handle_events, unpack_states, flatten_states, + TerminateSimulation) +from PharmaPy._assimulo import IDA, Implicit_Problem from PharmaPy.Connections import get_inputs, get_inputs_new from PharmaPy.Streams import LiquidStream, VaporStream from PharmaPy.Phases import LiquidPhase, VaporPhase, classify_phases @@ -17,10 +19,6 @@ import matplotlib.pyplot as plt from matplotlib.ticker import AutoMinorLocator -from assimulo.problem import Implicit_Problem -from assimulo.solvers import IDA -from assimulo.exception import TerminateSimulation - from pathlib import Path import copy diff --git a/PharmaPy/Reactors.py b/PharmaPy/Reactors.py index d41bdb5..d8ae128 100644 --- a/PharmaPy/Reactors.py +++ b/PharmaPy/Reactors.py @@ -1,8 +1,7 @@ # -*- coding: utf-8 -*- -from assimulo.solvers import CVode, LSODAR -from assimulo.problem import Explicit_Problem +from PharmaPy._assimulo import CVode, Explicit_Problem from PharmaPy.Phases import classify_phases from PharmaPy.Commons import (reorder_sens, plot_sens, trapezoidal_rule, diff --git a/PharmaPy/SolidLiquidSep.py b/PharmaPy/SolidLiquidSep.py index 1e5c4f0..55263fe 100644 --- a/PharmaPy/SolidLiquidSep.py +++ b/PharmaPy/SolidLiquidSep.py @@ -6,12 +6,15 @@ """ import numpy as np +from PharmaPy._assimulo import CVode, Explicit_Problem from PharmaPy.Commons import trapezoidal_rule, series_erfc from PharmaPy.Phases import classify_phases from PharmaPy.MixedPhases import Slurry, Cake from PharmaPy.general_interpolation import define_initial_state -from PharmaPy.Commons import unpack_states, reorder_pde_outputs, eval_state_events, handle_events, unpack_discretized +from PharmaPy.Commons import (unpack_states, reorder_pde_outputs, + eval_state_events, handle_events, + unpack_discretized, TerminateSimulation) from PharmaPy.Connections import get_inputs_new from PharmaPy.Results import DynamicResult from PharmaPy.NameAnalysis import get_dict_states @@ -22,10 +25,6 @@ from matplotlib.animation import FFMpegWriter import copy -from assimulo.solvers import CVode -from assimulo.problem import Explicit_Problem -from assimulo.exception import TerminateSimulation - from scipy.special import erfc eps = np.finfo(float).eps * 1.1 diff --git a/PharmaPy/ThreePhaseSettler.py b/PharmaPy/ThreePhaseSettler.py index 9e29ce2..2f83b87 100644 --- a/PharmaPy/ThreePhaseSettler.py +++ b/PharmaPy/ThreePhaseSettler.py @@ -6,7 +6,7 @@ """ import numpy as np -from assimulo.problem import Implicit_Problem +from PharmaPy._assimulo import IDA, Implicit_Problem from PharmaPy.Phases import classify_phases from PharmaPy.Connections import get_inputs_new from PharmaPy.Streams import LiquidStream, SolidStream @@ -14,8 +14,6 @@ from PharmaPy.Results import DynamicResult from PharmaPy.Plotting import plot_distrib -from assimulo.solvers import IDA - import scipy.optimize import scipy.sparse diff --git a/PharmaPy/_assimulo.py b/PharmaPy/_assimulo.py new file mode 100644 index 0000000..e28f8ba --- /dev/null +++ b/PharmaPy/_assimulo.py @@ -0,0 +1,182 @@ +"""Lazy internal constructors for the optional Assimulo solver backend. + +The exported names are factory functions, not Assimulo class aliases, and +therefore are not suitable for ``isinstance`` checks or subclassing. +""" + +from importlib import import_module +from typing import Any + +_INSTALL_MESSAGE = ( + "Assimulo is required for solver-backed PharmaPy simulations. " + "Install Assimulo 3.4.3 from conda-forge using environment.yml or " + "provide a compatible local installation." +) + + +def _load_assimulo_symbol(module_name: str, symbol_name: str) -> Any: + """Load a symbol from the optional Assimulo dependency. + + Parameters + ---------- + module_name : str + Fully qualified Assimulo module name. + symbol_name : str + Attribute to load from the module. + + Returns + ------- + Any + Requested Assimulo class or callable. + + Raises + ------ + ImportError + If Assimulo or one of its required runtime components cannot be + imported. + """ + try: + module = import_module(module_name) + except ImportError as exc: + raise ImportError(_INSTALL_MESSAGE) from exc + + try: + return getattr(module, symbol_name) + except AttributeError as exc: + raise ImportError( + f"{_INSTALL_MESSAGE} The installed version does not provide " + f"{module_name}.{symbol_name}." + ) from exc + + +def _construct_assimulo_object( + module_name: str, + symbol_name: str, + *args: Any, + **kwargs: Any, +) -> Any: + """Construct an object from the optional Assimulo dependency. + + Parameters + ---------- + module_name : str + Fully qualified Assimulo module name. + symbol_name : str + Constructor name within ``module_name``. + *args : Any + Positional arguments forwarded unchanged to the constructor. + **kwargs : Any + Keyword arguments forwarded unchanged to the constructor. + + Returns + ------- + Any + Constructed Assimulo problem or solver object. + + Raises + ------ + ImportError + If the requested Assimulo constructor is unavailable. + """ + constructor = _load_assimulo_symbol(module_name, symbol_name) + return constructor(*args, **kwargs) + + +def CVode(*args: Any, **kwargs: Any) -> Any: + """Construct an Assimulo CVode solver on first use. + + Parameters + ---------- + *args : Any + Positional arguments forwarded to ``assimulo.solvers.CVode``. + **kwargs : Any + Keyword arguments forwarded to ``assimulo.solvers.CVode``. + + Returns + ------- + Any + Assimulo CVode solver instance. + + Raises + ------ + ImportError + If the optional Assimulo backend is unavailable. + """ + return _construct_assimulo_object("assimulo.solvers", "CVode", *args, **kwargs) + + +def IDA(*args: Any, **kwargs: Any) -> Any: + """Construct an Assimulo IDA solver on first use. + + Parameters + ---------- + *args : Any + Positional arguments forwarded to ``assimulo.solvers.IDA``. + **kwargs : Any + Keyword arguments forwarded to ``assimulo.solvers.IDA``. + + Returns + ------- + Any + Assimulo IDA solver instance. + + Raises + ------ + ImportError + If the optional Assimulo backend is unavailable. + """ + return _construct_assimulo_object("assimulo.solvers", "IDA", *args, **kwargs) + + +def Explicit_Problem(*args: Any, **kwargs: Any) -> Any: + """Construct an Assimulo explicit problem on first use. + + Parameters + ---------- + *args : Any + Positional arguments forwarded to + ``assimulo.problem.Explicit_Problem``. + **kwargs : Any + Keyword arguments forwarded to + ``assimulo.problem.Explicit_Problem``. + + Returns + ------- + Any + Assimulo explicit-problem instance. + + Raises + ------ + ImportError + If the optional Assimulo backend is unavailable. + """ + return _construct_assimulo_object( + "assimulo.problem", "Explicit_Problem", *args, **kwargs + ) + + +def Implicit_Problem(*args: Any, **kwargs: Any) -> Any: + """Construct an Assimulo implicit problem on first use. + + Parameters + ---------- + *args : Any + Positional arguments forwarded to + ``assimulo.problem.Implicit_Problem``. + **kwargs : Any + Keyword arguments forwarded to + ``assimulo.problem.Implicit_Problem``. + + Returns + ------- + Any + Assimulo implicit-problem instance. + + Raises + ------ + ImportError + If the optional Assimulo backend is unavailable. + """ + return _construct_assimulo_object( + "assimulo.problem", "Implicit_Problem", *args, **kwargs + ) diff --git a/tests/assimulo_helpers.py b/tests/assimulo_helpers.py deleted file mode 100644 index 6bcbd98..0000000 --- a/tests/assimulo_helpers.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Helpers for tests that import optional Assimulo-backed modules.""" - -import importlib -import sys -from types import ModuleType - - -def _stub_assimulo_modules(monkeypatch, *, solvers, problem): - """Register a minimal temporary Assimulo module tree. - - Parameters - ---------- - monkeypatch : pytest.MonkeyPatch - Pytest cleanup fixture used to restore ``sys.modules``. - solvers : dict - Solver attributes to expose on ``assimulo.solvers``. - problem : dict - Problem attributes to expose on ``assimulo.problem``. - - Notes - ----- - This monkeypatch is limited to optional import availability in test - environments where Assimulo is not installed. It does not implement solver - behavior; solver-backed integration coverage remains in Assimulo-enabled - environments. - """ - assimulo = ModuleType("assimulo") - - solvers_module = ModuleType("assimulo.solvers") - for name, value in solvers.items(): - setattr(solvers_module, name, value) - - problem_module = ModuleType("assimulo.problem") - for name, value in problem.items(): - setattr(problem_module, name, value) - - exception = ModuleType("assimulo.exception") - exception.TerminateSimulation = Exception - - monkeypatch.setitem(sys.modules, "assimulo", assimulo) - monkeypatch.setitem(sys.modules, "assimulo.solvers", solvers_module) - monkeypatch.setitem(sys.modules, "assimulo.problem", problem_module) - monkeypatch.setitem(sys.modules, "assimulo.exception", exception) - - -def import_module_with_assimulo_stub(monkeypatch, module_name, *, - solvers, problem): - """Import a module, stubbing only optional Assimulo imports if needed. - - Parameters - ---------- - monkeypatch : pytest.MonkeyPatch - Pytest cleanup fixture used only when the import needs stubbing. - module_name : str - Dotted module path to import. - solvers : dict - Solver attributes to expose on ``assimulo.solvers``. - problem : dict - Problem attributes to expose on ``assimulo.problem``. - - Returns - ------- - module - Imported module. - - Raises - ------ - ModuleNotFoundError - If a dependency other than optional Assimulo is missing. - - Notes - ----- - The temporary monkeypatch is justified because these unit tests exercise - constructor and handoff logic that can run without Assimulo, while the full - solver suite is only available when the optional Assimulo dependency is - installed. - """ - try: - return importlib.import_module(module_name) - except ModuleNotFoundError as exc: - if exc.name != "assimulo": - raise - - _stub_assimulo_modules(monkeypatch, solvers=solvers, problem=problem) - monkeypatch.delitem(sys.modules, module_name, raising=False) - return importlib.import_module(module_name) diff --git a/tests/test_cryst_kinetics.py b/tests/test_cryst_kinetics.py index 6c16d41..e2fea7f 100644 --- a/tests/test_cryst_kinetics.py +++ b/tests/test_cryst_kinetics.py @@ -1,9 +1,7 @@ -import sys -from types import ModuleType - import numpy as np import pytest +from PharmaPy.Crystallizers import MSMPR from PharmaPy.Kinetics import CrystKinetics @@ -21,37 +19,6 @@ def _primary_growth_kinetics(): return kin -def _stub_assimulo_modules(monkeypatch): - assimulo = ModuleType("assimulo") - - solvers = ModuleType("assimulo.solvers") - solvers.CVode = object - - problem = ModuleType("assimulo.problem") - problem.Explicit_Problem = object - - exception = ModuleType("assimulo.exception") - exception.TerminateSimulation = Exception - - monkeypatch.setitem(sys.modules, "assimulo", assimulo) - monkeypatch.setitem(sys.modules, "assimulo.solvers", solvers) - monkeypatch.setitem(sys.modules, "assimulo.problem", problem) - monkeypatch.setitem(sys.modules, "assimulo.exception", exception) - - -def _import_msmpr(monkeypatch): - try: - from PharmaPy.Crystallizers import MSMPR - except ModuleNotFoundError as exc: - if exc.name != "assimulo": - raise - _stub_assimulo_modules(monkeypatch) - - from PharmaPy.Crystallizers import MSMPR - - return MSMPR - - @pytest.mark.parametrize("conc", [0.5, np.array(0.5)]) def test_get_kinetics_accepts_scalar_target_concentration(conc): kin = _primary_growth_kinetics() @@ -106,9 +73,7 @@ def test_get_kinetics_uses_secondary_parameters_from_vector_update(): np.testing.assert_allclose(dissol, [0.0, 0.0]) -def test_msmpr_steady_state_accepts_scalar_seed(monkeypatch): - MSMPR = _import_msmpr(monkeypatch) - +def test_msmpr_steady_state_accepts_scalar_seed(): crystallizer = MSMPR.__new__(MSMPR) crystallizer.vol_slurry = 1.0 crystallizer.target_ind = 0 diff --git a/tests/test_distillation.py b/tests/test_distillation.py index 63c5fc8..7f1973f 100644 --- a/tests/test_distillation.py +++ b/tests/test_distillation.py @@ -1,10 +1,9 @@ import pytest -pytest.importorskip("assimulo") from PharmaPy.Distillation import DistillationColumn -pytestmark = [pytest.mark.assimulo, pytest.mark.unit] +pytestmark = pytest.mark.unit def test_steady_state_solve_uses_calc_plates_results(monkeypatch): diff --git a/tests/test_drying_energy_rate_basis.py b/tests/test_drying_energy_rate_basis.py index 14e5e91..7392695 100644 --- a/tests/test_drying_energy_rate_basis.py +++ b/tests/test_drying_energy_rate_basis.py @@ -16,11 +16,10 @@ import numpy as np import pytest -pytest.importorskip("assimulo") import PharmaPy.Drying_Model as drying_module from PharmaPy.Drying_Model import Drying -pytestmark = pytest.mark.assimulo +pytestmark = pytest.mark.unit def test_unit_model_uses_mass_drying_rate_in_energy_terms(monkeypatch): diff --git a/tests/test_drying_gas_balance.py b/tests/test_drying_gas_balance.py index 04f78e1..401616f 100644 --- a/tests/test_drying_gas_balance.py +++ b/tests/test_drying_gas_balance.py @@ -6,79 +6,19 @@ pin the local unit and holdup contracts affected by #81. """ -import sys -from types import ModuleType, SimpleNamespace +from types import SimpleNamespace import numpy as np import pytest +import PharmaPy.Drying_Model as drying_model -pytestmark = pytest.mark.unit - - -def _stub_assimulo_modules(monkeypatch): - """Provide the minimal optional Assimulo API needed to import Drying. - - Parameters - ---------- - monkeypatch : pytest.MonkeyPatch - Pytest cleanup fixture used only to register temporary import modules. - - Notes - ----- - The stub is limited to import-time solver symbols. These tests do not - replace the drying calculations under review with solver behavior. - """ - assimulo = ModuleType("assimulo") - - class ExplicitProblem: - def __init__(self, *args, **kwargs): - self.args = args - self.kwargs = kwargs - - solvers = ModuleType("assimulo.solvers") - solvers.CVode = object - - problem = ModuleType("assimulo.problem") - problem.Explicit_Problem = ExplicitProblem - - exception = ModuleType("assimulo.exception") - exception.TerminateSimulation = Exception - monkeypatch.setitem(sys.modules, "assimulo", assimulo) - monkeypatch.setitem(sys.modules, "assimulo.solvers", solvers) - monkeypatch.setitem(sys.modules, "assimulo.problem", problem) - monkeypatch.setitem(sys.modules, "assimulo.exception", exception) - - -def _import_drying_model(monkeypatch): - """Import ``PharmaPy.Drying_Model`` for solver-free unit tests. - - Parameters - ---------- - monkeypatch : pytest.MonkeyPatch - Used only when the optional Assimulo package is absent locally. - - Returns - ------- - module - Imported ``PharmaPy.Drying_Model`` module. - """ - try: - from PharmaPy import Drying_Model - except ModuleNotFoundError as exc: - if exc.name != "assimulo": - raise - _stub_assimulo_modules(monkeypatch) - - from PharmaPy import Drying_Model - - return Drying_Model +pytestmark = pytest.mark.unit -def test_unit_model_uses_relative_permeability_for_gas_velocity(monkeypatch): +def test_unit_model_uses_relative_permeability_for_gas_velocity(): """Darcy velocity uses the clipped relative permeability factor [-].""" - drying_model = _import_drying_model(monkeypatch) dryer = drying_model.Drying(number_nodes=3, supercrit_names=["nitrogen"]) dryer.idx_volatiles = np.array([0, 2]) # component indices [-] dryer.num_volatiles = 2 # [-] @@ -162,9 +102,8 @@ def energy_balance( np.testing.assert_allclose(captured["u_gas"], np.array([0.0, 0.55, 0.0])) -def test_material_balance_uses_single_gas_holdup_factor_for_transfer(monkeypatch): +def test_material_balance_uses_single_gas_holdup_factor_for_transfer(): """Gas transfer divides by the gas holdup exactly once.""" - drying_model = _import_drying_model(monkeypatch) dryer = drying_model.Drying(number_nodes=3, supercrit_names=["nitrogen"]) dryer.idx_volatiles = np.array([0, 2]) # component indices [-] dryer.porosity = 0.5 # [-] @@ -226,8 +165,6 @@ def test_solve_unit_single_node_initial_state_includes_condensed_temperature( full transient; the broader end-to-end Drying solve remains deferred until the open Drying correctness issues are resolved. """ - drying_model = _import_drying_model(monkeypatch) - dryer = drying_model.Drying(number_nodes=1, supercrit_names=["nitrogen"]) dryer.names_states_in = ["temp", "mass_frac"] dryer.idx_supercrit = np.array([1]) # component indices [-] @@ -275,6 +212,11 @@ def assert_initial_state_width(time, states, sw=None): raise CapturedInitialState dryer.unit_model = assert_initial_state_width + monkeypatch.setattr( + drying_model, + "Explicit_Problem", + lambda *args, **kwargs: SimpleNamespace(), + ) with pytest.raises(CapturedInitialState): dryer.solve_unit(deltaP=10.0) diff --git a/tests/test_drying_model.py b/tests/test_drying_model.py index e9582ef..9a5aca1 100644 --- a/tests/test_drying_model.py +++ b/tests/test_drying_model.py @@ -1,4 +1,4 @@ -"""Assimulo-marked regressions for drying-rate unit contracts. +"""Solver-free regressions for drying-rate unit contracts. The fixtures are intentionally small, made-up drying states. They isolate the molar-to-mass drying-rate conversion and the downstream material-balance terms @@ -10,10 +10,9 @@ import numpy as np import pytest -pytest.importorskip("assimulo") from PharmaPy.Drying_Model import Drying -pytestmark = pytest.mark.assimulo +pytestmark = pytest.mark.unit def test_drying_rate_mass_basis_converts_molar_rates_with_component_mw(): diff --git a/tests/test_dynamic_extractor_default_k_fun.py b/tests/test_dynamic_extractor_default_k_fun.py index 6db8144..5f0a77a 100644 --- a/tests/test_dynamic_extractor_default_k_fun.py +++ b/tests/test_dynamic_extractor_default_k_fun.py @@ -3,33 +3,12 @@ import numpy as np import pytest -from assimulo_helpers import import_module_with_assimulo_stub +import PharmaPy.DynamicExtraction as dynamic_extraction pytestmark = pytest.mark.unit -def _load_dynamic_extraction(monkeypatch): - """Import DynamicExtraction while stubbing only optional Assimulo symbols. - - Parameters - ---------- - monkeypatch : pytest.MonkeyPatch - Pytest cleanup fixture used by the shared optional-import helper. - - Returns - ------- - module - Imported ``PharmaPy.DynamicExtraction`` module. - """ - return import_module_with_assimulo_stub( - monkeypatch, - "PharmaPy.DynamicExtraction", - solvers={"IDA": object, "Radau5DAE": object}, - problem={"Implicit_Problem": object}, - ) - - class _ActivityPhase: def __init__(self): self.calls = [] @@ -43,10 +22,12 @@ class _StopAfterConstruction(Exception): """Raised by the stub to stop initialization at a known point.""" -def test_default_k_fun_uses_selected_activity_model(monkeypatch): +def test_default_k_fun_uses_selected_activity_model(): """The default DynamicExtractor k_fun returns gamma_light/gamma_heavy.""" - module = _load_dynamic_extraction(monkeypatch) - extractor = module.DynamicExtractor(num_stages=2, gamma_model="UNIFAC") + extractor = dynamic_extraction.DynamicExtractor( + num_stages=2, + gamma_model="UNIFAC", + ) extractor.Liquid_1 = _ActivityPhase() x_light = np.array([[0.2, 0.8], [0.4, 0.6]]) # [-] @@ -64,14 +45,15 @@ def test_default_k_fun_uses_selected_activity_model(monkeypatch): assert extractor.Liquid_1.calls[0][2] == pytest.approx(temp) -def test_default_k_fun_rejects_unknown_activity_model(monkeypatch): +def test_default_k_fun_rejects_unknown_activity_model(): """DynamicExtractor rejects unknown default activity-model selectors.""" - module = _load_dynamic_extraction(monkeypatch) - # Lowercase ``uniquac`` is deliberate: activity-model selectors are # case-sensitive and must match the exact Phases.getActivityCoeff branch. with pytest.raises(ValueError, match="gamma_model must be one of"): - module.DynamicExtractor(num_stages=1, gamma_model="uniquac") + dynamic_extraction.DynamicExtractor( + num_stages=1, + gamma_model="uniquac", + ) def test_initialize_model_passes_default_k_fun_to_batch_extractor(monkeypatch): @@ -82,7 +64,6 @@ def test_initialize_model_passes_default_k_fun_to_batch_extractor(monkeypatch): objects, outlet construction, and broader stage-wise K_i behavior tracked separately in #123; the sentinel stops at the fixed boundary. """ - module = _load_dynamic_extraction(monkeypatch) captured = {} class _BatchExtractor: @@ -93,9 +74,12 @@ def __init__(self, k_fun=None, gamma_method="UNIQUAC"): def solve_unit(self): raise _StopAfterConstruction - monkeypatch.setattr(module, "BatchExtractor", _BatchExtractor) + monkeypatch.setattr(dynamic_extraction, "BatchExtractor", _BatchExtractor) - extractor = module.DynamicExtractor(num_stages=1, gamma_model="ideal") + extractor = dynamic_extraction.DynamicExtractor( + num_stages=1, + gamma_model="ideal", + ) extractor.Liquid_1 = object() with pytest.raises(_StopAfterConstruction): diff --git a/tests/test_evaporator_adiabatic_energy.py b/tests/test_evaporator_adiabatic_energy.py index 0a42dbf..645d68a 100644 --- a/tests/test_evaporator_adiabatic_energy.py +++ b/tests/test_evaporator_adiabatic_energy.py @@ -1,13 +1,10 @@ import numpy as np import pytest - -pytest.importorskip("assimulo") - from PharmaPy.Evaporators import ContinuousEvaporator -pytestmark = [pytest.mark.assimulo, pytest.mark.unit] +pytestmark = pytest.mark.unit class _EnthalpySource: diff --git a/tests/test_optional_assimulo_imports.py b/tests/test_optional_assimulo_imports.py new file mode 100644 index 0000000..9852ff2 --- /dev/null +++ b/tests/test_optional_assimulo_imports.py @@ -0,0 +1,103 @@ +"""Import-boundary regressions for the optional Assimulo solver stack.""" + +import os +from pathlib import Path +import subprocess +import sys +import textwrap + +import pytest + +pytestmark = pytest.mark.unit + +REPO_ROOT = Path(__file__).resolve().parents[1] +AFFECTED_MODULES = ( + "PharmaPy.Containers", + "PharmaPy.Crystallizers", + "PharmaPy.Distillation", + "PharmaPy.Drying_Model", + "PharmaPy.DynamicExtraction", + "PharmaPy.Evaporators", + "PharmaPy.Reactors", + "PharmaPy.SolidLiquidSep", + "PharmaPy.ThreePhaseSettler", +) + +IMPORT_BLOCKER = textwrap.dedent(""" + import builtins + + original_import = builtins.__import__ + + def import_without_assimulo( + name, globals=None, locals=None, fromlist=(), level=0 + ): + if name == "assimulo" or name.startswith("assimulo."): + raise ModuleNotFoundError( + "Assimulo import blocked by regression test", + name="assimulo", + ) + return original_import(name, globals, locals, fromlist, level) + + builtins.__import__ = import_without_assimulo + """) + + +def _run_without_assimulo(script, tmp_path): + """Run Python source while rejecting imports of Assimulo. + + Parameters + ---------- + script : str + Python source to execute after installing the import blocker. + tmp_path : pathlib.Path + Temporary directory used for the Matplotlib configuration cache. + + Returns + ------- + subprocess.CompletedProcess + Completed child-process result with captured text output. + """ + environment = os.environ.copy() + environment["MPLCONFIGDIR"] = str(tmp_path) + return subprocess.run( + [sys.executable, "-c", f"{IMPORT_BLOCKER}\n{script}"], + cwd=REPO_ROOT, + env=environment, + capture_output=True, + text=True, + check=False, + ) + + +def test_model_modules_import_without_assimulo(tmp_path): + """Every solver-backed model module imports without the optional backend.""" + script = textwrap.dedent(f""" + import importlib + + for module_name in {AFFECTED_MODULES!r}: + importlib.import_module(module_name) + """) + result = _run_without_assimulo(script, tmp_path) + + assert result.returncode == 0, result.stderr + + +def test_solver_construction_reports_missing_assimulo(tmp_path): + """Lazy solver construction gives an actionable dependency error.""" + script = textwrap.dedent(""" + from PharmaPy._assimulo import CVode + + try: + CVode(object()) + except ImportError as exc: + expected = ( + "Assimulo is required for solver-backed PharmaPy simulations" + ) + if expected not in str(exc): + raise AssertionError(str(exc)) from exc + else: + raise AssertionError("CVode construction unexpectedly succeeded") + """) + result = _run_without_assimulo(script, tmp_path) + + assert result.returncode == 0, result.stderr