diff --git a/src/optimagic/optimization/algorithm.py b/src/optimagic/optimization/algorithm.py index 334c40daf..f3188533a 100644 --- a/src/optimagic/optimization/algorithm.py +++ b/src/optimagic/optimization/algorithm.py @@ -14,7 +14,7 @@ from optimagic.optimization.internal_optimization_problem import ( InternalOptimizationProblem, ) -from optimagic.type_conversion import TYPE_CONVERTERS +from optimagic.type_conversion import TYPE_CONVERTERS, TYPE_CONVERTERS_BY_NAME from optimagic.typing import AggregationLevel @@ -219,10 +219,16 @@ def _solve_internal_problem( def __post_init__(self) -> None: for field in self.__dataclass_fields__: raw_value = getattr(self, field) - target_type = typing.cast(type, self.__dataclass_fields__[field].type) - if target_type in TYPE_CONVERTERS: + target_type = self.__dataclass_fields__[field].type + # In modules with `from __future__ import annotations`, field types + # are annotation strings rather than type objects. + if isinstance(target_type, str): + converter = TYPE_CONVERTERS_BY_NAME.get(target_type) + else: + converter = TYPE_CONVERTERS.get(typing.cast(type, target_type)) + if converter is not None: try: - value = TYPE_CONVERTERS[target_type](raw_value) + value = converter(raw_value) except (KeyboardInterrupt, SystemExit): raise except Exception as e: diff --git a/src/optimagic/type_conversion.py b/src/optimagic/type_conversion.py index d9d5506ac..9cbb0753d 100644 --- a/src/optimagic/type_conversion.py +++ b/src/optimagic/type_conversion.py @@ -87,3 +87,17 @@ def _process_bool_like(value: Any) -> bool: NonNegativeFloat: _process_non_negative_float_like, GtOneFloat: _process_gt_one_float_like, } + +# In modules with `from __future__ import annotations`, dataclass field types are +# annotation strings rather than type objects, so converter lookups need a +# name-based mapping. +TYPE_CONVERTERS_BY_NAME = { + "float": _process_float_like, + "int": _process_int_like, + "bool": _process_bool_like, + "PositiveInt": _process_positive_int_like, + "NonNegativeInt": _process_non_negative_int_like, + "PositiveFloat": _process_positive_float_like, + "NonNegativeFloat": _process_non_negative_float_like, + "GtOneFloat": _process_gt_one_float_like, +} diff --git a/tests/optimagic/optimization/test_algorithm.py b/tests/optimagic/optimization/test_algorithm.py index 71bae8a79..251078947 100644 --- a/tests/optimagic/optimization/test_algorithm.py +++ b/tests/optimagic/optimization/test_algorithm.py @@ -1,8 +1,9 @@ -from dataclasses import dataclass +from dataclasses import dataclass, fields import numpy as np import pytest +from optimagic.algorithms import ALL_ALGORITHMS from optimagic.exceptions import InvalidAlgoInfoError, InvalidAlgoOptionError from optimagic.optimization.algorithm import AlgoInfo, Algorithm, InternalOptimizeResult from optimagic.optimization.history import HistoryEntry @@ -10,6 +11,7 @@ AggregationLevel, EvalTask, NonNegativeFloat, + NonNegativeInt, PositiveFloat, PositiveInt, ) @@ -198,6 +200,16 @@ def test_with_option_if_applicable(): # ====================================================================================== +def test_field_types_are_type_objects(): + # Guard: this module must NOT use `from __future__ import annotations`, + # otherwise the tests below no longer cover the type-object code path of the + # option conversion. The stringified-annotations path is covered in + # test_algorithm_future_annotations.py. + field_types = {f.name: f.type for f in fields(DummyAlgorithm)} + assert field_types["stopping_maxiter"] == PositiveInt + assert field_types["initial_radius"] == PositiveFloat + + def test_algorithm_does_type_conversion(): algo = DummyAlgorithm( initial_radius="1.0", @@ -229,6 +241,61 @@ def test_algorithm_does_type_conversion_in_with_option(): assert new_algo.max_radius == 20.0 +def test_algorithm_converts_float_to_int(): + algo = DummyAlgorithm(stopping_maxiter=1000.0) + assert isinstance(algo.stopping_maxiter, int) + assert algo.stopping_maxiter == 1000 + + def test_error_with_negative_radius(): with pytest.raises(Exception): # noqa: B017 DummyAlgorithm(initial_radius=-1.0) + + +# ====================================================================================== +# Test type conversion works for all registered algorithms +# ====================================================================================== + +# Field types are type objects in modules without `from __future__ import +# annotations` and annotation strings in modules with it. Both must be coerced. +INT_ANNOTATIONS = ( + int, + PositiveInt, + NonNegativeInt, + "int", + "PositiveInt", + "NonNegativeInt", +) + + +def _int_options_with_int_defaults(cls): + out = {} + for field in fields(cls): + has_int_annotation = any(field.type == t for t in INT_ANNOTATIONS) + has_int_default = isinstance(field.default, int) and not isinstance( + field.default, bool + ) + if has_int_annotation and has_int_default: + out[field.name] = field.default + return out + + +@pytest.mark.parametrize("cls", ALL_ALGORITHMS.values(), ids=ALL_ALGORITHMS.keys()) +def test_int_options_are_coerced_for_all_algorithms(cls): + """Passing floats for int-typed options must result in int attributes. + + This guards against the option conversion in Algorithm.__post_init__ being + silently skipped, as happened for optimizer modules with postponed annotations + where the field type is a string rather than a type object. + + """ + int_options = _int_options_with_int_defaults(cls) + if not int_options: + pytest.skip("Algorithm has no int-typed options with int defaults.") + + algo = cls(**{name: float(default) for name, default in int_options.items()}) + + for name, default in int_options.items(): + value = getattr(algo, name) + assert isinstance(value, int), f"Option {name} was not coerced to int." + assert value == default diff --git a/tests/optimagic/optimization/test_algorithm_future_annotations.py b/tests/optimagic/optimization/test_algorithm_future_annotations.py new file mode 100644 index 000000000..1f1beb144 --- /dev/null +++ b/tests/optimagic/optimization/test_algorithm_future_annotations.py @@ -0,0 +1,99 @@ +"""Test algo option type conversion in modules with postponed annotations. + +With `from __future__ import annotations`, dataclass field types are annotation +strings rather than type objects. This silently disabled the type conversion in +`Algorithm.__post_init__` for all optimizer modules using the import. The tests in +this module mirror the type conversion tests in test_algorithm.py for a dummy +algorithm defined under postponed annotations; test_algorithm.py covers the case +without the import. + +""" + +from __future__ import annotations + +from dataclasses import dataclass, fields + +import pytest + +from optimagic.exceptions import InvalidAlgoOptionError +from optimagic.optimization.algorithm import Algorithm, InternalOptimizeResult +from optimagic.optimization.history import HistoryEntry +from optimagic.typing import ( + EvalTask, + NonNegativeFloat, + PositiveFloat, + PositiveInt, +) + + +@dataclass(frozen=True) +class DummyAlgorithm(Algorithm): + initial_radius: PositiveFloat = 1.0 + max_radius: PositiveFloat = 10.0 + convergence_ftol_rel: NonNegativeFloat = 1e-6 + stopping_maxiter: PositiveInt = 1000 + + def _solve_internal_problem(self, problem, x0): + hist_entry = HistoryEntry( + params=x0, + fun=0.0, + start_time=0.0, + task=EvalTask.FUN, + ) + problem.history.add_entry(hist_entry) + return InternalOptimizeResult(x=x0, fun=0.0, success=True) + + +def test_field_types_are_annotation_strings(): + # Guard: this module must keep `from __future__ import annotations`, otherwise + # the tests below no longer cover the stringified-annotations code path. + field_types = {f.name: f.type for f in fields(DummyAlgorithm)} + assert field_types["stopping_maxiter"] == "PositiveInt" + assert field_types["initial_radius"] == "PositiveFloat" + + +def test_algorithm_does_type_conversion(): + algo = DummyAlgorithm( + initial_radius="1.0", + max_radius="10.0", + convergence_ftol_rel="1e-6", + stopping_maxiter="1000", + ) + + assert isinstance(algo.initial_radius, float) + assert algo.initial_radius == 1.0 + assert isinstance(algo.max_radius, float) + assert algo.max_radius == 10.0 + assert isinstance(algo.convergence_ftol_rel, float) + assert algo.convergence_ftol_rel == 1e-6 + assert isinstance(algo.stopping_maxiter, int) + assert algo.stopping_maxiter == 1000 + + +def test_algorithm_converts_float_to_int(): + algo = DummyAlgorithm(stopping_maxiter=1000.0) + assert isinstance(algo.stopping_maxiter, int) + assert algo.stopping_maxiter == 1000 + + +def test_algorithm_does_type_conversion_in_with_option(): + algo = DummyAlgorithm() + new_algo = algo.with_option( + initial_radius="2.0", + max_radius="20.0", + ) + + assert isinstance(new_algo.initial_radius, float) + assert new_algo.initial_radius == 2.0 + assert isinstance(new_algo.max_radius, float) + assert new_algo.max_radius == 20.0 + + +def test_error_with_negative_radius(): + with pytest.raises(InvalidAlgoOptionError): + DummyAlgorithm(initial_radius=-1.0) + + +def test_error_with_negative_maxiter(): + with pytest.raises(InvalidAlgoOptionError): + DummyAlgorithm(stopping_maxiter=-1)