Skip to content

Commit 88ad8dc

Browse files
committed
Add strongly typed IDs for microgrids and components
Introduce `MicrogridId` and `ComponentId` classes to replace plain integer IDs. These classes provide type safety and prevent accidental errors by: - Making it impossible to mix up microgrid and component IDs (equality comparisons between different ID types always return false). - Preventing accidental math operations on IDs. - Providing clear string representations for debugging (MID42, CID42). - Ensuring proper hash behavior in collections. Signed-off-by: Leandro Lucarella <[email protected]>
1 parent 51128dd commit 88ad8dc

File tree

2 files changed

+193
-0
lines changed

2 files changed

+193
-0
lines changed
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Strongly typed IDs for microgrids and components."""
5+
6+
7+
class MicrogridId:
8+
"""A unique identifier for a microgrid."""
9+
10+
def __init__(self, id_: int, /) -> None:
11+
"""Initialize this instance.
12+
13+
Args:
14+
id_: The numeric unique identifier of the microgrid.
15+
16+
Raises:
17+
ValueError: If the ID is negative.
18+
"""
19+
if id_ < 0:
20+
raise ValueError("Microgrid ID can't be negative.")
21+
self._id = id_
22+
23+
def __int__(self) -> int:
24+
"""Return the numeric ID of this instance."""
25+
return self._id
26+
27+
def __eq__(self, other: object) -> bool:
28+
"""Check if this instance is equal to another object."""
29+
# This is not an unidiomatic typecheck, that's an odd name for the check.
30+
# isinstance() returns True for subclasses, which is not what we want here.
31+
# pylint: disable-next=unidiomatic-typecheck
32+
return type(other) is MicrogridId and self._id == other._id
33+
34+
def __lt__(self, other: object) -> bool:
35+
"""Check if this instance is less than another object."""
36+
# pylint: disable-next=unidiomatic-typecheck
37+
if type(other) is MicrogridId:
38+
return self._id < other._id
39+
return NotImplemented
40+
41+
def __hash__(self) -> int:
42+
"""Return the hash of this instance."""
43+
# We include the class because we explicitly want to avoid the same ID to give
44+
# the same hash for different classes of IDs
45+
return hash((MicrogridId, self._id))
46+
47+
def __repr__(self) -> str:
48+
"""Return the string representation of this instance."""
49+
return f"{type(self).__name__}({self._id!r})"
50+
51+
def __str__(self) -> str:
52+
"""Return the short string representation of this instance."""
53+
return f"MID{self._id}"
54+
55+
56+
class ComponentId:
57+
"""A unique identifier for a microgrid component."""
58+
59+
def __init__(self, id_: int, /) -> None:
60+
"""Initialize this instance.
61+
62+
Args:
63+
id_: The numeric unique identifier of the microgrid component.
64+
65+
Raises:
66+
ValueError: If the ID is negative.
67+
"""
68+
if id_ < 0:
69+
raise ValueError("Component ID can't be negative.")
70+
self._id = id_
71+
72+
def __int__(self) -> int:
73+
"""Return the numeric ID of this instance."""
74+
return self._id
75+
76+
def __eq__(self, other: object) -> bool:
77+
"""Check if this instance is equal to another object."""
78+
# This is not an unidiomatic typecheck, that's an odd name for the check.
79+
# isinstance() returns True for subclasses, which is not what we want here.
80+
# pylint: disable-next=unidiomatic-typecheck
81+
return type(other) is ComponentId and self._id == other._id
82+
83+
def __lt__(self, other: object) -> bool:
84+
"""Check if this instance is less than another object."""
85+
# pylint: disable-next=unidiomatic-typecheck
86+
if type(other) is ComponentId:
87+
return self._id < other._id
88+
return NotImplemented
89+
90+
def __hash__(self) -> int:
91+
"""Return the hash of this instance."""
92+
# We include the class because we explicitly want to avoid the same ID to give
93+
# the same hash for different classes of IDs
94+
return hash((ComponentId, self._id))
95+
96+
def __repr__(self) -> str:
97+
"""Return the string representation of this instance."""
98+
return f"{type(self).__name__}({self._id!r})"
99+
100+
def __str__(self) -> str:
101+
"""Return the short string representation of this instance."""
102+
return f"CID{self._id}"

tests/test_id.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Tests for the microgrid and component IDs."""
5+
6+
from dataclasses import dataclass
7+
8+
import pytest
9+
10+
from frequenz.client.microgrid import ComponentId, MicrogridId
11+
12+
13+
@dataclass(frozen=True)
14+
class IdTypeInfo:
15+
"""Information about an ID type for testing."""
16+
17+
id_class: type
18+
str_prefix: str
19+
error_prefix: str
20+
21+
22+
# Define all ID types to test here
23+
ID_TYPES: list[IdTypeInfo] = [
24+
IdTypeInfo(MicrogridId, "MID", "Microgrid"),
25+
IdTypeInfo(ComponentId, "CID", "Component"),
26+
]
27+
28+
29+
@pytest.mark.parametrize(
30+
"type_info",
31+
ID_TYPES,
32+
ids=lambda type_info: type_info.id_class.__name__,
33+
)
34+
class TestIds:
35+
"""Tests for ID classes."""
36+
37+
def test_valid_id(self, type_info: IdTypeInfo) -> None:
38+
"""Test creating a valid ID."""
39+
id_obj = type_info.id_class(42)
40+
assert int(id_obj) == 42
41+
42+
def test_negative_id_raises(self, type_info: IdTypeInfo) -> None:
43+
"""Test that creating a negative ID raises ValueError."""
44+
error_msg = f"{type_info.error_prefix} ID can't be negative"
45+
with pytest.raises(ValueError, match=error_msg):
46+
type_info.id_class(-1)
47+
48+
def test_equality(self, type_info: IdTypeInfo) -> None:
49+
"""Test equality comparison."""
50+
assert type_info.id_class(1) == type_info.id_class(1)
51+
assert type_info.id_class(1) != type_info.id_class(2)
52+
53+
# Test against all other types
54+
for other_type in ID_TYPES:
55+
if other_type != type_info:
56+
assert type_info.id_class(1) != other_type.id_class(1)
57+
58+
def test_ordering(self, type_info: IdTypeInfo) -> None:
59+
"""Test ordering comparison."""
60+
assert type_info.id_class(1) < type_info.id_class(2)
61+
assert not type_info.id_class(2) < type_info.id_class(1)
62+
63+
# Test against all other types
64+
for other_type in ID_TYPES:
65+
if other_type != type_info:
66+
with pytest.raises(TypeError):
67+
_ = type_info.id_class(1) < other_type.id_class(2)
68+
69+
def test_hash(self, type_info: IdTypeInfo) -> None:
70+
"""Test hash behavior."""
71+
# Same IDs should hash to same value
72+
assert hash(type_info.id_class(1)) == hash(type_info.id_class(1))
73+
# Different IDs should hash to different values
74+
assert hash(type_info.id_class(1)) != hash(type_info.id_class(2))
75+
76+
# Test against all other types
77+
for other_type in ID_TYPES:
78+
if other_type != type_info:
79+
# Same ID but different types should hash to different values
80+
assert hash(type_info.id_class(1)) != hash(other_type.id_class(1))
81+
82+
def test_str_and_repr(self, type_info: IdTypeInfo) -> None:
83+
"""Test string representations."""
84+
id_obj = type_info.id_class(42)
85+
assert str(id_obj) == f"{type_info.str_prefix}42"
86+
assert repr(id_obj) == f"{type_info.id_class.__name__}(42)"
87+
88+
def test_invalid_creation(self, type_info: IdTypeInfo) -> None:
89+
"""Test that creating an ID with a non-integer raises TypeError."""
90+
with pytest.raises(TypeError):
91+
type_info.id_class("not-an-int")

0 commit comments

Comments
 (0)