Skip to content

Commit 738e64f

Browse files
committed
Make get_metric_config_bounds() mimic dict.get()
Now that an absent metric-config entry means "explicitly unbounded", forcing users to special-case absence (catching `KeyError` or checking for `None`) is just friction: they almost always want the bounds and an unbounded `Bounds()` is a perfectly usable answer. Return an unbounded `Bounds()` for absent metrics instead of raising, and accept a keyword-only `default` (like `dict.get()`) for callers that do need to detect absence — `default=None` recovers the old `get_metric_config_bounds_or_none()` behavior exactly, so that accessor is removed. Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
1 parent abae640 commit 738e64f

3 files changed

Lines changed: 73 additions & 59 deletions

File tree

‎src/frequenz/client/common/microgrid/electrical_components/__init__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from ._converter import Converter
1919
from ._crypto_miner import CryptoMiner
2020
from ._diagnostic_code import ElectricalComponentDiagnosticCode
21-
from ._electrical_component import ElectricalComponent
21+
from ._electrical_component import DefaultT, ElectricalComponent
2222
from ._electrical_component_connection import (
2323
BaseElectricalComponentConnection,
2424
ElectricalComponentConnection,
@@ -85,6 +85,7 @@
8585
"Converter",
8686
"CryptoMiner",
8787
"DcEvCharger",
88+
"DefaultT",
8889
"ElectricalComponent",
8990
"ElectricalComponentCategory",
9091
"ElectricalComponentConnection",

‎src/frequenz/client/common/microgrid/electrical_components/_electrical_component.py‎

Lines changed: 37 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,17 @@
66
import dataclasses
77
from collections.abc import Mapping
88
from datetime import datetime, timezone
9-
from typing import Any, Self, assert_never
9+
from typing import Any, Self, TypeVar, assert_never, overload
1010

1111
from ..._exception import UnrecognizedEnumValueError, UnspecifiedEnumValueError
1212
from ...metrics import Bounds, InvalidBounds, InvalidBoundsError, Metric
1313
from .. import MicrogridId
1414
from .._lifetime import InvalidLifetime, InvalidLifetimeError, Lifetime
1515
from ._ids import ElectricalComponentId
1616

17+
DefaultT = TypeVar("DefaultT")
18+
"""A type variable for the default value of dict-like getters."""
19+
1720

1821
@dataclasses.dataclass(frozen=True, kw_only=True)
1922
class ElectricalComponent: # pylint: disable=too-many-instance-attributes
@@ -96,8 +99,7 @@ class ElectricalComponent: # pylint: disable=too-many-instance-attributes
9699
as plain `int` keys for forward-compatibility.
97100
98101
Tip:
99-
Prefer [`get_metric_config_bounds()`][..get_metric_config_bounds] or
100-
[`get_metric_config_bounds_or_none()`][..get_metric_config_bounds_or_none]
102+
Prefer [`get_metric_config_bounds()`][..get_metric_config_bounds]
101103
when a valid [`Bounds`][.....metrics.Bounds] is required.
102104
"""
103105

@@ -203,54 +205,56 @@ def accepts_control(self) -> bool:
203205
case unknown:
204206
assert_never(unknown)
205207

206-
def get_metric_config_bounds(self, metric: Metric) -> Bounds: # noqa: DOC502,DOC503
208+
@overload
209+
def get_metric_config_bounds(self, metric: Metric) -> Bounds: ...
210+
211+
@overload
212+
def get_metric_config_bounds(
213+
self, metric: Metric, *, default: DefaultT
214+
) -> Bounds | DefaultT: ...
215+
216+
def get_metric_config_bounds(
217+
self, metric: Metric, *, default: object = Bounds()
218+
) -> object:
207219
"""Return the configured bounds for a metric as a valid `Bounds`.
208220
209-
Args:
210-
metric: The metric whose bounds to retrieve.
221+
An absent entry returns an unbounded metric, so when no bounds are
222+
configured for `metric` this returns an unbounded
223+
[`Bounds`][frequenz.client.common.metrics.Bounds] by default. Pass
224+
`default` to return a different value for absent entries instead,
225+
mimicking [`dict.get()`][dict.get].
211226
212-
Returns:
213-
The valid [`Bounds`][.....metrics.Bounds] configured for `metric`.
227+
Example:
228+
To check if a `metric` has **valid** configured bounds, you can use:
214229
215-
Raises:
216-
KeyError: If no bounds are configured for `metric`.
217-
InvalidBoundsError: If the bounds configured for `metric` are
218-
malformed. The offending instance is available on the
219-
exception's `bounds` attribute.
220-
"""
221-
bounds = self.metric_config_bounds[metric]
222-
match bounds:
223-
case InvalidBounds() as invalid:
224-
raise InvalidBoundsError(
225-
self,
226-
"metric_config_bounds",
227-
invalid,
228-
f"invalid bounds {invalid!r} for metric {metric} in {self}",
229-
)
230-
case Bounds() as valid:
231-
return valid
232-
case unknown:
233-
assert_never(unknown)
230+
```py
231+
component: ElectricalComponent
232+
metric: Metric
233+
if component.get_metric_config_bounds(metric, default=None) is not None:
234+
print(f"{metric} has valid configured bounds")
235+
```
234236
235-
def get_metric_config_bounds_or_none(self, metric: Metric) -> Bounds | None:
236-
"""Return the configured bounds for a metric, or `None` when absent.
237+
This is similar to accessing
238+
[`metric_config_bounds`][...ElectricalComponent.metric_config_bounds]
239+
directly, but avoid the special handling of invalid bounds.
237240
238241
Args:
239242
metric: The metric whose bounds to retrieve.
243+
default: The value to return when no bounds are configured for
244+
`metric`.
240245
241246
Returns:
242247
The valid [`Bounds`][.....metrics.Bounds] configured for `metric`,
243-
or `None` when no bounds are configured for it.
248+
or `default` when there is no entry for `metric`.
244249
245250
Raises:
246251
InvalidBoundsError: If the bounds configured for `metric` are
247252
malformed. The offending instance is available on the
248253
exception's `bounds` attribute.
249254
"""
250-
bounds = self.metric_config_bounds.get(metric)
251-
match bounds:
255+
match self.metric_config_bounds.get(metric):
252256
case None:
253-
return None
257+
return default
254258
case InvalidBounds() as invalid:
255259
raise InvalidBoundsError(
256260
self,

‎tests/microgrid/electrical_components/test_electrical_component_base.py‎

Lines changed: 34 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -237,53 +237,62 @@ def test_get_metric_config_bounds_returns_valid_bounds() -> None:
237237
assert result is bounds
238238

239239

240-
def test_get_metric_config_bounds_absent_raises_key_error() -> None:
241-
"""`get_metric_config_bounds` raises `KeyError` when no bounds are configured."""
240+
def test_get_metric_config_bounds_absent_returns_unbounded() -> None:
241+
"""`get_metric_config_bounds` returns an unbounded `Bounds` for absent metrics."""
242242
component = _make_component(metric_config_bounds={})
243243

244-
with pytest.raises(KeyError):
245-
component.get_metric_config_bounds(Metric.AC_POWER_ACTIVE)
244+
result = component.get_metric_config_bounds(Metric.AC_POWER_ACTIVE)
246245

246+
assert result == Bounds()
247+
assert isinstance(result, Bounds)
247248

248-
def test_get_metric_config_bounds_invalid_raises_error() -> None:
249-
"""`get_metric_config_bounds` raises `InvalidBoundsError` for malformed entries."""
250-
invalid = InvalidBounds(lower=10.0, upper=-10.0)
251-
component = _make_component(metric_config_bounds={Metric.AC_POWER_ACTIVE: invalid})
252249

253-
with pytest.raises(InvalidBoundsError) as exc_info:
254-
component.get_metric_config_bounds(Metric.AC_POWER_ACTIVE)
250+
def test_get_metric_config_bounds_absent_returns_default() -> None:
251+
"""`get_metric_config_bounds` returns `default` for absent metrics."""
252+
component = _make_component(metric_config_bounds={})
253+
sentinel = object()
255254

256-
assert exc_info.value.bounds is invalid
257-
assert "AC_POWER_ACTIVE" in str(exc_info.value)
255+
assert (
256+
component.get_metric_config_bounds(Metric.AC_POWER_ACTIVE, default=None) is None
257+
)
258+
assert (
259+
component.get_metric_config_bounds(Metric.AC_POWER_ACTIVE, default=sentinel)
260+
is sentinel
261+
)
258262

259263

260-
def test_get_metric_config_bounds_or_none_returns_valid_bounds() -> None:
261-
"""`get_metric_config_bounds_or_none` returns the configured `Bounds`."""
264+
def test_get_metric_config_bounds_present_ignores_default() -> None:
265+
"""`get_metric_config_bounds` ignores `default` when the metric has bounds."""
262266
bounds = Bounds(lower=-10.0, upper=10.0)
263267
component = _make_component(metric_config_bounds={Metric.AC_POWER_ACTIVE: bounds})
264268

265-
assert component.get_metric_config_bounds_or_none(Metric.AC_POWER_ACTIVE) is bounds
266-
267-
268-
def test_get_metric_config_bounds_or_none_absent_returns_none() -> None:
269-
"""`get_metric_config_bounds_or_none` returns `None` when no bounds are configured."""
270-
component = _make_component(metric_config_bounds={})
271-
272-
assert component.get_metric_config_bounds_or_none(Metric.AC_POWER_ACTIVE) is None
269+
assert (
270+
component.get_metric_config_bounds(Metric.AC_POWER_ACTIVE, default=None)
271+
is bounds
272+
)
273273

274274

275-
def test_get_metric_config_bounds_or_none_invalid_raises_error() -> None:
276-
"""`get_metric_config_bounds_or_none` still raises for malformed entries."""
275+
def test_get_metric_config_bounds_invalid_raises_error() -> None:
276+
"""`get_metric_config_bounds` raises `InvalidBoundsError` for malformed entries."""
277277
invalid = InvalidBounds(lower=10.0, upper=-10.0)
278278
component = _make_component(metric_config_bounds={Metric.AC_POWER_ACTIVE: invalid})
279279

280280
with pytest.raises(InvalidBoundsError) as exc_info:
281-
component.get_metric_config_bounds_or_none(Metric.AC_POWER_ACTIVE)
281+
component.get_metric_config_bounds(Metric.AC_POWER_ACTIVE)
282282

283283
assert exc_info.value.bounds is invalid
284284
assert "AC_POWER_ACTIVE" in str(exc_info.value)
285285

286286

287+
def test_get_metric_config_bounds_invalid_raises_despite_default() -> None:
288+
"""`default` only applies to absent metrics, not malformed ones."""
289+
invalid = InvalidBounds(lower=10.0, upper=-10.0)
290+
component = _make_component(metric_config_bounds={Metric.AC_POWER_ACTIVE: invalid})
291+
292+
with pytest.raises(InvalidBoundsError):
293+
component.get_metric_config_bounds(Metric.AC_POWER_ACTIVE, default=None)
294+
295+
287296
@pytest.mark.parametrize(
288297
"name,expected_str",
289298
[

0 commit comments

Comments
 (0)