Skip to content

Commit 51d4bec

Browse files
committed
Add tests for the type conversion bug with stringified type hints.
1 parent 153ddf9 commit 51d4bec

2 files changed

Lines changed: 167 additions & 1 deletion

File tree

tests/optimagic/optimization/test_algorithm.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
1-
from dataclasses import dataclass
1+
from dataclasses import dataclass, fields
22

33
import numpy as np
44
import pytest
55

6+
from optimagic.algorithms import ALL_ALGORITHMS
67
from optimagic.exceptions import InvalidAlgoInfoError, InvalidAlgoOptionError
78
from optimagic.optimization.algorithm import AlgoInfo, Algorithm, InternalOptimizeResult
89
from optimagic.optimization.history import HistoryEntry
910
from optimagic.typing import (
1011
AggregationLevel,
1112
EvalTask,
1213
NonNegativeFloat,
14+
NonNegativeInt,
1315
PositiveFloat,
1416
PositiveInt,
1517
)
@@ -198,6 +200,16 @@ def test_with_option_if_applicable():
198200
# ======================================================================================
199201

200202

203+
def test_field_types_are_type_objects():
204+
# Guard: this module must NOT use `from __future__ import annotations`,
205+
# otherwise the tests below no longer cover the type-object code path of the
206+
# option conversion. The stringified-annotations path is covered in
207+
# test_algorithm_future_annotations.py.
208+
field_types = {f.name: f.type for f in fields(DummyAlgorithm)}
209+
assert field_types["stopping_maxiter"] == PositiveInt
210+
assert field_types["initial_radius"] == PositiveFloat
211+
212+
201213
def test_algorithm_does_type_conversion():
202214
algo = DummyAlgorithm(
203215
initial_radius="1.0",
@@ -229,6 +241,61 @@ def test_algorithm_does_type_conversion_in_with_option():
229241
assert new_algo.max_radius == 20.0
230242

231243

244+
def test_algorithm_converts_float_to_int():
245+
algo = DummyAlgorithm(stopping_maxiter=1000.0)
246+
assert isinstance(algo.stopping_maxiter, int)
247+
assert algo.stopping_maxiter == 1000
248+
249+
232250
def test_error_with_negative_radius():
233251
with pytest.raises(Exception): # noqa: B017
234252
DummyAlgorithm(initial_radius=-1.0)
253+
254+
255+
# ======================================================================================
256+
# Test type conversion works for all registered algorithms
257+
# ======================================================================================
258+
259+
# Field types are type objects in modules without `from __future__ import
260+
# annotations` and annotation strings in modules with it. Both must be coerced.
261+
INT_ANNOTATIONS = (
262+
int,
263+
PositiveInt,
264+
NonNegativeInt,
265+
"int",
266+
"PositiveInt",
267+
"NonNegativeInt",
268+
)
269+
270+
271+
def _int_options_with_int_defaults(cls):
272+
out = {}
273+
for field in fields(cls):
274+
has_int_annotation = any(field.type == t for t in INT_ANNOTATIONS)
275+
has_int_default = isinstance(field.default, int) and not isinstance(
276+
field.default, bool
277+
)
278+
if has_int_annotation and has_int_default:
279+
out[field.name] = field.default
280+
return out
281+
282+
283+
@pytest.mark.parametrize("cls", ALL_ALGORITHMS.values(), ids=ALL_ALGORITHMS.keys())
284+
def test_int_options_are_coerced_for_all_algorithms(cls):
285+
"""Passing floats for int-typed options must result in int attributes.
286+
287+
This guards against the option conversion in Algorithm.__post_init__ being
288+
silently skipped, as happened for optimizer modules with postponed annotations
289+
where the field type is a string rather than a type object.
290+
291+
"""
292+
int_options = _int_options_with_int_defaults(cls)
293+
if not int_options:
294+
pytest.skip("Algorithm has no int-typed options with int defaults.")
295+
296+
algo = cls(**{name: float(default) for name, default in int_options.items()})
297+
298+
for name, default in int_options.items():
299+
value = getattr(algo, name)
300+
assert isinstance(value, int), f"Option {name} was not coerced to int."
301+
assert value == default
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""Test algo option type conversion in modules with postponed annotations.
2+
3+
With `from __future__ import annotations`, dataclass field types are annotation
4+
strings rather than type objects. This silently disabled the type conversion in
5+
`Algorithm.__post_init__` for all optimizer modules using the import. The tests in
6+
this module mirror the type conversion tests in test_algorithm.py for a dummy
7+
algorithm defined under postponed annotations; test_algorithm.py covers the case
8+
without the import.
9+
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from dataclasses import dataclass, fields
15+
16+
import pytest
17+
18+
from optimagic.exceptions import InvalidAlgoOptionError
19+
from optimagic.optimization.algorithm import Algorithm, InternalOptimizeResult
20+
from optimagic.optimization.history import HistoryEntry
21+
from optimagic.typing import (
22+
EvalTask,
23+
NonNegativeFloat,
24+
PositiveFloat,
25+
PositiveInt,
26+
)
27+
28+
29+
@dataclass(frozen=True)
30+
class DummyAlgorithm(Algorithm):
31+
initial_radius: PositiveFloat = 1.0
32+
max_radius: PositiveFloat = 10.0
33+
convergence_ftol_rel: NonNegativeFloat = 1e-6
34+
stopping_maxiter: PositiveInt = 1000
35+
36+
def _solve_internal_problem(self, problem, x0):
37+
hist_entry = HistoryEntry(
38+
params=x0,
39+
fun=0.0,
40+
start_time=0.0,
41+
task=EvalTask.FUN,
42+
)
43+
problem.history.add_entry(hist_entry)
44+
return InternalOptimizeResult(x=x0, fun=0.0, success=True)
45+
46+
47+
def test_field_types_are_annotation_strings():
48+
# Guard: this module must keep `from __future__ import annotations`, otherwise
49+
# the tests below no longer cover the stringified-annotations code path.
50+
field_types = {f.name: f.type for f in fields(DummyAlgorithm)}
51+
assert field_types["stopping_maxiter"] == "PositiveInt"
52+
assert field_types["initial_radius"] == "PositiveFloat"
53+
54+
55+
def test_algorithm_does_type_conversion():
56+
algo = DummyAlgorithm(
57+
initial_radius="1.0",
58+
max_radius="10.0",
59+
convergence_ftol_rel="1e-6",
60+
stopping_maxiter="1000",
61+
)
62+
63+
assert isinstance(algo.initial_radius, float)
64+
assert algo.initial_radius == 1.0
65+
assert isinstance(algo.max_radius, float)
66+
assert algo.max_radius == 10.0
67+
assert isinstance(algo.convergence_ftol_rel, float)
68+
assert algo.convergence_ftol_rel == 1e-6
69+
assert isinstance(algo.stopping_maxiter, int)
70+
assert algo.stopping_maxiter == 1000
71+
72+
73+
def test_algorithm_converts_float_to_int():
74+
algo = DummyAlgorithm(stopping_maxiter=1000.0)
75+
assert isinstance(algo.stopping_maxiter, int)
76+
assert algo.stopping_maxiter == 1000
77+
78+
79+
def test_algorithm_does_type_conversion_in_with_option():
80+
algo = DummyAlgorithm()
81+
new_algo = algo.with_option(
82+
initial_radius="2.0",
83+
max_radius="20.0",
84+
)
85+
86+
assert isinstance(new_algo.initial_radius, float)
87+
assert new_algo.initial_radius == 2.0
88+
assert isinstance(new_algo.max_radius, float)
89+
assert new_algo.max_radius == 20.0
90+
91+
92+
def test_error_with_negative_radius():
93+
with pytest.raises(InvalidAlgoOptionError):
94+
DummyAlgorithm(initial_radius=-1.0)
95+
96+
97+
def test_error_with_negative_maxiter():
98+
with pytest.raises(InvalidAlgoOptionError):
99+
DummyAlgorithm(stopping_maxiter=-1)

0 commit comments

Comments
 (0)