Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions src/optimagic/optimization/algorithm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions src/optimagic/type_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
69 changes: 68 additions & 1 deletion tests/optimagic/optimization/test_algorithm.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
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
from optimagic.typing import (
AggregationLevel,
EvalTask,
NonNegativeFloat,
NonNegativeInt,
PositiveFloat,
PositiveInt,
)
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
99 changes: 99 additions & 0 deletions tests/optimagic/optimization/test_algorithm_future_annotations.py
Original file line number Diff line number Diff line change
@@ -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)
Loading